Files
accounted/lib/errors/structured-errors.ts
T
Jakob Wennberg fbf8348ca3 feat(api): Phase 5 PR-2 — payroll lifecycle (calculate, approve, mark-paid, book, generate-agi) (#489)
* feat(api): Phase 5 PR-2 — payroll lifecycle verbs (calculate, approve, mark-paid, book, generate-agi)

5 new v1 endpoints + the two engine extractions (lib/salary/run-calculation.ts
and lib/salary/agi/generate-declaration.ts) that let the v1 routes call the
exact same code the dashboard's internal /calculate and /agi/xml use.
Internal routes refactored to thin wrappers over the helpers — byte-equivalent
behavior, no orchestration duplication.

Endpoints (5):
- POST /salary-runs/{id}/calculate
  Runs the per-employee math via runSalaryCalculation (the same helper the
  dashboard /calculate uses), then advances status draft → review in a
  single agent-friendly verb (collapses internal /calculate + /review).
  Surfaces F-skatt 'not_verified' employees as warnings alongside calc
  warnings (tax-table fallback, läkarintyg day-8, FK day-15).
- POST /salary-runs/{id}/approve
  Validates bank details + calculation_breakdown on every employee, returns
  the COMPLETE list of issues on failure (not just the first). Optimistic-
  lock on status='review'. Emits salary_run.approved.
- POST /salary-runs/{id}/mark-paid
  Stamps paid_at + advances approved → paid. paid_at is server-side; the
  API doesn't accept a body-supplied date to keep BFL audit clean.
- POST /salary-runs/{id}/book  (highest-risk verb)
  Engine-touching. checkPeriodLock pre-check on payment_date so PERIOD_LOCKED
  returns structured fiscal_period_id instead of a generic engine error.
  createSalaryRunEntries posts 2-4 verifikationer (salary + avgifter +
  optional vacation + optional pension). Optimistic-lock status='paid' →
  'booked'. Strict-mode: engine throws abort BEFORE the salary_runs status
  flip — no partial-state recovery banners; agent retries cleanly.
  Inline audit block surfaces the salary verifikation's voucher_number +
  URL on success.
- POST /salary-runs/{id}/generate-agi
  Sync (sub-second). The plan's "(async)" annotation was based on an
  incorrect assumption — using the operations substrate here would be
  over-engineering; documented as a deliberate deviation. Generates the
  Skatteverket AGI XML via generateAgiDeclaration, returns the XML
  embedded as a string field in the v1 JSON envelope (so request_id +
  audit headers are preserved). Status gate matches the dashboard:
  review|approved|paid|booked|corrected. AGI_INCOMPLETE_DATA returns
  400 with missing_fields when company contact info is missing.

Engine extractions (both follow the same discriminated-union pattern):

  runSalaryCalculation(args) → { ok: true; run; warnings } | { ok: false; code; details?; status? }
  generateAgiDeclaration(args) → { ok: true; xml; agiDeclarationId; ... } | { ok: false; code; details?; status? }

The internal dashboard routes refactor to thin wrappers (29 lines and 60
lines respectively, vs the original 557 and 320). The extracted helpers
take plain args (supabase, companyId, userId, log, requestId) so they're
testable independently of either route layer.

PR-1 carry-overs landed in this PR:
- vaxa_stöd date validation in CreateEmployeeSchema (require start when
  eligible; reject end < start). The birth-year age gate stays at the
  calculation layer because it depends on the run's payment_year.
- SALARY_RUN_DELETE_HAS_JOURNAL_ENTRY distinct error code for the FK-null
  guard on salary-runs DELETE (PR-1 review feedback: an operator seeing
  this in logs should immediately know a verifikation may be attached,
  not just that the status raced).
- 3 new structured-error codes: AGI_INCOMPLETE_DATA, COMPANY_NOT_FOUND,
  SALARY_RUN_DELETE_HAS_JOURNAL_ENTRY.

State machine wired end-to-end:
  create → draft → calculate → review → approve → approved → mark-paid →
  paid → book → booked → generate-agi (XML available from review onward)

Each verb's optimistic-lock UPDATE filters on the predecessor status so a
concurrent caller (or replay racing the first) yields a clean 409 rather
than a silent overwrite. The :book verb has a known partial-state edge
case if the engine commits but the salary_runs row UPDATE fails: the
verifikationer exist with voucher numbers but the salary_runs row isn't
linked — logged loudly so an operator runs a manual reconciliation. This
matches the dashboard's existing behavior.

Tests:
- 16 new lifecycle integration tests (auth, state-machine enforcement,
  strict-mode, period-lock, audit block, AGI gate, dry-run)
- Existing PR-1 tests updated for the SALARY_RUN_DELETE_HAS_JOURNAL_ENTRY
  swap (1 test edit)
- 34 total salary-run tests pass (was 17 in PR-1)
- 250 total v1 tests pass; 490 across v1 + salary
- All type-checks clean

Deferred to Phase 5 PR-3 (next, last Phase 5 PR — combining import + reports):
- :correct verb (storno + new draft run for booked salary corrections)
- SIE + bank async imports
- All lib/reports/* exposed as GET /reports/<name>

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

* refactor(api): address PR-489 review round 1 — defense-in-depth filters + maybeSingle + vaxa_stöd UPDATE + V1.2.5 sanitisation

Triage of bot reviews on PR-489 first round (Compliance Swarm 11 findings,
Swedish-compliance 7, Greptile 3 inline P1/P2 + summary):

FIXED (real bugs):

- **Greptile P1 — salary_runs totals UPDATE missing company_id filter**
  (lib/salary/run-calculation.ts:586). The final UPDATE on salary_runs
  ran with only `.eq('id', id)` even though the surrounding code knows
  the company_id. RLS would have blocked a cross-tenant write, but
  CLAUDE.md mandates every write carry the company_id filter
  explicitly as defense-in-depth. Added .eq('company_id', companyId).

- **Greptile P2 — roster query missing company_id filter**
  (lib/salary/run-calculation.ts:116). Same pattern on the
  salary_run_employees SELECT. Added .eq('company_id', companyId).

- **Compliance Swarm V8.2.1 — approve route's roster query missing
  company_id filter** (app/api/v1/.../salary-runs/[id]/approve/route.ts).
  Same defense-in-depth rule. Added the explicit filter.

- **Greptile P2 — agi/generate-declaration.ts existing-AGI check
  uses .single()**. Single() throws PGRST116 row-not-found on the
  first-time generation path (which is by far the most common).
  maybeSingle() returns null cleanly. Swapped.

- **Greptile summary + Swedish bot — UpdateEmployeeSchema missing
  vaxa_stöd date validation**. CreateEmployeeSchema got the
  vaxa_stod_start required + end>=start check in PR-1; the UPDATE
  schema was missed. Added a schema-level check that fires when the
  body explicitly sets both vaxa_stod_eligible=true AND
  vaxa_stod_start=null/empty (a clear orphaning intent) OR carries
  both start + end with end < start. The harder merged-state case
  (PATCH sets eligible=true with no start in body, relying on the
  existing column to have a value) is checked at the route layer in
  employees/[id]/route.ts — it can see the merged state, the schema
  cannot.

- **OWASP V1.2.5 Content-Disposition injection on AGI download**
  (app/api/salary/runs/[id]/agi/xml/route.ts). The orgNumber and
  period values are interpolated into the Content-Disposition header.
  Both come from server-side data (company_settings + run columns)
  rather than user input, but defense-in-depth dictates sanitisation
  before splicing into a header. Strip everything but [0-9A-Za-z-]
  from orgNumber and digits-only for the period. Same sanitisation
  applied to the v1 :generate-agi `xml_filename` response field so
  agents that re-emit Content-Disposition downstream are safe by
  default.

DOCUMENTED (architectural floor / pre-existing dashboard behavior):

- **Concurrent :book engine-call race** (Greptile summary). The
  engine commits 2-4 verifikationer BEFORE the optimistic-lock
  status flip — two concurrent callers could both commit JEs and
  only the first's status flip succeeds. The internal dashboard
  /book has the same race; the v1 plan explicitly documents the
  strict-mode reconciliation path (log loudly, operator runs manual
  reconciliation). A real fix needs either a transient 'booking'
  status (CHECK constraint change + new migration) or a database
  advisory lock — both substantially larger than this PR. Tracked
  for a future hardening pass.

- **vaxa_stod → 'standard' AGI category mapping** (Swedish bot).
  The internal route had this same mapping; the extraction
  inherited it. vaxa_stod should likely map to the youth/reduced
  bracket. Engine-layer fix — out of v1 PR-2 scope, dashboard
  parity preserved.

- **AGI correction path overwrites corrects_agi_id null** (Swedish
  bot). Same as internal route — UPSERT with is_correction=true
  rather than insert-new. Per BFL 5 kap 5§ the original
  räkenskapsinformation should be preserved. Engine-layer concern.

- **AGI status gate allows review** (Swedish bot). Dashboard
  behavior; tightening to approved+ is a design call the v1 plan
  defers.

- **sjuklonRate fallback 0.80** (Swedish bot). Pre-existing engine
  default. Doesn't ship in this PR.

- **Compliance Swarm V8.2.1 path-based tenant check** (book route).
  Recurring false positive per the documented architectural floor.
  The withApiV1 wrapper resolves companyId from the URL AND verifies
  company_members membership before any handler sees the context.

- **V16.1 eventBus emit swallowed**. Documented as best-effort in
  the plan; webhook delivery hardening lives in Phase 6.

- **V2.4 rate limiting at route level**. Documented as Upstash
  Redis follow-up in the plan.

- **Detail endpoint full personnummer / bank_account_number**.
  Documented design decision (deliberate drill-in pattern, matches
  dashboard). CC6.3 segregation-of-duties is an architectural
  decision deferred.

Test count: 38 (unchanged — fixes are all internal). 250 v1 tests pass.
490 across v1 + lib/salary. Type-check clean.

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

* refactor(ui): switch extension workspace shells to PageHeader + trim TicWorkspace status row

Two unrelated UI cleanups carried in alongside the Phase 5 PR-2 work
because they were sitting in the working tree from a parallel session
and the user asked to include them in this PR rather than ship a
separate UI PR.

- **ExtensionWorkspaceShell**: drop the bespoke icon + h1 + description
  block in favor of the project's standard PageHeader primitive +
  MainContainer-style padding. Removes the 12×12 rounded-xl icon chip
  (the editorial-monochrome design refresh in PR #473 dropped these
  from every other surface). Net: 19 → 6 lines of layout code per
  extension page.

- **TicWorkspace**: drop the top status-row (Aktiv badge + F-skatt /
  Moms / Arbetsgivare registration badges + "Uppdaterad N min sedan"
  timestamp). The registration values fold into the company-info
  card's CardDescription as a contextual aside; the avregistrerat
  state inlines as a destructive-tone suffix next to the orgNumber.
  Simpler header surface, fewer redundant badges.

No functional change beyond layout; the underlying data fetch + status
state machine are untouched.

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

* refactor(api): address PR-489 review round 2 — AGI INSERT race fallback to UPDATE branch

Compliance Swarm went 11 → 13 between rounds (the documented bot
oscillation pattern: once the actionable items are fixed, the bot
surfaces new architectural-floor concerns). Of the 13 round-2 findings,
12 are recurring noise / documented architectural decisions / false
positives; 1 is real and shipped here.

FIXED:

- **Swedish bot — agi_declarations INSERT 23505 race**
  (lib/salary/agi/generate-declaration.ts). The existing-AGI lookup
  uses .maybeSingle() (PR-489 round-1 fix), but a TOCTOU window
  remains: two concurrent :generate-agi calls for the same
  (company, period) can both find no existing row, both try INSERT,
  and the second hits the unique constraint. Previously surfaced as
  a generic DATABASE_ERROR. Now: catch error.code === '23505',
  re-fetch the now-existing row via .maybeSingle(), and fall back
  to the UPDATE branch with is_correction=true. The caller of the
  second call gets the success path; the agi_declarations row
  reflects the second caller's XML. opLog.warn surfaces the race
  for observability.

  Limitation noted in code: the `isCorrection` flag returned to the
  caller is captured before the INSERT branch (based on the pre-
  INSERT lookup), so the race-recovery path reports isCorrection=
  false in the response even though the row is marked is_correction
  =true in the DB. Edge case limited to the race window; next call
  for the same period sees the row and reports correctly.

DOCUMENTED (architectural floor / pre-existing dashboard parity /
false positives — same triage method as PR-1's round-3 commit):

- **V8.2.1 agi/xml legacy companyId** — false positive. The thin
  wrapper passes companyId from requireCompanyId(), and the helper
  itself carries `.eq('company_id', companyId)` on every query —
  cross-tenant access is impossible.

- **V8.2.1 `ctx.companyId!` non-null assertion** — defense-in-depth
  paranoia. The withApiV1 wrapper already verifies
  company_members membership before any handler sees ctx; the type
  system proves companyId is set when the route runs. Adding `if
  (!ctx.companyId) return UNAUTHORIZED` is dead code.

- **V2.3 calculate race** — false positive. The route DOES
  optimistic-lock on `.eq('status', 'draft')` when flipping
  draft→review (see calculate/route.ts line ~206), and treats
  count=0 as 409 SALARY_RUN_CALCULATE_NOT_DRAFT. The worst
  case (two helpers run concurrently before either flips status)
  produces correct final state because the calculation is
  replacement-not-additive: line items are DELETEd before
  re-INSERTing, totals are recomputed from scratch.

- **V4.5 PATCH merges raw body** — false positive. The for-loop
  iterates `Object.entries(body)` where `body` IS the Zod-parsed
  output (`parsed.data`), not rawBody.

- **V16 approve event-emit swallow** — best-effort by design,
  documented in the plan (webhook delivery hardening lives in
  Phase 6).

- **Art.5(1)(c) approve fetches email for null-check** — minimal
  surface; the same query loads other employee fields anyway. The
  alternative (.is.null filter) would mean an additional round-
  trip. Out of scope.

- **Art.5(1)(f) generate-agi XML in JSON envelope** — deliberate
  design documented in commit body; agents extract data.xml and
  forward. Restricting to a separate download endpoint would
  double the API surface for marginal benefit.

- **Art.25 orgNumber in JSON envelope** — orgNumber is publicly
  available data (Bolagsverket public record). Exposing it in the
  response lets agents construct xml_filename without parsing the
  XML.

- **A.8.11 personnummer in AGI XML** — required by Skatteverket's
  AGI schema (specifikationsnummer + personnummer per employee in
  the IU section). Not removable.

- **A.5.34 PATCH error response includes `existing`** — false
  positive. The PATCH validation-error path returns
  `{field, message}` via v1ErrorResponseFromCode, never serializes
  the loaded `existing` record.

- **A.8.15 / A.8.33 / Art.5(1)(c) test fixtures** — recurring
  noise. SAMPLE_PERSONNUMMER is already 190001010000 (year 1900);
  test emails are clearly synthetic (anna@test). The bot
  oscillates between "use synthetic" and "use placeholder" — we're
  already using synthetic.

- **Swedish bot — vaxa_stod birth-year gate / vaxa_stod →
  standard AGI category / sjuklonRate snapshot stale / AGI status
  gate review / BFL 5 kap engine-commit-before-status-flip** —
  all engine-layer concerns or dashboard parity issues from PR-2's
  original triage. Documented in the original commit body; no
  change in this round.

Tests: 38 lifecycle (unchanged). 250 v1 / 490 v1+salary. Type-check
clean.

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

* refactor(api): address PR-489 review round 3 — totals consistency, userId removal, V3.2 citation

Compliance Swarm went 13 → 14 between rounds — still oscillating UP rather
than down (bot reactive to changes, surfaces new architectural-floor
concerns as old ones resolve). Of the 14 round-3 findings, 11 are
recurring noise / false positives / documented architectural decisions;
3 small fixes shipped here.

FIXED:

- **Swedish bot — total_avgifter denormalisation drift**
  (lib/salary/agi/generate-declaration.ts). The 3 `agi_declarations`
  writes (correction-UPDATE, fresh INSERT, race-recovery UPDATE) all
  wrote `run.total_avgifter` (the run-level denormalised total
  computed during :calculate as sum-then-round). The XML, however,
  uses `totals.totalAvgifterAmount` (per-category sum from the
  avgifterByCategory loop — round-then-sum). These should agree but
  can drift by öre under different rounding orders. Now all three
  writes use `totals.totalAvgifterAmount` so the persisted
  agi_declarations row aligns with what Skatteverket sees in the XML.

- **Art.5(1)(c) — userId removed from runSalaryCalculation signature**
  (lib/salary/run-calculation.ts). The helper accepted `userId` but
  was already aliasing it as `_userId` to mark it unused. Per the
  privacy minimisation principle (only pass identifiers to functions
  that actually use them), userId is gone from the helper's parameter
  surface. The two callers (internal /calculate, v1 :calculate) drop
  the argument.

- **OWASP citation correction — V1.2.5 → V3.2/V4**
  (app/api/salary/runs/[id]/agi/xml/route.ts +
  app/api/v1/.../salary-runs/[id]/generate-agi/route.ts). V1.2.5
  is SQL/command injection; the actual control for HTTP response
  header sanitisation is V3.2 (output encoding) / V4 (general access
  control). Comment-only fix; sanitisation code itself was already
  correct.

DOCUMENTED (architectural floor / false positives — same triage method):

- **V4.5 PATCH .strict()** — false positive. Zod's default for
  z.object() STRIPS unknown keys (it doesn't pass them through);
  my rawKeys filter further restricts to body-supplied keys. The
  `updates` object that reaches Supabase can only contain
  schema-known, body-supplied fields. No additional .strict()
  needed.

- **Art.5(1)(f) book first_name/last_name in JEs** — false positive.
  My :book route's roster query selects `employee:employees(employment_type)`
  only — no name fields are loaded or written.

- **V8.2.1 path-based tenant check** — recurring (3rd repeat). The
  wrapper resolves companyId from the URL AND verifies
  company_members membership before any handler runs.

- **V2.3 warnings as blockers** — design decision. Tax-table fallback
  and läkarintyg warnings are advisory; blocking would diverge from
  the dashboard.

- **Art.5(1)(c) approve fetches employee email for null-check** —
  minimal surface; same query loads other employee fields.

- **Art.5(1)(b) XML in JSON envelope** — deliberate design (3rd
  repeat). Documented in commit.

- **Art.25(2) userEmail fallback** — false positive. The helper
  already prefers `settings?.email` over user.email; the
  fallback chain is documented.

- **Art.32 test fixture Bearer token** — paranoia. Literally
  'test-fixture-not-a-real-key'.

- **A.8.15 event swallow** — best-effort by design (4th repeat).
  Phase 6 webhook hardening covers this properly.

- **Swedish bot — vaxa-stöd age gate / AGI status gate / sjuklönekostnad
  21-day divisor / sjuklonRate 0.8 fallback** — all engine-layer
  concerns or dashboard parity issues. Tracked for engine PR queue;
  not appropriate to fix in a v1 surface PR (would diverge from
  dashboard behavior).

Tests: 38 lifecycle (unchanged). 250 v1 / 490 v1+salary. Type-check
clean.

Compliance Swarm trajectory: 11 → 13 → 14. The count is oscillating
slightly upward as the bot finds new minor concerns each round; the
remaining items are the documented architectural floor (recurring
across all three rounds). Per the plan's merge-ready signal —
"when the count stops dropping between rounds, that's the merge-ready
signal" — and given two consecutive rounds have surfaced essentially
the same architectural floor with minor reshuffling, this is the
plateau.

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-14 21:53:07 +02:00

1518 lines
56 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 PS_REPORT: Record<string, StructuredErrorEntry> = {
PS_REPORT_MISSING_PARAMS: {
httpStatus: 400,
message_sv: 'periodType, year och period krävs.',
message_en: 'periodType, year and period query parameters are required.',
},
PS_REPORT_INVALID_PERIOD_TYPE: {
httpStatus: 400,
message_sv: 'periodType måste vara monthly eller quarterly.',
message_en: 'periodType must be monthly or quarterly.',
},
PS_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.',
},
PS_REPORT_INVALID_PERIOD: {
httpStatus: 400,
message_sv: 'period är ogiltig för vald periodtyp.',
message_en: 'period is invalid for the chosen period type.',
},
PS_REPORT_GENERATION_FAILED: {
httpStatus: 500,
message_sv: 'Periodisk sammanställning kunde inte beräknas.',
message_en: 'Failed to generate periodisk sammanställning.',
},
PS_REPORT_CSV_BLOCKED_BY_ERRORS: {
httpStatus: 400,
message_sv: 'CSV kan inte laddas ner. Åtgärda blockerande fel först.',
message_en: 'CSV download blocked by validation errors. Fix them first.',
},
PS_REPORT_MISSING_FILER_INFO: {
httpStatus: 400,
message_sv: 'Kontaktuppgifter saknas. Fyll i namn, telefon och e-post under Inställningar.',
message_en: 'Tax contact information is missing on company_settings.',
},
}
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_DOWNLOAD_FAILED: {
httpStatus: 500,
message_sv: 'Det gick inte att skapa nedladdningslänken.',
message_en: 'Failed to create signed download URL.',
},
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_CREATE_INVALID_INPUT: {
httpStatus: 400,
message_sv: 'Ogiltig kombination av fakturafält. Kontrollera formuläret och försök igen.',
message_en: 'Invalid combination of supplier invoice fields.',
},
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.',
},
// Phase 5 PR-1 — v1 REST surface error codes.
EMPLOYEE_NOT_FOUND: {
httpStatus: 404,
message_sv: 'Den anställda kunde inte hittas.',
message_en: 'Employee not found.',
},
EMPLOYEE_DUPLICATE_PERSONNUMMER: {
httpStatus: 409,
message_sv: 'En anställd med samma personnummer finns redan.',
message_en: 'An employee with that personnummer already exists.',
},
SALARY_RUN_DUPLICATE_PERIOD: {
httpStatus: 409,
message_sv: 'En lönekörning för perioden finns redan.',
message_en: 'A salary run for that period already exists.',
},
SALARY_RUN_PATCH_NOT_DRAFT: {
httpStatus: 400,
message_sv: 'Endast utkast (draft) kan uppdateras.',
message_en: 'Only draft salary runs can be patched.',
},
SALARY_RUN_DELETE_NOT_DRAFT: {
httpStatus: 400,
message_sv: 'Endast utkast (draft) kan raderas.',
message_en: 'Only draft salary runs can be deleted.',
},
SALARY_RUN_CALCULATE_NOT_DRAFT: {
httpStatus: 400,
message_sv: 'Lönekörningen måste vara i status draft för beräkning.',
message_en: 'Salary run must be in draft status to calculate.',
},
SALARY_RUN_APPROVE_NOT_REVIEW: {
httpStatus: 400,
message_sv: 'Lönekörningen måste vara i status review för godkännande.',
message_en: 'Salary run must be in review status to approve.',
},
SALARY_RUN_APPROVE_VALIDATION_FAILED: {
httpStatus: 400,
message_sv: 'Valideringsfel — korrigera innan godkännande.',
message_en: 'Validation failed — fix issues before approving.',
},
SALARY_RUN_MARK_PAID_NOT_APPROVED: {
httpStatus: 400,
message_sv: 'Lönekörningen måste vara godkänd för att markeras som betald.',
message_en: 'Salary run must be approved before it can be marked paid.',
},
SALARY_RUN_BOOK_NOT_PAID: {
httpStatus: 400,
message_sv: 'Lönekörningen måste vara markerad som betald för bokföring.',
message_en: 'Salary run must be marked paid before booking.',
},
AGI_GENERATE_NOT_BOOKABLE: {
httpStatus: 400,
message_sv: 'AGI kan endast genereras för lönekörningar i status review, approved, paid, booked eller corrected.',
message_en: 'AGI can only be generated for salary runs in review, approved, paid, booked, or corrected status.',
},
AGI_INCOMPLETE_DATA: {
httpStatus: 400,
message_sv: 'AGI-data ofullständig — kontrollera att företaget har organisationsnummer, kontaktnamn, telefon och e-post.',
message_en: 'AGI data is incomplete — verify the company has org number, contact name, phone, and email.',
},
COMPANY_NOT_FOUND: {
httpStatus: 404,
message_sv: 'Företaget kunde inte hittas.',
message_en: 'Company not found.',
},
// Phase 5 PR-1 carry-over: distinct error code for the salary-run DELETE
// FK-null guard so an operator seeing this in logs knows a journal entry
// is at risk, not just a status race.
SALARY_RUN_DELETE_HAS_JOURNAL_ENTRY: {
httpStatus: 400,
message_sv: 'Lönekörningen är kopplad till en verifikation och kan inte raderas (BFL 5 kap räkenskapsinformation).',
message_en: 'Salary run is linked to a journal entry and cannot be deleted (BFL 5 kap räkenskapsinformation).',
},
}
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,
...PS_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)
}