523a8650cc
* feat(api): Phase 5 PR-3 — reports + import async (final Phase 5 PR)
Combines the originally-planned PR-3 (import) and PR-4 (reports) into one
final Phase 5 PR per the user's "split into two PRs" scoping after PR-2.
16 new endpoints, 12 new tests, 1 shared helper. 502 v1+salary tests
total (was 490 before this PR).
Endpoints (16):
**JSON reports (14):**
- trial-balance, balance-sheet, income-statement, general-ledger,
journal-register, vat-declaration, monthly-breakdown, ar-ledger,
supplier-ledger, continuity-check, salary-journal, avgifter-basis,
vacation-liability — all wrap existing `lib/reports/*` generators
byte-equivalently with the dashboard.
- New shared helpers (`lib/api/v1/report-period.ts`):
- `loadPeriodFromQuery(request, ctx)` — parse + validate the
`period_id` query param, fetch the fiscal_periods row scoped to
the caller's company, return a discriminated result so the route
either gets a typed period or a pre-built 400/404 response.
- `safeGenerate(fn, ctx)` — wrap a lib generator call in a try/catch
that surfaces a structured REPORT_GENERATION_FAILED instead of
letting the raw error leak.
- Net effect: each report route stays at ~50 lines of business logic
while preserving complete OpenAPI documentation per endpoint.
**Binary report (1):**
- sie-export: returns text/plain UTF-8 SIE4 content with
Content-Disposition: attachment. OWASP V3.2 sanitisation strips
everything but [0-9a-fA-F-] from the period_id before splicing into
the filename header.
**Async imports (2):**
- POST /imports/sie: multipart, 50 MB cap, 5-minute maxDuration. Auto-
detects encoding (CP437/Windows-1252/UTF-8), parses, dedupes by
SHA-256 hash, then calls executeSIEImport(). Records lifecycle on
the `operations` table for `GET /operations/{id}` polling. Returns
the 202 envelope from `accepted()`.
- POST /imports/bank: multipart, 10 MB cap. Auto-detects format across
11 bank format modules (SEB, Swedbank, Handelsbanken, Nordea,
Nordea Business, Lansforsakringar, Lunar, ICA Banken, Skandia,
CAMT053, generic CSV) — or honors a `format` override. Calls
`ingestTransactions()` with the parsed transactions; updates the
`bank_file_imports` row to completed; emits `transaction.synced`
per ingested row through the standard ingest path. Same operations
table polling shape.
Both imports execute INLINE today. A future cron worker can take over
by flipping `initialStatus` from `'running'` to `'queued'` in
startOperation — the response contract stays identical.
Deferred to a follow-up (each has lib-module structure quirks that
warrant their own focused PR):
- `kpi` — composition of multiple lib generators rather than wrapping one
- `audit-trail` — lives in lib/core/audit/ not lib/reports/
- `ne-bilaga` + `ink2` — each has its own subdir + engine layer
- `periodisk-sammanstallning` — JSON + CSV variants with complex params
- PDF variants of balance-sheet / income-statement / etc. — agents can
render from JSON; binary PDF is nice-to-have not must-have for v1
Tests:
- 12 new integration tests (route-layer contract: auth/scope, period_id
validation, the shared loadPeriodFromQuery helper, the safeGenerate
error path, sie-export Content-Type + Content-Disposition, vat-
declaration query-param validation, generator pass-through). The
lib functions have their own unit tests; route tests focus on the
wrapper.
- 502 total v1 + lib/salary tests pass.
- Type-check clean.
3 new structured-error codes: SIE_IMPORT_DUPLICATE, BANK_IMPORT_FAILED,
BANK_FILE_FORMAT_UNKNOWN. Plus the existing SIE_PARSE_FAILED /
SIE_IMPORT_FAILED / BANK_FILE_NO_TRANSACTIONS reused.
Plan doc updated to mark Phase 5 complete (3 PRs shipped: PR-1
registers, PR-2 lifecycle, PR-3 reports+imports).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* refactor(api): address PR-490 review round 1 — 3 Greptile P1 bugs + 5 defensive items
Compliance Swarm landed at 17 findings (5 high + 10 medium + 2 low) on the
first round; Swedish bot at 8; Greptile flagged 3 inline P1 bugs. CI all
green from the first push.
FIXED — Greptile P1 bugs (all 3 confirmed real):
- **VAT declaration cross-field bounds**
(app/api/v1/.../reports/vat-declaration/route.ts). The schema
validated `period` as 1-12 for every period_type. A caller could pass
period_type=quarterly + period=7 (or yearly + period=5) and the route
would forward garbage to calculateVatDeclaration — the agent might
submit a nonsensical declaration to Skatteverket. Added a
.superRefine() that enforces: monthly → 1-12, quarterly → 1-4,
yearly → must equal 1. Swedish-compliance bot flagged the same
concern independently.
- **SIE import options JSON.parse unguarded**
(app/api/v1/.../imports/sie/route.ts). The route inlined
`JSON.parse(optionsRaw)` inside the Zod safeParse call. A malformed
options string threw SyntaxError before Zod ran, producing an
unhandled 500 instead of the documented 400 VALIDATION_ERROR.
Wrapped in an explicit try/catch that returns a structured 400 with
the parse-error message.
- **Bank import upsert conflict key cross-company collision**
(app/api/v1/.../imports/bank/route.ts). The `bank_file_imports`
unique constraint is (user_id, file_hash) from the single-tenant
single-company-per-user era. If the same user uploads the same file
to two companies they're a member of, the second upload's upsert
(with onConflict='user_id,file_hash') would silently overwrite the
first row's company_id. Added a pre-check that loads the existing
row by (user_id, file_hash) and returns
BANK_IMPORT_DUPLICATE_OTHER_COMPANY (409) if the company_id
differs. The proper fix is a migration widening the unique index to
(user_id, file_hash, company_id) — engine-PR-queue concern.
FIXED — defensive items from Compliance Swarm V2.2, V5.2:
- **General-ledger account_from/account_to validation**
(V2.2). The query params were passed straight through to the
generator without format checks. Added a `^\d{3,8}$` regex
(covers 4-digit BAS today + sub-account schemes up to 8 digits).
- **SIE import file header sanity check**
(V5.2). Before invoking parseSIEFile we now check the first 4 KiB
of the decoded content for at least one of #FLAGGA / #PROGRAM /
#FORMAT / #SIETYP — the mandatory SIE4 header records. An HTML /
executable / JSON payload that got past the multipart filter would
lack all of them and gets a structured 400 SIE_PARSE_FAILED
instead of being fed to parseSIEFile.
FIXED — doc / metadata corrections (Swedish bot):
- **VAT description**: Expanded the rutor list from "05/10/11/12/30/31/
32/39/40/48/49" to include the import-VAT rutor 20-24, 35-36, 50,
and 60-62. Matters because agents read the description to decide
what fields to map; an incomplete list causes agents to omit import
VAT.
- **Continuity-check citation**: Replaced the wrong "BFL 5 kap 7 §"
citation (which is rättelse, not IB/UB continuity) with the correct
derivation — BFL 5 kap (löpande bokföring) + BFNAR 2013:2 + SIE4
spec's #IB(N) = #UB(N-1) invariant.
- **Vacation-liability description**: Clarified that the "sums to BAS
2920" guarantee only holds when no employees use `semesterersattning`
(which is expensed immediately, not accrued). The exclusion of
vacation_rule='semesterersattning' and 'none' was already mentioned
in pitfalls; now the legal-basis text is consistent.
DOCUMENTED (architectural floor / dashboard parity / engine concerns —
not changed):
- **V8.2.1 path-based tenant check** (4th repeat across phases). The
wrapper resolves companyId from the URL AND verifies company_members
membership before any handler runs.
- **V5.2 bank file magic-byte check**: defensible defense-in-depth, but
the dashboard's /api/import/bank-file/parse uses the same content-
+ filename + format-module detection pattern. Diverging in v1 would
break parity. Tracked for a cross-cutting "tighten upload validation"
PR.
- **V16 error log internals leak**: the error responses do surface
err.message in the operation_id error envelope, but this is the
intentional contract for an integrator polling operations/{id}.
Stack traces are not included.
- **Art.32 SIE raw fileContent persisted**: the executeSIEImport helper
receives the raw content for hash + parse purposes; whether it
persists it beyond the import transaction is an engine-layer
concern. Tracked.
- **Art.25(1) journal-register + general-ledger no pagination**:
dashboard parity. The reports are designed to return the period's
full content because period-bounded reports have natural size limits
(a single fiscal year). Cursor pagination would diverge from
dashboard behavior.
- **Swedish: SIE export UTF-8 vs CP437**: legacy SIE consumers (BL
Administration, older Hogia/Visma) want CP437. The dashboard serves
UTF-8 today and modern SIE consumers accept it. Diverging in v1
would break parity. If real-world legacy-consumer demand surfaces,
add a `?encoding=cp437` override; not building on speculation.
- **Swedish: SIE #FLAGGA mutation, bank_file_imports mutability**:
schema + engine concerns; v1 mirrors dashboard behavior.
- **Swedish: avgifter-basis age-tier verification**: requires reading
the lib generator's internals; tracked.
1 new structured-error code: BANK_IMPORT_DUPLICATE_OTHER_COMPANY (409).
Test count: 261 v1 (unchanged — fixes are internal). 502 across v1 +
lib/salary. Type-check clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* refactor(api): address PR-490 review round 2 — IDOR fix + bank format enum + calendar date validation + 3 doc fixes
Compliance Swarm went 17 → 12 between rounds (high count 5 → 2 — the three
Greptile P1s from round 1 dropped out cleanly). 6 actionable items this
round; the rest are recurring architectural-floor noise documented in the
PR-1/PR-2 commit pattern.
FIXED (security):
- **V8.2.1 / CC6.1 — BANK_IMPORT_DUPLICATE_OTHER_COMPANY IDOR leak**
(app/api/v1/.../imports/bank/route.ts). The round-1 fix added a pre-
check that returned the cross-company collision details (existing_
company_id + existing_import_id) in the error response body — that's
a cross-tenant enumeration vector. The fix now logs those details
server-side for operator investigation (CC7.2 audit trail) but
returns ONLY the fixed error code + the generic message to the
caller. The agent learns "file already imported into another
company" but never sees the other company's UUID.
- **V2.2 / PI1.1 — bank format query param allowlist**
(app/api/v1/.../imports/bank/route.ts). The route cast
`url.searchParams.get('format')` directly to `BankFileFormatId`
without validation. Now validated against an explicit Zod enum of
all 11 accepted format ids before reaching parseBankFile /
detectFileFormat. Unknown values fail fast with 400
VALIDATION_ERROR + a helpful list of accepted values.
FIXED (correctness):
- **A.8.28 — as_of_date calendar validity**
(ar-ledger + supplier-ledger routes). The regex `^\d{4}-\d{2}-\d{2}$`
matched '2026-13-45'. Now we also round-trip through Date(): construct
with the date string, check the ISOString re-extraction equals the
input. Catches month/day/leap-year invalidity without pulling in a
date library.
FIXED (docs — Swedish bot + Compliance Swarm):
- **VAT example block** completed to include all rutor (60/61/62 import
VAT + 20-24 + 35-36 + 50). The round-1 description was extended; this
round extends the example so an agent reading the OpenAPI spec sees
the complete contract.
- **salary-journal description** — clarified that `paid`-but-unbooked
runs are excluded. Matters for AGI-vs-ledger reconciliation: an
operator checking the lönejournal against AGI will see a gap for
any paid run that hasn't been booked yet.
- **bank import description** — added an explicit BFL 5 kap 1 § note
that `ingestTransactions` creates transaction rows (the underlag)
NOT verifikationer (the bookings themselves). Operators relying on
this endpoint as their "bookkeeping is complete" signal would be
wrong; the transactions still need matching/categorization to
become verifikationer.
DOCUMENTED (architectural floor / recurring / engine concerns —
not changed):
- **V5.2 magic-byte upload validation** (5th repeat across phases).
Dashboard pattern; magic-byte inspection would diverge from the
internal /api/import/bank-file/parse behavior. Tracked for a
cross-cutting upload-validation hardening PR.
- **V16 err.message reflection** (2nd repeat). The integrator-facing
contract for an operations.failed result deliberately includes the
reason — agents need actionable info to retry vs abort. Removing
err.message would be a regression for debuggability.
- **A.8.28 / CC6.1 parser DoS on large SIE/bank files**. Bounded by
the 50 MB / 10 MB file caps + 5-min maxDuration. A pathological 50
MB SIE file caps the line count at ~5M lines (10 bytes per line
minimum); the parser is sync and hits the route timeout long before
exhausting memory.
- **CC7.2 log injection via err.message**. Best-effort logging by
design; structured fields include fileHash + operationId
(server-safe) and the message tag is fixed.
- **Swedish: SIE export UTF-8 vs CP437** (2nd repeat — dashboard
parity). A future `?encoding=cp437` override is the right
evolution if real legacy-consumer demand materialises.
- **Swedish: #FLAGGA reset to 1, period-occupancy check on SIE
import**. Engine-layer concerns inside executeSIEImport. Tracked.
Test count: 261 v1 (unchanged — fixes are internal). Type-check clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* refactor(api): address PR-490 review round 3 — SIE IDOR symmetry, bank log fields, VAT doc corrections
Compliance Swarm went 12 → 26 between rounds — the documented oscillation
pattern at its most aggressive (the bot reactivates and finds more
speculative items as the actionable ones resolve). 4 small real fixes
this round; the rest are recurring noise documented across PR-1/PR-2/PR-3.
FIXED (security parity):
- **V8.2.1 — SIE duplicate IDOR leak**
(app/api/v1/.../imports/sie/route.ts). The bank-import IDOR fix in
round 2 removed existing_company_id + existing_import_id from the
response details; SIE_IMPORT_DUPLICATE was still echoing
existing_import_id + imported_at. Symmetric fix: log forensics
server-side (CC7.2 audit trail), return only the error code +
generic message to the caller.
FIXED (audit log consistency):
- **V16 — bank error log missing userId/companyId fields**
(app/api/v1/.../imports/bank/route.ts). The SIE error log includes
these fields per ASVS V16 audit-record content requirements; the
bank error log didn't. Added for consistency.
FIXED (Swedish bot doc corrections):
- **VAT description: rutor 35/36 don't exist on SKV 4700**. The
round-1 expansion incorrectly listed "ruta 35-36 (export + EU
services)". SKV 4700 has ruta 39 (export) and ruta 40 (EU services)
— there are no boxes 35 or 36. Removed from both the description
and the example block.
- **ML 13 kap → ML 15 kap**. The kontantmetod citation referenced
the pre-2023 chapter. ML 2023:200 replaced ML 1994:200 on 1 July
2023 and moved kontantmetod to ML 15 kap 8–11 §§. Fixed the pitfall
text to cite the current statute with a brief explanation of why
the old reference appears in older documentation.
DOCUMENTED (recurring noise / architectural floor / dashboard parity /
feature work — same triage method as PR-1/PR-2/PR-3 prior rounds):
- **V5.2 magic-byte upload validation** (6th repeat). Dashboard
doesn't do this either. Tracked for a cross-cutting hardening PR
if a real attack surface emerges.
- **V2.3 / Art.5(1)(f) err.message reflection in API response**
(3rd repeat). Intentional contract for operations.failed result —
agents need actionable info to retry vs abort. Removing
err.message would be a regression for debuggability. The bot
framings ("PII leakage" / "implementation detail leak") differ
round-to-round but the underlying ask is the same.
- **Art.5(1)(c) — z.unknown() response schemas on salary-journal /
avgifter-basis / ar-ledger / supplier-ledger** (new framing).
Typing every report response would require importing the lib's
domain types and would break under future lib changes; the
dashboard doesn't enforce typed responses either. Recurring
dashboard-parity concern.
- **Art.5(1)(f) — cross-tenant log linkage from the round-2 IDOR
fix**. The server log carrying who-attempted-what IS the audit
trail; log retention + access control are infrastructure-layer
obligations (RoPA + log-store ACL), not code-layer. The bot wants
me to confirm/document; tracked outside this PR.
- **Art.5(1)(f) — filename in SIE error log** (new framing).
Marginal: SIE filenames sometimes encode company name + fiscal
year, but the route logs them server-side, never reflects in
responses. The audit trail is more valuable than the marginal
identifying surface.
- **Art.25(2) — report endpoint pagination** (2nd repeat).
Dashboard returns full-period data; pagination would diverge from
parity. A future date-range filter (date_from/date_to) could be
added if real callers hit response-size pain.
- **Swedish: SIE export UTF-8 vs CP437** (3rd repeat — dashboard
parity).
- **Swedish: #FLAGGA mutation / IB-UB chain on import / AGI-vs-
ledger flag / transactions_pending_booking counter** — all
feature work, not bug fixes. Tracked for engine PR queue or
future Phase 5.x.
Test count: 261 v1 (unchanged — fixes are internal). Type-check clean.
Trajectory: 17 → 12 → 26. The count is oscillating widely — not the
documented "plateau-then-stop" signal exactly, but the actual
finding set is mostly recurring noise. Continuing to fix small real
items while the noise stabilises.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* refactor(api): address PR-490 review round 4 — 7 small final fixes (defense in depth + doc corrections)
Compliance Swarm: 17 → 12 → 26 → **10** between rounds. Round 4 is the
plateau-then-stop signal per the documented merge-ready criterion — the
count dropped back significantly after round 3's fixes resolved the
real items the bot was finding alongside its speculative noise.
FIXED (defense in depth):
- **V8.2.1 — bank `bank_file_imports` UPDATE missing company_id filter**
(app/api/v1/.../imports/bank/route.ts). The cross-company pre-check
in round 1 catches the collision case, but the post-ingest UPDATE
itself only scoped to `(file_hash, user_id)`. Added `.eq('company_id',
ctx.companyId!)` so even a hypothetical race past the pre-check
can't overwrite the wrong company's status row.
- **V5.2 — SIE header check line-start regex**
(app/api/v1/.../imports/sie/route.ts). The round-3 string-contains
check would have accepted an HTML payload with `<!-- #FLAGGA -->`.
Tightened to require line-start anchoring:
`/(^|\n)\s*#(FLAGGA|PROGRAM|FORMAT|SIETYP)\b/`. A SIE header record
always starts on its own line per the spec.
- **V2.2 — as_of_date year range clamp**
(ar-ledger + supplier-ledger routes). Calendar validity (round 2)
alone accepts `as_of_date=9999-01-01`. Added a sanity range:
year 2000 → currentYear + 1. The +1 tolerance allows year-end
filing for the year that just turned over.
FIXED (Zod hardening):
- **V4.5 — SIE options `.strict()`** (sie/route.ts). The options
schema accepts unknown keys; Zod's default strips them, but
`.strict()` rejects them with VALIDATION_ERROR so a future schema
edit doesn't silently mass-assign through an extension.
FIXED (Swedish bot doc corrections):
- **Bank pitfall: BFL 5 kap 1 § → BFL 5 kap 6-7 §§**. BFL 5 kap 1 §
is the general bokföringsskyldighet; the verifikation content
requirements are in 6-7 §§. Important because the pitfall is the
legal-citation surface agents consume to understand the compliance
boundary.
- **Vacation-liability description**: replaced "the 2920
reconciliation only matches when no employees use that rule" —
which incorrectly implied a reconciliation failure — with "the
2920 reconciliation is CORRECT whether or not the company has
semesterersättning employees, since those employees contribute
zero to both the report and the 2920 balance." Same fact, but
no longer signals a phantom failure.
- **Salary-journal warning**: strengthened the paid-but-unbooked
exclusion note to flag that KU preparation from this report can
understate wages if any paid runs are still unbooked at KU time
(an SFL obligation breach). Now an explicit ⚠️ warning rather
than a buried pitfall bullet.
DOCUMENTED (architectural floor — same as prior rounds, 3rd-7th
repeats):
- **V8.2.1 widen `bank_file_imports` unique constraint** — schema
migration concern (route-layer pre-check is the mitigation).
- **V5.2 magic-byte upload validation** (7th repeat across phases) —
dashboard pattern.
- **V16.1.1 / CC6.1 err.message / operation_id reflection in API
response** (3rd-4th repeat) — intentional contract for
operations.failed.
- **Swedish: SIE #FLAGGA writeback / SIE UTF-8 vs CP437 (4th repeat)
/ sequential verifikation numbering** — engine/lib concerns.
- **Swedish: VAT formula omits rutor 20-24** — false positive. My
formula matches Skatteverket's SKV 4700 spec: ruta 20-24 are EU
acquisition BASES (amounts without VAT), not output-VAT rutor.
The corresponding output VAT for EU acquisitions goes via reverse
charge into rutor 30-32, which my formula already includes.
Test count: 261 v1 (unchanged — fixes are internal). Type-check clean.
Compliance Swarm trajectory: 17 → 12 → 26 → 10. The round-4 count is
the lowest across the four rounds AND matches the architectural-floor
pattern documented in the plan (5-9 findings across PR #467, #469,
#471 once actionable items are fixed). This PR has reached the
plateau-then-stop signal.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1539 lines
56 KiB
TypeScript
1539 lines
56 KiB
TypeScript
/**
|
||
* 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 2024–2026 hittades hos leverantören.',
|
||
message_en: 'No fiscal years available for 2024–2026.',
|
||
},
|
||
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).',
|
||
},
|
||
// Phase 5 PR-3 — additional import error codes.
|
||
SIE_IMPORT_DUPLICATE: {
|
||
httpStatus: 409,
|
||
message_sv: 'Den här SIE-filen har redan importerats.',
|
||
message_en: 'This SIE file has already been imported.',
|
||
},
|
||
BANK_IMPORT_FAILED: {
|
||
httpStatus: 500,
|
||
message_sv: 'Bankfilsimporten misslyckades.',
|
||
message_en: 'Bank file import failed.',
|
||
},
|
||
BANK_FILE_FORMAT_UNKNOWN: {
|
||
httpStatus: 400,
|
||
message_sv: 'Bankfilens format kunde inte identifieras.',
|
||
message_en: 'Bank file format could not be identified.',
|
||
},
|
||
BANK_IMPORT_DUPLICATE_OTHER_COMPANY: {
|
||
httpStatus: 409,
|
||
message_sv: 'Den här filen har redan importerats för ett annat företag av samma användare.',
|
||
message_en: 'This file has already been imported into another company by this user.',
|
||
},
|
||
}
|
||
|
||
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)
|
||
}
|