Files
accounted/lib/errors/structured-errors.ts
T
Jakob Wennberg abb9f5868c feat(api): Phase 4 PR-1 — AP world (suppliers + supplier-invoices) (#467)
* feat(api): Phase 4 PR-1 — AP world (suppliers + supplier-invoices)

First of two Phase 4 PRs. Ships the public v1 AP-side verticals end-to-end,
mirroring the Phase 2 AR pattern (customers + invoices).

ENDPOINTS (13)

Suppliers:
  GET    /suppliers                       — cursor list + filters
  GET    /suppliers/{id}                  — detail, ?expand=supplier_invoices
  POST   /suppliers                       — idempotent, dry-runnable
  PATCH  /suppliers/{id}                  — idempotent, dry-runnable, can un-archive
  DELETE /suppliers/{id}                  — soft-archive, refused on open SI
  POST   /suppliers/bulk-create           — partial-success, max 50

Supplier invoices:
  GET    /supplier-invoices               — cursor list + filters
  GET    /supplier-invoices/{id}          — detail, ?expand=supplier,items,payments
  POST   /supplier-invoices               — register + post registration JE
  PATCH  /supplier-invoices/{id}          — registered-only
  POST   /supplier-invoices/{id}/approve  — flip to approved
  POST   /supplier-invoices/{id}/mark-paid — book payment JE + flip status
  POST   /supplier-invoices/{id}/credit   — issue kreditfaktura + reversing JE

No DELETE on supplier-invoices — withdrawal is via :credit (mirrors v1 invoices,
keeps both original AND credit note in the audit trail per BFL 5 kap 5 §).

STRICT-MODE V1

Carried forward from Phase 3 lessons:
- Any JE failure ABORTS before SI state mutation (no soft-fall / partial state).
  Applies to register, mark-paid, and credit.
- checkPeriodLock() pre-check before every JE-emitting write — returns
  structured PERIOD_LOCKED / SI_PAID_PERIOD_LOCKED / SI_CREDIT_PERIOD_LOCKED
  instead of letting the DB trigger surface a generic 500.
- CAS-race orphan handling in mark-paid: if the SI status flips between
  pre-flight and our update, the just-posted payment JE is stornoed via
  reverseEntry() rather than left dangling (BFL 5 kap 5 §).
- Math.round monetary throughout. Half-öre epsilon on remaining_amount==0.

SCHEMA MIGRATION

`20260513150000_archived_at_for_customers_and_suppliers.sql`:
  - Adds suppliers.archived_at (new — required for the soft-archive flow).
  - Adds customers.archived_at + customers.vat_number_validated_at —
    retroactively. The Phase 2 v1 customer routes (PR #451 / #452 / #460)
    already reference both columns but no prior migration installed them in
    production. This commit fixes that latent bug while we have the
    migration open.
  - Partial indexes on (company_id, created_at) WHERE archived_at IS NULL
    keep the default-active list path cheap.
  - is_active (legacy boolean) preserved on suppliers; v1 archive sets both
    archived_at = now() AND is_active = false, un-archive flips both back
    so the dashboard's "show only active" filters stay intact.

NEW ERROR CODES

  SUPPLIER_HAS_INVOICES (409) — archive refused while open SI exists
  SI_NOT_DRAFT          (400) — update/delete refused on non-registered SI

GDPR ART.5(1)(c) DEFENSE-IN-DEPTH

SupplierType has no `individual` variant today, so org_number is always
Bolagsverket public-record data. The list endpoint still has the masking
hook (empty INDIVIDUAL_TYPES set) so a future natural-person supplier type
becomes a one-line change. Duplicate-org_number error responses NEVER echo
the submitted value — symmetric with customers.

SCOPES

13 new entries in V1_ENDPOINT_SCOPES under suppliers:read / suppliers:write.

TESTS

36 new integration cases across 2 suites:
  - suppliers: list (incl. filter), get (incl. 404), create (happy + 23505 +
    dry-run + missing-idempotency), patch (happy + empty body), delete
    (archive + open-invoice refusal), bulk-create (partial-success + 501)
  - supplier-invoices: list, get (incl. 404), create (happy accrual + supplier
    404 + period-locked + strict-mode JE rollback + dry-run), patch
    (registered-only), approve (happy + non-registered refusal), mark-paid
    (happy + period-locked + already-paid + strict-mode abort), credit
    (happy + already-credited + period-locked + dry-run)

Full suite green: 3333 passing (237 files). Build + lint clean.

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

* fix(api): PR #467 round-2 — Greptile P1/P2 fixes

Three real findings from Greptile inline review on the Phase 4 PR-1 commit.

P1 — mark-paid: storno orphan JE when SI update fails.
  When the `.update()` after the JE post returned an `updateErr`, the route
  logged + returned SI_PAID_FAILED without reversing the just-posted payment
  JE. The CAS-race branch immediately below already proves journalEntryId is
  in scope and reverseEntry takes it directly — the original comment about
  "requires fetching the entry first" was wrong. Now both error branches
  (the `updateErr` DB-failure path and the `!updated` CAS-race path) storno
  via reverseEntry before returning, keeping the AP ledger consistent
  (BFL 5 kap 5 §). Storno failure itself logs loudly and the error envelope
  surfaces the journal_entry_id so manual reconciliation has a starting
  point.

P1 — credit + register: capture JE link-update result, storno on failure.
  Both supplier-invoices/route.ts (register) and supplier-invoices/[id]/
  credit/route.ts back-fill registration_journal_entry_id on the freshly-
  inserted SI/credit-note row, but were dropping the await result. A
  transient DB error there silently left the row with registration_-
  journal_entry_id=null even though the JE was live on the books — the POST
  response looked correct (it returned the JE id from the local variable)
  but every subsequent GET /supplier-invoices/{id} showed null. Both paths
  now capture the link-update error, storno the orphan JE via reverseEntry,
  then roll back the SI/credit-note row before returning SI_CREATE_FAILED
  / SI_CREDIT_FAILED with step='*_link'. Strict-mode atomicity restored.

P2 — mark-paid: dry-run paid_at format alignment.
  Dry-run preview set `paid_at: paymentDate` (YYYY-MM-DD), but the live
  `.update()` writes `new Date().toISOString()` (full UTC timestamp). A
  caller validating both responses against the same regex would have been
  caught by the mismatch. Dry-run now mirrors the live shape.

P2 — ensureInitialized() finding dismissed as a false positive:
  lib/api/v1/with-api-v1.ts:52 already calls ensureInitialized() at module
  load. Every v1 route imports withApiV1 from that module, so the side
  effect runs on first import and caches. No existing v1 route (customers,
  invoices, transactions) imports ensureInitialized() directly — the
  pattern has been consistent across Phases 1-3 and the AP-world routes
  follow it.

Tests + build green: 3333 passing across 237 files, AP suite 36/36.

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

* fix(api): PR #467 round-3 — compliance swarm + swedish-compliance fixes

Both bots re-ran and converged on a set of substantive findings. Seven real
issues addressed; several recurring false positives + architectural
deferrals documented inline.

REAL FIXES (7)

1. credit: `remaining_amount` calc was nonsensical.
   `Math.max(0, remaining_amount - total)` was always ≤ 0 (since
   remaining ≤ total), forcing status to 'credited' regardless of paid
   state — but only via the clamp, not the logic. Both swedish-compliance
   and Compliance Swarm (OWASP V2.3 + SOC 2 PI1.3) caught this. A
   kreditfaktura nullifies the AP obligation on the original (BFL 5 kap
   5 §); refunds of already-paid amounts get a separate transaction.
   `remaining_amount: 0` and `status: 'credited'` unconditionally.

2. supplier-invoices register: VAT rate whitelist.
   `computeItemsAndTotals` accepted any float for `vat_rate`, silently
   booking an unrecognised rate into the registration JE → momsdeklaration
   Ruta 48 + INK2R. Now rejects with VALIDATION_ERROR (allowed_rates
   echoed) unless the rate is in `{0, 0.06, 0.12, 0.25}` (ML 2 kap 1 §).

3. mark-paid: `exchange_rate_difference` is required for non-SEK accrual.
   The pitfall docs warned about this but the code didn't enforce it. Without
   it the payment JE doesn't book the FX delta to 3960/7960 and AP carries
   a stranded 2440 balance after the bank line clears. Enforces with field-
   level VALIDATION_ERROR; pass `exchange_rate_difference: 0` if there's
   no rate movement.

4. suppliers PATCH: refuse on archived suppliers (BFL 7 kap 1 §).
   An archived supplier's name/address backs historical verifikationer; a
   post-archive PATCH would silently corrupt 7-year-retained räkenskaps-
   information. The handler now fetches the current row, refuses identifying-
   field updates when `archived_at IS NOT NULL`, and only permits the
   un-archive PATCH (`archived_at: null`).

5. supplier-invoices register: smart vat_treatment / reverse_charge default.
   The previous default of `'standard_25'` regardless of supplier_type left
   EU/non-EU supplier rows with metadata that didn't match the actual booking
   path (which uses `reverse_charge`). Now derives both fields from
   `supplier.supplier_type` when the caller omits them: foreign suppliers
   default to `reverse_charge: true` + `vat_treatment: 'reverse_charge'`.
   Explicit body values still win.

6. reverseEntry: static import (SOC 2 CC8.1).
   Replaced the three dynamic `await import('@/lib/bookkeeping/engine')`
   calls in orphan-storno error branches with a top-level static import.
   The dependency is now visible to SCA / tree-shake / static analysis.

7. Add `userId: ctx.userId` to every storno-failure log context (OWASP
   V16.1). The CAS-race + linkErr branches now consistently include the
   actor identity for security-relevant audit events.

TESTS (+6 new)

  - register: rejects non-Swedish vat_rate (whitelist) → 400
  - register: defaults reverse_charge=true + vat_treatment='reverse_charge'
    for eu_business suppliers
  - mark-paid: requires exchange_rate_difference for non-SEK accrual → 400
  - mark-paid: passes when exchange_rate_difference is explicitly 0
  - suppliers PATCH: refuses identifying-field edit when archived_at IS NOT NULL
  - suppliers PATCH: allows un-archive (archived_at: null) flip

AP suite 42/42 (was 36). Full suite 3339/3339 green (was 3333).

DISMISSED WITH RATIONALE

- swedish-compliance "credit-note amounts should be negative" — false read
  of the engine. `createSupplierCreditNoteEntry` calls `Math.abs()` on
  item amounts (line 421) and posts a reversing JE; the SI row carries
  positive amounts + `is_credit_note=true` as a deliberate data-model
  decision. Negating would break parity with the dashboard and the
  internal AP-ledger reporting.

- OWASP V8.2.1 cross-tenant via path — recurring false positive across
  Phases 2-4. `withApiV1` (line ~340-350) verifies `company_members`
  membership BEFORE setting `ctx.companyId` from the URL.

- OWASP V8.2.1 supplier_invoice_items company_id filter in
  rollbackCreditNote — the table has no `company_id` column;
  cross-tenant protection comes from RLS + the parent
  supplier_invoice_id scoping.

- OWASP V4.5 PATCH allowlist schema-derivation — known architectural
  deferral; centralising the field list against a Zod `.pick()` is a
  separate refactor.

- GDPR Art.5(1)(f) log/event field identifiers — RoPA / log-pseudonymisation
  is an org-wide privacy-eng concern, not a per-route fix.

- ISO 27001 A.8.15/A.8.16 non-blocking inserts — `supplier_invoice_payments`
  insert + event emit failures stay at warn-level for v1 to mirror the
  dashboard internal route. Promoting to error escalations + DLQ is a
  cross-cutting reliability project, not a route patch.

- SOC 2 CC6.3 segregation-of-duties — v1's API-key scope IS the boundary
  by design. Role-based separation between register / approve / pay is a
  v1.x feature, not a v1 surface bug.

- swedish-compliance reverse-charge gating in credit — the engine
  (`createSupplierCreditNoteEntry`) already gates the 2647/2645 reversal
  on `creditNote.reverse_charge` (line 437). Mirrors the registration
  engine.

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

* fix(api): PR #467 round-4 — BFL 5 kap 5 § + remaining compliance fixes

Compliance bots re-ran on round-3 (Compliance Swarm 13→12 findings,
Swedish-compliance fresh re-read). Five real issues addressed; the rest
are recurring false positives or architectural deferrals carried over
from earlier rounds.

REAL FIXES (5)

1. mark-paid: future payment_date rejected at the schema layer.
   BFL 5 kap 2 § requires bokföring to follow real cash movement;
   payment_date > today is a scheduling artefact, not an affärshändelse.
   Returns 400 VALIDATION_ERROR before the JE engine runs.

2. credit: drop user_id from SI_FULL_COLUMNS (GDPR Art.25).
   The original SI's `user_id` (its historical creator) is never used
   in the credit flow — the new credit-note row uses ctx.userId (the
   actor performing the credit). Don't fetch what you don't need.
   Also drops company_id from the select since it's already filtered.

3. supplier-invoices register: reverse-charge cross-field VAT check.
   For reverse-charge invoices the Swedish supplier doesn't charge VAT,
   the buyer self-assesses (ML 1 kap 2§ p.4b / 16 kap 6 § / 16 kap 13 §).
   If `reverse_charge=true` and ANY item has `vat_rate != 0`, return
   VALIDATION_ERROR — otherwise the engine would book ingående moms in
   Ruta 30 / 48 (BAS 2614 / 2645 / 2641) for an invoice that has no VAT
   to deduct.

4. rollbackSupplierInvoice + rollbackCreditNote: soft-mark, not delete.
   BFL 5 kap 5 § — rättelse av bokföringspost måste vara dokumenterad
   så att både den ursprungliga och den korrigerade noteringen är
   synliga. Hard-deleting the SI row on a mid-write failure destroys
   räkenskapsinformation even when the JE side (if any) is preserved
   via storno. Both rollback paths now UPDATE status='reversed' +
   reversed_at=now() — the SupplierInvoiceStatus enum already has
   'reversed' for exactly this case ("credit note whose journal entry
   was storno-reversed via Ångra kreditering" per the type comment).
   Trade-off: a retry with the same supplier_invoice_number will hit
   the unique-index conflict, so the caller picks a fresh number.

TESTS (+2 new)

  - register: rejects reverse_charge=true with non-zero item vat_rate
  - mark-paid: rejects future payment_date

Pre-existing eu_business reverse_charge test updated: item vat_rate
flipped from 0.25 → 0 to remain valid under the new cross-field check.

AP suite 44/44 (was 42). Full suite 3341/3341 green (was 3339).

DISMISSED (recurring or architectural)

- OWASP V8.2.1 cross-tenant via path — recurring false positive across
  Phase 2-4. withApiV1 verifies company_members membership BEFORE
  setting ctx.companyId from the URL.

- ISO A.8.3 approve-route TOCTOU — already mitigated. The UPDATE has
  `.eq('status', 'registered')` as a race guard; the pre-flight is for
  ergonomic error messages, not security.

- SOC 2 PI1.3 floating-point — project-wide convention is
  Math.round(x * 100) / 100 per CLAUDE.md. Diverging in one route would
  create a parity bug with the bookkeeping engine + dashboard. Settled.

- SOC 2 CC7.3 storno-failure alerting / ISO A.8.15 audit-log on success
  / SOC 2 CC6.1 test-fixture key / OWASP V2.2 status state-machine /
  V1.2.5 dynamic select-clause / V16 audit-log silent-failure / Art.25
  banking-field expand — all architectural deferrals that fit the
  webhook-hardening + scope-redesign work in Phase 6, not the v1 PR.

- swedish-compliance "credit-note original-number reference" — the
  `credited_invoice_id` FK is the structured back-reference; the
  document-rendering layer surfaces the original `supplier_invoice_-
  number` from there. Not a v1 surface bug.

- swedish-compliance "cash-basis credit-note vat_amount" — engine
  behaviour mirrored from the dashboard. Engine-layer audit, separate
  effort.

- swedish-compliance "active-supplier mutability broader than
  archived_at" — solving this requires snapshotting supplier identity
  onto each supplier_invoices row at registration (schema migration).
  Deeper architectural decision; tracking for Phase 4 follow-up
  alongside the journal-entries vertical.

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

* fix(api): PR #467 round-5 — strict schema + vat_treatment normalisation +
narrow BFL archive lock

Compliance bots re-ran on round-4. Most findings are recurring (V8.2.1
cross-tenant, PI1.3 floating-point, CC6.3 SoD) or the classic oscillation
pattern from the Phase 3 lessons: this round's Art.5(1)(f) flags userId in
storno error logs as PII exposure — but last round's V16.1 demanded I ADD
userId for audit attribution. Staying with audit attribution; the bot can
pick a side.

Three substantive findings addressed.

REAL FIXES (3)

1. V4.5 mass-assignment defense-in-depth on PATCH /supplier-invoices/{id}.
   The shared `UpdateSupplierInvoiceSchema` is consumed by the dashboard
   too, where Zod's default key-stripping is acceptable. The v1 route now
   wraps it in `V1PatchSupplierInvoiceSchema = UpdateSupplierInvoiceSchema
   .strict()` so any unknown key (e.g. `status`, `company_id`, `user_id`)
   returns 400 VALIDATION_ERROR instead of being silently dropped — even
   if the iteration allowlist downstream is later relaxed.

2. vat_treatment normalisation when reverse_charge resolves true.
   Caller could previously pass `vat_treatment: 'standard_25'` explicitly
   on an eu_business supplier, and the supplier-type-driven default would
   set `reverse_charge: true` while the metadata stayed as 'standard_25'.
   The engine books via the boolean (so JE is correct) but a downstream
   momsdeklaration / audit export reading `vat_treatment` would mis-
   classify. Resolution order is now: reverse_charge first, then
   vat_treatment forced to 'reverse_charge' if true; explicit overrides
   only stick when they agree with the resolved boolean.

3. Narrow archived-supplier PATCH lock to identifying fields only.
   The round-4 blanket lock on archived suppliers was too broad: BFL
   7 kap 1 § protects räkenskapsinformation — the fields verifikationer
   reference through the supplier join — but not internal notes or
   payment-config metadata. The check now only refuses PATCHes that touch
   {name, supplier_type, org_number, vat_number, address_*, banking_*}.
   Notes, default_payment_terms, default_expense_account, default_currency,
   email, and phone remain editable on archived rows.

TESTS (+3 new)

  - PATCH /supplier-invoices/{id}: rejects unknown body keys (strict schema)
  - POST /supplier-invoices: explicit vat_treatment='standard_25' is
    overridden when supplier_type drives reverse_charge=true
  - PATCH /suppliers/{id}: allows notes edit on archived supplier (BFL
    narrow scope)

AP suite 47/47 (was 44). Full suite 3344/3344 green (was 3341).

DISMISSED (recurring / settled / oscillating)

- OWASP V8.2.1 cross-tenant via path — recurring false positive 4 rounds
  running. withApiV1 verifies company_members membership BEFORE setting
  ctx.companyId from the URL.

- GDPR Art.5(1)(f) userId in error logs — direct contradiction of
  round-3's OWASP V16.1 finding which demanded userId be ADDED for audit
  attribution. Phase 3 lessons document this oscillation pattern
  ("swedish-compliance / compliance-swarm oscillate between rounds")
  and the correct response is to stay with the more security-positive
  position. Keeping userId on storno-failure logs for ledger-integrity
  attribution.

- SOC 2 CC6.3 segregation-of-duties — same as round-3. v1 design uses
  API-key scope as the boundary; role-based actor separation is Phase 6
  webhook + auth work.

- SOC 2 CC6.1 null-userId guard — redundant. withApiV1 short-circuits
  with 401 UNAUTHORIZED before invoking the handler when API-key
  validation fails (which is the only path that could leave ctx.userId
  unset).

- SOC 2 CC7.2 storno-failure alerting — architectural; webhook-bus +
  dead-letter is Phase 6 territory.

- SOC 2 / OWASP PI1.3 / V2.3 floating-point — project-wide convention
  per CLAUDE.md; the engine, dashboard, and v1 all use Math.round(x*100)/100.

- ISO 27001 A.8.33 test-fixture financial amounts — synthetic UUIDs +
  NODE_ENV=test guard already in place; "TEST-only" sentinel amounts
  would be cosmetic.

- OWASP V16.1 eventBus failure retry / DLQ — Phase 6 webhook hardening.

- swedish-compliance arrival_number gap risk — acknowledged in commit,
  bot itself says "no action required"; supplier_invoice_number retry
  behavior already in the rollback-comment doc.

- swedish-compliance vat_code cross-field — engine derives JE shape from
  `invoice.reverse_charge` (boolean), ignores item vat_code in the RC
  path. No surface-layer leak.

- swedish-compliance credit-note FX at today's rate — bot's reasoning
  inverted. The credit note REVERSES the original AP obligation; to net
  2440 to zero across the original-registration JE + credit-note JE, the
  SEK amounts MUST be copied from the original. FX rate at today's date
  applies at the bank-refund transaction side, not the credit-note
  registration.

- swedish-compliance KREDIT- prefix — dashboard parity. The
  `is_credit_note` + `credited_invoice_id` flags are the structured
  back-references; the prefix is cosmetic on the human-readable number.

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

* fix(api): PR #467 round-6 — overpayment guard + two-phase rollback +
SI_FULL_COLUMNS minimisation

Compliance Swarm trended down 12→9 findings, Swedish-compliance 6→5.
Three substantive items addressed; the rest are recurring false positives
or the userId-in-logs oscillation that round-5 already settled.

REAL FIXES (3)

1. mark-paid: reject overpayment up front (Compliance Swarm V2.3).
   Previously `Math.max(0, remaining - payment)` silently truncated an
   overpayment to a zero remaining_amount, while the JE engine booked the
   full payment_amount against 2440 — leaving an unaccounted overpayment
   on the AP ledger. Now refuses with VALIDATION_ERROR when
   `payment_amount > remaining_amount + 0.005` (half-öre tolerance for
   FX-rounding artefacts). Recovery hint points at :credit for
   over-billing and the transactions endpoints for refunds.

2. credit: trim SI_FULL_COLUMNS to fields actually read (Art.25(1)).
   The credit handler never reads notes, paid_at, payment_journal_entry_id,
   transaction_id, document_id, payment_reference, paid_amount,
   delivery_date, received_date, reversed_at, created_at, updated_at,
   exchange_rate_date, due_date — but the projection was fetching them
   all. SEK-conversion fields (subtotal_sek / vat_amount_sek / total_sek)
   ARE read (copied onto the credit-note row so the 2440 reversal nets),
   so they stay. Continues the round-4 user_id / company_id drop.

3. Two-phase soft-rollback (Swedish-compliance, BFL 5 kap 5 §).
   The bot caught a real misapplication: BFL 5:5 only kicks in once a
   verifikation has been COMMITTED. Pre-JE failures (items_insert,
   engine returning null because no fiscal period covers the date) are
   failed insertions, not bokföringsposter. Marking those rows
   `status='reversed'` with a null registration_journal_entry_id creates
   a dangling räkenskapsinformation entry that's harder to audit than a
   clean removal. Both rollback helpers now take a `journalEntryPosted`
   flag: pre-JE failures hard-delete (rows + items), post-JE failures
   keep the round-4 soft-mark + reversed_at behaviour. Call sites tagged
   per failure reason:

     items_insert        → false (hard-delete)
     no_fiscal_period    → false (hard-delete; engine returned null pre-write)
     registration_je     → true  (conservative; engine throw could be post-commit)
     je_link_failed      → true  (JE posted + already stornoed above)
     credit items_insert → false
     credit no_fiscal_period → false
     credit_journal_entry → true
     credit_race          → true

TESTS (+1 new)

  - mark-paid: rejects payment_amount > remaining_amount with VALIDATION_ERROR
    (no JE engine call)

AP suite 48/48 (was 47). Full suite 3345/3345 green (was 3344).

DISMISSED (with rationale)

- OWASP V8.2.1 cross-tenant via path — recurring across 5 rounds.
  withApiV1 verifies company_members membership BEFORE setting
  ctx.companyId from the URL. Fix-once decision in the wrapper, not a
  per-route concern.

- OWASP V4.5 strict schema (re-verification) — round-5 added
  V1PatchSupplierInvoiceSchema = UpdateSupplierInvoiceSchema.strict() +
  a test asserting {"status": "approved"} is rejected. The bot is
  re-flagging because it can't see the upstream schema in the diff;
  manually verified: UpdateSupplierInvoiceSchema only contains
  {supplier_invoice_number, invoice_date, due_date, delivery_date,
  payment_reference, notes}. No status / company_id / user_id field.

- GDPR Art.5(1)(f) userId in logs — same oscillation as round-4. Last
  round V16.1 demanded userId be ADDED for audit attribution; this
  round Art.5(1)(f) wants it REMOVED. Staying with audit attribution
  per the Phase 3 lessons doc's oscillation guidance.

- OWASP V16.1 / ISO A.8.15 / SOC 2 CC7.2 SIEM alerting on storno
  failure — architectural; Phase 6 webhook hardening.

- GDPR Art.25(2) supplier-expand banking fields default-on — same as
  round-4. A scope split (suppliers:read:sensitive) is a v1.x scope
  refactor, not a single-route patch.

- swedish-compliance VAT 0.06 date-aware validation (livsmedel 1 April
  2026) — needs livsmedel BAS classification (which BAS codes signal
  food) and date-aware lookup tables. Engine-layer concern; not
  achievable without engine changes. Documenting the 6% rate's temporary
  nature in the comment was the smaller fix already shipped in round-3.

- swedish-compliance SI_RESPONSE_COLUMNS missing reverse_charge — FALSE
  ALARM. `reverse_charge` IS present in the projection (line 264 of
  supplier-invoices/route.ts); the engine receives it correctly.

- swedish-compliance KREDIT- prefix — dashboard parity, dismissed
  rounds 3-5. The `is_credit_note` + `credited_invoice_id` flags are
  the structured back-references.

- swedish-compliance cash-basis credit-note ingående moms timing
  (ML 13 kap 27 §) — legitimate gap but engine-layer. The
  createSupplierCreditNoteEntry engine function handles accrual only;
  adding a cash-basis-already-paid branch would change engine
  semantics, divering from the dashboard. Tracking as a Phase 4 engine
  follow-up, not a v1 surface bug.

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

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 20:16:27 +02:00

1395 lines
51 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Canonical registry of structured error codes used by both REST routes and
* the MCP server.
*
* Each entry defines:
* - httpStatus: status returned by errorResponse() for this code
* - message_sv: Swedish user-facing message (consumed by toast)
* - message_en: English message for agents and developer logs
* - remediation: optional pointer to a fix (tool/resource/description)
*
* Adding a new code = add a row here. The error-code-matrix in
* `.claude/plans/for-all-of-those-mutable-sunset.md` lists the codes per
* operation; keep that document and this file in sync.
*
* Codes follow `<DOMAIN>_<OPERATION>_<CAUSE>` naming. Stable forever once
* shipped — agents pattern-match on them.
*/
export interface StructuredErrorRemediation {
description: string
tool?: string
args?: Record<string, unknown>
resource?: string
}
export interface StructuredErrorEntry {
httpStatus: number
message_sv: string
message_en: string
remediation?: StructuredErrorRemediation
}
// ─────────────────────────────────────────────────────────────────
// Generic / cross-cutting codes
// ─────────────────────────────────────────────────────────────────
const GENERIC: Record<string, StructuredErrorEntry> = {
UNKNOWN_ERROR: {
httpStatus: 500,
message_sv: 'Något gick fel. Försök igen.',
message_en: 'An unexpected error occurred.',
},
INTERNAL_ERROR: {
httpStatus: 500,
message_sv: 'Ett oväntat serverfel uppstod. Försök igen senare.',
message_en: 'Internal server error.',
},
VALIDATION_ERROR: {
httpStatus: 400,
message_sv: 'Förfrågan innehåller ogiltiga uppgifter.',
message_en: 'Validation error.',
},
UNAUTHORIZED: {
httpStatus: 401,
message_sv: 'Din session har gått ut. Logga in igen.',
message_en: 'Authentication required.',
},
MFA_REQUIRED: {
httpStatus: 403,
message_sv: 'Tvåstegsverifiering krävs för att utföra åtgärden.',
message_en: 'MFA verification required.',
},
FORBIDDEN: {
httpStatus: 403,
message_sv: 'Du har inte behörighet att utföra denna åtgärd.',
message_en: 'Insufficient permissions.',
},
NOT_FOUND: {
httpStatus: 404,
message_sv: 'Resursen kunde inte hittas.',
message_en: 'Resource not found.',
},
CONFLICT: {
httpStatus: 409,
message_sv: 'En konflikt uppstod. Ladda om sidan och försök igen.',
message_en: 'Conflict.',
},
RATE_LIMITED: {
httpStatus: 429,
message_sv: 'För många förfrågningar. Vänta en stund och försök igen.',
message_en: 'Rate limit exceeded.',
},
NOT_IMPLEMENTED: {
httpStatus: 501,
message_sv: 'Funktionen är inte implementerad ännu.',
message_en: 'This feature is accepted by the schema but not yet implemented.',
},
COMPANY_CONTEXT_MISSING: {
httpStatus: 400,
message_sv: 'Ingen aktiv företagskontext. Välj ett företag och försök igen.',
message_en: 'No active company context resolved for the request.',
},
IDEMPOTENCY_KEY_REUSE: {
httpStatus: 409,
message_sv: 'Idempotensnyckeln har redan använts med en annan begäran.',
message_en: 'Idempotency key was previously used with a different request body.',
remediation: {
description:
'Use a fresh UUID for a new operation, or send the original request body to replay.',
},
},
INSUFFICIENT_SCOPE: {
httpStatus: 403,
message_sv: 'API-nyckeln saknar behörighet för denna åtgärd.',
message_en: 'The current API key does not have the required scope.',
remediation: {
description:
'Mint a new key with the missing scope or grant it through the API key settings.',
resource: 'gnubok://capabilities',
},
},
}
// ─────────────────────────────────────────────────────────────────
// Bookkeeping engine codes (already used by lib/bookkeeping/errors.ts)
// ─────────────────────────────────────────────────────────────────
const BOOKKEEPING: Record<string, StructuredErrorEntry> = {
ACCOUNTS_NOT_IN_CHART: {
httpStatus: 400,
message_sv: 'Konton saknas i kontoplanen.',
message_en: 'One or more BAS accounts are not active in the chart of accounts.',
remediation: {
description:
'Activate the missing accounts via bookkeeping settings, or use a different category.',
resource: 'gnubok://chart-of-accounts',
},
},
JOURNAL_ENTRY_NOT_BALANCED: {
httpStatus: 400,
message_sv: 'Verifikationen balanserar inte.',
message_en: 'Debits and credits do not match.',
remediation: {
description: 'Recalculate the lines so totals are equal before retrying.',
},
},
FISCAL_PERIOD_NOT_FOUND: {
httpStatus: 404,
message_sv: 'Räkenskapsperioden kunde inte hittas.',
message_en: 'No fiscal period covers the entry date.',
remediation: {
description: 'Create or extend the relevant fiscal period before retrying.',
resource: 'gnubok://period/active',
},
},
ENTRY_DATE_OUTSIDE_FISCAL_PERIOD: {
httpStatus: 400,
message_sv: 'Datumet ligger utanför det valda räkenskapsåret.',
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',
},
},
JOURNAL_ENTRY_NOT_FOUND: {
httpStatus: 404,
message_sv: 'Verifikationen kunde inte hittas.',
message_en: 'Journal entry not found.',
},
CANNOT_REVERSE_NON_POSTED: {
httpStatus: 400,
message_sv: 'Endast bokförda verifikationer kan stornas.',
message_en: 'Only posted entries can be reversed.',
},
CANNOT_CORRECT_NON_POSTED: {
httpStatus: 400,
message_sv: 'Endast bokförda verifikationer kan rättas.',
message_en: 'Only posted entries can be corrected.',
},
ENTRY_ALREADY_REVERSED: {
httpStatus: 409,
message_sv:
'Verifikationen har redan stornats av en annan användare. Ladda om sidan och försök igen.',
message_en: 'Entry was already reversed by a concurrent operation.',
},
CURRENCY_REVALUATION_ALREADY_EXISTS: {
httpStatus: 409,
message_sv: 'En valutaomvärdering finns redan för denna period.',
message_en: 'Currency revaluation already exists for this period.',
},
INVALID_MAPPING_RESULT: {
httpStatus: 400,
message_sv: 'Kontering saknas för transaktionen. Kontrollera bokföringsreglerna.',
message_en: 'Mapping rules produced an invalid debit/credit account pair.',
},
BOOKKEEPING_DATABASE_ERROR: {
httpStatus: 500,
message_sv: 'Verifikationen kunde inte sparas. Försök igen.',
message_en: 'Bookkeeping database operation failed.',
},
PERIOD_LOCKED: {
httpStatus: 400,
message_sv: 'Bokföringen är låst för denna period.',
message_en: 'Period is locked or closed; entries cannot be added.',
},
PERIOD_NOT_LOCKED: {
httpStatus: 400,
message_sv: 'Perioden måste först låsas innan den kan stängas.',
message_en: 'Period must be locked before it can be closed.',
remediation: {
description: 'Call gnubok_lock_period before closing.',
tool: 'gnubok_lock_period',
},
},
PERIOD_HAS_UNBOOKED_TRANSACTIONS: {
httpStatus: 400,
message_sv:
'Perioden innehåller okategoriserade affärstransaktioner. Bokför eller markera dem som privata innan låsning.',
message_en: 'The period contains uncategorized business transactions.',
remediation: {
description: 'Categorize or mark uncategorized transactions before locking.',
tool: 'gnubok_list_uncategorized_transactions',
},
},
YEAR_END_NOT_RUN: {
httpStatus: 400,
message_sv: 'Bokslutsåtgärder måste utföras innan perioden kan stängas.',
message_en: 'Year-end closing must be executed before the period can be closed.',
},
TRANSACTION_ALREADY_CATEGORIZED: {
httpStatus: 409,
message_sv:
'Transaktionen är redan bokförd. Ångra kategoriseringen om du vill ändra den.',
message_en: 'The transaction already has a journal entry.',
remediation: {
description:
'Use gnubok_uncategorize_transaction first if you need to recategorize.',
tool: 'gnubok_uncategorize_transaction',
},
},
INVOICE_ALREADY_SENT: {
httpStatus: 409,
message_sv: 'Fakturan har redan skickats eller betalats.',
message_en: 'The invoice is already sent or paid.',
},
}
// ─────────────────────────────────────────────────────────────────
// Wave 1: invoicing & transactions
// ─────────────────────────────────────────────────────────────────
const TRANSACTIONS: Record<string, StructuredErrorEntry> = {
TX_CATEGORIZE_TX_NOT_FOUND: {
httpStatus: 404,
message_sv: 'Transaktionen kunde inte hittas.',
message_en: 'Transaction not found.',
},
TX_CATEGORIZE_INVALID_ACCOUNT: {
httpStatus: 400,
message_sv: 'Det valda kontot finns inte i kontoplanen.',
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',
},
},
TX_CATEGORIZE_INVALID_TEMPLATE: {
httpStatus: 400,
message_sv: 'Bokföringsmallen är ogiltig eller passar inte din bolagsform.',
message_en: 'The supplied booking template is invalid or does not match the entity type.',
},
TX_CATEGORIZE_INVALID_MAPPING: {
httpStatus: 400,
message_sv: 'Konteringen saknar debet- eller kreditkonto.',
message_en: 'Mapping result is missing a debit or credit account.',
},
TX_CATEGORIZE_RACE: {
httpStatus: 409,
message_sv: 'Transaktionen kategoriserades av en annan förfrågan. Ladda om och försök igen.',
message_en: 'Transaction was already categorized by another request.',
},
TX_CATEGORIZE_SUGGEST_SI_MATCH: {
httpStatus: 409,
message_sv:
'Det finns en öppen leverantörsfaktura från samma leverantör med samma belopp. Matcha mot fakturan istället för att bokföra direkt på leverantörsskuldskontot — annars skapas en dubblerad verifikation som måste stornas (BFL 5 kap 5 §).',
message_en:
'An open supplier invoice from the same supplier matches this amount. Suggest matching to the invoice instead of a plain 244x categorization to avoid producing a duplicate verifikation (BFL 5 kap 5 §).',
remediation: {
description:
'Match the transaction via POST /api/transactions/{id}/match-supplier-invoice, or resend with confirm_no_match: true to keep the plain 244x categorization.',
},
},
TX_UNCATEGORIZE_NO_LINKED_ENTRY: {
httpStatus: 400,
message_sv: 'Transaktionen har ingen kopplad verifikation att stornera.',
message_en: 'Transaction has no linked journal entry to reverse.',
},
}
const MATCH_INVOICE: Record<string, StructuredErrorEntry> = {
MATCH_INVOICE_NOT_FOUND: {
httpStatus: 404,
message_sv: 'Fakturan kunde inte hittas.',
message_en: 'Invoice not found.',
},
MATCH_INVOICE_NOT_INCOME: {
httpStatus: 400,
message_sv: 'Endast intäktstransaktioner kan matchas mot kundfakturor.',
message_en: 'Only income transactions can be matched to customer invoices.',
},
MATCH_INVOICE_TX_ALREADY_LINKED: {
httpStatus: 400,
message_sv: 'Transaktionen är redan kopplad till en faktura.',
message_en: 'Transaction is already linked to an invoice.',
},
MATCH_INVOICE_NOT_OPEN: {
httpStatus: 400,
message_sv: 'Fakturan är inte i ett obetalt läge och kan inte matchas.',
message_en: 'Invoice is not in an unpaid state.',
},
MATCH_INVOICE_NOT_INVOICE_TYPE: {
httpStatus: 400,
message_sv: 'Endast fakturor kan matchas mot en transaktion. Proforma och följesedel saknar momsskyldighet.',
message_en: 'Only invoices may be matched to a transaction; proforma and delivery notes have no VAT obligation.',
},
MATCH_INVOICE_ALREADY_PAID: {
httpStatus: 409,
message_sv: 'Fakturan har redan slutbetalats av en annan förfrågan.',
message_en: 'Invoice has already been fully paid or is no longer matchable.',
},
MATCH_INVOICE_DUPLICATE_PAYMENT: {
httpStatus: 409,
message_sv: 'Den här transaktionen är redan matchad mot fakturan.',
message_en: 'This transaction is already matched to this invoice.',
},
MATCH_INVOICE_RECORD_PAYMENT_FAILED: {
httpStatus: 500,
message_sv: 'Kunde inte registrera fakturabetalningen.',
message_en: 'Failed to record invoice payment.',
},
MATCH_INVOICE_LINK_TX_FAILED: {
httpStatus: 500,
message_sv: 'Kunde inte koppla transaktionen till fakturan.',
message_en: 'Failed to link transaction to invoice.',
},
MATCH_INVOICE_PARTIAL: {
httpStatus: 200,
message_sv: 'Matchningen registrerades men verifikationen kunde inte skapas.',
message_en: 'Match recorded but the journal entry could not be created.',
},
}
const MATCH_SI: Record<string, StructuredErrorEntry> = {
MATCH_SI_NOT_FOUND: {
httpStatus: 404,
message_sv: 'Leverantörsfakturan kunde inte hittas.',
message_en: 'Supplier invoice not found.',
},
MATCH_SI_NOT_EXPENSE: {
httpStatus: 400,
message_sv: 'Endast utgiftstransaktioner kan matchas mot leverantörsfakturor.',
message_en: 'Only expense transactions can be matched to supplier invoices.',
},
MATCH_SI_TX_ALREADY_LINKED: {
httpStatus: 400,
message_sv: 'Transaktionen är redan kopplad till en leverantörsfaktura.',
message_en: 'Transaction is already linked to a supplier invoice.',
},
MATCH_SI_ALREADY_PAID: {
httpStatus: 400,
message_sv: 'Leverantörsfakturan är redan betald eller krediterad.',
message_en: 'Supplier invoice is already paid or credited.',
},
MATCH_SI_NOT_OPEN: {
httpStatus: 409,
message_sv: 'Leverantörsfakturan har redan slutbetalats av en annan förfrågan.',
message_en: 'Supplier invoice has already been fully paid or is no longer matchable.',
},
MATCH_SI_DUPLICATE_PAYMENT: {
httpStatus: 409,
message_sv: 'Den här transaktionen är redan matchad mot leverantörsfakturan.',
message_en: 'This transaction is already matched to this supplier invoice.',
},
MATCH_SI_RECORD_PAYMENT_FAILED: {
httpStatus: 500,
message_sv: 'Kunde inte registrera leverantörsfakturabetalningen.',
message_en: 'Failed to record supplier invoice payment.',
},
MATCH_SI_LINK_TX_FAILED: {
httpStatus: 500,
message_sv: 'Kunde inte koppla transaktionen till leverantörsfakturan.',
message_en: 'Failed to link transaction to supplier invoice.',
},
MATCH_SI_CASH_FX_UNSUPPORTED: {
httpStatus: 400,
message_sv:
'Kontantmetoden stödjer inte valutakursdifferenser. Byt till löpande bokföring eller bokför valutakursdifferensen manuellt.',
message_en:
'Cash accounting does not support exchange-rate differences. Switch to accrual or book the FX difference manually.',
},
TX_UNCATEGORIZE_NOT_BOOKED: {
httpStatus: 400,
message_sv: 'Transaktionen är inte bokförd. Det finns inget att av-kategorisera.',
message_en: 'Transaction has no journal entry — nothing to uncategorize.',
},
TX_UNCATEGORIZE_JE_NOT_POSTED: {
httpStatus: 400,
message_sv: 'Verifikationen är inte bokförd. Reversal kan inte utföras.',
message_en: 'Journal entry is not in posted status; reversal is not possible.',
},
TX_INGEST_INSERT_FAILED: {
httpStatus: 500,
message_sv: 'Transaktionerna kunde inte importeras.',
message_en: 'Transaction ingest failed.',
},
TX_BATCH_CATEGORIZE_EMPTY: {
httpStatus: 400,
message_sv: 'Batchen är tom.',
message_en: 'Batch is empty — pass at least one item.',
},
}
const INVOICE: Record<string, StructuredErrorEntry> = {
INVOICE_CUSTOMER_NOT_FOUND: {
httpStatus: 404,
message_sv: 'Kunden kunde inte hittas.',
message_en: 'Customer not found.',
},
INVOICE_CREATE_VAT_RULE_VIOLATION: {
httpStatus: 400,
message_sv: 'Momssatsen är inte tillåten för denna kundtyp.',
message_en: 'The VAT rate is not allowed for this customer type.',
},
INVOICE_CREATE_INSERT_FAILED: {
httpStatus: 500,
message_sv: 'Fakturan kunde inte sparas.',
message_en: 'Invoice insert failed.',
},
INVOICE_CREATE_ITEMS_FAILED: {
httpStatus: 500,
message_sv: 'Fakturaraderna kunde inte sparas.',
message_en: 'Invoice items insert failed.',
},
INVOICE_CREATE_NUMBER_ASSIGN_FAILED: {
httpStatus: 500,
message_sv: 'Kunde inte tilldela fakturanummer vid skapande.',
message_en: 'Failed to assign invoice number on create.',
},
INVOICE_CREDIT_ORIGINAL_NOT_FOUND: {
httpStatus: 404,
message_sv: 'Ursprungsfakturan kunde inte hittas.',
message_en: 'Original invoice not found.',
},
INVOICE_CREDIT_NOT_INVOICE: {
httpStatus: 400,
message_sv: 'Kreditfakturor kan endast skapas från riktiga fakturor.',
message_en: 'Credit notes can only be created from standard invoices.',
},
INVOICE_CREDIT_ALREADY_CREDITED: {
httpStatus: 400,
message_sv: 'Fakturan har redan krediterats.',
message_en: 'Invoice has already been credited.',
},
INVOICE_CREDIT_NOT_SENT: {
httpStatus: 400,
message_sv: 'Endast skickade, betalda eller förfallna fakturor kan krediteras.',
message_en: 'Only sent, paid, or overdue invoices can be credited.',
},
INVOICE_SEND_EMAIL_NOT_CONFIGURED: {
httpStatus: 503,
message_sv:
'E-posttjänsten är inte konfigurerad. Kontrollera att RESEND_API_KEY och RESEND_FROM_EMAIL är satta.',
message_en: 'Email service is not configured.',
remediation: {
description: 'Set RESEND_API_KEY and RESEND_FROM_EMAIL in the deployment environment.',
},
},
INVOICE_SEND_NO_CUSTOMER_EMAIL: {
httpStatus: 400,
message_sv: 'Kunden saknar e-postadress. Uppdatera kunduppgifterna först.',
message_en: 'Customer has no email address.',
remediation: { description: 'Add an email address on the customer record before sending.' },
},
INVOICE_SEND_COMPANY_SETTINGS_MISSING: {
httpStatus: 404,
message_sv: 'Företagsinställningar saknas.',
message_en: 'Company settings are missing.',
},
INVOICE_SEND_NUMBER_ASSIGN_FAILED: {
httpStatus: 500,
message_sv: 'Kunde inte tilldela fakturanummer.',
message_en: 'Failed to assign invoice number on send.',
},
INVOICE_SEND_PROVIDER_FAILED: {
httpStatus: 502,
message_sv: 'E-postleverantören kunde inte skicka meddelandet.',
message_en: 'The email provider could not deliver the message.',
},
INVOICE_SEND_PDF_RENDER_FAILED: {
httpStatus: 500,
message_sv:
'Fakturans PDF kunde inte skapas. Kontrollera fakturarader och kunduppgifter och försök igen.',
message_en: 'Failed to render invoice PDF before send; no invoice number was consumed.',
},
INVOICE_PDF_RENDER_FAILED: {
httpStatus: 500,
message_sv: 'Fakturans PDF kunde inte skapas.',
message_en: 'Invoice PDF rendering failed.',
},
INVOICE_SEND_PARTIAL: {
httpStatus: 200,
message_sv:
'Fakturan skickades men en efterföljande åtgärd misslyckades (verifikation eller PDF-bilaga).',
message_en: 'Invoice was sent but a follow-up step (journal entry or PDF) failed.',
},
INVOICE_SEND_CANCELLED: {
httpStatus: 400,
message_sv: 'Makulerade fakturor kan inte skickas. Skapa en ny faktura istället.',
message_en: 'Cancelled invoices cannot be sent; create a new invoice instead.',
},
INVOICE_PAID_NOT_FOUND: {
httpStatus: 404,
message_sv: 'Fakturan kunde inte hittas.',
message_en: 'Invoice not found.',
},
INVOICE_PAID_NOT_PAYABLE: {
httpStatus: 400,
message_sv: 'Fakturan kan inte markeras som betald i nuvarande status.',
message_en: 'Invoice is not in a payable status.',
},
INVOICE_PAID_LINES_UNBALANCED: {
httpStatus: 400,
message_sv: 'Verifikationsraderna är inte balanserade (debet ≠ kredit).',
message_en: 'Custom journal lines do not balance.',
},
INVOICE_PAID_NO_FISCAL_PERIOD: {
httpStatus: 400,
message_sv: 'Ingen öppen räkenskapsperiod för betalningsdatumet.',
message_en: 'No open fiscal period covers the payment date.',
},
INVOICE_PAID_RACE: {
httpStatus: 409,
message_sv: 'Fakturan har redan betalats av en annan förfrågan.',
message_en: 'Invoice was already paid by another request.',
},
INVOICE_PAID_BOOK_FAILED: {
httpStatus: 500,
message_sv: 'Kunde inte bokföra betalningen.',
message_en: 'Failed to create payment journal entry.',
},
INVOICE_DELETE_NOT_DRAFT: {
httpStatus: 400,
message_sv: 'Endast utkast kan tas bort. Bokförda fakturor måste krediteras istället.',
message_en: 'Only draft invoices can be deleted; non-drafts must be credited.',
remediation: {
description: 'Issue a credit note instead of deleting a posted invoice.',
},
},
INVOICE_UPDATE_NOT_DRAFT: {
httpStatus: 409,
message_sv: 'Endast utkast kan ändras. Bokförda fakturor är oföränderliga — utfärda en kreditfaktura istället.',
message_en: 'Only draft invoices can be updated. Issued invoices are immutable — issue a credit note instead.',
remediation: {
description: 'Issue a credit note via POST /invoices/{id}:credit and create a fresh invoice with the corrected details.',
},
},
INVOICE_CANCEL_RACE: {
httpStatus: 409,
message_sv: 'Fakturan ändrades samtidigt och kunde inte makuleras. Ladda om och försök igen.',
message_en: 'Invoice was modified concurrently and could not be cancelled. Reload and retry.',
},
}
const SUPPLIER_INVOICE: Record<string, StructuredErrorEntry> = {
SI_NOT_FOUND: {
httpStatus: 404,
message_sv: 'Leverantörsfakturan kunde inte hittas.',
message_en: 'Supplier invoice not found.',
},
SI_APPROVE_NOT_REGISTERED: {
httpStatus: 400,
message_sv: 'Endast registrerade fakturor kan godkännas.',
message_en: 'Only invoices in registered status can be approved.',
},
SI_APPROVE_UPDATE_FAILED: {
httpStatus: 500,
message_sv: 'Kunde inte godkänna leverantörsfakturan.',
message_en: 'Failed to update supplier invoice status to approved.',
},
}
// ─────────────────────────────────────────────────────────────────
// Wave 2: periods, year-end, reports
// ─────────────────────────────────────────────────────────────────
const PERIOD: Record<string, StructuredErrorEntry> = {
PERIOD_NOT_FOUND: {
httpStatus: 404,
message_sv: 'Räkenskapsperioden kunde inte hittas.',
message_en: 'Fiscal period not found.',
},
PERIOD_LOCK_FAILED: {
httpStatus: 400,
message_sv: 'Perioden kunde inte låsas.',
message_en: 'Failed to lock period.',
},
PERIOD_LOCK_HAS_DRAFTS: {
httpStatus: 400,
message_sv: 'Perioden innehåller verifikationsutkast som måste bokföras eller raderas innan låsning.',
message_en: 'Period contains draft journal entries.',
},
PERIOD_LOCK_ALREADY_LOCKED: {
httpStatus: 409,
message_sv: 'Perioden är redan låst.',
message_en: 'Period is already locked.',
},
}
const YEAR_END: Record<string, StructuredErrorEntry> = {
YEAR_END_PREVIEW_FAILED: {
httpStatus: 400,
message_sv: 'Bokslutsförhandsgranskningen misslyckades.',
message_en: 'Failed to preview year-end closing.',
},
YEAR_END_FAILED: {
httpStatus: 400,
message_sv: 'Bokslutet kunde inte verkställas.',
message_en: 'Failed to execute year-end closing.',
},
YEAR_END_PRIOR_PERIOD_OPEN: {
httpStatus: 400,
message_sv: 'En tidigare period är fortfarande öppen. Stäng den först.',
message_en: 'A prior fiscal period is still open.',
},
YEAR_END_UNBALANCED_TRIAL: {
httpStatus: 400,
message_sv: 'Resultaträkningens debet och kredit balanserar inte. Granska verifikationerna innan bokslut.',
message_en: 'Trial balance does not balance.',
},
}
const OPENING_BAL: Record<string, StructuredErrorEntry> = {
OPENING_BAL_PERIOD_NOT_FOUND: {
httpStatus: 404,
message_sv: 'Räkenskapsperioden kunde inte hittas.',
message_en: 'Fiscal period not found.',
},
}
const FX: Record<string, StructuredErrorEntry> = {
FX_PERIOD_NOT_FOUND: {
httpStatus: 404,
message_sv: 'Räkenskapsperioden kunde inte hittas.',
message_en: 'Fiscal period not found.',
},
FX_PERIOD_CLOSED: {
httpStatus: 400,
message_sv: 'Perioden är redan stängd. Valutaomvärdering kan inte köras.',
message_en: 'Period is already closed; currency revaluation cannot be run.',
},
FX_FAILED: {
httpStatus: 400,
message_sv: 'Valutaomvärderingen misslyckades.',
message_en: 'Currency revaluation failed.',
},
}
const REPORT: Record<string, StructuredErrorEntry> = {
REPORT_PERIOD_REQUIRED: {
httpStatus: 400,
message_sv: 'period_id krävs.',
message_en: 'period_id query parameter is required.',
},
REPORT_GENERATION_FAILED: {
httpStatus: 500,
message_sv: 'Rapporten kunde inte genereras.',
message_en: 'Failed to generate the report.',
},
}
const VAT_REPORT: Record<string, StructuredErrorEntry> = {
VAT_REPORT_MISSING_PARAMS: {
httpStatus: 400,
message_sv: 'periodType, year och period krävs.',
message_en: 'periodType, year and period query parameters are required.',
},
VAT_REPORT_INVALID_PERIOD_TYPE: {
httpStatus: 400,
message_sv: 'periodType måste vara monthly, quarterly eller yearly.',
message_en: 'periodType must be one of monthly, quarterly, yearly.',
},
VAT_REPORT_INVALID_YEAR: {
httpStatus: 400,
message_sv: 'year måste vara ett giltigt årtal mellan 2000 och 2100.',
message_en: 'year must be a number between 2000 and 2100.',
},
VAT_REPORT_INVALID_PERIOD: {
httpStatus: 400,
message_sv: 'period är ogiltig för vald periodtyp.',
message_en: 'period is invalid for the chosen period type.',
},
VAT_REPORT_GENERATION_FAILED: {
httpStatus: 500,
message_sv: 'Momsdeklarationen kunde inte beräknas.',
message_en: 'Failed to calculate VAT declaration.',
},
}
const SIE_EXPORT: Record<string, StructuredErrorEntry> = {
SIE_EXPORT_COMPANY_NOT_FOUND: {
httpStatus: 404,
message_sv: 'Företagsinställningar saknas — SIE-exporten kan inte skapas.',
message_en: 'Company settings missing; SIE export cannot be generated.',
},
SIE_EXPORT_FAILED: {
httpStatus: 500,
message_sv: 'SIE-exporten misslyckades.',
message_en: 'Failed to generate SIE export.',
},
}
const TAX_DECL: Record<string, StructuredErrorEntry> = {
TAX_DECL_GENERATION_FAILED: {
httpStatus: 500,
message_sv: 'Skattedeklarationen kunde inte genereras.',
message_en: 'Failed to generate tax declaration.',
},
}
// ─────────────────────────────────────────────────────────────────
// Wave 3: imports (SIE, bank-file, opening-balance)
// ─────────────────────────────────────────────────────────────────
const SIE_IMPORT: Record<string, StructuredErrorEntry> = {
SIE_PARSE_NO_FILE: {
httpStatus: 400,
message_sv: 'Ingen fil bifogad i förfrågan.',
message_en: 'No file attached to the request.',
},
SIE_PARSE_INVALID_TYPE: {
httpStatus: 400,
message_sv: 'Filtypen stöds inte. Ladda upp en fil med ändelsen .sie eller .se.',
message_en: 'Unsupported file type; upload a .sie or .se file.',
},
SIE_PARSE_FILE_TOO_LARGE: {
httpStatus: 400,
message_sv: 'Filen är för stor. Maxstorlek är 50 MB.',
message_en: 'File exceeds the 50 MB size limit.',
},
SIE_PARSE_EMPTY: {
httpStatus: 400,
message_sv: 'Filen är tom (0 bytes). Kontrollera exporten från bokföringsprogrammet.',
message_en: 'File is empty.',
},
SIE_PARSE_FAILED: {
httpStatus: 500,
message_sv: 'Kunde inte tolka SIE-filen. Filen kan vara skadad eller i ett format som inte stöds.',
message_en: 'Failed to parse the SIE file.',
},
SIE_PARSE_VALIDATION_FAILED: {
httpStatus: 400,
message_sv: 'SIE-filen innehåller valideringsfel som måste åtgärdas innan import.',
message_en: 'SIE file failed validation.',
},
SIE_DUPLICATE_FILE: {
httpStatus: 409,
message_sv: 'Den här filen har redan importerats.',
message_en: 'File has already been imported.',
},
SIE_DUPLICATE_PERIOD: {
httpStatus: 409,
message_sv: 'En SIE-import för ett överlappande räkenskapsår finns redan.',
message_en: 'An SIE import for an overlapping fiscal period already exists.',
},
SIE_IMPORT_UNMAPPED_ACCOUNTS: {
httpStatus: 400,
message_sv: 'Vissa konton saknar mappning. Gå tillbaka till kontomappningssteget och koppla alla konton.',
message_en: 'One or more accounts have no mapping target.',
remediation: { description: 'Map every source account to a BAS account before importing.' },
},
SIE_IMPORT_ACCOUNT_ACTIVATION_FAILED: {
httpStatus: 500,
message_sv: 'Kunde inte aktivera konton i kontoplanen. Kontrollera att kontona inte redan finns med andra inställningar.',
message_en: 'Failed to activate mapped accounts in the chart of accounts.',
},
SIE_IMPORT_FAILED: {
httpStatus: 400,
message_sv: 'Importen slutfördes med fel. Se detaljerna nedan.',
message_en: 'SIE import completed with errors.',
},
SIE_IMPORT_UNEXPECTED: {
httpStatus: 500,
message_sv: 'Importen avbröts oväntat. Ingen data har sparats.',
message_en: 'Unexpected error during SIE import; no data was committed.',
},
SIE_REPLACE_FAILED: {
httpStatus: 400,
message_sv: 'SIE-importen kunde inte ersättas.',
message_en: 'Failed to replace SIE import.',
},
}
const BANK_FILE: Record<string, StructuredErrorEntry> = {
BANK_FILE_NO_FILE: {
httpStatus: 400,
message_sv: 'Ingen fil bifogad i förfrågan.',
message_en: 'No file attached to the request.',
},
BANK_FILE_TOO_LARGE: {
httpStatus: 400,
message_sv: 'Filen är för stor. Maxstorlek är 10 MB.',
message_en: 'File exceeds the 10 MB size limit.',
},
BANK_FILE_DUPLICATE: {
httpStatus: 409,
message_sv: 'Den här filen har redan importerats.',
message_en: 'Bank file has already been imported.',
},
BANK_FILE_PARSE_FAILED: {
httpStatus: 500,
message_sv: 'Kunde inte tolka bankfilen.',
message_en: 'Failed to parse the bank file.',
},
BANK_FILE_NO_TRANSACTIONS: {
httpStatus: 400,
message_sv: 'Bankfilen innehåller inga transaktioner att importera.',
message_en: 'No transactions to import.',
},
BANK_FILE_IMPORT_RECORD_FAILED: {
httpStatus: 500,
message_sv: 'Kunde inte skapa importpost.',
message_en: 'Failed to create the bank file import record.',
},
BANK_FILE_EXECUTE_FAILED: {
httpStatus: 500,
message_sv: 'Bankfilsimporten misslyckades.',
message_en: 'Bank file import failed.',
},
}
const OPENING_BALANCE_IMPORT: Record<string, StructuredErrorEntry> = {
OB_NO_FILE: {
httpStatus: 400,
message_sv: 'Ingen fil bifogad.',
message_en: 'No file attached.',
},
OB_FILE_TOO_LARGE: {
httpStatus: 400,
message_sv: 'Filen är för stor. Maxstorlek är 10 MB.',
message_en: 'File exceeds the 10 MB size limit.',
},
OB_INVALID_FORMAT: {
httpStatus: 400,
message_sv: 'Filformatet stöds inte. Tillåtna format: .xlsx, .xls, .csv, .ods.',
message_en: 'Unsupported file format.',
},
OB_INVALID_COLUMN_OVERRIDES: {
httpStatus: 400,
message_sv: 'Ogiltig kolumnmappning.',
message_en: 'Invalid column overrides JSON.',
},
OB_PARSE_FAILED: {
httpStatus: 500,
message_sv: 'Kunde inte tolka filen.',
message_en: 'Failed to parse the opening balance file.',
},
OB_PERIOD_NOT_FOUND: {
httpStatus: 404,
message_sv: 'Räkenskapsperioden hittades inte.',
message_en: 'Fiscal period not found.',
},
OB_PERIOD_CLOSED: {
httpStatus: 400,
message_sv: 'Räkenskapsperioden är stängd.',
message_en: 'Fiscal period is closed.',
},
OB_PERIOD_LOCKED: {
httpStatus: 400,
message_sv: 'Räkenskapsperioden är låst.',
message_en: 'Fiscal period is locked.',
},
OB_PERIOD_ALREADY_HAS_BALANCES: {
httpStatus: 409,
message_sv: 'Räkenskapsperioden har redan ingående balanser.',
message_en: 'Fiscal period already has opening balances set.',
},
OB_TOO_FEW_LINES: {
httpStatus: 400,
message_sv: 'Minst två rader med belopp krävs.',
message_en: 'At least two lines with amounts are required.',
},
OB_PNL_ACCOUNT: {
httpStatus: 400,
message_sv: 'Resultatkonton (klass 3-8) kan inte användas i ingående balanser.',
message_en: 'Profit & loss accounts (class 3-8) are not allowed in opening balances.',
},
OB_UNBALANCED: {
httpStatus: 400,
message_sv: 'Debet och kredit balanserar inte.',
message_en: 'Opening balance debits and credits do not match.',
},
OB_ACCOUNT_ACTIVATION_FAILED: {
httpStatus: 500,
message_sv: 'Kunde inte aktivera konton i kontoplanen.',
message_en: 'Failed to activate accounts in the chart of accounts.',
},
OB_EXECUTE_FAILED: {
httpStatus: 500,
message_sv: 'Importen misslyckades.',
message_en: 'Opening balance import failed.',
},
}
const REGISTER_IMPORT: Record<string, StructuredErrorEntry> = {
REG_IMPORT_NO_FILE: {
httpStatus: 400,
message_sv: 'Ingen fil bifogad.',
message_en: 'No file attached.',
},
REG_IMPORT_FILE_TOO_LARGE: {
httpStatus: 400,
message_sv: 'Filen är för stor. Maxstorlek är 10 MB.',
message_en: 'File exceeds the 10 MB size limit.',
},
REG_IMPORT_INVALID_FORMAT: {
httpStatus: 400,
message_sv: 'Filformatet stöds inte. Tillåtna format: .xlsx, .xls, .csv, .ods.',
message_en: 'Unsupported file format.',
},
REG_IMPORT_INVALID_COLUMN_OVERRIDES: {
httpStatus: 400,
message_sv: 'Ogiltig kolumnmappning.',
message_en: 'Invalid column overrides JSON.',
},
REG_IMPORT_PARSE_FAILED: {
httpStatus: 500,
message_sv: 'Kunde inte tolka filen.',
message_en: 'Failed to parse the register file.',
},
REG_IMPORT_NO_ROWS: {
httpStatus: 400,
message_sv: 'Inga giltiga rader hittades i filen.',
message_en: 'No valid rows found in the file.',
},
REG_IMPORT_EXECUTE_FAILED: {
httpStatus: 500,
message_sv: 'Importen misslyckades.',
message_en: 'Register import failed.',
},
}
// ─────────────────────────────────────────────────────────────────
// Wave 3 tail: provider migration extension codes
// ─────────────────────────────────────────────────────────────────
const PROVIDER_MIGRATION: Record<string, StructuredErrorEntry> = {
PROVIDER_INVALID: {
httpStatus: 400,
message_sv: 'Okänd leverantör.',
message_en: 'Unknown provider.',
},
PROVIDER_CONSENT_NOT_READY: {
httpStatus: 400,
message_sv: 'Anslutningen är inte klar. Slutför inloggningen först.',
message_en: 'Provider consent is not ready; finish authentication first.',
},
PROVIDER_CONSENT_NOT_FOUND: {
httpStatus: 404,
message_sv: 'Anslutningen kunde inte hittas.',
message_en: 'Provider consent not found.',
},
PROVIDER_CONNECT_FAILED: {
httpStatus: 500,
message_sv: 'Kunde inte starta anslutningen till leverantören.',
message_en: 'Failed to start provider connection flow.',
},
PROVIDER_TOKEN_REQUIRED: {
httpStatus: 400,
message_sv: 'API-token krävs för den här leverantören.',
message_en: 'apiToken is required for this provider.',
},
PROVIDER_COMPANY_ID_REQUIRED: {
httpStatus: 400,
message_sv: 'companyId krävs för den här leverantören.',
message_en: 'companyId is required for this provider.',
},
PROVIDER_TOKEN_SUBMIT_FAILED: {
httpStatus: 500,
message_sv: 'Tokensubmissionen misslyckades.',
message_en: 'Failed to submit provider token.',
},
PROVIDER_PREVIEW_FAILED: {
httpStatus: 500,
message_sv: 'Förhandsgranskningen från leverantören misslyckades.',
message_en: 'Provider preview failed.',
},
PROVIDER_SIE_FETCH_FAILED: {
httpStatus: 502,
message_sv: 'Kunde inte hämta SIE-data från leverantören.',
message_en: 'Failed to fetch SIE data from the provider.',
},
PROVIDER_SIE_NO_YEARS: {
httpStatus: 404,
message_sv: 'Inga räkenskapsår 20242026 hittades hos leverantören.',
message_en: 'No fiscal years available for 20242026.',
},
PROVIDER_SIE_ONLY_FORTNOX: {
httpStatus: 400,
message_sv: 'SIE-export stöds för närvarande endast för Fortnox.',
message_en: 'SIE export is currently only supported for Fortnox.',
},
PROVIDER_MIGRATE_FAILED: {
httpStatus: 500,
message_sv: 'Migrationen från leverantören misslyckades.',
message_en: 'Provider migration failed.',
},
PROVIDER_DISCONNECT_FAILED: {
httpStatus: 500,
message_sv: 'Frånkoppling från leverantören misslyckades.',
message_en: 'Provider disconnect failed.',
},
PROVIDER_ACCEPT_FAILED: {
httpStatus: 500,
message_sv: 'Kunde inte slutföra anslutningen.',
message_en: 'Failed to accept consent.',
},
PROVIDER_STATUS_FAILED: {
httpStatus: 500,
message_sv: 'Kunde inte hämta status från leverantören.',
message_en: 'Failed to fetch provider status.',
},
}
// ─────────────────────────────────────────────────────────────────
// Wave 4: documents, masters, salary, company, API keys
// ─────────────────────────────────────────────────────────────────
const DOCUMENT: Record<string, StructuredErrorEntry> = {
DOC_UPLOAD_NO_FILE: {
httpStatus: 400,
message_sv: 'Ingen fil bifogad.',
message_en: 'No file attached.',
},
DOC_UPLOAD_TOO_LARGE: {
httpStatus: 400,
message_sv: 'Filen är för stor.',
message_en: 'Uploaded file exceeds the size limit.',
},
DOC_UPLOAD_UNSUPPORTED_TYPE: {
httpStatus: 400,
message_sv: 'Filtypen stöds inte.',
message_en: 'Unsupported file type.',
},
DOC_UPLOAD_STORAGE_FAILED: {
httpStatus: 500,
message_sv: 'Filen kunde inte sparas.',
message_en: 'Document storage failed.',
},
DOC_NOT_FOUND: {
httpStatus: 404,
message_sv: 'Dokumentet kunde inte hittas.',
message_en: 'Document not found.',
},
DOC_LINK_ENTRY_NOT_FOUND: {
httpStatus: 404,
message_sv: 'Verifikationen kunde inte hittas.',
message_en: 'Journal entry not found.',
},
DOC_LINK_ALREADY_LINKED: {
httpStatus: 409,
message_sv: 'Dokumentet är redan kopplat till en verifikation.',
message_en: 'Document is already linked to a journal entry.',
},
DOC_LINK_FAILED: {
httpStatus: 500,
message_sv: 'Kopplingen misslyckades.',
message_en: 'Failed to link document to journal entry.',
},
}
const CUSTOMER: Record<string, StructuredErrorEntry> = {
CUSTOMER_NOT_FOUND: {
httpStatus: 404,
message_sv: 'Kunden kunde inte hittas.',
message_en: 'Customer not found.',
},
CUSTOMER_DUPLICATE_ORG_NUMBER: {
httpStatus: 409,
message_sv: 'En kund med samma organisationsnummer finns redan.',
message_en: 'A customer with that organisation number already exists.',
},
CUSTOMER_CREATE_FAILED: {
httpStatus: 500,
message_sv: 'Kunden kunde inte skapas.',
message_en: 'Failed to create customer.',
},
CUSTOMER_UPDATE_FAILED: {
httpStatus: 500,
message_sv: 'Kunden kunde inte uppdateras.',
message_en: 'Failed to update customer.',
},
CUSTOMER_DELETE_FAILED: {
httpStatus: 500,
message_sv: 'Kunden kunde inte tas bort.',
message_en: 'Failed to delete customer.',
},
CUSTOMER_HAS_INVOICES: {
httpStatus: 409,
message_sv: 'Kunden har fakturor och kan inte tas bort.',
message_en: 'Customer cannot be deleted while invoices reference it.',
},
}
const SUPPLIER: Record<string, StructuredErrorEntry> = {
SUPPLIER_NOT_FOUND: {
httpStatus: 404,
message_sv: 'Leverantören kunde inte hittas.',
message_en: 'Supplier not found.',
},
SUPPLIER_DUPLICATE_ORG_NUMBER: {
httpStatus: 409,
message_sv: 'En leverantör med samma organisationsnummer finns redan.',
message_en: 'A supplier with that organisation number already exists.',
},
SUPPLIER_CREATE_FAILED: {
httpStatus: 500,
message_sv: 'Leverantören kunde inte skapas.',
message_en: 'Failed to create supplier.',
},
SUPPLIER_UPDATE_FAILED: {
httpStatus: 500,
message_sv: 'Leverantören kunde inte uppdateras.',
message_en: 'Failed to update supplier.',
},
SUPPLIER_DELETE_FAILED: {
httpStatus: 500,
message_sv: 'Leverantören kunde inte tas bort.',
message_en: 'Failed to delete supplier.',
},
// v1 archive refusal — leverantörsfakturor pointing at this supplier still
// need its name/address for BFL 7 kap audit. Issue credit notes first.
SUPPLIER_HAS_INVOICES: {
httpStatus: 409,
message_sv:
'Leverantören kan inte arkiveras eftersom det finns öppna leverantörsfakturor som refererar till den.',
message_en:
'Supplier cannot be archived while open supplier invoices reference it.',
remediation: {
description:
'Close (credit / mark paid) every open supplier invoice before archiving the supplier. The dashboard exposes the same blocker.',
},
},
// v1 strict-mode: update / delete only allowed on `registered` SIs (the
// SI analogue of `draft`). Mirrors the dashboard internal route.
SI_NOT_DRAFT: {
httpStatus: 400,
message_sv:
'Leverantörsfakturan är inte längre i status "registrerad" och kan därför inte uppdateras eller tas bort.',
message_en:
'Supplier invoice is not in `registered` status and cannot be updated or deleted.',
},
}
const SUPPLIER_INVOICE_WAVE4: Record<string, StructuredErrorEntry> = {
SI_CREATE_DUPLICATE_INVOICE_NUMBER: {
httpStatus: 409,
message_sv: 'En leverantörsfaktura med samma nummer finns redan.',
message_en: 'A supplier invoice with that number already exists.',
},
SI_CREATE_FAILED: {
httpStatus: 500,
message_sv: 'Leverantörsfakturan kunde inte skapas.',
message_en: 'Failed to create supplier invoice.',
},
SI_PAID_ALREADY: {
httpStatus: 409,
message_sv: 'Leverantörsfakturan är redan betald eller krediterad.',
message_en: 'Supplier invoice is already paid or credited.',
},
SI_PAID_NOT_PAYABLE: {
httpStatus: 400,
message_sv: 'Leverantörsfakturan kan inte markeras som betald i nuvarande status.',
message_en: 'Supplier invoice is not in a payable state.',
},
SI_PAID_PERIOD_LOCKED: {
httpStatus: 400,
message_sv: 'Bokföringen är låst. Betalningen kan inte registreras.',
message_en: 'Bookkeeping is locked; payment cannot be recorded.',
},
SI_PAID_FAILED: {
httpStatus: 500,
message_sv: 'Kunde inte registrera betalningen.',
message_en: 'Failed to record supplier invoice payment.',
},
SI_PAID_LIKELY_DUPLICATE: {
httpStatus: 409,
message_sv:
'Det finns redan en obokförd banktransaktion som kan vara denna betalning. Länka den istället, eller markera som betald ändå om du är säker.',
message_en:
'A likely-matching unlinked bank transaction was found for this supplier. Suggest linking it instead of creating a new payment entry.',
remediation: {
description:
'Match the candidate transaction via POST /api/transactions/{id}/match-supplier-invoice, or resend mark-paid with force: true to create the payment entry anyway.',
},
},
SI_CREDIT_ALREADY_CREDITED: {
httpStatus: 409,
message_sv: 'Leverantörsfakturan har redan krediterats.',
message_en: 'Supplier invoice has already been credited.',
},
SI_CREDIT_PERIOD_LOCKED: {
httpStatus: 400,
message_sv: 'Bokföringen är låst. Krediteringen kan inte skapas.',
message_en: 'Bookkeeping is locked; credit note cannot be created.',
},
SI_CREDIT_FAILED: {
httpStatus: 500,
message_sv: 'Kunde inte kreditera leverantörsfakturan.',
message_en: 'Failed to credit supplier invoice.',
},
}
const SALARY: Record<string, StructuredErrorEntry> = {
SALARY_RUN_NOT_FOUND: {
httpStatus: 404,
message_sv: 'Lönekörningen kunde inte hittas.',
message_en: 'Salary run not found.',
},
SALARY_RUN_NO_EMPLOYEES: {
httpStatus: 400,
message_sv: 'Inga aktiva anställda finns i företaget.',
message_en: 'No active employees in the company.',
},
SALARY_RUN_TAX_TABLE_MISSING: {
httpStatus: 400,
message_sv: 'Skattetabellen saknas för perioden. Importera skattetabellen först.',
message_en: 'Tax table is missing for the period.',
},
SALARY_RUN_PERIOD_LOCKED: {
httpStatus: 400,
message_sv: 'Lönekörningen kan inte göras i en låst period.',
message_en: 'Salary run cannot be processed in a locked period.',
},
SALARY_RUN_NOT_CALCULATED: {
httpStatus: 400,
message_sv: 'Lönekörningen måste beräknas innan bokföring.',
message_en: 'Salary run must be calculated before booking.',
},
SALARY_RUN_CREATE_FAILED: {
httpStatus: 500,
message_sv: 'Lönekörningen kunde inte skapas.',
message_en: 'Failed to create salary run.',
},
SALARY_RUN_CALCULATE_FAILED: {
httpStatus: 500,
message_sv: 'Lönekörningen kunde inte beräknas.',
message_en: 'Failed to calculate salary run.',
},
SALARY_RUN_BOOK_FAILED: {
httpStatus: 500,
message_sv: 'Lönekörningen kunde inte bokföras.',
message_en: 'Failed to book salary run.',
},
AGI_NO_SALARY_RUN: {
httpStatus: 400,
message_sv: 'Det finns ingen lönekörning för perioden.',
message_en: 'No salary run exists for the period.',
},
AGI_FSKATT_VERIFICATION_FAILED: {
httpStatus: 400,
message_sv: 'F-skattekontrollen misslyckades. Kontrollera leverantörens F-skatt.',
message_en: 'F-skatt verification failed.',
},
AGI_GENERATION_FAILED: {
httpStatus: 500,
message_sv: 'AGI-deklarationen kunde inte genereras.',
message_en: 'Failed to generate AGI declaration.',
},
}
const COMPANY: Record<string, StructuredErrorEntry> = {
COMPANY_CREATE_DUPLICATE_ORG_NUMBER: {
httpStatus: 409,
message_sv: 'Ett företag med samma organisationsnummer finns redan.',
message_en: 'A company with that organisation number already exists.',
},
COMPANY_CREATE_BAS_SEED_FAILED: {
httpStatus: 500,
message_sv: 'Kontoplanen kunde inte skapas. Försök igen.',
message_en: 'Failed to seed the chart of accounts.',
},
COMPANY_CREATE_FAILED: {
httpStatus: 500,
message_sv: 'Företaget kunde inte skapas.',
message_en: 'Failed to create company.',
},
}
const API_KEY: Record<string, StructuredErrorEntry> = {
API_KEY_SCOPE_INVALID: {
httpStatus: 400,
message_sv: 'En eller flera scopes är ogiltiga.',
message_en: 'One or more requested scopes are invalid.',
},
API_KEY_QUOTA_EXCEEDED: {
httpStatus: 429,
message_sv: 'Du har nått maxgränsen för antal API-nycklar.',
message_en: 'API key quota exceeded.',
},
API_KEY_CREATE_FAILED: {
httpStatus: 500,
message_sv: 'API-nyckeln kunde inte skapas.',
message_en: 'Failed to create API key.',
},
API_KEY_REVOKE_FAILED: {
httpStatus: 500,
message_sv: 'API-nyckeln kunde inte återkallas.',
message_en: 'Failed to revoke API key.',
},
API_KEY_NOT_FOUND: {
httpStatus: 404,
message_sv: 'API-nyckeln kunde inte hittas.',
message_en: 'API key not found.',
},
}
// ─────────────────────────────────────────────────────────────────
// Provider connection / external HTTP codes
// ─────────────────────────────────────────────────────────────────
const PROVIDER: Record<string, StructuredErrorEntry> = {
PROVIDER_AUTH_EXPIRED: {
httpStatus: 401,
message_sv: 'Anslutningen till leverantören har gått ut. Återanslut för att fortsätta.',
message_en: 'Provider authentication expired or refresh failed.',
},
PROVIDER_RATE_LIMITED: {
httpStatus: 429,
message_sv:
'Leverantören begränsar antalet anrop just nu. Vänta en stund och försök igen.',
message_en: 'Provider rate limit exceeded.',
},
PROVIDER_UNREACHABLE: {
httpStatus: 502,
message_sv: 'Leverantörens tjänst är inte tillgänglig just nu. Försök igen om en stund.',
message_en: 'Provider service is unreachable (network/DNS error).',
},
PROVIDER_UPSTREAM_ERROR: {
httpStatus: 502,
message_sv: 'Leverantören svarade med ett fel. Försök igen om en stund.',
message_en: 'Provider returned an upstream 5xx error.',
},
}
// ─────────────────────────────────────────────────────────────────
// Combined registry
// ─────────────────────────────────────────────────────────────────
const REGISTRY: Record<string, StructuredErrorEntry> = {
...GENERIC,
...BOOKKEEPING,
...TRANSACTIONS,
...MATCH_INVOICE,
...MATCH_SI,
...INVOICE,
...SUPPLIER_INVOICE,
...PERIOD,
...YEAR_END,
...OPENING_BAL,
...FX,
...REPORT,
...VAT_REPORT,
...SIE_EXPORT,
...TAX_DECL,
...SIE_IMPORT,
...BANK_FILE,
...OPENING_BALANCE_IMPORT,
...REGISTER_IMPORT,
...PROVIDER_MIGRATION,
...DOCUMENT,
...CUSTOMER,
...SUPPLIER,
...SUPPLIER_INVOICE_WAVE4,
...SALARY,
...COMPANY,
...API_KEY,
...PROVIDER,
}
export function getErrorEntry(code: string): StructuredErrorEntry | undefined {
return REGISTRY[code]
}
export function hasErrorEntry(code: string): boolean {
return code in REGISTRY
}
/**
* Test-only: returns all registered codes. Used by the unit test that asserts
* the matrix in the plan file stays in sync with this registry.
*/
export function listErrorCodes(): string[] {
return Object.keys(REGISTRY)
}