diff --git a/.claude/skills/openapi-to-skill/SKILL.md b/.claude/skills/openapi-to-skill/SKILL.md new file mode 100644 index 00000000..8c2d9c6b --- /dev/null +++ b/.claude/skills/openapi-to-skill/SKILL.md @@ -0,0 +1,20 @@ +--- +name: openapi-to-skill +description: >- + Turn an OpenAPI/Swagger spec (JSON or YAML, file or URL) into an installable + agent skill for consuming that API. Use when the user invokes + /openapi-to-skill, points at an openapi.json or swagger.yaml, or asks to + "build a skill for this API" or "make these API docs agent-ready". +metadata: + internal: true +--- + +This is a local pointer so the skill is invocable inside this repo. The +canonical skill (kept installable for external consumers via +`npx skills add erp-mafia/accounted --skill openapi-to-skill`) lives at: + +**`skills/openapi-to-skill/SKILL.md`** (repo root) + +Read that file and follow it. Its bundled tool is at +`skills/openapi-to-skill/scripts/openapi-inventory.mjs` and the output +template at `skills/openapi-to-skill/references/output-template.md`. diff --git a/.github/workflows/core-build.yml b/.github/workflows/core-build.yml index eb0dacf0..70978ec3 100644 --- a/.github/workflows/core-build.yml +++ b/.github/workflows/core-build.yml @@ -44,6 +44,13 @@ jobs: # so drift here means filings tagged against a stale concept set. run: npm run taxonomy:check + - name: Verify the accounted-api agent skill is in sync with the registry + # Fails if a v1 endpoint / registry schema / overlay changed without + # regenerating skills/accounted-api (npm run apiskill:generate). The + # skill is the installable API reference for external coding agents; + # drift here means agents integrate against stale endpoint contracts. + run: npm run apiskill:check + - name: Lint ratchet (no new ESLint errors) # `npm run lint` was never wired into CI, so ~60 legacy errors # accumulated. This ratchet (sibling of check:guards) fails only when diff --git a/DECISIONS.md b/DECISIONS.md index f5d118fc..759940ba 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -862,3 +862,4 @@ One line per decision: `[YYYY-MM-DD] : `. Appended by agents and [2026-08-10] Tax table percent rows (>80 000 kr/month) drop ore via Math.floor: whole-krona rule (oretal bortfaller) per SFF 2011:1261 22 kap. 1 § as applied by Skatteverket's tabellavdrag guidance (the statute governs stated amounts; Skatteverket's tables and guidance apply the same truncation to computed skatteavdrag); an API response missing either table section (30B or 30%) is treated as API failure so the bundled fallback serves complete data instead of clamping. Incomplete bracket data (gaps, failed pagination pages, malformed kolumn values) fails loudly rather than withholding 0. [2026-08-10] Staging DB reconcile (metjnjrhvujscngnpzdv): the tracker had skipped everything from 20260721101500 to 2026-08-10 (105 local-only versions) while 25 rows existed only remotely. Renamed 10 remote rows to their repo versions (same name, MCP apply-time version drift: sandbox-cleanup consolidation, shopify, tax-depreciation, JEL index), deleted 7 superseded sandbox-iteration rows with no local file, and left 8 rows from unmerged branches (white-label brands/teams, vacation columns, agent-atom product tier) untouched since their content is deliberately live for the byra rigs. Older seed_agent_atom_bodies files register version-only: each seed is a full idempotent upsert with a version guard, so only the newest seed's content needs to run. [2026-08-11] suggest-booking derives the proposed kontering on demand rather than storing it on the inbox row or computing it in the receipt hunt: a stored proposal goes stale against a corrected amount, a re-matched transaction or a template the company taught itself since, and the nightly hunt is already at its 300 s ceiling for a proposal most rows never open. It composes the existing evaluateMappingRules -> buildTransactionEntryLines chain rather than a second one, so the shown lines cannot drift from the posted lines. It withholds the proposal entirely on a foreign-currency row that matched via the mapping_rules branch: mapping-engine.ts buildResult computes VAT from the transaction's own currency while every other line is SEK (its own NOTE tracks this), which understates ingaende moms by the exchange rate and still balances, so nothing downstream catches it. Guarding the surface was chosen over fixing buildResult in this PR because that changes posted VAT amounts across every caller; the counterparty and static-template paths already convert correctly and are not withheld. +[2026-08-11] Agent skills for the API ship as generated artifacts, not authored docs: skills/accounted-api/ is CI-checked output (apiskill:check) of scripts/api-skill/generate.ts, rendered from the same lib/api/v1 registry that serves the API and its OpenAPI spec, so the installable skill cannot drift from the server. Edit scripts/api-skill/overlays/ or the registry, never the output. The per-operation renderer is the portable tool inside skills/openapi-to-skill/ (the generic spec-to-skill generator): our own skill dogfoods it. Skills live in top-level skills/ because that is the directory `npx skills add erp-mafia/accounted` scans; the OpenAPI generator was extended to emit requestBody + path parameters (previously response-only) rather than teaching the skill generator to read Zod directly, so every spec consumer benefits, not just the skill. diff --git a/app/llms.txt/route.ts b/app/llms.txt/route.ts index a4b25b6a..1387ab36 100644 --- a/app/llms.txt/route.ts +++ b/app/llms.txt/route.ts @@ -50,6 +50,7 @@ declarations, ingest SIE files, and subscribe to webhooks for state changes. ## Resources +- Agent skill (full API surface for coding agents): \`npx skills add erp-mafia/accounted --skill accounted-api\` - OpenAPI 3.1 spec: ${base}/api/v1/openapi.json - Skills catalogue: ${base}/.well-known/skills/index.json - Health check: ${base}/api/v1/health diff --git a/lib/api/v1/__tests__/openapi-request-schemas.test.ts b/lib/api/v1/__tests__/openapi-request-schemas.test.ts new file mode 100644 index 00000000..3dcc5cf2 --- /dev/null +++ b/lib/api/v1/__tests__/openapi-request-schemas.test.ts @@ -0,0 +1,77 @@ +/** + * The OpenAPI generator emits machine-readable request contracts: + * path `parameters` derived from the route pattern and `requestBody` from + * the registered Zod body schema. Historically the spec only carried + * response schemas + prose, which forced spec consumers (agent skills, + * client generators) to guess request shapes. + */ + +import { describe, expect, it } from 'vitest' +import { generateOpenApiSpec } from '../registry' +// Side-effect import: populates the ENDPOINTS registry from every route file. +import '../load-routes' + +type OperationObject = { + parameters?: Array<{ name: string; in: string; required: boolean; schema: unknown }> + requestBody?: { + required: boolean + content: Record; required?: string[] } }> + } +} + +const spec = generateOpenApiSpec('https://unit.test') + +function operation(path: string, method: string): OperationObject { + const op = (spec.paths[path] as Record | undefined)?.[method] + expect(op, `${method.toUpperCase()} ${path} missing from spec`).toBeDefined() + return op as OperationObject +} + +describe('generateOpenApiSpec request contracts', () => { + it('declares a path parameter for every templated segment, on every operation', () => { + for (const [path, item] of Object.entries(spec.paths)) { + const templated = [...path.matchAll(/\{([^}]+)\}/g)].map((m) => m[1]) + for (const [method, op] of Object.entries(item as Record)) { + const declared = (op.parameters ?? []).filter((p) => p.in === 'path').map((p) => p.name) + expect(declared.sort(), `${method.toUpperCase()} ${path}`).toEqual([...templated].sort()) + } + } + }) + + it('emits requestBody from the registered Zod body schema', () => { + const op = operation('/api/v1/companies/{companyId}/invoices', 'post') + expect(op.requestBody?.required).toBe(true) + const schema = op.requestBody?.content['application/json']?.schema + expect(schema?.properties).toHaveProperty('customer_id') + expect(schema?.properties).toHaveProperty('items') + expect(schema?.required).toContain('customer_id') + }) + + it('renders multipart z.unknown() parts as binary file parts', () => { + const op = operation('/api/v1/companies/{companyId}/documents', 'post') + const schema = op.requestBody?.content['multipart/form-data']?.schema + expect(schema?.properties?.file).toEqual({ type: 'string', format: 'binary' }) + // Non-file parts keep their real schema. + expect(schema?.properties?.journal_entry_id).toMatchObject({ type: 'string' }) + }) + + it('omits requestBody on endpoints without a registered body', () => { + expect(operation('/api/v1/companies', 'get').requestBody).toBeUndefined() + }) + + it('converts wrapped Zod constructs (.default, z.record) instead of degrading to {}', () => { + const op = operation('/api/v1/companies/{companyId}/journal-entries', 'post') + const schema = op.requestBody?.content['application/json']?.schema as { + properties: Record } }> + required?: string[] + } + // JournalEntrySourceTypeSchema.default('manual'): enum survives, field not required. + expect(schema.properties.source_type.enum).toContain('manual') + expect(schema.required).not.toContain('source_type') + const line = schema.properties.lines.items!.properties + // nonNegativeAmount.default(0) is a number, and z.record renders as an + // object with additionalProperties rather than an empty schema. + expect(line.debit_amount.type).toBe('number') + expect(line.dimensions.additionalProperties).toBeTruthy() + }) +}) diff --git a/lib/api/v1/registry.ts b/lib/api/v1/registry.ts index 92ec07a0..daf53608 100644 --- a/lib/api/v1/registry.ts +++ b/lib/api/v1/registry.ts @@ -275,10 +275,34 @@ function zodToJsonSchema(schema: ZodTypeAny): JsonSchema { case 'optional': case 'ZodOptional': case 'nullable': - case 'ZodNullable': { + case 'ZodNullable': + // A `.default()` field accepts the inner type on input; the object case + // below additionally treats it as not-required. + case 'default': + case 'ZodDefault': { const inner = (def as { innerType: ZodTypeAny }).innerType return zodToJsonSchema(inner) } + case 'record': + case 'ZodRecord': { + const valueType = (def as { valueType?: ZodTypeAny }).valueType + return { + type: 'object', + additionalProperties: valueType ? zodToJsonSchema(valueType) : true, + } + } + // `.transform()` / `.pipe()` wrappers: describe the INPUT side, which is + // what an API caller must send. + case 'pipe': + case 'ZodPipeline': { + const input = (def as { in?: ZodTypeAny }).in + return input ? zodToJsonSchema(input) : {} + } + case 'effects': + case 'ZodEffects': { + const inner = (def as { schema?: ZodTypeAny }).schema + return inner ? zodToJsonSchema(inner) : {} + } case 'object': case 'ZodObject': { const shape = (schema as unknown as { shape: Record }).shape @@ -288,7 +312,9 @@ function zodToJsonSchema(schema: ZodTypeAny): JsonSchema { properties[key] = zodToJsonSchema(value) const valueDef = (value as unknown as { _def: { typeName?: string; type?: string } })._def const valueDisc = valueDef.type ?? valueDef.typeName ?? '' - if (valueDisc !== 'optional' && valueDisc !== 'ZodOptional') { + // Optional and defaulted fields may be omitted by the caller. + const mayOmit = ['optional', 'ZodOptional', 'default', 'ZodDefault'].includes(valueDisc) + if (!mayOmit) { required.push(key) } } @@ -362,6 +388,44 @@ export function generateOpenApiSpec(serverUrl: string): OpenApiSpec { ? { '204': { description: 'No Content' } } : { '200': { description: 'Success', content: successContent } } + // Path parameters, derived from the `:param` pattern itself so every + // templated segment is declared even though routes don't register a + // params schema. All v1 path params are string ids. + const parameters = [...def.path.matchAll(/:([^/]+)/g)].map(([, name]) => ({ + name, + in: 'path', + required: true, + schema: { type: 'string' }, + })) + + // Request body from the registered Zod schema. In multipart bodies a + // part registered as `z.unknown()` is by convention the binary file part + // (see documents.upload); the converter turns it into an empty schema, + // which is rewritten here to `format: binary` so client generators + // produce correct multipart uploads. + let requestBody: Record | undefined + if (def.request?.body) { + const contentType = def.request.contentType ?? 'application/json' + let bodySchema = zodToJsonSchema(def.request.body) + if (contentType === 'multipart/form-data' && bodySchema.properties) { + bodySchema = { + ...bodySchema, + properties: Object.fromEntries( + Object.entries(bodySchema.properties).map(([key, prop]) => [ + key, + Object.keys(prop).length === 0 + ? { type: 'string', format: 'binary' } + : prop, + ]), + ), + } + } + requestBody = { + required: true, + content: { [contentType]: { schema: bodySchema } }, + } + } + const operationDef: Record = { operationId: def.operation, summary: def.summary, @@ -377,6 +441,8 @@ export function generateOpenApiSpec(serverUrl: string): OpenApiSpec { 'x-reversible': def.reversible, 'x-dry-run-supported': def.dryRunSupported, ...(def.scope ? { 'x-required-scope': def.scope } : {}), + ...(parameters.length > 0 ? { parameters } : {}), + ...(requestBody ? { requestBody } : {}), responses: { ...successResponse, '400': { description: 'Validation error', $ref: '#/components/responses/Error' }, diff --git a/lib/docs/content/landing.ts b/lib/docs/content/landing.ts index 40f6aeca..0fb2b777 100644 --- a/lib/docs/content/landing.ts +++ b/lib/docs/content/landing.ts @@ -20,7 +20,7 @@ curl https://app.gnubok.se/api/v1/companies \\ Create keys in the accounted dashboard at **/settings/api**. Two key prefixes are available: - \`gnubok_sk_live_*\`: hits real customer data. Use in production. -- \`gnubok_sk_test_*\`: bound to deterministic sandbox companies. Safe for evals, demos, and agent learning. Same surface, different blast radius. +- \`gnubok_sk_test_*\`: reads real company data, but every write is forced into dry-run and nothing persists (responses carry \`X-Gnubok-Mode: test\`). Safe for evals, demos, and agent learning. Same surface, different blast radius. Each key carries one or more **scopes** (\`invoices:read\`, \`invoices:write\`, \`payroll:write\`, \`webhooks:manage\`, ...) that gate which endpoints it can call. Scopes are listed on every endpoint reference page. @@ -102,6 +102,7 @@ Every error code is documented in the [error reference](/docs/api/errors). - **[Changelog](/docs/api/changelog)**: what shipped when. For LLM-based agents: +- **Agent skill for integrators**: \`npx skills add erp-mafia/accounted --skill accounted-api\` teaches your coding agent (Claude Code, Cursor, Codex, ...) this entire API: auth, conventions, and every endpoint with request/response schemas. Generated from the same registry that serves this spec, so it cannot drift. - **[\`/llms.txt\`](/llms.txt)**: concise agent-discovery index. - **[\`/llms-full.txt\`](/llms-full.txt)**: full docs concatenated for ingestion. - **[\`/api/v1/openapi.json\`](/api/v1/openapi.json)**: machine-readable OpenAPI 3.1 spec. diff --git a/package.json b/package.json index 16db66b9..fd415f99 100644 --- a/package.json +++ b/package.json @@ -7,6 +7,8 @@ "setup:extensions": "npx tsx scripts/generate-extension-registry.ts", "skills:generate": "npx tsx scripts/generate-skill-bodies.ts", "skills:check": "npx tsx scripts/generate-skill-bodies.ts --check", + "apiskill:generate": "npx tsx --conditions react-server scripts/api-skill/generate.ts", + "apiskill:check": "npx tsx --conditions react-server scripts/api-skill/generate.ts --check", "crontabs:generate": "npx tsx scripts/generate-crontabs.ts", "taxonomy:generate": "npx tsx scripts/generate-taxonomy-registry.ts", "taxonomy:check": "npx tsx scripts/generate-taxonomy-registry.ts --check", diff --git a/scripts/api-skill/generate.ts b/scripts/api-skill/generate.ts new file mode 100644 index 00000000..65a01091 --- /dev/null +++ b/scripts/api-skill/generate.ts @@ -0,0 +1,335 @@ +/** + * Deterministic generator for the installable `accounted-api` agent skill + * (skills/accounted-api/), the consumer-side skill that teaches coding + * agents to build against the v1 REST API. + * + * Renders straight from the same endpoint registry that serves the API and + * its OpenAPI spec, so the skill cannot drift from the server. Hand-authored + * knowledge (auth, conventions, domain gotchas) lives in + * scripts/api-skill/overlays/*.md and is stitched in verbatim. + * + * Run with the react-server condition so the `server-only` guards in the + * route import chain resolve (see package.json): + * + * npm run apiskill:generate # write skills/accounted-api/ + * npm run apiskill:check # CI staleness gate (no writes) + * + * The per-operation rendering is done by the portable inventory tool that + * ships inside the sibling `openapi-to-skill` skill: the generic tool has to + * be good enough to build our own skill, or it is not good enough to ship. + */ + +import { readFileSync, writeFileSync, mkdirSync, readdirSync, rmSync, existsSync } from 'node:fs' +import { join, dirname } from 'node:path' +import { fileURLToPath } from 'node:url' + +import '@/lib/api/v1/load-routes' +import { generateOpenApiSpec } from '@/lib/api/v1/registry' +import { API_V1_VERSION } from '@/lib/api/v1/version' + +import { + listOperations, + formatOpLine, + renderOperationMd, + type OperationEntry, +} from '../../skills/openapi-to-skill/scripts/openapi-inventory.mjs' + +const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..', '..') +const OVERLAY_DIR = join(ROOT, 'scripts', 'api-skill', 'overlays') +const OUT_DIR = join(ROOT, 'skills', 'accounted-api') + +/** Base URL shown in examples: the permanent machine host (see lib/api/v1/base-url.ts). */ +const MACHINE_BASE = 'https://app.gnubok.se' + +/** + * Reference-file layout: every derived path group must be claimed by exactly + * one file. A new v1 resource fails apiskill:check until it is mapped here, + * which is the point: grouping is an editorial decision, not a default. + */ +const GROUPS: Array<{ file: string; title: string; members: string[]; blurb: string }> = [ + { + file: 'core.md', + title: 'Core', + members: ['health', 'companies', 'operations', 'settings'], + blurb: + 'Connectivity, company discovery, async-operation polling, and company settings. ' + + 'Every session starts with GET /companies to resolve the companyId that all other URLs need.', + }, + { + file: 'journal-entries.md', + title: 'Journal entries', + members: ['journal-entries', 'voucher-gap-explanations'], + blurb: + 'The ledger itself: journal entries follow draft -> commit -> immutable. There is no ' + + 'edit or delete after commit; undo via reverse (storno) or correct. Voucher numbers are ' + + 'server-assigned and gapless; explain unavoidable gaps via voucher-gap-explanations.', + }, + { + file: 'periods.md', + title: 'Periods and registers', + members: ['fiscal-periods', 'accounts', 'compliance', 'dimensions'], + blurb: + 'Fiscal periods and their lock/close/year-end lifecycle (async operations), the BAS ' + + 'chart of accounts, cost-center/project dimensions, and the compliance pre-flight check.', + }, + { + file: 'invoices.md', + title: 'Invoices (AR)', + members: ['invoices'], + blurb: + 'Accounts receivable invoices: draft -> send -> paid/credited; the F-series number is ' + + 'assigned at send, not create. Supplier bills you receive are a different resource: see ' + + 'suppliers.md. Customer and article registers: customers.md.', + }, + { + file: 'customers.md', + title: 'Customers and articles', + members: ['customers', 'articles'], + blurb: + 'The customer register (bulk-create supported, archive via DELETE) and the read-only ' + + 'article register used for invoice line linkage.', + }, + { + file: 'suppliers.md', + title: 'Suppliers (AP)', + members: ['suppliers', 'supplier-invoices'], + blurb: + 'Accounts payable: supplier register and received supplier invoices ' + + '(register -> approve -> mark-paid, or credit).', + }, + { + file: 'documents.md', + title: 'Documents', + members: ['documents', 'inbox-items'], + blurb: + 'The WORM document archive (7-year legal retention: uploads are permanent) and inbox-item stamping. ' + + 'Link every uploaded receipt/invoice document to its journal entry.', + }, + { + file: 'banking.md', + title: 'Banking', + members: ['transactions', 'reconciliation', 'imports'], + blurb: + 'Bank transactions (ingest, categorize, match against invoices), bank reconciliation runs, ' + + 'and file imports (SIE, bank statements).', + }, + { + file: 'employees.md', + title: 'Employees', + members: ['employees', 'salary'], + blurb: + 'The employee register plus absence (frånvaro), vacation balances and year close, and ' + + 'payroll cutover opening balances. Running payroll itself: salary-runs.md.', + }, + { + file: 'salary-runs.md', + title: 'Salary runs', + members: ['salary-runs'], + blurb: + 'Swedish payroll runs: create -> calculate -> approve -> book/mark-paid -> generate-agi ' + + '(arbetsgivardeklaration), with per-employee payslips and draft-only line edits.', + }, + { + file: 'reports.md', + title: 'Reports', + members: ['reports'], + blurb: + 'Read-only statutory and management reports: trial balance, balance sheet, income statement, ' + + 'general ledger, VAT declaration, AR/AP ledgers, salary journal, and SIE export.', + }, + { + file: 'webhooks.md', + title: 'Webhooks', + members: ['webhooks', 'webhook-deliveries'], + blurb: + 'HMAC-signed event subscriptions with delivery logs, test pings, retries, and secret rotation.', + }, +] + +/** + * The standard error line every operation shares; lifted into the + * conventions overlay once instead of repeated 124 times. Operations with + * additional codes keep their (then non-matching) line. + */ +const STANDARD_ERRORS_LINE = + 'Errors: `400` (Validation error), `401` (Unauthorized), `403` (Insufficient scope), ' + + '`404` (Not found), `429` (Rate limited), `500` (Internal error)' + +const GENERATED_NOTE = + '' + +function overlay(name: string): string { + return readFileSync(join(OVERLAY_DIR, name), 'utf8').trim() +} + +function methodRank(method: string): number { + return ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'].indexOf(method) +} + +function buildFiles(): Map { + const spec = generateOpenApiSpec(MACHINE_BASE) + const ops = listOperations(spec) + + const memberToFile = new Map() + for (const group of GROUPS) { + for (const member of group.members) { + if (memberToFile.has(member)) throw new Error(`group member mapped twice: ${member}`) + memberToFile.set(member, group) + } + } + + const byFile = new Map() + for (const entry of ops) { + const group = memberToFile.get(entry.group) + if (!group) { + throw new Error( + `Unmapped endpoint group "${entry.group}" (${entry.method} ${entry.path}). ` + + 'Add it to a reference file in scripts/api-skill/generate.ts GROUPS.', + ) + } + if (!byFile.has(group.file)) byFile.set(group.file, []) + byFile.get(group.file)!.push(entry) + } + for (const list of byFile.values()) { + list.sort((a, b) => a.path.localeCompare(b.path) || methodRank(a.method) - methodRank(b.method)) + } + + const files = new Map() + + // Reference files. + for (const group of GROUPS) { + const members = byFile.get(group.file) ?? [] + if (members.length === 0) throw new Error(`reference file with no operations: ${group.file}`) + const blocks = members.map((entry) => + renderOperationMd(spec, entry).replace(STANDARD_ERRORS_LINE, '').trimEnd(), + ) + files.set( + join('references', group.file), + [ + GENERATED_NOTE, + '', + `# ${group.title} endpoints`, + '', + group.blurb, + '', + 'Conventions (auth, envelope, pagination, dry-run, idempotency, standard errors)', + 'are in SKILL.md and are not repeated per endpoint.', + '', + blocks.join('\n\n---\n\n'), + '', + ].join('\n'), + ) + } + + // SKILL.md: frontmatter + overlays + endpoint index. + const indexSections = GROUPS.map((group) => { + const members = byFile.get(group.file)! + const lines = members.map((entry) => + formatOpLine(entry).replace(` ${entry.path} `, ` ${entry.path.replace('/api/v1', '')} `), + ) + return [ + `### ${group.title} (${members.length})`, + '', + `Full detail: [references/${group.file}](references/${group.file})`, + '', + '```text', + ...lines, + '```', + ].join('\n') + }) + + const frontmatter = [ + '---', + 'name: accounted-api', + 'description: >-', + ' Consume the Accounted REST API (Swedish double-entry bookkeeping SaaS,', + ` ${MACHINE_BASE}/api/v1). Use when building an integration, app, backend`, + ' job, or agent tool layer against Accounted: invoices, customers,', + ' suppliers, supplier invoices, journal entries (bokföring), bank', + ' transactions and reconciliation, payroll (lön), VAT/moms and financial', + ' reports, SIE import/export, documents, webhooks. Covers auth with', + ` gnubok_sk_ API keys, conventions (dry-run, idempotency, cursor`, + ` pagination, scopes), and all ${ops.length} endpoints.`, + '---', + ].join('\n') + + files.set( + 'SKILL.md', + [ + frontmatter, + '', + GENERATED_NOTE, + '', + overlay('intro.md'), + '', + overlay('quickstart.md'), + '', + overlay('conventions.md'), + '', + '## Endpoint index', + '', + `API version \`${API_V1_VERSION}\`, ${ops.length} operations. Paths are shown without`, + `their \`/api/v1\` prefix (full base URL: \`${MACHINE_BASE}/api/v1\`).`, + '', + indexSections.join('\n\n'), + '', + overlay('gotchas.md'), + '', + overlay('verification.md'), + '', + ].join('\n'), + ) + + return files +} + +function listMarkdownFiles(dir: string, prefix = ''): string[] { + if (!existsSync(dir)) return [] + const out: string[] = [] + for (const dirent of readdirSync(dir, { withFileTypes: true })) { + const rel = prefix ? join(prefix, dirent.name) : dirent.name + if (dirent.isDirectory()) out.push(...listMarkdownFiles(join(dir, dirent.name), rel)) + else if (dirent.name.endsWith('.md')) out.push(rel) + } + return out +} + +function main() { + const check = process.argv.includes('--check') + const files = buildFiles() + + const existing = listMarkdownFiles(OUT_DIR) + const orphans = existing.filter((rel) => !files.has(rel)) + + if (check) { + const stale: string[] = [] + for (const [rel, content] of files) { + const abs = join(OUT_DIR, rel) + if (!existsSync(abs)) stale.push(`${rel} (missing)`) + else if (readFileSync(abs, 'utf8') !== content) stale.push(`${rel} (outdated)`) + } + stale.push(...orphans.map((rel) => `${rel} (orphaned)`)) + if (stale.length > 0) { + console.error('skills/accounted-api is stale relative to the endpoint registry/overlays:') + for (const entry of stale) console.error(` - ${entry}`) + console.error('Run `npm run apiskill:generate` and commit the result.') + process.exit(1) + } + console.log(`skills/accounted-api is up to date (${files.size} files).`) + return + } + + for (const [rel, content] of files) { + const abs = join(OUT_DIR, rel) + mkdirSync(dirname(abs), { recursive: true }) + writeFileSync(abs, content) + } + for (const rel of orphans) { + rmSync(join(OUT_DIR, rel)) + console.log(`removed orphan: ${rel}`) + } + console.log(`wrote ${files.size} files to skills/accounted-api/`) +} + +main() diff --git a/scripts/api-skill/overlays/conventions.md b/scripts/api-skill/overlays/conventions.md new file mode 100644 index 00000000..74358b88 --- /dev/null +++ b/scripts/api-skill/overlays/conventions.md @@ -0,0 +1,77 @@ +## Conventions + +These rules hold across the whole surface; endpoint entries below do not +repeat them. + +**Response envelope.** Success: `{ "data": ..., "meta": { "request_id", +"api_version", "next_cursor"?, "audit"?, "partial_expansions"? } }`. Errors +replace `data` with `error` (no `meta`; `request_id` moves inside `error`). + +**Errors.** Stable machine codes with agent-oriented remediation: + +```json +{ "error": { "code": "PERIOD_LOCKED", "message": "Svenska", "message_en": "English", + "details": {}, "recovery_hint": "next step", "docs_url": "...", + "valid_alternatives": {}, "request_id": "req_..." } } +``` + +React to `code`, read `message_en` and `recovery_hint`, follow +`valid_alternatives` when present (e.g. `next_open_period`). Standard codes on +every endpoint: `400` validation, `401` bad key, `403` missing scope, `404`, +`429` rate limited (honor `Retry-After`), `500`. Full catalogue: +https://app.gnubok.se/docs/api/errors. Only endpoint-specific codes are +mentioned per endpoint below. + +**Cursor pagination.** List endpoints take `?cursor=` and return +`meta.next_cursor`; loop until it is absent/null. A stale or tampered cursor +is NOT an error: the first page is returned again, so terminate on +`next_cursor`, never on "page looks familiar". + +**Dry-run on every write that supports it** (`dry-run` badge in the index). +Send `?dry_run=true` (or `X-Dry-Run: true`): the response is always `200` with +`data.dry_run: true` plus a preview (would-be record, journal lines, voucher +number) and the `X-Dry-Run: true` response header; nothing is committed. +Commit by re-issuing without the flag and with the SAME `Idempotency-Key` +(the dry run is not cached against the key). Preview first on any financial +write; it is free. + +**Idempotency-Key.** Send a fresh UUID header on every POST/PATCH/DELETE; +several endpoints reject writes without one (`400`). Replaying the same +key+body returns the original response with the `Idempotent-Replayed: true` +header; the same key with a different body returns `409 IDEMPOTENCY_KEY_REUSE` +(24h window). Safe retry loop: keep the key, keep the body. + +**Test keys are simulation-only.** With a `gnubok_sk_test_*` key, reads +return real company data (responses carry `X-Gnubok-Mode: test`) and every +write is forced into dry-run; writes that cannot be simulated return +`403 TEST_KEY_WRITE_BLOCKED`. Nothing a test key does ever persists: it is +`?dry_run=true` baked into the credential. Full end-to-end write tests +therefore need a live key against a company you own. + +**Atomic writes.** A mutation either commits fully or errors with no side +effects. There is no partial state to clean up after an error response +(`bulk-create` endpoints that do partial success say so explicitly). + +**Audit inline.** Successful financial writes include `meta.audit` (voucher +number, audit-trail URL, immutability timestamp). No follow-up read needed to +confirm what was booked. + +**Expansion.** Some list/detail endpoints take `?expand=a,b` (documented per +endpoint). If an expansion fails the response still succeeds and names the +failed parts in `meta.partial_expansions`; check it before trusting expanded +fields. + +**Async operations.** Long-running actions (fiscal-period lock/close/year-end, +imports) return `202` with an operation id; poll `GET /api/v1/operations/{id}` +until `status` is `succeeded`/`failed`. The response shape is identical +whether the work ran inline or queued. + +**Versioning.** Dated versions (current: see `meta.api_version`). Pin with the +`Gnubok-Version` request header; responses echo it. Additive changes ship +without a version bump; see https://app.gnubok.se/docs/api/versioning. + +**Index badges.** Every operation line below carries machine-readable +annotations from the spec: `scope:` (required key scope), `risk:` (low/medium/ +high; confirm with a human before unprompted high-risk calls), `idempotent` +(safe to retry), `dry-run` (previewable), `reversible` (a single follow-up +call can undo it, e.g. invoice credit). diff --git a/scripts/api-skill/overlays/gotchas.md b/scripts/api-skill/overlays/gotchas.md new file mode 100644 index 00000000..9c12265e --- /dev/null +++ b/scripts/api-skill/overlays/gotchas.md @@ -0,0 +1,38 @@ +## Gotchas (Swedish accounting domain) + +Rules a generic REST integration will violate unless told: + +- **Account numbers are strings, not numbers.** BAS accounts (`"1930"`, + `"3001"`) are identifiers; send them as JSON strings. Arithmetic on them, + zero-stripping, or number coercion corrupts postings. +- **Posted journal entries are immutable by law** (Bokföringslagen). There is + no PATCH or DELETE on a committed entry, ever. Undo with + `POST .../journal-entries/{id}/reverse` (storno), fix with + `POST .../journal-entries/{id}/correct`. Design flows around + reverse-and-repost, not edit-in-place. +- **Voucher numbers are gapless and server-assigned.** Never assume or + pre-allocate one; read it from `meta.audit.voucher_number` after commit. A + legally required gap explanation goes through + `POST .../voucher-gap-explanations`. +- **Every entry balances.** `sum(debit) === sum(credit)` to the öre, amounts + are decimal SEK numbers (max 2 decimals). Do rounding with + round-half-away-from-zero on öre; never float-accumulate line totals + client-side and "fix" the difference on a random line. +- **Period locks are a feature, not an error to retry.** Writes into a + locked/closed period return `PERIOD_LOCKED` (with `valid_alternatives` + pointing at open periods). Retrying the same request cannot succeed; either + target an open period or surface the lock to the user. +- **Drafts vs posted.** Invoices are created as drafts with + `invoice_number: null`; the F-series number is assigned atomically on send. + Journal entries follow draft -> commit. Nothing financial exists in the + ledger until the commit/send action. +- **Two invoice worlds.** `invoices` = accounts receivable (you bill + customers); `supplier-invoices` = accounts payable (you receive bills). + They are different resources with different lifecycles. +- **Swedish user-facing text.** `error.message` is Swedish by design; show it + to Swedish end users, and use `message_en` for your own logs/logic. Domain + terms in responses (moms, verifikat, kostnadsställe) are not translatable + labels but legal concepts. +- **Compliance pre-flight.** Before building your own validation for Swedish + rules, call `GET .../compliance/check`: it runs the server's own rule set + (VAT plausibility, sequence integrity, period status) and returns findings. diff --git a/scripts/api-skill/overlays/intro.md b/scripts/api-skill/overlays/intro.md new file mode 100644 index 00000000..dba99b86 --- /dev/null +++ b/scripts/api-skill/overlays/intro.md @@ -0,0 +1,17 @@ +# Accounted API integration + +Accounted is Swedish double-entry bookkeeping (bokföring) as a service: BAS +chart of accounts, verifikationer with legally immutable audit trails, VAT +(moms), payroll (lön), invoicing, bank reconciliation, and statutory reports, +exposed as a REST API designed for agents and integrations first. + +**This skill is for building software against the REST API** (an app, a +backend job, an agent tool layer). If the goal is to *operate* a ledger +conversationally (book receipts, run month close), use the Accounted MCP +connector and its workflow skills instead: install the `accounted` plugin or +see https://app.gnubok.se/docs/api/connect-claude. + +If you have used Stripe's API the shape will feel familiar: bearer keys, dated +versions, idempotency keys, webhook signatures, cursor pagination. The domain +rules are Swedish accounting law; the Gotchas section below stops the classic +violations before you ship them. diff --git a/scripts/api-skill/overlays/quickstart.md b/scripts/api-skill/overlays/quickstart.md new file mode 100644 index 00000000..3c1b06c9 --- /dev/null +++ b/scripts/api-skill/overlays/quickstart.md @@ -0,0 +1,28 @@ +## Auth and base URL + +Every request sends a bearer key: + +```bash +curl https://app.gnubok.se/api/v1/companies \ + -H "Authorization: Bearer gnubok_sk_live_..." +``` + +- Base URL: `https://app.gnubok.se/api/v1` (legacy machine host, permanent). + `https://app.accounted.se/api/v1` serves the identical API. +- Keys are created in the Accounted dashboard under **Settings -> API** + (`/settings/api`). Two prefixes: + - `gnubok_sk_live_*` commits real writes. + - `gnubok_sk_test_*` reads real company data but forces every write into + dry-run (responses carry `X-Gnubok-Mode: test`). Develop and run evals + with a test key; switch to live last. +- Each key carries **scopes** (`invoices:read`, `invoices:write`, + `payroll:write`, `webhooks:manage`, ...). Every endpoint in the index below + is annotated with its required scope; a missing scope returns `403`. +- Rate limit: 100 requests/minute per key. On `429`, honor `Retry-After`. +- URLs carry the company id explicitly + (`/api/v1/companies/{companyId}/invoices`). A key can act on any company its + user is a member of; start every session with `GET /api/v1/companies` to + discover ids. There is no implicit "current company". + +First calls, in order: `GET /api/v1/health` (no auth, connectivity), then +`GET /api/v1/companies` (auth works, discover `companyId`). diff --git a/scripts/api-skill/overlays/verification.md b/scripts/api-skill/overlays/verification.md new file mode 100644 index 00000000..f071f38c --- /dev/null +++ b/scripts/api-skill/overlays/verification.md @@ -0,0 +1,18 @@ +## Verification + +This skill is generated (`npm run apiskill:generate` in the Accounted repo) +from the same endpoint registry that serves the live API, its OpenAPI spec +(`https://app.gnubok.se/api/v1/openapi.json`), and its runtime request +validators, so schema drift between this text and the server cannot occur for +a matching `api_version`. CI regenerates and diffs it on every change. + +Before first use in a new environment, smoke-test: + +```bash +curl -s https://app.gnubok.se/api/v1/health +curl -s https://app.gnubok.se/api/v1/companies -H "Authorization: Bearer $ACCOUNTED_API_KEY" +``` + +If `meta.api_version` in responses is newer than the version in this skill's +index header, refetch the skill (or read the changelog at +https://app.gnubok.se/docs/api/changelog) before relying on endpoint details. diff --git a/skills/accounted-api/SKILL.md b/skills/accounted-api/SKILL.md new file mode 100644 index 00000000..c06563f3 --- /dev/null +++ b/skills/accounted-api/SKILL.md @@ -0,0 +1,410 @@ +--- +name: accounted-api +description: >- + Consume the Accounted REST API (Swedish double-entry bookkeeping SaaS, + https://app.gnubok.se/api/v1). Use when building an integration, app, backend + job, or agent tool layer against Accounted: invoices, customers, + suppliers, supplier invoices, journal entries (bokföring), bank + transactions and reconciliation, payroll (lön), VAT/moms and financial + reports, SIE import/export, documents, webhooks. Covers auth with + gnubok_sk_ API keys, conventions (dry-run, idempotency, cursor + pagination, scopes), and all 124 endpoints. +--- + + + +# Accounted API integration + +Accounted is Swedish double-entry bookkeeping (bokföring) as a service: BAS +chart of accounts, verifikationer with legally immutable audit trails, VAT +(moms), payroll (lön), invoicing, bank reconciliation, and statutory reports, +exposed as a REST API designed for agents and integrations first. + +**This skill is for building software against the REST API** (an app, a +backend job, an agent tool layer). If the goal is to *operate* a ledger +conversationally (book receipts, run month close), use the Accounted MCP +connector and its workflow skills instead: install the `accounted` plugin or +see https://app.gnubok.se/docs/api/connect-claude. + +If you have used Stripe's API the shape will feel familiar: bearer keys, dated +versions, idempotency keys, webhook signatures, cursor pagination. The domain +rules are Swedish accounting law; the Gotchas section below stops the classic +violations before you ship them. + +## Auth and base URL + +Every request sends a bearer key: + +```bash +curl https://app.gnubok.se/api/v1/companies \ + -H "Authorization: Bearer gnubok_sk_live_..." +``` + +- Base URL: `https://app.gnubok.se/api/v1` (legacy machine host, permanent). + `https://app.accounted.se/api/v1` serves the identical API. +- Keys are created in the Accounted dashboard under **Settings -> API** + (`/settings/api`). Two prefixes: + - `gnubok_sk_live_*` commits real writes. + - `gnubok_sk_test_*` reads real company data but forces every write into + dry-run (responses carry `X-Gnubok-Mode: test`). Develop and run evals + with a test key; switch to live last. +- Each key carries **scopes** (`invoices:read`, `invoices:write`, + `payroll:write`, `webhooks:manage`, ...). Every endpoint in the index below + is annotated with its required scope; a missing scope returns `403`. +- Rate limit: 100 requests/minute per key. On `429`, honor `Retry-After`. +- URLs carry the company id explicitly + (`/api/v1/companies/{companyId}/invoices`). A key can act on any company its + user is a member of; start every session with `GET /api/v1/companies` to + discover ids. There is no implicit "current company". + +First calls, in order: `GET /api/v1/health` (no auth, connectivity), then +`GET /api/v1/companies` (auth works, discover `companyId`). + +## Conventions + +These rules hold across the whole surface; endpoint entries below do not +repeat them. + +**Response envelope.** Success: `{ "data": ..., "meta": { "request_id", +"api_version", "next_cursor"?, "audit"?, "partial_expansions"? } }`. Errors +replace `data` with `error` (no `meta`; `request_id` moves inside `error`). + +**Errors.** Stable machine codes with agent-oriented remediation: + +```json +{ "error": { "code": "PERIOD_LOCKED", "message": "Svenska", "message_en": "English", + "details": {}, "recovery_hint": "next step", "docs_url": "...", + "valid_alternatives": {}, "request_id": "req_..." } } +``` + +React to `code`, read `message_en` and `recovery_hint`, follow +`valid_alternatives` when present (e.g. `next_open_period`). Standard codes on +every endpoint: `400` validation, `401` bad key, `403` missing scope, `404`, +`429` rate limited (honor `Retry-After`), `500`. Full catalogue: +https://app.gnubok.se/docs/api/errors. Only endpoint-specific codes are +mentioned per endpoint below. + +**Cursor pagination.** List endpoints take `?cursor=` and return +`meta.next_cursor`; loop until it is absent/null. A stale or tampered cursor +is NOT an error: the first page is returned again, so terminate on +`next_cursor`, never on "page looks familiar". + +**Dry-run on every write that supports it** (`dry-run` badge in the index). +Send `?dry_run=true` (or `X-Dry-Run: true`): the response is always `200` with +`data.dry_run: true` plus a preview (would-be record, journal lines, voucher +number) and the `X-Dry-Run: true` response header; nothing is committed. +Commit by re-issuing without the flag and with the SAME `Idempotency-Key` +(the dry run is not cached against the key). Preview first on any financial +write; it is free. + +**Idempotency-Key.** Send a fresh UUID header on every POST/PATCH/DELETE; +several endpoints reject writes without one (`400`). Replaying the same +key+body returns the original response with the `Idempotent-Replayed: true` +header; the same key with a different body returns `409 IDEMPOTENCY_KEY_REUSE` +(24h window). Safe retry loop: keep the key, keep the body. + +**Test keys are simulation-only.** With a `gnubok_sk_test_*` key, reads +return real company data (responses carry `X-Gnubok-Mode: test`) and every +write is forced into dry-run; writes that cannot be simulated return +`403 TEST_KEY_WRITE_BLOCKED`. Nothing a test key does ever persists: it is +`?dry_run=true` baked into the credential. Full end-to-end write tests +therefore need a live key against a company you own. + +**Atomic writes.** A mutation either commits fully or errors with no side +effects. There is no partial state to clean up after an error response +(`bulk-create` endpoints that do partial success say so explicitly). + +**Audit inline.** Successful financial writes include `meta.audit` (voucher +number, audit-trail URL, immutability timestamp). No follow-up read needed to +confirm what was booked. + +**Expansion.** Some list/detail endpoints take `?expand=a,b` (documented per +endpoint). If an expansion fails the response still succeeds and names the +failed parts in `meta.partial_expansions`; check it before trusting expanded +fields. + +**Async operations.** Long-running actions (fiscal-period lock/close/year-end, +imports) return `202` with an operation id; poll `GET /api/v1/operations/{id}` +until `status` is `succeeded`/`failed`. The response shape is identical +whether the work ran inline or queued. + +**Versioning.** Dated versions (current: see `meta.api_version`). Pin with the +`Gnubok-Version` request header; responses echo it. Additive changes ship +without a version bump; see https://app.gnubok.se/docs/api/versioning. + +**Index badges.** Every operation line below carries machine-readable +annotations from the spec: `scope:` (required key scope), `risk:` (low/medium/ +high; confirm with a human before unprompted high-risk calls), `idempotent` +(safe to retry), `dry-run` (previewable), `reversible` (a single follow-up +call can undo it, e.g. invoice credit). + +## Endpoint index + +API version `2026-05-12`, 124 operations. Paths are shown without +their `/api/v1` prefix (full base URL: `https://app.gnubok.se/api/v1`). + +### Core (4) + +Full detail: [references/core.md](references/core.md) + +```text +GET /companies : List companies the API key can access [scope:companies:read risk:low idempotent] +PATCH /companies/{companyId}/settings : Partially update company settings [scope:companies:write risk:medium idempotent dry-run reversible] +GET /health : Health check [risk:low idempotent] +GET /operations/{id} : Poll a long-running operation by id [scope:operations:read risk:low idempotent] +``` + +### Journal entries (8) + +Full detail: [references/journal-entries.md](references/journal-entries.md) + +```text +GET /companies/{companyId}/journal-entries : List journal entries (verifikationer) [scope:reports:read risk:low idempotent] +POST /companies/{companyId}/journal-entries : Create a draft journal entry (verifikation) [scope:bookkeeping:write risk:high idempotent dry-run reversible] +GET /companies/{companyId}/journal-entries/{id} : Retrieve a single verifikation by id [scope:reports:read risk:low idempotent] +POST /companies/{companyId}/journal-entries/{id}/commit : Commit a draft journal entry [scope:bookkeeping:write risk:high idempotent dry-run reversible] +POST /companies/{companyId}/journal-entries/{id}/correct : Correct a posted journal entry (BFL 5:5 storno-then-replace) [scope:bookkeeping:write risk:high idempotent dry-run] +POST /companies/{companyId}/journal-entries/{id}/reverse : Storno a posted journal entry [scope:bookkeeping:write risk:high idempotent dry-run] +POST /companies/{companyId}/journal-entries/batch-create : Create up to 50 draft journal entries (partial-success) [scope:bookkeeping:write risk:high idempotent dry-run reversible] +POST /companies/{companyId}/voucher-gap-explanations : Document a gap in the verifikationsserie (BFL 5 kap 6-7 §§) [scope:bookkeeping:write risk:low idempotent dry-run] +``` + +### Periods and registers (12) + +Full detail: [references/periods.md](references/periods.md) + +```text +GET /companies/{companyId}/accounts : List chart-of-accounts entries (BAS chart) [scope:reports:read risk:low idempotent] +GET /companies/{companyId}/compliance/check : Run a structured compliance pre-flight check [scope:compliance:read risk:low idempotent] +GET /companies/{companyId}/dimensions : List dimensions (kostnadsställe/projekt) with their values [scope:reports:read risk:low idempotent] +POST /companies/{companyId}/dimensions/{id}/values : Create a dimension value (kostnadsställe/projekt code) [scope:bookkeeping:write risk:low idempotent dry-run reversible] +PATCH /companies/{companyId}/dimensions/{id}/values/{valueId} : Update a dimension value (rename, archive, set start/end date) [scope:bookkeeping:write risk:low idempotent dry-run reversible] +DELETE /companies/{companyId}/dimensions/{id}/values/{valueId} : Delete an unreferenced dimension value [scope:bookkeeping:write risk:medium idempotent] +GET /companies/{companyId}/fiscal-periods : List fiscal periods (räkenskapsår) [scope:reports:read risk:low idempotent] +POST /companies/{companyId}/fiscal-periods/{id}/close : Close a fiscal period (IRREVERSIBLE per BFL 5 kap 8 §) [scope:bookkeeping:write risk:high idempotent] +POST /companies/{companyId}/fiscal-periods/{id}/currency-revaluation : Run FX revaluation for the fiscal period [scope:bookkeeping:write risk:high idempotent reversible] +POST /companies/{companyId}/fiscal-periods/{id}/lock : Lock a fiscal period (no new entries can be posted into it) [scope:bookkeeping:write risk:high idempotent reversible] +POST /companies/{companyId}/fiscal-periods/{id}/opening-balances : Generate opening-balance verifikation for the next fiscal period [scope:bookkeeping:write risk:high idempotent reversible] +POST /companies/{companyId}/fiscal-periods/{id}/year-end : Execute year-end closing (currency revaluation + closing entry) [scope:bookkeeping:write risk:high idempotent] +``` + +### Invoices (AR) (10) + +Full detail: [references/invoices.md](references/invoices.md) + +```text +GET /companies/{companyId}/invoices : List invoices for a company [scope:invoices:read risk:low idempotent] +POST /companies/{companyId}/invoices : Create a draft invoice, proforma, or delivery note [scope:invoices:write risk:medium idempotent dry-run reversible] +GET /companies/{companyId}/invoices/{id} : Retrieve a single invoice by id [scope:invoices:read risk:low idempotent] +PATCH /companies/{companyId}/invoices/{id} : Update a draft invoice (metadata fields, optionally replacing line items) [scope:invoices:write risk:low idempotent dry-run reversible] +POST /companies/{companyId}/invoices/{id}/credit : Issue a credit note (kreditfaktura) against an invoice [scope:invoices:write risk:high idempotent dry-run] +POST /companies/{companyId}/invoices/{id}/mark-paid : Record a payment against an invoice [scope:invoices:write risk:medium idempotent dry-run] +POST /companies/{companyId}/invoices/{id}/mark-sent : Transition a draft invoice to sent (without emailing) [scope:invoices:write risk:medium idempotent dry-run] +GET /companies/{companyId}/invoices/{id}/pdf : Download the rendered invoice PDF [scope:invoices:read risk:low idempotent] +POST /companies/{companyId}/invoices/{id}/send : Send a draft invoice to the customer by email [scope:invoices:write risk:high idempotent dry-run] +POST /companies/{companyId}/invoices/bulk-create : Create up to 50 draft invoices in one call (partial-success) [scope:invoices:write risk:medium idempotent dry-run reversible] +``` + +### Customers and articles (7) + +Full detail: [references/customers.md](references/customers.md) + +```text +GET /companies/{companyId}/articles : List the article register (artikelregister) [scope:invoices:read risk:low idempotent] +GET /companies/{companyId}/customers : List customers for a company [scope:customers:read risk:low idempotent] +POST /companies/{companyId}/customers : Create a customer [scope:customers:write risk:low idempotent dry-run reversible] +GET /companies/{companyId}/customers/{id} : Retrieve a single customer by id [scope:customers:read risk:low idempotent] +PATCH /companies/{companyId}/customers/{id} : Partially update a customer [scope:customers:write risk:low idempotent dry-run reversible] +DELETE /companies/{companyId}/customers/{id} : Archive a customer (soft-delete) [scope:customers:write risk:medium idempotent dry-run reversible] +POST /companies/{companyId}/customers/bulk-create : Create up to 50 customers in one call (partial-success) [scope:customers:write risk:low idempotent dry-run reversible] +``` + +### Suppliers (AP) (13) + +Full detail: [references/suppliers.md](references/suppliers.md) + +```text +GET /companies/{companyId}/supplier-invoices : List supplier invoices for a company [scope:suppliers:read risk:low idempotent] +POST /companies/{companyId}/supplier-invoices : Register a new supplier invoice [scope:suppliers:write risk:medium idempotent dry-run reversible] +GET /companies/{companyId}/supplier-invoices/{id} : Retrieve a single supplier invoice by id [scope:suppliers:read risk:low idempotent] +PATCH /companies/{companyId}/supplier-invoices/{id} : Update a registered supplier invoice [scope:suppliers:write risk:low idempotent dry-run reversible] +POST /companies/{companyId}/supplier-invoices/{id}/approve : Approve a registered or overdue supplier invoice [scope:suppliers:write risk:low idempotent dry-run] +POST /companies/{companyId}/supplier-invoices/{id}/credit : Issue a credit note for a supplier invoice [scope:suppliers:write risk:high idempotent dry-run] +POST /companies/{companyId}/supplier-invoices/{id}/mark-paid : Record a payment against a supplier invoice [scope:suppliers:write risk:medium idempotent dry-run] +GET /companies/{companyId}/suppliers : List suppliers for a company [scope:suppliers:read risk:low idempotent] +POST /companies/{companyId}/suppliers : Create a supplier [scope:suppliers:write risk:low idempotent dry-run reversible] +GET /companies/{companyId}/suppliers/{id} : Retrieve a single supplier by id [scope:suppliers:read risk:low idempotent] +PATCH /companies/{companyId}/suppliers/{id} : Partially update a supplier [scope:suppliers:write risk:low idempotent dry-run reversible] +DELETE /companies/{companyId}/suppliers/{id} : Archive a supplier (soft-delete) [scope:suppliers:write risk:medium idempotent dry-run reversible] +POST /companies/{companyId}/suppliers/bulk-create : Create up to 50 suppliers in one call (partial-success) [scope:suppliers:write risk:low idempotent dry-run reversible] +``` + +### Documents (4) + +Full detail: [references/documents.md](references/documents.md) + +```text +POST /companies/{companyId}/documents : Upload a document to the WORM archive [scope:documents:write risk:medium idempotent] +GET /companies/{companyId}/documents/{id}/download : Get a time-limited signed download URL for a document [scope:documents:read risk:low idempotent] +POST /companies/{companyId}/documents/{id}/link : Link a document to a journal entry [scope:documents:write risk:medium idempotent dry-run] +POST /companies/{companyId}/inbox-items/{id}/stamp : Mark an inbox item as consumed by a journal entry [scope:documents:write risk:low idempotent] +``` + +### Banking (12) + +Full detail: [references/banking.md](references/banking.md) + +```text +POST /companies/{companyId}/imports/bank : Import a bank-file (CSV / XML / CAMT053) [scope:transactions:write risk:medium idempotent] +POST /companies/{companyId}/imports/sie : Import a SIE4 file [scope:bookkeeping:write risk:high idempotent] +POST /companies/{companyId}/reconciliation/bank/run : Run the bank-reconciliation matcher [scope:transactions:write risk:medium idempotent dry-run] +GET /companies/{companyId}/reconciliation/bank/status : Bank-reconciliation health snapshot [scope:transactions:read risk:low idempotent] +GET /companies/{companyId}/transactions : List transactions for a company [scope:transactions:read risk:low idempotent] +GET /companies/{companyId}/transactions/{id} : Retrieve a single transaction by id [scope:transactions:read risk:low idempotent] +POST /companies/{companyId}/transactions/{id}/categorize : Categorize a transaction and create the journal entry [scope:transactions:write risk:medium idempotent dry-run reversible] +POST /companies/{companyId}/transactions/{id}/match-invoice : Match a positive bank transaction to a customer invoice [scope:transactions:write risk:high idempotent] +POST /companies/{companyId}/transactions/{id}/match-supplier-invoice : Match a negative bank transaction to a supplier invoice [scope:transactions:write risk:high idempotent] +POST /companies/{companyId}/transactions/{id}/uncategorize : Reverse the categorization of a transaction (storno + reset) [scope:transactions:write risk:medium idempotent dry-run] +POST /companies/{companyId}/transactions/batch-categorize : Categorize up to 100 transactions in one call (partial-success) [scope:transactions:write risk:medium idempotent dry-run reversible] +POST /companies/{companyId}/transactions/ingest : Bulk-ingest transactions (up to 500 per call) [scope:transactions:write risk:medium idempotent dry-run] +``` + +### Employees (13) + +Full detail: [references/employees.md](references/employees.md) + +```text +GET /companies/{companyId}/employees : List employees for a company [scope:payroll:read risk:low idempotent] +POST /companies/{companyId}/employees : Create an employee [scope:payroll:write risk:low idempotent dry-run reversible] +GET /companies/{companyId}/employees/{id} : Get a single employee [scope:payroll:read risk:low idempotent] +PATCH /companies/{companyId}/employees/{id} : Update an employee [scope:payroll:write risk:low idempotent dry-run] +DELETE /companies/{companyId}/employees/{id} : Soft-delete an employee [scope:payroll:write risk:low idempotent dry-run reversible] +GET /companies/{companyId}/employees/{id}/absence : List absence days for an employee in a date range [scope:payroll:read risk:low idempotent] +PUT /companies/{companyId}/employees/{id}/absence : Register absence for an employee over a date range [scope:payroll:write risk:low idempotent dry-run reversible] +DELETE /companies/{companyId}/employees/{id}/absence : Delete absence days for an employee in a date range [scope:payroll:write risk:low idempotent dry-run] +GET /companies/{companyId}/employees/{id}/opening-balances : Get an employee's payroll cutover opening balances [scope:payroll:read risk:low idempotent] +PUT /companies/{companyId}/employees/{id}/opening-balances : Set an employee's payroll cutover opening balances [scope:payroll:write risk:medium idempotent dry-run reversible] +GET /companies/{companyId}/employees/{id}/vacation-balance : Get an employee's current vacation balance [scope:payroll:read risk:low idempotent] +PUT /companies/{companyId}/employees/opening-balances : Bulk-set payroll cutover opening balances (atomic) [scope:payroll:write risk:medium idempotent dry-run reversible] +POST /companies/{companyId}/salary/vacation-year-close : Close a vacation year (semesterberedning + arsavslut) [scope:payroll:write risk:high idempotent dry-run] +``` + +### Salary runs (18) + +Full detail: [references/salary-runs.md](references/salary-runs.md) + +```text +GET /companies/{companyId}/salary-runs : List salary runs [scope:payroll:read risk:low idempotent] +POST /companies/{companyId}/salary-runs : Create a salary run [scope:payroll:write risk:low idempotent dry-run reversible] +GET /companies/{companyId}/salary-runs/{id} : Get a salary run [scope:payroll:read risk:low idempotent] +PATCH /companies/{companyId}/salary-runs/{id} : Update a draft salary run [scope:payroll:write risk:low idempotent dry-run] +DELETE /companies/{companyId}/salary-runs/{id} : Delete a draft salary run [scope:payroll:write risk:low idempotent dry-run] +POST /companies/{companyId}/salary-runs/{id}/approve : Approve a reviewed salary run [scope:payroll:write risk:low idempotent dry-run] +POST /companies/{companyId}/salary-runs/{id}/book : Post the verifikationer for a paid salary run [scope:payroll:write risk:high idempotent dry-run] +POST /companies/{companyId}/salary-runs/{id}/calculate : Calculate a draft salary run and advance it to review [scope:payroll:write risk:medium idempotent dry-run] +GET /companies/{companyId}/salary-runs/{id}/employees : List per-employee results of a salary run [scope:payroll:read risk:low idempotent] +POST /companies/{companyId}/salary-runs/{id}/employees : Add an employee to a draft salary run [scope:payroll:write risk:low idempotent dry-run reversible] +GET /companies/{companyId}/salary-runs/{id}/employees/{employeeId} : Get one employee's payslip in a salary run [scope:payroll:read risk:low idempotent] +DELETE /companies/{companyId}/salary-runs/{id}/employees/{employeeId} : Remove an employee from a draft salary run [scope:payroll:write risk:low idempotent dry-run reversible] +POST /companies/{companyId}/salary-runs/{id}/employees/{employeeId}/lines : Add a payslip line to an employee in a draft salary run [scope:payroll:write risk:low idempotent dry-run reversible] +POST /companies/{companyId}/salary-runs/{id}/generate-agi : Generate the Skatteverket AGI XML for a salary run [scope:payroll:write risk:medium idempotent] +PATCH /companies/{companyId}/salary-runs/{id}/lines/{lineId} : Update a payslip line in a draft salary run [scope:payroll:write risk:low idempotent dry-run reversible] +DELETE /companies/{companyId}/salary-runs/{id}/lines/{lineId} : Delete a payslip line from a draft salary run [scope:payroll:write risk:low idempotent dry-run] +POST /companies/{companyId}/salary-runs/{id}/mark-paid : Mark an approved salary run as paid [scope:payroll:write risk:low idempotent dry-run] +GET /companies/{companyId}/salary-runs/{id}/payslips/{employeeId}/pdf : Download one employee's payslip as PDF [scope:payroll:read risk:low idempotent] +``` + +### Reports (14) + +Full detail: [references/reports.md](references/reports.md) + +```text +GET /companies/{companyId}/reports/ar-ledger : AR ledger: unpaid customer invoices with aging [scope:reports:read risk:low idempotent] +GET /companies/{companyId}/reports/avgifter-basis : Annual arbetsgivaravgifter basis per employee [scope:payroll:read risk:low idempotent] +GET /companies/{companyId}/reports/balance-sheet : Balance sheet (balansräkning) for a fiscal period [scope:reports:read risk:low idempotent] +GET /companies/{companyId}/reports/continuity-check : IB/UB continuity check: opening balances match prior closing [scope:reports:read risk:low idempotent] +GET /companies/{companyId}/reports/general-ledger : General ledger (huvudbok) for a fiscal period [scope:reports:read risk:low idempotent] +GET /companies/{companyId}/reports/income-statement : Income statement (resultatrapport) for a fiscal period [scope:reports:read risk:low idempotent] +GET /companies/{companyId}/reports/journal-register : Journal register (verifikationsregister) for a fiscal period [scope:reports:read risk:low idempotent] +GET /companies/{companyId}/reports/monthly-breakdown : Income statement broken down by month for a fiscal period [scope:reports:read risk:low idempotent] +GET /companies/{companyId}/reports/salary-journal : Salary journal (lönejournal) for a year and optional month range [scope:payroll:read risk:low idempotent] +GET /companies/{companyId}/reports/sie-export : SIE4 export (.se file) for a fiscal period [scope:reports:read risk:low idempotent] +GET /companies/{companyId}/reports/supplier-ledger : Supplier ledger: unpaid supplier invoices with aging [scope:reports:read risk:low idempotent] +GET /companies/{companyId}/reports/trial-balance : Trial balance (huvudboksrapport) for a fiscal period [scope:reports:read risk:low idempotent] +GET /companies/{companyId}/reports/vacation-liability : Vacation liability (semesterlöneskuld) per employee at year-end [scope:payroll:read risk:low idempotent] +GET /companies/{companyId}/reports/vat-declaration : Swedish VAT declaration (momsdeklaration) for a period [scope:reports:read risk:low idempotent] +``` + +### Webhooks (9) + +Full detail: [references/webhooks.md](references/webhooks.md) + +```text +GET /companies/{companyId}/webhooks : List webhook subscriptions for a company [scope:webhooks:manage risk:low idempotent] +POST /companies/{companyId}/webhooks : Register a webhook subscription [scope:webhooks:manage risk:low idempotent dry-run reversible] +GET /companies/{companyId}/webhooks/{id} : Get a webhook subscription by id [scope:webhooks:manage risk:low idempotent] +PATCH /companies/{companyId}/webhooks/{id} : Update a webhook subscription [scope:webhooks:manage risk:low idempotent dry-run reversible] +DELETE /companies/{companyId}/webhooks/{id} : Delete a webhook subscription [scope:webhooks:manage risk:medium idempotent] +GET /companies/{companyId}/webhooks/{id}/deliveries : List deliveries for a webhook subscription [scope:webhooks:manage risk:low idempotent] +POST /companies/{companyId}/webhooks/{id}/rotate-secret : Rotate the HMAC signing secret on a webhook [scope:webhooks:manage risk:medium] +POST /companies/{companyId}/webhooks/{id}/test : Send a synthetic test event to a webhook [scope:webhooks:manage risk:low] +POST /webhook-deliveries/{id}/retry : Retry a webhook delivery [scope:webhooks:manage risk:medium] +``` + +## Gotchas (Swedish accounting domain) + +Rules a generic REST integration will violate unless told: + +- **Account numbers are strings, not numbers.** BAS accounts (`"1930"`, + `"3001"`) are identifiers; send them as JSON strings. Arithmetic on them, + zero-stripping, or number coercion corrupts postings. +- **Posted journal entries are immutable by law** (Bokföringslagen). There is + no PATCH or DELETE on a committed entry, ever. Undo with + `POST .../journal-entries/{id}/reverse` (storno), fix with + `POST .../journal-entries/{id}/correct`. Design flows around + reverse-and-repost, not edit-in-place. +- **Voucher numbers are gapless and server-assigned.** Never assume or + pre-allocate one; read it from `meta.audit.voucher_number` after commit. A + legally required gap explanation goes through + `POST .../voucher-gap-explanations`. +- **Every entry balances.** `sum(debit) === sum(credit)` to the öre, amounts + are decimal SEK numbers (max 2 decimals). Do rounding with + round-half-away-from-zero on öre; never float-accumulate line totals + client-side and "fix" the difference on a random line. +- **Period locks are a feature, not an error to retry.** Writes into a + locked/closed period return `PERIOD_LOCKED` (with `valid_alternatives` + pointing at open periods). Retrying the same request cannot succeed; either + target an open period or surface the lock to the user. +- **Drafts vs posted.** Invoices are created as drafts with + `invoice_number: null`; the F-series number is assigned atomically on send. + Journal entries follow draft -> commit. Nothing financial exists in the + ledger until the commit/send action. +- **Two invoice worlds.** `invoices` = accounts receivable (you bill + customers); `supplier-invoices` = accounts payable (you receive bills). + They are different resources with different lifecycles. +- **Swedish user-facing text.** `error.message` is Swedish by design; show it + to Swedish end users, and use `message_en` for your own logs/logic. Domain + terms in responses (moms, verifikat, kostnadsställe) are not translatable + labels but legal concepts. +- **Compliance pre-flight.** Before building your own validation for Swedish + rules, call `GET .../compliance/check`: it runs the server's own rule set + (VAT plausibility, sequence integrity, period status) and returns findings. + +## Verification + +This skill is generated (`npm run apiskill:generate` in the Accounted repo) +from the same endpoint registry that serves the live API, its OpenAPI spec +(`https://app.gnubok.se/api/v1/openapi.json`), and its runtime request +validators, so schema drift between this text and the server cannot occur for +a matching `api_version`. CI regenerates and diffs it on every change. + +Before first use in a new environment, smoke-test: + +```bash +curl -s https://app.gnubok.se/api/v1/health +curl -s https://app.gnubok.se/api/v1/companies -H "Authorization: Bearer $ACCOUNTED_API_KEY" +``` + +If `meta.api_version` in responses is newer than the version in this skill's +index header, refetch the skill (or read the changelog at +https://app.gnubok.se/docs/api/changelog) before relying on endpoint details. diff --git a/skills/accounted-api/references/banking.md b/skills/accounted-api/references/banking.md new file mode 100644 index 00000000..846fb01f --- /dev/null +++ b/skills/accounted-api/references/banking.md @@ -0,0 +1,575 @@ + + +# Banking endpoints + +Bank transactions (ingest, categorize, match against invoices), bank reconciliation runs, and file imports (SIE, bank statements). + +Conventions (auth, envelope, pagination, dry-run, idempotency, standard errors) +are in SKILL.md and are not repeated per endpoint. + +### `POST /api/v1/companies/{companyId}/imports/bank` + +**Import a bank-file (CSV / XML / CAMT053).** +`scope:transactions:write · risk:medium · idempotent` + +Accepts a bank statement file (UTF-8 / Windows-1252, up to 10 MB) as multipart/form-data. Auto-detects the bank format (SEB, Swedbank, Handelsbanken, Nordea, Nordea Business, Lansforsakringar, Lunar, ICA Banken, Skandia, Wise transaction history, Wise balance statement, CAMT053, generic CSV) or honors a `format` override. Parses transactions, ingests them into the `transactions` table (NOT into journal entries: see BFL note in pitfalls), and emits `transaction.synced` events. Returns operation_id for polling. + +**Use when:** Importing a bank statement export for a period. Common with PSD2 bank connections that don't auto-sync, or for legacy bank accounts. +**Do not use for:** SIE bookkeeping import (use /imports/sie). Auto-bank sync (use the enable-banking extension). Single-transaction creation (use POST /transactions/ingest with a 1-element array). + +**Pitfalls:** +- File size cap: 10 MB. Larger files require splitting client-side. +- `format` query parameter is optional; auto-detection works for all supported banks. Pass `format` only to force a specific format. Accepted values: seb, swedbank, handelsbanken, nordea, nordea_business, lansforsakringar, ica_banken, skandia, lunar, northmill, wise, wise_statement, generic_csv, camt053. +- Wise transaction-history rows with refunded or unknown statuses, unknown directions, or different source and target currencies are rejected instead of guessed. Import the matching per-currency Wise balance statements. +- Duplicate detection is by external_id (composed from format + date + description + amount + row index, or the camt.053 entry reference / Wise transfer id where the file carries one); a re-import of the same file typically deduplicates rather than creating doubles. +- BFL 5 kap 6-7 §§ note: this endpoint creates `transactions` rows (the underlag for a verifikation), NOT verifikationer themselves. The verifikation content requirements are in BFL 5 kap 6-7 §§; until each transaction is matched to an invoice/supplier-invoice (POST /transactions/{id}/match-*) or categorised (POST /transactions/{id}/categorize), the bookkeeping obligation isn't discharged. A successful import here means the data is ingested: not booked. +- A successful import returns operation_id; poll /operations/{id} for the final ingested/duplicates/errors counts. + +| Parameter | In | Type | Required | Notes | +|---|---|---|---|---| +| `companyId` | path | `string` | yes | | + +Response `200`: +```ts +{ + data: { operation_id: string, type: "import.bank", status: "queued", poll_url: string }, + meta: { + request_id: string, + api_version: string, + next_cursor?: string, + audit?: { voucher_number?: string, voucher_url?: string, audit_trail_url?: string, immutable_at?: string }, + partial_expansions?: string[] + } +} +``` + +--- + +### `POST /api/v1/companies/{companyId}/imports/sie` + +**Import a SIE4 file.** +`scope:bookkeeping:write · risk:high · idempotent` + +Accepts a SIE4 file (CP437 / Windows-1252 / UTF-8 auto-detected, up to 50 MB) as the request body, parses it, checks for duplicate imports by file-hash, and replays every #VER + #TRANS into the company's bookkeeping. Returns an `operation_id` immediately: poll `GET /api/v1/operations/{id}` for status + final result. The byte-equivalent dashboard route at /api/import/sie/execute backs the same lib helper, so a SIE imported via v1 matches what the dashboard would produce. + +**Use when:** Migrating bookkeeping data from another system (Fortnox, Bokio, Visma) into Accounted, restoring from a backup .se file, or recreating a period from an archive. +**Do not use for:** Bank transaction CSV/XML imports (use POST /imports/bank). Single-voucher creation (use POST /journal-entries). Importing into a period that already has posted entries: SIE imports run on a fresh period. + +**Pitfalls:** +- Body content-type must be multipart/form-data with a `file` field carrying the .se / .sie file (or a JSON body with `file_base64` for agents that can't do multipart). +- File size cap: 50 MB. Larger files require chunking client-side or a future streaming import endpoint. +- Duplicate-file detection is by SHA-256 hash: re-importing the same file returns 409 SIE_IMPORT_DUPLICATE without re-running the import. +- The operation can take 1-5 minutes for multi-year files. The HTTP response returns immediately with operation_id; poll /operations/{id} every ~2s for status. +- BFL 7 kap räkenskapsinformation: once a SIE import completes, the resulting verifikationer are immutable. Cancellation midway is not supported. +- Account mappings are generated server-side from the file's #KONTO records (plus stored per-company overrides). By default the file's account names are carried into the chart, renaming existing accounts whose names differ: pass options.updateAccountNames=false to keep BAS default names. + +| Parameter | In | Type | Required | Notes | +|---|---|---|---|---| +| `companyId` | path | `string` | yes | | + +Response `200`: +```ts +{ + data: { operation_id: string, type: "import.sie", status: "queued", poll_url: string }, + meta: { + request_id: string, + api_version: string, + next_cursor?: string, + audit?: { voucher_number?: string, voucher_url?: string, audit_trail_url?: string, immutable_at?: string }, + partial_expansions?: string[] + } +} +``` + +--- + +### `POST /api/v1/companies/{companyId}/reconciliation/bank/run` + +**Run the bank-reconciliation matcher.** +`scope:transactions:write · risk:medium · idempotent · dry-run` + +Walks all unbooked bank transactions in the requested date range and pairs them with open GL lines (1930-side) by amount + date proximity. Applies confirmed matches by setting transactions.journal_entry_id (the GL row already exists). Dry-runnable. + +**Use when:** You want to auto-match outstanding bank transactions against existing journal entries: typically as the closing step of a sync. Dry-run first to inspect proposed matches. +**Do not use for:** Creating new journal entries: this only links bank transactions to existing GL lines. Matching to invoices: use `:match-invoice` or `:match-supplier-invoice` for explicit invoice payments. + +**Pitfalls:** +- date_from / date_to default to the company's full bank history if omitted. Specify a window for predictable performance. +- account_number defaults to 1930. Multi-account companies must pass the BAS code of the account they are reconciling (e.g. 1932 for a EUR account), or it silently reconciles 1930. +- Idempotency-Key is mandatory. +- Without confidence_threshold, a non-dry run applies EVERY match found, including fuzzy ones at confidence 0.75. Pass confidence_threshold (0.9 recommended, matching gnubok_auto_match_period) for unattended runs, or dry-run first and review matches.confidence before applying. Matches below the threshold are returned but not applied (skipped_below_threshold counts them). +- The 366-day window bound only applies when BOTH date_from and date_to are set; a single-sided or absent window scans full history. + +| Parameter | In | Type | Required | Notes | +|---|---|---|---|---| +| `companyId` | path | `string` | yes | | + +Request body: +```ts +{ date_from?: string, date_to?: string, account_number?: string, confidence_threshold?: number } +``` + +Response `200`: +```ts +{ + data: { + matches: { transaction_id: string, transaction_date: string, transaction_description: string, transaction_amount: number, journal_entry_id: string, voucher_number: number, voucher_series: string, entry_date: string, entry_description: string, method: string, confidence: number }[], + applied: number, + errors: number, + skipped_below_threshold: number + }, + meta: { + request_id: string, + api_version: string, + next_cursor?: string, + audit?: { voucher_number?: string, voucher_url?: string, audit_trail_url?: string, immutable_at?: string }, + partial_expansions?: string[] + } +} +``` + +--- + +### `GET /api/v1/companies/{companyId}/reconciliation/bank/status` + +**Bank-reconciliation health snapshot.** +`scope:transactions:read · risk:low · idempotent` + +Returns matched / unmatched counts and the balance delta between the bank ledger and the GL for the requested window. Optional ?date_from / ?date_to (default: company history). + +**Use when:** You're building a dashboard widget, an audit report, or a pre-close check that needs to know how many bank transactions are still unbooked. +**Do not use for:** Running the matcher: that's POST `/reconciliation/bank/run`. Per-transaction detail: use the transaction list with `?status=unbooked`. + +**Pitfalls:** +- A non-zero difference is normal between sync runs (uncleared cheques, in-flight transfers). Investigate only if it persists across reconciliations. +- difference compares against gl_1930_period_movement (movement excl. opening balance), NOT gl_1930_balance. Do not display gl_1930_balance next to difference. +- is_reconciled means |difference| < 0.01 for the window, an aggregate check, not a per-transaction guarantee. + +| Parameter | In | Type | Required | Notes | +|---|---|---|---|---| +| `companyId` | path | `string` | yes | | + +Response `200`: +```ts +{ + data: { + bank_transaction_total: number, + gl_1930_balance: number, + gl_1930_period_movement: number, + gl_1930_opening_balance: number, + gl_1930_correction_adjustment: number, + difference: number, + is_reconciled: boolean, + matched_count: number, + unmatched_transaction_count: number, + unmatched_gl_line_count: number + }, + meta: { + request_id: string, + api_version: string, + next_cursor?: string, + audit?: { voucher_number?: string, voucher_url?: string, audit_trail_url?: string, immutable_at?: string }, + partial_expansions?: string[] + } +} +``` + +--- + +### `GET /api/v1/companies/{companyId}/transactions` + +**List transactions for a company.** +`scope:transactions:read · risk:low · idempotent` + +Cursor-paginated transaction list ordered by created_at DESC, id ASC (newest-imported first; the `date` column is the transaction date and is filterable but not the sort key). Filter by ?status=booked|unbooked, ?currency, ?date_from / ?date_to, ?search (description ilike). + +**Use when:** You need to walk a company's bank ledger: building a categorization queue, reconciling against external statements, or sampling for audit. +**Do not use for:** Looking up one transaction by id (use the detail endpoint). Reconciliation status (use /reconciliation/bank/status). + +**Pitfalls:** +- Default page size is 50. Pass ?limit=100 for the maximum. Cursor pagination: pass ?cursor= from the previous response. +- A booked transaction has a non-null journal_entry_id. is_business / category live on the transaction row even before booking. +- reverse-charge or storno entries can leave a transaction with journal_entry_id pointing at a cancelled JE: check status on the JE separately. + +| Parameter | In | Type | Required | Notes | +|---|---|---|---|---| +| `companyId` | path | `string` | yes | | + +Response `200`: +```ts +{ + data: { id: string, date: string, description: string, amount: number, currency: string, reference: string, merchant_name: string, journal_entry_id: string, invoice_id: string, supplier_invoice_id: string, is_business: boolean, category: string, import_source: string, created_at: string }[], + meta: { + request_id: string, + api_version: string, + next_cursor?: string, + audit?: { voucher_number?: string, voucher_url?: string, audit_trail_url?: string, immutable_at?: string }, + partial_expansions?: string[] + } +} +``` + +--- + +### `GET /api/v1/companies/{companyId}/transactions/{id}` + +**Retrieve a single transaction by id.** +`scope:transactions:read · risk:low · idempotent` + +Returns the full transaction record including match state, booking state, and import metadata. + +**Use when:** You have a transaction id (from the list or a webhook) and need the full record before deciding to categorize, match, or attach a document. +**Do not use for:** Walking the ledger: use the list endpoint with a cursor. Fetching the linked invoice/journal entry: separate endpoints. + +**Pitfalls:** +- Both invoice_id (matched) and potential_invoice_id (suggested) can be set independently. The matched id is authoritative for accounting. +- reconciliation_method is null for transactions that have never been auto-reconciled. journal_entry_id may still be set via manual categorize. + +| Parameter | In | Type | Required | Notes | +|---|---|---|---|---| +| `companyId` | path | `string` | yes | | +| `id` | path | `string` | yes | | + +Response `200`: +```ts +{ + data: { + id: string, + date: string, + description: string, + amount: number, + currency: string, + amount_sek: number, + reference: string, + merchant_name: string, + counterparty_account: string, + journal_entry_id: string, + invoice_id: string, + supplier_invoice_id: string, + potential_invoice_id: string, + is_business: boolean, + category: string, + receipt_id: string, + document_id: string, + external_id: string, + import_source: string, + reconciliation_method: string, + created_at: string, + updated_at: string + }, + meta: { + request_id: string, + api_version: string, + next_cursor?: string, + audit?: { voucher_number?: string, voucher_url?: string, audit_trail_url?: string, immutable_at?: string }, + partial_expansions?: string[] + } +} +``` + +--- + +### `POST /api/v1/companies/{companyId}/transactions/{id}/categorize` + +**Categorize a transaction and create the journal entry.** +`scope:transactions:write · risk:medium · idempotent · dry-run · reversible` + +Resolves the BAS account mapping for the transaction (via category, booking template, or counterparty template), creates the corresponding verifikation, and updates the transaction with is_business / category / journal_entry_id. Idempotent on (transaction, key). Dry-runnable. + +**Use when:** You're categorizing a bank transaction. Pass `is_business: true` plus either `category`, `template_id` (booking template), `counterparty_template_id`, or `account_override`. For private transactions, `is_business: false` is enough. +**Do not use for:** Matching a payment to an invoice: use `:match-invoice` or `:match-supplier-invoice`, which storno any conflicting JE first. Uncategorizing: `:uncategorize`. + +**Pitfalls:** +- A bank payment that looks like an invoice payment will be flagged via TX_CATEGORIZE_SUGGEST_SI_MATCH: pass `confirm_no_match: true` to override and force-categorize as direct expense (e.g. when the supplier invoice was already booked). +- Already-categorized fast path: if the transaction already has a journal_entry_id, only flags get updated. The JE is immutable post-commit. +- account_override must exist in the chart of accounts; an unknown account returns TX_CATEGORIZE_INVALID_ACCOUNT. + +| Parameter | In | Type | Required | Notes | +|---|---|---|---|---| +| `companyId` | path | `string` | yes | | +| `id` | path | `string` | yes | | + +Request body: +```ts +{ + is_business: boolean, + category?: "income_services" | "income_products" | "income_other" | "expense_equipment" | "expense_software" | "expense_travel" | "expense_office" | "expense_marketing" | "expense_professional_services" | "expense_education" | "expense_representation" | "expense_consumables" | "expense_vehicle" | "expense_telecom" | "expense_bank_fees" | "expense_card_fees" | "expense_currency_exchange" | "expense_other" | "private" | "uncategorized", + template_id?: string, + vat_treatment?: "standard_25" | "reduced_12" | "reduced_6" | "reverse_charge" | "export" | "exempt", + account_override?: string, + counterparty_template_id?: string, + dimensions?: Record, + user_description?: string, + inbox_item_id?: string, + confirm_no_match?: boolean, + force?: boolean, + expected_duplicate_transaction_id?: string, + expected_duplicate_journal_entry_id?: string +} +``` + +Response `200`: +```ts +{ + data: { + success: boolean, + journal_entry_created: boolean, + journal_entry_id: string, + journal_entry_error: string, + document_link_warning?: string, + category: string, + already_had_journal_entry?: boolean + }, + meta: { + request_id: string, + api_version: string, + next_cursor?: string, + audit?: { voucher_number?: string, voucher_url?: string, audit_trail_url?: string, immutable_at?: string }, + partial_expansions?: string[] + } +} +``` + +--- + +### `POST /api/v1/companies/{companyId}/transactions/{id}/match-invoice` + +**Match a positive bank transaction to a customer invoice.** +`scope:transactions:write · risk:high · idempotent` + +Confirms an invoice match for a transaction. Storno any conflicting auto-categorization JE, create the payment journal entry, update the invoice status (paid / partially_paid), insert into invoice_payments, and link the transaction. Idempotent. + +**Use when:** You have a bank receipt and a known open invoice it pays. The transaction must be positive (income) and unlinked. +**Do not use for:** Categorizing a transaction without an invoice: use `:categorize`. Matching to a supplier invoice: use `:match-supplier-invoice`. Bulk auto-match: use `POST /reconciliation/bank/run`. + +**Pitfalls:** +- Proforma + delivery notes are rejected (MATCH_INVOICE_NOT_INVOICE_TYPE): only document_type='invoice' can be matched. +- Transaction must be positive (amount > 0): negative transactions return MATCH_INVOICE_NOT_INCOME. +- Invoice must be in sent / overdue / partially_paid status: paid or draft invoices return MATCH_INVOICE_NOT_OPEN. +- Idempotency-Key is mandatory. + +| Parameter | In | Type | Required | Notes | +|---|---|---|---|---| +| `companyId` | path | `string` | yes | | +| `id` | path | `string` | yes | | + +Request body: +```ts +{ + invoice_id: string, + force?: boolean, + expected_journal_entry_id?: string, + lines?: { account_number: string, debit_amount?: number, credit_amount?: number, line_description?: string }[], + manual_exchange_rate?: number +} +``` + +Response `200`: +```ts +{ + data: { + success: boolean, + invoice_status: string, + paid_at: string, + paid_amount: number, + remaining_amount: number, + journal_entry_id: string, + category: string + }, + meta: { + request_id: string, + api_version: string, + next_cursor?: string, + audit?: { voucher_number?: string, voucher_url?: string, audit_trail_url?: string, immutable_at?: string }, + partial_expansions?: string[] + } +} +``` + +--- + +### `POST /api/v1/companies/{companyId}/transactions/{id}/match-supplier-invoice` + +**Match a negative bank transaction to a supplier invoice.** +`scope:transactions:write · risk:high · idempotent` + +Confirms a supplier invoice payment match. Creates the payment journal entry (accrual: 2440 debit, credit on the transaction's own settlement account, 1930 when unlinked; cash-method: collapsed registration+payment), updates supplier_invoices, inserts a supplier_invoice_payments row, and links the transaction. Handles FX differences for cross-currency payments (7960 gain / 3960 loss). + +**Use when:** You have a bank payment and a known open supplier invoice. The transaction must be negative (expense) and unlinked. +**Do not use for:** Categorizing a direct supplier expense without an invoice: use `:categorize`. Matching to a customer invoice: use `:match-invoice`. Bulk auto-match: `POST /reconciliation/bank/run`. + +**Pitfalls:** +- Cash-method companies can settle a foreign invoice in full (booked at the payment-date rate); only a PARTIAL cash-method payment across currencies is rejected (MATCH_SI_CASH_FX_UNSUPPORTED): pay in full, switch to accrual, or book manually. +- Transaction must be negative (amount < 0). Positive returns MATCH_SI_NOT_EXPENSE. +- Supplier invoice must NOT be paid/credited already. paid/credited returns MATCH_SI_ALREADY_PAID; registered/approved/partially_paid/overdue are matchable. +- Idempotency-Key is mandatory. + +| Parameter | In | Type | Required | Notes | +|---|---|---|---|---| +| `companyId` | path | `string` | yes | | +| `id` | path | `string` | yes | | + +Request body: +```ts +{ + supplier_invoice_id: string, + lines?: { account_number: string, debit_amount?: number, credit_amount?: number, line_description?: string }[] +} +``` + +Response `200`: +```ts +{ + data: { + success: boolean, + invoice_status: string, + paid_amount: number, + remaining_amount: number, + journal_entry_id: string + }, + meta: { + request_id: string, + api_version: string, + next_cursor?: string, + audit?: { voucher_number?: string, voucher_url?: string, audit_trail_url?: string, immutable_at?: string }, + partial_expansions?: string[] + } +} +``` + +--- + +### `POST /api/v1/companies/{companyId}/transactions/{id}/uncategorize` + +**Reverse the categorization of a transaction (storno + reset).** +`scope:transactions:write · risk:medium · idempotent · dry-run` + +Storno the transaction's journal entry (BFL 5 kap 5 §: posted entries are never deleted, only cancelled via a reversing entry) and reset is_business / category / journal_entry_id on the transaction row. Idempotent: a second call on an already-uncategorized transaction returns 400 TX_UNCATEGORIZE_NOT_BOOKED. Dry-runnable. + +**Use when:** You categorized a transaction by mistake and want to redo it from scratch. The storno keeps the audit trail intact. +**Do not use for:** Changing the categorization of an already-booked transaction: categorize again instead (the second call sees journal_entry_id and only updates flags). Reversing a payment match: there is no v1 verb for that yet. + +**Pitfalls:** +- Idempotency-Key is mandatory. +- The storno creates a new (cancelling) journal entry. The original entry stays in the ledger marked as cancelled: voucher gaps are documented automatically. +- A transaction without a journal_entry_id returns 400 TX_UNCATEGORIZE_NOT_BOOKED: there is nothing to reverse. + +| Parameter | In | Type | Required | Notes | +|---|---|---|---|---| +| `companyId` | path | `string` | yes | | +| `id` | path | `string` | yes | | + +Response `200`: +```ts +{ + data: { success: boolean, reversed_journal_entry_id: string }, + meta: { + request_id: string, + api_version: string, + next_cursor?: string, + audit?: { voucher_number?: string, voucher_url?: string, audit_trail_url?: string, immutable_at?: string }, + partial_expansions?: string[] + } +} +``` + +--- + +### `POST /api/v1/companies/{companyId}/transactions/batch-categorize` + +**Categorize up to 100 transactions in one call (partial-success).** +`scope:transactions:write · risk:medium · idempotent · dry-run · reversible` + +Per-item categorization mirroring the single :categorize endpoint. Same `{ results, summary }` shape as the other bulk endpoints. all_or_nothing: true returns 501 NOT_IMPLEMENTED. Idempotent over the whole batch. + +**Use when:** You have many transactions to categorize with the same logic (e.g. apply a booking template across a queue, mark a batch as private, override accounts on a series). +**Do not use for:** Categorizing transactions with mixed logic: make multiple :categorize calls. Auto-categorization via templates: handled inside `ingest` for matching rows, no separate endpoint needed. + +**Pitfalls:** +- Max 100 items per call. Sequential processing. +- Idempotency-Key covers the WHOLE batch: replays return the cached full response. +- all_or_nothing: true returns 501 NOT_IMPLEMENTED. Today only partial-success batches exist. + +| Parameter | In | Type | Required | Notes | +|---|---|---|---|---| +| `companyId` | path | `string` | yes | | + +Request body: +```ts +{ + items: { transaction_id: string, categorization: { is_business: boolean, category?: "income_services" | "income_products" | "income_other" | "expense_equipment" | "expense_software" | "expense_travel" | "expense_office" | "expense_marketing" | "expense_professional_services" | "expense_education" | "expense_representation" | "expense_consumables" | "expense_vehicle" | "expense_telecom" | "expense_bank_fees" | "expense_card_fees" | "expense_currency_exchange" | "expense_other" | "private" | "uncategorized", template_id?: string, vat_treatment?: "standard_25" | "reduced_12" | "reduced_6" | "reverse_charge" | "export" | "exempt", account_override?: string, counterparty_template_id?: string, dimensions?: Record, user_description?: string, inbox_item_id?: string, confirm_no_match?: boolean, force?: boolean, expected_duplicate_transaction_id?: string, expected_duplicate_journal_entry_id?: string } }[], + all_or_nothing?: boolean +} +``` + +Response `200`: +```ts +{ + data: { + results: { ok: boolean, request_index: number, transaction_id: string, data?: unknown, error?: { code: string, message: string, details?: unknown } }[], + summary: { total: number, succeeded: number, failed: number } + }, + meta: { + request_id: string, + api_version: string, + next_cursor?: string, + audit?: { voucher_number?: string, voucher_url?: string, audit_trail_url?: string, immutable_at?: string }, + partial_expansions?: string[] + } +} +``` + +--- + +### `POST /api/v1/companies/{companyId}/transactions/ingest` + +**Bulk-ingest transactions (up to 500 per call).** +`scope:transactions:write · risk:medium · idempotent · dry-run` + +Runs the same ingest pipeline as the dashboard CSV importer and the PSD2 bank sync: dedup, insert, invoice match, mapping-rule auto-categorize, auto-JE for high-confidence matches. Idempotent over the whole batch via Idempotency-Key. Dry-runnable. + +**Use when:** You're importing transactions from a CSV, a custom bank feed, or an external accounting system. Each item must have a stable external_id: this is the primary dedup key. +**Do not use for:** Single ad-hoc transactions (use the dashboard). Documents/receipts (use the documents endpoint). Manually-created journal entries (Phase 4). + +**Pitfalls:** +- external_id is the primary dedup key: make it stable for the same physical transaction across reruns. +- Content-based dedup runs in addition: a row matching an already-booked transaction by date, amount AND description (prefix-containment, to survive PSD2 title enrichment) is skipped even if external_id differs. +- raw_insert_only=true skips ALL post-insert pipeline steps (matching, categorization). Use for viewer-only imports. +- Max 500 items per call. For larger imports, split into pages of 500. +- Dry-run previews external_id + content dedup against BOOKED rows only; the live pipeline also dedups against unbooked bank-synced rows, so preview skips are a lower bound on the live skip count. + +| Parameter | In | Type | Required | Notes | +|---|---|---|---|---| +| `companyId` | path | `string` | yes | | + +Request body: +```ts +{ + transactions: { date: string, description: string, amount: number, currency: string, external_id: string, mcc_code?: number, merchant_name?: string, reference?: string, import_source?: string }[], + skip_auto_categorization?: boolean, + settlement_account?: string, + raw_insert_only?: boolean +} +``` + +Response `200`: +```ts +{ + data: { + imported: number, + duplicates: number, + reconciled: number, + auto_categorized: number, + auto_matched_invoices: number, + errors: number, + transaction_ids: string[] + }, + meta: { + request_id: string, + api_version: string, + next_cursor?: string, + audit?: { voucher_number?: string, voucher_url?: string, audit_trail_url?: string, immutable_at?: string }, + partial_expansions?: string[] + } +} +``` diff --git a/skills/accounted-api/references/core.md b/skills/accounted-api/references/core.md new file mode 100644 index 00000000..ae8ad489 --- /dev/null +++ b/skills/accounted-api/references/core.md @@ -0,0 +1,184 @@ + + +# Core endpoints + +Connectivity, company discovery, async-operation polling, and company settings. Every session starts with GET /companies to resolve the companyId that all other URLs need. + +Conventions (auth, envelope, pagination, dry-run, idempotency, standard errors) +are in SKILL.md and are not repeated per endpoint. + +### `GET /api/v1/companies` + +**List companies the API key can access.** +`scope:companies:read · risk:low · idempotent` + +Returns every non-archived company the API key user is a member of, together with their role. Use the returned `id` as `{companyId}` in subsequent endpoints. + +**Use when:** You need to discover which company IDs an API key has access to before calling company-scoped endpoints. +**Do not use for:** Fetching a single company you already know the id of: use GET /api/v1/companies/{companyId} for that. + +**Pitfalls:** +- Multi-company keys (e.g. consultants) will see >1 result. Always pass the correct companyId in subsequent paths. +- Archived companies are excluded; if a company disappears the user has been removed from it or it was archived. + +Response `200`: +```ts +{ + data: { id: string, name: string, org_number: string, entity_type: string, role: "owner" | "admin" | "member" | "viewer", created_at: string }[], + meta: { + request_id: string, + api_version: string, + next_cursor?: string, + audit?: { voucher_number?: string, voucher_url?: string, audit_trail_url?: string, immutable_at?: string }, + partial_expansions?: string[] + } +} +``` + +--- + +### `PATCH /api/v1/companies/{companyId}/settings` + +**Partially update company settings.** +`scope:companies:write · risk:medium · idempotent · dry-run · reversible` + +Patches the company payment details (bank account, Bankgiro, Plusgiro, Swish, IBAN/BIC), the contact details shown on invoices (contact_person, email, phone, website), and the custom invoice email texts. All fields optional; at least one must be supplied. Idempotent (mandatory Idempotency-Key). Dry-runnable. The same validation as the MCP staging tool applies: Bankgiro/Plusgiro numbers are Luhn-checked and invoice email texts only accept a fixed placeholder set. + +**Use when:** You need to change the payment or contact details that appear on invoices, or override the invoice email texts, directly over REST instead of the staged MCP flow. +**Do not use for:** Legal or tax profile changes (org number, VAT registration, fiscal year, accounting method): those are not exposed on the public API. Reading settings (no GET endpoint yet; use the MCP tool gnubok_get_company_settings). + +**Pitfalls:** +- Idempotency-Key is mandatory; calls without it return 400. +- contact_person is stored as default_our_reference: the default "Our reference" value on new invoices. +- bankgiro and plusgiro must carry a valid Luhn check digit; null or empty string clears them. +- invoice_email_texts only accepts the placeholders {fakturanummer} {kundnamn} {förnamn} {företag} {förfallodatum} {belopp}; any other {token} is rejected. Null clears every override. + +| Parameter | In | Type | Required | Notes | +|---|---|---|---|---| +| `companyId` | path | `string` | yes | | + +Request body: +```ts +{ + bank_name?: string, + clearing_number: string | "", + account_number: string | "", + bankgiro: string | "", + plusgiro: string | "", + swish?: string, + iban: string | "", + bic: string | "", + contact_person?: string, + email: string | "", + phone?: string, + website: string | "", + invoice_email_texts?: { + sv?: { subject?: string, greeting?: string, body?: string, signoff?: string }, + en?: { subject?: string, greeting?: string, body?: string, signoff?: string } + } +} +``` + +Response `200`: +```ts +{ + data: { + company_id: string, + bank_name: string, + clearing_number: string, + account_number: string, + bankgiro: string, + plusgiro: string, + swish: string, + iban: string, + bic: string, + contact_person: string, + email: string, + phone: string, + website: string, + invoice_email_texts: { sv?: { subject?: string, greeting?: string, body?: string, signoff?: string }, en?: { subject?: string, greeting?: string, body?: string, signoff?: string } } + }, + meta: { + request_id: string, + api_version: string, + next_cursor?: string, + audit?: { voucher_number?: string, voucher_url?: string, audit_trail_url?: string, immutable_at?: string }, + partial_expansions?: string[] + } +} +``` + +--- + +### `GET /api/v1/health` + +**Health check.** +`risk:low · idempotent` + +Reports the API is reachable and what version is currently served. Public; no auth required. + +**Use when:** You want to verify connectivity, latency, or which API version is live before issuing other requests. +**Do not use for:** Anything that needs authenticated data. This endpoint returns no company-specific information. + +**Pitfalls:** +- A 200 here only means the API process responds: downstream Postgres/Supabase may still be degraded. + +Response `200`: +```ts +{ + data: { status: "ok" | "degraded", service: "gnubok", api_version: string, timestamp: string }, + meta: { + request_id: string, + api_version: string, + next_cursor?: string, + audit?: { voucher_number?: string, voucher_url?: string, audit_trail_url?: string, immutable_at?: string }, + partial_expansions?: string[] + } +} +``` + +--- + +### `GET /api/v1/operations/{id}` + +**Poll a long-running operation by id.** +`scope:operations:read · risk:low · idempotent` + +Returns the current snapshot of a v1 async operation: status (queued / running / succeeded / failed / cancelled), progress (jsonb, free-form), result (on success), and error (on failure). The operation_id is returned by the POST endpoints that initiate async work (period close, year-end, currency revaluation, SIE import). + +**Use when:** You started an async operation and need to know whether it has finished. Poll every 5-30 seconds until a terminal status. (The 202 response advertises `operation.completed` as the eventual push signal, but that webhook event is not deliverable yet — polling is the only supported completion signal today.) +**Do not use for:** Fetching the resource the operation produced: once status=succeeded, read the result field or call the resource-specific GET endpoint. Cancelling a running operation (no cancel endpoint exists in v1). + +**Pitfalls:** +- Terminal statuses (`succeeded`, `failed`, `cancelled`) are final; the row never transitions out of them. +- progress is free-form jsonb; agents should treat it as opaque except for the documented fields `phase` (string), `current` / `total` (numbers for percent calculation). +- started_at is null while status=queued (the work has not begun yet); completed_at is null until a terminal status is reached. + +| Parameter | In | Type | Required | Notes | +|---|---|---|---|---| +| `id` | path | `string` | yes | | + +Response `200`: +```ts +{ + data: { + operation_id: string, + type: string, + status: "queued" | "running" | "succeeded" | "failed" | "cancelled", + progress?: Record, + result: unknown, + error: { code?: string, message?: string, details?: unknown }, + started_at: string, + completed_at: string, + poll_url: string, + webhook_event: "operation.completed" + }, + meta: { + request_id: string, + api_version: string, + next_cursor?: string, + audit?: { voucher_number?: string, voucher_url?: string, audit_trail_url?: string, immutable_at?: string }, + partial_expansions?: string[] + } +} +``` diff --git a/skills/accounted-api/references/customers.md b/skills/accounted-api/references/customers.md new file mode 100644 index 00000000..8c1e8338 --- /dev/null +++ b/skills/accounted-api/references/customers.md @@ -0,0 +1,376 @@ + + +# Customers and articles endpoints + +The customer register (bulk-create supported, archive via DELETE) and the read-only article register used for invoice line linkage. + +Conventions (auth, envelope, pagination, dry-run, idempotency, standard errors) +are in SKILL.md and are not repeated per endpoint. + +### `GET /api/v1/companies/{companyId}/articles` + +**List the article register (artikelregister).** +`scope:invoices:read · risk:low · idempotent` + +Returns the company's articles ordered by name. Pass ?include_inactive=true to include soft-deactivated articles. Use the returned id as items[].article_id when creating invoices; housework_type carries the ROT/RUT arbetstypskod for service articles, and revenue_account the optional BAS class-3 override. + +**Use when:** You need the article catalog before composing invoice lines: to resolve an article_id, read its price/VAT defaults, or find ROT/RUT-tagged service articles (housework_type set). +**Do not use for:** Creating or editing articles (dashboard-only for now). Invoice line creation itself (POST …/invoices with items[].article_id). + +**Pitfalls:** +- Linking article_id does NOT auto-fill the invoice line: send description, unit_price, vat_rate etc. explicitly on the item (copy them from this response). +- price_excl_vat always excludes VAT. +- price_excl_vat is denominated in the article's own currency, which is NOT always SEK. Check currency before copying the price onto an invoice line: the invoice carries a single currency for all its lines and there is no FX conversion here. +- housework_type is an arbetstypskod hint (e.g. BYGG, STAD); the invoice line still needs deduction_type + labor_hours + work_type set explicitly for ROT/RUT. +- Inactive articles (active=false) are hidden by default but remain linkable for historical reads. + +| Parameter | In | Type | Required | Notes | +|---|---|---|---|---| +| `companyId` | path | `string` | yes | | + +Response `200`: +```ts +{ + data: { + articles: { id: string, article_number: string, name: string, name_en: string, type: "vara" | "tjanst", unit: string, price_excl_vat: number, currency: string, vat_rate: number, revenue_account: string, cost_price: number, ean: string, housework_type: string, notes: string, active: boolean, created_at: string, updated_at: string }[] + }, + meta: { + request_id: string, + api_version: string, + next_cursor?: string, + audit?: { voucher_number?: string, voucher_url?: string, audit_trail_url?: string, immutable_at?: string }, + partial_expansions?: string[] + } +} +``` + +--- + +### `GET /api/v1/companies/{companyId}/customers` + +**List customers for a company.** +`scope:customers:read · risk:low · idempotent` + +Returns active customers in created-first order. Pass ?include_archived=true to include archived rows. Use ?search to match against name or org_number. + +**Use when:** You need a customer roster: for building a UI picker, syncing a CRM, or resolving a customer_id before creating an invoice. +**Do not use for:** Fetching a single customer you already know the id of: use GET /api/v1/companies/{companyId}/customers/{id}. Suppliers are a separate resource. + +**Pitfalls:** +- Archived customers are hidden by default; the dashboard makes the same choice. +- org_number is included so callers can match against external CRM identifiers; for sole traders (enskild firma) it equals the personnummer. + +| Parameter | In | Type | Required | Notes | +|---|---|---|---|---| +| `companyId` | path | `string` | yes | | + +Response `200`: +```ts +{ + data: { id: string, name: string, customer_type: "individual" | "swedish_business" | "eu_business" | "non_eu_business", email: string, org_number: string, vat_number: string, default_payment_terms: number, archived_at: string, created_at: string }[], + meta: { + request_id: string, + api_version: string, + next_cursor?: string, + audit?: { voucher_number?: string, voucher_url?: string, audit_trail_url?: string, immutable_at?: string }, + partial_expansions?: string[] + } +} +``` + +--- + +### `POST /api/v1/companies/{companyId}/customers` + +**Create a customer.** +`scope:customers:write · risk:low · idempotent · dry-run · reversible` + +Creates a new customer for the company. Requires Idempotency-Key (UUID). Supports ?dry_run=true for input validation without committing: the dry-run response shows the would-be record minus id and timestamps. EU-business customers with a VAT number are auto-validated against VIES on commit. + +**Use when:** You need to register a new customer before invoicing them. Use dry-run first to catch validation errors before committing. +**Do not use for:** Updating an existing customer (PATCH instead). Creating suppliers (different resource). + +**Pitfalls:** +- Idempotency-Key is mandatory: calls without it return 400 VALIDATION_ERROR. +- org_number uniqueness is enforced at the database level; duplicate inserts return 409 CUSTOMER_DUPLICATE_ORG_NUMBER. +- For Swedish sole traders (customer_type=individual), org_number IS the personnummer. List responses mask it; the create endpoint accepts it as input. +- VIES validation runs only on commit. Dry-run skips the external call and leaves vat_number_validated=false in the preview. + +| Parameter | In | Type | Required | Notes | +|---|---|---|---|---| +| `companyId` | path | `string` | yes | | + +Request body: +```ts +{ + name: string, + customer_type: "individual" | "swedish_business" | "eu_business" | "non_eu_business", + customer_number?: string, + contact_person?: string, + email?: string, + phone?: string, + invoice_email_cc_addresses?: string[], + invoice_email_bcc_addresses?: string[], + address_line1?: string, + address_line2?: string, + postal_code?: string, + city?: string, + country?: string, + org_number?: string, + vat_number?: string, + personal_number: string, + language?: "sv" | "en", + default_payment_terms?: number, + notes?: string +} +``` + +Response `200`: +```ts +{ + data: { + id: string, + name: string, + customer_type: "individual" | "swedish_business" | "eu_business" | "non_eu_business", + customer_number: string, + contact_person: string, + email: string, + phone: string, + invoice_email_cc_addresses: string[], + invoice_email_bcc_addresses: string[], + address_line1: string, + address_line2: string, + postal_code: string, + city: string, + country: string, + org_number: string, + vat_number: string, + vat_number_validated: boolean, + default_payment_terms: number, + notes: string, + archived_at: string, + created_at: string, + updated_at: string + }, + meta: { + request_id: string, + api_version: string, + next_cursor?: string, + audit?: { voucher_number?: string, voucher_url?: string, audit_trail_url?: string, immutable_at?: string }, + partial_expansions?: string[] + } +} +``` + +--- + +### `GET /api/v1/companies/{companyId}/customers/{id}` + +**Retrieve a single customer by id.** +`scope:customers:read · risk:low · idempotent` + +Returns the full customer record. Pass ?expand=invoices to embed any open invoices (sent / partially_paid / overdue) for the customer in the same response. + +**Use when:** You need the full customer record: address, payment terms, VAT validation status, contact details: before invoicing or syncing to another system. +**Do not use for:** Listing customers (use the list endpoint). Looking up arbitrary supplier or employee records (different resources). + +**Pitfalls:** +- archived_at is non-null when the customer has been soft-deleted; the customer is still queryable by id but excluded from default lists. +- vat_number_validated reflects the last successful VIES check; it can become stale if the EU registry revokes a number. + +| Parameter | In | Type | Required | Notes | +|---|---|---|---|---| +| `companyId` | path | `string` | yes | | +| `id` | path | `string` | yes | | + +Response `200`: +```ts +{ + data: { + id: string, + name: string, + customer_type: string, + customer_number: string, + contact_person: string, + email: string, + phone: string, + invoice_email_cc_addresses: string[], + invoice_email_bcc_addresses: string[], + address_line1: string, + address_line2: string, + postal_code: string, + city: string, + country: string, + org_number: string, + vat_number: string, + vat_number_validated: boolean, + default_payment_terms: number, + notes: string, + archived_at: string, + created_at: string, + updated_at: string + }, + meta: { + request_id: string, + api_version: string, + next_cursor?: string, + audit?: { voucher_number?: string, voucher_url?: string, audit_trail_url?: string, immutable_at?: string }, + partial_expansions?: string[] + } +} +``` + +--- + +### `PATCH /api/v1/companies/{companyId}/customers/{id}` + +**Partially update a customer.** +`scope:customers:write · risk:low · idempotent · dry-run · reversible` + +Patches the customer with the supplied fields. All fields optional. Idempotent (mandatory Idempotency-Key). Dry-runnable. When vat_number changes on an eu_business customer, VIES re-validation runs on commit (best-effort). + +**Use when:** You need to change a customer's contact details, payment terms, address, or VAT registration. Use dry-run first to confirm the merged record before committing. +**Do not use for:** Archiving a customer (use DELETE: sets archived_at). Replacing the entire record (no PUT verb is exposed; PATCH is partial). + +**Pitfalls:** +- Idempotency-Key is mandatory; calls without it return 400. +- org_number uniqueness is enforced at DB level: 23505 → 409 CUSTOMER_DUPLICATE_ORG_NUMBER. +- VIES re-validation is best-effort and runs only on commit. A VIES timeout does not fail the update. + +| Parameter | In | Type | Required | Notes | +|---|---|---|---|---| +| `companyId` | path | `string` | yes | | +| `id` | path | `string` | yes | | + +Request body: +```ts +{ + name?: string, + customer_type?: "individual" | "swedish_business" | "eu_business" | "non_eu_business", + customer_number?: string, + contact_person?: string, + email?: string, + phone?: string, + invoice_email_cc_addresses?: string[], + invoice_email_bcc_addresses?: string[], + address_line1?: string, + address_line2?: string, + postal_code?: string, + city?: string, + country?: string, + org_number?: string, + vat_number?: string, + personal_number?: string, + language?: "sv" | "en", + default_payment_terms?: number, + notes?: string +} +``` + +Response `200`: +```ts +{ + data: { + id: string, + name: string, + customer_type: string, + customer_number: string, + contact_person: string, + email: string, + phone: string, + invoice_email_cc_addresses: string[], + invoice_email_bcc_addresses: string[], + address_line1: string, + address_line2: string, + postal_code: string, + city: string, + country: string, + org_number: string, + vat_number: string, + vat_number_validated: boolean, + default_payment_terms: number, + notes: string, + archived_at: string, + created_at: string, + updated_at: string + }, + meta: { + request_id: string, + api_version: string, + next_cursor?: string, + audit?: { voucher_number?: string, voucher_url?: string, audit_trail_url?: string, immutable_at?: string }, + partial_expansions?: string[] + } +} +``` + +--- + +### `DELETE /api/v1/companies/{companyId}/customers/{id}` + +**Archive a customer (soft-delete).** +`scope:customers:write · risk:medium · idempotent · dry-run · reversible` + +Sets archived_at on the customer; the record is preserved (invoices and audit history remain intact) but excluded from default list responses. To un-archive, PATCH archived_at back to null. Idempotent: archiving an already-archived customer is a no-op. Dry-runnable. + +**Use when:** You want to remove a customer from active rosters without losing their history. Idempotent: re-archiving is safe. +**Do not use for:** Permanently deleting a customer with all history: the public API does not expose hard-delete. GDPR erasure requests go through a dedicated workflow. + +**Pitfalls:** +- Idempotency-Key is mandatory. +- A customer with any open invoice (sent / partially_paid / overdue) cannot be archived: returns 409 CUSTOMER_HAS_INVOICES. Issue a kreditfaktura first if you need to close the relationship cleanly. This protects ML 17 kap 24§: the customer record is the canonical source of buyer name/address for invoice reissuance. +- 204 No Content is returned on success: there is no response body to parse. + +| Parameter | In | Type | Required | Notes | +|---|---|---|---|---| +| `companyId` | path | `string` | yes | | +| `id` | path | `string` | yes | | + +Response `204`. + +--- + +### `POST /api/v1/companies/{companyId}/customers/bulk-create` + +**Create up to 50 customers in one call (partial-success).** +`scope:customers:write · risk:low · idempotent · dry-run · reversible` + +Bulk-create endpoint mirroring /invoices/bulk-create. Each customer is validated and inserted independently: per-item failures do not roll back items that succeeded. Returns a results array plus a summary. Idempotent over the whole batch. Dry-runnable. + +**Use when:** You're importing a roster of customers from another CRM, or seeding a fresh company with its existing client list. Use dry-run first to validate the batch. +**Do not use for:** Updating existing customers: PATCH /customers/{id} once per customer. Bulk uploads of > 50 customers: split into pages of 50. Transactional all-or-nothing imports: passing all_or_nothing: true returns 501 NOT_IMPLEMENTED. + +**Pitfalls:** +- Idempotency-Key is mandatory and covers the WHOLE batch. A retried bulk-create returns the cached full response: it does not retry only the failed items. +- Passing all_or_nothing: true returns 501 NOT_IMPLEMENTED. Today only partial-success batches exist; omit the flag or pass false. +- org_number uniqueness is enforced at the DB level: items with duplicates fail individually with CUSTOMER_DUPLICATE_ORG_NUMBER. +- VIES validation for eu_business customers is best-effort per item; a VIES timeout leaves vat_number_validated=false but does NOT fail the item. + +| Parameter | In | Type | Required | Notes | +|---|---|---|---|---| +| `companyId` | path | `string` | yes | | + +Request body: +```ts +{ + customers: { name: string, customer_type: "individual" | "swedish_business" | "eu_business" | "non_eu_business", customer_number?: string, contact_person?: string, email?: string, phone?: string, invoice_email_cc_addresses?: string[], invoice_email_bcc_addresses?: string[], address_line1?: string, address_line2?: string, postal_code?: string, city?: string, country?: string, org_number?: string, vat_number?: string, personal_number: string, language?: "sv" | "en", default_payment_terms?: number, notes?: string }[], + all_or_nothing?: boolean +} +``` + +Response `200`: +```ts +{ + data: { + results: { ok: boolean, request_index: number, data?: unknown, error?: { code: string, message: string, details?: unknown } }[], + summary: { total: number, succeeded: number, failed: number } + }, + meta: { + request_id: string, + api_version: string, + next_cursor?: string, + audit?: { voucher_number?: string, voucher_url?: string, audit_trail_url?: string, immutable_at?: string }, + partial_expansions?: string[] + } +} +``` diff --git a/skills/accounted-api/references/documents.md b/skills/accounted-api/references/documents.md new file mode 100644 index 00000000..dc2f6b22 --- /dev/null +++ b/skills/accounted-api/references/documents.md @@ -0,0 +1,192 @@ + + +# Documents endpoints + +The WORM document archive (7-year legal retention: uploads are permanent) and inbox-item stamping. Link every uploaded receipt/invoice document to its journal entry. + +Conventions (auth, envelope, pagination, dry-run, idempotency, standard errors) +are in SKILL.md and are not repeated per endpoint. + +### `POST /api/v1/companies/{companyId}/documents` + +**Upload a document to the WORM archive.** +`scope:documents:write · risk:medium · idempotent` + +Multipart upload of a document (PDF / image) under the BFL 7 kap retention regime. The bytes are hashed (SHA-256), written to Supabase Storage, and recorded in document_attachments at version=1. Allowed MIME types: application/pdf, image/jpeg, image/png, image/webp. Max size: 10 MB. + +**Use when:** You have a receipt, invoice scan, or supporting document for a posted verifikation and want it archived for the 7-year BFL retention period. Optionally link to a journal entry at upload time via journal_entry_id. +**Do not use for:** Updating an existing document (no v1 update endpoint; new versions go through the dashboard). Bulk uploads: call once per file. + +**Pitfalls:** +- Idempotency-Key is mandatory; multipart retries with the same key replay the cached response. +- Max size 10 MB enforced server-side: DOC_UPLOAD_TOO_LARGE on overrun. +- Only application/pdf / image/jpeg / image/png / image/webp accepted: DOC_UPLOAD_UNSUPPORTED_TYPE otherwise. +- WORM: once linked to a posted journal entry, the document row cannot be modified or deleted (DB trigger). Upload-then-link is reversible (the document exists with journal_entry_id=null until linked); once linked, treat as immutable. +- Dry-run is not supported on this endpoint: the engine hashes + stores + inserts in one atomic flow. + +| Parameter | In | Type | Required | Notes | +|---|---|---|---|---| +| `companyId` | path | `string` | yes | | + +Request body (`multipart/form-data`): +```ts +{ + file: string, + upload_source?: "file_upload" | "camera" | "email" | "api", + journal_entry_id?: string, + journal_entry_line_id?: string +} +``` + +Response `200`: +```ts +{ + data: { + id: string, + file_name: string, + mime_type: string, + file_size_bytes: number, + sha256_hash: string, + version: number, + is_current_version: boolean, + upload_source: string, + journal_entry_id: string, + journal_entry_line_id: string, + created_at: string + }, + meta: { + request_id: string, + api_version: string, + next_cursor?: string, + audit?: { voucher_number?: string, voucher_url?: string, audit_trail_url?: string, immutable_at?: string }, + partial_expansions?: string[] + } +} +``` + +--- + +### `GET /api/v1/companies/{companyId}/documents/{id}/download` + +**Get a time-limited signed download URL for a document.** +`scope:documents:read · risk:low · idempotent` + +Returns a Supabase Storage signed URL valid for 15 minutes. The URL itself is the canonical download: fetch it with any HTTP client; no API key needed on the storage host. Verify file integrity client-side against the returned sha256_hash if your workflow requires it. + +**Use when:** You need the bytes of an archived document (e.g. for OCR, attachment to an email, regulatory export). Always re-fetch the URL before each download: old URLs expire. +**Do not use for:** Persisting the URL anywhere: it expires. Storing the URL in a webhook payload or audit log makes the audit trail dependent on URL state. + +**Pitfalls:** +- The signed URL expires after 15 minutes. Don't cache it beyond the immediate transaction. +- The URL leaks the Supabase Storage origin; this is benign (the signature alone authorizes the read) but rate-limit any forwarding so you don't reveal the storage layout to untrusted callers. +- Each call emits a document.accessed event. Polling this endpoint produces audit noise; cache the URL for its full TTL. + +| Parameter | In | Type | Required | Notes | +|---|---|---|---|---| +| `companyId` | path | `string` | yes | | +| `id` | path | `string` | yes | | + +Response `200`: +```ts +{ + data: { + id: string, + file_name: string, + mime_type: string, + sha256_hash: string, + is_current_version: boolean, + download_url: string, + expires_in_seconds: number + }, + meta: { + request_id: string, + api_version: string, + next_cursor?: string, + audit?: { voucher_number?: string, voucher_url?: string, audit_trail_url?: string, immutable_at?: string }, + partial_expansions?: string[] + } +} +``` + +--- + +### `POST /api/v1/companies/{companyId}/documents/{id}/link` + +**Link a document to a journal entry.** +`scope:documents:write · risk:medium · idempotent · dry-run` + +Sets journal_entry_id (and optionally journal_entry_line_id) on an existing document. Optionally stamps the originating invoice_inbox_items row as consumed via inbox_item_id. Use this after /documents upload when the link target was unknown at upload time, or to re-link a stray document. Once the target JE is posted, the document row is effectively immutable per BFL 7 kap retention. + +**Use when:** A document was uploaded without a journal_entry_id (e.g. bulk import) and you now want to attach it to a posted verifikation. Pass inbox_item_id when the document came from the invoice inbox so the item is marked resolved in one call. +**Do not use for:** Unlinking: no v1 unlink endpoint. The dashboard exposes a manual override; v1 keeps the WORM contract by refusing to revert posted-JE links. + +**Pitfalls:** +- Idempotency-Key is mandatory. +- Both the document and the journal_entry_id must belong to the caller's company. NOT_FOUND on mismatch (enumeration hardening). +- Re-linking an already-linked document overwrites the previous journal_entry_id: confirm the old target is what you intend to break. +- inbox_item_id stamping is best-effort: if the stamp fails the document link still succeeds. Use POST /api/v1/companies/:companyId/inbox-items/:id/stamp to stamp independently. + +| Parameter | In | Type | Required | Notes | +|---|---|---|---|---| +| `companyId` | path | `string` | yes | | +| `id` | path | `string` | yes | | + +Request body: +```ts +{ journal_entry_id: string, journal_entry_line_id?: string, inbox_item_id?: string } +``` + +Response `200`: +```ts +{ + data: { id: string, journal_entry_id: string, journal_entry_line_id: string, file_name: string }, + meta: { + request_id: string, + api_version: string, + next_cursor?: string, + audit?: { voucher_number?: string, voucher_url?: string, audit_trail_url?: string, immutable_at?: string }, + partial_expansions?: string[] + } +} +``` + +--- + +### `POST /api/v1/companies/{companyId}/inbox-items/{id}/stamp` + +**Mark an inbox item as consumed by a journal entry.** +`scope:documents:write · risk:low · idempotent` + +Sets created_journal_entry_id on an invoice_inbox_items row so the item drops out of the active inbox todo list. Use when the document was linked to a JE via a separate call and you need to close the inbox item independently. + +**Use when:** An inbox document has already been attached to a verifikation (via documents link) but the inbox item itself was not stamped at link time: e.g. when using the v1 link endpoint without inbox_item_id. +**Do not use for:** Creating a new journal entry from an inbox item: use the invoice-inbox extension book-direct route for that. + +**Pitfalls:** +- Idempotency-Key is mandatory. +- The inbox item and journal_entry_id must both belong to the caller's company. +- Stamping with a different journal_entry_id than the one already set returns CONFLICT: the item is already resolved. + +| Parameter | In | Type | Required | Notes | +|---|---|---|---|---| +| `companyId` | path | `string` | yes | | +| `id` | path | `string` | yes | | + +Request body: +```ts +{ journal_entry_id: string } +``` + +Response `200`: +```ts +{ + data: { id: string, created_journal_entry_id: string }, + meta: { + request_id: string, + api_version: string, + next_cursor?: string, + audit?: { voucher_number?: string, voucher_url?: string, audit_trail_url?: string, immutable_at?: string }, + partial_expansions?: string[] + } +} +``` diff --git a/skills/accounted-api/references/employees.md b/skills/accounted-api/references/employees.md new file mode 100644 index 00000000..44cb56a9 --- /dev/null +++ b/skills/accounted-api/references/employees.md @@ -0,0 +1,728 @@ + + +# Employees endpoints + +The employee register plus absence (frånvaro), vacation balances and year close, and payroll cutover opening balances. Running payroll itself: salary-runs.md. + +Conventions (auth, envelope, pagination, dry-run, idempotency, standard errors) +are in SKILL.md and are not repeated per endpoint. + +### `GET /api/v1/companies/{companyId}/employees` + +**List employees for a company.** +`scope:payroll:read · risk:low · idempotent` + +Returns active employees in created-first order. Pass ?include_inactive=true to include soft-deleted (is_active=false) rows. Use ?search to match against first or last name. Personnummer is masked (birthdate visible, last-4 hidden); use GET /employees/{id} for the full value. + +**Use when:** You need a roster: for building a UI picker, resolving employee_id before adding to a salary run, or syncing an external HR system. +**Do not use for:** Fetching a single employee you already know the id of: use GET /api/v1/companies/{companyId}/employees/{id}. Salary calculations live on /salary-runs/{id}. + +**Pitfalls:** +- Inactive employees are hidden by default; soft-delete via DELETE sets is_active=false (BFL 7 kap retention). +- personnummer is masked in the list response (GDPR Art.5(1)(c) data minimisation). The detail endpoint returns the full value. +- salary_type drives which field is meaningful: monthly_salary for monthly, hourly_rate for hourly. The other is null. + +| Parameter | In | Type | Required | Notes | +|---|---|---|---|---| +| `companyId` | path | `string` | yes | | + +Response `200`: +```ts +{ + data: { id: string, first_name: string, last_name: string, personnummer_masked: string, employment_type: "employee" | "company_owner" | "board_member", employment_start: string, employment_end: string, salary_type: "monthly" | "hourly", monthly_salary: number, hourly_rate: number, f_skatt_status: "a_skatt" | "f_skatt" | "fa_skatt" | "not_verified", is_active: boolean, created_at: string }[], + meta: { + request_id: string, + api_version: string, + next_cursor?: string, + audit?: { voucher_number?: string, voucher_url?: string, audit_trail_url?: string, immutable_at?: string }, + partial_expansions?: string[] + } +} +``` + +--- + +### `POST /api/v1/companies/{companyId}/employees` + +**Create an employee.** +`scope:payroll:write · risk:low · idempotent · dry-run · reversible` + +Creates a new employee for the company. Requires Idempotency-Key (UUID). Supports ?dry_run=true for input validation without committing. The personnummer in the request body must be 12 digits (ÅÅÅÅMMDDNNNN); the response echoes a masked form (birthdate + XXXX): GDPR Art.5(1)(c). + +**Use when:** You need to register a new employee before adding them to a salary run. Use dry-run first to catch validation errors (missing tax table, salary amount, F-skatt mismatch) before committing. +**Do not use for:** Updating an existing employee (PATCH instead). Soft-deactivating (DELETE: sets is_active=false). Hard-deleting (the API does not expose hard delete; BFL 7 kap retention). + +**Pitfalls:** +- Idempotency-Key is mandatory: calls without it return 400 VALIDATION_ERROR. +- personnummer must be exactly 12 digits with the YYYYMMDD prefix (not the short 10-digit form). +- Duplicate personnummer within a company returns 409 EMPLOYEE_DUPLICATE_PERSONNUMMER. Personnummer is unique per (company_id, personnummer). +- For A-skatt employees who are not sidoinkomst, tax_table_number is required (29-42). +- salary_type drives which salary field is required: monthly_salary for monthly, hourly_rate for hourly. +- The response masks personnummer; never echo back the supplied value. Detail endpoint (deliberate drill-in) returns the full value. + +| Parameter | In | Type | Required | Notes | +|---|---|---|---|---| +| `companyId` | path | `string` | yes | | + +Request body: +```ts +{ + first_name: string, + last_name: string, + personnummer: string, + employment_type?: "employee" | "company_owner" | "board_member", + employment_start: string, + employment_end?: string, + employment_degree?: number, + hours_per_week?: number, + workdays_per_week?: number, + salary_type?: "monthly" | "hourly", + monthly_salary?: number, + hourly_rate?: number, + tax_table_number?: number, + tax_column?: number, + tax_municipality?: string, + is_sidoinkomst?: boolean, + f_skatt_status?: "a_skatt" | "f_skatt" | "fa_skatt" | "not_verified", + clearing_number?: string, + bank_account_number?: string, + vacation_rule?: "procentregeln" | "sammaloneregeln" | "none" | "semesterersattning", + vacation_days_per_year?: number, + semestertillagg_rate?: number, + email?: string, + phone?: string, + address_line1?: string, + postal_code?: string, + city?: string, + vaxa_stod_eligible?: boolean, + vaxa_stod_start?: string, + vaxa_stod_end?: string, + jamkning_percentage?: number, + jamkning_valid_from?: string, + jamkning_valid_to?: string, + default_dimensions?: Record +} +``` + +Response `200`: +```ts +{ + data: { + id: string, + first_name: string, + last_name: string, + personnummer_masked: string, + employment_type: "employee" | "company_owner" | "board_member", + employment_start: string, + employment_end: string, + employment_degree: number, + salary_type: "monthly" | "hourly", + monthly_salary: number, + hourly_rate: number, + tax_table_number: number, + tax_column: number, + tax_municipality: string, + is_sidoinkomst: boolean, + f_skatt_status: "a_skatt" | "f_skatt" | "fa_skatt" | "not_verified", + vacation_rule: string, + vacation_days_per_year: number, + is_active: boolean, + created_at: string + }, + meta: { + request_id: string, + api_version: string, + next_cursor?: string, + audit?: { voucher_number?: string, voucher_url?: string, audit_trail_url?: string, immutable_at?: string }, + partial_expansions?: string[] + } +} +``` + +--- + +### `GET /api/v1/companies/{companyId}/employees/{id}` + +**Get a single employee.** +`scope:payroll:read · risk:low · idempotent` + +Returns the full employee record including the 12-digit personnummer, bank details, tax configuration, and contact info. This is the deliberate drill-in for an id you already know: list calls mask personnummer. + +**Use when:** You have an employee id and need every field (tax table, bank account, vacation rule): typically to render an edit form or to construct a payroll calculation input. +**Do not use for:** Rosters or pickers (use the list endpoint: personnummer is masked there). + +**Pitfalls:** +- The response includes the full personnummer. Treat it as a national identifier (GDPR Art.5(1)(c)): do not propagate it to logs or external systems beyond what your integration strictly requires. +- Inactive (soft-deleted) employees are returned by the detail endpoint; check `is_active` if your flow should skip them. + +| Parameter | In | Type | Required | Notes | +|---|---|---|---|---| +| `companyId` | path | `string` | yes | | +| `id` | path | `string` | yes | | + +Response `200`: +```ts +{ + data: { + id: string, + first_name: string, + last_name: string, + personnummer: string, + employment_type: "employee" | "company_owner" | "board_member", + employment_start: string, + employment_end: string, + employment_degree: number, + hours_per_week: number, + workdays_per_week: number, + salary_type: "monthly" | "hourly", + monthly_salary: number, + hourly_rate: number, + tax_table_number: number, + tax_column: number, + tax_municipality: string, + is_sidoinkomst: boolean, + f_skatt_status: "a_skatt" | "f_skatt" | "fa_skatt" | "not_verified", + clearing_number: string, + bank_account_number: string, + vacation_rule: string, + vacation_days_per_year: number, + semestertillagg_rate: number, + email: string, + phone: string, + address_line1: string, + postal_code: string, + city: string, + vaxa_stod_eligible: boolean, + vaxa_stod_start: string, + vaxa_stod_end: string, + jamkning_percentage: number, + jamkning_valid_from: string, + jamkning_valid_to: string, + default_dimensions: Record, + is_active: boolean, + created_at: string, + updated_at: string + }, + meta: { + request_id: string, + api_version: string, + next_cursor?: string, + audit?: { voucher_number?: string, voucher_url?: string, audit_trail_url?: string, immutable_at?: string }, + partial_expansions?: string[] + } +} +``` + +--- + +### `PATCH /api/v1/companies/{companyId}/employees/{id}` + +**Update an employee.** +`scope:payroll:write · risk:low · idempotent · dry-run` + +Partial update of an employee. Only the fields supplied in the body are changed. Supports ?dry_run=true to validate the merged record without committing. Personnummer changes are NOT permitted via this endpoint: the natural-person identity is immutable post-creation. + +**Use when:** You need to change tax configuration, bank details, salary amount, or contact info on an existing employee. +**Do not use for:** Changing personnummer (not supported: create a new employee if the natural-person identity changes, which is a rare edge case). Soft-deleting (use DELETE). + +**Pitfalls:** +- personnummer in the body is ignored by this endpoint. To change it you must DELETE and recreate. +- salary_type changes require the matching salary field in the same request: switching to monthly without monthly_salary returns 400. +- tax_table_number changes only take effect on future salary runs; runs already in `review` or beyond use a frozen snapshot. + +| Parameter | In | Type | Required | Notes | +|---|---|---|---|---| +| `companyId` | path | `string` | yes | | +| `id` | path | `string` | yes | | + +Request body: +```ts +{ + first_name?: string, + last_name?: string, + personnummer?: string, + employment_type?: "employee" | "company_owner" | "board_member", + employment_start?: string, + employment_end?: string, + employment_degree?: number, + hours_per_week?: number, + workdays_per_week?: number, + salary_type?: "monthly" | "hourly", + monthly_salary?: number, + hourly_rate?: number, + tax_table_number?: number, + tax_column?: number, + tax_municipality?: string, + is_sidoinkomst?: boolean, + f_skatt_status?: "a_skatt" | "f_skatt" | "fa_skatt" | "not_verified", + clearing_number?: string, + bank_account_number?: string, + vacation_rule?: "procentregeln" | "sammaloneregeln" | "none" | "semesterersattning", + vacation_days_per_year?: number, + semestertillagg_rate?: number, + email?: string, + phone?: string, + address_line1?: string, + postal_code?: string, + city?: string, + vaxa_stod_eligible?: boolean, + vaxa_stod_start?: string, + vaxa_stod_end?: string, + jamkning_percentage?: number, + jamkning_valid_from?: string, + jamkning_valid_to?: string, + default_dimensions?: Record +} +``` + +Response `200`: +```ts +{ + data: { + id: string, + first_name: string, + last_name: string, + employment_type: "employee" | "company_owner" | "board_member", + employment_start: string, + employment_end: string, + employment_degree: number, + hours_per_week: number, + workdays_per_week: number, + salary_type: "monthly" | "hourly", + monthly_salary: number, + hourly_rate: number, + tax_table_number: number, + tax_column: number, + tax_municipality: string, + is_sidoinkomst: boolean, + f_skatt_status: "a_skatt" | "f_skatt" | "fa_skatt" | "not_verified", + clearing_number: string, + bank_account_number: string, + vacation_rule: string, + vacation_days_per_year: number, + semestertillagg_rate: number, + email: string, + phone: string, + address_line1: string, + postal_code: string, + city: string, + vaxa_stod_eligible: boolean, + vaxa_stod_start: string, + vaxa_stod_end: string, + jamkning_percentage: number, + jamkning_valid_from: string, + jamkning_valid_to: string, + default_dimensions: Record, + is_active: boolean, + created_at: string, + updated_at: string, + personnummer_masked: string + }, + meta: { + request_id: string, + api_version: string, + next_cursor?: string, + audit?: { voucher_number?: string, voucher_url?: string, audit_trail_url?: string, immutable_at?: string }, + partial_expansions?: string[] + } +} +``` + +--- + +### `DELETE /api/v1/companies/{companyId}/employees/{id}` + +**Soft-delete an employee.** +`scope:payroll:write · risk:low · idempotent · dry-run · reversible` + +Sets `is_active=false`. The row is preserved because past salary runs reference it via salary_run_employees and those verifikationer are räkenskapsinformation under BFL 7 kap (BFL retention attaches to the verifikationer themselves, not strictly to the personnummer attribute on the master row). Hard delete is never exposed. + +**Use when:** An employee has left the company and should no longer appear in active rosters or default to new salary runs. +**Do not use for:** Reactivating later (PATCH `is_active=true` instead). Hard-deleting (not supported: retention). + +**Pitfalls:** +- Idempotent: deleting an already-inactive employee returns 204 No Content (the same as the first call). +- The row is NOT removed from the database: re-creating with the same personnummer returns 409 EMPLOYEE_DUPLICATE_PERSONNUMMER even after soft-delete. +- Past salary runs still reference this employee; their data continues to surface in GET /salary-runs/{id} and SIE exports. + +| Parameter | In | Type | Required | Notes | +|---|---|---|---|---| +| `companyId` | path | `string` | yes | | +| `id` | path | `string` | yes | | + +Response `204`. + +--- + +### `GET /api/v1/companies/{companyId}/employees/{id}/absence` + +**List absence days for an employee in a date range.** +`scope:payroll:read · risk:low · idempotent` + +Returns per-day absence rows (sick, vab, parental, ...) between ?from and ?to (inclusive, max 92 days). No cursor pagination: the bounded range is the page. Optional ?type filter. + +**Use when:** You need an employee's registered absence: to reconcile with an external time-tracking system, to verify what the salary engine will derive, or to display a calendar. +**Do not use for:** The derived pay impact (karensavdrag, sjuklön lines): that lives on the payslip detail after :calculate. Worked hours for hourly staff: separate register, not on v1 yet. + +**Pitfalls:** +- Ranges over 92 days return 400 ABSENCE_RANGE_TOO_LARGE: iterate quarters instead. +- A day can carry multiple rows with different absence_type values (e.g. half-day sick + half-day vab). +- Rows may reference the salary run that consumed them via salary_run_employee_id. + +| Parameter | In | Type | Required | Notes | +|---|---|---|---|---| +| `companyId` | path | `string` | yes | | +| `id` | path | `string` | yes | | + +Response `200`: +```ts +{ + data: { salary_absence_day_id: string, absence_date: string, absence_type: "sick" | "vab" | "parental" | "pregnancy" | "care_relative" | "study" | "unpaid_leave" | "other_leave", hours: number, notes: string, salary_run_employee_id: string, created_at: string, updated_at: string }[], + meta: { + request_id: string, + api_version: string, + next_cursor?: string, + audit?: { voucher_number?: string, voucher_url?: string, audit_trail_url?: string, immutable_at?: string }, + partial_expansions?: string[] + } +} +``` + +--- + +### `PUT /api/v1/companies/{companyId}/employees/{id}/absence` + +**Register absence for an employee over a date range.** +`scope:payroll:write · risk:low · idempotent · dry-run · reversible` + +Expands [from, to] (max 92 days) to per-day rows and upserts them on the natural key (employee, date, type). Weekends are skipped unless include_weekends=true. Single day = from == to. Idempotent by construction: replaying the same PUT converges on the same rows. + +**Use when:** "Anna was sick 3-7 March": one call registers the whole event. Also for pre-cutover history backfill when migrating from another payroll system (any past date is legal; imported sick days feed the karensavdrag lookback). +**Do not use for:** Vacation day REQUESTS/approval workflows (out of scope). Editing hours on one existing day inside a range: PUT the single day (from == to) with the new hours. + +**Pitfalls:** +- Weekends are skipped by default: pass include_weekends=true for schedules that span them. +- Upsert REPLACES the (date, type) rows in the range: hours/notes are overwritten, not merged. +- A day whose combined absence + worked hours exceed 24h returns 409 ABSENCE_HOURS_CONFLICT and the whole range is rejected (atomic). +- Registering absence does not recompute an open salary run: call POST /salary-runs/{id}/calculate afterwards. + +| Parameter | In | Type | Required | Notes | +|---|---|---|---|---| +| `companyId` | path | `string` | yes | | +| `id` | path | `string` | yes | | + +Request body: +```ts +{ + from: string, + to: string, + absence_type: "sick" | "vab" | "parental" | "pregnancy" | "care_relative" | "study" | "unpaid_leave" | "other_leave", + hours_per_day?: number, + notes?: string, + include_weekends?: boolean +} +``` + +Response `200`: +```ts +{ + data: { + count: number, + days: { salary_absence_day_id?: string, absence_date: string, absence_type: "sick" | "vab" | "parental" | "pregnancy" | "care_relative" | "study" | "unpaid_leave" | "other_leave", hours: number, notes?: string, salary_run_employee_id?: string, created_at?: string, updated_at?: string }[] + }, + meta: { + request_id: string, + api_version: string, + next_cursor?: string, + audit?: { voucher_number?: string, voucher_url?: string, audit_trail_url?: string, immutable_at?: string }, + partial_expansions?: string[] + } +} +``` + +--- + +### `DELETE /api/v1/companies/{companyId}/employees/{id}/absence` + +**Delete absence days for an employee in a date range.** +`scope:payroll:write · risk:low · idempotent · dry-run` + +Deletes per-day absence rows between ?from and ?to (inclusive), optionally filtered by ?type. Returns deleted_count (200, not 204) so callers can verify how many rows went. + +**Use when:** An absence event was registered by mistake or ended early: "Anna came back Thursday, delete Thu-Fri sick days". +**Do not use for:** Correcting hours on a day: PUT the day again instead. Rows already consumed by a BOOKED run: deleting them does not un-book the run; use the run correction flow. + +**Pitfalls:** +- Without ?type, ALL absence types in the range are deleted. +- deleted_count: 0 with a 200 means nothing matched: not an error. + +| Parameter | In | Type | Required | Notes | +|---|---|---|---|---| +| `companyId` | path | `string` | yes | | +| `id` | path | `string` | yes | | + +Response `200`: +```ts +{ + data: { deleted_count: number }, + meta: { + request_id: string, + api_version: string, + next_cursor?: string, + audit?: { voucher_number?: string, voucher_url?: string, audit_trail_url?: string, immutable_at?: string }, + partial_expansions?: string[] + } +} +``` + +--- + +### `GET /api/v1/companies/{companyId}/employees/{id}/opening-balances` + +**Get an employee's payroll cutover opening balances.** +`scope:payroll:read · risk:low · idempotent` + +Returns the opening balances set for a mid-year migration (YTD gross/tax/net, vacation balances, opening semesterlöneskuld, karens adjustment) plus the lock state: locked=true once the employee has a booked salary run. + +**Use when:** Verifying cutover state before the first calculated run, or checking whether balances can still be edited (locked=false). +**Do not use for:** The live vacation liability (GET /reports/vacation-liability includes the opening terms). Pre-cutover absence history: GET /employees/{id}/absence. + +**Pitfalls:** +- 404 NOT_FOUND when no opening balances have been set: distinct from an all-zeros row. +- locked_by_run_id names the booked run that froze the row; correcting that run unlocks it. + +| Parameter | In | Type | Required | Notes | +|---|---|---|---|---| +| `companyId` | path | `string` | yes | | +| `id` | path | `string` | yes | | + +Response `200`: +```ts +{ + data: { + employee_opening_balances_id: string, + employee_id: string, + cutover_date: string, + ytd_gross: number, + ytd_tax: number, + ytd_net: number, + vacation_paid_days_remaining: number, + vacation_days_taken_this_year: number, + vacation_saved_days_by_year: Record, + opening_semester_liability: number, + opening_semester_liability_avgifter: number, + karens_periods_adjustment: number, + locked: boolean, + locked_by_run_id: string + }, + meta: { + request_id: string, + api_version: string, + next_cursor?: string, + audit?: { voucher_number?: string, voucher_url?: string, audit_trail_url?: string, immutable_at?: string }, + partial_expansions?: string[] + } +} +``` + +--- + +### `PUT /api/v1/companies/{companyId}/employees/{id}/opening-balances` + +**Set an employee's payroll cutover opening balances.** +`scope:payroll:write · risk:medium · idempotent · dry-run · reversible` + +Full-replace upsert of the cutover state: YTD gross/tax/net for the cutover year, paid vacation days remaining, paid days already taken this vacation year, sparade dagar keyed by origin year (5-year rule), opening semesterlöneskuld SEK (+avgifter), and karens periods not covered by imported absence rows. cutover_date must be the first of a month in the current or previous year, on/after employment_start. + +**Use when:** Onboarding one employee during a mid-year migration from Fortnox/Visma/etc. For whole-company onboarding, prefer the bulk PUT /employees/opening-balances. +**Do not use for:** SIE opening balances on the LEDGER (2920/2940 arrive via the SIE import). Ongoing sick cases: import pre-cutover days via PUT /employees/{id}/absence instead. + +**Pitfalls:** +- Full replace: omitted numeric fields reset to 0 (their defaults). Send the complete state every time. +- 409 OPENING_BALANCES_LOCKED once the employee has a booked run; correcting that run unlocks. +- The opening liability is NOT booked by Accounted: it only feeds the vacation-liability report. +- YTD affects payslip display and reports only; per-month tax and avgifter caps never read it. + +| Parameter | In | Type | Required | Notes | +|---|---|---|---|---| +| `companyId` | path | `string` | yes | | +| `id` | path | `string` | yes | | + +Request body: +```ts +{ + cutover_date: string, + ytd_gross?: number, + ytd_tax?: number, + ytd_net?: number, + vacation_paid_days_remaining?: number, + vacation_days_taken_this_year?: number, + vacation_saved_days_by_year?: Record, + opening_semester_liability?: number, + opening_semester_liability_avgifter?: number, + karens_periods_adjustment?: number +} +``` + +Response `200`: +```ts +{ + data: { + employee_opening_balances_id: string, + employee_id: string, + cutover_date: string, + ytd_gross: number, + ytd_tax: number, + ytd_net: number, + vacation_paid_days_remaining: number, + vacation_days_taken_this_year: number, + vacation_saved_days_by_year: Record, + opening_semester_liability: number, + opening_semester_liability_avgifter: number, + karens_periods_adjustment: number, + locked: boolean, + locked_by_run_id: string + }, + meta: { + request_id: string, + api_version: string, + next_cursor?: string, + audit?: { voucher_number?: string, voucher_url?: string, audit_trail_url?: string, immutable_at?: string }, + partial_expansions?: string[] + } +} +``` + +--- + +### `GET /api/v1/companies/{companyId}/employees/{id}/vacation-balance` + +**Get an employee's current vacation balance.** +`scope:payroll:read · risk:low · idempotent` + +Returns the open vacation-ledger row (recomputed on every booking): entitled/taken/remaining days, sparade dagar keyed by origin year (Semesterlagen 5-year rule), forced-payout days from expired savings, and a computed SEK estimate of the individual semesterlöneskuld. + +**Use when:** Answering "how many vacation days does Anna have left", pre-payroll review, or preparing the year-close. +**Do not use for:** The company-wide liability report: GET /reports/vacation-liability. Closing the year: POST /salary/vacation-year-close. + +**Pitfalls:** +- 404 VACATION_BALANCE_NOT_FOUND until the first booking (or year-close) touches the employee: the ledger seeds lazily. +- remaining_days can go negative if more days were taken than entitled: surface it, do not clamp. +- The SEK estimate uses the year-close day valuation (simplified BFNAR 2016:10); the booked 2920 is reconciled only at year-close. + +| Parameter | In | Type | Required | Notes | +|---|---|---|---|---| +| `companyId` | path | `string` | yes | | +| `id` | path | `string` | yes | | + +Response `200`: +```ts +{ + data: { + employee_vacation_balance_id: string, + employee_id: string, + vacation_year_start: string, + entitled_days: number, + accrued_days: number, + taken_days: number, + remaining_days: number, + saved_days: Record, + saved_days_total: number, + forced_payout_days: number, + estimated_liability_sek: number + }, + meta: { + request_id: string, + api_version: string, + next_cursor?: string, + audit?: { voucher_number?: string, voucher_url?: string, audit_trail_url?: string, immutable_at?: string }, + partial_expansions?: string[] + } +} +``` + +--- + +### `PUT /api/v1/companies/{companyId}/employees/opening-balances` + +**Bulk-set payroll cutover opening balances (atomic).** +`scope:payroll:write · risk:medium · idempotent · dry-run · reversible` + +Upserts opening balances for up to 200 employees in one call. Validation is all-or-nothing: any invalid item (unknown/inactive employee, cutover before employment_start, locked by a booked run) fails the WHOLE request with a per-item error list and zero writes. + +**Use when:** Onboarding a whole company mid-year from another payroll system: one call per migration file instead of N sequential PUTs. +**Do not use for:** Single-employee corrections after go-live: PUT /employees/{id}/opening-balances. Ledger opening balances (SIE import). + +**Pitfalls:** +- Atomic: one bad item fails everything. The error details carry item_errors[{index, employee_id, code, message}]: fix and resubmit the full set. +- Full replace per employee: resubmitting with fewer fields resets the omitted ones to 0. +- Duplicate employee_id within items is rejected outright. + +| Parameter | In | Type | Required | Notes | +|---|---|---|---|---| +| `companyId` | path | `string` | yes | | + +Request body: +```ts +{ + items: { employee_id: string, cutover_date: string, ytd_gross?: number, ytd_tax?: number, ytd_net?: number, vacation_paid_days_remaining?: number, vacation_days_taken_this_year?: number, vacation_saved_days_by_year?: Record, opening_semester_liability?: number, opening_semester_liability_avgifter?: number, karens_periods_adjustment?: number }[] +} +``` + +Response `200`: +```ts +{ + data: { + count: number, + rows: { employee_opening_balances_id: string, employee_id: string, cutover_date: string, locked: boolean }[] + }, + meta: { + request_id: string, + api_version: string, + next_cursor?: string, + audit?: { voucher_number?: string, voucher_url?: string, audit_trail_url?: string, immutable_at?: string }, + partial_expansions?: string[] + } +} +``` + +--- + +### `POST /api/v1/companies/{companyId}/salary/vacation-year-close` + +**Close a vacation year (semesterberedning + arsavslut).** +`scope:payroll:write · risk:high · idempotent · dry-run` + +Rolls every active employee's vacation balances into the next year (only days above the 20-day must-take floor are saved; saved days older than 5 years become forced payouts) and reconciles the day-valued semesterlöneskuld against the booked 2920/2940, posting one adjustment verifikation when drift exceeds 1 kr. The frozen report is stored with the closure (BFL 7 kap). + +**Use when:** Once per year after the vacation year ends (Jan for calendar basis, Apr for statutory). ALWAYS dry-run first and review the report: the close is not reversible via API. +**Do not use for:** Mid-year balance corrections (fix the source: absence days, opening balances, or run corrections). Paying out expired days (create a semesterersattning line in the next salary run: the close only flags them). + +**Pitfalls:** +- dry_run=true returns the full review report with zero writes: treat it as mandatory before the live call. +- 409 VACATION_YEAR_ALREADY_CLOSED on replay: the closure row is the idempotency anchor. +- 423-style PERIOD_LOCKED when the adjustment date falls in a locked period: unlock or close without adjustment (book_adjustment=false) and post manually. +- Untaken days at or below the 20-day floor are flagged in the report, NOT auto-saved (Semesterlagen 18 §). + +| Parameter | In | Type | Required | Notes | +|---|---|---|---|---| +| `companyId` | path | `string` | yes | | + +Request body: +```ts +{ vacation_year_start?: string, book_adjustment?: boolean } +``` + +Response `200`: +```ts +{ + data: { vacation_year_closure_id: string, adjustment_entry_id: string, report: unknown }, + meta: { + request_id: string, + api_version: string, + next_cursor?: string, + audit?: { voucher_number?: string, voucher_url?: string, audit_trail_url?: string, immutable_at?: string }, + partial_expansions?: string[] + } +} +``` diff --git a/skills/accounted-api/references/invoices.md b/skills/accounted-api/references/invoices.md new file mode 100644 index 00000000..17017f9a --- /dev/null +++ b/skills/accounted-api/references/invoices.md @@ -0,0 +1,523 @@ + + +# Invoices (AR) endpoints + +Accounts receivable invoices: draft -> send -> paid/credited; the F-series number is assigned at send, not create. Supplier bills you receive are a different resource: see suppliers.md. Customer and article registers: customers.md. + +Conventions (auth, envelope, pagination, dry-run, idempotency, standard errors) +are in SKILL.md and are not repeated per endpoint. + +### `GET /api/v1/companies/{companyId}/invoices` + +**List invoices for a company.** +`scope:invoices:read · risk:low · idempotent` + +Cursor-paginated invoice list ordered by created_at DESC, id ASC (newest-registered first; the `invoice_date` column is the business date and is filterable via ?date_from / ?date_to but is not the sort key). Includes the customer name inline; pass ?expand=customer for the full customer record, ?expand=items for line items. + +**Use when:** You need to enumerate invoices for a company: for AR reporting, payment matching, or building an invoice dashboard. +**Do not use for:** Fetching a single invoice you already know the id of: use GET /api/v1/companies/{companyId}/invoices/{id}. Supplier invoices are a different resource (supplier-invoices). + +**Pitfalls:** +- Draft invoices have invoice_number=null until they are sent. +- remaining_amount is the unpaid portion (total − paid_amount); use status=paid or remaining_amount=0 to filter for closed invoices. +- Credit notes appear with status=credited and a credited_invoice_id field on the detail endpoint. +- Ordering is by created_at (registration time), not invoice_date. Backdated invoices therefore appear where they were created, not where their date falls: filter on ?date_from / ?date_to when you care about the business date. +- Cursor pagination: pass ?cursor= from the previous response. A stale or tampered cursor is ignored and the first page is returned again. + +| Parameter | In | Type | Required | Notes | +|---|---|---|---|---| +| `companyId` | path | `string` | yes | | + +Response `200`: +```ts +{ + data: { id: string, invoice_number: string, customer_id: string, customer_name: string, invoice_date: string, due_date: string, status: "draft" | "sent" | "paid" | "partially_paid" | "overdue" | "cancelled" | "credited", document_type: "invoice" | "proforma" | "delivery_note", currency: string, subtotal: number, vat_amount: number, total: number, remaining_amount: number, paid_at: string, created_at: string }[], + meta: { + request_id: string, + api_version: string, + next_cursor?: string, + audit?: { voucher_number?: string, voucher_url?: string, audit_trail_url?: string, immutable_at?: string }, + partial_expansions?: string[] + } +} +``` + +--- + +### `POST /api/v1/companies/{companyId}/invoices` + +**Create a draft invoice, proforma, or delivery note.** +`scope:invoices:write · risk:medium · idempotent · dry-run · reversible` + +Creates an invoice in draft status. The F-series invoice_number is allocated atomically on the first send action (PR-B-2b). Per-item VAT rates are validated against the customer's allowed rates (mixed-rate invoices supported). Non-SEK invoices are converted to SEK at the Riksbanken exchange rate fetched at create time. Supports ROT/RUT deduction lines (items[].deduction_type = "rot"|"rut" with invoice-level deduction_personnummer + deduction_housing_designation, or deduction_apartment_number + deduction_brf_org_number for bostadsrätt), article linkage (items[].article_id + optional revenue_account override from the artikelregister), and project/cost-centre tagging (default_dimensions / items[].dimensions). Idempotent (mandatory Idempotency-Key). Dry-runnable: the preview returns the validated would-be invoice + items with computed totals; no journal entry is involved at draft stage (posting happens on :send). Set is_self_billed=true (with external_invoice_number + received_date) to instead register a received self-billing invoice (mottagen självfaktura, ML 17 kap 15§): a sale booked immediately with the counterparty's number, not a draft. + +**Use when:** You need to issue a new invoice, proforma, or delivery note. Use dry-run first to confirm VAT calculations and currency conversion before committing. +**Do not use for:** Updating an existing invoice (PATCH instead, drafts only). Issuing a credit note (use POST /:id:credit in PR-B-2b). Posting a previously-created draft to the journal (use POST /:id:send in PR-B-2b). + +**Pitfalls:** +- Idempotency-Key is mandatory; calls without it return 400. +- For mixed-rate invoices, set vat_rate per item explicitly. Items where vat_rate is omitted use the customer's default rate from getVatRules(). +- Non-SEK currencies require an active Riksbanken exchange-rate fetch. Failure is non-fatal: the invoice is created with null SEK fields and the agent can recompute later. +- invoice_number is null on creation. The number is allocated atomically when the invoice transitions out of draft. Counting on a specific number at create time is a bug. +- document_type='delivery_note' produces no VAT and a different number sequence (D-series). Most use cases want the default document_type='invoice'. +- is_self_billed=true registers a self-billing invoice your CUSTOMER issued on your behalf (a sale for you). It is booked immediately (not a draft, no F-number), so external_invoice_number and received_date are required and it is NOT dry-run-free of side effects on the live call. Do NOT set it for a normal invoice you issue yourself. +- Project/cost-center tagging: pass default_dimensions ({"6":"P001"} = project, {"1":"KS01"} = kostnadsställe) for the whole invoice and/or items[].dimensions per line (per-line wins per key). Tags are stored on the draft and applied to the journal entry lines when the invoice is sent. When the company has the dimension registry enabled, unknown or archived codes are rejected at :send with 400 DIMENSION_VALIDATION_FAILED — list valid codes via GET /dimensions. +- ROT/RUT: set items[].deduction_type ("rot"|"rut") on labor lines plus labor_hours and work_type (Skatteverket arbetstypskod). The invoice must carry deduction_personnummer AND housing info: deduction_housing_designation (fastighetsbeteckning) for småhus, or deduction_apartment_number + deduction_brf_org_number for bostadsrätt. deduction_amount is computed server-side and cannot be set by the caller; the response exposes deduction_total and remaining_amount = total - deduction_total (Skatteverket pays the rest via 1513). Validation failures return 400 INVOICE_CREATE_ROT_RUT_VALIDATION. +- Articles: pass items[].article_id (from the artikelregister, GET /articles) to link a line to a catalog article; price/description are still taken from the request body (the API never auto-fills from the article: send the values you want on the invoice). items[].revenue_account is the legacy wire name for an optional BAS class 1-3 posting-account override and is validated against the chart of accounts. + +| Parameter | In | Type | Required | Notes | +|---|---|---|---|---| +| `companyId` | path | `string` | yes | | + +Request body: +```ts +{ + customer_id: string, + invoice_date: string, + due_date: string, + delivery_date?: string | "", + currency: "SEK" | "EUR" | "USD" | "GBP" | "NOK" | "DKK", + document_type?: "invoice" | "proforma" | "delivery_note", + your_reference?: string, + our_reference?: string, + notes?: string, + payment_link_url?: string | "", + payment_link_auto?: boolean, + deduction_personnummer?: string, + deduction_housing_designation?: string, + deduction_apartment_number?: string, + deduction_brf_org_number?: string | "", + save_as_draft?: boolean, + ore_rounding?: boolean, + default_dimensions?: Record, + is_self_billed?: boolean, + external_invoice_number?: string | "", + self_billing_agreement_ref?: string, + received_date?: string | "", + items: { line_type?: "product" | "text", description: string, quantity: number, unit: string, unit_price: number, vat_rate?: number, article_id?: string, revenue_account?: string, deduction_type?: "rot" | "rut", labor_hours?: number, work_type?: string, housing_designation?: string, apartment_number?: string, brf_org_number?: string | "", accrual_period_start?: string, accrual_period_end?: string, accrual_balance_account?: string, dimensions?: Record }[] +} +``` + +Response `200`: +```ts +{ + data: { + id: string, + invoice_number: string, + customer_id: string, + invoice_date: string, + due_date: string, + status: string, + document_type: string, + currency: string, + subtotal: number, + vat_amount: number, + total: number, + remaining_amount: number, + created_at: string + }, + meta: { + request_id: string, + api_version: string, + next_cursor?: string, + audit?: { voucher_number?: string, voucher_url?: string, audit_trail_url?: string, immutable_at?: string }, + partial_expansions?: string[] + } +} +``` + +--- + +### `GET /api/v1/companies/{companyId}/invoices/{id}` + +**Retrieve a single invoice by id.** +`scope:invoices:read · risk:low · idempotent` + +Returns the full invoice record with the customer embedded. Pass ?expand=items for line items, ?expand=payments for payment history, or ?expand=items,payments for both. + +**Use when:** You have an invoice id (from a webhook, the list endpoint, or a customer transaction) and need the full record including amounts, dates, status, and the customer details. +**Do not use for:** Listing invoices (use GET /api/v1/companies/{companyId}/invoices). Bookkeeping verifikationer tied to the invoice (use the journal-entries endpoints in a later phase). + +**Pitfalls:** +- Returns 404 if the invoice does not belong to the company in the URL: does not leak existence across companies. +- paid_at and remaining_amount can lag behind the latest payment by a few seconds during high-volume reconciliation. + +| Parameter | In | Type | Required | Notes | +|---|---|---|---|---| +| `companyId` | path | `string` | yes | | +| `id` | path | `string` | yes | | + +Response `200`: +```ts +{ + data: { + id: string, + invoice_number: string, + customer_id: string, + invoice_date: string, + due_date: string, + status: string, + document_type: string, + currency: string, + total: number, + remaining_amount: number, + paid_at: string, + created_at: string + }, + meta: { + request_id: string, + api_version: string, + next_cursor?: string, + audit?: { voucher_number?: string, voucher_url?: string, audit_trail_url?: string, immutable_at?: string }, + partial_expansions?: string[] + } +} +``` + +--- + +### `PATCH /api/v1/companies/{companyId}/invoices/{id}` + +**Update a draft invoice (metadata fields, optionally replacing line items).** +`scope:invoices:write · risk:low · idempotent · dry-run · reversible` + +Partial update for invoices in draft status. Allowed fields: invoice_date, due_date, delivery_date, your_reference, our_reference, notes, default_dimensions (project/cost-centre tags, e.g. {"6":"P001"}; replaces the whole bag), and an optional items array. When items is present, it fully REPLACES the draft's line items and subtotal / VAT / total are recomputed against the invoice's existing customer (same validation as POST /invoices); when omitted, items and totals are unchanged. customer_id, currency, and document_type are immutable: replace those by deleting the draft and recreating it. Returns 409 INVOICE_UPDATE_NOT_DRAFT if the invoice is no longer in draft status. Idempotent and dry-runnable. + +**Use when:** You need to correct a typo, push the due date, update a customer reference, or rewrite the line items on a draft you have not sent yet. The invoice number stays null until the first :send action. +**Do not use for:** Updating a sent / paid / credited invoice (those are immutable per ML 17 kap; issue a credit note via POST /:id:credit in PR-B-2b). Changing currency or customer: drafts are cheap to delete and recreate. + +**Pitfalls:** +- Idempotency-Key is mandatory. +- A 409 INVOICE_UPDATE_NOT_DRAFT means the invoice has been sent / paid / credited / cancelled. The error code name is shared with the DELETE handler. +- items is a FULL REPLACE (no per-line merge): send the complete new line set, minimum one item. Omitting items keeps the current lines untouched. VAT rates are re-validated against the customer type and totals are recomputed server-side. +- items are always built against the invoice's EXISTING customer: customer_id cannot change on PATCH. +- default_dimensions replaces the entire bag (no per-key merge): read the current value first if you want to add a tag. Send {} to clear all tags. Codes are validated against the dimension registry at :send, not at PATCH time. + +| Parameter | In | Type | Required | Notes | +|---|---|---|---|---| +| `companyId` | path | `string` | yes | | +| `id` | path | `string` | yes | | + +Request body: +```ts +{ + invoice_date?: string, + due_date?: string, + delivery_date?: string | unknown, + your_reference?: string | unknown, + our_reference?: string | unknown, + notes?: string | unknown, + default_dimensions?: Record, + items?: { line_type?: "product" | "text", description: string, quantity: number, unit: string, unit_price: number, vat_rate?: number, article_id?: string, revenue_account?: string, deduction_type?: "rot" | "rut", labor_hours?: number, work_type?: string, housing_designation?: string, apartment_number?: string, brf_org_number?: string | "", accrual_period_start?: string, accrual_period_end?: string, accrual_balance_account?: string, dimensions?: Record }[] +} +``` + +Response `200`: +```ts +{ + data: { + id: string, + invoice_number: string, + customer_id: string, + invoice_date: string, + due_date: string, + status: string, + document_type: string, + currency: string, + total: number, + remaining_amount: number, + paid_at: string, + created_at: string + }, + meta: { + request_id: string, + api_version: string, + next_cursor?: string, + audit?: { voucher_number?: string, voucher_url?: string, audit_trail_url?: string, immutable_at?: string }, + partial_expansions?: string[] + } +} +``` + +--- + +### `POST /api/v1/companies/{companyId}/invoices/{id}/credit` + +**Issue a credit note (kreditfaktura) against an invoice.** +`scope:invoices:write · risk:high · idempotent · dry-run` + +Creates a credit note referencing the original invoice. The credit note carries reversed-sign amounts (matching the original line for line) and gets invoice_number=KR-. The original invoice transitions to status=credited. Under faktureringsmetoden, posts a reversing journal entry (Credit AR 1510 / Debit revenue + Debit output VAT). Under kontantmetoden the credit note still creates the row but defers the reversal entry until refund. Idempotent and dry-runnable. Emits invoice.credited. + +**Use when:** You need to legally cancel an issued invoice (ML 17 kap 22-23§). The original invoice cannot be edited once issued: credit it and reissue corrected. +**Do not use for:** Cancelling a draft (DELETE the draft instead). Refunding a partial payment without invalidating the whole invoice (book the refund manually via the journal-entries API in a future PR). + +**Pitfalls:** +- Idempotency-Key is mandatory. Retried credits with the same key replay the cached response: no duplicate credit note is created. +- The original invoice must be in sent / paid / overdue status. Drafts, cancelled invoices, and already-credited invoices are rejected with specific error codes. +- Credit-note items mirror the original's lines with negated values. To credit only part of an invoice (line-level), credit the full invoice first then reissue with the corrected lines. +- Under kontantmetoden no journal entry is created here: refund booking is deferred. A `JOURNAL_ENTRY_NOT_POSTED` warning is NOT emitted in this case (the deferral is correct, not a failure). + +| Parameter | In | Type | Required | Notes | +|---|---|---|---|---| +| `companyId` | path | `string` | yes | | +| `id` | path | `string` | yes | | + +Request body: +```ts +{ reason?: string } +``` + +Response `200`: +```ts +{ + data: { + id: string, + invoice_number: string, + credited_invoice_id: string, + status: "sent", + total: number, + journal_entry_id: string, + warnings?: { code: string, message: string }[] + }, + meta: { + request_id: string, + api_version: string, + next_cursor?: string, + audit?: { voucher_number?: string, voucher_url?: string, audit_trail_url?: string, immutable_at?: string }, + partial_expansions?: string[] + } +} +``` + +--- + +### `POST /api/v1/companies/{companyId}/invoices/{id}/mark-paid` + +**Record a payment against an invoice.** +`scope:invoices:write · risk:medium · idempotent · dry-run` + +Marks a sent / overdue invoice as paid (or partially_paid). Books the payment via Debit 1930 / Credit 1510 under faktureringsmetoden, or Debit 1930 / Credit revenue + Credit output VAT under kontantmetoden. Optional body supports partial payments via custom balanced journal lines and exchange-rate adjustments for foreign-currency invoices. Idempotent and dry-runnable. Emits invoice.paid. + +**Use when:** A customer paid an invoice via a channel other than the synced bank account (cash, manual transfer, separate processor). Use dry-run to confirm the booking before committing. +**Do not use for:** Reverting a payment: the public API does not expose unmark-paid. Issue a credit note via POST /:id/credit to cancel the underlying invoice instead. Bank-matched payments: those flow through the transactions endpoints. + +**Pitfalls:** +- Idempotency-Key is mandatory. Retried marks with the same key replay the cached response. +- Custom `lines` must balance (sum of debits = sum of credits, both > 0). Otherwise returns 400 INVOICE_PAID_LINES_UNBALANCED. +- For foreign-currency invoices, supply `exchange_rate_difference` (SEK delta vs the invoice's booked rate) to book the FX adjustment correctly. Omitting it on a non-SEK invoice will mis-book the FX gain/loss. +- Custom `lines` are journal lines and therefore SEK, while `total` / `paid_amount` / `remaining_amount` are stored in the invoice currency. The route converts the line total via `invoice.exchange_rate`; a non-SEK invoice with no exchange_rate on file returns 400 MATCH_INVOICE_BOOKING_RATE_MISSING rather than silently treating the SEK amount as invoice currency. +- Cash basis (kontantmetoden) recognizes revenue HERE, not at :mark-sent. The dashboard tracks this via company_settings.accounting_method. +- Duplicate-payment guard: if an unlinked inbound bank transaction looks like this payment, returns 409 INVOICE_PAID_LIKELY_DUPLICATE with candidate transactions. Retry with `force: true` to bypass, but the retry MUST use a fresh Idempotency-Key (the original is body-hash bound; reusing it returns 400 IDEMPOTENCY_KEY_REUSE). The guard is also evaluated under dry-run, so a successful dry-run does not guarantee a successful commit. + +| Parameter | In | Type | Required | Notes | +|---|---|---|---|---| +| `companyId` | path | `string` | yes | | +| `id` | path | `string` | yes | | + +Request body: +```ts +{ + payment_date?: string, + exchange_rate_difference?: number, + notes?: string, + lines?: { account_number: string, debit_amount?: number, credit_amount?: number, line_description?: string, dimensions?: Record }[], + force?: boolean +} +``` + +Response `200`: +```ts +{ + data: { + id: string, + invoice_number: string, + status: "paid" | "partially_paid", + total: number, + paid_amount: number, + remaining_amount: number, + paid_at: string, + journal_entry_id: string, + warnings?: { code: string, message: string }[] + }, + meta: { + request_id: string, + api_version: string, + next_cursor?: string, + audit?: { voucher_number?: string, voucher_url?: string, audit_trail_url?: string, immutable_at?: string }, + partial_expansions?: string[] + } +} +``` + +--- + +### `POST /api/v1/companies/{companyId}/invoices/{id}/mark-sent` + +**Transition a draft invoice to sent (without emailing).** +`scope:invoices:write · risk:medium · idempotent · dry-run` + +Marks a draft invoice as sent: for invoices delivered outside Accounted (Peppol, postal, manual email). Allocates the F-series invoice_number atomically (ML 17 kap 24§ p.2). On accounting_method=accrual, also posts the invoice journal entry (Debit AR 1510 / Credit revenue + output VAT). Emits invoice.sent. Idempotent and dry-runnable. The companion :send action (PR-B-2b-3) adds PDF rendering and email delivery on top of this same flow. + +**Use when:** You delivered the invoice through a channel other than Accounted's email (Peppol, postal, your own SMTP) and need to record it as sent so the F-series number is allocated and the journal entry is posted. +**Do not use for:** Sending the invoice via Accounted email: use :send (PR-B-2b-3) for that. Marking an already-sent invoice as paid: use :mark-paid (PR-B-2b-2). + +**Pitfalls:** +- Only invoices in `status=draft` can be marked sent. Other states return 409 INVOICE_UPDATE_NOT_DRAFT (re-used; the action is structurally an update). +- Allocation is atomic. If a concurrent transition beats the agent's request to the same draft, the runner-up gets 409 INVOICE_UPDATE_NOT_DRAFT and no number is consumed. +- Delivery notes (document_type=delivery_note) don't transition to sent: they were never drafts in the f-series sense. This endpoint will reject them with 400 VALIDATION_ERROR. +- Idempotency-Key is mandatory. A retried mark-sent with the same key replays the cached response. + +| Parameter | In | Type | Required | Notes | +|---|---|---|---|---| +| `companyId` | path | `string` | yes | | +| `id` | path | `string` | yes | | + +Response `200`: +```ts +{ + data: { + id: string, + invoice_number: string, + status: "sent", + total: number, + journal_entry_id: string, + warnings?: { code: string, message: string }[] + }, + meta: { + request_id: string, + api_version: string, + next_cursor?: string, + audit?: { voucher_number?: string, voucher_url?: string, audit_trail_url?: string, immutable_at?: string }, + partial_expansions?: string[] + } +} +``` + +--- + +### `GET /api/v1/companies/{companyId}/invoices/{id}/pdf` + +**Download the rendered invoice PDF.** +`scope:invoices:read · risk:low · idempotent` + +Returns the invoice as application/pdf. The descriptive filename contains company, customer, document type, invoice number or draft identifier, and invoice date. This endpoint is byte-equivalent to the dashboard download. + +**Use when:** You need to fetch an invoice PDF for archival, forwarding to a customer outside the Accounted send flow, or attaching to an external workflow. +**Do not use for:** Sending the invoice to the customer: use POST /invoices/{id}/send, which renders the PDF, emails it, and archives it as a verifikationsunderlag in one atomic step. + +**Pitfalls:** +- Drafts (no invoice_number yet) render with an "utkast" filename. The PDF carries no F-series number: do not treat it as a finalized invoice. +- PDF rendering can take several hundred milliseconds for invoices with many line items. Cache on the client if requesting repeatedly. +- Credit notes embed the original invoice's löpnummer per ML 17 kap 22-23§: if the original was hard-deleted (not possible via Accounted but theoretically via a manual DB edit), the reference is omitted. + +| Parameter | In | Type | Required | Notes | +|---|---|---|---|---| +| `companyId` | path | `string` | yes | | +| `id` | path | `string` | yes | | + +Response `200` (`application/pdf`). + +--- + +### `POST /api/v1/companies/{companyId}/invoices/{id}/send` + +**Send a draft invoice to the customer by email.** +`scope:invoices:write · risk:high · idempotent · dry-run` + +The full send pipeline: preflight PDF render → allocate F-series number atomically → final PDF render → email via Resend (PDF attachment, copy to company) → flip status to sent → post journal entry (accrual + real invoice) → archive PDF as underlag → emit invoice.sent. Email failure is a hard 502 before state changes; post-email failures surface as warnings but the invoice IS marked sent. + +**Use when:** You want Accounted to deliver the invoice to the customer via email. For invoices delivered through another channel (Peppol, postal, own SMTP) use :mark-sent instead. +**Do not use for:** Re-sending an already-sent invoice (returns 409 INVOICE_UPDATE_NOT_DRAFT). Sending a delivery note (no F-series lifecycle). Sending a credit note (use the :credit endpoint to issue the kreditfaktura; subsequent re-send of the credit note via :mark-sent is the supported path). + +**Pitfalls:** +- Idempotency-Key is mandatory. +- Email service must be configured: without RESEND_API_KEY + RESEND_FROM_EMAIL the endpoint returns 503 INVOICE_SEND_EMAIL_NOT_CONFIGURED. +- Customer must have an email address. 400 INVOICE_SEND_NO_CUSTOMER_EMAIL otherwise. +- A cancelled invoice is rejected (400 INVOICE_SEND_CANCELLED): its F-series number is preserved for compliance but the document is not a valid faktura. +- Email failure before the status flip leaves the F-series number consumed but the invoice in `draft` status. Same orphan window as :mark-sent (architecturally tracked, matches internal route). +- After the email succeeds, journal-entry/archive/event failures become warnings on the response; the invoice IS marked sent regardless. +- additional_cc and additional_bcc require the API key user to be an owner or admin of the company. +- The deprecated cc response field contains only the first address. Use cc_addresses for the complete CC list. +- BCC recipients are retained only in the restricted delivery archive and are omitted from normal and dry-run responses. + +| Parameter | In | Type | Required | Notes | +|---|---|---|---|---| +| `companyId` | path | `string` | yes | | +| `id` | path | `string` | yes | | + +Request body: +```ts +{ additional_cc?: string[], additional_bcc?: string[] } +``` + +Response `200`: +```ts +{ + data: { + id: string, + invoice_number: string, + status: "sent", + total: number, + message_id: string, + sent_to: string, + cc: string, + cc_addresses: string[], + journal_entry_id: string, + warnings?: { code: string, message: string }[] + }, + meta: { + request_id: string, + api_version: string, + next_cursor?: string, + audit?: { voucher_number?: string, voucher_url?: string, audit_trail_url?: string, immutable_at?: string }, + partial_expansions?: string[] + } +} +``` + +--- + +### `POST /api/v1/companies/{companyId}/invoices/bulk-create` + +**Create up to 50 draft invoices in one call (partial-success).** +`scope:invoices:write · risk:medium · idempotent · dry-run · reversible` + +Bulk-creation endpoint. Each invoice in the request array is validated and inserted independently. By default, individual failures do not roll back successes: the response carries a per-item results array with ok/error markers and a summary. Idempotent (the whole batch is keyed by the single Idempotency-Key). Dry-runnable. + +**Use when:** You're importing a batch of invoices from another system, or producing many invoices programmatically (e.g. monthly subscription billing). Use dry-run first to validate the whole batch before committing. +**Do not use for:** Sending the same invoice to multiple customers: POST /invoices once per customer. Long-running imports of > 50 invoices: split into pages. Transactional all-or-nothing imports: not yet supported (passing all_or_nothing: true returns 501 NOT_IMPLEMENTED; the flag is reserved for a future RPC). + +**Pitfalls:** +- Idempotency-Key is mandatory and covers the WHOLE batch. A retried bulk-create returns the cached full response: it does not retry only the failed items. +- Passing all_or_nothing: true returns 501 NOT_IMPLEMENTED. Today only partial-success batches exist; omit the flag (or pass false). +- Each per-item invoice still goes through the same VAT-rule validation as POST /invoices. A mismatched per-item vat_rate produces a per-item failure, not a whole-batch failure. +- Currency conversion is best-effort PER ITEM. A failed Riksbanken fetch leaves that item's SEK columns null but does NOT fail the item. + +| Parameter | In | Type | Required | Notes | +|---|---|---|---|---| +| `companyId` | path | `string` | yes | | + +Request body: +```ts +{ + invoices: { customer_id: string, invoice_date: string, due_date: string, delivery_date?: string | "", currency: "SEK" | "EUR" | "USD" | "GBP" | "NOK" | "DKK", document_type?: "invoice" | "proforma" | "delivery_note", your_reference?: string, our_reference?: string, notes?: string, payment_link_url?: string | "", payment_link_auto?: boolean, deduction_personnummer?: string, deduction_housing_designation?: string, deduction_apartment_number?: string, deduction_brf_org_number?: string | "", save_as_draft?: boolean, ore_rounding?: boolean, default_dimensions?: Record, is_self_billed?: boolean, external_invoice_number?: string | "", self_billing_agreement_ref?: string, received_date?: string | "", items: { line_type?: "product" | "text", description: string, quantity: number, unit: string, unit_price: number, vat_rate?: number, article_id?: string, revenue_account?: string, deduction_type?: "rot" | "rut", labor_hours?: number, work_type?: string, housing_designation?: string, apartment_number?: string, brf_org_number?: string | "", accrual_period_start?: string, accrual_period_end?: string, accrual_balance_account?: string, dimensions?: Record }[] }[], + all_or_nothing?: boolean +} +``` + +Response `200`: +```ts +{ + data: { + results: { ok: boolean, request_index: number, data?: unknown, error?: { code: string, message: string, details?: unknown } }[], + summary: { total: number, succeeded: number, failed: number } + }, + meta: { + request_id: string, + api_version: string, + next_cursor?: string, + audit?: { voucher_number?: string, voucher_url?: string, audit_trail_url?: string, immutable_at?: string }, + partial_expansions?: string[] + } +} +``` diff --git a/skills/accounted-api/references/journal-entries.md b/skills/accounted-api/references/journal-entries.md new file mode 100644 index 00000000..a43e43bf --- /dev/null +++ b/skills/accounted-api/references/journal-entries.md @@ -0,0 +1,397 @@ + + +# Journal entries endpoints + +The ledger itself: journal entries follow draft -> commit -> immutable. There is no edit or delete after commit; undo via reverse (storno) or correct. Voucher numbers are server-assigned and gapless; explain unavoidable gaps via voucher-gap-explanations. + +Conventions (auth, envelope, pagination, dry-run, idempotency, standard errors) +are in SKILL.md and are not repeated per endpoint. + +### `GET /api/v1/companies/{companyId}/journal-entries` + +**List journal entries (verifikationer).** +`scope:reports:read · risk:low · idempotent` + +Cursor-paginated list of journal entries ordered by created_at DESC, id ASC (newest-booked first; the `entry_date` column is the verifikationsdatum and is filterable via ?date_from / ?date_to but is not the sort key). Filters: fiscal_period_id, status, date_from, date_to. Excludes status=cancelled by default; pass status=cancelled to inspect storno-cancelled drafts. + +**Use when:** You need to walk the verifikationsserie for a period (audit, SIE export, gap detection) or list recent activity for a UI. +**Do not use for:** Reading a single verifikation (use GET /{id}). Reading lines without the header (no separate endpoint: they ride in /{id}). + +**Pitfalls:** +- Cancelled drafts are hidden by default. They are NOT a löpnummer gap (no voucher_number is allocated for drafts); the filter is for noise reduction. +- voucher_number=0 indicates a draft that has not been committed. Posted entries always have voucher_number > 0. +- Ordering is by created_at (when the verifikat was booked), not entry_date. A backdated verifikat appears where it was booked: filter on ?date_from / ?date_to when you need entry_date ranges, and walk the whole cursor chain when you need a full period. +- Cursor pagination: pass ?cursor= from the previous response. A stale or tampered cursor is ignored and the first page is returned again. + +| Parameter | In | Type | Required | Notes | +|---|---|---|---|---| +| `companyId` | path | `string` | yes | | + +Response `200`: +```ts +{ + data: { id: string, fiscal_period_id: string, voucher_series: string, voucher_number: number, entry_date: string, description: string, status: "draft" | "posted" | "cancelled", source_type: string, created_at: string }[], + meta: { + request_id: string, + api_version: string, + next_cursor?: string, + audit?: { voucher_number?: string, voucher_url?: string, audit_trail_url?: string, immutable_at?: string }, + partial_expansions?: string[] + } +} +``` + +--- + +### `POST /api/v1/companies/{companyId}/journal-entries` + +**Create a draft journal entry (verifikation).** +`scope:bookkeeping:write · risk:high · idempotent · dry-run · reversible` + +Creates a draft journal entry via the engine's createDraftEntry(). The draft has no voucher_number until /commit is called. Idempotent (mandatory Idempotency-Key). Dry-runnable: a dry-run validates balance + account-chart membership + period date constraints without inserting any row. + +**Use when:** You're posting an arbitrary verifikation (manual journal entries, accrual reversals, period closing adjustments) outside the invoicing / supplier-invoice / transaction flows. +**Do not use for:** Bookkeeping flows that have a dedicated endpoint (invoices, supplier-invoices, transactions). Editing an existing posted entry: use /correct instead. + +**Pitfalls:** +- Idempotency-Key is mandatory. +- Lines must sum to zero (Σ debit = Σ credit). Engine rejects with JOURNAL_ENTRY_NOT_BALANCED on imbalance. +- entry_date must fall within fiscal_period_id's [period_start, period_end]; otherwise ENTRY_DATE_OUTSIDE_FISCAL_PERIOD. +- All account_numbers must be active in the chart_of_accounts; otherwise ACCOUNTS_NOT_IN_CHART. +- voucher_series defaults to "A" if omitted. Must be a single uppercase letter. +- This creates a DRAFT only: call POST /{id}/commit to assign the voucher_number and post atomically. + +| Parameter | In | Type | Required | Notes | +|---|---|---|---|---| +| `companyId` | path | `string` | yes | | + +Request body: +```ts +{ + fiscal_period_id: string, + entry_date: string, + description: string, + source_type?: "manual" | "bank_transaction" | "invoice_created" | "invoice_paid" | "invoice_cash_payment" | "credit_note" | "salary_payment" | "opening_balance" | "year_end" | "storno" | "correction" | "import" | "system" | "inbox_item" | "supplier_invoice_registered" | "supplier_invoice_paid" | "supplier_invoice_cash_payment" | "supplier_invoice_privately_paid" | "supplier_credit_note" | "currency_revaluation" | "reminder_fee" | "accrual" | "result_appropriation" | "rot_rut_payout" | "vat_settlement" | "stripe_payout", + source_id?: string, + voucher_series?: string, + notes?: string, + lines: { account_number: string, debit_amount?: number, credit_amount?: number, line_description?: string, currency?: string, amount_in_currency?: number, exchange_rate?: number, tax_code?: string, dimensions?: Record, cost_center?: string, project?: string }[] +} +``` + +Response `200`: +```ts +{ + data: { + id: string, + fiscal_period_id: string, + voucher_series: string, + voucher_number: number, + entry_date: string, + description: string, + status: "draft" | "posted" | "cancelled", + source_type: string, + created_at: string, + notes: string, + reverses_id: string, + reversed_by_id: string, + correction_of_id: string, + lines: { id: string, account_number: string, debit_amount: number, credit_amount: number, line_description: string, currency: string, amount_in_currency: number, exchange_rate: number, tax_code: string, cost_center: string, project: string }[] + }, + meta: { + request_id: string, + api_version: string, + next_cursor?: string, + audit?: { voucher_number?: string, voucher_url?: string, audit_trail_url?: string, immutable_at?: string }, + partial_expansions?: string[] + } +} +``` + +--- + +### `GET /api/v1/companies/{companyId}/journal-entries/{id}` + +**Retrieve a single verifikation by id.** +`scope:reports:read · risk:low · idempotent` + +Returns the full journal entry including all lines, dimensions, and the storno chain (reverses_id, reversed_by_id, correction_of_id). + +**Use when:** You need the full verifikation for audit / reconciliation, or to display the line-by-line breakdown. +**Do not use for:** Listing entries (use the list endpoint with filters). + +**Pitfalls:** +- Cancelled drafts are returned (no filter on status here); inspect status before assuming the entry is posted. +- Lines are sorted by sort_order; the order matters for display but not for accounting (the sum across lines is the meaningful quantity). + +| Parameter | In | Type | Required | Notes | +|---|---|---|---|---| +| `companyId` | path | `string` | yes | | +| `id` | path | `string` | yes | | + +Response `200`: +```ts +{ + data: { + id: string, + fiscal_period_id: string, + voucher_series: string, + voucher_number: number, + entry_date: string, + description: string, + status: "draft" | "posted" | "cancelled", + source_type: string, + source_id: string, + notes: string, + reverses_id: string, + reversed_by_id: string, + correction_of_id: string, + lines: { id: string, account_number: string, debit_amount: number, credit_amount: number, line_description: string, currency: string, amount_in_currency: number, exchange_rate: number, tax_code: string, cost_center: string, project: string, sort_order: number }[], + created_at: string, + updated_at: string + }, + meta: { + request_id: string, + api_version: string, + next_cursor?: string, + audit?: { voucher_number?: string, voucher_url?: string, audit_trail_url?: string, immutable_at?: string }, + partial_expansions?: string[] + } +} +``` + +--- + +### `POST /api/v1/companies/{companyId}/journal-entries/{id}/commit` + +**Commit a draft journal entry.** +`scope:bookkeeping:write · risk:high · idempotent · dry-run · reversible` + +Atomically advances the voucher series and flips the draft to posted. The voucher_number is the smallest integer not yet used in (fiscal_period_id, voucher_series); a failed commit does NOT burn the number. + +**Use when:** You created a draft via POST /journal-entries and now want to post it to the books. After commit the entry is immutable per BFL 5 kap 2 §; corrections require /reverse or /correct. +**Do not use for:** Re-committing an already-posted entry (returns 409). Committing across companies: the URL companyId must match the draft's company. + +**Pitfalls:** +- Idempotency-Key is mandatory. +- Posted entries cannot be edited. Plan the lines carefully or call /correct after commit if you need to change them. +- Voucher numbers are sequential within (fiscal_period_id, voucher_series). A commit failure (e.g. period locked between draft creation and commit) does not advance the sequence. + +| Parameter | In | Type | Required | Notes | +|---|---|---|---|---| +| `companyId` | path | `string` | yes | | +| `id` | path | `string` | yes | | + +Response `200`: +```ts +{ + data: { id: string, voucher_series: string, voucher_number: number, status: "posted", entry_date: string }, + meta: { + request_id: string, + api_version: string, + next_cursor?: string, + audit?: { voucher_number?: string, voucher_url?: string, audit_trail_url?: string, immutable_at?: string }, + partial_expansions?: string[] + } +} +``` + +--- + +### `POST /api/v1/companies/{companyId}/journal-entries/{id}/correct` + +**Correct a posted journal entry (BFL 5:5 storno-then-replace).** +`scope:bookkeeping:write · risk:high · idempotent · dry-run` + +Per Bokföringslagen 5 kap 5 §, posted entries cannot be modified. This endpoint creates the canonical correction trail: a storno reversing the original, then a new entry with the corrected lines. All three are visible in the verifikationsserie and linked via reverses_id / reversed_by_id / correction_of_id. Idempotent. Dry-runnable. + +**Use when:** You need to amend a posted verifikation. Use this rather than /reverse when the entry is being REPLACED with new lines: /reverse just nullifies. +**Do not use for:** Drafts (no voucher_number: cancel via dashboard). Already-corrected entries (the chain only supports one correction; correct the latest in the chain). + +**Pitfalls:** +- Idempotency-Key is mandatory. +- The new lines must balance. JOURNAL_ENTRY_NOT_BALANCED if not. +- The original's entry_date and fiscal_period_id are inherited. If the original's period has been locked since posting, the call returns PERIOD_LOCKED. +- Three voucher numbers are advanced in this call: the original (already burned), the reversal, and the corrected. The series stays unbroken. + +| Parameter | In | Type | Required | Notes | +|---|---|---|---|---| +| `companyId` | path | `string` | yes | | +| `id` | path | `string` | yes | | + +Request body: +```ts +{ + description?: string, + lines: { account_number: string, debit_amount?: number, credit_amount?: number, line_description?: string, currency?: string, amount_in_currency?: number, exchange_rate?: number, tax_code?: string, dimensions?: Record, cost_center?: string, project?: string }[] +} +``` + +Response `200`: +```ts +{ + data: { + reversal_id: string, + corrected_id: string, + original_id: string, + voucher_series: string, + reversal_voucher_number: number, + corrected_voucher_number: number + }, + meta: { + request_id: string, + api_version: string, + next_cursor?: string, + audit?: { voucher_number?: string, voucher_url?: string, audit_trail_url?: string, immutable_at?: string }, + partial_expansions?: string[] + } +} +``` + +--- + +### `POST /api/v1/companies/{companyId}/journal-entries/{id}/reverse` + +**Storno a posted journal entry.** +`scope:bookkeeping:write · risk:high · idempotent · dry-run` + +Creates a reversing journal entry that nullifies the original. The original remains posted and visible: the reversal links via reverses_id and the original is annotated reversed_by_id. The reversal carries its own voucher_number in the same series so the löpnummer chain stays unbroken (BFL 5 kap 5-7 §§). + +**Use when:** A posted entry needs to be cancelled and there is no replacement coming: e.g. a duplicate booking, an entry posted to the wrong period. Use /correct instead when you need to replace the entry with corrected lines. +**Do not use for:** Cancelling a draft (drafts have no voucher_number; cancel via the dashboard). Reversing an already-reversed entry (returns ENTRY_ALREADY_REVERSED). + +**Pitfalls:** +- Idempotency-Key is mandatory. +- reversal_date defaults to today; the reversal is posted in the fiscal period covering that date. If today's period is locked the call returns PERIOD_LOCKED. +- You cannot reverse a draft (status must be posted). Use /correct after commit if the original needs replacing. + +| Parameter | In | Type | Required | Notes | +|---|---|---|---|---| +| `companyId` | path | `string` | yes | | +| `id` | path | `string` | yes | | + +Request body: +```ts +{ reversal_date?: string } +``` + +Response `200`: +```ts +{ + data: { + reversal_id: string, + original_id: string, + voucher_series: string, + voucher_number: number, + entry_date: string, + status: "posted" + }, + meta: { + request_id: string, + api_version: string, + next_cursor?: string, + audit?: { voucher_number?: string, voucher_url?: string, audit_trail_url?: string, immutable_at?: string }, + partial_expansions?: string[] + } +} +``` + +--- + +### `POST /api/v1/companies/{companyId}/journal-entries/batch-create` + +**Create up to 50 draft journal entries (partial-success).** +`scope:bookkeeping:write · risk:high · idempotent · dry-run · reversible` + +Bulk-create endpoint mirroring /invoices/bulk-create and /suppliers/bulk-create. Each entry is validated and inserted independently: per-item failures do not roll back items that succeeded. Returns DRAFTS only; commit each separately. Idempotent over the whole batch. Dry-runnable. + +**Use when:** You're replaying historical bookkeeping from another system, or batching a set of manual verifikationer from a spreadsheet. Use dry-run first to validate the batch. +**Do not use for:** Committing posted entries: use POST /{id}/commit per entry. Transactional all-or-nothing imports: passing all_or_nothing: true returns 501 NOT_IMPLEMENTED. + +**Pitfalls:** +- Idempotency-Key is mandatory and covers the WHOLE batch. +- all_or_nothing: true returns 501 NOT_IMPLEMENTED. Today only partial-success batches exist. +- Each entry must balance independently. Per-item JOURNAL_ENTRY_NOT_BALANCED appears in the results array. + +| Parameter | In | Type | Required | Notes | +|---|---|---|---|---| +| `companyId` | path | `string` | yes | | + +Request body: +```ts +{ + journal_entries: { fiscal_period_id: string, entry_date: string, description: string, source_type?: "manual" | "bank_transaction" | "invoice_created" | "invoice_paid" | "invoice_cash_payment" | "credit_note" | "salary_payment" | "opening_balance" | "year_end" | "storno" | "correction" | "import" | "system" | "inbox_item" | "supplier_invoice_registered" | "supplier_invoice_paid" | "supplier_invoice_cash_payment" | "supplier_invoice_privately_paid" | "supplier_credit_note" | "currency_revaluation" | "reminder_fee" | "accrual" | "result_appropriation" | "rot_rut_payout" | "vat_settlement" | "stripe_payout", source_id?: string, voucher_series?: string, notes?: string, lines: { account_number: string, debit_amount?: number, credit_amount?: number, line_description?: string, currency?: string, amount_in_currency?: number, exchange_rate?: number, tax_code?: string, dimensions?: Record, cost_center?: string, project?: string }[] }[], + all_or_nothing?: boolean +} +``` + +Response `200`: +```ts +{ + data: { + results: { ok: boolean, request_index: number, data?: unknown, error?: { code: string, message: string, details?: unknown } }[], + summary: { total: number, succeeded: number, failed: number } + }, + meta: { + request_id: string, + api_version: string, + next_cursor?: string, + audit?: { voucher_number?: string, voucher_url?: string, audit_trail_url?: string, immutable_at?: string }, + partial_expansions?: string[] + } +} +``` + +--- + +### `POST /api/v1/companies/{companyId}/voucher-gap-explanations` + +**Document a gap in the verifikationsserie (BFL 5 kap 6-7 §§).** +`scope:bookkeeping:write · risk:low · idempotent · dry-run` + +Records an explanation for one or more missing voucher numbers in a series. Required when a number is unaccounted for during audit. Statutory basis: BFL 5 kap 6-7 §§ (verifikationsnummer i löpande följd utan luckor); BFNAR 2013:2 kap 8 § governs the systemdokumentation that surfaces the gap. Idempotent. Dry-runnable. + +**Use when:** You're responding to a voucher-gap audit finding and need to document the cause. Also used by migration flows that claim numbers without filling them. +**Do not use for:** Falsifying a series: every gap MUST have a genuine explanation. The dashboard surfaces these for auditor review. + +**Pitfalls:** +- Idempotency-Key is mandatory. +- gap_end must be >= gap_start; a single-number gap has gap_start = gap_end. +- voucher_series is a single uppercase letter (A-Z); the same series + period + numeric range must not already exist. + +| Parameter | In | Type | Required | Notes | +|---|---|---|---|---| +| `companyId` | path | `string` | yes | | + +Request body: +```ts +{ + fiscal_period_id: string, + voucher_series: string, + gap_start: number, + gap_end: number, + explanation: string +} +``` + +Response `200`: +```ts +{ + data: { + id: string, + fiscal_period_id: string, + voucher_series: string, + gap_start: number, + gap_end: number, + explanation: string, + created_at: string + }, + meta: { + request_id: string, + api_version: string, + next_cursor?: string, + audit?: { voucher_number?: string, voucher_url?: string, audit_trail_url?: string, immutable_at?: string }, + partial_expansions?: string[] + } +} +``` diff --git a/skills/accounted-api/references/periods.md b/skills/accounted-api/references/periods.md new file mode 100644 index 00000000..283e5564 --- /dev/null +++ b/skills/accounted-api/references/periods.md @@ -0,0 +1,502 @@ + + +# Periods and registers endpoints + +Fiscal periods and their lock/close/year-end lifecycle (async operations), the BAS chart of accounts, cost-center/project dimensions, and the compliance pre-flight check. + +Conventions (auth, envelope, pagination, dry-run, idempotency, standard errors) +are in SKILL.md and are not repeated per endpoint. + +### `GET /api/v1/companies/{companyId}/accounts` + +**List chart-of-accounts entries (BAS chart).** +`scope:reports:read · risk:low · idempotent` + +Returns every account in the company's chart of accounts, ordered by sort_order (the BAS canonical sequence). Filter by ?class=<1..8> (BAS account class: 1=assets, 2=equity/liabilities, 3=revenue, 4=cost of goods sold, 5=övriga externa kostnader (rents, supplies, services), 6=övriga externa kostnader (marketing, professional services, IT), 7=labour, 8=financial). Note: BAS 5xxx and 6xxx are both övriga externa kostnader but cover distinct subgroups; see the BAS chart for the canonical mapping. Pass ?active=false to include archived accounts. + +**Use when:** You need account numbers and names to render verifikation tables, build a custom report, or look up the canonical BAS label for an account. +**Do not use for:** Fetching balances: use the trial-balance report. Creating new accounts: this endpoint is read-only in v1 (use the dashboard). + +**Pitfalls:** +- account_number is a STRING: "1930", not 1930. The leading character can be 0 in non-BAS plans. +- is_system_account=true means the account was seeded by Accounted and cannot be archived or renamed. +- Default filter excludes archived accounts; pass ?active=false to include them. + +| Parameter | In | Type | Required | Notes | +|---|---|---|---|---| +| `companyId` | path | `string` | yes | | + +Response `200`: +```ts +{ + data: { + accounts: { account_number: string, account_name: string, account_class: number, account_group: string, account_type: string, normal_balance: string, is_system_account: boolean, is_active: boolean, description: string, default_vat_code: string, sru_code: string, sort_order: number }[] + }, + meta: { + request_id: string, + api_version: string, + next_cursor?: string, + audit?: { voucher_number?: string, voucher_url?: string, audit_trail_url?: string, immutable_at?: string }, + partial_expansions?: string[] + } +} +``` + +--- + +### `GET /api/v1/companies/{companyId}/compliance/check` + +**Run a structured compliance pre-flight check.** +`scope:compliance:read · risk:low · idempotent` + +Generalised pre-flight that consolidates the Accounted pre-close validators under one envelope. Supported check types: year_end_readiness (BFNAR 2017:3 + ÅRL 2:1 blockers), voucher_gaps (BFNAR 2013:2 kap 8 § series continuity). vat_close is planned for a follow-up PR (the underlying function currently lives in the MCP extension and core routes cannot import from extensions; it will be extracted into lib/reports/ then exposed here). New types can be added without changing the response shape. + +**Use when:** Before committing to an irreversible action (VAT close, year-end close), or as a periodic audit sweep to surface blockers before they become urgent. +**Do not use for:** Executing the underlying action: this is read-only. After a passing check, call the corresponding async endpoint (POST /fiscal-periods/{id}/year-end, etc). + +**Pitfalls:** +- year_end_readiness and voucher_gaps require fiscal_period_id (UUID). +- voucher_gaps covers EVERY voucher series registered for the period (A, B, F, ...), not only series A. +- A passing check is a SNAPSHOT: the state can change between the check and the action. The same blocker logic runs again on commit. +- vat_close is documented in the plan but NOT yet supported by this endpoint: call gnubok_vat_close_check via the MCP server until the function is extracted into lib/reports/. + +| Parameter | In | Type | Required | Notes | +|---|---|---|---|---| +| `companyId` | path | `string` | yes | | + +Response `200`: +```ts +{ + data: { + type: string, + ready: boolean, + findings: { severity: "info" | "warning" | "blocker", code: string, message: string, details?: unknown }[], + summary: string, + generated_at: string, + params: Record + }, + meta: { + request_id: string, + api_version: string, + next_cursor?: string, + audit?: { voucher_number?: string, voucher_url?: string, audit_trail_url?: string, immutable_at?: string }, + partial_expansions?: string[] + } +} +``` + +--- + +### `GET /api/v1/companies/{companyId}/dimensions` + +**List dimensions (kostnadsställe/projekt) with their values.** +`scope:reports:read · risk:low · idempotent` + +Returns the company's dimension registry: SIE #DIM entries keyed by sie_dim_no (1 = Kostnadsställe, 6 = Projekt; both always exist): with the registered values (#OBJEKT) nested under each dimension. Dimensions are ordered by sort_order, values by code. Line-level tags on journal entries reference these values as {"":""} in the `dimensions` map. + +**Use when:** You need the valid dimension value codes before tagging journal-entry lines with a cost centre or project, or you are rendering a dimension picker. +**Do not use for:** Filtering reports (pass the dimension filter to the report endpoints once available) or reading which lines carry a tag (read the journal entries themselves). + +**Pitfalls:** +- Dimension value codes are STRINGS and case-sensitive: "P001", not 1. +- sie_dim_no is the key used in journal_entry_lines.dimensions, NOT the dimension row id. +- is_active=false values are historical (archived): do not tag new lines with them. +- resets_annually=true (dim 1) means balances reset each fiscal year; dim 6 (projekt) accumulates across years. + +| Parameter | In | Type | Required | Notes | +|---|---|---|---|---| +| `companyId` | path | `string` | yes | | + +Response `200`: +```ts +{ + data: { + dimensions: { id: string, sie_dim_no: number, name: string, resets_annually: boolean, is_system: boolean, is_active: boolean, sort_order: number, values: { id: string, code: string, name: string, is_active: boolean, start_date: string, end_date: string }[] }[] + }, + meta: { + request_id: string, + api_version: string, + next_cursor?: string, + audit?: { voucher_number?: string, voucher_url?: string, audit_trail_url?: string, immutable_at?: string }, + partial_expansions?: string[] + } +} +``` + +--- + +### `POST /api/v1/companies/{companyId}/dimensions/{id}/values` + +**Create a dimension value (kostnadsställe/projekt code).** +`scope:bookkeeping:write · risk:low · idempotent · dry-run · reversible` + +Registers a new value (SIE #OBJEKT) under a dimension: e.g. a new project code under dimension 6. Requires Idempotency-Key (UUID). Supports ?dry_run=true to validate the code format without committing. The `:id` path segment is the dimension row id (from GET …/dimensions), not the sie_dim_no. Duplicate codes within the dimension return 409 DIMENSION_VALUE_DUPLICATE_CODE. + +**Use when:** A voucher or invoice references a cost centre / project code that does not exist yet and the user has confirmed it should be created. +**Do not use for:** Renaming or archiving an existing value (dashboard register in v1). Tagging lines: pass the dimensions map on the journal-entry line instead. + +**Pitfalls:** +- Idempotency-Key is mandatory: calls without it return 400 VALIDATION_ERROR. +- The :id segment is the dimension UUID, not the SIE dimension number. +- Codes are limited to the strict Fortnox charset (A-Ö, digits, _, +, -; max 20 chars) even though historical imported codes may be looser. +- code is immutable after creation: there is no rename in v1; create the correct code and archive the wrong one. + +| Parameter | In | Type | Required | Notes | +|---|---|---|---|---| +| `companyId` | path | `string` | yes | | +| `id` | path | `string` | yes | | + +Request body: +```ts +{ code: string, name: string, is_active?: boolean, start_date?: string, end_date?: string } +``` + +Response `200`: +```ts +{ + data: { + id: string, + dimension_id: string, + code: string, + name: string, + is_active: boolean, + start_date: string, + end_date: string, + created_at: string + }, + meta: { + request_id: string, + api_version: string, + next_cursor?: string, + audit?: { voucher_number?: string, voucher_url?: string, audit_trail_url?: string, immutable_at?: string }, + partial_expansions?: string[] + } +} +``` + +--- + +### `PATCH /api/v1/companies/{companyId}/dimensions/{id}/values/{valueId}` + +**Update a dimension value (rename, archive, set start/end date).** +`scope:bookkeeping:write · risk:low · idempotent · dry-run · reversible` + +Sparse update of a dimension value (SIE #OBJEKT): name, is_active (false = archive), start_date, end_date. `code` is immutable: renaming a code would orphan every journal line tagged with it; create a new value and archive the old one instead. Dates are only allowed on accumulating dimensions (resets_annually=false, e.g. dim 6 Projekt): use end_date to close a finished project. Idempotent (mandatory Idempotency-Key) and dry-runnable. + +**Use when:** You need to rename a project/cost-centre, mark a finished project with an end date, or archive (is_active=false) a value that should no longer be used on new lines. +**Do not use for:** Changing the code (immutable: create + archive instead). Removing an unused value entirely (use DELETE). Tagging lines (pass dimensions on the journal-entry line or invoice). + +**Pitfalls:** +- Idempotency-Key is mandatory. +- The :id segment is the dimension UUID and :valueId the value UUID (both from GET …/dimensions), not SIE numbers or codes. +- start_date/end_date return 400 DIMENSION_VALUE_DATES_NOT_ALLOWED on resets_annually dimensions (dim 1 Kostnadsställe). +- Archived values (is_active=false) still appear in GET …/dimensions and remain valid on historical lines; they are only blocked for NEW tags. + +| Parameter | In | Type | Required | Notes | +|---|---|---|---|---| +| `companyId` | path | `string` | yes | | +| `id` | path | `string` | yes | | +| `valueId` | path | `string` | yes | | + +Request body: +```ts +{ name?: string, is_active?: boolean, start_date?: string, end_date?: string } +``` + +Response `200`: +```ts +{ + data: { + id: string, + dimension_id: string, + code: string, + name: string, + is_active: boolean, + start_date: string, + end_date: string + }, + meta: { + request_id: string, + api_version: string, + next_cursor?: string, + audit?: { voucher_number?: string, voucher_url?: string, audit_trail_url?: string, immutable_at?: string }, + partial_expansions?: string[] + } +} +``` + +--- + +### `DELETE /api/v1/companies/{companyId}/dimensions/{id}/values/{valueId}` + +**Delete an unreferenced dimension value.** +`scope:bookkeeping:write · risk:medium · idempotent` + +Hard-deletes a dimension value (SIE #OBJEKT) that no journal line references. Values used on posted or reversed verifikat are retained for the BFL 7-year archive and cannot be deleted: the DB trigger blocks it and this endpoint returns 409 DIMENSION_VALUE_REFERENCED. Archive those instead (PATCH is_active=false). Requires Idempotency-Key. + +**Use when:** A project/cost-centre code was created by mistake (typo, duplicate) and has never been used on any booking. +**Do not use for:** Retiring a project that has bookings: PATCH is_active=false (and optionally end_date) instead. Deleting a whole dimension (not supported). + +**Pitfalls:** +- Idempotency-Key is mandatory. +- 409 DIMENSION_VALUE_REFERENCED means the value is used on booked verifikat: it can never be deleted, only archived. +- Deletion is permanent: the code can be re-created afterwards, but the old row id is gone. + +| Parameter | In | Type | Required | Notes | +|---|---|---|---|---| +| `companyId` | path | `string` | yes | | +| `id` | path | `string` | yes | | +| `valueId` | path | `string` | yes | | + +Response `200`: +```ts +{ + data: { deleted: true, id: string }, + meta: { + request_id: string, + api_version: string, + next_cursor?: string, + audit?: { voucher_number?: string, voucher_url?: string, audit_trail_url?: string, immutable_at?: string }, + partial_expansions?: string[] + } +} +``` + +--- + +### `GET /api/v1/companies/{companyId}/fiscal-periods` + +**List fiscal periods (räkenskapsår).** +`scope:reports:read · risk:low · idempotent` + +Returns every fiscal period for the company ordered by period_start DESC. is_closed=true means bokslut has been signed; locked_at non-null means writes are blocked at the DB-trigger level. + +**Use when:** You need to find the active period before booking, build a year-selector UI, or audit the period-lock history. +**Do not use for:** Creating, locking, or closing periods: those land in Phase 4 (`POST /fiscal-periods/{id}/lock`, `:close`, `:year-end`). Use the dashboard or wait for Phase 4. + +**Pitfalls:** +- previous_period_id chains the bokslut continuity (BFNAR 2013:2). A null value on a non-first period is a data-quality red flag. +- A period can be locked but not closed (löpande bokföring of the new year while bokslut work continues on the prior year: see BFL 5 kap 2 § for the löpande bokföring deadline). +- BFL 3 kap caps a single fiscal period at 18 months. First-year exceptions are allowed. + +| Parameter | In | Type | Required | Notes | +|---|---|---|---|---| +| `companyId` | path | `string` | yes | | + +Response `200`: +```ts +{ + data: { + fiscal_periods: { id: string, name: string, period_start: string, period_end: string, is_closed: boolean, closed_at: string, locked_at: string, previous_period_id: string, created_at: string, duration_days: number, exceeds_18_months: boolean }[] + }, + meta: { + request_id: string, + api_version: string, + next_cursor?: string, + audit?: { voucher_number?: string, voucher_url?: string, audit_trail_url?: string, immutable_at?: string }, + partial_expansions?: string[] + } +} +``` + +--- + +### `POST /api/v1/companies/{companyId}/fiscal-periods/{id}/close` + +**Close a fiscal period (IRREVERSIBLE per BFL 5 kap 8 §).** +`scope:bookkeeping:write · risk:high · idempotent` + +Sets is_closed=true + closed_at on the period. Pre-requisites: period must be locked (call /lock first) AND year-end closing must have been executed (call /year-end first). Sync. The DB blocks any subsequent JE inserts. + +**Use when:** Final step in the year-end flow: lock → year-end → close. Closing freezes the period for BFL 7 kap retention. +**Do not use for:** Locking a period (use /lock). Running the year-end closing entry (use /year-end). UNDOING a close (not supported, irreversible). + +**Pitfalls:** +- Idempotency-Key is mandatory. +- IRREVERSIBLE. Once is_closed=true, the period is read-only forever (BFL 5 kap 8 § + 7 kap). +- Pre-conditions: locked + closing_entry_id present. Otherwise the call returns CONFLICT. + +| Parameter | In | Type | Required | Notes | +|---|---|---|---|---| +| `companyId` | path | `string` | yes | | +| `id` | path | `string` | yes | | + +Response `200`: +```ts +{ + data: { id: string, is_closed: true, closed_at: string }, + meta: { + request_id: string, + api_version: string, + next_cursor?: string, + audit?: { voucher_number?: string, voucher_url?: string, audit_trail_url?: string, immutable_at?: string }, + partial_expansions?: string[] + } +} +``` + +--- + +### `POST /api/v1/companies/{companyId}/fiscal-periods/{id}/currency-revaluation` + +**Run FX revaluation for the fiscal period.** +`scope:bookkeeping:write · risk:high · idempotent · reversible` + +Re-rates open foreign-currency AR (1510) and AP (2440) at the closing date's Riksbanken rate and posts the SEK delta to 3960 (valutakursvinst) / 7960 (valutakursförlust). Returns 202 with operation_id. Idempotent per-period: the engine throws if a revaluation has already been posted for the same fiscal_period_id. + +**Use when:** Before /year-end if your books have open foreign-currency receivables or payables. /year-end also runs this internally, so you only need to call it separately when you want the FX-only entry without the full closing. +**Do not use for:** Re-running on the same period (CURRENCY_REVALUATION_ALREADY_EXISTS). Revaluing a closed period (the trigger blocks JE writes to closed periods). + +**Pitfalls:** +- Idempotency-Key is mandatory. +- Engine returns null if no open foreign-currency items exist: the operation succeeds with result.revaluation_entry_id=null. +- as_of_date defaults to period_end if omitted. + +| Parameter | In | Type | Required | Notes | +|---|---|---|---|---| +| `companyId` | path | `string` | yes | | +| `id` | path | `string` | yes | | + +Request body: +```ts +{ as_of_date?: string } +``` + +Response `200`: +```ts +{ + data: { + operation_id: string, + type: "fiscal_periods.currency_revaluation", + status: "queued" | "running" | "succeeded" | "failed", + poll_url: string, + webhook_event: "operation.completed" + }, + meta: { + request_id: string, + api_version: string, + next_cursor?: string, + audit?: { voucher_number?: string, voucher_url?: string, audit_trail_url?: string, immutable_at?: string }, + partial_expansions?: string[] + } +} +``` + +--- + +### `POST /api/v1/companies/{companyId}/fiscal-periods/{id}/lock` + +**Lock a fiscal period (no new entries can be posted into it).** +`scope:bookkeeping:write · risk:high · idempotent · reversible` + +Sets locked_at on the period. Refuses if uncategorised business transactions remain in the period: they must be bokfört first. The DB trigger blocks JE inserts into locked periods; locking is the application-level pre-step before /close. Sync. + +**Use when:** Finishing a period and you want to stop new postings. Step 1 of a three-step year-end flow: lock → year-end → close. +**Do not use for:** Locking an already-closed period (no-op). Bypassing the uncategorised-transactions guard: categorise or mark-private first. + +**Pitfalls:** +- Idempotency-Key is mandatory. +- A period with uncategorised business transactions cannot be locked; the response surfaces the count. +- Locking is reversible until /close. The unlock endpoint is not in v1; use the dashboard. + +| Parameter | In | Type | Required | Notes | +|---|---|---|---|---| +| `companyId` | path | `string` | yes | | +| `id` | path | `string` | yes | | + +Response `200`: +```ts +{ + data: { id: string, locked_at: string, is_closed: boolean }, + meta: { + request_id: string, + api_version: string, + next_cursor?: string, + audit?: { voucher_number?: string, voucher_url?: string, audit_trail_url?: string, immutable_at?: string }, + partial_expansions?: string[] + } +} +``` + +--- + +### `POST /api/v1/companies/{companyId}/fiscal-periods/{id}/opening-balances` + +**Generate opening-balance verifikation for the next fiscal period.** +`scope:bookkeeping:write · risk:high · idempotent · reversible` + +Reads the closed period's trial balance, filters to BAS class 1-2 accounts with non-zero closing balance, and posts an opening verifikation (status=posted) onto the next_period_id. Sync. The path id is the CLOSED period; body.next_period_id is the target. + +**Use when:** After /year-end + /close on a period, generate the IB into the next period so the new year starts with the correct balance sheet. +**Do not use for:** Posting opening balances on a manually-edited basis (use POST /journal-entries with source_type=manual). Re-running on the same target period (will produce duplicate IB entries). + +**Pitfalls:** +- Idempotency-Key is mandatory. +- next_period_id must reference the SAME company and must NOT already have an IB entry. The engine throws if it does. +- Only class 1 (assets) and 2 (equity/liabilities) flow into the IB; class 3-8 are zeroed by the closing entry. + +| Parameter | In | Type | Required | Notes | +|---|---|---|---|---| +| `companyId` | path | `string` | yes | | +| `id` | path | `string` | yes | | + +Request body: +```ts +{ next_period_id: string } +``` + +Response `200`: +```ts +{ + data: { opening_entry_id: string, voucher_series: string, voucher_number: number, next_period_id: string }, + meta: { + request_id: string, + api_version: string, + next_cursor?: string, + audit?: { voucher_number?: string, voucher_url?: string, audit_trail_url?: string, immutable_at?: string }, + partial_expansions?: string[] + } +} +``` + +--- + +### `POST /api/v1/companies/{companyId}/fiscal-periods/{id}/year-end` + +**Execute year-end closing (currency revaluation + closing entry).** +`scope:bookkeeping:write · risk:high · idempotent` + +Async-operation endpoint. Runs the year-end closing flow: currency revaluation (FX gains/losses to 3960/7960), then posts the closing entry that zeroes class 3-8 onto årets resultat (2099 for AB, the relevant eget-kapital account in the 2010-2019 range for enskild firma: the engine resolves which based on company.entity_type). Returns 202 with operation_id; subscribe to operation.completed or poll /v1/operations/{id}. + +**Use when:** After /lock and a passing /compliance/check?type=year_end_readiness, you want to run the closing entry. This is step 2 of the lock → year-end → close flow. +**Do not use for:** Re-running year-end (per-period idempotent: fails if closing_entry_id is already set). Closing the period (use /close after year-end succeeds). + +**Pitfalls:** +- Idempotency-Key is mandatory. +- Period must pass year_end_readiness checks (no drafts, no unexplained voucher gaps, trial balance balanced). The engine re-validates and aborts if not. +- Closing entry is itself a verifikation (posted): the period must NOT already be closed. + +| Parameter | In | Type | Required | Notes | +|---|---|---|---|---| +| `companyId` | path | `string` | yes | | +| `id` | path | `string` | yes | | + +Response `200`: +```ts +{ + data: { + operation_id: string, + type: "fiscal_periods.year_end", + status: "queued" | "running" | "succeeded" | "failed", + poll_url: string, + webhook_event: "operation.completed" + }, + meta: { + request_id: string, + api_version: string, + next_cursor?: string, + audit?: { voucher_number?: string, voucher_url?: string, audit_trail_url?: string, immutable_at?: string }, + partial_expansions?: string[] + } +} +``` diff --git a/skills/accounted-api/references/reports.md b/skills/accounted-api/references/reports.md new file mode 100644 index 00000000..99c8b18d --- /dev/null +++ b/skills/accounted-api/references/reports.md @@ -0,0 +1,483 @@ + + +# Reports endpoints + +Read-only statutory and management reports: trial balance, balance sheet, income statement, general ledger, VAT declaration, AR/AP ledgers, salary journal, and SIE export. + +Conventions (auth, envelope, pagination, dry-run, idempotency, standard errors) +are in SKILL.md and are not repeated per endpoint. + +### `GET /api/v1/companies/{companyId}/reports/ar-ledger` + +**AR ledger: unpaid customer invoices with aging.** +`scope:reports:read · risk:low · idempotent` + +Returns the customer-receivable ledger as of `as_of_date` (defaults to today). Each customer entry includes outstanding invoices grouped into aging buckets (0-30, 31-60, 61-90, 90+ days). Reconciles against BAS 1510. + +**Use when:** Cash collection dashboards, dunning workflows, end-of-period reconciliation against the 1510 trial-balance figure. +**Do not use for:** Listing all invoices regardless of status (use /invoices). Sending dunning emails (the v1 surface does not yet expose dunning). + +**Pitfalls:** +- `as_of_date` is optional; format `YYYY-MM-DD`. Defaults to today (UTC). +- Only invoices in `sent`/`overdue`/`partially_paid` status appear. Drafts and credited invoices are excluded. + +| Parameter | In | Type | Required | Notes | +|---|---|---|---|---| +| `companyId` | path | `string` | yes | | + +Response `200`: +```ts +{ + data: unknown, + meta: { + request_id: string, + api_version: string, + next_cursor?: string, + audit?: { voucher_number?: string, voucher_url?: string, audit_trail_url?: string, immutable_at?: string }, + partial_expansions?: string[] + } +} +``` + +--- + +### `GET /api/v1/companies/{companyId}/reports/avgifter-basis` + +**Annual arbetsgivaravgifter basis per employee.** +`scope:payroll:read · risk:low · idempotent` + +Returns the annual avgifter basis per employee for `year`, summed across booked salary runs. Each row shows the basis, applied rate, and computed avgifter amount: useful for reconciling against monthly AGI filings (HU sum across the year). + +**Use when:** Annual reconciliation between the AGI declarations and the bookkeeping (BAS 7510). Year-end audit prep. +**Do not use for:** Real-time AGI generation (POST /salary-runs/{id}/generate-agi). Per-run breakdown (use /reports/salary-journal). + +**Pitfalls:** +- `year` is required. +- Only `booked` runs are included. + +| Parameter | In | Type | Required | Notes | +|---|---|---|---|---| +| `companyId` | path | `string` | yes | | + +Response `200`: +```ts +{ + data: unknown, + meta: { + request_id: string, + api_version: string, + next_cursor?: string, + audit?: { voucher_number?: string, voucher_url?: string, audit_trail_url?: string, immutable_at?: string }, + partial_expansions?: string[] + } +} +``` + +--- + +### `GET /api/v1/companies/{companyId}/reports/balance-sheet` + +**Balance sheet (balansräkning) for a fiscal period.** +`scope:reports:read · risk:low · idempotent` + +Returns assets / liabilities / equity grouped into BAS sections, with the period's opening and closing balances. Sums match the income statement for the same period; the closing equity flows into next period's opening balance. + +**Use when:** You need the company's balance position at period end: typically for management reporting, year-end review, or the K2/K3 årsredovisning uppställningsform. +**Do not use for:** Per-account drill-down (use /reports/general-ledger). Net result for the period (use /reports/income-statement). + +**Pitfalls:** +- `period_id` is required. +- Balance sheet equity includes the period's computed result: recalculation happens on every call, so a freshly-posted entry is reflected immediately (no caching). + +| Parameter | In | Type | Required | Notes | +|---|---|---|---|---| +| `companyId` | path | `string` | yes | | + +Response `200`: +```ts +{ + data: unknown, + meta: { + request_id: string, + api_version: string, + next_cursor?: string, + audit?: { voucher_number?: string, voucher_url?: string, audit_trail_url?: string, immutable_at?: string }, + partial_expansions?: string[] + } +} +``` + +--- + +### `GET /api/v1/companies/{companyId}/reports/continuity-check` + +**IB/UB continuity check: opening balances match prior closing.** +`scope:reports:read · risk:low · idempotent` + +Validates that the target period's opening balances (IB) equal the prior period's closing balances (UB). The requirement derives from BFL 5 kap (löpande bokföring), BFNAR 2013:2 (systemdokumentation/behandlingshistorik), and the SIE4 spec's core invariant that #IB(year N) must equal #UB(year N-1). Returns per-account discrepancies so an operator can rectify them before period close. + +**Use when:** Before locking or closing a period, or as part of an automated year-end readiness gate. Any discrepancy is a hard data-integrity issue. +**Do not use for:** Computing balances (use /reports/balance-sheet or /reports/trial-balance). Closing the period (POST /fiscal-periods/{id}/close). + +**Pitfalls:** +- `period_id` is required. +- A non-zero discrepancy means IB ≠ prior UB and indicates the opening-balance entry was edited or the prior period was changed after close. Investigate before posting any new entries. + +| Parameter | In | Type | Required | Notes | +|---|---|---|---|---| +| `companyId` | path | `string` | yes | | + +Response `200`: +```ts +{ + data: unknown, + meta: { + request_id: string, + api_version: string, + next_cursor?: string, + audit?: { voucher_number?: string, voucher_url?: string, audit_trail_url?: string, immutable_at?: string }, + partial_expansions?: string[] + } +} +``` + +--- + +### `GET /api/v1/companies/{companyId}/reports/general-ledger` + +**General ledger (huvudbok) for a fiscal period.** +`scope:reports:read · risk:low · idempotent` + +Returns every posted journal line in the period grouped by account, with opening / running / closing balances. Supports optional `account_from` and `account_to` query parameters to limit the report to an account range (e.g. ?account_from=3000&account_to=3999 for revenue-only). + +**Use when:** You're reconciling a specific account or range (bank account drilldown, revenue audit, expense investigation) and need every voucher-line that hit the account. +**Do not use for:** Period totals only (use /reports/trial-balance). Specific transaction lookup (use /journal-entries/{id}). + +**Pitfalls:** +- `period_id` is required. +- Account ranges are inclusive on both bounds. `account_from=3000` includes 3000; `account_to=3999` includes 3999. +- Lines with `status != 'posted'` (drafts, reversed) are excluded. + +| Parameter | In | Type | Required | Notes | +|---|---|---|---|---| +| `companyId` | path | `string` | yes | | + +Response `200`: +```ts +{ + data: unknown, + meta: { + request_id: string, + api_version: string, + next_cursor?: string, + audit?: { voucher_number?: string, voucher_url?: string, audit_trail_url?: string, immutable_at?: string }, + partial_expansions?: string[] + } +} +``` + +--- + +### `GET /api/v1/companies/{companyId}/reports/income-statement` + +**Income statement (resultatrapport) for a fiscal period.** +`scope:reports:read · risk:low · idempotent` + +Returns the period's revenue and expenses grouped by BAS class with subtotals (gross margin, operating result, net result). The net result flows into the balance-sheet equity for the same period. + +**Use when:** You need the company's profit/loss for a period: month-end management reporting, K2/K3 årsredovisning resultaträkning, or feeding KPI dashboards. +**Do not use for:** Per-account drill (use /reports/general-ledger). VAT figures (use /reports/vat-declaration). Balance position (use /reports/balance-sheet). + +**Pitfalls:** +- `period_id` is required. +- Net result on the income statement equals the period's equity-line delta on the balance sheet: they're derived from the same posted entries. + +| Parameter | In | Type | Required | Notes | +|---|---|---|---|---| +| `companyId` | path | `string` | yes | | + +Response `200`: +```ts +{ + data: unknown, + meta: { + request_id: string, + api_version: string, + next_cursor?: string, + audit?: { voucher_number?: string, voucher_url?: string, audit_trail_url?: string, immutable_at?: string }, + partial_expansions?: string[] + } +} +``` + +--- + +### `GET /api/v1/companies/{companyId}/reports/journal-register` + +**Journal register (verifikationsregister) for a fiscal period.** +`scope:reports:read · risk:low · idempotent` + +Returns every committed journal entry in the period with its voucher number, date, description, and complete debit/credit line set. The canonical compliance report: what an accountant or Skatteverket audit would pull as proof of every booking. + +**Use when:** You need the BFL-required register of all verifikationer for a period: typically for an audit, year-end review, or feeding an external accountant's tooling. +**Do not use for:** Per-account drilldown (use /reports/general-ledger). Aggregate totals only (use /reports/trial-balance). + +**Pitfalls:** +- `period_id` is required. +- Output includes every line of every entry: large periods produce large responses. Consider paginating client-side or filtering by date range via /journal-entries list if you only need a slice. +- Reversed entries appear with status `reversed`; the original they reversed also remains. + +| Parameter | In | Type | Required | Notes | +|---|---|---|---|---| +| `companyId` | path | `string` | yes | | + +Response `200`: +```ts +{ + data: unknown, + meta: { + request_id: string, + api_version: string, + next_cursor?: string, + audit?: { voucher_number?: string, voucher_url?: string, audit_trail_url?: string, immutable_at?: string }, + partial_expansions?: string[] + } +} +``` + +--- + +### `GET /api/v1/companies/{companyId}/reports/monthly-breakdown` + +**Income statement broken down by month for a fiscal period.** +`scope:reports:read · risk:low · idempotent` + +Returns revenue + expenses + net result per calendar month inside the fiscal period. The sum across all months equals the period's full income-statement totals. + +**Use when:** Building a trend chart, computing rolling KPIs, or producing a månadsrapport for management. +**Do not use for:** Single-month snapshot only (call /reports/income-statement with a month-sized period). Cash flow analysis (a dedicated cash-flow report is not yet on v1). + +**Pitfalls:** +- `period_id` is required. + +| Parameter | In | Type | Required | Notes | +|---|---|---|---|---| +| `companyId` | path | `string` | yes | | + +Response `200`: +```ts +{ + data: unknown, + meta: { + request_id: string, + api_version: string, + next_cursor?: string, + audit?: { voucher_number?: string, voucher_url?: string, audit_trail_url?: string, immutable_at?: string }, + partial_expansions?: string[] + } +} +``` + +--- + +### `GET /api/v1/companies/{companyId}/reports/salary-journal` + +**Salary journal (lönejournal) for a year and optional month range.** +`scope:payroll:read · risk:low · idempotent` + +Returns per-employee salary figures (gross / tax / net / avgifter / vacation accrual) summed across booked salary runs in `year`. Optional `month_from` and `month_to` limit the window. The output mirrors the dashboard's lönejournal export. ⚠️ KU (kontrolluppgift) preparation requires the FULL annual paid amount per employee: if any salary runs are in paid-but-unbooked state at KU time, generating KU from this report will understate wages (an SFL obligation breach). Confirm all paid runs are booked before using this report for KU. + +**Use when:** Year-end KU preparation, employee comp reviews, reconciliation against the 7xxx wage accounts. +**Do not use for:** Per-run drill-down (use /salary-runs/{id} once the per-employee endpoint ships). AGI declarations (POST /salary-runs/{id}/generate-agi). + +**Pitfalls:** +- `year` is required (integer 2020-2100). +- Only `booked` salary runs are included: `draft`/`review`/`approved`/`paid` runs are excluded as they aren't legally final. +- `paid`-but-unbooked runs are EXCLUDED. This means the report reconciles cleanly against BAS 7xxx (the ledger), but an AGI-vs-ledger cross-check will show a gap until the run is booked. The AGI is filed at `approved`/`paid` (Phase 5 PR-2 allows it from `review`), so reconciling AGI against this report requires waiting until every paid run is also booked. +- month_from/month_to are 1-12 inclusive. + +| Parameter | In | Type | Required | Notes | +|---|---|---|---|---| +| `companyId` | path | `string` | yes | | + +Response `200`: +```ts +{ + data: unknown, + meta: { + request_id: string, + api_version: string, + next_cursor?: string, + audit?: { voucher_number?: string, voucher_url?: string, audit_trail_url?: string, immutable_at?: string }, + partial_expansions?: string[] + } +} +``` + +--- + +### `GET /api/v1/companies/{companyId}/reports/sie-export` + +**SIE4 export (.se file) for a fiscal period.** +`scope:reports:read · risk:low · idempotent` + +Returns the period's SIE4 export as text/plain UTF-8. Includes #FNAMN / #ORGNR header, #KONTO chart, #IB/#UB opening + closing balances, #RES result-account totals, and every #VER + #TRANS verifikation in the period. The byte stream matches what the dashboard's `/api/reports/sie-export` produces. + +**Use when:** Year-end accountant handoff, migration to another bookkeeping system, audit archival, BFL 7 kap räkenskapsinformation backup. +**Do not use for:** JSON drilldown of period entries (use /reports/journal-register). Full archive including documents (use /reports/full-archive: not yet on v1). + +**Pitfalls:** +- `period_id` is required. +- The response is text/plain with Content-Disposition: attachment: clients should treat as a binary download. Filename uses the pattern `export_{period_id}.se`. +- The compulsory #FORMAT PC8 tag is always present, but default byte encoding is UTF-8 (the de-facto cloud convention; importers detect encoding from the bytes). Pass `encoding=cp437` for actual CP437 bytes, required by some legacy desktop bookkeeping software. +- Only `posted` entries are exported; drafts and reversed entries' originals are included but marked accordingly. + +| Parameter | In | Type | Required | Notes | +|---|---|---|---|---| +| `companyId` | path | `string` | yes | | + +Response `200` (`text/plain`). + +--- + +### `GET /api/v1/companies/{companyId}/reports/supplier-ledger` + +**Supplier ledger: unpaid supplier invoices with aging.** +`scope:reports:read · risk:low · idempotent` + +Returns the supplier-payable ledger as of `as_of_date` (defaults to today). Each supplier entry includes outstanding invoices grouped into aging buckets. Reconciles against BAS 2440. + +**Use when:** AP workflow dashboards, due-date prioritisation, reconciliation against the 2440 trial-balance figure. +**Do not use for:** Listing all supplier invoices regardless of status (use /supplier-invoices). Initiating payment (the v1 surface does not expose payment files yet). + +**Pitfalls:** +- `as_of_date` is optional; format `YYYY-MM-DD`. Defaults to today (UTC). +- Only invoices with outstanding `remaining_amount > 0` appear. Credited and fully-paid invoices are excluded. + +| Parameter | In | Type | Required | Notes | +|---|---|---|---|---| +| `companyId` | path | `string` | yes | | + +Response `200`: +```ts +{ + data: unknown, + meta: { + request_id: string, + api_version: string, + next_cursor?: string, + audit?: { voucher_number?: string, voucher_url?: string, audit_trail_url?: string, immutable_at?: string }, + partial_expansions?: string[] + } +} +``` + +--- + +### `GET /api/v1/companies/{companyId}/reports/trial-balance` + +**Trial balance (huvudboksrapport) for a fiscal period.** +`scope:reports:read · risk:low · idempotent` + +Returns the per-account opening balance + period debit/credit + closing balance plus run-level totals and an `isBalanced` flag. The numbers come from the same `lib/reports/trial-balance.ts` generator the dashboard uses. + +**Use when:** You need a snapshot of every active account's movement during a period: typically the first report an accountant checks before running balance sheet or income statement. +**Do not use for:** Reconciliation against AR/AP (use /reports/ar-ledger or /supplier-ledger). Specific account drill-in (use /reports/general-ledger with account_from/account_to filters). + +**Pitfalls:** +- `period_id` is required as a query parameter. +- `isBalanced=false` means the period has unbalanced postings: a data-integrity red flag. The lib generator rounds at the source so a true imbalance is rare; investigate immediately. +- Closed/locked periods are still queryable: the report is read-only. + +| Parameter | In | Type | Required | Notes | +|---|---|---|---|---| +| `companyId` | path | `string` | yes | | + +Response `200`: +```ts +{ + data: { + rows: { account: string, account_name: string, opening_balance: number, period_debit: number, period_credit: number, closing_balance: number }[], + totalDebit: number, + totalCredit: number, + isBalanced: boolean + }, + meta: { + request_id: string, + api_version: string, + next_cursor?: string, + audit?: { voucher_number?: string, voucher_url?: string, audit_trail_url?: string, immutable_at?: string }, + partial_expansions?: string[] + } +} +``` + +--- + +### `GET /api/v1/companies/{companyId}/reports/vacation-liability` + +**Vacation liability (semesterlöneskuld) per employee at year-end.** +`scope:payroll:read · risk:low · idempotent` + +Returns per-employee semesterlöneskuld balances as of year-end based on their vacation_rule (procentregeln / sammaloneregeln) and accrued days. For employees on procentregeln or sammaloneregeln the row total contributes to the BAS 2920 closing balance. Employees on `none` or `semesterersattning` are excluded because their cost is expensed immediately (no balance-sheet accrual): the BAS 2920 reconciliation against this report is therefore CORRECT whether or not the company has semesterersättning employees, since those employees contribute zero to both the report and the 2920 balance. Feeds the K2/K3 årsredovisning notes. + +**Use when:** Year-end reconciliation between the accrued liability on 2920 and the per-employee detail. Audit prep. +**Do not use for:** Real-time accrual posting (handled per salary run). Vacation request management (not in scope for v1). + +**Pitfalls:** +- `year` is required. +- Employees with vacation_rule = none or semesterersattning are excluded: they have no semesterlöneskuld liability. + +| Parameter | In | Type | Required | Notes | +|---|---|---|---|---| +| `companyId` | path | `string` | yes | | + +Response `200`: +```ts +{ + data: unknown, + meta: { + request_id: string, + api_version: string, + next_cursor?: string, + audit?: { voucher_number?: string, voucher_url?: string, audit_trail_url?: string, immutable_at?: string }, + partial_expansions?: string[] + } +} +``` + +--- + +### `GET /api/v1/companies/{companyId}/reports/vat-declaration` + +**Swedish VAT declaration (momsdeklaration) for a period.** +`scope:reports:read · risk:low · idempotent` + +Computes momsdeklaration rutor for the given period_type / year / period. The result includes ruta 05 (domestic taxable sales), 10-12 (output VAT 25/12/6%), 20-24 (EU acquisitions of goods + tax on services from EU/non-EU), 30-32 (reverse-charge output VAT 25/12/6%), 39 (export), 40 (EU-services / momsfri försäljning), 48 (input VAT), 50 (import beskattningsunderlag), 60-62 (calculated output VAT on imports 25/12/6%), and 49 (moms att betala/återfå: the bottom line). Mapping rules match SKV 4700. + +**Use when:** Submitting momsdeklaration to Skatteverket, reconciling VAT balances at month/quarter end, or building a VAT-payable dashboard. +**Do not use for:** Specific transaction VAT lookups (use /transactions/{id}). Period-mismatch reconciliation (use /reports/general-ledger filtered to 26xx accounts). + +**Pitfalls:** +- `period_type` (monthly|quarterly|yearly), `year`, and `period` are all required. +- For monthly: period is 1-12. For quarterly: period is 1-4. For yearly: period is 1. +- `accounting_method` is accepted for backward compatibility but has no effect on the figures: the declaration is a pure ledger projection, and the method (faktureringsmetoden vs kontantmetoden per ML 15 kap 8-11 §§, ML 2023:200) is already reflected in when VAT-bearing journal entries are posted. +- Output ruta 49 = (10+11+12+30+31+32+60+61+62) − 48. Positive = pay; negative = refund. + +| Parameter | In | Type | Required | Notes | +|---|---|---|---|---| +| `companyId` | path | `string` | yes | | + +Response `200`: +```ts +{ + data: unknown, + meta: { + request_id: string, + api_version: string, + next_cursor?: string, + audit?: { voucher_number?: string, voucher_url?: string, audit_trail_url?: string, immutable_at?: string }, + partial_expansions?: string[] + } +} +``` diff --git a/skills/accounted-api/references/salary-runs.md b/skills/accounted-api/references/salary-runs.md new file mode 100644 index 00000000..6100d8aa --- /dev/null +++ b/skills/accounted-api/references/salary-runs.md @@ -0,0 +1,860 @@ + + +# Salary runs endpoints + +Swedish payroll runs: create -> calculate -> approve -> book/mark-paid -> generate-agi (arbetsgivardeklaration), with per-employee payslips and draft-only line edits. + +Conventions (auth, envelope, pagination, dry-run, idempotency, standard errors) +are in SKILL.md and are not repeated per endpoint. + +### `GET /api/v1/companies/{companyId}/salary-runs` + +**List salary runs.** +`scope:payroll:read · risk:low · idempotent` + +Returns salary runs in created-first order with their lifecycle status (draft|review|approved|paid|booked|corrected) and denormalised totals. Filters: ?period_year=YYYY, ?status=draft. + +**Use when:** You need an overview of payroll activity: for building a list view, finding the current open run, or resolving a salary_run_id before invoking a lifecycle verb. +**Do not use for:** Per-employee details (those live on the detail endpoint). Salary journal report (use GET /reports/salary-journal in Phase 5 PR-3). + +**Pitfalls:** +- A company has at most one salary run per (period_year, period_month). The unique constraint is at the DB layer. +- Totals are denormalised: they are 0 until POST /calculate runs. +- `corrected` status is reached via the internal /correct route (not yet exposed on v1): Phase 5 PR-1 ships create/calculate/approve/mark-paid/book/generate-agi only. + +| Parameter | In | Type | Required | Notes | +|---|---|---|---|---| +| `companyId` | path | `string` | yes | | + +Response `200`: +```ts +{ + data: { id: string, period_year: number, period_month: number, payment_date: string, status: "draft" | "review" | "approved" | "paid" | "booked" | "corrected", voucher_series: string, total_gross: number, total_tax: number, total_net: number, total_avgifter: number, total_employer_cost: number, agi_generated_at: string, agi_submitted_at: string, approved_at: string, paid_at: string, booked_at: string, created_at: string }[], + meta: { + request_id: string, + api_version: string, + next_cursor?: string, + audit?: { voucher_number?: string, voucher_url?: string, audit_trail_url?: string, immutable_at?: string }, + partial_expansions?: string[] + } +} +``` + +--- + +### `POST /api/v1/companies/{companyId}/salary-runs` + +**Create a salary run.** +`scope:payroll:write · risk:low · idempotent · dry-run · reversible` + +Creates a draft salary run for the given period (period_year, period_month). The run starts empty: add employees via the internal /salary/runs/{id}/employees endpoints, then POST /salary-runs/{id}/calculate. Requires Idempotency-Key. Dry-runnable. + +**Use when:** You are starting a new month's payroll. Use dry-run first to validate the period + voucher_series choice without committing. +**Do not use for:** Adding employees to an existing run (that is a separate surface: see internal /salary/runs/{id}/employees for Phase 5 PR-1; promoting it to v1 is deferred to a follow-up). + +**Pitfalls:** +- Idempotency-Key is mandatory. +- Duplicate (period_year, period_month) for the same company returns 409 SALARY_RUN_DUPLICATE_PERIOD. +- period_month is 1-12. The DB CHECK enforces this: a 0 or 13 returns 400 VALIDATION_ERROR before reaching the DB. +- voucher_series defaults to "A". If the company uses a dedicated salary voucher series, set it explicitly. +- A newly-created run has no employees: :calculate without employees returns 400 SALARY_RUN_NO_EMPLOYEES. + +| Parameter | In | Type | Required | Notes | +|---|---|---|---|---| +| `companyId` | path | `string` | yes | | + +Request body: +```ts +{ + period_year: number, + period_month: number, + payment_date: string, + voucher_series?: string, + notes?: string +} +``` + +Response `200`: +```ts +{ + data: { + id: string, + period_year: number, + period_month: number, + payment_date: string, + status: "draft" | "review" | "approved" | "paid" | "booked" | "corrected", + voucher_series: string, + total_gross: number, + total_tax: number, + total_net: number, + total_avgifter: number, + total_employer_cost: number, + agi_generated_at: string, + agi_submitted_at: string, + approved_at: string, + paid_at: string, + booked_at: string, + created_at: string, + notes: string, + calculation_params: unknown, + updated_at: string + }, + meta: { + request_id: string, + api_version: string, + next_cursor?: string, + audit?: { voucher_number?: string, voucher_url?: string, audit_trail_url?: string, immutable_at?: string }, + partial_expansions?: string[] + } +} +``` + +--- + +### `GET /api/v1/companies/{companyId}/salary-runs/{id}` + +**Get a salary run.** +`scope:payroll:read · risk:low · idempotent` + +Returns the salary run's lifecycle state, denormalised totals (gross/tax/net/avgifter/vacation/employer_cost), and references to the journal entries it produced (once :book has run). + +**Use when:** You have a salary_run_id and need its current status: typically to decide which lifecycle verb to call next, or to display the run header in a UI. +**Do not use for:** Per-employee breakdown: use GET /salary-runs/{id}/employees (list) or /salary-runs/{id}/employees/{employeeId} (payslip detail). Salary journal report: use GET /reports/salary-journal. + +**Pitfalls:** +- salary_entry_id / avgifter_entry_id / vacation_entry_id are null until POST /book has run. They reference the journal_entries table. +- total_* fields are 0 until POST /calculate has run. + +| Parameter | In | Type | Required | Notes | +|---|---|---|---|---| +| `companyId` | path | `string` | yes | | +| `id` | path | `string` | yes | | + +Response `200`: +```ts +{ + data: { + id: string, + period_year: number, + period_month: number, + payment_date: string, + status: "draft" | "review" | "approved" | "paid" | "booked" | "corrected", + voucher_series: string, + total_gross: number, + total_tax: number, + total_net: number, + total_avgifter: number, + total_vacation_accrual: number, + total_employer_cost: number, + salary_entry_id: string, + avgifter_entry_id: string, + vacation_entry_id: string, + agi_generated_at: string, + agi_submitted_at: string, + calculation_params: unknown, + approved_by: string, + approved_at: string, + paid_at: string, + booked_at: string, + booked_by: string, + notes: string, + created_at: string, + updated_at: string + }, + meta: { + request_id: string, + api_version: string, + next_cursor?: string, + audit?: { voucher_number?: string, voucher_url?: string, audit_trail_url?: string, immutable_at?: string }, + partial_expansions?: string[] + } +} +``` + +--- + +### `PATCH /api/v1/companies/{companyId}/salary-runs/{id}` + +**Update a draft salary run.** +`scope:payroll:write · risk:low · idempotent · dry-run` + +Updates payment_date, voucher_series, or notes on a draft salary run. ONLY allowed when status === "draft": once :calculate has advanced the run to review, these fields are frozen because they feed into the verifikation that :book will eventually post. + +**Use when:** You created a draft, then noticed payment_date should be different (e.g. moved from the 25th to the 23rd) before running :calculate. +**Do not use for:** Changing period_year / period_month (immutable: DELETE the draft and create a new one). Modifying employees in the run (not in v1 PR-1 scope). + +**Pitfalls:** +- Returns 400 SALARY_RUN_PATCH_NOT_DRAFT if status !== "draft". +- period_year + period_month are immutable post-create. + +| Parameter | In | Type | Required | Notes | +|---|---|---|---|---| +| `companyId` | path | `string` | yes | | +| `id` | path | `string` | yes | | + +Request body: +```ts +{ payment_date?: string, voucher_series?: string, notes?: string } +``` + +Response `200`: +```ts +{ + data: { + id: string, + period_year: number, + period_month: number, + payment_date: string, + status: "draft" | "review" | "approved" | "paid" | "booked" | "corrected", + voucher_series: string, + total_gross: number, + total_tax: number, + total_net: number, + total_avgifter: number, + total_vacation_accrual: number, + total_employer_cost: number, + salary_entry_id: string, + avgifter_entry_id: string, + vacation_entry_id: string, + agi_generated_at: string, + agi_submitted_at: string, + calculation_params: unknown, + approved_by: string, + approved_at: string, + paid_at: string, + booked_at: string, + booked_by: string, + notes: string, + created_at: string, + updated_at: string + }, + meta: { + request_id: string, + api_version: string, + next_cursor?: string, + audit?: { voucher_number?: string, voucher_url?: string, audit_trail_url?: string, immutable_at?: string }, + partial_expansions?: string[] + } +} +``` + +--- + +### `DELETE /api/v1/companies/{companyId}/salary-runs/{id}` + +**Delete a draft salary run.** +`scope:payroll:write · risk:low · idempotent · dry-run` + +Hard-deletes a salary run. ONLY allowed when status === "draft": once the run has calculated numbers or posted a verifikation, BFL 5 kap immutability applies and storno is the only correction path. CASCADE deletes salary_run_employees and salary_line_items. + +**Use when:** You created a run by mistake or want to recreate it with different period_month. Only draft runs can be deleted. +**Do not use for:** Reverting a booked run (use the internal /correct flow; v1 promotion deferred). Hiding a run from listings (no soft-delete on this table: drafts are truly removed). + +**Pitfalls:** +- Returns 400 SALARY_RUN_DELETE_NOT_DRAFT for any status other than draft. +- Hard delete: the salary_run_employees + salary_line_items rows cascade away. +- Idempotent in the absent-row sense: DELETE on a non-existent id returns 404 SALARY_RUN_NOT_FOUND rather than re-emitting a deletion event. + +| Parameter | In | Type | Required | Notes | +|---|---|---|---|---| +| `companyId` | path | `string` | yes | | +| `id` | path | `string` | yes | | + +Response `204`. + +--- + +### `POST /api/v1/companies/{companyId}/salary-runs/{id}/approve` + +**Approve a reviewed salary run.** +`scope:payroll:write · risk:low · idempotent · dry-run` + +Advances a salary run from `review` to `approved` after validating every employee has the data required for the payment step (bank account + clearing number for the bank transfer) and the booking step (`calculation_breakdown` proves `:calculate` ran). Records the approving user + timestamp. Strict-mode: validation errors return a complete list rather than failing on the first one. + +**Use when:** You have a salary run in `review` status and want to authorize it for payment. This is the human (or agent) signoff step before money moves; the verifikation is still pending and won't exist until `:book` runs. +**Do not use for:** Posting journal entries (use `:book` after `:mark-paid`). Reverting an approval (the lifecycle has no `:unapprove`: call `:correct` once the run is booked if you need to undo). + +**Pitfalls:** +- Run must be in `review`: non-`review` runs return 400 SALARY_RUN_APPROVE_NOT_REVIEW. +- Every employee on the run needs a `clearing_number` + `bank_account_number`. Missing bank details return 400 SALARY_RUN_APPROVE_VALIDATION_FAILED with the per-employee list. +- Every employee on the run needs `calculation_breakdown` populated. If you skipped `:calculate` somehow, approve fails. +- Employees without email get a non-blocking warning (lönebesked can't be sent automatically). +- No period-lock check here: that lives on `:book` where the verifikation is posted. An agent can approve a run whose payment date falls in a now-locked period; `:book` will later refuse. + +| Parameter | In | Type | Required | Notes | +|---|---|---|---|---| +| `companyId` | path | `string` | yes | | +| `id` | path | `string` | yes | | + +Response `200`: +```ts +{ + data: { id: string, status: "approved", approved_at: string, approved_by: string, warnings: string[] }, + meta: { + request_id: string, + api_version: string, + next_cursor?: string, + audit?: { voucher_number?: string, voucher_url?: string, audit_trail_url?: string, immutable_at?: string }, + partial_expansions?: string[] + } +} +``` + +--- + +### `POST /api/v1/companies/{companyId}/salary-runs/{id}/book` + +**Post the verifikationer for a paid salary run.** +`scope:payroll:write · risk:high · idempotent · dry-run` + +Creates 2-4 journal entries (1: salary brutto/tax/net; 2: arbetsgivaravgifter; 3 if applicable: semesterlöneskuld accrual; 4 if applicable: pension + SLP from löneväxling), then advances status `paid` → `booked` with all the entry IDs recorded on the salary_runs row. Strict-mode: any engine failure aborts BEFORE the status flip: the run stays in `paid` so the caller can fix the cause (locked period, missing BAS account, etc.) and retry. + +**Use when:** You've marked a salary run as paid and want to post the BFL-required verifikationer. This is the final lifecycle verb before AGI generation; after :book, the run can no longer be edited and corrections must use the (forthcoming) `:correct` verb. +**Do not use for:** Posting salary entries outside the salary-run lifecycle (use POST /journal-entries directly). Re-booking an already-booked run (returns 400 SALARY_RUN_BOOK_NOT_PAID). + +**Pitfalls:** +- Run must be in `paid`: non-`paid` runs return 400 SALARY_RUN_BOOK_NOT_PAID. +- payment_date must fall in an open fiscal period: locked period returns 400 PERIOD_LOCKED with `fiscal_period_id` and a hint of what unlock action is needed. +- BFL 5 kap immutability: once `:book` succeeds the verifikationer cannot be edited or deleted. Corrections require `:correct` (Phase 5 PR-3) which does a storno-then-rebook. +- The salary verifikation is the primary one; its voucher_number appears in the response audit block. The avgifter, vacation, and pension entries get separate voucher numbers (returned as `entry_ids`). +- Strict-mode: if the engine fails partway, the salary_runs row stays in `paid`. There is no "partial booking": the engine either commits all entries or the entire booking fails. + +| Parameter | In | Type | Required | Notes | +|---|---|---|---|---| +| `companyId` | path | `string` | yes | | +| `id` | path | `string` | yes | | + +Response `200`: +```ts +{ + data: { + id: string, + status: "booked", + booked_at: string, + booked_by: string, + salary_entry_id: string, + avgifter_entry_id: string, + vacation_entry_id: string, + pension_entry_id: string, + entry_ids: string[] + }, + meta: { + request_id: string, + api_version: string, + next_cursor?: string, + audit?: { voucher_number?: string, voucher_url?: string, audit_trail_url?: string, immutable_at?: string }, + partial_expansions?: string[] + } +} +``` + +--- + +### `POST /api/v1/companies/{companyId}/salary-runs/{id}/calculate` + +**Calculate a draft salary run and advance it to review.** +`scope:payroll:write · risk:medium · idempotent · dry-run` + +Runs the per-employee payroll calculation (tax withholding, employer contributions, vacation accrual) for every employee on a draft run, persists the line items + run totals + calculation_params snapshot, then promotes status from draft to review in a single atomic verb. Returns the updated run plus a `warnings` array surfacing non-blocking issues (Skatteverket tax-table fallback, läkarintyg day-8 transition, Försäkringskassan day-15 transition, F-skatt not-verified employees). Strict-mode: any failure (validation, tax-table unavailable, DB error) aborts before the status flip: the run stays in draft. + +**Use when:** You have a draft salary run with employees added and want to compute the numbers + freeze them for approval. This is the first lifecycle verb after creating a run. +**Do not use for:** re-running a salary run already in review or later (only `draft` is accepted: call POST :correct in Phase 5 PR-3 once that ships to revise a booked run). Adding employees to the run (that surface is not yet on v1; use the dashboard). + +**Pitfalls:** +- Run must be in `draft` status: calculate on a non-draft run returns 400 SALARY_RUN_CALCULATE_NOT_DRAFT. +- Salary run must have at least one employee: empty runs return 400 SALARY_RUN_NO_EMPLOYEES. +- If Skatteverket's tax-table API is down and local fallback is missing the required table, calculate returns 503 SALARY_RUN_TAX_TABLE_MISSING. Retry is safe; the operation is idempotent at the helper level. +- F-skatt "not_verified" employees produce a non-blocking warning; an integrator should treat the warning as a hard signal that withholding will be wrong until F-skatt is verified. +- Warnings about tax-table fallback or läkarintyg / FK day-15 transitions are non-blocking; the run still advances to review. Surface them to a human reviewer before calling :approve. + +| Parameter | In | Type | Required | Notes | +|---|---|---|---|---| +| `companyId` | path | `string` | yes | | +| `id` | path | `string` | yes | | + +Response `200`: +```ts +{ + data: { + id: string, + status: "review", + period_year: number, + period_month: number, + total_gross: number, + total_tax: number, + total_net: number, + total_avgifter: number, + total_employer_cost: number, + warnings: string[] + }, + meta: { + request_id: string, + api_version: string, + next_cursor?: string, + audit?: { voucher_number?: string, voucher_url?: string, audit_trail_url?: string, immutable_at?: string }, + partial_expansions?: string[] + } +} +``` + +--- + +### `GET /api/v1/companies/{companyId}/salary-runs/{id}/employees` + +**List per-employee results of a salary run.** +`scope:payroll:read · risk:low · idempotent` + +Returns one row per employee in the run with the calculated aggregates: gross salary, tax withheld, net pay, arbetsgivaravgifter, vacation accrual, and absence day counts. All aggregate fields are 0 until POST /calculate has run. Cursor pagination on (created_at, id). + +**Use when:** You need the per-employee outcome of a run: to review before approval, to reconcile against an external system, or to pick an employee_id for the payslip drill-in. +**Do not use for:** Payslip line items or the step-by-step calculation breakdown: use GET /salary-runs/{id}/employees/{employeeId}. The employee master record: use GET /employees/{id}. + +**Pitfalls:** +- Aggregates are 0 until POST /calculate has advanced the run to review. +- tax_withheld_override / avgifter_amount_override are review-stage manual adjustments; the effective value is COALESCE(override, calculated). +- personnummer is masked on all payslip-shaped responses (GDPR Art.5(1)(c)); the employee detail endpoint returns the full value. + +| Parameter | In | Type | Required | Notes | +|---|---|---|---|---| +| `companyId` | path | `string` | yes | | +| `id` | path | `string` | yes | | + +Response `200`: +```ts +{ + data: { salary_run_employee_id: string, employee_id: string, first_name: string, last_name: string, personnummer_masked: string, salary_type: string, employment_degree: number, monthly_salary: number, hours_worked: number, gross_salary: number, taxable_income: number, tax_withheld: number, tax_withheld_override: number, net_salary: number, avgifter_basis: number, avgifter_amount: number, avgifter_amount_override: number, avgifter_category: string, vacation_accrual: number, sick_days: number, vab_days: number, parental_days: number, vacation_days_taken: number, created_at: string, updated_at: string }[], + meta: { + request_id: string, + api_version: string, + next_cursor?: string, + audit?: { voucher_number?: string, voucher_url?: string, audit_trail_url?: string, immutable_at?: string }, + partial_expansions?: string[] + } +} +``` + +--- + +### `POST /api/v1/companies/{companyId}/salary-runs/{id}/employees` + +**Add an employee to a draft salary run.** +`scope:payroll:write · risk:low · idempotent · dry-run · reversible` + +Attaches an active employee to a draft run: snapshots their pay configuration (salary, degree, tax table) onto the run and seeds the base salary line (Grundlön/Timlön). For hourly employees, pass hours_worked. + +**Use when:** The run was created without this employee (e.g. hired after the run was drafted), or you create runs empty and attach employees one by one from an external system. +**Do not use for:** Changing an attached employee's pay for this month (internal per-run PATCH; not on v1). Re-attaching after removal is fine: the snapshot is retaken. + +**Pitfalls:** +- Draft-only: 400 SALARY_RUN_EMPLOYEES_NOT_DRAFT once the run has advanced. +- Attaching twice returns 409 SALARY_RUN_EMPLOYEE_DUPLICATE. +- The snapshot freezes salary/degree/tax-table at attach time: later employee edits do not flow into this run. +- Inactive (soft-deleted) employees cannot be attached: 404 EMPLOYEE_NOT_FOUND. + +| Parameter | In | Type | Required | Notes | +|---|---|---|---|---| +| `companyId` | path | `string` | yes | | +| `id` | path | `string` | yes | | + +Request body: +```ts +{ employee_id: string, hours_worked?: number } +``` + +Response `200`: +```ts +{ + data: { + salary_run_employee_id: string, + employee_id: string, + salary_type: string, + employment_degree: number, + monthly_salary: number, + hours_worked: number, + tax_table_number: number, + tax_column: number + }, + meta: { + request_id: string, + api_version: string, + next_cursor?: string, + audit?: { voucher_number?: string, voucher_url?: string, audit_trail_url?: string, immutable_at?: string }, + partial_expansions?: string[] + } +} +``` + +--- + +### `GET /api/v1/companies/{companyId}/salary-runs/{id}/employees/{employeeId}` + +**Get one employee's payslip in a salary run.** +`scope:payroll:read · risk:low · idempotent` + +Returns the full payslip for one employee in a run: gross/tax/net aggregates, arbetsgivaravgifter with category, vacation accrual, YTD accumulators, every payslip line item (grundlön, tillägg, avdrag, förmåner), and the step-by-step calculation_breakdown recorded by the engine. + +**Use when:** You need to verify how a specific employee's pay was computed: reviewing a run before approval, answering "why is the tax this amount", or rendering a payslip in an external system. +**Do not use for:** The rendered PDF payslip: use GET /salary-runs/{id}/payslips/{employeeId}/pdf. Editing line items: POST/PATCH/DELETE on the lines endpoints. + +**Pitfalls:** +- calculation_breakdown is null and aggregates are 0 until POST /calculate has run. +- line_items include engine-derived rows (absence, benefits) that are regenerated on every :calculate; manual rows survive recalculation. +- The effective tax is COALESCE(tax_withheld_override, tax_withheld); same for avgifter overrides. +- personnummer is masked here (GDPR Art.5(1)(c)); GET /employees/{id} is the identity drill-in. + +| Parameter | In | Type | Required | Notes | +|---|---|---|---|---| +| `companyId` | path | `string` | yes | | +| `id` | path | `string` | yes | | +| `employeeId` | path | `string` | yes | | + +Response `200`: +```ts +{ + data: { + salary_run_employee_id: string, + salary_run_id: string, + employee_id: string, + first_name: string, + last_name: string, + personnummer_masked: string, + salary_type: string, + employment_degree: number, + monthly_salary: number, + hours_worked: number, + gross_salary: number, + gross_deductions: number, + benefit_values: number, + taxable_income: number, + tax_withheld: number, + tax_withheld_override: number, + net_deductions: number, + net_salary: number, + avgifter_rate: number, + avgifter_basis: number, + avgifter_amount: number, + avgifter_basis_override: number, + avgifter_amount_override: number, + avgifter_category: string, + override_reason: string, + vacation_accrual: number, + vacation_accrual_avgifter: number, + tax_table_number: number, + tax_column: number, + tax_table_year: number, + sick_days: number, + vab_days: number, + parental_days: number, + vacation_days_taken: number, + ytd_gross: number, + ytd_tax: number, + ytd_net: number, + calculation_breakdown: unknown, + line_items: { salary_line_item_id: string, item_type: string, description: string, quantity: number, unit_price: number, amount: number, is_taxable: boolean, is_avgift_basis: boolean, is_vacation_basis: boolean, is_gross_deduction: boolean, is_net_deduction: boolean, account_number: string, sort_order: number }[], + created_at: string, + updated_at: string + }, + meta: { + request_id: string, + api_version: string, + next_cursor?: string, + audit?: { voucher_number?: string, voucher_url?: string, audit_trail_url?: string, immutable_at?: string }, + partial_expansions?: string[] + } +} +``` + +--- + +### `DELETE /api/v1/companies/{companyId}/salary-runs/{id}/employees/{employeeId}` + +**Remove an employee from a draft salary run.** +`scope:payroll:write · risk:low · idempotent · dry-run · reversible` + +Detaches the employee from the run and cascades away their payslip line items. Draft-only. The employee master record is untouched: this only affects the run roster. + +**Use when:** An employee should not be paid this period (unpaid leave the whole month, employment ended) but was auto-added when the run was created. +**Do not use for:** Deactivating the employee entirely: DELETE /employees/{id} (soft-delete). Zero-salary months: keep them in the run with a 0 base instead if you want a nollkörning on record. + +**Pitfalls:** +- Draft-only: 400 SALARY_RUN_EMPLOYEES_NOT_DRAFT once the run has advanced. +- Cascade-deletes the employee's line items in this run, including manual ones. +- Re-attaching later retakes the pay snapshot from the employee master. + +| Parameter | In | Type | Required | Notes | +|---|---|---|---|---| +| `companyId` | path | `string` | yes | | +| `id` | path | `string` | yes | | +| `employeeId` | path | `string` | yes | | + +Response `204`. + +--- + +### `POST /api/v1/companies/{companyId}/salary-runs/{id}/employees/{employeeId}/lines` + +**Add a payslip line to an employee in a draft salary run.** +`scope:payroll:write · risk:low · idempotent · dry-run · reversible` + +Creates a salary_line_items row (bonus, overtime, gross/net deduction, benefit, traktamente, ...) for one employee in a draft run. account_number auto-resolves from item_type when omitted. Amounts are rounded to whole öre. + +**Use when:** You need to add a one-off pay component before calculating: a bonus, an expense reimbursement, a union fee, or a manual correction line. +**Do not use for:** Editing the base monthly salary (PATCH the run-employee via the internal surface; not on v1 yet). Absence: register absence days instead (PUT /employees/{id}/absence); the engine derives sick/VAB lines itself. + +**Pitfalls:** +- Draft-only: returns 400 SALARY_RUN_LINE_NOT_DRAFT once the run has advanced. +- Line edits do not recompute tax or totals: call POST /salary-runs/{id}/calculate afterwards. +- Engine-derived lines (absence, benefits) are regenerated on every :calculate; manual lines survive. + +| Parameter | In | Type | Required | Notes | +|---|---|---|---|---| +| `companyId` | path | `string` | yes | | +| `id` | path | `string` | yes | | +| `employeeId` | path | `string` | yes | | + +Request body: +```ts +{ + item_type: "monthly_salary" | "hourly_salary" | "overtime" | "overtime_50" | "overtime_100" | "ob_weekday_evening" | "ob_weekend" | "ob_night" | "ob_holiday" | "bonus" | "commission" | "gross_deduction_pension" | "gross_deduction_other" | "benefit_car" | "benefit_housing" | "benefit_meals" | "benefit_wellness" | "benefit_bike" | "benefit_other" | "sick_karens" | "sick_day2_14" | "sick_day15_plus" | "vab" | "parental_leave" | "vacation" | "semesterersattning" | "traktamente_taxfree" | "traktamente_taxable" | "mileage_taxfree" | "mileage_taxable" | "net_deduction_advance" | "net_deduction_union" | "net_deduction_benefit_payment" | "net_deduction_other" | "correction" | "other", + description: string, + quantity?: number, + unit_price?: number, + amount: number, + is_taxable?: boolean, + is_avgift_basis?: boolean, + is_vacation_basis?: boolean, + is_gross_deduction?: boolean, + is_net_deduction?: boolean, + account_number?: string, + sort_order?: number +} +``` + +Response `200`: +```ts +{ + data: { + salary_line_item_id: string, + salary_run_employee_id: string, + item_type: string, + description: string, + quantity: number, + unit_price: number, + amount: number, + is_taxable: boolean, + is_avgift_basis: boolean, + is_vacation_basis: boolean, + is_gross_deduction: boolean, + is_net_deduction: boolean, + account_number: string, + sort_order: number + }, + meta: { + request_id: string, + api_version: string, + next_cursor?: string, + audit?: { voucher_number?: string, voucher_url?: string, audit_trail_url?: string, immutable_at?: string }, + partial_expansions?: string[] + } +} +``` + +--- + +### `POST /api/v1/companies/{companyId}/salary-runs/{id}/generate-agi` + +**Generate the Skatteverket AGI XML for a salary run.** +`scope:payroll:write · risk:medium · idempotent` + +Generates the arbetsgivardeklaration-på-individnivå XML for the run (HU section + per-employee IU + Frånvarouppgift for VAB/parental), upserts the agi_declarations row (correction-aware), stamps salary_runs.agi_generated_at, emits `agi.generated`, and auto-completes the `arbetsgivardeklaration` deadline. Returns the XML as a string field in the v1 envelope: agents extract `data.xml` and forward to Skatteverket directly (Mina Sidor upload or via a connected extension). + +**Use when:** You've reviewed (or approved / paid / booked) a salary run and need to file AGI with Skatteverket. The Skatteverket filing deadline is the 12th of the following month (17th in Jan / Aug for companies ≤40 MSEK turnover). +**Do not use for:** Submitting the AGI to Skatteverket: this endpoint only generates and persists the XML. Submission is a separate flow via the (optional) `skatteverket` extension. + +**Pitfalls:** +- Run status must be one of review, approved, paid, booked, corrected: `draft` returns 400 AGI_GENERATE_NOT_BOOKABLE. +- Generating AGI from a `review`-status run risks submitting figures that will change at `:approve`. The dashboard allows this for flexibility; agents should prefer `approved+` unless an early-warning workflow specifically wants the preview. +- Subsequent calls for the same period UPDATE the agi_declarations row (is_correction=true) and overwrite the XML. The FK570 specifikationsnummer stays consistent per employee: different number = new record per Skatteverket spec. +- AGI_INCOMPLETE_DATA returns 400 when company contact info is missing (org_number, contact name, phone, email). Fix via /settings/company before retrying. +- The XML content is räkenskapsinformation: BFL 7 kap retention applies. The agi_declarations row is never auto-deleted. + +| Parameter | In | Type | Required | Notes | +|---|---|---|---|---| +| `companyId` | path | `string` | yes | | +| `id` | path | `string` | yes | | + +Response `200`: +```ts +{ + data: { + agi_declaration_id: string, + period_year: number, + period_month: number, + employee_count: number, + is_correction: boolean, + totals: { totalTax: number, totalAvgifterBasis: number, totalAvgifterAmount: number, totalSjuklonekostnad: number, avgifterByCategory: Record }, + xml: string, + xml_filename: string + }, + meta: { + request_id: string, + api_version: string, + next_cursor?: string, + audit?: { voucher_number?: string, voucher_url?: string, audit_trail_url?: string, immutable_at?: string }, + partial_expansions?: string[] + } +} +``` + +--- + +### `PATCH /api/v1/companies/{companyId}/salary-runs/{id}/lines/{lineId}` + +**Update a payslip line in a draft salary run.** +`scope:payroll:write · risk:low · idempotent · dry-run · reversible` + +Updates fields on a salary_line_items row (amount, description, quantity, unit_price, flags, account_number) while the run is a draft. Amounts are rounded to whole öre. + +**Use when:** You spotted a wrong amount or description on a manual line before calculating: fix it in place instead of delete + recreate. +**Do not use for:** Post-calculation tax/avgifter adjustments (review-stage overrides are not on v1). Engine-derived lines (absence/benefits): they are regenerated by :calculate, so edits are overwritten. + +**Pitfalls:** +- Draft-only: 400 SALARY_RUN_LINE_NOT_DRAFT once the run has advanced. +- A lineId that belongs to a different run returns 404 SALARY_LINE_NOT_FOUND. +- Line edits do not recompute tax or totals: call POST /salary-runs/{id}/calculate afterwards. + +| Parameter | In | Type | Required | Notes | +|---|---|---|---|---| +| `companyId` | path | `string` | yes | | +| `id` | path | `string` | yes | | +| `lineId` | path | `string` | yes | | + +Request body: +```ts +{ + item_type?: "monthly_salary" | "hourly_salary" | "overtime" | "overtime_50" | "overtime_100" | "ob_weekday_evening" | "ob_weekend" | "ob_night" | "ob_holiday" | "bonus" | "commission" | "gross_deduction_pension" | "gross_deduction_other" | "benefit_car" | "benefit_housing" | "benefit_meals" | "benefit_wellness" | "benefit_bike" | "benefit_other" | "sick_karens" | "sick_day2_14" | "sick_day15_plus" | "vab" | "parental_leave" | "vacation" | "semesterersattning" | "traktamente_taxfree" | "traktamente_taxable" | "mileage_taxfree" | "mileage_taxable" | "net_deduction_advance" | "net_deduction_union" | "net_deduction_benefit_payment" | "net_deduction_other" | "correction" | "other", + description?: string, + quantity?: number, + unit_price?: number, + amount?: number, + is_taxable?: boolean, + is_avgift_basis?: boolean, + is_vacation_basis?: boolean, + is_gross_deduction?: boolean, + is_net_deduction?: boolean, + account_number?: string, + sort_order?: number +} +``` + +Response `200`: +```ts +{ + data: { + salary_line_item_id: string, + salary_run_employee_id: string, + item_type: string, + description: string, + quantity: number, + unit_price: number, + amount: number, + is_taxable: boolean, + is_avgift_basis: boolean, + is_vacation_basis: boolean, + is_gross_deduction: boolean, + is_net_deduction: boolean, + account_number: string, + sort_order: number + }, + meta: { + request_id: string, + api_version: string, + next_cursor?: string, + audit?: { voucher_number?: string, voucher_url?: string, audit_trail_url?: string, immutable_at?: string }, + partial_expansions?: string[] + } +} +``` + +--- + +### `DELETE /api/v1/companies/{companyId}/salary-runs/{id}/lines/{lineId}` + +**Delete a payslip line from a draft salary run.** +`scope:payroll:write · risk:low · idempotent · dry-run` + +Removes a salary_line_items row while the run is a draft. Engine-derived lines (absence, benefits) reappear on the next :calculate; delete the underlying absence/benefit record instead. + +**Use when:** A manual line (bonus, deduction) was added by mistake and the run has not been calculated/advanced yet. +**Do not use for:** Removing an employee from the run entirely: DELETE /salary-runs/{id}/employees/{employeeId}. Suppressing engine-derived lines: fix the source data (absence days, benefits). + +**Pitfalls:** +- Draft-only: 400 SALARY_RUN_LINE_NOT_DRAFT once the run has advanced. +- Deleting an engine-derived line is futile: :calculate regenerates it from source data. + +| Parameter | In | Type | Required | Notes | +|---|---|---|---|---| +| `companyId` | path | `string` | yes | | +| `id` | path | `string` | yes | | +| `lineId` | path | `string` | yes | | + +Response `204`. + +--- + +### `POST /api/v1/companies/{companyId}/salary-runs/{id}/mark-paid` + +**Mark an approved salary run as paid.** +`scope:payroll:write · risk:low · idempotent · dry-run` + +Advances a salary run from `approved` to `paid` and stamps `paid_at`. This is the state-change verb after the bank transfer (or autogiro file) has been processed; it does NOT initiate payment, and does NOT post journal entries (use `:book` after this for that). + +**Use when:** You've confirmed the salary payment hit employee bank accounts and want to advance the run's lifecycle so `:book` can post the verifikation. +**Do not use for:** Initiating the actual bank transfer (the v1 API does not yet expose payment-file generation; use the dashboard's payment-file endpoints). Posting journal entries (use `:book`). Reverting a paid run (no `:unpaid` exists: call `:correct` once booked if you need to undo). + +**Pitfalls:** +- Run must be in `approved`: non-`approved` runs return 400 SALARY_RUN_MARK_PAID_NOT_APPROVED. +- paid_at is set server-side to the current UTC timestamp; the API does not accept a body-supplied date to keep BFL audit clean. + +| Parameter | In | Type | Required | Notes | +|---|---|---|---|---| +| `companyId` | path | `string` | yes | | +| `id` | path | `string` | yes | | + +Response `200`: +```ts +{ + data: { id: string, status: "paid", paid_at: string }, + meta: { + request_id: string, + api_version: string, + next_cursor?: string, + audit?: { voucher_number?: string, voucher_url?: string, audit_trail_url?: string, immutable_at?: string }, + partial_expansions?: string[] + } +} +``` + +--- + +### `GET /api/v1/companies/{companyId}/salary-runs/{id}/payslips/{employeeId}/pdf` + +**Download one employee's payslip as PDF.** +`scope:payroll:read · risk:low · idempotent` + +Returns the rendered payslip (lönespecifikation) as application/pdf, byte-equivalent to the dashboard download. Content-Disposition is attachment with a filename derived from the period and employee name. + +**Use when:** You need the payslip document itself: archiving, forwarding to the employee outside the Accounted send flow, or attaching to an external HR system. +**Do not use for:** The payslip DATA (amounts, line items): use GET /salary-runs/{id}/employees/{employeeId}, which is cheaper and structured. Emailing payslips to employees: the send flow is internal-only today. + +**Pitfalls:** +- The PDF renders whatever the run currently holds: for a draft run that has not been calculated, amounts are 0. +- PDF rendering takes a few hundred milliseconds; cache on the client if requesting repeatedly. + +| Parameter | In | Type | Required | Notes | +|---|---|---|---|---| +| `companyId` | path | `string` | yes | | +| `id` | path | `string` | yes | | +| `employeeId` | path | `string` | yes | | + +Response `200` (`application/pdf`). diff --git a/skills/accounted-api/references/suppliers.md b/skills/accounted-api/references/suppliers.md new file mode 100644 index 00000000..d4291626 --- /dev/null +++ b/skills/accounted-api/references/suppliers.md @@ -0,0 +1,744 @@ + + +# Suppliers (AP) endpoints + +Accounts payable: supplier register and received supplier invoices (register -> approve -> mark-paid, or credit). + +Conventions (auth, envelope, pagination, dry-run, idempotency, standard errors) +are in SKILL.md and are not repeated per endpoint. + +### `GET /api/v1/companies/{companyId}/supplier-invoices` + +**List supplier invoices for a company.** +`scope:suppliers:read · risk:low · idempotent` + +Cursor-paginated supplier-invoice list ordered by created_at DESC, id ASC (newest-registered first; the `invoice_date` column is the seller's invoice date and is filterable via ?date_from / ?date_to but is not the sort key). Filters: status, supplier_id, currency, date_from / date_to (filter by invoice_date). + +**Use when:** You need to enumerate registered supplier invoices for an AP dashboard, a payment run, or a leverantörsreskontra reconciliation. +**Do not use for:** Fetching a single supplier invoice: use GET /supplier-invoices/{id}. Listing customer invoices (different resource). + +**Pitfalls:** +- Credit notes (is_credit_note=true) appear in the same list as the originals; filter by status=credited or check the flag to separate. +- remaining_amount is the unpaid portion; a partially_paid SI has remaining_amount > 0. +- arrival_number is internal book-keeping, not the seller's invoice number: use supplier_invoice_number for matching to received documents. +- Ordering is by created_at (registration time), not invoice_date. A late-registered invoice appears where it was registered: filter on ?date_from / ?date_to when you care about the seller's invoice date. +- Cursor pagination: pass ?cursor= from the previous response. A stale or tampered cursor is ignored and the first page is returned again. + +| Parameter | In | Type | Required | Notes | +|---|---|---|---|---| +| `companyId` | path | `string` | yes | | + +Response `200`: +```ts +{ + data: { id: string, supplier_id: string, supplier_name: string, arrival_number: number, supplier_invoice_number: string, invoice_date: string, due_date: string, status: "registered" | "approved" | "paid" | "partially_paid" | "overdue" | "disputed" | "credited" | "reversed", currency: string, subtotal: number, vat_amount: number, total: number, paid_amount: number, remaining_amount: number, is_credit_note: boolean, paid_at: string, created_at: string }[], + meta: { + request_id: string, + api_version: string, + next_cursor?: string, + audit?: { voucher_number?: string, voucher_url?: string, audit_trail_url?: string, immutable_at?: string }, + partial_expansions?: string[] + } +} +``` + +--- + +### `POST /api/v1/companies/{companyId}/supplier-invoices` + +**Register a new supplier invoice.** +`scope:suppliers:write · risk:medium · idempotent · dry-run · reversible` + +Creates a supplier invoice in `registered` status and posts the registration journal entry under faktureringsmetoden (Debit expense + Debit 2641 Ingående moms / Credit 2440 Leverantörsskulder). Under kontantmetoden no JE is posted at this stage. Idempotent (mandatory Idempotency-Key). Dry-runnable. + +**Use when:** You're registering an incoming leverantörsfaktura. Use dry-run first to validate VAT calculations + period-lock state before committing. +**Do not use for:** Marking an existing SI as paid (use POST /:id/mark-paid). Issuing a credit note (use POST /:id/credit). Customer invoices (different resource). + +**Pitfalls:** +- Idempotency-Key is mandatory. +- invoice_date must fall within an open fiscal period: a date covered by a locked period or the company-wide bookkeeping lock returns 400 PERIOD_LOCKED. +- Under faktureringsmetoden the registration JE is posted atomically with the SI row. JE failure aborts the whole call and no SI row is left behind (strict-mode). +- supplier_id must reference an existing, non-archived supplier in the same company: 404 SUPPLIER_NOT_FOUND otherwise. +- Duplicate (supplier_id, supplier_invoice_number) returns 409 SI_CREATE_DUPLICATE_INVOICE_NUMBER. Use the credit flow on the original instead of re-registering with a tweaked number. +- Foreign currency: omit exchange_rate and the server fetches Riksbanken's rate for invoice_date (ML 8 kap 21-23 §). If no rate can be resolved the create is refused with 400 SI_FX_RATE_MISSING rather than stored unconverted: pass exchange_rate explicitly to proceed. A SEK invoice needs no rate and gets total_sek = total. +- exchange_rate is SEK per 1 unit of the invoice currency and must satisfy 0 < rate < 100000, the same bounds the supplier_invoices CHECK enforces. Out-of-range values return 400 VALIDATION_ERROR; passing an invoice total where a rate belongs is the usual cause. +- Project/cost-center tagging: pass default_dimensions ({"6":"P001"} = project, {"1":"KS01"} = kostnadsställe) for the whole invoice and/or items[].dimensions per line (per-line wins per key). The registration JE lines are tagged accordingly. When the company has the dimension registry enabled, unknown or archived codes are rejected with 400 DIMENSION_VALIDATION_FAILED — list valid codes via GET /dimensions. + +| Parameter | In | Type | Required | Notes | +|---|---|---|---|---| +| `companyId` | path | `string` | yes | | + +Request body: +```ts +{ + supplier_id: string, + document_id?: string, + supplier_invoice_number: string, + invoice_date: string, + due_date: string, + delivery_date?: string | "", + currency?: "SEK" | "EUR" | "USD" | "GBP" | "NOK" | "DKK", + exchange_rate?: number, + vat_treatment?: "standard_25" | "reduced_12" | "reduced_6" | "reverse_charge" | "export" | "exempt", + reverse_charge?: boolean, + payment_reference?: string, + notes?: string, + ore_rounding?: boolean, + paid_with_private_funds?: boolean, + payment_date?: string, + default_dimensions?: Record, + items: { description: string, amount?: number, account_number: string, vat_rate?: 0 | 0.06 | 0.12 | 0.25, vat_amount?: number, reverse_charge_rate?: number, vat_code?: string, quantity?: number, unit?: string, unit_price?: number, accrual_period_start?: string, accrual_period_end?: string, accrual_balance_account?: string, dimensions?: Record }[] +} +``` + +Response `200`: +```ts +{ + data: { + id: string, + supplier_id: string, + arrival_number: number, + supplier_invoice_number: string, + invoice_date: string, + due_date: string, + status: string, + currency: string, + subtotal: number, + vat_amount: number, + total: number, + remaining_amount: number, + is_credit_note: boolean, + registration_journal_entry_id: string, + created_at: string + }, + meta: { + request_id: string, + api_version: string, + next_cursor?: string, + audit?: { voucher_number?: string, voucher_url?: string, audit_trail_url?: string, immutable_at?: string }, + partial_expansions?: string[] + } +} +``` + +--- + +### `GET /api/v1/companies/{companyId}/supplier-invoices/{id}` + +**Retrieve a single supplier invoice by id.** +`scope:suppliers:read · risk:low · idempotent` + +Returns the full supplier-invoice record. Pass ?expand=supplier,items,payments to embed the related rows in the same response. + +**Use when:** You need the full record before approving, paying, or crediting it, or for audit trail / reconciliation. +**Do not use for:** Listing supplier invoices (use the list endpoint). Customer-invoice lookups (different resource). + +**Pitfalls:** +- Credit notes return is_credit_note=true and a credited_invoice_id pointing at the original. +- registration_journal_entry_id and payment_journal_entry_id let you trace the SI to its bokföring rows; they are null when no JE has been posted (e.g. on a kontantmetoden SI before payment). + +| Parameter | In | Type | Required | Notes | +|---|---|---|---|---| +| `companyId` | path | `string` | yes | | +| `id` | path | `string` | yes | | + +Response `200`: +```ts +{ + data: { + id: string, + supplier_id: string, + arrival_number: number, + supplier_invoice_number: string, + invoice_date: string, + due_date: string, + received_date: string, + delivery_date: string, + status: string, + currency: string, + exchange_rate: number, + subtotal: number, + vat_amount: number, + total: number, + vat_treatment: string, + reverse_charge: boolean, + paid_amount: number, + remaining_amount: number, + is_credit_note: boolean, + credited_invoice_id: string, + registration_journal_entry_id: string, + payment_journal_entry_id: string, + notes: string, + created_at: string, + updated_at: string + }, + meta: { + request_id: string, + api_version: string, + next_cursor?: string, + audit?: { voucher_number?: string, voucher_url?: string, audit_trail_url?: string, immutable_at?: string }, + partial_expansions?: string[] + } +} +``` + +--- + +### `PATCH /api/v1/companies/{companyId}/supplier-invoices/{id}` + +**Update a registered supplier invoice.** +`scope:suppliers:write · risk:low · idempotent · dry-run · reversible` + +Patches a supplier invoice with the supplied fields. Only allowed on `registered` status: once approved, paid, or credited, the record is effectively immutable from the API's perspective. Idempotent (mandatory Idempotency-Key). Dry-runnable. + +**Use when:** You need to adjust due_date, or attach a payment reference / notes to a registered SI before approval. Use dry-run to confirm the merged state first. +**Do not use for:** Editing line items (immutable: credit the SI and register a new one). Changing status (use action verbs). Approved/paid/credited SIs (returns 400 SI_NOT_DRAFT). invoice_date / supplier_invoice_number on an SI that already has a registration verifikat (returns 400 SI_EDIT_VERIFIKAT_LOCKED). + +**Pitfalls:** +- Returns 400 SI_NOT_DRAFT when current status !== "registered". +- invoice_date and supplier_invoice_number are on the posted registration verifikat (entry_date and description). Once registration_journal_entry_id is set, patching them returns 400 SI_EDIT_VERIFIKAT_LOCKED: correct the entry via a rättelse (gnubok_correct_entry) or credit the SI and re-register. Resending the unchanged value is accepted. +- Patching a field never re-posts the registration JE. + +| Parameter | In | Type | Required | Notes | +|---|---|---|---|---| +| `companyId` | path | `string` | yes | | +| `id` | path | `string` | yes | | + +Request body: +```ts +{ + supplier_invoice_number?: string, + invoice_date?: string, + due_date?: string, + delivery_date?: string | "", + payment_reference?: string, + notes?: string +} +``` + +Response `200`: +```ts +{ + data: { + id: string, + supplier_id: string, + arrival_number: number, + supplier_invoice_number: string, + invoice_date: string, + due_date: string, + received_date: string, + delivery_date: string, + status: string, + currency: string, + exchange_rate: number, + subtotal: number, + vat_amount: number, + total: number, + vat_treatment: string, + reverse_charge: boolean, + paid_amount: number, + remaining_amount: number, + is_credit_note: boolean, + credited_invoice_id: string, + registration_journal_entry_id: string, + payment_journal_entry_id: string, + notes: string, + created_at: string, + updated_at: string + }, + meta: { + request_id: string, + api_version: string, + next_cursor?: string, + audit?: { voucher_number?: string, voucher_url?: string, audit_trail_url?: string, immutable_at?: string }, + partial_expansions?: string[] + } +} +``` + +--- + +### `POST /api/v1/companies/{companyId}/supplier-invoices/{id}/approve` + +**Approve a registered or overdue supplier invoice.** +`scope:suppliers:write · risk:low · idempotent · dry-run` + +Attests a supplier invoice that has not been approved yet (status `registered` or `overdue`). The resulting status is `approved`, or `overdue` when the invoice is still past its due date. No journal entry is posted here: the registration JE was already booked at :create under accrual, or is deferred to :mark-paid under cash. Idempotent. Dry-runnable. + +**Use when:** A registered SI has been reviewed and you want to mark it ready for payment. Many AP workflows gate :mark-paid behind an explicit approval step. +**Do not use for:** Posting a journal entry (already done at :create under accrual). Paying the SI (use :mark-paid). Re-approving an already-approved SI (returns 400 SI_APPROVE_NOT_REGISTERED). + +**Pitfalls:** +- Idempotency-Key is mandatory. +- Returns 400 SI_APPROVE_NOT_REGISTERED when the invoice is already approved (approved_at set) or sits in a settled status. Use the detail endpoint to inspect status first if unsure. +- A still-past-due invoice comes back with status "overdue", not "approved": approved_at is the attest marker, the status is derived from the due date. + +| Parameter | In | Type | Required | Notes | +|---|---|---|---|---| +| `companyId` | path | `string` | yes | | +| `id` | path | `string` | yes | | + +Response `200`: +```ts +{ + data: { + id: string, + status: "approved" | "overdue", + arrival_number: number, + supplier_invoice_number: string + }, + meta: { + request_id: string, + api_version: string, + next_cursor?: string, + audit?: { voucher_number?: string, voucher_url?: string, audit_trail_url?: string, immutable_at?: string }, + partial_expansions?: string[] + } +} +``` + +--- + +### `POST /api/v1/companies/{companyId}/supplier-invoices/{id}/credit` + +**Issue a credit note for a supplier invoice.** +`scope:suppliers:write · risk:high · idempotent · dry-run` + +Creates a kreditfaktura that reverses the original supplier invoice. Under accrual the reversing JE is posted atomically (Debit 2440 / Credit expense + Credit 2641). The original status flips to `credited`. Strict-mode: any failure rolls back the credit-note row. Idempotent. Dry-runnable. + +**Use when:** You need to nullify a registered, approved, partially_paid, or paid supplier invoice: for a returned shipment, an over-invoice, or a vendor dispute resolution. Use dry-run to confirm the totals first. +**Do not use for:** Editing line items on an unchanged invoice (use PATCH on `registered` SIs). Crediting an already-credited SI (returns 409 SI_CREDIT_ALREADY_CREDITED). Reversing a v1-issued credit (no v1 endpoint today: use the dashboard). + +**Pitfalls:** +- Idempotency-Key is mandatory. +- Today's date is used as the credit-note invoice_date. It must fall in an open fiscal period: locked period returns 400 SI_CREDIT_PERIOD_LOCKED. +- Cash basis (kontantmetoden): no reversing JE is posted: recognition is deferred until a refund transaction is booked. The credit-note row is still created so the AP audit trail stays consistent. +- The original SI is flipped to `credited` regardless of how much of it was already paid; reconcile the bank refund via the transactions endpoints. + +| Parameter | In | Type | Required | Notes | +|---|---|---|---|---| +| `companyId` | path | `string` | yes | | +| `id` | path | `string` | yes | | + +Response `200`: +```ts +{ + data: { + credit_note_id: string, + original_id: string, + arrival_number: number, + supplier_invoice_number: string, + registration_journal_entry_id: string + }, + meta: { + request_id: string, + api_version: string, + next_cursor?: string, + audit?: { voucher_number?: string, voucher_url?: string, audit_trail_url?: string, immutable_at?: string }, + partial_expansions?: string[] + } +} +``` + +--- + +### `POST /api/v1/companies/{companyId}/supplier-invoices/{id}/mark-paid` + +**Record a payment against a supplier invoice.** +`scope:suppliers:write · risk:medium · idempotent · dry-run` + +Books the payment journal entry (Debit 2440 / Credit 1930 under accrual; or Debit expense + Debit 2641 / Credit 1930 under cash) and flips the SI status to `paid` (full settlement) or `partially_paid`. Strict-mode: a JE failure aborts before any SI mutation. Idempotent. Dry-runnable. + +**Use when:** You paid a registered or approved leverantörsfaktura through a channel other than the synced bank flow. For bank-matched payments use POST /transactions/{id}/match-supplier-invoice instead: that path also reconciles the bank line. +**Do not use for:** Refunding a payment (the public API does not expose unmark-paid; credit the SI instead). Paying a credited or already-paid SI (returns 409 SI_PAID_ALREADY). + +**Pitfalls:** +- Idempotency-Key is mandatory. +- payment_date must fall in an open fiscal period: locked period returns 400 PERIOD_LOCKED. +- exchange_rate_difference (SEK delta vs the booked rate at registration) is required for foreign-currency SIs to book the FX gain/loss to 3960 / 7960. Omitting it on a non-SEK SI under accrual mis-books FX. +- Strict-mode: a JE creation failure ABORTS before the status flip. There is no partial-state recovery banner: retry the call. +- Cash basis (kontantmetoden) recognizes the expense + ingående moms HERE, not at :create. + +| Parameter | In | Type | Required | Notes | +|---|---|---|---|---| +| `companyId` | path | `string` | yes | | +| `id` | path | `string` | yes | | + +Request body: +```ts +{ + amount?: number, + payment_date?: string, + exchange_rate_difference?: number, + notes?: string, + force?: boolean, + payment_account?: string, + lines?: { account_number: string, debit_amount?: number, credit_amount?: number, line_description?: string, dimensions?: Record }[] +} +``` + +Response `200`: +```ts +{ + data: { + id: string, + status: "paid" | "partially_paid", + total: number, + paid_amount: number, + remaining_amount: number, + paid_at: string, + payment_journal_entry_id: string + }, + meta: { + request_id: string, + api_version: string, + next_cursor?: string, + audit?: { voucher_number?: string, voucher_url?: string, audit_trail_url?: string, immutable_at?: string }, + partial_expansions?: string[] + } +} +``` + +--- + +### `GET /api/v1/companies/{companyId}/suppliers` + +**List suppliers for a company.** +`scope:suppliers:read · risk:low · idempotent` + +Returns active suppliers in created-first order. Pass ?include_archived=true to include archived rows. Use ?search to match against name or org_number. + +**Use when:** You need a supplier roster: for building a UI picker, resolving a supplier_id before registering a supplier invoice, or syncing an external AP system. +**Do not use for:** Fetching a single supplier you already know the id of: use GET /api/v1/companies/{companyId}/suppliers/{id}. Customers are a separate resource. + +**Pitfalls:** +- Archived suppliers are hidden by default; the dashboard makes the same choice. +- org_number identifies legal entities only: suppliers currently have no `individual` type, so the field is Bolagsverket public-record data when present. +- vat_number is stored as supplied; unlike customers, suppliers are not auto-validated against VIES on create. Validate externally if the integration requires it. + +| Parameter | In | Type | Required | Notes | +|---|---|---|---|---| +| `companyId` | path | `string` | yes | | + +Response `200`: +```ts +{ + data: { id: string, name: string, supplier_type: "swedish_business" | "eu_business" | "non_eu_business", email: string, org_number: string, vat_number: string, default_payment_terms: number, default_currency: string, archived_at: string, created_at: string }[], + meta: { + request_id: string, + api_version: string, + next_cursor?: string, + audit?: { voucher_number?: string, voucher_url?: string, audit_trail_url?: string, immutable_at?: string }, + partial_expansions?: string[] + } +} +``` + +--- + +### `POST /api/v1/companies/{companyId}/suppliers` + +**Create a supplier.** +`scope:suppliers:write · risk:low · idempotent · dry-run · reversible` + +Creates a new supplier for the company. Requires Idempotency-Key (UUID). Supports ?dry_run=true for input validation without committing: the dry-run response shows the would-be record minus id and timestamps. + +**Use when:** You need to register a new supplier before booking supplier invoices against them. Use dry-run first to catch validation errors before committing. +**Do not use for:** Updating an existing supplier (PATCH instead). Creating customers (different resource). + +**Pitfalls:** +- Idempotency-Key is mandatory: calls without it return 400 VALIDATION_ERROR. +- org_number uniqueness is enforced at the database level; duplicate inserts return 409 SUPPLIER_DUPLICATE_ORG_NUMBER. +- Unlike customers, suppliers carry no `vat_number_validated` flag: vat_number is stored as supplied without VIES verification. Validate externally if your workflow requires it. +- default_expense_account is a BAS account number (e.g. "5410"); the value is stored as-is and used as the suggested debit account when supplier invoices are booked. + +| Parameter | In | Type | Required | Notes | +|---|---|---|---|---| +| `companyId` | path | `string` | yes | | + +Request body: +```ts +{ + name: string, + supplier_type: "swedish_business" | "eu_business" | "non_eu_business", + email?: string, + phone?: string, + address_line1?: string, + address_line2?: string, + postal_code?: string, + city?: string, + country?: string, + org_number?: string, + vat_number?: string, + bankgiro?: string, + plusgiro?: string, + bank_account?: string, + iban?: string, + bic?: string, + clearing_number?: string, + account_number?: string, + default_expense_account?: string, + default_payment_terms?: number, + default_currency?: "SEK" | "EUR" | "USD" | "GBP" | "NOK" | "DKK", + notes?: string +} +``` + +Response `200`: +```ts +{ + data: { + id: string, + name: string, + supplier_type: "swedish_business" | "eu_business" | "non_eu_business", + email: string, + phone: string, + address_line1: string, + address_line2: string, + postal_code: string, + city: string, + country: string, + org_number: string, + vat_number: string, + bankgiro: string, + plusgiro: string, + bank_account: string, + iban: string, + bic: string, + default_expense_account: string, + default_payment_terms: number, + default_currency: string, + notes: string, + archived_at: string, + created_at: string, + updated_at: string + }, + meta: { + request_id: string, + api_version: string, + next_cursor?: string, + audit?: { voucher_number?: string, voucher_url?: string, audit_trail_url?: string, immutable_at?: string }, + partial_expansions?: string[] + } +} +``` + +--- + +### `GET /api/v1/companies/{companyId}/suppliers/{id}` + +**Retrieve a single supplier by id.** +`scope:suppliers:read · risk:low · idempotent` + +Returns the full supplier record. Pass ?expand=supplier_invoices to embed any open supplier invoices (registered / approved / partially_paid / overdue / disputed) for the supplier in the same response. + +**Use when:** You need the full supplier record: address, payment terms, banking details, default expense account: before booking a supplier invoice or syncing to an external AP system. +**Do not use for:** Listing suppliers (use the list endpoint). Looking up customer or employee records (different resources). + +**Pitfalls:** +- archived_at is non-null when the supplier has been soft-deleted; the supplier is still queryable by id but excluded from default lists. +- Banking fields (bankgiro / plusgiro / iban / bic) are stored as supplied; no Luhn or IBAN check is performed at this layer. + +| Parameter | In | Type | Required | Notes | +|---|---|---|---|---| +| `companyId` | path | `string` | yes | | +| `id` | path | `string` | yes | | + +Response `200`: +```ts +{ + data: { + id: string, + name: string, + supplier_type: string, + email: string, + phone: string, + address_line1: string, + address_line2: string, + postal_code: string, + city: string, + country: string, + org_number: string, + vat_number: string, + bankgiro: string, + plusgiro: string, + bank_account: string, + iban: string, + bic: string, + default_expense_account: string, + default_payment_terms: number, + default_currency: string, + notes: string, + archived_at: string, + created_at: string, + updated_at: string + }, + meta: { + request_id: string, + api_version: string, + next_cursor?: string, + audit?: { voucher_number?: string, voucher_url?: string, audit_trail_url?: string, immutable_at?: string }, + partial_expansions?: string[] + } +} +``` + +--- + +### `PATCH /api/v1/companies/{companyId}/suppliers/{id}` + +**Partially update a supplier.** +`scope:suppliers:write · risk:low · idempotent · dry-run · reversible` + +Patches the supplier with the supplied fields. All fields optional. Idempotent (mandatory Idempotency-Key). Dry-runnable. + +**Use when:** You need to change a supplier's contact details, payment terms, banking info, default expense account, or VAT number. Use dry-run first to confirm the merged record before committing. +**Do not use for:** Archiving a supplier (use DELETE: sets archived_at). Replacing the entire record (no PUT verb is exposed; PATCH is partial). + +**Pitfalls:** +- Idempotency-Key is mandatory; calls without it return 400. +- org_number uniqueness is enforced at DB level: 23505 → 409 SUPPLIER_DUPLICATE_ORG_NUMBER. +- Changing default_expense_account does not retroactively rebook prior supplier invoices: only future bookings pick up the new default. + +| Parameter | In | Type | Required | Notes | +|---|---|---|---|---| +| `companyId` | path | `string` | yes | | +| `id` | path | `string` | yes | | + +Request body: +```ts +{ + name?: string, + supplier_type?: "swedish_business" | "eu_business" | "non_eu_business", + email?: string, + phone?: string, + address_line1?: string, + address_line2?: string, + postal_code?: string, + city?: string, + country?: string, + org_number?: string, + vat_number?: string, + bankgiro?: string, + plusgiro?: string, + bank_account?: string, + iban?: string, + bic?: string, + clearing_number?: string, + account_number?: string, + default_expense_account?: string, + default_payment_terms?: number, + default_currency?: "SEK" | "EUR" | "USD" | "GBP" | "NOK" | "DKK", + notes?: string +} +``` + +Response `200`: +```ts +{ + data: { + id: string, + name: string, + supplier_type: string, + email: string, + phone: string, + address_line1: string, + address_line2: string, + postal_code: string, + city: string, + country: string, + org_number: string, + vat_number: string, + bankgiro: string, + plusgiro: string, + bank_account: string, + iban: string, + bic: string, + default_expense_account: string, + default_payment_terms: number, + default_currency: string, + notes: string, + archived_at: string, + created_at: string, + updated_at: string + }, + meta: { + request_id: string, + api_version: string, + next_cursor?: string, + audit?: { voucher_number?: string, voucher_url?: string, audit_trail_url?: string, immutable_at?: string }, + partial_expansions?: string[] + } +} +``` + +--- + +### `DELETE /api/v1/companies/{companyId}/suppliers/{id}` + +**Archive a supplier (soft-delete).** +`scope:suppliers:write · risk:medium · idempotent · dry-run · reversible` + +Sets archived_at on the supplier; the record is preserved (supplier invoices and audit history remain intact) but excluded from default list responses. To un-archive, PATCH archived_at back to null. Idempotent: archiving an already-archived supplier is a no-op. Dry-runnable. + +**Use when:** You want to remove a supplier from active rosters without losing their history. Idempotent: re-archiving is safe. +**Do not use for:** Permanently deleting a supplier with all history: the public API does not expose hard-delete. GDPR erasure requests go through a dedicated workflow. + +**Pitfalls:** +- Idempotency-Key is mandatory. +- A supplier with any open supplier invoice (registered / approved / partially_paid / overdue / disputed) cannot be archived: returns 409 SUPPLIER_HAS_INVOICES. Close the invoices first. This protects BFL 7 kap audit: the supplier record is the canonical source of seller name/address for invoice reissuance. +- 204 No Content is returned on success: there is no response body to parse. + +| Parameter | In | Type | Required | Notes | +|---|---|---|---|---| +| `companyId` | path | `string` | yes | | +| `id` | path | `string` | yes | | + +Response `204`. + +--- + +### `POST /api/v1/companies/{companyId}/suppliers/bulk-create` + +**Create up to 50 suppliers in one call (partial-success).** +`scope:suppliers:write · risk:low · idempotent · dry-run · reversible` + +Bulk-create endpoint mirroring /customers/bulk-create. Each supplier is validated and inserted independently: per-item failures do not roll back items that succeeded. Returns a results array plus a summary. Idempotent over the whole batch. Dry-runnable. + +**Use when:** You're importing a roster of suppliers from another AP system, or seeding a fresh company with its existing vendor list. Use dry-run first to validate the batch. +**Do not use for:** Updating existing suppliers: PATCH /suppliers/{id} once per supplier. Bulk uploads of > 50 suppliers: split into pages of 50. Transactional all-or-nothing imports: passing all_or_nothing: true returns 501 NOT_IMPLEMENTED. + +**Pitfalls:** +- Idempotency-Key is mandatory and covers the WHOLE batch. A retried bulk-create returns the cached full response: it does not retry only the failed items. +- Passing all_or_nothing: true returns 501 NOT_IMPLEMENTED. Today only partial-success batches exist; omit the flag or pass false. +- org_number uniqueness is enforced at the DB level: items with duplicates fail individually with SUPPLIER_DUPLICATE_ORG_NUMBER. +- No VIES validation runs per item; vat_number is stored as supplied. Validate externally if your workflow requires it. + +| Parameter | In | Type | Required | Notes | +|---|---|---|---|---| +| `companyId` | path | `string` | yes | | + +Request body: +```ts +{ + suppliers: { name: string, supplier_type: "swedish_business" | "eu_business" | "non_eu_business", email?: string, phone?: string, address_line1?: string, address_line2?: string, postal_code?: string, city?: string, country?: string, org_number?: string, vat_number?: string, bankgiro?: string, plusgiro?: string, bank_account?: string, iban?: string, bic?: string, clearing_number?: string, account_number?: string, default_expense_account?: string, default_payment_terms?: number, default_currency?: "SEK" | "EUR" | "USD" | "GBP" | "NOK" | "DKK", notes?: string }[], + all_or_nothing?: boolean +} +``` + +Response `200`: +```ts +{ + data: { + results: { ok: boolean, request_index: number, data?: unknown, error?: { code: string, message: string, details?: unknown } }[], + summary: { total: number, succeeded: number, failed: number } + }, + meta: { + request_id: string, + api_version: string, + next_cursor?: string, + audit?: { voucher_number?: string, voucher_url?: string, audit_trail_url?: string, immutable_at?: string }, + partial_expansions?: string[] + } +} +``` diff --git a/skills/accounted-api/references/webhooks.md b/skills/accounted-api/references/webhooks.md new file mode 100644 index 00000000..1716b9c7 --- /dev/null +++ b/skills/accounted-api/references/webhooks.md @@ -0,0 +1,351 @@ + + +# Webhooks endpoints + +HMAC-signed event subscriptions with delivery logs, test pings, retries, and secret rotation. + +Conventions (auth, envelope, pagination, dry-run, idempotency, standard errors) +are in SKILL.md and are not repeated per endpoint. + +### `GET /api/v1/companies/{companyId}/webhooks` + +**List webhook subscriptions for a company.** +`scope:webhooks:manage · risk:low · idempotent` + +Returns all webhook subscriptions for the company. The HMAC signing secret is never exposed by this endpoint: it is returned exactly once when the webhook is created. + +**Use when:** You need to enumerate the webhook subscriptions an integration has registered, e.g. to build a UI listing or sync state with an external system. +**Do not use for:** Reading delivery history (use GET /webhooks/{id}/deliveries). Reading the secret (it is unrecoverable after the create response: generate a new webhook if lost). + +**Pitfalls:** +- Disabled webhooks (auto-disabled after HTTP 410, or manually disabled via PATCH) appear in the list with active=false and a disabled_reason. + +| Parameter | In | Type | Required | Notes | +|---|---|---|---|---| +| `companyId` | path | `string` | yes | | + +Response `200`: +```ts +{ + data: { + webhooks: { id: string, name: string, event_type: string, webhook_url: string, active: boolean, api_version_pinned: string, disabled_at: string, disabled_reason: string, created_at: string }[] + }, + meta: { + request_id: string, + api_version: string, + next_cursor?: string, + audit?: { voucher_number?: string, voucher_url?: string, audit_trail_url?: string, immutable_at?: string }, + partial_expansions?: string[] + } +} +``` + +--- + +### `POST /api/v1/companies/{companyId}/webhooks` + +**Register a webhook subscription.** +`scope:webhooks:manage · risk:low · idempotent · dry-run · reversible` + +Creates a webhook subscription for one event type. The response includes a freshly generated HMAC signing secret, returned EXACTLY ONCE: store it on the receiver side immediately. The webhook is pinned to the current API version on creation; payload shapes for this webhook will not change until you explicitly upgrade. + +**Use when:** You are wiring a downstream integration that needs push notifications instead of polling. +**Do not use for:** Subscribing to internal MCP telemetry events (mcp.tool_called etc. are not delivered as webhooks). Replacing an existing webhook URL: use PATCH instead. + +**Pitfalls:** +- The secret is returned exactly once. If lost, delete and recreate the webhook. +- Delivery is at-least-once with exponential backoff (1m / 5m / 30m / 2h / 12h / 24h / 48h). Receivers MUST be idempotent. +- HTTP 410 from your receiver auto-disables the webhook (sets active=false + disabled_reason). + +| Parameter | In | Type | Required | Notes | +|---|---|---|---|---| +| `companyId` | path | `string` | yes | | + +Request body: +```ts +{ + event_type: "invoice.created" | "invoice.sent" | "invoice.paid" | "credit_note.created" | "customer.created" | "supplier.created" | "supplier_invoice.registered" | "supplier_invoice.approved" | "supplier_invoice.paid" | "supplier_invoice.credited" | "supplier_invoice.uncredited" | "transaction.categorized" | "transaction.reconciled" | "journal_entry.committed" | "journal_entry.reversed" | "journal_entry.corrected" | "period.locked" | "period.unlocked" | "period.year_closed" | "salary_run.created" | "salary_run.approved" | "salary_run.booked" | "agi.generated" | "document.uploaded", + webhook_url: string, + name: string, + description?: string +} +``` + +Response `200`: +```ts +{ + data: { + id: string, + name: string, + event_type: string, + webhook_url: string, + active: boolean, + api_version_pinned: string, + disabled_at: string, + disabled_reason: string, + created_at: string, + secret: string, + description: string + }, + meta: { + request_id: string, + api_version: string, + next_cursor?: string, + audit?: { voucher_number?: string, voucher_url?: string, audit_trail_url?: string, immutable_at?: string }, + partial_expansions?: string[] + } +} +``` + +--- + +### `GET /api/v1/companies/{companyId}/webhooks/{id}` + +**Get a webhook subscription by id.** +`scope:webhooks:manage · risk:low · idempotent` + +Returns the webhook configuration. The HMAC signing secret is never exposed. + +**Use when:** You need the current state of a single webhook (e.g. to render a settings page). +**Do not use for:** Reading the secret (returned only once on creation). + +| Parameter | In | Type | Required | Notes | +|---|---|---|---|---| +| `companyId` | path | `string` | yes | | +| `id` | path | `string` | yes | | + +Response `200`: +```ts +{ + data: { + id: string, + name: string, + description: string, + event_type: string, + webhook_url: string, + active: boolean, + api_version_pinned: string, + disabled_at: string, + disabled_reason: string, + created_at: string, + updated_at: string + }, + meta: { + request_id: string, + api_version: string, + next_cursor?: string, + audit?: { voucher_number?: string, voucher_url?: string, audit_trail_url?: string, immutable_at?: string }, + partial_expansions?: string[] + } +} +``` + +--- + +### `PATCH /api/v1/companies/{companyId}/webhooks/{id}` + +**Update a webhook subscription.** +`scope:webhooks:manage · risk:low · idempotent · dry-run · reversible` + +Update the URL, name, description, or active flag. event_type is immutable: delete and recreate to change it. Setting active=false manually pauses delivery without deleting; setting active=true clears any disabled_at/disabled_reason set by the auto-disable on HTTP 410. + +**Use when:** You need to point an existing webhook at a new URL or temporarily pause delivery. +**Do not use for:** Rotating the signing secret (delete and recreate). Changing event_type. + +**Pitfalls:** +- Re-enabling a webhook (active: true) does NOT replay deliveries that went to dead status while it was disabled: those need POST /webhook-deliveries/{id}/retry. + +| Parameter | In | Type | Required | Notes | +|---|---|---|---|---| +| `companyId` | path | `string` | yes | | +| `id` | path | `string` | yes | | + +Request body: +```ts +{ name?: string, description?: string, webhook_url?: string, active?: boolean } +``` + +Response `200`: +```ts +{ + data: { + id: string, + name: string, + description: string, + event_type: string, + webhook_url: string, + active: boolean, + api_version_pinned: string, + disabled_at: string, + disabled_reason: string, + created_at: string, + updated_at: string + }, + meta: { + request_id: string, + api_version: string, + next_cursor?: string, + audit?: { voucher_number?: string, voucher_url?: string, audit_trail_url?: string, immutable_at?: string }, + partial_expansions?: string[] + } +} +``` + +--- + +### `DELETE /api/v1/companies/{companyId}/webhooks/{id}` + +**Delete a webhook subscription.** +`scope:webhooks:manage · risk:medium · idempotent` + +Hard-deletes the webhook. The delivery audit trail SURVIVES: both terminal (delivered, dead) and non-terminal (pending, failed) delivery rows persist with webhook_id = NULL so the BFNAR 2013:2 kap 8 § behandlingshistorik (7-year retention) for accounting-event deliveries is preserved. Non-terminal rows go dormant (the dispatcher skips them). + +**Use when:** You no longer want this webhook to receive events. +**Do not use for:** Temporarily pausing delivery: use PATCH with active=false instead so the configuration survives. + +**Pitfalls:** +- Audit history survives DELETE; only the receiver subscription is removed. To suppress future events without retaining the registration use PATCH active=false. + +| Parameter | In | Type | Required | Notes | +|---|---|---|---|---| +| `companyId` | path | `string` | yes | | +| `id` | path | `string` | yes | | + +Response `204`. + +--- + +### `GET /api/v1/companies/{companyId}/webhooks/{id}/deliveries` + +**List deliveries for a webhook subscription.** +`scope:webhooks:manage · risk:low · idempotent` + +Returns deliveries for the webhook in newest-first order. Each row carries the current status (pending / in_flight / delivered / failed / dead), the attempt count, the next scheduled retry time, and the captured response details from the last attempt. + +**Use when:** You are debugging a flaky receiver, or building a delivery-history UI for a settings page. +**Do not use for:** Listing deliveries across multiple webhooks (this endpoint is single-webhook scoped). + +**Pitfalls:** +- response_body is truncated to 4 KB: receivers returning long error pages have their response truncated. +- A delivery in `failed` status is non-terminal: the dispatcher will retry it at next_attempt_at. `dead` is terminal. + +| Parameter | In | Type | Required | Notes | +|---|---|---|---|---| +| `companyId` | path | `string` | yes | | +| `id` | path | `string` | yes | | + +Response `200`: +```ts +{ + data: { id: string, webhook_id: string, event_type: string, status: "pending" | "in_flight" | "delivered" | "failed" | "dead", attempts: number, next_attempt_at: string, response_status: number, response_body: string, error: string, request_id: string, created_at: string, delivered_at: string }[], + meta: { + request_id: string, + api_version: string, + next_cursor?: string, + audit?: { voucher_number?: string, voucher_url?: string, audit_trail_url?: string, immutable_at?: string }, + partial_expansions?: string[] + } +} +``` + +--- + +### `POST /api/v1/companies/{companyId}/webhooks/{id}/rotate-secret` + +**Rotate the HMAC signing secret on a webhook.** +`scope:webhooks:manage · risk:medium` + +Generates a fresh HMAC signing secret for the webhook and returns it EXACTLY ONCE. The previous secret is invalidated immediately. There is no grace period: coordinate the rotation on the receiver side BEFORE calling this endpoint, or temporarily disable the webhook (PATCH active=false) to pause delivery while you swap secrets. + +**Use when:** After a suspected secret leak, on a routine rotation cadence (Stripe pattern: every 90 days for compliance-grade integrations), or when changing the receiver implementation and you want to invalidate the old secret deliberately. +**Do not use for:** Routine integration setup: the secret returned at create time is the canonical one. Recovering a lost secret (rotation does not recover the prior value; it issues a fresh one). + +**Pitfalls:** +- The secret is returned exactly once. If you lose this response, the recovery path is to rotate again. +- In-flight deliveries between the rotation and the receiver-side update may fail signature verification on the new secret. Pause the webhook (PATCH active=false) first if your tolerance for that window is zero. + +| Parameter | In | Type | Required | Notes | +|---|---|---|---|---| +| `companyId` | path | `string` | yes | | +| `id` | path | `string` | yes | | + +Response `200`: +```ts +{ + data: { id: string, secret: string, rotated_at: string }, + meta: { + request_id: string, + api_version: string, + next_cursor?: string, + audit?: { voucher_number?: string, voucher_url?: string, audit_trail_url?: string, immutable_at?: string }, + partial_expansions?: string[] + } +} +``` + +--- + +### `POST /api/v1/companies/{companyId}/webhooks/{id}/test` + +**Send a synthetic test event to a webhook.** +`scope:webhooks:manage · risk:low` + +Enqueues a webhook.test delivery against the configured receiver and dispatches it immediately, so the outcome is normally available within a second or two rather than on the next per-minute cron tick. Use the returned webhook_delivery_id to poll GET /webhooks/{id}/deliveries for the outcome. + +**Use when:** After creating or modifying a webhook, before relying on it in production: to validate that the receiver is reachable and that signature verification works on the receiver side. +**Do not use for:** Smoke-testing the dispatcher itself (use a real event). Replaying a failed delivery (use POST /webhook-deliveries/{id}/retry). + +**Pitfalls:** +- Test deliveries follow the same retry policy as real events: a 500 from your receiver will retry 7 times over ~87h (about 3.6 days). Use a 2xx ack-only handler if you want a clean signal. + +| Parameter | In | Type | Required | Notes | +|---|---|---|---|---| +| `companyId` | path | `string` | yes | | +| `id` | path | `string` | yes | | + +Response `200`: +```ts +{ + data: { webhook_delivery_id: string, status: "pending" }, + meta: { + request_id: string, + api_version: string, + next_cursor?: string, + audit?: { voucher_number?: string, voucher_url?: string, audit_trail_url?: string, immutable_at?: string }, + partial_expansions?: string[] + } +} +``` + +--- + +### `POST /api/v1/webhook-deliveries/{id}/retry` + +**Retry a webhook delivery.** +`scope:webhooks:manage · risk:medium` + +Re-enqueues a dead (or delivered) delivery as a fresh pending row. The new delivery references the same webhook + payload; the dispatcher picks it up at the next per-minute cron tick. The original row is preserved in the audit log. + +**Use when:** After a receiver outage you want to replay deliveries that died, or after fixing a receiver-side bug you want to redeliver a successful one. +**Do not use for:** Retrying live deliveries (pending / in_flight / failed): the dispatcher is already managing them. + +**Pitfalls:** +- Retrying a delivered delivery causes the receiver to see the event twice. Receivers MUST be idempotent (check the X-Gnubok-Delivery header). + +| Parameter | In | Type | Required | Notes | +|---|---|---|---|---| +| `id` | path | `string` | yes | | + +Response `200`: +```ts +{ + data: { webhook_delivery_id: string, status: "pending" }, + meta: { + request_id: string, + api_version: string, + next_cursor?: string, + audit?: { voucher_number?: string, voucher_url?: string, audit_trail_url?: string, immutable_at?: string }, + partial_expansions?: string[] + } +} +``` diff --git a/skills/openapi-to-skill/SKILL.md b/skills/openapi-to-skill/SKILL.md new file mode 100644 index 00000000..e583a467 --- /dev/null +++ b/skills/openapi-to-skill/SKILL.md @@ -0,0 +1,127 @@ +--- +name: openapi-to-skill +description: >- + Turn an OpenAPI/Swagger spec (JSON or YAML, file or URL) into an installable + agent skill that teaches coding agents how to call that API correctly. Use + when the user says "build a skill for this API", "generate a skill from this + spec", "make our API docs agent-ready", or points at an openapi.json / + swagger.yaml and wants an integration skill. Produces a SKILL.md + + references/ folder ready for `npx skills add`. +--- + +# OpenAPI spec to agent skill + +You are building a **consumer-side skill**: a folder of distilled instructions +that lets an agent integrate correctly against an API on the first attempt. +The output is NOT a restatement of the spec. A spec restatement grades D: the +agent could have read the spec itself. The value you add is selection +(what matters), compression (condensed schemas), conventions (the cross-cutting +rules), and verification (what the live API actually does). + +## Inputs + +Collect these before starting (ask only for what is missing): + +1. **Spec location**: file path or URL. If URL, download it next to your work. +2. **Output directory**: `skills//` in the repo when the skill will + be distributed (installable via `npx skills add owner/repo --skill `), + `.claude/skills//` when it is for personal/project use only. +3. **Credentials for verification** (optional but strongly preferred): a base + URL and an env var holding a low-privilege or test API key. Read-only + verification only; never call write endpoints without explicit user consent. + +## Process + +### 1. Inventory the spec + +Run the bundled tool (portable, stdlib-only Node): + +```bash +node scripts/openapi-inventory.mjs # overview: groups + one line per operation +node scripts/openapi-inventory.mjs --group # full detail for one group +``` + +YAML specs: convert first (`npx -y js-yaml spec.yaml > spec.json`). + +The overview gives you: title, servers, auth schemes, operation count, and +operations grouped by tag (or by dominant path segment when the spec has no +tags). Read it fully before writing anything. + +### 2. Extract the conventions + +The conventions section is the most valuable part of the output skill: it is +what lets an agent predict endpoint behaviour instead of looking everything up. +Hunt for them in this order: + +- `info.description`: many agent-first APIs state their invariants here. +- `components.securitySchemes`: auth header shape, token format, where keys + come from (docs often say; if not, ask the user). +- Recurring response envelope: sample 3-4 operations with `--group` and + compare. Note pagination style (cursor vs offset), error envelope, request + id fields. +- Recurring parameters and headers: idempotency keys, dry-run flags, expand + parameters, rate limits. +- `x-*` extensions: risk levels, scopes, idempotency markers. Surface them; + they exist for agents. + +If the spec is thin on conventions, check the API's human docs (ask the user +for the docs URL) rather than guessing. + +### 3. Choose the reference grouping + +Target 6 to 12 reference files. Merge tiny groups thematically (e.g. +`customers` + `articles` into `invoicing`); split any group that would exceed +roughly 500 lines rendered. Every operation must land in exactly one +reference file. + +### 4. Write the skill + +Follow `references/output-template.md` for the exact shape. Non-negotiables: + +- **SKILL.md stays under ~400 lines.** Frontmatter description must name the + API and its domain so the skill triggers (the agent sees only name + + description at discovery time). +- **Conventions before endpoints.** Auth + base URL + envelope + pagination + + error handling first; the endpoint index after. +- **Endpoint index**: one line per operation (`METHOD path : summary [badges]`), + grouped, each group naming its reference file. The inventory tool's overview + output is already in this format. +- **References**: start from `--group` output, then edit. Deduplicate + boilerplate the spec repeats per operation (shared error lists, envelope + wrappers) up into the conventions section, keep operation-specific pitfalls + inline, and add a worked example (request + realistic response) for the 2-3 + most-used operations per group. +- **Schemas in condensed TypeScript-ish form**, never raw JSON Schema. + +### 5. Verify against the live API + +This step separates a usable skill from a plausible-looking one. Desk review +and live testing find different failure classes. + +With credentials: smoke-test read-only endpoints (health/list endpoints, one +or two per group), using the exact auth header the skill documents. Compare +actual responses against the documented envelope and schemas. Every mismatch +is gold: record it in a **Gotchas** section in SKILL.md. Replace invented +examples with real (redacted) response bodies. + +Without credentials: add a **Verification** section stating the skill was +generated from the spec and not yet tested live, with the smoke-test commands +a future session should run once a key exists. + +### 6. Grade it + +Score the output against the checklist at the bottom of +`references/output-template.md`. Fix what fails, once. Then report to the +user: what was generated, what was verified live vs. desk-only, and how to +install it (`npx skills add / --skill `). + +## Anti-patterns + +- Restating the spec operation-by-operation with no selection or added + operational knowledge. +- Raw JSON Schema dumps, or full request/response schemas for every endpoint + when a condensed form plus one worked example carries more information. +- Auth buried mid-file. It is always the first section after the intro. +- Inventing example responses when a live call could have produced a real one. +- A giant single SKILL.md instead of progressive disclosure via references/. +- Skipping the grade step because the output "looks complete". diff --git a/skills/openapi-to-skill/references/output-template.md b/skills/openapi-to-skill/references/output-template.md new file mode 100644 index 00000000..08be0dcb --- /dev/null +++ b/skills/openapi-to-skill/references/output-template.md @@ -0,0 +1,116 @@ +# Output skill template + +The generated skill is a folder: + +``` +/ + SKILL.md # conventions + endpoint index; under ~400 lines + references/ + .md # full operation detail for one resource group + .md + ... +``` + +## SKILL.md skeleton + +```markdown +--- +name: +description: >- + Consume the (, ). + Use when building an integration or app against : . Covers auth, + conventions, and every endpoint. +--- + +# integration + + + +## Auth and base URL + + + +## Conventions + + + +## Endpoint index + +.md". Line format: +`METHOD /path : summary [scope:x risk:y idempotent dry-run]`> + +## Gotchas + + + +## Verification + + against : ." +Or: "Generated from spec, NOT yet verified live. Before first use run: +."> +``` + +## references/.md skeleton + +```markdown +# endpoints + + sent -> paid). Cross-reference sibling groups.> + + +### `METHOD /path` + +**.** +`scope: · risk: · idempotent · dry-run` + + + + + +Request body: (writes only) +```ts +{ condensed: "typescript-ish" } +``` + +Response `200`: +```ts +{ condensed: "typescript-ish" } +``` + + +``` + +## Quality checklist + +Grade the generated skill against every item. Fix failures before delivering. + +1. **First-call test**: could an agent with only SKILL.md (no references) make + a correct authenticated list call? Auth format, base URL, and envelope must + be sufficient. +2. **Trigger test**: does the frontmatter description name the API, its + domain, and its resource nouns? An agent that has never seen this skill + must match it from a prompt like "add invoicing to our app". +3. **Coverage test**: every operation in the spec appears exactly once in the + endpoint index, and every index group has a reference file. +4. **Restatement test**: does SKILL.md contain anything an agent would learn + anyway from one endpoint call? Cut it. +5. **Convention test**: pagination, error handling, and idempotency are + documented as rules, not repeated per endpoint. +6. **Honesty test**: every example response is real or clearly marked as + spec-derived; the Verification section says what was and was not tested. +7. **Size test**: SKILL.md under ~400 lines; no reference file over ~700. +8. **Write-safety test**: destructive or high-risk operations are visibly + marked so an agent knows to confirm before calling. diff --git a/skills/openapi-to-skill/scripts/openapi-inventory.d.mts b/skills/openapi-to-skill/scripts/openapi-inventory.d.mts new file mode 100644 index 00000000..d50afde8 --- /dev/null +++ b/skills/openapi-to-skill/scripts/openapi-inventory.d.mts @@ -0,0 +1,45 @@ +/** Type surface of openapi-inventory.mjs for TypeScript consumers. */ + +export interface OperationObjectLite { + operationId?: string + summary?: string + description?: string + tags?: string[] + deprecated?: boolean + parameters?: unknown[] + requestBody?: unknown + responses?: Record + [extension: string]: unknown +} + +export interface OperationEntry { + path: string + method: string + op: OperationObjectLite + pathItem: Record + segments: string[] + depth: number + group: string +} + +export interface Inventory { + title: string + version: string + description: string + servers: string[] + securitySchemes: Record + operationCount: number + groups: Array<{ name: string; operations: OperationEntry[] }> +} + +export function loadSpec(specPath: string): unknown +export function resolveRef(spec: unknown, ref: string): unknown +export function condenseSchema( + spec: unknown, + schema: unknown, + opts?: { depth?: number; seen?: Set }, +): string +export function listOperations(spec: unknown): OperationEntry[] +export function buildInventory(spec: unknown): Inventory +export function formatOpLine(entry: OperationEntry): string +export function renderOperationMd(spec: unknown, entry: OperationEntry): string diff --git a/skills/openapi-to-skill/scripts/openapi-inventory.mjs b/skills/openapi-to-skill/scripts/openapi-inventory.mjs new file mode 100644 index 00000000..0f3e816b --- /dev/null +++ b/skills/openapi-to-skill/scripts/openapi-inventory.mjs @@ -0,0 +1,447 @@ +#!/usr/bin/env node +/** + * openapi-inventory: turn a large OpenAPI 3.x spec into agent-readable text. + * + * Portable by design: no imports outside the Node standard library (YAML + * specs are handled via js-yaml when it is resolvable from the working + * directory, with a clear error otherwise). This file is shipped inside the + * `openapi-to-skill` agent skill and copied into consumer repos, so it must + * never grow a dependency on the repository that hosts it. + * + * CLI: + * node openapi-inventory.mjs # compact overview + * node openapi-inventory.mjs --group # full detail for one group + * node openapi-inventory.mjs --json # machine-readable inventory + * + * Library (used by deterministic skill generators): + * loadSpec, listOperations, buildInventory, condenseSchema, + * renderOperationMd, formatOpLine + */ + +import { readFileSync } from 'node:fs' +import { createRequire } from 'node:module' + +// --------------------------------------------------------------------------- +// Spec loading +// --------------------------------------------------------------------------- + +export function loadSpec(specPath) { + const raw = readFileSync(specPath, 'utf8') + const trimmed = raw.trimStart() + if (trimmed.startsWith('{')) return JSON.parse(raw) + + // YAML fallback: resolve js-yaml from the consumer's project if present. + try { + const require = createRequire(`${process.cwd()}/`) + const yaml = require('js-yaml') + return yaml.load(raw) + } catch { + throw new Error( + `${specPath} looks like YAML but js-yaml is not resolvable from ${process.cwd()}. ` + + `Convert it first (e.g. "npx -y js-yaml ${specPath} > spec.json") and re-run on the JSON file.`, + ) + } +} + +// --------------------------------------------------------------------------- +// $ref resolution +// --------------------------------------------------------------------------- + +export function resolveRef(spec, ref) { + if (typeof ref !== 'string' || !ref.startsWith('#/')) return undefined + let node = spec + for (const part of ref.slice(2).split('/')) { + node = node?.[part.replace(/~1/g, '/').replace(/~0/g, '~')] + if (node === undefined) return undefined + } + return node +} + +function deref(spec, schema, seen) { + if (schema && typeof schema === 'object' && schema.$ref) { + if (seen.has(schema.$ref)) return { __cycle: schema.$ref } + seen.add(schema.$ref) + const target = resolveRef(spec, schema.$ref) + return target === undefined ? { __unresolved: schema.$ref } : target + } + return schema +} + +// --------------------------------------------------------------------------- +// Schema condenser: JSON Schema -> TypeScript-ish compact notation +// --------------------------------------------------------------------------- + +const MAX_DEPTH = 6 + +export function condenseSchema(spec, schema, { depth = 0, seen = new Set() } = {}) { + if (schema === undefined || schema === null) return 'unknown' + if (schema === true) return 'unknown' + if (schema === false) return 'never' + + schema = deref(spec, schema, seen) + if (schema.__cycle) return refName(schema.__cycle) + if (schema.__unresolved) return refName(schema.__unresolved) + if (depth > MAX_DEPTH) return '{...}' + + if (Array.isArray(schema.enum)) { + return schema.enum.map((v) => JSON.stringify(v)).join(' | ') + } + if (schema.const !== undefined) return JSON.stringify(schema.const) + + for (const key of ['oneOf', 'anyOf']) { + if (Array.isArray(schema[key]) && schema[key].length > 0) { + const parts = schema[key].map((s) => condenseSchema(spec, s, { depth: depth + 1, seen })) + return [...new Set(parts)].join(' | ') + } + } + if (Array.isArray(schema.allOf) && schema.allOf.length > 0) { + const merged = {} + const required = new Set(schema.required ?? []) + for (const part of schema.allOf) { + const resolved = deref(spec, part, seen) + if (resolved && typeof resolved === 'object') { + Object.assign(merged, resolved.properties) + for (const r of resolved.required ?? []) required.add(r) + } + } + return condenseSchema( + spec, + { type: 'object', properties: merged, required: [...required] }, + { depth, seen }, + ) + } + + let type = schema.type + if (Array.isArray(type)) { + const parts = type.map((t) => + condenseSchema(spec, { ...schema, type: t }, { depth, seen }), + ) + return [...new Set(parts)].join(' | ') + } + + if (type === 'array') { + const item = condenseSchema(spec, schema.items, { depth: depth + 1, seen }) + return item.includes(' ') && !item.startsWith('{') ? `(${item})[]` : `${item}[]` + } + + if (type === 'object' || schema.properties) { + const props = schema.properties ?? {} + const required = new Set(schema.required ?? []) + const keys = Object.keys(props) + if (keys.length === 0) { + if (schema.additionalProperties) { + const val = condenseSchema(spec, schema.additionalProperties, { depth: depth + 1, seen }) + return `Record` + } + return '{}' + } + const entries = keys.map((k) => { + const opt = required.has(k) ? '' : '?' + const val = condenseSchema(spec, props[k], { depth: depth + 1, seen }) + return `${k}${opt}: ${val}` + }) + const inline = `{ ${entries.join(', ')} }` + if (inline.length <= 100 || depth >= 2) return inline + const pad = ' '.repeat(depth + 1) + const close = ' '.repeat(depth) + return `{\n${pad}${entries.join(`,\n${pad}`)}\n${close}}` + } + + if (type === 'integer') type = 'number' + if (!type) return 'unknown' + const fmt = schema.format && schema.format !== 'binary' ? `(${schema.format})` : '' + return `${type}${fmt}` +} + +function refName(ref) { + const parts = ref.split('/') + return parts[parts.length - 1] || 'unknown' +} + +// --------------------------------------------------------------------------- +// Operation listing and grouping +// --------------------------------------------------------------------------- + +const HTTP_METHODS = ['get', 'put', 'post', 'patch', 'delete', 'head', 'options', 'trace'] +const SKIP_SEGMENTS = /^(api|v\d+)$/i + +function staticSegments(path) { + return path + .split('/') + .filter(Boolean) + .filter((s) => !s.startsWith('{') && !SKIP_SEGMENTS.test(s)) +} + +/** + * List every operation with a derived `group`. + * + * Grouping: tags win when present. Otherwise the group is a static path + * segment, chosen by "dominance descent": start with the first static + * segment; while a single group holds more than 60% of all operations and + * its members have deeper static segments to descend into, regroup those + * members one static segment deeper. This turns + * `/companies/{companyId}/invoices/...` into group `invoices` instead of + * lumping the whole API under `companies`. + */ +export function listOperations(spec) { + const ops = [] + for (const [path, pathItem] of Object.entries(spec.paths ?? {})) { + for (const method of HTTP_METHODS) { + const op = pathItem?.[method] + if (!op) continue + ops.push({ + path, + method: method.toUpperCase(), + op, + pathItem, + segments: staticSegments(path), + depth: 0, + group: op.tags?.[0] ?? null, + }) + } + } + + const untagged = ops.filter((o) => o.group === null) + for (const o of untagged) o.group = o.segments[0] ?? 'root' + + for (let round = 0; round < 3 && untagged.length > 0; round++) { + const counts = new Map() + for (const o of untagged) counts.set(o.group, (counts.get(o.group) ?? 0) + 1) + const [dominant, count] = [...counts.entries()].sort((a, b) => b[1] - a[1])[0] + if (count / ops.length <= 0.6) break + let descended = false + for (const o of untagged) { + if (o.group !== dominant) continue + const next = o.segments[o.depth + 1] + if (next) { + o.depth += 1 + o.group = next + descended = true + } + } + if (!descended) break + } + + return ops +} + +export function buildInventory(spec) { + const ops = listOperations(spec) + const groups = new Map() + for (const o of ops) { + if (!groups.has(o.group)) groups.set(o.group, []) + groups.get(o.group).push(o) + } + return { + title: spec.info?.title ?? 'Untitled API', + version: spec.info?.version ?? '', + description: spec.info?.description ?? '', + servers: (spec.servers ?? []).map((s) => s.url), + securitySchemes: spec.components?.securitySchemes ?? {}, + operationCount: ops.length, + groups: [...groups.entries()] + .sort((a, b) => a[0].localeCompare(b[0])) + .map(([name, members]) => ({ name, operations: members })), + } +} + +// --------------------------------------------------------------------------- +// Rendering +// --------------------------------------------------------------------------- + +/** Known agent-oriented x- extensions, rendered as a compact annotation. */ +function extensionBadges(op) { + const badges = [] + if (op['x-required-scope']) badges.push(`scope:${op['x-required-scope']}`) + if (op['x-action-risk']) badges.push(`risk:${op['x-action-risk']}`) + if (op['x-idempotent']) badges.push('idempotent') + if (op['x-dry-run-supported']) badges.push('dry-run') + if (op['x-reversible']) badges.push('reversible') + if (op.deprecated) badges.push('DEPRECATED') + return badges +} + +export function formatOpLine(entry) { + const { method, path, op } = entry + const badges = extensionBadges(op) + const summary = (op.summary ?? op.operationId ?? '').replace(/\.$/, '') + const badgeStr = badges.length > 0 ? ` [${badges.join(' ')}]` : '' + return `${method} ${path} : ${summary}${badgeStr}` +} + +/** Escape a string for use inside a Markdown table cell. */ +function mdCell(text) { + return text.replace(/\\/g, '\\\\').replace(/\|/g, '\\|') +} + +function collectParameters(spec, entry) { + const seen = new Set() + const params = [] + for (const raw of [...(entry.pathItem.parameters ?? []), ...(entry.op.parameters ?? [])]) { + const p = raw.$ref ? resolveRef(spec, raw.$ref) : raw + if (!p) continue + const key = `${p.in}:${p.name}` + if (seen.has(key)) continue + seen.add(key) + params.push(p) + } + return params +} + +function successResponse(op) { + for (const code of ['200', '201', '202', '204']) { + if (op.responses?.[code]) return [code, op.responses[code]] + } + return [null, null] +} + +/** + * Full Markdown block for one operation: what a reference file is built from. + */ +export function renderOperationMd(spec, entry) { + const { method, path, op } = entry + const lines = [] + const summary = (op.summary ?? '').replace(/\.$/, '') + lines.push(`### \`${method} ${path}\``) + lines.push('') + if (summary) lines.push(`**${summary}.**`) + const badges = extensionBadges(op) + if (badges.length > 0) lines.push(`\`${badges.join(' · ')}\``) + lines.push('') + if (op.description) { + lines.push(op.description.trim()) + lines.push('') + } + + const params = collectParameters(spec, entry) + const queryAndPath = params.filter((p) => p.in === 'query' || p.in === 'path' || p.in === 'header') + if (queryAndPath.length > 0) { + lines.push('| Parameter | In | Type | Required | Notes |') + lines.push('|---|---|---|---|---|') + for (const p of queryAndPath) { + const type = condenseSchema(spec, p.schema, { depth: 2 }) + const note = (p.description ?? '').replace(/\s+/g, ' ').trim() + lines.push( + `| \`${p.name}\` | ${p.in} | \`${mdCell(type)}\` | ${p.required ? 'yes' : 'no'} | ${mdCell(note)} |`, + ) + } + lines.push('') + } + + const body = op.requestBody?.content?.['application/json']?.schema + const multipart = op.requestBody?.content?.['multipart/form-data']?.schema + if (body) { + lines.push('Request body:') + lines.push('```ts') + lines.push(condenseSchema(spec, body)) + lines.push('```') + lines.push('') + } else if (multipart) { + lines.push('Request body (`multipart/form-data`):') + lines.push('```ts') + lines.push(condenseSchema(spec, multipart)) + lines.push('```') + lines.push('') + } + + const [code, response] = successResponse(op) + const responseSchema = response?.content?.['application/json']?.schema + if (responseSchema) { + lines.push(`Response \`${code}\`:`) + lines.push('```ts') + lines.push(condenseSchema(spec, responseSchema)) + lines.push('```') + lines.push('') + } else if (response) { + const contentTypes = Object.keys(response.content ?? {}) + lines.push( + `Response \`${code}\`${contentTypes.length > 0 ? ` (\`${contentTypes.join('`, `')}\`)` : ''}.`, + ) + lines.push('') + } + + const errorCodes = Object.keys(op.responses ?? {}).filter((c) => /^[45]/.test(c)) + if (errorCodes.length > 0) { + const described = errorCodes + .map((c) => { + const desc = (op.responses[c].description ?? '').replace(/\s+/g, ' ').trim() + return desc && desc.toLowerCase() !== 'error' ? `\`${c}\` (${desc})` : `\`${c}\`` + }) + .join(', ') + lines.push(`Errors: ${described}`) + lines.push('') + } + + return lines.join('\n') +} + +// --------------------------------------------------------------------------- +// CLI +// --------------------------------------------------------------------------- + +function main() { + const args = process.argv.slice(2) + const specPath = args.find((a) => !a.startsWith('--')) + if (!specPath) { + console.error( + 'Usage: openapi-inventory.mjs [--group ] [--json]', + ) + process.exit(1) + } + const spec = loadSpec(specPath) + const inv = buildInventory(spec) + + if (args.includes('--json')) { + const plain = { + ...inv, + groups: inv.groups.map((g) => ({ + name: g.name, + operations: g.operations.map((o) => ({ + method: o.method, + path: o.path, + operationId: o.op.operationId, + summary: o.op.summary, + })), + })), + } + console.log(JSON.stringify(plain, null, 2)) + return + } + + const groupArg = args.indexOf('--group') + if (groupArg !== -1) { + const name = args[groupArg + 1] + const group = inv.groups.find((g) => g.name === name) + if (!group) { + console.error( + `No group "${name}". Groups: ${inv.groups.map((g) => g.name).join(', ')}`, + ) + process.exit(1) + } + console.log(`## ${name} (${group.operations.length} operations)\n`) + for (const entry of group.operations) { + console.log(renderOperationMd(spec, entry)) + console.log('---\n') + } + return + } + + // Compact overview. + console.log(`# ${inv.title} ${inv.version}`.trim()) + if (inv.servers.length > 0) console.log(`Servers: ${inv.servers.join(', ')}`) + const schemes = Object.entries(inv.securitySchemes) + .map(([n, s]) => `${n} (${[s.type, s.scheme, s.bearerFormat].filter(Boolean).join(' ')})`) + .join(', ') + if (schemes) console.log(`Auth: ${schemes}`) + console.log(`Operations: ${inv.operationCount}\n`) + if (inv.description) console.log(`${inv.description.trim()}\n`) + for (const group of inv.groups) { + console.log(`## ${group.name} (${group.operations.length})`) + for (const entry of group.operations) console.log(formatOpLine(entry)) + console.log('') + } +} + +const isDirectRun = + process.argv[1] && import.meta.url === new URL(`file://${process.argv[1]}`).href +if (isDirectRun) main()