feat(api): Phase 5 PR-3 — reports + import async (final Phase 5 PR) (#490)

* 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>
This commit is contained in:
Jakob Wennberg
2026-05-14 22:50:35 +02:00
committed by GitHub
co-authored by Claude Opus 4.7
parent fbf8348ca3
commit 523a8650cc
21 changed files with 2440 additions and 0 deletions
@@ -0,0 +1,352 @@
/**
* POST /api/v1/companies/{companyId}/imports/bank
*
* Bank-file import. Multipart upload — the file is the request body. The
* route:
* 1. Decodes the file (UTF-8 / Windows-1252 auto-detected).
* 2. Detects the bank file format (SEB / Swedbank / Nordea / Handelsbanken
* / Lansforsakringar / Lunar / ICA Banken / Skandia / CAMT053 /
* Nordea Business / generic CSV) — or honors the optional `format`
* override.
* 3. Parses transactions.
* 4. Records a `bank_file_imports` row and ingests transactions via
* `ingestTransactions()`.
* 5. Emits `transaction.synced` per ingested transaction.
* 6. Records the result on the `operations` table for consistent
* polling-shape with SIE imports.
*
* Runs INLINE today. The dashboard's /api/import/bank-file/execute backs
* the same `ingestTransactions` helper, so a v1 import is byte-equivalent.
*/
import { z } from 'zod'
import { accepted } from '@/lib/api/v1/response'
import { registerEndpoint } from '@/lib/api/v1/registry'
import { withApiV1 } from '@/lib/api/v1/with-api-v1'
import { v1ErrorResponseFromCode } from '@/lib/api/v1/errors'
import {
startOperation,
completeOperation,
failOperation,
} from '@/lib/api/v1/operations'
import {
parseBankFile,
detectFileFormat,
generateFileHash,
generateExternalId,
} from '@/lib/import/bank-file/parser'
import { ingestTransactions, type RawTransaction } from '@/lib/transactions/ingest'
import type { BankFileFormatId } from '@/lib/import/bank-file/types'
const BankImportAccepted = z.object({
operation_id: z.string().uuid(),
type: z.literal('import.bank'),
status: z.literal('queued'),
poll_url: z.string(),
})
const MAX_FILE_SIZE = 10 * 1024 * 1024 // 10 MB — matches dashboard
registerEndpoint({
operation: 'imports.bank',
method: 'POST',
path: '/api/v1/companies/:companyId/imports/bank',
summary: 'Import a bank-file (CSV / XML / CAMT053).',
description:
'Accepts a bank statement file (UTF-8 / Windows-1252, up to 10 MB) as multipart/form-data. Auto-detects the bank format (SEB, Swedbank, Handelsbanken, Nordea, Nordea Business, Lansforsakringar, Lunar, ICA Banken, Skandia, CAMT053, generic CSV) or honors a `format` override. Parses transactions, ingests them into the `transactions` table (NOT into journal entries — see BFL note in pitfalls), and emits `transaction.synced` events. Returns operation_id for polling.',
useWhen:
'Importing a bank statement export for a period. Common with PSD2 bank connections that don\'t auto-sync, or for legacy bank accounts.',
doNotUseFor:
'SIE bookkeeping import (use /imports/sie). Auto-bank sync (use the enable-banking extension). Single-transaction creation (use POST /transactions/ingest with a 1-element array).',
pitfalls: [
'File size cap: 10 MB. Larger files require splitting client-side.',
'`format` query parameter is optional; auto-detection works for all supported banks. Pass `format` only to force a specific format. Accepted values: seb, swedbank, handelsbanken, nordea, nordea_business, lansforsakringar, ica_banken, skandia, lunar, generic_csv, camt053.',
'Duplicate detection is by external_id (composed from date + amount + counterparty); a re-import of the same file with the same flag set typically deduplicates rather than creating doubles.',
'BFL 5 kap 6-7 §§ note: this endpoint creates `transactions` rows (the underlag for a verifikation), NOT verifikationer themselves. The verifikation content requirements are in BFL 5 kap 6-7 §§; until each transaction is matched to an invoice/supplier-invoice (POST /transactions/{id}/match-*) or categorised (POST /transactions/{id}/categorize), the bookkeeping obligation isn\'t discharged. A successful import here means the data is ingested — not booked.',
'A successful import returns operation_id; poll /operations/{id} for the final ingested/duplicates/errors counts.',
],
example: {
response: {
data: {
operation_id: 'op_a8f1…',
type: 'import.bank',
status: 'queued',
poll_url: '/api/v1/operations/op_a8f1…',
webhook_event: 'operation.completed',
},
meta: { request_id: 'req_…', api_version: '2026-05-12' },
},
},
scope: 'transactions:write',
risk: 'medium',
idempotent: true,
reversible: false,
dryRunSupported: false,
request: { contentType: 'multipart/form-data' },
response: { success: BankImportAccepted },
})
export const POST = withApiV1<{ params: Promise<{ companyId: string }> }>(
'imports.bank',
async (request, ctx) => {
let formData: FormData
try {
formData = await request.formData()
} catch {
return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, {
requestId: ctx.requestId,
details: { field: 'body', message: 'Expected multipart/form-data with a `file` field.' },
})
}
const file = formData.get('file')
if (!(file instanceof File)) {
return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, {
requestId: ctx.requestId,
details: { field: 'file', message: 'Missing or invalid `file` field.' },
})
}
if (file.size > MAX_FILE_SIZE) {
return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, {
requestId: ctx.requestId,
details: {
field: 'file',
message: `File too large (${file.size} bytes). Max ${MAX_FILE_SIZE} bytes.`,
},
})
}
const url = new URL(request.url)
// Validate `format` against the canonical BankFileFormatId enum BEFORE
// letting it reach parseBankFile / detectFileFormat. A raw cast would
// pass any string through and rely on the parser to surface
// BANK_FILE_FORMAT_UNKNOWN — better to fail with VALIDATION_ERROR up
// front so an attacker-supplied value never reaches the format module
// (V2.2 / PI1.1 hardening).
const formatParam = url.searchParams.get('format')
const BankFormatEnum = z.enum([
'nordea',
'nordea_business',
'seb',
'swedbank',
'handelsbanken',
'lansforsakringar',
'ica_banken',
'skandia',
'lunar',
'generic_csv',
'camt053',
])
let formatOverride: BankFileFormatId | null = null
if (formatParam) {
const parsed = BankFormatEnum.safeParse(formatParam)
if (!parsed.success) {
return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, {
requestId: ctx.requestId,
details: {
field: 'format',
message:
'Unknown bank file format. Accepted: ' + BankFormatEnum.options.join(', '),
},
})
}
formatOverride = parsed.data
}
// Decode the file. Bank files are typically Windows-1252 or UTF-8; we
// try UTF-8 first and fall back if invalid replacement chars appear.
const buffer = await file.arrayBuffer()
const utf8 = new TextDecoder('utf-8').decode(buffer)
const content = utf8.includes('�')
? new TextDecoder('windows-1252').decode(buffer)
: utf8
const fileHash = await generateFileHash(content)
// Detect format (or honor explicit override).
const format = formatOverride ?? detectFileFormat(content, file.name)?.id
if (!format) {
return v1ErrorResponseFromCode('BANK_FILE_FORMAT_UNKNOWN', ctx.log, {
requestId: ctx.requestId,
details: { filename: file.name },
})
}
const parseResult = parseBankFile(content, file.name, format)
if (parseResult.transactions.length === 0) {
return v1ErrorResponseFromCode('BANK_FILE_NO_TRANSACTIONS', ctx.log, {
requestId: ctx.requestId,
details: { format, filename: file.name },
})
}
const op = await startOperation(
ctx.supabase,
{
companyId: ctx.companyId!,
userId: ctx.userId,
operationType: 'import.bank',
params: {
filename: file.name,
file_size: file.size,
format,
file_hash: fileHash,
transaction_count: parseResult.transactions.length,
},
},
ctx.log,
)
try {
// Cross-company collision pre-check. The `bank_file_imports` unique
// constraint is `(user_id, file_hash)` — set when the table was
// designed for the single-tenant single-company-per-user world. If
// the same user is a member of two companies and uploads the same
// file to both, a naive upsert with onConflict='user_id,file_hash'
// would silently overwrite the first company's row with the second
// company_id. Pre-check for that case and surface a structured
// error so an agent sees the explicit conflict instead of a
// silently-stolen row.
//
// A migration to widen the unique constraint to (user_id, file_hash,
// company_id) is the proper fix; that's an engine-PR concern.
const { data: existingImport } = await ctx.supabase
.from('bank_file_imports')
.select('id, company_id, filename, imported_at, status')
.eq('user_id', ctx.userId)
.eq('file_hash', fileHash)
.maybeSingle()
if (existingImport && (existingImport as { company_id: string }).company_id !== ctx.companyId) {
// Log the cross-tenant collision details server-side for operator
// investigation (CC7.2 — audit trail), but do NOT echo the other
// company's id or the other import's id back to the caller. Doing
// so would be a cross-tenant enumeration vector (V8.2.1 / CC6.1).
// The caller sees a fixed error code + a generic message; the
// server log carries enough context to debug.
ctx.log.warn('bank import: cross-company file-hash collision', {
fileHash,
attemptedCompanyId: ctx.companyId,
existingCompanyId: (existingImport as { company_id: string }).company_id,
existingImportId: (existingImport as { id: string }).id,
})
await failOperation(
ctx.supabase,
{
id: op.id,
error: {
code: 'BANK_IMPORT_DUPLICATE_OTHER_COMPANY',
message: 'This file has already been imported into another company by this user.',
},
},
ctx.log,
)
return v1ErrorResponseFromCode('BANK_IMPORT_DUPLICATE_OTHER_COMPANY', ctx.log, {
requestId: ctx.requestId,
// Deliberately empty details — see comment above.
})
}
// Record the import row so the dashboard's "bank file imports" tab
// shows v1 imports too. `upsert` on (user_id, file_hash) gives
// duplicate-rerun protection for the same-company case.
await ctx.supabase
.from('bank_file_imports')
.upsert(
{
user_id: ctx.userId,
company_id: ctx.companyId!,
filename: file.name,
file_hash: fileHash,
file_format: format,
transaction_count: parseResult.transactions.length,
status: 'processing',
date_from: parseResult.date_from,
date_to: parseResult.date_to,
},
{ onConflict: 'user_id,file_hash' },
)
// Convert parsed transactions to the RawTransaction shape that
// ingestTransactions expects. external_id stays stable so re-imports
// are deduplicated server-side.
const raw: RawTransaction[] = parseResult.transactions.map((t, idx) => ({
external_id: generateExternalId(t, format, idx),
date: t.date,
amount: t.amount,
currency: t.currency ?? 'SEK',
description: t.description ?? null,
counterparty: t.counterparty ?? null,
reference: t.reference ?? null,
source: 'bank_file',
}))
const ingestResult = await ingestTransactions(
ctx.supabase,
ctx.companyId!,
ctx.userId,
raw,
)
// Mark the bank_file_imports row complete. Scope by all three
// identifying fields — `(user_id, file_hash)` is the unique
// constraint today but adding `company_id` is defense in depth:
// even if a concurrent same-user same-hash import in a different
// company slipped past the pre-check, this update can never
// overwrite the wrong company's status row.
await ctx.supabase
.from('bank_file_imports')
.update({
status: 'completed',
imported_at: new Date().toISOString(),
transaction_count: ingestResult.imported,
})
.eq('file_hash', fileHash)
.eq('user_id', ctx.userId)
.eq('company_id', ctx.companyId!)
await completeOperation(
ctx.supabase,
{
id: op.id,
result: {
format,
file_hash: fileHash,
transactions_imported: ingestResult.imported,
transactions_duplicates: ingestResult.duplicates,
transactions_reconciled: ingestResult.reconciled,
transactions_auto_categorized: ingestResult.auto_categorized,
transactions_errors: ingestResult.errors,
date_from: parseResult.date_from,
date_to: parseResult.date_to,
},
},
ctx.log,
)
} catch (err) {
ctx.log.error('bank file import failed', err as Error, {
operationId: op.id,
userId: ctx.userId,
companyId: ctx.companyId,
filename: file.name,
fileHash,
})
await failOperation(
ctx.supabase,
{
id: op.id,
error: {
code: 'BANK_IMPORT_FAILED',
message: err instanceof Error ? err.message : 'Unknown failure during bank import.',
},
},
ctx.log,
)
return v1ErrorResponseFromCode('BANK_IMPORT_FAILED', ctx.log, {
requestId: ctx.requestId,
details: { operation_id: op.id, reason: err instanceof Error ? err.message : 'unknown' },
})
}
return accepted(op.id, 'import.bank', { requestId: ctx.requestId })
},
)
@@ -0,0 +1,287 @@
/**
* POST /api/v1/companies/{companyId}/imports/sie
*
* SIE4 file import. Multipart upload — the file is the request body. The
* route:
* 1. Decodes the file (CP437 / Windows-1252 / UTF-8 auto-detected).
* 2. Parses the SIE structure.
* 3. Checks for duplicate file-hash imports (rejects if already imported).
* 4. Runs the full import via `executeSIEImport()` — fiscal period
* creation, opening balance entry, voucher commits.
* 5. Records the result on the `operations` table so the v1 caller
* receives a consistent `{ operation_id }` shape.
*
* Currently executes INLINE (the operation is stamped `succeeded` /
* `failed` before the response returns). A future cron worker can take
* over by flipping `initialStatus` from `'running'` to `'queued'` —
* the API contract stays identical.
*
* SIE imports are expensive: a typical multi-year SIE file produces
* thousands of journal entries. The dashboard route allows up to 5
* minutes (`maxDuration = 300`); this route inherits the v1 default.
* For very large imports, consider chunking client-side.
*/
import { z } from 'zod'
import { accepted } from '@/lib/api/v1/response'
import { registerEndpoint } from '@/lib/api/v1/registry'
import { withApiV1 } from '@/lib/api/v1/with-api-v1'
import { v1ErrorResponseFromCode } from '@/lib/api/v1/errors'
import {
startOperation,
completeOperation,
failOperation,
} from '@/lib/api/v1/operations'
import {
parseSIEFile,
detectEncoding,
decodeBuffer,
calculateFileHash,
} from '@/lib/import/sie-parser'
import {
executeSIEImport,
checkDuplicateImport,
} from '@/lib/import/sie-import'
const SieImportAccepted = z.object({
operation_id: z.string().uuid(),
type: z.literal('import.sie'),
status: z.literal('queued'),
poll_url: z.string(),
})
const MAX_FILE_SIZE = 50 * 1024 * 1024 // 50 MB — matches the dashboard's limit
export const maxDuration = 300 // 5 minutes — large multi-year SIE files
registerEndpoint({
operation: 'imports.sie',
method: 'POST',
path: '/api/v1/companies/:companyId/imports/sie',
summary: 'Import a SIE4 file.',
description:
'Accepts a SIE4 file (CP437 / Windows-1252 / UTF-8 auto-detected, up to 50 MB) as the request body, parses it, checks for duplicate imports by file-hash, and replays every #VER + #TRANS into the company\'s bookkeeping. Returns an `operation_id` immediately — poll `GET /api/v1/operations/{id}` for status + final result. The byte-equivalent dashboard route at /api/import/sie/execute backs the same lib helper, so a SIE imported via v1 matches what the dashboard would produce.',
useWhen:
'Migrating bookkeeping data from another system (Fortnox, Bokio, Visma) into gnubok, restoring from a backup .se file, or recreating a period from an archive.',
doNotUseFor:
'Bank transaction CSV/XML imports (use POST /imports/bank). Single-voucher creation (use POST /journal-entries). Importing into a period that already has posted entries — SIE imports run on a fresh period.',
pitfalls: [
'Body content-type must be multipart/form-data with a `file` field carrying the .se / .sie file (or a JSON body with `file_base64` for agents that can\'t do multipart).',
'File size cap: 50 MB. Larger files require chunking client-side or a future streaming import endpoint.',
'Duplicate-file detection is by SHA-256 hash — re-importing the same file returns 409 SIE_IMPORT_DUPLICATE without re-running the import.',
'The operation can take 1–5 minutes for multi-year files. The HTTP response returns immediately with operation_id; poll /operations/{id} every ~2s for status.',
'BFL 7 kap räkenskapsinformation: once a SIE import completes, the resulting verifikationer are immutable. Cancellation midway is not supported.',
],
example: {
response: {
data: {
operation_id: 'op_a8f1…',
type: 'import.sie',
status: 'queued',
poll_url: '/api/v1/operations/op_a8f1…',
webhook_event: 'operation.completed',
},
meta: { request_id: 'req_…', api_version: '2026-05-12' },
},
},
scope: 'bookkeeping:write',
risk: 'high',
idempotent: true,
reversible: false,
dryRunSupported: false,
request: { contentType: 'multipart/form-data' },
response: { success: SieImportAccepted },
})
export const POST = withApiV1<{ params: Promise<{ companyId: string }> }>(
'imports.sie',
async (request, ctx) => {
// Parse multipart form
let formData: FormData
try {
formData = await request.formData()
} catch {
return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, {
requestId: ctx.requestId,
details: { field: 'body', message: 'Expected multipart/form-data with a `file` field.' },
})
}
const file = formData.get('file')
if (!(file instanceof File)) {
return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, {
requestId: ctx.requestId,
details: { field: 'file', message: 'Missing or invalid `file` field.' },
})
}
if (file.size > MAX_FILE_SIZE) {
return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, {
requestId: ctx.requestId,
details: {
field: 'file',
message: `File too large (${file.size} bytes). Max ${MAX_FILE_SIZE} bytes.`,
},
})
}
// Optional execution flags. Defaults mirror the dashboard's "import all"
// behavior. The schema is permissive — agents can omit and get sane
// defaults.
const optionsRaw = formData.get('options')
let parsedOptions: unknown = {}
if (typeof optionsRaw === 'string') {
try {
parsedOptions = JSON.parse(optionsRaw)
} catch (err) {
return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, {
requestId: ctx.requestId,
details: {
field: 'options',
message: `options must be a valid JSON string: ${err instanceof Error ? err.message : 'parse error'}`,
},
})
}
}
const optionsParse = z
.object({
createFiscalPeriod: z.boolean().optional().default(true),
importOpeningBalances: z.boolean().optional().default(true),
importTransactions: z.boolean().optional().default(true),
voucherSeries: z.string().min(1).max(2).optional().default('A'),
})
// OWASP V4.5: reject unknown keys so a future schema-extension
// (or a careless edit) doesn't silently pass mass-assigned fields
// through. Zod's default is to strip unknowns — `.strict()` is
// belt-and-suspenders.
.strict()
.safeParse(parsedOptions)
if (!optionsParse.success) {
return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, {
requestId: ctx.requestId,
details: {
issues: optionsParse.error.issues.map((i) => ({
field: i.path.join('.'),
message: i.message,
})),
},
})
}
const options = optionsParse.data
// Decode + parse + hash. These are all sync / fast — done before
// starting the operation row so a malformed file gets a 400 instead of
// a permanently-failed operation row.
const buffer = await file.arrayBuffer()
const encoding = detectEncoding(buffer)
const content = decodeBuffer(buffer, encoding)
const fileHash = await calculateFileHash(content)
// OWASP V5.2: cheap content-shape check before letting the SIE parser
// chew on arbitrary bytes. A valid SIE4 file's first 4 KiB contains at
// least one of #FLAGGA / #PROGRAM / #FORMAT / #SIETYP at the start
// of a line. The regex requires line-start anchoring so an HTML
// payload with `<!-- #FLAGGA -->` in a comment can't bypass — the
// round-3 string-contains check was tighter than no-check, but the
// regex is tighter still.
const headerSlice = content.slice(0, 4096)
if (!/(^|\n)\s*#(FLAGGA|PROGRAM|FORMAT|SIETYP)\b/.test(headerSlice)) {
return v1ErrorResponseFromCode('SIE_PARSE_FAILED', ctx.log, {
requestId: ctx.requestId,
details: {
reason: 'File does not appear to be SIE4 — no #FLAGGA / #PROGRAM / #FORMAT / #SIETYP header record at the start of a line in the first 4 KiB.',
},
})
}
let parsed: Awaited<ReturnType<typeof parseSIEFile>>
try {
parsed = parseSIEFile(content)
} catch (err) {
ctx.log.error('SIE parse failed', err as Error)
return v1ErrorResponseFromCode('SIE_PARSE_FAILED', ctx.log, {
requestId: ctx.requestId,
details: { reason: err instanceof Error ? err.message : 'unknown' },
})
}
// Duplicate-file check before starting the operation. Log the
// existing import id + timestamp server-side for operator forensics
// (CC7.2 audit trail), but do NOT echo them in the response body —
// symmetry with the bank IDOR fix. The agent learns "this file is
// already imported" via the error code; the server log carries the
// context for debugging.
const dup = await checkDuplicateImport(ctx.supabase, ctx.companyId!, content)
if (dup) {
ctx.log.info('SIE duplicate import rejected', {
fileHash,
existingImportId: dup.id,
existingImportedAt: dup.imported_at,
})
return v1ErrorResponseFromCode('SIE_IMPORT_DUPLICATE', ctx.log, {
requestId: ctx.requestId,
// Deliberately empty details. Server log has the forensic info.
})
}
// Start the operation row — caller polls /operations/{id} for status.
const op = await startOperation(
ctx.supabase,
{
companyId: ctx.companyId!,
userId: ctx.userId,
operationType: 'import.sie',
params: {
filename: file.name,
file_size: file.size,
encoding,
file_hash: fileHash,
voucher_count: parsed.vouchers?.length ?? 0,
},
},
ctx.log,
)
// Run import INLINE. Future worker can take this over.
try {
const result = await executeSIEImport(
ctx.supabase,
ctx.companyId!,
ctx.userId,
parsed,
[],
{
filename: file.name,
fileContent: content,
createFiscalPeriod: options.createFiscalPeriod,
importOpeningBalances: options.importOpeningBalances,
importTransactions: options.importTransactions,
voucherSeries: options.voucherSeries,
},
)
await completeOperation(ctx.supabase, { id: op.id, result }, ctx.log)
} catch (err) {
ctx.log.error('SIE import failed', err as Error, {
operationId: op.id,
filename: file.name,
fileHash,
})
await failOperation(
ctx.supabase,
{
id: op.id,
error: {
code: 'SIE_IMPORT_FAILED',
message: err instanceof Error ? err.message : 'Unknown failure during SIE import.',
},
},
ctx.log,
)
return v1ErrorResponseFromCode('SIE_IMPORT_FAILED', ctx.log, {
requestId: ctx.requestId,
details: { operation_id: op.id, reason: err instanceof Error ? err.message : 'unknown' },
})
}
return accepted(op.id, 'import.sie', { requestId: ctx.requestId })
},
)
@@ -0,0 +1,437 @@
/**
* Integration tests for the v1 reports + imports surface (Phase 5 PR-3).
*
* Most report routes are thin wrappers over `lib/reports/*` generators —
* the lib functions have their own unit tests, so these specs focus on
* the route-layer contract: auth / scope, period_id validation, the
* shared `loadPeriodFromQuery` helper, and the safeGenerate error path.
*
* Imports are tested for multipart parsing + operation-id response shape;
* the actual SIE / bank-file lib behavior is covered elsewhere.
*/
import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
beforeAll(() => {
if (process.env.NODE_ENV !== 'test') {
throw new Error(
`reports route tests require NODE_ENV=test (got ${process.env.NODE_ENV ?? 'undefined'})`,
)
}
process.env.NEXT_PUBLIC_SUPABASE_URL ||= 'http://localhost:54321'
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY ||= 'test-anon-key'
})
vi.mock('@/lib/auth/api-keys', async () => {
const actual = await vi.importActual<typeof import('@/lib/auth/api-keys')>('@/lib/auth/api-keys')
return {
...actual,
validateApiKey: vi.fn(),
createServiceClientNoCookies: vi.fn(),
}
})
vi.mock('@supabase/supabase-js', async () => {
const actual = await vi.importActual<typeof import('@supabase/supabase-js')>('@supabase/supabase-js')
return { ...actual, createClient: vi.fn().mockReturnValue({}) }
})
const mocks = vi.hoisted(() => ({
generateBalanceSheet: vi.fn(),
generateTrialBalance: vi.fn(),
generateIncomeStatement: vi.fn(),
generateSIEExport: vi.fn(),
calculateVatDeclaration: vi.fn(),
}))
vi.mock('@/lib/reports/balance-sheet', () => ({
generateBalanceSheet: mocks.generateBalanceSheet,
}))
vi.mock('@/lib/reports/trial-balance', () => ({
generateTrialBalance: mocks.generateTrialBalance,
}))
vi.mock('@/lib/reports/income-statement', () => ({
generateIncomeStatement: mocks.generateIncomeStatement,
}))
vi.mock('@/lib/reports/sie-export', () => ({
generateSIEExport: mocks.generateSIEExport,
}))
vi.mock('@/lib/reports/vat-declaration', () => ({
calculateVatDeclaration: mocks.calculateVatDeclaration,
}))
import { validateApiKey, createServiceClientNoCookies } from '@/lib/auth/api-keys'
import { GET as balanceSheet } from '../balance-sheet/route'
import { GET as trialBalance } from '../trial-balance/route'
import { GET as incomeStatement } from '../income-statement/route'
import { GET as sieExport } from '../sie-export/route'
import { GET as vatDeclaration } from '../vat-declaration/route'
const mockValidate = validateApiKey as ReturnType<typeof vi.fn>
const mockServiceClient = createServiceClientNoCookies as ReturnType<typeof vi.fn>
interface TableResp {
data?: unknown
error?: unknown
count?: number | null
}
function makeFlexibleSupabase(byTable: Record<string, TableResp | TableResp[]>) {
const queues = new Map<string, TableResp[]>()
for (const [t, val] of Object.entries(byTable)) {
queues.set(t, Array.isArray(val) ? [...val] : [val])
}
const buildChain = (table: string): unknown => {
const handler: ProxyHandler<object> = {
get(_t, prop) {
if (prop === 'then') {
return (resolve: (v: unknown) => void) => {
const q = queues.get(table)
const next = q && q.length > 1 ? q.shift()! : (q?.[0] ?? { data: null, error: null })
resolve(next)
}
}
return (..._args: unknown[]) => buildChain(table)
},
}
return new Proxy({}, handler)
}
return { from: vi.fn((table: string) => buildChain(table)) }
}
const COMPANY_ID = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa'
const PERIOD_ID = 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb'
function makeReq(url: string): Request {
return new Request(url, {
headers: { Authorization: 'Bearer test-fixture-not-a-real-key' },
})
}
function companyParams(companyId: string) {
return { params: Promise.resolve({ companyId }) }
}
beforeEach(() => {
vi.clearAllMocks()
mockValidate.mockResolvedValue({
userId: 'user-1',
companyId: COMPANY_ID,
apiKeyId: 'ak_1',
apiKeyName: 'CI key',
scopes: ['reports:read'],
mode: 'live',
})
})
describe('GET /reports/trial-balance', () => {
it('returns 400 VALIDATION_ERROR when period_id is missing', async () => {
mockServiceClient.mockReturnValue(
makeFlexibleSupabase({
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
}),
)
const res = await trialBalance(
makeReq(`https://x.test/api/v1/companies/${COMPANY_ID}/reports/trial-balance`),
companyParams(COMPANY_ID),
)
expect(res.status).toBe(400)
const body = await res.json()
expect(body.error.code).toBe('VALIDATION_ERROR')
expect(body.error.details.field).toBe('period_id')
expect(mocks.generateTrialBalance).not.toHaveBeenCalled()
})
it('returns 400 VALIDATION_ERROR when period_id is not a UUID', async () => {
mockServiceClient.mockReturnValue(
makeFlexibleSupabase({
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
}),
)
const res = await trialBalance(
makeReq(
`https://x.test/api/v1/companies/${COMPANY_ID}/reports/trial-balance?period_id=not-a-uuid`,
),
companyParams(COMPANY_ID),
)
expect(res.status).toBe(400)
expect(mocks.generateTrialBalance).not.toHaveBeenCalled()
})
it('returns 404 NOT_FOUND when the period belongs to another company', async () => {
mockServiceClient.mockReturnValue(
makeFlexibleSupabase({
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
fiscal_periods: { data: null, error: null },
}),
)
const res = await trialBalance(
makeReq(
`https://x.test/api/v1/companies/${COMPANY_ID}/reports/trial-balance?period_id=${PERIOD_ID}`,
),
companyParams(COMPANY_ID),
)
expect(res.status).toBe(404)
expect(mocks.generateTrialBalance).not.toHaveBeenCalled()
})
it('returns the trial-balance from the generator on the happy path', async () => {
mockServiceClient.mockReturnValue(
makeFlexibleSupabase({
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
fiscal_periods: {
data: {
id: PERIOD_ID,
period_start: '2026-01-01',
period_end: '2026-12-31',
is_closed: false,
locked_at: null,
},
error: null,
},
}),
)
mocks.generateTrialBalance.mockResolvedValue({
rows: [],
totalDebit: 0,
totalCredit: 0,
isBalanced: true,
})
const res = await trialBalance(
makeReq(
`https://x.test/api/v1/companies/${COMPANY_ID}/reports/trial-balance?period_id=${PERIOD_ID}`,
),
companyParams(COMPANY_ID),
)
expect(res.status).toBe(200)
const body = await res.json()
expect(body.data.isBalanced).toBe(true)
expect(mocks.generateTrialBalance).toHaveBeenCalledOnce()
})
it('surfaces REPORT_GENERATION_FAILED when the lib throws', async () => {
mockServiceClient.mockReturnValue(
makeFlexibleSupabase({
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
fiscal_periods: {
data: {
id: PERIOD_ID,
period_start: '2026-01-01',
period_end: '2026-12-31',
is_closed: false,
locked_at: null,
},
error: null,
},
}),
)
mocks.generateTrialBalance.mockRejectedValue(new Error('lib crash'))
const res = await trialBalance(
makeReq(
`https://x.test/api/v1/companies/${COMPANY_ID}/reports/trial-balance?period_id=${PERIOD_ID}`,
),
companyParams(COMPANY_ID),
)
expect(res.status).toBe(500)
const body = await res.json()
expect(body.error.code).toBe('REPORT_GENERATION_FAILED')
})
it('rejects keys without reports:read scope', async () => {
mockValidate.mockResolvedValue({
userId: 'user-1',
companyId: COMPANY_ID,
apiKeyId: 'ak_1',
apiKeyName: 'wrong scope',
scopes: ['invoices:read'],
mode: 'live',
})
mockServiceClient.mockReturnValue(makeFlexibleSupabase({}))
const res = await trialBalance(
makeReq(
`https://x.test/api/v1/companies/${COMPANY_ID}/reports/trial-balance?period_id=${PERIOD_ID}`,
),
companyParams(COMPANY_ID),
)
expect(res.status).toBe(403)
})
})
describe('GET /reports/balance-sheet', () => {
it('enriches the generator result with period dates', async () => {
mockServiceClient.mockReturnValue(
makeFlexibleSupabase({
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
fiscal_periods: {
data: {
id: PERIOD_ID,
period_start: '2026-01-01',
period_end: '2026-12-31',
is_closed: false,
locked_at: null,
},
error: null,
},
}),
)
mocks.generateBalanceSheet.mockResolvedValue({ sections: [], totals: {} })
const res = await balanceSheet(
makeReq(
`https://x.test/api/v1/companies/${COMPANY_ID}/reports/balance-sheet?period_id=${PERIOD_ID}`,
),
companyParams(COMPANY_ID),
)
expect(res.status).toBe(200)
const body = await res.json()
expect(body.data.period).toEqual({ start: '2026-01-01', end: '2026-12-31' })
})
})
describe('GET /reports/income-statement', () => {
it('enriches the generator result with period dates', async () => {
mockServiceClient.mockReturnValue(
makeFlexibleSupabase({
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
fiscal_periods: {
data: {
id: PERIOD_ID,
period_start: '2026-01-01',
period_end: '2026-12-31',
is_closed: false,
locked_at: null,
},
error: null,
},
}),
)
mocks.generateIncomeStatement.mockResolvedValue({ sections: [], grossMargin: 0, netResult: 0 })
const res = await incomeStatement(
makeReq(
`https://x.test/api/v1/companies/${COMPANY_ID}/reports/income-statement?period_id=${PERIOD_ID}`,
),
companyParams(COMPANY_ID),
)
expect(res.status).toBe(200)
const body = await res.json()
expect(body.data.period).toEqual({ start: '2026-01-01', end: '2026-12-31' })
})
})
describe('GET /reports/sie-export', () => {
it('returns the SIE content with text/plain Content-Type + attachment Content-Disposition', async () => {
mockServiceClient.mockReturnValue(
makeFlexibleSupabase({
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
fiscal_periods: {
data: {
id: PERIOD_ID,
period_start: '2026-01-01',
period_end: '2026-12-31',
is_closed: false,
locked_at: null,
},
error: null,
},
company_settings: {
data: { company_name: 'Test AB', org_number: '5566778899' },
error: null,
},
}),
)
mocks.generateSIEExport.mockResolvedValue('#FLAGGA 0\n#PROGRAM gnubok\n')
const res = await sieExport(
makeReq(
`https://x.test/api/v1/companies/${COMPANY_ID}/reports/sie-export?period_id=${PERIOD_ID}`,
),
companyParams(COMPANY_ID),
)
expect(res.status).toBe(200)
expect(res.headers.get('Content-Type')).toMatch(/text\/plain/)
expect(res.headers.get('Content-Disposition')).toMatch(/attachment.*\.se/)
const body = await res.text()
expect(body).toContain('#FLAGGA')
})
})
describe('GET /reports/vat-declaration', () => {
it('rejects missing required period_type/year/period', async () => {
mockServiceClient.mockReturnValue(
makeFlexibleSupabase({
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
}),
)
const res = await vatDeclaration(
makeReq(`https://x.test/api/v1/companies/${COMPANY_ID}/reports/vat-declaration`),
companyParams(COMPANY_ID),
)
expect(res.status).toBe(400)
const body = await res.json()
expect(body.error.code).toBe('VALIDATION_ERROR')
expect(mocks.calculateVatDeclaration).not.toHaveBeenCalled()
})
it('rejects out-of-range period_type', async () => {
mockServiceClient.mockReturnValue(
makeFlexibleSupabase({
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
}),
)
const res = await vatDeclaration(
makeReq(
`https://x.test/api/v1/companies/${COMPANY_ID}/reports/vat-declaration?period_type=biennial&year=2026&period=1`,
),
companyParams(COMPANY_ID),
)
expect(res.status).toBe(400)
})
it('passes through to calculateVatDeclaration on the happy path', async () => {
mockServiceClient.mockReturnValue(
makeFlexibleSupabase({
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
}),
)
mocks.calculateVatDeclaration.mockResolvedValue({ rutor: { ruta49: 0 } })
const res = await vatDeclaration(
makeReq(
`https://x.test/api/v1/companies/${COMPANY_ID}/reports/vat-declaration?period_type=monthly&year=2026&period=4`,
),
companyParams(COMPANY_ID),
)
expect(res.status).toBe(200)
const body = await res.json()
expect(body.data.rutor.ruta49).toBe(0)
expect(mocks.calculateVatDeclaration).toHaveBeenCalledWith(
expect.anything(),
COMPANY_ID,
'monthly',
2026,
4,
undefined,
)
})
})
@@ -0,0 +1,93 @@
/**
* GET /api/v1/companies/{companyId}/reports/ar-ledger
*
* Accounts receivable ledger (kundreskontra) — unpaid customer invoices
* grouped by customer with aging buckets.
*/
import { z } from 'zod'
import { ok } from '@/lib/api/v1/response'
import { registerEndpoint } from '@/lib/api/v1/registry'
import { withApiV1 } from '@/lib/api/v1/with-api-v1'
import { v1ErrorResponseFromCode } from '@/lib/api/v1/errors'
import { safeGenerate } from '@/lib/api/v1/report-period'
import { generateARLedger } from '@/lib/reports/ar-ledger'
registerEndpoint({
operation: 'reports.ar-ledger',
method: 'GET',
path: '/api/v1/companies/:companyId/reports/ar-ledger',
summary: 'AR ledger — unpaid customer invoices with aging.',
description:
'Returns the customer-receivable ledger as of `as_of_date` (defaults to today). Each customer entry includes outstanding invoices grouped into aging buckets (0–30, 31–60, 61–90, 90+ days). Reconciles against BAS 1510.',
useWhen:
'Cash collection dashboards, dunning workflows, end-of-period reconciliation against the 1510 trial-balance figure.',
doNotUseFor:
'Listing all invoices regardless of status (use /invoices). Sending dunning emails (the v1 surface does not yet expose dunning).',
pitfalls: [
'`as_of_date` is optional; format `YYYY-MM-DD`. Defaults to today (UTC).',
'Only invoices in `sent`/`overdue`/`partially_paid` status appear. Drafts and credited invoices are excluded.',
],
example: {
response: {
data: { as_of_date: '2026-05-31', customers: [], totals: {} },
meta: { request_id: 'req_…', api_version: '2026-05-12' },
},
},
scope: 'reports:read',
risk: 'low',
idempotent: true,
reversible: false,
dryRunSupported: false,
response: { success: z.unknown() },
})
export const GET = withApiV1<{ params: Promise<{ companyId: string }> }>(
'reports.ar-ledger',
async (request, ctx) => {
const url = new URL(request.url)
const asOfDate = url.searchParams.get('as_of_date') || undefined
// The regex shape AND the calendar validity. A pure regex accepts
// 2026-13-45; the Date round-trip catches that.
if (asOfDate) {
if (!/^\d{4}-\d{2}-\d{2}$/.test(asOfDate)) {
return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, {
requestId: ctx.requestId,
details: { field: 'as_of_date', message: 'Expected YYYY-MM-DD.' },
})
}
const probe = new Date(`${asOfDate}T00:00:00Z`)
if (Number.isNaN(probe.getTime()) || probe.toISOString().slice(0, 10) !== asOfDate) {
return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, {
requestId: ctx.requestId,
details: { field: 'as_of_date', message: 'Not a valid calendar date.' },
})
}
// Sanity range: year 2000 → current+1. Outside this window is
// either a typo or a resource-abuse probe (an as_of_date in year
// 9999 would still parse but the report generator may walk
// arbitrary-large invoice histories). The +1 tolerance allows a
// year-end filing for the year that just turned over without
// refusing on Jan 1.
const year = probe.getUTCFullYear()
const maxYear = new Date().getUTCFullYear() + 1
if (year < 2000 || year > maxYear) {
return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, {
requestId: ctx.requestId,
details: {
field: 'as_of_date',
message: `Year out of supported range. Accepted: 2000 to ${maxYear}.`,
},
})
}
}
const gen = await safeGenerate(
() => generateARLedger(ctx.supabase, ctx.companyId!, asOfDate),
{ log: ctx.log, requestId: ctx.requestId, reportName: 'ar-ledger' },
)
if (!gen.ok) return gen.response
return ok(gen.result, { requestId: ctx.requestId })
},
)
@@ -0,0 +1,65 @@
/**
* GET /api/v1/companies/{companyId}/reports/avgifter-basis
*
* Annual arbetsgivaravgifter basis per employee — feeds the AGI HU
* verification.
*/
import { z } from 'zod'
import { ok } from '@/lib/api/v1/response'
import { registerEndpoint } from '@/lib/api/v1/registry'
import { withApiV1 } from '@/lib/api/v1/with-api-v1'
import { v1ErrorResponseFromCode } from '@/lib/api/v1/errors'
import { safeGenerate } from '@/lib/api/v1/report-period'
import { generateAvgifterBasis } from '@/lib/reports/avgifter-basis'
registerEndpoint({
operation: 'reports.avgifter-basis',
method: 'GET',
path: '/api/v1/companies/:companyId/reports/avgifter-basis',
summary: 'Annual arbetsgivaravgifter basis per employee.',
description:
'Returns the annual avgifter basis per employee for `year`, summed across booked salary runs. Each row shows the basis, applied rate, and computed avgifter amount — useful for reconciling against monthly AGI filings (HU sum across the year).',
useWhen:
'Annual reconciliation between the AGI declarations and the bookkeeping (BAS 7510). Year-end audit prep.',
doNotUseFor:
'Real-time AGI generation (POST /salary-runs/{id}/generate-agi). Per-run breakdown (use /reports/salary-journal).',
pitfalls: [
'`year` is required.',
'Only `booked` runs are included.',
],
example: {
response: {
data: { year: 2026, employees: [], totals: {} },
meta: { request_id: 'req_…', api_version: '2026-05-12' },
},
},
scope: 'payroll:read',
risk: 'low',
idempotent: true,
reversible: false,
dryRunSupported: false,
response: { success: z.unknown() },
})
export const GET = withApiV1<{ params: Promise<{ companyId: string }> }>(
'reports.avgifter-basis',
async (request, ctx) => {
const url = new URL(request.url)
const yearParse = z.coerce.number().int().min(2020).max(2100).safeParse(url.searchParams.get('year'))
if (!yearParse.success) {
return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, {
requestId: ctx.requestId,
details: { field: 'year', message: 'year query parameter is required (integer 2020-2100).' },
})
}
const gen = await safeGenerate(
() => generateAvgifterBasis(ctx.supabase, ctx.companyId!, yearParse.data),
{ log: ctx.log, requestId: ctx.requestId, reportName: 'avgifter-basis' },
)
if (!gen.ok) return gen.response
return ok(gen.result, { requestId: ctx.requestId })
},
)
@@ -0,0 +1,82 @@
/**
* GET /api/v1/companies/{companyId}/reports/balance-sheet
*
* Returns the balansrapport for a fiscal period — assets / liabilities /
* equity broken into sections per BAS class. Mirrors the dashboard
* generator (`lib/reports/balance-sheet.ts`).
*/
import { z } from 'zod'
import { ok } from '@/lib/api/v1/response'
import { registerEndpoint } from '@/lib/api/v1/registry'
import { withApiV1 } from '@/lib/api/v1/with-api-v1'
import { loadPeriodFromQuery, safeGenerate } from '@/lib/api/v1/report-period'
import { generateBalanceSheet } from '@/lib/reports/balance-sheet'
// Use z.unknown for the rich nested shape — the lib types are stable and
// callers consume via `data.sections[…]`. Strict Zod schemas here would
// require importing every BAS-section type, which adds maintenance with no
// runtime benefit (the server is the source of truth, not the agent).
const BalanceSheetResponse = z.unknown()
registerEndpoint({
operation: 'reports.balance-sheet',
method: 'GET',
path: '/api/v1/companies/:companyId/reports/balance-sheet',
summary: 'Balance sheet (balansräkning) for a fiscal period.',
description:
'Returns assets / liabilities / equity grouped into BAS sections, with the period\'s opening and closing balances. Sums match the income statement for the same period; the closing equity flows into next period\'s opening balance.',
useWhen:
'You need the company\'s balance position at period end — typically for management reporting, year-end review, or the K2/K3 årsredovisning uppställningsform.',
doNotUseFor:
'Per-account drill-down (use /reports/general-ledger). Net result for the period (use /reports/income-statement).',
pitfalls: [
'`period_id` is required.',
'Balance sheet equity includes the period\'s computed result — recalculation happens on every call, so a freshly-posted entry is reflected immediately (no caching).',
],
example: {
response: {
data: {
period: { start: '2026-01-01', end: '2026-12-31' },
sections: [],
totals: {},
},
meta: { request_id: 'req_…', api_version: '2026-05-12' },
},
},
scope: 'reports:read',
risk: 'low',
idempotent: true,
reversible: false,
dryRunSupported: false,
response: { success: BalanceSheetResponse },
})
export const GET = withApiV1<{ params: Promise<{ companyId: string }> }>(
'reports.balance-sheet',
async (request, ctx) => {
const period = await loadPeriodFromQuery(request, {
supabase: ctx.supabase,
companyId: ctx.companyId!,
requestId: ctx.requestId,
log: ctx.log,
})
if (!period.ok) return period.response
const gen = await safeGenerate(
() => generateBalanceSheet(ctx.supabase, ctx.companyId!, period.period.id),
{ log: ctx.log, requestId: ctx.requestId, reportName: 'balance-sheet' },
)
if (!gen.ok) return gen.response
// The dashboard route enriches the result with the period dates; mirror.
// The cast through `unknown` is the standard pattern for adding an
// ad-hoc field to a structurally-typed lib return (BalanceSheetReport
// doesn't formally include `period`, but the dashboard's behavior
// attaches it).
const result = gen.result as unknown as Record<string, unknown>
result.period = { start: period.period.period_start, end: period.period.period_end }
return ok(result, { requestId: ctx.requestId })
},
)
@@ -0,0 +1,67 @@
/**
* GET /api/v1/companies/{companyId}/reports/continuity-check
*
* IB/UB continuity check — verifies that the period's opening balances
* match the previous period's closing balances per account. The legal
* basis is the general löpande bokföring obligation in BFL 5 kap +
* BFNAR 2013:2 systemdokumentation/behandlingshistorik, AND the SIE4
* spec's core invariant that #IB(year N) must equal #UB(year N-1).
* (Not BFL 5 kap 7 § — that section covers rättelse, a separate rule.)
*/
import { z } from 'zod'
import { ok } from '@/lib/api/v1/response'
import { registerEndpoint } from '@/lib/api/v1/registry'
import { withApiV1 } from '@/lib/api/v1/with-api-v1'
import { loadPeriodFromQuery, safeGenerate } from '@/lib/api/v1/report-period'
import { validateBalanceContinuity } from '@/lib/reports/continuity-check'
registerEndpoint({
operation: 'reports.continuity-check',
method: 'GET',
path: '/api/v1/companies/:companyId/reports/continuity-check',
summary: 'IB/UB continuity check — opening balances match prior closing.',
description:
'Validates that the target period\'s opening balances (IB) equal the prior period\'s closing balances (UB). The requirement derives from BFL 5 kap (löpande bokföring), BFNAR 2013:2 (systemdokumentation/behandlingshistorik), and the SIE4 spec\'s core invariant that #IB(year N) must equal #UB(year N-1). Returns per-account discrepancies so an operator can rectify them before period close.',
useWhen:
'Before locking or closing a period, or as part of an automated year-end readiness gate. Any discrepancy is a hard data-integrity issue.',
doNotUseFor:
'Computing balances (use /reports/balance-sheet or /reports/trial-balance). Closing the period (POST /fiscal-periods/{id}/close).',
pitfalls: [
'`period_id` is required.',
'A non-zero discrepancy means IB ≠ prior UB and indicates the opening-balance entry was edited or the prior period was changed after close. Investigate before posting any new entries.',
],
example: {
response: {
data: { is_continuous: true, discrepancies: [] },
meta: { request_id: 'req_…', api_version: '2026-05-12' },
},
},
scope: 'reports:read',
risk: 'low',
idempotent: true,
reversible: false,
dryRunSupported: false,
response: { success: z.unknown() },
})
export const GET = withApiV1<{ params: Promise<{ companyId: string }> }>(
'reports.continuity-check',
async (request, ctx) => {
const period = await loadPeriodFromQuery(request, {
supabase: ctx.supabase,
companyId: ctx.companyId!,
requestId: ctx.requestId,
log: ctx.log,
})
if (!period.ok) return period.response
const gen = await safeGenerate(
() => validateBalanceContinuity(ctx.supabase, ctx.companyId!, period.period.id),
{ log: ctx.log, requestId: ctx.requestId, reportName: 'continuity-check' },
)
if (!gen.ok) return gen.response
return ok(gen.result, { requestId: ctx.requestId })
},
)
@@ -0,0 +1,97 @@
/**
* GET /api/v1/companies/{companyId}/reports/general-ledger
*
* Per-account journal-line ledger (huvudbok). Returns every posted line in
* the period grouped by account, with running balances. Accepts
* `account_from`/`account_to` to drill into a range.
*/
import { z } from 'zod'
import { ok } from '@/lib/api/v1/response'
import { registerEndpoint } from '@/lib/api/v1/registry'
import { withApiV1 } from '@/lib/api/v1/with-api-v1'
import { loadPeriodFromQuery, safeGenerate } from '@/lib/api/v1/report-period'
import { v1ErrorResponseFromCode } from '@/lib/api/v1/errors'
import { generateGeneralLedger } from '@/lib/reports/general-ledger'
const GeneralLedgerResponse = z.unknown()
registerEndpoint({
operation: 'reports.general-ledger',
method: 'GET',
path: '/api/v1/companies/:companyId/reports/general-ledger',
summary: 'General ledger (huvudbok) for a fiscal period.',
description:
'Returns every posted journal line in the period grouped by account, with opening / running / closing balances. Supports optional `account_from` and `account_to` query parameters to limit the report to an account range (e.g. ?account_from=3000&account_to=3999 for revenue-only).',
useWhen:
'You\'re reconciling a specific account or range — bank account drilldown, revenue audit, expense investigation — and need every voucher-line that hit the account.',
doNotUseFor:
'Period totals only (use /reports/trial-balance). Specific transaction lookup (use /journal-entries/{id}).',
pitfalls: [
'`period_id` is required.',
'Account ranges are inclusive on both bounds. `account_from=3000` includes 3000; `account_to=3999` includes 3999.',
'Lines with `status != \'posted\'` (drafts, reversed) are excluded.',
],
example: {
response: {
data: { period: {}, accounts: [] },
meta: { request_id: 'req_…', api_version: '2026-05-12' },
},
},
scope: 'reports:read',
risk: 'low',
idempotent: true,
reversible: false,
dryRunSupported: false,
response: { success: GeneralLedgerResponse },
})
export const GET = withApiV1<{ params: Promise<{ companyId: string }> }>(
'reports.general-ledger',
async (request, ctx) => {
const url = new URL(request.url)
const accountFrom = url.searchParams.get('account_from') || undefined
const accountTo = url.searchParams.get('account_to') || undefined
// BAS account numbers are 4 digits today but extensible to 5 / 6 in
// sub-account schemes (kostställen). Pattern allows 3–8 to leave room
// without accepting arbitrary strings. OWASP V2.2 — bound the values
// before they reach the report generator's downstream queries.
const accountRe = /^\d{3,8}$/
if (accountFrom && !accountRe.test(accountFrom)) {
return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, {
requestId: ctx.requestId,
details: { field: 'account_from', message: 'Expected 3-8 digit account number.' },
})
}
if (accountTo && !accountRe.test(accountTo)) {
return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, {
requestId: ctx.requestId,
details: { field: 'account_to', message: 'Expected 3-8 digit account number.' },
})
}
const period = await loadPeriodFromQuery(request, {
supabase: ctx.supabase,
companyId: ctx.companyId!,
requestId: ctx.requestId,
log: ctx.log,
})
if (!period.ok) return period.response
const gen = await safeGenerate(
() =>
generateGeneralLedger(
ctx.supabase,
ctx.companyId!,
period.period.id,
accountFrom,
accountTo,
),
{ log: ctx.log, requestId: ctx.requestId, reportName: 'general-ledger' },
)
if (!gen.ok) return gen.response
return ok(gen.result, { requestId: ctx.requestId })
},
)
@@ -0,0 +1,69 @@
/**
* GET /api/v1/companies/{companyId}/reports/income-statement
*
* Returns the resultatrapport for a fiscal period — revenue / cost of
* goods / operating expenses / financial items, ending in the net result.
* Same generator as the dashboard.
*/
import { z } from 'zod'
import { ok } from '@/lib/api/v1/response'
import { registerEndpoint } from '@/lib/api/v1/registry'
import { withApiV1 } from '@/lib/api/v1/with-api-v1'
import { loadPeriodFromQuery, safeGenerate } from '@/lib/api/v1/report-period'
import { generateIncomeStatement } from '@/lib/reports/income-statement'
const IncomeStatementResponse = z.unknown()
registerEndpoint({
operation: 'reports.income-statement',
method: 'GET',
path: '/api/v1/companies/:companyId/reports/income-statement',
summary: 'Income statement (resultatrapport) for a fiscal period.',
description:
'Returns the period\'s revenue and expenses grouped by BAS class with subtotals (gross margin, operating result, net result). The net result flows into the balance-sheet equity for the same period.',
useWhen:
'You need the company\'s profit/loss for a period — month-end management reporting, K2/K3 årsredovisning resultaträkning, or feeding KPI dashboards.',
doNotUseFor:
'Per-account drill (use /reports/general-ledger). VAT figures (use /reports/vat-declaration). Balance position (use /reports/balance-sheet).',
pitfalls: [
'`period_id` is required.',
'Net result on the income statement equals the period\'s equity-line delta on the balance sheet — they\'re derived from the same posted entries.',
],
example: {
response: {
data: { period: { start: '…', end: '…' }, sections: [], grossMargin: 0, netResult: 0 },
meta: { request_id: 'req_…', api_version: '2026-05-12' },
},
},
scope: 'reports:read',
risk: 'low',
idempotent: true,
reversible: false,
dryRunSupported: false,
response: { success: IncomeStatementResponse },
})
export const GET = withApiV1<{ params: Promise<{ companyId: string }> }>(
'reports.income-statement',
async (request, ctx) => {
const period = await loadPeriodFromQuery(request, {
supabase: ctx.supabase,
companyId: ctx.companyId!,
requestId: ctx.requestId,
log: ctx.log,
})
if (!period.ok) return period.response
const gen = await safeGenerate(
() => generateIncomeStatement(ctx.supabase, ctx.companyId!, period.period.id),
{ log: ctx.log, requestId: ctx.requestId, reportName: 'income-statement' },
)
if (!gen.ok) return gen.response
const result = gen.result as unknown as Record<string, unknown>
result.period = { start: period.period.period_start, end: period.period.period_end }
return ok(result, { requestId: ctx.requestId })
},
)
@@ -0,0 +1,64 @@
/**
* GET /api/v1/companies/{companyId}/reports/journal-register
*
* The verifikationsregister — every committed journal entry in the period
* with all its lines. Mirrors the dashboard generator.
*/
import { z } from 'zod'
import { ok } from '@/lib/api/v1/response'
import { registerEndpoint } from '@/lib/api/v1/registry'
import { withApiV1 } from '@/lib/api/v1/with-api-v1'
import { loadPeriodFromQuery, safeGenerate } from '@/lib/api/v1/report-period'
import { generateJournalRegister } from '@/lib/reports/journal-register'
registerEndpoint({
operation: 'reports.journal-register',
method: 'GET',
path: '/api/v1/companies/:companyId/reports/journal-register',
summary: 'Journal register (verifikationsregister) for a fiscal period.',
description:
'Returns every committed journal entry in the period with its voucher number, date, description, and complete debit/credit line set. The canonical compliance report — what an accountant or Skatteverket audit would pull as proof of every booking.',
useWhen:
'You need the BFL-required register of all verifikationer for a period — typically for an audit, year-end review, or feeding an external accountant\'s tooling.',
doNotUseFor:
'Per-account drilldown (use /reports/general-ledger). Aggregate totals only (use /reports/trial-balance).',
pitfalls: [
'`period_id` is required.',
'Output includes every line of every entry — large periods produce large responses. Consider paginating client-side or filtering by date range via /journal-entries list if you only need a slice.',
'Reversed entries appear with status `reversed`; the original they reversed also remains.',
],
example: {
response: {
data: { period: {}, entries: [] },
meta: { request_id: 'req_…', api_version: '2026-05-12' },
},
},
scope: 'reports:read',
risk: 'low',
idempotent: true,
reversible: false,
dryRunSupported: false,
response: { success: z.unknown() },
})
export const GET = withApiV1<{ params: Promise<{ companyId: string }> }>(
'reports.journal-register',
async (request, ctx) => {
const period = await loadPeriodFromQuery(request, {
supabase: ctx.supabase,
companyId: ctx.companyId!,
requestId: ctx.requestId,
log: ctx.log,
})
if (!period.ok) return period.response
const gen = await safeGenerate(
() => generateJournalRegister(ctx.supabase, ctx.companyId!, period.period.id),
{ log: ctx.log, requestId: ctx.requestId, reportName: 'journal-register' },
)
if (!gen.ok) return gen.response
return ok(gen.result, { requestId: ctx.requestId })
},
)
@@ -0,0 +1,61 @@
/**
* GET /api/v1/companies/{companyId}/reports/monthly-breakdown
*
* Income-statement-by-month for a fiscal period. Useful for cash-flow
* narratives, trend dashboards, and the K2/K3 årsredovisning explanatory
* notes.
*/
import { z } from 'zod'
import { ok } from '@/lib/api/v1/response'
import { registerEndpoint } from '@/lib/api/v1/registry'
import { withApiV1 } from '@/lib/api/v1/with-api-v1'
import { loadPeriodFromQuery, safeGenerate } from '@/lib/api/v1/report-period'
import { generateMonthlyBreakdown } from '@/lib/reports/monthly-breakdown'
registerEndpoint({
operation: 'reports.monthly-breakdown',
method: 'GET',
path: '/api/v1/companies/:companyId/reports/monthly-breakdown',
summary: 'Income statement broken down by month for a fiscal period.',
description:
'Returns revenue + expenses + net result per calendar month inside the fiscal period. The sum across all months equals the period\'s full income-statement totals.',
useWhen:
'Building a trend chart, computing rolling KPIs, or producing a månadsrapport for management.',
doNotUseFor:
'Single-month snapshot only (call /reports/income-statement with a month-sized period). Cash flow analysis (a dedicated cash-flow report is not yet on v1).',
pitfalls: ['`period_id` is required.'],
example: {
response: {
data: { period: {}, months: [] },
meta: { request_id: 'req_…', api_version: '2026-05-12' },
},
},
scope: 'reports:read',
risk: 'low',
idempotent: true,
reversible: false,
dryRunSupported: false,
response: { success: z.unknown() },
})
export const GET = withApiV1<{ params: Promise<{ companyId: string }> }>(
'reports.monthly-breakdown',
async (request, ctx) => {
const period = await loadPeriodFromQuery(request, {
supabase: ctx.supabase,
companyId: ctx.companyId!,
requestId: ctx.requestId,
log: ctx.log,
})
if (!period.ok) return period.response
const gen = await safeGenerate(
() => generateMonthlyBreakdown(ctx.supabase, ctx.companyId!, period.period.id),
{ log: ctx.log, requestId: ctx.requestId, reportName: 'monthly-breakdown' },
)
if (!gen.ok) return gen.response
return ok(gen.result, { requestId: ctx.requestId })
},
)
@@ -0,0 +1,89 @@
/**
* GET /api/v1/companies/{companyId}/reports/salary-journal
*
* Per-employee salary journal — annual or monthly window. The lönejournal
* report.
*/
import { z } from 'zod'
import { ok } from '@/lib/api/v1/response'
import { registerEndpoint } from '@/lib/api/v1/registry'
import { withApiV1 } from '@/lib/api/v1/with-api-v1'
import { v1ErrorResponseFromCode } from '@/lib/api/v1/errors'
import { safeGenerate } from '@/lib/api/v1/report-period'
import { generateSalaryJournal } from '@/lib/reports/salary-journal'
registerEndpoint({
operation: 'reports.salary-journal',
method: 'GET',
path: '/api/v1/companies/:companyId/reports/salary-journal',
summary: 'Salary journal (lönejournal) for a year and optional month range.',
description:
'Returns per-employee salary figures (gross / tax / net / avgifter / vacation accrual) summed across booked salary runs in `year`. Optional `month_from` and `month_to` limit the window. The output mirrors the dashboard\'s lönejournal export. ⚠️ KU (kontrolluppgift) preparation requires the FULL annual paid amount per employee — if any salary runs are in paid-but-unbooked state at KU time, generating KU from this report will understate wages (an SFL obligation breach). Confirm all paid runs are booked before using this report for KU.',
useWhen:
'Year-end KU preparation, employee comp reviews, reconciliation against the 7xxx wage accounts.',
doNotUseFor:
'Per-run drill-down (use /salary-runs/{id} once the per-employee endpoint ships). AGI declarations (POST /salary-runs/{id}/generate-agi).',
pitfalls: [
'`year` is required (integer 2020-2100).',
'Only `booked` salary runs are included — `draft`/`review`/`approved`/`paid` runs are excluded as they aren\'t legally final.',
'`paid`-but-unbooked runs are EXCLUDED. This means the report reconciles cleanly against BAS 7xxx (the ledger), but an AGI-vs-ledger cross-check will show a gap until the run is booked. The AGI is filed at `approved`/`paid` (Phase 5 PR-2 allows it from `review`), so reconciling AGI against this report requires waiting until every paid run is also booked.',
'month_from/month_to are 1–12 inclusive.',
],
example: {
response: {
data: { year: 2026, employees: [], totals: {} },
meta: { request_id: 'req_…', api_version: '2026-05-12' },
},
},
scope: 'payroll:read',
risk: 'low',
idempotent: true,
reversible: false,
dryRunSupported: false,
response: { success: z.unknown() },
})
export const GET = withApiV1<{ params: Promise<{ companyId: string }> }>(
'reports.salary-journal',
async (request, ctx) => {
const url = new URL(request.url)
const yearStr = url.searchParams.get('year')
const monthFromStr = url.searchParams.get('month_from')
const monthToStr = url.searchParams.get('month_to')
const yearParse = z.coerce.number().int().min(2020).max(2100).safeParse(yearStr)
if (!yearParse.success) {
return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, {
requestId: ctx.requestId,
details: { field: 'year', message: 'year query parameter is required (integer 2020-2100).' },
})
}
const year = yearParse.data
const month = z.coerce.number().int().min(1).max(12)
const monthFrom = monthFromStr ? month.safeParse(monthFromStr) : null
const monthTo = monthToStr ? month.safeParse(monthToStr) : null
if ((monthFrom && !monthFrom.success) || (monthTo && !monthTo.success)) {
return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, {
requestId: ctx.requestId,
details: { field: 'month_from/month_to', message: 'Expected integer 1-12.' },
})
}
const gen = await safeGenerate(
() =>
generateSalaryJournal(
ctx.supabase,
ctx.companyId!,
year,
monthFrom?.data,
monthTo?.data,
),
{ log: ctx.log, requestId: ctx.requestId, reportName: 'salary-journal' },
)
if (!gen.ok) return gen.response
return ok(gen.result, { requestId: ctx.requestId })
},
)
@@ -0,0 +1,94 @@
/**
* GET /api/v1/companies/{companyId}/reports/sie-export
*
* Generates a SIE4 export (text/plain, .se file) for the given fiscal
* period. Returns the SIE content with Content-Disposition: attachment so
* agents can save it directly. Mirrors the dashboard generator.
*/
import { z } from 'zod'
import { NextResponse } from 'next/server'
import { registerEndpoint } from '@/lib/api/v1/registry'
import { withApiV1 } from '@/lib/api/v1/with-api-v1'
import { v1ErrorResponseFromCode, v1ErrorResponse } from '@/lib/api/v1/errors'
import { loadPeriodFromQuery, safeGenerate } from '@/lib/api/v1/report-period'
import { generateSIEExport } from '@/lib/reports/sie-export'
registerEndpoint({
operation: 'reports.sie-export',
method: 'GET',
path: '/api/v1/companies/:companyId/reports/sie-export',
summary: 'SIE4 export (.se file) for a fiscal period.',
description:
'Returns the period\'s SIE4 export as text/plain UTF-8. Includes #FNAMN / #ORGNR header, #KONTO chart, #IB/#UB opening + closing balances, #RES result-account totals, and every #VER + #TRANS verifikation in the period. The byte stream matches what the dashboard\'s `/api/reports/sie-export` produces.',
useWhen:
'Year-end accountant handoff, migration to another bookkeeping system, audit archival, BFL 7 kap räkenskapsinformation backup.',
doNotUseFor:
'JSON drilldown of period entries (use /reports/journal-register). Full archive including documents (use /reports/full-archive — not yet on v1).',
pitfalls: [
'`period_id` is required.',
'The response is text/plain with Content-Disposition: attachment — clients should treat as a binary download. Filename uses the pattern `export_{period_id}.se`.',
'Encoding is UTF-8 (modern systems accept it; some legacy Swedish bookkeeping software still expects CP437/Latin-1 — convert client-side if needed).',
'Only `posted` entries are exported; drafts and reversed entries\' originals are included but marked accordingly.',
],
example: {
response: { _note: 'Returns text/plain SIE4 content as binary download.' },
},
scope: 'reports:read',
risk: 'low',
idempotent: true,
reversible: false,
dryRunSupported: false,
response: { success: z.unknown(), contentType: 'text/plain' },
})
export const GET = withApiV1<{ params: Promise<{ companyId: string }> }>(
'reports.sie-export',
async (request, ctx) => {
const period = await loadPeriodFromQuery(request, {
supabase: ctx.supabase,
companyId: ctx.companyId!,
requestId: ctx.requestId,
log: ctx.log,
})
if (!period.ok) return period.response
const { data: company, error: companyErr } = await ctx.supabase
.from('company_settings')
.select('company_name, org_number')
.eq('company_id', ctx.companyId!)
.maybeSingle()
if (companyErr) {
return v1ErrorResponse(companyErr, ctx.log, { requestId: ctx.requestId })
}
if (!company) {
return v1ErrorResponseFromCode('COMPANY_NOT_FOUND', ctx.log, { requestId: ctx.requestId })
}
const gen = await safeGenerate(
() =>
generateSIEExport(ctx.supabase, ctx.companyId!, {
fiscal_period_id: period.period.id,
company_name: (company as { company_name: string | null }).company_name || 'Unknown',
org_number: (company as { org_number: string | null }).org_number,
}),
{ log: ctx.log, requestId: ctx.requestId, reportName: 'sie-export' },
)
if (!gen.ok) return gen.response
// OWASP V3.2 / V4 — sanitise period_id before splicing into the
// Content-Disposition header. period_id is a server-supplied UUID
// (already constrained by the fiscal_periods row lookup), so this
// is belt-and-suspenders.
const safeId = period.period.id.replace(/[^0-9a-fA-F-]/g, '')
return new NextResponse(gen.result, {
status: 200,
headers: {
'Content-Type': 'text/plain; charset=utf-8',
'Content-Disposition': `attachment; filename="export_${safeId}.se"`,
'X-Request-Id': ctx.requestId,
},
})
},
)
@@ -0,0 +1,87 @@
/**
* GET /api/v1/companies/{companyId}/reports/supplier-ledger
*
* Accounts payable ledger (leverantörsreskontra) — unpaid supplier
* invoices grouped by supplier with aging buckets.
*/
import { z } from 'zod'
import { ok } from '@/lib/api/v1/response'
import { registerEndpoint } from '@/lib/api/v1/registry'
import { withApiV1 } from '@/lib/api/v1/with-api-v1'
import { v1ErrorResponseFromCode } from '@/lib/api/v1/errors'
import { safeGenerate } from '@/lib/api/v1/report-period'
import { generateSupplierLedger } from '@/lib/reports/supplier-ledger'
registerEndpoint({
operation: 'reports.supplier-ledger',
method: 'GET',
path: '/api/v1/companies/:companyId/reports/supplier-ledger',
summary: 'Supplier ledger — unpaid supplier invoices with aging.',
description:
'Returns the supplier-payable ledger as of `as_of_date` (defaults to today). Each supplier entry includes outstanding invoices grouped into aging buckets. Reconciles against BAS 2440.',
useWhen:
'AP workflow dashboards, due-date prioritisation, reconciliation against the 2440 trial-balance figure.',
doNotUseFor:
'Listing all supplier invoices regardless of status (use /supplier-invoices). Initiating payment (the v1 surface does not expose payment files yet).',
pitfalls: [
'`as_of_date` is optional; format `YYYY-MM-DD`. Defaults to today (UTC).',
'Only invoices with outstanding `remaining_amount > 0` appear. Credited and fully-paid invoices are excluded.',
],
example: {
response: {
data: { as_of_date: '2026-05-31', suppliers: [], totals: {} },
meta: { request_id: 'req_…', api_version: '2026-05-12' },
},
},
scope: 'reports:read',
risk: 'low',
idempotent: true,
reversible: false,
dryRunSupported: false,
response: { success: z.unknown() },
})
export const GET = withApiV1<{ params: Promise<{ companyId: string }> }>(
'reports.supplier-ledger',
async (request, ctx) => {
const url = new URL(request.url)
const asOfDate = url.searchParams.get('as_of_date') || undefined
if (asOfDate) {
if (!/^\d{4}-\d{2}-\d{2}$/.test(asOfDate)) {
return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, {
requestId: ctx.requestId,
details: { field: 'as_of_date', message: 'Expected YYYY-MM-DD.' },
})
}
// Calendar validity — regex alone accepts 2026-13-45.
const probe = new Date(`${asOfDate}T00:00:00Z`)
if (Number.isNaN(probe.getTime()) || probe.toISOString().slice(0, 10) !== asOfDate) {
return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, {
requestId: ctx.requestId,
details: { field: 'as_of_date', message: 'Not a valid calendar date.' },
})
}
// Sanity range: year 2000 → current+1 (see ar-ledger comment).
const year = probe.getUTCFullYear()
const maxYear = new Date().getUTCFullYear() + 1
if (year < 2000 || year > maxYear) {
return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, {
requestId: ctx.requestId,
details: {
field: 'as_of_date',
message: `Year out of supported range. Accepted: 2000 to ${maxYear}.`,
},
})
}
}
const gen = await safeGenerate(
() => generateSupplierLedger(ctx.supabase, ctx.companyId!, asOfDate),
{ log: ctx.log, requestId: ctx.requestId, reportName: 'supplier-ledger' },
)
if (!gen.ok) return gen.response
return ok(gen.result, { requestId: ctx.requestId })
},
)
@@ -0,0 +1,89 @@
/**
* GET /api/v1/companies/{companyId}/reports/trial-balance
*
* Returns the trial-balance (huvudbok-summa) for a fiscal period: opening
* balance + period debit + period credit + closing balance per active
* account. Mirrors the dashboard report byte-equivalently — same `lib/reports/
* trial-balance.ts` generator backs both surfaces.
*/
import { z } from 'zod'
import { ok } from '@/lib/api/v1/response'
import { registerEndpoint } from '@/lib/api/v1/registry'
import { withApiV1 } from '@/lib/api/v1/with-api-v1'
import { loadPeriodFromQuery, safeGenerate } from '@/lib/api/v1/report-period'
import { generateTrialBalance } from '@/lib/reports/trial-balance'
const TrialBalanceRow = z.object({
account: z.string(),
account_name: z.string(),
opening_balance: z.number(),
period_debit: z.number(),
period_credit: z.number(),
closing_balance: z.number(),
})
const TrialBalanceResponse = z.object({
rows: z.array(TrialBalanceRow),
totalDebit: z.number(),
totalCredit: z.number(),
isBalanced: z.boolean(),
})
registerEndpoint({
operation: 'reports.trial-balance',
method: 'GET',
path: '/api/v1/companies/:companyId/reports/trial-balance',
summary: 'Trial balance (huvudboksrapport) for a fiscal period.',
description:
'Returns the per-account opening balance + period debit/credit + closing balance plus run-level totals and an `isBalanced` flag. The numbers come from the same `lib/reports/trial-balance.ts` generator the dashboard uses.',
useWhen:
'You need a snapshot of every active account\'s movement during a period — typically the first report an accountant checks before running balance sheet or income statement.',
doNotUseFor:
'Reconciliation against AR/AP (use /reports/ar-ledger or /supplier-ledger). Specific account drill-in (use /reports/general-ledger with account_from/account_to filters).',
pitfalls: [
'`period_id` is required as a query parameter.',
'`isBalanced=false` means the period has unbalanced postings — a data-integrity red flag. The lib generator rounds at the source so a true imbalance is rare; investigate immediately.',
'Closed/locked periods are still queryable — the report is read-only.',
],
example: {
response: {
data: {
rows: [
{ account: '1930', account_name: 'Företagskonto', opening_balance: 100000, period_debit: 25000, period_credit: 18000, closing_balance: 107000 },
],
totalDebit: 25000,
totalCredit: 25000,
isBalanced: true,
},
meta: { request_id: 'req_…', api_version: '2026-05-12' },
},
},
scope: 'reports:read',
risk: 'low',
idempotent: true,
reversible: false,
dryRunSupported: false,
response: { success: TrialBalanceResponse },
})
export const GET = withApiV1<{ params: Promise<{ companyId: string }> }>(
'reports.trial-balance',
async (request, ctx) => {
const period = await loadPeriodFromQuery(request, {
supabase: ctx.supabase,
companyId: ctx.companyId!,
requestId: ctx.requestId,
log: ctx.log,
})
if (!period.ok) return period.response
const gen = await safeGenerate(
() => generateTrialBalance(ctx.supabase, ctx.companyId!, period.period.id),
{ log: ctx.log, requestId: ctx.requestId, reportName: 'trial-balance' },
)
if (!gen.ok) return gen.response
return ok(gen.result, { requestId: ctx.requestId })
},
)
@@ -0,0 +1,65 @@
/**
* GET /api/v1/companies/{companyId}/reports/vacation-liability
*
* Per-employee semesterlöneskuld (vacation liability) at year end. Feeds
* the BAS 2920 reconciliation.
*/
import { z } from 'zod'
import { ok } from '@/lib/api/v1/response'
import { registerEndpoint } from '@/lib/api/v1/registry'
import { withApiV1 } from '@/lib/api/v1/with-api-v1'
import { v1ErrorResponseFromCode } from '@/lib/api/v1/errors'
import { safeGenerate } from '@/lib/api/v1/report-period'
import { generateVacationLiability } from '@/lib/reports/vacation-liability'
registerEndpoint({
operation: 'reports.vacation-liability',
method: 'GET',
path: '/api/v1/companies/:companyId/reports/vacation-liability',
summary: 'Vacation liability (semesterlöneskuld) per employee at year-end.',
description:
'Returns per-employee semesterlöneskuld balances as of year-end based on their vacation_rule (procentregeln / sammaloneregeln) and accrued days. For employees on procentregeln or sammaloneregeln the row total contributes to the BAS 2920 closing balance. Employees on `none` or `semesterersattning` are excluded because their cost is expensed immediately (no balance-sheet accrual) — the BAS 2920 reconciliation against this report is therefore CORRECT whether or not the company has semesterersättning employees, since those employees contribute zero to both the report and the 2920 balance. Feeds the K2/K3 årsredovisning notes.',
useWhen:
'Year-end reconciliation between the accrued liability on 2920 and the per-employee detail. Audit prep.',
doNotUseFor:
'Real-time accrual posting (handled per salary run). Vacation request management (not in scope for v1).',
pitfalls: [
'`year` is required.',
'Employees with vacation_rule = none or semesterersattning are excluded — they have no semesterlöneskuld liability.',
],
example: {
response: {
data: { year: 2026, employees: [], total_liability: 0 },
meta: { request_id: 'req_…', api_version: '2026-05-12' },
},
},
scope: 'payroll:read',
risk: 'low',
idempotent: true,
reversible: false,
dryRunSupported: false,
response: { success: z.unknown() },
})
export const GET = withApiV1<{ params: Promise<{ companyId: string }> }>(
'reports.vacation-liability',
async (request, ctx) => {
const url = new URL(request.url)
const yearParse = z.coerce.number().int().min(2020).max(2100).safeParse(url.searchParams.get('year'))
if (!yearParse.success) {
return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, {
requestId: ctx.requestId,
details: { field: 'year', message: 'year query parameter is required (integer 2020-2100).' },
})
}
const gen = await safeGenerate(
() => generateVacationLiability(ctx.supabase, ctx.companyId!, yearParse.data),
{ log: ctx.log, requestId: ctx.requestId, reportName: 'vacation-liability' },
)
if (!gen.ok) return gen.response
return ok(gen.result, { requestId: ctx.requestId })
},
)
@@ -0,0 +1,144 @@
/**
* GET /api/v1/companies/{companyId}/reports/vat-declaration
*
* Computes the Swedish momsdeklaration for a period (monthly, quarterly,
* or yearly). Returns all 12 declaration rutor (05/06/07/10/11/12/30/31/32/39/40/48/49)
* mapped from the BAS accounts (2611/2621/2631/3001/3002/3003/etc.).
*/
import { z } from 'zod'
import { ok } from '@/lib/api/v1/response'
import { registerEndpoint } from '@/lib/api/v1/registry'
import { withApiV1 } from '@/lib/api/v1/with-api-v1'
import { v1ErrorResponseFromCode } from '@/lib/api/v1/errors'
import { safeGenerate } from '@/lib/api/v1/report-period'
import { calculateVatDeclaration } from '@/lib/reports/vat-declaration'
import type { AccountingMethod, VatPeriodType } from '@/types'
const VatPeriodTypeEnum = z.enum(['monthly', 'quarterly', 'yearly'])
const AccountingMethodEnum = z.enum(['accrual', 'cash'])
registerEndpoint({
operation: 'reports.vat-declaration',
method: 'GET',
path: '/api/v1/companies/:companyId/reports/vat-declaration',
summary: 'Swedish VAT declaration (momsdeklaration) for a period.',
description:
'Computes momsdeklaration rutor for the given period_type / year / period. The result includes ruta 05 (domestic taxable sales), 10-12 (output VAT 25/12/6%), 20-24 (EU acquisitions of goods + tax on services from EU/non-EU), 30-32 (reverse-charge output VAT 25/12/6%), 39 (export), 40 (EU-services / momsfri försäljning), 48 (input VAT), 50 (import beskattningsunderlag), 60-62 (calculated output VAT on imports 25/12/6%), and 49 (moms att betala/återfå — the bottom line). Mapping rules match SKV 4700.',
useWhen:
'Submitting momsdeklaration to Skatteverket, reconciling VAT balances at month/quarter end, or building a VAT-payable dashboard.',
doNotUseFor:
'Specific transaction VAT lookups (use /transactions/{id}). Period-mismatch reconciliation (use /reports/general-ledger filtered to 26xx accounts).',
pitfalls: [
'`period_type` (monthly|quarterly|yearly), `year`, and `period` are all required.',
'For monthly: period is 1-12. For quarterly: period is 1-4. For yearly: period is 1.',
'`accounting_method` defaults to accrual (faktureringsmetoden); pass cash for kontantmetoden to honor the VAT-on-payment rule per ML 15 kap 8–11 §§ (ML 2023:200, which replaced ML 1994:200 on 1 July 2023 — the prior ML 13 kap reference is outdated).',
'Output ruta 49 = (10+11+12+30+31+32+60+61+62) − 48. Positive = pay; negative = refund.',
],
example: {
response: {
data: {
period_type: 'monthly',
year: 2026,
period: 4,
rutor: {
ruta05: 0,
ruta10: 0,
ruta11: 0,
ruta12: 0,
ruta20: 0,
ruta21: 0,
ruta22: 0,
ruta23: 0,
ruta24: 0,
ruta30: 0,
ruta31: 0,
ruta32: 0,
ruta39: 0,
ruta40: 0,
ruta48: 0,
ruta50: 0,
ruta60: 0,
ruta61: 0,
ruta62: 0,
ruta49: 0,
},
},
meta: { request_id: 'req_…', api_version: '2026-05-12' },
},
},
scope: 'reports:read',
risk: 'low',
idempotent: true,
reversible: false,
dryRunSupported: false,
response: { success: z.unknown() },
})
export const GET = withApiV1<{ params: Promise<{ companyId: string }> }>(
'reports.vat-declaration',
async (request, ctx) => {
const url = new URL(request.url)
const FiltersSchema = z
.object({
period_type: VatPeriodTypeEnum,
year: z.coerce.number().int().min(2000).max(2100),
period: z.coerce.number().int().min(1).max(12),
accounting_method: AccountingMethodEnum.optional(),
})
// Cross-field bounds: monthly accepts 1-12, quarterly 1-4, yearly only 1.
// Without this guard a caller could pass period_type=quarterly + period=7
// and silently get a nonsensical declaration that they might submit to
// Skatteverket.
.superRefine((data, ctx) => {
if (data.period_type === 'quarterly' && (data.period < 1 || data.period > 4)) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ['period'],
message: 'For quarterly period_type, period must be 1-4.',
})
}
if (data.period_type === 'yearly' && data.period !== 1) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ['period'],
message: 'For yearly period_type, period must be 1.',
})
}
})
const filters = FiltersSchema.safeParse({
period_type: url.searchParams.get('period_type'),
year: url.searchParams.get('year'),
period: url.searchParams.get('period'),
accounting_method: url.searchParams.get('accounting_method') ?? undefined,
})
if (!filters.success) {
return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, {
requestId: ctx.requestId,
details: {
issues: filters.error.issues.map((i) => ({
field: i.path.join('.'),
message: i.message,
})),
},
})
}
const { period_type, year, period, accounting_method } = filters.data
const gen = await safeGenerate(
() =>
calculateVatDeclaration(
ctx.supabase,
ctx.companyId!,
period_type as VatPeriodType,
year,
period,
accounting_method as AccountingMethod | undefined,
),
{ log: ctx.log, requestId: ctx.requestId, reportName: 'vat-declaration' },
)
if (!gen.ok) return gen.response
return ok(gen.result, { requestId: ctx.requestId })
},
)
+22
View File
@@ -97,4 +97,26 @@ import '@/app/api/v1/companies/[companyId]/salary-runs/[id]/mark-paid/route'
import '@/app/api/v1/companies/[companyId]/salary-runs/[id]/book/route'
import '@/app/api/v1/companies/[companyId]/salary-runs/[id]/generate-agi/route'
// Phase 5 PR-3 — Reports + import async. All reports wrap existing
// lib/reports/* generators. Imports run inline today but record their
// progress on the `operations` table for consistent polling-shape. KPI,
// audit-trail, periodisk-sammanstallning, ne-bilaga, and ink2 are deferred
// to a follow-up PR (different lib-module structures).
import '@/app/api/v1/companies/[companyId]/reports/trial-balance/route'
import '@/app/api/v1/companies/[companyId]/reports/balance-sheet/route'
import '@/app/api/v1/companies/[companyId]/reports/income-statement/route'
import '@/app/api/v1/companies/[companyId]/reports/general-ledger/route'
import '@/app/api/v1/companies/[companyId]/reports/journal-register/route'
import '@/app/api/v1/companies/[companyId]/reports/vat-declaration/route'
import '@/app/api/v1/companies/[companyId]/reports/monthly-breakdown/route'
import '@/app/api/v1/companies/[companyId]/reports/ar-ledger/route'
import '@/app/api/v1/companies/[companyId]/reports/supplier-ledger/route'
import '@/app/api/v1/companies/[companyId]/reports/continuity-check/route'
import '@/app/api/v1/companies/[companyId]/reports/salary-journal/route'
import '@/app/api/v1/companies/[companyId]/reports/avgifter-basis/route'
import '@/app/api/v1/companies/[companyId]/reports/vacation-liability/route'
import '@/app/api/v1/companies/[companyId]/reports/sie-export/route'
import '@/app/api/v1/companies/[companyId]/imports/sie/route'
import '@/app/api/v1/companies/[companyId]/imports/bank/route'
export {}
+125
View File
@@ -0,0 +1,125 @@
/**
* Shared helpers for v1 report endpoints.
*
* Most reports follow the same shape: parse `period_id` from the query
* string, validate it as a UUID, and confirm it's a fiscal period the
* caller's company owns before invoking the lib generator. This helper
* centralises that pattern so each route stays at ~40 lines of business
* logic and the validation behavior stays consistent across all reports.
*/
import { z } from 'zod'
import type { NextResponse } from 'next/server'
import type { SupabaseClient } from '@supabase/supabase-js'
import type { Logger } from '@/lib/logger'
import { v1ErrorResponse, v1ErrorResponseFromCode } from './errors'
const UUID_RE = /^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/
export interface FiscalPeriodRow {
id: string
period_start: string
period_end: string
is_closed: boolean
locked_at: string | null
}
export type PeriodResult =
| { ok: true; period: FiscalPeriodRow }
| { ok: false; response: Response }
/**
* Parse + validate `period_id` from the URL's query string, then load the
* matching `fiscal_periods` row scoped to the caller's company. Returns
* either the row (success) or a pre-built error response (caller just
* returns it).
*
* Why a tight helper: every report endpoint does this same 4-step dance
* (parse query, validate UUID, fetch period, 404 on miss). Pulling it
* out reduces each route to its actual business logic.
*/
export async function loadPeriodFromQuery(
request: Request,
ctx: {
supabase: SupabaseClient
companyId: string
requestId: string
log: Logger
},
): Promise<PeriodResult> {
const url = new URL(request.url)
const periodId = url.searchParams.get('period_id')
if (!periodId) {
return {
ok: false,
response: await v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, {
requestId: ctx.requestId,
details: { field: 'period_id', message: 'period_id query parameter is required.' },
}),
}
}
if (!UUID_RE.test(periodId)) {
return {
ok: false,
response: await v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, {
requestId: ctx.requestId,
details: { field: 'period_id', message: 'period_id must be a UUID.' },
}),
}
}
const { data, error } = await ctx.supabase
.from('fiscal_periods')
.select('id, period_start, period_end, is_closed, locked_at')
.eq('id', periodId)
.eq('company_id', ctx.companyId)
.maybeSingle()
if (error) {
return {
ok: false,
response: await v1ErrorResponse(error, ctx.log, { requestId: ctx.requestId }),
}
}
if (!data) {
return {
ok: false,
response: await v1ErrorResponseFromCode('NOT_FOUND', ctx.log, {
requestId: ctx.requestId,
details: { resource: 'fiscal_period', id: periodId },
}),
}
}
return { ok: true, period: data as FiscalPeriodRow }
}
/**
* Wrap a report-generator call in a try/catch that surfaces a structured
* REPORT_GENERATION_FAILED error if the generator throws. Mirrors the
* dashboard's pattern so any lib-layer exception becomes a clean v1
* envelope rather than leaking the underlying error.
*/
export async function safeGenerate<T>(
generate: () => Promise<T>,
ctx: { log: Logger; requestId: string; reportName: string },
): Promise<{ ok: true; result: T } | { ok: false; response: NextResponse }> {
try {
const result = await generate()
return { ok: true, result }
} catch (err) {
ctx.log.error(`${ctx.reportName} report generation failed`, err as Error)
return {
ok: false,
response: await v1ErrorResponseFromCode('REPORT_GENERATION_FAILED', ctx.log, {
requestId: ctx.requestId,
details: {
report: ctx.reportName,
reason: err instanceof Error ? err.message : 'unknown',
},
}),
}
}
}
+30
View File
@@ -128,6 +128,36 @@ export const V1_ENDPOINT_SCOPES: Record<string, ApiKeyScope> = {
'POST /api/v1/companies/:companyId/reconciliation/bank/run': 'transactions:write',
'GET /api/v1/companies/:companyId/reconciliation/bank/status': 'transactions:read',
// Phase 5 PR-3 — Reports + import async. Reports are read-only over
// existing lib/reports/* generators; imports are async over the Phase 4
// PR-2 operations substrate.
// JSON reports — all share `reports:read` (or `payroll:read` for the
// salary-scoped ones). kpi, audit-trail, periodisk-sammanstallning,
// ne-bilaga, and ink2 are deferred to a follow-up PR — kpi composes
// multiple lib generators rather than wrapping one; audit-trail lives in
// lib/core/audit/ rather than lib/reports/; ne-bilaga + ink2 + periodisk
// each have their own lib subdir structure that needs more care.
'GET /api/v1/companies/:companyId/reports/trial-balance': 'reports:read',
'GET /api/v1/companies/:companyId/reports/balance-sheet': 'reports:read',
'GET /api/v1/companies/:companyId/reports/income-statement': 'reports:read',
'GET /api/v1/companies/:companyId/reports/general-ledger': 'reports:read',
'GET /api/v1/companies/:companyId/reports/journal-register': 'reports:read',
'GET /api/v1/companies/:companyId/reports/vat-declaration': 'reports:read',
'GET /api/v1/companies/:companyId/reports/monthly-breakdown': 'reports:read',
'GET /api/v1/companies/:companyId/reports/ar-ledger': 'reports:read',
'GET /api/v1/companies/:companyId/reports/supplier-ledger': 'reports:read',
'GET /api/v1/companies/:companyId/reports/continuity-check': 'reports:read',
'GET /api/v1/companies/:companyId/reports/salary-journal': 'payroll:read',
'GET /api/v1/companies/:companyId/reports/avgifter-basis': 'payroll:read',
'GET /api/v1/companies/:companyId/reports/vacation-liability': 'payroll:read',
// Binary report — SIE4 text/plain export. JSON variants of INK2 / NE-bilaga
// are deferred (see above).
'GET /api/v1/companies/:companyId/reports/sie-export': 'reports:read',
// Imports — async via the Phase 4 PR-2 operations substrate. Multipart
// uploads (the file is the request body).
'POST /api/v1/companies/:companyId/imports/sie': 'bookkeeping:write',
'POST /api/v1/companies/:companyId/imports/bank': 'transactions:write',
// Phase 5 PR-1 — Payroll vertical (employees + salary-runs + lifecycle verbs).
// Reuses the pre-existing `payroll:read` / `payroll:write` scopes already
// defined for the MCP tool surface (gnubok_list_employees, gnubok_create_salary_run, ...).
+21
View File
@@ -1388,6 +1388,27 @@ const SALARY: Record<string, StructuredErrorEntry> = {
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> = {