From abe9ac9d8c59b879799398dcbb4624794aef3453 Mon Sep 17 00:00:00 2001 From: Mattsson <111893710+mattssonn@users.noreply.github.com> Date: Wed, 8 Jul 2026 09:54:46 +0200 Subject: [PATCH] Fix/attributes config (#926) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(git): pin LF on generated extension registry and vitest snapshots setup:extensions and vitest write these files with LF; with core.autocrlf=true git expects CRLF and flags them as phantom modifications on every dev/build run. Co-Authored-By: Claude Fable 5 * fix(security): enforce MFA on mcp-oauth consent and gate viewer storno route mcp-oauth/authorize renders an HTML consent page and issues 303 redirects that withRouteContext cannot express, so it kept raw getUser() and thereby skipped the AAL2 gate: a password-only (AAL1) session could approve consent that mints a long-lived, MFA-bypassing API key. Add a route-local requireAal2() step-up on GET and POST; AAL1 sessions redirect to /mfa/verify, BankID users are exempt. Separately, POST /api/reports/vat-declaration/rc-basis-gaps/fix calls correctEntry() (storno of a posted entry) but lacked requireWrite, so viewer-role members could trigger it. Add { requireWrite: true }. Co-Authored-By: Claude Fable 5 * fix(api): route transactions endpoints through withRouteContext Migrate the transactions routes off hand-rolled supabase.auth.getUser() onto the MFA-enforcing withRouteContext wrapper; add requireWrite on mutating handlers (book, uncategorize, attach-document, ignore, batch-match, create-from-document). Behavior and response shapes preserved; tests updated to the wrapper mock pattern. Co-Authored-By: Claude Fable 5 * fix(api): route SIE import and bank reconciliation through withRouteContext Migrate import/sie and reconciliation/bank routes onto the MFA-enforcing wrapper; requireWrite on mutations (import execute, create-accounts, mappings write verbs, link/unlink/run/mark-opening-balance). Reads (status, unmatched-entries) stay ungated. Response shapes preserved; tests added/updated to the wrapper mock pattern. Co-Authored-By: Claude Fable 5 * fix(api): route salary endpoints through withRouteContext Migrate salary employees and runs routes (plus ku, payroll-config, tax-tables) onto the MFA-enforcing wrapper; requireWrite on mutations. Personnummer masking/encryption untouched; file downloads (AGI XML, payslip PDF, payment files) keep their headers. Two payment-file GETs retain requireWrite because they stamp *_file_generated_at and previously gated viewers. Tests added/updated to the wrapper mock pattern. Co-Authored-By: Claude Fable 5 * fix(api): route report endpoints through withRouteContext Migrate the read-only report routes (trial balance, balansrapport, resultatrapport, income statement, ledgers, KPI, VAT declaration, salary journal, monthly breakdown, journal register, continuity check, full archive, etc.) onto the MFA-enforcing wrapper. All read-only, no requireWrite. JSON/XLSX/PDF/ZIP response bodies and headers preserved byte-for-byte; tests updated to the wrapper mock pattern. Co-Authored-By: Claude Fable 5 * fix(api): route invoices, skatteverket, agent and extension endpoints through withRouteContext Migrate invoices, supplier-invoices, skatteverket tax-payments, and dynamic extension routes onto the MFA-enforcing wrapper with requireWrite on mutations. The two NDJSON streaming agent routes (invoke, onboarding/stream) use requireAuth() directly (the wrapper can't wrap a streaming response) so MFA is still enforced. skatteverket payment-file GET keeps requireWrite (stamps a generated-at field). Response shapes and file headers preserved; tests added/updated. Co-Authored-By: Claude Fable 5 * fix(api): route documents, events, team and account endpoints through withRouteContext Migrate documents, events, kpi/preferences, vat/validate, support/contact onto the MFA-enforcing wrapper with requireWrite on mutations. account/password, team/accept and team/members use requireAuth() directly (user-level or pre-membership flows with no active company context) so MFA is still enforced. events keeps its dual API-key-or-session auth. Document retention guard untouched; tests added/updated. Co-Authored-By: Claude Fable 5 * fix(api): route settings and pending-operations endpoints through withRouteContext Migrate settings (api-keys, oauth-clients, booking-templates, counterparty-templates, logo, company settings) and pending-operations (commit, bulk-commit, reject, edit-before-approve) onto the MFA-enforcing wrapper with requireWrite on mutations. Credential-guarding routes keep their per-user ownership filters. Response shapes preserved; tests added/updated to the wrapper mock pattern. Co-Authored-By: Claude Fable 5 * chore(guards): ratchet raw-route-auth baseline 119->1 after A1 migration Lock in the withRouteContext migration so the count cannot regress. The single remaining entry, mcp-oauth/authorize, is a documented exception (HTML consent + redirects, MFA enforced via route-local step-up). Record the campaign and requireWrite decisions in DECISIONS.md. Co-Authored-By: Claude Fable 5 * feat(vat): add eSKD momsdeklaration file export for "Deklarera via fil" Generate the Skatteverket eSKDUpload v6.0 XML file so users can file VAT by upload instead of typing every ruta into the form. Extract buildFiledAmounts() as the shared whole-krona source of truth (öre truncated per SFL 22 kap 1 §) so the XML file and the manual-filing PDF can never disagree. Adds the /eskd API route, an XML option in the report export menu, and the upload button on the manual-filing card. Strings in sv + en. Co-Authored-By: Claude Opus 4.8 (1M context) * feat(vat): add 'vat_settlement' source type and update related components * fix(booking): adjust search input layout and enable autofocus * fix(vat): support 12-digit org numbers and adjust emission order for eSKD file * fix(migration): add 'vat_settlement' to journal_entries.source_type CHECK --------- Co-authored-by: Claude Fable 5 --- .gitattributes | 7 + DECISIONS.md | 4 + .../account/password/__tests__/route.test.ts | 29 +- app/api/account/password/route.ts | 13 +- app/api/agent/invoke/route.ts | 6 +- app/api/agent/onboarding/stream/route.ts | 7 +- .../documents/[id]/__tests__/route.test.ts | 14 +- .../documents/[id]/extraction-status/route.ts | 76 ++-- app/api/documents/[id]/route.ts | 149 +++--- .../[id]/verify/__tests__/route.test.ts | 82 ++++ app/api/documents/[id]/verify/route.ts | 50 +- .../[id]/versions/__tests__/route.test.ts | 104 +++++ app/api/documents/[id]/versions/route.ts | 150 +++--- app/api/events/__tests__/route.test.ts | 17 +- app/api/events/route.ts | 14 +- .../[slug]/data/__tests__/route.test.ts | 122 +++++ .../extensions/[sector]/[slug]/data/route.ts | 66 +-- .../[slug]/settings/__tests__/route.test.ts | 91 ++++ .../[sector]/[slug]/settings/route.ts | 44 +- .../skatteverket/skattekonto/drift/route.ts | 35 +- .../import/sie/[id]/__tests__/route.test.ts | 116 +++++ app/api/import/sie/[id]/route.ts | 128 +++--- .../create-accounts/__tests__/route.test.ts | 107 +++++ app/api/import/sie/create-accounts/route.ts | 163 +++---- .../sie/mappings/__tests__/route.test.ts | 169 +++++++ app/api/import/sie/mappings/route.ts | 234 ++++------ app/api/import/sie/route.ts | 70 ++- .../[id]/convert/__tests__/route.test.ts | 16 +- app/api/invoices/[id]/convert/route.ts | 27 +- .../[id]/mark-sent/__tests__/route.test.ts | 8 +- app/api/invoices/[id]/mark-sent/route.ts | 29 +- app/api/invoices/[id]/pdf/route.ts | 22 +- app/api/invoices/preview-pdf/route.ts | 17 +- .../kpi/preferences/__tests__/route.test.ts | 104 +++++ app/api/kpi/preferences/route.ts | 109 ++--- .../authorize/__tests__/route.test.ts | 106 ++++- app/api/mcp-oauth/authorize/route.ts | 36 ++ .../[id]/__tests__/route.test.ts | 24 +- .../pending-operations/[id]/commit/route.ts | 110 ++--- .../[id]/reject/__tests__/route.test.ts | 35 +- .../pending-operations/[id]/reject/route.ts | 152 +++---- app/api/pending-operations/[id]/route.ts | 327 +++++++------- .../bulk-commit/__tests__/route.test.ts | 36 +- .../pending-operations/bulk-commit/route.ts | 126 +++--- app/api/pending-operations/route.ts | 56 +-- .../bank/link/__tests__/route.test.ts | 131 ++++++ app/api/reconciliation/bank/link/route.ts | 54 +-- .../__tests__/route.test.ts | 119 +++++ .../bank/mark-opening-balance/route.ts | 78 ++-- .../bank/run/__tests__/route.test.ts | 140 ++++++ app/api/reconciliation/bank/run/route.ts | 140 +++--- app/api/reconciliation/bank/status/route.ts | 89 ++-- .../bank/unlink/__tests__/route.test.ts | 122 +++++ app/api/reconciliation/bank/unlink/route.ts | 40 +- .../bank/unmatched-entries/route.ts | 157 +++---- .../invoices/__tests__/route.test.ts | 44 +- .../customer/[customerId]/invoices/route.ts | 20 +- app/api/reports/ar-ledger/route.ts | 17 +- app/api/reports/ar-ledger/xlsx/route.ts | 16 +- .../audit-trail/__tests__/route.test.ts | 48 +- app/api/reports/audit-trail/route.ts | 16 +- app/api/reports/avgifter-basis/route.ts | 13 +- app/api/reports/balance-sheet/pdf/route.ts | 16 +- app/api/reports/balance-sheet/xlsx/route.ts | 16 +- app/api/reports/balansrapport/pdf/route.ts | 16 +- app/api/reports/balansrapport/route.ts | 16 +- app/api/reports/balansrapport/xlsx/route.ts | 16 +- app/api/reports/continuity-check/route.ts | 16 +- .../full-archive/__tests__/route.test.ts | 42 +- app/api/reports/full-archive/route.ts | 16 +- app/api/reports/general-ledger/xlsx/route.ts | 16 +- app/api/reports/income-statement/pdf/route.ts | 16 +- .../reports/income-statement/xlsx/route.ts | 16 +- app/api/reports/journal-register/route.ts | 17 +- .../reports/journal-register/xlsx/route.ts | 16 +- .../reports/kassaflodesanalys/pdf/route.ts | 16 +- app/api/reports/kassaflodesanalys/route.ts | 16 +- app/api/reports/kpi/route.ts | 13 +- app/api/reports/kpi/xlsx/route.ts | 13 +- app/api/reports/monthly-breakdown/route.ts | 16 +- .../reports/monthly-breakdown/xlsx/route.ts | 16 +- app/api/reports/resultatrapport/pdf/route.ts | 16 +- app/api/reports/resultatrapport/route.ts | 16 +- app/api/reports/resultatrapport/xlsx/route.ts | 16 +- app/api/reports/salary-journal/route.ts | 13 +- app/api/reports/salary-journal/xlsx/route.ts | 16 +- app/api/reports/supplier-ledger/route.ts | 17 +- .../invoices/__tests__/route.test.ts | 41 +- .../supplier/[supplierId]/invoices/route.ts | 20 +- app/api/reports/supplier-ledger/xlsx/route.ts | 16 +- .../sources/__tests__/route.test.ts | 68 ++- .../account/[accountNumber]/sources/route.ts | 20 +- app/api/reports/trial-balance/route.ts | 16 +- app/api/reports/trial-balance/xlsx/route.ts | 16 +- app/api/reports/vacation-liability/route.ts | 13 +- .../eskd/__tests__/route.test.ts | 135 ++++++ app/api/reports/vat-declaration/eskd/route.ts | 92 ++++ .../rc-basis-gaps/__tests__/fix.test.ts | 143 ++++++ .../rc-basis-gaps/fix/route.ts | 1 + .../[ruta]/sources/__tests__/route.test.ts | 54 +-- .../ruta/[ruta]/sources/route.ts | 20 +- app/api/reports/vat-declaration/xlsx/route.ts | 16 +- .../employees/[id]/__tests__/route.test.ts | 75 +++ .../[id]/absence/__tests__/route.test.ts | 86 ++++ .../salary/employees/[id]/absence/route.ts | 276 ++++++------ .../[benefitId]/__tests__/route.test.ts | 76 ++++ .../[id]/benefits/[benefitId]/route.ts | 150 +++--- .../[id]/benefits/__tests__/route.test.ts | 92 ++++ .../salary/employees/[id]/benefits/route.ts | 140 +++--- app/api/salary/employees/[id]/route.ts | 290 ++++++------ .../[id]/worked-hours/__tests__/route.test.ts | 86 ++++ .../batch/__tests__/route.test.ts | 90 ++++ .../[id]/worked-hours/batch/route.ts | 155 +++---- .../employees/[id]/worked-hours/route.ts | 272 ++++++----- .../salary/employees/__tests__/route.test.ts | 48 +- app/api/salary/employees/route.ts | 28 +- app/api/salary/ku/[year]/route.ts | 252 +++++------ app/api/salary/payroll-config/[year]/route.ts | 37 +- .../[id]/agi/submit/__tests__/route.test.ts | 90 ++-- app/api/salary/runs/[id]/agi/submit/route.ts | 242 +++++----- app/api/salary/runs/[id]/agi/xml/route.ts | 89 ++-- .../runs/[id]/correct/__tests__/route.test.ts | 121 +++++ app/api/salary/runs/[id]/correct/route.ts | 256 +++++------ .../[employeeId]/__tests__/route.test.ts | 73 ++- .../runs/[id]/employees/[employeeId]/route.ts | 348 +++++++------- .../[id]/employees/__tests__/route.test.ts | 120 +++++ app/api/salary/runs/[id]/employees/route.ts | 188 ++++---- .../lines/[lineId]/__tests__/route.test.ts | 132 ++++++ .../salary/runs/[id]/lines/[lineId]/route.ts | 140 +++--- .../runs/[id]/lines/__tests__/route.test.ts | 93 ++++ app/api/salary/runs/[id]/lines/route.ts | 130 +++--- .../payment/bg-lb/__tests__/route.test.ts | 100 ++++ .../salary/runs/[id]/payment/bg-lb/route.ts | 254 +++++------ .../payment/pain001/__tests__/route.test.ts | 100 ++++ .../salary/runs/[id]/payment/pain001/route.ts | 212 +++++---- .../[employeeId]/pdf/__tests__/route.test.ts | 31 +- .../[id]/payslips/[employeeId]/pdf/route.ts | 126 +++--- app/api/salary/runs/[id]/preview/route.ts | 337 +++++++------- .../runs/[id]/review/__tests__/route.test.ts | 112 +++++ app/api/salary/runs/[id]/review/route.ts | 82 ++-- app/api/salary/tax-tables/lookup/route.ts | 10 +- app/api/salary/tax-tables/status/route.ts | 10 +- app/api/settings/__tests__/route.test.ts | 86 ++++ app/api/settings/api-keys/[id]/route.ts | 49 +- .../settings/booking-templates/[id]/route.ts | 49 +- .../booking-templates/[id]/touch/route.ts | 50 +- .../booking-templates/export/route.ts | 46 +- .../booking-templates/import/route.ts | 81 ++-- app/api/settings/booking-templates/route.ts | 259 ++++++----- .../__tests__/route.test.ts | 78 ++++ .../settings/counterparty-templates/route.ts | 80 ++-- app/api/settings/logo/__tests__/route.test.ts | 85 ++++ app/api/settings/logo/route.ts | 189 ++++---- .../[id]/__tests__/route.test.ts | 86 ++++ app/api/settings/oauth-clients/[id]/route.ts | 112 ++--- .../oauth-clients/__tests__/route.test.ts | 85 ++++ app/api/settings/oauth-clients/route.ts | 119 +++-- app/api/settings/route.ts | 212 ++++----- .../mark-paid/__tests__/route.test.ts | 99 ++++ .../tax-payments/[period]/mark-paid/route.ts | 24 +- .../payment-file/__tests__/route.test.ts | 98 ++++ .../[period]/payment-file/route.ts | 28 +- .../tax-payments/[period]/route.ts | 19 +- .../[id]/__tests__/route.test.ts | 15 +- app/api/supplier-invoices/[id]/route.ts | 72 +-- .../[id]/uncredit/__tests__/route.test.ts | 15 +- .../supplier-invoices/[id]/uncredit/route.ts | 27 +- app/api/support/contact/route.ts | 7 +- app/api/team/accept/__tests__/route.test.ts | 103 +++++ app/api/team/accept/route.ts | 10 +- app/api/team/members/route.ts | 13 +- .../attach-document/__tests__/route.test.ts | 50 +- .../[id]/attach-document/route.ts | 426 +++++++++--------- .../[id]/book/__tests__/route.test.ts | 37 +- app/api/transactions/[id]/book/route.ts | 332 +++++++------- .../[id]/ignore/__tests__/route.test.ts | 179 ++++++++ app/api/transactions/[id]/ignore/route.ts | 124 +++-- .../[id]/uncategorize/__tests__/route.test.ts | 34 +- .../transactions/[id]/uncategorize/route.ts | 126 +++--- .../__tests__/route.test.ts | 50 +- .../batch-match-invoices/route.ts | 111 ++--- .../__tests__/route.test.ts | 54 ++- .../create-from-document/route.ts | 210 +++++---- .../transactions/suggest-categories/route.ts | 194 ++++---- app/api/vat/validate/__tests__/route.test.ts | 89 ++-- app/api/vat/validate/route.ts | 17 +- .../bookkeeping/BookingTemplatePicker.tsx | 11 +- components/bookkeeping/JournalEntryForm.tsx | 81 +++- .../bookkeeping/JournalEntryStatusBadge.tsx | 1 + components/reports/ReportExportMenu.tsx | 15 +- components/reports/views/index.tsx | 29 +- .../VoucherSeriesPerSourceTypeForm.tsx | 2 + lib/api/__tests__/schemas.test.ts | 1 + lib/api/schemas.ts | 1 + .../__tests__/template-source-type.test.ts | 30 ++ lib/bookkeeping/template-source-type.ts | 22 + lib/reports/__tests__/vat-eskd-file.test.ts | 133 ++++++ lib/reports/vat-eskd-file.ts | 143 ++++++ lib/reports/vat-manual-filing.ts | 44 +- messages/en.json | 4 +- messages/sv.json | 4 +- scripts/checks/antipatterns-baseline.json | 124 +---- ...000_journal_source_type_vat_settlement.sql | 38 ++ types/index.ts | 1 + 204 files changed, 9841 insertions(+), 6362 deletions(-) create mode 100644 app/api/documents/[id]/verify/__tests__/route.test.ts create mode 100644 app/api/documents/[id]/versions/__tests__/route.test.ts create mode 100644 app/api/extensions/[sector]/[slug]/data/__tests__/route.test.ts create mode 100644 app/api/extensions/[sector]/[slug]/settings/__tests__/route.test.ts create mode 100644 app/api/import/sie/[id]/__tests__/route.test.ts create mode 100644 app/api/import/sie/create-accounts/__tests__/route.test.ts create mode 100644 app/api/import/sie/mappings/__tests__/route.test.ts create mode 100644 app/api/kpi/preferences/__tests__/route.test.ts create mode 100644 app/api/reconciliation/bank/link/__tests__/route.test.ts create mode 100644 app/api/reconciliation/bank/mark-opening-balance/__tests__/route.test.ts create mode 100644 app/api/reconciliation/bank/run/__tests__/route.test.ts create mode 100644 app/api/reconciliation/bank/unlink/__tests__/route.test.ts create mode 100644 app/api/reports/vat-declaration/eskd/__tests__/route.test.ts create mode 100644 app/api/reports/vat-declaration/eskd/route.ts create mode 100644 app/api/reports/vat-declaration/rc-basis-gaps/__tests__/fix.test.ts create mode 100644 app/api/salary/employees/[id]/__tests__/route.test.ts create mode 100644 app/api/salary/employees/[id]/absence/__tests__/route.test.ts create mode 100644 app/api/salary/employees/[id]/benefits/[benefitId]/__tests__/route.test.ts create mode 100644 app/api/salary/employees/[id]/benefits/__tests__/route.test.ts create mode 100644 app/api/salary/employees/[id]/worked-hours/__tests__/route.test.ts create mode 100644 app/api/salary/employees/[id]/worked-hours/batch/__tests__/route.test.ts create mode 100644 app/api/salary/runs/[id]/correct/__tests__/route.test.ts create mode 100644 app/api/salary/runs/[id]/employees/__tests__/route.test.ts create mode 100644 app/api/salary/runs/[id]/lines/[lineId]/__tests__/route.test.ts create mode 100644 app/api/salary/runs/[id]/lines/__tests__/route.test.ts create mode 100644 app/api/salary/runs/[id]/payment/bg-lb/__tests__/route.test.ts create mode 100644 app/api/salary/runs/[id]/payment/pain001/__tests__/route.test.ts create mode 100644 app/api/salary/runs/[id]/review/__tests__/route.test.ts create mode 100644 app/api/settings/__tests__/route.test.ts create mode 100644 app/api/settings/counterparty-templates/__tests__/route.test.ts create mode 100644 app/api/settings/logo/__tests__/route.test.ts create mode 100644 app/api/settings/oauth-clients/[id]/__tests__/route.test.ts create mode 100644 app/api/settings/oauth-clients/__tests__/route.test.ts create mode 100644 app/api/skatteverket/tax-payments/[period]/mark-paid/__tests__/route.test.ts create mode 100644 app/api/skatteverket/tax-payments/[period]/payment-file/__tests__/route.test.ts create mode 100644 app/api/team/accept/__tests__/route.test.ts create mode 100644 app/api/transactions/[id]/ignore/__tests__/route.test.ts create mode 100644 lib/bookkeeping/__tests__/template-source-type.test.ts create mode 100644 lib/bookkeeping/template-source-type.ts create mode 100644 lib/reports/__tests__/vat-eskd-file.test.ts create mode 100644 lib/reports/vat-eskd-file.ts create mode 100644 supabase/migrations/20260708100000_journal_source_type_vat_settlement.sql diff --git a/.gitattributes b/.gitattributes index e0ae0cd5..6672a4ff 100644 --- a/.gitattributes +++ b/.gitattributes @@ -11,3 +11,10 @@ docker/crontab.* text eol=lf # taxonomy:check` (CI); keep LF on Windows checkouts so the local check # matches what the generator writes. lib/bokslut/ixbrl/taxonomy/generated/*.json text eol=lf linguist-generated=true + +# The extension registry is rewritten with LF by `setup:extensions` on every +# dev/build, and Vitest rewrites snapshots with LF. With core.autocrlf=true +# git would expect CRLF and flag these as phantom modifications; pin LF so +# the working tree matches what the generators write. +lib/extensions/_generated/** text eol=lf linguist-generated=true +*.snap text eol=lf linguist-generated=true diff --git a/DECISIONS.md b/DECISIONS.md index d47f4603..af6430c6 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -37,3 +37,7 @@ One line per decision: `[YYYY-MM-DD] : `. Appended by agents and [2026-07-07] Sjalvfaktura via the public invoice API (support request: "kan inte hitta det i docs"): exposed the RECEIVED self-billing invoice (mottagen sjalvfaktura, ML 17 kap 15: a SALE, Debit 1510 / Credit 30xx+26xx) on the public API as an OPTIONAL is_self_billed flag on the existing POST /api/v1/.../invoices endpoint (+ external_invoice_number, self_billing_agreement_ref, received_date), on Emil's explicit call ("configure sjalvfaktura when creating an invoice ... optional field") over a dedicated /invoices/self-billed v1 endpoint. First built the WRONG interpretation (issue a self-invoice on a SUPPLIER's behalf = a purchase, on the supplier-invoice stack, new SJ- series + PDF + MCP tool + migration 20260706130000); reverted all of it after Emil clarified the user meant the existing seller/received feature, which already existed internally (/api/invoices/self-billed, cookie-session) but was absent from the public API. No migration for B (is_self_billed/external_invoice_number/self_billing_agreement_ref columns already exist from 20260613100000). Extracted lib/invoices/self-billed-sale.ts (resolveSelfBilledSaleDraft + createSelfBilledSaleInvoice) as the single implementation and refactored the internal route to a thin wrapper over it, so the dashboard "Sjalvfaktura" tab and the API can't drift (internal route test still green, 8/8). Fields added as PLAIN optionals (no z.superRefine) so UpdateInvoiceSchema = CreateInvoiceSchema.omit() keeps working (superRefine turns it into ZodEffects, which has no .omit); "external_invoice_number + received_date required when is_self_billed" enforced in the route instead. Documented the flag in the invoices.create registerEndpoint (description + pitfall) since the whole ask was "can't find it in docs". No git touched; nothing deployed. [2026-07-07] Compliance-review sweep on add/api-and-invoice. FIXED: (1) recurring cron double-send window: replaced the read-only "already ran today" check with an atomic compare-and-set claim on last_run_at (release-on-failure) so two overlapping hourly invocations can't both spawn from the same stale batch row; (2) recurring schedule edit dialog could PATCH auto_send=true for a customer with no email (disabled-but-checked box, async customer load after defaultValues): added a useEffect that forces auto_send=false whenever the effective customer lacks an email, mirroring the manual-select guard; (3) momsdeklaration manual-filing rows: switched Math.round -> Math.trunc (öretal faller bort per SFL 22 kap 1 §; in-repo swedish-sru-filing skill confirms öre are DROPPED not rounded, and this now matches the SRU income-tax path). This narrows the earlier [2026-07-06] "whole-krona rounding is deliberate" decision: whole-krona stays deliberate, but the öre handling is truncation, not round-to-nearest. DECLINED (with rationale): self-billed "Självfakturering" notation + own-voucher-number findings are misdirected: createSelfBilledSaleInvoice books a RECEIVED självfaktura (the counterparty issues the document, we render no PDF), and numberOverride only sets the human-readable verifikat description/tag, not the sequential verifikationsnummer (still assigned atomically by commit_journal_entry). Bank-account mod11 padStart padding is correct: leading zeros contribute 0 and preserve the right-aligned check-digit weighting (already tested vs a real Forex account). AGI/KU10 employer name kept as [2026-07-06] (särskilt-företagsnamn caveat already accepted). Livsmedel 12%->6% (April 2026) reactivation guard declined as a description-keyword food-detector: violates the determinism/never-guess principle, and the one-time pause-all-on-deploy already forces conscious reactivation as the natural checkpoint. [2026-07-07] bank_file_imports dedup key widened (user_id,file_hash) -> (company_id,file_hash), migration 20260707130000 applied to prod: the old key made a same-user re-import of the same file into a second company resolve the upsert onto the first company's row, which RLS rightly blocked (42501). Mirrors what 20260330130000 did for sie_imports; v1 route's BANK_IMPORT_DUPLICATE_OTHER_COMPANY pre-check removed as obsolete (structured-errors entry kept for API compat). +[2026-07-07] A1 route-auth campaign migrated 118/119 routes off hand-rolled supabase.auth.getUser() onto withRouteContext (enforces MFA/AAL2); ratchet baseline lowered 119->1. +[2026-07-07] mcp-oauth/authorize left on the raw-auth baseline (count 1, not 0): it renders an HTML consent page and issues 303 redirects, which withRouteContext (JSON envelopes + company-context gate) cannot express; MFA is enforced instead via a route-local requireAal2() step-up (AAL1 sessions redirect to /mfa/verify) since consent mints a long-lived API key that bypasses MFA thereafter. +[2026-07-07] Added { requireWrite: true } to POST /api/reports/vat-declaration/rc-basis-gaps/fix: it calls correctEntry() (storno of a posted entry) and was reachable by viewer-role members. +[2026-07-07] Two GET routes kept requireWrite (salary/runs payment bg-lb/pain001, skatteverket payment-file): they persist a *_file_generated_at stamp and previously gated viewers, so dropping the gate would regress write-protection. diff --git a/app/api/account/password/__tests__/route.test.ts b/app/api/account/password/__tests__/route.test.ts index aedd8234..aa985069 100644 --- a/app/api/account/password/__tests__/route.test.ts +++ b/app/api/account/password/__tests__/route.test.ts @@ -1,15 +1,19 @@ import { describe, it, expect, vi, beforeEach } from 'vitest' +import { NextResponse } from 'next/server' import { createMockRequest, parseJsonResponse } from '@/tests/helpers' vi.mock('@/lib/supabase/server', () => ({ - createClient: vi.fn(), createServiceClient: vi.fn(), })) -import { createClient, createServiceClient } from '@/lib/supabase/server' +const requireAuthMock = vi.fn() +vi.mock('@/lib/auth/require-auth', () => ({ + requireAuth: (...args: unknown[]) => requireAuthMock(...args), +})) + +import { createServiceClient } from '@/lib/supabase/server' import { POST } from '../route' -const mockCreateClient = vi.mocked(createClient) const mockCreateServiceClient = vi.mocked(createServiceClient) type AuthMetadata = Record @@ -23,13 +27,18 @@ function mockUserClient(opts: { error: opts.updateUserError ?? null, }) - mockCreateClient.mockResolvedValue({ - auth: { - getUser: vi.fn().mockResolvedValue({ data: { user: opts.user } }), - updateUser, - }, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - } as any) + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const supabase = { auth: { updateUser } } as any + + if (opts.user) { + requireAuthMock.mockResolvedValue({ user: opts.user, supabase, error: null }) + } else { + requireAuthMock.mockResolvedValue({ + user: null, + supabase, + error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }), + }) + } return { updateUser } } diff --git a/app/api/account/password/route.ts b/app/api/account/password/route.ts index 7badedb7..62a3d599 100644 --- a/app/api/account/password/route.ts +++ b/app/api/account/password/route.ts @@ -1,6 +1,7 @@ -import { createClient, createServiceClient } from '@/lib/supabase/server' +import { createServiceClient } from '@/lib/supabase/server' import { NextResponse } from 'next/server' import { z } from 'zod' +import { requireAuth } from '@/lib/auth/require-auth' import { validateBody } from '@/lib/api/validate' import { createLogger } from '@/lib/logger' @@ -49,14 +50,8 @@ const SetPasswordSchema = z.object({ * more time, but a retry will re-flip the flag. */ export async function POST(request: Request) { - const supabase = await createClient() - - const { - data: { user }, - } = await supabase.auth.getUser() - if (!user) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } + const { user, supabase, error: authError } = await requireAuth() + if (authError) return authError const result = await validateBody(request, SetPasswordSchema) if (!result.success) return result.response diff --git a/app/api/agent/invoke/route.ts b/app/api/agent/invoke/route.ts index 380d7ce0..b85300a1 100644 --- a/app/api/agent/invoke/route.ts +++ b/app/api/agent/invoke/route.ts @@ -2,6 +2,7 @@ import { createClient } from '@/lib/supabase/server' import { NextResponse } from 'next/server' import { z } from 'zod' import { ensureInitialized } from '@/lib/init' +import { requireAuth } from '@/lib/auth/require-auth' import { getActiveCompanyId } from '@/lib/company/context' import { getIntent } from '@/lib/agent/intents/registry' import { checkAgentRateLimit, agentRateLimitResponseBody } from '@/lib/rate-limits/agent' @@ -68,9 +69,8 @@ const BodySchema = z.object({ // // Plan ref: dev_docs/specialized-agent-plan.md §9 (chat loop). export async function POST(request: Request) { - const supabase = await createClient() - const { data: { user } } = await supabase.auth.getUser() - if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + const { user, supabase, error } = await requireAuth() + if (error) return error // Generous per-user rate limit: bounds runaway Bedrock spend (loop-firing // sessions). Fails open on infra error. diff --git a/app/api/agent/onboarding/stream/route.ts b/app/api/agent/onboarding/stream/route.ts index 92d492dd..943115bb 100644 --- a/app/api/agent/onboarding/stream/route.ts +++ b/app/api/agent/onboarding/stream/route.ts @@ -1,6 +1,6 @@ -import { createClient } from '@/lib/supabase/server' import { NextResponse } from 'next/server' import { z } from 'zod' +import { requireAuth } from '@/lib/auth/require-auth' import { getActiveCompanyId } from '@/lib/company/context' import { guardSandbox } from '@/lib/sandbox/guard' import { requireCapability } from '@/lib/entitlements/has-capability' @@ -64,9 +64,8 @@ interface ProfilePayload { // // Response: application/x-ndjson, one JSON event per line. export async function POST(request: Request) { - const supabase = await createClient() - const { data: { user } } = await supabase.auth.getUser() - if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + const { user, supabase, error } = await requireAuth() + if (error) return error // Generous per-user rate limit: bounds reload-spam of the onboarding build // (each run fires 2 LLM calls). Fails open on infra error. diff --git a/app/api/documents/[id]/__tests__/route.test.ts b/app/api/documents/[id]/__tests__/route.test.ts index 6924eda3..e26df263 100644 --- a/app/api/documents/[id]/__tests__/route.test.ts +++ b/app/api/documents/[id]/__tests__/route.test.ts @@ -7,8 +7,10 @@ import { } from '@/tests/helpers' const { supabase: mockSupabase, enqueue, reset } = createQueuedMockSupabase() -vi.mock('@/lib/supabase/server', () => ({ - createClient: () => Promise.resolve(mockSupabase), + +const requireAuthMock = vi.fn() +vi.mock('@/lib/auth/require-auth', () => ({ + requireAuth: (...args: unknown[]) => requireAuthMock(...args), })) vi.mock('@/lib/company/context', () => ({ @@ -34,7 +36,7 @@ beforeEach(() => { vi.clearAllMocks() reset() eventBus.clear() - mockSupabase.auth.getUser.mockResolvedValue({ data: { user: mockUser } }) + requireAuthMock.mockResolvedValue({ user: mockUser, supabase: mockSupabase, error: null }) // Reset write-permission mock to default ok vi.mocked(requireWritePermission).mockResolvedValue({ ok: true }) }) @@ -45,7 +47,11 @@ function makeReq() { describe('DELETE /api/documents/[id]', () => { it('returns 401 when not authenticated', async () => { - mockSupabase.auth.getUser.mockResolvedValue({ data: { user: null } }) + requireAuthMock.mockResolvedValue({ + user: null, + supabase: mockSupabase, + error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }), + }) const res = await DELETE(makeReq(), createMockRouteParams({ id: 'doc-1' })) const { status, body } = await parseJsonResponse(res) expect(status).toBe(401) diff --git a/app/api/documents/[id]/extraction-status/route.ts b/app/api/documents/[id]/extraction-status/route.ts index e2cc189e..8849c517 100644 --- a/app/api/documents/[id]/extraction-status/route.ts +++ b/app/api/documents/[id]/extraction-status/route.ts @@ -1,6 +1,5 @@ -import { createClient } from '@/lib/supabase/server' import { NextResponse } from 'next/server' -import { requireCompanyId } from '@/lib/company/context' +import { withRouteContext } from '@/lib/api/with-route-context' // GET /api/documents/:id/extraction-status // @@ -18,48 +17,43 @@ import { requireCompanyId } from '@/lib/company/context' // stays untouched indefinitely). Client times out and shows // a quiet fallback. We don't distinguish this from running // server-side: the client decides based on elapsed time. -export async function GET( - _request: Request, - { params }: { params: Promise<{ id: string }> }, -) { - const supabase = await createClient() - const { data: { user } } = await supabase.auth.getUser() - if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) +export const GET = withRouteContext<{ params: Promise<{ id: string }> }>( + 'document.extraction_status', + async (_request, { supabase, companyId }, { params }) => { + const { id } = await params - const companyId = await requireCompanyId(supabase, user.id) - const { id } = await params + const { data, error } = await supabase + .from('document_attachments') + .select('id, extracted_at, extracted_data, extraction_model') + .eq('id', id) + .eq('company_id', companyId) + .maybeSingle() - const { data, error } = await supabase - .from('document_attachments') - .select('id, extracted_at, extracted_data, extraction_model') - .eq('id', id) - .eq('company_id', companyId) - .maybeSingle() + if (error) return NextResponse.json({ error: error.message }, { status: 500 }) + if (!data) return NextResponse.json({ error: 'Not found' }, { status: 404 }) - if (error) return NextResponse.json({ error: error.message }, { status: 500 }) - if (!data) return NextResponse.json({ error: 'Not found' }, { status: 404 }) + const extractedAt = data.extracted_at as string | null + const extractedData = data.extracted_data as Record | null + const model = data.extraction_model as string | null - const extractedAt = data.extracted_at as string | null - const extractedData = data.extracted_data as Record | null - const model = data.extraction_model as string | null + let status: 'running' | 'succeeded' | 'failed' | 'unsupported' + if (!extractedAt) { + status = 'running' + } else if (extractedData) { + status = 'succeeded' + } else if (model?.startsWith('skipped:')) { + status = 'unsupported' + } else { + status = 'failed' + } - let status: 'running' | 'succeeded' | 'failed' | 'unsupported' - if (!extractedAt) { - status = 'running' - } else if (extractedData) { - status = 'succeeded' - } else if (model?.startsWith('skipped:')) { - status = 'unsupported' - } else { - status = 'failed' + return NextResponse.json({ + data: { + id: data.id, + status, + extracted_at: extractedAt, + extraction_model: model, + }, + }) } - - return NextResponse.json({ - data: { - id: data.id, - status, - extracted_at: extractedAt, - extraction_model: model, - }, - }) -} +) diff --git a/app/api/documents/[id]/route.ts b/app/api/documents/[id]/route.ts index c3f649dd..d3067773 100644 --- a/app/api/documents/[id]/route.ts +++ b/app/api/documents/[id]/route.ts @@ -1,8 +1,6 @@ -import { createClient } from '@/lib/supabase/server' import { NextResponse } from 'next/server' import { ensureInitialized } from '@/lib/init' -import { requireCompanyId } from '@/lib/company/context' -import { requireWritePermission } from '@/lib/auth/require-write' +import { withRouteContext } from '@/lib/api/with-route-context' import { deleteDocument } from '@/lib/core/documents/document-service' import { eventBus } from '@/lib/events' @@ -12,62 +10,52 @@ ensureInitialized() * GET /api/documents/:id * Fetch document metadata + signed download URL (60 min expiry) */ -export async function GET( - request: Request, - { params }: { params: Promise<{ id: string }> } -) { - const supabase = await createClient() +export const GET = withRouteContext<{ params: Promise<{ id: string }> }>( + 'document.get', + async (_request, { supabase, companyId, user }, { params }) => { + const { id } = await params - const { data: { user } } = await supabase.auth.getUser() + // Fetch document record + const { data: doc, error: docError } = await supabase + .from('document_attachments') + .select('*') + .eq('id', id) + .eq('company_id', companyId) + .single() - if (!user) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + if (docError || !doc) { + return NextResponse.json({ error: 'Document not found' }, { status: 404 }) + } + + // Create signed download URL (60 minutes) + const { data: signedUrl, error: signError } = await supabase.storage + .from('documents') + .createSignedUrl(doc.storage_path, 3600) + + if (signError) { + return NextResponse.json( + { error: `Failed to create download URL: ${signError.message}` }, + { status: 500 } + ) + } + + await eventBus.emit({ + type: 'document.accessed', + payload: { + document: { id: doc.id, file_name: doc.file_name }, + userId: user.id, + companyId, + }, + }) + + return NextResponse.json({ + data: { + ...doc, + download_url: signedUrl.signedUrl, + }, + }) } - - const companyId = await requireCompanyId(supabase, user.id) - - const { id } = await params - - // Fetch document record - const { data: doc, error: docError } = await supabase - .from('document_attachments') - .select('*') - .eq('id', id) - .eq('company_id', companyId) - .single() - - if (docError || !doc) { - return NextResponse.json({ error: 'Document not found' }, { status: 404 }) - } - - // Create signed download URL (60 minutes) - const { data: signedUrl, error: signError } = await supabase.storage - .from('documents') - .createSignedUrl(doc.storage_path, 3600) - - if (signError) { - return NextResponse.json( - { error: `Failed to create download URL: ${signError.message}` }, - { status: 500 } - ) - } - - await eventBus.emit({ - type: 'document.accessed', - payload: { - document: { id: doc.id, file_name: doc.file_name }, - userId: user.id, - companyId, - }, - }) - - return NextResponse.json({ - data: { - ...doc, - download_url: signedUrl.signedUrl, - }, - }) -} +) /** * DELETE /api/documents/:id @@ -76,37 +64,26 @@ export async function GET( * BFL 7 kap 2§ and must be retained for 7 years. For linked docs the caller * should use POST /api/documents/:id/versions to supersede via a new version. */ -export async function DELETE( - _request: Request, - { params }: { params: Promise<{ id: string }> } -) { - const supabase = await createClient() +export const DELETE = withRouteContext<{ params: Promise<{ id: string }> }>( + 'document.delete', + async (_request, { supabase, companyId }, { params }) => { + const { id } = await params - const { data: { user } } = await supabase.auth.getUser() - if (!user) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } + try { + const result = await deleteDocument(supabase, companyId, id) - const writeCheck = await requireWritePermission(supabase, user.id) - if (!writeCheck.ok) return writeCheck.response + if (!result.ok) { + return NextResponse.json({ error: result.message }, { status: result.status }) + } - const companyId = await requireCompanyId(supabase, user.id) - - const { id } = await params - - try { - const result = await deleteDocument(supabase, companyId, id) - - if (!result.ok) { - return NextResponse.json({ error: result.message }, { status: result.status }) + return NextResponse.json({ data: { id: result.document.id, deleted: true } }) + } catch (error) { + console.error('[documents/DELETE] Failed to delete document:', error) + return NextResponse.json( + { error: error instanceof Error ? error.message : 'Failed to delete document' }, + { status: 500 } + ) } - - return NextResponse.json({ data: { id: result.document.id, deleted: true } }) - } catch (error) { - console.error('[documents/DELETE] Failed to delete document:', error) - return NextResponse.json( - { error: error instanceof Error ? error.message : 'Failed to delete document' }, - { status: 500 } - ) - } -} + }, + { requireWrite: true } +) diff --git a/app/api/documents/[id]/verify/__tests__/route.test.ts b/app/api/documents/[id]/verify/__tests__/route.test.ts new file mode 100644 index 00000000..08aef473 --- /dev/null +++ b/app/api/documents/[id]/verify/__tests__/route.test.ts @@ -0,0 +1,82 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { NextResponse } from 'next/server' +import { + parseJsonResponse, + createMockRouteParams, + createQueuedMockSupabase, +} from '@/tests/helpers' + +const { supabase: mockSupabase, reset } = createQueuedMockSupabase() + +const requireAuthMock = vi.fn() +vi.mock('@/lib/auth/require-auth', () => ({ + requireAuth: (...args: unknown[]) => requireAuthMock(...args), +})) + +vi.mock('@/lib/company/context', () => ({ + requireCompanyId: vi.fn().mockResolvedValue('company-1'), + getActiveCompanyId: vi.fn().mockResolvedValue('company-1'), +})) + +vi.mock('@/lib/auth/require-write', () => ({ + requireWritePermission: vi.fn().mockResolvedValue({ ok: true }), +})) + +vi.mock('@/lib/init', () => ({ ensureInitialized: vi.fn() })) + +const mockVerifyIntegrity = vi.fn() +vi.mock('@/lib/core/documents/document-service', () => ({ + verifyIntegrity: (...args: unknown[]) => mockVerifyIntegrity(...args), +})) + +import { POST } from '../route' +import { requireWritePermission } from '@/lib/auth/require-write' + +const mockUser = { id: 'user-1', email: 'test@test.se' } + +function makeReq() { + return new Request('http://localhost/api/documents/doc-1/verify', { method: 'POST' }) +} + +beforeEach(() => { + vi.clearAllMocks() + reset() + requireAuthMock.mockResolvedValue({ user: mockUser, supabase: mockSupabase, error: null }) + vi.mocked(requireWritePermission).mockResolvedValue({ ok: true }) +}) + +describe('POST /api/documents/[id]/verify', () => { + it('returns 401 when not authenticated', async () => { + requireAuthMock.mockResolvedValue({ + user: null, + supabase: mockSupabase, + error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }), + }) + const res = await POST(makeReq(), createMockRouteParams({ id: 'doc-1' })) + const { status } = await parseJsonResponse(res) + expect(status).toBe(401) + }) + + it('returns 403 when caller has read-only role', async () => { + vi.mocked(requireWritePermission).mockResolvedValue({ + ok: false, + response: NextResponse.json( + { error: 'Du har endast läsbehörighet i detta företag.' }, + { status: 403 }, + ), + }) + const res = await POST(makeReq(), createMockRouteParams({ id: 'doc-1' })) + const { status } = await parseJsonResponse(res) + expect(status).toBe(403) + expect(mockVerifyIntegrity).not.toHaveBeenCalled() + }) + + it('returns the integrity result on success', async () => { + mockVerifyIntegrity.mockResolvedValue({ verified: true, hash_matches: true }) + const res = await POST(makeReq(), createMockRouteParams({ id: 'doc-1' })) + const { status, body } = await parseJsonResponse<{ data: { verified: boolean } }>(res) + expect(status).toBe(200) + expect(body.data.verified).toBe(true) + expect(mockVerifyIntegrity).toHaveBeenCalledWith(mockSupabase, 'company-1', 'doc-1') + }) +}) diff --git a/app/api/documents/[id]/verify/route.ts b/app/api/documents/[id]/verify/route.ts index eef09825..e1da1c3e 100644 --- a/app/api/documents/[id]/verify/route.ts +++ b/app/api/documents/[id]/verify/route.ts @@ -1,9 +1,7 @@ -import { createClient } from '@/lib/supabase/server' import { NextResponse } from 'next/server' import { ensureInitialized } from '@/lib/init' +import { withRouteContext } from '@/lib/api/with-route-context' import { verifyIntegrity } from '@/lib/core/documents/document-service' -import { requireCompanyId } from '@/lib/company/context' -import { requireWritePermission } from '@/lib/auth/require-write' ensureInitialized() @@ -11,34 +9,22 @@ ensureInitialized() * POST /api/documents/:id/verify * Verify document integrity by re-computing SHA-256 and comparing */ -export async function POST( - request: Request, - { params }: { params: Promise<{ id: string }> } -) { - const supabase = await createClient() +export const POST = withRouteContext<{ params: Promise<{ id: string }> }>( + 'document.verify', + async (_request, { supabase, companyId }, { params }) => { + const { id } = await params - const { data: { user } } = await supabase.auth.getUser() + try { + const result = await verifyIntegrity(supabase, companyId, id) - if (!user) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const writeCheck = await requireWritePermission(supabase, user.id) - if (!writeCheck.ok) return writeCheck.response - - const companyId = await requireCompanyId(supabase, user.id) - - const { id } = await params - - try { - const result = await verifyIntegrity(supabase, companyId, id) - - return NextResponse.json({ data: result }) - } catch (error) { - console.error('[documents/verify/POST] Verification failed:', error) - return NextResponse.json( - { error: error instanceof Error ? error.message : 'Verification failed' }, - { status: 500 } - ) - } -} + return NextResponse.json({ data: result }) + } catch (error) { + console.error('[documents/verify/POST] Verification failed:', error) + return NextResponse.json( + { error: error instanceof Error ? error.message : 'Verification failed' }, + { status: 500 } + ) + } + }, + { requireWrite: true } +) diff --git a/app/api/documents/[id]/versions/__tests__/route.test.ts b/app/api/documents/[id]/versions/__tests__/route.test.ts new file mode 100644 index 00000000..332f2063 --- /dev/null +++ b/app/api/documents/[id]/versions/__tests__/route.test.ts @@ -0,0 +1,104 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { NextResponse } from 'next/server' +import { + parseJsonResponse, + createMockRouteParams, + createQueuedMockSupabase, +} from '@/tests/helpers' + +const { supabase: mockSupabase, reset } = createQueuedMockSupabase() + +const requireAuthMock = vi.fn() +vi.mock('@/lib/auth/require-auth', () => ({ + requireAuth: (...args: unknown[]) => requireAuthMock(...args), +})) + +vi.mock('@/lib/company/context', () => ({ + requireCompanyId: vi.fn().mockResolvedValue('company-1'), + getActiveCompanyId: vi.fn().mockResolvedValue('company-1'), +})) + +vi.mock('@/lib/auth/require-write', () => ({ + requireWritePermission: vi.fn().mockResolvedValue({ ok: true }), +})) + +vi.mock('@/lib/init', () => ({ ensureInitialized: vi.fn() })) + +const mockCreateNewVersion = vi.fn() +const mockValidateDocumentFile = vi.fn() +vi.mock('@/lib/core/documents/document-service', () => ({ + createNewVersion: (...args: unknown[]) => mockCreateNewVersion(...args), + validateDocumentFile: (...args: unknown[]) => mockValidateDocumentFile(...args), +})) + +import { POST } from '../route' +import { requireWritePermission } from '@/lib/auth/require-write' + +const mockUser = { id: 'user-1', email: 'test@test.se' } + +function makeReq(withFile = true) { + const form = new FormData() + if (withFile) { + form.append('file', new File(['content'], 'kvitto.pdf', { type: 'application/pdf' })) + } + return new Request('http://localhost/api/documents/doc-1/versions', { + method: 'POST', + body: form, + }) +} + +beforeEach(() => { + vi.clearAllMocks() + reset() + requireAuthMock.mockResolvedValue({ user: mockUser, supabase: mockSupabase, error: null }) + vi.mocked(requireWritePermission).mockResolvedValue({ ok: true }) + mockValidateDocumentFile.mockReturnValue(null) +}) + +describe('POST /api/documents/[id]/versions', () => { + it('returns 401 when not authenticated', async () => { + requireAuthMock.mockResolvedValue({ + user: null, + supabase: mockSupabase, + error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }), + }) + const res = await POST(makeReq(), createMockRouteParams({ id: 'doc-1' })) + const { status } = await parseJsonResponse(res) + expect(status).toBe(401) + }) + + it('returns 403 when caller has read-only role', async () => { + vi.mocked(requireWritePermission).mockResolvedValue({ + ok: false, + response: NextResponse.json( + { error: 'Du har endast läsbehörighet i detta företag.' }, + { status: 403 }, + ), + }) + const res = await POST(makeReq(), createMockRouteParams({ id: 'doc-1' })) + const { status } = await parseJsonResponse(res) + expect(status).toBe(403) + expect(mockCreateNewVersion).not.toHaveBeenCalled() + }) + + it('returns 400 when no file is provided', async () => { + const res = await POST(makeReq(false), createMockRouteParams({ id: 'doc-1' })) + const { status, body } = await parseJsonResponse<{ error: string }>(res) + expect(status).toBe(400) + expect(body.error).toBe('No file provided') + }) + + it('creates a new version on the happy path', async () => { + mockCreateNewVersion.mockResolvedValue({ id: 'doc-2', version: 2 }) + const res = await POST(makeReq(), createMockRouteParams({ id: 'doc-1' })) + const { status, body } = await parseJsonResponse<{ data: { id: string; version: number } }>(res) + expect(status).toBe(200) + expect(body.data).toEqual({ id: 'doc-2', version: 2 }) + expect(mockCreateNewVersion).toHaveBeenCalledWith( + mockSupabase, + 'user-1', + 'doc-1', + expect.objectContaining({ name: 'kvitto.pdf', type: 'application/pdf' }), + ) + }) +}) diff --git a/app/api/documents/[id]/versions/route.ts b/app/api/documents/[id]/versions/route.ts index c8a2d4cc..ab3bdeab 100644 --- a/app/api/documents/[id]/versions/route.ts +++ b/app/api/documents/[id]/versions/route.ts @@ -1,9 +1,7 @@ -import { createClient } from '@/lib/supabase/server' import { NextResponse } from 'next/server' import { ensureInitialized } from '@/lib/init' +import { withRouteContext } from '@/lib/api/with-route-context' import { createNewVersion, validateDocumentFile } from '@/lib/core/documents/document-service' -import { requireCompanyId } from '@/lib/company/context' -import { requireWritePermission } from '@/lib/auth/require-write' ensureInitialized() @@ -14,102 +12,80 @@ ensureInitialized() * Accepts multipart/form-data with: * - file: The new version file */ -export async function POST( - request: Request, - { params }: { params: Promise<{ id: string }> } -) { - const supabase = await createClient() +export const POST = withRouteContext<{ params: Promise<{ id: string }> }>( + 'document.versions.create', + async (request, { supabase, user }, { params }) => { + const { id } = await params - const { data: { user } } = await supabase.auth.getUser() + try { + const formData = await request.formData() + const file = formData.get('file') as File | null - if (!user) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } + if (!file) { + return NextResponse.json({ error: 'No file provided' }, { status: 400 }) + } - const writeCheck = await requireWritePermission(supabase, user.id) - if (!writeCheck.ok) return writeCheck.response + const validationError = validateDocumentFile({ size: file.size, type: file.type }) + if (validationError) { + return NextResponse.json({ error: validationError }, { status: 400 }) + } - const companyId = await requireCompanyId(supabase, user.id) + const buffer = await file.arrayBuffer() - const { id } = await params + const newVersion = await createNewVersion(supabase, user.id, id, { + name: file.name, + buffer, + type: file.type, + }) - try { - const formData = await request.formData() - const file = formData.get('file') as File | null - - if (!file) { - return NextResponse.json({ error: 'No file provided' }, { status: 400 }) + return NextResponse.json({ data: newVersion }) + } catch (error) { + console.error('[documents/versions/POST] Version creation failed:', error) + return NextResponse.json( + { error: error instanceof Error ? error.message : 'Version creation failed' }, + { status: 500 } + ) } - - const validationError = validateDocumentFile({ size: file.size, type: file.type }) - if (validationError) { - return NextResponse.json({ error: validationError }, { status: 400 }) - } - - const buffer = await file.arrayBuffer() - - const newVersion = await createNewVersion(supabase, user.id, id, { - name: file.name, - buffer, - type: file.type, - }) - - return NextResponse.json({ data: newVersion }) - } catch (error) { - console.error('[documents/versions/POST] Version creation failed:', error) - return NextResponse.json( - { error: error instanceof Error ? error.message : 'Version creation failed' }, - { status: 500 } - ) - } -} + }, + { requireWrite: true } +) /** * GET /api/documents/:id/versions * List all versions in the document chain */ -export async function GET( - request: Request, - { params }: { params: Promise<{ id: string }> } -) { - const supabase = await createClient() +export const GET = withRouteContext<{ params: Promise<{ id: string }> }>( + 'document.versions.list', + async (_request, { supabase, companyId }, { params }) => { + const { id } = await params - const { data: { user } } = await supabase.auth.getUser() + // First, check if the document belongs to the company + const { data: doc, error: docError } = await supabase + .from('document_attachments') + .select('id, original_id') + .eq('id', id) + .eq('company_id', companyId) + .single() - if (!user) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + if (docError || !doc) { + return NextResponse.json({ error: 'Document not found' }, { status: 404 }) + } + + // The root document is either the original_id or the document itself + const rootId = doc.original_id || doc.id + + // Fetch all versions in the chain + const { data: versions, error: versionsError } = await supabase + .from('document_attachments') + .select('*') + .eq('company_id', companyId) + .or(`id.eq.${rootId},original_id.eq.${rootId}`) + .order('version', { ascending: true }) + + if (versionsError) { + return NextResponse.json({ error: versionsError.message }, { status: 500 }) + } + + return NextResponse.json({ data: versions }) } - - const companyId = await requireCompanyId(supabase, user.id) - - const { id } = await params - - // First, check if the document belongs to the company - const { data: doc, error: docError } = await supabase - .from('document_attachments') - .select('id, original_id') - .eq('id', id) - .eq('company_id', companyId) - .single() - - if (docError || !doc) { - return NextResponse.json({ error: 'Document not found' }, { status: 404 }) - } - - // The root document is either the original_id or the document itself - const rootId = doc.original_id || doc.id - - // Fetch all versions in the chain - const { data: versions, error: versionsError } = await supabase - .from('document_attachments') - .select('*') - .eq('company_id', companyId) - .or(`id.eq.${rootId},original_id.eq.${rootId}`) - .order('version', { ascending: true }) - - if (versionsError) { - return NextResponse.json({ error: versionsError.message }, { status: 500 }) - } - - return NextResponse.json({ data: versions }) -} +) diff --git a/app/api/events/__tests__/route.test.ts b/app/api/events/__tests__/route.test.ts index a56ef766..b2ee0adf 100644 --- a/app/api/events/__tests__/route.test.ts +++ b/app/api/events/__tests__/route.test.ts @@ -1,14 +1,17 @@ import { describe, it, expect, vi, beforeEach } from 'vitest' +import { NextResponse } from 'next/server' import { createMockRequest, parseJsonResponse, createQueuedMockSupabase, } from '@/tests/helpers' -// Mock supabase server (session auth) +// Mock session auth (requireAuth enforces MFA; returns the request-scoped client) const { supabase: mockSupabase, enqueue, reset } = createQueuedMockSupabase() -vi.mock('@/lib/supabase/server', () => ({ - createClient: () => Promise.resolve(mockSupabase), + +const requireAuthMock = vi.fn() +vi.mock('@/lib/auth/require-auth', () => ({ + requireAuth: (...args: unknown[]) => requireAuthMock(...args), })) vi.mock('@/lib/company/context', () => ({ @@ -52,12 +55,16 @@ describe('GET /api/events', () => { vi.clearAllMocks() reset() mockExtractBearerToken.mockReturnValue(null) - mockSupabase.auth.getUser.mockResolvedValue({ data: { user: mockUser } }) + requireAuthMock.mockResolvedValue({ user: mockUser, supabase: mockSupabase, error: null }) }) it('returns 401 when not authenticated', async () => { mockExtractBearerToken.mockReturnValue(null) - mockSupabase.auth.getUser.mockResolvedValue({ data: { user: null } }) + requireAuthMock.mockResolvedValue({ + user: null, + supabase: mockSupabase, + error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }), + }) const request = createMockRequest('/api/events') const response = await GET(request) diff --git a/app/api/events/route.ts b/app/api/events/route.ts index f96a79d4..f7b1fd97 100644 --- a/app/api/events/route.ts +++ b/app/api/events/route.ts @@ -1,5 +1,5 @@ -import { createClient } from '@/lib/supabase/server' import { NextResponse } from 'next/server' +import { requireAuth } from '@/lib/auth/require-auth' import { extractBearerToken, validateApiKey, createServiceClientNoCookies } from '@/lib/auth/api-keys' import { validateQuery } from '@/lib/api/validate' import { EventsQuerySchema } from '@/lib/api/schemas' @@ -39,12 +39,12 @@ export async function GET(request: Request) { keyCompanyId = authResult.companyId supabase = createServiceClientNoCookies() } else { - supabase = await createClient() - const { data: { user } } = await supabase.auth.getUser() - if (!user) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - userId = user.id + // Session auth: requireAuth enforces MFA (AAL2) on hosted, unlike a bare + // getUser call which skips the assurance-level check. + const auth = await requireAuth() + if (auth.error) return auth.error + supabase = auth.supabase + userId = auth.user.id } // Session auth resolves the active company; API-key auth uses the key's bound company. diff --git a/app/api/extensions/[sector]/[slug]/data/__tests__/route.test.ts b/app/api/extensions/[sector]/[slug]/data/__tests__/route.test.ts new file mode 100644 index 00000000..05292ffa --- /dev/null +++ b/app/api/extensions/[sector]/[slug]/data/__tests__/route.test.ts @@ -0,0 +1,122 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { NextResponse } from 'next/server' +import { + createMockRequest, + createMockRouteParams, + parseJsonResponse, + createQueuedMockSupabase, +} from '@/tests/helpers' + +const { supabase: mockSupabase, enqueue, reset } = createQueuedMockSupabase() + +const requireAuthMock = vi.fn() +vi.mock('@/lib/auth/require-auth', () => ({ + requireAuth: (...args: unknown[]) => requireAuthMock(...args), +})) + +vi.mock('@/lib/company/context', () => ({ + getActiveCompanyId: vi.fn().mockResolvedValue('company-1'), + requireCompanyId: vi.fn().mockResolvedValue('company-1'), +})) + +const requireWriteMock = vi.fn() +vi.mock('@/lib/auth/require-write', () => ({ + requireWritePermission: (...args: unknown[]) => requireWriteMock(...args), +})) + +import { GET, POST, DELETE } from '../route' + +const params = () => createMockRouteParams({ sector: 'general', slug: 'demo' }) + +describe('/api/extensions/[sector]/[slug]/data', () => { + const mockUser = { id: 'user-1', email: 'test@test.se' } + + beforeEach(() => { + vi.clearAllMocks() + reset() + requireAuthMock.mockResolvedValue({ user: mockUser, supabase: mockSupabase, error: null }) + requireWriteMock.mockResolvedValue({ ok: true }) + }) + + it('GET lists extension data', async () => { + enqueue({ data: [{ key: 'a', value: 1 }] }) + + const response = await GET(createMockRequest('/api/extensions/general/demo/data'), params()) + const { status, body } = await parseJsonResponse<{ data: unknown[] }>(response) + + expect(status).toBe(200) + expect(body.data).toHaveLength(1) + }) + + it('POST returns 401 when not authenticated', async () => { + requireAuthMock.mockResolvedValue({ + user: null, + supabase: mockSupabase, + error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }), + }) + + const response = await POST( + createMockRequest('/api/extensions/general/demo/data', { method: 'POST', body: { key: 'k', value: 1 } }), + params(), + ) + expect(response.status).toBe(401) + }) + + it('POST returns 403 for a viewer', async () => { + requireWriteMock.mockResolvedValue({ + ok: false, + response: NextResponse.json({ error: 'Forbidden' }, { status: 403 }), + }) + + const response = await POST( + createMockRequest('/api/extensions/general/demo/data', { method: 'POST', body: { key: 'k', value: 1 } }), + params(), + ) + expect(response.status).toBe(403) + }) + + it('POST returns 400 when key is missing', async () => { + const response = await POST( + createMockRequest('/api/extensions/general/demo/data', { method: 'POST', body: { value: 1 } }), + params(), + ) + expect(response.status).toBe(400) + }) + + it('POST upserts a key/value (happy path)', async () => { + enqueue({ data: { key: 'k', value: 42 }, error: null }) + + const response = await POST( + createMockRequest('/api/extensions/general/demo/data', { method: 'POST', body: { key: 'k', value: 42 } }), + params(), + ) + const { status, body } = await parseJsonResponse<{ data: { value: number } }>(response) + + expect(status).toBe(200) + expect(body.data.value).toBe(42) + }) + + it('DELETE returns 400 without a key query param', async () => { + const response = await DELETE( + createMockRequest('/api/extensions/general/demo/data', { method: 'DELETE' }), + params(), + ) + expect(response.status).toBe(400) + }) + + it('DELETE removes a key (happy path)', async () => { + enqueue({ data: null, error: null }) + + const response = await DELETE( + createMockRequest('/api/extensions/general/demo/data', { + method: 'DELETE', + searchParams: { key: 'k' }, + }), + params(), + ) + const { status, body } = await parseJsonResponse<{ success: boolean }>(response) + + expect(status).toBe(200) + expect(body.success).toBe(true) + }) +}) diff --git a/app/api/extensions/[sector]/[slug]/data/route.ts b/app/api/extensions/[sector]/[slug]/data/route.ts index 117d8271..3c05fd7f 100644 --- a/app/api/extensions/[sector]/[slug]/data/route.ts +++ b/app/api/extensions/[sector]/[slug]/data/route.ts @@ -1,21 +1,10 @@ -import { createClient } from '@/lib/supabase/server' import { NextResponse } from 'next/server' -import { requireCompanyId } from '@/lib/company/context' -import { requireWritePermission } from '@/lib/auth/require-write' +import { withRouteContext } from '@/lib/api/with-route-context' -export async function GET( - request: Request, - { params }: { params: Promise<{ sector: string; slug: string }> } -) { +export const GET = withRouteContext<{ params: Promise<{ sector: string; slug: string }> }>( + 'extension.data.get', + async (request, { supabase, companyId }, { params }) => { const { sector, slug } = await params - const supabase = await createClient() - const { data: { user } } = await supabase.auth.getUser() - - if (!user) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const companyId = await requireCompanyId(supabase, user.id) const extensionId = `${sector}/${slug}` @@ -43,24 +32,13 @@ export async function GET( } return NextResponse.json({ data }) -} + }, +) -export async function POST( - request: Request, - { params }: { params: Promise<{ sector: string; slug: string }> } -) { +export const POST = withRouteContext<{ params: Promise<{ sector: string; slug: string }> }>( + 'extension.data.set', + async (request, { supabase, user, companyId }, { params }) => { const { sector, slug } = await params - const supabase = await createClient() - const { data: { user } } = await supabase.auth.getUser() - - if (!user) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const writeCheck = await requireWritePermission(supabase, user.id) - if (!writeCheck.ok) return writeCheck.response - - const companyId = await requireCompanyId(supabase, user.id) const body = await request.json() const { key, value } = body @@ -91,24 +69,14 @@ export async function POST( } return NextResponse.json({ data }) -} + }, + { requireWrite: true }, +) -export async function DELETE( - request: Request, - { params }: { params: Promise<{ sector: string; slug: string }> } -) { +export const DELETE = withRouteContext<{ params: Promise<{ sector: string; slug: string }> }>( + 'extension.data.delete', + async (request, { supabase, companyId }, { params }) => { const { sector, slug } = await params - const supabase = await createClient() - const { data: { user } } = await supabase.auth.getUser() - - if (!user) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const writeCheck = await requireWritePermission(supabase, user.id) - if (!writeCheck.ok) return writeCheck.response - - const companyId = await requireCompanyId(supabase, user.id) const { searchParams } = new URL(request.url) const key = searchParams.get('key') @@ -131,4 +99,6 @@ export async function DELETE( } return NextResponse.json({ success: true }) -} + }, + { requireWrite: true }, +) diff --git a/app/api/extensions/[sector]/[slug]/settings/__tests__/route.test.ts b/app/api/extensions/[sector]/[slug]/settings/__tests__/route.test.ts new file mode 100644 index 00000000..84c2bc0d --- /dev/null +++ b/app/api/extensions/[sector]/[slug]/settings/__tests__/route.test.ts @@ -0,0 +1,91 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { NextResponse } from 'next/server' +import { + createMockRequest, + createMockRouteParams, + parseJsonResponse, + createQueuedMockSupabase, +} from '@/tests/helpers' + +const { supabase: mockSupabase, enqueue, reset } = createQueuedMockSupabase() + +const requireAuthMock = vi.fn() +vi.mock('@/lib/auth/require-auth', () => ({ + requireAuth: (...args: unknown[]) => requireAuthMock(...args), +})) + +vi.mock('@/lib/company/context', () => ({ + getActiveCompanyId: vi.fn().mockResolvedValue('company-1'), + requireCompanyId: vi.fn().mockResolvedValue('company-1'), +})) + +const requireWriteMock = vi.fn() +vi.mock('@/lib/auth/require-write', () => ({ + requireWritePermission: (...args: unknown[]) => requireWriteMock(...args), +})) + +import { GET, PATCH } from '../route' + +const params = () => createMockRouteParams({ sector: 'general', slug: 'demo' }) + +describe('/api/extensions/[sector]/[slug]/settings', () => { + const mockUser = { id: 'user-1', email: 'test@test.se' } + + beforeEach(() => { + vi.clearAllMocks() + reset() + requireAuthMock.mockResolvedValue({ user: mockUser, supabase: mockSupabase, error: null }) + requireWriteMock.mockResolvedValue({ ok: true }) + }) + + it('GET returns the stored settings value', async () => { + enqueue({ data: { value: { theme: 'dark' } } }) + + const response = await GET(createMockRequest('/api/extensions/general/demo/settings'), params()) + const { status, body } = await parseJsonResponse<{ data: { theme: string } }>(response) + + expect(status).toBe(200) + expect(body.data.theme).toBe('dark') + }) + + it('PATCH returns 401 when not authenticated', async () => { + requireAuthMock.mockResolvedValue({ + user: null, + supabase: mockSupabase, + error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }), + }) + + const response = await PATCH( + createMockRequest('/api/extensions/general/demo/settings', { method: 'PATCH', body: { b: 2 } }), + params(), + ) + expect(response.status).toBe(401) + }) + + it('PATCH returns 403 for a viewer', async () => { + requireWriteMock.mockResolvedValue({ + ok: false, + response: NextResponse.json({ error: 'Forbidden' }, { status: 403 }), + }) + + const response = await PATCH( + createMockRequest('/api/extensions/general/demo/settings', { method: 'PATCH', body: { b: 2 } }), + params(), + ) + expect(response.status).toBe(403) + }) + + it('PATCH merges and upserts settings (happy path)', async () => { + enqueue({ data: { value: { a: 1 } } }) // existing settings + enqueue({ data: { value: { a: 1, b: 2 } }, error: null }) // upsert + + const response = await PATCH( + createMockRequest('/api/extensions/general/demo/settings', { method: 'PATCH', body: { b: 2 } }), + params(), + ) + const { status, body } = await parseJsonResponse<{ data: { a: number; b: number } }>(response) + + expect(status).toBe(200) + expect(body.data).toEqual({ a: 1, b: 2 }) + }) +}) diff --git a/app/api/extensions/[sector]/[slug]/settings/route.ts b/app/api/extensions/[sector]/[slug]/settings/route.ts index 1d8d5c7c..fcf30146 100644 --- a/app/api/extensions/[sector]/[slug]/settings/route.ts +++ b/app/api/extensions/[sector]/[slug]/settings/route.ts @@ -1,21 +1,10 @@ -import { createClient } from '@/lib/supabase/server' import { NextResponse } from 'next/server' -import { requireCompanyId } from '@/lib/company/context' -import { requireWritePermission } from '@/lib/auth/require-write' +import { withRouteContext } from '@/lib/api/with-route-context' -export async function GET( - _request: Request, - { params }: { params: Promise<{ sector: string; slug: string }> } -) { +export const GET = withRouteContext<{ params: Promise<{ sector: string; slug: string }> }>( + 'extension.settings.get', + async (_request, { supabase, companyId }, { params }) => { const { sector, slug } = await params - const supabase = await createClient() - const { data: { user } } = await supabase.auth.getUser() - - if (!user) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const companyId = await requireCompanyId(supabase, user.id) const extensionId = `${sector}/${slug}` @@ -28,24 +17,13 @@ export async function GET( .single() return NextResponse.json({ data: data?.value ?? {} }) -} + }, +) -export async function PATCH( - request: Request, - { params }: { params: Promise<{ sector: string; slug: string }> } -) { +export const PATCH = withRouteContext<{ params: Promise<{ sector: string; slug: string }> }>( + 'extension.settings.update', + async (request, { supabase, user, companyId }, { params }) => { const { sector, slug } = await params - const supabase = await createClient() - const { data: { user } } = await supabase.auth.getUser() - - if (!user) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const writeCheck = await requireWritePermission(supabase, user.id) - if (!writeCheck.ok) return writeCheck.response - - const companyId = await requireCompanyId(supabase, user.id) const body = await request.json() const extensionId = `${sector}/${slug}` @@ -81,4 +59,6 @@ export async function PATCH( } return NextResponse.json({ data: data.value }) -} + }, + { requireWrite: true }, +) diff --git a/app/api/extensions/skatteverket/skattekonto/drift/route.ts b/app/api/extensions/skatteverket/skattekonto/drift/route.ts index 0659da3f..3d5ec084 100644 --- a/app/api/extensions/skatteverket/skattekonto/drift/route.ts +++ b/app/api/extensions/skatteverket/skattekonto/drift/route.ts @@ -1,15 +1,11 @@ -import { createClient } from '@/lib/supabase/server' import { NextResponse } from 'next/server' import { ensureInitialized } from '@/lib/init' -import { requireCompanyId } from '@/lib/company/context' +import { withRouteContext } from '@/lib/api/with-route-context' import { createExtensionContext } from '@/lib/extensions/context-factory' import { computeSkattekontoDrift } from '@/extensions/general/skatteverket/lib/skattekonto-drift' -import { createLogger } from '@/lib/logger' ensureInitialized() -const log = createLogger('skattekonto-drift-route') - /** * GET /api/extensions/skatteverket/skattekonto/drift * @@ -23,22 +19,15 @@ const log = createLogger('skattekonto-drift-route') * log line gives an auditable record without overrunning the 30-day event * log retention (SOC 2 CC8.1, ISO 27001 A.8.15). */ -export async function GET(_request: Request) { - const supabase = await createClient() - const { data: { user } } = await supabase.auth.getUser() +export const GET = withRouteContext( + 'skatteverket.skattekonto.drift', + async (_request, { supabase, user, companyId, log, requestId }) => { + const ctx = createExtensionContext(supabase, user.id, companyId, 'skatteverket', requestId) - if (!user) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const companyId = await requireCompanyId(supabase, user.id) - const ctx = createExtensionContext(supabase, user.id, companyId, 'skatteverket') - - const drift = await computeSkattekontoDrift(ctx) - log.info('skattekonto drift snapshot accessed', { - userId: user.id, - companyId, - hasDrift: drift !== null, - }) - return NextResponse.json({ data: drift }) -} + const drift = await computeSkattekontoDrift(ctx) + log.info('skattekonto drift snapshot accessed', { + hasDrift: drift !== null, + }) + return NextResponse.json({ data: drift }) + }, +) diff --git a/app/api/import/sie/[id]/__tests__/route.test.ts b/app/api/import/sie/[id]/__tests__/route.test.ts new file mode 100644 index 00000000..5ed39617 --- /dev/null +++ b/app/api/import/sie/[id]/__tests__/route.test.ts @@ -0,0 +1,116 @@ +/** + * Tests for GET/DELETE /api/import/sie/[id]. + * + * Exercises the route through the real withRouteContext wrapper, mocking only + * its auth/company/write dependencies and injecting a queued Supabase mock via + * requireAuth. Covers: 401, 403 viewer, the completed-import guard, and the + * happy-path delete of a failed import. + */ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { NextResponse } from 'next/server' +import { + createQueuedMockSupabase, + createMockRequest, + createMockRouteParams, + parseJsonResponse, +} from '@/tests/helpers' + +const { supabase, enqueue, reset } = createQueuedMockSupabase() + +const requireAuthMock = vi.fn() +vi.mock('@/lib/auth/require-auth', () => ({ + requireAuth: (...args: unknown[]) => requireAuthMock(...args), +})) + +vi.mock('@/lib/company/context', () => ({ + getActiveCompanyId: vi.fn().mockResolvedValue('company-1'), + requireCompanyId: vi.fn().mockResolvedValue('company-1'), +})) + +const requireWriteMock = vi.fn() +vi.mock('@/lib/auth/require-write', () => ({ + requireWritePermission: (...args: unknown[]) => requireWriteMock(...args), +})) + +import { GET, DELETE } from '../route' + +const routeParams = () => createMockRouteParams({ id: 'import-1' }) + +describe('GET/DELETE /api/import/sie/[id]', () => { + beforeEach(() => { + vi.clearAllMocks() + reset() + requireAuthMock.mockResolvedValue({ user: { id: 'user-1' }, supabase }) + requireWriteMock.mockResolvedValue({ ok: true }) + }) + + it('DELETE returns 401 when unauthenticated', async () => { + requireAuthMock.mockResolvedValue({ + user: null, + supabase, + error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }), + }) + + const response = await DELETE( + createMockRequest('/api/import/sie/import-1', { method: 'DELETE' }), + routeParams(), + ) + + expect(response.status).toBe(401) + }) + + it('DELETE returns 403 for a viewer', async () => { + requireWriteMock.mockResolvedValue({ + ok: false, + response: NextResponse.json({ error: 'Forbidden' }, { status: 403 }), + }) + + const response = await DELETE( + createMockRequest('/api/import/sie/import-1', { method: 'DELETE' }), + routeParams(), + ) + + expect(response.status).toBe(403) + }) + + it('DELETE refuses to delete a completed import (BFL retention)', async () => { + enqueue({ data: { status: 'completed' } }) + + const response = await DELETE( + createMockRequest('/api/import/sie/import-1', { method: 'DELETE' }), + routeParams(), + ) + const { status, body } = await parseJsonResponse<{ error: string }>(response) + + expect(status).toBe(403) + expect(body.error).toContain('BFL 7 kap') + }) + + it('DELETE removes a failed import', async () => { + // 1st DB hit: status lookup. 2nd DB hit: the delete itself. + enqueue({ data: { status: 'failed' } }) + enqueue({ data: null }) + + const response = await DELETE( + createMockRequest('/api/import/sie/import-1', { method: 'DELETE' }), + routeParams(), + ) + const { status, body } = await parseJsonResponse<{ success: boolean }>(response) + + expect(status).toBe(200) + expect(body.success).toBe(true) + }) + + it('GET returns the import record', async () => { + enqueue({ data: { id: 'import-1', status: 'pending' } }) + + const response = await GET( + createMockRequest('/api/import/sie/import-1'), + routeParams(), + ) + const { status, body } = await parseJsonResponse<{ data: { id: string } }>(response) + + expect(status).toBe(200) + expect(body.data.id).toBe('import-1') + }) +}) diff --git a/app/api/import/sie/[id]/route.ts b/app/api/import/sie/[id]/route.ts index dae70085..e00bd5e0 100644 --- a/app/api/import/sie/[id]/route.ts +++ b/app/api/import/sie/[id]/route.ts @@ -1,46 +1,33 @@ -import { createClient } from '@/lib/supabase/server' import { NextResponse } from 'next/server' -import { requireCompanyId } from '@/lib/company/context' -import { requireWritePermission } from '@/lib/auth/require-write' +import { withRouteContext } from '@/lib/api/with-route-context' /** * GET /api/import/sie/[id] * Get details of a specific SIE import */ -export async function GET( - request: Request, - { params }: { params: Promise<{ id: string }> } -) { - const supabase = await createClient() - const { id } = await params +export const GET = withRouteContext<{ params: Promise<{ id: string }> }>( + 'sie_import.get', + async (_request, { supabase, companyId }, { params }) => { + const { id } = await params - const { - data: { user }, - } = await supabase.auth.getUser() + const { data, error } = await supabase + .from('sie_imports') + .select('*') + .eq('id', id) + .eq('company_id', companyId) + .single() - if (!user) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } + if (error) { + return NextResponse.json({ error: error.message }, { status: 500 }) + } - const companyId = await requireCompanyId(supabase, user.id) + if (!data) { + return NextResponse.json({ error: 'Import not found' }, { status: 404 }) + } - const { data, error } = await supabase - .from('sie_imports') - .select('*') - .eq('id', id) - .eq('company_id', companyId) - .single() - - if (error) { - return NextResponse.json({ error: error.message }, { status: 500 }) - } - - if (!data) { - return NextResponse.json({ error: 'Import not found' }, { status: 404 }) - } - - return NextResponse.json({ data }) -} + return NextResponse.json({ data }) + }, +) /** * DELETE /api/import/sie/[id] @@ -51,53 +38,40 @@ export async function GET( * without reversing entries would leave orphaned bookkeeping data, and deleting * both is prohibited under BFL 7 kap (7-year retention). */ -export async function DELETE( - request: Request, - { params }: { params: Promise<{ id: string }> } -) { - const supabase = await createClient() - const { id } = await params +export const DELETE = withRouteContext<{ params: Promise<{ id: string }> }>( + 'sie_import.delete', + async (_request, { supabase, companyId }, { params }) => { + const { id } = await params - const { - data: { user }, - } = await supabase.auth.getUser() + // Check current status before deleting + const { data: importRecord } = await supabase + .from('sie_imports') + .select('status') + .eq('id', id) + .eq('company_id', companyId) + .single() - if (!user) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } + if (!importRecord) { + return NextResponse.json({ error: 'Import not found' }, { status: 404 }) + } - const writeCheck = await requireWritePermission(supabase, user.id) - if (!writeCheck.ok) return writeCheck.response + if (importRecord.status === 'completed') { + return NextResponse.json({ + error: 'Slutförd import kan inte raderas. Importerade verifikationer ingår i räkenskapsinformationen (BFL 7 kap).', + }, { status: 403 }) + } - const companyId = await requireCompanyId(supabase, user.id) + const { error } = await supabase + .from('sie_imports') + .delete() + .eq('id', id) + .eq('company_id', companyId) - // Check current status before deleting - const { data: importRecord } = await supabase - .from('sie_imports') - .select('status') - .eq('id', id) - .eq('company_id', companyId) - .single() + if (error) { + return NextResponse.json({ error: error.message }, { status: 500 }) + } - if (!importRecord) { - return NextResponse.json({ error: 'Import not found' }, { status: 404 }) - } - - if (importRecord.status === 'completed') { - return NextResponse.json({ - error: 'Slutförd import kan inte raderas. Importerade verifikationer ingår i räkenskapsinformationen (BFL 7 kap).', - }, { status: 403 }) - } - - const { error } = await supabase - .from('sie_imports') - .delete() - .eq('id', id) - .eq('company_id', companyId) - - if (error) { - return NextResponse.json({ error: error.message }, { status: 500 }) - } - - return NextResponse.json({ success: true }) -} + return NextResponse.json({ success: true }) + }, + { requireWrite: true }, +) diff --git a/app/api/import/sie/create-accounts/__tests__/route.test.ts b/app/api/import/sie/create-accounts/__tests__/route.test.ts new file mode 100644 index 00000000..a045f92b --- /dev/null +++ b/app/api/import/sie/create-accounts/__tests__/route.test.ts @@ -0,0 +1,107 @@ +/** + * Tests for POST /api/import/sie/create-accounts. + * + * Exercises the route through the real withRouteContext wrapper, mocking only + * its auth/company/write dependencies and injecting a queued Supabase mock via + * requireAuth. Covers: 401, 403 viewer, empty-body validation (400), and the + * happy-path batch upsert. + */ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { NextResponse } from 'next/server' +import { createQueuedMockSupabase, createMockRequest, parseJsonResponse } from '@/tests/helpers' + +const { supabase, enqueue, reset } = createQueuedMockSupabase() + +const requireAuthMock = vi.fn() +vi.mock('@/lib/auth/require-auth', () => ({ + requireAuth: (...args: unknown[]) => requireAuthMock(...args), +})) + +vi.mock('@/lib/company/context', () => ({ + getActiveCompanyId: vi.fn().mockResolvedValue('company-1'), + requireCompanyId: vi.fn().mockResolvedValue('company-1'), +})) + +const requireWriteMock = vi.fn() +vi.mock('@/lib/auth/require-write', () => ({ + requireWritePermission: (...args: unknown[]) => requireWriteMock(...args), +})) + +import { POST } from '../route' + +const emptyParams = { params: Promise.resolve({}) } + +describe('POST /api/import/sie/create-accounts', () => { + beforeEach(() => { + vi.clearAllMocks() + reset() + requireAuthMock.mockResolvedValue({ user: { id: 'user-1' }, supabase }) + requireWriteMock.mockResolvedValue({ ok: true }) + }) + + it('returns 401 when unauthenticated', async () => { + requireAuthMock.mockResolvedValue({ + user: null, + supabase, + error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }), + }) + + const request = createMockRequest('/api/import/sie/create-accounts', { + method: 'POST', + body: { accounts: [{ number: '1930', name: 'Företagskonto' }] }, + }) + + const response = await POST(request, emptyParams) + expect(response.status).toBe(401) + }) + + it('returns 403 for a viewer', async () => { + requireWriteMock.mockResolvedValue({ + ok: false, + response: NextResponse.json({ error: 'Forbidden' }, { status: 403 }), + }) + + const request = createMockRequest('/api/import/sie/create-accounts', { + method: 'POST', + body: { accounts: [{ number: '1930', name: 'Företagskonto' }] }, + }) + + const response = await POST(request, emptyParams) + expect(response.status).toBe(403) + }) + + it('rejects an empty account list with 400', async () => { + const request = createMockRequest('/api/import/sie/create-accounts', { + method: 'POST', + body: { accounts: [] }, + }) + + const response = await POST(request, emptyParams) + const { status, body } = await parseJsonResponse<{ error: string }>(response) + + expect(status).toBe(400) + expect(body.error).toBe('Inga konton att skapa.') + }) + + it('upserts the accounts and reports how many were created', async () => { + // Single batch upsert returning the inserted account numbers. + enqueue({ data: [{ account_number: '1930' }, { account_number: '3001' }] }) + + const request = createMockRequest('/api/import/sie/create-accounts', { + method: 'POST', + body: { + accounts: [ + { number: '1930', name: 'Företagskonto' }, + { number: '3001', name: 'Försäljning tjänster 25%' }, + ], + }, + }) + + const response = await POST(request, emptyParams) + const { status, body } = await parseJsonResponse<{ success: boolean; created: number }>(response) + + expect(status).toBe(200) + expect(body.success).toBe(true) + expect(body.created).toBe(2) + }) +}) diff --git a/app/api/import/sie/create-accounts/route.ts b/app/api/import/sie/create-accounts/route.ts index 86cc9464..de7f77ad 100644 --- a/app/api/import/sie/create-accounts/route.ts +++ b/app/api/import/sie/create-accounts/route.ts @@ -1,7 +1,5 @@ -import { createClient } from '@/lib/supabase/server' import { NextResponse } from 'next/server' -import { requireCompanyId } from '@/lib/company/context' -import { requireWritePermission } from '@/lib/auth/require-write' +import { withRouteContext } from '@/lib/api/with-route-context' import type { SIEAccount } from '@/lib/import/types' /** @@ -54,92 +52,81 @@ function getNormalBalance(accountType: string): 'debit' | 'credit' { * POST /api/import/sie/create-accounts * Create missing accounts from SIE file definitions */ -export async function POST(request: Request) { - const supabase = await createClient() +export const POST = withRouteContext( + 'sie_import.create_accounts', + async (request, { supabase, user, companyId }) => { + try { + const body = await request.json() + const accounts: SIEAccount[] = body.accounts - const { - data: { user }, - } = await supabase.auth.getUser() - - if (!user) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const writeCheck = await requireWritePermission(supabase, user.id) - if (!writeCheck.ok) return writeCheck.response - - const companyId = await requireCompanyId(supabase, user.id) - - try { - const body = await request.json() - const accounts: SIEAccount[] = body.accounts - - if (!accounts || !Array.isArray(accounts) || accounts.length === 0) { - return NextResponse.json({ error: 'Inga konton att skapa.' }, { status: 400 }) - } - - // Prepare accounts for upsert (idempotent, safe to retry) - const accountsToUpsert = accounts.map(account => { - const accountClass = parseInt(account.number.charAt(0), 10) || 1 - const accountGroup = account.number.substring(0, 2) - const accountType = getAccountType(account.number) - const normalBalance = getNormalBalance(accountType) - - return { - user_id: user.id, - company_id: companyId, - account_number: account.number, - account_name: account.name, - account_class: accountClass, - account_group: accountGroup, - account_type: accountType, - normal_balance: normalBalance, - plan_type: 'full_bas', - is_active: true, - is_system_account: false, // User-created via import - sort_order: parseInt(account.number, 10) || 0, - } - }) - - // Upsert in batches of 100 to avoid timeout - // ignoreDuplicates skips rows that already exist (no update) - const batchSize = 100 - let totalCreated = 0 - - for (let i = 0; i < accountsToUpsert.length; i += batchSize) { - const batch = accountsToUpsert.slice(i, i + batchSize) - - const { data: upserted, error } = await supabase - .from('chart_of_accounts') - .upsert(batch, { - onConflict: 'company_id,account_number', - ignoreDuplicates: true, - count: 'exact', - }) - .select('account_number') - - if (error) { - console.error('Error upserting accounts batch:', error) - return NextResponse.json({ - error: `Kunde inte skapa konton (batch ${Math.floor(i / batchSize) + 1}): ${error.message}. ${totalCreated} konton skapades innan felet.`, - created: totalCreated, - }, { status: 500 }) + if (!accounts || !Array.isArray(accounts) || accounts.length === 0) { + return NextResponse.json({ error: 'Inga konton att skapa.' }, { status: 400 }) } - totalCreated += upserted?.length ?? batch.length + // Prepare accounts for upsert (idempotent, safe to retry) + const accountsToUpsert = accounts.map(account => { + const accountClass = parseInt(account.number.charAt(0), 10) || 1 + const accountGroup = account.number.substring(0, 2) + const accountType = getAccountType(account.number) + const normalBalance = getNormalBalance(accountType) + + return { + user_id: user.id, + company_id: companyId, + account_number: account.number, + account_name: account.name, + account_class: accountClass, + account_group: accountGroup, + account_type: accountType, + normal_balance: normalBalance, + plan_type: 'full_bas', + is_active: true, + is_system_account: false, // User-created via import + sort_order: parseInt(account.number, 10) || 0, + } + }) + + // Upsert in batches of 100 to avoid timeout + // ignoreDuplicates skips rows that already exist (no update) + const batchSize = 100 + let totalCreated = 0 + + for (let i = 0; i < accountsToUpsert.length; i += batchSize) { + const batch = accountsToUpsert.slice(i, i + batchSize) + + const { data: upserted, error } = await supabase + .from('chart_of_accounts') + .upsert(batch, { + onConflict: 'company_id,account_number', + ignoreDuplicates: true, + count: 'exact', + }) + .select('account_number') + + if (error) { + console.error('Error upserting accounts batch:', error) + return NextResponse.json({ + error: `Kunde inte skapa konton (batch ${Math.floor(i / batchSize) + 1}): ${error.message}. ${totalCreated} konton skapades innan felet.`, + created: totalCreated, + }, { status: 500 }) + } + + totalCreated += upserted?.length ?? batch.length + } + + return NextResponse.json({ + success: true, + created: totalCreated, + message: `Created ${totalCreated} new accounts`, + }) + + } catch (error) { + console.error('Create accounts error:', error) + return NextResponse.json( + { error: `Kunde inte skapa konton: ${error instanceof Error ? error.message : 'Okänt fel'}. Försök igen.` }, + { status: 500 } + ) } - - return NextResponse.json({ - success: true, - created: totalCreated, - message: `Created ${totalCreated} new accounts`, - }) - - } catch (error) { - console.error('Create accounts error:', error) - return NextResponse.json( - { error: `Kunde inte skapa konton: ${error instanceof Error ? error.message : 'Okänt fel'}. Försök igen.` }, - { status: 500 } - ) - } -} + }, + { requireWrite: true }, +) diff --git a/app/api/import/sie/mappings/__tests__/route.test.ts b/app/api/import/sie/mappings/__tests__/route.test.ts new file mode 100644 index 00000000..b882f13e --- /dev/null +++ b/app/api/import/sie/mappings/__tests__/route.test.ts @@ -0,0 +1,169 @@ +/** + * Tests for GET/POST/PUT/DELETE /api/import/sie/mappings. + * + * Exercises the routes through the real withRouteContext wrapper, mocking only + * its auth/company/write dependencies and injecting a queued Supabase mock via + * requireAuth. Covers: 401, 403 viewer, validation (400), and happy paths. + */ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { NextResponse } from 'next/server' +import { createQueuedMockSupabase, createMockRequest, parseJsonResponse } from '@/tests/helpers' + +const { supabase, enqueue, reset } = createQueuedMockSupabase() + +const requireAuthMock = vi.fn() +vi.mock('@/lib/auth/require-auth', () => ({ + requireAuth: (...args: unknown[]) => requireAuthMock(...args), +})) + +vi.mock('@/lib/company/context', () => ({ + getActiveCompanyId: vi.fn().mockResolvedValue('company-1'), + requireCompanyId: vi.fn().mockResolvedValue('company-1'), +})) + +const requireWriteMock = vi.fn() +vi.mock('@/lib/auth/require-write', () => ({ + requireWritePermission: (...args: unknown[]) => requireWriteMock(...args), +})) + +const saveMappingsMock = vi.fn() +vi.mock('@/lib/import/sie-import', () => ({ + saveMappings: (...args: unknown[]) => saveMappingsMock(...args), +})) + +import { GET, POST, PUT, DELETE } from '../route' + +const emptyParams = { params: Promise.resolve({}) } + +describe('/api/import/sie/mappings', () => { + beforeEach(() => { + vi.clearAllMocks() + reset() + requireAuthMock.mockResolvedValue({ user: { id: 'user-1' }, supabase }) + requireWriteMock.mockResolvedValue({ ok: true }) + saveMappingsMock.mockResolvedValue(undefined) + }) + + it('POST returns 401 when unauthenticated', async () => { + requireAuthMock.mockResolvedValue({ + user: null, + supabase, + error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }), + }) + + const request = createMockRequest('/api/import/sie/mappings', { + method: 'POST', + body: { mappings: [] }, + }) + + const response = await POST(request, emptyParams) + expect(response.status).toBe(401) + }) + + it('POST returns 403 for a viewer', async () => { + requireWriteMock.mockResolvedValue({ + ok: false, + response: NextResponse.json({ error: 'Forbidden' }, { status: 403 }), + }) + + const request = createMockRequest('/api/import/sie/mappings', { + method: 'POST', + body: { mappings: [] }, + }) + + const response = await POST(request, emptyParams) + expect(response.status).toBe(403) + }) + + it('POST rejects a non-array mappings payload with 400', async () => { + const request = createMockRequest('/api/import/sie/mappings', { + method: 'POST', + body: { mappings: 'not-an-array' }, + }) + + const response = await POST(request, emptyParams) + const { status, body } = await parseJsonResponse<{ error: string }>(response) + + expect(status).toBe(400) + expect(body.error).toBe('Invalid mappings data') + }) + + it('POST saves the mappings', async () => { + const mappings = [{ sourceAccount: '1920', targetAccount: '1930' }] + const request = createMockRequest('/api/import/sie/mappings', { + method: 'POST', + body: { mappings }, + }) + + const response = await POST(request, emptyParams) + const { status, body } = await parseJsonResponse<{ success: boolean }>(response) + + expect(status).toBe(200) + expect(body.success).toBe(true) + expect(saveMappingsMock).toHaveBeenCalledWith(supabase, 'user-1', mappings) + }) + + it('GET lists the saved mappings', async () => { + enqueue({ data: [{ source_account: '1920', target_account: '1930' }] }) + + const response = await GET(createMockRequest('/api/import/sie/mappings'), emptyParams) + const { status, body } = await parseJsonResponse<{ data: unknown[] }>(response) + + expect(status).toBe(200) + expect(body.data).toHaveLength(1) + }) + + it('PUT rejects a body missing targetAccount with 400', async () => { + const request = createMockRequest('/api/import/sie/mappings', { + method: 'PUT', + body: { sourceAccount: '1920' }, + }) + + const response = await PUT(request, emptyParams) + const { status } = await parseJsonResponse(response) + + expect(status).toBe(400) + }) + + it('PUT upserts a single mapping', async () => { + enqueue({ data: { source_account: '1920', target_account: '1930' } }) + + const request = createMockRequest('/api/import/sie/mappings', { + method: 'PUT', + body: { sourceAccount: '1920', targetAccount: '1930' }, + }) + + const response = await PUT(request, emptyParams) + const { status, body } = await parseJsonResponse<{ data: { target_account: string } }>(response) + + expect(status).toBe(200) + expect(body.data.target_account).toBe('1930') + }) + + it('DELETE returns 403 for a viewer', async () => { + requireWriteMock.mockResolvedValue({ + ok: false, + response: NextResponse.json({ error: 'Forbidden' }, { status: 403 }), + }) + + const request = createMockRequest('/api/import/sie/mappings', { method: 'DELETE' }) + + const response = await DELETE(request, emptyParams) + expect(response.status).toBe(403) + }) + + it('DELETE removes a specific mapping', async () => { + enqueue({ data: null }) + + const request = createMockRequest('/api/import/sie/mappings', { + method: 'DELETE', + searchParams: { sourceAccount: '1920' }, + }) + + const response = await DELETE(request, emptyParams) + const { status, body } = await parseJsonResponse<{ success: boolean }>(response) + + expect(status).toBe(200) + expect(body.success).toBe(true) + }) +}) diff --git a/app/api/import/sie/mappings/route.ts b/app/api/import/sie/mappings/route.ts index 3ffb70a0..4a38401a 100644 --- a/app/api/import/sie/mappings/route.ts +++ b/app/api/import/sie/mappings/route.ts @@ -1,175 +1,131 @@ -import { createClient } from '@/lib/supabase/server' import { NextResponse } from 'next/server' +import { withRouteContext } from '@/lib/api/with-route-context' import { saveMappings } from '@/lib/import/sie-import' -import { requireCompanyId } from '@/lib/company/context' -import { requireWritePermission } from '@/lib/auth/require-write' import type { AccountMapping } from '@/lib/import/types' /** * GET /api/import/sie/mappings * Get all saved account mappings for the user */ -export async function GET() { - const supabase = await createClient() +export const GET = withRouteContext( + 'sie_import.mappings.list', + async (_request, { supabase, companyId }) => { + const { data, error } = await supabase + .from('sie_account_mappings') + .select('*') + .eq('company_id', companyId) + .order('source_account') - const { - data: { user }, - } = await supabase.auth.getUser() + if (error) { + return NextResponse.json({ error: error.message }, { status: 500 }) + } - if (!user) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const companyId = await requireCompanyId(supabase, user.id) - - const { data, error } = await supabase - .from('sie_account_mappings') - .select('*') - .eq('company_id', companyId) - .order('source_account') - - if (error) { - return NextResponse.json({ error: error.message }, { status: 500 }) - } - - return NextResponse.json({ data }) -} + return NextResponse.json({ data }) + }, +) /** * POST /api/import/sie/mappings * Save account mappings (bulk upsert) */ -export async function POST(request: Request) { - const supabase = await createClient() +export const POST = withRouteContext( + 'sie_import.mappings.save', + async (request, { supabase, user }) => { + const body = await request.json() + const mappings: AccountMapping[] = body.mappings - const { - data: { user }, - } = await supabase.auth.getUser() + if (!mappings || !Array.isArray(mappings)) { + return NextResponse.json({ error: 'Invalid mappings data' }, { status: 400 }) + } - if (!user) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const writeCheck = await requireWritePermission(supabase, user.id) - if (!writeCheck.ok) return writeCheck.response - - const companyId = await requireCompanyId(supabase, user.id) - - const body = await request.json() - const mappings: AccountMapping[] = body.mappings - - if (!mappings || !Array.isArray(mappings)) { - return NextResponse.json({ error: 'Invalid mappings data' }, { status: 400 }) - } - - try { - await saveMappings(supabase, user.id, mappings) - return NextResponse.json({ success: true }) - } catch (error) { - return NextResponse.json( - { error: error instanceof Error ? error.message : 'Failed to save mappings' }, - { status: 500 } - ) - } -} + try { + await saveMappings(supabase, user.id, mappings) + return NextResponse.json({ success: true }) + } catch (error) { + return NextResponse.json( + { error: error instanceof Error ? error.message : 'Failed to save mappings' }, + { status: 500 } + ) + } + }, + { requireWrite: true }, +) /** * PUT /api/import/sie/mappings * Update a single mapping */ -export async function PUT(request: Request) { - const supabase = await createClient() +export const PUT = withRouteContext( + 'sie_import.mappings.update', + async (request, { supabase, user, companyId }) => { + const body = await request.json() + const { sourceAccount, targetAccount } = body - const { - data: { user }, - } = await supabase.auth.getUser() + if (!sourceAccount || !targetAccount) { + return NextResponse.json( + { error: 'sourceAccount and targetAccount are required' }, + { status: 400 } + ) + } - if (!user) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } + const { data, error } = await supabase + .from('sie_account_mappings') + .upsert({ + user_id: user.id, + company_id: companyId, + source_account: sourceAccount, + target_account: targetAccount, + confidence: 1.0, + match_type: 'manual', + }, { + onConflict: 'user_id,source_account', + }) + .select() + .single() - const writeCheck = await requireWritePermission(supabase, user.id) - if (!writeCheck.ok) return writeCheck.response + if (error) { + return NextResponse.json({ error: error.message }, { status: 500 }) + } - const companyId = await requireCompanyId(supabase, user.id) - - const body = await request.json() - const { sourceAccount, targetAccount } = body - - if (!sourceAccount || !targetAccount) { - return NextResponse.json( - { error: 'sourceAccount and targetAccount are required' }, - { status: 400 } - ) - } - - const { data, error } = await supabase - .from('sie_account_mappings') - .upsert({ - user_id: user.id, - company_id: companyId, - source_account: sourceAccount, - target_account: targetAccount, - confidence: 1.0, - match_type: 'manual', - }, { - onConflict: 'user_id,source_account', - }) - .select() - .single() - - if (error) { - return NextResponse.json({ error: error.message }, { status: 500 }) - } - - return NextResponse.json({ data }) -} + return NextResponse.json({ data }) + }, + { requireWrite: true }, +) /** * DELETE /api/import/sie/mappings * Delete a specific mapping or all mappings */ -export async function DELETE(request: Request) { - const supabase = await createClient() +export const DELETE = withRouteContext( + 'sie_import.mappings.delete', + async (request, { supabase, companyId }) => { + const { searchParams } = new URL(request.url) + const sourceAccount = searchParams.get('sourceAccount') - const { - data: { user }, - } = await supabase.auth.getUser() + if (sourceAccount) { + // Delete specific mapping + const { error } = await supabase + .from('sie_account_mappings') + .delete() + .eq('company_id', companyId) + .eq('source_account', sourceAccount) - if (!user) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } + if (error) { + return NextResponse.json({ error: error.message }, { status: 500 }) + } + } else { + // Delete all mappings + const { error } = await supabase + .from('sie_account_mappings') + .delete() + .eq('company_id', companyId) - const writeCheck = await requireWritePermission(supabase, user.id) - if (!writeCheck.ok) return writeCheck.response - - const companyId = await requireCompanyId(supabase, user.id) - - const { searchParams } = new URL(request.url) - const sourceAccount = searchParams.get('sourceAccount') - - if (sourceAccount) { - // Delete specific mapping - const { error } = await supabase - .from('sie_account_mappings') - .delete() - .eq('company_id', companyId) - .eq('source_account', sourceAccount) - - if (error) { - return NextResponse.json({ error: error.message }, { status: 500 }) + if (error) { + return NextResponse.json({ error: error.message }, { status: 500 }) + } } - } else { - // Delete all mappings - const { error } = await supabase - .from('sie_account_mappings') - .delete() - .eq('company_id', companyId) - if (error) { - return NextResponse.json({ error: error.message }, { status: 500 }) - } - } - - return NextResponse.json({ success: true }) -} + return NextResponse.json({ success: true }) + }, + { requireWrite: true }, +) diff --git a/app/api/import/sie/route.ts b/app/api/import/sie/route.ts index 52cc4beb..3fe1d965 100644 --- a/app/api/import/sie/route.ts +++ b/app/api/import/sie/route.ts @@ -1,51 +1,41 @@ -import { createClient } from '@/lib/supabase/server' import { NextResponse } from 'next/server' -import { requireCompanyId } from '@/lib/company/context' +import { withRouteContext } from '@/lib/api/with-route-context' /** * GET /api/import/sie * List all SIE imports for the user */ -export async function GET(request: Request) { - const supabase = await createClient() +export const GET = withRouteContext( + 'sie_import.list', + async (request, { supabase, companyId }) => { + // Parse query params + const { searchParams } = new URL(request.url) + const limit = parseInt(searchParams.get('limit') || '20', 10) + const offset = parseInt(searchParams.get('offset') || '0', 10) + const status = searchParams.get('status') - const { - data: { user }, - } = await supabase.auth.getUser() + let query = supabase + .from('sie_imports') + .select('*', { count: 'exact' }) + .eq('company_id', companyId) + .order('created_at', { ascending: false }) + .range(offset, offset + limit - 1) - if (!user) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } + if (status) { + query = query.eq('status', status) + } - const companyId = await requireCompanyId(supabase, user.id) + const { data, error, count } = await query - // Parse query params - const { searchParams } = new URL(request.url) - const limit = parseInt(searchParams.get('limit') || '20', 10) - const offset = parseInt(searchParams.get('offset') || '0', 10) - const status = searchParams.get('status') + if (error) { + return NextResponse.json({ error: error.message }, { status: 500 }) + } - let query = supabase - .from('sie_imports') - .select('*', { count: 'exact' }) - .eq('company_id', companyId) - .order('created_at', { ascending: false }) - .range(offset, offset + limit - 1) - - if (status) { - query = query.eq('status', status) - } - - const { data, error, count } = await query - - if (error) { - return NextResponse.json({ error: error.message }, { status: 500 }) - } - - return NextResponse.json({ - data, - count, - limit, - offset, - }) -} + return NextResponse.json({ + data, + count, + limit, + offset, + }) + }, +) diff --git a/app/api/invoices/[id]/convert/__tests__/route.test.ts b/app/api/invoices/[id]/convert/__tests__/route.test.ts index 40f202d4..29bed643 100644 --- a/app/api/invoices/[id]/convert/__tests__/route.test.ts +++ b/app/api/invoices/[id]/convert/__tests__/route.test.ts @@ -1,4 +1,5 @@ import { describe, it, expect, vi, beforeEach } from 'vitest' +import { NextResponse } from 'next/server' import { createMockRequest, createMockRouteParams, @@ -8,8 +9,10 @@ import { import { eventBus } from '@/lib/events' const { supabase: mockSupabase, enqueue, reset } = createQueuedMockSupabase() -vi.mock('@/lib/supabase/server', () => ({ - createClient: () => Promise.resolve(mockSupabase), + +const requireAuthMock = vi.fn() +vi.mock('@/lib/auth/require-auth', () => ({ + requireAuth: (...args: unknown[]) => requireAuthMock(...args), })) vi.mock('@/lib/init', () => ({ @@ -17,6 +20,7 @@ vi.mock('@/lib/init', () => ({ })) vi.mock('@/lib/company/context', () => ({ + getActiveCompanyId: vi.fn().mockResolvedValue('company-1'), requireCompanyId: vi.fn().mockResolvedValue('company-1'), })) @@ -67,11 +71,15 @@ describe('POST /api/invoices/[id]/convert', () => { vi.clearAllMocks() reset() eventBus.clear() - mockSupabase.auth.getUser.mockResolvedValue({ data: { user: mockUser } }) + requireAuthMock.mockResolvedValue({ user: mockUser, supabase: mockSupabase, error: null }) }) it('returns 401 when not authenticated', async () => { - mockSupabase.auth.getUser.mockResolvedValue({ data: { user: null } }) + requireAuthMock.mockResolvedValue({ + user: null, + supabase: mockSupabase, + error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }), + }) const response = await POST( createMockRequest('/api/invoices/pf-1/convert', { method: 'POST' }), diff --git a/app/api/invoices/[id]/convert/route.ts b/app/api/invoices/[id]/convert/route.ts index 83367a4a..eff07f0e 100644 --- a/app/api/invoices/[id]/convert/route.ts +++ b/app/api/invoices/[id]/convert/route.ts @@ -1,9 +1,7 @@ -import { createClient } from '@/lib/supabase/server' import { NextResponse } from 'next/server' import { eventBus } from '@/lib/events' import { ensureInitialized } from '@/lib/init' -import { requireCompanyId } from '@/lib/company/context' -import { requireWritePermission } from '@/lib/auth/require-write' +import { withRouteContext } from '@/lib/api/with-route-context' import { ensureInvoiceNumber } from '@/lib/invoices/ensure-invoice-number' import type { Invoice } from '@/types' @@ -20,23 +18,10 @@ ensureInitialized() * cancelled, so a partial failure in any earlier step rolls back the orphan * row without leaking a number. */ -export async function POST( - request: Request, - { params }: { params: Promise<{ id: string }> } -) { +export const POST = withRouteContext<{ params: Promise<{ id: string }> }>( + 'invoice.convert', + async (request, { supabase, user, companyId }, { params }) => { const { id } = await params - const supabase = await createClient() - - const { data: { user } } = await supabase.auth.getUser() - - if (!user) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const writeCheck = await requireWritePermission(supabase, user.id) - if (!writeCheck.ok) return writeCheck.response - - const companyId = await requireCompanyId(supabase, user.id) const { data: proforma, error: proformaError } = await supabase .from('invoices') @@ -168,4 +153,6 @@ export async function POST( } return NextResponse.json({ data: completeInvoice }) -} + }, + { requireWrite: true }, +) diff --git a/app/api/invoices/[id]/mark-sent/__tests__/route.test.ts b/app/api/invoices/[id]/mark-sent/__tests__/route.test.ts index e5eaf702..09000e56 100644 --- a/app/api/invoices/[id]/mark-sent/__tests__/route.test.ts +++ b/app/api/invoices/[id]/mark-sent/__tests__/route.test.ts @@ -10,8 +10,10 @@ import { } from '@/tests/helpers' const { supabase: mockSupabase, enqueue, reset } = createQueuedMockSupabase() -vi.mock('@/lib/supabase/server', () => ({ - createClient: () => Promise.resolve(mockSupabase), + +const requireAuthMock = vi.fn() +vi.mock('@/lib/auth/require-auth', () => ({ + requireAuth: (...args: unknown[]) => requireAuthMock(...args), })) vi.mock('@/lib/init', () => ({ @@ -89,7 +91,7 @@ describe('POST /api/invoices/[id]/mark-sent: PDF archival', () => { beforeEach(() => { vi.clearAllMocks() reset() - mockSupabase.auth.getUser.mockResolvedValue({ data: { user: mockUser } }) + requireAuthMock.mockResolvedValue({ user: mockUser, supabase: mockSupabase, error: null }) mockRenderToBuffer.mockResolvedValue(Buffer.from('fake-pdf')) mockUploadDocument.mockResolvedValue({ id: 'doc-1' }) }) diff --git a/app/api/invoices/[id]/mark-sent/route.ts b/app/api/invoices/[id]/mark-sent/route.ts index 2d8b06c2..b01b7a0c 100644 --- a/app/api/invoices/[id]/mark-sent/route.ts +++ b/app/api/invoices/[id]/mark-sent/route.ts @@ -1,16 +1,13 @@ -import { createClient } from '@/lib/supabase/server' import { NextResponse } from 'next/server' import { renderToBuffer } from '@react-pdf/renderer' import { createInvoiceJournalEntry } from '@/lib/bookkeeping/invoice-entries' import { createSchedulesForCustomerInvoice } from '@/lib/bookkeeping/accruals/from-invoices' import { ensureInvoiceNumber } from '@/lib/invoices/ensure-invoice-number' import { ensureInitialized } from '@/lib/init' +import { withRouteContext } from '@/lib/api/with-route-context' import { InvoicePDF } from '@/lib/invoices/pdf-template' import { prepareInvoicePdfRender, buildSwishQrDataUrl } from '@/lib/invoices/pdf-render-helpers' import { uploadDocument } from '@/lib/core/documents/document-service' -import { requireCompanyId } from '@/lib/company/context' -import { requireWritePermission } from '@/lib/auth/require-write' -import { createLogger } from '@/lib/logger' import type { CompanySettings, Customer, EntityType, Invoice, InvoiceItem } from '@/types' ensureInitialized() @@ -22,24 +19,10 @@ ensureInitialized() * Under faktureringsmetoden (accrual): creates the journal entry (Debit 1510, Credit 30xx/26xx). * Under kontantmetoden (cash): no journal entry; booking happens at payment. */ -export async function POST( - request: Request, - { params }: { params: Promise<{ id: string }> } -) { +export const POST = withRouteContext<{ params: Promise<{ id: string }> }>( + 'invoice.mark_sent', + async (request, { supabase, user, companyId, log }, { params }) => { const { id } = await params - const supabase = await createClient() - - const { data: { user } } = await supabase.auth.getUser() - - if (!user) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const writeCheck = await requireWritePermission(supabase, user.id) - if (!writeCheck.ok) return writeCheck.response - - const companyId = await requireCompanyId(supabase, user.id) - const log = createLogger('invoice.mark-sent', { companyId, invoiceId: id }) // Fetch invoice const { data: invoice, error: invoiceError } = await supabase @@ -208,4 +191,6 @@ export async function POST( status: 'sent', journal_entry_id: journalEntryId, }) -} + }, + { requireWrite: true }, +) diff --git a/app/api/invoices/[id]/pdf/route.ts b/app/api/invoices/[id]/pdf/route.ts index d397efc9..e4f7f519 100644 --- a/app/api/invoices/[id]/pdf/route.ts +++ b/app/api/invoices/[id]/pdf/route.ts @@ -1,25 +1,14 @@ -import { createClient } from '@/lib/supabase/server' import { NextResponse } from 'next/server' import { renderToBuffer } from '@react-pdf/renderer' +import { withRouteContext } from '@/lib/api/with-route-context' import { InvoicePDF } from '@/lib/invoices/pdf-template' import { prepareInvoicePdfRender, buildSwishQrDataUrl } from '@/lib/invoices/pdf-render-helpers' -import { requireCompanyId } from '@/lib/company/context' import type { Invoice, InvoiceItem, Customer, CompanySettings } from '@/types' -export async function GET( - request: Request, - { params }: { params: Promise<{ id: string }> } -) { +export const GET = withRouteContext<{ params: Promise<{ id: string }> }>( + 'invoice.pdf', + async (request, { supabase, companyId }, { params }) => { const { id } = await params - const supabase = await createClient() - - const { data: { user } } = await supabase.auth.getUser() - - if (!user) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const companyId = await requireCompanyId(supabase, user.id) // Fetch invoice with customer and items const { data: invoice, error: invoiceError } = await supabase @@ -108,4 +97,5 @@ export async function GET( { status: 500 } ) } -} + }, +) diff --git a/app/api/invoices/preview-pdf/route.ts b/app/api/invoices/preview-pdf/route.ts index 06e04e71..522c3580 100644 --- a/app/api/invoices/preview-pdf/route.ts +++ b/app/api/invoices/preview-pdf/route.ts @@ -1,10 +1,9 @@ -import { createClient } from '@/lib/supabase/server' import { NextResponse } from 'next/server' import { renderToBuffer } from '@react-pdf/renderer' +import { withRouteContext } from '@/lib/api/with-route-context' import { InvoicePDF } from '@/lib/invoices/pdf-template' import { prepareInvoicePdfRender, buildSwishQrDataUrl } from '@/lib/invoices/pdf-render-helpers' import { getVatRules } from '@/lib/invoices/vat-rules' -import { requireCompanyId } from '@/lib/company/context' import type { Invoice, InvoiceItem, Customer, CompanySettings, InvoiceDocumentType } from '@/types' /** @@ -13,17 +12,7 @@ import type { Invoice, InvoiceItem, Customer, CompanySettings, InvoiceDocumentTy * Generates a preview PDF from form data without creating an invoice. * Returns the PDF as an inline blob for display in a new browser tab. */ -export async function POST(request: Request) { - const supabase = await createClient() - - const { data: { user } } = await supabase.auth.getUser() - - if (!user) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const companyId = await requireCompanyId(supabase, user.id) - +export const POST = withRouteContext('invoice.preview_pdf', async (request, { supabase, user, companyId }) => { const body = await request.json() const { customer_id, invoice_date, due_date, delivery_date, currency, items, your_reference, our_reference, notes, document_type, invoice_number } = body @@ -206,4 +195,4 @@ export async function POST(request: Request) { { status: 500 } ) } -} +}) diff --git a/app/api/kpi/preferences/__tests__/route.test.ts b/app/api/kpi/preferences/__tests__/route.test.ts new file mode 100644 index 00000000..c3428e4c --- /dev/null +++ b/app/api/kpi/preferences/__tests__/route.test.ts @@ -0,0 +1,104 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { NextResponse } from 'next/server' +import { createMockRequest, parseJsonResponse, createQueuedMockSupabase } from '@/tests/helpers' + +const { supabase: mockSupabase, enqueue, reset } = createQueuedMockSupabase() + +const requireAuthMock = vi.fn() +vi.mock('@/lib/auth/require-auth', () => ({ + requireAuth: (...args: unknown[]) => requireAuthMock(...args), +})) + +vi.mock('@/lib/company/context', () => ({ + requireCompanyId: vi.fn().mockResolvedValue('company-1'), + getActiveCompanyId: vi.fn().mockResolvedValue('company-1'), +})) + +vi.mock('@/lib/auth/require-write', () => ({ + requireWritePermission: vi.fn().mockResolvedValue({ ok: true }), +})) + +// mergeWithDefaults is exercised for real; it just fills defaults on the input. +import { GET, PUT } from '../route' +import { requireWritePermission } from '@/lib/auth/require-write' + +const mockUser = { id: 'user-1', email: 'test@test.se' } + +beforeEach(() => { + vi.clearAllMocks() + reset() + requireAuthMock.mockResolvedValue({ user: mockUser, supabase: mockSupabase, error: null }) + vi.mocked(requireWritePermission).mockResolvedValue({ ok: true }) +}) + +describe('GET /api/kpi/preferences', () => { + it('returns 401 when not authenticated', async () => { + requireAuthMock.mockResolvedValue({ + user: null, + supabase: mockSupabase, + error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }), + }) + const res = await GET(createMockRequest('/api/kpi/preferences'), { params: Promise.resolve({}) }) + const { status } = await parseJsonResponse(res) + expect(status).toBe(401) + }) + + it('returns merged preferences', async () => { + enqueue({ data: { value: {} } }) + const res = await GET(createMockRequest('/api/kpi/preferences'), { params: Promise.resolve({}) }) + const { status, body } = await parseJsonResponse<{ data: unknown }>(res) + expect(status).toBe(200) + expect(body.data).toBeDefined() + }) +}) + +describe('PUT /api/kpi/preferences', () => { + it('returns 401 when not authenticated', async () => { + requireAuthMock.mockResolvedValue({ + user: null, + supabase: mockSupabase, + error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }), + }) + const req = createMockRequest('/api/kpi/preferences', { method: 'PUT', body: {} }) + const res = await PUT(req, { params: Promise.resolve({}) }) + const { status } = await parseJsonResponse(res) + expect(status).toBe(401) + }) + + it('returns 403 when caller has read-only role', async () => { + vi.mocked(requireWritePermission).mockResolvedValue({ + ok: false, + response: NextResponse.json( + { error: 'Du har endast läsbehörighet i detta företag.' }, + { status: 403 }, + ), + }) + const req = createMockRequest('/api/kpi/preferences', { method: 'PUT', body: {} }) + const res = await PUT(req, { params: Promise.resolve({}) }) + const { status } = await parseJsonResponse(res) + expect(status).toBe(403) + }) + + it('rejects an account override that is not a 4-digit string', async () => { + const req = createMockRequest('/api/kpi/preferences', { + method: 'PUT', + body: { accountOverrides: { some_kpi: ['abc'] } }, + }) + const res = await PUT(req, { params: Promise.resolve({}) }) + const { status, body } = await parseJsonResponse<{ error: string }>(res) + expect(status).toBe(400) + expect(body.error).toContain('4 digits') + }) + + it('upserts and returns the stored value on the happy path', async () => { + enqueue({ data: { value: { accountOverrides: { some_kpi: ['3001'] } } } }) + const req = createMockRequest('/api/kpi/preferences', { + method: 'PUT', + body: { accountOverrides: { some_kpi: ['3001'] } }, + }) + const res = await PUT(req, { params: Promise.resolve({}) }) + const { status, body } = await parseJsonResponse<{ data: { accountOverrides: Record } }>(res) + expect(status).toBe(200) + expect(body.data.accountOverrides.some_kpi).toEqual(['3001']) + }) +}) diff --git a/app/api/kpi/preferences/route.ts b/app/api/kpi/preferences/route.ts index 75b50f80..5936bf6f 100644 --- a/app/api/kpi/preferences/route.ts +++ b/app/api/kpi/preferences/route.ts @@ -1,20 +1,12 @@ -import { createClient } from '@/lib/supabase/server' import { NextResponse } from 'next/server' +import { withRouteContext } from '@/lib/api/with-route-context' import { mergeWithDefaults } from '@/lib/reports/kpi-definitions' -import { requireCompanyId } from '@/lib/company/context' -import { requireWritePermission } from '@/lib/auth/require-write' import type { KPIPreferences } from '@/types' const EXTENSION_ID = 'core/kpi' const KEY = 'preferences' -export async function GET() { - const supabase = await createClient() - const { data: { user } } = await supabase.auth.getUser() - if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - - const companyId = await requireCompanyId(supabase, user.id) - +export const GET = withRouteContext('kpi.preferences.get', async (_request, { supabase, companyId }) => { const { data } = await supabase .from('extension_data') .select('value') @@ -25,67 +17,62 @@ export async function GET() { const preferences = mergeWithDefaults((data?.value as Partial) ?? {}) return NextResponse.json({ data: preferences }) -} +}) -export async function PUT(request: Request) { - const supabase = await createClient() - const { data: { user } } = await supabase.auth.getUser() - if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) +export const PUT = withRouteContext( + 'kpi.preferences.update', + async (request, { supabase, companyId, user }) => { + let body: unknown + try { + body = await request.json() + } catch { + return NextResponse.json({ error: 'Invalid JSON' }, { status: 400 }) + } - const writeCheck = await requireWritePermission(supabase, user.id) - if (!writeCheck.ok) return writeCheck.response + const prefs = body as Partial - const companyId = await requireCompanyId(supabase, user.id) - - let body: unknown - try { - body = await request.json() - } catch { - return NextResponse.json({ error: 'Invalid JSON' }, { status: 400 }) - } - - const prefs = body as Partial - - // Validate account overrides: must be 4-digit numeric strings - if (prefs.accountOverrides) { - for (const [kpiId, accounts] of Object.entries(prefs.accountOverrides)) { - if (!Array.isArray(accounts)) { - return NextResponse.json( - { error: `accountOverrides.${kpiId} must be an array` }, - { status: 400 } - ) - } - for (const acc of accounts) { - if (typeof acc !== 'string' || !/^\d{4}$/.test(acc)) { + // Validate account overrides: must be 4-digit numeric strings + if (prefs.accountOverrides) { + for (const [kpiId, accounts] of Object.entries(prefs.accountOverrides)) { + if (!Array.isArray(accounts)) { return NextResponse.json( - { error: `Invalid account number "${acc}" in ${kpiId}: must be 4 digits` }, + { error: `accountOverrides.${kpiId} must be an array` }, { status: 400 } ) } + for (const acc of accounts) { + if (typeof acc !== 'string' || !/^\d{4}$/.test(acc)) { + return NextResponse.json( + { error: `Invalid account number "${acc}" in ${kpiId}: must be 4 digits` }, + { status: 400 } + ) + } + } } } - } - const merged = mergeWithDefaults(prefs) + const merged = mergeWithDefaults(prefs) - const { data, error } = await supabase - .from('extension_data') - .upsert( - { - user_id: user.id, - company_id: companyId, - extension_id: EXTENSION_ID, - key: KEY, - value: merged, - }, - { onConflict: 'user_id,extension_id,key' } - ) - .select() - .single() + const { data, error } = await supabase + .from('extension_data') + .upsert( + { + user_id: user.id, + company_id: companyId, + extension_id: EXTENSION_ID, + key: KEY, + value: merged, + }, + { onConflict: 'user_id,extension_id,key' } + ) + .select() + .single() - if (error) { - return NextResponse.json({ error: error.message }, { status: 500 }) - } + if (error) { + return NextResponse.json({ error: error.message }, { status: 500 }) + } - return NextResponse.json({ data: data.value }) -} + return NextResponse.json({ data: data.value }) + }, + { requireWrite: true } +) diff --git a/app/api/mcp-oauth/authorize/__tests__/route.test.ts b/app/api/mcp-oauth/authorize/__tests__/route.test.ts index 69270598..051c57d4 100644 --- a/app/api/mcp-oauth/authorize/__tests__/route.test.ts +++ b/app/api/mcp-oauth/authorize/__tests__/route.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest' +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' const mocks = vi.hoisted(() => ({ createClient: vi.fn(), @@ -23,7 +23,7 @@ vi.mock('@/lib/branding/service', () => ({ getBranding: () => mocks.getBranding(), })) -import { GET } from '../route' +import { GET, POST } from '../route' function buildAuthorizeUrl(params: Record): string { const url = new URL('http://localhost/api/mcp-oauth/authorize') @@ -31,9 +31,18 @@ function buildAuthorizeUrl(params: Record): string { return url.toString() } -function buildSupabase(user: { id: string } | null, companyName = 'Test AB') { +function buildSupabase( + user: { id: string } | null, + companyName = 'Test AB', + aal: { currentLevel: string; nextLevel: string } = { currentLevel: 'aal2', nextLevel: 'aal2' }, +) { return { - auth: { getUser: vi.fn().mockResolvedValue({ data: { user }, error: null }) }, + auth: { + getUser: vi.fn().mockResolvedValue({ data: { user }, error: null }), + mfa: { + getAuthenticatorAssuranceLevel: vi.fn().mockResolvedValue({ data: aal, error: null }), + }, + }, from: vi.fn().mockReturnValue({ select: vi.fn().mockReturnValue({ eq: vi.fn().mockReturnValue({ @@ -187,3 +196,92 @@ describe('GET /api/mcp-oauth/authorize: CSP', () => { // of truth for which origins can land at this endpoint. }) }) + +describe('MFA step-up on /api/mcp-oauth/authorize', () => { + // Consent here ultimately mints a long-lived API key that bypasses MFA on + // every subsequent request, so an AAL1 (password-only) session must never + // reach the consent page or approve it. The middleware MFA gate exempts + // /api/mcp-oauth/*, making the route responsible for its own step-up. + const authorizeParams = { + response_type: 'code', + redirect_uri: 'https://claude.ai/api/mcp/auth_callback', + code_challenge: 'abc', + code_challenge_method: 'S256', + scope: 'mcp', + state: 'xyz', + } + + beforeEach(() => { + vi.clearAllMocks() + process.env.SUPABASE_SERVICE_ROLE_KEY = 'test-service-key' + vi.stubEnv('NEXT_PUBLIC_REQUIRE_MFA', 'true') + vi.stubEnv('NEXT_PUBLIC_SELF_HOSTED', 'false') + mocks.isAllowedRedirectUri.mockResolvedValue(true) + mocks.requireCompanyId.mockResolvedValue('company-1') + mocks.getBranding.mockReturnValue({ appName: 'gnubok' }) + }) + + afterEach(() => { + vi.unstubAllEnvs() + }) + + it('GET redirects an AAL1 session to /mfa/verify with returnTo', async () => { + mocks.createClient.mockResolvedValue( + buildSupabase({ id: 'user-1' }, 'Test AB', { currentLevel: 'aal1', nextLevel: 'aal2' }), + ) + + const response = await GET(new Request(buildAuthorizeUrl(authorizeParams))) + + expect(response.status).toBeGreaterThanOrEqual(300) + expect(response.status).toBeLessThan(400) + const location = new URL(response.headers.get('location')!) + expect(location.pathname).toBe('/mfa/verify') + const returnTo = new URL(location.searchParams.get('returnTo')!, location.origin) + expect(returnTo.pathname).toBe('/api/mcp-oauth/authorize') + expect(returnTo.searchParams.get('state')).toBe('xyz') + }) + + it('POST rejects an AAL1 session even when the consent form is forged', async () => { + mocks.createClient.mockResolvedValue( + buildSupabase({ id: 'user-1' }, 'Test AB', { currentLevel: 'aal1', nextLevel: 'aal2' }), + ) + + const formData = new FormData() + formData.set('consent', 'allow') + const response = await POST( + new Request(buildAuthorizeUrl(authorizeParams), { method: 'POST', body: formData }), + ) + + expect(response.status).toBeGreaterThanOrEqual(300) + expect(response.status).toBeLessThan(400) + expect(new URL(response.headers.get('location')!).pathname).toBe('/mfa/verify') + // No auth code must be minted: the redirect target is the step-up page, + // never the client callback. + expect(response.headers.get('location')).not.toContain('code=') + }) + + it('GET renders consent for an AAL2 session', async () => { + mocks.createClient.mockResolvedValue( + buildSupabase({ id: 'user-1' }, 'Test AB', { currentLevel: 'aal2', nextLevel: 'aal2' }), + ) + + const response = await GET(new Request(buildAuthorizeUrl(authorizeParams))) + expect(response.status).toBe(200) + }) + + it('GET skips step-up for BankID-linked users (inherently 2FA)', async () => { + const supabase = buildSupabase( + { id: 'user-1' }, + 'Test AB', + { currentLevel: 'aal1', nextLevel: 'aal2' }, + ) + ;(supabase.auth.getUser as ReturnType).mockResolvedValue({ + data: { user: { id: 'user-1', app_metadata: { bankid_linked: true } } }, + error: null, + }) + mocks.createClient.mockResolvedValue(supabase) + + const response = await GET(new Request(buildAuthorizeUrl(authorizeParams))) + expect(response.status).toBe(200) + }) +}) diff --git a/app/api/mcp-oauth/authorize/route.ts b/app/api/mcp-oauth/authorize/route.ts index fd3a20c5..802f45ff 100644 --- a/app/api/mcp-oauth/authorize/route.ts +++ b/app/api/mcp-oauth/authorize/route.ts @@ -1,7 +1,9 @@ import crypto from 'crypto' import { createClient } from '@/lib/supabase/server' import { NextResponse } from 'next/server' +import type { SupabaseClient, User } from '@supabase/supabase-js' import { createAuthCode } from '@/lib/auth/oauth-codes' +import { shouldEnforceMfa } from '@/lib/auth/mfa' import { requireCompanyId } from '@/lib/company/context' import { getBranding } from '@/lib/branding/service' import { isAllowedRedirectUri } from '@/lib/auth/oauth-allowlist' @@ -105,6 +107,31 @@ function buildLoginRedirect(request: Request): Response { ) } +/** + * Consent here mints a long-lived API key at /token, and that key bypasses + * MFA on every subsequent call: so the consent session itself must be AAL2. + * The middleware MFA gate deliberately exempts /api/mcp-oauth/* (the token + * endpoint is Bearer-only), which makes this route responsible for its own + * step-up. Returns null when the session is AAL2 (or MFA isn't required), + * otherwise a redirect to /mfa/verify that returns to this authorize URL. + */ +async function requireAal2( + supabase: SupabaseClient, + user: User, + request: Request, +): Promise { + if (!shouldEnforceMfa(user)) return null + const { data: aal } = await supabase.auth.mfa.getAuthenticatorAssuranceLevel() + if (aal?.nextLevel === 'aal2' && aal?.currentLevel !== 'aal2') { + const url = new URL(request.url) + const returnTo = `${url.pathname}${url.search}` + return NextResponse.redirect( + new URL(`/mfa/verify?returnTo=${encodeURIComponent(returnTo)}`, url.origin), + ) + } + return null +} + function errorRedirect(redirectUri: string, state: string | null, error: string, desc: string): Response { const url = new URL(redirectUri) url.searchParams.set('error', error) @@ -165,6 +192,9 @@ export async function GET(request: Request) { return buildLoginRedirect(request) } + const mfaRedirect = await requireAal2(supabase, user, request) + if (mfaRedirect) return mfaRedirect + // Validate redirect_uri against allowlist (prevents open redirect). Passing // the authenticated client makes the trust boundary explicit (SOC 2 CC6.1). if (!(await isAllowedRedirectUri(redirectUri, supabase))) { @@ -631,6 +661,12 @@ export async function POST(request: Request) { return buildLoginRedirect(request) } + // An AAL1 session must not be able to approve consent (the GET step-up can + // be bypassed by POSTing the form directly). The redirect lands back on the + // GET consent page after verification. + const mfaRedirect = await requireAal2(supabase, user, request) + if (mfaRedirect) return mfaRedirect + // Pass the authenticated client so the lookup is bound to the same session // that the consent display ran under (SOC 2 CC6.1). if (!(await isAllowedRedirectUri(redirectUri, supabase))) { diff --git a/app/api/pending-operations/[id]/__tests__/route.test.ts b/app/api/pending-operations/[id]/__tests__/route.test.ts index f5d21448..9d068e02 100644 --- a/app/api/pending-operations/[id]/__tests__/route.test.ts +++ b/app/api/pending-operations/[id]/__tests__/route.test.ts @@ -7,13 +7,19 @@ import { } from '@/tests/helpers' const { supabase: mockSupabase, enqueue, reset } = createQueuedMockSupabase() -vi.mock('@/lib/supabase/server', () => ({ - createClient: () => Promise.resolve(mockSupabase), + +// The route runs through the real withRouteContext wrapper: mock its auth, +// company-resolution and write-permission dependencies (getActiveCompanyId, +// not requireCompanyId, is what the wrapper calls) and inject the queued +// Supabase mock via requireAuth so the route's own queries stay in sequence. +const requireAuthMock = vi.fn() +vi.mock('@/lib/auth/require-auth', () => ({ + requireAuth: (...args: unknown[]) => requireAuthMock(...args), })) -const requireCompanyIdMock = vi.fn() vi.mock('@/lib/company/context', () => ({ - requireCompanyId: (...args: unknown[]) => requireCompanyIdMock(...args), + getActiveCompanyId: vi.fn().mockResolvedValue('company-1'), + requireCompanyId: vi.fn().mockResolvedValue('company-1'), })) const requireWritePermissionMock = vi.fn() @@ -42,9 +48,8 @@ const mockUser = { id: 'user-1' } beforeEach(() => { vi.clearAllMocks() reset() - mockSupabase.auth.getUser.mockResolvedValue({ data: { user: mockUser } }) + requireAuthMock.mockResolvedValue({ user: mockUser, supabase: mockSupabase, error: null }) requireWritePermissionMock.mockResolvedValue({ ok: true }) - requireCompanyIdMock.mockResolvedValue('company-1') mappingMock.mockReturnValue({ debit_account: '5410', credit_account: '1930', @@ -62,7 +67,12 @@ beforeEach(() => { describe('PATCH /api/pending-operations/[id]', () => { it('returns 401 when not authenticated', async () => { - mockSupabase.auth.getUser.mockResolvedValue({ data: { user: null } }) + const { NextResponse } = await import('next/server') + requireAuthMock.mockResolvedValue({ + user: null, + supabase: mockSupabase, + error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }), + }) const res = await PATCH( createMockRequest('/api/pending-operations/op-1', { method: 'PATCH', diff --git a/app/api/pending-operations/[id]/commit/route.ts b/app/api/pending-operations/[id]/commit/route.ts index aa3dd41b..f3ef0432 100644 --- a/app/api/pending-operations/[id]/commit/route.ts +++ b/app/api/pending-operations/[id]/commit/route.ts @@ -1,74 +1,62 @@ -import { createClient } from '@/lib/supabase/server' import { NextResponse } from 'next/server' import { ensureInitialized } from '@/lib/init' -import { requireCompanyId } from '@/lib/company/context' -import { requireWritePermission } from '@/lib/auth/require-write' +import { withRouteContext } from '@/lib/api/with-route-context' import { commitPendingOperation } from '@/lib/pending-operations/commit' import { bookkeepingErrorResponse, AccountsNotInChartError, ACCOUNTS_NOT_IN_CHART } from '@/lib/bookkeeping/errors' import type { PendingOperation } from '@/types' ensureInitialized() -export async function POST( - _request: Request, - { params }: { params: Promise<{ id: string }> } -) { - const supabase = await createClient() - const { id } = await params +export const POST = withRouteContext<{ params: Promise<{ id: string }> }>( + 'pending_operation.commit', + async (_request, { supabase, user, companyId }, { params }) => { + const { id } = await params - const { data: { user } } = await supabase.auth.getUser() - if (!user) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } + const { data: op, error: fetchError } = await supabase + .from('pending_operations') + .select('*') + .eq('id', id) + .eq('company_id', companyId) + .single() - const writeCheck = await requireWritePermission(supabase, user.id) - if (!writeCheck.ok) return writeCheck.response - - const companyId = await requireCompanyId(supabase, user.id) - - const { data: op, error: fetchError } = await supabase - .from('pending_operations') - .select('*') - .eq('id', id) - .eq('company_id', companyId) - .single() - - if (fetchError || !op) { - return NextResponse.json({ error: 'Pending operation not found' }, { status: 404 }) - } - - try { - const result = await commitPendingOperation( - supabase, - user.id, - companyId, - op as PendingOperation, - { - userEmail: user.email, - commitMethod: 'user_accept', - actor: { type: 'user', ...(user.email ? { label: user.email } : {}) }, - } - ) - - if (result.status === 'committed') { - return NextResponse.json({ data: result.data }) + if (fetchError || !op) { + return NextResponse.json({ error: 'Pending operation not found' }, { status: 404 }) } - // Recoverable accounts-not-in-chart: return the structured envelope (code + - // account_numbers) so the client can offer activation and retry the still- - // pending op, instead of leaking the raw error string into the chat. - if (result.code === ACCOUNTS_NOT_IN_CHART && result.account_numbers?.length) { - const structured = bookkeepingErrorResponse( - new AccountsNotInChartError(result.account_numbers) + + try { + const result = await commitPendingOperation( + supabase, + user.id, + companyId, + op as PendingOperation, + { + userEmail: user.email, + commitMethod: 'user_accept', + actor: { type: 'user', ...(user.email ? { label: user.email } : {}) }, + } ) - if (structured) return structured + + if (result.status === 'committed') { + return NextResponse.json({ data: result.data }) + } + // Recoverable accounts-not-in-chart: return the structured envelope (code + + // account_numbers) so the client can offer activation and retry the still- + // pending op, instead of leaking the raw error string into the chat. + if (result.code === ACCOUNTS_NOT_IN_CHART && result.account_numbers?.length) { + const structured = bookkeepingErrorResponse( + new AccountsNotInChartError(result.account_numbers) + ) + if (structured) return structured + } + return NextResponse.json( + { error: result.error }, + { status: result.http_status ?? 500 } + ) + } catch (err) { + const typed = bookkeepingErrorResponse(err) + if (typed) return typed + throw err } - return NextResponse.json( - { error: result.error }, - { status: result.http_status ?? 500 } - ) - } catch (err) { - const typed = bookkeepingErrorResponse(err) - if (typed) return typed - throw err - } -} + }, + { requireWrite: true }, +) diff --git a/app/api/pending-operations/[id]/reject/__tests__/route.test.ts b/app/api/pending-operations/[id]/reject/__tests__/route.test.ts index 53662d05..6f15ae8d 100644 --- a/app/api/pending-operations/[id]/reject/__tests__/route.test.ts +++ b/app/api/pending-operations/[id]/reject/__tests__/route.test.ts @@ -1,4 +1,5 @@ import { describe, it, expect, vi, beforeEach } from 'vitest' +import { NextResponse } from 'next/server' import { createMockRequest, createMockRouteParams, @@ -7,33 +8,40 @@ import { } from '@/tests/helpers' const { supabase: mockSupabase, enqueue, enqueueMany, reset } = createQueuedMockSupabase() -vi.mock('@/lib/supabase/server', () => ({ - createClient: () => Promise.resolve(mockSupabase), + +const requireAuthMock = vi.fn() +vi.mock('@/lib/auth/require-auth', () => ({ + requireAuth: (...args: unknown[]) => requireAuthMock(...args), })) vi.mock('@/lib/company/context', () => ({ - requireCompanyId: vi.fn().mockResolvedValue('company-1'), getActiveCompanyId: vi.fn().mockResolvedValue('company-1'), + requireCompanyId: vi.fn().mockResolvedValue('company-1'), })) +const requireWriteMock = vi.fn() vi.mock('@/lib/auth/require-write', () => ({ - requireWritePermission: vi.fn().mockResolvedValue({ ok: true }), + requireWritePermission: (...args: unknown[]) => requireWriteMock(...args), })) import { POST } from '../../reject/route' describe('POST /api/pending-operations/:id/reject', () => { - const mockUser = { id: 'user-1', email: 'test@test.se' } const routeParams = createMockRouteParams({ id: 'op-1' }) beforeEach(() => { vi.clearAllMocks() reset() - mockSupabase.auth.getUser.mockResolvedValue({ data: { user: mockUser } }) + requireAuthMock.mockResolvedValue({ user: { id: 'user-1' }, supabase: mockSupabase, error: null }) + requireWriteMock.mockResolvedValue({ ok: true }) }) it('returns 401 when not authenticated', async () => { - mockSupabase.auth.getUser.mockResolvedValue({ data: { user: null } }) + requireAuthMock.mockResolvedValue({ + user: null, + supabase: mockSupabase, + error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }), + }) const request = createMockRequest('/api/pending-operations/op-1/reject', { method: 'POST' }) const response = await POST(request, routeParams) @@ -42,6 +50,19 @@ describe('POST /api/pending-operations/:id/reject', () => { expect(status).toBe(401) }) + it('returns 403 for a viewer without write permission', async () => { + requireWriteMock.mockResolvedValue({ + ok: false, + response: NextResponse.json({ error: 'Forbidden' }, { status: 403 }), + }) + + const request = createMockRequest('/api/pending-operations/op-1/reject', { method: 'POST' }) + const response = await POST(request, routeParams) + const { status } = await parseJsonResponse(response) + + expect(status).toBe(403) + }) + it('returns 404 when not found', async () => { enqueue({ data: null, error: { message: 'not found' } }) diff --git a/app/api/pending-operations/[id]/reject/route.ts b/app/api/pending-operations/[id]/reject/route.ts index 4e1e409c..b509860f 100644 --- a/app/api/pending-operations/[id]/reject/route.ts +++ b/app/api/pending-operations/[id]/reject/route.ts @@ -1,8 +1,6 @@ -import { createClient } from '@/lib/supabase/server' import { NextResponse } from 'next/server' import { z } from 'zod' -import { requireCompanyId } from '@/lib/company/context' -import { requireWritePermission } from '@/lib/auth/require-write' +import { withRouteContext } from '@/lib/api/with-route-context' const RejectBodySchema = z.object({ rejection_category: z @@ -20,91 +18,81 @@ const RejectBodySchema = z.object({ * and learn from "no". The body is optional: bodyless POSTs from older * clients still mark the op rejected with NULL category/reason. */ -export async function POST( - request: Request, - { params }: { params: Promise<{ id: string }> } -) { - const supabase = await createClient() - const { id } = await params +export const POST = withRouteContext<{ params: Promise<{ id: string }> }>( + 'pending_operation.reject', + async (request, { supabase, companyId }, { params }) => { + const { id } = await params - const { data: { user } } = await supabase.auth.getUser() - if (!user) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const writeCheck = await requireWritePermission(supabase, user.id) - if (!writeCheck.ok) return writeCheck.response - - const companyId = await requireCompanyId(supabase, user.id) - - // Body is optional: accept empty/missing body without rejecting the request. - // Old clients posted no body; the UI dialog will now post a body, but we - // keep accepting both shapes to avoid coupling the API to the UI version. - let rejectionCategory: string | undefined - let rejectionReason: string | undefined - const contentLength = request.headers.get('content-length') - if (contentLength && contentLength !== '0') { - try { - const raw = await request.json() - const parsed = RejectBodySchema.safeParse(raw) - if (!parsed.success) { - return NextResponse.json( - { error: parsed.error.issues.map((i) => i.message).join('; ') }, - { status: 400 }, - ) + // Body is optional: accept empty/missing body without rejecting the request. + // Old clients posted no body; the UI dialog will now post a body, but we + // keep accepting both shapes to avoid coupling the API to the UI version. + let rejectionCategory: string | undefined + let rejectionReason: string | undefined + const contentLength = request.headers.get('content-length') + if (contentLength && contentLength !== '0') { + try { + const raw = await request.json() + const parsed = RejectBodySchema.safeParse(raw) + if (!parsed.success) { + return NextResponse.json( + { error: parsed.error.issues.map((i) => i.message).join('; ') }, + { status: 400 }, + ) + } + rejectionCategory = parsed.data.rejection_category + rejectionReason = parsed.data.rejection_reason?.trim() || undefined + } catch { + // Body present but unparseable: fail closed. + return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 }) } - rejectionCategory = parsed.data.rejection_category - rejectionReason = parsed.data.rejection_reason?.trim() || undefined - } catch { - // Body present but unparseable: fail closed. - return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 }) } - } - const { data: op, error: fetchError } = await supabase - .from('pending_operations') - .select('id, status') - .eq('id', id) - .eq('company_id', companyId) - .single() + const { data: op, error: fetchError } = await supabase + .from('pending_operations') + .select('id, status') + .eq('id', id) + .eq('company_id', companyId) + .single() - if (fetchError || !op) { - return NextResponse.json({ error: 'Pending operation not found' }, { status: 404 }) - } + if (fetchError || !op) { + return NextResponse.json({ error: 'Pending operation not found' }, { status: 404 }) + } - if (op.status !== 'pending') { - // There is no auto-commit path (removed in 20260505190027), so a non-pending - // status here means the op was resolved explicitly: almost always the user - // pressed Godkänn in the /pending (Att göra) UI in parallel, or another - // client already rejected it. Spell that out so an agent doesn't read the - // generic 409 as "the system committed it behind my back". - const explained = - op.status === 'rejected' - ? 'Operation already rejected.' - : op.status === 'expired' - ? 'Operation already expired and can no longer be rejected.' - : `Operation already ${op.status}: it was approved explicitly (most likely via the ` + - 'Att göra / pending UI in parallel), not auto-committed. It can no longer be rejected; ' + - 'reverse or correct the resulting verifikat instead.' - return NextResponse.json( - { error: explained, status: op.status }, - { status: 409 } - ) - } + if (op.status !== 'pending') { + // There is no auto-commit path (removed in 20260505190027), so a non-pending + // status here means the op was resolved explicitly: almost always the user + // pressed Godkänn in the /pending (Att göra) UI in parallel, or another + // client already rejected it. Spell that out so an agent doesn't read the + // generic 409 as "the system committed it behind my back". + const explained = + op.status === 'rejected' + ? 'Operation already rejected.' + : op.status === 'expired' + ? 'Operation already expired and can no longer be rejected.' + : `Operation already ${op.status}: it was approved explicitly (most likely via the ` + + 'Att göra / pending UI in parallel), not auto-committed. It can no longer be rejected; ' + + 'reverse or correct the resulting verifikat instead.' + return NextResponse.json( + { error: explained, status: op.status }, + { status: 409 } + ) + } - const { error: updateError } = await supabase - .from('pending_operations') - .update({ - status: 'rejected', - resolved_at: new Date().toISOString(), - ...(rejectionCategory ? { rejection_category: rejectionCategory } : {}), - ...(rejectionReason ? { rejection_reason: rejectionReason } : {}), - }) - .eq('id', id) + const { error: updateError } = await supabase + .from('pending_operations') + .update({ + status: 'rejected', + resolved_at: new Date().toISOString(), + ...(rejectionCategory ? { rejection_category: rejectionCategory } : {}), + ...(rejectionReason ? { rejection_reason: rejectionReason } : {}), + }) + .eq('id', id) - if (updateError) { - return NextResponse.json({ error: updateError.message }, { status: 500 }) - } + if (updateError) { + return NextResponse.json({ error: updateError.message }, { status: 500 }) + } - return NextResponse.json({ data: { id, status: 'rejected' } }) -} + return NextResponse.json({ data: { id, status: 'rejected' } }) + }, + { requireWrite: true }, +) diff --git a/app/api/pending-operations/[id]/route.ts b/app/api/pending-operations/[id]/route.ts index 0311d11b..5612d85b 100644 --- a/app/api/pending-operations/[id]/route.ts +++ b/app/api/pending-operations/[id]/route.ts @@ -1,9 +1,7 @@ -import { createClient } from '@/lib/supabase/server' import { NextResponse } from 'next/server' import { z } from 'zod' import { ensureInitialized } from '@/lib/init' -import { requireCompanyId } from '@/lib/company/context' -import { requireWritePermission } from '@/lib/auth/require-write' +import { withRouteContext } from '@/lib/api/with-route-context' import { buildMappingResultFromCategory, getCategoryAccountMapping } from '@/lib/bookkeeping/category-mapping' import { buildTransactionEntryLines } from '@/lib/bookkeeping/transaction-entries' import { getVatRate } from '@/lib/bookkeeping/vat-entries' @@ -48,180 +46,173 @@ const PatchSchema = z { message: 'Nothing to update' }, ) -export async function PATCH( - request: Request, - { params }: { params: Promise<{ id: string }> }, -) { - const supabase = await createClient() - const { data: { user } } = await supabase.auth.getUser() - if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) +export const PATCH = withRouteContext<{ params: Promise<{ id: string }> }>( + 'pending_operation.update', + async (request, { supabase, companyId }, { params }) => { + const { id } = await params - const writeCheck = await requireWritePermission(supabase, user.id) - if (!writeCheck.ok) return writeCheck.response - - const companyId = await requireCompanyId(supabase, user.id) - const { id } = await params - - let body: z.infer - try { - body = PatchSchema.parse(await request.json()) - } catch (err) { - return NextResponse.json( - { error: err instanceof Error ? err.message : 'Invalid body' }, - { status: 400 }, - ) - } - - const { data: op } = await supabase - .from('pending_operations') - .select('id, company_id, operation_type, status, params, preview_data, title') - .eq('id', id) - .eq('company_id', companyId) - .maybeSingle() - if (!op) return NextResponse.json({ error: 'Pending operation not found' }, { status: 404 }) - - if (op.status !== 'pending') { - return NextResponse.json( - { error: `Operation already ${op.status}: cannot edit.` }, - { status: 409 }, - ) - } - - if (op.operation_type !== 'categorize_transaction') { - return NextResponse.json( - { error: `Editing ${op.operation_type} is not supported.` }, - { status: 400 }, - ) - } - - const oldParams = (op.params as Record) ?? {} - const newCategory = - body.category ?? (oldParams.category as TransactionCategory | undefined) - const newVatTreatment = - body.vat_treatment !== undefined - ? (body.vat_treatment ?? undefined) - : (oldParams.vat_treatment as VatTreatment | undefined) - - if (!newCategory) { - return NextResponse.json({ error: 'category is required' }, { status: 400 }) - } - - const txId = oldParams.transaction_id as string | undefined - if (!txId) { - return NextResponse.json( - { error: 'Operation has no transaction_id; cannot re-derive.' }, - { status: 500 }, - ) - } - - // Re-derive the preview using the same mapping engine the commit path uses. - const { data: tx } = await supabase - .from('transactions') - .select('*') - .eq('id', txId) - .eq('company_id', companyId) - .maybeSingle() - if (!tx) { - return NextResponse.json({ error: 'Transaction not found' }, { status: 404 }) - } - - const { data: settings } = await supabase - .from('company_settings') - .select('entity_type') - .eq('company_id', companyId) - .maybeSingle() - const entityType = ((settings?.entity_type as EntityType) || 'enskild_firma') - - const isBusiness = newCategory !== 'private' - - // Resolve whether the (possibly defaulted) treatment carries a rate-based - // VAT line: only then can a vat_amount override survive. An explicit - // override on a VAT-less treatment is a caller error; a preserved one from - // before the edit is simply stale and gets dropped. - const probe = getCategoryAccountMapping( - newCategory, (tx as Transaction).amount, isBusiness, entityType, newVatTreatment, - ) - const carriesRateVat = - isBusiness && - probe.vatTreatment !== null && - probe.vatTreatment !== 'reverse_charge' && - getVatRate(probe.vatTreatment as VatTreatment) > 0 - - let newVatAmount: number | null - if (body.vat_amount !== undefined) { - if (body.vat_amount !== null && !carriesRateVat) { + let body: z.infer + try { + body = PatchSchema.parse(await request.json()) + } catch (err) { return NextResponse.json( - { error: 'vat_amount kräver en momspliktig vat_treatment (standard_25, reduced_12 eller reduced_6).' }, + { error: err instanceof Error ? err.message : 'Invalid body' }, { status: 400 }, ) } - newVatAmount = body.vat_amount - } else { - const previous = typeof oldParams.vat_amount === 'number' ? oldParams.vat_amount : null - newVatAmount = carriesRateVat ? previous : null - } - let mapping - try { - mapping = buildMappingResultFromCategory( - newCategory, - tx as Transaction, - isBusiness, - entityType, - newVatTreatment, - newVatAmount, + const { data: op } = await supabase + .from('pending_operations') + .select('id, company_id, operation_type, status, params, preview_data, title') + .eq('id', id) + .eq('company_id', companyId) + .maybeSingle() + if (!op) return NextResponse.json({ error: 'Pending operation not found' }, { status: 404 }) + + if (op.status !== 'pending') { + return NextResponse.json( + { error: `Operation already ${op.status}: cannot edit.` }, + { status: 409 }, + ) + } + + if (op.operation_type !== 'categorize_transaction') { + return NextResponse.json( + { error: `Editing ${op.operation_type} is not supported.` }, + { status: 400 }, + ) + } + + const oldParams = (op.params as Record) ?? {} + const newCategory = + body.category ?? (oldParams.category as TransactionCategory | undefined) + const newVatTreatment = + body.vat_treatment !== undefined + ? (body.vat_treatment ?? undefined) + : (oldParams.vat_treatment as VatTreatment | undefined) + + if (!newCategory) { + return NextResponse.json({ error: 'category is required' }, { status: 400 }) + } + + const txId = oldParams.transaction_id as string | undefined + if (!txId) { + return NextResponse.json( + { error: 'Operation has no transaction_id; cannot re-derive.' }, + { status: 500 }, + ) + } + + // Re-derive the preview using the same mapping engine the commit path uses. + const { data: tx } = await supabase + .from('transactions') + .select('*') + .eq('id', txId) + .eq('company_id', companyId) + .maybeSingle() + if (!tx) { + return NextResponse.json({ error: 'Transaction not found' }, { status: 404 }) + } + + const { data: settings } = await supabase + .from('company_settings') + .select('entity_type') + .eq('company_id', companyId) + .maybeSingle() + const entityType = ((settings?.entity_type as EntityType) || 'enskild_firma') + + const isBusiness = newCategory !== 'private' + + // Resolve whether the (possibly defaulted) treatment carries a rate-based + // VAT line: only then can a vat_amount override survive. An explicit + // override on a VAT-less treatment is a caller error; a preserved one from + // before the edit is simply stale and gets dropped. + const probe = getCategoryAccountMapping( + newCategory, (tx as Transaction).amount, isBusiness, entityType, newVatTreatment, ) - } catch (err) { - return NextResponse.json( - { error: err instanceof Error ? err.message : 'Ogiltig momsjustering' }, - { status: 400 }, - ) - } + const carriesRateVat = + isBusiness && + probe.vatTreatment !== null && + probe.vatTreatment !== 'reverse_charge' && + getVatRate(probe.vatTreatment as VatTreatment) > 0 - if (!mapping.debit_account || !mapping.credit_account) { - return NextResponse.json( - { error: `Inget kontomappning för kategorin "${newCategory}" (${entityType}).` }, - { status: 400 }, - ) - } + let newVatAmount: number | null + if (body.vat_amount !== undefined) { + if (body.vat_amount !== null && !carriesRateVat) { + return NextResponse.json( + { error: 'vat_amount kräver en momspliktig vat_treatment (standard_25, reduced_12 eller reduced_6).' }, + { status: 400 }, + ) + } + newVatAmount = body.vat_amount + } else { + const previous = typeof oldParams.vat_amount === 'number' ? oldParams.vat_amount : null + newVatAmount = carriesRateVat ? previous : null + } - const oldPreview = (op.preview_data as Record) ?? {} - const newPreview = { - ...oldPreview, - debit_account: mapping.debit_account, - credit_account: mapping.credit_account, - amount: Math.abs((tx as Transaction).amount), - currency: (tx as Transaction).currency, - // Re-derive the exact journal lines (net cost line, VAT, gross bank) — - // spreading oldPreview would otherwise leave stale lines from staging. - lines: buildTransactionEntryLines(tx as Transaction, mapping).map((l) => ({ - account_number: l.account_number, - debit_amount: l.debit_amount, - credit_amount: l.credit_amount, - description: l.line_description ?? '', - })), - vat_lines: (mapping.vat_lines ?? []).map((v) => ({ - account: v.account_number, - amount: v.debit_amount || v.credit_amount, - })), - category: newCategory, - } + let mapping + try { + mapping = buildMappingResultFromCategory( + newCategory, + tx as Transaction, + isBusiness, + entityType, + newVatTreatment, + newVatAmount, + ) + } catch (err) { + return NextResponse.json( + { error: err instanceof Error ? err.message : 'Ogiltig momsjustering' }, + { status: 400 }, + ) + } - const newParams = { - ...oldParams, - category: newCategory, - vat_treatment: newVatTreatment ?? null, - vat_amount: newVatAmount, - } + if (!mapping.debit_account || !mapping.credit_account) { + return NextResponse.json( + { error: `Inget kontomappning för kategorin "${newCategory}" (${entityType}).` }, + { status: 400 }, + ) + } - const { data: updated, error } = await supabase - .from('pending_operations') - .update({ params: newParams, preview_data: newPreview }) - .eq('id', id) - .eq('company_id', companyId) - .select('id, params, preview_data, title, status') - .single() - if (error) return NextResponse.json({ error: error.message }, { status: 500 }) + const oldPreview = (op.preview_data as Record) ?? {} + const newPreview = { + ...oldPreview, + debit_account: mapping.debit_account, + credit_account: mapping.credit_account, + amount: Math.abs((tx as Transaction).amount), + currency: (tx as Transaction).currency, + // Re-derive the exact journal lines (net cost line, VAT, gross bank): + // spreading oldPreview would otherwise leave stale lines from staging. + lines: buildTransactionEntryLines(tx as Transaction, mapping).map((l) => ({ + account_number: l.account_number, + debit_amount: l.debit_amount, + credit_amount: l.credit_amount, + description: l.line_description ?? '', + })), + vat_lines: (mapping.vat_lines ?? []).map((v) => ({ + account: v.account_number, + amount: v.debit_amount || v.credit_amount, + })), + category: newCategory, + } - return NextResponse.json({ data: updated }) -} + const newParams = { + ...oldParams, + category: newCategory, + vat_treatment: newVatTreatment ?? null, + vat_amount: newVatAmount, + } + + const { data: updated, error } = await supabase + .from('pending_operations') + .update({ params: newParams, preview_data: newPreview }) + .eq('id', id) + .eq('company_id', companyId) + .select('id, params, preview_data, title, status') + .single() + if (error) return NextResponse.json({ error: error.message }, { status: 500 }) + + return NextResponse.json({ data: updated }) + }, + { requireWrite: true }, +) diff --git a/app/api/pending-operations/bulk-commit/__tests__/route.test.ts b/app/api/pending-operations/bulk-commit/__tests__/route.test.ts index c80ba20d..f6676fc8 100644 --- a/app/api/pending-operations/bulk-commit/__tests__/route.test.ts +++ b/app/api/pending-operations/bulk-commit/__tests__/route.test.ts @@ -1,4 +1,5 @@ import { describe, it, expect, vi, beforeEach } from 'vitest' +import { NextResponse } from 'next/server' import { createMockRequest, parseJsonResponse, @@ -7,9 +8,12 @@ import { import { eventBus } from '@/lib/events/bus' const { supabase: mockSupabase, enqueue, reset } = createQueuedMockSupabase() -vi.mock('@/lib/supabase/server', () => ({ - createClient: () => Promise.resolve(mockSupabase), + +const requireAuthMock = vi.fn() +vi.mock('@/lib/auth/require-auth', () => ({ + requireAuth: (...args: unknown[]) => requireAuthMock(...args), })) + vi.mock('@/lib/init', () => ({ ensureInitialized: vi.fn() })) vi.mock('@/lib/company/context', () => ({ @@ -17,8 +21,9 @@ vi.mock('@/lib/company/context', () => ({ getActiveCompanyId: vi.fn().mockResolvedValue('company-1'), })) +const requireWriteMock = vi.fn() vi.mock('@/lib/auth/require-write', () => ({ - requireWritePermission: vi.fn().mockResolvedValue({ ok: true }), + requireWritePermission: (...args: unknown[]) => requireWriteMock(...args), })) const mockCommit = vi.fn() @@ -56,11 +61,16 @@ describe('POST /api/pending-operations/bulk-commit', () => { vi.clearAllMocks() eventBus.clear() reset() - mockSupabase.auth.getUser.mockResolvedValue({ data: { user: mockUser } }) + requireAuthMock.mockResolvedValue({ user: mockUser, supabase: mockSupabase, error: null }) + requireWriteMock.mockResolvedValue({ ok: true }) }) it('returns 401 when not authenticated', async () => { - mockSupabase.auth.getUser.mockResolvedValue({ data: { user: null } }) + requireAuthMock.mockResolvedValue({ + user: null, + supabase: mockSupabase, + error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }), + }) const request = createMockRequest('/api/pending-operations/bulk-commit', { method: 'POST', @@ -73,6 +83,22 @@ describe('POST /api/pending-operations/bulk-commit', () => { expect(body).toEqual({ error: 'Unauthorized' }) }) + it('returns 403 for a viewer without write permission', async () => { + requireWriteMock.mockResolvedValue({ + ok: false, + response: NextResponse.json({ error: 'Forbidden' }, { status: 403 }), + }) + + const request = createMockRequest('/api/pending-operations/bulk-commit', { + method: 'POST', + body: { ids: [VALID_ID_1] }, + }) + const response = await POST(request) + const { status } = await parseJsonResponse(response) + + expect(status).toBe(403) + }) + it('returns 400 when ids array is empty', async () => { const request = createMockRequest('/api/pending-operations/bulk-commit', { method: 'POST', diff --git a/app/api/pending-operations/bulk-commit/route.ts b/app/api/pending-operations/bulk-commit/route.ts index f2955021..61dd9718 100644 --- a/app/api/pending-operations/bulk-commit/route.ts +++ b/app/api/pending-operations/bulk-commit/route.ts @@ -1,8 +1,6 @@ -import { createClient } from '@/lib/supabase/server' import { NextResponse } from 'next/server' import { ensureInitialized } from '@/lib/init' -import { requireCompanyId } from '@/lib/company/context' -import { requireWritePermission } from '@/lib/auth/require-write' +import { withRouteContext } from '@/lib/api/with-route-context' import { validateBody } from '@/lib/api/validate' import { PendingOperationsBulkSchema } from '@/lib/api/schemas' import { commitPendingOperation } from '@/lib/pending-operations/commit' @@ -16,76 +14,68 @@ interface BulkCommitItemResult { error?: string } -export async function POST(request: Request) { - const supabase = await createClient() +export const POST = withRouteContext( + 'pending_operation.bulk_commit', + async (request, { user, supabase, companyId }) => { + const validated = await validateBody(request, PendingOperationsBulkSchema) + if (!validated.success) return validated.response + const { ids } = validated.data - const { data: { user } } = await supabase.auth.getUser() - if (!user) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } + const { data: ops, error: fetchError } = await supabase + .from('pending_operations') + .select('*') + .in('id', ids) + .eq('company_id', companyId) - const writeCheck = await requireWritePermission(supabase, user.id) - if (!writeCheck.ok) return writeCheck.response - - const validated = await validateBody(request, PendingOperationsBulkSchema) - if (!validated.success) return validated.response - const { ids } = validated.data - - const companyId = await requireCompanyId(supabase, user.id) - - const { data: ops, error: fetchError } = await supabase - .from('pending_operations') - .select('*') - .in('id', ids) - .eq('company_id', companyId) - - if (fetchError) { - return NextResponse.json({ error: fetchError.message }, { status: 500 }) - } - - const opsById = new Map((ops ?? []).map((op) => [op.id, op as PendingOperation])) - const results: BulkCommitItemResult[] = [] - - for (const id of ids) { - const op = opsById.get(id) - if (!op) { - results.push({ id, status: 'failed', error: 'Operation not found' }) - continue + if (fetchError) { + return NextResponse.json({ error: fetchError.message }, { status: 500 }) } - if (op.status !== 'pending') { - results.push({ id, status: 'skipped', error: `Already ${op.status}` }) - continue - } - if (op.risk_level === 'high') { - results.push({ - id, - status: 'skipped', - error: 'Hög risk: kräver individuellt godkännande', + + const opsById = new Map((ops ?? []).map((op) => [op.id, op as PendingOperation])) + const results: BulkCommitItemResult[] = [] + + for (const id of ids) { + const op = opsById.get(id) + if (!op) { + results.push({ id, status: 'failed', error: 'Operation not found' }) + continue + } + if (op.status !== 'pending') { + results.push({ id, status: 'skipped', error: `Already ${op.status}` }) + continue + } + if (op.risk_level === 'high') { + results.push({ + id, + status: 'skipped', + error: 'Hög risk: kräver individuellt godkännande', + }) + continue + } + + const result = await commitPendingOperation(supabase, user.id, companyId, op, { + userEmail: user.email, + commitMethod: 'bulk_accept', + actor: { type: 'user', ...(user.email ? { label: user.email } : {}) }, }) - continue + if (result.status === 'committed') { + results.push({ id, status: 'committed' }) + } else if (result.status === 'rejected' && result.auto_rejected) { + results.push({ id, status: 'rejected', error: result.error ?? 'Avvisad' }) + } else { + results.push({ id, status: 'failed', error: result.error ?? 'Misslyckades' }) + } } - const result = await commitPendingOperation(supabase, user.id, companyId, op, { - userEmail: user.email, - commitMethod: 'bulk_accept', - actor: { type: 'user', ...(user.email ? { label: user.email } : {}) }, - }) - if (result.status === 'committed') { - results.push({ id, status: 'committed' }) - } else if (result.status === 'rejected' && result.auto_rejected) { - results.push({ id, status: 'rejected', error: result.error ?? 'Avvisad' }) - } else { - results.push({ id, status: 'failed', error: result.error ?? 'Misslyckades' }) + const summary = { + total: results.length, + committed: results.filter((r) => r.status === 'committed').length, + failed: results.filter((r) => r.status === 'failed').length, + skipped: results.filter((r) => r.status === 'skipped').length, + rejected: results.filter((r) => r.status === 'rejected').length, } - } - const summary = { - total: results.length, - committed: results.filter((r) => r.status === 'committed').length, - failed: results.filter((r) => r.status === 'failed').length, - skipped: results.filter((r) => r.status === 'skipped').length, - rejected: results.filter((r) => r.status === 'rejected').length, - } - - return NextResponse.json({ data: { results, summary } }) -} + return NextResponse.json({ data: { results, summary } }) + }, + { requireWrite: true }, +) diff --git a/app/api/pending-operations/route.ts b/app/api/pending-operations/route.ts index 4a22e7be..688e4fd0 100644 --- a/app/api/pending-operations/route.ts +++ b/app/api/pending-operations/route.ts @@ -1,8 +1,7 @@ -import { createClient } from '@/lib/supabase/server' import { NextResponse } from 'next/server' +import { withRouteContext } from '@/lib/api/with-route-context' import { validateQuery } from '@/lib/api/validate' import { PendingOperationsQuerySchema } from '@/lib/api/schemas' -import { requireCompanyId } from '@/lib/company/context' /** * GET /api/pending-operations @@ -10,36 +9,31 @@ import { requireCompanyId } from '@/lib/company/context' * List pending operations for the authenticated user. * Query params: status (default: pending), limit, offset */ -export async function GET(request: Request) { - const supabase = await createClient() - const { data: { user } } = await supabase.auth.getUser() - if (!user) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } +export const GET = withRouteContext( + 'pending_operation.list', + async (request, { supabase, companyId }) => { + const result = validateQuery(request, PendingOperationsQuerySchema) + if (!result.success) return result.response + const { status, limit, offset } = result.data - const companyId = await requireCompanyId(supabase, user.id) + // Terminal tabs (Godkända/Avvisade) order by when the op was RESOLVED, not + // created: auto-expired ops are ≥30 days old by construction, so a + // created_at ordering would bury a fresh expiry sweep below a month of + // newer rejections and the "Utgick automatiskt" context would never be seen. + const orderColumn = status === 'pending' ? 'created_at' : 'resolved_at' - const result = validateQuery(request, PendingOperationsQuerySchema) - if (!result.success) return result.response - const { status, limit, offset } = result.data + const { data, error, count } = await supabase + .from('pending_operations') + .select('*', { count: 'exact' }) + .eq('company_id', companyId) + .eq('status', status) + .order(orderColumn, { ascending: false, nullsFirst: false }) + .range(offset, offset + limit - 1) - // Terminal tabs (Godkända/Avvisade) order by when the op was RESOLVED, not - // created: auto-expired ops are ≥30 days old by construction, so a - // created_at ordering would bury a fresh expiry sweep below a month of - // newer rejections and the "Utgick automatiskt" context would never be seen. - const orderColumn = status === 'pending' ? 'created_at' : 'resolved_at' + if (error) { + return NextResponse.json({ error: error.message }, { status: 500 }) + } - const { data, error, count } = await supabase - .from('pending_operations') - .select('*', { count: 'exact' }) - .eq('company_id', companyId) - .eq('status', status) - .order(orderColumn, { ascending: false, nullsFirst: false }) - .range(offset, offset + limit - 1) - - if (error) { - return NextResponse.json({ error: error.message }, { status: 500 }) - } - - return NextResponse.json({ data: data ?? [], count }) -} + return NextResponse.json({ data: data ?? [], count }) + }, +) diff --git a/app/api/reconciliation/bank/link/__tests__/route.test.ts b/app/api/reconciliation/bank/link/__tests__/route.test.ts new file mode 100644 index 00000000..42c1fc17 --- /dev/null +++ b/app/api/reconciliation/bank/link/__tests__/route.test.ts @@ -0,0 +1,131 @@ +/** + * Tests for POST /api/reconciliation/bank/link. + * + * Exercises the route through the real withRouteContext wrapper, mocking only + * its auth/company/write dependencies plus the manualLink service. Covers: + * 401, 403 viewer, validation (400), service failure (400), and happy path. + */ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { NextResponse } from 'next/server' +import { createQueuedMockSupabase, createMockRequest, parseJsonResponse } from '@/tests/helpers' + +const { supabase, reset } = createQueuedMockSupabase() + +const requireAuthMock = vi.fn() +vi.mock('@/lib/auth/require-auth', () => ({ + requireAuth: (...args: unknown[]) => requireAuthMock(...args), +})) + +vi.mock('@/lib/company/context', () => ({ + getActiveCompanyId: vi.fn().mockResolvedValue('company-1'), + requireCompanyId: vi.fn().mockResolvedValue('company-1'), +})) + +const requireWriteMock = vi.fn() +vi.mock('@/lib/auth/require-write', () => ({ + requireWritePermission: (...args: unknown[]) => requireWriteMock(...args), +})) + +vi.mock('@/lib/init', () => ({ ensureInitialized: vi.fn() })) + +const manualLinkMock = vi.fn() +vi.mock('@/lib/reconciliation/bank-reconciliation', () => ({ + manualLink: (...args: unknown[]) => manualLinkMock(...args), +})) + +import { POST } from '../route' + +const emptyParams = { params: Promise.resolve({}) } +const TX_ID = '11111111-1111-4111-8111-111111111111' +const JE_ID = '22222222-2222-4222-8222-222222222222' + +describe('POST /api/reconciliation/bank/link', () => { + beforeEach(() => { + vi.clearAllMocks() + reset() + requireAuthMock.mockResolvedValue({ user: { id: 'user-1' }, supabase }) + requireWriteMock.mockResolvedValue({ ok: true }) + manualLinkMock.mockResolvedValue({ success: true }) + }) + + it('returns 401 when unauthenticated', async () => { + requireAuthMock.mockResolvedValue({ + user: null, + supabase, + error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }), + }) + + const request = createMockRequest('/api/reconciliation/bank/link', { + method: 'POST', + body: { transaction_id: TX_ID, journal_entry_id: JE_ID }, + }) + + const response = await POST(request, emptyParams) + expect(response.status).toBe(401) + }) + + it('returns 403 for a viewer', async () => { + requireWriteMock.mockResolvedValue({ + ok: false, + response: NextResponse.json({ error: 'Forbidden' }, { status: 403 }), + }) + + const request = createMockRequest('/api/reconciliation/bank/link', { + method: 'POST', + body: { transaction_id: TX_ID, journal_entry_id: JE_ID }, + }) + + const response = await POST(request, emptyParams) + expect(response.status).toBe(403) + }) + + it('rejects a non-uuid transaction_id with 400', async () => { + const request = createMockRequest('/api/reconciliation/bank/link', { + method: 'POST', + body: { transaction_id: 'not-a-uuid', journal_entry_id: JE_ID }, + }) + + const response = await POST(request, emptyParams) + expect(response.status).toBe(400) + expect(manualLinkMock).not.toHaveBeenCalled() + }) + + it('surfaces a manualLink failure as 400 with the service error', async () => { + manualLinkMock.mockResolvedValue({ + success: false, + error: 'Transaktionen är redan kopplad till en verifikation.', + }) + + const request = createMockRequest('/api/reconciliation/bank/link', { + method: 'POST', + body: { transaction_id: TX_ID, journal_entry_id: JE_ID }, + }) + + const response = await POST(request, emptyParams) + const { status, body } = await parseJsonResponse<{ error: string }>(response) + + expect(status).toBe(400) + expect(body.error).toBe('Transaktionen är redan kopplad till en verifikation.') + }) + + it('links the transaction, defaulting the account to 1930', async () => { + const request = createMockRequest('/api/reconciliation/bank/link', { + method: 'POST', + body: { transaction_id: TX_ID, journal_entry_id: JE_ID }, + }) + + const response = await POST(request, emptyParams) + const { status, body } = await parseJsonResponse<{ data: { success: boolean } }>(response) + + expect(status).toBe(200) + expect(body.data.success).toBe(true) + expect(manualLinkMock).toHaveBeenCalledWith( + supabase, + 'company-1', + TX_ID, + JE_ID, + 'user-1', + '1930', + ) + }) +}) diff --git a/app/api/reconciliation/bank/link/route.ts b/app/api/reconciliation/bank/link/route.ts index 91854780..7fe25b1c 100644 --- a/app/api/reconciliation/bank/link/route.ts +++ b/app/api/reconciliation/bank/link/route.ts @@ -1,43 +1,33 @@ -import { createClient } from '@/lib/supabase/server' import { NextResponse } from 'next/server' import { ensureInitialized } from '@/lib/init' +import { withRouteContext } from '@/lib/api/with-route-context' import { manualLink } from '@/lib/reconciliation/bank-reconciliation' import { validateBody } from '@/lib/api/validate' import { BankLinkSchema } from '@/lib/api/schemas' -import { requireCompanyId } from '@/lib/company/context' -import { requireWritePermission } from '@/lib/auth/require-write' ensureInitialized() -export async function POST(request: Request) { - const supabase = await createClient() - const { data: { user } } = await supabase.auth.getUser() +export const POST = withRouteContext( + 'reconciliation.bank.link', + async (request, { supabase, user, companyId }) => { + const validation = await validateBody(request, BankLinkSchema) + if (!validation.success) return validation.response + const { transaction_id, journal_entry_id, account_number } = validation.data - if (!user) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } + const result = await manualLink( + supabase, + companyId, + transaction_id, + journal_entry_id, + user.id, + account_number ?? '1930', + ) - const writeCheck = await requireWritePermission(supabase, user.id) - if (!writeCheck.ok) return writeCheck.response + if (!result.success) { + return NextResponse.json({ error: result.error }, { status: 400 }) + } - const companyId = await requireCompanyId(supabase, user.id) - - const validation = await validateBody(request, BankLinkSchema) - if (!validation.success) return validation.response - const { transaction_id, journal_entry_id, account_number } = validation.data - - const result = await manualLink( - supabase, - companyId, - transaction_id, - journal_entry_id, - user.id, - account_number ?? '1930', - ) - - if (!result.success) { - return NextResponse.json({ error: result.error }, { status: 400 }) - } - - return NextResponse.json({ data: { success: true } }) -} + return NextResponse.json({ data: { success: true } }) + }, + { requireWrite: true }, +) diff --git a/app/api/reconciliation/bank/mark-opening-balance/__tests__/route.test.ts b/app/api/reconciliation/bank/mark-opening-balance/__tests__/route.test.ts new file mode 100644 index 00000000..18106058 --- /dev/null +++ b/app/api/reconciliation/bank/mark-opening-balance/__tests__/route.test.ts @@ -0,0 +1,119 @@ +/** + * Tests for POST /api/reconciliation/bank/mark-opening-balance. + * + * Exercises the route through the real withRouteContext wrapper, mocking only + * its auth/company/write dependencies and injecting a queued Supabase mock via + * requireAuth. Covers: 401, 403 viewer, validation (400), the RPC error + * translation to Swedish, and the happy path. + */ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { NextResponse } from 'next/server' +import { createQueuedMockSupabase, createMockRequest, parseJsonResponse } from '@/tests/helpers' + +const { supabase, enqueue, reset } = createQueuedMockSupabase() + +const requireAuthMock = vi.fn() +vi.mock('@/lib/auth/require-auth', () => ({ + requireAuth: (...args: unknown[]) => requireAuthMock(...args), +})) + +vi.mock('@/lib/company/context', () => ({ + getActiveCompanyId: vi.fn().mockResolvedValue('company-1'), + requireCompanyId: vi.fn().mockResolvedValue('company-1'), +})) + +const requireWriteMock = vi.fn() +vi.mock('@/lib/auth/require-write', () => ({ + requireWritePermission: (...args: unknown[]) => requireWriteMock(...args), +})) + +vi.mock('@/lib/init', () => ({ ensureInitialized: vi.fn() })) + +import { POST } from '../route' + +const emptyParams = { params: Promise.resolve({}) } +const JE_ID = '33333333-3333-4333-8333-333333333333' + +describe('POST /api/reconciliation/bank/mark-opening-balance', () => { + beforeEach(() => { + vi.clearAllMocks() + reset() + requireAuthMock.mockResolvedValue({ user: { id: 'user-1' }, supabase }) + requireWriteMock.mockResolvedValue({ ok: true }) + }) + + it('returns 401 when unauthenticated', async () => { + requireAuthMock.mockResolvedValue({ + user: null, + supabase, + error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }), + }) + + const request = createMockRequest('/api/reconciliation/bank/mark-opening-balance', { + method: 'POST', + body: { journal_entry_id: JE_ID }, + }) + + const response = await POST(request, emptyParams) + expect(response.status).toBe(401) + }) + + it('returns 403 for a viewer', async () => { + requireWriteMock.mockResolvedValue({ + ok: false, + response: NextResponse.json({ error: 'Forbidden' }, { status: 403 }), + }) + + const request = createMockRequest('/api/reconciliation/bank/mark-opening-balance', { + method: 'POST', + body: { journal_entry_id: JE_ID }, + }) + + const response = await POST(request, emptyParams) + expect(response.status).toBe(403) + }) + + it('rejects a non-uuid journal_entry_id with 400', async () => { + const request = createMockRequest('/api/reconciliation/bank/mark-opening-balance', { + method: 'POST', + body: { journal_entry_id: 'nope' }, + }) + + const response = await POST(request, emptyParams) + expect(response.status).toBe(400) + }) + + it('translates a locked-period RPC error to Swedish', async () => { + enqueue({ error: { message: 'Cannot modify entries in a locked fiscal period' } }) + + const request = createMockRequest('/api/reconciliation/bank/mark-opening-balance', { + method: 'POST', + body: { journal_entry_id: JE_ID }, + }) + + const response = await POST(request, emptyParams) + const { status, body } = await parseJsonResponse<{ error: string }>(response) + + expect(status).toBe(400) + expect(body.error).toBe('Perioden är låst. Lås upp perioden innan du ändrar verifikationen.') + }) + + it('marks the entry as opening balance via the RPC', async () => { + enqueue({ data: { updated: true } }) + + const request = createMockRequest('/api/reconciliation/bank/mark-opening-balance', { + method: 'POST', + body: { journal_entry_id: JE_ID }, + }) + + const response = await POST(request, emptyParams) + const { status, body } = await parseJsonResponse<{ data: { updated: boolean } }>(response) + + expect(status).toBe(200) + expect(body.data.updated).toBe(true) + expect(supabase.rpc).toHaveBeenCalledWith('mark_entry_as_opening_balance', { + p_company_id: 'company-1', + p_entry_id: JE_ID, + }) + }) +}) diff --git a/app/api/reconciliation/bank/mark-opening-balance/route.ts b/app/api/reconciliation/bank/mark-opening-balance/route.ts index 295ffd50..c73ff751 100644 --- a/app/api/reconciliation/bank/mark-opening-balance/route.ts +++ b/app/api/reconciliation/bank/mark-opening-balance/route.ts @@ -1,10 +1,8 @@ -import { createClient } from '@/lib/supabase/server' import { NextResponse } from 'next/server' import { ensureInitialized } from '@/lib/init' +import { withRouteContext } from '@/lib/api/with-route-context' import { validateBody } from '@/lib/api/validate' import { MarkOpeningBalanceSchema } from '@/lib/api/schemas' -import { requireCompanyId } from '@/lib/company/context' -import { requireWritePermission } from '@/lib/auth/require-write' ensureInitialized() @@ -15,48 +13,40 @@ ensureInitialized() * enforces owner/admin role, the manual/import precondition, a bank-line check, * and the period lock. We only translate its errors to Swedish here. */ -export async function POST(request: Request) { - const supabase = await createClient() - const { data: { user } } = await supabase.auth.getUser() +export const POST = withRouteContext( + 'reconciliation.bank.mark_opening_balance', + async (request, { supabase, companyId }) => { + const validation = await validateBody(request, MarkOpeningBalanceSchema) + if (!validation.success) return validation.response + const { journal_entry_id } = validation.data - if (!user) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } + const { data, error } = await supabase.rpc('mark_entry_as_opening_balance', { + p_company_id: companyId, + p_entry_id: journal_entry_id, + }) - const writeCheck = await requireWritePermission(supabase, user.id) - if (!writeCheck.ok) return writeCheck.response - - const companyId = await requireCompanyId(supabase, user.id) - - const validation = await validateBody(request, MarkOpeningBalanceSchema) - if (!validation.success) return validation.response - const { journal_entry_id } = validation.data - - const { data, error } = await supabase.rpc('mark_entry_as_opening_balance', { - p_company_id: companyId, - p_entry_id: journal_entry_id, - }) - - if (error) { - const raw = error.message || '' - let message = 'Kunde inte markera verifikationen som ingående balans.' - if (/owners and admins/i.test(raw)) { - message = 'Endast ägare och administratörer kan markera en ingående balans.' - } else if (/not found/i.test(raw)) { - message = 'Verifikationen kunde inte hittas.' - } else if (/manual\/import/i.test(raw)) { - message = 'Bara manuellt eller importerat bokförda verifikationer kan markeras som ingående balans.' - } else if (/posted entries/i.test(raw)) { - message = 'Bara bokförda verifikationer kan markeras som ingående balans.' - } else if (/bank\/cash account/i.test(raw)) { - message = 'Verifikationen saknar rad på ett bankkonto (19xx) och kan inte vara en ingående balans.' - } else if (/closed fiscal period/i.test(raw)) { - message = 'Perioden är stängd. Öppna perioden innan du ändrar verifikationen.' - } else if (/locked fiscal period/i.test(raw)) { - message = 'Perioden är låst. Lås upp perioden innan du ändrar verifikationen.' + if (error) { + const raw = error.message || '' + let message = 'Kunde inte markera verifikationen som ingående balans.' + if (/owners and admins/i.test(raw)) { + message = 'Endast ägare och administratörer kan markera en ingående balans.' + } else if (/not found/i.test(raw)) { + message = 'Verifikationen kunde inte hittas.' + } else if (/manual\/import/i.test(raw)) { + message = 'Bara manuellt eller importerat bokförda verifikationer kan markeras som ingående balans.' + } else if (/posted entries/i.test(raw)) { + message = 'Bara bokförda verifikationer kan markeras som ingående balans.' + } else if (/bank\/cash account/i.test(raw)) { + message = 'Verifikationen saknar rad på ett bankkonto (19xx) och kan inte vara en ingående balans.' + } else if (/closed fiscal period/i.test(raw)) { + message = 'Perioden är stängd. Öppna perioden innan du ändrar verifikationen.' + } else if (/locked fiscal period/i.test(raw)) { + message = 'Perioden är låst. Lås upp perioden innan du ändrar verifikationen.' + } + return NextResponse.json({ error: message }, { status: 400 }) } - return NextResponse.json({ error: message }, { status: 400 }) - } - return NextResponse.json({ data }) -} + return NextResponse.json({ data }) + }, + { requireWrite: true }, +) diff --git a/app/api/reconciliation/bank/run/__tests__/route.test.ts b/app/api/reconciliation/bank/run/__tests__/route.test.ts new file mode 100644 index 00000000..526ff36a --- /dev/null +++ b/app/api/reconciliation/bank/run/__tests__/route.test.ts @@ -0,0 +1,140 @@ +/** + * Tests for POST /api/reconciliation/bank/run. + * + * Exercises the route through the real withRouteContext wrapper, mocking only + * its auth/company/write dependencies plus the runReconciliation service. + * Covers: 401, 403 viewer, unknown cash account (400), and the happy path. + */ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { NextResponse } from 'next/server' +import { createQueuedMockSupabase, createMockRequest, parseJsonResponse } from '@/tests/helpers' + +const { supabase, enqueue, reset } = createQueuedMockSupabase() + +const requireAuthMock = vi.fn() +vi.mock('@/lib/auth/require-auth', () => ({ + requireAuth: (...args: unknown[]) => requireAuthMock(...args), +})) + +vi.mock('@/lib/company/context', () => ({ + getActiveCompanyId: vi.fn().mockResolvedValue('company-1'), + requireCompanyId: vi.fn().mockResolvedValue('company-1'), +})) + +const requireWriteMock = vi.fn() +vi.mock('@/lib/auth/require-write', () => ({ + requireWritePermission: (...args: unknown[]) => requireWriteMock(...args), +})) + +vi.mock('@/lib/init', () => ({ ensureInitialized: vi.fn() })) + +const runReconciliationMock = vi.fn() +vi.mock('@/lib/reconciliation/bank-reconciliation', () => ({ + runReconciliation: (...args: unknown[]) => runReconciliationMock(...args), +})) + +import { POST } from '../route' + +const emptyParams = { params: Promise.resolve({}) } + +describe('POST /api/reconciliation/bank/run', () => { + beforeEach(() => { + vi.clearAllMocks() + reset() + requireAuthMock.mockResolvedValue({ user: { id: 'user-1' }, supabase }) + requireWriteMock.mockResolvedValue({ ok: true }) + runReconciliationMock.mockResolvedValue({ matches: [], applied: 0, errors: [] }) + }) + + it('returns 401 when unauthenticated', async () => { + requireAuthMock.mockResolvedValue({ + user: null, + supabase, + error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }), + }) + + const request = createMockRequest('/api/reconciliation/bank/run', { + method: 'POST', + body: { dry_run: true }, + }) + + const response = await POST(request, emptyParams) + expect(response.status).toBe(401) + }) + + it('returns 403 for a viewer', async () => { + requireWriteMock.mockResolvedValue({ + ok: false, + response: NextResponse.json({ error: 'Forbidden' }, { status: 403 }), + }) + + const request = createMockRequest('/api/reconciliation/bank/run', { + method: 'POST', + body: { dry_run: true }, + }) + + const response = await POST(request, emptyParams) + expect(response.status).toBe(403) + }) + + it('rejects a non-default account with no cash_accounts row', async () => { + // cash_accounts lookup finds nothing for 1932. + enqueue({ data: null }) + + const request = createMockRequest('/api/reconciliation/bank/run', { + method: 'POST', + body: { account_number: '1932', dry_run: true }, + }) + + const response = await POST(request, emptyParams) + const { status, body } = await parseJsonResponse<{ error: string }>(response) + + expect(status).toBe(400) + expect(body.error).toBe('Okänt kassakonto för det här företaget') + expect(runReconciliationMock).not.toHaveBeenCalled() + }) + + it('runs reconciliation on the default 1930 account even without a cash_accounts row', async () => { + // cash_accounts lookup: no row, but '1930' is exempt. + enqueue({ data: null }) + runReconciliationMock.mockResolvedValue({ + matches: [ + { + transaction: { id: 't-1', date: '2024-06-15', description: 'Betalning', amount: 1250 }, + glLine: { + journal_entry_id: 'je-1', + voucher_number: 12, + voucher_series: 'A', + entry_date: '2024-06-15', + entry_description: 'Kundfaktura', + }, + method: 'exact', + confidence: 1, + }, + ], + applied: 1, + errors: [], + }) + + const request = createMockRequest('/api/reconciliation/bank/run', { + method: 'POST', + body: { date_from: '2024-06-01', date_to: '2024-06-30' }, + }) + + const response = await POST(request, emptyParams) + const { status, body } = await parseJsonResponse<{ + data: { matches: { transaction_id: string }[]; applied: number; dry_run: boolean } + }>(response) + + expect(status).toBe(200) + expect(body.data.applied).toBe(1) + expect(body.data.dry_run).toBe(false) + expect(body.data.matches[0].transaction_id).toBe('t-1') + expect(runReconciliationMock).toHaveBeenCalledWith( + supabase, + 'company-1', + 'user-1', + expect.objectContaining({ accountNumber: '1930', currency: 'SEK', dryRun: false }), + ) + }) +}) diff --git a/app/api/reconciliation/bank/run/route.ts b/app/api/reconciliation/bank/run/route.ts index 1a6c2c78..8af01ba1 100644 --- a/app/api/reconciliation/bank/run/route.ts +++ b/app/api/reconciliation/bank/run/route.ts @@ -1,88 +1,78 @@ -import { createClient } from '@/lib/supabase/server' import { NextResponse } from 'next/server' import { ensureInitialized } from '@/lib/init' +import { withRouteContext } from '@/lib/api/with-route-context' import { runReconciliation } from '@/lib/reconciliation/bank-reconciliation' import { validateBody } from '@/lib/api/validate' import { RunReconciliationSchema } from '@/lib/api/schemas' -import { requireCompanyId } from '@/lib/company/context' -import { requireWritePermission } from '@/lib/auth/require-write' ensureInitialized() -export async function POST(request: Request) { - const supabase = await createClient() - const { data: { user } } = await supabase.auth.getUser() +export const POST = withRouteContext( + 'reconciliation.bank.run', + async (request, { supabase, user, companyId }) => { + const validation = await validateBody(request, RunReconciliationSchema) + if (!validation.success) return validation.response + const { date_from, date_to, account_number, dry_run, selected_matches } = validation.data - if (!user) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } + const accountNumber = account_number ?? '1930' - const writeCheck = await requireWritePermission(supabase, user.id) - if (!writeCheck.ok) return writeCheck.response + // Defense-in-depth: reject a non-default account the company hasn't + // registered as a cash account. The default '1930' is exempt: when no + // cash_accounts row exists it falls back to currency-only scoping + // (cashAccountId undefined), so a company reconciling its primary SEK account + // without a row behaves exactly as before this feature. Matches the status + // endpoint, which is likewise lenient for '1930'. + const { data: cashAccount } = await supabase + .from('cash_accounts') + .select('id, currency, is_primary') + .eq('company_id', companyId) + .eq('ledger_account', accountNumber) + .maybeSingle() - const companyId = await requireCompanyId(supabase, user.id) + if (!cashAccount && accountNumber !== '1930') { + return NextResponse.json( + { error: 'Okänt kassakonto för det här företaget' }, + { status: 400 }, + ) + } + const currency = (cashAccount?.currency as string | undefined) ?? 'SEK' - const validation = await validateBody(request, RunReconciliationSchema) - if (!validation.success) return validation.response - const { date_from, date_to, account_number, dry_run, selected_matches } = validation.data - - const accountNumber = account_number ?? '1930' - - // Defense-in-depth: reject a non-default account the company hasn't - // registered as a cash account. The default '1930' is exempt: when no - // cash_accounts row exists it falls back to currency-only scoping - // (cashAccountId undefined), so a company reconciling its primary SEK account - // without a row behaves exactly as before this feature. Matches the status - // endpoint, which is likewise lenient for '1930'. - const { data: cashAccount } = await supabase - .from('cash_accounts') - .select('id, currency, is_primary') - .eq('company_id', companyId) - .eq('ledger_account', accountNumber) - .maybeSingle() - - if (!cashAccount && accountNumber !== '1930') { - return NextResponse.json( - { error: 'Okänt kassakonto för det här företaget' }, - { status: 400 }, - ) - } - const currency = (cashAccount?.currency as string | undefined) ?? 'SEK' - - const result = await runReconciliation(supabase, companyId, user.id, { - dateFrom: date_from, - dateTo: date_to, - accountNumber, - currency, - cashAccountId: cashAccount?.id as string | undefined, - // Only the primary account claims unassigned (NULL cash_account_id) rows: - // a secondary same-currency account must scope strictly to its own id. - includeUnassigned: Boolean(cashAccount?.is_primary), - dryRun: dry_run ?? false, - applyOnly: selected_matches?.map((m) => ({ - transactionId: m.transaction_id, - journalEntryId: m.journal_entry_id, - })), - }) - - return NextResponse.json({ - data: { - matches: result.matches.map((m) => ({ - transaction_id: m.transaction.id, - transaction_date: m.transaction.date, - transaction_description: m.transaction.description, - transaction_amount: m.transaction.amount, - journal_entry_id: m.glLine.journal_entry_id, - voucher_number: m.glLine.voucher_number, - voucher_series: m.glLine.voucher_series, - entry_date: m.glLine.entry_date, - entry_description: m.glLine.entry_description, - method: m.method, - confidence: m.confidence, + const result = await runReconciliation(supabase, companyId, user.id, { + dateFrom: date_from, + dateTo: date_to, + accountNumber, + currency, + cashAccountId: cashAccount?.id as string | undefined, + // Only the primary account claims unassigned (NULL cash_account_id) rows: + // a secondary same-currency account must scope strictly to its own id. + includeUnassigned: Boolean(cashAccount?.is_primary), + dryRun: dry_run ?? false, + applyOnly: selected_matches?.map((m) => ({ + transactionId: m.transaction_id, + journalEntryId: m.journal_entry_id, })), - applied: result.applied, - errors: result.errors, - dry_run: dry_run ?? false, - }, - }) -} + }) + + return NextResponse.json({ + data: { + matches: result.matches.map((m) => ({ + transaction_id: m.transaction.id, + transaction_date: m.transaction.date, + transaction_description: m.transaction.description, + transaction_amount: m.transaction.amount, + journal_entry_id: m.glLine.journal_entry_id, + voucher_number: m.glLine.voucher_number, + voucher_series: m.glLine.voucher_series, + entry_date: m.glLine.entry_date, + entry_description: m.glLine.entry_description, + method: m.method, + confidence: m.confidence, + })), + applied: result.applied, + errors: result.errors, + dry_run: dry_run ?? false, + }, + }) + }, + { requireWrite: true }, +) diff --git a/app/api/reconciliation/bank/status/route.ts b/app/api/reconciliation/bank/status/route.ts index 5be29ad7..ce7c9285 100644 --- a/app/api/reconciliation/bank/status/route.ts +++ b/app/api/reconciliation/bank/status/route.ts @@ -1,57 +1,50 @@ -import { createClient } from '@/lib/supabase/server' import { NextResponse } from 'next/server' +import { withRouteContext } from '@/lib/api/with-route-context' import { getReconciliationStatus } from '@/lib/reconciliation/bank-reconciliation' -import { requireCompanyId } from '@/lib/company/context' -export async function GET(request: Request) { - const supabase = await createClient() - const { data: { user } } = await supabase.auth.getUser() +export const GET = withRouteContext( + 'reconciliation.bank.status', + async (request, { supabase, companyId }) => { + const { searchParams } = new URL(request.url) + const dateFrom = searchParams.get('date_from') || undefined + const dateTo = searchParams.get('date_to') || undefined + const accountNumber = searchParams.get('account_number') || '1930' - if (!user) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } + // Look up the cash account so we can pair the bank account with the right + // currency. Comparing EUR GL movements against SEK transactions silently + // produces nonsense. + const { data: cashAccount } = await supabase + .from('cash_accounts') + .select('id, currency, is_primary') + .eq('company_id', companyId) + .eq('ledger_account', accountNumber) + .maybeSingle() - const companyId = await requireCompanyId(supabase, user.id) + if (!cashAccount && accountNumber !== '1930') { + return NextResponse.json( + { error: 'Okänt kassakonto för det här företaget' }, + { status: 400 }, + ) + } - const { searchParams } = new URL(request.url) - const dateFrom = searchParams.get('date_from') || undefined - const dateTo = searchParams.get('date_to') || undefined - const accountNumber = searchParams.get('account_number') || '1930' + const currency = (cashAccount?.currency as string | undefined) ?? 'SEK' + const cashAccountId = cashAccount?.id as string | undefined + // Only the primary account claims unassigned (NULL cash_account_id) rows. + // A secondary same-currency account (e.g. a 1931 savings account) must not, or + // 1930's unassigned rows inflate its bank total and show a bogus difference. + const includeUnassigned = Boolean(cashAccount?.is_primary) - // Look up the cash account so we can pair the bank account with the right - // currency. Comparing EUR GL movements against SEK transactions silently - // produces nonsense. - const { data: cashAccount } = await supabase - .from('cash_accounts') - .select('id, currency, is_primary') - .eq('company_id', companyId) - .eq('ledger_account', accountNumber) - .maybeSingle() - - if (!cashAccount && accountNumber !== '1930') { - return NextResponse.json( - { error: 'Okänt kassakonto för det här företaget' }, - { status: 400 }, + const status = await getReconciliationStatus( + supabase, + companyId, + dateFrom, + dateTo, + accountNumber, + currency, + cashAccountId, + includeUnassigned, ) - } - const currency = (cashAccount?.currency as string | undefined) ?? 'SEK' - const cashAccountId = cashAccount?.id as string | undefined - // Only the primary account claims unassigned (NULL cash_account_id) rows. - // A secondary same-currency account (e.g. a 1931 savings account) must not, or - // 1930's unassigned rows inflate its bank total and show a bogus difference. - const includeUnassigned = Boolean(cashAccount?.is_primary) - - const status = await getReconciliationStatus( - supabase, - companyId, - dateFrom, - dateTo, - accountNumber, - currency, - cashAccountId, - includeUnassigned, - ) - - return NextResponse.json({ data: status }) -} + return NextResponse.json({ data: status }) + }, +) diff --git a/app/api/reconciliation/bank/unlink/__tests__/route.test.ts b/app/api/reconciliation/bank/unlink/__tests__/route.test.ts new file mode 100644 index 00000000..bee120f8 --- /dev/null +++ b/app/api/reconciliation/bank/unlink/__tests__/route.test.ts @@ -0,0 +1,122 @@ +/** + * Tests for POST /api/reconciliation/bank/unlink. + * + * Exercises the route through the real withRouteContext wrapper, mocking only + * its auth/company/write dependencies plus the unlinkReconciliation service. + * Covers: 401, 403 viewer, validation (400), service failure (400), and the + * happy path. + */ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { NextResponse } from 'next/server' +import { createQueuedMockSupabase, createMockRequest, parseJsonResponse } from '@/tests/helpers' + +const { supabase, reset } = createQueuedMockSupabase() + +const requireAuthMock = vi.fn() +vi.mock('@/lib/auth/require-auth', () => ({ + requireAuth: (...args: unknown[]) => requireAuthMock(...args), +})) + +vi.mock('@/lib/company/context', () => ({ + getActiveCompanyId: vi.fn().mockResolvedValue('company-1'), + requireCompanyId: vi.fn().mockResolvedValue('company-1'), +})) + +const requireWriteMock = vi.fn() +vi.mock('@/lib/auth/require-write', () => ({ + requireWritePermission: (...args: unknown[]) => requireWriteMock(...args), +})) + +const unlinkMock = vi.fn() +vi.mock('@/lib/reconciliation/bank-reconciliation', () => ({ + unlinkReconciliation: (...args: unknown[]) => unlinkMock(...args), +})) + +import { POST } from '../route' + +const emptyParams = { params: Promise.resolve({}) } +const TX_ID = '44444444-4444-4444-8444-444444444444' + +describe('POST /api/reconciliation/bank/unlink', () => { + beforeEach(() => { + vi.clearAllMocks() + reset() + requireAuthMock.mockResolvedValue({ user: { id: 'user-1' }, supabase }) + requireWriteMock.mockResolvedValue({ ok: true }) + unlinkMock.mockResolvedValue({ success: true }) + }) + + it('returns 401 when unauthenticated', async () => { + requireAuthMock.mockResolvedValue({ + user: null, + supabase, + error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }), + }) + + const request = createMockRequest('/api/reconciliation/bank/unlink', { + method: 'POST', + body: { transaction_id: TX_ID }, + }) + + const response = await POST(request, emptyParams) + expect(response.status).toBe(401) + }) + + it('returns 403 for a viewer', async () => { + requireWriteMock.mockResolvedValue({ + ok: false, + response: NextResponse.json({ error: 'Forbidden' }, { status: 403 }), + }) + + const request = createMockRequest('/api/reconciliation/bank/unlink', { + method: 'POST', + body: { transaction_id: TX_ID }, + }) + + const response = await POST(request, emptyParams) + expect(response.status).toBe(403) + }) + + it('rejects a non-uuid transaction_id with 400', async () => { + const request = createMockRequest('/api/reconciliation/bank/unlink', { + method: 'POST', + body: { transaction_id: 'not-a-uuid' }, + }) + + const response = await POST(request, emptyParams) + expect(response.status).toBe(400) + expect(unlinkMock).not.toHaveBeenCalled() + }) + + it('surfaces an unlink failure as 400 with the service error', async () => { + unlinkMock.mockResolvedValue({ + success: false, + error: 'Cannot unlink a categorization-created entry. Use storno to reverse it instead.', + }) + + const request = createMockRequest('/api/reconciliation/bank/unlink', { + method: 'POST', + body: { transaction_id: TX_ID }, + }) + + const response = await POST(request, emptyParams) + const { status, body } = await parseJsonResponse<{ error: string }>(response) + + expect(status).toBe(400) + expect(body.error).toContain('storno') + }) + + it('unlinks the transaction', async () => { + const request = createMockRequest('/api/reconciliation/bank/unlink', { + method: 'POST', + body: { transaction_id: TX_ID }, + }) + + const response = await POST(request, emptyParams) + const { status, body } = await parseJsonResponse<{ data: { success: boolean } }>(response) + + expect(status).toBe(200) + expect(body.data.success).toBe(true) + expect(unlinkMock).toHaveBeenCalledWith(supabase, 'company-1', TX_ID, 'user-1') + }) +}) diff --git a/app/api/reconciliation/bank/unlink/route.ts b/app/api/reconciliation/bank/unlink/route.ts index dde836a9..9401bf80 100644 --- a/app/api/reconciliation/bank/unlink/route.ts +++ b/app/api/reconciliation/bank/unlink/route.ts @@ -1,33 +1,23 @@ -import { createClient } from '@/lib/supabase/server' import { NextResponse } from 'next/server' +import { withRouteContext } from '@/lib/api/with-route-context' import { unlinkReconciliation } from '@/lib/reconciliation/bank-reconciliation' import { validateBody } from '@/lib/api/validate' import { BankUnlinkSchema } from '@/lib/api/schemas' -import { requireCompanyId } from '@/lib/company/context' -import { requireWritePermission } from '@/lib/auth/require-write' -export async function POST(request: Request) { - const supabase = await createClient() - const { data: { user } } = await supabase.auth.getUser() +export const POST = withRouteContext( + 'reconciliation.bank.unlink', + async (request, { supabase, user, companyId }) => { + const validation = await validateBody(request, BankUnlinkSchema) + if (!validation.success) return validation.response + const { transaction_id } = validation.data - if (!user) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } + const result = await unlinkReconciliation(supabase, companyId, transaction_id, user.id) - const writeCheck = await requireWritePermission(supabase, user.id) - if (!writeCheck.ok) return writeCheck.response + if (!result.success) { + return NextResponse.json({ error: result.error }, { status: 400 }) + } - const companyId = await requireCompanyId(supabase, user.id) - - const validation = await validateBody(request, BankUnlinkSchema) - if (!validation.success) return validation.response - const { transaction_id } = validation.data - - const result = await unlinkReconciliation(supabase, companyId, transaction_id, user.id) - - if (!result.success) { - return NextResponse.json({ error: result.error }, { status: 400 }) - } - - return NextResponse.json({ data: { success: true } }) -} + return NextResponse.json({ data: { success: true } }) + }, + { requireWrite: true }, +) diff --git a/app/api/reconciliation/bank/unmatched-entries/route.ts b/app/api/reconciliation/bank/unmatched-entries/route.ts index c46a1fa3..f595394f 100644 --- a/app/api/reconciliation/bank/unmatched-entries/route.ts +++ b/app/api/reconciliation/bank/unmatched-entries/route.ts @@ -1,93 +1,86 @@ -import { createClient } from '@/lib/supabase/server' import { NextResponse } from 'next/server' +import { withRouteContext } from '@/lib/api/with-route-context' import { fetchGLLinesForMatching, tryReconcileTransaction } from '@/lib/reconciliation/bank-reconciliation' -import { requireCompanyId } from '@/lib/company/context' import type { Transaction } from '@/types' -export async function GET(request: Request) { - const supabase = await createClient() - const { data: { user } } = await supabase.auth.getUser() +export const GET = withRouteContext( + 'reconciliation.bank.unmatched_entries', + async (request, { supabase, companyId }) => { + const { searchParams } = new URL(request.url) + const dateFrom = searchParams.get('date_from') || undefined + const dateTo = searchParams.get('date_to') || undefined + const accountNumber = searchParams.get('account_number') || '1930' + // Optional: when set, rank the returned candidates for this specific bank + // transaction (used by the Transactions-page "Matcha mot befintlig + // verifikation" dialog). Ranking happens server-side on purpose: + // lib/reconciliation/bank-reconciliation pulls in server-only deps (event + // bus, match-log) and must never reach the client bundle. + const transactionId = searchParams.get('transaction_id') || undefined + // When true, also return vouchers already matched to a bank transaction (each + // carries linked_transaction_count) so the user can attach a second/third + // transaction to the same verifikat, the N:1 "lägga på flera" case. Default + // false keeps the list to unmatched candidates only. + const includeMatched = searchParams.get('include_matched') === 'true' - if (!user) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const companyId = await requireCompanyId(supabase, user.id) - - const { searchParams } = new URL(request.url) - const dateFrom = searchParams.get('date_from') || undefined - const dateTo = searchParams.get('date_to') || undefined - const accountNumber = searchParams.get('account_number') || '1930' - // Optional: when set, rank the returned candidates for this specific bank - // transaction (used by the Transactions-page "Matcha mot befintlig - // verifikation" dialog). Ranking happens server-side on purpose: - // lib/reconciliation/bank-reconciliation pulls in server-only deps (event - // bus, match-log) and must never reach the client bundle. - const transactionId = searchParams.get('transaction_id') || undefined - // When true, also return vouchers already matched to a bank transaction (each - // carries linked_transaction_count) so the user can attach a second/third - // transaction to the same verifikat, the N:1 "lägga på flera" case. Default - // false keeps the list to unmatched candidates only. - const includeMatched = searchParams.get('include_matched') === 'true' - - // Defense-in-depth: only allow account numbers that the company has actually - // registered as a cash account. Without this, a curious caller could probe - // arbitrary GL accounts for posted-but-unmatched amounts. Applies uniformly - // including '1930': the cash_accounts backfill seeds 1930 for every company - // that had a SEK PSD2 account, and the AccountPickerDialog seeds it for new - // companies on first connection. - const { data: cashAccount } = await supabase - .from('cash_accounts') - .select('id') - .eq('company_id', companyId) - .eq('ledger_account', accountNumber) - .maybeSingle() - - if (!cashAccount) { - return NextResponse.json( - { error: 'Okänt kassakonto för det här företaget' }, - { status: 400 }, - ) - } - - const lines = await fetchGLLinesForMatching(supabase, companyId, accountNumber, dateFrom, dateTo, includeMatched) - - if (transactionId) { - // company-scoped fetch (defense-in-depth). A malformed/foreign id yields no - // row → we fall through to the unranked list rather than erroring. - const { data: tx } = await supabase - .from('transactions') - .select('id, amount, date, currency, reference') - .eq('id', transactionId) + // Defense-in-depth: only allow account numbers that the company has actually + // registered as a cash account. Without this, a curious caller could probe + // arbitrary GL accounts for posted-but-unmatched amounts. Applies uniformly + // including '1930': the cash_accounts backfill seeds 1930 for every company + // that had a SEK PSD2 account, and the AccountPickerDialog seeds it for new + // companies on first connection. + const { data: cashAccount } = await supabase + .from('cash_accounts') + .select('id') .eq('company_id', companyId) + .eq('ledger_account', accountNumber) .maybeSingle() - if (!tx) { - // transaction_id was supplied but doesn't resolve to a row in the - // caller's company: the ranking context is invalid. Return no candidates - // rather than silently falling back to the full unranked list, so a - // fabricated or foreign id can never yield a broader result set. - return NextResponse.json({ data: [] }) + if (!cashAccount) { + return NextResponse.json( + { error: 'Okänt kassakonto för det här företaget' }, + { status: 400 }, + ) } - const txCurrency = (tx.currency as string | null) ?? 'SEK' - const txDate = tx.date as string - const ranked = lines - .map((line) => { - // Score each line in isolation; confidence 0 means "no auto-match - // rule fired": the line still appears so the user can pick it - // manually (e.g. a salary or Fortnox voucher with a tweaked date). - const match = tryReconcileTransaction(tx as unknown as Transaction, [line], txCurrency) - return { ...line, confidence: match?.confidence ?? 0 } - }) - .sort((a, b) => { - if (b.confidence !== a.confidence) return b.confidence - a.confidence - const da = Math.abs(new Date(a.entry_date).getTime() - new Date(txDate).getTime()) - const db = Math.abs(new Date(b.entry_date).getTime() - new Date(txDate).getTime()) - return da - db - }) - return NextResponse.json({ data: ranked }) - } + const lines = await fetchGLLinesForMatching(supabase, companyId, accountNumber, dateFrom, dateTo, includeMatched) - return NextResponse.json({ data: lines }) -} + if (transactionId) { + // company-scoped fetch (defense-in-depth). A malformed/foreign id yields no + // row → we fall through to the unranked list rather than erroring. + const { data: tx } = await supabase + .from('transactions') + .select('id, amount, date, currency, reference') + .eq('id', transactionId) + .eq('company_id', companyId) + .maybeSingle() + + if (!tx) { + // transaction_id was supplied but doesn't resolve to a row in the + // caller's company: the ranking context is invalid. Return no candidates + // rather than silently falling back to the full unranked list, so a + // fabricated or foreign id can never yield a broader result set. + return NextResponse.json({ data: [] }) + } + + const txCurrency = (tx.currency as string | null) ?? 'SEK' + const txDate = tx.date as string + const ranked = lines + .map((line) => { + // Score each line in isolation; confidence 0 means "no auto-match + // rule fired": the line still appears so the user can pick it + // manually (e.g. a salary or Fortnox voucher with a tweaked date). + const match = tryReconcileTransaction(tx as unknown as Transaction, [line], txCurrency) + return { ...line, confidence: match?.confidence ?? 0 } + }) + .sort((a, b) => { + if (b.confidence !== a.confidence) return b.confidence - a.confidence + const da = Math.abs(new Date(a.entry_date).getTime() - new Date(txDate).getTime()) + const db = Math.abs(new Date(b.entry_date).getTime() - new Date(txDate).getTime()) + return da - db + }) + return NextResponse.json({ data: ranked }) + } + + return NextResponse.json({ data: lines }) + }, +) diff --git a/app/api/reports/ar-ledger/customer/[customerId]/invoices/__tests__/route.test.ts b/app/api/reports/ar-ledger/customer/[customerId]/invoices/__tests__/route.test.ts index f3af3850..e1e398bd 100644 --- a/app/api/reports/ar-ledger/customer/[customerId]/invoices/__tests__/route.test.ts +++ b/app/api/reports/ar-ledger/customer/[customerId]/invoices/__tests__/route.test.ts @@ -1,11 +1,14 @@ import { describe, it, expect, vi, beforeEach } from 'vitest' +import { NextResponse } from 'next/server' import { createMockRequest, createMockRouteParams } from '@/tests/helpers' -vi.mock('@/lib/supabase/server', () => ({ - createClient: vi.fn(), +const requireAuthMock = vi.fn() +vi.mock('@/lib/auth/require-auth', () => ({ + requireAuth: (...args: unknown[]) => requireAuthMock(...args), })) vi.mock('@/lib/company/context', () => ({ + getActiveCompanyId: vi.fn().mockResolvedValue('company-1'), requireCompanyId: vi.fn().mockResolvedValue('company-1'), })) @@ -13,28 +16,19 @@ vi.mock('@/lib/bookkeeping/currency-utils', () => ({ resolveSekAmount: vi.fn((amount: number) => amount), })) -import { createClient } from '@/lib/supabase/server' import { GET } from '../route' -const mockCreateClient = vi.mocked(createClient) - interface QueryResult { data: unknown error: unknown } function buildSupabase( - user: { id: string } | null, customer: { id: string; name: string } | null, invoicesResult: QueryResult, entriesResult: QueryResult ) { - let invoiceCallNum = 0 - let entryCallNum = 0 return { - auth: { - getUser: vi.fn().mockResolvedValue({ data: { user } }), - }, from: vi.fn().mockImplementation((table: string) => { if (table === 'customers') { return { @@ -44,7 +38,6 @@ function buildSupabase( } } if (table === 'invoices') { - invoiceCallNum += 1 return { select: vi.fn().mockReturnThis(), eq: vi.fn().mockReturnThis(), @@ -55,7 +48,6 @@ function buildSupabase( } } // journal_entries - entryCallNum += 1 return { select: vi.fn().mockReturnThis(), eq: vi.fn().mockReturnThis(), @@ -63,19 +55,28 @@ function buildSupabase( then: (resolve: (v: QueryResult) => void) => resolve(entriesResult), } }), - _stats: () => ({ invoiceCallNum, entryCallNum }), } } +function authWith(supabase: unknown) { + requireAuthMock.mockResolvedValue({ user: { id: 'user-1' }, supabase, error: null }) +} + +function unauthed() { + requireAuthMock.mockResolvedValue({ + user: null, + supabase: {}, + error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }), + }) +} + beforeEach(() => { vi.clearAllMocks() }) describe('GET /api/reports/ar-ledger/customer/[customerId]/invoices', () => { it('returns 401 when not authenticated', async () => { - mockCreateClient.mockResolvedValue( - buildSupabase(null, null, { data: [], error: null }, { data: [], error: null }) as never - ) + unauthed() const req = createMockRequest( '/api/reports/ar-ledger/customer/cust-1/invoices' ) @@ -84,8 +85,8 @@ describe('GET /api/reports/ar-ledger/customer/[customerId]/invoices', () => { }) it('returns 404 when customer is unknown', async () => { - mockCreateClient.mockResolvedValue( - buildSupabase({ id: 'user-1' }, null, { data: [], error: null }, { data: [], error: null }) as never + authWith( + buildSupabase(null, { data: [], error: null }, { data: [], error: null }) ) const req = createMockRequest( '/api/reports/ar-ledger/customer/cust-1/invoices' @@ -118,13 +119,12 @@ describe('GET /api/reports/ar-ledger/customer/[customerId]/invoices', () => { source_id: 'inv-1', }, ] - mockCreateClient.mockResolvedValue( + authWith( buildSupabase( - { id: 'user-1' }, { id: 'cust-1', name: 'Acme AB' }, { data: invoices, error: null }, { data: entries, error: null } - ) as never + ) ) const req = createMockRequest( '/api/reports/ar-ledger/customer/cust-1/invoices' diff --git a/app/api/reports/ar-ledger/customer/[customerId]/invoices/route.ts b/app/api/reports/ar-ledger/customer/[customerId]/invoices/route.ts index ccb166a2..c2ae075e 100644 --- a/app/api/reports/ar-ledger/customer/[customerId]/invoices/route.ts +++ b/app/api/reports/ar-ledger/customer/[customerId]/invoices/route.ts @@ -1,6 +1,5 @@ -import { createClient } from '@/lib/supabase/server' import { NextResponse } from 'next/server' -import { requireCompanyId } from '@/lib/company/context' +import { withRouteContext } from '@/lib/api/with-route-context' import { resolveSekAmount } from '@/lib/bookkeeping/currency-utils' import type { ReportSourceLine } from '@/lib/reports/source-lines' @@ -16,18 +15,9 @@ import type { ReportSourceLine } from '@/lib/reports/source-lines' */ const PAGE_LIMIT = 500 -export async function GET( - request: Request, - { params }: { params: Promise<{ customerId: string }> } -) { - const supabase = await createClient() - const { data: { user } } = await supabase.auth.getUser() - - if (!user) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const companyId = await requireCompanyId(supabase, user.id) +export const GET = withRouteContext<{ params: Promise<{ customerId: string }> }>( + 'report.ar_ledger.customer_invoices', + async (request, { supabase, companyId }, { params }) => { const { customerId } = await params // Verify customer belongs to the company. @@ -149,4 +139,4 @@ export async function GET( next_cursor: null, }, }) -} +}) diff --git a/app/api/reports/ar-ledger/route.ts b/app/api/reports/ar-ledger/route.ts index de1492f4..bf783987 100644 --- a/app/api/reports/ar-ledger/route.ts +++ b/app/api/reports/ar-ledger/route.ts @@ -1,20 +1,9 @@ -import { createClient } from '@/lib/supabase/server' import { NextResponse } from 'next/server' import { generateARLedger } from '@/lib/reports/ar-ledger' import { generateARReconciliation } from '@/lib/reports/ar-reconciliation' -import { requireCompanyId } from '@/lib/company/context' - -export async function GET(request: Request) { - const supabase = await createClient() - - const { data: { user } } = await supabase.auth.getUser() - - if (!user) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const companyId = await requireCompanyId(supabase, user.id) +import { withRouteContext } from '@/lib/api/with-route-context' +export const GET = withRouteContext('report.ar_ledger', async (request, { supabase, companyId }) => { const { searchParams } = new URL(request.url) const asOfDate = searchParams.get('as_of_date') || undefined const periodId = searchParams.get('period_id') || undefined @@ -32,4 +21,4 @@ export async function GET(request: Request) { reconciliation, }, }) -} +}) diff --git a/app/api/reports/ar-ledger/xlsx/route.ts b/app/api/reports/ar-ledger/xlsx/route.ts index 49f67a24..6df52578 100644 --- a/app/api/reports/ar-ledger/xlsx/route.ts +++ b/app/api/reports/ar-ledger/xlsx/route.ts @@ -1,7 +1,6 @@ -import { createClient } from '@/lib/supabase/server' import { NextResponse } from 'next/server' import { generateARLedger } from '@/lib/reports/ar-ledger' -import { requireCompanyId } from '@/lib/company/context' +import { withRouteContext } from '@/lib/api/with-route-context' import { reportToWorkbook, textColumn, @@ -40,16 +39,7 @@ function toDate(s: string): Date | null { return isNaN(d.getTime()) ? null : d } -export async function GET(request: Request) { - const supabase = await createClient() - const { data: { user } } = await supabase.auth.getUser() - - if (!user) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const companyId = await requireCompanyId(supabase, user.id) - +export const GET = withRouteContext('report.ar_ledger.xlsx', async (request, { supabase, companyId }) => { const { searchParams } = new URL(request.url) const asOfDate = searchParams.get('as_of_date') || undefined @@ -160,4 +150,4 @@ export async function GET(request: Request) { { status: 500 } ) } -} +}) diff --git a/app/api/reports/audit-trail/__tests__/route.test.ts b/app/api/reports/audit-trail/__tests__/route.test.ts index 675b6521..3fc1058c 100644 --- a/app/api/reports/audit-trail/__tests__/route.test.ts +++ b/app/api/reports/audit-trail/__tests__/route.test.ts @@ -1,35 +1,38 @@ import { describe, it, expect, vi, beforeEach } from 'vitest' -import { createMockRequest } from '@/tests/helpers' +import { NextResponse } from 'next/server' +import { createQueuedMockSupabase, createMockRequest } from '@/tests/helpers' -vi.mock('@/lib/supabase/server', () => ({ - createClient: vi.fn(), +const { supabase } = createQueuedMockSupabase() + +const requireAuthMock = vi.fn() +vi.mock('@/lib/auth/require-auth', () => ({ + requireAuth: (...args: unknown[]) => requireAuthMock(...args), +})) + +vi.mock('@/lib/company/context', () => ({ + getActiveCompanyId: vi.fn().mockResolvedValue('company-1'), + requireCompanyId: vi.fn().mockResolvedValue('company-1'), })) vi.mock('@/lib/core/audit/audit-service', () => ({ getAuditLog: vi.fn(), })) -vi.mock('@/lib/company/context', () => ({ - requireCompanyId: vi.fn().mockResolvedValue('company-1'), - getActiveCompanyId: vi.fn().mockResolvedValue('company-1'), -})) - -import { createClient } from '@/lib/supabase/server' import { getAuditLog } from '@/lib/core/audit/audit-service' import { GET } from '../route' -const mockCreateClient = vi.mocked(createClient) const mockGetAuditLog = vi.mocked(getAuditLog) -function mockAuth(userId: string | null) { - mockCreateClient.mockResolvedValue({ - auth: { - getUser: vi.fn().mockResolvedValue({ - data: { user: userId ? { id: userId } : null }, - }), - }, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - } as any) +function authed() { + requireAuthMock.mockResolvedValue({ user: { id: 'user-1' }, supabase, error: null }) +} + +function unauthed() { + requireAuthMock.mockResolvedValue({ + user: null, + supabase, + error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }), + }) } const sampleEntries = [ @@ -61,18 +64,18 @@ const sampleEntries = [ beforeEach(() => { vi.clearAllMocks() + authed() }) describe('GET /api/reports/audit-trail', () => { it('returns 401 when not authenticated', async () => { - mockAuth(null) + unauthed() const req = createMockRequest('/api/reports/audit-trail') const res = await GET(req) expect(res.status).toBe(401) }) it('returns CSV format with correct headers', async () => { - mockAuth('user-1') // eslint-disable-next-line @typescript-eslint/no-explicit-any mockGetAuditLog.mockResolvedValue({ data: sampleEntries as any, count: 2 }) @@ -94,7 +97,6 @@ describe('GET /api/reports/audit-trail', () => { }) it('returns JSON format as downloadable file', async () => { - mockAuth('user-1') // eslint-disable-next-line @typescript-eslint/no-explicit-any mockGetAuditLog.mockResolvedValue({ data: sampleEntries as any, count: 2 }) @@ -113,8 +115,6 @@ describe('GET /api/reports/audit-trail', () => { }) it('paginates through all entries', async () => { - mockAuth('user-1') - // First call returns 500 entries (full page), second returns 100 (last page) const bigPage = Array.from({ length: 500 }, (_, i) => ({ ...sampleEntries[0], diff --git a/app/api/reports/audit-trail/route.ts b/app/api/reports/audit-trail/route.ts index fe733535..905b1fa7 100644 --- a/app/api/reports/audit-trail/route.ts +++ b/app/api/reports/audit-trail/route.ts @@ -1,7 +1,6 @@ -import { createClient } from '@/lib/supabase/server' import { NextResponse } from 'next/server' import { getAuditLog } from '@/lib/core/audit/audit-service' -import { requireCompanyId } from '@/lib/company/context' +import { withRouteContext } from '@/lib/api/with-route-context' import type { AuditLogEntry, AuditAction } from '@/types' const CSV_HEADERS = 'timestamp,action,table_name,record_id,description,old_state,new_state' @@ -27,16 +26,7 @@ function entryToCSVRow(entry: AuditLogEntry): string { ].join(',') } -export async function GET(request: Request) { - const supabase = await createClient() - const { data: { user } } = await supabase.auth.getUser() - - if (!user) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const companyId = await requireCompanyId(supabase, user.id) - +export const GET = withRouteContext('report.audit_trail', async (request, { supabase, companyId }) => { const { searchParams } = new URL(request.url) const format = searchParams.get('format') || 'json' @@ -94,4 +84,4 @@ export async function GET(request: Request) { { status: 500 } ) } -} +}) diff --git a/app/api/reports/avgifter-basis/route.ts b/app/api/reports/avgifter-basis/route.ts index 903a8300..04bd0847 100644 --- a/app/api/reports/avgifter-basis/route.ts +++ b/app/api/reports/avgifter-basis/route.ts @@ -1,6 +1,5 @@ -import { createClient } from '@/lib/supabase/server' import { NextResponse } from 'next/server' -import { requireCompanyId } from '@/lib/company/context' +import { withRouteContext } from '@/lib/api/with-route-context' import { generateAvgifterBasis } from '@/lib/reports/avgifter-basis' /** @@ -8,13 +7,7 @@ import { generateAvgifterBasis } from '@/lib/reports/avgifter-basis' * Monthly breakdown by avgifter rate category for AGI reconciliation. * Per BFL: Part of räkenskapsinformation, 7-year retention. */ -export async function GET(request: Request) { - const supabase = await createClient() - const { data: { user } } = await supabase.auth.getUser() - if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - - const companyId = await requireCompanyId(supabase, user.id) - +export const GET = withRouteContext('report.avgifter_basis', async (request, { supabase, companyId }) => { const { searchParams } = new URL(request.url) const year = parseInt(searchParams.get('year') || new Date().getFullYear().toString()) @@ -25,4 +18,4 @@ export async function GET(request: Request) { const message = err instanceof Error ? err.message : 'Kunde inte generera avgiftsunderlag' return NextResponse.json({ error: message }, { status: 500 }) } -} +}) diff --git a/app/api/reports/balance-sheet/pdf/route.ts b/app/api/reports/balance-sheet/pdf/route.ts index a3a5fd47..32a8bcdb 100644 --- a/app/api/reports/balance-sheet/pdf/route.ts +++ b/app/api/reports/balance-sheet/pdf/route.ts @@ -1,22 +1,12 @@ -import { createClient } from '@/lib/supabase/server' import { NextResponse } from 'next/server' import { renderToBuffer } from '@react-pdf/renderer' import { generateBalanceSheet } from '@/lib/reports/balance-sheet' import { FinancialStatementPDF } from '@/lib/reports/financial-statement-pdf-template' -import { requireCompanyId } from '@/lib/company/context' +import { withRouteContext } from '@/lib/api/with-route-context' import { parseReportDateRange } from '@/lib/reports/date-range' import type { CompanySettings } from '@/types' -export async function GET(request: Request) { - const supabase = await createClient() - const { data: { user } } = await supabase.auth.getUser() - - if (!user) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const companyId = await requireCompanyId(supabase, user.id) - +export const GET = withRouteContext('report.balance_sheet.pdf', async (request, { supabase, companyId }) => { const { searchParams } = new URL(request.url) const periodId = searchParams.get('period_id') @@ -121,4 +111,4 @@ export async function GET(request: Request) { { status: 500 } ) } -} +}) diff --git a/app/api/reports/balance-sheet/xlsx/route.ts b/app/api/reports/balance-sheet/xlsx/route.ts index 22dcdc9b..d46d877d 100644 --- a/app/api/reports/balance-sheet/xlsx/route.ts +++ b/app/api/reports/balance-sheet/xlsx/route.ts @@ -1,7 +1,6 @@ -import { createClient } from '@/lib/supabase/server' import { NextResponse } from 'next/server' import { generateBalanceSheet } from '@/lib/reports/balance-sheet' -import { requireCompanyId } from '@/lib/company/context' +import { withRouteContext } from '@/lib/api/with-route-context' import { parseReportDateRange } from '@/lib/reports/date-range' import { reportToWorkbook, @@ -18,16 +17,7 @@ interface FlatRow { isSubtotal: boolean } -export async function GET(request: Request) { - const supabase = await createClient() - const { data: { user } } = await supabase.auth.getUser() - - if (!user) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const companyId = await requireCompanyId(supabase, user.id) - +export const GET = withRouteContext('report.balance_sheet.xlsx', async (request, { supabase, companyId }) => { const { searchParams } = new URL(request.url) const periodId = searchParams.get('period_id') @@ -158,4 +148,4 @@ export async function GET(request: Request) { { status: 500 } ) } -} +}) diff --git a/app/api/reports/balansrapport/pdf/route.ts b/app/api/reports/balansrapport/pdf/route.ts index 9b138f06..2d8d89c9 100644 --- a/app/api/reports/balansrapport/pdf/route.ts +++ b/app/api/reports/balansrapport/pdf/route.ts @@ -1,22 +1,12 @@ -import { createClient } from '@/lib/supabase/server' import { NextResponse } from 'next/server' import { renderToBuffer } from '@react-pdf/renderer' import { generateBalansrapport } from '@/lib/reports/balansrapport' import { BalansrapportPDF } from '@/lib/reports/operational-report-pdf-template' -import { requireCompanyId } from '@/lib/company/context' +import { withRouteContext } from '@/lib/api/with-route-context' import { parseReportDateRange } from '@/lib/reports/date-range' import type { CompanySettings } from '@/types' -export async function GET(request: Request) { - const supabase = await createClient() - const { data: { user } } = await supabase.auth.getUser() - - if (!user) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const companyId = await requireCompanyId(supabase, user.id) - +export const GET = withRouteContext('report.balansrapport.pdf', async (request, { supabase, companyId }) => { const { searchParams } = new URL(request.url) const periodId = searchParams.get('period_id') @@ -82,4 +72,4 @@ export async function GET(request: Request) { { status: 500 } ) } -} +}) diff --git a/app/api/reports/balansrapport/route.ts b/app/api/reports/balansrapport/route.ts index 3d850659..64f13e16 100644 --- a/app/api/reports/balansrapport/route.ts +++ b/app/api/reports/balansrapport/route.ts @@ -1,19 +1,9 @@ -import { createClient } from '@/lib/supabase/server' import { NextResponse } from 'next/server' import { generateBalansrapport } from '@/lib/reports/balansrapport' -import { requireCompanyId } from '@/lib/company/context' +import { withRouteContext } from '@/lib/api/with-route-context' import { parseReportDateRange } from '@/lib/reports/date-range' -export async function GET(request: Request) { - const supabase = await createClient() - const { data: { user } } = await supabase.auth.getUser() - - if (!user) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const companyId = await requireCompanyId(supabase, user.id) - +export const GET = withRouteContext('report.balansrapport', async (request, { supabase, companyId }) => { const { searchParams } = new URL(request.url) const periodId = searchParams.get('period_id') @@ -46,4 +36,4 @@ export async function GET(request: Request) { { status: 500 } ) } -} +}) diff --git a/app/api/reports/balansrapport/xlsx/route.ts b/app/api/reports/balansrapport/xlsx/route.ts index fe891859..ef38e223 100644 --- a/app/api/reports/balansrapport/xlsx/route.ts +++ b/app/api/reports/balansrapport/xlsx/route.ts @@ -1,7 +1,6 @@ -import { createClient } from '@/lib/supabase/server' import { NextResponse } from 'next/server' import { generateBalansrapport } from '@/lib/reports/balansrapport' -import { requireCompanyId } from '@/lib/company/context' +import { withRouteContext } from '@/lib/api/with-route-context' import { parseReportDateRange } from '@/lib/reports/date-range' import { reportToWorkbook, @@ -19,16 +18,7 @@ interface FlatRow { ub: number } -export async function GET(request: Request) { - const supabase = await createClient() - const { data: { user } } = await supabase.auth.getUser() - - if (!user) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const companyId = await requireCompanyId(supabase, user.id) - +export const GET = withRouteContext('report.balansrapport.xlsx', async (request, { supabase, companyId }) => { const { searchParams } = new URL(request.url) const periodId = searchParams.get('period_id') @@ -132,4 +122,4 @@ export async function GET(request: Request) { { status: 500 } ) } -} +}) diff --git a/app/api/reports/continuity-check/route.ts b/app/api/reports/continuity-check/route.ts index 54e0fa15..4f1548f3 100644 --- a/app/api/reports/continuity-check/route.ts +++ b/app/api/reports/continuity-check/route.ts @@ -1,22 +1,12 @@ -import { createClient } from '@/lib/supabase/server' import { NextResponse } from 'next/server' import { validateBalanceContinuity } from '@/lib/reports/continuity-check' -import { requireCompanyId } from '@/lib/company/context' +import { withRouteContext } from '@/lib/api/with-route-context' /** * GET: Validate IB/UB continuity for a fiscal period. * Query param: period_id (required) */ -export async function GET(request: Request) { - const supabase = await createClient() - const { data: { user } } = await supabase.auth.getUser() - - if (!user) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const companyId = await requireCompanyId(supabase, user.id) - +export const GET = withRouteContext('report.continuity_check', async (request, { supabase, companyId }) => { const { searchParams } = new URL(request.url) const periodId = searchParams.get('period_id') @@ -33,4 +23,4 @@ export async function GET(request: Request) { { status: 400 } ) } -} +}) diff --git a/app/api/reports/full-archive/__tests__/route.test.ts b/app/api/reports/full-archive/__tests__/route.test.ts index c9827654..c69c1ef6 100644 --- a/app/api/reports/full-archive/__tests__/route.test.ts +++ b/app/api/reports/full-archive/__tests__/route.test.ts @@ -1,14 +1,18 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ import { describe, it, expect, vi, beforeEach } from 'vitest' -import { createMockRequest, parseJsonResponse } from '@/tests/helpers' +import { NextResponse } from 'next/server' +import { createQueuedMockSupabase, createMockRequest, parseJsonResponse } from '@/tests/helpers' -vi.mock('@/lib/supabase/server', () => ({ - createClient: vi.fn(), +const { supabase } = createQueuedMockSupabase() + +const requireAuthMock = vi.fn() +vi.mock('@/lib/auth/require-auth', () => ({ + requireAuth: (...args: unknown[]) => requireAuthMock(...args), })) vi.mock('@/lib/company/context', () => ({ - requireCompanyId: vi.fn().mockResolvedValue('company-1'), getActiveCompanyId: vi.fn().mockResolvedValue('company-1'), + requireCompanyId: vi.fn().mockResolvedValue('company-1'), })) vi.mock('@/lib/reports/full-archive-export', () => ({ @@ -16,34 +20,35 @@ vi.mock('@/lib/reports/full-archive-export', () => ({ estimateArchiveSize: vi.fn(), })) -import { createClient } from '@/lib/supabase/server' import { generateFullArchive, estimateArchiveSize, } from '@/lib/reports/full-archive-export' import { GET } from '../route' -const mockCreateClient = vi.mocked(createClient) const mockGenerate = vi.mocked(generateFullArchive) const mockEstimate = vi.mocked(estimateArchiveSize) -function mockAuth(userId: string | null) { - mockCreateClient.mockResolvedValue({ - auth: { - getUser: vi.fn().mockResolvedValue({ - data: { user: userId ? { id: userId } : null }, - }), - }, - } as any) +function authed() { + requireAuthMock.mockResolvedValue({ user: { id: 'user-1' }, supabase, error: null }) +} + +function unauthed() { + requireAuthMock.mockResolvedValue({ + user: null, + supabase, + error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }), + }) } beforeEach(() => { vi.clearAllMocks() + authed() }) describe('GET /api/reports/full-archive', () => { it('returns 401 when not authenticated', async () => { - mockAuth(null) + unauthed() const { status, body } = await parseJsonResponse( await GET(createMockRequest('/api/reports/full-archive')) ) @@ -52,7 +57,6 @@ describe('GET /api/reports/full-archive', () => { }) it('returns estimate-only response when ?estimate=1', async () => { - mockAuth('user-1') mockEstimate.mockResolvedValue({ total_bytes: 10_000_000, document_bytes: 5_000_000, @@ -80,7 +84,6 @@ describe('GET /api/reports/full-archive', () => { }) it('returns 413 archive_too_large when estimate exceeds limit', async () => { - mockAuth('user-1') mockEstimate.mockResolvedValue({ total_bytes: 200 * 1024 * 1024, document_bytes: 195 * 1024 * 1024, @@ -106,7 +109,6 @@ describe('GET /api/reports/full-archive', () => { }) it('skips 413 when include_documents=false', async () => { - mockAuth('user-1') mockEstimate.mockResolvedValue({ total_bytes: 200 * 1024 * 1024, document_bytes: 195 * 1024 * 1024, @@ -130,7 +132,6 @@ describe('GET /api/reports/full-archive', () => { }) it('defaults to scope=all when no params given', async () => { - mockAuth('user-1') mockEstimate.mockResolvedValue({ total_bytes: 1_000_000, document_bytes: 500_000, @@ -149,7 +150,6 @@ describe('GET /api/reports/full-archive', () => { }) it('uses scope=period when period_id is provided without explicit scope', async () => { - mockAuth('user-1') mockEstimate.mockResolvedValue({ total_bytes: 1_000_000, document_bytes: 500_000, @@ -172,7 +172,6 @@ describe('GET /api/reports/full-archive', () => { }) it('returns 400 when scope=period without period_id', async () => { - mockAuth('user-1') const { status, body } = await parseJsonResponse( await GET( createMockRequest('/api/reports/full-archive', { @@ -187,7 +186,6 @@ describe('GET /api/reports/full-archive', () => { }) it('returns 404 when generate throws "not found"', async () => { - mockAuth('user-1') mockEstimate.mockResolvedValue({ total_bytes: 1_000_000, document_bytes: 500_000, diff --git a/app/api/reports/full-archive/route.ts b/app/api/reports/full-archive/route.ts index 69f4e3bc..d8e1a60e 100644 --- a/app/api/reports/full-archive/route.ts +++ b/app/api/reports/full-archive/route.ts @@ -1,27 +1,17 @@ -import { createClient } from '@/lib/supabase/server' import { NextResponse } from 'next/server' import { generateFullArchive, estimateArchiveSize, type ArchiveScope, } from '@/lib/reports/full-archive-export' -import { requireCompanyId } from '@/lib/company/context' +import { withRouteContext } from '@/lib/api/with-route-context' export const runtime = 'nodejs' export const maxDuration = 300 const SIZE_LIMIT_BYTES = 80 * 1024 * 1024 -export async function GET(request: Request) { - const supabase = await createClient() - const { data: { user } } = await supabase.auth.getUser() - - if (!user) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const companyId = await requireCompanyId(supabase, user.id) - +export const GET = withRouteContext('report.full_archive', async (request, { supabase, companyId }) => { const { searchParams } = new URL(request.url) const scopeParam = searchParams.get('scope') const periodId = searchParams.get('period_id') @@ -93,7 +83,7 @@ export async function GET(request: Request) { const status = message.includes('not found') ? 404 : 500 return NextResponse.json({ error: message }, { status }) } -} +}) function formatDateStamp(d: Date): string { const y = d.getUTCFullYear() diff --git a/app/api/reports/general-ledger/xlsx/route.ts b/app/api/reports/general-ledger/xlsx/route.ts index fbb3c3d3..53e47822 100644 --- a/app/api/reports/general-ledger/xlsx/route.ts +++ b/app/api/reports/general-ledger/xlsx/route.ts @@ -1,7 +1,6 @@ -import { createClient } from '@/lib/supabase/server' import { NextResponse } from 'next/server' import { generateGeneralLedger } from '@/lib/reports/general-ledger' -import { requireCompanyId } from '@/lib/company/context' +import { withRouteContext } from '@/lib/api/with-route-context' import { parseDimensionFilterParams, dimensionFilterDisclosure, dimensionFilterFileSuffix } from '@/lib/reports/dimension-filter' import { reportToWorkbook, @@ -30,16 +29,7 @@ function toDate(s: string): Date | string { return isNaN(d.getTime()) ? s : d } -export async function GET(request: Request) { - const supabase = await createClient() - const { data: { user } } = await supabase.auth.getUser() - - if (!user) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const companyId = await requireCompanyId(supabase, user.id) - +export const GET = withRouteContext('report.general_ledger.xlsx', async (request, { supabase, companyId }) => { const { searchParams } = new URL(request.url) const periodId = searchParams.get('period_id') const accountFrom = searchParams.get('account_from') || undefined @@ -167,4 +157,4 @@ export async function GET(request: Request) { { status: 500 } ) } -} +}) diff --git a/app/api/reports/income-statement/pdf/route.ts b/app/api/reports/income-statement/pdf/route.ts index d0cd2d3c..1adbf673 100644 --- a/app/api/reports/income-statement/pdf/route.ts +++ b/app/api/reports/income-statement/pdf/route.ts @@ -1,9 +1,8 @@ -import { createClient } from '@/lib/supabase/server' import { NextResponse } from 'next/server' import { renderToBuffer } from '@react-pdf/renderer' import { generateIncomeStatement } from '@/lib/reports/income-statement' import { FinancialStatementPDF, type FinancialStatementGroup, type FinancialStatementSection, type FinancialStatementSummaryRow } from '@/lib/reports/financial-statement-pdf-template' -import { requireCompanyId } from '@/lib/company/context' +import { withRouteContext } from '@/lib/api/with-route-context' import { parseReportDateRange } from '@/lib/reports/date-range' import type { CompanySettings } from '@/types' import { parseDimensionFilterParams, dimensionFilterDisclosure, dimensionFilterFileSuffix } from '@/lib/reports/dimension-filter' @@ -30,16 +29,7 @@ function sectionPrefix(section: FinancialStatementSection, prefixes: string[]): return prefixes.some((p) => acc.startsWith(p)) } -export async function GET(request: Request) { - const supabase = await createClient() - const { data: { user } } = await supabase.auth.getUser() - - if (!user) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const companyId = await requireCompanyId(supabase, user.id) - +export const GET = withRouteContext('report.income_statement.pdf', async (request, { supabase, companyId }) => { const { searchParams } = new URL(request.url) const periodId = searchParams.get('period_id') @@ -233,4 +223,4 @@ export async function GET(request: Request) { { status: 500 } ) } -} +}) diff --git a/app/api/reports/income-statement/xlsx/route.ts b/app/api/reports/income-statement/xlsx/route.ts index 549ee5d9..edd54240 100644 --- a/app/api/reports/income-statement/xlsx/route.ts +++ b/app/api/reports/income-statement/xlsx/route.ts @@ -1,7 +1,6 @@ -import { createClient } from '@/lib/supabase/server' import { NextResponse } from 'next/server' import { generateIncomeStatement } from '@/lib/reports/income-statement' -import { requireCompanyId } from '@/lib/company/context' +import { withRouteContext } from '@/lib/api/with-route-context' import { parseReportDateRange } from '@/lib/reports/date-range' import { reportToWorkbook, @@ -51,16 +50,7 @@ function flatten( return rows } -export async function GET(request: Request) { - const supabase = await createClient() - const { data: { user } } = await supabase.auth.getUser() - - if (!user) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const companyId = await requireCompanyId(supabase, user.id) - +export const GET = withRouteContext('report.income_statement.xlsx', async (request, { supabase, companyId }) => { const { searchParams } = new URL(request.url) const periodId = searchParams.get('period_id') @@ -185,4 +175,4 @@ export async function GET(request: Request) { { status: 500 } ) } -} +}) diff --git a/app/api/reports/journal-register/route.ts b/app/api/reports/journal-register/route.ts index ad025158..74530163 100644 --- a/app/api/reports/journal-register/route.ts +++ b/app/api/reports/journal-register/route.ts @@ -1,19 +1,8 @@ -import { createClient } from '@/lib/supabase/server' import { NextResponse } from 'next/server' import { generateJournalRegister } from '@/lib/reports/journal-register' -import { requireCompanyId } from '@/lib/company/context' - -export async function GET(request: Request) { - const supabase = await createClient() - - const { data: { user } } = await supabase.auth.getUser() - - if (!user) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const companyId = await requireCompanyId(supabase, user.id) +import { withRouteContext } from '@/lib/api/with-route-context' +export const GET = withRouteContext('report.journal_register', async (request, { supabase, companyId }) => { const { searchParams } = new URL(request.url) const periodId = searchParams.get('period_id') @@ -24,4 +13,4 @@ export async function GET(request: Request) { const data = await generateJournalRegister(supabase, companyId, periodId) return NextResponse.json({ data }) -} +}) diff --git a/app/api/reports/journal-register/xlsx/route.ts b/app/api/reports/journal-register/xlsx/route.ts index be62bbec..633f1e43 100644 --- a/app/api/reports/journal-register/xlsx/route.ts +++ b/app/api/reports/journal-register/xlsx/route.ts @@ -1,7 +1,6 @@ -import { createClient } from '@/lib/supabase/server' import { NextResponse } from 'next/server' import { generateJournalRegister } from '@/lib/reports/journal-register' -import { requireCompanyId } from '@/lib/company/context' +import { withRouteContext } from '@/lib/api/with-route-context' import { reportToWorkbook, textColumn, @@ -28,16 +27,7 @@ function toDate(s: string): Date | null { return isNaN(d.getTime()) ? null : d } -export async function GET(request: Request) { - const supabase = await createClient() - const { data: { user } } = await supabase.auth.getUser() - - if (!user) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const companyId = await requireCompanyId(supabase, user.id) - +export const GET = withRouteContext('report.journal_register.xlsx', async (request, { supabase, companyId }) => { const { searchParams } = new URL(request.url) const periodId = searchParams.get('period_id') @@ -116,4 +106,4 @@ export async function GET(request: Request) { { status: 500 } ) } -} +}) diff --git a/app/api/reports/kassaflodesanalys/pdf/route.ts b/app/api/reports/kassaflodesanalys/pdf/route.ts index cc861860..f6068b0c 100644 --- a/app/api/reports/kassaflodesanalys/pdf/route.ts +++ b/app/api/reports/kassaflodesanalys/pdf/route.ts @@ -1,21 +1,11 @@ -import { createClient } from '@/lib/supabase/server' import { NextResponse } from 'next/server' import { renderToBuffer } from '@react-pdf/renderer' import { generateKassaflodesanalys } from '@/lib/reports/kassaflodesanalys' import { KassaflodesanalysPDF } from '@/lib/reports/kassaflodesanalys-pdf-template' -import { requireCompanyId } from '@/lib/company/context' +import { withRouteContext } from '@/lib/api/with-route-context' import type { CompanySettings } from '@/types' -export async function GET(request: Request) { - const supabase = await createClient() - const { data: { user } } = await supabase.auth.getUser() - - if (!user) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const companyId = await requireCompanyId(supabase, user.id) - +export const GET = withRouteContext('report.kassaflodesanalys.pdf', async (request, { supabase, companyId }) => { const { searchParams } = new URL(request.url) const periodId = searchParams.get('period_id') @@ -77,4 +67,4 @@ export async function GET(request: Request) { { status: 500 } ) } -} +}) diff --git a/app/api/reports/kassaflodesanalys/route.ts b/app/api/reports/kassaflodesanalys/route.ts index cdc07c99..cc9134b2 100644 --- a/app/api/reports/kassaflodesanalys/route.ts +++ b/app/api/reports/kassaflodesanalys/route.ts @@ -1,18 +1,8 @@ -import { createClient } from '@/lib/supabase/server' import { NextResponse } from 'next/server' import { generateKassaflodesanalys } from '@/lib/reports/kassaflodesanalys' -import { requireCompanyId } from '@/lib/company/context' - -export async function GET(request: Request) { - const supabase = await createClient() - const { data: { user } } = await supabase.auth.getUser() - - if (!user) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const companyId = await requireCompanyId(supabase, user.id) +import { withRouteContext } from '@/lib/api/with-route-context' +export const GET = withRouteContext('report.kassaflodesanalys', async (request, { supabase, companyId }) => { const { searchParams } = new URL(request.url) const periodId = searchParams.get('period_id') @@ -29,4 +19,4 @@ export async function GET(request: Request) { { status: 500 } ) } -} +}) diff --git a/app/api/reports/kpi/route.ts b/app/api/reports/kpi/route.ts index 08c95b40..e8f2a96f 100644 --- a/app/api/reports/kpi/route.ts +++ b/app/api/reports/kpi/route.ts @@ -1,4 +1,4 @@ -import { createClient } from '@/lib/supabase/server' +import { withRouteContext } from '@/lib/api/with-route-context' import { NextResponse } from 'next/server' import { generateIncomeStatement } from '@/lib/reports/income-statement' import { generateTrialBalance } from '@/lib/reports/trial-balance' @@ -12,17 +12,10 @@ import { calculateVatLiability, } from '@/lib/reports/kpi' import { mergeWithDefaults } from '@/lib/reports/kpi-definitions' -import { requireCompanyId } from '@/lib/company/context' import { parseDimensionFilterParams } from '@/lib/reports/dimension-filter' import type { KPIReport, KPIPreferences } from '@/types' -export async function GET(request: Request) { - const supabase = await createClient() - const { data: { user } } = await supabase.auth.getUser() - if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - - const companyId = await requireCompanyId(supabase, user.id) - +export const GET = withRouteContext('report.kpi', async (request, { supabase, companyId }) => { const { searchParams } = new URL(request.url) const periodId = searchParams.get('period_id') if (!periodId) { @@ -201,4 +194,4 @@ export async function GET(request: Request) { } return NextResponse.json({ data: report }) -} +}) diff --git a/app/api/reports/kpi/xlsx/route.ts b/app/api/reports/kpi/xlsx/route.ts index 081683fc..f07f041f 100644 --- a/app/api/reports/kpi/xlsx/route.ts +++ b/app/api/reports/kpi/xlsx/route.ts @@ -1,4 +1,4 @@ -import { createClient } from '@/lib/supabase/server' +import { withRouteContext } from '@/lib/api/with-route-context' import { NextResponse } from 'next/server' import { generateIncomeStatement } from '@/lib/reports/income-statement' import { generateTrialBalance } from '@/lib/reports/trial-balance' @@ -11,7 +11,6 @@ import { calculateAvgPaymentDays, calculateVatLiability, } from '@/lib/reports/kpi' -import { requireCompanyId } from '@/lib/company/context' import { reportToWorkbook, textColumn, @@ -43,13 +42,7 @@ interface SupplierRow { total: number } -export async function GET(request: Request) { - const supabase = await createClient() - const { data: { user } } = await supabase.auth.getUser() - if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - - const companyId = await requireCompanyId(supabase, user.id) - +export const GET = withRouteContext('report.kpi.xlsx', async (request, { supabase, companyId }) => { const { searchParams } = new URL(request.url) const periodId = searchParams.get('period_id') if (!periodId) { @@ -242,7 +235,7 @@ export async function GET(request: Request) { { status: 500 } ) } -} +}) function scaleToFraction(value: number | null): number | null { return value === null ? null : Math.round(value) / 100 diff --git a/app/api/reports/monthly-breakdown/route.ts b/app/api/reports/monthly-breakdown/route.ts index 92a88772..dd014f20 100644 --- a/app/api/reports/monthly-breakdown/route.ts +++ b/app/api/reports/monthly-breakdown/route.ts @@ -1,19 +1,9 @@ -import { createClient } from '@/lib/supabase/server' +import { withRouteContext } from '@/lib/api/with-route-context' import { NextResponse } from 'next/server' import { generateMonthlyBreakdown } from '@/lib/reports/monthly-breakdown' -import { requireCompanyId } from '@/lib/company/context' import { parseDimensionFilterParams } from '@/lib/reports/dimension-filter' -export async function GET(request: Request) { - const supabase = await createClient() - const { data: { user } } = await supabase.auth.getUser() - - if (!user) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const companyId = await requireCompanyId(supabase, user.id) - +export const GET = withRouteContext('report.monthly_breakdown', async (request, { supabase, companyId }) => { const { searchParams } = new URL(request.url) const periodId = searchParams.get('period_id') @@ -34,4 +24,4 @@ export async function GET(request: Request) { } catch { return NextResponse.json({ error: 'Failed to generate monthly breakdown' }, { status: 500 }) } -} +}) diff --git a/app/api/reports/monthly-breakdown/xlsx/route.ts b/app/api/reports/monthly-breakdown/xlsx/route.ts index 89618a15..048a451b 100644 --- a/app/api/reports/monthly-breakdown/xlsx/route.ts +++ b/app/api/reports/monthly-breakdown/xlsx/route.ts @@ -1,7 +1,6 @@ -import { createClient } from '@/lib/supabase/server' +import { withRouteContext } from '@/lib/api/with-route-context' import { NextResponse } from 'next/server' import { generateMonthlyBreakdown } from '@/lib/reports/monthly-breakdown' -import { requireCompanyId } from '@/lib/company/context' import { reportToWorkbook, textColumn, @@ -9,16 +8,7 @@ import { xlsxFilename, } from '@/lib/reports/xlsx-export' -export async function GET(request: Request) { - const supabase = await createClient() - const { data: { user } } = await supabase.auth.getUser() - - if (!user) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const companyId = await requireCompanyId(supabase, user.id) - +export const GET = withRouteContext('report.monthly_breakdown.xlsx', async (request, { supabase, companyId }) => { const { searchParams } = new URL(request.url) const periodId = searchParams.get('period_id') @@ -74,4 +64,4 @@ export async function GET(request: Request) { { status: 500 } ) } -} +}) diff --git a/app/api/reports/resultatrapport/pdf/route.ts b/app/api/reports/resultatrapport/pdf/route.ts index 015a1bf4..5db9a08d 100644 --- a/app/api/reports/resultatrapport/pdf/route.ts +++ b/app/api/reports/resultatrapport/pdf/route.ts @@ -1,23 +1,13 @@ -import { createClient } from '@/lib/supabase/server' +import { withRouteContext } from '@/lib/api/with-route-context' import { NextResponse } from 'next/server' import { renderToBuffer } from '@react-pdf/renderer' import { generateResultatrapport } from '@/lib/reports/resultatrapport' import { ResultatrapportPDF } from '@/lib/reports/operational-report-pdf-template' -import { requireCompanyId } from '@/lib/company/context' import { parseReportDateRange } from '@/lib/reports/date-range' import type { CompanySettings } from '@/types' import { parseDimensionFilterParams, dimensionFilterDisclosure, dimensionFilterFileSuffix } from '@/lib/reports/dimension-filter' -export async function GET(request: Request) { - const supabase = await createClient() - const { data: { user } } = await supabase.auth.getUser() - - if (!user) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const companyId = await requireCompanyId(supabase, user.id) - +export const GET = withRouteContext('report.resultatrapport.pdf', async (request, { supabase, companyId }) => { const { searchParams } = new URL(request.url) const periodId = searchParams.get('period_id') @@ -91,4 +81,4 @@ export async function GET(request: Request) { { status: 500 } ) } -} +}) diff --git a/app/api/reports/resultatrapport/route.ts b/app/api/reports/resultatrapport/route.ts index 0e28218e..ab345fe8 100644 --- a/app/api/reports/resultatrapport/route.ts +++ b/app/api/reports/resultatrapport/route.ts @@ -1,20 +1,10 @@ -import { createClient } from '@/lib/supabase/server' +import { withRouteContext } from '@/lib/api/with-route-context' import { NextResponse } from 'next/server' import { generateResultatrapport } from '@/lib/reports/resultatrapport' -import { requireCompanyId } from '@/lib/company/context' import { parseReportDateRange } from '@/lib/reports/date-range' import { parseDimensionFilterParams } from '@/lib/reports/dimension-filter' -export async function GET(request: Request) { - const supabase = await createClient() - const { data: { user } } = await supabase.auth.getUser() - - if (!user) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const companyId = await requireCompanyId(supabase, user.id) - +export const GET = withRouteContext('report.resultatrapport', async (request, { supabase, companyId }) => { const { searchParams } = new URL(request.url) const periodId = searchParams.get('period_id') @@ -55,4 +45,4 @@ export async function GET(request: Request) { { status: 500 } ) } -} +}) diff --git a/app/api/reports/resultatrapport/xlsx/route.ts b/app/api/reports/resultatrapport/xlsx/route.ts index 4546f31a..ad35aac0 100644 --- a/app/api/reports/resultatrapport/xlsx/route.ts +++ b/app/api/reports/resultatrapport/xlsx/route.ts @@ -1,7 +1,6 @@ -import { createClient } from '@/lib/supabase/server' +import { withRouteContext } from '@/lib/api/with-route-context' import { NextResponse } from 'next/server' import { generateResultatrapport } from '@/lib/reports/resultatrapport' -import { requireCompanyId } from '@/lib/company/context' import { parseReportDateRange } from '@/lib/reports/date-range' import { parseDimensionFilterParams, dimensionFilterDisclosure, dimensionFilterFileSuffix } from '@/lib/reports/dimension-filter' import { @@ -19,16 +18,7 @@ interface FlatRow { prior_period: number } -export async function GET(request: Request) { - const supabase = await createClient() - const { data: { user } } = await supabase.auth.getUser() - - if (!user) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const companyId = await requireCompanyId(supabase, user.id) - +export const GET = withRouteContext('report.resultatrapport.xlsx', async (request, { supabase, companyId }) => { const { searchParams } = new URL(request.url) const periodId = searchParams.get('period_id') @@ -148,4 +138,4 @@ export async function GET(request: Request) { { status: 500 } ) } -} +}) diff --git a/app/api/reports/salary-journal/route.ts b/app/api/reports/salary-journal/route.ts index e347ddcd..0f71c23c 100644 --- a/app/api/reports/salary-journal/route.ts +++ b/app/api/reports/salary-journal/route.ts @@ -1,19 +1,12 @@ -import { createClient } from '@/lib/supabase/server' +import { withRouteContext } from '@/lib/api/with-route-context' import { NextResponse } from 'next/server' -import { requireCompanyId } from '@/lib/company/context' import { generateSalaryJournal } from '@/lib/reports/salary-journal' /** * Lönejournal report, per BFNAR 2013:2 behandlingshistorik requirement. * Monthly/annual per-employee salary register for AGI reconciliation. */ -export async function GET(request: Request) { - const supabase = await createClient() - const { data: { user } } = await supabase.auth.getUser() - if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - - const companyId = await requireCompanyId(supabase, user.id) - +export const GET = withRouteContext('report.salary_journal', async (request, { supabase, companyId }) => { const { searchParams } = new URL(request.url) const year = parseInt(searchParams.get('year') || new Date().getFullYear().toString()) const monthFrom = searchParams.get('month_from') ? parseInt(searchParams.get('month_from')!) : undefined @@ -26,4 +19,4 @@ export async function GET(request: Request) { const message = err instanceof Error ? err.message : 'Kunde inte generera lönejournal' return NextResponse.json({ error: message }, { status: 500 }) } -} +}) diff --git a/app/api/reports/salary-journal/xlsx/route.ts b/app/api/reports/salary-journal/xlsx/route.ts index 95775c6d..5f5802e7 100644 --- a/app/api/reports/salary-journal/xlsx/route.ts +++ b/app/api/reports/salary-journal/xlsx/route.ts @@ -1,6 +1,5 @@ -import { createClient } from '@/lib/supabase/server' +import { withRouteContext } from '@/lib/api/with-route-context' import { NextResponse } from 'next/server' -import { requireCompanyId } from '@/lib/company/context' import { generateSalaryJournal } from '@/lib/reports/salary-journal' import { reportToWorkbook, @@ -17,16 +16,7 @@ function toDate(s: string): Date | null { return isNaN(d.getTime()) ? null : d } -export async function GET(request: Request) { - const supabase = await createClient() - const { data: { user } } = await supabase.auth.getUser() - - if (!user) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const companyId = await requireCompanyId(supabase, user.id) - +export const GET = withRouteContext('report.salary_journal.xlsx', async (request, { supabase, companyId }) => { const { searchParams } = new URL(request.url) const year = parseInt(searchParams.get('year') || new Date().getFullYear().toString()) const monthFrom = searchParams.get('month_from') ? parseInt(searchParams.get('month_from')!) : undefined @@ -110,4 +100,4 @@ export async function GET(request: Request) { { status: 500 } ) } -} +}) diff --git a/app/api/reports/supplier-ledger/route.ts b/app/api/reports/supplier-ledger/route.ts index 6bae0496..173593dc 100644 --- a/app/api/reports/supplier-ledger/route.ts +++ b/app/api/reports/supplier-ledger/route.ts @@ -1,20 +1,9 @@ -import { createClient } from '@/lib/supabase/server' +import { withRouteContext } from '@/lib/api/with-route-context' import { NextResponse } from 'next/server' import { generateSupplierLedger } from '@/lib/reports/supplier-ledger' import { generateReconciliation } from '@/lib/reports/supplier-reconciliation' -import { requireCompanyId } from '@/lib/company/context' - -export async function GET(request: Request) { - const supabase = await createClient() - - const { data: { user } } = await supabase.auth.getUser() - - if (!user) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const companyId = await requireCompanyId(supabase, user.id) +export const GET = withRouteContext('report.supplier_ledger', async (request, { supabase, companyId }) => { const { searchParams } = new URL(request.url) const asOfDate = searchParams.get('as_of_date') || undefined const periodId = searchParams.get('period_id') || undefined @@ -32,4 +21,4 @@ export async function GET(request: Request) { reconciliation, }, }) -} +}) diff --git a/app/api/reports/supplier-ledger/supplier/[supplierId]/invoices/__tests__/route.test.ts b/app/api/reports/supplier-ledger/supplier/[supplierId]/invoices/__tests__/route.test.ts index d985dc04..a1301ea2 100644 --- a/app/api/reports/supplier-ledger/supplier/[supplierId]/invoices/__tests__/route.test.ts +++ b/app/api/reports/supplier-ledger/supplier/[supplierId]/invoices/__tests__/route.test.ts @@ -1,11 +1,14 @@ import { describe, it, expect, vi, beforeEach } from 'vitest' +import { NextResponse } from 'next/server' import { createMockRequest, createMockRouteParams } from '@/tests/helpers' -vi.mock('@/lib/supabase/server', () => ({ - createClient: vi.fn(), +const requireAuthMock = vi.fn() +vi.mock('@/lib/auth/require-auth', () => ({ + requireAuth: (...args: unknown[]) => requireAuthMock(...args), })) vi.mock('@/lib/company/context', () => ({ + getActiveCompanyId: vi.fn().mockResolvedValue('company-1'), requireCompanyId: vi.fn().mockResolvedValue('company-1'), })) @@ -13,26 +16,19 @@ vi.mock('@/lib/bookkeeping/currency-utils', () => ({ resolveSekAmount: vi.fn((amount: number) => amount), })) -import { createClient } from '@/lib/supabase/server' import { GET } from '../route' -const mockCreateClient = vi.mocked(createClient) - interface QueryResult { data: unknown error: unknown } function buildSupabase( - user: { id: string } | null, supplier: { id: string; name: string } | null, invoicesResult: QueryResult, entriesResult: QueryResult ) { return { - auth: { - getUser: vi.fn().mockResolvedValue({ data: { user } }), - }, from: vi.fn().mockImplementation((table: string) => { if (table === 'suppliers') { return { @@ -68,9 +64,11 @@ beforeEach(() => { describe('GET /api/reports/supplier-ledger/supplier/[supplierId]/invoices', () => { it('returns 401 when not authenticated', async () => { - mockCreateClient.mockResolvedValue( - buildSupabase(null, null, { data: [], error: null }, { data: [], error: null }) as never - ) + requireAuthMock.mockResolvedValue({ + user: null, + supabase: buildSupabase(null, { data: [], error: null }, { data: [], error: null }), + error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }), + }) const req = createMockRequest( '/api/reports/supplier-ledger/supplier/sup-1/invoices' ) @@ -79,9 +77,11 @@ describe('GET /api/reports/supplier-ledger/supplier/[supplierId]/invoices', () = }) it('returns 404 when supplier is unknown', async () => { - mockCreateClient.mockResolvedValue( - buildSupabase({ id: 'user-1' }, null, { data: [], error: null }, { data: [], error: null }) as never - ) + requireAuthMock.mockResolvedValue({ + user: { id: 'user-1' }, + supabase: buildSupabase(null, { data: [], error: null }, { data: [], error: null }), + error: null, + }) const req = createMockRequest( '/api/reports/supplier-ledger/supplier/sup-1/invoices' ) @@ -113,14 +113,15 @@ describe('GET /api/reports/supplier-ledger/supplier/[supplierId]/invoices', () = entry_date: '2026-05-10', }, ] - mockCreateClient.mockResolvedValue( - buildSupabase( - { id: 'user-1' }, + requireAuthMock.mockResolvedValue({ + user: { id: 'user-1' }, + supabase: buildSupabase( { id: 'sup-1', name: 'Office Supply AB' }, { data: invoices, error: null }, { data: entries, error: null } - ) as never - ) + ), + error: null, + }) const req = createMockRequest( '/api/reports/supplier-ledger/supplier/sup-1/invoices' ) diff --git a/app/api/reports/supplier-ledger/supplier/[supplierId]/invoices/route.ts b/app/api/reports/supplier-ledger/supplier/[supplierId]/invoices/route.ts index 62e898a5..31de9d52 100644 --- a/app/api/reports/supplier-ledger/supplier/[supplierId]/invoices/route.ts +++ b/app/api/reports/supplier-ledger/supplier/[supplierId]/invoices/route.ts @@ -1,6 +1,5 @@ -import { createClient } from '@/lib/supabase/server' +import { withRouteContext } from '@/lib/api/with-route-context' import { NextResponse } from 'next/server' -import { requireCompanyId } from '@/lib/company/context' import { resolveSekAmount } from '@/lib/bookkeeping/currency-utils' import type { ReportSourceLine } from '@/lib/reports/source-lines' @@ -13,18 +12,9 @@ import type { ReportSourceLine } from '@/lib/reports/source-lines' */ const PAGE_LIMIT = 500 -export async function GET( - request: Request, - { params }: { params: Promise<{ supplierId: string }> } -) { - const supabase = await createClient() - const { data: { user } } = await supabase.auth.getUser() - - if (!user) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const companyId = await requireCompanyId(supabase, user.id) +export const GET = withRouteContext<{ params: Promise<{ supplierId: string }> }>( + 'report.supplier_ledger.invoices', + async (request, { supabase, companyId }, { params }) => { const { supplierId } = await params const { data: supplier } = await supabase @@ -140,4 +130,4 @@ export async function GET( next_cursor: null, }, }) -} +}) diff --git a/app/api/reports/supplier-ledger/xlsx/route.ts b/app/api/reports/supplier-ledger/xlsx/route.ts index dc7df499..b3c572e0 100644 --- a/app/api/reports/supplier-ledger/xlsx/route.ts +++ b/app/api/reports/supplier-ledger/xlsx/route.ts @@ -1,7 +1,6 @@ -import { createClient } from '@/lib/supabase/server' +import { withRouteContext } from '@/lib/api/with-route-context' import { NextResponse } from 'next/server' import { generateSupplierLedger } from '@/lib/reports/supplier-ledger' -import { requireCompanyId } from '@/lib/company/context' import { reportToWorkbook, textColumn, @@ -19,16 +18,7 @@ interface AgingRow { total_outstanding: number } -export async function GET(request: Request) { - const supabase = await createClient() - const { data: { user } } = await supabase.auth.getUser() - - if (!user) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const companyId = await requireCompanyId(supabase, user.id) - +export const GET = withRouteContext('report.supplier_ledger.xlsx', async (request, { supabase, companyId }) => { const { searchParams } = new URL(request.url) const asOfDate = searchParams.get('as_of_date') || undefined @@ -93,4 +83,4 @@ export async function GET(request: Request) { { status: 500 } ) } -} +}) diff --git a/app/api/reports/trial-balance/account/[accountNumber]/sources/__tests__/route.test.ts b/app/api/reports/trial-balance/account/[accountNumber]/sources/__tests__/route.test.ts index fa3277f2..95223f22 100644 --- a/app/api/reports/trial-balance/account/[accountNumber]/sources/__tests__/route.test.ts +++ b/app/api/reports/trial-balance/account/[accountNumber]/sources/__tests__/route.test.ts @@ -1,33 +1,28 @@ import { describe, it, expect, vi, beforeEach } from 'vitest' +import { NextResponse } from 'next/server' import { createMockRequest, createMockRouteParams } from '@/tests/helpers' -vi.mock('@/lib/supabase/server', () => ({ - createClient: vi.fn(), +const requireAuthMock = vi.fn() +vi.mock('@/lib/auth/require-auth', () => ({ + requireAuth: (...args: unknown[]) => requireAuthMock(...args), })) vi.mock('@/lib/company/context', () => ({ + getActiveCompanyId: vi.fn().mockResolvedValue('company-1'), requireCompanyId: vi.fn().mockResolvedValue('company-1'), })) -import { createClient } from '@/lib/supabase/server' import { GET } from '../route' -const mockCreateClient = vi.mocked(createClient) - -interface AuthShape { - auth: { getUser: ReturnType } +interface SupabaseShape { from: ReturnType } function buildSupabase( - user: { id: string } | null, account: { account_number: string; account_name: string } | null, linesResult: { data: unknown; error: unknown } -): AuthShape { +): SupabaseShape { return { - auth: { - getUser: vi.fn().mockResolvedValue({ data: { user } }), - }, from: vi.fn().mockImplementation((table: string) => { if (table === 'chart_of_accounts') { const chain = { @@ -57,15 +52,25 @@ function buildSupabase( } } +function authOk(supabase: SupabaseShape) { + requireAuthMock.mockResolvedValue({ user: { id: 'user-1' }, supabase, error: null }) +} + +function authFail(supabase: SupabaseShape) { + requireAuthMock.mockResolvedValue({ + user: null, + supabase, + error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }), + }) +} + beforeEach(() => { vi.clearAllMocks() }) describe('GET /api/reports/trial-balance/account/[accountNumber]/sources', () => { it('returns 401 when not authenticated', async () => { - mockCreateClient.mockResolvedValue( - buildSupabase(null, null, { data: [], error: null }) as never - ) + authFail(buildSupabase(null, { data: [], error: null })) const req = createMockRequest( '/api/reports/trial-balance/account/1930/sources', { searchParams: { fiscal_period_id: 'period-1' } } @@ -75,9 +80,7 @@ describe('GET /api/reports/trial-balance/account/[accountNumber]/sources', () => }) it('returns 400 when fiscal_period_id is missing', async () => { - mockCreateClient.mockResolvedValue( - buildSupabase({ id: 'user-1' }, null, { data: [], error: null }) as never - ) + authOk(buildSupabase(null, { data: [], error: null })) const req = createMockRequest( '/api/reports/trial-balance/account/1930/sources' ) @@ -86,9 +89,7 @@ describe('GET /api/reports/trial-balance/account/[accountNumber]/sources', () => }) it('returns 404 when account is unknown for the company', async () => { - mockCreateClient.mockResolvedValue( - buildSupabase({ id: 'user-1' }, null, { data: [], error: null }) as never - ) + authOk(buildSupabase(null, { data: [], error: null })) const req = createMockRequest( '/api/reports/trial-balance/account/9999/sources', { searchParams: { fiscal_period_id: 'period-1' } } @@ -100,12 +101,11 @@ describe('GET /api/reports/trial-balance/account/[accountNumber]/sources', () => it('returns 400 when the cursor date component is not a structural ISO date', async () => { // Defense-in-depth (ASVS V1.2): the cursor is applied in JS, but a // malformed date component must still be rejected structurally. - mockCreateClient.mockResolvedValue( + authOk( buildSupabase( - { id: 'user-1' }, { account_number: '1930', account_name: 'Företagskonto' }, { data: [], error: null } - ) as never + ) ) const req = createMockRequest( '/api/reports/trial-balance/account/1930/sources', @@ -148,12 +148,11 @@ describe('GET /api/reports/trial-balance/account/[accountNumber]/sources', () => }, }, ] - mockCreateClient.mockResolvedValue( + authOk( buildSupabase( - { id: 'user-1' }, { account_number: '1930', account_name: 'Företagskonto' }, { data: linesData, error: null } - ) as never + ) ) const req = createMockRequest( @@ -233,12 +232,11 @@ describe('GET /api/reports/trial-balance/account/[accountNumber]/sources', () => }, ] - mockCreateClient.mockResolvedValue( + authOk( buildSupabase( - { id: 'user-1' }, { account_number: '1930', account_name: 'Företagskonto' }, { data: linesData, error: null } - ) as never + ) ) const req = createMockRequest( @@ -292,12 +290,11 @@ describe('GET /api/reports/trial-balance/account/[accountNumber]/sources', () => }, ] - mockCreateClient.mockResolvedValue( + authOk( buildSupabase( - { id: 'user-1' }, { account_number: '1930', account_name: 'Företagskonto' }, { data: linesData, error: null } - ) as never + ) ) const req = createMockRequest( @@ -345,12 +342,11 @@ describe('GET /api/reports/trial-balance/account/[accountNumber]/sources', () => a.journal_entry_id < b.journal_entry_id ? 1 : -1 ) - mockCreateClient.mockResolvedValue( + authOk( buildSupabase( - { id: 'user-1' }, { account_number: '3001', account_name: 'Försäljning' }, { data: shuffled, error: null } - ) as never + ) ) const req = createMockRequest( diff --git a/app/api/reports/trial-balance/account/[accountNumber]/sources/route.ts b/app/api/reports/trial-balance/account/[accountNumber]/sources/route.ts index 0b9b1d80..7783dce6 100644 --- a/app/api/reports/trial-balance/account/[accountNumber]/sources/route.ts +++ b/app/api/reports/trial-balance/account/[accountNumber]/sources/route.ts @@ -1,6 +1,5 @@ -import { createClient } from '@/lib/supabase/server' +import { withRouteContext } from '@/lib/api/with-route-context' import { NextResponse } from 'next/server' -import { requireCompanyId } from '@/lib/company/context' import { fetchAllRows } from '@/lib/supabase/fetch-all' import { parseDimensionFilterParams } from '@/lib/reports/dimension-filter' import type { ReportSourceLine } from '@/lib/reports/source-lines' @@ -17,18 +16,9 @@ import type { ReportSourceLine } from '@/lib/reports/source-lines' */ const PAGE_LIMIT = 500 -export async function GET( - request: Request, - { params }: { params: Promise<{ accountNumber: string }> } -) { - const supabase = await createClient() - const { data: { user } } = await supabase.auth.getUser() - - if (!user) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const companyId = await requireCompanyId(supabase, user.id) +export const GET = withRouteContext<{ params: Promise<{ accountNumber: string }> }>( + 'report.trial_balance.account_sources', + async (request, { supabase, companyId }, { params }) => { const { accountNumber } = await params const { searchParams } = new URL(request.url) @@ -179,4 +169,4 @@ export async function GET( next_cursor, }, }) -} +}) diff --git a/app/api/reports/trial-balance/route.ts b/app/api/reports/trial-balance/route.ts index 5aeee60e..02d2b377 100644 --- a/app/api/reports/trial-balance/route.ts +++ b/app/api/reports/trial-balance/route.ts @@ -1,18 +1,8 @@ -import { createClient } from '@/lib/supabase/server' +import { withRouteContext } from '@/lib/api/with-route-context' import { NextResponse } from 'next/server' import { generateTrialBalance } from '@/lib/reports/trial-balance' -import { requireCompanyId } from '@/lib/company/context' - -export async function GET(request: Request) { - const supabase = await createClient() - const { data: { user } } = await supabase.auth.getUser() - - if (!user) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const companyId = await requireCompanyId(supabase, user.id) +export const GET = withRouteContext('report.trial_balance', async (request, { supabase, companyId }) => { const { searchParams } = new URL(request.url) const periodId = searchParams.get('period_id') @@ -29,4 +19,4 @@ export async function GET(request: Request) { { status: 500 } ) } -} +}) diff --git a/app/api/reports/trial-balance/xlsx/route.ts b/app/api/reports/trial-balance/xlsx/route.ts index d12bd758..c1581764 100644 --- a/app/api/reports/trial-balance/xlsx/route.ts +++ b/app/api/reports/trial-balance/xlsx/route.ts @@ -1,7 +1,6 @@ -import { createClient } from '@/lib/supabase/server' +import { withRouteContext } from '@/lib/api/with-route-context' import { NextResponse } from 'next/server' import { generateTrialBalance } from '@/lib/reports/trial-balance' -import { requireCompanyId } from '@/lib/company/context' import { reportToWorkbook, textColumn, @@ -11,16 +10,7 @@ import { } from '@/lib/reports/xlsx-export' import type { TrialBalanceRow } from '@/types' -export async function GET(request: Request) { - const supabase = await createClient() - const { data: { user } } = await supabase.auth.getUser() - - if (!user) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const companyId = await requireCompanyId(supabase, user.id) - +export const GET = withRouteContext('report.trial_balance.xlsx', async (request, { supabase, companyId }) => { const { searchParams } = new URL(request.url) const periodId = searchParams.get('period_id') @@ -91,4 +81,4 @@ export async function GET(request: Request) { { status: 500 } ) } -} +}) diff --git a/app/api/reports/vacation-liability/route.ts b/app/api/reports/vacation-liability/route.ts index ccad42ef..72011893 100644 --- a/app/api/reports/vacation-liability/route.ts +++ b/app/api/reports/vacation-liability/route.ts @@ -1,6 +1,5 @@ -import { createClient } from '@/lib/supabase/server' +import { withRouteContext } from '@/lib/api/with-route-context' import { NextResponse } from 'next/server' -import { requireCompanyId } from '@/lib/company/context' import { generateVacationLiability } from '@/lib/reports/vacation-liability' /** @@ -8,13 +7,7 @@ import { generateVacationLiability } from '@/lib/reports/vacation-liability' * Per-employee vacation liability (accounts 2920 + 2940). * Required for year-end closing. */ -export async function GET(request: Request) { - const supabase = await createClient() - const { data: { user } } = await supabase.auth.getUser() - if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - - const companyId = await requireCompanyId(supabase, user.id) - +export const GET = withRouteContext('report.vacation_liability', async (request, { supabase, companyId }) => { const { searchParams } = new URL(request.url) const year = parseInt(searchParams.get('year') || new Date().getFullYear().toString()) @@ -25,4 +18,4 @@ export async function GET(request: Request) { const message = err instanceof Error ? err.message : 'Kunde inte generera semesterlöneskuld' return NextResponse.json({ error: message }, { status: 500 }) } -} +}) diff --git a/app/api/reports/vat-declaration/eskd/__tests__/route.test.ts b/app/api/reports/vat-declaration/eskd/__tests__/route.test.ts new file mode 100644 index 00000000..3e7e0947 --- /dev/null +++ b/app/api/reports/vat-declaration/eskd/__tests__/route.test.ts @@ -0,0 +1,135 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { NextResponse } from 'next/server' + +const mockSupabase = { + auth: { getUser: vi.fn() }, + from: vi.fn(), +} + +vi.mock('@/lib/supabase/server', () => ({ + createClient: () => Promise.resolve(mockSupabase), +})) + +vi.mock('@/lib/company/context', () => ({ + getActiveCompanyId: vi.fn().mockResolvedValue('company-1'), +})) + +vi.mock('@/lib/auth/require-auth', () => ({ + requireAuth: vi.fn(), +})) + +vi.mock('@/lib/reports/vat-declaration', () => ({ + calculateVatDeclaration: vi.fn(), +})) + +import { GET } from '../route' +import { requireAuth } from '@/lib/auth/require-auth' +import { calculateVatDeclaration } from '@/lib/reports/vat-declaration' + +const mockUser = { id: 'user-1', email: 'test@test.se' } + +function companySettingsQuery(data: unknown) { + return { + select: vi.fn().mockReturnThis(), + eq: vi.fn().mockReturnThis(), + single: vi.fn().mockResolvedValue({ data, error: null }), + } +} + +function makeDeclaration() { + return { + period: { start: '2026-07-01', end: '2026-09-30' }, + rutor: { + ruta05: 100000, ruta06: 0, ruta07: 0, ruta08: 0, + ruta10: 25000, ruta11: 0, ruta12: 0, + ruta20: 0, ruta21: 0, ruta22: 0, ruta23: 0, ruta24: 0, + ruta30: 0, ruta31: 0, ruta32: 0, + ruta35: 0, ruta36: 0, ruta37: 0, ruta38: 0, ruta39: 0, ruta40: 0, ruta41: 0, ruta42: 0, + ruta48: 3200, + ruta49: 21800, + ruta50: 0, ruta60: 0, ruta61: 0, ruta62: 0, + }, + } +} + +describe('GET /api/reports/vat-declaration/eskd', () => { + beforeEach(() => { + vi.clearAllMocks() + vi.mocked(requireAuth).mockResolvedValue({ + user: mockUser as never, + supabase: mockSupabase as never, + error: null, + }) + mockSupabase.from.mockReturnValue( + companySettingsQuery({ + company_name: 'Testbolaget AB', + org_number: '5566778899', + vat_number: 'SE556677889901', + accounting_method: 'accrual', + }), + ) + vi.mocked(calculateVatDeclaration).mockResolvedValue(makeDeclaration() as never) + }) + + it('returns 401 when not authenticated', async () => { + vi.mocked(requireAuth).mockResolvedValue({ + user: null as never, + supabase: mockSupabase as never, + error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }), + }) + const req = new Request( + 'http://localhost/api/reports/vat-declaration/eskd?periodType=quarterly&year=2026&period=3', + ) + const res = await GET(req, { params: Promise.resolve({}) }) + expect(res.status).toBe(401) + }) + + it('returns 400 when period params are missing', async () => { + const req = new Request('http://localhost/api/reports/vat-declaration/eskd') + const res = await GET(req, { params: Promise.resolve({}) }) + expect(res.status).toBe(400) + }) + + it('returns 404 when company settings are missing', async () => { + mockSupabase.from.mockReturnValue(companySettingsQuery(null)) + const req = new Request( + 'http://localhost/api/reports/vat-declaration/eskd?periodType=quarterly&year=2026&period=3', + ) + const res = await GET(req, { params: Promise.resolve({}) }) + expect(res.status).toBe(404) + }) + + it('returns 400 when the org number is missing or invalid', async () => { + mockSupabase.from.mockReturnValue( + companySettingsQuery({ + company_name: 'Testbolaget AB', + org_number: null, + accounting_method: 'accrual', + }), + ) + const req = new Request( + 'http://localhost/api/reports/vat-declaration/eskd?periodType=quarterly&year=2026&period=3', + ) + const res = await GET(req, { params: Promise.resolve({}) }) + expect(res.status).toBe(400) + expect(calculateVatDeclaration).not.toHaveBeenCalled() + }) + + it('happy path: returns an eSKD XML attachment', async () => { + const req = new Request( + 'http://localhost/api/reports/vat-declaration/eskd?periodType=quarterly&year=2026&period=3', + ) + const res = await GET(req, { params: Promise.resolve({}) }) + expect(res.status).toBe(200) + expect(res.headers.get('Content-Type')).toBe('application/xml; charset=ISO-8859-1') + expect(res.headers.get('Content-Disposition')).toContain( + 'momsdeklaration-2026-07-01--2026-09-30.xml', + ) + const body = await res.text() + expect(body).toContain('') + expect(body).toContain('556677-8899') + expect(body).toContain('202609') + expect(body).toContain('21800') + expect(calculateVatDeclaration).toHaveBeenCalledOnce() + }) +}) diff --git a/app/api/reports/vat-declaration/eskd/route.ts b/app/api/reports/vat-declaration/eskd/route.ts new file mode 100644 index 00000000..3d8cbe33 --- /dev/null +++ b/app/api/reports/vat-declaration/eskd/route.ts @@ -0,0 +1,92 @@ +import { NextResponse } from 'next/server' +import { withRouteContext } from '@/lib/api/with-route-context' +import { calculateVatDeclaration } from '@/lib/reports/vat-declaration' +import { buildESkdFile } from '@/lib/reports/vat-eskd-file' +import type { VatPeriodType, AccountingMethod } from '@/types' + +/** + * Momsdeklaration eSKDUpload (v6.0) XML file for filing at skatteverket.se via + * "Deklarera via fil". Unlike the PDF sibling route (a read/record copy), this + * is a real submission artifact the user uploads, reviews, signs and sends. The + * declaration is computed purely from the bookkeeping, so no Skatteverket + * connection is required. See lib/reports/vat-eskd-file.ts. + */ +export const GET = withRouteContext( + 'reports.vat-declaration.eskd', + async (request, { supabase, companyId }) => { + const { searchParams } = new URL(request.url) + const periodType = searchParams.get('periodType') as VatPeriodType | null + const yearStr = searchParams.get('year') + const periodStr = searchParams.get('period') + // Yearly = räkenskapsår (see the main vat-declaration route); ignored for + // monthly/quarterly. + const fiscalPeriodId = searchParams.get('fiscal_period_id') ?? undefined + + if (!periodType || !yearStr || !periodStr) { + return NextResponse.json( + { error: 'periodType, year, and period are required' }, + { status: 400 }, + ) + } + if (!['monthly', 'quarterly', 'yearly'].includes(periodType)) { + return NextResponse.json({ error: 'Invalid periodType' }, { status: 400 }) + } + const year = parseInt(yearStr, 10) + const period = parseInt(periodStr, 10) + if (isNaN(year) || isNaN(period)) { + return NextResponse.json({ error: 'Invalid year or period' }, { status: 400 }) + } + + const { data: companyRow } = await supabase + .from('company_settings') + .select('*') + .eq('company_id', companyId) + .single() + + if (!companyRow) { + return NextResponse.json({ error: 'Företagsinställningar saknas' }, { status: 404 }) + } + + // The eSKD header requires a valid 10-digit OrgNr; without it the file is an + // "avvisande fel" Skatteverket rejects, so fail honestly up front instead of + // handing the user a file that bounces at upload. 12-digit century-prefixed + // values are fine: the builder strips the prefix (settings rows predating + // org-number normalization hold them, and settings PUT can no longer fix + // org_number after onboarding, so rejecting 12 digits would be a dead end). + const orgDigits = (companyRow.org_number ?? '').replace(/\D/g, '') + if (orgDigits.length !== 10 && orgDigits.length !== 12) { + return NextResponse.json( + { + error: + 'Organisationsnummer saknas eller är ogiltigt. Ange ett giltigt organisationsnummer i företagsinställningarna för att skapa momsdeklarationsfilen.', + }, + { status: 400 }, + ) + } + + const accountingMethod = (companyRow.accounting_method as AccountingMethod) || 'accrual' + + const declaration = await calculateVatDeclaration( + supabase, + companyId, + periodType, + year, + period, + accountingMethod, + { fiscalPeriodId }, + ) + + const xml = buildESkdFile(declaration.rutor, { + orgNumber: companyRow.org_number, + periodEnd: declaration.period.end, + }) + + const filename = `momsdeklaration-${declaration.period.start}--${declaration.period.end}.xml` + return new Response(new Uint8Array(Buffer.from(xml, 'latin1')), { + headers: { + 'Content-Type': 'application/xml; charset=ISO-8859-1', + 'Content-Disposition': `attachment; filename="${filename}"`, + }, + }) + }, +) diff --git a/app/api/reports/vat-declaration/rc-basis-gaps/__tests__/fix.test.ts b/app/api/reports/vat-declaration/rc-basis-gaps/__tests__/fix.test.ts new file mode 100644 index 00000000..eb59945a --- /dev/null +++ b/app/api/reports/vat-declaration/rc-basis-gaps/__tests__/fix.test.ts @@ -0,0 +1,143 @@ +/** + * Tests for POST /api/reports/vat-declaration/rc-basis-gaps/fix. + * + * Exercises the route through the real withRouteContext wrapper, mocking its + * auth/company/write dependencies and correctEntry(). Covers: 401, viewer 403 + * (the route corrects posted entries, so requireWrite must gate it), + * validation 400, entry 404, and the happy-path correction. + */ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { NextResponse } from 'next/server' +import { createQueuedMockSupabase, createMockRequest, parseJsonResponse } from '@/tests/helpers' + +const { supabase, enqueue, reset } = createQueuedMockSupabase() + +const requireAuthMock = vi.fn() +vi.mock('@/lib/auth/require-auth', () => ({ + requireAuth: (...args: unknown[]) => requireAuthMock(...args), +})) + +vi.mock('@/lib/company/context', () => ({ + getActiveCompanyId: vi.fn().mockResolvedValue('company-1'), + requireCompanyId: vi.fn().mockResolvedValue('company-1'), +})) + +const requireWriteMock = vi.fn() +vi.mock('@/lib/auth/require-write', () => ({ + requireWritePermission: (...args: unknown[]) => requireWriteMock(...args), +})) + +const correctEntryMock = vi.fn() +vi.mock('@/lib/core/bookkeeping/storno-service', () => ({ + correctEntry: (...args: unknown[]) => correctEntryMock(...args), +})) + +import { POST } from '../fix/route' + +const ENTRY_ID = '11111111-1111-4111-8111-111111111111' + +function fixRequest(body: Record) { + return createMockRequest('/api/reports/vat-declaration/rc-basis-gaps/fix', { + method: 'POST', + body, + }) +} + +const validBody = { + entryId: ENTRY_ID, + supplierType: 'eu_business', + supplyType: 'service', +} + +describe('POST /api/reports/vat-declaration/rc-basis-gaps/fix', () => { + beforeEach(() => { + vi.clearAllMocks() + reset() + requireAuthMock.mockResolvedValue({ user: { id: 'user-1' }, supabase }) + requireWriteMock.mockResolvedValue({ ok: true }) + }) + + it('returns 401 when unauthenticated', async () => { + requireAuthMock.mockResolvedValue({ + user: null, + supabase, + error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }), + }) + + const response = await POST(fixRequest(validBody), { params: Promise.resolve({}) }) + expect(response.status).toBe(401) + }) + + it('returns 403 for a viewer-role member (requireWrite gate)', async () => { + requireWriteMock.mockResolvedValue({ + ok: false, + response: NextResponse.json({ error: 'Forbidden' }, { status: 403 }), + }) + + const response = await POST(fixRequest(validBody), { params: Promise.resolve({}) }) + expect(response.status).toBe(403) + expect(correctEntryMock).not.toHaveBeenCalled() + }) + + it('returns 400 for an invalid body (bad supplierType)', async () => { + const response = await POST( + fixRequest({ ...validBody, supplierType: 'martian_business' }), + { params: Promise.resolve({}) }, + ) + const { status } = await parseJsonResponse(response) + expect(status).toBe(400) + }) + + it('returns 404 when the entry does not exist in the company', async () => { + enqueue({ data: null, error: { message: 'not found' } }) + + const response = await POST(fixRequest(validBody), { params: Promise.resolve({}) }) + const { status, body } = await parseJsonResponse<{ error: { code: string } }>(response) + + expect(status).toBe(404) + expect(body.error.code).toBe('JOURNAL_ENTRY_NOT_FOUND') + }) + + it('corrects a posted entry with missing basis lines (happy path)', async () => { + enqueue({ + data: { + id: ENTRY_ID, + status: 'posted', + lines: [ + { + account_number: '2614', + debit_amount: 0, + credit_amount: 2500, + line_description: 'Beräknad utgående moms EU-tjänst', + }, + { + account_number: '2645', + debit_amount: 2500, + credit_amount: 0, + line_description: 'Beräknad ingående moms', + }, + ], + }, + }) + correctEntryMock.mockResolvedValue({ + reversal: { id: 'rev-1' }, + corrected: { id: 'cor-1' }, + }) + + const response = await POST(fixRequest(validBody), { params: Promise.resolve({}) }) + const { status, body } = await parseJsonResponse<{ + data: { reversalId: string; correctedId: string; basisAccount: string; basisAmount: number } + }>(response) + + expect(status).toBe(200) + expect(body.data.reversalId).toBe('rev-1') + expect(body.data.correctedId).toBe('cor-1') + expect(correctEntryMock).toHaveBeenCalledWith( + supabase, + 'company-1', + 'user-1', + ENTRY_ID, + expect.any(Array), + ) + }) +}) diff --git a/app/api/reports/vat-declaration/rc-basis-gaps/fix/route.ts b/app/api/reports/vat-declaration/rc-basis-gaps/fix/route.ts index b49166ea..2a4d5781 100644 --- a/app/api/reports/vat-declaration/rc-basis-gaps/fix/route.ts +++ b/app/api/reports/vat-declaration/rc-basis-gaps/fix/route.ts @@ -179,4 +179,5 @@ export const POST = withRouteContext( }) } }, + { requireWrite: true }, ) diff --git a/app/api/reports/vat-declaration/ruta/[ruta]/sources/__tests__/route.test.ts b/app/api/reports/vat-declaration/ruta/[ruta]/sources/__tests__/route.test.ts index 45d13bc9..14dd71c5 100644 --- a/app/api/reports/vat-declaration/ruta/[ruta]/sources/__tests__/route.test.ts +++ b/app/api/reports/vat-declaration/ruta/[ruta]/sources/__tests__/route.test.ts @@ -1,27 +1,27 @@ import { describe, it, expect, vi, beforeEach } from 'vitest' +import { NextResponse } from 'next/server' import { createMockRequest, createMockRouteParams } from '@/tests/helpers' -vi.mock('@/lib/supabase/server', () => ({ - createClient: vi.fn(), +const requireAuthMock = vi.fn() +vi.mock('@/lib/auth/require-auth', () => ({ + requireAuth: (...args: unknown[]) => requireAuthMock(...args), })) vi.mock('@/lib/company/context', () => ({ + getActiveCompanyId: vi.fn().mockResolvedValue('company-1'), requireCompanyId: vi.fn().mockResolvedValue('company-1'), })) -import { createClient } from '@/lib/supabase/server' import { GET } from '../route' -const mockCreateClient = vi.mocked(createClient) +interface SupabaseShape { + from: ReturnType +} function buildSupabase( - user: { id: string } | null, linesResult: { data: unknown; error: unknown } -) { +): SupabaseShape { return { - auth: { - getUser: vi.fn().mockResolvedValue({ data: { user } }), - }, from: vi.fn().mockImplementation(() => ({ select: vi.fn().mockReturnThis(), eq: vi.fn().mockReturnThis(), @@ -39,15 +39,25 @@ function buildSupabase( } } +function authOk(supabase: SupabaseShape) { + requireAuthMock.mockResolvedValue({ user: { id: 'user-1' }, supabase, error: null }) +} + +function authFail(supabase: SupabaseShape) { + requireAuthMock.mockResolvedValue({ + user: null, + supabase, + error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }), + }) +} + beforeEach(() => { vi.clearAllMocks() }) describe('GET /api/reports/vat-declaration/ruta/[ruta]/sources', () => { it('returns 401 when not authenticated', async () => { - mockCreateClient.mockResolvedValue( - buildSupabase(null, { data: [], error: null }) as never - ) + authFail(buildSupabase({ data: [], error: null })) const req = createMockRequest( '/api/reports/vat-declaration/ruta/10/sources', { searchParams: { periodType: 'monthly', year: '2026', period: '5' } } @@ -57,9 +67,7 @@ describe('GET /api/reports/vat-declaration/ruta/[ruta]/sources', () => { }) it('returns 400 when period params are missing', async () => { - mockCreateClient.mockResolvedValue( - buildSupabase({ id: 'user-1' }, { data: [], error: null }) as never - ) + authOk(buildSupabase({ data: [], error: null })) const req = createMockRequest( '/api/reports/vat-declaration/ruta/10/sources' ) @@ -68,9 +76,7 @@ describe('GET /api/reports/vat-declaration/ruta/[ruta]/sources', () => { }) it('returns 404 when ruta has no underlying BAS accounts', async () => { - mockCreateClient.mockResolvedValue( - buildSupabase({ id: 'user-1' }, { data: [], error: null }) as never - ) + authOk(buildSupabase({ data: [], error: null })) const req = createMockRequest( '/api/reports/vat-declaration/ruta/99/sources', { searchParams: { periodType: 'monthly', year: '2026', period: '5' } } @@ -96,9 +102,7 @@ describe('GET /api/reports/vat-declaration/ruta/[ruta]/sources', () => { }, }, ] - mockCreateClient.mockResolvedValue( - buildSupabase({ id: 'user-1' }, { data: linesData, error: null }) as never - ) + authOk(buildSupabase({ data: linesData, error: null })) const req = createMockRequest( '/api/reports/vat-declaration/ruta/10/sources', @@ -123,9 +127,7 @@ describe('GET /api/reports/vat-declaration/ruta/[ruta]/sources', () => { it('returns 400 when the cursor date component is not a structural ISO date', async () => { // Defense-in-depth (ASVS V1.2): the cursor is applied in JS, but a // malformed date component must still be rejected structurally. - mockCreateClient.mockResolvedValue( - buildSupabase({ id: 'user-1' }, { data: [], error: null }) as never - ) + authOk(buildSupabase({ data: [], error: null })) const req = createMockRequest( '/api/reports/vat-declaration/ruta/10/sources', { @@ -189,9 +191,7 @@ describe('GET /api/reports/vat-declaration/ruta/[ruta]/sources', () => { }, }, ] - mockCreateClient.mockResolvedValue( - buildSupabase({ id: 'user-1' }, { data: linesData, error: null }) as never - ) + authOk(buildSupabase({ data: linesData, error: null })) const req = createMockRequest( '/api/reports/vat-declaration/ruta/10/sources', diff --git a/app/api/reports/vat-declaration/ruta/[ruta]/sources/route.ts b/app/api/reports/vat-declaration/ruta/[ruta]/sources/route.ts index 5a146e4a..5593e768 100644 --- a/app/api/reports/vat-declaration/ruta/[ruta]/sources/route.ts +++ b/app/api/reports/vat-declaration/ruta/[ruta]/sources/route.ts @@ -1,6 +1,5 @@ -import { createClient } from '@/lib/supabase/server' +import { withRouteContext } from '@/lib/api/with-route-context' import { NextResponse } from 'next/server' -import { requireCompanyId } from '@/lib/company/context' import { fetchAllRows } from '@/lib/supabase/fetch-all' import { ACCOUNT_RUTA, @@ -24,18 +23,9 @@ import type { VatDeclarationRutor, VatPeriodType } from '@/types' */ const PAGE_LIMIT = 500 -export async function GET( - request: Request, - { params }: { params: Promise<{ ruta: string }> } -) { - const supabase = await createClient() - const { data: { user } } = await supabase.auth.getUser() - - if (!user) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const companyId = await requireCompanyId(supabase, user.id) +export const GET = withRouteContext<{ params: Promise<{ ruta: string }> }>( + 'report.vat_declaration.ruta_sources', + async (request, { supabase, companyId }, { params }) => { const { ruta: rutaParam } = await params const { searchParams } = new URL(request.url) @@ -193,4 +183,4 @@ export async function GET( next_cursor, }, }) -} +}) diff --git a/app/api/reports/vat-declaration/xlsx/route.ts b/app/api/reports/vat-declaration/xlsx/route.ts index d015604a..20d790c0 100644 --- a/app/api/reports/vat-declaration/xlsx/route.ts +++ b/app/api/reports/vat-declaration/xlsx/route.ts @@ -1,10 +1,9 @@ -import { createClient } from '@/lib/supabase/server' +import { withRouteContext } from '@/lib/api/with-route-context' import { NextResponse } from 'next/server' import { calculateVatDeclaration, formatPeriodLabel, } from '@/lib/reports/vat-declaration' -import { requireCompanyId } from '@/lib/company/context' import { reportToWorkbook, textColumn, @@ -24,16 +23,7 @@ interface RutaRow { amount: number } -export async function GET(request: Request) { - const supabase = await createClient() - const { data: { user } } = await supabase.auth.getUser() - - if (!user) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const companyId = await requireCompanyId(supabase, user.id) - +export const GET = withRouteContext('report.vat_declaration.xlsx', async (request, { supabase, companyId }) => { const { searchParams } = new URL(request.url) const periodType = searchParams.get('periodType') as VatPeriodType | null const yearStr = searchParams.get('year') @@ -116,4 +106,4 @@ export async function GET(request: Request) { { status: 500 } ) } -} +}) diff --git a/app/api/salary/employees/[id]/__tests__/route.test.ts b/app/api/salary/employees/[id]/__tests__/route.test.ts new file mode 100644 index 00000000..7873f068 --- /dev/null +++ b/app/api/salary/employees/[id]/__tests__/route.test.ts @@ -0,0 +1,75 @@ +/** + * Auth-wiring tests for /api/salary/employees/[id] (GET/PATCH/DELETE). + * + * Exercises the route through the real withRouteContext wrapper, mocking only + * its auth/company/write dependencies and injecting a queued Supabase mock via + * requireAuth. Covers 401 (unauth), 403 (viewer role), and a DELETE happy path + * (soft delete, BFL retention). + */ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { NextResponse } from 'next/server' +import { createQueuedMockSupabase, createMockRequest, parseJsonResponse } from '@/tests/helpers' + +const { supabase, enqueue, reset } = createQueuedMockSupabase() + +const requireAuthMock = vi.fn() +vi.mock('@/lib/auth/require-auth', () => ({ + requireAuth: (...args: unknown[]) => requireAuthMock(...args), +})) + +vi.mock('@/lib/company/context', () => ({ + getActiveCompanyId: vi.fn().mockResolvedValue('company-1'), + requireCompanyId: vi.fn().mockResolvedValue('company-1'), + getCompanyEntityType: vi.fn().mockResolvedValue('aktiebolag'), +})) + +const requireWriteMock = vi.fn() +vi.mock('@/lib/auth/require-write', () => ({ + requireWritePermission: (...args: unknown[]) => requireWriteMock(...args), +})) + +vi.mock('@/lib/init', () => ({ ensureInitialized: vi.fn() })) + +import { DELETE } from '../route' + +const params = { params: Promise.resolve({ id: 'emp-1' }) } as never + +describe('DELETE /api/salary/employees/[id]', () => { + beforeEach(() => { + vi.clearAllMocks() + reset() + requireAuthMock.mockResolvedValue({ user: { id: 'user-1' }, supabase }) + requireWriteMock.mockResolvedValue({ ok: true }) + }) + + it('returns 401 when unauthenticated', async () => { + requireAuthMock.mockResolvedValue({ + user: null, + supabase, + error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }), + }) + + const response = await DELETE(createMockRequest('/api/salary/employees/emp-1', { method: 'DELETE' }), params) + expect(response.status).toBe(401) + }) + + it('returns 403 for a viewer (no write permission)', async () => { + requireWriteMock.mockResolvedValue({ + ok: false, + response: NextResponse.json({ error: 'Forbidden' }, { status: 403 }), + }) + + const response = await DELETE(createMockRequest('/api/salary/employees/emp-1', { method: 'DELETE' }), params) + expect(response.status).toBe(403) + }) + + it('soft-deletes the employee (happy path)', async () => { + enqueue({ data: { id: 'emp-1' } }) + + const response = await DELETE(createMockRequest('/api/salary/employees/emp-1', { method: 'DELETE' }), params) + const { status, body } = await parseJsonResponse<{ data: { id: string; is_active: boolean } }>(response) + + expect(status).toBe(200) + expect(body.data).toEqual({ id: 'emp-1', is_active: false }) + }) +}) diff --git a/app/api/salary/employees/[id]/absence/__tests__/route.test.ts b/app/api/salary/employees/[id]/absence/__tests__/route.test.ts new file mode 100644 index 00000000..cebecbbc --- /dev/null +++ b/app/api/salary/employees/[id]/absence/__tests__/route.test.ts @@ -0,0 +1,86 @@ +/** + * Auth-wiring tests for /api/salary/employees/[id]/absence (POST upsert). + * + * Runs the route through the real withRouteContext wrapper; mocks auth/company/ + * write and injects a queued Supabase mock via requireAuth. Covers 401, 403 + * (viewer), and a POST happy path. + */ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { NextResponse } from 'next/server' +import { createQueuedMockSupabase, createMockRequest, parseJsonResponse } from '@/tests/helpers' + +const { supabase, enqueue, reset } = createQueuedMockSupabase() + +const requireAuthMock = vi.fn() +vi.mock('@/lib/auth/require-auth', () => ({ + requireAuth: (...args: unknown[]) => requireAuthMock(...args), +})) + +vi.mock('@/lib/company/context', () => ({ + getActiveCompanyId: vi.fn().mockResolvedValue('company-1'), + requireCompanyId: vi.fn().mockResolvedValue('company-1'), +})) + +const requireWriteMock = vi.fn() +vi.mock('@/lib/auth/require-write', () => ({ + requireWritePermission: (...args: unknown[]) => requireWriteMock(...args), +})) + +vi.mock('@/lib/init', () => ({ ensureInitialized: vi.fn() })) + +import { POST } from '../route' + +const params = { params: Promise.resolve({ id: 'emp-1' }) } as never + +function post(body: unknown) { + return createMockRequest('/api/salary/employees/emp-1/absence', { method: 'POST', body }) +} + +describe('POST /api/salary/employees/[id]/absence', () => { + beforeEach(() => { + vi.clearAllMocks() + reset() + requireAuthMock.mockResolvedValue({ user: { id: 'user-1' }, supabase }) + requireWriteMock.mockResolvedValue({ ok: true }) + }) + + it('returns 401 when unauthenticated', async () => { + requireAuthMock.mockResolvedValue({ + user: null, + supabase, + error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }), + }) + + const response = await POST(post({ absence_date: '2026-07-01', absence_type: 'sick', hours: 8 }), params) + expect(response.status).toBe(401) + }) + + it('returns 403 for a viewer (no write permission)', async () => { + requireWriteMock.mockResolvedValue({ + ok: false, + response: NextResponse.json({ error: 'Forbidden' }, { status: 403 }), + }) + + const response = await POST(post({ absence_date: '2026-07-01', absence_type: 'sick', hours: 8 }), params) + expect(response.status).toBe(403) + }) + + it('upserts an absence day (happy path)', async () => { + enqueue({ data: { id: 'emp-1' } }) // loadEmployee + enqueue({ data: null }) // delete existing + enqueue({ data: { id: 'abs-1', absence_date: '2026-07-01', absence_type: 'sick', hours: 8 } }) // insert + + const response = await POST(post({ absence_date: '2026-07-01', absence_type: 'sick', hours: 8 }), params) + const { status, body } = await parseJsonResponse<{ data: { id: string } }>(response) + + expect(status).toBe(201) + expect(body.data.id).toBe('abs-1') + }) + + it('returns 404 when the employee is not in the company', async () => { + enqueue({ data: null }) // loadEmployee → not found + + const response = await POST(post({ absence_date: '2026-07-01', absence_type: 'sick', hours: 8 }), params) + expect(response.status).toBe(404) + }) +}) diff --git a/app/api/salary/employees/[id]/absence/route.ts b/app/api/salary/employees/[id]/absence/route.ts index 8a8f9d44..09c04ef8 100644 --- a/app/api/salary/employees/[id]/absence/route.ts +++ b/app/api/salary/employees/[id]/absence/route.ts @@ -1,22 +1,21 @@ import { z } from 'zod' -import { createClient } from '@/lib/supabase/server' +import type { SupabaseClient } from '@supabase/supabase-js' import { NextResponse } from 'next/server' import { ensureInitialized } from '@/lib/init' +import { withRouteContext } from '@/lib/api/with-route-context' import { validateBody, validateQuery } from '@/lib/api/validate' import { UpsertAbsenceDaySchema, AbsenceRangeQuerySchema, AbsenceTypeSchema, } from '@/lib/api/schemas' -import { requireCompanyId } from '@/lib/company/context' -import { requireWritePermission } from '@/lib/auth/require-write' const isoDate = z.string().regex(/^\d{4}-\d{2}-\d{2}$/) ensureInitialized() async function loadEmployee( - supabase: Awaited>, + supabase: SupabaseClient, employeeId: string, companyId: string, ) { @@ -29,106 +28,94 @@ async function loadEmployee( return data } -export async function GET( - request: Request, - { params }: { params: Promise<{ id: string }> }, -) { - const { id: employeeId } = await params - const supabase = await createClient() - const { data: { user } } = await supabase.auth.getUser() - if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) +export const GET = withRouteContext<{ params: Promise<{ id: string }> }>( + 'salary.employees.absence.list', + async (request, { supabase, companyId }, { params }) => { + const { id: employeeId } = await params - const companyId = await requireCompanyId(supabase, user.id) - - const employee = await loadEmployee(supabase, employeeId, companyId) - if (!employee) { - return NextResponse.json({ error: 'Anställd hittades inte' }, { status: 404 }) - } - - const query = validateQuery(request, AbsenceRangeQuerySchema) - if (!query.success) return query.response - - const { data, error } = await supabase - .from('salary_absence_days') - .select('id, absence_date, absence_type, hours, notes, salary_run_employee_id, created_at, updated_at') - .eq('company_id', companyId) - .eq('employee_id', employeeId) - .gte('absence_date', query.data.from) - .lte('absence_date', query.data.to) - .order('absence_date', { ascending: true }) - - if (error) { - return NextResponse.json({ error: error.message }, { status: 500 }) - } - - return NextResponse.json({ data }) -} - -export async function POST( - request: Request, - { params }: { params: Promise<{ id: string }> }, -) { - const { id: employeeId } = await params - const supabase = await createClient() - const { data: { user } } = await supabase.auth.getUser() - if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - - const writeCheck = await requireWritePermission(supabase, user.id) - if (!writeCheck.ok) return writeCheck.response - - const companyId = await requireCompanyId(supabase, user.id) - - const employee = await loadEmployee(supabase, employeeId, companyId) - if (!employee) { - return NextResponse.json({ error: 'Anställd hittades inte' }, { status: 404 }) - } - - const validation = await validateBody(request, UpsertAbsenceDaySchema) - if (!validation.success) return validation.response - const body = validation.data - - // Upsert via DELETE+INSERT on the natural key (employee, date, type) so the - // notes/hours/run-link can be replaced cleanly. The unique index makes ON - // CONFLICT viable too, but Supabase's typed client doesn't expose - // onConflict for our composite key without a named constraint name: - // delete-then-insert keeps the pattern consistent with token-store.ts. - const { error: deleteError } = await supabase - .from('salary_absence_days') - .delete() - .eq('company_id', companyId) - .eq('employee_id', employeeId) - .eq('absence_date', body.absence_date) - .eq('absence_type', body.absence_type) - - if (deleteError) { - return NextResponse.json({ error: deleteError.message }, { status: 500 }) - } - - const { data, error } = await supabase - .from('salary_absence_days') - .insert({ - company_id: companyId, - employee_id: employeeId, - absence_date: body.absence_date, - absence_type: body.absence_type, - hours: body.hours, - notes: body.notes ?? null, - salary_run_employee_id: body.salary_run_employee_id ?? null, - }) - .select() - .single() - - if (error) { - // The 24h cap trigger raises check_violation when worked + absence > 24h - // for the same date. Surface a clean 409 with the Swedish message. - if (error.message?.includes('Total tid') || error.code === '23514') { - return NextResponse.json({ error: error.message }, { status: 409 }) + const employee = await loadEmployee(supabase, employeeId, companyId) + if (!employee) { + return NextResponse.json({ error: 'Anställd hittades inte' }, { status: 404 }) } - return NextResponse.json({ error: error.message }, { status: 500 }) - } - return NextResponse.json({ data }, { status: 201 }) -} + const query = validateQuery(request, AbsenceRangeQuerySchema) + if (!query.success) return query.response + + const { data, error } = await supabase + .from('salary_absence_days') + .select('id, absence_date, absence_type, hours, notes, salary_run_employee_id, created_at, updated_at') + .eq('company_id', companyId) + .eq('employee_id', employeeId) + .gte('absence_date', query.data.from) + .lte('absence_date', query.data.to) + .order('absence_date', { ascending: true }) + + if (error) { + return NextResponse.json({ error: error.message }, { status: 500 }) + } + + return NextResponse.json({ data }) + }, +) + +export const POST = withRouteContext<{ params: Promise<{ id: string }> }>( + 'salary.employees.absence.upsert', + async (request, { supabase, companyId }, { params }) => { + const { id: employeeId } = await params + + const employee = await loadEmployee(supabase, employeeId, companyId) + if (!employee) { + return NextResponse.json({ error: 'Anställd hittades inte' }, { status: 404 }) + } + + const validation = await validateBody(request, UpsertAbsenceDaySchema) + if (!validation.success) return validation.response + const body = validation.data + + // Upsert via DELETE+INSERT on the natural key (employee, date, type) so the + // notes/hours/run-link can be replaced cleanly. The unique index makes ON + // CONFLICT viable too, but Supabase's typed client doesn't expose + // onConflict for our composite key without a named constraint name: + // delete-then-insert keeps the pattern consistent with token-store.ts. + const { error: deleteError } = await supabase + .from('salary_absence_days') + .delete() + .eq('company_id', companyId) + .eq('employee_id', employeeId) + .eq('absence_date', body.absence_date) + .eq('absence_type', body.absence_type) + + if (deleteError) { + return NextResponse.json({ error: deleteError.message }, { status: 500 }) + } + + const { data, error } = await supabase + .from('salary_absence_days') + .insert({ + company_id: companyId, + employee_id: employeeId, + absence_date: body.absence_date, + absence_type: body.absence_type, + hours: body.hours, + notes: body.notes ?? null, + salary_run_employee_id: body.salary_run_employee_id ?? null, + }) + .select() + .single() + + if (error) { + // The 24h cap trigger raises check_violation when worked + absence > 24h + // for the same date. Surface a clean 409 with the Swedish message. + if (error.message?.includes('Total tid') || error.code === '23514') { + return NextResponse.json({ error: error.message }, { status: 409 }) + } + return NextResponse.json({ error: error.message }, { status: 500 }) + } + + return NextResponse.json({ data }, { status: 201 }) + }, + { requireWrite: true }, +) // Two modes: ?date=YYYY-MM-DD&type=... (single row) or ?from=…&to=… (range). // We don't reuse AbsenceRangeQuerySchema.partial() because Zod refuses @@ -140,57 +127,50 @@ const DeleteQuerySchema = z.object({ type: AbsenceTypeSchema.optional(), }) -export async function DELETE( - request: Request, - { params }: { params: Promise<{ id: string }> }, -) { - const { id: employeeId } = await params - const supabase = await createClient() - const { data: { user } } = await supabase.auth.getUser() - if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) +export const DELETE = withRouteContext<{ params: Promise<{ id: string }> }>( + 'salary.employees.absence.delete', + async (request, { supabase, companyId }, { params }) => { + const { id: employeeId } = await params - const writeCheck = await requireWritePermission(supabase, user.id) - if (!writeCheck.ok) return writeCheck.response + const employee = await loadEmployee(supabase, employeeId, companyId) + if (!employee) { + return NextResponse.json({ error: 'Anställd hittades inte' }, { status: 404 }) + } - const companyId = await requireCompanyId(supabase, user.id) + const query = validateQuery(request, DeleteQuerySchema) + if (!query.success) return query.response + const { date, type, from, to } = query.data - const employee = await loadEmployee(supabase, employeeId, companyId) - if (!employee) { - return NextResponse.json({ error: 'Anställd hittades inte' }, { status: 404 }) - } + // Two delete modes: a single (date, type) row, or a date range. + const hasSingle = !!date + const hasRange = !!from && !!to + if (!hasSingle && !hasRange) { + return NextResponse.json( + { error: 'Ange antingen ?date=YYYY-MM-DD&type=... eller ?from=...&to=...' }, + { status: 400 }, + ) + } - const query = validateQuery(request, DeleteQuerySchema) - if (!query.success) return query.response - const { date, type, from, to } = query.data + let q = supabase + .from('salary_absence_days') + .delete() + .eq('company_id', companyId) + .eq('employee_id', employeeId) - // Two delete modes: a single (date, type) row, or a date range. - const hasSingle = !!date - const hasRange = !!from && !!to - if (!hasSingle && !hasRange) { - return NextResponse.json( - { error: 'Ange antingen ?date=YYYY-MM-DD&type=... eller ?from=...&to=...' }, - { status: 400 }, - ) - } + if (hasSingle) { + q = q.eq('absence_date', date!) + if (type) q = q.eq('absence_type', type) + } else { + q = q.gte('absence_date', from!).lte('absence_date', to!) + if (type) q = q.eq('absence_type', type) + } - let q = supabase - .from('salary_absence_days') - .delete() - .eq('company_id', companyId) - .eq('employee_id', employeeId) + const { error } = await q + if (error) { + return NextResponse.json({ error: error.message }, { status: 500 }) + } - if (hasSingle) { - q = q.eq('absence_date', date!) - if (type) q = q.eq('absence_type', type) - } else { - q = q.gte('absence_date', from!).lte('absence_date', to!) - if (type) q = q.eq('absence_type', type) - } - - const { error } = await q - if (error) { - return NextResponse.json({ error: error.message }, { status: 500 }) - } - - return NextResponse.json({ data: { ok: true } }) -} + return NextResponse.json({ data: { ok: true } }) + }, + { requireWrite: true }, +) diff --git a/app/api/salary/employees/[id]/benefits/[benefitId]/__tests__/route.test.ts b/app/api/salary/employees/[id]/benefits/[benefitId]/__tests__/route.test.ts new file mode 100644 index 00000000..9062a548 --- /dev/null +++ b/app/api/salary/employees/[id]/benefits/[benefitId]/__tests__/route.test.ts @@ -0,0 +1,76 @@ +/** + * Auth-wiring tests for /api/salary/employees/[id]/benefits/[benefitId] + * (PATCH/DELETE). Runs through the real withRouteContext wrapper; mocks auth/ + * company/write and injects a queued Supabase mock via requireAuth. Covers 401, + * 403 (viewer), and a DELETE happy path. + */ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { NextResponse } from 'next/server' +import { createQueuedMockSupabase, createMockRequest, parseJsonResponse } from '@/tests/helpers' + +const { supabase, enqueue, reset } = createQueuedMockSupabase() + +const requireAuthMock = vi.fn() +vi.mock('@/lib/auth/require-auth', () => ({ + requireAuth: (...args: unknown[]) => requireAuthMock(...args), +})) + +vi.mock('@/lib/company/context', () => ({ + getActiveCompanyId: vi.fn().mockResolvedValue('company-1'), + requireCompanyId: vi.fn().mockResolvedValue('company-1'), +})) + +const requireWriteMock = vi.fn() +vi.mock('@/lib/auth/require-write', () => ({ + requireWritePermission: (...args: unknown[]) => requireWriteMock(...args), +})) + +vi.mock('@/lib/init', () => ({ ensureInitialized: vi.fn() })) + +import { DELETE } from '../route' + +const params = { params: Promise.resolve({ id: 'emp-1', benefitId: 'ben-1' }) } as never + +function del() { + return createMockRequest('/api/salary/employees/emp-1/benefits/ben-1', { method: 'DELETE' }) +} + +describe('DELETE /api/salary/employees/[id]/benefits/[benefitId]', () => { + beforeEach(() => { + vi.clearAllMocks() + reset() + requireAuthMock.mockResolvedValue({ user: { id: 'user-1' }, supabase }) + requireWriteMock.mockResolvedValue({ ok: true }) + }) + + it('returns 401 when unauthenticated', async () => { + requireAuthMock.mockResolvedValue({ + user: null, + supabase, + error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }), + }) + + const response = await DELETE(del(), params) + expect(response.status).toBe(401) + }) + + it('returns 403 for a viewer (no write permission)', async () => { + requireWriteMock.mockResolvedValue({ + ok: false, + response: NextResponse.json({ error: 'Forbidden' }, { status: 403 }), + }) + + const response = await DELETE(del(), params) + expect(response.status).toBe(403) + }) + + it('deletes the benefit (happy path)', async () => { + enqueue({ data: null }) // delete (no error) + + const response = await DELETE(del(), params) + const { status, body } = await parseJsonResponse<{ data: { id: string; deleted: boolean } }>(response) + + expect(status).toBe(200) + expect(body.data).toEqual({ id: 'ben-1', deleted: true }) + }) +}) diff --git a/app/api/salary/employees/[id]/benefits/[benefitId]/route.ts b/app/api/salary/employees/[id]/benefits/[benefitId]/route.ts index 3da76f2c..71b50e89 100644 --- a/app/api/salary/employees/[id]/benefits/[benefitId]/route.ts +++ b/app/api/salary/employees/[id]/benefits/[benefitId]/route.ts @@ -1,103 +1,87 @@ -import { createClient } from '@/lib/supabase/server' import { NextResponse } from 'next/server' import { ensureInitialized } from '@/lib/init' +import { withRouteContext } from '@/lib/api/with-route-context' import { validateBody } from '@/lib/api/validate' -import { requireCompanyId } from '@/lib/company/context' -import { requireWritePermission } from '@/lib/auth/require-write' import { UpdateEmployeeBenefitSchema } from '@/lib/api/schemas' import { calculateBikeBenefit } from '@/lib/salary/benefits' ensureInitialized() -export async function PATCH( - request: Request, - { params }: { params: Promise<{ id: string; benefitId: string }> } -) { - const { id, benefitId } = await params - const supabase = await createClient() - const { data: { user } } = await supabase.auth.getUser() - if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) +export const PATCH = withRouteContext<{ params: Promise<{ id: string; benefitId: string }> }>( + 'salary.employees.benefits.update', + async (request, { supabase, companyId }, { params }) => { + const { id, benefitId } = await params - const writeCheck = await requireWritePermission(supabase, user.id) - if (!writeCheck.ok) return writeCheck.response + const validation = await validateBody(request, UpdateEmployeeBenefitSchema) + if (!validation.success) return validation.response + const body = validation.data - const companyId = await requireCompanyId(supabase, user.id) + const { data: existing, error: fetchError } = await supabase + .from('employee_benefits') + .select('benefit_type, metadata') + .eq('id', benefitId) + .eq('employee_id', id) + .eq('company_id', companyId) + .single() - const validation = await validateBody(request, UpdateEmployeeBenefitSchema) - if (!validation.success) return validation.response - const body = validation.data - - const { data: existing, error: fetchError } = await supabase - .from('employee_benefits') - .select('benefit_type, metadata') - .eq('id', benefitId) - .eq('employee_id', id) - .eq('company_id', companyId) - .single() - - if (fetchError || !existing) { - return NextResponse.json({ error: 'Förmån hittades inte' }, { status: 404 }) - } - - const updates: Record = { ...body } - - if (body.annual_market_value !== undefined) { - if (existing.benefit_type !== 'bike') { - return NextResponse.json( - { error: 'annual_market_value gäller endast cykelförmån' }, - { status: 400 }, - ) + if (fetchError || !existing) { + return NextResponse.json({ error: 'Förmån hittades inte' }, { status: 404 }) } - const calc = calculateBikeBenefit(body.annual_market_value) - updates.monthly_value = calc.monthlyValue - updates.metadata = { - ...(existing.metadata as Record ?? {}), - ...(body.metadata ?? {}), - annual_market_value: body.annual_market_value, - annual_taxable: calc.annualTaxable, - tax_free_portion: calc.taxFreePortion, + + const updates: Record = { ...body } + + if (body.annual_market_value !== undefined) { + if (existing.benefit_type !== 'bike') { + return NextResponse.json( + { error: 'annual_market_value gäller endast cykelförmån' }, + { status: 400 }, + ) + } + const calc = calculateBikeBenefit(body.annual_market_value) + updates.monthly_value = calc.monthlyValue + updates.metadata = { + ...(existing.metadata as Record ?? {}), + ...(body.metadata ?? {}), + annual_market_value: body.annual_market_value, + annual_taxable: calc.annualTaxable, + tax_free_portion: calc.taxFreePortion, + } + delete (updates as Record).annual_market_value } - delete (updates as Record).annual_market_value - } - const { data, error } = await supabase - .from('employee_benefits') - .update(updates) - .eq('id', benefitId) - .eq('employee_id', id) - .eq('company_id', companyId) - .select() - .single() + const { data, error } = await supabase + .from('employee_benefits') + .update(updates) + .eq('id', benefitId) + .eq('employee_id', id) + .eq('company_id', companyId) + .select() + .single() - if (error || !data) { - return NextResponse.json({ error: 'Förmån hittades inte' }, { status: 404 }) - } + if (error || !data) { + return NextResponse.json({ error: 'Förmån hittades inte' }, { status: 404 }) + } - return NextResponse.json({ data }) -} + return NextResponse.json({ data }) + }, + { requireWrite: true }, +) -export async function DELETE( - _request: Request, - { params }: { params: Promise<{ id: string; benefitId: string }> } -) { - const { id, benefitId } = await params - const supabase = await createClient() - const { data: { user } } = await supabase.auth.getUser() - if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) +export const DELETE = withRouteContext<{ params: Promise<{ id: string; benefitId: string }> }>( + 'salary.employees.benefits.delete', + async (_request, { supabase, companyId }, { params }) => { + const { id, benefitId } = await params - const writeCheck = await requireWritePermission(supabase, user.id) - if (!writeCheck.ok) return writeCheck.response + const { error } = await supabase + .from('employee_benefits') + .delete() + .eq('id', benefitId) + .eq('employee_id', id) + .eq('company_id', companyId) - const companyId = await requireCompanyId(supabase, user.id) + if (error) return NextResponse.json({ error: error.message }, { status: 500 }) - const { error } = await supabase - .from('employee_benefits') - .delete() - .eq('id', benefitId) - .eq('employee_id', id) - .eq('company_id', companyId) - - if (error) return NextResponse.json({ error: error.message }, { status: 500 }) - - return NextResponse.json({ data: { id: benefitId, deleted: true } }) -} + return NextResponse.json({ data: { id: benefitId, deleted: true } }) + }, + { requireWrite: true }, +) diff --git a/app/api/salary/employees/[id]/benefits/__tests__/route.test.ts b/app/api/salary/employees/[id]/benefits/__tests__/route.test.ts new file mode 100644 index 00000000..76de831c --- /dev/null +++ b/app/api/salary/employees/[id]/benefits/__tests__/route.test.ts @@ -0,0 +1,92 @@ +/** + * Auth-wiring tests for /api/salary/employees/[id]/benefits (POST create). + * + * Runs the route through the real withRouteContext wrapper; mocks auth/company/ + * write and injects a queued Supabase mock via requireAuth. Covers 401, 403 + * (viewer), and a POST happy path. + */ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { NextResponse } from 'next/server' +import { createQueuedMockSupabase, createMockRequest, parseJsonResponse } from '@/tests/helpers' + +const { supabase, enqueue, reset } = createQueuedMockSupabase() + +const requireAuthMock = vi.fn() +vi.mock('@/lib/auth/require-auth', () => ({ + requireAuth: (...args: unknown[]) => requireAuthMock(...args), +})) + +vi.mock('@/lib/company/context', () => ({ + getActiveCompanyId: vi.fn().mockResolvedValue('company-1'), + requireCompanyId: vi.fn().mockResolvedValue('company-1'), +})) + +const requireWriteMock = vi.fn() +vi.mock('@/lib/auth/require-write', () => ({ + requireWritePermission: (...args: unknown[]) => requireWriteMock(...args), +})) + +vi.mock('@/lib/init', () => ({ ensureInitialized: vi.fn() })) + +import { POST } from '../route' + +const params = { params: Promise.resolve({ id: 'emp-1' }) } as never + +function post(body: unknown) { + return createMockRequest('/api/salary/employees/emp-1/benefits', { method: 'POST', body }) +} + +const validBenefit = { + benefit_type: 'other', + description: 'Friskvård', + monthly_value: 500, + valid_from: '2026-01-01', +} + +describe('POST /api/salary/employees/[id]/benefits', () => { + beforeEach(() => { + vi.clearAllMocks() + reset() + requireAuthMock.mockResolvedValue({ user: { id: 'user-1' }, supabase }) + requireWriteMock.mockResolvedValue({ ok: true }) + }) + + it('returns 401 when unauthenticated', async () => { + requireAuthMock.mockResolvedValue({ + user: null, + supabase, + error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }), + }) + + const response = await POST(post(validBenefit), params) + expect(response.status).toBe(401) + }) + + it('returns 403 for a viewer (no write permission)', async () => { + requireWriteMock.mockResolvedValue({ + ok: false, + response: NextResponse.json({ error: 'Forbidden' }, { status: 403 }), + }) + + const response = await POST(post(validBenefit), params) + expect(response.status).toBe(403) + }) + + it('creates a benefit (happy path)', async () => { + enqueue({ data: { id: 'emp-1' } }) // employee ownership check + enqueue({ data: { id: 'ben-1', benefit_type: 'other', monthly_value: 500 } }) // insert + + const response = await POST(post(validBenefit), params) + const { status, body } = await parseJsonResponse<{ data: { id: string } }>(response) + + expect(status).toBe(201) + expect(body.data.id).toBe('ben-1') + }) + + it('returns 404 when the employee is not in the company', async () => { + enqueue({ data: null }) // employee ownership check → not found + + const response = await POST(post(validBenefit), params) + expect(response.status).toBe(404) + }) +}) diff --git a/app/api/salary/employees/[id]/benefits/route.ts b/app/api/salary/employees/[id]/benefits/route.ts index 2aadd883..4e56479d 100644 --- a/app/api/salary/employees/[id]/benefits/route.ts +++ b/app/api/salary/employees/[id]/benefits/route.ts @@ -1,97 +1,83 @@ -import { createClient } from '@/lib/supabase/server' import { NextResponse } from 'next/server' import { ensureInitialized } from '@/lib/init' +import { withRouteContext } from '@/lib/api/with-route-context' import { validateBody } from '@/lib/api/validate' -import { requireCompanyId } from '@/lib/company/context' -import { requireWritePermission } from '@/lib/auth/require-write' import { CreateEmployeeBenefitSchema } from '@/lib/api/schemas' import { calculateBikeBenefit } from '@/lib/salary/benefits' ensureInitialized() -export async function GET( - _request: Request, - { params }: { params: Promise<{ id: string }> } -) { - const { id } = await params - const supabase = await createClient() - const { data: { user } } = await supabase.auth.getUser() - if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) +export const GET = withRouteContext<{ params: Promise<{ id: string }> }>( + 'salary.employees.benefits.list', + async (_request, { supabase, companyId }, { params }) => { + const { id } = await params - const companyId = await requireCompanyId(supabase, user.id) + const { data, error } = await supabase + .from('employee_benefits') + .select('*') + .eq('employee_id', id) + .eq('company_id', companyId) + .order('valid_from', { ascending: false }) - const { data, error } = await supabase - .from('employee_benefits') - .select('*') - .eq('employee_id', id) - .eq('company_id', companyId) - .order('valid_from', { ascending: false }) + if (error) return NextResponse.json({ error: error.message }, { status: 500 }) - if (error) return NextResponse.json({ error: error.message }, { status: 500 }) + return NextResponse.json({ data }) + }, +) - return NextResponse.json({ data }) -} +export const POST = withRouteContext<{ params: Promise<{ id: string }> }>( + 'salary.employees.benefits.create', + async (request, { supabase, companyId, user }, { params }) => { + const { id } = await params -export async function POST( - request: Request, - { params }: { params: Promise<{ id: string }> } -) { - const { id } = await params - const supabase = await createClient() - const { data: { user } } = await supabase.auth.getUser() - if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + const validation = await validateBody(request, CreateEmployeeBenefitSchema) + if (!validation.success) return validation.response + const body = validation.data - const writeCheck = await requireWritePermission(supabase, user.id) - if (!writeCheck.ok) return writeCheck.response + // Confirm employee belongs to the company + const { data: emp } = await supabase + .from('employees') + .select('id') + .eq('id', id) + .eq('company_id', companyId) + .single() + if (!emp) return NextResponse.json({ error: 'Anställd hittades inte' }, { status: 404 }) - const companyId = await requireCompanyId(supabase, user.id) + // Bike benefit: derive monthly_value + metadata from the annual market value + let monthlyValue = body.monthly_value ?? 0 + let metadata: Record = body.metadata ?? {} - const validation = await validateBody(request, CreateEmployeeBenefitSchema) - if (!validation.success) return validation.response - const body = validation.data - - // Confirm employee belongs to the company - const { data: emp } = await supabase - .from('employees') - .select('id') - .eq('id', id) - .eq('company_id', companyId) - .single() - if (!emp) return NextResponse.json({ error: 'Anställd hittades inte' }, { status: 404 }) - - // Bike benefit: derive monthly_value + metadata from the annual market value - let monthlyValue = body.monthly_value ?? 0 - let metadata: Record = body.metadata ?? {} - - if (body.benefit_type === 'bike' && body.annual_market_value !== undefined) { - const calc = calculateBikeBenefit(body.annual_market_value) - monthlyValue = calc.monthlyValue - metadata = { - ...metadata, - annual_market_value: body.annual_market_value, - annual_taxable: calc.annualTaxable, - tax_free_portion: calc.taxFreePortion, + if (body.benefit_type === 'bike' && body.annual_market_value !== undefined) { + const calc = calculateBikeBenefit(body.annual_market_value) + monthlyValue = calc.monthlyValue + metadata = { + ...metadata, + annual_market_value: body.annual_market_value, + annual_taxable: calc.annualTaxable, + tax_free_portion: calc.taxFreePortion, + } } - } - const { data, error } = await supabase - .from('employee_benefits') - .insert({ - employee_id: id, - company_id: companyId, - user_id: user.id, - benefit_type: body.benefit_type, - description: body.description, - monthly_value: monthlyValue, - valid_from: body.valid_from, - valid_to: body.valid_to ?? null, - metadata, - is_active: body.is_active ?? true, - }) - .select() - .single() + const { data, error } = await supabase + .from('employee_benefits') + .insert({ + employee_id: id, + company_id: companyId, + user_id: user.id, + benefit_type: body.benefit_type, + description: body.description, + monthly_value: monthlyValue, + valid_from: body.valid_from, + valid_to: body.valid_to ?? null, + metadata, + is_active: body.is_active ?? true, + }) + .select() + .single() - if (error) return NextResponse.json({ error: error.message }, { status: 500 }) + if (error) return NextResponse.json({ error: error.message }, { status: 500 }) - return NextResponse.json({ data }, { status: 201 }) -} + return NextResponse.json({ data }, { status: 201 }) + }, + { requireWrite: true }, +) diff --git a/app/api/salary/employees/[id]/route.ts b/app/api/salary/employees/[id]/route.ts index 41e73b3f..90213b55 100644 --- a/app/api/salary/employees/[id]/route.ts +++ b/app/api/salary/employees/[id]/route.ts @@ -1,181 +1,161 @@ -import { createClient } from '@/lib/supabase/server' import { NextResponse } from 'next/server' import { ensureInitialized } from '@/lib/init' +import { withRouteContext } from '@/lib/api/with-route-context' import { validateBody } from '@/lib/api/validate' import { UpdateEmployeeSchema } from '@/lib/api/schemas' -import { requireCompanyId, getCompanyEntityType } from '@/lib/company/context' -import { requireWritePermission } from '@/lib/auth/require-write' +import { getCompanyEntityType } from '@/lib/company/context' import { decryptPersonnummer, encryptPersonnummer, extractLast4, maskPersonnummer, validatePersonnummer } from '@/lib/salary/personnummer' import { isEmploymentTypeAllowedForEntity, EF_OWNER_EMPLOYMENT_ERROR } from '@/lib/salary/employment-rules' import { validateEmployeeBankAccount } from '@/lib/salary/payment/bank-account' ensureInitialized() -export async function GET( - request: Request, - { params }: { params: Promise<{ id: string }> } -) { - const { id } = await params - const supabase = await createClient() - const { data: { user } } = await supabase.auth.getUser() - if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) +export const GET = withRouteContext<{ params: Promise<{ id: string }> }>( + 'salary.employees.get', + async (request, { supabase, companyId }, { params }) => { + const { id } = await params - const companyId = await requireCompanyId(supabase, user.id) + const { data: employee, error } = await supabase + .from('employees') + .select('*') + .eq('id', id) + .eq('company_id', companyId) + .single() - const { data: employee, error } = await supabase - .from('employees') - .select('*') - .eq('id', id) - .eq('company_id', companyId) - .single() - - if (error || !employee) { - return NextResponse.json({ error: 'Anställd hittades inte' }, { status: 404 }) - } - - return NextResponse.json({ - data: { - ...employee, - personnummer: maskPersonnummer(decryptPersonnummer(employee.personnummer)), - }, - }) -} - -export async function PATCH( - request: Request, - { params }: { params: Promise<{ id: string }> } -) { - const { id } = await params - const supabase = await createClient() - const { data: { user } } = await supabase.auth.getUser() - if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - - const writeCheck = await requireWritePermission(supabase, user.id) - if (!writeCheck.ok) return writeCheck.response - - const companyId = await requireCompanyId(supabase, user.id) - - const validation = await validateBody(request, UpdateEmployeeSchema) - if (!validation.success) return validation.response - const body = validation.data - - // Load existing employee for merged validation - const { data: existing, error: fetchError } = await supabase - .from('employees') - .select('*') - .eq('id', id) - .eq('company_id', companyId) - .single() - - if (fetchError || !existing) { - return NextResponse.json({ error: 'Anställd hittades inte' }, { status: 404 }) - } - - // Merged validation: combine existing + updates to check full integrity - const merged = { ...existing, ...body } - const mergedErrors: string[] = [] - - if (merged.salary_type === 'monthly' && (!merged.monthly_salary || merged.monthly_salary <= 0)) { - mergedErrors.push('Månadslön krävs och måste vara större än 0 för månadslöneform') - } - if (merged.salary_type === 'hourly' && (!merged.hourly_rate || merged.hourly_rate <= 0)) { - mergedErrors.push('Timlön krävs och måste vara större än 0 för timlöneform') - } - if (merged.f_skatt_status === 'a_skatt' && !merged.is_sidoinkomst && !merged.tax_table_number) { - mergedErrors.push('Skattetabell krävs för A-skatt anställda') - } - if (mergedErrors.length > 0) { - return NextResponse.json({ error: mergedErrors.join('. ') }, { status: 400 }) - } - - // Validate bank details only when the caller actually changes them, so a - // legacy employee with incomplete/free-text bank data (from before this - // validation existed) can still be edited in unrelated ways. Validate the - // merged pair so both-or-neither reflects the row's real end state. - const clearingChanged = - body.clearing_number !== undefined && body.clearing_number !== existing.clearing_number - const accountChanged = - body.bank_account_number !== undefined && body.bank_account_number !== existing.bank_account_number - if (clearingChanged || accountChanged) { - const bankIssues = validateEmployeeBankAccount(merged.clearing_number, merged.bank_account_number) - if (bankIssues.length > 0) { - return NextResponse.json({ error: bankIssues.map((i) => i.message).join('. ') }, { status: 400 }) + if (error || !employee) { + return NextResponse.json({ error: 'Anställd hittades inte' }, { status: 404 }) } - } - // Only when the caller is changing employment_type: block setting an EF's - // owner/board on payroll (mirrors the enforce_ef_no_owner_employee trigger, - // which fires on UPDATE OF employment_type: so unrelated edits to any - // grandfathered row aren't blocked). #782 - if (body.employment_type !== undefined) { - const entityType = await getCompanyEntityType(supabase, companyId) - if (!isEmploymentTypeAllowedForEntity(entityType, body.employment_type)) { - return NextResponse.json({ error: EF_OWNER_EMPLOYMENT_ERROR }, { status: 400 }) + return NextResponse.json({ + data: { + ...employee, + personnummer: maskPersonnummer(decryptPersonnummer(employee.personnummer)), + }, + }) + }, +) + +export const PATCH = withRouteContext<{ params: Promise<{ id: string }> }>( + 'salary.employees.update', + async (request, { supabase, companyId }, { params }) => { + const { id } = await params + + const validation = await validateBody(request, UpdateEmployeeSchema) + if (!validation.success) return validation.response + const body = validation.data + + // Load existing employee for merged validation + const { data: existing, error: fetchError } = await supabase + .from('employees') + .select('*') + .eq('id', id) + .eq('company_id', companyId) + .single() + + if (fetchError || !existing) { + return NextResponse.json({ error: 'Anställd hittades inte' }, { status: 404 }) } - } - // Build update object - const updates: Record = { ...body } + // Merged validation: combine existing + updates to check full integrity + const merged = { ...existing, ...body } + const mergedErrors: string[] = [] - // Handle personnummer update if provided - if (body.personnummer) { - const pnrValidation = validatePersonnummer(body.personnummer) - if (!pnrValidation.valid) { - return NextResponse.json({ error: pnrValidation.error }, { status: 400 }) + if (merged.salary_type === 'monthly' && (!merged.monthly_salary || merged.monthly_salary <= 0)) { + mergedErrors.push('Månadslön krävs och måste vara större än 0 för månadslöneform') } - updates.personnummer = encryptPersonnummer(body.personnummer) - updates.personnummer_last4 = extractLast4(body.personnummer) - } - - const { data: updated, error } = await supabase - .from('employees') - .update(updates) - .eq('id', id) - .eq('company_id', companyId) - .select() - .single() - - if (error) { - if (error.code === '23505') { - return NextResponse.json({ error: 'En anställd med detta personnummer finns redan' }, { status: 409 }) + if (merged.salary_type === 'hourly' && (!merged.hourly_rate || merged.hourly_rate <= 0)) { + mergedErrors.push('Timlön krävs och måste vara större än 0 för timlöneform') + } + if (merged.f_skatt_status === 'a_skatt' && !merged.is_sidoinkomst && !merged.tax_table_number) { + mergedErrors.push('Skattetabell krävs för A-skatt anställda') + } + if (mergedErrors.length > 0) { + return NextResponse.json({ error: mergedErrors.join('. ') }, { status: 400 }) } - return NextResponse.json({ error: error.message }, { status: 500 }) - } - return NextResponse.json({ - data: { - ...updated, - personnummer: maskPersonnummer(decryptPersonnummer(updated.personnummer)), - }, - }) -} + // Validate bank details only when the caller actually changes them, so a + // legacy employee with incomplete/free-text bank data (from before this + // validation existed) can still be edited in unrelated ways. Validate the + // merged pair so both-or-neither reflects the row's real end state. + const clearingChanged = + body.clearing_number !== undefined && body.clearing_number !== existing.clearing_number + const accountChanged = + body.bank_account_number !== undefined && body.bank_account_number !== existing.bank_account_number + if (clearingChanged || accountChanged) { + const bankIssues = validateEmployeeBankAccount(merged.clearing_number, merged.bank_account_number) + if (bankIssues.length > 0) { + return NextResponse.json({ error: bankIssues.map((i) => i.message).join('. ') }, { status: 400 }) + } + } -export async function DELETE( - request: Request, - { params }: { params: Promise<{ id: string }> } -) { - const { id } = await params - const supabase = await createClient() - const { data: { user } } = await supabase.auth.getUser() - if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + // Only when the caller is changing employment_type: block setting an EF's + // owner/board on payroll (mirrors the enforce_ef_no_owner_employee trigger, + // which fires on UPDATE OF employment_type: so unrelated edits to any + // grandfathered row aren't blocked). #782 + if (body.employment_type !== undefined) { + const entityType = await getCompanyEntityType(supabase, companyId) + if (!isEmploymentTypeAllowedForEntity(entityType, body.employment_type)) { + return NextResponse.json({ error: EF_OWNER_EMPLOYMENT_ERROR }, { status: 400 }) + } + } - const writeCheck = await requireWritePermission(supabase, user.id) - if (!writeCheck.ok) return writeCheck.response + // Build update object + const updates: Record = { ...body } - const companyId = await requireCompanyId(supabase, user.id) + // Handle personnummer update if provided + if (body.personnummer) { + const pnrValidation = validatePersonnummer(body.personnummer) + if (!pnrValidation.valid) { + return NextResponse.json({ error: pnrValidation.error }, { status: 400 }) + } + updates.personnummer = encryptPersonnummer(body.personnummer) + updates.personnummer_last4 = extractLast4(body.personnummer) + } - // Soft delete only, BFL 7 kap retention - const { data, error } = await supabase - .from('employees') - .update({ is_active: false }) - .eq('id', id) - .eq('company_id', companyId) - .select('id') - .single() + const { data: updated, error } = await supabase + .from('employees') + .update(updates) + .eq('id', id) + .eq('company_id', companyId) + .select() + .single() - if (error || !data) { - return NextResponse.json({ error: 'Anställd hittades inte' }, { status: 404 }) - } + if (error) { + if (error.code === '23505') { + return NextResponse.json({ error: 'En anställd med detta personnummer finns redan' }, { status: 409 }) + } + return NextResponse.json({ error: error.message }, { status: 500 }) + } - return NextResponse.json({ data: { id: data.id, is_active: false } }) -} + return NextResponse.json({ + data: { + ...updated, + personnummer: maskPersonnummer(decryptPersonnummer(updated.personnummer)), + }, + }) + }, + { requireWrite: true }, +) + +export const DELETE = withRouteContext<{ params: Promise<{ id: string }> }>( + 'salary.employees.delete', + async (request, { supabase, companyId }, { params }) => { + const { id } = await params + + // Soft delete only, BFL 7 kap retention + const { data, error } = await supabase + .from('employees') + .update({ is_active: false }) + .eq('id', id) + .eq('company_id', companyId) + .select('id') + .single() + + if (error || !data) { + return NextResponse.json({ error: 'Anställd hittades inte' }, { status: 404 }) + } + + return NextResponse.json({ data: { id: data.id, is_active: false } }) + }, + { requireWrite: true }, +) diff --git a/app/api/salary/employees/[id]/worked-hours/__tests__/route.test.ts b/app/api/salary/employees/[id]/worked-hours/__tests__/route.test.ts new file mode 100644 index 00000000..b3716c85 --- /dev/null +++ b/app/api/salary/employees/[id]/worked-hours/__tests__/route.test.ts @@ -0,0 +1,86 @@ +/** + * Auth-wiring tests for /api/salary/employees/[id]/worked-hours (POST upsert). + * + * Runs the route through the real withRouteContext wrapper; mocks auth/company/ + * write and injects a queued Supabase mock via requireAuth. Covers 401, 403 + * (viewer), and a POST happy path. + */ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { NextResponse } from 'next/server' +import { createQueuedMockSupabase, createMockRequest, parseJsonResponse } from '@/tests/helpers' + +const { supabase, enqueue, reset } = createQueuedMockSupabase() + +const requireAuthMock = vi.fn() +vi.mock('@/lib/auth/require-auth', () => ({ + requireAuth: (...args: unknown[]) => requireAuthMock(...args), +})) + +vi.mock('@/lib/company/context', () => ({ + getActiveCompanyId: vi.fn().mockResolvedValue('company-1'), + requireCompanyId: vi.fn().mockResolvedValue('company-1'), +})) + +const requireWriteMock = vi.fn() +vi.mock('@/lib/auth/require-write', () => ({ + requireWritePermission: (...args: unknown[]) => requireWriteMock(...args), +})) + +vi.mock('@/lib/init', () => ({ ensureInitialized: vi.fn() })) + +import { POST } from '../route' + +const params = { params: Promise.resolve({ id: 'emp-1' }) } as never + +function post(body: unknown) { + return createMockRequest('/api/salary/employees/emp-1/worked-hours', { method: 'POST', body }) +} + +describe('POST /api/salary/employees/[id]/worked-hours', () => { + beforeEach(() => { + vi.clearAllMocks() + reset() + requireAuthMock.mockResolvedValue({ user: { id: 'user-1' }, supabase }) + requireWriteMock.mockResolvedValue({ ok: true }) + }) + + it('returns 401 when unauthenticated', async () => { + requireAuthMock.mockResolvedValue({ + user: null, + supabase, + error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }), + }) + + const response = await POST(post({ work_date: '2026-07-01', hours: 8 }), params) + expect(response.status).toBe(401) + }) + + it('returns 403 for a viewer (no write permission)', async () => { + requireWriteMock.mockResolvedValue({ + ok: false, + response: NextResponse.json({ error: 'Forbidden' }, { status: 403 }), + }) + + const response = await POST(post({ work_date: '2026-07-01', hours: 8 }), params) + expect(response.status).toBe(403) + }) + + it('upserts a worked day (happy path)', async () => { + enqueue({ data: { id: 'emp-1', salary_type: 'hourly' } }) // loadEmployee + enqueue({ data: null }) // delete existing + enqueue({ data: { id: 'wd-1', work_date: '2026-07-01', hours: 8 } }) // insert + + const response = await POST(post({ work_date: '2026-07-01', hours: 8 }), params) + const { status, body } = await parseJsonResponse<{ data: { id: string } }>(response) + + expect(status).toBe(201) + expect(body.data.id).toBe('wd-1') + }) + + it('returns 404 when the employee is not in the company', async () => { + enqueue({ data: null }) // loadEmployee → not found + + const response = await POST(post({ work_date: '2026-07-01', hours: 8 }), params) + expect(response.status).toBe(404) + }) +}) diff --git a/app/api/salary/employees/[id]/worked-hours/batch/__tests__/route.test.ts b/app/api/salary/employees/[id]/worked-hours/batch/__tests__/route.test.ts new file mode 100644 index 00000000..7059a0d3 --- /dev/null +++ b/app/api/salary/employees/[id]/worked-hours/batch/__tests__/route.test.ts @@ -0,0 +1,90 @@ +/** + * Auth-wiring tests for /api/salary/employees/[id]/worked-hours/batch (POST). + * + * Runs the route through the real withRouteContext wrapper; mocks auth/company/ + * write and injects a queued Supabase mock via requireAuth. Covers 401, 403 + * (viewer), and a POST happy path (bulk insert). + */ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { NextResponse } from 'next/server' +import { createQueuedMockSupabase, createMockRequest, parseJsonResponse } from '@/tests/helpers' + +const { supabase, enqueue, reset } = createQueuedMockSupabase() + +const requireAuthMock = vi.fn() +vi.mock('@/lib/auth/require-auth', () => ({ + requireAuth: (...args: unknown[]) => requireAuthMock(...args), +})) + +vi.mock('@/lib/company/context', () => ({ + getActiveCompanyId: vi.fn().mockResolvedValue('company-1'), + requireCompanyId: vi.fn().mockResolvedValue('company-1'), +})) + +const requireWriteMock = vi.fn() +vi.mock('@/lib/auth/require-write', () => ({ + requireWritePermission: (...args: unknown[]) => requireWriteMock(...args), +})) + +vi.mock('@/lib/init', () => ({ ensureInitialized: vi.fn() })) + +import { POST } from '../route' + +const params = { params: Promise.resolve({ id: 'emp-1' }) } as never + +function post(body: unknown) { + return createMockRequest('/api/salary/employees/emp-1/worked-hours/batch', { method: 'POST', body }) +} + +const validBatch = { dates: ['2026-07-01', '2026-07-02'], hours: 8 } + +describe('POST /api/salary/employees/[id]/worked-hours/batch', () => { + beforeEach(() => { + vi.clearAllMocks() + reset() + requireAuthMock.mockResolvedValue({ user: { id: 'user-1' }, supabase }) + requireWriteMock.mockResolvedValue({ ok: true }) + }) + + it('returns 401 when unauthenticated', async () => { + requireAuthMock.mockResolvedValue({ + user: null, + supabase, + error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }), + }) + + const response = await POST(post(validBatch), params) + expect(response.status).toBe(401) + }) + + it('returns 403 for a viewer (no write permission)', async () => { + requireWriteMock.mockResolvedValue({ + ok: false, + response: NextResponse.json({ error: 'Forbidden' }, { status: 403 }), + }) + + const response = await POST(post(validBatch), params) + expect(response.status).toBe(403) + }) + + it('bulk-inserts worked days (happy path)', async () => { + enqueue({ data: { id: 'emp-1' } }) // employee ownership check + enqueue({ data: null }) // bulk delete + enqueue({ data: null }) // insert date 1 + enqueue({ data: null }) // insert date 2 + + const response = await POST(post(validBatch), params) + const { status, body } = await parseJsonResponse<{ data: { inserted: number; conflicts: unknown[] } }>(response) + + expect(status).toBe(201) + expect(body.data.inserted).toBe(2) + expect(body.data.conflicts).toEqual([]) + }) + + it('returns 404 when the employee is not in the company', async () => { + enqueue({ data: null }) // employee ownership check → not found + + const response = await POST(post(validBatch), params) + expect(response.status).toBe(404) + }) +}) diff --git a/app/api/salary/employees/[id]/worked-hours/batch/route.ts b/app/api/salary/employees/[id]/worked-hours/batch/route.ts index edc54f83..d248dcbe 100644 --- a/app/api/salary/employees/[id]/worked-hours/batch/route.ts +++ b/app/api/salary/employees/[id]/worked-hours/batch/route.ts @@ -1,10 +1,8 @@ -import { createClient } from '@/lib/supabase/server' import { NextResponse } from 'next/server' import { ensureInitialized } from '@/lib/init' +import { withRouteContext } from '@/lib/api/with-route-context' import { validateBody } from '@/lib/api/validate' import { BatchUpsertWorkedDaysSchema } from '@/lib/api/schemas' -import { requireCompanyId } from '@/lib/company/context' -import { requireWritePermission } from '@/lib/auth/require-write' ensureInitialized() @@ -13,85 +11,78 @@ interface BatchConflict { reason: string } -export async function POST( - request: Request, - { params }: { params: Promise<{ id: string }> }, -) { - const { id: employeeId } = await params - const supabase = await createClient() - const { data: { user } } = await supabase.auth.getUser() - if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) +export const POST = withRouteContext<{ params: Promise<{ id: string }> }>( + 'salary.employees.worked_hours.batch', + async (request, { supabase, companyId }, { params }) => { + const { id: employeeId } = await params - const writeCheck = await requireWritePermission(supabase, user.id) - if (!writeCheck.ok) return writeCheck.response - - const companyId = await requireCompanyId(supabase, user.id) - - const { data: employee } = await supabase - .from('employees') - .select('id') - .eq('id', employeeId) - .eq('company_id', companyId) - .maybeSingle() - if (!employee) { - return NextResponse.json({ error: 'Anställd hittades inte' }, { status: 404 }) - } - - const validation = await validateBody(request, BatchUpsertWorkedDaysSchema) - if (!validation.success) return validation.response - const body = validation.data - - // Dedupe dates so the user can pass an array with accidental duplicates - // (e.g. shift-clicking over the same date twice). - const uniqueDates = Array.from(new Set(body.dates)) - - // Bulk delete existing rows on these dates first so the per-row insert step - // is a clean replace. Stays within RLS via company_id + employee_id filter. - const { error: deleteError } = await supabase - .from('salary_worked_days') - .delete() - .eq('company_id', companyId) - .eq('employee_id', employeeId) - .in('work_date', uniqueDates) - - if (deleteError) { - return NextResponse.json({ error: deleteError.message }, { status: 500 }) - } - - // Per-row insert so we can isolate trigger failures (24h cap on a date with - // existing absence) without aborting the whole batch. A single multi-row - // insert would fail-fast and surface only the first conflict. - const conflicts: BatchConflict[] = [] - let inserted = 0 - - for (const date of uniqueDates) { - const { error } = await supabase - .from('salary_worked_days') - .insert({ - company_id: companyId, - employee_id: employeeId, - work_date: date, - hours: body.hours, - notes: body.notes ?? null, - salary_run_employee_id: body.salary_run_employee_id ?? null, - }) - if (error) { - // 24h cap trigger uses ERRCODE check_violation (23514) and a Swedish - // message starting with "Total tid". Other failures are unexpected. - if (error.message?.includes('Total tid') || error.code === '23514') { - conflicts.push({ date, reason: error.message }) - continue - } - return NextResponse.json( - { error: error.message, inserted, conflicts }, - { status: 500 }, - ) + const { data: employee } = await supabase + .from('employees') + .select('id') + .eq('id', employeeId) + .eq('company_id', companyId) + .maybeSingle() + if (!employee) { + return NextResponse.json({ error: 'Anställd hittades inte' }, { status: 404 }) } - inserted += 1 - } - return NextResponse.json( - { data: { inserted, conflicts } }, - { status: conflicts.length > 0 ? 207 : 201 }, - ) -} + const validation = await validateBody(request, BatchUpsertWorkedDaysSchema) + if (!validation.success) return validation.response + const body = validation.data + + // Dedupe dates so the user can pass an array with accidental duplicates + // (e.g. shift-clicking over the same date twice). + const uniqueDates = Array.from(new Set(body.dates)) + + // Bulk delete existing rows on these dates first so the per-row insert step + // is a clean replace. Stays within RLS via company_id + employee_id filter. + const { error: deleteError } = await supabase + .from('salary_worked_days') + .delete() + .eq('company_id', companyId) + .eq('employee_id', employeeId) + .in('work_date', uniqueDates) + + if (deleteError) { + return NextResponse.json({ error: deleteError.message }, { status: 500 }) + } + + // Per-row insert so we can isolate trigger failures (24h cap on a date with + // existing absence) without aborting the whole batch. A single multi-row + // insert would fail-fast and surface only the first conflict. + const conflicts: BatchConflict[] = [] + let inserted = 0 + + for (const date of uniqueDates) { + const { error } = await supabase + .from('salary_worked_days') + .insert({ + company_id: companyId, + employee_id: employeeId, + work_date: date, + hours: body.hours, + notes: body.notes ?? null, + salary_run_employee_id: body.salary_run_employee_id ?? null, + }) + if (error) { + // 24h cap trigger uses ERRCODE check_violation (23514) and a Swedish + // message starting with "Total tid". Other failures are unexpected. + if (error.message?.includes('Total tid') || error.code === '23514') { + conflicts.push({ date, reason: error.message }) + continue + } + return NextResponse.json( + { error: error.message, inserted, conflicts }, + { status: 500 }, + ) + } + inserted += 1 + } + + return NextResponse.json( + { data: { inserted, conflicts } }, + { status: conflicts.length > 0 ? 207 : 201 }, + ) + }, + { requireWrite: true }, +) diff --git a/app/api/salary/employees/[id]/worked-hours/route.ts b/app/api/salary/employees/[id]/worked-hours/route.ts index c106541a..6e827a40 100644 --- a/app/api/salary/employees/[id]/worked-hours/route.ts +++ b/app/api/salary/employees/[id]/worked-hours/route.ts @@ -1,21 +1,20 @@ import { z } from 'zod' -import { createClient } from '@/lib/supabase/server' +import type { SupabaseClient } from '@supabase/supabase-js' import { NextResponse } from 'next/server' import { ensureInitialized } from '@/lib/init' +import { withRouteContext } from '@/lib/api/with-route-context' import { validateBody, validateQuery } from '@/lib/api/validate' import { UpsertWorkedDaySchema, WorkedHoursRangeQuerySchema, } from '@/lib/api/schemas' -import { requireCompanyId } from '@/lib/company/context' -import { requireWritePermission } from '@/lib/auth/require-write' const isoDate = z.string().regex(/^\d{4}-\d{2}-\d{2}$/) ensureInitialized() async function loadEmployee( - supabase: Awaited>, + supabase: SupabaseClient, employeeId: string, companyId: string, ) { @@ -28,107 +27,95 @@ async function loadEmployee( return data } -export async function GET( - request: Request, - { params }: { params: Promise<{ id: string }> }, -) { - const { id: employeeId } = await params - const supabase = await createClient() - const { data: { user } } = await supabase.auth.getUser() - if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) +export const GET = withRouteContext<{ params: Promise<{ id: string }> }>( + 'salary.employees.worked_hours.list', + async (request, { supabase, companyId }, { params }) => { + const { id: employeeId } = await params - const companyId = await requireCompanyId(supabase, user.id) - - const employee = await loadEmployee(supabase, employeeId, companyId) - if (!employee) { - return NextResponse.json({ error: 'Anställd hittades inte' }, { status: 404 }) - } - - const query = validateQuery(request, WorkedHoursRangeQuerySchema) - if (!query.success) return query.response - - const { data, error } = await supabase - .from('salary_worked_days') - .select('id, work_date, hours, notes, salary_run_employee_id, created_at, updated_at') - .eq('company_id', companyId) - .eq('employee_id', employeeId) - .gte('work_date', query.data.from) - .lte('work_date', query.data.to) - .order('work_date', { ascending: true }) - - if (error) { - return NextResponse.json({ error: error.message }, { status: 500 }) - } - - const totalHours = (data ?? []).reduce( - (sum, d) => Math.round((sum + Number(d.hours)) * 100) / 100, - 0, - ) - - return NextResponse.json({ data, total_hours: totalHours }) -} - -export async function POST( - request: Request, - { params }: { params: Promise<{ id: string }> }, -) { - const { id: employeeId } = await params - const supabase = await createClient() - const { data: { user } } = await supabase.auth.getUser() - if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - - const writeCheck = await requireWritePermission(supabase, user.id) - if (!writeCheck.ok) return writeCheck.response - - const companyId = await requireCompanyId(supabase, user.id) - - const employee = await loadEmployee(supabase, employeeId, companyId) - if (!employee) { - return NextResponse.json({ error: 'Anställd hittades inte' }, { status: 404 }) - } - - const validation = await validateBody(request, UpsertWorkedDaySchema) - if (!validation.success) return validation.response - const body = validation.data - - // Upsert via DELETE+INSERT on the natural key (employee, date). Worked days - // have one row per date: re-marking overwrites. Mirrors the absence route's - // pattern so behaviour stays predictable across the two calendars. - const { error: deleteError } = await supabase - .from('salary_worked_days') - .delete() - .eq('company_id', companyId) - .eq('employee_id', employeeId) - .eq('work_date', body.work_date) - - if (deleteError) { - return NextResponse.json({ error: deleteError.message }, { status: 500 }) - } - - const { data, error } = await supabase - .from('salary_worked_days') - .insert({ - company_id: companyId, - employee_id: employeeId, - work_date: body.work_date, - hours: body.hours, - notes: body.notes ?? null, - salary_run_employee_id: body.salary_run_employee_id ?? null, - }) - .select() - .single() - - if (error) { - // The 24h cap trigger raises check_violation when worked + absence > 24h - // for the same date. Surface a clean 409 with a Swedish message. - if (error.message?.includes('Total tid') || error.code === '23514') { - return NextResponse.json({ error: error.message }, { status: 409 }) + const employee = await loadEmployee(supabase, employeeId, companyId) + if (!employee) { + return NextResponse.json({ error: 'Anställd hittades inte' }, { status: 404 }) } - return NextResponse.json({ error: error.message }, { status: 500 }) - } - return NextResponse.json({ data }, { status: 201 }) -} + const query = validateQuery(request, WorkedHoursRangeQuerySchema) + if (!query.success) return query.response + + const { data, error } = await supabase + .from('salary_worked_days') + .select('id, work_date, hours, notes, salary_run_employee_id, created_at, updated_at') + .eq('company_id', companyId) + .eq('employee_id', employeeId) + .gte('work_date', query.data.from) + .lte('work_date', query.data.to) + .order('work_date', { ascending: true }) + + if (error) { + return NextResponse.json({ error: error.message }, { status: 500 }) + } + + const totalHours = (data ?? []).reduce( + (sum, d) => Math.round((sum + Number(d.hours)) * 100) / 100, + 0, + ) + + return NextResponse.json({ data, total_hours: totalHours }) + }, +) + +export const POST = withRouteContext<{ params: Promise<{ id: string }> }>( + 'salary.employees.worked_hours.upsert', + async (request, { supabase, companyId }, { params }) => { + const { id: employeeId } = await params + + const employee = await loadEmployee(supabase, employeeId, companyId) + if (!employee) { + return NextResponse.json({ error: 'Anställd hittades inte' }, { status: 404 }) + } + + const validation = await validateBody(request, UpsertWorkedDaySchema) + if (!validation.success) return validation.response + const body = validation.data + + // Upsert via DELETE+INSERT on the natural key (employee, date). Worked days + // have one row per date: re-marking overwrites. Mirrors the absence route's + // pattern so behaviour stays predictable across the two calendars. + const { error: deleteError } = await supabase + .from('salary_worked_days') + .delete() + .eq('company_id', companyId) + .eq('employee_id', employeeId) + .eq('work_date', body.work_date) + + if (deleteError) { + return NextResponse.json({ error: deleteError.message }, { status: 500 }) + } + + const { data, error } = await supabase + .from('salary_worked_days') + .insert({ + company_id: companyId, + employee_id: employeeId, + work_date: body.work_date, + hours: body.hours, + notes: body.notes ?? null, + salary_run_employee_id: body.salary_run_employee_id ?? null, + }) + .select() + .single() + + if (error) { + // The 24h cap trigger raises check_violation when worked + absence > 24h + // for the same date. Surface a clean 409 with a Swedish message. + if (error.message?.includes('Total tid') || error.code === '23514') { + return NextResponse.json({ error: error.message }, { status: 409 }) + } + return NextResponse.json({ error: error.message }, { status: 500 }) + } + + return NextResponse.json({ data }, { status: 201 }) + }, + { requireWrite: true }, +) // Two modes: ?date=YYYY-MM-DD (single row) or ?from=…&to=… (range). const DeleteQuerySchema = z.object({ @@ -137,54 +124,47 @@ const DeleteQuerySchema = z.object({ date: isoDate.optional(), }) -export async function DELETE( - request: Request, - { params }: { params: Promise<{ id: string }> }, -) { - const { id: employeeId } = await params - const supabase = await createClient() - const { data: { user } } = await supabase.auth.getUser() - if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) +export const DELETE = withRouteContext<{ params: Promise<{ id: string }> }>( + 'salary.employees.worked_hours.delete', + async (request, { supabase, companyId }, { params }) => { + const { id: employeeId } = await params - const writeCheck = await requireWritePermission(supabase, user.id) - if (!writeCheck.ok) return writeCheck.response + const employee = await loadEmployee(supabase, employeeId, companyId) + if (!employee) { + return NextResponse.json({ error: 'Anställd hittades inte' }, { status: 404 }) + } - const companyId = await requireCompanyId(supabase, user.id) + const query = validateQuery(request, DeleteQuerySchema) + if (!query.success) return query.response + const { date, from, to } = query.data - const employee = await loadEmployee(supabase, employeeId, companyId) - if (!employee) { - return NextResponse.json({ error: 'Anställd hittades inte' }, { status: 404 }) - } + const hasSingle = !!date + const hasRange = !!from && !!to + if (!hasSingle && !hasRange) { + return NextResponse.json( + { error: 'Ange antingen ?date=YYYY-MM-DD eller ?from=...&to=...' }, + { status: 400 }, + ) + } - const query = validateQuery(request, DeleteQuerySchema) - if (!query.success) return query.response - const { date, from, to } = query.data + let q = supabase + .from('salary_worked_days') + .delete() + .eq('company_id', companyId) + .eq('employee_id', employeeId) - const hasSingle = !!date - const hasRange = !!from && !!to - if (!hasSingle && !hasRange) { - return NextResponse.json( - { error: 'Ange antingen ?date=YYYY-MM-DD eller ?from=...&to=...' }, - { status: 400 }, - ) - } + if (hasSingle) { + q = q.eq('work_date', date!) + } else { + q = q.gte('work_date', from!).lte('work_date', to!) + } - let q = supabase - .from('salary_worked_days') - .delete() - .eq('company_id', companyId) - .eq('employee_id', employeeId) + const { error } = await q + if (error) { + return NextResponse.json({ error: error.message }, { status: 500 }) + } - if (hasSingle) { - q = q.eq('work_date', date!) - } else { - q = q.gte('work_date', from!).lte('work_date', to!) - } - - const { error } = await q - if (error) { - return NextResponse.json({ error: error.message }, { status: 500 }) - } - - return NextResponse.json({ data: { ok: true } }) -} + return NextResponse.json({ data: { ok: true } }) + }, + { requireWrite: true }, +) diff --git a/app/api/salary/employees/__tests__/route.test.ts b/app/api/salary/employees/__tests__/route.test.ts index 8263e0da..54dbba9c 100644 --- a/app/api/salary/employees/__tests__/route.test.ts +++ b/app/api/salary/employees/__tests__/route.test.ts @@ -1,5 +1,5 @@ /** - * Regression test for the plaintext-personnummer crash. + * Regression test for the plaintext-personnummer crash, plus auth wiring. * * GET /api/salary/employees decrypts every employee's personnummer on read and * maps over the whole roster. A row whose personnummer was stored UNENCRYPTED @@ -8,25 +8,28 @@ * the .map(), 500-ing the entire endpoint for the affected company. The decrypt * helper now passes a raw 12-digit value through unchanged, so a mixed * encrypted/plaintext table no longer takes the roster down. + * + * The route now runs through the withRouteContext wrapper, so we mock its + * auth/company/write dependencies and inject the Supabase client via requireAuth. */ import { beforeEach, describe, expect, it, vi } from 'vitest' +import { NextResponse } from 'next/server' vi.mock('@/lib/init', () => ({ ensureInitialized: vi.fn() })) -vi.mock('@/lib/auth/require-write', () => ({ - requireWritePermission: vi.fn().mockResolvedValue({ ok: true }), -})) +vi.mock('@/lib/auth/require-auth', () => ({ requireAuth: vi.fn() })) vi.mock('@/lib/company/context', () => ({ + getActiveCompanyId: vi.fn().mockResolvedValue('company-1'), requireCompanyId: vi.fn().mockResolvedValue('company-1'), getCompanyEntityType: vi.fn(), })) -vi.mock('@/lib/supabase/server', () => ({ createClient: vi.fn() })) +vi.mock('@/lib/auth/require-write', () => ({ + requireWritePermission: vi.fn().mockResolvedValue({ ok: true }), +})) import { GET } from '../route' -import { createClient } from '@/lib/supabase/server' +import { requireAuth } from '@/lib/auth/require-auth' import { encryptPersonnummer } from '@/lib/salary/personnummer' -const mockCreateClient = createClient as ReturnType - // Synthetic 12-digit values (year 1900 / 1902, zero suffix): obviously not // real birthdates. ISO A.5.34 / GDPR Art.5(1)(c): fixtures must not look like // production PII. @@ -38,39 +41,48 @@ function supabaseWithRows(rows: unknown[]) { query.select = vi.fn(() => query) query.eq = vi.fn(() => query) query.order = vi.fn(() => Promise.resolve({ data: rows, error: null })) - return { - auth: { getUser: vi.fn().mockResolvedValue({ data: { user: { id: 'user-1' } } }) }, - from: vi.fn(() => query), - } + return { from: vi.fn(() => query) } +} + +function authed(supabase: unknown) { + vi.mocked(requireAuth).mockResolvedValue({ + user: { id: 'user-1' } as never, + supabase: supabase as never, + error: null, + } as never) } function req() { return new Request('https://x.test/api/salary/employees') } +const params = { params: Promise.resolve({}) } as never + beforeEach(() => { vi.clearAllMocks() }) describe('GET /api/salary/employees', () => { it('returns 401 when unauthenticated', async () => { - mockCreateClient.mockResolvedValue({ - auth: { getUser: vi.fn().mockResolvedValue({ data: { user: null } }) }, - }) + vi.mocked(requireAuth).mockResolvedValue({ + user: null, + supabase: null, + error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }), + } as never) - const res = await GET(req()) + const res = await GET(req(), params) expect(res.status).toBe(401) }) it('does not 500 on a mixed plaintext + encrypted roster; masks both', async () => { - mockCreateClient.mockResolvedValue( + authed( supabaseWithRows([ { id: 'e1', last_name: 'A', personnummer: PLAINTEXT_PNR }, { id: 'e2', last_name: 'B', personnummer: ENCRYPTED_PNR }, ]), ) - const res = await GET(req()) + const res = await GET(req(), params) expect(res.status).toBe(200) const body = await res.json() // Both rows masked birthdate-visible, last-4 hidden. diff --git a/app/api/salary/employees/route.ts b/app/api/salary/employees/route.ts index 6e56b092..2b072391 100644 --- a/app/api/salary/employees/route.ts +++ b/app/api/salary/employees/route.ts @@ -1,22 +1,15 @@ -import { createClient } from '@/lib/supabase/server' import { NextResponse } from 'next/server' import { ensureInitialized } from '@/lib/init' +import { withRouteContext } from '@/lib/api/with-route-context' import { validateBody } from '@/lib/api/validate' import { CreateEmployeeSchema } from '@/lib/api/schemas' -import { requireCompanyId, getCompanyEntityType } from '@/lib/company/context' -import { requireWritePermission } from '@/lib/auth/require-write' +import { getCompanyEntityType } from '@/lib/company/context' import { decryptPersonnummer, encryptPersonnummer, extractLast4, maskPersonnummer, validatePersonnummer } from '@/lib/salary/personnummer' import { isEmploymentTypeAllowedForEntity, EF_OWNER_EMPLOYMENT_ERROR } from '@/lib/salary/employment-rules' ensureInitialized() -export async function GET(request: Request) { - const supabase = await createClient() - const { data: { user } } = await supabase.auth.getUser() - if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - - const companyId = await requireCompanyId(supabase, user.id) - +export const GET = withRouteContext('salary.employees.list', async (request, { supabase, companyId }) => { const { searchParams } = new URL(request.url) const activeOnly = searchParams.get('active') !== 'false' @@ -42,18 +35,9 @@ export async function GET(request: Request) { })) return NextResponse.json({ data: masked }) -} - -export async function POST(request: Request) { - const supabase = await createClient() - const { data: { user } } = await supabase.auth.getUser() - if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - - const writeCheck = await requireWritePermission(supabase, user.id) - if (!writeCheck.ok) return writeCheck.response - - const companyId = await requireCompanyId(supabase, user.id) +}) +export const POST = withRouteContext('salary.employees.create', async (request, { supabase, companyId, user }) => { const validation = await validateBody(request, CreateEmployeeSchema) if (!validation.success) return validation.response const body = validation.data @@ -130,4 +114,4 @@ export async function POST(request: Request) { personnummer: maskPersonnummer(body.personnummer), }, }, { status: 201 }) -} +}, { requireWrite: true }) diff --git a/app/api/salary/ku/[year]/route.ts b/app/api/salary/ku/[year]/route.ts index 4993e4ba..5ff302b1 100644 --- a/app/api/salary/ku/[year]/route.ts +++ b/app/api/salary/ku/[year]/route.ts @@ -1,7 +1,6 @@ -import { createClient } from '@/lib/supabase/server' import { NextResponse } from 'next/server' import { ensureInitialized } from '@/lib/init' -import { requireCompanyId } from '@/lib/company/context' +import { withRouteContext } from '@/lib/api/with-route-context' import { generateKU10Xml } from '@/lib/salary/ku/ku10-generator' import type { KU10EmployeeData, KU10CompanyData } from '@/lib/salary/ku/ku10-generator' @@ -15,145 +14,140 @@ ensureInitialized() * * The XML is räkenskapsinformation per BFL 7 kap, 7-year retention. */ -export async function GET( - request: Request, - { params }: { params: Promise<{ year: string }> } -) { - const { year } = await params - const yearNum = parseInt(year) - const supabase = await createClient() - const { data: { user } } = await supabase.auth.getUser() - if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) +export const GET = withRouteContext<{ params: Promise<{ year: string }> }>( + 'salary.ku.export', + async (request, { supabase, companyId, user }, { params }) => { + const { year } = await params + const yearNum = parseInt(year) - const companyId = await requireCompanyId(supabase, user.id) + if (isNaN(yearNum) || yearNum < 2020 || yearNum > 2100) { + return NextResponse.json({ error: 'Ogiltigt år' }, { status: 400 }) + } - if (isNaN(yearNum) || yearNum < 2020 || yearNum > 2100) { - return NextResponse.json({ error: 'Ogiltigt år' }, { status: 400 }) - } + // Load company + const { data: company } = await supabase + .from('companies') + .select('name, org_number') + .eq('id', companyId) + .single() - // Load company - const { data: company } = await supabase - .from('companies') - .select('name, org_number') - .eq('id', companyId) - .single() + if (!company) return NextResponse.json({ error: 'Företag hittades inte' }, { status: 404 }) - if (!company) return NextResponse.json({ error: 'Företag hittades inte' }, { status: 404 }) + const { data: settings } = await supabase + .from('company_settings') + .select('company_name, org_number, phone, email') + .eq('company_id', companyId) + .single() - const { data: settings } = await supabase - .from('company_settings') - .select('company_name, org_number, phone, email') - .eq('company_id', companyId) - .single() + const { data: profile } = await supabase + .from('profiles') + .select('full_name, email') + .eq('id', user.id) + .single() - const { data: profile } = await supabase - .from('profiles') - .select('full_name, email') - .eq('id', user.id) - .single() + // Load all booked salary run employees for the year, grouped by employee + const { data: runEmployees, error } = await supabase + .from('salary_run_employees') + .select(` + employee_id, gross_salary, tax_withheld, tax_withheld_override, + avgifter_basis, avgifter_basis_override, + employee:employees(personnummer, specification_number, employment_start, employment_end), + salary_run:salary_runs!inner(period_year, status), + line_items:salary_line_items(item_type, amount) + `) + .eq('company_id', companyId) - // Load all booked salary run employees for the year, grouped by employee - const { data: runEmployees, error } = await supabase - .from('salary_run_employees') - .select(` - employee_id, gross_salary, tax_withheld, tax_withheld_override, - avgifter_basis, avgifter_basis_override, - employee:employees(personnummer, specification_number, employment_start, employment_end), - salary_run:salary_runs!inner(period_year, status), - line_items:salary_line_items(item_type, amount) - `) - .eq('company_id', companyId) + if (error) return NextResponse.json({ error: error.message }, { status: 500 }) - if (error) return NextResponse.json({ error: error.message }, { status: 500 }) + // Filter to booked runs for the year + const bookedForYear = (runEmployees || []).filter(sre => { + const run = sre.salary_run as unknown as { period_year: number; status: string } | null + return run && run.period_year === yearNum && run.status === 'booked' + }) - // Filter to booked runs for the year - const bookedForYear = (runEmployees || []).filter(sre => { - const run = sre.salary_run as unknown as { period_year: number; status: string } | null - return run && run.period_year === yearNum && run.status === 'booked' - }) + // Aggregate per employee + const byEmployee = new Map() - // Aggregate per employee - const byEmployee = new Map() + for (const sre of bookedForYear) { + const emp = sre.employee as unknown as { personnummer: string; specification_number: number; employment_start: string; employment_end: string | null } | null + if (!emp) continue - for (const sre of bookedForYear) { - const emp = sre.employee as unknown as { personnummer: string; specification_number: number; employment_start: string; employment_end: string | null } | null - if (!emp) continue + const current = byEmployee.get(sre.employee_id) || { + personnummer: emp.personnummer, + specificationNumber: emp.specification_number, + employmentStart: emp.employment_start, + employmentEnd: emp.employment_end, + totalGross: 0, totalTax: 0, totalAvgifterBasis: 0, + benefitCar: 0, benefitHousing: 0, benefitMeals: 0, benefitOther: 0, + } - const current = byEmployee.get(sre.employee_id) || { + current.totalGross += sre.gross_salary + // Honor advanced-mode override so KU matches AGI + the ledger. + current.totalTax += sre.tax_withheld_override ?? sre.tax_withheld + current.totalAvgifterBasis += sre.avgifter_basis_override ?? sre.avgifter_basis + + // Sum benefits by type from line items + const lineItems = (sre.line_items || []) as Array<{ item_type: string; amount: number }> + for (const li of lineItems) { + if (li.item_type === 'benefit_car') current.benefitCar += li.amount + else if (li.item_type === 'benefit_housing') current.benefitHousing += li.amount + else if (li.item_type === 'benefit_meals') current.benefitMeals += li.amount + else if (['benefit_wellness', 'benefit_other'].includes(li.item_type)) current.benefitOther += li.amount + } + + byEmployee.set(sre.employee_id, current) + } + + if (byEmployee.size === 0) { + return NextResponse.json({ error: `Inga bokförda lönekörningar för ${yearNum}` }, { status: 404 }) + } + + // Employer name on the KU10 follows the current company name + // (company_settings.company_name), not the frozen onboarding companies.name. + const companyName = settings?.company_name || company.name + const companyData: KU10CompanyData = { + orgNumber: (settings?.org_number || company.org_number || '').trim(), + companyName, + year: yearNum, + contactName: (profile?.full_name || companyName || '').trim(), + contactPhone: (settings?.phone || '').trim(), + contactEmail: (settings?.email || profile?.email || user.email || '').trim(), + } + + const r = (x: number) => Math.round(x * 100) / 100 + const employeeData: KU10EmployeeData[] = Array.from(byEmployee.values()).map(emp => ({ personnummer: emp.personnummer, - specificationNumber: emp.specification_number, - employmentStart: emp.employment_start, - employmentEnd: emp.employment_end, - totalGross: 0, totalTax: 0, totalAvgifterBasis: 0, - benefitCar: 0, benefitHousing: 0, benefitMeals: 0, benefitOther: 0, - } + specificationNumber: emp.specificationNumber, + totalGross: r(emp.totalGross), + totalTax: r(emp.totalTax), + totalAvgifterBasis: r(emp.totalAvgifterBasis), + benefitCar: emp.benefitCar > 0 ? r(emp.benefitCar) : undefined, + benefitHousing: emp.benefitHousing > 0 ? r(emp.benefitHousing) : undefined, + benefitMeals: emp.benefitMeals > 0 ? r(emp.benefitMeals) : undefined, + benefitOther: emp.benefitOther > 0 ? r(emp.benefitOther) : undefined, + employmentStart: emp.employmentStart || undefined, + employmentEnd: emp.employmentEnd || undefined, + })) - current.totalGross += sre.gross_salary - // Honor advanced-mode override so KU matches AGI + the ledger. - current.totalTax += sre.tax_withheld_override ?? sre.tax_withheld - current.totalAvgifterBasis += sre.avgifter_basis_override ?? sre.avgifter_basis + const xml = generateKU10Xml(companyData, employeeData) - // Sum benefits by type from line items - const lineItems = (sre.line_items || []) as Array<{ item_type: string; amount: number }> - for (const li of lineItems) { - if (li.item_type === 'benefit_car') current.benefitCar += li.amount - else if (li.item_type === 'benefit_housing') current.benefitHousing += li.amount - else if (li.item_type === 'benefit_meals') current.benefitMeals += li.amount - else if (['benefit_wellness', 'benefit_other'].includes(li.item_type)) current.benefitOther += li.amount - } - - byEmployee.set(sre.employee_id, current) - } - - if (byEmployee.size === 0) { - return NextResponse.json({ error: `Inga bokförda lönekörningar för ${yearNum}` }, { status: 404 }) - } - - // Employer name on the KU10 follows the current company name - // (company_settings.company_name), not the frozen onboarding companies.name. - const companyName = settings?.company_name || company.name - const companyData: KU10CompanyData = { - orgNumber: (settings?.org_number || company.org_number || '').trim(), - companyName, - year: yearNum, - contactName: (profile?.full_name || companyName || '').trim(), - contactPhone: (settings?.phone || '').trim(), - contactEmail: (settings?.email || profile?.email || user.email || '').trim(), - } - - const r = (x: number) => Math.round(x * 100) / 100 - const employeeData: KU10EmployeeData[] = Array.from(byEmployee.values()).map(emp => ({ - personnummer: emp.personnummer, - specificationNumber: emp.specificationNumber, - totalGross: r(emp.totalGross), - totalTax: r(emp.totalTax), - totalAvgifterBasis: r(emp.totalAvgifterBasis), - benefitCar: emp.benefitCar > 0 ? r(emp.benefitCar) : undefined, - benefitHousing: emp.benefitHousing > 0 ? r(emp.benefitHousing) : undefined, - benefitMeals: emp.benefitMeals > 0 ? r(emp.benefitMeals) : undefined, - benefitOther: emp.benefitOther > 0 ? r(emp.benefitOther) : undefined, - employmentStart: emp.employmentStart || undefined, - employmentEnd: emp.employmentEnd || undefined, - })) - - const xml = generateKU10Xml(companyData, employeeData) - - return new Response(xml, { - headers: { - 'Content-Type': 'application/xml; charset=utf-8', - 'Content-Disposition': `attachment; filename="KU10_${company.org_number}_${yearNum}.xml"`, - }, - }) -} + return new Response(xml, { + headers: { + 'Content-Type': 'application/xml; charset=utf-8', + 'Content-Disposition': `attachment; filename="KU10_${company.org_number}_${yearNum}.xml"`, + }, + }) + }, +) diff --git a/app/api/salary/payroll-config/[year]/route.ts b/app/api/salary/payroll-config/[year]/route.ts index ed3faf71..e2805f46 100644 --- a/app/api/salary/payroll-config/[year]/route.ts +++ b/app/api/salary/payroll-config/[year]/route.ts @@ -1,25 +1,22 @@ -import { createClient } from '@/lib/supabase/server' import { NextResponse } from 'next/server' +import { withRouteContext } from '@/lib/api/with-route-context' import { loadPayrollConfig } from '@/lib/salary/payroll-config' -export async function GET( - request: Request, - { params }: { params: Promise<{ year: string }> } -) { - const { year } = await params - const supabase = await createClient() - const { data: { user } } = await supabase.auth.getUser() - if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) +export const GET = withRouteContext<{ params: Promise<{ year: string }> }>( + 'salary.payroll_config.get', + async (request, { supabase }, { params }) => { + const { year } = await params - const yearNum = parseInt(year) - if (isNaN(yearNum) || yearNum < 2020 || yearNum > 2100) { - return NextResponse.json({ error: 'Ogiltigt år' }, { status: 400 }) - } + const yearNum = parseInt(year) + if (isNaN(yearNum) || yearNum < 2020 || yearNum > 2100) { + return NextResponse.json({ error: 'Ogiltigt år' }, { status: 400 }) + } - try { - const config = await loadPayrollConfig(supabase, yearNum) - return NextResponse.json({ data: config }) - } catch { - return NextResponse.json({ error: `Löneuppgifter för ${year} saknas` }, { status: 404 }) - } -} + try { + const config = await loadPayrollConfig(supabase, yearNum) + return NextResponse.json({ data: config }) + } catch { + return NextResponse.json({ error: `Löneuppgifter för ${year} saknas` }, { status: 404 }) + } + }, +) diff --git a/app/api/salary/runs/[id]/agi/submit/__tests__/route.test.ts b/app/api/salary/runs/[id]/agi/submit/__tests__/route.test.ts index 4f34abc5..ca3d09df 100644 --- a/app/api/salary/runs/[id]/agi/submit/__tests__/route.test.ts +++ b/app/api/salary/runs/[id]/agi/submit/__tests__/route.test.ts @@ -1,4 +1,5 @@ import { describe, it, expect, vi, beforeEach } from 'vitest' +import { NextResponse } from 'next/server' import { createQueuedMockSupabase, createMockRequest, @@ -7,24 +8,19 @@ import { } from '@/tests/helpers' // ── Mocks ──────────────────────────────────────────────────── +// The route is wrapped in withRouteContext (auth via requireAuth, company via +// getActiveCompanyId, write-gate via requireWritePermission). We inject a queued +// Supabase mock through requireAuth and mock fetch to the extension endpoint. -const mockCreateClient = vi.fn() -vi.mock('@/lib/supabase/server', () => ({ - createClient: () => mockCreateClient(), +vi.mock('@/lib/init', () => ({ ensureInitialized: vi.fn() })) +vi.mock('@/lib/auth/require-auth', () => ({ requireAuth: vi.fn() })) +vi.mock('@/lib/company/context', () => ({ + getActiveCompanyId: vi.fn().mockResolvedValue('company-1'), + requireCompanyId: vi.fn().mockResolvedValue('company-1'), })) - -vi.mock('@/lib/init', () => ({ - ensureInitialized: vi.fn(), -})) - vi.mock('@/lib/auth/require-write', () => ({ requireWritePermission: vi.fn().mockResolvedValue({ ok: true }), })) - -vi.mock('@/lib/company/context', () => ({ - requireCompanyId: vi.fn().mockResolvedValue('company-1'), -})) - vi.mock('@/lib/events', () => ({ eventBus: { emit: vi.fn().mockResolvedValue(undefined) }, })) @@ -34,6 +30,8 @@ const mockFetch = vi.fn() vi.stubGlobal('fetch', mockFetch) import { POST } from '../route' +import { requireAuth } from '@/lib/auth/require-auth' +import { requireWritePermission } from '@/lib/auth/require-write' import { eventBus } from '@/lib/events' // ── Test data ──────────────────────────────────────────────── @@ -64,16 +62,29 @@ const makeAgiDeclaration = (overrides = {}) => ({ ...overrides, }) +function authed() { + const { supabase, enqueueMany } = createQueuedMockSupabase() + vi.mocked(requireAuth).mockResolvedValue({ + user: mockUser as never, + supabase: supabase as never, + error: null, + }) + return { supabase, enqueueMany } +} + // ── Tests ──────────────────────────────────────────────────── describe('POST /api/salary/runs/[id]/agi/submit', () => { beforeEach(() => { vi.clearAllMocks() + vi.mocked(requireWritePermission).mockResolvedValue({ ok: true } as never) }) it('returns 401 when not authenticated', async () => { - mockCreateClient.mockResolvedValue({ - auth: { getUser: vi.fn().mockResolvedValue({ data: { user: null } }) }, + vi.mocked(requireAuth).mockResolvedValue({ + user: null, + supabase: null as never, + error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }), }) const request = createMockRequest('/api/salary/runs/run-1/agi/submit', { method: 'POST' }) @@ -84,11 +95,22 @@ describe('POST /api/salary/runs/[id]/agi/submit', () => { expect(body).toEqual({ error: 'Unauthorized' }) }) - it('returns 404 when salary run not found', async () => { - const { supabase, enqueueMany } = createQueuedMockSupabase() - supabase.auth = { getUser: vi.fn().mockResolvedValue({ data: { user: mockUser } }) } - mockCreateClient.mockResolvedValue(supabase) + it('returns 403 for a viewer (no write permission)', async () => { + authed() + vi.mocked(requireWritePermission).mockResolvedValue({ + ok: false, + response: NextResponse.json({ error: 'Forbidden' }, { status: 403 }), + } as never) + const request = createMockRequest('/api/salary/runs/run-1/agi/submit', { method: 'POST' }) + const response = await POST(request, createMockRouteParams({ id: 'run-1' })) + const { status } = await parseJsonResponse(response) + + expect(status).toBe(403) + }) + + it('returns 404 when salary run not found', async () => { + const { enqueueMany } = authed() enqueueMany([ { data: null, error: { message: 'Not found' } }, // salary_runs query ]) @@ -102,10 +124,7 @@ describe('POST /api/salary/runs/[id]/agi/submit', () => { }) it('returns 400 when salary run is in draft status', async () => { - const { supabase, enqueueMany } = createQueuedMockSupabase() - supabase.auth = { getUser: vi.fn().mockResolvedValue({ data: { user: mockUser } }) } - mockCreateClient.mockResolvedValue(supabase) - + const { enqueueMany } = authed() enqueueMany([ { data: makeSalaryRun({ status: 'draft' }) }, // salary_runs query ]) @@ -119,10 +138,7 @@ describe('POST /api/salary/runs/[id]/agi/submit', () => { }) it('returns 400 when AGI has not been generated', async () => { - const { supabase, enqueueMany } = createQueuedMockSupabase() - supabase.auth = { getUser: vi.fn().mockResolvedValue({ data: { user: mockUser } }) } - mockCreateClient.mockResolvedValue(supabase) - + const { enqueueMany } = authed() enqueueMany([ { data: makeSalaryRun() }, // salary_runs query { data: null }, // agi_declarations query (not found) @@ -137,10 +153,7 @@ describe('POST /api/salary/runs/[id]/agi/submit', () => { }) it('returns 409 when AGI has already been submitted', async () => { - const { supabase, enqueueMany } = createQueuedMockSupabase() - supabase.auth = { getUser: vi.fn().mockResolvedValue({ data: { user: mockUser } }) } - mockCreateClient.mockResolvedValue(supabase) - + const { enqueueMany } = authed() enqueueMany([ { data: makeSalaryRun() }, { data: makeAgiDeclaration({ status: 'submitted' }) }, @@ -155,10 +168,7 @@ describe('POST /api/salary/runs/[id]/agi/submit', () => { }) it('submits AGI draft and returns success', async () => { - const { supabase, enqueueMany } = createQueuedMockSupabase() - supabase.auth = { getUser: vi.fn().mockResolvedValue({ data: { user: mockUser } }) } - mockCreateClient.mockResolvedValue(supabase) - + const { enqueueMany } = authed() enqueueMany([ { data: makeSalaryRun() }, // salary_runs query { data: makeAgiDeclaration() }, // agi_declarations query @@ -214,10 +224,7 @@ describe('POST /api/salary/runs/[id]/agi/submit', () => { }) it('returns error when extension draft endpoint fails', async () => { - const { supabase, enqueueMany } = createQueuedMockSupabase() - supabase.auth = { getUser: vi.fn().mockResolvedValue({ data: { user: mockUser } }) } - mockCreateClient.mockResolvedValue(supabase) - + const { enqueueMany } = authed() enqueueMany([ { data: makeSalaryRun() }, { data: makeAgiDeclaration() }, @@ -241,10 +248,7 @@ describe('POST /api/salary/runs/[id]/agi/submit', () => { }) it('accepts booked salary runs for submission', async () => { - const { supabase, enqueueMany } = createQueuedMockSupabase() - supabase.auth = { getUser: vi.fn().mockResolvedValue({ data: { user: mockUser } }) } - mockCreateClient.mockResolvedValue(supabase) - + const { enqueueMany } = authed() enqueueMany([ { data: makeSalaryRun({ status: 'booked' }) }, { data: makeAgiDeclaration() }, diff --git a/app/api/salary/runs/[id]/agi/submit/route.ts b/app/api/salary/runs/[id]/agi/submit/route.ts index fc4af82d..2481e82b 100644 --- a/app/api/salary/runs/[id]/agi/submit/route.ts +++ b/app/api/salary/runs/[id]/agi/submit/route.ts @@ -1,8 +1,6 @@ -import { createClient } from '@/lib/supabase/server' import { NextResponse } from 'next/server' import { ensureInitialized } from '@/lib/init' -import { requireCompanyId } from '@/lib/company/context' -import { requireWritePermission } from '@/lib/auth/require-write' +import { withRouteContext } from '@/lib/api/with-route-context' import { eventBus } from '@/lib/events' ensureInitialized() @@ -19,136 +17,130 @@ ensureInitialized() * The user then signs on Skatteverket's site (Mina Sidor). The frontend * polls /api/extensions/ext/skatteverket/agi/kvittenser to detect completion. */ -export async function POST( - request: Request, - { params }: { params: Promise<{ id: string }> } -) { - const { id } = await params - const supabase = await createClient() - const { data: { user } } = await supabase.auth.getUser() - if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) +export const POST = withRouteContext<{ params: Promise<{ id: string }> }>( + 'salary.run.agi.submit', + async (request, ctx, { params }) => { + const { id } = await params + const { user, supabase, companyId, log } = ctx - const writeCheck = await requireWritePermission(supabase, user.id) - if (!writeCheck.ok) return writeCheck.response + // Load salary run + const { data: run, error: runError } = await supabase + .from('salary_runs') + .select('*') + .eq('id', id) + .eq('company_id', companyId) + .single() - const companyId = await requireCompanyId(supabase, user.id) + if (runError || !run) { + return NextResponse.json({ error: 'Lönekörning hittades inte' }, { status: 404 }) + } - // Load salary run - const { data: run, error: runError } = await supabase - .from('salary_runs') - .select('*') - .eq('id', id) - .eq('company_id', companyId) - .single() - - if (runError || !run) { - return NextResponse.json({ error: 'Lönekörning hittades inte' }, { status: 404 }) - } - - if (!['review', 'approved', 'paid', 'booked'].includes(run.status)) { - return NextResponse.json( - { error: 'AGI kan bara skickas till Skatteverket efter granskning' }, - { status: 400 } - ) - } - - // Ensure AGI has been generated - const { data: agiDeclaration } = await supabase - .from('agi_declarations') - .select('id, status') - .eq('company_id', companyId) - .eq('salary_run_id', id) - .single() - - if (!agiDeclaration) { - return NextResponse.json( - { error: 'AGI har inte genererats ännu. Generera AGI XML först.' }, - { status: 400 } - ) - } - - if (agiDeclaration.status === 'submitted' || agiDeclaration.status === 'accepted') { - return NextResponse.json( - { error: 'AGI har redan skickats till Skatteverket för denna period' }, - { status: 409 } - ) - } - - // The actual SKV interaction lives in the Skatteverket extension. This - // route is a thin orchestrator: it forwards the salary_run_id to the - // extension's /agi/submit endpoint (which posts the stored XML underlag), - // then records that the AGI submission process has started. - // - // The frontend (AGIPanel) handles the rest of the flow: - // 1. POST /api/extensions/ext/skatteverket/agi/submit { salaryRunId } - // → returns { inlamningId } - // 2. GET /api/extensions/ext/skatteverket/agi/kontrollresultat?inlamningId=... - // → poll until status != PROCESSING - // 3. POST /api/extensions/ext/skatteverket/agi/spara { inlamningId } - // 4. POST /api/extensions/ext/skatteverket/agi/granskningsunderlag?arbetsgivare&period - // → returns { link } (Mina Sidor BankID signing) - // 5. GET /api/extensions/ext/skatteverket/agi/kvittenser?arbetsgivare&period - - const appUrl = process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3000' - - try { - const submitResponse = await fetch( - `${appUrl}/api/extensions/ext/skatteverket/agi/submit`, - { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'Cookie': request.headers.get('Cookie') || '', - }, - body: JSON.stringify({ salaryRunId: id }), - } - ) - - if (!submitResponse.ok) { - const errorData = await submitResponse.json().catch(() => ({ error: 'Okänt fel' })) + if (!['review', 'approved', 'paid', 'booked'].includes(run.status)) { return NextResponse.json( - { error: errorData.error || `Kunde inte skicka AGI-underlag (${submitResponse.status})` }, - { status: submitResponse.status } + { error: 'AGI kan bara skickas till Skatteverket efter granskning' }, + { status: 400 } ) } - const submitData = await submitResponse.json() + // Ensure AGI has been generated + const { data: agiDeclaration } = await supabase + .from('agi_declarations') + .select('id, status') + .eq('company_id', companyId) + .eq('salary_run_id', id) + .single() - // Don't stamp salary_runs.agi_submitted_at here. The underlag has only - // been ingested; the user still has to pass kontrollresultat, save, - // produce a granskningsunderlag, and sign with BankID before the AGI is - // actually filed. Recording the submission time at ingest would make the - // audit trail lie about when filing completed. + if (!agiDeclaration) { + return NextResponse.json( + { error: 'AGI har inte genererats ännu. Generera AGI XML först.' }, + { status: 400 } + ) + } + + if (agiDeclaration.status === 'submitted' || agiDeclaration.status === 'accepted') { + return NextResponse.json( + { error: 'AGI har redan skickats till Skatteverket för denna period' }, + { status: 409 } + ) + } + + // The actual SKV interaction lives in the Skatteverket extension. This + // route is a thin orchestrator: it forwards the salary_run_id to the + // extension's /agi/submit endpoint (which posts the stored XML underlag), + // then records that the AGI submission process has started. // - // The real timestamp is set by the kvittenser handler in the extension - // (extensions/general/skatteverket/index.ts /agi/kvittenser route) when - // it observes a uuidKvittens for the period, mirroring SKV's signeradTid. + // The frontend (AGIPanel) handles the rest of the flow: + // 1. POST /api/extensions/ext/skatteverket/agi/submit { salaryRunId } + // → returns { inlamningId } + // 2. GET /api/extensions/ext/skatteverket/agi/kontrollresultat?inlamningId=... + // → poll until status != PROCESSING + // 3. POST /api/extensions/ext/skatteverket/agi/spara { inlamningId } + // 4. POST /api/extensions/ext/skatteverket/agi/granskningsunderlag?arbetsgivare&period + // → returns { link } (Mina Sidor BankID signing) + // 5. GET /api/extensions/ext/skatteverket/agi/kvittenser?arbetsgivare&period - await eventBus.emit({ - type: 'agi.submitted', - payload: { - salaryRunId: id, - periodYear: run.period_year, - periodMonth: run.period_month, - userId: user.id, - companyId, - }, - }) + const appUrl = process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3000' - return NextResponse.json({ - data: { - ...submitData.data, - salaryRunId: id, - periodYear: run.period_year, - periodMonth: run.period_month, - message: 'AGI-underlag inläst hos Skatteverket. Skapa granskningsunderlag och signera med BankID i Mina Sidor.', - }, - }) - } catch (err) { - console.error('[salary/agi/submit] Error:', err) - return NextResponse.json( - { error: err instanceof Error ? err.message : 'Kunde inte skicka AGI till Skatteverket' }, - { status: 500 } - ) - } -} + try { + const submitResponse = await fetch( + `${appUrl}/api/extensions/ext/skatteverket/agi/submit`, + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Cookie': request.headers.get('Cookie') || '', + }, + body: JSON.stringify({ salaryRunId: id }), + } + ) + + if (!submitResponse.ok) { + const errorData = await submitResponse.json().catch(() => ({ error: 'Okänt fel' })) + return NextResponse.json( + { error: errorData.error || `Kunde inte skicka AGI-underlag (${submitResponse.status})` }, + { status: submitResponse.status } + ) + } + + const submitData = await submitResponse.json() + + // Don't stamp salary_runs.agi_submitted_at here. The underlag has only + // been ingested; the user still has to pass kontrollresultat, save, + // produce a granskningsunderlag, and sign with BankID before the AGI is + // actually filed. Recording the submission time at ingest would make the + // audit trail lie about when filing completed. + // + // The real timestamp is set by the kvittenser handler in the extension + // (extensions/general/skatteverket/index.ts /agi/kvittenser route) when + // it observes a uuidKvittens for the period, mirroring SKV's signeradTid. + + await eventBus.emit({ + type: 'agi.submitted', + payload: { + salaryRunId: id, + periodYear: run.period_year, + periodMonth: run.period_month, + userId: user.id, + companyId, + }, + }) + + return NextResponse.json({ + data: { + ...submitData.data, + salaryRunId: id, + periodYear: run.period_year, + periodMonth: run.period_month, + message: 'AGI-underlag inläst hos Skatteverket. Skapa granskningsunderlag och signera med BankID i Mina Sidor.', + }, + }) + } catch (err) { + log.error('[salary/agi/submit] Error', err as Error) + return NextResponse.json( + { error: err instanceof Error ? err.message : 'Kunde inte skicka AGI till Skatteverket' }, + { status: 500 } + ) + } + }, + { requireWrite: true }, +) diff --git a/app/api/salary/runs/[id]/agi/xml/route.ts b/app/api/salary/runs/[id]/agi/xml/route.ts index c3b5dee9..d5eb8329 100644 --- a/app/api/salary/runs/[id]/agi/xml/route.ts +++ b/app/api/salary/runs/[id]/agi/xml/route.ts @@ -1,9 +1,6 @@ -import { createClient } from '@/lib/supabase/server' -import { NextResponse } from 'next/server' import { ensureInitialized } from '@/lib/init' -import { requireCompanyId } from '@/lib/company/context' +import { withRouteContext } from '@/lib/api/with-route-context' import { generateAgiDeclaration } from '@/lib/salary/agi/generate-declaration' -import { createLogger } from '@/lib/logger' import { errorResponseFromCode } from '@/lib/errors/get-structured-error' ensureInitialized() @@ -25,54 +22,46 @@ ensureInitialized() * - Filing deadline: the 12th of the following month (17th in Jan/Aug for * companies ≤ 40 MSEK turnover) */ -export async function GET( - _request: Request, - { params }: { params: Promise<{ id: string }> }, -) { - const { id } = await params - const supabase = await createClient() - const { - data: { user }, - } = await supabase.auth.getUser() - if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) +export const GET = withRouteContext<{ params: Promise<{ id: string }> }>( + 'salary.run.agi.xml', + async (_request, ctx, { params }) => { + const { id } = await params + const { user, supabase, companyId, log, requestId } = ctx - const companyId = await requireCompanyId(supabase, user.id) - const requestId = `req_${crypto.randomUUID()}` - const log = createLogger('api/salary/agi/xml', { requestId, userId: user.id }) - - const result = await generateAgiDeclaration({ - supabase, - companyId, - userId: user.id, - userEmail: user.email ?? null, - salaryRunId: id, - log, - requestId, - }) - - if (!result.ok) { - return errorResponseFromCode(result.code, log, { + const result = await generateAgiDeclaration({ + supabase, + companyId, + userId: user.id, + userEmail: user.email ?? null, + salaryRunId: id, + log, requestId, - details: result.details, - status: result.status, }) - } - // OWASP V3.2 / V4 (HTTP response header injection prevention): sanitise - // header-interpolated values. orgNumber comes from company_settings - // (user-editable) and period_* from the run's own columns, but defense - // in depth requires we strip anything that could be construed as a - // header-injection character before splicing into Content-Disposition. - const safeOrg = result.orgNumber.replace(/[^0-9A-Za-z-]/g, '') - const safePeriod = `${result.periodYear}${String(result.periodMonth).padStart(2, '0')}`.replace( - /[^0-9]/g, - '', - ) + if (!result.ok) { + return errorResponseFromCode(result.code, log, { + requestId, + details: result.details, + status: result.status, + }) + } - return new Response(result.xml, { - headers: { - 'Content-Type': 'application/xml; charset=utf-8', - 'Content-Disposition': `attachment; filename="AGI_${safeOrg}_${safePeriod}.xml"`, - }, - }) -} + // OWASP V3.2 / V4 (HTTP response header injection prevention): sanitise + // header-interpolated values. orgNumber comes from company_settings + // (user-editable) and period_* from the run's own columns, but defense + // in depth requires we strip anything that could be construed as a + // header-injection character before splicing into Content-Disposition. + const safeOrg = result.orgNumber.replace(/[^0-9A-Za-z-]/g, '') + const safePeriod = `${result.periodYear}${String(result.periodMonth).padStart(2, '0')}`.replace( + /[^0-9]/g, + '', + ) + + return new Response(result.xml, { + headers: { + 'Content-Type': 'application/xml; charset=utf-8', + 'Content-Disposition': `attachment; filename="AGI_${safeOrg}_${safePeriod}.xml"`, + }, + }) + }, +) diff --git a/app/api/salary/runs/[id]/correct/__tests__/route.test.ts b/app/api/salary/runs/[id]/correct/__tests__/route.test.ts new file mode 100644 index 00000000..8bba333c --- /dev/null +++ b/app/api/salary/runs/[id]/correct/__tests__/route.test.ts @@ -0,0 +1,121 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { NextResponse } from 'next/server' +import { + createQueuedMockSupabase, + createMockRequest, + parseJsonResponse, + createMockRouteParams, +} from '@/tests/helpers' + +// The route is wrapped in withRouteContext. Auth/company/write are injected via +// mocks; the storno engine and payslip-link revocation are stubbed. +vi.mock('@/lib/init', () => ({ ensureInitialized: vi.fn() })) +vi.mock('@/lib/auth/require-auth', () => ({ requireAuth: vi.fn() })) +vi.mock('@/lib/company/context', () => ({ + getActiveCompanyId: vi.fn().mockResolvedValue('company-1'), + requireCompanyId: vi.fn().mockResolvedValue('company-1'), +})) +vi.mock('@/lib/auth/require-write', () => ({ + requireWritePermission: vi.fn().mockResolvedValue({ ok: true }), +})) +vi.mock('@/lib/bookkeeping/engine', () => ({ reverseEntry: vi.fn() })) +vi.mock('@/lib/salary/payslips/links', () => ({ revokeLinksForRun: vi.fn() })) + +import { POST } from '../route' +import { requireAuth } from '@/lib/auth/require-auth' +import { requireWritePermission } from '@/lib/auth/require-write' + +const mockUser = { id: 'user-1', email: 'test@test.se' } + +function authed() { + const { supabase, enqueueMany } = createQueuedMockSupabase() + vi.mocked(requireAuth).mockResolvedValue({ + user: mockUser as never, + supabase: supabase as never, + error: null, + }) + return { supabase, enqueueMany } +} + +describe('POST /api/salary/runs/[id]/correct', () => { + beforeEach(() => { + vi.clearAllMocks() + vi.mocked(requireWritePermission).mockResolvedValue({ ok: true } as never) + }) + + it('returns 401 when not authenticated', async () => { + vi.mocked(requireAuth).mockResolvedValue({ + user: null, + supabase: null as never, + error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }), + }) + const response = await POST( + createMockRequest('/api/salary/runs/run-1/correct', { method: 'POST' }), + createMockRouteParams({ id: 'run-1' }), + ) + expect(response.status).toBe(401) + }) + + it('returns 403 for a viewer', async () => { + authed() + vi.mocked(requireWritePermission).mockResolvedValue({ + ok: false, + response: NextResponse.json({ error: 'Forbidden' }, { status: 403 }), + } as never) + const response = await POST( + createMockRequest('/api/salary/runs/run-1/correct', { method: 'POST' }), + createMockRouteParams({ id: 'run-1' }), + ) + expect(response.status).toBe(403) + }) + + it('creates a correction run for a booked original', async () => { + const { enqueueMany } = authed() + enqueueMany([ + // original booked run (no entry ids → no reversal loop) + { + data: { + id: 'run-1', + status: 'booked', + period_year: 2026, + period_month: 3, + payment_date: '2026-03-25', + voucher_series: 'A', + salary_entry_id: null, + avgifter_entry_id: null, + vacation_entry_id: null, + pension_entry_id: null, + }, + }, + { data: null }, // update original → corrected + { data: { id: 'corr-1', period_year: 2026, period_month: 3 } }, // insert correction run + { data: [] }, // original employees (none to copy) + ]) + + const response = await POST( + createMockRequest('/api/salary/runs/run-1/correct', { method: 'POST' }), + createMockRouteParams({ id: 'run-1' }), + ) + const { status, body } = await parseJsonResponse<{ data: { id: string }; reversed_entry_count: number }>( + response, + ) + + expect(status).toBe(201) + expect(body.data.id).toBe('corr-1') + expect(body.reversed_entry_count).toBe(0) + }) + + it('rejects correcting a run that is not booked', async () => { + const { enqueueMany } = authed() + enqueueMany([{ data: null, error: { message: 'no rows' } }]) // status filter excludes it + + const response = await POST( + createMockRequest('/api/salary/runs/run-1/correct', { method: 'POST' }), + createMockRouteParams({ id: 'run-1' }), + ) + const { status, body } = await parseJsonResponse<{ error: string }>(response) + + expect(status).toBe(400) + expect(body.error).toContain('bokförda') + }) +}) diff --git a/app/api/salary/runs/[id]/correct/route.ts b/app/api/salary/runs/[id]/correct/route.ts index 2a07f6b4..64ead61a 100644 --- a/app/api/salary/runs/[id]/correct/route.ts +++ b/app/api/salary/runs/[id]/correct/route.ts @@ -1,8 +1,6 @@ -import { createClient } from '@/lib/supabase/server' import { NextResponse } from 'next/server' import { ensureInitialized } from '@/lib/init' -import { requireCompanyId } from '@/lib/company/context' -import { requireWritePermission } from '@/lib/auth/require-write' +import { withRouteContext } from '@/lib/api/with-route-context' import { reverseEntry } from '@/lib/bookkeeping/engine' import { bookkeepingErrorResponse, EntryAlreadyReversedError } from '@/lib/bookkeeping/errors' import { revokeLinksForRun } from '@/lib/salary/payslips/links' @@ -24,151 +22,145 @@ ensureInitialized() * * AGI must be re-generated with same FK570 (correction flag) per agi-filing.md. */ -export async function POST( - request: Request, - { params }: { params: Promise<{ id: string }> } -) { - const { id } = await params - const supabase = await createClient() - const { data: { user } } = await supabase.auth.getUser() - if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) +export const POST = withRouteContext<{ params: Promise<{ id: string }> }>( + 'salary.run.correct', + async (_request, ctx, { params }) => { + const { id } = await params + const { user, supabase, companyId } = ctx - const writeCheck = await requireWritePermission(supabase, user.id) - if (!writeCheck.ok) return writeCheck.response + // Load the original booked run + const { data: originalRun, error: runError } = await supabase + .from('salary_runs') + .select('*') + .eq('id', id) + .eq('company_id', companyId) + .eq('status', 'booked') + .single() - const companyId = await requireCompanyId(supabase, user.id) - - // Load the original booked run - const { data: originalRun, error: runError } = await supabase - .from('salary_runs') - .select('*') - .eq('id', id) - .eq('company_id', companyId) - .eq('status', 'booked') - .single() - - if (runError || !originalRun) { - return NextResponse.json({ error: 'Kan bara korrigera bokförda lönekörningar' }, { status: 400 }) - } - - // Reverse all journal entries from the original run (storno per BFL 5 kap 5§) - const entryIds = [ - originalRun.salary_entry_id, - originalRun.avgifter_entry_id, - originalRun.vacation_entry_id, - originalRun.pension_entry_id, - ].filter(Boolean) as string[] - - for (const entryId of entryIds) { - try { - await reverseEntry(supabase, companyId, user.id, entryId) - } catch (err) { - // Entry may already be reversed: continue - if (err instanceof EntryAlreadyReversedError) continue - const typed = bookkeepingErrorResponse(err) - if (typed) return typed - const msg = err instanceof Error ? err.message : '' - return NextResponse.json({ error: `Kunde inte makulera verifikation: ${msg}` }, { status: 500 }) + if (runError || !originalRun) { + return NextResponse.json({ error: 'Kan bara korrigera bokförda lönekörningar' }, { status: 400 }) } - } - // Mark original as corrected - await supabase - .from('salary_runs') - .update({ status: 'corrected' }) - .eq('id', id) + // Reverse all journal entries from the original run (storno per BFL 5 kap 5§) + const entryIds = [ + originalRun.salary_entry_id, + originalRun.avgifter_entry_id, + originalRun.vacation_entry_id, + originalRun.pension_entry_id, + ].filter(Boolean) as string[] - // The storno replaces the payslips — previously emailed payslip links for - // the original run must stop resolving (they show as "ersatt" to the - // employee). Fresh links are issued when the correction run's payslips - // are sent. - await revokeLinksForRun(supabase, id) - - // Create new correction run for same period - // Remove the unique constraint conflict by using the original run's unique key - // The unique constraint is (company_id, period_year, period_month) so we need - // to delete the uniqueness or handle it. Since original is now 'corrected', - // and we want a new run for the same period, we update the unique constraint. - // Actually the DB still enforces uniqueness. The correction run needs the same period. - // Solution: drop the old unique index and add a partial one excluding corrected runs, - // OR just use the same run ID pattern. Let's create the correction run and handle the conflict. - - const { data: correctionRun, error: createError } = await supabase - .from('salary_runs') - .insert({ - company_id: companyId, - user_id: user.id, - period_year: originalRun.period_year, - period_month: originalRun.period_month, - payment_date: originalRun.payment_date, - voucher_series: originalRun.voucher_series, - is_correction: true, - corrects_run_id: originalRun.id, - notes: `Korrigering av lönekörning ${originalRun.period_year}-${String(originalRun.period_month).padStart(2, '0')}`, - }) - .select() - .single() - - if (createError) { - // If unique constraint violation, the period already has an active run - if (createError.code === '23505') { - return NextResponse.json({ - error: 'Det finns redan en aktiv lönekörning för denna period. Ta bort den först.', - }, { status: 409 }) + for (const entryId of entryIds) { + try { + await reverseEntry(supabase, companyId, user.id, entryId) + } catch (err) { + // Entry may already be reversed: continue + if (err instanceof EntryAlreadyReversedError) continue + const typed = bookkeepingErrorResponse(err) + if (typed) return typed + const msg = err instanceof Error ? err.message : '' + return NextResponse.json({ error: `Kunde inte makulera verifikation: ${msg}` }, { status: 500 }) + } } - return NextResponse.json({ error: createError.message }, { status: 500 }) - } - // Copy employees from original run to correction run (with snapshots) - const { data: originalEmployees } = await supabase - .from('salary_run_employees') - .select('*, line_items:salary_line_items(*)') - .eq('salary_run_id', id) + // Mark original as corrected + await supabase + .from('salary_runs') + .update({ status: 'corrected' }) + .eq('id', id) - for (const origEmp of originalEmployees || []) { - const { data: newSre } = await supabase - .from('salary_run_employees') + // The storno replaces the payslips — previously emailed payslip links for + // the original run must stop resolving (they show as "ersatt" to the + // employee). Fresh links are issued when the correction run's payslips + // are sent. + await revokeLinksForRun(supabase, id) + + // Create new correction run for same period + // Remove the unique constraint conflict by using the original run's unique key + // The unique constraint is (company_id, period_year, period_month) so we need + // to delete the uniqueness or handle it. Since original is now 'corrected', + // and we want a new run for the same period, we update the unique constraint. + // Actually the DB still enforces uniqueness. The correction run needs the same period. + // Solution: drop the old unique index and add a partial one excluding corrected runs, + // OR just use the same run ID pattern. Let's create the correction run and handle the conflict. + + const { data: correctionRun, error: createError } = await supabase + .from('salary_runs') .insert({ - salary_run_id: correctionRun.id, - employee_id: origEmp.employee_id, company_id: companyId, - employment_degree: origEmp.employment_degree, - monthly_salary: origEmp.monthly_salary, - salary_type: origEmp.salary_type, - hours_worked: origEmp.hours_worked, - tax_table_number: origEmp.tax_table_number, - tax_column: origEmp.tax_column, + user_id: user.id, + period_year: originalRun.period_year, + period_month: originalRun.period_month, + payment_date: originalRun.payment_date, + voucher_series: originalRun.voucher_series, + is_correction: true, + corrects_run_id: originalRun.id, + notes: `Korrigering av lönekörning ${originalRun.period_year}-${String(originalRun.period_month).padStart(2, '0')}`, }) .select() .single() - if (newSre) { - // Copy line items - const lineItems = (origEmp.line_items || []) as Array> - for (const li of lineItems) { - await supabase.from('salary_line_items').insert({ - salary_run_employee_id: newSre.id, + if (createError) { + // If unique constraint violation, the period already has an active run + if (createError.code === '23505') { + return NextResponse.json({ + error: 'Det finns redan en aktiv lönekörning för denna period. Ta bort den först.', + }, { status: 409 }) + } + return NextResponse.json({ error: createError.message }, { status: 500 }) + } + + // Copy employees from original run to correction run (with snapshots) + const { data: originalEmployees } = await supabase + .from('salary_run_employees') + .select('*, line_items:salary_line_items(*)') + .eq('salary_run_id', id) + + for (const origEmp of originalEmployees || []) { + const { data: newSre } = await supabase + .from('salary_run_employees') + .insert({ + salary_run_id: correctionRun.id, + employee_id: origEmp.employee_id, company_id: companyId, - item_type: li.item_type, - description: li.description, - quantity: li.quantity, - unit_price: li.unit_price, - amount: li.amount, - is_taxable: li.is_taxable, - is_avgift_basis: li.is_avgift_basis, - is_vacation_basis: li.is_vacation_basis, - is_gross_deduction: li.is_gross_deduction, - is_net_deduction: li.is_net_deduction, - account_number: li.account_number, - sort_order: li.sort_order, + employment_degree: origEmp.employment_degree, + monthly_salary: origEmp.monthly_salary, + salary_type: origEmp.salary_type, + hours_worked: origEmp.hours_worked, + tax_table_number: origEmp.tax_table_number, + tax_column: origEmp.tax_column, }) + .select() + .single() + + if (newSre) { + // Copy line items + const lineItems = (origEmp.line_items || []) as Array> + for (const li of lineItems) { + await supabase.from('salary_line_items').insert({ + salary_run_employee_id: newSre.id, + company_id: companyId, + item_type: li.item_type, + description: li.description, + quantity: li.quantity, + unit_price: li.unit_price, + amount: li.amount, + is_taxable: li.is_taxable, + is_avgift_basis: li.is_avgift_basis, + is_vacation_basis: li.is_vacation_basis, + is_gross_deduction: li.is_gross_deduction, + is_net_deduction: li.is_net_deduction, + account_number: li.account_number, + sort_order: li.sort_order, + }) + } } } - } - return NextResponse.json({ - data: correctionRun, - message: 'Korrigeringskörning skapad. Originalverifikationer har makulerats (storno). Redigera och beräkna om den nya körningen.', - reversed_entry_count: entryIds.length, - }, { status: 201 }) -} + return NextResponse.json({ + data: correctionRun, + message: 'Korrigeringskörning skapad. Originalverifikationer har makulerats (storno). Redigera och beräkna om den nya körningen.', + reversed_entry_count: entryIds.length, + }, { status: 201 }) + }, + { requireWrite: true }, +) diff --git a/app/api/salary/runs/[id]/employees/[employeeId]/__tests__/route.test.ts b/app/api/salary/runs/[id]/employees/[employeeId]/__tests__/route.test.ts index 438de757..4ff1d8d8 100644 --- a/app/api/salary/runs/[id]/employees/[employeeId]/__tests__/route.test.ts +++ b/app/api/salary/runs/[id]/employees/[employeeId]/__tests__/route.test.ts @@ -1,4 +1,5 @@ import { describe, it, expect, vi, beforeEach } from 'vitest' +import { NextResponse } from 'next/server' import { createQueuedMockSupabase, createMockRequest, @@ -7,43 +8,83 @@ import { } from '@/tests/helpers' // ── Mocks ──────────────────────────────────────────────────── -// This route hand-rolls auth (createClient + getUser) rather than -// withRouteContext, so we mock createClient and the write/company helpers. +// The route is wrapped in withRouteContext. We inject a queued Supabase mock +// through requireAuth and mock the company/write helpers the wrapper uses. vi.mock('@/lib/init', () => ({ ensureInitialized: vi.fn() })) - -const mockCreateClient = vi.fn() -vi.mock('@/lib/supabase/server', () => ({ createClient: () => mockCreateClient() })) - +vi.mock('@/lib/auth/require-auth', () => ({ requireAuth: vi.fn() })) +vi.mock('@/lib/company/context', () => ({ + getActiveCompanyId: vi.fn().mockResolvedValue('company-1'), + requireCompanyId: vi.fn().mockResolvedValue('company-1'), +})) vi.mock('@/lib/auth/require-write', () => ({ requireWritePermission: vi.fn().mockResolvedValue({ ok: true }), })) -vi.mock('@/lib/company/context', () => ({ - requireCompanyId: vi.fn().mockResolvedValue('company-1'), -})) vi.mock('@/lib/salary/personnummer', () => ({ decryptPersonnummer: (x: string) => x, maskPersonnummer: (x: string) => x, })) import { PATCH } from '../route' +import { requireAuth } from '@/lib/auth/require-auth' +import { requireWritePermission } from '@/lib/auth/require-write' const mockUser = { id: 'user-1', email: 'test@test.se' } -function authedSupabase() { +function authed() { const { supabase, enqueueMany } = createQueuedMockSupabase() - supabase.auth = { getUser: vi.fn().mockResolvedValue({ data: { user: mockUser } }) } - mockCreateClient.mockResolvedValue(supabase) + vi.mocked(requireAuth).mockResolvedValue({ + user: mockUser as never, + supabase: supabase as never, + error: null, + }) return { supabase, enqueueMany } } describe('PATCH /api/salary/runs/[id]/employees/[employeeId]: monthly salary edit', () => { beforeEach(() => { vi.clearAllMocks() + vi.mocked(requireWritePermission).mockResolvedValue({ ok: true } as never) + }) + + it('returns 401 when not authenticated', async () => { + vi.mocked(requireAuth).mockResolvedValue({ + user: null, + supabase: null as never, + error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }), + }) + + const request = createMockRequest('/api/salary/runs/run-1/employees/emp-1', { + method: 'PATCH', + body: { monthly_salary: 30000 }, + }) + const response = await PATCH( + request, + createMockRouteParams({ id: 'run-1', employeeId: 'emp-1' }), + ) + expect(response.status).toBe(401) + }) + + it('returns 403 for a viewer (no write permission)', async () => { + authed() + vi.mocked(requireWritePermission).mockResolvedValue({ + ok: false, + response: NextResponse.json({ error: 'Forbidden' }, { status: 403 }), + } as never) + + const request = createMockRequest('/api/salary/runs/run-1/employees/emp-1', { + method: 'PATCH', + body: { monthly_salary: 30000 }, + }) + const response = await PATCH( + request, + createMockRouteParams({ id: 'run-1', employeeId: 'emp-1' }), + ) + expect(response.status).toBe(403) }) it('updates the per-run monthly salary while the run is a draft', async () => { - const { enqueueMany } = authedSupabase() + const { enqueueMany } = authed() enqueueMany([ { data: { id: 'run-1', status: 'draft' } }, // salary_runs lookup { @@ -67,7 +108,7 @@ describe('PATCH /api/salary/runs/[id]/employees/[employeeId]: monthly salary edi }) it('allows a zero monthly salary (nollkörning) on a draft', async () => { - const { enqueueMany } = authedSupabase() + const { enqueueMany } = authed() enqueueMany([ { data: { id: 'run-1', status: 'draft' } }, { data: { id: 'sre-1', employment_degree: 100, salary_type: 'monthly', monthly_salary: 0 } }, @@ -88,7 +129,7 @@ describe('PATCH /api/salary/runs/[id]/employees/[employeeId]: monthly salary edi }) it('rejects a monthly salary edit when the run is no longer a draft', async () => { - const { enqueueMany } = authedSupabase() + const { enqueueMany } = authed() enqueueMany([ { data: { id: 'run-1', status: 'review' } }, // not a draft ]) @@ -108,7 +149,7 @@ describe('PATCH /api/salary/runs/[id]/employees/[employeeId]: monthly salary edi }) it('rejects mixing a salary edit with a tax override in one request', async () => { - authedSupabase() + authed() const request = createMockRequest('/api/salary/runs/run-1/employees/emp-1', { method: 'PATCH', diff --git a/app/api/salary/runs/[id]/employees/[employeeId]/route.ts b/app/api/salary/runs/[id]/employees/[employeeId]/route.ts index 7a7506d5..63ad694a 100644 --- a/app/api/salary/runs/[id]/employees/[employeeId]/route.ts +++ b/app/api/salary/runs/[id]/employees/[employeeId]/route.ts @@ -1,8 +1,6 @@ -import { createClient } from '@/lib/supabase/server' import { NextResponse } from 'next/server' import { ensureInitialized } from '@/lib/init' -import { requireCompanyId } from '@/lib/company/context' -import { requireWritePermission } from '@/lib/auth/require-write' +import { withRouteContext } from '@/lib/api/with-route-context' import { validateBody } from '@/lib/api/validate' import { SalaryEmployeeOverrideSchema } from '@/lib/api/schemas' import { decryptPersonnummer, maskPersonnummer } from '@/lib/salary/personnummer' @@ -10,47 +8,43 @@ import { decryptPersonnummer, maskPersonnummer } from '@/lib/salary/personnummer ensureInitialized() /** Fetch one employee's pay spec within a salary run, with employee + line items. */ -export async function GET( - request: Request, - { params }: { params: Promise<{ id: string; employeeId: string }> }, -) { - const { id, employeeId } = await params - const supabase = await createClient() - const { data: { user } } = await supabase.auth.getUser() - if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) +export const GET = withRouteContext<{ params: Promise<{ id: string; employeeId: string }> }>( + 'salary.run.employee.get', + async (_request, ctx, { params }) => { + const { id, employeeId } = await params + const { supabase, companyId } = ctx - const companyId = await requireCompanyId(supabase, user.id) + const { data, error } = await supabase + .from('salary_run_employees') + .select('*, employee:employees(*), line_items:salary_line_items(*)') + .eq('salary_run_id', id) + .eq('employee_id', employeeId) + .eq('company_id', companyId) + .maybeSingle() - const { data, error } = await supabase - .from('salary_run_employees') - .select('*, employee:employees(*), line_items:salary_line_items(*)') - .eq('salary_run_id', id) - .eq('employee_id', employeeId) - .eq('company_id', companyId) - .maybeSingle() + if (error) { + return NextResponse.json({ error: error.message }, { status: 500 }) + } + if (!data) { + return NextResponse.json({ error: 'Anställd hittades inte i lönekörningen' }, { status: 404 }) + } - if (error) { - return NextResponse.json({ error: error.message }, { status: 500 }) - } - if (!data) { - return NextResponse.json({ error: 'Anställd hittades inte i lönekörningen' }, { status: 404 }) - } + // Strip the encrypted personnummer ciphertext before sending to the browser: + // replace it with the YYYYMMDD-XXXX masked form so the page can render + // identity without exposing the suffix or the raw cipher blob. + const masked = { + ...data, + employee: data.employee + ? { + ...data.employee, + personnummer: maskPersonnummer(decryptPersonnummer(data.employee.personnummer)), + } + : data.employee, + } - // Strip the encrypted personnummer ciphertext before sending to the browser: - // replace it with the YYYYMMDD-XXXX masked form so the page can render - // identity without exposing the suffix or the raw cipher blob. - const masked = { - ...data, - employee: data.employee - ? { - ...data.employee, - personnummer: maskPersonnummer(decryptPersonnummer(data.employee.personnummer)), - } - : data.employee, - } - - return NextResponse.json({ data: masked }) -} + return NextResponse.json({ data: masked }) + }, +) /** * Per-employee edits within a salary run. Two operations, gated to different @@ -67,170 +61,158 @@ export async function GET( * the effective values; further changes require correction flows. Pass `null` * for any override field to clear it. */ -export async function PATCH( - request: Request, - { params }: { params: Promise<{ id: string; employeeId: string }> }, -) { - const { id, employeeId } = await params - const supabase = await createClient() - const { data: { user } } = await supabase.auth.getUser() - if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) +export const PATCH = withRouteContext<{ params: Promise<{ id: string; employeeId: string }> }>( + 'salary.run.employee.update', + async (request, ctx, { params }) => { + const { id, employeeId } = await params + const { supabase, companyId } = ctx - const writeCheck = await requireWritePermission(supabase, user.id) - if (!writeCheck.ok) return writeCheck.response + const parsed = await validateBody(request, SalaryEmployeeOverrideSchema) + if (!parsed.success) return parsed.response - const companyId = await requireCompanyId(supabase, user.id) + // Two distinct operations share this endpoint, gated to different statuses: + // • monthly_salary → edit this month's base salary (draft only) + // • *_override → manual tax/avgifter adjustment (review only) + // They must not be mixed in one request. + const wantsSalaryEdit = parsed.data.monthly_salary !== undefined + const wantsOverride = + parsed.data.tax_withheld_override !== undefined || + parsed.data.avgifter_amount_override !== undefined || + parsed.data.avgifter_basis_override !== undefined || + parsed.data.reason !== undefined - const parsed = await validateBody(request, SalaryEmployeeOverrideSchema) - if (!parsed.success) return parsed.response - - // Two distinct operations share this endpoint, gated to different statuses: - // • monthly_salary → edit this month's base salary (draft only) - // • *_override → manual tax/avgifter adjustment (review only) - // They must not be mixed in one request. - const wantsSalaryEdit = parsed.data.monthly_salary !== undefined - const wantsOverride = - parsed.data.tax_withheld_override !== undefined || - parsed.data.avgifter_amount_override !== undefined || - parsed.data.avgifter_basis_override !== undefined || - parsed.data.reason !== undefined - - if (wantsSalaryEdit && wantsOverride) { - return NextResponse.json( - { error: 'Kan inte ändra månadslön och skatte-/avgiftsjustering i samma anrop.' }, - { status: 400 }, - ) - } - - const { data: run } = await supabase - .from('salary_runs') - .select('id, status') - .eq('id', id) - .eq('company_id', companyId) - .single() - - if (!run) return NextResponse.json({ error: 'Lönekörning hittades inte' }, { status: 404 }) - - // ── Draft-stage edit of this month's base salary ── - if (wantsSalaryEdit) { - if (run.status !== 'draft') { + if (wantsSalaryEdit && wantsOverride) { return NextResponse.json( - { error: 'Månadslönen kan bara redigeras medan lönekörningen är ett utkast.' }, + { error: 'Kan inte ändra månadslön och skatte-/avgiftsjustering i samma anrop.' }, { status: 400 }, ) } - const monthly = Math.round((parsed.data.monthly_salary as number) * 100) / 100 - const { data: sre, error: sreErr } = await supabase + const { data: run } = await supabase + .from('salary_runs') + .select('id, status') + .eq('id', id) + .eq('company_id', companyId) + .single() + + if (!run) return NextResponse.json({ error: 'Lönekörning hittades inte' }, { status: 404 }) + + // ── Draft-stage edit of this month's base salary ── + if (wantsSalaryEdit) { + if (run.status !== 'draft') { + return NextResponse.json( + { error: 'Månadslönen kan bara redigeras medan lönekörningen är ett utkast.' }, + { status: 400 }, + ) + } + const monthly = Math.round((parsed.data.monthly_salary as number) * 100) / 100 + + const { data: sre, error: sreErr } = await supabase + .from('salary_run_employees') + .update({ monthly_salary: monthly }) + .eq('salary_run_id', id) + .eq('employee_id', employeeId) + .eq('company_id', companyId) + .select('id, employment_degree, salary_type, monthly_salary') + .maybeSingle() + + if (sreErr) return NextResponse.json({ error: sreErr.message }, { status: 400 }) + if (!sre) { + return NextResponse.json({ error: 'Anställd hittades inte i lönekörningen' }, { status: 404 }) + } + + // Keep the displayed 'Grundlön' line consistent with the new salary. This is + // display-only: the engine recomputes baseSalary from monthly_salary at + // calc time, but it avoids a stale row before the user clicks Beräkna. + if (sre.salary_type === 'monthly') { + const baseAmount = Math.round(monthly * (sre.employment_degree / 100) * 100) / 100 + await supabase + .from('salary_line_items') + .update({ amount: baseAmount }) + .eq('salary_run_employee_id', sre.id) + .eq('company_id', companyId) + .eq('item_type', 'monthly_salary') + } + + return NextResponse.json({ data: sre }) + } + + // ── Review-stage override of tax/avgifter ── + if (run.status !== 'review') { + return NextResponse.json( + { error: 'Justering av skatt/avgifter är bara tillåten i granskningsläge (review).' }, + { status: 400 }, + ) + } + + // Build patch: only include fields that were explicitly provided so + // unrelated overrides are not nulled. + const patch: Record = {} + if ('tax_withheld_override' in parsed.data) { + patch.tax_withheld_override = parsed.data.tax_withheld_override ?? null + } + if ('avgifter_amount_override' in parsed.data) { + patch.avgifter_amount_override = parsed.data.avgifter_amount_override ?? null + } + if ('avgifter_basis_override' in parsed.data) { + patch.avgifter_basis_override = parsed.data.avgifter_basis_override ?? null + } + if ('reason' in parsed.data) { + patch.override_reason = parsed.data.reason ?? null + } + + const { data, error } = await supabase .from('salary_run_employees') - .update({ monthly_salary: monthly }) + .update(patch) .eq('salary_run_id', id) .eq('employee_id', employeeId) .eq('company_id', companyId) - .select('id, employment_degree, salary_type, monthly_salary') + .select('id, tax_withheld, tax_withheld_override, avgifter_amount, avgifter_amount_override, avgifter_basis, avgifter_basis_override, override_reason') .maybeSingle() - if (sreErr) return NextResponse.json({ error: sreErr.message }, { status: 400 }) - if (!sre) { + if (error) { + return NextResponse.json({ error: error.message }, { status: 400 }) + } + if (!data) { return NextResponse.json({ error: 'Anställd hittades inte i lönekörningen' }, { status: 404 }) } - // Keep the displayed 'Grundlön' line consistent with the new salary. This is - // display-only: the engine recomputes baseSalary from monthly_salary at - // calc time, but it avoids a stale row before the user clicks Beräkna. - if (sre.salary_type === 'monthly') { - const baseAmount = Math.round(monthly * (sre.employment_degree / 100) * 100) / 100 - await supabase - .from('salary_line_items') - .update({ amount: baseAmount }) - .eq('salary_run_employee_id', sre.id) - .eq('company_id', companyId) - .eq('item_type', 'monthly_salary') - } - - return NextResponse.json({ data: sre }) - } - - // ── Review-stage override of tax/avgifter ── - if (run.status !== 'review') { - return NextResponse.json( - { error: 'Justering av skatt/avgifter är bara tillåten i granskningsläge (review).' }, - { status: 400 }, - ) - } - - // Build patch: only include fields that were explicitly provided so - // unrelated overrides are not nulled. - const patch: Record = {} - if ('tax_withheld_override' in parsed.data) { - patch.tax_withheld_override = parsed.data.tax_withheld_override ?? null - } - if ('avgifter_amount_override' in parsed.data) { - patch.avgifter_amount_override = parsed.data.avgifter_amount_override ?? null - } - if ('avgifter_basis_override' in parsed.data) { - patch.avgifter_basis_override = parsed.data.avgifter_basis_override ?? null - } - if ('reason' in parsed.data) { - patch.override_reason = parsed.data.reason ?? null - } - - const { data, error } = await supabase - .from('salary_run_employees') - .update(patch) - .eq('salary_run_id', id) - .eq('employee_id', employeeId) - .eq('company_id', companyId) - .select('id, tax_withheld, tax_withheld_override, avgifter_amount, avgifter_amount_override, avgifter_basis, avgifter_basis_override, override_reason') - .maybeSingle() - - if (error) { - return NextResponse.json({ error: error.message }, { status: 400 }) - } - if (!data) { - return NextResponse.json({ error: 'Anställd hittades inte i lönekörningen' }, { status: 404 }) - } - - return NextResponse.json({ data }) -} + return NextResponse.json({ data }) + }, + { requireWrite: true }, +) /** Remove employee from a draft salary run. Cascades to delete their line items. */ -export async function DELETE( - request: Request, - { params }: { params: Promise<{ id: string; employeeId: string }> } -) { - const { id, employeeId } = await params - const supabase = await createClient() - const { data: { user } } = await supabase.auth.getUser() - if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) +export const DELETE = withRouteContext<{ params: Promise<{ id: string; employeeId: string }> }>( + 'salary.run.employee.delete', + async (_request, ctx, { params }) => { + const { id, employeeId } = await params + const { supabase, companyId } = ctx - const writeCheck = await requireWritePermission(supabase, user.id) - if (!writeCheck.ok) return writeCheck.response + // Verify run is draft + const { data: run } = await supabase + .from('salary_runs') + .select('id, status') + .eq('id', id) + .eq('company_id', companyId) + .single() - const companyId = await requireCompanyId(supabase, user.id) + if (!run) return NextResponse.json({ error: 'Lönekörning hittades inte' }, { status: 404 }) + if (run.status !== 'draft') return NextResponse.json({ error: 'Kan bara redigera utkast' }, { status: 400 }) - // Verify run is draft - const { data: run } = await supabase - .from('salary_runs') - .select('id, status') - .eq('id', id) - .eq('company_id', companyId) - .single() + // Delete the salary_run_employee (cascades to salary_line_items via ON DELETE CASCADE) + const { error } = await supabase + .from('salary_run_employees') + .delete() + .eq('salary_run_id', id) + .eq('employee_id', employeeId) + .eq('company_id', companyId) - if (!run) return NextResponse.json({ error: 'Lönekörning hittades inte' }, { status: 404 }) - if (run.status !== 'draft') return NextResponse.json({ error: 'Kan bara redigera utkast' }, { status: 400 }) + if (error) { + return NextResponse.json({ error: error.message }, { status: 500 }) + } - // Delete the salary_run_employee (cascades to salary_line_items via ON DELETE CASCADE) - const { error } = await supabase - .from('salary_run_employees') - .delete() - .eq('salary_run_id', id) - .eq('employee_id', employeeId) - .eq('company_id', companyId) - - if (error) { - return NextResponse.json({ error: error.message }, { status: 500 }) - } - - return NextResponse.json({ data: { deleted: true } }) -} + return NextResponse.json({ data: { deleted: true } }) + }, + { requireWrite: true }, +) diff --git a/app/api/salary/runs/[id]/employees/__tests__/route.test.ts b/app/api/salary/runs/[id]/employees/__tests__/route.test.ts new file mode 100644 index 00000000..9b0af4d5 --- /dev/null +++ b/app/api/salary/runs/[id]/employees/__tests__/route.test.ts @@ -0,0 +1,120 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { NextResponse } from 'next/server' +import { + createQueuedMockSupabase, + createMockRequest, + parseJsonResponse, + createMockRouteParams, +} from '@/tests/helpers' + +vi.mock('@/lib/init', () => ({ ensureInitialized: vi.fn() })) +vi.mock('@/lib/auth/require-auth', () => ({ requireAuth: vi.fn() })) +vi.mock('@/lib/company/context', () => ({ + getActiveCompanyId: vi.fn().mockResolvedValue('company-1'), + requireCompanyId: vi.fn().mockResolvedValue('company-1'), +})) +vi.mock('@/lib/auth/require-write', () => ({ + requireWritePermission: vi.fn().mockResolvedValue({ ok: true }), +})) + +import { POST } from '../route' +import { requireAuth } from '@/lib/auth/require-auth' +import { requireWritePermission } from '@/lib/auth/require-write' + +const mockUser = { id: 'user-1', email: 'test@test.se' } +const EMP_UUID = '11111111-1111-4111-8111-111111111111' + +function authed() { + const { supabase, enqueueMany } = createQueuedMockSupabase() + vi.mocked(requireAuth).mockResolvedValue({ + user: mockUser as never, + supabase: supabase as never, + error: null, + }) + return { supabase, enqueueMany } +} + +describe('POST /api/salary/runs/[id]/employees', () => { + beforeEach(() => { + vi.clearAllMocks() + vi.mocked(requireWritePermission).mockResolvedValue({ ok: true } as never) + }) + + it('returns 401 when not authenticated', async () => { + vi.mocked(requireAuth).mockResolvedValue({ + user: null, + supabase: null as never, + error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }), + }) + const response = await POST( + createMockRequest('/api/salary/runs/run-1/employees', { + method: 'POST', + body: { employee_id: EMP_UUID }, + }), + createMockRouteParams({ id: 'run-1' }), + ) + expect(response.status).toBe(401) + }) + + it('returns 403 for a viewer', async () => { + authed() + vi.mocked(requireWritePermission).mockResolvedValue({ + ok: false, + response: NextResponse.json({ error: 'Forbidden' }, { status: 403 }), + } as never) + const response = await POST( + createMockRequest('/api/salary/runs/run-1/employees', { + method: 'POST', + body: { employee_id: EMP_UUID }, + }), + createMockRouteParams({ id: 'run-1' }), + ) + expect(response.status).toBe(403) + }) + + it('returns 400 on an invalid body', async () => { + authed() + const response = await POST( + createMockRequest('/api/salary/runs/run-1/employees', { + method: 'POST', + body: { employee_id: 'not-a-uuid' }, + }), + createMockRouteParams({ id: 'run-1' }), + ) + expect(response.status).toBe(400) + }) + + it('adds an employee to a draft run and returns 201', async () => { + const { enqueueMany } = authed() + enqueueMany([ + { data: { id: 'run-1', status: 'draft' } }, // salary_runs lookup + { + data: { + id: EMP_UUID, + employment_degree: 100, + monthly_salary: 30000, + salary_type: 'monthly', + tax_table_number: 31, + tax_column: 1, + employment_type: 'employee', + hourly_rate: null, + }, + }, // employees lookup + { data: null }, // already-added check (not present) + { data: { id: 'sre-1', employee_id: EMP_UUID } }, // insert salary_run_employees + { data: null }, // insert base line item + ]) + + const response = await POST( + createMockRequest('/api/salary/runs/run-1/employees', { + method: 'POST', + body: { employee_id: EMP_UUID }, + }), + createMockRouteParams({ id: 'run-1' }), + ) + const { status, body } = await parseJsonResponse<{ data: { id: string } }>(response) + + expect(status).toBe(201) + expect(body.data.id).toBe('sre-1') + }) +}) diff --git a/app/api/salary/runs/[id]/employees/route.ts b/app/api/salary/runs/[id]/employees/route.ts index dd08e810..2fe29471 100644 --- a/app/api/salary/runs/[id]/employees/route.ts +++ b/app/api/salary/runs/[id]/employees/route.ts @@ -1,119 +1,111 @@ -import { createClient } from '@/lib/supabase/server' import { NextResponse } from 'next/server' import { ensureInitialized } from '@/lib/init' +import { withRouteContext } from '@/lib/api/with-route-context' import { validateBody } from '@/lib/api/validate' import { AddEmployeeToRunSchema } from '@/lib/api/schemas' -import { requireCompanyId } from '@/lib/company/context' -import { requireWritePermission } from '@/lib/auth/require-write' import { getLineItemAccount } from '@/lib/salary/account-mapping' import type { SalaryLineItemType } from '@/types' ensureInitialized() -export async function POST( - request: Request, - { params }: { params: Promise<{ id: string }> } -) { - const { id } = await params - const supabase = await createClient() - const { data: { user } } = await supabase.auth.getUser() - if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) +export const POST = withRouteContext<{ params: Promise<{ id: string }> }>( + 'salary.run.employee.add', + async (request, ctx, { params }) => { + const { id } = await params + const { supabase, companyId } = ctx - const writeCheck = await requireWritePermission(supabase, user.id) - if (!writeCheck.ok) return writeCheck.response + const validation = await validateBody(request, AddEmployeeToRunSchema) + if (!validation.success) return validation.response + const body = validation.data - const companyId = await requireCompanyId(supabase, user.id) + // Verify run is draft + const { data: run, error: runError } = await supabase + .from('salary_runs') + .select('id, status') + .eq('id', id) + .eq('company_id', companyId) + .single() - const validation = await validateBody(request, AddEmployeeToRunSchema) - if (!validation.success) return validation.response - const body = validation.data + if (runError || !run) { + return NextResponse.json({ error: 'Lönekörning hittades inte' }, { status: 404 }) + } + if (run.status !== 'draft') { + return NextResponse.json({ error: 'Kan bara lägga till anställda i utkast' }, { status: 400 }) + } - // Verify run is draft - const { data: run, error: runError } = await supabase - .from('salary_runs') - .select('id, status') - .eq('id', id) - .eq('company_id', companyId) - .single() + // Verify employee exists and is active + const { data: employee, error: empError } = await supabase + .from('employees') + .select('*') + .eq('id', body.employee_id) + .eq('company_id', companyId) + .eq('is_active', true) + .single() - if (runError || !run) { - return NextResponse.json({ error: 'Lönekörning hittades inte' }, { status: 404 }) - } - if (run.status !== 'draft') { - return NextResponse.json({ error: 'Kan bara lägga till anställda i utkast' }, { status: 400 }) - } + if (empError || !employee) { + return NextResponse.json({ error: 'Anställd hittades inte' }, { status: 404 }) + } - // Verify employee exists and is active - const { data: employee, error: empError } = await supabase - .from('employees') - .select('*') - .eq('id', body.employee_id) - .eq('company_id', companyId) - .eq('is_active', true) - .single() + // Check if already added + const { data: existing } = await supabase + .from('salary_run_employees') + .select('id') + .eq('salary_run_id', id) + .eq('employee_id', body.employee_id) + .single() - if (empError || !employee) { - return NextResponse.json({ error: 'Anställd hittades inte' }, { status: 404 }) - } + if (existing) { + return NextResponse.json({ error: 'Anställd redan tillagd i denna lönekörning' }, { status: 409 }) + } - // Check if already added - const { data: existing } = await supabase - .from('salary_run_employees') - .select('id') - .eq('salary_run_id', id) - .eq('employee_id', body.employee_id) - .single() + // Snapshot employee data + const { data: sre, error: sreError } = await supabase + .from('salary_run_employees') + .insert({ + salary_run_id: id, + employee_id: employee.id, + company_id: companyId, + employment_degree: employee.employment_degree, + monthly_salary: employee.monthly_salary || 0, + salary_type: employee.salary_type, + hours_worked: body.hours_worked || null, + tax_table_number: employee.tax_table_number, + tax_column: employee.tax_column, + }) + .select() + .single() - if (existing) { - return NextResponse.json({ error: 'Anställd redan tillagd i denna lönekörning' }, { status: 409 }) - } + if (sreError) { + return NextResponse.json({ error: sreError.message }, { status: 500 }) + } - // Snapshot employee data - const { data: sre, error: sreError } = await supabase - .from('salary_run_employees') - .insert({ - salary_run_id: id, - employee_id: employee.id, - company_id: companyId, - employment_degree: employee.employment_degree, - monthly_salary: employee.monthly_salary || 0, - salary_type: employee.salary_type, - hours_worked: body.hours_worked || null, - tax_table_number: employee.tax_table_number, - tax_column: employee.tax_column, - }) - .select() - .single() + // Auto-create base salary line item + const baseSalaryType: SalaryLineItemType = employee.salary_type === 'monthly' ? 'monthly_salary' : 'hourly_salary' + let baseAmount: number + if (employee.salary_type === 'monthly') { + baseAmount = Math.round((employee.monthly_salary || 0) * (employee.employment_degree / 100) * 100) / 100 + } else { + baseAmount = Math.round((employee.hourly_rate || 0) * (body.hours_worked || 0) * 100) / 100 + } - if (sreError) { - return NextResponse.json({ error: sreError.message }, { status: 500 }) - } + await supabase + .from('salary_line_items') + .insert({ + salary_run_employee_id: sre.id, + company_id: companyId, + item_type: baseSalaryType, + description: employee.salary_type === 'monthly' ? 'Grundlön' : 'Timlön', + quantity: employee.salary_type === 'hourly' ? body.hours_worked : null, + unit_price: employee.salary_type === 'hourly' ? employee.hourly_rate : null, + amount: baseAmount, + is_taxable: true, + is_avgift_basis: true, + is_vacation_basis: true, + account_number: getLineItemAccount(baseSalaryType, employee.employment_type), + sort_order: 0, + }) - // Auto-create base salary line item - const baseSalaryType: SalaryLineItemType = employee.salary_type === 'monthly' ? 'monthly_salary' : 'hourly_salary' - let baseAmount: number - if (employee.salary_type === 'monthly') { - baseAmount = Math.round((employee.monthly_salary || 0) * (employee.employment_degree / 100) * 100) / 100 - } else { - baseAmount = Math.round((employee.hourly_rate || 0) * (body.hours_worked || 0) * 100) / 100 - } - - await supabase - .from('salary_line_items') - .insert({ - salary_run_employee_id: sre.id, - company_id: companyId, - item_type: baseSalaryType, - description: employee.salary_type === 'monthly' ? 'Grundlön' : 'Timlön', - quantity: employee.salary_type === 'hourly' ? body.hours_worked : null, - unit_price: employee.salary_type === 'hourly' ? employee.hourly_rate : null, - amount: baseAmount, - is_taxable: true, - is_avgift_basis: true, - is_vacation_basis: true, - account_number: getLineItemAccount(baseSalaryType, employee.employment_type), - sort_order: 0, - }) - - return NextResponse.json({ data: sre }, { status: 201 }) -} + return NextResponse.json({ data: sre }, { status: 201 }) + }, + { requireWrite: true }, +) diff --git a/app/api/salary/runs/[id]/lines/[lineId]/__tests__/route.test.ts b/app/api/salary/runs/[id]/lines/[lineId]/__tests__/route.test.ts new file mode 100644 index 00000000..4a7aad8f --- /dev/null +++ b/app/api/salary/runs/[id]/lines/[lineId]/__tests__/route.test.ts @@ -0,0 +1,132 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { NextResponse } from 'next/server' +import { + createQueuedMockSupabase, + createMockRequest, + parseJsonResponse, + createMockRouteParams, +} from '@/tests/helpers' + +vi.mock('@/lib/init', () => ({ ensureInitialized: vi.fn() })) +vi.mock('@/lib/auth/require-auth', () => ({ requireAuth: vi.fn() })) +vi.mock('@/lib/company/context', () => ({ + getActiveCompanyId: vi.fn().mockResolvedValue('company-1'), + requireCompanyId: vi.fn().mockResolvedValue('company-1'), +})) +vi.mock('@/lib/auth/require-write', () => ({ + requireWritePermission: vi.fn().mockResolvedValue({ ok: true }), +})) + +import { PATCH, DELETE } from '../route' +import { requireAuth } from '@/lib/auth/require-auth' +import { requireWritePermission } from '@/lib/auth/require-write' + +const mockUser = { id: 'user-1', email: 'test@test.se' } + +function authed() { + const { supabase, enqueueMany } = createQueuedMockSupabase() + vi.mocked(requireAuth).mockResolvedValue({ + user: mockUser as never, + supabase: supabase as never, + error: null, + }) + return { supabase, enqueueMany } +} + +const params = () => createMockRouteParams({ id: 'run-1', lineId: 'line-1' }) + +describe('PATCH /api/salary/runs/[id]/lines/[lineId]', () => { + beforeEach(() => { + vi.clearAllMocks() + vi.mocked(requireWritePermission).mockResolvedValue({ ok: true } as never) + }) + + it('returns 401 when not authenticated', async () => { + vi.mocked(requireAuth).mockResolvedValue({ + user: null, + supabase: null as never, + error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }), + }) + const response = await PATCH( + createMockRequest('/api/salary/runs/run-1/lines/line-1', { method: 'PATCH', body: { amount: 100 } }), + params(), + ) + expect(response.status).toBe(401) + }) + + it('returns 403 for a viewer', async () => { + authed() + vi.mocked(requireWritePermission).mockResolvedValue({ + ok: false, + response: NextResponse.json({ error: 'Forbidden' }, { status: 403 }), + } as never) + const response = await PATCH( + createMockRequest('/api/salary/runs/run-1/lines/line-1', { method: 'PATCH', body: { amount: 100 } }), + params(), + ) + expect(response.status).toBe(403) + }) + + it('updates a line on a draft run', async () => { + const { enqueueMany } = authed() + enqueueMany([ + { data: { id: 'run-1', status: 'draft' } }, // salary_runs lookup + { data: { id: 'line-1', amount: 100 } }, // update returning + ]) + const response = await PATCH( + createMockRequest('/api/salary/runs/run-1/lines/line-1', { method: 'PATCH', body: { amount: 100 } }), + params(), + ) + const { status, body } = await parseJsonResponse<{ data: { id: string } }>(response) + expect(status).toBe(200) + expect(body.data.id).toBe('line-1') + }) +}) + +describe('DELETE /api/salary/runs/[id]/lines/[lineId]', () => { + beforeEach(() => { + vi.clearAllMocks() + vi.mocked(requireWritePermission).mockResolvedValue({ ok: true } as never) + }) + + it('returns 401 when not authenticated', async () => { + vi.mocked(requireAuth).mockResolvedValue({ + user: null, + supabase: null as never, + error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }), + }) + const response = await DELETE( + createMockRequest('/api/salary/runs/run-1/lines/line-1', { method: 'DELETE' }), + params(), + ) + expect(response.status).toBe(401) + }) + + it('returns 403 for a viewer', async () => { + authed() + vi.mocked(requireWritePermission).mockResolvedValue({ + ok: false, + response: NextResponse.json({ error: 'Forbidden' }, { status: 403 }), + } as never) + const response = await DELETE( + createMockRequest('/api/salary/runs/run-1/lines/line-1', { method: 'DELETE' }), + params(), + ) + expect(response.status).toBe(403) + }) + + it('deletes a line on a draft run', async () => { + const { enqueueMany } = authed() + enqueueMany([ + { data: { id: 'run-1', status: 'draft' } }, // salary_runs lookup + { data: null }, // delete (error null) + ]) + const response = await DELETE( + createMockRequest('/api/salary/runs/run-1/lines/line-1', { method: 'DELETE' }), + params(), + ) + const { status, body } = await parseJsonResponse<{ data: { deleted: boolean } }>(response) + expect(status).toBe(200) + expect(body.data.deleted).toBe(true) + }) +}) diff --git a/app/api/salary/runs/[id]/lines/[lineId]/route.ts b/app/api/salary/runs/[id]/lines/[lineId]/route.ts index 69a6bb50..fdae1137 100644 --- a/app/api/salary/runs/[id]/lines/[lineId]/route.ts +++ b/app/api/salary/runs/[id]/lines/[lineId]/route.ts @@ -1,97 +1,83 @@ -import { createClient } from '@/lib/supabase/server' import { NextResponse } from 'next/server' import { ensureInitialized } from '@/lib/init' +import { withRouteContext } from '@/lib/api/with-route-context' import { validateBody } from '@/lib/api/validate' import { UpdateSalaryLineItemSchema } from '@/lib/api/schemas' -import { requireCompanyId } from '@/lib/company/context' -import { requireWritePermission } from '@/lib/auth/require-write' ensureInitialized() -export async function PATCH( - request: Request, - { params }: { params: Promise<{ id: string; lineId: string }> } -) { - const { id, lineId } = await params - const supabase = await createClient() - const { data: { user } } = await supabase.auth.getUser() - if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) +export const PATCH = withRouteContext<{ params: Promise<{ id: string; lineId: string }> }>( + 'salary.run.line.update', + async (request, ctx, { params }) => { + const { id, lineId } = await params + const { supabase, companyId } = ctx - const writeCheck = await requireWritePermission(supabase, user.id) - if (!writeCheck.ok) return writeCheck.response + // Verify run is draft + const { data: run } = await supabase + .from('salary_runs') + .select('id, status') + .eq('id', id) + .eq('company_id', companyId) + .single() - const companyId = await requireCompanyId(supabase, user.id) + if (!run) return NextResponse.json({ error: 'Lönekörning hittades inte' }, { status: 404 }) + if (run.status !== 'draft') return NextResponse.json({ error: 'Kan bara redigera utkast' }, { status: 400 }) - // Verify run is draft - const { data: run } = await supabase - .from('salary_runs') - .select('id, status') - .eq('id', id) - .eq('company_id', companyId) - .single() + const validation = await validateBody(request, UpdateSalaryLineItemSchema) + if (!validation.success) return validation.response + const body = validation.data - if (!run) return NextResponse.json({ error: 'Lönekörning hittades inte' }, { status: 404 }) - if (run.status !== 'draft') return NextResponse.json({ error: 'Kan bara redigera utkast' }, { status: 400 }) + // Round amount if provided + const updates = { ...body } + if (updates.amount !== undefined) { + updates.amount = Math.round(updates.amount * 100) / 100 + } - const validation = await validateBody(request, UpdateSalaryLineItemSchema) - if (!validation.success) return validation.response - const body = validation.data + const { data: updated, error } = await supabase + .from('salary_line_items') + .update(updates) + .eq('id', lineId) + .eq('company_id', companyId) + .select() + .single() - // Round amount if provided - const updates = { ...body } - if (updates.amount !== undefined) { - updates.amount = Math.round(updates.amount * 100) / 100 - } + if (error || !updated) { + return NextResponse.json({ error: 'Rad hittades inte' }, { status: 404 }) + } - const { data: updated, error } = await supabase - .from('salary_line_items') - .update(updates) - .eq('id', lineId) - .eq('company_id', companyId) - .select() - .single() + return NextResponse.json({ data: updated }) + }, + { requireWrite: true }, +) - if (error || !updated) { - return NextResponse.json({ error: 'Rad hittades inte' }, { status: 404 }) - } +export const DELETE = withRouteContext<{ params: Promise<{ id: string; lineId: string }> }>( + 'salary.run.line.delete', + async (_request, ctx, { params }) => { + const { id, lineId } = await params + const { supabase, companyId } = ctx - return NextResponse.json({ data: updated }) -} + // Verify run is draft + const { data: run } = await supabase + .from('salary_runs') + .select('id, status') + .eq('id', id) + .eq('company_id', companyId) + .single() -export async function DELETE( - request: Request, - { params }: { params: Promise<{ id: string; lineId: string }> } -) { - const { id, lineId } = await params - const supabase = await createClient() - const { data: { user } } = await supabase.auth.getUser() - if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + if (!run) return NextResponse.json({ error: 'Lönekörning hittades inte' }, { status: 404 }) + if (run.status !== 'draft') return NextResponse.json({ error: 'Kan bara redigera utkast' }, { status: 400 }) - const writeCheck = await requireWritePermission(supabase, user.id) - if (!writeCheck.ok) return writeCheck.response + const { error } = await supabase + .from('salary_line_items') + .delete() + .eq('id', lineId) + .eq('company_id', companyId) - const companyId = await requireCompanyId(supabase, user.id) + if (error) { + return NextResponse.json({ error: error.message }, { status: 500 }) + } - // Verify run is draft - const { data: run } = await supabase - .from('salary_runs') - .select('id, status') - .eq('id', id) - .eq('company_id', companyId) - .single() - - if (!run) return NextResponse.json({ error: 'Lönekörning hittades inte' }, { status: 404 }) - if (run.status !== 'draft') return NextResponse.json({ error: 'Kan bara redigera utkast' }, { status: 400 }) - - const { error } = await supabase - .from('salary_line_items') - .delete() - .eq('id', lineId) - .eq('company_id', companyId) - - if (error) { - return NextResponse.json({ error: error.message }, { status: 500 }) - } - - return NextResponse.json({ data: { deleted: true } }) -} + return NextResponse.json({ data: { deleted: true } }) + }, + { requireWrite: true }, +) diff --git a/app/api/salary/runs/[id]/lines/__tests__/route.test.ts b/app/api/salary/runs/[id]/lines/__tests__/route.test.ts new file mode 100644 index 00000000..be3ff650 --- /dev/null +++ b/app/api/salary/runs/[id]/lines/__tests__/route.test.ts @@ -0,0 +1,93 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { NextResponse } from 'next/server' +import { + createQueuedMockSupabase, + createMockRequest, + parseJsonResponse, + createMockRouteParams, +} from '@/tests/helpers' + +vi.mock('@/lib/init', () => ({ ensureInitialized: vi.fn() })) +vi.mock('@/lib/auth/require-auth', () => ({ requireAuth: vi.fn() })) +vi.mock('@/lib/company/context', () => ({ + getActiveCompanyId: vi.fn().mockResolvedValue('company-1'), + requireCompanyId: vi.fn().mockResolvedValue('company-1'), +})) +vi.mock('@/lib/auth/require-write', () => ({ + requireWritePermission: vi.fn().mockResolvedValue({ ok: true }), +})) + +import { POST } from '../route' +import { requireAuth } from '@/lib/auth/require-auth' +import { requireWritePermission } from '@/lib/auth/require-write' + +const mockUser = { id: 'user-1', email: 'test@test.se' } +const SRE_UUID = '22222222-2222-4222-8222-222222222222' + +function authed() { + const { supabase, enqueueMany } = createQueuedMockSupabase() + vi.mocked(requireAuth).mockResolvedValue({ + user: mockUser as never, + supabase: supabase as never, + error: null, + }) + return { supabase, enqueueMany } +} + +const validBody = { + salary_run_employee_id: SRE_UUID, + item_type: 'bonus', + description: 'Bonus', + amount: 5000, +} + +describe('POST /api/salary/runs/[id]/lines', () => { + beforeEach(() => { + vi.clearAllMocks() + vi.mocked(requireWritePermission).mockResolvedValue({ ok: true } as never) + }) + + it('returns 401 when not authenticated', async () => { + vi.mocked(requireAuth).mockResolvedValue({ + user: null, + supabase: null as never, + error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }), + }) + const response = await POST( + createMockRequest('/api/salary/runs/run-1/lines', { method: 'POST', body: validBody }), + createMockRouteParams({ id: 'run-1' }), + ) + expect(response.status).toBe(401) + }) + + it('returns 403 for a viewer', async () => { + authed() + vi.mocked(requireWritePermission).mockResolvedValue({ + ok: false, + response: NextResponse.json({ error: 'Forbidden' }, { status: 403 }), + } as never) + const response = await POST( + createMockRequest('/api/salary/runs/run-1/lines', { method: 'POST', body: validBody }), + createMockRouteParams({ id: 'run-1' }), + ) + expect(response.status).toBe(403) + }) + + it('creates a line item on a draft run and returns 201', async () => { + const { enqueueMany } = authed() + enqueueMany([ + { data: { id: 'run-1', status: 'draft' } }, // salary_runs lookup + { data: { id: SRE_UUID, employee_id: 'emp-1' } }, // salary_run_employees membership + { data: { id: 'li-1' } }, // insert line item + ]) + + const response = await POST( + createMockRequest('/api/salary/runs/run-1/lines', { method: 'POST', body: validBody }), + createMockRouteParams({ id: 'run-1' }), + ) + const { status, body } = await parseJsonResponse<{ data: { id: string } }>(response) + + expect(status).toBe(201) + expect(body.data.id).toBe('li-1') + }) +}) diff --git a/app/api/salary/runs/[id]/lines/route.ts b/app/api/salary/runs/[id]/lines/route.ts index cf8bc2ca..48915b9a 100644 --- a/app/api/salary/runs/[id]/lines/route.ts +++ b/app/api/salary/runs/[id]/lines/route.ts @@ -1,86 +1,78 @@ -import { createClient } from '@/lib/supabase/server' import { NextResponse } from 'next/server' import { ensureInitialized } from '@/lib/init' +import { withRouteContext } from '@/lib/api/with-route-context' import { validateBody } from '@/lib/api/validate' import { CreateSalaryLineItemSchema } from '@/lib/api/schemas' -import { requireCompanyId } from '@/lib/company/context' -import { requireWritePermission } from '@/lib/auth/require-write' import { getLineItemAccount } from '@/lib/salary/account-mapping' ensureInitialized() -export async function POST( - request: Request, - { params }: { params: Promise<{ id: string }> } -) { - const { id } = await params - const supabase = await createClient() - const { data: { user } } = await supabase.auth.getUser() - if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) +export const POST = withRouteContext<{ params: Promise<{ id: string }> }>( + 'salary.run.line.create', + async (request, ctx, { params }) => { + const { id } = await params + const { supabase, companyId } = ctx - const writeCheck = await requireWritePermission(supabase, user.id) - if (!writeCheck.ok) return writeCheck.response + const validation = await validateBody(request, CreateSalaryLineItemSchema) + if (!validation.success) return validation.response + const body = validation.data - const companyId = await requireCompanyId(supabase, user.id) + // Verify run is draft + const { data: run } = await supabase + .from('salary_runs') + .select('id, status') + .eq('id', id) + .eq('company_id', companyId) + .single() - const validation = await validateBody(request, CreateSalaryLineItemSchema) - if (!validation.success) return validation.response - const body = validation.data + if (!run) { + return NextResponse.json({ error: 'Lönekörning hittades inte' }, { status: 404 }) + } + if (run.status !== 'draft') { + return NextResponse.json({ error: 'Kan bara redigera utkast' }, { status: 400 }) + } - // Verify run is draft - const { data: run } = await supabase - .from('salary_runs') - .select('id, status') - .eq('id', id) - .eq('company_id', companyId) - .single() + // Verify salary_run_employee belongs to this run + const { data: sre } = await supabase + .from('salary_run_employees') + .select('id, employee_id') + .eq('id', body.salary_run_employee_id) + .eq('salary_run_id', id) + .single() - if (!run) { - return NextResponse.json({ error: 'Lönekörning hittades inte' }, { status: 404 }) - } - if (run.status !== 'draft') { - return NextResponse.json({ error: 'Kan bara redigera utkast' }, { status: 400 }) - } + if (!sre) { + return NextResponse.json({ error: 'Anställd finns inte i denna lönekörning' }, { status: 404 }) + } - // Verify salary_run_employee belongs to this run - const { data: sre } = await supabase - .from('salary_run_employees') - .select('id, employee_id') - .eq('id', body.salary_run_employee_id) - .eq('salary_run_id', id) - .single() + // Auto-resolve account if not provided + const accountNumber = body.account_number || getLineItemAccount(body.item_type as never) - if (!sre) { - return NextResponse.json({ error: 'Anställd finns inte i denna lönekörning' }, { status: 404 }) - } + const { data: lineItem, error } = await supabase + .from('salary_line_items') + .insert({ + salary_run_employee_id: body.salary_run_employee_id, + company_id: companyId, + item_type: body.item_type, + description: body.description, + quantity: body.quantity || null, + unit_price: body.unit_price || null, + amount: Math.round(body.amount * 100) / 100, + is_taxable: body.is_taxable, + is_avgift_basis: body.is_avgift_basis, + is_vacation_basis: body.is_vacation_basis, + is_gross_deduction: body.is_gross_deduction, + is_net_deduction: body.is_net_deduction, + account_number: accountNumber, + sort_order: body.sort_order, + }) + .select() + .single() - // Auto-resolve account if not provided - const accountNumber = body.account_number || getLineItemAccount(body.item_type as never) + if (error) { + return NextResponse.json({ error: error.message }, { status: 500 }) + } - const { data: lineItem, error } = await supabase - .from('salary_line_items') - .insert({ - salary_run_employee_id: body.salary_run_employee_id, - company_id: companyId, - item_type: body.item_type, - description: body.description, - quantity: body.quantity || null, - unit_price: body.unit_price || null, - amount: Math.round(body.amount * 100) / 100, - is_taxable: body.is_taxable, - is_avgift_basis: body.is_avgift_basis, - is_vacation_basis: body.is_vacation_basis, - is_gross_deduction: body.is_gross_deduction, - is_net_deduction: body.is_net_deduction, - account_number: accountNumber, - sort_order: body.sort_order, - }) - .select() - .single() - - if (error) { - return NextResponse.json({ error: error.message }, { status: 500 }) - } - - return NextResponse.json({ data: lineItem }, { status: 201 }) -} + return NextResponse.json({ data: lineItem }, { status: 201 }) + }, + { requireWrite: true }, +) diff --git a/app/api/salary/runs/[id]/payment/bg-lb/__tests__/route.test.ts b/app/api/salary/runs/[id]/payment/bg-lb/__tests__/route.test.ts new file mode 100644 index 00000000..7d384bda --- /dev/null +++ b/app/api/salary/runs/[id]/payment/bg-lb/__tests__/route.test.ts @@ -0,0 +1,100 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { NextResponse } from 'next/server' +import { createQueuedMockSupabase, createMockRequest, createMockRouteParams } from '@/tests/helpers' + +// The route is wrapped in withRouteContext and gated with requireWrite (it +// persists payment_file_generated_at). The file generator, net-payout helper and +// bankgiro validator are stubbed so we can exercise auth + the happy path. +vi.mock('@/lib/init', () => ({ ensureInitialized: vi.fn() })) +vi.mock('@/lib/auth/require-auth', () => ({ requireAuth: vi.fn() })) +vi.mock('@/lib/company/context', () => ({ + getActiveCompanyId: vi.fn().mockResolvedValue('company-1'), + requireCompanyId: vi.fn().mockResolvedValue('company-1'), +})) +vi.mock('@/lib/auth/require-write', () => ({ + requireWritePermission: vi.fn().mockResolvedValue({ ok: true }), +})) +vi.mock('@/lib/salary/payment/bg-lb-generator', () => ({ + generateBgLb: vi.fn(() => ({ content: 'LBFILE', filename: 'lb_2026-03.txt' })), +})) +vi.mock('@/lib/salary/payment/effective-net', () => ({ + effectiveNetPayout: vi.fn(() => 20000), +})) +vi.mock('@/lib/bankgiro/luhn', () => ({ + validateBankgiroNumber: vi.fn(() => true), +})) + +import { GET } from '../route' +import { requireAuth } from '@/lib/auth/require-auth' +import { requireWritePermission } from '@/lib/auth/require-write' + +const mockUser = { id: 'user-1', email: 'test@test.se' } + +function authed() { + const { supabase, enqueueMany } = createQueuedMockSupabase() + vi.mocked(requireAuth).mockResolvedValue({ + user: mockUser as never, + supabase: supabase as never, + error: null, + }) + return { supabase, enqueueMany } +} + +describe('GET /api/salary/runs/[id]/payment/bg-lb', () => { + beforeEach(() => { + vi.clearAllMocks() + vi.mocked(requireWritePermission).mockResolvedValue({ ok: true } as never) + }) + + it('returns 401 when not authenticated', async () => { + vi.mocked(requireAuth).mockResolvedValue({ + user: null, + supabase: null as never, + error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }), + }) + const response = await GET( + createMockRequest('/api/salary/runs/run-1/payment/bg-lb'), + createMockRouteParams({ id: 'run-1' }), + ) + expect(response.status).toBe(401) + }) + + it('returns 403 for a viewer', async () => { + authed() + vi.mocked(requireWritePermission).mockResolvedValue({ + ok: false, + response: NextResponse.json({ error: 'Forbidden' }, { status: 403 }), + } as never) + const response = await GET( + createMockRequest('/api/salary/runs/run-1/payment/bg-lb'), + createMockRouteParams({ id: 'run-1' }), + ) + expect(response.status).toBe(403) + }) + + it('generates a Bankgirot LB file for an approved run', async () => { + const { enqueueMany } = authed() + enqueueMany([ + { data: { id: 'run-1', status: 'approved', period_year: 2026, period_month: 3, payment_date: '2026-03-25' } }, + { data: { name: 'Bolaget AB' } }, // companies + { data: { company_name: 'Bolaget AB', bankgiro: '123-4567' } }, // company_settings + { + data: [ + { + employee: { first_name: 'Anna', last_name: 'A', clearing_number: '1234', bank_account_number: '567890' }, + }, + ], + }, // salary_run_employees + { data: null }, // salary_runs update (payment_file_generated_at) + ]) + + const response = await GET( + createMockRequest('/api/salary/runs/run-1/payment/bg-lb'), + createMockRouteParams({ id: 'run-1' }), + ) + + expect(response.status).toBe(200) + expect(response.headers.get('Content-Type')).toBe('text/plain; charset=iso-8859-1') + expect(response.headers.get('Content-Disposition')).toContain('lb_2026-03.txt') + }) +}) diff --git a/app/api/salary/runs/[id]/payment/bg-lb/route.ts b/app/api/salary/runs/[id]/payment/bg-lb/route.ts index 27899bbe..7c5a4272 100644 --- a/app/api/salary/runs/[id]/payment/bg-lb/route.ts +++ b/app/api/salary/runs/[id]/payment/bg-lb/route.ts @@ -1,8 +1,6 @@ -import { createClient } from '@/lib/supabase/server' import { NextResponse } from 'next/server' import { ensureInitialized } from '@/lib/init' -import { requireCompanyId } from '@/lib/company/context' -import { requireWritePermission } from '@/lib/auth/require-write' +import { withRouteContext } from '@/lib/api/with-route-context' import { generateBgLb } from '@/lib/salary/payment/bg-lb-generator' import { effectiveNetPayout } from '@/lib/salary/payment/effective-net' import { validateBankgiroNumber } from '@/lib/bankgiro/luhn' @@ -20,146 +18,140 @@ ensureInitialized() * Per BFL: The payment file is räkenskapsinformation linked to the salary * journal entry. Subject to 7-year retention. */ -export async function GET( - request: Request, - { params }: { params: Promise<{ id: string }> } -) { - const { id } = await params - const supabase = await createClient() - const { data: { user } } = await supabase.auth.getUser() - if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) +export const GET = withRouteContext<{ params: Promise<{ id: string }> }>( + 'salary.run.payment.bg_lb', + async (_request, ctx, { params }) => { + const { id } = await params + const { supabase, companyId } = ctx - const writeCheck = await requireWritePermission(supabase, user.id) - if (!writeCheck.ok) return writeCheck.response + const { data: run } = await supabase + .from('salary_runs') + .select('*') + .eq('id', id) + .eq('company_id', companyId) + .single() - const companyId = await requireCompanyId(supabase, user.id) + if (!run) { + return NextResponse.json({ error: 'Lönekörning hittades inte' }, { status: 404 }) + } - const { data: run } = await supabase - .from('salary_runs') - .select('*') - .eq('id', id) - .eq('company_id', companyId) - .single() + if (!['approved', 'paid', 'booked'].includes(run.status)) { + return NextResponse.json({ error: 'Betalfil kan bara genereras efter godkännande' }, { status: 400 }) + } - if (!run) { - return NextResponse.json({ error: 'Lönekörning hittades inte' }, { status: 404 }) - } + const { data: company } = await supabase + .from('companies') + .select('name') + .eq('id', companyId) + .single() - if (!['approved', 'paid', 'booked'].includes(run.status)) { - return NextResponse.json({ error: 'Betalfil kan bara genereras efter godkännande' }, { status: 400 }) - } + if (!company) { + return NextResponse.json({ error: 'Företag hittades inte' }, { status: 404 }) + } - const { data: company } = await supabase - .from('companies') - .select('name') - .eq('id', companyId) - .single() + const { data: settings } = await supabase + .from('company_settings') + .select('company_name, bankgiro') + .eq('company_id', companyId) + .single() - if (!company) { - return NextResponse.json({ error: 'Företag hittades inte' }, { status: 404 }) - } + if (!settings?.bankgiro) { + return NextResponse.json( + { error: 'Bankgironummer saknas i företagsinställningar. Krävs för Bankgirot LB-fil.' }, + { status: 400 } + ) + } - const { data: settings } = await supabase - .from('company_settings') - .select('company_name, bankgiro') - .eq('company_id', companyId) - .single() + if (!validateBankgiroNumber(settings.bankgiro)) { + return NextResponse.json( + { error: 'Bankgironumret i företagsinställningar är ogiltigt (felaktig kontrollsiffra).' }, + { status: 400 } + ) + } - if (!settings?.bankgiro) { - return NextResponse.json( - { error: 'Bankgironummer saknas i företagsinställningar. Krävs för Bankgirot LB-fil.' }, - { status: 400 } - ) - } + const { data: runEmployees } = await supabase + .from('salary_run_employees') + .select('*, employee:employees(first_name, last_name, clearing_number, bank_account_number)') + .eq('salary_run_id', id) - if (!validateBankgiroNumber(settings.bankgiro)) { - return NextResponse.json( - { error: 'Bankgironumret i företagsinställningar är ogiltigt (felaktig kontrollsiffra).' }, - { status: 400 } - ) - } + if (!runEmployees || runEmployees.length === 0) { + return NextResponse.json({ error: 'Inga anställda i lönekörningen' }, { status: 400 }) + } - const { data: runEmployees } = await supabase - .from('salary_run_employees') - .select('*, employee:employees(first_name, last_name, clearing_number, bank_account_number)') - .eq('salary_run_id', id) - - if (!runEmployees || runEmployees.length === 0) { - return NextResponse.json({ error: 'Inga anställda i lönekörningen' }, { status: 400 }) - } - - // Only employees with a positive payout end up in the file (see filter - // below), so missing bank details must only block when they're actually - // being paid: a zero-net employee needs no destination account. - const missingBank = runEmployees.filter((sre) => { - if (effectiveNetPayout(sre) <= 0) return false - const emp = sre.employee as { clearing_number: string | null; bank_account_number: string | null } | null - return !emp?.clearing_number || !emp?.bank_account_number - }) - - if (missingBank.length > 0) { - return NextResponse.json( - { error: `${missingBank.length} anställd(a) saknar bankkontouppgifter` }, - { status: 400 } - ) - } - - const companyData: BgLbCompanyData = { - // Sender name follows the current company name (company_settings.company_name), - // not the frozen onboarding companies.name. - name: settings.company_name || company.name, - senderBankgiro: settings.bankgiro, - } - - const employees: BgLbEmployee[] = runEmployees - // Honor tax override on the bank payment file too: the net the employee - // actually receives depends on the effective tax. - .map((sre) => ({ sre, effectiveNet: effectiveNetPayout(sre) })) - .filter(({ effectiveNet }) => effectiveNet > 0) - .map(({ sre, effectiveNet }) => { - const emp = sre.employee as { - first_name: string - last_name: string - clearing_number: string - bank_account_number: string - } - return { - name: `${emp.first_name} ${emp.last_name}`, - clearingNumber: emp.clearing_number, - bankAccountNumber: emp.bank_account_number, - netSalary: effectiveNet, - } + // Only employees with a positive payout end up in the file (see filter + // below), so missing bank details must only block when they're actually + // being paid: a zero-net employee needs no destination account. + const missingBank = runEmployees.filter((sre) => { + if (effectiveNetPayout(sre) <= 0) return false + const emp = sre.employee as { clearing_number: string | null; bank_account_number: string | null } | null + return !emp?.clearing_number || !emp?.bank_account_number }) - const periodLabel = `${run.period_year}-${String(run.period_month).padStart(2, '0')}` + if (missingBank.length > 0) { + return NextResponse.json( + { error: `${missingBank.length} anställd(a) saknar bankkontouppgifter` }, + { status: 400 } + ) + } - let result - try { - result = generateBgLb(companyData, employees, { - paymentDate: run.payment_date, - periodLabel, + const companyData: BgLbCompanyData = { + // Sender name follows the current company name (company_settings.company_name), + // not the frozen onboarding companies.name. + name: settings.company_name || company.name, + senderBankgiro: settings.bankgiro, + } + + const employees: BgLbEmployee[] = runEmployees + // Honor tax override on the bank payment file too: the net the employee + // actually receives depends on the effective tax. + .map((sre) => ({ sre, effectiveNet: effectiveNetPayout(sre) })) + .filter(({ effectiveNet }) => effectiveNet > 0) + .map(({ sre, effectiveNet }) => { + const emp = sre.employee as { + first_name: string + last_name: string + clearing_number: string + bank_account_number: string + } + return { + name: `${emp.first_name} ${emp.last_name}`, + clearingNumber: emp.clearing_number, + bankAccountNumber: emp.bank_account_number, + netSalary: effectiveNet, + } + }) + + const periodLabel = `${run.period_year}-${String(run.period_month).padStart(2, '0')}` + + let result + try { + result = generateBgLb(companyData, employees, { + paymentDate: run.payment_date, + periodLabel, + }) + } catch (err) { + const msg = err instanceof Error ? err.message : 'Kunde inte generera Bankgirot LB-fil' + return NextResponse.json({ error: msg }, { status: 400 }) + } + + await supabase + .from('salary_runs') + .update({ + payment_file_format: 'bg_lb', + payment_file_generated_at: new Date().toISOString(), + }) + .eq('id', id) + .eq('company_id', companyId) + + // ISO 8859-1 encoding: re-encode the JS string to Latin-1 bytes. + const buffer = Buffer.from(result.content, 'latin1') + + return new Response(buffer, { + headers: { + 'Content-Type': 'text/plain; charset=iso-8859-1', + 'Content-Disposition': `attachment; filename="${result.filename}"`, + }, }) - } catch (err) { - const msg = err instanceof Error ? err.message : 'Kunde inte generera Bankgirot LB-fil' - return NextResponse.json({ error: msg }, { status: 400 }) - } - - await supabase - .from('salary_runs') - .update({ - payment_file_format: 'bg_lb', - payment_file_generated_at: new Date().toISOString(), - }) - .eq('id', id) - .eq('company_id', companyId) - - // ISO 8859-1 encoding: re-encode the JS string to Latin-1 bytes. - const buffer = Buffer.from(result.content, 'latin1') - - return new Response(buffer, { - headers: { - 'Content-Type': 'text/plain; charset=iso-8859-1', - 'Content-Disposition': `attachment; filename="${result.filename}"`, - }, - }) -} + }, + { requireWrite: true }, +) diff --git a/app/api/salary/runs/[id]/payment/pain001/__tests__/route.test.ts b/app/api/salary/runs/[id]/payment/pain001/__tests__/route.test.ts new file mode 100644 index 00000000..d9190a86 --- /dev/null +++ b/app/api/salary/runs/[id]/payment/pain001/__tests__/route.test.ts @@ -0,0 +1,100 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { NextResponse } from 'next/server' +import { createQueuedMockSupabase, createMockRequest, createMockRouteParams } from '@/tests/helpers' + +// The route is wrapped in withRouteContext and gated with requireWrite (it +// persists payment_file_generated_at). The XML generator, net-payout helper and +// branding are stubbed so we can exercise auth + the happy path. +vi.mock('@/lib/init', () => ({ ensureInitialized: vi.fn() })) +vi.mock('@/lib/auth/require-auth', () => ({ requireAuth: vi.fn() })) +vi.mock('@/lib/company/context', () => ({ + getActiveCompanyId: vi.fn().mockResolvedValue('company-1'), + requireCompanyId: vi.fn().mockResolvedValue('company-1'), +})) +vi.mock('@/lib/auth/require-write', () => ({ + requireWritePermission: vi.fn().mockResolvedValue({ ok: true }), +})) +vi.mock('@/lib/salary/payment/pain001-generator', () => ({ + generatePain001: vi.fn(() => ''), +})) +vi.mock('@/lib/salary/payment/effective-net', () => ({ + effectiveNetPayout: vi.fn(() => 20000), +})) +vi.mock('@/lib/branding/service', () => ({ + getBranding: vi.fn(() => ({ appName: 'gnubok' })), +})) + +import { GET } from '../route' +import { requireAuth } from '@/lib/auth/require-auth' +import { requireWritePermission } from '@/lib/auth/require-write' + +const mockUser = { id: 'user-1', email: 'test@test.se' } + +function authed() { + const { supabase, enqueueMany } = createQueuedMockSupabase() + vi.mocked(requireAuth).mockResolvedValue({ + user: mockUser as never, + supabase: supabase as never, + error: null, + }) + return { supabase, enqueueMany } +} + +describe('GET /api/salary/runs/[id]/payment/pain001', () => { + beforeEach(() => { + vi.clearAllMocks() + vi.mocked(requireWritePermission).mockResolvedValue({ ok: true } as never) + }) + + it('returns 401 when not authenticated', async () => { + vi.mocked(requireAuth).mockResolvedValue({ + user: null, + supabase: null as never, + error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }), + }) + const response = await GET( + createMockRequest('/api/salary/runs/run-1/payment/pain001'), + createMockRouteParams({ id: 'run-1' }), + ) + expect(response.status).toBe(401) + }) + + it('returns 403 for a viewer', async () => { + authed() + vi.mocked(requireWritePermission).mockResolvedValue({ + ok: false, + response: NextResponse.json({ error: 'Forbidden' }, { status: 403 }), + } as never) + const response = await GET( + createMockRequest('/api/salary/runs/run-1/payment/pain001'), + createMockRouteParams({ id: 'run-1' }), + ) + expect(response.status).toBe(403) + }) + + it('generates a pain.001 file for an approved run', async () => { + const { enqueueMany } = authed() + enqueueMany([ + { data: { id: 'run-1', status: 'approved', period_year: 2026, period_month: 3, payment_date: '2026-03-25' } }, + { data: { name: 'Bolaget AB', org_number: '5560000000' } }, // companies + { data: { company_name: 'Bolaget AB', iban: 'SE4550000000058398257466', bic: 'NDEASESS' } }, // settings + { + data: [ + { + employee: { first_name: 'Anna', last_name: 'A', clearing_number: '1234', bank_account_number: '567890' }, + }, + ], + }, // salary_run_employees + { data: null }, // salary_runs update + ]) + + const response = await GET( + createMockRequest('/api/salary/runs/run-1/payment/pain001'), + createMockRouteParams({ id: 'run-1' }), + ) + + expect(response.status).toBe(200) + expect(response.headers.get('Content-Type')).toBe('application/xml; charset=utf-8') + expect(response.headers.get('Content-Disposition')).toContain('pain001_lon_2026-03.xml') + }) +}) diff --git a/app/api/salary/runs/[id]/payment/pain001/route.ts b/app/api/salary/runs/[id]/payment/pain001/route.ts index 0919bf58..96eb20bc 100644 --- a/app/api/salary/runs/[id]/payment/pain001/route.ts +++ b/app/api/salary/runs/[id]/payment/pain001/route.ts @@ -1,8 +1,6 @@ -import { createClient } from '@/lib/supabase/server' import { NextResponse } from 'next/server' import { ensureInitialized } from '@/lib/init' -import { requireCompanyId } from '@/lib/company/context' -import { requireWritePermission } from '@/lib/auth/require-write' +import { withRouteContext } from '@/lib/api/with-route-context' import { generatePain001 } from '@/lib/salary/payment/pain001-generator' import { effectiveNetPayout } from '@/lib/salary/payment/effective-net' import { getBranding } from '@/lib/branding/service' @@ -18,126 +16,120 @@ ensureInitialized() * * The file is uploaded to the bank's corporate portal for batch payment. */ -export async function GET( - request: Request, - { params }: { params: Promise<{ id: string }> } -) { - const { id } = await params - const supabase = await createClient() - const { data: { user } } = await supabase.auth.getUser() - if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) +export const GET = withRouteContext<{ params: Promise<{ id: string }> }>( + 'salary.run.payment.pain001', + async (_request, ctx, { params }) => { + const { id } = await params + const { supabase, companyId } = ctx - const writeCheck = await requireWritePermission(supabase, user.id) - if (!writeCheck.ok) return writeCheck.response + // Load salary run + const { data: run } = await supabase + .from('salary_runs') + .select('*') + .eq('id', id) + .eq('company_id', companyId) + .single() - const companyId = await requireCompanyId(supabase, user.id) + if (!run) { + return NextResponse.json({ error: 'Lönekörning hittades inte' }, { status: 404 }) + } - // Load salary run - const { data: run } = await supabase - .from('salary_runs') - .select('*') - .eq('id', id) - .eq('company_id', companyId) - .single() + if (!['approved', 'paid', 'booked'].includes(run.status)) { + return NextResponse.json({ error: 'Betalfil kan bara genereras efter godkännande' }, { status: 400 }) + } - if (!run) { - return NextResponse.json({ error: 'Lönekörning hittades inte' }, { status: 404 }) - } + // Load company + settings + const { data: company } = await supabase + .from('companies') + .select('name, org_number') + .eq('id', companyId) + .single() - if (!['approved', 'paid', 'booked'].includes(run.status)) { - return NextResponse.json({ error: 'Betalfil kan bara genereras efter godkännande' }, { status: 400 }) - } + const { data: settings } = await supabase + .from('company_settings') + .select('company_name, iban, bic') + .eq('company_id', companyId) + .single() - // Load company + settings - const { data: company } = await supabase - .from('companies') - .select('name, org_number') - .eq('id', companyId) - .single() + if (!company) { + return NextResponse.json({ error: 'Företag hittades inte' }, { status: 404 }) + } - const { data: settings } = await supabase - .from('company_settings') - .select('company_name, iban, bic') - .eq('company_id', companyId) - .single() + if (!settings?.iban || !settings?.bic) { + return NextResponse.json({ error: 'IBAN och BIC krävs i företagsinställningar för betalfil' }, { status: 400 }) + } - if (!company) { - return NextResponse.json({ error: 'Företag hittades inte' }, { status: 404 }) - } + // Load employees + const { data: runEmployees } = await supabase + .from('salary_run_employees') + .select('*, employee:employees(first_name, last_name, clearing_number, bank_account_number)') + .eq('salary_run_id', id) - if (!settings?.iban || !settings?.bic) { - return NextResponse.json({ error: 'IBAN och BIC krävs i företagsinställningar för betalfil' }, { status: 400 }) - } + if (!runEmployees || runEmployees.length === 0) { + return NextResponse.json({ error: 'Inga anställda i lönekörningen' }, { status: 400 }) + } - // Load employees - const { data: runEmployees } = await supabase - .from('salary_run_employees') - .select('*, employee:employees(first_name, last_name, clearing_number, bank_account_number)') - .eq('salary_run_id', id) - - if (!runEmployees || runEmployees.length === 0) { - return NextResponse.json({ error: 'Inga anställda i lönekörningen' }, { status: 400 }) - } - - // Validate bank accounts, but only for employees who will actually appear - // in the file (positive payout). A zero-net employee is filtered out below, - // so missing bank details for them must not block the file. - const missingBank = runEmployees.filter(sre => { - if (effectiveNetPayout(sre) <= 0) return false - const emp = sre.employee as { clearing_number: string | null; bank_account_number: string | null } | null - return !emp?.clearing_number || !emp?.bank_account_number - }) - - if (missingBank.length > 0) { - return NextResponse.json({ - error: `${missingBank.length} anställd(a) saknar bankkontouppgifter`, - }, { status: 400 }) - } - - const companyData: Pain001CompanyData = { - // Sender name follows the current company name (company_settings.company_name), - // not the frozen onboarding companies.name. - name: settings.company_name || company.name, - orgNumber: company.org_number || '', - iban: settings.iban, - bic: settings.bic, - } - - const employees: Pain001Employee[] = runEmployees - .map(sre => ({ sre, effectiveNet: effectiveNetPayout(sre) })) - .filter(({ effectiveNet }) => effectiveNet > 0) - .map(({ sre, effectiveNet }) => { - const emp = sre.employee as { first_name: string; last_name: string; clearing_number: string; bank_account_number: string } - return { - name: `${emp.first_name} ${emp.last_name}`, - clearingNumber: emp.clearing_number, - bankAccountNumber: emp.bank_account_number, - netSalary: effectiveNet, - } + // Validate bank accounts, but only for employees who will actually appear + // in the file (positive payout). A zero-net employee is filtered out below, + // so missing bank details for them must not block the file. + const missingBank = runEmployees.filter(sre => { + if (effectiveNetPayout(sre) <= 0) return false + const emp = sre.employee as { clearing_number: string | null; bank_account_number: string | null } | null + return !emp?.clearing_number || !emp?.bank_account_number }) - const periodLabel = `${run.period_year}-${String(run.period_month).padStart(2, '0')}` - const messageId = `${getBranding().appName.toUpperCase()}-${company.org_number?.replace('-', '')}-${periodLabel}` + if (missingBank.length > 0) { + return NextResponse.json({ + error: `${missingBank.length} anställd(a) saknar bankkontouppgifter`, + }, { status: 400 }) + } - const xml = generatePain001(companyData, employees, { - messageId, - paymentDate: run.payment_date, - periodLabel, - }) + const companyData: Pain001CompanyData = { + // Sender name follows the current company name (company_settings.company_name), + // not the frozen onboarding companies.name. + name: settings.company_name || company.name, + orgNumber: company.org_number || '', + iban: settings.iban, + bic: settings.bic, + } - await supabase - .from('salary_runs') - .update({ - payment_file_format: 'pain001', - payment_file_generated_at: new Date().toISOString(), + const employees: Pain001Employee[] = runEmployees + .map(sre => ({ sre, effectiveNet: effectiveNetPayout(sre) })) + .filter(({ effectiveNet }) => effectiveNet > 0) + .map(({ sre, effectiveNet }) => { + const emp = sre.employee as { first_name: string; last_name: string; clearing_number: string; bank_account_number: string } + return { + name: `${emp.first_name} ${emp.last_name}`, + clearingNumber: emp.clearing_number, + bankAccountNumber: emp.bank_account_number, + netSalary: effectiveNet, + } + }) + + const periodLabel = `${run.period_year}-${String(run.period_month).padStart(2, '0')}` + const messageId = `${getBranding().appName.toUpperCase()}-${company.org_number?.replace('-', '')}-${periodLabel}` + + const xml = generatePain001(companyData, employees, { + messageId, + paymentDate: run.payment_date, + periodLabel, }) - .eq('id', id) - .eq('company_id', companyId) - return new Response(xml, { - headers: { - 'Content-Type': 'application/xml; charset=utf-8', - 'Content-Disposition': `attachment; filename="pain001_lon_${periodLabel}.xml"`, - }, - }) -} + await supabase + .from('salary_runs') + .update({ + payment_file_format: 'pain001', + payment_file_generated_at: new Date().toISOString(), + }) + .eq('id', id) + .eq('company_id', companyId) + + return new Response(xml, { + headers: { + 'Content-Type': 'application/xml; charset=utf-8', + 'Content-Disposition': `attachment; filename="pain001_lon_${periodLabel}.xml"`, + }, + }) + }, + { requireWrite: true }, +) diff --git a/app/api/salary/runs/[id]/payslips/[employeeId]/pdf/__tests__/route.test.ts b/app/api/salary/runs/[id]/payslips/[employeeId]/pdf/__tests__/route.test.ts index aa7f9fc6..04de05b8 100644 --- a/app/api/salary/runs/[id]/payslips/[employeeId]/pdf/__tests__/route.test.ts +++ b/app/api/salary/runs/[id]/payslips/[employeeId]/pdf/__tests__/route.test.ts @@ -1,9 +1,13 @@ import { describe, it, expect, vi, beforeEach } from 'vitest' +import { NextResponse } from 'next/server' import { createQueuedMockSupabase, createMockRequest, createMockRouteParams } from '@/tests/helpers' +// The route is wrapped in withRouteContext. Auth/company are injected via the +// mocked requireAuth + getActiveCompanyId; the PDF pipeline is fully stubbed. vi.mock('@/lib/init', () => ({ ensureInitialized: vi.fn() })) -vi.mock('@/lib/supabase/server', () => ({ createClient: vi.fn() })) +vi.mock('@/lib/auth/require-auth', () => ({ requireAuth: vi.fn() })) vi.mock('@/lib/company/context', () => ({ + getActiveCompanyId: vi.fn().mockResolvedValue('company-1'), requireCompanyId: vi.fn().mockResolvedValue('company-1'), getCompanyDisplayName: vi.fn().mockResolvedValue('Ny Firma AB'), })) @@ -17,16 +21,19 @@ vi.mock('@/lib/salary/payslips/build-payslip-data', () => ({ })) import { GET } from '../route' -import { createClient } from '@/lib/supabase/server' +import { requireAuth } from '@/lib/auth/require-auth' import { getCompanyDisplayName } from '@/lib/company/context' import { buildPayslipData } from '@/lib/salary/payslips/build-payslip-data' -const mockUser = { id: 'user-1' } +const mockUser = { id: 'user-1', email: 'test@test.se' } -function mockClient(user: unknown) { +function authed() { const { supabase, enqueue, enqueueMany } = createQueuedMockSupabase() - supabase.auth.getUser = vi.fn().mockResolvedValue({ data: { user }, error: null }) - vi.mocked(createClient).mockResolvedValue(supabase as never) + vi.mocked(requireAuth).mockResolvedValue({ + user: mockUser as never, + supabase: supabase as never, + error: null, + }) return { supabase, enqueue, enqueueMany } } @@ -37,7 +44,11 @@ describe('GET /api/salary/runs/[id]/payslips/[employeeId]/pdf', () => { }) it('returns 401 when unauthenticated', async () => { - mockClient(null) + vi.mocked(requireAuth).mockResolvedValue({ + user: null, + supabase: null as never, + error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }), + }) const response = await GET( createMockRequest('/api/salary/runs/run-1/payslips/emp-1/pdf'), createMockRouteParams({ id: 'run-1', employeeId: 'emp-1' }), @@ -46,7 +57,7 @@ describe('GET /api/salary/runs/[id]/payslips/[employeeId]/pdf', () => { }) it('returns 404 when the run does not exist', async () => { - const { enqueueMany } = mockClient(mockUser) + const { enqueueMany } = authed() enqueueMany([{ data: null }]) const response = await GET( createMockRequest('/api/salary/runs/run-x/payslips/emp-1/pdf'), @@ -56,7 +67,7 @@ describe('GET /api/salary/runs/[id]/payslips/[employeeId]/pdf', () => { }) it('renders the payslip PDF with the current company name', async () => { - const { enqueueMany } = mockClient(mockUser) + const { enqueueMany } = authed() enqueueMany([ { data: { id: 'run-1', period_year: 2026, period_month: 6, payment_date: '2026-06-25' } }, { data: { employee: { first_name: 'Anna', last_name: 'A', personnummer: 'enc' }, line_items: [] } }, @@ -78,7 +89,7 @@ describe('GET /api/salary/runs/[id]/payslips/[employeeId]/pdf', () => { }) it('falls back to companies.name when the resolver returns null', async () => { - const { enqueueMany } = mockClient(mockUser) + const { enqueueMany } = authed() vi.mocked(getCompanyDisplayName).mockResolvedValue(null) enqueueMany([ { data: { id: 'run-1', period_year: 2026, period_month: 6, payment_date: '2026-06-25' } }, diff --git a/app/api/salary/runs/[id]/payslips/[employeeId]/pdf/route.ts b/app/api/salary/runs/[id]/payslips/[employeeId]/pdf/route.ts index bdec542e..60ff6a1d 100644 --- a/app/api/salary/runs/[id]/payslips/[employeeId]/pdf/route.ts +++ b/app/api/salary/runs/[id]/payslips/[employeeId]/pdf/route.ts @@ -1,7 +1,7 @@ -import { createClient } from '@/lib/supabase/server' import { NextResponse } from 'next/server' import { ensureInitialized } from '@/lib/init' -import { requireCompanyId, getCompanyDisplayName } from '@/lib/company/context' +import { withRouteContext } from '@/lib/api/with-route-context' +import { getCompanyDisplayName } from '@/lib/company/context' import { renderToBuffer } from '@react-pdf/renderer' import { PayslipPDF } from '@/lib/salary/pdf/payslip-template' import { buildPayslipData, payslipFileName } from '@/lib/salary/payslips/build-payslip-data' @@ -17,76 +17,72 @@ ensureInitialized() * Data assembly is shared with the public token surface via * lib/salary/payslips/build-payslip-data — both must render identical PDFs. */ -export async function GET( - request: Request, - { params }: { params: Promise<{ id: string; employeeId: string }> } -) { - const { id, employeeId } = await params - const supabase = await createClient() - const { data: { user } } = await supabase.auth.getUser() - if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) +export const GET = withRouteContext<{ params: Promise<{ id: string; employeeId: string }> }>( + 'salary.run.payslip.pdf', + async (_request, ctx, { params }) => { + const { id, employeeId } = await params + const { supabase, companyId } = ctx - const companyId = await requireCompanyId(supabase, user.id) + // Load salary run + const { data: run } = await supabase + .from('salary_runs') + .select('*') + .eq('id', id) + .eq('company_id', companyId) + .single() - // Load salary run - const { data: run } = await supabase - .from('salary_runs') - .select('*') - .eq('id', id) - .eq('company_id', companyId) - .single() + if (!run) { + return NextResponse.json({ error: 'Lönekörning hittades inte' }, { status: 404 }) + } - if (!run) { - return NextResponse.json({ error: 'Lönekörning hittades inte' }, { status: 404 }) - } + // Load salary run employee + const { data: sre } = await supabase + .from('salary_run_employees') + .select('*, employee:employees(first_name, last_name, personnummer, personnummer_last4, employment_type, tax_table_number, tax_column, clearing_number, bank_account_number), line_items:salary_line_items(*)') + .eq('salary_run_id', id) + .eq('employee_id', employeeId) + .single() - // Load salary run employee - const { data: sre } = await supabase - .from('salary_run_employees') - .select('*, employee:employees(first_name, last_name, personnummer, personnummer_last4, employment_type, tax_table_number, tax_column, clearing_number, bank_account_number), line_items:salary_line_items(*)') - .eq('salary_run_id', id) - .eq('employee_id', employeeId) - .single() + if (!sre) { + return NextResponse.json({ error: 'Anställd hittades inte i lönekörningen' }, { status: 404 }) + } - if (!sre) { - return NextResponse.json({ error: 'Anställd hittades inte i lönekörningen' }, { status: 404 }) - } + // Load company + const { data: company } = await supabase + .from('companies') + .select('name, org_number') + .eq('id', companyId) + .single() - // Load company - const { data: company } = await supabase - .from('companies') - .select('name, org_number') - .eq('id', companyId) - .single() + if (!company) { + return NextResponse.json({ error: 'Företag hittades inte' }, { status: 404 }) + } - if (!company) { - return NextResponse.json({ error: 'Företag hittades inte' }, { status: 404 }) - } + const emp = sre.employee as { + first_name: string; last_name: string; personnummer: string; personnummer_last4: string; + employment_type: string; tax_table_number: number | null; tax_column: number; + clearing_number: string | null; bank_account_number: string | null; + } - const emp = sre.employee as { - first_name: string; last_name: string; personnummer: string; personnummer_last4: string; - employment_type: string; tax_table_number: number | null; tax_column: number; - clearing_number: string | null; bank_account_number: string | null; - } + // Employer name on the payslip follows the current company name + // (company_settings.company_name), not the frozen onboarding companies.name. + const displayName = await getCompanyDisplayName(supabase, companyId) + const data = buildPayslipData({ + run, + sre, + employee: emp, + company: { name: displayName ?? company.name, org_number: company.org_number }, + }) + const fileName = payslipFileName(run, emp) - // Employer name on the payslip follows the current company name - // (company_settings.company_name), not the frozen onboarding companies.name. - const displayName = await getCompanyDisplayName(supabase, companyId) - const data = buildPayslipData({ - run, - sre, - employee: emp, - company: { name: displayName ?? company.name, org_number: company.org_number }, - }) - const fileName = payslipFileName(run, emp) + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const buffer = await renderToBuffer(PayslipPDF({ data }) as any) - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const buffer = await renderToBuffer(PayslipPDF({ data }) as any) - - return new Response(buffer as unknown as BodyInit, { - headers: { - 'Content-Type': 'application/pdf', - 'Content-Disposition': `inline; filename="${fileName}"`, - }, - }) -} + return new Response(buffer as unknown as BodyInit, { + headers: { + 'Content-Type': 'application/pdf', + 'Content-Disposition': `inline; filename="${fileName}"`, + }, + }) + }, +) diff --git a/app/api/salary/runs/[id]/preview/route.ts b/app/api/salary/runs/[id]/preview/route.ts index 8aa0a6ce..3f12edc0 100644 --- a/app/api/salary/runs/[id]/preview/route.ts +++ b/app/api/salary/runs/[id]/preview/route.ts @@ -1,7 +1,6 @@ -import { createClient } from '@/lib/supabase/server' import { NextResponse } from 'next/server' import { ensureInitialized } from '@/lib/init' -import { requireCompanyId } from '@/lib/company/context' +import { withRouteContext } from '@/lib/api/with-route-context' import { SALARY_ACCOUNTS, getLineItemAccount } from '@/lib/salary/account-mapping' import type { CreateJournalEntryLineInput } from '@/types' @@ -11,190 +10,186 @@ ensureInitialized() * Preview the journal entries that would be created when booking this salary run. * Shows exact BAS accounts and amounts: this is a key differentiator. */ -export async function GET( - request: Request, - { params }: { params: Promise<{ id: string }> } -) { - const { id } = await params - const supabase = await createClient() - const { data: { user } } = await supabase.auth.getUser() - if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) +export const GET = withRouteContext<{ params: Promise<{ id: string }> }>( + 'salary.run.preview', + async (_request, ctx, { params }) => { + const { id } = await params + const { supabase, companyId } = ctx - const companyId = await requireCompanyId(supabase, user.id) + const { data: run, error: runError } = await supabase + .from('salary_runs') + .select('*') + .eq('id', id) + .eq('company_id', companyId) + .single() - const { data: run, error: runError } = await supabase - .from('salary_runs') - .select('*') - .eq('id', id) - .eq('company_id', companyId) - .single() - - if (runError || !run) { - return NextResponse.json({ error: 'Lönekörning hittades inte' }, { status: 404 }) - } - - // Load employees with line items - const { data: employees } = await supabase - .from('salary_run_employees') - .select('*, employee:employees(employment_type), line_items:salary_line_items(*)') - .eq('salary_run_id', id) - - if (!employees || employees.length === 0) { - return NextResponse.json({ error: 'Inga beräknade resultat: kör beräkning först' }, { status: 400 }) - } - - const periodLabel = `${run.period_year}-${String(run.period_month).padStart(2, '0')}` - const desc = `Lön ${periodLabel}` - - // Build salary entry preview - const salaryLines: CreateJournalEntryLineInput[] = [] - const expenseByAccount = new Map() - - for (const sre of employees) { - for (const li of sre.line_items || []) { - if (li.is_net_deduction || li.is_gross_deduction) continue - const account = li.account_number || getLineItemAccount(li.item_type, sre.employee?.employment_type || 'employee') - expenseByAccount.set(account, (expenseByAccount.get(account) || 0) + li.amount) + if (runError || !run) { + return NextResponse.json({ error: 'Lönekörning hittades inte' }, { status: 404 }) } - } - for (const [account, amount] of expenseByAccount) { - if (amount === 0) continue - salaryLines.push({ - account_number: account, - debit_amount: amount > 0 ? Math.round(amount * 100) / 100 : 0, - credit_amount: amount < 0 ? Math.round(Math.abs(amount) * 100) / 100 : 0, - line_description: `${desc}`, - }) - } + // Load employees with line items + const { data: employees } = await supabase + .from('salary_run_employees') + .select('*, employee:employees(employment_type), line_items:salary_line_items(*)') + .eq('salary_run_id', id) - const totalTax = employees.reduce((sum, e) => sum + e.tax_withheld, 0) - if (totalTax > 0) { - salaryLines.push({ - account_number: SALARY_ACCOUNTS.TAX_WITHHELD, - debit_amount: 0, - credit_amount: Math.round(totalTax * 100) / 100, - line_description: `${desc}: Personalskatt`, - }) - } + if (!employees || employees.length === 0) { + return NextResponse.json({ error: 'Inga beräknade resultat: kör beräkning först' }, { status: 400 }) + } - const totalNet = employees.reduce((sum, e) => sum + e.net_salary, 0) - if (totalNet > 0) { - salaryLines.push({ - account_number: SALARY_ACCOUNTS.BANK, - debit_amount: 0, - credit_amount: Math.round(totalNet * 100) / 100, - line_description: `${desc}: Nettolön`, - }) - } + const periodLabel = `${run.period_year}-${String(run.period_month).padStart(2, '0')}` + const desc = `Lön ${periodLabel}` - // Build avgifter entry preview: skipped for a nollkörning (0 avgifter), - // mirroring the vacation/pension guards below. The bookkeeping engine never - // posts an all-zero 7510/2731 voucher (see book/route.ts nollkörning path), - // so previewing one would falsely imply a verifikat that is never created. - const totalAvgifter = employees.reduce((sum, e) => sum + e.avgifter_amount, 0) - const roundedAvgifter = Math.round(totalAvgifter * 100) / 100 - const avgifterLines: CreateJournalEntryLineInput[] = roundedAvgifter !== 0 - ? [ + // Build salary entry preview + const salaryLines: CreateJournalEntryLineInput[] = [] + const expenseByAccount = new Map() + + for (const sre of employees) { + for (const li of sre.line_items || []) { + if (li.is_net_deduction || li.is_gross_deduction) continue + const account = li.account_number || getLineItemAccount(li.item_type, sre.employee?.employment_type || 'employee') + expenseByAccount.set(account, (expenseByAccount.get(account) || 0) + li.amount) + } + } + + for (const [account, amount] of expenseByAccount) { + if (amount === 0) continue + salaryLines.push({ + account_number: account, + debit_amount: amount > 0 ? Math.round(amount * 100) / 100 : 0, + credit_amount: amount < 0 ? Math.round(Math.abs(amount) * 100) / 100 : 0, + line_description: `${desc}`, + }) + } + + const totalTax = employees.reduce((sum, e) => sum + e.tax_withheld, 0) + if (totalTax > 0) { + salaryLines.push({ + account_number: SALARY_ACCOUNTS.TAX_WITHHELD, + debit_amount: 0, + credit_amount: Math.round(totalTax * 100) / 100, + line_description: `${desc}: Personalskatt`, + }) + } + + const totalNet = employees.reduce((sum, e) => sum + e.net_salary, 0) + if (totalNet > 0) { + salaryLines.push({ + account_number: SALARY_ACCOUNTS.BANK, + debit_amount: 0, + credit_amount: Math.round(totalNet * 100) / 100, + line_description: `${desc}: Nettolön`, + }) + } + + // Build avgifter entry preview: skipped for a nollkörning (0 avgifter), + // mirroring the vacation/pension guards below. The bookkeeping engine never + // posts an all-zero 7510/2731 voucher (see book/route.ts nollkörning path), + // so previewing one would falsely imply a verifikat that is never created. + const totalAvgifter = employees.reduce((sum, e) => sum + e.avgifter_amount, 0) + const roundedAvgifter = Math.round(totalAvgifter * 100) / 100 + const avgifterLines: CreateJournalEntryLineInput[] = roundedAvgifter !== 0 + ? [ + { + account_number: SALARY_ACCOUNTS.AVGIFTER_EXPENSE, + debit_amount: roundedAvgifter, + credit_amount: 0, + line_description: `${desc}: Arbetsgivaravgifter`, + }, + { + account_number: SALARY_ACCOUNTS.AVGIFTER_LIABILITY, + debit_amount: 0, + credit_amount: roundedAvgifter, + line_description: `${desc}: Arbetsgivaravgifter`, + }, + ] + : [] + + // Build vacation entry preview + const totalVacation = employees.reduce((sum, e) => sum + e.vacation_accrual, 0) + const totalVacationAvgifter = employees.reduce((sum, e) => sum + e.vacation_accrual_avgifter, 0) + const vacationLines: CreateJournalEntryLineInput[] = [] + if (totalVacation > 0) { + vacationLines.push( { - account_number: SALARY_ACCOUNTS.AVGIFTER_EXPENSE, - debit_amount: roundedAvgifter, + account_number: SALARY_ACCOUNTS.VACATION_ACCRUAL_EXPENSE, + debit_amount: Math.round(totalVacation * 100) / 100, credit_amount: 0, - line_description: `${desc}: Arbetsgivaravgifter`, + line_description: `${desc}: Semesteravsättning`, }, { - account_number: SALARY_ACCOUNTS.AVGIFTER_LIABILITY, + account_number: SALARY_ACCOUNTS.VACATION_ACCRUAL_LIABILITY, debit_amount: 0, - credit_amount: roundedAvgifter, - line_description: `${desc}: Arbetsgivaravgifter`, - }, - ] - : [] - - // Build vacation entry preview - const totalVacation = employees.reduce((sum, e) => sum + e.vacation_accrual, 0) - const totalVacationAvgifter = employees.reduce((sum, e) => sum + e.vacation_accrual_avgifter, 0) - const vacationLines: CreateJournalEntryLineInput[] = [] - if (totalVacation > 0) { - vacationLines.push( - { - account_number: SALARY_ACCOUNTS.VACATION_ACCRUAL_EXPENSE, - debit_amount: Math.round(totalVacation * 100) / 100, - credit_amount: 0, - line_description: `${desc}: Semesteravsättning`, - }, - { - account_number: SALARY_ACCOUNTS.VACATION_ACCRUAL_LIABILITY, - debit_amount: 0, - credit_amount: Math.round(totalVacation * 100) / 100, - line_description: `${desc}: Semesteravsättning`, - } - ) - } - if (totalVacationAvgifter > 0) { - vacationLines.push( - { - account_number: SALARY_ACCOUNTS.VACATION_AVGIFTER_EXPENSE, - debit_amount: Math.round(totalVacationAvgifter * 100) / 100, - credit_amount: 0, - line_description: `${desc}: Sociala avgifter semester`, - }, - { - account_number: SALARY_ACCOUNTS.VACATION_AVGIFTER_LIABILITY, - debit_amount: 0, - credit_amount: Math.round(totalVacationAvgifter * 100) / 100, - line_description: `${desc}: Sociala avgifter semester`, - } - ) - } - - // Build pension entry preview (löneväxling, per deductions-lonevaxling.md) - // This would be populated from salary_line_items with type 'gross_deduction_pension' - // For now, pension preview is shown when pension line items exist - const pensionLineItems = employees.flatMap(e => - ((e.line_items || []) as Array>) - .filter(li => li.item_type === 'gross_deduction_pension') - ) - const pensionLines: CreateJournalEntryLineInput[] = [] - if (pensionLineItems.length > 0) { - const totalPensionDeduction = Math.abs(pensionLineItems.reduce((s, li) => s + ((li.amount as number) || 0), 0)) - const pensionContribution = Math.round(totalPensionDeduction * 1.058 * 100) / 100 - const slp = Math.round(pensionContribution * 0.2426 * 100) / 100 - if (pensionContribution > 0) { - pensionLines.push( - { account_number: '7410', debit_amount: pensionContribution, credit_amount: 0, line_description: `${desc}: Pensionsförsäkringspremier` }, - { account_number: '2740', debit_amount: 0, credit_amount: pensionContribution, line_description: `${desc}: Pensionsförsäkringspremier` }, + credit_amount: Math.round(totalVacation * 100) / 100, + line_description: `${desc}: Semesteravsättning`, + } ) - if (slp > 0) { + } + if (totalVacationAvgifter > 0) { + vacationLines.push( + { + account_number: SALARY_ACCOUNTS.VACATION_AVGIFTER_EXPENSE, + debit_amount: Math.round(totalVacationAvgifter * 100) / 100, + credit_amount: 0, + line_description: `${desc}: Sociala avgifter semester`, + }, + { + account_number: SALARY_ACCOUNTS.VACATION_AVGIFTER_LIABILITY, + debit_amount: 0, + credit_amount: Math.round(totalVacationAvgifter * 100) / 100, + line_description: `${desc}: Sociala avgifter semester`, + } + ) + } + + // Build pension entry preview (löneväxling, per deductions-lonevaxling.md) + // This would be populated from salary_line_items with type 'gross_deduction_pension' + // For now, pension preview is shown when pension line items exist + const pensionLineItems = employees.flatMap(e => + ((e.line_items || []) as Array>) + .filter(li => li.item_type === 'gross_deduction_pension') + ) + const pensionLines: CreateJournalEntryLineInput[] = [] + if (pensionLineItems.length > 0) { + const totalPensionDeduction = Math.abs(pensionLineItems.reduce((s, li) => s + ((li.amount as number) || 0), 0)) + const pensionContribution = Math.round(totalPensionDeduction * 1.058 * 100) / 100 + const slp = Math.round(pensionContribution * 0.2426 * 100) / 100 + if (pensionContribution > 0) { pensionLines.push( - { account_number: '7533', debit_amount: slp, credit_amount: 0, line_description: `${desc}: Särskild löneskatt 24,26%` }, - { account_number: '2514', debit_amount: 0, credit_amount: slp, line_description: `${desc}: Särskild löneskatt 24,26%` }, + { account_number: '7410', debit_amount: pensionContribution, credit_amount: 0, line_description: `${desc}: Pensionsförsäkringspremier` }, + { account_number: '2740', debit_amount: 0, credit_amount: pensionContribution, line_description: `${desc}: Pensionsförsäkringspremier` }, ) + if (slp > 0) { + pensionLines.push( + { account_number: '7533', debit_amount: slp, credit_amount: 0, line_description: `${desc}: Särskild löneskatt 24,26%` }, + { account_number: '2514', debit_amount: 0, credit_amount: slp, line_description: `${desc}: Särskild löneskatt 24,26%` }, + ) + } } } - } - return NextResponse.json({ - data: { - // Each entry is null when it has no lines: a nollkörning posts nothing, - // so the salary and avgifter entries fall away just like vacation/pension - // already do, and the UI can simply skip the null ones. - salaryEntry: salaryLines.length > 0 ? { - description: desc, - lines: salaryLines, - } : null, - avgifterEntry: avgifterLines.length > 0 ? { - description: `${desc}: Arbetsgivaravgifter`, - lines: avgifterLines, - } : null, - vacationEntry: vacationLines.length > 0 ? { - description: `${desc}: Semesteravsättning`, - lines: vacationLines, - } : null, - pensionEntry: pensionLines.length > 0 ? { - description: `${desc}: Pensionsavsättning`, - lines: pensionLines, - } : null, - }, - }) -} + return NextResponse.json({ + data: { + // Each entry is null when it has no lines: a nollkörning posts nothing, + // so the salary and avgifter entries fall away just like vacation/pension + // already do, and the UI can simply skip the null ones. + salaryEntry: salaryLines.length > 0 ? { + description: desc, + lines: salaryLines, + } : null, + avgifterEntry: avgifterLines.length > 0 ? { + description: `${desc}: Arbetsgivaravgifter`, + lines: avgifterLines, + } : null, + vacationEntry: vacationLines.length > 0 ? { + description: `${desc}: Semesteravsättning`, + lines: vacationLines, + } : null, + pensionEntry: pensionLines.length > 0 ? { + description: `${desc}: Pensionsavsättning`, + lines: pensionLines, + } : null, + }, + }) + }, +) diff --git a/app/api/salary/runs/[id]/review/__tests__/route.test.ts b/app/api/salary/runs/[id]/review/__tests__/route.test.ts new file mode 100644 index 00000000..c08c70a7 --- /dev/null +++ b/app/api/salary/runs/[id]/review/__tests__/route.test.ts @@ -0,0 +1,112 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { NextResponse } from 'next/server' +import { + createQueuedMockSupabase, + createMockRequest, + parseJsonResponse, + createMockRouteParams, +} from '@/tests/helpers' + +vi.mock('@/lib/init', () => ({ ensureInitialized: vi.fn() })) +vi.mock('@/lib/auth/require-auth', () => ({ requireAuth: vi.fn() })) +vi.mock('@/lib/company/context', () => ({ + getActiveCompanyId: vi.fn().mockResolvedValue('company-1'), + requireCompanyId: vi.fn().mockResolvedValue('company-1'), +})) +vi.mock('@/lib/auth/require-write', () => ({ + requireWritePermission: vi.fn().mockResolvedValue({ ok: true }), +})) + +import { POST } from '../route' +import { requireAuth } from '@/lib/auth/require-auth' +import { requireWritePermission } from '@/lib/auth/require-write' + +const mockUser = { id: 'user-1', email: 'test@test.se' } + +function authed() { + const { supabase, enqueueMany } = createQueuedMockSupabase() + vi.mocked(requireAuth).mockResolvedValue({ + user: mockUser as never, + supabase: supabase as never, + error: null, + }) + return { supabase, enqueueMany } +} + +describe('POST /api/salary/runs/[id]/review', () => { + beforeEach(() => { + vi.clearAllMocks() + vi.mocked(requireWritePermission).mockResolvedValue({ ok: true } as never) + }) + + it('returns 401 when not authenticated', async () => { + vi.mocked(requireAuth).mockResolvedValue({ + user: null, + supabase: null as never, + error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }), + }) + const response = await POST( + createMockRequest('/api/salary/runs/run-1/review', { method: 'POST' }), + createMockRouteParams({ id: 'run-1' }), + ) + expect(response.status).toBe(401) + }) + + it('returns 403 for a viewer', async () => { + authed() + vi.mocked(requireWritePermission).mockResolvedValue({ + ok: false, + response: NextResponse.json({ error: 'Forbidden' }, { status: 403 }), + } as never) + const response = await POST( + createMockRequest('/api/salary/runs/run-1/review', { method: 'POST' }), + createMockRouteParams({ id: 'run-1' }), + ) + expect(response.status).toBe(403) + }) + + it('moves a draft run to review', async () => { + const { enqueueMany } = authed() + enqueueMany([ + { data: [] }, // salary_run_employees (F-skatt check, no warnings) + { data: { id: 'run-1', status: 'review' } }, // update draft → review + ]) + const response = await POST( + createMockRequest('/api/salary/runs/run-1/review', { method: 'POST' }), + createMockRouteParams({ id: 'run-1' }), + ) + const { status, body } = await parseJsonResponse<{ data: { status: string } }>(response) + expect(status).toBe(200) + expect(body.data.status).toBe('review') + }) + + it('surfaces an F-skatt warning when an employee is unverified', async () => { + const { enqueueMany } = authed() + enqueueMany([ + { data: [{ employee: { first_name: 'Anna', last_name: 'A', f_skatt_status: 'not_verified' } }] }, + { data: { id: 'run-1', status: 'review' } }, + ]) + const response = await POST( + createMockRequest('/api/salary/runs/run-1/review', { method: 'POST' }), + createMockRouteParams({ id: 'run-1' }), + ) + const { status, body } = await parseJsonResponse<{ warnings?: string[] }>(response) + expect(status).toBe(200) + expect(body.warnings?.[0]).toContain('F-skatt ej verifierad') + }) + + it('returns 400 when the run is not a draft', async () => { + const { enqueueMany } = authed() + enqueueMany([ + { data: [] }, // F-skatt check + { data: null, error: { message: 'no rows' } }, // update finds no draft row + ]) + const response = await POST( + createMockRequest('/api/salary/runs/run-1/review', { method: 'POST' }), + createMockRouteParams({ id: 'run-1' }), + ) + const { status, body } = await parseJsonResponse<{ error: string }>(response) + expect(status).toBe(400) + expect(body.error).toContain('utkaststatus') + }) +}) diff --git a/app/api/salary/runs/[id]/review/route.ts b/app/api/salary/runs/[id]/review/route.ts index f09f7735..5beb21eb 100644 --- a/app/api/salary/runs/[id]/review/route.ts +++ b/app/api/salary/runs/[id]/review/route.ts @@ -1,8 +1,6 @@ -import { createClient } from '@/lib/supabase/server' import { NextResponse } from 'next/server' import { ensureInitialized } from '@/lib/init' -import { requireCompanyId } from '@/lib/company/context' -import { requireWritePermission } from '@/lib/auth/require-write' +import { withRouteContext } from '@/lib/api/with-route-context' ensureInitialized() @@ -13,51 +11,45 @@ ensureInitialized() * If any employee has f_skatt_status = 'not_verified', return a warning. * The user can still proceed but the warning is logged for audit trail. */ -export async function POST( - request: Request, - { params }: { params: Promise<{ id: string }> } -) { - const { id } = await params - const supabase = await createClient() - const { data: { user } } = await supabase.auth.getUser() - if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) +export const POST = withRouteContext<{ params: Promise<{ id: string }> }>( + 'salary.run.review', + async (_request, ctx, { params }) => { + const { id } = await params + const { supabase, companyId } = ctx - const writeCheck = await requireWritePermission(supabase, user.id) - if (!writeCheck.ok) return writeCheck.response + // Check for F-skatt verification warnings + const { data: runEmployees } = await supabase + .from('salary_run_employees') + .select('employee:employees(first_name, last_name, f_skatt_status)') + .eq('salary_run_id', id) - const companyId = await requireCompanyId(supabase, user.id) - - // Check for F-skatt verification warnings - const { data: runEmployees } = await supabase - .from('salary_run_employees') - .select('employee:employees(first_name, last_name, f_skatt_status)') - .eq('salary_run_id', id) - - const warnings: string[] = [] - for (const sre of runEmployees || []) { - const emp = sre.employee as unknown as { first_name: string; last_name: string; f_skatt_status: string } | null - if (emp?.f_skatt_status === 'not_verified') { - warnings.push( - `${emp.first_name} ${emp.last_name}: F-skatt ej verifierad: 30% skatteavdrag och fulla avgifter tillämpas (f-skatt.md)` - ) + const warnings: string[] = [] + for (const sre of runEmployees || []) { + const emp = sre.employee as unknown as { first_name: string; last_name: string; f_skatt_status: string } | null + if (emp?.f_skatt_status === 'not_verified') { + warnings.push( + `${emp.first_name} ${emp.last_name}: F-skatt ej verifierad: 30% skatteavdrag och fulla avgifter tillämpas (f-skatt.md)` + ) + } } - } - const { data: run, error } = await supabase - .from('salary_runs') - .update({ status: 'review' }) - .eq('id', id) - .eq('company_id', companyId) - .eq('status', 'draft') - .select() - .single() + const { data: run, error } = await supabase + .from('salary_runs') + .update({ status: 'review' }) + .eq('id', id) + .eq('company_id', companyId) + .eq('status', 'draft') + .select() + .single() - if (error || !run) { - return NextResponse.json({ error: 'Lönekörningen måste vara i utkaststatus' }, { status: 400 }) - } + if (error || !run) { + return NextResponse.json({ error: 'Lönekörningen måste vara i utkaststatus' }, { status: 400 }) + } - return NextResponse.json({ - data: run, - warnings: warnings.length > 0 ? warnings : undefined, - }) -} + return NextResponse.json({ + data: run, + warnings: warnings.length > 0 ? warnings : undefined, + }) + }, + { requireWrite: true }, +) diff --git a/app/api/salary/tax-tables/lookup/route.ts b/app/api/salary/tax-tables/lookup/route.ts index 89f123d9..b6c14fd4 100644 --- a/app/api/salary/tax-tables/lookup/route.ts +++ b/app/api/salary/tax-tables/lookup/route.ts @@ -1,12 +1,8 @@ -import { createClient } from '@/lib/supabase/server' import { NextResponse } from 'next/server' +import { withRouteContext } from '@/lib/api/with-route-context' import { lookupTaxFromApi, TaxTableUnavailableError } from '@/lib/salary/tax-tables' -export async function GET(request: Request) { - const supabase = await createClient() - const { data: { user } } = await supabase.auth.getUser() - if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - +export const GET = withRouteContext('salary.tax_tables.lookup', async (request) => { const { searchParams } = new URL(request.url) const year = parseInt(searchParams.get('year') || new Date().getFullYear().toString()) const tableNumber = parseInt(searchParams.get('table') || '0') @@ -35,4 +31,4 @@ export async function GET(request: Request) { } throw err } -} +}) diff --git a/app/api/salary/tax-tables/status/route.ts b/app/api/salary/tax-tables/status/route.ts index 0e388e5f..150385e0 100644 --- a/app/api/salary/tax-tables/status/route.ts +++ b/app/api/salary/tax-tables/status/route.ts @@ -1,16 +1,12 @@ import { NextResponse } from 'next/server' -import { createClient } from '@/lib/supabase/server' +import { withRouteContext } from '@/lib/api/with-route-context' import { fetchTaxTableRates, TaxTableUnavailableError } from '@/lib/salary/tax-tables' /** * Probe Skatteverket's open data API to confirm tax tables are reachable. * Used by the salary settings page to surface that fetching is automatic. */ -export async function GET(request: Request) { - const supabase = await createClient() - const { data: { user } } = await supabase.auth.getUser() - if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - +export const GET = withRouteContext('salary.tax_tables.status', async (request) => { const { searchParams } = new URL(request.url) const year = parseInt(searchParams.get('year') || String(new Date().getFullYear())) @@ -38,4 +34,4 @@ export async function GET(request: Request) { } throw err } -} +}) diff --git a/app/api/settings/__tests__/route.test.ts b/app/api/settings/__tests__/route.test.ts new file mode 100644 index 00000000..76093b52 --- /dev/null +++ b/app/api/settings/__tests__/route.test.ts @@ -0,0 +1,86 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { NextResponse } from 'next/server' +import { createMockRequest, parseJsonResponse, createQueuedMockSupabase } from '@/tests/helpers' + +const { supabase, enqueue, enqueueMany, reset } = createQueuedMockSupabase() + +const requireAuthMock = vi.fn() +vi.mock('@/lib/auth/require-auth', () => ({ + requireAuth: (...args: unknown[]) => requireAuthMock(...args), +})) + +vi.mock('@/lib/company/context', () => ({ + getActiveCompanyId: vi.fn().mockResolvedValue('company-1'), + requireCompanyId: vi.fn().mockResolvedValue('company-1'), +})) + +const requireWriteMock = vi.fn() +vi.mock('@/lib/auth/require-write', () => ({ + requireWritePermission: (...args: unknown[]) => requireWriteMock(...args), +})) + +vi.mock('@/lib/tax/deadline-generator', () => ({ + didTaxFieldsChange: vi.fn().mockReturnValue(false), + regenerateTaxDeadlinesForUser: vi.fn().mockResolvedValue(undefined), +})) + +import { PUT } from '../route' + +describe('PUT /api/settings', () => { + beforeEach(() => { + vi.clearAllMocks() + reset() + requireAuthMock.mockResolvedValue({ user: { id: 'user-1' }, supabase, error: null }) + requireWriteMock.mockResolvedValue({ ok: true }) + }) + + it('returns 401 when not authenticated', async () => { + requireAuthMock.mockResolvedValue({ + user: null, + supabase, + error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }), + }) + + const request = createMockRequest('/api/settings', { + method: 'PUT', + body: { company_name: 'New Name' }, + }) + const response = await PUT(request, { params: Promise.resolve({}) }) + const { status } = await parseJsonResponse(response) + + expect(status).toBe(401) + }) + + it('returns 403 for a viewer without write permission', async () => { + requireWriteMock.mockResolvedValue({ + ok: false, + response: NextResponse.json({ error: 'Forbidden' }, { status: 403 }), + }) + + const request = createMockRequest('/api/settings', { + method: 'PUT', + body: { company_name: 'New Name' }, + }) + const response = await PUT(request, { params: Promise.resolve({}) }) + const { status } = await parseJsonResponse(response) + + expect(status).toBe(403) + }) + + it('updates the settings on the happy path', async () => { + enqueueMany([ + { data: { entity_type: 'enskild_firma', onboarding_complete: false } }, // fetch oldSettings + { data: { id: 's1', company_name: 'New Name' } }, // update ... returning + ]) + + const request = createMockRequest('/api/settings', { + method: 'PUT', + body: { company_name: 'New Name' }, + }) + const response = await PUT(request, { params: Promise.resolve({}) }) + const { status, body } = await parseJsonResponse<{ data: { company_name: string } }>(response) + + expect(status).toBe(200) + expect(body.data.company_name).toBe('New Name') + }) +}) diff --git a/app/api/settings/api-keys/[id]/route.ts b/app/api/settings/api-keys/[id]/route.ts index b71d2668..eb477876 100644 --- a/app/api/settings/api-keys/[id]/route.ts +++ b/app/api/settings/api-keys/[id]/route.ts @@ -1,38 +1,27 @@ -import { createClient } from '@/lib/supabase/server' import { NextResponse } from 'next/server' -import { requireCompanyId } from '@/lib/company/context' -import { requireWritePermission } from '@/lib/auth/require-write' +import { withRouteContext } from '@/lib/api/with-route-context' /** * DELETE /api/settings/api-keys/[id]: Revoke an API key (soft delete) */ -export async function DELETE( - _request: Request, - { params }: { params: Promise<{ id: string }> } -) { - const supabase = await createClient() - const { id } = await params - const { data: { user } } = await supabase.auth.getUser() +export const DELETE = withRouteContext<{ params: Promise<{ id: string }> }>( + 'api_key.revoke', + async (_request, ctx, { params }) => { + const { id } = await params + const { supabase, companyId } = ctx - if (!user) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } + const { error } = await supabase + .from('api_keys') + .update({ revoked_at: new Date().toISOString() }) + .eq('id', id) + .eq('company_id', companyId) + .is('revoked_at', null) - const writeCheck = await requireWritePermission(supabase, user.id) - if (!writeCheck.ok) return writeCheck.response + if (error) { + return NextResponse.json({ error: error.message }, { status: 500 }) + } - const companyId = await requireCompanyId(supabase, user.id) - - const { error } = await supabase - .from('api_keys') - .update({ revoked_at: new Date().toISOString() }) - .eq('id', id) - .eq('company_id', companyId) - .is('revoked_at', null) - - if (error) { - return NextResponse.json({ error: error.message }, { status: 500 }) - } - - return NextResponse.json({ success: true }) -} + return NextResponse.json({ success: true }) + }, + { requireWrite: true }, +) diff --git a/app/api/settings/booking-templates/[id]/route.ts b/app/api/settings/booking-templates/[id]/route.ts index eab838ec..17eb079a 100644 --- a/app/api/settings/booking-templates/[id]/route.ts +++ b/app/api/settings/booking-templates/[id]/route.ts @@ -1,6 +1,5 @@ -import { createClient } from '@/lib/supabase/server' import { NextResponse } from 'next/server' -import { requireWritePermission } from '@/lib/auth/require-write' +import { withRouteContext } from '@/lib/api/with-route-context' import { z } from 'zod' import { validateBody } from '@/lib/api/validate' @@ -29,32 +28,28 @@ const UpdateBookingTemplateSchema = z.object({ * PUT /api/settings/booking-templates/[id] * Update a non-system template. */ -export async function PUT( - request: Request, - { params }: { params: Promise<{ id: string }> }, -) { - const { id } = await params - const supabase = await createClient() - const { data: { user } } = await supabase.auth.getUser() - if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) +export const PUT = withRouteContext<{ params: Promise<{ id: string }> }>( + 'booking_template.update', + async (request, ctx, { params }) => { + const { id } = await params + const { supabase } = ctx - const writeCheck = await requireWritePermission(supabase, user.id) - if (!writeCheck.ok) return writeCheck.response + const result = await validateBody(request, UpdateBookingTemplateSchema) + if (!result.success) return result.response - const result = await validateBody(request, UpdateBookingTemplateSchema) - if (!result.success) return result.response + // RLS prevents updating system templates + const { data, error } = await supabase + .from('booking_template_library') + .update(result.data) + .eq('id', id) + .eq('is_system', false) + .select() + .single() - // RLS prevents updating system templates - const { data, error } = await supabase - .from('booking_template_library') - .update(result.data) - .eq('id', id) - .eq('is_system', false) - .select() - .single() + if (error) return NextResponse.json({ error: error.message }, { status: 500 }) + if (!data) return NextResponse.json({ error: 'Template not found' }, { status: 404 }) - if (error) return NextResponse.json({ error: error.message }, { status: 500 }) - if (!data) return NextResponse.json({ error: 'Template not found' }, { status: 404 }) - - return NextResponse.json({ data }) -} + return NextResponse.json({ data }) + }, + { requireWrite: true }, +) diff --git a/app/api/settings/booking-templates/[id]/touch/route.ts b/app/api/settings/booking-templates/[id]/touch/route.ts index b3f398bc..6f50cd5f 100644 --- a/app/api/settings/booking-templates/[id]/touch/route.ts +++ b/app/api/settings/booking-templates/[id]/touch/route.ts @@ -1,6 +1,5 @@ -import { createClient } from '@/lib/supabase/server' import { NextResponse } from 'next/server' -import { requireCompanyId } from '@/lib/company/context' +import { withRouteContext } from '@/lib/api/with-route-context' /** * POST /api/settings/booking-templates/[id]/touch @@ -11,31 +10,28 @@ import { requireCompanyId } from '@/lib/company/context' * * Fire-and-forget from the client: errors are non-fatal. */ -export async function POST( - _request: Request, - { params }: { params: Promise<{ id: string }> }, -) { - const { id } = await params - const supabase = await createClient() - const { data: { user } } = await supabase.auth.getUser() - if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) +export const POST = withRouteContext<{ params: Promise<{ id: string }> }>( + 'booking_template.touch', + async (_request, ctx, { params }) => { + const { id } = await params + const { supabase, companyId } = ctx - const companyId = await requireCompanyId(supabase, user.id) + const { error } = await supabase + .from('booking_template_usage') + .upsert( + { + template_id: id, + company_id: companyId, + last_used_at: new Date().toISOString(), + }, + { onConflict: 'template_id,company_id' }, + ) - const { error } = await supabase - .from('booking_template_usage') - .upsert( - { - template_id: id, - company_id: companyId, - last_used_at: new Date().toISOString(), - }, - { onConflict: 'template_id,company_id' }, - ) + if (error) { + return NextResponse.json({ error: error.message }, { status: 500 }) + } - if (error) { - return NextResponse.json({ error: error.message }, { status: 500 }) - } - - return NextResponse.json({ data: { success: true } }) -} + return NextResponse.json({ data: { success: true } }) + }, + { requireWrite: true }, +) diff --git a/app/api/settings/booking-templates/export/route.ts b/app/api/settings/booking-templates/export/route.ts index 49ef6231..c0f84fb3 100644 --- a/app/api/settings/booking-templates/export/route.ts +++ b/app/api/settings/booking-templates/export/route.ts @@ -1,34 +1,32 @@ -import { createClient } from '@/lib/supabase/server' import { NextResponse } from 'next/server' -import { requireCompanyId } from '@/lib/company/context' +import { withRouteContext } from '@/lib/api/with-route-context' /** * GET /api/settings/booking-templates/export * Export company + team templates as JSON (excludes system templates). * Useful for sharing templates between unrelated companies. */ -export async function GET() { - const supabase = await createClient() - const { data: { user } } = await supabase.auth.getUser() - if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) +export const GET = withRouteContext( + 'booking_template.export', + async (_request, ctx) => { + const { supabase, companyId } = ctx - const companyId = await requireCompanyId(supabase, user.id) + const { data, error } = await supabase + .from('booking_template_library') + .select('name, description, category, entity_type, lines') + .eq('company_id', companyId) + .eq('is_active', true) + .eq('is_system', false) + .order('category') + .order('name') - const { data, error } = await supabase - .from('booking_template_library') - .select('name, description, category, entity_type, lines') - .eq('company_id', companyId) - .eq('is_active', true) - .eq('is_system', false) - .order('category') - .order('name') + if (error) return NextResponse.json({ error: error.message }, { status: 500 }) - if (error) return NextResponse.json({ error: error.message }, { status: 500 }) - - return new NextResponse(JSON.stringify({ version: 1, templates: data }, null, 2), { - headers: { - 'Content-Type': 'application/json', - 'Content-Disposition': 'attachment; filename="bokforingsmallar.json"', - }, - }) -} + return new NextResponse(JSON.stringify({ version: 1, templates: data }, null, 2), { + headers: { + 'Content-Type': 'application/json', + 'Content-Disposition': 'attachment; filename="bokforingsmallar.json"', + }, + }) + }, +) diff --git a/app/api/settings/booking-templates/import/route.ts b/app/api/settings/booking-templates/import/route.ts index a89c9052..53f81c0c 100644 --- a/app/api/settings/booking-templates/import/route.ts +++ b/app/api/settings/booking-templates/import/route.ts @@ -1,7 +1,5 @@ -import { createClient } from '@/lib/supabase/server' import { NextResponse } from 'next/server' -import { requireCompanyId } from '@/lib/company/context' -import { requireWritePermission } from '@/lib/auth/require-write' +import { withRouteContext } from '@/lib/api/with-route-context' import { z } from 'zod' const ImportLineSchema = z.object({ @@ -35,49 +33,46 @@ const ImportPayloadSchema = z.object({ * Import templates from JSON (exported from another company). * Creates company-scoped templates for the active company. */ -export async function POST(request: Request) { - const supabase = await createClient() - const { data: { user } } = await supabase.auth.getUser() - if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) +export const POST = withRouteContext( + 'booking_template.import', + async (request, ctx) => { + const { supabase, user, companyId } = ctx - const writeCheck = await requireWritePermission(supabase, user.id) - if (!writeCheck.ok) return writeCheck.response + let body: unknown + try { + body = await request.json() + } catch { + return NextResponse.json({ error: 'Invalid JSON' }, { status: 400 }) + } - const companyId = await requireCompanyId(supabase, user.id) + const parsed = ImportPayloadSchema.safeParse(body) + if (!parsed.success) { + return NextResponse.json( + { error: 'Invalid import format', details: parsed.error.issues }, + { status: 400 }, + ) + } - let body: unknown - try { - body = await request.json() - } catch { - return NextResponse.json({ error: 'Invalid JSON' }, { status: 400 }) - } + const rows = parsed.data.templates.map((t) => ({ + company_id: companyId, + team_id: null, + created_by: user.id, + name: t.name, + description: t.description, + category: t.category, + entity_type: t.entity_type, + lines: t.lines, + is_system: false, + })) - const parsed = ImportPayloadSchema.safeParse(body) - if (!parsed.success) { - return NextResponse.json( - { error: 'Invalid import format', details: parsed.error.issues }, - { status: 400 }, - ) - } + const { data, error } = await supabase + .from('booking_template_library') + .insert(rows) + .select() - const rows = parsed.data.templates.map((t) => ({ - company_id: companyId, - team_id: null, - created_by: user.id, - name: t.name, - description: t.description, - category: t.category, - entity_type: t.entity_type, - lines: t.lines, - is_system: false, - })) + if (error) return NextResponse.json({ error: error.message }, { status: 500 }) - const { data, error } = await supabase - .from('booking_template_library') - .insert(rows) - .select() - - if (error) return NextResponse.json({ error: error.message }, { status: 500 }) - - return NextResponse.json({ data, imported: data?.length ?? 0 }, { status: 201 }) -} + return NextResponse.json({ data, imported: data?.length ?? 0 }, { status: 201 }) + }, + { requireWrite: true }, +) diff --git a/app/api/settings/booking-templates/route.ts b/app/api/settings/booking-templates/route.ts index 2415f567..25b6229a 100644 --- a/app/api/settings/booking-templates/route.ts +++ b/app/api/settings/booking-templates/route.ts @@ -1,7 +1,5 @@ -import { createClient } from '@/lib/supabase/server' import { NextResponse } from 'next/server' -import { requireCompanyId } from '@/lib/company/context' -import { requireWritePermission } from '@/lib/auth/require-write' +import { withRouteContext } from '@/lib/api/with-route-context' import { z } from 'zod' import { validateBody } from '@/lib/api/validate' @@ -42,158 +40,155 @@ const CreateBookingTemplateSchema = z.object({ * and name for never-used templates. Usage is tracked in * booking_template_usage via POST /[id]/touch. */ -export async function GET() { - const supabase = await createClient() - const { data: { user } } = await supabase.auth.getUser() - if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) +export const GET = withRouteContext( + 'booking_template.list', + async (_request, ctx) => { + const { supabase, companyId } = ctx - const companyId = await requireCompanyId(supabase, user.id) + // Resolve the team this company belongs to (if any) so team-shared + // templates stay visible while this company is selected. + const { data: company } = await supabase + .from('companies') + .select('team_id') + .eq('id', companyId) + .maybeSingle() + const teamId = company?.team_id ?? null - // Resolve the team this company belongs to (if any) so team-shared - // templates stay visible while this company is selected. - const { data: company } = await supabase - .from('companies') - .select('team_id') - .eq('id', companyId) - .maybeSingle() - const teamId = company?.team_id ?? null - - // requireCompanyId only ever returns a real membership UUID, but assert the - // shape before interpolating it into the .or() filter. - if (!UUID_RE.test(companyId)) { - return NextResponse.json({ error: 'Invalid company context' }, { status: 400 }) - } - - // Scope to the SELECTED company: system + this company + this company's team. - // RLS (btl_select) is membership-wide: it returns templates from *every* - // company the user belongs to: so the active-company narrowing must happen - // here in the API layer (mirrors counterparty-templates). Without this, a - // user who owns several companies sees all of their templates merged. - // Only interpolate a team id that passes the strict UUID guard. - const scope = [ - 'is_system.eq.true', - `company_id.eq.${companyId}`, - ...(teamId && UUID_RE.test(teamId) ? [`team_id.eq.${teamId}`] : []), - ].join(',') - - const [templatesRes, usageRes] = await Promise.all([ - supabase - .from('booking_template_library') - .select('*') - .eq('is_active', true) - .or(scope) - .order('category') - .order('name'), - supabase - .from('booking_template_usage') - .select('template_id, last_used_at') - .eq('company_id', companyId), - ]) - - if (templatesRes.error) { - return NextResponse.json({ error: templatesRes.error.message }, { status: 500 }) - } - // usage lookup failing is non-fatal: we just fall back to default ordering - const usageByTemplate = new Map() - if (!usageRes.error && usageRes.data) { - for (const row of usageRes.data) { - usageByTemplate.set(row.template_id, row.last_used_at) + // The wrapper only ever resolves a real membership UUID, but assert the + // shape before interpolating it into the .or() filter. + if (!UUID_RE.test(companyId)) { + return NextResponse.json({ error: 'Invalid company context' }, { status: 400 }) } - } - const templates = templatesRes.data ?? [] - const decorated = templates.map((t) => ({ - ...t, - last_used_at: usageByTemplate.get(t.id) ?? null, - })) + // Scope to the SELECTED company: system + this company + this company's team. + // RLS (btl_select) is membership-wide: it returns templates from *every* + // company the user belongs to: so the active-company narrowing must happen + // here in the API layer (mirrors counterparty-templates). Without this, a + // user who owns several companies sees all of their templates merged. + // Only interpolate a team id that passes the strict UUID guard. + const scope = [ + 'is_system.eq.true', + `company_id.eq.${companyId}`, + ...(teamId && UUID_RE.test(teamId) ? [`team_id.eq.${teamId}`] : []), + ].join(',') - // Stable-sort: templates with last_used_at come first (most-recent first). - // Templates without usage keep their category/name order from the query. - // ISO 8601 timestamps are fixed-width ASCII: plain relational comparison - // is correct and avoids any locale-dependent behaviour from localeCompare. - decorated.sort((a, b) => { - const aUsed = a.last_used_at - const bUsed = b.last_used_at - if (aUsed && bUsed) { - if (bUsed > aUsed) return -1 - if (bUsed < aUsed) return 1 + const [templatesRes, usageRes] = await Promise.all([ + supabase + .from('booking_template_library') + .select('*') + .eq('is_active', true) + .or(scope) + .order('category') + .order('name'), + supabase + .from('booking_template_usage') + .select('template_id, last_used_at') + .eq('company_id', companyId), + ]) + + if (templatesRes.error) { + return NextResponse.json({ error: templatesRes.error.message }, { status: 500 }) + } + // usage lookup failing is non-fatal: we just fall back to default ordering + const usageByTemplate = new Map() + if (!usageRes.error && usageRes.data) { + for (const row of usageRes.data) { + usageByTemplate.set(row.template_id, row.last_used_at) + } + } + + const templates = templatesRes.data ?? [] + const decorated = templates.map((t) => ({ + ...t, + last_used_at: usageByTemplate.get(t.id) ?? null, + })) + + // Stable-sort: templates with last_used_at come first (most-recent first). + // Templates without usage keep their category/name order from the query. + // ISO 8601 timestamps are fixed-width ASCII: plain relational comparison + // is correct and avoids any locale-dependent behaviour from localeCompare. + decorated.sort((a, b) => { + const aUsed = a.last_used_at + const bUsed = b.last_used_at + if (aUsed && bUsed) { + if (bUsed > aUsed) return -1 + if (bUsed < aUsed) return 1 + return 0 + } + if (aUsed) return -1 + if (bUsed) return 1 return 0 - } - if (aUsed) return -1 - if (bUsed) return 1 - return 0 - }) + }) - return NextResponse.json({ data: decorated }) -} + return NextResponse.json({ data: decorated }) + }, +) /** * POST /api/settings/booking-templates * Create a company-scoped or team-scoped template. */ -export async function POST(request: Request) { - const supabase = await createClient() - const { data: { user } } = await supabase.auth.getUser() - if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) +export const POST = withRouteContext( + 'booking_template.create', + async (request, ctx) => { + const { supabase, user } = ctx - const writeCheck = await requireWritePermission(supabase, user.id) - if (!writeCheck.ok) return writeCheck.response + const result = await validateBody(request, CreateBookingTemplateSchema) + if (!result.success) return result.response - const result = await validateBody(request, CreateBookingTemplateSchema) - if (!result.success) return result.response + const body = result.data + const companyId = body.team_id ? null : ctx.companyId - const body = result.data - const companyId = body.team_id ? null : await requireCompanyId(supabase, user.id) + const { data, error } = await supabase + .from('booking_template_library') + .insert({ + company_id: companyId, + team_id: body.team_id ?? null, + created_by: user.id, + name: body.name, + description: body.description, + category: body.category, + entity_type: body.entity_type, + lines: body.lines, + is_system: false, + }) + .select() + .single() - const { data, error } = await supabase - .from('booking_template_library') - .insert({ - company_id: companyId, - team_id: body.team_id ?? null, - created_by: user.id, - name: body.name, - description: body.description, - category: body.category, - entity_type: body.entity_type, - lines: body.lines, - is_system: false, - }) - .select() - .single() + if (error) return NextResponse.json({ error: error.message }, { status: 500 }) - if (error) return NextResponse.json({ error: error.message }, { status: 500 }) - - return NextResponse.json({ data }, { status: 201 }) -} + return NextResponse.json({ data }, { status: 201 }) + }, + { requireWrite: true }, +) /** * DELETE /api/settings/booking-templates * Soft-delete a template by id (company or team scope only, never system). */ -export async function DELETE(request: Request) { - const supabase = await createClient() - const { data: { user } } = await supabase.auth.getUser() - if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) +export const DELETE = withRouteContext( + 'booking_template.delete', + async (request, ctx) => { + const { supabase } = ctx - const writeCheck = await requireWritePermission(supabase, user.id) - if (!writeCheck.ok) return writeCheck.response + let id: string | undefined + try { + const body = await request.json() + id = body?.id + } catch { + return NextResponse.json({ error: 'Invalid request body' }, { status: 400 }) + } + if (!id) return NextResponse.json({ error: 'Missing id' }, { status: 400 }) - let id: string | undefined - try { - const body = await request.json() - id = body?.id - } catch { - return NextResponse.json({ error: 'Invalid request body' }, { status: 400 }) - } - if (!id) return NextResponse.json({ error: 'Missing id' }, { status: 400 }) + // RLS prevents deleting system templates (btl_delete policy checks NOT is_system) + const { error } = await supabase + .from('booking_template_library') + .update({ is_active: false }) + .eq('id', id) - // RLS prevents deleting system templates (btl_delete policy checks NOT is_system) - const { error } = await supabase - .from('booking_template_library') - .update({ is_active: false }) - .eq('id', id) + if (error) return NextResponse.json({ error: error.message }, { status: 500 }) - if (error) return NextResponse.json({ error: error.message }, { status: 500 }) - - return NextResponse.json({ data: { success: true } }) -} + return NextResponse.json({ data: { success: true } }) + }, + { requireWrite: true }, +) diff --git a/app/api/settings/counterparty-templates/__tests__/route.test.ts b/app/api/settings/counterparty-templates/__tests__/route.test.ts new file mode 100644 index 00000000..a0b46fdd --- /dev/null +++ b/app/api/settings/counterparty-templates/__tests__/route.test.ts @@ -0,0 +1,78 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { NextResponse } from 'next/server' +import { createMockRequest, parseJsonResponse, createQueuedMockSupabase } from '@/tests/helpers' + +const { supabase, enqueue, reset } = createQueuedMockSupabase() + +const requireAuthMock = vi.fn() +vi.mock('@/lib/auth/require-auth', () => ({ + requireAuth: (...args: unknown[]) => requireAuthMock(...args), +})) + +vi.mock('@/lib/company/context', () => ({ + getActiveCompanyId: vi.fn().mockResolvedValue('company-1'), + requireCompanyId: vi.fn().mockResolvedValue('company-1'), +})) + +const requireWriteMock = vi.fn() +vi.mock('@/lib/auth/require-write', () => ({ + requireWritePermission: (...args: unknown[]) => requireWriteMock(...args), +})) + +import { DELETE } from '../route' + +describe('DELETE /api/settings/counterparty-templates', () => { + beforeEach(() => { + vi.clearAllMocks() + reset() + requireAuthMock.mockResolvedValue({ user: { id: 'user-1' }, supabase, error: null }) + requireWriteMock.mockResolvedValue({ ok: true }) + }) + + it('returns 401 when not authenticated', async () => { + requireAuthMock.mockResolvedValue({ + user: null, + supabase, + error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }), + }) + + const request = createMockRequest('/api/settings/counterparty-templates', { + method: 'DELETE', + body: { id: 't1' }, + }) + const response = await DELETE(request, { params: Promise.resolve({}) }) + const { status } = await parseJsonResponse(response) + + expect(status).toBe(401) + }) + + it('returns 403 for a viewer without write permission', async () => { + requireWriteMock.mockResolvedValue({ + ok: false, + response: NextResponse.json({ error: 'Forbidden' }, { status: 403 }), + }) + + const request = createMockRequest('/api/settings/counterparty-templates', { + method: 'DELETE', + body: { id: 't1' }, + }) + const response = await DELETE(request, { params: Promise.resolve({}) }) + const { status } = await parseJsonResponse(response) + + expect(status).toBe(403) + }) + + it('soft-deletes the template on the happy path', async () => { + enqueue({ error: null }) // update is_active: false + + const request = createMockRequest('/api/settings/counterparty-templates', { + method: 'DELETE', + body: { id: 't1' }, + }) + const response = await DELETE(request, { params: Promise.resolve({}) }) + const { status, body } = await parseJsonResponse<{ data: { success: boolean } }>(response) + + expect(status).toBe(200) + expect(body.data.success).toBe(true) + }) +}) diff --git a/app/api/settings/counterparty-templates/route.ts b/app/api/settings/counterparty-templates/route.ts index b9220a6a..8c6d9d32 100644 --- a/app/api/settings/counterparty-templates/route.ts +++ b/app/api/settings/counterparty-templates/route.ts @@ -1,53 +1,43 @@ -import { createClient } from '@/lib/supabase/server' import { NextResponse } from 'next/server' -import { requireCompanyId } from '@/lib/company/context' -import { requireWritePermission } from '@/lib/auth/require-write' +import { withRouteContext } from '@/lib/api/with-route-context' -export async function GET() { - const supabase = await createClient() - const { data: { user } } = await supabase.auth.getUser() - if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) +export const GET = withRouteContext( + 'counterparty_template.list', + async (_request, { supabase, companyId }) => { + const { data, error } = await supabase + .from('categorization_templates') + .select('*') + .eq('company_id', companyId) + .eq('is_active', true) + .order('occurrence_count', { ascending: false }) - const companyId = await requireCompanyId(supabase, user.id) + if (error) return NextResponse.json({ error: error.message }, { status: 500 }) - const { data, error } = await supabase - .from('categorization_templates') - .select('*') - .eq('company_id', companyId) - .eq('is_active', true) - .order('occurrence_count', { ascending: false }) + return NextResponse.json({ data }) + }, +) - if (error) return NextResponse.json({ error: error.message }, { status: 500 }) +export const DELETE = withRouteContext( + 'counterparty_template.delete', + async (request, { supabase, companyId }) => { + let id: string | undefined + try { + const body = await request.json() + id = body?.id + } catch { + return NextResponse.json({ error: 'Invalid request body' }, { status: 400 }) + } + if (!id) return NextResponse.json({ error: 'Missing id' }, { status: 400 }) - return NextResponse.json({ data }) -} + const { error } = await supabase + .from('categorization_templates') + .update({ is_active: false }) + .eq('id', id) + .eq('company_id', companyId) -export async function DELETE(request: Request) { - const supabase = await createClient() - const { data: { user } } = await supabase.auth.getUser() - if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + if (error) return NextResponse.json({ error: error.message }, { status: 500 }) - const writeCheck = await requireWritePermission(supabase, user.id) - if (!writeCheck.ok) return writeCheck.response - - const companyId = await requireCompanyId(supabase, user.id) - - let id: string | undefined - try { - const body = await request.json() - id = body?.id - } catch { - return NextResponse.json({ error: 'Invalid request body' }, { status: 400 }) - } - if (!id) return NextResponse.json({ error: 'Missing id' }, { status: 400 }) - - const { error } = await supabase - .from('categorization_templates') - .update({ is_active: false }) - .eq('id', id) - .eq('company_id', companyId) - - if (error) return NextResponse.json({ error: error.message }, { status: 500 }) - - return NextResponse.json({ data: { success: true } }) -} + return NextResponse.json({ data: { success: true } }) + }, + { requireWrite: true }, +) diff --git a/app/api/settings/logo/__tests__/route.test.ts b/app/api/settings/logo/__tests__/route.test.ts new file mode 100644 index 00000000..e9df07f3 --- /dev/null +++ b/app/api/settings/logo/__tests__/route.test.ts @@ -0,0 +1,85 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { NextResponse } from 'next/server' +import { parseJsonResponse, createQueuedMockSupabase } from '@/tests/helpers' + +const { supabase, enqueue, reset } = createQueuedMockSupabase() + +const requireAuthMock = vi.fn() +vi.mock('@/lib/auth/require-auth', () => ({ + requireAuth: (...args: unknown[]) => requireAuthMock(...args), +})) + +vi.mock('@/lib/company/context', () => ({ + getActiveCompanyId: vi.fn().mockResolvedValue('company-1'), + requireCompanyId: vi.fn().mockResolvedValue('company-1'), +})) + +const requireWriteMock = vi.fn() +vi.mock('@/lib/auth/require-write', () => ({ + requireWritePermission: (...args: unknown[]) => requireWriteMock(...args), +})) + +const serviceStorage = { + from: vi.fn().mockReturnValue({ + list: vi.fn().mockResolvedValue({ data: [], error: null }), + remove: vi.fn().mockResolvedValue({ data: [], error: null }), + upload: vi.fn().mockResolvedValue({ data: {}, error: null }), + getPublicUrl: vi.fn().mockReturnValue({ data: { publicUrl: 'https://cdn.example.com/logo.png' } }), + }), +} +vi.mock('@/lib/supabase/server', () => ({ + createClient: vi.fn(), + createServiceClient: () => ({ storage: serviceStorage }), +})) + +import { POST } from '../route' + +function makeFormRequest(): Request { + const fd = new FormData() + fd.append('file', new File([new Uint8Array([1, 2, 3])], 'logo.png', { type: 'image/png' })) + return new Request('http://localhost/api/settings/logo', { method: 'POST', body: fd }) +} + +describe('POST /api/settings/logo', () => { + beforeEach(() => { + vi.clearAllMocks() + reset() + requireAuthMock.mockResolvedValue({ user: { id: 'user-1' }, supabase, error: null }) + requireWriteMock.mockResolvedValue({ ok: true }) + }) + + it('returns 401 when not authenticated', async () => { + requireAuthMock.mockResolvedValue({ + user: null, + supabase, + error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }), + }) + + const response = await POST(makeFormRequest(), { params: Promise.resolve({}) }) + const { status } = await parseJsonResponse(response) + + expect(status).toBe(401) + }) + + it('returns 403 for a viewer without write permission', async () => { + requireWriteMock.mockResolvedValue({ + ok: false, + response: NextResponse.json({ error: 'Forbidden' }, { status: 403 }), + }) + + const response = await POST(makeFormRequest(), { params: Promise.resolve({}) }) + const { status } = await parseJsonResponse(response) + + expect(status).toBe(403) + }) + + it('uploads the logo and returns its public url on the happy path', async () => { + enqueue({ error: null }) // company_settings update + + const response = await POST(makeFormRequest(), { params: Promise.resolve({}) }) + const { status, body } = await parseJsonResponse<{ data: { logo_url: string } }>(response) + + expect(status).toBe(200) + expect(body.data.logo_url).toBe('https://cdn.example.com/logo.png') + }) +}) diff --git a/app/api/settings/logo/route.ts b/app/api/settings/logo/route.ts index 8532ff15..8bbe8ec5 100644 --- a/app/api/settings/logo/route.ts +++ b/app/api/settings/logo/route.ts @@ -1,107 +1,41 @@ -import { createClient, createServiceClient } from '@/lib/supabase/server' import { NextResponse } from 'next/server' -import { requireCompanyId } from '@/lib/company/context' -import { requireWritePermission } from '@/lib/auth/require-write' +import { createServiceClient } from '@/lib/supabase/server' +import { withRouteContext } from '@/lib/api/with-route-context' const MAX_SIZE = 2 * 1024 * 1024 // 2MB const ALLOWED_TYPES = ['image/png', 'image/jpeg', 'image/svg+xml', 'image/webp'] -export async function POST(request: Request) { - const supabase = await createClient() - const { data: { user } } = await supabase.auth.getUser() - if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) +export const POST = withRouteContext( + 'settings.logo.upload', + async (request, { supabase, companyId }) => { + const formData = await request.formData() + const file = formData.get('file') as File | null - const writeCheck = await requireWritePermission(supabase, user.id) - if (!writeCheck.ok) return writeCheck.response + if (!file) { + return NextResponse.json({ error: 'Ingen fil angiven' }, { status: 400 }) + } - const companyId = await requireCompanyId(supabase, user.id) - if (!companyId) return NextResponse.json({ error: 'No company' }, { status: 403 }) + if (!ALLOWED_TYPES.includes(file.type)) { + return NextResponse.json({ error: 'Otillåten filtyp. Tillåtna: PNG, JPG, SVG, WebP.' }, { status: 400 }) + } - const formData = await request.formData() - const file = formData.get('file') as File | null + if (file.size > MAX_SIZE) { + return NextResponse.json({ error: 'Filen är för stor (max 2 MB).' }, { status: 400 }) + } - if (!file) { - return NextResponse.json({ error: 'Ingen fil angiven' }, { status: 400 }) - } + const buffer = Buffer.from(await file.arrayBuffer()) + const mimeToExt: Record = { + 'image/png': 'png', + 'image/jpeg': 'jpg', + 'image/svg+xml': 'svg', + 'image/webp': 'webp', + } + const ext = mimeToExt[file.type] ?? 'png' + const storagePath = `${companyId}/logo-${Date.now()}.${ext}` - if (!ALLOWED_TYPES.includes(file.type)) { - return NextResponse.json({ error: 'Otillåten filtyp. Tillåtna: PNG, JPG, SVG, WebP.' }, { status: 400 }) - } - - if (file.size > MAX_SIZE) { - return NextResponse.json({ error: 'Filen är för stor (max 2 MB).' }, { status: 400 }) - } - - const buffer = Buffer.from(await file.arrayBuffer()) - const mimeToExt: Record = { - 'image/png': 'png', - 'image/jpeg': 'jpg', - 'image/svg+xml': 'svg', - 'image/webp': 'webp', - } - const ext = mimeToExt[file.type] ?? 'png' - const storagePath = `${companyId}/logo-${Date.now()}.${ext}` - - const serviceClient = createServiceClient() - - // Remove any previous logo files for this company so we don't pile up orphans. - const { data: existing } = await serviceClient.storage - .from('logos') - .list(companyId) - if (existing && existing.length > 0) { - await serviceClient.storage - .from('logos') - .remove(existing.map((f) => `${companyId}/${f.name}`)) - } - - const { error: uploadError } = await serviceClient.storage - .from('logos') - .upload(storagePath, buffer, { - contentType: file.type, - upsert: true, - }) - - if (uploadError) { - return NextResponse.json({ error: `Uppladdning misslyckades: ${uploadError.message}` }, { status: 500 }) - } - - const { data: urlData } = serviceClient.storage - .from('logos') - .getPublicUrl(storagePath) - - // Update company settings - const { error: updateError } = await supabase - .from('company_settings') - .update({ logo_url: urlData.publicUrl }) - .eq('company_id', companyId) - - if (updateError) { - return NextResponse.json({ error: 'Kunde inte uppdatera inställningar' }, { status: 500 }) - } - - return NextResponse.json({ data: { logo_url: urlData.publicUrl } }) -} - -export async function DELETE() { - const supabase = await createClient() - const { data: { user } } = await supabase.auth.getUser() - if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - - const writeCheck = await requireWritePermission(supabase, user.id) - if (!writeCheck.ok) return writeCheck.response - - const companyId = await requireCompanyId(supabase, user.id) - if (!companyId) return NextResponse.json({ error: 'No company' }, { status: 403 }) - - // Get current logo path - const { data: settings } = await supabase - .from('company_settings') - .select('logo_url') - .eq('company_id', companyId) - .single() - - if (settings?.logo_url) { const serviceClient = createServiceClient() + + // Remove any previous logo files for this company so we don't pile up orphans. const { data: existing } = await serviceClient.storage .from('logos') .list(companyId) @@ -110,13 +44,66 @@ export async function DELETE() { .from('logos') .remove(existing.map((f) => `${companyId}/${f.name}`)) } - } - // Clear logo_url - await supabase - .from('company_settings') - .update({ logo_url: null }) - .eq('company_id', companyId) + const { error: uploadError } = await serviceClient.storage + .from('logos') + .upload(storagePath, buffer, { + contentType: file.type, + upsert: true, + }) - return NextResponse.json({ data: { logo_url: null } }) -} + if (uploadError) { + return NextResponse.json({ error: `Uppladdning misslyckades: ${uploadError.message}` }, { status: 500 }) + } + + const { data: urlData } = serviceClient.storage + .from('logos') + .getPublicUrl(storagePath) + + // Update company settings + const { error: updateError } = await supabase + .from('company_settings') + .update({ logo_url: urlData.publicUrl }) + .eq('company_id', companyId) + + if (updateError) { + return NextResponse.json({ error: 'Kunde inte uppdatera inställningar' }, { status: 500 }) + } + + return NextResponse.json({ data: { logo_url: urlData.publicUrl } }) + }, + { requireWrite: true }, +) + +export const DELETE = withRouteContext( + 'settings.logo.delete', + async (_request, { supabase, companyId }) => { + // Get current logo path + const { data: settings } = await supabase + .from('company_settings') + .select('logo_url') + .eq('company_id', companyId) + .single() + + if (settings?.logo_url) { + const serviceClient = createServiceClient() + const { data: existing } = await serviceClient.storage + .from('logos') + .list(companyId) + if (existing && existing.length > 0) { + await serviceClient.storage + .from('logos') + .remove(existing.map((f) => `${companyId}/${f.name}`)) + } + } + + // Clear logo_url + await supabase + .from('company_settings') + .update({ logo_url: null }) + .eq('company_id', companyId) + + return NextResponse.json({ data: { logo_url: null } }) + }, + { requireWrite: true }, +) diff --git a/app/api/settings/oauth-clients/[id]/__tests__/route.test.ts b/app/api/settings/oauth-clients/[id]/__tests__/route.test.ts new file mode 100644 index 00000000..3961ff26 --- /dev/null +++ b/app/api/settings/oauth-clients/[id]/__tests__/route.test.ts @@ -0,0 +1,86 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { NextResponse } from 'next/server' +import { createMockRouteParams, parseJsonResponse, createQueuedMockSupabase } from '@/tests/helpers' + +const { supabase, enqueue, reset } = createQueuedMockSupabase() + +const requireAuthMock = vi.fn() +vi.mock('@/lib/auth/require-auth', () => ({ + requireAuth: (...args: unknown[]) => requireAuthMock(...args), +})) + +vi.mock('@/lib/company/context', () => ({ + getActiveCompanyId: vi.fn().mockResolvedValue('company-1'), + requireCompanyId: vi.fn().mockResolvedValue('company-1'), +})) + +const requireWriteMock = vi.fn() +vi.mock('@/lib/auth/require-write', () => ({ + requireWritePermission: (...args: unknown[]) => requireWriteMock(...args), +})) + +const appendMock = vi.fn() +vi.mock('@/lib/processing-history/append', () => ({ + appendProcessingHistory: (...args: unknown[]) => appendMock(...args), +})) + +import { DELETE } from '../route' + +describe('DELETE /api/settings/oauth-clients/[id]', () => { + const routeParams = createMockRouteParams({ id: 'reg-1' }) + + beforeEach(() => { + vi.clearAllMocks() + reset() + requireAuthMock.mockResolvedValue({ user: { id: 'user-1' }, supabase, error: null }) + requireWriteMock.mockResolvedValue({ ok: true }) + appendMock.mockResolvedValue(undefined) + }) + + it('returns 401 when not authenticated', async () => { + requireAuthMock.mockResolvedValue({ + user: null, + supabase, + error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }), + }) + + const response = await DELETE(new Request('http://localhost/x', { method: 'DELETE' }), routeParams) + const { status } = await parseJsonResponse(response) + + expect(status).toBe(401) + }) + + it('returns 403 for a viewer without write permission', async () => { + requireWriteMock.mockResolvedValue({ + ok: false, + response: NextResponse.json({ error: 'Forbidden' }, { status: 403 }), + }) + + const response = await DELETE(new Request('http://localhost/x', { method: 'DELETE' }), routeParams) + const { status } = await parseJsonResponse(response) + + expect(status).toBe(403) + }) + + it('returns 404 when the registration is unknown or already revoked', async () => { + enqueue({ data: [] }) // update ... returning no rows + + const response = await DELETE(new Request('http://localhost/x', { method: 'DELETE' }), routeParams) + const { status } = await parseJsonResponse(response) + + expect(status).toBe(404) + }) + + it('revokes the registration and appends an audit event on the happy path', async () => { + enqueue({ + data: [{ id: 'reg-1', redirect_uri: 'https://app.example.com/cb', client_name: 'My App' }], + }) + + const response = await DELETE(new Request('http://localhost/x', { method: 'DELETE' }), routeParams) + const { status, body } = await parseJsonResponse<{ success: boolean }>(response) + + expect(status).toBe(200) + expect(body.success).toBe(true) + expect(appendMock).toHaveBeenCalledOnce() + }) +}) diff --git a/app/api/settings/oauth-clients/[id]/route.ts b/app/api/settings/oauth-clients/[id]/route.ts index d0bfa886..90cd0cab 100644 --- a/app/api/settings/oauth-clients/[id]/route.ts +++ b/app/api/settings/oauth-clients/[id]/route.ts @@ -1,11 +1,6 @@ -import { createClient } from '@/lib/supabase/server' import { NextResponse } from 'next/server' -import { requireWritePermission } from '@/lib/auth/require-write' -import { requireCompanyId } from '@/lib/company/context' +import { withRouteContext } from '@/lib/api/with-route-context' import { appendProcessingHistory } from '@/lib/processing-history/append' -import { createLogger } from '@/lib/logger' - -const log = createLogger('oauth-clients:delete') /** * DELETE /api/settings/oauth-clients/[id]: revoke a redirect URI @@ -17,65 +12,56 @@ const log = createLogger('oauth-clients:delete') * (SOC 2 CC7.2). Returns 404 when the row is unknown or already revoked * so callers can surface failures rather than treating "no-op" as success. */ -export async function DELETE( - _request: Request, - { params }: { params: Promise<{ id: string }> } -) { - const supabase = await createClient() - const { id } = await params - const { data: { user } } = await supabase.auth.getUser() +export const DELETE = withRouteContext<{ params: Promise<{ id: string }> }>( + 'oauth_client.delete', + async (_request, { supabase, user, companyId, log }, { params }) => { + const { id } = await params - if (!user) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } + const { data: rows, error } = await supabase + .from('oauth_client_registrations') + .update({ revoked_at: new Date().toISOString() }) + .eq('id', id) + .eq('user_id', user.id) + .is('revoked_at', null) + .select('id, redirect_uri, client_name') - const writeCheck = await requireWritePermission(supabase, user.id) - if (!writeCheck.ok) return writeCheck.response + if (error) { + return NextResponse.json({ error: error.message }, { status: 500 }) + } - const { data: rows, error } = await supabase - .from('oauth_client_registrations') - .update({ revoked_at: new Date().toISOString() }) - .eq('id', id) - .eq('user_id', user.id) - .is('revoked_at', null) - .select('id, redirect_uri, client_name') + if (!rows || rows.length === 0) { + return NextResponse.json( + { error: 'OAuth-klient hittades inte eller är redan återkallad.' }, + { status: 404 } + ) + } - if (error) { - return NextResponse.json({ error: error.message }, { status: 500 }) - } + // Audit the revocation. Failure to append must not break the user flow: + // the revocation has already happened in the DB. We log the appendError + // so a systematic outage is visible in operations rather than silently + // degraded. + try { + await appendProcessingHistory({ + companyId, + correlationId: id, + aggregateType: 'System', + aggregateId: id, + eventType: 'OAuthClientRevoked', + payload: { + client_id: id, + // Note: redirect_uri may contain a host the user identifies with their + // own infrastructure but is not PII per Art. 4(1). Stored to satisfy + // SOC 2 CC7.2 "what was revoked" evidence trail. + redirect_uri: rows[0].redirect_uri, + }, + actor: { type: 'user', id: user.id }, + occurredAt: new Date(), + }) + } catch (auditErr) { + log.warn('Failed to append OAuthClientRevoked audit event', auditErr) + } - if (!rows || rows.length === 0) { - return NextResponse.json( - { error: 'OAuth-klient hittades inte eller är redan återkallad.' }, - { status: 404 } - ) - } - - // Audit the revocation. Failure to append must not break the user flow: - // the revocation has already happened in the DB. We log the appendError - // so a systematic outage is visible in operations rather than silently - // degraded. - try { - const companyId = await requireCompanyId(supabase, user.id) - await appendProcessingHistory({ - companyId, - correlationId: id, - aggregateType: 'System', - aggregateId: id, - eventType: 'OAuthClientRevoked', - payload: { - client_id: id, - // Note: redirect_uri may contain a host the user identifies with their - // own infrastructure but is not PII per Art. 4(1). Stored to satisfy - // SOC 2 CC7.2 "what was revoked" evidence trail. - redirect_uri: rows[0].redirect_uri, - }, - actor: { type: 'user', id: user.id }, - occurredAt: new Date(), - }) - } catch (auditErr) { - log.warn('Failed to append OAuthClientRevoked audit event', auditErr) - } - - return NextResponse.json({ success: true }) -} + return NextResponse.json({ success: true }) + }, + { requireWrite: true }, +) diff --git a/app/api/settings/oauth-clients/__tests__/route.test.ts b/app/api/settings/oauth-clients/__tests__/route.test.ts new file mode 100644 index 00000000..66cdfe2a --- /dev/null +++ b/app/api/settings/oauth-clients/__tests__/route.test.ts @@ -0,0 +1,85 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { NextResponse } from 'next/server' +import { createMockRequest, parseJsonResponse, createQueuedMockSupabase } from '@/tests/helpers' + +const { supabase, enqueue, reset } = createQueuedMockSupabase() + +const requireAuthMock = vi.fn() +vi.mock('@/lib/auth/require-auth', () => ({ + requireAuth: (...args: unknown[]) => requireAuthMock(...args), +})) + +vi.mock('@/lib/company/context', () => ({ + getActiveCompanyId: vi.fn().mockResolvedValue('company-1'), + requireCompanyId: vi.fn().mockResolvedValue('company-1'), +})) + +const requireWriteMock = vi.fn() +vi.mock('@/lib/auth/require-write', () => ({ + requireWritePermission: (...args: unknown[]) => requireWriteMock(...args), +})) + +import { POST } from '../route' + +describe('POST /api/settings/oauth-clients', () => { + beforeEach(() => { + vi.clearAllMocks() + reset() + requireAuthMock.mockResolvedValue({ user: { id: 'user-1' }, supabase, error: null }) + requireWriteMock.mockResolvedValue({ ok: true }) + }) + + it('returns 401 when not authenticated', async () => { + requireAuthMock.mockResolvedValue({ + user: null, + supabase, + error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }), + }) + + const request = createMockRequest('/api/settings/oauth-clients', { + method: 'POST', + body: { client_name: 'My App', redirect_uri: 'https://app.example.com/cb' }, + }) + const response = await POST(request, { params: Promise.resolve({}) }) + const { status } = await parseJsonResponse(response) + + expect(status).toBe(401) + }) + + it('returns 403 for a viewer without write permission', async () => { + requireWriteMock.mockResolvedValue({ + ok: false, + response: NextResponse.json({ error: 'Forbidden' }, { status: 403 }), + }) + + const request = createMockRequest('/api/settings/oauth-clients', { + method: 'POST', + body: { client_name: 'My App', redirect_uri: 'https://app.example.com/cb' }, + }) + const response = await POST(request, { params: Promise.resolve({}) }) + const { status } = await parseJsonResponse(response) + + expect(status).toBe(403) + }) + + it('registers a redirect uri scoped to the current user on the happy path', async () => { + enqueue({ + data: { + id: 'reg-1', + client_name: 'My App', + redirect_uri: 'https://app.example.com/cb', + created_at: '2026-01-01T00:00:00Z', + }, + }) + + const request = createMockRequest('/api/settings/oauth-clients', { + method: 'POST', + body: { client_name: 'My App', redirect_uri: 'https://app.example.com/cb' }, + }) + const response = await POST(request, { params: Promise.resolve({}) }) + const { status, body } = await parseJsonResponse<{ data: { id: string } }>(response) + + expect(status).toBe(200) + expect(body.data.id).toBe('reg-1') + }) +}) diff --git a/app/api/settings/oauth-clients/route.ts b/app/api/settings/oauth-clients/route.ts index 867f3cb9..ae5881ca 100644 --- a/app/api/settings/oauth-clients/route.ts +++ b/app/api/settings/oauth-clients/route.ts @@ -1,6 +1,5 @@ -import { createClient } from '@/lib/supabase/server' import { NextResponse } from 'next/server' -import { requireWritePermission } from '@/lib/auth/require-write' +import { withRouteContext } from '@/lib/api/with-route-context' import { z } from 'zod' /** @@ -29,66 +28,62 @@ const RegistrationSchema = z.object({ .max(500), }) -export async function GET() { - const supabase = await createClient() - const { data: { user } } = await supabase.auth.getUser() - if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) +export const GET = withRouteContext( + 'oauth_client.list', + async (_request, { supabase, user }) => { + const { data, error } = await supabase + .from('oauth_client_registrations') + .select('id, client_name, redirect_uri, created_at, revoked_at') + .eq('user_id', user.id) + .order('created_at', { ascending: false }) - const { data, error } = await supabase - .from('oauth_client_registrations') - .select('id, client_name, redirect_uri, created_at, revoked_at') - .eq('user_id', user.id) - .order('created_at', { ascending: false }) - - if (error) { - return NextResponse.json({ error: error.message }, { status: 500 }) - } - - return NextResponse.json({ data }) -} - -export async function POST(request: Request) { - const supabase = await createClient() - const { data: { user } } = await supabase.auth.getUser() - if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - - const writeCheck = await requireWritePermission(supabase, user.id) - if (!writeCheck.ok) return writeCheck.response - - let body: z.infer - try { - const json = await request.json() - body = RegistrationSchema.parse(json) - } catch (err) { - const message = - err instanceof z.ZodError - ? err.issues[0]?.message ?? 'Ogiltig redirect URI' - : err instanceof SyntaxError - ? 'Ogiltig JSON i request body' - : 'Ogiltig redirect URI' - return NextResponse.json({ error: message }, { status: 400 }) - } - - const { data, error } = await supabase - .from('oauth_client_registrations') - .insert({ - user_id: user.id, - client_name: body.client_name, - redirect_uri: body.redirect_uri, - }) - .select('id, client_name, redirect_uri, created_at') - .single() - - if (error) { - // Unique-index violation on redirect_uri → 409 - if (error.code === '23505') { - return NextResponse.json( - { error: 'Den här redirect URI:n är redan registrerad.' }, - { status: 409 } - ) + if (error) { + return NextResponse.json({ error: error.message }, { status: 500 }) } - return NextResponse.json({ error: error.message }, { status: 500 }) - } - return NextResponse.json({ data }) -} + return NextResponse.json({ data }) + }, +) + +export const POST = withRouteContext( + 'oauth_client.create', + async (request, { supabase, user }) => { + let body: z.infer + try { + const json = await request.json() + body = RegistrationSchema.parse(json) + } catch (err) { + const message = + err instanceof z.ZodError + ? err.issues[0]?.message ?? 'Ogiltig redirect URI' + : err instanceof SyntaxError + ? 'Ogiltig JSON i request body' + : 'Ogiltig redirect URI' + return NextResponse.json({ error: message }, { status: 400 }) + } + + const { data, error } = await supabase + .from('oauth_client_registrations') + .insert({ + user_id: user.id, + client_name: body.client_name, + redirect_uri: body.redirect_uri, + }) + .select('id, client_name, redirect_uri, created_at') + .single() + + if (error) { + // Unique-index violation on redirect_uri → 409 + if (error.code === '23505') { + return NextResponse.json( + { error: 'Den här redirect URI:n är redan registrerad.' }, + { status: 409 } + ) + } + return NextResponse.json({ error: error.message }, { status: 500 }) + } + + return NextResponse.json({ data }) + }, + { requireWrite: true }, +) diff --git a/app/api/settings/route.ts b/app/api/settings/route.ts index 11ea710b..58d1463f 100644 --- a/app/api/settings/route.ts +++ b/app/api/settings/route.ts @@ -1,137 +1,119 @@ -import { createClient } from '@/lib/supabase/server' import { NextResponse } from 'next/server' +import { withRouteContext } from '@/lib/api/with-route-context' import { didTaxFieldsChange, regenerateTaxDeadlinesForUser } from '@/lib/tax/deadline-generator' import { validateBody } from '@/lib/api/validate' import { UpdateSettingsSchema } from '@/lib/api/schemas' -import { requireCompanyId } from '@/lib/company/context' -import { requireWritePermission } from '@/lib/auth/require-write' -export async function GET() { - const supabase = await createClient() - - const { data: { user } } = await supabase.auth.getUser() - - if (!user) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const companyId = await requireCompanyId(supabase, user.id) - - const { data, error } = await supabase - .from('company_settings') - .select('*') - .eq('company_id', companyId) - .single() - - if (error) { - return NextResponse.json({ error: error.message }, { status: 500 }) - } - - // Fall back to companies.entity_type if company_settings.entity_type is null - let responseData = data - if (data && !data.entity_type) { - const { data: company } = await supabase - .from('companies') - .select('entity_type') - .eq('id', companyId) +export const GET = withRouteContext( + 'settings.get', + async (_request, { supabase, companyId }) => { + const { data, error } = await supabase + .from('company_settings') + .select('*') + .eq('company_id', companyId) .single() - if (company?.entity_type) { - responseData = { ...data, entity_type: company.entity_type } + + if (error) { + return NextResponse.json({ error: error.message }, { status: 500 }) } - } - return NextResponse.json({ data: responseData }) -} + // Fall back to companies.entity_type if company_settings.entity_type is null + let responseData = data + if (data && !data.entity_type) { + const { data: company } = await supabase + .from('companies') + .select('entity_type') + .eq('id', companyId) + .single() + if (company?.entity_type) { + responseData = { ...data, entity_type: company.entity_type } + } + } -export async function PUT(request: Request) { - const supabase = await createClient() + return NextResponse.json({ data: responseData }) + }, +) - const { data: { user } } = await supabase.auth.getUser() +export const PUT = withRouteContext( + 'settings.update', + async (request, { supabase, companyId }) => { + // Fetch current settings to check for tax-relevant changes + const { data: oldSettings } = await supabase + .from('company_settings') + .select('entity_type, moms_period, f_skatt, vat_registered, vat_number, pays_salaries, fiscal_year_start_month, onboarding_complete') + .eq('company_id', companyId) + .single() - if (!user) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } + const validation = await validateBody(request, UpdateSettingsSchema) + if (!validation.success) return validation.response + const body = validation.data - const writeCheck = await requireWritePermission(supabase, user.id) - if (!writeCheck.ok) return writeCheck.response + // Lock org_number after onboarding is complete (legal identifier: changing it + // would orphan vouchers, SIE history, and tax filings). company_name remains + // editable so users can update their display/brand name (e.g. särskilt företagsnamn). + if (oldSettings && (oldSettings as Record).onboarding_complete === true) { + delete (body as Record).org_number + } - const companyId = await requireCompanyId(supabase, user.id) - - // Fetch current settings to check for tax-relevant changes - const { data: oldSettings } = await supabase - .from('company_settings') - .select('entity_type, moms_period, f_skatt, vat_registered, vat_number, pays_salaries, fiscal_year_start_month, onboarding_complete') - .eq('company_id', companyId) - .single() - - const validation = await validateBody(request, UpdateSettingsSchema) - if (!validation.success) return validation.response - const body = validation.data - - // Lock org_number after onboarding is complete (legal identifier: changing it - // would orphan vouchers, SIE history, and tax filings). company_name remains - // editable so users can update their display/brand name (e.g. särskilt företagsnamn). - if (oldSettings && (oldSettings as Record).onboarding_complete === true) { - delete (body as Record).org_number - } - - // Validate: enskild firma must use calendar year (BFL 3 kap.) - const effectiveEntityType = body.entity_type || oldSettings?.entity_type - const effectiveFYStartMonth = body.fiscal_year_start_month ?? oldSettings?.fiscal_year_start_month - if (effectiveEntityType === 'enskild_firma' && effectiveFYStartMonth && effectiveFYStartMonth !== 1) { - return NextResponse.json( - { error: 'Enskild firma måste använda kalenderår (BFL 3 kap.)' }, - { status: 400 } - ) - } - - // Validate: VAT-registered must have VAT number (ML 11 kap. 8§) and moms period (SFL 26 kap.) - const effectiveVatRegistered = body.vat_registered ?? oldSettings?.vat_registered - if (effectiveVatRegistered === true) { - const effectiveVatNumber = body.vat_number ?? oldSettings?.vat_number - if (!effectiveVatNumber) { + // Validate: enskild firma must use calendar year (BFL 3 kap.) + const effectiveEntityType = body.entity_type || oldSettings?.entity_type + const effectiveFYStartMonth = body.fiscal_year_start_month ?? oldSettings?.fiscal_year_start_month + if (effectiveEntityType === 'enskild_firma' && effectiveFYStartMonth && effectiveFYStartMonth !== 1) { return NextResponse.json( - { error: 'Momsregistreringsnummer krävs när företaget är momsregistrerat (ML 11 kap. 8§)' }, + { error: 'Enskild firma måste använda kalenderår (BFL 3 kap.)' }, { status: 400 } ) } - const effectiveMomsPeriod = body.moms_period ?? oldSettings?.moms_period - if (!effectiveMomsPeriod) { - return NextResponse.json( - { error: 'Momsperiod krävs när företaget är momsregistrerat (SFL 26 kap.)' }, - { status: 400 } - ) + + // Validate: VAT-registered must have VAT number (ML 11 kap. 8§) and moms period (SFL 26 kap.) + const effectiveVatRegistered = body.vat_registered ?? oldSettings?.vat_registered + if (effectiveVatRegistered === true) { + const effectiveVatNumber = body.vat_number ?? oldSettings?.vat_number + if (!effectiveVatNumber) { + return NextResponse.json( + { error: 'Momsregistreringsnummer krävs när företaget är momsregistrerat (ML 11 kap. 8§)' }, + { status: 400 } + ) + } + const effectiveMomsPeriod = body.moms_period ?? oldSettings?.moms_period + if (!effectiveMomsPeriod) { + return NextResponse.json( + { error: 'Momsperiod krävs när företaget är momsregistrerat (SFL 26 kap.)' }, + { status: 400 } + ) + } } - } - const { data, error } = await supabase - .from('company_settings') - .update(body) - .eq('company_id', companyId) - .select() - .single() + const { data, error } = await supabase + .from('company_settings') + .update(body) + .eq('company_id', companyId) + .select() + .single() - if (error) { - return NextResponse.json({ error: error.message }, { status: 500 }) - } - - // Check if tax-relevant fields changed and regenerate deadlines - if (oldSettings && didTaxFieldsChange(oldSettings, data)) { - try { - await regenerateTaxDeadlinesForUser(supabase, companyId, { - entity_type: data.entity_type, - moms_period: data.moms_period, - f_skatt: data.f_skatt, - vat_registered: data.vat_registered, - pays_salaries: data.pays_salaries ?? false, - fiscal_year_start_month: data.fiscal_year_start_month, - }) - console.log('Tax deadlines regenerated after settings change') - } catch (err) { - console.error('Failed to regenerate tax deadlines:', err) - // Don't fail the settings update if deadline generation fails + if (error) { + return NextResponse.json({ error: error.message }, { status: 500 }) } - } - return NextResponse.json({ data }) -} + // Check if tax-relevant fields changed and regenerate deadlines + if (oldSettings && didTaxFieldsChange(oldSettings, data)) { + try { + await regenerateTaxDeadlinesForUser(supabase, companyId, { + entity_type: data.entity_type, + moms_period: data.moms_period, + f_skatt: data.f_skatt, + vat_registered: data.vat_registered, + pays_salaries: data.pays_salaries ?? false, + fiscal_year_start_month: data.fiscal_year_start_month, + }) + console.log('Tax deadlines regenerated after settings change') + } catch (err) { + console.error('Failed to regenerate tax deadlines:', err) + // Don't fail the settings update if deadline generation fails + } + } + + return NextResponse.json({ data }) + }, + { requireWrite: true }, +) diff --git a/app/api/skatteverket/tax-payments/[period]/mark-paid/__tests__/route.test.ts b/app/api/skatteverket/tax-payments/[period]/mark-paid/__tests__/route.test.ts new file mode 100644 index 00000000..cec57763 --- /dev/null +++ b/app/api/skatteverket/tax-payments/[period]/mark-paid/__tests__/route.test.ts @@ -0,0 +1,99 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { NextResponse } from 'next/server' +import { + createMockRequest, + createMockRouteParams, + parseJsonResponse, + createQueuedMockSupabase, +} from '@/tests/helpers' + +const { supabase: mockSupabase, enqueue, reset } = createQueuedMockSupabase() + +const requireAuthMock = vi.fn() +vi.mock('@/lib/auth/require-auth', () => ({ + requireAuth: (...args: unknown[]) => requireAuthMock(...args), +})) + +vi.mock('@/lib/init', () => ({ ensureInitialized: vi.fn() })) + +vi.mock('@/lib/company/context', () => ({ + getActiveCompanyId: vi.fn().mockResolvedValue('company-1'), + requireCompanyId: vi.fn().mockResolvedValue('company-1'), +})) + +const requireWriteMock = vi.fn() +vi.mock('@/lib/auth/require-write', () => ({ + requireWritePermission: (...args: unknown[]) => requireWriteMock(...args), +})) + +import { POST } from '../route' + +describe('POST /api/skatteverket/tax-payments/[period]/mark-paid', () => { + const mockUser = { id: 'user-1', email: 'test@test.se' } + + beforeEach(() => { + vi.clearAllMocks() + reset() + requireAuthMock.mockResolvedValue({ user: mockUser, supabase: mockSupabase, error: null }) + requireWriteMock.mockResolvedValue({ ok: true }) + }) + + it('returns 401 when not authenticated', async () => { + requireAuthMock.mockResolvedValue({ + user: null, + supabase: mockSupabase, + error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }), + }) + + const response = await POST( + createMockRequest('/api/skatteverket/tax-payments/2026-04/mark-paid', { method: 'POST' }), + createMockRouteParams({ period: '2026-04' }), + ) + expect(response.status).toBe(401) + }) + + it('returns 403 for a viewer without write permission', async () => { + requireWriteMock.mockResolvedValue({ + ok: false, + response: NextResponse.json({ error: 'Forbidden' }, { status: 403 }), + }) + + const response = await POST( + createMockRequest('/api/skatteverket/tax-payments/2026-04/mark-paid', { method: 'POST' }), + createMockRouteParams({ period: '2026-04' }), + ) + expect(response.status).toBe(403) + }) + + it('returns 400 for an invalid period', async () => { + const response = await POST( + createMockRequest('/api/skatteverket/tax-payments/nope/mark-paid', { method: 'POST' }), + createMockRouteParams({ period: 'nope' }), + ) + expect(response.status).toBe(400) + }) + + it('returns 404 when no AGI exists for the period', async () => { + enqueue({ data: null }) // agi lookup + + const response = await POST( + createMockRequest('/api/skatteverket/tax-payments/2026-04/mark-paid', { method: 'POST' }), + createMockRouteParams({ period: '2026-04' }), + ) + expect(response.status).toBe(404) + }) + + it('marks the AGI period as paid (happy path)', async () => { + enqueue({ data: { id: 'agi-1' } }) // agi lookup + enqueue({ data: null, error: null }) // update + + const response = await POST( + createMockRequest('/api/skatteverket/tax-payments/2026-04/mark-paid', { method: 'POST' }), + createMockRouteParams({ period: '2026-04' }), + ) + const { status, body } = await parseJsonResponse<{ data: { ok: boolean } }>(response) + + expect(status).toBe(200) + expect(body.data.ok).toBe(true) + }) +}) diff --git a/app/api/skatteverket/tax-payments/[period]/mark-paid/route.ts b/app/api/skatteverket/tax-payments/[period]/mark-paid/route.ts index 9bec7602..952d01fa 100644 --- a/app/api/skatteverket/tax-payments/[period]/mark-paid/route.ts +++ b/app/api/skatteverket/tax-payments/[period]/mark-paid/route.ts @@ -1,8 +1,6 @@ -import { createClient } from '@/lib/supabase/server' import { NextResponse } from 'next/server' import { ensureInitialized } from '@/lib/init' -import { requireCompanyId } from '@/lib/company/context' -import { requireWritePermission } from '@/lib/auth/require-write' +import { withRouteContext } from '@/lib/api/with-route-context' ensureInitialized() @@ -13,10 +11,9 @@ ensureInitialized() * Skattekontot transactions can also flip this flag automatically (handled * elsewhere via the Skattekonto sync). */ -export async function POST( - request: Request, - { params }: { params: Promise<{ period: string }> } -) { +export const POST = withRouteContext<{ params: Promise<{ period: string }> }>( + 'tax_payment.mark_paid', + async (request, { supabase, companyId }, { params }) => { const { period } = await params const periodMatch = /^(\d{4})-(\d{2})$/.exec(period) if (!periodMatch) { @@ -28,15 +25,6 @@ export async function POST( const periodYear = parseInt(periodMatch[1], 10) const periodMonth = parseInt(periodMatch[2], 10) - const supabase = await createClient() - const { data: { user } } = await supabase.auth.getUser() - if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - - const writeCheck = await requireWritePermission(supabase, user.id) - if (!writeCheck.ok) return writeCheck.response - - const companyId = await requireCompanyId(supabase, user.id) - const { data: agi } = await supabase .from('agi_declarations') .select('id') @@ -62,4 +50,6 @@ export async function POST( } return NextResponse.json({ data: { ok: true } }) -} + }, + { requireWrite: true }, +) diff --git a/app/api/skatteverket/tax-payments/[period]/payment-file/__tests__/route.test.ts b/app/api/skatteverket/tax-payments/[period]/payment-file/__tests__/route.test.ts new file mode 100644 index 00000000..0ec8d10a --- /dev/null +++ b/app/api/skatteverket/tax-payments/[period]/payment-file/__tests__/route.test.ts @@ -0,0 +1,98 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { NextResponse } from 'next/server' +import { + createMockRequest, + createMockRouteParams, + createQueuedMockSupabase, +} from '@/tests/helpers' + +const { supabase: mockSupabase, enqueue, reset } = createQueuedMockSupabase() + +const requireAuthMock = vi.fn() +vi.mock('@/lib/auth/require-auth', () => ({ + requireAuth: (...args: unknown[]) => requireAuthMock(...args), +})) + +vi.mock('@/lib/init', () => ({ ensureInitialized: vi.fn() })) + +vi.mock('@/lib/company/context', () => ({ + getActiveCompanyId: vi.fn().mockResolvedValue('company-1'), + requireCompanyId: vi.fn().mockResolvedValue('company-1'), +})) + +const requireWriteMock = vi.fn() +vi.mock('@/lib/auth/require-write', () => ({ + requireWritePermission: (...args: unknown[]) => requireWriteMock(...args), +})) + +const mockGenerateBgLb = vi.fn() +vi.mock('@/lib/salary/payment/bg-lb-generator', () => ({ + generateBankgiroPaymentBgLb: (...args: unknown[]) => mockGenerateBgLb(...args), +})) + +vi.mock('@/lib/skatteverket/skattekonto-ocr', () => ({ + generateSkattekontoOcr: vi.fn().mockReturnValue('1234567890'), + SKATTEKONTO_BANKGIRO: '5050-1055', +})) + +vi.mock('@/lib/bankgiro/luhn', () => ({ + validateBankgiroNumber: vi.fn().mockReturnValue(true), +})) + +import { GET } from '../route' + +describe('GET /api/skatteverket/tax-payments/[period]/payment-file', () => { + const mockUser = { id: 'user-1', email: 'test@test.se' } + + beforeEach(() => { + vi.clearAllMocks() + reset() + requireAuthMock.mockResolvedValue({ user: mockUser, supabase: mockSupabase, error: null }) + requireWriteMock.mockResolvedValue({ ok: true }) + mockGenerateBgLb.mockReturnValue({ content: 'LB-FILE', filename: 'skatt-2026-04.txt' }) + }) + + it('returns 401 when not authenticated', async () => { + requireAuthMock.mockResolvedValue({ + user: null, + supabase: mockSupabase, + error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }), + }) + + const response = await GET( + createMockRequest('/api/skatteverket/tax-payments/2026-04/payment-file'), + createMockRouteParams({ period: '2026-04' }), + ) + expect(response.status).toBe(401) + }) + + it('returns 403 for a viewer without write permission', async () => { + requireWriteMock.mockResolvedValue({ + ok: false, + response: NextResponse.json({ error: 'Forbidden' }, { status: 403 }), + }) + + const response = await GET( + createMockRequest('/api/skatteverket/tax-payments/2026-04/payment-file'), + createMockRouteParams({ period: '2026-04' }), + ) + expect(response.status).toBe(403) + }) + + it('generates the LB file (happy path)', async () => { + enqueue({ data: { id: 'agi-1', total_tax: 1000, total_avgifter: 500 } }) // agi + enqueue({ data: { name: 'Test AB', org_number: '5566778899' } }) // companies + enqueue({ data: { bankgiro: '123-4567' } }) // company_settings + enqueue({ data: null, error: null }) // update tax_payment_file_generated_at + + const response = await GET( + createMockRequest('/api/skatteverket/tax-payments/2026-04/payment-file'), + createMockRouteParams({ period: '2026-04' }), + ) + + expect(response.status).toBe(200) + expect(response.headers.get('Content-Type')).toBe('text/plain; charset=iso-8859-1') + expect(response.headers.get('Content-Disposition')).toContain('skatt-2026-04.txt') + expect(mockGenerateBgLb).toHaveBeenCalledTimes(1) + }) +}) diff --git a/app/api/skatteverket/tax-payments/[period]/payment-file/route.ts b/app/api/skatteverket/tax-payments/[period]/payment-file/route.ts index bde29d7d..8e5a3926 100644 --- a/app/api/skatteverket/tax-payments/[period]/payment-file/route.ts +++ b/app/api/skatteverket/tax-payments/[period]/payment-file/route.ts @@ -1,8 +1,6 @@ -import { createClient } from '@/lib/supabase/server' import { NextResponse } from 'next/server' import { ensureInitialized } from '@/lib/init' -import { requireCompanyId } from '@/lib/company/context' -import { requireWritePermission } from '@/lib/auth/require-write' +import { withRouteContext } from '@/lib/api/with-route-context' import { generateBankgiroPaymentBgLb } from '@/lib/salary/payment/bg-lb-generator' import { generateSkattekontoOcr, SKATTEKONTO_BANKGIRO } from '@/lib/skatteverket/skattekonto-ocr' import { validateBankgiroNumber } from '@/lib/bankgiro/luhn' @@ -18,11 +16,14 @@ ensureInitialized() * * Per BFL: Generated payment file is räkenskapsinformation linked to the * salary journal entry. Subject to 7-year retention. + * + * requireWrite: this GET mutates state (stamps tax_payment_file_generated_at + * on the AGI declaration), so it retains the non-viewer role gate the + * hand-rolled version enforced. */ -export async function GET( - request: Request, - { params }: { params: Promise<{ period: string }> } -) { +export const GET = withRouteContext<{ params: Promise<{ period: string }> }>( + 'tax_payment.payment_file', + async (request, { supabase, companyId }, { params }) => { const { period } = await params const periodMatch = /^(\d{4})-(\d{2})$/.exec(period) if (!periodMatch) { @@ -34,15 +35,6 @@ export async function GET( const periodYear = parseInt(periodMatch[1], 10) const periodMonth = parseInt(periodMatch[2], 10) - const supabase = await createClient() - const { data: { user } } = await supabase.auth.getUser() - if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - - const writeCheck = await requireWritePermission(supabase, user.id) - if (!writeCheck.ok) return writeCheck.response - - const companyId = await requireCompanyId(supabase, user.id) - const { data: agi } = await supabase .from('agi_declarations') .select('id, total_tax, total_avgifter') @@ -145,7 +137,9 @@ export async function GET( 'Content-Disposition': `attachment; filename="${result.filename}"`, }, }) -} + }, + { requireWrite: true }, +) /** * Tax payment deadline = the 12th of the month *following* the AGI period. diff --git a/app/api/skatteverket/tax-payments/[period]/route.ts b/app/api/skatteverket/tax-payments/[period]/route.ts index 2e911d92..c0d3d924 100644 --- a/app/api/skatteverket/tax-payments/[period]/route.ts +++ b/app/api/skatteverket/tax-payments/[period]/route.ts @@ -1,7 +1,6 @@ -import { createClient } from '@/lib/supabase/server' import { NextResponse } from 'next/server' import { ensureInitialized } from '@/lib/init' -import { requireCompanyId } from '@/lib/company/context' +import { withRouteContext } from '@/lib/api/with-route-context' ensureInitialized() @@ -12,10 +11,9 @@ ensureInitialized() * paid at, totals) so the UI can render the TaxPaymentPanel without * round-tripping to load the full declaration. */ -export async function GET( - request: Request, - { params }: { params: Promise<{ period: string }> } -) { +export const GET = withRouteContext<{ params: Promise<{ period: string }> }>( + 'tax_payment.status', + async (request, { supabase, companyId }, { params }) => { const { period } = await params const periodMatch = /^(\d{4})-(\d{2})$/.exec(period) if (!periodMatch) { @@ -27,12 +25,6 @@ export async function GET( const periodYear = parseInt(periodMatch[1], 10) const periodMonth = parseInt(periodMatch[2], 10) - const supabase = await createClient() - const { data: { user } } = await supabase.auth.getUser() - if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - - const companyId = await requireCompanyId(supabase, user.id) - const { data: agi } = await supabase .from('agi_declarations') .select('total_tax, total_avgifter, tax_payment_file_generated_at, tax_payment_file_format, tax_paid_at') @@ -46,4 +38,5 @@ export async function GET( } return NextResponse.json({ data: agi }) -} + }, +) diff --git a/app/api/supplier-invoices/[id]/__tests__/route.test.ts b/app/api/supplier-invoices/[id]/__tests__/route.test.ts index fbdc7b33..256ef9bf 100644 --- a/app/api/supplier-invoices/[id]/__tests__/route.test.ts +++ b/app/api/supplier-invoices/[id]/__tests__/route.test.ts @@ -1,4 +1,5 @@ import { describe, it, expect, vi, beforeEach } from 'vitest' +import { NextResponse } from 'next/server' import { createMockRequest, parseJsonResponse, @@ -7,8 +8,10 @@ import { } from '@/tests/helpers' const { supabase: mockSupabase, enqueue, reset } = createQueuedMockSupabase() -vi.mock('@/lib/supabase/server', () => ({ - createClient: () => Promise.resolve(mockSupabase), + +const requireAuthMock = vi.fn() +vi.mock('@/lib/auth/require-auth', () => ({ + requireAuth: (...args: unknown[]) => requireAuthMock(...args), })) vi.mock('@/lib/company/context', () => ({ @@ -28,7 +31,7 @@ describe('DELETE /api/supplier-invoices/[id]', () => { beforeEach(() => { vi.clearAllMocks() reset() - mockSupabase.auth.getUser.mockResolvedValue({ data: { user: mockUser } }) + requireAuthMock.mockResolvedValue({ user: mockUser, supabase: mockSupabase, error: null }) }) function deleteRequest() { @@ -39,7 +42,11 @@ describe('DELETE /api/supplier-invoices/[id]', () => { } it('returns 401 when not authenticated', async () => { - mockSupabase.auth.getUser.mockResolvedValue({ data: { user: null } }) + requireAuthMock.mockResolvedValue({ + user: null, + supabase: mockSupabase, + error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }), + }) const response = await deleteRequest() expect(response.status).toBe(401) diff --git a/app/api/supplier-invoices/[id]/route.ts b/app/api/supplier-invoices/[id]/route.ts index f1d19e77..a49a8967 100644 --- a/app/api/supplier-invoices/[id]/route.ts +++ b/app/api/supplier-invoices/[id]/route.ts @@ -1,29 +1,14 @@ -import { createClient } from '@/lib/supabase/server' import { NextResponse } from 'next/server' +import { withRouteContext } from '@/lib/api/with-route-context' import { validateBody } from '@/lib/api/validate' import { UpdateSupplierInvoiceSchema } from '@/lib/api/schemas' -import { requireCompanyId } from '@/lib/company/context' -import { requireWritePermission } from '@/lib/auth/require-write' import { errorResponseFromCode } from '@/lib/errors/get-structured-error' -import { createLogger } from '@/lib/logger' -const log = createLogger('api.supplier_invoices.id') - -export async function GET( - _request: Request, - { params }: { params: Promise<{ id: string }> } -) { - const supabase = await createClient() +export const GET = withRouteContext<{ params: Promise<{ id: string }> }>( + 'supplier_invoice.get', + async (_request, { supabase, companyId }, { params }) => { const { id } = await params - const { data: { user } } = await supabase.auth.getUser() - - if (!user) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const companyId = await requireCompanyId(supabase, user.id) - const { data: invoice, error } = await supabase .from('supplier_invoices') .select( @@ -38,26 +23,14 @@ export async function GET( } return NextResponse.json({ data: invoice }) -} + }, +) -export async function PUT( - request: Request, - { params }: { params: Promise<{ id: string }> } -) { - const supabase = await createClient() +export const PUT = withRouteContext<{ params: Promise<{ id: string }> }>( + 'supplier_invoice.update', + async (request, { supabase, companyId }, { params }) => { const { id } = await params - const { data: { user } } = await supabase.auth.getUser() - - if (!user) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const writeCheck = await requireWritePermission(supabase, user.id) - if (!writeCheck.ok) return writeCheck.response - - const companyId = await requireCompanyId(supabase, user.id) - // Only allow editing registered invoices const { data: existing } = await supabase .from('supplier_invoices') @@ -94,26 +67,15 @@ export async function PUT( } return NextResponse.json({ data }) -} + }, + { requireWrite: true }, +) -export async function DELETE( - _request: Request, - { params }: { params: Promise<{ id: string }> } -) { - const supabase = await createClient() +export const DELETE = withRouteContext<{ params: Promise<{ id: string }> }>( + 'supplier_invoice.delete', + async (_request, { supabase, companyId, log }, { params }) => { const { id } = await params - const { data: { user } } = await supabase.auth.getUser() - - if (!user) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const writeCheck = await requireWritePermission(supabase, user.id) - if (!writeCheck.ok) return writeCheck.response - - const companyId = await requireCompanyId(supabase, user.id) - // Only allow deleting registered invoices without journal entries const { data: existing } = await supabase .from('supplier_invoices') @@ -188,4 +150,6 @@ export async function DELETE( } return NextResponse.json({ success: true }) -} + }, + { requireWrite: true }, +) diff --git a/app/api/supplier-invoices/[id]/uncredit/__tests__/route.test.ts b/app/api/supplier-invoices/[id]/uncredit/__tests__/route.test.ts index 87ebf4c8..8f5c963c 100644 --- a/app/api/supplier-invoices/[id]/uncredit/__tests__/route.test.ts +++ b/app/api/supplier-invoices/[id]/uncredit/__tests__/route.test.ts @@ -1,4 +1,5 @@ import { describe, it, expect, vi, beforeEach } from 'vitest' +import { NextResponse } from 'next/server' import { createMockRequest, parseJsonResponse, @@ -8,8 +9,10 @@ import { } from '@/tests/helpers' const { supabase: mockSupabase, enqueue, reset } = createQueuedMockSupabase() -vi.mock('@/lib/supabase/server', () => ({ - createClient: () => Promise.resolve(mockSupabase), + +const requireAuthMock = vi.fn() +vi.mock('@/lib/auth/require-auth', () => ({ + requireAuth: (...args: unknown[]) => requireAuthMock(...args), })) vi.mock('@/lib/init', () => ({ @@ -43,11 +46,15 @@ describe('POST /api/supplier-invoices/[id]/uncredit', () => { vi.clearAllMocks() reset() eventBus.clear() - mockSupabase.auth.getUser.mockResolvedValue({ data: { user: mockUser } }) + requireAuthMock.mockResolvedValue({ user: mockUser, supabase: mockSupabase, error: null }) }) it('returns 401 when not authenticated', async () => { - mockSupabase.auth.getUser.mockResolvedValue({ data: { user: null } }) + requireAuthMock.mockResolvedValue({ + user: null, + supabase: mockSupabase, + error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }), + }) const request = createMockRequest('/api/supplier-invoices/inv-1/uncredit', { method: 'POST' }) const response = await POST(request, createMockRouteParams({ id: 'inv-1' })) diff --git a/app/api/supplier-invoices/[id]/uncredit/route.ts b/app/api/supplier-invoices/[id]/uncredit/route.ts index 72680cf5..933e05ed 100644 --- a/app/api/supplier-invoices/[id]/uncredit/route.ts +++ b/app/api/supplier-invoices/[id]/uncredit/route.ts @@ -1,7 +1,7 @@ -import { createClient } from '@/lib/supabase/server' import { NextResponse } from 'next/server' import { eventBus } from '@/lib/events' import { ensureInitialized } from '@/lib/init' +import { withRouteContext } from '@/lib/api/with-route-context' import { reverseEntry } from '@/lib/bookkeeping/engine' import { bookkeepingErrorResponse, @@ -9,30 +9,15 @@ import { EntryAlreadyReversedError, } from '@/lib/bookkeeping/errors' import { getErrorMessage } from '@/lib/errors/get-error-message' -import { requireCompanyId } from '@/lib/company/context' -import { requireWritePermission } from '@/lib/auth/require-write' import type { SupplierInvoice, SupplierInvoicePayment } from '@/types' ensureInitialized() -export async function POST( - _request: Request, - { params }: { params: Promise<{ id: string }> } -) { - const supabase = await createClient() +export const POST = withRouteContext<{ params: Promise<{ id: string }> }>( + 'supplier_invoice.uncredit', + async (_request, { supabase, user, companyId }, { params }) => { const { id } = await params - const { data: { user } } = await supabase.auth.getUser() - - if (!user) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const writeCheck = await requireWritePermission(supabase, user.id) - if (!writeCheck.ok) return writeCheck.response - - const companyId = await requireCompanyId(supabase, user.id) - const { data: original, error: fetchError } = await supabase .from('supplier_invoices') .select('*, payments:supplier_invoice_payments(*)') @@ -172,4 +157,6 @@ export async function POST( data: restored, reversal_entry_id: reversalEntryId, }) -} + }, + { requireWrite: true }, +) diff --git a/app/api/support/contact/route.ts b/app/api/support/contact/route.ts index 523d5d58..ca7112d6 100644 --- a/app/api/support/contact/route.ts +++ b/app/api/support/contact/route.ts @@ -1,5 +1,5 @@ -import { createClient } from '@/lib/supabase/server' import { NextResponse } from 'next/server' +import { requireAuth } from '@/lib/auth/require-auth' import { getEmailService } from '@/lib/email/service' import { getSupportRecipientEmail } from '@/lib/support' import { requireCompanyId } from '@/lib/company/context' @@ -13,9 +13,8 @@ function escapeHtml(s: string): string { } export async function POST(request: Request) { - const supabase = await createClient() - const { data: { user } } = await supabase.auth.getUser() - if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + const { user, supabase, error } = await requireAuth() + if (error) return error await requireCompanyId(supabase, user.id) diff --git a/app/api/team/accept/__tests__/route.test.ts b/app/api/team/accept/__tests__/route.test.ts new file mode 100644 index 00000000..3198697e --- /dev/null +++ b/app/api/team/accept/__tests__/route.test.ts @@ -0,0 +1,103 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { NextResponse } from 'next/server' +import { parseJsonResponse, createQueuedMockSupabase } from '@/tests/helpers' + +// team/accept uses the service client for all DB work (invite acceptance can +// run before the user has any company membership). requireAuth only gates the +// caller's identity + MFA. +const { supabase: serviceSupabase, enqueue, reset } = createQueuedMockSupabase() + +const requireAuthMock = vi.fn() +vi.mock('@/lib/auth/require-auth', () => ({ + requireAuth: (...args: unknown[]) => requireAuthMock(...args), +})) + +vi.mock('@/lib/supabase/server', () => ({ + createServiceClient: () => serviceSupabase, +})) + +vi.mock('@/lib/auth/invite-tokens', () => ({ + hashInviteToken: (t: string) => `hash-${t}`, +})) + +import { POST } from '../route' + +const mockUser = { id: 'user-1', email: 'invitee@test.se' } + +function makeReq(body: unknown) { + return new Request('http://localhost/api/team/accept', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }) as never +} + +beforeEach(() => { + vi.clearAllMocks() + reset() + requireAuthMock.mockResolvedValue({ user: mockUser, supabase: serviceSupabase, error: null }) +}) + +describe('POST /api/team/accept', () => { + it('returns 401 when not authenticated', async () => { + requireAuthMock.mockResolvedValue({ + user: null, + supabase: serviceSupabase, + error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }), + }) + const res = await POST(makeReq({ token: 'abc' })) + const { status } = await parseJsonResponse(res) + expect(status).toBe(401) + }) + + it('returns 400 when the token is missing', async () => { + const res = await POST(makeReq({})) + const { status, body } = await parseJsonResponse<{ error: string }>(res) + expect(status).toBe(400) + expect(body.error).toContain('Token') + }) + + it('accepts a valid company invite', async () => { + const future = new Date(Date.now() + 86_400_000).toISOString() + // 1. company_invitations lookup + enqueue({ + data: { + id: 'inv-1', + company_id: 'company-1', + email: 'invitee@test.se', + role: 'member', + status: 'pending', + expires_at: future, + }, + }) + // 2. company_members insert + enqueue({ error: null }) + // 3. user_preferences upsert + enqueue({ error: null }) + // 4. company_invitations update -> accepted + enqueue({ error: null }) + + const res = await POST(makeReq({ token: 'abc' })) + const { status, body } = await parseJsonResponse<{ data: { type: string; companyId: string } }>(res) + expect(status).toBe(200) + expect(body.data).toEqual({ type: 'company', companyId: 'company-1' }) + }) + + it('returns 403 when the invite email does not match the user', async () => { + const future = new Date(Date.now() + 86_400_000).toISOString() + enqueue({ + data: { + id: 'inv-1', + company_id: 'company-1', + email: 'someone-else@test.se', + role: 'member', + status: 'pending', + expires_at: future, + }, + }) + const res = await POST(makeReq({ token: 'abc' })) + const { status, body } = await parseJsonResponse<{ error: string }>(res) + expect(status).toBe(403) + expect(body.error).toContain('matchar inte') + }) +}) diff --git a/app/api/team/accept/route.ts b/app/api/team/accept/route.ts index 8e5fcbb9..70dc640f 100644 --- a/app/api/team/accept/route.ts +++ b/app/api/team/accept/route.ts @@ -1,5 +1,6 @@ -import { createClient, createServiceClient } from '@/lib/supabase/server' +import { createServiceClient } from '@/lib/supabase/server' import { NextResponse, type NextRequest } from 'next/server' +import { requireAuth } from '@/lib/auth/require-auth' import { hashInviteToken } from '@/lib/auth/invite-tokens' /** @@ -54,11 +55,8 @@ export async function GET(request: NextRequest) { * Team invitations are disabled: teams are single-user. */ export async function POST(request: NextRequest) { - const supabase = await createClient() - const { data: { user } } = await supabase.auth.getUser() - if (!user) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } + const { user, error } = await requireAuth() + if (error) return error const body = await request.json() const token = body.token as string diff --git a/app/api/team/members/route.ts b/app/api/team/members/route.ts index 7c247aa9..a3660625 100644 --- a/app/api/team/members/route.ts +++ b/app/api/team/members/route.ts @@ -1,14 +1,19 @@ -import { createClient, createServiceClient } from '@/lib/supabase/server' +import { createServiceClient } from '@/lib/supabase/server' import { NextResponse } from 'next/server' +import { requireAuth } from '@/lib/auth/require-auth' /** * GET /api/team/members * Returns team members (single-user teams, no invitations). + * + * Team-scoped (not company-scoped): a brand-new user with no company must + * still get a valid empty response (ownsCompany: false), so this uses + * requireAuth() directly rather than withRouteContext, which would require an + * active company context. requireAuth still enforces MFA (AAL2) on hosted. */ export async function GET() { - const supabase = await createClient() - const { data: { user } } = await supabase.auth.getUser() - if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + const { user, error } = await requireAuth() + if (error) return error const serviceClient = await createServiceClient() diff --git a/app/api/transactions/[id]/attach-document/__tests__/route.test.ts b/app/api/transactions/[id]/attach-document/__tests__/route.test.ts index 2a565bad..9228a477 100644 --- a/app/api/transactions/[id]/attach-document/__tests__/route.test.ts +++ b/app/api/transactions/[id]/attach-document/__tests__/route.test.ts @@ -1,4 +1,5 @@ import { describe, it, expect, vi, beforeEach } from 'vitest' +import { NextResponse } from 'next/server' import { parseJsonResponse, createMockRouteParams, @@ -6,8 +7,10 @@ import { } from '@/tests/helpers' const { supabase: mockSupabase, enqueue, reset } = createQueuedMockSupabase() -vi.mock('@/lib/supabase/server', () => ({ - createClient: () => Promise.resolve(mockSupabase), + +const requireAuthMock = vi.fn() +vi.mock('@/lib/auth/require-auth', () => ({ + requireAuth: (...args: unknown[]) => requireAuthMock(...args), })) vi.mock('@/lib/company/context', () => ({ @@ -15,8 +18,9 @@ vi.mock('@/lib/company/context', () => ({ getActiveCompanyId: vi.fn().mockResolvedValue('company-1'), })) +const requireWriteMock = vi.fn() vi.mock('@/lib/auth/require-write', () => ({ - requireWritePermission: vi.fn().mockResolvedValue({ ok: true }), + requireWritePermission: (...args: unknown[]) => requireWriteMock(...args), })) vi.mock('@/lib/init', () => ({ @@ -30,7 +34,8 @@ const mockUser = { id: 'user-1', email: 'test@test.se' } beforeEach(() => { vi.clearAllMocks() reset() - mockSupabase.auth.getUser.mockResolvedValue({ data: { user: mockUser } }) + requireAuthMock.mockResolvedValue({ user: mockUser, supabase: mockSupabase }) + requireWriteMock.mockResolvedValue({ ok: true }) }) function makeReq(body: unknown, method: 'POST' | 'DELETE' = 'POST') { @@ -43,13 +48,31 @@ function makeReq(body: unknown, method: 'POST' | 'DELETE' = 'POST') { describe('POST /api/transactions/[id]/attach-document', () => { it('returns 401 when not authenticated', async () => { - mockSupabase.auth.getUser.mockResolvedValue({ data: { user: null } }) + requireAuthMock.mockResolvedValue({ + user: null, + supabase: mockSupabase, + error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }), + }) const res = await POST(makeReq({ document_id: 'doc-1' }), createMockRouteParams({ id: 'tx-1' })) const { status, body } = await parseJsonResponse(res) expect(status).toBe(401) expect(body).toEqual({ error: 'Unauthorized' }) }) + it('returns 403 when the caller is a viewer', async () => { + requireWriteMock.mockResolvedValue({ + ok: false, + response: NextResponse.json({ error: 'Forbidden' }, { status: 403 }), + }) + const res = await POST( + makeReq({ document_id: '11111111-1111-4111-8111-111111111111' }), + createMockRouteParams({ id: 'tx-1' }), + ) + const { status, body } = await parseJsonResponse(res) + expect(status).toBe(403) + expect(body).toEqual({ error: 'Forbidden' }) + }) + it('returns 400 when document_id missing', async () => { const res = await POST(makeReq({}), createMockRouteParams({ id: 'tx-1' })) const { status } = await parseJsonResponse(res) @@ -234,13 +257,28 @@ describe('POST /api/transactions/[id]/attach-document', () => { describe('DELETE /api/transactions/[id]/attach-document', () => { it('returns 401 when not authenticated', async () => { - mockSupabase.auth.getUser.mockResolvedValue({ data: { user: null } }) + requireAuthMock.mockResolvedValue({ + user: null, + supabase: mockSupabase, + error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }), + }) const res = await DELETE(makeReq(null, 'DELETE'), createMockRouteParams({ id: 'tx-1' })) const { status, body } = await parseJsonResponse(res) expect(status).toBe(401) expect(body).toEqual({ error: 'Unauthorized' }) }) + it('returns 403 when the caller is a viewer', async () => { + requireWriteMock.mockResolvedValue({ + ok: false, + response: NextResponse.json({ error: 'Forbidden' }, { status: 403 }), + }) + const res = await DELETE(makeReq(null, 'DELETE'), createMockRouteParams({ id: 'tx-1' })) + const { status, body } = await parseJsonResponse(res) + expect(status).toBe(403) + expect(body).toEqual({ error: 'Forbidden' }) + }) + it('returns 404 when transaction not in company', async () => { enqueue({ data: null, error: null }) // tx fetch const res = await DELETE(makeReq(null, 'DELETE'), createMockRouteParams({ id: 'tx-1' })) diff --git a/app/api/transactions/[id]/attach-document/route.ts b/app/api/transactions/[id]/attach-document/route.ts index 8fe8fc58..e2ca7f83 100644 --- a/app/api/transactions/[id]/attach-document/route.ts +++ b/app/api/transactions/[id]/attach-document/route.ts @@ -1,10 +1,8 @@ -import { createClient } from '@/lib/supabase/server' import { NextResponse } from 'next/server' import { ensureInitialized } from '@/lib/init' +import { withRouteContext } from '@/lib/api/with-route-context' import { validateBody } from '@/lib/api/validate' import { AttachDocumentSchema } from '@/lib/api/schemas' -import { requireCompanyId } from '@/lib/company/context' -import { requireWritePermission } from '@/lib/auth/require-write' import { appendProcessingHistory } from '@/lib/processing-history/append' ensureInitialized() @@ -21,183 +19,175 @@ ensureInitialized() * * Idempotent: overwrites any existing link. */ -export async function POST( - request: Request, - { params }: { params: Promise<{ id: string }> } -) { - const supabase = await createClient() - const { id: transactionId } = await params +export const POST = withRouteContext<{ params: Promise<{ id: string }> }>( + 'transaction.attach_document', + async (request, { supabase, user, companyId }, { params }) => { + const { id: transactionId } = await params - const { data: { user } } = await supabase.auth.getUser() - if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + const validation = await validateBody(request, AttachDocumentSchema) + if (!validation.success) return validation.response + const { document_id } = validation.data - const writeCheck = await requireWritePermission(supabase, user.id) - if (!writeCheck.ok) return writeCheck.response + const { data: transaction, error: txError } = await supabase + .from('transactions') + .select('id, document_id, journal_entry_id') + .eq('id', transactionId) + .eq('company_id', companyId) + .maybeSingle() - const companyId = await requireCompanyId(supabase, user.id) + if (txError || !transaction) { + return NextResponse.json({ error: 'Transaction not found' }, { status: 404 }) + } - const validation = await validateBody(request, AttachDocumentSchema) - if (!validation.success) return validation.response - const { document_id } = validation.data + const previousDocumentId = (transaction.document_id as string | null) ?? null - const { data: transaction, error: txError } = await supabase - .from('transactions') - .select('id, document_id, journal_entry_id') - .eq('id', transactionId) - .eq('company_id', companyId) - .maybeSingle() + const { data: document, error: docError } = await supabase + .from('document_attachments') + .select('id, journal_entry_id') + .eq('id', document_id) + .eq('company_id', companyId) + .maybeSingle() - if (txError || !transaction) { - return NextResponse.json({ error: 'Transaction not found' }, { status: 404 }) - } + if (docError || !document) { + return NextResponse.json({ error: 'Document not found' }, { status: 404 }) + } - const previousDocumentId = (transaction.document_id as string | null) ?? null - - const { data: document, error: docError } = await supabase - .from('document_attachments') - .select('id, journal_entry_id') - .eq('id', document_id) - .eq('company_id', companyId) - .maybeSingle() - - if (docError || !document) { - return NextResponse.json({ error: 'Document not found' }, { status: 404 }) - } - - // A document that already serves as underlag for a DIFFERENT verifikation - // cannot be pinned here: propagating would either corrupt that link or be - // blocked by the document-metadata immutability trigger. Same verifikation - // is fine (idempotent re-attach; propagation below becomes a no-op). - const docJournalEntryId = (document.journal_entry_id as string | null) ?? null - if (docJournalEntryId && docJournalEntryId !== transaction.journal_entry_id) { - return NextResponse.json( - { error: 'Underlaget är redan kopplat till en annan verifikation.' }, - { status: 409 }, - ) - } - - // Race-free read of journal_entry_id: UPDATE ... RETURNING so the value we - // propagate against reflects any concurrent categorize that committed before - // our UPDATE acquired the row lock. Mirrors commitAttachDocumentToTransaction - // in lib/pending-operations/commit.ts so REST and MCP attaches converge. - const { data: postUpdate, error: updateError } = await supabase - .from('transactions') - .update({ document_id }) - .eq('id', transactionId) - .eq('company_id', companyId) - .select('journal_entry_id') - .maybeSingle() - - if (updateError) { - const errMsg = (updateError as { message?: string }).message ?? '' - if (errMsg.includes('BFL_DOCUMENT_IMMUTABILITY')) { + // A document that already serves as underlag for a DIFFERENT verifikation + // cannot be pinned here: propagating would either corrupt that link or be + // blocked by the document-metadata immutability trigger. Same verifikation + // is fine (idempotent re-attach; propagation below becomes a no-op). + const docJournalEntryId = (document.journal_entry_id as string | null) ?? null + if (docJournalEntryId && docJournalEntryId !== transaction.journal_entry_id) { return NextResponse.json( - { - error: - 'Bilagan är kopplad till en bokförd verifikation och kan inte ersättas. Storno verifikationen först.', - }, + { error: 'Underlaget är redan kopplat till en annan verifikation.' }, { status: 409 }, ) } - console.error('[attach-document] Failed to attach:', updateError) - return NextResponse.json({ error: 'Failed to attach document' }, { status: 500 }) - } - if (!postUpdate) { - return NextResponse.json({ error: 'Transaction not found' }, { status: 404 }) - } - // If this document came from an invoice_inbox_items row, mark that row - // as matched so the inbox UI can show it as "Kopplad" + link back to the - // transaction. Best-effort: a failure here must not roll back the - // (compliant) document attach. - // - // The Supabase client resolves with { error } rather than rejecting on - // RLS/DB errors, so we destructure rather than try/catch. - const { error: inboxLinkErr } = await supabase - .from('invoice_inbox_items') - .update({ matched_transaction_id: transactionId }) - .eq('document_id', document_id) - .eq('company_id', companyId) - .is('matched_transaction_id', null) - .is('created_supplier_invoice_id', null) - if (inboxLinkErr) { - console.error('[attach-document] Failed to link inbox item:', inboxLinkErr) - } - - // If the transaction is already booked, propagate the link onto the - // verifikation immediately (BFL 5 kap 6 §: the verifikation must reference - // its underlag). Skipped when the doc already points at this verifikation - // (idempotent re-attach). Mirrors commitAttachDocumentToTransaction. - const journalEntryId = (postUpdate.journal_entry_id as string | null) ?? null - if (journalEntryId && docJournalEntryId !== journalEntryId) { - const { error: linkErr } = await supabase - .from('document_attachments') - .update({ journal_entry_id: journalEntryId }) - .eq('id', document_id) + // Race-free read of journal_entry_id: UPDATE ... RETURNING so the value we + // propagate against reflects any concurrent categorize that committed before + // our UPDATE acquired the row lock. Mirrors commitAttachDocumentToTransaction + // in lib/pending-operations/commit.ts so REST and MCP attaches converge. + const { data: postUpdate, error: updateError } = await supabase + .from('transactions') + .update({ document_id }) + .eq('id', transactionId) .eq('company_id', companyId) - if (linkErr) { - // The enforce_period_lock trigger blocks journal_entry_id writes when - // the target entry sits in a closed/locked period. - const linkMsg = (linkErr as { message?: string }).message ?? '' - if (/locked\/closed fiscal period|Bokföringen är låst/i.test(linkMsg)) { - // Honest about the partial write: the pin on the transaction (and the - // inbox back-link) persisted; only the verifikat link was blocked. + .select('journal_entry_id') + .maybeSingle() + + if (updateError) { + const errMsg = (updateError as { message?: string }).message ?? '' + if (errMsg.includes('BFL_DOCUMENT_IMMUTABILITY')) { return NextResponse.json( { error: - 'Bilagan kopplades till transaktionen men verifikationens period är låst: den kunde inte länkas till verifikationen.', + 'Bilagan är kopplad till en bokförd verifikation och kan inte ersättas. Storno verifikationen först.', }, { status: 409 }, ) } - // Surface the propagation failure rather than logging-and-continuing: - // a "succeeded" attach that left document_attachments.journal_entry_id - // null would be a silent compliance gap. A retry is idempotent. - console.error('[attach-document] Failed to propagate to journal entry:', linkErr) - return NextResponse.json( - { - error: - 'Bilagan kopplades till transaktionen men kunde inte länkas till verifikationen. Försök igen: operationen är idempotent.', - }, - { status: 500 }, - ) + console.error('[attach-document] Failed to attach:', updateError) + return NextResponse.json({ error: 'Failed to attach document' }, { status: 500 }) } - } - - // Rättelse audit trail (BFL 5 kap 5 §): record swaps where a non-null doc - // was replaced. Best-effort: a logging failure must not roll back the - // (compliant) attach. - if (previousDocumentId && previousDocumentId !== document_id) { - try { - await appendProcessingHistory({ - companyId, - correlationId: transactionId, - aggregateType: 'BankTransaction', - aggregateId: transactionId, - eventType: 'TransactionDocumentReplaced', - payload: { - transaction_id: transactionId, - previous_document_id: previousDocumentId, - new_document_id: document_id, - journal_entry_id: journalEntryId, - }, - actor: { type: 'user', id: user.id }, - occurredAt: new Date(), - }) - } catch (logErr) { - console.error('[attach-document] Failed to append rättelse event:', logErr) + if (!postUpdate) { + return NextResponse.json({ error: 'Transaction not found' }, { status: 404 }) } - } - return NextResponse.json({ - data: { - transaction_id: transactionId, - document_id, - previous_document_id: previousDocumentId, - journal_entry_id: journalEntryId, - }, - }) -} + // If this document came from an invoice_inbox_items row, mark that row + // as matched so the inbox UI can show it as "Kopplad" + link back to the + // transaction. Best-effort: a failure here must not roll back the + // (compliant) document attach. + // + // The Supabase client resolves with { error } rather than rejecting on + // RLS/DB errors, so we destructure rather than try/catch. + const { error: inboxLinkErr } = await supabase + .from('invoice_inbox_items') + .update({ matched_transaction_id: transactionId }) + .eq('document_id', document_id) + .eq('company_id', companyId) + .is('matched_transaction_id', null) + .is('created_supplier_invoice_id', null) + if (inboxLinkErr) { + console.error('[attach-document] Failed to link inbox item:', inboxLinkErr) + } + + // If the transaction is already booked, propagate the link onto the + // verifikation immediately (BFL 5 kap 6 §: the verifikation must reference + // its underlag). Skipped when the doc already points at this verifikation + // (idempotent re-attach). Mirrors commitAttachDocumentToTransaction. + const journalEntryId = (postUpdate.journal_entry_id as string | null) ?? null + if (journalEntryId && docJournalEntryId !== journalEntryId) { + const { error: linkErr } = await supabase + .from('document_attachments') + .update({ journal_entry_id: journalEntryId }) + .eq('id', document_id) + .eq('company_id', companyId) + if (linkErr) { + // The enforce_period_lock trigger blocks journal_entry_id writes when + // the target entry sits in a closed/locked period. + const linkMsg = (linkErr as { message?: string }).message ?? '' + if (/locked\/closed fiscal period|Bokföringen är låst/i.test(linkMsg)) { + // Honest about the partial write: the pin on the transaction (and the + // inbox back-link) persisted; only the verifikat link was blocked. + return NextResponse.json( + { + error: + 'Bilagan kopplades till transaktionen men verifikationens period är låst: den kunde inte länkas till verifikationen.', + }, + { status: 409 }, + ) + } + // Surface the propagation failure rather than logging-and-continuing: + // a "succeeded" attach that left document_attachments.journal_entry_id + // null would be a silent compliance gap. A retry is idempotent. + console.error('[attach-document] Failed to propagate to journal entry:', linkErr) + return NextResponse.json( + { + error: + 'Bilagan kopplades till transaktionen men kunde inte länkas till verifikationen. Försök igen: operationen är idempotent.', + }, + { status: 500 }, + ) + } + } + + // Rättelse audit trail (BFL 5 kap 5 §): record swaps where a non-null doc + // was replaced. Best-effort: a logging failure must not roll back the + // (compliant) attach. + if (previousDocumentId && previousDocumentId !== document_id) { + try { + await appendProcessingHistory({ + companyId, + correlationId: transactionId, + aggregateType: 'BankTransaction', + aggregateId: transactionId, + eventType: 'TransactionDocumentReplaced', + payload: { + transaction_id: transactionId, + previous_document_id: previousDocumentId, + new_document_id: document_id, + journal_entry_id: journalEntryId, + }, + actor: { type: 'user', id: user.id }, + occurredAt: new Date(), + }) + } catch (logErr) { + console.error('[attach-document] Failed to append rättelse event:', logErr) + } + } + + return NextResponse.json({ + data: { + transaction_id: transactionId, + document_id, + previous_document_id: previousDocumentId, + journal_entry_id: journalEntryId, + }, + }) + }, + { requireWrite: true }, +) /** * DELETE /api/transactions/[id]/attach-document @@ -208,75 +198,67 @@ export async function POST( * räkenskapsinformation immutability): at that point the doc is the * verifikation's underlag and can only be undone by reversing the entry. */ -export async function DELETE( - _request: Request, - { params }: { params: Promise<{ id: string }> } -) { - const supabase = await createClient() - const { id: transactionId } = await params +export const DELETE = withRouteContext<{ params: Promise<{ id: string }> }>( + 'transaction.detach_document', + async (_request, { supabase, companyId }, { params }) => { + const { id: transactionId } = await params - const { data: { user } } = await supabase.auth.getUser() - if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - - const writeCheck = await requireWritePermission(supabase, user.id) - if (!writeCheck.ok) return writeCheck.response - - const companyId = await requireCompanyId(supabase, user.id) - - const { data: tx, error: fetchError } = await supabase - .from('transactions') - .select('id, document_id') - .eq('id', transactionId) - .eq('company_id', companyId) - .maybeSingle() - - if (fetchError || !tx) { - return NextResponse.json({ error: 'Transaction not found' }, { status: 404 }) - } - - if (tx.document_id) { - const { data: doc } = await supabase - .from('document_attachments') - .select('journal_entry_id') - .eq('id', tx.document_id) + const { data: tx, error: fetchError } = await supabase + .from('transactions') + .select('id, document_id') + .eq('id', transactionId) .eq('company_id', companyId) .maybeSingle() - if (doc?.journal_entry_id) { - return NextResponse.json( - { - error: - 'Bilagan är kopplad till en bokförd verifikation och kan inte tas bort. Storno verifikationen först.', - }, - { status: 409 }, - ) + + if (fetchError || !tx) { + return NextResponse.json({ error: 'Transaction not found' }, { status: 404 }) } - } - const { error: updateError } = await supabase - .from('transactions') - .update({ document_id: null }) - .eq('id', transactionId) - .eq('company_id', companyId) - - if (updateError) { - // The enforce_transactions_document_immutability trigger raises a - // P0001 exception with a stable BFL_DOCUMENT_IMMUTABILITY: prefix when the - // previously-attached doc has already become räkenskapsinformation. - // Match on the prefix (not on the generic SQLSTATE) so unrelated future - // exceptions don't get translated into the Swedish underlag message. - const errMsg = (updateError as { message?: string }).message ?? '' - if (errMsg.includes('BFL_DOCUMENT_IMMUTABILITY')) { - return NextResponse.json( - { - error: - 'Bilagan är kopplad till en bokförd verifikation och kan inte tas bort. Storno verifikationen först.', - }, - { status: 409 }, - ) + if (tx.document_id) { + const { data: doc } = await supabase + .from('document_attachments') + .select('journal_entry_id') + .eq('id', tx.document_id) + .eq('company_id', companyId) + .maybeSingle() + if (doc?.journal_entry_id) { + return NextResponse.json( + { + error: + 'Bilagan är kopplad till en bokförd verifikation och kan inte tas bort. Storno verifikationen först.', + }, + { status: 409 }, + ) + } } - console.error('[attach-document] Failed to detach:', updateError) - return NextResponse.json({ error: 'Failed to detach document' }, { status: 500 }) - } - return NextResponse.json({ data: { transaction_id: transactionId, document_id: null } }) -} + const { error: updateError } = await supabase + .from('transactions') + .update({ document_id: null }) + .eq('id', transactionId) + .eq('company_id', companyId) + + if (updateError) { + // The enforce_transactions_document_immutability trigger raises a + // P0001 exception with a stable BFL_DOCUMENT_IMMUTABILITY: prefix when the + // previously-attached doc has already become räkenskapsinformation. + // Match on the prefix (not on the generic SQLSTATE) so unrelated future + // exceptions don't get translated into the Swedish underlag message. + const errMsg = (updateError as { message?: string }).message ?? '' + if (errMsg.includes('BFL_DOCUMENT_IMMUTABILITY')) { + return NextResponse.json( + { + error: + 'Bilagan är kopplad till en bokförd verifikation och kan inte tas bort. Storno verifikationen först.', + }, + { status: 409 }, + ) + } + console.error('[attach-document] Failed to detach:', updateError) + return NextResponse.json({ error: 'Failed to detach document' }, { status: 500 }) + } + + return NextResponse.json({ data: { transaction_id: transactionId, document_id: null } }) + }, + { requireWrite: true }, +) diff --git a/app/api/transactions/[id]/book/__tests__/route.test.ts b/app/api/transactions/[id]/book/__tests__/route.test.ts index 272c3316..3bad15d4 100644 --- a/app/api/transactions/[id]/book/__tests__/route.test.ts +++ b/app/api/transactions/[id]/book/__tests__/route.test.ts @@ -1,4 +1,5 @@ import { describe, it, expect, vi, beforeEach } from 'vitest' +import { NextResponse } from 'next/server' import { createMockRequest, parseJsonResponse, @@ -10,8 +11,10 @@ import { import { eventBus } from '@/lib/events' const { supabase: mockSupabase, enqueue, reset } = createQueuedMockSupabase() -vi.mock('@/lib/supabase/server', () => ({ - createClient: () => Promise.resolve(mockSupabase), + +const requireAuthMock = vi.fn() +vi.mock('@/lib/auth/require-auth', () => ({ + requireAuth: (...args: unknown[]) => requireAuthMock(...args), })) vi.mock('@/lib/init', () => ({ @@ -23,8 +26,9 @@ vi.mock('@/lib/company/context', () => ({ getActiveCompanyId: vi.fn().mockResolvedValue('company-1'), })) +const requireWriteMock = vi.fn() vi.mock('@/lib/auth/require-write', () => ({ - requireWritePermission: vi.fn().mockResolvedValue({ ok: true }), + requireWritePermission: (...args: unknown[]) => requireWriteMock(...args), })) const mockCreateJournalEntry = vi.fn() @@ -70,14 +74,19 @@ describe('POST /api/transactions/[id]/book', () => { vi.clearAllMocks() reset() eventBus.clear() - mockSupabase.auth.getUser.mockResolvedValue({ data: { user: mockUser } }) + requireAuthMock.mockResolvedValue({ user: mockUser, supabase: mockSupabase }) + requireWriteMock.mockResolvedValue({ ok: true }) // No booking-duplicate by default; guard tests override per-case. mockDetectDup.mockResolvedValue(null) mockAppendProcessingHistory.mockResolvedValue('evt-1') }) it('returns 401 when not authenticated', async () => { - mockSupabase.auth.getUser.mockResolvedValue({ data: { user: null } }) + requireAuthMock.mockResolvedValue({ + user: null, + supabase: mockSupabase, + error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }), + }) const request = createMockRequest('/api/transactions/tx-1/book', { method: 'POST', @@ -90,6 +99,24 @@ describe('POST /api/transactions/[id]/book', () => { expect(body).toEqual({ error: 'Unauthorized' }) }) + it('returns 403 when the caller is a viewer', async () => { + requireWriteMock.mockResolvedValue({ + ok: false, + response: NextResponse.json({ error: 'Forbidden' }, { status: 403 }), + }) + + const request = createMockRequest('/api/transactions/tx-1/book', { + method: 'POST', + body: validBody, + }) + const response = await POST(request, createMockRouteParams({ id: 'tx-1' })) + const { status, body } = await parseJsonResponse(response) + + expect(status).toBe(403) + expect(body).toEqual({ error: 'Forbidden' }) + expect(mockCreateJournalEntry).not.toHaveBeenCalled() + }) + it('returns 400 when missing required fields', async () => { const request = createMockRequest('/api/transactions/tx-1/book', { method: 'POST', diff --git a/app/api/transactions/[id]/book/route.ts b/app/api/transactions/[id]/book/route.ts index c3860b44..f00e408b 100644 --- a/app/api/transactions/[id]/book/route.ts +++ b/app/api/transactions/[id]/book/route.ts @@ -1,201 +1,187 @@ -import { createClient } from '@/lib/supabase/server' import { NextResponse } from 'next/server' import { eventBus } from '@/lib/events' import { ensureInitialized } from '@/lib/init' +import { withRouteContext } from '@/lib/api/with-route-context' import { createJournalEntry } from '@/lib/bookkeeping/engine' import { bookkeepingErrorResponse } from '@/lib/bookkeeping/errors' import { validateBody } from '@/lib/api/validate' import { BookTransactionSchema } from '@/lib/api/schemas' -import { requireCompanyId } from '@/lib/company/context' -import { requireWritePermission } from '@/lib/auth/require-write' import { detectBookingDuplicate } from '@/lib/transactions/booking-duplicate-detection' import { errorResponseFromCode } from '@/lib/errors/get-structured-error' -import { createLogger } from '@/lib/logger' import { appendProcessingHistory } from '@/lib/processing-history/append' import type { Transaction } from '@/types' ensureInitialized() -export async function POST( - request: Request, - { params }: { params: Promise<{ id: string }> } -) { - const supabase = await createClient() - const { id } = await params +export const POST = withRouteContext<{ params: Promise<{ id: string }> }>( + 'transaction.book', + async (request, { supabase, user, companyId, log }, { params }) => { + const { id } = await params - const { data: { user } } = await supabase.auth.getUser() + const validation = await validateBody(request, BookTransactionSchema) + if (!validation.success) return validation.response + const { fiscal_period_id, entry_date, description, lines, force, expected_duplicate_transaction_id, expected_duplicate_journal_entry_id } = validation.data - if (!user) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } + // Fetch transaction (validates ownership) + const { data: transaction, error: fetchError } = await supabase + .from('transactions') + .select('*') + .eq('id', id) + .eq('company_id', companyId) + .single() - const writeCheck = await requireWritePermission(supabase, user.id) - if (!writeCheck.ok) return writeCheck.response + if (fetchError || !transaction) { + return NextResponse.json({ error: 'Transaction not found' }, { status: 404 }) + } - const companyId = await requireCompanyId(supabase, user.id) + // Reject if already booked + if (transaction.journal_entry_id) { + return NextResponse.json( + { error: 'Transaction already has a journal entry' }, + { status: 409 } + ) + } - const validation = await validateBody(request, BookTransactionSchema) - if (!validation.success) return validation.response - const { fiscal_period_id, entry_date, description, lines, force, expected_duplicate_transaction_id, expected_duplicate_journal_entry_id } = validation.data - - // Fetch transaction (validates ownership) - const { data: transaction, error: fetchError } = await supabase - .from('transactions') - .select('*') - .eq('id', id) - .eq('company_id', companyId) - .single() - - if (fetchError || !transaction) { - return NextResponse.json({ error: 'Transaction not found' }, { status: 404 }) - } - - // Reject if already booked - if (transaction.journal_entry_id) { - return NextResponse.json( - { error: 'Transaction already has a journal entry' }, - { status: 409 } - ) - } - - // Booking-time duplicate guard: if another transaction with the same - // date+amount+account is already booked, booking this one would double-count - // one real event (two verifikationer: felaktig bokföring per BFL). Warn; the - // user confirms with force=true bound to the reviewed sibling. Mirrors the - // match-invoice soft-duplicate guard. - const dupLog = createLogger('transactions.book', { companyId, userId: user.id }) - try { - const candidate = await detectBookingDuplicate(supabase, companyId, { - id, - date: transaction.date, - amount: transaction.amount, - cash_account_id: transaction.cash_account_id ?? null, - }) - if (!force) { - if (candidate) { - return errorResponseFromCode('TRANSACTION_BOOK_POSSIBLE_DUPLICATE', dupLog, { - details: { candidate }, + // Booking-time duplicate guard: if another transaction with the same + // date+amount+account is already booked, booking this one would double-count + // one real event (two verifikationer: felaktig bokföring per BFL). Warn; the + // user confirms with force=true bound to the reviewed sibling. Mirrors the + // match-invoice soft-duplicate guard. + const dupLog = log + try { + const candidate = await detectBookingDuplicate(supabase, companyId, { + id, + date: transaction.date, + amount: transaction.amount, + cash_account_id: transaction.cash_account_id ?? null, + }) + if (!force) { + if (candidate) { + return errorResponseFromCode('TRANSACTION_BOOK_POSSIBLE_DUPLICATE', dupLog, { + details: { candidate }, + }) + } + } else if ( + // force=true is bound to the reviewed candidate. A sibling-transaction + // candidate carries a transaction_id; a ledger-only voucher candidate does + // not, so both are bound by journal_entry_id. Either echoed id confirms. + // Re-detect and refuse the bypass unless it still matches, so a guessed id + // can't wave the guard. + !candidate || + !( + (candidate.journal_entry_id && candidate.journal_entry_id === expected_duplicate_journal_entry_id) || + (candidate.transaction_id && candidate.transaction_id === expected_duplicate_transaction_id) + ) + ) { + return errorResponseFromCode('TRANSACTION_BOOK_FORCE_CANDIDATE_MISMATCH', dupLog, { + details: { + expected_duplicate_transaction_id: expected_duplicate_transaction_id ?? null, + expected_duplicate_journal_entry_id: expected_duplicate_journal_entry_id ?? null, + detected_transaction_id: candidate?.transaction_id ?? null, + detected_journal_entry_id: candidate?.journal_entry_id ?? null, + }, + }) + } else { + dupLog.warn('booking-time duplicate guard bypassed', { + reason: 'force=true', + transactionId: id, + dismissedTransactionId: candidate.transaction_id, + }) + // Persist the dismissal to behandlingshistorik (BFNAR 2013:2 kap 8): the + // decision to book over a DETECTED possible double-booking is a + // bookkeeping act that must leave a durable, queryable record; a warn in + // the application log is ephemeral and does not satisfy the requirement. + // Best-effort: a logging failure must never block a legitimate booking. + try { + await appendProcessingHistory({ + companyId, + correlationId: id, + aggregateType: 'BankTransaction', + aggregateId: id, + eventType: 'BankTransactionDuplicateDismissed', + payload: { + transaction_id: id, + dismissed_transaction_id: candidate.transaction_id, + dismissed_journal_entry_id: candidate.journal_entry_id, + amount_ore: Math.round(candidate.amount * 100), + entry_date: candidate.entry_date, + }, + actor: { type: 'user', id: user.id }, + occurredAt: new Date(), + }) + } catch (logErr) { + dupLog.error('failed to append duplicate-dismissal behandlingshistorik', logErr as Error) + } + } + } catch (err) { + // Detection is fail-open for the non-force path; force requires a confirmed + // candidate, so a detection failure under force is rejected as a mismatch. + if (force) { + return errorResponseFromCode('TRANSACTION_BOOK_FORCE_CANDIDATE_MISMATCH', dupLog, { + details: { detection_failed: true }, }) } - } else if ( - // force=true is bound to the reviewed candidate. A sibling-transaction - // candidate carries a transaction_id; a ledger-only voucher candidate does - // not, so both are bound by journal_entry_id. Either echoed id confirms. - // Re-detect and refuse the bypass unless it still matches, so a guessed id - // can't wave the guard. - !candidate || - !( - (candidate.journal_entry_id && candidate.journal_entry_id === expected_duplicate_journal_entry_id) || - (candidate.transaction_id && candidate.transaction_id === expected_duplicate_transaction_id) + dupLog.warn('booking-time duplicate detection failed (continuing)', err as Error) + } + + // Create journal entry via the engine + let journalEntry + try { + journalEntry = await createJournalEntry(supabase, companyId, user.id, { + fiscal_period_id, + entry_date, + description, + source_type: 'bank_transaction', + source_id: id, + lines, + }) + } catch (err) { + const typed = bookkeepingErrorResponse(err) + if (typed) return typed + return NextResponse.json( + { error: err instanceof Error ? err.message : 'Failed to create journal entry' }, + { status: 400 } ) - ) { - return errorResponseFromCode('TRANSACTION_BOOK_FORCE_CANDIDATE_MISMATCH', dupLog, { - details: { - expected_duplicate_transaction_id: expected_duplicate_transaction_id ?? null, - expected_duplicate_journal_entry_id: expected_duplicate_journal_entry_id ?? null, - detected_transaction_id: candidate?.transaction_id ?? null, - detected_journal_entry_id: candidate?.journal_entry_id ?? null, + } + + // Link transaction to the journal entry + const { error: updateError } = await supabase + .from('transactions') + .update({ + journal_entry_id: journalEntry.id, + is_business: true, + category: 'uncategorized', + }) + .eq('id', id) + + if (updateError) { + return NextResponse.json( + { error: 'Failed to update transaction' }, + { status: 500 } + ) + } + + // Emit event (non-blocking) + try { + await eventBus.emit({ + type: 'transaction.categorized', + payload: { + transaction: transaction as Transaction, + account: lines[0]?.account_number || '', + taxCode: '', + userId: user.id, + companyId, }, }) - } else { - dupLog.warn('booking-time duplicate guard bypassed', { - reason: 'force=true', - transactionId: id, - dismissedTransactionId: candidate.transaction_id, - }) - // Persist the dismissal to behandlingshistorik (BFNAR 2013:2 kap 8): the - // decision to book over a DETECTED possible double-booking is a - // bookkeeping act that must leave a durable, queryable record; a warn in - // the application log is ephemeral and does not satisfy the requirement. - // Best-effort: a logging failure must never block a legitimate booking. - try { - await appendProcessingHistory({ - companyId, - correlationId: id, - aggregateType: 'BankTransaction', - aggregateId: id, - eventType: 'BankTransactionDuplicateDismissed', - payload: { - transaction_id: id, - dismissed_transaction_id: candidate.transaction_id, - dismissed_journal_entry_id: candidate.journal_entry_id, - amount_ore: Math.round(candidate.amount * 100), - entry_date: candidate.entry_date, - }, - actor: { type: 'user', id: user.id }, - occurredAt: new Date(), - }) - } catch (logErr) { - dupLog.error('failed to append duplicate-dismissal behandlingshistorik', logErr as Error) - } + } catch { + // Non-critical } - } catch (err) { - // Detection is fail-open for the non-force path; force requires a confirmed - // candidate, so a detection failure under force is rejected as a mismatch. - if (force) { - return errorResponseFromCode('TRANSACTION_BOOK_FORCE_CANDIDATE_MISMATCH', dupLog, { - details: { detection_failed: true }, - }) - } - dupLog.warn('booking-time duplicate detection failed (continuing)', err as Error) - } - // Create journal entry via the engine - let journalEntry - try { - journalEntry = await createJournalEntry(supabase, companyId, user.id, { - fiscal_period_id, - entry_date, - description, - source_type: 'bank_transaction', - source_id: id, - lines, - }) - } catch (err) { - const typed = bookkeepingErrorResponse(err) - if (typed) return typed - return NextResponse.json( - { error: err instanceof Error ? err.message : 'Failed to create journal entry' }, - { status: 400 } - ) - } - - // Link transaction to the journal entry - const { error: updateError } = await supabase - .from('transactions') - .update({ + return NextResponse.json({ + data: journalEntry, journal_entry_id: journalEntry.id, - is_business: true, - category: 'uncategorized', + success: true, }) - .eq('id', id) - - if (updateError) { - return NextResponse.json( - { error: 'Failed to update transaction' }, - { status: 500 } - ) - } - - // Emit event (non-blocking) - try { - await eventBus.emit({ - type: 'transaction.categorized', - payload: { - transaction: transaction as Transaction, - account: lines[0]?.account_number || '', - taxCode: '', - userId: user.id, - companyId, - }, - }) - } catch { - // Non-critical - } - - return NextResponse.json({ - data: journalEntry, - journal_entry_id: journalEntry.id, - success: true, - }) -} + }, + { requireWrite: true }, +) diff --git a/app/api/transactions/[id]/ignore/__tests__/route.test.ts b/app/api/transactions/[id]/ignore/__tests__/route.test.ts new file mode 100644 index 00000000..1d7776e0 --- /dev/null +++ b/app/api/transactions/[id]/ignore/__tests__/route.test.ts @@ -0,0 +1,179 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { NextResponse } from 'next/server' +import { + createMockRequest, + parseJsonResponse, + createMockRouteParams, + createQueuedMockSupabase, +} from '@/tests/helpers' + +const { supabase: mockSupabase, enqueue, reset } = createQueuedMockSupabase() + +const requireAuthMock = vi.fn() +vi.mock('@/lib/auth/require-auth', () => ({ + requireAuth: (...args: unknown[]) => requireAuthMock(...args), +})) + +vi.mock('@/lib/company/context', () => ({ + requireCompanyId: vi.fn().mockResolvedValue('company-1'), + getActiveCompanyId: vi.fn().mockResolvedValue('company-1'), +})) + +const requireWriteMock = vi.fn() +vi.mock('@/lib/auth/require-write', () => ({ + requireWritePermission: (...args: unknown[]) => requireWriteMock(...args), +})) + +import { POST, DELETE } from '../route' + +const mockUser = { id: 'user-1', email: 'test@test.se' } + +beforeEach(() => { + vi.clearAllMocks() + reset() + requireAuthMock.mockResolvedValue({ user: mockUser, supabase: mockSupabase }) + requireWriteMock.mockResolvedValue({ ok: true }) +}) + +describe('POST /api/transactions/[id]/ignore', () => { + it('returns 401 when not authenticated', async () => { + requireAuthMock.mockResolvedValue({ + user: null, + supabase: mockSupabase, + error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }), + }) + + const request = createMockRequest('/api/transactions/tx-1/ignore', { method: 'POST' }) + const response = await POST(request, createMockRouteParams({ id: 'tx-1' })) + const { status, body } = await parseJsonResponse(response) + + expect(status).toBe(401) + expect(body).toEqual({ error: 'Unauthorized' }) + }) + + it('returns 403 when the caller is a viewer', async () => { + requireWriteMock.mockResolvedValue({ + ok: false, + response: NextResponse.json({ error: 'Forbidden' }, { status: 403 }), + }) + + const request = createMockRequest('/api/transactions/tx-1/ignore', { method: 'POST' }) + const response = await POST(request, createMockRouteParams({ id: 'tx-1' })) + const { status, body } = await parseJsonResponse(response) + + expect(status).toBe(403) + expect(body).toEqual({ error: 'Forbidden' }) + // No DB read/write happens when the role gate rejects. + expect(mockSupabase.from).not.toHaveBeenCalled() + }) + + it('returns 404 when transaction not found', async () => { + enqueue({ data: null, error: { message: 'Not found' } }) + + const request = createMockRequest('/api/transactions/tx-999/ignore', { method: 'POST' }) + const response = await POST(request, createMockRouteParams({ id: 'tx-999' })) + const { status, body } = await parseJsonResponse(response) + + expect(status).toBe(404) + expect(body).toEqual({ error: 'Transaction not found' }) + }) + + it('returns 409 when the transaction is already booked', async () => { + enqueue({ data: { id: 'tx-1', journal_entry_id: 'je-1', is_ignored: false }, error: null }) + + const request = createMockRequest('/api/transactions/tx-1/ignore', { method: 'POST' }) + const response = await POST(request, createMockRouteParams({ id: 'tx-1' })) + const { status, body } = await parseJsonResponse<{ error: string }>(response) + + expect(status).toBe(409) + expect(body.error).toContain('redan bokförd') + }) + + it('is idempotent when the transaction is already ignored', async () => { + enqueue({ data: { id: 'tx-1', journal_entry_id: null, is_ignored: true }, error: null }) + + const request = createMockRequest('/api/transactions/tx-1/ignore', { method: 'POST' }) + const response = await POST(request, createMockRouteParams({ id: 'tx-1' })) + const { status, body } = await parseJsonResponse(response) + + expect(status).toBe(200) + expect(body).toEqual({ success: true, already_ignored: true }) + }) + + it('marks the transaction ignored (happy path)', async () => { + enqueue({ data: { id: 'tx-1', journal_entry_id: null, is_ignored: false }, error: null }) // fetch + enqueue({ data: null, error: null }) // update + + const request = createMockRequest('/api/transactions/tx-1/ignore', { method: 'POST' }) + const response = await POST(request, createMockRouteParams({ id: 'tx-1' })) + const { status, body } = await parseJsonResponse(response) + + expect(status).toBe(200) + expect(body).toEqual({ success: true }) + }) + + it('returns 500 when the update fails', async () => { + enqueue({ data: { id: 'tx-1', journal_entry_id: null, is_ignored: false }, error: null }) // fetch + enqueue({ data: null, error: { message: 'db down' } }) // update fails + + const request = createMockRequest('/api/transactions/tx-1/ignore', { method: 'POST' }) + const response = await POST(request, createMockRouteParams({ id: 'tx-1' })) + const { status, body } = await parseJsonResponse(response) + + expect(status).toBe(500) + expect(body).toEqual({ error: 'db down' }) + }) +}) + +describe('DELETE /api/transactions/[id]/ignore', () => { + it('returns 401 when not authenticated', async () => { + requireAuthMock.mockResolvedValue({ + user: null, + supabase: mockSupabase, + error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }), + }) + + const request = createMockRequest('/api/transactions/tx-1/ignore', { method: 'DELETE' }) + const response = await DELETE(request, createMockRouteParams({ id: 'tx-1' })) + const { status, body } = await parseJsonResponse(response) + + expect(status).toBe(401) + expect(body).toEqual({ error: 'Unauthorized' }) + }) + + it('returns 403 when the caller is a viewer', async () => { + requireWriteMock.mockResolvedValue({ + ok: false, + response: NextResponse.json({ error: 'Forbidden' }, { status: 403 }), + }) + + const request = createMockRequest('/api/transactions/tx-1/ignore', { method: 'DELETE' }) + const response = await DELETE(request, createMockRouteParams({ id: 'tx-1' })) + const { status, body } = await parseJsonResponse(response) + + expect(status).toBe(403) + expect(body).toEqual({ error: 'Forbidden' }) + }) + + it('clears the ignore flag (happy path)', async () => { + enqueue({ data: null, error: null }) // update + + const request = createMockRequest('/api/transactions/tx-1/ignore', { method: 'DELETE' }) + const response = await DELETE(request, createMockRouteParams({ id: 'tx-1' })) + const { status, body } = await parseJsonResponse(response) + + expect(status).toBe(200) + expect(body).toEqual({ success: true }) + }) + + it('returns 500 when the update fails', async () => { + enqueue({ data: null, error: { message: 'db down' } }) // update fails + + const request = createMockRequest('/api/transactions/tx-1/ignore', { method: 'DELETE' }) + const response = await DELETE(request, createMockRouteParams({ id: 'tx-1' })) + const { status, body } = await parseJsonResponse(response) + + expect(status).toBe(500) + expect(body).toEqual({ error: 'db down' }) + }) +}) diff --git a/app/api/transactions/[id]/ignore/route.ts b/app/api/transactions/[id]/ignore/route.ts index b4be2ea8..93b469cd 100644 --- a/app/api/transactions/[id]/ignore/route.ts +++ b/app/api/transactions/[id]/ignore/route.ts @@ -1,7 +1,5 @@ -import { createClient } from '@/lib/supabase/server' import { NextResponse } from 'next/server' -import { requireCompanyId } from '@/lib/company/context' -import { requireWritePermission } from '@/lib/auth/require-write' +import { withRouteContext } from '@/lib/api/with-route-context' /** * POST /api/transactions/[id]/ignore @@ -15,57 +13,47 @@ import { requireWritePermission } from '@/lib/auth/require-write' * Refuses when the transaction is already booked; once a verifikation exists, * the proper way to revisit it is /uncategorize (storno). */ -export async function POST( - _request: Request, - { params }: { params: Promise<{ id: string }> } -) { - const supabase = await createClient() - const { id } = await params +export const POST = withRouteContext<{ params: Promise<{ id: string }> }>( + 'transaction.ignore', + async (_request, { supabase, companyId }, { params }) => { + const { id } = await params - const { data: { user } } = await supabase.auth.getUser() - if (!user) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } + const { data: transaction, error: fetchError } = await supabase + .from('transactions') + .select('id, journal_entry_id, is_ignored') + .eq('id', id) + .eq('company_id', companyId) + .single() - const writeCheck = await requireWritePermission(supabase, user.id) - if (!writeCheck.ok) return writeCheck.response + if (fetchError || !transaction) { + return NextResponse.json({ error: 'Transaction not found' }, { status: 404 }) + } - const companyId = await requireCompanyId(supabase, user.id) + if (transaction.journal_entry_id) { + return NextResponse.json( + { error: 'Transaktionen är redan bokförd: använd Avmatcha eller backa verifikationen för att ändra status.' }, + { status: 409 } + ) + } - const { data: transaction, error: fetchError } = await supabase - .from('transactions') - .select('id, journal_entry_id, is_ignored') - .eq('id', id) - .eq('company_id', companyId) - .single() + if (transaction.is_ignored) { + return NextResponse.json({ success: true, already_ignored: true }) + } - if (fetchError || !transaction) { - return NextResponse.json({ error: 'Transaction not found' }, { status: 404 }) - } + const { error: updateError } = await supabase + .from('transactions') + .update({ is_ignored: true }) + .eq('id', id) + .eq('company_id', companyId) - if (transaction.journal_entry_id) { - return NextResponse.json( - { error: 'Transaktionen är redan bokförd: använd Avmatcha eller backa verifikationen för att ändra status.' }, - { status: 409 } - ) - } + if (updateError) { + return NextResponse.json({ error: updateError.message }, { status: 500 }) + } - if (transaction.is_ignored) { - return NextResponse.json({ success: true, already_ignored: true }) - } - - const { error: updateError } = await supabase - .from('transactions') - .update({ is_ignored: true }) - .eq('id', id) - .eq('company_id', companyId) - - if (updateError) { - return NextResponse.json({ error: updateError.message }, { status: 500 }) - } - - return NextResponse.json({ success: true }) -} + return NextResponse.json({ success: true }) + }, + { requireWrite: true }, +) /** * DELETE /api/transactions/[id]/ignore @@ -74,32 +62,22 @@ export async function POST( * no further side effects: we never created a verifikation, so there's * nothing to storno. */ -export async function DELETE( - _request: Request, - { params }: { params: Promise<{ id: string }> } -) { - const supabase = await createClient() - const { id } = await params +export const DELETE = withRouteContext<{ params: Promise<{ id: string }> }>( + 'transaction.unignore', + async (_request, { supabase, companyId }, { params }) => { + const { id } = await params - const { data: { user } } = await supabase.auth.getUser() - if (!user) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } + const { error: updateError } = await supabase + .from('transactions') + .update({ is_ignored: false }) + .eq('id', id) + .eq('company_id', companyId) - const writeCheck = await requireWritePermission(supabase, user.id) - if (!writeCheck.ok) return writeCheck.response + if (updateError) { + return NextResponse.json({ error: updateError.message }, { status: 500 }) + } - const companyId = await requireCompanyId(supabase, user.id) - - const { error: updateError } = await supabase - .from('transactions') - .update({ is_ignored: false }) - .eq('id', id) - .eq('company_id', companyId) - - if (updateError) { - return NextResponse.json({ error: updateError.message }, { status: 500 }) - } - - return NextResponse.json({ success: true }) -} + return NextResponse.json({ success: true }) + }, + { requireWrite: true }, +) diff --git a/app/api/transactions/[id]/uncategorize/__tests__/route.test.ts b/app/api/transactions/[id]/uncategorize/__tests__/route.test.ts index 495d1629..da7bad4e 100644 --- a/app/api/transactions/[id]/uncategorize/__tests__/route.test.ts +++ b/app/api/transactions/[id]/uncategorize/__tests__/route.test.ts @@ -1,4 +1,5 @@ import { describe, it, expect, vi, beforeEach } from 'vitest' +import { NextResponse } from 'next/server' import { createMockRequest, parseJsonResponse, @@ -8,8 +9,10 @@ import { import { eventBus } from '@/lib/events' const { supabase: mockSupabase, enqueue, reset } = createQueuedMockSupabase() -vi.mock('@/lib/supabase/server', () => ({ - createClient: () => Promise.resolve(mockSupabase), + +const requireAuthMock = vi.fn() +vi.mock('@/lib/auth/require-auth', () => ({ + requireAuth: (...args: unknown[]) => requireAuthMock(...args), })) vi.mock('@/lib/init', () => ({ @@ -21,8 +24,9 @@ vi.mock('@/lib/company/context', () => ({ getActiveCompanyId: vi.fn().mockResolvedValue('company-1'), })) +const requireWriteMock = vi.fn() vi.mock('@/lib/auth/require-write', () => ({ - requireWritePermission: vi.fn().mockResolvedValue({ ok: true }), + requireWritePermission: (...args: unknown[]) => requireWriteMock(...args), })) const mockReverseEntry = vi.fn() @@ -39,11 +43,16 @@ describe('POST /api/transactions/[id]/uncategorize', () => { vi.clearAllMocks() reset() eventBus.clear() - mockSupabase.auth.getUser.mockResolvedValue({ data: { user: mockUser } }) + requireAuthMock.mockResolvedValue({ user: mockUser, supabase: mockSupabase }) + requireWriteMock.mockResolvedValue({ ok: true }) }) it('returns 401 when not authenticated', async () => { - mockSupabase.auth.getUser.mockResolvedValue({ data: { user: null } }) + requireAuthMock.mockResolvedValue({ + user: null, + supabase: mockSupabase, + error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }), + }) const request = createMockRequest('/api/transactions/tx-1/uncategorize', { method: 'POST' }) const response = await POST(request, createMockRouteParams({ id: 'tx-1' })) @@ -53,6 +62,21 @@ describe('POST /api/transactions/[id]/uncategorize', () => { expect(body).toEqual({ error: 'Unauthorized' }) }) + it('returns 403 when the caller is a viewer', async () => { + requireWriteMock.mockResolvedValue({ + ok: false, + response: NextResponse.json({ error: 'Forbidden' }, { status: 403 }), + }) + + const request = createMockRequest('/api/transactions/tx-1/uncategorize', { method: 'POST' }) + const response = await POST(request, createMockRouteParams({ id: 'tx-1' })) + const { status, body } = await parseJsonResponse(response) + + expect(status).toBe(403) + expect(body).toEqual({ error: 'Forbidden' }) + expect(mockReverseEntry).not.toHaveBeenCalled() + }) + it('returns 404 when transaction not found', async () => { enqueue({ data: null, error: { message: 'Not found' } }) diff --git a/app/api/transactions/[id]/uncategorize/route.ts b/app/api/transactions/[id]/uncategorize/route.ts index 89b6da95..e8b02665 100644 --- a/app/api/transactions/[id]/uncategorize/route.ts +++ b/app/api/transactions/[id]/uncategorize/route.ts @@ -1,86 +1,74 @@ -import { createClient } from '@/lib/supabase/server' import { NextResponse } from 'next/server' import { reverseEntry } from '@/lib/bookkeeping/engine' import { bookkeepingErrorResponse } from '@/lib/bookkeeping/errors' import { ensureInitialized } from '@/lib/init' -import { requireCompanyId } from '@/lib/company/context' -import { requireWritePermission } from '@/lib/auth/require-write' +import { withRouteContext } from '@/lib/api/with-route-context' ensureInitialized() -export async function POST( - _request: Request, - { params }: { params: Promise<{ id: string }> } -) { - const supabase = await createClient() - const { data: { user } } = await supabase.auth.getUser() - if (!user) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } +export const POST = withRouteContext<{ params: Promise<{ id: string }> }>( + 'transaction.uncategorize', + async (_request, { supabase, user, companyId }, { params }) => { + const { id } = await params - const writeCheck = await requireWritePermission(supabase, user.id) - if (!writeCheck.ok) return writeCheck.response + // Fetch transaction + const { data: transaction, error: txError } = await supabase + .from('transactions') + .select('id, journal_entry_id') + .eq('id', id) + .eq('company_id', companyId) + .single() - const companyId = await requireCompanyId(supabase, user.id) + if (txError || !transaction) { + return NextResponse.json({ error: 'Transaction not found' }, { status: 404 }) + } - const { id } = await params + if (!transaction.journal_entry_id) { + return NextResponse.json({ error: 'Transaction has no journal entry' }, { status: 400 }) + } - // Fetch transaction - const { data: transaction, error: txError } = await supabase - .from('transactions') - .select('id, journal_entry_id') - .eq('id', id) - .eq('company_id', companyId) - .single() + // Verify journal entry is posted + const { data: entry, error: entryError } = await supabase + .from('journal_entries') + .select('id, status') + .eq('id', transaction.journal_entry_id) + .eq('company_id', companyId) + .single() - if (txError || !transaction) { - return NextResponse.json({ error: 'Transaction not found' }, { status: 404 }) - } + if (entryError || !entry) { + return NextResponse.json({ error: 'Journal entry not found' }, { status: 400 }) + } - if (!transaction.journal_entry_id) { - return NextResponse.json({ error: 'Transaction has no journal entry' }, { status: 400 }) - } + if (entry.status !== 'posted') { + return NextResponse.json({ error: 'Journal entry is not posted' }, { status: 400 }) + } - // Verify journal entry is posted - const { data: entry, error: entryError } = await supabase - .from('journal_entries') - .select('id, status') - .eq('id', transaction.journal_entry_id) - .eq('company_id', companyId) - .single() + // Storno reversal (legally compliant: never deletes) + try { + await reverseEntry(supabase, companyId, user.id, transaction.journal_entry_id) + } catch (err) { + const typed = bookkeepingErrorResponse(err) + if (typed) return typed + const message = err instanceof Error ? err.message : 'Reversal failed' + return NextResponse.json({ error: message }, { status: 500 }) + } - if (entryError || !entry) { - return NextResponse.json({ error: 'Journal entry not found' }, { status: 400 }) - } + // Reset transaction categorization + const { error: updateError } = await supabase + .from('transactions') + .update({ + is_business: null, + category: null, + journal_entry_id: null, + }) + .eq('id', id) + .eq('company_id', companyId) - if (entry.status !== 'posted') { - return NextResponse.json({ error: 'Journal entry is not posted' }, { status: 400 }) - } + if (updateError) { + return NextResponse.json({ error: 'Failed to reset transaction' }, { status: 500 }) + } - // Storno reversal (legally compliant: never deletes) - try { - await reverseEntry(supabase, companyId, user.id, transaction.journal_entry_id) - } catch (err) { - const typed = bookkeepingErrorResponse(err) - if (typed) return typed - const message = err instanceof Error ? err.message : 'Reversal failed' - return NextResponse.json({ error: message }, { status: 500 }) - } - - // Reset transaction categorization - const { error: updateError } = await supabase - .from('transactions') - .update({ - is_business: null, - category: null, - journal_entry_id: null, - }) - .eq('id', id) - .eq('company_id', companyId) - - if (updateError) { - return NextResponse.json({ error: 'Failed to reset transaction' }, { status: 500 }) - } - - return NextResponse.json({ success: true }) -} + return NextResponse.json({ success: true }) + }, + { requireWrite: true }, +) diff --git a/app/api/transactions/batch-match-invoices/__tests__/route.test.ts b/app/api/transactions/batch-match-invoices/__tests__/route.test.ts index 223134fa..d908191d 100644 --- a/app/api/transactions/batch-match-invoices/__tests__/route.test.ts +++ b/app/api/transactions/batch-match-invoices/__tests__/route.test.ts @@ -1,9 +1,12 @@ import { describe, it, expect, vi, beforeEach } from 'vitest' -import { parseJsonResponse, createQueuedMockSupabase, makeTransaction } from '@/tests/helpers' +import { NextResponse } from 'next/server' +import { createMockRequest, parseJsonResponse, createQueuedMockSupabase, makeTransaction } from '@/tests/helpers' const { supabase: mockSupabase, enqueue, reset } = createQueuedMockSupabase() -vi.mock('@/lib/supabase/server', () => ({ - createClient: () => Promise.resolve(mockSupabase), + +const requireAuthMock = vi.fn() +vi.mock('@/lib/auth/require-auth', () => ({ + requireAuth: (...args: unknown[]) => requireAuthMock(...args), })) const mockGetBestInvoiceMatch = vi.fn() @@ -13,33 +16,60 @@ vi.mock('@/lib/invoices/invoice-matching', () => ({ vi.mock('@/lib/company/context', () => ({ requireCompanyId: vi.fn().mockResolvedValue('company-1'), + getActiveCompanyId: vi.fn().mockResolvedValue('company-1'), })) +const requireWriteMock = vi.fn() vi.mock('@/lib/auth/require-write', () => ({ - requireWritePermission: vi.fn().mockResolvedValue({ ok: true }), + requireWritePermission: (...args: unknown[]) => requireWriteMock(...args), })) import { POST } from '../route' +function makeReq() { + return createMockRequest('/api/transactions/batch-match-invoices', { method: 'POST' }) +} + +const routeParams = { params: Promise.resolve({}) } + describe('POST /api/transactions/batch-match-invoices', () => { const mockUser = { id: 'user-1', email: 'test@test.se' } beforeEach(() => { vi.clearAllMocks() reset() - mockSupabase.auth.getUser.mockResolvedValue({ data: { user: mockUser } }) + requireAuthMock.mockResolvedValue({ user: mockUser, supabase: mockSupabase }) + requireWriteMock.mockResolvedValue({ ok: true }) }) it('returns 401 when not authenticated', async () => { - mockSupabase.auth.getUser.mockResolvedValue({ data: { user: null } }) + requireAuthMock.mockResolvedValue({ + user: null, + supabase: mockSupabase, + error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }), + }) - const response = await POST() + const response = await POST(makeReq(), routeParams) const { status, body } = await parseJsonResponse(response) expect(status).toBe(401) expect(body).toEqual({ error: 'Unauthorized' }) }) + it('returns 403 when the caller is a viewer', async () => { + requireWriteMock.mockResolvedValue({ + ok: false, + response: NextResponse.json({ error: 'Forbidden' }, { status: 403 }), + }) + + const response = await POST(makeReq(), routeParams) + const { status, body } = await parseJsonResponse(response) + + expect(status).toBe(403) + expect(body).toEqual({ error: 'Forbidden' }) + expect(mockGetBestInvoiceMatch).not.toHaveBeenCalled() + }) + it('calls the invoice matcher with companyId (not user.id) and records the match', async () => { const tx = makeTransaction({ id: 'tx-1', amount: 12500 }) enqueue({ data: [tx], error: null }) // fetch uncategorized income transactions @@ -47,7 +77,7 @@ describe('POST /api/transactions/batch-match-invoices', () => { mockGetBestInvoiceMatch.mockResolvedValue({ invoice: { id: 'inv-1' }, confidence: 0.9 }) - const response = await POST() + const response = await POST(makeReq(), routeParams) const { status, body } = await parseJsonResponse<{ processed: number; matched: number }>(response) expect(status).toBe(200) @@ -75,7 +105,7 @@ describe('POST /api/transactions/batch-match-invoices', () => { mockGetBestInvoiceMatch.mockResolvedValue(null) - const response = await POST() + const response = await POST(makeReq(), routeParams) const { status, body } = await parseJsonResponse<{ processed: number; matched: number }>(response) expect(status).toBe(200) @@ -85,7 +115,7 @@ describe('POST /api/transactions/batch-match-invoices', () => { it('returns 500 when the transaction fetch fails', async () => { enqueue({ data: null, error: { message: 'boom' } }) - const response = await POST() + const response = await POST(makeReq(), routeParams) const { status, body } = await parseJsonResponse<{ error: string }>(response) expect(status).toBe(500) diff --git a/app/api/transactions/batch-match-invoices/route.ts b/app/api/transactions/batch-match-invoices/route.ts index ab528da7..e1c7633b 100644 --- a/app/api/transactions/batch-match-invoices/route.ts +++ b/app/api/transactions/batch-match-invoices/route.ts @@ -1,72 +1,61 @@ -import { createClient } from '@/lib/supabase/server' import { NextResponse } from 'next/server' +import { withRouteContext } from '@/lib/api/with-route-context' import { getBestInvoiceMatch } from '@/lib/invoices/invoice-matching' -import { requireCompanyId } from '@/lib/company/context' -import { requireWritePermission } from '@/lib/auth/require-write' import type { Transaction } from '@/types' /** * POST /api/transactions/batch-match-invoices * Run invoice matching for all uncategorized income transactions without potential_invoice_id */ -export async function POST() { - const supabase = await createClient() +export const POST = withRouteContext( + 'transaction.batch_match_invoices', + async (_request, { supabase, companyId }) => { + // Fetch uncategorized income transactions without a potential match + const { data: transactions, error: txError } = await supabase + .from('transactions') + .select('*') + .eq('company_id', companyId) + .is('is_business', null) + .gt('amount', 0) + .is('potential_invoice_id', null) + .is('invoice_id', null) + .order('date', { ascending: false }) + .limit(50) - const { data: { user } } = await supabase.auth.getUser() - - if (!user) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const writeCheck = await requireWritePermission(supabase, user.id) - if (!writeCheck.ok) return writeCheck.response - - const companyId = await requireCompanyId(supabase, user.id) - - // Fetch uncategorized income transactions without a potential match - const { data: transactions, error: txError } = await supabase - .from('transactions') - .select('*') - .eq('company_id', companyId) - .is('is_business', null) - .gt('amount', 0) - .is('potential_invoice_id', null) - .is('invoice_id', null) - .order('date', { ascending: false }) - .limit(50) - - if (txError || !transactions) { - return NextResponse.json({ error: 'Failed to fetch transactions' }, { status: 500 }) - } - - let matched = 0 - const matchedInvoiceIds = new Set() - - for (const tx of transactions) { - try { - const bestMatch = await getBestInvoiceMatch( - supabase, - companyId, - tx as Transaction, - 0.50 - ) - - if (bestMatch && !matchedInvoiceIds.has(bestMatch.invoice.id)) { - await supabase - .from('transactions') - .update({ potential_invoice_id: bestMatch.invoice.id }) - .eq('id', tx.id) - - matchedInvoiceIds.add(bestMatch.invoice.id) - matched++ - } - } catch { - // Continue with other transactions + if (txError || !transactions) { + return NextResponse.json({ error: 'Failed to fetch transactions' }, { status: 500 }) } - } - return NextResponse.json({ - processed: transactions.length, - matched, - }) -} + let matched = 0 + const matchedInvoiceIds = new Set() + + for (const tx of transactions) { + try { + const bestMatch = await getBestInvoiceMatch( + supabase, + companyId, + tx as Transaction, + 0.50 + ) + + if (bestMatch && !matchedInvoiceIds.has(bestMatch.invoice.id)) { + await supabase + .from('transactions') + .update({ potential_invoice_id: bestMatch.invoice.id }) + .eq('id', tx.id) + + matchedInvoiceIds.add(bestMatch.invoice.id) + matched++ + } + } catch { + // Continue with other transactions + } + } + + return NextResponse.json({ + processed: transactions.length, + matched, + }) + }, + { requireWrite: true }, +) diff --git a/app/api/transactions/create-from-document/__tests__/route.test.ts b/app/api/transactions/create-from-document/__tests__/route.test.ts index cb0f02cc..f451ff53 100644 --- a/app/api/transactions/create-from-document/__tests__/route.test.ts +++ b/app/api/transactions/create-from-document/__tests__/route.test.ts @@ -1,9 +1,12 @@ import { describe, it, expect, vi, beforeEach } from 'vitest' +import { NextResponse } from 'next/server' import { parseJsonResponse, createQueuedMockSupabase } from '@/tests/helpers' const { supabase: mockSupabase, enqueue, reset } = createQueuedMockSupabase() -vi.mock('@/lib/supabase/server', () => ({ - createClient: () => Promise.resolve(mockSupabase), + +const requireAuthMock = vi.fn() +vi.mock('@/lib/auth/require-auth', () => ({ + requireAuth: (...args: unknown[]) => requireAuthMock(...args), })) vi.mock('@/lib/company/context', () => ({ @@ -11,8 +14,9 @@ vi.mock('@/lib/company/context', () => ({ getActiveCompanyId: vi.fn().mockResolvedValue('company-1'), })) +const requireWriteMock = vi.fn() vi.mock('@/lib/auth/require-write', () => ({ - requireWritePermission: vi.fn().mockResolvedValue({ ok: true }), + requireWritePermission: (...args: unknown[]) => requireWriteMock(...args), })) vi.mock('@/lib/init', () => ({ @@ -26,11 +30,14 @@ const mockUser = { id: 'user-1', email: 'test@test.se' } beforeEach(() => { vi.clearAllMocks() reset() - mockSupabase.auth.getUser.mockResolvedValue({ data: { user: mockUser } }) + requireAuthMock.mockResolvedValue({ user: mockUser, supabase: mockSupabase }) + requireWriteMock.mockResolvedValue({ ok: true }) }) const VALID_UUID = '11111111-1111-4111-8111-111111111111' +const routeParams = { params: Promise.resolve({}) } + function makeReq(body: unknown) { return new Request('http://localhost/api/transactions/create-from-document', { method: 'POST', @@ -56,28 +63,43 @@ function validBody(overrides: Partial<{ describe('POST /api/transactions/create-from-document', () => { it('returns 401 when not authenticated', async () => { - mockSupabase.auth.getUser.mockResolvedValue({ data: { user: null } }) - const res = await POST(makeReq(validBody())) + requireAuthMock.mockResolvedValue({ + user: null, + supabase: mockSupabase, + error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }), + }) + const res = await POST(makeReq(validBody()), routeParams) const { status, body } = await parseJsonResponse(res) expect(status).toBe(401) expect(body).toEqual({ error: 'Unauthorized' }) }) + it('returns 403 when the caller is a viewer', async () => { + requireWriteMock.mockResolvedValue({ + ok: false, + response: NextResponse.json({ error: 'Forbidden' }, { status: 403 }), + }) + const res = await POST(makeReq(validBody()), routeParams) + const { status, body } = await parseJsonResponse(res) + expect(status).toBe(403) + expect(body).toEqual({ error: 'Forbidden' }) + }) + it('returns 400 when the body is invalid', async () => { - const res = await POST(makeReq({ inbox_item_id: 'not-a-uuid', amount: 0 })) + const res = await POST(makeReq({ inbox_item_id: 'not-a-uuid', amount: 0 }), routeParams) const { status } = await parseJsonResponse(res) expect(status).toBe(400) }) it('returns 400 when amount is zero (schema refine)', async () => { - const res = await POST(makeReq(validBody({ amount: 0 }))) + const res = await POST(makeReq(validBody({ amount: 0 })), routeParams) const { status } = await parseJsonResponse(res) expect(status).toBe(400) }) it('returns 404 when the inbox item is not in the user company', async () => { enqueue({ data: null, error: null }) // inbox item lookup misses - const res = await POST(makeReq(validBody())) + const res = await POST(makeReq(validBody()), routeParams) const { status, body } = await parseJsonResponse<{ error: string }>(res) expect(status).toBe(404) expect(body.error).toBe('Inbox item not found') @@ -94,7 +116,7 @@ describe('POST /api/transactions/create-from-document', () => { }, error: null, }) - const res = await POST(makeReq(validBody())) + const res = await POST(makeReq(validBody()), routeParams) const { status, body } = await parseJsonResponse<{ error: string }>(res) expect(status).toBe(409) expect(body.error).toMatch(/redan kopplad/) @@ -111,7 +133,7 @@ describe('POST /api/transactions/create-from-document', () => { }, error: null, }) - const res = await POST(makeReq(validBody())) + const res = await POST(makeReq(validBody()), routeParams) const { status, body } = await parseJsonResponse<{ error: string }>(res) expect(status).toBe(409) expect(body.error).toMatch(/redan bokförd/) @@ -131,7 +153,7 @@ describe('POST /api/transactions/create-from-document', () => { enqueue({ data: { id: 'new-tx-1' }, error: null }) // insert enqueue({ data: [{ id: VALID_UUID }], error: null }) // inbox update: one row affected - const res = await POST(makeReq(validBody())) + const res = await POST(makeReq(validBody()), routeParams) const { status, body } = await parseJsonResponse<{ data: { transaction_id: string; inbox_item_id: string; document_id: string } }>(res) @@ -159,7 +181,7 @@ describe('POST /api/transactions/create-from-document', () => { enqueue({ data: [], error: null }) // inbox update affects zero rows: lost the race enqueue({ data: null, error: null }) // rollback delete of the orphan - const res = await POST(makeReq(validBody())) + const res = await POST(makeReq(validBody()), routeParams) const { status, body } = await parseJsonResponse<{ error: string }>(res) expect(status).toBe(409) expect(body.error).toMatch(/parallell begäran/) @@ -182,7 +204,7 @@ describe('POST /api/transactions/create-from-document', () => { enqueue({ data: { id: 'new-tx-3' }, error: null }) enqueue({ data: [{ id: VALID_UUID }], error: null }) - const res = await POST(makeReq(validBody())) + const res = await POST(makeReq(validBody()), routeParams) const { status } = await parseJsonResponse(res) expect(status).toBe(200) }) @@ -201,7 +223,7 @@ describe('POST /api/transactions/create-from-document', () => { enqueue({ data: null, error: { message: 'db down' } }) // insert fails // Silence the console.error const spy = vi.spyOn(console, 'error').mockImplementation(() => {}) - const res = await POST(makeReq(validBody())) + const res = await POST(makeReq(validBody()), routeParams) const { status, body } = await parseJsonResponse<{ error: string }>(res) expect(status).toBe(500) expect(body.error).toMatch(/Kunde inte skapa transaktion/) @@ -222,7 +244,7 @@ describe('POST /api/transactions/create-from-document', () => { enqueue({ data: { id: 'new-tx-2' }, error: null }) // insert ok enqueue({ data: null, error: { message: 'rls' } }) // link update fails const spy = vi.spyOn(console, 'error').mockImplementation(() => {}) - const res = await POST(makeReq(validBody())) + const res = await POST(makeReq(validBody()), routeParams) const { status, body } = await parseJsonResponse<{ data: { transaction_id: string; inbox_link_failed?: boolean } }>(res) diff --git a/app/api/transactions/create-from-document/route.ts b/app/api/transactions/create-from-document/route.ts index 3c600d5c..c2607c69 100644 --- a/app/api/transactions/create-from-document/route.ts +++ b/app/api/transactions/create-from-document/route.ts @@ -1,10 +1,8 @@ -import { createClient } from '@/lib/supabase/server' import { NextResponse } from 'next/server' import { ensureInitialized } from '@/lib/init' +import { withRouteContext } from '@/lib/api/with-route-context' import { validateBody } from '@/lib/api/validate' import { CreateTransactionFromDocumentSchema } from '@/lib/api/schemas' -import { requireCompanyId } from '@/lib/company/context' -import { requireWritePermission } from '@/lib/auth/require-write' ensureInitialized() @@ -20,120 +18,114 @@ ensureInitialized() * Use case: receipt in the inbox has no matching bank transaction * (cash purchase, personal-card expense, missed sync). */ -export async function POST(request: Request) { - const supabase = await createClient() +export const POST = withRouteContext( + 'transaction.create_from_document', + async (request, { supabase, user, companyId }) => { + const validation = await validateBody(request, CreateTransactionFromDocumentSchema) + if (!validation.success) return validation.response + const { inbox_item_id, amount, transaction_date, description } = validation.data - const { data: { user } } = await supabase.auth.getUser() - if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + const { data: item, error: itemError } = await supabase + .from('invoice_inbox_items') + .select('id, document_id, matched_transaction_id, created_supplier_invoice_id, extracted_data') + .eq('id', inbox_item_id) + .eq('company_id', companyId) + .maybeSingle() - const writeCheck = await requireWritePermission(supabase, user.id) - if (!writeCheck.ok) return writeCheck.response + if (itemError || !item) { + return NextResponse.json({ error: 'Inbox item not found' }, { status: 404 }) + } + if (item.matched_transaction_id) { + return NextResponse.json( + { error: 'Inkorgsposten är redan kopplad till en transaktion.' }, + { status: 409 }, + ) + } + if (item.created_supplier_invoice_id) { + return NextResponse.json( + { error: 'Inkorgsposten är redan bokförd som leverantörsfaktura.' }, + { status: 409 }, + ) + } - const companyId = await requireCompanyId(supabase, user.id) + // Allowlist the currency: extracted_data.invoice.currency comes from the + // (deterministic, but still untrusted) PDF extractor, so an arbitrary + // string like "XYZ" or '"SEK\'"' could otherwise be persisted directly to + // the transactions table and break later formatCurrency / journal-entry + // bookings (BFL 5 kap 6 §). Coerce anything outside the supported set + // to SEK; the user can change it manually on the transaction. + const ALLOWED_CURRENCIES = new Set(['SEK', 'EUR', 'USD', 'GBP', 'NOK', 'DKK']) + const extractedCurrency = ( + item.extracted_data as { invoice?: { currency?: string } } | null + )?.invoice?.currency + const currency = + extractedCurrency && ALLOWED_CURRENCIES.has(extractedCurrency) + ? extractedCurrency + : 'SEK' - const validation = await validateBody(request, CreateTransactionFromDocumentSchema) - if (!validation.success) return validation.response - const { inbox_item_id, amount, transaction_date, description } = validation.data + const { data: newTx, error: insertError } = await supabase + .from('transactions') + .insert({ + company_id: companyId, + user_id: user.id, + date: transaction_date, + description, + amount, + currency, + category: 'uncategorized', + is_business: null, + import_source: 'manual', + document_id: item.document_id, + }) + .select('id') + .single() - const { data: item, error: itemError } = await supabase - .from('invoice_inbox_items') - .select('id, document_id, matched_transaction_id, created_supplier_invoice_id, extracted_data') - .eq('id', inbox_item_id) - .eq('company_id', companyId) - .maybeSingle() + if (insertError || !newTx) { + console.error('[create-from-document] Failed to insert transaction:', insertError) + return NextResponse.json({ error: 'Kunde inte skapa transaktion.' }, { status: 500 }) + } - if (itemError || !item) { - return NextResponse.json({ error: 'Inbox item not found' }, { status: 404 }) - } - if (item.matched_transaction_id) { - return NextResponse.json( - { error: 'Inkorgsposten är redan kopplad till en transaktion.' }, - { status: 409 }, - ) - } - if (item.created_supplier_invoice_id) { - return NextResponse.json( - { error: 'Inkorgsposten är redan bokförd som leverantörsfaktura.' }, - { status: 409 }, - ) - } + // Concurrency guard: the .is('matched_transaction_id', null) predicate + + // the rows-affected check turn this into an optimistic-lock release. If + // two requests with the same inbox_item_id race past the earlier + // matched_transaction_id check, only the first UPDATE will match a row + // here. The loser's transaction insert is then an orphan we proactively + // delete so the user doesn't get a duplicate uncategorized row. + const { data: linked, error: linkError } = await supabase + .from('invoice_inbox_items') + .update({ matched_transaction_id: newTx.id }) + .eq('id', inbox_item_id) + .eq('company_id', companyId) + .is('matched_transaction_id', null) + .select('id') - // Allowlist the currency: extracted_data.invoice.currency comes from the - // (deterministic, but still untrusted) PDF extractor, so an arbitrary - // string like "XYZ" or '"SEK\'"' could otherwise be persisted directly to - // the transactions table and break later formatCurrency / journal-entry - // bookings (BFL 5 kap 6 §). Coerce anything outside the supported set - // to SEK; the user can change it manually on the transaction. - const ALLOWED_CURRENCIES = new Set(['SEK', 'EUR', 'USD', 'GBP', 'NOK', 'DKK']) - const extractedCurrency = ( - item.extracted_data as { invoice?: { currency?: string } } | null - )?.invoice?.currency - const currency = - extractedCurrency && ALLOWED_CURRENCIES.has(extractedCurrency) - ? extractedCurrency - : 'SEK' + if (linkError) { + console.error('[create-from-document] Failed to link inbox item:', linkError) + // Transaction was created; surface a 200 with a warning so the user can + // still find it under Transaktioner: the inbox-link orphan is recoverable. + return NextResponse.json({ + data: { transaction_id: newTx.id, inbox_link_failed: true }, + }) + } - const { data: newTx, error: insertError } = await supabase - .from('transactions') - .insert({ - company_id: companyId, - user_id: user.id, - date: transaction_date, - description, - amount, - currency, - category: 'uncategorized', - is_business: null, - import_source: 'manual', - document_id: item.document_id, - }) - .select('id') - .single() + if (!linked || linked.length === 0) { + // Lost a race: another concurrent request linked the inbox item first. + // Roll back our newly-created transaction (only safe because we own it + // and it has no journal_entry_id yet) and return 409 so the client can + // refetch and reuse the winning transaction instead of creating a dupe. + // Re-assert company_id on the delete (defence in depth: newTx.id is a + // fresh UUID from a company-scoped insert above, but scoping the rollback + // makes the invariant explicit). + await supabase.from('transactions').delete().eq('id', newTx.id).eq('company_id', companyId) + return NextResponse.json( + { error: 'Inkorgsposten kopplades av en parallell begäran. Försök igen.' }, + { status: 409 }, + ) + } - if (insertError || !newTx) { - console.error('[create-from-document] Failed to insert transaction:', insertError) - return NextResponse.json({ error: 'Kunde inte skapa transaktion.' }, { status: 500 }) - } - - // Concurrency guard: the .is('matched_transaction_id', null) predicate + - // the rows-affected check turn this into an optimistic-lock release. If - // two requests with the same inbox_item_id race past the earlier - // matched_transaction_id check, only the first UPDATE will match a row - // here. The loser's transaction insert is then an orphan we proactively - // delete so the user doesn't get a duplicate uncategorized row. - const { data: linked, error: linkError } = await supabase - .from('invoice_inbox_items') - .update({ matched_transaction_id: newTx.id }) - .eq('id', inbox_item_id) - .eq('company_id', companyId) - .is('matched_transaction_id', null) - .select('id') - - if (linkError) { - console.error('[create-from-document] Failed to link inbox item:', linkError) - // Transaction was created; surface a 200 with a warning so the user can - // still find it under Transaktioner: the inbox-link orphan is recoverable. return NextResponse.json({ - data: { transaction_id: newTx.id, inbox_link_failed: true }, + data: { transaction_id: newTx.id, inbox_item_id, document_id: item.document_id }, }) - } - - if (!linked || linked.length === 0) { - // Lost a race: another concurrent request linked the inbox item first. - // Roll back our newly-created transaction (only safe because we own it - // and it has no journal_entry_id yet) and return 409 so the client can - // refetch and reuse the winning transaction instead of creating a dupe. - // Re-assert company_id on the delete (defence in depth: newTx.id is a - // fresh UUID from a company-scoped insert above, but scoping the rollback - // makes the invariant explicit). - await supabase.from('transactions').delete().eq('id', newTx.id).eq('company_id', companyId) - return NextResponse.json( - { error: 'Inkorgsposten kopplades av en parallell begäran. Försök igen.' }, - { status: 409 }, - ) - } - - return NextResponse.json({ - data: { transaction_id: newTx.id, inbox_item_id, document_id: item.document_id }, - }) -} + }, + { requireWrite: true }, +) diff --git a/app/api/transactions/suggest-categories/route.ts b/app/api/transactions/suggest-categories/route.ts index cbf7756f..c3d28261 100644 --- a/app/api/transactions/suggest-categories/route.ts +++ b/app/api/transactions/suggest-categories/route.ts @@ -1,115 +1,107 @@ -import { createClient } from '@/lib/supabase/server' import { NextResponse } from 'next/server' +import { withRouteContext } from '@/lib/api/with-route-context' import { getSuggestedCategories, getSuggestedTemplates, buildMerchantHistory, merchantHistoryFor, type SuggestedCategory, type SuggestedTemplate } from '@/lib/transactions/category-suggestions' import { findCounterpartyTemplatesBatch, formatCounterpartyName, toCounterpartyTemplateId } from '@/lib/bookkeeping/counterparty-templates' -import { requireCompanyId } from '@/lib/company/context' import type { Transaction, EntityType } from '@/types' /** * POST /api/transactions/suggest-categories * Batch endpoint for getting category suggestions for multiple transactions */ -export async function POST(request: Request) { - const supabase = await createClient() +export const POST = withRouteContext( + 'transaction.suggest_categories', + async (request, { supabase, companyId }) => { + const { transaction_ids } = await request.json() - const { data: { user } } = await supabase.auth.getUser() - - if (!user) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const companyId = await requireCompanyId(supabase, user.id) - - const { transaction_ids } = await request.json() - - if (!Array.isArray(transaction_ids) || transaction_ids.length === 0) { - return NextResponse.json({ error: 'transaction_ids is required' }, { status: 400 }) - } - - // Limit batch size - const ids = transaction_ids.slice(0, 50) - - // Fetch transactions - const { data: transactions, error: txError } = await supabase - .from('transactions') - .select('*') - .eq('company_id', companyId) - .in('id', ids) - - if (txError || !transactions) { - return NextResponse.json({ error: 'Failed to fetch transactions' }, { status: 500 }) - } - - // Fetch user's mapping rules (once, for all transactions) - const { data: mappingRules } = await supabase - .from('mapping_rules') - .select('*') - .or(`company_id.eq.${companyId},company_id.is.null`) - .eq('is_active', true) - .order('priority', { ascending: false }) - - // Counterparty-keyed history from past categorized transactions: the - // suggestion engine only surfaces history tied to the SAME merchant - // (global frequency padding produced identical low-confidence spreads). - const { data: historicalTxns } = await supabase - .from('transactions') - .select('category, merchant_name') - .eq('company_id', companyId) - .not('is_business', 'is', null) - .neq('category', 'uncategorized') - .neq('category', 'private') - .limit(200) - - const merchantHistory = buildMerchantHistory(historicalTxns ?? []) - - // Fetch entity type for template matching - const { data: settings } = await supabase - .from('company_settings') - .select('entity_type') - .eq('company_id', companyId) - .single() - const entityType = (settings?.entity_type as EntityType) || undefined - - // Batch counterparty template matching (1 DB query, in-memory matching) - const counterpartyMatches = await findCounterpartyTemplatesBatch(supabase, companyId, transactions as Transaction[]) - - // Generate initial suggestions for each transaction - const suggestions: Record = {} - const template_suggestions: Record = {} - - for (const tx of transactions) { - suggestions[tx.id] = getSuggestedCategories( - tx as Transaction, - mappingRules || [], - merchantHistoryFor(merchantHistory, (tx as Transaction).merchant_name) - ) - template_suggestions[tx.id] = await getSuggestedTemplates(tx as Transaction, entityType, mappingRules || undefined) - } - - // Inject counterparty template matches as top suggestions - for (const tx of transactions) { - const cpMatch = counterpartyMatches.get(tx.id) - if (!cpMatch) continue - - const tmpl = cpMatch.template - const cpSuggestion: SuggestedTemplate = { - template_id: toCounterpartyTemplateId(tmpl.id), - name_sv: formatCounterpartyName(tmpl.counterparty_name), - name_en: formatCounterpartyName(tmpl.counterparty_name), - group: 'counterparty', - debit_account: tmpl.debit_account, - credit_account: tmpl.credit_account, - confidence: cpMatch.confidence, - description_sv: `${tmpl.occurrence_count} tidigare bokföringar`, - risk_level: 'NONE', - requires_review: false, - line_pattern: tmpl.line_pattern ?? null, + if (!Array.isArray(transaction_ids) || transaction_ids.length === 0) { + return NextResponse.json({ error: 'transaction_ids is required' }, { status: 400 }) } - const existing = template_suggestions[tx.id] || [] - template_suggestions[tx.id] = [cpSuggestion, ...existing] - } + // Limit batch size + const ids = transaction_ids.slice(0, 50) + + // Fetch transactions + const { data: transactions, error: txError } = await supabase + .from('transactions') + .select('*') + .eq('company_id', companyId) + .in('id', ids) + + if (txError || !transactions) { + return NextResponse.json({ error: 'Failed to fetch transactions' }, { status: 500 }) + } + + // Fetch user's mapping rules (once, for all transactions) + const { data: mappingRules } = await supabase + .from('mapping_rules') + .select('*') + .or(`company_id.eq.${companyId},company_id.is.null`) + .eq('is_active', true) + .order('priority', { ascending: false }) + + // Counterparty-keyed history from past categorized transactions: the + // suggestion engine only surfaces history tied to the SAME merchant + // (global frequency padding produced identical low-confidence spreads). + const { data: historicalTxns } = await supabase + .from('transactions') + .select('category, merchant_name') + .eq('company_id', companyId) + .not('is_business', 'is', null) + .neq('category', 'uncategorized') + .neq('category', 'private') + .limit(200) + + const merchantHistory = buildMerchantHistory(historicalTxns ?? []) + + // Fetch entity type for template matching + const { data: settings } = await supabase + .from('company_settings') + .select('entity_type') + .eq('company_id', companyId) + .single() + const entityType = (settings?.entity_type as EntityType) || undefined + + // Batch counterparty template matching (1 DB query, in-memory matching) + const counterpartyMatches = await findCounterpartyTemplatesBatch(supabase, companyId, transactions as Transaction[]) + + // Generate initial suggestions for each transaction + const suggestions: Record = {} + const template_suggestions: Record = {} + + for (const tx of transactions) { + suggestions[tx.id] = getSuggestedCategories( + tx as Transaction, + mappingRules || [], + merchantHistoryFor(merchantHistory, (tx as Transaction).merchant_name) + ) + template_suggestions[tx.id] = await getSuggestedTemplates(tx as Transaction, entityType, mappingRules || undefined) + } + + // Inject counterparty template matches as top suggestions + for (const tx of transactions) { + const cpMatch = counterpartyMatches.get(tx.id) + if (!cpMatch) continue + + const tmpl = cpMatch.template + const cpSuggestion: SuggestedTemplate = { + template_id: toCounterpartyTemplateId(tmpl.id), + name_sv: formatCounterpartyName(tmpl.counterparty_name), + name_en: formatCounterpartyName(tmpl.counterparty_name), + group: 'counterparty', + debit_account: tmpl.debit_account, + credit_account: tmpl.credit_account, + confidence: cpMatch.confidence, + description_sv: `${tmpl.occurrence_count} tidigare bokföringar`, + risk_level: 'NONE', + requires_review: false, + line_pattern: tmpl.line_pattern ?? null, + } + + const existing = template_suggestions[tx.id] || [] + template_suggestions[tx.id] = [cpSuggestion, ...existing] + } - return NextResponse.json({ suggestions, template_suggestions }) -} + return NextResponse.json({ suggestions, template_suggestions }) + }, +) diff --git a/app/api/vat/validate/__tests__/route.test.ts b/app/api/vat/validate/__tests__/route.test.ts index 3741713a..4715ef79 100644 --- a/app/api/vat/validate/__tests__/route.test.ts +++ b/app/api/vat/validate/__tests__/route.test.ts @@ -1,9 +1,14 @@ import { describe, it, expect, vi, beforeEach } from 'vitest' +import { NextResponse } from 'next/server' import { createQueuedMockSupabase, createMockRequest, parseJsonResponse } from '@/tests/helpers' -// Mock Supabase -vi.mock('@/lib/supabase/server', () => ({ - createClient: vi.fn(), +// Exercised through the real withRouteContext wrapper: mock its auth/company +// dependencies and inject the Supabase client via requireAuth. +const { supabase } = createQueuedMockSupabase() + +const requireAuthMock = vi.fn() +vi.mock('@/lib/auth/require-auth', () => ({ + requireAuth: (...args: unknown[]) => requireAuthMock(...args), })) vi.mock('@/lib/company/context', () => ({ @@ -17,30 +22,32 @@ vi.mock('@/lib/vat/vies-client', () => ({ validateVatNumber: (...args: unknown[]) => mockValidateVatNumber(...args), })) -import { createClient } from '@/lib/supabase/server' -import { POST } from '../route' +// Company is not a sandbox, so VIES calls proceed. +vi.mock('@/lib/sandbox/guard', () => ({ + guardSandbox: vi.fn().mockResolvedValue(null), +})) -const mockCreateClient = vi.mocked(createClient) +import { POST } from '../route' describe('POST /api/vat/validate', () => { beforeEach(() => { vi.clearAllMocks() + requireAuthMock.mockResolvedValue({ user: { id: 'user-1' }, supabase, error: null }) }) it('returns 401 when not authenticated', async () => { - const { supabase } = createQueuedMockSupabase() - supabase.auth.getUser.mockResolvedValue({ - data: { user: null }, - error: { message: 'Not authenticated' }, + requireAuthMock.mockResolvedValue({ + user: null, + supabase, + error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }), }) - mockCreateClient.mockResolvedValue(supabase as never) const req = createMockRequest('/api/vat/validate', { method: 'POST', body: { vat_number: 'DE123456789' }, }) - const res = await POST(req) + const res = await POST(req, { params: Promise.resolve({}) }) const { status, body } = await parseJsonResponse(res) expect(status).toBe(401) @@ -48,50 +55,30 @@ describe('POST /api/vat/validate', () => { }) it('returns 400 when vat_number is missing', async () => { - const { supabase } = createQueuedMockSupabase() - supabase.auth.getUser.mockResolvedValue({ - data: { user: { id: 'user-1' } }, - error: null, - }) - mockCreateClient.mockResolvedValue(supabase as never) - const req = createMockRequest('/api/vat/validate', { method: 'POST', body: {}, }) - const res = await POST(req) + const res = await POST(req, { params: Promise.resolve({}) }) const { status } = await parseJsonResponse(res) expect(status).toBe(400) }) it('returns 400 when vat_number is too short', async () => { - const { supabase } = createQueuedMockSupabase() - supabase.auth.getUser.mockResolvedValue({ - data: { user: { id: 'user-1' } }, - error: null, - }) - mockCreateClient.mockResolvedValue(supabase as never) - const req = createMockRequest('/api/vat/validate', { method: 'POST', body: { vat_number: 'DE' }, }) - const res = await POST(req) + const res = await POST(req, { params: Promise.resolve({}) }) const { status } = await parseJsonResponse(res) expect(status).toBe(400) }) it('returns valid result from VIES', async () => { - const { supabase } = createQueuedMockSupabase() - supabase.auth.getUser.mockResolvedValue({ - data: { user: { id: 'user-1' } }, - error: null, - }) - mockCreateClient.mockResolvedValue(supabase as never) mockValidateVatNumber.mockResolvedValueOnce({ valid: true, name: 'Test GmbH', @@ -105,7 +92,7 @@ describe('POST /api/vat/validate', () => { body: { vat_number: 'DE123456789' }, }) - const res = await POST(req) + const res = await POST(req, { params: Promise.resolve({}) }) const { status, body } = await parseJsonResponse(res) expect(status).toBe(200) @@ -119,12 +106,6 @@ describe('POST /api/vat/validate', () => { }) it('returns invalid result from VIES', async () => { - const { supabase } = createQueuedMockSupabase() - supabase.auth.getUser.mockResolvedValue({ - data: { user: { id: 'user-1' } }, - error: null, - }) - mockCreateClient.mockResolvedValue(supabase as never) mockValidateVatNumber.mockResolvedValueOnce({ valid: false, country_code: 'DE', @@ -136,7 +117,7 @@ describe('POST /api/vat/validate', () => { body: { vat_number: 'DE000000000' }, }) - const res = await POST(req) + const res = await POST(req, { params: Promise.resolve({}) }) const { status, body } = await parseJsonResponse(res) expect(status).toBe(200) @@ -144,12 +125,6 @@ describe('POST /api/vat/validate', () => { }) it('updates customer when customer_id provided and valid', async () => { - const { supabase } = createQueuedMockSupabase() - supabase.auth.getUser.mockResolvedValue({ - data: { user: { id: 'user-1' } }, - error: null, - }) - mockCreateClient.mockResolvedValue(supabase as never) mockValidateVatNumber.mockResolvedValueOnce({ valid: true, name: 'Test GmbH', @@ -165,7 +140,7 @@ describe('POST /api/vat/validate', () => { }, }) - const res = await POST(req) + const res = await POST(req, { params: Promise.resolve({}) }) const { status, body } = await parseJsonResponse(res) expect(status).toBe(200) @@ -173,12 +148,6 @@ describe('POST /api/vat/validate', () => { }) it('does not update customer when validation fails', async () => { - const { supabase } = createQueuedMockSupabase() - supabase.auth.getUser.mockResolvedValue({ - data: { user: { id: 'user-1' } }, - error: null, - }) - mockCreateClient.mockResolvedValue(supabase as never) mockValidateVatNumber.mockResolvedValueOnce({ valid: false, error: 'Invalid VAT number format', @@ -192,7 +161,7 @@ describe('POST /api/vat/validate', () => { }, }) - const res = await POST(req) + const res = await POST(req, { params: Promise.resolve({}) }) const { status, body } = await parseJsonResponse(res) expect(status).toBe(200) @@ -200,12 +169,6 @@ describe('POST /api/vat/validate', () => { }) it('handles VIES service error gracefully', async () => { - const { supabase } = createQueuedMockSupabase() - supabase.auth.getUser.mockResolvedValue({ - data: { user: { id: 'user-1' } }, - error: null, - }) - mockCreateClient.mockResolvedValue(supabase as never) mockValidateVatNumber.mockResolvedValueOnce({ valid: false, error: 'Could not verify VAT number. Service temporarily unavailable.', @@ -216,7 +179,7 @@ describe('POST /api/vat/validate', () => { body: { vat_number: 'DE123456789' }, }) - const res = await POST(req) + const res = await POST(req, { params: Promise.resolve({}) }) const { status, body } = await parseJsonResponse(res) expect(status).toBe(200) diff --git a/app/api/vat/validate/route.ts b/app/api/vat/validate/route.ts index 5ad1cf58..a49879fe 100644 --- a/app/api/vat/validate/route.ts +++ b/app/api/vat/validate/route.ts @@ -1,22 +1,11 @@ -import { createClient } from '@/lib/supabase/server' import { NextResponse } from 'next/server' +import { withRouteContext } from '@/lib/api/with-route-context' import { validateBody } from '@/lib/api/validate' import { ValidateVatNumberSchema } from '@/lib/api/schemas' import { validateVatNumber } from '@/lib/vat/vies-client' -import { requireCompanyId } from '@/lib/company/context' import { guardSandbox } from '@/lib/sandbox/guard' -export async function POST(request: Request) { - const supabase = await createClient() - - const { data: { user } } = await supabase.auth.getUser() - - if (!user) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const companyId = await requireCompanyId(supabase, user.id) - +export const POST = withRouteContext('vat.validate', async (request, { supabase, companyId }) => { // VIES is a live external call to the EU Commission: block in the sandbox // so the demo can't generate background traffic against it. const blocked = await guardSandbox(supabase, companyId) @@ -42,4 +31,4 @@ export async function POST(request: Request) { } return NextResponse.json(validation) -} +}) diff --git a/components/bookkeeping/BookingTemplatePicker.tsx b/components/bookkeeping/BookingTemplatePicker.tsx index 6a92e86d..1949c759 100644 --- a/components/bookkeeping/BookingTemplatePicker.tsx +++ b/components/bookkeeping/BookingTemplatePicker.tsx @@ -17,7 +17,7 @@ import type { BookingTemplateLibrary, BookingTemplateCategory, EntityType } from import type { FormLine } from '@/components/bookkeeping/JournalEntryForm' interface Props { - onApply: (lines: FormLine[], description: string) => void + onApply: (lines: FormLine[], description: string, category?: BookingTemplateCategory) => void entityType?: EntityType /** Prefill the "total amount" field when the caller already knows it (e.g. * booking from an underlag with a known total). The user can still edit it. */ @@ -114,7 +114,7 @@ export default function BookingTemplatePicker({ onApply, entityType, defaultAmou const lines = applyTemplate(selected.lines, totalAmount) // Fire-and-forget MRU bump so this template surfaces at the top next time. fetch(`/api/settings/booking-templates/${selected.id}/touch`, { method: 'POST' }).catch(() => {}) - onApply(lines, selected.name) + onApply(lines, selected.name, selected.category) setOpen(false) setSelectedId(null) setAmount('') @@ -135,14 +135,15 @@ export default function BookingTemplatePicker({ onApply, entityType, defaultAmou {/* Search + category filter */} -
-
- +
+
+ setSearch(e.target.value)} placeholder="Sök mall..." className="pl-9" + autoFocus />
diff --git a/components/bookkeeping/JournalEntryForm.tsx b/components/bookkeeping/JournalEntryForm.tsx index 26efe9ea..aacadc6b 100644 --- a/components/bookkeeping/JournalEntryForm.tsx +++ b/components/bookkeeping/JournalEntryForm.tsx @@ -21,6 +21,7 @@ import LineDimensionFields from '@/components/dimensions/LineDimensionFields' import { loadBasCatalog, type CatalogAccount } from '@/lib/bookkeeping/bas-catalog-client' import BookingTemplatePicker from '@/components/bookkeeping/BookingTemplatePicker' import { deriveTemplateLinesFromBooking } from '@/lib/bookkeeping/template-library' +import { sourceTypeForTemplateCategory } from '@/lib/bookkeeping/template-source-type' import { TemplateForm } from '@/components/settings/TemplateForm' import CreatePeriodDialog from '@/components/bookkeeping/CreatePeriodDialog' import { ActivateAccountsDialog } from '@/components/bookkeeping/ActivateAccountsDialog' @@ -37,7 +38,7 @@ import { formatVoucher, resolveDefaultSeriesForSource } from '@/lib/bookkeeping/ import { useUnsavedChanges } from '@/lib/hooks/use-unsaved-changes' import { useCompany } from '@/contexts/CompanyContext' import type { UploadedFile } from '@/components/bookkeeping/DocumentUploadZone' -import type { CreateJournalEntryLineInput, FiscalPeriod, BASAccount, JournalEntrySourceType, Currency, BookingTemplateLibrary } from '@/types' +import type { CreateJournalEntryLineInput, FiscalPeriod, BASAccount, JournalEntrySourceType, Currency, BookingTemplateLibrary, BookingTemplateCategory } from '@/types' import type { BookedDuplicateCandidate } from '@/lib/transactions/booking-duplicate-detection' const CURRENCIES: { value: Currency; label: string }[] = [ @@ -134,6 +135,23 @@ export default function JournalEntryForm({ initialLines ?? [{ ...BLANK_LINE }, { ...BLANK_LINE }] ) const [voucherSeries, setVoucherSeries] = useState(initialVoucherSeries ?? 'A') + // The source_type the entry will be committed with. Seeded from the prop + // (undefined -> 'manual' for the standalone form). Applying a booking template + // whose category maps to a dedicated source type (e.g. VAT -> vat_settlement) + // flips this so the entry lands in that type's configured voucher series. + const [effectiveSourceType, setEffectiveSourceType] = useState( + sourceType ?? 'manual', + ) + // Cache of the company's series config so template routing can re-resolve the + // default series without re-fetching /api/settings. Populated by the settings + // effect below. + const seriesMapRef = useRef | null>(null) + const defaultSeriesRef = useRef('A') + // Mirror of effectiveSourceType for the settings-fetch callback: if a template + // routed the source type before /api/settings resolved, the late callback must + // re-apply the series for the ROUTED type, not the mount-time base (otherwise + // the entry submits as vat_settlement in the manual series). + const effectiveSourceTypeRef = useRef(sourceType ?? 'manual') const [nextVoucherNumber, setNextVoucherNumber] = useState(null) const [isSubmitting, setIsSubmitting] = useState(false) // Booking-time duplicate guard (TRANSACTION_BOOK_POSSIBLE_DUPLICATE): the @@ -222,6 +240,15 @@ export default function JournalEntryForm({ setAccounts(data || []) } + // Resolve + set the default voucher series for a source type from the cached + // company config: prefer the per-source-type mapping, fall back to the legacy + // default_voucher_series, then to 'A'. Reads refs (stable), so it can run both + // on load and when template application changes the source type. + const applySeriesForSourceType = useCallback((st: JournalEntrySourceType) => { + const perSource = resolveDefaultSeriesForSource(seriesMapRef.current, st) + setVoucherSeries(perSource !== 'A' ? perSource : defaultSeriesRef.current || 'A') + }, []) + useEffect(() => { fetchPeriods() fetchAccounts() @@ -235,17 +262,14 @@ export default function JournalEntryForm({ fetch('/api/settings').then(r => r.json()).then(({ data }) => { if (!data) return setDimensionsEnabled(data.dimensions_enabled === true) + seriesMapRef.current = + (data.default_voucher_series_per_source_type as Record | null) ?? null + defaultSeriesRef.current = data.default_voucher_series || 'A' if (!embedded && !editEntryId) { - const effectiveSourceType = sourceType ?? 'manual' - const perSource = resolveDefaultSeriesForSource( - data as { default_voucher_series_per_source_type?: Record | null } | null, - effectiveSourceType, - ) - const fallback = data.default_voucher_series || 'A' - setVoucherSeries(perSource !== 'A' ? perSource : fallback) + applySeriesForSourceType(effectiveSourceTypeRef.current) } }).catch(() => {/* keep 'A' + hidden dimension affordances */}) - }, [embedded, sourceType, editEntryId]) + }, [embedded, sourceType, editEntryId, applySeriesForSourceType]) // Auto-select period when entry date changes useEffect(() => { @@ -650,15 +674,36 @@ export default function JournalEntryForm({ const selectedPeriodObj = periods.find((p) => p.id === selectedPeriod) const selectedPeriodLocked = !!(selectedPeriodObj?.locked_at || selectedPeriodObj?.is_closed) - const handleTemplateApply = (templateLines: FormLine[], templateDescription: string) => { + const handleTemplateApply = ( + templateLines: FormLine[], + templateDescription: string, + category?: BookingTemplateCategory, + ) => { setLines(templateLines) if (!description) setDescription(templateDescription) + // Route templates whose category maps to a dedicated source type (VAT -> + // vat_settlement) so the entry books into that type's configured series. + // Create mode only: embedded/edit keep their caller-provided source type. + // Non-mapped categories fall back to the form's base source type, which + // also reverts a prior VAT routing if the user swaps templates. + if (!embedded && !editEntryId) { + const base = sourceType ?? 'manual' + const routed = sourceTypeForTemplateCategory(category) ?? base + if (routed !== effectiveSourceType) { + setEffectiveSourceType(routed) + effectiveSourceTypeRef.current = routed + applySeriesForSourceType(routed) + } + } } // Wipe the form back to a blank entry. Mirrors the post-submit reset: it // clears the data the user typed (lines, description, note, attachments, - // currency) but keeps the contextual defaults (period, date, series) so the - // form is immediately ready for the next entry. + // currency) but keeps the contextual defaults (period, date) so the form is + // immediately ready for the next entry. Template-routed source type does NOT + // survive a clear: the next entry is hand-typed, and a sticky vat_settlement + // would tag it into the moms series and skip the manual-entry underlag + // tracking. Posted entries are immutable, so that mistag is storno-only. const handleClearAll = () => { setDescription('') setNotes('') @@ -668,6 +713,14 @@ export default function JournalEntryForm({ setEntryCurrency('SEK') setExchangeRate('') setForeignAmount('') + if (!embedded && !editEntryId) { + const base = sourceType ?? 'manual' + if (base !== effectiveSourceType) { + setEffectiveSourceType(base) + effectiveSourceTypeRef.current = base + applySeriesForSourceType(base) + } + } } const handleOpenCreateAccount = (lineIndex: number, prefill: string) => { @@ -810,7 +863,7 @@ export default function JournalEntryForm({ fiscal_period_id: selectedPeriod, entry_date: entryDate, description, - source_type: sourceType ?? 'manual', + source_type: effectiveSourceType, source_id: sourceId, voucher_series: voucherSeries || 'A', notes: notes || undefined, @@ -822,7 +875,7 @@ export default function JournalEntryForm({ }), }) return (await throwOnStructuredError(res)) as { data?: { id?: string; voucher_series?: string; voucher_number?: number }; journal_entry_id?: string } - }, [lines, isForeign, rate, entryCurrency, computedForeignAmount, submitUrl, editEntryId, selectedPeriod, entryDate, description, sourceType, sourceId, voucherSeries, notes]) + }, [lines, isForeign, rate, entryCurrency, computedForeignAmount, submitUrl, editEntryId, selectedPeriod, entryDate, description, effectiveSourceType, sourceId, voucherSeries, notes]) const { runSubmit, dialog: activationDialog, confirm: confirmActivation, cancel: cancelActivation } = useSubmitWithAccountActivation(postJournalEntry) diff --git a/components/bookkeeping/JournalEntryStatusBadge.tsx b/components/bookkeeping/JournalEntryStatusBadge.tsx index 8720f558..90b3d217 100644 --- a/components/bookkeeping/JournalEntryStatusBadge.tsx +++ b/components/bookkeeping/JournalEntryStatusBadge.tsx @@ -36,6 +36,7 @@ const SOURCE_TYPES = [ 'supplier_invoice_cash_payment', 'currency_revaluation', 'reminder_fee', + 'vat_settlement', ] as const /** diff --git a/components/reports/ReportExportMenu.tsx b/components/reports/ReportExportMenu.tsx index 76a954c0..2e9b153f 100644 --- a/components/reports/ReportExportMenu.tsx +++ b/components/reports/ReportExportMenu.tsx @@ -1,6 +1,6 @@ 'use client' -import { Download, FileSpreadsheet, FileText, Table } from 'lucide-react' +import { Download, FileCode, FileSpreadsheet, FileText, Table } from 'lucide-react' import { useTranslations } from 'next-intl' import { Button } from '@/components/ui/button' import { @@ -10,8 +10,11 @@ import { DropdownMenuTrigger, } from '@/components/ui/dropdown-menu' -/** pdf/xlsx for reports; csv is additionally used by register exports. */ -export type ExportMenuFormat = 'pdf' | 'xlsx' | 'csv' +/** + * pdf/xlsx for reports; csv is additionally used by register exports; xml is the + * Skatteverket eSKD momsdeklaration file. + */ +export type ExportMenuFormat = 'pdf' | 'xlsx' | 'csv' | 'xml' export interface ReportExportItem { format: ExportMenuFormat @@ -56,6 +59,8 @@ export function ReportExportMenu({ ) : item.format === 'csv' ? ( + ) : item.format === 'xml' ? ( + ) : ( )} @@ -63,7 +68,9 @@ export function ReportExportMenu({ ? t('download_pdf') : item.format === 'csv' ? t('download_csv') - : t('download_excel')} + : item.format === 'xml' + ? t('download_xml') + : t('download_excel')} ))} diff --git a/components/reports/views/index.tsx b/components/reports/views/index.tsx index 477b74eb..e13c3900 100644 --- a/components/reports/views/index.tsx +++ b/components/reports/views/index.tsx @@ -10,7 +10,7 @@ import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' import { Button } from '@/components/ui/button' import { Label } from '@/components/ui/label' import { Badge } from '@/components/ui/badge' -import { AlertCircle, ChevronDown, ChevronRight, ExternalLink, FileDown, Percent } from 'lucide-react' +import { AlertCircle, ChevronDown, ChevronRight, ExternalLink, FileCode, FileDown, Percent } from 'lucide-react' import AgentSparkleButton from '@/components/agent/AgentSparkleButton' import { Skeleton } from '@/components/ui/skeleton' import { EmptyState } from '@/components/ui/empty-state' @@ -1052,11 +1052,12 @@ const SKATTEVERKET_MOMS_URL = * Manual-filing affordance shown directly under the calculated momsdeklaration. * The report is generated purely from the bookkeeping and never depends on the * Skatteverket connection, so every user (including core builds with the - * skatteverket extension disabled) can file manually. This card makes that - * explicit and offers the momsdeklaration as a PDF (in hela kronor) to read off - * while filling in the form at skatteverket.se. + * skatteverket extension disabled) can file manually. Two paths are offered: + * an eSKD XML file to upload directly under "Deklarera via fil" (the fast path), + * and a PDF (in hela kronor) to read off if the user would rather type the + * boxes into the form. A PDF cannot be uploaded to Skatteverket, only the XML. */ -function VatManualFilingCard({ pdfHref }: { pdfHref: string }) { +function VatManualFilingCard({ xmlHref, pdfHref }: { xmlHref: string; pdfHref: string }) { return ( @@ -1065,15 +1066,21 @@ function VatManualFilingCard({ pdfHref }: { pdfHref: string }) {

Deklarationen ovan är klar. Du behöver inte vara ansluten till - Skatteverket för att lämna in den, du kan fylla i beloppen direkt hos - Skatteverket. + Skatteverket för att lämna in den.

- Ladda ner momsdeklarationen som PDF (belopp i hela kronor). Logga sedan - in på skatteverket.se med BankID, öppna Moms- och - arbetsgivardeklarationer och skriv in rutorna. + Enklast: ladda ner filen (XML), logga in på skatteverket.se med BankID, + öppna Moms- och arbetsgivardeklarationer och välj Deklarera via fil. + Ladda upp filen, granska och signera. Vill du hellre fylla i rutorna + för hand laddar du ner PDF:en och skriver av beloppen (i hela kronor).

+