Accounted rebrand + swarm-skill cleanup + bank-reconciliation fixes (#643)

* feat(reconciliation): close the bank-feed loop on voucher links and re-tag mis-typed opening balances

Two related fixes to bank reconciliation correctness:

1. Auto-reconcile on voucher link. Linking an invoice or supplier invoice to
   an existing voucher previously advanced only the invoice — the bank
   transaction that paid it kept sitting in the Transactions inbox with a null
   journal_entry_id. linkInvoiceToVoucher / linkSupplierInvoiceToVoucher now
   call autoReconcileTransactionForLinkedVoucher (lib/reconciliation), which
   links the bank transaction to the same verifikat when exactly one unbooked
   line matches it. Best-effort and post-commit: a failure here never fails the
   link. The result surfaces reconciledTransactionId; the inbox row leaves the
   list and the UI shows link_success_tx_reconciled.

2. Re-tag mis-typed opening balances. getReconciliationStatus and the GL-line
   matching RPCs identify a cash account's ingående balans solely by
   journal_entries.source_type='opening_balance'. Companies migrated from other
   systems often booked the bank IB as an ordinary voucher (source_type
   'import' or 'manual'), so it was never excluded and surfaced as a phantom
   reconciliation difference equal to the opening balance. Adds:
   - migration mark_entry_as_opening_balance: a GUC-gated carve-out in the
     immutability trigger plus a SECURITY DEFINER RPC that validates the entry
     (balance-sheet lines only, dated on a fiscal-period boundary), flips the
     source_type, and writes an audit row — no blanket data sweep.
   - POST /api/reconciliation/bank/mark-opening-balance + MarkOpeningBalanceSchema.
   - BankReconciliationView action to trigger it from the IB diff.

The gnubok_create_voucher executor now accepts a typed is_opening_balance flag
and derives source_type='opening_balance' only after validating class 1/2 lines
on the period start, so new IBs land correctly typed.

Covered by lib/reconciliation auto-reconcile tests, voucher-executors tests,
and a mark-entry-as-opening-balance pg-real test.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* chore: rebrand gnubok → Accounted and prune swarm agent skills

Product rebrand and skills housekeeping. No runtime behaviour change.

Rebrand: replace user-visible "gnubok" with "Accounted" across docs, READMEs,
in-code comments, doc-site content, MCP skill/resource prose, and the
gnubok-mcp package description. The MCP resource URI scheme is moved gnubok://
→ Accounted:// consistently across resource registrations, the event-type
comment, and the resource/skill tests. Deliberately preserved as stable
identifiers (NOT rebranded): the gnubok-company-id cookie, gnubok_sk_ / gnubok_inv_
token prefixes, the gnubok-mcp npm bridge name, and the AGI <gem:Programnamn>
value (kept 'gnubok' per its source comment — it is the software identifier sent
to Skatteverket and must not churn across visual rebrands).

Skills: remove the 27 swarm-* agent SKILL.md atoms (no longer used; already
absent from the agent_atom_registry in prod), refresh the remaining skill docs,
add the .claude/rules/ path-scoped rule set, and regenerate the
seed_agent_atom_bodies migration + .skill-body-manifest.json via
`npm run skills:generate` so the DB-backed skill bodies match the trimmed set.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-06-03 10:52:01 +02:00
committed by GitHub
co-authored by Claude Opus 4.8
parent 331ae11867
commit c74b19df1b
183 changed files with 19621 additions and 4175 deletions
+2 -2
View File
@@ -272,14 +272,14 @@ export function buildIdentityBlock(args: BuildArgs): string {
// MCP catalog, and then claims access to tools that aren't in this
// intent's whitelist. The tools-parameter the model receives via the
// Anthropic API is the canonical source of truth — anything outside it
// is reachable from *other* gnubok surfaces, not from here.
// is reachable from *other* Accounted surfaces, not from here.
lines.push('# Verktyg')
lines.push('')
lines.push('Verktygen du kan anropa just nu är EXAKT de som ligger i din tools-parameter — varken fler eller färre. Om du har sett andra verktygsnamn via gnubok_search_tools eller gnubok_list_skills så finns de i systemet, men de är inte anropbara från denna ingång. Påstå aldrig att du har ett verktyg som inte ligger i tools-parametern.')
lines.push('')
lines.push('När användaren frågar "vad kan du?" / "vilka verktyg har du?": svara i förmågor (vad du faktiskt kan hjälpa till med här), inte i API-namn. Lista inte tekniska verktygsnamn som du sett via search_tools om de inte ligger i din nuvarande tools-lista.')
lines.push('')
lines.push('När en uppgift kräver ett verktyg du inte har: hänvisa användaren till rätt vy i gnubok där motsvarande knapp har rätt verktyg inkopplat (t.ex. en transaktionsrad, /invoices/new, /bookkeeping/year-end). Säg vart de ska gå — försök inte fejka åtgärden.')
lines.push('När en uppgift kräver ett verktyg du inte har: hänvisa användaren till rätt vy i Accounted där motsvarande knapp har rätt verktyg inkopplat (t.ex. en transaktionsrad, /invoices/new, /bookkeeping/year-end). Säg vart de ska gå — försök inte fejka åtgärden.')
lines.push('')
lines.push('När du HAR rätt verktyg — använd dem. Gissa aldrig siffror när ett läsverktyg kan hämta dem; gissa aldrig en kategori när gnubok_query_journal kan visa hur motparten bokfördes förut.')
lines.push('')
+1 -1
View File
@@ -214,7 +214,7 @@ function buildUserPrompt(inputs: ComposerInputs): string {
const lines: string[] = []
lines.push(`# Företag`)
lines.push(`Namn: ${inputs.companyName}`)
lines.push(`Juridisk form (gnubok): ${inputs.entityType}`)
lines.push(`Juridisk form (Accounted): ${inputs.entityType}`)
lines.push('')
const known = buildKnownFacts(inputs)
+2 -2
View File
@@ -84,7 +84,7 @@ export async function loadCompanySettings(
// ownership in the user's mouth.
//
// We match the user's enrichment row against this company's org_number.
// `companyId` is the gnubok UUID — we need to read the orgnr from
// `companyId` is the Accounted UUID — we need to read the orgnr from
// `companies` to do the match. Cheap (single SELECT each) and only runs once
// per agent build.
//
@@ -108,7 +108,7 @@ export async function loadUserDirectorship(
companyId: string,
): Promise<{ confirmedDirector: boolean }> {
// Read this company's org_number — the BankID CompanyRoles row keys on
// companyRegistrationNumber, not the gnubok company UUID.
// companyRegistrationNumber, not the Accounted company UUID.
const { data: companyRow } = await supabase
.from('companies')
.select('org_number')
+10
View File
@@ -975,6 +975,16 @@ export const BankUnlinkSchema = z.object({
transaction_id: uuid,
})
/**
* Re-tag a mis-typed bank-account opening balance (a manual/import voucher that
* is really an ingående balans) as source_type='opening_balance' so bank
* reconciliation excludes it from the period movement. Routed to the
* mark_entry_as_opening_balance SECURITY DEFINER RPC, which enforces the rest.
*/
export const MarkOpeningBalanceSchema = z.object({
journal_entry_id: uuid,
})
export const RunReconciliationSchema = z.object({
date_from: isoDate.optional(),
date_to: isoDate.optional(),
+1 -1
View File
@@ -27,7 +27,7 @@ import '@/app/api/v1/companies/[companyId]/journal-entries/[id]/correct/route'
import '@/app/api/v1/companies/[companyId]/journal-entries/batch-create/route'
import '@/app/api/v1/companies/[companyId]/voucher-gap-explanations/route'
// Phase 4 PR-2 — compliance-check (gnubok's defensible edge).
// Phase 4 PR-2 — compliance-check (Accounted's defensible edge).
import '@/app/api/v1/companies/[companyId]/compliance/check/route'
// Phase 4 PR-2 — fiscal-periods async ops (lock/close/year-end/opening-balances/currency-revaluation).
+2 -2
View File
@@ -297,10 +297,10 @@ export function generateOpenApiSpec(serverUrl: string): OpenApiSpec {
return {
openapi: '3.1.0',
info: {
title: 'gnubok API',
title: 'Accounted API',
version: API_V1_VERSION,
description:
'Public REST API for gnubok — Swedish double-entry bookkeeping. ' +
'Public REST API for Accounted — Swedish double-entry bookkeeping. ' +
'Every write supports dry-run via `?dry_run=true`. Every request must include ' +
'`Authorization: Bearer gnubok_sk_...`. See /docs/api for the cookbook.',
},
+1 -1
View File
@@ -30,7 +30,7 @@ export const PUBLIC_SECURITY_HEADERS: Record<string, string> = {
// form contexts. `default-src 'none'` is the strictest possible CSP and
// costs nothing here.
'Content-Security-Policy': "default-src 'none'; frame-ancestors 'none'",
// HSTS — every gnubok deployment is HTTPS-only. 1 year is the standard
// HSTS — every Accounted deployment is HTTPS-only. 1 year is the standard
// production value; includeSubDomains because the apex serves everything.
'Strict-Transport-Security': 'max-age=31536000; includeSubDomains',
}
+1 -1
View File
@@ -103,7 +103,7 @@ export const V1_ENDPOINT_SCOPES: Record<string, ApiKeyScope> = {
'POST /api/v1/companies/:companyId/fiscal-periods/:id/year-end': 'bookkeeping:write',
'POST /api/v1/companies/:companyId/fiscal-periods/:id/opening-balances': 'bookkeeping:write',
'POST /api/v1/companies/:companyId/fiscal-periods/:id/currency-revaluation': 'bookkeeping:write',
// Compliance check (gnubok's defensible edge).
// Compliance check (Accounted's defensible edge).
'GET /api/v1/companies/:companyId/compliance/check': 'compliance:read',
// Phase 4 PR-3 — Documents (multipart).
'POST /api/v1/companies/:companyId/documents': 'documents:write',
+2 -2
View File
@@ -43,7 +43,7 @@ describe('branding service', () => {
const b = getBranding()
expect(b.appName).toBe('Accounted')
expect(b.appDescription).toBe('Ekonomihantering')
expect(b.legalEntity).toBe('Arcim')
expect(b.legalEntity).toBe('Arcim Technology AB')
expect(b.supportEmail).toBe('support@gnubok.se')
expect(b.privacyEmail).toBe('privacy@gnubok.se')
expect(b.securityEmail).toBe('security@arcim.io')
@@ -128,7 +128,7 @@ describe('branding service', () => {
registerBrandingService({ appName: 'Holdio' })
const b = getBranding()
expect(b.appName).toBe('Holdio')
expect(b.legalEntity).toBe('Arcim')
expect(b.legalEntity).toBe('Arcim Technology AB')
expect(b.supportEmail).toBe('support@gnubok.se')
})
+3 -3
View File
@@ -6,7 +6,7 @@
*
* defaults < env vars < extension override
*
* If nothing is set, gnubok defaults are returned — production behaviour
* If nothing is set, Accounted defaults are returned — production behaviour
* is unchanged. A whitelabel sets env vars (NEXT_PUBLIC_BRANDING_* for
* client-readable, BRANDING_* for server-only) or registers a branding
* extension via registerBrandingService().
@@ -60,8 +60,8 @@ export interface BrandingConfig {
const DEFAULT_BRANDING: BrandingConfig = {
appName: 'Accounted',
appDescription: 'Ekonomihantering',
legalEntity: 'Arcim',
// Emails and URLs intentionally keep the gnubok hostname — the rebrand is
legalEntity: 'Arcim Technology AB',
// Emails and URLs intentionally keep the gnubok.se hostname — the rebrand is
// visual only; we don't churn the support inbox or app domain alongside it.
supportEmail: 'support@gnubok.se',
privacyEmail: 'privacy@gnubok.se',
+1 -1
View File
@@ -1,7 +1,7 @@
import type { EntityType } from '@/types'
/**
* Explicit allow-lists for TIC/Bolagsverket `legalEntityType` → gnubok
* Explicit allow-lists for TIC/Bolagsverket `legalEntityType` → Accounted
* EntityType. Strict (not substring) matching avoids misclassifications like
* "Enskild stiftelse" → enskild_firma, which would provision with K1/
* kontantmetoden defaults — an ML/BFL correctness risk.
+2 -2
View File
@@ -1,7 +1,7 @@
import { luhnValidate } from '@/lib/bankgiro/luhn'
/**
* Normalize an org number to gnubok's canonical 10-digit storage form.
* Normalize an org number to Accounted's canonical 10-digit storage form.
*
* Accepts hyphen/space-formatted input in either of the two shapes Swedish
* users commonly type:
@@ -12,7 +12,7 @@ import { luhnValidate } from '@/lib/bankgiro/luhn'
* check digit (the structural rule Bolagsverket and personnummer share).
* Storing a structurally invalid org number would later be caught by
* Skatteverket SRU and any receiving SIE4 system — refusing at the boundary
* keeps gnubok's bookkeeping from accumulating under an unusable identifier.
* keeps Accounted's bookkeeping from accumulating under an unusable identifier.
*
* 10-digit storage matches the rest of the codebase — see
* `lib/skatteverket/format.ts`, which converts 10→12 at export time by
+1 -1
View File
@@ -8,7 +8,7 @@ import type { CompanyLookupResult } from '@/lib/company-lookup/types'
/**
* Check whether an org number is already registered in any non-archived
* gnubok company. Uses the service role because RLS hides rows the caller
* Accounted company. Uses the service role because RLS hides rows the caller
* isn't a member of — and "other users' duplicates" is exactly what we
* need to detect. Returns null when `orgNumber` is empty/malformed. Throws
* if the underlying query fails — callers must not silently treat that as
+4 -4
View File
@@ -2,7 +2,7 @@ import { API_V1_VERSION } from '@/lib/api/v1/version'
export const CHANGELOG_MD = `# Changelog
> Reverse-chronological release notes for the gnubok REST API. Versions follow Stripe's dated format (\`YYYY-MM-DD\`). The current version is **\`${API_V1_VERSION}\`**.
> Reverse-chronological release notes for the Accounted REST API. Versions follow Stripe's dated format (\`YYYY-MM-DD\`). The current version is **\`${API_V1_VERSION}\`**.
---
@@ -47,10 +47,10 @@ The first stable release of the public REST API. Six phases of development cover
### Payroll + reports + import (Phase 5)
- **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). gnubok 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.
- **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.
- **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. gnubok 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 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.
### Webhooks (Phase 6 PR-1) *— shipped 2026-05-15*
@@ -58,7 +58,7 @@ The first stable release of the public REST API. Six phases of development cover
- **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.
- **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. gnubok 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\`).
- **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\`).
- **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)
@@ -1,6 +1,6 @@
export const COOKBOOK_INGEST_BANK_MD = `# Cookbook — ingest and categorise bank transactions
> Push a bank statement file into gnubok, get AI-assisted category suggestions, commit the categorisations, and match payments against open invoices. End-to-end transaction-to-booking pipeline.
> Push a bank statement file into Accounted, get AI-assisted category suggestions, commit the categorisations, and match payments against open invoices. End-to-end transaction-to-booking pipeline.
This is the operational companion to the [Transactions reference](/docs/api/reference/transactions) and the [Imports reference](/docs/api/reference/imports). Use it for the first integration where transactions enter the system from a bank source.
+1 -1
View File
@@ -4,7 +4,7 @@ export const QUICKSTART_MD = `# Quickstart — send your first invoice
## What you'll need
- A test API key (\`gnubok_sk_test_*\`) from the gnubok dashboard at **/settings/api**. Test keys are bound to a deterministic sandbox company seeded with realistic data — safe for evals.
- 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.
- \`curl\` or any HTTP client.
## 1. List the companies the key can access
@@ -109,7 +109,7 @@ Failures return \`SALARY_RUN_APPROVE_VALIDATION_FAILED\` with a per-employee bre
## 4. Mark paid (approved → paid)
After the bank transfer settles (or you mark it on the same day for cash-method shops), tell gnubok:
After the bank transfer settles (or you mark it on the same day for cash-method shops), tell Accounted:
\`\`\`bash
curl -X POST "https://gnubok.app/api/v1/companies/$COMPANY_ID/salary-runs/$SR_ID/mark-paid" \\
+4 -4
View File
@@ -7,7 +7,7 @@ This is the operational companion to the [Webhooks concept page](/docs/api/webho
## What you'll need
- A test API key with \`webhooks:manage\` scope (and \`payroll:read\` if you intend to subscribe to payroll events).
- A receiver URL that gnubok can POST to. For local development use [smee.io](https://smee.io) or \`ngrok\` — gnubok refuses webhook URLs that resolve to private IPs (SSRF protection), so localhost won't work directly.
- A receiver URL that Accounted can POST to. For local development use [smee.io](https://smee.io) or \`ngrok\` — Accounted refuses webhook URLs that resolve to private IPs (SSRF protection), so localhost won't work directly.
- HTTPS only — \`http://\` URLs are rejected at registration.
## 1. Register the webhook
@@ -82,7 +82,7 @@ X-Gnubok-Event: webhook.test
X-Gnubok-Delivery: wh_dlv_...
X-Gnubok-Api-Version: 2026-05-12
{"id":"wh_dlv_...","type":"webhook.test","api_version":"2026-05-12","created":1715797800,"data":{"object":{"hello":"from gnubok","tested_at":"2026-05-15T12:00:00Z"}},"previous_attributes":null}
{"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}
\`\`\`
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\`.
@@ -148,7 +148,7 @@ async function handleEvent(event) {
}
\`\`\`
This pattern: a unique constraint on \`delivery_id\`, an INSERT-on-conflict-do-nothing, and short-circuit when nothing was inserted. Every gnubok delivery passes through that gate at most once even if the dispatcher retries.
This pattern: a unique constraint on \`delivery_id\`, an INSERT-on-conflict-do-nothing, and short-circuit when nothing was inserted. Every Accounted delivery passes through that gate at most once even if the dispatcher retries.
## Replaying a dead delivery
@@ -181,7 +181,7 @@ This clears \`disabled_at\` and \`disabled_reason\` but does NOT replay the deli
## Common pitfalls
- **Re-serialising the body.** \`JSON.parse(rawBody); JSON.stringify(parsed)\` produces different bytes than gnubok sent. Always sign-check against the raw bytes.
- **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.
- **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 -1
View File
@@ -73,7 +73,7 @@ export function buildErrorReferenceMd(): string {
const lines: string[] = []
lines.push('# Errors')
lines.push('')
lines.push(`> Every error returned by the gnubok 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 anchor for that specific code.`)
lines.push('')
lines.push('## Envelope shape')
lines.push('')
+1 -1
View File
@@ -188,7 +188,7 @@ export function buildReferenceOverviewMd(): string {
const lines: string[] = []
lines.push('# API reference')
lines.push('')
lines.push(`> Every endpoint exposed by the gnubok REST API, grouped by resource. Auto-generated from the same Zod registry that powers the [OpenAPI 3.1 spec](/api/v1/openapi.json), the MCP tool surface, and runtime validators — there is no separate doc-source to keep in sync.`)
lines.push(`> Every endpoint exposed by the Accounted REST API, grouped by resource. Auto-generated from the same Zod registry that powers the [OpenAPI 3.1 spec](/api/v1/openapi.json), the MCP tool surface, and runtime validators — there is no separate doc-source to keep in sync.`)
lines.push('')
lines.push('## Resources')
lines.push('')
+6 -6
View File
@@ -1,13 +1,13 @@
export const WEBHOOKS_MD = `# Webhooks
> Receive HMAC-signed POST notifications when state changes in gnubok — 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 ~72 hours.
If you've used [Stripe webhooks](https://docs.stripe.com/webhooks), the model is identical: subscribe a URL to an event type, gnubok 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.
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.
2. **gnubok emits events** internally (e.g. an invoice is marked paid via the dashboard or another API call). The webhook handler enqueues a delivery row.
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.
@@ -115,7 +115,7 @@ const app = express()
const SECRET = process.env.GNUBOK_WEBHOOK_SECRET // whsec_...
// Important: capture the RAW body before any JSON parsing — the signature
// is computed against the exact bytes gnubok sent, not a re-serialised JSON.
// is computed against the exact bytes Accounted sent, not a re-serialised JSON.
app.post(
'/webhook',
express.raw({ type: 'application/json' }),
@@ -207,7 +207,7 @@ def verify_signature(body: bytes, header: str, secret: bytes) -> bool:
### Common pitfalls
- **Using parsed JSON instead of raw bytes.** Re-serialising the body (\`JSON.stringify(req.body)\`) produces different bytes than gnubok sent — the signature won't match. Capture the raw body before any framework parses it.
- **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.
@@ -235,7 +235,7 @@ Re-enable with [\`PATCH /webhooks/{webhookId}\`](/docs/api/reference/webhooks#pa
Webhook delivery rows are *behandlingshistorik* (a system-event log) per BFNAR 2013:2 kap 8 § — they are immutable once they reach a terminal state (\`delivered\` or \`dead\`) so the audit trail of who-was-notified-when stays intact. The underlying *räkenskapsinformation* (the verifikation, the faktura, the AGI XML itself) lives in its own table with its own BFL 7 kap retention — webhook delivery rows are NOT räkenskapsinformation and the 7-year retention applies to the underlying record, not to the delivery envelope.
For accounting-event delivery rows (\`journal_entry.*\`, \`period.*\`, \`salary_run.booked\`, \`agi.generated\`, \`invoice.paid\`, \`supplier_invoice.paid\`), gnubok keeps the delivery rows for 7 years. **This is a voluntary operational audit-trail policy gnubok chose because the duration aligns conveniently with BFL 7 kap retention on the underlying records — it is NOT itself a statutory obligation.** The 7-year statutory retention under BFL 7 kap 1 § applies to the underlying verifikation / faktura / AGI XML in its own table, not to the delivery envelope. The integrator's own retention obligations likewise attach to the underlying records you receive (and any local copies you persist), not to the delivery-row metadata.
For accounting-event delivery rows (\`journal_entry.*\`, \`period.*\`, \`salary_run.booked\`, \`agi.generated\`, \`invoice.paid\`, \`supplier_invoice.paid\`), Accounted keeps the delivery rows for 7 years. **This is a voluntary operational audit-trail policy Accounted chose because the duration aligns conveniently with BFL 7 kap retention on the underlying records — it is NOT itself a statutory obligation.** The 7-year statutory retention under BFL 7 kap 1 § applies to the underlying verifikation / faktura / AGI XML in its own table, not to the delivery envelope. The integrator's own retention obligations likewise attach to the underlying records you receive (and any local copies you persist), not to the delivery-row metadata.
Deleting a webhook does not delete its delivery history; the FK is \`ON DELETE SET NULL\` so the audit trail survives.
`
+1 -1
View File
@@ -24,7 +24,7 @@ export const DOCS_NAV: DocsNavSection[] = [
{
label: 'Getting started',
links: [
{ label: 'Introduction', href: '/docs/api', summary: 'What the gnubok REST API is and how to authenticate.' },
{ label: 'Introduction', href: '/docs/api', summary: 'What the Accounted REST API is and how to authenticate.' },
{ label: 'Quickstart', href: '/docs/api/cookbook/quickstart', summary: 'Send your first invoice in five minutes.' },
{ label: 'Authentication', href: '/docs/api#authentication', summary: 'API keys, scopes, test mode.' },
],
@@ -26,7 +26,7 @@ describe('getStructuredError', () => {
}
const result = getStructuredError(new FakeBookkeepingError())
expect(result.code).toBe('ACCOUNTS_NOT_IN_CHART')
expect(result.remediation?.resource).toBe('gnubok://chart-of-accounts')
expect(result.remediation?.resource).toBe('Accounted://chart-of-accounts')
})
it('infers PERIOD_NOT_LOCKED from message text', () => {
@@ -50,7 +50,7 @@ describe('getStructuredError', () => {
)
expect(result.code).toBe('INSUFFICIENT_SCOPE')
expect(result.remediation?.description).toContain('"bookkeeping:write"')
expect(result.remediation?.resource).toBe('gnubok://capabilities')
expect(result.remediation?.resource).toBe('Accounted://capabilities')
})
it('infers TRANSACTION_ALREADY_CATEGORIZED', () => {
+5 -5
View File
@@ -114,7 +114,7 @@ const GENERIC: Record<string, StructuredErrorEntry> = {
remediation: {
description:
'Mint a new key with the missing scope or grant it through the API key settings.',
resource: 'gnubok://capabilities',
resource: 'Accounted://capabilities',
},
},
}
@@ -131,7 +131,7 @@ const BOOKKEEPING: Record<string, StructuredErrorEntry> = {
remediation: {
description:
'Activate the missing accounts via bookkeeping settings, or use a different category.',
resource: 'gnubok://chart-of-accounts',
resource: 'Accounted://chart-of-accounts',
},
},
JOURNAL_ENTRY_NOT_BALANCED: {
@@ -148,7 +148,7 @@ const BOOKKEEPING: Record<string, StructuredErrorEntry> = {
message_en: 'No fiscal period covers the entry date.',
remediation: {
description: 'Create or extend the relevant fiscal period before retrying.',
resource: 'gnubok://period/active',
resource: 'Accounted://period/active',
},
},
ENTRY_DATE_OUTSIDE_FISCAL_PERIOD: {
@@ -157,7 +157,7 @@ const BOOKKEEPING: Record<string, StructuredErrorEntry> = {
message_en: 'Entry date is outside the active fiscal period.',
remediation: {
description: 'Use a date inside an open period or create one that covers it.',
resource: 'gnubok://period/active',
resource: 'Accounted://period/active',
},
},
JOURNAL_ENTRY_NOT_FOUND: {
@@ -272,7 +272,7 @@ const TRANSACTIONS: Record<string, StructuredErrorEntry> = {
message_en: 'The supplied account does not exist in the chart of accounts.',
remediation: {
description: 'Activate the account in the chart of accounts or pick a different one.',
resource: 'gnubok://chart-of-accounts',
resource: 'Accounted://chart-of-accounts',
},
},
TX_CATEGORIZE_INVALID_TEMPLATE: {
+1 -1
View File
@@ -174,7 +174,7 @@ export type CoreEvent =
// get loaded by agents. `kind` discriminates by URI scheme so we can
// GROUP BY skill vs widget vs data without parsing URIs.
| { type: 'mcp.resource_read'; payload: {
uri: string // e.g. 'gnubok://skill/month-end-close'
uri: string // e.g. 'Accounted://skill/month-end-close'
kind: 'widget' | 'skill' | 'data' | 'unknown'
success: boolean
errorCode: string | null
+1 -1
View File
@@ -1024,7 +1024,7 @@ describe('importVouchers — per-voucher series preservation', () => {
it('preserves original source series/number on each imported entry, even across skipped vouchers', async () => {
const { supabase, journalEntryInserts } = buildCapturingSupabase()
// A2 is an empty voucher (no lines) — will be skipped. A1 and A3 survive.
// Gnubok assigns target numbers 1 and 2 (contiguous), but source_voucher_number
// Accounted assigns target numbers 1 and 2 (contiguous), but source_voucher_number
// must preserve the SIE originals (1 and 3) so traceability is not lost.
const parsed = makeParsedFile({
vouchers: [
+31 -4
View File
@@ -355,7 +355,7 @@ export async function ensureFiscalPeriod(
if (!replaceableGateOpen || hasEntries) {
throw new Error(
`SIE-filens räkenskapsår (${startDate} – ${endDate}) överlappar men matchar inte ett befintligt räkenskapsår i gnubok ` +
`SIE-filens räkenskapsår (${startDate} – ${endDate}) överlappar men matchar inte ett befintligt räkenskapsår i Accounted ` +
`(${existing.name}: ${existing.period_start} – ${existing.period_end}). ` +
`Justera räkenskapsåret i Inställningar → Företag så att det matchar SIE-filen exakt, eller importera en SIE-fil som täcker exakt samma period.`
)
@@ -902,11 +902,28 @@ export async function importVouchers(
// traceability alongside the aggregate sie_imports.migration_documentation.
sourceSeries: string | null
sourceNumber: number | null
// 'import' for ordinary migrated vouchers; 'opening_balance' for a #VER that
// is really the year's ingående balans (see isLikelyOpeningBalance below).
sourceType: 'import' | 'opening_balance'
lines: { account_number: string; debit_amount: number; credit_amount: number; line_description: string | null }[]
}
const preparedVouchers: PreparedVoucher[] = []
// A SIE file represents the opening balance either as #IB records (handled
// separately by createOpeningBalanceEntry → source_type='opening_balance') or,
// in some source systems, as an ordinary #VER dated on the fiscal-year start.
// When there are NO current-year #IB records, detect a clearly-labelled IB
// voucher and tag it opening_balance so bank reconciliation excludes it from
// the period movement (otherwise it lands as 'import' and surfaces as a phantom
// difference equal to the IB). Deliberately conservative — requires the IB
// wording AND a balance-sheet-only voucher on FY start, and never a
// share-capital deposit. A missed IB still falls back to the manual "Märk som
// ingående balans" action in Bankavstämning, so we never risk hiding a real
// bank movement by over-classifying.
const hasCurrentYearIb = parsed.openingBalances.some((b) => b.yearIndex === 0)
const fyStart = parsed.stats.fiscalYearStart
for (const voucher of parsed.vouchers) {
const lines: PreparedVoucher['lines'] = []
let hasUnmappedAccount = false
@@ -1038,13 +1055,23 @@ export async function importVouchers(
const rawSourceSeries = voucher.series && voucher.series.trim() ? voucher.series.trim() : null
const rawSourceNumber = Number.isFinite(voucher.number) ? voucher.number : null
const voucherDateStr = formatDate(voucher.date)
const isLikelyOpeningBalance =
!hasCurrentYearIb &&
!!fyStart && fyStart.slice(0, 10) === voucherDateStr &&
lines.length > 0 &&
lines.every((l) => isBalanceSheetAccount(l.account_number)) &&
/ing[åa]ende balans|ing[åa]ende saldo|opening balance/i.test(voucher.description || '') &&
!/aktiekapital/i.test(voucher.description || '')
preparedVouchers.push({
sourceId: voucherId,
series: resolvedSeries,
date: formatDate(voucher.date),
date: voucherDateStr,
description: voucher.description || `Import: ${voucher.series}${voucher.number}`,
sourceSeries: rawSourceSeries,
sourceNumber: rawSourceNumber,
sourceType: isLikelyOpeningBalance ? 'opening_balance' : 'import',
lines,
})
}
@@ -1141,7 +1168,7 @@ export async function importVouchers(
voucher_series: series,
entry_date: v.date,
description: v.description,
source_type: 'import',
source_type: v.sourceType,
source_voucher_series: v.sourceSeries,
source_voucher_number: v.sourceNumber,
status: 'posted',
@@ -1778,7 +1805,7 @@ export async function loadMappings(supabase: SupabaseClient, companyId: string):
* updated data from Fortnox without manual cleanup.
*
* Replace only cancels journal entries with source_type='import' — entries
* the user created natively in gnubok (categorized transactions, invoices,
* the user created natively in Accounted (categorized transactions, invoices,
* etc.) are left alone. See the replace_sie_import RPC.
*/
export async function executeSIEImport(
@@ -60,21 +60,21 @@ describe('isWcagAACompliant', () => {
expect(isWcagAACompliant('#ffff00', '#ffffff')).toBe(false)
})
// gnubok brand semantic colors. All three are used as DATA-ONLY indicators
// Accounted brand semantic colors. All three are used as DATA-ONLY indicators
// (charts, positive/negative deltas), never as chrome text on white. These
// tests document where they sit relative to AA — terracotta/destructive is
// the only one that comfortably passes AA on white.
it('gnubok terracotta (#c2410c equivalent dark red) passes AA on white', () => {
it('Accounted terracotta (#c2410c equivalent dark red) passes AA on white', () => {
// 5.91:1 — passes AA for normal text.
expect(isWcagAACompliant('#c2410c', '#ffffff')).toBe(true)
})
it('gnubok sage (#84a98c lighter green) fails AA on white', () => {
it('Accounted sage (#84a98c lighter green) fails AA on white', () => {
// ~2.4:1 — fails AA, as expected for a soft pastel sage.
expect(isWcagAACompliant('#84a98c', '#ffffff')).toBe(false)
})
it('gnubok ochre (#d4a373 warm yellow) fails AA on white', () => {
it('Accounted ochre (#d4a373 warm yellow) fails AA on white', () => {
// ~2.3:1 — fails AA, as expected for a warm soft ochre.
expect(isWcagAACompliant('#d4a373', '#ffffff')).toBe(false)
})
+1 -1
View File
@@ -54,7 +54,7 @@ const LATE_PAYMENT_PREMIUM = 0.08 // 8 procentenheter per Räntelagen §6
*
* If the requested date is before the earliest entry in the table we fall
* back to the earliest entry (this is a defensive measure — should never
* happen in practice since gnubok was launched after 2022).
* happen in practice since Accounted was launched after 2022).
*/
export function getReferensrantaAt(date: string): number {
// Walk newest-first so the first match wins.
+28
View File
@@ -24,6 +24,7 @@ import {
amountsMatchFuzzy,
customerNameMatches,
} from './invoice-matching'
import { autoReconcileTransactionForLinkedVoucher } from '@/lib/reconciliation/bank-reconciliation'
import type { SupplierInvoice, Supplier } from '@/types'
const log = createLogger('supplier-voucher-matching')
@@ -480,6 +481,9 @@ export interface LinkSupplierInvoiceToVoucherResult {
remainingAmount: number
paymentAmount: number
journalEntryId: string
/** Bank transaction auto-reconciled to the linked voucher, if exactly one
* unbooked line matched it; null when nothing was safely linkable. */
reconciledTransactionId: string | null
}
/**
@@ -594,6 +598,29 @@ export async function linkSupplierInvoiceToVoucher(
}
}
// Close the loop on the bank feed: the link above only advanced the supplier
// invoice, leaving the bank transaction that paid it in the Transactions
// inbox. Reconcile it to the same verifikat when unambiguous. Best-effort —
// the link RPC has already committed.
let reconciledTransactionId: string | null = null
try {
const recon = await autoReconcileTransactionForLinkedVoucher(
supabase,
companyId,
userId,
params.journalEntryId,
{ supplierInvoiceId: params.supplierInvoiceId },
)
reconciledTransactionId = recon?.linkedTransactionId ?? null
} catch (err) {
log.warn('auto-reconcile of bank transaction after supplier voucher link failed (non-blocking)', {
companyId,
supplierInvoiceId: params.supplierInvoiceId,
journalEntryId: params.journalEntryId,
reason: err instanceof Error ? err.message : String(err),
})
}
return {
ok: true,
result: {
@@ -603,6 +630,7 @@ export async function linkSupplierInvoiceToVoucher(
remainingAmount: result.remaining_amount,
paymentAmount: result.payment_amount,
journalEntryId: result.journal_entry_id,
reconciledTransactionId,
},
}
}
+30
View File
@@ -24,6 +24,7 @@ import {
amountsMatchFuzzy,
customerNameMatches,
} from './invoice-matching'
import { autoReconcileTransactionForLinkedVoucher } from '@/lib/reconciliation/bank-reconciliation'
import type { Invoice, Customer } from '@/types'
const log = createLogger('voucher-matching')
@@ -460,6 +461,10 @@ export interface LinkInvoiceToVoucherResult {
remainingAmount: number
paymentAmount: number
journalEntryId: string
/** Bank transaction auto-reconciled to the linked voucher, if exactly one
* unbooked line matched it; null when nothing was safely linkable. Lets the
* inbox row leave the Transactions list — the gap this whole flow fixes. */
reconciledTransactionId: string | null
}
/**
@@ -602,6 +607,30 @@ export async function linkInvoiceToVoucher(
/* non-critical */
}
// Close the loop on the bank feed: the invoice→voucher link above only
// advanced the invoice, so the bank transaction that paid it kept sitting in
// the Transactions inbox (journal_entry_id still null). Reconcile it to the
// same verifikat when it can be done unambiguously. Best-effort — the invoice
// link has already committed, so a failure here must not fail the whole call.
let reconciledTransactionId: string | null = null
try {
const recon = await autoReconcileTransactionForLinkedVoucher(
supabase,
companyId,
userId,
params.journalEntryId,
{ invoiceId: params.invoiceId },
)
reconciledTransactionId = recon?.linkedTransactionId ?? null
} catch (err) {
log.warn('auto-reconcile of bank transaction after voucher link failed (non-blocking)', {
companyId,
invoiceId: params.invoiceId,
journalEntryId: params.journalEntryId,
reason: err instanceof Error ? err.message : String(err),
})
}
return {
ok: true,
result: {
@@ -611,6 +640,7 @@ export async function linkInvoiceToVoucher(
remainingAmount: newRemaining,
paymentAmount: validation.paymentAmount,
journalEntryId: params.journalEntryId,
reconciledTransactionId,
},
}
}
@@ -243,6 +243,137 @@ describe('commitPendingOperation: create_voucher', () => {
)
})
// ── opening-balance (IB) flow ──────────────────────────────────────
// gnubok_create_voucher accepts a typed boolean is_opening_balance. The
// executor derives source_type='opening_balance' (so bank reconciliation
// excludes the IB from period movement) ONLY after re-validating the entry:
// every line must be a balance-sheet account (class 1/2) AND entry_date must
// equal the fiscal period's period_start. It never trusts a raw source_type.
it('opening_balance: derives source_type=opening_balance for a valid IB (class 1/2 lines, dated on period_start)', async () => {
vi.mocked(createJournalEntry).mockResolvedValueOnce(
makeJournalEntry({ id: 'je-ib', voucher_number: 1, voucher_series: 'A' })
)
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: { id: 'op-1' }, error: null }) // CAS claim
enqueue({ data: { period_start: '2026-01-01', name: '2026' }, error: null }) // fiscal_periods lookup
enqueue({ data: null, error: null }) // dispatcher's commit update
const op = makePendingOp({
params: {
entry_date: '2026-01-01',
description: 'Ingående balanser (migrering)',
fiscal_period_id: 'fp-1',
is_opening_balance: true,
lines: [
{ account_number: '1930', debit_amount: 5000, credit_amount: 0 },
{ account_number: '2081', debit_amount: 0, credit_amount: 5000 },
],
},
})
const result = await commitPendingOperation(supabase as never, 'user-1', 'company-1', op)
expect(result.status).toBe('committed')
expect(createJournalEntry).toHaveBeenCalledWith(
expect.anything(),
'company-1',
'user-1',
expect.objectContaining({ source_type: 'opening_balance' }),
'user_accept'
)
})
it('opening_balance: rejects with 400 when any line is a P&L account (class 3-8)', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: { id: 'op-1' }, error: null }) // CAS claim
enqueue({ data: null, error: null }) // dispatcher's reject update
const op = makePendingOp({
params: {
entry_date: '2026-01-01',
description: 'IB with a revenue account smuggled in',
fiscal_period_id: 'fp-1',
is_opening_balance: true,
lines: [
{ account_number: '1930', debit_amount: 5000, credit_amount: 0 },
{ account_number: '3001', debit_amount: 0, credit_amount: 5000 },
],
},
})
const result = await commitPendingOperation(supabase as never, 'user-1', 'company-1', op)
expect(result.status).toBe('failed')
expect(result.http_status).toBe(400)
expect(result.error).toMatch(/balanskonton|3001/i)
// No period lookup and no engine call once a P&L account is detected.
expect(createJournalEntry).not.toHaveBeenCalled()
})
it('opening_balance: rejects with 400 when entry_date is not the fiscal period start', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: { id: 'op-1' }, error: null }) // CAS claim
enqueue({ data: { period_start: '2026-01-01', name: '2026' }, error: null }) // fiscal_periods lookup
enqueue({ data: null, error: null }) // dispatcher's reject update
const op = makePendingOp({
params: {
entry_date: '2026-03-15',
description: 'IB dated mid-year',
fiscal_period_id: 'fp-1',
is_opening_balance: true,
lines: [
{ account_number: '1930', debit_amount: 5000, credit_amount: 0 },
{ account_number: '2081', debit_amount: 0, credit_amount: 5000 },
],
},
})
const result = await commitPendingOperation(supabase as never, 'user-1', 'company-1', op)
expect(result.status).toBe('failed')
expect(result.http_status).toBe(400)
expect(result.error).toMatch(/första dag|2026-01-01/i)
expect(createJournalEntry).not.toHaveBeenCalled()
})
it('opening_balance absent: source_type stays manual (no period lookup, unchanged behaviour)', async () => {
vi.mocked(createJournalEntry).mockResolvedValueOnce(
makeJournalEntry({ id: 'je-manual', voucher_number: 50 })
)
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: { id: 'op-1' }, error: null }) // CAS claim
enqueue({ data: null, error: null }) // dispatcher's commit update
const op = makePendingOp({
params: {
entry_date: '2026-03-15',
description: 'ordinary manual voucher, mid-year, on class-1 accounts',
fiscal_period_id: 'fp-1',
// is_opening_balance omitted — must NOT trigger the IB path or its
// extra fiscal_periods lookup, even though the lines are class 1/2.
lines: [
{ account_number: '1930', debit_amount: 250, credit_amount: 0 },
{ account_number: '1910', debit_amount: 0, credit_amount: 250 },
],
},
})
const result = await commitPendingOperation(supabase as never, 'user-1', 'company-1', op)
expect(result.status).toBe('committed')
expect(createJournalEntry).toHaveBeenCalledWith(
expect.anything(),
'company-1',
'user-1',
expect.objectContaining({ source_type: 'manual' }),
'user_accept'
)
})
it('passes bulk_accept commit_method when invoked from the bulk-commit path', async () => {
// The bulk-commit route passes opts.commitMethod = 'bulk_accept' so the
// resulting journal_entry rows are tagged correctly per BFNAR 2013:2
+61 -4
View File
@@ -1035,6 +1035,7 @@ async function commitLinkInvoiceVoucher(
payment_amount: outcome.result.paymentAmount,
payment_id: outcome.result.paymentId,
journal_entry_id: outcome.result.journalEntryId,
reconciled_transaction_id: outcome.result.reconciledTransactionId,
},
}
}
@@ -1077,6 +1078,7 @@ async function commitLinkSupplierInvoiceVoucher(
payment_amount: outcome.result.paymentAmount,
payment_id: outcome.result.paymentId,
journal_entry_id: outcome.result.journalEntryId,
reconciled_transaction_id: outcome.result.reconciledTransactionId,
},
}
}
@@ -2330,6 +2332,64 @@ async function commitCreateVoucher(
fiscalPeriodId = resolved
}
// source_type is derived here — never trust params.source_type. The MCP tool
// stages a typed boolean (is_opening_balance), not a raw source_type string,
// so a tampered or future direct-staging path can't inject
// 'bank'/'invoice'/etc. and corrupt audit attribution. The default is
// 'manual'. We only upgrade to 'opening_balance' after independently
// re-validating the entry genuinely looks like an ingående balans — this
// matters because bank reconciliation excludes an IB from the period movement
// ONLY when source_type='opening_balance' (lib/reconciliation/bank-reconciliation.ts);
// a mislabelled 'manual' IB shows up as a phantom reconciliation difference.
let sourceType: JournalEntrySourceType = 'manual'
if (params.is_opening_balance === true) {
// Constraint 1: every line must be a balance-sheet account (BAS class 1 or
// 2). Mirrors the canonical opening-balance flow which rejects P&L accounts
// (app/api/import/opening-balance/execute/route.ts). Inlined to avoid
// coupling this executor to the SIE-import module.
const nonBalanceSheet = lines
.map((l) => l.account_number)
.filter((num) => {
const cls = parseInt(num.charAt(0), 10)
return !(cls === 1 || cls === 2)
})
if (nonBalanceSheet.length > 0) {
return {
error:
`Ingående balans får bara innehålla balanskonton (klass 1–2). ` +
`Dessa konton hör inte hemma i en IB: ${[...new Set(nonBalanceSheet)].join(', ')}. ` +
`Bokför resultatkonton som en vanlig verifikation utan is_opening_balance.`,
status: 400,
}
}
// Constraint 2: the entry must be dated on the fiscal period's first day —
// an IB opens the period (same as the canonical flow, which dates the entry
// on period.period_start). We fetch period_start here because the resolved
// fiscalPeriodId may have come from either the explicit param or a date
// lookup; either way the date must line up exactly.
const { data: period, error: periodErr } = await supabase
.from('fiscal_periods')
.select('period_start, name')
.eq('id', fiscalPeriodId)
.eq('company_id', companyId)
.maybeSingle()
if (periodErr || !period) {
return { error: 'Räkenskapsperioden hittades inte.', status: 404 }
}
if (entryDate !== period.period_start) {
return {
error:
`En ingående balans måste dateras på räkenskapsårets första dag ` +
`(${period.period_start}). Angivet datum: ${entryDate}. ` +
`Ändra datumet eller bokför som en vanlig verifikation utan is_opening_balance.`,
status: 400,
}
}
sourceType = 'opening_balance'
}
try {
const entry = await createJournalEntry(
supabase,
@@ -2339,10 +2399,7 @@ async function commitCreateVoucher(
fiscal_period_id: fiscalPeriodId,
entry_date: entryDate,
description,
// source_type is hardcoded — never trust params.source_type. The MCP
// tool stages 'manual', but a future direct-staging path could
// otherwise inject 'bank'/'invoice'/etc. and corrupt audit attribution.
source_type: 'manual' as JournalEntrySourceType,
source_type: sourceType,
voucher_series: (params.voucher_series as string) || undefined,
notes: (params.notes as string) || undefined,
lines,
@@ -0,0 +1,197 @@
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { autoReconcileTransactionForLinkedVoucher } from '@/lib/reconciliation/bank-reconciliation'
import { eventBus } from '@/lib/events/bus'
import { makeTransaction } from '@/tests/helpers'
// Queue-based Supabase mock: every awaited chain consumes the next enqueued
// result regardless of the builder methods called, so we enqueue results in the
// exact order the function under test awaits them. Mirrors the manualLink suite
// in bank-reconciliation.test.ts.
function createQueueMockSupabase() {
const resultQueue: { data: unknown; error: unknown }[] = []
const enqueue = (...results: { data?: unknown; error?: unknown }[]) => {
for (const r of results) {
resultQueue.push({ data: r.data ?? null, error: r.error ?? null })
}
}
const buildChain = (): unknown => {
const handler: ProxyHandler<object> = {
get(_target, prop) {
if (prop === 'then') {
const next = resultQueue.shift() ?? { data: null, error: null }
return (resolve: (v: unknown) => void) => resolve(next)
}
return (..._args: unknown[]) => buildChain()
},
}
return new Proxy({}, handler)
}
const supabase = {
from: vi.fn().mockImplementation(() => buildChain()),
rpc: vi.fn().mockImplementation(() => buildChain()),
}
return { supabase, enqueue }
}
const POSTED_VOUCHER = { id: 'je-1', entry_date: '2026-05-10', status: 'posted' }
// cash_accounts.id is a UUID — scopeTransactionsToAccount asserts that shape.
const CA_1930 = 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa'
const CA_1932 = 'bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb'
const SEK_PRIMARY = { id: CA_1930, ledger_account: '1930', currency: 'SEK', is_primary: true }
/**
* Enqueue the manualLink sub-call results (tx fetch → entry fetch → 1930 line
* → update). cash_account_id is null on the fetched tx so manualLink skips the
* cross-account check (one fewer query).
*/
function enqueueManualLinkSuccess(
enqueue: ReturnType<typeof createQueueMockSupabase>['enqueue'],
txAmount: number,
) {
enqueue({ data: makeTransaction({ id: 'tx-1', journal_entry_id: null, cash_account_id: null, amount: txAmount, currency: 'SEK' }) })
enqueue({ data: { id: 'je-1', user_id: 'company-1', status: 'posted' } })
enqueue({ data: [{ debit_amount: Math.max(txAmount, 0), credit_amount: Math.max(-txAmount, 0), account_number: '1930' }] })
enqueue({ data: null, error: null }) // update
}
describe('autoReconcileTransactionForLinkedVoucher', () => {
beforeEach(() => {
vi.clearAllMocks()
eventBus.clear()
})
it('links the single matching unbooked transaction for a customer payment (income)', async () => {
const { supabase, enqueue } = createQueueMockSupabase()
enqueue({ data: [] }) // 1. no tx already reconciled to the voucher
enqueue({ data: POSTED_VOUCHER }) // 2. voucher
enqueue({ data: [ // 3. lines: one 1930 debit + the 1510 credit
{ account_number: '1930', debit_amount: 1000, credit_amount: 0 },
{ account_number: '1510', debit_amount: 0, credit_amount: 1000 },
] })
enqueue({ data: [SEK_PRIMARY] }) // 4. cash accounts
enqueue({ data: [{ id: 'tx-1', amount: 1000 }] }) // 5. exactly one candidate
enqueueManualLinkSuccess(enqueue, 1000)
const result = await autoReconcileTransactionForLinkedVoucher(
supabase as never, 'company-1', 'user-1', 'je-1', { invoiceId: 'inv-1' },
)
expect(result).toEqual({ linkedTransactionId: 'tx-1' })
})
it('links the single matching unbooked transaction for a supplier payment (expense)', async () => {
const { supabase, enqueue } = createQueueMockSupabase()
enqueue({ data: [] })
enqueue({ data: POSTED_VOUCHER })
enqueue({ data: [ // 2440 debit + 1930 credit (money out)
{ account_number: '2440', debit_amount: 1000, credit_amount: 0 },
{ account_number: '1930', debit_amount: 0, credit_amount: 1000 },
] })
enqueue({ data: [SEK_PRIMARY] })
enqueue({ data: [{ id: 'tx-1', amount: -1000 }] }) // expense tx
enqueueManualLinkSuccess(enqueue, -1000)
const result = await autoReconcileTransactionForLinkedVoucher(
supabase as never, 'company-1', 'user-1', 'je-1', { supplierInvoiceId: 'si-1' },
)
expect(result).toEqual({ linkedTransactionId: 'tx-1' })
})
it('does nothing when a transaction is already reconciled to the voucher', async () => {
const { supabase, enqueue } = createQueueMockSupabase()
enqueue({ data: [{ id: 'tx-existing' }] }) // voucher already has a bank line
const result = await autoReconcileTransactionForLinkedVoucher(
supabase as never, 'company-1', 'user-1', 'je-1', { invoiceId: 'inv-1' },
)
expect(result).toBeNull()
})
it('does nothing when the voucher has no cash-account line (AR/AP reclass)', async () => {
const { supabase, enqueue } = createQueueMockSupabase()
enqueue({ data: [] })
enqueue({ data: POSTED_VOUCHER })
enqueue({ data: [ // no 19xx line at all
{ account_number: '1510', debit_amount: 1000, credit_amount: 0 },
{ account_number: '3001', debit_amount: 0, credit_amount: 1000 },
] })
enqueue({ data: [SEK_PRIMARY] })
const result = await autoReconcileTransactionForLinkedVoucher(
supabase as never, 'company-1', 'user-1', 'je-1', { invoiceId: 'inv-1' },
)
expect(result).toBeNull()
})
it('does nothing when the voucher touches two cash accounts (a transfer)', async () => {
const { supabase, enqueue } = createQueueMockSupabase()
enqueue({ data: [] })
enqueue({ data: POSTED_VOUCHER })
enqueue({ data: [
{ account_number: '1930', debit_amount: 0, credit_amount: 1000 },
{ account_number: '1932', debit_amount: 1000, credit_amount: 0 },
] })
enqueue({ data: [SEK_PRIMARY, { id: CA_1932, ledger_account: '1932', currency: 'EUR', is_primary: false }] })
const result = await autoReconcileTransactionForLinkedVoucher(
supabase as never, 'company-1', 'user-1', 'je-1', { invoiceId: 'inv-1' },
)
expect(result).toBeNull()
})
it('does nothing when two unbooked transactions match the amount (ambiguous)', async () => {
const { supabase, enqueue } = createQueueMockSupabase()
enqueue({ data: [] })
enqueue({ data: POSTED_VOUCHER })
enqueue({ data: [
{ account_number: '1930', debit_amount: 1000, credit_amount: 0 },
{ account_number: '1510', debit_amount: 0, credit_amount: 1000 },
] })
enqueue({ data: [SEK_PRIMARY] })
enqueue({ data: [{ id: 'tx-1', amount: 1000 }, { id: 'tx-2', amount: 1000 }] }) // two hits
const result = await autoReconcileTransactionForLinkedVoucher(
supabase as never, 'company-1', 'user-1', 'je-1', { invoiceId: 'inv-1' },
)
expect(result).toBeNull()
})
it('does nothing when no candidate matches the bank movement amount', async () => {
const { supabase, enqueue } = createQueueMockSupabase()
enqueue({ data: [] })
enqueue({ data: POSTED_VOUCHER })
enqueue({ data: [
{ account_number: '1930', debit_amount: 1000, credit_amount: 0 },
{ account_number: '1510', debit_amount: 0, credit_amount: 1000 },
] })
enqueue({ data: [SEK_PRIMARY] })
enqueue({ data: [{ id: 'tx-9', amount: 5000 }] }) // wrong amount
const result = await autoReconcileTransactionForLinkedVoucher(
supabase as never, 'company-1', 'user-1', 'je-1', { invoiceId: 'inv-1' },
)
expect(result).toBeNull()
})
it('does nothing when the voucher is not posted', async () => {
const { supabase, enqueue } = createQueueMockSupabase()
enqueue({ data: [] })
enqueue({ data: { id: 'je-1', entry_date: '2026-05-10', status: 'draft' } })
const result = await autoReconcileTransactionForLinkedVoucher(
supabase as never, 'company-1', 'user-1', 'je-1', { invoiceId: 'inv-1' },
)
expect(result).toBeNull()
})
})
+200
View File
@@ -640,6 +640,206 @@ export async function unlinkReconciliation(
return { success: true }
}
/** Float tolerance for matching a bank line to a verifikat (0.5 öre). */
const VOUCHER_LINK_AMOUNT_TOLERANCE = 0.005
/** A bank line settling a verifikat sits within a few days of the voucher's
* entry_date. Kept tight so the single-candidate rule below stays meaningful. */
const VOUCHER_LINK_DATE_WINDOW_DAYS = 7
/** Shift an ISO 'YYYY-MM-DD' date by ±days, returning the same string shape. */
function shiftIsoDate(date: string, days: number): string {
const d = new Date(date)
if (Number.isNaN(d.getTime())) return date
d.setUTCDate(d.getUTCDate() + days)
return d.toISOString().slice(0, 10)
}
interface CashAccountInfo {
id: string | null
currency: string
isPrimary: boolean
}
/**
* Reconcile the single unbooked bank transaction that corresponds to a verifikat
* the user just linked to an invoice from the invoice page — the symmetric move
* to the transactions-side match, closing the gap where linkInvoiceToVoucher /
* linkSupplierInvoiceToVoucher advanced the invoice but left the bank line
* sitting in the Transactions inbox (still journal_entry_id = null).
*
* Deliberately conservative — it only acts when the link is unambiguous:
* • the voucher has NO bank transaction reconciled to it yet (never adds a
* second one automatically — that N:1 case must be an explicit choice in
* Bankavstämning),
* • the voucher touches exactly ONE cash-account line (a transfer hitting two
* bank accounts, or an AR/AP reclass with none, is left alone), and
* • exactly ONE unbooked, non-ignored transaction on that account matches the
* bank movement (same amount within tolerance, same direction) inside a
* tight date window.
* Anything else is left untouched: the user can still match it by hand from the
* Transactions list. The link itself uses manualLink — no new journal entry, no
* JE mutation — so this is a reconciliation link, never a second booking.
*
* Best-effort by contract: returns the linked transaction id or null, and the
* caller treats a throw as "nothing linked" because the invoice link has already
* committed.
*/
export async function autoReconcileTransactionForLinkedVoucher(
supabase: SupabaseClient,
companyId: string,
userId: string,
journalEntryId: string,
options: {
invoiceId?: string
supplierInvoiceId?: string
dateWindowDays?: number
} = {},
): Promise<{ linkedTransactionId: string } | null> {
const windowDays = options.dateWindowDays ?? VOUCHER_LINK_DATE_WINDOW_DAYS
// 1. If a bank transaction already points at this voucher, the bank side is
// settled — don't attach another one behind the user's back.
const { data: alreadyLinked } = await supabase
.from('transactions')
.select('id')
.eq('company_id', companyId)
.eq('journal_entry_id', journalEntryId)
.limit(1)
if (alreadyLinked && alreadyLinked.length > 0) return null
// 2. Load the voucher (must be posted) and its lines.
const { data: entry } = await supabase
.from('journal_entries')
.select('id, entry_date, status')
.eq('id', journalEntryId)
.eq('company_id', companyId)
.maybeSingle()
if (!entry || entry.status !== 'posted' || !entry.entry_date) return null
const { data: lines } = await supabase
.from('journal_entry_lines')
.select('account_number, debit_amount, credit_amount')
.eq('journal_entry_id', journalEntryId)
if (!lines || lines.length === 0) return null
// 3. Which BAS codes carry a bank feed? cash_accounts is the source of truth.
const { data: cashAccounts } = await supabase
.from('cash_accounts')
.select('id, ledger_account, currency, is_primary')
.eq('company_id', companyId)
const cashByAccount = new Map<string, CashAccountInfo>()
for (const raw of (cashAccounts ?? []) as Array<{
id: string
ledger_account: string | null
currency: string | null
is_primary: boolean | null
}>) {
if (raw.ledger_account) {
cashByAccount.set(raw.ledger_account, {
id: raw.id,
currency: raw.currency ?? 'SEK',
isPrimary: raw.is_primary ?? false,
})
}
}
// Companies created before cash_accounts seeding reconcile against 1930/SEK.
if (cashByAccount.size === 0) {
cashByAccount.set('1930', { id: null, currency: 'SEK', isPrimary: true })
}
const cashLines = (lines as Array<{
account_number: string
debit_amount: number | null
credit_amount: number | null
}>).filter((l) => cashByAccount.has(l.account_number))
// Exactly one bank movement → exactly one bank transaction to attach.
if (cashLines.length !== 1) return null
const cashLine = cashLines[0]
const accountNumber = cashLine.account_number
const cashAccount = cashByAccount.get(accountNumber)!
const debit = Number(cashLine.debit_amount ?? 0)
const credit = Number(cashLine.credit_amount ?? 0)
const movement = debit > 0 ? debit : -credit // + money in, − money out
if (Math.abs(movement) <= VOUCHER_LINK_AMOUNT_TOLERANCE) return null
// 4. Unbooked, non-ignored candidate transactions on that account, scoped the
// same way Bankavstämning scopes (handles legacy NULL cash_account_id rows).
const fromDate = shiftIsoDate(entry.entry_date, -windowDays)
const toDate = shiftIsoDate(entry.entry_date, windowDays)
let candQuery = supabase
.from('transactions')
.select('id, amount')
.eq('company_id', companyId)
.is('journal_entry_id', null)
.eq('is_ignored', false)
.gte('date', fromDate)
.lte('date', toDate)
candQuery = scopeTransactionsToAccount(
candQuery,
cashAccount.id ?? undefined,
cashAccount.currency,
cashAccount.isPrimary,
)
const { data: candidates } = await candQuery
const matches = ((candidates ?? []) as Array<{ id: string; amount: number }>).filter((tx) => {
const amt = Number(tx.amount)
if (Math.abs(Math.abs(amt) - Math.abs(movement)) > VOUCHER_LINK_AMOUNT_TOLERANCE) return false
return Math.sign(amt) === Math.sign(movement)
})
// Two same-amount unbooked lines near the same date → don't guess.
if (matches.length !== 1) return null
const transactionId = matches[0].id
// 5. Reconcile via the exact path Bankavstämning uses (manualLink re-validates
// posted status, the cash-account line, and the not-already-linked guard,
// then sets journal_entry_id + reconciliation_method + is_business). No new
// journal entry is created.
const linkResult = await manualLink(
supabase,
companyId,
transactionId,
journalEntryId,
userId,
accountNumber,
)
if (!linkResult.success) return null
// Tag the transaction with the (supplier) invoice for traceability + parity
// with the transactions-side match. is_business is already set by manualLink,
// so the row has already dropped out of the inbox regardless of this update.
const tag: Record<string, unknown> = { potential_invoice_id: null }
if (options.invoiceId) tag.invoice_id = options.invoiceId
if (options.supplierInvoiceId) {
tag.supplier_invoice_id = options.supplierInvoiceId
tag.potential_supplier_invoice_id = null
}
if (Object.keys(tag).length > 1) {
await supabase
.from('transactions')
.update(tag)
.eq('id', transactionId)
.eq('company_id', companyId)
}
logMatchEvent(supabase, userId, transactionId, 'linked_to_existing_voucher', {
invoiceId: options.invoiceId,
supplierInvoiceId: options.supplierInvoiceId,
matchMethod: 'invoice_voucher_link',
newState: {
journal_entry_id: journalEntryId,
reconciliation_method: 'manual',
},
})
return { linkedTransactionId: transactionId }
}
// ============================================================
// Helpers
// ============================================================
@@ -128,7 +128,7 @@ describe.skipIf(!fixturesAvailable)('income statement — Bokio SIE regression (
const report = await generateIncomeStatement(supabase, 'company-1', 'period-2025')
// Bokio shows årets resultat = 221 316.27 kr on their V194 voucher.
// gnubok computes 220 906.27 (Revenue 370 314.68 − Expenses 149 408.41),
// Accounted computes 220 906.27 (Revenue 370 314.68 − Expenses 149 408.41),
// which is the mathematically exact figure. The 410 kr difference is
// öresutjämning/rounding that Bokio absorbed into V194 itself.
expect(report.net_result).toBeCloseTo(220_906.27, 2)
+1 -1
View File
@@ -526,7 +526,7 @@ interface SieSourceManifestEntry {
/**
* Copy raw imported SIE files from the `sie-files` storage bucket into the
* archive under `sie/original/`. Preserves the byte-identical source that the
* user uploaded (vs the `sie/<period>.se` files which gnubok re-generates from
* user uploaded (vs the `sie/<period>.se` files which Accounted re-generates from
* the current journal entries).
*
* `sie/imports.json` and `sie/account_mappings.json` are written regardless of
+1 -1
View File
@@ -443,7 +443,7 @@ describe('generateAGIXml — Frånvarouppgift', () => {
expect(closeRoot).toBeGreaterThan(firstFranvaro)
})
it('emits FK820/824/826 absence-removal and percent fields not at all (gnubok always sends timmar)', () => {
it('emits FK820/824/826 absence-removal and percent fields not at all (Accounted always sends timmar)', () => {
const xml = generateAGIXml(company, employeesWithAbsence, totals)
expect(xml).not.toContain('faltkod="820"')
expect(xml).not.toContain('faltkod="824"')
+1 -1
View File
@@ -28,7 +28,7 @@ import { decryptPersonnummer } from '../personnummer'
* FORALDRAPENNING for parental leave) — the borttag flow is not used.
* - Hours emitted via FranvaroTimmarTFP (FK825) for VAB or FranvaroTimmarFP
* (FK827) for parental. The procent variants (824/826) are not used —
* gnubok tracks hours, not percent.
* Accounted tracks hours, not percent.
* - FranvaroSpecifikationsnummer is persisted on salary_absence_days
* (column franvaro_specifikationsnummer; assigned by DB trigger on
* INSERT, never re-numbered). Skatteverket replaces a Frånvarouppgift
+1 -1
View File
@@ -198,7 +198,7 @@ export function deriveAbsenceLineItems(input: DeriveInput): DeriveResult {
// period count. An alternative reading is that cap-suppressed periods
// shouldn't count toward future windows (only periods that actually
// had karens deducted). That requires persisting per-period karens-
// deduction state, which gnubok doesn't yet do. The period-count
// deduction state, which Accounted doesn't yet do. The period-count
// reading can over-suppress karens for an employee who hits the cap
// repeatedly — softer error than the opposite.
//
+2 -2
View File
@@ -1,7 +1,7 @@
import type { VatPeriodType } from '@/types'
/**
* Convert a gnubok org_number to Skatteverket's 12-digit "redovisare" format.
* Convert a Accounted org_number to Skatteverket's 12-digit "redovisare" format.
*
* Rules:
* - Organisationsnummer (10 digits, e.g. 5020000013): prefix with "16" → 165020000013
@@ -30,7 +30,7 @@ export function formatRedovisare(
}
/**
* Convert gnubok period parameters to Skatteverket's YYYYMM format.
* Convert Accounted period parameters to Skatteverket's YYYYMM format.
*
* Skatteverket expects the last month of the period.
* - monthly period 3, year 2025 → "202503"