diff --git a/app/api/v1/companies/[companyId]/imports/bank/route.ts b/app/api/v1/companies/[companyId]/imports/bank/route.ts new file mode 100644 index 00000000..628d37c3 --- /dev/null +++ b/app/api/v1/companies/[companyId]/imports/bank/route.ts @@ -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 }) + }, +) diff --git a/app/api/v1/companies/[companyId]/imports/sie/route.ts b/app/api/v1/companies/[companyId]/imports/sie/route.ts new file mode 100644 index 00000000..a76d9664 --- /dev/null +++ b/app/api/v1/companies/[companyId]/imports/sie/route.ts @@ -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 `` 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> + 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 }) + }, +) diff --git a/app/api/v1/companies/[companyId]/reports/__tests__/reports.test.ts b/app/api/v1/companies/[companyId]/reports/__tests__/reports.test.ts new file mode 100644 index 00000000..de41ba19 --- /dev/null +++ b/app/api/v1/companies/[companyId]/reports/__tests__/reports.test.ts @@ -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('@/lib/auth/api-keys') + return { + ...actual, + validateApiKey: vi.fn(), + createServiceClientNoCookies: vi.fn(), + } +}) + +vi.mock('@supabase/supabase-js', async () => { + const actual = await vi.importActual('@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 +const mockServiceClient = createServiceClientNoCookies as ReturnType + +interface TableResp { + data?: unknown + error?: unknown + count?: number | null +} + +function makeFlexibleSupabase(byTable: Record) { + const queues = new Map() + for (const [t, val] of Object.entries(byTable)) { + queues.set(t, Array.isArray(val) ? [...val] : [val]) + } + const buildChain = (table: string): unknown => { + const handler: ProxyHandler = { + 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, + ) + }) +}) diff --git a/app/api/v1/companies/[companyId]/reports/ar-ledger/route.ts b/app/api/v1/companies/[companyId]/reports/ar-ledger/route.ts new file mode 100644 index 00000000..c8983698 --- /dev/null +++ b/app/api/v1/companies/[companyId]/reports/ar-ledger/route.ts @@ -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 }) + }, +) diff --git a/app/api/v1/companies/[companyId]/reports/avgifter-basis/route.ts b/app/api/v1/companies/[companyId]/reports/avgifter-basis/route.ts new file mode 100644 index 00000000..ba8fcf49 --- /dev/null +++ b/app/api/v1/companies/[companyId]/reports/avgifter-basis/route.ts @@ -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 }) + }, +) diff --git a/app/api/v1/companies/[companyId]/reports/balance-sheet/route.ts b/app/api/v1/companies/[companyId]/reports/balance-sheet/route.ts new file mode 100644 index 00000000..aaa4e833 --- /dev/null +++ b/app/api/v1/companies/[companyId]/reports/balance-sheet/route.ts @@ -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 + result.period = { start: period.period.period_start, end: period.period.period_end } + + return ok(result, { requestId: ctx.requestId }) + }, +) diff --git a/app/api/v1/companies/[companyId]/reports/continuity-check/route.ts b/app/api/v1/companies/[companyId]/reports/continuity-check/route.ts new file mode 100644 index 00000000..ee2c21e1 --- /dev/null +++ b/app/api/v1/companies/[companyId]/reports/continuity-check/route.ts @@ -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 }) + }, +) diff --git a/app/api/v1/companies/[companyId]/reports/general-ledger/route.ts b/app/api/v1/companies/[companyId]/reports/general-ledger/route.ts new file mode 100644 index 00000000..6bf4e271 --- /dev/null +++ b/app/api/v1/companies/[companyId]/reports/general-ledger/route.ts @@ -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 }) + }, +) diff --git a/app/api/v1/companies/[companyId]/reports/income-statement/route.ts b/app/api/v1/companies/[companyId]/reports/income-statement/route.ts new file mode 100644 index 00000000..412c7312 --- /dev/null +++ b/app/api/v1/companies/[companyId]/reports/income-statement/route.ts @@ -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 + result.period = { start: period.period.period_start, end: period.period.period_end } + + return ok(result, { requestId: ctx.requestId }) + }, +) diff --git a/app/api/v1/companies/[companyId]/reports/journal-register/route.ts b/app/api/v1/companies/[companyId]/reports/journal-register/route.ts new file mode 100644 index 00000000..73e2dc37 --- /dev/null +++ b/app/api/v1/companies/[companyId]/reports/journal-register/route.ts @@ -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 }) + }, +) diff --git a/app/api/v1/companies/[companyId]/reports/monthly-breakdown/route.ts b/app/api/v1/companies/[companyId]/reports/monthly-breakdown/route.ts new file mode 100644 index 00000000..7041669b --- /dev/null +++ b/app/api/v1/companies/[companyId]/reports/monthly-breakdown/route.ts @@ -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 }) + }, +) diff --git a/app/api/v1/companies/[companyId]/reports/salary-journal/route.ts b/app/api/v1/companies/[companyId]/reports/salary-journal/route.ts new file mode 100644 index 00000000..d7145857 --- /dev/null +++ b/app/api/v1/companies/[companyId]/reports/salary-journal/route.ts @@ -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 }) + }, +) diff --git a/app/api/v1/companies/[companyId]/reports/sie-export/route.ts b/app/api/v1/companies/[companyId]/reports/sie-export/route.ts new file mode 100644 index 00000000..8baa7e5f --- /dev/null +++ b/app/api/v1/companies/[companyId]/reports/sie-export/route.ts @@ -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, + }, + }) + }, +) diff --git a/app/api/v1/companies/[companyId]/reports/supplier-ledger/route.ts b/app/api/v1/companies/[companyId]/reports/supplier-ledger/route.ts new file mode 100644 index 00000000..308a1045 --- /dev/null +++ b/app/api/v1/companies/[companyId]/reports/supplier-ledger/route.ts @@ -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 }) + }, +) diff --git a/app/api/v1/companies/[companyId]/reports/trial-balance/route.ts b/app/api/v1/companies/[companyId]/reports/trial-balance/route.ts new file mode 100644 index 00000000..80cdb281 --- /dev/null +++ b/app/api/v1/companies/[companyId]/reports/trial-balance/route.ts @@ -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 }) + }, +) diff --git a/app/api/v1/companies/[companyId]/reports/vacation-liability/route.ts b/app/api/v1/companies/[companyId]/reports/vacation-liability/route.ts new file mode 100644 index 00000000..0ef05329 --- /dev/null +++ b/app/api/v1/companies/[companyId]/reports/vacation-liability/route.ts @@ -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 }) + }, +) diff --git a/app/api/v1/companies/[companyId]/reports/vat-declaration/route.ts b/app/api/v1/companies/[companyId]/reports/vat-declaration/route.ts new file mode 100644 index 00000000..4abbff23 --- /dev/null +++ b/app/api/v1/companies/[companyId]/reports/vat-declaration/route.ts @@ -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 }) + }, +) diff --git a/lib/api/v1/load-routes.ts b/lib/api/v1/load-routes.ts index 126fcbbd..671c3d54 100644 --- a/lib/api/v1/load-routes.ts +++ b/lib/api/v1/load-routes.ts @@ -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 {} diff --git a/lib/api/v1/report-period.ts b/lib/api/v1/report-period.ts new file mode 100644 index 00000000..66dadeba --- /dev/null +++ b/lib/api/v1/report-period.ts @@ -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 { + 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( + generate: () => Promise, + 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', + }, + }), + } + } +} diff --git a/lib/auth/scopes.ts b/lib/auth/scopes.ts index afac0204..ca0d4794 100644 --- a/lib/auth/scopes.ts +++ b/lib/auth/scopes.ts @@ -128,6 +128,36 @@ export const V1_ENDPOINT_SCOPES: Record = { '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, ...). diff --git a/lib/errors/structured-errors.ts b/lib/errors/structured-errors.ts index 7678accd..a4bb34a6 100644 --- a/lib/errors/structured-errors.ts +++ b/lib/errors/structured-errors.ts @@ -1388,6 +1388,27 @@ const SALARY: Record = { 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 = {