From f24b26a139798230d7727d28122e8cd1fd7c0938 Mon Sep 17 00:00:00 2001 From: Mattsson <111893710+mattssonn@users.noreply.github.com> Date: Mon, 27 Jul 2026 03:34:56 +0200 Subject: [PATCH] fix: similar-sweep currency remediation, security hardening and v1 API fixes (#1215) * fix(security): gate replace_sie_import behind owner/admin membership The RPC was SECURITY DEFINER with EXECUTE granted to PUBLIC and anon, no company_members lookup, no auth.uid() reference and no unauthorized raise, while setting gnubok.allow_delete to disarm the BFL immutability and retention triggers. Any caller holding a company_id and an import id could hard delete another tenant's verifikationer. Confirmed live in production. Applies the same fail closed owner/admin guard that undo_sie_import already carries (migration 20260624120000), resolving the actor from COALESCE(p_user_id, auth.uid()) so it denies when the role is NULL, then revokes EXECUTE from PUBLIC and anon. search_path and the raised statement_timeout are restated, since CREATE OR REPLACE drops settings that are not repeated. userId is a required parameter on replaceSIEImport: the service client has a NULL auth.uid(), so a caller without an explicit actor now fails to compile rather than hitting the closed gate at runtime. Co-Authored-By: Claude Opus 5 (1M context) * fix(security): validate arcim OAuth callback state server side The callback route is skipAuth and decoded the state parameter as plain base64url JSON, trusting consentId and provider from it. A one time code was minted at flow start and never read. An unauthenticated attacker who learned a consent id could run an OAuth flow on their own provider account and post the callback with a forged state, landing their tokens on another tenant's consent, so the victim's next migration imported the attacker's ledger. State is now an opaque randomBytes(32) pointer to a provider_otc row, consumed by a single atomic UPDATE guarded on used_at IS NULL and expires_at, so a replay loses the row lock race and updates nothing. provider is read from provider_consents rather than trusted from the client. provider_otc already existed for exactly this purpose and was never wired up. Also scopes getConsent to an owning company, closing a cross tenant status oracle where the preview and migrate paths echoed a consent's status before the scoped check ran. Co-Authored-By: Claude Opus 5 (1M context) * fix(security): scope documents storage to company_id (phase A) The documents bucket policies matched on auth.uid(), and upload keys were documents/{userId}/..., so company membership was never consulted. Removing a member revoked nothing: their session still authenticated and they kept direct Storage read access to every receipt, supplier invoice and bank statement they had uploaded. The same bug was fixed for sie-files in 20260416120000; this bucket was left behind. Phase A is additive. Company scoped policies are added alongside the uploader scoped ones, uploads move to documents/{companyId}/{userId}/..., and reads accept either layout so nothing breaks mid migration. Phase C, which drops the old policies, is gated on the backfill reporting zero remaining legacy prefix objects. The policy compares the company segment as text rather than casting to uuid the way sie-files does: this bucket holds keys whose second segment is not a uuid (MCP audit packages), and Postgres does not guarantee the bucket prefix qual runs before the cast, so a planner reordering would raise 22P02 and fail the whole query instead of filtering the row out. deleteDocument now removes both candidate keys. Removing only the stored pointer would leave a readable orphan copy of a document the user asked to erase. The backfill script is included but has never been run. It defaults to dry run, refuses .env.local by name, and verifies each copy is readable and SHA-256 identical before repointing the row. Co-Authored-By: Claude Opus 5 (1M context) * fix(security): enforce events:read scope and membership on /api/events This was the only one of the three validateApiKey call sites with no downstream guard: v1 and the MCP server both check scope and re-verify company membership, this route did neither. An events:read scope existed and was documented as gating the endpoint but was never called, so a legacy key falling back to DEFAULT_SCOPES read the full log. The bound company id went straight from the api_keys row into a service role query, so a key whose user had been removed from the company kept reading. Adds the scope check before any database access, re-verifies company_members with archived_at IS NULL, honours test mode by stamping X-Gnubok-Mode instead of ignoring it, applies minimisePayload so the pull surface can never return a wider payload than the push surface, and replaces the three flat error strings with the canonical envelope. Test key reads are served rather than blocked: TEST_KEY_WRITE_BLOCKED is gated on mutations in with-api-v1, so a read gets the same treatment as every other v1 read endpoint. Co-Authored-By: Claude Opus 5 (1M context) * perf(bookkeeping): sweep remaining journal_entries!inner embeds A previous refactor removed this pattern from lib/reports and introduced fetchEntryLines, but the class was never swept. Seventeen sites remained and had become the top application consumer of production database time: measured across the resulting query shapes, 32,694 calls and 25,848 seconds of execution, mean 790ms, with shapes averaging 2.6s and 3.0s and maxing at 7,962ms against the 8s statement_timeout, which surfaced to users as 500s on the booking path. PostgREST compiles an embed with filters on the embedded side into a correlated INNER JOIN LATERAL with a parameterized LIMIT, which stops Postgres reordering the join, so each query walked the whole journal_entry_lines table across all tenants. Driving from the entries side instead turns that into two indexed round trips. Converted sites keep their existing shape: the helper reattaches the parent entry under the same key the embed produced. Several conversions also remove a latent silent truncation where an unpaginated query was capped at PostgREST's 1000 row ceiling. Two deliberate exceptions. The free text ilike legs of the MCP display query stay on the embed, because each is capped at legLimit and that cap drives the truncation contract the tool reports, while the helper is unbounded. The accounts route moves to the existing get_account_usage_counts RPC instead, since its embed was a head count and the helper returns rows. commitEntry's write path is untouched: the change there is confined to the read query of the pre-commit dimension rule check. Co-Authored-By: Claude Opus 5 (1M context) * fix(api): anchor v1 list cursors on created_at Page two returned page one, forever, while still advertising a fresh next_cursor. The three routes sorted by and encoded a Postgres date column, which serializes as YYYY-MM-DD, but decodeDefaultCursor validates the cursor timestamp as full ISO-8601 and returned null, so the keyset filter was never applied and has_more never went false. An integrator syncing verifikat looped on the newest rows indefinitely. The transactions route already solved this and its comment names the trap; the fix was never ported. All three now order and encode on created_at with an id tie break, matching the transactions keyset predicate exactly. ISO_TIMESTAMP is deliberately left alone: relaxing it would silently change sort semantics on the route that currently works. Default ordering therefore moves from business date to insert order. Every business date is still on the row, and the invoices list gains date_from and date_to filters so a date range is still reachable; the other two already had them. The tests use an in-memory PostgREST that actually evaluates the filters, because the repo's pass-through mock cannot catch this class of bug: the bug is that the filter is never sent. They walk to exhaustion with a hard iteration cap, so an unterminated walk fails instead of hanging. Co-Authored-By: Claude Opus 5 (1M context) * fix(api): separate dry run from commit in the idempotency hash The request hash was built from url.pathname, which excludes the query string, so a dry run and its commit hashed identically. Following the flow documented in dry-run.ts, re-issuing the request with the same Idempotency-Key returned the cached preview with Idempotent-Replayed set and wrote nothing, while reporting 200. An agent or integrator saw success for a write that never happened. dry_run is folded into the hash only when true, not as an unconditional boolean. Including it as false would change the hash of every ordinary write, and with a 24h idempotency TTL any key in flight across the deploy would fail the request_hash comparison and 409 on a legitimate retry. Both hash call sites now go through one shared helper so they cannot drift into a permanent cache miss, and dry run responses are no longer stored at all. Co-Authored-By: Claude Opus 5 (1M context) * ci: install the Bedrock SDK out of tree in the compliance review The Swedish accounting compliance gate had failed ten consecutive runs and so was posting nothing. With --no-package-lock npm discarded the lockfile and re-resolved the whole tree from package.json, floating @hookform/resolvers to 5.4.3, whose valibot ^1 peer conflicts with the pinned valibot 0.39.0. Installing into the parent of the checkout resolves only that one package, so an unrelated peer conflict can never take the gate down again. Node still finds it because ESM bare specifiers walk up parent node_modules; NODE_PATH would not have worked, as it is CommonJS only. --legacy-peer-deps was rejected because it masks future genuine peer conflicts and still reifies the full tree. The same step's SDK version is aligned from 0.31.0 back to the 0.29.1 that package.json and check:guards enforce after the streaming outage. That drift went unnoticed because the pin guard only inspects package.json and the lockfile, never workflow files. Co-Authored-By: Claude Opus 5 (1M context) * build(docker): generate crontabs from vercel.json vercel.json defines 16 cron jobs; both Docker crontabs carried 9, and were byte identical to each other. Self hosted deployments therefore never sent recurring invoices, never dispatched webhooks and never cleaned up idempotency keys. tax-deadlines also ran once a year on 2 January instead of daily, and documents/verify weekly instead of daily. Extension crons are included rather than excluded. The Dockerfile copies the whole tree before building, so every extension cron route is compiled into the image regardless of the enabled preset, and each returns 200 when its extension is unconfigured, so curl -sf logs no failure. Two such entries were already present in the crontab for extensions absent from the preset, which settles the intent. documents/verify is treated as drift rather than a self hosted concession: the weekly cadence was present in the hosted crontab too, and the run is capped at 200 documents walking a nulls-first queue, so weekly drains the integrity queue seven times slower on a check that exists for BFL retention. webhooks/dispatch keeps its per minute cadence, adding 1,440 requests a day on self hosted. A gentler tick would silently stretch the first retry, since the retry ladder opens at 60 seconds. SCHEDULE_OVERRIDES is the one line place to change that. A parity test asserts the path sets match minus a documented exclusion list, and ratchets three cron routes that are currently scheduled nowhere so they are named rather than silently rotting. Co-Authored-By: Claude Opus 5 (1M context) * chore(observability): add a provider agnostic error sink There is no error tracking in this codebase: logs go to console and Vercel retention and nowhere else, nothing alerts on the 16 cron jobs, and seven code comments across lib, app, components and extensions asserted that Sentry captures errors when Sentry is not a dependency. The two most recent bug fixes on this repo were both discovered by customer email. This adds the sink, not a vendor. No dependency is taken: the interface has a no-op default and a registration point, so behaviour is unchanged until an adapter is registered. Releases are tagged from the build id already inlined by next.config.ts. Redaction moved out of lib/logger.ts into a leaf module that both the logger and the sink import, so there is one denylist and no path from application data to a third party can skip the personnummer regex, including direct sink calls that bypass the logger. That matters here because these logs carry personnummer and financial data. verifyCronSecret now reports its own 401s, which covers all 16 jobs without touching a route file and catches the case where CRON_SECRET is rotated without updating the scheduler and every job silently 401s forever. The threshold is one failure rather than the backup alert's three: suppressing the first occurrence is precisely how an outage stays invisible. The seven misleading comments are corrected to describe what the code actually does, including the two cases that still are not covered: the client side one, since the sink is server side, and a warn level call that is not forwarded. Co-Authored-By: Claude Opus 5 (1M context) * fix: remediate the 2026-07-26 similar-sweep findings across all surfaces Resolves the ~150-finding sweep (dev_docs/similar-sweep-2026-07-26.md) with one agent per finding; every behavioural fix carries a regression test proven to fail at HEAD. Full status, corrections to the sweep, refusals and open decisions in dev_docs/similar-sweep-2026-07-26-remediation-status.md. Structural roots closed: - resolveSekAmountOrNull(): honest SEK resolution refuses instead of booking 1:1; four duplicated toSek closures now refuse via INVOICE_FX_RATE_MISSING - ledger-line-amount.ts: journal_entry_lines.currency labels the document, not the amount; SQL pre-filter decoy proven and fixed - sparse-patch.ts: .partial() does not strip .default() in Zod 4.4.3; the exploitable salary payslip-line PATCH and KPI preferences sinks fixed - tests/schema: migration-replay phantom-column guard (13k+ refs, closed CHECK sets, onConflict targets); found 28 real defects, all fixed, all four baselines now empty - three new ratchet guards: sek-labelled-amount, cross-extension-import, ungated-extension-route Highlights: lawful VAT-rate set on all seven invoice surfaces (ML 6 kap), RC input VAT mismatch wired on web + both MCP callers, missing-underlag resource delegates to the shared RPC predicate, push-notifications consent polarity fail-closed, deadlines undo honours requested state, silent-failure and read-side-fabrication classes fixed across settings/KPI/inbox/Stripe/ Arcim/kassaflodesanalys, error-envelope stringification fixed at 10+ sites with isSwedishUserMessage extended. Also includes the parallel session's MCP invoice tools (update_invoice, recurring schedules, invoice deliveries) which share files with the sweep work and are verified green together. 13 new migrations are NOT applied anywhere; they apply via branch merge. 20260726120000 backfills 1247 supplier-invoice rows. pg tests for new DDL are written but unrun (no local Postgres). Verified: 11088 tests / 881 files green, tsc 0 non-test errors, lint 0 errors, check:guards passing, MCP payload 57475/57500. Co-Authored-By: Claude Fable 5 * fix(migrations): rename replace_sie_import migration off main's 20260726090000 version origin/main shipped 20260726090000_agent_quota_rpc_caller_guard.sql; keeping our replace_sie_import migration on the same version would abort the Supabase apply with a schema_migrations_pkey duplicate at merge time. Co-Authored-By: Claude Fable 5 * fix(review): remediate pre-publish deep-review findings across all slices A 13-agent review of the full branch diff surfaced 1 critical, 5 high and ~45 further findings; this commit resolves them in one pass: - replace_sie_import / undo_sie_import: p_user_id honored only for service_role callers; any other caller is pinned to auth.uid() (impersonation gate bypass), authz raise errcode 42501 mapped to a Swedish 403 in the route, new caller-guard migration for undo - bulk_book_transactions refuses homogeneous non-SEK batches instead of writing foreign magnitudes into SEK ledger columns - credit-note cap trigger: company-match on credited_invoice_id, no cross-tenant figures in exception text - link_voucher RPCs resolve NULL invoice currency as SEK end to end - personal-number ciphertext CHECK split into NOT VALID + VALIDATE - same-currency foreign settlements clear 1510 at booking rate and book realized diff to 3960/7960; rate-less foreign write paths refuse - receivables revaluation covers partially_paid and outstanding amounts - period lock guard paginates candidates past the PostgREST 1000 cap - documents: service-client storage removals after authz, dual-layout reads in integrity cron and archive export, backfill delete-source sweep actually deletes with hash verification and shared-key grouping - invoice matching normalizes NULL/lowercase currencies (regression), duplicate candidates stop claiming amount matches they never ran - match-invoice aborts on any booking failure (no paid-without-verifikat) - refresh-exchange-rate reverts on concurrent booking (TOCTOU window) - KPI preferences upsert arbiter aligned to the company-scoped constraint - personnummer_last4 stripped from all salary responses incl. MCP tools - worked-hours batch restores destroyed rows on conflict and error paths - MCP: shared duplicate-claim builder (no more 'null kr'), short-circuit on tag_journal_lines overflow, auto_send schedules stage as high risk - observability sink redacts emails/IBANs/API keys and keeps redacted stacks in prod; assorted small guards (safe-return-to /@, dry_run=True, cursor helper off-by-one, OAuth state TTL 10 min, arcim saveMappings call removed) Full dispositions, deferred items and hand-verified accounting numbers are documented in the PR body and DECISIONS.md. Co-Authored-By: Claude Fable 5 * feat(personnummer): implement masking and encryption for personal numbers with tests * fix(review): address CI and compliance-bot findings for PR #1215 pg-real: the CI image's auth shim reads the legacy request.jwt.claim.role GUC, so both service-role simulations (runAsServiceRole and the invoice-delivery test's local helper) never satisfied auth.role() = 'service_role' and every legitimate p_user_id path failed closed; the shared helper now sets both GUC shapes plus SET LOCAL ROLE with a fail-loud sanity check, and the delivery test reuses it. The link-voucher migration had recreated both RPCs from pre-rewrite file text, reintroducing the NULL-unsafe membership pattern the null-safe-tenant-guards ratchet bans; both guards now use public.caller_is_company_member() with all currency changes preserved. Compliance bots: the customers export now emits the standard masked form instead of raw AES-256-GCM ciphertext in the Org-/personnummer column, and maskCustomerRow returns a non-round-trippable placeholder on decrypt failure instead of 500ing the list. MCP parity: gnubok_lock_period's staging pre-check now runs the exact countUnbookedInPeriod the commit path enforces (exported from period-service; local mirror deleted), and gnubok_agi_status resolves AGI state run-scoped so a correction run no longer renders as already filed. Declined with evidence: PR-Agent's opening-balances null-zeroing concern (all mergeable columns are NOT NULL with defaults per 20260713101000). Co-Authored-By: Claude Fable 5 * fix(review): address codex review findings on PR #1215 - restore 20260726140000 to its preview-recorded content and restate the NULL-safe tenant guard under 20260727130000: a recorded migration version never re-runs, so the in-place edit could not reach the preview branch - replace toFixed() with sv-SE two-decimal formatting in the ROT/RUT cap warning texts and update the pinned test expectations - drop the em dash in the fiscal-periods route comment - strip trailing whitespace in import-existing.test.ts Co-Authored-By: Claude Fable 5 * test(reports): raise timeout on real PDF render tests renderToBuffer does real @react-pdf layout work and exceeds the 5s default when the full suite saturates the CPU; tests pass in isolation. Co-Authored-By: Claude Fable 5 --------- Co-authored-by: Claude Opus 5 (1M context) --- .claude/rules/bookkeeping.md | 5 +- .env.example | 18 + .github/workflows/docker-image-scan.yml | 6 + .github/workflows/docker-publish.yml | 23 +- .../workflows/swedish-compliance-review.yml | 21 +- .github/workflows/test-pg-real.yml | 8 +- DECISIONS.md | 168 ++ app/(auth)/login/page.tsx | 70 +- app/(auth)/mfa/verify/page.tsx | 46 +- .../__tests__/invite-email-prefill.test.ts | 181 ++ app/(auth)/register/page.tsx | 79 +- .../__tests__/invite-handoff.test.ts | 365 +++ app/(auth)/reset-password/invite-handoff.ts | 135 ++ app/(auth)/reset-password/page.tsx | 31 + app/(dashboard)/articles/[id]/page.tsx | 1 + app/(dashboard)/articles/page.tsx | 1 + app/(dashboard)/deadlines/page.tsx | 335 ++- app/(dashboard)/import/page.tsx | 11 +- app/(dashboard)/invoices/[id]/credit/page.tsx | 23 +- app/(dashboard)/invoices/[id]/page.tsx | 233 +- app/(dashboard)/kpi/page.tsx | 203 +- .../KassaflodesanalysClient.tsx | 88 +- .../salary/employees/[id]/page.tsx | 11 +- app/(dashboard)/salary/employees/page.tsx | 6 +- app/(dashboard)/salary/page.tsx | 4 +- .../runs/[id]/employees/[employeeId]/page.tsx | 32 +- app/(dashboard)/salary/runs/[id]/page.tsx | 516 +++-- app/(dashboard)/skattekonto/page.tsx | 32 +- app/(dashboard)/transactions/page.tsx | 10 +- app/(onboarding)/onboarding/agent/page.tsx | 38 +- app/(public)/invoice-action/[token]/page.tsx | 32 +- .../bookkeeping/accounts/[number]/route.ts | 43 +- .../accounts/__tests__/accounts.test.ts | 63 +- .../fiscal-periods/__tests__/route.test.ts | 123 +- app/api/bookkeeping/fiscal-periods/route.ts | 98 +- .../[id]/correct/__tests__/route.test.ts | 12 +- .../[id]/recordate/__tests__/route.test.ts | 7 +- app/api/customers/[id]/route.ts | 23 +- .../__tests__/personal-number.test.ts | 117 +- .../[id]/complete/__tests__/route.test.ts | 256 +++ app/api/deadlines/[id]/complete/route.ts | 56 +- .../__tests__/import-existing.test.ts | 58 +- app/api/dimensions/import-existing/route.ts | 25 +- .../tagging/__tests__/lines.test.ts | 24 + app/api/dimensions/tagging/lines/route.ts | 44 +- .../documents/[id]/__tests__/route.test.ts | 25 +- .../verify/cron/__tests__/route.test.ts | 26 + app/api/documents/verify/cron/route.ts | 17 +- app/api/events/__tests__/route.test.ts | 240 +- app/api/events/route.ts | 140 +- .../export/customers/__tests__/route.test.ts | 75 +- app/api/export/customers/route.ts | 10 +- .../cron/__tests__/route.test.ts | 114 + .../push-notifications/cron/route.ts | 21 +- .../skatteverket/skattekonto/drift/route.ts | 5 +- .../import/opening-balance/correct/route.ts | 3 +- .../sie/[id]/replace/__tests__/route.test.ts | 118 + app/api/import/sie/[id]/replace/route.ts | 17 +- .../sie/mappings/__tests__/route.test.ts | 316 ++- app/api/import/sie/mappings/route.ts | 96 +- app/api/invoices/[id]/__tests__/patch.test.ts | 4 + .../[id]/mark-paid/__tests__/route.test.ts | 224 ++ app/api/invoices/[id]/mark-paid/route.ts | 64 +- .../__tests__/route.test.ts | 403 ++++ .../[id]/refresh-exchange-rate/route.ts | 263 +++ app/api/invoices/[id]/route.ts | 32 +- app/api/invoices/__tests__/route.test.ts | 39 + .../reminders/action/__tests__/route.test.ts | 131 ++ app/api/invoices/reminders/action/route.ts | 25 +- app/api/invoices/route.ts | 16 + .../self-billed/__tests__/route.test.ts | 98 +- .../kpi/preferences/__tests__/route.test.ts | 122 +- app/api/kpi/preferences/route.ts | 65 +- .../unmatched-entries/__tests__/route.test.ts | 251 ++ .../bank/unmatched-entries/route.ts | 59 +- .../ar-ledger/pdf/__tests__/route.test.ts | 133 ++ app/api/reports/ar-ledger/pdf/route.ts | 6 + .../pdf/__tests__/route.test.ts | 217 ++ app/api/reports/kpi/__tests__/route.test.ts | 135 +- app/api/reports/kpi/route.ts | 49 +- .../reports/kpi/xlsx/__tests__/route.test.ts | 266 +++ app/api/reports/kpi/xlsx/route.ts | 46 +- .../invoices/__tests__/route.test.ts | 229 +- .../supplier/[supplierId]/invoices/route.ts | 90 +- .../sources/__tests__/route.test.ts | 67 +- .../account/[accountNumber]/sources/route.ts | 62 +- .../vat-declaration/__tests__/route.test.ts | 61 + .../[ruta]/sources/__tests__/route.test.ts | 138 +- .../ruta/[ruta]/sources/route.ts | 70 +- .../personnummer-write-guard.test.ts | 179 ++ .../employees/[id]/__tests__/route.test.ts | 139 +- .../[benefitId]/__tests__/route.test.ts | 201 +- .../[id]/benefits/[benefitId]/route.ts | 50 +- .../[id]/benefits/__tests__/route.test.ts | 72 + .../salary/employees/[id]/benefits/route.ts | 10 +- app/api/salary/employees/[id]/route.ts | 79 +- .../__tests__/recording-supabase.ts | 92 + .../[id]/worked-hours/__tests__/route.test.ts | 248 +- .../batch/__tests__/route.test.ts | 297 ++- .../[id]/worked-hours/batch/route.ts | 121 +- .../employees/[id]/worked-hours/route.ts | 16 +- .../salary/employees/__tests__/route.test.ts | 93 +- app/api/salary/employees/route.ts | 20 +- .../salary/runs/[id]/__tests__/route.test.ts | 55 +- .../[employeeId]/__tests__/route.test.ts | 55 +- .../runs/[id]/employees/[employeeId]/route.ts | 20 +- .../[lineId]/__tests__/sparse-patch.test.ts | 123 + .../salary/runs/[id]/lines/[lineId]/route.ts | 14 +- app/api/salary/runs/[id]/route.ts | 11 +- .../[id]/__tests__/route.test.ts | 284 +++ .../settings/booking-templates/[id]/route.ts | 62 +- .../__tests__/duplicate-guard-band.test.ts | 272 +++ .../[id]/mark-paid/__tests__/route.test.ts | 114 + .../supplier-invoices/[id]/mark-paid/route.ts | 122 +- .../supplier-invoices/__tests__/route.test.ts | 240 +- app/api/supplier-invoices/route.ts | 55 +- .../[id]/book/__tests__/route.test.ts | 4 +- app/api/transactions/[id]/book/route.ts | 18 +- .../[id]/categorize/__tests__/route.test.ts | 325 ++- .../suggestion-band-currency.test.ts | 370 +++ app/api/transactions/[id]/categorize/route.ts | 283 ++- .../[id]/duplicate-payment-check/route.ts | 11 +- .../match-invoice/__tests__/route.test.ts | 69 +- .../[id]/match-invoice/preview/route.ts | 39 +- .../transactions/[id]/match-invoice/route.ts | 115 +- .../match-supplier-invoice/preview/route.ts | 138 +- .../[id]/match-supplier-invoice/route.ts | 34 +- .../bulk-book/__tests__/route.test.ts | 232 +- app/api/transactions/bulk-book/route.ts | 97 +- .../__tests__/cursor-pagination.test.ts | 641 ++++++ .../compliance/check/__tests__/route.test.ts | 239 ++ .../[companyId]/compliance/check/route.ts | 95 +- .../[companyId]/customers/[id]/route.ts | 3 +- .../employees/__tests__/route.test.ts | 60 +- .../companies/[companyId]/employees/route.ts | 17 +- .../imports/bank/__tests__/route.test.ts | 294 +++ .../[companyId]/imports/bank/route.ts | 75 +- .../invoices/[id]/__tests__/route.test.ts | 368 +++ .../[id]/mark-paid/__tests__/route.test.ts | 221 ++ .../invoices/[id]/mark-paid/route.ts | 62 +- .../[companyId]/invoices/[id]/route.ts | 223 +- .../[id]/send/__tests__/route.test.ts | 6 +- .../invoices/__tests__/route.test.ts | 6 +- .../bulk-create/__tests__/route.test.ts | 70 + .../[companyId]/invoices/bulk-create/route.ts | 14 +- .../companies/[companyId]/invoices/route.ts | 38 +- .../[companyId]/journal-entries/route.ts | 27 +- .../supplier-invoices/__tests__/route.test.ts | 239 +- .../[companyId]/supplier-invoices/route.ts | 71 +- .../transactions/[id]/__tests__/route.test.ts | 53 +- .../[id]/categorize/__tests__/route.test.ts | 205 ++ .../transactions/[id]/categorize/route.ts | 20 +- .../transactions/[id]/match-invoice/route.ts | 231 +- .../[id]/match-supplier-invoice/route.ts | 86 +- .../batch-categorize/__tests__/route.test.ts | 83 +- .../transactions/batch-categorize/route.ts | 20 +- .../[token]/__tests__/invite-cookie.test.ts | 235 ++ app/invite/[token]/page.tsx | 55 +- components/agent-knowledge/LedgerGraph.tsx | 81 +- .../__tests__/ledger-graph-magnitude.test.ts | 163 ++ .../agent-knowledge/ledger-graph-magnitude.ts | 176 ++ components/agent/ApprovalCard.tsx | 27 +- components/articles/ArticleForm.tsx | 570 +++-- components/bookkeeping/AccountCombobox.tsx | 61 +- .../bookkeeping/AccrualPeriodControl.tsx | 21 +- components/bookkeeping/AddAccountDialog.tsx | 11 +- .../bookkeeping/ChartOfAccountsManager.tsx | 12 +- components/bookkeeping/CreatePeriodDialog.tsx | 217 +- .../bookkeeping/FiscalPeriodDateFields.tsx | 2 +- components/bookkeeping/JournalEntryForm.tsx | 253 ++- components/bookkeeping/JournalEntryList.tsx | 16 +- .../bookkeeping/NoDocRequiredToggle.tsx | 9 +- .../bookkeeping/PruneAccountsDialog.tsx | 12 +- .../__tests__/accrual-k2-hint.test.ts | 105 + components/bookkeeping/accrual-k2-hint.ts | 75 + components/customers/CustomerForm.tsx | 13 + components/dashboard/AttGoraSection.tsx | 7 +- components/deadlines/TaxTodoWidget.tsx | 13 +- .../deadlines/UpcomingDeadlinesWidget.tsx | 13 +- .../general/ArcimMigrationWorkspace.tsx | 22 +- .../general/BulkBookInboxDialog.tsx | 45 +- .../general/InvoiceInboxWorkspace.tsx | 353 ++- .../__tests__/bulk-book-inbox-totals.test.ts | 147 ++ .../__tests__/inbox-address-copy.test.ts | 69 + .../general/bulk-book-inbox-totals.ts | 106 + .../extensions/general/inbox-address-copy.ts | 35 + components/invoices/InvoiceEditor.tsx | 99 +- components/invoices/NewInvoiceDialog.tsx | 7 +- .../invoices/__tests__/line-vat-rates.test.ts | 197 ++ .../new-invoice-dialog-query-shape.test.ts | 43 + components/invoices/line-vat-rates.ts | 140 ++ components/kpi/KPISettingsDialog.tsx | 31 +- .../kpi/__tests__/load-preferences.test.ts | 200 ++ .../kpi/__tests__/save-preferences.test.ts | 193 ++ components/kpi/load-preferences.ts | 93 + components/kpi/save-preferences.ts | 125 + components/onboarding/agent/ReviewCard.tsx | 14 +- components/reports/SkatteverketPanel.tsx | 14 +- components/reports/views/index.tsx | 98 +- components/salary/AGIPanel.tsx | 50 +- components/salary/PaymentFilePanel.tsx | 43 +- components/salary/SalaryCalendar.tsx | 58 +- components/salary/TaxPaymentPanel.tsx | 59 +- components/salary/VacationBalanceCard.tsx | 14 +- components/salary/run/RunEmployeesTable.tsx | 8 +- components/settings/AccountDangerZone.tsx | 111 +- components/settings/AgentMemoryPanel.tsx | 153 +- components/settings/AgentSkillsPanel.tsx | 125 +- components/settings/ApiKeysPanel.tsx | 66 +- components/settings/BackupDownloadForm.tsx | 94 +- components/settings/BookingTemplatesPanel.tsx | 46 +- components/settings/CompanyMembersSection.tsx | 96 +- components/settings/DimensionsToggle.tsx | 12 + components/settings/TeamPanel.tsx | 107 +- .../__tests__/account-deletion.test.ts | 23 + .../__tests__/members-payload.test.ts | 84 + components/settings/account-deletion.ts | 19 + components/settings/members-payload.ts | 51 + .../sections/AssistantSettingsContent.tsx | 122 +- .../sections/BillingSettingsContent.tsx | 110 +- .../skattekonto/SkattekontoMatchDialog.tsx | 18 +- .../NewSupplierInvoiceForm.tsx | 7 + components/transactions/BulkBookDialog.tsx | 70 + .../transactions/DuplicateBookingDialog.tsx | 53 +- .../transactions/InvoiceMatchDialog.tsx | 201 +- components/transactions/InvoicePicker.tsx | 52 +- components/transactions/QuickReviewDialog.tsx | 51 +- .../transactions/SupplierInvoicePicker.tsx | 52 +- .../invoice-candidate-ranking.test.ts | 396 ++++ .../invoice-match-dialog-duplicate.test.ts | 85 + .../__tests__/invoice-match-dialog-fx.test.ts | 274 +++ .../transactions/invoice-candidate-ranking.ts | 246 ++ components/transactions/invoice-match-fx.ts | 117 + docker/crontab.hosted | 50 +- docker/crontab.self-hosted | 50 +- .../__tests__/entity-mapper-fx.test.ts | 236 ++ .../import-sie-no-direct-savemappings.test.ts | 32 + .../__tests__/migrate-guard.test.ts | 4 + .../__tests__/oauth-callback-state.test.ts | 292 +++ .../provider-client-oauth-state.test.ts | 250 ++ extensions/general/arcim-migration/index.ts | 103 +- .../arcim-migration/lib/entity-mapper.ts | 230 +- .../lib/migration-orchestrator.ts | 59 +- .../arcim-migration/lib/provider-client.ts | 92 +- extensions/general/arcim-migration/types.ts | 13 +- .../__tests__/convert-route.test.ts | 166 +- extensions/general/invoice-inbox/index.ts | 53 +- .../categorize-duplicate-message.test.ts | 164 ++ .../__tests__/company-current.test.ts | 262 +++ .../__tests__/company-settings-tools.test.ts | 105 + .../__tests__/create-invoice-vat-gate.test.ts | 108 + .../__tests__/create-supplier.test.ts | 18 +- .../__tests__/get-invoice-deliveries.test.ts | 240 ++ .../__tests__/lock-period-guard.test.ts | 145 ++ .../__tests__/payload-size.bench.test.ts | 11 +- .../__tests__/payroll-read-tools.test.ts | 117 +- .../__tests__/payroll-staged-tools.test.ts | 144 ++ .../__tests__/query-journal.test.ts | 83 +- .../__tests__/receipt-matcher.test.ts | 8 +- .../__tests__/recent-activity.test.ts | 158 ++ .../recurring-schedule-tools.test.ts | 392 ++++ .../__tests__/skatteverket-tools.test.ts | 98 + .../__tests__/tag-journal-lines.test.ts | 78 +- .../__tests__/update-invoice.test.ts | 216 ++ .../vat-close-check-completeness.test.ts | 267 +++ .../vat-close-check-missing-underlag.test.ts | 143 ++ ...-declaration-validate-completeness.test.ts | 257 +++ .../__tests__/vat-report-compute.test.ts | 69 +- .../mcp-server/resources/company-current.ts | 29 +- .../mcp-server/resources/recent-activity.ts | 14 +- .../mcp-server/resources/vat-treatments.ts | 23 +- extensions/general/mcp-server/server.ts | 2021 +++++++++++++++-- .../mcp-server/skills/customer-onboarding.ts | 16 +- .../NotificationSettings.tsx | 14 +- .../__tests__/api-routes.test.ts | 138 ++ .../__tests__/notification-consent.test.ts | 288 +++ .../__tests__/settings-routes.test.ts | 169 ++ .../general/push-notifications/api-routes.ts | 26 +- .../general/push-notifications/index.ts | 35 +- .../notification-scheduler.ts | 50 +- .../push-notifications/notification-sender.ts | 70 +- .../__tests__/skattekonto-drift-email.test.ts | 278 +++ .../__tests__/skattekonto-drift.test.ts | 51 +- .../__tests__/skattekonto-match-agi.test.ts | 66 +- .../__tests__/skattekonto-match.test.ts | 100 +- extensions/general/skatteverket/index.ts | 4 +- .../lib/skattekonto-drift-email.ts | 56 +- .../skatteverket/lib/skattekonto-drift.ts | 59 +- .../skatteverket/lib/skattekonto-match.ts | 120 +- .../stripe/__tests__/settings-actions.test.ts | 278 +++ .../stripe/components/StripeSettingsPanel.tsx | 177 +- .../general/stripe/lib/settings-actions.ts | 225 ++ lib/__tests__/cron-reporting.test.ts | 271 +++ lib/__tests__/logger-sink.test.ts | 254 +++ .../__tests__/composer-currency.test.ts | 401 ++++ .../composer/__tests__/employee-facts.test.ts | 241 ++ .../__tests__/narrative-voice.test.ts | 1 + lib/agent/composer/atom-selection.ts | 197 +- lib/agent/composer/employee-facts.ts | 144 ++ lib/agent/composer/inputs.ts | 307 ++- .../intents/__tests__/bokslut-step.test.ts | 199 ++ .../intents/__tests__/inbox-bulk-book.test.ts | 178 ++ .../__tests__/supplier-invoice-review.test.ts | 342 +++ .../__tests__/verifikation-draft.test.ts | 213 +- lib/agent/intents/bokslut-step.ts | 78 +- lib/agent/intents/inbox-bulk-book.ts | 53 +- lib/agent/intents/supplier-invoice-review.ts | 174 +- lib/agent/intents/verifikation-draft.ts | 86 +- lib/api/__tests__/schemas.test.ts | 58 + lib/api/__tests__/sparse-patch.test.ts | 274 +++ lib/api/__tests__/validate.test.ts | 83 +- lib/api/schemas.ts | 114 +- lib/api/sparse-patch.ts | 196 ++ lib/api/v1/__tests__/pagination.test.ts | 14 +- .../with-api-v1-dry-run-idempotency.test.ts | 472 ++++ lib/api/v1/dry-run.ts | 8 +- lib/api/v1/pagination.ts | 13 +- lib/api/v1/with-api-v1.ts | 77 +- lib/api/validate.ts | 33 +- .../__tests__/consume-invite-cookie.test.ts | 316 +++ lib/auth/__tests__/safe-return-to.test.ts | 38 + lib/auth/api-keys.ts | 6 + lib/auth/consume-invite-cookie.ts | 195 ++ lib/auth/cron.ts | 239 +- lib/auth/safe-return-to.ts | 21 +- .../__tests__/narrative-service.test.ts | 177 ++ .../__tests__/profile-service.test.ts | 196 ++ .../arsredovisning/narrative-service.ts | 27 +- lib/bokslut/arsredovisning/profile-service.ts | 25 +- .../__tests__/cancel-orphaned-entry.test.ts | 129 +- .../__tests__/currency-revaluation.test.ts | 481 +++- .../__tests__/currency-utils.test.ts | 115 +- .../engine-dimension-validation.test.ts | 23 + .../__tests__/fiscal-period-warnings.test.ts | 105 + .../__tests__/fx-line-slot.test.ts | 263 +++ .../__tests__/invoice-entries.test.ts | 320 +++ .../__tests__/invoice-payment-lines.test.ts | 299 ++- .../__tests__/mapping-engine.test.ts | 274 +++ .../__tests__/payment-sync.test.ts | 105 +- .../__tests__/propose-payment-lines.test.ts | 91 + .../__tests__/propose-send-lines.test.ts | 98 + .../supplier-invoice-entries.test.ts | 163 ++ lib/bookkeeping/cancel-orphaned-entry.ts | 87 +- lib/bookkeeping/currency-revaluation.ts | 195 +- lib/bookkeeping/currency-utils.ts | 67 +- lib/bookkeeping/engine.ts | 36 +- lib/bookkeeping/fiscal-period-warnings.ts | 55 + lib/bookkeeping/fx-line-slot.ts | 218 ++ lib/bookkeeping/invoice-entries.ts | 153 +- lib/bookkeeping/invoice-payment-lines.ts | 122 +- lib/bookkeeping/ledger-line-amount.ts | 118 + lib/bookkeeping/mapping-engine.ts | 119 +- lib/bookkeeping/payment-sync.ts | 32 +- lib/bookkeeping/propose-payment-lines.ts | 22 +- lib/bookkeeping/propose-send-lines.ts | 39 +- lib/bookkeeping/supplier-invoice-entries.ts | 70 +- lib/browser/__tests__/action-failure.test.ts | 95 + .../__tests__/copy-to-clipboard.test.ts | 62 + lib/browser/__tests__/download-file.test.ts | 243 ++ lib/browser/__tests__/post-action.test.ts | 157 ++ lib/browser/action-failure.ts | 56 + lib/browser/copy-to-clipboard.ts | 46 + lib/browser/download-file.ts | 149 ++ lib/browser/post-action.ts | 74 + lib/company/first-year-defaults.ts | 7 +- .../__tests__/period-service.test.ts | 531 ++++- .../__tests__/year-end-service.test.ts | 413 +++- lib/core/bookkeeping/period-service.ts | 277 ++- lib/core/bookkeeping/year-end-service.ts | 236 +- .../__tests__/document-service.test.ts | 331 ++- lib/core/documents/document-service.ts | 214 +- .../tax/__tests__/tax-code-service.test.ts | 111 - lib/core/tax/tax-code-service.ts | 165 -- .../__tests__/supplier-invoice-rate.test.ts | 239 ++ lib/currency/supplier-invoice-rate.ts | 236 ++ .../__tests__/protect-personal-number.test.ts | 120 + lib/customers/protect-personal-number.ts | 35 +- .../__tests__/link-documents.test.ts | 192 ++ lib/documents/link-documents.ts | 140 ++ .../__tests__/reminder-templates.test.ts | 160 +- lib/email/reminder-templates.ts | 127 +- .../__tests__/get-error-message.test.ts | 129 ++ lib/errors/get-error-message.ts | 18 + lib/errors/structured-errors.ts | 151 +- .../__tests__/sie-import.replace.pg.test.ts | 356 ++- .../sie-import.save-mappings.test.ts | 247 ++ lib/import/__tests__/sie-import.test.ts | 158 ++ .../undo-sie-import-actor.pg.test.ts | 87 +- .../undo-sie-import-dimensions.pg.test.ts | 13 +- lib/import/bank-file/__tests__/parser.test.ts | 60 +- .../__tests__/suggest-column-mapping.test.ts | 85 +- lib/import/bank-file/formats/generic-csv.ts | 46 +- lib/import/bank-file/formats/handelsbanken.ts | 21 +- .../bank-file/formats/lansforsakringar.ts | 40 +- lib/import/sie-import.ts | 153 +- .../__tests__/build-invoice-write-fx.test.ts | 206 ++ .../__tests__/build-invoice-write.test.ts | 138 +- .../duplicate-guard-currency.test.ts | 189 ++ .../duplicate-payment-candidates.test.ts | 248 ++ .../duplicate-payment-detection.test.ts | 452 +++- .../__tests__/invoice-matching.test.ts | 260 +++ .../__tests__/invoice-pdf-source.test.ts | 211 ++ .../link-voucher-currency.pg.test.ts | 720 ++++++ .../recurring-schedule-service.test.ts | 183 ++ .../__tests__/replace-invoice-items.test.ts | 187 ++ lib/invoices/__tests__/rot-rut-file.test.ts | 174 ++ lib/invoices/__tests__/rot-rut-rules.test.ts | 98 + .../supplier-invoice-matching.test.ts | 167 ++ .../supplier-voucher-matching.test.ts | 280 +++ .../__tests__/vat-rate-gate-parity.test.ts | 47 + lib/invoices/__tests__/vat-rules.test.ts | 56 + .../__tests__/voucher-matching-fx.test.ts | 1112 +++++++++ .../__tests__/voucher-matching.test.ts | 135 ++ lib/invoices/build-invoice-write.ts | 70 +- .../bulk-reconcile-supplier-vouchers.ts | 11 + lib/invoices/duplicate-guard-currency.ts | 236 ++ lib/invoices/duplicate-payment-candidates.ts | 127 +- lib/invoices/duplicate-payment-detection.ts | 174 +- lib/invoices/invoice-matching.ts | 201 +- lib/invoices/invoice-pdf-source.ts | 164 ++ lib/invoices/recurring-schedule-service.ts | 23 +- lib/invoices/reminder-processor.ts | 15 +- lib/invoices/replace-invoice-items.ts | 94 + lib/invoices/rot-rut-file.ts | 51 +- lib/invoices/rot-rut-rules.ts | 114 +- lib/invoices/self-billed-sale.ts | 15 +- lib/invoices/supplier-invoice-matching.ts | 116 +- lib/invoices/supplier-voucher-matching.ts | 186 +- lib/invoices/vat-rules.ts | 67 +- lib/invoices/voucher-matching.ts | 176 +- lib/logger.ts | 206 +- lib/observability/__tests__/redact.test.ts | 103 + lib/observability/__tests__/sink.test.ts | 224 ++ lib/observability/index.ts | 44 + lib/observability/redact.ts | 115 + lib/observability/sink.ts | 231 ++ .../__tests__/fiscal-options.test.ts | 73 + lib/onboarding-journey/fiscal-options.ts | 11 +- .../company-settings-executor.test.ts | 73 + .../__tests__/create-invoice-executor.test.ts | 86 +- .../__tests__/executors.test.ts | 18 + .../recurring-schedule-executors.test.ts | 407 ++++ .../__tests__/risk-tiers.test.ts | 26 + .../__tests__/staged-fx-rates.test.ts | 594 +++++ .../__tests__/update-invoice-executor.test.ts | 257 +++ lib/pending-operations/commit.ts | 732 +++++- lib/pending-operations/risk-tiers.ts | 48 +- .../__tests__/company-settings.test.ts | 121 + .../schemas/__tests__/create-supplier.test.ts | 83 + .../schemas/company-settings.ts | 45 + .../schemas/create-supplier.ts | 42 +- .../schemas/recurring-schedule.ts | 39 + .../schemas/update-invoice.ts | 47 + .../auto-reconcile-linked-voucher.test.ts | 105 +- .../__tests__/bank-reconciliation.test.ts | 275 +++ lib/reconciliation/bank-reconciliation.ts | 158 +- lib/reports/__tests__/ar-ledger.test.ts | 104 + .../financial-statement-pdf-template.test.ts | 10 +- .../__tests__/full-archive-export.test.ts | 260 ++- lib/reports/__tests__/kpi.test.ts | 91 + .../__tests__/reskontra-pdf-template.test.ts | 220 ++ .../__tests__/vat-declaration-checks.test.ts | 265 +++ lib/reports/__tests__/vat-declaration.test.ts | 181 ++ lib/reports/__tests__/vat-filing-gate.test.ts | 128 ++ lib/reports/ar-ledger.ts | 15 +- lib/reports/archive-readme.ts | 4 + lib/reports/full-archive-export.ts | 107 +- lib/reports/kpi.ts | 134 ++ lib/reports/reskontra-pdf-template.tsx | 89 +- lib/reports/vat-declaration-checks.ts | 174 +- lib/reports/vat-declaration.ts | 57 + lib/reports/vat-filing-gate.ts | 125 + .../__tests__/agi-submission-state.test.ts | 382 +++- .../__tests__/payslip-zip-report.test.ts | 112 + lib/salary/__tests__/personnummer.test.ts | 184 +- lib/salary/agi-submission-state.ts | 116 +- lib/salary/agi/generate-declaration.ts | 4 +- lib/salary/payslip-zip-report.ts | 91 + lib/salary/personnummer.ts | 67 +- lib/supabase/__tests__/middleware.test.ts | 301 +++ lib/supabase/middleware.ts | 77 +- lib/tax/__tests__/deadline-generator.test.ts | 251 +- lib/tax/deadline-generator.ts | 135 +- .../booking-duplicate-detection.test.ts | 323 ++- .../categorize-core.duplicate-message.test.ts | 223 ++ lib/transactions/__tests__/ingest.test.ts | 144 ++ .../booking-duplicate-detection.ts | 343 ++- lib/transactions/categorize-core.ts | 82 +- lib/transactions/ingest.ts | 68 +- lib/utils.ts | 18 + .../__tests__/dispatcher-auto-disable.test.ts | 226 ++ lib/webhooks/dispatcher.ts | 68 +- messages/en.json | 134 +- messages/sv.json | 134 +- package-lock.json | 96 +- package.json | 5 +- scripts/__tests__/generate-crontabs.test.ts | 219 ++ scripts/backfill-document-storage-paths.ts | 900 ++++++++ .../format-currency-sek-label.test.ts | 137 ++ scripts/checks/extension-route-guards.mjs | 114 + scripts/checks/format-currency-sek-label.mjs | 277 +++ scripts/checks/no-new-antipatterns.mjs | 109 +- scripts/generate-crontabs.ts | 250 ++ scripts/seed-demo-account.ts | 36 +- ...6092000_documents_bucket_company_scope.sql | 126 + ..._bulk_book_transactions_currency_guard.sql | 419 ++++ ...omers_personal_number_ciphertext_check.sql | 56 + ...e_customers_personal_number_ciphertext.sql | 10 + ..._backfill_supplier_invoice_sek_amounts.sql | 79 + ...dit_note_amount_cap_replaces_count_cap.sql | 202 ++ ...pcs_resolve_amount_in_invoice_currency.sql | 553 +++++ ...ledger_deep_context_honest_sek_amounts.sql | 284 +++ ...tion_settings_missing_underlag_enabled.sql | 29 + ...settings_restore_invoice_default_notes.sql | 46 + ..._pending_operations_add_update_invoice.sql | 72 + ...date_pending_operations_update_invoice.sql | 6 + ...invoice_delivery_summaries_for_service.sql | 126 + ...ding_operations_add_recurring_schedule.sql | 81 + ..._pending_operations_recurring_schedule.sql | 6 + ...000_replace_sie_import_authorize_actor.sql | 255 +++ ...727121000_undo_sie_import_caller_guard.sql | 238 ++ ...nk_voucher_rpcs_null_safe_tenant_guard.sql | 584 +++++ tests/pg/bulk-book-mixed-currency.pg.test.ts | 240 ++ tests/pg/credit-note-amount-cap.pg.test.ts | 349 +++ .../pg/credit-note-creation-guards.pg.test.ts | 24 +- ...mers-personal-number-ciphertext.pg.test.ts | 146 ++ .../documents-bucket-company-scope.pg.test.ts | 261 +++ tests/pg/full-archive-coverage.pg.test.ts | 51 + ...oice-delivery-summaries-service.pg.test.ts | 304 +++ tests/pg/ledger-deep-context-rpc.pg.test.ts | 172 +- ...g-operations-recurring-schedule.pg.test.ts | 65 + ...nding-operations-update-invoice.pg.test.ts | 37 + tests/pg/setup.ts | 54 + tests/schema/no-phantom-columns.test.ts | 491 ++++ tests/schema/schema-guard.ts | 1613 +++++++++++++ types/index.ts | 51 + 536 files changed, 68921 insertions(+), 4851 deletions(-) create mode 100644 app/(auth)/register/__tests__/invite-email-prefill.test.ts create mode 100644 app/(auth)/reset-password/__tests__/invite-handoff.test.ts create mode 100644 app/(auth)/reset-password/invite-handoff.ts create mode 100644 app/api/deadlines/[id]/complete/__tests__/route.test.ts create mode 100644 app/api/extensions/push-notifications/cron/__tests__/route.test.ts create mode 100644 app/api/import/sie/[id]/replace/__tests__/route.test.ts create mode 100644 app/api/invoices/[id]/refresh-exchange-rate/__tests__/route.test.ts create mode 100644 app/api/invoices/[id]/refresh-exchange-rate/route.ts create mode 100644 app/api/invoices/reminders/action/__tests__/route.test.ts create mode 100644 app/api/reconciliation/bank/unmatched-entries/__tests__/route.test.ts create mode 100644 app/api/reports/kassaflodesanalys/pdf/__tests__/route.test.ts create mode 100644 app/api/reports/kpi/xlsx/__tests__/route.test.ts create mode 100644 app/api/salary/employees/[id]/__tests__/personnummer-write-guard.test.ts create mode 100644 app/api/salary/employees/[id]/worked-hours/__tests__/recording-supabase.ts create mode 100644 app/api/salary/runs/[id]/lines/[lineId]/__tests__/sparse-patch.test.ts create mode 100644 app/api/settings/booking-templates/[id]/__tests__/route.test.ts create mode 100644 app/api/supplier-invoices/[id]/mark-paid/__tests__/duplicate-guard-band.test.ts create mode 100644 app/api/transactions/[id]/categorize/__tests__/suggestion-band-currency.test.ts create mode 100644 app/api/v1/companies/[companyId]/__tests__/cursor-pagination.test.ts create mode 100644 app/api/v1/companies/[companyId]/compliance/check/__tests__/route.test.ts create mode 100644 app/api/v1/companies/[companyId]/imports/bank/__tests__/route.test.ts create mode 100644 app/api/v1/companies/[companyId]/invoices/[id]/__tests__/route.test.ts create mode 100644 app/api/v1/companies/[companyId]/transactions/[id]/categorize/__tests__/route.test.ts create mode 100644 app/invite/[token]/__tests__/invite-cookie.test.ts create mode 100644 components/agent-knowledge/__tests__/ledger-graph-magnitude.test.ts create mode 100644 components/agent-knowledge/ledger-graph-magnitude.ts create mode 100644 components/bookkeeping/__tests__/accrual-k2-hint.test.ts create mode 100644 components/bookkeeping/accrual-k2-hint.ts create mode 100644 components/extensions/general/__tests__/bulk-book-inbox-totals.test.ts create mode 100644 components/extensions/general/__tests__/inbox-address-copy.test.ts create mode 100644 components/extensions/general/bulk-book-inbox-totals.ts create mode 100644 components/extensions/general/inbox-address-copy.ts create mode 100644 components/invoices/__tests__/line-vat-rates.test.ts create mode 100644 components/invoices/__tests__/new-invoice-dialog-query-shape.test.ts create mode 100644 components/invoices/line-vat-rates.ts create mode 100644 components/kpi/__tests__/load-preferences.test.ts create mode 100644 components/kpi/__tests__/save-preferences.test.ts create mode 100644 components/kpi/load-preferences.ts create mode 100644 components/kpi/save-preferences.ts create mode 100644 components/settings/__tests__/account-deletion.test.ts create mode 100644 components/settings/__tests__/members-payload.test.ts create mode 100644 components/settings/account-deletion.ts create mode 100644 components/settings/members-payload.ts create mode 100644 components/transactions/__tests__/invoice-candidate-ranking.test.ts create mode 100644 components/transactions/__tests__/invoice-match-dialog-duplicate.test.ts create mode 100644 components/transactions/__tests__/invoice-match-dialog-fx.test.ts create mode 100644 components/transactions/invoice-candidate-ranking.ts create mode 100644 components/transactions/invoice-match-fx.ts create mode 100644 extensions/general/arcim-migration/__tests__/entity-mapper-fx.test.ts create mode 100644 extensions/general/arcim-migration/__tests__/import-sie-no-direct-savemappings.test.ts create mode 100644 extensions/general/arcim-migration/__tests__/oauth-callback-state.test.ts create mode 100644 extensions/general/arcim-migration/__tests__/provider-client-oauth-state.test.ts create mode 100644 extensions/general/mcp-server/__tests__/categorize-duplicate-message.test.ts create mode 100644 extensions/general/mcp-server/__tests__/company-current.test.ts create mode 100644 extensions/general/mcp-server/__tests__/create-invoice-vat-gate.test.ts create mode 100644 extensions/general/mcp-server/__tests__/get-invoice-deliveries.test.ts create mode 100644 extensions/general/mcp-server/__tests__/lock-period-guard.test.ts create mode 100644 extensions/general/mcp-server/__tests__/recent-activity.test.ts create mode 100644 extensions/general/mcp-server/__tests__/recurring-schedule-tools.test.ts create mode 100644 extensions/general/mcp-server/__tests__/update-invoice.test.ts create mode 100644 extensions/general/mcp-server/__tests__/vat-close-check-completeness.test.ts create mode 100644 extensions/general/mcp-server/__tests__/vat-close-check-missing-underlag.test.ts create mode 100644 extensions/general/mcp-server/__tests__/vat-declaration-validate-completeness.test.ts create mode 100644 extensions/general/push-notifications/__tests__/api-routes.test.ts create mode 100644 extensions/general/push-notifications/__tests__/notification-consent.test.ts create mode 100644 extensions/general/push-notifications/__tests__/settings-routes.test.ts create mode 100644 extensions/general/skatteverket/__tests__/skattekonto-drift-email.test.ts create mode 100644 extensions/general/stripe/__tests__/settings-actions.test.ts create mode 100644 extensions/general/stripe/lib/settings-actions.ts create mode 100644 lib/__tests__/cron-reporting.test.ts create mode 100644 lib/__tests__/logger-sink.test.ts create mode 100644 lib/agent/composer/__tests__/composer-currency.test.ts create mode 100644 lib/agent/composer/__tests__/employee-facts.test.ts create mode 100644 lib/agent/composer/employee-facts.ts create mode 100644 lib/agent/intents/__tests__/bokslut-step.test.ts create mode 100644 lib/agent/intents/__tests__/inbox-bulk-book.test.ts create mode 100644 lib/agent/intents/__tests__/supplier-invoice-review.test.ts create mode 100644 lib/api/__tests__/sparse-patch.test.ts create mode 100644 lib/api/sparse-patch.ts create mode 100644 lib/api/v1/__tests__/with-api-v1-dry-run-idempotency.test.ts create mode 100644 lib/auth/__tests__/consume-invite-cookie.test.ts create mode 100644 lib/auth/consume-invite-cookie.ts create mode 100644 lib/bokslut/arsredovisning/__tests__/narrative-service.test.ts create mode 100644 lib/bokslut/arsredovisning/__tests__/profile-service.test.ts create mode 100644 lib/bookkeeping/__tests__/fiscal-period-warnings.test.ts create mode 100644 lib/bookkeeping/__tests__/fx-line-slot.test.ts create mode 100644 lib/bookkeeping/fiscal-period-warnings.ts create mode 100644 lib/bookkeeping/fx-line-slot.ts create mode 100644 lib/bookkeeping/ledger-line-amount.ts create mode 100644 lib/browser/__tests__/action-failure.test.ts create mode 100644 lib/browser/__tests__/copy-to-clipboard.test.ts create mode 100644 lib/browser/__tests__/download-file.test.ts create mode 100644 lib/browser/__tests__/post-action.test.ts create mode 100644 lib/browser/action-failure.ts create mode 100644 lib/browser/copy-to-clipboard.ts create mode 100644 lib/browser/download-file.ts create mode 100644 lib/browser/post-action.ts delete mode 100644 lib/core/tax/__tests__/tax-code-service.test.ts delete mode 100644 lib/core/tax/tax-code-service.ts create mode 100644 lib/currency/__tests__/supplier-invoice-rate.test.ts create mode 100644 lib/currency/supplier-invoice-rate.ts create mode 100644 lib/customers/__tests__/protect-personal-number.test.ts create mode 100644 lib/documents/__tests__/link-documents.test.ts create mode 100644 lib/documents/link-documents.ts create mode 100644 lib/import/__tests__/sie-import.save-mappings.test.ts create mode 100644 lib/invoices/__tests__/build-invoice-write-fx.test.ts create mode 100644 lib/invoices/__tests__/duplicate-guard-currency.test.ts create mode 100644 lib/invoices/__tests__/duplicate-payment-candidates.test.ts create mode 100644 lib/invoices/__tests__/invoice-pdf-source.test.ts create mode 100644 lib/invoices/__tests__/link-voucher-currency.pg.test.ts create mode 100644 lib/invoices/__tests__/replace-invoice-items.test.ts create mode 100644 lib/invoices/__tests__/vat-rate-gate-parity.test.ts create mode 100644 lib/invoices/__tests__/voucher-matching-fx.test.ts create mode 100644 lib/invoices/duplicate-guard-currency.ts create mode 100644 lib/invoices/invoice-pdf-source.ts create mode 100644 lib/invoices/replace-invoice-items.ts create mode 100644 lib/observability/__tests__/redact.test.ts create mode 100644 lib/observability/__tests__/sink.test.ts create mode 100644 lib/observability/index.ts create mode 100644 lib/observability/redact.ts create mode 100644 lib/observability/sink.ts create mode 100644 lib/onboarding-journey/__tests__/fiscal-options.test.ts create mode 100644 lib/pending-operations/__tests__/recurring-schedule-executors.test.ts create mode 100644 lib/pending-operations/__tests__/staged-fx-rates.test.ts create mode 100644 lib/pending-operations/__tests__/update-invoice-executor.test.ts create mode 100644 lib/pending-operations/schemas/__tests__/company-settings.test.ts create mode 100644 lib/pending-operations/schemas/__tests__/create-supplier.test.ts create mode 100644 lib/pending-operations/schemas/recurring-schedule.ts create mode 100644 lib/pending-operations/schemas/update-invoice.ts create mode 100644 lib/reports/__tests__/reskontra-pdf-template.test.ts create mode 100644 lib/reports/__tests__/vat-filing-gate.test.ts create mode 100644 lib/reports/vat-filing-gate.ts create mode 100644 lib/salary/__tests__/payslip-zip-report.test.ts create mode 100644 lib/salary/payslip-zip-report.ts create mode 100644 lib/supabase/__tests__/middleware.test.ts create mode 100644 lib/transactions/__tests__/categorize-core.duplicate-message.test.ts create mode 100644 lib/webhooks/__tests__/dispatcher-auto-disable.test.ts create mode 100644 scripts/__tests__/generate-crontabs.test.ts create mode 100644 scripts/backfill-document-storage-paths.ts create mode 100644 scripts/checks/__tests__/format-currency-sek-label.test.ts create mode 100644 scripts/checks/extension-route-guards.mjs create mode 100644 scripts/checks/format-currency-sek-label.mjs create mode 100644 scripts/generate-crontabs.ts create mode 100644 supabase/migrations/20260726092000_documents_bucket_company_scope.sql create mode 100644 supabase/migrations/20260726100000_bulk_book_transactions_currency_guard.sql create mode 100644 supabase/migrations/20260726110000_customers_personal_number_ciphertext_check.sql create mode 100644 supabase/migrations/20260726110001_validate_customers_personal_number_ciphertext.sql create mode 100644 supabase/migrations/20260726120000_backfill_supplier_invoice_sek_amounts.sql create mode 100644 supabase/migrations/20260726130000_credit_note_amount_cap_replaces_count_cap.sql create mode 100644 supabase/migrations/20260726140000_link_voucher_rpcs_resolve_amount_in_invoice_currency.sql create mode 100644 supabase/migrations/20260726150000_ledger_deep_context_honest_sek_amounts.sql create mode 100644 supabase/migrations/20260726174500_notification_settings_missing_underlag_enabled.sql create mode 100644 supabase/migrations/20260726181500_company_settings_restore_invoice_default_notes.sql create mode 100644 supabase/migrations/20260727090000_pending_operations_add_update_invoice.sql create mode 100644 supabase/migrations/20260727090001_validate_pending_operations_update_invoice.sql create mode 100644 supabase/migrations/20260727100000_list_invoice_delivery_summaries_for_service.sql create mode 100644 supabase/migrations/20260727110000_pending_operations_add_recurring_schedule.sql create mode 100644 supabase/migrations/20260727110001_validate_pending_operations_recurring_schedule.sql create mode 100644 supabase/migrations/20260727120000_replace_sie_import_authorize_actor.sql create mode 100644 supabase/migrations/20260727121000_undo_sie_import_caller_guard.sql create mode 100644 supabase/migrations/20260727130000_link_voucher_rpcs_null_safe_tenant_guard.sql create mode 100644 tests/pg/bulk-book-mixed-currency.pg.test.ts create mode 100644 tests/pg/credit-note-amount-cap.pg.test.ts create mode 100644 tests/pg/customers-personal-number-ciphertext.pg.test.ts create mode 100644 tests/pg/documents-bucket-company-scope.pg.test.ts create mode 100644 tests/pg/invoice-delivery-summaries-service.pg.test.ts create mode 100644 tests/pg/pending-operations-recurring-schedule.pg.test.ts create mode 100644 tests/pg/pending-operations-update-invoice.pg.test.ts create mode 100644 tests/schema/no-phantom-columns.test.ts create mode 100644 tests/schema/schema-guard.ts diff --git a/.claude/rules/bookkeeping.md b/.claude/rules/bookkeeping.md index 9c702ef3..ef9ba4ea 100644 --- a/.claude/rules/bookkeeping.md +++ b/.claude/rules/bookkeeping.md @@ -17,7 +17,6 @@ For Swedish accounting-law questions, use the domain skills (`swedish-vat`, `swe - `bookkeeping/period-service.ts`: Fiscal period lifecycle management (open, close, lock) - `bookkeeping/year-end-service.ts`: Year-end closing procedures - `bookkeeping/storno-service.ts`: Reversal/correction entry generation -- `tax/tax-code-service.ts`: Tax code definitions and rates - `audit/audit-service.ts`: Audit trail and compliance logging - `documents/document-service.ts`: Document attachment lifecycle (WORM storage with version chains) @@ -31,10 +30,12 @@ BAS data (`lib/bookkeeping/bas-data/`): full BAS 2026 chart by class (1-8) + SRU `standard_25`, `reduced_12`, `reduced_6`, `reverse_charge`, `export`, `exempt` -Invoice items support individual `vat_rate` values (mixed-rate invoices). Use `getAvailableVatRates(customerType, vatNumberValidated)` from `lib/invoices/vat-rules.ts`. VIES validation via `lib/vat/vies-client.ts`. +Invoice items support individual `vat_rate` values (mixed-rate invoices). Both helpers live in `lib/invoices/vat-rules.ts` and are not interchangeable: `getAvailableVatRates(customerType, vatNumberValidated)` is the DEFAULT a picker offers or a line starts on (a single locked 0% for a foreign business); `getPermittedVatRates(...)` is the LAWFUL set every validation gate must use, because the ML 6 kap. supplies taxed where they are performed carry Swedish VAT even to a foreign business. VIES validation via `lib/vat/vies-client.ts`. ## VAT Declaration Rutor (SKV 4700) +Ruta assignment is driven by **BAS account number**, never by a per-line tax code: `ACCOUNT_RUTA` in `lib/reports/vat-declaration.ts` is the source of truth, mirrored by `ACCOUNT_TO_BOX` in `lib/vat/moms-box-mapping.ts` (a regression test asserts the two agree). `journal_entry_lines.tax_code` is a free-text tag that nothing reads; there is no `tax_codes` table (migration 012 is a placeholder for a design that was never deployed). + `VatDeclarationRutor` type maps to momsdeklaration: - **Ruta 05**: Domestic taxable sales (3001+3002+3003) - **Ruta 06/07**: Unused, always 0 diff --git a/.env.example b/.env.example index 478989f3..a7fb8e7e 100644 --- a/.env.example +++ b/.env.example @@ -52,3 +52,21 @@ CRON_SECRET=generate-a-random-secret # NEXT_PUBLIC_BOLAGSVERKET_FILING_ENABLED=false # BOLAGSVERKET_ARELLE_VALIDATOR_URL= # BOLAGSVERKET_ARELLE_VALIDATOR_TOKEN= + +# ── Optional: error tracking / observability ────────────── +# The app routes every error-level log line, and anything flagged +# `alert: true`, to a provider-agnostic sink (lib/observability). No vendor is +# wired up: the sink is a NO-OP until an adapter is registered with +# registerObservabilitySink(). Setting these variables alone changes nothing, +# and self-hosted installs can leave them unset forever. +# +# Names are generic placeholders. When a provider is picked, either keep these +# and read them in the adapter, or replace them with the vendor's own names. +# OBSERVABILITY_DSN= # server-side ingest endpoint / key +# NEXT_PUBLIC_OBSERVABILITY_DSN= # browser ingest endpoint / key, if used +# Optional overrides. Both have sensible defaults: the environment falls back +# to VERCEL_ENV then NODE_ENV, and the release falls back to +# NEXT_PUBLIC_BUILD_ID (the commit sha next.config.ts inlines at build time) +# then VERCEL_GIT_COMMIT_SHA. Set them only when tagging must differ. +# OBSERVABILITY_ENVIRONMENT= +# OBSERVABILITY_RELEASE= diff --git a/.github/workflows/docker-image-scan.yml b/.github/workflows/docker-image-scan.yml index 9aaa6164..5943e893 100644 --- a/.github/workflows/docker-image-scan.yml +++ b/.github/workflows/docker-image-scan.yml @@ -68,6 +68,12 @@ jobs: ignore-unfixed: true format: sarif output: trivy-results.sarif + # Without this, trivy-action unsets TRIVY_SEVERITY when format is + # sarif (entrypoint.sh: "Building SARIF report with all severities"), + # silently discarding the CRITICAL,HIGH filter above. The exit code + # then fires on any fixable CVE down to LOW, which is not the policy + # documented here and turns routine low-severity noise into a red run. + limit-severities-for-sarif: true - name: Upload Trivy results to GitHub Security tab # if: always() so findings still reach the Security tab even though the diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index 86125b07..6b35b33b 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -65,12 +65,23 @@ jobs: # OCI attestations, queryable via `docker buildx imagetools inspect`. provenance: mode=max sbom: true - # Per-branch cache scope so a PR branch can't poison main's cache - # layers. Fall back to main's cache on first build of a new branch. - cache-from: | - type=gha,scope=${{ github.ref_name }} - type=gha,scope=main - cache-to: type=gha,scope=${{ github.ref_name }},mode=max + # Layer cache lives in GHCR, not the GitHub Actions cache. Two + # platforms x mode=max is ~7 GB, which alone ate 70% of the repo's + # 10 GB Actions cache quota and, because buildx refreshes every blob's + # access time on each run, never aged out under LRU: it starved the + # other workflows' caches instead. GHCR storage is free for public + # repos and off that quota. mode=max is kept deliberately: mode=min + # would drop the deps/builder stages and make every push redo + # `npm ci` + `next build`. + # + # A single stable cache tag is safe here: this workflow only runs on + # main and v*.*.* tags, and tags are cut from main, so there is no + # untrusted ref that could poison the layers. + # + # image-manifest=true,oci-mediatypes=true is required by GHCR, which + # rejects buildkit's default cache manifest media type. + cache-from: type=registry,ref=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:buildcache + cache-to: type=registry,ref=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:buildcache,mode=max,image-manifest=true,oci-mediatypes=true - name: Install cosign uses: sigstore/cosign-installer@v4.1.2 diff --git a/.github/workflows/swedish-compliance-review.yml b/.github/workflows/swedish-compliance-review.yml index 1845cf21..bff4c872 100644 --- a/.github/workflows/swedish-compliance-review.yml +++ b/.github/workflows/swedish-compliance-review.yml @@ -57,10 +57,23 @@ jobs: fi echo "number=$NUM" >> "$GITHUB_OUTPUT" - name: Install Anthropic Bedrock SDK - # Pinned exact version + --ignore-scripts: this is the privileged job - # (write token in env), so no floating @latest and no dependency - # lifecycle scripts may execute here. - run: npm install --no-save --no-package-lock --ignore-scripts @anthropic-ai/bedrock-sdk@0.31.0 + # Installed ONE DIRECTORY ABOVE the checkout on purpose. Running this + # inside the repo makes npm re-resolve the whole dependency tree from + # package.json (--no-package-lock throws away the pinned resolutions), + # and that re-resolution dies on an unrelated floating peer conflict + # (@hookform/resolvers -> valibot), which silently killed this review on + # every PR. Out of tree npm resolves this one package and nothing else, + # so an unrelated peer conflict can never take the compliance gate down + # again. Node still finds it: ESM bare specifiers walk up the parent + # directories' node_modules. NODE_PATH is not an alternative here, the + # ESM loader ignores it (CommonJS only). + # Version tracks the exact package.json pin: 0.32.0 broke Bedrock + # streaming in prod (DECISIONS.md 2026-07-08) and check:guards enforces + # 0.29.1 repo-wide, so this job must not be the one place running an + # unvetted build. Pinned exact + --ignore-scripts because this is the + # privileged job (write token in env): no floating @latest, and no + # dependency lifecycle scripts may execute here. + run: npm install --prefix "$GITHUB_WORKSPACE/.." --no-save --no-package-lock --ignore-scripts @anthropic-ai/bedrock-sdk@0.29.1 - name: Run compliance review env: AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} diff --git a/.github/workflows/test-pg-real.yml b/.github/workflows/test-pg-real.yml index 173a011a..65af2a94 100644 --- a/.github/workflows/test-pg-real.yml +++ b/.github/workflows/test-pg-real.yml @@ -52,10 +52,16 @@ jobs: steps: - uses: actions/checkout@v7 + # Deliberately no `cache: npm` here. This workflow runs on pull_request + # only, so the cache is never written on main, and GitHub scopes caches + # by ref: every PR uploaded its own 284 MB copy under an identical key + # that no other PR could ever restore. Fourteen dead copies (4 GB, 40% of + # the repo quota) accumulated in a single day. If this is ever worth + # caching again, it has to be actions/cache/save on main plus + # actions/cache/restore here, which is the only shape that gets hits. - uses: actions/setup-node@v6 with: node-version: 20 - cache: npm - run: npm ci diff --git a/DECISIONS.md b/DECISIONS.md index c11102aa..95d981dc 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -384,8 +384,176 @@ One line per decision: `[YYYY-MM-DD] : `. Appended by agents and [2026-07-25] Declined the review suggestion to 200-ack the Resend delivery webhook when RESEND_DELIVERY_WEBHOOK_SECRET is unset; kept 503. The endpoint is only ever called because an operator pointed Resend at it, so a missing secret at that moment is a live misconfiguration: Svix retry then endpoint-disable is a visible signal, whereas a silent 200 loses every delivery outcome with only a log line. The "optional" wording in docs/WHITELABEL.md describes not wiring the webhook at all, not wiring it half way. [2026-07-25] Reverted the settings panel-sheet redesign on bug/resend-and-invoices back to main: Emil prefers the settings UI as it stands on main. The routed sheet, the sheet/ primitives (SettingsMasterDetail, SettingsAccordion, SettingsFieldRow), the *Subsections.tsx decompositions, the cold-load sheet and the settings_sheet i18n namespace were removed; every app/(dashboard)/settings/* page, components/settings/** file and MainContainer scroll exception now matches origin/main byte for byte. Unrelated branch work (invoice delivery outcomes, Stripe feed-only, article currency/deactivation, PDF logo) is untouched. [2026-07-25] Settings UI on bug/resend-and-invoices now comes from feat/settings-fonster-redesign (dbae8792, Jakob) instead of the panel-sheet work reverted earlier the same day: Emil chose the Fonster concept (flat hairline rows, help behind "?", sticky dirty-only save bar, 920x680 modal, switches instead of checkboxes). Applied as a patch rather than a merge because the redesign branch forks from b5e3c476 and merging would have dragged that older main in; every file applied cleanly since no settings file changed on main since that fork point. The 10 settings_payments keys the redesign still carries (needs_review_*, reason_*, sync_done_description/transactions) were deliberately NOT restored: the Stripe feed-only commit on this branch deleted both them and their call sites. +[2026-07-25] Scheduled Image Vulnerability Scan has been red since 2026-07-22 08:45 UTC (last green 07-21 22:45, same code): Vercel published 7 CVEs against next 16.2.10 on 07-22, two HIGH SSRF (CVE-2026-64649 Server Actions on custom servers, CVE-2026-64645 rewrites destination hostname) plus five MEDIUM, all fixed in 16.2.11. Bumped next and eslint-config-next to 16.2.12 (latest 16.2.x, no advisories against it) rather than the minimum 16.2.11, since both are patch-level and 16.2.12 avoids an immediate second bump. Nothing auto-caught this because Dependabot was removed in #1084 and Dependabot alerts are off on the repo, so npm CVE bumps are now a manual chore. +[2026-07-25] Added limit-severities-for-sarif: true to docker-image-scan.yml. trivy-action v0.36.0 entrypoint.sh unsets TRIVY_SEVERITY whenever format is sarif unless that input is true, so the workflow's documented "block on fixable CRITICAL/HIGH" policy was silently running as "block on any fixable CVE down to LOW". Kept format: sarif (the Security-tab upload depends on it) and narrowed the gate instead. Side effect: the Security tab now receives only CRITICAL/HIGH findings from this workflow; docker-publish.yml is untouched and still uploads all severities, so nothing is lost. +[2026-07-26] GitHub Actions cache hit the repo's 10 GB quota (11.09 GB across 272 entries). Two causes, two fixes. (1) docker-publish.yml's buildx layer cache was 6.89 GB of type=gha blobs, and since buildx refreshes every blob's access time on each run it never aged out under LRU, starving the other workflows instead. Moved to type=registry in GHCR (free for public repos, off the quota) rather than dropping to mode=min, which would have dropped the deps/builder stages and made every push redo npm ci + next build. Needs image-manifest=true,oci-mediatypes=true, which GHCR requires. Per-branch scope dropped at the same time: the workflow only runs on main and v*.*.* tags, so there was no untrusted ref to isolate, and per-tag scopes were cutting a fresh cache per release. (2) test-pg-real.yml's setup-node cache: npm produced 3.98 GB as 14 byte-identical 284 MB copies in one day: the workflow is pull_request-only, so the cache was never written on main, and GitHub scopes caches by ref, meaning no PR could ever restore another PR's copy. Removed outright; re-adding requires actions/cache/save on main plus restore here. The 6.89 GB of stale gha blobs stay until one post-merge main build populates ghcr.io/erp-mafia/gnubok:buildcache, since until then they are the only layer cache. +[2026-07-26] swedish-compliance-review.yml now installs the Bedrock SDK OUT OF TREE (npm install --prefix "$GITHUB_WORKSPACE/..") instead of adding --legacy-peer-deps to the in-repo install: with --no-package-lock npm discarded the lockfile and re-resolved the whole tree from package.json, floating @hookform/resolvers to 5.4.3 whose valibot ^1 peer conflicts with the pinned valibot 0.39.0, so the Swedish accounting compliance gate had posted nothing for 10 consecutive runs. Out of tree npm resolves only that one package, so an unrelated peer conflict can never take the gate down again, and Node still finds it because ESM bare specifiers walk up parent node_modules (NODE_PATH is CommonJS-only, so it is not an option); --legacy-peer-deps was rejected because it would mask future genuine peer conflicts and still install the full tree. Same step's SDK version aligned from 0.31.0 (undocumented drift) to the 0.29.1 that package.json and check:guards enforce after the 0.32.0 streaming outage. docker-image-scan.yml deliberately left unchanged: its red runs are the next 16.2.10 CVEs already fixed by the 16.2.12 bump on this branch, not an unfixable advisory, so a suppression would only hide a real patchable HIGH. +[2026-07-26] replace_sie_import got the undo_sie_import owner/admin guard (COALESCE(p_user_id, auth.uid()), fail closed when the role is NULL) plus REVOKE FROM PUBLIC, anon: prod held EXECUTE for anon on a SECURITY DEFINER function with no authorization check at all that sets gnubok.allow_delete, so any caller with a company_id and an import id could hard delete another tenant's verifikationer past the BFL triggers. p_user_id is optional rather than required because app/api/import/sie/[id]/replace/route.ts passes only two args and the session client resolves the actor via auth.uid(); EXECUTE is granted to authenticated as well as service_role because rpcClientForBulkDelete falls back to the caller's session client when SUPABASE_SERVICE_ROLE_KEY is unset, and the fail closed guard makes that safe. +[2026-07-26] arcim-migration OAuth reuses the existing provider_otc table as its state store instead of a new one: the table was created in 20260402010000 for exactly this ("One-time codes for OAuth callback validation") and never wired up, with 139 rows in prod and zero ever consumed. State is now an opaque randomBytes(32) pointer consumed by a single atomic UPDATE ... WHERE used_at IS NULL AND expires_at > now() RETURNING, provider is read from provider_consents rather than trusted from the client, and no grandfathering window was added for legacy base64url state because accepting it for even a minute keeps the critical forgery exploitable. +[2026-07-26] v1 idempotency hash folds dry_run only when true, not as an unconditional boolean: including dryRun:false would change the hash of every ordinary write, and with a 24h idempotency TTL any key in flight across the deploy would fail the request_hash comparison and 409 on a legitimate retry. Both hash call sites now go through one shared buildRequestHash so they cannot drift into a permanent cache miss. +[2026-07-26] documents storage policies compare the company path segment as text instead of casting ::uuid the way the sie-files precedent does: the bucket holds keys whose second segment is not a UUID (MCP audit packages write {userId}/audit-packages/...), and Postgres does not guarantee the bucket prefix qual is evaluated before the cast, so a planner reordering would raise 22P02 and fail the whole query instead of filtering the row out. INSERT is scoped to company membership only, not additionally pinned to auth.uid(), because e-post inbox, bank sync and Bolagsverket legitimately upload on behalf of another member. +[2026-07-26] The journal_entries!inner sweep left the two free text ilike legs of the MCP DISPLAY_SELECT on the embed: each is capped at legLimit and that cap drives the legCapHit/truncated contract, and fetchEntryLines has no limit, so converting would turn a bounded fetch into an unbounded one. app/api/bookkeeping/accounts/[number] moved to the existing get_account_usage_counts RPC rather than the helper, because the embed there was a head:true count and the helper returns rows. +[2026-07-26] Error tracking landed as a provider agnostic sink with a no-op default and zero dependencies rather than a vendor SDK: no dependency was approved and an AGPL-3.0 project audits its surface, and lib/logger.ts already emits structured JSON so a Vercel log drain or a 60 line fetch adapter may make the dependency unnecessary. Redaction moved from lib/logger.ts to lib/observability/redact.ts so logger and sink share one denylist and no path to a third party can skip the personnummer regex; cron failure reporting uses threshold 1 and a 15 minute throttle rather than backup-alert's 3 and 7 days, because suppressing the first occurrence of a failure is the exact bug this work exists to fix. +[2026-07-26] buildInvoicePaymentClearingLines now throws InvoiceBookingRateMissingError (new code MATCH_INVOICE_BOOKING_RATE_MISSING) instead of defaulting a missing invoice.exchange_rate to 1 on the cross-currency path: `?? 1` valued a 1000 EUR receivable at 1000 kr, so an 11 496,70 kr settlement posted 10 496,70 kr to 3960 as a phantom kursvinst with revenue understated by the same amount, and the verifikat balanced so no trigger fired. Kursvinst/kursförlust is by definition the difference between the settlement value and the booked value (ML 8 kap 21-23§, K3 kap 30), so with no booking rate there is no second number and nothing may be posted. A new code was added rather than reusing BATCH_FX_RATE_MISSING, whose Swedish text says "innan du fördelar" and belongs to the batch-allocation flow; the guard bounds (null, <=0, >=100000) deliberately mirror that RPC. "Currency is SEK" stays a separate condition from "rate is missing": a SEK invoice never consults the rate and cannot throw. +[2026-07-26] verifikation.draft's period gate now calls resolvePeriodStatusForDate instead of an inline fiscal_periods query: it had selected fiscal_periods.status and locked_through, neither of which exists, so the whole select errored, period_status was always null and the BFL lock guard never once fired. The shared helper is the same two layer source (company_settings.bookkeeping_locked_through, then is_closed/locked_at) the DB triggers, the MCP server and the v1 REST gate use, so this surface cannot drift from them. A local 'unknown' status was added rather than widening the helper: the helper swallows PostgREST errors and returns 'open', and this intent must fail closed, so a thrown lookup now renders an explicit "do not assume the period is open" instruction plus, for a posted entry, the storno only rule from BFL 5 kap 5 §. +[2026-07-26] Supplier-invoice booking now refuses a foreign-currency invoice with no exchange rate (SI_FX_RATE_MISSING) instead of inheriting resolveSekAmount's "return the raw foreign amount" fallback: the fallback scaled every leg by the same wrong factor, so a 1000 EUR EU reverse-charge invoice booked 250,00 kr of fiktiv moms on 2614/2645 instead of 2875,00 kr at 11,50 SEK/EUR, the entry still balanced, no trigger fired, and rutorna 20/21 + 30 of the momsdeklaration were understated (oriktig uppgift, SFL 49 kap 4 §). Precedent: the match_batch_allocate RPC hard-fails with BATCH_FX_RATE_MISSING and lib/reports/supplier-ledger.ts skips such invoices rather than faking 1:1. The guard was applied to every conversion in the file, not only the three VAT/basis helpers, so no leg (expense, moms, basbelopp, 2440) can post at a fabricated rate; resolveSekAmount itself was deliberately left alone as a separate root fix. SEK invoices and any invoice with a positive stored or settlement-derived rate are byte-identical to before. +[2026-07-26] voucher_gap_explanations inserts are centralized in recordVoucherGapExplanation (lib/bookkeeping/cancel-orphaned-entry.ts): the four copies all named nonexistent gap_number/created_by and omitted the NOT NULL user_id/gap_start/gap_end, so no CAS-race gap has ever been documented. A single stranded voucher N is written as the closed range gap_start=gap_end=N because every reader (voucher-gaps UI, gnubok_list_voucher_gaps, checkYearEndReadiness) keys on voucher_series:gap_start:gap_end. A failed insert is logged at error level with the full payload rather than failing the request: the caller is already returning the correct CAS-conflict response and a 500 would be a worse answer; 23505 counts as already-documented. The two v1 routes keep their "skip the gap row when the orphan has no voucher_series" policy and cancelOrphanedPaymentEntry keeps its 'A' fallback; only the insert was unified. +[2026-07-26] Declined to promote the "Aktiekapitalnoten saknas" source warning to a filing-stage error in lib/bokslut/arsredovisning/completeness.ts: the swedish-financial-reporting skill puts antal aktier/kvotvärde under ÅRL 5:34 § (större företag only) and does NOT list a share-capital note among the K2 mandatory noter (BFNAR 2016:10 kap. 18-19); ÅRL 5 kap 14 §, which build-data.ts:646/662/935 cites for that note, is Ställda säkerheter, a different note the builder always emits and completeness.ts already gates with AR-NOTE-SECURITIES-UNCONFIRMED (error). ok:true is only reachable for K2 + aktiebolag + size_classification 'smaller' (K3 always errors via AR-K3-DRAFT-ONLY, larger/unknown via AR-K2-LARGE/AR-K2-SIZE-UNKNOWN), i.e. exactly the mindre-företag population ÅRL 5:34 § does not reach, so no mandatory note is in fact missing on a passing filing and promoting it would over-block filings against a hard 7-month Bolagsverket deadline. Separately noted, not fixed: report.warnings reaches completeness.ts as untyped string[], so per-warning severity would require coded warnings from build-data.ts (not owned in this pass). +[2026-07-26] lockPeriod's pre-lock guard now counts the canonical worklist "att bokföra" predicate (is_business IS NULL AND is_ignored = false) instead of journal_entry_id IS NULL AND is_business = true: triage is what sets is_business, so the old pair could never describe an untriaged row, and prod confirmed it (18 342 untriaged transactions, none matched; 74 of them are already stranded behind 3 locked periods where enforce_period_lock now makes them unbookable in place, a BFL 5 kap 2 § problem). The journal_entry_id clause was dropped rather than kept alongside, because lib/transactions/is-booked.ts documents that bulk-booked and multi-allocated transactions keep journal_entry_id NULL while anchored to a verifikat, so it blocked on already-booked rows (1 756 matches in prod, mostly sandbox-seed fixtures) while missing every real one. Kept as a hard block, not the bypassable-dialog pattern from the soft-guard rule: that rule covers advisory heuristics whose premise can be genuinely false, whereas "these rows are untriaged" is a deterministic fact, the two triage outs (mark private, ignore) are one click each and both clear the guard, and unlockPeriod already provides an audited escape, so it is not a dead end. Message now names the count and points at Transaktioner, keeping the words "saknar bokföring" the lock route regex-maps to PERIOD_HAS_UNBOOKED_TRANSACTIONS. Not fixed: the byte-identical dead guard in extensions/general/mcp-server/server.ts gnubok_lock_period (not owned in this pass), so the MCP lock path still stages a lock over untriaged rows. +[2026-07-26] bulk-book's mixed-currency guard was hoisted above the branch split in app/api/transactions/bulk-book/route.ts rather than copied into each branch: the check only existed on the template branch, so manual_lines and existing_journal_entry_id reached the RPC, which summed v_tx.amount across currencies and turned 100 EUR + 100 SEK into the scalar 200 on a samlingsverifikat whose belopp matched no affärshändelse. Legal basis cited as BFL 4 kap 6 § (one redovisningsvaluta) plus BFL 5 kap 7 § ("belopp"), NOT the BFL 5 kap 2 § the MCP twin cites: 5 kap 2 § is the bokföringstidpunkt rule (kontant senast påföljande arbetsdag, övrigt så snart det kan ske) and says nothing about currency. Kept as a hard block with no "book anyway" escape, unlike the soft-guard-dialog rule: that rule covers advisory heuristics whose premise can be false, whereas there is no correct amount to bypass to here, so the dialog shows honest per-currency subtotals instead of one summed scalar and never renders the booking UI. The same guard was added to the SQL RPC (20260726100000) even though the route now blocks it, because EXECUTE is granted to authenticated (a browser session can call the RPC straight over PostgREST) and lib/pending-operations/commit.ts commitBulkBookTransactions is a thin pass-through with no currency check. NULL currency is COALESCEd to 'SEK' in all three layers, since transactions.currency is `text default 'SEK'` and nullable; the MCP twin's un-normalized `new Set(txs.map(t => t.currency))` therefore still false-positives on a legacy NULL + SEK batch (server.ts not owned in this pass). +[2026-07-26] Finished the half-applied match-invoice FX conversion instead of reverting it: the v1 route's switch to buildInvoicePaymentClearingLines was already complete end to end (guard, Riksbanken/manual rate, shared line builder, invoice_payments amount+currency+exchange_rate, match-log provenance), and only the test's engine mock had never been updated, so the two red tests were a stale mock (createInvoicePaymentJournalEntry) and not a broken route. The mock now exports createJournalEntry + findFiscalPeriod and the assertions check the emitted clearing lines (bank leg on the transaction's own resolved account, 1510 credit) rather than a delegate call that no longer happens. Also fixed as part of making the two doors agree: (a) the dashboard match-invoice route soft-failed the new InvoiceBookingRateMissingError, which would have marked an invoice paid with no verifikat while 1510 still carried the receivable, an unrecoverable half-state since mark-paid rejects 'paid' invoices; it is now fatal and returns the registered MATCH_INVOICE_BOOKING_RATE_MISSING 400, matching v1. (b) the dashboard route now re-propagates invoice.default_dimensions onto the clearing lines, which createInvoicePaymentJournalEntry did and the shared line builder does not, so a project's kursvinst/kursförlust stays in the project P&L on both surfaces. Both FX-missing dispatches key on the error's `code` string literal, not instanceof, for the same reason the supplier routes do: route tests vi.mock the defining module away, and an imported-but-mocked-away constant would degrade `code === undefined` into a catch-all. +[2026-07-26] Left the six naive Math.round(x*100)/100 calls in the supplier-invoice match preview alone rather than swapping them for roundOre to shrink the check:guards naive-ore-round delta: roundOre applies a Number.EPSILON nudge and therefore disagrees with the naive form at exact-half öre, and the POST handler plus createSupplierInvoicePaymentEntry both use the naive form. Converting only the preview would reintroduce the exact bug the preview rewrite exists to fix (user approves one number, a different one is booked). Converting both sides is a wider money-math change than this pass owns; the honest fix is a shared amount-resolution helper for preview and POST. +[2026-07-26] AGI filing state now resolves per salary run in lib/salary/agi-submission-state.ts, not per period, and the fix is application-code only: no migration. The extension_data cache key agi_submission_{period} really is period-scoped by design (see the 2026-07-13 entry) and agi_declarations really is UNIQUE per company+period, but salary_runs is not: migration 20260414130000 replaced the period unique constraint with a partial index WHERE status != 'corrected', so a correction run legitimately coexists with the run it corrects in the same month and the two share one cache record. Consequence before the fix: deriveAgiFilingState returned 'signed' for a correction run off the ORIGINAL run's record, so the rail rendered the AGI step done and displayed the original's kvittensnummer while nothing had been filed for the correction. Per swedish-payroll references/agi-filing.md a correction is a complete resubmission for the same redovisningsperiod with the same specifikationsnummer, i.e. its own submission with its own kvittens, so run-scoping is the correct data model and no schema change is needed to express it. The record is now matched to the run by salaryRunId when the extension recorded one (/agi/submit and the MCP submit path do), else by signeradTid == run.agi_submitted_at for signed records on an already-filed run, else by refusing any record older than the run's own agi_generated_at (and any record at all when the run has never generated XML). Declined to widen the agi_declarations unique index to one row per salary run: it would need generate-declaration.ts to insert per run instead of upserting per period (not owned in this pass) and the reported bug is fully fixed in derivation. Known residual, not owned here: the original run's kvittensnummer is genuinely overwritten in agi_declarations when the correction is filed (one row per period), so a superseded run renders as filed without a receipt number rather than with the wrong one. +[2026-07-26] previewCurrencyRevaluation now prices each currency through fetchExchangeRate (per currency, with the supabase exchange_rates cache) instead of fetchMultipleRates, and executeCurrencyRevaluation throws ClosingRateUnavailableError before posting when any balansdagen rate is missing: fetchMultipleRates pads its Map with getFallbackRate, whose EUR 11.5 / USD 10.5 / GBP 13.5 constants riksbanken.ts:211-214 documents as display-only and "the booking path never returns them", yet the revaluation posted a real 3960/7960 verifikat computed from them, so a Riksbanken 429 at 05:00 silently produced an orealiserad kursvinst off a hardcoded number. The permitted rate sources are the Nasdaq OMX mid-rate published by Riksbanken or the ECB rate (ML 8 kap 21-23 §), and monetary items must be revalued to balansdagskurs (ARL 4 kap 13 §), so a constant is not a rate and refusing is the only correct outcome; fetchExchangeRate returns null rather than a constant, which is why it is the booking path. A new code FX_CLOSING_RATE_UNAVAILABLE (502, retryable) was added rather than reusing SI_FX_RATE_MISSING, MATCH_INVOICE_BOOKING_RATE_MISSING or TX_EXCHANGE_RATE_UNAVAILABLE: the first two are 400s about a missing stored rate on a row that the user fixes by typing it in, whereas this is the market observation not being published, is not user-fixable, and belongs in the existing FX_ namespace next to FX_PERIOD_NOT_FOUND / FX_PERIOD_CLOSED / FX_FAILED. The refusal is all or nothing and is checked ahead of the empty-preview null shortcut, because a partial post would understate the FX result while looking like a complete balansdagen valuation, and returning null when every currency lacks a rate would report "nothing to revalue" for "we could not value it". Separately, the `.not('exchange_rate','is',null)` filter was dropped from both fetchers: it hid the rows with the largest unmeasured exposure, which are now partitioned out and returned as unconvertedFx / unconvertedFxCount exactly as lib/reports/supplier-ledger.ts surfaces unconverted_fx_count (same "present and > 0" rule, so a 0 rate no longer values the whole receivable as a kursvinst). Those rows report but do not block; only a missing closing rate blocks. Preview never throws so the year-end readiness page still renders. Known residuals, not owned in this pass: errorResponse dispatches this class on path 4, which passes no details, so the REST envelope carries the registry's generic Swedish sentence and the per-currency naming only appears where getErrorMessage sees the instance (MCP getStructuredError) or the envelope carries details.missingRates; and the v1 operation result plus lib/pending-operations/commit.ts commitRunCurrencyRevaluation drop unconvertedFxCount from their payloads. +[2026-07-26] lib/invoices/supplier-invoice-matching.ts now refuses to compare amounts across currencies in passes 2-4: same currency compares raw magnitudes, different currencies compare stored SEK values only, and an invoice with no stored conversion is skipped as a candidate rather than scored. Without this a 1000 EUR supplier invoice "exact matched" a -1000 SEK bank debit at 0.85 confidence on every bank import, offering the user a confident match between amounts differing by a factor of about 11. Two deliberate deviations: (a) the local sekValue helper is lib/bookkeeping/currency-utils.ts#resolveSekAmount minus its final "no conversion info, return the amount as-is" fallback, because that fallback is safe for booking legacy rows but here would hand the matcher a raw EUR total dressed up as kronor, i.e. the exact false match being fixed; (b) an excluded candidate is dropped silently instead of being surfaced with a reason, because findSupplierInvoiceMatch returns a single `SupplierInvoiceMatch | null` and both callers (lib/transactions/ingest.ts, lib/bookkeeping/handlers/supplier-invoice-handler.ts) only ever write a potential_supplier_invoice_id, so there is nowhere to carry a reason without a breaking signature change. Pass 1 (OCR/payment reference) stays currency-agnostic on purpose: the reference identifies the invoice on its own and no amount is involved. Pass 4's 5-unit fuzzy tolerance is expressed in whatever unit the comparison settled on (SEK when cross-currency, the shared currency when both sides already agree), which preserves the pre-existing behaviour for same-currency rows. SEK-only companies take the same-currency path and are byte-for-byte unaffected. +[2026-07-26] The ingest content-dedup currency fix was applied as a per-entry guard inside consumeBridgingTwin (lib/transactions/ingest.ts), NOT by adding currency to contentBucketKey: the bucket keys on (date, öre) only, so a stored 250,00 SEK row and an incoming 250,00 EUR row shared a bucket and the text bridge or the cross-channel mirror could consume either for the other, dropping a real affärshändelse that is then never bokförd (BFL 5 kap 2 §) with only an aggregate duplicate count as evidence. Only the booked-hand-entered mirror had ever carried a currency check; that check was hoisted to the loop so all three match paths (text bridge, cross-channel mirror, hand mirror) share it. Widening the key was rejected for two reasons: contentBucketKey lives in lib/transactions/external-id.ts next to buildStableExternalIds, whose `eb_{iban}_{date}_{öre}_{n}` template is a frozen persisted key, and a currency component in the key would send legacy currency-NULL rows to a different bucket than incoming SEK rows and stop deduping them, re-opening the fleet-wide re-import the June 2026 incident was about. The guard keeps the established null-tolerance (null on either side is compatible), so SEK-only and legacy companies are byte-for-byte unaffected. The cross-channel mirror's count maps were deliberately left currency-blind: a coarse count can only turn the mirror ON, after which the per-entry guard still refuses the match and the row inserts, so the failure direction is a visible deletable duplicate rather than a silent drop. A log.info was added on the content-bridge drop because result.duplicates reaches the API response and bank_file_imports.duplicate_count but no import UI renders it and it cannot distinguish this judgement call from an exact Layer-1 id collision; surfacing skipped rows in BankFileResultStep and the Enable Banking sync toast is left as a follow-up (not owned in this pass). +[2026-07-26] Removed the surviving 6-month first-year floor from lib/onboarding-journey/fiscal-options.ts (abFirstYearEndOptions) and rewrote the four copy sites that still stated it as law. PR #1165 (2026-07-25) already deleted the same invented floor from validatePeriodDuration: BFL 3 kap sets no minimum for a first räkenskapsår, only the 18-month maximum, so an autumn-registered AB may end its first year at 31 Dec. The onboarding journey is the surface a brand new AB actually walks through, and it silently withheld exactly those short options while efFirstYearEndOptions offered them, so AB and EF disagreed for no stated reason. The floor became `months >= 1`, a structural guard (it drops end months that fall before the start month, which would otherwise produce zero or negative spans), not a legal one. journey_fyend_ab_info and journey_fy_info are the worst of the copy: they cited "enligt bokföringslagen" / "under the Bookkeeping Act" for a constraint that does not exist. They now cite the Act only for the 18-month cap, which is real, and state plainly that there is no minimum. The 18-month maximum is enforced twice on this path (option generation never emits > 18, and computeFiscalPeriod runs validatePeriodDuration before submit), so removing the fake floor left the real ceiling intact. +[2026-07-26] lib/reconciliation/bank-reconciliation.ts now resolves every ledger amount through one local helper, ledgerLineAmountIn(line, currency), shared by the matcher, the status card and the auto-link path, rather than each site re-deriving debit/credit. journal_entry_lines.currency labels the DOCUMENT, not the debit/credit columns: lib/bookkeeping/currency-utils.ts converts a foreign amount to SEK for debit_amount/credit_amount and then stamps currency + amount_in_currency onto the same line, so every "the currencies match, so these amounts are comparable" guard passed on exactly the FX rows it existed to catch. The helper returns debit - credit on SEK (never null, so SEK-only companies are byte-for-byte unaffected), and on a foreign currency takes the magnitude from amount_in_currency and the direction from the debit/credit side (some rows store a negatively signed amount_in_currency; the ledger side is authoritative), returning null when the row carries no amount in that currency. Null is reported, never converted with an invented rate: getReconciliationStatus counts those rows as unconvertible_gl_line_count and sets not_reconcilable_reason = 'gl_lines_missing_currency_amount' instead of publishing a difference computed over a subset, and autoReconcileTransactionForLinkedVoucher (which PERSISTS a link) returns null and leaves the row for manual matching. Separately, is_reconciled now requires BOTH a zero net difference AND unmatched_transaction_count === 0: a net-zero difference alone made two offsetting unmatched rows read as avstämt while both were still unbooked affärshändelser each owing its own verifikation identifying belopp and motpart (BFL 5 kap 1-2 §, 6-7 §), and ÅRL 2 kap's individuell värdering and bruttoredovisning forbid treating two offsetting unknowns as knowledge. unmatched_gl_line_count is deliberately NOT in that condition: it is scoped and windowed differently (it counts vouchers not settled on THIS account, so the far leg of an own-account transfer belongs there by design). Declined to extend get_unlinked_gl_lines / get_account_gl_lines_for_matching with currency + amount_in_currency: that needs a migration this pass does not own, so on a foreign account the batch matcher now proposes NO candidates instead of wrong ones (fail safe), while getReconciliationStatus reads journal_entry_lines directly and does reconcile in EUR. Follow-ups not owned here: those two RPCs, a UI surface for not_reconcilable_reason (needs sv+en strings), and lib/bokslut/readiness-aggregator.ts, whose "differens på X kr" fallback message reads oddly when the reason code is set. +[2026-07-26] customers_personal_number_check was re-pointed at the ciphertext shape (migration 20260726110000) instead of moving the encrypted value into a new differently-constrained column. The column was added as plaintext by 20260522130000 with a CHECK on the personnummer format; since the 2026-07-15 encryption change the write path stores AES-256-GCM hex (76-82 chars), which no personnummer regex can ever match, so every write was rejected and prod holds 0 non-null personal_number rows across 4957 customers (1355 of them individuals). Widening is honest rather than a loosening: the format guarantee was destroyed by the encryption change, not by this migration, and a CHECK cannot validate a personnummer it can no longer read; plaintext format validation stays in CreateCustomerSchema/UpdateCustomerSchema, which run before the cipher. The constraint now enforces the opposite and still-enforceable guarantee, that the column must never hold a bare personnummer: lowercase hex only, 76 to 255 chars (255 is the highest repetition count a Postgres POSIX regex accepts), so every plaintext form is too short and/or carries a separator and an unencrypted write fails loudly instead of persisting PII. A separate personal_number_encrypted column was rejected: it would add a third storage shape for the same data next to employees.personnummer and invoices.deduction_personnummer_encrypted, which are both encrypted TEXT with no format CHECK, and would touch every read site for zero rows of benefit. Separately, the "a masked value means leave it alone" rule moved from components/customers/CustomerForm.tsx into the PATCH write path, which required accepting the '********-1234' sentinel in UpdateCustomerSchema (the only edit to lib/api/schemas.ts) so the route can see and ignore it; CreateCustomerSchema stays strict because on create there is no stored value to preserve. Known residual, not owned in this pass: app/api/export/customers/route.ts writes c.org_number ?? c.personal_number into the "Org-/personnummer" column of the kundregister export, which will emit raw ciphertext now that rows can exist, and lib/customers/protect-personal-number.ts lets a decrypt failure throw out of maskCustomerRow, which would 500 the whole customer list. +[2026-07-26] The three inline underlag-link loops (JournalEntryForm handleConfirm and handleSaveDraft, QuickReviewDialog handleConfirm) now go through lib/documents/link-documents.ts#linkDocuments, which checks res.ok and returns a per-document verdict; all three previously counted only thrown errors, so any 4xx/5xx from POST /api/documents/{id}/link (PERIOD_LOCKED, DOC_LINK_ENTRY_NOT_FOUND, a 500) left the verifikat without the underlag BFL 5 kap 7 § requires it to reference and BFL 7 kap requires archived with it, while the user got a green "Verifikation skapad" and the files were cleared. The success toast is now branched rather than preceded by a separate warning toast: use-toast has TOAST_LIMIT 1, so the destructive toast the two counting sites did emit was immediately evicted by the success toast added right after it, meaning the warning was never actually visible. Files that failed to link are kept in the upload zone (the failed ones only; the linked ones are dropped) because clearing them erases the user's only pointer to the underlag they believed was filed; the accepted residual is that a leftover file would attach to the NEXT entry booked from the same open form, which is loud and visible rather than silent. No new retry feature was invented: /bookkeeping/{id} already renders JournalEntryAttachments (upload plus "Välj från inkorgen") for both posted entries and drafts, so the toast names the missing files and carries a ToastAction that navigates there. TransactionBookingDialog was left untouched as the correct reference (it already checked res.ok); it still clears its files unconditionally, which is the one behaviour the new sites deliberately do not copy. +[2026-07-26] extensions/general/arcim-migration/lib/entity-mapper.ts now resolves a real SEK conversion for migrated invoices instead of the dead ternary `exchange_rate: dto.currencyCode === 'SEK' ? null : null`, which returned null on BOTH branches and left every foreign-currency invoice the migration imported unconverted, alongside null subtotal_sek / vat_amount_sek / total_sek. The provider DTOs (lib/providers/dto.ts) offer NO rate and NO SEK amount: a SalesInvoiceDto/SupplierInvoiceDto carries only currencyCode plus amounts already expressed in that currency, so the rate had to be fetched. It is fetched for the invoice's OWN issueDate, never today, via the in-house pattern from lib/transactions/ingest.ts: fetchExchangeRate(currency, new Date(date), supabase) with the supabase client so the shared exchange_rates cache absorbs repeat dates, pre-resolved once per unique (currency, date) pair at concurrency 4 so a wide historical backfill is not rate-limited by Riksbanken. An imported invoice is rakenskapsinformation (BFL 7 kap) and its SEK value is part of the record, so stamping it with an import-day rate would misstate it. Three deliberate choices: (a) an invoice whose rate cannot be established (currency outside Riksbanken's series, or no observation for that date) is still IMPORTED, not skipped, because dropping it would lose rakenskapsinformation; it keeps exchange_rate = null so lib/bookkeeping/supplier-invoice-entries.ts and lib/bookkeeping/invoice-payment-lines.ts refuse it loudly (SupplierInvoiceFxRateMissingError / InvoiceBookingRateMissingError) rather than posting at a fabricated 1:1, and it is counted into MigrationResults.{salesInvoices,supplierInvoices}.fxUnresolved plus a per-invoice console.error so the migration reports it instead of passing it off as an ordinary import; (b) the "needs attention" marker was NOT written into invoice.notes, which was the obvious in-product surface, because lib/invoices/pdf-template.tsx renders notes on the customer-facing PDF and a Swedish exchange-rate warning would be printed on a real faktura; (c) the fxRates parameter is optional rather than required so the existing entity-mapper tests and any other caller keep compiling, and the failure mode when it is omitted is a reported rate_unavailable, never a silent 1:1. The SEK branch now writes a plain commented null for exchange_rate (a domestic invoice has no rate to record) while still filling the *_sek columns via a sekFactor of 1, and `dto.currencyCode` undefined now normalises to SEK consistently, which also fixes the old mismatch where currency was written as 'SEK' but subtotal_sek came out null. Follow-ups not owned in this pass: components/extensions/general/ArcimMigrationWorkspace.tsx duplicates the MigrationResults type and does not render fxUnresolved, so the count reaches the API response but no UI yet; mapSalesInvoice still writes your_reference/our_reference as null although SalesInvoiceDto carries buyerReference/orderReference; mapSupplier hardcodes default_currency 'SEK'; and mapSalesInvoice's paid_amount lacks the Math.max(0, ...) floor its supplier-invoice counterpart has. +[2026-07-26] The momsdeklaration import pair (IMPORT_BASE_WITHOUT_OUTPUT / IMPORT_OUTPUT_WITHOUT_BASE) was converted from a binary presence test to the proportional form PR #1164 gave the RC pair, comparing ruta 50 against ruta60/0.25 + ruta61/0.12 + ruta62/0.06 with the same max(1 kr, 0.5%) tolerance; the sales pair (TAXABLE_SALES_WITHOUT_OUTPUT / OUTPUT_VAT_WITHOUT_SALES_BASE) was deliberately LEFT binary. Binary was unsound on both: a period where one import voucher carries both halves cleared the check while the rest shipped tullvardesunderlag with no utgaende importmoms, i.e. undeclared moms and skattetillagg 20 % under SFL 49 kap 4 §. The import implied base is exact because ACCOUNT_RUTA feeds ruta 50 only from 4545/4546/4547 (Import av ravaror och material, 25/12/6 % moms) and rutorna 60/61/62 only from 2615/2625/2635, a closed one-to-one set at exactly those three rates, with VAT-free import on the unmapped 4540. The same arithmetic is NOT exact on the sales side, which is why it was not applied there: rutorna 07 (VMB) and 08 (frivillig uthyrning) have no source accounts in ACCOUNT_RUTA at all while their output moms (2616/2626/2636, 2613/2623/2633) does feed rutorna 10-12; ruta 05 recognises only 3001/3002/3003 while lib/bookkeeping/invoice-entries.ts lets any per-line revenue_account override land the moms on 2611/2621/2631 anyway; and periodiserade lines credit 29xx with the moms left undeferred on 2611, then credit 3001 with no moms on dissolution, so the two sides drift in both directions by design. Any of those would make a proportional sales check a permanent filing-blocking ERROR (isFilingBlocked) on a correct declaration. Mapping rutorna 07/08 and reconciling the revenue_account override is the prerequisite for converting the sales pair. +[2026-07-26] Addendum to the currency-revaluation entry above: the `remaining_amount <= 0` skip for payables was moved out of the item loop and into the new partition loop, so a fully settled foreign-currency leverantorsskuld is now dropped before its currency is collected. Consequence, and the reason for the move: such a row has no exposure to revalue, so it must neither be counted into unconvertedFx (it is not unmeasured exposure, it is nothing) nor pull a closing rate whose absence would then refuse the whole balansdagen posting. Keeping the old order would have let a settled row block a valuation it cannot affect. +[2026-07-26] components/salary/AGIPanel.tsx narrows the AGI submission record for the FILING RECEIPT only (a locally derived runSubmission = resolveRunAgiSubmission({ id: salaryRunId, agi_generated_at, agi_submitted_at }, submission)) instead of receiving an already-narrowed record as its `submission` prop, and app/(dashboard)/salary/runs/[id]/page.tsx therefore keeps passing the raw period record down while routing the progress rail through resolveRunAgiKvittensnummer. The panel computed isSigned = submission?.status === 'signed' || !!agiSubmittedAt independently of deriveAgiFilingState, so on a corrected month (salary_runs is unique per period only for non-corrected runs, migration 20260414130000, while the cache key is agi_submission_{YYYYMM}) the correction run read the original's signed record, rendered as already filed, printed the superseded declaration's kvittensnummer, and hid every filing action behind its `!isSigned` gate: the correction could not be filed from the UI at all. Swapping the whole prop for the narrowed record was rejected because the in-flight machine is genuinely period-scoped: Skatteverket's granskningsunderlag lock and lasUpp address a redovisningsperiod, not a run, and the signing-link card, the stale-draft warning and the "Lås upp" button all hang off awaitingSigning. Narrowing that too would delete the only recovery path in a reachable state, since lib/salary/agi/generate-declaration.ts re-stamps agi_generated_at on every call, so one click on "Ladda ner AGI-fil" while a draft is locked pushes the run's XML past the record and resolveRunAgiSubmission stops claiming it; it would also strand a correction whose period is still locked by the original's draft, which is exactly the case where the unlock button is the required next step. A correction is a complete replacement declaration for the same period (same specifikationsnummer per employee, references/agi-filing.md) filed on its own and receiving its own kvittens, so an original whose cached receipt a later correction has replaced now renders as filed WITHOUT a number rather than with the successor's. No AGIPanel props changed and no new UI strings were needed. Not owned in this pass: the gnubok_agi_status MCP tool (extensions/general/mcp-server/server.ts) returns the same raw period record as local_state keyed by salary_run_id and has the identical bug. +[2026-07-26] The momsdeklaration filing gate was extracted into lib/reports/vat-filing-gate.ts (withRcBasisGapFindings + isFilingBlocked) and the per-verifikat rc-basis-gaps scan is now folded into the SAME check array the "Kontroll av underlaget" banner, the stegen counters and "Skicka till Skatteverket" all read, because checksBlocked previously derived from runVatDeclarationChecks alone: that aggregate compares period totals against max(1 kr, 0.5 % of the implied basis), so at a 400 000 kr RC basis up to 2 000 kr of missing basbelopp hides inside the tolerance and the green "Inga fel hittades i underlaget for perioden" rendered directly above the worklist of the very verifikationer that made the declaration wrong, with Skicka enabled. The per-voucher scan is authoritative because each gap is fiktiv moms on 2614/2624/2634 with no basbelopp on 44xx/45xx, which understates rutorna 20-24; vid omvand skattskyldighet ska bade beskattningsunderlaget och den fiktiva momsen redovisas (tyst kvittning ar inte tillaten), so the declaration is incomplete, not merely suspect, and the finding blocks exactly like the aggregate ERROR it stands in for. Blocking rather than warning is not a dead end: the one-click Korrigera worklist sits under the finding and VatManualFilingCard (eSKD-XML + PDF, and the /api/reports/vat-declaration/eskd and /pdf routes behind them) is deliberately ungated, so only the direct SKV submission is gated. The 0.5 % tolerance itself was left untouched in vat-declaration-checks.ts. Three scan states are kept apart: only a settled count of zero means "inga brister", a FAILED scan adds a non-blocking WARNING row (a network hiccup must not lock a user out of a statutory deadline, but an empty list would render as an all-clear the scan never earned), and an in-flight scan says nothing. Because "says nothing" is only safe while the card is unmounted, the scan result is tagged with a PERIOD key rather than the retryKey-bearing fetchKey: steg 1 stays mounted across a korrigering, so re-tagging on retryKey dropped the gate open for one round trip after every Korrigera click and re-exposed the exact banner-above-worklist regression. Stale-but-closed is the safe direction and matches the declaration, which stays on screen dimmed while the next one loads. +[2026-07-26] Both mark-paid surfaces (app/api/invoices/[id]/mark-paid/route.ts and app/api/v1/companies/[companyId]/invoices/[id]/mark-paid/route.ts) now resolve paymentAmountInInvoiceCurrency BEFORE the duplicate-payment guard instead of after it, and refuse a non-SEK invoice that carries no usable exchange_rate with 400 MATCH_INVOICE_BOOKING_RATE_MISSING instead of dividing by a `?? 1` fallback. Two separate defects sat in the old shape. First, the guard compared the raw SEK sum of the custom lines against remaining_amount, which is stored in the INVOICE currency: total/paid_amount/remaining_amount have no _sek twins (core_schema 20240101000001 gives subtotal/vat_amount/total a _sek column each but paid_amount none, 20260323120001 backfills remaining_amount as GREATEST(0, total - COALESCE(paid_amount,0)), and match_batch_allocate 20260531120000 writes exactly v_paid_in_inv_currency into both), so a 5 748,35 kr payment on a 1 000 EUR invoice at 11,4967 read as a full settlement, ran the advisory on a genuine 500 EUR partial, and 409'd INVOICE_PAID_LIKELY_DUPLICATE on the matching bank row. Second, the `?? 1` fallback made an 11 496,70 kr payment on a rate-less 1 000 EUR invoice arrive at planInvoicePayment as 11 496,70 EUR, i.e. remaining 1 000 - 11 496,70 = -10 496,70, which only survived as a MATCH_AMOUNT_EXCEEDS_REMAINING that blamed the amount rather than the missing rate; below the remaining nothing caught it at all, so 500 kr booked as 500 EUR paid against a customer who had really paid ~43 EUR. MATCH_INVOICE_BOOKING_RATE_MISSING was chosen over BATCH_FX_RATE_MISSING because the latter is the match_batch_allocate RPC's own code (its Swedish text says "innan du fordelar", allocation wording that does not fit a manual mark-paid) while the former is the app-layer code buildInvoicePaymentClearingLines already throws for the identical condition on the bank-match path, and its registry remediation (structured-errors.ts:467) already tells the user to set invoice.exchange_rate: one condition, one code across every invoice-settlement surface. The gate is deliberately narrowed to `isForeignCurrency && customLines !== undefined`: the default no-lines path pays remaining_amount, which is already in invoice currency, so a rate-less foreign invoice can still be settled in full and needs no rate at all, and a SEK invoice never consults exchange_rate so paidRounded stays byte-identical to the old expression. The raw SEK paymentAmount is still what reaches findDuplicatePaymentCandidatesForInvoice, because that helper scans transactions.amount, which is kronor; only the comparison moved into invoice currency. Two pre-existing divergences between the two routes were left as found rather than widened into this diff: v1's guard reads `remaining_amount ?? total` while the dashboard route reads `remaining_amount ?? total - paid_amount` (differs only for legacy rows with a NULL remaining and a non-zero paid), and v1 validates custom-line balance before the FX gate while the dashboard route validates it inside settleInvoicePayment after the gate, so a request that is both unbalanced and rate-less gets INVOICE_PAID_LINES_UNBALANCED from one and MATCH_INVOICE_BOOKING_RATE_MISSING from the other (both 400, neither books). Not adopted: buildInvoicePaymentClearingLines' MAX_PLAUSIBLE_FX_RATE upper bound (rate >= 100000), so an absurd stored rate still divides the payment down to a near-zero partial on both routes; the shared error message already claims to cover "out of range", so closing that is a follow-up. Parity is guaranteed structurally, not by convention: both routes feed the converted amount into the same planInvoicePaymentForLines (lib/invoices/apply-invoice-payment.ts, FX-agnostic by contract), v1 directly and the dashboard route via settleInvoicePayment. +[2026-07-26] lib/invoices/rot-rut-file.ts now converts every belopp to SEK with the invoice's booking rate before rounding to whole kronor, and blocks a foreign-currency invoice that has no usable rate with a new MISSING_EXCHANGE_RATE blocker instead of emitting a guessed figure. Begaran.xsd V6 (vendored, dev_docs/skatteverket/husavdrag/V6/BegaranCOMPONENT.xsd) types BetaltBelopp and BegartBelopp as BeloppTYPE = xs:long 0..99999999999 and PrisForArbete as its own xs:long with minInclusive 2, and carries no currency attribute anywhere, so the begaran is a kronor document by construction; the file previously emitted raw invoice-currency amounts while the ledger leg (generateRotRutLines, lib/bookkeeping/invoice-entries.ts) already debited BAS 1513 in SEK, so a 625 EUR avdrag at 11,40 stood as 7 125 kr on 1513 while the begaran asked Skatteverket for 625 and the receivable could never clear. The conversion is single-sourced through deductionSekConverter() in rot-rut-rules.ts, which reproduces the ledger's per-amount Math.round(amount * rate * 100) / 100 exactly, so the two can only ever disagree by the whole-kronor rounding the XSD forces. Three deliberate choices: (a) a new per-invoice blocker code rather than reusing SI_FX_RATE_MISSING or MATCH_INVOICE_BOOKING_RATE_MISSING from structured-errors.ts, because this surface returns RotRutBlocker[] (code plus Swedish message) through ROT_RUT_INVOICES_BLOCKED and the eligible-list API, never a structured envelope, and both existing codes name a different document (supplier invoice, payment matching); (b) validateInvoice() takes an OPTIONAL fourth DeductionCurrencyContext argument so every existing caller keeps compiling and the SEK path stays byte-identical, with lib/invoices/build-invoice-write.ts passing only { currency: input.currency } because the Riksbanken rate is fetched further down the same function (the write needs the totals first) and reordering a shared write-builder for a warning string was not worth the risk; a foreign invoice therefore reports "kan inte stammas av mot arsmaximum, fakturan saknar vaxelkurs" rather than measuring a foreign figure against the 50 000 kr ceiling; (c) the missing-rate case is a WARNING in validateInvoice and a BLOCKER in rot-rut-file, because invoicing ROT work in a foreign currency is legal (ML lets any currency be used) and must stay creatable, while the statutory filing must not go out with a number nobody can defend. requested_total is now safe to sum because no unconvertible invoice can reach it. Left alone on purpose: the MIXED_DEDUCTION_TYPES rule at rot-rut-file.ts:177, whose remedy ("dela upp i separata fakturor") is impossible for an issued and paid invoice, stays UNRESOLVED pending a reading of whether two begaran may reference one fakturanummer; and computeDeduction() still takes 30 percent of the line total EXCLUDING moms while HUSFL and the skill both say ROT is 30 percent of arbetskostnaden INKLUSIVE moms, a separate under-deduction that predates this pass. +[2026-07-26] Supplier-invoice creation now REFUSES a foreign-currency invoice it cannot translate (400 SI_FX_RATE_MISSING) instead of persisting exchange_rate = NULL. All three writers (POST /api/supplier-invoices, POST /api/v1/companies/:id/supplier-invoices, invoice-inbox /items/:id/convert) previously took body.exchange_rate ?? null with no server-side fetch, which is what produced the permanently unconverted rows the booking path now rejects. Storing NULL only relocates the same failure to the booking step, where the user no longer has the invoice in hand, so the refusal happens at creation, before the ankomstnummer sequence is touched. The three surfaces share ONE resolver (lib/currency/supplier-invoice-rate.ts) rather than three copies of the policy: that shared function, not a convention, is what keeps them from drifting apart again. +[2026-07-26] FX rate for a supplier invoice is fetched for the INVOICE DATE, not delivery_date. ML 8 kap 21-23 § anchors the translation at the taxable event, which is delivery_date when it differs, so this is knowingly the looser of the two anchors; chosen because invoice_date is already the date the registration verifikat is posted on and the date the period-lock check runs against, and splitting the money anchor from the verifikat anchor for the minority of rows that carry a delivery_date is a bigger change than this fix. Flagged as an open refinement rather than changed silently. Source stays Riksbanken's SWEA series (the Nasdaq OMX mid-rate), the first of the two sources ML 8 kap 21 § permits. +[2026-07-26] supplier_invoices.total_sek is now written for SEK invoices too (rate 1, so total_sek = total). The old writers gated all three _sek columns on an exchange rate existing, and a SEK invoice never has one, so 1 238 of 1 595 prod supplier invoices across 540 companies had total_sek NULL. Migration 20260726120000 backfills the SEK rows and any foreign row that already carries a rate; the 9 foreign rows with no rate are left NULL on purpose because inventing a rate after the fact lands in rutorna 20-24 + 30-32 of the momsdeklaration. That migration is written but NOT applied. +[2026-07-26] Reminder paminnelseavgift is quoted in SEK on foreign-currency invoices instead of being converted or relabelled: Lag (1981:739) 2 § plus forordning (1981:1057) fix the fee at 60 kronor with no conversion rule, and Accounted books it 1510/3990 in SEK, so a converted figure would demand money the books do not record. The swedish-invoice-compliance skill only maps the fee to BAS 3930 and does not settle the foreign-invoice question, so the conservative option was taken. Reminder emails and the public /invoice-action page now show "1 010,00 EUR + 60 kr" rather than one mixed-currency scalar. +[2026-07-26] The KPI reader now resolves the SEK amount per supplier invoice instead of reading supplier_invoices.total_sek: for a SEK invoice the SEK total IS total (exact, not an approximation), so "Storsta leverantorer" no longer depends on the writer fix or on the 20260726120000 backfill landing. Prod evidence: 1 238 of 1 547 SEK rows and 9 of 48 foreign rows had total_sek NULL, and only 23 of 552 companies with supplier invoices had a single row where it was set, so the panel and the "Topp leverantorer" xlsx sheet were empty for essentially every company. The conversion goes through resolveSekAmount (lib/bookkeeping/currency-utils.ts) for parity with the ledger, EXCEPT its last branch: that branch returns the raw foreign amount as if it were SEK, which is exactly the silent wrongness this sweep is about, so a foreign invoice with neither total_sek nor an exchange_rate is counted in topSuppliersUnconvertedFxCount / the "Ej omraknade valutafakturor" xlsx row instead, mirroring unconverted_fx_count in lib/reports/supplier-ledger.ts. Both the query and the aggregation live in ONE place (topSupplierInvoicesQuery + aggregateTopSuppliers in lib/reports/kpi.ts, the module both routes already imported): a shared function, not a convention, is what stops the JSON route and the xlsx export from disagreeing about the same company, and it is asserted by a cross-route test that runs both handlers over identical rows. +[2026-07-26] Both worked-hours writers now persist start_time/end_time and the GET selects them back. UpsertWorkedDaySchema and BatchUpsertWorkedDaysSchema validated and documented the shift window, salary_worked_days.start_time/end_time exist in prod (added by 20260526120900_ob_overtime_premiums.sql, verified via information_schema, so no migration was needed), run-calculation.ts already SELECTs both and feeds them to computePremiumLines, and no other code path writes the table: the two API routes simply never included the fields in their INSERT, so every row in production carries NULL times. shift-premium-engine.ts then falls back to an assumed 08:00-17:00 day, which gives a pure-night OB rule (22:00-06:00) zero overlap and prices weekend work on nine fabricated daytime hours instead of the hours actually worked; a night shift was paid as office hours with no OB-tillagg at all. The batch route additionally reads the rows it is about to replace BEFORE deleting them and carries forward every per-day value the body omits (notes, salary_run_employee_id, the shift window), because the batch has ONE shared body for N dates and no way to express per-day values: the UI sends notes as `notes.trim() || undefined`, so "mark Mon-Fri as 8 h" wiped every note the user had written on those days. The single-day route deliberately keeps full-replace semantics rather than adopting the same preserve-on-omit rule: it addresses exactly one day and the caller can describe every field of it, so an omitted field there genuinely means "not set", and preserve-on-omit would make the window unclearable given the schema accepts optional-but-not-null (lib/api/schemas.ts was out of scope to change). NOT settled: whether the 08:00-17:00 fallback is defensible for the legacy NULL-time rows. .claude/skills/swedish-payroll/references/ob-overtime.md is a byte-identical copy of sick-pay.md (md5 68e7aba1..., wrong since the file was added in #202), so the skill carries no OB-tillagg or Arbetstidslagen content at all and the question was left unanswered rather than guessed from training data. Note for whoever fixes that file: the engine treats WorkedDayShift.hours as a documented "sanity cap" but never applies it (the only use is `hours <= 0`), so premium hours come purely from the window and a 4 h shift stored with a 12 h window would be over-paid. +[2026-07-26] The supplier_invoice.review "KANDA FAKTA ... fraga INTE om dessa" block no longer carries an AI-extracted amount whose currency is unknown or disagrees with the registered invoice: those values move to a separate "EJ BEKRAFTADE BELOPP" block that says the currency is missing (or names the conflict) and tells the agent to read the underlag before attesting. A number without a unit is half a fact, and half a fact under a "do not ask about this" header reads to the model as confirmation of the registered amount, which is how a 1 200 EUR invoice was being attested on the number 1 200. The alternative (keep the amount in the known-facts block and merely append a warning) was rejected: the header's whole function is to suppress questions, so the fix has to be removal from that block, not annotation inside it. The supplier name / org.nr / VAT-nr stay known facts, since they need no unit. Registered amounts now print their own currency plus the SEK leg from total_sek/exchange_rate when one exists, and an explicit "SEK-BELOPP SAKNAS" when it does not: the same shape inbox.bulk-book uses for the bank leg, and deliberately WITHOUT resolveSekAmount's last branch, which returns the foreign face value as if it were SEK. +[2026-07-26] The inbox "Bokfor valda" dialog (components/extensions/general/BulkBookInboxDialog.tsx) now shows per-currency subtotals instead of one scalar, but deliberately does NOT block submit on a mixed-currency selection, diverging from the sibling fix in components/transactions/BulkBookDialog.tsx. That dialog builds ONE samlingsverifikation, which must sit in a single redovisningsvaluta (BFL 4 kap 6 §), so refusing is the only correct outcome there. This dialog posts to POST /api/extensions/ext/invoice-inbox/items/bulk-book, which runs bulkBookMatchedInboxItems -> categorizeMatchedTransaction per item: ONE verifikat per underlag, and the booked belopp is read off that item's matched bank transaction, never off extracted_data.totals.total (lib/transactions/categorize-core.ts header: "Booking is always in SEK off the bank transaction's own amount"). A EUR invoice settled by the bank in SEK is therefore booked correctly, and a mixed selection yields a set of individually correct SEK verifikat, so hard-blocking would refuse a legal everyday batch (a receipt run where some suppliers invoice in EUR) and would also contradict the decision already documented in that same file, that currency must not drive behaviour because a Swedish seller can invoice in EUR and still debit 25% moms. The real defect was display-only and is fixed: the old `totalSek` summed totals.total across currencies AND rendered it through formatCurrency()'s SEK default, so a mixed batch added 100 EUR to 100 SEK and stamped "kr" on it, and even a homogeneous EUR batch read as kronor. Currency normalisation (missing/blank -> SEK, matching pickCurrency() and emptyExtraction()) lives in the pure helper components/extensions/general/bulk-book-inbox-totals.ts so a legacy null-currency underlag is not split away from its SEK siblings into a phantom currency mix; a malformed code also falls back to SEK because the value comes from AI extraction and Intl.NumberFormat throws RangeError on a bad code. NOT done: no server-side guard was added, correctly, since the server has nothing to refuse here. +[2026-07-26] buildInvoiceWriteData now fetches the customer-invoice rate for delivery_date ?? invoice_date and passes the supabase client, where it previously called fetchExchangeRate(currency) with neither. Two separate defects sat in that one call. The missing date stamped TODAY's rate on every invoice, so a back-dated or historical invoice booked 1510 and 2611 at the wrong SEK value; the swedish-invoice-compliance reference (ML 8 kap 21-23 §) is explicit that the rate to use is the one "at time of taxable event (delivery/supply date or advance payment date, not invoice date unless same)", and delivery_date is exactly that date whenever it is set, because ML 17 kap 24 § p.7 requires the delivery date on the invoice precisely when the two differ. The missing client meant the shared exchange_rates cache was consulted on NEITHER leg: not the read-through before Riksbanken, and not the last-cached-observation fallback after a 429, so one transient rate limit during invoice creation left exchange_rate permanently NULL and resolveSekAmount() then books the raw foreign number as if it were kronor (1 000 EUR posted as 1 000 kr). lib/transactions/ingest.ts has always passed the client; this is the invoice path catching up. Knowingly DIVERGES from the supplier-invoice entry above, which anchors on invoice_date and flags the taxable-event anchor as an open refinement: on the customer side the anchor was the finding under repair and the skill quote settles it, so the looser choice was not carried over. The verifikat's entry_date stays invoice_date on both sides, so a customer invoice with a delivery_date in a different month now carries a rate from a different day than its verifikat date; that is what the law asks for, and it is the same split every Swedish system has. +[2026-07-26] POST /api/invoices/[id]/refresh-exchange-rate REFUSES a booked invoice (409 INVOICE_FX_REFRESH_BOOKED) rather than routing the re-rate through storno or the inline rattelse RPCs. The endpoint exists because a sent invoice is no longer a draft, so PATCH /api/invoices/[id] cannot reach it and several structured errors (MATCH_INVOICE_BOOKING_RATE_MISSING, BATCH_FX_RATE_MISSING) told the user to "komplettera fakturans exchange_rate" via an endpoint that did not exist; both remediations now name it. Refusing beats auto-correcting for three reasons: once the invoice has a verifikat the SEK amounts ARE bokforda poster, and BFL 5 kap 5 § allows exactly two tracks to change those, both of which are decisions about the ledger that the user must make deliberately (a storno consumes a voucher number and an inline rattelse writes an immutable who/when row); the correct rebooking is not mechanical, since a changed rate on a settled invoice also moves the kursvinst/kursforlust leg on 3960/7960 that the payment entry already posted; and a silent UPDATE of the invoice row behind a committed entry would desync the invoice from the ledger it produced, leaving the reports showing the old wrong value while the invoice claims the new one. The booked check is deliberately belt-and-braces (invoice.journal_entry_id, which was backfilled late, PLUS a journal_entries lookup by source_id, PLUS an .is('journal_entry_id', null) guard on the UPDATE for the TOCTOU window). Period locks are checked against invoice_date, the date the verifikat would carry, through resolvePeriodStatusForDate, which fails closed. The route writes ONLY exchange_rate, exchange_rate_date and the three _sek columns: subtotal / vat_amount / total / remaining_amount are denominated in the invoice currency and re-rating them would silently reprice a sent invoice. No manual-rate override was added: ML 8 kap 21-23 § permits only the Nasdaq OMX mid-rate via Riksbanken or the latest ECB rate, so an unreachable Riksbanken returns 502 and stays retryable instead of accepting a typed-in number. NOT wired to any UI yet: the transaction twin is reachable from QuickReviewDialog, the invoice one is API/agent only. +[2026-07-26] The customer-invoice matcher's currency guard stays exactly as strict, but stops being silent: findInvoiceMatchCandidates() is a new sibling of findMatchingInvoices() that returns { matches, unconvertedFxCount, unconvertedFxInvoices } so a caller can say WHY the EUR invoice that obviously pays the bank row is missing from the suggestions, and point at POST /api/invoices/{id}/refresh-exchange-rate. Shape copied from unconverted_fx_count in lib/reports/supplier-ledger.ts. A sibling function was chosen over changing findMatchingInvoices' return type because three callers consume the array (lib/transactions/ingest.ts via getBestInvoiceMatch, app/api/transactions/batch-match-invoices, and the gnubok_auto_match_period MCP tool) and only the MCP tool has an output schema that could render the reason today; a breaking change to all three to serve one is not worth it. getBestInvoiceMatch keeps returning InvoiceMatch | null: same "no room for a reason" constraint the supplier-side twin reported, and widening it is left to whoever has a surface to render it on. Also fixed while in there: the conversion fell through to `return invoiceAmount` when total_sek was missing, handing a raw EUR figure to a SEK comparison. It was reachable, not theoretical, via total_sek = 0, which passes the `!= null` currency guard and then fails the `total_sek && total` truthiness check: a 1 000 EUR invoice matched a 1 000 kr receipt at 0.80 confidence, asserted by a test that fails against HEAD. It now returns null. DIVERGES from lib/invoices/supplier-invoice-matching.ts in two known ways, both deliberate because the fix is about visibility and must not make the matcher match more: the supplier side falls back to the invoice's stored exchange_rate when the _sek column is absent and normalises currency codes (case, and NULL meaning SEK), the customer side reads only total_sek and compares codes raw, so a legacy transaction with a NULL currency is still silently excluded against a SEK invoice. +[2026-07-26] lockPeriod's unbooked-transaction guard now blocks on TWO predicates, not one, and the shared resolvePeriodStatusForDate fails closed. The old guard was `journal_entry_id IS NULL AND is_business = true`, which is close to backwards: triage is what sets is_business, so it could not describe an untriaged row at all (prod: 18 289 rows with is_business IS NULL and is_ignored = false, exactly ONE of which has a journal_entry_id), while the journal_entry_id leg falsely matched already-booked rows whose verifikat lives in transaction_voucher_links or the two payment tables. Locking therefore succeeded on periods full of untriaged transactions and enforce_period_lock then froze them unbookable in place, a BFL 5 kap 2 § problem with no exit but an unlock or a rattelse. Leg 1 is now the canonical worklist predicate (is_business IS NULL AND is_ignored = false) so the number in the error reconciles with the "N st att bokfora" badge. Leg 2 was added after prod evidence contradicted the first draft of this fix: 1 758 rows have is_business = true with journal_entry_id NULL, and only 5 of them are genuinely booked per is_transaction_booked(), so dropping the old leg entirely would have silently stopped guarding ~1 750 rows (about 136 outside the sandbox seed) that the user has already CONFIRMED are affarshandelser and that still have no verifikat: the stronger stranding case, not a weaker one. Leg 2 therefore reproduces all three anchoring locations from lib/transactions/is-booked.ts rather than trusting journal_entry_id, which is what keeps it from over-blocking the bulk-booked and multi-allocated rows the old guard tripped on. Hard block, no bypass: locking is a voluntary internal control with no legal deadline, the affarshandelser it strands do have one, and all three escape hatches (bokfor, mark privat, mark ignored) are exits from the predicate rather than overrides of it, so a "lock anyway" button would only add an audit_log control override. is_business = false and is_ignored = true are triaged-and-excluded and never block. Message keeps the two load-bearing phrases: "saknar bokforing" for both lock routes' 400 mapping, and an unconditional "affarstransaktioner" for inferCode()'s /Kan inte lasa period:.*affarstransaktion/ regex, which the per-leg breakdown alone does not satisfy in the untriaged-only case; the infra-failure message deliberately matches NEITHER so an unreachable DB does not send an agent off remediating transactions. Separately, resolvePeriodStatusForDate swallowed PostgREST errors and returned 'open' with period_id null, a fail-open in a helper that six write paths consult; it now reports status 'locked' with an additive optional lookup_failed: true, kept distinguishable from the legitimate "verified, no covering period" case (open + period_id null). lookup_failed is a new optional FIELD rather than a fourth PeriodStatusValue because lib/agent/intents/bokslut-step.ts consumes the union as an exhaustive Record and widening it would break an unrelated caller at compile time. NOT fixed, both out of the owned diff: extensions/general/mcp-server/server.ts (~line 11133) carries a verbatim copy of the old dead guard as gnubok_lock_period's staging-time pre-check (harmless to the legal guarantee, since commit goes through the fixed lockPeriod, but it lets an agent stage a lock that then fails at approval), and lib/api/v1/check-period-lock.ts, the documented mirror of resolvePeriodStatusForDate, still drops both query errors and returns locked: false. +[2026-07-26] The composer's banking and SIE counterparty summaries now carry a CurrencyMagnitude (SEK total of the rows whose SEK value is actually known + native total per currency + count of rows with no SEK equivalent) instead of one `abs_amount` number, and lib/agent/composer/atom-selection.ts renders that magnitude per currency rather than stamping "kr" on it. loadBankingSummary() selected only `description, amount, date, journal_entry_id`, but transactions.amount is denominated in transactions.currency, so a 30 000 EUR row was summed with SEK rows and printed to the model as "30 000 kr". That text is the INPUT to the call that writes the company's standing agent profile, so a wrong magnitude is baked into durable instructions rather than misleading one answer, which is why this is rendered honestly instead of approximately. Chosen: per-currency rendering with an explicit unknown, over three alternatives. Not a single SEK total (that is the bug), not a live FX lookup at compose time (the composer must stay a cheap read of stored data, and Riksbanken's rate today is not the rate at transaction date), and not silently excluding foreign rows (that would understate a EUR-heavy consultancy and read as "small" rather than "unmeasured"). Resolution order per row mirrors lib/bookkeeping/currency-utils.ts resolveSekAmount() but deliberately DROPS its final fallback, which returns the raw foreign amount when no rate is stored: correct for booking legacy data, fatal here, since it is exactly what turns 500 EUR into "500 kr". Same choice lib/agent/intents/inbox-bulk-book.ts txSek() already made. Rows with no stored rate are surfaced twice: inline as "SEK-motsvarighet OKÄND", and, when the SEK ranking would have dropped them below the top-20 slice (they rank as 0 kr), in a separate `unconvertible_counterparties` block labelled as omatt, not small. monthly_volume counts only convertible rows and is labelled GOLV when any row is missing, or OKÄND when none convert, instead of the old total that silently added foreign amounts. Uncertainty is flagged deterministically, not left to the model: buildCurrencyUncertaintyNotes() appends the caveats to uncertainty_notes inside selectAtoms() after Zod validation, the same belt-and-braces pattern already used for hallucinated atom ids, so the durable profile carries the caveat even when the model ignored the prompt rule. The field is still named `abs_amount` rather than `abs_amount_sek` on purpose: SourceSignalsSchema in lib/agent/composer/schemas.ts persists this shape into agent_profiles.source_signals, and renaming it would change what already-written snapshots mean, so schemas.ts was left untouched and the richer object rides along as extra JSONB keys. SIE top_accounts keeps its bare kr rendering, correctly: journal_entry_lines.debit/credit are booked SEK per BFL 4 kap 6 §, with any foreign original kept separately in currency/amount_in_currency. NOT done: no per-company redovisningsvaluta support (BFL 4 kap 6 § allows EUR bookkeeping; the whole codebase assumes SEK and this fix does not change that), and no backfill of exchange_rate on existing rate-less transactions. +[2026-07-26] The four auth redirects in lib/supabase/middleware.ts now go through one bounceToAuth(request, target) helper that records the destination, and safeReturnTo was HARDENED in the same change because preserving a destination without it would have shipped a live open redirect. Three of the four sites dropped the destination although the pages they land on read it, and the protected-route bounce cloned request.nextUrl and overwrote only pathname, so /settings/billing?success=1 arrived as /login?success=1: the stray parameter leaked while the thing worth keeping was thrown away. The helper builds the URL fresh from the request origin (an absolute-path reference clears query and fragment), which fixes the leak structurally rather than by deleting known keys. Parameter name is per target, not global: /login reads next (app/(auth)/login/page.tsx) and the MFA pages read returnTo, so AUTH_DESTINATION_PARAM maps it explicitly instead of guessing, since sending the wrong name is a silent no-op. safeReturnTo re-checked: its prefix guards and sentinel-origin check both PASS /..//evil.com, /.//evil.com and /%2e%2e//evil.com, all of which URL normalisation collapses to //evil.com, and the login page then does window.location.assign on it. That was already exploitable at /login?next=/..//evil.com before this change; the fix re-validates the NORMALISED path, which cannot be re-normalised around, and a middleware test proves the new site-4 code emits Location: http://evil.com/ without it. Site 4 (authenticated user on an auth page) deliberately honours next for /login and /register ONLY: the same block also covers /auth and /sandbox, and /auth/callback is the PKCE exchange, where honouring a destination would skip the code exchange, so those keep bouncing to / unchanged. /register does not itself read next today (it reads invite), so consuming it there is symmetry, not a live path. MFA semantics untouched: same conditions, same targets, only a query parameter added, and the pages navigate to the destination only after the step-up succeeds. +[2026-07-26] The two booking-time duplicate guards (lib/transactions/booking-duplicate-detection.ts, lib/invoices/duplicate-payment-detection.ts) now compare in SEK and say so, instead of putting a raw transactions.amount next to an always-SEK journal_entry_lines.debit_amount/credit_amount. Basis chosen: SEK, resolved by a single new exported helper resolveTransactionAmountSek() (currency SEK/null -> abs(amount); else amount_sek; else amount * exchange_rate; else NULL). Deliberately the OPPOSITE choice from ledgerLineAmountIn() in lib/reconciliation/bank-reconciliation.ts, which resolves ledger lines in the ACCOUNT's currency: there the whole statement being reconciled is foreign, whereas the twin these guards hunt is usually an ordinary SEK verifikat (invoice markera som betald, salary net-wage payout, a hand-posted entry) that carries no amount_in_currency at all, so comparing in EUR would resolve every one of those to null and reopen the double-booking hole. line.currency is never read as evidence of comparability, because currency-utils.ts stamps it onto lines whose debit/credit is SEK. Null (never the raw foreign number, which is what currency-utils' resolveSekAmount() falls back to) is the point: guessing the unit IS the bug. When the bank line has no SEK value the amount test is SKIPPED rather than failed, and the surviving candidate is returned with amount_verified: false + unverified_reason: 'transaction_missing_sek_value'. Returning null there would read as "no duplicate, go ahead" and mint a second verifikat for one affarshandelse (BFL 5 kap 1-2 §); hard-blocking would refuse a booking the software cannot judge, and an unbooked affarshandelse breaks lopande bokforing just as surely, so the guard warns and leaves the call to the user via the existing force / allow_duplicate bypass. The existence half of the question stays unit-free (direction + settlement account + date window + unlinked + not storno), so an empty candidate list is still a genuine verified pass. Both entry points take currency as a REQUIRED field rather than an optional one: optional reads as undefined for any caller projecting a narrow column list, undefined would have to default to SEK, and that default silently disables the FX half of the guard: required turns the mistake into a compile error, which is what caught app/api/transactions/[id]/duplicate-payment-check/route.ts (narrow GDPR-minimal select) and the gnubok_categorize_transaction MCP tool (select had currency but not amount_sek/exchange_rate). Also fixed in the sibling-transaction half: it compared transactions.amount to transactions.amount with no currency check, so 100 EUR matched 100 SEK on the same date; it now requires the two currency labels to agree and compares in that shared currency, which is exact, rather than routing both through amount_sek. lib/invoices/duplicate-payment-detection.ts imports the helper from the booking module rather than duplicating it, accepting the cross-directory import so the two documented mirrors cannot drift. NOT done: the client dialogs (DuplicateBookingDialog, InvoiceMatchDialog) still render copy asserting the amounts matched; amount_verified/unverified_reason ride along in the candidate payload and the server-generated Swedish messages (categorize-core, MCP) were made honest, but the two dialogs need an i18n string pair before an unverified candidate reads correctly in the UI. +[2026-07-26] The invite cookie is now consumed through one lib/auth/consume-invite-cookie.ts and is deleted ONLY on a definitive outcome, replacing four hand-rolled copies (login BankID + login password + register BankID signup + mfa/verify) that cleared it on the `!res.ok` fall-through AND on the throw. A network blip or a 500 from /api/team/accept therefore destroyed the browser's only copy of a token whose invitation was still `pending`, and stranded the invitee in the app with no company, no message, and no recovery short of the inviter re-sending the invitation. classifyInviteAcceptStatus() reads the definitive/transient split straight off app/api/team/accept/route.ts: 2xx and 409 ("Du är redan medlem.") are `accepted` because the invitee IS in the company; 400 ("Inbjudan är ogiltig.", covers unknown and non-pending), 404 and 410 ("Inbjudan har gått ut.", which the route also marks expired) are `spent`; everything else, notably 401 (the session cookie had not propagated yet), 429 and 5xx, is `retryable`. 403 email-mismatch is its own disposition and deliberately RETAINS the token: the invitation is untouched and still pending server-side, so it is not spent, and the user's only remedy ("log out, log in as the invited address") requires the token to survive the logout, which is the same reasoning app/invite/[token]/page.tsx handleSignOutAndRetry already encodes by re-writing the cookie for exactly this case. Retention is a real recovery path rather than a deferral, which is why keeping it beats clearing-plus-apologising: /onboarding and /select-company both re-run acceptance server-side from the surviving cookie via acceptPendingInviteByToken(), and the dashboard root redirects a company-less user to /onboarding, so a retained token is retried on the very next page load. No widening of the token's exposure: the cookie's max-age, path, samesite and secure flags are set at write time in the invite page and are untouched here (the 3600s-vs-7-day-TTL mismatch is a separate queued finding, deliberately not adjusted in either direction), its authority is bounded server-side by company_invitations.expires_at plus the single-use status transition, and acceptance is re-authorized on every POST by requireAuth() plus an email equality check, so a surviving cookie confers nothing on its own. Non-definitive outcomes now toast a Swedish message (invite.accept_retry_*, accept_wrong_email_*, accept_spent_*); the Toaster is mounted in the root layout with TOAST_REMOVE_DELAY 1000000, so it survives the router.push that follows, though NOT the window.location.assign taken when ?next= / ?returnTo= is set, which the invite flow never sets. NOT converted, and not part of this finding: the fifth copy at app/(auth)/register/page.tsx ~:259 (auto-confirmed local-dev signup) already clears only on success and hard-navigates afterwards, so a toast there would be destroyed on the spot; app/invite/[token]/page.tsx handleJoinNow (token comes from the route param, which the helper's `options.token` covers when someone converts it); and app/(auth)/auth/callback/route.ts, which accepts server-side against the DB rather than over fetch and can reuse the classification but not the transport. +[2026-07-26] The invoice download on app/(dashboard)/invoices/[id]/page.tsx now distinguishes three states instead of two, via the new pure helper lib/invoices/invoice-pdf-source.ts. A failed /api/invoices/[id]/deliveries read used to collapse to [], which is byte-identical to "nothing was ever sent", and the download then silently switched from the ARCHIVED PDF the customer received (invoice_deliveries.document_attachment_id, WORM, rakenskapsinformation kept 7 years per BFL 7 kap) to a freshly re-rendered one built from today's invoice row, customer row, company_settings and logo, under the same unqualified "nedladdad" toast. The three states are now: archived (serve it, plain toast), unavailable (delivery history unreadable: nothing is downloaded, a dialog offers Forsok igen / Ladda ner nyskapad PDF anda / Avbryt), and rerender (no archived copy can exist: served, but the toast title says "Nyskapad PDF" and names the reason). Chosen over the simpler alternative of just retrying the deliveries fetch, because a retry that also fails lands back in the same ambiguity; and over blocking the re-render entirely, because for invoices predating invoice_deliveries (2026-07-22) and for manually marked-sent invoices the re-render is the only document that exists and refusing it would be a dead end. The consent path deliberately uses ONE dialog and ONE toast per download: components/ui/use-toast.tsx has TOAST_LIMIT = 1, so a warning toast emitted next to a success toast would be evicted and never render. invoiceStatus gates ONLY the unreadable case, so a draft is never blocked by an unrelated fetch failure; when the history did load it is the data that decides, because "cancelled" does not prove nothing was sent (a proforma can be emailed and makulerad afterwards, and its archived copy is still what the recipient got). +[2026-07-26] Every async handler on app/(dashboard)/salary/runs/[id]/page.tsx now releases actionLoading on a throw, and the bulk payslip ZIP reports partial failures by name through the new pure helper lib/salary/payslip-zip-report.ts: actionLoading gates the header button, the progress rail AND the employee table, so the one handler with no try/catch (Ladda ner AGI) froze the entire page on any rejected fetch until a reload, and the ZIP loop did if (!res.ok) continue and only warned when the counter reached zero, so 17 of 20 payslips downloaded under a plain success toast. The partial archive is still handed over rather than withheld: 17 real lonespecifikationer are worth more than none, and the employer can fetch the rest individually. It is reported with ONE destructive toast naming who is missing, never success-plus-warning, because components/ui/use-toast.tsx has TOAST_LIMIT = 1 and ADD_TOAST does [newToast, ...state.toasts].slice(0, 1), so the last dispatch wins and a warning emitted before a success is evicted unrendered (same reasoning as the invoice-download entry above). handleDelete and handleCorrect deliberately keep no finally: their success path navigates away and the buttons should stay disabled across the route change, so only the paths that stay on the page release. The verdict logic was extracted to a pure module rather than tested through the component because the suite is node-env with no component tests (CLAUDE.md Testing), and the extraction is what makes never-call-a-partial-archive-complete an assertion instead of a comment. Not owned in this pass, same TOAST_LIMIT eviction: handleCalculate loops toast() over every warning and handleSendPayslips emits a success toast followed by one toast per failed recipient, so in both only the last dispatched toast is ever seen. Also unresolved: .claude/skills/swedish-payroll documents the AGI individuppgift and its 12th-of-month deadline but says nothing about what the employer owes the employee on a lonespecifikation or when, so the loudness of the ZIP fix was argued from the artefact (the employer hands out what the archive contains and has no other signal that someone is missing) rather than guessed from training data; note references/ob-overtime.md and references/sick-pay.md are still byte-identical (md5 68e7aba1), so that skill has known holes. +[2026-07-26] The PUT upsert in app/api/import/sie/mappings/route.ts now writes source_name, resolved as (client-supplied sourceName if a non-empty string) else (the value already stored for that company_id + source_account) else null, and its onConflict target was corrected from the dropped 'user_id,source_account' constraint to the one that actually exists, 'company_id,source_account'. source_name is the label the exporting system wrote into the SIE file's #KONTO record ('#KONTO 1910 "Kassa"'), it is per-file and cannot be recomputed from the BAS chart, and lib/import/account-mapper.ts ~:144 reads it straight back for display during the mapping review, so omitting it from the INSERT path left the reviewer a bare 4-digit number with no context on the one screen where the whole migration's account mapping is approved. Chosen: read-then-carry-forward over requiring the client to resend the field, because a client that does not know about the field is exactly how it got erased; the extra round trip is acceptable on a single-mapping override endpoint. Re-reading the label from the archived SIE file was rejected: sie_account_mappings is company-scoped and outlives any one file (unique on company_id + source_account), the PUT payload carries no import id, and recovering it would mean fetching from the sie-files bucket and re-running encoding detection (CP437/UTF-8) plus parsing for one text field, with no defined answer when two imported files label the same account differently. The onConflict fix is bundled deliberately rather than as scope creep: prod has no unique index on (user_id, source_account) (dropped by the multi-tenant refactor 0dd1f5eb, which repointed saveMappings() but not this route), so the statement could only raise 42P10 and the source_name fix would have been inert; prod has 7323 mapping rows and zero with match_type='manual', which is what this endpoint writes, corroborating that it has never once succeeded. NOT fixed, same file, separate finding: POST passes user.id into saveMappings(supabase, companyId, mappings), writing a user UUID into company_id and failing the FK, so POST 500s too; left alone to keep the diff on one finding, and its existing test asserts the broken call. +[2026-07-26] Verified the SEK-basis duplicate-guard sweep (entry above) and declined to widen it into the two client dialogs, which is now the only gap left: components/transactions/InvoiceMatchDialog.tsx renders formatCurrency(candidate.amount, transaction.currency), and since candidate.amount is deliberately the 19xx leg's SEK figure, a EUR bank line makes it print a kronor number under a EUR symbol. That mislabel predates this sweep (it is byte-identical at HEAD), it needs an i18n string pair in messages/sv.json + messages/en.json to fix properly alongside the missing amount_verified: false copy, and both dialogs are outside the two files this pass owns, so the fix is left as the follow-up the sweep entry already names rather than smuggled in as a drive-by. Also confirmed not to be a regression: with a non-finite transactionAmount, detectDuplicatePaymentVoucher now warns (unverified) where it previously returned null, because resolveTransactionAmountSek rejects NaN before the zero-guard can; transactions.amount is NOT NULL numeric so no production row reaches it, and warning is the safe direction anyway. +[2026-07-26] data/.json files in the full archive now carry the parent row's currency (and exchange_rate where the parent has one) as invoice_currency / supplier_invoice_currency / receipt_currency, via a declarative denormalize: { prefix, columns } on MasterDataTableSpec, rather than leaving the unit recoverable only by joining data/invoices.json. invoice_items, supplier_invoice_items and receipt_line_items carry money (unit_price, line_total, vat_amount) and no currency column of their own, and their "unit" column is the quantity unit ("st"), so a revisor opening one file alone could not tell 1000 EUR from 1000 SEK; the parent dumps were never affected because select(*) already carries currency, exchange_rate and the *_sek twins side by side. BFL 7 kap does not literally require each archived file to be self-describing (it requires varaktigt lasbart skick, preserved structure/metadata and a producible whole, with BFNAR 2013:2 kap 8 putting traceability from verifikation to bokslut in systemdokumentation), so a documented join would have been defensible; the denormalisation was chosen anyway because it is strictly additive, costs one extra column per parent row on a query that already runs, and removes a reading that depends on the reader still having the sibling file. Deliberately NOT applied to rot_rut_payout_request_items (parent rot_rut_payout_requests has no currency column; HUS-avdrag is SEK by statute, and asserting a unit that no column holds would fabricate rakenskapsinformation) and not needed for journal_entry_lines (its own table carries currency, exchange_rate and amount_in_currency, and grundbok/SIE amounts are SEK by construction). Only the conversion basis is copied, never the parent totals: a line's SEK value is not the invoice's total_sek. Collisions are avoided by never overwriting an existing child key, and the "?? null" fallback is load-bearing because JSON.stringify drops undefined keys. Three new assertions in tests/pg/full-archive-coverage.pg.test.ts keep it from rotting (parent columns exist, no collision with a real child column, every child of a currency-carrying parent denormalises it). Known gap left open: recurring_invoice_schedule_items is not in MASTER_DATA_DUMP_TABLES at all (it has no company_id, so the coverage contract cannot see it) even though its parent recurring_invoice_schedules is dumped and does have a currency column; it holds invoice TEMPLATES for affarshandelser that have not occurred, so it is arguably not rakenskapsinformation, but the exclusion is undocumented. + +[2026-07-26] app/api/bookkeeping/fiscal-periods POST no longer refuses to create the next rakenskapsar while a prior period is still fully open (the PERIOD_CREATE_BLOCKED_BY_OPEN_PERIODS 409 is gone); the same detection now rides along as a non-blocking `warnings: [{ code: 'PRIOR_FISCAL_YEAR_STILL_OPEN', message }]` on the 200, matching the {code,message} warning shape already used by app/api/invoices/[id]/book. The old gate had no support in BFL and inverted two rules that bind at once: BFL 5 kap 2 § requires the new year's affarshandelser to be bokforda "sa snart det kan ske" (per BFNAR 2013:2 senast manaden efter), which needs a rakenskapsar covering January within weeks, while BFL 6 kap gives the arsbokslut 6 months (AB filing 7), so the prior year is legitimately unfinished and must stay UNLOCKED for bokslutsposter. Concretely: one untriaged December bank transaction makes lockPeriod refuse (correctly, lib/core/bookkeeping/period-service.ts), and the route then refused to create the new year, so all bookkeeping stopped; with that guard now failing closed on a lookup error, the pair was a hard deadlock no user action could clear. The gate also pushed users into locking the prior year before bokslut, and every later unlock lands in audit_log as a control override. What still blocks is unchanged and narrower: the 18-month BFL 3 kap duration cap, predecessor/successor date adjacency (BFNAR 2013:2 continuity chain), and the overlap 409 backed by the no_overlapping_fiscal_periods exclusion constraint. Prior-year protection was never this route's job: it lives in fiscal_periods.locked_at (enforce_period_lock) and company_settings.bookkeeping_locked_through (enforce_company_lock_date), and creating the next period writes no row into the prior one. Opening balances are not made worse: executeYearEndClosing already reuses a pre-existing next period and posts the IB verifikat (entry_date = nextPeriod.period_start) into it, refusing only when opening_balance_entry_id is already set, so the IB lands by itself later either way. Follow-up left open (not this route's files): components/bookkeeping/CreatePeriodDialog.tsx still carries the now-unreachable "Las aret och skapa" branch keyed on the retired code, and does not surface the new warnings. + +[2026-07-26] validatePersonnummer (lib/salary/personnummer.ts) now accepts samordningsnummer by stripping the +60 day offset before the 1-31 range check, and the v1 REST employee create route runs it (it previously parsed only CreateEmployeeSchema's `^\d{12}$`, so a check-digit-invalid number entered payroll and surfaced weeks later as a Skatteverket AGI rejection). The offset is stripped into a local birthDay only: the Luhn check still runs over the printed digits including the +60, which is load-bearing rather than cosmetic, since the check digit for day D and day D+60 differ (verified: day 01 -> 2, day 61 -> 9). The agreement test in lib/salary/__tests__/personnummer.test.ts reads IDENTITET_PATTERN out of lib/salary/agi/xml-generator.ts at test time with readFileSync and lifts the regex literal, rather than copying the pattern or exporting it: the const is module-private, and a copy would keep passing after the generator changed, which is the exact drift the test exists to catch (the loader throws if the const is renamed, so it cannot silently degrade into testing nothing). Two divergences from Skatteverket's pattern are asserted rather than fixed, so whichever side moves fails loudly: the generator also accepts day 60 (unknown day of month) and month 00 with day 60-99 (unknown birth date entirely), which the employee validator rejects and which registering an employee has never supported; and the validator's plain 1-31 range is looser than the generator for short months (April 31, February 30), pre-existing and untouched. MCP gnubok_create_employee (extensions/general/mcp-server/server.ts ~:10669) still encrypts and stages without any validation and remains the one surface out of step, queued separately. Report-only, not fixed: CreateCustomerSchema.personal_number is format-only (`^(\d{6}|\d{8})[-+]?\d{4}$`, no Luhn, no date check, 10-digit short form allowed), unlike the ROT/RUT invoice path in lib/invoices/build-invoice-write.ts ~:358 which runs the full validatePersonnummer. + +[2026-07-26] components/bookkeeping/AccrualPeriodControl.tsx compared the K2 5 000 kr vasentlighetsgrans against a line amount that is denominated in the invoice's currency, so a 500 EUR line (about 5 750 kr, i.e. ABOVE the limit) rendered "Belopp under 5 000 kr behover normalt inte periodiseras (K2)" and a 6 000 NOK line (about 4 800 kr, BELOW it) rendered nothing. The threshold is now applied to a SEK value resolved by a new pure helper, components/bookkeeping/accrual-k2-hint.ts, and the hint is suppressed entirely when that value cannot be determined. Suppression, not a guess, was chosen because the K2 rule (BFNAR 2016:10) is a simplification the company MAY use and not an obligation: "Individual recurring costs below 5,000 SEK (not fluctuating >20%) need not be accrued (except personnel costs)". Nothing is blocked or booked differently by the hint, so an advisory nudge measured in the wrong unit is strictly worse than no nudge, and the proportionate fix is to stay silent. Deliberately NOT reusing resolveSekAmount() from lib/bookkeeping/currency-utils.ts: its documented fallback returns the foreign amount untouched, which is exactly the silent wrong-unit branch this fix exists to remove; the helper returns null instead. The two call sites differ and the asymmetry is real, not an oversight: components/supplier-invoices/NewSupplierInvoiceForm.tsx carries a Riksbanken-fetched or hand-typed exchange_rate on the form, so its hint is now correct in every currency, while components/invoices/InvoiceEditor.tsx has no exchange_rate field anywhere in its zod schema, defaultValues or submit payload (invoices.exchange_rate exists as a column and the edit page selects * , but the editor never reads or writes it), so its foreign-currency lines get currency only and the hint stays hidden until the customer-invoice editor grows a rate. SEK behaviour is byte-for-byte unchanged, including the amount > 0 guard that keeps discount lines silent. Also fixed in the same prop: the "N manader x X" preview called formatCurrency(amounts[0]) with no currency and therefore printed a kronor symbol on a EUR installment; it now passes the line currency. No new i18n keys were needed, and all nine keys the component already references exist in both messages/sv.json and messages/en.json. + +[2026-07-26] POST app/api/import/sie/mappings now passes the withRouteContext-resolved companyId into saveMappings(supabase, companyId, mappings) instead of user.id, which had been writing a user UUID into sie_account_mappings.company_id (NOT NULL, FK to companies). Both are strings, so TypeScript could not see it; the fix that would prevent recurrence is a branded CompanyId/UserId pair or a named-argument object on saveMappings, which lives in lib/import/sie-import.ts and is outside this pass's files, so it is reported rather than implemented. Correction to the finding as filed: the endpoint did not 500. supabase-js returns {data, error} and only throws on transport failure, and saveMappings discards the return value entirely, so the FK violation was swallowed and POST answered 200 {success:true} while persisting nothing, which is why nobody reported it. The fix is necessary but NOT sufficient and the endpoint is still inert: sie_account_mappings.user_id is NOT NULL with no default and no BEFORE INSERT trigger, and saveMappings never supplies it, so ExecConstraints rejects the INSERT before ON CONFLICT can turn it into an UPDATE. Prod proves the two defects are independent: lib/import/sie-import.ts:2368 inside executeSIEImport has always passed the correct companyId, 834 SIE imports ran between the multi-tenant refactor and today, and sie_account_mappings has zero rows created after 2026-03-29 (6506 exact + 817 bas_range, all pre-refactor). Fixing user_id and surfacing the discarded error belong to lib/import/sie-import.ts; the same wrong-argument call also survives at extensions/general/arcim-migration/index.ts:914. The route-level test that pinned the bug (toHaveBeenCalledWith(supabase, 'user-1', mappings)) was repointed to 'company-1', and because a mocked saveMappings can only show WHICH value was passed and never which COLUMN it reaches, a second test now runs the real saveMappings against a recording Supabase double and asserts the upserted row carries company_id: 'company-1'. Element-level validation of body.mappings (blind-cast to AccountMapping[], so matchType and confidence reach the DB unchecked) was deliberately left alone: the endpoint has no caller anywhere in the repo, so no payload shape could be verified against a real client, and the match_type CHECK constraint is the existing backstop. + +[2026-07-26] Two reskontra FX/visibility fixes. (1) app/api/reports/supplier-ledger/supplier/[supplierId]/invoices was the last `remainingSek ?? remaining` in the ledger family: for a foreign-currency supplier invoice with no exchange_rate it put the raw foreign amount into the ReportSourceLine `credit` field, which components/reports/ReportRowExpansion renders under a bare "Kredit" column in kronor, i.e. 1 000 EUR read as 1 000 kr against 2440. `credit` now falls back to 0, never to the foreign number, and the row keeps its own-currency amount in a new `remaining` field alongside the existing `remaining_sek` (mirrors ARInvoiceDetail's outstanding / outstanding_sek pair, so no information is lost by refusing to lie about the unit). A consumer distinguishes the two states on `remaining_sek`: null = SEK value unknown and excluded from every SEK total, 0 = genuinely settled; the response envelope also carries `unconverted_fx_count`, the same contract the ledger reports already expose. Chose 0 over omitting the row: BFL 5 kap 4 § sidoordnad bokföring means the open item must stay visible, it just must not be counted in kronor. (2) lib/reports/ar-ledger.ts's `.filter(total_outstanding !== 0)` conflated two different zeros: a customer settled by credit notes (suppress, that is why the filter exists) and a customer whose open invoices were ALL unconvertible, whose buckets never accumulated so the total is 0 by absence. The second was still counted in unconverted_fx_count, so PDF, XLSX and web view claimed "N fakturor saknar växelkurs" while the rows appeared nowhere, contradicting the "always: even if unconvertible, so it's visible" comment 40 lines above. The filter now keeps an entry when total_outstanding !== 0 OR any of its invoices has outstanding_sek === null. The predicate is deliberately derived from outstanding_sek rather than from a new per-customer counter, because outstanding_sek === null is the exact same condition that increments unconverted_fx_count, so the invariant "every invoice in the count is reachable in entries[].invoices" holds by construction. Not fixed, reported: the supplier side has no such filter (a supplier netting to zero still shows a 0,00 row there, unlike AR), but it hides all-unconvertible suppliers by a different mechanism, the `continue` at lib/reports/supplier-ledger.ts ~:107 fires before the bySupplier entry is created, so the supplier never enters `entries` at all and, having no per-invoice detail array in SupplierLedgerEntry, has nowhere to surface either. + +[2026-07-26] SIE import now REFUSES a #RAR longer than 18 months instead of warning and continuing: ensureFiscalPeriod (lib/import/sie-import.ts) validated the #RAR start day and end day but never the BFL 3 kap duration cap, so a 24-month rakenskapsar imported cleanly while every other period-creation path (app/api/bookkeeping/fiscal-periods POST and PATCH, period-service createNextPeriod/createPreviousPeriod, onboarding computeFiscalPeriod) rejected the same span through validatePeriodDuration. Reused monthsBetween() from lib/bookkeeping/validate-period-duration.ts rather than writing a third month-counting variant, so the boundary is bit-identical: an 18-month forlangt rakenskapsar still imports, 19 does not. Applying the 18-month ceiling to EVERY #RAR is the loosest correct bound and blocks no legal import: 18 is the maximum for an extended or re-laid year and 12 is the norm otherwise, so nothing legitimate is longer. No minimum was added (BFL 3 kap sets none, per the 2026-07-25 and 2026-07-26 removals of the invented 6-month floor). Refuse over warn even though refusing a migration import is heavy and the accountant cannot edit the file: no reading of BFL 3 kap permits a rakenskapsar past 18 months, so this is a malformed or merged #RAR rather than historical data booked under older rules, and continuing would stamp every imported voucher with a period that no UI path can repair afterwards (the fiscal-period editor rejects the identical span), leaving undo_sie_import as the only exit. The check sits before the destructive delete of an empty onboarding-seeded period, so a refused import leaves the company untouched, and the Swedish message names the real recovery: re-export from the source system with one rakenskapsar per file, which is the standard SIE migration path anyway. Not fixed, reported: only #RAR 0 reaches ensureFiscalPeriod, so prior-year #RAR -1/-2 records are parsed into header.fiscalYears and never date-validated at all (they create no period, so they are inert today), and sie-parser.ts parses the #RAR year index with a bare parseInt that can yield NaN without complaint. +[2026-07-26] Tax deadline regeneration now inherits the superseded row's user-owned columns (notes, due_time, priority, customer_id) and manually reported status (in_progress/submitted/confirmed), generalizing the old in_progress-only key-match. One rule: the generator owns what the statute decides (title, due_date, deadline_type, linked report), the row owns every mark a person put on it. The statutory columns always take the template value because the schema cannot tell "user edited this" from "template changed under an untouched row" (nothing records the template value at creation, and the deadlines_updated_at trigger fires on every automatic status sweep); a preserved divergent due_date would also make the backfill cron flag the company forever, since its identity is type:period:due_date. An obligation the settings no longer produce is still deleted with its edits: the settings change is the user's own statement that it does not apply. + +[2026-07-26] InvoicePicker and SupplierInvoicePicker now rank candidates through the shared pure helper components/transactions/invoice-candidate-ranking.ts instead of `Math.abs((remaining_amount ?? total) - Math.abs(tx.amount))`, which compared two numbers in different units: a 1 000 EUR invoice sorted above the genuine 1 000 SEK invoice for a 1 000 SEK bank row, same number, roughly eleven times the money. Ranking fix, not a filter: the manual pickers deliberately list every open invoice regardless of currency because the user must still be able to settle a foreign invoice by hand, so nothing is ever removed and three tests pin that (list length preserved, unconvertible row present but last). Deviated from BankTransactionPicker, which the finding said to mirror, on one point: it puts same-currency rows strictly first and lets cross-currency rows keep date order, whereas these two rank same-currency and SEK-converted rows against each other by their difference and only tie-break on basis. Justified because the two targets differ in kind. BankTransactionPicker's target is a half-typed invoice form whose amount has no SEK equivalent yet, so its converted tier is unreachable and the tie-break never arises; here both sides are persisted rows carrying their own conversion data, and an 11 496,70 kr deposit settling a 1 000 EUR invoice is the entire reason cross-currency matching exists, so burying that invoice under every wildly-off SEK invoice would have traded one bad ranking for another. The SEK conversion is `remaining * invoices.exchange_rate` guarded by the shared isValidExchangeRate, deliberately identical to match_batch_allocate (20260531120000_match_batch_allocate_cross_currency.sql), which values a cross-currency settlement the same way and refuses it outright with BATCH_FX_RATE_MISSING when the rate is missing: ranking on the same rule means the order the user sees agrees with what the booking path will accept, and a foreign invoice with no booked rate reports `incomparable` rather than being scored on a meaningless number. `exact`/`close` stay false on a converted comparison because that figure values the invoice at its own booking rate, not at what the bank moved, so agreeing to the öre is a coincidence, not a confirmation. Currency is normalized to SEK before every comparison (transactions.currency and invoices.currency are both nullable `text default 'SEK'`; a plain `===` reads NULL as "some other currency" and demotes an ordinary SEK invoice into the unranked tier). The new row indicators are deliberately language-neutral, the ISO code on a deviating currency plus `≈ {formatCurrency(sek)}`, so no i18n keys were added: SupplierInvoicePicker has no useTranslations at all and one t() call amid six hardcoded Swedish strings would render half-localized, while converting the whole file was out of scope. Not fixed because it is not the same bug: components/inbox/TransactionMatchPicker.tsx already scores with receiptCurrency/receiptSek and MatchAllocationDialog sorts by selection then due date, so neither ranks currency-blind. + +[2026-07-26] The pre-auth invite cookie (`gnubok-invite-token`, written by app/invite/[token]/page.tsx) now lives 7 days instead of 3600 seconds, matching the invite TTL rather than guessing one: `INVITE_TTL_DAYS = 7` in lib/auth/invite-tokens.ts stamps `company_invitations.expires_at`, prod confirms it (77 invitations, min and modal `expires_at - created_at` = 7 days, no DB default on the column so app code is the only writer), and the max of 63 days is just re-invites refreshing expires_at while created_at stays put. The one-hour cookie could not survive the hop it exists for: an invitee who registers at 17:00 confirms the signup mail the next morning, by which point the cookie, the only copy the browser holds, was six hours dead and acceptance was impossible with no recovery short of the inviter re-sending. Widening a non-httpOnly cookie is a security decision, so it was argued against OWASP ASVS v5.0.0 rather than assumed: the caps that look relevant do not govern (V6.4's 60-minute ceiling is for account-recovery tokens that BYPASS authentication, V9.4's 15 minutes is for access tokens that carry authority on their own, and this token does neither, since POST /api/team/accept re-authorizes every attempt with `requireAuth()` plus an email equality check and the `status` transition is single-use). What does govern is V7.3's rule that the client-side lifetime is compared against the documented server-side bound, whose failure pattern is a client lifetime EXCEEDING it; 7 days against 7 days is equality, and the test asserts `<= inviteTtlSeconds()` so over-widening fails too. The old 3600 bought nothing anyway: by V7.4.1's logic it was client-side expiry over a server-side record that stayed `pending` for the full week, so it shortened the invitee's window without shortening the attacker's. Chose widening over the alternatives (keeping it short and leaning on the `?invite=` query parameter, which only survives the register hop and not the mail round-trip, or on the `/onboarding` + `/select-company` server-side healing path, which reads the very cookie that had expired) because those signpost the cookie-less class rather than removing it. The value is one constant feeding one `buildInviteCookie()` builder, replacing three duplicated literals, and the flags are untouched (`path=/`, `samesite=lax`, `secure` only on https, still not httpOnly since it is written from `document.cookie`). Shared-device exposure is bounded by the same email equality check: a later user on the same browser cannot redeem the surviving token, they get 403 `wrong_email`, which the consume side deliberately retains rather than clears; residual and accepted is that they learn an invitation exists for the other address, which was equally true at one hour. Tests read the TTL back out of `getInviteExpiry()` instead of restating 604800, so raising INVITE_TTL_DAYS without moving the cookie fails CI. + +[2026-07-26] Replaced the credit-note COUNT cap with an AMOUNT cap. 20260715120000's uq_invoices_company_credited_invoice (UNIQUE on company_id, credited_invoice_id) permitted at most one kreditfaktura per invoice ever, which forbids the partial andringsfaktura that ML (2023:200) 17 kap 22-23 SS explicitly allows ("Partial credits: Fully permitted", swedish-invoice-compliance references/invoice-rules.md:77-79) and that nothing in ML caps in number. The concern the index encoded is real but different: two credit notes that each mirror the full original would post two reversing verifikat and double-reverse revenue plus utgaende moms, since createCreditNoteJournalEntry derives its lines from the credit note's OWN items. Migration 20260726130000 therefore enforces the actual rule, sum(ABS(total)) over all non-cancelled credit notes for one original <= ABS(original.total), which still refuses two full mirrors while allowing several partials that fit. Chose a trigger over an index or CHECK because the invariant is a cross-row aggregate: an index cannot express it and a CHECK may not read other rows, so a trigger is the honest option rather than a workaround. The trigger takes FOR UPDATE on the original invoice before summing (without it two concurrent inserts under READ COMMITTED both read the pre-insert sum and both pass) and skips that locking read entirely for updates that cannot move the sum (journal_entry_id link, creation_complete flip, draft->sent), so ordinary credit-note traffic is not serialized. Cancelled credit notes are excluded from the sum, which is what keeps route.ts's "reopen a cancelled unissued draft" path working. A currency-equality assert rides along because an amount cap across two currencies is meaningless; both creation paths already copy the original's currency, so it pins existing behaviour rather than adding a restriction. Deliberately NOT built: the app cannot create a partial credit note today (CreateCreditNoteSchema is {credited_invoice_id, reason} only, createCreditNote mirrors -Math.abs of every original total, and the v1 endpoint documents "To credit only part of an invoice, credit the full invoice first then reissue"), so this is a constraint correction plus headroom, not a partial-credit feature. Two latent blockers for whoever builds that feature: app/api/invoices/route.ts uses .maybeSingle() on the existing-credit-note lookup (throws once two credit notes share an original) and its 23505 race-recovery branch no longer fires now that the unique index is gone. The status guard in app/api/invoices/route.ts was briefly widened to accept status='partially_paid' and then REVERTED in the same pass, along with the INVOICE_CREDIT_NOT_SENT wording that had been changed to promise "delvis betalda ... kan krediteras". The premise is right (whether the customer paid nothing, part or all of an issued invoice has no bearing on the right to issue an andringsfaktura, and part-paid is where a credit note is most often needed) but that door is not where the flow ends. lib/invoices/issue-credit-note.ts ~:253 flips the original to 'credited' with .in('status', ['sent','paid','overdue']), and it runs AFTER createCreditNoteJournalEntry has posted the reversing verifikat: on a part-paid original the voucher is committed, the status flip matches zero rows, and mark-sent/send return INVOICE_CREDIT_REPAIR_REQUIRED with a fully credited invoice left at 'partially_paid', open in the AR ledger and still chased by the reminder processor. An immutable voucher in a half-state is worse than the clean 400 it replaced, and the widened error text promised a capability no surface delivered (app/(dashboard)/invoices/[id]/credit/page.tsx ~:76 pre-gates on the same three statuses, so the dashboard never reached the widened route guard at all; only a hand-rolled authenticated POST did). Lifting the gap is one coordinated change over six sites, issue-credit-note.ts first, then the dashboard credit page, app/api/v1/companies/[companyId]/invoices/[id]/credit/route.ts ~:211 (plus its OpenAPI description), lib/pending-operations/commit.ts ~:3104, extensions/general/mcp-server/server.ts ~:12380 and its kreditfaktura-process skill text. A route test now pins the refusal and names the reason, so lifting it in one place fails loudly instead of passing. +[2026-07-26] Three plus-minus DUPLICATE_AMOUNT_TOLERANCE_PCT bands were built in one currency and applied, in SQL, to a column denominated in another: findDuplicatePaymentCandidatesForInvoice and POST /api/supplier-invoices/[id]/mark-paid banded the invoice-currency payment against transactions.amount, and POST /api/transactions/[id]/categorize banded transactions.amount against supplier_invoices.remaining_amount / invoices.remaining_amount. At roughly 11,50 SEK/EUR a 2 % band is off by a factor of eleven, so each guard either selected nothing (a second verifikat for one affarshandelse then posts unopposed, BFL 5 kap 1-2 paragraf) or selected an unrelated row of the wrong magnitude. All three now route through the new lib/invoices/duplicate-guard-currency.ts: planAmountSweeps issues ONE SQL sweep per currency (each sweep restricts rows to a single currency via .or('currency.is.null,currency.eq.SEK') / .or('currency.eq.XXX') and carries the band expressed in that same currency) and magnitudesWithinTolerance re-checks every returned row in a shared unit. Chosen over the alternatives: banding on the SEK column instead was rejected because transactions.amount_sek and a SEK-derived predicate on invoices would need a second column in the same range filter and would drop legacy rows where amount_sek is null (4 of 695 foreign transactions in prod); widening the band by the max plausible rate was rejected because a band wide enough for JPY matches everything. Same-currency compares raw, cross-currency compares only STORED conversions (amount_sek / total_sek pro-rated to the remaining amount / exchange_rate), and no stored conversion means the candidate is EXCLUDED, never compared as a raw number: guessing the unit is the bug. That exclusion is the common case for foreign customer invoices in prod (257 of 285 have total_sek null-or-zero and no exchange_rate), so all three sites log it as a warn rather than let an unevaluated candidate set pass as a clean "no duplicate" (BFNAR 2013:2 kap 8); the customer-side lookup got a module-level logger for that purpose since it takes no ctx logger. A SEK reference yields exactly one sweep whose band values are unchanged, and prod carries zero non-uppercase and zero NULL currency codes in transactions / invoices / supplier_invoices, so a SEK-only company runs the identical single query. Band arithmetic moved from the naive Math.round(x * 100) / 100 to roundOre() per CLAUDE.md guard rail 9. Verified against HEAD: the currency tests fail there at all three sites. The voucher-gap-explanation refactor that shares app/api/transactions/[id]/categorize/route.ts (cancelOrphanedPaymentEntry) is a separate agent's change and was left untouched. + +[2026-07-26] Split "the VAT rate the picker defaults to" from "the VAT rate an invoice line may lawfully carry". lib/invoices/vat-rules.ts getAvailableVatRates() returned a single locked 0% option for a VAT-validated eu_business and for non_eu_business, and six server-side gates plus the invoice form treated that one option as the only permitted rate. The 0% default is right: huvudregeln (ML 6 kap. 34 SS, Article 44 VAT Directive) taxes a B2B service where the buyer is established, so a Swedish consultant invoicing a German or a US company charges no Swedish VAT and reverse-charges instead. The refusal was not right: the swedish-vat reference lists five exceptions "(taxed where performed)" that carry Swedish VAT no matter where the buyer sits, being fastighetstjanster (property location), persontransporter (where transport occurs), korttidsuthyrning of transport vehicles (pickup location), restaurang/catering (where performed) and admission to cultural/sports events (event location). A Stockholm hotel invoicing a German company (12%) or a conference organiser selling a ticket to a US company (6%) was refused outright with INVOICE_CREATE_VAT_RULE_VIOLATION and had no workaround. Added getPermittedVatRates(), which appends 25/12/6 after the customer's 0% option for those two customer types, and pointed the build-invoice-write.ts gate at it. Chose widening the PERMITTED set over widening getAvailableVatRates() because the invoice form derives three behaviours from that array being length 1 (picker disabled, new-line default availableRates[0].rate, article-rate adoption suppressed) plus a snap-all-lines-to-0% effect on customer change; widening it in place would have silently left a stale 25% line at 25% after switching to an EU customer, which books the opposite error. Also rejected adding an explicit per-line "taxed where performed" flag: nothing on invoice_items distinguishes consulting for a German company from a hotel night sold to one, so a flag is the honest long-term model, but it needs a migration plus schema, form, MCP and v1 surfaces and cannot be set anywhere today. Because no single non-zero rate covers the exceptions (they span 25%, 12% and 6%) nothing narrower than the full Swedish set would do. The default is untouched on purpose: getVatRules().rate stays 0 and is still the fallback for a line that omits vat_rate, and the 0% option stays first in the permitted array, so a Swedish rate reaches such an invoice only when set explicitly on that line. Reverse-charge notation now follows the lines rather than the customer: when a special-treatment customer's invoice has no zero-rated line at all, the header takes the domestic treatment/ruta and drops reverse_charge_text, because "Omvand betalningsskyldighet" (ML 17 kap 24 SS p.11) printed next to charged Swedish VAT tells the buyer to self-assess tax the seller already collected and blocks their deduction. Mixed invoices keep the notation, since their zero-rated lines genuinely are reverse-charged and generatePerRateLines already applies the invoice-level treatment only to rate-0 lines, so 3308 and 3002/2621 land in the right ruta on their own. Text-only documents count as zero-rated so their header is not restamped. Not fixed here (same invented rule, other owners): lib/pending-operations/commit.ts:1063, extensions/general/mcp-server/server.ts:4475, app/api/v1/companies/[companyId]/invoices/bulk-create/route.ts:185, lib/invoices/recurring-schedule-service.ts:199, lib/invoices/self-billed-sale.ts:123, and components/invoices/InvoiceEditor.tsx:718/790/792 where the picker must render getPermittedVatRates() while keeping getAvailableVatRates() for the default and the snap. +[2026-07-26] lib/api/validate.ts validateBody() now builds the top-level `error` from the first three Zod issues ("Valideringsfel: : . ... (+N till)") instead of the constant 'Validation failed'. `errors[]` already carried the actionable detail and lib/errors/get-error-message.ts:223-234 already knows that shape, but the many clients that forward only `body.error` collapsed it: a raw English "Validation failed" in a Swedish payroll UI (components/salary/SalaryOverridePanel.tsx:78) or, once through getErrorMessage, the generic "Nagot gick fel. Forsok igen." fallback, because the constant matches no pattern the function knows. The 'Valideringsfel' lead-in is load-bearing, not decoration: it is what makes isSwedishUserMessage() recognize the sentence as an already-Swedish user message and pass it through verbatim, so no per-call-site change was needed anywhere. Fixed centrally in validate.ts rather than in get-error-message.ts (adding a 'Validation failed' -> Swedish entry to ERROR_PATTERN_MAP would have produced one generic sentence for every field error, losing the field name, and would not help the sites that render data.error raw without going through getErrorMessage at all) and rather than at the three reported pages (the same collapse exists at 8+ call sites). The machine-readable contract is untouched: `type: 'validation_error'` stays the discriminator and `errors[].code` stays the per-field code, so components/settings/SettingsFormWrapper.tsx:73 and every other consumer branches on structure, never on prose; a repo-wide search found zero consumers comparing against the literal string, only producers emitting it. The v1 REST envelope is a separate shape (`{ error: { code, message } }` via v1ErrorResponseFromCode) and no route under app/api/v1/** calls validateBody, so public API clients see no change. Deliberately left English and out of scope, same bug class, different constants: validateQuery()'s 'Invalid query parameters', validateBody()'s 'Invalid JSON in request body', and the nine hand-rolled `error: 'Validation failed'` responses that bypass the helper (app/api/agent/composer/route.ts:47, app/api/agent/profile/verify/route.ts:25, app/api/invoices/route.ts:73/88, app/api/invoices/recurring/route.ts:50, app/api/invoices/recurring/[id]/route.ts:57, app/api/invoices/self-billed/route.ts:97, app/api/onboarding/state/route.ts:74, app/api/tax-assessment-notices/[id]/route.ts:65). The summary is Swedish-only in both locales while the Zod field messages behind it are still mostly English (lib/api/schemas.ts), so an English-locale user now sees one Swedish word plus English field detail instead of a fully English but contentless sentence: accepted as strictly more information, and consistent with .claude/rules/i18n.md keeping regulatory error text Swedish in both locales. Translating lib/api/schemas.ts is the real fix and is not owned here. +[2026-07-26] Declined to route gnubok_set_employee_opening_balances through the shared lib/api/sparse-patch.ts helper; kept the inline whitelist merge instead. sparsePatch() parses the caller's body against the full schema and THEN narrows the write set to the keys literally present, which is right for the `UPDATE ... SET col = $1` sinks it was written for but wrong here for two reasons. First, the sink is setOpeningBalancesBulk()'s 9-column upsert shared with the v1 REST and internal UI routes, so "absent" is not expressible at the write: the stored values must be materialized into the item before it reaches the service, which is a merge, not a narrowing. Second, openingBalancesShape's refinement is cross-field (ytd_tax <= ytd_gross, sparade-dagar origin years validated against cutover_date), so parsing a sparse patch on its own sees the .default() zeros and gets both directions wrong: `{ytd_gross: 1000}` against a stored ytd_tax of 48000 passes when the merged row is illegal, and `{ytd_tax: 50000}` against a stored ytd_gross of 210000 is rejected because the default ytd_gross is 0. The tool therefore builds the patch from an explicit MERGEABLE_FIELDS whitelist, layers it over the stored row, and validates the MERGED item, which is what the four new tests in extensions/general/mcp-server/__tests__/payroll-staged-tools.test.ts pin. Empirically reconfirmed on zod 4.4.3 that .partial() does not strip .default() (`z.object({amount: z.number(), is_taxable: z.boolean().default(true)}).partial().parse({amount: 5500})` yields `{amount: 5500, is_taxable: true}`, byte-identical to the non-partial parse), so this is the fourth site of the same trap rather than a new one. Omission versus explicit clear is distinguished by `item[field] !== undefined` over the raw MCP args, before Zod runs: absent means carry the stored value, present-with-0 (or `{}` for vacation_saved_days_by_year) means clear, and the staging preview reports fields_provided/new_rows/updated_rows so the approver can see which columns the caller actually described. cutover_date was deliberately left in the inputSchema's `required` even though every other field became optional-by-merge: it carries no .default() so it cannot be silently zeroed, and forcing the caller to restate which cutover it is amending is worth more than the convenience. The real remaining gap is that no MCP tool reads employee_opening_balances back (gnubok_get_employee omits it, gnubok_get_vacation_balance reads the ledger), so an agent must already know the stored cutover_date; the staged params are the only read-back today. +[2026-07-26] Finished the components/bookkeeping/CreatePeriodDialog.tsx half of the fiscal-period 409 retirement, the follow-up the entry two above left open. The unreachable PERIOD_CREATE_BLOCKED_BY_OPEN_PERIODS branch and its "Las aret och skapa" button are gone together with handleLockAndRetry, so the dialog no longer POSTs to /fiscal-periods/{id}/lock at all and cannot push a user into locking a rakenskapsar before the bokslut. The route's new non-blocking `warnings` array is surfaced as ONE ochre AttnLine (UI-migration convention 6: 12.5px, --attn tone, single line, max one per surface) inside a done-state titled "Rakenskapsar skapat" with the created range under it, never a banner and never a toast: the period WAS created, and a toast fired while the dialog closes flashes past the one sentence that explains why the ingaende balanser are still pending. Reading the warnings off the response lives in the new pure lib/bookkeeping/fiscal-period-warnings.ts (extractFiscalPeriodWarnings + fiscalPeriodAdvisoryText) rather than inline in the component, because the repo has no component tests and lib/ is the scope vitest actually covers; it collapses a missing key, a non-array value and malformed entries to [] so a shape change can never render a blank ochre line, and joins several messages into one sentence run instead of stacking attn lines. onCreated() is deliberately deferred to the close handler on the advisory path: it refetches `periods` in both call sites (JournalEntryForm, FiscalYearsManager), which changes the suggested period and resets the form, which would pull the advisory off screen before it was read. Deleted the orphaned registry entry in lib/errors/structured-errors.ts outright instead of keeping it with a corrected comment: nothing emits the code (a repo-wide grep including ignored files finds only the retirement comment, this log, and one regression test asserting the legacy 409 envelope yields no advisory), StructuredErrorEntry has no deprecation field, and a live entry keeps the code selectable by errorResponseFromCode; a comment in its place records the retired "prior year must at least be locked" theory as wrong so it does not come back. The advisory renders as the route's own Swedish and is not keyed: the route emits no message_en, and bokslut/rakenskapsar domain copy stays Swedish in both locales per .claude/rules/i18n.md. Only the added chrome is keyed (bookkeeping.period_created_title, bookkeeping.period_created_range, common.close, all present in sv+en); the dialog's pre-existing hardcoded Swedish form copy was left untouched as out of scope. + +[2026-07-26] enforce_credit_note_total_within_original() (migration 20260726130000) is NULL-safe at every term rather than only at the sibling SUM, because invoices.status and invoices.currency are both nullable columns with a default and no NOT NULL, and every NULL in this trigger fails in the permissive direction: a sibling that drops out of the sum hands out credit capacity that has already been spent. Concretely: COALESCE(c.status, '') <> 'cancelled' counts a NULL-status sibling (a bare <> is NULL and excludes it); c.id IS DISTINCT FROM NEW.id instead of <> so a NULL NEW.id could not exclude every sibling at once and make the cap unconditionally pass (column defaults fire before BEFORE-INSERT triggers, so NEW.id is never actually NULL: this removes the dependency on that reasoning rather than restating it); COALESCE inside ABS and around SUM so an empty sibling set and an all-NULL total read as 0 instead of NULL. The currency assert was changed from "skip when either side IS NULL" to COALESCE(currency, 'SEK') on both sides: opting out on NULL let a NULL-currency credit note be capped against a EUR original in a unit nobody had agreed on, and 'SEK' is what the column default and every read path already assume. Two comparisons are left deliberately three-valued in the SAFE direction and commented as such: NEW.status = 'cancelled' (a NULL status falls through and IS capped, because treating an unknown status as cancelled would be free capacity), and the sibling sum is NOT scoped by company_id even though the index it replaces was, since credited_invoice_id is a globally unique FK and counting any cross-company row is the conservative side. + +[2026-07-26] LedgerGraph now weights the account ring through the pure helper components/agent-knowledge/ledger-graph-magnitude.ts, which measures the WHOLE ring in one unit (kronor if every account has a usable amount, booking volume otherwise) instead of the old per-group `spend > 0 ? spend : occurrences` fallback whose results were then summed into one denominator: 250 000 kr plus 4 bookings gave the amount-less account a wedge of 0,0016 percent of the circle and ranked it last, so the top-9 cap dropped it from the map entirely. Verified against HEAD: replaying HEAD selection verbatim reproduces both the sliver and the silent drop, and the new tests fail there. Deliberately did NOT reproduce the sibling resolution order from lib/agent/composer/inputs.ts (SEK row short-circuits, else amount_sek, else amount * exchange_rate, else null): the component cannot, because get_ledger_deep_context (20260708130000) already collapses each entity to one scalar as `abs(coalesce(t.amount_sek, t.amount))` and `coalesce(si.total_sek, si.total, 0)` and projects no currency column, so a 500 EUR invoice with no stored rate arrives as the number 500, indistinguishable from 500 kr. Forming a per-currency total on the client would mean inventing one. Refused to paper over it: the raw-foreign fallback stays reachable IN THE RPC and the real fix is a migration projecting total_amount_sek plus per-currency totals plus a count of rows with no SEK equivalent; entityMagnitude() is written as the single place that has to learn about them. Accepted the exposure because this surface is display-only (deep context is read solely by GET /api/agent/knowledge, rendered by AgentKnowledgePanel, never cached, never persisted, and lib/agent/** does not import lib/agent-context at all), so a wrong wedge misleads a human reading "Vad din agent vet" and cannot reach a verifikat or the standing agent instructions. The legend switches to a new key legend_size_volume when the ring is volume-weighted rather than claiming "Storlek = belopp" for a unit nothing is drawn in, and the detail card got its own card_amount label since legend_size no longer names the amount; both keys added to sv.json and en.json. Nothing is dropped from the ring: an account with no usable amount keeps a proportional wedge, which is what the truncation test pins. +[2026-07-26] Handelsbanken + Länsförsäkringar CSV parsers switched from the transaction date (Transaktionsdatum / Datum) to the booking date (Reskontradatum / Bokföringsdag), making them agree with every other parser (SEB, Swedbank, Nordea Företag, Northmill, camt053 BookgDt) and with the PSD2 feed. Why: the emitted date feeds generateExternalId AND the exact-date contentBucketKey, and enable-banking/lib/sync.ts keys every row on the ASPSP booking_date and cannot move (Berlin Group has no stable transaction date; the value_date path caused the June 2026 fleet-wide re-import). A card purchase swiped the 14th and booked the 16th therefore landed in two different dedup buckets and inserted twice, since the plus-minus-one-day drift bridge is shadow-only. Accepted cost, stated not incidental: rows where the two dates DIFFER get a new external_id, so a user who re-uploads an overlapping (not byte-identical) export can get one visible duplicate; rows where the dates are equal (the large majority) keep byte-identical ids and are not re-orphaned. Bounded by the hard (company_id, file_hash) block on the dashboard parse route, by CSV import being a manual action with no cron, and by the rows being user-visible; the v1 REST import has no such hard block. Chose this over adding date tolerance to the dedup bucket (would need the shadow drift bridge promoted to enforcing, which is a separate fleet-validated decision) and over moving the PSD2 side (impossible). Not applied: generic-csv.ts pickDateHeader still ranks transaktionsdatum above reskontradatum for the manual "Annan CSV" mapping, same bug, left to the owning change. + +[2026-07-26] Manual-entry FX metadata (JournalEntryForm) now resolves its carrier line through a pure pre-pass, lib/bookkeeping/fx-line-slot.ts#resolveFxLineSlot, instead of the inline "first line whose account starts with 19, unless a latch already closed" rule. Three defects in one expression: (a) the 19xx prefix is not the rule the generators follow (invoice-entries.ts stamps 1510, supplier-invoice-entries.ts stamps 2440, transaction-entries.ts stamps the settlement leg), so a SEK/EUR växling booked 1930 then 1932 stamped the SEK leg and every consumer that looks the metadata up BY ACCOUNT then read the wrong one: bank-reconciliation ledgerLineAmountIn() reconciles a foreign cash account against amount_in_currency on that account's own lines, and voucher-matching narrows on journal_entry_lines.currency under the 1510/2440 prefix; (b) the latch closed only when the map reached a hydrated FX line, so an agent-created EUR draft with the bank leg first got the entry-level metadata stamped onto a SECOND line and two lines claimed the same foreign amount; (c) with no 19xx leg at all (a EUR supplier invoice booked 4010/2641/2440) the rate was dropped silently, and journal_entries has no currency or exchange_rate column, so it was unrecoverable. The rule adopted is the generators' rule stated explicitly: the monetary leg (ÅRL 4 kap. 13 § fordringar/skulder/kassa, so 15/16/17/19/23/24/25/27/28/29 and deliberately NOT 10-14 lager och anläggningstillgångar, 18 placeringar, 26 moms) whose SEK amount equals foreignAmount × rate, tie-broken by the account's own conventional denomination (1932 for EUR) and never by position. Chose to REFUSE (three new sv+en strings, thrown before the POST) over inventing a journal_entries.currency/exchange_rate column: a migration would be the honest place for an entry-level rate but the metadata is per-line by design and the schema change is not this pass's to make, and refusing loses nothing because the form still holds the values. Accepted cost: an entry whose 19xx leg is only PART of the foreign amount (two partial bank legs) is now refused where it previously stamped the full foreign amount onto a partial SEK leg, which was a mislabel rather than a feature. Falsification-checked rather than assumed: a faithful copy of the pre-fix rule was run against the new test file, 13 of 18 expectations fail against it, and the 5 that pass are labelled in the test as no-regression guards (SEK entry, missing rate, hydrated-line preservation, the SEK-cash-leg common case, the rounding tolerance the old rule "passed" by never comparing amounts). The two order-independence tests were each rewritten to assert BOTH orders in one test, because the reversed order alone also passed the old rule and therefore pinned nothing. + +[2026-07-26] get_ledger_deep_context (migration 20260726150000) stopped counting foreign amounts as kronor, closing the RPC-side half of the LedgerGraph entry above. Both amount expressions fell back to the row own-currency column when no SEK value was recorded: abs(coalesce(t.amount_sek, t.amount)) and coalesce(si.total_sek, si.total, 0). Correct for SEK rows, where amount_sek/total_sek are NULL by design and the invoice-currency column already IS kronor; wrong for a non-SEK row with no stored SEK value, where the same expression yields the raw foreign magnitude, which was then summed into total_amount AND used as the total_amount sort key, so a 500 EUR invoice ranked as 500 kr instead of ~5750 kr and displaced a genuinely smaller counterparty. AP side is the worse one: total_sek is NULL whenever the caller merely omitted exchange_rate, the exact cohort 20260726120000 deliberately left NULL. Adopted the TypeScript rule verbatim (resolveSekAmountOrNull in mapping-engine.ts, resolveTransactionAmountSek in booking-duplicate-detection.ts, the unconverted_fx_count contract in ar-ledger.ts): SEK -> the existing coalesce untouched; non-SEK with a stored SEK value -> use it; non-SEK with a rate on the row -> multiply (deterministic arithmetic, statement 2 of the backfill); non-SEK with neither -> NULL, so sum() skips it. The SEK-vs-foreign discriminator is upper(coalesce(nullif(btrim(currency), empty-string), literal SEK)) = SEK, tested FIRST, which is what makes SEK rows byte-identical rather than merely equivalent. Chose per-entity unconverted_fx_count over a NULL total_amount: the field is typed number in DeepEntity, and a 0 paired with a nonzero count is the ar-ledger reading of a zero total, unknown rather than nothing. Deliberately did NOT exclude the rows from occurrences, cadence_days or the dominant-account pick: which account a booking went to is currency-free and is the part of this payload the agent leans on hardest, so only the money magnitude is withheld. Not yet rendered: DeepEntity in lib/agent-context/ledger-deep.ts does not declare the new field and LedgerGraph.tsx still prints formatCurrency(e.total_amount) unqualified at :268 and :744, so an all-unconvertible entity reads 0 kr with no explanation until that one-line wiring lands; the ring geometry is already honest because ledger-graph-magnitude.ts flips the whole ring to volume basis when any shown account has no usable amount. Migration NOT applied to any database and the pg test is UNRUN (no local Postgres). + +[2026-07-26] employee_benefits validity period: the DB CHECK (valid_to IS NULL OR valid_to >= valid_from) is now mirrored in BOTH CreateEmployeeBenefitSchema and UpdateEmployeeBenefitSchema, and the message lives in one exported constant (BENEFIT_PERIOD_ORDER_MESSAGE) so the schema 400 and the route 400 cannot drift. The bound is inclusive and a NULL/omitted valid_to stays legal, matching run-calculation.ts, which selects benefits with valid_from <= payment_date AND (valid_to IS NULL OR valid_to >= payment_date). For the PATCH the schema refine can only fire when the body carries BOTH dates: a single-date PATCH has nothing in-body to compare against, so rather than leave that hole or reject single-date patches, the route recomputes the merged stored+patched pair against the row it already fetches for the bike-benefit check (one extra column on an existing SELECT, no extra round trip). Chose that over a DB-error-to-message mapping because the constraint is unnamed in the migration and matching on the generated employee_benefits_check name is not a deterministic guarantee. Also split the PATCH error mapping: it collapsed every post-update error into 404 'Förmån hittades inte', so a check_violation on the date range sent users hunting for a record that demonstrably exists (the route had just SELECTed it); now PGRST116 alone means not-found, 23514 means 400, everything else is an honest 500, and the same split was applied to the pre-update existence lookup. POST maps 23514 to 400 too: a CHECK violation is bad input, and 500 told the user to retry an insert that can never succeed. NOT fixed because the file was out of scope: components/salary/EmployeeBenefitsPanel.tsx renders the two date inputs with no min/max linkage and no client check, which is why the bad range was trivially reachable. + +[2026-07-26] resolveSekAmount() in lib/bookkeeping/currency-utils.ts got a strict sibling, resolveSekAmountOrNull(), instead of having its return type changed to number | null (option b) or made to throw (option c). The root defect: the last resort returned the RAW FOREIGN AMOUNT, so no caller could distinguish 1150 SEK from 100 EUR relabelled, and 58 call sites inherited it. Chose the sibling because it CANNOT silently shift behaviour at a call site nobody read: the lenient function is now a one-line wrapper (resolveSekAmountOrNull(...) ?? amount), so its output is byte-identical to before for every input, and a delegation test pins that the two agree on every resolvable case. Option b would have touched ~58 sites in one pass, including roughly twenty that other agents had already fitted with local honest guards this session (SI_FX_RATE_MISSING, MATCH_INVOICE_BOOKING_RATE_MISSING, FX_CLOSING_RATE_UNAVAILABLE), and would have risked double-guarding or un-guarding them; option c would have broken lib/reports/, which legitimately needs the lenient arithmetic for pre-FX legacy rows. The lenient path is now documented READ-ONLY and every remaining lib/reports/ caller was confirmed to establish convertibility BEFORE calling, so for them the fallback is already unreachable. The pinned test at currency-utils.test.ts:34 was NOT inverted: it still asserts the lenient fallback, now relabelled "legacy readers only" and paired in the same test with the strict sibling returning null for the identical input, so the file states the contract of both rather than leaving it ambiguous. + +[2026-07-26] The four duplicated private toSek closures on the sales booking paths (invoice-entries.ts generatePerRateLines + generateRotRutLines, propose-send-lines.ts, propose-payment-lines.ts) now route through resolveSekAmountOrNull() and refuse, via a new INVOICE_FX_RATE_MISSING code registered in structured-errors.ts (sales-side twin of SI_FX_RATE_MISSING). Each closure had its own inline "return amount // fallback for legacy data" branch, and because the 1510/1930 debit on the FX branch is DERIVED from the sum of the credits, every leg was scaled by the same wrong factor: the verifikation balanced, no trigger fired, and a 1 000 EUR sale posted 1 000 kr to 3001 and 250 kr to 2611 instead of 11 500 kr and 2 875 kr at 11,50, understating ruta 05 and ruta 10 (oriktig uppgift, SFL 49 kap 4 §). amountSek is passed as null on purpose: an InvoiceItem has no per-item SEK column, so exchange_rate is the only honest source at item granularity. Deliberate asymmetry in how the refusal surfaces: propose-payment-lines THROWS (PaymentBookingDialog resolves the proposal inside a try/catch and turns it into a translated toast), while propose-send-lines RETURNS [] (proposeSendLines runs inside a useMemo during SendInvoiceDialog's render, where a throw takes out the page; [] is the signal that function already uses for a text-only invoice, and `editable` is SEK-only so an empty proposal cannot disable the submit button). The [] bail is scoped to the item-driven path only: the invoice-level fallback in both propose files reads subtotal_sek / vat_amount_sek and has a genuine second source, so killing it would blank the preview for rows that CAN be expressed in kronor. Deliberately NOT touched, and reported instead: the ~10 invoice-level resolveSekAmount() call sites inside those same four files (invoice-entries.ts:390/402/438/513/653/761/797, propose-payment-lines.ts proposeAccrualLines) all pass a real *_sek second source and changing them would shift the most common FX path (accrual payment of a booked receivable) on a call site this pass did not own. +[2026-07-26] The two voucher-link matchers (lib/invoices/voucher-matching.ts, supplier-voucher-matching.ts) now resolve a ledger line's amount through the shared rule instead of reading debit_amount/credit_amount raw, and ledgerLineAmountIn was PROMOTED out of lib/reconciliation/bank-reconciliation.ts into lib/bookkeeping/ledger-line-amount.ts (which adds ledgerLineSideAmountIn for the one-sided sub-ledger case) with bank-reconciliation.ts re-exporting it unchanged, so the two sides cannot drift; a second implementation was declined for exactly that reason. journal_entry_lines.currency labels the DOCUMENT, so the "currencies match, safe to compare" guard passed on precisely the FX rows it existed to catch. The customer side was worse than a bad score: the amount band is pushed into SQL, so a foreign band bounded the always-SEK credit_amount and the one correct voucher was excluded server-side BEFORE scoring, leaving a same-magnitude decoy as the only candidate (reproduced against HEAD: the assertion fails with "expected [ 'je-decoy' ] to include 'je-correct'"). On a foreign invoice the band therefore moves onto amount_in_currency as |amount| <= ceil (some rows store the foreign figure negatively; direction comes from the debit/credit side) plus an equality filter on the currency label; NULL amount_in_currency drops out of both comparisons by SQL NULL semantics, which is correct because such a row carries no rate. The label guards in scoreCandidate/validate* were KEPT unchanged in shape but re-documented as counterparty discriminators rather than unit checks. Both validators fail CLOSED (CURRENCY_MISMATCH) on a matched-side line that carries no figure in the invoice's currency, rather than summing only the readable lines and understating a voucher that settles more than can be seen. invoice.currency is normalised through a new documentCurrency() because the column is `text default 'SEK'` and therefore NULLABLE while the TS type is a non-null union: a NULL would test `!== 'SEK'` and send a plain domestic invoice down the FX path where nothing converts. Confirmed the fix is NOT inert: both matchers read journal_entry_lines directly (a PostgREST embed on the customer side, fetchEntryLines on the supplier side) and both column lists were extended, so unlike the bank path they do not go through get_unlinked_gl_lines / get_account_gl_lines_for_matching, which project neither FX column. bulk-reconcile-supplier-vouchers.ts needed comments only: remainingOf() and ap_debit_amount are now both in the invoice's currency. A TypeScript-only fix is NOT sufficient: link_invoice_to_voucher and link_supplier_invoice_to_voucher are the authoritative commit path (the web routes and bulk reconcile call them with no TS pre-validation) and both summed the SEK column against a remainder in the invoice's currency, so the correct EUR voucher was rejected as AMOUNT_EXCEEDS_REMAINING and LEAST() wrote a payment_amount in the wrong unit; migration 20260726140000 rewrites only the foreign branch of both, leaving the SEK branch verbatim so SEK-only companies are byte-identical in SQL as well as in TS. Migration NOT applied to any database and its pg test (lib/invoices/__tests__/link-voucher-currency.pg.test.ts) is UNRUN: no local Postgres, and applying it to staging would contradict the instruction to leave it unapplied. + +[2026-07-26] Removed the "eu_business suppliers must have a vat_number" rule from lib/pending-operations/schemas/create-supplier.ts instead of relocating it to the point reverse charge is applied. The rule cited ML 17 kap 24 §, which lists what a SELLER must put on an invoice it issues (p.3 the seller VAT id, p.4 the buyer VAT id when reverse charge or intra-EU applies); it says nothing about what a BUYER key supplier register must hold, so it cannot make a register field mandatory. Checked whether the requirement had a real basis under a wrong label: it does not. A Swedish buyer self-assessing omvand betalningsskyldighet on an EU purchase reports the purchase in ruta 21 and the output VAT in ruta 30-32, then deducts it in ruta 48, on the strength of being a taxable person established in Sweden (ML 16 kap 6 § with 6 kap 34 §); the VIES-validated counterparty VAT number is a condition for zero-rating an intra-community SUPPLY, i.e. the seller side, which is why only customers carries vat_number_validated / vat_number_validated_at and suppliers never has, and why lib/reports/periodisk-sammanstallning.ts reads customers only. Nothing downstream consumes supplier.vat_number: lib/bookkeeping/supplier-invoice-entries.ts and generateReverseChargeBasisLines() select 4535/4531/4425 from supplier_type plus invoice.reverse_charge alone, suppliers.vat_number is a nullable text column, and both other create paths (components/suppliers/SupplierForm.tsx and CreateSupplierSchema behind /api/suppliers and the v1 bulk-create) already treat it as optional, so the same EU company could be created through the dashboard and refused through the staged/agent path. An EU supplier below its national registration threshold has no VAT number at all. Also corrected two attribution errors in the same file header: Peppol SE-R-008/009 mandate a 7-8 digit Bankgiro length, not the modulus-10 check digit (that is Bankgirot own spec; SE-R-013 is the Luhn rule, and it is for organisationsnummer), and SE-R-001 only fixes Swedish VAT ids at 14 chars rather than supplying the per-country patterns, which come from lib/vat/vies-client.ts and happen to agree for SE. Left the file "ASVS V4.5" input-validation reference alone despite verifying it is wrong (ASVS v5.0.0 puts centralized validation at V2.2 and uses V4 for API and Web Service; 4.0.3 has no V4.5 either): it is a 12-site repo-wide convention and a one-file correction would only make the set inconsistent. + +[2026-07-26] PATCH /api/salary/employees/[id] no longer spreads `personnummer` into its UPDATE payload, and the four internal salary read surfaces (employees list, employee detail GET+PATCH, employee POST, salary run GET, salary run employee GET) now return the mask as `personnummer_masked` with the encrypted column dropped from the payload entirely. Two halves of one hazard. Write half: `{ ...body }` carried personnummer verbatim while the encrypt branch was gated on the truthy `if (body.personnummer)`, so an empty string skipped the encrypt but was already in the payload; the UPDATE would have replaced the AES-256-GCM ciphertext with '' and left personnummer_last4 stale, then 500'd on the response decrypt AFTER the write committed. The only thing preventing that was the /^\d{12}$/ regex in lib/api/schemas.ts, i.e. a guard in another file, and house style there adds `.or(z.literal(''))` to optional string fields (clearing_number, account_number, bankgiro, tax_contact_email), so one routine edit would have destroyed encrypted PII. The guard now lives in the write path: absent/undefined means identity unchanged, '' and null are 400 (the column is NOT NULL and Skatteverket FK215 filing needs the value; omit the key to leave it alone), a masked value is 400 (validatePersonnummer strips non-digits, so a decorated 12-digit value would otherwise sail through), 12 digits validate and re-encrypt. Read half: these salary routes were the ONLY surface returning a masked value under the writable key name, the mirror that lets a read-modify-write client post the mask into the encrypt path; v1 (EmployeeWriteResponse), the MCP tools and lib/salary/employee-commands.ts already use the _masked suffix. Kept the personnummer-update capability rather than rejecting it outright like v1 and MCP do (identity immutable post-create): this route is the only place a mistyped personnummer can be corrected, and removing that silently would be a behaviour change nobody asked for. New exported type EmployeeMasked (Omit & { personnummer_masked: string }) so the six client consumers are typed against what the API actually returns instead of a field that no longer exists. NOT changed: extensions/general/mcp-server/server.ts:9578 and :9609, two MCP RESOURCE readers (not tools) that still mask under the writable `personnummer` key; that file was out of scope this pass. + +[2026-07-26] The two arsredovisning persistence services (lib/bokslut/arsredovisning/profile-service.ts, narrative-service.ts) now rethrow the PostgrestError untouched (`if (error) throw error`) instead of re-wrapping it as `new Error("Failed to save ...: " + error.message)`. The re-wrap kept only `.message` and dropped `.code`, `.details` and `.hint`, so errorResponse()'s isPostgresError() branch never fired and every constraint failure on those two tables became INTERNAL_ERROR / 500. Reachable cases, both real caller mistakes: 23514 on annual_report_profiles_parent_consistency (PATCHing parent_group_size or prepares_consolidated_accounts onto a row already stored with is_parent_company = false, since the upsert writes only the columns present in the payload) and 23514 on arsredovisning_narratives_agm_decision_consistency (the route's Zod superRefine only catches it when outcome and decision arrive in the SAME body; a partial save leaves the merged row inconsistent). Chose plain rethrow over wrapping in a typed error that carries `code` because three services already do exactly this and document it (lib/articles/validate-revenue-account.ts, lib/bookkeeping/account-validation.ts, lib/invoices/issue-credit-note.ts): a second convention for the same job is the thing that lets code drift. The lost "which operation" prose is recovered from the bound `operation` on the log line plus PostgREST's own message, which names the relation and the constraint. `!data` was split out and left as a plain Error so the belt-and-braces branch still maps to 500 rather than borrowing a 400. Verified no over-correction: postgresCodeToStructured() maps only 23502/23503/23505/23514/22P02/22003/42501/42P01/40001/40P01, so an unmapped SQLSTATE (XX000 internal_error) and a non-Postgres throw both still produce 500. Also verified no data leak: errorResponse puts only pgCode in the envelope, and redact() reduces an Error to { name, message, stack, code }, so PostgREST's `details` ("Failing row contains ...") reaches neither the client nor the log. DECLINED to build a constraint-name-to-message table for the 163 named CHECKs, on the same reasoning as the employee_benefits entry above: the two constraints here are explicitly named, but the useful ones elsewhere are anonymous column-level CHECKs whose generated names are not a deterministic guarantee, and both constraints reachable here are already mirrored in Zod for the full-payload case, so the DB CHECK is the partial-update backstop rather than the primary user-facing validator. + +[2026-07-26] app/(auth)/reset-password/page.tsx now consumes the pre-auth invite cookie, closing the last session-establishing path that never did. It calls the shared consumeInviteCookie() (lib/auth/consume-invite-cookie.ts) through a small co-located module, app/(auth)/reset-password/invite-handoff.ts, rather than inlining an eighth copy of the accept-and-clear logic. Three non-obvious calls. (1) The call site is AFTER POST /api/account/password returns 2xx, not at mode === 'set-password' where the recovery session already exists: that response comes out of requireAuth(), so its success is proof of a server-validated session, and waiting means no membership is ever created off a half-finished credential reset (ASVS v5.0.0 V6.4 account recovery must not weaken the factor, V7.5 sensitive operations need recent authentication). (2) The handoff DEFERS instead of attempting when the session still owes an MFA step-up, mirroring the middleware predicate exactly (shouldEnforceMfa plus aal1-with-aal2-required). POST /api/team/accept is not in apiPathSkipsMfaGate, and a recovery session is AAL1, so on hosted the exact user shape the finding is about (a consultant who already has a company, and whom middleware therefore forced to enroll TOTP) would have received a bare 403 from the middleware, which classifyInviteAcceptStatus reads as 'wrong_email': a legitimate invitee told their invitation belongs to someone else. Deferring leaves the cookie untouched and the same predicate bounces them to /mfa/verify, which consumes it after the second factor. BankID-linked and self-hosted users are deliberately never deferred, because nothing would bounce them and the token would sit unused. (3) A token that survives a transient failure routes to /select-company, not '/': it re-runs acceptPendingInviteByToken() server-side on every load and, unlike /onboarding, is reachable for a user who already has a company, which is precisely why the existing safety nets did not cover this shape. 'wrong_email' and 'spent' deliberately do NOT take that detour (the email equality check is deterministic for the same account, and a spent token is gone), so they just toast and keep the normal navigation. The flow lives in a sibling module rather than page.tsx because a Next.js page file may not export anything but the component and the route segment config (tsconfig includes .next/types), which would have left it untestable in a repo with no component harness; app/(dashboard)/request-context.ts and app/api/sandbox/seed/customers.ts are the existing precedent. No new i18n keys: the toast reuses INVITE_PROBLEM_MESSAGE_KEYS so all five surfaces word the same failure identically. + +[2026-07-26] The three staged-operation FX writers in lib/pending-operations/commit.ts (create_transaction, create_invoice, create_supplier_invoice_from_inbox) now REFUSE at commit rather than persisting a foreign-currency row with a NULL rate, and all three anchor the Riksbanken fetch on the row's own date with the supabase client passed so exchange_rates is the read-through cache (the lib/transactions/ingest.ts call shape). ingest.ts is allowed to store NULL because it is a bulk bank feed where one unreachable rate must not abort the batch and its rows stay repairable via /api/transactions/[id]/refresh-exchange-rate; a staged operation is a single row with the approver present, and the categorization path does NOT refuse a rateless row (it books through the lenient resolveSekAmount, so a 1500 USD transaction debited 1500 kr while buildCurrencyMetadata stamped the same line currency USD / amount_in_currency 1500). Commit is therefore the last boundary where the contradiction is still visible to a human, so it is where the refusal belongs. create_supplier_invoice_from_inbox reuses resolveSupplierInvoiceExchangeRate + supplierInvoiceSekAmounts rather than a fourth private copy, and resolves the rate BEFORE get_next_arrival_number so a refusal never burns an ankomstnummer; create_transaction likewise resolves before ensureManualCashAccount. A caller-supplied positive rate is still trusted verbatim on the supplier-invoice path, which is what makes the number the approver saw in the staged preview the number written. Both invoice paths also stopped gating the *_sek columns on `exchangeRate ? ... : null`: a SEK invoice resolves to rate 1 so total_sek === total, because that guard left total_sek NULL on every ordinary Swedish invoice. lib/invoices/build-invoice-write.ts (the web/REST customer-invoice path) still leaves the SEK columns NULL for SEK invoices and on a failed fetch: not changed here because it is outside this fix's scope, so the two customer-invoice writers are knowingly divergent until it is aligned. No new structured-error code was added for the transaction refusal (SI_FX_RATE_MISSING and INVOICE_FX_RATE_MISSING already cover the two invoice cases): a literal Swedish message matching the register's wording keeps the diff inside the one file this fix owns. + +[2026-07-26] generic-csv.ts pickDateHeader (the auto-guess that seeds the manual "Annan CSV" column-mapping UI) re-ordered from transaktionsdatum-first to booking-date-first, closing the third site of the CSV date finding logged above. Tiers are now bokföringsdag/bokföringsdatum/bokfdag, then reskontradatum, then a bare datum/date, then transaktionsdatum/transdag; every tier still skips headers containing 'valuta' so Valutadag and a plain Valuta column can never be picked. transaktionsdatum was DEMOTED, not removed: a file carrying only a transaction date must still map without user input, which is now pinned by a test. Two non-obvious calls. (1) A bare 'datum' was placed ABOVE transaktionsdatum rather than below: in every single-date export in this directory (Nordea, Skandia, Nordea Business format D) the bare Datum column is the posting date the Saldo ties to, while transaktionsdatum is explicitly labelled the swipe date, and keeping bare datum high also leaves behavior byte-identical for the large majority of files that carry exactly one date column. Files carrying both plus a real booking column resolve at tier 1 or 2 before either is reached. (2) The booking regex was widened from /bokf(ö|o)ringsda(g|tum)/ to /bokf((ö|o)rings)?da(g|tum)/ so Swedbank's abbreviated Bokfdag matches by LABEL. Swedbank is the decisive precedent (Transdag is present in the file and formats/swedbank.ts deliberately picks Bokfdag), and at HEAD a Swedbank file forced through this path only landed on Bokfdag by accident: no tier matched, so the value-based fallback took the leftmost date-shaped column, which an exactly-8-digit Kontonr would have stolen (SUGGEST_DATE_PATTERNS accepts /^\d{8}$/). Severity is deliberately stated as a bad DEFAULT, not a correctness gate: suggestColumnMapping is reachable only from components/import/BankFileColumnMappingStep.tsx, the "Datum *" dropdown lists every column with its own header label, and the live preview renders the chosen date column, so the user can see and change the guess before confirming. The v1 REST import cannot reach it at all (parseBankFile with format generic_csv uses genericCSVFormat.parse's fixed date-0/description-1/amount-2 mapping). external_id consequence is the same bounded one as the parsers: the date is an input to generateExternalId's composite, so only rows where the two dates DIFFER get a new id; the (company_id, file_hash) block at app/api/import/bank-file/parse/route.ts:49 stops a byte-identical re-upload from reaching the parser, but it does not cover an overlapping non-identical export, and the v1 route has no such block (it upserts on the same key and parses regardless). NOT fixed, reported instead: parseGenericCSV hardcodes currency 'SEK' and GenericCSVColumnMapping has no currency field at all, so a foreign-currency row imported through "Annan CSV" is booked as SEK where nordea-business.ts honors its Valuta column and wise.ts refuses a row with no currency; fixing that needs a type, mapping and UI change, which is a different class from the date tier list this pass owns. + +[2026-07-26] The three payment-file handlers in components/salary/PaymentFilePanel.tsx and components/salary/TaxPaymentPanel.tsx (LB / pain.001 salary payment file, skattekonto payment file, mark-as-paid) had try/finally with no catch, so a fetch that threw escaped as an unhandled rejection and the user was told nothing at all on the one screen where "nothing happened" means wages or the skattekonto payment do not move. All three now report exactly one toast per outcome. Four non-obvious calls. (1) The two download handlers adopt downloadFile() unchanged at its default 15s deadline (these routes are 3 to 4 indexed queries plus in-memory string generation, so the deadline is many times the realistic cold-start-plus-one-eu-north-1-round-trip worst case), but mark-paid is a POST and downloadFile exists to save a blob, so it got a sibling, lib/browser/post-action.ts, with the same discriminated union and the same bounding. POST_ACTION_TIMEOUT_MS is deliberately not shorter than DOWNLOAD_TIMEOUT_MS: aborting a mutation early reports failure for a write that may have landed, and that ambiguity is worse than a few extra seconds of spinner. postAction does not parse a success body, following download-file.ts's rejection of body-shape validation as coupling the client to the route's payload. (2) The timeout and network copy is supplied by the call site through lib/browser/action-failure.ts failureDescription(), while a 'server' failure keeps the route's own sentence: the routes name the exact missing field ("Bankgironummer saknas i företagsinställningar"), which beats any panel copy, whereas getErrorMessage has only the generic "Något gick fel" for a fetch that produced no response, and a user cannot act on that. mark-paid's timeout copy says to reload and check rather than claiming the change failed, because on a mutation a timeout genuinely does not know. (3) Moving into downloadFile/postAction drops the old context: 'salary' argument, since neither helper takes one. This changes nothing for any status in HTTP_STATUS_MAP (400/401/403/404/409/422/429/500/502/503 all answered before the context fallback was ever consulted) and only swaps "Kunde inte hantera löneuppgifterna" for "Något gick fel" on statuses outside that map, e.g. a raw 504; both are generic and the toast title is more specific than either. The three *_failed_fallback keys are now unreferenced but were left in place: this pass owned only the keys it added. (4) NOT fixed, reported instead: nothing in the codebase stops a second payment file being generated for the same salary run. Both salary routes accept status in (approved, paid, booked) and only overwrite payment_file_format / payment_file_generated_at, the tax route does not consult tax_paid_at, and the LB format carries no batch identifier at all (TK 11 has sender bankgiro plus a YYMMDD creation date, so two files made the same day are byte-identical), so cross-format bg_lb-plus-pain001 duplicates for one run share no id any dedup could correlate. pain.001 is the partial exception: MsgId is ACCOUNTED-{orgdigits}-{YYYY-MM} and every InstrId/EndToEndId derives from it, so a regenerated file repeats them, which is the key a bank duplicate-file check uses, but that control is bank-side and not ours to rely on. Inside these panels the fix is the click-race guard (if (downloading) return, plus the existing disabled state) and the catch itself, since a silent failure is what invites the human-speed retry that produces the second file; a real regeneration guard is a server change to routes this pass did not own. + +[2026-07-26] lib/api/sparse-patch.ts sparsePatchBody forwards zod issues by SPREAD (`ctx.addIssue({ ...issue })`) rather than through a double cast, and declares its return as `z.ZodType>, unknown>` rather than casting the pipe. Both casts turned out to be avoidable on zod 4.4.3. `addIssue` is typed against `$ZodSuperRefineIssue`, whose members flatten to `{ [k: string]: unknown } & `; a finalized `$ZodIssue` is declared as an interface, so it gets no implicit index signature and is rejected, but a spread produces an object-literal type, which does get one, so the same value passes with no cast. Verified that the forwarded issues are byte-identical to what a plain `validateBody(request, schema)` produces (same `code`, same `path`, same `expected`, same `message`, all issues, not just the first), pinned by three tests in lib/api/__tests__/sparse-patch.test.ts including a direct equality assertion against the bare-schema 400 body: flattening to `code: 'custom'` would have been a silent API break for clients that branch on it. The return-type change works because ZodType's Input parameter is declared `out`, so the pipe's `unknown` input does not have to be laundered to satisfy `validateBody`'s `z.ZodType`. Also made the contract total by throwing a TypeError when the schema's output is not an object, instead of letting `Object.entries(null)` surface as an opaque "Cannot convert undefined or null to object" 500 from a misuse like `sparsePatchBody(Schema.transform(() => null))`. DECLINED to add a check:guards rule for this antipattern. A syntactic rule can only see "validateBody called with an Update*-shaped schema" (19 sites), which cannot distinguish the three cases that need three DIFFERENT fixes: narrow-after-parse is right for a `SET col = $1` sink, wrong for a fixed-column upsert (which needs a stored-row merge, as app/api/kpi/preferences/route.ts now does), and wrong again for a cross-field refinement (which needs a defaults-stripped patch base, as EmployeeSchemaPatchBase does). A ratchet that pushed all 19 toward sparsePatchBody would therefore manufacture bugs in the second and third class, which is worse than noise; the trap is pinned by an executable test of the zod behavior itself instead. + +[2026-07-26] components/extensions/general/InvoiceInboxWorkspace.tsx: the inbox-address copy button now awaits the clipboard write through a new copyInboxAddress() (components/extensions/general/inbox-address-copy.ts, wrapping lib/browser/copy-to-clipboard.ts) and shows an icon-swap plus one ochre AttnLine, and the "Adress kopierad" toast was REMOVED rather than made conditional. Two reasons: TOAST_LIMIT is 1 (components/ui/use-toast.tsx ADD_TOAST slices to one), so a warning emitted next to any other toast is evicted and never rendered, and the CopyBlock in components/settings/ApiKeysPanel.tsx already established the inline idle/copied/failed treatment for exactly this problem; a second, toast-shaped idiom for the same event would have to be learned separately. On failure the address drops its `truncate` for `break-all` and keeps `select-all`, because with no clipboard, reading it off the screen is the only way to get it. The document-preview pane grew a four-state DocumentLoadState because "Inget underlag bifogat" was being rendered for rows that DO carry a document_id: both the swallowed catch and the missing !res.ok branch left docUrl null, and so did the entire in-flight window on the happy path. `none` is now the only state allowed to claim nothing is attached; the metadata fetch also moved to fetchWithTimeout at 15s so the new loading state cannot spin forever. Third fix, same class, different surface: a non-404 answer from /inbox/address (500, 503 with no RESEND_INBOUND_DOMAIN, an HTML error page, offline) previously left inboxAddress null and offered "Aktivera inkorgsadress", and handleRotateAddress skips its confirm() precisely when inboxAddress is null, so one click silently retired a live address that suppliers and forwarding rules point at; a 404 still means "none provisioned" and still offers activation, anything else now shows a retry line, suppresses the EmptyPreview activation CTA, and forces the confirm dialog. A failed items list read no longer renders "Inkorgen är tom" or the "ladda upp ditt första underlag" onboarding step. NOT fixed, reported instead: FieldsRail's handleRetry (retry-extraction) has try/finally with no catch, so a throwing fetch clears the spinner and says nothing at all; that is a missing-handler bug rather than a swallow-and-claim, and it belongs with the other salary/payment no-catch handlers fixed the same day. + +[2026-07-26] The invoice editor's per-line Moms picker now RENDERS getPermittedVatRates() while the DEFAULT, the article-rate adoption test and the customer-switch snap all keep reading getAvailableVatRates(). The picker previously rendered the default set, which for a VAT-validated EU business or a non-EU business is a single locked 0% option, so a Stockholm hotel could not put 12% on a hotel night sold to a German company even though that supply is taxed where it is performed (ML 6 kap.) and carries Swedish VAT. Widening getAvailableVatRates() itself was rejected (again): it would leave a stale 25% line at 25% after switching to an EU customer, and it would make the new-line default and the snap follow the widest lawful rate instead of the lawful DEFAULT (0% under huvudregeln, ML 6 kap. 34 §). The two sets therefore live in one plain module, components/invoices/line-vat-rates.ts, so the four behaviours are named and unit-testable (the repo renders no components in tests). Three non-obvious calls. (1) The customer-switch snap moves ONLY the lines still sitting on the previous customer's default rate, instead of every line. Snapping all of them destroys the lawful case the widening exists for (a deliberate 12% hotel line becomes 0% the moment the customer is picked), while snapping none of them keeps both stale-rate bugs. An inherited rate was never chosen by the user, so following the new customer is safe; a rate the user moved off the default is the only signal that a taxed-where-performed supply is on the line, and overwriting it would be guessing. A prompt was rejected as a dialog on every customer change for a case that is almost always a no-op. The rule is symmetric, which also fixes the reverse direction the old code never handled: 0% lines inherited from an EU customer now follow to 25% when the customer becomes Swedish, where before they silently under-charged VAT. Free-text rows are excluded (no amounts, never book). Edit and copy mode only RECORD the baseline on the first customer resolution and never snap, since the pre-filled lines already belong to that customer. (2) Article-rate adoption still gates on the DEFAULT set, so a foreign business customer keeps the line's rate when an article is picked. An article's stored vat_rate is its domestic rate and nothing on it says the supply is taxed where performed, so adopting 25% from the article would put Swedish VAT on a reverse-charge invoice by itself; the hotel picks the article for name/price/unit and sets 12% explicitly. (3) Attention is one ochre AttnLine sentence (UI convention 6) that renders only once a non-zero rate is actually selected for a customer whose default is 0%, never a banner and never for the normal 0% case. The four remaining server gates (lib/pending-operations/commit.ts, lib/invoices/self-billed-sale.ts, lib/invoices/recurring-schedule-service.ts, app/api/v1/.../invoices/bulk-create/route.ts) were pointed at getPermittedVatRates so every write surface accepts exactly what buildInvoiceWriteData accepts, pinned by lib/invoices/__tests__/vat-rate-gate-parity.test.ts. NOT fixed, reported instead: extensions/general/mcp-server/server.ts around line 4475 still builds its allowedRates from getAvailableVatRates, so gnubok_create_invoice still refuses a 12% line to an EU business; that file is owned elsewhere. + +[2026-07-26] The sharpened RC_INPUT_VAT_MISMATCH is now WIRED on both surfaces; before this it was dead code, because `runVatDeclarationChecks(rutor, accountTotals?)` had no caller passing the second argument and every caller therefore ran the weak `ruta48 + 0.5 < rcOutput` fallback. MCP: computeVatReportWithRutor returns the `accountTotals` map it already builds, runVatCompletenessChecks threads it, and both callers pass it (gnubok_vat_close_check from the report aggregate, gnubok_vat_declaration_validate from the declaration). Web UI: calculateVatDeclaration now carries ONLY the 2645/2647 debit/credit pair on the response as `VatDeclaration.rcInputAccountTotals`, and components/reports/views/index.tsx rebuilds a 2-entry Map through the new rcInputTotalsFromDeclaration(). Four non-obvious calls. (1) The pair, not the whole totals map: the check reads nothing but 2645 and 2647, so shipping every VAT account balance would grow the response (and the v1 REST body, which returns the declaration verbatim) for no behavioural gain; lib/reports/__tests__/vat-declaration.test.ts asserts the 2-entry projection and the full map produce identical findings, on a fixture that carries a balance on every OTHER ruta 48 account, which is also what pins the mirrored RC_INPUT_VAT_ACCOUNTS list to the private RC_INPUT_ACCOUNTS inside vat-declaration-checks.ts. (2) The field is OPTIONAL and rcInputTotalsFromDeclaration returns undefined (never an empty Map) when it is absent: an empty map reads as "0 kr beräknad ingående moms" and would turn a correct declaration from an older deploy into a false warning, whereas undefined falls back to the ruta 48 form. (3) The severity stays WARNING on both surfaces: limited avdragsrätt (blandad verksamhet, ML 13 kap 18/24-25) makes a shortfall legally correct for some filers and no SKV gateway rule rejects it, so blocking would invent a rule and risk a 625 kr forseningsavgift for a legal filer. isFilingBlocked reads ERROR only, so the sharper check cannot disable Skicka. (4) The `noInputVatAtAll` escalation in computeVatCloseCheck was KEPT even though its stated reason ("cannot isolate the RC share of ruta 48") no longer holds: its import half (rutor 60-62 against ruta 48) is coverage the shared checks still do not have, since they compare import output only against the tullvärdesunderlag in ruta 50. Comment corrected in place instead of deleting the block. No MCP schema changed, so the tools/list payload stays at 57 475 of 57 500 tokens. + +[2026-07-26] The phantom-column net (tests/schema/no-phantom-columns.test.ts + tests/schema/schema-guard.ts) takes its ground truth from REPLAYING supabase/migrations/*.sql, not from information_schema in a pg-real test and not from a checked-in schema snapshot. pg-real is authoritative but only runs in the test-pg-real CI job, so a developer's `npm test` would never see the guard that exists precisely because mocked tests cannot see this class; a snapshot rots and needs a generator to keep honest. Replaying 523 migrations costs ~250ms, has no artifact that can go stale, and the migration files are already the repo's contract with prod ("never leave a remote DB ahead of the repo"). The cost is parser fidelity, so every unparsed construct degrades to "unresolved" rather than to an accusation, and fidelity is pinned three ways: the tables that deliberately lack company_id, the tables that got company_id from an `EXECUTE format('ALTER TABLE %I ADD COLUMN ...')` loop, and full agreement with MASTER_DATA_DUMP_TABLES in lib/reports/full-archive-export.ts, whose own contract is asserted against a live database by tests/pg/full-archive-coverage.pg.test.ts. Two parser features exist only because the repo needs them and their absence produced 1 554 false accusations on the first run: dynamic ADD COLUMN inside `DO $$` blocks over an `ARRAY[...]` table list (40 tables got company_id that way), and plain ALTER TABLE inside `DO $$` idempotency guards (supplier_invoice_payments.user_id, and the automation_webhooks -> webhooks rename, without which 14 v1 webhook routes read a table the model denied existed). Failure mode is split deliberately: named columns, tables, CHECK values and onConflict targets that resolve confidently are hard assertions minus a 24-entry documented baseline of pre-existing breakage, while expressions the scanner cannot resolve (145 runtime payloads, 116 interpolated selects, 48 dynamic or-strings, 32 spreads) are counted against a ceiling of 420 instead of failing, because failing on every unresolvable expression is the noise that gets a guard disabled. A floor of 12 000 resolved references guards the guard: if the scanner stops following builder chains the test fails rather than going quietly slack. Test files are OUT of scope (a phantom column in a mock is not a production bug) and scripts/ is IN (those run against prod). DECLINED to also ship a pg-real companion that diffs the replayed model against information_schema: it is the obvious next step and would close the fidelity loop permanently, but with no local Postgres it could not be run in-session, and shipping an unverified test is how a green tree becomes a red one. + +[2026-07-26] A blocked OAuth popup in ArcimMigrationWorkspace now falls back to a full-page flow instead of getting a toast, matching SkatteverketConnectPanel.tsx:205-213 rather than inventing a second treatment. The return value of `window.open` was discarded at both provider-auth sites (the first-connect button and the retry arm of handleReconnect), so a blocked popup looked exactly like a successful one: nothing opened and nothing was said. A toast was rejected because the flow is recoverable: the callback route already handles a missing window.opener by redirecting to `/import?migration=connected&consentId=...` (extensions/general/arcim-migration/index.ts:527-535, and the mirror error arm at :452-475), `?migration=` sets mode='migration' on the import page, and handleOAuthReturn resumes the wizard at the preview step, so the user finishes the migration instead of being told it failed. That also keeps the fix free of a new i18n key and free of the TOAST_LIMIT=1 eviction trap. lib/browser/deferred-tab.ts is deliberately NOT used here: it severs `tab.opener` to preserve noopener semantics, and this flow's success signal IS `window.opener.postMessage`. No activation problem exists at either site (both opens are synchronous in the click, or already pre-opened), so no pre-open indirection was added. Not testable as a component: the repo has no jsdom/testing-library/E2E and vitest runs node-env over lib/ + app/api/, and the only extractable seam would be `if (!popup) navigate(url)`, a test of itself. Instead the fallback's precondition is pinned, since this change promotes the callback's previously near-dead no-opener arm into the only path a popup-blocked user has: two cases in extensions/general/arcim-migration/__tests__/oauth-callback-state.test.ts assert the exact `/import` URL and params for both success and failure. Those pass at HEAD by design; they are a contract guard, not proof of the fix. + +[2026-07-26] The "Ångra" ToastAction on app/(dashboard)/deadlines/page.tsx now goes through a real handler (handleUndoComplete) and the /api/deadlines/[id]/complete route honours an explicit `is_completed` boolean instead of blindly toggling. The finding was that the inline onClick had no res.ok check and no refetch, so a successful undo of a Skatteverket deadline looked exactly like a failed one. Two non-obvious calls. (1) DECLINED lib/browser/post-action.ts even though it exists for precisely this shape: postAction sends `{ method: 'POST' }` with no body, and this fix needs a body, because the honest undo is an explicit target state rather than a second toggle. Editing post-action.ts to grow a `body` option was off-limits this session, so the handler hand-rolls the fetch in the same shape as the five sibling handlers already in the file, using getErrorMessage(body, { statusCode }) for the server arm (envelope-aware, unlike the siblings' `new Error(result.error)` which renders "[object Object]" when the canonical envelope is returned) and the existing `load_failed_description` key for the never-completed arm. No new i18n keys. (2) The route change is load-bearing, not scope creep: the client picks its toast from the state it asked for, so a route that ignores the body can persist the opposite of what the toast claims, and an undo click landing after the row was already un-ticked in another tab or by an MCP agent would re-complete the deadline. Explicit state makes it idempotent; a body-less or non-boolean body still toggles, so nothing else has to migrate (the only two callers are both on this page). fetchData now returns whether the list was really refreshed, and the success toast is withheld when it was not, because TOAST_LIMIT=1 means a success sentence would evict fetchData's own load-failure sentence. Proven at the route seam: the two idempotency cases in app/api/deadlines/[id]/complete/__tests__/route.test.ts fail against HEAD (expected false, got true). The component itself is untestable here (no jsdom/testing-library, vitest is node-env over lib/ + app/api/). + +[2026-07-26] Sweep 5.5's two "weaker" settings toggles split: one fixed, one declined. FIXED `components/settings/sections/AssistantSettingsContent.tsx` FabVisibilityRow, which already checked `res.ok` and already rolled the switch back, so the defect was never the divergence the sweep described (UI new, DB old) but pure silence: a switch that flips and flips back reads as a broken control, so the user re-clicks instead of re-authenticating, and 401 on an expired session is the likeliest refusal on a route that is `requireAuth()` plus one upsert. It now emits exactly ONE destructive toast on both failure arms, mirroring the same-directory sibling `DimensionsToggle.tsx:48-55` rather than inventing a treatment. Four non-obvious calls. (1) `lib/browser/post-action.ts` was NOT adopted: it hardcodes `{ method: 'POST' }` with no body, this route is PATCH with a JSON body, and widening the helper was out of bounds. (2) The optimistic flip plus rollback was KEPT instead of moving to DimensionsToggle's derive-only-on-success model; the rollback was already correct and a laggy switch is a worse trade than a responsive one that explains itself. (3) `router.refresh()` moved OUT of the try: inside it, a refresh that threw would roll the switch back and announce a failed save for a write that had already landed. (4) `statusCode: res.status` is passed to `getErrorMessage` on purpose, because the route hand-builds `{ error: 'Could not save preference' }`, un-localized English that `isSwedishUserMessage` correctly declines, so without the status the description would degrade to the generic "Något gick fel"; with it, 401 reads "Din session har gått ut. Logga in igen." One new key pair, `settings_assistant.fab_save_failed` (sv + en). NOT testable: no component or E2E harness exists (vitest is node-env over lib/ + app/api/), and the server half of the contract is already pinned by `app/api/user/preferences/__tests__/route.test.ts:106-110` (500 when the upsert fails), which passes at HEAD, so a test there would assert someone else's correct code, not this change. DECLINED `components/settings/PeriodiseringAutoDetectToggle.tsx:72-79`: the swallowed `setItem` catch cannot produce the lie the sweep alleges, because the switch is `checked={enabled}` off `useSyncExternalStore(subscribe, readStored)`, `components/ui/switch.tsx` is a pure controlled Radix pass-through with no internal state, and a `setItem` that throws leaves `readStored()` returning the old value, so the `notifyChange()` re-read snaps the switch back on the same tick; there is no persisted state to misreport. Its real defect is one the sweep did not see and is a product decision, not a silent-failure fix: `periodisering_autodetect_enabled` has no reader anywhere in the repo, and the wizard consumes `data.autoDetected` unconditionally (`app/(dashboard)/bookkeeping/year-end/periodisering/page.tsx:171,243,457,481`), so the preference is inert and a "storage blocked" toast would be noise attached to a control that changes nothing. + +[2026-07-26] The kassaflödesanalys PDF button (`app/(dashboard)/reports/kassaflodesanalys/KassaflodesanalysClient.tsx`) now downloads through `lib/browser/download-file.ts` with its own busy state instead of assigning `window.location.href`. Sweep 5.5 called this a missing busy state whose disable tracked the report fetch; the site is worse than that reading and worse than the ten neighbouring download sites it was grouped with. A location assignment has no `res` to check and no promise to catch, so a busy state cannot be bolted on: it has to become a fetch. And where the neighbours save an error envelope to disk and leave the app running, this one navigates the whole SPA to the route's `{ error: string }` body, because the error responses carry no Content-Disposition to cancel the navigation, so a failed statutory report costs the user the page they were on. Three non-obvious calls. (1) The client now names the file itself, duplicating the route's `kassaflodesanalys-.pdf`; `downloadFile` has no Content-Disposition seam and `lib/browser/**` was off-limits this session, so the duplication is forced. It is safe because both sides read `period_start` from the same `generateKassaflodesanalys` output (the JSON route returns it verbatim), and it is now pinned by an exact Content-Disposition assertion in the new `app/api/reports/kassaflodesanalys/pdf/__tests__/route.test.ts`. (2) The route's hand-rolled `{ error: 'Företagsinställningar saknas' }` / `'Räkenskapsperioden kunde inte läsas...'` bodies were left alone rather than migrated to the canonical envelope: `getErrorMessage` passes both through on `isSwedishUserMessage`, so the toast already shows the route's own sentence, and rewriting an untouched route's error shape is scope the finding does not ask for. Those two bodies became user-facing with this change, so the same new test asserts they survive `getErrorMessage` instead of degrading to the generic status text. (3) The shared 15s deadline is used unchanged rather than widened: this is one fiscal year of aggregation plus a single-page stock-font render, and the route sets no `maxDuration`, so a longer client deadline would only keep the spinner alive past the point the function itself is killed. No success toast (the saved file is the feedback), which also keeps the handler clear of the TOAST_LIMIT=1 eviction trap. Three new `reports.pdf_download_*` key pairs; the page's Swedish body copy was left hardcoded, since `useLocale()` was needed for `getErrorMessage` anyway and translating the statutory section headings is not this fix's business. The fix itself is NOT testable here: the entire delta is in a `.tsx` client component, and vitest runs node-env over `lib/` + `app/api/` with no component or E2E harness. The new route test passes at HEAD by construction (the route is unchanged); it is a contract guard for the filename and error bodies the client now depends on, not proof of the fix. +[2026-07-26] Deleted lib/core/tax/tax-code-service.ts and its test (phantom `tax_codes` table found by tests/schema/no-phantom-columns.test.ts): the table was never created by any migration, migration 20240101000012 is an explicit placeholder recording that the tax-code engine was "planned but never deployed", the `seed_tax_codes_for_user` RPC it called does not exist either, and the only importer was its own mock-backed test. The need is covered, and covered more completely, by the account-driven route: `ACCOUNT_RUTA` in lib/reports/vat-declaration.ts mirrored by `ACCOUNT_TO_BOX` in lib/vat/moms-box-mapping.ts, which spans every ruta 05-62 with debit/credit sides, matches the swedish-vat reference (which maps rutor to BAS accounts, not to skattekoder; a per-line tax-code dimension is a Fortnox/Visma implementation detail, not a legal requirement), and is already the documented contract in the MCP tool schema ("tax_code: free-text tag, does NOT drive momsdeklaration ruta"). The dead code was also arithmetically wrong: it added the same abs(net) amount to basis and tax boxes alike, so an MP1 line put its VAT amount into ruta 05 as well as ruta 10. Kept: the `TaxCode`/`TaxCodeId` types in types/index.ts and the `makeTaxCode` fixture in tests/helpers.ts, left in place only to stay out of files other agents were editing; both are now orphaned and can go with the next sweep of those files. + +[2026-07-26] The MCP staging tool gnubok_create_invoice (extensions/general/mcp-server/server.ts) now gates line VAT rates on getPermittedVatRates instead of getAvailableVatRates, making it the seventh and last write path to agree with the other six; it is pinned in lib/invoices/__tests__/vat-rate-gate-parity.test.ts, whose exclusion note for this file is removed. It gates at STAGING time, so gating on the picker default refused a lawful invoice ("VAT rate 12% is not allowed for customer type eu_business. Allowed rates: 0%") before the executor's own already-widened gate in lib/pending-operations/commit.ts was ever reached. Law verified against the swedish-vat reference, not from memory: huvudregeln (ML 6 kap. 34) taxes a B2B service where the buyer is established, but supplies taxed where they are performed carry Swedish VAT even to a foreign business (hotel/restaurang 12%, persontransport and event admission 6%, fastighetstjanst and korttidsuthyrning 25%), so the lawful set spans 0/25/12/6 and no single non-zero rate can be whitelisted instead. Defaults are untouched: an item that omits vat_rate still falls back to getVatRules().rate. Booking is already correct for the widened case (lib/bookkeeping/invoice-entries.ts keeps reverse_charge/export only for 0% lines, so a 12% line on a reverse-charge invoice books 3002 + 2621 into ruta 05/11 while the 0% lines stay 3308/ruta 39). Non-obvious call on the advertised surface: extensions/general/mcp-server/resources/vat-treatments.ts now publishes BOTH sets per customer type, renamed default_rates (the picker default) alongside permitted_rates (the lawful set), plus a swedish_vat_to_foreign_business note naming the exceptions. Publishing only the default told an agent 0% was the only lawful rate and it would never attempt the hotel invoice; publishing only the lawful set would invite 25% on a plain consulting invoice to the same German company. The pair plus the note is the only variant that makes an agent more likely to be correct in both directions. This costs zero of the tools/list payload budget (57475 of 57500 tokens across 129 tools, 25 tokens of headroom, unchanged by this change) because resources are read on demand via resources/read, not serialized into tools/list. Also corrected the two now-false bullets in the customer-onboarding MCP skill, which documented a refusal message the code can no longer produce and told agents to always use 0% for a validated EU business. + +[2026-07-26] The Nyckeltal save (app/(dashboard)/kpi/page.tsx) had an empty catch commented "Silently fail: user can retry" over both the !res.ok throw and every thrown fetch, and components/kpi/KPISettingsDialog.tsx called onSave without awaiting and closed immediately: together, a failed save was pixel-identical to a successful one (dialog gone, grid still rendering the draft) until the next page load read the untouched extension_data row back and reverted the layout. The comment was wrong on its own terms: "the user can retry" presumes the user knows there is something to retry. Five non-obvious calls. (1) The decision moved into components/kpi/save-preferences.ts rather than reusing lib/browser/post-action.ts, which is a bodyless POST that deliberately does not parse the success body, whereas this is a PUT carrying the preferences whose echoed row is what the page renders next; the failure union is still ActionFailure, so the page describes a failed save through the same failureDescription() as a failed download. Colocating the module next to the component (the inbox-address-copy.ts precedent) keeps it inside vitest's include glob, which is how a client-side decision gets tested at all in a repo with no component tests. (2) onSave now returns Promise and the dialog closes only on true, so the draft survives a failed save and the parent's saving state finally has a render window; Esc and click-outside are blocked while saving, bounded by the request's own 15s deadline. (3) One destructive toast, never two (TOAST_LIMIT is 1), and a toast rather than an inline AttnLine: the toast viewport is z-[100] over the dialog's z-50, and CreatePeriodDialog already reports a failed create exactly this way while keeping its dialog open. AttnLine in this codebase carries success-path advisories, not failures. (4) A 2xx whose body carries no usable row returns ok with the payload the caller sent, not a failure: the row was written, and claiming "not saved" for a save that landed is the same lie as the silent catch pointing the other way. It also closes a real crash path, since the old code assigned the destructured undefined straight into page state that both the grid and the dialog read .visibleKpis off. (5) The busy state covers the save only; the follow-up report refetch is deliberately not awaited, because it is an unbounded fetch and holding saving across it would lock the dialog on a request that is not the save. NOT fixed, reported instead: the preferences GET at the top of the same page still falls back to defaults silently, so a failed read followed by an open-and-save overwrites the stored layout with defaults, which is the same symptom through a different door and needs a UI decision (block the Anpassa button, or show the read failure) rather than another toast. + +[2026-07-26] The ten push-notifications defects found by tests/schema/no-phantom-columns.test.ts were fixed as CODE bugs, not by adding company_id to notification_settings and push_subscriptions: both tables are deliberately user-scoped, and the guard's own root-cause note ("both tables predate multi-tenancy and were left out of the company_id backfill") is wrong. 20260330130000_multi_tenant_company_refactor.sql enumerates 40 tables by hand for its ADD COLUMN, backfill and NOT NULL passes, and that list includes notification_log, the third table created by the very same migration (20240101000008) as the other two: an author who had forgotten push notifications would have dropped notification_log too. 20260415000000_schema_sync.sql then files both under an explicit "User-scoped tables (no company_id, use auth.uid())" heading and gives them DELETE policies on auth.uid() = user_id. The domain agrees: a push endpoint is issued per browser profile per origin (hence `endpoint text unique`, which is also why the only legal onConflict target is `endpoint` alone and the shipped `user_id,endpoint` raised 42P10 on every subscribe call), one human has one set of quiet hours while belonging to several companies, and a company-scoped layer already exists separately as the extension's own extension_data blob behind ctx.settings. Company-scoping would have required one subscription row per (user, company) for the same physical device, which the endpoint unique constraint forbids. So every company_id reference in the extension was the bug, including the DELETE in api-routes.ts that passed a user id into company_id and therefore unsubscribed nothing. The single migration written (20260726174500, NOT APPLIED) adds only notification_settings.missing_underlag_enabled, the sixth per-event toggle whose five siblings 20240101000035 already created and which the code has read and written since the missing_underlag notification type shipped in 20260712090000. Because PostgREST rejects the whole select on one unknown column, that one missing column disabled all six toggles and saveSettings alike: every notification honoured defaults and the settings PUT silently wrote nothing while returning the merged object as if saved. No pg test ships with it, deliberately: adding a column touches no trigger, RPC, RLS policy or DEFERRABLE constraint, and the migration replay in the schema guard already proves both that the column exists and that all ten sites now resolve, which an unrunnable pg test in this session could not. Fixed unasked because it is the same find/replace in the same file: wasNotificationSent() filtered notification_log on company_id while logNotification() writes only user_id, so the duplicate check could never match a row it had itself written and every cron pass re-sent the same notification; that column does exist (nullable, from the refactor's list), so the phantom-column guard was structurally unable to see it. NOT fixed, reported instead: app/api/extensions/push-notifications/cron/route.ts is core code importing from @/extensions/, so this extension's query surface compiles into the core build whether or not the extension is enabled, which is why "the extension is off" was never the containment it looked like. + +[2026-07-26] The Stripe settings panel (extensions/general/stripe/components/StripeSettingsPanel.tsx) had try/finally with no catch on "Synka nu" and on Koppla från, so a rejected fetch skipped every toast line and the spinner just started and stopped. Four non-obvious calls. (1) The classification moved into extensions/general/stripe/lib/settings-actions.ts rather than reusing lib/browser/post-action.ts: three of the four calls need a request body or a non-POST method (transaction-sync requires {enabled} or 400s, disconnect is a DELETE carrying connection_id), and the sync arm needs the parsed success body for its counts, all three of which postAction deliberately does not do. The failure union is still ActionFailure, so the panel describes a failed click through the same failureDescription() as the salary payment panels. (2) The sync deadline is STRIPE_SYNC_TIMEOUT_MS = 310s, not the 15s used for the writes, because a first sync backfills 90 days and up to 10000 balance transactions; aborting earlier would report failure for a run that keeps going server-side and advances last_balance_txn_synced_at, so the retry would find nothing. The only honest bound is the route's own maxDuration = 300 plus margin. (3) A non-2xx keeps the route's own Swedish sentence in preference to getErrorMessage's status map: "Inget anslutet Stripe-konto." carries none of the heuristic's trigger words, so routing it through getErrorMessage alone would have downgraded it to "Resursen kunde inte hittas." while fixing a bug about teaching the user nothing. error_en is honoured first for an English UI because capabilityBlockedResponse emits that top-level pair and getErrorMessage only reads message_en inside a structured envelope. (4) Two failures were being delivered inside a 200 and are now reported as failures: a connection Stripe revoked upstream comes back as {revoked: true} with zero counts and was shown as a finished sync that "returned no transactions, check that the right account is connected", and a success body that never parsed became fetched=0 and the same sentence. Also fixed outside the cited handlers: loadStatus swallowed its error into configured=false, which rendered "Stripe-integrationen är inte konfigurerad på den här installationen. Kontakta administratören." on a correctly configured installation; it now has its own load-failed state with a retry. + +[2026-07-26] formatCurrency's SEK default (lib/utils.ts) got a RATCHET GUARD, not a signature change: scripts/checks/format-currency-sek-label.mjs, wired as check 6 of npm run check:guards, hard-failing at 0 with no baseline entry. Making the second argument required was rejected outright: .claude/rules/i18n.md fixes formatCurrency(amount) as SEK with sv-SE conventions in BOTH locales because that is a Swedish accounting convention rather than a UI string, and 171 of the 385 call sites are single-argument on values that genuinely ARE kronor (ledger columns, KPI aggregates, salary, tax). The guard therefore judges only the one shape the default cannot defend: a single-argument call whose value is read off an owner the SAME file also reads .currency from (the file demonstrably knows the unit and dropped it), plus any single-argument call on amount_in_currency, which is the foreign figure by definition. Owner paths are matched in full (invoice.currency does not license a verdict on line.amount), *_sek twins and the ledger columns debit_amount/credit_amount are excluded by rule because journal entry line amounts are ALWAYS SEK and line.currency labels the DOCUMENT (structural root 4.1, lib/bookkeeping/ledger-line-amount.ts), and multiplicative expressions are excluded because amount * rate is a conversion INTO kronor. Number()/Math.abs()/unary minus wrappers and both branches of ??/||/?:/+/- are followed, so the total_sek ?? total fallback is caught. Deliberately NOT judged: bare locals like formatCurrency(totalDebit), which would need whole-program dataflow; guessing there is how a guard earns its way onto an ignore list. Re-derived the true current count before baselining: 171 single-argument calls, 96 property-rooted, 5 on a currency-bearing owner, and all 5 format a *_sek twin under a currency !== SEK guard, so the count is 0 and the sweep's latent-not-active reading holds for the call sites. The sweep is nonetheless incomplete on the hazard: lib/transactions/booking-duplicate-detection.ts:291 returns the sibling candidate amount in the TARGET transaction currency while the ledger-voucher branch at :488-496 documents the opposite invariant (always the leg's SEK figure, so the UI never prints a foreign number with kr after it), and components/transactions/DuplicateBookingDialog.tsx:163 prints it single-argument, so a 1 000 EUR duplicate warning reads 1 000,00 kr. No guard of this shape can see that, because the candidate record carries no currency at all: the unit is erased at the producer, one module away from the call site. Reported, not fixed: the fix is a producer change with a decision about the unconvertible case. + +[2026-07-26] "Har bolaget anställda" for the agent onboarding review card and the composer now resolves through lib/agent/composer/employee-facts.ts instead of company_settings.employee_count / company_settings.has_employees, neither of which is a column on that table (employee_count is on agi_declarations per 20260414120000; has_employees appears in no migration at all), so PostgREST answered 42703 and rejected BOTH selects whole: the composer's loadCompanySettings returned null for the entire row, silently discarding city, moms_period, fiscal_year_start_month, f_skatt, vat_registered, pays_salaries and accounting_method as well, and the onboarding page lost is_sandbox and the address fields on top. Four non-obvious calls. (1) The replacement ranks a live count of active `employees` rows ABOVE the TIC employeeRange, contrary to the "TIC wins because it is authoritative Bolagsverket data" precedence that page.tsx states for the other fields: that comment is about TIC versus user-entered settings, and an employee register is neither. The count is what a salary run actually enumerates and is current by construction, where the snapshot is only as fresh as the last TIC fetch. In practice the ordering rarely bites at onboarding (a new company has zero rows and falls through to TIC anyway); it matters when the composer re-runs later. (2) A ZERO count is never read as "nej". Every company arriving at agent onboarding has zero employee rows because nobody has opened the payroll module yet, so treating zero as the negative would answer a question nobody was asked. (3) pays_salaries === false is likewise NOT evidence: the column is NOT NULL DEFAULT false (20260401000000), no onboarding step sets it, and its only writer is the tax settings form, so false is indistinguishable from "never answered". Only employer_registered, which is deliberately NULLABLE so that "never attested" is representable (DECISIONS.md 2026-07-17), settles the question in the negative direction. Reading the default as "nej" would have suppressed the composer's "Har bolaget anställda?" verification question for every company that never opened /settings/tax. (4) Being a registered employer and having employees are not the same predicate, since a registered employer still files nil AGI months with nobody on payroll, so the resolver only ever reports a NUMBER when actual employee rows back it and otherwise reports the employer fact as a plain ja/nej. The employees-table derivation is not new: extensions/general/mcp-server/server.ts:2464 already computes its has_employees as count(employees where is_active) > 0, and this aligns the composer with the pattern that already ships. Behaviour is covered by lib/agent/composer/__tests__/employee-facts.test.ts (4 of its cases fail against the old derivation) and the column names by tests/schema/no-phantom-columns.test.ts, whose baseline loses the four company_settings.employee_count / has_employees entries. + +[2026-07-26] The deadlines page's overdue-invoice attn line (app/(dashboard)/deadlines/page.tsx) filtered invoices on status IN ('sent', 'unpaid'); 'unpaid' is not in invoices_status_check, whose live value set is draft / sent / paid / partially_paid / overdue / cancelled / credited (20240101000001 plus 20260323120001, the only two migrations that touch that constraint). The filter therefore read as ('sent') alone and dropped exactly the invoices the reminder run had already flipped to 'overdue' (lib/invoices/reminder-processor.ts:393), i.e. the ones most certainly overdue, so the count and the SEK total both under-reported with no error anywhere. Fixed to ('sent', 'overdue'). partially_paid was deliberately NOT added, though a part-paid invoice past its due date is genuinely overdue for its remainder: this query sums total_sek / total, not remaining_amount, so including it would swap an under-count for an over-count by reporting money already received as still owed, and the line's own action link goes to /invoices?status=unpaid whose tab filter is exactly ['sent','overdue'] (app/(dashboard)/invoices/page.tsx:171), so the sentence would stop describing the list it leads to. Counting the open remainder is a real reporting question (it would need remaining_amount, the partially_paid status on the invoices tab, and the same call in lib/reports/ar-ledger.ts:88, which also omits partially_paid today) and is left to the owner of that surface. The credit-note and proforma / delivery_note rows that the same query counts but the destination tab excludes are noted for the same owner and not touched here. Also in the same file: all five sibling mutation handlers did throw new Error(result.error || '...') and then getErrorMessage(error), which stringifies the canonical envelope { error: { code, message } } into "[object Object]" and falls through to the generic "Något gick fel", discarding the route's own Swedish reason, its Zod field list and its 403 read-only refusal. They now share describeFailure(response), which hands the parsed body plus statusCode to the mapper, the shape handleUndoComplete already used. Create and edit still rethrow so a failed submit keeps the form open, but they toast first and the thrown message is a developer signal that is never displayed, so the catch cannot re-toast a generic sentence over the specific one (TOAST_LIMIT is 1). No page-level test exists or was invented: this repo has no component tests, and describeFailure cannot be exported from a Next.js page file; the behaviour it depends on is pinned in lib/errors/__tests__/get-error-message.test.ts and the status literal by tests/schema/no-phantom-columns.test.ts, whose baseline loses the invoices.status = 'unpaid' entry. + +[2026-07-26] company_settings.invoice_default_notes was fixed by RESTORING the column via a new migration (20260726181500, ADD COLUMN IF NOT EXISTS, NOT applied to any database in-session), not by dropping it from the InvoiceEditor select, and the guard baseline entry was then removed. The guard's baseline note said "never existed", but git shows the inverse of the orphan case: 20260407120100 added the column, landed on main in #188 (so branching applied it to prod), and #244's migration consolidation then deleted the file without carrying the column into 20260415000000_schema_sync.sql, leaving the REPO behind prod. The feature is whole and live on hosted prod (settings textarea, save payload, UpdateSettingsSchema, CompanySettings type, makeCompanySettings fixture all reference the column at HEAD), so dropping it from the select would have killed a working feature to satisfy the guard. Only databases built from the repo's migrations (self-hosted Docker, pg-real CI, migration-built preview branches) lack the column, and on those PostgREST's 42703 rejects the WHOLE 11-column select whose error the editor discards, so ten real settings silently read as null: hasBankDetails forced false (bank-setup dialog detour before every invoice review plus a standing "bank details missing" bar), accounting_method stuck on 'accrual' (kontantmetoden companies got the accrual review text and accrual periodisering controls; books stayed correct since the server reads settings itself), ore_rounding stuck true (rounding written onto every new invoice against the company setting; display-only but persisted), vat_registered stuck true (non-momsregistrerade companies got the Moms column, 25% line defaults and VAT in totals), dimensions_enabled and invoice_payment_links_enabled stuck off (affordances hidden for companies that opted in), logo_url null (first-invoice logo prompt for companies that have a logo; sent PDFs unaffected, pdf-template reads live), and invoice_default_notes plus default_our_reference never prefilled. IF NOT EXISTS is load-bearing: prod already has the column and a bare ADD COLUMN would 42701-abort the migration batch behind it. No pg test, deliberately: a plain column addition touches no trigger, RPC, RLS policy or DEFERRABLE constraint, and the schema guard's migration replay already proves the select resolves. + +[2026-07-26] The push-notifications cron route (app/api/extensions/push-notifications/cron/route.ts) is NOT a core-imports-extensions violation: .github/workflows/core-build.yml's "Check no core imports from extensions" step exempts app/api/extensions/ by design, because Vercel crons and OAuth callbacks need physical file routes the ext/[...path] dispatcher cannot provide. The real defect was that such routes compile into every build while extensions.config.json only edits the runtime registry, so the DISABLED push-notifications extension still exposed a live cron surface. Fixed with a registry gate (loadExtensions() + extensionRegistry.get('push-notifications'), 503 EXTENSION_DISABLED when absent) rather than moving the cron behind the dispatcher, which would break the concrete paths vercel.json crons and generated Docker crontabs address. The pattern is now enforced by scripts/checks/extension-route-guards.mjs in check:guards: cross-extension imports from these routes hard-fail at 0, and the 10 pre-existing ungated routes (cloud-backup, enable-banking, skatteverket, stripe) are a shrink-only allowlist; new physical extension routes must ship the gate. The push cron itself remains unscheduled: it is absent from vercel.json, so nothing invokes it in prod; adding it there is Emil's call. + +[2026-07-26] API-gap report triage: five agent-facing gaps were implemented as MCP/v1 surfaces (update_invoice staged tool + v1 items PATCH, widened company-settings tools, invoice-deliveries read tool + service RPC, recurring-schedule tools), but a company_id override on cookie-session routes was REJECTED: the reported blocker ("/api/settings PATCH ignores X-Company-Id") is a wrong-door problem, since both real API surfaces already carry per-request company routing (v1 via the URL path with membership re-check in with-api-v1.ts, MCP via company-routing.ts), and adding an override to 100+ withRouteContext handlers would duplicate the company-authz check per handler instead of keeping it in one wrapper. The Resend deliverability item (report #5) was deliberately NOT built: it is verification work (RESEND_DELIVERY_WEBHOOK_SECRET in Vercel, webhook registration in the Resend dashboard, SPF/DKIM/DMARC alignment for M365) requiring dashboard access only Emil has, and whether "2624" is an invoice number or a mail count is still unconfirmed with the reporter. + +[2026-07-26] gnubok_update_company_settings widened to email/phone/website/invoice_email_texts but invoice_email_cc_addresses and invoice_email_bcc_addresses were EXCLUDED: the enforce_invoice_email_recipient_settings_admin trigger (20260723003000) requires owner/admin, so a member-role API key would fail at commit with a raw Postgres exception instead of a clean envelope; exposing them needs tool-level role gating first. Accounting-behaviour and legal-registration flags (defer_invoice_booking, agent_auto_commit_*, default_voucher_series, dimensions_enabled, ai_flow_enabled, vat_registered, moms_period, f_skatt, entity_type, org_number, fiscal_year_start_month) stay out of agent reach by design, and the exclusions are pinned by .strict() schema tests. invoice_email_texts placeholders are validated against INVOICE_EMAIL_PLACEHOLDER_KEYS so agents cannot invent placeholders that render literally in customer mail. + +[2026-07-26] gnubok_get_invoice_deliveries required a NEW service-role RPC (list_invoice_delivery_summaries_for_service, migration 20260727100000) rather than reusing list_invoice_delivery_summaries: the existing function rejects when auth.uid() IS NULL and when p_company_id differs from current_active_company_id(), and the MCP server runs on createServiceClientNoCookies() with cross-company routing, so it can never satisfy either check (same trap that broke undo_sie_import). The sibling takes an explicit p_user_id, requires service_role, re-checks company_members, and returns the identical masked shape; the tool never selects invoice_deliveries directly because the masking is the point. + +[2026-07-26] Recurring-schedule MCP tools shipped WITHOUT a run-now tool and WITHOUT v1 REST routes, both deliberately deferred: run-now creates and may immediately send a real customer invoice, which should not arrive via a side door; auto_send keeps its schema default of false and must be set explicitly so it appears in the staged approval preview (an auto-sending schedule is recurring outbound customer email that never sees approval again). All four new MCP tools (update_invoice, get_invoice_deliveries, and the three recurring ones minus the read tools where applicable) are catalogVisibility 'search' to stay under the 57500-token payload-size bench ceiling, following the update_customer/update_company_settings precedent, rather than bumping the ceiling. + +[2026-07-26] The pending_operations operation-type CHECK gained update_invoice (20260727090000/090001) and create/update_recurring_schedule (20260727110000/110001) in two paired add+validate migrations; because the constraint is re-created wholesale from a full value list, the second pair explicitly carries update_invoice forward, otherwise it would silently revoke the first pair's value at apply time. +[2026-07-26] Declined a new check:guards ratchet for the "new Error(body.error)" toast-error class: whether a given site is broken depends on the backing route's error shape (canonical envelope object vs hand-rolled Swedish string), which is a cross-file dynamic property (fetch URL -> route module -> which code path answered) that a static AST guard cannot resolve, especially through the extension catch-all dispatcher; a syntactic ratchet would flag the fine string-route sites the audit explicitly excludes and its count would not track user-visible breakage. Broadened isSwedishUserMessage instead (kan inte/hittades/redan/låst) so real Swedish route sentences survive the correct getErrorMessage(body, { statusCode }) treatment verbatim. [2026-07-26] Fixed cross-user attachment access (Odin Aero support case) at the call sites with service-role clients after company-scoped authorization, instead of rewriting the documents bucket storage policy to be company-scoped like sie-files got in 20260416120000: the documents path layout (documents/{userId}/...) carries no company_id, so a company-scoped policy needs a per-object join against document_attachments on every storage op, and the authorize-then-service-client pattern was already the established model (inline proxy route, v1 download route, MCP tools). Sweep found and fixed the same defect in the metadata/sign route, the integrity probe, verifyIntegrity, invoice-inbox retry-extraction, and cloud-backup archive generation. Known leftover, deliberately unfixed: deleteDocument and the upload-failure cleanups call storage remove() with a user-bound client, which silently no-ops (no DELETE policy, WORM), orphaning storage objects; harmless for compliance, needs a separate decision on whether files should ever be hard-deleted. [2026-07-26] reverseEntry now blocks only source_type='storno', no longer 'correction': BFL 5 kap 5 § requires traceability, not immunity for rättelseverifikat, and blocking corrections left users with no sanctioned exit when a rättelse duplicated an affärshändelse booked elsewhere (support case 2026-07-26); it also broke uncategorize-after-rättelse since transactions are relinked to the correction entry. Storno-of-storno stays blocked (chain ambiguity). [2026-07-26] Supplier-invoice DELETE allows 'approved' (not only 'registered'/'overdue'): the overdue cron flips BOTH registered and approved invoices to 'overdue', so excluding 'approved' would make deletability depend on whether the cron ran yet; the orphan-safety checks (no registration verifikat, no payments, no accrual schedule) are the real guard, and an attested but unbooked, unpaid invoice deletes nothing from the books. [2026-07-26] Hydrating approval cards on resume re-links pending_operations via agent_metadata->>conversation_id rather than persisting the staged_operation stream events: the metadata stamp already exists for the BFL trail, so no migration and no second source of truth for a proposal. Cards render from the operation row, which is also what commit/reject act on, so a hydrated card and a live one cannot disagree. [2026-07-26] Regenerate now rejects the discarded turn's staged operations through the normal reject endpoint instead of leaving them pending: the alternative (silently deleting) would break the append-only audit trail, and leaving them (previous behaviour) produced two live proposals for one booking, each with its own 30-day expiry. +[2026-07-27] replace_sie_import and undo_sie_import honor p_user_id only when auth.role() = 'service_role'; every other caller is pinned to auth.uid(): the prior COALESCE(p_user_id, auth.uid()) with EXECUTE granted to authenticated let any member pass an owner's UUID over PostgREST and impersonate them into the owner/admin gate that disarms the immutability triggers (pre-publish review finding; same guard shape as list_invoice_delivery_summaries_for_service). +[2026-07-27] bulk_book_transactions refuses homogeneous non-SEK batches (BULK_BOOK_FOREIGN_CURRENCY) instead of writing foreign magnitudes into the always-SEK debit/credit columns: a 100+200 EUR batch previously produced a verifikat whose 300 was read as kronor by balansrakning, moms and SIE export; foreign transactions book individually through the FX-aware flow. The pg test that pinned the old outcome as correct was rewritten to assert the refusal. +[2026-07-27] Same-currency foreign settlements (EUR invoice paid by EUR bank tx) now clear 1510 at the invoice booking rate and book the realized diff to 3960/7960, mirroring the cross-currency path; rate-less foreign invoices refuse with MATCH_INVOICE_BOOKING_RATE_MISSING. The old arSek = bankSek shortcut stranded the kursdiff on 1510 and broke reskontra-to-GL tie-out; a branch test had pinned that behavior and was rewritten. +[2026-07-27] Balansdagen revaluation of receivables includes status partially_paid and revalues outstanding (total - paid_amount) rather than face value, mirroring the payables side per ARL 4 kap 13 §; the year-end readiness check follows the same status list. +[2026-07-27] deleteDocument and the upload-failure cleanups now remove storage objects via the cookieless service client after company-scoped authorization, revisiting the 2026-07-26 "deliberately unfixed" entry: the phase-A company-scoped SELECT policy changed the premise, since an orphaned object is now readable by every company member while its DB row and audit context are gone, so silent retention became a leak rather than a harmless orphan. WORM stays: no DELETE RLS policy exists, and documents linked to journal entries still cannot be deleted. +[2026-07-27] The credit-note cap trigger treats a cross-company credited_invoice_id as a rejection rather than not-found-so-uncapped, and no longer prints the original invoice's total or currency in exception text: with a SECURITY DEFINER lookup and no company match those messages were a cross-tenant amount oracle. +[2026-07-27] KPI preferences stay company-scoped (one row per company, last writer wins): the read path filters on (company_id, extension_id, key) with no user filter, so the broken upsert arbiter was aligned to the real unique constraint instead of introducing per-user semantics nothing reads. +[2026-07-27] Booking-template updates scope-check company templates against the active company and team templates against the active company's team_id, instead of a blind company_id filter which would have broken team-shared template editing (team templates carry company_id NULL). +[2026-07-27] Deferred from the pre-publish review, each needs its own decision: arcim OAuth state is not yet bound to the initiating browser session (TTL cut from 60 to 10 minutes as mitigation); customer invoices can still be created rate-less while supplier invoices refuse (the engine now refuses at booking instead); the v1 journal-entries dry-run commit still burns voucher numbers (pre-existing audit P0, route untouched by this branch). +[2026-07-27] Restored 20260726140000 to its preview-recorded content (60b14193) and restated the NULL-safe tenant guard as 20260727130000: the PR #1215 preview branch recorded that version before review hardening edited it, an applied version never re-runs, and a bare rename would have orphaned the preview's version row; CREATE OR REPLACE makes both replay orders converge on identical prosrc. diff --git a/app/(auth)/login/page.tsx b/app/(auth)/login/page.tsx index c5d3216d..1e03dd33 100644 --- a/app/(auth)/login/page.tsx +++ b/app/(auth)/login/page.tsx @@ -17,6 +17,10 @@ import { isBankIdEnabled } from '@/lib/auth/bankid' import { getBranding } from '@/lib/branding/service' import { detectWebmailHint } from '@/lib/auth/webmail-search' import { safeReturnTo } from '@/lib/auth/safe-return-to' +import { + consumeInviteCookie, + INVITE_PROBLEM_MESSAGE_KEYS, +} from '@/lib/auth/consume-invite-cookie' import { AuthPageSkeleton } from '@/components/auth/AuthPageSkeleton' const branding = getBranding() @@ -59,8 +63,27 @@ function LoginPageContent() { const bankIdEnabled = isBankIdEnabled() const tAuth = useTranslations('auth') const tCommon = useTranslations('common') + const tInvite = useTranslations('invite') const errorLocale = useLocale() as ErrorLocale + // Accept a pending invite, if any, and report a non-definitive failure. + // Returns true when the caller should land the user in the app directly. + // The invite cookie survives anything that is not a settled outcome, so + // /onboarding and /select-company can retry acceptance server-side. + const acceptPendingInvite = async (): Promise => { + const invite = await consumeInviteCookie() + if (invite.accepted) return true + if (invite.problem) { + const keys = INVITE_PROBLEM_MESSAGE_KEYS[invite.problem] + toast({ + title: tInvite(keys.title), + description: tInvite(keys.body), + variant: 'destructive', + }) + } + return false + } + // Reset cooldown timer useEffect(() => { if (!resetCooldownUntil) return @@ -114,26 +137,9 @@ function LoginPageContent() { } // Check for pending invite token - const bankIdCookieMatch = document.cookie.match(/gnubok-invite-token=([^;]+)/) - const bankIdInviteToken = bankIdCookieMatch?.[1] - - if (bankIdInviteToken) { - try { - const res = await fetch('/api/team/accept', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ token: bankIdInviteToken }), - }) - - if (res.ok) { - document.cookie = 'gnubok-invite-token=; path=/; max-age=0' - window.location.href = '/' - return - } - } catch (err) { - console.error('[login] invite acceptance failed:', err) - } - document.cookie = 'gnubok-invite-token=; path=/; max-age=0' + if (await acceptPendingInvite()) { + window.location.href = '/' + return } if (nextPath !== '/') { @@ -196,27 +202,9 @@ function LoginPageContent() { } // Check for pending invite token - const cookieMatch = document.cookie.match(/gnubok-invite-token=([^;]+)/) - const inviteToken = cookieMatch?.[1] - - if (inviteToken) { - try { - const res = await fetch('/api/team/accept', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ token: inviteToken }), - }) - - if (res.ok) { - document.cookie = 'gnubok-invite-token=; path=/; max-age=0' - window.location.href = '/' - return - } - } catch (err) { - console.error('[login] invite acceptance failed:', err) - } - // Clear cookie even on failure to avoid retrying stale tokens - document.cookie = 'gnubok-invite-token=; path=/; max-age=0' + if (await acceptPendingInvite()) { + window.location.href = '/' + return } if (nextPath !== '/') { diff --git a/app/(auth)/mfa/verify/page.tsx b/app/(auth)/mfa/verify/page.tsx index 63b228e5..2e338d4e 100644 --- a/app/(auth)/mfa/verify/page.tsx +++ b/app/(auth)/mfa/verify/page.tsx @@ -11,6 +11,10 @@ import { useToast } from '@/components/ui/use-toast' import { Loader2, ShieldCheck, LogOut } from 'lucide-react' import { SupportLink } from '@/components/ui/support-link' import { safeReturnTo } from '@/lib/auth/safe-return-to' +import { + consumeInviteCookie, + INVITE_PROBLEM_MESSAGE_KEYS, +} from '@/lib/auth/consume-invite-cookie' export default function MfaVerifyPage() { return ( @@ -23,6 +27,7 @@ export default function MfaVerifyPage() { function MfaVerifyContent() { const t = useTranslations('mfa') const tCommon = useTranslations('common') + const tInvite = useTranslations('invite') const [code, setCode] = useState('') const [isLoading, setIsLoading] = useState(false) const [factorId, setFactorId] = useState(null) @@ -68,6 +73,24 @@ function MfaVerifyContent() { return () => clearInterval(interval) }, [lockoutUntil]) + // Accept a pending invite, if any, and report a non-definitive failure. + // Returns true when the caller should land the user in the app directly. + // The invite cookie survives anything that is not a settled outcome, so + // /onboarding and /select-company can retry acceptance server-side. + const acceptPendingInvite = async (): Promise => { + const invite = await consumeInviteCookie() + if (invite.accepted) return true + if (invite.problem) { + const keys = INVITE_PROBLEM_MESSAGE_KEYS[invite.problem] + toast({ + title: tInvite(keys.title), + description: tInvite(keys.body), + variant: 'destructive', + }) + } + return false + } + const handleVerify = async (e: React.FormEvent) => { e.preventDefault() if (!factorId || code.length !== 6) return @@ -116,26 +139,9 @@ function MfaVerifyContent() { return } - const cookieMatch = document.cookie.match(/gnubok-invite-token=([^;]+)/) - const inviteToken = cookieMatch?.[1] - - if (inviteToken) { - try { - const res = await fetch('/api/team/accept', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ token: inviteToken }), - }) - - if (res.ok) { - document.cookie = 'gnubok-invite-token=; path=/; max-age=0' - window.location.href = '/' - return - } - } catch (err) { - console.error('[mfa/verify] invite acceptance failed:', err) - } - document.cookie = 'gnubok-invite-token=; path=/; max-age=0' + if (await acceptPendingInvite()) { + window.location.href = '/' + return } if (returnTo.startsWith('/api/')) { diff --git a/app/(auth)/register/__tests__/invite-email-prefill.test.ts b/app/(auth)/register/__tests__/invite-email-prefill.test.ts new file mode 100644 index 00000000..82d2378d --- /dev/null +++ b/app/(auth)/register/__tests__/invite-email-prefill.test.ts @@ -0,0 +1,181 @@ +import { describe, it, expect } from 'vitest' +import fs from 'node:fs' +import path from 'node:path' + +/** + * Two findings on the signup page, both about an address travelling (or not) + * with the user. + * + * 1. The BankID signup form left its email field blank and editable while the + * password form on the very same page pre-filled and locked it from the + * invitation. An invitee who chose BankID therefore typed their private + * address, POST /api/team/accept answered 403 on its email equality check, + * and they landed on /select-company with no membership. The token now + * survives that 403 (lib/auth/consume-invite-cookie.ts retains it on + * `wrong_email`, and /select-company re-runs acceptance server-side), so the + * dead end is recoverable; the signup still should not walk into it. + * 2. The duplicate-email screen linked to `/login?email=...`, which + * app/(auth)/login/page.tsx never reads. The address rode along in the URL, + * the browser history, the Referer header and every proxy access log, and + * arrived nowhere. + * + * The page is a client component and this repo deliberately has no component + * test harness (CLAUDE.md: scope is lib/ + app/api/), so these assert on the + * page source, the same pattern used by + * app/(auth)/reset-password/__tests__/invite-handoff.test.ts and + * app/invite/[token]/__tests__/invite-cookie.test.ts. + */ +const SRC = fs.readFileSync(path.resolve(__dirname, '../page.tsx'), 'utf8') + +/** The page source with comment lines dropped, so prose about a pattern is never mistaken for the pattern. */ +const CODE = SRC.split('\n') + .filter((line) => !/^\s*(\*|\/\/|\/\*)/.test(line)) + .join('\n') + +/** The props of one ``, located by its id. */ +function inputProps(id: string): string { + const anchor = SRC.indexOf(`id="${id}"`) + expect(anchor, `no on the page`).toBeGreaterThan(-1) + return SRC.slice(SRC.lastIndexOf('', anchor) + 2) +} + +/** A whole labelled field group (Label + Input + hint), located by the input's id. */ +function fieldGroup(id: string): string { + const anchor = SRC.indexOf(`id="${id}"`) + expect(anchor, `no on the page`).toBeGreaterThan(-1) + return SRC.slice( + SRC.lastIndexOf('
', anchor), + SRC.indexOf('
', anchor) + ''.length, + ) +} + +/** The body of the effect that resolves `?invite=` into the invited address. */ +function inviteEffect(): string { + const start = SRC.indexOf("searchParams.get('invite')") + expect(start).toBeGreaterThan(-1) + return SRC.slice(start, SRC.indexOf('}, [searchParams])', start)) +} + +/** + * Just the duplicate-email screen. Scoped, because the page footer carries its + * own bare `/login` link and would satisfy an unscoped assertion. + */ +function duplicateScreen(): string { + const start = CODE.indexOf('if (duplicateEmail) {') + expect(start).toBeGreaterThan(-1) + const end = CODE.indexOf('if (isRegistered) {', start) + expect(end).toBeGreaterThan(start) + return CODE.slice(start, end) +} + +describe('an invited signup', () => { + it('pre-fills the BankID email field from the invitation, not just the password one', () => { + const effect = inviteEffect() + expect(effect).toContain('setEmail(data.data.email)') + expect(effect).toContain('setBankIdEmail(data.data.email)') + }) + + it('locks the BankID email field exactly the way the password field is locked', () => { + // Same invitation, same page: one behaviour. The divergence was the bug. + for (const id of ['email', 'bankid_email']) { + const props = inputProps(id) + expect(props, id).toContain('disabled={isLoading || !!inviteEmail}') + expect(props, id).toContain('readOnly={!!inviteEmail}') + } + }) + + it('tells the invitee where the locked address came from', () => { + expect(fieldGroup('bankid_email')).toContain("t('invite_email_hint')") + expect(fieldGroup('email')).toContain("t('invite_email_hint')") + }) + + it('still submits the invited address although a disabled input is not in the FormData', () => { + // `disabled` (unlike `readOnly`) excludes a field from FormData, so the + // locked value only reaches the request through the state fallback. Both + // handlers rely on it; dropping either fallback would POST an empty email. + expect(CODE).toContain("(formData.get('bankid_email') as string) || bankIdEmail") + expect(CODE).toContain("(formData.get('email') as string) || email") + }) +}) + +describe('a signup that did not come from an invitation', () => { + // The overwhelming majority. Nothing may lock or pre-fill for these users. + it('starts with no invited address', () => { + expect(CODE).toContain('const [inviteEmail, setInviteEmail] = useState(null)') + expect(CODE).toContain("const [bankIdEmail, setBankIdEmail] = useState('')") + }) + + it('never resolves an invitation when the link carries no token', () => { + expect(inviteEffect()).toContain('if (!inviteToken) return') + }) + + it('leaves both email fields editable, gating every lock on the invitation', () => { + for (const id of ['email', 'bankid_email']) { + const props = inputProps(id) + // No unconditional lock: every disabled/readOnly here names inviteEmail. + const locks = props.match(/(disabled|readOnly)=\{[^}]*\}/g) ?? [] + expect(locks.length, id).toBeGreaterThan(0) + for (const lock of locks) { + expect(lock, `${id}: ${lock} is not conditional on the invitation`).toContain('inviteEmail') + } + } + }) + + it('keeps the BankID field its own hint when there is no invitation', () => { + expect(fieldGroup('bankid_email')).toContain("t('bankid_email_hint')") + }) +}) + +describe('the duplicate-email screen', () => { + it('sends the user to plain /login', () => { + expect(duplicateScreen()).toContain('') + }) + + it('does not put the address in the URL of a page that never reads it', () => { + expect(duplicateScreen()).not.toMatch(/\/login\?/) + expect(CODE).not.toContain('encodeURIComponent(duplicateEmail)') + }) + + it('still shows the address, so nothing is lost by dropping the parameter', () => { + expect(duplicateScreen()).toContain('{duplicateEmail}') + }) +}) + +describe('the post-auth destination parameter', () => { + it('does not read `next`', () => { + // Nothing links to /register with one: bounceToAuth (lib/supabase/ + // middleware.ts) targets /login and the two MFA pages, and + // app/invite/[token]/page.tsx sends `?invite=`. An unread parameter cannot + // redirect anyone. + expect(CODE).not.toContain("searchParams.get('next')") + }) + + it('would have to sanitize a destination through safeReturnTo if one is ever added', () => { + // The ratchet, not a restatement of today's behaviour: the moment someone + // reads a destination off this URL, the shared validator has to be the + // thing that vets it. Hand-rolled checks here emitted + // `Location: http://evil.com/`. Rejection of absolute and + // protocol-relative values is covered by + // lib/auth/__tests__/safe-return-to.test.ts. + const readsDestination = /searchParams\.get\('(next|returnTo|redirect|redirectTo)'\)/.test(CODE) + if (readsDestination) { + expect(CODE).toContain("from '@/lib/auth/safe-return-to'") + expect(CODE).toMatch(/safeReturnTo\(\s*searchParams\.get\('(next|returnTo|redirect|redirectTo)'\)/) + } + }) +}) + +describe('the wording the locked field renders', () => { + const messages = (locale: 'sv' | 'en') => + JSON.parse( + fs.readFileSync(path.resolve(__dirname, `../../../../messages/${locale}.json`), 'utf8'), + ) as { register: Record } + + for (const locale of ['sv', 'en'] as const) { + it(`has both email hints in ${locale}.json`, () => { + const register = messages(locale).register + expect(register.invite_email_hint).toBeTruthy() + expect(register.bankid_email_hint).toBeTruthy() + }) + } +}) diff --git a/app/(auth)/register/page.tsx b/app/(auth)/register/page.tsx index bee67af4..21446180 100644 --- a/app/(auth)/register/page.tsx +++ b/app/(auth)/register/page.tsx @@ -17,6 +17,10 @@ import { isBankIdEnabled } from '@/lib/auth/bankid' import type { BankIdResult } from '@/components/auth/BankIdAuth' import { getBranding } from '@/lib/branding/service' import { detectWebmailHint } from '@/lib/auth/webmail-search' +import { + consumeInviteCookie, + INVITE_PROBLEM_MESSAGE_KEYS, +} from '@/lib/auth/consume-invite-cookie' import { AuthPageSkeleton } from '@/components/auth/AuthPageSkeleton' const branding = getBranding() @@ -35,6 +39,17 @@ export default function RegisterPage() { } function RegisterPageContent() { + // `invite` is the only query parameter this page reads. It deliberately does + // NOT read `next`: nothing links here with one (bounceToAuth in + // lib/supabase/middleware.ts targets /login and the two MFA pages only, and + // app/invite/[token]/page.tsx sends `?invite=`), the already-signed-in case + // is handled in the middleware behind safeReturnTo, and neither signup path + // has a destination to spend it on: the password path leaves through the + // confirmation mail and /auth/callback, and the BankID path must land a + // brand-new account on '/' or /select-company rather than a deep link it has + // no membership for. If a destination is ever wanted here it MUST go through + // safeReturnTo (lib/auth/safe-return-to.ts); a hand-rolled check on this + // value is an open redirect. const searchParams = useSearchParams() const [email, setEmail] = useState('') const [password, setPassword] = useState('') @@ -51,10 +66,35 @@ function RegisterPageContent() { const supabase = createClient() const bankIdEnabled = isBankIdEnabled() const t = useTranslations('register') + const tInvite = useTranslations('invite') const errorLocale = useLocale() as ErrorLocale + // Accept a pending invite, if any, and report a non-definitive failure. + // Returns true when the caller should land the user in the app directly. + // The invite cookie survives anything that is not a settled outcome, so + // /onboarding and /select-company can retry acceptance server-side. + const acceptPendingInvite = async (): Promise => { + const invite = await consumeInviteCookie() + if (invite.accepted) return true + if (invite.problem) { + const keys = INVITE_PROBLEM_MESSAGE_KEYS[invite.problem] + toast({ + title: tInvite(keys.title), + description: tInvite(keys.body), + variant: 'destructive', + }) + } + return false + } + // When arriving from an invite link, fetch the invite info to pre-fill // and lock the email field so the user registers with the correct address. + // BOTH signup forms are pre-filled: the BankID form used to be left blank + // and editable, so an invitee who signed up with BankID typed their private + // address, POST /api/team/accept answered 403 on the email equality check, + // and they landed on /select-company with no membership. The token now + // survives that 403 (lib/auth/consume-invite-cookie.ts) so it is recoverable + // rather than terminal, but the signup should not walk into it at all. useEffect(() => { const inviteToken = searchParams.get('invite') if (!inviteToken) return @@ -65,6 +105,7 @@ function RegisterPageContent() { if (data?.data?.email) { setInviteEmail(data.data.email) setEmail(data.data.email) + setBankIdEmail(data.data.email) } }) .catch(() => {}) @@ -156,27 +197,9 @@ function RegisterPageContent() { // invitee who registers with BankID lands on /select-company with no // membership and gets funneled into creating a company instead of // joining the one they were invited to. - const inviteCookieMatch = document.cookie.match(/gnubok-invite-token=([^;]+)/) - const inviteToken = inviteCookieMatch?.[1] - - if (inviteToken) { - try { - const res = await fetch('/api/team/accept', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ token: inviteToken }), - }) - - if (res.ok) { - document.cookie = 'gnubok-invite-token=; path=/; max-age=0' - window.location.href = '/' - return - } - } catch (err) { - console.error('[register] invite acceptance failed:', err instanceof Error ? err.message : String(err)) - } - // Clear cookie even on failure to avoid retrying stale tokens - document.cookie = 'gnubok-invite-token=; path=/; max-age=0' + if (await acceptPendingInvite()) { + window.location.href = '/' + return } router.push('/select-company') @@ -326,8 +349,15 @@ function RegisterPageContent() {
+ {/* + Plain /login, no `email` parameter: app/(auth)/login/page.tsx + reads only `error`, `flow` and `next`, so the address was + travelling in the URL (browser history, Referer, every proxy + access log) and arriving nowhere. The address is already on + screen above, so nothing is lost by dropping it. + */} @@ -452,11 +482,12 @@ function RegisterPageContent() { value={bankIdEmail} onChange={(e) => setBankIdEmail(e.target.value)} required - disabled={isLoading} + disabled={isLoading || !!inviteEmail} + readOnly={!!inviteEmail} className="h-11" />

- {t('bankid_email_hint')} + {inviteEmail ? t('invite_email_hint') : t('bankid_email_hint')}

+ + + )} + + {isRealInvoice && !isSelfBilled && !deliveriesUnreadable && ( + {/* The archived PDF the customer received could not be produced. Nothing + has been downloaded at this point: a re-render is a different + document, so the user chooses it deliberately or not at all. */} + { + if (!open) setPdfArchiveIssue(null) + }} + > + + + {t('pdf_archive_issue_title')} + + {pdfArchiveIssue === 'document' + ? t('pdf_archive_issue_document_desc') + : t('pdf_archive_issue_history_desc')} + + + + + + + + + + ('') const [report, setReport] = useState(null) - const [preferences, setPreferences] = useState(getDefaultPreferences()) + // null = the stored layout is not known: still loading, or the read failed + // (prefsError). It never holds a fabricated value. + const [preferences, setPreferences] = useState(null) + const [prefsError, setPrefsError] = useState(null) + const [reloadKey, setReloadKey] = useState(0) const [isLoadingReport, setIsLoadingReport] = useState(false) const [isSavingPrefs, setIsSavingPrefs] = useState(false) const [error, setError] = useState(null) + const prefsStatusId = useId() + // A page that could not read the stored layout must not render one, and above + // all must not let the user save over it. The previous version fell back to + // getDefaultPreferences() on any failed read, silently: the grid presented + // the defaults as though they were the user's, the settings dialog seeded + // its draft from them, and the next save PUT a complete defaults-based + // object over the stored row (the route merges key by key, so a payload + // carrying every key replaces the row outright). A transient read failure + // became permanent loss of the user's layout. + // + // So the unknown stays unknown: preferences stays null, the preference-driven + // panes do not render, "Anpassa" is disabled, and one AttnLine says the + // layout could not be read. An expired session (401/403) is the only case + // where the reason changes what the user must do, so there the status-map + // sentence replaces the retry; everything else is transient and the retry is + // the whole answer. useEffect(() => { let cancelled = false ;(async () => { - try { - const res = await fetch('/api/kpi/preferences') - const { data } = await res.json() - if (!cancelled && data) setPreferences(data) - } catch { - // Silently fall back to defaults + setPrefsError(null) + const result = await loadKPIPreferences({ locale }) + if (cancelled) return + if (result.ok) { + setPreferences(result.preferences) + } else { + setPreferences(null) + setPrefsError(result) } })() return () => { cancelled = true } - }, []) + }, [reloadKey, locale]) const fetchReport = useCallback(async (periodId: string) => { setIsLoadingReport(true) @@ -64,60 +95,134 @@ export default function KpiPage() { return () => { cancelled = true } }, [selectedPeriod, fetchReport]) - async function handleSavePreferences(prefs: KPIPreferences) { + /** + * Resolves true only when the row was written. The dialog keeps itself open on + * false, so the draft the user assembled survives a failed save. + * + * The previous version swallowed both the `!res.ok` throw and every thrown + * fetch behind "Silently fail: user can retry": the dialog closed, this grid + * went on rendering the layout the user had just picked, and the next page + * load read the untouched row back and reverted it. Exactly one toast per + * outcome, since TOAST_LIMIT is 1. + */ + async function handleSavePreferences(prefs: KPIPreferences): Promise { + let result setIsSavingPrefs(true) try { - const res = await fetch('/api/kpi/preferences', { - method: 'PUT', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(prefs), - }) - if (!res.ok) throw new Error() - const { data } = await res.json() - setPreferences(data) - if (selectedPeriod) await fetchReport(selectedPeriod) - } catch { - // Silently fail: user can retry + result = await saveKPIPreferences({ preferences: prefs, locale }) } finally { + // The busy state covers the save and nothing else. The report refetch + // below is unbounded and has its own skeleton, and holding `saving` open + // across it would keep the dialog locked on a request that is not the save. setIsSavingPrefs(false) } + + if (!result.ok) { + toast({ + title: t('save_failed_title'), + description: failureDescription(result, { + // A timeout on a write is genuinely ambiguous: the row may have been + // written. Say that instead of claiming the save failed. + timeout: t('save_timeout'), + network: t('save_network'), + }), + variant: 'destructive', + }) + return false + } + + setPreferences(result.preferences) + // Not awaited: the layout is stored, so the dialog closes now. A failing + // refetch reports itself through the `error` surface below. + if (selectedPeriod) void fetchReport(selectedPeriod) + return true } + const isLoadingPrefs = preferences === null && prefsError === null + return (
- -

{t('help_text')}

- - } - action={ -
- - setSelectedPeriod(id || '')} - includeAllOption={false} - hideFuturePeriods - /> -
- } - /> + {/* Header and read-status share one flow child so the always-mounted + live region adds no vertical rhythm while it is empty. */} +
+ +

{t('help_text')}

+ + } + action={ +
+ {preferences ? ( + + ) : ( + // The dialog seeds its draft from `preferences` and saves the + // complete draft, so while the stored layout is unknown the + // control that opens it must not exist: a save from here would + // overwrite the row with a layout the user never chose. + + )} + setSelectedPeriod(id || '')} + includeAllOption={false} + hideFuturePeriods + /> +
+ } + /> + + {/* Live region always mounted so a failed read is announced when it + appears, not merely inserted. Inline, never a toast: TOAST_LIMIT is + 1 and a toast here could evict a save failure's toast. */} +
+ {prefsError && ( + setReloadKey((k) => k + 1) } + } + > + {failureDescription(prefsError, { + timeout: t('load_timeout'), + network: t('load_network'), + })} + + )} +
+
{error && (

{error}

)} - {isLoadingReport && } + {(isLoadingReport || (!error && report !== null && isLoadingPrefs)) && ( + + )} - {!isLoadingReport && !error && report && ( + {!isLoadingReport && !error && report && !isLoadingPrefs && (
- + {/* The panes are the preference-driven surface: with the layout + unknown they stay off rather than render defaults as if they + were the user's. The cost story below reads only the report. */} + {preferences && }
)} diff --git a/app/(dashboard)/reports/kassaflodesanalys/KassaflodesanalysClient.tsx b/app/(dashboard)/reports/kassaflodesanalys/KassaflodesanalysClient.tsx index f0d64514..93132bb9 100644 --- a/app/(dashboard)/reports/kassaflodesanalys/KassaflodesanalysClient.tsx +++ b/app/(dashboard)/reports/kassaflodesanalys/KassaflodesanalysClient.tsx @@ -2,22 +2,36 @@ import Link from 'next/link' import { useEffect, useState, useCallback } from 'react' +import { useLocale, useTranslations } from 'next-intl' import { PageHeader } from '@/components/ui/page-header' import { FyPicker } from '@/components/common/FyPicker' import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' import { Button } from '@/components/ui/button' import { Skeleton } from '@/components/ui/skeleton' import { EmptyState } from '@/components/ui/empty-state' +import { useToast } from '@/components/ui/use-toast' import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger, } from '@/components/ui/info-tooltip' -import { ArrowLeft, Download, FileSpreadsheet, AlertTriangle, CheckCircle2 } from 'lucide-react' +import { + ArrowLeft, + Download, + FileSpreadsheet, + AlertTriangle, + CheckCircle2, + Loader2, +} from 'lucide-react' import { formatDate } from '@/lib/utils' +import { downloadFile } from '@/lib/browser/download-file' +import { failureDescription } from '@/lib/browser/action-failure' import type { KassaflodesanalysReport } from '@/lib/reports/kassaflodesanalys' -import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message' +import { + getErrorMessage as getUserErrorMessage, + type ErrorLocale, +} from '@/lib/errors/get-error-message' function formatAmount(n: number): string { return n.toLocaleString('sv-SE', { @@ -55,10 +69,14 @@ function SubtotalRow({ label, amount }: SubtotalRowProps) { } export function KassaflodesanalysClient() { + const t = useTranslations('reports') + const locale = useLocale() as ErrorLocale + const { toast } = useToast() const [selectedPeriod, setSelectedPeriod] = useState(null) const [report, setReport] = useState(null) const [isLoadingPeriods, setIsLoadingPeriods] = useState(true) const [isLoadingReport, setIsLoadingReport] = useState(false) + const [isDownloadingPdf, setIsDownloadingPdf] = useState(false) const [error, setError] = useState(null) const loadReport = useCallback(async (periodId: string) => { @@ -88,10 +106,60 @@ export function KassaflodesanalysClient() { } }, [selectedPeriod, loadReport]) - const handleDownloadPdf = useCallback(() => { - if (!selectedPeriod) return - window.location.href = `/api/reports/kassaflodesanalys/pdf?period_id=${selectedPeriod}` - }, [selectedPeriod]) + /** + * Fetch the statutory kassaflödesanalys PDF and save it only if the server + * actually produced one. + * + * This used to be `window.location.href = `, which has no seam for + * either half of the problem. On success the browser cancels the navigation + * (the route answers Content-Disposition: attachment) and the click gives no + * feedback at all, so nothing marks the render as in flight: a second click + * starts a second full report query plus renderToBuffer. On failure the route + * answers a JSON error envelope with no Content-Disposition, so the browser + * navigates the whole app away and the user is left staring at raw JSON with + * their report page gone. There is no `res` to check and no promise to catch + * on a location assignment; the only fix is to make the download a bounded + * fetch. + * + * The filename mirrors the route's Content-Disposition + * (kassaflodesanalys-.pdf): both sides derive it from the same + * fiscal period, and the button is disabled until that report is loaded. + * + * No success toast: the saved file is the feedback. On failure nothing was + * written to disk and exactly one toast says why. Never two, TOAST_LIMIT is 1 + * (components/ui/use-toast.tsx), so a second toast in the same tick evicts the + * first and only the last is rendered. + */ + const handleDownloadPdf = useCallback(async () => { + // The button is disabled while a render is in flight; this guard closes the + // double-click / Enter-repeat race before React has re-rendered it. + if (!selectedPeriod || !report || isDownloadingPdf) return + setIsDownloadingPdf(true) + try { + // The shared 15s deadline applies: this is one fiscal year of aggregation + // plus a single-page render with stock fonts, so the realistic worst case + // is a cold start and one round trip. If a healthy render ever needs + // longer, the answer is maxDuration on the route plus a matching + // timeoutMs here, never an unbounded fetch that spins forever. + const result = await downloadFile({ + url: `/api/reports/kassaflodesanalys/pdf?period_id=${selectedPeriod}`, + filename: `kassaflodesanalys-${report.period_start}.pdf`, + locale, + }) + if (!result.ok) { + toast({ + title: t('pdf_download_failed_title'), + description: failureDescription(result, { + timeout: t('pdf_download_timeout'), + network: t('pdf_download_network'), + }), + variant: 'destructive', + }) + } + } finally { + setIsDownloadingPdf(false) + } + }, [selectedPeriod, report, isDownloadingPdf, locale, toast, t]) return (
@@ -116,9 +184,13 @@ export function KassaflodesanalysClient() { diff --git a/app/(dashboard)/salary/employees/[id]/page.tsx b/app/(dashboard)/salary/employees/[id]/page.tsx index 585a68aa..656db559 100644 --- a/app/(dashboard)/salary/employees/[id]/page.tsx +++ b/app/(dashboard)/salary/employees/[id]/page.tsx @@ -26,7 +26,7 @@ import { lookupBankByClearing, checkEmployeeAccountChecksum, } from '@/lib/salary/payment/bank-account' -import type { Employee } from '@/types' +import type { EmployeeMasked } from '@/types' import { EmployeeBenefitsPanel } from '@/components/salary/EmployeeBenefitsPanel' import { OpeningBalancesPanel } from '@/components/salary/OpeningBalancesPanel' import EmployeeTaxCard, { type EmployeeTaxValue } from '@/components/salary/EmployeeTaxCard' @@ -49,7 +49,7 @@ export default function EmployeeDetailPage({ params }: { params: Promise<{ id: s const { toast } = useToast() const { canWrite } = useCanWrite() const { dialogProps, confirm: confirmAction } = useDestructiveConfirm() - const [employee, setEmployee] = useState(null) + const [employee, setEmployee] = useState(null) const [loading, setLoading] = useState(true) const [saving, setSaving] = useState(false) const [deactivating, setDeactivating] = useState(false) @@ -241,7 +241,7 @@ export default function EmployeeDetailPage({ params }: { params: Promise<{ id: s {employee.first_name} {employee.last_name}

- {employee.personnummer} · {t(EMPLOYMENT_LABEL_KEYS[employee.employment_type])} + {employee.personnummer_masked} · {t(EMPLOYMENT_LABEL_KEYS[employee.employment_type])}

@@ -416,9 +416,10 @@ export default function EmployeeDetailPage({ params }: { params: Promise<{ id: s )} - {/* Tax */} + {/* Tax. The masked personnummer is enough input here: EmployeeTaxCard + only reads the leading birthdate digits to suggest a tax column. */} import('@/components/salary/NewEmployeeDialog'), @@ -33,7 +33,7 @@ const EMPLOYMENT_LABEL_KEYS: Record = { */ export default function EmployeesPage() { const t = useTranslations('employees') - const [employees, setEmployees] = useState([]) + const [employees, setEmployees] = useState([]) const [loading, setLoading] = useState(true) const { canWrite } = useCanWrite() const router = useRouter() @@ -120,7 +120,7 @@ export default function EmployeesPage() { - {emp.personnummer} + {emp.personnummer_masked} {EMPLOYMENT_LABEL_KEYS[emp.employment_type] diff --git a/app/(dashboard)/salary/page.tsx b/app/(dashboard)/salary/page.tsx index 540e64cb..04da9265 100644 --- a/app/(dashboard)/salary/page.tsx +++ b/app/(dashboard)/salary/page.tsx @@ -14,7 +14,7 @@ import { useToast } from '@/components/ui/use-toast' import { useCanWrite } from '@/lib/hooks/use-can-write' import { getErrorMessage } from '@/lib/errors/get-error-message' import { cn, formatCurrency, formatDate } from '@/lib/utils' -import type { Employee, SalaryRun } from '@/types' +import type { EmployeeMasked, SalaryRun } from '@/types' const STATUS_LABEL_KEYS: Record = { draft: 'status_draft', @@ -46,7 +46,7 @@ const STATUS_VARIANTS: Record([]) - const [employees, setEmployees] = useState([]) + const [employees, setEmployees] = useState([]) const [loading, setLoading] = useState(true) const [starting, setStarting] = useState(false) const { canWrite } = useCanWrite() diff --git a/app/(dashboard)/salary/runs/[id]/employees/[employeeId]/page.tsx b/app/(dashboard)/salary/runs/[id]/employees/[employeeId]/page.tsx index 0ca4ecf7..7b75c18e 100644 --- a/app/(dashboard)/salary/runs/[id]/employees/[employeeId]/page.tsx +++ b/app/(dashboard)/salary/runs/[id]/employees/[employeeId]/page.tsx @@ -9,7 +9,7 @@ import { Button } from '@/components/ui/button' import { SalaryCalendar } from '@/components/salary/SalaryCalendar' import { SalaryOverridePanel } from '@/components/salary/SalaryOverridePanel' import { formatCurrency } from '@/lib/utils' -import type { SalaryRun, SalaryRunEmployee, SalaryLineItem, SalaryLineItemType, Employee } from '@/types' +import type { SalaryRun, SalaryRunEmployee, SalaryLineItem, SalaryLineItemType, EmployeeMasked } from '@/types' import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message' /** Translation keys in the `salary_run_employee` namespace. */ @@ -55,7 +55,7 @@ const LINE_ITEM_TYPE_KEYS: Record = { interface DetailResponse { run: SalaryRun - runEmployee: SalaryRunEmployee & { employee: Employee; line_items: SalaryLineItem[] } + runEmployee: SalaryRunEmployee & { employee: EmployeeMasked; line_items: SalaryLineItem[] } } export default function SalaryRunEmployeeDetailPage({ @@ -81,10 +81,21 @@ export default function SalaryRunEmployeeDetailPage({ fetch(`/api/salary/runs/${runId}`), fetch(`/api/salary/runs/${runId}/employees/${employeeId}`), ]) - const runJson = await runRes.json() - const sreJson = await sreRes.json() - if (!runRes.ok) throw new Error(runJson.error || t('error_load_run')) - if (!sreRes.ok) throw new Error(sreJson.error || t('error_load_employee')) + const runJson = await runRes.json().catch(() => null) + const sreJson = await sreRes.json().catch(() => null) + // Map the parsed body plus the status, never `new Error(json.error)`: + // the routes answer thrown errors with the canonical envelope + // `{ error: { code, message } }`, and the Error constructor stringifies + // that object to "[object Object]", which falls through to the generic + // "Något gick fel" and discards the route's own Swedish reason. + if (!runRes.ok) { + setError(getUserErrorMessage(runJson, { statusCode: runRes.status })) + return + } + if (!sreRes.ok) { + setError(getUserErrorMessage(sreJson, { statusCode: sreRes.status })) + return + } setData({ run: runJson.data, runEmployee: sreJson.data }) } catch (e) { setError(e instanceof Error ? getUserErrorMessage(e) : t('unknown_error')) @@ -103,9 +114,12 @@ export default function SalaryRunEmployeeDetailPage({ setError(null) try { const res = await fetch(`/api/salary/runs/${runId}/calculate`, { method: 'POST' }) - const json = await res.json().catch(() => ({})) + const json = await res.json().catch(() => null) if (!res.ok) { - throw new Error(json.error || t('error_calculate')) + // Same reason as in load(): the calculate route builds its refusals + // with errorResponse(), so `json.error` is the envelope object. + setError(getUserErrorMessage(json, { statusCode: res.status })) + return } await load() } catch (e) { @@ -176,7 +190,7 @@ export default function SalaryRunEmployeeDetailPage({ {employee.first_name} {employee.last_name}

- {employee.personnummer} · {t('payslip_period', { period: periodLabel })} + {employee.personnummer_masked} · {t('payslip_period', { period: periodLabel })}

{run.status === 'draft' && ( diff --git a/app/(dashboard)/salary/runs/[id]/page.tsx b/app/(dashboard)/salary/runs/[id]/page.tsx index 86adfe01..28a49838 100644 --- a/app/(dashboard)/salary/runs/[id]/page.tsx +++ b/app/(dashboard)/salary/runs/[id]/page.tsx @@ -22,7 +22,15 @@ import { import { useToast } from '@/components/ui/use-toast' import { useCanWrite } from '@/lib/hooks/use-can-write' import { useAgiSubmission } from '@/lib/hooks/use-agi-submission' -import { deriveAgiFilingState } from '@/lib/salary/agi-submission-state' +import { + deriveAgiFilingState, + resolveRunAgiKvittensnummer, +} from '@/lib/salary/agi-submission-state' +import { + buildPayslipZipReport, + payslipEmployeeLabel, + type PayslipZipAttempt, +} from '@/lib/salary/payslip-zip-report' import { getErrorMessage } from '@/lib/errors/get-error-message' import { AGIPanel } from '@/components/salary/AGIPanel' import { PaymentFilePanel } from '@/components/salary/PaymentFilePanel' @@ -34,7 +42,7 @@ import { RunEmployeesTable } from '@/components/salary/run/RunEmployeesTable' import { RunCalculationDetails } from '@/components/salary/run/RunCalculationDetails' import { RunJournalPreview, type PreviewData } from '@/components/salary/run/RunJournalPreview' import { periodLabelOf, type RunDetail } from '@/components/salary/run/types' -import type { Employee, SalaryRunEmployee } from '@/types' +import type { EmployeeMasked, SalaryRunEmployee } from '@/types' export default function SalaryRunPage({ params }: { params: Promise<{ id: string }> }) { const { id } = use(params) @@ -45,7 +53,7 @@ export default function SalaryRunPage({ params }: { params: Promise<{ id: string const { dialogProps, confirm: confirmAction } = useDestructiveConfirm() const [run, setRun] = useState(null) - const [availableEmployees, setAvailableEmployees] = useState([]) + const [availableEmployees, setAvailableEmployees] = useState([]) const [preview, setPreview] = useState(null) const [loading, setLoading] = useState(true) const [actionLoading, setActionLoading] = useState(null) @@ -157,30 +165,41 @@ export default function SalaryRunPage({ params }: { params: Promise<{ id: string } }, [id, isCalculatedForPreview, run?.total_gross, run?.total_tax, run?.total_avgifter]) + // Every handler below releases actionLoading in a finally: the flag gates the + // header button, the progress rail and the employee table, so a rejected + // fetch that skipped the release froze the whole page until a reload. async function handleAction(action: string, method: string = 'POST') { setActionLoading(action) - const res = await fetch(`/api/salary/runs/${id}/${action}`, { method }) - if (res.ok) { - // Optimistic: the status-transition endpoints return the updated run row. - // Merge it in immediately so the screen flips without waiting for the - // heavy detail refetch, then reconcile in the background. This is what - // makes "Till granskning" / "Godkänn" feel instant. - const payload = await res.json().catch(() => null) - if (payload?.data) { - setRun(prev => (prev ? { ...prev, ...payload.data } : prev)) + try { + const res = await fetch(`/api/salary/runs/${id}/${action}`, { method }) + if (res.ok) { + // Optimistic: the status-transition endpoints return the updated run row. + // Merge it in immediately so the screen flips without waiting for the + // heavy detail refetch, then reconcile in the background. This is what + // makes "Till granskning" / "Godkänn" feel instant. + const payload = await res.json().catch(() => null) + if (payload?.data) { + setRun(prev => (prev ? { ...prev, ...payload.data } : prev)) + } + toast({ title: t('toast_status_updated') }) + loadRun() // background reconcile - not awaited + return } + const result = await res.json().catch(() => ({})) + toast({ + title: t('toast_status_failed'), + description: getErrorMessage(result, { context: 'salary', statusCode: res.status }), + variant: 'destructive', + }) + } catch (err) { + toast({ + title: t('toast_status_failed'), + description: err instanceof Error ? getErrorMessage(err) : t('unknown_error'), + variant: 'destructive', + }) + } finally { setActionLoading(null) - toast({ title: t('toast_status_updated') }) - loadRun() // background reconcile - not awaited - return } - const result = await res.json().catch(() => ({})) - toast({ - title: t('toast_status_failed'), - description: getErrorMessage(result, { context: 'salary', statusCode: res.status }), - variant: 'destructive', - }) - setActionLoading(null) } // Approval is an authorization step. Missing bank details are an *overridable* @@ -189,38 +208,46 @@ export default function SalaryRunPage({ params }: { params: Promise<{ id: string // the user chooses "Godkänn ändå". The payment-file step still hard-blocks. async function doApprove(force: boolean) { setActionLoading('approve') - const res = await fetch(`/api/salary/runs/${id}/approve${force ? '?force=true' : ''}`, { - method: 'POST', - }) - if (res.ok) { - setApproveOverride(null) - const payload = await res.json().catch(() => null) - if (payload?.data) { - setRun(prev => (prev ? { ...prev, ...payload.data } : prev)) + try { + const res = await fetch(`/api/salary/runs/${id}/approve${force ? '?force=true' : ''}`, { + method: 'POST', + }) + if (res.ok) { + setApproveOverride(null) + const payload = await res.json().catch(() => null) + if (payload?.data) { + setRun(prev => (prev ? { ...prev, ...payload.data } : prev)) + } + toast({ title: t('toast_status_updated') }) + loadRun() // background reconcile - not awaited + return } + const result = await res.json().catch(() => ({})) + // Overridable → open the confirm dialog instead of toasting the error. + if ( + !force && + result?.code === 'SALARY_APPROVE_BANK_DETAILS_MISSING' && + Array.isArray(result.details) + ) { + setApproveOverride(result.details as string[]) + return + } + setApproveOverride(null) + toast({ + title: t('toast_status_failed'), + description: getErrorMessage(result, { context: 'salary', statusCode: res.status }), + variant: 'destructive', + }) + } catch (err) { + setApproveOverride(null) + toast({ + title: t('toast_status_failed'), + description: err instanceof Error ? getErrorMessage(err) : t('unknown_error'), + variant: 'destructive', + }) + } finally { setActionLoading(null) - toast({ title: t('toast_status_updated') }) - loadRun() // background reconcile - not awaited - return } - const result = await res.json().catch(() => ({})) - // Overridable → open the confirm dialog instead of toasting the error. - if ( - !force && - result?.code === 'SALARY_APPROVE_BANK_DETAILS_MISSING' && - Array.isArray(result.details) - ) { - setApproveOverride(result.details as string[]) - setActionLoading(null) - return - } - setApproveOverride(null) - toast({ - title: t('toast_status_failed'), - description: getErrorMessage(result, { context: 'salary', statusCode: res.status }), - variant: 'destructive', - }) - setActionLoading(null) } // Recall an approval (approved → review). Approval is only an internal @@ -255,18 +282,29 @@ export default function SalaryRunPage({ params }: { params: Promise<{ id: string }) if (!ok) return setActionLoading('delete') - const res = await fetch(`/api/salary/runs/${id}`, { method: 'DELETE' }) - if (res.ok) { - toast({ title: t('toast_draft_deleted') }) - router.push('/salary') - return + // No finally here: the success path navigates away and deliberately leaves + // the buttons disabled for the duration of the route change. Every path + // that stays on the page releases the flag. + try { + const res = await fetch(`/api/salary/runs/${id}`, { method: 'DELETE' }) + if (res.ok) { + toast({ title: t('toast_draft_deleted') }) + router.push('/salary') + return + } + const result = await res.json().catch(() => ({})) + toast({ + title: t('toast_delete_failed'), + description: getErrorMessage(result, { context: 'salary', statusCode: res.status }), + variant: 'destructive', + }) + } catch (err) { + toast({ + title: t('toast_delete_failed'), + description: err instanceof Error ? getErrorMessage(err) : t('unknown_error'), + variant: 'destructive', + }) } - const result = await res.json().catch(() => ({})) - toast({ - title: t('toast_delete_failed'), - description: getErrorMessage(result, { context: 'salary', statusCode: res.status }), - variant: 'destructive', - }) setActionLoading(null) } @@ -274,41 +312,60 @@ export default function SalaryRunPage({ params }: { params: Promise<{ id: string // RunHeader; this fires only after the user has confirmed there. async function handleCorrect() { setActionLoading('correct') - const res = await fetch(`/api/salary/runs/${id}/correct`, { method: 'POST' }) - if (res.ok) { - const { data } = await res.json() - toast({ title: t('toast_correction_created'), description: t('toast_correction_description') }) - router.push(`/salary/runs/${data.id}`) - return + // As in handleDelete: the success path navigates to the new run and keeps + // the buttons disabled meanwhile; every path that stays here releases. + try { + const res = await fetch(`/api/salary/runs/${id}/correct`, { method: 'POST' }) + if (res.ok) { + const { data } = await res.json() + toast({ title: t('toast_correction_created'), description: t('toast_correction_description') }) + router.push(`/salary/runs/${data.id}`) + return + } + const result = await res.json().catch(() => ({})) + toast({ + title: t('toast_correction_failed'), + description: getErrorMessage(result, { context: 'salary', statusCode: res.status }), + variant: 'destructive', + }) + } catch (err) { + toast({ + title: t('toast_correction_failed'), + description: err instanceof Error ? getErrorMessage(err) : t('unknown_error'), + variant: 'destructive', + }) } - const result = await res.json().catch(() => ({})) - toast({ - title: t('toast_correction_failed'), - description: getErrorMessage(result, { context: 'salary', statusCode: res.status }), - variant: 'destructive', - }) setActionLoading(null) } async function handleAddEmployee(employeeId: string) { setActionLoading('add-employee') - const res = await fetch(`/api/salary/runs/${id}/employees`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ employee_id: employeeId }), - }) - if (res.ok) { - await loadRun() - toast({ title: t('toast_employee_added') }) - } else { - const result = await res.json().catch(() => ({})) + try { + const res = await fetch(`/api/salary/runs/${id}/employees`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ employee_id: employeeId }), + }) + if (res.ok) { + await loadRun() + toast({ title: t('toast_employee_added') }) + } else { + const result = await res.json().catch(() => ({})) + toast({ + title: t('toast_add_employee_failed'), + description: getErrorMessage(result, { context: 'salary', statusCode: res.status }), + variant: 'destructive', + }) + } + } catch (err) { toast({ title: t('toast_add_employee_failed'), - description: getErrorMessage(result, { context: 'salary', statusCode: res.status }), + description: err instanceof Error ? getErrorMessage(err) : t('unknown_error'), variant: 'destructive', }) + } finally { + setActionLoading(null) } - setActionLoading(null) } // Remove an employee from a draft run. The DELETE endpoint is draft-only and @@ -322,21 +379,30 @@ export default function SalaryRunPage({ params }: { params: Promise<{ id: string }) if (!ok) return setActionLoading(`remove-${employeeId}`) - const res = await fetch(`/api/salary/runs/${id}/employees/${employeeId}`, { - method: 'DELETE', - }) - if (res.ok) { - await loadRun() - toast({ title: t('toast_employee_removed') }) - } else { - const result = await res.json() + try { + const res = await fetch(`/api/salary/runs/${id}/employees/${employeeId}`, { + method: 'DELETE', + }) + if (res.ok) { + await loadRun() + toast({ title: t('toast_employee_removed') }) + } else { + const result = await res.json().catch(() => ({})) + toast({ + title: t('toast_remove_employee_failed'), + description: getErrorMessage(result, { context: 'salary', statusCode: res.status }), + variant: 'destructive', + }) + } + } catch (err) { toast({ title: t('toast_remove_employee_failed'), - description: getErrorMessage(result, { context: 'salary', statusCode: res.status }), + description: err instanceof Error ? getErrorMessage(err) : t('unknown_error'), variant: 'destructive', }) + } finally { + setActionLoading(null) } - setActionLoading(null) } // Edit this month's monthly salary for one employee (draft only). The engine @@ -346,89 +412,127 @@ export default function SalaryRunPage({ params }: { params: Promise<{ id: string const monthly = Number(raw.replace(/\s/g, '').replace(',', '.')) if (!Number.isFinite(monthly) || monthly < 0 || monthly === previous) return setActionLoading(`salary-${employeeId}`) - const res = await fetch(`/api/salary/runs/${id}/employees/${employeeId}`, { - method: 'PATCH', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ monthly_salary: monthly }), - }) - if (res.ok) { - await loadRun() - toast({ title: t('toast_salary_updated'), description: t('toast_salary_updated_hint') }) - } else { - const result = await res.json() + try { + const res = await fetch(`/api/salary/runs/${id}/employees/${employeeId}`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ monthly_salary: monthly }), + }) + if (res.ok) { + await loadRun() + toast({ title: t('toast_salary_updated'), description: t('toast_salary_updated_hint') }) + } else { + const result = await res.json().catch(() => ({})) + toast({ + title: t('toast_salary_update_failed'), + description: getErrorMessage(result, { context: 'salary', statusCode: res.status }), + variant: 'destructive', + }) + } + } catch (err) { toast({ title: t('toast_salary_update_failed'), - description: getErrorMessage(result, { context: 'salary', statusCode: res.status }), + description: err instanceof Error ? getErrorMessage(err) : t('unknown_error'), variant: 'destructive', }) + } finally { + setActionLoading(null) } - setActionLoading(null) } async function handleCalculate() { setActionLoading('calculate') - const res = await fetch(`/api/salary/runs/${id}/calculate`, { method: 'POST' }) - if (res.ok) { - const payload = await res.json() - await loadRun() - const warnings = (payload.warnings as string[] | undefined) ?? [] - if (warnings.length === 0) { - toast({ title: t('toast_calculation_done') }) - } else { - for (const warning of warnings) { - toast({ title: t('toast_calculation_warning'), description: warning }) + try { + const res = await fetch(`/api/salary/runs/${id}/calculate`, { method: 'POST' }) + if (res.ok) { + const payload = await res.json().catch(() => ({})) + await loadRun() + const warnings = (payload.warnings as string[] | undefined) ?? [] + if (warnings.length === 0) { + toast({ title: t('toast_calculation_done') }) + } else { + for (const warning of warnings) { + toast({ title: t('toast_calculation_warning'), description: warning }) + } } + } else { + const result = await res.json().catch(() => ({})) + toast({ + title: t('toast_calculation_failed'), + description: getErrorMessage(result, { context: 'salary', statusCode: res.status }), + variant: 'destructive', + }) } - } else { - const result = await res.json() + } catch (err) { toast({ title: t('toast_calculation_failed'), - description: getErrorMessage(result, { context: 'salary', statusCode: res.status }), + description: err instanceof Error ? getErrorMessage(err) : t('unknown_error'), variant: 'destructive', }) + } finally { + setActionLoading(null) } - setActionLoading(null) } async function handlePreview() { setActionLoading('preview') - const res = await fetch(`/api/salary/runs/${id}/preview`) - if (res.ok) { - const { data } = await res.json() - setPreview(data) + try { + const res = await fetch(`/api/salary/runs/${id}/preview`) + if (res.ok) { + const { data } = await res.json() + setPreview(data) + } + } catch { + // The preview is a read-only convenience; the auto-load effect retries on + // the next calculation. Nothing to report, but the flag must be released. + } finally { + setActionLoading(null) } - setActionLoading(null) } async function handleSendPayslips() { setActionLoading('payslips-send') - const res = await fetch(`/api/salary/runs/${id}/payslips/send`, { method: 'POST' }) - if (res.ok) { - const { data } = await res.json() - await loadRun() - toast({ - title: t('toast_payslips_sent'), - description: t('toast_payslips_sent_detail', { - sent: data.sent, - skipped: data.skipped, - }), - }) - if (data.errors?.length) { - for (const err of data.errors as string[]) { - toast({ title: t('toast_payslip_error'), description: err, variant: 'destructive' }) + try { + const res = await fetch(`/api/salary/runs/${id}/payslips/send`, { method: 'POST' }) + if (res.ok) { + const { data } = await res.json() + await loadRun() + toast({ + title: t('toast_payslips_sent'), + description: t('toast_payslips_sent_detail', { + sent: data.sent, + skipped: data.skipped, + }), + }) + if (data.errors?.length) { + for (const err of data.errors as string[]) { + toast({ title: t('toast_payslip_error'), description: err, variant: 'destructive' }) + } } + } else { + const result = await res.json().catch(() => ({})) + toast({ + title: t('toast_payslips_send_failed'), + description: getErrorMessage(result, { context: 'salary', statusCode: res.status }), + variant: 'destructive', + }) } - } else { - const result = await res.json() + } catch (err) { toast({ title: t('toast_payslips_send_failed'), - description: getErrorMessage(result, { context: 'salary', statusCode: res.status }), + description: err instanceof Error ? getErrorMessage(err) : t('unknown_error'), variant: 'destructive', }) + } finally { + setActionLoading(null) } - setActionLoading(null) } + // Bulk payslip archive. One PDF request per employee, so a single failure + // must not take the batch down - but it must not vanish either: the employer + // hands out what the ZIP contains and has no other signal that someone is + // missing from it. Every outcome is recorded per employee and reported by + // name; a short archive is never presented as a complete one. async function handleBulkPayslipDownload() { if (!run) return setActionLoading('bulk_payslip') @@ -436,24 +540,45 @@ export default function SalaryRunPage({ params }: { params: Promise<{ id: string const { default: JSZip } = await import('jszip') const zip = new JSZip() const periodLabel = periodLabelOf(run) - let added = 0 + const attempts: PayslipZipAttempt[] = [] for (const sre of employees) { const employee = (sre as SalaryRunEmployee & { employee?: { first_name: string; last_name: string } }).employee - const res = await fetch(`/api/salary/runs/${id}/payslips/${sre.employee_id}/pdf`) - if (!res.ok) continue - const blob = await res.blob() - const name = employee - ? `${employee.last_name}_${employee.first_name}`.replace(/[^A-Za-z0-9_-]/g, '_') - : sre.employee_id.slice(0, 8) - zip.file(`Lonespec_${periodLabel}_${name}.pdf`, blob) - added++ + let ok = false + try { + const res = await fetch(`/api/salary/runs/${id}/payslips/${sre.employee_id}/pdf`) + if (res.ok) { + const blob = await res.blob() + const fileName = employee + ? `${employee.last_name}_${employee.first_name}`.replace(/[^A-Za-z0-9_-]/g, '_') + : sre.employee_id.slice(0, 8) + zip.file(`Lonespec_${periodLabel}_${fileName}.pdf`, blob) + ok = true + } + } catch { + // A network-level failure for this one employee counts exactly like a + // non-ok response: missing from the archive, named in the report. + } + attempts.push({ name: payslipEmployeeLabel(sre.employee_id, employee), ok }) } - if (added === 0) { + + const report = buildPayslipZipReport(attempts) + + // Nothing to archive: no file is produced, so say so and stop. + if (report.outcome === 'empty') { toast({ title: t('toast_payslips_download_empty'), variant: 'destructive' }) return } + if (report.outcome === 'none') { + toast({ + title: t('toast_payslips_download_empty'), + description: t('toast_payslips_download_none_detail', { total: report.total }), + variant: 'destructive', + }) + return + } + const archive = await zip.generateAsync({ type: 'blob' }) const url = URL.createObjectURL(archive) const a = document.createElement('a') @@ -463,7 +588,32 @@ export default function SalaryRunPage({ params }: { params: Promise<{ id: string a.click() document.body.removeChild(a) URL.revokeObjectURL(url) - toast({ title: t('toast_payslips_downloaded'), description: t('toast_payslips_downloaded_detail', { count: added }) }) + + // The toaster holds one toast at a time: a warning emitted next to a + // success toast is evicted and never rendered. So a partial archive gets + // a single destructive toast naming who is missing, not success + warning. + if (report.outcome === 'partial') { + const shown = report.missingShown.join(', ') + const names = + report.missingOverflow > 0 + ? t('toast_payslips_download_more', { names: shown, count: report.missingOverflow }) + : shown + toast({ + title: t('toast_payslips_download_partial'), + description: t('toast_payslips_download_partial_detail', { + added: report.added, + total: report.total, + names, + }), + variant: 'destructive', + }) + return + } + + toast({ + title: t('toast_payslips_downloaded'), + description: t('toast_payslips_downloaded_detail', { count: report.added }), + }) } catch (err) { toast({ title: t('toast_zip_failed'), @@ -475,33 +625,44 @@ export default function SalaryRunPage({ params }: { params: Promise<{ id: string } } + // actionLoading gates this button, the progress rail and the employee table, + // so it has to be released on every path. A rejected fetch (or a blob/read + // failure) used to leave it set and freeze the page until a full reload. async function handleDownloadAgi() { if (!run) return setActionLoading('agi-download') - const res = await fetch(`/api/salary/runs/${id}/agi/xml`) - if (!res.ok) { - const result = await res.json().catch(() => ({ error: t('toast_agi_failed') })) + try { + const res = await fetch(`/api/salary/runs/${id}/agi/xml`) + if (!res.ok) { + const result = await res.json().catch(() => ({ error: t('toast_agi_failed') })) + toast({ + title: t('toast_agi_failed'), + description: getErrorMessage(result, { context: 'salary', statusCode: res.status }), + variant: 'destructive', + }) + return + } + const blob = await res.blob() + const url = URL.createObjectURL(blob) + const compactPeriod = `${run.period_year}${String(run.period_month).padStart(2, '0')}` + const a = document.createElement('a') + a.href = url + a.download = `AGI_${compactPeriod}.xml` + document.body.appendChild(a) + a.click() + document.body.removeChild(a) + URL.revokeObjectURL(url) + await loadRun() + toast({ title: t('toast_agi_downloaded') }) + } catch (err) { toast({ title: t('toast_agi_failed'), - description: getErrorMessage(result, { context: 'salary', statusCode: res.status }), + description: err instanceof Error ? getErrorMessage(err) : t('unknown_error'), variant: 'destructive', }) + } finally { setActionLoading(null) - return } - const blob = await res.blob() - const url = URL.createObjectURL(blob) - const compactPeriod = `${run.period_year}${String(run.period_month).padStart(2, '0')}` - const a = document.createElement('a') - a.href = url - a.download = `AGI_${compactPeriod}.xml` - document.body.appendChild(a) - a.click() - document.body.removeChild(a) - URL.revokeObjectURL(url) - await loadRun() - toast({ title: t('toast_agi_downloaded') }) - setActionLoading(null) } if (loading) { @@ -536,6 +697,11 @@ export default function SalaryRunPage({ params }: { params: Promise<{ id: string // Real AGI filing state: run-row timestamps + the extension's submission // record. Falls back gracefully when the extension is unavailable. const agiState = deriveAgiFilingState(run, agiSubmission) + // The submission record is cached per PERIOD, but a corrected month holds two + // runs and each files its own complete replacement declaration, so each gets + // its own kvittens from Skatteverket. Resolving the record against this run + // keeps the rail from labelling one run's step with the sibling's receipt. + const agiKvittensnummer = resolveRunAgiKvittensnummer(run, agiSubmission) // Advancing a draft to review. For a nollkörning confirm first: an empty // declaration is filed to Skatteverket, which should be deliberate. @@ -586,7 +752,7 @@ export default function SalaryRunPage({ params }: { params: Promise<{ id: string isCalculated={isCalculated} noPayout={noPayout} agiState={agiState} - agiKvittensnummer={agiSubmission?.kvittensnummer ?? null} + agiKvittensnummer={agiKvittensnummer} canWrite={canWrite} actionLoading={actionLoading} primaryAction={primaryAction} diff --git a/app/(dashboard)/skattekonto/page.tsx b/app/(dashboard)/skattekonto/page.tsx index 1b280943..38c65bc8 100644 --- a/app/(dashboard)/skattekonto/page.tsx +++ b/app/(dashboard)/skattekonto/page.tsx @@ -188,7 +188,15 @@ export default function SkattekontoPage() { } return } - throw new Error(json.error || 'Synk misslyckades') + // Map the parsed body plus the status, never `new Error(json.error)`: + // the Error constructor stringifies a non-string body field, and the + // mapper would discard the route's own Swedish reason. + toast({ + title: 'Synk misslyckades', + description: getUserErrorMessage(json, { statusCode: res.status }), + variant: 'destructive', + }) + return } setReconnectMessage(null) toast({ @@ -216,7 +224,12 @@ export default function SkattekontoPage() { ) const json = await res.json() if (!res.ok) { - throw new Error(json.error || 'Bokföring misslyckades') + toast({ + title: 'Kunde inte bokföra', + description: getUserErrorMessage(json, { statusCode: res.status }), + variant: 'destructive', + }) + return } toast({ title: 'Utkast skapat', @@ -245,7 +258,13 @@ export default function SkattekontoPage() { ) const json = await res.json() if (!res.ok) { - throw new Error(json.error || 'Kunde inte söka kandidater') + toast({ + title: 'Kunde inte hämta kandidater', + description: getUserErrorMessage(json, { statusCode: res.status }), + variant: 'destructive', + }) + setMatchOpenFor(null) + return } setMatchCandidates(json.data.candidates as MatchCandidate[]) } catch (err) { @@ -274,7 +293,12 @@ export default function SkattekontoPage() { ) const json = await res.json() if (!res.ok) { - throw new Error(json.error || 'Matchning misslyckades') + toast({ + title: 'Kunde inte koppla transaktionen', + description: getUserErrorMessage(json, { statusCode: res.status }), + variant: 'destructive', + }) + return } toast({ title: 'Transaktion kopplad till verifikat' }) setMatchOpenFor(null) diff --git a/app/(dashboard)/transactions/page.tsx b/app/(dashboard)/transactions/page.tsx index 549ac248..48f681ba 100644 --- a/app/(dashboard)/transactions/page.tsx +++ b/app/(dashboard)/transactions/page.tsx @@ -1824,7 +1824,15 @@ export default function TransactionsPage() { ) const json = await res.json() if (!res.ok) { - throw new Error(json.error || 'Bokföring misslyckades') + // Map the parsed body plus the status, never `new Error(json.error)`: + // the Error constructor stringifies a non-string body field, and the + // mapper would discard the route's own Swedish reason. + toast({ + title: 'Kunde inte bokföra', + description: getErrorMessage(json, { statusCode: res.status }), + variant: 'destructive', + }) + return } toast({ title: 'Utkast skapat', diff --git a/app/(onboarding)/onboarding/agent/page.tsx b/app/(onboarding)/onboarding/agent/page.tsx index dcdffc5f..44d299a8 100644 --- a/app/(onboarding)/onboarding/agent/page.tsx +++ b/app/(onboarding)/onboarding/agent/page.tsx @@ -5,6 +5,7 @@ import { getActiveCompanyId } from '@/lib/company/context' import { hasCapability } from '@/lib/entitlements/has-capability' import { CAPABILITY } from '@/lib/entitlements/keys' import { ensureTicSnapshot } from '@/lib/agent/composer/tic-fetch' +import { employeeFieldValue, loadActiveEmployeeCount } from '@/lib/agent/composer/employee-facts' import AgentOnboarding from '@/components/onboarding/agent/AgentOnboarding' export const dynamic = 'force-dynamic' @@ -41,12 +42,13 @@ export default async function AgentOnboardingPage() { { data: profile }, { data: existingProfile }, { data: atomRows }, + activeEmployees, hdrs, ] = await Promise.all([ supabase .from('company_settings') .select( - 'is_sandbox, city, address_line1, postal_code, f_skatt, vat_registered, moms_period, fiscal_year_start_month, employee_count, has_employees', + 'is_sandbox, city, address_line1, postal_code, f_skatt, vat_registered, moms_period, fiscal_year_start_month, employer_registered, pays_salaries', ) .eq('company_id', companyId) .maybeSingle(), @@ -61,6 +63,9 @@ export default async function AgentOnboardingPage() { .select('id, title') .eq('is_active', true) .is('parent_atom_id', null), // skill titles only; reference children never appear as profile chips + // The only source that actually knows the headcount. Cheap: a head-only + // count served by idx_employees_active, and it rides the same batch. + loadActiveEmployeeCount(supabase, companyId).catch(() => null), headers(), ]) @@ -101,7 +106,7 @@ export default async function AgentOnboardingPage() { const firstName = profile?.full_name?.split(' ')[0] ?? null // Pre-render-friendly snapshot of company info: used to seed Phase B fields // before the stream completes so the layout doesn't jump. - const initialFields = buildInitialFields(company, settings) + const initialFields = buildInitialFields(company, settings, activeEmployees) const atomTitles: Record = {} for (const row of (atomRows ?? []) as { id: string; title: string }[]) { @@ -143,8 +148,12 @@ interface CompanySettingsForPhaseB { vat_registered: boolean | null moms_period: string | null fiscal_year_start_month: number | null - employee_count: number | null - has_employees: boolean | null + // The employer facts that exist on company_settings. This select used to name + // `employee_count` and `has_employees`, neither of which is a column on this + // table, so PostgREST rejected the select whole (42703) and every field above + // arrived null as well. + employer_registered: boolean | null + pays_salaries: boolean | null } function buildInitialFields( @@ -155,6 +164,7 @@ function buildInitialFields( tic_snapshot: Record | null }, settings: CompanySettingsForPhaseB | null, + activeEmployees: number | null, ): InitialFields { const tic = (company.tic_snapshot ?? null) as Record | null const entityLabel = @@ -173,7 +183,7 @@ function buildInitialFields( let fSkatt: string | null = null let vatPeriod: string | null = null let fiscalPeriod: string | null = null - let employees: string | null = null + let ticEmployeeRange: string | null = null if (tic) { const sni = (tic.sniCodes as { code: string; name: string }[] | undefined) ?? [] @@ -184,7 +194,7 @@ function buildInitialFields( if (addr?.city) city = addr.city const reg = tic.registration as { fTax?: boolean } | undefined if (reg) fSkatt = reg.fTax ? 'Aktivt' : 'Saknas' - if (tic.employeeRange) employees = tic.employeeRange as string + if (tic.employeeRange) ticEmployeeRange = tic.employeeRange as string // v2 caches `fiscalYear` (current fiscal-year configuration) on the // snapshot. Prefer it over the user-entered value below so onboarding // can show the registered fiscal period from Bolagsverket without @@ -208,13 +218,19 @@ function buildInitialFields( if (!fiscalPeriod && settings.fiscal_year_start_month != null) { fiscalPeriod = fiscalYearLabel(settings.fiscal_year_start_month) } - if (!employees && settings.employee_count != null) { - employees = String(settings.employee_count) - } else if (!employees && settings.has_employees != null) { - employees = settings.has_employees ? 'Ja' : 'Nej' - } } + // "Anställda": the live headcount if there is one, else Bolagsverket's + // interval, else the attested employer facts, else null so the row shows its + // placeholder instead of a number nobody entered. See employee-facts.ts for + // why pays_salaries === false is not treated as "Nej". + const employees = employeeFieldValue({ + activeEmployees, + ticEmployeeRange, + employerRegistered: settings?.employer_registered ?? null, + paysSalaries: settings?.pays_salaries ?? null, + }) + return { entity_type_label: entityLabel, sni_codes: sniCodes, diff --git a/app/(public)/invoice-action/[token]/page.tsx b/app/(public)/invoice-action/[token]/page.tsx index 537d50f5..21dce74b 100644 --- a/app/(public)/invoice-action/[token]/page.tsx +++ b/app/(public)/invoice-action/[token]/page.tsx @@ -24,12 +24,20 @@ interface InvoiceData { previousResponse: 'marked_paid' | 'disputed' | null // Dröjsmålsränta + lagstadgad påminnelseavgift (Räntelagen §6, Lag 1981:739). // Default to 0 for older reminders sent before the surcharge feature shipped. + // + // interestAmount is a share of the invoice total, so it is in `currency`. + // The påminnelseavgift is a fixed krona amount fixed by statute and booked + // 1510/3990 in SEK, so it is quoted in `reminderFeeCurrency` (always 'SEK') + // and is only inside `totalDue` when the invoice itself is in SEK. Otherwise + // it arrives as `feeDueSeparately` and is shown as its own amount. interestAmount: number interestRate: number interestFromDate: string | null interestDays: number | null reminderFee: number + reminderFeeCurrency?: string totalDue: number + feeDueSeparately?: number } export default function InvoiceActionPage({ params }: { params: Promise<{ token: string }> }) { @@ -151,6 +159,12 @@ export default function InvoiceActionPage({ params }: { params: Promise<{ token: const now = new Date() const daysOverdue = Math.floor((now.getTime() - dueDate.getTime()) / (1000 * 60 * 60 * 24)) + // The statutory påminnelseavgift is a krona amount (Lag 1981:739). On a + // foreign-currency invoice it is never converted or relabelled: it is shown + // as its own SEK amount next to the invoice-currency total. + const feeCurrency = invoice.reminderFeeCurrency || 'SEK' + const feeDueSeparately = invoice.feeDueSeparately ?? 0 + return (
@@ -212,7 +226,7 @@ export default function InvoiceActionPage({ params }: { params: Promise<{ token: {invoice.reminderFee > 0 && (
Påminnelseavgift - {formatCurrency(invoice.reminderFee, invoice.currency)} + {formatCurrency(invoice.reminderFee, feeCurrency)}
)}
@@ -221,10 +235,24 @@ export default function InvoiceActionPage({ params }: { params: Promise<{ token:

{formatCurrency(invoice.totalDue || invoice.total, invoice.currency)} + {feeDueSeparately > 0 && ( + <> + {' + '} + {formatCurrency(feeDueSeparately, feeCurrency)} + + )}

{(invoice.interestAmount > 0 || invoice.reminderFee > 0) && (

- Att betala (inkl. dröjsmålsränta och påminnelseavgift) + {feeDueSeparately > 0 + ? 'Att betala (inkl. dröjsmålsränta) plus påminnelseavgift i SEK' + : 'Att betala (inkl. dröjsmålsränta och påminnelseavgift)'} +

+ )} + {feeDueSeparately > 0 && ( +

+ Påminnelseavgiften är lagstadgad (Lag 1981:739) och anges i svenska kronor. + Den räknas inte om till fakturans valuta utan betalas som ett separat belopp i SEK.

)}
diff --git a/app/api/bookkeeping/accounts/[number]/route.ts b/app/api/bookkeeping/accounts/[number]/route.ts index 58266eb7..6e8228fb 100644 --- a/app/api/bookkeeping/accounts/[number]/route.ts +++ b/app/api/bookkeeping/accounts/[number]/route.ts @@ -1,6 +1,7 @@ import { NextResponse } from 'next/server' import { withRouteContext } from '@/lib/api/with-route-context' import { validateBody } from '@/lib/api/validate' +import { sparsePatchBody } from '@/lib/api/sparse-patch' import { UpdateAccountSchema } from '@/lib/api/schemas' import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message' @@ -35,14 +36,31 @@ export const DELETE = withRouteContext( } // Check if the account is referenced in THIS company's journal entries. - // journal_entry_lines has no company_id column, so scope via the parent - // entry — a user can be a member of several companies, and another - // company's usage of the same BAS number must not block deletion here. - const { count } = await supabase - .from('journal_entry_lines') - .select('id, journal_entries!inner(company_id)', { count: 'exact', head: true }) - .eq('journal_entries.company_id', companyId) - .eq('account_number', number) + // journal_entry_lines has no company_id column, so the scope has to come + // from the parent entry: a user can be a member of several companies, and + // another company's usage of the same BAS number must not block deletion + // here. + // + // This used to be a `journal_entries!inner(company_id)` head+count query. + // PostgREST compiles that embed into a correlated LATERAL join, so the + // count walked the ENTIRE journal_entry_lines table across all tenants to + // answer a single-account question (see lib/bookkeeping/entry-lines.ts). + // fetchEntryLines cannot replace it (it returns rows, not a count), so the + // count moves into SQL: get_account_usage_counts is the same + // company-scoped aggregate the kontoplan usage column and the prune dialog + // already run (migration 20260704110000, covering index 20260706120000), + // and it counts lines on ALL entry statuses, exactly like the old query. + const { data: usage, error: usageError } = await supabase.rpc('get_account_usage_counts', { + p_company_id: companyId, + }) + + if (usageError) { + return NextResponse.json({ error: getUserErrorMessage(usageError) }, { status: 500 }) + } + + const count = ((usage ?? []) as { account_number: string; usage_count: number }[]).find( + (row) => row.account_number === number, + )?.usage_count if (count && count > 0) { return NextResponse.json( @@ -72,7 +90,14 @@ export const PUT = withRouteContext( const { number } = await params const { supabase, companyId, log } = ctx - const validation = await validateBody(request, UpdateAccountSchema, { + // The body is spread straight into .update(), so only the fields the + // caller actually named may reach it. UpdateAccountSchema carries no + // .default() today, so sparsePatchBody is a no-op here: it is the + // structural guarantee that adding one later cannot make a PUT that + // renames an account also rewrite its VAT code or SRU mapping. An + // explicit null (clearing sru_code, default_vat_code, default_vat_rate) + // still survives. + const validation = await validateBody(request, sparsePatchBody(UpdateAccountSchema), { log, operation: 'bookkeeping.accounts.update', }) diff --git a/app/api/bookkeeping/accounts/__tests__/accounts.test.ts b/app/api/bookkeeping/accounts/__tests__/accounts.test.ts index a391d664..a272cb64 100644 --- a/app/api/bookkeeping/accounts/__tests__/accounts.test.ts +++ b/app/api/bookkeeping/accounts/__tests__/accounts.test.ts @@ -218,10 +218,10 @@ describe('POST /api/bookkeeping/accounts', () => { }) describe('DELETE /api/bookkeeping/accounts/[number]', () => { - it('scopes the usage check to the company via the journal_entries join', async () => { + it('scopes the usage check to the company via the usage-counts RPC', async () => { const { supabase, calls } = createCapturingSupabase([ { data: { id: 'acc-1', is_system_account: false } }, // account fetch - { count: 0 }, // usage count + { data: [{ account_number: '4010', usage_count: 7 }] }, // usage counts (not 5010) { data: null }, // delete ]) auth(supabase) @@ -231,16 +231,18 @@ describe('DELETE /api/bookkeeping/accounts/[number]', () => { ) expect(status).toBe(200) + // The company scope is an RPC argument now, not a filter on an embed: + // another company's use of the same BAS number can never be counted here. + const rpcCalls = calls.filter((c) => c.method === 'rpc').map((c) => c.args) + expect(rpcCalls).toContainEqual(['get_account_usage_counts', { p_company_id: 'company-1' }]) const selectArgs = calls.filter((c) => c.method === 'select').map((c) => c.args[0]) - expect(selectArgs).toContain('id, journal_entries!inner(company_id)') - const eqCalls = calls.filter((c) => c.method === 'eq').map((c) => c.args) - expect(eqCalls).toContainEqual(['journal_entries.company_id', 'company-1']) + expect(selectArgs).not.toContain('id, journal_entries!inner(company_id)') }) it('refuses deleting an account used in this company with 400', async () => { const { supabase } = createCapturingSupabase([ { data: { id: 'acc-1', is_system_account: false } }, - { count: 3 }, + { data: [{ account_number: '5010', usage_count: 3 }] }, ]) auth(supabase) @@ -321,6 +323,55 @@ describe('PUT /api/bookkeeping/accounts/[number]', () => { } expect(updateArg?.default_vat_rate).toBe(0) }) + + // The body is spread straight into .update(), so the write set must be + // exactly what the caller named. UpdateAccountSchema carries no .default() + // today; these two lock the property in so adding one cannot turn a rename + // into a silent rewrite of the VAT code and SRU mapping. + it('writes only the field the caller named', async () => { + const { supabase, calls } = createCapturingSupabase([ + { data: { account_number: '5010', account_name: 'Nytt namn' } }, + ]) + auth(supabase) + const req = createMockRequest('/api/bookkeeping/accounts/5010', { + method: 'PUT', + body: { account_name: 'Nytt namn' }, + }) + expect((await parseJsonResponse(await PUT(req, numberParams))).status).toBe(200) + + const updateArg = calls.find((c) => c.method === 'update')?.args[0] as Record + expect(Object.keys(updateArg)).toEqual(['account_name']) + }) + + it('keeps an explicit null so sru_code can be cleared', async () => { + const { supabase, calls } = createCapturingSupabase([ + { data: { account_number: '5010', sru_code: null } }, + ]) + auth(supabase) + const req = createMockRequest('/api/bookkeeping/accounts/5010', { + method: 'PUT', + body: { sru_code: null }, + }) + expect((await parseJsonResponse(await PUT(req, numberParams))).status).toBe(200) + + const updateArg = calls.find((c) => c.method === 'update')?.args[0] as Record + expect(updateArg).toEqual({ sru_code: null }) + }) + + it('drops unknown keys instead of forwarding them to the update', async () => { + const { supabase, calls } = createCapturingSupabase([ + { data: { account_number: '5010' } }, + ]) + auth(supabase) + const req = createMockRequest('/api/bookkeeping/accounts/5010', { + method: 'PUT', + body: { account_name: 'Nytt namn', is_system_account: true, company_id: 'other' }, + }) + expect((await parseJsonResponse(await PUT(req, numberParams))).status).toBe(200) + + const updateArg = calls.find((c) => c.method === 'update')?.args[0] as Record + expect(Object.keys(updateArg)).toEqual(['account_name']) + }) }) describe('POST /api/bookkeeping/accounts/activate', () => { diff --git a/app/api/bookkeeping/fiscal-periods/__tests__/route.test.ts b/app/api/bookkeeping/fiscal-periods/__tests__/route.test.ts index 738f66f3..4821aff3 100644 --- a/app/api/bookkeeping/fiscal-periods/__tests__/route.test.ts +++ b/app/api/bookkeeping/fiscal-periods/__tests__/route.test.ts @@ -153,6 +153,8 @@ describe('POST /api/bookkeeping/fiscal-periods', () => { expect(res.status).toBe(200) const body = await res.json() expect(body.data).toBeDefined() + // Nothing precedes the first period, so there is nothing to advise about. + expect(body.warnings).toBeUndefined() }) it('rejects overlapping periods', async () => { @@ -168,6 +170,22 @@ describe('POST /api/bookkeeping/fiscal-periods', () => { expect(body.error).toMatch(/Overlaps/) }) + // Dropping the "prior year must be locked" gate must not open an overlap + // hole: a period colliding with a still-open prior year is still a 409. + it('rejects an overlapping period even when the prior year is still open', async () => { + buildMockSupabase({ + allPeriods: [{ id: 'p1', period_start: '2025-01-01', period_end: '2025-12-31', is_closed: false }], + openPeriods: [{ id: 'p1', name: 'FY 2025', period_start: '2025-01-01', period_end: '2025-12-31' }], + overlapping: [{ id: 'p1', name: 'FY 2025' }], + }) + // Starts inside FY 2025. + const req = createMockRequest({ name: 'FY 2025 dup', period_start: '2025-07-01', period_end: '2026-06-30' }) + const res = await POST(req) + expect(res.status).toBe(409) + const body = await res.json() + expect(body.error).toMatch(/Overlaps/) + }) + it('rejects forward period with wrong start date', async () => { buildMockSupabase({ allPeriods: [{ id: 'p1', period_start: '2025-01-01', period_end: '2025-12-31', is_closed: true }], @@ -179,22 +197,81 @@ describe('POST /api/bookkeeping/fiscal-periods', () => { expect(body.error).toMatch(/must start on 2026-01-01/) }) - it('rejects forward period when an unlocked open period exists and returns it as a blocking period', async () => { + // Regression (BFL 5 kap 2 §): this used to be a 409. One unbooked December + // bank transaction makes FY 2025 unlockable (lockPeriod refuses, correctly), + // and the old guard then refused to create FY 2026, so ALL bookkeeping in the + // new year stopped, while BFL 5 kap 2 § requires the new year's + // affärshändelser to be booked "så snart det kan ske" (senast månaden efter) + // and BFL 6 kap gives the bokslut 6 months. Both bind at once: the new + // räkenskapsår must be creatable with the prior one still fully open. + it('creates the new räkenskapsår while the prior year is still fully open', async () => { buildMockSupabase({ allPeriods: [{ id: 'p1', period_start: '2025-01-01', period_end: '2025-12-31', is_closed: false }], - openPeriods: [{ id: 'p1', name: 'FY 2025', period_start: '2025-01-01', period_end: '2025-12-31' }], + openPeriods: [{ id: 'p1', name: 'Räkenskapsår 2025', period_start: '2025-01-01', period_end: '2025-12-31' }], + overlapping: [], + }) + const req = createMockRequest({ + name: 'Räkenskapsår 2026', + period_start: '2026-01-01', + period_end: '2026-12-31', }) - const req = createMockRequest({ name: 'FY 2026', period_start: '2026-01-01', period_end: '2026-12-31' }) const res = await POST(req) - expect(res.status).toBe(409) + expect(res.status).toBe(200) const body = await res.json() - // Canonical envelope with a machine code + the blocking periods so the - // dialog can offer to lock them inline. - expect(body.error.code).toBe('PERIOD_CREATE_BLOCKED_BY_OPEN_PERIODS') - expect(body.error.message).toMatch(/låsa föregående räkenskapsår/) - expect(body.error.details.blockingPeriods).toEqual([ - { id: 'p1', name: 'FY 2025', period_start: '2025-01-01', period_end: '2025-12-31' }, - ]) + expect(body.data).toBeDefined() + // The open prior year survives only as a non-blocking advisory. + expect(body.warnings).toHaveLength(1) + expect(body.warnings[0].code).toBe('PRIOR_FISCAL_YEAR_STILL_OPEN') + expect(body.warnings[0].message).toContain('Räkenskapsår 2025') + }) + + // The advisory must be actionable and must not dead-end: it states that the + // new year is bookable now and that the IB arrives with the bokslut, rather + // than demanding a lock the user may be unable to perform. + it('advisory tells the user booking may continue and that IB follows the bokslut', async () => { + buildMockSupabase({ + allPeriods: [{ id: 'p1', period_start: '2025-01-01', period_end: '2025-12-31', is_closed: false }], + openPeriods: [{ id: 'p1', name: 'Räkenskapsår 2025', period_start: '2025-01-01', period_end: '2025-12-31' }], + overlapping: [], + }) + const req = createMockRequest({ + name: 'Räkenskapsår 2026', + period_start: '2026-01-01', + period_end: '2026-12-31', + }) + const res = await POST(req) + const body = await res.json() + expect(body.warnings[0].message).toMatch(/bokföra i det direkt/) + expect(body.warnings[0].message).toMatch(/Ingående balanser bokförs automatiskt/) + // No wording that orders the user to lock the prior year first. + expect(body.warnings[0].message).not.toMatch(/måste låsa/) + }) + + // Every still-open prior year is named, not just the immediate predecessor: + // the user needs to know the full set whose UB is still provisional. + it('names every still-open prior räkenskapsår in the single advisory', async () => { + buildMockSupabase({ + allPeriods: [ + { id: 'p1', period_start: '2024-01-01', period_end: '2024-12-31', is_closed: false }, + { id: 'p2', period_start: '2025-01-01', period_end: '2025-12-31', is_closed: false }, + ], + openPeriods: [ + { id: 'p1', name: 'Räkenskapsår 2024', period_start: '2024-01-01', period_end: '2024-12-31' }, + { id: 'p2', name: 'Räkenskapsår 2025', period_start: '2025-01-01', period_end: '2025-12-31' }, + ], + overlapping: [], + }) + const req = createMockRequest({ + name: 'Räkenskapsår 2026', + period_start: '2026-01-01', + period_end: '2026-12-31', + }) + const res = await POST(req) + expect(res.status).toBe(200) + const body = await res.json() + expect(body.warnings).toHaveLength(1) + expect(body.warnings[0].message).toContain('Räkenskapsår 2024') + expect(body.warnings[0].message).toContain('Räkenskapsår 2025') }) // Regression: BFL 6 kap allows löpande bokföring of the new year in parallel @@ -232,10 +309,14 @@ describe('POST /api/bookkeeping/fiscal-periods', () => { expect(res.status).toBe(200) const body = await res.json() expect(body.data).toBeDefined() + // Effectively locked: nothing to advise about. + expect(body.warnings).toBeUndefined() }) - // Partial coverage: lock-through covers only part of the period: must still block. - it('rejects forward period creation when company-wide lock only partially covers prior period', async () => { + // Partial coverage: lock-through covers only part of the period, so the prior + // year still counts as open. Creation proceeds either way; only the advisory + // distinguishes the two states. + it('advises but does not block when company-wide lock only partially covers prior period', async () => { buildMockSupabase({ allPeriods: [{ id: 'p1', period_start: '2024-01-01', period_end: '2024-12-31', is_closed: false }], openPeriods: [{ id: 'p1', name: 'FY 2024', period_start: '2024-01-01', period_end: '2024-12-31' }], @@ -244,12 +325,11 @@ describe('POST /api/bookkeeping/fiscal-periods', () => { }) const req = createMockRequest({ name: 'FY 2025', period_start: '2025-01-01', period_end: '2025-12-31' }) const res = await POST(req) - expect(res.status).toBe(409) + expect(res.status).toBe(200) const body = await res.json() - expect(body.error.code).toBe('PERIOD_CREATE_BLOCKED_BY_OPEN_PERIODS') - expect(body.error.details.blockingPeriods).toEqual([ - { id: 'p1', name: 'FY 2024', period_start: '2024-01-01', period_end: '2024-12-31' }, - ]) + expect(body.warnings).toHaveLength(1) + expect(body.warnings[0].code).toBe('PRIOR_FISCAL_YEAR_STILL_OPEN') + expect(body.warnings[0].message).toContain('FY 2024') }) it('allows backward period creation', async () => { @@ -283,6 +363,10 @@ describe('POST /api/bookkeeping/fiscal-periods', () => { const req = createMockRequest({ name: 'FY 2025', period_start: '2025-01-01', period_end: '2025-12-31' }) const res = await POST(req) expect(res.status).toBe(200) + // The advisory is about the NEXT year's ingående balanser, so it belongs to + // appends only. Backfilling an earlier year says nothing about IB. + const body = await res.json() + expect(body.warnings).toBeUndefined() }) // Regression (2026-06-16): a company with FY 2024 + FY 2026 but no @@ -356,6 +440,9 @@ describe('POST /api/bookkeeping/fiscal-periods', () => { const req = createMockRequest({ name: 'FY 2025', period_start: '2025-01-01', period_end: '2025-12-31' }) const res = await POST(req) expect(res.status).toBe(200) + // Gap fill is a backfill too: no IB advisory. + const body = await res.json() + expect(body.warnings).toBeUndefined() }) it('rejects invalid period duration (> 18 months)', async () => { diff --git a/app/api/bookkeeping/fiscal-periods/route.ts b/app/api/bookkeeping/fiscal-periods/route.ts index 169ac6f0..a05df91a 100644 --- a/app/api/bookkeeping/fiscal-periods/route.ts +++ b/app/api/bookkeeping/fiscal-periods/route.ts @@ -3,11 +3,19 @@ import { withRouteContext } from '@/lib/api/with-route-context' import { validatePeriodDuration } from '@/lib/bookkeeping/validate-period-duration' import { validateBody } from '@/lib/api/validate' import { CreateFiscalPeriodSchema } from '@/lib/api/schemas' -import { errorResponseFromCode } from '@/lib/errors/get-structured-error' import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message' -// Response shapes are legacy `{ error: string }` (plus one envelope code for -// the blocked-by-open-periods dialog) — kept for the räkenskapsår UI. +// Response shapes are legacy `{ error: string }`, kept for the räkenskapsår UI. +// Success may carry a non-blocking `warnings` array (same shape as the invoice +// booking routes: `{ code, message }`). + +/** A prior räkenskapsår that is still fully open when the next one is created. */ +interface OpenPriorPeriod { + id: string + name: string + period_start: string + period_end: string +} export const GET = withRouteContext('period.list', async (_request, ctx) => { const { supabase, companyId } = ctx @@ -58,6 +66,11 @@ export const POST = withRouteContext( const predecessor = [...sortedPeriods].reverse().find((p) => p.period_end < body.period_start) ?? null const successor = sortedPeriods.find((p) => p.period_start > body.period_end) ?? null + // Prior räkenskapsår that are still fully open at the moment the next one is + // appended. Advisory only (see the isAppend block below), attached to the + // success response so the UI can nudge without gating. + let openPriorPeriods: OpenPriorPeriod[] = [] + if (sortedPeriods.length > 0) { const earliest = sortedPeriods[0] const latest = sortedPeriods[sortedPeriods.length - 1] @@ -118,17 +131,38 @@ export const POST = withRouteContext( } } - // The "prior year must be locked" guard applies only when appending a new - // latest räkenskapsår, not when backfilling a gap between existing years. - // A gap fill is a backfill (like prepend) and must not be blocked by an - // open neighbouring year. + // An open prior räkenskapsår is INFORMATION, never a gate. // + // This used to hard-refuse (409) a new latest räkenskapsår while any + // prior period was still fully open, on the theory that the prior year + // "must at least be locked so nothing is back-posted into a year you've + // moved on from". That theory has no support in BFL and inverts two rules + // that bind simultaneously: + // - BFL 5 kap 2 §: kontanta in-/utbetalningar bokförs senast påföljande + // arbetsdag, övriga affärshändelser "så snart det kan ske" (per BFNAR + // 2013:2 senast månaden efter). Booking January REQUIRES a + // räkenskapsår covering January, within weeks. + // - BFL 6 kap: årsbokslut/årsredovisning ska upprättas inom 6 månader + // efter räkenskapsårets utgång (AB filing 7 månader). The prior year + // is therefore legitimately unfinished, and must stay unlocked so + // bokslutsposter (periodiseringar, avskrivningar, skatt) can be + // posted into it, for months into the new year. + // Running the two years in parallel is not a tolerated edge case, it is + // the normal and legally required state during that window. The old guard + // made one unbooked December bank transaction (which blocks lockPeriod, + // correctly, per BFL 5 kap 2 §) freeze ALL bookkeeping in the new year. + // + // What genuinely protects the prior year is unchanged and lives + // elsewhere: period locked_at (enforce_period_lock) and + // company_settings.bookkeeping_locked_through (enforce_company_lock_date), + // both set deliberately by the user. Creating the next räkenskapsår does + // not write a single row into the prior one. + // + // What IS kept from the old check is its detection, downgraded to an + // advisory on the success response: an open prior year means its UB is + // not final, so the new year's ingående balanser are not posted yet. // A period counts as "effectively locked" if its own locked_at is set, OR - // company_settings.bookkeeping_locked_through covers its end date (the - // enforce_company_lock_date trigger blocks any entry on/before that date). - // BFL 6 kap allows löpande bokföring of the new year in parallel with - // bokslut work on the prior year, so locked-but-not-closed prior periods - // must not block creating the next räkenskapsår. + // company_settings.bookkeeping_locked_through covers its end date. if (isAppend) { const { data: openPeriods } = await supabase .from('fiscal_periods') @@ -149,20 +183,12 @@ export const POST = withRouteContext( (p) => !(lockThrough && p.period_end <= lockThrough) ) - if (trulyOpen.length > 0) { - // Hand the blocking periods (id + name + dates) to the client so the - // "Skapa räkenskapsår" dialog can offer to lock them inline and retry, - // instead of dead-ending the user on a message they can't act on. - const blockingPeriods = trulyOpen.map((p) => ({ - id: p.id, - name: p.name, - period_start: p.period_start, - period_end: p.period_end, - })) - return errorResponseFromCode('PERIOD_CREATE_BLOCKED_BY_OPEN_PERIODS', log, { - details: { blockingPeriods }, - }) - } + openPriorPeriods = trulyOpen.map((p) => ({ + id: p.id, + name: p.name, + period_start: p.period_start, + period_end: p.period_end, + })) } } } @@ -231,7 +257,25 @@ export const POST = withRouteContext( } } - return NextResponse.json({ data }) + // Non-blocking advisory: the new year exists and is bookable right now, but + // its ingående balanser are still pending because the prior year's bokslut + // has not run. Every action named here is reachable, so this never dead-ends: + // the user can book in the new year immediately and the IB lands by itself + // when the bokslut for the prior year is executed (executeYearEndClosing + // reuses an already-created next period and posts the IB verifikat into it). + const warnings: Array<{ code: string; message: string }> = [] + if (openPriorPeriods.length > 0) { + const names = openPriorPeriods.map((p) => p.name).join(', ') + warnings.push({ + code: 'PRIOR_FISCAL_YEAR_STILL_OPEN', + message: + `Räkenskapsåret är skapat och du kan bokföra i det direkt. ${names} är fortfarande öppet, ` + + 'vilket är normalt medan bokslutet pågår: du får bokföra i båda åren samtidigt. ' + + 'Ingående balanser bokförs automatiskt när bokslutet för föregående år körs.', + }) + } + + return NextResponse.json(warnings.length > 0 ? { data, warnings } : { data }) }, { requireWrite: true }, ) diff --git a/app/api/bookkeeping/journal-entries/[id]/correct/__tests__/route.test.ts b/app/api/bookkeeping/journal-entries/[id]/correct/__tests__/route.test.ts index 95ebc8b2..b86f6b64 100644 --- a/app/api/bookkeeping/journal-entries/[id]/correct/__tests__/route.test.ts +++ b/app/api/bookkeeping/journal-entries/[id]/correct/__tests__/route.test.ts @@ -70,7 +70,11 @@ describe('POST /api/bookkeeping/journal-entries/[id]/correct', () => { const { status, body } = await parseJsonResponse<{ error: string }>(response) expect(status).toBe(400) - expect(body.error).toBe('Validation failed') + // Inverted from `toBe('Validation failed')`: the constant was the bug. + // `error` now names the offending field so a UI reading only `error` is + // actionable. + expect(body.error).toMatch(/^Valideringsfel: /) + expect(body.error).toContain('lines') }) it('returns 400 when lines array is empty', async () => { @@ -82,7 +86,8 @@ describe('POST /api/bookkeeping/journal-entries/[id]/correct', () => { const { status, body } = await parseJsonResponse<{ error: string }>(response) expect(status).toBe(400) - expect(body.error).toBe('Validation failed') + expect(body.error).toMatch(/^Valideringsfel: /) + expect(body.error).toContain('At least two lines are required for double-entry') }) it('returns reversal and corrected entries on success', async () => { @@ -155,7 +160,8 @@ describe('POST /api/bookkeeping/journal-entries/[id]/correct', () => { const { status, body } = await parseJsonResponse<{ error: string }>(response) expect(status).toBe(400) - expect(body.error).toBe('Validation failed') + expect(body.error).toMatch(/^Valideringsfel: /) + expect(body.error).toContain('description') expect(mockCorrectEntry).not.toHaveBeenCalled() }) diff --git a/app/api/bookkeeping/journal-entries/[id]/recordate/__tests__/route.test.ts b/app/api/bookkeeping/journal-entries/[id]/recordate/__tests__/route.test.ts index d9716ce8..9313cd97 100644 --- a/app/api/bookkeeping/journal-entries/[id]/recordate/__tests__/route.test.ts +++ b/app/api/bookkeeping/journal-entries/[id]/recordate/__tests__/route.test.ts @@ -67,7 +67,9 @@ describe('POST /api/bookkeeping/journal-entries/[id]/recordate', () => { const { status, body } = await parseJsonResponse<{ error: string }>(response) expect(status).toBe(400) - expect(body.error).toBe('Validation failed') + // Inverted from `toBe('Validation failed')`: the constant was the bug. + expect(body.error).toMatch(/^Valideringsfel: /) + expect(body.error).toContain('new_entry_date') }) it('returns 400 when new_entry_date is not an ISO date', async () => { @@ -79,7 +81,8 @@ describe('POST /api/bookkeeping/journal-entries/[id]/recordate', () => { const { status, body } = await parseJsonResponse<{ error: string }>(response) expect(status).toBe(400) - expect(body.error).toBe('Validation failed') + expect(body.error).toMatch(/^Valideringsfel: /) + expect(body.error).toContain('new_entry_date') }) it('returns reversal and corrected entries on success', async () => { diff --git a/app/api/customers/[id]/route.ts b/app/api/customers/[id]/route.ts index 7e15dbf0..1824b802 100644 --- a/app/api/customers/[id]/route.ts +++ b/app/api/customers/[id]/route.ts @@ -7,6 +7,17 @@ import { errorResponseFromCode } from '@/lib/errors/get-structured-error' import { encryptCustomerPersonalNumber, maskCustomerRow } from '@/lib/customers/protect-personal-number' import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message' +/** + * Shape produced by maskCustomerPersonalNumber: no read path ever returns the + * stored personnummer, only '********-1234'. A client that PATCHes back a + * customer it just read therefore submits the mask, which must mean "leave the + * stored value alone", never "store this literally" and never "clear it". + * components/customers/CustomerForm.tsx strips it before sending, but that + * guard belongs here too: any other client (script, agent, future UI) that + * skips it would otherwise destroy the value. + */ +const MASKED_PERSONAL_NUMBER = /^\*{8}-\d{4}$/ + export const GET = withRouteContext( 'customer.get', async (_request, ctx, { params }: { params: Promise<{ id: string }> }) => { @@ -72,8 +83,14 @@ export const PATCH = withRouteContext( return errorResponseFromCode('CUSTOMER_UPDATE_FAILED', opLog, { requestId }) } + // The masked sentinel counts as "field not supplied": it carries no new + // value, so it must not be validated, stored or treated as a clear. + const personalNumberSubmitted = + body.personal_number !== undefined && + !(typeof body.personal_number === 'string' && MASKED_PERSONAL_NUMBER.test(body.personal_number)) + const effectiveType = body.customer_type ?? existing.customer_type - if (body.personal_number && effectiveType !== 'individual') { + if (personalNumberSubmitted && body.personal_number && effectiveType !== 'individual') { return errorResponseFromCode('CUSTOMER_PERSONAL_NUMBER_NOT_ALLOWED', opLog, { requestId }) } @@ -91,7 +108,9 @@ export const PATCH = withRouteContext( if (body.country !== undefined) updateData.country = body.country if (body.org_number !== undefined) updateData.org_number = body.org_number if (body.vat_number !== undefined) updateData.vat_number = body.vat_number - if (body.personal_number !== undefined) { + if (personalNumberSubmitted) { + // Stored as ciphertext; customers_personal_number_check accepts that + // shape only (20260726110000). updateData.personal_number = encryptCustomerPersonalNumber(body.personal_number) } else if (body.customer_type !== undefined && effectiveType !== 'individual') { updateData.personal_number = null diff --git a/app/api/customers/__tests__/personal-number.test.ts b/app/api/customers/__tests__/personal-number.test.ts index 67629894..8d301180 100644 --- a/app/api/customers/__tests__/personal-number.test.ts +++ b/app/api/customers/__tests__/personal-number.test.ts @@ -2,7 +2,7 @@ import { NextResponse } from 'next/server' import { beforeEach, describe, expect, it, vi } from 'vitest' import { eventBus } from '@/lib/events' import { createMockRequest, parseJsonResponse } from '@/tests/helpers' -import { decryptPersonnummer } from '@/lib/salary/personnummer' +import { decryptPersonnummer, encryptPersonnummer } from '@/lib/salary/personnummer' const captured: { insert: unknown[]; update: unknown[] } = { insert: [], update: [] } let queryResult: { data: unknown; error: unknown } = { data: null, error: null } @@ -51,6 +51,23 @@ import { PATCH } from '../[id]/route' type CustomerWrite = { personal_number?: string | null } +// Synthetic personnummer, never a real one. +const PERSONAL_NUMBER = '19900101-1234' +const MASKED = '********-1234' + +/** + * The shape customers_personal_number_check accepts as of 20260726110000: + * lowercase hex, 76 to 255 chars (24 iv + 32 auth tag + >= 20 ciphertext). + * Asserting against it is the point of these tests. The routes encrypt before + * writing, and until 20260726110000 the column still demanded the plaintext + * personnummer format below, so every write was rejected by Postgres while + * this mocked suite passed. Pinning both directions is what makes the unit + * test able to catch that mismatch; tests/pg/customers-personal-number- + * ciphertext.pg.test.ts proves the constraint itself. + */ +const CIPHERTEXT_SHAPE = /^[0-9a-f]{76,255}$/ +const OLD_PLAINTEXT_CHECK = /^(\d{6}|\d{8})[-+]?\d{4}$/ + describe('personal_number on customer routes', () => { const routeParams = { params: Promise.resolve({ id: 'customer-1' }) } @@ -99,12 +116,14 @@ describe('personal_number on customer routes', () => { }) it('stores the personal number when creating a private customer', async () => { + // What the insert returns is what the DB would hold: ciphertext. + const stored = encryptPersonnummer(PERSONAL_NUMBER) queryResult = { data: { id: 'customer-1', name: 'Anna Andersson', customer_type: 'individual', - personal_number: '19900101-1234', + personal_number: stored, }, error: null, } @@ -115,7 +134,7 @@ describe('personal_number on customer routes', () => { body: { name: 'Anna Andersson', customer_type: 'individual', - personal_number: '19900101-1234', + personal_number: PERSONAL_NUMBER, }, }), { params: Promise.resolve({}) }, @@ -123,18 +142,25 @@ describe('personal_number on customer routes', () => { const { status, body } = await parseJsonResponse<{ data: { personal_number: string } }>(response) expect(status).toBe(200) - const encrypted = (captured.insert[0] as CustomerWrite).personal_number as string - expect(encrypted).not.toBe('19900101-1234') - expect(decryptPersonnummer(encrypted)).toBe('19900101-1234') - expect(body.data.personal_number).toBe('********-1234') + + const written = (captured.insert[0] as CustomerWrite).personal_number as string + // The value must be storable: Postgres accepts ciphertext shape only. + expect(written).toMatch(CIPHERTEXT_SHAPE) + expect(written).not.toMatch(OLD_PLAINTEXT_CHECK) + // ...and it must still be the customer's personnummer. + expect(written).not.toBe(PERSONAL_NUMBER) + expect(decryptPersonnummer(written)).toBe(PERSONAL_NUMBER) + // Read back masked: no read path returns the personnummer itself. + expect(body.data.personal_number).toBe(MASKED) }) it('updates the personal number for an existing private customer', async () => { + const stored = encryptPersonnummer('900101-1234') queryResult = { data: { id: 'customer-1', customer_type: 'individual', - personal_number: '900101-1234', + personal_number: stored, }, error: null, } @@ -147,10 +173,79 @@ describe('personal_number on customer routes', () => { routeParams, ) + const { status, body } = await parseJsonResponse<{ data: { personal_number: string } }>(response) + expect(status).toBe(200) + + const written = (captured.update[0] as CustomerWrite).personal_number as string + expect(written).toMatch(CIPHERTEXT_SHAPE) + expect(written).not.toMatch(OLD_PLAINTEXT_CHECK) + expect(written).not.toBe('900101-1234') + expect(decryptPersonnummer(written)).toBe('900101-1234') + expect(body.data.personal_number).toBe(MASKED) + }) + + it('keeps the stored personal number when the masked value is sent back', async () => { + queryResult = { + data: { + id: 'customer-1', + customer_type: 'individual', + name: 'Anna A', + personal_number: encryptPersonnummer(PERSONAL_NUMBER), + }, + error: null, + } + + // A client that PATCHes back the customer it just read submits the mask. + const response = await PATCH( + createMockRequest('/api/customers/customer-1', { + method: 'PATCH', + body: { name: 'Anna A', personal_number: MASKED }, + }), + routeParams, + ) + + const { status, body } = await parseJsonResponse<{ data: { personal_number: string } }>(response) + expect(status).toBe(200) + // Neither stored literally nor cleared: the column is left untouched. + expect(captured.update[0]).not.toHaveProperty('personal_number') + expect(body.data.personal_number).toBe(MASKED) + }) + + it('does not treat a masked value as a personal number on a corporate customer', async () => { + queryResult = { + data: { id: 'customer-1', customer_type: 'individual', name: 'Anna A' }, + error: null, + } + + // Switching type away from individual while echoing the mask clears the + // column, and must not trip the "not allowed for businesses" guard. + const response = await PATCH( + createMockRequest('/api/customers/customer-1', { + method: 'PATCH', + body: { customer_type: 'swedish_business', personal_number: MASKED }, + }), + routeParams, + ) + expect(response.status).toBe(200) - const encrypted = (captured.update[0] as CustomerWrite).personal_number as string - expect(encrypted).not.toBe('900101-1234') - expect(decryptPersonnummer(encrypted)).toBe('900101-1234') + expect((captured.update[0] as CustomerWrite).personal_number).toBeNull() + }) + + it('rejects the masked value on create, where there is nothing to preserve', async () => { + const response = await POST( + createMockRequest('/api/customers', { + method: 'POST', + body: { + name: 'Anna Andersson', + customer_type: 'individual', + personal_number: MASKED, + }, + }), + { params: Promise.resolve({}) }, + ) + + expect(response.status).toBe(400) + expect(captured.insert).toHaveLength(0) }) it('clears the personal number when null is sent', async () => { diff --git a/app/api/deadlines/[id]/complete/__tests__/route.test.ts b/app/api/deadlines/[id]/complete/__tests__/route.test.ts new file mode 100644 index 00000000..f72da856 --- /dev/null +++ b/app/api/deadlines/[id]/complete/__tests__/route.test.ts @@ -0,0 +1,256 @@ +/** + * Tests for POST /api/deadlines/[id]/complete. + * + * The load-bearing case is the explicit-state one: the page's "Ångra" button + * posts `{ is_completed: false }`, and a route that toggles instead of setting + * turns a second undo click (or an undo of a row already un-ticked elsewhere) + * into a re-completed Skatteverket deadline. + */ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { NextResponse } from 'next/server' +import { createMockRequest, parseJsonResponse } from '@/tests/helpers' + +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 idParams = { params: Promise.resolve({ id: 'deadline-1' }) } + +interface Captured { + update?: Record +} + +/** + * Sequential query results plus the payload handed to `.update()`, which is the + * only place the persisted state is observable. + */ +function createCapturingSupabase( + results: { data?: unknown; error?: unknown }[], + captured: Captured, +) { + let idx = 0 + const makeBuilder = () => { + const result = results[idx++] ?? { data: null, error: null } + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const b: any = {} + for (const m of ['select', 'eq', 'single', 'maybeSingle']) { + b[m] = () => b + } + b.update = (payload: Record) => { + captured.update = payload + return b + } + b.then = (resolve: (v: unknown) => void) => + resolve({ data: result.data ?? null, error: result.error ?? null }) + return b + } + return { from: () => makeBuilder() } +} + +function auth(supabase: unknown) { + requireAuthMock.mockResolvedValue({ user: { id: 'user-1' }, supabase, error: null }) +} + +beforeEach(() => { + vi.clearAllMocks() + requireWriteMock.mockResolvedValue({ ok: true }) +}) + +describe('POST /api/deadlines/[id]/complete', () => { + it('returns 401 when not authenticated', async () => { + requireAuthMock.mockResolvedValue({ + user: null, + supabase: {}, + error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }), + }) + const res = await POST(createMockRequest('/x', { method: 'POST' }), idParams) + expect(res.status).toBe(401) + }) + + it('returns 403 for a viewer', async () => { + auth(createCapturingSupabase([], {})) + requireWriteMock.mockResolvedValue({ + ok: false, + response: NextResponse.json({ error: 'Forbidden' }, { status: 403 }), + }) + const res = await POST( + createMockRequest('/x', { method: 'POST', body: { is_completed: false } }), + idParams, + ) + expect(res.status).toBe(403) + }) + + it('maps zero-rows to 404 with a Swedish message', async () => { + auth(createCapturingSupabase([{ error: { code: 'PGRST116', message: 'no rows' } }], {})) + const { status, body } = await parseJsonResponse<{ error: string }>( + await POST( + createMockRequest('/x', { method: 'POST', body: { is_completed: false } }), + idParams, + ), + ) + expect(status).toBe(404) + expect(body.error).toBe('Deadline hittades inte') + }) + + it('marks a pending deadline done and stamps completed_at', async () => { + const captured: Captured = {} + auth( + createCapturingSupabase( + [{ data: { is_completed: false } }, { data: { id: 'deadline-1', is_completed: true } }], + captured, + ), + ) + const { status } = await parseJsonResponse( + await POST( + createMockRequest('/x', { method: 'POST', body: { is_completed: true } }), + idParams, + ), + ) + expect(status).toBe(200) + expect(captured.update?.is_completed).toBe(true) + expect(captured.update?.completed_at).toBeTruthy() + }) + + it('undoes a completed deadline and clears completed_at', async () => { + const captured: Captured = {} + auth( + createCapturingSupabase( + [{ data: { is_completed: true } }, { data: { id: 'deadline-1', is_completed: false } }], + captured, + ), + ) + const { status } = await parseJsonResponse( + await POST( + createMockRequest('/x', { method: 'POST', body: { is_completed: false } }), + idParams, + ), + ) + expect(status).toBe(200) + expect(captured.update?.is_completed).toBe(false) + expect(captured.update?.completed_at).toBeNull() + }) + + it('is idempotent: undoing an already-pending deadline leaves it pending', async () => { + // The "Ångra" click can land after the row was already un-ticked in another + // tab or by an MCP agent. A route that toggled here would re-complete the + // deadline, and the caller would still report it as put back on the list. + const captured: Captured = {} + auth( + createCapturingSupabase( + [{ data: { is_completed: false } }, { data: { id: 'deadline-1', is_completed: false } }], + captured, + ), + ) + const { status } = await parseJsonResponse( + await POST( + createMockRequest('/x', { method: 'POST', body: { is_completed: false } }), + idParams, + ), + ) + expect(status).toBe(200) + expect(captured.update?.is_completed).toBe(false) + expect(captured.update?.completed_at).toBeNull() + }) + + it('is idempotent: re-completing an already-done deadline keeps it done', async () => { + const captured: Captured = {} + auth( + createCapturingSupabase( + [{ data: { is_completed: true } }, { data: { id: 'deadline-1', is_completed: true } }], + captured, + ), + ) + await POST( + createMockRequest('/x', { method: 'POST', body: { is_completed: true } }), + idParams, + ) + expect(captured.update?.is_completed).toBe(true) + expect(captured.update?.completed_at).toBeTruthy() + }) + + it('falls back to toggling when the body carries no state', async () => { + // Backwards compatibility: a body-less POST keeps the original toggle. + const captured: Captured = {} + auth( + createCapturingSupabase( + [{ data: { is_completed: false } }, { data: { id: 'deadline-1', is_completed: true } }], + captured, + ), + ) + await POST(createMockRequest('/x', { method: 'POST' }), idParams) + expect(captured.update?.is_completed).toBe(true) + }) + + it('rejects a non-boolean is_completed with 400 instead of guessing', async () => { + // { is_completed: "false" } is the load-bearing case: a string is truthy, + // and the old hand-rolled parser silently degraded it to a toggle, so an + // "undo" carrying the string could re-complete the deadline. A wrong type + // must fail loudly and persist nothing. + const captured: Captured = {} + auth(createCapturingSupabase([], captured)) + const { status } = await parseJsonResponse( + await POST( + createMockRequest('/x', { method: 'POST', body: { is_completed: 'false' } }), + idParams, + ), + ) + expect(status).toBe(400) + expect(captured.update).toBeUndefined() + }) + + it('rejects other wrong types for is_completed with 400', async () => { + const captured: Captured = {} + auth(createCapturingSupabase([], captured)) + for (const bad of [1, 'nope', null, [true]]) { + const { status } = await parseJsonResponse( + await POST( + createMockRequest('/x', { method: 'POST', body: { is_completed: bad } }), + idParams, + ), + ) + expect(status, `is_completed=${JSON.stringify(bad)} must be a 400`).toBe(400) + } + expect(captured.update).toBeUndefined() + }) + + it('rejects unknown body keys with 400 (strict schema)', async () => { + const captured: Captured = {} + auth(createCapturingSupabase([], captured)) + const { status } = await parseJsonResponse( + await POST( + createMockRequest('/x', { method: 'POST', body: { is_completed: true, extra: 1 } }), + idParams, + ), + ) + expect(status).toBe(400) + expect(captured.update).toBeUndefined() + }) + + it('treats an explicit empty object as a toggle', async () => { + const captured: Captured = {} + auth( + createCapturingSupabase( + [{ data: { is_completed: false } }, { data: { id: 'deadline-1', is_completed: true } }], + captured, + ), + ) + const { status } = await parseJsonResponse( + await POST(createMockRequest('/x', { method: 'POST', body: {} }), idParams), + ) + expect(status).toBe(200) + expect(captured.update?.is_completed).toBe(true) + }) +}) diff --git a/app/api/deadlines/[id]/complete/route.ts b/app/api/deadlines/[id]/complete/route.ts index 115b13e6..0f170a84 100644 --- a/app/api/deadlines/[id]/complete/route.ts +++ b/app/api/deadlines/[id]/complete/route.ts @@ -1,17 +1,62 @@ import { NextResponse } from 'next/server' +import { z } from 'zod' import { withRouteContext } from '@/lib/api/with-route-context' +import { validateBody } from '@/lib/api/validate' import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message' +// Strict: `is_completed` must be a genuine boolean or absent. The hand-rolled +// parser this replaces silently degraded { is_completed: "false" } (a string) +// to a toggle: the exact hazard the explicit-state contract was built to +// remove, since a truthy-string undo could re-complete the row it meant to +// un-tick. Wrong types are a 400 now, never a guess. +const CompleteDeadlineSchema = z + .object({ + is_completed: z.boolean().optional(), + }) + .strict() + /** * POST /api/deadlines/[id]/complete - * Toggle completion status of a deadline + * Set the completion status of a deadline. + * + * The body is optional. When it carries a boolean `is_completed` the route sets + * exactly that state; a caller that sends no body (or `{}`) keeps the original + * toggle behaviour. A body that IS present but malformed (wrong type, unknown + * keys, broken JSON) is rejected with 400 instead of being reinterpreted. + * + * Honouring an explicit state is what makes the "Ångra" affordance an undo + * rather than a second toggle: the click has to be idempotent, because the row + * can already have been un-ticked in another tab or by an MCP agent between the + * toast appearing and the click landing, and a blind toggle would then + * re-complete a Skatteverket deadline the user was trying to put back on the + * list. It also makes the caller's own confirmation truthful: the client picks + * its toast from the state it asked for, so the server must not silently + * persist the opposite. */ export const POST = withRouteContext<{ params: Promise<{ id: string }> }>( 'deadline.toggle_complete', - async (_request, ctx, { params }) => { + async (request, ctx, { params }) => { const { id } = await params const { supabase, companyId } = ctx + // Absent/empty body = toggle (the pre-explicit-state ergonomics); anything + // else must validate. The raw text is read first because validateBody + // treats an empty body as invalid JSON, which would 400 the plain toggle. + const raw = (await request.text()).trim() + let requestedState: boolean | null = null + if (raw !== '') { + const validation = await validateBody( + new Request(request.url, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: raw, + }), + CompleteDeadlineSchema, + ) + if (!validation.success) return validation.response + requestedState = validation.data.is_completed ?? null + } + // First, get current deadline state const { data: existing, error: fetchError } = await supabase .from('deadlines') @@ -22,13 +67,14 @@ export const POST = withRouteContext<{ params: Promise<{ id: string }> }>( if (fetchError) { if (fetchError.code === 'PGRST116') { - return NextResponse.json({ error: 'Deadline not found' }, { status: 404 }) + // "Deadline" is the term the Swedish UI uses too (messages/sv.json). + return NextResponse.json({ error: 'Deadline hittades inte' }, { status: 404 }) } return NextResponse.json({ error: getUserErrorMessage(fetchError) }, { status: 500 }) } - // Toggle completion - const newCompletedState = !existing.is_completed + // Explicit state when the caller supplied one, otherwise toggle. + const newCompletedState = requestedState ?? !existing.is_completed const { data, error } = await supabase .from('deadlines') .update({ diff --git a/app/api/dimensions/__tests__/import-existing.test.ts b/app/api/dimensions/__tests__/import-existing.test.ts index 783e7630..3eb1cd70 100644 --- a/app/api/dimensions/__tests__/import-existing.test.ts +++ b/app/api/dimensions/__tests__/import-existing.test.ts @@ -33,6 +33,19 @@ import { POST } from '../import-existing/route' const request = () => createMockRequest('/api/dimensions/import-existing', { method: 'POST' }) const noParams = { params: Promise.resolve({}) } +/** + * Enqueue the two pages the scan reads through the two-step entry-lines fetch + * (lib/bookkeeping/entry-lines.ts): the company's parent entries first, then + * the lines keyed by journal_entry_id. The route never reads the parent, so a + * single synthetic entry per line set is enough. + */ +function enqueueLines(rows: Array<{ id: string; dimensions: Record }>) { + enqueue({ data: rows.length > 0 ? [{ id: 'entry-1' }] : [] }) + // No entries means the helper never queries the lines at all. + if (rows.length === 0) return + enqueue({ data: rows.map((r) => ({ ...r, journal_entry_id: 'entry-1' })) }) +} + describe('POST /api/dimensions/import-existing', () => { beforeEach(() => { vi.clearAllMocks() @@ -52,7 +65,7 @@ describe('POST /api/dimensions/import-existing', () => { it('returns { created: 0 } when no line carries dimensions', async () => { enqueue({ data: null }) // ensure RPC - enqueue({ data: [] }) // journal_entry_lines scan + enqueueLines([]) // journal_entry_lines scan const response = await POST(request(), noParams) const { status, body } = await parseJsonResponse<{ created: number }>(response) @@ -64,13 +77,12 @@ describe('POST /api/dimensions/import-existing', () => { it('creates inactive placeholder values for codes missing from the registry', async () => { enqueue({ data: null }) // ensure RPC // Lines: KS01 (dim 1) appears twice, P001 (dim 6) once, BUTIK already registered. - enqueue({ - data: [ - { id: 'l1', dimensions: { '1': 'KS01' } }, - { id: 'l2', dimensions: { '1': 'KS01', '6': 'P001' } }, - { id: 'l3', dimensions: { '1': 'BUTIK' } }, - ], - }) + enqueueLines([ + { id: 'l1', dimensions: { '1': 'KS01' } }, + { id: 'l2', dimensions: { '1': 'KS01', '6': 'P001' } }, + { id: 'l3', dimensions: { '1': 'BUTIK' } }, + + ]) // Registry dims 1 & 6 exist (system dims). enqueue({ data: [ @@ -94,14 +106,13 @@ describe('POST /api/dimensions/import-existing', () => { enqueue({ data: null }) // ensure RPC // 'KS"01"' and 'KS{01}' both sanitize to KS01 (one candidate); a 50-char // code is capped at 40; '"{}"' sanitizes to empty and is dropped entirely. - enqueue({ - data: [ - { id: 'l1', dimensions: { '1': 'KS"01"' } }, - { id: 'l2', dimensions: { '1': 'KS{01}' } }, - { id: 'l3', dimensions: { '1': 'X'.repeat(50) } }, - { id: 'l4', dimensions: { '1': '"{}"' } }, - ], - }) + enqueueLines([ + { id: 'l1', dimensions: { '1': 'KS"01"' } }, + { id: 'l2', dimensions: { '1': 'KS{01}' } }, + { id: 'l3', dimensions: { '1': 'X'.repeat(50) } }, + { id: 'l4', dimensions: { '1': '"{}"' } }, + + ]) enqueue({ data: [{ id: 'dim-1', sie_dim_no: 1 }] }) // registry dims enqueue({ data: [] }) // no existing values // Upsert returns the two surviving sanitized codes (KS01 + the capped one). @@ -116,12 +127,11 @@ describe('POST /api/dimensions/import-existing', () => { it('tolerates duplicates in the batch: created counts only the rows the upsert returned', async () => { enqueue({ data: null }) // ensure RPC - enqueue({ - data: [ - { id: 'l1', dimensions: { '1': 'KS01' } }, - { id: 'l2', dimensions: { '1': 'KS02' } }, - ], - }) + enqueueLines([ + { id: 'l1', dimensions: { '1': 'KS01' } }, + { id: 'l2', dimensions: { '1': 'KS02' } }, + + ]) enqueue({ data: [{ id: 'dim-1', sie_dim_no: 1 }] }) // registry dims enqueue({ data: [] }) // existing-values snapshot missed a raced KS02 // ignoreDuplicates upsert skips the conflicting row instead of aborting @@ -137,7 +147,7 @@ describe('POST /api/dimensions/import-existing', () => { it('creates a registry dimension for an unregistered dim number found on lines', async () => { enqueue({ data: null }) // ensure RPC - enqueue({ data: [{ id: 'l1', dimensions: { '7': 'AVD-A' } }] }) // lines + enqueueLines([{ id: 'l1', dimensions: { '7': 'AVD-A' } }]) // lines // Registry only has the system dims: dim 7 is missing. enqueue({ data: [ @@ -161,7 +171,7 @@ describe('POST /api/dimensions/import-existing', () => { it('returns 500 DIMENSION_IMPORT_FAILED when the scan blows up', async () => { enqueue({ data: null }) // ensure RPC - enqueue({ error: { message: 'relation missing' } }) // fetchAllRows throws + enqueue({ error: { message: 'relation missing' } }) // entry page throws const response = await POST(request(), noParams) const { status, body } = await parseJsonResponse<{ error: { code: string } }>(response) diff --git a/app/api/dimensions/import-existing/route.ts b/app/api/dimensions/import-existing/route.ts index 9b315ec1..0dc79a00 100644 --- a/app/api/dimensions/import-existing/route.ts +++ b/app/api/dimensions/import-existing/route.ts @@ -16,7 +16,7 @@ import { NextResponse } from 'next/server' import { ensureInitialized } from '@/lib/init' import { withRouteContext } from '@/lib/api/with-route-context' -import { fetchAllRows } from '@/lib/supabase/fetch-all' +import { fetchEntryLines, type EntryLinesQuery } from '@/lib/bookkeeping/entry-lines' import { errorResponse, errorResponseFromCode } from '@/lib/errors/get-structured-error' import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message' @@ -42,16 +42,19 @@ export const POST = withRouteContext( try { // 1. Collect every {dim_no: code} pair used on this company's lines. - // Inner-join scoping: journal_entry_lines has no company_id column. - const lines = await fetchAllRows(({ from, to }) => - supabase - .from('journal_entry_lines') - .select('id, dimensions, journal_entries!inner(company_id)') - .eq('journal_entries.company_id', companyId) - .neq('dimensions', '{}') - .order('id', { ascending: true }) - .range(from, to), - ) + // journal_entry_lines has no company_id column, so the scope comes + // from the parent entries. Driving the query from that side + // (lib/bookkeeping/entry-lines.ts) instead of a + // `journal_entries!inner(company_id)` embed keeps PostgREST from + // compiling a correlated LATERAL join that walks every tenant's + // lines. The parent is not read here, so it is not reattached. + const lines = await fetchEntryLines({ + supabase, + lineColumns: 'id, dimensions', + filterEntries: (q: EntryLinesQuery) => q.eq('company_id', companyId), + filterLines: (q: EntryLinesQuery) => q.neq('dimensions', '{}'), + attachEntriesAs: null, + }) const codesByDimNo = new Map>() for (const line of lines) { diff --git a/app/api/dimensions/tagging/__tests__/lines.test.ts b/app/api/dimensions/tagging/__tests__/lines.test.ts index b50bdac5..0e07a3e3 100644 --- a/app/api/dimensions/tagging/__tests__/lines.test.ts +++ b/app/api/dimensions/tagging/__tests__/lines.test.ts @@ -174,6 +174,30 @@ describe('GET /api/dimensions/tagging/lines', () => { expect(second.lines.map((l) => l.id)).toEqual(['line-3']) }) + it('orders each voucher lines by sort_order regardless of fetch order', async () => { + // The two-step fetch (lib/bookkeeping/entry-lines.ts) pages the lines on + // their PK for stable paging, so the workbench's voucher order + // (sort_order, then id) has to be restored per verifikat. + enqueue({ data: [makeRawEntry()] }) + enqueue({ + data: [ + makeRawLine({ id: 'line-c', sort_order: 2, account_number: '2641' }), + makeRawLine({ id: 'line-a', sort_order: 0, account_number: '4010' }), + makeRawLine({ id: 'line-b', sort_order: 1, account_number: '1930' }), + ], + }) + + const response = await GET(request(), noParams) + const { status, body } = await parseJsonResponse(response) + + expect(status).toBe(200) + expect(body.data.vouchers[0].lines.map((l) => l.id)).toEqual([ + 'line-a', + 'line-b', + 'line-c', + ]) + }) + it('caps the result at limit vouchers and reports total_capped', async () => { // limit=2 → route fetches 3 entries; a third means "there is more". enqueue({ diff --git a/app/api/dimensions/tagging/lines/route.ts b/app/api/dimensions/tagging/lines/route.ts index 93d6b73d..a9ffb438 100644 --- a/app/api/dimensions/tagging/lines/route.ts +++ b/app/api/dimensions/tagging/lines/route.ts @@ -29,6 +29,7 @@ import { withRouteContext } from '@/lib/api/with-route-context' import { validateQuery } from '@/lib/api/validate' import { DimensionTaggingLinesQuerySchema } from '@/lib/api/schemas' import { errorResponse } from '@/lib/errors/get-structured-error' +import { fetchLinesByEntryIds } from '@/lib/bookkeeping/entry-lines' import { escapeLikePattern } from '@/lib/invoices/duplicate-payment-guard' ensureInitialized() @@ -169,29 +170,40 @@ export const GET = withRouteContext( } // Step 2: the COMPLETE line set for each qualifying voucher, so voucher- - // level tagging always covers the whole verifikat. The entry IDs already - // come from the company-filtered step-1 query; the explicit parent scope - // here is defense in depth (repo convention). - const { data: lineData, error: lineError } = await supabase - .from('journal_entry_lines') - .select('id, account_number, debit_amount, credit_amount, dimensions, journal_entry_id, sort_order, journal_entries!inner(company_id)') - .eq('journal_entries.company_id', companyId) - .in('journal_entry_id', entries.map((e) => e.id)) - .order('journal_entry_id', { ascending: true }) - .order('sort_order', { ascending: true }) - .order('id', { ascending: true }) - - if (lineError) { - log.error('tagging voucher line fetch failed', lineError) - return errorResponse(lineError, log, { requestId }) + // level tagging always covers the whole verifikat. + // + // The entry IDs already come from the company-filtered step-1 query, so + // the old `journal_entries!inner(company_id)` embed added nothing but the + // correlated LATERAL join PostgREST compiles it into, which walks every + // tenant's journal_entry_lines (see lib/bookkeeping/entry-lines.ts). + // fetchLinesByEntryIds keeps the same `.in('journal_entry_id', ...)` + // scope, chunks it, and pages each chunk: the whole line set for a full + // page of vouchers no longer silently stops at PostgREST's 1000-row cap. + let lineData: RawLine[] + try { + lineData = await fetchLinesByEntryIds( + supabase, + entries.map((e) => e.id), + 'id, account_number, debit_amount, credit_amount, dimensions, sort_order', + ) + } catch (err) { + log.error('tagging voucher line fetch failed', err as Error) + return errorResponse(err, log, { requestId }) } const linesByEntry = new Map() - for (const line of (lineData ?? []) as unknown as RawLine[]) { + for (const line of lineData) { const bucket = linesByEntry.get(line.journal_entry_id) ?? [] bucket.push(line) linesByEntry.set(line.journal_entry_id, bucket) } + // The helper orders on the line PK for stable paging; the workbench shows + // lines in voucher order, which is (sort_order, id) inside each verifikat. + for (const bucket of linesByEntry.values()) { + bucket.sort( + (a, b) => (a.sort_order ?? 0) - (b.sort_order ?? 0) || a.id.localeCompare(b.id), + ) + } const vouchers = entries.map((e) => ({ journal_entry_id: e.id, diff --git a/app/api/documents/[id]/__tests__/route.test.ts b/app/api/documents/[id]/__tests__/route.test.ts index b095bc3c..c34b0573 100644 --- a/app/api/documents/[id]/__tests__/route.test.ts +++ b/app/api/documents/[id]/__tests__/route.test.ts @@ -38,6 +38,16 @@ vi.mock('@/lib/supabase/server', () => ({ }), })) +// deleteDocument removes storage objects via the cookieless service-role +// client: the documents bucket is WORM (no DELETE policy on storage.objects), +// so a caller-bound remove() is silently blocked by RLS. +const serviceRemoveMock = vi.fn() +vi.mock('@/lib/auth/api-keys', () => ({ + createServiceClientNoCookies: () => ({ + storage: { from: vi.fn(() => ({ remove: serviceRemoveMock })) }, + }), +})) + import { GET, DELETE } from '../route' import { requireWritePermission } from '@/lib/auth/require-write' import { NextResponse } from 'next/server' @@ -55,6 +65,7 @@ beforeEach(() => { data: { signedUrl: 'https://example.com/signed' }, error: null, }) + serviceRemoveMock.mockResolvedValue({ data: [], error: null }) }) function makeReq(method: 'GET' | 'DELETE' = 'DELETE') { @@ -228,9 +239,17 @@ describe('DELETE /api/documents/[id]', () => { expect(status).toBe(200) expect(body.data).toEqual({ id: 'doc-1', deleted: true }) - expect(mockSupabase.storage.from).toHaveBeenCalledWith('documents') - const storageBucket = mockSupabase.storage.from.mock.results[0]?.value - expect(storageBucket.remove).toHaveBeenCalledWith(['documents/user-1/kvitto.pdf']) + // Both storage layouts are removed: the stored pointer plus the alternate + // candidate key. During the company-scoped path migration a document can + // exist under either prefix, and removing only the stored one would leave a + // readable orphan copy of a document the user asked to erase. The removal + // must go through the service-role client (WORM bucket: RLS silently + // blocks a caller-bound remove()), never the user-bound client. + expect(serviceRemoveMock).toHaveBeenCalledWith([ + 'documents/user-1/kvitto.pdf', + 'documents/company-1/user-1/kvitto.pdf', + ]) + expect(mockSupabase.storage.from).not.toHaveBeenCalled() expect(handler).toHaveBeenCalledOnce() expect(handler).toHaveBeenCalledWith( diff --git a/app/api/documents/verify/cron/__tests__/route.test.ts b/app/api/documents/verify/cron/__tests__/route.test.ts index 5b3cb3f4..9e51241b 100644 --- a/app/api/documents/verify/cron/__tests__/route.test.ts +++ b/app/api/documents/verify/cron/__tests__/route.test.ts @@ -192,6 +192,32 @@ describe('GET /api/documents/verify/cron', () => { expect(String(state.auditInserts[0].description)).not.toContain('DOCUMENT_OBJECT_MISSING') }) + it('verifies via the company-scoped fallback key when a concurrent backfill re-homed the object', async () => { + // The batch snapshot carries a stale legacy pointer: mid-batch, the + // Phase B backfill copied the object to the company-scoped key and + // removed the source. This must NOT produce a permanent false + // DOCUMENT_OBJECT_MISSING audit row for a healthy document. + const doc = makeDoc({ + id: 'doc-repointed', + storage_path: 'documents/user-1/1_receipt.pdf', + }) + const hash = registerObject('documents/company-1/user-1/1_receipt.pdf', 'healthy bytes') + state.documents = [{ ...doc, sha256_hash: hash }] + + const response = await GET(cronRequest()) + const json = await response.json() + + expect(state.auditInserts).toHaveLength(0) + expect(state.updates.map((u) => u.id)).toEqual(['doc-repointed']) + expect(json).toEqual({ + processed: 1, + verified: 1, + failures: 0, + missingObjects: 0, + errors: 0, + }) + }) + it('surfaces a missing storage object as an audit incident AND stamps the check', async () => { const missing = makeDoc({ id: 'doc-missing', storage_path: 'user-1/company-1/gone.pdf' }) const healthy = makeDoc({ id: 'doc-healthy', storage_path: 'user-1/company-1/ok.pdf' }) diff --git a/app/api/documents/verify/cron/route.ts b/app/api/documents/verify/cron/route.ts index 36f4aee1..4418f98f 100644 --- a/app/api/documents/verify/cron/route.ts +++ b/app/api/documents/verify/cron/route.ts @@ -1,6 +1,7 @@ import { createClient } from '@supabase/supabase-js' import { NextResponse } from 'next/server' import { withCronContext } from '@/lib/api/with-cron-context' +import { downloadDocumentObject } from '@/lib/core/documents/document-service' import { errorResponse, errorResponseFromCode } from '@/lib/errors/get-structured-error' /** @@ -55,9 +56,19 @@ export const GET = withCronContext('cron.documents_verify', async (_request, ctx let missingObjects = 0 const summary = await ctx.forEach('document', documents, async (doc, itemCtx) => { - const { data: fileData, error: downloadError } = await supabase.storage - .from('documents') - .download(doc.storage_path) + // Dual-layout download: the batch is snapshotted up front, and a + // concurrent Phase B backfill (scripts/backfill-document-storage-paths.ts) + // can re-home an object from the legacy uploader-scoped key to the + // company-scoped key (and later remove the source) mid-batch, leaving + // doc.storage_path stale. Trusting the stale pointer here wrote a + // PERMANENT false DOCUMENT_OBJECT_MISSING INTEGRITY_FAILURE row into the + // immutable audit log for a healthy document. The helper tries the + // stored pointer first, then the alternate layout. + const { blob: fileData, error: downloadError } = await downloadDocumentObject( + supabase, + doc.storage_path, + doc.company_id + ) if (downloadError || !fileData) { // The storage object is unreadable: surface it as an incident in the diff --git a/app/api/events/__tests__/route.test.ts b/app/api/events/__tests__/route.test.ts index b2ee0adf..8e3d53b6 100644 --- a/app/api/events/__tests__/route.test.ts +++ b/app/api/events/__tests__/route.test.ts @@ -19,18 +19,28 @@ vi.mock('@/lib/company/context', () => ({ getActiveCompanyId: vi.fn().mockResolvedValue('company-1'), })) -// Mock API key auth +// Mock API key auth. Only the three IO-bound helpers are faked: hasScope and +// DEFAULT_SCOPES stay real so the scope tests exercise the actual scope table. const mockValidateApiKey = vi.fn() const mockExtractBearerToken = vi.fn() const mockCreateServiceClientNoCookies = vi.fn() -vi.mock('@/lib/auth/api-keys', () => ({ - validateApiKey: (...args: unknown[]) => mockValidateApiKey(...args), - extractBearerToken: (...args: unknown[]) => mockExtractBearerToken(...args), - createServiceClientNoCookies: () => mockCreateServiceClientNoCookies(), -})) +vi.mock('@/lib/auth/api-keys', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + validateApiKey: (...args: unknown[]) => mockValidateApiKey(...args), + extractBearerToken: (...args: unknown[]) => mockExtractBearerToken(...args), + createServiceClientNoCookies: () => mockCreateServiceClientNoCookies(), + } +}) +import { DEFAULT_SCOPES } from '@/lib/auth/api-keys' import { GET } from '../route' +interface ErrorEnvelope { + error: { code: string; message: string; message_en?: string; details?: unknown } +} + describe('GET /api/events', () => { const mockUser = { id: 'user-1', email: 'test@test.se' } @@ -51,6 +61,33 @@ describe('GET /api/events', () => { }, ] + /** A successful validateApiKey result, scoped and live by default. */ + const apiKeyAuth = (overrides: Record = {}) => ({ + userId: 'user-1', + companyId: 'company-1', + apiKeyId: 'key-1', + apiKeyName: 'n8n poller', + scopes: ['events:read'], + mode: 'live', + ...overrides, + }) + + /** + * Wire the service-role client the API-key branch uses. The route makes at + * most two queries in order: company_members, then event_log. + */ + const withKeyClient = ( + results: { data?: unknown; error?: unknown }[], + ) => { + const keyClient = createQueuedMockSupabase() + for (const r of results) keyClient.enqueue(r) + mockCreateServiceClientNoCookies.mockReturnValue(keyClient.supabase) + return keyClient + } + + /** The membership row the re-check expects to find. */ + const membershipFound = { data: { company_id: 'company-1' } } + beforeEach(() => { vi.clearAllMocks() reset() @@ -58,6 +95,8 @@ describe('GET /api/events', () => { requireAuthMock.mockResolvedValue({ user: mockUser, supabase: mockSupabase, error: null }) }) + // ── Session auth (unchanged behaviour) ─────────────────────── + it('returns 401 when not authenticated', async () => { mockExtractBearerToken.mockReturnValue(null) requireAuthMock.mockResolvedValue({ @@ -75,6 +114,8 @@ describe('GET /api/events', () => { }) it('returns events with session auth', async () => { + // Exactly ONE queued result: if the session path wrongly ran the + // company_members re-check it would consume this and return no events. enqueue({ data: sampleEvents }) const request = createMockRequest('/api/events') @@ -89,22 +130,21 @@ describe('GET /api/events', () => { expect(body.data).toHaveLength(2) expect(body.cursor).toBe(2) expect(body.has_more).toBe(false) + expect(mockValidateApiKey).not.toHaveBeenCalled() + expect(response.headers.get('X-Gnubok-Mode')).toBeNull() }) + // ── API key auth ───────────────────────────────────────────── + it('returns events with API key auth', async () => { mockExtractBearerToken.mockReturnValue('gnubok_sk_test123') - mockValidateApiKey.mockResolvedValue({ userId: 'user-1' }) - - const apiKeySupabase = createQueuedMockSupabase() - apiKeySupabase.enqueue({ data: sampleEvents }) - mockCreateServiceClientNoCookies.mockReturnValue(apiKeySupabase.supabase) + mockValidateApiKey.mockResolvedValue(apiKeyAuth()) + withKeyClient([membershipFound, { data: sampleEvents }]) const request = createMockRequest('/api/events') const response = await GET(request) const { status, body } = await parseJsonResponse<{ data: typeof sampleEvents - cursor: number - has_more: boolean }>(response) expect(status).toBe(200) @@ -118,23 +158,175 @@ describe('GET /api/events', () => { const request = createMockRequest('/api/events') const response = await GET(request) - const { status, body } = await parseJsonResponse(response) + const { status, body } = await parseJsonResponse(response) expect(status).toBe(401) - expect(body).toEqual({ error: 'Invalid API key' }) + expect(body.error.code).toBe('UNAUTHORIZED') }) - it('returns 429 for rate-limited API key', async () => { + it('returns 429 with a RATE_LIMITED code for a rate-limited API key', async () => { mockExtractBearerToken.mockReturnValue('gnubok_sk_limited') mockValidateApiKey.mockResolvedValue({ error: 'Rate limit exceeded', status: 429 }) const request = createMockRequest('/api/events') const response = await GET(request) - const { status } = await parseJsonResponse(response) + const { status, body } = await parseJsonResponse(response) expect(status).toBe(429) + expect(body.error.code).toBe('RATE_LIMITED') }) + // ── Guard 1: scope ─────────────────────────────────────────── + + it('returns 403 INSUFFICIENT_SCOPE for a key without events:read', async () => { + mockExtractBearerToken.mockReturnValue('gnubok_sk_noscope') + mockValidateApiKey.mockResolvedValue( + apiKeyAuth({ scopes: ['reports:read', 'invoices:read'] }), + ) + const keyClient = withKeyClient([membershipFound, { data: sampleEvents }]) + + const request = createMockRequest('/api/events') + const response = await GET(request) + const { status, body } = await parseJsonResponse(response) + + expect(status).toBe(403) + expect(body.error.code).toBe('INSUFFICIENT_SCOPE') + expect(body.error.details).toMatchObject({ required_scope: 'events:read' }) + // Denied before any DB access. + expect(keyClient.supabase.from).not.toHaveBeenCalled() + }) + + it('returns 403 for a legacy null-scope key falling back to DEFAULT_SCOPES', async () => { + // validateApiKey substitutes DEFAULT_SCOPES when the api_keys row has + // scopes = NULL. Those six read scopes do NOT include events:read. + expect(DEFAULT_SCOPES).not.toContain('events:read') + + mockExtractBearerToken.mockReturnValue('gnubok_sk_legacy') + mockValidateApiKey.mockResolvedValue(apiKeyAuth({ scopes: DEFAULT_SCOPES })) + withKeyClient([membershipFound, { data: sampleEvents }]) + + const request = createMockRequest('/api/events') + const response = await GET(request) + const { status, body } = await parseJsonResponse(response) + + expect(status).toBe(403) + expect(body.error.code).toBe('INSUFFICIENT_SCOPE') + }) + + // ── Guard 2: company membership re-check ───────────────────── + + it('returns 404 when the key user is no longer a member of the bound company', async () => { + mockExtractBearerToken.mockReturnValue('gnubok_sk_offboarded') + mockValidateApiKey.mockResolvedValue(apiKeyAuth()) + // Membership lookup finds nothing (offboarded, or company archived). + withKeyClient([{ data: null }, { data: sampleEvents }]) + + const request = createMockRequest('/api/events') + const response = await GET(request) + const { status, body } = await parseJsonResponse(response) + + expect(status).toBe(404) + expect(body.error.code).toBe('NOT_FOUND') + }) + + it('returns 500 when the membership lookup itself fails', async () => { + mockExtractBearerToken.mockReturnValue('gnubok_sk_dberror') + mockValidateApiKey.mockResolvedValue(apiKeyAuth()) + withKeyClient([{ error: { message: 'connection failure' } }, { data: sampleEvents }]) + + const request = createMockRequest('/api/events') + const response = await GET(request) + const { status, body } = await parseJsonResponse(response) + + expect(status).toBe(500) + expect(body.error.code).toBe('INTERNAL_ERROR') + }) + + // ── Guard 3: test-mode keys ────────────────────────────────── + + it('serves a test-mode key and labels the response X-Gnubok-Mode: test', async () => { + mockExtractBearerToken.mockReturnValue('gnubok_sk_test_abc') + mockValidateApiKey.mockResolvedValue(apiKeyAuth({ mode: 'test' })) + withKeyClient([membershipFound, { data: sampleEvents }]) + + const request = createMockRequest('/api/events') + const response = await GET(request) + const { status, body } = await parseJsonResponse<{ data: typeof sampleEvents }>(response) + + // A read has no write path to simulate, so it is served (matching every v1 + // read) rather than blocked with TEST_KEY_WRITE_BLOCKED. + expect(status).toBe(200) + expect(body.data).toHaveLength(2) + expect(response.headers.get('X-Gnubok-Mode')).toBe('test') + }) + + it('does not label live-key responses with X-Gnubok-Mode', async () => { + mockExtractBearerToken.mockReturnValue('gnubok_sk_live') + mockValidateApiKey.mockResolvedValue(apiKeyAuth({ mode: 'live' })) + withKeyClient([membershipFound, { data: sampleEvents }]) + + const request = createMockRequest('/api/events') + const response = await GET(request) + + expect(response.headers.get('X-Gnubok-Mode')).toBeNull() + }) + + // ── Payload minimisation ───────────────────────────────────── + + it('minimises event payloads before returning them', async () => { + mockExtractBearerToken.mockReturnValue('gnubok_sk_ok') + mockValidateApiKey.mockResolvedValue(apiKeyAuth()) + withKeyClient([ + membershipFound, + { + data: [ + { + sequence: 7, + event_type: 'invoice.created', + entity_id: 'inv-7', + // userId is what minimisePayload strips: an internal + // auth.users.id that identifies the gnubok-side actor. + data: { userId: 'user-1', invoice: { id: 'inv-7', total: 1250 } }, + created_at: '2026-03-25T10:00:00Z', + }, + ], + }, + ]) + + const request = createMockRequest('/api/events') + const response = await GET(request) + const { status, body } = await parseJsonResponse<{ + data: { data: Record }[] + }>(response) + + expect(status).toBe(200) + expect(body.data[0].data).not.toHaveProperty('userId') + expect(body.data[0].data).toHaveProperty('invoice') + }) + + it('leaves non-object payloads untouched', async () => { + enqueue({ + data: [ + { + sequence: 3, + event_type: 'period.locked', + entity_id: null, + data: null, + created_at: '2026-03-25T10:00:00Z', + }, + ], + }) + + const request = createMockRequest('/api/events') + const response = await GET(request) + const { status, body } = await parseJsonResponse<{ data: { data: unknown }[] }>(response) + + expect(status).toBe(200) + expect(body.data[0].data).toBeNull() + }) + + // ── Query params + cursor semantics ────────────────────────── + it('supports after cursor parameter', async () => { enqueue({ data: [sampleEvents[1]] }) @@ -219,4 +411,18 @@ describe('GET /api/events', () => { expect(status).toBe(400) }) + + it('rejects invalid query params before running the event query for API keys', async () => { + mockExtractBearerToken.mockReturnValue('gnubok_sk_ok') + mockValidateApiKey.mockResolvedValue(apiKeyAuth()) + withKeyClient([membershipFound, { data: sampleEvents }]) + + const request = createMockRequest('/api/events', { + searchParams: { after: '-1' }, + }) + const response = await GET(request) + const { status } = await parseJsonResponse(response) + + expect(status).toBe(400) + }) }) diff --git a/app/api/events/route.ts b/app/api/events/route.ts index 8f7628b7..ad4684d0 100644 --- a/app/api/events/route.ts +++ b/app/api/events/route.ts @@ -1,11 +1,59 @@ import { NextResponse } from 'next/server' import { requireAuth } from '@/lib/auth/require-auth' -import { extractBearerToken, validateApiKey, createServiceClientNoCookies } from '@/lib/auth/api-keys' +import { + extractBearerToken, + validateApiKey, + createServiceClientNoCookies, + hasScope, + type ApiKeyMode, +} from '@/lib/auth/api-keys' import { validateQuery } from '@/lib/api/validate' import { EventsQuerySchema } from '@/lib/api/schemas' import { requireCompanyId } from '@/lib/company/context' import type { SupabaseClient } from '@supabase/supabase-js' import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message' +import { errorResponseFromCode } from '@/lib/errors/get-structured-error' +import { minimisePayload } from '@/lib/webhooks/handler' +import { createLogger } from '@/lib/logger' + +const log = createLogger('api/events') + +/** + * Scope that gates this endpoint. Declared in lib/auth/api-keys.ts as + * "Polla händelseloggen (event_log) som webhook-fallback": the scope existed + * from day one but was never enforced here, so a key holding only + * DEFAULT_SCOPES (the six read scopes a legacy null-scope key falls back to, + * none of which is events:read) could still drain the whole event log. + */ +const REQUIRED_SCOPE = 'events:read' as const + +/** Response header mirroring the v1 wrapper so integrators can see the key mode. */ +const MODE_HEADER = 'X-Gnubok-Mode' + +interface EventLogRow { + sequence: number + event_type: string + entity_id: string | null + data: unknown + created_at: string +} + +/** + * Project a stored event payload through the same minimisation the webhook + * fan-out applies (lib/webhooks/handler.ts `minimisePayload`). + * + * The event_log row stores the emit-site payload minus userId/companyId + * (lib/events/handlers/event-log-handler.ts `stripMetaFields`), which is a + * different and narrower projection than the webhook one. Routing the polled + * rows through minimisePayload keeps the pull surface from ever handing out + * more than the push surface for the same event, and means a future tightening + * (e.g. stripping personnummer from payroll payloads) lands in one place for + * both. GDPR Art.5(1)(c) data minimisation. + */ +function minimiseEventData(value: unknown): unknown { + if (!value || typeof value !== 'object' || Array.isArray(value)) return value + return minimisePayload(value as Record) +} /** * GET /api/events @@ -21,7 +69,12 @@ import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-m * Supports both session auth (browser) and API key auth (automation platforms). */ export async function GET(request: Request) { - // Dual auth: API key or session + // Dual auth: API key or session. This is a deliberate withRouteContext + // opt-out: the wrapper is cookie-session only and cannot express the API-key + // branch. The session branch below still goes through requireAuth(), so MFA + // (AAL2) stays enforced; the key branch runs the same guards the other two + // validateApiKey call sites run (lib/api/v1/with-api-v1.ts, + // extensions/general/mcp-server): scope, then company membership. let userId: string let supabase: SupabaseClient // When authenticated via an API key, the key is BOUND to a specific company. @@ -29,15 +82,39 @@ export async function GET(request: Request) { // active company: otherwise a key scoped to company A would leak company B's // events whenever the user's active_company_id happened to point elsewhere. let keyCompanyId: string | null = null + let keyMode: ApiKeyMode = 'live' const token = extractBearerToken(request) if (token?.startsWith('gnubok_sk_')) { const authResult = await validateApiKey(token) if ('error' in authResult) { - return NextResponse.json({ error: authResult.error }, { status: authResult.status }) + // validateApiKey only ever returns 401 (bad/unknown/refresh token) or + // 429 (rate limit): map both onto the canonical envelope, same as v1. + return errorResponseFromCode( + authResult.status === 429 ? 'RATE_LIMITED' : 'UNAUTHORIZED', + log, + { reason: authResult.error }, + ) } + + // Guard 1: scope. Mirrors with-api-v1.ts step 4. + if (!hasScope(authResult.scopes, REQUIRED_SCOPE)) { + return errorResponseFromCode('INSUFFICIENT_SCOPE', log, { + details: { required_scope: REQUIRED_SCOPE, granted_scopes: authResult.scopes }, + }) + } + userId = authResult.userId keyCompanyId = authResult.companyId + // Guard 3: test keys. A test key is simulation-only for WRITES (the v1 + // wrapper forces dry-run and blocks non-simulatable mutations with + // TEST_KEY_WRITE_BLOCKED). This endpoint has no write path at all, so the + // consistent behaviour for a read is the one v1 already ships: serve the + // key's own bound company and label the response, rather than 403 with a + // "cannot be simulated" message that would be untrue for a GET. + keyMode = authResult.mode + // Service-role client: RLS does NOT apply below this line, which is exactly + // why the membership re-check further down is mandatory. supabase = createServiceClientNoCookies() } else { // Session auth: requireAuth enforces MFA (AAL2) on hosted, unlike a bare @@ -54,7 +131,44 @@ export async function GET(request: Request) { // scope. requireCompanyId throws when there is no company, but guard the // key-bound path too so a malformed binding can't widen the query scope. if (!companyId) { - return NextResponse.json({ error: 'Forbidden' }, { status: 403 }) + return errorResponseFromCode('FORBIDDEN', log) + } + + // Guard 2: membership re-check, API-key path only. + // + // company_id on the api_keys row is a snapshot taken when the key was minted. + // Offboarding a user from a company does not revoke their keys, and the + // service-role client above bypasses RLS, so without this the key keeps + // draining the event log of a company its owner no longer belongs to. Both + // sibling call sites already re-check (with-api-v1.ts :349-370 and + // mcp-server/company-routing.ts :90-102); this closes the third. + // + // archived_at IS NULL follows company-routing: an archived company is a + // deactivated tenant and should stop feeding automation platforms. + // + // The session path is deliberately excluded: it runs on the request-scoped + // client where RLS (user_company_ids()) already enforces the same rule. + if (keyCompanyId !== null) { + const { data: membership, error: membershipError } = await supabase + .from('company_members') + .select('company_id, companies!inner(archived_at)') + .eq('user_id', userId) + .eq('company_id', companyId) + .is('companies.archived_at', null) + .maybeSingle() + + if (membershipError) { + log.error('failed to resolve company membership', membershipError) + return errorResponseFromCode('INTERNAL_ERROR', log, { + details: { reason: getUserErrorMessage(membershipError) }, + }) + } + + if (!membership) { + // 404 rather than 403, matching v1 and MCP: do not confirm the company + // exists to a caller who is no longer allowed to see it. + return errorResponseFromCode('NOT_FOUND', log, { details: { companyId } }) + } } // Validate query params @@ -81,14 +195,26 @@ export async function GET(request: Request) { const { data, error } = await query if (error) { - return NextResponse.json({ error: getUserErrorMessage(error) }, { status: 500 }) + log.error('event_log query failed', error) + return errorResponseFromCode('INTERNAL_ERROR', log, { + details: { reason: getUserErrorMessage(error) }, + }) } - const events = data ?? [] + const rows = (data ?? []) as EventLogRow[] + const events = rows.map((row) => ({ ...row, data: minimiseEventData(row.data) })) - return NextResponse.json({ + const response = NextResponse.json({ data: events, cursor: events.length > 0 ? events[events.length - 1].sequence : (after ?? 0), has_more: events.length === limit, }) + + // Signal test mode the same way the v1 wrapper does, so an integrator can see + // which key mode served the response without inspecting the body. + if (keyMode === 'test') { + response.headers.set(MODE_HEADER, 'test') + } + + return response } diff --git a/app/api/export/customers/__tests__/route.test.ts b/app/api/export/customers/__tests__/route.test.ts index d8d59964..5b0703c5 100644 --- a/app/api/export/customers/__tests__/route.test.ts +++ b/app/api/export/customers/__tests__/route.test.ts @@ -19,6 +19,8 @@ vi.mock('@/lib/supabase/fetch-all', () => ({ })) import { GET } from '../route' +import { encryptPersonnummer } from '@/lib/salary/personnummer' +import { UNDECRYPTABLE_PERSONAL_NUMBER_MASK } from '@/lib/customers/protect-personal-number' const mockUser = { id: 'user-1', email: 'test@test.se' } @@ -50,14 +52,14 @@ beforeEach(() => { describe('GET /api/export/customers', () => { it('returns 401 when unauthenticated', async () => { mockSupabase.auth.getUser.mockResolvedValue({ data: { user: null } }) - const res = await GET(createMockRequest('/api/export/customers')) + const res = await GET(createMockRequest('/api/export/customers'), { params: Promise.resolve({}) }) const { status } = await parseJsonResponse(res) expect(status).toBe(401) }) it('returns an xlsx customer register', async () => { enqueue({ data: { company_name: 'Acme AB' } }) - const res = await GET(createMockRequest('/api/export/customers')) + const res = await GET(createMockRequest('/api/export/customers'), { params: Promise.resolve({}) }) expect(res.status).toBe(200) expect(res.headers.get('Content-Type')).toContain('spreadsheetml') @@ -73,7 +75,7 @@ describe('GET /api/export/customers', () => { it('returns a CSV with BOM when format=csv', async () => { enqueue({ data: { company_name: 'Acme AB' } }) - const res = await GET(createMockRequest('/api/export/customers', { searchParams: { format: 'csv' } })) + const res = await GET(createMockRequest('/api/export/customers', { searchParams: { format: 'csv' } }), { params: Promise.resolve({}) }) expect(res.status).toBe(200) expect(res.headers.get('Content-Type')).toContain('text/csv') @@ -81,4 +83,71 @@ describe('GET /api/export/customers', () => { expect([buf[0], buf[1], buf[2]]).toEqual([0xef, 0xbb, 0xbf]) expect(buf.toString('utf-8')).toContain('Göteborg') }) + + it('masks an encrypted personal_number: never ciphertext, never the full number', async () => { + // customers.personal_number holds AES-256-GCM ciphertext (migration + // 20260726110000). The export must show what the UI shows a member: + // '********-1234', not the hex blob and not the decrypted personnummer. + const stored = encryptPersonnummer('19900101-1234') // synthetic + mockFetchAllRows.mockResolvedValue([ + { + ...CUSTOMER, + id: 'c2', + name: 'Anna Andersson', + customer_type: 'individual', + org_number: null, + vat_number: null, + personal_number: stored, + }, + ]) + enqueue({ data: { company_name: 'Acme AB' } }) + + const res = await GET(createMockRequest('/api/export/customers'), { params: Promise.resolve({}) }) + expect(res.status).toBe(200) + + const buf = Buffer.from(await res.arrayBuffer()) + const wb = XLSX.read(new Uint8Array(buf), { type: 'array' }) + const sheet = wb.Sheets[wb.SheetNames[0]] + const rows = XLSX.utils.sheet_to_json(sheet, { header: 1 }) + const serialized = JSON.stringify(rows) + + expect(rows[1] as string[]).toContain('********-1234') + // Neither the ciphertext nor the birthdate half of the number may appear. + expect(serialized).not.toContain(stored) + expect(serialized).not.toContain('19900101') + }) + + it('falls back to a placeholder mask when the stored value cannot be decrypted', async () => { + // Hex of the shape the DB CHECK accepts, but with an auth tag that can + // never verify. One bad row must neither leak nor abort the export. + const garbage = 'ab'.repeat(40) + mockFetchAllRows.mockResolvedValue([ + { + ...CUSTOMER, + id: 'c3', + name: 'Berit Bengtsson', + customer_type: 'individual', + org_number: null, + vat_number: null, + personal_number: garbage, + }, + ]) + enqueue({ data: { company_name: 'Acme AB' } }) + + const res = await GET(createMockRequest('/api/export/customers', { searchParams: { format: 'csv' } }), { params: Promise.resolve({}) }) + expect(res.status).toBe(200) + + const text = Buffer.from(await res.arrayBuffer()).toString('utf-8') + expect(text).toContain(UNDECRYPTABLE_PERSONAL_NUMBER_MASK) + expect(text).not.toContain(garbage) + }) + + it('keeps the raw org number for business customers', async () => { + // Org numbers are public registry data: they export untouched, and the + // masking branch only engages when org_number is absent. + enqueue({ data: { company_name: 'Acme AB' } }) + const res = await GET(createMockRequest('/api/export/customers', { searchParams: { format: 'csv' } }), { params: Promise.resolve({}) }) + const text = Buffer.from(await res.arrayBuffer()).toString('utf-8') + expect(text).toContain('5560217780') + }) }) diff --git a/app/api/export/customers/route.ts b/app/api/export/customers/route.ts index 376abb5f..802c0b38 100644 --- a/app/api/export/customers/route.ts +++ b/app/api/export/customers/route.ts @@ -4,6 +4,7 @@ import { errorResponse } from '@/lib/errors/get-structured-error' import { fetchAllRows } from '@/lib/supabase/fetch-all' import { textColumn, integerColumn } from '@/lib/reports/xlsx-export' import { buildRegisterExport, parseExportFormat, todayIso } from '@/lib/export/register-export' +import { maskStoredCustomerPersonalNumber } from '@/lib/customers/protect-personal-number' import type { Customer } from '@/types' /** @@ -58,7 +59,14 @@ export const GET = withRouteContext( rows: customers, mapRow: (c) => [ c.name, - c.org_number ?? c.personal_number, + // personal_number holds AES-256-GCM ciphertext (migration + // 20260726110000). Decrypt-and-mask exactly like the customer + // read surfaces: the export shows a member the same + // '********-1234' the UI shows, never the ciphertext and never + // the full personnummer (GDPR Art. 5(1)(f) data minimization on + // a file that leaves the system). Decrypt failures fall back to + // a placeholder mask instead of aborting the export. + c.org_number ?? maskStoredCustomerPersonalNumber(c.personal_number), c.customer_type, c.email, c.phone, diff --git a/app/api/extensions/push-notifications/cron/__tests__/route.test.ts b/app/api/extensions/push-notifications/cron/__tests__/route.test.ts new file mode 100644 index 00000000..9fd1eb5f --- /dev/null +++ b/app/api/extensions/push-notifications/cron/__tests__/route.test.ts @@ -0,0 +1,114 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { NextResponse } from 'next/server' + +vi.mock('@supabase/supabase-js', () => ({ + createClient: vi.fn().mockReturnValue({}), +})) + +vi.mock('@/lib/extensions/loader', () => ({ + loadExtensions: vi.fn(), +})) + +vi.mock('@/lib/extensions/registry', () => ({ + extensionRegistry: { + get: vi.fn(), + }, +})) + +vi.mock('@/extensions/general/push-notifications/notification-scheduler', () => ({ + sendTaxDeadlineNotifications: vi.fn(), + sendInvoiceNotifications: vi.fn(), + sendMissingUnderlagNotifications: vi.fn(), +})) + +vi.mock('@/lib/auth/cron', () => ({ + verifyCronSecret: vi.fn().mockReturnValue(null), +})) + +import { GET } from '../route' +import { extensionRegistry } from '@/lib/extensions/registry' +import { loadExtensions } from '@/lib/extensions/loader' +import { + sendTaxDeadlineNotifications, + sendInvoiceNotifications, + sendMissingUnderlagNotifications, +} from '@/extensions/general/push-notifications/notification-scheduler' +import { verifyCronSecret } from '@/lib/auth/cron' + +const mockRegistryGet = vi.mocked(extensionRegistry.get) +const mockVerifyCronSecret = vi.mocked(verifyCronSecret) +const mockTax = vi.mocked(sendTaxDeadlineNotifications) +const mockInvoice = vi.mocked(sendInvoiceNotifications) +const mockUnderlag = vi.mocked(sendMissingUnderlagNotifications) + +function makeRequest() { + return new Request('http://localhost/api/extensions/push-notifications/cron', { + headers: { authorization: 'Bearer synthetic-cron-secret' }, + }) +} + +beforeEach(() => { + vi.clearAllMocks() + mockVerifyCronSecret.mockReturnValue(null) + process.env.NEXT_PUBLIC_SUPABASE_URL = 'https://synthetic.supabase.co' + process.env.SUPABASE_SERVICE_ROLE_KEY = 'synthetic-service-role-key' +}) + +describe('GET /api/extensions/push-notifications/cron', () => { + it('returns 401 when the cron secret is rejected', async () => { + mockVerifyCronSecret.mockReturnValue( + NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + ) + + const response = await GET(makeRequest()) + + expect(response.status).toBe(401) + expect(mockTax).not.toHaveBeenCalled() + }) + + it('returns 503 EXTENSION_DISABLED when the extension is not in the registry', async () => { + // Physical extension routes deploy in every build; the registry, generated + // from extensions.config.json, is what turns them on. Disabled must mean + // no sends AND a visible failure if the cron is ever scheduled anyway. + mockRegistryGet.mockReturnValue(undefined) + + const response = await GET(makeRequest()) + const body = await response.json() + + expect(response.status).toBe(503) + expect(body.code).toBe('EXTENSION_DISABLED') + expect(mockTax).not.toHaveBeenCalled() + expect(mockInvoice).not.toHaveBeenCalled() + expect(mockUnderlag).not.toHaveBeenCalled() + }) + + it('runs all three schedulers and sums the totals when enabled', async () => { + mockRegistryGet.mockReturnValue({ id: 'push-notifications' } as never) + mockTax.mockResolvedValue({ sent: 2, skipped: 1 }) + mockInvoice.mockResolvedValue({ sent: 3, skipped: 0 }) + mockUnderlag.mockResolvedValue({ sent: 1, skipped: 4 }) + + const response = await GET(makeRequest()) + const body = await response.json() + + expect(loadExtensions).toHaveBeenCalled() + expect(mockRegistryGet).toHaveBeenCalledWith('push-notifications') + expect(response.status).toBe(200) + expect(body.success).toBe(true) + expect(body.totalSent).toBe(6) + expect(body.totalSkipped).toBe(5) + expect(body.details.taxDeadlines).toEqual({ sent: 2, skipped: 1 }) + }) + + it('returns the error envelope when Supabase configuration is missing', async () => { + mockRegistryGet.mockReturnValue({ id: 'push-notifications' } as never) + delete process.env.NEXT_PUBLIC_SUPABASE_URL + + const response = await GET(makeRequest()) + const body = await response.json() + + expect(response.status).toBe(500) + expect(body.error.code).toBe('INTERNAL_ERROR') + expect(mockTax).not.toHaveBeenCalled() + }) +}) diff --git a/app/api/extensions/push-notifications/cron/route.ts b/app/api/extensions/push-notifications/cron/route.ts index e5ef17d6..50b2e1e5 100644 --- a/app/api/extensions/push-notifications/cron/route.ts +++ b/app/api/extensions/push-notifications/cron/route.ts @@ -1,6 +1,7 @@ import { createClient } from '@supabase/supabase-js' import { NextResponse } from 'next/server' import { loadExtensions } from '@/lib/extensions/loader' +import { extensionRegistry } from '@/lib/extensions/registry' import { sendTaxDeadlineNotifications, sendInvoiceNotifications, @@ -12,11 +13,29 @@ import { errorResponse, errorResponseFromCode } from '@/lib/errors/get-structure /** * GET /api/extensions/push-notifications/cron: daily 09:00 UTC. * Sends due tax, invoice and missing-underlag push notifications. + * + * NOT scheduled: this path is absent from vercel.json's crons (and therefore + * from the Docker crontabs generated from it). Adding it there is a product + * decision, not a code change. */ export const GET = withCronContext('cron.push_notifications', async (_request, ctx) => { - // Ensure extensions are loaded so event handlers are registered. + // Load the registry so it reflects extensions.config.json. loadExtensions() + // Physical routes under app/api/extensions// compile into EVERY build, + // including the core-with-zero-extensions one: the registry (generated from + // extensions.config.json) is what actually switches an extension on. Mirror + // the ext/[...path] dispatcher: a disabled extension must not expose a live + // send/query surface, and a scheduled-but-disabled cron must fail visibly + // (503) instead of quietly doing the work anyway. + if (!extensionRegistry.get('push-notifications')) { + ctx.log.warn('push-notifications extension is not enabled; cron refused') + return NextResponse.json( + { error: 'Push notifications extension is not enabled', code: 'EXTENSION_DISABLED' }, + { status: 503 } + ) + } + const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL const supabaseServiceKey = process.env.SUPABASE_SERVICE_ROLE_KEY diff --git a/app/api/extensions/skatteverket/skattekonto/drift/route.ts b/app/api/extensions/skatteverket/skattekonto/drift/route.ts index 3d5ec084..49ff570d 100644 --- a/app/api/extensions/skatteverket/skattekonto/drift/route.ts +++ b/app/api/extensions/skatteverket/skattekonto/drift/route.ts @@ -13,8 +13,9 @@ ensureInitialized() * company. Backs the dashboard SkattekontoDriftTile. Returns null when no * snapshot exists yet (fresh company, never synced). * - * Access is recorded through the structured logger (Sentry / Vercel logs) - * because the response carries sensitive GL drift figures. Persisting every + * Access is recorded through the structured logger (Vercel logs; the + * observability sink only sees errors and no-ops until a provider is + * configured) because the response carries sensitive GL drift figures. Persisting every * dashboard tile poll into event_log would be too noisy: the structured * log line gives an auditable record without overrunning the 30-day event * log retention (SOC 2 CC8.1, ISO 27001 A.8.15). diff --git a/app/api/import/opening-balance/correct/route.ts b/app/api/import/opening-balance/correct/route.ts index f390a5f4..2ffb8bee 100644 --- a/app/api/import/opening-balance/correct/route.ts +++ b/app/api/import/opening-balance/correct/route.ts @@ -180,7 +180,8 @@ export const POST = withRouteContext( // ASVS V16: durable audit sink for a failed correction. The core event bus // has no opening_balance.* correction event type and lib/events/types.ts is // outside the scope of this change, so the failure is recorded via the - // structured logger: it lands in the JSON log sink (Vercel/Sentry), tagged + // structured logger: it lands in the JSON log (Vercel logs, plus the + // observability sink, which no-ops until a provider is configured), tagged // `audit: true` + both entry ids so an operator can reconcile the period by // hand. (Follow-up: promote to a typed event persisted to event_log.) const auditCorrectionFailure = (fields: Record) => { diff --git a/app/api/import/sie/[id]/replace/__tests__/route.test.ts b/app/api/import/sie/[id]/replace/__tests__/route.test.ts new file mode 100644 index 00000000..5cdbde98 --- /dev/null +++ b/app/api/import/sie/[id]/replace/__tests__/route.test.ts @@ -0,0 +1,118 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +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 }), +})) + +// The route delegates everything to replaceSIEImport (which itself runs the +// replace_sie_import RPC on the service client). The route's own contract is +// what these tests pin: auth, the 403 mapping of the RPC's authorization +// raise, the 400 fallback, and the success envelope. +const replaceSIEImportMock = vi.fn() +vi.mock('@/lib/import/sie-import', () => ({ + replaceSIEImport: (...args: unknown[]) => replaceSIEImportMock(...args), +})) + +import { POST } from '../route' +import { NextResponse } from 'next/server' + +const mockUser = { id: 'user-1', email: 'test@test.se' } + +function makeReq() { + return new Request('http://localhost/api/import/sie/import-1/replace', { method: 'POST' }) +} + +beforeEach(() => { + vi.clearAllMocks() + reset() + requireAuthMock.mockResolvedValue({ user: mockUser, supabase: mockSupabase, error: null }) +}) + +describe('POST /api/import/sie/[id]/replace', () => { + 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: 'import-1' })) + expect(res.status).toBe(401) + expect(replaceSIEImportMock).not.toHaveBeenCalled() + }) + + it('maps the RPC authorization raise (42501) to 403 SIE_REPLACE_FORBIDDEN', async () => { + // replaceSIEImport flattens the PostgREST error into a message string, so + // the route matches the raise text pinned by migration 20260727120000. + replaceSIEImportMock.mockResolvedValue({ + success: false, + deletedEntries: 0, + error: 'Kunde inte ersätta import: Only company owners and admins can replace SIE imports', + }) + + const res = await POST(makeReq(), createMockRouteParams({ id: 'import-1' })) + const { status, body } = await parseJsonResponse<{ + error: { code: string; message: string } + }>(res) + + expect(status).toBe(403) + expect(body.error.code).toBe('SIE_REPLACE_FORBIDDEN') + // Swedish user-facing message, not raw Postgres prose. + expect(body.error.message).toBe( + 'Endast ägare eller administratörer kan ersätta en SIE-import.', + ) + }) + + it('returns 400 SIE_REPLACE_FAILED with the reason for other failures', async () => { + replaceSIEImportMock.mockResolvedValue({ + success: false, + deletedEntries: 0, + error: 'Kan inte ersätta import i ett låst eller stängt räkenskapsår. Öppna perioden först.', + }) + + const res = await POST(makeReq(), createMockRouteParams({ id: 'import-1' })) + const { status, body } = await parseJsonResponse<{ + error: { code: string; details?: { reason?: string } } + }>(res) + + expect(status).toBe(400) + expect(body.error.code).toBe('SIE_REPLACE_FAILED') + expect(body.error.details?.reason).toContain('låst eller stängt') + }) + + it('passes the authorising user through and returns the deleted count', async () => { + replaceSIEImportMock.mockResolvedValue({ success: true, deletedEntries: 42 }) + + const res = await POST(makeReq(), createMockRouteParams({ id: 'import-1' })) + const { status, body } = await parseJsonResponse<{ + success: boolean + deletedEntries: number + }>(res) + + expect(status).toBe(200) + expect(body.success).toBe(true) + expect(body.deletedEntries).toBe(42) + expect(replaceSIEImportMock).toHaveBeenCalledWith( + mockSupabase, + 'company-1', + 'import-1', + 'user-1', + ) + }) +}) diff --git a/app/api/import/sie/[id]/replace/route.ts b/app/api/import/sie/[id]/replace/route.ts index c79047cf..39f5fcdd 100644 --- a/app/api/import/sie/[id]/replace/route.ts +++ b/app/api/import/sie/[id]/replace/route.ts @@ -18,12 +18,25 @@ export const POST = withRouteContext( 'sie_import.replace', async (_request, ctx, { params }: { params: Promise<{ id: string }> }) => { const { id } = await params - const { supabase, companyId, log, requestId } = ctx + const { supabase, companyId, user, log, requestId } = ctx const opLog = log.child({ sieImportId: id }) - const result = await replaceSIEImport(supabase, companyId!, id) + // The replace_sie_import RPC gates on owner/admin membership. It usually + // runs on the service client where auth.uid() is NULL, so the authorising + // user is passed explicitly; the RPC honors it only for service_role + // callers (migration 20260727120000). + const result = await replaceSIEImport(supabase, companyId!, id, user.id) if (!result.success) { + // The RPC raises 42501 with this exact text when the actor is not an + // owner/admin. replaceSIEImport flattens the PostgREST error into a + // message string, so the route detects the authorization raise by the + // text, which is pinned by migration 20260727120000 and by + // lib/import/__tests__/sie-import.replace.pg.test.ts. Surface it as a + // structured 403 instead of a 400 with raw English prose in details. + if (result.error?.includes('Only company owners and admins can replace SIE imports')) { + return errorResponseFromCode('SIE_REPLACE_FORBIDDEN', opLog, { requestId }) + } return errorResponseFromCode('SIE_REPLACE_FAILED', opLog, { requestId, details: { reason: result.error }, diff --git a/app/api/import/sie/mappings/__tests__/route.test.ts b/app/api/import/sie/mappings/__tests__/route.test.ts index b882f13e..bc74a558 100644 --- a/app/api/import/sie/mappings/__tests__/route.test.ts +++ b/app/api/import/sie/mappings/__tests__/route.test.ts @@ -4,6 +4,23 @@ * 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. + * + * The route has no 404 path: PUT upserts (a missing mapping is created, not + * rejected) and DELETE is idempotent (removing an unknown source account is a + * no-op, not a miss). The DELETE test below pins that contract. + * + * The PUT column-level tests use createRecordingSupabase() instead of the + * shared queued mock: the queued mock is a Proxy that discards call arguments, + * so it cannot see which columns the route actually writes, which is exactly + * what regressed here (source_name silently dropped from the upsert). + * + * The POST tests do the same one level deeper. Mocking saveMappings only + * proves which value the route passed as the second positional argument; it + * cannot show which COLUMN that value ends up in. That is what hid the bug + * this file used to pin: the route passed user.id where saveMappings expects + * companyId, and both are strings, so nothing complained. One POST test + * therefore runs the real saveMappings against a recording Supabase double and + * asserts on the row it upserts. */ import { describe, it, expect, vi, beforeEach } from 'vitest' import { NextResponse } from 'next/server' @@ -35,6 +52,78 @@ import { GET, POST, PUT, DELETE } from '../route' const emptyParams = { params: Promise.resolve({}) } +type StoredMapping = { source_name: string | null } | null + +type ReadChain = { + eq: (column: string, value: unknown) => ReadChain + maybeSingle: () => Promise<{ data: StoredMapping; error: null }> +} + +type UpsertCall = { + table: string + payload: Record + options: { onConflict?: string } | undefined +} + +type BatchUpsertCall = { + table: string + rows: Record[] + options: { onConflict?: string } | undefined +} + +/** + * Supabase double for the POST path, which upserts an array of rows in + * batches. Records every row verbatim so a test can assert which column each + * value landed in. + */ +function createBatchRecordingSupabase() { + const upserts: BatchUpsertCall[] = [] + + const supabase = { + from: (table: string) => ({ + upsert: async (rows: Record[], options?: { onConflict?: string }) => { + upserts.push({ table, rows, options }) + return { data: null, error: null } + }, + }), + } + + return { supabase, upserts } +} + +/** + * Supabase double that records the upsert payload and options verbatim. + * `stored` is the mapping row the read-back finds (null = first save). + */ +function createRecordingSupabase(stored: StoredMapping) { + const upserts: UpsertCall[] = [] + const readBackColumns: string[] = [] + + const readChain: ReadChain = { + eq: () => readChain, + maybeSingle: async () => ({ data: stored, error: null }), + } + + const supabase = { + from: (table: string) => ({ + select: (columns: string) => { + readBackColumns.push(columns) + return readChain + }, + upsert: (payload: Record, options?: { onConflict?: string }) => { + upserts.push({ table, payload, options }) + return { + select: () => ({ + single: async () => ({ data: payload, error: null }), + }), + } + }, + }), + } + + return { supabase, upserts, readBackColumns } +} + describe('/api/import/sie/mappings', () => { beforeEach(() => { vi.clearAllMocks() @@ -82,13 +171,47 @@ describe('/api/import/sie/mappings', () => { }) const response = await POST(request, emptyParams) - const { status, body } = await parseJsonResponse<{ error: string }>(response) + const { status, body } = await parseJsonResponse<{ error: string; type: string }>(response) expect(status).toBe(400) - expect(body.error).toBe('Invalid mappings data') + expect(body.type).toBe('validation_error') + expect(saveMappingsMock).not.toHaveBeenCalled() }) - it('POST saves the mappings', async () => { + it('POST rejects wrongly-typed mapping elements with 400 instead of a Postgres 500', async () => { + // Element-level payloads used to be unvalidated: a numeric sourceAccount + // or a string confidence sailed through to Postgres and surfaced as 500. + const badElements = [ + [{ sourceAccount: 1920, targetAccount: '1930' }], // number, not string + [{ sourceAccount: '1920', targetAccount: '1930', confidence: 'high' }], + [{ sourceAccount: '1920', targetAccount: '1930', matchType: 'guess' }], + [{ sourceAccount: '', targetAccount: '1930' }], // empty source key + ['not-an-object'], + ] + for (const mappings of badElements) { + const response = await POST( + createMockRequest('/api/import/sie/mappings', { method: 'POST', body: { mappings } }), + emptyParams, + ) + expect(response.status, `payload ${JSON.stringify(mappings)} must be a 400`).toBe(400) + } + expect(saveMappingsMock).not.toHaveBeenCalled() + }) + + it('POST accepts unmapped elements (no targetAccount): saveMappings filters them', async () => { + const mappings = [ + { sourceAccount: '1920', targetAccount: '1930' }, + { sourceAccount: '8888' }, // not yet mapped: valid on the wire + ] + const response = await POST( + createMockRequest('/api/import/sie/mappings', { method: 'POST', body: { mappings } }), + emptyParams, + ) + expect(response.status).toBe(200) + expect(saveMappingsMock).toHaveBeenCalledWith(supabase, 'company-1', mappings, 'user-1') + }) + + it('POST saves the mappings under the active company', async () => { const mappings = [{ sourceAccount: '1920', targetAccount: '1930' }] const request = createMockRequest('/api/import/sie/mappings', { method: 'POST', @@ -100,7 +223,70 @@ describe('/api/import/sie/mappings', () => { expect(status).toBe(200) expect(body.success).toBe(true) - expect(saveMappingsMock).toHaveBeenCalledWith(supabase, 'user-1', mappings) + // saveMappings(supabase, companyId, mappings): the second argument is the + // tenant key and is written straight into sie_account_mappings.company_id. + // The route used to pass user.id here; both are strings, so the compiler + // could not tell them apart. + expect(saveMappingsMock).toHaveBeenCalledWith(supabase, 'company-1', mappings, 'user-1') + expect(saveMappingsMock.mock.calls[0][1]).not.toBe('user-1') + }) + + it('POST writes company_id, not the user id, into the mapping row', async () => { + // One level deeper than the mocked-argument assertion above: run the real + // saveMappings so the test sees which COLUMN the value lands in. company_id + // is NOT NULL and FK-bound to companies, so a user UUID there can never + // persist. + const { saveMappings: realSaveMappings } = + await vi.importActual('@/lib/import/sie-import') + const { supabase: recording, upserts } = createBatchRecordingSupabase() + requireAuthMock.mockResolvedValue({ user: { id: 'user-1' }, supabase: recording }) + saveMappingsMock.mockImplementation(realSaveMappings) + + const request = createMockRequest('/api/import/sie/mappings', { + method: 'POST', + body: { + mappings: [ + { + sourceAccount: '1920', + sourceName: 'Bank', + targetAccount: '1930', + confidence: 1.0, + matchType: 'manual', + }, + ], + }, + }) + + const response = await POST(request, emptyParams) + expect(response.status).toBe(200) + + expect(upserts).toHaveLength(1) + expect(upserts[0].table).toBe('sie_account_mappings') + expect(upserts[0].rows).toHaveLength(1) + expect(upserts[0].rows[0]).toMatchObject({ + company_id: 'company-1', + source_account: '1920', + target_account: '1930', + }) + expect(upserts[0].rows[0].company_id).not.toBe('user-1') + expect(upserts[0].options?.onConflict).toBe('company_id,source_account') + }) + + it('POST ignores a company id smuggled into the body', async () => { + // Tenancy comes from withRouteContext (membership-validated), never from + // the caller. A body field naming another company must not redirect the + // write. + const mappings = [{ sourceAccount: '1920', targetAccount: '1930' }] + const request = createMockRequest('/api/import/sie/mappings', { + method: 'POST', + body: { mappings, companyId: 'company-2', company_id: 'company-2' }, + }) + + const response = await POST(request, emptyParams) + expect(response.status).toBe(200) + + expect(saveMappingsMock).toHaveBeenCalledWith(supabase, 'company-1', mappings, 'user-1') + expect(saveMappingsMock.mock.calls[0][1]).not.toBe('company-2') }) it('GET lists the saved mappings', async () => { @@ -125,7 +311,26 @@ describe('/api/import/sie/mappings', () => { expect(status).toBe(400) }) + it('PUT rejects wrongly-typed fields with 400 instead of a Postgres 500', async () => { + const badBodies = [ + { sourceAccount: '1920', targetAccount: 1930 }, // number, not string + { sourceAccount: 1920, targetAccount: '1930' }, + { sourceAccount: '1920', targetAccount: '1930', sourceName: 42 }, + { sourceAccount: '1920', targetAccount: '' }, // empty target + ] + for (const body of badBodies) { + const response = await PUT( + createMockRequest('/api/import/sie/mappings', { method: 'PUT', body }), + emptyParams, + ) + const { status, body: parsed } = await parseJsonResponse<{ type?: string }>(response) + expect(status, `body ${JSON.stringify(body)} must be a 400`).toBe(400) + expect(parsed.type).toBe('validation_error') + } + }) + it('PUT upserts a single mapping', async () => { + enqueue({ data: null }) // read-back of the stored SIE label enqueue({ data: { source_account: '1920', target_account: '1930' } }) const request = createMockRequest('/api/import/sie/mappings', { @@ -140,6 +345,95 @@ describe('/api/import/sie/mappings', () => { expect(body.data.target_account).toBe('1930') }) + it('PUT returns 500 when the stored-label read-back fails', async () => { + enqueue({ error: { code: '42P01', message: 'relation does not exist' } }) + + const request = createMockRequest('/api/import/sie/mappings', { + method: 'PUT', + body: { sourceAccount: '1920', targetAccount: '1930' }, + }) + + const response = await PUT(request, emptyParams) + const { status } = await parseJsonResponse(response) + + expect(status).toBe(500) + }) + + it('PUT keeps the stored SIE label when the client does not resend it', async () => { + // Re-save: the mapping row already carries the label from the SIE file's + // #KONTO record. The client only sends the new target account. + const { supabase: recording, upserts, readBackColumns } = + createRecordingSupabase({ source_name: 'Kassa' }) + requireAuthMock.mockResolvedValue({ user: { id: 'user-1' }, supabase: recording }) + + const request = createMockRequest('/api/import/sie/mappings', { + method: 'PUT', + body: { sourceAccount: '1910', targetAccount: '1930' }, + }) + + const response = await PUT(request, emptyParams) + expect(response.status).toBe(200) + + expect(readBackColumns).toEqual(['source_name']) + expect(upserts).toHaveLength(1) + // Full column set: every column the route is expected to write. id, + // created_at and updated_at are deliberately absent (DB defaults/trigger). + expect(upserts[0].payload).toEqual({ + user_id: 'user-1', + company_id: 'company-1', + source_account: '1910', + source_name: 'Kassa', + target_account: '1930', + confidence: 1.0, + match_type: 'manual', + }) + // (user_id, source_account) was dropped by the multi-tenant refactor. + expect(upserts[0].options?.onConflict).toBe('company_id,source_account') + }) + + it('PUT sets the SIE label on a first save when the client sends it', async () => { + const { supabase: recording, upserts } = createRecordingSupabase(null) + requireAuthMock.mockResolvedValue({ user: { id: 'user-1' }, supabase: recording }) + + const request = createMockRequest('/api/import/sie/mappings', { + method: 'PUT', + body: { sourceAccount: '1910', targetAccount: '1930', sourceName: 'Bankgiro' }, + }) + + const response = await PUT(request, emptyParams) + expect(response.status).toBe(200) + + expect(upserts[0].payload.source_name).toBe('Bankgiro') + }) + + it('PUT prefers the label the client sends over the stored one', async () => { + const { supabase: recording, upserts } = createRecordingSupabase({ source_name: 'Kassa' }) + requireAuthMock.mockResolvedValue({ user: { id: 'user-1' }, supabase: recording }) + + const request = createMockRequest('/api/import/sie/mappings', { + method: 'PUT', + body: { sourceAccount: '1910', targetAccount: '1930', sourceName: 'Kassa och bank' }, + }) + + await PUT(request, emptyParams) + + expect(upserts[0].payload.source_name).toBe('Kassa och bank') + }) + + it('PUT leaves source_name null when no label is known anywhere', async () => { + const { supabase: recording, upserts } = createRecordingSupabase(null) + requireAuthMock.mockResolvedValue({ user: { id: 'user-1' }, supabase: recording }) + + const request = createMockRequest('/api/import/sie/mappings', { + method: 'PUT', + body: { sourceAccount: '1910', targetAccount: '1930', sourceName: ' ' }, + }) + + await PUT(request, emptyParams) + + expect(upserts[0].payload.source_name).toBeNull() + }) + it('DELETE returns 403 for a viewer', async () => { requireWriteMock.mockResolvedValue({ ok: false, @@ -166,4 +460,18 @@ describe('/api/import/sie/mappings', () => { expect(status).toBe(200) expect(body.success).toBe(true) }) + + it('DELETE of an unknown source account succeeds: the route has no 404 path', async () => { + enqueue({ data: null }) + + const request = createMockRequest('/api/import/sie/mappings', { + method: 'DELETE', + searchParams: { sourceAccount: '9999' }, + }) + + const response = await DELETE(request, emptyParams) + const { status } = await parseJsonResponse<{ success: boolean }>(response) + + expect(status).toBe(200) + }) }) diff --git a/app/api/import/sie/mappings/route.ts b/app/api/import/sie/mappings/route.ts index 32093fb4..c522c8c2 100644 --- a/app/api/import/sie/mappings/route.ts +++ b/app/api/import/sie/mappings/route.ts @@ -1,9 +1,39 @@ import { NextResponse } from 'next/server' +import { z } from 'zod' import { withRouteContext } from '@/lib/api/with-route-context' +import { validateBody } from '@/lib/api/validate' import { saveMappings } from '@/lib/import/sie-import' import type { AccountMapping } from '@/lib/import/types' import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message' +// Mirrors what saveMappings() (lib/import/sie-import.ts) reads off each +// element: sourceAccount/sourceName/targetAccount/confidence/matchType land +// verbatim in sie_account_mappings columns. Everything except sourceAccount is +// optional because saveMappings itself tolerates absence: it filters out +// elements with a falsy targetAccount (unmapped accounts travel in the same +// array), and confidence/match_type fall back to their column defaults. +// The point of the schema is type safety: a wrongly-typed element used to +// reach Postgres and surface as a 500 instead of a 400. +const SieMappingElementSchema = z.object({ + sourceAccount: z.string().min(1).max(20), + sourceName: z.string().max(200).nullish(), + targetAccount: z.string().max(20).nullish(), + targetName: z.string().max(200).nullish(), + confidence: z.number().min(0).max(1).nullish(), + matchType: z.enum(['exact', 'name', 'class', 'manual', 'bas_range']).nullish(), + isOverride: z.boolean().nullish(), +}) + +const SaveMappingsSchema = z.object({ + mappings: z.array(SieMappingElementSchema), +}) + +const UpdateMappingSchema = z.object({ + sourceAccount: z.string().min(1).max(20), + targetAccount: z.string().min(1).max(20), + sourceName: z.string().max(200).nullish(), +}) + /** * GET /api/import/sie/mappings * Get all saved account mappings for the user @@ -31,16 +61,26 @@ export const GET = withRouteContext( */ export const POST = withRouteContext( 'sie_import.mappings.save', - async (request, { supabase, user }) => { - const body = await request.json() - const mappings: AccountMapping[] = body.mappings - - if (!mappings || !Array.isArray(mappings)) { - return NextResponse.json({ error: 'Invalid mappings data' }, { status: 400 }) - } + async (request, { supabase, companyId, user }) => { + const validation = await validateBody(request, SaveMappingsSchema) + if (!validation.success) return validation.response + // Sparse elements are deliberate (see the schema comment): saveMappings + // handles absent fields itself, so the wire type is a relaxed superset of + // AccountMapping. + const mappings = validation.data.mappings as unknown as AccountMapping[] try { - await saveMappings(supabase, user.id, mappings) + // saveMappings' second parameter is the tenant key: it lands verbatim in + // sie_account_mappings.company_id, which is NOT NULL and FK-bound to + // companies. This used to pass user.id, so every row the endpoint tried + // to write carried a user UUID in the company column. companyId is + // resolved by withRouteContext from the caller's own membership and is + // never taken from the request body, so a caller cannot write mappings + // into a company they do not belong to. + // userId is passed explicitly: sie_account_mappings.user_id is NOT NULL + // with no default, and saveMappings' session fallback returns nothing on + // the cookieless service client used by API-key and MCP callers. + await saveMappings(supabase, companyId, mappings, user.id) return NextResponse.json({ success: true }) } catch (error) { return NextResponse.json( @@ -59,27 +99,51 @@ export const POST = withRouteContext( export const PUT = withRouteContext( 'sie_import.mappings.update', async (request, { supabase, user, companyId }) => { - const body = await request.json() - const { sourceAccount, targetAccount } = body + const validation = await validateBody(request, UpdateMappingSchema) + if (!validation.success) return validation.response + const { sourceAccount, targetAccount, sourceName } = validation.data - if (!sourceAccount || !targetAccount) { - return NextResponse.json( - { error: 'sourceAccount and targetAccount are required' }, - { status: 400 } - ) + // source_name is the label the exporting system gave the account in the SIE + // file's #KONTO record ('#KONTO 1910 "Kassa"'). During the mapping review it + // is the only thing that tells one unfamiliar source account number from + // another, and lib/import/account-mapper.ts reads it back for display. It is + // per-file and cannot be recomputed from the BAS chart, so read the stored + // value and carry it forward instead of requiring the caller to resend it: a + // client that doesn't know about the field is exactly how it got erased. + const { data: existing, error: existingError } = await supabase + .from('sie_account_mappings') + .select('source_name') + .eq('company_id', companyId) + .eq('source_account', sourceAccount) + .maybeSingle() + + if (existingError) { + return NextResponse.json({ error: getUserErrorMessage(existingError) }, { status: 500 }) } + // A caller that does know the label (the import review sends it straight + // from the parsed file) wins, so the first save can set it; otherwise keep + // whatever is already stored. + const resolvedSourceName = + typeof sourceName === 'string' && sourceName.trim().length > 0 + ? sourceName + : (existing?.source_name ?? null) + const { data, error } = await supabase .from('sie_account_mappings') .upsert({ user_id: user.id, company_id: companyId, source_account: sourceAccount, + source_name: resolvedSourceName, target_account: targetAccount, confidence: 1.0, match_type: 'manual', }, { - onConflict: 'user_id,source_account', + // The (user_id, source_account) unique constraint was dropped by the + // multi-tenant refactor; (company_id, source_account) is the one that + // exists, and the one saveMappings() upserts against. + onConflict: 'company_id,source_account', }) .select() .single() diff --git a/app/api/invoices/[id]/__tests__/patch.test.ts b/app/api/invoices/[id]/__tests__/patch.test.ts index cad35194..04f127e4 100644 --- a/app/api/invoices/[id]/__tests__/patch.test.ts +++ b/app/api/invoices/[id]/__tests__/patch.test.ts @@ -32,6 +32,9 @@ const mockGetAvailableVatRates = vi.fn() vi.mock('@/lib/invoices/vat-rules', () => ({ getVatRules: (...args: unknown[]) => mockGetVatRules(...args), getAvailableVatRates: (...args: unknown[]) => mockGetAvailableVatRates(...args), + // The builder gates on the permitted set (taxed-where-performed exceptions); + // these route tests only care that the gate reads the stubbed rates. + getPermittedVatRates: (...args: unknown[]) => mockGetAvailableVatRates(...args), })) vi.mock('@/lib/currency/riksbanken', () => ({ @@ -151,6 +154,7 @@ describe('PATCH /api/invoices/[id]', () => { enqueue({ data: makeCustomer({ id: 'customer-1', customer_type: 'swedish_business' }), error: null }) // customer enqueue({ data: { vat_registered: true }, error: null }) // company_settings.vat_registered enqueue({ data: [{ id: 'inv-1' }], error: null }) // update ... select('id') + enqueue({ data: [], error: null }) // snapshot existing invoice_items enqueue({ data: [], error: null }) // delete invoice_items enqueue({ data: null, error: null }) // insert invoice_items enqueue({ diff --git a/app/api/invoices/[id]/mark-paid/__tests__/route.test.ts b/app/api/invoices/[id]/mark-paid/__tests__/route.test.ts index 0684aab4..1632ac05 100644 --- a/app/api/invoices/[id]/mark-paid/__tests__/route.test.ts +++ b/app/api/invoices/[id]/mark-paid/__tests__/route.test.ts @@ -705,4 +705,228 @@ describe('POST /api/invoices/[id]/mark-paid', () => { expect(mockCreateInvoicePaymentJournalEntry).toHaveBeenCalled() expect(mockCreateJournalEntry).not.toHaveBeenCalled() }) + + // ------------------------------------------------------------------ + // Foreign-currency unit handling. + // total / paid_amount / remaining_amount are stored in the INVOICE currency; + // custom lines are journal lines and therefore SEK. Everything below pins + // the conversion between the two. + // ------------------------------------------------------------------ + + it('converts a SEK custom-line payment to invoice currency before touching the ledger (EUR, partial)', async () => { + // 1 000 EUR invoice at 11,4967 SEK/EUR. The customer pays 5 748,35 kr, + // which is exactly 500 EUR: a genuine partial payment. Without the + // conversion the guard compared 5 748,35 (SEK) against 1 000 (EUR), read + // the partial as a full settlement and ran the duplicate-payment guard on + // it: a matching bank row would then 409 a perfectly valid partial. + const customer = makeCustomer() + const invoice = makeInvoice({ + id: 'inv-1', + status: 'sent', + currency: 'EUR', + exchange_rate: 11.4967, + total: 1000, + remaining_amount: 1000, + customer, + }) + + enqueue({ data: invoice, error: null }) + // No duplicate-guard probes enqueued: 500 EUR < 1 000 EUR remaining, so the + // guard is skipped entirely. + enqueue({ data: { accounting_method: 'accrual', entity_type: 'enskild_firma' }, error: null }) + enqueue({ data: [{ id: 'inv-1' }], error: null }) + + mockFindFiscalPeriod.mockResolvedValue('fp-1') + mockCreateJournalEntry.mockResolvedValue({ id: 'je-eur-partial' }) + const paidHandler = vi.fn() + eventBus.on('invoice.paid', paidHandler) + + const request = createMockRequest('/api/invoices/inv-1/mark-paid', { + method: 'POST', + body: { + lines: [ + { account_number: '1930', debit_amount: 5748.35, credit_amount: 0 }, + { account_number: '1510', debit_amount: 0, credit_amount: 5748.35 }, + ], + }, + }) + const response = await POST(request, createMockRouteParams({ id: 'inv-1' })) + const { status, body } = await parseJsonResponse<{ + success: boolean + status: string + paid_amount: number + remaining_amount: number + journal_entry_id: string + }>(response) + + expect(status).toBe(200) + expect(body.status).toBe('partially_paid') + // Both in EUR, never 5 748,35 and never a negative remainder. + expect(body.paid_amount).toBe(500) + expect(body.remaining_amount).toBe(500) + expect(body.journal_entry_id).toBe('je-eur-partial') + // The load-bearing assertion for the guard fix: the guard compares in + // invoice currency now, so a 500 EUR payment on a 1 000 EUR remaining is a + // partial and the transactions scan never runs. Comparing the raw SEK + // 5 748,35 against 1 000 read it as a full settlement and probed. + expect(mockSupabase.from).not.toHaveBeenCalledWith('transactions') + // The event carries the invoice-currency amount, matching the ledger. + expect(paidHandler).toHaveBeenCalledWith( + expect.objectContaining({ paymentAmount: 500 }), + ) + }) + + it('still runs the duplicate guard when the converted SEK lines settle a EUR invoice in full', async () => { + // The complement of the test above: 11 496,70 kr at 11,4967 is exactly the + // 1 000 EUR remaining, so this IS a full settlement and the advisory must + // still fire. Converting for the comparison must not disable the guard on + // foreign-currency invoices. + const customer = makeCustomer() + const invoice = makeInvoice({ + id: 'inv-1', + status: 'sent', + currency: 'EUR', + exchange_rate: 11.4967, + total: 1000, + remaining_amount: 1000, + customer, + }) + + enqueue({ data: invoice, error: null }) + // merchant_name ILIKE probe: the bank row is in kronor, because the + // candidate lookup scans transactions.amount, which is SEK. + enqueue({ + data: [ + { + id: 'tx-eur', + date: '2026-05-10', + amount: 11496.7, + description: 'Inbetalning Test AB', + merchant_name: 'Test AB', + reference: null, + }, + ], + error: null, + }) + // description ILIKE probe: no additional match. + enqueue({ data: [], error: null }) + + const request = createMockRequest('/api/invoices/inv-1/mark-paid', { + method: 'POST', + body: { + lines: [ + { account_number: '1930', debit_amount: 11496.7, credit_amount: 0 }, + { account_number: '1510', debit_amount: 0, credit_amount: 11496.7 }, + ], + }, + }) + const response = await POST(request, createMockRouteParams({ id: 'inv-1' })) + const { status, body } = await parseJsonResponse<{ + error: { code: string; details: { candidates: Array<{ id: string }> } } + }>(response) + + expect(status).toBe(409) + expect(body.error.code).toBe('INVOICE_PAID_LIKELY_DUPLICATE') + expect(body.error.details.candidates[0].id).toBe('tx-eur') + expect(mockCreateJournalEntry).not.toHaveBeenCalled() + }) + + it('returns 400 MATCH_INVOICE_BOOKING_RATE_MISSING when a EUR invoice carries no exchange rate', async () => { + // 11 496,70 kr against a 1 000 EUR invoice with no rate on file. Defaulting + // the rate to 1 would read the payment as 11 496,70 EUR. + const invoice = makeInvoice({ + id: 'inv-1', + status: 'sent', + currency: 'EUR', + exchange_rate: null, + total: 1000, + remaining_amount: 1000, + }) + + enqueue({ data: invoice, error: null }) + + const request = createMockRequest('/api/invoices/inv-1/mark-paid', { + method: 'POST', + body: { + lines: [ + { account_number: '1930', debit_amount: 11496.7, credit_amount: 0 }, + { account_number: '1510', debit_amount: 0, credit_amount: 11496.7 }, + ], + }, + }) + const response = await POST(request, createMockRouteParams({ id: 'inv-1' })) + const { status, body } = await parseJsonResponse<{ error: { code: string; details?: unknown } }>(response) + + expect(status).toBe(400) + // Same code the bank-match path throws for the same condition + // (lib/bookkeeping/invoice-payment-lines.ts). + expect(body.error.code).toBe('MATCH_INVOICE_BOOKING_RATE_MISSING') + expect(mockCreateJournalEntry).not.toHaveBeenCalled() + expect(mockCreateInvoicePaymentJournalEntry).not.toHaveBeenCalled() + }) + + it('refuses a sub-remaining SEK payment on a rate-less EUR invoice instead of booking kronor as euro', async () => { + // The silent-corruption direction: 500 kr against a 1 000 EUR invoice sits + // below the invoice-currency remaining, so no overpayment guard catches it. + // With a rate-1 fallback it recorded 500 EUR paid and left 500 EUR + // remaining, when the customer had really paid ~43 EUR. + const invoice = makeInvoice({ + id: 'inv-1', + status: 'sent', + currency: 'EUR', + exchange_rate: null, + total: 1000, + remaining_amount: 1000, + }) + + enqueue({ data: invoice, error: null }) + + const request = createMockRequest('/api/invoices/inv-1/mark-paid', { + method: 'POST', + body: { + lines: [ + { account_number: '1930', debit_amount: 500, credit_amount: 0 }, + { account_number: '1510', debit_amount: 0, credit_amount: 500 }, + ], + }, + }) + const response = await POST(request, createMockRouteParams({ id: 'inv-1' })) + const { status, body } = await parseJsonResponse<{ error: { code: string } }>(response) + + expect(status).toBe(400) + expect(body.error.code).toBe('MATCH_INVOICE_BOOKING_RATE_MISSING') + expect(mockCreateJournalEntry).not.toHaveBeenCalled() + }) + + it('leaves a rate-less EUR invoice payable when no custom lines are supplied', async () => { + // The default path pays remaining_amount, which is already in invoice + // currency: no conversion happens, so no rate is required. + const invoice = makeInvoice({ + id: 'inv-1', + status: 'sent', + currency: 'EUR', + exchange_rate: null, + total: 1000, + remaining_amount: 1000, + }) + + enqueue({ data: invoice, error: null }) + enqueue({ data: { accounting_method: 'accrual', entity_type: 'enskild_firma' }, error: null }) + enqueue({ data: [{ id: 'inv-1' }], error: null }) + + mockCreateInvoicePaymentJournalEntry.mockResolvedValue({ id: 'je-eur-full' }) + + const request = createMockRequest('/api/invoices/inv-1/mark-paid', { method: 'POST' }) + const response = await POST(request, createMockRouteParams({ id: 'inv-1' })) + const { status, body } = await parseJsonResponse<{ + status: string + paid_amount: number + remaining_amount: number + }>(response) + + expect(status).toBe(200) + expect(body.status).toBe('paid') + expect(body.paid_amount).toBe(1000) + expect(body.remaining_amount).toBe(0) + }) }) diff --git a/app/api/invoices/[id]/mark-paid/route.ts b/app/api/invoices/[id]/mark-paid/route.ts index 3604a27f..6e5f729a 100644 --- a/app/api/invoices/[id]/mark-paid/route.ts +++ b/app/api/invoices/[id]/mark-paid/route.ts @@ -116,7 +116,37 @@ export const POST = withRouteContext( const paymentAmount = customLines ? customLines.reduce((s, l) => s + l.debit_amount, 0) : remainingAmount - const paidRounded = Math.round(paymentAmount * 100) / 100 + + // Unit contract: total / paid_amount / remaining_amount are stored in the + // INVOICE currency (total_sek carries the SEK view of total); custom lines + // are journal lines, so they are always SEK. The SEK amount therefore has + // to be converted before it is compared against, or subtracted from, the + // invoice-currency remaining. The default path (no lines) already pays the + // remaining in invoice currency and needs no rate at all. + const isForeignCurrency = !!invoice.currency && invoice.currency !== 'SEK' + const needsFxConversion = isForeignCurrency && customLines !== undefined + const fxRate = + invoice.exchange_rate && invoice.exchange_rate > 0 ? invoice.exchange_rate : null + if (needsFxConversion && fxRate === null) { + // Never fall back to rate 1: that reads an 11 496,70 kr payment against a + // 1 000 EUR invoice as 11 496,70 EUR and corrupts the AR sub-ledger. + // Same code as buildInvoicePaymentClearingLines' refusal + // (MATCH_INVOICE_BOOKING_RATE_MISSING, lib/bookkeeping/invoice-payment-lines.ts): + // one condition, one code across every invoice-settlement surface. + opLog.warn('mark-paid rejected: foreign-currency invoice without exchange rate', { + invoiceId: id, + currency: invoice.currency, + }) + return errorResponseFromCode('MATCH_INVOICE_BOOKING_RATE_MISSING', opLog, { + requestId, + details: { invoice_id: id, currency: invoice.currency }, + }) + } + const paymentAmountInInvoiceCurrency = needsFxConversion + ? roundOre(paymentAmount / fxRate!) + : paymentAmount + + const paidRounded = Math.round(paymentAmountInInvoiceCurrency * 100) / 100 const remainingRounded = Math.round(remainingAmount * 100) / 100 if (!force && paidRounded >= remainingRounded) { const customerName = (invoice as Invoice & { customer?: { name?: string } }).customer?.name @@ -126,10 +156,23 @@ export const POST = withRouteContext( invoiceId: id, }) } else { + // Invoice currency on purpose. The lookup scans transactions.amount, + // which is denominated in the BANK ROW's currency, not necessarily + // kronor; it therefore takes the payment in invoice currency plus the + // invoice's stored conversion and bands each currency separately. + // Handing it the raw SEK custom-line total would band a kronor figure + // against a EUR column (and vice versa). const candidates = await findDuplicatePaymentCandidatesForInvoice(supabase, { companyId: companyId!, - invoice: { invoice_number: invoice.invoice_number, customer_name: customerName }, - paymentAmount, + invoice: { + invoice_number: invoice.invoice_number, + customer_name: customerName, + currency: invoice.currency ?? null, + total: invoice.total ?? null, + total_sek: invoice.total_sek ?? null, + exchange_rate: invoice.exchange_rate ?? null, + }, + paymentAmount: paymentAmountInInvoiceCurrency, paymentDate, }) if (candidates.length > 0) { @@ -157,18 +200,9 @@ export const POST = withRouteContext( const accountingMethod = settings?.accounting_method || 'accrual' const entityType = (settings?.entity_type as EntityType) || 'enskild_firma' - // paymentAmount and the duplicate-payment guard above operate in the - // booking currency (SEK for custom lines); convert to invoice currency for - // the ledger comparison so a foreign-currency invoice isn't falsely - // rejected as overpaid. - const fxRate = - invoice.currency && invoice.currency !== 'SEK' && invoice.exchange_rate - ? invoice.exchange_rate - : 1 - const paymentAmountInInvoiceCurrency = customLines - ? roundOre(paymentAmount / fxRate) - : paymentAmount - + // paymentAmountInInvoiceCurrency was resolved above, before the + // duplicate-payment guard, so the guard comparison and the ledger math run + // in the same unit as remaining_amount. const result = await settleInvoicePayment(supabase, companyId!, user.id, { invoice: invoice as Invoice & { customer?: { name?: string | null } | null }, paymentAmountInInvoiceCurrency, diff --git a/app/api/invoices/[id]/refresh-exchange-rate/__tests__/route.test.ts b/app/api/invoices/[id]/refresh-exchange-rate/__tests__/route.test.ts new file mode 100644 index 00000000..6b873af5 --- /dev/null +++ b/app/api/invoices/[id]/refresh-exchange-rate/__tests__/route.test.ts @@ -0,0 +1,403 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { createMockRequest, parseJsonResponse, createMockRouteParams, makeInvoice } from '@/tests/helpers' + +/** + * Minimal recording Supabase mock: the queue behaviour of + * createQueuedMockSupabase plus captured table/op/payload/filters, so the + * "only the SEK columns are rewritten" contract can actually be asserted. + */ +interface QueryResult { + data: unknown + error: unknown +} +interface RecordedQuery { + table: string + op: 'select' | 'update' | 'insert' | 'upsert' | 'delete' + payload?: unknown + filters: Record +} + +function createRecordingSupabase() { + const queue: QueryResult[] = [] + const recorded: RecordedQuery[] = [] + + const enqueue = (r: { data?: unknown; error?: unknown }) => + queue.push({ data: r.data ?? null, error: r.error ?? null }) + const reset = () => { + queue.length = 0 + recorded.length = 0 + } + + const from = (table: string) => { + const result = queue.shift() ?? { data: null, error: null } + const rec: RecordedQuery = { table, op: 'select', filters: {} } + recorded.push(rec) + + const api: unknown = new Proxy( + {}, + { + get(_target, prop) { + if (prop === 'then') { + return (onFulfilled: (v: QueryResult) => void) => onFulfilled(result) + } + return (...args: unknown[]) => { + if (prop === 'update' || prop === 'insert' || prop === 'upsert') { + rec.op = prop + rec.payload = args[0] + } else if (prop === 'delete') { + rec.op = 'delete' + } else if (prop === 'eq' || prop === 'is') { + rec.filters[String(args[0])] = args[1] + } + return api + } + }, + }, + ) + return api + } + + const supabase = { + from: vi.fn(from), + rpc: vi.fn(), + auth: { getUser: vi.fn() }, + } + + return { supabase, enqueue, reset, recorded } +} + +const { supabase: mockSupabase, enqueue, reset, recorded } = createRecordingSupabase() + +vi.mock('@/lib/supabase/server', () => ({ + createClient: () => Promise.resolve(mockSupabase), +})) + +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 }), +})) + +const mockFetchExchangeRate = vi.fn() +vi.mock('@/lib/currency/riksbanken', () => ({ + fetchExchangeRate: (...args: unknown[]) => mockFetchExchangeRate(...args), +})) + +const mockResolvePeriodStatus = vi.fn() +vi.mock('@/lib/core/bookkeeping/period-service', () => ({ + resolvePeriodStatusForDate: (...args: unknown[]) => mockResolvePeriodStatus(...args), +})) + +import { POST } from '../route' + +const INVOICE_ID = '11111111-2222-4333-8444-555555555555' + +const sentEurInvoice = makeInvoice({ + id: INVOICE_ID, + status: 'sent', + invoice_number: 'F-2026001', + currency: 'EUR', + invoice_date: '2026-06-15', + delivery_date: null, + subtotal: 1000, + vat_amount: 0, + total: 1000, + subtotal_sek: null, + vat_amount_sek: null, + total_sek: null, + exchange_rate: null, + exchange_rate_date: null, + journal_entry_id: null, +}) + +function post(id = INVOICE_ID) { + const request = createMockRequest(`/api/invoices/${id}/refresh-exchange-rate`, { method: 'POST' }) + return POST(request, createMockRouteParams({ id })) +} + +describe('POST /api/invoices/[id]/refresh-exchange-rate', () => { + beforeEach(() => { + vi.clearAllMocks() + reset() + mockSupabase.auth.getUser.mockResolvedValue({ data: { user: { id: 'user-1' } } }) + mockResolvePeriodStatus.mockResolvedValue({ period_id: 'fp-1', status: 'open', lock_date: null }) + mockFetchExchangeRate.mockResolvedValue({ currency: 'EUR', rate: 11.5, date: '2026-06-15' }) + }) + + it('returns 401 when not authenticated', async () => { + mockSupabase.auth.getUser.mockResolvedValue({ data: { user: null } }) + + const { status, body } = await parseJsonResponse(await post()) + + expect(status).toBe(401) + expect(body).toEqual({ error: 'Unauthorized' }) + }) + + it('returns 400 when the invoice id is not a uuid', async () => { + const { status, body } = await parseJsonResponse<{ error: { code: string } }>( + await post('not-a-uuid'), + ) + + expect(status).toBe(400) + expect(body.error.code).toBe('VALIDATION_ERROR') + // Rejected before any DB work, including the sandbox lookup. + expect(mockSupabase.from).not.toHaveBeenCalled() + }) + + it('returns 404 when the invoice does not exist for the company', async () => { + enqueue({ data: { is_sandbox: false } }) + enqueue({ data: null, error: { message: 'Not found' } }) + + const { status, body } = await parseJsonResponse<{ error: { code: string } }>(await post()) + + expect(status).toBe(404) + expect(body.error.code).toBe('INVOICE_NOT_FOUND') + }) + + it('fills in the taxable-event rate on an unbooked SENT invoice without touching its currency amounts', async () => { + enqueue({ data: { is_sandbox: false } }) + enqueue({ data: sentEurInvoice }) + enqueue({ data: [] }) // journal_entries: nothing references the invoice + enqueue({ + data: [{ ...sentEurInvoice, exchange_rate: 11.5, exchange_rate_date: '2026-06-15', total_sek: 11500 }], + }) + + const { status, body } = await parseJsonResponse<{ data: { exchange_rate: number } }>(await post()) + + expect(status).toBe(200) + expect(body.data.exchange_rate).toBe(11.5) + + // The rate is fetched for the invoice date (the taxable event here, since + // delivery_date is null) and WITH the supabase client, so the shared + // exchange_rates cache backs both the read-through and the 429 fallback. + expect(mockFetchExchangeRate).toHaveBeenCalledTimes(1) + const [currency, date, client] = mockFetchExchangeRate.mock.calls[0] + expect(currency).toBe('EUR') + expect((date as Date).toISOString().slice(0, 10)).toBe('2026-06-15') + expect(client).toBe(mockSupabase) + + const update = recorded.find((q) => q.op === 'update') + expect(update?.table).toBe('invoices') + expect(update?.payload).toEqual({ + exchange_rate: 11.5, + exchange_rate_date: '2026-06-15', + subtotal_sek: 11500, + vat_amount_sek: 0, + total_sek: 11500, + updated_at: expect.any(String), + }) + // The debt stays denominated in the invoice currency. + const payloadKeys = Object.keys(update?.payload as Record) + expect(payloadKeys).not.toContain('subtotal') + expect(payloadKeys).not.toContain('vat_amount') + expect(payloadKeys).not.toContain('total') + expect(payloadKeys).not.toContain('remaining_amount') + expect(payloadKeys).not.toContain('currency') + // TOCTOU guard: the write loses to a concurrent send/book. + expect(update?.filters).toMatchObject({ journal_entry_id: null, company_id: 'company-1' }) + }) + + it('uses delivery_date as the rate date when it differs from the invoice date', async () => { + enqueue({ data: { is_sandbox: false } }) + enqueue({ data: { ...sentEurInvoice, delivery_date: '2026-05-20' } }) + enqueue({ data: [] }) + enqueue({ data: [sentEurInvoice] }) + + await post() + + const [, date] = mockFetchExchangeRate.mock.calls[0] + expect((date as Date).toISOString().slice(0, 10)).toBe('2026-05-20') + }) + + it('refuses a booked invoice and points at the rättelse tracks', async () => { + enqueue({ data: { is_sandbox: false } }) + enqueue({ data: { ...sentEurInvoice, journal_entry_id: 'je-1' } }) + + const { status, body } = await parseJsonResponse<{ error: { code: string } }>(await post()) + + expect(status).toBe(409) + expect(body.error.code).toBe('INVOICE_FX_REFRESH_BOOKED') + // Nothing was fetched or written: the SEK amounts are in a verifikat. + expect(mockFetchExchangeRate).not.toHaveBeenCalled() + expect(recorded.some((q) => q.op === 'update')).toBe(false) + }) + + it('refuses when a verifikat references the invoice even though journal_entry_id is null (legacy rows)', async () => { + enqueue({ data: { is_sandbox: false } }) + enqueue({ data: sentEurInvoice }) + enqueue({ data: [{ id: 'je-9', voucher_series: 'A', voucher_number: 17 }] }) + + const { status, body } = await parseJsonResponse<{ error: { code: string } }>(await post()) + + expect(status).toBe(409) + expect(body.error.code).toBe('INVOICE_FX_REFRESH_BOOKED') + expect(recorded.some((q) => q.op === 'update')).toBe(false) + }) + + it('refuses when the fiscal period covering the invoice date is locked', async () => { + mockResolvePeriodStatus.mockResolvedValue({ + period_id: 'fp-1', + status: 'locked', + lock_date: '2026-06-30', + }) + enqueue({ data: { is_sandbox: false } }) + enqueue({ data: sentEurInvoice }) + enqueue({ data: [] }) + + const { status, body } = await parseJsonResponse<{ error: { code: string } }>(await post()) + + expect(status).toBe(409) + expect(body.error.code).toBe('INVOICE_FX_REFRESH_PERIOD_LOCKED') + expect(mockResolvePeriodStatus).toHaveBeenCalledWith(mockSupabase, 'company-1', '2026-06-15') + expect(mockFetchExchangeRate).not.toHaveBeenCalled() + expect(recorded.some((q) => q.op === 'update')).toBe(false) + }) + + it('refuses fail-closed when the period lock state could not be read', async () => { + mockResolvePeriodStatus.mockResolvedValue({ + period_id: null, + status: 'locked', + lock_date: null, + lookup_failed: true, + }) + enqueue({ data: { is_sandbox: false } }) + enqueue({ data: sentEurInvoice }) + enqueue({ data: [] }) + + const { status, body } = await parseJsonResponse<{ + error: { code: string; details: { lookup_failed: boolean } } + }>(await post()) + + expect(status).toBe(409) + expect(body.error.code).toBe('INVOICE_FX_REFRESH_PERIOD_LOCKED') + expect(body.error.details.lookup_failed).toBe(true) + }) + + it('returns 502 and writes nothing when Riksbanken and the cache both fail', async () => { + mockFetchExchangeRate.mockResolvedValue(null) + enqueue({ data: { is_sandbox: false } }) + enqueue({ data: sentEurInvoice }) + enqueue({ data: [] }) + + const { status, body } = await parseJsonResponse<{ error: { code: string } }>(await post()) + + expect(status).toBe(502) + expect(body.error.code).toBe('INVOICE_FX_REFRESH_RATE_UNAVAILABLE') + // Never an invented rate: the invoice is left untouched and retryable. + expect(recorded.some((q) => q.op === 'update')).toBe(false) + }) + + it('is a no-op for a SEK invoice', async () => { + enqueue({ data: { is_sandbox: false } }) + enqueue({ data: makeInvoice({ id: INVOICE_ID, status: 'sent', currency: 'SEK' }) }) + + const { status } = await parseJsonResponse(await post()) + + expect(status).toBe(200) + expect(mockFetchExchangeRate).not.toHaveBeenCalled() + expect(recorded.some((q) => q.op === 'update')).toBe(false) + }) + + it('writes nothing when the stored rate is already the taxable-event rate', async () => { + enqueue({ data: { is_sandbox: false } }) + enqueue({ + data: { ...sentEurInvoice, exchange_rate: 11.5, exchange_rate_date: '2026-06-15' }, + }) + enqueue({ data: [] }) + + const { status } = await parseJsonResponse(await post()) + + expect(status).toBe(200) + expect(recorded.some((q) => q.op === 'update')).toBe(false) + }) + + it('reports the concurrent-booking race instead of silently succeeding', async () => { + enqueue({ data: { is_sandbox: false } }) + enqueue({ data: sentEurInvoice }) + enqueue({ data: [] }) + enqueue({ data: [] }) // update matched 0 rows: journal_entry_id was set meanwhile + + const { status, body } = await parseJsonResponse<{ error: { code: string } }>(await post()) + + expect(status).toBe(409) + expect(body.error.code).toBe('INVOICE_FX_REFRESH_BOOKED') + }) + + it('reverts the update with the prior values when a verifikat appears between the guard and the write', async () => { + // TOCTOU: a booking flow that read the invoice at the OLD rate commits + // its journal entry AFTER the source_id guard but BEFORE journal_entry_id + // is stamped on the invoice. The route must detect the entry on the + // post-update recheck, put the previous rate/SEK values back via a CAS on + // what it just wrote, and answer with the booked conflict. + const previouslyRated = { + ...sentEurInvoice, + exchange_rate: 11.2, + exchange_rate_date: '2026-06-10', + subtotal_sek: 11200, + vat_amount_sek: 0, + total_sek: 11200, + } + enqueue({ data: { is_sandbox: false } }) + enqueue({ data: previouslyRated }) + enqueue({ data: [] }) // pre-write guard: no entry references the invoice yet + enqueue({ data: [{ ...previouslyRated, exchange_rate: 11.5 }] }) // guarded update succeeds + enqueue({ data: [{ id: 'je-race' }] }) // post-update recheck: an entry appeared + enqueue({ data: [] }) // revert update + + const { status, body } = await parseJsonResponse<{ + error: { code: string; details: { journal_entry_id: string; reason: string } } + }>(await post()) + + expect(status).toBe(409) + expect(body.error.code).toBe('INVOICE_FX_REFRESH_BOOKED') + expect(body.error.details.journal_entry_id).toBe('je-race') + expect(body.error.details.reason).toBe('booked_concurrently_reverted') + + const updates = recorded.filter((q) => q.op === 'update') + expect(updates).toHaveLength(2) + // The revert restores exactly the values read before the update. + expect(updates[1].table).toBe('invoices') + expect(updates[1].payload).toEqual({ + exchange_rate: 11.2, + exchange_rate_date: '2026-06-10', + subtotal_sek: 11200, + vat_amount_sek: 0, + total_sek: 11200, + updated_at: expect.any(String), + }) + // CAS: the revert only touches the row if it still holds the values this + // request wrote, so a later legitimate write is never clobbered. + expect(updates[1].filters).toMatchObject({ + id: INVOICE_ID, + company_id: 'company-1', + exchange_rate: 11.5, + exchange_rate_date: '2026-06-15', + }) + }) + + it('does not revert when the post-update recheck finds no verifikat', async () => { + enqueue({ data: { is_sandbox: false } }) + enqueue({ data: sentEurInvoice }) + enqueue({ data: [] }) // pre-write guard + enqueue({ data: [{ ...sentEurInvoice, exchange_rate: 11.5 }] }) // update + enqueue({ data: [] }) // recheck: still unbooked + + const { status } = await parseJsonResponse(await post()) + + expect(status).toBe(200) + expect(recorded.filter((q) => q.op === 'update')).toHaveLength(1) + }) + + it('is blocked in the sandbox', async () => { + enqueue({ data: { is_sandbox: true } }) + + const { status, body } = await parseJsonResponse<{ sandbox_blocked: boolean }>(await post()) + + expect(status).toBe(403) + expect(body.sandbox_blocked).toBe(true) + expect(mockFetchExchangeRate).not.toHaveBeenCalled() + }) +}) diff --git a/app/api/invoices/[id]/refresh-exchange-rate/route.ts b/app/api/invoices/[id]/refresh-exchange-rate/route.ts new file mode 100644 index 00000000..a3cc9ca8 --- /dev/null +++ b/app/api/invoices/[id]/refresh-exchange-rate/route.ts @@ -0,0 +1,263 @@ +import { NextResponse } from 'next/server' +import { withRouteContext } from '@/lib/api/with-route-context' +import { errorResponse, errorResponseFromCode } from '@/lib/errors/get-structured-error' +import { fetchExchangeRate } from '@/lib/currency/riksbanken' +import { roundOre } from '@/lib/money' +import { resolvePeriodStatusForDate } from '@/lib/core/bookkeeping/period-service' +import { guardSandbox } from '@/lib/sandbox/guard' +import type { Currency, Invoice } from '@/types' + +const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i + +/** + * POST /api/invoices/[id]/refresh-exchange-rate + * + * Repair path for a foreign-currency invoice whose SEK conversion is missing + * or was stamped from the wrong day's rate. The invoice twin of + * /api/transactions/[id]/refresh-exchange-rate. + * + * Why it exists: buildInvoiceWriteData fetches the Riksbanken rate at create + * time. One transient 429 there used to leave exchange_rate NULL forever, and + * resolveSekAmount() then books the raw foreign number as if it were kronor + * (1 000 EUR posted as 1 000 kr on 1510/2611). PATCH /api/invoices/[id] cannot + * fix it: that route only accepts drafts, and the invoice is already sent. + * Several structured errors (MATCH_INVOICE_BOOKING_RATE_MISSING, + * BATCH_FX_RATE_MISSING) tell the user to "komplettera fakturans exchange_rate" + * and this is the endpoint that actually does it. + * + * What it changes: only exchange_rate, exchange_rate_date and the three *_sek + * columns. The invoice's own currency amounts (subtotal / vat_amount / total / + * remaining_amount) are never touched: the customer's debt is denominated in + * the invoice currency and re-rating it would silently reprice a sent invoice. + * + * Which rate: the one at the TAXABLE EVENT, per ML 8 kap 21-23 § ("Rate to + * use: at time of taxable event (delivery/supply date or advance payment date, + * not invoice date unless same)"). delivery_date when set, otherwise + * invoice_date, which is the same day by definition when delivery_date is null + * (ML 17 kap 24 § p.7 requires the delivery date on the invoice precisely when + * the two differ). + * + * Booked invoices are REFUSED, never re-rated. Once the invoice has a + * verifikat, the SEK amounts are bokförda poster: changing them is a rättelse + * of a posted entry and BFL 5 kap 5 § allows exactly two tracks for that + * (storno via reverseEntry/correctEntry, or the inline rättelse RPCs). A + * silent UPDATE of the invoice row behind a committed verifikat would also + * desync the invoice from the ledger it produced, which is the worse failure: + * the reports would keep showing the old, wrong SEK value. + */ +export const POST = withRouteContext<{ params: Promise<{ id: string }> }>( + 'invoice.refreshExchangeRate', + async (_request, { supabase, companyId, log, requestId }, { params }) => { + const { id } = await params + + // Defense in depth: a malformed id would otherwise reach Postgres as an + // invalid uuid literal (22P02) and surface as a confusing 404. + if (!UUID_RE.test(id)) { + return errorResponseFromCode('VALIDATION_ERROR', log, { + requestId, + details: { field: 'id', reason: 'not_a_uuid' }, + }) + } + + // Riksbanken is a gated external service in the sandbox. + const blocked = await guardSandbox(supabase, companyId) + if (blocked) return blocked + + // The *_sek columns are projected solely so the post-update compensation + // below can restore the exact prior values if a concurrent booking wins. + const { data: invoice, error: fetchError } = await supabase + .from('invoices') + .select( + 'id, status, currency, invoice_date, delivery_date, subtotal, vat_amount, total, exchange_rate, exchange_rate_date, subtotal_sek, vat_amount_sek, total_sek, journal_entry_id', + ) + .eq('id', id) + .eq('company_id', companyId) + .single< + Pick< + Invoice, + | 'id' + | 'status' + | 'currency' + | 'invoice_date' + | 'delivery_date' + | 'subtotal' + | 'vat_amount' + | 'total' + | 'exchange_rate' + | 'exchange_rate_date' + | 'subtotal_sek' + | 'vat_amount_sek' + | 'total_sek' + | 'journal_entry_id' + > + >() + + if (fetchError || !invoice) { + return errorResponseFromCode('INVOICE_NOT_FOUND', log, { requestId }) + } + + // A SEK invoice has no conversion to repair. + if (invoice.currency === 'SEK') { + return NextResponse.json({ data: invoice }) + } + + // Guard 1: already booked → the SEK values live in a verifikat. + // invoice.journal_entry_id is the primary link, but it was backfilled late + // (see mark-sent) so older booked rows can still carry NULL. The + // journal_entries lookup by source_id is the authoritative check. + if (invoice.journal_entry_id) { + return errorResponseFromCode('INVOICE_FX_REFRESH_BOOKED', log, { + requestId, + details: { invoice_id: id, journal_entry_id: invoice.journal_entry_id }, + }) + } + + const { data: linkedEntries, error: entriesError } = await supabase + .from('journal_entries') + .select('id, voucher_series, voucher_number') + .eq('company_id', companyId) + .eq('source_id', id) + .limit(1) + + if (entriesError) { + // Fail closed: an unknown booking state must not authorise a re-rate. + log.error('invoice fx refresh: journal entry lookup failed', entriesError, { invoiceId: id }) + return errorResponse(entriesError, log, { requestId }) + } + if (linkedEntries && linkedEntries.length > 0) { + return errorResponseFromCode('INVOICE_FX_REFRESH_BOOKED', log, { + requestId, + details: { invoice_id: id, journal_entry_id: linkedEntries[0].id }, + }) + } + + // Guard 2: period locks. The invoice's verifikat is dated invoice_date + // (invoice-entries.ts), so that is the period this repair feeds. Behind a + // lock/close the whole räkenskapsinformation for the period is frozen and + // the only route is storno. resolvePeriodStatusForDate fails CLOSED: a + // failed lookup reports `locked`, which is the answer we want here too. + const periodStatus = await resolvePeriodStatusForDate(supabase, companyId, invoice.invoice_date) + if (periodStatus.status !== 'open') { + return errorResponseFromCode('INVOICE_FX_REFRESH_PERIOD_LOCKED', log, { + requestId, + details: { + invoice_id: id, + date: invoice.invoice_date, + period_status: periodStatus.status, + lock_date: periodStatus.lock_date, + lookup_failed: periodStatus.lookup_failed ?? false, + }, + }) + } + + const rateDate = invoice.delivery_date || invoice.invoice_date + const rate = await fetchExchangeRate( + invoice.currency as Currency, + new Date(rateDate), + supabase, + ) + if (!rate) { + return errorResponseFromCode('INVOICE_FX_REFRESH_RATE_UNAVAILABLE', log, { + requestId, + details: { invoice_id: id, currency: invoice.currency, date: rateDate }, + }) + } + + // Idempotent: nothing to write when the stored rate already is the + // taxable-event rate. + if (invoice.exchange_rate === rate.rate && invoice.exchange_rate_date === rate.date) { + return NextResponse.json({ data: invoice }) + } + + const toSek = (amount: number) => roundOre(amount * rate.rate) + + const { data: updated, error: updateError } = await supabase + .from('invoices') + .update({ + exchange_rate: rate.rate, + exchange_rate_date: rate.date, + subtotal_sek: toSek(invoice.subtotal), + vat_amount_sek: toSek(invoice.vat_amount), + total_sek: toSek(invoice.total), + updated_at: new Date().toISOString(), + }) + .eq('id', id) + .eq('company_id', companyId) + // TOCTOU: a concurrent send/book between the guard above and this write + // must lose, not silently re-rate a now-posted invoice. + .is('journal_entry_id', null) + .select('*') + + if (updateError) { + log.error('invoice fx refresh: persist failed', updateError, { invoiceId: id }) + return errorResponse(updateError, log, { requestId }) + } + if (!updated || updated.length === 0) { + return errorResponseFromCode('INVOICE_FX_REFRESH_BOOKED', log, { + requestId, + details: { invoice_id: id, reason: 'booked_concurrently' }, + }) + } + + // TOCTOU compensation. The `.is('journal_entry_id', null)` CAS above only + // covers the invoice ROW, but a booking flow that read the invoice at the + // OLD rate can commit its journal entry after our journal_entries check + // and before invoices.journal_entry_id is stamped: the verifikat then + // posts at the old rate while the row now stores the new one, the exact + // divergence this route exists to prevent. Re-check by source_id; if an + // entry appeared, restore the prior values via a CAS on the values this + // request just wrote (never clobbering a later legitimate write) and + // report the booked conflict. A perfect fix needs DB-level serialization + // (out of scope); this shrinks the race window from the whole request to + // the few milliseconds between this SELECT and the revert. + const { data: postEntries, error: postCheckError } = await supabase + .from('journal_entries') + .select('id') + .eq('company_id', companyId) + .eq('source_id', id) + .limit(1) + + if (postCheckError) { + // Cannot tell whether a booking won the race: leave the update in place + // (same residual risk as before this compensation existed) but make the + // blind spot visible in logs. + log.warn('invoice fx refresh: post-update booking recheck failed', { + invoiceId: id, + error: postCheckError.message, + }) + } else if (postEntries && postEntries.length > 0) { + const { error: revertError } = await supabase + .from('invoices') + .update({ + exchange_rate: invoice.exchange_rate, + exchange_rate_date: invoice.exchange_rate_date, + subtotal_sek: invoice.subtotal_sek ?? null, + vat_amount_sek: invoice.vat_amount_sek ?? null, + total_sek: invoice.total_sek ?? null, + updated_at: new Date().toISOString(), + }) + .eq('id', id) + .eq('company_id', companyId) + // CAS on exactly what this request wrote: if someone else already + // changed the rate again, their write wins and nothing is reverted. + .eq('exchange_rate', rate.rate) + .eq('exchange_rate_date', rate.date) + if (revertError) { + log.error('invoice fx refresh: revert after concurrent booking failed', revertError, { + invoiceId: id, + }) + } + return errorResponseFromCode('INVOICE_FX_REFRESH_BOOKED', log, { + requestId, + details: { + invoice_id: id, + journal_entry_id: (postEntries[0] as { id: string }).id, + reason: 'booked_concurrently_reverted', + }, + }) + } + + return NextResponse.json({ data: updated[0] }) + }, + { requireWrite: true }, +) diff --git a/app/api/invoices/[id]/route.ts b/app/api/invoices/[id]/route.ts index 936b1bef..0a629e9d 100644 --- a/app/api/invoices/[id]/route.ts +++ b/app/api/invoices/[id]/route.ts @@ -7,6 +7,7 @@ import { validateBody } from '@/lib/api/validate' import { UpdateInvoiceSchema } from '@/lib/api/schemas' import { buildInvoiceWriteData } from '@/lib/invoices/build-invoice-write' import { isEditableInvoiceDraft } from '@/lib/invoices/is-editable-draft' +import { replaceInvoiceItems } from '@/lib/invoices/replace-invoice-items' import type { InvoiceDocumentType } from '@/types' import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message' @@ -237,30 +238,17 @@ export const PATCH = withRouteContext<{ params: Promise<{ id: string }> }>( return errorResponseFromCode('INVOICE_UPDATE_NOT_DRAFT', ctxLog, { requestId }) } - // Replace line items wholesale. A draft has no journal entry or linked docs, - // so delete + reinsert is safe and lets the user add / remove / reorder rows - // freely. invoice_items cascade nothing else. - const { error: deleteItemsError } = await supabase - .from('invoice_items') - .delete() - .eq('invoice_id', id) - - if (deleteItemsError) { - ctxLog.error('invoice items delete failed on update', deleteItemsError, { invoiceId: id }) + // Replace line items wholesale (shared helper: the v1 REST route and the + // update_invoice commit executor use the same delete + reinsert). A draft + // has no journal entry or linked docs, so full replace is safe and lets + // the user add / remove / reorder rows freely. invoice_items cascade + // nothing else. + const replaced = await replaceInvoiceItems(supabase, id, build.items) + if (!replaced.ok) { + ctxLog.error(`invoice items ${replaced.stage} failed on update`, replaced.error, { invoiceId: id }) return errorResponseFromCode('INVOICE_CREATE_ITEMS_FAILED', ctxLog, { requestId, - details: { pgCode: deleteItemsError.code, pgMessage: getUserErrorMessage(deleteItemsError) }, - }) - } - - const itemsToInsert = build.items.map((item) => ({ ...item, invoice_id: id })) - const { error: itemsError } = await supabase.from('invoice_items').insert(itemsToInsert) - - if (itemsError) { - ctxLog.error('invoice items insert failed on update', itemsError, { invoiceId: id }) - return errorResponseFromCode('INVOICE_CREATE_ITEMS_FAILED', ctxLog, { - requestId, - details: { pgCode: itemsError.code, pgMessage: getUserErrorMessage(itemsError) }, + details: { pgCode: replaced.error.code, pgMessage: getUserErrorMessage(replaced.error) }, }) } diff --git a/app/api/invoices/__tests__/route.test.ts b/app/api/invoices/__tests__/route.test.ts index 8840a28d..44d8e012 100644 --- a/app/api/invoices/__tests__/route.test.ts +++ b/app/api/invoices/__tests__/route.test.ts @@ -33,6 +33,9 @@ vi.mock('@/lib/invoices/vat-rules', () => ({ getVatRules: (...args: unknown[]) => mockGetVatRules(...args), calculateVat: (...args: unknown[]) => mockCalculateVat(...args), getAvailableVatRates: (...args: unknown[]) => mockGetAvailableVatRates(...args), + // The builder gates on the permitted set (taxed-where-performed exceptions); + // these route tests only care that the gate reads the stubbed rates. + getPermittedVatRates: (...args: unknown[]) => mockGetAvailableVatRates(...args), calculateTotal: vi.fn(), })) @@ -413,6 +416,42 @@ describe('POST /api/invoices (create credit note)', () => { expect((body.error as unknown as { code: string }).code).toBe('INVOICE_CREDIT_NOT_SENT') }) + it('returns 400 when invoice is cancelled', async () => { + const original = makeInvoice({ id: VALID_UUID, status: 'cancelled' }) + enqueue({ data: original, error: null }) + + const request = createMockRequest('/api/invoices', { + method: 'POST', + body: { credited_invoice_id: VALID_UUID }, + }) + const response = await POST(request) + const { status, body } = await parseJsonResponse<{ error: string }>(response) + + expect(status).toBe(400) + expect((body.error as unknown as { code: string }).code).toBe('INVOICE_CREDIT_NOT_SENT') + }) + + // Documents a KNOWN GAP, not a rule. ML (2023:200) 17 kap 22-23 SS permits an + // aendringsfaktura against a part-paid invoice; the app refuses it because + // issueCreditNote() cannot flip a 'partially_paid' original to 'credited' and + // would strand a posted reversing verifikat (see the comment on the guard in + // route.ts and the DECISIONS.md entry). This test exists so lifting the gap + // fails here and forces the coordinated change rather than passing silently. + it('refuses a partially paid invoice (known gap: needs issue-credit-note.ts too)', async () => { + const original = makeInvoice({ id: VALID_UUID, status: 'partially_paid' }) + enqueue({ data: original, error: null }) + + const request = createMockRequest('/api/invoices', { + method: 'POST', + body: { credited_invoice_id: VALID_UUID }, + }) + const response = await POST(request) + const { status, body } = await parseJsonResponse<{ error: string }>(response) + + expect(status).toBe(400) + expect((body.error as unknown as { code: string }).code).toBe('INVOICE_CREDIT_NOT_SENT') + }) + it('creates a credit note draft without booking or crediting the original', async () => { const items = [ { diff --git a/app/api/invoices/reminders/action/__tests__/route.test.ts b/app/api/invoices/reminders/action/__tests__/route.test.ts new file mode 100644 index 00000000..e71bae60 --- /dev/null +++ b/app/api/invoices/reminders/action/__tests__/route.test.ts @@ -0,0 +1,131 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' + +/** + * The public reminder action endpoint feeds /invoice-action/[token], an + * unauthenticated page that tells a customer what to pay. The statutory + * påminnelseavgift (Lag 1981:739) is a krona amount booked 1510/3990 in SEK, so + * it must never be relabelled as the invoice currency nor summed into a + * foreign-currency total. + */ + +let queued: { data: unknown; error: unknown } = { data: null, error: null } + +vi.mock('@supabase/ssr', () => { + const buildChain = (): unknown => + new Proxy( + {}, + { + get(_t, prop) { + if (prop === 'then') { + return (resolve: (v: unknown) => void) => resolve(queued) + } + return () => buildChain() + }, + }, + ) + + return { + createServerClient: vi.fn(() => ({ + from: vi.fn(() => buildChain()), + })), + } +}) + +import { GET } from '../route' +import { createMockRequest, parseJsonResponse } from '@/tests/helpers' + +interface ActionPayload { + currency: string + total: number + interestAmount: number + reminderFee: number + reminderFeeCurrency: string + totalDue: number + feeDueSeparately: number +} + +function makeReminderRow(currency: string, total: number) { + return { + id: 'reminder-1', + reminder_level: 1, + sent_at: '2026-05-20T08:00:00Z', + response_type: null, + action_token_used: false, + interest_amount: 10, + interest_rate: 0.105, + interest_from_date: '2026-05-01', + interest_days: 30, + reminder_fee: 60, + invoice: { + id: 'invoice-1', + invoice_number: 'F2026011', + invoice_date: '2026-04-15', + due_date: '2026-05-01', + total, + currency, + status: 'overdue', + customer: { name: 'Erik Andersson' }, + }, + } +} + +async function callGet() { + const response = await GET( + createMockRequest('/api/invoices/reminders/action', { + searchParams: { token: 'tok-1' }, + }), + ) + return parseJsonResponse(response) +} + +describe('GET /api/invoices/reminders/action', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('returns 400 without a token', async () => { + queued = { data: null, error: null } + const response = await GET(createMockRequest('/api/invoices/reminders/action')) + expect(response.status).toBe(400) + }) + + it('returns 404 for an unknown token', async () => { + queued = { data: null, error: { message: 'not found' } } + const { status } = await callGet() + expect(status).toBe(404) + }) + + it('folds the fee into the total for a SEK invoice (unchanged behaviour)', async () => { + queued = { data: makeReminderRow('SEK', 10_000), error: null } + const { status, body } = await callGet() + + expect(status).toBe(200) + expect(body.currency).toBe('SEK') + expect(body.reminderFee).toBe(60) + expect(body.reminderFeeCurrency).toBe('SEK') + expect(body.totalDue).toBe(10_070) + expect(body.feeDueSeparately).toBe(0) + }) + + it('never sums the SEK fee into a EUR total', async () => { + queued = { data: makeReminderRow('EUR', 1_000), error: null } + const { status, body } = await callGet() + + expect(status).toBe(200) + expect(body.currency).toBe('EUR') + // 1000 EUR + 10 EUR interest. The 60 kr fee stays out of the EUR figure: + // 1070 here would demand roughly 690 kr too much on a public page. + expect(body.totalDue).toBe(1_010) + expect(body.feeDueSeparately).toBe(60) + expect(body.reminderFeeCurrency).toBe('SEK') + }) + + it('always reports the fee currency as SEK so the page cannot label 60 as EUR', async () => { + queued = { data: makeReminderRow('USD', 500), error: null } + const { body } = await callGet() + + expect(body.reminderFeeCurrency).toBe('SEK') + expect(body.reminderFee).toBe(60) + expect(body.totalDue).toBe(510) + }) +}) diff --git a/app/api/invoices/reminders/action/route.ts b/app/api/invoices/reminders/action/route.ts index 431eb6ef..e1863ce6 100644 --- a/app/api/invoices/reminders/action/route.ts +++ b/app/api/invoices/reminders/action/route.ts @@ -1,5 +1,9 @@ import { createServerClient } from '@supabase/ssr' import { NextResponse } from 'next/server' +import { + calculateReminderAmounts, + REMINDER_FEE_CURRENCY, +} from '@/lib/email/reminder-templates' // Create a service client (no auth needed - public endpoint with token validation) function createServiceClient() { @@ -166,8 +170,18 @@ export async function GET(request: Request) { const interestAmount = Number(reminder.interest_amount ?? 0) const reminderFee = Number(reminder.reminder_fee ?? 0) - const totalDue = - Math.round((Number(invoice.total) + interestAmount + reminderFee) * 100) / 100 + + // The invoice total and the dröjsmålsränta are in the invoice currency; the + // påminnelseavgift is a statutory SEK amount booked 1510/3990 in SEK. They are + // split per currency instead of summed into one scalar: adding 60 kr to a EUR + // total, or relabelling it as 60 EUR, would demand the wrong money from the + // customer on a public page. + const amounts = calculateReminderAmounts({ + invoiceTotal: Number(invoice.total), + interestAmount, + reminderFee, + currency: invoice.currency, + }) return NextResponse.json({ invoiceNumber: invoice.invoice_number, @@ -187,6 +201,11 @@ export async function GET(request: Request) { interestFromDate: reminder.interest_from_date, interestDays: reminder.interest_days, reminderFee, - totalDue, + /** Always 'SEK': the fee is a krona statute, never the invoice currency. */ + reminderFeeCurrency: REMINDER_FEE_CURRENCY, + /** In `currency`. Includes the fee only when the invoice is itself in SEK. */ + totalDue: amounts.totalDue, + /** In SEK. Non-zero only when the fee must be demanded outside `totalDue`. */ + feeDueSeparately: amounts.feeDueSeparately, }) } diff --git a/app/api/invoices/route.ts b/app/api/invoices/route.ts index 6aba6106..4a05758f 100644 --- a/app/api/invoices/route.ts +++ b/app/api/invoices/route.ts @@ -275,6 +275,22 @@ async function createCreditNote( return errorResponseFromCode('INVOICE_CREDIT_ALREADY_CREDITED', log, { requestId }) } + // 'partially_paid' is missing from this list and that is a real gap, not a + // rule: an aendringsfaktura per ML (2023:200) 17 kap 22-23 SS references the + // original's loepnummer, and whether the customer has paid nothing, part or + // all of it has no bearing on the right to issue one. It is NOT added here + // alone, because this door is not where the flow ends: issueCreditNote() + // (lib/invoices/issue-credit-note.ts) flips the original to 'credited' with + // the same three-status compare-and-set, and it runs AFTER the reversing + // verifikat is posted. Widening only this check would post an immutable + // voucher and then fail on the status flip, leaving a fully credited invoice + // sitting at 'partially_paid': open in the AR ledger and still chased by + // reminders. Widening it is a coordinated change across the six sites listed + // in DECISIONS.md, with issue-credit-note.ts first. + // + // Genuinely refused either way: 'draft' (never issued, so there is no + // loepnummer for ML 17 kap 22 to reference) and 'cancelled'. 'credited' is + // refused above. if (!['sent', 'paid', 'overdue'].includes(originalInvoice.status)) { return errorResponseFromCode('INVOICE_CREDIT_NOT_SENT', log, { requestId, diff --git a/app/api/invoices/self-billed/__tests__/route.test.ts b/app/api/invoices/self-billed/__tests__/route.test.ts index ac263cef..cba02979 100644 --- a/app/api/invoices/self-billed/__tests__/route.test.ts +++ b/app/api/invoices/self-billed/__tests__/route.test.ts @@ -28,9 +28,13 @@ vi.mock('@/lib/auth/require-write', () => ({ const mockGetVatRules = vi.fn() const mockGetAvailableVatRates = vi.fn() +// Mocked SEPARATELY from getAvailableVatRates on purpose: the picker default and +// the validation gate are two different sets, and the gate must read this one. +const mockGetPermittedVatRates = vi.fn() vi.mock('@/lib/invoices/vat-rules', () => ({ getVatRules: (...args: unknown[]) => mockGetVatRules(...args), getAvailableVatRates: (...args: unknown[]) => mockGetAvailableVatRates(...args), + getPermittedVatRates: (...args: unknown[]) => mockGetPermittedVatRates(...args), })) vi.mock('@/lib/currency/riksbanken', () => ({ @@ -59,6 +63,13 @@ const validBody = { items: [{ description: 'Konsulttjänst', quantity: 10, unit: 'tim', unit_price: 1000 }], } +const DOMESTIC_RATES = [ + { rate: 25, label: '25%', treatment: 'standard_25' }, + { rate: 12, label: '12%', treatment: 'reduced_12' }, + { rate: 6, label: '6%', treatment: 'reduced_6' }, + { rate: 0, label: '0% (momsfri)', treatment: 'exempt' }, +] + function mockDomesticVat() { mockGetVatRules.mockReturnValue({ treatment: 'standard_25', @@ -66,11 +77,33 @@ function mockDomesticVat() { momsRuta: '05', reverseChargeText: null, }) + // Domestically the two sets are identical (getPermittedVatRates only widens + // for a foreign business customer). + mockGetAvailableVatRates.mockReturnValue(DOMESTIC_RATES) + mockGetPermittedVatRates.mockReturnValue(DOMESTIC_RATES) +} + +/** + * A VAT-validated EU business: the picker DEFAULT is a single locked 0% + * (huvudregeln, ML 6 kap. 34 §), while the PERMITTED set also carries the + * Swedish rates for the supplies taxed where they are performed. The gate must + * read the permitted set, so the two mocks deliberately disagree here. + */ +function mockEuBusinessVat() { + mockGetVatRules.mockReturnValue({ + treatment: 'reverse_charge', + rate: 0, + momsRuta: '39', + reverseChargeText: 'Omvänd skattskyldighet / Reverse charge', + }) mockGetAvailableVatRates.mockReturnValue([ + { rate: 0, label: '0% (omvänd skattskyldighet)', treatment: 'reverse_charge' }, + ]) + mockGetPermittedVatRates.mockReturnValue([ + { rate: 0, label: '0% (omvänd skattskyldighet)', treatment: 'reverse_charge' }, { rate: 25, label: '25%', treatment: 'standard_25' }, { rate: 12, label: '12%', treatment: 'reduced_12' }, { rate: 6, label: '6%', treatment: 'reduced_6' }, - { rate: 0, label: '0% (momsfri)', treatment: 'exempt' }, ]) } @@ -118,11 +151,13 @@ describe('POST /api/invoices/self-billed', () => { it('rejects an item VAT rate the customer is not allowed to use', async () => { mockGetVatRules.mockReturnValue({ treatment: 'standard_25', rate: 25, momsRuta: '05', reverseChargeText: null }) // Domestic-only set: 0% is NOT allowed for this customer. - mockGetAvailableVatRates.mockReturnValue([ + const noZero = [ { rate: 25, label: '25%', treatment: 'standard_25' }, { rate: 12, label: '12%', treatment: 'reduced_12' }, { rate: 6, label: '6%', treatment: 'reduced_6' }, - ]) + ] + mockGetAvailableVatRates.mockReturnValue(noZero) + mockGetPermittedVatRates.mockReturnValue(noZero) enqueue({ data: makeCustomer({ id: VALID_UUID }), error: null }) const request = createMockRequest('/api/invoices/self-billed', { @@ -136,6 +171,63 @@ describe('POST /api/invoices/self-billed', () => { expect(body.error.code).toBe('INVOICE_CREATE_VAT_RULE_VIOLATION') }) + it('accepts 12% to a VAT-validated EU business (taxed where performed)', async () => { + // A Stockholm hotel night self-billed by a German customer: 12% Swedish VAT + // is lawful even though the picker default for that customer is 0%. Refusing + // it made the sale impossible to register at all. + mockEuBusinessVat() + const customer = makeCustomer({ id: VALID_UUID, customer_type: 'eu_business' }) + const created = makeInvoice({ + id: 'inv-1', + invoice_number: null, + is_self_billed: true, + external_invoice_number: 'KUND-55012', + total: 11200, + }) + + enqueue({ data: customer, error: null }) // fetch customer + enqueue({ data: created, error: null }) // insert invoice + enqueue({ data: null, error: null }) // insert items + enqueue({ data: { accounting_method: 'accrual', entity_type: 'aktiebolag' }, error: null }) // settings + enqueue({ data: { ...created, customer, items: [] }, error: null }) // fetch complete + enqueue({ data: null, error: null }) // update journal_entry_id + enqueue({ data: { ...created, customer, items: [], journal_entry_id: 'je-1' }, error: null }) // fetch final + + mockCreateInvoiceJournalEntry.mockResolvedValue({ id: 'je-1' }) + + const request = createMockRequest('/api/invoices/self-billed', { + method: 'POST', + body: { + ...validBody, + items: [{ description: 'Hotellnatt Stockholm', quantity: 10, unit: 'st', unit_price: 1000, vat_rate: 12 }], + }, + }) + const response = await POST(request, { params: Promise.resolve({}) }) + const { status } = await parseJsonResponse(response) + + expect(status).toBe(200) + }) + + it('still rejects a rate that is not a Swedish VAT rate at all', async () => { + // Widening the permitted set must not turn this into a pass-through: 10% is + // no Swedish rate. Here it never even reaches the domain gate, because + // SelfBillingInvoiceItemSchema only accepts 0/6/12/25. + mockEuBusinessVat() + + const request = createMockRequest('/api/invoices/self-billed', { + method: 'POST', + body: { + ...validBody, + items: [{ description: 'X', quantity: 1, unit: 'st', unit_price: 100, vat_rate: 10 }], + }, + }) + const response = await POST(request, { params: Promise.resolve({}) }) + const { status, body } = await parseJsonResponse<{ type: string }>(response) + + expect(status).toBe(400) + expect(body.type).toBe('validation_error') + }) + it('creates a self-billed sale, books it (accrual), skips own numbering, and emits invoice.created', async () => { mockDomesticVat() const customer = makeCustomer({ id: VALID_UUID, name: 'Stora Bolaget AB' }) diff --git a/app/api/kpi/preferences/__tests__/route.test.ts b/app/api/kpi/preferences/__tests__/route.test.ts index c3428e4c..1608929c 100644 --- a/app/api/kpi/preferences/__tests__/route.test.ts +++ b/app/api/kpi/preferences/__tests__/route.test.ts @@ -91,7 +91,8 @@ describe('PUT /api/kpi/preferences', () => { }) it('upserts and returns the stored value on the happy path', async () => { - enqueue({ data: { value: { accountOverrides: { some_kpi: ['3001'] } } } }) + enqueue({ data: { value: {} } }) // existing row lookup + enqueue({ data: { value: { accountOverrides: { some_kpi: ['3001'] } } } }) // upsert const req = createMockRequest('/api/kpi/preferences', { method: 'PUT', body: { accountOverrides: { some_kpi: ['3001'] } }, @@ -101,4 +102,123 @@ describe('PUT /api/kpi/preferences', () => { expect(status).toBe(200) expect(body.data.accountOverrides.some_kpi).toEqual(['3001']) }) + + it('rejects an accountOverrides value that is not an array of strings', async () => { + const req = createMockRequest('/api/kpi/preferences', { + method: 'PUT', + body: { accountOverrides: { some_kpi: 'not-an-array' } }, + }) + const res = await PUT(req, { params: Promise.resolve({}) }) + const { status } = await parseJsonResponse(res) + expect(status).toBe(400) + }) + +}) + +/** + * A partial save used to reset every unmentioned setting: mergeWithDefaults() + * fills absent keys with FACTORY defaults, and the merged document was what got + * stored, so saving an account override wiped the user's visible-KPI selection + * and ordering. The merge base is now the STORED row. Asserted on the payload + * handed to upsert(); the shared queued mock proxies every chain method, so the + * payload is captured with a purpose-built recorder. + */ +describe('PUT /api/kpi/preferences merge semantics', () => { + function createCapturingSupabase(results: { data?: unknown; error?: unknown }[]) { + const upsertPayloads: Record[] = [] + const upsertOptions: ({ onConflict?: string } | undefined)[] = [] + let idx = 0 + const makeBuilder = () => { + const result = results[idx++] ?? { data: null, error: null } + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const b: any = {} + for (const m of ['select', 'eq', 'maybeSingle', 'single']) { + b[m] = () => b + } + b.upsert = (payload: Record, options?: { onConflict?: string }) => { + upsertPayloads.push(payload) + upsertOptions.push(options) + return b + } + b.then = (resolve: (v: unknown) => void) => + resolve({ data: result.data ?? null, error: result.error ?? null }) + return b + } + return { supabase: { from: () => makeBuilder() }, upsertPayloads, upsertOptions } + } + + const stored = { + visibleKpis: ['kpi_a'], + kpiOrder: ['kpi_a', 'kpi_b'], + accountOverrides: { kpi_a: ['3001'] }, + } + + async function put(body: unknown, existing: unknown) { + const { supabase, upsertPayloads, upsertOptions } = createCapturingSupabase([ + { data: existing === undefined ? null : { value: existing } }, + { data: { value: 'ok' } }, + ]) + requireAuthMock.mockResolvedValue({ user: mockUser, supabase, error: null }) + const res = await PUT( + createMockRequest('/api/kpi/preferences', { method: 'PUT', body }), + { params: Promise.resolve({}) }, + ) + return { + res, + value: upsertPayloads[0]?.value as Record | undefined, + payload: upsertPayloads[0], + options: upsertOptions[0], + } + } + + it('keeps the stored visibleKpis and kpiOrder when only accountOverrides is sent', async () => { + const { res, value } = await put({ accountOverrides: { kpi_b: ['4010'] } }, stored) + expect(res.status).toBe(200) + expect(value).toEqual({ + visibleKpis: ['kpi_a'], + kpiOrder: ['kpi_a', 'kpi_b'], + accountOverrides: { kpi_b: ['4010'] }, + }) + }) + + it('replaces every key when the dialog sends the complete object', async () => { + const full = { + visibleKpis: ['kpi_z'], + kpiOrder: ['kpi_z'], + accountOverrides: { kpi_z: ['3010'] }, + } + const { value } = await put(full, stored) + expect(value).toEqual(full) + }) + + it('falls back to defaults for a row that has never been written', async () => { + const { res, value } = await put({ visibleKpis: ['kpi_new'] }, undefined) + expect(res.status).toBe(200) + expect(value?.visibleKpis).toEqual(['kpi_new']) + // The other two keys come from the defaults, not from undefined. + expect(value?.kpiOrder).toBeDefined() + expect(value?.accountOverrides).toBeDefined() + }) + + it('an empty body stores the stored value unchanged', async () => { + const { value } = await put({}, stored) + expect(value).toEqual(stored) + }) + + it('upserts against the company-scoped unique constraint', async () => { + // Migration 20260330130000 dropped UNIQUE (user_id, extension_id, key) in + // favor of UNIQUE (company_id, extension_id, key). Naming the old trio in + // onConflict makes Postgres reject every save with 42P10, because the + // surviving user_id index is non-unique and cannot arbitrate ON CONFLICT. + const { res, payload, options } = await put({ visibleKpis: ['kpi_a'] }, stored) + expect(res.status).toBe(200) + expect(options?.onConflict).toBe('company_id,extension_id,key') + // The row still carries company scoping + last-writer attribution. + expect(payload).toMatchObject({ + company_id: 'company-1', + user_id: 'user-1', + extension_id: 'core/kpi', + key: 'preferences', + }) + }) }) diff --git a/app/api/kpi/preferences/route.ts b/app/api/kpi/preferences/route.ts index 8aaa0dcd..b2cc14ea 100644 --- a/app/api/kpi/preferences/route.ts +++ b/app/api/kpi/preferences/route.ts @@ -1,5 +1,8 @@ import { NextResponse } from 'next/server' +import { z } from 'zod' import { withRouteContext } from '@/lib/api/with-route-context' +import { validateBody } from '@/lib/api/validate' +import { sparsePatchBody } from '@/lib/api/sparse-patch' import { mergeWithDefaults } from '@/lib/reports/kpi-definitions' import type { KPIPreferences } from '@/types' import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message' @@ -7,6 +10,19 @@ import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-m const EXTENSION_ID = 'core/kpi' const KEY = 'preferences' +/** + * Deliberately carries no `.default()`: the defaults belong to + * mergeWithDefaults() on the read path, not to the parse of a write. A schema + * default here would resurrect on every save exactly the way a `.partial()` + * schema's defaults do. Keys are all optional so the route can tell "the + * caller set this" from "the caller said nothing about this". + */ +const UpdateKPIPreferencesSchema = z.object({ + visibleKpis: z.array(z.string()).optional(), + kpiOrder: z.array(z.string()).optional(), + accountOverrides: z.record(z.string(), z.array(z.string())).optional(), +}) + export const GET = withRouteContext('kpi.preferences.get', async (_request, { supabase, companyId }) => { const { data } = await supabase .from('extension_data') @@ -23,26 +39,15 @@ export const GET = withRouteContext('kpi.preferences.get', async (_request, { su 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 prefs = body as Partial + const validation = await validateBody(request, sparsePatchBody(UpdateKPIPreferencesSchema)) + if (!validation.success) return validation.response + const prefs = validation.data // 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)) { + if (!/^\d{4}$/.test(acc)) { return NextResponse.json( { error: `Invalid account number "${acc}" in ${kpiId}: must be 4 digits` }, { status: 400 } @@ -52,8 +57,34 @@ export const PUT = withRouteContext( } } - const merged = mergeWithDefaults(prefs) + // Merge over what is STORED, not over the defaults. mergeWithDefaults() + // fills every absent key with its default value, so saving one setting + // used to reset the other two: a PUT of `{ accountOverrides: … }` wiped + // visibleKpis and kpiOrder back to factory settings. Defaults still apply + // to a row that has never been written (and on the read path), but they no + // longer overwrite a stored choice the caller never mentioned. The + // settings dialog always sends the complete object (its reset button fills + // the draft with getDefaultPreferences() before saving), so its behaviour + // is unchanged; only sparse callers stop losing data. + const { data: existing } = await supabase + .from('extension_data') + .select('value') + .eq('company_id', companyId) + .eq('extension_id', EXTENSION_ID) + .eq('key', KEY) + .maybeSingle() + const stored = mergeWithDefaults((existing?.value as Partial) ?? {}) + const merged: KPIPreferences = { ...stored, ...prefs } + + // KPI preferences are COMPANY-scoped, not per-user: the read paths (GET + // above and the KPI report route) filter on (company_id, extension_id, + // key) with no user filter, and the key carries no user id. user_id is + // stored purely as "who wrote this last" attribution. The upsert must + // therefore arbitrate on the company-scoped unique constraint: migration + // 20260330130000 dropped UNIQUE (user_id, extension_id, key) in favor of + // UNIQUE (company_id, extension_id, key), so naming the old column trio + // here makes Postgres fail every save with 42P10 (no matching constraint). const { data, error } = await supabase .from('extension_data') .upsert( @@ -64,7 +95,7 @@ export const PUT = withRouteContext( key: KEY, value: merged, }, - { onConflict: 'user_id,extension_id,key' } + { onConflict: 'company_id,extension_id,key' } ) .select() .single() diff --git a/app/api/reconciliation/bank/unmatched-entries/__tests__/route.test.ts b/app/api/reconciliation/bank/unmatched-entries/__tests__/route.test.ts new file mode 100644 index 00000000..398c004b --- /dev/null +++ b/app/api/reconciliation/bank/unmatched-entries/__tests__/route.test.ts @@ -0,0 +1,251 @@ +/** + * Tests for GET /api/reconciliation/bank/unmatched-entries. + * + * Exercises the route through the real withRouteContext wrapper and the REAL + * matcher (tryReconcileTransaction / ledgerLineAmountIn); only the RPC-backed + * candidate fetch is mocked, so the currency guard is tested end to end rather + * than against a stubbed scorer. + * + * The focus is the ranking currency: it is the CASH ACCOUNT's, never the + * transaction's own. Sourcing it from the transaction made + * tryReconcileTransaction's `transaction.currency !== expectedCurrency` guard + * compare the transaction against itself, so it never rejected anything and a + * foreign bank line was ranked against this account's SEK vouchers. + */ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { NextResponse } from 'next/server' +import { createQueuedMockSupabase, createMockRequest, parseJsonResponse } from '@/tests/helpers' +import type { GLLineForMatching } from '@/lib/reconciliation/bank-reconciliation' + +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'), +})) + +// Only the candidate fetch is stubbed: the scorer and ledgerLineAmountIn stay +// real so a regression in the currency handling actually fails here. +const fetchGLLinesForMatchingMock = vi.fn() +vi.mock('@/lib/reconciliation/bank-reconciliation', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + fetchGLLinesForMatching: (...args: unknown[]) => fetchGLLinesForMatchingMock(...args), + } +}) + +import { GET } from '../route' + +const emptyParams = { params: Promise.resolve({}) } +const TX_ID = '11111111-1111-4111-8111-111111111111' + +type RankedLine = GLLineForMatching & { confidence: number } +type Body = { data: RankedLine[]; not_rankable_reason?: string } + +function makeGLLine(overrides: Partial = {}): GLLineForMatching { + return { + line_id: 'line-1', + journal_entry_id: 'je-1', + // The RPC projects the SEK debit/credit columns only: see the currency / + // amount_in_currency note on UnlinkedGLLine. + debit_amount: 0, + credit_amount: 1150, + line_description: null, + entry_date: '2026-03-10', + voucher_number: 42, + voucher_series: 'A', + entry_description: 'Leverantörsbetalning', + source_type: 'manual', + linked_transaction_count: 0, + ...overrides, + } +} + +/** Queue the cash_accounts lookup the route always performs first. */ +function enqueueCashAccount(currency: string | null = 'SEK') { + enqueue({ data: { id: 'cash-1', currency } }) +} + +/** Queue the transaction lookup the ranked path performs second. */ +function enqueueTransaction(overrides: Record = {}) { + enqueue({ + data: { + id: TX_ID, + amount: -1150, + date: '2026-03-10', + currency: 'SEK', + reference: null, + ...overrides, + }, + }) +} + +function request(searchParams: Record) { + return createMockRequest('/api/reconciliation/bank/unmatched-entries', { searchParams }) +} + +describe('GET /api/reconciliation/bank/unmatched-entries', () => { + beforeEach(() => { + vi.clearAllMocks() + reset() + requireAuthMock.mockResolvedValue({ user: { id: 'user-1' }, supabase }) + fetchGLLinesForMatchingMock.mockResolvedValue([]) + }) + + it('returns 401 when unauthenticated', async () => { + requireAuthMock.mockResolvedValue({ + user: null, + supabase, + error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }), + }) + + const response = await GET(request({ account_number: '1930' }), emptyParams) + + expect(response.status).toBe(401) + expect(fetchGLLinesForMatchingMock).not.toHaveBeenCalled() + }) + + it('rejects an account the company has not registered as a cash account', async () => { + // The route's "not found": an unregistered ledger account is refused rather + // than probed, so this endpoint cannot be used to read arbitrary GL accounts. + enqueue({ data: null }) + + const response = await GET(request({ account_number: '1510' }), emptyParams) + + expect(response.status).toBe(400) + expect(fetchGLLinesForMatchingMock).not.toHaveBeenCalled() + }) + + it('returns no candidates when transaction_id does not resolve in this company', async () => { + enqueueCashAccount('SEK') + enqueue({ data: null }) + fetchGLLinesForMatchingMock.mockResolvedValue([makeGLLine()]) + + const response = await GET( + request({ account_number: '1930', transaction_id: TX_ID }), + emptyParams, + ) + const { status, body } = await parseJsonResponse(response) + + expect(status).toBe(200) + expect(body.data).toEqual([]) + }) + + // ---------------------------------------------------------------- + // SEK: the 95% path, byte-for-byte unchanged + // ---------------------------------------------------------------- + + it('ranks a SEK bank line against SEK vouchers exactly as before', async () => { + enqueueCashAccount('SEK') + enqueueTransaction({ currency: 'SEK', amount: -1150, date: '2026-03-10' }) + fetchGLLinesForMatchingMock.mockResolvedValue([ + makeGLLine({ line_id: 'line-far', journal_entry_id: 'je-far', entry_date: '2026-02-01' }), + makeGLLine({ line_id: 'line-hit', journal_entry_id: 'je-hit', entry_date: '2026-03-10' }), + ]) + + const response = await GET( + request({ account_number: '1930', transaction_id: TX_ID }), + emptyParams, + ) + const { status, body } = await parseJsonResponse(response) + + expect(status).toBe(200) + // Nothing withheld on a SEK account: ledgerLineAmountIn never returns null there. + expect(body.data).toHaveLength(2) + expect(body.not_rankable_reason).toBeUndefined() + // Exact amount + exact date = auto_exact, sorted to the top. + expect(body.data[0].journal_entry_id).toBe('je-hit') + expect(body.data[0].confidence).toBe(0.95) + expect(body.data[1].confidence).toBe(0) + }) + + it('leaves the unranked list untouched when no transaction_id is passed', async () => { + enqueueCashAccount('SEK') + const lines = [makeGLLine()] + fetchGLLinesForMatchingMock.mockResolvedValue(lines) + + const response = await GET(request({ account_number: '1930' }), emptyParams) + const { status, body } = await parseJsonResponse(response) + + expect(status).toBe(200) + expect(body.data).toHaveLength(1) + expect(body.data[0]).not.toHaveProperty('confidence') + }) + + // ---------------------------------------------------------------- + // Foreign currency: never ranked against an unconverted SEK ledger leg + // ---------------------------------------------------------------- + + it('does not rank a foreign bank line against a same-magnitude SEK voucher', async () => { + // A 1 150 EUR payment out of the EUR account. The voucher's 1932 leg holds + // 1 150 in the SEK columns for an unrelated amount of money; the RPC + // projects no currency / amount_in_currency, so there is no rate to convert + // with. Offering it as the settlement would be a coincidence of magnitude. + enqueueCashAccount('EUR') + enqueueTransaction({ currency: 'EUR', amount: -1150, date: '2026-03-10' }) + fetchGLLinesForMatchingMock.mockResolvedValue([makeGLLine({ credit_amount: 1150 })]) + + const response = await GET( + request({ account_number: '1932', transaction_id: TX_ID }), + emptyParams, + ) + const { status, body } = await parseJsonResponse(response) + + expect(status).toBe(200) + expect(body.data).toEqual([]) + // Honest about WHY the list is empty: candidates exist, they just cannot be + // expressed in the account's currency. Not "no unmatched verifikationer". + expect(body.not_rankable_reason).toBe('gl_lines_missing_currency_amount') + }) + + it('refuses to rank a bank line denominated in another currency than the account', async () => { + // The tautology this fixes: with the reconciliation currency read off the + // transaction, this EUR row was ranked against the SEK account's vouchers. + enqueueCashAccount('SEK') + enqueueTransaction({ currency: 'EUR', amount: -1150, date: '2026-03-10' }) + fetchGLLinesForMatchingMock.mockResolvedValue([makeGLLine({ credit_amount: 1150 })]) + + const response = await GET( + request({ account_number: '1930', transaction_id: TX_ID }), + emptyParams, + ) + const { status, body } = await parseJsonResponse(response) + + expect(status).toBe(200) + expect(body.data).toEqual([]) + expect(body.not_rankable_reason).toBe('transaction_currency_mismatch') + }) + + it('ranks a foreign bank line when the ledger line carries a foreign amount', async () => { + // Proves the filter is per-line, not a blanket "non-SEK account cannot + // rank": the day the RPC projects currency + amount_in_currency, ranking + // resumes with no further change here. + enqueueCashAccount('EUR') + enqueueTransaction({ currency: 'EUR', amount: -100, date: '2026-03-10' }) + fetchGLLinesForMatchingMock.mockResolvedValue([ + makeGLLine({ + // Debit/credit are SEK; the EUR figure lives in amount_in_currency. + credit_amount: 1150, + currency: 'EUR', + amount_in_currency: 100, + }), + ]) + + const response = await GET( + request({ account_number: '1932', transaction_id: TX_ID }), + emptyParams, + ) + const { status, body } = await parseJsonResponse(response) + + expect(status).toBe(200) + expect(body.data).toHaveLength(1) + expect(body.data[0].confidence).toBe(0.95) + expect(body.not_rankable_reason).toBeUndefined() + }) +}) diff --git a/app/api/reconciliation/bank/unmatched-entries/route.ts b/app/api/reconciliation/bank/unmatched-entries/route.ts index f595394f..3a630714 100644 --- a/app/api/reconciliation/bank/unmatched-entries/route.ts +++ b/app/api/reconciliation/bank/unmatched-entries/route.ts @@ -1,6 +1,10 @@ import { NextResponse } from 'next/server' import { withRouteContext } from '@/lib/api/with-route-context' -import { fetchGLLinesForMatching, tryReconcileTransaction } from '@/lib/reconciliation/bank-reconciliation' +import { + fetchGLLinesForMatching, + ledgerLineAmountIn, + tryReconcileTransaction, +} from '@/lib/reconciliation/bank-reconciliation' import type { Transaction } from '@/types' export const GET = withRouteContext( @@ -28,9 +32,11 @@ export const GET = withRouteContext( // 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. + // `currency` comes along because it, not the transaction's own currency, is + // the unit this account is reconciled in: see the ranking block below. const { data: cashAccount } = await supabase .from('cash_accounts') - .select('id') + .select('id, currency') .eq('company_id', companyId) .eq('ledger_account', accountNumber) .maybeSingle() @@ -42,6 +48,11 @@ export const GET = withRouteContext( ) } + // cash_accounts.currency is NOT NULL in the schema; the fallback only guards + // against a legacy/hand-built row so a missing value can never silently turn + // into "no currency" and disable ranking for a SEK company. + const accountCurrency = (cashAccount.currency as string | null) ?? 'SEK' + const lines = await fetchGLLinesForMatching(supabase, companyId, accountNumber, dateFrom, dateTo, includeMatched) if (transactionId) { @@ -62,14 +73,45 @@ export const GET = withRouteContext( return NextResponse.json({ data: [] }) } + // The reconciliation currency is the CASH ACCOUNT's, never the + // transaction's. Reading it off `tx` made tryReconcileTransaction's + // `transaction.currency !== expectedCurrency` guard compare the + // transaction against itself: a tautology that passed for every row, so + // the guard never rejected anything and a foreign bank line was ranked + // against this account's (SEK) vouchers. const txCurrency = (tx.currency as string | null) ?? 'SEK' + if (txCurrency !== accountCurrency) { + // A bank line in another currency cannot be settled on this account, so + // there is no honest ranking to produce. Same posture as the unresolved + // transaction above: no candidates beats plausible-looking wrong ones. + return NextResponse.json({ + data: [], + not_rankable_reason: 'transaction_currency_mismatch', + }) + } + + // Withhold any candidate that cannot be expressed in the account's + // currency. journal_entry_lines.debit_amount / credit_amount are ALWAYS + // SEK; `currency` on the line labels the underlying DOCUMENT and + // amount_in_currency carries the foreign figure (see ledgerLineAmountIn). + // On a SEK account this filter is a no-op: ledgerLineAmountIn never + // returns null there, so SEK-only companies see exactly the list they + // always did. On a foreign account it currently drops EVERY candidate, + // because get_account_gl_lines_for_matching projects neither currency nor + // amount_in_currency (verified against prod), so no row carries a rate to + // convert with. Ranking them anyway would offer a 1 150 SEK ledger leg as + // the settlement for a 1 150 EUR bank line. Written per-line rather than + // as an `accountCurrency !== 'SEK'` short-circuit so that ranking resumes + // by itself the day the RPC projects those two columns. + const rankable = lines.filter((line) => ledgerLineAmountIn(line, accountCurrency) !== null) + const txDate = tx.date as string - const ranked = lines + const ranked = rankable .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) + const match = tryReconcileTransaction(tx as unknown as Transaction, [line], accountCurrency) return { ...line, confidence: match?.confidence ?? 0 } }) .sort((a, b) => { @@ -78,6 +120,15 @@ export const GET = withRouteContext( const db = Math.abs(new Date(b.entry_date).getTime() - new Date(txDate).getTime()) return da - db }) + + // Say so when candidates were withheld, instead of letting an empty list + // read as "this account has no unmatched verifikationer in the period". + if (rankable.length !== lines.length) { + return NextResponse.json({ + data: ranked, + not_rankable_reason: 'gl_lines_missing_currency_amount', + }) + } return NextResponse.json({ data: ranked }) } diff --git a/app/api/reports/ar-ledger/pdf/__tests__/route.test.ts b/app/api/reports/ar-ledger/pdf/__tests__/route.test.ts index 682656d3..8ccfdad2 100644 --- a/app/api/reports/ar-ledger/pdf/__tests__/route.test.ts +++ b/app/api/reports/ar-ledger/pdf/__tests__/route.test.ts @@ -1,4 +1,5 @@ import { describe, it, expect, vi, beforeEach } from 'vitest' +import { isValidElement } from 'react' import { NextResponse } from 'next/server' const mockSupabase = { @@ -36,9 +37,41 @@ vi.mock('@/lib/reports/ar-ledger', () => ({ import { GET } from '../route' import { requireAuth } from '@/lib/auth/require-auth' import { generateARLedger } from '@/lib/reports/ar-ledger' +import { renderToBuffer } from '@react-pdf/renderer' const mockUser = { id: 'user-1', email: 'test@test.se' } +/** + * The template renders for real (only renderToBuffer is stubbed), so the + * document handed to it can be walked for its text. Joining the leaves with '' + * reproduces the text of any single . + */ +function renderedText(node: unknown): string { + const out: string[] = [] + const walk = (n: unknown): void => { + if (n === null || n === undefined || typeof n === 'boolean') return + if (typeof n === 'string' || typeof n === 'number') { + out.push(String(n)) + return + } + if (Array.isArray(n)) { + n.forEach(walk) + return + } + if (isValidElement(n)) { + walk((n.props as { children?: unknown }).children) + } + } + walk(node) + // Intl's sv-SE group separator is a non-breaking space; normalise every + // space-like character so the assertions below can use ordinary spaces. + return out.join('').replace(/\s/g, ' ') +} + +function renderedDocumentText(): string { + return renderedText(vi.mocked(renderToBuffer).mock.calls[0][0]) +} + function companySettingsQuery(data: unknown) { return { select: vi.fn().mockReturnThis(), @@ -83,6 +116,71 @@ function makeLedger() { } } +/** + * One SEK invoice plus one EUR invoice with a rate, plus one USD invoice with + * no rate (outstanding_sek null, so it is missing from the aging buckets). + * The aging totals are SEK: 1 000 + 11 475. + */ +function makeFxLedger() { + return { + entries: [ + { + customer_id: 'cust-1', + customer_name: 'Acme AB', + invoices: [ + { + invoice_id: 'inv-1', + invoice_number: 'F001', + invoice_date: '2026-05-01', + due_date: '2026-06-01', + total: 1000, + paid_amount: 0, + outstanding: 1000, + outstanding_sek: 1000, + days_overdue: 14, + currency: 'SEK', + }, + { + invoice_id: 'inv-2', + invoice_number: 'F002', + invoice_date: '2026-05-02', + due_date: '2026-06-02', + total: 1000, + paid_amount: 0, + outstanding: 1000, + outstanding_sek: 11475, + days_overdue: 13, + currency: 'EUR', + }, + { + invoice_id: 'inv-3', + invoice_number: 'F003', + invoice_date: '2026-05-03', + due_date: '2026-06-03', + total: 500, + paid_amount: 0, + outstanding: 500, + outstanding_sek: null, + days_overdue: 12, + currency: 'USD', + }, + ], + current: 0, + days_1_30: 12475, + days_31_60: 0, + days_61_90: 0, + days_90_plus: 0, + total_outstanding: 12475, + }, + ], + total_outstanding: 12475, + total_current: 0, + total_overdue: 12475, + unpaid_count: 3, + unconverted_fx_count: 1, + } +} + function makeRequest(query = '') { return new Request(`http://localhost/api/reports/ar-ledger/pdf${query}`) } @@ -149,4 +247,39 @@ describe('GET /api/reports/ar-ledger/pdf', () => { const res = await GET(makeRequest('?as_of_date=2026-06-30'), { params: Promise.resolve({}) } as never) expect(res.status).toBe(500) }) + + it('forwards outstanding_sek so the PDF carries the SEK bridge column', async () => { + vi.mocked(generateARLedger).mockResolvedValue(makeFxLedger() as never) + + const res = await GET(makeRequest('?as_of_date=2026-06-30'), { params: Promise.resolve({}) } as never) + expect(res.status).toBe(200) + + const text = renderedDocumentText() + // The bridge column the XLSX export has ("Utestående (SEK)") now exists here too. + expect(text).toContain('Utest. SEK') + expect(text).toContain('11 475,00') + // ...and the two units on the page are named rather than left implicit. + expect(text).toContain('Åldersfördelning per kund (SEK)') + expect(text).toContain('Fakturor (fakturans valuta)') + }) + + it('marks an unconvertible FX invoice instead of dropping it from the PDF', async () => { + vi.mocked(generateARLedger).mockResolvedValue(makeFxLedger() as never) + + await GET(makeRequest('?as_of_date=2026-06-30'), { params: Promise.resolve({}) } as never) + + const text = renderedDocumentText() + expect(text).toContain('F003') + expect(text).toContain('saknas') + expect(text).toContain('1 faktura i utländsk valuta saknar växelkurs') + }) + + it('leaves a SEK-only PDF without the bridge column', async () => { + const res = await GET(makeRequest('?as_of_date=2026-06-30'), { params: Promise.resolve({}) } as never) + expect(res.status).toBe(200) + + const text = renderedDocumentText() + expect(text).not.toContain('Utest. SEK') + expect(text).toContain('Fakturor (SEK)') + }) }) diff --git a/app/api/reports/ar-ledger/pdf/route.ts b/app/api/reports/ar-ledger/pdf/route.ts index 941cb816..65c34a54 100644 --- a/app/api/reports/ar-ledger/pdf/route.ts +++ b/app/api/reports/ar-ledger/pdf/route.ts @@ -39,6 +39,12 @@ export const GET = withRouteContext('report.ar_ledger.pdf', async (request, { su invoice_date: inv.invoice_date, due_date: inv.due_date, outstanding: inv.outstanding, + // The SEK bridge: `outstanding` is in the invoice's own currency + // while the aging table above it is in SEK, so the PDF needs the + // converted amount to let a reader tie the two together. `null` + // means the FX rate was missing, i.e. the row is absent from the + // aging totals. Matches the XLSX export's "Utestående (SEK)". + outstanding_sek: inv.outstanding_sek, currency: inv.currency, days_overdue: inv.days_overdue, }) diff --git a/app/api/reports/kassaflodesanalys/pdf/__tests__/route.test.ts b/app/api/reports/kassaflodesanalys/pdf/__tests__/route.test.ts new file mode 100644 index 00000000..7857f321 --- /dev/null +++ b/app/api/reports/kassaflodesanalys/pdf/__tests__/route.test.ts @@ -0,0 +1,217 @@ +/** + * Contract tests for the kassaflödesanalys PDF route. + * + * Two of these are load-bearing for the client. `KassaflodesanalysClient` now + * fetches this route with `downloadFile` instead of assigning + * `window.location.href`, which means: + * + * 1. The client names the saved file itself, as + * `kassaflodesanalys-.pdf`. It reads period_start from + * the sibling JSON route, which returns the same generator output this + * route renders, so the two names agree. The Content-Disposition assertion + * below pins the server half of that agreement: change the filename here + * and the archived statutory artefact silently gets a different name than + * the one the browser writes. + * 2. The route's error bodies are now read and shown in a toast instead of + * being rendered as a raw JSON document after the browser navigated the + * whole app away. `getErrorMessage` must therefore find a real sentence in + * them rather than falling back to the generic HTTP status text, which is + * what the last test checks. + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { NextResponse } from 'next/server' +import { getErrorMessage } from '@/lib/errors/get-error-message' + +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(), +})) + +// Stub the renderer so no real PDF layout runs. The primitives the template +// imports at module load (StyleSheet.create) still have to exist. +vi.mock('@react-pdf/renderer', () => ({ + renderToBuffer: vi.fn().mockResolvedValue(Buffer.from('%PDF-1.4 test')), + StyleSheet: { create: (s: unknown) => s }, + Document: (p: unknown) => p, + Page: (p: unknown) => p, + Text: (p: unknown) => p, + View: (p: unknown) => p, +})) + +vi.mock('@/lib/reports/kassaflodesanalys', () => ({ + generateKassaflodesanalys: vi.fn(), +})) + +import { GET } from '../route' +import { requireAuth } from '@/lib/auth/require-auth' +import { generateKassaflodesanalys } from '@/lib/reports/kassaflodesanalys' + +const mockUser = { id: 'user-1', email: 'test@test.se' } + +const PERIOD = { period_start: '2025-01-01', period_end: '2025-12-31' } + +function makeReport() { + return { + fiscal_period_id: 'period-1', + ...PERIOD, + lopande: { + resultat_efter_finansiella_poster: 120000, + avskrivningar: 20000, + ovriga_ej_kassaflodesposter: 0, + delta_kortfristiga_fordringar: -5000, + delta_varulager: 0, + delta_kortfristiga_skulder: 3000, + skatt_betald: -25000, + total: 113000, + }, + investerings: { + forvarv_anlaggningar: -40000, + avyttring_anlaggningar: 0, + total: -40000, + }, + finansierings: { + delta_lan: 0, + utdelningar: -30000, + nyemission: 0, + erhallna_aktieagartillskott: 0, + total: -30000, + }, + total_cash_flow: 43000, + reconciliation: { + opening_cash_1xxx: 100000, + closing_cash_1xxx: 143000, + delta_actual: 43000, + delta_calculated: 43000, + mismatch_amount: 0, + is_reconciled: true, + }, + } +} + +/** One chain object serves both queries: select/eq chain, single resolves. */ +function tableQuery(data: unknown) { + return { + select: vi.fn().mockReturnThis(), + eq: vi.fn().mockReturnThis(), + single: vi.fn().mockResolvedValue({ data, error: null }), + } +} + +let periodRow: unknown = PERIOD +let companyRow: unknown = { company_name: 'Testbolaget AB', org_number: '5566778899' } + +function makeRequest(query = '') { + return new Request(`http://localhost/api/reports/kassaflodesanalys/pdf${query}`) +} + +function call(query = '') { + return GET(makeRequest(query), { params: Promise.resolve({}) } as never) +} + +describe('GET /api/reports/kassaflodesanalys/pdf', () => { + beforeEach(() => { + vi.clearAllMocks() + periodRow = PERIOD + companyRow = { company_name: 'Testbolaget AB', org_number: '5566778899' } + vi.mocked(requireAuth).mockResolvedValue({ + user: mockUser as never, + supabase: mockSupabase as never, + error: null, + }) + mockSupabase.from.mockImplementation((table: string) => + tableQuery(table === 'fiscal_periods' ? periodRow : companyRow), + ) + vi.mocked(generateKassaflodesanalys).mockResolvedValue(makeReport() 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 res = await call('?period_id=period-1') + expect(res.status).toBe(401) + expect(generateKassaflodesanalys).not.toHaveBeenCalled() + }) + + it('returns 400 when period_id is missing', async () => { + const res = await call() + expect(res.status).toBe(400) + expect(generateKassaflodesanalys).not.toHaveBeenCalled() + }) + + it('refuses to render when the fiscal period cannot be read', async () => { + // An identifiable period is part of räkenskapsinformation (BFL 7 kap): a + // PDF that cannot be archived with the period it refers to is not produced. + periodRow = null + + const res = await call('?period_id=missing') + expect(res.status).toBe(400) + expect(generateKassaflodesanalys).not.toHaveBeenCalled() + }) + + it('returns 404 when company settings are missing', async () => { + companyRow = null + + const res = await call('?period_id=period-1') + expect(res.status).toBe(404) + expect(generateKassaflodesanalys).not.toHaveBeenCalled() + }) + + it('names the file the way the client saves it', async () => { + const res = await call('?period_id=period-1') + + expect(res.status).toBe(200) + expect(res.headers.get('Content-Type')).toBe('application/pdf') + // KassaflodesanalysClient builds exactly this name from the report it + // already holds. Both sides read period_start from the same generator + // output, so this literal is the contract between them. + expect(res.headers.get('Content-Disposition')).toBe( + 'attachment; filename="kassaflodesanalys-2025-01-01.pdf"', + ) + expect(generateKassaflodesanalys).toHaveBeenCalledWith(mockSupabase, 'company-1', 'period-1') + }) + + it('returns 500 when the generator throws', async () => { + vi.mocked(generateKassaflodesanalys).mockRejectedValue(new Error('boom')) + + const res = await call('?period_id=period-1') + expect(res.status).toBe(500) + }) + + it('answers failures with a body the download toast can show', async () => { + // The client passes the parsed body to getErrorMessage. If a body carried + // no recognisable sentence, the user would get the generic status text + // ("Förfrågan innehåller ogiltiga uppgifter") and learn nothing about which + // period or setting is actually missing. + periodRow = null + const badPeriod = await call('?period_id=missing') + const badPeriodBody = await badPeriod.json() + expect(getErrorMessage(badPeriodBody, { statusCode: 400 })).toContain( + 'Räkenskapsperioden kunde inte läsas', + ) + + periodRow = PERIOD + companyRow = null + const noSettings = await call('?period_id=period-1') + const noSettingsBody = await noSettings.json() + expect(getErrorMessage(noSettingsBody, { statusCode: 404 })).toBe( + 'Företagsinställningar saknas', + ) + }) +}) diff --git a/app/api/reports/kpi/__tests__/route.test.ts b/app/api/reports/kpi/__tests__/route.test.ts index 17b5aa10..743b04e8 100644 --- a/app/api/reports/kpi/__tests__/route.test.ts +++ b/app/api/reports/kpi/__tests__/route.test.ts @@ -105,12 +105,63 @@ const PAID_INVOICES = Array.from({ length: 5 }, () => ({ paid_at: '2026-01-11', })) +function supplierRow( + overrides: Partial<{ + supplier_id: string + total: number + total_sek: number | null + currency: string + exchange_rate: number | null + supplier: { id: string; name: string } + }> = {} +) { + const supplierId = overrides.supplier_id ?? 'sup-1' + return { + supplier_id: supplierId, + total: 100, + total_sek: null, + currency: 'SEK', + exchange_rate: null, + supplier: { id: supplierId, name: 'Leverantören AB' }, + ...overrides, + } +} + const SUPPLIER_ROWS = [ - { supplier_id: 'sup-1', total_sek: 400, total: 400, supplier: { id: 'sup-1', name: 'Leverantören AB' } }, - { supplier_id: 'sup-1', total_sek: 100, total: 100, supplier: { id: 'sup-1', name: 'Leverantören AB' } }, - { supplier_id: 'sup-2', total_sek: 200, total: 200, supplier: { id: 'sup-2', name: 'Andra AB' } }, + supplierRow({ supplier_id: 'sup-1', total: 400, total_sek: 400 }), + supplierRow({ supplier_id: 'sup-1', total: 100, total_sek: 100 }), + supplierRow({ + supplier_id: 'sup-2', + total: 200, + total_sek: 200, + supplier: { id: 'sup-2', name: 'Andra AB' }, + }), ] +/** + * The realistic shape of an ordinary Swedish supplier invoice: currency SEK, + * no exchange rate, and `total_sek` NULL because no conversion ever ran. + */ +const SEK_ONLY_ROWS = [ + supplierRow({ supplier_id: 'sup-1', total: 1250, supplier: { id: 'sup-1', name: 'Städbolaget AB' } }), + supplierRow({ supplier_id: 'sup-1', total: 750.5, supplier: { id: 'sup-1', name: 'Städbolaget AB' } }), + supplierRow({ + supplier_id: 'sup-2', + total: 400, + supplier: { id: 'sup-2', name: 'Kontorsvaror AB' }, + }), +] + +/** Queue the six responses the hot path consumes after the fiscal period. */ +function enqueueHotPath(supplierRows: unknown[]) { + enqueue({ data: aggPayload() }) // rpc get_kpi_report_aggregates + enqueue({ data: [] }) // rpc compute_prior_opening_balances + enqueue({ data: CHART }) // chart_of_accounts + enqueue({ data: null }) // extension_data prefs + enqueue({ data: [] }) // invoices + enqueue({ data: supplierRows }) // supplier_invoices +} + function kpiRequest(searchParams: Record = { period_id: 'period-1' }) { return createMockRequest('/api/reports/kpi', { searchParams }) } @@ -186,6 +237,7 @@ describe('GET /api/reports/kpi', () => { { supplier_id: 'sup-1', supplier_name: 'Leverantören AB', total: 500 }, { supplier_id: 'sup-2', supplier_name: 'Andra AB', total: 200 }, ], + topSuppliersUnconvertedFxCount: 0, }) expect(supabase.rpc).toHaveBeenCalledWith('get_kpi_report_aggregates', { @@ -279,6 +331,83 @@ describe('GET /api/reports/kpi', () => { expect(supabase.rpc).not.toHaveBeenCalled() }) + it('populates Största leverantörer for a SEK-only company whose total_sek is NULL', async () => { + // Headline regression: total_sek is only written when a conversion runs, + // so an ordinary Swedish supplier invoice has it NULL. Reading total_sek + // alone left the panel permanently empty for these companies. + enqueue({ data: makePeriod() }) // fiscal_periods + enqueueHotPath(SEK_ONLY_ROWS) + + const res = await GET(kpiRequest(), noParams) + const { status, body } = await parseJsonResponse<{ data: KPIReport }>(res) + + expect(status).toBe(200) + expect(body.data.topSuppliers).toEqual([ + { supplier_id: 'sup-1', supplier_name: 'Städbolaget AB', total: 2000.5 }, + { supplier_id: 'sup-2', supplier_name: 'Kontorsvaror AB', total: 400 }, + ]) + expect(body.data.topSuppliersUnconvertedFxCount).toBe(0) + }) + + it('aggregates a mixed SEK/EUR company in SEK', async () => { + enqueue({ data: makePeriod() }) // fiscal_periods + enqueueHotPath([ + // SEK invoice: total is the SEK total, exactly. + supplierRow({ supplier_id: 'sup-1', total: 1000, supplier: { id: 'sup-1', name: 'Svensk Lev AB' } }), + // EUR invoice with a rate: converted at 100 * 11.4567 = 1145.67. + supplierRow({ + supplier_id: 'sup-1', + total: 100, + currency: 'EUR', + exchange_rate: 11.4567, + supplier: { id: 'sup-1', name: 'Svensk Lev AB' }, + }), + // EUR invoice with a stored SEK total: that value wins. + supplierRow({ + supplier_id: 'sup-2', + total: 200, + total_sek: 2300, + currency: 'EUR', + exchange_rate: 11.5, + supplier: { id: 'sup-2', name: 'Euro Supplier GmbH' }, + }), + ]) + + const res = await GET(kpiRequest(), noParams) + const { status, body } = await parseJsonResponse<{ data: KPIReport }>(res) + + expect(status).toBe(200) + expect(body.data.topSuppliers).toEqual([ + { supplier_id: 'sup-2', supplier_name: 'Euro Supplier GmbH', total: 2300 }, + { supplier_id: 'sup-1', supplier_name: 'Svensk Lev AB', total: 2145.67 }, + ]) + expect(body.data.topSuppliersUnconvertedFxCount).toBe(0) + }) + + it('reports an unconvertible FX invoice instead of silently dropping it', async () => { + enqueue({ data: makePeriod() }) // fiscal_periods + enqueueHotPath([ + supplierRow({ supplier_id: 'sup-1', total: 1000, supplier: { id: 'sup-1', name: 'Svensk Lev AB' } }), + // USD, no rate and no SEK total: cannot be expressed in SEK. + supplierRow({ + supplier_id: 'sup-2', + total: 500, + currency: 'USD', + supplier: { id: 'sup-2', name: 'US Vendor Inc' }, + }), + ]) + + const res = await GET(kpiRequest(), noParams) + const { status, body } = await parseJsonResponse<{ data: KPIReport }>(res) + + expect(status).toBe(200) + // The raw 500 USD is neither added as SEK nor hidden: it is counted. + expect(body.data.topSuppliers).toEqual([ + { supplier_id: 'sup-1', supplier_name: 'Svensk Lev AB', total: 1000 }, + ]) + expect(body.data.topSuppliersUnconvertedFxCount).toBe(1) + }) + it('returns 500 when the aggregates RPC fails', async () => { enqueue({ data: makePeriod() }) // fiscal_periods enqueue({ data: null, error: { message: 'connection reset' } }) // rpc get_kpi_report_aggregates diff --git a/app/api/reports/kpi/route.ts b/app/api/reports/kpi/route.ts index 5b5885fb..cbd29d77 100644 --- a/app/api/reports/kpi/route.ts +++ b/app/api/reports/kpi/route.ts @@ -23,6 +23,9 @@ import { calculateExpenseRatio, calculateAvgPaymentDays, calculateVatLiability, + aggregateTopSuppliers, + fetchTopSupplierInvoices, + type KpiSupplierInvoiceRow, } from '@/lib/reports/kpi' import { mergeWithDefaults } from '@/lib/reports/kpi-definitions' import { parseDimensionFilterParams } from '@/lib/reports/dimension-filter' @@ -79,14 +82,10 @@ export const GET = withRouteContext('report.kpi', async (request, { supabase, co .eq('company_id', companyId) .eq('status', 'paid') .not('paid_at', 'is', null) + // Paginated: awaiting the bare query capped the rows at PostgREST's 1000 + // default and silently corrupted the supplier totals for large companies. const topSuppliersQuery = () => - supabase - .from('supplier_invoices') - .select('supplier_id, total_sek, total, supplier:suppliers(id, name)') - .eq('company_id', companyId) - .gte('invoice_date', period.period_start) - .lte('invoice_date', period.period_end) - .neq('status', 'credited') + fetchTopSupplierInvoices(supabase, companyId, period.period_start, period.period_end) let prefsValue: unknown let incomeStatement: IncomeStatementReport @@ -252,40 +251,17 @@ export const GET = withRouteContext('report.kpi', async (request, { supabase, co .sort((a, b) => b.total - a.total) .slice(0, 5) - // Top suppliers by spend within the fiscal period. Sum total_sek to avoid - // mixing currencies. Drop FX invoices without a SEK conversion (total_sek - // null): they would otherwise inflate a supplier's total with raw - // foreign-currency amounts. - type SupplierInvoiceRow = { - supplier_id: string | null - total_sek: number | null - total: number | null - supplier: { id: string; name: string } | { id: string; name: string }[] | null - } + // Top suppliers by spend within the fiscal period, in SEK. The per-row SEK + // resolution and the FX exclusion count both live in aggregateTopSuppliers, + // which the xlsx export calls with the same query, so the two reports cannot + // disagree about the same company. if (topSuppliersResult.error) { // Surface the failure rather than silently rendering an empty chart that // matches the legitimate "no supplier invoices" empty state. console.error('[kpi] topSuppliersResult error:', topSuppliersResult.error) } - const supplierTotals = new Map() - for (const row of (topSuppliersResult.data ?? []) as SupplierInvoiceRow[]) { - if (!row.supplier_id) continue - const supplier = Array.isArray(row.supplier) ? row.supplier[0] : row.supplier - if (!supplier?.name) continue - const amount = row.total_sek ?? null - if (amount == null) continue - const existing = supplierTotals.get(row.supplier_id) - if (existing) existing.total += amount - else supplierTotals.set(row.supplier_id, { name: supplier.name, total: amount }) - } - const topSuppliers = Array.from(supplierTotals.entries()) - .map(([supplier_id, v]) => ({ - supplier_id, - supplier_name: v.name, - total: Math.round(v.total * 100) / 100, - })) - .sort((a, b) => b.total - a.total) - .slice(0, 7) + const { suppliers: topSuppliers, unconvertedFxCount: topSuppliersUnconvertedFxCount } = + aggregateTopSuppliers((topSuppliersResult.data ?? []) as KpiSupplierInvoiceRow[]) const report: KPIReport = { netResult: incomeStatement.net_result, @@ -309,6 +285,7 @@ export const GET = withRouteContext('report.kpi', async (request, { supabase, co }, topExpenseAccounts, topSuppliers, + topSuppliersUnconvertedFxCount, } return NextResponse.json({ data: report }) diff --git a/app/api/reports/kpi/xlsx/__tests__/route.test.ts b/app/api/reports/kpi/xlsx/__tests__/route.test.ts new file mode 100644 index 00000000..6be67926 --- /dev/null +++ b/app/api/reports/kpi/xlsx/__tests__/route.test.ts @@ -0,0 +1,266 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { NextResponse } from 'next/server' +import * as XLSX from 'xlsx' +import { createQueuedMockSupabase, createMockRequest, parseJsonResponse } from '@/tests/helpers' +import type { KPIReport } from '@/types' + +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'), +})) + +// The heavy report generators are stubbed: this suite is about the supplier +// figures. The pure builders the KPI JSON hot path uses stay real so the +// cross-route consistency test compares two genuinely computed reports. +vi.mock('@/lib/reports/trial-balance', () => ({ + generateTrialBalance: vi.fn(), +})) +vi.mock('@/lib/reports/ar-ledger', () => ({ + generateARLedger: vi.fn(), +})) +vi.mock('@/lib/reports/income-statement', async (importOriginal) => { + const actual = await importOriginal() + return { ...actual, generateIncomeStatement: vi.fn() } +}) +vi.mock('@/lib/reports/monthly-breakdown', async (importOriginal) => { + const actual = await importOriginal() + return { ...actual, generateMonthlyBreakdown: vi.fn() } +}) + +import { GET } from '../route' +import { GET as GET_JSON } from '../../route' +import { generateTrialBalance } from '@/lib/reports/trial-balance' +import { generateARLedger } from '@/lib/reports/ar-ledger' +import { generateIncomeStatement } from '@/lib/reports/income-statement' +import { generateMonthlyBreakdown } from '@/lib/reports/monthly-breakdown' + +const mockTrialBalance = vi.mocked(generateTrialBalance) +const mockARLedger = vi.mocked(generateARLedger) +const mockIncomeStatement = vi.mocked(generateIncomeStatement) +const mockMonthlyBreakdown = vi.mocked(generateMonthlyBreakdown) + +const noParams = { params: Promise.resolve({}) } + +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 PERIOD = { + period_start: '2026-01-01', + period_end: '2026-03-31', + is_closed: false, +} + +function supplierRow( + overrides: Partial<{ + supplier_id: string + total: number + total_sek: number | null + currency: string + exchange_rate: number | null + supplier: { id: string; name: string } + }> = {} +) { + const supplierId = overrides.supplier_id ?? 'sup-1' + return { + supplier_id: supplierId, + total: 100, + total_sek: null, + currency: 'SEK', + exchange_rate: null, + supplier: { id: supplierId, name: 'Leverantören AB' }, + ...overrides, + } +} + +/** + * An ordinary Swedish supplier invoice: currency SEK, no exchange rate and + * `total_sek` NULL because no conversion ever ran. + */ +const SEK_ONLY_ROWS = [ + supplierRow({ supplier_id: 'sup-1', total: 1250, supplier: { id: 'sup-1', name: 'Städbolaget AB' } }), + supplierRow({ supplier_id: 'sup-1', total: 750.5, supplier: { id: 'sup-1', name: 'Städbolaget AB' } }), + supplierRow({ + supplier_id: 'sup-2', + total: 400, + supplier: { id: 'sup-2', name: 'Kontorsvaror AB' }, + }), +] + +const MIXED_ROWS = [ + supplierRow({ supplier_id: 'sup-1', total: 1000, supplier: { id: 'sup-1', name: 'Svensk Lev AB' } }), + supplierRow({ + supplier_id: 'sup-1', + total: 100, + currency: 'EUR', + exchange_rate: 11.4567, + supplier: { id: 'sup-1', name: 'Svensk Lev AB' }, + }), + supplierRow({ + supplier_id: 'sup-2', + total: 200, + total_sek: 2300, + currency: 'EUR', + exchange_rate: 11.5, + supplier: { id: 'sup-2', name: 'Euro Supplier GmbH' }, + }), + // USD without a rate and without a SEK total: unconvertible. + supplierRow({ + supplier_id: 'sup-3', + total: 500, + currency: 'USD', + supplier: { id: 'sup-3', name: 'US Vendor Inc' }, + }), +] + +function xlsxRequest(searchParams: Record = { period_id: 'period-1' }) { + return createMockRequest('/api/reports/kpi/xlsx', { searchParams }) +} + +/** Queue the four responses the xlsx route consumes, in call order. */ +function enqueueXlsx(supplierRows: unknown[]) { + enqueue({ data: PERIOD }) // fiscal_periods + enqueue({ data: { company_name: 'Acme AB' } }) // company_settings + enqueue({ data: [] }) // invoices (paid) + enqueue({ data: supplierRows }) // supplier_invoices +} + +/** Parse the produced workbook. The response body can only be read once. */ +async function readWorkbook(res: Response): Promise { + const buf = Buffer.from(await res.arrayBuffer()) + return XLSX.read(new Uint8Array(buf), { type: 'array' }) +} + +/** Read one sheet back out of the workbook as label/value pairs. */ +function sheetRows(wb: XLSX.WorkBook, sheetName: string): [string, unknown][] { + const sheet = wb.Sheets[sheetName] + expect(sheet, `sheet ${sheetName} missing`).toBeDefined() + const rows = XLSX.utils.sheet_to_json(sheet, { header: 1 }) + // Drop the header row. + return rows.slice(1).map((r) => [String(r[0]), r[1]] as [string, unknown]) +} + +beforeEach(() => { + vi.clearAllMocks() + reset() + authed() + mockARLedger.mockResolvedValue({ total_outstanding: 0, total_overdue: 0 } as never) + mockIncomeStatement.mockResolvedValue({ + revenue_sections: [], + total_revenue: 0, + expense_sections: [], + total_expenses: 0, + financial_sections: [], + total_financial: 0, + net_result: 0, + period: { start: '2026-01-01', end: '2026-03-31' }, + }) + mockTrialBalance.mockResolvedValue({ rows: [], totalDebit: 0, totalCredit: 0, isBalanced: true }) + mockMonthlyBreakdown.mockResolvedValue({ months: [] }) +}) + +describe('GET /api/reports/kpi/xlsx', () => { + it('returns 401 when not authenticated', async () => { + unauthed() + const res = await GET(xlsxRequest(), noParams) + expect(res.status).toBe(401) + expect(supabase.from).not.toHaveBeenCalled() + }) + + it('returns 400 when period_id is missing', async () => { + const res = await GET(xlsxRequest({}), noParams) + expect(res.status).toBe(400) + }) + + it('returns 404 for an unknown fiscal period', async () => { + enqueue({ data: null }) // fiscal_periods + enqueue({ data: null }) // company_settings + const res = await GET(xlsxRequest(), noParams) + const { status } = await parseJsonResponse(res) + expect(status).toBe(404) + }) + + it('fills Topp leverantörer for a SEK-only company whose total_sek is NULL', async () => { + // Headline regression: reading total_sek alone produced an empty sheet for + // every ordinary Swedish company. + enqueueXlsx(SEK_ONLY_ROWS) + + const res = await GET(xlsxRequest(), noParams) + expect(res.status).toBe(200) + + expect(sheetRows(await readWorkbook(res), 'Topp leverantörer')).toEqual([ + ['Städbolaget AB', 2000.5], + ['Kontorsvaror AB', 400], + ]) + }) + + it('aggregates mixed SEK/EUR rows and reports the unconvertible FX invoice', async () => { + enqueueXlsx(MIXED_ROWS) + + const res = await GET(xlsxRequest(), noParams) + expect(res.status).toBe(200) + + const wb = await readWorkbook(res) + expect(sheetRows(wb, 'Topp leverantörer')).toEqual([ + ['Euro Supplier GmbH', 2300], + ['Svensk Lev AB', 2145.67], + ]) + + // The 500 USD row is neither converted at a made-up rate nor dropped in + // silence: the export states how many rows it could not convert. + expect(sheetRows(wb, 'Nyckeltal (övrigt)')).toContainEqual([ + 'Ej omräknade valutafakturor (leverantörer)', + 1, + ]) + }) +}) + +describe('KPI JSON and xlsx agree', () => { + it('reports identical supplier totals for the same company and period', async () => { + // JSON route (hot path) queue order. + enqueue({ data: { id: 'period-1', company_id: 'company-1', ...PERIOD, opening_balance_entry_id: null } }) + enqueue({ data: { tb: [], tb_ex_year_end: [], ob: [], monthly: [] } }) // aggregates RPC + enqueue({ data: [] }) // compute_prior_opening_balances + enqueue({ data: [] }) // chart_of_accounts + enqueue({ data: null }) // extension_data prefs + enqueue({ data: [] }) // invoices + enqueue({ data: MIXED_ROWS }) // supplier_invoices + + const jsonRes = await GET_JSON( + createMockRequest('/api/reports/kpi', { searchParams: { period_id: 'period-1' } }), + noParams + ) + const { body } = await parseJsonResponse<{ data: KPIReport }>(jsonRes) + + // xlsx route over the identical supplier rows. + enqueueXlsx(MIXED_ROWS) + const xlsxRes = await GET(xlsxRequest(), noParams) + const wb = await readWorkbook(xlsxRes) + const suppliers = sheetRows(wb, 'Topp leverantörer') + const other = sheetRows(wb, 'Nyckeltal (övrigt)') + + expect(suppliers).toEqual( + body.data.topSuppliers.map((s) => [s.supplier_name, s.total]) + ) + expect(other).toContainEqual([ + 'Ej omräknade valutafakturor (leverantörer)', + body.data.topSuppliersUnconvertedFxCount, + ]) + expect(body.data.topSuppliersUnconvertedFxCount).toBe(1) + }) +}) diff --git a/app/api/reports/kpi/xlsx/route.ts b/app/api/reports/kpi/xlsx/route.ts index befcad40..4ffaef13 100644 --- a/app/api/reports/kpi/xlsx/route.ts +++ b/app/api/reports/kpi/xlsx/route.ts @@ -10,6 +10,9 @@ import { calculateExpenseRatio, calculateAvgPaymentDays, calculateVatLiability, + aggregateTopSuppliers, + fetchTopSupplierInvoices, + type KpiSupplierInvoiceRow, } from '@/lib/reports/kpi' import { reportToWorkbook, @@ -87,13 +90,9 @@ export const GET = withRouteContext('report.kpi.xlsx', async (request, { supabas .eq('company_id', companyId) .eq('status', 'paid') .not('paid_at', 'is', null), - supabase - .from('supplier_invoices') - .select('supplier_id, total_sek, total, supplier:suppliers(id, name)') - .eq('company_id', companyId) - .gte('invoice_date', period.period_start) - .lte('invoice_date', period.period_end) - .neq('status', 'credited'), + // Paginated past PostgREST's 1000-row cap so the export sums every + // supplier invoice in the period, same as the KPI JSON route. + fetchTopSupplierInvoices(supabase, companyId, period.period_start, period.period_end), ]) const cashPosition = calculateCashPosition(trialBalanceResult.rows) @@ -119,30 +118,12 @@ export const GET = withRouteContext('report.kpi.xlsx', async (request, { supabas { class4: 0, class5: 0, class6: 0, class7: 0 }, ) - type SupplierInvoiceRow = { - supplier_id: string | null - total_sek: number | null - total: number | null - supplier: { id: string; name: string } | { id: string; name: string }[] | null - } - const supplierTotals = new Map() - for (const row of (topSuppliersResult.data ?? []) as SupplierInvoiceRow[]) { - if (!row.supplier_id) continue - const supplier = Array.isArray(row.supplier) ? row.supplier[0] : row.supplier - if (!supplier?.name) continue - const amount = row.total_sek ?? null - if (amount == null) continue - const existing = supplierTotals.get(row.supplier_id) - if (existing) existing.total += amount - else supplierTotals.set(row.supplier_id, { name: supplier.name, total: amount }) - } - const topSuppliers = Array.from(supplierTotals.values()) - .map((v) => ({ - supplier_name: v.name, - total: Math.round(v.total * 100) / 100, - })) - .sort((a, b) => b.total - a.total) - .slice(0, 7) + // Same query, same aggregation as the KPI JSON route: both go through + // topSupplierInvoicesQuery + aggregateTopSuppliers, so the export and the + // in-app panel report identical supplier totals for a given period. + const { suppliers: topSuppliers, unconvertedFxCount } = aggregateTopSuppliers( + (topSuppliersResult.data ?? []) as KpiSupplierInvoiceRow[], + ) // Sheet 1: scalar KPIs, label + value. Currency by default; percent rows // are split into a separate sheet so the formatting is unambiguous. @@ -166,6 +147,9 @@ export const GET = withRouteContext('report.kpi.xlsx', async (request, { supabas const integerKpis: KpiKv[] = [ { label: 'Genomsnittliga betaldagar', value: calculateAvgPaymentDays(paidInvoices) }, + // Antal valutafakturor som saknar både SEK-belopp och kurs och därför + // inte kan räknas in i "Topp leverantörer". 0 = inget är exkluderat. + { label: 'Ej omräknade valutafakturor (leverantörer)', value: unconvertedFxCount }, ] const monthRows: MonthRow[] = monthlyBreakdown.months 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 a1301ea2..5db31571 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 @@ -13,7 +13,14 @@ vi.mock('@/lib/company/context', () => ({ })) vi.mock('@/lib/bookkeeping/currency-utils', () => ({ - resolveSekAmount: vi.fn((amount: number) => amount), + resolveSekAmount: vi.fn( + ( + amount: number, + _amountSek: number | null, + currency: string | null, + rate: number | null + ) => (currency && currency !== 'SEK' && rate ? amount * rate : amount) + ), })) import { GET } from '../route' @@ -23,12 +30,20 @@ interface QueryResult { error: unknown } +/** + * The invoices query is paginated via fetchAllRows (.range per page), so the + * mock serves one page result per .range() call. A single QueryResult is a + * one-page company; pass an array to exercise multi-page paging. + */ function buildSupabase( supplier: { id: string; name: string } | null, - invoicesResult: QueryResult, + invoicesResults: QueryResult | QueryResult[], entriesResult: QueryResult ) { - return { + const pages = Array.isArray(invoicesResults) ? [...invoicesResults] : [invoicesResults] + const rangeCalls: Array<[number, number]> = [] + const orderCalls: unknown[][] = [] + const supabase = { from: vi.fn().mockImplementation((table: string) => { if (table === 'suppliers') { return { @@ -42,9 +57,14 @@ function buildSupabase( select: vi.fn().mockReturnThis(), eq: vi.fn().mockReturnThis(), in: vi.fn().mockReturnThis(), - order: vi.fn().mockReturnThis(), - limit: vi.fn().mockReturnThis(), - then: (resolve: (v: QueryResult) => void) => resolve(invoicesResult), + order: vi.fn().mockImplementation(function (this: unknown, ...args: unknown[]) { + orderCalls.push(args) + return this + }), + range: vi.fn().mockImplementation((from: number, to: number) => { + rangeCalls.push([from, to]) + return Promise.resolve(pages.shift() ?? { data: [], error: null }) + }), } } // journal_entries @@ -56,6 +76,7 @@ function buildSupabase( } }), } + return Object.assign(supabase, { rangeCalls, orderCalls }) } beforeEach(() => { @@ -149,4 +170,200 @@ describe('GET /api/reports/supplier-ledger/supplier/[supplierId]/invoices', () = expect(body.data.lines[0].voucher_number).toBe(33) expect(body.data.lines[0].credit).toBe(2500) }) + + it('never reports a foreign amount as SEK when the exchange rate is missing', async () => { + // 1 000 EUR with no rate: there is no SEK figure for the 2440 balance, so + // the Kredit column (always SEK) must not be handed the EUR number. + const invoices = [ + { + id: 'si-fx', + supplier_invoice_number: 'EUR-1', + invoice_date: '2026-05-10', + due_date: '2026-06-10', + total: 1000, + paid_amount: 0, + remaining_amount: 1000, + currency: 'EUR', + exchange_rate: null, + registration_journal_entry_id: null, + }, + ] + requireAuthMock.mockResolvedValue({ + user: { id: 'user-1' }, + supabase: buildSupabase( + { id: 'sup-1', name: 'Euro Supply GmbH' }, + { data: invoices, error: null }, + { data: [], error: null } + ), + error: null, + }) + const req = createMockRequest( + '/api/reports/supplier-ledger/supplier/sup-1/invoices' + ) + const res = await GET(req, createMockRouteParams({ supplierId: 'sup-1' })) + expect(res.status).toBe(200) + + const body = (await res.json()) as { + data: { + unconverted_fx_count: number + lines: Array<{ + credit: number + remaining: number + remaining_sek: number | null + currency: string + }> + } + } + + const line = body.data.lines[0] + // The EUR amount must not leak into the SEK column. + expect(line.credit).not.toBe(1000) + expect(line.credit).toBe(0) + // ... but the invoice stays visible, with its own-currency amount intact. + expect(line.remaining).toBe(1000) + expect(line.currency).toBe('EUR') + // Explicit null marker + count, same contract as the ledger report. + expect(line.remaining_sek).toBeNull() + expect(body.data.unconverted_fx_count).toBe(1) + }) + + it('lets the consumer tell an unconverted row from a settled one', async () => { + const invoices = [ + // Unconvertible: SEK value unknown. + { + id: 'si-fx', + supplier_invoice_number: 'EUR-1', + invoice_date: '2026-05-10', + due_date: '2026-06-10', + total: 1000, + paid_amount: 0, + remaining_amount: 1000, + currency: 'EUR', + exchange_rate: null, + registration_journal_entry_id: null, + }, + // Convertible: 100 EUR at 11.50. + { + id: 'si-fx-rate', + supplier_invoice_number: 'EUR-2', + invoice_date: '2026-05-11', + due_date: '2026-06-11', + total: 100, + paid_amount: 0, + remaining_amount: 100, + currency: 'EUR', + exchange_rate: 11.5, + registration_journal_entry_id: null, + }, + // Genuinely settled: nothing left on 2440. + { + id: 'si-settled', + supplier_invoice_number: 'SEK-1', + invoice_date: '2026-05-12', + due_date: '2026-06-12', + total: 500, + paid_amount: 500, + remaining_amount: 0, + currency: 'SEK', + exchange_rate: null, + registration_journal_entry_id: null, + }, + ] + requireAuthMock.mockResolvedValue({ + user: { id: 'user-1' }, + supabase: buildSupabase( + { id: 'sup-1', name: 'Euro Supply GmbH' }, + { data: invoices, error: null }, + { data: [], error: null } + ), + error: null, + }) + const req = createMockRequest( + '/api/reports/supplier-ledger/supplier/sup-1/invoices' + ) + const res = await GET(req, createMockRouteParams({ supplierId: 'sup-1' })) + + const body = (await res.json()) as { + data: { + unconverted_fx_count: number + lines: Array<{ + supplier_invoice_id: string + credit: number + remaining_sek: number | null + }> + } + } + + const byId = new Map(body.data.lines.map((l) => [l.supplier_invoice_id, l])) + // Unknown SEK value: null, not 0. + expect(byId.get('si-fx')!.remaining_sek).toBeNull() + // Settled: 0, not null. + expect(byId.get('si-settled')!.remaining_sek).toBe(0) + // Converted rows are unaffected: still the SEK amount in the Kredit column. + expect(byId.get('si-fx-rate')!.remaining_sek).toBe(1150) + expect(byId.get('si-fx-rate')!.credit).toBe(1150) + expect(body.data.unconverted_fx_count).toBe(1) + }) + + it('paginates past the old hardcoded cap and counts FX rows over the full set', async () => { + // 1000 SEK invoices fill page one; page two carries one more SEK invoice + // plus an unconvertible FX invoice. The old 500-row limit truncated both + // the lines AND unconverted_fx_count while next_cursor: null claimed the + // list was complete. + const makeInvoice = (i: number) => ({ + id: `si-${i}`, + supplier_invoice_number: `INV-${i}`, + invoice_date: '2026-05-10', + due_date: '2026-06-10', + total: 100, + paid_amount: 0, + remaining_amount: 100, + currency: 'SEK', + exchange_rate: null, + registration_journal_entry_id: null, + }) + const page1 = Array.from({ length: 1000 }, (_, i) => makeInvoice(i)) + const page2 = [ + makeInvoice(1000), + { ...makeInvoice(1001), currency: 'EUR', exchange_rate: null }, + ] + + const supabase = buildSupabase( + { id: 'sup-1', name: 'Volym AB' }, + [ + { data: page1, error: null }, + { data: page2, error: null }, + ], + { data: [], error: null } + ) + requireAuthMock.mockResolvedValue({ user: { id: 'user-1' }, supabase, error: null }) + + const res = await GET( + createMockRequest('/api/reports/supplier-ledger/supplier/sup-1/invoices'), + createMockRouteParams({ supplierId: 'sup-1' }) + ) + expect(res.status).toBe(200) + + const body = (await res.json()) as { + data: { + lines: Array<{ remaining_sek: number | null }> + unconverted_fx_count: number + next_cursor: null + } + } + + // Every row made it through, not just the first page. + expect(body.data.lines).toHaveLength(1002) + expect(supabase.rangeCalls).toEqual([ + [0, 999], + [1000, 1999], + ]) + // Stable paging order: invoice_date is not unique, so id must break ties. + expect(supabase.orderCalls).toContainEqual(['invoice_date', { ascending: true }]) + expect(supabase.orderCalls).toContainEqual(['id', { ascending: true }]) + // The honesty counter sees the FX row on page two. + expect(body.data.unconverted_fx_count).toBe(1) + // The shape is unchanged, and the null cursor is now truthful. + expect(body.data.next_cursor).toBeNull() + }) }) 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 4270629f..601831f8 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,7 @@ import { withRouteContext } from '@/lib/api/with-route-context' import { NextResponse } from 'next/server' import { resolveSekAmount } from '@/lib/bookkeeping/currency-utils' +import { fetchAllRows } from '@/lib/supabase/fetch-all' import type { ReportSourceLine } from '@/lib/reports/source-lines' import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message' @@ -10,9 +11,16 @@ import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-m * Returns the supplier invoices behind a supplier's outstanding balance. * Each row's `journal_entry_id` points at the registration journal entry * (when posted) so the UI can link to `/bookkeeping/[id]`. + * + * Currency contract (mirrors `generateSupplierLedger`): `credit` is the open + * balance on 2440 and is therefore always SEK. A foreign-currency invoice with + * no `exchange_rate` has no known SEK amount, so it gets `remaining_sek: null` + * and `credit: 0` instead of its raw foreign amount, and is counted in + * `unconverted_fx_count`. A consumer tells the two apart on `remaining_sek`: + * `null` means "SEK value unknown, excluded from the ledger totals", `0` means + * "genuinely settled". `remaining` (+ `currency`) always carries the invoice's + * own-currency amount, so the row stays visible and readable either way. */ -const PAGE_LIMIT = 500 - export const GET = withRouteContext<{ params: Promise<{ supplierId: string }> }>( 'report.supplier_ledger.invoices', async (request, { supabase, companyId }, { params }) => { @@ -31,32 +39,42 @@ export const GET = withRouteContext<{ params: Promise<{ supplierId: string }> }> // Mirror `generateSupplierLedger`'s filter: registered/approved/partially // paid/overdue invoices that still have an outstanding balance. - const { data, error } = await supabase - .from('supplier_invoices') - .select(` - id, - supplier_invoice_number, - invoice_date, - due_date, - total, - paid_amount, - remaining_amount, - currency, - exchange_rate, - registration_journal_entry_id - `) - .eq('company_id', companyId) - .eq('supplier_id', supplierId) - .in('status', ['registered', 'approved', 'partially_paid', 'overdue']) - .order('invoice_date', { ascending: true }) - .limit(PAGE_LIMIT) - - if (error) { - return NextResponse.json({ error: getUserErrorMessage(error) }, { status: 500 }) - } - + // + // fetchAllRows: bounded by one supplier's OPEN invoices, so the volume is + // naturally small, but the previous hardcoded 500-row .limit() silently + // truncated the page for outliers while next_cursor: null claimed the list + // was complete, and `unconverted_fx_count` was computed over the truncated + // page (the honesty counter under-reported). The secondary .order('id') + // gives .range() paging the stable total order it needs (invoice_date is + // not unique). // eslint-disable-next-line @typescript-eslint/no-explicit-any - const invoices = (data || []) as any[] + let invoices: any[] + try { + invoices = await fetchAllRows(({ from, to }) => + supabase + .from('supplier_invoices') + .select(` + id, + supplier_invoice_number, + invoice_date, + due_date, + total, + paid_amount, + remaining_amount, + currency, + exchange_rate, + registration_journal_entry_id + `) + .eq('company_id', companyId) + .eq('supplier_id', supplierId) + .in('status', ['registered', 'approved', 'partially_paid', 'overdue']) + .order('invoice_date', { ascending: true }) + .order('id', { ascending: true }) + .range(from, to) + ) + } catch (err) { + return NextResponse.json({ error: getUserErrorMessage(err) }, { status: 500 }) + } // Pull the registration entries in one batch to get voucher numbers. const entryIds = invoices @@ -86,6 +104,9 @@ export const GET = withRouteContext<{ params: Promise<{ supplierId: string }> }> const lines: (ReportSourceLine & { supplier_invoice_id: string supplier_invoice_number: string + /** Open amount in the invoice's own currency. Display only: never summed. */ + remaining: number + /** `remaining` in SEK, or `null` when the FX rate is missing. */ remaining_sek: number | null currency: string paid_amount: number @@ -112,10 +133,15 @@ export const GET = withRouteContext<{ params: Promise<{ supplierId: string }> }> entry?.description ?? `Leverantörsfaktura ${inv.supplier_invoice_number || ''}`, debit: 0, - // For an unpaid AP entry, the open balance is a credit on 2440. - credit: remainingSek ?? remaining, + // For an unpaid AP entry, the open balance is a credit on 2440, which is + // posted in SEK. With no rate there is no SEK figure to show: fall back + // to 0 rather than to the foreign amount, which would render as kronor in + // the Kredit column. `remaining_sek: null` is what marks the difference + // between "unknown" and "settled". + credit: remainingSek ?? 0, supplier_invoice_id: inv.id, supplier_invoice_number: inv.supplier_invoice_number || '', + remaining, remaining_sek: remainingSek, currency: inv.currency || 'SEK', paid_amount: Number(inv.paid_amount) || 0, @@ -128,6 +154,12 @@ export const GET = withRouteContext<{ params: Promise<{ supplierId: string }> }> supplier_id: supplier.id, supplier_name: supplier.name, lines, + // Same contract as the ledger report itself: the rows are listed, but + // their SEK value is unknown and missing from every SEK total. Computed + // over the FULL row set now that the query paginates. + unconverted_fx_count: lines.filter((l) => l.remaining_sek === null).length, + // Kept for response-shape compatibility. Truthful now: every open + // invoice for the supplier is in `lines`, so there is never a next page. next_cursor: null, }, }) 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 95223f22..d05404b1 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 @@ -18,36 +18,67 @@ interface SupabaseShape { from: ReturnType } +interface EmbedShapedLine { + debit_amount: number + credit_amount: number + journal_entry_id: string + dimensions?: Record | null + journal_entries: Record +} + +/** + * The route uses the two-step entry-lines fetch + * (lib/bookkeeping/entry-lines.ts): journal_entries is queried first, then + * journal_entry_lines by parent id, and the parent is reattached under + * `journal_entries`. Both steps page with `.order('id').range(from, to)`, so + * `.range()` is the terminal and one short page (< the 1000-row PAGE_SIZE) + * ends the loop. + * + * Fixtures stay embed-shaped; the mock splits them into the two row sets, so + * the route sees exactly what the old `journal_entries!inner` embed returned. + */ function buildSupabase( account: { account_number: string; account_name: string } | null, linesResult: { data: unknown; error: unknown } ): SupabaseShape { + const fixtures = (linesResult.data ?? []) as EmbedShapedLine[] + const entries = linesResult.error + ? [] + : [...new Map(fixtures.map((l) => [l.journal_entry_id, l.journal_entries])).values()] + const bareLines = linesResult.error + ? [] + : fixtures.map((l, i) => ({ + id: `line-${String(i).padStart(5, '0')}`, + journal_entry_id: l.journal_entry_id, + debit_amount: l.debit_amount, + credit_amount: l.credit_amount, + dimensions: l.dimensions ?? null, + })) + + const rowsChain = (rows: unknown[]) => ({ + select: vi.fn().mockReturnThis(), + eq: vi.fn().mockReturnThis(), + in: vi.fn().mockReturnThis(), + gte: vi.fn().mockReturnThis(), + lte: vi.fn().mockReturnThis(), + contains: vi.fn().mockReturnThis(), + order: vi.fn().mockReturnThis(), + limit: vi.fn().mockReturnThis(), + or: vi.fn().mockReturnThis(), + range: vi.fn().mockResolvedValue({ data: rows, error: linesResult.error }), + then: (resolve: (v: unknown) => void) => resolve({ data: rows, error: linesResult.error }), + }) + return { from: vi.fn().mockImplementation((table: string) => { if (table === 'chart_of_accounts') { - const chain = { + return { select: vi.fn().mockReturnThis(), eq: vi.fn().mockReturnThis(), maybeSingle: vi.fn().mockResolvedValue({ data: account, error: null }), } - return chain } - // journal_entry_lines: terminates on `.range()` (fetchAllRows), which - // resolves to the line result. `data.length < PAGE_SIZE` so a single - // page is fetched. - const chain = { - select: vi.fn().mockReturnThis(), - eq: vi.fn().mockReturnThis(), - in: vi.fn().mockReturnThis(), - gte: vi.fn().mockReturnThis(), - lte: vi.fn().mockReturnThis(), - order: vi.fn().mockReturnThis(), - limit: vi.fn().mockReturnThis(), - or: vi.fn().mockReturnThis(), - range: vi.fn().mockResolvedValue(linesResult), - then: (resolve: (v: unknown) => void) => resolve(linesResult), - } - return chain + return rowsChain(table === 'journal_entries' ? entries : bareLines) }), } } 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 7783dce6..097d50b5 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,6 @@ import { withRouteContext } from '@/lib/api/with-route-context' import { NextResponse } from 'next/server' -import { fetchAllRows } from '@/lib/supabase/fetch-all' +import { fetchEntryLines, type EntryLinesQuery } from '@/lib/bookkeeping/entry-lines' import { parseDimensionFilterParams } from '@/lib/reports/dimension-filter' import type { ReportSourceLine } from '@/lib/reports/source-lines' @@ -77,48 +77,38 @@ export const GET = withRouteContext<{ params: Promise<{ accountNumber: string }> // cannot give us a chronological parent order. Without a stable parent order // a raw `.limit()` returns an arbitrary subset that varies between identical // requests, which surfaced as the trial-balance drill-down showing - // "different rows on every reload" for high-volume accounts. We instead page - // on the line PK (`id`) for a stable total order (see fetch-all.ts) and do - // the chronological sort here, mirroring `generateGeneralLedger`. - const rows = await fetchAllRows<{ + // "different rows on every reload" for high-volume accounts. The two-step + // fetch pages both sides on their PK for a stable total order (see + // fetch-all.ts) and we do the chronological sort here, mirroring + // `generateGeneralLedger`. + // + // The scope filters used to live on a `journal_entries!inner` embed, which + // PostgREST compiles into a correlated LATERAL join that walks the ENTIRE + // journal_entry_lines table across all tenants (see + // lib/bookkeeping/entry-lines.ts); driving from the entry side keeps the + // work inside this company's period. + const rows = await fetchEntryLines<{ id: string debit_amount: number credit_amount: number dimensions: Record | null // eslint-disable-next-line @typescript-eslint/no-explicit-any journal_entries: any - }>(({ from, to }) => { - let query = supabase - .from('journal_entry_lines') - .select(` - id, - debit_amount, - credit_amount, - dimensions, - journal_entry_id, - journal_entries!inner( - id, - voucher_number, - voucher_series, - entry_date, - description, - status, - company_id, - fiscal_period_id - ) - `) - .eq('account_number', accountNumber) - .eq('journal_entries.company_id', companyId) - .eq('journal_entries.fiscal_period_id', fiscalPeriodId) - .in('journal_entries.status', ['posted', 'reversed']) - - if (dimFilter.dimensions) { + }>({ + supabase, + entryColumns: 'id, voucher_number, voucher_series, entry_date, description, status, company_id, fiscal_period_id', + lineColumns: 'id, debit_amount, credit_amount, dimensions, journal_entry_id', + filterEntries: (q: EntryLinesQuery) => + q + .eq('company_id', companyId) + .eq('fiscal_period_id', fiscalPeriodId) + .in('status', ['posted', 'reversed']), + filterLines: (q: EntryLinesQuery) => { + const scoped = q.eq('account_number', accountNumber) // jsonb containment (@>): served by idx_jel_dimensions_gin. - query = query.contains('dimensions', dimFilter.dimensions) - } - - return query.order('id', { ascending: true }).range(from, to) - }, { dedupeBy: (r) => r.id }) + return dimFilter.dimensions ? scoped.contains('dimensions', dimFilter.dimensions) : scoped + }, + }) // Map then sort in JS (date ASC, voucher_number ASC, journal_entry_id ASC as // a final deterministic tiebreak for lines sharing a date and voucher number diff --git a/app/api/reports/vat-declaration/__tests__/route.test.ts b/app/api/reports/vat-declaration/__tests__/route.test.ts index 09a41da2..6bb5542b 100644 --- a/app/api/reports/vat-declaration/__tests__/route.test.ts +++ b/app/api/reports/vat-declaration/__tests__/route.test.ts @@ -25,6 +25,8 @@ vi.mock('@/lib/auth/require-auth', () => ({ import { GET } from '../route' import { requireAuth } from '@/lib/auth/require-auth' +import { rcInputTotalsFromDeclaration } from '@/lib/reports/vat-declaration' +import { runVatDeclarationChecks } from '@/lib/reports/vat-declaration-checks' const mockUser = { id: 'user-1', email: 'test@test.se' } @@ -161,6 +163,65 @@ describe('GET /api/reports/vat-declaration', () => { ) }) + // The declaration is what the momsdeklaration UI runs its local + // "Kontroll av underlaget" checks on, and that client has no ledger access of + // its own. Without the 2645/2647 pair on the response it could only compare + // rutor 30-32 against the ruta 48 aggregate, where ordinary debiterad ingående + // moms on 2641 hides a missing beräknad ingående moms completely. + describe('rcInputAccountTotals travels with the response', () => { + /** The masking ledger: 50 000 kr RC output, underlag booked, no 2645/2647. */ + function maskedRcPayload() { + return { + totals: [ + { account_number: '4535', debit: 200000, credit: 0 }, // ruta 21 basis + { account_number: '2614', debit: 0, credit: 50000 }, // ruta 30 + { account_number: '2641', debit: 60000, credit: 0 }, // ordinary input VAT + ], + settlement_shaped_entries: [], + source_type_counts: {}, + } + } + + async function fetchDeclaration() { + const res = await GET( + makeRequest('?periodType=monthly&year=2026&period=1'), + { params: Promise.resolve({}) }, + ) + expect(res.status).toBe(200) + return (await res.json()).data + } + + it('carries both accounts, zeros included, on an ordinary SEK period', async () => { + const data = await fetchDeclaration() + // The default fixture has no reverse charge at all: the pair is still + // present, so a client can tell "no RC activity" from "field missing". + expect(data.rcInputAccountTotals).toEqual({ + '2645': { debit: 0, credit: 0 }, + '2647': { debit: 0, credit: 0 }, + }) + expect(runVatDeclarationChecks(data.rutor, rcInputTotalsFromDeclaration(data))).toEqual([]) + }) + + it('lets the client run the sharp RC input check off the response alone', async () => { + mockSupabase.rpc.mockResolvedValue({ data: maskedRcPayload(), error: null }) + const data = await fetchDeclaration() + + expect(data.rutor.ruta30).toBe(50000) + expect(data.rutor.ruta48).toBe(60000) + expect(data.rcInputAccountTotals['2645']).toEqual({ debit: 0, credit: 0 }) + + // Same call the momsdeklaration view makes. + const checks = runVatDeclarationChecks(data.rutor, rcInputTotalsFromDeclaration(data)) + const mismatch = checks.find((c) => c.code === 'RC_INPUT_VAT_MISMATCH') + expect(mismatch?.status).toBe('WARNING') + // \s, not a literal space: sv-SE groups thousands with a no-break space. + expect(mismatch?.message).toMatch(/50\s000 kr saknas/) + + // Ruta 48 alone: silent, which is the state the wiring replaced. + expect(runVatDeclarationChecks(data.rutor)).toEqual([]) + }) + }) + it('returns the VAT_REPORT_GENERATION_FAILED envelope when the RPC errors', async () => { mockSupabase.rpc.mockResolvedValue({ data: null, 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 76090156..261969e8 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 @@ -13,14 +13,21 @@ vi.mock('@/lib/company/context', () => ({ })) import { GET } from '../route' +import { resolvePeriodDates } from '@/lib/reports/vat-declaration' interface SupabaseShape { from: ReturnType rpc: ReturnType } +/** + * `fiscalPeriodResult` is what a `fiscal_periods` lookup resolves to: both the + * explicit-id lookup and the "räkenskapsår ending in `year`" lookup that + * `resolvePeriodDates` performs for helårsmoms. + */ function buildSupabase( - linesResult: { data: unknown; error: unknown } + linesResult: { data: unknown; error: unknown }, + fiscalPeriodResult: { data: unknown; error: unknown } = { data: null, error: null } ): SupabaseShape { return { rpc: vi.fn().mockResolvedValue(linesResult), @@ -33,13 +40,19 @@ function buildSupabase( order: vi.fn().mockReturnThis(), limit: vi.fn().mockReturnThis(), or: vi.fn().mockReturnThis(), - maybeSingle: vi.fn().mockResolvedValue({ data: null, error: null }), + maybeSingle: vi.fn().mockResolvedValue(fiscalPeriodResult), range: vi.fn().mockResolvedValue(linesResult), then: (resolve: (v: unknown) => void) => resolve(linesResult), })), } } +/** The { p_start, p_end } the route handed to get_vat_ruta_source_lines. */ +function rpcPeriod(supabase: SupabaseShape): { start: string; end: string } { + const args = supabase.rpc.mock.calls[0][1] as { p_start: string; p_end: string } + return { start: args.p_start, end: args.p_end } +} + function authOk(supabase: SupabaseShape) { requireAuthMock.mockResolvedValue({ user: { id: 'user-1' }, supabase, error: null }) } @@ -193,3 +206,124 @@ describe('GET /api/reports/vat-declaration/ruta/[ruta]/sources', () => { ]) }) }) + +describe('GET /api/reports/vat-declaration/ruta/[ruta]/sources: period resolution', () => { + // A first räkenskapsår may run up to 18 months (BFL 3 kap 3 §), and + // helårsmoms is filed per räkenskapsår, not per calendar year + // (SFL 26 kap 10-11 §§). + const EXTENDED_FIRST_YEAR = { period_start: '2025-07-03', period_end: '2026-12-31' } + const CALENDAR_YEAR = { period_start: '2026-01-01', period_end: '2026-12-31' } + + const noLines = () => ({ data: [], error: null }) + + function get(searchParams: Record, ruta = '05') { + const req = createMockRequest( + `/api/reports/vat-declaration/ruta/${ruta}/sources`, + { searchParams } + ) + return GET(req, createMockRouteParams({ ruta })) + } + + it('yearly: drills into the räkenskapsår, agreeing with the declaration resolver', async () => { + const supabase = buildSupabase(noLines(), { data: EXTENDED_FIRST_YEAR, error: null }) + authOk(supabase) + + const res = await get({ periodType: 'yearly', year: '2026', period: '1' }) + expect(res.status).toBe(200) + + // The old calendar-span behaviour would have started 2026-01-01 and hidden + // every verifikat from 2025-07-03 to 2025-12-31 that the declaration counts. + expect(rpcPeriod(supabase)).toEqual({ start: '2025-07-03', end: '2026-12-31' }) + + // And it is the exact span `calculateVatDeclaration` computes the figure + // from: both go through resolvePeriodDates with the same arguments. + const declarationPeriod = await resolvePeriodDates( + buildSupabase(noLines(), { + data: EXTENDED_FIRST_YEAR, + error: null, + }) as unknown as Parameters[0], + 'company-1', + 'yearly', + 2026, + 1 + ) + expect(rpcPeriod(supabase)).toEqual(declarationPeriod) + }) + + it('yearly: an explicit fiscal_period_id selects that räkenskapsår', async () => { + const supabase = buildSupabase(noLines(), { data: EXTENDED_FIRST_YEAR, error: null }) + authOk(supabase) + + const res = await get({ + periodType: 'yearly', + year: '2026', + period: '1', + fiscal_period_id: '11111111-1111-4111-8111-111111111111', + }) + expect(res.status).toBe(200) + expect(rpcPeriod(supabase)).toEqual({ start: '2025-07-03', end: '2026-12-31' }) + }) + + it('yearly: calendar-year company is unchanged (Jan-Dec)', async () => { + const supabase = buildSupabase(noLines(), { data: CALENDAR_YEAR, error: null }) + authOk(supabase) + + const res = await get({ periodType: 'yearly', year: '2026', period: '1' }) + expect(res.status).toBe(200) + expect(rpcPeriod(supabase)).toEqual({ start: '2026-01-01', end: '2026-12-31' }) + }) + + it('yearly: falls back to the calendar span when no fiscal period is found', async () => { + const supabase = buildSupabase(noLines(), { data: null, error: null }) + authOk(supabase) + + const res = await get({ periodType: 'yearly', year: '2026', period: '1' }) + expect(res.status).toBe(200) + expect(rpcPeriod(supabase)).toEqual({ start: '2026-01-01', end: '2026-12-31' }) + }) + + it('monthly: stays a calendar month even for a broken fiscal year', async () => { + // kalendermånad per SFL 26 kap: fiscal_period_id must not widen the span. + const supabase = buildSupabase(noLines(), { data: EXTENDED_FIRST_YEAR, error: null }) + authOk(supabase) + + const res = await get({ + periodType: 'monthly', + year: '2026', + period: '5', + fiscal_period_id: '11111111-1111-4111-8111-111111111111', + }) + expect(res.status).toBe(200) + expect(rpcPeriod(supabase)).toEqual({ start: '2026-05-01', end: '2026-05-31' }) + }) + + it('quarterly: stays a calendar quarter', async () => { + const supabase = buildSupabase(noLines(), { data: EXTENDED_FIRST_YEAR, error: null }) + authOk(supabase) + + const res = await get({ periodType: 'quarterly', year: '2026', period: '2' }) + expect(res.status).toBe(200) + expect(rpcPeriod(supabase)).toEqual({ start: '2026-04-01', end: '2026-06-30' }) + }) + + it('returns 400 for an unknown periodType', async () => { + authOk(buildSupabase(noLines())) + const res = await get({ periodType: 'weekly', year: '2026', period: '5' }) + expect(res.status).toBe(400) + }) + + it('fiscal_period_id alone still selects the period by its own bounds', async () => { + const supabase = buildSupabase(noLines(), { data: EXTENDED_FIRST_YEAR, error: null }) + authOk(supabase) + + const res = await get({ fiscal_period_id: '11111111-1111-4111-8111-111111111111' }) + expect(res.status).toBe(200) + expect(rpcPeriod(supabase)).toEqual({ start: '2025-07-03', end: '2026-12-31' }) + }) + + it('returns 404 when fiscal_period_id alone matches no period', async () => { + authOk(buildSupabase(noLines(), { data: null, error: null })) + const res = await get({ fiscal_period_id: '11111111-1111-4111-8111-111111111111' }) + expect(res.status).toBe(404) + }) +}) 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 6714711b..d643fe7e 100644 --- a/app/api/reports/vat-declaration/ruta/[ruta]/sources/route.ts +++ b/app/api/reports/vat-declaration/ruta/[ruta]/sources/route.ts @@ -2,7 +2,7 @@ import { withRouteContext } from '@/lib/api/with-route-context' import { NextResponse } from 'next/server' import { ACCOUNT_RUTA, - calculatePeriodDates, + resolvePeriodDates, } from '@/lib/reports/vat-declaration' import type { ReportSourceLine } from '@/lib/reports/source-lines' import type { VatDeclarationRutor, VatPeriodType } from '@/types' @@ -15,10 +15,13 @@ import type { VatDeclarationRutor, VatPeriodType } from '@/types' * `ACCOUNT_RUTA` in `lib/reports/vat-declaration.ts`. * * Period can be specified either via: - * ?periodType=monthly|quarterly|yearly&year=2026&period=5 + * ?periodType=monthly|quarterly|yearly&year=2026&period=5[&fiscal_period_id=] * ?fiscal_period_id= * - * The periodType form mirrors the way the main VAT report is fetched. + * The periodType form mirrors the way the main VAT report is fetched, down to + * the period resolution itself: it goes through `resolvePeriodDates`, the same + * helper the declaration uses, so the drill-down can never answer a query + * string with a different span than the figure it drills into. */ const PAGE_LIMIT = 500 const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i @@ -48,11 +51,43 @@ export const GET = withRouteContext<{ params: Promise<{ ruta: string }> }>( ) } - // Resolve the period: either by fiscal_period_id or periodType/year/period. - let start: string | null = null - let end: string | null = null + // Resolve the period. The periodType form is primary and resolves through + // `resolvePeriodDates`, the same helper the declaration itself uses, with + // fiscal_period_id threaded through exactly as the sibling vat-declaration + // routes do. Helårsmoms is filed per räkenskapsår (SFL 26 kap 10-11 §§) and + // a räkenskapsår is not always a calendar year (a first/changed year runs up + // to 18 months, BFL 3 kap 3 §), so a plain Jan-Dec span would list a + // different set of verifikat than the declaration was computed from. + // Monthly/quarterly are calendar periods and resolve identically to before. + // The fiscal_period_id-only form (no periodType) still selects the span from + // the fiscal period's own bounds. + const periodType = searchParams.get('periodType') as VatPeriodType | null + const yearStr = searchParams.get('year') + const periodStr = searchParams.get('period') const fiscalPeriodId = searchParams.get('fiscal_period_id') - if (fiscalPeriodId) { + + let start: string + let end: string + if (periodType && yearStr && periodStr) { + if (!['monthly', 'quarterly', 'yearly'].includes(periodType)) { + return NextResponse.json({ error: 'Invalid periodType' }, { status: 400 }) + } + const year = parseInt(yearStr, 10) + const periodNum = parseInt(periodStr, 10) + if (isNaN(year) || isNaN(periodNum)) { + return NextResponse.json({ error: 'Invalid period' }, { status: 400 }) + } + const dates = await resolvePeriodDates( + supabase, + companyId, + periodType, + year, + periodNum, + fiscalPeriodId ?? undefined + ) + start = dates.start + end = dates.end + } else if (fiscalPeriodId) { const { data: period } = await supabase .from('fiscal_periods') .select('period_start, period_end') @@ -65,23 +100,10 @@ export const GET = withRouteContext<{ params: Promise<{ ruta: string }> }>( start = period.period_start end = period.period_end } else { - const periodType = searchParams.get('periodType') as VatPeriodType | null - const yearStr = searchParams.get('year') - const periodStr = searchParams.get('period') - if (!periodType || !yearStr || !periodStr) { - return NextResponse.json( - { error: 'periodType/year/period or fiscal_period_id is required' }, - { status: 400 } - ) - } - const year = parseInt(yearStr, 10) - const periodNum = parseInt(periodStr, 10) - if (isNaN(year) || isNaN(periodNum)) { - return NextResponse.json({ error: 'Invalid period' }, { status: 400 }) - } - const dates = calculatePeriodDates(periodType, year, periodNum) - start = dates.start - end = dates.end + return NextResponse.json( + { error: 'periodType/year/period or fiscal_period_id is required' }, + { status: 400 } + ) } // New cursors include entry and line IDs so multiple rows with the same diff --git a/app/api/salary/employees/[id]/__tests__/personnummer-write-guard.test.ts b/app/api/salary/employees/[id]/__tests__/personnummer-write-guard.test.ts new file mode 100644 index 00000000..755ecbae --- /dev/null +++ b/app/api/salary/employees/[id]/__tests__/personnummer-write-guard.test.ts @@ -0,0 +1,179 @@ +/** + * PATCH /api/salary/employees/[id]: the personnummer wipe guard. + * + * The route used to build its update payload with `{ ...body }` and then apply + * the encrypt branch only under `if (body.personnummer)`. For an empty string + * that branch is skipped, but the spread has already put '' into the payload: + * the UPDATE would overwrite the AES-256-GCM ciphertext with an empty string and + * leave personnummer_last4 pointing at the old value. Nothing in the route + * stopped it. The only thing standing in the way was the 12-digit regex in + * UpdateEmployeeSchema, i.e. a guard in a DIFFERENT file, and house style in + * lib/api/schemas.ts adds `.or(z.literal(''))` to optional string fields + * (see clearing_number, account_number, bankgiro, tax_contact_email, ...). + * One such edit to the employee schema would have turned a routine "clear this + * field" idiom into silent destruction of encrypted PII. + * + * These tests deliberately mock @/lib/api/schemas so the personnummer field is + * waved through validation and the request reaches the route body. They therefore + * assert the defence that lives in the write path, not the one that lives in the + * schema. `captured.updates` staying null means no UPDATE was issued at all, so + * the ciphertext survives. + * + * Fixtures are obviously synthetic: no real personnummer appears here. + */ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { createMockRequest } from '@/tests/helpers' + +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() })) + +// A schema that validates everything EXCEPT the personnummer shape. The point +// is to prove the route defends on its own, so the field is waved through here +// and the only remaining guard is the one in the write path. The concrete +// production edit that produces the empty-string case is a single +// `.or(z.literal(''))` on the real schema's personnummer field, matching how +// clearing_number, account_number, bankgiro and tax_contact_email are already +// written in lib/api/schemas.ts. +vi.mock('@/lib/api/schemas', async () => { + const { z } = await import('zod') + return { + UpdateEmployeeSchema: z.object({ + first_name: z.string().min(1).max(200).optional(), + personnummer: z.string().nullable().optional(), + }), + } +}) + +import { PATCH } from '../route' +import { encryptPersonnummer } from '@/lib/salary/personnummer' + +const params = { params: Promise.resolve({ id: 'emp-1' }) } as never + +const STORED_CIPHERTEXT = encryptPersonnummer('190203040000') + +const EXISTING_ROW = { + id: 'emp-1', + company_id: 'company-1', + first_name: 'Test', + last_name: 'Testsson', + personnummer: STORED_CIPHERTEXT, + personnummer_last4: '0000', + employment_type: 'employee', + salary_type: 'monthly', + monthly_salary: 30000, + f_skatt_status: 'a_skatt', + is_sidoinkomst: false, + tax_table_number: 34, +} + +function employeeSupabase() { + const captured: { updates: Record | null } = { updates: null } + + function chainFor(state: { isUpdate: boolean }): unknown { + const handler: ProxyHandler = { + get(_target, prop) { + if (prop === 'then') { + const data = state.isUpdate + ? { ...EXISTING_ROW, ...(captured.updates ?? {}) } + : EXISTING_ROW + return (resolve: (v: unknown) => void) => resolve({ data, error: null }) + } + return (...args: unknown[]) => { + if (prop === 'update') { + state.isUpdate = true + captured.updates = args[0] as Record + } + return chainFor(state) + } + }, + } + return new Proxy({}, handler) + } + + return { supabase: { from: vi.fn(() => chainFor({ isUpdate: false })) }, captured } +} + +describe('PATCH /api/salary/employees/[id]: personnummer cannot be wiped', () => { + let captured: { updates: Record | null } + + beforeEach(() => { + vi.clearAllMocks() + const mock = employeeSupabase() + captured = mock.captured + requireAuthMock.mockResolvedValue({ user: { id: 'user-1' }, supabase: mock.supabase }) + requireWriteMock.mockResolvedValue({ ok: true }) + }) + + it('rejects an empty-string personnummer instead of overwriting the ciphertext', async () => { + const response = await PATCH( + createMockRequest('/api/salary/employees/emp-1', { + method: 'PATCH', + body: { personnummer: '' }, + }), + params, + ) + + expect(response.status).toBe(400) + // No UPDATE at all: the ciphertext and personnummer_last4 both survive. + expect(captured.updates).toBeNull() + }) + + it('rejects a null personnummer (the column is NOT NULL and AGI/KU need it)', async () => { + const response = await PATCH( + createMockRequest('/api/salary/employees/emp-1', { + method: 'PATCH', + body: { personnummer: null }, + }), + params, + ) + + expect(response.status).toBe(400) + expect(captured.updates).toBeNull() + }) + + it('refuses the whole PATCH rather than applying the other fields silently', async () => { + // Failing loudly matters more than partial success: a caller that thought it + // was clearing the personnummer must learn that it did not happen. + const response = await PATCH( + createMockRequest('/api/salary/employees/emp-1', { + method: 'PATCH', + body: { first_name: 'Ny', personnummer: '' }, + }), + params, + ) + + expect(response.status).toBe(400) + expect(captured.updates).toBeNull() + }) + + it('rejects a masked personnummer that reached the route past a loose schema', async () => { + // validatePersonnummer() strips non-digits, so a decorated value carrying 12 + // real digits would sail through it. The explicit mask check is what makes + // the refusal deterministic. + const response = await PATCH( + createMockRequest('/api/salary/employees/emp-1', { + method: 'PATCH', + body: { personnummer: '190203040000-XXXX' }, + }), + params, + ) + + expect(response.status).toBe(400) + expect(captured.updates).toBeNull() + }) +}) diff --git a/app/api/salary/employees/[id]/__tests__/route.test.ts b/app/api/salary/employees/[id]/__tests__/route.test.ts index 7873f068..85c3312e 100644 --- a/app/api/salary/employees/[id]/__tests__/route.test.ts +++ b/app/api/salary/employees/[id]/__tests__/route.test.ts @@ -5,6 +5,17 @@ * 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). + * + * Plus the personnummer contract on this route, which handles encrypted PII on + * both the read and the write side: + * - reads expose the mask under `personnummer_masked`, never under the + * writable `personnummer` key (no mask round-trip into the encrypt path), + * - a PATCH that omits personnummer must leave the ciphertext and + * personnummer_last4 completely untouched, + * - a masked value offered as a write is refused, + * - a genuine new value is stored encrypted, never plaintext. + * The empty-string / null wipe guard is pinned in personnummer-write-guard.test.ts, + * which loosens the Zod schema to prove the defence lives in the route. */ import { describe, it, expect, vi, beforeEach } from 'vitest' import { NextResponse } from 'next/server' @@ -30,10 +41,67 @@ vi.mock('@/lib/auth/require-write', () => ({ vi.mock('@/lib/init', () => ({ ensureInitialized: vi.fn() })) -import { DELETE } from '../route' +import { DELETE, GET, PATCH } from '../route' +import { decryptPersonnummer, encryptPersonnummer } from '@/lib/salary/personnummer' const params = { params: Promise.resolve({ id: 'emp-1' }) } as never +// Obviously synthetic fixtures. STORED_PNR is only ever masked, so it needs no +// check digit; NEW_PNR goes through validatePersonnummer, so its Luhn check +// digit is genuinely correct (sum over 0001010008 = 10). +const STORED_PNR = '190203040000' +const STORED_CIPHERTEXT = encryptPersonnummer(STORED_PNR) +const NEW_PNR = '190001010008' + +const EXISTING_ROW = { + id: 'emp-1', + company_id: 'company-1', + first_name: 'Test', + last_name: 'Testsson', + personnummer: STORED_CIPHERTEXT, + personnummer_last4: '0000', + employment_type: 'employee', + salary_type: 'monthly', + monthly_salary: 30000, + f_skatt_status: 'a_skatt', + is_sidoinkomst: false, + tax_table_number: 34, +} as const + +/** + * Supabase double that returns EXISTING_ROW on reads, records the payload handed + * to `.update()`, and resolves the update chain with the merged row. `captured.updates` + * staying null is the assertion that no write was attempted at all. + */ +function employeeSupabase(existing: Record = { ...EXISTING_ROW }) { + const captured: { updates: Record | null } = { updates: null } + + function chainFor(state: { isUpdate: boolean }): unknown { + const handler: ProxyHandler = { + get(_target, prop) { + if (prop === 'then') { + const data = state.isUpdate ? { ...existing, ...(captured.updates ?? {}) } : existing + return (resolve: (v: unknown) => void) => resolve({ data, error: null }) + } + return (...args: unknown[]) => { + if (prop === 'update') { + state.isUpdate = true + captured.updates = args[0] as Record + } + return chainFor(state) + } + }, + } + return new Proxy({}, handler) + } + + return { supabase: { from: vi.fn(() => chainFor({ isUpdate: false })) }, captured } +} + +function patchRequest(body: Record) { + return createMockRequest('/api/salary/employees/emp-1', { method: 'PATCH', body }) +} + describe('DELETE /api/salary/employees/[id]', () => { beforeEach(() => { vi.clearAllMocks() @@ -73,3 +141,72 @@ describe('DELETE /api/salary/employees/[id]', () => { expect(body.data).toEqual({ id: 'emp-1', is_active: false }) }) }) + +describe('personnummer contract on /api/salary/employees/[id]', () => { + let captured: { updates: Record | null } + + beforeEach(() => { + vi.clearAllMocks() + reset() + const mock = employeeSupabase() + captured = mock.captured + requireAuthMock.mockResolvedValue({ user: { id: 'user-1' }, supabase: mock.supabase }) + requireWriteMock.mockResolvedValue({ ok: true }) + }) + + it('GET returns the mask under personnummer_masked and drops the ciphertext', async () => { + const response = await GET(createMockRequest('/api/salary/employees/emp-1'), params) + const { status, body } = await parseJsonResponse<{ data: Record }>(response) + + expect(status).toBe(200) + expect(body.data.personnummer_masked).toBe('19020304-XXXX') + // The writable key name must not carry the mask: a client that reads this + // object and writes it back would otherwise send the mask to the encrypt path. + expect('personnummer' in body.data).toBe(false) + // personnummer_last4 must not ride along either: the mask is + // YYYYMMDD-XXXX, so mask + last4 reassembles the full personnummer. + expect('personnummer_last4' in body.data).toBe(false) + // Neither the plaintext nor the stored ciphertext may leave the server. + expect(JSON.stringify(body)).not.toContain(STORED_PNR) + expect(JSON.stringify(body)).not.toContain(STORED_CIPHERTEXT) + }) + + it('PATCH without personnummer leaves the ciphertext and last4 untouched', async () => { + const response = await PATCH(patchRequest({ first_name: 'Ny' }), params) + const { status, body } = await parseJsonResponse<{ data: Record }>(response) + + expect(status).toBe(200) + expect(captured.updates).toEqual({ first_name: 'Ny' }) + // Absent key means "identity unchanged": neither column may appear. + expect(captured.updates).not.toHaveProperty('personnummer') + expect(captured.updates).not.toHaveProperty('personnummer_last4') + expect(body.data.personnummer_masked).toBe('19020304-XXXX') + expect('personnummer' in body.data).toBe(false) + expect('personnummer_last4' in body.data).toBe(false) + }) + + it('PATCH refuses a masked personnummer instead of encrypting the mask', async () => { + const response = await PATCH(patchRequest({ personnummer: '19020304-XXXX' }), params) + + expect(response.status).toBe(400) + expect(captured.updates).toBeNull() + }) + + it('PATCH stores a genuine new personnummer encrypted, never plaintext', async () => { + const response = await PATCH(patchRequest({ personnummer: NEW_PNR }), params) + const { status, body } = await parseJsonResponse<{ data: Record }>(response) + + expect(status).toBe(200) + const stored = captured.updates?.personnummer as string + expect(stored).not.toBe(NEW_PNR) + expect(decryptPersonnummer(stored)).toBe(NEW_PNR) + expect(captured.updates?.personnummer_last4).toBe('0008') + // The response echoes only the mask, under the read-only key. The updated + // last4 is written to the row but must not appear in the response. + expect(body.data.personnummer_masked).toBe('19000101-XXXX') + expect('personnummer' in body.data).toBe(false) + expect('personnummer_last4' in body.data).toBe(false) + expect(JSON.stringify(body)).not.toContain(NEW_PNR) + expect(JSON.stringify(body)).not.toContain('"0008"') + }) +}) 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 index 9062a548..3b9e5d40 100644 --- a/app/api/salary/employees/[id]/benefits/[benefitId]/__tests__/route.test.ts +++ b/app/api/salary/employees/[id]/benefits/[benefitId]/__tests__/route.test.ts @@ -2,7 +2,10 @@ * 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. + * 403 (viewer), a DELETE happy path, and the validity-period contract on PATCH + * (CHECK (valid_to IS NULL OR valid_to >= valid_from), migration + * 20260512200100), including the error-mapping split that used to report a + * check_violation as 404 "Förmån hittades inte". */ import { describe, it, expect, vi, beforeEach } from 'vitest' import { NextResponse } from 'next/server' @@ -27,7 +30,7 @@ vi.mock('@/lib/auth/require-write', () => ({ vi.mock('@/lib/init', () => ({ ensureInitialized: vi.fn() })) -import { DELETE } from '../route' +import { DELETE, PATCH } from '../route' const params = { params: Promise.resolve({ id: 'emp-1', benefitId: 'ben-1' }) } as never @@ -35,6 +38,18 @@ function del() { return createMockRequest('/api/salary/employees/emp-1/benefits/ben-1', { method: 'DELETE' }) } +function patch(body: unknown) { + return createMockRequest('/api/salary/employees/emp-1/benefits/ben-1', { method: 'PATCH', body }) +} + +/** Stored row as the PATCH route fetches it before writing. */ +const storedBenefit = { + benefit_type: 'other', + metadata: {}, + valid_from: '2026-06-01', + valid_to: '2026-12-31', +} + describe('DELETE /api/salary/employees/[id]/benefits/[benefitId]', () => { beforeEach(() => { vi.clearAllMocks() @@ -74,3 +89,185 @@ describe('DELETE /api/salary/employees/[id]/benefits/[benefitId]', () => { expect(body.data).toEqual({ id: 'ben-1', deleted: true }) }) }) + +describe('PATCH /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 PATCH(patch({ monthly_value: 100 }), 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 PATCH(patch({ monthly_value: 100 }), params) + expect(response.status).toBe(403) + }) + + it('updates the benefit (happy path)', async () => { + enqueue({ data: storedBenefit }) // existence check + enqueue({ data: { id: 'ben-1', monthly_value: 100 } }) // update + + const response = await PATCH(patch({ monthly_value: 100 }), params) + const { status, body } = await parseJsonResponse<{ data: { id: string } }>(response) + + expect(status).toBe(200) + expect(body.data.id).toBe('ben-1') + }) + + it('returns 404 when the benefit does not exist in the company', async () => { + enqueue({ data: null, error: { code: 'PGRST116', message: 'no rows' } }) + + const response = await PATCH(patch({ monthly_value: 100 }), params) + const { status, body } = await parseJsonResponse<{ error: string }>(response) + + expect(status).toBe(404) + expect(body.error).toBe('Förmån hittades inte') + }) + + describe('valid_from / valid_to ordering', () => { + // Both dates in the body: the schema can compare them itself, so this 400 + // lands before any DB call. + it('rejects both-dates-in-body with valid_to before valid_from', async () => { + const response = await PATCH( + patch({ valid_from: '2026-06-01', valid_to: '2026-05-31' }), + params, + ) + const { status, body } = await parseJsonResponse<{ + error: string + errors: { field: string }[] + }>(response) + + expect(status).toBe(400) + expect(body.errors).toEqual( + expect.arrayContaining([expect.objectContaining({ field: 'valid_to' })]), + ) + expect(body.error).toContain('Gäller till') + expect(supabase.from).not.toHaveBeenCalled() + }) + + // Single-field PATCH: the schema cannot see the stored half, so the route + // compares the merged pair against the fetched row and answers 400 itself. + // This is the case that used to reach Postgres and return 404. + it('rejects a valid_to-only patch that predates the stored valid_from', async () => { + enqueue({ data: storedBenefit }) // stored valid_from 2026-06-01 + + const response = await PATCH(patch({ valid_to: '2026-05-31' }), params) + const { status, body } = await parseJsonResponse<{ error: string }>(response) + + expect(status).toBe(400) + expect(body.error).toContain('Gäller till') + expect(body.error).not.toContain('hittades inte') + // Fetched once, never written. + expect(supabase.from).toHaveBeenCalledTimes(1) + }) + + it('rejects a valid_from-only patch that postdates the stored valid_to', async () => { + enqueue({ data: storedBenefit }) // stored valid_to 2026-12-31 + + const response = await PATCH(patch({ valid_from: '2027-01-01' }), params) + expect(response.status).toBe(400) + expect(supabase.from).toHaveBeenCalledTimes(1) + }) + + it('accepts a valid_to-only patch that is still on or after the stored valid_from', async () => { + enqueue({ data: storedBenefit }) + enqueue({ data: { id: 'ben-1', valid_to: '2026-06-01' } }) + + // Equal to the stored valid_from: legal, the bound is inclusive. + const response = await PATCH(patch({ valid_to: '2026-06-01' }), params) + expect(response.status).toBe(200) + }) + + it('accepts both dates equal (the bound is inclusive)', async () => { + enqueue({ data: storedBenefit }) + enqueue({ data: { id: 'ben-1' } }) + + const response = await PATCH( + patch({ valid_from: '2026-06-01', valid_to: '2026-06-01' }), + params, + ) + expect(response.status).toBe(200) + }) + + it('accepts valid_to: null (clearing the end date keeps the benefit open-ended)', async () => { + enqueue({ data: storedBenefit }) + enqueue({ data: { id: 'ben-1', valid_to: null } }) + + const response = await PATCH(patch({ valid_to: null }), params) + expect(response.status).toBe(200) + }) + + it('accepts a valid_from-only patch when the stored valid_to is null', async () => { + enqueue({ data: { ...storedBenefit, valid_to: null } }) + enqueue({ data: { id: 'ben-1' } }) + + const response = await PATCH(patch({ valid_from: '2030-01-01' }), params) + expect(response.status).toBe(200) + }) + }) + + describe('write-error mapping (not "hittades inte")', () => { + it('maps a check_violation on the update to 400 with the period message', async () => { + enqueue({ data: storedBenefit }) + enqueue({ + data: null, + error: { code: '23514', message: 'violates check constraint "employee_benefits_check"' }, + }) + + const response = await PATCH(patch({ monthly_value: 100 }), params) + const { status, body } = await parseJsonResponse<{ error: string }>(response) + + expect(status).toBe(400) + expect(body.error).toContain('Gäller till') + expect(body.error).not.toContain('hittades inte') + }) + + it('maps a real DB failure on the update to 500, not 404', async () => { + enqueue({ data: storedBenefit }) + enqueue({ data: null, error: { code: '08006', message: 'connection failure' } }) + + const response = await PATCH(patch({ monthly_value: 100 }), params) + const { status, body } = await parseJsonResponse<{ error: string }>(response) + + expect(status).toBe(500) + expect(body.error).not.toContain('hittades inte') + }) + + it('keeps 404 when the row disappears between the fetch and the update', async () => { + enqueue({ data: storedBenefit }) + enqueue({ data: null, error: { code: 'PGRST116', message: 'no rows' } }) + + const response = await PATCH(patch({ monthly_value: 100 }), params) + const { status, body } = await parseJsonResponse<{ error: string }>(response) + + expect(status).toBe(404) + expect(body.error).toBe('Förmån hittades inte') + }) + + it('maps a failed existence lookup (non-PGRST116) to 500, not 404', async () => { + enqueue({ data: null, error: { code: '08006', message: 'connection failure' } }) + + const response = await PATCH(patch({ monthly_value: 100 }), params) + const { status, body } = await parseJsonResponse<{ error: string }>(response) + + expect(status).toBe(500) + expect(body.error).not.toContain('hittades inte') + }) + }) +}) diff --git a/app/api/salary/employees/[id]/benefits/[benefitId]/route.ts b/app/api/salary/employees/[id]/benefits/[benefitId]/route.ts index 2e01c977..cafbfd6b 100644 --- a/app/api/salary/employees/[id]/benefits/[benefitId]/route.ts +++ b/app/api/salary/employees/[id]/benefits/[benefitId]/route.ts @@ -2,7 +2,7 @@ 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 { UpdateEmployeeBenefitSchema } from '@/lib/api/schemas' +import { UpdateEmployeeBenefitSchema, BENEFIT_PERIOD_ORDER_MESSAGE } from '@/lib/api/schemas' import { calculateBikeBenefit } from '@/lib/salary/benefits' import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message' @@ -19,16 +19,36 @@ export const PATCH = withRouteContext<{ params: Promise<{ id: string; benefitId: const { data: existing, error: fetchError } = await supabase .from('employee_benefits') - .select('benefit_type, metadata') + .select('benefit_type, metadata, valid_from, valid_to') .eq('id', benefitId) .eq('employee_id', id) .eq('company_id', companyId) .single() - if (fetchError || !existing) { + // Only zero rows (PGRST116) means the benefit really isn't there. A + // transport/DB failure is not a missing record and must not be reported as + // one. + if (fetchError && fetchError.code !== 'PGRST116') { + return NextResponse.json({ error: getUserErrorMessage(fetchError) }, { status: 500 }) + } + if (!existing) { return NextResponse.json({ error: 'Förmån hittades inte' }, { status: 404 }) } + // Validity period against the MERGED state. UpdateEmployeeBenefitSchema can + // only compare the two dates when the body carries both; when only one is + // patched, the other half lives on the row we just fetched. Without this the + // CHECK (valid_to IS NULL OR valid_to >= valid_from) fired in Postgres and + // the error branch below dressed it up as "Förmån hittades inte". + // Inclusive bound, and a null/cleared valid_to stays legal. + const mergedValidFrom = (body.valid_from ?? existing.valid_from ?? null) as string | null + const mergedValidTo = ( + body.valid_to !== undefined ? body.valid_to : existing.valid_to ?? null + ) as string | null + if (mergedValidFrom !== null && mergedValidTo !== null && mergedValidTo < mergedValidFrom) { + return NextResponse.json({ error: BENEFIT_PERIOD_ORDER_MESSAGE }, { status: 400 }) + } + const updates: Record = { ...body } if (body.annual_market_value !== undefined) { @@ -59,7 +79,29 @@ export const PATCH = withRouteContext<{ params: Promise<{ id: string; benefitId: .select() .single() - if (error || !data) { + if (error) { + // The row's existence was already established above, so `error` here is a + // write failure, not a lookup miss. Collapsing every error into 404 + // "Förmån hittades inte" sent users hunting for a record that exists: a + // check_violation on valid_to >= valid_from is the one they actually hit. + // PGRST116 (zero rows) is the only shape that still means not-found: the + // row was deleted or moved out of the company between fetch and update. + if (error.code === 'PGRST116') { + return NextResponse.json({ error: 'Förmån hittades inte' }, { status: 404 }) + } + // employee_benefits has exactly three CHECKs (migration 20260512200100): + // benefit_type IN (...) and monthly_value >= 0 are unreachable from this + // body (benefit_type is not patchable; the schema and the bike calc both + // keep monthly_value non-negative), leaving the validity-period range as + // the only one an UPDATE can trip: a concurrent write that moved the other + // date after the merged check above. + if (error.code === '23514') { + return NextResponse.json({ error: BENEFIT_PERIOD_ORDER_MESSAGE }, { status: 400 }) + } + return NextResponse.json({ error: getUserErrorMessage(error) }, { status: 500 }) + } + + if (!data) { return NextResponse.json({ error: 'Förmån hittades inte' }, { status: 404 }) } diff --git a/app/api/salary/employees/[id]/benefits/__tests__/route.test.ts b/app/api/salary/employees/[id]/benefits/__tests__/route.test.ts index 76de831c..352258e5 100644 --- a/app/api/salary/employees/[id]/benefits/__tests__/route.test.ts +++ b/app/api/salary/employees/[id]/benefits/__tests__/route.test.ts @@ -89,4 +89,76 @@ describe('POST /api/salary/employees/[id]/benefits', () => { const response = await POST(post(validBenefit), params) expect(response.status).toBe(404) }) + + // Validity period: mirrors CHECK (valid_to IS NULL OR valid_to >= valid_from) + // on employee_benefits (migration 20260512200100). Before the schema mirrored + // it, an end date before the start date reached Postgres and came back as an + // opaque 500. + describe('valid_from / valid_to ordering', () => { + it('rejects valid_to before valid_from with an actionable 400', async () => { + // Queued as the DB would answer without the schema mirror: the employee + // resolves, then the insert trips the CHECK. The route must never get + // there; before the mirror this exact input returned an opaque 500. + enqueue({ data: { id: 'emp-1' } }) + enqueue({ + data: null, + error: { code: '23514', message: 'violates check constraint "employee_benefits_check"' }, + }) + + const response = await POST( + post({ ...validBenefit, valid_from: '2026-06-01', valid_to: '2026-05-31' }), + params, + ) + const { status, body } = await parseJsonResponse<{ + error: string + errors: { field: string; message: string }[] + }>(response) + + expect(status).toBe(400) + expect(body.errors).toEqual( + expect.arrayContaining([expect.objectContaining({ field: 'valid_to' })]), + ) + // Names both fields as labelled in the form and how to express open-ended. + expect(body.error).toContain('Gäller till') + expect(body.error).toContain('Gäller från') + expect(body.error).toContain('löpande') + // Nothing was attempted against the DB. + expect(supabase.from).not.toHaveBeenCalled() + }) + + it('accepts valid_to equal to valid_from (the bound is inclusive)', async () => { + enqueue({ data: { id: 'emp-1' } }) + enqueue({ data: { id: 'ben-1' } }) + + const response = await POST( + post({ ...validBenefit, valid_from: '2026-06-01', valid_to: '2026-06-01' }), + params, + ) + expect(response.status).toBe(201) + }) + + it('accepts an omitted valid_to (open-ended benefit stays legal)', async () => { + enqueue({ data: { id: 'emp-1' } }) + enqueue({ data: { id: 'ben-1' } }) + + const response = await POST(post(validBenefit), params) + expect(response.status).toBe(201) + }) + + it('maps a check_violation from the insert to 400, not 500', async () => { + enqueue({ data: { id: 'emp-1' } }) + enqueue({ data: null, error: { code: '23514', message: 'violates check constraint' } }) + + const response = await POST(post(validBenefit), params) + expect(response.status).toBe(400) + }) + + it('still reports a genuine DB failure as 500', async () => { + enqueue({ data: { id: 'emp-1' } }) + enqueue({ data: null, error: { code: '08006', message: 'connection failure' } }) + + const response = await POST(post(validBenefit), params) + expect(response.status).toBe(500) + }) + }) }) diff --git a/app/api/salary/employees/[id]/benefits/route.ts b/app/api/salary/employees/[id]/benefits/route.ts index af5994a6..c511ddfd 100644 --- a/app/api/salary/employees/[id]/benefits/route.ts +++ b/app/api/salary/employees/[id]/benefits/route.ts @@ -76,7 +76,15 @@ export const POST = withRouteContext<{ params: Promise<{ id: string }> }>( .select() .single() - if (error) return NextResponse.json({ error: getUserErrorMessage(error) }, { status: 500 }) + if (error) { + // A CHECK violation is bad input, not a server fault. The create schema + // now mirrors every CHECK on the table (benefit_type, monthly_value >= 0, + // valid_to >= valid_from), so this is only the backstop for non-schema + // callers; answering 500 told the user to retry an insert that can never + // succeed. + const status = error.code === '23514' ? 400 : 500 + return NextResponse.json({ error: getUserErrorMessage(error) }, { status }) + } return NextResponse.json({ data }, { status: 201 }) }, diff --git a/app/api/salary/employees/[id]/route.ts b/app/api/salary/employees/[id]/route.ts index af5041e8..2d68ae6c 100644 --- a/app/api/salary/employees/[id]/route.ts +++ b/app/api/salary/employees/[id]/route.ts @@ -4,13 +4,20 @@ import { withRouteContext } from '@/lib/api/with-route-context' import { validateBody } from '@/lib/api/validate' import { UpdateEmployeeSchema } from '@/lib/api/schemas' import { getCompanyEntityType } from '@/lib/company/context' -import { decryptPersonnummer, encryptPersonnummer, extractLast4, maskPersonnummer, validatePersonnummer } from '@/lib/salary/personnummer' +import { encryptPersonnummer, extractLast4, maskEmployeeForResponse, validatePersonnummer } from '@/lib/salary/personnummer' import { isEmploymentTypeAllowedForEntity, EF_OWNER_EMPLOYMENT_ERROR } from '@/lib/salary/employment-rules' import { validateEmployeeBankAccount } from '@/lib/salary/payment/bank-account' import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message' ensureInitialized() +// Response shaping lives in the shared maskEmployeeForResponse: it drops the +// ciphertext AND personnummer_last4 (mask + last4 reassembles the full +// personnummer) and returns the mask under the read-only `personnummer_masked` +// key, never the writable `personnummer` key. This route both reads and writes +// the same object shape, so a mask under the write key would round-trip +// 'ÅÅÅÅMMDD-XXXX' straight into the encrypt path. + export const GET = withRouteContext<{ params: Promise<{ id: string }> }>( 'salary.employees.get', async (request, { supabase, companyId }, { params }) => { @@ -27,12 +34,7 @@ export const GET = withRouteContext<{ params: Promise<{ id: string }> }>( return NextResponse.json({ error: 'Anställd hittades inte' }, { status: 404 }) } - return NextResponse.json({ - data: { - ...employee, - personnummer: maskPersonnummer(decryptPersonnummer(employee.personnummer)), - }, - }) + return NextResponse.json({ data: maskEmployeeForResponse(employee) }) }, ) @@ -100,17 +102,59 @@ export const PATCH = withRouteContext<{ params: Promise<{ id: string }> }>( } } - // Build update object - const updates: Record = { ...body } + // Build update object. `personnummer` is destructured OUT of the spread and + // handled explicitly below: spreading it verbatim would let any + // falsy-but-present value reach the row and overwrite the AES-256-GCM + // ciphertext with it (`if (body.personnummer)` skips the encrypt branch for + // an empty string, but `{ ...body }` has already put the empty string in + // `updates`). The only thing that stopped that today was the 12-digit regex + // in UpdateEmployeeSchema, i.e. a guard in another file: house style there + // adds `.or(z.literal(''))` to optional string fields, and one such edit + // would have turned this spread into a silent wipe of encrypted PII plus a + // stale `personnummer_last4`. The guard belongs in the write path. + const { personnummer: pnrInput, ...bodyWithoutPnr } = body + const updates: Record = { ...bodyWithoutPnr } - // Handle personnummer update if provided - if (body.personnummer) { - const pnrValidation = validatePersonnummer(body.personnummer) + // personnummer semantics on PATCH: + // • key absent / undefined → identity unchanged; ciphertext and + // personnummer_last4 are preserved untouched. + // • '' or null → 400. There is no "clear the personnummer" + // operation: the column is NOT NULL, and AGI/KU filing (Skatteverket + // FK215) cannot be produced without it. Omit the key to leave it alone. + // • masked display value → 400. Read surfaces return the mask under + // `personnummer_masked` so it cannot be written back by accident, but + // reject it here too so a hand-built round-trip fails loudly instead of + // encrypting 'ÅÅÅÅMMDD-XXXX' as somebody's identity. Note that + // validatePersonnummer() strips non-digits, so a decorated value + // carrying 12 real digits would otherwise pass. + // • 12 digits → validate (format, date range, Luhn) and + // re-encrypt, refreshing personnummer_last4 in the same update. + const rawPnr: unknown = pnrInput + if (rawPnr !== undefined) { + if (typeof rawPnr !== 'string' || rawPnr.trim() === '') { + return NextResponse.json( + { + error: + 'Personnummer kan inte tömmas. Utelämna fältet för att lämna det oförändrat.', + }, + { status: 400 }, + ) + } + if (/x/i.test(rawPnr)) { + return NextResponse.json( + { + error: + 'Maskerat personnummer kan inte sparas. Skicka hela personnummret (12 siffror) eller utelämna fältet.', + }, + { status: 400 }, + ) + } + const pnrValidation = validatePersonnummer(rawPnr) if (!pnrValidation.valid) { return NextResponse.json({ error: pnrValidation.error }, { status: 400 }) } - updates.personnummer = encryptPersonnummer(body.personnummer) - updates.personnummer_last4 = extractLast4(body.personnummer) + updates.personnummer = encryptPersonnummer(rawPnr) + updates.personnummer_last4 = extractLast4(rawPnr) } const { data: updated, error } = await supabase @@ -128,12 +172,7 @@ export const PATCH = withRouteContext<{ params: Promise<{ id: string }> }>( return NextResponse.json({ error: getUserErrorMessage(error) }, { status: 500 }) } - return NextResponse.json({ - data: { - ...updated, - personnummer: maskPersonnummer(decryptPersonnummer(updated.personnummer)), - }, - }) + return NextResponse.json({ data: maskEmployeeForResponse(updated) }) }, { requireWrite: true }, ) diff --git a/app/api/salary/employees/[id]/worked-hours/__tests__/recording-supabase.ts b/app/api/salary/employees/[id]/worked-hours/__tests__/recording-supabase.ts new file mode 100644 index 00000000..8c93acc2 --- /dev/null +++ b/app/api/salary/employees/[id]/worked-hours/__tests__/recording-supabase.ts @@ -0,0 +1,92 @@ +/** + * Supabase mock that records what each query actually did. + * + * `createQueuedMockSupabase()` in tests/helpers.ts throws call arguments away, + * so it cannot tell "the route wrote start_time" apart from "the route silently + * dropped it": both produce the same 201. The worked-hours bug was exactly that + * shape (validated, documented, never written), so these tests assert on the + * payload handed to `.insert()` and on the column list handed to `.select()`. + * + * Result queue semantics match createQueuedMockSupabase: one enqueued result is + * consumed per `.from()` / `.rpc()` call, in order. + * + * Not a `*.test.ts` file, so Vitest does not collect it as a suite. + */ +import { vi } from 'vitest' + +/** One `.from()` chain, flattened. */ +export interface RecordedOp { + table: string + /** First data verb seen on the chain: select / insert / update / upsert / delete. */ + verb: string + /** Column list passed to `.select('...')`, if any. */ + columns: string | null + /** Payload passed to `.insert()` / `.update()` / `.upsert()`, if any. */ + payload: Record | null +} + +const DATA_VERBS = new Set(['select', 'insert', 'update', 'upsert', 'delete']) + +export function createRecordingSupabase() { + const queue: { data: unknown; error: unknown }[] = [] + const ops: RecordedOp[] = [] + + const enqueue = (result: { data?: unknown; error?: unknown } = {}) => { + queue.push({ data: result.data ?? null, error: result.error ?? null }) + } + + const reset = () => { + queue.length = 0 + ops.length = 0 + } + + const buildChain = (op: RecordedOp, result: { data: unknown; error: unknown }): unknown => + new Proxy( + {}, + { + get(_target, prop) { + if (prop === 'then') { + return (resolve: (v: unknown) => void) => resolve(result) + } + const name = String(prop) + return (...args: unknown[]) => { + if (DATA_VERBS.has(name)) { + if (op.verb === '') op.verb = name + if (name === 'select' && typeof args[0] === 'string') { + op.columns = args[0] + } + if ( + (name === 'insert' || name === 'update' || name === 'upsert') && + args[0] !== undefined + ) { + op.payload = args[0] as Record + } + } + return buildChain(op, result) + } + }, + }, + ) + + const start = (table: string): unknown => { + const op: RecordedOp = { table, verb: '', columns: null, payload: null } + ops.push(op) + return buildChain(op, queue.shift() ?? { data: null, error: null }) + } + + const supabase = { + from: vi.fn((table: string) => start(table)), + rpc: vi.fn((fn: string) => start(`rpc:${fn}`)), + auth: { getUser: vi.fn() }, + } + + /** Payloads of every recorded insert, in call order. */ + const insertedRows = () => + ops.filter((op) => op.verb === 'insert' && op.payload).map((op) => op.payload!) + + /** Column lists of every recorded read (select-first chains), in call order. */ + const selectedColumns = () => + ops.filter((op) => op.verb === 'select').map((op) => op.columns) + + return { supabase, enqueue, reset, ops, insertedRows, selectedColumns } +} 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 index b3716c85..a4c2e43f 100644 --- a/app/api/salary/employees/[id]/worked-hours/__tests__/route.test.ts +++ b/app/api/salary/employees/[id]/worked-hours/__tests__/route.test.ts @@ -1,15 +1,26 @@ /** - * Auth-wiring tests for /api/salary/employees/[id]/worked-hours (POST upsert). + * Tests for /api/salary/employees/[id]/worked-hours (GET list, 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. + * Runs the routes through the real withRouteContext wrapper; mocks auth/company/ + * write and injects a recording Supabase mock via requireAuth. Covers 401, 403 + * (viewer), 400, 404 and the happy path, plus the shift-window round trip. + * + * The shift-window tests are the point of this file. `start_time` / `end_time` + * are validated and documented by UpsertWorkedDaySchema, but used to be dropped + * on the way to the database. A row without times makes the shift-premium engine + * assume a default 08:00-17:00 day, so OB-tillägg for night and weekend work + * could never trigger and a night shift was paid as office hours. The last two + * tests therefore feed the payload the route actually inserts into the real + * engine and assert the premium appears. */ import { describe, it, expect, vi, beforeEach } from 'vitest' import { NextResponse } from 'next/server' -import { createQueuedMockSupabase, createMockRequest, parseJsonResponse } from '@/tests/helpers' +import { createMockRequest, parseJsonResponse } from '@/tests/helpers' +import { createRecordingSupabase } from './recording-supabase' +import { computePremiumLines } from '@/lib/salary/shift-premium-engine' +import type { ShiftPremiumRule } from '@/types' -const { supabase, enqueue, reset } = createQueuedMockSupabase() +const { supabase, enqueue, reset, insertedRows, selectedColumns } = createRecordingSupabase() const requireAuthMock = vi.fn() vi.mock('@/lib/auth/require-auth', () => ({ @@ -28,7 +39,7 @@ vi.mock('@/lib/auth/require-write', () => ({ vi.mock('@/lib/init', () => ({ ensureInitialized: vi.fn() })) -import { POST } from '../route' +import { GET, POST } from '../route' const params = { params: Promise.resolve({ id: 'emp-1' }) } as never @@ -36,6 +47,31 @@ function post(body: unknown) { return createMockRequest('/api/salary/employees/emp-1/worked-hours', { method: 'POST', body }) } +function get(searchParams: Record) { + return createMockRequest('/api/salary/employees/emp-1/worked-hours', { searchParams }) +} + +function makeRule(overrides: Partial = {}): ShiftPremiumRule { + return { + id: 'rule-1', + company_id: 'company-1', + name: 'OB natt', + applies_to_all_employees: true, + applies_to_employee_ids: [], + day_of_week: [1, 2, 3, 4, 5, 6, 7], + start_time: '22:00', + end_time: '06:00', + premium_percent: 70, + item_type: 'ob_night', + priority: 10, + is_active: true, + created_at: '2026-01-01T00:00:00Z', + updated_at: '2026-01-01T00:00:00Z', + created_by: null, + ...overrides, + } +} + describe('POST /api/salary/employees/[id]/worked-hours', () => { beforeEach(() => { vi.clearAllMocks() @@ -65,6 +101,18 @@ describe('POST /api/salary/employees/[id]/worked-hours', () => { expect(response.status).toBe(403) }) + it('returns 400 when only one half of the shift window is given', async () => { + enqueue({ data: { id: 'emp-1', salary_type: 'hourly' } }) // loadEmployee + + const response = await POST( + post({ work_date: '2026-07-01', hours: 8, start_time: '22:00' }), + params, + ) + expect(response.status).toBe(400) + // Nothing was written. + expect(insertedRows()).toHaveLength(0) + }) + it('upserts a worked day (happy path)', async () => { enqueue({ data: { id: 'emp-1', salary_type: 'hourly' } }) // loadEmployee enqueue({ data: null }) // delete existing @@ -83,4 +131,190 @@ describe('POST /api/salary/employees/[id]/worked-hours', () => { const response = await POST(post({ work_date: '2026-07-01', hours: 8 }), params) expect(response.status).toBe(404) }) + + it('writes start_time and end_time to the database', 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, + start_time: '22:00', + end_time: '06:00', + }, + }) // insert + + const response = await POST( + post({ work_date: '2026-07-01', hours: 8, start_time: '22:00', end_time: '06:00' }), + params, + ) + + expect(response.status).toBe(201) + const rows = insertedRows() + expect(rows).toHaveLength(1) + expect(rows[0]).toMatchObject({ + work_date: '2026-07-01', + hours: 8, + start_time: '22:00', + end_time: '06:00', + }) + }) + + it('stores NULL times when the caller omits the shift window', async () => { + enqueue({ data: { id: 'emp-1', salary_type: 'hourly' } }) + enqueue({ data: null }) + enqueue({ data: { id: 'wd-1' } }) + + await POST(post({ work_date: '2026-07-01', hours: 8 }), params) + + expect(insertedRows()[0]).toMatchObject({ start_time: null, end_time: null }) + }) + + it('makes a night shift trigger OB natt (regression: times used to be dropped)', async () => { + // Wednesday 2026-07-01, 22:00 -> 06:00. A pure-night OB rule (22:00-06:00) + // has zero overlap with the engine's 08:00-17:00 fallback, so before the + // times were persisted this employee got no OB-tillägg at all. + enqueue({ data: { id: 'emp-1', salary_type: 'hourly' } }) + enqueue({ data: null }) + enqueue({ data: { id: 'wd-1' } }) + + await POST( + post({ work_date: '2026-07-01', hours: 8, start_time: '22:00', end_time: '06:00' }), + params, + ) + + const inserted = insertedRows()[0] + const lines = computePremiumLines({ + employeeId: 'emp-1', + baseHourlyRate: 200, + workedDays: [ + { + work_date: inserted.work_date as string, + hours: inserted.hours as number, + start_time: inserted.start_time as string | null, + end_time: inserted.end_time as string | null, + }, + ], + rules: [makeRule()], + }) + + expect(lines).toHaveLength(1) + expect(lines[0].itemType).toBe('ob_night') + // 22:00-24:00 plus 00:00-06:00 = 8 h at 70 % of 200 SEK/h. + expect(lines[0].hours).toBe(8) + expect(lines[0].amount).toBe(1120) + }) + + it('prices a Saturday evening shift on its real hours, not the fabricated day', async () => { + // Saturday 2026-07-04, 18:00 -> 23:00 = 5 h. With a full-day weekend rule the + // 08:00-17:00 fallback would have billed 9 h of the wrong hours instead. + enqueue({ data: { id: 'emp-1', salary_type: 'hourly' } }) + enqueue({ data: null }) + enqueue({ data: { id: 'wd-1' } }) + + await POST( + post({ work_date: '2026-07-04', hours: 5, start_time: '18:00', end_time: '23:00' }), + params, + ) + + const inserted = insertedRows()[0] + const lines = computePremiumLines({ + employeeId: 'emp-1', + baseHourlyRate: 200, + workedDays: [ + { + work_date: inserted.work_date as string, + hours: inserted.hours as number, + start_time: inserted.start_time as string | null, + end_time: inserted.end_time as string | null, + }, + ], + rules: [ + makeRule({ + id: 'rule-weekend', + name: 'OB helg', + item_type: 'ob_weekend', + day_of_week: [6, 7], + start_time: '00:00', + end_time: '00:00', // full 24 h on Saturday and Sunday + premium_percent: 100, + }), + ], + }) + + expect(lines).toHaveLength(1) + expect(lines[0].itemType).toBe('ob_weekend') + expect(lines[0].hours).toBe(5) + expect(lines[0].amount).toBe(1000) + }) +}) + +describe('GET /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 GET(get({ from: '2026-07-01', to: '2026-07-31' }), params) + expect(response.status).toBe(401) + }) + + it('returns 404 when the employee is not in the company', async () => { + enqueue({ data: null }) // loadEmployee → not found + + const response = await GET(get({ from: '2026-07-01', to: '2026-07-31' }), params) + expect(response.status).toBe(404) + }) + + it('returns 400 when the range is inverted', async () => { + enqueue({ data: { id: 'emp-1', salary_type: 'hourly' } }) // loadEmployee + + const response = await GET(get({ from: '2026-07-31', to: '2026-07-01' }), params) + expect(response.status).toBe(400) + }) + + it('selects the shift window back and returns it', async () => { + enqueue({ data: { id: 'emp-1', salary_type: 'hourly' } }) // loadEmployee + enqueue({ + data: [ + { + id: 'wd-1', + work_date: '2026-07-01', + hours: 8, + notes: null, + salary_run_employee_id: null, + start_time: '22:00:00', + end_time: '06:00:00', + created_at: '2026-07-01T00:00:00Z', + updated_at: '2026-07-01T00:00:00Z', + }, + ], + }) + + const response = await GET(get({ from: '2026-07-01', to: '2026-07-31' }), params) + const { status, body } = await parseJsonResponse<{ + data: Array<{ start_time: string | null; end_time: string | null }> + total_hours: number + }>(response) + + expect(status).toBe(200) + // The read path has to ask for the columns, otherwise the round trip is + // silently lossy no matter what the write path stored. + const readColumns = selectedColumns().find((cols) => cols?.includes('work_date')) + expect(readColumns).toContain('start_time') + expect(readColumns).toContain('end_time') + expect(body.data[0].start_time).toBe('22:00:00') + expect(body.data[0].end_time).toBe('06:00:00') + expect(body.total_hours).toBe(8) + }) }) 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 index 7059a0d3..808abfd7 100644 --- 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 @@ -1,15 +1,22 @@ /** - * Auth-wiring tests for /api/salary/employees/[id]/worked-hours/batch (POST). + * 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). + * write and injects a recording Supabase mock via requireAuth. Covers 401, 403 + * (viewer), 400, 404 and the happy path. + * + * The rest of the file guards the delete-and-reinsert. The batch carries one + * shared body for N dates, so anything it omits (per-day notes, per-day shift + * windows, per-day run links) has to survive the replace. It used to write the + * shared value or NULL over every day, which wiped notes the user had written + * and shift windows that made OB-tillägg computable. */ import { describe, it, expect, vi, beforeEach } from 'vitest' import { NextResponse } from 'next/server' -import { createQueuedMockSupabase, createMockRequest, parseJsonResponse } from '@/tests/helpers' +import { createMockRequest, parseJsonResponse } from '@/tests/helpers' +import { createRecordingSupabase } from '../../__tests__/recording-supabase' -const { supabase, enqueue, reset } = createQueuedMockSupabase() +const { supabase, enqueue, reset, insertedRows, ops } = createRecordingSupabase() const requireAuthMock = vi.fn() vi.mock('@/lib/auth/require-auth', () => ({ @@ -33,11 +40,21 @@ 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 }) + return createMockRequest('/api/salary/employees/emp-1/worked-hours/batch', { + method: 'POST', + body, + }) } const validBatch = { dates: ['2026-07-01', '2026-07-02'], hours: 8 } +/** Queue the reads the route does before its inserts: employee, prefetch, delete. */ +function enqueuePreamble(existing: unknown[] = []) { + enqueue({ data: { id: 'emp-1' } }) // employee ownership check + enqueue({ data: existing }) // prefetch of rows about to be replaced + enqueue({ data: null }) // bulk delete +} + describe('POST /api/salary/employees/[id]/worked-hours/batch', () => { beforeEach(() => { vi.clearAllMocks() @@ -67,14 +84,31 @@ describe('POST /api/salary/employees/[id]/worked-hours/batch', () => { expect(response.status).toBe(403) }) - it('bulk-inserts worked days (happy path)', async () => { + it('returns 400 for an empty date list', async () => { enqueue({ data: { id: 'emp-1' } }) // employee ownership check - enqueue({ data: null }) // bulk delete + + const response = await POST(post({ dates: [], hours: 8 }), params) + expect(response.status).toBe(400) + expect(insertedRows()).toHaveLength(0) + }) + + it('returns 400 when only one half of the shift window is given', async () => { + enqueue({ data: { id: 'emp-1' } }) // employee ownership check + + const response = await POST(post({ ...validBatch, end_time: '06:00' }), params) + expect(response.status).toBe(400) + expect(insertedRows()).toHaveLength(0) + }) + + it('bulk-inserts worked days (happy path)', async () => { + enqueuePreamble() 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) + const { status, body } = await parseJsonResponse<{ + data: { inserted: number; conflicts: unknown[] } + }>(response) expect(status).toBe(201) expect(body.data.inserted).toBe(2) @@ -87,4 +121,249 @@ describe('POST /api/salary/employees/[id]/worked-hours/batch', () => { const response = await POST(post(validBatch), params) expect(response.status).toBe(404) }) + + it('keeps each day its own note when the batch carries none', async () => { + enqueuePreamble([ + { + work_date: '2026-07-01', + notes: 'Inventering lager', + salary_run_employee_id: null, + start_time: null, + end_time: null, + }, + { + work_date: '2026-07-02', + notes: 'Kundbesök Göteborg', + salary_run_employee_id: null, + start_time: null, + end_time: null, + }, + ]) + enqueue({ data: null }) + enqueue({ data: null }) + + const response = await POST(post(validBatch), params) + expect(response.status).toBe(201) + + const rows = insertedRows() + expect(rows).toHaveLength(2) + expect(rows[0]).toMatchObject({ work_date: '2026-07-01', notes: 'Inventering lager' }) + expect(rows[1]).toMatchObject({ work_date: '2026-07-02', notes: 'Kundbesök Göteborg' }) + }) + + it('lets an explicit batch note override the stored per-day notes', async () => { + enqueuePreamble([ + { + work_date: '2026-07-01', + notes: 'Inventering lager', + salary_run_employee_id: null, + start_time: null, + end_time: null, + }, + ]) + enqueue({ data: null }) + enqueue({ data: null }) + + await POST(post({ ...validBatch, notes: 'Projektvecka' }), params) + + const rows = insertedRows() + expect(rows[0]).toMatchObject({ work_date: '2026-07-01', notes: 'Projektvecka' }) + expect(rows[1]).toMatchObject({ work_date: '2026-07-02', notes: 'Projektvecka' }) + }) + + it('writes the shift window when the batch supplies one', async () => { + enqueuePreamble() + enqueue({ data: null }) + enqueue({ data: null }) + + await POST(post({ ...validBatch, start_time: '22:00', end_time: '06:00' }), params) + + for (const row of insertedRows()) { + expect(row).toMatchObject({ start_time: '22:00', end_time: '06:00' }) + } + }) + + it('keeps a stored shift window when the batch supplies none', async () => { + // Re-marking hours must not silently erase the times: that would send the + // day back to the engine's 08:00-17:00 fallback and drop the OB-tillägg. + enqueuePreamble([ + { + work_date: '2026-07-01', + notes: null, + salary_run_employee_id: null, + start_time: '22:00:00', + end_time: '06:00:00', + }, + ]) + enqueue({ data: null }) + enqueue({ data: null }) + + await POST(post(validBatch), params) + + const rows = insertedRows() + expect(rows[0]).toMatchObject({ start_time: '22:00:00', end_time: '06:00:00' }) + // The untouched day has no stored window, so it stays NULL. + expect(rows[1]).toMatchObject({ start_time: null, end_time: null }) + }) + + it('keeps a stored salary_run_employee_id when the batch supplies none', async () => { + enqueuePreamble([ + { + work_date: '2026-07-01', + notes: null, + salary_run_employee_id: 'sre-1', + start_time: null, + end_time: null, + }, + ]) + enqueue({ data: null }) + enqueue({ data: null }) + + await POST(post(validBatch), params) + + const rows = insertedRows() + expect(rows[0]).toMatchObject({ salary_run_employee_id: 'sre-1' }) + expect(rows[1]).toMatchObject({ salary_run_employee_id: null }) + }) + + it('reads the existing rows before deleting them', async () => { + enqueuePreamble() + enqueue({ data: null }) + enqueue({ data: null }) + + await POST(post(validBatch), params) + + const workedDayOps = ops.filter((op) => op.table === 'salary_worked_days') + expect(workedDayOps[0].verb).toBe('select') + expect(workedDayOps[0].columns).toContain('notes') + expect(workedDayOps[0].columns).toContain('start_time') + // hours must be captured too: it is what makes a destroyed row restorable + // when a reinsert fails after the bulk delete already ran. + expect(workedDayOps[0].columns).toContain('hours') + expect(workedDayOps[1].verb).toBe('delete') + }) + + it('restores the pre-existing row when a date conflicts on the 24h cap', async () => { + // The conflict is reported as "nothing changed" for that date, so the row + // the bulk delete destroyed must be put back verbatim: original hours, + // notes and shift window, not the batch's values. + enqueuePreamble([ + { + work_date: '2026-07-01', + hours: 5, + notes: 'Halvdag', + salary_run_employee_id: 'sre-1', + start_time: '08:00:00', + end_time: '13:00:00', + }, + ]) + enqueue({ error: { message: 'Total tid över 24h', code: '23514' } }) // insert date 1 trips the cap + enqueue({ data: null }) // restore of date 1 + enqueue({ data: null }) // insert date 2 + + const response = await POST(post(validBatch), params) + const { status, body } = await parseJsonResponse<{ + data: { inserted: number; conflicts: { date: string }[] } + }>(response) + + expect(status).toBe(207) + expect(body.data.inserted).toBe(1) + expect(body.data.conflicts).toHaveLength(1) + expect(body.data.conflicts[0].date).toBe('2026-07-01') + + const rows = insertedRows() + // 1: failed replacement attempt, 2: restore, 3: date-2 replacement. + expect(rows).toHaveLength(3) + expect(rows[1]).toMatchObject({ + work_date: '2026-07-01', + hours: 5, + notes: 'Halvdag', + salary_run_employee_id: 'sre-1', + start_time: '08:00:00', + end_time: '13:00:00', + }) + expect(rows[2]).toMatchObject({ work_date: '2026-07-02', hours: 8 }) + }) + + it('does not attempt a restore for a conflicting date that had no prior row', async () => { + enqueuePreamble() // nothing stored on either date + enqueue({ error: { message: 'Total tid över 24h', code: '23514' } }) // 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(207) + expect(body.data.inserted).toBe(1) + expect(body.data.conflicts).toHaveLength(1) + // Only the two replacement attempts: no phantom restore insert. + expect(insertedRows()).toHaveLength(2) + }) + + it('restores the remaining dates when an unexpected error aborts the batch', async () => { + // Three dates; the bulk delete destroyed all three stored rows. Date 1 + // replaces fine, date 2 hits an unexpected error: dates 2 and 3 must be + // put back before the 500 goes out, otherwise their rows are simply gone. + const threeDates = { dates: ['2026-07-01', '2026-07-02', '2026-07-03'], hours: 8 } + enqueuePreamble([ + { + work_date: '2026-07-02', + hours: 4, + notes: 'Halvdag', + salary_run_employee_id: null, + start_time: null, + end_time: null, + }, + { + work_date: '2026-07-03', + hours: 6, + notes: 'Kundbesök', + salary_run_employee_id: 'sre-9', + start_time: '10:00:00', + end_time: '16:00:00', + }, + ]) + enqueue({ data: null }) // insert date 1: ok + enqueue({ error: { message: 'connection reset', code: '08006' } }) // insert date 2: unexpected + enqueue({ data: null }) // restore date 2 + enqueue({ data: null }) // restore date 3 + + const response = await POST(post(threeDates), params) + const { status, body } = await parseJsonResponse<{ inserted: number }>(response) + + expect(status).toBe(500) + expect(body.inserted).toBe(1) + + const rows = insertedRows() + // 1: date-1 replacement, 2: failed date-2 attempt, 3-4: restores. + expect(rows).toHaveLength(4) + expect(rows[2]).toMatchObject({ work_date: '2026-07-02', hours: 4, notes: 'Halvdag' }) + expect(rows[3]).toMatchObject({ + work_date: '2026-07-03', + hours: 6, + notes: 'Kundbesök', + salary_run_employee_id: 'sre-9', + start_time: '10:00:00', + end_time: '16:00:00', + }) + // Date 1 was successfully replaced: it must NOT be clobbered by a restore. + expect(rows.filter((r) => r.work_date === '2026-07-01')).toHaveLength(1) + }) + + it('aborts without deleting anything when the prefetch fails', async () => { + enqueue({ data: { id: 'emp-1' } }) // employee ownership check + enqueue({ error: { message: 'connection reset', code: '08006' } }) // prefetch fails + + const response = await POST(post(validBatch), params) + + expect(response.status).toBe(500) + expect(insertedRows()).toHaveLength(0) + // The only salary_worked_days query issued was the failed read: had the + // delete run first, the batch would have destroyed rows it never replaced. + const workedDayOps = ops.filter((op) => op.table === 'salary_worked_days') + expect(workedDayOps).toHaveLength(1) + expect(workedDayOps[0].verb).toBe('select') + }) }) 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 04bf674c..ecb593f9 100644 --- a/app/api/salary/employees/[id]/worked-hours/batch/route.ts +++ b/app/api/salary/employees/[id]/worked-hours/batch/route.ts @@ -12,9 +12,24 @@ interface BatchConflict { reason: string } +/** + * The per-day values the delete-and-reinsert below must not destroy. `hours` + * is captured too: it is not merged into the replacement rows (the batch's + * whole point is to overwrite hours), but it is what makes a destroyed row + * restorable when the replacement insert fails after the delete already ran. + */ +interface ExistingWorkedDay { + work_date: string + hours: number + notes: string | null + salary_run_employee_id: string | null + start_time: string | null + end_time: string | null +} + export const POST = withRouteContext<{ params: Promise<{ id: string }> }>( 'salary.employees.worked_hours.batch', - async (request, { supabase, companyId }, { params }) => { + async (request, { supabase, companyId, log }, { params }) => { const { id: employeeId } = await params const { data: employee } = await supabase @@ -35,6 +50,36 @@ export const POST = withRouteContext<{ params: Promise<{ id: string }> }>( // (e.g. shift-clicking over the same date twice). const uniqueDates = Array.from(new Set(body.dates)) + // Read the rows we are about to replace BEFORE deleting them. The batch + // carries one shared value for N dates, so it cannot express per-day notes, + // per-day shift windows or per-day run links: anything the body omits has to + // survive the replace. Without this, "mark Mon-Fri as 8 h" silently wipes + // every note the user wrote on those days and every shift window that made + // OB-tillägg computable. Read first so a failure here aborts before we have + // deleted anything. + const { data: existingRows, error: existingError } = await supabase + .from('salary_worked_days') + .select('work_date, hours, notes, salary_run_employee_id, start_time, end_time') + .eq('company_id', companyId) + .eq('employee_id', employeeId) + .in('work_date', uniqueDates) + + if (existingError) { + return NextResponse.json({ error: getUserErrorMessage(existingError) }, { status: 500 }) + } + + const existingByDate = new Map( + ((existingRows ?? []) as ExistingWorkedDay[]).map((row) => [row.work_date, row]), + ) + + // A supplied shift window applies to every date in the batch; an omitted one + // leaves each day's stored window alone. Resolved as a pair so a body start + // time is never mixed with a stored end time (the schema pairs them too). + const bodyShiftWindow = + body.start_time != null && body.end_time != null + ? { start_time: body.start_time, end_time: body.end_time } + : null + // 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 @@ -48,30 +93,98 @@ export const POST = withRouteContext<{ params: Promise<{ id: string }> }>( return NextResponse.json({ error: getUserErrorMessage(deleteError) }, { status: 500 }) } + // Reinsert a captured pre-existing row verbatim. The bulk delete above has + // already destroyed the stored row for every date in the batch, so when a + // date's replacement insert fails, this is what turns "row destroyed" back + // into "nothing changed". The original row passed the 24h-cap trigger when + // it was first written, so restoring the identical values is expected to + // pass it again. + const restoreDay = async (existing: ExistingWorkedDay) => { + const { error } = await supabase.from('salary_worked_days').insert({ + company_id: companyId, + employee_id: employeeId, + work_date: existing.work_date, + hours: existing.hours, + notes: existing.notes, + salary_run_employee_id: existing.salary_run_employee_id, + start_time: existing.start_time, + end_time: existing.end_time, + }) + return error + } + + // Best-effort restore of every not-yet-processed date's pre-existing row, + // used when an unexpected error aborts the loop: without it, every date + // after the failure point would have been deleted and never reinserted. + const restoreRemaining = async (fromIndex: number) => { + for (let j = fromIndex; j < uniqueDates.length; j++) { + const existing = existingByDate.get(uniqueDates[j]) + if (!existing) continue + const restoreError = await restoreDay(existing) + if (restoreError) { + log.error('worked-hours batch: failed to restore a deleted row after an aborted batch', { + employeeId, + workDate: existing.work_date, + error: restoreError.message, + }) + } + } + } + // 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) { + for (let i = 0; i < uniqueDates.length; i++) { + const date = uniqueDates[i] + const existing = existingByDate.get(date) + const shiftWindow = bodyShiftWindow ?? { + start_time: existing?.start_time ?? null, + end_time: existing?.end_time ?? null, + } const { error } = await supabase .from('salary_worked_days') .insert({ company_id: companyId, employee_id: employeeId, work_date: date, + // hours is the point of the batch: it always overwrites. hours: body.hours, - notes: body.notes ?? null, - salary_run_employee_id: body.salary_run_employee_id ?? null, + // Everything else: the body wins when it carries a value, otherwise + // the day keeps what it already had. + notes: body.notes ?? existing?.notes ?? null, + salary_run_employee_id: + body.salary_run_employee_id ?? existing?.salary_run_employee_id ?? null, + start_time: shiftWindow.start_time, + end_time: shiftWindow.end_time, }) 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') { + // The conflict report says "nothing changed for this date": make + // that true by reinserting the pre-existing row the bulk delete + // destroyed. A date with no prior row has nothing to restore. + if (existing) { + const restoreError = await restoreDay(existing) + if (restoreError) { + // The restore itself failed: the date's data IS lost unless the + // remaining dates are put back and the caller is told loudly. + await restoreRemaining(i + 1) + return NextResponse.json( + { error: getUserErrorMessage(restoreError), inserted, conflicts }, + { status: 500 }, + ) + } + } conflicts.push({ date, reason: getUserErrorMessage(error) }) continue } + // Unexpected error: put back the captured rows for this date and every + // date the loop never reached, then surface the failure. + await restoreRemaining(i) return NextResponse.json( { error: getUserErrorMessage(error), inserted, conflicts }, { status: 500 }, diff --git a/app/api/salary/employees/[id]/worked-hours/route.ts b/app/api/salary/employees/[id]/worked-hours/route.ts index d361d2ba..5ff849dc 100644 --- a/app/api/salary/employees/[id]/worked-hours/route.ts +++ b/app/api/salary/employees/[id]/worked-hours/route.ts @@ -43,7 +43,9 @@ export const GET = withRouteContext<{ params: Promise<{ id: string }> }>( const { data, error } = await supabase .from('salary_worked_days') - .select('id, work_date, hours, notes, salary_run_employee_id, created_at, updated_at') + .select( + 'id, work_date, hours, notes, salary_run_employee_id, start_time, end_time, created_at, updated_at', + ) .eq('company_id', companyId) .eq('employee_id', employeeId) .gte('work_date', query.data.from) @@ -80,6 +82,11 @@ export const POST = withRouteContext<{ params: Promise<{ id: string }> }>( // 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. + // + // Replace semantics are deliberate here: this endpoint addresses exactly one + // day and the caller can describe every field of it, so an omitted field + // means "not set". The batch endpoint cannot make that claim (one shared + // body for N dates), which is why it carries omitted values forward instead. const { error: deleteError } = await supabase .from('salary_worked_days') .delete() @@ -100,6 +107,13 @@ export const POST = withRouteContext<{ params: Promise<{ id: string }> }>( hours: body.hours, notes: body.notes ?? null, salary_run_employee_id: body.salary_run_employee_id ?? null, + // The shift window. Persisting it is what lets the shift-premium engine + // intersect the real hours with the OB rule windows; a row without times + // falls back to an assumed 08:00-17:00 day, so a night shift would be + // paid as office hours and OB-tillägg would never trigger. The schema + // guarantees both fields are present or both absent. + start_time: body.start_time ?? null, + end_time: body.end_time ?? null, }) .select() .single() diff --git a/app/api/salary/employees/__tests__/route.test.ts b/app/api/salary/employees/__tests__/route.test.ts index 54dbba9c..bf104494 100644 --- a/app/api/salary/employees/__tests__/route.test.ts +++ b/app/api/salary/employees/__tests__/route.test.ts @@ -26,7 +26,7 @@ vi.mock('@/lib/auth/require-write', () => ({ requireWritePermission: vi.fn().mockResolvedValue({ ok: true }), })) -import { GET } from '../route' +import { GET, POST } from '../route' import { requireAuth } from '@/lib/auth/require-auth' import { encryptPersonnummer } from '@/lib/salary/personnummer' @@ -77,8 +77,8 @@ describe('GET /api/salary/employees', () => { it('does not 500 on a mixed plaintext + encrypted roster; masks both', async () => { authed( supabaseWithRows([ - { id: 'e1', last_name: 'A', personnummer: PLAINTEXT_PNR }, - { id: 'e2', last_name: 'B', personnummer: ENCRYPTED_PNR }, + { id: 'e1', last_name: 'A', personnummer: PLAINTEXT_PNR, personnummer_last4: '0000' }, + { id: 'e2', last_name: 'B', personnummer: ENCRYPTED_PNR, personnummer_last4: '0000' }, ]), ) @@ -86,10 +86,93 @@ describe('GET /api/salary/employees', () => { expect(res.status).toBe(200) const body = await res.json() // Both rows masked birthdate-visible, last-4 hidden. - expect(body.data[0].personnummer).toBe('19000101-XXXX') - expect(body.data[1].personnummer).toBe('19020304-XXXX') + expect(body.data[0].personnummer_masked).toBe('19000101-XXXX') + expect(body.data[1].personnummer_masked).toBe('19020304-XXXX') // Neither the plaintext nor the stored ciphertext may leak. expect(JSON.stringify(body)).not.toContain(PLAINTEXT_PNR) expect(JSON.stringify(body)).not.toContain(ENCRYPTED_PNR) }) + + it('never returns the mask under the writable `personnummer` key', async () => { + // The roster feeds edit forms. If the mask came back as `personnummer`, a + // client that reads a row and writes it back would post 'ÅÅÅÅMMDD-XXXX' + // into the encrypt path. The masked value lives under `personnummer_masked` + // so the read key and the write key can never be confused. + authed(supabaseWithRows([{ id: 'e1', last_name: 'A', personnummer: ENCRYPTED_PNR }])) + + const res = await GET(req(), params) + const body = await res.json() + expect(body.data[0].personnummer).toBeUndefined() + expect('personnummer' in body.data[0]).toBe(false) + expect(body.data[0].personnummer_masked).toBe('19020304-XXXX') + }) + + it('strips personnummer_last4 so the mask cannot be reassembled', async () => { + // The mask is YYYYMMDD-XXXX. A response carrying the mask AND the last + // four digits hands the client the full personnummer by concatenation, so + // personnummer_last4 must never ride along with the roster rows. + authed( + supabaseWithRows([ + { id: 'e1', last_name: 'A', personnummer: ENCRYPTED_PNR, personnummer_last4: '0000' }, + ]), + ) + + const res = await GET(req(), params) + const body = await res.json() + expect(body.data[0]).not.toHaveProperty('personnummer_last4') + expect(body.data[0]).not.toHaveProperty('personnummer') + expect(body.data[0].personnummer_masked).toBe('19020304-XXXX') + }) +}) + +describe('POST /api/salary/employees', () => { + // Luhn-valid synthetic personnummer (checksum verified in personnummer.test.ts). + const NEW_PNR = '199001019802' + + function supabaseWithInsert(returned: Record) { + const single = vi.fn(() => Promise.resolve({ data: returned, error: null })) + const select = vi.fn(() => ({ single })) + const insert = vi.fn(() => ({ select })) + return { supabase: { from: vi.fn(() => ({ insert })) }, insert } + } + + it('create response carries the mask only: no ciphertext, no last4', async () => { + const inserted = { + id: 'emp-new', + company_id: 'company-1', + first_name: 'Test', + last_name: 'Testsson', + personnummer: encryptPersonnummer(NEW_PNR), + personnummer_last4: '9802', + employment_type: 'employee', + } + const { supabase } = supabaseWithInsert(inserted) + authed(supabase) + + const res = await POST( + new Request('https://x.test/api/salary/employees', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + first_name: 'Test', + last_name: 'Testsson', + personnummer: NEW_PNR, + employment_start: '2026-01-01', + monthly_salary: 30000, + tax_table_number: 34, + tax_municipality: 'Stockholm', + }), + }), + params, + ) + expect(res.status).toBe(201) + const body = await res.json() + expect(body.data.personnummer_masked).toBe('19900101-XXXX') + expect(body.data).not.toHaveProperty('personnummer') + expect(body.data).not.toHaveProperty('personnummer_last4') + // Neither the full personnummer nor its suffix may appear anywhere in the + // serialized response: mask + last4 would reassemble the identity. + expect(JSON.stringify(body)).not.toContain(NEW_PNR) + expect(JSON.stringify(body)).not.toContain('9802') + }) }) diff --git a/app/api/salary/employees/route.ts b/app/api/salary/employees/route.ts index 90267578..9bc9300e 100644 --- a/app/api/salary/employees/route.ts +++ b/app/api/salary/employees/route.ts @@ -4,7 +4,7 @@ import { withRouteContext } from '@/lib/api/with-route-context' import { validateBody } from '@/lib/api/validate' import { CreateEmployeeSchema } from '@/lib/api/schemas' import { getCompanyEntityType } from '@/lib/company/context' -import { decryptPersonnummer, encryptPersonnummer, extractLast4, maskPersonnummer, validatePersonnummer } from '@/lib/salary/personnummer' +import { encryptPersonnummer, extractLast4, maskEmployeeForResponse, maskPersonnummer, validatePersonnummer } from '@/lib/salary/personnummer' import { isEmploymentTypeAllowedForEntity, EF_OWNER_EMPLOYMENT_ERROR } from '@/lib/salary/employment-rules' import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message' @@ -29,11 +29,10 @@ export const GET = withRouteContext('salary.employees.list', async (request, { s return NextResponse.json({ error: getUserErrorMessage(error) }, { status: 500 }) } - // Mask personnummer: show birthdate, hide the 4-digit suffix - const masked = (data || []).map(emp => ({ - ...emp, - personnummer: maskPersonnummer(decryptPersonnummer(emp.personnummer)), - })) + // Mask personnummer: show birthdate, hide the 4-digit suffix. The shared + // helper also strips personnummer_last4: mask + last4 in the same payload + // would reassemble the full personnummer. + const masked = (data || []).map((emp) => maskEmployeeForResponse(emp)) return NextResponse.json({ data: masked }) }) @@ -114,10 +113,15 @@ export const POST = withRouteContext('salary.employees.create', async (request, return NextResponse.json({ error: getUserErrorMessage(error) }, { status: 500 }) } + // Same rule as the read surfaces: the create response carries the mask under + // `personnummer_masked` only. Spreading the inserted row would otherwise echo + // the stored ciphertext under `personnummer`, and personnummer_last4 must not + // ride along with the mask (mask + last4 = the full personnummer). + const { personnummer: _storedPnr, personnummer_last4: _storedLast4, ...created } = employee return NextResponse.json({ data: { - ...employee, - personnummer: maskPersonnummer(body.personnummer), + ...created, + personnummer_masked: maskPersonnummer(body.personnummer), }, }, { status: 201 }) }, { requireWrite: true }) diff --git a/app/api/salary/runs/[id]/__tests__/route.test.ts b/app/api/salary/runs/[id]/__tests__/route.test.ts index 5e6aa2d1..433299d0 100644 --- a/app/api/salary/runs/[id]/__tests__/route.test.ts +++ b/app/api/salary/runs/[id]/__tests__/route.test.ts @@ -28,13 +28,9 @@ vi.mock('@/lib/auth/require-write', () => ({ requireWritePermission: vi.fn().mockResolvedValue({ ok: true }), })) -vi.mock('@/lib/salary/personnummer', () => ({ - decryptPersonnummer: vi.fn((v: string) => v), - maskPersonnummer: vi.fn(() => '19900101-****'), -})) - import { DELETE, GET } from '../route' import { requireAuth } from '@/lib/auth/require-auth' +import { encryptPersonnummer } from '@/lib/salary/personnummer' // ── Test data ──────────────────────────────────────────────── @@ -228,6 +224,55 @@ describe('GET /api/salary/runs/[id] — additive detail fields', () => { expect(body.data.previous_run).toBeNull() }) + it('masks embedded employees: no ciphertext, no personnummer_last4', async () => { + // The mask is YYYYMMDD-XXXX; a payload carrying the mask AND the last four + // digits would reassemble the full personnummer by concatenation. + const STORED_PNR = '190203040000' // synthetic + const { supabase, enqueueMany } = createQueuedMockSupabase() + authed(supabase) + + enqueueMany([ + { data: GET_RUN }, + { + data: [ + { + id: 'sre-1', + gross_salary: 30000, + employee: { + id: 'emp-1', + first_name: 'Test', + last_name: 'Testsson', + personnummer: encryptPersonnummer(STORED_PNR), + personnummer_last4: '0000', + employment_type: 'employee', + default_dimensions: {}, + }, + line_items: [], + }, + ], + }, + { data: null }, // settings + { data: null }, // no previous booked run + { data: [] }, // deliveries + ]) + + const response = await GET( + createMockRequest('/api/salary/runs/run-2'), + createMockRouteParams({ id: 'run-2' }), + ) + const { status, body } = await parseJsonResponse<{ + data: { employees: Array<{ employee: Record }> } + }>(response) + + expect(status).toBe(200) + const employee = body.data.employees[0].employee + expect(employee.personnummer_masked).toBe('19020304-XXXX') + expect(employee).not.toHaveProperty('personnummer') + expect(employee).not.toHaveProperty('personnummer_last4') + // Neither the plaintext nor the suffix may appear anywhere in the payload. + expect(JSON.stringify(body)).not.toContain(STORED_PNR) + }) + it('exposes corrected_by_run_id on corrected originals and counts latest deliveries', async () => { const { supabase, enqueueMany } = createQueuedMockSupabase() authed(supabase) 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 4ff1d8d8..d0ac97c1 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 @@ -20,14 +20,10 @@ vi.mock('@/lib/company/context', () => ({ vi.mock('@/lib/auth/require-write', () => ({ requireWritePermission: vi.fn().mockResolvedValue({ ok: true }), })) -vi.mock('@/lib/salary/personnummer', () => ({ - decryptPersonnummer: (x: string) => x, - maskPersonnummer: (x: string) => x, -})) - -import { PATCH } from '../route' +import { GET, PATCH } from '../route' import { requireAuth } from '@/lib/auth/require-auth' import { requireWritePermission } from '@/lib/auth/require-write' +import { encryptPersonnummer } from '@/lib/salary/personnummer' const mockUser = { id: 'user-1', email: 'test@test.se' } @@ -41,6 +37,53 @@ function authed() { return { supabase, enqueueMany } } +describe('GET /api/salary/runs/[id]/employees/[employeeId]', () => { + beforeEach(() => { + vi.clearAllMocks() + vi.mocked(requireWritePermission).mockResolvedValue({ ok: true } as never) + }) + + it('masks the embedded employee: no ciphertext, no personnummer_last4', async () => { + // The embed is employees(*), so personnummer_last4 rides along from the DB. + // The mask is YYYYMMDD-XXXX: mask + last4 in the same payload would + // reassemble the full personnummer, so both pn-derived columns must be + // stripped before the payload leaves the server. + const STORED_PNR = '190203040000' // synthetic + const { enqueueMany } = authed() + enqueueMany([ + { + data: { + id: 'sre-1', + gross_salary: 30000, + employee: { + id: 'emp-1', + first_name: 'Test', + last_name: 'Testsson', + personnummer: encryptPersonnummer(STORED_PNR), + personnummer_last4: '0000', + employment_type: 'employee', + }, + line_items: [], + }, + }, + ]) + + const response = await GET( + createMockRequest('/api/salary/runs/run-1/employees/emp-1'), + createMockRouteParams({ id: 'run-1', employeeId: 'emp-1' }), + ) + const { status, body } = await parseJsonResponse<{ + data: { employee: Record } + }>(response) + + expect(status).toBe(200) + expect(body.data.employee.personnummer_masked).toBe('19020304-XXXX') + expect(body.data.employee).not.toHaveProperty('personnummer') + expect(body.data.employee).not.toHaveProperty('personnummer_last4') + expect(JSON.stringify(body)).not.toContain(STORED_PNR) + }) +}) + describe('PATCH /api/salary/runs/[id]/employees/[employeeId]: monthly salary edit', () => { beforeEach(() => { vi.clearAllMocks() diff --git a/app/api/salary/runs/[id]/employees/[employeeId]/route.ts b/app/api/salary/runs/[id]/employees/[employeeId]/route.ts index 8060dc7b..f2ed60e8 100644 --- a/app/api/salary/runs/[id]/employees/[employeeId]/route.ts +++ b/app/api/salary/runs/[id]/employees/[employeeId]/route.ts @@ -3,7 +3,7 @@ import { ensureInitialized } from '@/lib/init' 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' +import { maskEmployeeForResponse } from '@/lib/salary/personnummer' import { removeEmployeeFromRun } from '@/lib/salary/run-employees' import { getErrorEntry } from '@/lib/errors/structured-errors' import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message' @@ -32,17 +32,17 @@ export const GET = withRouteContext<{ params: Promise<{ id: string; employeeId: 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. + // Strip the encrypted personnummer ciphertext AND personnummer_last4 + // before sending to the browser (the embed is employees(*), so the last4 + // column rides along otherwise, and mask + last4 reassembles the full + // personnummer). The shared helper replaces both with the YYYYMMDD-XXXX + // masked form under `personnummer_masked`, never under the writable + // `personnummer` key: this payload is an employee object, and a mask under + // the write key could be read here and posted straight back into the + // encrypt path. const masked = { ...data, - employee: data.employee - ? { - ...data.employee, - personnummer: maskPersonnummer(decryptPersonnummer(data.employee.personnummer)), - } - : data.employee, + employee: data.employee ? maskEmployeeForResponse(data.employee) : data.employee, } return NextResponse.json({ data: masked }) diff --git a/app/api/salary/runs/[id]/lines/[lineId]/__tests__/sparse-patch.test.ts b/app/api/salary/runs/[id]/lines/[lineId]/__tests__/sparse-patch.test.ts new file mode 100644 index 00000000..c8667d55 --- /dev/null +++ b/app/api/salary/runs/[id]/lines/[lineId]/__tests__/sparse-patch.test.ts @@ -0,0 +1,123 @@ +/** + * A one-field PATCH must not rewrite the fields it did not name. + * + * UpdateSalaryLineItemSchema is CreateSalaryLineItemSchema.partial(), and + * .partial() does NOT strip .default(). Parsed bare, `{ amount: 5500 }` comes + * back carrying is_taxable/is_avgift_basis/is_vacation_basis=true, + * is_gross_deduction/is_net_deduction=false and sort_order=0, and + * updatePayslipLine spreads the patch straight into .update(). Correcting the + * amount on a net deduction line would have silently converted it into a + * taxable, avgift-bearing, vacation-bearing earning: wrong skatteavdrag, wrong + * arbetsgivaravgifter, wrong semesterlöneskuld. + * + * The writer is mocked here on purpose: the assertion is about the exact patch + * object handed to it, which is the thing that reaches the UPDATE. + */ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { 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 }), +})) +vi.mock('@/lib/salary/payslip-lines', () => ({ + updatePayslipLine: vi.fn(), + deletePayslipLine: vi.fn(), +})) + +import { PATCH } from '../route' +import { requireAuth } from '@/lib/auth/require-auth' +import { requireWritePermission } from '@/lib/auth/require-write' +import { updatePayslipLine } from '@/lib/salary/payslip-lines' + +const params = () => createMockRouteParams({ id: 'run-1', lineId: 'line-1' }) + +beforeEach(() => { + vi.clearAllMocks() + vi.mocked(requireAuth).mockResolvedValue({ + user: { id: 'user-1', email: 'test@test.se' } as never, + supabase: {} as never, + error: null, + }) + vi.mocked(requireWritePermission).mockResolvedValue({ ok: true } as never) + vi.mocked(updatePayslipLine).mockResolvedValue({ + ok: true, + data: { id: 'line-1' } as never, + }) +}) + +async function patch(body: unknown) { + return PATCH( + createMockRequest('/api/salary/runs/run-1/lines/line-1', { method: 'PATCH', body }), + params(), + ) +} + +/** The patch object the route handed to the writer. */ +function sentPatch(): Record { + const call = vi.mocked(updatePayslipLine).mock.calls[0] + return (call[1] as { patch: Record }).patch +} + +describe('PATCH /api/salary/runs/[id]/lines/[lineId] sparse patch', () => { + it('sends ONLY the named field, not the whole default set', async () => { + const response = await patch({ amount: 5500 }) + expect(response.status).toBe(200) + expect(sentPatch()).toEqual({ amount: 5500 }) + }) + + it('does not resurrect the taxability / avgift / vacation flags', async () => { + await patch({ amount: 5500 }) + const sent = sentPatch() + for (const flag of [ + 'is_taxable', + 'is_avgift_basis', + 'is_vacation_basis', + 'is_gross_deduction', + 'is_net_deduction', + 'sort_order', + ]) { + expect(sent, `${flag} must not be written by an amount-only PATCH`).not.toHaveProperty(flag) + } + }) + + it('still writes a default-carrying flag when the caller sets it explicitly', async () => { + await patch({ is_net_deduction: true }) + expect(sentPatch()).toEqual({ is_net_deduction: true }) + }) + + it('rejects null on a non-nullable field rather than quietly clearing it', async () => { + // No field on CreateSalaryLineItemSchema is .nullable(), so "clear this + // column" is not expressible on this endpoint. sparsePatchBody must keep + // that a 400 and not translate null into a write. (The null-survives-as-a- + // deliberate-clear path is covered where the schema allows it: see + // lib/api/__tests__/sparse-patch.test.ts and the accounts PUT tests.) + const response = await patch({ account_number: null }) + expect(response.status).toBe(400) + expect(updatePayslipLine).not.toHaveBeenCalled() + }) + + it('drops unknown keys instead of forwarding them to the update', async () => { + await patch({ amount: 100, company_id: 'other-company', id: 'other-line' }) + expect(sentPatch()).toEqual({ amount: 100 }) + }) + + it('returns 400 for an empty body instead of writing an empty update', async () => { + const response = await patch({}) + const { status, body } = await parseJsonResponse<{ error: string }>(response) + expect(status).toBe(400) + expect(body.error).toBe('Inget att uppdatera') + expect(updatePayslipLine).not.toHaveBeenCalled() + }) + + it('still rejects an invalid value with a 400', async () => { + const response = await patch({ amount: 'inte ett tal' }) + expect(response.status).toBe(400) + expect(updatePayslipLine).not.toHaveBeenCalled() + }) +}) diff --git a/app/api/salary/runs/[id]/lines/[lineId]/route.ts b/app/api/salary/runs/[id]/lines/[lineId]/route.ts index 09d452c2..5a5222a3 100644 --- a/app/api/salary/runs/[id]/lines/[lineId]/route.ts +++ b/app/api/salary/runs/[id]/lines/[lineId]/route.ts @@ -2,6 +2,7 @@ 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 { sparsePatchBody } from '@/lib/api/sparse-patch' import { UpdateSalaryLineItemSchema } from '@/lib/api/schemas' import { updatePayslipLine, deletePayslipLine } from '@/lib/salary/payslip-lines' import { getErrorEntry } from '@/lib/errors/structured-errors' @@ -22,9 +23,20 @@ export const PATCH = withRouteContext<{ params: Promise<{ id: string; lineId: st const { id, lineId } = await params const { supabase, companyId } = ctx - const validation = await validateBody(request, UpdateSalaryLineItemSchema) + // sparsePatchBody, not the bare schema: UpdateSalaryLineItemSchema is + // CreateSalaryLineItemSchema.partial(), and .partial() does NOT strip + // .default(). Parsed bare, `{ amount: 5500 }` comes back carrying + // is_taxable/is_avgift_basis/is_vacation_basis=true, + // is_gross_deduction/is_net_deduction=false, sort_order=0, all of which + // updatePayslipLine spreads straight into .update(). Correcting the amount + // on a net deduction line would silently turn it into a taxable earning. + const validation = await validateBody(request, sparsePatchBody(UpdateSalaryLineItemSchema)) if (!validation.success) return validation.response + if (Object.keys(validation.data).length === 0) { + return NextResponse.json({ error: 'Inget att uppdatera' }, { status: 400 }) + } + const result = await updatePayslipLine(supabase, { companyId, salaryRunId: id, diff --git a/app/api/salary/runs/[id]/route.ts b/app/api/salary/runs/[id]/route.ts index 3d81a532..2aa2f43f 100644 --- a/app/api/salary/runs/[id]/route.ts +++ b/app/api/salary/runs/[id]/route.ts @@ -2,7 +2,7 @@ import { NextResponse } from 'next/server' import { ensureInitialized } from '@/lib/init' import { withRouteContext } from '@/lib/api/with-route-context' import { formatRedovisare } from '@/lib/skatteverket/format' -import { decryptPersonnummer, maskPersonnummer } from '@/lib/salary/personnummer' +import { maskEmployeeForResponse } from '@/lib/salary/personnummer' import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message' ensureInitialized() @@ -41,7 +41,7 @@ export const GET = withRouteContext<{ params: Promise<{ id: string }> }>( // client offer an already-added employee and get a confusing 409. supabase .from('salary_run_employees') - .select('*, employee:employees(id, first_name, last_name, personnummer, personnummer_last4, employment_type, default_dimensions), line_items:salary_line_items(*)') + .select('*, employee:employees(id, first_name, last_name, personnummer, employment_type, default_dimensions), line_items:salary_line_items(*)') .eq('salary_run_id', id) .order('created_at'), // Skatteverket arbetsgivare ID for AGI submission. @@ -156,12 +156,11 @@ export const GET = withRouteContext<{ params: Promise<{ id: string }> }>( previous_run: previousRun, corrected_by_run_id: correctedByRunId, payslip_deliveries_summary: deliveriesSummary, + // maskEmployeeForResponse drops the ciphertext and personnummer_last4 + // from every embedded employee, exposing only `personnummer_masked`. employees: (employees || []).map(emp => ({ ...emp, - employee: emp.employee ? { - ...emp.employee, - personnummer: maskPersonnummer(decryptPersonnummer(emp.employee.personnummer)), - } : null, + employee: emp.employee ? maskEmployeeForResponse(emp.employee) : null, })), }, }) diff --git a/app/api/settings/booking-templates/[id]/__tests__/route.test.ts b/app/api/settings/booking-templates/[id]/__tests__/route.test.ts new file mode 100644 index 00000000..6741060e --- /dev/null +++ b/app/api/settings/booking-templates/[id]/__tests__/route.test.ts @@ -0,0 +1,284 @@ +/** + * Tests for PUT /api/settings/booking-templates/[id]. + * + * The validated body is spread straight into .update(), so the write set must + * be exactly the fields the caller named. UpdateBookingTemplateSchema carries + * no .default() today, so this route was never exploitable; the tests lock in + * the property so a future .default() on the schema cannot turn a rename into a + * silent rewrite of the template's lines, category, or entity_type. + */ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { NextResponse } from 'next/server' +import { createMockRequest, parseJsonResponse } from '@/tests/helpers' + +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 { PUT } from '../route' + +interface CapturedCall { + method: string + args: unknown[] +} + +/** Chainable builder recording calls; resolves queued {data,error} per from(). */ +function createCapturingSupabase(results: { data?: unknown; error?: unknown }[]) { + const calls: CapturedCall[] = [] + let idx = 0 + const makeBuilder = () => { + const result = results[idx++] ?? { data: null, error: null } + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const b: any = {} + for (const m of ['select', 'eq', 'update', 'maybeSingle', 'single']) { + b[m] = (...args: unknown[]) => { + calls.push({ method: m, args }) + return b + } + } + b.then = (resolve: (v: unknown) => void) => + resolve({ data: result.data ?? null, error: result.error ?? null }) + return b + } + return { + supabase: { + from: (table: string) => { + calls.push({ method: 'from', args: [table] }) + return makeBuilder() + }, + }, + calls, + } +} + +const idParams = { params: Promise.resolve({ id: 'tpl-1' }) } + +const validLines = [ + { account: '5010', label: 'Hyra', side: 'debit', type: 'business' }, + { account: '1930', label: 'Bank', side: 'credit', type: 'settlement' }, +] + +/** The pre-update fetch result for a template owned by the active company. */ +const OWN_TEMPLATE = { + data: { id: 'tpl-1', company_id: 'company-1', team_id: null, is_system: false }, +} + +beforeEach(() => { + vi.clearAllMocks() + requireWriteMock.mockResolvedValue({ ok: true }) +}) + +function auth(supabase: unknown) { + requireAuthMock.mockResolvedValue({ user: { id: 'user-1' }, supabase, error: null }) +} + +function updatePayload(calls: CapturedCall[]): Record { + return calls.find((c) => c.method === 'update')?.args[0] as Record +} + +describe('PUT /api/settings/booking-templates/[id]', () => { + it('returns 401 when not authenticated', async () => { + requireAuthMock.mockResolvedValue({ + user: null, + supabase: {}, + error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }), + }) + const req = createMockRequest('/api/settings/booking-templates/tpl-1', { + method: 'PUT', + body: { name: 'Nytt namn' }, + }) + expect((await PUT(req, idParams)).status).toBe(401) + }) + + it('returns 403 for a viewer', async () => { + const { supabase } = createCapturingSupabase([]) + auth(supabase) + requireWriteMock.mockResolvedValue({ + ok: false, + response: NextResponse.json({ error: 'Forbidden' }, { status: 403 }), + }) + const req = createMockRequest('/api/settings/booking-templates/tpl-1', { + method: 'PUT', + body: { name: 'Nytt namn' }, + }) + expect((await PUT(req, idParams)).status).toBe(403) + }) + + it('returns 400 for an invalid body', async () => { + const { supabase } = createCapturingSupabase([]) + auth(supabase) + const req = createMockRequest('/api/settings/booking-templates/tpl-1', { + method: 'PUT', + // A template needs at least two lines (double entry). + body: { lines: [validLines[0]] }, + }) + const { status } = await parseJsonResponse(await PUT(req, idParams)) + expect(status).toBe(400) + }) + + it('returns 500 when the update fails', async () => { + const { supabase } = createCapturingSupabase([OWN_TEMPLATE, { error: { message: 'boom' } }]) + auth(supabase) + const req = createMockRequest('/api/settings/booking-templates/tpl-1', { + method: 'PUT', + body: { name: 'Nytt namn' }, + }) + const { status } = await parseJsonResponse(await PUT(req, idParams)) + expect(status).toBe(500) + }) + + it('returns 404 when the template does not exist', async () => { + // .single() used to turn zero rows into a PGRST116 ERROR, so not-found + // surfaced as 500 and the 404 branch was dead. + const { supabase, calls } = createCapturingSupabase([{ data: null }]) + auth(supabase) + const req = createMockRequest('/api/settings/booking-templates/tpl-1', { + method: 'PUT', + body: { name: 'Nytt namn' }, + }) + const { status, body } = await parseJsonResponse<{ error: string }>(await PUT(req, idParams)) + expect(status).toBe(404) + expect(body.error).toBe('Mallen hittades inte') + expect(calls.find((c) => c.method === 'update')).toBeUndefined() + }) + + it("returns 404 for another company's template without touching it", async () => { + // RLS is membership-wide: a user who belongs to companies A and B can see + // B's templates while acting in A. The route must scope to the ACTIVE + // company, so B's template reads as not-found here and no update runs. + const { supabase, calls } = createCapturingSupabase([ + { data: { id: 'tpl-1', company_id: 'company-2', team_id: null, is_system: false } }, + ]) + auth(supabase) + const req = createMockRequest('/api/settings/booking-templates/tpl-1', { + method: 'PUT', + body: { name: 'Nytt namn' }, + }) + const { status, body } = await parseJsonResponse<{ error: string }>(await PUT(req, idParams)) + expect(status).toBe(404) + expect(body.error).toBe('Mallen hittades inte') + expect(calls.find((c) => c.method === 'update')).toBeUndefined() + }) + + it('returns 404 for a system template', async () => { + const { supabase, calls } = createCapturingSupabase([ + { data: { id: 'tpl-1', company_id: null, team_id: null, is_system: true } }, + ]) + auth(supabase) + const req = createMockRequest('/api/settings/booking-templates/tpl-1', { + method: 'PUT', + body: { name: 'Nytt namn' }, + }) + const { status } = await parseJsonResponse(await PUT(req, idParams)) + expect(status).toBe(404) + expect(calls.find((c) => c.method === 'update')).toBeUndefined() + }) + + it("updates a team template shared with the active company's team", async () => { + // Team templates carry company_id NULL: a blind company_id filter would + // have broken them. The scope check goes through the company's team_id. + const { supabase, calls } = createCapturingSupabase([ + { data: { id: 'tpl-1', company_id: null, team_id: 'team-1', is_system: false } }, + { data: { team_id: 'team-1' } }, // companies lookup + { data: { id: 'tpl-1', name: 'Nytt namn' } }, // update + ]) + auth(supabase) + const req = createMockRequest('/api/settings/booking-templates/tpl-1', { + method: 'PUT', + body: { name: 'Nytt namn' }, + }) + const { status } = await parseJsonResponse(await PUT(req, idParams)) + expect(status).toBe(200) + expect(updatePayload(calls)).toEqual({ name: 'Nytt namn' }) + }) + + it("returns 404 for another team's template", async () => { + const { supabase, calls } = createCapturingSupabase([ + { data: { id: 'tpl-1', company_id: null, team_id: 'team-other', is_system: false } }, + { data: { team_id: 'team-1' } }, // companies lookup + ]) + auth(supabase) + const req = createMockRequest('/api/settings/booking-templates/tpl-1', { + method: 'PUT', + body: { name: 'Nytt namn' }, + }) + const { status } = await parseJsonResponse(await PUT(req, idParams)) + expect(status).toBe(404) + expect(calls.find((c) => c.method === 'update')).toBeUndefined() + }) + + it('updates the template on the happy path', async () => { + const { supabase } = createCapturingSupabase([ + OWN_TEMPLATE, + { data: { id: 'tpl-1', name: 'Nytt namn' } }, + ]) + auth(supabase) + const req = createMockRequest('/api/settings/booking-templates/tpl-1', { + method: 'PUT', + body: { name: 'Nytt namn' }, + }) + const { status, body } = await parseJsonResponse<{ data: { name: string } }>( + await PUT(req, idParams), + ) + expect(status).toBe(200) + expect(body.data.name).toBe('Nytt namn') + }) + + it('writes only the field the caller named', async () => { + const { supabase, calls } = createCapturingSupabase([OWN_TEMPLATE, { data: { id: 'tpl-1' } }]) + auth(supabase) + const req = createMockRequest('/api/settings/booking-templates/tpl-1', { + method: 'PUT', + body: { name: 'Nytt namn' }, + }) + expect((await PUT(req, idParams)).status).toBe(200) + expect(Object.keys(updatePayload(calls))).toEqual(['name']) + }) + + it('leaves lines, category and entity_type alone on a name-only update', async () => { + const { supabase, calls } = createCapturingSupabase([OWN_TEMPLATE, { data: { id: 'tpl-1' } }]) + auth(supabase) + const req = createMockRequest('/api/settings/booking-templates/tpl-1', { + method: 'PUT', + body: { name: 'Nytt namn' }, + }) + await PUT(req, idParams) + const payload = updatePayload(calls) + for (const field of ['lines', 'category', 'entity_type', 'description']) { + expect(payload, `${field} must not be written by a name-only PUT`).not.toHaveProperty(field) + } + }) + + it('takes a supplied lines array wholesale', async () => { + const { supabase, calls } = createCapturingSupabase([OWN_TEMPLATE, { data: { id: 'tpl-1' } }]) + auth(supabase) + const req = createMockRequest('/api/settings/booking-templates/tpl-1', { + method: 'PUT', + body: { lines: validLines }, + }) + expect((await PUT(req, idParams)).status).toBe(200) + expect(updatePayload(calls)).toEqual({ lines: validLines }) + }) + + it('drops unknown keys instead of forwarding them to the update', async () => { + const { supabase, calls } = createCapturingSupabase([OWN_TEMPLATE, { data: { id: 'tpl-1' } }]) + auth(supabase) + const req = createMockRequest('/api/settings/booking-templates/tpl-1', { + method: 'PUT', + body: { name: 'Nytt namn', is_system: false, company_id: 'other' }, + }) + expect((await PUT(req, idParams)).status).toBe(200) + expect(Object.keys(updatePayload(calls))).toEqual(['name']) + }) +}) diff --git a/app/api/settings/booking-templates/[id]/route.ts b/app/api/settings/booking-templates/[id]/route.ts index 3abad843..87b21728 100644 --- a/app/api/settings/booking-templates/[id]/route.ts +++ b/app/api/settings/booking-templates/[id]/route.ts @@ -2,6 +2,7 @@ import { NextResponse } from 'next/server' import { withRouteContext } from '@/lib/api/with-route-context' import { z } from 'zod' import { validateBody } from '@/lib/api/validate' +import { sparsePatchBody } from '@/lib/api/sparse-patch' import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message' const BookingTemplateLineSchema = z.object({ @@ -27,28 +28,77 @@ const UpdateBookingTemplateSchema = z.object({ /** * PUT /api/settings/booking-templates/[id] - * Update a non-system template. + * Update a non-system template belonging to the active company (or its team). */ export const PUT = withRouteContext<{ params: Promise<{ id: string }> }>( 'booking_template.update', async (request, ctx, { params }) => { const { id } = await params - const { supabase } = ctx + const { supabase, companyId } = ctx - const result = await validateBody(request, UpdateBookingTemplateSchema) + // result.data is spread straight into .update(), so it must carry only the + // fields the caller named. UpdateBookingTemplateSchema has no .default() + // today; sparsePatchBody makes that a structural property rather than a + // thing a future edit to the schema can quietly undo. + const result = await validateBody(request, sparsePatchBody(UpdateBookingTemplateSchema)) if (!result.success) return result.response - // RLS prevents updating system templates + // Read the template first so a missing row is a clean 404: .single() on + // the update chain reported zero rows as a PGRST116 ERROR, which fell into + // the 500 branch and left the 404 below unreachable. + const { data: existing, error: fetchError } = await supabase + .from('booking_template_library') + .select('id, company_id, team_id, is_system') + .eq('id', id) + .maybeSingle() + + if (fetchError) { + return NextResponse.json({ error: getUserErrorMessage(fetchError) }, { status: 500 }) + } + // System templates are never editable (customize duplicates them instead); + // reported as not-found rather than forbidden, matching the RLS view. + if (!existing || existing.is_system) { + return NextResponse.json({ error: 'Mallen hittades inte' }, { status: 404 }) + } + + // Defense in depth alongside RLS (which is membership-wide): only + // templates scoped to the ACTIVE company, or shared with its team, are + // editable in this context. Without this, a user who belongs to several + // companies could edit company B's template while acting in company A. + // Team templates carry company_id NULL, so a plain company_id filter on + // the update would break them: check the applicable scope explicitly. + if (existing.company_id) { + if (existing.company_id !== companyId) { + return NextResponse.json({ error: 'Mallen hittades inte' }, { status: 404 }) + } + } else if (existing.team_id) { + const { data: company } = await supabase + .from('companies') + .select('team_id') + .eq('id', companyId) + .maybeSingle() + if (!company?.team_id || company.team_id !== existing.team_id) { + return NextResponse.json({ error: 'Mallen hittades inte' }, { status: 404 }) + } + } else { + // Non-system template with neither company nor team scope should not + // exist; refuse rather than let anyone edit an orphan. + return NextResponse.json({ error: 'Mallen hittades inte' }, { status: 404 }) + } + + // RLS prevents updating system templates; is_system is re-checked here so + // the guard holds even on a service-role client. const { data, error } = await supabase .from('booking_template_library') .update(result.data) .eq('id', id) .eq('is_system', false) .select() - .single() + .maybeSingle() if (error) return NextResponse.json({ error: getUserErrorMessage(error) }, { status: 500 }) - if (!data) return NextResponse.json({ error: 'Template not found' }, { status: 404 }) + // The row can vanish between the scope check and the update. + if (!data) return NextResponse.json({ error: 'Mallen hittades inte' }, { status: 404 }) return NextResponse.json({ data }) }, diff --git a/app/api/supplier-invoices/[id]/mark-paid/__tests__/duplicate-guard-band.test.ts b/app/api/supplier-invoices/[id]/mark-paid/__tests__/duplicate-guard-band.test.ts new file mode 100644 index 00000000..742a7325 --- /dev/null +++ b/app/api/supplier-invoices/[id]/mark-paid/__tests__/duplicate-guard-band.test.ts @@ -0,0 +1,272 @@ +/** + * The duplicate-payment guard's plus-minus 2 % band and the column it is + * applied to must share a unit. + * + * `paymentAmount` (from `supplier_invoices.remaining_amount` or `body.amount`) + * is denominated in the INVOICE's currency; `transactions.amount` is + * denominated in the BANK ROW's currency. At roughly 11,50 SEK/EUR a band built + * around a EUR figure and applied to a kronor column is off by a factor of + * eleven: it selects nothing (a second verifikat for one affärshändelse then + * posts unopposed, BFL 5 kap 1-2 §) or it selects an unrelated row. + * + * These tests assert the FILTER VALUES the route actually sends. The shared + * `createQueuedMockSupabase` helper drops filter arguments, so an assertion on + * the response shape alone passes against the pre-fix band as long as the + * queued page happens to be empty. The whole finding lives in the arguments. + */ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import type { SupabaseClient } from '@supabase/supabase-js' +import { + createMockRequest, + parseJsonResponse, + createMockRouteParams, + makeSupplierInvoice, + makeSupplier, +} from '@/tests/helpers' + +/** One recorded query: which table, and every filter argument it received. */ +type RecordedQuery = { table: string; calls: Record } + +/** + * Chainable Supabase stub that RECORDS each query's filter arguments and serves + * one queued page per table, in call order. Keying the pages by table (rather + * than by a single global queue) keeps the assertions stable when an unrelated + * lookup is added to the route. + */ +function createRecordingSupabase(pages: Record>) { + const queries: RecordedQuery[] = [] + const queues: Record> = {} + for (const [table, list] of Object.entries(pages)) { + queues[table] = list.map((r) => ({ data: r.data ?? null, error: r.error ?? null })) + } + + const from = (table: string) => { + const result = queues[table]?.shift() ?? { data: null, error: null } + const calls: Record = {} + queries.push({ table, calls }) + const chain: unknown = new Proxy( + {}, + { + get(_target, prop: string) { + if (prop === 'then') { + return (resolve: (v: unknown) => void) => resolve(result) + } + return (...args: unknown[]) => { + ;(calls[prop] ??= []).push(args) + return chain + } + }, + }, + ) + return chain + } + + const supabase = { + from: vi.fn(from), + rpc: vi.fn(() => from('__rpc')), + auth: { getUser: vi.fn() }, + } + + return { supabase, queries } +} + +const recording = createRecordingSupabase({}) +let mockSupabase = recording.supabase +let recorded: RecordedQuery[] = recording.queries + +vi.mock('@/lib/supabase/server', () => ({ + createClient: () => Promise.resolve(mockSupabase as unknown as SupabaseClient), +})) + +vi.mock('@/lib/init', () => ({ + ensureInitialized: vi.fn(), +})) + +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 }), +})) + +const mockCreateSupplierInvoicePaymentEntry = vi.fn() +const mockCreateSupplierInvoiceCashEntry = vi.fn() +vi.mock('@/lib/bookkeeping/supplier-invoice-entries', () => ({ + createSupplierInvoicePaymentEntry: (...args: unknown[]) => + mockCreateSupplierInvoicePaymentEntry(...args), + createSupplierInvoiceCashEntry: (...args: unknown[]) => + mockCreateSupplierInvoiceCashEntry(...args), +})) + +vi.mock('@/lib/core/documents/document-service', async () => { + const actual = await vi.importActual( + '@/lib/core/documents/document-service', + ) + return { ...actual, linkToJournalEntry: vi.fn() } +}) + +import { eventBus } from '@/lib/events' +import { POST } from '../route' + +/** Install a fresh recording client with the given per-table pages. */ +function useSupabase(pages: Record>) { + const next = createRecordingSupabase(pages) + mockSupabase = next.supabase + recorded = next.queries + mockSupabase.auth.getUser.mockResolvedValue({ data: { user: { id: 'user-1' } } }) + return next +} + +const bankRow = (over: Record = {}) => ({ + id: 'tx-99', + date: '2026-05-10', + amount: -12500, + description: 'Betalning Leverantör AB', + merchant_name: 'Leverantör AB', + currency: 'SEK', + amount_sek: null, + exchange_rate: null, + ...over, +}) + +const markPaid = async () => { + const request = createMockRequest('/api/supplier-invoices/si-1/mark-paid', { + method: 'POST', + body: {}, + }) + return POST(request, createMockRouteParams({ id: 'si-1' })) +} + +const txQueries = () => recorded.filter((q) => q.table === 'transactions') + +describe('POST /api/supplier-invoices/[id]/mark-paid: duplicate-guard band units', () => { + beforeEach(() => { + vi.clearAllMocks() + eventBus.clear() + }) + + it('SEK invoice: one kronor-banded sweep, byte-identical to the pre-fix query', async () => { + useSupabase({ + supplier_invoices: [ + { + data: makeSupplierInvoice({ + id: 'si-1', + status: 'approved', + currency: 'SEK', + total: 12500, + total_sek: 12500, + exchange_rate: null, + remaining_amount: 12500, + paid_amount: 0, + supplier: makeSupplier({ name: 'Leverantör AB' }), + items: [], + }), + }, + ], + transactions: [{ data: [bankRow()] }], + }) + + const response = await markPaid() + const { status, body } = await parseJsonResponse<{ error: { code: string } }>(response) + expect(status).toBe(409) + expect(body.error.code).toBe('SI_PAID_LIKELY_DUPLICATE') + + // A SEK-only company must still see exactly one query with the old band. + expect(txQueries()).toHaveLength(1) + const q = txQueries()[0].calls + expect(q.gte).toContainEqual(['amount', -12750]) + expect(q.lte).toContainEqual(['amount', -12250]) + // Band is kronor, so the rows it is applied to must be kronor. NULL is + // kronor too: transactions.currency is nullable with DEFAULT 'SEK'. + expect(q.or).toEqual([['currency.is.null,currency.eq.SEK']]) + // The per-row re-check reads these columns; a narrow projection would make + // it read `undefined` and silently default every row to SEK. + expect(q.select?.[0][0]).toContain('currency') + expect(q.select?.[0][0]).toContain('amount_sek') + expect(q.select?.[0][0]).toContain('exchange_rate') + }) + + it('EUR invoice with a rate: EUR rows banded in EUR, kronor rows banded in kronor', async () => { + useSupabase({ + supplier_invoices: [ + { + data: makeSupplierInvoice({ + id: 'si-1', + status: 'approved', + currency: 'EUR', + total: 1000, + total_sek: 11500, + exchange_rate: 11.5, + remaining_amount: 1000, + paid_amount: 0, + supplier: makeSupplier({ name: 'Leverantör AB' }), + items: [], + }), + }, + ], + // EUR sweep finds nothing; the kronor sweep finds the row that actually + // paid the invoice, at the converted magnitude. + transactions: [{ data: [] }, { data: [bankRow({ amount: -11500 })] }], + }) + + const response = await markPaid() + const { status, body } = await parseJsonResponse<{ + error: { code: string; details: { candidates: Array<{ id: string }> } } + }>(response) + expect(status).toBe(409) + expect(body.error.code).toBe('SI_PAID_LIKELY_DUPLICATE') + expect(body.error.details.candidates.map((c) => c.id)).toEqual(['tx-99']) + + expect(txQueries()).toHaveLength(2) + const eur = txQueries()[0].calls + expect(eur.or).toEqual([['currency.eq.EUR']]) + expect(eur.gte).toContainEqual(['amount', -1020]) + expect(eur.lte).toContainEqual(['amount', -980]) + + const sek = txQueries()[1].calls + expect(sek.or).toEqual([['currency.is.null,currency.eq.SEK']]) + // 1 000 EUR x 11,50 = 11 500 kr, banded plus-minus 2 %. The pre-fix query + // asked kronor rows for -1 020..-980 and matched nothing. + expect(sek.gte).toContainEqual(['amount', -11730]) + expect(sek.lte).toContainEqual(['amount', -11270]) + }) + + it('EUR invoice with no stored rate: no kronor sweep is invented', async () => { + useSupabase({ + supplier_invoices: [ + { + data: makeSupplierInvoice({ + id: 'si-1', + status: 'approved', + currency: 'EUR', + total: 1000, + total_sek: null, + exchange_rate: null, + remaining_amount: 1000, + paid_amount: 0, + supplier: makeSupplier({ name: 'Leverantör AB' }), + items: [], + }), + }, + // The status flip after the guard lets the payment through. + { data: [{ id: 'si-1' }] }, + ], + // The single EUR sweep returns a kronor row anyway (PostgREST `.or()` + // composes with the other filters and this stub ignores them): the + // per-row re-check must drop it rather than read -1000 kr as -1000 EUR. + transactions: [{ data: [bankRow({ amount: -1000, currency: 'SEK' })] }], + company_settings: [{ data: { accounting_method: 'accrual' } }], + }) + mockCreateSupplierInvoicePaymentEntry.mockResolvedValue({ id: 'je-1' }) + + const response = await markPaid() + const { status, body } = await parseJsonResponse<{ success: boolean }>(response) + + expect(status).toBe(200) + expect(body.success).toBe(true) + expect(txQueries()).toHaveLength(1) + expect(txQueries()[0].calls.or).toEqual([['currency.eq.EUR']]) + }) +}) diff --git a/app/api/supplier-invoices/[id]/mark-paid/__tests__/route.test.ts b/app/api/supplier-invoices/[id]/mark-paid/__tests__/route.test.ts index 2851d15c..6d1030da 100644 --- a/app/api/supplier-invoices/[id]/mark-paid/__tests__/route.test.ts +++ b/app/api/supplier-invoices/[id]/mark-paid/__tests__/route.test.ts @@ -405,6 +405,120 @@ describe('POST /api/supplier-invoices/[id]/mark-paid', () => { expect(mockCreateSupplierInvoicePaymentEntry).not.toHaveBeenCalled() }) + // ── Duplicate-guard currency: the plus-minus 2 % band and the column it is + // applied to must share a unit. `remaining_amount` is invoice currency, + // `transactions.amount` is the bank row's currency; at ~11,50 SEK/EUR a EUR + // band on a kronor column is off by a factor of eleven. + const eurInvoice = (over: Record = {}) => + makeSupplierInvoice({ + id: 'si-1', + status: 'approved', + currency: 'EUR', + total: 1000, + total_sek: 11500, + exchange_rate: 11.5, + remaining_amount: 1000, + paid_amount: 0, + supplier: makeSupplier(), + items: [], + ...over, + }) + + const bankRow = (over: Record = {}) => ({ + id: 'tx-99', + date: '2026-05-10', + amount: -1000, + description: 'Betalning Leverantör AB', + merchant_name: 'Leverantör AB', + currency: 'SEK', + amount_sek: null, + exchange_rate: null, + ...over, + }) + + it('EUR invoice: a 1 000 SEK bank row is not treated as the payment for 1 000 EUR', async () => { + enqueue({ data: eurInvoice(), error: null }) + // Sweep 1 (EUR rows): nothing. Sweep 2 (kronor rows): a same-magnitude + // kronor row, which is exactly what the old EUR band selected. + enqueue({ data: [], error: null }) + enqueue({ data: [bankRow({ amount: -1000 })], error: null }) + enqueue({ data: { accounting_method: 'accrual' }, error: null }) + mockCreateSupplierInvoicePaymentEntry.mockResolvedValue({ id: 'je-1' }) + enqueue({ data: [{ id: 'si-1' }], error: null }) + enqueue({ data: null, error: null }) + + const request = createMockRequest('/api/supplier-invoices/si-1/mark-paid', { + method: 'POST', + body: {}, + }) + const response = await POST(request, createMockRouteParams({ id: 'si-1' })) + const { status, body } = await parseJsonResponse<{ success: boolean; status: string }>(response) + + expect(status).toBe(200) + expect(body.success).toBe(true) + expect(mockCreateSupplierInvoicePaymentEntry).toHaveBeenCalled() + }) + + it('EUR invoice with a rate: the 11 500 SEK bank row that paid it IS flagged', async () => { + enqueue({ data: eurInvoice(), error: null }) + enqueue({ data: [], error: null }) + enqueue({ data: [bankRow({ amount: -11500 })], error: null }) + + const request = createMockRequest('/api/supplier-invoices/si-1/mark-paid', { + method: 'POST', + body: {}, + }) + const response = await POST(request, createMockRouteParams({ id: 'si-1' })) + const { status, body } = await parseJsonResponse<{ + error: { code: string; details: { candidates: Array<{ id: string }> } } + }>(response) + + expect(status).toBe(409) + expect(body.error.code).toBe('SI_PAID_LIKELY_DUPLICATE') + expect(body.error.details.candidates.map((c) => c.id)).toEqual(['tx-99']) + expect(mockCreateSupplierInvoicePaymentEntry).not.toHaveBeenCalled() + }) + + it('EUR invoice with no stored rate: kronor rows are excluded, never compared raw', async () => { + enqueue({ data: eurInvoice({ total_sek: null, exchange_rate: null }), error: null }) + // Only the EUR sweep can be planned; the kronor row it returns here cannot + // be brought into a shared unit and must be dropped, not read as kronor. + enqueue({ data: [bankRow({ amount: -1000 })], error: null }) + enqueue({ data: { accounting_method: 'accrual' }, error: null }) + mockCreateSupplierInvoicePaymentEntry.mockResolvedValue({ id: 'je-1' }) + enqueue({ data: [{ id: 'si-1' }], error: null }) + enqueue({ data: null, error: null }) + + const request = createMockRequest('/api/supplier-invoices/si-1/mark-paid', { + method: 'POST', + body: {}, + }) + const response = await POST(request, createMockRouteParams({ id: 'si-1' })) + const { status, body } = await parseJsonResponse<{ success: boolean }>(response) + + expect(status).toBe(200) + expect(body.success).toBe(true) + }) + + it('EUR invoice: a 1 000 EUR bank row still matches in its own currency', async () => { + enqueue({ data: eurInvoice(), error: null }) + enqueue({ + data: [bankRow({ amount: -1000, currency: 'EUR', amount_sek: -11500 })], + error: null, + }) + enqueue({ data: [], error: null }) + + const request = createMockRequest('/api/supplier-invoices/si-1/mark-paid', { + method: 'POST', + body: {}, + }) + const response = await POST(request, createMockRouteParams({ id: 'si-1' })) + const { status, body } = await parseJsonResponse<{ error: { code: string } }>(response) + + expect(status).toBe(409) + expect(body.error.code).toBe('SI_PAID_LIKELY_DUPLICATE') + }) + it('proceeds when force=true even with candidates present', async () => { const supplier = makeSupplier() const invoice = makeSupplierInvoice({ diff --git a/app/api/supplier-invoices/[id]/mark-paid/route.ts b/app/api/supplier-invoices/[id]/mark-paid/route.ts index e7e5d10b..b953588f 100644 --- a/app/api/supplier-invoices/[id]/mark-paid/route.ts +++ b/app/api/supplier-invoices/[id]/mark-paid/route.ts @@ -18,6 +18,14 @@ import { DUPLICATE_DATE_WINDOW_DAYS, escapeLikePattern, } from '@/lib/invoices/duplicate-payment-guard' +import { + invoiceAmountSek, + magnitudesWithinTolerance, + normalizeCurrencyCode, + planAmountSweeps, + type ComparableAmount, +} from '@/lib/invoices/duplicate-guard-currency' +import { resolveTransactionAmountSek } from '@/lib/transactions/booking-duplicate-detection' import type { SupplierInvoice, SupplierInvoiceItem } from '@/types' import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message' @@ -86,30 +94,108 @@ export const POST = withRouteContext( }) } if (supplierName) { - const windowLow = Math.round(paymentAmount * (1 - DUPLICATE_AMOUNT_TOLERANCE_PCT) * 100) / 100 - const windowHigh = Math.round(paymentAmount * (1 + DUPLICATE_AMOUNT_TOLERANCE_PCT) * 100) / 100 + // Units: `paymentAmount` is denominated in the supplier invoice's + // currency (that is what `remaining_amount` and `body.amount` are), + // while `transactions.amount` is denominated in the bank row's own + // currency. The plus-minus tolerance band is therefore planned per + // currency and re-checked per row, so band and column always share a + // unit. A SEK invoice yields exactly one sweep with the band it had + // before, so a SEK-only company sees the identical single query. + const paymentCurrency = normalizeCurrencyCode(invoice.currency) + const reference: ComparableAmount = { + amount: paymentAmount, + currency: paymentCurrency, + sek: invoiceAmountSek({ + amount: paymentAmount, + currency: paymentCurrency, + total: invoice.total, + totalSek: invoice.total_sek, + exchangeRate: invoice.exchange_rate, + }), + } + const { sweeps, crossCurrencyUnverifiable } = planAmountSweeps( + reference, + DUPLICATE_AMOUNT_TOLERANCE_PCT, + ) + if (crossCurrencyUnverifiable) { + // A foreign invoice with no stored rate cannot be stated in kronor, + // so kronor bank rows can only be excluded, never compared raw + // (a raw compare reads 1 000 EUR as 1 000 kr). Same-currency rows are + // still swept. Logged so the blind spot is visible in audit rather + // than passing as a clean "no duplicate". + opLog.warn('duplicate-payment guard: cross-currency candidates not evaluated', { + reason: 'invoice_missing_sek_value', + currency: paymentCurrency, + supplierInvoiceId: id, + }) + } + const dateMs = new Date(paymentDate).getTime() const dateLow = new Date(dateMs - DUPLICATE_DATE_WINDOW_DAYS * 24 * 3600 * 1000).toISOString().split('T')[0] const dateHigh = new Date(dateMs + DUPLICATE_DATE_WINDOW_DAYS * 24 * 3600 * 1000).toISOString().split('T')[0] const escapedSupplierName = escapeLikePattern(supplierName) - const { data: candidates } = await supabase - .from('transactions') - .select('id, date, amount, description, merchant_name') - .eq('company_id', companyId!) - .eq('is_business', true) - .is('supplier_invoice_id', null) - .is('invoice_id', null) - .lt('amount', 0) - .gte('amount', -windowHigh) - .lte('amount', -windowLow) - .gte('date', dateLow) - .lte('date', dateHigh) - .ilike('merchant_name', `%${escapedSupplierName}%`) - .order('date', { ascending: false }) - .limit(5) + type CandidateRow = { + id: string + date: string + amount: number + description: string | null + merchant_name: string | null + currency: string | null + amount_sek: number | null + exchange_rate: number | null + } - if (candidates && candidates.length > 0) { + const sweepResults = await Promise.all( + sweeps.map((sweep) => + supabase + .from('transactions') + .select( + 'id, date, amount, description, merchant_name, currency, amount_sek, exchange_rate', + ) + .eq('company_id', companyId!) + .eq('is_business', true) + .is('supplier_invoice_id', null) + .is('invoice_id', null) + .lt('amount', 0) + .or(sweep.currencyFilter) + .gte('amount', -sweep.high) + .lte('amount', -sweep.low) + .gte('date', dateLow) + .lte('date', dateHigh) + .ilike('merchant_name', `%${escapedSupplierName}%`) + .order('date', { ascending: false }) + .limit(5), + ), + ) + + const byId = new Map() + for (const res of sweepResults) { + for (const row of (res.data ?? []) as CandidateRow[]) { + if (!byId.has(row.id)) byId.set(row.id, row) + } + } + const candidates = Array.from(byId.values()) + .filter((c) => + magnitudesWithinTolerance( + reference, + { + amount: Number(c.amount), + currency: normalizeCurrencyCode(c.currency), + sek: resolveTransactionAmountSek({ + amount: c.amount, + currency: c.currency, + amount_sek: c.amount_sek, + exchange_rate: c.exchange_rate, + }), + }, + DUPLICATE_AMOUNT_TOLERANCE_PCT, + ), + ) + .sort((a, b) => (a.date < b.date ? 1 : a.date > b.date ? -1 : 0)) + .slice(0, 5) + + if (candidates.length > 0) { return errorResponseFromCode('SI_PAID_LIKELY_DUPLICATE', opLog, { requestId, details: { diff --git a/app/api/supplier-invoices/__tests__/route.test.ts b/app/api/supplier-invoices/__tests__/route.test.ts index 9d6a285e..ad27b9cf 100644 --- a/app/api/supplier-invoices/__tests__/route.test.ts +++ b/app/api/supplier-invoices/__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' import { createMockRequest, parseJsonResponse, @@ -44,6 +44,16 @@ vi.mock('@/lib/core/documents/document-service', () => ({ linkToJournalEntry: (...args: unknown[]) => mockLinkToJournalEntry(...args), })) +// Riksbanken is the only external dependency of the new server-side rate +// lookup. Spread the real module so anything else importing from it (e.g. +// convertToSEK) keeps working. +const mockFetchExchangeRate = vi.fn() +vi.mock('@/lib/currency/riksbanken', async () => { + const actual = + await vi.importActual('@/lib/currency/riksbanken') + return { ...actual, fetchExchangeRate: (...args: unknown[]) => mockFetchExchangeRate(...args) } +}) + import { eventBus } from '@/lib/events' import { GET, POST } from '../route' @@ -904,3 +914,231 @@ describe('POST /api/supplier-invoices', () => { expect(mockCreateSupplierInvoiceRegistrationEntry).not.toHaveBeenCalled() }) }) + +// ── Exchange rate + SEK amounts ───────────────────────────────────────────── +// The queued Supabase mock is a bare Proxy, so the only way to assert what was +// actually written is to record the argument handed to `.insert()`. The route +// echoes back the enqueued fixture row, not its own payload. + +type InsertRecord = { table: string; payload: Record } + +function wrapCapturing(chain: unknown, table: string, sink: InsertRecord[]): unknown { + return new Proxy( + {}, + { + get(_target, prop) { + const inner = (chain as Record)[prop as string] + if (prop === 'then') return inner + return (...args: unknown[]) => { + if ( + prop === 'insert' && + args[0] && + typeof args[0] === 'object' && + !Array.isArray(args[0]) + ) { + sink.push({ table, payload: args[0] as Record }) + } + return wrapCapturing((inner as (...a: unknown[]) => unknown)(...args), table, sink) + } + }, + }, + ) +} + +describe('POST /api/supplier-invoices: exchange rate + SEK amounts', () => { + const mockUser = { id: 'user-1', email: 'test@test.se' } + const captured: InsertRecord[] = [] + let baseFrom: (...args: unknown[]) => unknown + + const supplierInvoiceInsert = () => + captured.find((c) => c.table === 'supplier_invoices')?.payload + + function enqueueHappyPath() { + enqueue({ data: makeSupplier({ id: VALID_UUID }), error: null }) // supplier lookup + enqueue({ data: 7 }) // get_next_arrival_number + enqueue({ data: makeSupplierInvoice({ id: 'si-fx' }), error: null }) // insert invoice + enqueue({ data: [], error: null }) // insert items + enqueue({ data: { accounting_method: 'cash' }, error: null }) // company_settings + } + + function body(overrides: Record = {}) { + return { + supplier_id: VALID_UUID, + supplier_invoice_number: 'LF-FX', + invoice_date: '2024-06-01', + due_date: '2024-07-01', + items: [ + { description: 'Molntjänst', amount: 10000, account_number: '6540', vat_rate: 0.25 }, + ], + ...overrides, + } + } + + beforeEach(() => { + vi.clearAllMocks() + reset() + eventBus.clear() + captured.length = 0 + mockSupabase.auth.getUser.mockResolvedValue({ data: { user: mockUser } }) + mockFetchExchangeRate.mockReset() + baseFrom = mockSupabase.from.getMockImplementation() as (...args: unknown[]) => unknown + mockSupabase.from.mockImplementation((table: string) => + wrapCapturing(baseFrom(table), table, captured), + ) + }) + + afterEach(() => { + mockSupabase.from.mockImplementation(baseFrom) + }) + + it('populates total_sek for an ordinary SEK invoice and never asks for a rate', async () => { + enqueueHappyPath() + + const response = await POST( + createMockRequest('/api/supplier-invoices', { method: 'POST', body: body() }), + ) + const { status } = await parseJsonResponse(response) + + expect(status).toBe(200) + const payload = supplierInvoiceInsert() + expect(payload).toBeDefined() + // total_sek used to be NULL for every SEK invoice because the writer gated + // it on an exchange rate existing. A SEK invoice has none by definition. + expect(payload!.subtotal_sek).toBe(10000) + expect(payload!.vat_amount_sek).toBe(2500) + expect(payload!.total_sek).toBe(12500) + expect(payload!.total_sek).toBe(payload!.total) + expect(payload!.exchange_rate).toBeNull() + expect(payload!.exchange_rate_date).toBeNull() + expect(mockFetchExchangeRate).not.toHaveBeenCalled() + }) + + it('uses a caller-supplied rate for a foreign invoice without fetching', async () => { + enqueueHappyPath() + + const response = await POST( + createMockRequest('/api/supplier-invoices', { + method: 'POST', + body: body({ currency: 'EUR', exchange_rate: 11.5 }), + }), + ) + const { status } = await parseJsonResponse(response) + + expect(status).toBe(200) + const payload = supplierInvoiceInsert() + expect(payload!.currency).toBe('EUR') + expect(payload!.exchange_rate).toBe(11.5) + expect(payload!.subtotal_sek).toBe(115000) + expect(payload!.vat_amount_sek).toBe(28750) + expect(payload!.total_sek).toBe(143750) + expect(mockFetchExchangeRate).not.toHaveBeenCalled() + }) + + it('fetches the invoice-date rate server-side when the caller omits one', async () => { + enqueueHappyPath() + mockFetchExchangeRate.mockResolvedValue({ currency: 'EUR', rate: 11.2, date: '2024-05-31' }) + + const response = await POST( + createMockRequest('/api/supplier-invoices', { + method: 'POST', + body: body({ currency: 'EUR' }), + }), + ) + const { status } = await parseJsonResponse(response) + + expect(status).toBe(200) + expect(mockFetchExchangeRate).toHaveBeenCalledTimes(1) + const [currencyArg, dateArg, clientArg] = mockFetchExchangeRate.mock.calls[0] + expect(currencyArg).toBe('EUR') + expect((dateArg as Date).toISOString().slice(0, 10)).toBe('2024-06-01') + // The supabase client must be passed through: that is what makes the + // shared exchange_rates cache a read-through cache instead of dead weight. + expect(clientArg).toBe(mockSupabase) + + const payload = supplierInvoiceInsert() + expect(payload!.exchange_rate).toBe(11.2) + // Observation date, not the requested date: Riksbanken publishes no rate + // on weekends and the lookback picks the previous banking day. + expect(payload!.exchange_rate_date).toBe('2024-05-31') + expect(payload!.total_sek).toBe(140000) + }) + + it('refuses the create with SI_FX_RATE_MISSING when no rate can be resolved', async () => { + enqueue({ data: makeSupplier({ id: VALID_UUID }), error: null }) + mockFetchExchangeRate.mockResolvedValue(null) + + const response = await POST( + createMockRequest('/api/supplier-invoices', { + method: 'POST', + body: body({ currency: 'USD' }), + }), + ) + const { status, body: responseBody } = await parseJsonResponse<{ + error: { code: string; details?: { currency?: string; invoice_date?: string } } + }>(response) + + expect(status).toBe(400) + expect(responseBody.error.code).toBe('SI_FX_RATE_MISSING') + expect(responseBody.error.details?.currency).toBe('USD') + // Nothing may be persisted, no ankomstnummer burned, no verifikat posted: + // an unconverted row would only fail again inside the booking path. + expect(supplierInvoiceInsert()).toBeUndefined() + expect(mockSupabase.rpc).not.toHaveBeenCalled() + expect(mockCreateSupplierInvoiceRegistrationEntry).not.toHaveBeenCalled() + }) + + // supplier_invoices_exchange_rate_check is `> 0 AND < 100000`. The schema + // used to have no ceiling, so 250000 sailed past validation, reached the + // constraint and came back to the user as an unexplained 500. + it('rejects an out-of-range exchange rate as a 400, not a constraint-violation 500', async () => { + const response = await POST( + createMockRequest('/api/supplier-invoices', { + method: 'POST', + body: body({ currency: 'EUR', exchange_rate: 250000 }), + }), + ) + const { status, body: responseBody } = await parseJsonResponse<{ + error: string + type: string + errors: Array<{ field: string; message: string }> + }>(response) + + expect(status).toBe(400) + expect(responseBody.type).toBe('validation_error') + const issue = responseBody.errors.find((e) => e.field === 'exchange_rate') + // Actionable, and Swedish: getErrorMessage passes a 'Valideringsfel:' + // summary through verbatim, so this is what the user actually reads. + expect(issue?.message).toContain('100 000') + expect(responseBody.error).toContain('Valideringsfel') + expect(supplierInvoiceInsert()).toBeUndefined() + expect(mockCreateSupplierInvoiceRegistrationEntry).not.toHaveBeenCalled() + }) + + it('rejects exactly 100000: the CHECK bound is exclusive, so the mirror is too', async () => { + const response = await POST( + createMockRequest('/api/supplier-invoices', { + method: 'POST', + body: body({ currency: 'EUR', exchange_rate: 100000 }), + }), + ) + const { status } = await parseJsonResponse(response) + + expect(status).toBe(400) + expect(supplierInvoiceInsert()).toBeUndefined() + }) + + it('accepts 99999.99, the largest rate the CHECK allows', async () => { + enqueueHappyPath() + + const response = await POST( + createMockRequest('/api/supplier-invoices', { + method: 'POST', + body: body({ currency: 'EUR', exchange_rate: 99999.99 }), + }), + ) + const { status } = await parseJsonResponse(response) + + expect(status).toBe(200) + expect(supplierInvoiceInsert()!.exchange_rate).toBe(99999.99) + }) +}) diff --git a/app/api/supplier-invoices/route.ts b/app/api/supplier-invoices/route.ts index 84f097d8..5530231f 100644 --- a/app/api/supplier-invoices/route.ts +++ b/app/api/supplier-invoices/route.ts @@ -13,6 +13,11 @@ import { validateBody } from '@/lib/api/validate' import { CreateSupplierInvoiceSchema } from '@/lib/api/schemas' import { withRouteContext } from '@/lib/api/with-route-context' import { errorResponse, errorResponseFromCode } from '@/lib/errors/get-structured-error' +import { + resolveSupplierInvoiceExchangeRate, + supplierInvoiceSekAmounts, +} from '@/lib/currency/supplier-invoice-rate' +import { roundOre } from '@/lib/money' import { linkToJournalEntry } from '@/lib/core/documents/document-service' import type { SupplierInvoice, SupplierInvoiceItem } from '@/types' import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message' @@ -181,6 +186,26 @@ export const POST = withRouteContext( entityType = company.entity_type as 'aktiebolag' | 'enskild_firma' } + // Resolve the exchange rate BEFORE the arrival-number sequence is touched: + // a foreign invoice we cannot translate must not burn an ankomstnummer. + // Shared with the v1 REST route and the inbox convert route so all three + // write paths apply the same currency policy (lib/currency/supplier-invoice-rate.ts). + const fx = await resolveSupplierInvoiceExchangeRate(supabase, { + currency: body.currency, + invoiceDate: body.invoice_date, + suppliedRate: body.exchange_rate, + }) + if (!fx.ok) { + // Storing exchange_rate = NULL here is what created the permanently + // unconverted rows: the booking path refuses them (SI_FX_RATE_MISSING) + // and the user is by then far away from the invoice. Refuse at creation + // instead, where the kurs can still be typed into the form. + return errorResponseFromCode('SI_FX_RATE_MISSING', log, { + requestId, + details: { currency: fx.currency, invoice_date: fx.invoiceDate }, + }) + } + const { data: arrivalNum, error: arrivalError } = await supabase .rpc('get_next_arrival_number', { p_company_id: companyId }) @@ -240,7 +265,9 @@ export const POST = withRouteContext( // the net. VAT is still tracked separately (vat_amount) for declarations // and books fiktiv 2614/2645 in the engine, but neither side moves cash. const payableVat = body.reverse_charge ? 0 : vatAmount - const total = Math.round((subtotal + payableVat) * 100) / 100 + // roundOre, not the naive form: `total` and `total_sek` must round + // identically or a SEK invoice ends up with total_sek one öre off `total`. + const total = roundOre(subtotal + payableVat) // Representation (BAS 6070-6079): ingående moms is only deductible up to // 300 SEK base/person per ML 8 kap. 1 §, and the income-tax deduction was @@ -263,12 +290,17 @@ export const POST = withRouteContext( } } - const exchangeRate = body.exchange_rate || null - const subtotalSek = exchangeRate ? Math.round(subtotal * exchangeRate * 100) / 100 : null - const vatAmountSek = exchangeRate ? Math.round(vatAmount * exchangeRate * 100) / 100 : null - const totalSek = exchangeRate ? Math.round(total * exchangeRate * 100) / 100 : null + // SEK invoices resolve to rate 1, so total_sek === total. The old + // `exchangeRate ? … : null` guard left every ordinary Swedish supplier + // invoice with total_sek = NULL, which is why SEK-reporting readers saw + // nothing. + const { + subtotal_sek: subtotalSek, + vat_amount_sek: vatAmountSek, + total_sek: totalSek, + } = supplierInvoiceSekAmounts(fx.rate, { subtotal, vatAmount, total }) - const totalRounded = Math.round(total * 100) / 100 + const totalRounded = roundOre(total) const { data: invoice, error: invoiceError } = await supabase .from('supplier_invoices') .insert({ @@ -282,15 +314,18 @@ export const POST = withRouteContext( due_date: body.due_date, delivery_date: body.delivery_date || null, status: paidPrivately ? 'paid' : 'registered', - currency: body.currency || 'SEK', - exchange_rate: exchangeRate, + currency: fx.rate.currency, + exchange_rate: fx.rate.exchangeRate, + // Which day's kurs the SEK amounts were translated at: the audit trail + // that makes them verifiable (BFL 5 kap). + exchange_rate_date: fx.rate.exchangeRateDate, vat_treatment: body.vat_treatment || 'standard_25', reverse_charge: body.reverse_charge || false, payment_reference: body.payment_reference || null, paid_with_private_funds: paidPrivately, - subtotal: Math.round(subtotal * 100) / 100, + subtotal: roundOre(subtotal), subtotal_sek: subtotalSek, - vat_amount: Math.round(vatAmount * 100) / 100, + vat_amount: roundOre(vatAmount), vat_amount_sek: vatAmountSek, total: totalRounded, total_sek: totalSek, diff --git a/app/api/transactions/[id]/book/__tests__/route.test.ts b/app/api/transactions/[id]/book/__tests__/route.test.ts index b8ca9903..d9852d93 100644 --- a/app/api/transactions/[id]/book/__tests__/route.test.ts +++ b/app/api/transactions/[id]/book/__tests__/route.test.ts @@ -126,7 +126,9 @@ describe('POST /api/transactions/[id]/book', () => { const { status, body } = await parseJsonResponse<{ error: string }>(response) expect(status).toBe(400) - expect(body.error).toBe('Validation failed') + // Inverted from `toBe('Validation failed')`: the constant was the bug. + expect(body.error).toMatch(/^Valideringsfel: /) + expect(body.error).toContain('entry_date') }) it('returns 404 when transaction not found', async () => { diff --git a/app/api/transactions/[id]/book/route.ts b/app/api/transactions/[id]/book/route.ts index def75bbd..e87bd780 100644 --- a/app/api/transactions/[id]/book/route.ts +++ b/app/api/transactions/[id]/book/route.ts @@ -54,6 +54,12 @@ export const POST = withRouteContext<{ params: Promise<{ id: string }> }>( id, date: transaction.date, amount: transaction.amount, + // The FX trio must ride along: `amount` is in `currency`, the ledger + // legs it is compared against are always SEK. Selected above via + // select('*'). + currency: transaction.currency ?? null, + amount_sek: transaction.amount_sek ?? null, + exchange_rate: transaction.exchange_rate ?? null, cash_account_id: transaction.cash_account_id ?? null, }) if (!force) { @@ -104,8 +110,18 @@ export const POST = withRouteContext<{ params: Promise<{ id: string }> }>( transaction_id: id, dismissed_transaction_id: candidate.transaction_id, dismissed_journal_entry_id: candidate.journal_entry_id, - amount_ore: Math.round(candidate.amount * 100), + // Null when the candidate's SEK value could not be established + // (a rateless foreign sibling); the foreign figures below then + // carry the durable record instead of a fabricated kr amount. + amount_ore: candidate.amount != null ? Math.round(candidate.amount * 100) : null, + dismissed_currency: candidate.currency, + dismissed_amount_in_currency: candidate.amount_in_currency, entry_date: candidate.entry_date, + // Whether the user dismissed a confirmed same-amount twin or a + // candidate whose amounts could never be compared (BFNAR 2013:2 + // kap 8: the behandlingshistorik has to say which). + amount_verified: candidate.amount_verified, + unverified_reason: candidate.unverified_reason, }, actor: { type: 'user', id: user.id }, occurredAt: new Date(), diff --git a/app/api/transactions/[id]/categorize/__tests__/route.test.ts b/app/api/transactions/[id]/categorize/__tests__/route.test.ts index f4c8eeae..6bacc733 100644 --- a/app/api/transactions/[id]/categorize/__tests__/route.test.ts +++ b/app/api/transactions/[id]/categorize/__tests__/route.test.ts @@ -43,7 +43,11 @@ vi.mock('@/lib/bookkeeping/transaction-entries', () => ({ // tests exercise categorization, not the guard. The detection query is // unit-tested in lib/transactions/__tests__/booking-duplicate-detection.test.ts. const mockDetectDup = vi.fn() -vi.mock('@/lib/transactions/booking-duplicate-detection', () => ({ +// Spread the real module so pure helpers the route also imports from here +// (resolveTransactionAmountSek) keep their real behaviour; only the DB-backed +// detector is stubbed. A bare factory would leave those exports undefined. +vi.mock('@/lib/transactions/booking-duplicate-detection', async (importActual) => ({ + ...(await importActual()), detectBookingDuplicate: (...args: unknown[]) => mockDetectDup(...args), })) @@ -76,6 +80,15 @@ vi.mock('@/lib/bookkeeping/counterparty-templates', () => ({ upsertCounterpartyTemplate: vi.fn().mockResolvedValue(undefined), })) +// CAS-race compensation is centralized in lib/bookkeeping/cancel-orphaned-entry. +// The route must delegate to it rather than hand-rolling the cancel + the +// voucher_gap_explanations insert (BFNAR 2013:2). The exact insert payload is +// asserted in that helper's own test. +const mockCancelOrphanedPaymentEntry = vi.fn() +vi.mock('@/lib/bookkeeping/cancel-orphaned-entry', () => ({ + cancelOrphanedPaymentEntry: (...args: unknown[]) => mockCancelOrphanedPaymentEntry(...args), +})) + const mockFindMissingActiveAccounts = vi.fn() vi.mock('@/lib/bookkeeping/account-validation', async () => { const actual = await vi.importActual( @@ -115,6 +128,46 @@ describe('POST /api/transactions/[id]/categorize', () => { // Default: no booking-time duplicate. The dedicated guard test overrides this. mockDetectDup.mockResolvedValue(null) mockAppendProcessingHistory.mockResolvedValue('evt-1') + mockCancelOrphanedPaymentEntry.mockResolvedValue(undefined) + }) + + it('delegates the CAS-race orphan to cancelOrphanedPaymentEntry (documented voucher gap)', async () => { + const tx = makeTransaction({ + id: 'tx-1', + amount: -500, + merchant_name: 'GitHub', + journal_entry_id: null, + }) + + enqueue({ data: tx, error: null }) // fetch transaction + enqueue({ data: { entity_type: 'enskild_firma', fiscal_year_start_month: 1 }, error: null }) // settings + enqueue({ data: [{ id: 'period-1' }], error: null }) // ensureFiscalPeriod + + mockCreateTransactionJournalEntry.mockResolvedValue({ id: 'je-1' }) + mockSaveUserMappingRule.mockResolvedValue(undefined) + + // Lost the CAS: another request stamped journal_entry_id first. + enqueue({ data: [], error: null }) + + const request = createMockRequest('/api/transactions/tx-1/categorize', { + method: 'POST', + body: { is_business: true, category: 'expense_software' }, + }) + const response = await POST(request, createMockRouteParams({ id: 'tx-1' })) + const { status, body } = await parseJsonResponse<{ error: unknown }>(response) + + expect(status).toBe(409) + expect((body.error as { code: string }).code).toBe('TX_CATEGORIZE_RACE') + + // No hand-rolled insert: the helper owns the real column set. + expect(mockCancelOrphanedPaymentEntry).toHaveBeenCalledTimes(1) + expect(mockCancelOrphanedPaymentEntry).toHaveBeenCalledWith( + expect.anything(), + 'company-1', + 'user-1', + 'je-1', + 'Automatiskt makulerad: dubblettbokning förhindrad av samtidighetsskydd', + ) }) it('returns 401 when not authenticated', async () => { @@ -547,6 +600,276 @@ describe('POST /api/transactions/[id]/categorize', () => { expect(body.journal_entry_created).toBe(true) }) + // ── Suggestion-guard currency. `transactions.amount` is denominated in + // `transactions.currency`; `remaining_amount` is denominated in the invoice's + // currency. A plus-minus 2 % band around a EUR bank row applied to a kronor + // `remaining_amount` column is off by the whole exchange rate. + const eurExpenseTx = (over: Record = {}) => + makeTransaction({ + id: 'tx-1', + amount: -1000, + currency: 'EUR', + amount_sek: null, + exchange_rate: 11.5, + merchant_name: 'Leverantör AB', + journal_entry_id: null, + ...over, + }) + + const sekSupplierInvoice = (remaining: number) => ({ + id: 'si-1', + supplier_invoice_number: 'INV-2026-0042', + invoice_date: '2026-05-01', + remaining_amount: remaining, + total: remaining, + currency: 'SEK', + total_sek: remaining, + exchange_rate: null, + supplier: { name: 'Leverantör AB' }, + }) + + it('EUR transaction: a 1 000 SEK supplier invoice is not suggested for a 1 000 EUR payment', async () => { + enqueue({ data: eurExpenseTx(), error: null }) + enqueue({ data: { entity_type: 'enskild_firma', fiscal_year_start_month: 1 }, error: null }) + + mockBuildMappingResultFromCategory.mockReturnValue({ + ...defaultMappingResult, + debit_account: '2440', + }) + + enqueue({ data: [{ id: 'sup-1' }], error: null }) + // First sweep returns the same-magnitude kronor invoice the old EUR band + // selected; the shared-unit re-check must drop it. + enqueue({ data: [sekSupplierInvoice(1000)], error: null }) + enqueue({ data: [], error: null }) + // ensureFiscalPeriod + transaction update + enqueue({ data: [{ id: 'period-1' }], error: null }) + mockCreateTransactionJournalEntry.mockResolvedValue({ id: 'je-1' }) + enqueue({ data: [{ id: 'tx-1' }], error: null }) + + const request = createMockRequest('/api/transactions/tx-1/categorize', { + method: 'POST', + body: { is_business: true, category: 'expense_software' }, + }) + const response = await POST(request, createMockRouteParams({ id: 'tx-1' })) + const { status, body } = await parseJsonResponse<{ + success: boolean + journal_entry_created: boolean + }>(response) + + expect(status).toBe(200) + expect(body.success).toBe(true) + expect(body.journal_entry_created).toBe(true) + }) + + it('EUR transaction with a rate: the 11 500 SEK supplier invoice IS suggested', async () => { + enqueue({ data: eurExpenseTx(), error: null }) + enqueue({ data: { entity_type: 'enskild_firma', fiscal_year_start_month: 1 }, error: null }) + + mockBuildMappingResultFromCategory.mockReturnValue({ + ...defaultMappingResult, + debit_account: '2440', + }) + + enqueue({ data: [{ id: 'sup-1' }], error: null }) + // EUR sweep finds nothing; the kronor sweep finds the invoice at the + // converted magnitude. + enqueue({ data: [], error: null }) + enqueue({ data: [sekSupplierInvoice(11500)], error: null }) + + const request = createMockRequest('/api/transactions/tx-1/categorize', { + method: 'POST', + body: { is_business: true, category: 'expense_software' }, + }) + const response = await POST(request, createMockRouteParams({ id: 'tx-1' })) + const { status, body } = await parseJsonResponse<{ + error: { code: string; details: { candidates: Array<{ supplier_invoice_id: string }> } } + }>(response) + + expect(status).toBe(409) + expect(body.error.code).toBe('TX_CATEGORIZE_SUGGEST_SI_MATCH') + expect(body.error.details.candidates.map((c) => c.supplier_invoice_id)).toEqual(['si-1']) + expect(mockCreateTransactionJournalEntry).not.toHaveBeenCalled() + }) + + it('EUR transaction without a rate: kronor invoices are excluded, never compared raw', async () => { + enqueue({ data: eurExpenseTx({ exchange_rate: null }), error: null }) + enqueue({ data: { entity_type: 'enskild_firma', fiscal_year_start_month: 1 }, error: null }) + + mockBuildMappingResultFromCategory.mockReturnValue({ + ...defaultMappingResult, + debit_account: '2440', + }) + + enqueue({ data: [{ id: 'sup-1' }], error: null }) + // Only the EUR sweep is planned; the kronor invoice it returns here has no + // shared unit with the bank row and must be dropped. + enqueue({ data: [sekSupplierInvoice(1000)], error: null }) + enqueue({ data: [{ id: 'period-1' }], error: null }) + mockCreateTransactionJournalEntry.mockResolvedValue({ id: 'je-1' }) + enqueue({ data: [{ id: 'tx-1' }], error: null }) + + const request = createMockRequest('/api/transactions/tx-1/categorize', { + method: 'POST', + body: { is_business: true, category: 'expense_software' }, + }) + const response = await POST(request, createMockRouteParams({ id: 'tx-1' })) + const { status, body } = await parseJsonResponse<{ success: boolean }>(response) + + expect(status).toBe(200) + expect(body.success).toBe(true) + }) + + it('EUR transaction: a 1 000 EUR supplier invoice still matches in its own currency', async () => { + enqueue({ data: eurExpenseTx(), error: null }) + enqueue({ data: { entity_type: 'enskild_firma', fiscal_year_start_month: 1 }, error: null }) + + mockBuildMappingResultFromCategory.mockReturnValue({ + ...defaultMappingResult, + debit_account: '2440', + }) + + enqueue({ data: [{ id: 'sup-1' }], error: null }) + enqueue({ + data: [ + { + ...sekSupplierInvoice(1000), + currency: 'EUR', + total_sek: 11500, + exchange_rate: 11.5, + }, + ], + error: null, + }) + enqueue({ data: [], error: null }) + + const request = createMockRequest('/api/transactions/tx-1/categorize', { + method: 'POST', + body: { is_business: true, category: 'expense_software' }, + }) + const response = await POST(request, createMockRouteParams({ id: 'tx-1' })) + const { status, body } = await parseJsonResponse<{ error: { code: string } }>(response) + + expect(status).toBe(409) + expect(body.error.code).toBe('TX_CATEGORIZE_SUGGEST_SI_MATCH') + }) + + it('EUR inbound transaction: a 1 000 SEK customer invoice is not suggested', async () => { + const tx = makeTransaction({ + id: 'tx-1', + amount: 1000, + currency: 'EUR', + amount_sek: null, + exchange_rate: 11.5, + description: 'Inbetalning Acme AB', + merchant_name: 'Acme AB', + journal_entry_id: null, + }) + + enqueue({ data: tx, error: null }) + enqueue({ data: { entity_type: 'enskild_firma', fiscal_year_start_month: 1 }, error: null }) + + mockBuildMappingResultFromCategory.mockReturnValue({ + ...defaultMappingResult, + debit_account: '1930', + credit_account: '1510', + }) + + // Customer lookups (merchant_name, description) + enqueue({ data: [{ id: 'cust-1' }], error: null }) + enqueue({ data: [{ id: 'cust-1' }], error: null }) + // EUR sweep returns the same-magnitude kronor invoice; kronor sweep empty. + enqueue({ + data: [ + { + id: 'inv-1', + invoice_number: '2026-0042', + invoice_date: '2026-05-01', + due_date: '2026-05-31', + remaining_amount: 1000, + total: 1000, + currency: 'SEK', + total_sek: 1000, + exchange_rate: null, + customer: { name: 'Acme AB' }, + }, + ], + error: null, + }) + enqueue({ data: [], error: null }) + // ensureFiscalPeriod + transaction update + enqueue({ data: [{ id: 'period-1' }], error: null }) + mockCreateTransactionJournalEntry.mockResolvedValue({ id: 'je-1' }) + enqueue({ data: [{ id: 'tx-1' }], error: null }) + + const request = createMockRequest('/api/transactions/tx-1/categorize', { + method: 'POST', + body: { is_business: true, category: 'income_services' }, + }) + const response = await POST(request, createMockRouteParams({ id: 'tx-1' })) + const { status, body } = await parseJsonResponse<{ success: boolean }>(response) + + expect(status).toBe(200) + expect(body.success).toBe(true) + }) + + it('EUR inbound transaction with a rate: the 11 500 SEK customer invoice IS suggested', async () => { + const tx = makeTransaction({ + id: 'tx-1', + amount: 1000, + currency: 'EUR', + amount_sek: 11500, + exchange_rate: null, + description: 'Inbetalning Acme AB', + merchant_name: 'Acme AB', + journal_entry_id: null, + }) + + enqueue({ data: tx, error: null }) + enqueue({ data: { entity_type: 'enskild_firma', fiscal_year_start_month: 1 }, error: null }) + + mockBuildMappingResultFromCategory.mockReturnValue({ + ...defaultMappingResult, + debit_account: '1930', + credit_account: '1510', + }) + + enqueue({ data: [{ id: 'cust-1' }], error: null }) + enqueue({ data: [{ id: 'cust-1' }], error: null }) + // EUR sweep empty; kronor sweep finds the invoice at the converted amount. + enqueue({ data: [], error: null }) + enqueue({ + data: [ + { + id: 'inv-1', + invoice_number: '2026-0042', + invoice_date: '2026-05-01', + due_date: '2026-05-31', + remaining_amount: 11500, + total: 11500, + currency: 'SEK', + total_sek: 11500, + exchange_rate: null, + customer: { name: 'Acme AB' }, + }, + ], + error: null, + }) + + const request = createMockRequest('/api/transactions/tx-1/categorize', { + method: 'POST', + body: { is_business: true, category: 'income_services' }, + }) + const response = await POST(request, createMockRouteParams({ id: 'tx-1' })) + const { status, body } = await parseJsonResponse<{ + error: { code: string; details: { candidates: Array<{ invoice_id: string }> } } + }>(response) + + expect(status).toBe(409) + expect(body.error.code).toBe('TX_CATEGORIZE_SUGGEST_CI_MATCH') + expect(body.error.details.candidates.map((c) => c.invoice_id)).toEqual(['inv-1']) + }) + it('returns 409 TX_CATEGORIZE_SUGGEST_CI_MATCH when 1930/1510 mapping matches an open customer invoice', async () => { const tx = makeTransaction({ id: 'tx-1', diff --git a/app/api/transactions/[id]/categorize/__tests__/suggestion-band-currency.test.ts b/app/api/transactions/[id]/categorize/__tests__/suggestion-band-currency.test.ts new file mode 100644 index 00000000..5c95ce77 --- /dev/null +++ b/app/api/transactions/[id]/categorize/__tests__/suggestion-band-currency.test.ts @@ -0,0 +1,370 @@ +/** + * The invoice-suggestion guards' plus-minus 2 % band and the column it is + * applied to must share a unit. + * + * `transactions.amount` is denominated in `transactions.currency`, while + * `supplier_invoices.remaining_amount` / `invoices.remaining_amount` are + * denominated in the INVOICE's currency. At roughly 11,50 SEK/EUR a band built + * around a EUR bank row and applied to a kronor `remaining_amount` column is + * off by a factor of eleven: it matches nothing (the user books straight to + * 244x/151x and is later lured into a duplicate payment) or it points at an + * unrelated invoice. + * + * These tests assert the FILTER VALUES the route actually sends. The shared + * `createQueuedMockSupabase` helper drops filter arguments, so a response-shape + * assertion alone passes against the pre-fix band whenever the queued page is + * empty. The finding lives entirely in the arguments. + */ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import type { SupabaseClient } from '@supabase/supabase-js' +import { createMockRequest, parseJsonResponse, createMockRouteParams, makeTransaction } from '@/tests/helpers' + +/** One recorded query: which table, and every filter argument it received. */ +type RecordedQuery = { table: string; calls: Record } + +/** + * Chainable Supabase stub that RECORDS each query's filter arguments and serves + * one queued page per table, in call order. Keyed by table so an unrelated + * lookup elsewhere in the route does not shift the assertions. + */ +function createRecordingSupabase(pages: Record>) { + const queries: RecordedQuery[] = [] + const queues: Record> = {} + for (const [table, list] of Object.entries(pages)) { + queues[table] = list.map((r) => ({ data: r.data ?? null, error: r.error ?? null })) + } + + const from = (table: string) => { + const result = queues[table]?.shift() ?? { data: null, error: null } + const calls: Record = {} + queries.push({ table, calls }) + const chain: unknown = new Proxy( + {}, + { + get(_target, prop: string) { + if (prop === 'then') { + return (resolve: (v: unknown) => void) => resolve(result) + } + return (...args: unknown[]) => { + ;(calls[prop] ??= []).push(args) + return chain + } + }, + }, + ) + return chain + } + + const supabase = { + from: vi.fn(from), + rpc: vi.fn(() => from('__rpc')), + auth: { getUser: vi.fn() }, + } + + return { supabase, queries } +} + +const initial = createRecordingSupabase({}) +let mockSupabase = initial.supabase +let recorded: RecordedQuery[] = initial.queries + +vi.mock('@/lib/supabase/server', () => ({ + createClient: () => Promise.resolve(mockSupabase as unknown as SupabaseClient), +})) + +vi.mock('@/lib/init', () => ({ ensureInitialized: vi.fn() })) + +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 }), +})) + +const mockBuildMappingResultFromCategory = vi.fn() +vi.mock('@/lib/bookkeeping/category-mapping', () => ({ + buildMappingResultFromCategory: (...args: unknown[]) => mockBuildMappingResultFromCategory(...args), +})) + +const mockCreateTransactionJournalEntry = vi.fn() +vi.mock('@/lib/bookkeeping/transaction-entries', () => ({ + createTransactionJournalEntry: (...args: unknown[]) => mockCreateTransactionJournalEntry(...args), +})) + +// Only the DB-backed detector is stubbed; the pure helpers the route imports +// from this module (resolveTransactionAmountSek) keep their real behaviour. +const mockDetectDup = vi.fn() +vi.mock('@/lib/transactions/booking-duplicate-detection', async (importActual) => ({ + ...(await importActual()), + detectBookingDuplicate: (...args: unknown[]) => mockDetectDup(...args), +})) + +vi.mock('@/lib/processing-history/append', () => ({ + appendProcessingHistory: vi.fn().mockResolvedValue('evt-1'), +})) + +vi.mock('@/lib/bookkeeping/mapping-engine', () => ({ + saveUserMappingRule: vi.fn().mockResolvedValue(undefined), + applySettlementAccount: (result: unknown) => result, +})) + +vi.mock('@/lib/bookkeeping/counterparty-templates', () => ({ + upsertCounterpartyTemplate: vi.fn().mockResolvedValue(undefined), +})) + +vi.mock('@/lib/bookkeeping/cancel-orphaned-entry', () => ({ + cancelOrphanedPaymentEntry: vi.fn().mockResolvedValue(undefined), +})) + +vi.mock('@/lib/bookkeeping/account-validation', async () => { + const actual = await vi.importActual( + '@/lib/bookkeeping/account-validation', + ) + return { ...actual, findUnresolvableAccounts: vi.fn().mockResolvedValue([]) } +}) + +import { eventBus } from '@/lib/events' +import { POST } from '../route' + +function useSupabase(pages: Record>) { + const next = createRecordingSupabase(pages) + mockSupabase = next.supabase + recorded = next.queries + mockSupabase.auth.getUser.mockResolvedValue({ data: { user: { id: 'user-1' } } }) +} + +const settingsPage = { data: { entity_type: 'enskild_firma', fiscal_year_start_month: 1 } } + +const baseMapping = { + rule: null, + debit_account: '6200', + credit_account: '1930', + risk_level: 'NONE', + confidence: 1, + requires_review: false, + default_private: false, + vat_lines: [], + description: 'Test', +} + +const supplierInvoiceRow = (over: Record = {}) => ({ + id: 'si-1', + supplier_invoice_number: 'INV-2026-0042', + invoice_date: '2026-05-01', + remaining_amount: 11500, + total: 11500, + currency: 'SEK', + total_sek: 11500, + exchange_rate: null, + supplier: { name: 'Leverantör AB' }, + ...over, +}) + +const customerInvoiceRow = (over: Record = {}) => ({ + id: 'inv-1', + invoice_number: '2026-0042', + invoice_date: '2026-05-01', + due_date: '2026-05-31', + remaining_amount: 11500, + total: 11500, + currency: 'SEK', + total_sek: 11500, + exchange_rate: null, + customer: { name: 'Acme AB' }, + ...over, +}) + +const categorize = async (category: string) => { + const request = createMockRequest('/api/transactions/tx-1/categorize', { + method: 'POST', + body: { is_business: true, category }, + }) + return POST(request, createMockRouteParams({ id: 'tx-1' })) +} + +const queriesOn = (table: string) => recorded.filter((q) => q.table === table) + +describe('POST /api/transactions/[id]/categorize: suggestion band units', () => { + beforeEach(() => { + vi.clearAllMocks() + eventBus.clear() + mockDetectDup.mockResolvedValue(null) + mockBuildMappingResultFromCategory.mockReturnValue(baseMapping) + }) + + it('SEK payment to 2440: one kronor-banded sweep, byte-identical to the pre-fix query', async () => { + useSupabase({ + transactions: [ + { + data: makeTransaction({ + id: 'tx-1', + amount: -1000, + currency: 'SEK', + amount_sek: null, + exchange_rate: null, + merchant_name: 'Leverantör AB', + reference: null, + cash_account_id: null, + journal_entry_id: null, + }), + }, + ], + company_settings: [settingsPage], + suppliers: [{ data: [{ id: 'sup-1' }] }], + supplier_invoices: [{ data: [supplierInvoiceRow({ remaining_amount: 1000, total: 1000, total_sek: 1000 })] }], + }) + mockBuildMappingResultFromCategory.mockReturnValue({ ...baseMapping, debit_account: '2440' }) + + const response = await categorize('expense_software') + const { status, body } = await parseJsonResponse<{ error: { code: string } }>(response) + expect(status).toBe(409) + expect(body.error.code).toBe('TX_CATEGORIZE_SUGGEST_SI_MATCH') + + expect(queriesOn('supplier_invoices')).toHaveLength(1) + const q = queriesOn('supplier_invoices')[0].calls + expect(q.gte).toContainEqual(['remaining_amount', 980]) + expect(q.lte).toContainEqual(['remaining_amount', 1020]) + expect(q.or).toEqual([['currency.is.null,currency.eq.SEK']]) + }) + + it('EUR payment to 2440: EUR invoices banded in EUR, kronor invoices banded in kronor', async () => { + useSupabase({ + transactions: [ + { + data: makeTransaction({ + id: 'tx-1', + amount: -1000, + currency: 'EUR', + amount_sek: null, + exchange_rate: 11.5, + merchant_name: 'Leverantör AB', + reference: null, + cash_account_id: null, + journal_entry_id: null, + }), + }, + ], + company_settings: [settingsPage], + suppliers: [{ data: [{ id: 'sup-1' }] }], + // EUR sweep empty; the kronor sweep finds the 11 500 kr invoice this + // payment actually settles. + supplier_invoices: [{ data: [] }, { data: [supplierInvoiceRow()] }], + }) + mockBuildMappingResultFromCategory.mockReturnValue({ ...baseMapping, debit_account: '2440' }) + + const response = await categorize('expense_software') + const { status, body } = await parseJsonResponse<{ + error: { code: string; details: { candidates: Array<{ supplier_invoice_id: string }> } } + }>(response) + expect(status).toBe(409) + expect(body.error.code).toBe('TX_CATEGORIZE_SUGGEST_SI_MATCH') + expect(body.error.details.candidates.map((c) => c.supplier_invoice_id)).toEqual(['si-1']) + + expect(queriesOn('supplier_invoices')).toHaveLength(2) + const eur = queriesOn('supplier_invoices')[0].calls + expect(eur.or).toEqual([['currency.eq.EUR']]) + expect(eur.gte).toContainEqual(['remaining_amount', 980]) + expect(eur.lte).toContainEqual(['remaining_amount', 1020]) + + const sek = queriesOn('supplier_invoices')[1].calls + expect(sek.or).toEqual([['currency.is.null,currency.eq.SEK']]) + // 1 000 EUR x 11,50 = 11 500 kr. The pre-fix query asked kronor invoices + // for 980..1020 and found nothing. + expect(sek.gte).toContainEqual(['remaining_amount', 11270]) + expect(sek.lte).toContainEqual(['remaining_amount', 11730]) + // The per-row re-check pro-rates total_sek; a narrow projection would make + // it read `undefined` and silently treat every invoice as kronor. + expect(sek.select?.[0][0]).toContain('total_sek') + expect(sek.select?.[0][0]).toContain('exchange_rate') + expect(sek.select?.[0][0]).toContain('currency') + }) + + it('EUR receipt to 1510: customer invoices are banded per currency too', async () => { + useSupabase({ + transactions: [ + { + data: makeTransaction({ + id: 'tx-1', + amount: 1000, + currency: 'EUR', + amount_sek: 11500, + exchange_rate: null, + merchant_name: 'Acme AB', + description: 'Inbetalning Acme AB', + reference: null, + cash_account_id: null, + journal_entry_id: null, + }), + }, + ], + company_settings: [settingsPage], + customers: [{ data: [{ id: 'cust-1' }] }, { data: [{ id: 'cust-1' }] }], + invoices: [{ data: [] }, { data: [customerInvoiceRow()] }], + }) + mockBuildMappingResultFromCategory.mockReturnValue({ + ...baseMapping, + debit_account: '1930', + credit_account: '1510', + }) + + const response = await categorize('income_services') + const { status, body } = await parseJsonResponse<{ + error: { code: string; details: { candidates: Array<{ invoice_id: string }> } } + }>(response) + expect(status).toBe(409) + expect(body.error.code).toBe('TX_CATEGORIZE_SUGGEST_CI_MATCH') + expect(body.error.details.candidates.map((c) => c.invoice_id)).toEqual(['inv-1']) + + expect(queriesOn('invoices')).toHaveLength(2) + const eur = queriesOn('invoices')[0].calls + expect(eur.or).toEqual([['currency.eq.EUR']]) + expect(eur.gte).toContainEqual(['remaining_amount', 980]) + expect(eur.lte).toContainEqual(['remaining_amount', 1020]) + + const sek = queriesOn('invoices')[1].calls + expect(sek.or).toEqual([['currency.is.null,currency.eq.SEK']]) + expect(sek.gte).toContainEqual(['remaining_amount', 11270]) + expect(sek.lte).toContainEqual(['remaining_amount', 11730]) + }) + + it('EUR payment with no stored rate: only the EUR sweep runs, kronor invoices are excluded', async () => { + useSupabase({ + transactions: [ + { + data: makeTransaction({ + id: 'tx-1', + amount: -1000, + currency: 'EUR', + amount_sek: null, + exchange_rate: null, + merchant_name: 'Leverantör AB', + reference: null, + cash_account_id: null, + journal_entry_id: null, + }), + }, + // The post-suggestion status update. + { data: [{ id: 'tx-1' }] }, + ], + company_settings: [settingsPage], + suppliers: [{ data: [{ id: 'sup-1' }] }], + // The single EUR sweep returns a kronor invoice anyway (this stub ignores + // the filters): the shared-unit re-check must drop it rather than read + // 1 000 kr as 1 000 EUR. + supplier_invoices: [{ data: [supplierInvoiceRow({ remaining_amount: 1000, total: 1000, total_sek: 1000 })] }], + fiscal_periods: [{ data: [{ id: 'period-1' }] }], + }) + mockBuildMappingResultFromCategory.mockReturnValue({ ...baseMapping, debit_account: '2440' }) + mockCreateTransactionJournalEntry.mockResolvedValue({ id: 'je-1' }) + + const response = await categorize('expense_software') + const { status, body } = await parseJsonResponse<{ success: boolean }>(response) + + expect(status).toBe(200) + expect(body.success).toBe(true) + expect(queriesOn('supplier_invoices')).toHaveLength(1) + expect(queriesOn('supplier_invoices')[0].calls.or).toEqual([['currency.eq.EUR']]) + }) +}) diff --git a/app/api/transactions/[id]/categorize/route.ts b/app/api/transactions/[id]/categorize/route.ts index ee9649e2..c7499fc1 100644 --- a/app/api/transactions/[id]/categorize/route.ts +++ b/app/api/transactions/[id]/categorize/route.ts @@ -5,6 +5,7 @@ import { ensureInitialized } from '@/lib/init' import { buildMappingResultFromCategory } from '@/lib/bookkeeping/category-mapping' import { getTemplateById, buildMappingResultFromTemplate, validateTemplateForEntity } from '@/lib/bookkeeping/booking-templates' import { createTransactionJournalEntry } from '@/lib/bookkeeping/transaction-entries' +import { cancelOrphanedPaymentEntry } from '@/lib/bookkeeping/cancel-orphaned-entry' import { detectBookingDuplicate } from '@/lib/transactions/booking-duplicate-detection' import { appendProcessingHistory } from '@/lib/processing-history/append' import { saveUserMappingRule, applySettlementAccount } from '@/lib/bookkeeping/mapping-engine' @@ -18,6 +19,14 @@ import { escapeLikePattern, normalizeOcrReference, } from '@/lib/invoices/duplicate-payment-guard' +import { + invoiceAmountSek, + magnitudesWithinTolerance, + normalizeCurrencyCode, + planAmountSweeps, + type ComparableAmount, +} from '@/lib/invoices/duplicate-guard-currency' +import { resolveTransactionAmountSek } from '@/lib/transactions/booking-duplicate-detection' import { AccountsNotInChartError, accountsNotInChartResponse } from '@/lib/bookkeeping/errors' import { collectMappingResultAccounts, findUnresolvableAccounts } from '@/lib/bookkeeping/account-validation' import { getErrorMessage } from '@/lib/errors/get-error-message' @@ -158,6 +167,11 @@ export const POST = withRouteContext( id, date: transaction.date, amount: transaction.amount, + // `amount` is denominated in `currency`; the ledger legs the guard + // compares it against are always SEK. Selected above via select('*'). + currency: transaction.currency ?? null, + amount_sek: transaction.amount_sek ?? null, + exchange_rate: transaction.exchange_rate ?? null, cash_account_id: transaction.cash_account_id ?? null, }) if (!body.force) { @@ -207,8 +221,19 @@ export const POST = withRouteContext( transaction_id: id, dismissed_transaction_id: candidate.transaction_id, dismissed_journal_entry_id: candidate.journal_entry_id, - amount_ore: Math.round(candidate.amount * 100), + // Null when the candidate's SEK value could not be established + // (a rateless foreign sibling); the foreign figures below then + // carry the durable record instead of a fabricated kr amount. + amount_ore: candidate.amount != null ? Math.round(candidate.amount * 100) : null, + dismissed_currency: candidate.currency, + dismissed_amount_in_currency: candidate.amount_in_currency, entry_date: candidate.entry_date, + // Whether the user dismissed a confirmed same-amount twin or a + // candidate whose kr figure was never established (BFNAR 2013:2 + // kap 8: the behandlingshistorik has to say which). Parity with + // the /book route's dismissal record. + amount_verified: candidate.amount_verified, + unverified_reason: candidate.unverified_reason, }, actor: { type: 'user', id: user.id }, occurredAt: new Date(), @@ -403,6 +428,50 @@ export const POST = withRouteContext( }) } + // Units for both invoice-suggestion prongs below. `transactions.amount` is + // denominated in `transactions.currency`, while `remaining_amount` on + // `supplier_invoices` / `invoices` is denominated in the INVOICE's + // currency. A plus-minus 2 % band built around a EUR bank row and applied + // to a kronor `remaining_amount` column is off by the whole exchange rate: + // it either matches nothing or points the user at an unrelated invoice. + // `planAmountSweeps` therefore issues one SQL sweep per currency (band and + // column in the same unit) and `magnitudesWithinTolerance` re-checks every + // returned row. A SEK transaction yields exactly one sweep with the band it + // had before, so a SEK-only company runs the identical single query. + const txReferenceAmount: ComparableAmount = { + amount: transaction.amount, + currency: normalizeCurrencyCode(transaction.currency), + sek: resolveTransactionAmountSek({ + amount: transaction.amount, + currency: transaction.currency, + amount_sek: transaction.amount_sek, + exchange_rate: transaction.exchange_rate, + }), + } + + /** A candidate invoice row as a comparable amount (pro-rates `total_sek`). */ + const invoiceRowAmount = (row: { + remaining_amount: number | null + total?: number | null + currency: string | null + total_sek?: number | null + exchange_rate?: number | null + }): ComparableAmount => { + const remaining = row.remaining_amount ?? row.total ?? 0 + const currency = normalizeCurrencyCode(row.currency) + return { + amount: Number(remaining), + currency, + sek: invoiceAmountSek({ + amount: Number(remaining), + currency, + total: row.total, + totalSek: row.total_sek, + exchangeRate: row.exchange_rate, + }), + } + } + // Prong B: intercept plain 244x categorization of supplier payments when // an open supplier invoice already covers this amount. Categorizing direct // to 244x leaves the invoice with status='approved' and lures the user @@ -416,9 +485,20 @@ export const POST = withRouteContext( /^244\d$/.test(mappingResult.debit_account) && /^1\d{3}$/.test(mappingResult.credit_account) ) { - const txAmountAbs = Math.abs(transaction.amount) - const windowLow = Math.round(txAmountAbs * (1 - DUPLICATE_AMOUNT_TOLERANCE_PCT) * 100) / 100 - const windowHigh = Math.round(txAmountAbs * (1 + DUPLICATE_AMOUNT_TOLERANCE_PCT) * 100) / 100 + const { sweeps, crossCurrencyUnverifiable } = planAmountSweeps( + txReferenceAmount, + DUPLICATE_AMOUNT_TOLERANCE_PCT, + ) + if (crossCurrencyUnverifiable) { + // A foreign bank row with neither amount_sek nor exchange_rate cannot + // be stated in kronor, so kronor invoices are excluded rather than + // compared raw. Logged: an unevaluated candidate set is not the same + // thing as "no open invoice matches". + txLog.warn('supplier-invoice suggestion: cross-currency candidates not evaluated', { + reason: 'transaction_missing_sek_value', + currency: txReferenceAmount.currency, + }) + } let supplierIds: string[] = [] if (transaction.merchant_name) { @@ -444,20 +524,56 @@ export const POST = withRouteContext( .toISOString() .split('T')[0] - const { data: openInvoices } = await supabase - .from('supplier_invoices') - .select('id, supplier_invoice_number, invoice_date, remaining_amount, currency, supplier:suppliers(name)') - .eq('company_id', companyId) - .in('supplier_id', supplierIds) - .in('status', ['registered', 'approved', 'partially_paid', 'overdue']) - .gte('remaining_amount', windowLow) - .lte('remaining_amount', windowHigh) - .gte('invoice_date', invoiceDateLow) - .lte('invoice_date', invoiceDateHigh) - .order('invoice_date', { ascending: false }) - .limit(5) + type SupplierCandidateRow = { + id: string + supplier_invoice_number: string | null + invoice_date: string + remaining_amount: number | null + total: number | null + currency: string | null + total_sek: number | null + exchange_rate: number | null + supplier: { name?: string } | null + } - if (openInvoices && openInvoices.length > 0) { + const sweepResults = await Promise.all( + sweeps.map((sweep) => + supabase + .from('supplier_invoices') + .select( + 'id, supplier_invoice_number, invoice_date, remaining_amount, total, currency, total_sek, exchange_rate, supplier:suppliers(name)', + ) + .eq('company_id', companyId) + .in('supplier_id', supplierIds) + .in('status', ['registered', 'approved', 'partially_paid', 'overdue']) + .or(sweep.currencyFilter) + .gte('remaining_amount', sweep.low) + .lte('remaining_amount', sweep.high) + .gte('invoice_date', invoiceDateLow) + .lte('invoice_date', invoiceDateHigh) + .order('invoice_date', { ascending: false }) + .limit(5), + ), + ) + + const byId = new Map() + for (const res of sweepResults) { + for (const row of (res.data ?? []) as unknown as SupplierCandidateRow[]) { + if (!byId.has(row.id)) byId.set(row.id, row) + } + } + const openInvoices = Array.from(byId.values()) + .filter((inv) => + magnitudesWithinTolerance( + txReferenceAmount, + invoiceRowAmount(inv), + DUPLICATE_AMOUNT_TOLERANCE_PCT, + ), + ) + .sort((a, b) => (a.invoice_date < b.invoice_date ? 1 : a.invoice_date > b.invoice_date ? -1 : 0)) + .slice(0, 5) + + if (openInvoices.length > 0) { return errorResponseFromCode('TX_CATEGORIZE_SUGGEST_SI_MATCH', txLog, { requestId, details: { @@ -488,9 +604,16 @@ export const POST = withRouteContext( /^19\d{2}$/.test(mappingResult.debit_account) && /^151\d$/.test(mappingResult.credit_account) ) { - const txAmount = transaction.amount - const windowLow = Math.round(txAmount * (1 - DUPLICATE_AMOUNT_TOLERANCE_PCT) * 100) / 100 - const windowHigh = Math.round(txAmount * (1 + DUPLICATE_AMOUNT_TOLERANCE_PCT) * 100) / 100 + const { sweeps, crossCurrencyUnverifiable } = planAmountSweeps( + txReferenceAmount, + DUPLICATE_AMOUNT_TOLERANCE_PCT, + ) + if (crossCurrencyUnverifiable) { + txLog.warn('customer-invoice suggestion: cross-currency candidates not evaluated', { + reason: 'transaction_missing_sek_value', + currency: txReferenceAmount.currency, + }) + } // Resolve candidate customer(s) by name. Inbound bank txs are typically // described by payer name in EITHER merchant_name OR description, so @@ -533,28 +656,47 @@ export const POST = withRouteContext( due_date: string | null remaining_amount: number | null total: number - currency: string + currency: string | null + total_sek: number | null + exchange_rate: number | null customer: { name?: string } | null } + const CANDIDATE_COLUMNS = + 'id, invoice_number, invoice_date, due_date, remaining_amount, total, currency, total_sek, exchange_rate, customer:customers(name)' const openInvoiceCandidates: CandidateRow[] = [] + /** Same-unit re-check: drops any row the SQL sweep let through. */ + const comparable = (row: CandidateRow) => + magnitudesWithinTolerance( + txReferenceAmount, + invoiceRowAmount(row), + DUPLICATE_AMOUNT_TOLERANCE_PCT, + ) if (customerIds.length > 0) { - const { data: byCustomer } = await supabase - .from('invoices') - .select( - 'id, invoice_number, invoice_date, due_date, remaining_amount, total, currency, customer:customers(name)', - ) - .eq('company_id', companyId) - .in('customer_id', customerIds) - .in('status', ['sent', 'overdue', 'partially_paid']) - .gte('remaining_amount', windowLow) - .lte('remaining_amount', windowHigh) - .gte('due_date', dueDateLow) - .lte('due_date', dueDateHigh) - .order('due_date', { ascending: false }) - .limit(5) - for (const row of (byCustomer ?? []) as unknown as CandidateRow[]) { - openInvoiceCandidates.push(row) + const sweepResults = await Promise.all( + sweeps.map((sweep) => + supabase + .from('invoices') + .select(CANDIDATE_COLUMNS) + .eq('company_id', companyId) + .in('customer_id', customerIds) + .in('status', ['sent', 'overdue', 'partially_paid']) + .or(sweep.currencyFilter) + .gte('remaining_amount', sweep.low) + .lte('remaining_amount', sweep.high) + .gte('due_date', dueDateLow) + .lte('due_date', dueDateHigh) + .order('due_date', { ascending: false }) + .limit(5), + ), + ) + for (const res of sweepResults) { + for (const row of (res.data ?? []) as unknown as CandidateRow[]) { + if (!comparable(row)) continue + if (!openInvoiceCandidates.some((existing) => existing.id === row.id)) { + openInvoiceCandidates.push(row) + } + } } } @@ -565,21 +707,26 @@ export const POST = withRouteContext( const txReference = (transaction as Transaction & { reference?: string | null }).reference const normalizedTxRef = normalizeOcrReference(txReference ?? null) if (normalizedTxRef) { - const { data: byRef } = await supabase - .from('invoices') - .select( - 'id, invoice_number, invoice_date, due_date, remaining_amount, total, currency, customer:customers(name)', - ) - .eq('company_id', companyId) - .in('status', ['sent', 'overdue', 'partially_paid']) - .gte('remaining_amount', windowLow) - .lte('remaining_amount', windowHigh) - .gte('due_date', dueDateLow) - .lte('due_date', dueDateHigh) - .order('due_date', { ascending: false }) - .limit(20) - for (const row of (byRef ?? []) as unknown as CandidateRow[]) { - if (normalizeOcrReference(row.invoice_number) === normalizedTxRef) { + const refSweepResults = await Promise.all( + sweeps.map((sweep) => + supabase + .from('invoices') + .select(CANDIDATE_COLUMNS) + .eq('company_id', companyId) + .in('status', ['sent', 'overdue', 'partially_paid']) + .or(sweep.currencyFilter) + .gte('remaining_amount', sweep.low) + .lte('remaining_amount', sweep.high) + .gte('due_date', dueDateLow) + .lte('due_date', dueDateHigh) + .order('due_date', { ascending: false }) + .limit(20), + ), + ) + for (const res of refSweepResults) { + for (const row of (res.data ?? []) as unknown as CandidateRow[]) { + if (normalizeOcrReference(row.invoice_number) !== normalizedTxRef) continue + if (!comparable(row)) continue if (!openInvoiceCandidates.some((existing) => existing.id === row.id)) { openInvoiceCandidates.unshift(row) } @@ -779,28 +926,16 @@ export const POST = withRouteContext( if ((!updateResult || updateResult.length === 0) && journalEntryId) { // CAS guard: another request set journal_entry_id between our read and - // write. Cancel the orphaned entry and document the voucher gap. - const { data: orphan } = await supabase - .from('journal_entries') - .select('fiscal_period_id, voucher_series, voucher_number') - .eq('id', journalEntryId) - .single() - - await supabase - .from('journal_entries') - .update({ status: 'cancelled' }) - .eq('id', journalEntryId) - - if (orphan) { - await supabase.from('voucher_gap_explanations').insert({ - company_id: companyId, - fiscal_period_id: orphan.fiscal_period_id, - voucher_series: orphan.voucher_series || 'A', - gap_number: orphan.voucher_number, - explanation: 'Automatiskt makulerad: dubblettbokning förhindrad av samtidighetsskydd', - created_by: user.id, - }) - } + // write. Cancel the orphaned entry and document the voucher gap through + // the shared helper (BFNAR 2013:2), which owns the correct + // voucher_gap_explanations column set and logs failures loudly. + await cancelOrphanedPaymentEntry( + supabase, + companyId, + user.id, + journalEntryId, + 'Automatiskt makulerad: dubblettbokning förhindrad av samtidighetsskydd', + ) return errorResponseFromCode('TX_CATEGORIZE_RACE', txLog, { requestId }) } diff --git a/app/api/transactions/[id]/duplicate-payment-check/route.ts b/app/api/transactions/[id]/duplicate-payment-check/route.ts index 57640b70..b91241a5 100644 --- a/app/api/transactions/[id]/duplicate-payment-check/route.ts +++ b/app/api/transactions/[id]/duplicate-payment-check/route.ts @@ -27,9 +27,15 @@ export const GET = withRouteContext( // narrow (id, date, amount, journal_entry_id) so this endpoint cannot // leak description / counterparty fields that aren't required to // surface a duplicate-payment candidate. GDPR Art.5(1)(c)/(f). + // + // currency / amount_sek / exchange_rate are part of that minimum, not an + // expansion of it: they carry no personal data, and without them the + // detector cannot state a non-SEK bank line in SEK and would compare a + // foreign amount against an always-SEK ledger leg. A narrow column list is + // exactly how this guard would go dead on FX rows. const { data: transaction, error } = await supabase .from('transactions') - .select('id, date, amount, journal_entry_id') + .select('id, date, amount, currency, amount_sek, exchange_rate, journal_entry_id') .eq('id', transactionId) .eq('company_id', companyId) .single() @@ -49,6 +55,9 @@ export const GET = withRouteContext( transactionId, transactionDate: transaction.date, transactionAmount: transaction.amount, + transactionCurrency: transaction.currency ?? null, + transactionAmountSek: transaction.amount_sek ?? null, + transactionExchangeRate: transaction.exchange_rate ?? null, }) return NextResponse.json({ candidate }) } catch (err) { diff --git a/app/api/transactions/[id]/match-invoice/__tests__/route.test.ts b/app/api/transactions/[id]/match-invoice/__tests__/route.test.ts index d3d41881..601371b9 100644 --- a/app/api/transactions/[id]/match-invoice/__tests__/route.test.ts +++ b/app/api/transactions/[id]/match-invoice/__tests__/route.test.ts @@ -112,7 +112,9 @@ describe('POST /api/transactions/[id]/match-invoice', () => { const { status, body } = await parseJsonResponse<{ error: string }>(response) expect(status).toBe(400) - expect(body.error).toBe('Validation failed') + // Inverted from `toBe('Validation failed')`: the constant was the bug. + expect(body.error).toMatch(/^Valideringsfel: /) + expect(body.error).toContain('invoice_id') }) it('returns 404 when transaction not found', async () => { @@ -1014,7 +1016,44 @@ describe('POST /api/transactions/[id]/match-invoice', () => { expect((body.error as unknown as { code: string }).code).toBe('MATCH_INVOICE_DUPLICATE_PAYMENT') }) - it('returns success with journal_entry_error when journal entry fails (non-blocking)', async () => { + it('aborts the match with 500 when the payment journal entry fails (no invoice update, no link)', async () => { + // Regression for the half-state: a generic booking failure used to mark + // the invoice paid and link the transaction with NO verifikat, which no + // flow could ever repair (mark-paid rejects paid invoices, this route + // rejects linked transactions). Mirrors match-supplier-invoice: the match + // aborts before ANY write. + const tx = makeTransaction({ id: 'tx-1', amount: 12500, invoice_id: null, date: '2024-06-15' }) + const invoice = makeInvoice({ id: VALID_UUID, status: 'sent', total: 12500, remaining_amount: 12500 }) + + enqueue({ data: tx, error: null }) + enqueue({ data: invoice, error: null }) + enqueue({ data: [], error: null }) // hard-duplicate check + enqueue({ data: { accounting_method: 'accrual', entity_type: 'enskild_firma' }, error: null }) + // Nothing else enqueued on purpose: the route must return before the + // invoice update, payment insert, or transaction link ever run. + + mockCreateJournalEntry.mockRejectedValue(new Error('deadlock detected')) + + const request = createMockRequest('/api/transactions/tx-1/match-invoice', { + method: 'POST', + body: { invoice_id: VALID_UUID }, + }) + const response = await POST(request, createMockRouteParams({ id: 'tx-1' })) + const { status, body } = await parseJsonResponse<{ + error: { code: string; details?: { reason?: string } } + }>(response) + + expect(status).toBe(500) + expect(body.error.code).toBe('MATCH_INVOICE_RECORD_PAYMENT_FAILED') + // The raw English message never reaches the user (issue #337): the reason + // detail carries the Swedish invoice-context fallback. + expect(body.error.details?.reason).toBe('Kunde inte hantera fakturan. Försök igen.') + // Exactly the four reads happened (tx, invoice, hard-dup, settings): + // no invoice update, no invoice_payments insert, no transaction link. + expect(mockSupabase.from).toHaveBeenCalledTimes(4) + }) + + it('aborts the match when createJournalEntry resolves without an id (no half-state)', async () => { const tx = makeTransaction({ id: 'tx-1', amount: 12500, invoice_id: null, date: '2024-06-15' }) const invoice = makeInvoice({ id: VALID_UUID, status: 'sent', total: 12500, remaining_amount: 12500 }) @@ -1023,34 +1062,18 @@ describe('POST /api/transactions/[id]/match-invoice', () => { enqueue({ data: [], error: null }) // hard-duplicate check enqueue({ data: { accounting_method: 'accrual', entity_type: 'enskild_firma' }, error: null }) - mockCreateJournalEntry.mockRejectedValue(new Error('Period locked')) - - // Update invoice (optimistic lock) - enqueue({ data: [{ id: VALID_UUID }], error: null }) - // Insert invoice_payments - enqueue({ data: null, error: null }) - // Update transaction - enqueue({ data: null, error: null }) - // logMatchEvent - enqueue({ data: null, error: null }) + mockCreateJournalEntry.mockResolvedValue(null) const request = createMockRequest('/api/transactions/tx-1/match-invoice', { method: 'POST', body: { invoice_id: VALID_UUID }, }) const response = await POST(request, createMockRouteParams({ id: 'tx-1' })) - const { status, body } = await parseJsonResponse<{ - success: boolean - journal_entry_id: null - journal_entry_error: string - }>(response) + const { status, body } = await parseJsonResponse<{ error: { code: string } }>(response) - expect(status).toBe(200) - expect(body.success).toBe(true) - expect(body.journal_entry_id).toBeNull() - // Untyped errors no longer leak their raw English message (issue #337): - // they map to the Swedish invoice-context fallback. - expect(body.journal_entry_error).toBe('Kunde inte hantera fakturan. Försök igen.') + expect(status).toBe(500) + expect(body.error.code).toBe('MATCH_INVOICE_RECORD_PAYMENT_FAILED') + expect(mockSupabase.from).toHaveBeenCalledTimes(4) }) // ──────────────────────────────────────────────────────────────── diff --git a/app/api/transactions/[id]/match-invoice/preview/route.ts b/app/api/transactions/[id]/match-invoice/preview/route.ts index 59c1350f..b31fe581 100644 --- a/app/api/transactions/[id]/match-invoice/preview/route.ts +++ b/app/api/transactions/[id]/match-invoice/preview/route.ts @@ -135,6 +135,41 @@ export const GET = withRouteContext( | { required: true; error: 'rate_unavailable'; tx_currency: string; invoice_currency: string } | { required: false } + // Mirrors the POST handler's guard. transactions.amount is denominated in + // transactions.currency; the SEK value lives in amount_sek (pre-computed at + // ingest) or is derivable from exchange_rate. A foreign row carrying + // neither (the shape a row gets when the Riksbanken lookup failed at + // ingest, see lib/transactions/ingest.ts) has no establishable SEK value, + // and the raw foreign number must never stand in for one: the dialog would + // preview a 500 USD receipt as a 500 SEK verifikat. Refuse here too so the + // preview never shows lines the POST would reject. + const txIsForeign = !!transaction.currency && transaction.currency !== 'SEK' + if ( + txIsForeign && + transaction.amount_sek == null && + !(transaction.exchange_rate != null && transaction.exchange_rate > 0) + ) { + return errorResponseFromCode('MATCH_INVOICE_TX_FX_RATE_MISSING', log, { + requestId, + details: { + transactionCurrency: transaction.currency, + transactionDate: transaction.date, + }, + }) + } + // Actual SEK that hit the bank, resolved through the same helper + // buildInvoicePaymentClearingLines uses for the bank leg. SEK rows return + // Math.abs(amount) unchanged. + const txAbsSek = + Math.round( + resolveSekAmount( + Math.abs(transaction.amount), + transaction.amount_sek != null ? Math.abs(transaction.amount_sek) : null, + transaction.currency, + transaction.exchange_rate, + ) * 100, + ) / 100 + let fxConversion: FxConversion = { required: false } if (transaction.currency !== invoice.currency) { const rateInfo = await fetchExchangeRate( @@ -145,10 +180,6 @@ export const GET = withRouteContext( // bankSek / rate = how many units of invoice.currency this payment // satisfies. Round to 4 decimal places to preserve precision through // subsequent partial-payment accumulations. - const txAbsSek = - transaction.currency === 'SEK' - ? Math.abs(transaction.amount) - : Math.abs(transaction.amount) * (transaction.exchange_rate ?? 1) const paidInInvoiceCurrency = Math.round((txAbsSek / rateInfo.rate) * 10000) / 10000 fxConversion = { diff --git a/app/api/transactions/[id]/match-invoice/route.ts b/app/api/transactions/[id]/match-invoice/route.ts index 0798661b..c2870c66 100644 --- a/app/api/transactions/[id]/match-invoice/route.ts +++ b/app/api/transactions/[id]/match-invoice/route.ts @@ -1,10 +1,12 @@ import { NextResponse } from 'next/server' import { createInvoiceCashEntry } from '@/lib/bookkeeping/invoice-entries' import { buildInvoicePaymentClearingLines } from '@/lib/bookkeeping/invoice-payment-lines' +import { resolveSekAmount } from '@/lib/bookkeeping/currency-utils' +import { coerceDimensionsBag } from '@/lib/bookkeeping/dimension-resolver' import { resolveSettlementAccount } from '@/lib/bookkeeping/settlement-account' import { fetchExchangeRate } from '@/lib/currency/riksbanken' import { reverseEntry, createJournalEntry, findFiscalPeriod } from '@/lib/bookkeeping/engine' -import { AccountsNotInChartError } from '@/lib/bookkeeping/errors' +import { AccountsNotInChartError, isBookkeepingError } from '@/lib/bookkeeping/errors' import { getErrorMessage } from '@/lib/errors/get-error-message' import { withRouteContext } from '@/lib/api/with-route-context' import { errorResponse, errorResponseFromCode } from '@/lib/errors/get-structured-error' @@ -155,6 +157,46 @@ export const POST = withRouteContext( source: 'manual' | 'riksbanken' } + // A bank row is stored in ITS OWN currency: transactions.amount is + // denominated in transactions.currency, and the SEK value lives either in + // amount_sek (pre-computed at ingest) or is derivable from exchange_rate. + // Every journal entry line is SEK, so a foreign row whose SEK value cannot + // be established must not be booked or allocated at all: substituting the + // raw foreign number would settle a 500 USD receipt as 500 SEK, a tenth of + // the real payment. Rows in exactly that shape exist: when the Riksbanken + // lookup fails at ingest the transaction is written with neither field + // (lib/transactions/ingest.ts). Refuse loudly, the same way the + // match_batch_allocate RPC refuses with BATCH_FX_RATE_MISSING, instead of + // guessing a rate of 1. + const txIsForeign = !!transaction.currency && transaction.currency !== 'SEK' + if ( + txIsForeign && + transaction.amount_sek == null && + !(transaction.exchange_rate != null && transaction.exchange_rate > 0) + ) { + return errorResponseFromCode('MATCH_INVOICE_TX_FX_RATE_MISSING', txLog, { + requestId, + details: { + transactionCurrency: transaction.currency, + transactionDate: transaction.date, + }, + }) + } + // The actual SEK that hit the bank. Resolved through the SAME helper + // buildInvoicePaymentClearingLines uses for the bank leg (amount_sek first, + // then amount * exchange_rate), so the FX conversion below and the posted + // verifikat can never disagree on the SEK figure. SEK rows return + // Math.abs(amount) unchanged. + const txAbsSek = + Math.round( + resolveSekAmount( + Math.abs(transaction.amount), + transaction.amount_sek != null ? Math.abs(transaction.amount_sek) : null, + transaction.currency, + transaction.exchange_rate, + ) * 100, + ) / 100 + let fx: FxConversion = { required: false } if (transaction.currency !== invoice.currency) { const manualRate = @@ -184,10 +226,6 @@ export const POST = withRouteContext( }, }) } - const txAbsSek = - transaction.currency === 'SEK' - ? Math.abs(transaction.amount) - : Math.abs(transaction.amount) * (transaction.exchange_rate ?? 1) const paidInInvoiceCurrency = Math.round((txAbsSek / rate) * 10000) / 10000 fx = { required: true, @@ -239,6 +277,11 @@ export const POST = withRouteContext( transactionId, transactionDate: transaction.date, transactionAmount: transaction.amount, + // `amount` is in `currency`; the 19xx legs the detector compares it + // against are always SEK. Selected above via select('*'). + transactionCurrency: transaction.currency ?? null, + transactionAmountSek: transaction.amount_sek ?? null, + transactionExchangeRate: transaction.exchange_rate ?? null, }) if (!force) { if (candidate) { @@ -376,7 +419,6 @@ export const POST = withRouteContext( const useCashEntry = !invoiceAlreadyBooked && accountingMethod === 'cash' && isFullyPaid let journalEntryId: string | null = null - let journalEntryError: string | null = null try { if (customLines) { @@ -463,6 +505,19 @@ export const POST = withRouteContext( fx.required ? fx.paidInInvoiceCurrency : undefined, paymentAccount, ) + // Re-propagate the invoice's default dimension bag onto every leg, + // including the FX result lines, so a project's kursvinst/kursförlust + // stays inside the project P&L. createInvoicePaymentJournalEntry does + // this for its own callers; the shared line-builder is dimension- + // agnostic, so the two routes that use it have to do it themselves or + // dimension users silently lose the tagging on payment vouchers. + // Copied per line: a shared object would let one line's mutation leak. + const defaultDimensions = coerceDimensionsBag( + (invoice as { default_dimensions?: unknown }).default_dimensions, + ) + if (defaultDimensions) { + for (const line of clearingLines) line.dimensions = { ...defaultDimensions } + } const journalEntry = await createJournalEntry(supabase, companyId!, user.id, { fiscal_period_id: fiscalPeriodId, entry_date: transaction.date, @@ -478,11 +533,40 @@ export const POST = withRouteContext( if (err instanceof AccountsNotInChartError) { return errorResponse(err, txLog, { requestId }) } + // A foreign-currency invoice with no booking rate is a missing-input + // failure, not a transient booking failure: buildInvoicePaymentClearing + // Lines refuses rather than valuing the 1510 credit at a fabricated rate. + // Fully retryable once invoice.exchange_rate is on file. + // Dispatch on `code`, not instanceof: the class lives in a module route + // tests routinely vi.mock away, and the literal keeps a mocked-away + // export from turning into an `undefined === undefined` catch-all. + if ((err as { code?: unknown })?.code === 'MATCH_INVOICE_BOOKING_RATE_MISSING') { + return errorResponse(err, txLog, { requestId }) + } txLog.error('failed to create payment journal entry', err as Error) - // Other errors are recorded but don't abort the match: the user can - // re-book the verifikation manually. All errors map to Swedish via - // getErrorMessage; the raw message must never reach the user (issue #337). - journalEntryError = getErrorMessage(err, { context: 'invoice' }) + // ANY failed payment voucher fails the whole match (mirrors + // match-supplier-invoice): proceeding used to mark the invoice paid and + // link the transaction with NO verifikat, an unrecoverable half-state: + // mark-paid rejects 'paid' invoices and this route rejects linked + // transactions, so no flow could ever complete the booking afterwards. + // Typed bookkeeping errors (period locked, no fiscal period, ...) map + // to their registered envelope; everything else returns the invoice- + // side payment-failure code with a Swedish reason via getErrorMessage, + // so the raw message never reaches the user (issue #337). + if (isBookkeepingError(err)) { + return errorResponse(err, txLog, { requestId }) + } + return errorResponseFromCode('MATCH_INVOICE_RECORD_PAYMENT_FAILED', txLog, { + requestId, + details: { reason: getErrorMessage(err, { context: 'invoice' }) }, + }) + } + + if (!journalEntryId) { + // createJournalEntry resolved without an id: the same unrecoverable + // half-state as a thrown failure, so the match aborts here too + // (mirrors the supplier route's !journalEntryId guard). + return errorResponseFromCode('MATCH_INVOICE_RECORD_PAYMENT_FAILED', txLog, { requestId }) } // Underlag for the payment verifikation: re-attach the invoice PDF that @@ -652,13 +736,6 @@ export const POST = withRouteContext( txLog.warn('invoice.match_confirmed event emission failed', err as Error) } - if (journalEntryError) { - txLog.warn('match recorded but payment journal entry failed', { - errorCode: 'MATCH_INVOICE_PARTIAL', - message: journalEntryError, - }) - } - return NextResponse.json({ success: true, invoice_status: newStatus, @@ -666,7 +743,9 @@ export const POST = withRouteContext( paid_amount: newPaidAmount, remaining_amount: newRemaining, journal_entry_id: journalEntryId, - journal_entry_error: journalEntryError, + // Always null since a failed voucher now aborts the whole match; the + // field survives for response-shape compatibility with existing callers. + journal_entry_error: null, category: 'income_services', }) }, diff --git a/app/api/transactions/[id]/match-supplier-invoice/preview/route.ts b/app/api/transactions/[id]/match-supplier-invoice/preview/route.ts index ef57d535..25e30dbd 100644 --- a/app/api/transactions/[id]/match-supplier-invoice/preview/route.ts +++ b/app/api/transactions/[id]/match-supplier-invoice/preview/route.ts @@ -93,6 +93,58 @@ export const GET = withRouteContext( const siAlreadyBooked = !!(invoice as { registration_journal_entry_id?: string | null }).registration_journal_entry_id const useCashEntry = !siAlreadyBooked && accountingMethod === 'cash' + const si = invoice as SupplierInvoice & { items?: SupplierInvoiceItem[] } + + // Amount resolution, byte-identical to the POST handler: same inputs, same + // `Math.round(x * 100) / 100` form. This preview is what the user approves + // in the dialog, so any divergence here means one number is approved and a + // different one is booked. + const txAmountAbs = Math.abs(transaction.amount) + const remainingInvoiceCurrency = si.remaining_amount ?? si.total + // In the INVOICE's currency: a cross-currency match settles whatever + // remains rather than reading the bank figure as invoice currency. + const paymentAmountInvoiceCurrency = + transaction.currency === si.currency ? txAmountAbs : remainingInvoiceCurrency + // SEK that actually left the bank, when known. SEK bank line → the absolute + // amount; foreign line with a stored amount_sek → that value; foreign line + // WITHOUT amount_sek → unknown (null). The raw foreign amount must never + // stand in: that is what renders 19 USD as 19 kr. + const bankSekStored = + transaction.currency === 'SEK' + ? txAmountAbs + : transaction.amount_sek != null + ? Math.abs(transaction.amount_sek) + : null + const invoiceFxRate = si.exchange_rate ?? null + // SEK the invoice was booked at for this payment portion; null when the + // invoice is foreign and carries no exchange_rate. + const bookedSek = + si.currency === 'SEK' + ? paymentAmountInvoiceCurrency + : invoiceFxRate && invoiceFxRate > 0 + ? Math.round(paymentAmountInvoiceCurrency * invoiceFxRate * 100) / 100 + : null + const actualBankSek = bankSekStored ?? bookedSek + if (actualBankSek == null) { + // Neither side yields a SEK figure (foreign bank line without amount_sek + // paying a foreign invoice without exchange_rate). The POST handler + // refuses this row with the same code, so refuse here instead of + // previewing a 1:1 number that can never be committed. + return errorResponseFromCode('SI_FX_RATE_MISSING', log, { + requestId, + details: { + transaction_currency: transaction.currency, + invoice_currency: si.currency, + }, + }) + } + const originalBookedSek = bookedSek ?? actualBankSek + const exchangeRateDifference = + Math.round((originalBookedSek - actualBankSek) * 100) / 100 + const fullSettlement = + transaction.currency !== si.currency || + txAmountAbs >= remainingInvoiceCurrency - 0.005 + const lines: PreviewLine[] = [] let entryType: 'clearing' | 'cash' = 'clearing' // Drives the dialog's "markeras som betald" / öresavrundning copy. Cash @@ -102,25 +154,30 @@ export const GET = withRouteContext( if (useCashEntry) { entryType = 'cash' - const si = invoice as SupplierInvoice & { items?: SupplierInvoiceItem[] } const items = si.items ?? [] - // Kontantmetoden books the expense AT PAYMENT at the payment-date rate - // (the SEK that actually left the bank), so translate this preview the - // same way the committed verifikat does. The bank SEK is only known when - // the transaction is in SEK or carries a stored amount_sek; for a foreign - // transaction without it we fall back to the invoice's own rate (the raw - // foreign amount must never be used: that would render 19 USD as 19 kr). - const bankSek = - transaction.currency === 'SEK' - ? Math.abs(transaction.amount) - : transaction.amount_sek != null - ? Math.abs(transaction.amount_sek) - : null + // Kontantmetoden books the expense AT PAYMENT at the payment-date rate. + // The POST handler passes createSupplierInvoiceCashEntry a settledBankSek + // override only for a full foreign settlement whose rate actually moved; + // the builder then derives its rate as settledBankSek / invoice.total and + // otherwise keeps the invoice's stored rate. Mirror that derivation + // exactly (createSupplierInvoiceCashEntry's `effectiveRate`). + const settledBankSek = + exchangeRateDifference !== 0 && fullSettlement ? actualBankSek : undefined const cashRate = - bankSek != null && si.currency !== 'SEK' && si.total > 0 - ? bankSek / si.total + settledBankSek != null && settledBankSek > 0 && si.currency !== 'SEK' && si.total > 0 + ? settledBankSek / si.total : si.exchange_rate + // The builder routes every leg through toSekOrThrow, which refuses a + // foreign invoice with no usable rate rather than posting it as if + // 1 EUR = 1 SEK. Refuse the same rows here so the dialog can't display + // amounts the commit will reject. + if (si.currency !== 'SEK' && !(cashRate != null && cashRate > 0)) { + return errorResponseFromCode('SI_FX_RATE_MISSING', log, { + requestId, + details: { invoice_currency: si.currency }, + }) + } // Mirror createSupplierInvoiceCashEntry: per-item expense debit + VAT // debit + bank credit. We only need a faithful preview, not exact @@ -175,16 +232,13 @@ export const GET = withRouteContext( }) } else { // Clearing: Dr 2440 / Cr 1930 (or chosen payment account). - const si = invoice as SupplierInvoice const isPureSek = transaction.currency === 'SEK' && si.currency === 'SEK' if (isPureSek) { // Shared builder so the previewed lines (including any 3740 // öresavrundning row) are byte-identical to what the POST commits. - const remainingSek = si.remaining_amount ?? si.total - const bankSek = Math.abs(transaction.amount) const { lines: clearingLines, oreDiffSek } = buildSupplierPaymentClearingLines({ - apSek: remainingSek, - bankSek, + apSek: remainingInvoiceCurrency, + bankSek: txAmountAbs, paymentAccount, }) for (const l of clearingLines) { @@ -198,29 +252,49 @@ export const GET = withRouteContext( oreRounding = oreDiffSek !== 0 // Full settlement when the öre residual is absorbed or the bank covers // the whole remaining; a ≥1 kr short payment leaves a partial. - isFullyPaid = oreRounding || bankSek >= remainingSek - ORE_TOLERANCE + isFullyPaid = oreRounding || txAmountAbs >= remainingInvoiceCurrency - ORE_TOLERANCE } else { - const amountSek = resolveSekAmount( - Math.abs(transaction.amount), - null, - transaction.currency, - null, - ) - const total = resolveSekAmount(si.total, si.total_sek, si.currency, si.exchange_rate) - const amount = Math.round(Math.min(amountSek, total) * 100) / 100 + // Foreign leg under faktureringsmetoden. createSupplierInvoicePaymentEntry + // clears 2440 at the SEK the leverantörsskuld was BOOKED at and credits + // the bank with the SEK that actually moved, booking the difference as + // kursvinst (3960) or kursförlust (7960). The old preview showed a single + // min(bankSEK, invoiceSEK) figure on both legs and no FX line, so the + // bank credit the user approved differed from the committed one by + // exactly the kursdifferens (and, with no conversion inputs at all, + // showed the raw foreign amount as kronor). lines.push({ account_number: '2440', - debit_amount: amount, + debit_amount: Math.round(originalBookedSek * 100) / 100, credit_amount: 0, description: 'Kvittning leverantörsskuld', }) lines.push({ account_number: paymentAccount, debit_amount: 0, - credit_amount: amount, + credit_amount: Math.round(actualBankSek * 100) / 100, description: 'Utbetalning från bank', }) - isFullyPaid = amount >= total - ORE_TOLERANCE + if (exchangeRateDifference > 0) { + lines.push({ + account_number: '3960', + debit_amount: 0, + credit_amount: Math.round(Math.abs(exchangeRateDifference) * 100) / 100, + description: 'Valutakursvinst', + }) + } else if (exchangeRateDifference < 0) { + lines.push({ + account_number: '7960', + debit_amount: Math.round(Math.abs(exchangeRateDifference) * 100) / 100, + credit_amount: 0, + description: 'Valutakursförlust', + }) + } + // Mirrors planSupplierPayment without öre absorption (the accrual FX + // path never absorbs): a cross-currency match is clamped to the + // remaining balance and therefore always settles in full; a + // same-currency foreign match settles when the bank amount covers it. + isFullyPaid = + paymentAmountInvoiceCurrency >= remainingInvoiceCurrency - ORE_TOLERANCE } } diff --git a/app/api/transactions/[id]/match-supplier-invoice/route.ts b/app/api/transactions/[id]/match-supplier-invoice/route.ts index 468c4626..8a2b88d6 100644 --- a/app/api/transactions/[id]/match-supplier-invoice/route.ts +++ b/app/api/transactions/[id]/match-supplier-invoice/route.ts @@ -178,10 +178,27 @@ export const POST = withRouteContext( // Actual SEK leaving the bank. Prefer the stored bank figure; if a foreign // transaction has no amount_sek, fall back to the invoice's booked SEK so // the magnitude is right (→ exchangeRateDifference 0, i.e. "no independent - // bank figure to reconcile against"). Last resort, with no invoice rate - // either, is the raw amount. The FX diff hits 7960/3960 so 2440 clears - // cleanly whenever bank-paid SEK genuinely differs from booked SEK. - const actualBankSek = bankSekStored ?? bookedSek ?? txAmountAbs + // bank figure to reconcile against"). The FX diff hits 7960/3960 so 2440 + // clears cleanly whenever bank-paid SEK genuinely differs from booked SEK. + // + // When BOTH are unknown (foreign bank line without amount_sek paying a + // foreign invoice without exchange_rate) there is no SEK figure at all. + // The old last resort was the raw foreign amount, which is precisely what + // the comment above forbids: 19 USD posted as 19 kr on a ~175 kr payment, + // and the entry still balances so no trigger catches it. Refuse instead, + // same policy as the match_batch_allocate RPC (BATCH_FX_RATE_MISSING) and + // toSekOrThrow() in the entry generators. Rejecting here, before any JE or + // ledger write, leaves the match fully retryable once the rate is filled in. + const actualBankSek = bankSekStored ?? bookedSek + if (actualBankSek == null) { + return errorResponseFromCode('SI_FX_RATE_MISSING', txLog, { + requestId, + details: { + transaction_currency: transaction.currency, + invoice_currency: invoice.currency, + }, + }) + } const originalBookedSek = bookedSek ?? actualBankSek // Positive = gain (AP credited at more SEK than the bank actually paid). @@ -316,6 +333,15 @@ export const POST = withRouteContext( // mark the invoice paid with NO voucher: an unrecoverable half-state: // mark-paid rejects 'paid' invoices and this route rejects linked // transactions, so no flow could ever complete the booking afterwards. + // The cash-method builder converts every leg through toSekOrThrow, so a + // foreign invoice with no usable rate surfaces here as + // SupplierInvoiceFxRateMissingError. Dispatch on its `code` (not + // instanceof: the class is routinely vi.mock'ed away) so errorResponse + // maps it to the registered 400 "ange fakturans växelkurs" entry instead + // of a generic 500. Same code the preview returns for the same row. + if ((err as { code?: unknown })?.code === 'SI_FX_RATE_MISSING') { + return errorResponse(err, txLog, { requestId }) + } if (isBookkeepingError(err)) { return errorResponse(err, txLog, { requestId }) } diff --git a/app/api/transactions/bulk-book/__tests__/route.test.ts b/app/api/transactions/bulk-book/__tests__/route.test.ts index 159a0c52..9dc31984 100644 --- a/app/api/transactions/bulk-book/__tests__/route.test.ts +++ b/app/api/transactions/bulk-book/__tests__/route.test.ts @@ -75,6 +75,14 @@ describe('POST /api/transactions/bulk-book', () => { }) it('link path passes through to RPC and returns the success envelope', async () => { + // Currency gate tx fetch (hoisted: runs on every path). + enqueue({ + data: [ + { id: TX1, amount: 100, currency: 'SEK', description: 'Swish 1', date: '2026-06-05' }, + { id: TX2, amount: 200, currency: 'SEK', description: 'Swish 2', date: '2026-06-05' }, + ], + error: null, + }) // RPC returns the link-existing happy path. enqueue({ data: { @@ -109,6 +117,14 @@ describe('POST /api/transactions/bulk-book', () => { }) it('create-new path fetches template, expands per mode, and calls RPC', async () => { + // Tx fetch (hoisted for the currency gate): 2 incomes totalling 300. + enqueue({ + data: [ + { id: TX1, amount: 100, currency: 'SEK', description: 'Swish 1', date: '2026-06-05' }, + { id: TX2, amount: 200, currency: 'SEK', description: 'Swish 2', date: '2026-06-05' }, + ], + error: null, + }) // Template fetch. enqueue({ data: { @@ -123,14 +139,6 @@ describe('POST /api/transactions/bulk-book', () => { }, error: null, }) - // Tx fetch: 2 incomes totalling 300. - enqueue({ - data: [ - { id: TX1, amount: 100, currency: 'SEK', description: 'Swish 1', date: '2026-06-05' }, - { id: TX2, amount: 200, currency: 'SEK', description: 'Swish 2', date: '2026-06-05' }, - ], - error: null, - }) // applyTemplate stub: return a balanced 3-line set per call. vi.mocked(applyTemplate).mockImplementation((_lines, total) => [ @@ -178,6 +186,14 @@ describe('POST /api/transactions/bulk-book', () => { }) it('maps RPC structured failure code to errorResponseFromCode', async () => { + // Currency gate tx fetch. + enqueue({ + data: [ + { id: TX1, amount: 100, currency: 'SEK', description: 'Swish 1', date: '2026-06-05' }, + { id: TX2, amount: 200, currency: 'SEK', description: 'Swish 2', date: '2026-06-06' }, + ], + error: null, + }) enqueue({ data: { ok: false, code: 'BULK_BOOK_DATE_MISMATCH', details: { expected: '2026-06-05', got: '2026-06-06' } }, error: null, @@ -193,3 +209,203 @@ describe('POST /api/transactions/bulk-book', () => { expect(body.error.code).toBe('BULK_BOOK_DATE_MISMATCH') }) }) + +/** + * Mixed-currency guard (BFL 4 kap 6 §: one redovisningsvaluta). A + * samlingsverifikation spanning SEK and EUR has no representable single + * belopp, so all three request shapes must refuse it with the SAME code the + * MCP twin uses. Before this, only the template branch checked; manual_lines + * and existing_journal_entry_id went straight to the RPC, which summed + * 100 EUR + 100 SEK into the scalar 200. + */ +describe('POST /api/transactions/bulk-book: mixed-currency guard', () => { + const mockUser = { id: 'user-1', email: 'test@test.se' } + + const MIXED_TXS = [ + { id: TX1, amount: 100, currency: 'SEK', description: 'Swish', date: '2026-06-05' }, + { id: TX2, amount: 100, currency: 'EUR', description: 'Stripe', date: '2026-06-05' }, + ] + + beforeEach(() => { + vi.clearAllMocks() + reset() + mockSupabase.auth.getUser.mockResolvedValue({ data: { user: mockUser } }) + }) + + it('refuses a mixed-currency selection on the template branch', async () => { + enqueue({ data: MIXED_TXS, error: null }) + + const request = createMockRequest('/api/transactions/bulk-book', { + method: 'POST', + body: { + tx_ids: [TX1, TX2], + template_id: TPL, + mode: 'sum_per_account', + entry_description: 'Samlingsverifikation', + }, + }) + const response = await POST(request) + const { status, body } = await parseJsonResponse<{ + error: { code: string; details?: { currencies?: string[] } } + }>(response) + expect(status).toBe(400) + expect(body.error.code).toBe('BULK_BOOK_MIXED_CURRENCY') + }) + + it('refuses a mixed-currency selection on the manual_lines branch', async () => { + enqueue({ data: MIXED_TXS, error: null }) + + const request = createMockRequest('/api/transactions/bulk-book', { + method: 'POST', + body: { + tx_ids: [TX1, TX2], + entry_description: 'Samlingsverifikation', + manual_lines: [ + { account_number: '1930', debit_amount: 200, credit_amount: 0, currency: 'SEK' }, + { account_number: '3001', debit_amount: 0, credit_amount: 200, currency: 'SEK' }, + ], + }, + }) + const response = await POST(request) + const { status, body } = await parseJsonResponse<{ error: { code: string } }>(response) + expect(status).toBe(400) + expect(body.error.code).toBe('BULK_BOOK_MIXED_CURRENCY') + // Refused before the RPC: the chart-of-accounts lookup never ran either. + expect(mockSupabase.rpc).not.toHaveBeenCalled() + }) + + it('refuses a mixed-currency selection on the existing_journal_entry_id branch', async () => { + enqueue({ data: MIXED_TXS, error: null }) + + const request = createMockRequest('/api/transactions/bulk-book', { + method: 'POST', + body: { tx_ids: [TX1, TX2], existing_journal_entry_id: JE }, + }) + const response = await POST(request) + const { status, body } = await parseJsonResponse<{ error: { code: string } }>(response) + expect(status).toBe(400) + expect(body.error.code).toBe('BULK_BOOK_MIXED_CURRENCY') + expect(mockSupabase.rpc).not.toHaveBeenCalled() + }) + + it('refuses a homogeneous non-SEK selection with BULK_BOOK_FOREIGN_CURRENCY', async () => { + // Same currency throughout, but not kronor: the RPC would write the + // foreign magnitudes into the always-SEK debit/credit columns, so the + // route refuses before the RPC just like the mixed-currency case. + enqueue({ + data: [ + { id: TX1, amount: 100, currency: 'EUR', description: 'Stripe 1', date: '2026-06-05' }, + { id: TX2, amount: 200, currency: 'EUR', description: 'Stripe 2', date: '2026-06-05' }, + ], + error: null, + }) + + const request = createMockRequest('/api/transactions/bulk-book', { + method: 'POST', + body: { + tx_ids: [TX1, TX2], + entry_description: 'Samlingsverifikation', + manual_lines: [ + { account_number: '1930', debit_amount: 300, credit_amount: 0, currency: 'EUR' }, + { account_number: '3001', debit_amount: 0, credit_amount: 300, currency: 'EUR' }, + ], + }, + }) + const response = await POST(request) + const { status, body } = await parseJsonResponse<{ + error: { code: string; details?: { currency?: string } } + }>(response) + expect(status).toBe(400) + expect(body.error.code).toBe('BULK_BOOK_FOREIGN_CURRENCY') + expect(body.error.details?.currency).toBe('EUR') + expect(mockSupabase.rpc).not.toHaveBeenCalled() + }) + + it('books a single-currency manual_lines selection', async () => { + // Tx fetch: both SEK. + enqueue({ + data: [ + { id: TX1, amount: 100, currency: 'SEK', description: 'Swish', date: '2026-06-05' }, + { id: TX2, amount: 100, currency: 'SEK', description: 'Swish', date: '2026-06-05' }, + ], + error: null, + }) + // chart_of_accounts allowlist. + enqueue({ + data: [{ account_number: '1930' }, { account_number: '3001' }], + error: null, + }) + // Dimension rules: none configured. + enqueue({ data: [], error: null }) + // RPC happy path. + enqueue({ + data: { + ok: true, + mode: 'create_new', + journal_entry_id: JE, + voucher_series: 'A', + voucher_number: 14, + linked_tx_count: 2, + tx_sum: 200, + }, + error: null, + }) + // Event re-fetch. + enqueue({ data: [], error: null }) + + const request = createMockRequest('/api/transactions/bulk-book', { + method: 'POST', + body: { + tx_ids: [TX1, TX2], + entry_description: 'Samlingsverifikation', + manual_lines: [ + { account_number: '1930', debit_amount: 200, credit_amount: 0, currency: 'SEK' }, + { account_number: '3001', debit_amount: 0, credit_amount: 200, currency: 'SEK' }, + ], + }, + }) + const response = await POST(request) + const { status, body } = await parseJsonResponse<{ data: { mode: string } }>(response) + expect(status).toBe(200) + expect(body.data.mode).toBe('create_new') + }) + + it('treats NULL currency as SEK and still books', async () => { + enqueue({ + data: [ + { id: TX1, amount: 100, currency: null, description: 'Legacy row', date: '2026-06-05' }, + { id: TX2, amount: 100, currency: 'SEK', description: 'Swish', date: '2026-06-05' }, + ], + error: null, + }) + enqueue({ data: [{ account_number: '1930' }, { account_number: '3001' }], error: null }) + enqueue({ data: [], error: null }) + enqueue({ + data: { + ok: true, + mode: 'create_new', + journal_entry_id: JE, + voucher_series: 'A', + voucher_number: 15, + linked_tx_count: 2, + tx_sum: 200, + }, + error: null, + }) + enqueue({ data: [], error: null }) + + const request = createMockRequest('/api/transactions/bulk-book', { + method: 'POST', + body: { + tx_ids: [TX1, TX2], + entry_description: 'Samlingsverifikation', + manual_lines: [ + { account_number: '1930', debit_amount: 200, credit_amount: 0, currency: 'SEK' }, + { account_number: '3001', debit_amount: 0, credit_amount: 200, currency: 'SEK' }, + ], + }, + }) + const response = await POST(request) + expect(response.status).toBe(200) + }) +}) diff --git a/app/api/transactions/bulk-book/route.ts b/app/api/transactions/bulk-book/route.ts index 2394d775..951bb6c2 100644 --- a/app/api/transactions/bulk-book/route.ts +++ b/app/api/transactions/bulk-book/route.ts @@ -82,6 +82,66 @@ export const POST = withRouteContext( const opLog = log.child({ txCount: body.tx_ids.length }) + // Fetch the selected txs ONCE, up front, for every path. The template + // branch needs the amounts to expand the template; all three branches + // need the currencies for the homogeneity gate below. + const { data: txs, error: txError } = await supabase + .from('transactions') + .select('id, amount, currency, description, date') + .in('id', body.tx_ids) + .eq('company_id', companyId) + + if (txError || !txs || txs.length === 0) { + return errorResponseFromCode('BULK_BOOK_TXS_NOT_FOUND', opLog, { requestId }) + } + if (txs.length !== body.tx_ids.length) { + return errorResponseFromCode('BULK_BOOK_TXS_NOT_FOUND', opLog, { + requestId, + details: { expected: body.tx_ids.length, found: txs.length }, + }) + } + + const txTyped = txs as Pick[] + + // Currency homogeneity, enforced BEFORE the branch split so it covers + // all three paths (template, manual_lines, existing_journal_entry_id). + // BFL 4 kap 6 § requires the bokföring to be presented in one and the + // same redovisningsvaluta. A samlingsverifikation mixing e.g. SEK and + // EUR has no representable single belopp: summing the raw amounts adds + // 100 EUR to 100 SEK as if they were one unit, and the verifikat would + // then state an amount matching no affärshändelse (BFL 5 kap 7 § + // "belopp"). Nothing the caller can pass makes that correct without + // per-tx FX rates, so this refuses instead of warning. Mirrors the MCP + // twin (gnubok_bulk_book_transactions), which guards the same three + // paths; cross-currency batches belong in the FX-aware batch-allocate + // flow (kursdifferens on 7960/3960). + // NULL currency is legacy for the column default 'SEK' (the codebase + // reads it that way everywhere), so it is normalized before comparison: + // a NULL/SEK selection is not a currency mix and must stay bookable. + const currencies = new Set(txTyped.map((t) => t.currency ?? 'SEK')) + if (currencies.size > 1) { + return errorResponseFromCode('BULK_BOOK_MIXED_CURRENCY', opLog, { + requestId, + details: { currencies: Array.from(currencies).sort() }, + }) + } + const currency = txTyped[0]!.currency ?? 'SEK' + + // A HOMOGENEOUS foreign batch is refused too: the RPC writes the line + // amounts into journal_entry_lines.debit_amount/credit_amount, which are + // ALWAYS kronor, and neither the route nor the RPC carries an exchange + // rate here. Two EUR transactions of 100 + 200 would produce a verifikat + // whose 300 is read as kronor by balansräkning, momsdeklaration and SIE + // export. Foreign-currency transactions are booked individually through + // the FX-aware flows, which resolve a rate and book the kursdifferens. + // The RPC enforces the same refusal for callers that bypass this route. + if (currency !== 'SEK') { + return errorResponseFromCode('BULK_BOOK_FOREIGN_CURRENCY', opLog, { + requestId, + details: { currency }, + }) + } + // Three paths now (PR #608): // 1. existing_journal_entry_id → null new_entry, RPC links txs to JE. // 2. template_id → route expands template per mode, builds lines. @@ -156,40 +216,9 @@ export const POST = withRouteContext( const templateLines = (template.lines ?? []) as BookingTemplateLibraryLine[] - // Need each tx's amount + currency to expand per mode. The RPC also - // re-validates (date, direction, not-already-booked) but we need the - // amount sum to drive the template expansion. - const { data: txs, error: txError } = await supabase - .from('transactions') - .select('id, amount, currency, description, date') - .in('id', body.tx_ids) - .eq('company_id', companyId) - - if (txError || !txs || txs.length === 0) { - return errorResponseFromCode('BULK_BOOK_TXS_NOT_FOUND', opLog, { requestId }) - } - if (txs.length !== body.tx_ids.length) { - return errorResponseFromCode('BULK_BOOK_TXS_NOT_FOUND', opLog, { - requestId, - details: { expected: body.tx_ids.length, found: txs.length }, - }) - } - - const txTyped = txs as Pick[] - - // Same-currency invariant for v1. Mixed-currency batches would need - // FX conversion per tx; out of scope. Use the dedicated - // BULK_BOOK_MIXED_CURRENCY code so the toast doesn't blame direction - // (PR #606 review fix). - const currencies = new Set(txTyped.map((t) => t.currency)) - if (currencies.size > 1) { - return errorResponseFromCode('BULK_BOOK_MIXED_CURRENCY', opLog, { - requestId, - details: { currencies: Array.from(currencies) }, - }) - } - const currency = txTyped[0]!.currency - + // The tx rows (amount + currency) were fetched and currency-gated + // above; the RPC still re-validates date, direction, and + // not-already-booked. const txAbsAmounts = txTyped.map((t) => Math.abs(t.amount)) const totalAbs = round2(txAbsAmounts.reduce((s, a) => s + a, 0)) diff --git a/app/api/v1/companies/[companyId]/__tests__/cursor-pagination.test.ts b/app/api/v1/companies/[companyId]/__tests__/cursor-pagination.test.ts new file mode 100644 index 00000000..f8ab670c --- /dev/null +++ b/app/api/v1/companies/[companyId]/__tests__/cursor-pagination.test.ts @@ -0,0 +1,641 @@ +/** + * Cursor-pagination contract for every v1 list endpoint that pages with the + * default (created_at, id) keyset cursor. + * + * Regression lock for the P0 where invoices / journal-entries / + * supplier-invoices encoded the cursor on a Postgres `date` column + * (invoice_date / entry_date). PostgREST serializes `date` as "2026-07-25", + * `decodeDefaultCursor` rejects anything that is not a full ISO-8601 + * timestamp, so the keyset predicate was never applied: page 2 returned + * page 1, forever, while still advertising a fresh next_cursor. An + * integrator syncing verifikat looped on the newest rows indefinitely. + * + * The mock here is NOT the usual pass-through Proxy: it is a small in-memory + * PostgREST that actually evaluates .eq/.neq/.gte/.lte/.or/.order/.limit. + * A pass-through mock cannot catch this bug at all, because the bug is that + * the filter is never sent. Anything the routes emit that the mini-parser + * does not understand throws, so the suite fails loudly instead of passing + * vacuously. + */ +import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' + +beforeAll(() => { + if (process.env.NODE_ENV !== 'test') { + throw new Error( + `cursor pagination tests require NODE_ENV=test (got ${process.env.NODE_ENV ?? 'undefined'})`, + ) + } + process.env.NEXT_PUBLIC_SUPABASE_URL ||= 'http://localhost:54321' + process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY ||= 'test-anon-key' +}) + +vi.mock('@/lib/auth/api-keys', async () => { + const actual = await vi.importActual('@/lib/auth/api-keys') + return { + ...actual, + validateApiKey: vi.fn(), + createServiceClientNoCookies: vi.fn(), + } +}) + +vi.mock('@supabase/supabase-js', async () => { + const actual = await vi.importActual('@supabase/supabase-js') + return { ...actual, createClient: vi.fn().mockReturnValue({}) } +}) + +import { validateApiKey, createServiceClientNoCookies } from '@/lib/auth/api-keys' +import { decodeDefaultCursor } from '@/lib/api/v1/pagination' +import { GET as listInvoices } from '../invoices/route' +import { GET as listJournalEntries } from '../journal-entries/route' +import { GET as listSupplierInvoices } from '../supplier-invoices/route' +import { GET as listTransactions } from '../transactions/route' + +const mockValidate = validateApiKey as ReturnType +const mockServiceClient = createServiceClientNoCookies as ReturnType + +// ────────────────────────────────────────────────────────────────── +// In-memory PostgREST +// ────────────────────────────────────────────────────────────────── + +type Row = Record + +/** + * String comparison, which matches Postgres ordering for the two column + * kinds this harness sorts on: same-format ISO-8601 timestamps (lexical + * order == chronological order) and lowercase-hex UUIDs. + */ +function cmp(a: unknown, b: unknown): number { + const left = a === null || a === undefined ? '' : String(a) + const right = b === null || b === undefined ? '' : String(b) + return left < right ? -1 : left > right ? 1 : 0 +} + +/** Split a PostgREST filter list on commas that are not inside and(...)/or(...). */ +function splitTopLevel(expression: string): string[] { + const parts: string[] = [] + let depth = 0 + let current = '' + for (const ch of expression) { + if (ch === '(') depth++ + if (ch === ')') depth-- + if (ch === ',' && depth === 0) { + parts.push(current) + current = '' + continue + } + current += ch + } + if (current) parts.push(current) + return parts +} + +/** Evaluate a single `column.operator.value` term (or a nested and(...) group). */ +function matchesTerm(row: Row, term: string): boolean { + if (term.startsWith('and(') && term.endsWith(')')) { + return splitTopLevel(term.slice(4, -1)).every((sub) => matchesTerm(row, sub)) + } + const firstDot = term.indexOf('.') + const secondDot = term.indexOf('.', firstDot + 1) + if (firstDot === -1 || secondDot === -1) { + throw new Error(`in-memory PostgREST: unsupported filter term "${term}"`) + } + const column = term.slice(0, firstDot) + const operator = term.slice(firstDot + 1, secondDot) + const value = term.slice(secondDot + 1) + const delta = cmp(row[column], value) + switch (operator) { + case 'lt': + return delta < 0 + case 'lte': + return delta <= 0 + case 'gt': + return delta > 0 + case 'gte': + return delta >= 0 + case 'eq': + return delta === 0 + case 'neq': + return delta !== 0 + default: + throw new Error(`in-memory PostgREST: unsupported operator "${operator}" in "${term}"`) + } +} + +function makeKeysetSupabase(tables: Record) { + const from = vi.fn((table: string) => { + let rows = [...(tables[table] ?? [])] + const orders: Array<{ column: string; ascending: boolean }> = [] + let limitValue: number | null = null + let singleRow = false + + const builder = { + select: () => builder, + eq: (column: string, value: unknown) => { + rows = rows.filter((r) => cmp(r[column], value) === 0) + return builder + }, + neq: (column: string, value: unknown) => { + rows = rows.filter((r) => cmp(r[column], value) !== 0) + return builder + }, + gte: (column: string, value: unknown) => { + rows = rows.filter((r) => cmp(r[column], value) >= 0) + return builder + }, + lte: (column: string, value: unknown) => { + rows = rows.filter((r) => cmp(r[column], value) <= 0) + return builder + }, + is: (column: string, value: unknown) => { + rows = rows.filter((r) => (r[column] ?? null) === value) + return builder + }, + not: (column: string, operator: string, value: unknown) => { + if (operator !== 'is') { + throw new Error(`in-memory PostgREST: unsupported not() operator "${operator}"`) + } + rows = rows.filter((r) => (r[column] ?? null) !== value) + return builder + }, + or: (expression: string) => { + const terms = splitTopLevel(expression) + rows = rows.filter((r) => terms.some((term) => matchesTerm(r, term))) + return builder + }, + order: (column: string, options?: { ascending?: boolean }) => { + orders.push({ column, ascending: options?.ascending !== false }) + return builder + }, + limit: (count: number) => { + limitValue = count + return builder + }, + maybeSingle: () => { + singleRow = true + return builder + }, + single: () => { + singleRow = true + return builder + }, + then: (resolve: (value: unknown) => void) => { + const sorted = [...rows].sort((a, b) => { + for (const o of orders) { + const delta = cmp(a[o.column], b[o.column]) + if (delta !== 0) return o.ascending ? delta : -delta + } + return 0 + }) + const sliced = limitValue === null ? sorted : sorted.slice(0, limitValue) + resolve(singleRow ? { data: sliced[0] ?? null, error: null } : { data: sliced, error: null }) + }, + } + return builder + }) + + return { from, rpc: vi.fn(async () => ({ data: null, error: null })) } +} + +// ────────────────────────────────────────────────────────────────── +// Fixtures +// ────────────────────────────────────────────────────────────────── + +const COMPANY_ID = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa' +const CUSTOMER_ID = 'cccccccc-cccc-4ccc-8ccc-cccccccccccc' +const SUPPLIER_ID = 'dddddddd-dddd-4ddd-8ddd-dddddddddddd' +const FISCAL_PERIOD_ID = 'eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeee' +const USER_ID = 'user-1' + +// Ascending lexical order, which is also the id tie-break order. +const IDS = [ + '11111111-1111-4111-8111-111111111111', + '22222222-2222-4222-8222-222222222222', + '33333333-3333-4333-8333-333333333333', + '44444444-4444-4444-8444-444444444444', + '55555555-5555-4555-8555-555555555555', + '66666666-6666-4666-8666-666666666666', + '77777777-7777-4777-8777-777777777777', +] + +// Rows 3 and 4 deliberately share a created_at so the id tie-break is +// exercised at a page boundary (limit=2 splits the pair across pages 2/3). +const CREATED_AT = [ + '2026-07-25T10:00:06.000Z', + '2026-07-25T10:00:05.000Z', + '2026-07-25T10:00:04.000Z', + '2026-07-25T10:00:03.000Z', + '2026-07-25T10:00:03.000Z', + '2026-07-25T10:00:02.000Z', + '2026-07-25T10:00:01.000Z', +] + +// Every row carries the SAME business date: this is the bulk-import shape +// that made the old date-anchored cursor loop forever. +const BUSINESS_DATE = '2026-07-25' + +/** Expected read order under (created_at DESC, id ASC). */ +const EXPECTED_ORDER = IDS + +const MEMBERSHIP = [{ user_id: USER_ID, company_id: COMPANY_ID, role: 'owner' }] + +function invoiceRows(): Row[] { + return IDS.map((id, i) => ({ + id, + company_id: COMPANY_ID, + invoice_number: `F-10${i}`, + customer_id: CUSTOMER_ID, + invoice_date: BUSINESS_DATE, + due_date: '2026-08-24', + status: 'sent', + document_type: 'invoice', + currency: 'SEK', + subtotal: 1000, + vat_amount: 250, + total: 1250, + remaining_amount: 1250, + paid_at: null, + created_at: CREATED_AT[i], + customer: { id: CUSTOMER_ID, name: 'Acme AB' }, + })) +} + +function journalEntryRows(): Row[] { + return IDS.map((id, i) => ({ + id, + company_id: COMPANY_ID, + fiscal_period_id: FISCAL_PERIOD_ID, + voucher_series: 'A', + voucher_number: 100 + i, + entry_date: BUSINESS_DATE, + description: `Verifikat ${100 + i}`, + status: 'posted', + source_type: 'manual', + source_id: null, + notes: null, + reverses_id: null, + reversed_by_id: null, + correction_of_id: null, + created_at: CREATED_AT[i], + updated_at: CREATED_AT[i], + })) +} + +function supplierInvoiceRows(): Row[] { + return IDS.map((id, i) => ({ + id, + company_id: COMPANY_ID, + supplier_id: SUPPLIER_ID, + arrival_number: 40 + i, + supplier_invoice_number: `2026-12${i}`, + invoice_date: BUSINESS_DATE, + due_date: '2026-08-24', + status: 'registered', + currency: 'SEK', + subtotal: 1000, + vat_amount: 250, + total: 1250, + paid_amount: 0, + remaining_amount: 1250, + is_credit_note: false, + paid_at: null, + created_at: CREATED_AT[i], + supplier: { id: SUPPLIER_ID, name: 'Office Depot AB' }, + })) +} + +function transactionRows(): Row[] { + return IDS.map((id, i) => ({ + id, + company_id: COMPANY_ID, + date: BUSINESS_DATE, + description: `Kortköp ${i}`, + amount: -100 - i, + currency: 'SEK', + reference: null, + merchant_name: null, + journal_entry_id: null, + invoice_id: null, + supplier_invoice_id: null, + is_business: null, + category: null, + import_source: 'csv', + created_at: CREATED_AT[i], + })) +} + +// ────────────────────────────────────────────────────────────────── +// Endpoint table +// ────────────────────────────────────────────────────────────────── + +type ListHandler = ( + request: Request, + params: { params: Promise<{ companyId: string }> }, +) => Promise + +interface ListEndpointCase { + name: string + segment: string + table: string + scope: string + handler: ListHandler + rows: () => Row[] + /** A query string that must fail validation with 400. */ + invalidQuery: string +} + +const ENDPOINTS: ListEndpointCase[] = [ + { + name: 'invoices', + segment: 'invoices', + table: 'invoices', + scope: 'invoices:read', + handler: listInvoices as ListHandler, + rows: invoiceRows, + invalidQuery: 'status=quantum', + }, + { + name: 'journal-entries', + segment: 'journal-entries', + table: 'journal_entries', + scope: 'reports:read', + handler: listJournalEntries as ListHandler, + rows: journalEntryRows, + invalidQuery: 'status=quantum', + }, + { + name: 'supplier-invoices', + segment: 'supplier-invoices', + table: 'supplier_invoices', + scope: 'suppliers:read', + handler: listSupplierInvoices as ListHandler, + rows: supplierInvoiceRows, + invalidQuery: 'date_from=2026/07/25', + }, + { + name: 'transactions', + segment: 'transactions', + table: 'transactions', + scope: 'transactions:read', + handler: listTransactions as ListHandler, + rows: transactionRows, + invalidQuery: 'status=unknown', + }, +] + +const ALL_SCOPES = ENDPOINTS.map((e) => e.scope) + +function makeRequest(url: string, withAuth = true): Request { + return new Request(url, { + method: 'GET', + headers: withAuth ? { Authorization: 'Bearer test-fixture-not-a-real-key' } : {}, + }) +} + +function companyParams(companyId: string) { + return { params: Promise.resolve({ companyId }) } +} + +interface Page { + ids: string[] + nextCursor: string | undefined +} + +async function fetchPage( + endpoint: ListEndpointCase, + query: string, +): Promise<{ status: number; page: Page; body: { data: Array<{ id: string }>; meta: { next_cursor?: string } } }> { + const res = await endpoint.handler( + makeRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/${endpoint.segment}?${query}`), + companyParams(COMPANY_ID), + ) + const body = await res.json() + return { + status: res.status, + body, + page: { + ids: (body.data ?? []).map((row: { id: string }) => row.id), + nextCursor: body.meta?.next_cursor, + }, + } +} + +beforeEach(() => { + vi.clearAllMocks() + mockValidate.mockResolvedValue({ + userId: USER_ID, + companyId: COMPANY_ID, + apiKeyId: 'ak_1', + apiKeyName: 'CI key', + scopes: ALL_SCOPES, + mode: 'live', + }) +}) + +// ────────────────────────────────────────────────────────────────── +// The decisive tests +// ────────────────────────────────────────────────────────────────── + +describe.each(ENDPOINTS)('GET /api/v1/companies/:companyId/$name cursor pagination', (endpoint) => { + function mountRows() { + mockServiceClient.mockReturnValue( + makeKeysetSupabase({ + company_members: MEMBERSHIP, + [endpoint.table]: endpoint.rows(), + }), + ) + } + + it('page 2 shares zero ids with page 1', async () => { + mountRows() + const first = await fetchPage(endpoint, 'limit=2') + expect(first.status).toBe(200) + expect(first.page.ids).toHaveLength(2) + expect(first.page.nextCursor).toBeTruthy() + + const second = await fetchPage(endpoint, `limit=2&cursor=${encodeURIComponent(first.page.nextCursor!)}`) + expect(second.status).toBe(200) + expect(second.page.ids).toHaveLength(2) + + const overlap = second.page.ids.filter((id) => first.page.ids.includes(id)) + expect(overlap).toEqual([]) + }) + + it('the emitted cursor is a decodable (timestamp, uuid) pair', async () => { + mountRows() + const first = await fetchPage(endpoint, 'limit=2') + // The whole bug was an encoder/decoder mismatch: a cursor that the + // route's own decoder throws away silently disables the keyset. + const decoded = decodeDefaultCursor(first.page.nextCursor!) + expect(decoded).not.toBeNull() + expect(decoded!.id).toBe(first.page.ids[first.page.ids.length - 1]) + }) + + it('walks every row exactly once and terminates', async () => { + mountRows() + const seen: string[] = [] + let cursor: string | undefined + let pages = 0 + + // Hard stop well above the 4 pages this fixture needs: an unterminated + // walk fails the assertion below instead of hanging CI. + while (pages < 20) { + pages++ + const query = cursor ? `limit=2&cursor=${encodeURIComponent(cursor)}` : 'limit=2' + const { status, page } = await fetchPage(endpoint, query) + expect(status).toBe(200) + seen.push(...page.ids) + if (!page.nextCursor) break + cursor = page.nextCursor + } + + expect(pages).toBe(4) // 7 rows at limit=2 + expect(seen).toEqual(EXPECTED_ORDER) + expect(new Set(seen).size).toBe(EXPECTED_ORDER.length) + }) + + it('breaks created_at ties on id so a tied pair is not re-served', async () => { + mountRows() + // Rows 3 and 4 share a created_at. Page 2 ends on row 3; page 3 must + // start on row 4 rather than repeating the tied timestamp block. + const first = await fetchPage(endpoint, 'limit=2') + const second = await fetchPage(endpoint, `limit=2&cursor=${encodeURIComponent(first.page.nextCursor!)}`) + expect(second.page.ids).toEqual([IDS[2], IDS[3]]) + const third = await fetchPage(endpoint, `limit=2&cursor=${encodeURIComponent(second.page.nextCursor!)}`) + expect(third.page.ids).toEqual([IDS[4], IDS[5]]) + }) + + it('does not advertise a next_cursor on the final page', async () => { + mountRows() + const { page } = await fetchPage(endpoint, 'limit=100') + expect(page.ids).toEqual(EXPECTED_ORDER) + expect(page.nextCursor).toBeUndefined() + }) + + it('fails soft on a tampered cursor: restarts at page 1 with 200', async () => { + // pagination.ts documents the contract at its ISO_TIMESTAMP guard: a + // stale or corrupt cursor is discarded and treated as "start from the + // beginning", never a 400. + const garbage = [ + 'not-base64-or-json', + Buffer.from(JSON.stringify({ ts: '2026-07-25', id: IDS[0] })).toString('base64url'), + Buffer.from(JSON.stringify({ ts: '2026-07-25T10:00:03.000Z', id: 'not-a-uuid' })).toString('base64url'), + Buffer.from(JSON.stringify({ ts: "'); drop table api_keys; --", id: IDS[0] })).toString('base64url'), + ] + + for (const cursor of garbage) { + mountRows() + const { status, page } = await fetchPage(endpoint, `limit=2&cursor=${encodeURIComponent(cursor)}`) + expect(status).toBe(200) + expect(page.ids).toEqual([IDS[0], IDS[1]]) + } + }) + + it('orders by created_at, not by the business date column', async () => { + // Every fixture row shares the same invoice_date / entry_date / date. + // A date-anchored sort cannot produce a stable total order here, which + // is exactly how the P0 manifested in production. + mountRows() + const { body } = await fetchPage(endpoint, 'limit=100') + const timestamps = body.data.map((row) => (row as unknown as { created_at: string }).created_at) + const descending = [...timestamps].sort().reverse() + expect(timestamps).toEqual(descending) + }) +}) + +// ────────────────────────────────────────────────────────────────── +// Standard route guards +// ────────────────────────────────────────────────────────────────── + +describe.each(ENDPOINTS)('GET /api/v1/companies/:companyId/$name guards', (endpoint) => { + it('returns 401 UNAUTHORIZED without a bearer token', async () => { + mockServiceClient.mockReturnValue(makeKeysetSupabase({})) + const res = await endpoint.handler( + makeRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/${endpoint.segment}`, false), + companyParams(COMPANY_ID), + ) + expect(res.status).toBe(401) + const body = await res.json() + expect(body.error.code).toBe('UNAUTHORIZED') + }) + + it('returns 400 VALIDATION_ERROR on a malformed filter', async () => { + mockServiceClient.mockReturnValue(makeKeysetSupabase({ company_members: MEMBERSHIP })) + const { status, body } = await fetchPage(endpoint, endpoint.invalidQuery) + expect(status).toBe(400) + expect((body as unknown as { error: { code: string } }).error.code).toBe('VALIDATION_ERROR') + }) + + it('returns 404 NOT_FOUND when the key user is not a member of the company', async () => { + // No company_members row: the wrapper 404s rather than leaking existence. + mockServiceClient.mockReturnValue( + makeKeysetSupabase({ company_members: [], [endpoint.table]: endpoint.rows() }), + ) + const { status, body } = await fetchPage(endpoint, 'limit=2') + expect(status).toBe(404) + expect((body as unknown as { error: { code: string } }).error.code).toBe('NOT_FOUND') + }) + + it('returns 403 INSUFFICIENT_SCOPE when the key lacks the read scope', async () => { + mockValidate.mockResolvedValue({ + userId: USER_ID, + companyId: COMPANY_ID, + apiKeyId: 'ak_1', + scopes: ALL_SCOPES.filter((s) => s !== endpoint.scope), + mode: 'live', + }) + mockServiceClient.mockReturnValue(makeKeysetSupabase({ company_members: MEMBERSHIP })) + const { status, body } = await fetchPage(endpoint, 'limit=2') + expect(status).toBe(403) + expect((body as unknown as { error: { code: string } }).error.code).toBe('INSUFFICIENT_SCOPE') + }) +}) + +// ────────────────────────────────────────────────────────────────── +// Business-date filters stay available now that the sort key moved +// ────────────────────────────────────────────────────────────────── + +describe('business-date filters', () => { + it('invoices: ?date_from / ?date_to narrow on invoice_date', async () => { + const rows = invoiceRows() + rows[0].invoice_date = '2026-01-15' + mockServiceClient.mockReturnValue( + makeKeysetSupabase({ company_members: MEMBERSHIP, invoices: rows }), + ) + const { status, page } = await fetchPage(ENDPOINTS[0], 'limit=100&date_from=2026-07-01&date_to=2026-07-31') + expect(status).toBe(200) + expect(page.ids).toEqual(EXPECTED_ORDER.slice(1)) + }) + + it('journal-entries: ?date_from / ?date_to narrow on entry_date', async () => { + const rows = journalEntryRows() + rows[0].entry_date = '2026-01-15' + mockServiceClient.mockReturnValue( + makeKeysetSupabase({ company_members: MEMBERSHIP, journal_entries: rows }), + ) + const { status, page } = await fetchPage(ENDPOINTS[1], 'limit=100&date_from=2026-07-01&date_to=2026-07-31') + expect(status).toBe(200) + expect(page.ids).toEqual(EXPECTED_ORDER.slice(1)) + }) + + it('supplier-invoices: ?date_from / ?date_to narrow on invoice_date', async () => { + const rows = supplierInvoiceRows() + rows[0].invoice_date = '2026-01-15' + mockServiceClient.mockReturnValue( + makeKeysetSupabase({ company_members: MEMBERSHIP, supplier_invoices: rows }), + ) + const { status, page } = await fetchPage(ENDPOINTS[2], 'limit=100&date_from=2026-07-01&date_to=2026-07-31') + expect(status).toBe(200) + expect(page.ids).toEqual(EXPECTED_ORDER.slice(1)) + }) + + it('transactions: ?date_from / ?date_to narrow on date', async () => { + const rows = transactionRows() + rows[0].date = '2026-01-15' + mockServiceClient.mockReturnValue( + makeKeysetSupabase({ company_members: MEMBERSHIP, transactions: rows }), + ) + const { status, page } = await fetchPage(ENDPOINTS[3], 'limit=100&date_from=2026-07-01&date_to=2026-07-31') + expect(status).toBe(200) + expect(page.ids).toEqual(EXPECTED_ORDER.slice(1)) + }) +}) diff --git a/app/api/v1/companies/[companyId]/compliance/check/__tests__/route.test.ts b/app/api/v1/companies/[companyId]/compliance/check/__tests__/route.test.ts new file mode 100644 index 00000000..ea983dc7 --- /dev/null +++ b/app/api/v1/companies/[companyId]/compliance/check/__tests__/route.test.ts @@ -0,0 +1,239 @@ +/** + * Integration tests for GET /api/v1/companies/:companyId/compliance/check. + * + * Focus: the voucher_gaps runner must check EVERY voucher series registered + * for the period (BFNAR 2013:2 kap 8 §: the verifikationsnummerserie must be + * unbroken per series, and a gap must be documented). Checking only series + * 'A' reports "clean" books that are not clean. + */ +import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' + +beforeAll(() => { + if (process.env.NODE_ENV !== 'test') throw new Error('NODE_ENV=test required') + process.env.NEXT_PUBLIC_SUPABASE_URL ||= 'http://localhost:54321' + process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY ||= 'test-anon-key' +}) + +vi.mock('@/lib/auth/api-keys', async () => { + const actual = await vi.importActual('@/lib/auth/api-keys') + return { ...actual, validateApiKey: vi.fn(), createServiceClientNoCookies: vi.fn() } +}) +vi.mock('@supabase/supabase-js', async () => { + const actual = await vi.importActual('@supabase/supabase-js') + return { ...actual, createClient: vi.fn().mockReturnValue({}) } +}) + +import { validateApiKey, createServiceClientNoCookies } from '@/lib/auth/api-keys' +import { GET as complianceCheck } from '../route' + +const mockValidate = validateApiKey as ReturnType +const mockServiceClient = createServiceClientNoCookies as ReturnType + +const COMPANY_ID = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa' +const PERIOD_ID = 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb' + +type MockResult = { data?: unknown; error?: unknown } +type Gap = { gap_start: number; gap_end: number } + +/** Per-table result queues + a `detect_voucher_gaps` stub keyed by p_series. */ +function makeSupabase(opts: { + tables?: Record + gapsBySeries?: Record + rpcError?: unknown +}) { + const tables = opts.tables ?? {} + const buildChain = (table: string): unknown => { + const handler: ProxyHandler = { + get(_target, prop) { + if (prop === 'then') { + return (resolve: (v: unknown) => void) => + resolve(tables[table] ?? { data: null, error: null }) + } + return (..._args: unknown[]) => buildChain(table) + }, + } + return new Proxy({}, handler) + } + const rpc = vi.fn(async (_name: string, args: Record) => { + if (opts.rpcError) return { data: null, error: opts.rpcError } + const series = args.p_series as string + return { data: opts.gapsBySeries?.[series] ?? [], error: null } + }) + return { from: vi.fn((table: string) => buildChain(table)), rpc } +} + +const MEMBERSHIP: MockResult = { data: { company_id: COMPANY_ID, role: 'owner' }, error: null } +const PERIOD: MockResult = { data: { id: PERIOD_ID }, error: null } + +function makeRequest(query: string, opts: { auth?: boolean } = {}): Request { + return new Request(`https://x.test/api/v1/companies/${COMPANY_ID}/compliance/check${query}`, { + method: 'GET', + headers: opts.auth === false ? {} : { Authorization: 'Bearer test-fixture-not-a-real-key' }, + }) +} + +function call(request: Request) { + return complianceCheck(request, { params: Promise.resolve({ companyId: COMPANY_ID }) }) +} + +beforeEach(() => { + vi.clearAllMocks() + mockValidate.mockResolvedValue({ + userId: 'user-1', + companyId: COMPANY_ID, + apiKeyId: 'ak_1', + scopes: ['compliance:read'], + mode: 'live', + }) +}) + +describe('GET /api/v1/companies/:companyId/compliance/check', () => { + it('returns 401 without a bearer token', async () => { + mockServiceClient.mockReturnValue(makeSupabase({})) + const res = await call(makeRequest(`?type=voucher_gaps&fiscal_period_id=${PERIOD_ID}`, { auth: false })) + expect(res.status).toBe(401) + }) + + it('returns 400 for an unsupported check type', async () => { + mockServiceClient.mockReturnValue(makeSupabase({ tables: { company_members: MEMBERSHIP } })) + const res = await call(makeRequest('?type=not_a_check')) + expect(res.status).toBe(400) + }) + + it('returns 400 when voucher_gaps is called without fiscal_period_id', async () => { + mockServiceClient.mockReturnValue(makeSupabase({ tables: { company_members: MEMBERSHIP } })) + const res = await call(makeRequest('?type=voucher_gaps')) + expect(res.status).toBe(400) + }) + + it('returns 404 when the caller is not a member of the company', async () => { + mockServiceClient.mockReturnValue( + makeSupabase({ tables: { company_members: { data: null, error: null } } }), + ) + const res = await call(makeRequest(`?type=voucher_gaps&fiscal_period_id=${PERIOD_ID}`)) + expect(res.status).toBe(404) + }) + + it('reports clean when every series is continuous', async () => { + mockServiceClient.mockReturnValue( + makeSupabase({ + tables: { + company_members: MEMBERSHIP, + fiscal_periods: PERIOD, + voucher_sequences: { data: [{ voucher_series: 'A' }, { voucher_series: 'F' }], error: null }, + }, + gapsBySeries: {}, + }), + ) + const res = await call(makeRequest(`?type=voucher_gaps&fiscal_period_id=${PERIOD_ID}`)) + expect(res.status).toBe(200) + const body = await res.json() + expect(body.data.ready).toBe(true) + expect(body.data.findings).toHaveLength(0) + expect(body.data.details.total_gaps).toBe(0) + expect(body.data.details.series_checked).toEqual(['A', 'F']) + }) + + it('reports a gap that sits in a non-A series (series F: 33 → 37)', async () => { + const supabase = makeSupabase({ + tables: { + company_members: MEMBERSHIP, + fiscal_periods: PERIOD, + voucher_sequences: { data: [{ voucher_series: 'A' }, { voucher_series: 'F' }], error: null }, + voucher_gap_explanations: { data: [], error: null }, + }, + gapsBySeries: { F: [{ gap_start: 34, gap_end: 36 }] }, + }) + mockServiceClient.mockReturnValue(supabase) + + const res = await call(makeRequest(`?type=voucher_gaps&fiscal_period_id=${PERIOD_ID}`)) + expect(res.status).toBe(200) + const body = await res.json() + + // Every registered series is checked, not only the RPC's 'A' default. + const seriesArgs = supabase.rpc.mock.calls.map((c) => (c[1] as { p_series: string }).p_series) + expect(seriesArgs).toEqual(['A', 'F']) + + expect(body.data.ready).toBe(false) + expect(body.data.findings).toHaveLength(1) + const finding = body.data.findings[0] + expect(finding.severity).toBe('blocker') + expect(finding.code).toBe('VOUCHER_GAP_UNEXPLAINED') + // The RPC returns only (gap_start, gap_end): the series must still be real. + expect(finding.message).toContain('Series F') + expect(finding.message).not.toContain('undefined') + expect(finding.details).toMatchObject({ + voucher_series: 'F', + gap_start: 34, + gap_end: 36, + has_explanation: false, + }) + expect(body.data.details.unexplained_count).toBe(1) + }) + + it('downgrades a gap to info only when voucher_gap_explanations documents it', async () => { + mockServiceClient.mockReturnValue( + makeSupabase({ + tables: { + company_members: MEMBERSHIP, + fiscal_periods: PERIOD, + voucher_sequences: { data: [{ voucher_series: 'F' }], error: null }, + voucher_gap_explanations: { + data: [{ voucher_series: 'F', gap_start: 34, gap_end: 36 }], + error: null, + }, + }, + gapsBySeries: { F: [{ gap_start: 34, gap_end: 36 }] }, + }), + ) + const res = await call(makeRequest(`?type=voucher_gaps&fiscal_period_id=${PERIOD_ID}`)) + expect(res.status).toBe(200) + const body = await res.json() + expect(body.data.ready).toBe(true) + expect(body.data.findings[0].severity).toBe('info') + expect(body.data.findings[0].code).toBe('VOUCHER_GAP_EXPLAINED') + expect(body.data.findings[0].details.voucher_series).toBe('F') + }) + + it('falls back to series A when the period has no voucher_sequences rows', async () => { + const supabase = makeSupabase({ + tables: { + company_members: MEMBERSHIP, + fiscal_periods: PERIOD, + voucher_sequences: { data: [], error: null }, + }, + gapsBySeries: {}, + }) + mockServiceClient.mockReturnValue(supabase) + const res = await call(makeRequest(`?type=voucher_gaps&fiscal_period_id=${PERIOD_ID}`)) + expect(res.status).toBe(200) + expect(supabase.rpc.mock.calls.map((c) => (c[1] as { p_series: string }).p_series)).toEqual(['A']) + }) + + it('surfaces a failing detection instead of reporting "no gaps"', async () => { + mockServiceClient.mockReturnValue( + makeSupabase({ + tables: { + company_members: MEMBERSHIP, + fiscal_periods: PERIOD, + voucher_sequences: { data: [{ voucher_series: 'F' }], error: null }, + }, + rpcError: { message: 'boom', code: 'XX000' }, + }), + ) + const res = await call(makeRequest(`?type=voucher_gaps&fiscal_period_id=${PERIOD_ID}`)) + expect(res.status).toBeGreaterThanOrEqual(400) + const body = await res.json() + expect(body.data).toBeUndefined() + }) + + it('returns 400 when fiscal_period_id belongs to another company', async () => { + mockServiceClient.mockReturnValue( + makeSupabase({ + tables: { company_members: MEMBERSHIP, fiscal_periods: { data: null, error: null } }, + }), + ) + const res = await call(makeRequest(`?type=voucher_gaps&fiscal_period_id=${PERIOD_ID}`)) + expect(res.status).toBe(400) + }) +}) diff --git a/app/api/v1/companies/[companyId]/compliance/check/route.ts b/app/api/v1/companies/[companyId]/compliance/check/route.ts index 6fd44044..f46d75f5 100644 --- a/app/api/v1/companies/[companyId]/compliance/check/route.ts +++ b/app/api/v1/companies/[companyId]/compliance/check/route.ts @@ -165,21 +165,75 @@ async function runVoucherGapsCheck( return { error: 'fiscal_period_id not found in this company.' } } - const { data, error } = await supabase.rpc('detect_voucher_gaps', { - p_company_id: companyId, - p_fiscal_period_id: fiscalPeriodId, + // `detect_voucher_gaps` checks ONE series per call (p_series defaults to + // 'A'), so the series have to be enumerated first: a company booking into + // B / F / ... would otherwise be told "no gaps" while a real break sits in + // another series. Same source and same ['A'] fallback as + // `validateYearEndReadiness` and GET /api/bookkeeping/voucher-gaps. + const { data: seriesRows, error: seriesError } = await supabase + .from('voucher_sequences') + .select('voucher_series') + .eq('company_id', companyId) + .eq('fiscal_period_id', fiscalPeriodId) + if (seriesError) throw seriesError + + const seriesToCheck = + seriesRows && seriesRows.length > 0 + ? (seriesRows as Array<{ voucher_series: string }>).map((r) => r.voucher_series) + : ['A'] + + // The RPC returns ONLY (gap_start, gap_end). The series is what we passed + // in; whether the gap is documented comes from voucher_gap_explanations + // below. Never read those off the RPC row. + const gaps: Array<{ voucher_series: string; gap_start: number; gap_end: number }> = [] + for (const series of seriesToCheck) { + const { data, error } = await supabase.rpc('detect_voucher_gaps', { + p_company_id: companyId, + p_fiscal_period_id: fiscalPeriodId, + p_series: series, + }) + // A failed detection must surface: silently dropping a series would + // report "clean" for books whose continuity was never checked. + if (error) throw error + for (const g of (data ?? []) as Array<{ gap_start: number; gap_end: number }>) { + gaps.push({ voucher_series: series, gap_start: g.gap_start, gap_end: g.gap_end }) + } + } + + // Explanations are keyed exactly like the table's UNIQUE constraint + // (company_id, fiscal_period_id, voucher_series, gap_start, gap_end). + // A failed lookup throws rather than defaulting gaps to "explained". + const explainedKeys = new Set() + if (gaps.length > 0) { + const { data: explanations, error: explError } = await supabase + .from('voucher_gap_explanations') + .select('voucher_series, gap_start, gap_end') + .eq('company_id', companyId) + .eq('fiscal_period_id', fiscalPeriodId) + if (explError) throw explError + for (const e of (explanations ?? []) as Array<{ + voucher_series: string + gap_start: number + gap_end: number + }>) { + explainedKeys.add(`${e.voucher_series}:${e.gap_start}:${e.gap_end}`) + } + } + + const findings: FindingShape[] = gaps.map((g) => { + const hasExplanation = explainedKeys.has(`${g.voucher_series}:${g.gap_start}:${g.gap_end}`) + return { + severity: hasExplanation ? 'info' : 'blocker', + code: hasExplanation ? 'VOUCHER_GAP_EXPLAINED' : 'VOUCHER_GAP_UNEXPLAINED', + message: `Series ${g.voucher_series}: gap ${g.gap_start}${g.gap_end > g.gap_start ? `-${g.gap_end}` : ''}${hasExplanation ? ' (explained)' : ' (no explanation)'}.`, + details: { + voucher_series: g.voucher_series, + gap_start: g.gap_start, + gap_end: g.gap_end, + has_explanation: hasExplanation, + }, + } }) - if (error) throw error - - type GapRow = { voucher_series: string; gap_start: number; gap_end: number; has_explanation: boolean } - const rows = (data ?? []) as GapRow[] - - const findings: FindingShape[] = rows.map((r) => ({ - severity: r.has_explanation ? 'info' : 'blocker', - code: r.has_explanation ? 'VOUCHER_GAP_EXPLAINED' : 'VOUCHER_GAP_UNEXPLAINED', - message: `Series ${r.voucher_series}: gap ${r.gap_start}${r.gap_end > r.gap_start ? `-${r.gap_end}` : ''}${r.has_explanation ? ' (explained)' : ' (no explanation)'}.`, - details: { voucher_series: r.voucher_series, gap_start: r.gap_start, gap_end: r.gap_end, has_explanation: r.has_explanation }, - })) const unexplainedCount = findings.filter((f) => f.code === 'VOUCHER_GAP_UNEXPLAINED').length @@ -187,10 +241,14 @@ async function runVoucherGapsCheck( ready: unexplainedCount === 0, findings, summary: - rows.length === 0 - ? 'Verifikationsserie is continuous (no gaps).' - : `${unexplainedCount} unexplained gap(s) of ${rows.length} total. Document via POST /voucher-gap-explanations.`, - extra: { total_gaps: rows.length, unexplained_count: unexplainedCount }, + gaps.length === 0 + ? `Verifikationsserie ${seriesToCheck.join(', ')} is continuous (no gaps).` + : `${unexplainedCount} unexplained gap(s) of ${gaps.length} total. Document via POST /voucher-gap-explanations.`, + extra: { + total_gaps: gaps.length, + unexplained_count: unexplainedCount, + series_checked: seriesToCheck, + }, } } @@ -211,6 +269,7 @@ registerEndpoint({ 'Executing the underlying action: this is read-only. After a passing check, call the corresponding async endpoint (POST /fiscal-periods/{id}/year-end, etc).', pitfalls: [ 'year_end_readiness and voucher_gaps require fiscal_period_id (UUID).', + 'voucher_gaps covers EVERY voucher series registered for the period (A, B, F, ...), not only series A.', 'A passing check is a SNAPSHOT: the state can change between the check and the action. The same blocker logic runs again on commit.', 'vat_close is documented in the plan but NOT yet supported by this endpoint: call gnubok_vat_close_check via the MCP server until the function is extracted into lib/reports/.', ], diff --git a/app/api/v1/companies/[companyId]/customers/[id]/route.ts b/app/api/v1/companies/[companyId]/customers/[id]/route.ts index 994ea3bb..65bca839 100644 --- a/app/api/v1/companies/[companyId]/customers/[id]/route.ts +++ b/app/api/v1/companies/[companyId]/customers/[id]/route.ts @@ -178,7 +178,8 @@ export const GET = withApiV1<{ params: Promise<{ companyId: string; id: string } // Postgres error class 42 = "Syntax Error or Access Rule Violation" // (includes 42501 insufficient_privilege). These indicate a real // misconfiguration: a revoked grant or an incorrect RLS policy: - // and should reach Sentry/error monitoring rather than blending + // and should be logged at error level, which is what the observability + // sink (lib/observability) forwards, rather than blending // into informational warn logs. Other classes are typically // transient (network, timeout) and stay at warn. const isPermissionError = typeof errCode === 'string' && errCode.startsWith('42') diff --git a/app/api/v1/companies/[companyId]/employees/__tests__/route.test.ts b/app/api/v1/companies/[companyId]/employees/__tests__/route.test.ts index c08f0459..baa84bed 100644 --- a/app/api/v1/companies/[companyId]/employees/__tests__/route.test.ts +++ b/app/api/v1/companies/[companyId]/employees/__tests__/route.test.ts @@ -110,11 +110,12 @@ beforeEach(() => { }) // 12-digit synthetic personnummer: passes the schema's `^\d{12}$` regex -// while being obviously not a real birthdate (year 1900, day 1, zero +// while being obviously not a real birthdate (year 1900, day 1, near-zero // suffix). ISO A.5.34 / GDPR Art.5(1)(c): test fixtures must not look like -// production-format PII. Last-4 is '0000' so the mask assertion is still -// easy to spot. -const SAMPLE_PERSONNUMMER = '190001010000' +// production-format PII. The last digit is the Luhn check digit for this +// otherwise-zero suffix: the create route now enforces the checksum, so a +// fixture ending '0000' would be rejected before it reached the insert. +const SAMPLE_PERSONNUMMER = '190001010008' const SAMPLE_EMPLOYEE = { id: EMPLOYEE_ID, @@ -493,6 +494,55 @@ describe('POST /api/v1/companies/:companyId/employees', () => { expect(body.error.code).toBe('VALIDATION_ERROR') }) + it('returns 400 for a check-digit-invalid personnummer (matches the dashboard surface)', async () => { + // The schema only checks `^\d{12}$`, so before this guard a transposed or + // mistyped digit entered payroll here and only surfaced weeks later as a + // rejected arbetsgivardeklaration from Skatteverket. '190001010001' has a + // valid date but the wrong Luhn check digit (the correct one is 8). + mockServiceClient.mockReturnValue( + makeFlexibleSupabase({ + company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null }, + }), + ) + + const res = await createEmployee( + makeRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/employees`, { + method: 'POST', + body: JSON.stringify({ ...validBody, personnummer: '190001010001' }), + }), + companyParams(COMPANY_ID), + ) + + expect(res.status).toBe(400) + const body = await res.json() + expect(body.error.code).toBe('VALIDATION_ERROR') + expect(body.error.details.field).toBe('personnummer') + // GDPR Art.5(1)(c): the rejection must not echo the supplied number back. + expect(JSON.stringify(body)).not.toContain('190001010001') + }) + + it('accepts a samordningsnummer (day carries +60), same as the AGI generator', async () => { + // A samordningsnummer holder can be filed for under FK215, so registering + // one as an employee must work on this surface too. '19000161' is day + // 01 + 60; '5' is the matching Luhn check digit. + mockServiceClient.mockReturnValue( + makeFlexibleSupabase({ + company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null }, + employees: { data: SAMPLE_EMPLOYEE, error: null }, + }), + ) + + const res = await createEmployee( + makeRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/employees`, { + method: 'POST', + body: JSON.stringify({ ...validBody, personnummer: '190001610005' }), + }), + companyParams(COMPANY_ID), + ) + + expect(res.status).not.toBe(400) + }) + it('requires tax_table_number for A-skatt non-sidoinkomst employees', async () => { mockServiceClient.mockReturnValue( makeFlexibleSupabase({ @@ -506,7 +556,7 @@ describe('POST /api/v1/companies/:companyId/employees', () => { body: JSON.stringify({ first_name: 'Bo', last_name: 'Berg', - personnummer: '190001020000', + personnummer: '190001020007', employment_start: '2024-02-01', salary_type: 'monthly', monthly_salary: 30000, diff --git a/app/api/v1/companies/[companyId]/employees/route.ts b/app/api/v1/companies/[companyId]/employees/route.ts index 61bffb9d..4419cc1e 100644 --- a/app/api/v1/companies/[companyId]/employees/route.ts +++ b/app/api/v1/companies/[companyId]/employees/route.ts @@ -29,7 +29,7 @@ import { withApiV1 } from '@/lib/api/v1/with-api-v1' import { v1ErrorResponse, v1ErrorResponseFromCode } from '@/lib/api/v1/errors' import { CreateEmployeeSchema } from '@/lib/api/schemas' import { maskPersonnummer } from '@/lib/api/v1/mask-personnummer' -import { decryptPersonnummer, encryptPersonnummer } from '@/lib/salary/personnummer' +import { decryptPersonnummer, encryptPersonnummer, validatePersonnummer } from '@/lib/salary/personnummer' import { getCompanyEntityType } from '@/lib/company/context' import { isEmploymentTypeAllowedForEntity, EF_OWNER_EMPLOYMENT_ERROR } from '@/lib/salary/employment-rules' @@ -355,6 +355,21 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string }> }>( } const body = parsed.data + // CreateEmployeeSchema only checks the shape (12 digits), so on its own it + // lets a check-digit-invalid number into payroll. That number then surfaces + // as a rejected arbetsgivardeklaration from Skatteverket weeks later, which + // is a far worse place to discover it. Run the same date + Luhn validation + // the cookie-session route runs, before the dry-run branch so a dry run + // reports it too. The error text is a static Swedish message: it never + // echoes the supplied personnummer back (GDPR Art.5(1)(c)). + const pnrValidation = validatePersonnummer(body.personnummer) + if (!pnrValidation.valid) { + return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, { + requestId: ctx.requestId, + details: { field: 'personnummer', message: pnrValidation.error }, + }) + } + // An enskild firma owner cannot be put on payroll (owner takes egna uttag, // not lön). Reject owner/board employment types for EF: validated before // dry-run so a dry run surfaces the error too. The DB trigger is the diff --git a/app/api/v1/companies/[companyId]/imports/bank/__tests__/route.test.ts b/app/api/v1/companies/[companyId]/imports/bank/__tests__/route.test.ts new file mode 100644 index 00000000..ddfe921f --- /dev/null +++ b/app/api/v1/companies/[companyId]/imports/bank/__tests__/route.test.ts @@ -0,0 +1,294 @@ +/** + * Integration tests for POST /api/v1/companies/:companyId/imports/bank. + * + * Regression cover for three ways this route diverged from the dashboard + * import path (app/api/import/bank-file/execute/route.ts) it claims to be + * equivalent to: + * + * 1. It built its ingest payload with a key `source`, but RawTransaction's + * provenance field is `import_source`. Rows landed with NULL provenance, + * which makes them user-deletable (isImportedTransaction) and disables + * every content-dedup mirror in ingestTransactions, so a later PSD2 sync + * re-inserts the whole batch as duplicate affärshändelser. + * 2. The same call site passed `counterparty`, which RawTransaction does not + * have; it was silently dropped. + * 3. Completion was written to `bank_file_imports.imported_at`, a column that + * does not exist on that table (it lives on sie_imports). PostgREST + * rejected the whole statement and the unchecked result hid it, so the row + * stayed `status: 'processing'` with zeroed counters forever. + */ +import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' + +beforeAll(() => { + if (process.env.NODE_ENV !== 'test') throw new Error('NODE_ENV=test required') + process.env.NEXT_PUBLIC_SUPABASE_URL ||= 'http://localhost:54321' + process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY ||= 'test-anon-key' +}) + +vi.mock('@/lib/auth/api-keys', async () => { + const actual = await vi.importActual('@/lib/auth/api-keys') + return { ...actual, validateApiKey: vi.fn(), createServiceClientNoCookies: vi.fn() } +}) +vi.mock('@supabase/supabase-js', async () => { + const actual = await vi.importActual('@supabase/supabase-js') + return { ...actual, createClient: vi.fn().mockReturnValue({}) } +}) + +const { ingestMock, startOperationMock, completeOperationMock, failOperationMock } = vi.hoisted( + () => ({ + ingestMock: vi.fn(), + startOperationMock: vi.fn(), + completeOperationMock: vi.fn().mockResolvedValue(undefined), + failOperationMock: vi.fn().mockResolvedValue(undefined), + }), +) + +vi.mock('@/lib/transactions/ingest', () => ({ ingestTransactions: ingestMock })) +vi.mock('@/lib/api/v1/operations', () => ({ + startOperation: startOperationMock, + completeOperation: completeOperationMock, + failOperation: failOperationMock, +})) + +import { validateApiKey, createServiceClientNoCookies } from '@/lib/auth/api-keys' +import type { RawTransaction } from '@/types' +import { POST } from '../route' + +const mockValidate = validateApiKey as ReturnType +const mockServiceClient = createServiceClientNoCookies as ReturnType + +const COMPANY_ID = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa' + +/** SEB CSV: semicolon-delimited, comma decimals. Auto-detects as format `seb`. */ +const SEB_CSV = [ + 'Bokföringsdag;Valutadag;Verifikationsnummer;Text;Belopp;Saldo', + '2024-01-15;2024-01-15;12345;SPOTIFY AB;-99,00;12345,67', + '2024-01-14;2024-01-14;12346;HEMKÖP FRIDHEMSPLAN;-432,50;12444,67', + '2024-01-13;2024-01-13;12347;LÖNEUTBETALNING;25000,00;12877,17', +].join('\n') + +type MockResult = { data?: unknown; error?: unknown } +type RecordedCall = { table: string; method: string; args: unknown[] } + +/** + * Per-table Supabase double that also records every chained call, so a test + * can assert on the exact payload handed to `.update()` / `.upsert()`. + */ +function makeSupabase(byTable: Record) { + const calls: RecordedCall[] = [] + const buildChain = (table: string): unknown => { + const handler: ProxyHandler = { + get(_target, prop) { + if (prop === 'then') { + return (resolve: (v: unknown) => void) => + resolve(byTable[table] ?? { data: null, error: null }) + } + return (...args: unknown[]) => { + calls.push({ table, method: String(prop), args }) + return buildChain(table) + } + }, + } + return new Proxy({}, handler) + } + return { from: vi.fn((table: string) => buildChain(table)), calls } +} + +let supabase: ReturnType + +function makeRequest(options?: { + body?: FormData | string + auth?: boolean + search?: string +}): Request { + const fd = new FormData() + fd.append('file', new File([SEB_CSV], 'kontoutdrag.csv', { type: 'text/csv' })) + const init: RequestInit = { + method: 'POST', + body: options?.body ?? fd, + headers: options?.auth === false ? {} : { Authorization: 'Bearer test-fixture-not-a-real-key' }, + } + return new Request( + `https://x.test/api/v1/companies/${COMPANY_ID}/imports/bank${options?.search ?? ''}`, + init, + ) +} + +function callRoute(options?: Parameters[0]) { + return POST(makeRequest(options), { params: Promise.resolve({ companyId: COMPANY_ID }) }) +} + +/** The RawTransaction[] the route handed to ingestTransactions. */ +function ingestedRows(): RawTransaction[] { + return ingestMock.mock.calls[0][3] as RawTransaction[] +} + +/** Payload of the last `.update()` issued against `bank_file_imports`. */ +function bankImportUpdatePayload(): Record | undefined { + const updates = supabase.calls.filter( + (c) => c.table === 'bank_file_imports' && c.method === 'update', + ) + return updates.at(-1)?.args[0] as Record | undefined +} + +beforeEach(() => { + vi.clearAllMocks() + mockValidate.mockResolvedValue({ + userId: 'user-1', + companyId: COMPANY_ID, + apiKeyId: 'ak_1', + scopes: ['transactions:write'], + mode: 'live', + }) + startOperationMock.mockResolvedValue({ id: 'op-1' }) + completeOperationMock.mockResolvedValue(undefined) + failOperationMock.mockResolvedValue(undefined) + ingestMock.mockResolvedValue({ + imported: 3, + duplicates: 1, + reconciled: 0, + auto_categorized: 0, + auto_matched_invoices: 2, + errors: 0, + transaction_ids: ['t1', 't2', 't3'], + }) + supabase = makeSupabase({ + company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null }, + bank_file_imports: { data: null, error: null }, + }) + mockServiceClient.mockReturnValue(supabase) +}) + +describe('POST /api/v1/companies/:companyId/imports/bank', () => { + it('returns 401 without a bearer token', async () => { + const res = await callRoute({ auth: false }) + + expect(res.status).toBe(401) + expect(ingestMock).not.toHaveBeenCalled() + }) + + it('returns 400 when the multipart body carries no `file` field', async () => { + const res = await callRoute({ body: new FormData() }) + + expect(res.status).toBe(400) + const body = await res.json() + expect(body.error.code).toBe('VALIDATION_ERROR') + expect(ingestMock).not.toHaveBeenCalled() + }) + + it('returns 400 for an unknown `format` override', async () => { + const res = await callRoute({ search: '?format=nordbanken' }) + + expect(res.status).toBe(400) + const body = await res.json() + expect(body.error.code).toBe('VALIDATION_ERROR') + expect(ingestMock).not.toHaveBeenCalled() + }) + + it('returns 404 when the key user is not a member of the company in the URL', async () => { + supabase = makeSupabase({ company_members: { data: null, error: null } }) + mockServiceClient.mockReturnValue(supabase) + + const res = await callRoute() + + expect(res.status).toBe(404) + expect(ingestMock).not.toHaveBeenCalled() + }) + + it('ingests the parsed file and returns 202 with an operation id', async () => { + const res = await callRoute() + + expect(res.status).toBe(202) + const body = await res.json() + expect(body.data.operation_id).toBe('op-1') + expect(body.data.type).toBe('import.bank') + + expect(ingestMock).toHaveBeenCalledTimes(1) + expect(ingestedRows()).toHaveLength(3) + expect(completeOperationMock).toHaveBeenCalledTimes(1) + expect(failOperationMock).not.toHaveBeenCalled() + }) + + it('stamps every ingested row with the dashboard-equivalent import_source', async () => { + await callRoute() + + const rows = ingestedRows() + expect(rows).not.toHaveLength(0) + for (const row of rows) { + // `csv_` for CSV formats, exactly what + // app/api/import/bank-file/execute/route.ts writes. A NULL/absent value + // here is the provenance bug: it makes the row user-deletable and + // disables ingestTransactions' cross-channel dedup mirrors. + expect(row.import_source).toBe('csv_seb') + } + }) + + it('never sends keys RawTransaction does not have (`source`, `counterparty`)', async () => { + await callRoute() + + for (const row of ingestedRows()) { + expect(row).not.toHaveProperty('source') + expect(row).not.toHaveProperty('counterparty') + } + }) + + it('carries external_id, date, amount, currency and description through', async () => { + await callRoute() + + const rows = ingestedRows() + const spotify = rows.find((r) => r.description === 'SPOTIFY AB') + expect(spotify).toBeDefined() + expect(spotify!.date).toBe('2024-01-15') + expect(spotify!.amount).toBe(-99) + expect(spotify!.currency).toBe('SEK') + expect(spotify!.external_id).toMatch(/^seb_/) + // external_id must be unique per row or Layer-1 dedup collapses the batch. + expect(new Set(rows.map((r) => r.external_id)).size).toBe(rows.length) + }) + + it('marks the bank_file_imports row completed using real columns only', async () => { + await callRoute() + + const payload = bankImportUpdatePayload() + expect(payload).toBeDefined() + expect(payload).toEqual({ + status: 'completed', + imported_count: 3, + duplicate_count: 1, + matched_count: 2, + }) + // `imported_at` exists on sie_imports, NOT on bank_file_imports. Writing it + // made PostgREST reject the statement, leaving status 'processing' forever. + expect(payload).not.toHaveProperty('imported_at') + // The parsed row count set by the upsert must survive; the imported count + // belongs in imported_count. + expect(payload).not.toHaveProperty('transaction_count') + }) + + it('still returns 202 when the completion status write fails', async () => { + supabase = makeSupabase({ + company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null }, + bank_file_imports: { data: null, error: { message: 'permission denied' } }, + }) + mockServiceClient.mockReturnValue(supabase) + + const res = await callRoute() + + // The transactions are already ingested: a bookkeeping-status write must + // not fail the request, but it is logged rather than swallowed. + expect(res.status).toBe(202) + expect(completeOperationMock).toHaveBeenCalledTimes(1) + }) + + it('fails the operation and returns an error envelope when ingest throws', async () => { + ingestMock.mockRejectedValue(new Error('ingest exploded')) + + const res = await callRoute() + + expect(res.status).toBeGreaterThanOrEqual(400) + const body = await res.json() + expect(body.error.code).toBe('BANK_IMPORT_FAILED') + expect(failOperationMock).toHaveBeenCalledTimes(1) + expect(completeOperationMock).not.toHaveBeenCalled() + }) +}) diff --git a/app/api/v1/companies/[companyId]/imports/bank/route.ts b/app/api/v1/companies/[companyId]/imports/bank/route.ts index 75baea01..8b285324 100644 --- a/app/api/v1/companies/[companyId]/imports/bank/route.ts +++ b/app/api/v1/companies/[companyId]/imports/bank/route.ts @@ -62,7 +62,7 @@ registerEndpoint({ pitfalls: [ 'File size cap: 10 MB. Larger files require splitting client-side.', '`format` query parameter is optional; auto-detection works for all supported banks. Pass `format` only to force a specific format. Accepted values: seb, swedbank, handelsbanken, nordea, nordea_business, lansforsakringar, ica_banken, skandia, lunar, northmill, wise, generic_csv, camt053.', - 'Duplicate detection is by external_id (composed from date + amount + counterparty); a re-import of the same file with the same flag set typically deduplicates rather than creating doubles.', + 'Duplicate detection is by external_id (composed from format + date + description + amount + row index, or the camt.053 entry reference / Wise transfer id where the file carries one); a re-import of the same file typically deduplicates rather than creating doubles.', 'BFL 5 kap 6-7 §§ note: this endpoint creates `transactions` rows (the underlag for a verifikation), NOT verifikationer themselves. The verifikation content requirements are in BFL 5 kap 6-7 §§; until each transaction is matched to an invoice/supplier-invoice (POST /transactions/{id}/match-*) or categorised (POST /transactions/{id}/categorize), the bookkeeping obligation isn\'t discharged. A successful import here means the data is ingested: not booked.', 'A successful import returns operation_id; poll /operations/{id} for the final ingested/duplicates/errors counts.', ], @@ -228,16 +228,42 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string }> }>( // Convert parsed transactions to the RawTransaction shape that // ingestTransactions expects. external_id stays stable so re-imports // are deduplicated server-side. - const raw: RawTransaction[] = parseResult.transactions.map((t, idx) => ({ - external_id: generateExternalId(t, format, idx), - date: t.date, - amount: t.amount, - currency: t.currency ?? 'SEK', - description: t.description ?? null, - counterparty: t.counterparty ?? null, - reference: t.reference ?? null, - source: 'bank_file', - })) + // + // The callback return type is annotated ON PURPOSE. Without it, the + // `RawTransaction[]` annotation on the const alone does NOT make an + // excess or misspelled property an error: `Array.prototype.map` infers + // its own element type from the literal, the object loses freshness, and + // only a plain assignability check runs. That is how this call site used + // to ship `source: 'bank_file'` (no such key: the real one is + // `import_source`) and `counterparty` (no such key at all) with a clean + // type-check, landing every v1-imported row with NULL provenance. + // + // `import_source` must match the dashboard path + // (app/api/import/bank-file/execute/route.ts): 'camt053' or + // `csv_`. It is load-bearing, not cosmetic: + // - isImportedTransaction() (lib/transactions/origin.ts) reads it to + // keep imported rows ignore-only instead of user-deletable. + // - ingestTransactions' cross-channel and hand-entered dedup mirrors + // are gated on the batch being an import feed, so a NULL source + // disables them and a later PSD2 / Enable Banking sync re-inserts the + // whole batch as duplicate affärshändelser. + // + // ParsedBankTransaction.counterparty is deliberately NOT forwarded: + // RawTransaction has no such field and the ingest pipeline never reads + // one, so passing it only looked like provenance. The dashboard path + // drops it too. If counterparty ever needs persisting it belongs in the + // shared ingest contract, not in this route alone. + const raw: RawTransaction[] = parseResult.transactions.map( + (t, idx): RawTransaction => ({ + external_id: generateExternalId(t, format, idx), + date: t.date, + amount: t.amount, + currency: t.currency || 'SEK', + description: t.description, + reference: t.reference ?? null, + import_source: format === 'camt053' ? 'camt053' : `csv_${format}`, + }), + ) const ingestResult = await ingestTransactions( ctx.supabase, @@ -250,17 +276,38 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string }> }>( // `(company_id, file_hash)` since 20260707130000; scoping the update by // user_id as well is defense in depth so a concurrent same-hash import // can never overwrite the wrong company's status row. - await ctx.supabase + // + // Completion is recorded exactly like the dashboard path: `status` plus + // the three outcome counters. There is no `imported_at` column on + // bank_file_imports (that column lives on sie_imports); writing it made + // PostgREST reject the whole statement with PGRST204, and because the + // result was never inspected the row stayed `status: 'processing'` with + // zeroed counters forever. `transaction_count` keeps the parsed row count + // set by the upsert above and is not overwritten with the imported count. + const { error: completionError } = await ctx.supabase .from('bank_file_imports') .update({ status: 'completed', - imported_at: new Date().toISOString(), - transaction_count: ingestResult.imported, + imported_count: ingestResult.imported, + duplicate_count: ingestResult.duplicates, + matched_count: ingestResult.auto_matched_invoices, }) .eq('file_hash', fileHash) .eq('user_id', ctx.userId) .eq('company_id', ctx.companyId!) + // Non-fatal: the transactions are already ingested, so a failed status + // write must not fail the request. It must not be silent either: an + // unchecked error here is what hid the phantom column. + if (completionError) { + ctx.log.warn('failed to mark bank_file_imports row completed', { + operationId: op.id, + companyId: ctx.companyId, + fileHash, + error: completionError.message, + }) + } + await completeOperation( ctx.supabase, { diff --git a/app/api/v1/companies/[companyId]/invoices/[id]/__tests__/route.test.ts b/app/api/v1/companies/[companyId]/invoices/[id]/__tests__/route.test.ts new file mode 100644 index 00000000..7d228fea --- /dev/null +++ b/app/api/v1/companies/[companyId]/invoices/[id]/__tests__/route.test.ts @@ -0,0 +1,368 @@ +/** + * Integration tests for PATCH /api/v1/companies/:companyId/invoices/:id, + * focused on the optional `items` full-replace path (metadata-only updates + * keep their original behaviour and get a regression case here). + */ +import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' + +beforeAll(() => { + if (process.env.NODE_ENV !== 'test') { + throw new Error( + `invoice PATCH route tests require NODE_ENV=test (got ${process.env.NODE_ENV ?? 'undefined'})`, + ) + } + process.env.NEXT_PUBLIC_SUPABASE_URL ||= 'http://localhost:54321' + process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY ||= 'test-anon-key' +}) + +vi.mock('@/lib/auth/api-keys', async () => { + const actual = await vi.importActual('@/lib/auth/api-keys') + return { + ...actual, + validateApiKey: vi.fn(), + createServiceClientNoCookies: vi.fn(), + } +}) + +vi.mock('@supabase/supabase-js', async () => { + const actual = await vi.importActual('@supabase/supabase-js') + return { ...actual, createClient: vi.fn().mockReturnValue({}) } +}) + +import { validateApiKey, createServiceClientNoCookies } from '@/lib/auth/api-keys' +import { PATCH as patchInvoice } from '../route' + +const mockValidate = validateApiKey as ReturnType +const mockServiceClient = createServiceClientNoCookies as ReturnType + +type MockResult = { data?: unknown; error?: unknown } +type Capture = { table: string; op: 'update' | 'insert'; payload: unknown } + +/** + * Per-table result queues (arrays pop in order; single values repeat) plus a + * capture log of update/insert payloads so totals recomputation is assertable. + */ +function makeFlexibleSupabase( + byTable: Record, + captures: Capture[] = [], +) { + const queues = new Map() + for (const [t, val] of Object.entries(byTable)) { + queues.set(t, Array.isArray(val) ? [...val] : [val]) + } + const buildChain = (table: string): unknown => { + const handler: ProxyHandler = { + get(_target, prop) { + if (prop === 'then') { + return (resolve: (v: unknown) => void) => { + const q = queues.get(table) + const next = q && q.length > 1 ? q.shift()! : (q?.[0] ?? { data: null, error: null }) + resolve(next) + } + } + if (prop === 'update' || prop === 'insert') { + return (payload: unknown) => { + captures.push({ table, op: prop, payload }) + return buildChain(table) + } + } + return (..._args: unknown[]) => buildChain(table) + }, + } + return new Proxy({}, handler) + } + return { from: vi.fn((table: string) => buildChain(table)) } +} + +const COMPANY_ID = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa' +const INVOICE_ID = 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb' +const CUSTOMER_ID = 'cccccccc-cccc-4ccc-8ccc-cccccccccccc' +const USER_ID = 'user-1' + +const DRAFT_INVOICE = { + id: INVOICE_ID, + invoice_number: null, + customer_id: CUSTOMER_ID, + invoice_date: '2026-07-01', + due_date: '2026-07-31', + delivery_date: null, + status: 'draft', + currency: 'SEK', + subtotal: 10000, + vat_amount: 2500, + total: 12500, + vat_treatment: 'standard_25', + document_type: 'invoice', + your_reference: null, + our_reference: null, + notes: null, + payment_link_url: null, + payment_link_auto: true, + default_dimensions: {}, + remaining_amount: 12500, + created_at: '2026-07-01T09:00:00Z', +} + +const INTERNAL_COLUMNS = { + ore_rounding: null, + deduction_personnummer_encrypted: null, + deduction_personnummer_last4: null, +} + +const NEW_ITEMS = [ + { description: 'Konsultation', quantity: 2, unit: 'tim', unit_price: 1000, vat_rate: 25 }, +] + +function makePatchRequest(body: unknown, opts: { idempotencyKey?: boolean; auth?: boolean; dryRun?: boolean } = {}) { + const headers: Record = { 'Content-Type': 'application/json' } + if (opts.auth !== false) headers.Authorization = 'Bearer test-fixture-not-a-real-key' + if (opts.idempotencyKey !== false) headers['Idempotency-Key'] = 'idem1234-7777-4abc-8def-1234567890ab' + const url = `https://x.test/api/v1/companies/${COMPANY_ID}/invoices/${INVOICE_ID}${opts.dryRun ? '?dry_run=true' : ''}` + return new Request(url, { + method: 'PATCH', + headers, + body: JSON.stringify(body), + }) +} + +function detailParams(companyId: string, id: string) { + return { params: Promise.resolve({ companyId, id }) } +} + +beforeEach(() => { + vi.clearAllMocks() + mockValidate.mockResolvedValue({ + userId: USER_ID, + companyId: COMPANY_ID, + apiKeyId: 'ak_1', + apiKeyName: 'CI key', + scopes: ['invoices:write'], + mode: 'live', + }) +}) + +describe('PATCH /api/v1/companies/:companyId/invoices/:id', () => { + it('returns 401 without a bearer token', async () => { + mockServiceClient.mockReturnValue(makeFlexibleSupabase({})) + + const res = await patchInvoice( + makePatchRequest({ notes: 'x' }, { auth: false }), + detailParams(COMPANY_ID, INVOICE_ID), + ) + + expect(res.status).toBe(401) + const body = await res.json() + expect(body.error.code).toBe('UNAUTHORIZED') + }) + + it('returns 400 VALIDATION_ERROR for an empty items array', async () => { + mockServiceClient.mockReturnValue( + makeFlexibleSupabase({ + company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null }, + }), + ) + + const res = await patchInvoice( + makePatchRequest({ items: [] }), + detailParams(COMPANY_ID, INVOICE_ID), + ) + + expect(res.status).toBe(400) + const body = await res.json() + expect(body.error.code).toBe('VALIDATION_ERROR') + }) + + it('returns 404 when the invoice does not belong to the company', async () => { + mockServiceClient.mockReturnValue( + makeFlexibleSupabase({ + company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null }, + invoices: { data: null, error: null }, + }), + ) + + const res = await patchInvoice( + makePatchRequest({ items: NEW_ITEMS }), + detailParams(COMPANY_ID, INVOICE_ID), + ) + + expect(res.status).toBe(404) + const body = await res.json() + expect(body.error.code).toBe('NOT_FOUND') + }) + + it('returns 409 INVOICE_UPDATE_NOT_DRAFT when replacing items on a sent invoice', async () => { + mockServiceClient.mockReturnValue( + makeFlexibleSupabase({ + company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null }, + invoices: { data: { ...DRAFT_INVOICE, status: 'sent', invoice_number: '2026-0042' }, error: null }, + }), + ) + + const res = await patchInvoice( + makePatchRequest({ items: NEW_ITEMS }), + detailParams(COMPANY_ID, INVOICE_ID), + ) + + expect(res.status).toBe(409) + const body = await res.json() + expect(body.error.code).toBe('INVOICE_UPDATE_NOT_DRAFT') + expect(body.error.details.current_status).toBe('sent') + }) + + it('replaces the items and recomputes totals against the existing customer', async () => { + const captures: Capture[] = [] + const COMPLETE = { + ...DRAFT_INVOICE, + subtotal: 2000, + vat_amount: 500, + total: 2500, + items: [ + { + id: 'iiiiiiii-iiii-4iii-8iii-iiiiiiiiiiii', + sort_order: 0, + description: 'Konsultation', + quantity: 2, + unit: 'tim', + unit_price: 1000, + line_total: 2000, + vat_rate: 25, + vat_amount: 500, + }, + ], + } + mockServiceClient.mockReturnValue( + makeFlexibleSupabase( + { + company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null }, + invoices: [ + { data: DRAFT_INVOICE, error: null }, // pre-flight + { data: INTERNAL_COLUMNS, error: null }, // internal-only columns + { data: { ...DRAFT_INVOICE, subtotal: 2000, vat_amount: 500, total: 2500 }, error: null }, // update + { data: COMPLETE, error: null }, // refetch with items + ], + customers: { + data: { id: CUSTOMER_ID, customer_type: 'swedish_business', vat_number_validated: true }, + error: null, + }, + company_settings: { data: { vat_registered: true }, error: null }, + invoice_items: { data: null, error: null }, + }, + captures, + ), + ) + + const res = await patchInvoice( + makePatchRequest({ items: NEW_ITEMS }), + detailParams(COMPANY_ID, INVOICE_ID), + ) + + expect(res.status).toBe(200) + const body = await res.json() + expect(body.data.total).toBe(2500) + expect(body.data.items).toHaveLength(1) + + // The update payload carries recomputed money math (2 x 1000 + 25% VAT) + // built against the EXISTING customer_id. + const invoiceUpdate = captures.find((c) => c.table === 'invoices' && c.op === 'update') + expect(invoiceUpdate).toBeDefined() + expect(invoiceUpdate!.payload).toMatchObject({ + customer_id: CUSTOMER_ID, + subtotal: 2000, + vat_amount: 500, + total: 2500, + }) + + // Full replace: one insert with the new line set, invoice_id stamped on. + const itemsInsert = captures.find((c) => c.table === 'invoice_items' && c.op === 'insert') + expect(itemsInsert).toBeDefined() + expect(itemsInsert!.payload).toEqual([ + expect.objectContaining({ + invoice_id: INVOICE_ID, + description: 'Konsultation', + line_total: 2000, + vat_amount: 500, + }), + ]) + }) + + it('dry-run previews the replaced items without writing', async () => { + const captures: Capture[] = [] + mockServiceClient.mockReturnValue( + makeFlexibleSupabase( + { + company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null }, + invoices: [ + { data: DRAFT_INVOICE, error: null }, + { data: INTERNAL_COLUMNS, error: null }, + ], + customers: { + data: { id: CUSTOMER_ID, customer_type: 'swedish_business', vat_number_validated: true }, + error: null, + }, + company_settings: { data: { vat_registered: true }, error: null }, + }, + captures, + ), + ) + + const res = await patchInvoice( + makePatchRequest({ items: NEW_ITEMS }, { dryRun: true }), + detailParams(COMPANY_ID, INVOICE_ID), + ) + + expect(res.status).toBe(200) + expect(res.headers.get('X-Dry-Run')).toBe('true') + const body = await res.json() + expect(body.data.dry_run).toBe(true) + expect(body.data.preview.total).toBe(2500) + expect(body.data.preview.items).toHaveLength(1) + // The encrypted personnummer blob never appears in a preview. + expect(body.data.preview).not.toHaveProperty('deduction_personnummer_encrypted') + // No writes happened. + expect(captures).toEqual([]) + }) + + it('still performs a metadata-only update when items are omitted', async () => { + const captures: Capture[] = [] + mockServiceClient.mockReturnValue( + makeFlexibleSupabase( + { + company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null }, + invoices: [ + { data: DRAFT_INVOICE, error: null }, + { data: { ...DRAFT_INVOICE, due_date: '2026-08-15' }, error: null }, + ], + }, + captures, + ), + ) + + const res = await patchInvoice( + makePatchRequest({ due_date: '2026-08-15' }), + detailParams(COMPANY_ID, INVOICE_ID), + ) + + expect(res.status).toBe(200) + const body = await res.json() + expect(body.data.due_date).toBe('2026-08-15') + // No items were touched. + expect(captures.filter((c) => c.table === 'invoice_items')).toEqual([]) + }) + + it('rejects a write without an Idempotency-Key', async () => { + mockServiceClient.mockReturnValue( + makeFlexibleSupabase({ + company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null }, + }), + ) + + const res = await patchInvoice( + makePatchRequest({ items: NEW_ITEMS }, { idempotencyKey: false }), + detailParams(COMPANY_ID, INVOICE_ID), + ) + + expect(res.status).toBe(400) + const body = await res.json() + expect(body.error.code).toBe('VALIDATION_ERROR') + }) +}) diff --git a/app/api/v1/companies/[companyId]/invoices/[id]/mark-paid/__tests__/route.test.ts b/app/api/v1/companies/[companyId]/invoices/[id]/mark-paid/__tests__/route.test.ts index cbc2d846..ae226dfb 100644 --- a/app/api/v1/companies/[companyId]/invoices/[id]/mark-paid/__tests__/route.test.ts +++ b/app/api/v1/companies/[companyId]/invoices/[id]/mark-paid/__tests__/route.test.ts @@ -632,4 +632,225 @@ describe('POST /api/v1/companies/:companyId/invoices/:id/mark-paid', () => { expect(res.status).toBe(403) }) + + it('returns 401 when no bearer token is supplied', async () => { + mockServiceClient.mockReturnValue(makeFlexibleSupabase({})) + + const res = await markPaid( + new Request( + `https://x.test/api/v1/companies/${COMPANY_ID}/invoices/${INVOICE_ID}/mark-paid`, + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Idempotency-Key': 'idem4041-4041-4abc-8def-1234567890ab', + }, + }, + ), + detailParams(COMPANY_ID, INVOICE_ID), + ) + + expect(res.status).toBe(401) + expect(mockPayment).not.toHaveBeenCalled() + }) + + // ------------------------------------------------------------------ + // Foreign-currency unit handling. + // total / paid_amount / remaining_amount are stored in the INVOICE currency; + // custom lines are journal lines and therefore SEK. + // ------------------------------------------------------------------ + + const EUR_INVOICE = { + ...SENT_INVOICE, + currency: 'EUR', + exchange_rate: 11.4967, + subtotal: 800, + vat_amount: 200, + total: 1000, + total_sek: 11496.7, + remaining_amount: 1000, + paid_amount: 0, + } + + it('converts a SEK custom-line payment to invoice currency on a EUR invoice (partial)', async () => { + // 5 748,35 kr against a 1 000 EUR invoice at 11,4967 is exactly 500 EUR: a + // genuine partial. Comparing the raw SEK total against the invoice-currency + // remaining read it as a full settlement and let the duplicate-payment + // guard 409 it on the matching bank row below. + const calls: RecordedCall[] = [] + mockServiceClient.mockReturnValue( + makeFlexibleSupabase( + { + company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null }, + invoices: [ + { data: EUR_INVOICE, error: null }, + { + data: { ...EUR_INVOICE, status: 'partially_paid', remaining_amount: 500, paid_amount: 500 }, + error: null, + }, + ], + company_settings: { data: { accounting_method: 'accrual', entity_type: 'enskild_firma' }, error: null }, + transactions: { + data: [ + { + id: 'eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeee', + date: '2026-05-10', + amount: 5748.35, + description: 'Inbetalning Acme AB', + merchant_name: 'Acme AB', + reference: null, + }, + ], + error: null, + }, + }, + calls, + ), + ) + + const res = await markPaid( + makeRequest( + `https://x.test/api/v1/companies/${COMPANY_ID}/invoices/${INVOICE_ID}/mark-paid`, + { + payment_date: '2026-05-12', + lines: [ + { account_number: '1930', debit_amount: 5748.35, credit_amount: 0 }, + { account_number: '1510', debit_amount: 0, credit_amount: 5748.35 }, + ], + }, + ), + detailParams(COMPANY_ID, INVOICE_ID), + ) + + expect(res.status).toBe(200) + + // The persisted ledger math is the assertion that matters: both values in + // EUR, never 5 748,35 and never a negative remainder. + const update = calls.find((c) => c.table === 'invoices' && c.method === 'update') + expect(update).toBeDefined() + expect(update!.args[0]).toMatchObject({ + status: 'partially_paid', + paid_amount: 500, + remaining_amount: 500, + }) + // The guard compares in invoice currency now, so this stays a partial and + // the transactions scan never runs: the matching bank row above would + // otherwise have 409'd a perfectly valid partial payment. + expect(calls.some((c) => c.table === 'transactions')).toBe(false) + }) + + it('still runs the duplicate guard when the converted SEK lines settle a EUR invoice in full', async () => { + // The complement of the test above: 11 496,70 kr at 11,4967 is exactly the + // 1 000 EUR remaining, so this IS a full settlement and the advisory must + // still fire. Converting for the comparison must not disable the guard on + // foreign-currency invoices. + mockServiceClient.mockReturnValue( + makeFlexibleSupabase({ + company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null }, + invoices: { data: EUR_INVOICE, error: null }, + company_settings: { data: { accounting_method: 'accrual', entity_type: 'enskild_firma' }, error: null }, + transactions: { + // In kronor: the candidate lookup scans transactions.amount, which is SEK. + data: [ + { + id: 'eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeee', + date: '2026-05-10', + amount: 11496.7, + description: 'Inbetalning Acme AB', + merchant_name: 'Acme AB', + reference: null, + }, + ], + error: null, + }, + }), + ) + + const res = await markPaid( + makeRequest( + `https://x.test/api/v1/companies/${COMPANY_ID}/invoices/${INVOICE_ID}/mark-paid`, + { + payment_date: '2026-05-12', + lines: [ + { account_number: '1930', debit_amount: 11496.7, credit_amount: 0 }, + { account_number: '1510', debit_amount: 0, credit_amount: 11496.7 }, + ], + }, + ), + detailParams(COMPANY_ID, INVOICE_ID), + ) + + expect(res.status).toBe(409) + const body = await res.json() + expect(body.error.code).toBe('INVOICE_PAID_LIKELY_DUPLICATE') + expect(body.error.details.candidates[0].id).toBe('eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeee') + }) + + it('returns 400 MATCH_INVOICE_BOOKING_RATE_MISSING when a EUR invoice carries no exchange rate', async () => { + // 11 496,70 kr against a 1 000 EUR invoice with no rate on file. Defaulting + // the rate to 1 would read the payment as 11 496,70 EUR. + mockServiceClient.mockReturnValue( + makeFlexibleSupabase({ + company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null }, + invoices: { data: { ...EUR_INVOICE, exchange_rate: null, total_sek: null }, error: null }, + company_settings: { data: { accounting_method: 'accrual', entity_type: 'enskild_firma' }, error: null }, + }), + ) + + const res = await markPaid( + makeRequest( + `https://x.test/api/v1/companies/${COMPANY_ID}/invoices/${INVOICE_ID}/mark-paid`, + { + payment_date: '2026-05-12', + lines: [ + { account_number: '1930', debit_amount: 11496.7, credit_amount: 0 }, + { account_number: '1510', debit_amount: 0, credit_amount: 11496.7 }, + ], + }, + ), + detailParams(COMPANY_ID, INVOICE_ID), + ) + + expect(res.status).toBe(400) + const body = await res.json() + // Same code the bank-match path throws for the same condition + // (lib/bookkeeping/invoice-payment-lines.ts). + expect(body.error.code).toBe('MATCH_INVOICE_BOOKING_RATE_MISSING') + expect(body.error.details.currency).toBe('EUR') + expect(mockPayment).not.toHaveBeenCalled() + }) + + it('still pays a rate-less EUR invoice in full when no custom lines are supplied', async () => { + // The default path pays remaining_amount, already in invoice currency: no + // conversion happens, so no rate is required and nothing is rejected. + const calls: RecordedCall[] = [] + mockServiceClient.mockReturnValue( + makeFlexibleSupabase( + { + company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null }, + invoices: [ + { data: { ...EUR_INVOICE, exchange_rate: null, total_sek: null }, error: null }, + { + data: { ...EUR_INVOICE, exchange_rate: null, status: 'paid', remaining_amount: 0, paid_amount: 1000 }, + error: null, + }, + ], + company_settings: { data: { accounting_method: 'accrual', entity_type: 'enskild_firma' }, error: null }, + }, + calls, + ), + ) + + const res = await markPaid( + makeRequest( + `https://x.test/api/v1/companies/${COMPANY_ID}/invoices/${INVOICE_ID}/mark-paid`, + { payment_date: '2026-05-12' }, + ), + detailParams(COMPANY_ID, INVOICE_ID), + ) + + expect(res.status).toBe(200) + const update = calls.find((c) => c.table === 'invoices' && c.method === 'update') + expect(update!.args[0]).toMatchObject({ status: 'paid', paid_amount: 1000, remaining_amount: 0 }) + }) }) diff --git a/app/api/v1/companies/[companyId]/invoices/[id]/mark-paid/route.ts b/app/api/v1/companies/[companyId]/invoices/[id]/mark-paid/route.ts index 40635789..14c1bf6d 100644 --- a/app/api/v1/companies/[companyId]/invoices/[id]/mark-paid/route.ts +++ b/app/api/v1/companies/[companyId]/invoices/[id]/mark-paid/route.ts @@ -81,6 +81,7 @@ registerEndpoint({ 'Idempotency-Key is mandatory. Retried marks with the same key replay the cached response.', 'Custom `lines` must balance (sum of debits = sum of credits, both > 0). Otherwise returns 400 INVOICE_PAID_LINES_UNBALANCED.', 'For foreign-currency invoices, supply `exchange_rate_difference` (SEK delta vs the invoice\'s booked rate) to book the FX adjustment correctly. Omitting it on a non-SEK invoice will mis-book the FX gain/loss.', + 'Custom `lines` are journal lines and therefore SEK, while `total` / `paid_amount` / `remaining_amount` are stored in the invoice currency. The route converts the line total via `invoice.exchange_rate`; a non-SEK invoice with no exchange_rate on file returns 400 MATCH_INVOICE_BOOKING_RATE_MISSING rather than silently treating the SEK amount as invoice currency.', 'Cash basis (kontantmetoden) recognizes revenue HERE, not at :mark-sent. The dashboard tracks this via company_settings.accounting_method.', 'Duplicate-payment guard: if an unlinked inbound bank transaction looks like this payment, returns 409 INVOICE_PAID_LIKELY_DUPLICATE with candidate transactions. Retry with `force: true` to bypass, but the retry MUST use a fresh Idempotency-Key (the original is body-hash bound; reusing it returns 400 IDEMPOTENCY_KEY_REUSE). The guard is also evaluated under dry-run, so a successful dry-run does not guarantee a successful commit.', ], @@ -272,19 +273,41 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string ? customLines.reduce((s, l) => s + l.debit_amount, 0) : (typed.remaining_amount ?? typed.total - (typed.paid_amount ?? 0)) + // Unit contract: total / paid_amount / remaining_amount are stored in the + // INVOICE currency (total_sek carries the SEK view of total, and there is + // no remaining_amount_sek twin); custom lines are journal lines, so they + // are always SEK. The SEK amount therefore has to be converted before it is + // compared against, or subtracted from, the invoice-currency remaining. The + // default path (no lines) already pays the remaining in invoice currency + // and needs no rate at all. + const isForeignCurrency = !!typed.currency && typed.currency !== 'SEK' + const needsFxConversion = isForeignCurrency && customLines !== undefined + const fxRate: number | null = + typed.exchange_rate && typed.exchange_rate > 0 ? typed.exchange_rate : null + if (needsFxConversion && fxRate === null) { + // Never fall back to rate 1: that reads an 11 496,70 kr payment against a + // 1 000 EUR invoice as 11 496,70 EUR and corrupts the AR sub-ledger. + // Same code as buildInvoicePaymentClearingLines' refusal + // (MATCH_INVOICE_BOOKING_RATE_MISSING, lib/bookkeeping/invoice-payment-lines.ts): + // one condition, one code across every invoice-settlement surface. + ctx.log.warn('mark-paid rejected: foreign-currency invoice without exchange rate', { + invoiceId, + currency: typed.currency, + }) + return v1ErrorResponseFromCode('MATCH_INVOICE_BOOKING_RATE_MISSING', ctx.log, { + requestId: ctx.requestId, + details: { invoice_id: invoiceId, currency: typed.currency }, + }) + } + const paymentAmountInInvoiceCurrency = needsFxConversion + ? roundOre(paymentAmount / fxRate!) + : paymentAmount + // Ledger math + overpayment guard via the shared planInvoicePayment helper: // the single source of truth across all three mark-paid surfaces (this route, // the dashboard route, and the agent commit path), so the paid/remaining/ - // status math can never drift again. Custom lines are SEK; convert to invoice - // currency for the comparison so a foreign-currency invoice isn't falsely - // rejected as overpaid (the default path is already in invoice currency). - const fxRate = - typed.currency && typed.currency !== 'SEK' && typed.exchange_rate - ? typed.exchange_rate - : 1 - const paymentAmountInInvoiceCurrency = customLines - ? roundOre(paymentAmount / fxRate) - : paymentAmount + // status math can never drift again. It is FX-agnostic by contract, so it + // gets the converted amount. // Custom-line SEK settlements absorb a sub-krona öresavrundning residual // (rounded "Att betala" vs the stored öre total), but ONLY when the lines // actually carry the residual on 3740: otherwise the strict plan applies @@ -311,8 +334,14 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string // successful dry-run can't mask the warning). Skipped on partial // payments (paymentAmount < remaining is an explicit, deliberate action), // on force=true, and on invoices without a resolved customer name. + // Both sides in invoice currency: remaining_amount is stored that way, so + // the SEK custom-line total must be the converted one, never the raw SEK. + // The candidate lookup below takes the same converted figure: it scans + // transactions.amount, which is denominated in the BANK ROW's currency + // (not necessarily kronor), and bands each currency separately from the + // invoice's stored conversion. const remainingForGuard = typed.remaining_amount ?? typed.total - const paidRoundedGuard = Math.round(paymentAmount * 100) / 100 + const paidRoundedGuard = Math.round(paymentAmountInInvoiceCurrency * 100) / 100 const remainingRoundedGuard = Math.round(remainingForGuard * 100) / 100 if (!force && paidRoundedGuard >= remainingRoundedGuard) { const customerName = typed.customer?.name @@ -324,8 +353,15 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string } else { const candidates = await findDuplicatePaymentCandidatesForInvoice(ctx.supabase, { companyId: ctx.companyId!, - invoice: { invoice_number: typed.invoice_number, customer_name: customerName }, - paymentAmount, + invoice: { + invoice_number: typed.invoice_number, + customer_name: customerName, + currency: typed.currency ?? null, + total: typed.total ?? null, + total_sek: typed.total_sek ?? null, + exchange_rate: typed.exchange_rate ?? null, + }, + paymentAmount: paymentAmountInInvoiceCurrency, paymentDate, }) if (candidates.length > 0) { diff --git a/app/api/v1/companies/[companyId]/invoices/[id]/route.ts b/app/api/v1/companies/[companyId]/invoices/[id]/route.ts index 5f3e190e..fbf67824 100644 --- a/app/api/v1/companies/[companyId]/invoices/[id]/route.ts +++ b/app/api/v1/companies/[companyId]/invoices/[id]/route.ts @@ -3,9 +3,13 @@ * * GET: full invoice record. ?expand=items,payments controls embedding. * PATCH: partial update on DRAFT invoices only. Allowed fields are the - * "metadata" subset (dates, references, notes); customer_id, - * currency, document_type, and items are immutable: changing any - * of those means delete-and-recreate (drafts are cheap). Returns + * "metadata" subset (dates, references, notes, default_dimensions) + * plus an OPTIONAL `items` array: when present, it fully REPLACES + * the draft's line items and totals/VAT are recomputed via the + * shared buildInvoiceWriteData against the invoice's EXISTING + * customer; when omitted, items are untouched. customer_id, + * currency, and document_type remain immutable: changing those + * means delete-and-recreate (drafts are cheap). Returns * 409 INVOICE_UPDATE_NOT_DRAFT (reusing existing code) if the * invoice is not in draft status. * @@ -21,11 +25,18 @@ import { withApiV1 } from '@/lib/api/v1/with-api-v1' import { v1ErrorResponse, v1ErrorResponseFromCode } from '@/lib/api/v1/errors' import { INVOICE_FULL_COLUMNS, INVOICE_ITEM_FULL_COLUMNS } from '@/lib/api/v1/invoice-columns' import { DimensionsBagSchema } from '@/lib/bookkeeping/dimension-resolver' +import { CreateInvoiceItemSchema } from '@/lib/api/schemas' +import { buildInvoiceWriteData } from '@/lib/invoices/build-invoice-write' +import { replaceInvoiceItems } from '@/lib/invoices/replace-invoice-items' +import type { Currency, Customer, InvoiceDocumentType } from '@/types' -// Allowed PATCH fields for a draft invoice. Excludes items (separate -// workflow), customer_id / currency / document_type (structural: change -// via delete + recreate), invoice_number (allocated server-side), all -// computed totals, and status (state machine: use action verbs in PR-B-2b). +// Allowed PATCH fields for a draft invoice. Excludes customer_id / currency / +// document_type (structural: change via delete + recreate), invoice_number +// (allocated server-side), all computed totals, and status (state machine: +// use action verbs in PR-B-2b). `items` is OPTIONAL: when present it fully +// replaces the line set (delete + reinsert, totals recomputed); when omitted +// the items are unchanged. Note this differs from the cookie route, where +// items is required: a v1 metadata-only PATCH must keep working without it. const V1PatchDraftInvoiceSchema = z.object({ invoice_date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, 'Expected YYYY-MM-DD').optional(), due_date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, 'Expected YYYY-MM-DD').optional(), @@ -37,6 +48,9 @@ const V1PatchDraftInvoiceSchema = z.object({ // Send {} to clear all tags. Codes are validated against the dimension // registry when the invoice posts at :send, not here. default_dimensions: DimensionsBagSchema.optional(), + // FULL REPLACE when present. Same item shape as POST /invoices (article + // linkage, ROT/RUT lines, accrual periods, per-line dimensions included). + items: z.array(CreateInvoiceItemSchema).min(1, 'At least one item is required').optional(), }) // Loose schema: detail responses carry many fields, and pinning the exact @@ -183,17 +197,18 @@ registerEndpoint({ operation: 'invoices.update', method: 'PATCH', path: '/api/v1/companies/:companyId/invoices/:id', - summary: 'Update a draft invoice (metadata fields only).', + summary: 'Update a draft invoice (metadata fields, optionally replacing line items).', description: - 'Partial update for invoices in draft status. Allowed fields: invoice_date, due_date, delivery_date, your_reference, our_reference, notes, default_dimensions (project/cost-centre tags, e.g. {"6":"P001"}; replaces the whole bag). customer_id, currency, document_type, items, and computed totals are immutable: replace those by deleting the draft and recreating it. Returns 409 INVOICE_UPDATE_NOT_DRAFT if the invoice is no longer in draft status. Idempotent and dry-runnable.', + 'Partial update for invoices in draft status. Allowed fields: invoice_date, due_date, delivery_date, your_reference, our_reference, notes, default_dimensions (project/cost-centre tags, e.g. {"6":"P001"}; replaces the whole bag), and an optional items array. When items is present, it fully REPLACES the draft\'s line items and subtotal / VAT / total are recomputed against the invoice\'s existing customer (same validation as POST /invoices); when omitted, items and totals are unchanged. customer_id, currency, and document_type are immutable: replace those by deleting the draft and recreating it. Returns 409 INVOICE_UPDATE_NOT_DRAFT if the invoice is no longer in draft status. Idempotent and dry-runnable.', useWhen: - 'You need to correct a typo, push the due date, or update a customer reference on a draft you have not sent yet. The invoice number stays null until the first :send action.', + 'You need to correct a typo, push the due date, update a customer reference, or rewrite the line items on a draft you have not sent yet. The invoice number stays null until the first :send action.', doNotUseFor: - 'Updating a sent / paid / credited invoice (those are immutable per ML 17 kap; issue a credit note via POST /:id:credit in PR-B-2b). Changing items, currency, or customer: drafts are cheap to delete and recreate.', + 'Updating a sent / paid / credited invoice (those are immutable per ML 17 kap; issue a credit note via POST /:id:credit in PR-B-2b). Changing currency or customer: drafts are cheap to delete and recreate.', pitfalls: [ 'Idempotency-Key is mandatory.', 'A 409 INVOICE_UPDATE_NOT_DRAFT means the invoice has been sent / paid / credited / cancelled. The error code name is shared with the DELETE handler.', - 'Items are immutable here: to change line items, delete the draft and POST a fresh one.', + 'items is a FULL REPLACE (no per-line merge): send the complete new line set, minimum one item. Omitting items keeps the current lines untouched. VAT rates are re-validated against the customer type and totals are recomputed server-side.', + 'items are always built against the invoice\'s EXISTING customer: customer_id cannot change on PATCH.', 'default_dimensions replaces the entire bag (no per-key merge): read the current value first if you want to add a tag. Send {} to clear all tags. Codes are validated against the dimension registry at :send, not at PATCH time.', ], example: { @@ -270,7 +285,7 @@ export const PATCH = withApiV1<{ params: Promise<{ companyId: string; id: string if (body[key] !== undefined) updateData[key] = body[key] } - if (Object.keys(updateData).length === 0) { + if (Object.keys(updateData).length === 0 && !body.items) { return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, { requestId: ctx.requestId, details: { field: 'body', message: 'At least one field must be supplied for update.' }, @@ -304,6 +319,188 @@ export const PATCH = withApiV1<{ params: Promise<{ companyId: string; id: string }) } + // ── Items replacement path ──────────────────────────────────────── + // Full replace + recompute via the shared write-builder (the same one + // POST /invoices and the cookie PATCH route use), built against the + // invoice's EXISTING customer: customer_id is immutable on PATCH. + if (body.items) { + const cur = current as Record + + // Internal-only columns for the rebuild. Fetched separately so the + // response/preview projection (INVOICE_PATCH_RESPONSE_COLUMNS) can + // never leak the encrypted personnummer blob. + const { data: internal, error: internalErr } = await ctx.supabase + .from('invoices') + .select('ore_rounding, deduction_personnummer_encrypted, deduction_personnummer_last4') + .eq('company_id', ctx.companyId!) + .eq('id', invoiceId) + .maybeSingle() + if (internalErr) { + return v1ErrorResponse(internalErr, ctx.log, { requestId: ctx.requestId }) + } + if (!internal) { + return v1ErrorResponseFromCode('NOT_FOUND', ctx.log, { + requestId: ctx.requestId, + details: { resource: 'invoice' }, + }) + } + const internalCols = internal as { + ore_rounding: boolean | null + deduction_personnummer_encrypted: string | null + deduction_personnummer_last4: string | null + } + + // The builder only reads customer_type + vat_number_validated: narrow + // projection keeps customer PII out of this path. + const { data: customer, error: customerErr } = await ctx.supabase + .from('customers') + .select('id, customer_type, vat_number_validated') + .eq('company_id', ctx.companyId!) + .eq('id', cur.customer_id as string) + .maybeSingle() + if (customerErr) { + return v1ErrorResponse(customerErr, ctx.log, { requestId: ctx.requestId }) + } + if (!customer) { + return v1ErrorResponseFromCode('INVOICE_CUSTOMER_NOT_FOUND', ctx.log, { + requestId: ctx.requestId, + details: { resource: 'customer' }, + }) + } + + // ROT/RUT claim info lives per line, not on the header: surface the + // first deduction line's values as the invoice-level inputs the + // builder's presence checks expect (per-line values win regardless). + const firstDeduction = body.items.find((item) => item.deduction_type) + + const build = await buildInvoiceWriteData({ + supabase: ctx.supabase, + companyId: ctx.companyId!, + customer: customer as unknown as Customer, + documentType: ((cur.document_type as string) || 'invoice') as InvoiceDocumentType, + input: { + customer_id: cur.customer_id as string, + invoice_date: (body.invoice_date ?? cur.invoice_date) as string, + due_date: (body.due_date ?? cur.due_date) as string, + delivery_date: + body.delivery_date !== undefined + ? body.delivery_date + : (cur.delivery_date as string | null), + currency: cur.currency as Currency, + your_reference: + (body.your_reference !== undefined ? body.your_reference : (cur.your_reference as string | null)) ?? + undefined, + our_reference: + (body.our_reference !== undefined ? body.our_reference : (cur.our_reference as string | null)) ?? + undefined, + notes: (body.notes !== undefined ? body.notes : (cur.notes as string | null)) ?? undefined, + // Not editable on this surface: fed back so the builder echoes the + // stored values instead of clearing them. + payment_link_url: (cur.payment_link_url as string | null) ?? undefined, + payment_link_auto: (cur.payment_link_auto as boolean | null) ?? undefined, + ore_rounding: internalCols.ore_rounding ?? undefined, + deduction_housing_designation: firstDeduction?.housing_designation ?? undefined, + deduction_apartment_number: firstDeduction?.apartment_number ?? undefined, + deduction_brf_org_number: firstDeduction?.brf_org_number ?? undefined, + default_dimensions: + body.default_dimensions ?? + ((cur.default_dimensions as Record | null) ?? {}), + items: body.items, + }, + // The stored personnummer exists only as ciphertext: a replace that + // still carries deduction lines keeps the stored value. + existingPersonnummer: internalCols.deduction_personnummer_encrypted + ? { + encrypted: internalCols.deduction_personnummer_encrypted, + last4: internalCols.deduction_personnummer_last4, + } + : null, + }) + if (!build.ok) { + if ('dbError' in build) { + return v1ErrorResponse(build.dbError, ctx.log, { requestId: ctx.requestId }) + } + // Same snake_case wire mapping as POST /invoices for this code. + const details = + build.code === 'INVOICE_CREATE_VAT_RULE_VIOLATION' && build.details + ? { + attempted_rate: build.details.attemptedRate, + allowed_rates: build.details.allowedRates, + customer_type: build.details.customerType, + } + : build.details + return v1ErrorResponseFromCode(build.code, ctx.log, { + requestId: ctx.requestId, + details, + }) + } + + // Never echo the encrypted personnummer blob in a preview. + const { deduction_personnummer_encrypted: _omit, ...previewFields } = build.invoiceFields + + if (ctx.dryRun) { + return dryRunPreview( + { ...current, ...previewFields, ...updateData, items: build.items }, + { requestId: ctx.requestId, log: ctx.log }, + ) + } + + // Computed fields from the builder; explicitly-sent metadata (incl. + // nulls that clear a column) wins on top. invoice_number and status + // stay untouched; the status guard turns a concurrent send into a + // 0-row update instead of rewriting a now-issued invoice. + const { data: updatedRow, error: updateErr } = await ctx.supabase + .from('invoices') + .update({ ...build.invoiceFields, ...updateData, updated_at: new Date().toISOString() }) + .eq('company_id', ctx.companyId!) + .eq('id', invoiceId) + .eq('status', 'draft') + .select(INVOICE_PATCH_RESPONSE_COLUMNS) + .maybeSingle() + + if (updateErr) { + return v1ErrorResponse(updateErr, ctx.log, { requestId: ctx.requestId }) + } + if (!updatedRow) { + return v1ErrorResponseFromCode('INVOICE_UPDATE_NOT_DRAFT', ctx.log, { + requestId: ctx.requestId, + details: { reason: 'Invoice transitioned out of draft during update.' }, + }) + } + + // Full replace via the shared helper (same delete + reinsert as the + // cookie route and the update_invoice commit executor). + const replaced = await replaceInvoiceItems(ctx.supabase, invoiceId, build.items) + if (!replaced.ok) { + ctx.log.error(`invoice items ${replaced.stage} failed on v1 update`, replaced.error, { + invoiceId, + companyId: ctx.companyId, + }) + return v1ErrorResponseFromCode('INVOICE_CREATE_ITEMS_FAILED', ctx.log, { + requestId: ctx.requestId, + details: { stage: replaced.stage, pg_code: replaced.error.code }, + }) + } + + // Refetch with items so the caller sees the replaced line set. + const { data: complete, error: refetchErr } = await ctx.supabase + .from('invoices') + .select(`${INVOICE_PATCH_RESPONSE_COLUMNS}, items:invoice_items(${INVOICE_ITEM_COLUMNS})`) + .eq('company_id', ctx.companyId!) + .eq('id', invoiceId) + .maybeSingle() + + if (refetchErr || !complete) { + ctx.log.warn('invoice refetch after items update failed; returning header without items', { + invoiceId, + pgCode: (refetchErr as { code?: string } | null)?.code, + }) + return ok(updatedRow, { requestId: ctx.requestId }) + } + + return ok(complete, { requestId: ctx.requestId }) + } + if (ctx.dryRun) { return dryRunPreview({ ...current, ...updateData }, { requestId: ctx.requestId, log: ctx.log }) } diff --git a/app/api/v1/companies/[companyId]/invoices/[id]/send/__tests__/route.test.ts b/app/api/v1/companies/[companyId]/invoices/[id]/send/__tests__/route.test.ts index 31b49bc4..8989092a 100644 --- a/app/api/v1/companies/[companyId]/invoices/[id]/send/__tests__/route.test.ts +++ b/app/api/v1/companies/[companyId]/invoices/[id]/send/__tests__/route.test.ts @@ -102,7 +102,11 @@ vi.mock('@/lib/invoices/invoice-deliveries', () => ({ sendTrackedInvoiceEmail: (...args: unknown[]) => mockSendTrackedInvoiceEmail(...args as [never]), })) -vi.mock('@/lib/email/invoice-templates', () => ({ +// Partial mock: the route's module graph reaches INVOICE_EMAIL_PLACEHOLDER_KEYS +// through the company-settings staging schema, so keep the real exports and +// stub only the render functions. +vi.mock('@/lib/email/invoice-templates', async (importOriginal) => ({ + ...(await importOriginal()), generateInvoiceEmailHtml: vi.fn().mockReturnValue('...'), generateInvoiceEmailText: vi.fn().mockReturnValue('plain text'), generateInvoiceEmailSubject: vi.fn().mockReturnValue('Faktura'), diff --git a/app/api/v1/companies/[companyId]/invoices/__tests__/route.test.ts b/app/api/v1/companies/[companyId]/invoices/__tests__/route.test.ts index 16555738..0a05a7fc 100644 --- a/app/api/v1/companies/[companyId]/invoices/__tests__/route.test.ts +++ b/app/api/v1/companies/[companyId]/invoices/__tests__/route.test.ts @@ -1277,7 +1277,10 @@ describe('PATCH /api/v1/companies/:companyId/invoices/:id', () => { expect(body.error.code).toBe('VALIDATION_ERROR') }) - it('rejects forbidden fields (items / currency / customer_id)', async () => { + it('rejects forbidden structural fields (currency / customer_id)', async () => { + // `items` used to belong here but is now a supported full-replace field + // (see the [id]/__tests__/route.test.ts suite); customer_id and currency + // remain immutable on PATCH. withInvoiceWriteScope() mockServiceClient.mockReturnValue( makeFlexibleSupabase({ @@ -1289,7 +1292,6 @@ describe('PATCH /api/v1/companies/:companyId/invoices/:id', () => { makePatchInvoice(`https://x.test/api/v1/companies/${COMPANY_ID}/invoices/${INVOICE_ID}`, { customer_id: CUSTOMER_ID, currency: 'EUR', - items: [{ description: 'no', quantity: 1, unit: 'st', unit_price: 1 }], }), detailParams(COMPANY_ID, INVOICE_ID), ) diff --git a/app/api/v1/companies/[companyId]/invoices/bulk-create/__tests__/route.test.ts b/app/api/v1/companies/[companyId]/invoices/bulk-create/__tests__/route.test.ts index 3164f6fd..6496916b 100644 --- a/app/api/v1/companies/[companyId]/invoices/bulk-create/__tests__/route.test.ts +++ b/app/api/v1/companies/[companyId]/invoices/bulk-create/__tests__/route.test.ts @@ -181,6 +181,76 @@ describe('POST /api/v1/companies/:companyId/invoices/bulk-create', () => { expect(body.data.summary.succeeded).toBe(0) }) + // A validated EU business: the picker default is 0% (huvudregeln, ML 6 kap. + // 34 §), but the ML 6 kap. supplies taxed where they are performed carry + // Swedish VAT to that same customer, so the gate reads getPermittedVatRates. + const EU_CUSTOMER = { + id: CUSTOMER_ID, + customer_type: 'eu_business', + vat_number_validated: true, + } + + it('accepts a 12% line to a validated EU business (taxed where performed)', async () => { + mockServiceClient.mockReturnValue( + makeFlexibleSupabase({ + company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null }, + customers: { data: EU_CUSTOMER, error: null }, + invoices: { data: { id: 'inv-1', invoice_number: null, status: 'draft', total: 1120 }, error: null }, + invoice_items: { data: null, error: null }, + }), + ) + + const res = await bulkCreate( + makeRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/invoices/bulk-create`, { + invoices: [ + { + customer_id: CUSTOMER_ID, + invoice_date: '2026-05-12', + due_date: '2026-06-11', + currency: 'SEK', + items: [{ ...SAMPLE_ITEM('Hotellnatt Stockholm'), vat_rate: 12 }], + }, + ], + }), + companyParams(COMPANY_ID), + ) + + expect(res.status).toBe(200) + const body = await res.json() + expect(body.data.results[0].ok).toBe(true) + expect(body.data.summary.succeeded).toBe(1) + }) + + it('still rejects a non-Swedish rate for a validated EU business', async () => { + mockServiceClient.mockReturnValue( + makeFlexibleSupabase({ + company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null }, + customers: { data: EU_CUSTOMER, error: null }, + }), + ) + + const res = await bulkCreate( + makeRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/invoices/bulk-create`, { + invoices: [ + { + customer_id: CUSTOMER_ID, + invoice_date: '2026-05-12', + due_date: '2026-06-11', + currency: 'SEK', + items: [{ ...SAMPLE_ITEM('A'), vat_rate: 10 }], + }, + ], + }), + companyParams(COMPANY_ID), + ) + + expect(res.status).toBe(200) + const body = await res.json() + expect(body.data.results[0].ok).toBe(false) + expect(body.data.results[0].error.code).toBe('INVOICE_CREATE_VAT_RULE_VIOLATION') + expect(body.data.results[0].error.details.allowed_rates).toEqual([0, 25, 12, 6]) + }) + it('returns 400 VALIDATION_ERROR when the bulk envelope is malformed', async () => { mockServiceClient.mockReturnValue( makeFlexibleSupabase({ diff --git a/app/api/v1/companies/[companyId]/invoices/bulk-create/route.ts b/app/api/v1/companies/[companyId]/invoices/bulk-create/route.ts index 5493ab88..0f916185 100644 --- a/app/api/v1/companies/[companyId]/invoices/bulk-create/route.ts +++ b/app/api/v1/companies/[companyId]/invoices/bulk-create/route.ts @@ -40,7 +40,7 @@ import { registerEndpoint, dataEnvelope } from '@/lib/api/v1/registry' import { withApiV1 } from '@/lib/api/v1/with-api-v1' import { v1ErrorResponseFromCode } from '@/lib/api/v1/errors' import { CreateInvoiceSchema } from '@/lib/api/schemas' -import { getAvailableVatRates, getVatRules } from '@/lib/invoices/vat-rules' +import { getPermittedVatRates, getVatRules } from '@/lib/invoices/vat-rules' import { convertToSEK, fetchExchangeRate } from '@/lib/currency/riksbanken' import { eventBus } from '@/lib/events' import type { Logger } from '@/lib/logger' @@ -182,11 +182,17 @@ async function createOneInvoice( customer.customer_type as Parameters[0], customer.vat_number_validated, ) - const availableRates = getAvailableVatRates( - customer.customer_type as Parameters[0], + // Gate on the PERMITTED set, not the picker default, exactly like + // buildInvoiceWriteData: the ML 6 kap. supplies taxed where they are performed + // (hotel/restaurang 12%, persontransport and event admission 6%, + // fastighetstjänst and korttidsuthyrning 25%) carry Swedish VAT even to a + // foreign business customer. The default is still 0% (vatRules.rate is the + // fallback below), so a Swedish rate only lands here when sent explicitly. + const permittedRates = getPermittedVatRates( + customer.customer_type as Parameters[0], customer.vat_number_validated, ) - const allowedRates = new Set(availableRates.map((r) => r.rate)) + const allowedRates = new Set(permittedRates.map((r) => r.rate)) const subtotal = input.items.reduce((sum, item) => sum + item.quantity * item.unit_price, 0) let vatAmount = 0 diff --git a/app/api/v1/companies/[companyId]/invoices/route.ts b/app/api/v1/companies/[companyId]/invoices/route.ts index 79386eb2..1078b15d 100644 --- a/app/api/v1/companies/[companyId]/invoices/route.ts +++ b/app/api/v1/companies/[companyId]/invoices/route.ts @@ -1,8 +1,8 @@ /** * /api/v1/companies/{companyId}/invoices: list + create invoice endpoints. * - * GET: list with filters (status, customer_id, document_type, currency). - * Cursor pagination on (invoice_date DESC, id DESC). + * GET: list with filters (status, customer_id, document_type, currency, + * invoice_date range). Cursor pagination on (created_at DESC, id ASC). * POST: create draft invoice. Idempotent (mandatory Idempotency-Key). * Dry-runnable (?dry_run=true returns the validated would-be * invoice + items with computed VAT totals; no DB writes). @@ -141,7 +141,7 @@ registerEndpoint({ path: '/api/v1/companies/:companyId/invoices', summary: 'List invoices for a company.', description: - 'Returns invoices in most-recent-first order. Includes the customer name inline; pass ?expand=customer for the full customer record, ?expand=items for line items.', + 'Cursor-paginated invoice list ordered by created_at DESC, id ASC (newest-registered first; the `invoice_date` column is the business date and is filterable via ?date_from / ?date_to but is not the sort key). Includes the customer name inline; pass ?expand=customer for the full customer record, ?expand=items for line items.', useWhen: 'You need to enumerate invoices for a company: for AR reporting, payment matching, or building an invoice dashboard.', doNotUseFor: @@ -150,6 +150,8 @@ registerEndpoint({ 'Draft invoices have invoice_number=null until they are sent.', 'remaining_amount is the unpaid portion (total − paid_amount); use status=paid or remaining_amount=0 to filter for closed invoices.', 'Credit notes appear with status=credited and a credited_invoice_id field on the detail endpoint.', + 'Ordering is by created_at (registration time), not invoice_date. Backdated invoices therefore appear where they were created, not where their date falls: filter on ?date_from / ?date_to when you care about the business date.', + 'Cursor pagination: pass ?cursor= from the previous response. A stale or tampered cursor is ignored and the first page is returned again.', ], example: { response: { @@ -212,12 +214,19 @@ export const GET = withApiV1<{ params: Promise<{ companyId: string }> }>( customer_id: z.string().uuid().optional(), document_type: InvoiceDocumentType.optional(), currency: z.string().regex(/^[A-Z]{3}$/, 'currency must be a 3-letter ISO-4217 code').optional(), + // invoice_date range. The sort key is created_at (see the ordering + // rationale below), so these filters are how a caller narrows by the + // business date. + date_from: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, 'date_from must be ISO YYYY-MM-DD').optional(), + date_to: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, 'date_to must be ISO YYYY-MM-DD').optional(), }) const filtersResult = FiltersSchema.safeParse({ status: url.searchParams.get('status') ?? undefined, customer_id: url.searchParams.get('customer_id') ?? undefined, document_type: url.searchParams.get('document_type') ?? undefined, currency: url.searchParams.get('currency') ?? undefined, + date_from: url.searchParams.get('date_from') ?? undefined, + date_to: url.searchParams.get('date_to') ?? undefined, }) if (!filtersResult.success) { return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, { @@ -241,24 +250,35 @@ export const GET = withApiV1<{ params: Promise<{ companyId: string }> }>( const itemsSelect = expand.has('items') ? `, items:invoice_items(${INVOICE_ITEM_COLUMNS})` : '' const selectClause = `${INVOICE_SUMMARY_COLUMNS}, ${customerSelect}${itemsSelect}` + // Sort by (created_at DESC, id ASC). created_at is the stable cursor + // anchor: it's a real timestamp (passes ISO-8601 validation in + // decodeDefaultCursor), NOT NULL, and total-orderable once id breaks + // ties. Sorting by `invoice_date` directly broke the cursor: a Postgres + // `date` serializes as YYYY-MM-DD, the decoder rejected it, the keyset + // predicate was never applied, and every "next page" silently returned + // page 1 forever while still advertising a fresh next_cursor. The + // invoice date is still on every row and ?date_from / ?date_to filter + // on it. Same anchor as the transactions list. let query = ctx.supabase .from('invoices') .select(selectClause) .eq('company_id', ctx.companyId!) - .order('invoice_date', { ascending: false }) - .order('id', { ascending: false }) + .order('created_at', { ascending: false }) + .order('id', { ascending: true }) .limit(limit + 1) if (filters.status) query = query.eq('status', filters.status) if (filters.customer_id) query = query.eq('customer_id', filters.customer_id) if (filters.document_type) query = query.eq('document_type', filters.document_type) if (filters.currency) query = query.eq('currency', filters.currency) + if (filters.date_from) query = query.gte('invoice_date', filters.date_from) + if (filters.date_to) query = query.lte('invoice_date', filters.date_to) if (decoded) { - // Keyset on (invoice_date DESC, id DESC): - // invoice_date < ts OR (invoice_date = ts AND id < cursor_id) + // Keyset on (created_at DESC, id ASC): created_at moves backward, + // id breaks ties within the same timestamp. query = query.or( - `invoice_date.lt.${decoded.ts},and(invoice_date.eq.${decoded.ts},id.lt.${decoded.id})`, + `created_at.lt.${decoded.ts},and(created_at.eq.${decoded.ts},id.gt.${decoded.id})`, ) } @@ -327,7 +347,7 @@ export const GET = withApiV1<{ params: Promise<{ companyId: string }> }>( const last = trimmed[trimmed.length - 1] const nextCursor = hasMore && last - ? encodeDefaultCursor({ id: last.id, created_at: last.invoice_date }) + ? encodeDefaultCursor({ id: last.id, created_at: last.created_at }) : null return paginated(invoices, { diff --git a/app/api/v1/companies/[companyId]/journal-entries/route.ts b/app/api/v1/companies/[companyId]/journal-entries/route.ts index efe2446a..027932fd 100644 --- a/app/api/v1/companies/[companyId]/journal-entries/route.ts +++ b/app/api/v1/companies/[companyId]/journal-entries/route.ts @@ -2,7 +2,7 @@ * /api/v1/companies/{companyId}/journal-entries: list + create draft. * * GET : cursor-paginated list with filters (fiscal_period_id, status, date range). - * Cursor on (entry_date DESC, id DESC). + * Cursor on (created_at DESC, id ASC). * POST : create a draft verifikation. Idempotent (mandatory Idempotency-Key). * Dry-runnable. The draft has no voucher number until you call * /commit, so a draft that's never committed produces no löpnummer gap @@ -79,7 +79,7 @@ registerEndpoint({ path: '/api/v1/companies/:companyId/journal-entries', summary: 'List journal entries (verifikationer).', description: - 'Cursor-paginated list of journal entries. Filters: fiscal_period_id, status, date_from, date_to. Excludes status=cancelled by default; pass status=cancelled to inspect storno-cancelled drafts.', + 'Cursor-paginated list of journal entries ordered by created_at DESC, id ASC (newest-booked first; the `entry_date` column is the verifikationsdatum and is filterable via ?date_from / ?date_to but is not the sort key). Filters: fiscal_period_id, status, date_from, date_to. Excludes status=cancelled by default; pass status=cancelled to inspect storno-cancelled drafts.', useWhen: 'You need to walk the verifikationsserie for a period (audit, SIE export, gap detection) or list recent activity for a UI.', doNotUseFor: @@ -87,6 +87,8 @@ registerEndpoint({ pitfalls: [ 'Cancelled drafts are hidden by default. They are NOT a löpnummer gap (no voucher_number is allocated for drafts); the filter is for noise reduction.', 'voucher_number=0 indicates a draft that has not been committed. Posted entries always have voucher_number > 0.', + 'Ordering is by created_at (when the verifikat was booked), not entry_date. A backdated verifikat appears where it was booked: filter on ?date_from / ?date_to when you need entry_date ranges, and walk the whole cursor chain when you need a full period.', + 'Cursor pagination: pass ?cursor= from the previous response. A stale or tampered cursor is ignored and the first page is returned again.', ], example: { response: { @@ -141,12 +143,21 @@ export const GET = withApiV1<{ params: Promise<{ companyId: string }> }>( } const filters = fr.data + // Sort by (created_at DESC, id ASC). created_at is the stable cursor + // anchor: it's a real timestamp (passes ISO-8601 validation in + // decodeDefaultCursor), NOT NULL, and total-orderable once id breaks + // ties. Sorting by `entry_date` directly broke the cursor: a Postgres + // `date` serializes as YYYY-MM-DD, the decoder rejected it, the keyset + // predicate was never applied, and an integrator syncing verifikat + // looped on the newest page forever. entry_date is still on every row + // and ?date_from / ?date_to filter on it. Same anchor as the + // transactions list. let query = ctx.supabase .from('journal_entries') .select(JE_COLUMNS) .eq('company_id', ctx.companyId!) - .order('entry_date', { ascending: false }) - .order('id', { ascending: false }) + .order('created_at', { ascending: false }) + .order('id', { ascending: true }) .limit(limit + 1) if (filters.fiscal_period_id) query = query.eq('fiscal_period_id', filters.fiscal_period_id) @@ -159,7 +170,11 @@ export const GET = withApiV1<{ params: Promise<{ companyId: string }> }>( if (filters.date_to) query = query.lte('entry_date', filters.date_to) if (decoded) { - query = query.or(`entry_date.lt.${decoded.ts},and(entry_date.eq.${decoded.ts},id.lt.${decoded.id})`) + // Keyset on (created_at DESC, id ASC): created_at moves backward, + // id breaks ties within the same timestamp. + query = query.or( + `created_at.lt.${decoded.ts},and(created_at.eq.${decoded.ts},id.gt.${decoded.id})`, + ) } const { data, error } = await query @@ -182,7 +197,7 @@ export const GET = withApiV1<{ params: Promise<{ companyId: string }> }>( const hasMore = rows.length > limit const last = trimmed[trimmed.length - 1] const nextCursor = hasMore && last - ? encodeDefaultCursor({ id: last.id, created_at: last.entry_date }) + ? encodeDefaultCursor({ id: last.id, created_at: last.created_at }) : null return paginated( diff --git a/app/api/v1/companies/[companyId]/supplier-invoices/__tests__/route.test.ts b/app/api/v1/companies/[companyId]/supplier-invoices/__tests__/route.test.ts index 0bc796f7..c2abed4c 100644 --- a/app/api/v1/companies/[companyId]/supplier-invoices/__tests__/route.test.ts +++ b/app/api/v1/companies/[companyId]/supplier-invoices/__tests__/route.test.ts @@ -44,6 +44,15 @@ vi.mock('@/lib/bookkeeping/supplier-invoice-entries', () => ({ createSupplierCreditNoteEntry: (...args: unknown[]) => mockedCredit(...args), })) +// Riksbanken feeds the new server-side rate lookup on the create path. +// Spread the real module so unrelated exports stay intact. +const mockFetchExchangeRate = vi.fn() +vi.mock('@/lib/currency/riksbanken', async () => { + const actual = + await vi.importActual('@/lib/currency/riksbanken') + return { ...actual, fetchExchangeRate: (...args: unknown[]) => mockFetchExchangeRate(...args) } +}) + // reverseEntry is dynamically imported in the route file for orphan storno: // stub it so the import resolves quickly without exercising the real engine. vi.mock('@/lib/bookkeeping/engine', async () => { @@ -72,7 +81,15 @@ interface TableResp { count?: number | null } -function makeFlexibleSupabase(byTable: Record) { +/** Payload handed to `.insert()`, recorded per table so writes can be asserted. */ +type InsertRecord = { table: string; payload: Record } + +function makeFlexibleSupabase( + byTable: Record, + // Opt-in sink for insert payloads: the Proxy chain is otherwise write-only, + // and the route echoes back the fixture row rather than what it wrote. + insertSink?: InsertRecord[], +) { // Per-table queue: TableResp[] consumes one entry per await, then sticks // on the last entry. Plain TableResp is treated as a constant. const queues = new Map() @@ -89,7 +106,18 @@ function makeFlexibleSupabase(byTable: Record) resolve(next) } } - return (..._args: unknown[]) => buildChain(table) + return (...args: unknown[]) => { + if ( + insertSink && + prop === 'insert' && + args[0] && + typeof args[0] === 'object' && + !Array.isArray(args[0]) + ) { + insertSink.push({ table, payload: args[0] as Record }) + } + return buildChain(table) + } }, } return new Proxy({}, handler) @@ -774,6 +802,213 @@ describe('POST /api/v1/companies/:companyId/supplier-invoices', () => { }) }) +describe('POST /api/v1/companies/:companyId/supplier-invoices: exchange rate + SEK amounts', () => { + const captured: InsertRecord[] = [] + + const validBody = { + supplier_id: SUPPLIER_ID, + supplier_invoice_number: '2026-FX', + invoice_date: '2026-05-10', + due_date: '2026-06-09', + items: [ + { description: 'Cloud hosting', amount: 1000, account_number: '5410', vat_rate: 0.25 }, + ], + } + + function installSupabase() { + mockServiceClient.mockReturnValue( + makeFlexibleSupabase( + { + company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null }, + suppliers: { data: SAMPLE_SUPPLIER, error: null }, + company_settings: { data: { accounting_method: 'cash' }, error: null }, + fiscal_periods: { data: { id: 'fp-1', is_closed: false, locked_at: null }, error: null }, + supplier_invoices: { data: SAMPLE_SI, error: null }, + supplier_invoice_items: { data: null, error: null }, + idempotency_keys: { data: null, error: null }, + }, + captured, + ), + ) + } + + const siInsert = () => captured.find((c) => c.table === 'supplier_invoices')?.payload + + function post(body: Record) { + return createSI( + makeRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/supplier-invoices`, { + method: 'POST', + body: JSON.stringify(body), + }), + companyParams(COMPANY_ID), + ) + } + + beforeEach(() => { + captured.length = 0 + mockFetchExchangeRate.mockReset() + installSupabase() + }) + + it('returns 401 UNAUTHORIZED when the API key is rejected', async () => { + mockValidate.mockResolvedValue({ error: 'invalid api key', status: 401 }) + const res = await post(validBody) + expect(res.status).toBe(401) + const body = await res.json() + expect(body.error.code).toBe('UNAUTHORIZED') + expect(mockFetchExchangeRate).not.toHaveBeenCalled() + }) + + it('returns 400 VALIDATION_ERROR when the body is malformed', async () => { + const res = await post({ ...validBody, invoice_date: '10/05/2026' }) + expect(res.status).toBe(400) + const body = await res.json() + expect(body.error.code).toBe('VALIDATION_ERROR') + }) + + it('returns 404 SUPPLIER_NOT_FOUND before any rate lookup', async () => { + mockServiceClient.mockReturnValue( + makeFlexibleSupabase( + { + company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null }, + suppliers: { data: null, error: null }, + }, + captured, + ), + ) + const res = await post({ ...validBody, currency: 'EUR' }) + expect(res.status).toBe(404) + const body = await res.json() + expect(body.error.code).toBe('SUPPLIER_NOT_FOUND') + expect(mockFetchExchangeRate).not.toHaveBeenCalled() + }) + + it('populates total_sek for a SEK invoice without touching Riksbanken', async () => { + const res = await post(validBody) + expect(res.status).toBe(201) + + const payload = siInsert() + expect(payload).toBeDefined() + expect(payload!.subtotal_sek).toBe(1000) + expect(payload!.vat_amount_sek).toBe(250) + expect(payload!.total_sek).toBe(1250) + expect(payload!.total_sek).toBe(payload!.total) + expect(payload!.exchange_rate).toBeNull() + expect(payload!.exchange_rate_date).toBeNull() + expect(mockFetchExchangeRate).not.toHaveBeenCalled() + }) + + it('honours a caller-supplied rate on a foreign invoice', async () => { + const res = await post({ + ...validBody, + currency: 'USD', + exchange_rate: 10.5, + // SAMPLE_SUPPLIER is a Swedish business, so reverse_charge stays off and + // the 25 % line VAT is legal here. + }) + expect(res.status).toBe(201) + + const payload = siInsert() + expect(payload!.currency).toBe('USD') + expect(payload!.exchange_rate).toBe(10.5) + expect(payload!.subtotal_sek).toBe(10500) + expect(payload!.vat_amount_sek).toBe(2625) + expect(payload!.total_sek).toBe(13125) + expect(mockFetchExchangeRate).not.toHaveBeenCalled() + }) + + it('fetches the invoice-date rate when the agent omits exchange_rate', async () => { + mockFetchExchangeRate.mockResolvedValue({ currency: 'EUR', rate: 11.4, date: '2026-05-08' }) + + const res = await post({ ...validBody, currency: 'EUR' }) + expect(res.status).toBe(201) + + expect(mockFetchExchangeRate).toHaveBeenCalledTimes(1) + const [currencyArg, dateArg, clientArg] = mockFetchExchangeRate.mock.calls[0] + expect(currencyArg).toBe('EUR') + expect((dateArg as Date).toISOString().slice(0, 10)).toBe('2026-05-10') + // Passed through so the shared exchange_rates cache is read and written. + expect(clientArg).toBeDefined() + + const payload = siInsert() + expect(payload!.exchange_rate).toBe(11.4) + expect(payload!.exchange_rate_date).toBe('2026-05-08') + expect(payload!.total_sek).toBe(14250) + }) + + it('refuses the create with 400 SI_FX_RATE_MISSING when no rate can be resolved', async () => { + mockFetchExchangeRate.mockResolvedValue(null) + + const res = await post({ ...validBody, currency: 'EUR' }) + expect(res.status).toBe(400) + const body = await res.json() + expect(body.error.code).toBe('SI_FX_RATE_MISSING') + expect(body.error.details.currency).toBe('EUR') + // No row, no ankomstnummer, no verifikat: a NULL-rate row would only + // relocate the failure into the booking path. + expect(siInsert()).toBeUndefined() + expect(mockedReg).not.toHaveBeenCalled() + }) + + it('surfaces the same refusal in a dry run', async () => { + mockFetchExchangeRate.mockResolvedValue(null) + + const res = await createSI( + makeRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/supplier-invoices?dry_run=true`, { + method: 'POST', + body: JSON.stringify({ ...validBody, currency: 'EUR' }), + }), + companyParams(COMPANY_ID), + ) + expect(res.status).toBe(400) + const body = await res.json() + expect(body.error.code).toBe('SI_FX_RATE_MISSING') + }) + + // v1 shares CreateSupplierInvoiceSchema with POST /api/supplier-invoices and + // the inbox convert route, so the constraint mirror is enforced identically + // on all three. These pin that agreement: an agent posting a pasted total + // where a rate belongs gets a structured 400, never a 23514-driven 500. + it('rejects an out-of-range exchange rate with 400 VALIDATION_ERROR, not a 500', async () => { + const res = await post({ ...validBody, currency: 'EUR', exchange_rate: 250000 }) + expect(res.status).toBe(400) + const body = await res.json() + expect(body.error.code).toBe('VALIDATION_ERROR') + const issue = body.error.details.issues.find( + (i: { field: string }) => i.field === 'exchange_rate', + ) + expect(issue?.message).toContain('100 000') + expect(siInsert()).toBeUndefined() + expect(mockedReg).not.toHaveBeenCalled() + expect(mockFetchExchangeRate).not.toHaveBeenCalled() + }) + + it('rejects exactly 100000: the CHECK bound is exclusive', async () => { + const res = await post({ ...validBody, currency: 'EUR', exchange_rate: 100000 }) + expect(res.status).toBe(400) + const body = await res.json() + expect(body.error.code).toBe('VALIDATION_ERROR') + expect(siInsert()).toBeUndefined() + }) + + it('accepts 99999.99, the largest rate the CHECK allows', async () => { + const res = await post({ ...validBody, currency: 'EUR', exchange_rate: 99999.99 }) + expect(res.status).toBe(201) + expect(siInsert()!.exchange_rate).toBe(99999.99) + }) + + it('rejects a zero or negative rate the same way', async () => { + for (const rate of [0, -11.5]) { + captured.length = 0 + const res = await post({ ...validBody, currency: 'EUR', exchange_rate: rate }) + expect(res.status).toBe(400) + const body = await res.json() + expect(body.error.code).toBe('VALIDATION_ERROR') + expect(siInsert()).toBeUndefined() + } + }) +}) + describe('PATCH /api/v1/companies/:companyId/supplier-invoices/:id', () => { it('updates a registered SI', async () => { const updated = { ...SAMPLE_SI, payment_reference: 'OCR-9999' } diff --git a/app/api/v1/companies/[companyId]/supplier-invoices/route.ts b/app/api/v1/companies/[companyId]/supplier-invoices/route.ts index 38d89728..0ff04fa3 100644 --- a/app/api/v1/companies/[companyId]/supplier-invoices/route.ts +++ b/app/api/v1/companies/[companyId]/supplier-invoices/route.ts @@ -2,7 +2,7 @@ * /api/v1/companies/{companyId}/supplier-invoices: list + register endpoints. * * GET : list with filters (status, supplier_id, currency, invoice_date range). - * Cursor pagination on (invoice_date DESC, id DESC). + * Cursor pagination on (created_at DESC, id ASC). * POST : register a new supplier invoice. Idempotent (mandatory Idempotency-Key). * Dry-runnable. * @@ -34,6 +34,10 @@ import { withApiV1 } from '@/lib/api/v1/with-api-v1' import { v1ErrorResponse, v1ErrorResponseFromCode } from '@/lib/api/v1/errors' import { checkPeriodLock } from '@/lib/api/v1/check-period-lock' import { CreateSupplierInvoiceSchema } from '@/lib/api/schemas' +import { + resolveSupplierInvoiceExchangeRate, + supplierInvoiceSekAmounts, +} from '@/lib/currency/supplier-invoice-rate' import { createSupplierInvoiceRegistrationEntry } from '@/lib/bookkeeping/supplier-invoice-entries' import { reverseEntry } from '@/lib/bookkeeping/engine' import { isBookkeepingError } from '@/lib/bookkeeping/errors' @@ -85,7 +89,7 @@ registerEndpoint({ path: '/api/v1/companies/:companyId/supplier-invoices', summary: 'List supplier invoices for a company.', description: - 'Returns supplier invoices in most-recent-first order. Filters: status, supplier_id, currency, date_from / date_to (filter by invoice_date).', + 'Cursor-paginated supplier-invoice list ordered by created_at DESC, id ASC (newest-registered first; the `invoice_date` column is the seller\'s invoice date and is filterable via ?date_from / ?date_to but is not the sort key). Filters: status, supplier_id, currency, date_from / date_to (filter by invoice_date).', useWhen: 'You need to enumerate registered supplier invoices for an AP dashboard, a payment run, or a leverantörsreskontra reconciliation.', doNotUseFor: @@ -94,6 +98,8 @@ registerEndpoint({ 'Credit notes (is_credit_note=true) appear in the same list as the originals; filter by status=credited or check the flag to separate.', 'remaining_amount is the unpaid portion; a partially_paid SI has remaining_amount > 0.', 'arrival_number is internal book-keeping, not the seller\'s invoice number: use supplier_invoice_number for matching to received documents.', + 'Ordering is by created_at (registration time), not invoice_date. A late-registered invoice appears where it was registered: filter on ?date_from / ?date_to when you care about the seller\'s invoice date.', + 'Cursor pagination: pass ?cursor= from the previous response. A stale or tampered cursor is ignored and the first page is returned again.', ], example: { response: { @@ -163,12 +169,21 @@ export const GET = withApiV1<{ params: Promise<{ companyId: string }> }>( } const filters = filtersResult.data + // Sort by (created_at DESC, id ASC). created_at is the stable cursor + // anchor: it's a real timestamp (passes ISO-8601 validation in + // decodeDefaultCursor), NOT NULL, and total-orderable once id breaks + // ties. Sorting by `invoice_date` directly broke the cursor: a Postgres + // `date` serializes as YYYY-MM-DD, the decoder rejected it, the keyset + // predicate was never applied, and every "next page" silently returned + // page 1 forever while still advertising a fresh next_cursor. + // invoice_date is still on every row and ?date_from / ?date_to filter + // on it. Same anchor as the transactions list. let query = ctx.supabase .from('supplier_invoices') .select(`${SI_SUMMARY_COLUMNS}, supplier:suppliers(${SUPPLIER_NAME_ONLY_COLUMNS})`) .eq('company_id', ctx.companyId!) - .order('invoice_date', { ascending: false }) - .order('id', { ascending: false }) + .order('created_at', { ascending: false }) + .order('id', { ascending: true }) .limit(limit + 1) if (filters.status) query = query.eq('status', filters.status) @@ -178,9 +193,10 @@ export const GET = withApiV1<{ params: Promise<{ companyId: string }> }>( if (filters.date_to) query = query.lte('invoice_date', filters.date_to) if (decoded) { - // Keyset on (invoice_date DESC, id DESC). + // Keyset on (created_at DESC, id ASC): created_at moves backward, + // id breaks ties within the same timestamp. query = query.or( - `invoice_date.lt.${decoded.ts},and(invoice_date.eq.${decoded.ts},id.lt.${decoded.id})`, + `created_at.lt.${decoded.ts},and(created_at.eq.${decoded.ts},id.gt.${decoded.id})`, ) } @@ -244,7 +260,7 @@ export const GET = withApiV1<{ params: Promise<{ companyId: string }> }>( const last = trimmed[trimmed.length - 1] const nextCursor = hasMore && last - ? encodeDefaultCursor({ id: last.id, created_at: last.invoice_date }) + ? encodeDefaultCursor({ id: last.id, created_at: last.created_at }) : null return paginated(supplier_invoices, { @@ -302,6 +318,8 @@ registerEndpoint({ 'Under faktureringsmetoden the registration JE is posted atomically with the SI row. JE failure aborts the whole call and no SI row is left behind (strict-mode).', 'supplier_id must reference an existing, non-archived supplier in the same company: 404 SUPPLIER_NOT_FOUND otherwise.', 'Duplicate (supplier_id, supplier_invoice_number) returns 409 SI_CREATE_DUPLICATE_INVOICE_NUMBER. Use the credit flow on the original instead of re-registering with a tweaked number.', + 'Foreign currency: omit exchange_rate and the server fetches Riksbanken\'s rate for invoice_date (ML 8 kap 21-23 §). If no rate can be resolved the create is refused with 400 SI_FX_RATE_MISSING rather than stored unconverted: pass exchange_rate explicitly to proceed. A SEK invoice needs no rate and gets total_sek = total.', + 'exchange_rate is SEK per 1 unit of the invoice currency and must satisfy 0 < rate < 100000, the same bounds the supplier_invoices CHECK enforces. Out-of-range values return 400 VALIDATION_ERROR; passing an invoice total where a rate belongs is the usual cause.', 'Project/cost-center tagging: pass default_dimensions ({"6":"P001"} = project, {"1":"KS01"} = kostnadsställe) for the whole invoice and/or items[].dimensions per line (per-line wins per key). The registration JE lines are tagged accordingly. When the company has the dimension registry enabled, unknown or archived codes are rejected with 400 DIMENSION_VALIDATION_FAILED — list valid codes via GET /dimensions.', ], example: { @@ -488,10 +506,35 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string }> }>( }) } const { items, subtotal, vatAmount, total } = totalsResult - const exchangeRate = body.exchange_rate ?? null - const subtotalSek = exchangeRate ? Math.round(subtotal * exchangeRate * 100) / 100 : null - const vatAmountSek = exchangeRate ? Math.round(vatAmount * exchangeRate * 100) / 100 : null - const totalSek = exchangeRate ? Math.round(total * exchangeRate * 100) / 100 : null + + // Currency policy is shared with POST /api/supplier-invoices and the inbox + // convert route (lib/currency/supplier-invoice-rate.ts): a non-SEK invoice + // that arrives without a rate gets one fetched from Riksbanken for the + // invoice date, and if none can be had the create is refused rather than + // persisted with exchange_rate = NULL. Agents are the main caller here and + // the ones most likely to omit the field entirely; a NULL-rate row would + // only fail later, inside the booking path, with SI_FX_RATE_MISSING. + // Resolved before the arrival-number allocation and before the dry-run + // branch, so a dry run surfaces the same refusal a live commit would. + const fx = await resolveSupplierInvoiceExchangeRate(ctx.supabase, { + currency: body.currency, + invoiceDate: body.invoice_date, + suppliedRate: body.exchange_rate, + }) + if (!fx.ok) { + return v1ErrorResponseFromCode('SI_FX_RATE_MISSING', ctx.log, { + requestId: ctx.requestId, + details: { currency: fx.currency, invoice_date: fx.invoiceDate }, + }) + } + const exchangeRate = fx.rate.exchangeRate + const exchangeRateDate = fx.rate.exchangeRateDate + // SEK resolves to rate 1, so total_sek === total instead of NULL. + const { + subtotal_sek: subtotalSek, + vat_amount_sek: vatAmountSek, + total_sek: totalSek, + } = supplierInvoiceSekAmounts(fx.rate, { subtotal, vatAmount, total }) // Derive a sensible default for vat_treatment + reverse_charge from the // supplier_type. EU/non-EU suppliers default to reverse-charge unless the @@ -547,8 +590,9 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string }> }>( due_date: body.due_date, delivery_date: body.delivery_date ?? null, status: 'registered', - currency: body.currency ?? 'SEK', + currency: fx.rate.currency, exchange_rate: exchangeRate, + exchange_rate_date: exchangeRateDate, vat_treatment: vatTreatment, reverse_charge: reverseCharge, subtotal, @@ -596,8 +640,9 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string }> }>( due_date: body.due_date, delivery_date: body.delivery_date ?? null, status: 'registered', - currency: body.currency ?? 'SEK', + currency: fx.rate.currency, exchange_rate: exchangeRate, + exchange_rate_date: exchangeRateDate, vat_treatment: vatTreatment, reverse_charge: reverseCharge, payment_reference: body.payment_reference ?? null, diff --git a/app/api/v1/companies/[companyId]/transactions/[id]/__tests__/route.test.ts b/app/api/v1/companies/[companyId]/transactions/[id]/__tests__/route.test.ts index 7e45c58f..5630e201 100644 --- a/app/api/v1/companies/[companyId]/transactions/[id]/__tests__/route.test.ts +++ b/app/api/v1/companies/[companyId]/transactions/[id]/__tests__/route.test.ts @@ -28,7 +28,7 @@ vi.mock('@supabase/supabase-js', async () => { }) // Engine stubs: happy-path returns reusable across cases. -const { createTxJE, reverseEntryMock, createInvPmtJE, createInvCashJE, createSupplierInvPmtJE, createSupplierInvCashJE, findMissingAccountsMock } = vi.hoisted(() => ({ +const { createTxJE, reverseEntryMock, createInvPmtJE, createInvCashJE, createSupplierInvPmtJE, createSupplierInvCashJE, findMissingAccountsMock, createJEMock, findFiscalPeriodMock } = vi.hoisted(() => ({ createTxJE: vi.fn().mockResolvedValue({ id: 'je-fresh' }), reverseEntryMock: vi.fn().mockResolvedValue(undefined), createInvPmtJE: vi.fn().mockResolvedValue({ id: 'je-invpmt' }), @@ -39,6 +39,15 @@ const { createTxJE, reverseEntryMock, createInvPmtJE, createInvCashJE, createSup // template-references-inactive-account bug or a race where deactivation // happened between our validation and the engine's resolveAccountIds. findMissingAccountsMock: vi.fn().mockResolvedValue([]), + // match-invoice's accrual path builds its lines with + // buildInvoicePaymentClearingLines (the same helper the dashboard route and + // its preview use) and posts them through the engine directly, so the engine + // mock has to carry createJournalEntry + findFiscalPeriod. Stubbing them here + // keeps the assertions on the ORCHESTRATION (which account got the bank leg, + // what source_type, what period) while the line math stays covered by the + // helper's own unit tests. + createJEMock: vi.fn().mockResolvedValue({ id: 'je-clearing' }), + findFiscalPeriodMock: vi.fn().mockResolvedValue('fp-2026-05'), })) vi.mock('@/lib/bookkeeping/transaction-entries', () => ({ @@ -46,6 +55,8 @@ vi.mock('@/lib/bookkeeping/transaction-entries', () => ({ })) vi.mock('@/lib/bookkeeping/engine', () => ({ reverseEntry: reverseEntryMock, + createJournalEntry: createJEMock, + findFiscalPeriod: findFiscalPeriodMock, })) vi.mock('@/lib/bookkeeping/invoice-entries', () => ({ createInvoicePaymentJournalEntry: createInvPmtJE, @@ -533,7 +544,20 @@ describe('POST :id/match-invoice', () => { expect(res.status).toBe(200) const body = await res.json() expect(body.data.invoice_status).toBe('paid') - expect(body.data.journal_entry_id).toBe('je-invpmt') + expect(body.data.journal_entry_id).toBe('je-clearing') + // Pure-SEK accrual match: Dr 1930 / Cr 1510 for the full 12 500, no FX or + // öresavrundning leg. Asserted here because v1 now builds these lines + // itself (shared helper) instead of delegating to + // createInvoicePaymentJournalEntry, and the ledger result must stay + // identical to the dashboard route's. + expect(createJEMock).toHaveBeenCalledTimes(1) + const je = createJEMock.mock.calls[0][3] + expect(je.source_type).toBe('invoice_paid') + expect(je.fiscal_period_id).toBe('fp-2026-05') + expect(je.lines).toEqual([ + expect.objectContaining({ account_number: '1930', debit_amount: 12500, credit_amount: 0 }), + expect.objectContaining({ account_number: '1510', debit_amount: 0, credit_amount: 12500 }), + ]) }) it('rejects negative transaction with MATCH_INVOICE_NOT_INCOME', async () => { @@ -609,7 +633,7 @@ describe('POST :id/match-invoice', () => { expect(res.status).toBe(400) expect((await res.json()).error.code).toBe('MATCH_INVOICE_CREDIT_NOTE') - expect(createInvPmtJE).not.toHaveBeenCalled() + expect(createJEMock).not.toHaveBeenCalled() expect(createInvCashJE).not.toHaveBeenCalled() }) @@ -669,17 +693,14 @@ describe('POST :id/match-invoice', () => { expect(res.status).toBe(200) const body = await res.json() expect(body.data.invoice_status).toBe('paid') - expect(createInvPmtJE).toHaveBeenCalledWith( - expect.anything(), - COMPANY_ID, - 'user-1', - expect.objectContaining({ id: INV_ID }), - '2026-05-12', - undefined, - 'Acme', - 12500, - '1940', - ) + // The bank leg carries the account resolved from the transaction's own + // cash_account_id (1940), not the hardcoded primary 1930. + expect(createJEMock).toHaveBeenCalledTimes(1) + const je = createJEMock.mock.calls[0][3] + expect(je.lines).toEqual([ + expect.objectContaining({ account_number: '1940', debit_amount: 12500, credit_amount: 0 }), + expect.objectContaining({ account_number: '1510', debit_amount: 0, credit_amount: 12500 }), + ]) }) it('aborts with 500 BOOKKEEPING_DATABASE_ERROR (mutates nothing) when the cash_accounts lookup errors', async () => { @@ -728,7 +749,7 @@ describe('POST :id/match-invoice', () => { expect(res.status).toBe(500) const body = await res.json() expect(body.error.code).toBe('BOOKKEEPING_DATABASE_ERROR') - expect(createInvPmtJE).not.toHaveBeenCalled() + expect(createJEMock).not.toHaveBeenCalled() expect(createInvCashJE).not.toHaveBeenCalled() }) @@ -785,7 +806,7 @@ describe('POST :id/match-invoice', () => { expect(body.error.details.account_numbers).toEqual(['1940']) // Engine and invoice/transaction updates must NOT run: the match stays // retryable rather than posting a payment against a dead account. - expect(createInvPmtJE).not.toHaveBeenCalled() + expect(createJEMock).not.toHaveBeenCalled() expect(createInvCashJE).not.toHaveBeenCalled() }) }) diff --git a/app/api/v1/companies/[companyId]/transactions/[id]/categorize/__tests__/route.test.ts b/app/api/v1/companies/[companyId]/transactions/[id]/categorize/__tests__/route.test.ts new file mode 100644 index 00000000..075c5d40 --- /dev/null +++ b/app/api/v1/companies/[companyId]/transactions/[id]/categorize/__tests__/route.test.ts @@ -0,0 +1,205 @@ +/** + * Tests for POST /api/v1/companies/{companyId}/transactions/{id}/categorize. + * + * Focus: the CAS-race compensation. When the transaction update matches no + * row, the already-posted verifikation is orphaned. The route stornos it; if + * the storno fails the voucher number stays stranded, and BFNAR 2013:2 + * requires that break in the verifikationsnummerserie to be documented in + * voucher_gap_explanations. This asserts the insert payload column-for-column: + * the table has user_id / gap_start / gap_end (all NOT NULL) and no + * gap_number / created_by. + */ +import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' + +beforeAll(() => { + if (process.env.NODE_ENV !== 'test') throw new Error('NODE_ENV=test required') + process.env.NEXT_PUBLIC_SUPABASE_URL ||= 'http://localhost:54321' + process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY ||= 'test-anon-key' +}) + +vi.mock('@/lib/auth/api-keys', async () => { + const actual = await vi.importActual('@/lib/auth/api-keys') + return { ...actual, validateApiKey: vi.fn(), createServiceClientNoCookies: vi.fn() } +}) +vi.mock('@supabase/supabase-js', async () => { + const actual = await vi.importActual('@supabase/supabase-js') + return { ...actual, createClient: vi.fn().mockReturnValue({}) } +}) + +const { createTxJE, findMissingAccountsMock, reverseEntryMock } = vi.hoisted(() => ({ + createTxJE: vi.fn().mockResolvedValue({ id: 'je-fresh' }), + findMissingAccountsMock: vi.fn().mockResolvedValue([]), + reverseEntryMock: vi.fn().mockResolvedValue(undefined), +})) + +vi.mock('@/lib/bookkeeping/transaction-entries', () => ({ + createTransactionJournalEntry: createTxJE, +})) +vi.mock('@/lib/bookkeeping/engine', () => ({ + reverseEntry: reverseEntryMock, +})) +vi.mock('@/lib/bookkeeping/account-validation', async () => { + const actual = await vi.importActual( + '@/lib/bookkeeping/account-validation', + ) + return { ...actual, findUnresolvableAccounts: findMissingAccountsMock } +}) +// Best-effort learning writes: not part of this surface. +vi.mock('@/lib/bookkeeping/counterparty-templates', async () => { + const actual = await vi.importActual< + typeof import('@/lib/bookkeeping/counterparty-templates') + >('@/lib/bookkeeping/counterparty-templates') + return { ...actual, upsertCounterpartyTemplate: vi.fn().mockResolvedValue(undefined) } +}) +vi.mock('@/lib/bookkeeping/mapping-engine', async () => { + const actual = await vi.importActual( + '@/lib/bookkeeping/mapping-engine', + ) + return { ...actual, saveUserMappingRule: vi.fn().mockResolvedValue(undefined) } +}) + +import { validateApiKey, createServiceClientNoCookies } from '@/lib/auth/api-keys' +import { POST } from '../route' + +const mockValidate = validateApiKey as ReturnType +const mockServiceClient = createServiceClientNoCookies as ReturnType + +type MockResult = { data?: unknown; error?: unknown } +function makeFlexibleSupabase(byTable: Record) { + const queues = new Map() + for (const [t, val] of Object.entries(byTable)) { + queues.set(t, Array.isArray(val) ? [...val] : [val]) + } + // Insert payloads are recorded verbatim: the proxy would happily accept a + // phantom column, so the assertion has to inspect the object itself. + const inserts: Record = {} + const buildChain = (table: string): unknown => { + const handler: ProxyHandler = { + get(_target, prop) { + if (prop === 'then') { + return (resolve: (v: unknown) => void) => { + const q = queues.get(table) + const next = q && q.length > 1 ? q.shift()! : (q?.[0] ?? { data: null, error: null }) + resolve(next) + } + } + return (...args: unknown[]) => { + if (prop === 'insert') (inserts[table] ??= []).push(args[0]) + return buildChain(table) + } + }, + } + return new Proxy({}, handler) + } + return { supabase: { from: vi.fn((table: string) => buildChain(table)) }, inserts } +} + +const COMPANY_ID = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa' +const TX_ID = 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb' + +function makeRequest(body: unknown): Request { + return new Request( + `https://x.test/api/v1/companies/${COMPANY_ID}/transactions/${TX_ID}/categorize`, + { + method: 'POST', + headers: { + Authorization: 'Bearer test-fixture-not-a-real-key', + 'Content-Type': 'application/json', + 'Idempotency-Key': 'idem1234-aaaa-4abc-8def-1234567890ab', + }, + body: JSON.stringify(body), + }, + ) +} +function routeParams() { + return { params: Promise.resolve({ companyId: COMPANY_ID, id: TX_ID }) } +} + +function casRaceSupabase() { + return makeFlexibleSupabase({ + company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null }, + transactions: [ + // 1: the fetch. 2: the CAS update, matching no row because a concurrent + // request stamped journal_entry_id first. + { + data: { + id: TX_ID, + company_id: COMPANY_ID, + date: '2026-05-12', + amount: -349.5, + currency: 'SEK', + merchant_name: 'ICA', + cash_account_id: null, + journal_entry_id: null, + }, + error: null, + }, + { data: [], error: null }, + ], + company_settings: { data: { entity_type: 'enskild_firma' }, error: null }, + fiscal_periods: { data: { id: 'period-1', is_closed: false, locked_at: null }, error: null }, + journal_entries: { + data: { fiscal_period_id: 'period-1', voucher_series: 'B', voucher_number: 42 }, + error: null, + }, + voucher_gap_explanations: { data: null, error: null }, + }) +} + +beforeEach(() => { + vi.clearAllMocks() + findMissingAccountsMock.mockResolvedValue([]) + reverseEntryMock.mockResolvedValue(undefined) + createTxJE.mockResolvedValue({ id: 'je-fresh' }) + mockValidate.mockResolvedValue({ + userId: 'user-1', + companyId: COMPANY_ID, + apiKeyId: 'ak_1', + scopes: ['transactions:write'], + mode: 'live', + }) +}) + +describe('POST /api/v1/.../transactions/{id}/categorize CAS race', () => { + it('documents the stranded voucher with the real voucher_gap_explanations columns when the storno fails', async () => { + const { supabase, inserts } = casRaceSupabase() + mockServiceClient.mockReturnValue(supabase) + reverseEntryMock.mockRejectedValueOnce(new Error('period locked')) + + const res = await POST( + makeRequest({ is_business: true, category: 'expense_office' }), + routeParams(), + ) + + const body = await res.json() + expect(body.error.code).toBe('TX_CATEGORIZE_RACE') + + const gaps = inserts['voucher_gap_explanations'] as Record[] + expect(gaps).toHaveLength(1) + // Exhaustive: no gap_number, no created_by, and every NOT NULL column set. + expect(gaps[0]).toEqual({ + company_id: COMPANY_ID, + user_id: 'user-1', + fiscal_period_id: 'period-1', + voucher_series: 'B', + gap_start: 42, + gap_end: 42, + explanation: 'CAS-race orphan; automatisk storno misslyckades. Manuell reconciliation krävs.', + }) + }) + + it('writes no gap explanation when the storno succeeds (the series stays unbroken)', async () => { + const { supabase, inserts } = casRaceSupabase() + mockServiceClient.mockReturnValue(supabase) + + const res = await POST( + makeRequest({ is_business: true, category: 'expense_office' }), + routeParams(), + ) + + const body = await res.json() + expect(body.error.code).toBe('TX_CATEGORIZE_RACE') + expect(reverseEntryMock).toHaveBeenCalledTimes(1) + expect(inserts['voucher_gap_explanations']).toBeUndefined() + }) +}) diff --git a/app/api/v1/companies/[companyId]/transactions/[id]/categorize/route.ts b/app/api/v1/companies/[companyId]/transactions/[id]/categorize/route.ts index d86037a5..d12bae7a 100644 --- a/app/api/v1/companies/[companyId]/transactions/[id]/categorize/route.ts +++ b/app/api/v1/companies/[companyId]/transactions/[id]/categorize/route.ts @@ -32,6 +32,7 @@ import { buildMappingResultFromCounterpartyTemplate, } from '@/lib/bookkeeping/counterparty-templates' import { createTransactionJournalEntry } from '@/lib/bookkeeping/transaction-entries' +import { recordVoucherGapExplanation } from '@/lib/bookkeeping/cancel-orphaned-entry' import { reverseEntry } from '@/lib/bookkeeping/engine' import { saveUserMappingRule, applySettlementAccount } from '@/lib/bookkeeping/mapping-engine' import { resolveSettlementAccount } from '@/lib/bookkeeping/settlement-account' @@ -465,6 +466,7 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string .from('journal_entries') .select('fiscal_period_id, voucher_series, voucher_number') .eq('id', journalEntryId) + .eq('company_id', ctx.companyId!) .single() if (orphan && orphan.voucher_series) { // Skip the gap row when the engine didn't tag a series on the @@ -472,19 +474,23 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string // index the gap explanation under the wrong key, hiding it from // series-specific audit queries (BFL 5 kap 6 §). A missing series // is logged above already; a human will reconcile via that trail. - await ctx.supabase.from('voucher_gap_explanations').insert({ - company_id: ctx.companyId!, - fiscal_period_id: orphan.fiscal_period_id, - voucher_series: orphan.voucher_series, - gap_number: orphan.voucher_number, + // + // The insert itself lives in the shared helper: it owns the real + // voucher_gap_explanations column set (gap_start/gap_end/user_id) + // and logs a failed insert loudly instead of swallowing it. + await recordVoucherGapExplanation(ctx.supabase, { + companyId: ctx.companyId!, + userId: ctx.userId, + fiscalPeriodId: orphan.fiscal_period_id, + voucherSeries: orphan.voucher_series, + voucherNumber: orphan.voucher_number, explanation: 'CAS-race orphan; automatisk storno misslyckades. Manuell reconciliation krävs.', - created_by: ctx.userId, }) } } catch (gapErr) { txLog.error( - 'TX_CATEGORIZE_RACE: failed to log voucher_gap_explanations after storno failure', + 'TX_CATEGORIZE_RACE: failed to look up the orphan for its gap explanation', gapErr as Error, { orphanJournalEntryId: journalEntryId }, ) diff --git a/app/api/v1/companies/[companyId]/transactions/[id]/match-invoice/route.ts b/app/api/v1/companies/[companyId]/transactions/[id]/match-invoice/route.ts index 432aac10..5cafe5f7 100644 --- a/app/api/v1/companies/[companyId]/transactions/[id]/match-invoice/route.ts +++ b/app/api/v1/companies/[companyId]/transactions/[id]/match-invoice/route.ts @@ -5,10 +5,13 @@ * full flow: * 1. Storno any conflicting auto-categorization JE. * 2. Create the payment journal entry (resolved bank account debit / 1510 - * credit under accrual; cash-method path delegates to + * credit under accrual, built by the shared + * buildInvoicePaymentClearingLines helper; cash-method path delegates to * createInvoiceCashEntry). The debited account is resolved from this * transaction's own cash_account_id via resolveSettlementAccount, never * hardcoded to 1930 (mirrors the fix on the supplier-invoice side). + * Cross-currency settlement uses the same Riksbanken spot-rate path as + * the dashboard route so both doors post the same verifikat. * 3. Re-attach the invoice PDF to the new payment JE (BFL 7 kap underlag). * 4. Update invoice status (paid / partially_paid) with optimistic lock. * 5. Insert invoice_payments row; link transaction to invoice. @@ -25,12 +28,13 @@ import { registerEndpoint, dataEnvelope } from '@/lib/api/v1/registry' import { withApiV1 } from '@/lib/api/v1/with-api-v1' import { v1ErrorResponse, v1ErrorResponseFromCode } from '@/lib/api/v1/errors' import { MatchInvoiceSchema } from '@/lib/api/schemas' -import { - createInvoicePaymentJournalEntry, - createInvoiceCashEntry, -} from '@/lib/bookkeeping/invoice-entries' +import { createInvoiceCashEntry } from '@/lib/bookkeeping/invoice-entries' +import { buildInvoicePaymentClearingLines } from '@/lib/bookkeeping/invoice-payment-lines' +import { resolveSekAmount } from '@/lib/bookkeeping/currency-utils' +import { coerceDimensionsBag } from '@/lib/bookkeeping/dimension-resolver' import { resolveSettlementAccount } from '@/lib/bookkeeping/settlement-account' import { findUnresolvableAccounts } from '@/lib/bookkeeping/account-validation' +import { fetchExchangeRate } from '@/lib/currency/riksbanken' import { reverseEntry, createJournalEntry, findFiscalPeriod } from '@/lib/bookkeeping/engine' import { AccountsNotInChartError } from '@/lib/bookkeeping/errors' import { getErrorMessage } from '@/lib/errors/get-error-message' @@ -38,7 +42,7 @@ import { logMatchEvent } from '@/lib/invoices/match-log' import { planInvoicePayment } from '@/lib/invoices/apply-invoice-payment' import { detectDuplicatePaymentVoucher } from '@/lib/invoices/duplicate-payment-detection' import { eventBus } from '@/lib/events/bus' -import type { EntityType, Invoice, Transaction } from '@/types' +import type { Currency, EntityType, Invoice, Transaction } from '@/types' const MatchInvoiceResponse = z.object({ success: z.boolean(), @@ -196,6 +200,103 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string }) } + // Cross-currency settlement. Byte-for-byte the dashboard route's block + // (app/api/transactions/[id]/match-invoice/route.ts): same guard, same + // helpers, same order, so the same action through either door produces the + // same ledger. Before this, v1 had no FX path at all: it fed the raw + // transactions.amount straight into createInvoicePaymentJournalEntry, which + // re-read it as if it were already in the INVOICE's currency and multiplied + // by the invoice's booking rate, and then stored that SEK magnitude on + // invoice_payments under the invoice's foreign currency code. + // + // A bank row is stored in ITS OWN currency: transactions.amount is + // denominated in transactions.currency, and the SEK value lives either in + // amount_sek (pre-computed at ingest) or is derivable from exchange_rate. + // Every journal entry line is SEK, so a foreign row whose SEK value cannot + // be established must not be booked or allocated at all: substituting the + // raw foreign number would settle a 500 USD receipt as 500 SEK, a tenth of + // the real payment. Rows in exactly that shape exist: when the Riksbanken + // lookup fails at ingest the transaction is written with neither field + // (lib/transactions/ingest.ts). Refuse loudly, the same way the + // match_batch_allocate RPC refuses with BATCH_FX_RATE_MISSING. + const txIsForeign = !!transaction.currency && transaction.currency !== 'SEK' + if ( + txIsForeign && + transaction.amount_sek == null && + !(transaction.exchange_rate != null && transaction.exchange_rate > 0) + ) { + return v1ErrorResponseFromCode('MATCH_INVOICE_TX_FX_RATE_MISSING', txLog, { + requestId: ctx.requestId, + details: { + transaction_currency: transaction.currency, + transaction_date: transaction.date, + }, + }) + } + // Actual SEK that hit the bank, resolved through the same helper + // buildInvoicePaymentClearingLines uses for the bank leg (amount_sek first, + // then amount * exchange_rate). SEK rows return Math.abs(amount) unchanged. + const txAbsSek = + Math.round( + resolveSekAmount( + Math.abs(transaction.amount), + transaction.amount_sek != null ? Math.abs(transaction.amount_sek) : null, + transaction.currency, + transaction.exchange_rate, + ) * 100, + ) / 100 + + type FxConversion = + | { required: false } + | { + required: true + rate: number + rate_date: string + paidInInvoiceCurrency: number + // Provenance of the rate actually used (BFL 5 kap 6-7§; ML 8 kap + // 21-23§): 'manual' = caller-supplied, 'riksbanken' = spot rate on + // the payment date. + source: 'manual' | 'riksbanken' + } + + let fx: FxConversion = { required: false } + if (transaction.currency !== invoice.currency) { + const manualRate = + typeof parsed.data.manual_exchange_rate === 'number' && + parsed.data.manual_exchange_rate > 0 + ? parsed.data.manual_exchange_rate + : null + let rate = manualRate + let rateDate = transaction.date + if (rate == null) { + const rateInfo = await fetchExchangeRate( + invoice.currency as Currency, + new Date(transaction.date), + ) + if (rateInfo && rateInfo.rate > 0) { + rate = rateInfo.rate + rateDate = rateInfo.date + } + } + if (rate == null || rate <= 0) { + return v1ErrorResponseFromCode('MATCH_INVOICE_FX_RATE_UNAVAILABLE', txLog, { + requestId: ctx.requestId, + details: { + transaction_currency: transaction.currency, + invoice_currency: invoice.currency, + payment_date: transaction.date, + }, + }) + } + fx = { + required: true, + rate, + rate_date: rateDate, + paidInInvoiceCurrency: Math.round((txAbsSek / rate) * 10000) / 10000, + source: manualRate != null ? 'manual' : 'riksbanken', + } + } + // Hard-duplicate guard: status leak: the invoice still says // 'sent'/'overdue' but already has a payment voucher attached. Mirror // of the internal route's defensive check. @@ -231,6 +332,11 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string transactionId: txId, transactionDate: transaction.date, transactionAmount: transaction.amount, + // `amount` is in `currency`; the 19xx legs the detector compares it + // against are always SEK. Selected above via select('*'). + transactionCurrency: transaction.currency ?? null, + transactionAmountSek: transaction.amount_sek ?? null, + transactionExchangeRate: transaction.exchange_rate ?? null, }) if (!force) { if (candidate) { @@ -276,14 +382,25 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string }) } - const paidAmount = transaction.amount + // paidAmount is denominated in INVOICE currency: that is the unit of + // invoices.paid_amount / remaining_amount and of invoice_payments.amount. + // Same-currency → the raw tx amount; cross-currency → the spot-rate + // conversion computed above. Feeding a SEK figure in here for a foreign + // invoice corrupts the column units (and the stored payment row's + // currency label). + const paidAmount = fx.required ? fx.paidInInvoiceCurrency : transaction.amount // Overshoot guard + paid/remaining math: shared with the dashboard and // agent (commit) paths via planInvoicePayment. Without this, the public API // silently overpaid an invoice (recording paid_amount > total, over-crediting // AR). Runs BEFORE the storno + strict-mode JE creation, so a rejected match - // touches no state. - const payment = planInvoicePayment(invoice, paidAmount) + // touches no state. Pure-SEK settlements absorb sub-krona öresavrundning + // (booked to 3740 by buildInvoicePaymentClearingLines) so a whole-krona + // payment settles in full, exactly as on the dashboard route. + const pureSek = transaction.currency === 'SEK' && invoice.currency === 'SEK' + const payment = planInvoicePayment(invoice, paidAmount, { + absorbOreRounding: pureSek, + }) if (!payment.ok) { return v1ErrorResponseFromCode('MATCH_AMOUNT_EXCEEDS_REMAINING', txLog, { requestId: ctx.requestId, @@ -435,23 +552,80 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string ) journalEntryId = je?.id ?? null } else { - const je = await createInvoicePaymentJournalEntry( + // Clearing entry against 1510, built by the SAME shared helper the + // dashboard route and its preview use, so all three produce byte- + // identical lines: bank leg = the actual SEK that hit the account, + // 1510 credited at the invoice's booking rate, and a 3960/7960 FX-diff + // line (or a 3740 öresavrundning line on pure SEK) making the verifikat + // balance per BFL 5 kap 4-5§. + const fiscalPeriodId = await findFiscalPeriod( ctx.supabase, ctx.companyId!, - ctx.userId, - invoice as Invoice, transaction.date, - undefined, - invoice.customer?.name, - paidAmount, + ) + if (!fiscalPeriodId) { + return v1ErrorResponseFromCode('INVOICE_PAID_NO_FISCAL_PERIOD', txLog, { + requestId: ctx.requestId, + details: { payment_date: transaction.date }, + }) + } + const desc = invoice.customer?.name + ? `Inbetalning kundfaktura ${invoice.invoice_number}, ${invoice.customer.name}` + : `Inbetalning kundfaktura ${invoice.invoice_number}` + const { lines: clearingLines } = buildInvoicePaymentClearingLines( + { + amount: transaction.amount, + amount_sek: transaction.amount_sek ?? null, + currency: transaction.currency, + exchange_rate: transaction.exchange_rate ?? null, + }, + { + currency: invoice.currency, + exchange_rate: invoice.exchange_rate ?? null, + remaining_amount: invoice.remaining_amount ?? null, + total: invoice.total, + paid_amount: invoice.paid_amount ?? null, + }, + desc, + fx.required ? fx.paidInInvoiceCurrency : undefined, paymentAccount, ) + // Re-propagate the invoice's default dimension bag onto every leg, + // including the FX result lines, so a project's kursvinst/kursförlust + // stays inside the project P&L. createInvoicePaymentJournalEntry did + // this for v1 before; keeping it means the switch to the shared + // line-builder is not a silent regression for dimension users. Copied + // per line: a shared object would let one line's mutation leak. + const defaultDimensions = coerceDimensionsBag( + (invoice as { default_dimensions?: unknown }).default_dimensions, + ) + if (defaultDimensions) { + for (const line of clearingLines) line.dimensions = { ...defaultDimensions } + } + const je = await createJournalEntry(ctx.supabase, ctx.companyId!, ctx.userId, { + fiscal_period_id: fiscalPeriodId, + entry_date: transaction.date, + description: desc, + source_type: 'invoice_paid', + source_id: invoice.id, + lines: clearingLines, + }) journalEntryId = je?.id ?? null } } catch (err) { if (err instanceof AccountsNotInChartError) { return v1ErrorResponse(err, txLog, { requestId: ctx.requestId }) } + // buildInvoicePaymentClearingLines refuses a foreign invoice with no + // booking rate rather than valuing the 1510 credit at a fabricated one. + // Surface the registered 400 ("komplettera fakturans växelkurs") instead + // of the generic INVOICE_PAID_BOOK_FAILED, so the caller learns which + // field to fill in. Dispatch on `code` (not instanceof) for the same + // reason as the supplier route: the class's module is vi.mock'ed away in + // route tests, and the string literal can't degrade into a catch-all. + if ((err as { code?: unknown })?.code === 'MATCH_INVOICE_BOOKING_RATE_MISSING') { + return v1ErrorResponse(err, txLog, { requestId: ctx.requestId }) + } txLog.error('match-invoice: payment JE creation failed: aborting before state mutation', err as Error) return v1ErrorResponseFromCode('INVOICE_PAID_BOOK_FAILED', txLog, { requestId: ctx.requestId, @@ -519,11 +693,28 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string // kontantmetoden partials: never-booked invoices. When the invoice was // booked under accrual, the clearing entry handles the partial cleanly // and the note would be misleading. - const paymentNotes = + const cashMethodNote = !invoiceAlreadyBooked && accountingMethod === 'cash' && !isFullyPaid ? 'Kontantmetoden: intäkt bokförs vid slutbetalning' : null + // Provenance for a caller-supplied FX rate. A Riksbanken spot rate is + // self-documenting (rate + date are reproducible); a rate passed in the + // request body overrides the ML 8 kap 21-23§ obligation and must leave a + // trail on the payment row (BFL 5 kap 6-7§). + const manualRateNote = + fx.required && fx.source === 'manual' + ? `Manuell valutakurs ${fx.rate} ${invoice.currency}/SEK (betalningsdatum ${transaction.date})` + : null + + const paymentNotes = [cashMethodNote, manualRateNote].filter(Boolean).join(' · ') || null + + // amount and currency must agree: the row stores the payment in INVOICE + // currency (the column's unit), never a SEK magnitude wearing the invoice's + // foreign currency code. exchange_rate is the rate ACTUALLY USED for this + // payment (Riksbanken or the caller's override on the payment date, per + // ML 8 kap 21-23§), falling back to the invoice's booking rate only when no + // conversion was needed. const { error: paymentInsertErr } = await ctx.supabase .from('invoice_payments') .insert({ @@ -533,7 +724,7 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string payment_date: transaction.date, amount: paidAmount, currency: invoice.currency, - exchange_rate: invoice.exchange_rate, + exchange_rate: fx.required ? fx.rate : invoice.exchange_rate, journal_entry_id: journalEntryId, transaction_id: txId, notes: paymentNotes, @@ -580,10 +771,16 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string invoiceId: invoice_id, matchConfidence: 1.0, matchMethod: 'manual_confirm', + // rate_source / exchange_rate live inside new_state so a caller-supplied + // rate on a money path stays distinguishable from an automatic + // Riksbanken lookup in the audit trail. Same shape as the dashboard + // route; same-currency matches carry rate_source: null. newState: { status: newStatus, paid_amount: newPaidAmount, remaining_amount: newRemaining, + rate_source: fx.required ? fx.source : null, + exchange_rate: fx.required ? fx.rate : null, }, }) diff --git a/app/api/v1/companies/[companyId]/transactions/[id]/match-supplier-invoice/route.ts b/app/api/v1/companies/[companyId]/transactions/[id]/match-supplier-invoice/route.ts index bdefe3b5..5d5e9024 100644 --- a/app/api/v1/companies/[companyId]/transactions/[id]/match-supplier-invoice/route.ts +++ b/app/api/v1/companies/[companyId]/transactions/[id]/match-supplier-invoice/route.ts @@ -186,6 +186,53 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string } } + // Amount resolution runs BEFORE the storno below for the same reason the + // chart guard does: it is pure arithmetic that can reject the request, and + // a rejected request must leave no trace (reversing the transaction's + // existing categorization verifikat is irreversible). + const txAmountAbs = Math.abs(transaction.amount) + const paymentAmountInvoiceCurrency = + transaction.currency === invoice.currency ? txAmountAbs : invoice.remaining_amount + // SEK that actually left the bank, when known. A foreign transaction with + // no stored amount_sek is `null` here: the raw foreign amount must never + // stand in (treating 19 USD as 19 SEK books "19 kr" on a ~175 kr payment). + const bankSekStored = + transaction.currency === 'SEK' + ? txAmountAbs + : transaction.amount_sek != null + ? Math.abs(transaction.amount_sek) + : null + const invoiceFxRate = invoice.exchange_rate ?? null + // SEK the invoice was booked at for this payment portion (null if the + // invoice is foreign and carries no exchange_rate). + const bookedSek = + invoice.currency === 'SEK' + ? paymentAmountInvoiceCurrency + : invoiceFxRate && invoiceFxRate > 0 + ? Math.round(paymentAmountInvoiceCurrency * invoiceFxRate * 100) / 100 + : null + // Prefer the stored bank SEK; fall back to the invoice's booked SEK (right + // magnitude, FX diff 0). With NEITHER on file the SEK value is unknown and + // the old last resort (the raw foreign amount) violated the rule stated + // above, so refuse: same policy as the match_batch_allocate RPC + // (BATCH_FX_RATE_MISSING) and toSekOrThrow() in the entry generators. + // Byte-identical to the dashboard route so both surfaces agree. + const actualBankSek = bankSekStored ?? bookedSek + if (actualBankSek == null) { + return v1ErrorResponseFromCode('SI_FX_RATE_MISSING', txLog, { + requestId: ctx.requestId, + details: { + transaction_currency: transaction.currency, + invoice_currency: invoice.currency, + }, + }) + } + const originalBookedSek = bookedSek ?? actualBankSek + const exchangeRateDifference = + Math.round((originalBookedSek - actualBankSek) * 100) / 100 + const paymentAmountSek = + exchangeRateDifference !== 0 ? originalBookedSek : actualBankSek + // Storno any conflicting auto-categorization JE before booking the // payment. Mirrors the match-invoice path. Without this, an earlier // :categorize of the same transaction (e.g. as expense_office with a @@ -217,36 +264,6 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string } } - const txAmountAbs = Math.abs(transaction.amount) - const paymentAmountInvoiceCurrency = - transaction.currency === invoice.currency ? txAmountAbs : invoice.remaining_amount - // SEK that actually left the bank, when known. A foreign transaction with - // no stored amount_sek is `null` here: the raw foreign amount must never - // stand in (treating 19 USD as 19 SEK books "19 kr" on a ~175 kr payment). - const bankSekStored = - transaction.currency === 'SEK' - ? txAmountAbs - : transaction.amount_sek != null - ? Math.abs(transaction.amount_sek) - : null - const invoiceFxRate = invoice.exchange_rate ?? null - // SEK the invoice was booked at for this payment portion (null if the - // invoice is foreign and carries no exchange_rate). - const bookedSek = - invoice.currency === 'SEK' - ? paymentAmountInvoiceCurrency - : invoiceFxRate && invoiceFxRate > 0 - ? Math.round(paymentAmountInvoiceCurrency * invoiceFxRate * 100) / 100 - : null - // Prefer the stored bank SEK; fall back to the invoice's booked SEK (right - // magnitude, FX diff 0); last resort the raw amount. - const actualBankSek = bankSekStored ?? bookedSek ?? txAmountAbs - const originalBookedSek = bookedSek ?? actualBankSek - const exchangeRateDifference = - Math.round((originalBookedSek - actualBankSek) * 100) / 100 - const paymentAmountSek = - exchangeRateDifference !== 0 ? originalBookedSek : actualBankSek - const now = new Date().toISOString() const { data: settings } = await ctx.supabase @@ -365,6 +382,15 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string if (err instanceof AccountsNotInChartError) { return v1ErrorResponse(err, txLog, { requestId: ctx.requestId }) } + // The cash-method builder converts every leg through toSekOrThrow, so a + // foreign invoice with no usable rate surfaces here as + // SupplierInvoiceFxRateMissingError. Dispatch on its `code` (not + // instanceof: the class is routinely vi.mock'ed away) so the envelope + // carries the registered 400 rather than a generic 500. Mirrors the + // dashboard route and the preview for the same row. + if ((err as { code?: unknown })?.code === 'SI_FX_RATE_MISSING') { + return v1ErrorResponse(err, txLog, { requestId: ctx.requestId }) + } return v1ErrorResponseFromCode('MATCH_SI_RECORD_PAYMENT_FAILED', txLog, { requestId: ctx.requestId, details: { reason: getErrorMessage(err, { context: 'supplier_invoice' }) }, diff --git a/app/api/v1/companies/[companyId]/transactions/batch-categorize/__tests__/route.test.ts b/app/api/v1/companies/[companyId]/transactions/batch-categorize/__tests__/route.test.ts index bec9cf33..6dff39f1 100644 --- a/app/api/v1/companies/[companyId]/transactions/batch-categorize/__tests__/route.test.ts +++ b/app/api/v1/companies/[companyId]/transactions/batch-categorize/__tests__/route.test.ts @@ -24,18 +24,19 @@ vi.mock('@supabase/supabase-js', async () => { return { ...actual, createClient: vi.fn().mockReturnValue({}) } }) -const { createTxJE, findMissingAccountsMock } = vi.hoisted(() => ({ +const { createTxJE, findMissingAccountsMock, reverseEntryMock } = vi.hoisted(() => ({ createTxJE: vi.fn().mockResolvedValue({ id: 'je-fresh' }), // Default: every mapped account resolves (active, or seedable standard // BAS). Per-test overrides simulate the bug surface (inactive/unknown). findMissingAccountsMock: vi.fn().mockResolvedValue([]), + reverseEntryMock: vi.fn().mockResolvedValue(undefined), })) vi.mock('@/lib/bookkeeping/transaction-entries', () => ({ createTransactionJournalEntry: createTxJE, })) vi.mock('@/lib/bookkeeping/engine', () => ({ - reverseEntry: vi.fn().mockResolvedValue(undefined), + reverseEntry: reverseEntryMock, })) vi.mock('@/lib/bookkeeping/account-validation', async () => { const actual = await vi.importActual( @@ -60,6 +61,9 @@ function makeFlexibleSupabase(byTable: Record for (const [t, val] of Object.entries(byTable)) { queues.set(t, Array.isArray(val) ? [...val] : [val]) } + // Insert payloads are recorded verbatim: the proxy would happily accept a + // phantom column, so assertions have to inspect the object itself. + const inserts: Record = {} const buildChain = (table: string): unknown => { const handler: ProxyHandler = { get(_target, prop) { @@ -70,12 +74,15 @@ function makeFlexibleSupabase(byTable: Record resolve(next) } } - return (..._args: unknown[]) => buildChain(table) + return (...args: unknown[]) => { + if (prop === 'insert') (inserts[table] ??= []).push(args[0]) + return buildChain(table) + } }, } return new Proxy({}, handler) } - return { from: vi.fn((table: string) => buildChain(table)) } + return { supabase: { from: vi.fn((table: string) => buildChain(table)) }, inserts } } const COMPANY_ID = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa' @@ -100,6 +107,8 @@ function batchParams() { beforeEach(() => { vi.clearAllMocks() findMissingAccountsMock.mockResolvedValue([]) + reverseEntryMock.mockResolvedValue(undefined) + createTxJE.mockResolvedValue({ id: 'je-fresh' }) mockValidate.mockResolvedValue({ userId: 'user-1', companyId: COMPANY_ID, @@ -130,7 +139,7 @@ describe('POST batch-categorize', () => { }, company_settings: { data: { entity_type: 'enskild_firma' }, error: null }, fiscal_periods: { data: { id: 'period-1', is_closed: false, locked_at: null }, error: null }, - }), + }).supabase, ) // First item: mapping references an inactive account. Second item: clean. @@ -183,7 +192,7 @@ describe('POST batch-categorize', () => { }, company_settings: { data: { entity_type: 'enskild_firma' }, error: null }, fiscal_periods: { data: { id: 'period-1', is_closed: false, locked_at: null }, error: null }, - }), + }).supabase, ) // Pre-validation passes: race where an account got deactivated between // our chart_of_accounts read and the engine's resolveAccountIds read. @@ -211,4 +220,66 @@ describe('POST batch-categorize', () => { expect(body.data.results[0].error.details.account_numbers).toEqual(['5410']) expect(body.data.summary).toEqual({ total: 1, succeeded: 0, failed: 1 }) }) + + it('documents the stranded voucher with the real voucher_gap_explanations columns when the CAS-race storno fails', async () => { + const { supabase, inserts } = makeFlexibleSupabase({ + company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null }, + transactions: [ + // 1: item fetch. 2: the CAS update, which matches no row because a + // concurrent request already stamped journal_entry_id. + { + data: { + company_id: COMPANY_ID, + date: '2026-05-12', + amount: -349.5, + currency: 'SEK', + merchant_name: 'ICA', + journal_entry_id: null, + }, + error: null, + }, + { data: [], error: null }, + ], + company_settings: { data: { entity_type: 'enskild_firma' }, error: null }, + fiscal_periods: { data: { id: 'period-1', is_closed: false, locked_at: null }, error: null }, + journal_entries: { + data: { fiscal_period_id: 'period-1', voucher_series: 'B', voucher_number: 42 }, + error: null, + }, + voucher_gap_explanations: { data: null, error: null }, + }) + mockServiceClient.mockReturnValue(supabase) + // Storno fails: the orphan keeps its number, so the break in the + // verifikationsnummerserie must be documented (BFNAR 2013:2). + reverseEntryMock.mockRejectedValueOnce(new Error('period locked')) + + const res = await POST( + makeRequest( + `https://x.test/api/v1/companies/${COMPANY_ID}/transactions/batch-categorize`, + { + items: [ + { transaction_id: TX_A, categorization: { is_business: true, category: 'expense_office' } }, + ], + }, + ), + batchParams(), + ) + + expect(res.status).toBe(200) + const body = await res.json() + expect(body.data.results[0].error.code).toBe('TX_CATEGORIZE_RACE') + + const gaps = inserts['voucher_gap_explanations'] as Record[] + expect(gaps).toHaveLength(1) + // Exhaustive: no gap_number, no created_by, and every NOT NULL column set. + expect(gaps[0]).toEqual({ + company_id: COMPANY_ID, + user_id: 'user-1', + fiscal_period_id: 'period-1', + voucher_series: 'B', + gap_start: 42, + gap_end: 42, + explanation: 'CAS-race orphan; automatisk storno misslyckades. Manuell reconciliation krävs.', + }) + }) }) diff --git a/app/api/v1/companies/[companyId]/transactions/batch-categorize/route.ts b/app/api/v1/companies/[companyId]/transactions/batch-categorize/route.ts index 1b7606ed..5e614cfc 100644 --- a/app/api/v1/companies/[companyId]/transactions/batch-categorize/route.ts +++ b/app/api/v1/companies/[companyId]/transactions/batch-categorize/route.ts @@ -25,6 +25,7 @@ import { validateTemplateForEntity, } from '@/lib/bookkeeping/booking-templates' import { createTransactionJournalEntry } from '@/lib/bookkeeping/transaction-entries' +import { recordVoucherGapExplanation } from '@/lib/bookkeeping/cancel-orphaned-entry' import { reverseEntry } from '@/lib/bookkeeping/engine' import { AccountsNotInChartError, isBookkeepingError } from '@/lib/bookkeeping/errors' import { collectMappingResultAccounts, findUnresolvableAccounts } from '@/lib/bookkeeping/account-validation' @@ -367,23 +368,26 @@ async function categorizeOne( .from('journal_entries') .select('fiscal_period_id, voucher_series, voucher_number') .eq('id', journalEntryId) + .eq('company_id', companyId) .single() if (orphan && orphan.voucher_series) { // Same rationale as the single :categorize route: skip the gap row // when no series exists rather than filing under a fallback series - // that an audit query won't find. - await supabase.from('voucher_gap_explanations').insert({ - company_id: companyId, - fiscal_period_id: orphan.fiscal_period_id, - voucher_series: orphan.voucher_series, - gap_number: orphan.voucher_number, + // that an audit query won't find. The insert itself lives in the + // shared helper, which owns the real voucher_gap_explanations + // column set (gap_start/gap_end/user_id) and logs failures loudly. + await recordVoucherGapExplanation(supabase, { + companyId, + userId, + fiscalPeriodId: orphan.fiscal_period_id, + voucherSeries: orphan.voucher_series, + voucherNumber: orphan.voucher_number, explanation: 'CAS-race orphan; automatisk storno misslyckades. Manuell reconciliation krävs.', - created_by: userId, }) } } catch (gapErr) { - log.error('batch-categorize: failed to log voucher_gap_explanations', gapErr as Error, { + log.error('batch-categorize: failed to look up the orphan for its gap explanation', gapErr as Error, { request_index: index, orphanJournalEntryId: journalEntryId, }) diff --git a/app/invite/[token]/__tests__/invite-cookie.test.ts b/app/invite/[token]/__tests__/invite-cookie.test.ts new file mode 100644 index 00000000..a83ef810 --- /dev/null +++ b/app/invite/[token]/__tests__/invite-cookie.test.ts @@ -0,0 +1,235 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' +import fs from 'node:fs' +import path from 'node:path' +import { getInviteExpiry } from '@/lib/auth/invite-tokens' +import { + consumeInviteCookie, + isDefinitiveInviteDisposition, + INVITE_COOKIE_NAME, +} from '@/lib/auth/consume-invite-cookie' + +/** + * The invite cookie is written by app/invite/[token]/page.tsx before it hands + * the invitee off to /register or /login. That hop has to survive an email + * round-trip: the invitee registers at 17:00 and confirms the mail the next + * morning. The page used to write `max-age=3600` at three separate call sites + * against a 7-day invitation, so the cookie was gone long before the invitee + * came back and acceptance became impossible with no recovery path. + * + * The page is a client component, and this repo deliberately has no component + * test harness (CLAUDE.md: scope is lib/ + app/api/). These tests therefore + * assert on the page source, the same pattern already used by + * components/transactions/__tests__/invoice-match-dialog-fx.test.ts. The + * expected value is not restated here: it is read back out of + * `getInviteExpiry()`, the server-side source of the invite TTL, so changing + * INVITE_TTL_DAYS without moving the cookie fails this file. + */ +const PAGE_PATH = path.resolve(__dirname, '../page.tsx') +const SRC = fs.readFileSync(PAGE_PATH, 'utf8') + +/** + * The real invite TTL in seconds, derived from the code that stamps + * `company_invitations.expires_at`. Pinned to a date well clear of any DST + * transition, because `getInviteExpiry()` walks the local calendar. + */ +function inviteTtlSeconds(): number { + vi.useFakeTimers() + try { + vi.setSystemTime(new Date('2026-06-10T17:00:00.000Z')) + return (getInviteExpiry().getTime() - Date.now()) / 1000 + } finally { + vi.useRealTimers() + } +} + +/** + * Every line of the page that writes the invite cookie. A write is identified + * by carrying both a max-age and the samesite attribute, which excludes the + * delete performed elsewhere (`max-age=0`, no other attributes). Comment lines + * are dropped so prose describing the flags cannot be mistaken for a write. + */ +function cookieWriteLines(): string[] { + return SRC.split('\n') + .filter((line) => !/^\s*(\*|\/\/|\/\*)/.test(line)) + .filter((line) => line.includes('samesite=lax') && line.includes('max-age=')) +} + +/** + * Resolve the max-age used by a cookie write. Handles both a bare literal + * (`max-age=3600`) and an interpolated constant (`max-age=${NAME}`) whose + * declaration is a plain number or a product of numbers, so the assertion + * survives either spelling instead of only testing today's one. + */ +function resolveMaxAge(writeLine: string): number { + const raw = writeLine.match(/max-age=([^;`]+)/)?.[1]?.trim() + if (!raw) throw new Error(`no max-age in invite cookie write: ${writeLine}`) + + const interpolated = raw.match(/^\$\{(\w+)\}$/)?.[1] + if (!interpolated) return Number(raw) + + const decl = SRC.match(new RegExp(`const ${interpolated} = (.+)`))?.[1] + if (!decl) throw new Error(`${interpolated} is interpolated but never declared`) + + return decl + .split('*') + .map((part) => Number(part.trim())) + .reduce((product, n) => product * n, 1) +} + +describe('invite cookie lifetime', () => { + // The finding. Against HEAD this reports 3600 against 604800. + it('lives exactly as long as the invitation it carries', () => { + const ttl = inviteTtlSeconds() + expect(ttl).toBe(7 * 24 * 60 * 60) // sanity: getInviteExpiry() really is 7 days + + const writes = cookieWriteLines() + expect(writes.length).toBeGreaterThan(0) + + for (const write of writes) { + expect(resolveMaxAge(write)).toBe(ttl) + } + }) + + it('survives the overnight gap between registering and confirming the email', () => { + const OVERNIGHT_SECONDS = 15 * 60 * 60 // 17:00 to 08:00 the next morning + + for (const write of cookieWriteLines()) { + expect(resolveMaxAge(write)).toBeGreaterThan(OVERNIGHT_SECONDS) + } + }) + + // Never longer than the server honours: past expires_at the token is dead + // regardless, and a cookie outliving that is lifetime nobody grants. + it('never outlives the server-side bound', () => { + for (const write of cookieWriteLines()) { + expect(resolveMaxAge(write)).toBeLessThanOrEqual(inviteTtlSeconds()) + } + }) +}) + +describe('invite cookie construction', () => { + // Three separate literals is how the max-age drifts in the first place. + it('is built in exactly one place', () => { + expect(cookieWriteLines()).toHaveLength(1) + }) + + it('is still written on all three hops off the invite page', () => { + const callSites = SRC.match(/document\.cookie = buildInviteCookie\(/g) ?? [] + expect(callSites).toHaveLength(3) + }) + + it('takes the cookie name from the consume-side helper rather than a literal', () => { + expect(SRC).toContain("import { INVITE_COOKIE_NAME } from '@/lib/auth/consume-invite-cookie'") + expect(SRC).not.toMatch(/document\.cookie = `gnubok-invite-token=/) + }) +}) + +describe('invite cookie flags', () => { + const write = () => cookieWriteLines()[0] + + it('keeps the site-wide path so every auth surface can read it', () => { + expect(write()).toContain('path=/;') + }) + + it('keeps samesite=lax', () => { + expect(write()).toContain('samesite=lax') + }) + + it('keeps secure conditional on https and nothing else', () => { + // The flag is interpolated at the tail of the cookie string, never a + // hardcoded '; secure' that would break cookie writes on http self-hosts. + expect(write()).toMatch(/samesite=lax\$\{\w+\}`/) + expect(SRC).toContain( + "window.location.protocol === 'https:' ? '; secure' : ''", + ) + }) + + // Not a regression: the cookie is written from document.cookie, so httponly + // is impossible by construction. Asserted so a future reader does not think + // the flag was dropped as part of widening the lifetime. + it('is not httponly, by construction', () => { + expect(write()).not.toMatch(/httponly/i) + }) +}) + +/** + * The consume side (lib/auth/consume-invite-cookie.ts) now retains the cookie + * on transient failures, which compounds with a longer max-age. Re-assert here + * that a longer-lived cookie is still destroyed the moment the outcome is + * definitive, so the two changes do not add up to a token that lingers after + * it is spent. + */ +describe('a longer-lived cookie is still cleared on a definitive outcome', () => { + const TOKEN = 'gnubok_inv_Zm9vYmFyLXRva2Vu' + + function installCookieJar(initial: Record = {}) { + const jar = new Map(Object.entries(initial)) + + Object.defineProperty(globalThis, 'document', { + configurable: true, + value: { + get cookie() { + return [...jar.entries()].map(([k, v]) => `${k}=${v}`).join('; ') + }, + set cookie(raw: string) { + const [pair, ...attrs] = raw.split(';').map((s) => s.trim()) + const eq = pair.indexOf('=') + const name = pair.slice(0, eq) + const value = pair.slice(eq + 1) + if (attrs.some((a) => /^max-age=0$/i.test(a)) || value === '') { + jar.delete(name) + } else { + jar.set(name, value) + } + }, + }, + }) + + return jar + } + + let fetchMock: ReturnType + + beforeEach(() => { + vi.clearAllMocks() + fetchMock = vi.fn() + vi.stubGlobal('fetch', fetchMock) + vi.spyOn(console, 'error').mockImplementation(() => {}) + }) + + afterEach(() => { + vi.unstubAllGlobals() + Reflect.deleteProperty(globalThis, 'document') + vi.restoreAllMocks() + }) + + it('clears a 7-day cookie once the invite is accepted', async () => { + const jar = installCookieJar({ [INVITE_COOKIE_NAME]: TOKEN }) + fetchMock.mockResolvedValue({ status: 200, ok: true }) + + const result = await consumeInviteCookie() + + expect(isDefinitiveInviteDisposition(result.disposition!)).toBe(true) + expect(jar.has(INVITE_COOKIE_NAME)).toBe(false) + }) + + it('clears a 7-day cookie once the invitation itself has expired (410)', async () => { + const jar = installCookieJar({ [INVITE_COOKIE_NAME]: TOKEN }) + fetchMock.mockResolvedValue({ status: 410, ok: false }) + + const result = await consumeInviteCookie() + + expect(result.disposition).toBe('spent') + expect(jar.has(INVITE_COOKIE_NAME)).toBe(false) + }) + + it('still retains it on a transient failure, now for the full invite window', async () => { + const jar = installCookieJar({ [INVITE_COOKIE_NAME]: TOKEN }) + fetchMock.mockResolvedValue({ status: 500, ok: false }) + + const result = await consumeInviteCookie() + + expect(result.cleared).toBe(false) + expect(jar.get(INVITE_COOKIE_NAME)).toBe(TOKEN) + }) +}) diff --git a/app/invite/[token]/page.tsx b/app/invite/[token]/page.tsx index 5ff701d8..d5859f42 100644 --- a/app/invite/[token]/page.tsx +++ b/app/invite/[token]/page.tsx @@ -10,9 +10,58 @@ import { Loader2, Building2, AlertCircle } from 'lucide-react' import { createClient } from '@/lib/supabase/client' import { useToast } from '@/components/ui/use-toast' import { getBranding } from '@/lib/branding/service' +import { INVITE_COOKIE_NAME } from '@/lib/auth/consume-invite-cookie' const branding = getBranding() +/** + * How long the pre-auth invite cookie lives, in seconds. + * + * This hop has to survive an email round-trip: an invitee who clicks "create + * account" at 17:00 confirms the signup mail the next morning, and only then + * lands back on a surface that can redeem the token. A one-hour cookie made + * that acceptance impossible while the invitation itself was still `pending` + * in the database, with no way for the invitee to recover: the cookie is the + * only copy the browser holds. + * + * The value is the invite TTL, `INVITE_TTL_DAYS` in lib/auth/invite-tokens.ts, + * which is what actually bounds the token: `getInviteExpiry()` stamps + * `company_invitations.expires_at` 7 days out at issue time and POST + * /api/team/accept rejects anything past it with 410. `INVITE_TTL_DAYS` is a + * module-private const in a server-only module (it imports Node's `crypto`), + * so the number is restated here rather than imported; + * app/invite/[token]/__tests__/invite-cookie.test.ts reads the real TTL back + * out of `getInviteExpiry()` and fails if the two drift. + * + * Widening the cookie does not widen authority. Every acceptance attempt is + * re-authorized server-side by `requireAuth()` plus an email equality check + * against the invitation, and the `status` transition is single-use, so a + * surviving cookie confers nothing on its own. If the invitation expires + * before the cookie does (the invitee sat on the mail for six days), the next + * attempt gets a 410, which `consumeInviteCookie()` classifies as `spent` and + * clears. Never set this beyond the invite TTL: a cookie outliving the + * server-side bound would be lifetime the server does not honour. + */ +const INVITE_COOKIE_MAX_AGE_SECONDS = 7 * 24 * 60 * 60 + +/** + * The single construction site for the invite cookie. Three hops write it + * (register, login, sign-out-and-retry) and each used to carry its own copy of + * the string. They happened to still agree, but three literals is how a + * lifetime drifts; one builder is what makes the max-age and the flags + * provably identical across all three. + * + * Flags are unchanged from the original: path-scoped to the whole site so any + * auth surface can read it, `samesite=lax` so it survives the top-level + * navigation back from the confirmation mail without riding along on + * cross-site subrequests, and `secure` whenever the page is on https. It is + * deliberately not `httponly`: it is written from `document.cookie` and read + * back by client-side auth surfaces. + */ +function buildInviteCookie(token: string, secureFlag: string): string { + return `${INVITE_COOKIE_NAME}=${token}; path=/; max-age=${INVITE_COOKIE_MAX_AGE_SECONDS}; samesite=lax${secureFlag}` +} + interface InviteInfo { type: 'company' companyName?: string @@ -80,13 +129,13 @@ export default function InvitePage() { const handleAccept = () => { // Store invite token in cookie before redirecting to register - document.cookie = `gnubok-invite-token=${token}; path=/; max-age=3600; samesite=lax${secureCookieFlag}` + document.cookie = buildInviteCookie(token, secureCookieFlag) router.push(`/register?invite=${encodeURIComponent(token)}`) } const handleAcceptExistingUser = () => { // Store invite token in cookie before redirecting to login - document.cookie = `gnubok-invite-token=${token}; path=/; max-age=3600; samesite=lax${secureCookieFlag}` + document.cookie = buildInviteCookie(token, secureCookieFlag) router.push('/login') } @@ -138,7 +187,7 @@ export default function InvitePage() { const supabase = createClient() await supabase.auth.signOut() // Keep the invite cookie alive so the next login/register picks it up. - document.cookie = `gnubok-invite-token=${token}; path=/; max-age=3600; samesite=lax${secureCookieFlag}` + document.cookie = buildInviteCookie(token, secureCookieFlag) if (invite?.alreadyHasAccount) { router.push('/login') } else { diff --git a/components/agent-knowledge/LedgerGraph.tsx b/components/agent-knowledge/LedgerGraph.tsx index b6c26809..8e95b993 100644 --- a/components/agent-knowledge/LedgerGraph.tsx +++ b/components/agent-knowledge/LedgerGraph.tsx @@ -7,6 +7,7 @@ import { getAccountDescription } from '@/lib/bookkeeping/account-descriptions' import { useTranslations } from 'next-intl' import { formatCurrency } from '@/lib/utils' import type { DeepEntity, DeepLedgerContext } from '@/lib/agent-context/ledger-deep' +import { entityMagnitude, selectAccountRing, type RingBasis } from './ledger-graph-magnitude' /** * "Reconciliation Aurora" - the cinematic hero of the "Vad din agent vet" page. @@ -18,7 +19,7 @@ import type { DeepEntity, DeepLedgerContext } from '@/lib/agent-context/ledger-d * never cross between accounts (the anti-hairball guarantee). * * Four orthogonal channels, so no two compete: - * - node AREA = total spend (radius = k·√kr) + * - node AREA = magnitude (radius = k·√x), see ledger-graph-magnitude.ts * - node COLOUR = booking cadence (weekly / monthly / irregular) - the one * semantic axis; everything else stays achromatic * - node SHAPE = kind (supplier = filled, counterparty = open ring) @@ -45,8 +46,6 @@ const R_HUB = 34 const R_ACCOUNT = 178 const R_PAYEE_MIN = 258 const R_PAYEE_MAX = 432 -const MAX_ACCOUNTS = 9 -const MAX_PER_ACCOUNT = 6 const WEDGE_GAP = 0.07 // radians of padding between account wedges // This panel is its own dark world regardless of the app theme, so the depth of @@ -95,6 +94,8 @@ interface Model { accounts: Account[] payees: Payee[] truncated: boolean + /** What the wedge widths and node areas actually measure. See the legend. */ + basis: RingBasis totals: { tx: number; payees: number; accounts: number } } @@ -154,41 +155,14 @@ function buildModel(deep: DeepLedgerContext): Model { const totalTx = all.reduce((s, e) => s + e.occurrences, 0) - const byAccount = new Map() - for (const e of all) { - const acc = e.dominant_account_number as string - const arr = byAccount.get(acc) ?? [] - arr.push(e) - byAccount.set(acc, arr) - } - - // Weight a wedge by the money that flows through it (fall back to volume). - const activity = (items: DeepEntity[]) => { - const spend = items.reduce((s, i) => s + Math.max(i.total_amount, 0), 0) - return spend > 0 ? spend : items.reduce((s, i) => s + i.occurrences, 0) - } - - let groups = [...byAccount.entries()].map(([number, items]) => ({ - number, - items: items.slice().sort((a, b) => b.total_amount - a.total_amount), - weight: activity(items), - })) - // Rank by weight to pick what to show, then lay out in a fixed order (account - // number) so wedges never swap places between loads. - groups.sort((a, b) => b.weight - a.weight) - const totalAccounts = groups.length - groups = groups.slice(0, MAX_ACCOUNTS) - let truncated = totalAccounts > groups.length - for (const g of groups) { - if (g.items.length > MAX_PER_ACCOUNT) { - truncated = true - g.items = g.items.slice(0, MAX_PER_ACCOUNT) - } - } - groups.sort((a, b) => a.number.localeCompare(b.number)) - - const shownWeight = groups.reduce((s, g) => s + g.weight, 0) || 1 - const maxSpend = Math.max(...groups.flatMap((g) => g.items.map((i) => i.total_amount)), 1) + // Grouping, weighting, ranking and truncation of the account ring live in + // ledger-graph-magnitude.ts so they can be unit tested. The one rule that + // matters here: every wedge weight below is expressed in the SAME unit + // (`ring.basis`), so the shared denominator never adds kronor to booking + // counts and no account is squeezed to an invisible sliver, or truncated + // away entirely, just for being measured differently. + const ring = selectAccountRing(all) + const { basis, groups, truncated } = ring // The most name-merged payees earn the on-mount "descriptors collapse" moment. const mergeIds = new Set( @@ -205,7 +179,7 @@ function buildModel(deep: DeepLedgerContext): Model { const spans = 2 * Math.PI - WEDGE_GAP * groups.length let angle = -Math.PI / 2 + WEDGE_GAP / 2 groups.forEach((g, gi) => { - const width = spans * (g.weight / shownWeight) + const width = spans * (g.weight / ring.totalWeight) const mid = angle + width / 2 const anchor = polar(CX, CY, R_ACCOUNT, mid) @@ -226,8 +200,10 @@ function buildModel(deep: DeepLedgerContext): Model { const slot = order[rank] const t = n === 1 ? 0.5 : slot / (n - 1) const pa = angle + inner + t * (width - 2 * inner) - const spendFrac = Math.sqrt(Math.max(e.total_amount, 0) / maxSpend) - const radius = lerp(R_PAYEE_MIN, R_PAYEE_MAX, rand01(e.key)) + spendFrac * 14 + // Same unit as the wedge it sits in, so a node is never sized against a + // maximum measured in something else. + const sizeFrac = Math.sqrt(entityMagnitude(e, basis) / ring.maxMagnitude) + const radius = lerp(R_PAYEE_MIN, R_PAYEE_MAX, rand01(e.key)) + sizeFrac * 14 const pos = polar(CX, CY, Math.min(radius, R_PAYEE_MAX + 10), pa) const id = `${g.number}:${e.key}` payees.push({ @@ -236,7 +212,7 @@ function buildModel(deep: DeepLedgerContext): Model { accountNumber: g.number, x: pos.x, y: pos.y, - r: 7 + 19 * spendFrac, + r: 7 + 19 * sizeFrac, cadence: cadenceOf(e), bucket: bucketOf(e.dominant_account_share), thread: `M ${CX} ${CY} Q ${anchor.x.toFixed(2)} ${anchor.y.toFixed(2)} ${pos.x.toFixed(2)} ${pos.y.toFixed(2)}`, @@ -254,6 +230,7 @@ function buildModel(deep: DeepLedgerContext): Model { accounts, payees, truncated, + basis, totals: { tx: totalTx, payees: payees.length, accounts: accounts.length }, } } @@ -509,7 +486,12 @@ export function LedgerGraph({ deep, companyName }: { deep: DeepLedgerContext; co {t('cadence_irregular')} - {t('legend_size')} + {/* Says what the sizes actually measure. The ring drops to booking + volume whenever any account lacks a usable amount, and claiming + "Storlek = belopp" there would describe a unit nothing is drawn in. */} + + {model.basis === 'amount' ? t('legend_size') : t('legend_size_volume')} + {t('legend_focus')} {model.truncated && {t('graph_truncated')}} @@ -755,9 +737,18 @@ function DetailCard({ p, t }: { p: Payee; t: ReturnType ×{e.variant_count} )} + {/* Its own label: `legend_size` now describes whichever unit the ring + settled on, which is not always the amount. Rendered as a plain + number with NO currency suffix: total_amount is the RAW FOREIGN + amount when no SEK equivalent exists (ledger-graph-magnitude.ts), + so labelling it "kr" would show a 500 EUR supplier as "500 kr". + Proper per-currency display is deferred to the RPC fix documented + in that module. */}
- {t('legend_size')} - {formatCurrency(e.total_amount)} + {t('card_amount')} + + {e.total_amount.toLocaleString('sv-SE', { minimumFractionDigits: 2, maximumFractionDigits: 2 })} +
diff --git a/components/agent-knowledge/__tests__/ledger-graph-magnitude.test.ts b/components/agent-knowledge/__tests__/ledger-graph-magnitude.test.ts new file mode 100644 index 00000000..59304cef --- /dev/null +++ b/components/agent-knowledge/__tests__/ledger-graph-magnitude.test.ts @@ -0,0 +1,163 @@ +import { describe, it, expect } from 'vitest' +import { + entityMagnitude, + selectAccountRing, + MAX_ACCOUNTS, +} from '@/components/agent-knowledge/ledger-graph-magnitude' +import type { DeepEntity } from '@/lib/agent-context/ledger-deep' + +function entity(overrides: Partial & { key: string }): DeepEntity { + return { + name: overrides.key, + variants: [overrides.key], + variant_count: 1, + occurrences: 1, + total_amount: 0, + first_seen: '2026-01-01', + last_seen: '2026-06-01', + cadence_days: null, + dominant_account_number: '5420', + dominant_account_share: 0.8, + dominant_account_count: 4, + dominant_account_total: 5, + kind: 'counterparty', + ...overrides, + } +} + +/** Share of the ring an account gets, ignoring the fixed inter-wedge padding. */ +function share(weight: number, totalWeight: number): number { + return weight / totalWeight +} + +describe('selectAccountRing', () => { + it('weighs a SEK-only company by amount, exactly as before', () => { + const ring = selectAccountRing([ + entity({ key: 'a', dominant_account_number: '5420', total_amount: 24000, occurrences: 12 }), + entity({ key: 'b', dominant_account_number: '5420', total_amount: 6000, occurrences: 3 }), + entity({ key: 'c', dominant_account_number: '6540', total_amount: 10000, occurrences: 2 }), + ]) + + expect(ring.basis).toBe('amount') + expect(ring.groups.map((g) => g.number)).toEqual(['5420', '6540']) + expect(ring.groups.map((g) => g.weight)).toEqual([30000, 10000]) + expect(ring.totalWeight).toBe(40000) + expect(ring.maxMagnitude).toBe(24000) + expect(ring.truncated).toBe(false) + // Biggest spender first inside the wedge: it lands at the angular centre. + expect(ring.groups[0].items.map((e) => e.key)).toEqual(['a', 'b']) + }) + + it('never puts kronor and booking counts in the same denominator', () => { + // 6570 is booked 4 times but carries no usable amount: on the counterparty + // side that is a spend of 0, and it used to fall back to its booking count + // while 5420 stayed in kronor. + const entities = [ + entity({ key: 'big', dominant_account_number: '5420', total_amount: 250000, occurrences: 6 }), + entity({ key: 'nil', dominant_account_number: '6570', total_amount: 0, occurrences: 4 }), + ] + + const ring = selectAccountRing(entities) + + expect(ring.basis).toBe('volume') + expect(ring.groups.map((g) => [g.number, g.weight])).toEqual([ + ['5420', 6], + ['6570', 4], + ]) + expect(ring.totalWeight).toBe(10) + // No weight is a kronor figure, so nothing can be summed across units. + expect(ring.groups.every((g) => g.weight < 1000)).toBe(true) + + // The pre-fix mix: 250 000 kr against 4 bookings in one denominator gave + // 6570 a wedge of 0.0016% of the circle, an invisible sliver. Now it is a + // legible tenth of the ring. + const mixedShare = share(4, 250000 + 4) + expect(mixedShare).toBeLessThan(0.0001) + expect(share(ring.groups[1].weight, ring.totalWeight)).toBeCloseTo(0.4, 10) + }) + + it('keeps an account with no usable amount inside the truncation cap', () => { + // Ten accounts, cap is nine. The amount-less one is the most-booked account + // the company has, so dropping it is the worst possible choice. + const entities: DeepEntity[] = [ + entity({ key: 'unpriced', dominant_account_number: '4000', total_amount: 0, occurrences: 40 }), + ] + for (let i = 0; i < 9; i++) { + entities.push( + entity({ + key: `priced-${i}`, + dominant_account_number: `55${10 + i}`, + total_amount: 100000 + i, + occurrences: 2, + }), + ) + } + + const ring = selectAccountRing(entities) + + expect(ring.groups).toHaveLength(MAX_ACCOUNTS) + expect(ring.truncated).toBe(true) + expect(ring.groups.map((g) => g.number)).toContain('4000') + // It is the largest wedge, not a survivor by luck. + const unpriced = ring.groups.find((g) => g.number === '4000') + expect(unpriced?.weight).toBe(40) + expect(Math.max(...ring.groups.map((g) => g.weight))).toBe(40) + }) + + it('sizes nodes against a maximum measured in the same unit', () => { + const ring = selectAccountRing([ + entity({ key: 'a', dominant_account_number: '5420', total_amount: 0, occurrences: 9 }), + entity({ key: 'b', dominant_account_number: '6540', total_amount: 500, occurrences: 3 }), + ]) + + expect(ring.basis).toBe('volume') + expect(ring.maxMagnitude).toBe(9) + // The 500 is not compared against 9: under a volume ring it is not read. + expect(entityMagnitude(ring.groups[1].items[0], ring.basis)).toBe(3) + }) + + it('truncates payees per wedge without changing what the wedge weighs', () => { + const items = Array.from({ length: 8 }, (_, i) => + entity({ key: `e${i}`, dominant_account_number: '5420', total_amount: 1000, occurrences: 1 }), + ) + + const ring = selectAccountRing(items, { maxPerAccount: 6 }) + + expect(ring.truncated).toBe(true) + expect(ring.groups[0].items).toHaveLength(6) + expect(ring.groups[0].weight).toBe(8000) + }) + + it('drops entities with no dominant account and survives an empty ledger', () => { + const ring = selectAccountRing([ + entity({ key: 'orphan', dominant_account_number: null, total_amount: 900, occurrences: 2 }), + ]) + + expect(ring.groups).toEqual([]) + expect(ring.basis).toBe('volume') + // Safe divisors: the layout divides by both. + expect(ring.totalWeight).toBe(1) + expect(ring.maxMagnitude).toBe(1) + }) + + it('is deterministic when weights tie', () => { + const build = (order: string[]) => + selectAccountRing( + order.map((number) => + entity({ key: number, dominant_account_number: number, total_amount: 5000, occurrences: 1 }), + ), + { maxAccounts: 2 }, + ) + + expect(build(['6000', '5000', '7000']).groups.map((g) => g.number)).toEqual(['5000', '6000']) + expect(build(['7000', '6000', '5000']).groups.map((g) => g.number)).toEqual(['5000', '6000']) + }) +}) + +describe('entityMagnitude', () => { + it('clamps a negative amount rather than subtracting it from a sibling', () => { + const e = entity({ key: 'credit', total_amount: -4000, occurrences: 2 }) + expect(entityMagnitude(e, 'amount')).toBe(0) + expect(entityMagnitude(e, 'volume')).toBe(2) + }) +}) diff --git a/components/agent-knowledge/ledger-graph-magnitude.ts b/components/agent-knowledge/ledger-graph-magnitude.ts new file mode 100644 index 00000000..6085c848 --- /dev/null +++ b/components/agent-knowledge/ledger-graph-magnitude.ts @@ -0,0 +1,176 @@ +/** + * Pure account-ring selection and weighting for `LedgerGraph`. + * + * Lives outside the component (which is `'use client'` and pure JSX) so the + * rule below can be unit tested: this repo runs Vitest in the `node` + * environment and never renders components, so logic embedded in a component + * is unverifiable by construction. Same arrangement as + * `components/transactions/invoice-candidate-ranking.ts`. + * + * # Why this exists + * + * The ring weighted each account wedge with: + * + * const spend = items.reduce((s, i) => s + Math.max(i.total_amount, 0), 0) + * return spend > 0 ? spend : items.reduce((s, i) => s + i.occurrences, 0) + * + * The fallback is per group, but the weights are then summed into one + * denominator (`shownWeight`) and ranked against each other. So an account + * whose entities carry no usable amount contributed a booking COUNT (say 4) + * into a denominator otherwise made of kronor (say 250 000), and: + * + * - its wedge came out ~0.002% of the circle, i.e. an invisible sliver whose + * payee nodes all collapse onto one angle, and + * - it sorted below every money-weighted account, so it was the first thing + * dropped by the top-`maxAccounts` truncation. + * + * An account the agent genuinely books to therefore vanished from the map for + * no reason other than its magnitude being expressed in a different unit. + * Silent exclusion, which is exactly what this surface must not do. + * + * The rule here: the whole ring is measured in ONE unit. Amount if every + * account has a usable amount, booking volume otherwise. Never a mix. The unit + * is decided over every account, not just the nine that end up shown, because + * the unit is what ranks them: deciding it from the survivors would be circular + * and would reintroduce the truncation bug it exists to prevent. The chosen + * unit is returned as `basis` so the legend can say which one the sizes + * actually mean instead of always claiming "Storlek = belopp". + * + * # What this does NOT fix + * + * `DeepEntity.total_amount` arrives from `get_ledger_deep_context` already + * summed to a single scalar per entity, built as + * `abs(coalesce(t.amount_sek, t.amount))` on the counterparty side and + * `coalesce(si.total_sek, si.total, 0)` on the supplier side. That is SEK when + * a SEK equivalent was recorded and the RAW FOREIGN AMOUNT otherwise, and the + * RPC projects neither `currency` nor a per-row breakdown. A 500 EUR supplier + * invoice with no stored exchange rate therefore reaches this file as the + * number 500, indistinguishable from 500 kr. + * + * Nothing here can undo that: by the time the payload exists the currencies + * have already been added together upstream, and no field survives that would + * let us split them, convert them or even count them. Forming a per-currency + * total on this side would mean inventing one. The fix belongs in the RPC + * (project `total_amount_sek` + per-currency totals + a count of rows with no + * SEK equivalent, and drop the raw-foreign fallback); this file is written so + * that the moment those fields exist, `entityMagnitude` is the single place + * that has to learn about them. + */ +import type { DeepEntity } from '@/lib/agent-context/ledger-deep' + +/** How many account wedges the ring shows before it truncates. */ +export const MAX_ACCOUNTS = 9 +/** How many payee nodes one wedge shows before it truncates. */ +export const MAX_PER_ACCOUNT = 6 + +/** + * The unit the whole ring is measured in. + * + * - `amount`: `total_amount` as delivered by the RPC (kronor, with the caveat + * in the file header). Only chosen when every account has one. + * - `volume`: number of bookings. Currency-free, so it is always available and + * is what the ring falls back to rather than mixing units. + */ +export type RingBasis = 'amount' | 'volume' + +export interface RingGroup { + /** BAS account number. Strings, never numbers (CLAUDE.md). */ + number: string + /** Entities booked to this account, biggest magnitude first. */ + items: DeepEntity[] + /** + * Wedge weight, in `basis` units. Computed over ALL the account's entities, + * including the ones `items` truncated away, so the wedge keeps the size the + * account actually earned. + */ + weight: number +} + +export interface RingSelection { + basis: RingBasis + /** Shown accounts, ordered by account number so wedges never swap places. */ + groups: RingGroup[] + /** True when accounts or payees were dropped by the display caps. */ + truncated: boolean + /** Sum of the shown weights, floored at 1 so it is always a safe divisor. */ + totalWeight: number + /** Largest single-entity magnitude among the shown groups, floored at 1. */ + maxMagnitude: number +} + +/** + * One entity's magnitude in the ring's unit. + * + * `total_amount` is whole kronor (`round(...)::bigint` in the RPC), so summing + * these is exact integer arithmetic and needs no öre rounding. Negative totals + * are clamped to 0 rather than subtracted from a sibling: this drives geometry, + * and a negative radius is not a thing. + */ +export function entityMagnitude(entity: DeepEntity, basis: RingBasis): number { + if (basis === 'volume') return Math.max(entity.occurrences, 0) + return Math.max(entity.total_amount, 0) +} + +function sumMagnitude(items: DeepEntity[], basis: RingBasis): number { + return items.reduce((sum, item) => sum + entityMagnitude(item, basis), 0) +} + +/** + * Groups entities by their dominant account and picks what the ring shows. + * + * Deterministic end to end: ties break on account number rather than on input + * order, so the demo lays out identically on every load. + */ +export function selectAccountRing( + entities: DeepEntity[], + options: { maxAccounts?: number; maxPerAccount?: number } = {}, +): RingSelection { + const maxAccounts = options.maxAccounts ?? MAX_ACCOUNTS + const maxPerAccount = options.maxPerAccount ?? MAX_PER_ACCOUNT + + const byAccount = new Map() + for (const entity of entities) { + const account = entity.dominant_account_number + if (!account) continue + const bucket = byAccount.get(account) + if (bucket) bucket.push(entity) + else byAccount.set(account, [entity]) + } + + const buckets = [...byAccount.entries()] + + // The unit decision, made once for the whole ring. An account with no usable + // amount would otherwise be weighted in bookings while its siblings are + // weighted in kronor, and the two get summed into one denominator below. + const basis: RingBasis = + buckets.length > 0 && buckets.every(([, items]) => sumMagnitude(items, 'amount') > 0) + ? 'amount' + : 'volume' + + let groups: RingGroup[] = buckets.map(([number, items]) => ({ + number, + items: [...items].sort((a, b) => entityMagnitude(b, basis) - entityMagnitude(a, basis)), + weight: sumMagnitude(items, basis), + })) + + // Rank to decide what to show, then lay out in account-number order. + groups.sort((a, b) => b.weight - a.weight || a.number.localeCompare(b.number)) + const accountCount = groups.length + groups = groups.slice(0, maxAccounts) + let truncated = accountCount > groups.length + for (const group of groups) { + if (group.items.length > maxPerAccount) { + truncated = true + group.items = group.items.slice(0, maxPerAccount) + } + } + groups.sort((a, b) => a.number.localeCompare(b.number)) + + const totalWeight = groups.reduce((sum, group) => sum + group.weight, 0) || 1 + const maxMagnitude = Math.max( + ...groups.flatMap((group) => group.items.map((item) => entityMagnitude(item, basis))), + 1, + ) + + return { basis, groups, truncated, totalWeight, maxMagnitude } +} diff --git a/components/agent/ApprovalCard.tsx b/components/agent/ApprovalCard.tsx index dc8dda87..a4f37f18 100644 --- a/components/agent/ApprovalCard.tsx +++ b/components/agent/ApprovalCard.tsx @@ -138,7 +138,14 @@ export default function ApprovalCard({ setState('pending') return } - throw new Error(errorText(body.error) || `HTTP ${res.status}`) + // Map the parsed body plus the status, never `new Error(body.error)`: + // the route answers thrown errors with the canonical envelope + // `{ error: { code, message } }`, and the Error constructor would + // stringify that object to "[object Object]", discarding the route's + // own Swedish reason. + setState('error') + setErrorMessage(getUserErrorMessage(body, { statusCode: res.status })) + return } // Best-effort deep-link to the created artifact in the success state. if (body?.data) setCommitResult(body.data) @@ -163,8 +170,10 @@ export default function ApprovalCard({ body: JSON.stringify({ account_numbers: accountsToActivate }), }) if (!res.ok) { - const body = (await res.json().catch(() => ({}))) as { error?: string } - throw new Error(body.error || 'Kunde inte aktivera kontona.') + const body = await res.json().catch(() => null) + setState('error') + setErrorMessage(getUserErrorMessage(body, { statusCode: res.status })) + return } setAccountsToActivate(null) await handleCommit() @@ -196,8 +205,10 @@ export default function ApprovalCard({ : {}), }) if (!res.ok) { - const text = await res.text() - throw new Error(text || `HTTP ${res.status}`) + const body = await res.json().catch(() => null) + setState('error') + setErrorMessage(getUserErrorMessage(body, { statusCode: res.status })) + return } setShowRejectForm(false) setState('rejected') @@ -641,12 +652,6 @@ function CategorizeTransactionPreview({ // Pull a human message out of an API error body that may be either a bare // string ({ error: "…" }) or the structured envelope ({ error: { message } }). -function errorText(error: string | { message?: string } | undefined): string | null { - if (typeof error === 'string') return error - if (error && typeof error === 'object' && typeof error.message === 'string') return error.message - return null -} - function prettyCategory(value: string | undefined): string { if (!value) return '(saknas)' return CATEGORY_OPTIONS.find((o) => o.value === value)?.label ?? value diff --git a/components/articles/ArticleForm.tsx b/components/articles/ArticleForm.tsx index ea491ca1..2692b7da 100644 --- a/components/articles/ArticleForm.tsx +++ b/components/articles/ArticleForm.tsx @@ -6,12 +6,16 @@ import { zodResolver } from '@hookform/resolvers/zod' import { z } from 'zod' import { useTranslations } from 'next-intl' import { Button } from '@/components/ui/button' -import { Input } from '@/components/ui/input' -import { Label } from '@/components/ui/label' -import { Textarea } from '@/components/ui/textarea' -import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select' +import { + SettingsGroup, + SettingsInput, + SettingsRow, + SettingsSeg, + SettingsSelect, + SettingsTextarea, +} from '@/components/settings/SettingsRows' import { ChevronDown, Loader2, Lock } from 'lucide-react' -import { cn } from '@/lib/utils' +import { cn, formatCurrency } from '@/lib/utils' import { useCanWrite } from '@/lib/hooks/use-can-write' import { useCompany } from '@/contexts/CompanyContext' import { createClient } from '@/lib/supabase/client' @@ -34,21 +38,37 @@ const UNITS = ['st', 'tim', 'dag', 'månad', 'km', 'kg'] as const // lib/api/schemas.ts (25 | 12 | 6 | 0). const VAT_RATES = [25, 12, 6, 0] as const +/** Money is rounded to öre with arithmetic, never toFixed (CLAUDE.md rule 6). */ +function round2(value: number): number { + return Math.round(value * 100) / 100 +} + +// SettingsInput is a bare input, so it does not carry the wheel guard that +// components/ui/input.tsx applies to type="number". Without it, scrolling the +// page with the cursor over a focused price field silently edits the amount. +function blurOnWheel(e: React.WheelEvent) { + e.currentTarget.blur() +} + interface ArticleFormProps { onSubmit: (data: CreateArticleInput) => Promise isLoading: boolean initialData?: Partial + /** Closes the host dialog. When omitted the cancel button is not rendered. */ + onCancel?: () => void } export default function ArticleForm({ onSubmit, isLoading, initialData, + onCancel, }: ArticleFormProps) { const { canWrite } = useCanWrite() const { company } = useCompany() const supabase = createClient() const t = useTranslations('form_article') + const tCommon = useTranslations('common') // Active class 1-3 posting accounts for the combobox. The combobox accepts // unknown 4-digit numbers optimistically: the API answers with // ACCOUNTS_NOT_IN_CHART for activatable BAS accounts, and the host page's @@ -117,12 +137,14 @@ export default function ArticleForm({ } // eslint-disable-next-line react-hooks/exhaustive-deps }, [company?.id]) - // Open the advanced section by default when it already holds data, so an - // edit never hides a value the user previously set. - const [advancedOpen, setAdvancedOpen] = useState( + + // Open "Fler fält" by default when it already holds data, so an edit never + // hides a value the user previously set. Currency and posting account are no + // longer in here: both are permanent rows. + const [moreOpen, setMoreOpen] = useState( Boolean( - (initialData?.currency && initialData.currency !== 'SEK') || - initialData?.revenue_account || + initialData?.article_number || + initialData?.name_en || initialData?.cost_price != null || initialData?.ean || initialData?.housework_type || @@ -186,6 +208,29 @@ export default function ArticleForm({ }) const type = watch('type') + const watchedName = watch('name') + const watchedUnit = watch('unit') + const watchedPrice = watch('price_excl_vat') + const watchedVat = watch('vat_rate') + const watchedCurrency = watch('currency') + const watchedAccount = watch('revenue_account') + const watchedNumber = watch('article_number') + + // The summary strip: what this article becomes as a line on an invoice. + // Display only, so it never posts anything, but it is the reason currency is + // impossible to miss here. + const price = Number.isFinite(watchedPrice) ? Number(watchedPrice) : 0 + const effectiveVat = vatRegistered ? Number(watchedVat) || 0 : 0 + const vatAmount = round2((price * effectiveVat) / 100) + const totalInclVat = round2(price + vatAmount) + const currency = watchedCurrency || 'SEK' + + // Keep the article's own currency selectable even before the fetch resolves + // or if it has since been deactivated. + const currencyCodes = useMemo(() => { + const codes = currencies.map((c) => c.code) + return codes.includes(currency) ? codes : [currency, ...codes] + }, [currencies, currency]) const onFormSubmit = (data: FormData) => { onSubmit({ @@ -205,283 +250,304 @@ export default function ArticleForm({ }) } + const fieldError = (message?: string) => + message ?

{message}

: null + return ( -
- {/* Type + article number */} -
-
- + + + ( - + )} /> -
-
- - + + + - {errors.article_number ? ( -

{errors.article_number.message}

- ) : ( -

{t('number_hint')}

- )} -
-
+ {fieldError(errors.name?.message)} + - {/* Name */} -
- - - {errors.name && ( -

{errors.name.message}

- )} -
+ + + + ( + field.onChange(e.target.value)} + > + {currencyCodes.map((code) => ( + + ))} + + )} + /> + + {fieldError(errors.price_excl_vat?.message)} + - {/* English name */} -
- - -

{t('name_en_hint')}

-
- - {/* Unit + price + VAT (moms hidden for non-momsregistrerade) */} -
-
- + {/* Enhet closes the group when the company charges no moms, so the + group never ends on a dangling hairline. */} + ( - + field.onChange(e.target.value)} + > + {UNITS.map((u) => ( + + ))} + )} /> -
-
- - - {errors.price_excl_vat && ( -

{errors.price_excl_vat.message}

- )} -
+ + {vatRegistered && ( -
- + + ( + field.onChange(Number(e.target.value))} + > + {VAT_RATES.map((rate) => ( + + ))} + + )} + /> + + )} + + + + ( - + setCreateAccountPrefill(prefill)} + /> )} /> -
- )} -
+ {fieldError(errors.revenue_account?.message)} + + - {/* Advanced (collapsible) */} -
- + {moreOpen && ( + + + + {fieldError(errors.article_number?.message)} + - {advancedOpen && ( -
- {/* Revenue account */} -
- + + + + + + (v === '' || v == null ? undefined : Number(v)), + })} + /> + + + + + + + {type === 'tjanst' && ( + ( - setCreateAccountPrefill(prefill)} - /> + onChange={(e) => field.onChange(e.target.value)} + > + + + + )} /> - {errors.revenue_account && ( -

{errors.revenue_account.message}

- )} -

{t('revenue_account_hint')}

-
+ + )} - {/* Cost price */} -
- - (v === '' || v == null ? undefined : Number(v)), - })} - /> -

{t('cost_price_hint')}

-
+ + + + + )} - {/* Currency */} -
- - { - // Always keep the current value selectable, even before the - // fetch resolves or if it's since been deactivated. - const codes = currencies.map((c) => c.code) - const options = codes.includes(field.value) - ? codes - : [field.value, ...codes] - return ( - - ) - }} - /> -

{t('currency_hint')}

-
- - {/* EAN */} -
- - -
- - {/* Housework type (tjänst only) */} - {type === 'tjanst' && ( -
- - ( - - )} - /> -

{t('housework_hint')}

-
- )} - - {/* Notes */} -
- -