diff --git a/app/api/v1/companies/[companyId]/webhooks/[id]/rotate-secret/route.ts b/app/api/v1/companies/[companyId]/webhooks/[id]/rotate-secret/route.ts new file mode 100644 index 00000000..93af6f30 --- /dev/null +++ b/app/api/v1/companies/[companyId]/webhooks/[id]/rotate-secret/route.ts @@ -0,0 +1,142 @@ +/** + * /api/v1/companies/{companyId}/webhooks/{id}/rotate-secret — POST :rotate-secret verb. + * + * Generates a fresh HMAC signing secret for the webhook and returns it + * EXACTLY ONCE in the response. The old secret is invalidated immediately + * — there is no grace period. Callers must coordinate the rotation: + * + * 1. Stage the new secret on the receiver (separate config slot, + * do NOT activate yet). + * 2. POST /rotate-secret. + * 3. Activate the new secret on the receiver. + * 4. POST /webhooks/{id}/test to verify the receiver accepts the new + * signature. + * + * Steps 2–3 are the window where in-flight deliveries from the dispatcher + * may carry the new signature; receivers must accept both for at most a + * few seconds. If your operational tolerance for that window is zero, + * disable the webhook before rotation (`PATCH active=false`) and re-enable + * after step 3. + * + * A "previous_secret" column with TTL-based grace period (Stripe-style) + * is the natural follow-up. v1 ships instant rotation as the simplest + * shape that closes the "secret leaked, need to rotate now" use case. + */ + +import { z } from 'zod' +import { ok } from '@/lib/api/v1/response' +import { registerEndpoint } from '@/lib/api/v1/registry' +import { withApiV1 } from '@/lib/api/v1/with-api-v1' +import { v1ErrorResponse, v1ErrorResponseFromCode } from '@/lib/api/v1/errors' +import { generateWebhookSecret } from '@/lib/webhooks/signing' + +const RotateSecretResponse = z.object({ + id: z.string().uuid(), + secret: z.string(), + rotated_at: z.string(), +}) + +registerEndpoint({ + operation: 'webhooks.rotate_secret', + method: 'POST', + path: '/api/v1/companies/:companyId/webhooks/:id/rotate-secret', + summary: 'Rotate the HMAC signing secret on a webhook.', + description: + '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.', + useWhen: + '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.', + doNotUseFor: + '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.', + ], + example: { + response: { + data: { + id: 'a8f1…', + secret: 'whsec_…', + rotated_at: '2026-05-15T12:00:00Z', + }, + meta: { request_id: 'req_…', api_version: '2026-05-12' }, + }, + }, + scope: 'webhooks:manage', + risk: 'medium', + idempotent: false, + reversible: false, + dryRunSupported: false, + response: { success: RotateSecretResponse }, +}) + +export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string }> }>( + 'webhooks.rotate_secret', + async (_request, ctx, params) => { + const { id } = await params.params + + const newSecret = `whsec_${generateWebhookSecret()}` + const rotatedAt = new Date().toISOString() + + // Single atomic UPDATE … RETURNING name. The preflight existence-check + // SELECT is unnecessary because PostgREST's .select(...).maybeSingle() + // on the UPDATE returns null when no row matched — which is the same + // signal (existence) the SELECT gave us, but in one round trip and + // without the TOCTOU window a separate SELECT introduces. + // + // RETURNING `name` so the audit_log description carries a human + // identifier without a second read. + const { data: updatedRow, error: updateErr } = await ctx.supabase + .from('webhooks') + .update({ secret: newSecret }) + .eq('company_id', ctx.companyId!) + .eq('id', id) + .select('id, name') + .maybeSingle() + + if (updateErr) return v1ErrorResponse(updateErr, ctx.log, { requestId: ctx.requestId }) + if (!updatedRow) { + return v1ErrorResponseFromCode('NOT_FOUND', ctx.log, { requestId: ctx.requestId }) + } + const w = updatedRow as { id: string; name: string } + + // Audit log entry — V16 security event. Records the rotation with + // actor attribution but NEVER the secret value itself (signing + // material must not land in the audit trail). new_state carries the + // event metadata; the secret is omitted by design. CC7.2 — surface + // a structured warning when the audit write fails so SIEM can alert. + const { error: auditErr } = await ctx.supabase.from('audit_log').insert({ + user_id: ctx.userId, + company_id: ctx.companyId, + action: 'SECURITY_EVENT', + table_name: 'webhooks', + record_id: id, + actor_id: ctx.apiKeyId ?? null, + description: `Webhook secret rotated: "${w.name}"`, + new_state: { event: 'secret_rotated', rotated_at: rotatedAt }, + }) + if (auditErr) { + ctx.log.warn('audit_log insert failed for webhook rotate-secret', { + webhookId: id, + code: auditErr.code, + }) + } + + // Cache-Control: no-store prevents any intermediary (CDN, proxy, + // load-balancer access log, API gateway, browser cache) from + // persisting the response body. The HMAC secret is sensitive + // credential material returned exactly once — landing it in an + // intermediary log store with a different retention policy than + // intended would defeat the rotation's purpose (Art.25 / CC6.1). + return ok( + { id, secret: newSecret, rotated_at: rotatedAt }, + { + requestId: ctx.requestId, + headers: { + 'Cache-Control': 'no-store, no-cache, must-revalidate, private', + Pragma: 'no-cache', + }, + }, + ) + }, + { requireIdempotencyKey: true }, +) diff --git a/app/api/v1/companies/[companyId]/webhooks/[id]/route.ts b/app/api/v1/companies/[companyId]/webhooks/[id]/route.ts index fba255f7..d25d61c1 100644 --- a/app/api/v1/companies/[companyId]/webhooks/[id]/route.ts +++ b/app/api/v1/companies/[companyId]/webhooks/[id]/route.ts @@ -214,6 +214,16 @@ export const PATCH = withApiV1<{ params: Promise<{ companyId: string; id: string ) } + // Capture prior state for the audit_log old_state field. One extra + // SELECT — cost is negligible for a manual webhook PATCH and the + // before/after pair is what makes the audit row reconstructible. + const { data: prior } = await ctx.supabase + .from('webhooks') + .select('name, description, webhook_url, active, disabled_at, disabled_reason') + .eq('company_id', ctx.companyId!) + .eq('id', id) + .maybeSingle() + const { data, error } = await ctx.supabase .from('webhooks') .update(update) @@ -225,6 +235,40 @@ export const PATCH = withApiV1<{ params: Promise<{ companyId: string; id: string if (error) return v1ErrorResponse(error, ctx.log, { requestId: ctx.requestId }) if (!data) return v1ErrorResponseFromCode('NOT_FOUND', ctx.log, { requestId: ctx.requestId }) + // V16 audit log — webhook lifecycle event. Record the diff. + // + // new_state is populated from the DB-confirmed returned row (`data`) + // through an explicit field allowlist — NOT from the spread + // `update` object. Two reasons: (a) the post-UPDATE state is the + // ground truth, and a future column-level CHECK/trigger that + // rejects a field would leave the request-body-derived shape + // misleadingly out of sync (A.8.11 / V16.1.1); (b) the allowlist + // foreclosures any future widening of PatchWebhookSchema that + // accidentally pulls a sensitive field into the audit trail. + const changedFields = Object.keys(body) + const d = data as Record + const { error: auditErr } = await ctx.supabase.from('audit_log').insert({ + user_id: ctx.userId, + company_id: ctx.companyId, + action: 'UPDATE', + table_name: 'webhooks', + record_id: id, + actor_id: ctx.apiKeyId ?? null, + description: `Webhook updated: ${changedFields.join(', ')}`, + old_state: prior ?? null, + new_state: { + name: d.name, + description: d.description, + webhook_url: d.webhook_url, + active: d.active, + disabled_at: d.disabled_at, + disabled_reason: d.disabled_reason, + }, + }) + if (auditErr) { + ctx.log.warn('audit_log insert failed for webhook update', { webhookId: id, code: auditErr.code }) + } + return ok(data, { requestId: ctx.requestId }) }, ) @@ -262,13 +306,46 @@ export const DELETE = withApiV1<{ params: Promise<{ companyId: string; id: strin 'webhooks.delete', async (_request, ctx, params) => { const { id } = await params.params - const { error } = await ctx.supabase + + // Atomic delete + returning. One round trip captures both the + // deletion-confirmation row count and the deleted row's prior state + // for the audit_log entry — eliminates the pre-read TOCTOU window + // a separate SELECT introduced (V8.2.1). Idempotent DELETE: a 0-row + // delete (already-deleted webhook) still returns 204 because the + // resource is gone, which is the desired end state. + const { data: deleted, error } = await ctx.supabase .from('webhooks') .delete() .eq('company_id', ctx.companyId!) .eq('id', id) + .select('name, event_type, webhook_url, active') + .maybeSingle() if (error) return v1ErrorResponse(error, ctx.log, { requestId: ctx.requestId }) + + // V16 audit log — webhook lifecycle event. Records the deletion + // UNCONDITIONALLY. When `deleted` is null (no row matched — + // idempotent re-delete or cross-tenant id), the audit row still + // captures the attempt: record_id + actor_id + action + timestamp + // is the minimum CC6.3 attribution contract; old_state degrades + // to null. + const p = deleted as { name: string; event_type: string; webhook_url: string; active: boolean } | null + const { error: auditErr } = await ctx.supabase.from('audit_log').insert({ + user_id: ctx.userId, + company_id: ctx.companyId, + action: 'DELETE', + table_name: 'webhooks', + record_id: id, + actor_id: ctx.apiKeyId ?? null, + description: p + ? `Webhook deleted: "${p.name}" (${p.event_type})` + : `Webhook delete attempted on missing id=${id} (idempotent or cross-tenant)`, + old_state: p, + }) + if (auditErr) { + ctx.log.warn('audit_log insert failed for webhook delete', { webhookId: id, code: auditErr.code }) + } + return noContent({ requestId: ctx.requestId }) }, ) diff --git a/app/api/v1/companies/[companyId]/webhooks/__tests__/route.test.ts b/app/api/v1/companies/[companyId]/webhooks/__tests__/route.test.ts index 90495d7f..551db3c7 100644 --- a/app/api/v1/companies/[companyId]/webhooks/__tests__/route.test.ts +++ b/app/api/v1/companies/[companyId]/webhooks/__tests__/route.test.ts @@ -72,6 +72,7 @@ import { } from '../[id]/route' import { POST as testWebhook } from '../[id]/test/route' import { GET as listDeliveries } from '../[id]/deliveries/route' +import { POST as rotateSecret } from '../[id]/rotate-secret/route' const mockValidate = validateApiKey as ReturnType const mockServiceClient = createServiceClientNoCookies as ReturnType @@ -640,6 +641,88 @@ describe('GET /api/v1/companies/:companyId/webhooks/:id/deliveries', () => { }) }) +// ────────────────────────────────────────────────────────────────────── +// POST /webhooks/:id/rotate-secret +// ────────────────────────────────────────────────────────────────────── + +describe('POST /api/v1/companies/:companyId/webhooks/:id/rotate-secret', () => { + it('returns a freshly-minted secret EXACTLY ONCE on rotation', async () => { + mockServiceClient.mockReturnValue( + makeFlexibleSupabase({ + company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null }, + webhooks: { data: { id: WEBHOOK_ID, name: 'CRM sync' }, error: null }, + }), + ) + + const res = await rotateSecret( + makeRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/webhooks/${WEBHOOK_ID}/rotate-secret`, { + method: 'POST', + }), + detailParams(COMPANY_ID, WEBHOOK_ID), + ) + + expect(res.status).toBe(200) + const body = await res.json() + expect(body.data.id).toBe(WEBHOOK_ID) + expect(typeof body.data.secret).toBe('string') + expect(body.data.secret).toMatch(/^whsec_/) + expect(body.data.rotated_at).toBeTruthy() + }) + + it('returns 404 NOT_FOUND when the webhook does not exist for this company', async () => { + mockServiceClient.mockReturnValue( + makeFlexibleSupabase({ + company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null }, + webhooks: { data: null, error: null }, + }), + ) + + const res = await rotateSecret( + makeRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/webhooks/${WEBHOOK_ID}/rotate-secret`, { + method: 'POST', + }), + detailParams(COMPANY_ID, WEBHOOK_ID), + ) + + expect(res.status).toBe(404) + const body = await res.json() + expect(body.error.code).toBe('NOT_FOUND') + }) + + it('returns 401 UNAUTHORIZED when no Bearer token is supplied', async () => { + const req = new Request( + `https://x.test/api/v1/companies/${COMPANY_ID}/webhooks/${WEBHOOK_ID}/rotate-secret`, + { method: 'POST' }, + ) + + const res = await rotateSecret(req, detailParams(COMPANY_ID, WEBHOOK_ID)) + expect(res.status).toBe(401) + }) + + it('requires an Idempotency-Key header (write endpoint)', async () => { + mockServiceClient.mockReturnValue( + makeFlexibleSupabase({ + company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null }, + }), + ) + + // Build request WITHOUT the Idempotency-Key header (default makeRequest adds it). + const req = new Request( + `https://x.test/api/v1/companies/${COMPANY_ID}/webhooks/${WEBHOOK_ID}/rotate-secret`, + { + method: 'POST', + headers: { Authorization: 'Bearer test-fixture-not-a-real-key' }, + }, + ) + + const res = await rotateSecret(req, detailParams(COMPANY_ID, WEBHOOK_ID)) + + expect(res.status).toBe(400) + const body = await res.json() + expect(body.error.code).toBe('VALIDATION_ERROR') + }) +}) + // ────────────────────────────────────────────────────────────────────── // Cross-tenant URL guard (wrapper level) // ────────────────────────────────────────────────────────────────────── diff --git a/app/api/v1/companies/[companyId]/webhooks/route.ts b/app/api/v1/companies/[companyId]/webhooks/route.ts index 951b8e58..4665eb3f 100644 --- a/app/api/v1/companies/[companyId]/webhooks/route.ts +++ b/app/api/v1/companies/[companyId]/webhooks/route.ts @@ -323,9 +323,51 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string }> }>( return v1ErrorResponse(error, ctx.log, { requestId: ctx.requestId }) } + // V16 audit log — webhook lifecycle event. Records creation + actor + // attribution. new_state captures the row WITHOUT the secret (signing + // material must not land in the audit trail). A failed audit write + // is logged structurally so SIEM tooling can alert on the gap + // (CC7.2) — we don't roll back the create on audit failure because + // the webhook itself is already persisted. + const created_row = data as Record & { id: string } + const { error: auditErr } = await ctx.supabase.from('audit_log').insert({ + user_id: ctx.userId, + company_id: ctx.companyId, + action: 'INSERT', + table_name: 'webhooks', + record_id: created_row.id, + actor_id: ctx.apiKeyId ?? null, + description: `Webhook created: "${body.name}" → ${body.webhook_url} (${body.event_type})`, + new_state: { + name: body.name, + event_type: body.event_type, + webhook_url: body.webhook_url, + api_version_pinned: API_V1_VERSION, + active: true, + }, + }) + if (auditErr) { + ctx.log.warn('audit_log insert failed for webhook create', { + webhookId: created_row.id, + code: auditErr.code, + }) + } + // Secret returned exactly once. Caller must persist it on the receiver // side — gnubok will not surface it on any subsequent endpoint. - return created({ ...(data as Record), secret }, { requestId: ctx.requestId }) + // Cache-Control: no-store mirrors the rotate-secret response (A.8.12 / + // Art.25) so no intermediary (CDN / proxy / gateway log / browser + // cache) persists the secret beyond the direct response chain. + return created( + { ...created_row, secret }, + { + requestId: ctx.requestId, + headers: { + 'Cache-Control': 'no-store, no-cache, must-revalidate, private', + Pragma: 'no-cache', + }, + }, + ) }, { requireIdempotencyKey: true }, ) diff --git a/lib/api/v1/__tests__/__snapshots__/spec-snapshot.test.ts.snap b/lib/api/v1/__tests__/__snapshots__/spec-snapshot.test.ts.snap index c4717279..c86f6400 100644 --- a/lib/api/v1/__tests__/__snapshots__/spec-snapshot.test.ts.snap +++ b/lib/api/v1/__tests__/__snapshots__/spec-snapshot.test.ts.snap @@ -1,6 +1,6 @@ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html -exports[`v1 spec snapshot > matches the recorded endpoint count > endpoint-count 1`] = `100`; +exports[`v1 spec snapshot > matches the recorded endpoint count > endpoint-count 1`] = `101`; exports[`v1 spec snapshot > matches the recorded endpoint key set > endpoint-keys 1`] = ` [ @@ -102,6 +102,7 @@ exports[`v1 spec snapshot > matches the recorded endpoint key set > endpoint-key "POST /api/v1/companies/:companyId/transactions/ingest", "POST /api/v1/companies/:companyId/voucher-gap-explanations", "POST /api/v1/companies/:companyId/webhooks", + "POST /api/v1/companies/:companyId/webhooks/:id/rotate-secret", "POST /api/v1/companies/:companyId/webhooks/:id/test", "POST /api/v1/webhook-deliveries/:id/retry", ] diff --git a/lib/api/v1/load-routes.ts b/lib/api/v1/load-routes.ts index d0d37ad5..765fef82 100644 --- a/lib/api/v1/load-routes.ts +++ b/lib/api/v1/load-routes.ts @@ -126,4 +126,7 @@ import '@/app/api/v1/companies/[companyId]/webhooks/[id]/test/route' import '@/app/api/v1/companies/[companyId]/webhooks/[id]/deliveries/route' import '@/app/api/v1/webhook-deliveries/[id]/retry/route' +// Phase 6 PR-3 — webhook secret rotation. +import '@/app/api/v1/companies/[companyId]/webhooks/[id]/rotate-secret/route' + export {} diff --git a/lib/auth/scopes.ts b/lib/auth/scopes.ts index bc50b673..fd4114f4 100644 --- a/lib/auth/scopes.ts +++ b/lib/auth/scopes.ts @@ -189,6 +189,7 @@ export const V1_ENDPOINT_SCOPES: Record = { 'DELETE /api/v1/companies/:companyId/webhooks/:id': 'webhooks:manage', 'POST /api/v1/companies/:companyId/webhooks/:id/test': 'webhooks:manage', 'GET /api/v1/companies/:companyId/webhooks/:id/deliveries': 'webhooks:manage', + 'POST /api/v1/companies/:companyId/webhooks/:id/rotate-secret': 'webhooks:manage', 'POST /api/v1/webhook-deliveries/:id/retry': 'webhooks:manage', } diff --git a/lib/docs/content/cookbook/file-vat-declaration.ts b/lib/docs/content/cookbook/file-vat-declaration.ts new file mode 100644 index 00000000..9ea9a42f --- /dev/null +++ b/lib/docs/content/cookbook/file-vat-declaration.ts @@ -0,0 +1,142 @@ +export const COOKBOOK_VAT_DECLARATION_MD = `# Cookbook — compute and review a VAT declaration + +> Compute the Swedish momsdeklaration rutor 05–49 from your committed transactions, reconcile against the general ledger, and prepare the numbers for manual submission to Skatteverket. + +This is the operational companion to the [Reports reference](/docs/api/reference/reports) and the [Skatteverket integration notes](/docs/api/webhooks#operation-events). v1 does NOT submit the declaration to Skatteverket directly — that path exists via the BankID-gated Skatteverket extension, not the public REST API. v1 produces the numbers and the receipt-quality JSON for manual submission via Skatteverket Mina Sidor. + +## What you'll need + +- A test API key with \`reports:read\` scope. +- All transactions for the period categorised and posted (see [ingest-bank-transactions cookbook](/docs/api/cookbook/ingest-bank-transactions)). +- The company's \`moms_redovisning\` cycle configured — monthly (kvartalsvis is supported for small companies with omsättning ≤ 1M SEK; the API doesn't dictate cadence, your bookkeeping does). + +## 1. Compute the declaration + +\`GET /reports/vat-declaration\` returns rutor 05–62 plus the reconciliation block: + +\`\`\`bash +curl "https://gnubok.app/api/v1/companies/$COMPANY_ID/reports/vat-declaration?period=2026-04" \\ + -H "Authorization: Bearer gnubok_sk_test_..." +\`\`\` + +Response (abbreviated): + +\`\`\`json +{ + "data": { + "period": { "year": 2026, "month": 4, "label": "april 2026" }, + "company": { "org_number": "556677-8899", "vat_registration_no": "SE556677889901" }, + "rutor": { + "05": { "label": "Momspliktig försäljning", "amount": 124300.00 }, + "06": { "label": "Momspliktig försäljning som inte ingår i ruta 05", "amount": 0.00 }, + "07": { "label": "Momspliktig inköp omv. skattskyldighet", "amount": 0.00 }, + "10": { "label": "Utgående moms 25% (på ruta 05)", "amount": 31075.00 }, + "11": { "label": "Utgående moms 12%", "amount": 720.00 }, + "12": { "label": "Utgående moms 6%", "amount": 180.00 }, + "30": { "label": "Inköp av varor från EU (omv. skatt)", "amount": 0.00 }, + "31": { "label": "Inköp av tjänster från EU (omv. skatt)", "amount": 5200.00 }, + "32": { "label": "Inköp utanför EU (omv. skatt)", "amount": 0.00 }, + "39": { "label": "Försäljning tjänster EU", "amount": 3450.00 }, + "40": { "label": "Export utanför EU", "amount": 0.00 }, + "48": { "label": "Ingående moms (avdragsgill)", "amount": 12347.00 }, + "49": { "label": "Moms att betala (+) eller återfå (−)", "amount": 19628.00 } + }, + "reconciliation": { + "gl_balance_2611": 31075.00, + "gl_balance_2614": 0.00, + "gl_balance_2615": 0.00, + "gl_balance_2621": 720.00, + "gl_balance_2631": 180.00, + "gl_balance_2641": 12347.00, + "gl_balance_2645": 0.00, + "rutor_match_gl": true + }, + "warnings": [] + }, + "meta": { "request_id": "req_...", "api_version": "2026-05-12" } +} +\`\`\` + +Ruta 49 = (utgående moms 10+11+12 + utländsk omv. 30+31+32 + utländsk försäljning 60+61+62) − ingående moms (ruta 48). Positive → moms att betala. Negative → moms att återfå. + +## 2. Reconcile against the GL + +The \`reconciliation\` block compares the rutor against the actual general-ledger balances on the moms accounts: + +- \`2611\` — Utgående moms 25% (matches ruta 10) +- \`2614\` — Utgående moms vid omvänd skattskyldighet (matches ruta 30 / reverse-charge output) +- \`2615\` — Utgående moms vid import (matches ruta 60) +- \`2621\` — Utgående moms 12% (matches ruta 11) +- \`2631\` — Utgående moms 6% (matches ruta 12) +- \`2641\` — Ingående moms (matches ruta 48) +- \`2645\` — Beräknad ingående moms vid EU-förvärv (rolls into rutor 30/31/32 → ruta 48) + +\`rutor_match_gl: true\` means every figure on the declaration ties to the GL — the declaration is self-consistent. \`false\` triggers a per-rate \`warnings\` entry pointing at the offending account; investigate before submitting. + +## 3. The 2026-04-01 livsmedel rate change + +**Important compliance moment in April 2026.** The VAT rate on livsmedel (groceries) drops from 12% → 6% effective 2026-04-01 under the regeringens vårproposition 2025. The decisive date under ML (2023:200) 1 kap 3 § is the *tidpunkt för skattskyldighetens inträde* — for goods this is the **supply date** (delivery), not the invoice date. + +- **Always pass \`delivery_date\` explicitly when it differs from \`invoice_date\`.** The engine routes the booking by supply date: food delivered ≥ 2026-04-01 books to \`2631\` (6%), food delivered before that books to \`2621\` (12%), regardless of when the invoice was issued. For continuous or subscription food supplies (e.g. a weekly grocery box), the trigger point is the date when each individual delivery's skattskyldighet inträder — confirm against ML 1 kap 3 § rather than assuming the rule equals a single delivery date. +- The classic edge case: food delivered in March, invoiced in April. Without an explicit \`delivery_date\` the engine falls back to \`invoice_date\` and would mis-book at 6%. **Set \`delivery_date\` for every food-line item in March-April 2026 invoices** — the cost of explicit data is zero; the cost of a mis-booked verifikation is a manual rectification + a momsdeklaration adjustment. +- When \`delivery_date\` is omitted, the engine uses \`invoice_date\` as the fallback supply date. This is correct for **one-off** service supplies where delivery and invoice coincide; long-running service contracts (subscriptions, ongoing maintenance) have per-delprestation skattskyldighet under ML 1 kap 3 § and require an explicit \`delivery_date\` per billing cycle. Goods that straddle the cutover always need an explicit \`delivery_date\`. + +The VAT declaration for April 2026 onwards will show split balances on rutor 11/12: pre-2026-04-01 food sales remain on ruta 11 (12%), post-cutover food sales appear on ruta 12 (6%). The reconciliation block surfaces both; warnings flag any post-cutover transaction still booked at 12%. + +## 4. Pre-flight: voucher gaps + +BFNAR 2013:2 kap 6–7 §§ requires every voucher gap to have a documented explanation. Skatteverket may ask why \`F-2026-0042\` exists when no \`F-2026-0041\` is on the books. Check before declaring: + +\`\`\`bash +curl "https://gnubok.app/api/v1/companies/$COMPANY_ID/compliance/check?type=voucher_gaps&period=2026-04" \\ + -H "Authorization: Bearer gnubok_sk_test_..." +\`\`\` + +If gaps exist, file an explanation via \`POST /voucher-gap-explanations\` BEFORE submitting the declaration — gaps without explanations are a compliance audit finding. + +## 5. Pre-flight: locked period + +The declaration is computed from posted entries in the period. If the period is still open and you have draft entries that should be in this declaration, commit them before declaring. After declaring, lock the period: + +\`\`\`bash +curl -X POST "https://gnubok.app/api/v1/companies/$COMPANY_ID/fiscal-periods/$PERIOD_ID/lock" \\ + -H "Authorization: Bearer gnubok_sk_test_..." \\ + -H "Idempotency-Key: $(uuidgen)" +\`\`\` + +Locking is reversible (via \`PATCH /fiscal-periods/{id}\` with a clear reason in the audit log). Closing the period is irreversible per BFL 5 kap 8 §; only close after the declaration is submitted AND any audit-period grace window has passed. + +## 6. Manual submission to Skatteverket + +v1 does not submit the declaration. The receipt-quality JSON above is what you transcribe into Skatteverket Mina Sidor (or feed into your own Skatteverket-integration tooling, gated by BankID — handled by the optional \`skatteverket\` extension, not the public REST API). + +For audit-trail completeness, capture the submission confirmation number from Skatteverket and store it on the period via \`PATCH /fiscal-periods/{id}\`: + +\`\`\`bash +curl -X PATCH "https://gnubok.app/api/v1/companies/$COMPANY_ID/fiscal-periods/$PERIOD_ID" \\ + -H "Authorization: Bearer gnubok_sk_test_..." \\ + -H "Content-Type: application/json" \\ + -d '{ "submission_reference": "SKV-2026-04-AB123456" }' +\`\`\` + +## EU and reverse-charge handling + +Sales of services to other EU businesses (\`vat_treatment: 'reverse_charge_eu'\`) appear on ruta 39 and bypass the output-moms accounts (no entry on 26xx). The customer accounts for moms in their own country. + +Purchases of services from other EU businesses (supplier_invoice with \`vat_treatment: 'reverse_charge_eu'\`) appear on ruta 31. The engine books both an output-moms entry on 2614 (calculated 25% reverse) AND an input-moms entry on 2645 — net zero impact on cash flow when full avdragsrätt applies, full traceability on the declaration. **For blandad verksamhet (mixed-activity companies with partial avdragsrätt per HFD 2023 ref. 45),** the \`2645\` leg must be proportionally restricted before it reaches ruta 48 — set \`company_settings.vat_deduction_percent\` so the engine applies the correct restriction automatically; otherwise the input-moms reaches ruta 48 unrestricted and over-declares the deduction. + +Imports from outside EU (\`vat_treatment: 'import'\`) book through a customs-clearance flow — the customs invoice is what posts the moms, not the supplier invoice itself. Coverage of this is in the [Supplier invoices reference](/docs/api/reference/supplier-invoices). + +## Common pitfalls + +- **Decimals vs hela kronor — truncate öre, do not round.** The API returns rutor as decimal numbers (öre preserved). Skatteverket Mina Sidor and SRU filings accept only hela kronor; the rule per SFL 22 kap 1 § is **truncation** (drop öre), NOT half-up rounding. Use \`Math.floor\` for positive amounts when transcribing. Truncate at the rendering / submission boundary, not in storage. +- **Don't compute mid-month.** The figures are only meaningful for a complete month; calling \`?period=2026-04\` mid-April returns the partial state. The endpoint doesn't refuse partial periods, so this is on the integrator. +- **Mixed-rate invoices.** A single invoice with both 25% and 12% items lands on both \`2611\` and \`2621\`. The declaration handles this correctly because the per-line VAT rate is preserved in the engine; integrations that flatten to a single header rate will mis-declare. +- **Reverse-charge invoices in the wrong ruta.** A B2B sale to an EU customer with a missing/unvalidated VAT number does NOT qualify for reverse charge — those go to ruta 05 with normal 25% moms. Validate via \`POST /vat/validate\` (VIES) before issuing the invoice. + +## Next steps + +- **[Year-end closing](/docs/api/cookbook/year-end-closing)** — once all 12 monthly declarations are filed, close the fiscal year. +- **[Run payroll](/docs/api/cookbook/run-payroll-and-agi)** — moms and AGI are independent; both need to be filed monthly. +- **[Reports reference](/docs/api/reference/reports)** — every report, every parameter. +` diff --git a/lib/docs/content/cookbook/index.ts b/lib/docs/content/cookbook/index.ts index 002a1424..c24cb89e 100644 --- a/lib/docs/content/cookbook/index.ts +++ b/lib/docs/content/cookbook/index.ts @@ -1,17 +1,23 @@ /** - * Cookbook recipe registry. Two recipes ship in PR-2 (Phase 6 docs): + * Cookbook recipe registry. All six recipes ship live as of Phase 6 PR-3: * - quickstart: send your first invoice (high-leverage onboarding path) * - webhooks: end-to-end webhook setup with sig verification + retry handling - * - * The remaining 4 recipes from the docs nav (ingest-bank-transactions, - * file-vat-declaration, run-payroll-and-agi, year-end-closing) ship as - * placeholder pages pointing at the relevant API reference. They're - * scheduled for the docs polish follow-up after PR-3 hardening lands — - * Stripe-grade narrative quality benefits from its own focused pass. + * - ingest-bank-transactions: bank file → categorised + invoice-matched + * - file-vat-declaration: compute rutor 05–62, reconcile against GL, + * manual submission to Skatteverket (includes 2026-04-01 livsmedel + * 12% → 6% rate-change transition) + * - run-payroll-and-agi: draft → calculate → approve → mark-paid → + * book → generate-agi state machine + * - year-end-closing: IB/UB continuity per BFL 5 kap, year-end procedures, + * irreversible close per BFL 5 kap 8 § */ import { QUICKSTART_MD } from './quickstart' import { COOKBOOK_WEBHOOKS_MD } from './webhooks' +import { COOKBOOK_INGEST_BANK_MD } from './ingest-bank-transactions' +import { COOKBOOK_VAT_DECLARATION_MD } from './file-vat-declaration' +import { COOKBOOK_PAYROLL_AGI_MD } from './run-payroll-and-agi' +import { COOKBOOK_YEAR_END_MD } from './year-end-closing' interface CookbookEntry { slug: string @@ -51,30 +57,26 @@ export const COOKBOOK: CookbookEntry[] = [ { slug: 'ingest-bank-transactions', title: 'Ingest and categorise bank transactions', - markdown: null, - referenceLink: { href: '/docs/api/reference/transactions', label: 'Transactions reference' }, - description: 'Push CSV/CAMT into the engine, get AI suggestions, commit.', + markdown: COOKBOOK_INGEST_BANK_MD, + description: 'Push CSV/CAMT into the engine, get AI suggestions, commit, match payments.', }, { slug: 'file-vat-declaration', title: 'Compute and review a VAT declaration', - markdown: null, - referenceLink: { href: '/docs/api/reference/reports#get-reports-vat-declaration', label: 'VAT declaration report' }, - description: 'Compute momsdeklaration rutor 05–62 and reconcile against the GL before manual submission to Skatteverket.', + markdown: COOKBOOK_VAT_DECLARATION_MD, + description: 'Compute momsdeklaration rutor 05–62 and reconcile against the GL before manual submission to Skatteverket. Includes the 2026-04-01 livsmedel 12% → 6% rate-change transition.', }, { slug: 'run-payroll-and-agi', title: 'Run payroll and generate the AGI XML', - markdown: null, - referenceLink: { href: '/docs/api/reference/salary-runs', label: 'Salary runs reference' }, + markdown: COOKBOOK_PAYROLL_AGI_MD, description: 'Calculate, approve, mark paid, book, generate the AGI XML for manual submission to Skatteverket Mina Sidor.', }, { slug: 'year-end-closing', title: 'Year-end closing', - markdown: null, - referenceLink: { href: '/docs/api/reference/fiscal-periods', label: 'Fiscal periods reference' }, - description: 'Lock periods, run year-end, set opening balances.', + markdown: COOKBOOK_YEAR_END_MD, + description: 'Lock periods, run year-end procedures, set opening balances. IB/UB continuity per BFL 5 kap.', }, ] diff --git a/lib/docs/content/cookbook/ingest-bank-transactions.ts b/lib/docs/content/cookbook/ingest-bank-transactions.ts new file mode 100644 index 00000000..bbffe220 --- /dev/null +++ b/lib/docs/content/cookbook/ingest-bank-transactions.ts @@ -0,0 +1,240 @@ +export const COOKBOOK_INGEST_BANK_MD = `# Cookbook — ingest and categorise bank transactions + +> Push a bank statement file into gnubok, get AI-assisted category suggestions, commit the categorisations, and match payments against open invoices. End-to-end transaction-to-booking pipeline. + +This is the operational companion to the [Transactions reference](/docs/api/reference/transactions) and the [Imports reference](/docs/api/reference/imports). Use it for the first integration where transactions enter the system from a bank source. + +## What you'll need + +- A test API key with \`transactions:write\`, \`transactions:read\`, and \`imports:write\` scopes. +- A bank statement file in one of the supported formats: CSV (SEB / Swedbank / Handelsbanken / Nordea / Danske / ICA / Lendo / Ålandsbanken / SBAB / Marginalen / others auto-detected), CAMT.053 XML, or a plain account-statement CSV with at minimum date + amount + description columns. +- The settlement account for the bank — typically \`'1930'\` for an SEK business account. Check via \`GET /accounts\`. + +## 1. Upload the bank file + +\`POST /imports/bank\` accepts multipart upload. Format detection is automatic; the response includes the matched parser. The endpoint kicks off an async operation — you'll poll for the result. + +\`\`\`bash +curl "https://gnubok.app/api/v1/companies/$COMPANY_ID/imports/bank" \\ + -H "Authorization: Bearer gnubok_sk_test_..." \\ + -H "Idempotency-Key: $(uuidgen)" \\ + -F "file=@statement-2026-04.csv" \\ + -F 'settlement_account="1930"' +\`\`\` + +Response is a 202 with the operation handle: + +\`\`\`json +{ + "data": { + "operation_id": "op_a8f1...", + "status": "queued", + "poll_url": "/api/v1/operations/op_a8f1...", + "webhook_event": "operation.completed" + }, + "meta": { "request_id": "req_...", "api_version": "2026-05-12" } +} +\`\`\` + +## 2. Poll until the import completes + +Polling is the simplest pattern; subscribe to the \`operation.completed\` event ([cookbook](/docs/api/cookbook/webhooks)) for the push variant. The operation lifecycle is \`queued → running → succeeded | failed | cancelled\`. + +\`\`\`bash +curl "https://gnubok.app/api/v1/operations/$OPERATION_ID" \\ + -H "Authorization: Bearer gnubok_sk_test_..." +\`\`\` + +On \`succeeded\`: + +\`\`\`json +{ + "data": { + "operation_id": "op_a8f1...", + "type": "import.bank", + "status": "succeeded", + "progress": { "current": 187, "total": 187, "phase": "complete" }, + "result": { + "rows_inserted": 165, + "rows_skipped_duplicate": 22, + "format_detected": "seb_csv", + "earliest_date": "2026-04-01", + "latest_date": "2026-04-30" + }, + "started_at": "2026-05-01T08:00:00Z", + "completed_at": "2026-05-01T08:00:04Z" + } +} +\`\`\` + +Note the dedup: rows that match an existing transaction on \`(date, amount, description_hash)\` are skipped, not inserted twice. Re-uploading the same file is safe. + +## 3. List uncategorised transactions + +After ingest the rows are in \`transactions\` but uncategorised (\`account_number: null\`, \`category: null\`). List them: + +\`\`\`bash +curl "https://gnubok.app/api/v1/companies/$COMPANY_ID/transactions?status=uncategorized&period=2026-04&limit=50" \\ + -H "Authorization: Bearer gnubok_sk_test_..." +\`\`\` + +Response (cursor-paginated, oldest-first): + +\`\`\`json +{ + "data": [ + { + "id": "tx_...", + "transaction_date": "2026-04-03", + "description": "SEB CARD - SJ 25-...", + "amount": -487.00, + "currency": "SEK", + "category": null, + "account_number": null, + "vat_treatment": null, + "document_id": null, + "journal_entry_id": null + }, + ... + ], + "meta": { "request_id": "req_...", "next_cursor": "eyJ0cyI6Ij..." } +} +\`\`\` + +## 4. Get category suggestions + +\`POST /transactions/{id}/suggest-categories\` returns ranked guesses based on the description, counterparty history, and your booking-template library: + +\`\`\`bash +curl -X POST "https://gnubok.app/api/v1/companies/$COMPANY_ID/transactions/$TX_ID/suggest-categories" \\ + -H "Authorization: Bearer gnubok_sk_test_..." +\`\`\` + +\`\`\`json +{ + "data": { + "suggestions": [ + { + "category": "expense_travel", + "account_number": "5800", + "vat_treatment": "standard_25", + "confidence": 0.92, + "reason": "Counterparty 'SJ' matched booking template 'Tågresor' (12 prior matches)" + }, + { + "category": "expense_representation", + "account_number": "6071", + "vat_treatment": "standard_25", + "confidence": 0.15, + "reason": "Fallback — SJ has occasionally been booked as kund-representation" + } + ] + } +} +\`\`\` + +Confidence ≥ 0.85 is generally safe to auto-apply; below that surface to the user. + +## 5. Commit the categorisation + +\`POST /transactions/{id}/categorize\` stages the booking. Dry-run first to see the verifikation preview: + +\`\`\`bash +curl "https://gnubok.app/api/v1/companies/$COMPANY_ID/transactions/$TX_ID/categorize?dry_run=true" \\ + -H "Authorization: Bearer gnubok_sk_test_..." \\ + -H "Idempotency-Key: $(uuidgen)" \\ + -H "Content-Type: application/json" \\ + -d '{ + "category": "expense_travel", + "account_number": "5800", + "vat_treatment": "standard_25" + }' +\`\`\` + +Response includes the would-be journal entry lines: + +\`\`\`json +{ + "data": { + "staged_operation_id": "po_...", + "preview": { + "journal_lines": [ + { "account": "5800", "debit": 389.60, "credit": 0, "label": "Reskostnader" }, + { "account": "2641", "debit": 97.40, "credit": 0, "label": "Ingående moms 25%" }, + { "account": "1930", "debit": 0, "credit": 487.00, "label": "Företagskonto" } + ], + "voucher_number_assigned_on_commit": "auto", + "account_deltas": { "5800": -389.60, "2641": -97.40, "1930": +487.00 } + } + } +} +\`\`\` + +Drop \`?dry_run=true\` and reuse the same \`Idempotency-Key\` to commit. The response carries the audit block with the now-posted voucher number. + +## 6. Batch categorise + +For a backlog, use \`POST /transactions/batch-categorize\` (up to 100 transactions per call, dry-runnable, partial-success on commit): + +\`\`\`bash +curl "https://gnubok.app/api/v1/companies/$COMPANY_ID/transactions/batch-categorize" \\ + -H "Authorization: Bearer gnubok_sk_test_..." \\ + -H "Idempotency-Key: $(uuidgen)" \\ + -H "Content-Type: application/json" \\ + -d '{ + "items": [ + { "transaction_id": "tx_1", "category": "expense_travel", "account_number": "5800", "vat_treatment": "standard_25" }, + { "transaction_id": "tx_2", "category": "income_services", "account_number": "3001", "vat_treatment": "standard_25" }, + ... + ] + }' +\`\`\` + +Response shape — every item has its own \`ok\` flag: + +\`\`\`json +{ + "data": { + "results": [ + { "ok": true, "request_index": 0, "data": { "voucher_number": "A2026-0042" } }, + { "ok": false, "request_index": 1, "error": { "code": "PERIOD_LOCKED", "message": "Perioden är låst." } } + ], + "summary": { "total": 2, "succeeded": 1, "failed": 1 } + } +} +\`\`\` + +## 7. Match a payment against an invoice + +When a transaction is a customer payment, match it to the open invoice via \`POST /transactions/{id}/match-invoice\` instead of \`categorize\`. The engine posts the payment voucher (debit 1930, credit 1510) AND marks the invoice paid in a single transaction. + +\`\`\`bash +curl -X POST "https://gnubok.app/api/v1/companies/$COMPANY_ID/transactions/$TX_ID/match-invoice" \\ + -H "Authorization: Bearer gnubok_sk_test_..." \\ + -H "Idempotency-Key: $(uuidgen)" \\ + -H "Content-Type: application/json" \\ + -d '{ "invoice_id": "inv_...", "payment_date": "2026-04-15" }' +\`\`\` + +For supplier-invoice payments use \`POST /transactions/{id}/match-supplier-invoice\` — same shape, different counterparty side. + +## Multicurrency + +Bank statements that include non-SEK transactions are imported with the foreign amount preserved in \`amount_foreign\` + \`currency_foreign\`. When you categorise, the engine looks up the Riksbanken FX rate for the transaction date and books the SEK equivalent on the GL side. The FX delta (rate at booking vs rate at month-end revaluation) is later picked up by the currency-revaluation job. + +If you import a multi-currency statement, ensure the company has \`base_currency\` set (defaults to SEK) and that the relevant FX rates are available — fetch via \`GET /currency/rate?date=...&from=...&to=...\` or rely on the cached daily snapshot. + +## Common pitfalls + +- **Re-running the same file is safe; date-only overlap is also safe.** The dedup keys on \`(date, amount, description_hash)\` so partial overlap of two statements doesn't double-import. +- **Settlement account selection matters.** Importing into the wrong settlement account silently breaks bank reconciliation later. \`1930\` (företagskonto) is the default for SEK; a foreign-currency bank account uses its own asset account (e.g. \`1932\` for USD). +- **Cash-method companies and partial payments don't mix.** If \`company_settings.accounting_method = 'cash'\` and you try to match a partial payment, the response is \`VALIDATION_ERROR\` rather than booking accrual entries — cash-method cannot model the per-installment moms event correctly (ML 13 kap 8 §). Either book the partial payment as a separate categorisation or switch to accrual. +- **Batch-categorize is partial-success by default.** If one item hits a locked period, the others still commit. The summary block tells you the totals; check per-item \`ok\` flags. + +## Next steps + +- **[Set up webhooks](/docs/api/cookbook/webhooks)** — get notified of \`transaction.categorized\` events without polling. +- **[File a VAT declaration](/docs/api/cookbook/file-vat-declaration)** — compute the rutor 05–62 from your now-categorised transactions. +- **[Transactions reference](/docs/api/reference/transactions)** — every parameter, every filter. +- **[Imports reference](/docs/api/reference/imports)** — full bank-file format coverage. +` diff --git a/lib/docs/content/cookbook/run-payroll-and-agi.ts b/lib/docs/content/cookbook/run-payroll-and-agi.ts new file mode 100644 index 00000000..b9392001 --- /dev/null +++ b/lib/docs/content/cookbook/run-payroll-and-agi.ts @@ -0,0 +1,239 @@ +export const COOKBOOK_PAYROLL_AGI_MD = `# Cookbook — run payroll and generate the AGI XML + +> Drive a Swedish salary run from draft to booked, then generate the arbetsgivardeklaration på individnivå (AGI) XML for manual submission to Skatteverket. Five-step lifecycle, every transition idempotent and dry-runnable. + +This is the operational companion to the [Salary-runs reference](/docs/api/reference/salary-runs). The route surface mirrors the dashboard exactly — anything you can do in the UI is callable from the API. + +## What you'll need + +- A test API key with \`payroll:read\` AND \`payroll:write\` scopes. \`payroll:write\` is required for every state transition; \`payroll:read\` covers the read paths plus the elevated-scope gate on the webhook \`salary_run.*\` subscription. +- At least one employee on file with \`payroll_config\` set (\`grundlön\`, \`skattetabell\`, \`tax_column\`, \`F_skatt\` flag). +- An open fiscal period covering the salary date. + +## 1. Create a salary run (draft) + +\`POST /salary-runs\` opens a run in \`draft\` status. Personnummer in the response is masked to \`ÅÅÅÅMMDDXXXX\` per GDPR Art.5(1)(c) — the full value only appears on \`GET /employees/{id}\` (deliberate drill-in). + +\`\`\`bash +curl "https://gnubok.app/api/v1/companies/$COMPANY_ID/salary-runs" \\ + -H "Authorization: Bearer gnubok_sk_test_..." \\ + -H "Idempotency-Key: $(uuidgen)" \\ + -H "Content-Type: application/json" \\ + -d '{ + "period_year": 2026, + "period_month": 5, + "payment_date": "2026-05-25", + "employees": [ + { "employee_id": "emp_...", "grundlön": 38000 }, + { "employee_id": "emp_...", "grundlön": 42000, "övertidstillägg": 2400 } + ] + }' +\`\`\` + +Response: + +\`\`\`json +{ + "data": { + "id": "sr_...", + "status": "draft", + "period_year": 2026, + "period_month": 5, + "payment_date": "2026-05-25", + "employee_count": 2, + "total_brutto": null, + "total_avgifter": null, + "total_netto": null + } +} +\`\`\` + +Totals are null until you calculate. + +## 2. Calculate (math + draft → review) + +\`POST /salary-runs/{id}/calculate\` runs the full Swedish tax engine: skattetabell lookup per employee, sociala avgifter at the current rate (31.42% for 2026), age-adjusted reductions per Prop. 2025/26:66 (the youth-reduction band is **18–22 years old at the start of 2026** — i.e. employees **born 2003–2007** for the 2026 income year, NOT a blanket "under-25"; the elder reduction applies at **67+ from 2026**, not 66+), förmånsbeskattning, semesterlöneskuld, OB-tillägg, traktamente. + +\`\`\`bash +curl -X POST "https://gnubok.app/api/v1/companies/$COMPANY_ID/salary-runs/$SR_ID/calculate" \\ + -H "Authorization: Bearer gnubok_sk_test_..." \\ + -H "Idempotency-Key: $(uuidgen)" +\`\`\` + +Response transitions \`draft → review\`: + +\`\`\`json +{ + "data": { + "id": "sr_...", + "status": "review", + "total_brutto": 80000.00, + "total_skatt": 24300.00, + "total_avgifter": 25136.00, + "total_netto": 55700.00, + "lines": [ + { + "employee_id": "emp_...", + "personnummer": "19800401XXXX", + "brutto": 38000, + "preliminär_skatt": 11400, + "arbetsgivaravgifter": 11940, + "netto": 26600, + ... + }, + ... + ] + } +} +\`\`\` + +The \`review\` status is a soft hold — the math is done but no journal entries are posted yet. Treat this as the human-review step. + +## 3. Approve (review → approved) + +\`POST /salary-runs/{id}/approve\` validates and locks the math. After this point you can't \`PATCH\` per-employee \`grundlön\` etc. — corrections require reverting to draft (only possible if no payment is recorded). + +\`\`\`bash +curl -X POST "https://gnubok.app/api/v1/companies/$COMPANY_ID/salary-runs/$SR_ID/approve" \\ + -H "Authorization: Bearer gnubok_sk_test_..." \\ + -H "Idempotency-Key: $(uuidgen)" +\`\`\` + +Response shows \`status: 'approved'\`. The engine validates: +- Every employee has a valid \`skattetabell\` reference +- No employee's bank account is missing where required +- Sociala avgifter total matches per-employee sum to the öre +- No double-booking against a prior approved run for the same \`period_year, period_month\` + +Failures return \`SALARY_RUN_APPROVE_VALIDATION_FAILED\` with a per-employee breakdown in \`details\`. + +## 4. Mark paid (approved → paid) + +After the bank transfer settles (or you mark it on the same day for cash-method shops), tell gnubok: + +\`\`\`bash +curl -X POST "https://gnubok.app/api/v1/companies/$COMPANY_ID/salary-runs/$SR_ID/mark-paid" \\ + -H "Authorization: Bearer gnubok_sk_test_..." \\ + -H "Idempotency-Key: $(uuidgen)" \\ + -H "Content-Type: application/json" \\ + -d '{ "payment_date": "2026-05-25", "settlement_account": "1930" }' +\`\`\` + +This step records the payment event but does NOT post the journal entry yet — that's step 5. The split is deliberate: the \`mark-paid\` step gives integrators a hook to confirm the bank-side leg landed before locking the GL side. + +## 5. Book (paid → booked) + +\`POST /salary-runs/{id}/book\` is the engine-touching step. It generates 2–4 verifikationer atomically (the count depends on whether OB/övertid/traktamente have separate journals): + +- Verifikation A: Bruttolön debit → 7010 (or per-employee subkonto), credit → 2710 (preliminärskatt) + 1930 (utbetalning) +- Verifikation B: Arbetsgivaravgifter debit → 7510 (lagstadgade sociala avgifter), credit → 2731 (Avräkning sociala avgifter — payable to Skatteverket, cleared when arbetsgivardeklaration is paid) +- Optional: separate verifikationer for förmånsbeskattning (förmånsvärde → 7385 cost + 2731 avräkning), traktamente (7321 inrikes / 7322 utrikes), löneväxling (1.058 factor on 7390) + +The 2731 series is the **employer-contributions-payable** liability per BAS 2026 — not to be confused with 2615 (utgående moms vid import, unrelated to payroll). The arbetsgivardeklaration cycle posts the payable on book day and clears it via 1930 when the bank transfer to Skatteverket settles. + +\`\`\`bash +curl -X POST "https://gnubok.app/api/v1/companies/$COMPANY_ID/salary-runs/$SR_ID/book" \\ + -H "Authorization: Bearer gnubok_sk_test_..." \\ + -H "Idempotency-Key: $(uuidgen)" +\`\`\` + +Response: + +\`\`\`json +{ + "data": { + "id": "sr_...", + "status": "booked", + "journal_entries": [ + { "id": "je_a", "voucher_number": "L-2026-005", "kind": "bruttolön" }, + { "id": "je_b", "voucher_number": "L-2026-006", "kind": "arbetsgivaravgifter" } + ] + }, + "meta": { + "request_id": "req_...", + "audit": { + "voucher_numbers": ["L-2026-005", "L-2026-006"], + "immutable_at": "2026-05-25T16:00:00Z" + } + } +} +\`\`\` + +If \`book\` fails partway (e.g. period locked while waiting for the bank-side confirmation), the route is strict-mode v1 — no partial commits. The state stays at \`paid\` and the response carries the \`PERIOD_LOCKED\` error code with the offending period. + +## 6. Generate the AGI XML + +\`POST /salary-runs/{id}/generate-agi\` produces the arbetsgivardeklaration på individnivå XML for the period. Skatteverket requires AGI monthly; the XML is embedded in the JSON response — no separate file endpoint. + +\`\`\`bash +curl -X POST "https://gnubok.app/api/v1/companies/$COMPANY_ID/salary-runs/$SR_ID/generate-agi" \\ + -H "Authorization: Bearer gnubok_sk_test_..." \\ + -H "Idempotency-Key: $(uuidgen)" +\`\`\` + +Response: + +\`\`\`json +{ + "data": { + "agi_xml": "\\n", + "agi_id": "agi_...", + "period": { "year": 2026, "month": 5 }, + "total_brutto": 80000.00, + "total_avgifter": 25136.00, + "employee_count": 2, + "generated_at": "2026-05-25T16:02:00Z" + } +} +\`\`\` + +Save the XML to disk and upload it to **Skatteverket Mina Sidor → Tjänster → Arbetsgivardeklaration**. Mina Sidor accepts the file directly; no manual transcription needed. (Direct API submission requires BankID and goes through the \`skatteverket\` extension, not the public REST API.) + +After Skatteverket confirms acceptance, store the confirmation number on the AGI: + +\`\`\`bash +curl -X PATCH "https://gnubok.app/api/v1/companies/$COMPANY_ID/salary-runs/$SR_ID/agi" \\ + -H "Authorization: Bearer gnubok_sk_test_..." \\ + -H "Content-Type: application/json" \\ + -d '{ "submission_reference": "SKV-AGI-2026-05-A1B2C3" }' +\`\`\` + +## State machine summary + +\`\`\` +draft ──calculate──► review ──approve──► approved ──mark-paid──► paid ──book──► booked ──generate-agi──► (AGI XML) +\`\`\` + +Each transition is idempotent on \`Idempotency-Key\`. Retrying a transition that has already completed returns the same response with \`Idempotent-Replayed: true\`. Failed transitions don't advance the state — fix and retry. + +## Förmånsbeskattning + +When an employee has bilförmån / fri kost / friskvård, declare the förmånsvärde on the run-creation request: + +\`\`\`json +{ + "employee_id": "emp_...", + "grundlön": 42000, + "förmåner": { + "bilförmån_värde": 4250, + "kostförmån_dagar": 12 + } +} +\`\`\` + +The engine adds the förmånsvärde to bruttolön for the avgifts-basis (2731) and produces a separate \`förmåner\` line on the AGI. \`bilförmån_värde\` follows Skatteverkets schablon for 2026; pass the figure directly — the API does not compute it from car make/model/year. + +## Common pitfalls + +- **Don't \`PATCH\` after approve.** PATCH is draft-only. To correct an approved run, revert to draft (only possible before payment) or void the run and create a new one. +- **AGI period vs run period.** The AGI declaration covers \`(period_year, period_month)\` — the same period as the run, not the payment date. A run paid on 2026-06-02 for May still files as the May AGI. +- **F-skatt verification is the integrator's job.** The API trusts \`employee.payroll_config.F_skatt\` to be in sync with the employee's live Skatteverket registration. A wrong flag produces a non-compliant AGI; check the F-skattsedel before payroll runs. +- **Sociala avgifter age reduction.** Per Prop. 2025/26:66, employees who are **18–22 years old at the start of the 2026 income year (born 2003–2007)** AND employees who **have turned 67 at the start of the income year (1 January 2026)** get reduced satser. The "at the start of" boundary matters — a 66-year-old whose 67th birthday falls in February 2026 does NOT qualify for the elder reduction in 2026. The engine reads \`employee.birthdate\` and applies the correct sats automatically — don't override unless you've consulted [Skatteverkets table](https://www.skatteverket.se/foretagochorganisationer/skatter/arbetsgivareochinkomstuppgifter/arbetsgivaravgifteroch_skatteavdrag.4.18e1b10334ebe8bc80003392.html). The old "under 26" rule from 2024 does NOT apply for 2026 and later. +- **Bruttolöneavdrag vs nettolöneavdrag order.** Bruttolöneavdrag reduces both lön och avgifter; nettolöneavdrag only affects the employee's payout. Pass either explicitly in the run; don't mix them. + +## Next steps + +- **[Set up webhooks](/docs/api/cookbook/webhooks)** — subscribe to \`salary_run.booked\` and \`agi.generated\` events to drive downstream payroll integrations. +- **[Year-end closing](/docs/api/cookbook/year-end-closing)** — payroll's annual cap is the kontrolluppgift season (january of the following year). +- **[Salary-runs reference](/docs/api/reference/salary-runs)** — every parameter, every error code. +` diff --git a/lib/docs/content/cookbook/year-end-closing.ts b/lib/docs/content/cookbook/year-end-closing.ts new file mode 100644 index 00000000..e8355c6a --- /dev/null +++ b/lib/docs/content/cookbook/year-end-closing.ts @@ -0,0 +1,209 @@ +export const COOKBOOK_YEAR_END_MD = `# Cookbook — year-end closing (bokslut) + +> Lock a Swedish fiscal year, run the year-end procedures, set opening balances for the new year. Built around BFL 5 kap and 7 kap requirements (verifikationskedja, balanskontinuitet, 7-year retention). + +This is the operational companion to the [Fiscal-periods reference](/docs/api/reference/fiscal-periods). Year-end is the single most consequential lifecycle event in a Swedish bookkeeping system — closing is irreversible per BFL 5 kap 8 §. Treat the steps below as a checklist, not a script to copy-paste. + +## What you'll need + +- A test API key with \`bookkeeping:write\`, \`bookkeeping:read\`, and \`reports:read\` scopes. +- All transactions for the year posted (no drafts). +- All VAT declarations for the year filed (12 monthly, 4 quarterly, or 1 annual — see the [VAT cookbook](/docs/api/cookbook/file-vat-declaration)). +- All AGI declarations filed and kontrolluppgift (KU) generated. +- The bokslut date — usually 31 december for calendar-year companies (kalenderår), or the last day of the räkenskapsår for off-calendar (brutet räkenskapsår). + +## 1. Pre-flight: continuity check (IB/UB per BFL 5 kap) + +Before locking anything, verify the period's continuity. BFL 5 kap requires that the closing balance (UB) of year N equals the opening balance (IB) of year N+1 on every BAS 1xxx, 2xxx account. + +\`\`\`bash +curl "https://gnubok.app/api/v1/companies/$COMPANY_ID/reports/continuity-check?from=2025-01-01&to=2025-12-31" \\ + -H "Authorization: Bearer gnubok_sk_test_..." +\`\`\` + +Response: + +\`\`\`json +{ + "data": { + "checks": [ + { "account": "1930", "year_end_ub": 156432.00, "next_year_ib": 156432.00, "match": true }, + { "account": "1510", "year_end_ub": 47100.00, "next_year_ib": 47100.00, "match": true }, + { "account": "2440", "year_end_ub": -8200.00, "next_year_ib": -8200.00, "match": true }, + ... + ], + "ib_ub_continuity_holds": true, + "discrepancy_count": 0 + } +} +\`\`\` + +\`ib_ub_continuity_holds: false\` is a BFL violation — investigate before proceeding. A discrepancy on \`1930\` (bank) usually means a missed reconciliation; on \`2611-2641\` (moms) means a VAT declaration disagrees with the GL. + +## 2. Pre-flight: voucher gaps (BFNAR 2013:2) + +BFNAR 2013:2 kap 6–7 §§ requires explanations for missing voucher numbers. Run the check: + +\`\`\`bash +curl "https://gnubok.app/api/v1/companies/$COMPANY_ID/compliance/check?type=voucher_gaps&period=2025" \\ + -H "Authorization: Bearer gnubok_sk_test_..." +\`\`\` + +For every gap returned, file an explanation via \`POST /voucher-gap-explanations\` before the year is closed. Skatteverket can ask for these years later under the 7-year retention rule. + +## 3. Pre-flight: missing documents on posted entries + +For aktiebolag, BFL 7 kap requires every verifikation to have its underlag (receipt, faktura, kontrakt) attached. The check: + +\`\`\`bash +curl "https://gnubok.app/api/v1/companies/$COMPANY_ID/compliance/check?type=unmatched_documents&period=2025" \\ + -H "Authorization: Bearer gnubok_sk_test_..." +\`\`\` + +Attach missing documents via \`POST /journal-entries/{id}/documents\` before locking. After locking, the document-immutability trigger prevents detaching but allows attaching (first-link is treated as completing the audit trail, not modifying it). + +## 4. Lock the period + +\`POST /fiscal-periods/{id}/lock\` blocks all writes to the period while leaving it reversible. Use this when the year's books are "done" but you may still need to add a year-end accrual entry under supervision. + +\`\`\`bash +curl -X POST "https://gnubok.app/api/v1/companies/$COMPANY_ID/fiscal-periods/$PERIOD_ID/lock" \\ + -H "Authorization: Bearer gnubok_sk_test_..." \\ + -H "Idempotency-Key: $(uuidgen)" +\`\`\` + +Locked periods can be unlocked via \`PATCH\` with a clear reason that lands in the audit log. After year-end procedures are complete (step 6), close instead — closing is irreversible. + +## 5. Run year-end procedures + +\`POST /fiscal-periods/{id}/year-end\` is the engine-touching step. It: + +1. Posts the resultatdisposition — closes every 3xxx, 7xxx, 8xxx account into \`8910\` (årets resultat), then transfers to \`2099\` (årets resultat in equity). +2. Posts the periodiseringsfond adjustment if \`company_settings.use_periodiseringsfond\` is true. +3. Posts överavskrivningar för fastigheter och inventarier if the depreciation differential exists. +4. Computes bolagsskatten on the taxable result (currently 20.6% for 2026) and posts the \`8811\` (skatt på årets resultat) ↔ \`2512\` (beräknad skatt) entry. +5. Generates the opening-balance journal for year N+1 in a single atomic batch — every IB entry on the new period referencing the UB of the closing period. + +This is an async operation: + +\`\`\`bash +curl -X POST "https://gnubok.app/api/v1/companies/$COMPANY_ID/fiscal-periods/$PERIOD_ID/year-end" \\ + -H "Authorization: Bearer gnubok_sk_test_..." \\ + -H "Idempotency-Key: $(uuidgen)" \\ + -H "Content-Type: application/json" \\ + -d '{ + "next_period_id": "fp_...", + "result_disposition": { + "to_periodiseringsfond": 120000, + "to_balanserat_resultat": 380000 + } + }' +\`\`\` + +Response is a 202 with an operation handle (year-end can take minutes for large books): + +\`\`\`json +{ + "data": { + "operation_id": "op_...", + "status": "queued", + "poll_url": "/api/v1/operations/op_...", + "webhook_event": "operation.completed" + } +} +\`\`\` + +Poll the operation; on \`succeeded\` the result block lists every voucher posted: + +\`\`\`json +{ + "data": { + "operation_id": "op_...", + "status": "succeeded", + "result": { + "year_end_voucher_numbers": ["A-2025-9001", "A-2025-9002", "A-2025-9003"], + "opening_balance_voucher_number": "A-2026-0001", + "årets_resultat_amount": 580000.00, + "bolagsskatt_amount": 119480.00, + "periodiseringsfond_set_aside": 120000.00 + } + } +} +\`\`\` + +## 6. Verify opening balances on the new year + +After year-end runs, the new period (\`next_period_id\`) has IB on every balance-sheet account matching the prior period's UB. Verify: + +\`\`\`bash +curl "https://gnubok.app/api/v1/companies/$COMPANY_ID/reports/trial-balance?period=2026" \\ + -H "Authorization: Bearer gnubok_sk_test_..." +\`\`\` + +The \`opening_balance\` column on every 1xxx/2xxx row should equal the \`closing_balance\` on the same row for 2025. \`3xxx-8xxx\` accounts have zero opening balance — the year-end procedure cleared them into \`2099\`. + +If opening balances are wrong (rare; the engine validates before posting), use \`POST /fiscal-periods/{id}/opening-balances\` with explicit values — but this is a backstop, not a routine path. The year-end procedure should produce correct IB without manual intervention. + +## 7. Close the period (irreversible) + +After the declaration, the auditor's review (if applicable), and any year-end accruals are settled, close the period. **BFL 5 kap 8 §: closing is irreversible.** No code path can re-open a closed period. + +\`\`\`bash +curl -X POST "https://gnubok.app/api/v1/companies/$COMPANY_ID/fiscal-periods/$PERIOD_ID/close" \\ + -H "Authorization: Bearer gnubok_sk_test_..." \\ + -H "Idempotency-Key: $(uuidgen)" \\ + -H "Content-Type: application/json" \\ + -d '{ "confirmation_phrase": "close period 2025 irrevocably" }' +\`\`\` + +The \`confirmation_phrase\` is a forced typed acknowledgment. The request fails with \`VALIDATION_ERROR\` unless the literal phrase matches. + +## 8. Generate the årsredovisning (aktiebolag only) + +For an AB, the annual report (årsredovisning) is filed with Bolagsverket within 7 months of the fiscal-year end. v1 produces the K2/K3-formatted source data; you typeset it externally and submit via Bolagsverket Mina Sidor. + +\`\`\`bash +curl "https://gnubok.app/api/v1/companies/$COMPANY_ID/reports/annual-report?year=2025" \\ + -H "Authorization: Bearer gnubok_sk_test_..." +\`\`\` + +The response carries the resultaträkning, balansräkning, kassaflödesanalys (K3 only), the noter pre-populated from the GL, and the förvaltningsberättelse template. The signing flow (every styrelseledamot must sign) is outside the API surface. + +## 9. Generate INK2 / NE for the tax declaration + +The tax declaration (INK2 for AB, NE-bilaga for enskild firma) is due in March/May depending on entity type and fiscal-year shape. The endpoints: + +\`\`\`bash +# Aktiebolag — INK2 +curl "https://gnubok.app/api/v1/companies/$COMPANY_ID/reports/ink2?year=2025" \\ + -H "Authorization: Bearer gnubok_sk_test_..." + +# Enskild firma — NE-bilaga +curl "https://gnubok.app/api/v1/companies/$COMPANY_ID/reports/ne-bilaga?year=2025" \\ + -H "Authorization: Bearer gnubok_sk_test_..." +\`\`\` + +These produce **two files** — \`INFO.SRU\` (metadata header) plus \`BLANKETTER.SRU\` (the declaration body) — uploaded together as a single submission to Skatteverket. **The SRU format is plain text encoded in ISO 8859-1 (NOT XML)** — a tagged record-line shape per Skatteverket's SRU specification. A single-file upload is rejected by Skatteverket's validation. This is a separate artefact from Bolagsverket's digital årsredovisning filing, which uses **iXBRL** (an XML-based standard). SRU goes to Skatteverket for INK2/INK2R/INK2S declarations; iXBRL goes to Bolagsverket for the public annual report. Don't conflate them. + +> **Note:** \`/reports/ink2\` and \`/reports/ne-bilaga\` are queued as deferred endpoints — see the API changelog for current availability. Until they ship, generate the inputs via \`/reports/trial-balance?year=...\` and feed your tax-software of choice. + +## Brutet räkenskapsår (off-calendar year) + +For a company on a non-calendar fiscal year (e.g. 2024-07-01 → 2025-06-30), the entire flow is identical — substitute the actual period dates everywhere. The IB/UB continuity check, the year-end procedure, and the lock/close lifecycle all operate on the period regardless of its alignment with the calendar. + +The one exception: the VAT declaration cadence is monthly/kvartalsvis/årlig regardless of the räkenskapsår shape, so a brutet räkenskapsår company files moms on calendar months while closing books on its own fiscal calendar. + +## Common pitfalls + +- **Don't year-end before the last month's moms is declared.** The year-end procedure expects every moms-account balance to reconcile. A pending declaration leaves dangling balances on 2611-2641. +- **Periodiseringsfond reserve cap.** Per IL 30 kap 5 § and 30 kap 6 a §, AB can set aside max 25% of the **taxable profit AFTER schablonintäkt has been added back and BEFORE the periodiseringsfond deduction itself**. The schablonintäkt rate is **(SLR + 1%) × outstanding prior-year periodiseringsfonder balance**, where SLR is Skatteverket's statslåneränta as published on 30 November of the preceding income year — for 2026 SLR is 2.55%, so the rate is **3.55%**. The engine reads the canonical rate from \`tax_rates\` and surfaces both the schablonintäkt amount and the resulting cap on the year-end result block; pass \`to_periodiseringsfond\` as the desired set-aside amount and the engine returns \`VALIDATION_ERROR\` with the maximum allowed value if it exceeds the cap. Note: under BFL/BFNAR 2016:10 kap 13 (materiellt samband for AB), periodiseringsfond is BOOKED as an obeskattad reserv on accounts 2110–2139, not just declared on INK2 — the engine posts the booking automatically as part of the year-end procedure. +- **Don't unlock a period after the AB's annual report is filed.** The signed annual report is a public document at Bolagsverket; unlocking and changing the books afterwards creates a discrepancy with the filed report (which is itself an audit finding). Use storno (\`POST /journal-entries/{id}/reverse\`) to correct in the current open period instead. +- **Year-end is async.** The operation can take minutes; don't block your request loop on it. Subscribe to \`operation.completed\` or poll \`GET /operations/{id}\` with reasonable backoff (every 5–10s). +- **The closing-period confirmation phrase is locale-sensitive.** It must match exactly. If you localise the prompt to Swedish ("stäng period 2025 oåterkalleligt"), document the exact string your UI requires — the API requires the English version above. + +## Next steps + +- **[VAT declaration cookbook](/docs/api/cookbook/file-vat-declaration)** — covers each monthly cycle within the year. +- **[Payroll cookbook](/docs/api/cookbook/run-payroll-and-agi)** — kontrolluppgift season (jan of year N+1) follows naturally after year-end. +- **[Fiscal-periods reference](/docs/api/reference/fiscal-periods)** — every parameter, every state transition. +` diff --git a/lib/webhooks/dispatcher.ts b/lib/webhooks/dispatcher.ts index 286abd55..27ffa933 100644 --- a/lib/webhooks/dispatcher.ts +++ b/lib/webhooks/dispatcher.ts @@ -351,6 +351,14 @@ async function disableWebhook( webhookId: string, reason: string, ): Promise { + // Snapshot before the disable so the audit entry can record the prior + // state. Service-role read; bypasses RLS. + const { data: prior } = await supabase + .from('webhooks') + .select('user_id, company_id, name, active, disabled_at, disabled_reason') + .eq('id', webhookId) + .maybeSingle() + const { error } = await supabase .from('webhooks') .update({ @@ -359,7 +367,56 @@ async function disableWebhook( active: false, }) .eq('id', webhookId) - if (error) log.warn('webhook auto-disable failed', { webhookId, code: error.code }) + if (error) { + log.warn('webhook auto-disable failed', { webhookId, code: error.code }) + return + } + + // V16 security event log. Auto-disable is a privileged action taken by + // the dispatcher (not a human caller), so actor_id is null. The + // audit_log entry is written UNCONDITIONALLY — even when prior is null + // or prior.user_id is null — because the SECURITY_EVENT must produce + // a durable record (A.8.15 / V16.1.1 / CC7.2). The audit_log.user_id + // column is nullable post-multi-tenant-refactor (20260330130000), so + // a system-initiated event can legitimately write user_id=NULL. Such + // rows are invisible under the user-scoped SELECT policy but remain + // queryable under service-role review, which is appropriate for + // system-initiated events. + // + // The reason discriminates between the three auto-disable paths + // (http_410_gone / redirect_blocked / url_unsafe:) so SIEM + // tooling can alert on systematic patterns. + const p = prior as { + user_id: string | null + company_id: string | null + name: string + active: boolean + disabled_at: string | null + disabled_reason: string | null + } | null + + const { error: auditErr } = await supabase.from('audit_log').insert({ + user_id: p?.user_id ?? null, + company_id: p?.company_id ?? null, + action: 'SECURITY_EVENT', + table_name: 'webhooks', + record_id: webhookId, + actor_id: null, + description: p + ? `Webhook auto-disabled by dispatcher: ${reason} (was "${p.name}")` + : `Webhook auto-disabled by dispatcher: ${reason} (prior snapshot unavailable)`, + old_state: p + ? { active: p.active, disabled_at: p.disabled_at, disabled_reason: p.disabled_reason } + : null, + new_state: { active: false, disabled_reason: reason, disabled_at: new Date().toISOString() }, + }) + if (auditErr) { + log.warn('audit_log insert failed for webhook auto-disable', { + webhookId, + reason, + code: auditErr.code, + }) + } } // ──────────────────────────────────────────────────────────────────────