docs(api): correct /docs/api against the v1 implementation (#999)
Audited every endpoint, param, header, request/response field, error code, and webhook event in the public API docs against the v1 implementation and fixed the drift; addressed two rounds of CodeRabbit review. - Error envelope, idempotency, dry-run, and reversal-field corrections. - Registered the missing articles/dimensions/inbox-items reference resources. - Cookbook fixes: removed nonexistent endpoints, corrected params/fields, fixed the test-key vs live-key quickstart flow and the year-end lock/close sequence. - Webhooks/changelog: retry window ~87h (incl. route metadata), shipped-vs- coming-soon, counts, API-key format, previous_attributes. - export-docs-to-website.mts absolutises app-served links for the website. The gnubok-website side is on branch docs/api-correctness (already deployed). 🤖 Generated with [Claude Code](https://claude.com/claude-code)
This commit is contained in:
@@ -32,7 +32,7 @@ registerEndpoint({
|
||||
doNotUseFor:
|
||||
'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 ~72h. Use a 2xx ack-only handler if you want a clean signal.',
|
||||
'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.',
|
||||
],
|
||||
example: {
|
||||
response: {
|
||||
|
||||
@@ -46,7 +46,7 @@ registerEndpoint({
|
||||
description:
|
||||
'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).',
|
||||
useWhen:
|
||||
'You started an async operation and need to know whether it has finished. Poll every 5-30 seconds; switch to the `operation.completed` webhook for production integrations.',
|
||||
'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.)',
|
||||
doNotUseFor:
|
||||
'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: [
|
||||
|
||||
@@ -12,7 +12,7 @@ The first stable release of the public REST API. Six phases of development cover
|
||||
|
||||
### Authentication + discovery (Phase 1)
|
||||
|
||||
- API key auth via \`Authorization: Bearer gnubok_sk_<live|test>_<random>\`. 100 RPM rate limit per key.
|
||||
- API key auth via \`Authorization: Bearer gnubok_sk_<random>\` (live keys) / \`gnubok_sk_test_<random>\` (test keys). 100 RPM rate limit per key.
|
||||
- \`gnubok_sk_test_*\` keys bound to deterministic sandbox companies.
|
||||
- Scope-based authorisation per endpoint (\`invoices:read\`, \`payroll:write\`, \`webhooks:manage\`, ...).
|
||||
- Discovery: \`GET /llms.txt\`, \`GET /api/v1/openapi.json\`, \`GET /.well-known/skills/index.json\`.
|
||||
@@ -40,7 +40,7 @@ The first stable release of the public REST API. Six phases of development cover
|
||||
- **Suppliers + supplier-invoices** vertical (mirror of Phase 2 invoices on the AP side).
|
||||
- **Journal entries** primitives: \`POST /journal-entries\` (draft+commit), \`/{id}/commit\`, \`/{id}/reverse\` (storno) and \`/{id}/correct\` (rättelse): both satisfy BFL 5 kap 5 § (storno is the canonical method of rättelse), \`/batch-create\`.
|
||||
- **Voucher gap explanations**: \`POST /voucher-gap-explanations\` per BFNAR 2013:2.
|
||||
- **Fiscal-periods async ops**: \`/lock\`, \`/close\`, \`/year-end\`, \`/opening-balances\`, \`/currency-revaluation\`. All return 202 with operation_id; poll at \`GET /api/v1/operations/{id}\`.
|
||||
- **Fiscal-periods ops**: \`/lock\`, \`/close\` and \`/opening-balances\` are synchronous and return 200 with the updated period; \`/year-end\` and \`/currency-revaluation\` are async and return 202 with operation_id (poll at \`GET /api/v1/operations/{id}\`).
|
||||
- **Compliance check**: \`GET /compliance/check?type={year_end_readiness|voucher_gaps}\`: pre-flight findings before submission.
|
||||
- **Documents**: \`POST /documents\` (multipart upload, magic-number-checked), \`GET /{id}/download\` (15-min signed URL), \`POST /{id}/link\` (attach to journal entry).
|
||||
|
||||
@@ -48,26 +48,22 @@ The first stable release of the public REST API. Six phases of development cover
|
||||
|
||||
- **Employees**: full CRUD with personnummer masking on list/create per GDPR Art.5(1)(c). Soft-delete via \`is_active\`.
|
||||
- **Salary runs**: CRUD + lifecycle verbs \`/calculate\`, \`/approve\`, \`/mark-paid\`, \`/book\`, \`/generate-agi\`. State machine: draft → review → approved → paid → booked. \`/generate-agi\` produces and persists the arbetsgivardeklaration XML: the response carries it as \`data.xml\` for the integrator to upload to Skatteverket Mina Sidor (or via the optional \`skatteverket\` extension). Accounted does NOT auto-submit; the AGI deadline: **the 12th of the following month for every reporting period EXCEPT January and August, where companies with annual turnover ≤ 40 MSEK get the 17th**: is the integrator's responsibility.
|
||||
- **JSON reports** (14): trial-balance, balance-sheet, income-statement, general-ledger, journal-register, vat-declaration, monthly-breakdown, ar-ledger, supplier-ledger, continuity-check, salary-journal, avgifter-basis, vacation-liability.
|
||||
- **JSON reports** (13): trial-balance, balance-sheet, income-statement, general-ledger, journal-register, vat-declaration, monthly-breakdown, ar-ledger, supplier-ledger, continuity-check, salary-journal, avgifter-basis, vacation-liability.
|
||||
- **Binary report**: \`GET /reports/sie-export\` (text/plain SIE4 file). Note: a SIE4 export alone does NOT satisfy BFL 7 kap archiving obligations: SIE captures account-level positions and verifikationer but lacks system documentation and behandlingshistorik. Treat SIE as a portability format (Fortnox/Visma/Bokio migration), not as a complete archive.
|
||||
- **Async imports**: \`POST /imports/sie\` (multipart, 50 MB), \`POST /imports/bank\` (multipart, 10 MB, auto-format detection across 11 bank formats). Both async via \`operations\` substrate. **Post-SIE-import warning:** SIE files do NOT carry VAT codes or tax-rate-to-account mappings, AND they do NOT transfer behandlingshistorik (the source system's processing log required by BFNAR 2013:2 kap 8 §) or systemdokumentation. After importing from Fortnox / Visma / BL / SpeedLedger / Bokio you MUST manually reconfigure VAT codes (typically via \`/settings/tax-codes\`) before the first momsdeklaration; skipping this step is the most common source of incorrect VAT submissions in migrated bookkeeping. The behandlingshistorik gap must be preserved separately: under BFNAR 2013:2 kap 8 § the obligation attaches to the entire räkenskapsår, not from the import date forward. Best practice for a mid-year migration: export the source system's behandlingshistorik for the full fiscal year and archive it alongside the SIE file. Accounted starts a fresh behandlingshistorik from the import date forward; the pre-import portion of the year remains the source system's record.
|
||||
- **Async imports**: \`POST /imports/sie\` (multipart, 50 MB), \`POST /imports/bank\` (multipart, 10 MB, auto-format detection across 12 bank formats). Both async via \`operations\` substrate. **Post-SIE-import warning:** SIE files do NOT carry VAT codes or tax-rate-to-account mappings, AND they do NOT transfer behandlingshistorik (the source system's processing log required by BFNAR 2013:2 kap 8 §) or systemdokumentation. After importing from Fortnox / Visma / BL / SpeedLedger / Bokio you MUST manually reconfigure VAT codes (typically via \`/settings/tax-codes\`) before the first momsdeklaration; skipping this step is the most common source of incorrect VAT submissions in migrated bookkeeping. The behandlingshistorik gap must be preserved separately: under BFNAR 2013:2 kap 8 § the obligation attaches to the entire räkenskapsår, not from the import date forward. Best practice for a mid-year migration: export the source system's behandlingshistorik for the full fiscal year and archive it alongside the SIE file. Accounted starts a fresh behandlingshistorik from the import date forward; the pre-import portion of the year remains the source system's record.
|
||||
|
||||
### Webhooks (Phase 6 PR-1) *shipped 2026-05-15*
|
||||
### 2026-05-15 — Webhooks (Phase 6 PR-1)
|
||||
|
||||
- **Subscriptions**: \`POST /webhooks\` (HMAC secret returned exactly once), GET list + detail, PATCH, DELETE. Per-event-type elevated scope check (\`salary_run.*\` and \`agi.generated\` require \`payroll:read\`).
|
||||
- **Delivery substrate**: per-minute Vercel cron at \`/api/webhooks/dispatch/cron\`. Exponential backoff \`1m / 5m / 30m / 2h / 12h / 24h / 48h\` (7 retries, ~72h total). HTTP 410 from receiver auto-disables the webhook.
|
||||
- **Delivery substrate**: per-minute Vercel cron at \`/api/webhooks/dispatch/cron\`. Due rows are claimed atomically via the \`claim_due_webhook_deliveries\` SQL function (\`FOR UPDATE SKIP LOCKED\`), with \`*.pg.test.ts\` coverage for the claim path and the webhook DB triggers. Exponential backoff \`1m / 5m / 30m / 2h / 12h / 24h / 48h\` (7 retries, ~87h total). HTTP 410 from receiver auto-disables the webhook.
|
||||
- **Signature**: \`X-Gnubok-Signature: t=<unix>,v1=<hex-HMAC-SHA256>\`. Stripe-format. Sample receivers in [Node + Python](/docs/api/webhooks#verifying-signatures).
|
||||
- **SSRF protection**: webhook_url must be HTTPS; resolved IPs in private/loopback/link-local/CGNAT/cloud-metadata ranges are rejected at create AND dispatch time. \`redirect: 'error'\` on every outbound POST.
|
||||
- **Audit + retention**: webhook delivery rows are *behandlingshistorik* per BFNAR 2013:2 kap 8 §: immutable once terminal so the audit trail of what an integration was notified of stays intact. Delivery rows are NOT räkenskapsinformation themselves; the 7-year statutory retention under BFL 7 kap 1 § applies only to the underlying verifikation / faktura / AGI XML in its own table, NOT to the delivery envelope. Accounted keeps accounting-event delivery rows for 7 years as a voluntary operational policy (the duration aligns with BFL 7 kap on the underlying records but is not itself a statutory obligation on delivery rows). Webhook DELETE preserves the delivery audit trail (\`ON DELETE SET NULL\` on \`webhook_id\`).
|
||||
- **SSRF protection**: webhook_url must be HTTPS; resolved IPs in private/loopback/link-local/CGNAT/cloud-metadata ranges are rejected at create AND dispatch time. Dispatch pins the validated IP through a DNS-rebinding-safe \`node:https.request\` agent (\`lib/webhooks/pinned-fetch.ts\`); redirects are refused on every outbound POST (any 3xx is treated as a blocked redirect).
|
||||
- **Audit + retention**: webhook delivery rows are *behandlingshistorik* per BFNAR 2013:2 kap 8 §: immutable once terminal so the audit trail of what an integration was notified of stays intact. Delivery rows are NOT räkenskapsinformation themselves; the 7-year statutory retention under BFL 7 kap 1 § applies only to the underlying verifikation / faktura / AGI XML in its own table, NOT to the delivery envelope. Accounted keeps accounting-event delivery rows for 7 years as a voluntary operational policy (the duration aligns with BFL 7 kap on the underlying records but is not itself a statutory obligation on delivery rows). Webhook DELETE preserves the delivery audit trail (\`ON DELETE SET NULL\` on \`webhook_id\`). Webhook lifecycle events (create / update / delete, plus dispatcher auto-disable) each write a V16 \`audit_log\` entry.
|
||||
- **Verbs**: \`POST /webhooks/{id}/test\` enqueues a synthetic event; \`POST /webhook-deliveries/{id}/retry\` re-enqueues a dead/delivered delivery.
|
||||
|
||||
### Coming soon (Phase 6 PR-2 hardening)
|
||||
|
||||
- 90-day TTL cleanup cron for non-accounting webhook deliveries
|
||||
- Per-route rate limits on \`:test\`, \`:retry\`, and webhook \`:create\`
|
||||
- V16 audit-log entries on webhook lifecycle events
|
||||
- DNS-rebinding pinned-IP HTTPS agent
|
||||
- Integration tests + \`*.pg.test.ts\` for webhook triggers
|
||||
- \`claim_due_webhook_deliveries\` SQL function with \`FOR UPDATE SKIP LOCKED\`
|
||||
- Populated \`previous_attributes\` for update-style webhook events
|
||||
`
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
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.
|
||||
> Compute the Swedish momsdeklaration rutor 05-62 from your posted general-ledger entries 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.
|
||||
This is the operational companion to the [Reports reference](/docs/api/reference/reports). 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
|
||||
|
||||
@@ -12,10 +12,10 @@ This is the operational companion to the [Reports reference](/docs/api/reference
|
||||
|
||||
## 1. Compute the declaration
|
||||
|
||||
\`GET /reports/vat-declaration\` returns rutor 05-62 plus the reconciliation block:
|
||||
\`GET /reports/vat-declaration\` returns all the rutor plus supporting counts and a per-source breakdown. The period is selected with three **required** params — \`period_type\` (monthly|quarterly|yearly), \`year\`, and \`period\` (monthly 1-12, quarterly 1-4, yearly 1) — plus an optional \`accounting_method\` (accrual|cash, default accrual):
|
||||
|
||||
\`\`\`bash
|
||||
curl "https://app.gnubok.se/api/v1/companies/$COMPANY_ID/reports/vat-declaration?period=2026-04" \\
|
||||
curl "https://app.gnubok.se/api/v1/companies/$COMPANY_ID/reports/vat-declaration?period_type=monthly&year=2026&period=4" \\
|
||||
-H "Authorization: Bearer gnubok_sk_test_..."
|
||||
\`\`\`
|
||||
|
||||
@@ -24,75 +24,94 @@ Response (abbreviated):
|
||||
\`\`\`json
|
||||
{
|
||||
"data": {
|
||||
"period": { "year": 2026, "month": 4, "label": "april 2026" },
|
||||
"company": { "org_number": "556677-8899", "vat_registration_no": "SE556677889901" },
|
||||
"period": { "type": "monthly", "year": 2026, "period": 4, "start": "2026-04-01", "end": "2026-04-30" },
|
||||
"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 }
|
||||
"ruta05": 133300.00,
|
||||
"ruta06": 0.00,
|
||||
"ruta10": 31075.00,
|
||||
"ruta11": 720.00,
|
||||
"ruta12": 180.00,
|
||||
"ruta20": 0.00,
|
||||
"ruta21": 20800.00,
|
||||
"ruta22": 0.00,
|
||||
"ruta23": 0.00,
|
||||
"ruta24": 0.00,
|
||||
"ruta30": 5200.00,
|
||||
"ruta31": 0.00,
|
||||
"ruta32": 0.00,
|
||||
"ruta39": 3450.00,
|
||||
"ruta40": 0.00,
|
||||
"ruta48": 17547.00,
|
||||
"ruta50": 0.00,
|
||||
"ruta60": 0.00,
|
||||
"ruta61": 0.00,
|
||||
"ruta62": 0.00,
|
||||
"ruta49": 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": []
|
||||
"invoiceCount": 42,
|
||||
"transactionCount": 15,
|
||||
"breakdown": {
|
||||
"invoices": {
|
||||
"ruta05": 133300.00, "ruta06": 0.00, "ruta07": 0.00,
|
||||
"ruta10": 31075.00, "ruta11": 720.00, "ruta12": 180.00,
|
||||
"ruta39": 3450.00, "ruta40": 0.00,
|
||||
"base25": 124300.00, "base12": 6000.00, "base6": 3000.00
|
||||
},
|
||||
"transactions": { "ruta48": 17547.00 },
|
||||
"receipts": { "ruta48": 0.00 },
|
||||
"reverseCharge": {
|
||||
"ruta20": 0.00, "ruta21": 20800.00, "ruta22": 0.00, "ruta23": 0.00, "ruta24": 0.00,
|
||||
"ruta30": 5200.00, "ruta31": 0.00, "ruta32": 0.00
|
||||
}
|
||||
}
|
||||
},
|
||||
"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å.
|
||||
Ruta 49 = (utgående moms 10+11+12 + omvänd skattskyldighet 30+31+32 + utgående moms vid import 60+61+62) − ingående moms (ruta 48). Positive → moms att betala. Negative → moms att återfå.
|
||||
|
||||
## 2. Reconcile against the GL
|
||||
## 2. Trace the numbers back to the ledger
|
||||
|
||||
The \`reconciliation\` block compares the rutor against the actual general-ledger balances on the moms accounts:
|
||||
The declaration is a **pure projection of the general ledger**: every ruta is the aggregated balance of its BAS accounts over the period. There is no separate reconciliation object in the response — the rutor *are* the ledger balances. The account → ruta mapping (SKV 4700) is:
|
||||
|
||||
- \`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)
|
||||
- \`2611\`: Utgående moms 25% → ruta 10
|
||||
- \`2614\`: Utgående moms vid omvänd skattskyldighet → ruta 30
|
||||
- \`2615\`: Utgående moms vid import → ruta 60
|
||||
- \`2621\`: Utgående moms 12% → ruta 11
|
||||
- \`2631\`: Utgående moms 6% → ruta 12
|
||||
- \`2641\`: Ingående moms → ruta 48
|
||||
- \`2645\`: Ingående moms på utländska förvärv → 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.
|
||||
To sanity-check the figures, use the supporting fields the response already returns:
|
||||
|
||||
- \`invoiceCount\` / \`transactionCount\`: how many posted entries fed the period.
|
||||
- \`breakdown.invoices\`: the sales-side rutor (05/06/07/10/11/12/39/40) plus per-rate bases (\`base25\`/\`base12\`/\`base6\`).
|
||||
- \`breakdown.transactions.ruta48\` / \`breakdown.receipts.ruta48\`: where the input VAT in ruta 48 came from (\`breakdown.transactions.ruta48\` mirrors the full \`rutor.ruta48\`).
|
||||
- \`breakdown.reverseCharge\`: the reverse-charge purchase bases (ruta 20-24) and the self-assessed output VAT (ruta 30-32).
|
||||
|
||||
For a byte-level tie-out against raw account balances, pull the [general ledger report](/docs/api/reference/reports) filtered to the 26xx accounts and compare each account against the mapping above.
|
||||
|
||||
## 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.
|
||||
**Important compliance moment in April 2026.** The VAT rate on livsmedel (groceries) drops from 12% → 6% effective 2026-04-01 (Prop. 2025/26:55). The decisive date under ML (2023:200) is the *tidpunkt för skattskyldighetens inträde*, whose timing rules live in **7 kap. 3–13 §§**. The general rule for goods is the **supply date** (delivery), not the invoice date — **but** a *förskott* (advance payment) received before delivery triggers the VAT point, and pins the rate, at the time of that payment for the amount paid. So a food förskott paid in March for an April delivery is taxed at 12%, even though delivery is after the cutover. Drive the treatment off the taxable event (delivery, or earlier advance payment), not the delivery date alone.
|
||||
|
||||
- **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\`.
|
||||
- **Choose the VAT treatment from the supply date, and record \`delivery_date\` when it differs from \`invoice_date\`.** You set the rate per line via \`vat_treatment\`: \`reduced_6\` books to \`2631\` (6%) and \`reduced_12\` books to \`2621\` (12%). The engine does **not** infer the rate from \`delivery_date\` — it books to the account implied by the treatment you send — so for food supplied ≥ 2026-04-01 send \`reduced_6\`, and \`reduced_12\` for food supplied before the cutover, 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. The supply date (March) governs, so the line must be booked at 12% (\`reduced_12\`) even though the invoice is dated April. Drive the treatment off the supply date — not the invoice date — and record \`delivery_date\` on the invoice for the audit trail. **Do this 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.
|
||||
- If you omit \`delivery_date\`, the invoice carries no explicit supply date and you are implicitly treating the invoice date as the supply date — fine 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 need 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%.
|
||||
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 \`breakdown.invoices\` per-rate bases (\`base12\` / \`base6\`) let you see the split, so you can catch any post-cutover line still booked at 12% before you submit.
|
||||
|
||||
## 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://app.gnubok.se/api/v1/companies/$COMPANY_ID/compliance/check?type=voucher_gaps&period=2026-04" \\
|
||||
curl "https://app.gnubok.se/api/v1/companies/$COMPANY_ID/compliance/check?type=voucher_gaps&fiscal_period_id=$FISCAL_PERIOD_ID" \\
|
||||
-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.
|
||||
The check takes the period's UUID as \`fiscal_period_id\` (not a \`YYYY-MM\` string); get it from \`GET /fiscal-periods\`. Supported \`type\` values are \`voucher_gaps\` and \`year_end_readiness\`. 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
|
||||
|
||||
@@ -104,35 +123,28 @@ curl -X POST "https://app.gnubok.se/api/v1/companies/$COMPANY_ID/fiscal-periods/
|
||||
-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.
|
||||
Locking is reversible until you close the period, but the unlock endpoint is not exposed in v1: reverse a lock from the dashboard. 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://app.gnubok.se/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" }'
|
||||
\`\`\`
|
||||
For audit-trail completeness, capture the submission confirmation number from Skatteverket. v1 does not expose a field for a Skatteverket submission reference on the fiscal period, so record it in your own system of record (or your integration's audit log) alongside the period it belongs to.
|
||||
|
||||
## 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.
|
||||
Sales of services to other EU businesses (\`vat_treatment: 'reverse_charge'\`) book to account 3308 and appear on ruta 39, bypassing the output-moms accounts (no entry on 26xx). The customer accounts for moms in their own country. (Use \`export\` instead — account 3305, ruta 40 — for services sold outside the EU.)
|
||||
|
||||
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.
|
||||
Purchases of services from other EU businesses (supplier_invoice with \`vat_treatment: 'reverse_charge'\`) put the **purchase base** on ruta 21 (tjänster från EU). The engine books a calculated 25% reverse-charge output on account 2614 (→ ruta 30) AND an input-moms entry on 2645 (→ ruta 48): 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 deductible portion of the \`2645\` leg must be restricted at booking time (e.g. by splitting the non-deductible share onto account 2649, blandad verksamhet) — there is no company-level deduction-percent setting that does this for you; otherwise the full input-moms reaches ruta 48 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).
|
||||
Imports from outside EU book through a customs-clearance flow — there is no \`import\` \`vat_treatment\` value; the customs (Tullverket) invoice is what posts the moms, not the supplier invoice itself. The beskattningsunderlag lands on ruta 50 and the calculated import output VAT on rutor 60/61/62 (25/12/6%). 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.
|
||||
- **Don't compute mid-month.** The figures are only meaningful for a complete month; calling with \`period_type=monthly&year=2026&period=4\` 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.
|
||||
- **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 the customer's VAT number against VIES before issuing the invoice — note that VAT-number validation is a dashboard feature and is **not** exposed as a public v1 endpoint, so do the VIES check in your own flow.
|
||||
|
||||
## Next steps
|
||||
|
||||
|
||||
@@ -6,20 +6,19 @@ This is the operational companion to the [Transactions reference](/docs/api/refe
|
||||
|
||||
## 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\`.
|
||||
- A test API key with \`transactions:write\`, \`transactions:read\`, and \`operations:read\` scopes. (There is no \`imports:write\` scope: the bank import runs under \`transactions:write\`, and polling the operation needs \`operations:read\`.)
|
||||
- A bank statement file in one of the supported formats: CSV (SEB / Swedbank / Handelsbanken / Nordea / Nordea Business / Länsförsäkringar / ICA Banken / Skandia / Lunar / Northmill / generic CSV auto-detected), CAMT.053 XML, or a generic 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\`). \`/imports/bank\` resolves the settlement account automatically; \`settlement_account\` is an explicit parameter of \`POST /transactions/ingest\`, not of the file upload.
|
||||
|
||||
## 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.
|
||||
\`POST /imports/bank\` accepts multipart upload. Format detection is automatic; the detected format is reported in the polled operation result (\`result.format\`), not in the upload response. The endpoint kicks off an async operation: you'll poll for the result.
|
||||
|
||||
\`\`\`bash
|
||||
curl "https://app.gnubok.se/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"'
|
||||
-F "file=@statement-2026-04.csv"
|
||||
\`\`\`
|
||||
|
||||
Response is a 202 with the operation handle:
|
||||
@@ -27,9 +26,10 @@ Response is a 202 with the operation handle:
|
||||
\`\`\`json
|
||||
{
|
||||
"data": {
|
||||
"operation_id": "op_a8f1...",
|
||||
"operation_id": "3f2a9c8e-...",
|
||||
"type": "import.bank",
|
||||
"status": "queued",
|
||||
"poll_url": "/api/v1/operations/op_a8f1...",
|
||||
"poll_url": "/api/v1/operations/3f2a9c8e-...",
|
||||
"webhook_event": "operation.completed"
|
||||
},
|
||||
"meta": { "request_id": "req_...", "api_version": "2026-05-12" }
|
||||
@@ -38,7 +38,7 @@ Response is a 202 with the operation handle:
|
||||
|
||||
## 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\`.
|
||||
Polling is currently the way to detect import completion. \`operation.completed\` is named in the response as the intended push signal but is not yet a deliverable webhook event, so poll for now; the categorisation events (\`transaction.categorized\` / \`transaction.reconciled\`) that fire later in this flow are subscribable today ([cookbook](/docs/api/cookbook/webhooks)). The operation lifecycle is \`queued → running → succeeded | failed | cancelled\`.
|
||||
|
||||
\`\`\`bash
|
||||
curl "https://app.gnubok.se/api/v1/operations/$OPERATION_ID" \\
|
||||
@@ -50,16 +50,20 @@ On \`succeeded\`:
|
||||
\`\`\`json
|
||||
{
|
||||
"data": {
|
||||
"operation_id": "op_a8f1...",
|
||||
"operation_id": "3f2a9c8e-...",
|
||||
"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"
|
||||
"format": "seb",
|
||||
"file_hash": "e3b0c44298fc...",
|
||||
"transactions_imported": 165,
|
||||
"transactions_duplicates": 22,
|
||||
"transactions_reconciled": 0,
|
||||
"transactions_auto_categorized": 0,
|
||||
"transactions_errors": 0,
|
||||
"date_from": "2026-04-01",
|
||||
"date_to": "2026-04-30"
|
||||
},
|
||||
"started_at": "2026-05-01T08:00:00Z",
|
||||
"completed_at": "2026-05-01T08:00:04Z"
|
||||
@@ -67,33 +71,37 @@ On \`succeeded\`:
|
||||
}
|
||||
\`\`\`
|
||||
|
||||
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.
|
||||
Note the dedup: each row gets a stable \`external_id\` (composed from date + amount + counterparty) as the primary dedup key, and a secondary content match on \`(date, amount, description)\` against already-booked rows also skips duplicates. 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:
|
||||
After ingest the rows are in \`transactions\` but unbooked (\`journal_entry_id: null\`, \`category: null\`). The list \`status\` filter takes \`booked\` | \`unbooked\` (\`unbooked\` filters on \`journal_entry_id IS NULL\`), and date filtering uses \`date_from\` / \`date_to\` (there is no \`period\` param). List them:
|
||||
|
||||
\`\`\`bash
|
||||
curl "https://app.gnubok.se/api/v1/companies/$COMPANY_ID/transactions?status=uncategorized&period=2026-04&limit=50" \\
|
||||
curl "https://app.gnubok.se/api/v1/companies/$COMPANY_ID/transactions?status=unbooked&date_from=2026-04-01&date_to=2026-04-30&limit=50" \\
|
||||
-H "Authorization: Bearer gnubok_sk_test_..."
|
||||
\`\`\`
|
||||
|
||||
Response (cursor-paginated, oldest-first):
|
||||
Response (cursor-paginated, newest-imported first — ordered by \`created_at\` DESC):
|
||||
|
||||
\`\`\`json
|
||||
{
|
||||
"data": [
|
||||
{
|
||||
"id": "tx_...",
|
||||
"transaction_date": "2026-04-03",
|
||||
"date": "2026-04-03",
|
||||
"description": "SEB CARD - SJ 25-...",
|
||||
"amount": -487.00,
|
||||
"currency": "SEK",
|
||||
"reference": null,
|
||||
"merchant_name": "SJ",
|
||||
"journal_entry_id": null,
|
||||
"invoice_id": null,
|
||||
"supplier_invoice_id": null,
|
||||
"is_business": null,
|
||||
"category": null,
|
||||
"account_number": null,
|
||||
"vat_treatment": null,
|
||||
"document_id": null,
|
||||
"journal_entry_id": null
|
||||
"import_source": "bank_file",
|
||||
"created_at": "2026-05-01T08:00:04Z"
|
||||
},
|
||||
...
|
||||
],
|
||||
@@ -101,43 +109,15 @@ Response (cursor-paginated, oldest-first):
|
||||
}
|
||||
\`\`\`
|
||||
|
||||
## 4. Get category suggestions
|
||||
## 4. Decide the category
|
||||
|
||||
\`POST /transactions/{id}/suggest-categories\` returns ranked guesses based on the description, counterparty history, and your booking-template library:
|
||||
There is no category-suggestion endpoint in the v1 REST API. Ranked suggestions (from the description, counterparty history, and your booking-template library) are surfaced by the dashboard and by the MCP tool \`gnubok_suggest_categories\` — not over REST.
|
||||
|
||||
\`\`\`bash
|
||||
curl -X POST "https://app.gnubok.se/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.
|
||||
In a REST integration you supply the category yourself: choose a \`category\`, or pass an explicit \`account_override\` / \`template_id\` / \`counterparty_template_id\`, based on your own mapping logic. When categorising interactively, use the dry-run in the next step to preview the resolved verifikation before you commit.
|
||||
|
||||
## 5. Commit the categorisation
|
||||
|
||||
\`POST /transactions/{id}/categorize\` stages the booking. Dry-run first to see the verifikation preview:
|
||||
\`POST /transactions/{id}/categorize\` books the transaction (creates the verifikation directly — it is not staged for approval). \`is_business\` is required. Then supply a mapping: a \`template_id\` or \`counterparty_template_id\` (these take precedence), or a \`category\` for the default mapping. \`account_override\` is an optional posting-account override that **combines with \`category\`** — it applies only when neither \`template_id\` nor \`counterparty_template_id\` is present (as in the example below). Dry-run first to preview the resolved mapping:
|
||||
|
||||
\`\`\`bash
|
||||
curl "https://app.gnubok.se/api/v1/companies/$COMPANY_ID/transactions/$TX_ID/categorize?dry_run=true" \\
|
||||
@@ -145,32 +125,38 @@ curl "https://app.gnubok.se/api/v1/companies/$COMPANY_ID/transactions/$TX_ID/cat
|
||||
-H "Idempotency-Key: $(uuidgen)" \\
|
||||
-H "Content-Type: application/json" \\
|
||||
-d '{
|
||||
"is_business": true,
|
||||
"category": "expense_travel",
|
||||
"account_number": "5800",
|
||||
"account_override": "5800",
|
||||
"vat_treatment": "standard_25"
|
||||
}'
|
||||
\`\`\`
|
||||
|
||||
Response includes the would-be journal entry lines:
|
||||
The dry-run returns the resolved mapping (debit / credit accounts + VAT lines) under \`{ dry_run: true, preview }\` — not a full posted journal, and no voucher number is burned:
|
||||
|
||||
\`\`\`json
|
||||
{
|
||||
"data": {
|
||||
"staged_operation_id": "po_...",
|
||||
"dry_run": true,
|
||||
"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 }
|
||||
"category": "expense_travel",
|
||||
"mapping": {
|
||||
"debit_account": "5800",
|
||||
"credit_account": "1930",
|
||||
"vat_lines": [
|
||||
{ "account_number": "2641", "debit_amount": 97.40, "credit_amount": 0, "description": "Ingående moms 25%" }
|
||||
],
|
||||
"all_lines_complete": false
|
||||
},
|
||||
"would_create_journal_entry": true,
|
||||
"already_had_journal_entry": false
|
||||
}
|
||||
}
|
||||
},
|
||||
"meta": { "request_id": "req_...", "api_version": "2026-05-12" }
|
||||
}
|
||||
\`\`\`
|
||||
|
||||
Drop \`?dry_run=true\` and reuse the same \`Idempotency-Key\` to commit. The response carries the audit block with the now-posted voucher number.
|
||||
Drop \`?dry_run=true\` and reuse the same \`Idempotency-Key\` to commit. The commit response returns \`journal_entry_id\` (plus \`journal_entry_created\` and \`category\`); it does not include a voucher number or audit block — fetch the voucher via the journal entry if you need it.
|
||||
|
||||
## 6. Batch categorise
|
||||
|
||||
@@ -183,21 +169,21 @@ curl "https://app.gnubok.se/api/v1/companies/$COMPANY_ID/transactions/batch-cate
|
||||
-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" },
|
||||
{ "transaction_id": "tx_1", "categorization": { "is_business": true, "category": "expense_travel", "account_override": "5800", "vat_treatment": "standard_25" } },
|
||||
{ "transaction_id": "tx_2", "categorization": { "is_business": true, "category": "income_services", "account_override": "3001", "vat_treatment": "standard_25" } },
|
||||
...
|
||||
]
|
||||
}'
|
||||
\`\`\`
|
||||
|
||||
Response shape: every item has its own \`ok\` flag:
|
||||
Response shape: every item carries its own \`ok\` flag and \`transaction_id\`:
|
||||
|
||||
\`\`\`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." } }
|
||||
{ "ok": true, "request_index": 0, "transaction_id": "tx_1", "data": { "journal_entry_created": true, "journal_entry_id": "je_...", "category": "expense_travel" } },
|
||||
{ "ok": false, "request_index": 1, "transaction_id": "tx_2", "error": { "code": "PERIOD_LOCKED", "message": "Period is locked or closed; cannot post journal entry." } }
|
||||
],
|
||||
"summary": { "total": 2, "succeeded": 1, "failed": 1 }
|
||||
}
|
||||
@@ -213,21 +199,21 @@ curl -X POST "https://app.gnubok.se/api/v1/companies/$COMPANY_ID/transactions/$T
|
||||
-H "Authorization: Bearer gnubok_sk_test_..." \\
|
||||
-H "Idempotency-Key: $(uuidgen)" \\
|
||||
-H "Content-Type: application/json" \\
|
||||
-d '{ "invoice_id": "inv_...", "payment_date": "2026-04-15" }'
|
||||
-d '{ "invoice_id": "inv_..." }'
|
||||
\`\`\`
|
||||
|
||||
For supplier-invoice payments use \`POST /transactions/{id}/match-supplier-invoice\`, same shape, different counterparty side.
|
||||
There is no \`payment_date\` field: the payment date is taken from the transaction's own \`date\`. For supplier-invoice payments use \`POST /transactions/{id}/match-supplier-invoice\` — same shape, but the body field is \`supplier_invoice_id\` and the transaction must be negative (expense).
|
||||
|
||||
## 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.
|
||||
Bank statements that include non-SEK transactions are imported with the foreign amount preserved in \`amount\` + \`currency\`; the booked SEK value is stored in \`amount_sek\`. 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.
|
||||
The ledger is SEK-denominated; foreign amounts are converted to SEK automatically from the cached daily Riksbanken snapshot. There is no public FX-rate endpoint — rates are applied by the engine at categorisation time. (For cross-currency invoice settlement where no rate is published for a date, \`match-invoice\` accepts a \`manual_exchange_rate\`.)
|
||||
|
||||
## 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).
|
||||
- **Re-running the same file is safe; date-only overlap is also safe.** Dedup is primarily by a stable \`external_id\` (date + amount + counterparty); a secondary content match on \`(date, amount, description)\` against already-booked rows also skips, so partial overlap of two statements doesn't double-import.
|
||||
- **Settlement account selection matters.** The wrong settlement account silently breaks bank reconciliation later. \`1930\` (företagskonto) is the SEK default; a foreign-currency bank account uses its own asset account (e.g. \`1932\` for USD). \`/imports/bank\` resolves the settlement account automatically — to set it explicitly, ingest via \`POST /transactions/ingest\` with \`settlement_account\`.
|
||||
- **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.
|
||||
|
||||
|
||||
@@ -1,19 +1,19 @@
|
||||
export const QUICKSTART_MD = `# Quickstart: send your first invoice
|
||||
|
||||
> Five minutes from a fresh sandbox to an emailed invoice. Demonstrates the auth, dry-run, idempotency, and audit-block patterns you'll use everywhere.
|
||||
> Five minutes from a fresh sandbox to an emailed invoice. Demonstrates the auth, dry-run, and idempotency patterns you'll use everywhere.
|
||||
|
||||
## What you'll need
|
||||
|
||||
- A test API key (\`gnubok_sk_test_*\`) from the Accounted dashboard at **/settings/api**. Test keys are bound to a deterministic sandbox company seeded with realistic data: safe for evals.
|
||||
- A **live** API key (\`gnubok_sk_*\`, no \`test_\` infix) from the Accounted dashboard at **/settings/api**. Run the whole walkthrough with this one key: the create→send→pay sequence chains on real IDs (the customer's \`id\`, then the invoice's \`id\`) that only committed writes return. A **test** key (\`gnubok_sk_test_*\`) is useful to validate request *shapes* first — it's bound to a deterministic sandbox company and the wrapper forces every write into dry-run — but because a forced dry-run never persists (\`id: null\`, no emails), it can't complete the stateful flow, and its sandbox \`companyId\` differs from your live one. So: **use one key throughout.** If you validate with a test key first, re-run step 1 with your live key before step 3 so \`COMPANY_ID\` matches the company you're actually writing to.
|
||||
- \`curl\` or any HTTP client.
|
||||
|
||||
## 1. List the companies the key can access
|
||||
|
||||
Test keys are scoped to a single sandbox company by default; this call confirms the auth works and returns the \`companyId\` you'll use in the rest of the cookbook.
|
||||
This call confirms your key works and returns the \`companyId\` you'll use throughout. Run it with the **same key you'll use for the committing steps** so the \`companyId\` matches — a live key resolves to the real companies it can access; a test key resolves to its sandbox company.
|
||||
|
||||
\`\`\`bash
|
||||
curl https://app.gnubok.se/api/v1/companies \\
|
||||
-H "Authorization: Bearer gnubok_sk_test_..."
|
||||
-H "Authorization: Bearer gnubok_sk_..."
|
||||
\`\`\`
|
||||
|
||||
Response (truncated):
|
||||
@@ -29,11 +29,11 @@ Save the \`id\` as \`COMPANY_ID\` for the next steps.
|
||||
|
||||
## 2. Create a customer (dry-run first)
|
||||
|
||||
Every write supports \`?dry_run=true\`: the response shows the would-be record without committing. Use it in agent test loops to validate inputs before paying the side-effect cost.
|
||||
Most writes support \`?dry_run=true\`: the response shows the would-be record without committing. (Bank/SIE imports are the exception: they run for real or not at all.) Use it in agent test loops to validate inputs before paying the side-effect cost.
|
||||
|
||||
\`\`\`bash
|
||||
curl "https://app.gnubok.se/api/v1/companies/$COMPANY_ID/customers?dry_run=true" \\
|
||||
-H "Authorization: Bearer gnubok_sk_test_..." \\
|
||||
-H "Authorization: Bearer gnubok_sk_..." \\
|
||||
-H "Idempotency-Key: $(uuidgen)" \\
|
||||
-H "Content-Type: application/json" \\
|
||||
-d '{
|
||||
@@ -45,24 +45,27 @@ curl "https://app.gnubok.se/api/v1/companies/$COMPANY_ID/customers?dry_run=true"
|
||||
}'
|
||||
\`\`\`
|
||||
|
||||
Response (\`X-Dry-Run: true\` header, no row written):
|
||||
Response (\`X-Dry-Run: true\` header, no row written). The would-be record is nested under \`data.preview\`, with \`data.dry_run: true\` so agents can branch on it without reading headers:
|
||||
|
||||
\`\`\`json
|
||||
{
|
||||
"data": {
|
||||
"id": null,
|
||||
"name": "Acme AB",
|
||||
"customer_type": "swedish_business",
|
||||
"vat_number_validated": false,
|
||||
"default_payment_terms": 30,
|
||||
"created_at": null,
|
||||
...
|
||||
"dry_run": true,
|
||||
"preview": {
|
||||
"id": null,
|
||||
"name": "Acme AB",
|
||||
"customer_type": "swedish_business",
|
||||
"vat_number_validated": false,
|
||||
"default_payment_terms": 30,
|
||||
"created_at": null,
|
||||
...
|
||||
}
|
||||
},
|
||||
"meta": { "request_id": "req_...", "api_version": "2026-05-12" }
|
||||
}
|
||||
\`\`\`
|
||||
|
||||
Drop \`?dry_run=true\` to commit. The response now carries a real \`id\` and \`created_at\`.
|
||||
With a **live** key, dropping \`?dry_run=true\` commits the row: the response drops the \`dry_run\`/\`preview\` wrapper and returns the record directly under \`data\` with a real \`id\` and \`created_at\`. Save that committed \`data.id\` as \`CUSTOMER_ID\` for the next step. With a **test** key the write is forced to dry-run either way, so you keep getting this preview (\`id\`/\`created_at\` stay \`null\`) — which is why the create→send→pay steps below need a live key to produce IDs to chain on.
|
||||
|
||||
## 3. Draft an invoice
|
||||
|
||||
@@ -71,88 +74,96 @@ Invoices are typed (B2B, EU-business, individual) and support mixed-rate VAT (pe
|
||||
\`\`\`bash
|
||||
INVOICE_IDEMP=$(uuidgen)
|
||||
curl "https://app.gnubok.se/api/v1/companies/$COMPANY_ID/invoices" \\
|
||||
-H "Authorization: Bearer gnubok_sk_test_..." \\
|
||||
-H "Authorization: Bearer gnubok_sk_..." \\
|
||||
-H "Idempotency-Key: $INVOICE_IDEMP" \\
|
||||
-H "Content-Type: application/json" \\
|
||||
-d '{
|
||||
"customer_id": "'$CUSTOMER_ID'",
|
||||
"invoice_date": "2026-05-15",
|
||||
"due_date": "2026-06-14",
|
||||
"delivery_date": "2026-05-15",
|
||||
"currency": "SEK",
|
||||
"items": [
|
||||
{ "description": "Konsultation, maj 2026", "quantity": 8, "unit_price": 1200, "vat_rate": 25 }
|
||||
{ "description": "Konsultation, maj 2026", "quantity": 8, "unit": "tim", "unit_price": 1200, "vat_rate": 25 }
|
||||
]
|
||||
}'
|
||||
\`\`\`
|
||||
|
||||
Response includes the auto-allocated invoice number, the computed VAT lines, and the audit block (the verifikation hasn't been posted yet: drafts are not yet räkenskapsinformation):
|
||||
A fresh draft has \`invoice_number: null\`: the F-series löpnummer is allocated atomically on the first \`/send\`, not at create time (ML 17 kap 24§ p.2 requires an unbroken series covering only issued invoices, so abandoned drafts must not burn a number). The response carries the computed VAT lines and totals; there is no audit block, because the verifikation hasn't been posted yet (drafts are not yet räkenskapsinformation):
|
||||
|
||||
\`\`\`json
|
||||
{
|
||||
"data": {
|
||||
"id": "...",
|
||||
"invoice_number": "2026-0001",
|
||||
"subtotal": 9600.00,
|
||||
"vat_total": 2400.00,
|
||||
"total": 12000.00,
|
||||
"invoice_number": null,
|
||||
"status": "draft",
|
||||
"subtotal": 9600.00,
|
||||
"vat_amount": 2400.00,
|
||||
"total": 12000.00,
|
||||
"items": [...]
|
||||
},
|
||||
"meta": { "request_id": "req_...", "api_version": "2026-05-12", "audit": {...} }
|
||||
"meta": { "request_id": "req_...", "api_version": "2026-05-12" }
|
||||
}
|
||||
\`\`\`
|
||||
|
||||
(With a test key this call returns the dry-run preview shape instead: \`data: { "dry_run": true, "preview": { ... } }\`. The committed shape above is what a live key returns.)
|
||||
|
||||
## 4. Send it
|
||||
|
||||
\`POST /invoices/{id}/send\` posts the verifikation, generates the PDF, and emails the customer in a single transaction. Strict-mode: if any step fails, none of them commit.
|
||||
\`POST /invoices/{id}/send\` runs a sequential pipeline: preflight PDF render (validates rendering before a number is burned) → allocate the F-series number atomically → final PDF render → email the customer via Resend (PDF attached, copy to the company). Any hard failure up to and including the email rolls nothing forward. Once the email is delivered the pipeline hits a **point of no return**: the status flip to \`sent\`, the journal entry (accrual + real invoices), PDF archival as underlag, and the \`invoice.sent\` event are best-effort, and any of their failures surface as \`warnings\` on the response rather than unwinding the send. It is not a single atomic transaction.
|
||||
|
||||
\`\`\`bash
|
||||
# Generate the key ONCE and reuse it on every retry of this same send, so a
|
||||
# timeout-then-retry replays the original result instead of sending twice.
|
||||
SEND_IDEMP=$(uuidgen)
|
||||
curl -X POST "https://app.gnubok.se/api/v1/companies/$COMPANY_ID/invoices/$INVOICE_ID/send" \\
|
||||
-H "Authorization: Bearer gnubok_sk_test_..." \\
|
||||
-H "Idempotency-Key: $(uuidgen)"
|
||||
-H "Authorization: Bearer gnubok_sk_..." \\
|
||||
-H "Idempotency-Key: $SEND_IDEMP"
|
||||
\`\`\`
|
||||
|
||||
Response carries the now-posted voucher number:
|
||||
Response carries the allocated invoice number and the id of the posted journal entry (there is no \`meta.audit\` block on this endpoint: use \`data.journal_entry_id\` and \`data.invoice_number\` as the posting reference):
|
||||
|
||||
\`\`\`json
|
||||
{
|
||||
"data": {
|
||||
"id": "...",
|
||||
"invoice_number": "2026-0042",
|
||||
"status": "sent",
|
||||
"sent_at": "2026-05-15T12:00:00Z",
|
||||
...
|
||||
"total": 12000.00,
|
||||
"message_id": "re_abc123",
|
||||
"sent_to": "ap@acme.test",
|
||||
"cc": "billing@gnubok-user.test",
|
||||
"journal_entry_id": "7b3a..."
|
||||
},
|
||||
"meta": {
|
||||
"request_id": "req_...",
|
||||
"audit": {
|
||||
"voucher_number": "F-2026-001",
|
||||
"voucher_url": "https://app.gnubok.se/bookkeeping/...",
|
||||
"immutable_at": "2026-05-15T12:00:00Z"
|
||||
}
|
||||
}
|
||||
"meta": { "request_id": "req_...", "api_version": "2026-05-12" }
|
||||
}
|
||||
\`\`\`
|
||||
|
||||
If any post-email step degraded, \`data.warnings\` is present (e.g. \`JOURNAL_ENTRY_NOT_POSTED\`); the invoice is marked \`sent\` regardless.
|
||||
|
||||
## 5. Mark it paid
|
||||
|
||||
When the customer pays, mark the invoice paid. The engine generates the payment voucher (debit 1930 bank, credit 1510 AR) and links it to the invoice.
|
||||
When the customer pays, mark the invoice paid. The engine generates the payment voucher (debit 1930 bank, credit 1510 AR) and links it to the invoice. There is no \`payment_amount\` field: the default books the full remaining amount (\`remaining_amount\`). For a partial payment, pass a custom \`lines\` array (at least two balanced rows).
|
||||
|
||||
\`\`\`bash
|
||||
# One key per logical payment; reuse it across retries of this mark-paid.
|
||||
PAY_IDEMP=$(uuidgen)
|
||||
curl -X POST "https://app.gnubok.se/api/v1/companies/$COMPANY_ID/invoices/$INVOICE_ID/mark-paid" \\
|
||||
-H "Authorization: Bearer gnubok_sk_test_..." \\
|
||||
-H "Idempotency-Key: $(uuidgen)" \\
|
||||
-H "Authorization: Bearer gnubok_sk_..." \\
|
||||
-H "Idempotency-Key: $PAY_IDEMP" \\
|
||||
-H "Content-Type: application/json" \\
|
||||
-d '{ "payment_date": "2026-05-22", "payment_amount": 12000.00 }'
|
||||
-d '{ "payment_date": "2026-05-22" }'
|
||||
\`\`\`
|
||||
|
||||
## What just happened
|
||||
|
||||
You created a customer, drafted an invoice with one mixed-VAT line item, posted the verifikation, sent the PDF, and recorded the payment. Five API calls; the engine handled BAS account selection, voucher numbering, period-lock checks, audit-trail entries, and PDF rendering.
|
||||
This flow creates a customer, drafts a single-line invoice, posts the verifikation on \`/send\`, emails the PDF, and records the payment. That's **five committing calls** — list companies, create customer, create invoice, send, mark-paid — plus the optional \`?dry_run=true\` preview in step 2 if you run it. The engine handles BAS account selection, voucher numbering, period-lock checks, audit-trail entries, and PDF rendering. Run the committing calls with a live key; a test key can validate request shapes via dry-run first but never persists, so it can't chain the real IDs each step needs.
|
||||
|
||||
The rendered PDF that the customer received contains every field required by ML 17 kap 24 § (the Swedish faktura mandate): including \`beskattningsunderlag per skattesats\` (taxable amount per VAT rate; one line per distinct rate on multi-rate invoices), the supplier's organisationsnummer, sequential invoice number, per-line VAT rate, and the supply date. **Pass \`delivery_date\` explicitly** when goods or services are delivered on a different date than the invoice date: ML 17 kap 24 § field 7 requires the supply date and the API does NOT default it to \`invoice_date\`; a faktura with no supply date is non-compliant.
|
||||
|
||||
The "Godkänd för F-skatt" note is a **legal requirement** on every faktura issued by a Swedish momsregistrerad seller that holds F-skatt registration. The buyer uses this note to determine whether they must withhold preliminary tax (A-skatt): omitting it can shift liability onto the buyer and triggers a FATAL Peppol BIS 3.0 validation failure (SE-R-005) on B2G invoices. The requirement applies equally to PDF/paper and Peppol/e-invoice formats; B2G is just where the validation is automated. The PDF includes it automatically when \`company_settings.has_f_skatt\` is true. **The integrator is responsible for keeping \`has_f_skatt\` in sync with the company's live Skatteverket registration status.** Update via \`PATCH /api/v1/companies/{companyId}/settings\` or the settings page: a flag that's false while the company is actually F-skatt-registered produces non-compliant invoices, not merely a missing optional note.
|
||||
The "Godkänd för F-skatt" note is a **legal requirement** on every faktura issued by a Swedish momsregistrerad seller that holds F-skatt registration. The buyer uses this note to determine whether they must withhold preliminary tax (A-skatt): omitting it can shift liability onto the buyer and triggers a FATAL Peppol BIS 3.0 validation failure (SE-R-005) on B2G invoices. The requirement applies equally to PDF/paper and Peppol/e-invoice formats; B2G is just where the validation is automated. The PDF includes it automatically when \`company_settings.has_f_skatt\` is true. **The integrator is responsible for keeping \`has_f_skatt\` in sync with the company's live Skatteverket registration status.** Keep it in sync from the settings page in the dashboard (there is no v1 settings endpoint today): a flag that's false while the company is actually F-skatt-registered produces non-compliant invoices, not merely a missing optional note.
|
||||
|
||||
The summary fields in the JSON response (\`subtotal\`, \`vat_total\`, \`total\`) are convenience aggregates for the integration; the binding faktura content is the PDF itself.
|
||||
The summary fields in the JSON response (\`subtotal\`, \`vat_amount\`, \`total\`) are convenience aggregates for the integration; the binding faktura content is the PDF itself.
|
||||
|
||||
## Next steps
|
||||
|
||||
@@ -163,8 +174,8 @@ The summary fields in the JSON response (\`subtotal\`, \`vat_total\`, \`total\`)
|
||||
|
||||
## Common pitfalls
|
||||
|
||||
- **Idempotency keys must be UUIDs.** Calls with non-UUID keys are rejected with \`VALIDATION_ERROR\`. Generate one per logical action and reuse it across retries of that same action: never on a fresh attempt.
|
||||
- **Test keys can't email real addresses.** \`gnubok_sk_test_*\` short-circuits external providers: \`/send\` returns success but no email goes out. The PDF is still generated and the voucher posted.
|
||||
- **Use one idempotency key per logical action.** The key is treated as an opaque string (UUIDs are recommended, but any unique value works). Generate one per logical action and reuse it across retries of that same action: never on a fresh attempt. Reusing a key with a different request body is rejected with \`IDEMPOTENCY_KEY_REUSE\`.
|
||||
- **Test keys never send or post.** \`gnubok_sk_test_*\` keys are simulation-only: every write is forced to dry-run, so \`/send\` never emails the customer, never allocates an F-series number, and never posts a voucher. On top of that the sandbox company they're bound to blocks outbound email outright (403 \`sandbox_blocked\`). Use a live key on a real company to actually deliver an invoice.
|
||||
- **Period locks block writes.** If you try to invoice into a closed period (\`invoice_date\` falls inside a locked fiscal period), the response is \`PERIOD_LOCKED\` (400). Use \`GET /fiscal-periods\` to check before backdating.
|
||||
- **VIES VAT validation runs on commit only.** Dry-run skips the external VIES call; the real commit will block on slow VIES responses (we time out after 5s, but that's still 5s added to the request). Pre-validate via \`POST /api/v1/vat/validate\` if you want a fast first pass.
|
||||
- **VIES VAT validation runs on commit only.** It only fires when you create an \`eu_business\` customer with a \`vat_number\` (Swedish customers like the one above never trigger it). Dry-run skips the external VIES call; the real commit blocks on slow VIES responses (we time out after 10s, but that's still up to 10s added to the request). There is no v1 pre-validation endpoint today: rely on dry-run to check the rest of the payload first.
|
||||
`
|
||||
|
||||
@@ -1,18 +1,18 @@
|
||||
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.
|
||||
> 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 state transition idempotent and dry-runnable (generate-agi is idempotent but not 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.
|
||||
This is the operational companion to the [Salary-runs reference](/docs/api/reference/salary-runs). Most of the payroll lifecycle is API-callable, but one step is still dashboard-only: **attaching employees to a run** (the \`/salary-runs/{id}/employees\` v1 endpoint is forthcoming), so a run can't yet be populated end-to-end from the public API. Everything after that — calculate, approve, mark paid, book, generate AGI — is.
|
||||
|
||||
## 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).
|
||||
- At least one employee on file with the payroll fields set (\`monthly_salary\` or \`hourly_rate\`, \`tax_table_number\`, \`tax_column\`, \`f_skatt_status\`). \`GET /employees\` masks personnummer to \`ÅÅÅÅMMDDXXXX\`; \`GET /employees/{id}\` returns the full value (deliberate drill-in, GDPR Art.5(1)(c)).
|
||||
- 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).
|
||||
\`POST /salary-runs\` opens an **empty** run in \`draft\` status: it takes only the period + payment metadata (\`period_year\`, \`period_month\`, \`payment_date\`, optional \`voucher_series\` and \`notes\`). Employees are attached in a separate step (via the dashboard, or the forthcoming \`/salary-runs/{id}/employees\` surface) before you \`:calculate\`.
|
||||
|
||||
\`\`\`bash
|
||||
curl "https://app.gnubok.se/api/v1/companies/$COMPANY_ID/salary-runs" \\
|
||||
@@ -23,10 +23,7 @@ curl "https://app.gnubok.se/api/v1/companies/$COMPANY_ID/salary-runs" \\
|
||||
"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 }
|
||||
]
|
||||
"voucher_series": "L"
|
||||
}'
|
||||
\`\`\`
|
||||
|
||||
@@ -35,20 +32,22 @@ Response:
|
||||
\`\`\`json
|
||||
{
|
||||
"data": {
|
||||
"id": "sr_...",
|
||||
"id": "run_...",
|
||||
"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
|
||||
"voucher_series": "L",
|
||||
"total_gross": 0,
|
||||
"total_tax": 0,
|
||||
"total_net": 0,
|
||||
"total_avgifter": 0,
|
||||
"total_employer_cost": 0
|
||||
}
|
||||
}
|
||||
\`\`\`
|
||||
|
||||
Totals are null until you calculate.
|
||||
Totals are 0 until you calculate.
|
||||
|
||||
## 2. Calculate (math + draft → review)
|
||||
|
||||
@@ -65,33 +64,27 @@ Response transitions \`draft → review\`:
|
||||
\`\`\`json
|
||||
{
|
||||
"data": {
|
||||
"id": "sr_...",
|
||||
"id": "run_...",
|
||||
"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,
|
||||
...
|
||||
},
|
||||
...
|
||||
]
|
||||
"period_year": 2026,
|
||||
"period_month": 5,
|
||||
"total_gross": 80000.00,
|
||||
"total_tax": 24300.00,
|
||||
"total_net": 55700.00,
|
||||
"total_avgifter": 25136.00,
|
||||
"total_employer_cost": 105136.00,
|
||||
"warnings": []
|
||||
}
|
||||
}
|
||||
\`\`\`
|
||||
|
||||
The \`warnings\` array surfaces non-blocking issues (Skatteverket tax-table fallback, läkarintyg day-8 / Försäkringskassan day-15 transitions, F-skatt \`not_verified\` employees); the run still advances to \`review\`. Per-employee figures are not returned inline: read them from the run detail or the salary-journal report.
|
||||
|
||||
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).
|
||||
\`POST /salary-runs/{id}/approve\` validates and locks the run for payment. \`PATCH\` on a salary run is **draft-only** (\`SALARY_RUN_PATCH_NOT_DRAFT\`): once a run leaves \`draft\` it can no longer be edited, and there is no \`:unapprove\`. Correcting a booked run is done via the forthcoming \`:correct\` verb (storno-then-rebook).
|
||||
|
||||
\`\`\`bash
|
||||
curl -X POST "https://app.gnubok.se/api/v1/companies/$COMPANY_ID/salary-runs/$SR_ID/approve" \\
|
||||
@@ -99,13 +92,11 @@ curl -X POST "https://app.gnubok.se/api/v1/companies/$COMPANY_ID/salary-runs/$SR
|
||||
-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\`
|
||||
Response shows \`status: 'approved'\` plus a \`warnings\` array. The endpoint validates every employee on the run and returns **all** problems at once (not just the first):
|
||||
- Bank details present: \`clearing_number\` AND \`bank_account_number\` (required for the transfer)
|
||||
- \`calculation_breakdown\` populated (proves \`:calculate\` has run)
|
||||
|
||||
Failures return \`SALARY_RUN_APPROVE_VALIDATION_FAILED\` with a per-employee breakdown in \`details\`.
|
||||
Missing email is a **non-blocking warning** (lönebesked can't be sent automatically) and does not fail approval. Validation failures return \`SALARY_RUN_APPROVE_VALIDATION_FAILED\` with the full \`issues\` list (and any \`warnings\`) in \`details\`. A non-\`review\` run returns \`SALARY_RUN_APPROVE_NOT_REVIEW\`.
|
||||
|
||||
## 4. Mark paid (approved → paid)
|
||||
|
||||
@@ -114,20 +105,19 @@ After the bank transfer settles (or you mark it on the same day for cash-method
|
||||
\`\`\`bash
|
||||
curl -X POST "https://app.gnubok.se/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" }'
|
||||
-H "Idempotency-Key: $(uuidgen)"
|
||||
\`\`\`
|
||||
|
||||
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.
|
||||
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. It is a **bodyless POST**: \`paid_at\` is stamped server-side to the current UTC timestamp (the API does not accept a body-supplied date, to keep the BFL audit trail clean).
|
||||
|
||||
## 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):
|
||||
\`POST /salary-runs/{id}/book\` is the engine-touching step. It posts **2-4 verifikationer** atomically — always the salary and avgifter entries, plus a semesterlöneskuld-accrual entry and/or a löneväxling-pension entry when those apply:
|
||||
|
||||
- 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)
|
||||
- Verifikation 1: Bruttolön: debit → 7210 / 7220 / 7240 (Löner tjänstemän / företagsledare / styrelsearvoden, by \`employment_type\`), credit → 2710 (Personalskatt, avdragen skatt) + 1930 (utbetalning)
|
||||
- Verifikation 2: Arbetsgivaravgifter: debit → 7510 (Lagstadgade sociala avgifter), credit → 2731 (Avräkning sociala avgifter: payable to Skatteverket, cleared when arbetsgivardeklarationen is paid)
|
||||
- Verifikation 3 (if semesterlöneskuld): debit → 7290 (Förändring semesterlöneskuld) + 7519 (sociala avgifter på semester), credit → 2920 (Upplupna semesterlöner) + 2940 (Upplupna sociala avgifter)
|
||||
- Verifikation 4 (if löneväxling): debit → 7410 (Pensionsförsäkringspremier) + 7533 (Särskild löneskatt på pensionskostnader), credit → 2740 (Skuld pensionsförsäkringar) + 2514 (Beräknad särskild löneskatt); pension = löneväxling × 1.058
|
||||
|
||||
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.
|
||||
|
||||
@@ -142,18 +132,23 @@ Response:
|
||||
\`\`\`json
|
||||
{
|
||||
"data": {
|
||||
"id": "sr_...",
|
||||
"id": "run_...",
|
||||
"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" }
|
||||
]
|
||||
"booked_at": "2026-05-26T09:15:00Z",
|
||||
"booked_by": "user_...",
|
||||
"salary_entry_id": "je_salary...",
|
||||
"avgifter_entry_id": "je_avg...",
|
||||
"vacation_entry_id": "je_vac...",
|
||||
"pension_entry_id": null,
|
||||
"entry_ids": ["je_salary...", "je_avg...", "je_vac..."]
|
||||
},
|
||||
"meta": {
|
||||
"request_id": "req_...",
|
||||
"api_version": "2026-05-12",
|
||||
"audit": {
|
||||
"voucher_numbers": ["L-2026-005", "L-2026-006"],
|
||||
"immutable_at": "2026-05-25T16:00:00Z"
|
||||
"voucher_number": "L2026-0023",
|
||||
"voucher_url": "/api/v1/companies/.../journal-entries/je_salary...",
|
||||
"immutable_at": "2026-05-26T09:15:00Z"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -176,27 +171,27 @@ Response:
|
||||
\`\`\`json
|
||||
{
|
||||
"data": {
|
||||
"agi_xml": "<?xml version=\\"1.0\\" encoding=\\"UTF-8\\"?>\\n<Skatteverket ...>",
|
||||
"agi_id": "agi_...",
|
||||
"period": { "year": 2026, "month": 5 },
|
||||
"total_brutto": 80000.00,
|
||||
"total_avgifter": 25136.00,
|
||||
"agi_declaration_id": "agi_...",
|
||||
"period_year": 2026,
|
||||
"period_month": 5,
|
||||
"employee_count": 2,
|
||||
"generated_at": "2026-05-25T16:02:00Z"
|
||||
"is_correction": false,
|
||||
"totals": {
|
||||
"totalTax": 24300.00,
|
||||
"totalAvgifterBasis": 80000.00,
|
||||
"totalAvgifterAmount": 25136.00,
|
||||
"totalSjuklonekostnad": 0,
|
||||
"avgifterByCategory": { "standard": { "basis": 80000.00, "amount": 25136.00 } }
|
||||
},
|
||||
"xml": "<?xml version=\\"1.0\\" encoding=\\"UTF-8\\"?><Skatteverket omrade=\\"Arbetsgivardeklaration\\">…</Skatteverket>",
|
||||
"xml_filename": "AGI_5566778899_202605.xml"
|
||||
}
|
||||
}
|
||||
\`\`\`
|
||||
|
||||
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://app.gnubok.se/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" }'
|
||||
\`\`\`
|
||||
Generating the XML stamps \`agi_generated_at\` on the salary run and emits an \`agi.generated\` event. There is no public endpoint to store a Skatteverket confirmation number back on the run: track the submission reference in your own system.
|
||||
|
||||
## State machine summary
|
||||
|
||||
@@ -208,27 +203,14 @@ Each transition is idempotent on \`Idempotency-Key\`. Retrying a transition that
|
||||
|
||||
## 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.
|
||||
When an employee has bilförmån / fri kost / friskvård, the förmånsvärde is configured on the employee's benefit records: it is **not** passed on the run-creation request (\`POST /salary-runs\` takes only period + payment metadata). When you \`:calculate\`, 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; supply 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.
|
||||
- **\`PATCH\`/\`DELETE\` are draft-only.** Both require \`draft\` status (\`SALARY_RUN_PATCH_NOT_DRAFT\` / \`SALARY_RUN_DELETE_NOT_DRAFT\`). There is no revert-to-draft and no \`:unapprove\`; a booked run is corrected via the forthcoming \`:correct\` verb (storno-then-rebook), not by editing or deleting.
|
||||
- **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.
|
||||
- **F-skatt verification is the integrator's job.** The API trusts \`employee.f_skatt_status\` (\`a_skatt\` | \`f_skatt\` | \`fa_skatt\` | \`not_verified\`) to be in sync with the employee's live Skatteverket registration. A wrong status produces a non-compliant AGI; check the F-skattsedel before payroll runs. \`not_verified\` employees are surfaced as a non-blocking warning on \`:calculate\` (30% skatteavdrag and full avgifter are applied until verified).
|
||||
- **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 derives age from the employee's personnummer (the leading birthdate digits: there is no separate \`birthdate\` field) 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
|
||||
|
||||
@@ -31,7 +31,7 @@ Response:
|
||||
\`\`\`json
|
||||
{
|
||||
"data": {
|
||||
"id": "wh_a8f1...",
|
||||
"id": "a8f1e4c2-3b5d-4e6f-8a90-1b2c3d4e5f60",
|
||||
"name": "CRM sync: invoice paid",
|
||||
"event_type": "invoice.paid",
|
||||
"webhook_url": "https://my-receiver.example.com/gnubok",
|
||||
@@ -44,7 +44,7 @@ Response:
|
||||
}
|
||||
\`\`\`
|
||||
|
||||
> ⚠️ The \`secret\` field is returned only on creation. Subsequent GETs never include it. If you lose it, the recovery path is to delete the webhook and create a new one (which generates a fresh secret); receivers must re-deploy with the new value.
|
||||
> ⚠️ The \`secret\` field is returned only on creation. Subsequent GETs never include it. If you lose it, mint a fresh one with \`POST /api/v1/companies/{companyId}/webhooks/{id}/rotate-secret\`: it re-issues the signing secret in place (returned exactly once, like creation) without deleting the webhook or its delivery history. Rotation invalidates the old secret immediately — there is no grace period — so stage the new value on the receiver first (or \`PATCH active=false\` to pause delivery while you swap), then re-deploy the receiver with the new value.
|
||||
|
||||
**Store the secret in a secrets manager** (AWS Secrets Manager, GCP Secret Manager, HashiCorp Vault, Doppler, 1Password Connect, ...) rather than a plaintext \`.env\` file or a config commit. The secret is signing material: anyone who reads it can forge events that will pass your signature check. Treat it with the same care as a database password.
|
||||
|
||||
@@ -67,7 +67,7 @@ Response:
|
||||
|
||||
\`\`\`json
|
||||
{
|
||||
"data": { "webhook_delivery_id": "wh_dlv_...", "status": "pending" },
|
||||
"data": { "webhook_delivery_id": "f47ac10b-58cc-4372-a567-0e02b2c3d479", "status": "pending" },
|
||||
"meta": { "request_id": "req_...", "api_version": "2026-05-12" }
|
||||
}
|
||||
\`\`\`
|
||||
@@ -77,12 +77,12 @@ Wait up to 60s, then check the receiver logs. The delivery should arrive with:
|
||||
\`\`\`
|
||||
POST /gnubok HTTP/1.1
|
||||
Content-Type: application/json
|
||||
X-Gnubok-Signature: t=1715797800,v1=2f5c...
|
||||
X-Gnubok-Signature: t=1778846400,v1=2f5c...
|
||||
X-Gnubok-Event: webhook.test
|
||||
X-Gnubok-Delivery: wh_dlv_...
|
||||
X-Gnubok-Delivery: f47ac10b-58cc-4372-a567-0e02b2c3d479
|
||||
X-Gnubok-Api-Version: 2026-05-12
|
||||
|
||||
{"id":"wh_dlv_...","type":"webhook.test","api_version":"2026-05-12","created":1715797800,"data":{"object":{"hello":"from Accounted","tested_at":"2026-05-15T12:00:00Z"}},"previous_attributes":null}
|
||||
{"id":"f47ac10b-58cc-4372-a567-0e02b2c3d479","type":"webhook.test","api_version":"2026-05-12","created":1778846400,"data":{"object":{"hello":"from Accounted","tested_at":"2026-05-15T12:00:00Z"}},"previous_attributes":null}
|
||||
\`\`\`
|
||||
|
||||
If your receiver returns 2xx, the delivery moves to \`delivered\`. If it returns 4xx (other than 410) or 5xx, it goes to \`failed\` and retries on the schedule \`1m / 5m / 30m / 2h / 12h / 24h / 48h\`.
|
||||
@@ -99,7 +99,7 @@ Response carries the captured response status and body (truncated to 4 KB), whic
|
||||
\`\`\`json
|
||||
{
|
||||
"data": [{
|
||||
"id": "wh_dlv_...",
|
||||
"id": "f47ac10b-58cc-4372-a567-0e02b2c3d479",
|
||||
"event_type": "webhook.test",
|
||||
"status": "delivered",
|
||||
"attempts": 1,
|
||||
@@ -107,7 +107,7 @@ Response carries the captured response status and body (truncated to 4 KB), whic
|
||||
"response_status": 200,
|
||||
"response_body": "ok",
|
||||
"error": null,
|
||||
"request_id": "whdel_...",
|
||||
"request_id": "req_...",
|
||||
"created_at": "2026-05-15T12:00:00Z",
|
||||
"delivered_at": "2026-05-15T12:00:01Z"
|
||||
}]
|
||||
@@ -123,7 +123,7 @@ curl -X POST "https://app.gnubok.se/api/v1/companies/$COMPANY_ID/invoices/$INVOI
|
||||
-H "Authorization: Bearer gnubok_sk_test_..." \\
|
||||
-H "Idempotency-Key: $(uuidgen)" \\
|
||||
-H "Content-Type: application/json" \\
|
||||
-d '{ "payment_date": "2026-05-22", "payment_amount": 12000.00 }'
|
||||
-d '{ "payment_date": "2026-05-22" }'
|
||||
\`\`\`
|
||||
|
||||
The next dispatcher tick (within 60s) delivers an \`invoice.paid\` event to your receiver carrying the full invoice payload + payment details.
|
||||
@@ -159,7 +159,7 @@ curl -X POST "https://app.gnubok.se/api/v1/webhook-deliveries/$DELIVERY_ID/retry
|
||||
-H "Authorization: Bearer gnubok_sk_test_..."
|
||||
\`\`\`
|
||||
|
||||
The retry creates a fresh delivery row pointing at the same payload: the original audit row stays in place. Receivers see the same \`X-Gnubok-Delivery\` (the new row's id, not the original's), so the idempotency table needs no special handling.
|
||||
The retry creates a fresh delivery row pointing at the same payload: the original audit row stays in place. Receivers see a new \`X-Gnubok-Delivery\` (the new row's id, not the original's), so the idempotency table needs no special handling.
|
||||
|
||||
## Auto-disable
|
||||
|
||||
@@ -183,6 +183,6 @@ This clears \`disabled_at\` and \`disabled_reason\` but does NOT replay the deli
|
||||
|
||||
- **Re-serialising the body.** \`JSON.parse(rawBody); JSON.stringify(parsed)\` produces different bytes than Accounted sent. Always sign-check against the raw bytes.
|
||||
- **Forgetting the timestamp window.** Without a \`t\` check, an attacker who captured one signed payload can replay it forever. 5 minutes is the recommended tolerance.
|
||||
- **Returning 5xx for application errors.** A 5xx triggers full retries (~72h). If a payload is malformed-but-stable, return 200 and queue for internal investigation.
|
||||
- **Returning 5xx for application errors.** A 5xx triggers the full retry schedule (~87h, roughly 3.6 days). If a payload is malformed-but-stable, return 200 and queue for internal investigation.
|
||||
- **Treating \`failed\` as terminal.** \`failed\` rows will retry; only \`delivered\` and \`dead\` are terminal. Don't alert on \`failed\`: alert when retries exhaust to \`dead\`.
|
||||
`
|
||||
|
||||
@@ -1,70 +1,91 @@
|
||||
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).
|
||||
> Run the year-end procedures for a Swedish fiscal year — the engine locks and closes it and sets the 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.
|
||||
- A test API key with \`bookkeeping:write\`, \`bookkeeping:read\`, \`reports:read\`, \`compliance:read\`, \`documents:write\`, and \`operations: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.
|
||||
- All AGI declarations for the year filed. (Employment income reported through monthly AGI is **not** re-reported on an annual kontrolluppgift — KU remains only for the specific payment types AGI doesn't cover, e.g. certain ränta/utdelning; it is not a blanket year-end step.)
|
||||
- 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.
|
||||
Before you run year-end, verify the period's continuity. BFL 5 kap (with the SIE4 invariant) requires that a period's opening balance (IB) equals the previous period's closing balance (UB) on every BAS 1xxx, 2xxx account. The check takes the fiscal-period UUID and compares that period's IB against its predecessor's UB.
|
||||
|
||||
\`\`\`bash
|
||||
curl "https://app.gnubok.se/api/v1/companies/$COMPANY_ID/reports/continuity-check?from=2025-01-01&to=2025-12-31" \\
|
||||
curl "https://app.gnubok.se/api/v1/companies/$COMPANY_ID/reports/continuity-check?period_id=$PERIOD_ID" \\
|
||||
-H "Authorization: Bearer gnubok_sk_test_..."
|
||||
\`\`\`
|
||||
|
||||
Response:
|
||||
Response (a passing check returns \`valid: true\` with an empty \`discrepancies\` array):
|
||||
|
||||
\`\`\`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
|
||||
"valid": true,
|
||||
"period_name": "FY 2025",
|
||||
"previous_period_name": "FY 2024",
|
||||
"discrepancies": [],
|
||||
"checked_accounts": 42
|
||||
}
|
||||
}
|
||||
\`\`\`
|
||||
|
||||
\`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.
|
||||
When a mismatch exists \`valid\` is \`false\` and each offending account appears in \`discrepancies\`:
|
||||
|
||||
\`\`\`json
|
||||
{
|
||||
"account_number": "1930",
|
||||
"account_name": "Företagskonto",
|
||||
"previous_ub_net": 156432.00,
|
||||
"current_ib_net": 156430.00,
|
||||
"difference": 2.00
|
||||
}
|
||||
\`\`\`
|
||||
|
||||
\`valid: false\` is a BFL violation: the previous period's closing balance (UB) was not carried into this period as the opening balance (IB). This is always a carry-forward problem — a missing/incorrect opening-balance entry, or a post-close mutation of the prior period — not a missed bank reconciliation or a VAT-vs-GL disagreement. Investigate the opening-balance entry for the flagged account before proceeding.
|
||||
|
||||
## 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://app.gnubok.se/api/v1/companies/$COMPANY_ID/compliance/check?type=voucher_gaps&period=2025" \\
|
||||
curl "https://app.gnubok.se/api/v1/companies/$COMPANY_ID/compliance/check?type=voucher_gaps&fiscal_period_id=$PERIOD_ID" \\
|
||||
-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.
|
||||
The check returns a \`findings\` array; each unexplained gap is a \`blocker\` (code \`VOUCHER_GAP_UNEXPLAINED\`). For every gap, 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:
|
||||
For aktiebolag, BFL 7 kap requires every verifikation to have its underlag (receipt, faktura, kontrakt) attached. The v1 \`compliance/check\` endpoint does **not** gate on documents — its only supported types are \`year_end_readiness\` and \`voucher_gaps\` — so surface missing underlag through the MCP tools instead:
|
||||
|
||||
\`\`\`bash
|
||||
curl "https://app.gnubok.se/api/v1/companies/$COMPANY_ID/compliance/check?type=unmatched_documents&period=2025" \\
|
||||
-H "Authorization: Bearer gnubok_sk_test_..."
|
||||
\`\`\`
|
||||
gnubok_list_verifikat_without_documents
|
||||
gnubok_list_transactions_without_documents
|
||||
\`\`\`
|
||||
|
||||
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).
|
||||
Attach an already-uploaded document to its verifikation via \`POST /documents/{id}/link\`, passing the target entry in the body:
|
||||
|
||||
## 4. Lock the period
|
||||
\`\`\`bash
|
||||
curl -X POST "https://app.gnubok.se/api/v1/companies/$COMPANY_ID/documents/$DOCUMENT_ID/link" \\
|
||||
-H "Authorization: Bearer gnubok_sk_test_..." \\
|
||||
-H "Idempotency-Key: $(uuidgen)" \\
|
||||
-H "Content-Type: application/json" \\
|
||||
-d '{ "journal_entry_id": "a8f1c2d3-..." }'
|
||||
\`\`\`
|
||||
|
||||
\`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.
|
||||
The first link to a verifikation is allowed even after the period is locked (it completes the audit trail rather than modifying the entry). Re-linking a document that is already attached to a *posted* entry is refused with \`CONFLICT\` (\`document_already_linked_to_posted_entry\`) per BFL 5 kap 5 §.
|
||||
|
||||
## 4. Locking is handled by year-end (do NOT pre-lock)
|
||||
|
||||
The year-end run in step 5 locks the fiscal year itself as part of its flow. **Do not call \`/lock\` on the year you are about to close first**: \`executeYearEndClosing\` locks the period internally, and a period that is already locked makes the run fail with \`Period is already locked\`.
|
||||
|
||||
The standalone \`POST /fiscal-periods/{id}/lock\` endpoint (synchronous, returns \`200\` with the updated period) is for *interim* locking, e.g. freezing a finished month you are not year-end-closing. It refuses if uncategorised affärstransaktioner remain in the period (\`PERIOD_HAS_UNBOOKED_TRANSACTIONS\`).
|
||||
|
||||
\`\`\`bash
|
||||
curl -X POST "https://app.gnubok.se/api/v1/companies/$COMPANY_ID/fiscal-periods/$PERIOD_ID/lock" \\
|
||||
@@ -72,102 +93,108 @@ curl -X POST "https://app.gnubok.se/api/v1/companies/$COMPANY_ID/fiscal-periods/
|
||||
-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.
|
||||
Unlocking a locked period is not exposed in v1 (dashboard only). A closed period can never be unlocked: closing is irreversible per BFL 5 kap 8 §.
|
||||
|
||||
## 5. Run year-end procedures
|
||||
## 5. Run year-end
|
||||
|
||||
\`POST /fiscal-periods/{id}/year-end\` is the engine-touching step. It:
|
||||
\`POST /fiscal-periods/{id}/year-end\` is the single engine-touching step. It takes **no request body** (only the mandatory \`Idempotency-Key\` header); the next fiscal period is resolved automatically — an existing next period is reused, otherwise one is created. In order, \`executeYearEndClosing\`:
|
||||
|
||||
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.
|
||||
1. Re-validates readiness (no drafts, no unexplained voucher gaps, trial balance balanced). Preview it first with \`GET /compliance/check?type=year_end_readiness&fiscal_period_id=$PERIOD_ID\`.
|
||||
2. Runs currency revaluation, posting FX gains/losses to \`3960\`/\`7960\` **before** the closing entry so they get swept into the result.
|
||||
3. Posts one closing entry (\`source_type=year_end\`, series A, "Årsbokslut …"): it zeros every result account in BAS class 3-8 and books the net result **directly** onto \`2099\` "Årets resultat" (aktiebolag) — or \`2010\` "Eget kapital" for enskild firma. There is no \`8910\` intermediate step.
|
||||
4. Locks, then closes the period (irreversible per BFL 5 kap 8 §).
|
||||
5. Generates the opening-balance verifikat in the next period: every BAS class 1-2 account's UB becomes its IB, as a single balanced entry.
|
||||
6. Re-validates IB/UB continuity (rolling back on failure), then — for aktiebolag — omför föregående års resultat from \`2099\` to \`2098\` in the new period as a separate verifikat.
|
||||
|
||||
This is an async operation:
|
||||
The engine does **not** compute or post income tax (bolagsskatt), periodiseringsfond, or överavskrivningar. Book any bokslutsdispositioner and the tax entry yourself as ordinary verifikationer **before** running year-end — the tax entry debits \`8910\` against \`2512\`; a periodiseringsfond avsättning debits \`8811\` against an obeskattad reserv in \`21xx\` — so the closing entry sweeps their class-8 legs into the result.
|
||||
|
||||
This is an async operation. It takes no body:
|
||||
|
||||
\`\`\`bash
|
||||
curl -X POST "https://app.gnubok.se/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
|
||||
}
|
||||
}'
|
||||
-H "Idempotency-Key: $(uuidgen)"
|
||||
\`\`\`
|
||||
|
||||
Response is a 202 with an operation handle (year-end can take minutes for large books):
|
||||
Response is a \`202\` with an operation handle (operation ids are UUIDs):
|
||||
|
||||
\`\`\`json
|
||||
{
|
||||
"data": {
|
||||
"operation_id": "op_...",
|
||||
"operation_id": "0e9c1f2a-8b3d-4f6a-9c21-1a2b3c4d5e6f",
|
||||
"type": "fiscal_periods.year_end",
|
||||
"status": "queued",
|
||||
"poll_url": "/api/v1/operations/op_...",
|
||||
"poll_url": "/api/v1/operations/0e9c1f2a-8b3d-4f6a-9c21-1a2b3c4d5e6f",
|
||||
"webhook_event": "operation.completed"
|
||||
}
|
||||
}
|
||||
\`\`\`
|
||||
|
||||
Poll the operation; on \`succeeded\` the result block lists every voucher posted:
|
||||
Poll \`GET /operations/{id}\`; on \`succeeded\` the \`result\` block carries the ids of the entries the engine posted and the next period it opened:
|
||||
|
||||
\`\`\`json
|
||||
{
|
||||
"data": {
|
||||
"operation_id": "op_...",
|
||||
"operation_id": "0e9c1f2a-8b3d-4f6a-9c21-1a2b3c4d5e6f",
|
||||
"type": "fiscal_periods.year_end",
|
||||
"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
|
||||
}
|
||||
"closing_entry_id": "a8f1c2d3-...",
|
||||
"revaluation_entry_id": "b7e2d3c4-...",
|
||||
"opening_balance_entry_id": "c6d3e4f5-...",
|
||||
"next_period_id": "d5c4b3a2-..."
|
||||
},
|
||||
"error": null,
|
||||
"poll_url": "/api/v1/operations/0e9c1f2a-8b3d-4f6a-9c21-1a2b3c4d5e6f",
|
||||
"webhook_event": "operation.completed"
|
||||
}
|
||||
}
|
||||
\`\`\`
|
||||
|
||||
(\`revaluation_entry_id\` is \`null\` when there were no open foreign-currency items to revalue.)
|
||||
|
||||
## 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:
|
||||
After year-end runs, the new period (\`next_period_id\` from the operation result) has IB on every balance-sheet account matching the prior period's UB. Pass that period's UUID as \`period_id\`:
|
||||
|
||||
\`\`\`bash
|
||||
curl "https://app.gnubok.se/api/v1/companies/$COMPANY_ID/reports/trial-balance?period=2026" \\
|
||||
curl "https://app.gnubok.se/api/v1/companies/$COMPANY_ID/reports/trial-balance?period_id=$NEXT_PERIOD_ID" \\
|
||||
-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\`.
|
||||
The \`opening_balance\` column on every 1xxx/2xxx row should equal the \`closing_balance\` on the same row for the closed year. \`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.
|
||||
You normally never touch \`POST /fiscal-periods/{id}/opening-balances\`: year-end already generates the IB, and this endpoint returns \`CONFLICT\` (\`opening_balance_already_posted\`) once an IB entry exists in the target period. It exists only to generate the IB in isolation — its body is \`{ "next_period_id": "<uuid>" }\` and it computes balances from the closed period's UB (no explicit values are accepted). To correct a wrong IB, post a storno/manual entry instead.
|
||||
|
||||
## 7. Close the period (irreversible)
|
||||
## 7. The period is already closed (\`/close\` is recovery-only)
|
||||
|
||||
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.
|
||||
Step 5 already locked **and** closed the period — \`executeYearEndClosing\` runs \`closePeriod\` as its final bookkeeping step. **BFL 5 kap 8 §: closing is irreversible.** No code path can re-open a closed period, so in the normal flow you never call \`/close\` yourself.
|
||||
|
||||
\`POST /fiscal-periods/{id}/close\` exists only as a recovery path: it finalizes a year-end that posted its closing entry and locked the period but did not reach the close step (a rare mid-flow failure). It reads **no body** — only the mandatory \`Idempotency-Key\` header:
|
||||
|
||||
\`\`\`bash
|
||||
curl -X POST "https://app.gnubok.se/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" }'
|
||||
-H "Idempotency-Key: $(uuidgen)"
|
||||
\`\`\`
|
||||
|
||||
The \`confirmation_phrase\` is a forced typed acknowledgment. The request fails with \`VALIDATION_ERROR\` unless the literal phrase matches.
|
||||
It returns \`CONFLICT\` (\`already_closed\`) if the period is already closed, \`PERIOD_NOT_LOCKED\` if it was never locked, and \`CONFLICT\` (\`year_end_not_executed\`) if no closing entry exists yet.
|
||||
|
||||
## 8. Generate the årsredovisning (aktiebolag only)
|
||||
## 8. Assemble 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.
|
||||
For an AB, the annual report (årsredovisning) is filed with Bolagsverket within 7 months of the fiscal-year end. A single \`/reports/annual-report\` endpoint is **not yet exposed in v1**: assemble the K2/K3 source figures from the report endpoints that do exist, then typeset and submit via Bolagsverket Mina Sidor.
|
||||
|
||||
\`\`\`bash
|
||||
curl "https://app.gnubok.se/api/v1/companies/$COMPANY_ID/reports/annual-report?year=2025" \\
|
||||
# Resultaträkning
|
||||
curl "https://app.gnubok.se/api/v1/companies/$COMPANY_ID/reports/income-statement?period_id=$PERIOD_ID" \\
|
||||
-H "Authorization: Bearer gnubok_sk_test_..."
|
||||
|
||||
# Balansräkning
|
||||
curl "https://app.gnubok.se/api/v1/companies/$COMPANY_ID/reports/balance-sheet?period_id=$PERIOD_ID" \\
|
||||
-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.
|
||||
The resultaträkning and balansräkning come from those two reports; the noter and förvaltningsberättelse are assembled from the GL on your side. The signing flow (every styrelseledamot must sign) is outside the API surface.
|
||||
|
||||
## 9. Generate INK2 / NE for the tax declaration
|
||||
|
||||
@@ -185,7 +212,7 @@ curl "https://app.gnubok.se/api/v1/companies/$COMPANY_ID/reports/ne-bilaga?year=
|
||||
|
||||
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.
|
||||
> **Note:** \`/reports/ink2\` and \`/reports/ne-bilaga\` are not part of the v1 report surface yet: see the API changelog for current availability. Until they ship, generate the inputs via \`/reports/trial-balance?period_id=<uuid>\` and feed your tax-software of choice.
|
||||
|
||||
## Brutet räkenskapsår (off-calendar year)
|
||||
|
||||
@@ -196,14 +223,13 @@ The one exception: the VAT declaration cadence is monthly/kvartalsvis/årlig reg
|
||||
## 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.
|
||||
- **Periodiseringsfond is your entry, not the engine's.** \`/year-end\` does not accept a set-aside amount and does not post the reserve; it only revalues currency, posts the closing entry (class 3-8 → \`2099\`), and generates the IB. If you set aside a periodiseringsfond, book it yourself as an obeskattad reserv (\`21xx\`, against \`8811\`) **before** running year-end. For sizing it: per IL 30 kap 5 § and 30 kap 6 a §, an 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 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%**. Under BFL/BFNAR 2016:10 (materiellt samband for AB), the periodiseringsfond is BOOKED as an obeskattad reserv, not just declared on INK2.
|
||||
- **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. (Unlocking is dashboard-only; it is not in v1.)
|
||||
- **Year-end is async.** Don't block your request loop on it: poll \`GET /operations/{id}\` with reasonable backoff (every 5-10s). If you prefer webhooks, subscribe to \`period.year_closed\` — that is the event the engine emits on completion. (\`operation.completed\`, shown in the operation envelope, is the polling contract, not a separately deliverable subscription event.)
|
||||
|
||||
## 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.
|
||||
- **[Payroll cookbook](/docs/api/cookbook/run-payroll-and-agi)**: the monthly AGI cycle, plus any exception-based kontrolluppgifter (the KU types AGI doesn't cover) filed in January of year N+1.
|
||||
- **[Fiscal-periods reference](/docs/api/reference/fiscal-periods)**: every parameter, every state transition.
|
||||
`
|
||||
|
||||
@@ -73,7 +73,7 @@ export function buildErrorReferenceMd(): string {
|
||||
const lines: string[] = []
|
||||
lines.push('# Errors')
|
||||
lines.push('')
|
||||
lines.push(`> Every error returned by the Accounted REST API uses a stable code from this catalogue. Codes never change once shipped: agents can pattern-match on them safely. The \`docs_url\` field on every error envelope points at the anchor for that specific code.`)
|
||||
lines.push(`> Every error returned by the Accounted REST API uses a stable code from this catalogue. Codes never change once shipped: agents can pattern-match on them safely. The \`docs_url\` field on every error envelope points at the catalogue entry for that specific code.`)
|
||||
lines.push('')
|
||||
lines.push('## Envelope shape')
|
||||
lines.push('')
|
||||
@@ -83,18 +83,15 @@ export function buildErrorReferenceMd(): string {
|
||||
lines.push(' "code": "PERIOD_LOCKED",')
|
||||
lines.push(' "message": "Den valda perioden är låst.",')
|
||||
lines.push(' "message_en": "The selected period is locked.",')
|
||||
lines.push(' "remediation": {')
|
||||
lines.push(' "description": "Unlock via /fiscal-periods/{id}/unlock or pick an open period.",')
|
||||
lines.push(' "tool": "fiscal_periods.unlock"')
|
||||
lines.push(' },')
|
||||
lines.push(' "details": { "fiscal_period_id": "..." },')
|
||||
lines.push(' "docs_url": "https://app.gnubok.se/docs/api/errors#period_locked"')
|
||||
lines.push(' },')
|
||||
lines.push(' "meta": { "request_id": "req_...", "api_version": "..." }')
|
||||
lines.push(' "recovery_hint": "Unlock via /fiscal-periods/{id}/unlock or pick an open period.",')
|
||||
lines.push(' "docs_url": "https://app.gnubok.se/docs/api/errors/PERIOD_LOCKED",')
|
||||
lines.push(' "request_id": "req_..."')
|
||||
lines.push(' }')
|
||||
lines.push('}')
|
||||
lines.push('```')
|
||||
lines.push('')
|
||||
lines.push(`The \`message\` field is Swedish (matches the dashboard); \`message_en\` is English (for agent and developer logs); \`remediation\` (when present) hints at the canonical fix and may include a \`tool\` reference into the MCP surface.`)
|
||||
lines.push(`The \`message\` field is Swedish (matches the dashboard); \`message_en\` is English (for agent and developer logs). \`recovery_hint\` (when present) is a one-line description of the canonical fix. \`request_id\` echoes the request for support and log correlation — note it lives inside \`error\` on failures (there is no top-level \`meta\` block on an error response, unlike the success envelope). \`docs_url\` is built from the code (\`…/docs/api/errors/<CODE>\`).`)
|
||||
lines.push('')
|
||||
|
||||
for (const label of orderedLabels) {
|
||||
|
||||
@@ -4,7 +4,7 @@ export const LANDING_MD = `# accounted API
|
||||
|
||||
> Swedish double-entry bookkeeping as a public REST API for agents and integrations. API version \`${API_V1_VERSION}\`.
|
||||
|
||||
The accounted API lets you do anything the dashboard can do: create invoices, ingest bank transactions, file VAT declarations, run payroll, and subscribe to webhooks for state changes. Every endpoint is designed for autonomous agents first: machine-readable schemas, dry-run previews, idempotent retries, and inline audit blocks on every write.
|
||||
The accounted API covers the financial workflows an integration needs: create invoices, ingest bank transactions, file VAT declarations, run payroll, and subscribe to webhooks for state changes. Every endpoint is designed for autonomous agents first: machine-readable schemas, dry-run previews, idempotent retries, and inline audit blocks on every write. (A few dashboard-only steps remain — e.g. attaching employees to a salary run — and each cookbook calls out where one applies.)
|
||||
|
||||
If you've used [Stripe's API](https://docs.stripe.com/api), the shape will feel familiar: bearer-token auth, dated API versions, webhook signature verification, idempotency keys. The accounting concepts are Swedish (BAS chart, BFL retention, K2/K3, momsdeklaration) but the surface is built for the same kind of integrator.
|
||||
|
||||
@@ -24,7 +24,7 @@ Create keys in the accounted dashboard at **/settings/api**. Two key prefixes ar
|
||||
|
||||
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.
|
||||
|
||||
Rate limit: 100 requests per minute per key, returned in \`X-RateLimit-*\` headers.
|
||||
Rate limit: 100 requests per minute per key.
|
||||
|
||||
## Base URL
|
||||
|
||||
@@ -74,7 +74,7 @@ Every response has the same shape:
|
||||
}
|
||||
\`\`\`
|
||||
|
||||
Errors swap \`data\` for \`error\`:
|
||||
Errors return an \`error\` object instead of \`data\` (and carry no \`meta\` block — the \`request_id\` moves inside \`error\`):
|
||||
|
||||
\`\`\`json
|
||||
{
|
||||
@@ -82,11 +82,11 @@ Errors swap \`data\` for \`error\`:
|
||||
"code": "PERIOD_LOCKED",
|
||||
"message": "Den valda perioden är låst.",
|
||||
"message_en": "The selected period is locked.",
|
||||
"remediation": { "description": "Unlock via /fiscal-periods/{id}/unlock or pick an open period.", "tool": "fiscal_periods.unlock" },
|
||||
"details": { "fiscal_period_id": "..." },
|
||||
"docs_url": "https://app.gnubok.se/docs/api/errors#period_locked"
|
||||
},
|
||||
"meta": { "request_id": "req_...", "api_version": "${API_V1_VERSION}" }
|
||||
"recovery_hint": "Unlock via /fiscal-periods/{id}/unlock or pick an open period.",
|
||||
"docs_url": "https://app.gnubok.se/docs/api/errors/PERIOD_LOCKED",
|
||||
"request_id": "req_..."
|
||||
}
|
||||
}
|
||||
\`\`\`
|
||||
|
||||
|
||||
@@ -35,6 +35,7 @@ const RESOURCES: ResourceGroup[] = [
|
||||
{ slug: 'companies', label: 'Companies', description: 'List and read companies the API key can access.', matcher: (p) => /\/companies(?:\/:companyId)?$/.test(p) },
|
||||
{ slug: 'customers', label: 'Customers', description: 'CRM-side: who you invoice. Business and individual (sole-trader) customers with VIES validation.', matcher: (p) => /\/customers(\/|$)/.test(p) },
|
||||
{ slug: 'invoices', label: 'Invoices', description: 'Outbound invoicing: draft, send, mark paid, credit, PDF download. Mixed-rate VAT supported.', matcher: (p) => /\/invoices(\/|$)/.test(p) },
|
||||
{ slug: 'articles', label: 'Articles', description: 'Read the article/product catalogue used to build invoice line items.', matcher: (p) => /\/articles(\/|$)/.test(p) },
|
||||
{ slug: 'suppliers', label: 'Suppliers', description: 'AP-side counterparties. Mirrors customers on the supplier vertical.', matcher: (p) => /\/suppliers(\/|$)/.test(p) },
|
||||
{ slug: 'supplier-invoices', label: 'Supplier invoices', description: 'AP lifecycle: register, approve, mark paid, credit. With ROT/RUT and reverse-charge support.', matcher: (p) => /\/supplier-invoices(\/|$)/.test(p) },
|
||||
{ slug: 'transactions', label: 'Transactions', description: 'Bank transactions: ingest, categorise, match to invoices, reconcile.', matcher: (p) => /\/transactions(\/|$)/.test(p) },
|
||||
@@ -44,10 +45,12 @@ const RESOURCES: ResourceGroup[] = [
|
||||
{ slug: 'fiscal-periods', label: 'Fiscal periods', description: 'Period lifecycle: lock, close, year-end, opening balances, FX revaluation. Async via the operations substrate.', matcher: (p) => /\/fiscal-periods(\/|$)/.test(p) },
|
||||
{ slug: 'accounts', label: 'Accounts', description: 'Read the chart of accounts (BAS).', matcher: (p) => /\/accounts(\/|$)/.test(p) },
|
||||
{ slug: 'documents', label: 'Documents', description: 'Multipart upload, signed-URL download (15-min TTL), link to journal entries.', matcher: (p) => /\/documents(\/|$)/.test(p) },
|
||||
{ slug: 'inbox-items', label: 'Inbox items', description: 'Stamp incoming documents in the inbox to turn them into supplier invoices or transactions.', matcher: (p) => /\/inbox-items(\/|$)/.test(p) },
|
||||
{ slug: 'dimensions', label: 'Dimensions', description: 'Cost-centre / project dimensions and their values for tagging journal lines.', matcher: (p) => /\/dimensions(\/|$)/.test(p) },
|
||||
{ slug: 'employees', label: 'Employees', description: 'Payroll roster: CRUD with personnummer masking on list endpoints.', matcher: (p) => /\/employees(\/|$)/.test(p) },
|
||||
{ slug: 'salary-runs', label: 'Salary runs', description: 'Payroll lifecycle: create, calculate, approve, mark paid, book, generate AGI XML.', matcher: (p) => /\/salary-runs(\/|$)/.test(p) },
|
||||
{ slug: 'reports', label: 'Reports', description: 'Read-only reports: trial balance, P&L, balance sheet, GL, VAT, salary journal, SIE export, +9 more.', matcher: (p) => /\/reports(\/|$)/.test(p) },
|
||||
{ slug: 'imports', label: 'Imports', description: 'Bulk async ingest: SIE files (Fortnox/Visma/BL/SpeedLedger/Bokio migrations) and bank statements (11 formats).', matcher: (p) => /\/imports(\/|$)/.test(p) },
|
||||
{ slug: 'imports', label: 'Imports', description: 'Bulk async ingest: SIE files (Fortnox/Visma/BL/SpeedLedger/Bokio migrations) and bank statements (12 formats).', matcher: (p) => /\/imports(\/|$)/.test(p) },
|
||||
{ slug: 'compliance', label: 'Compliance check', description: 'Pre-flight verification: voucher gaps, year-end readiness, before submitting to Skatteverket.', matcher: (p) => /\/compliance(\/|$)/.test(p) },
|
||||
{ slug: 'webhooks', label: 'Webhooks', description: 'Subscribe to events with HMAC-signed delivery, exponential retries, and dead-letter replay.', matcher: (p) => /\/webhooks|\/webhook-deliveries/.test(p) },
|
||||
{ slug: 'operations', label: 'Operations', description: 'Poll long-running async operations (year-end closing, imports, currency revaluation).', matcher: (p) => /\/operations(\/|$)/.test(p) },
|
||||
|
||||
@@ -64,18 +64,18 @@ What does NOT count as a breaking change:
|
||||
|
||||
## Idempotency
|
||||
|
||||
Every state-changing endpoint (POST, PATCH, DELETE) accepts an \`Idempotency-Key\` header. The key is a UUID you generate; the server caches the response keyed by \`(api_key_id, company_id, idempotency_key, request_body_hash)\` for 24 hours.
|
||||
Every state-changing endpoint (POST, PATCH, DELETE) accepts an \`Idempotency-Key\` header. The key is treated as an opaque string (a UUID is recommended, but any unique value works); the server caches the response keyed by \`(user_id, company_id, idempotency_key)\` for 24 hours. The canonical hash of the request body is stored alongside and compared on replay: same key + same body returns the cached response, same key + different body returns \`409 IDEMPOTENCY_KEY_REUSE\`.
|
||||
|
||||
### How it works
|
||||
|
||||
- **First call with a fresh key** → executes normally; response is cached.
|
||||
- **Replay with the same key + same body** → returns the cached response with \`Idempotent-Replayed: true\` header. The original side effects are NOT re-executed.
|
||||
- **Replay with the same key + different body** → returns \`409 IDEMPOTENCY_KEY_REUSE\`. This indicates the key was reused incorrectly.
|
||||
- **Two concurrent requests with the same key** → one wins, the other waits for the cached response.
|
||||
- **Two concurrent requests with the same key** → the cache lookup takes no lock, so both may execute; the response write is best-effort (the first writer wins, the loser hits the unique-index conflict and skips). Idempotency de-duplicates *sequential* retries reliably; serialise concurrent retries of the same action on your side.
|
||||
|
||||
### Required vs supported
|
||||
|
||||
Some endpoints **require** an Idempotency-Key (the create routes for resources that would be expensive to deduplicate after the fact: invoices, customers, supplier-invoices, webhooks). Calls without the header return \`400 VALIDATION_ERROR\` with field \`Idempotency-Key\`.
|
||||
Most write endpoints **require** an Idempotency-Key — not only the obvious creates (invoices, customers, supplier-invoices, webhooks) but also journal-entry and salary-run creation, invoice \`send\`, period \`lock\`/\`close\`/\`year-end\`, bank reconciliation runs, dimension-value creation, PATCH/DELETE on customers, and more. Each endpoint's reference page states whether the key is required. Calls that omit a required key return \`400 VALIDATION_ERROR\` with field \`Idempotency-Key\`.
|
||||
|
||||
Other endpoints **support** but don't require it. Sending one is always safe.
|
||||
|
||||
@@ -95,11 +95,24 @@ In an agent loop, generate the key once at the *start* of an attempt and reuse i
|
||||
|
||||
## Dry-run
|
||||
|
||||
Every state-changing endpoint that supports dry-run (\`x-dry-run-supported: true\` in the OpenAPI spec) accepts \`?dry_run=true\` query param **or** \`X-Dry-Run: true\` header. The endpoint executes its full validation pipeline (Zod, business rules, period-lock checks, VAT-rate compatibility, cross-tenant guards, ...) but does NOT commit. The response shape matches a successful commit:
|
||||
Every state-changing endpoint that supports dry-run (\`x-dry-run-supported: true\` in the OpenAPI spec) accepts \`?dry_run=true\` query param **or** \`X-Dry-Run: true\` header. The endpoint executes its full validation pipeline (Zod, business rules, period-lock checks, VAT-rate compatibility, cross-tenant guards, ...) but does NOT commit. A dry-run always returns **HTTP 200** with the \`X-Dry-Run: true\` response header — never the resource's normal success status (201, 204). The body wraps a preview, not the resource itself:
|
||||
|
||||
- All \`validation_error\` shapes that a real commit would produce surface here.
|
||||
- The response \`data\` shows the would-be record with \`id: null\`, timestamps \`null\`, and any auto-generated values (voucher number, invoice number) shown as \`null\` or as the value that *would* have been allocated.
|
||||
- The response stamps \`X-Dry-Run: true\` in headers.
|
||||
- All **local** \`validation_error\` shapes that a real commit would produce surface here (Zod, business rules, period locks, cross-tenant guards). Failures that depend on external providers do **not** surface — dry-run skips them (see below), so a VIES/BankID/Skatteverket rejection only appears on the real commit.
|
||||
- \`data.preview\` holds the would-be record (same shape as the success response).
|
||||
- For **financial** writes (invoices, journal entries, period ops, salary) the preview also carries \`staged_operation_id\`, the \`journal_lines\` that would be posted, \`account_deltas\`, and \`voucher_number_assigned_on_commit\` (a projection — the committed number can differ by one or two if another writer takes the next number first).
|
||||
|
||||
\`\`\`json
|
||||
{
|
||||
"data": {
|
||||
"dry_run": true,
|
||||
"preview": { ... },
|
||||
"staged_operation_id": "po_...",
|
||||
"journal_lines": [{ "account": "1510", "debit": 12500, "credit": 0 }],
|
||||
"voucher_number_assigned_on_commit": "A-2026-043"
|
||||
},
|
||||
"meta": { "request_id": "req_...", "api_version": "${API_V1_VERSION}" }
|
||||
}
|
||||
\`\`\`
|
||||
|
||||
Use dry-run to:
|
||||
|
||||
@@ -118,9 +131,9 @@ A v1 mutation either commits fully or returns a structured error code with no si
|
||||
When a multi-step write fails:
|
||||
|
||||
- **Pre-engine failure** (validation, missing FK, period locked) → no rows written, structured error returned.
|
||||
- **Post-engine failure** (engine call succeeded, follow-up step failed) → the engine's writes are reversed via \`reverseEntry()\` (storno), the failure surfaces with code matching the failed step (e.g. \`MATCH_INVOICE_TX_LINK_FAILED\`).
|
||||
- **Post-engine failure** (engine call succeeded, follow-up step failed) → the engine's writes are reversed via \`reverseEntry()\` (storno), the failure surfaces with code matching the failed step (e.g. \`MATCH_INVOICE_LINK_TX_FAILED\`).
|
||||
|
||||
Storno reversals are themselves immutable journal entries: the original audit trail remains visible per BFL 5 kap 5 §. \`reversal_journal_entry_id\` on the original row points at the storno.
|
||||
Storno reversals are themselves immutable journal entries: the original audit trail remains visible per BFL 5 kap 5 §. \`reversed_by_id\` on the original row points at the storno; the storno carries \`reverses_id\` back to the original.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
export const WEBHOOKS_MD = `# Webhooks
|
||||
|
||||
> Receive HMAC-signed POST notifications when state changes in Accounted: invoices paid, journal entries committed, periods locked, salary runs booked, AGI files generated. At-least-once delivery with exponential backoff over ~72 hours.
|
||||
> Receive HMAC-signed POST notifications when state changes in Accounted: invoices paid, journal entries committed, periods locked, salary runs booked, AGI files generated. At-least-once delivery with exponential backoff over ~87 hours (about 3.6 days).
|
||||
|
||||
If you've used [Stripe webhooks](https://docs.stripe.com/webhooks), the model is identical: subscribe a URL to an event type, Accounted POSTs each event with a signed JSON body, your receiver returns 2xx to acknowledge. The signature header format and retry policy are the same. The event types are gnubok-specific.
|
||||
|
||||
## Lifecycle
|
||||
|
||||
1. **Register a receiver** with [\`POST /api/v1/companies/{companyId}/webhooks\`](/docs/api/reference/webhooks#post-webhooks). The response includes an HMAC signing secret returned **exactly once**: store it on the receiver side immediately. If you lose it, delete the webhook and create a new one.
|
||||
1. **Register a receiver** with [\`POST /api/v1/companies/{companyId}/webhooks\`](/docs/api/reference/webhooks#post-webhooks-create). The response includes an HMAC signing secret returned **exactly once**: store it on the receiver side immediately. If you lose it, rotate it with [\`POST /api/v1/companies/{companyId}/webhooks/{webhookId}/rotate-secret\`](/docs/api/reference/webhooks#post-webhooks-rotate_secret): a fresh secret is issued in place and the old one is invalidated immediately, with no change to the webhook's id or delivery history.
|
||||
2. **Accounted emits events** internally (e.g. an invoice is marked paid via the dashboard or another API call). The webhook handler enqueues a delivery row.
|
||||
3. **The dispatcher cron runs every minute**, signs the payload with HMAC-SHA256, and POSTs to your URL with a 10-second timeout.
|
||||
4. **Your receiver verifies the signature**, processes the event idempotently, and returns 2xx.
|
||||
5. **Failed deliveries retry** at \`1m / 5m / 30m / 2h / 12h / 24h / 48h\` (7 retries, ~72 hours total). After all attempts the delivery is marked \`dead\`. HTTP 410 from your receiver short-circuits to \`dead\` immediately and **auto-disables** the webhook.
|
||||
5. **Failed deliveries retry** at \`1m / 5m / 30m / 2h / 12h / 24h / 48h\` (7 retries, ~87 hours total, about 3.6 days). After all attempts the delivery is marked \`dead\`. HTTP 410 from your receiver short-circuits to \`dead\` immediately and **auto-disables** the webhook.
|
||||
|
||||
## Event types
|
||||
|
||||
@@ -62,10 +62,10 @@ Every delivery wraps the event in a Stripe-style envelope:
|
||||
|
||||
\`\`\`json
|
||||
{
|
||||
"id": "wh_dlv_a8f1...",
|
||||
"id": "d290f1ee-6c54-4b01-90e6-d701748f0851",
|
||||
"type": "invoice.paid",
|
||||
"api_version": "2026-05-12",
|
||||
"created": 1715797800,
|
||||
"created": 1778846400,
|
||||
"data": {
|
||||
"object": {
|
||||
"invoice": { "id": "...", "invoice_number": "2026-0042", "total": 12500.00, ... },
|
||||
@@ -78,9 +78,9 @@ Every delivery wraps the event in a Stripe-style envelope:
|
||||
}
|
||||
\`\`\`
|
||||
|
||||
- \`id\` matches the \`webhook_delivery_id\` you can poll at [\`GET /webhooks/{webhookId}/deliveries\`](/docs/api/reference/webhooks#get-deliveries).
|
||||
- \`id\` matches the \`webhook_delivery_id\` you can poll at [\`GET /api/v1/companies/{companyId}/webhooks/{webhookId}/deliveries\`](/docs/api/reference/webhooks#get-webhooks-deliveries-list).
|
||||
- \`api_version\` is the version pinned to your webhook at creation time. Payload shapes for *your* webhook will not change until you explicitly upgrade.
|
||||
- \`previous_attributes\` carries the prior values of any fields that changed on update-style events (e.g. \`invoice.paid\` carries the prior invoice state). \`null\` for create-style events.
|
||||
- \`previous_attributes\` is reserved for a future field-diff feature and is currently always \`null\` for every event type.
|
||||
|
||||
## Request headers
|
||||
|
||||
@@ -90,11 +90,11 @@ Every outbound POST carries:
|
||||
POST /your-receiver-url HTTP/1.1
|
||||
Content-Type: application/json
|
||||
User-Agent: gnubok-webhook/1
|
||||
X-Gnubok-Signature: t=1715797800,v1=2f5c...
|
||||
X-Gnubok-Signature: t=1778846400,v1=2f5c...
|
||||
X-Gnubok-Event: invoice.paid
|
||||
X-Gnubok-Delivery: wh_dlv_a8f1...
|
||||
X-Gnubok-Delivery: d290f1ee-6c54-4b01-90e6-d701748f0851
|
||||
X-Gnubok-Api-Version: 2026-05-12
|
||||
X-Request-Id: whdel_a8f1...
|
||||
X-Request-Id: whdel_d290f1ee-6c54-4b01-90e6-d701748f0851
|
||||
\`\`\`
|
||||
|
||||
The \`X-Gnubok-Delivery\` header is the canonical correlation id: log it on receipt and use it to deduplicate retries (deliveries are at-least-once, so the same delivery id may arrive more than once after a network blip).
|
||||
@@ -210,16 +210,16 @@ def verify_signature(body: bytes, header: str, secret: bytes) -> bool:
|
||||
- **Using parsed JSON instead of raw bytes.** Re-serialising the body (\`JSON.stringify(req.body)\`) produces different bytes than Accounted sent: the signature won't match. Capture the raw body before any framework parses it.
|
||||
- **Forgetting the timestamp window.** Without checking \`t\`, an attacker who captured one signed payload can replay it forever. 5 minutes is our recommended window; tighten if your clock skew is small.
|
||||
- **Treating retries as duplicates of failure.** Retries arrive when *we* didn't get a 2xx. A 200 response that arrives slowly may not reach us in time and we'll retry: your receiver sees the same \`X-Gnubok-Delivery\` twice. Idempotency is on you.
|
||||
- **Returning 5xx for application errors.** A 5xx triggers the full retry policy (~72h of attempts). If your handler hit an application bug that won't resolve on retry, return 200 and queue the failure for internal investigation; only return 5xx for genuinely transient problems.
|
||||
- **Returning 5xx for application errors.** A 5xx triggers the full retry policy (~87h of attempts). If your handler hit an application bug that won't resolve on retry, return 200 and queue the failure for internal investigation; only return 5xx for genuinely transient problems.
|
||||
- **Missing \`redirect: 'error'\`-style refusal at receiver level.** If your receiver follows redirects, an attacker who can MITM the response could redirect re-tries to a malicious URL. Modern HTTP clients refuse redirects by default for POST; verify yours does.
|
||||
|
||||
## Delivery debugging
|
||||
|
||||
Use [\`GET /api/v1/companies/{companyId}/webhooks/{webhookId}/deliveries\`](/docs/api/reference/webhooks#get-deliveries) to list the recent delivery history for a webhook: every row has the response status, response body (truncated to 4 KB, only \`text/plain\` and \`application/json\` content types persisted), error message, and current state (\`pending\` / \`in_flight\` / \`delivered\` / \`failed\` / \`dead\`).
|
||||
Use [\`GET /api/v1/companies/{companyId}/webhooks/{webhookId}/deliveries\`](/docs/api/reference/webhooks#get-webhooks-deliveries-list) to list the recent delivery history for a webhook: every row has the response status, response body (truncated to 4 KB, only \`text/plain\` and \`application/json\` content types persisted), error message, and current state (\`pending\` / \`in_flight\` / \`delivered\` / \`failed\` / \`dead\`).
|
||||
|
||||
To replay a \`dead\` or \`delivered\` delivery, call [\`POST /api/v1/webhook-deliveries/{deliveryId}/retry\`](/docs/api/reference/webhooks#post-retry). The retry creates a fresh delivery row pointing at the same payload: the original audit row stays in place. Receivers must be idempotent on the \`X-Gnubok-Delivery\` header.
|
||||
To replay a \`dead\` or \`delivered\` delivery, call [\`POST /api/v1/webhook-deliveries/{deliveryId}/retry\`](/docs/api/reference/webhooks#post-webhook_deliveries-retry). The retry creates a fresh delivery row pointing at the same payload: the original audit row stays in place. Receivers must be idempotent on the \`X-Gnubok-Delivery\` header.
|
||||
|
||||
To send a synthetic test event without driving real state, call [\`POST /webhooks/{webhookId}/test\`](/docs/api/reference/webhooks#post-test). The dispatcher delivers a \`webhook.test\` event with a static payload on the next per-minute tick.
|
||||
To send a synthetic test event without driving real state, call [\`POST /api/v1/companies/{companyId}/webhooks/{webhookId}/test\`](/docs/api/reference/webhooks#post-webhooks-test). The dispatcher delivers a \`webhook.test\` event with a static payload on the next per-minute tick.
|
||||
|
||||
## Auto-disable behaviour
|
||||
|
||||
@@ -229,7 +229,7 @@ The dispatcher disables a webhook (sets \`active=false\` + \`disabled_reason\`)
|
||||
- The receiver returns **HTTP 3xx redirect**: refusing to follow redirects to internal IPs is a security policy; a stable receiver should not return 3xx
|
||||
- The webhook URL **resolves to a private/loopback/link-local/cloud-metadata IP** at dispatch time (DNS rebinding refusal)
|
||||
|
||||
Re-enable with [\`PATCH /webhooks/{webhookId}\`](/docs/api/reference/webhooks#patch-webhooks) setting \`active: true\`. This clears \`disabled_at\` + \`disabled_reason\` but does NOT replay the deliveries that died while disabled: replay them individually with the retry endpoint.
|
||||
Re-enable with [\`PATCH /api/v1/companies/{companyId}/webhooks/{webhookId}\`](/docs/api/reference/webhooks#patch-webhooks-update) setting \`active: true\`. This clears \`disabled_at\` + \`disabled_reason\` but does NOT replay the deliveries that died while disabled: replay them individually with the retry endpoint.
|
||||
|
||||
## Audit + retention
|
||||
|
||||
|
||||
@@ -57,6 +57,7 @@ export const DOCS_NAV: DocsNavSection[] = [
|
||||
{ label: 'Companies', href: '/docs/api/reference/companies' },
|
||||
{ label: 'Customers', href: '/docs/api/reference/customers' },
|
||||
{ label: 'Invoices', href: '/docs/api/reference/invoices' },
|
||||
{ label: 'Articles', href: '/docs/api/reference/articles' },
|
||||
{ label: 'Suppliers', href: '/docs/api/reference/suppliers' },
|
||||
{ label: 'Supplier invoices', href: '/docs/api/reference/supplier-invoices' },
|
||||
{ label: 'Transactions', href: '/docs/api/reference/transactions' },
|
||||
@@ -64,6 +65,8 @@ export const DOCS_NAV: DocsNavSection[] = [
|
||||
{ label: 'Fiscal periods', href: '/docs/api/reference/fiscal-periods' },
|
||||
{ label: 'Accounts', href: '/docs/api/reference/accounts' },
|
||||
{ label: 'Documents', href: '/docs/api/reference/documents' },
|
||||
{ label: 'Inbox items', href: '/docs/api/reference/inbox-items' },
|
||||
{ label: 'Dimensions', href: '/docs/api/reference/dimensions' },
|
||||
{ label: 'Employees', href: '/docs/api/reference/employees' },
|
||||
{ label: 'Salary runs', href: '/docs/api/reference/salary-runs' },
|
||||
{ label: 'Reports', href: '/docs/api/reference/reports' },
|
||||
|
||||
@@ -28,7 +28,7 @@ import { createLogger } from '@/lib/logger'
|
||||
|
||||
const log = createLogger('webhooks/dispatcher')
|
||||
|
||||
/** 7 retries over ~72h. Index = attempts BEFORE this one. */
|
||||
/** 7 retries over ~87h (≈3.6 days). Index = attempts BEFORE this one. */
|
||||
const RETRY_BACKOFF_SECONDS: ReadonlyArray<number> = [
|
||||
60, // 1m: first retry
|
||||
5 * 60, // 5m
|
||||
|
||||
@@ -28,6 +28,18 @@ if (!buildErrorReferenceMd || !buildResourcePages || !buildReferenceOverviewMd)
|
||||
|
||||
const WEBSITE = resolve('/Users/jakobwennberg/gnubok-website')
|
||||
|
||||
/**
|
||||
* The website is served from www.accounted.se while the API (and everything
|
||||
* under /api/v1 and /.well-known) lives on app.gnubok.se — root-relative links
|
||||
* to app-served resources would 404 on the website, so absolutise them.
|
||||
*/
|
||||
const APP_ORIGIN = 'https://app.gnubok.se'
|
||||
function adaptForWebsite(md: string): string {
|
||||
return md
|
||||
.replaceAll('](/api/v1/', `](${APP_ORIGIN}/api/v1/`)
|
||||
.replaceAll('](/.well-known/', `](${APP_ORIGIN}/.well-known/`)
|
||||
}
|
||||
|
||||
function write(rel: string, content: string) {
|
||||
const out = resolve(WEBSITE, rel)
|
||||
mkdirSync(dirname(out), { recursive: true })
|
||||
@@ -35,13 +47,13 @@ function write(rel: string, content: string) {
|
||||
console.log(`wrote ${out} (${content.length} chars)`)
|
||||
}
|
||||
|
||||
const errorsMd = buildErrorReferenceMd()
|
||||
const errorsMd = adaptForWebsite(buildErrorReferenceMd())
|
||||
write(
|
||||
'lib/docs/content/errors.generated.ts',
|
||||
`// AUTO-GENERATED from erp-base: do not hand-edit.\n// Regenerate via \`npx tsx scripts/export-docs-to-website.mts\` in erp-base.\nexport const ERRORS_MD = ${JSON.stringify(errorsMd)}\n`,
|
||||
)
|
||||
|
||||
const refOverview = buildReferenceOverviewMd()
|
||||
const refOverview = adaptForWebsite(buildReferenceOverviewMd())
|
||||
const refPages = buildResourcePages()
|
||||
const slugs = refPages.map((p: { slug: string }) => p.slug)
|
||||
|
||||
@@ -49,7 +61,7 @@ const pagesPayload = refPages.map((p: { slug: string; label: string; description
|
||||
slug: p.slug,
|
||||
label: p.label,
|
||||
description: p.description,
|
||||
markdown: p.markdown,
|
||||
markdown: adaptForWebsite(p.markdown),
|
||||
}))
|
||||
|
||||
write(
|
||||
@@ -59,7 +71,7 @@ write(
|
||||
)} as const\n\nexport const RESOURCE_PAGES: ResourcePage[] = ${JSON.stringify(pagesPayload, null, 2)}\n\nexport function findResourcePage(slug: string): ResourcePage | undefined {\n return RESOURCE_PAGES.find((p) => p.slug === slug)\n}\n`,
|
||||
)
|
||||
|
||||
const connectClaudeMd = connectClaude.CONNECT_CLAUDE_MD
|
||||
const connectClaudeMd = connectClaude.CONNECT_CLAUDE_MD && adaptForWebsite(connectClaude.CONNECT_CLAUDE_MD)
|
||||
if (!connectClaudeMd) {
|
||||
console.error('Missing CONNECT_CLAUDE_MD export. Inspect:', { connectClaudeKeys: Object.keys(connectClaude) })
|
||||
process.exit(1)
|
||||
|
||||
Reference in New Issue
Block a user