Fix/skv connection flow (#1015)

* feat(salary): one-click AGI submission with filing state machine and success feedback

The AGI panel required users to know that "Ladda ner AGI-fil" was the
generate step, then click submit, signing link, and kvittens manually.
A nollkorning filing stalled on "AGI-XML saknas" pointing at a UI path
that does not exist.

- New primary button "Lamna in till Skatteverket" chains the existing
  endpoints client-side: generate XML if missing, POST underlag, poll
  kontrollresultat, create signing link, open Mina Sidor in a tab opened
  synchronously at click (popup-blocker safe). Inline stepper shows each
  step; the four old buttons become collapsed advanced/recovery actions,
  auto-expanded in stale-draft and rejected states. XML download stays
  visible and free for manual filing.
- deriveAgiFilingState() + useAgiSubmission() lift the per-period
  submission record to the run page: the progress rail and salary hero
  now render the real state machine (generated, underlag inskickat,
  vantar pa BankID-signatur, inlamnad med kvittensnummer) instead of
  telling users to "lamna in" an already-submitted declaration.
- Success card with kvittensnummer and signature metadata once signed,
  plus a toast when a poll flips the state while the page is open.
- AGI kvittens cron every 15 min instead of every 2 h so filings signed
  on another device get stamped and emailed promptly.
- Advanced submit also auto-generates, and the stale "Lon -> AGI ->
  Generera" error text now points at the real buttons.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(enable-banking): instant OAuth callback feedback and dead-attempt cleanup

The bank redirect landed on a blank page for the several seconds the
callback spent exchanging the PSD2 session and mirroring accounts, and
every failed connect attempt left a status='error' row that rendered
forever as an "Atgard kravs" card next to a successful retry, showing
duplicate connections to the same bank.

- Stream a branded "Slutfor bankanslutningen" progress page from the
  callback: the shell flushes before the session exchange starts and a
  script/meta redirect follows when the work completes, with a 30s
  slow-work escape hatch. Fast outcomes (denial, bad params, unknown
  state) keep their plain redirects.
- Delete never-activated connection rows (no session_id, no
  accounts_data) on denial or exchange failure, and sweep leftovers for
  the same bank on the next connect. Established connections keep their
  "Atgard krävs" card via the accounts_data guard; FKs are ON DELETE
  SET NULL so deletion has no dependents.
- Show "Banken ar ansluten: hamtar dina konton" while the settings
  panel loads after the callback instead of an anonymous spinner.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(invoices): reject re-send of issued invoices and gate bookkeeping on the sent flip

A direct POST to /api/invoices/[id]/send against an already-issued
invoice re-emailed the customer and posted a second revenue verifikat
(createInvoiceJournalEntry has no dedup), overwriting journal_entry_id
and orphaning the first entry. Only the UI hid the button; the v1 route
and the MCP commit executor already rejected non-drafts.

- Non-draft invoices now return 409 INVOICE_ALREADY_SENT.
- The draft to sent status flip is an optimistic lock (status guard plus
  row-count check); journal entry, accrual schedules, PDF archival and
  the invoice.sent event only run for the request that won the flip.
- On a flip failure the journal entry is deferred: the row stays draft
  and a retry re-runs the pipeline, ending with exactly one verifikat.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(invoices): payment links, failure visibility and sandbox guard for recurring auto-send

- sendInvoiceFromSchedule now auto-creates an online payment link via
  applyPaymentLinkToInvoice before rendering and passes the payment
  link QR to the PDF: parity with the dashboard and v1 send routes,
  which recurring invoices silently lacked.
- The recurring cron persists last_run_warning both when a claimed run
  throws (hourly retries stay visible on the schedule) and when a stale
  schedule is rolled forward, so a deterministic failure can no longer
  skip a month silently.
- Auto-send is blocked for sandbox companies at the email chokepoint
  (freeze-and-retain: the invoice is still generated as a draft),
  covering both the cron and the run-now route with one guard.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(salary): close the Fortnox payroll API gaps (phases 1-4)

Payroll now runs end-to-end through the open API, including onboarding a
client from another payroll system, with every write staged for approval.

- v1: per-employee payslips (list/detail/PDF), payslip line writes,
  run roster attach/remove, absence ranges (per-day storage), jamkning
  fields, cutover opening balances (single + atomic bulk PUT), vacation
  balance + vacation-year-close. PUT added to the wrapper's idempotency/
  test-key set (test keys could otherwise write through PUT).
- MCP: 10 new tools (get_employee/get_payslip/list_absence/
  get_vacation_balance reads + staged update_payslip_line,
  register_absence, create_employee, update_employee,
  set_employee_opening_balances, close_vacation_year), executors, risk
  tiers, op-type CHECK expansions. create_employee encrypts personnummer
  at staging: pending_operations never holds plaintext.
- Scope-map audit retrofit: 11 formerly unmapped tools now scoped;
  BREAKING for keys that relied on the 4 default-allow writes.
- Cutover: employee_opening_balances (derived lock trigger, self-unlocks
  on run correction), engine YTD/karens/liability integration,
  Ingaende saldon section in the employee editor.
- Arbetsschema-lite: employees.hours_per_week/workdays_per_week drive the
  hourly/daily divisors; legacy 173/21 preserved exactly at defaults so
  existing pay math is byte-identical.
- Vacation ledger + semesterberedning/arsavslut: recomputed per-year day
  balances (synced on book/correct, non-fatal), year-close with the
  min-20 floor, 5-year sparade-dagar expiry to forced payout, and a
  2920/2940 drift adjustment via the bookkeeping engine; Semester
  dashboard card with preview-then-confirm dialog.
- Fix: Zod 4 defaults leak through .partial(), which made every sparse
  employee PATCH fail validation and reset defaulted columns.

Migrations 20260713100000/101000/110000/121000/122000 (applied to
staging with version rows; prod via merge). vacation_ledger renamed from
20260713120000 to avoid colliding with vat_declaration_totals_rpc.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* perf: cut dashboard page-load latency (region, round trips, caching, VAT RPC)

The dominant cost was infrastructure: Vercel functions ran in iad1
(Washington D.C.) while Supabase (DB + auth) lives in eu-north-1
(Stockholm), so every request paid 4-5 transatlantic round trips of
auth + company resolution before doing any real work (measured
530-1900ms for single-query GETs in prod logs). Pin functions to arn1
and cut the redundant work on top:

- vercel.json: functions to arn1, same city as the database
- getActiveCompanyId: preference + first-membership queries run in
  parallel; the fallback result doubles as validation in the common
  single-company case (one round trip instead of two sequential)
- withRouteContext: Server-Timing header and authMs/companyMs/handlerMs
  in the op-completed log, so latency is attributable per phase
- dashboard layout: nav badge counts off the critical path; DashboardNav
  loads them client-side via the new use-worklist-badges SWR hook with
  debounced realtime revalidation
- swr (new dependency, approved): global provider; useCompanySettings
  shares one cache entry across consumers and renders from cache on
  back-navigation instead of re-showing skeletons
- /pending: realtime refetch debounced; bulk operations previously
  fired 4 requests per row-change event
- VAT declaration: new get_vat_declaration_totals RPC returns
  per-account totals, settlement-shape detection (#984) and
  source_type counts in ONE round trip instead of paging every
  entry+line through PostgREST. Account lists stay TS-side parameters
  so ACCOUNT_RUTA remains the single source of truth. Shape-exclusion
  coverage moved to tests/pg/vat-declaration-totals-rpc.pg.test.ts;
  DDL already applied to staging.
- bundle: CommandPalette lazy-mounts on first Ctrl/Cmd+K, AgentChat
  dynamic-imports the markdown parser, @vercel/speed-insights (new
  dependency, approved) added for real-user timings

The /salary fetch-waterfall fix from the same effort already landed
inside 2084a756.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(invoices): settle öre-rounded payments from the mark-paid flow

An invoice with öresavrundning shows a rounded "Att betala" on the PDF;
the customer pays that amount (up to 50 öre off the stored öre total) and
the invoice-page mark-paid flow rejected it with
MATCH_AMOUNT_EXCEEDS_REMAINING: a dead end, while the bank-transaction
match flow already absorbed the residual to 3740.

- PaymentBookingDialog now proposes the rounded bank leg plus the 3740
  residual line (credit when rounded up, debit when rounded down),
  resolved via getDisplayTotal from the per-invoice override and
  company_settings.ore_rounding.
- settleInvoicePayment and the v1 mark-paid route absorb the sub-krona
  residual, gated by planInvoicePaymentForLines: absorption applies ONLY
  when the caller lines carry the exact residual on 3740; otherwise the
  strict plan applies (sub-krona partials stay partial, no-3740
  overshoots keep the 400), so the GL can never diverge from the AR
  sub-ledger.
- planInvoicePayment absorb-band boundary tightened to >= 1 kr: an
  exactly-1-kr overshoot used to slip past both the guard and the absorb
  branch and silently over-record paid_amount (pre-existing on the
  bank-match path).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(security): resolve all 7 PR compliance findings

- ASVS V3.3: per-request CSP nonce on the enable-banking finalize page
  (mirrors the mcp-oauth consent page); inline scripts are nonce-bound
- ASVS V16: decouple callback finalize work from the response stream
  (eager promise + next/server after()) so a client disconnect cannot
  drop session persistence or the consent_granted audit emit
- ISO 27001 A.8.15: failed audit-event emits log through the structured
  logger with a stable message for log-based alerting
- ASVS V2.3: recurring-invoice cron and run-now routes resolve
  isSandboxCompany themselves and pass an explicit suppressAutoSend flag
  (defence in depth around the email chokepoint, freeze-and-retain kept)
- ISO 27001 A.8.11: stagePendingOperation rejects plaintext
  personnummer-bearing keys in params/preview_data (key-based guard;
  EF org numbers make value-matching unsafe)
- ASVS V4.5: employee PATCH body is truly sparse; cleared number fields
  are omitted instead of resetting DB values to hardcoded fallbacks
- ASVS V8.2.1: route-level tests pin the v1 cross-company deny (404 by
  convention, not 403) on the payslip PDF endpoint

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat: implement vacation-year basis change validation and error handling

- Added tests to block vacation-year basis changes when open balances exist.
- Implemented error handling for open-balances guard query failures in the settings route.
- Enhanced absence route to reject reversed date ranges with a validation error.
- Updated absence handling to use atomic upserts instead of delete+insert for better performance and reliability.
- Refactored salary calculation logic to correctly handle age-based avgifter rates according to Skatteverket's rules.
- Improved error messaging for vacation year closure adjustments.
- Adjusted employee opening balances handling to preserve audit information during upserts.

* feat(settings): add validation to block vacation-year basis change with open balances

feat(absence): reject reversed date ranges in absence queries

fix(absence): update absence handling to use atomic upserts instead of delete+insert

fix(employee): improve validation for jamkning dates in employee updates

fix(opening-balances): ensure created_by field is preserved during upserts

test(absence): enhance tests for absence range and date validations

test(calculation): add tests for age-based avgifter rates and edge cases

test(semesterberedning): validate vacation year closure adjustments and error handling

test(employee-opening-balances): update tests to reflect changes in salary_run_employees schema

* fix(migrations): implement NOT VALID constraints for pending_operations and add validation migration

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Mattsson
2026-07-13 22:54:33 +02:00
committed by GitHub
parent e7e3c35f9e
commit b6332e9ff4
154 changed files with 18364 additions and 1867 deletions
+35
View File
@@ -115,3 +115,38 @@ One line per decision: `[YYYY-MM-DD] <decision>: <why>`. Appended by agents and
[2026-07-13] postMessage hardening uses event.source identity (popup handle in a ref) plus a verified rebroadcast CustomEvent ('skatteverket-connection-updated') instead of the nonce the SOC2 finding suggested: a window reference cannot be forged by same-origin scripts, so the source check is strictly stronger than a nonce threaded through the OAuth flow, and pages that never open the popup (salary dashboard) consume the rebroadcast from the component that did verify it.
[2026-07-13] Kvittens cron HTTP response drops companyId per row (GDPR minimization) but keeps declarationId: it is an opaque UUID useless without DB access, the endpoint is cron-secret gated + Cache-Control: no-store, and losing it would make per-run ops debugging blind. The extension_data delete race flagged by the swarm is documented-and-accepted: the agi_submission_<period> key is period-scoped by design and agi_declarations is UNIQUE per company+period, so no two declarations share a key.
[2026-07-13] signeradAv (personnummer in agi_declarations.response_data) is documented in .compliance/ropa.yaml under the existing agi.submit entry (Art.6(1)(c), BFL 7 kap 2 par retention) rather than moved to a dedicated column with column-level grants: it is part of the SKV kvittens payload preserved verbatim as rakenskapsinformation, and submitted_by is now explicitly documented as the technical submitter with response_data.signeradAv as the authoritative legal signatory.
[2026-07-13] One-click AGI submission ("Lamna in till Skatteverket") orchestrates the existing panel endpoints client-side (xml -> submit -> kontrollresultat -> granskningsunderlag) instead of reusing the server-side commitSubmitAgi chain: the MCP commit path carries pending-operation semantics (audit rows, monotonic flips, recoverable codes) that the interactive UI does not want to re-plumb, and the client chain preserves the panel's existing per-step error surfaces. Signing tab is opened synchronously at click (placeholder) and navigated on success to dodge popup blockers.
[2026-07-13] Dashboard invoice send route rejects non-drafts (409 INVOICE_ALREADY_SENT) and gates JE/archive/event on winning an optimistic-locked draft->sent flip; on a flip DB error the JE is now DEFERRED (previously posted anyway, and v1 still posts): with the row left in draft, a retry re-runs the pipeline and ends with exactly one verifikat, whereas booking on a failed flip sets up a duplicate JE on retry. Cost: a duplicate customer email on retry, judged cheaper than duplicate revenue.
[2026-07-13] Enable Banking OAuth callback streams an interim "Slutfor bankanslutningen" HTML page (shell first, work, then script/meta redirect) instead of redirecting to settings and finalizing via a client-called endpoint: keeps the one-time authorization code server-side in a single round trip, needs no new endpoint or polling, and the global CSP already permits inline script. The blank-tab gap during createSession + cash-account mirroring was the reported "no loading state after redirect".
[2026-07-13] Failed bank-connect attempts that never activated (status pending/error, no session_id, no accounts_data) are DELETED, not parked as status=error: parked rows rendered forever as "Atgard kravs" cards next to a successful retry, showing duplicate connections to the same bank. Deletion is safe (transactions/cash_accounts FKs are ON DELETE SET NULL; never-activated rows have no dependents) and the stale-pending cron already deletes such rows. Established connections keep the error/expired card via the accounts_data guard.
[2026-07-13] Recurring auto-send sandbox enforcement lives inside sendInvoiceFromSchedule (isSandboxCompany at the email chokepoint, freeze-and-retain: invoice still created as draft) instead of route-level guardSandbox on run-now/create: run-now legitimately generates invoices in the sandbox, only the outbound email is forbidden, and one guard at the chokepoint covers both cron and run-now. Cron failure warnings overwrite (not append to) last_run_warning; the stale roll-forward message wins over the per-attempt failure detail because it carries the actionable state (skipped date + next run + "Skapa faktura nu" hint).
[2026-07-13] Payroll gap-closure: personnummer stays MASKED on the v1 payslip detail endpoint (deviation from the "full value on detail" convention): a payslip is a pay document, not an identity record; the employee master GET remains the only full-pnr drill-in. On MCP, personnummer is masked on EVERY tool (LLM context is a leak surface), incl. encrypt-at-staging for create_employee so pending_operations.params never persists plaintext.
[2026-07-13] Absence v1 API is range-in/per-day-storage: PUT expands [from,to] server-side (weekends skipped by default, 92-day cap) onto the (employee,date,type) natural key. Per-day rows are non-negotiable (karens/aterinsjuknande/hogriskskydd + AGI Franvarouppgift derive from dates); the range payload is pure ergonomics. PUT added to the v1 wrapper's REQUIRES_IDEMPOTENCY set: without it test keys would write through PUT for real.
[2026-07-13] UpdateEmployeeSchema rebuilt on a defaults-stripped base (EmployeeSchemaPatchBase): Zod 4 applies .default() through .partial(), so any sparse PATCH body materialized salary_type='monthly' and failed the byte-till-loneform refinement (latent bug: first surfaced by jamkning-only patches), and routes spreading the parsed body silently reset defaulted columns.
[2026-07-13] Scope-map audit retrofit ships accept-the-break (Emil 2026-07-13): 4 previously unmapped write tools (link_invoice_to_voucher, undo_sie_import, post_annual_depreciation, import_rot_rut_beslut) now require their scopes; keys relying on the default-allow hole lose access. No grandfathering migration (unlike gnubok_remember_fact): these were security holes, not granted capabilities. Release-note callout required.
[2026-07-13] employee_opening_balances lock is DERIVED (trigger checks for a booked run), not a locked_at flag: cannot drift, needs no hook in the two book routes, and self-unlocks when the only booked run is corrected, which is exactly when re-editing cutover state is legitimate. Ongoing sick cases get NO dedicated fields: imported pre-cutover salary_absence_days rows reconstruct segments exactly; only the karens-period count not covered by imports is a field (over-suppression of karens is the softer error).
[2026-07-13] Opening-balances bulk PUT is atomic all-or-nothing (validate every item, 400 with per-item errors and zero writes) rather than 207 partial: byra onboarding wants "all imported or fix the file"; partial success forces the caller to diff. Cutover YTD merges into the engine's ytdByEmployee for display/reports only: verified that AGI is per-run and youth/vaxa avgifter caps are per-month, so no calculation reads YTD.
[2026-07-13] runSalaryCalculation opening-balance merge has no dedicated unit test (no existing mock harness for the full orchestrator; building one is ~15 brittle queued queries): covered by type-check + the pure-function karens tests + vacation-liability tests; the pg-real suite and the E2E cutover smoke are the integration net.
[2026-07-13] MCP tools/list payload ceiling bumped 45.5K -> 50K for the 8 payroll tools: create/update_employee carry the full employee-config schema by design (agent-driveable onboarding is the point); descriptions trimmed to enum-only where self-evident first.
[2026-07-13] Arbetsschema-lite divisors keep the LEGACY constants (173/21) exactly at the default 40h/5d schedule and use the exact 52-week formula only for non-default schedules: switching defaults to exact formulas (173.33/21.67) would shift every running company's monthly-to-hourly derivation ~0.2% and sick/VAB daily deductions ~3% mid-year with zero schedule change. Exact-formula migration deferred to a fiscal-year boundary. Precedence: employment_degree prorates BASE SALARY, hours/workdays per week ONLY drive divisors; deliberately not reconciled.
[2026-07-13] Vacation ledger (employee_vacation_balances) is DAYS-ONLY and RECOMPUTED (never incremented) from booked runs on every book/correct, with lazy seeding from cutover opening balances or the legacy vacation_days_saved (attributed to the previous year: expires earlier, never later). SEK stays derived: persisting a parallel SEK column would create a reconciliation obligation with zero new information. Ledger sync is non-fatal in all three hooks: a ledger bug must never block a legally required booking.
[2026-07-13] Semesterberedning + arsavslut is ONE two-phase verb (dry-run report, then commit), not two: both act on the same year boundary, the review report is only judgeable with both halves, and two verbs would create half-closed states. Days roll books NOTHING (reclassifying days moves no liability); only the 2920/2940 drift adjustment posts, via createJournalEntry with source_type 'salary_payment' + source_id = closure id (no new source_type: avoids a CHECK expansion). Untaken days at/below the 20-day floor are FLAGGED, not auto-saved (Semesterlagen 18 par.); expired 5-year savings become forced_payout_days paid via a normal run.
[2026-07-13] Year-close SEK reconcile uses a day-valued computed liability (simplified BFNAR 2016:10: sammaloneregeln dagslön+tillägg, procentregeln annual basis x 12%/14.4% over entitled days, hourly via hours_per_week) against the trial-balance 2920/2940 closing balances: per-run accruals never relieve 2920 when vacation is taken, so drift accumulates BY DESIGN and the annual adjustment is the correction mechanism. Avgifter on the computed liability use flat 31.42% (per-employee reduced rates called out in the report, not silently applied). vacation_year_closures has NO DELETE policy: the frozen report is the underlag for the adjustment verifikat (BFL 7 kap); reopening is a future explicit feature.
[2026-07-13] company_settings.salary_vacation_year_basis (calendar default | statutory_apr_mar) can only change while ZERO open ledger rows exist (settings PUT guard): rows are keyed by vacation_year_start and a basis flip would orphan them. Settings UI deferred; calendar is right for the segment.
[2026-07-13] Page-load perf: root cause was Vercel functions in iad1 vs Supabase in eu-north-1 (~100ms per DB/auth round trip, measured 530-1900ms for single-query GETs); fix = "regions": ["arn1"] in vercel.json rather than any code-level caching first. Local JWT verification (getClaims + asymmetric keys) deliberately DEFERRED: the region move collapses getUser() to ~1-3ms, so changing auth semantics (revocation window) is not worth it now.
[2026-07-13] VAT declaration aggregation moved into get_vat_declaration_totals RPC (totals + settlement-shape detection + source_type counts in one round trip, SECURITY INVOKER). Account lists stay TS-side parameters (ACCOUNT_RUTA remains the single source of truth; mapping changes must never need a migration). The #984 shape-exclusion unit tests moved to tests/pg/vat-declaration-totals-rpc.pg.test.ts per repo convention (RPC behavior is pg-tested, not mocked).
[2026-07-13] Client data caching: chose SWR over TanStack Query (lighter, fetch-shaped hooks fit the codebase; MIT). Converted useCompanySettings + nav badges; pending page realtime stampede fixed with a 400ms trailing debounce instead of a full SWR rewrite of the page.
[2026-07-13] next-intl message splitting SKIPPED: ~25 of the main pages are client components, so nearly every namespace is needed client-side; pick()-based splitting would save little and risk MISSING_MESSAGE regressions. Real win would be route-level splitting, deferred until more pages are server components.
[2026-07-13] Öresavrundning in mark-paid: absorbing the sub-krona residual is gated on the caller lines actually carrying it on 3740 (planInvoicePaymentForLines); mismatch FALLS BACK to the strict plan instead of rejecting, so deliberate sub-krona partials keep working and no-3740 overshoots keep the pre-change 400. Rejecting outright would have broken the documented v1 partial-payment flow.
[2026-07-13] planInvoicePayment absorb-band boundary fixed to >= 1 kr (exactly-1-kr overshoot used to slip past both the guard and the absorb branch and over-record paid_amount). Supplier mirror planSupplierPayment deliberately NOT touched (same hole exists, reachable via match-supplier-invoice; separate change to keep this diff scoped). v1 match-invoice + MCP match paths still reject öre overshoot on bank matches (their line builders lack 3740); also deferred.
[2026-07-13] Compliance-report V8.2.1 remediation (403 on cross-company v1 access) REJECTED in favor of the existing 404: withApiV1 already enforces key-user-to-URL-company membership, and 404 deliberately avoids leaking which company ids exist. Pinned with route-level tests on the payslip PDF endpoint instead.
[2026-07-13] CSP unsafe-inline (ASVS V3.3): fixed with a per-request nonce CSP on the enable-banking finalize page (mirrors mcp-oauth consent page); removing 'unsafe-inline' from the GLOBAL next.config script-src is deferred, it requires an app-wide middleware nonce pipeline covering the Next.js bootstrap plus recapt/Enable Banking scripts and carries real breakage risk.
[2026-07-13] Recurring-invoice sandbox defence-in-depth: cron and run-now routes now resolve isSandboxCompany themselves and pass an explicit suppressAutoSend flag to executeRecurringSchedule, keeping the internal email chokepoint as the second layer. A route-level guardSandbox 403 was rejected: the cron is cross-company (no single company context) and sandbox schedules must still draft invoices (freeze-and-retain).
[2026-07-13] pending_operations PII chokepoint (ISO 27001 A.8.11): stagePendingOperation now rejects plaintext personnummer-bearing KEYS in params/preview_data. Key-based, not value-based detection: enskild firma org numbers ARE personnummer, so value-pattern matching would false-positive on legitimate counterparty data.
[2026-07-13] Enable-banking callback finalize work decoupled from the response stream (eager promise + next/server after()) so a client disconnect cannot abort session persistence or the consent_granted audit emit. A persistent outbox/dead-letter for audit events was rejected as disproportionate; failed emits now log through the structured logger for alerting (A.8.15).
[2026-07-13] Declined CodeRabbit suggestion to rewrite migration 20260713100000 (pending_operations CHECK) as NOT VALID + VALIDATE: the migration is already committed (2084a756) and applied to staging, and modifying shipped migrations is forbidden; pending_operations is small enough that the brief lock is a non-issue.
[2026-07-13] Vacation year close: 2940 target now uses per-employee age-tier avgifter (0% born <=1937, 10.21% fyllt 67 vid årets ingång at the settlement year, else 31.42%) instead of flat 31.42% (compliance-review finding): per-run accruals already credit 2940 at each employee's actual rate, so a flat target would "correct" a right booked balance to a wrong one for companies with 67+ staff. The temporary youth discount is deliberately NOT provisioned (payment-month- and cap-dependent, expires Sep 2027; the full rate is the prudent target per ÅRL försiktighetsprincipen), so youth accruals show a top-up drift at close.
[2026-07-13] calculateAgeAtYearStart is now birth-year based ((year - 1) - birth year) instead of birthday-inclusive age at Jan 1: Skatteverket applies "vid årets ingång fyllt X" rules as birth-year ranges (2026 youth cohort = born 2003-2007), and the old semantics misclassified employees born exactly on January 1 in both directions (born 2008-01-01 wrongly youth-rated, which AGI validation rejects; born 2003-01-01 wrongly standard-rated; born 1959-01-01 wrongly given the 67+ reduction a year early).
[2026-07-13] employee_opening_balances created_by preserved via read-then-upsert, not a DB trigger: a BEFORE UPDATE trigger would need a new migration for a pure audit concern; the extra select is one indexed query and the lock trigger already backstops races.
[2026-07-13] Opening balances are authoritative for pre-cutover YTD: runSalaryCalculation now excludes booked runs before the cutover month from the YTD aggregation for employees with opening balances, instead of blocking pre-cutover backdated runs (backfill of history is a supported flow).
[2026-07-13] Superseded the 2026-07-13 decline of the NOT VALID suggestion for migration 20260713100000: Emil asked to resolve the PR findings, and the migration is branch-only (verified absent from prod schema_migrations), so the never-modify-shipped-migrations rule does not apply; staging already recorded the versions, so edits only change what prod runs at merge. Implemented as ADD ... NOT VALID in 20260713100000 + 20260713121000 with VALIDATE split into 20260713123000: VALIDATE in the same transaction as ADD would be a no-op since Postgres holds the ACCESS EXCLUSIVE lock until commit; a separate migration file gets its own transaction and validates under SHARE UPDATE EXCLUSIVE. 20260713123000 applied to staging (no-op VALIDATE) and version recorded.
+6 -16
View File
@@ -7,7 +7,7 @@ import CompanyTabSync from '@/components/dashboard/CompanyTabSync'
import { RecaptIdentify } from '@/components/RecaptIdentify'
import { AgentSheetProvider } from '@/components/agent/AgentSheetProvider'
import AgentTrigger from '@/components/agent/AgentTrigger'
import CommandPalette from '@/components/common/CommandPalette'
import LazyCommandPalette from '@/components/common/LazyCommandPalette'
import { SettingsHotkey } from '@/components/settings/SettingsHotkey'
import { SandboxBanner } from '@/components/dashboard/SandboxBanner'
import { getExtensionNavItems } from '@/lib/extensions/sectors'
@@ -16,7 +16,6 @@ import { getActiveCompanyId } from '@/lib/company/context'
import { getCompanyEntitlements } from '@/lib/entitlements/has-capability'
import { getBranding } from '@/lib/branding/service'
import { ensureSandboxAgentProfile } from '@/lib/sandbox/ensure-agent'
import { countPendingOperations, countUnbookedTransactions } from '@/lib/worklist'
import type { EntityType, CompanyRole, Team } from '@/types'
/**
@@ -99,8 +98,6 @@ export default async function DashboardLayout({
<DashboardNav
companyName={getBranding().appName.toLowerCase()}
entityType="enskild_firma"
uncategorizedTransactionCount={0}
pendingOperationsCount={0}
isSandbox={false}
extensionNavItems={getExtensionNavItems()}
/>
@@ -131,8 +128,6 @@ export default async function DashboardLayout({
{ data: memberRow },
{ data: allMemberships },
{ data: settings },
uncategorizedCount,
pendingOpsCount,
{ data: agentProfileIdentity },
{ data: userProfile },
entitlements,
@@ -146,11 +141,10 @@ export default async function DashboardLayout({
.select('company_name, onboarding_complete, entity_type, pays_salaries, is_sandbox, dimensions_enabled')
.eq('company_id', companyId)
.single(),
// Shared worklist predicates (lib/worklist), the badge must show the
// same number as every other "att göra" surface. Notably this excludes
// is_ignored rows, which the old inline query here did not.
countUnbookedTransactions(supabase, companyId),
countPendingOperations(supabase, companyId),
// Nav badge counts (unbooked transactions, pending operations) are NOT
// fetched here anymore: DashboardNav loads them client-side after mount
// (lib/hooks/use-worklist-badges) so two head-count queries stop blocking
// first paint on every dashboard navigation.
// Agent identity, name + avatar, surfaced on the FAB and chat
// surfaces. Null when no agent_profile exists yet (banner CTA path).
supabase
@@ -203,8 +197,6 @@ export default async function DashboardLayout({
<DashboardNav
companyName={getBranding().appName.toLowerCase()}
entityType="enskild_firma"
uncategorizedTransactionCount={0}
pendingOperationsCount={0}
isSandbox={false}
extensionNavItems={getExtensionNavItems()}
/>
@@ -311,8 +303,6 @@ export default async function DashboardLayout({
entityType={entityType}
paysSalaries={paysSalaries}
dimensionsEnabled={dimensionsEnabled}
uncategorizedTransactionCount={uncategorizedCount}
pendingOperationsCount={pendingOpsCount}
isSandbox={isSandbox}
extensionNavItems={getExtensionNavItems()}
userName={userProfile?.full_name ?? null}
@@ -322,7 +312,7 @@ export default async function DashboardLayout({
<MainContainer companyId={companyId}>{children}</MainContainer>
</main>
<AgentTrigger />
<CommandPalette />
<LazyCommandPalette />
<SettingsHotkey />
{settingsModal}
</div>
+10 -2
View File
@@ -714,20 +714,28 @@ export default function PendingOperationsPage() {
// in-place) so server-side filtering, sorting, and computed fields stay in
// sync with whatever the API route returned. The counts endpoint isn't
// pushed by the same trigger, so we also refresh counts on every change.
// Trailing debounce: bulk actions emit one event per row, which previously
// stampeded 4 requests per event (list + 3 counts); the burst now collapses
// into a single refetch after the last event.
useEffect(() => {
const supabase = createClient()
let debounce: ReturnType<typeof setTimeout> | null = null
const channel = supabase
.channel('pending_operations:list')
.on(
'postgres_changes',
{ event: '*', schema: 'public', table: 'pending_operations' },
() => {
fetchOperations()
fetchAllCounts()
if (debounce) clearTimeout(debounce)
debounce = setTimeout(() => {
fetchOperations()
fetchAllCounts()
}, 400)
}
)
.subscribe()
return () => {
if (debounce) clearTimeout(debounce)
void supabase.removeChannel(channel)
}
}, [fetchOperations, fetchAllCounts])
+22 -3
View File
@@ -24,6 +24,7 @@ import {
} from '@/lib/salary/payment/bank-account'
import type { Employee } from '@/types'
import { EmployeeBenefitsPanel } from '@/components/salary/EmployeeBenefitsPanel'
import { OpeningBalancesPanel } from '@/components/salary/OpeningBalancesPanel'
import EmployeeTaxCard, { type EmployeeTaxValue } from '@/components/salary/EmployeeTaxCard'
import LineDimensionFields from '@/components/dimensions/LineDimensionFields'
@@ -124,12 +125,18 @@ export default function EmployeeDetailPage({ params }: { params: Promise<{ id: s
employment_type: employmentType,
employment_start: form.get('employment_start') as string || undefined,
employment_end: form.get('employment_end') as string || undefined,
employment_degree: parseFloat(form.get('employment_degree') as string) || 100,
// Sparse patch: an empty/cleared field is OMITTED (undefined keys are
// dropped by JSON.stringify) so the server's patch schema leaves the
// column unchanged. Hardcoded fallbacks here would silently reset real
// DB values on submit.
employment_degree: parseFloat(form.get('employment_degree') as string) || undefined,
hours_per_week: parseFloat(form.get('hours_per_week') as string) || undefined,
workdays_per_week: parseFloat(form.get('workdays_per_week') as string) || undefined,
salary_type: salaryType,
f_skatt_status: tax?.f_skatt_status,
is_sidoinkomst: tax?.is_sidoinkomst,
tax_table_number: tax?.tax_table_number ?? undefined,
tax_column: tax?.tax_column ?? 1,
tax_column: tax?.tax_column ?? undefined,
tax_municipality: tax?.tax_municipality || undefined,
email: form.get('email') as string || undefined,
phone: form.get('phone') as string || undefined,
@@ -139,7 +146,7 @@ export default function EmployeeDetailPage({ params }: { params: Promise<{ id: s
clearing_number: normalizeBankNumber(clearing) || undefined,
bank_account_number: normalizeBankNumber(account) || undefined,
vacation_rule: vacationRule,
vacation_days_per_year: parseInt(form.get('vacation_days_per_year') as string) || 25,
vacation_days_per_year: parseInt(form.get('vacation_days_per_year') as string) || undefined,
// Always sent: {} clears the employee's default dimensions.
default_dimensions: dimensions,
}
@@ -291,6 +298,15 @@ export default function EmployeeDetailPage({ params }: { params: Promise<{ id: s
<Label htmlFor="employment_degree">{t('form_employment_degree')}</Label>
<Input id="employment_degree" name="employment_degree" type="number" defaultValue={employee.employment_degree} min="1" max="100" disabled={!canWrite} />
</div>
<div className="space-y-2">
<Label htmlFor="hours_per_week">{t('form_hours_per_week')}</Label>
<Input id="hours_per_week" name="hours_per_week" type="number" defaultValue={employee.hours_per_week ?? 40} min="1" max="80" step="0.5" disabled={!canWrite} />
<p className="text-xs text-muted-foreground">{t('form_work_schedule_hint')}</p>
</div>
<div className="space-y-2">
<Label htmlFor="workdays_per_week">{t('form_workdays_per_week')}</Label>
<Input id="workdays_per_week" name="workdays_per_week" type="number" defaultValue={employee.workdays_per_week ?? 5} min="1" max="7" step="1" disabled={!canWrite} />
</div>
<div className="space-y-2">
<Label htmlFor="salary_type">{t('form_salary_type')}<RequiredMark /></Label>
<Select value={salaryType} onValueChange={setSalaryType} disabled={!canWrite}>
@@ -453,6 +469,9 @@ export default function EmployeeDetailPage({ params }: { params: Promise<{ id: s
{/* Benefits */}
<EmployeeBenefitsPanel employeeId={id} canWrite={canWrite} />
{/* Ingående saldon (payroll cutover) */}
<OpeningBalancesPanel employeeId={id} canWrite={canWrite} />
{canWrite && (
<div className="flex justify-end gap-3">
<Button variant="outline" asChild>
+70 -38
View File
@@ -15,6 +15,9 @@ import { ArrowRight, CalendarClock, CheckCircle2, HandCoins, Loader2, Plus, User
import { PageHeader } from '@/components/ui/page-header'
import { useToast } from '@/components/ui/use-toast'
import { useCanWrite } from '@/lib/hooks/use-can-write'
import { VacationBalanceCard } from '@/components/salary/VacationBalanceCard'
import { useAgiSubmission } from '@/lib/hooks/use-agi-submission'
import { deriveAgiFilingState } from '@/lib/salary/agi-submission-state'
import { useCompany } from '@/contexts/CompanyContext'
import { getErrorMessage } from '@/lib/errors/get-error-message'
import { formatCurrency, formatDate } from '@/lib/utils'
@@ -65,50 +68,52 @@ export default function SalaryPage() {
const tp = useTranslations('salary_payments')
const load = useCallback(async () => {
const [runsRes, empRes, settingsRes] = await Promise.all([
fetch('/api/salary/runs'),
fetch('/api/salary/employees'),
fetch('/api/settings'),
// Everything loads in parallel; the tax-payment fetch is the only
// dependent request and chains directly off the runs response instead of
// waiting for the whole batch. Was three sequential legs (batch → tax
// payment → SKV status), now the longest chain is runs → tax payment.
const runsPromise: Promise<SalaryRun[]> = fetch('/api/salary/runs')
.then(async res => (res.ok ? (await res.json()).data || [] : []))
.catch(() => [])
// Latest booked run drives the "skatt att betala" card. Resolves to
// undefined ("leave state unchanged") when there is no booked run or the
// fetch fails, mirroring the old sequential behavior on reloads.
const taxPaymentPromise: Promise<TaxPaymentState | null | undefined> = runsPromise
.then(async loadedRuns => {
const latestBooked = loadedRuns.find(r => r.status === 'booked')
if (!latestBooked) return undefined
const period = `${latestBooked.period_year}-${String(latestBooked.period_month).padStart(2, '0')}`
const txRes = await fetch(`/api/skatteverket/tax-payments/${period}`)
if (!txRes.ok) return undefined
return (await txRes.json()).data ?? null
})
.catch(() => undefined)
const [loadedRuns, taxPaymentData, empRes, settingsRes, skvStatus] = await Promise.all([
runsPromise,
taxPaymentPromise,
fetch('/api/salary/employees').catch(() => null),
fetch('/api/settings').catch(() => null),
// Connection health for the tax card hint. Only needs_reconsent counts:
// the routine short-lived token expiry is normal and must not nag. Any
// failure (extension disabled → 503, network) silently means no hint.
fetch('/api/extensions/ext/skatteverket/status')
.then(res => (res.ok ? res.json() : null))
.catch(() => null),
])
let loadedRuns: SalaryRun[] = []
if (runsRes.ok) {
const { data } = await runsRes.json()
loadedRuns = data || []
setRuns(loadedRuns)
}
if (empRes.ok) {
setRuns(loadedRuns)
if (taxPaymentData !== undefined) setTaxPayment(taxPaymentData)
if (empRes?.ok) {
const { data } = await empRes.json()
setEmployees(data || [])
}
if (settingsRes.ok) {
if (settingsRes?.ok) {
const { data } = await settingsRes.json()
if (typeof data?.salary_pay_day === 'number') setPayDay(data.salary_pay_day)
}
// Latest booked run drives the "skatt att betala" card.
const latestBooked = loadedRuns.find(r => r.status === 'booked')
if (latestBooked) {
const period = `${latestBooked.period_year}-${String(latestBooked.period_month).padStart(2, '0')}`
const txRes = await fetch(`/api/skatteverket/tax-payments/${period}`)
if (txRes.ok) {
const tx = await txRes.json()
setTaxPayment(tx.data)
}
}
// Connection health for the tax card hint. Only needs_reconsent counts:
// the routine short-lived token expiry is normal and must not nag. Any
// failure (extension disabled → 503, network) silently means no hint.
try {
const statusRes = await fetch('/api/extensions/ext/skatteverket/status')
if (statusRes.ok) {
const status = await statusRes.json()
setSkvNeedsReconsent(status?.needsReconsent === true)
}
} catch {
// Extension unavailable: no hint.
}
if (skvStatus) setSkvNeedsReconsent(skvStatus.needsReconsent === true)
setLoading(false)
}, [])
@@ -151,6 +156,16 @@ export default function SalaryPage() {
.then(({ data }) => setAgiDeadline(data ?? null))
}, [company])
// The active run's AGI submission record: lets the hero distinguish
// "lämna in till Skatteverket" from "väntar på din BankID-signatur".
// Only fetched while a booked run is still unfiled; null otherwise.
const activeRun = runs.find(r => r.status !== 'corrected')
const { submission: agiSubmission } = useAgiSubmission(
activeRun && activeRun.status === 'booked' && !activeRun.agi_submitted_at
? `${activeRun.period_year}${String(activeRun.period_month).padStart(2, '0')}`
: null,
)
// One-click run creation: the API seeds all active employees, calculates,
// and resolves period/pay-date/series defaults from settings.
async function startRun() {
@@ -244,7 +259,8 @@ export default function SalaryPage() {
}
// ── Hero state machine (first match wins) ────────────────────────────────
const activeRun = runs.find(r => r.status !== 'corrected')
// activeRun is derived above the loading return (the AGI submission hook
// needs it before any early return).
const latestBooked = runs.find(r => r.status === 'booked')
const periodOf = (r: SalaryRun) => `${r.period_year}-${String(r.period_month).padStart(2, '0')}`
@@ -315,6 +331,19 @@ export default function SalaryPage() {
}
}
if (activeRun && activeRun.status === 'booked' && !activeRun.agi_submitted_at) {
// The underlag may already be at Skatteverket waiting for a BankID
// signature: telling the user to "lämna in" something they already
// submitted reads as a broken flow. Follow the real filing state.
const agiState = deriveAgiFilingState(activeRun, agiSubmission)
if (agiState === 'awaiting_signing' || agiState === 'underlag_submitted') {
return {
kind: 'cta',
title: t('hero_agi_signing_title', { period: periodOf(activeRun) }),
description: t('hero_agi_signing_description'),
label: t('hero_agi_signing_action'),
runId: activeRun.id,
}
}
return {
kind: 'cta',
title: t('hero_agi_title', { period: periodOf(activeRun) }),
@@ -409,7 +438,7 @@ export default function SalaryPage() {
)}
{/* Attention cards */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
<Card>
<CardContent className="p-4">
<div className="flex items-center gap-2 mb-2">
@@ -504,6 +533,9 @@ export default function SalaryPage() {
)}
</CardContent>
</Card>
{/* Semester (vacation ledger + year close): payroll gap-closure 3.5 */}
<VacationBalanceCard canWrite={canWrite} />
</div>
{/* History */}
+20
View File
@@ -17,6 +17,8 @@ import {
import { AlertTriangle, Download, Loader2 } from 'lucide-react'
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 { getErrorMessage } from '@/lib/errors/get-error-message'
import { AGIPanel } from '@/components/salary/AGIPanel'
import { PaymentFilePanel } from '@/components/salary/PaymentFilePanel'
@@ -55,6 +57,16 @@ export default function SalaryRunPage({ params }: { params: Promise<{ id: string
tax_paid_at: string | null
} | null>(null)
// Skatteverket's per-period AGI submission record: drives the AGI step on
// the progress rail and the panel's state machine (underlag submitted /
// awaiting BankID signature / signed). Only booked runs can file AGI, so
// the fetch is skipped (null period) for everything else.
const { submission: agiSubmission, refresh: refreshAgiSubmission } = useAgiSubmission(
run && run.status === 'booked'
? `${run.period_year}${String(run.period_month).padStart(2, '0')}`
: null,
)
async function loadRun() {
const res = await fetch(`/api/salary/runs/${id}`)
if (res.ok) {
@@ -494,6 +506,10 @@ export default function SalaryRunPage({ params }: { params: Promise<{ id: string
// hidden - mirrors the pain.001 / BG-LB generators, which emit no rows here.
const noPayout = isCalculated && Math.round((run.total_net ?? 0) * 100) === 0
// 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)
// Advancing a draft to review. For a nollkörning confirm first: an empty
// declaration is filed to Skatteverket, which should be deliberate.
function handleToReview() {
@@ -536,6 +552,8 @@ export default function SalaryRunPage({ params }: { params: Promise<{ id: string
run={run}
isCalculated={isCalculated}
noPayout={noPayout}
agiState={agiState}
agiKvittensnummer={agiSubmission?.kvittensnummer ?? null}
canWrite={canWrite}
actionLoading={actionLoading}
primaryAction={primaryAction}
@@ -623,6 +641,8 @@ export default function SalaryRunPage({ params }: { params: Promise<{ id: string
period={`${run.period_year}${String(run.period_month).padStart(2, '0')}`}
agiGeneratedAt={run.agi_generated_at}
agiSubmittedAt={run.agi_submitted_at}
submission={agiSubmission}
onRefreshSubmission={refreshAgiSubmission}
readOnly={!canWrite}
onChange={loadRun}
/>
+9 -1
View File
@@ -1,5 +1,13 @@
import { Suspense } from 'react'
import { BankingSettingsContent } from '@/components/settings/sections/BankingSettingsContent'
// BankingSettingsContent (and the extension panel it hosts) reads
// useSearchParams for the OAuth-callback ?select_accounts param; without a
// Suspense boundary that de-opts the whole route to client rendering.
export default function BankingSettingsPage() {
return <BankingSettingsContent />
return (
<Suspense>
<BankingSettingsContent />
</Suspense>
)
}
@@ -50,7 +50,7 @@ function makeRequest(params: Record<string, string>) {
function mockChain(result: { data?: unknown; error?: unknown }) {
const chain: Record<string, unknown> = {}
for (const m of ['select', 'eq', 'in', 'single', 'update', 'order', 'limit']) {
for (const m of ['select', 'eq', 'in', 'is', 'single', 'update', 'delete', 'order', 'limit']) {
chain[m] = vi.fn().mockReturnValue(chain)
}
chain.single = vi.fn().mockResolvedValue({ data: result.data ?? null, error: result.error ?? null })
@@ -98,14 +98,17 @@ describe('GET /api/extensions/enable-banking/callback', () => {
expect(location).toContain('bank_error=invalid_state')
})
it('writes pending_selection and redirects to picker on success', async () => {
it('writes pending_selection and streams a finalizing page that redirects to the picker', async () => {
const capturedUpdates: Record<string, unknown>[] = []
let callIndex = 0
mockFrom.mockImplementation(() => {
callIndex++
if (callIndex === 1) {
// Find pending connection by oauth_state
return mockChain({ data: { id: 'conn-1', user_id: 'user-1', company_id: 'company-1' }, error: null })
return mockChain({
data: { id: 'conn-1', user_id: 'user-1', company_id: 'company-1', bank_name: 'TestBank', status: 'pending' },
error: null,
})
}
// Update connection: capture the payload, then chain returns the
// updated row via .select().single() for the audit event emission.
@@ -143,11 +146,27 @@ describe('GET /api/extensions/enable-banking/callback', () => {
const response = await GET(makeRequest({ code: 'auth-code', state: 'valid-state' }))
expect(response.status).toBe(307)
const location = response.headers.get('location') || ''
expect(location).toContain('/settings/banking?')
expect(location).toContain('select_accounts=conn-1')
expect(location).not.toContain('bank_connected=true')
// Success streams an interim page (instant feedback during the session
// exchange) that ends with a client-side redirect to the account picker.
expect(response.status).toBe(200)
expect(response.headers.get('content-type')).toContain('text/html')
expect(response.headers.get('cache-control')).toBe('no-store')
const body = await response.text()
// Shell flushed with the bank name, then the redirect to the picker.
expect(body).toContain('TestBank')
expect(body).toContain('window.location.replace')
expect(body).toContain('select_accounts=conn-1')
expect(body).not.toContain('bank_error')
// ASVS V3.3: inline scripts are nonce-bound. The response-level CSP
// declares the nonce and BOTH chunks (shell watchdog + redirect) carry
// it; no un-nonced inline script may exist on this page.
const csp = response.headers.get('content-security-policy') ?? ''
const nonceMatch = /script-src 'nonce-([^']+)'/.exec(csp)
expect(nonceMatch).not.toBeNull()
const nonce = nonceMatch![1]
expect(body.split(`<script nonce="${nonce}">`).length - 1).toBe(2)
expect(body).not.toContain('<script>')
// Verify the update payload: status=pending_selection, no last_synced_at,
// and every account defaults to enabled=true so the picker can simply
@@ -185,7 +204,10 @@ describe('GET /api/extensions/enable-banking/callback', () => {
mockFrom.mockImplementation((table: string) => {
callIndex++
if (callIndex === 1) {
return mockChain({ data: { id: 'conn-1', user_id: 'user-1', company_id: 'company-1' }, error: null })
return mockChain({
data: { id: 'conn-1', user_id: 'user-1', company_id: 'company-1', bank_name: 'TestBank', status: 'expired' },
error: null,
})
}
if (table === 'cash_accounts') {
// Already mirrored on a previous connect — acc-1 was remapped to 1935
@@ -218,7 +240,9 @@ describe('GET /api/extensions/enable-banking/callback', () => {
const response = await GET(makeRequest({ code: 'auth-code', state: 'valid-state' }))
expect(response.status).toBe(307)
expect(response.status).toBe(200)
const body = await response.text()
expect(body).toContain('select_accounts=conn-1')
// No allocation for an already-mirrored account; the upsert reuses 1935.
expect(mockAllocate).not.toHaveBeenCalled()
expect(mockUpsertFromPsd2).toHaveBeenCalledTimes(1)
@@ -227,6 +251,77 @@ describe('GET /api/extensions/enable-banking/callback', () => {
).toBe('1935')
})
it('deletes the fresh row and streams an error redirect when the session exchange fails', async () => {
const deleteCalls: unknown[] = []
const updateCalls: unknown[] = []
mockFrom.mockImplementation(() => {
const chain = mockChain({
data: { id: 'conn-1', user_id: 'user-1', company_id: 'company-1', bank_name: 'TestBank', status: 'pending' },
error: null,
})
chain.delete = vi.fn(() => {
deleteCalls.push('delete')
return chain
})
chain.update = vi.fn((payload: unknown) => {
updateCalls.push(payload)
return chain
})
return chain
})
mockCreateSession.mockRejectedValue(new Error('upstream timeout'))
const response = await GET(makeRequest({ code: 'auth-code', state: 'valid-state' }))
expect(response.status).toBe(200)
const body = await response.text()
// The streamed redirect carries the failure to the settings banner.
expect(body).toContain('window.location.replace')
expect(body).toContain('bank_error=')
expect(body).not.toContain('select_accounts=')
// A never-activated attempt is deleted, not parked as a zombie 'error'
// row that would render next to a successful retry as a duplicate.
expect(deleteCalls).toHaveLength(1)
expect(updateCalls).toHaveLength(0)
})
it('marks a reconnect row as error (not deleted) when the session exchange fails', async () => {
const deleteCalls: unknown[] = []
const updateCalls: Record<string, unknown>[] = []
mockFrom.mockImplementation(() => {
const chain = mockChain({
data: { id: 'conn-1', user_id: 'user-1', company_id: 'company-1', bank_name: 'TestBank', status: 'expired' },
error: null,
})
chain.delete = vi.fn(() => {
deleteCalls.push('delete')
return chain
})
chain.update = vi.fn((payload: Record<string, unknown>) => {
updateCalls.push(payload)
return chain
})
return chain
})
mockCreateSession.mockRejectedValue(new Error('upstream timeout'))
const response = await GET(makeRequest({ code: 'auth-code', state: 'valid-state' }))
expect(response.status).toBe(200)
const body = await response.text()
expect(body).toContain('bank_error=')
// An established connection keeps its row (history, accounts) and gets
// the error surfaced on it instead.
expect(deleteCalls).toHaveLength(0)
expect(updateCalls).toHaveLength(1)
expect(updateCalls[0].status).toBe('error')
expect(updateCalls[0].oauth_state).toBeNull()
})
it('redirects with error when bank returns error param (no state)', async () => {
const response = await GET(makeRequest({ error: 'access_denied', error_description: 'User cancelled' }))
@@ -257,10 +352,75 @@ describe('GET /api/extensions/enable-banking/callback', () => {
expect(mockFrom).toHaveBeenCalledWith('bank_connections')
})
it('deletes a fresh pending row on bank denial instead of parking it in error', async () => {
const deleteCalls: unknown[] = []
const updateCalls: unknown[] = []
mockFrom.mockImplementation(() => {
const chain = mockChain({
data: { id: 'conn-1', user_id: 'user-1', bank_name: 'TestBank', psu_type: 'business', status: 'pending' },
error: null,
})
chain.delete = vi.fn(() => {
deleteCalls.push('delete')
return chain
})
chain.update = vi.fn((payload: unknown) => {
updateCalls.push(payload)
return chain
})
return chain
})
const response = await GET(makeRequest({
error: 'access_denied',
error_description: 'User cancelled',
state: 'pending-state',
}))
expect(response.status).toBe(307)
const location = response.headers.get('location') || ''
// URLSearchParams encodes spaces as '+', unlike the encodeURIComponent
// fallback used when no matching row exists.
expect(location).toContain('bank_error=User+cancelled')
expect(deleteCalls).toHaveLength(1)
expect(updateCalls).toHaveLength(0)
})
it('keeps a reconnect row on bank denial and marks it expired on session-expiry errors', async () => {
const deleteCalls: unknown[] = []
const updateCalls: Record<string, unknown>[] = []
mockFrom.mockImplementation(() => {
const chain = mockChain({
data: { id: 'conn-1', user_id: 'user-1', bank_name: 'TestBank', psu_type: 'business', status: 'expired' },
error: null,
})
chain.delete = vi.fn(() => {
deleteCalls.push('delete')
return chain
})
chain.update = vi.fn((payload: Record<string, unknown>) => {
updateCalls.push(payload)
return chain
})
return chain
})
const response = await GET(makeRequest({
error: 'server_error',
error_description: 'Session expired at ASPSP',
state: 'pending-state',
}))
expect(response.status).toBe(307)
expect(deleteCalls).toHaveLength(0)
expect(updateCalls).toHaveLength(1)
expect(updateCalls[0].status).toBe('expired')
})
it('forwards bank_error_code and psu_type when the denied state matches a pending connection', async () => {
mockFrom.mockImplementation(() =>
mockChain({
data: { id: 'conn-1', user_id: 'user-1', bank_name: 'Handelsbanken', psu_type: 'business' },
data: { id: 'conn-1', user_id: 'user-1', bank_name: 'Handelsbanken', psu_type: 'business', status: 'pending' },
error: null,
})
)
@@ -0,0 +1,185 @@
/**
* Interim "finalizing" page for the Enable Banking OAuth callback.
*
* The callback has seconds of unavoidable server work between the bank's
* redirect and our own (session exchange with Enable Banking, account
* mirroring, audit events). A classic 307 would leave the user staring at a
* blank browser tab for that whole window, which reads as "the connection
* failed" and provokes retries (and, historically, duplicate connections).
*
* Instead the route streams this page in two chunks:
* 1. renderFinalizeShell() - flushed immediately, before any slow work:
* branded spinner + "Slutför anslutningen".
* 2. renderFinalizeRedirect() - flushed when the work is done: script +
* meta-refresh + visible fallback link that
* navigates to the settings page.
*
* The page is standalone HTML (no app bundle), styled to match the editorial
* monochrome design system; see app/api/mcp-oauth/authorize for the sibling
* standalone page this mirrors. Swedish-only, like the rest of the
* enable-banking extension surfaces.
*
* Inline scripts are nonce-bound (ASVS V3.3): the route generates a
* per-request nonce, stamps it on every <script> tag here, and sets a
* response-level CSP with script-src 'nonce-...'. The global next.config CSP
* (which still carries 'unsafe-inline' for the app bundle) also applies;
* browsers enforce the intersection, so an injected inline script without
* the nonce is blocked on this response.
*/
function escapeHtml(value: string): string {
return value
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;')
}
/**
* Opening chunk: full document head, styles, spinner and heading, plus a
* watchdog that reveals an escape hatch if the server work takes abnormally
* long (dead function, hung upstream). Deliberately does NOT close <body>:
* the redirect chunk does. `cspNonce` must match the script-src nonce the
* route puts in the response's Content-Security-Policy header.
*/
export function renderFinalizeShell(bankName: string | null, cspNonce: string): string {
const heading = bankName
? `Slutf&ouml;r anslutningen till ${escapeHtml(bankName)}&hellip;`
: 'Slutf&ouml;r bankanslutningen&hellip;'
return `<!DOCTYPE html>
<html lang="sv">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="robots" content="noindex, nofollow">
<meta name="color-scheme" content="light">
<title>Slutf&ouml;r bankanslutningen</title>
<style>
:root {
--bg: hsl(0 0% 100%);
--border: hsl(45 5% 85%);
--fg: hsl(0 0% 9%);
--fg-muted: hsl(0 0% 40%);
--fg-faint: hsl(0 0% 55%);
--warm-accent: hsl(38 45% 52%);
}
* { margin: 0; padding: 0; box-sizing: border-box; }
html { -webkit-text-size-adjust: 100%; }
body {
font-family: 'Geist', -apple-system, system-ui, 'Segoe UI', sans-serif;
background: var(--bg);
color: var(--fg);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 100vh;
padding: 2rem 1.5rem;
font-size: 14px;
line-height: 1.5;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
main {
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
max-width: 26rem;
}
.spinner {
width: 28px;
height: 28px;
border: 2px solid var(--border);
border-top-color: var(--fg);
border-radius: 50%;
animation: spin 0.8s linear infinite;
margin-bottom: 1.5rem;
}
@keyframes spin { to { transform: rotate(360deg); } }
@media (prefers-reduced-motion: reduce) {
/* Keep a slow spin: a frozen spinner reads as a hung page. */
.spinner { animation-duration: 2.5s; }
}
.eyebrow {
display: inline-flex;
align-items: center;
gap: 0.375rem;
font-size: 0.6875rem;
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--fg-faint);
margin-bottom: 0.875rem;
}
.eyebrow::before {
content: "";
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--warm-accent);
}
h1 {
font-family: 'Hedvig Letters Serif', Georgia, 'Times New Roman', serif;
font-size: 1.625rem;
font-weight: 400;
letter-spacing: -0.018em;
line-height: 1.15;
margin-bottom: 0.625rem;
}
.lede {
font-size: 0.875rem;
color: var(--fg-muted);
line-height: 1.55;
}
.slow {
margin-top: 1.5rem;
font-size: 0.8125rem;
color: var(--fg-muted);
line-height: 1.55;
}
.slow[hidden] { display: none; }
a { color: var(--fg); text-decoration: underline; text-underline-offset: 2px; }
.fallback { margin-top: 1.25rem; font-size: 0.8125rem; }
</style>
</head>
<body>
<main role="main" aria-busy="true">
<div class="spinner" aria-hidden="true"></div>
<div class="eyebrow">Bankanslutning</div>
<h1>${heading}</h1>
<p class="lede">Vi bekr&auml;ftar anslutningen och h&auml;mtar dina konton fr&aring;n banken. Du skickas vidare automatiskt.</p>
<p class="slow" id="slow-notice" hidden>
Det tar l&auml;ngre tid &auml;n vanligt. V&auml;nta g&auml;rna kvar en stund till,
eller <a href="/settings/banking">g&aring; till bankinst&auml;llningarna</a>.
</p>
</main>
<script nonce="${escapeHtml(cspNonce)}">
setTimeout(function () {
var el = document.getElementById('slow-notice');
if (el) el.hidden = false;
}, 30000);
</script>
`
}
/**
* Closing chunk: navigates to `url` the instant it arrives. Three mechanisms,
* most graceful first: location.replace (keeps the callback URL out of
* history so Back cannot re-trigger it), a meta refresh for no-JS, and a
* visible link as the last resort.
*/
export function renderFinalizeRedirect(url: string, cspNonce: string): string {
// <-escape so a "</script>" sequence can never terminate the block
// early, even though our URLs are app-relative and query-encoded.
const jsUrl = JSON.stringify(url).replace(/</g, '\\u003c')
return ` <script nonce="${escapeHtml(cspNonce)}">window.location.replace(${jsUrl});</script>
<noscript><meta http-equiv="refresh" content="0;url=${escapeHtml(url)}"></noscript>
<div class="fallback"><a href="${escapeHtml(url)}">Klicka h&auml;r om du inte skickas vidare automatiskt</a></div>
</body>
</html>
`
}
@@ -1,6 +1,8 @@
import { randomBytes } from 'node:crypto'
import { createServiceClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { NextResponse, after } from 'next/server'
import { ensureInitialized } from '@/lib/init'
import { createLogger } from '@/lib/logger'
import { createSession, type AccountInfo } from '@/extensions/general/enable-banking/lib/api-client'
import type { StoredAccount } from '@/extensions/general/enable-banking/types'
import { eventBus } from '@/lib/events/bus'
@@ -9,6 +11,7 @@ import {
allocatePsd2LedgerAccount,
defaultLedgerForCurrency,
} from '@/lib/cash-accounts/service'
import { renderFinalizeShell, renderFinalizeRedirect } from './finalize-page'
// This route emits bank_connection.consent_granted / .cash_account_mirror_failed
// (ASVS V16 / GDPR Art.30 audit events). ensureInitialized() must run at module
@@ -17,6 +20,26 @@ import {
// redirect route is the first event-emitting code path to execute.
ensureInitialized()
// Structured logger for audit-trail failures (ISO 27001 A.8.15): a failed
// audit-event emission must be visible to log-based alerting, not just a raw
// console line. The stable message below is what monitoring keys on.
const log = createLogger('enable-banking/callback')
const AUDIT_EMIT_FAILED = 'audit event emit failed'
type ServiceClient = Awaited<ReturnType<typeof createServiceClient>>
interface PendingConnection {
id: string
user_id: string
company_id: string
bank_name: string | null
status: string
}
// Shown in the settings banner when the session exchange/finalize fails.
// User-facing, so Swedish (the raw upstream error is in the server log).
const FINALIZE_FAILED_MESSAGE =
'Anslutningen kunde inte slutföras. Försök igen om en stund.'
/**
* GET /api/extensions/enable-banking/callback
@@ -24,6 +47,13 @@ ensureInitialized()
* OAuth callback for Enable Banking PSD2 authorization.
* Must be a real Next.js route (not extension handler) because
* banks redirect to this URL directly.
*
* Fast outcomes (bank denial, bad params, unknown state) respond with a
* classic 307. The success path instead streams an interim "Slutför
* bankanslutningen" page while the slow work runs (session exchange with
* Enable Banking, cash-account mirroring), then streams a client-side
* redirect: without this the user stares at a blank tab for several seconds,
* which reads as a failed connection and provokes duplicate retries.
*/
export async function GET(request: Request) {
const { searchParams } = new URL(request.url)
@@ -58,7 +88,7 @@ export async function GET(request: Request) {
// (which stays 'expired' during the round-trip) is also handled.
const { data: pendingConn } = await supabase
.from('bank_connections')
.select('id, user_id, bank_name, psu_type')
.select('id, user_id, bank_name, psu_type, status')
.eq('oauth_state', state)
.in('status', ['pending', 'expired', 'error'])
.single()
@@ -72,17 +102,33 @@ export async function GET(request: Request) {
error_description: errorDescription,
})
// If the bank reports a session-expiry during authorization itself,
// mark the row 'expired' (not generic 'error') so the settings panel
// surfaces the reconnect button rather than a dead-end error state.
const isSessionExpiry = /session.?expired|expired.?session|closed.?session|session.?closed|invalid.?session|session.?not.?found/i.test(
`${error} ${errorDescription ?? ''}`
)
if (pendingConn.status === 'pending') {
// Fresh connect that never became a connection: delete the row
// instead of parking it in 'error'. A parked row renders forever
// as an "Åtgärd krävs" card, so a failed attempt followed by a
// successful retry showed up as two connections to the same bank.
// The ?bank_error banner below is the actual failure feedback.
await supabase
.from('bank_connections')
.delete()
.eq('id', pendingConn.id)
.eq('status', 'pending')
} else {
// Reconnect of an established connection: keep the row (it holds
// accounts/transactions history) and surface the failure on it.
// If the bank reports a session-expiry during authorization
// itself, mark it 'expired' (not generic 'error') so the settings
// panel surfaces the reconnect button rather than a dead-end
// error state.
const isSessionExpiry = /session.?expired|expired.?session|closed.?session|session.?closed|invalid.?session|session.?not.?found/i.test(
`${error} ${errorDescription ?? ''}`
)
await supabase
.from('bank_connections')
.update({ status: isSessionExpiry ? 'expired' : 'error', error_message: errorMessage, oauth_state: null })
.eq('id', pendingConn.id)
await supabase
.from('bank_connections')
.update({ status: isSessionExpiry ? 'expired' : 'error', error_message: errorMessage, oauth_state: null })
.eq('id', pendingConn.id)
}
// Include bank name, error code, and psu_type in the redirect so the
// UI can render targeted guidance (e.g. PSU-type retry on
@@ -118,240 +164,341 @@ export async function GET(request: Request) {
const supabase = await createServiceClient()
try {
// Look up the connection awaiting this callback by oauth_state (CSRF-safe).
// oauth_state is a single-use random token cleared after use, so it uniquely
// identifies the row regardless of status. Accept 'expired'/'error' too: an
// in-place reconnect keeps the row in 'expired' during the round-trip (so
// the nightly stale-'pending' cleanup can't delete an established row).
const { data: pendingConnection, error: findError } = await supabase
.from('bank_connections')
.select('id, user_id, company_id')
.eq('oauth_state', state)
.in('status', ['pending', 'expired', 'error'])
.single()
// Look up the connection awaiting this callback by oauth_state (CSRF-safe).
// oauth_state is a single-use random token cleared after use, so it uniquely
// identifies the row regardless of status. Accept 'expired'/'error' too: an
// in-place reconnect keeps the row in 'expired' during the round-trip (so
// the nightly stale-'pending' cleanup can't delete an established row).
// This lookup is fast, so it runs BEFORE the streamed response: an unknown
// state stays a plain redirect.
const { data: pendingConnection, error: findError } = await supabase
.from('bank_connections')
.select('id, user_id, company_id, bank_name, status')
.eq('oauth_state', state)
.in('status', ['pending', 'expired', 'error'])
.single()
if (findError || !pendingConnection) {
console.error('[enable-banking] No pending connection for oauth_state', {
findError: findError ? { message: findError.message, code: findError.code, details: findError.details } : null,
state,
hasCode: !!code,
})
return NextResponse.redirect(
`${baseUrl}/settings/banking?bank_error=${encodeURIComponent('invalid_state')}`
)
}
const userId = pendingConnection.user_id
console.log('[enable-banking] Exchanging code for session', {
connectionId: pendingConnection.id,
userId,
codeLength: code.length,
})
const sessionData = await createSession(code)
const { session_id, accounts, access } = sessionData
const consentExpiresAt = access.valid_until
console.log('[enable-banking] Session created successfully', {
connectionId: pendingConnection.id,
sessionId: '[REDACTED]',
accountCount: accounts.length,
consentExpiresAt,
})
// GDPR Art.5(1)(c) / Art.25(1): data minimization. We only store the
// metadata the user needs to pick which accounts to sync (uid, name, IBAN,
// currency). Balances are bank account financial data: we don't fetch
// them here. The first sync (after the user enables specific accounts)
// populates balance + balance_updated_at via lib/sync.ts. Accounts the
// user deselects never have their balance pulled.
const accountsMetadata: StoredAccount[] = accounts.map((account: AccountInfo) => ({
uid: account.uid,
iban: account.account_id?.iban,
name: account.name || account.product,
currency: account.currency,
// Default to enabled. The user is presented with a picker
// immediately after this callback to uncheck unwanted accounts
// before any transactions are fetched.
enabled: true,
}))
// Stay in 'pending_selection' until the user confirms which accounts to sync.
// The cron and manual sync routes both skip this status, so no transactions
// can be pulled before the user has had a chance to deselect accounts.
// Do not set last_synced_at here either: no transactions have been fetched
// yet, and setting it would cause the cron's first-sync 90-day backfill
// path to be skipped. The first successful sync sets it.
const { data: updatedConnection, error: updateError } = await supabase
.from('bank_connections')
.update({
session_id,
status: 'pending_selection',
accounts_data: accountsMetadata,
consent_expires: consentExpiresAt,
oauth_state: null, // Clear to prevent replay
})
.eq('id', pendingConnection.id)
.select('id, bank_name, company_id, user_id')
.single()
if (updateError) {
console.error('[enable-banking] Failed to update connection after session creation', {
connectionId: pendingConnection.id,
updateError: { message: updateError.message, code: updateError.code, details: updateError.details },
sessionId: '[REDACTED]',
})
throw new Error(`Failed to update connection: ${updateError.message}`)
}
// Mirror each PSD2 account into cash_accounts so routing decisions read
// from the canonical entity table. Accounts already mirrored (reconnect)
// keep their ledger_account — re-deriving it here would clobber the
// user's remaps. New accounts each get a free BAS class-19 slot: a bank
// returning N same-currency accounts must not collide on the UNIQUE
// (company_id, ledger_account) constraint by all defaulting to 1930.
const { data: mirroredRows } = await supabase
.from('cash_accounts')
.select('external_uid, ledger_account')
.eq('company_id', updatedConnection.company_id)
.eq('bank_connection_id', updatedConnection.id)
const existingLedgerByUid = new Map(
((mirroredRows ?? []) as Array<{ external_uid: string; ledger_account: string }>).map(
(r) => [r.external_uid, r.ledger_account],
),
)
const assignedLedgers = new Set<string>(existingLedgerByUid.values())
let accountsDataDirty = false
for (const account of accountsMetadata) {
let targetLedger = existingLedgerByUid.get(account.uid)
if (!targetLedger) {
targetLedger =
(await allocatePsd2LedgerAccount(supabase, updatedConnection.company_id, updatedConnection.user_id, {
currency: account.currency,
accountName: account.name,
exclude: assignedLedgers,
})) ?? defaultLedgerForCurrency(account.currency)
}
assignedLedgers.add(targetLedger)
if (account.ledger_account !== targetLedger) {
account.ledger_account = targetLedger
accountsDataDirty = true
}
try {
await upsertFromPsd2(supabase, updatedConnection.company_id, {
bank_connection_id: updatedConnection.id,
external_uid: account.uid,
currency: account.currency,
ledger_account: targetLedger,
iban: account.iban ?? null,
name: account.name ?? null,
enabled: account.enabled ?? true,
})
} catch (cashErr) {
const reason = cashErr instanceof Error ? cashErr.message : String(cashErr)
console.error('[enable-banking] Failed to mirror cash_account on callback', {
connectionId: updatedConnection.id,
uid: account.uid,
error: reason,
})
// Persist the failure to event_log so a security review can see that
// a PSD2 account returned by the bank was not mirrored into our
// routing table; otherwise this is only visible in console output
// (ASVS V16 / ISO 27001 A.8.15 / SOC 2 CC7.2).
try {
await eventBus.emit({
type: 'bank_connection.cash_account_mirror_failed',
payload: {
connectionId: updatedConnection.id,
bankName: updatedConnection.bank_name ?? null,
accountUid: account.uid,
ledgerAccount: targetLedger,
currency: account.currency,
reason,
userId: updatedConnection.user_id,
companyId: updatedConnection.company_id,
},
})
} catch (emitError) {
console.error('[enable-banking] Failed to emit cash_account_mirror_failed event', {
connectionId: updatedConnection.id,
error: emitError instanceof Error ? emitError.message : String(emitError),
})
}
}
}
// Persist the allocated ledgers into accounts_data so the AccountPicker
// pre-fills the actual assignments instead of colliding currency
// defaults. Non-fatal: cash_accounts is the routing source of truth.
if (accountsDataDirty) {
const { error: accountsDataError } = await supabase
.from('bank_connections')
.update({ accounts_data: accountsMetadata })
.eq('id', updatedConnection.id)
if (accountsDataError) {
console.warn('[enable-banking] Failed to persist allocated ledgers to accounts_data', {
connectionId: updatedConnection.id,
error: accountsDataError.message,
})
}
}
// Audit trail: PSD2 consent has been exchanged and account metadata stored.
// ASVS V16 requires this transition to be logged as a security event; emit
// here so the event_log handler persists it (30-day TTL).
try {
await eventBus.emit({
type: 'bank_connection.consent_granted',
payload: {
connectionId: updatedConnection.id,
bankName: updatedConnection.bank_name ?? null,
accountCount: accounts.length,
consentExpiresAt: consentExpiresAt ?? null,
userId: updatedConnection.user_id,
companyId: updatedConnection.company_id,
},
})
} catch (emitError) {
// Non-fatal: redirect the user even if the audit event fails. Sentry
// surfaces the error; the underlying DB write (the source of truth for
// the connection state) has already succeeded.
console.error('[enable-banking] Failed to emit consent_granted event', {
connectionId: updatedConnection.id,
error: emitError instanceof Error ? emitError.message : String(emitError),
})
}
const connectionId = updatedConnection.id
const redirectTarget = `/settings/banking?select_accounts=${connectionId}`
return NextResponse.redirect(`${baseUrl}${redirectTarget}`)
} catch (error) {
console.error('[enable-banking] Callback error', {
message: error instanceof Error ? error.message : String(error),
stack: error instanceof Error ? error.stack : undefined,
name: error instanceof Error ? error.name : undefined,
if (findError || !pendingConnection) {
console.error('[enable-banking] No pending connection for oauth_state', {
findError: findError ? { message: findError.message, code: findError.code, details: findError.details } : null,
state,
hasCode: !!code,
})
try {
await supabase
.from('bank_connections')
.update({ status: 'error', error_message: error instanceof Error ? error.message : 'Connection failed', oauth_state: null })
.eq('oauth_state', state)
.in('status', ['pending', 'expired', 'error'])
} catch (cleanupError) {
console.error('[enable-banking] Callback cleanup failed', {
cleanupError: cleanupError instanceof Error ? cleanupError.message : String(cleanupError),
})
}
return NextResponse.redirect(
`${baseUrl}/settings/banking?bank_error=${encodeURIComponent('Connection failed')}`
`${baseUrl}/settings/banking?bank_error=${encodeURIComponent('invalid_state')}`
)
}
// Kick the finalize work off eagerly, decoupled from the response stream:
// if the user closes the tab mid-stream, the stream is cancelled but this
// promise keeps running, so the session persistence, cash-account mirror
// and consent_granted audit emit are not lost (ASVS V16). Never rejects:
// failures resolve to the cleanup redirect target.
const finalizePromise = (async (): Promise<string> => {
try {
return await finalizeConnection(supabase, pendingConnection, code)
} catch (finalizeError) {
console.error('[enable-banking] Callback error', {
message: finalizeError instanceof Error ? finalizeError.message : String(finalizeError),
stack: finalizeError instanceof Error ? finalizeError.stack : undefined,
name: finalizeError instanceof Error ? finalizeError.name : undefined,
state,
connectionId: pendingConnection.id,
})
return cleanupFailedFinalize(supabase, pendingConnection)
}
})()
// Keep the serverless function alive until the finalize work settles even
// if the client disconnects and the platform considers the response done.
try {
after(() => finalizePromise.then(() => undefined))
} catch {
// Outside a request scope (unit tests, plain node server): the stream's
// own await below still drives the promise to completion.
}
// Per-request CSP nonce for the two inline scripts on the finalize page
// (ASVS V3.3): mirrors the mcp-oauth consent page. The global next.config
// CSP also applies; the intersection means inline scripts on THIS response
// must carry the nonce.
const cspNonce = randomBytes(16).toString('base64')
const csp = [
"default-src 'none'",
`script-src 'nonce-${cspNonce}'`,
"style-src 'unsafe-inline'",
"base-uri 'none'",
"form-action 'self'",
"frame-ancestors 'none'",
].join('; ')
// Stream: flush the branded "Slutför bankanslutningen" shell immediately,
// await the finalize work, then stream a client-side redirect to the
// outcome URL. The user sees progress from the first byte instead of a
// blank tab.
const encoder = new TextEncoder()
const stream = new ReadableStream<Uint8Array>({
async start(controller) {
controller.enqueue(encoder.encode(renderFinalizeShell(pendingConnection.bank_name, cspNonce)))
const targetPath = await finalizePromise
try {
controller.enqueue(encoder.encode(renderFinalizeRedirect(`${baseUrl}${targetPath}`, cspNonce)))
controller.close()
} catch {
// Stream already cancelled (client closed the tab). The finalize
// work above completed regardless; there is just no one to redirect.
}
},
})
return new Response(stream, {
headers: {
'Content-Type': 'text/html; charset=utf-8',
'Content-Security-Policy': csp,
// The body carries a one-time OAuth outcome: never cache, never buffer
// (X-Accel-Buffering opts out of proxy buffering so the shell chunk
// actually reaches the browser before the work finishes).
'Cache-Control': 'no-store',
'X-Accel-Buffering': 'no',
},
})
}
/**
* The slow part of the callback: exchange the authorization code for a PSD2
* session, persist the account metadata, mirror accounts into cash_accounts,
* and emit the audit event. Returns the app-relative redirect target.
* Extracted so the route can run it behind the streamed progress page.
*/
async function finalizeConnection(
supabase: ServiceClient,
pendingConnection: PendingConnection,
code: string,
): Promise<string> {
const userId = pendingConnection.user_id
console.log('[enable-banking] Exchanging code for session', {
connectionId: pendingConnection.id,
userId,
codeLength: code.length,
})
const sessionData = await createSession(code)
const { session_id, accounts, access } = sessionData
const consentExpiresAt = access.valid_until
console.log('[enable-banking] Session created successfully', {
connectionId: pendingConnection.id,
sessionId: '[REDACTED]',
accountCount: accounts.length,
consentExpiresAt,
})
// GDPR Art.5(1)(c) / Art.25(1): data minimization. We only store the
// metadata the user needs to pick which accounts to sync (uid, name, IBAN,
// currency). Balances are bank account financial data: we don't fetch
// them here. The first sync (after the user enables specific accounts)
// populates balance + balance_updated_at via lib/sync.ts. Accounts the
// user deselects never have their balance pulled.
const accountsMetadata: StoredAccount[] = accounts.map((account: AccountInfo) => ({
uid: account.uid,
iban: account.account_id?.iban,
name: account.name || account.product,
currency: account.currency,
// Default to enabled. The user is presented with a picker
// immediately after this callback to uncheck unwanted accounts
// before any transactions are fetched.
enabled: true,
}))
// Stay in 'pending_selection' until the user confirms which accounts to sync.
// The cron and manual sync routes both skip this status, so no transactions
// can be pulled before the user has had a chance to deselect accounts.
// Do not set last_synced_at here either: no transactions have been fetched
// yet, and setting it would cause the cron's first-sync 90-day backfill
// path to be skipped. The first successful sync sets it.
const { data: updatedConnection, error: updateError } = await supabase
.from('bank_connections')
.update({
session_id,
status: 'pending_selection',
accounts_data: accountsMetadata,
consent_expires: consentExpiresAt,
oauth_state: null, // Clear to prevent replay
})
.eq('id', pendingConnection.id)
.select('id, bank_name, company_id, user_id')
.single()
if (updateError) {
console.error('[enable-banking] Failed to update connection after session creation', {
connectionId: pendingConnection.id,
updateError: { message: updateError.message, code: updateError.code, details: updateError.details },
sessionId: '[REDACTED]',
})
throw new Error(`Failed to update connection: ${updateError.message}`)
}
// Mirror each PSD2 account into cash_accounts so routing decisions read
// from the canonical entity table. Accounts already mirrored (reconnect)
// keep their ledger_account — re-deriving it here would clobber the
// user's remaps. New accounts each get a free BAS class-19 slot: a bank
// returning N same-currency accounts must not collide on the UNIQUE
// (company_id, ledger_account) constraint by all defaulting to 1930.
const { data: mirroredRows } = await supabase
.from('cash_accounts')
.select('external_uid, ledger_account')
.eq('company_id', updatedConnection.company_id)
.eq('bank_connection_id', updatedConnection.id)
const existingLedgerByUid = new Map(
((mirroredRows ?? []) as Array<{ external_uid: string; ledger_account: string }>).map(
(r) => [r.external_uid, r.ledger_account],
),
)
const assignedLedgers = new Set<string>(existingLedgerByUid.values())
let accountsDataDirty = false
for (const account of accountsMetadata) {
let targetLedger = existingLedgerByUid.get(account.uid)
if (!targetLedger) {
targetLedger =
(await allocatePsd2LedgerAccount(supabase, updatedConnection.company_id, updatedConnection.user_id, {
currency: account.currency,
accountName: account.name,
exclude: assignedLedgers,
})) ?? defaultLedgerForCurrency(account.currency)
}
assignedLedgers.add(targetLedger)
if (account.ledger_account !== targetLedger) {
account.ledger_account = targetLedger
accountsDataDirty = true
}
try {
await upsertFromPsd2(supabase, updatedConnection.company_id, {
bank_connection_id: updatedConnection.id,
external_uid: account.uid,
currency: account.currency,
ledger_account: targetLedger,
iban: account.iban ?? null,
name: account.name ?? null,
enabled: account.enabled ?? true,
})
} catch (cashErr) {
const reason = cashErr instanceof Error ? cashErr.message : String(cashErr)
console.error('[enable-banking] Failed to mirror cash_account on callback', {
connectionId: updatedConnection.id,
uid: account.uid,
error: reason,
})
// Persist the failure to event_log so a security review can see that
// a PSD2 account returned by the bank was not mirrored into our
// routing table; otherwise this is only visible in console output
// (ASVS V16 / ISO 27001 A.8.15 / SOC 2 CC7.2).
try {
await eventBus.emit({
type: 'bank_connection.cash_account_mirror_failed',
payload: {
connectionId: updatedConnection.id,
bankName: updatedConnection.bank_name ?? null,
accountUid: account.uid,
ledgerAccount: targetLedger,
currency: account.currency,
reason,
userId: updatedConnection.user_id,
companyId: updatedConnection.company_id,
},
})
} catch (emitError) {
// A.8.15: structured error (not bare console) so log-based alerting
// catches a dropped security event instead of it vanishing silently.
log.error(AUDIT_EMIT_FAILED, emitError as Error, {
eventType: 'bank_connection.cash_account_mirror_failed',
connectionId: updatedConnection.id,
accountUid: account.uid,
})
}
}
}
// Persist the allocated ledgers into accounts_data so the AccountPicker
// pre-fills the actual assignments instead of colliding currency
// defaults. Non-fatal: cash_accounts is the routing source of truth.
if (accountsDataDirty) {
const { error: accountsDataError } = await supabase
.from('bank_connections')
.update({ accounts_data: accountsMetadata })
.eq('id', updatedConnection.id)
if (accountsDataError) {
console.warn('[enable-banking] Failed to persist allocated ledgers to accounts_data', {
connectionId: updatedConnection.id,
error: accountsDataError.message,
})
}
}
// Audit trail: PSD2 consent has been exchanged and account metadata stored.
// ASVS V16 requires this transition to be logged as a security event; emit
// here so the event_log handler persists it (30-day TTL).
try {
await eventBus.emit({
type: 'bank_connection.consent_granted',
payload: {
connectionId: updatedConnection.id,
bankName: updatedConnection.bank_name ?? null,
accountCount: accounts.length,
consentExpiresAt: consentExpiresAt ?? null,
userId: updatedConnection.user_id,
companyId: updatedConnection.company_id,
},
})
} catch (emitError) {
// Non-fatal: redirect the user even if the audit event fails. The
// structured error record is the alerting channel (A.8.15): production
// log monitoring keys on the stable message. The underlying DB write
// (the source of truth for the connection state) has already succeeded.
log.error(AUDIT_EMIT_FAILED, emitError as Error, {
eventType: 'bank_connection.consent_granted',
connectionId: updatedConnection.id,
})
}
return `/settings/banking?select_accounts=${updatedConnection.id}`
}
/**
* Failure cleanup after finalizeConnection threw. A fresh connect (prior
* status 'pending') never became a connection: delete the row so it can't
* linger as a zombie "Åtgärd krävs" card next to a successful retry. A
* reconnect row (established connection) is kept and marked 'error' so the
* user retains the renew affordance. Returns the error redirect target.
*/
async function cleanupFailedFinalize(
supabase: ServiceClient,
pendingConnection: PendingConnection,
): Promise<string> {
try {
if (pendingConnection.status === 'pending') {
await supabase
.from('bank_connections')
.delete()
.eq('id', pendingConnection.id)
.eq('status', 'pending')
} else {
await supabase
.from('bank_connections')
.update({ status: 'error', error_message: FINALIZE_FAILED_MESSAGE, oauth_state: null })
.eq('id', pendingConnection.id)
.in('status', ['pending', 'expired', 'error'])
}
} catch (cleanupError) {
console.error('[enable-banking] Callback cleanup failed', {
cleanupError: cleanupError instanceof Error ? cleanupError.message : String(cleanupError),
})
}
const params = new URLSearchParams({
bank_error: FINALIZE_FAILED_MESSAGE,
...(pendingConnection.bank_name ? { bank_name: pendingConnection.bank_name } : {}),
})
return `/settings/banking?${params.toString()}`
}
@@ -475,6 +475,51 @@ describe('POST /api/invoices/[id]/mark-paid', () => {
expect(body.paid_at).toBeNull()
})
it('accepts an öresavrundning overshoot: rounded "Att betala" settles the invoice in full', async () => {
// Invoice stored with öre (1234.75), PDF shows the rounded 1235.00 and the
// customer pays that: the 3740 line carries the 0.25 residual. No customer
// → duplicate guard skips.
const invoice = makeInvoice({
id: 'inv-1',
status: 'sent',
total: 1234.75,
remaining_amount: 1234.75,
})
enqueue({ data: invoice, error: null })
enqueue({ data: { accounting_method: 'accrual', entity_type: 'enskild_firma' }, error: null })
enqueue({ data: [{ id: 'inv-1' }], error: null }) // CAS update matched
mockFindFiscalPeriod.mockResolvedValue('fp-1')
mockCreateJournalEntry.mockResolvedValue({ id: 'je-ore' })
const oreLines = [
{ account_number: '1930', debit_amount: 1235, credit_amount: 0 },
{ account_number: '1510', debit_amount: 0, credit_amount: 1234.75 },
{ account_number: '3740', debit_amount: 0, credit_amount: 0.25 },
]
const request = createMockRequest('/api/invoices/inv-1/mark-paid', {
method: 'POST',
body: { lines: oreLines },
})
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.success).toBe(true)
expect(body.status).toBe('paid')
expect(body.paid_amount).toBe(1234.75)
expect(body.remaining_amount).toBe(0)
expect(body.journal_entry_id).toBe('je-ore')
})
it('returns 400 MATCH_AMOUNT_EXCEEDS_REMAINING when custom lines overpay the invoice', async () => {
// No customer → duplicate guard skips; the overpayment guard must reject
// BEFORE any journal entry is created (planInvoicePayment runs first).
@@ -164,6 +164,84 @@ describe('POST /api/invoices/[id]/send', () => {
expect((body.error as unknown as { code: string }).code).toBe('INVOICE_SEND_CANCELLED')
})
it.each(['sent', 'paid', 'overdue', 'partially_paid', 'credited'] as const)(
'returns 409 and posts no journal entry when invoice status is %s',
async (issuedStatus) => {
const issuedInvoice = makeInvoice({
id: 'inv-1',
status: issuedStatus,
customer,
items: [],
})
enqueue({ data: issuedInvoice, error: null })
const request = createMockRequest('/api/invoices/inv-1/send', { method: 'POST' })
const response = await POST(request, createMockRouteParams({ id: 'inv-1' }))
const { status, body } = await parseJsonResponse<{ error: string }>(response)
expect(status).toBe(409)
expect((body.error as unknown as { code: string }).code).toBe('INVOICE_ALREADY_SENT')
expect(mockSendEmail).not.toHaveBeenCalled()
expect(mockCreateInvoiceJournalEntry).not.toHaveBeenCalled()
},
)
it('skips journal entry, archive and event when a concurrent request won the status flip', async () => {
enqueue({ data: invoice, error: null })
enqueue({ data: company, error: null })
mockSendEmail.mockResolvedValue({ success: true, messageId: 'msg-race' })
// Optimistic-locked flip matches 0 rows: another request already sent it.
enqueue({ data: [], error: null })
const emitSpy = vi.spyOn(eventBus, 'emit')
const request = createMockRequest('/api/invoices/inv-1/send', { method: 'POST' })
const response = await POST(request, createMockRouteParams({ id: 'inv-1' }))
const { status, body } = await parseJsonResponse<{
success: boolean
partial?: boolean
partial_failures?: Array<{ step: string }>
}>(response)
// The email did go out, so the response is still a (partial) success.
expect(status).toBe(200)
expect(body.success).toBe(true)
expect(body.partial).toBe(true)
expect(body.partial_failures?.some((f) => f.step === 'status_update')).toBe(true)
// The winning request owns the bookkeeping: no second verifikat here.
expect(mockCreateInvoiceJournalEntry).not.toHaveBeenCalled()
expect(emitSpy).not.toHaveBeenCalledWith(
expect.objectContaining({ type: 'invoice.sent' })
)
})
it('defers the journal entry when the status flip errors (row stays draft, retry re-books once)', async () => {
enqueue({ data: invoice, error: null })
enqueue({ data: company, error: null })
mockSendEmail.mockResolvedValue({ success: true, messageId: 'msg-fliperr' })
// Status flip hits a DB error: the invoice remains 'draft'.
enqueue({ data: null, error: { message: 'connection reset' } })
const request = createMockRequest('/api/invoices/inv-1/send', { method: 'POST' })
const response = await POST(request, createMockRouteParams({ id: 'inv-1' }))
const { status, body } = await parseJsonResponse<{
success: boolean
partial?: boolean
partial_failures?: Array<{ step: string; reason: string }>
}>(response)
expect(status).toBe(200)
expect(body.partial).toBe(true)
expect(body.partial_failures?.some((f) => f.step === 'status_update')).toBe(true)
// No entry now: the retry (invoice still draft) runs the full pipeline
// and posts exactly one, instead of this request + the retry posting two.
expect(mockCreateInvoiceJournalEntry).not.toHaveBeenCalled()
})
it('returns 400 when customer has no email', async () => {
const noEmailInvoice = makeInvoice({
id: 'inv-1',
@@ -201,8 +279,8 @@ describe('POST /api/invoices/[id]/send', () => {
mockSendEmail.mockResolvedValue({ success: true, messageId: 'msg-1' })
mockCreateInvoiceJournalEntry.mockResolvedValue({ id: 'je-1' })
// Update invoice status to 'sent'
enqueue({ data: null, error: null })
// Update invoice status to 'sent' (optimistic lock: returns the matched row)
enqueue({ data: [{ id: 'inv-1' }], error: null })
// Update invoice with journal_entry_id
enqueue({ data: null, error: null })
@@ -244,7 +322,7 @@ describe('POST /api/invoices/[id]/send', () => {
mockSendEmail.mockResolvedValue({ success: true, messageId: 'msg-2' })
// Update invoice status
enqueue({ data: null, error: null })
enqueue({ data: [{ id: 'inv-1' }], error: null })
const request = createMockRequest('/api/invoices/inv-1/send', { method: 'POST' })
const response = await POST(request, createMockRouteParams({ id: 'inv-1' }))
@@ -263,7 +341,7 @@ describe('POST /api/invoices/[id]/send', () => {
mockCreateInvoiceJournalEntry.mockRejectedValue(new Error('Period locked'))
// Update invoice status
enqueue({ data: null, error: null })
enqueue({ data: [{ id: 'inv-1' }], error: null })
const request = createMockRequest('/api/invoices/inv-1/send', { method: 'POST' })
const response = await POST(request, createMockRouteParams({ id: 'inv-1' }))
@@ -293,7 +371,7 @@ describe('POST /api/invoices/[id]/send', () => {
mockCreateInvoiceJournalEntry.mockResolvedValue({ id: 'je-1' })
// Update status to 'sent'
enqueue({ data: null, error: null })
enqueue({ data: [{ id: 'inv-1' }], error: null })
// Update with journal_entry_id
enqueue({ data: null, error: null })
@@ -325,7 +403,7 @@ describe('POST /api/invoices/[id]/send', () => {
mockSendEmail.mockResolvedValue({ success: true, messageId: 'msg-100' })
mockCreateInvoiceJournalEntry.mockResolvedValue({ id: 'je-2' })
enqueue({ data: null, error: null })
enqueue({ data: [{ id: 'inv-1' }], error: null })
enqueue({ data: null, error: null })
const request = createMockRequest('/api/invoices/inv-1/send', { method: 'POST' })
@@ -388,7 +466,7 @@ describe('POST /api/invoices/[id]/send', () => {
mockSendEmail.mockResolvedValue({ success: true, messageId: 'msg-banner' })
mockCreateInvoiceJournalEntry.mockResolvedValue({ id: 'je-1' })
enqueue({ data: null, error: null })
enqueue({ data: [{ id: 'inv-1' }], error: null })
enqueue({ data: null, error: null })
const request = createMockRequest('/api/invoices/inv-1/send', { method: 'POST' })
+48 -10
View File
@@ -60,13 +60,26 @@ export const POST = withRouteContext(
}
// A cancelled invoice keeps its F-series number for compliance with ML 17
// kap 24§ but is not a valid faktura: sending it would silently
// re-activate it (the .update({ status: 'sent' }) below has no status
// guard) and could deliver a "MAKULERAD" PDF as if it were live.
// kap 24§ but is not a valid faktura: sending it would deliver a
// "MAKULERAD" PDF as if it were live. Checked before the generic draft
// guard below for the more specific error message.
if (invoice.status === 'cancelled') {
return errorResponseFromCode('INVOICE_SEND_CANCELLED', opLog, { requestId })
}
// Only drafts may enter the send pipeline. The UI already hides Send for
// non-drafts, but a direct POST against an issued invoice would re-email
// the customer and post a SECOND revenue verifikat
// (createInvoiceJournalEntry has no dedup), overwriting journal_entry_id
// and orphaning the first entry. Mirrors the v1 route and the MCP commit
// executor, which both reject non-drafts.
if (invoice.status !== 'draft') {
return errorResponseFromCode('INVOICE_ALREADY_SENT', opLog, {
requestId,
details: { currentStatus: invoice.status },
})
}
const customer = invoice.customer as Customer
if (!customer.email) {
return errorResponseFromCode('INVOICE_SEND_NO_CUSTOMER_EMAIL', opLog, {
@@ -223,16 +236,36 @@ export const POST = withRouteContext(
partialFailures.push({ step: 'payment_link', reason: paymentLinkFailure })
}
// Optimistic-locked flip (draft → sent). Two concurrent sends can both
// pass the draft guard above and both email the customer, but only the
// request that wins this compare-and-set runs the bookkeeping steps
// below: the loser would otherwise post a duplicate revenue verifikat
// and archive the PDF twice. PostgREST returns no error for a 0-row
// update, so the row count via .select('id') is the actual lock signal.
// A genuine update error also skips the follow-ups: the row is still
// 'draft', so a later retry re-runs the whole pipeline and ends with
// exactly one journal entry (at the cost of a duplicate email).
let statusFlipped = false
{
const { error: updateError } = await supabase
const { data: flipRows, error: updateError } = await supabase
.from('invoices')
.update({ status: 'sent' })
.eq('id', id)
.eq('company_id', companyId)
.eq('status', 'draft')
.select('id')
if (updateError) {
opLog.warn('failed to update invoice status to sent', updateError)
partialFailures.push({ step: 'status_update', reason: updateError.message })
} else if (!flipRows || flipRows.length === 0) {
opLog.warn('invoice already flipped to sent by a concurrent request; skipping bookkeeping follow-ups')
partialFailures.push({
step: 'status_update',
reason: 'Fakturan skickades samtidigt av en annan begäran; bokföringen hanterades där.',
})
} else {
statusFlipped = true
}
}
@@ -240,7 +273,7 @@ export const POST = withRouteContext(
const accountingMethod = (company as Record<string, unknown>).accounting_method as string | undefined
let createdJournalEntryId: string | undefined
if (isRealInvoice && (!accountingMethod || accountingMethod === 'accrual')) {
if (statusFlipped && isRealInvoice && (!accountingMethod || accountingMethod === 'accrual')) {
try {
const journalEntry = await createInvoiceJournalEntry(
supabase,
@@ -284,7 +317,7 @@ export const POST = withRouteContext(
}
}
if (isRealInvoice) {
if (statusFlipped && isRealInvoice) {
try {
const pdfArrayBuffer = new Uint8Array(pdfBuffer).buffer as ArrayBuffer
await uploadDocument(supabase, user.id, companyId!, {
@@ -304,10 +337,15 @@ export const POST = withRouteContext(
}
}
await eventBus.emit({
type: 'invoice.sent',
payload: { invoice: invoice as Invoice, companyId: companyId!, userId: user.id },
})
// Gated like the steps above: on a lost race the winning request emits
// it; on a flip error the row is still 'draft', so emitting would
// contradict DB state and the retry emits it instead.
if (statusFlipped) {
await eventBus.emit({
type: 'invoice.sent',
payload: { invoice: invoice as Invoice, companyId: companyId!, userId: user.id },
})
}
if (partialFailures.length > 0) {
opLog.warn('invoice sent with partial follow-up failures', {
+10 -1
View File
@@ -3,6 +3,7 @@ import { ensureInitialized } from '@/lib/init'
import { withRouteContext } from '@/lib/api/with-route-context'
import { errorResponse } from '@/lib/errors/get-structured-error'
import { executeRecurringSchedule } from '@/lib/invoices/recurring-schedule-service'
import { isSandboxCompany } from '@/lib/sandbox/guard'
import type { RecurringInvoiceSchedule, RecurringInvoiceScheduleItem } from '@/types'
ensureInitialized()
@@ -46,8 +47,16 @@ export const POST = withRouteContext(
items: RecurringInvoiceScheduleItem[]
}
// Defence in depth (ASVS V2.3): mirror the cron route. The sandbox rule
// is enforced inside the service's email chokepoint too; resolving it at
// the route level as well means the invariant survives refactors of the
// service internals. Invoice creation is unaffected (freeze-and-retain).
const suppressAutoSend = typed.auto_send
? await isSandboxCompany(supabase, companyId)
: false
try {
const result = await executeRecurringSchedule(supabase, typed, new Date())
const result = await executeRecurringSchedule(supabase, typed, new Date(), { suppressAutoSend })
// Record the run for the list view (generated count, last invoice,
// warning) but leave next_run_date untouched: the monthly cadence runs
@@ -3,6 +3,27 @@ import { createQueuedMockSupabase, createMockRequest, parseJsonResponse } from '
const { supabase: mockSupabase, enqueue, reset } = createQueuedMockSupabase()
// The queued mock's proxy chain discards call arguments, but these tests need
// to assert exactly what the cron writes back to the schedule row (claim
// release + failure warning, stale roll-forward warning). Wrap .from() so
// every .update() payload is recorded before delegating to the queue chain.
const updatePayloads: Array<{ table: string; payload: Record<string, unknown> }> = []
const baseFrom = mockSupabase.from.getMockImplementation()!
mockSupabase.from.mockImplementation((table: string) => {
const chain = baseFrom(table) as Record<string, (...args: unknown[]) => unknown>
return new Proxy(chain, {
get(target, prop, receiver) {
if (prop === 'update') {
return (payload: Record<string, unknown>) => {
updatePayloads.push({ table, payload })
return target.update(payload)
}
}
return Reflect.get(target, prop, receiver)
},
})
})
vi.mock('@/lib/supabase/server', () => ({
createServiceClient: () => mockSupabase,
}))
@@ -23,6 +44,12 @@ vi.mock('@/lib/invoices/recurring-schedule-service', async (importActual) => {
}
})
// Route-level sandbox resolution (defence in depth, ASVS V2.3).
const isSandboxCompany = vi.fn()
vi.mock('@/lib/sandbox/guard', () => ({
isSandboxCompany: (...args: unknown[]) => isSandboxCompany(...args),
}))
import { GET } from '../route'
type ResultRow = {
@@ -53,6 +80,7 @@ describe('GET /api/invoices/recurring/cron', () => {
beforeEach(() => {
vi.clearAllMocks()
reset()
updatePayloads.length = 0
vi.useFakeTimers()
})
afterEach(() => {
@@ -77,6 +105,31 @@ describe('GET /api/invoices/recurring/cron', () => {
expect(executeRecurringSchedule).toHaveBeenCalledTimes(1)
expect(body.succeeded).toBe(1)
expect(body.results[0].invoiceId).toBe('inv-1')
// No auto_send on the schedule -> no sandbox lookup, no suppression.
expect(isSandboxCompany).not.toHaveBeenCalled()
expect(executeRecurringSchedule.mock.calls[0][3]).toEqual({ suppressAutoSend: false })
})
it('resolves the sandbox flag at the route level and suppresses auto-send for sandbox companies', async () => {
vi.setSystemTime(new Date('2026-07-06T08:30:00Z'))
enqueue({ data: [makeSchedule({ send_hour: 8, auto_send: true })], error: null })
// Atomic claim wins.
enqueue({ data: [{ id: 's-1' }], error: null })
isSandboxCompany.mockResolvedValue(true)
executeRecurringSchedule.mockResolvedValue({
invoiceId: 'inv-1',
invoiceNumber: 'F-1',
autoSent: false,
warning: 'Auto-utskick misslyckades: fakturan finns som utkast och kan skickas manuellt.',
})
const { status } = await parseJsonResponse<CronBody>(await GET(req()))
expect(status).toBe(200)
// Defence in depth: the route resolved the sandbox state itself and told
// the service explicitly, instead of relying only on the chokepoint
// inside sendInvoiceFromSchedule.
expect(isSandboxCompany).toHaveBeenCalledWith(expect.anything(), 'c-1')
expect(executeRecurringSchedule.mock.calls[0][3]).toEqual({ suppressAutoSend: true })
})
it('skips when a concurrent cron run already claimed the schedule', async () => {
@@ -110,6 +163,46 @@ describe('GET /api/invoices/recurring/cron', () => {
expect(body.results[0].skipReason).toBe('stale_rolled_forward')
})
it('releases the claim AND persists a failure warning when execution throws', async () => {
vi.setSystemTime(new Date('2026-07-06T08:30:00Z'))
enqueue({ data: [makeSchedule({ send_hour: 8 })], error: null })
// Atomic claim wins.
enqueue({ data: [{ id: 's-1' }], error: null })
executeRecurringSchedule.mockRejectedValue(new Error('VAT rate 25% not allowed'))
// Claim release + warning write.
enqueue({ data: null, error: null })
const { body } = await parseJsonResponse<CronBody & { failed: number }>(await GET(req()))
expect(body.failed).toBe(1)
// The release update must restore the pre-claim last_run_at (null here)
// so a later cron retries today, and carry a user-visible warning so a
// deterministic failure never skips the month silently.
const release = updatePayloads.find(
(u) => u.table === 'recurring_invoice_schedules' && 'last_run_warning' in u.payload,
)
expect(release).toBeDefined()
expect(release!.payload.last_run_at).toBeNull()
expect(release!.payload.last_run_warning).toContain('2026-07-06 misslyckades')
expect(release!.payload.last_run_warning).toContain('VAT rate 25% not allowed')
})
it('writes a skip warning when rolling a stale schedule forward', async () => {
vi.setSystemTime(new Date('2026-07-06T08:30:00Z'))
enqueue({ data: [makeSchedule({ next_run_date: '2026-07-05', day_of_month: 5 })], error: null })
// Roll-forward update.
enqueue({ data: null, error: null })
const { body } = await parseJsonResponse<CronBody>(await GET(req()))
expect(body.results[0].skipReason).toBe('stale_rolled_forward')
const roll = updatePayloads.find((u) => u.table === 'recurring_invoice_schedules')
expect(roll).toBeDefined()
expect(roll!.payload.next_run_date).toBe('2026-08-05')
expect(roll!.payload.last_run_warning).toContain('Ingen faktura skapades den 2026-07-05')
expect(roll!.payload.last_run_warning).toContain('2026-08-05')
})
it('skips a schedule that already ran earlier today', async () => {
vi.setSystemTime(new Date('2026-07-06T08:30:00Z'))
enqueue({
+29 -4
View File
@@ -8,6 +8,7 @@ import {
computeInitialRunDate,
getStockholmDateHour,
} from '@/lib/invoices/recurring-schedule-service'
import { isSandboxCompany } from '@/lib/sandbox/guard'
import type {
RecurringInvoiceSchedule,
RecurringInvoiceScheduleItem,
@@ -89,9 +90,16 @@ export const GET = withCronContext('cron.recurring_invoices', async (_request, c
// its next date rather than firing a stale one immediately.
if (schedule.next_run_date < todayStockholm) {
const rolledNext = computeInitialRunDate(stockholmToday, schedule.day_of_month)
// Surface the skip on the schedule: a day of failed runs (or a cron
// outage) would otherwise roll the month forward with no user-visible
// trace. The next successful run overwrites this, and a conscious
// reactivation clears it (PATCH route).
const { error: rollError } = await supabase
.from('recurring_invoice_schedules')
.update({ next_run_date: rolledNext })
.update({
next_run_date: rolledNext,
last_run_warning: `Ingen faktura skapades den ${schedule.next_run_date}. Nästa körning: ${rolledNext}. Använd "Skapa faktura nu" om månadens faktura fortfarande behövs.`,
})
.eq('id', schedule.id)
.eq('company_id', schedule.company_id)
if (rollError) {
@@ -169,14 +177,31 @@ export const GET = withCronContext('cron.recurring_invoices', async (_request, c
// 5. Spawn the invoice. If it throws after we claimed, release the claim
// (restore the prior last_run_at) so a later cron retries today rather
// than treating the row as already run.
// than treating the row as already run, and persist the failure as a
// user-visible warning: a deterministic error (bad VAT rate, missing
// items) fails every hourly retry and would otherwise skip the month
// silently via the stale roll-forward above. A later successful run
// overwrites the warning.
// Defence in depth (ASVS V2.3): the email chokepoint inside the schedule
// service enforces the sandbox rule on its own; the route additionally
// resolves it here and passes an explicit suppress flag, so the invariant
// does not hinge on a single check buried in a library function. The
// invoice is still generated as a draft (freeze-and-retain).
const suppressAutoSend = schedule.auto_send
? await isSandboxCompany(supabase, schedule.company_id)
: false
let result: Awaited<ReturnType<typeof executeRecurringSchedule>>
try {
result = await executeRecurringSchedule(supabase, schedule, now)
result = await executeRecurringSchedule(supabase, schedule, now, { suppressAutoSend })
} catch (err) {
const reason = (err instanceof Error ? err.message : String(err)).slice(0, 300)
await supabase
.from('recurring_invoice_schedules')
.update({ last_run_at: schedule.last_run_at })
.update({
last_run_at: schedule.last_run_at,
last_run_warning: `Körningen ${todayStockholm} misslyckades: ${reason}. Nytt försök görs automatiskt varje timme idag.`,
})
.eq('id', schedule.id)
.eq('company_id', schedule.company_id)
.eq('last_run_at', claimTs)
@@ -67,8 +67,7 @@ describe('POST /api/salary/employees/[id]/absence', () => {
it('upserts an absence day (happy path)', async () => {
enqueue({ data: { id: 'emp-1' } }) // loadEmployee
enqueue({ data: null }) // delete existing
enqueue({ data: { id: 'abs-1', absence_date: '2026-07-01', absence_type: 'sick', hours: 8 } }) // insert
enqueue({ data: { id: 'abs-1', absence_date: '2026-07-01', absence_type: 'sick', hours: 8 } }) // upsert
const response = await POST(post({ absence_date: '2026-07-01', absence_type: 'sick', hours: 8 }), params)
const { status, body } = await parseJsonResponse<{ data: { id: string } }>(response)
+36 -95
View File
@@ -1,5 +1,4 @@
import { z } from 'zod'
import type { SupabaseClient } from '@supabase/supabase-js'
import { NextResponse } from 'next/server'
import { ensureInitialized } from '@/lib/init'
import { withRouteContext } from '@/lib/api/with-route-context'
@@ -9,23 +8,22 @@ import {
AbsenceRangeQuerySchema,
AbsenceTypeSchema,
} from '@/lib/api/schemas'
import {
listAbsenceDays,
upsertAbsenceDay,
deleteAbsenceRange,
} from '@/lib/salary/absence'
import { getErrorEntry } from '@/lib/errors/structured-errors'
const isoDate = z.string().regex(/^\d{4}-\d{2}-\d{2}$/)
ensureInitialized()
async function loadEmployee(
supabase: SupabaseClient,
employeeId: string,
companyId: string,
) {
const { data } = await supabase
.from('employees')
.select('id')
.eq('id', employeeId)
.eq('company_id', companyId)
.maybeSingle()
return data
function errorResponse(code: string, details?: Record<string, unknown>): NextResponse {
const entry = getErrorEntry(code)
const message =
(details?.message as string | undefined) ?? entry?.message_sv ?? 'Något gick fel'
return NextResponse.json({ error: message, code }, { status: entry?.httpStatus ?? 500 })
}
export const GET = withRouteContext<{ params: Promise<{ id: string }> }>(
@@ -33,28 +31,18 @@ export const GET = withRouteContext<{ params: Promise<{ id: string }> }>(
async (request, { supabase, companyId }, { params }) => {
const { id: employeeId } = await params
const employee = await loadEmployee(supabase, employeeId, companyId)
if (!employee) {
return NextResponse.json({ error: 'Anställd hittades inte' }, { status: 404 })
}
const query = validateQuery(request, AbsenceRangeQuerySchema)
if (!query.success) return query.response
const { data, error } = await supabase
.from('salary_absence_days')
.select('id, absence_date, absence_type, hours, notes, salary_run_employee_id, created_at, updated_at')
.eq('company_id', companyId)
.eq('employee_id', employeeId)
.gte('absence_date', query.data.from)
.lte('absence_date', query.data.to)
.order('absence_date', { ascending: true })
const result = await listAbsenceDays(supabase, {
companyId,
employeeId,
from: query.data.from,
to: query.data.to,
})
if (error) {
return NextResponse.json({ error: error.message }, { status: 500 })
}
return NextResponse.json({ data })
if (!result.ok) return errorResponse(result.code, result.details)
return NextResponse.json({ data: result.data })
},
)
@@ -63,56 +51,24 @@ export const POST = withRouteContext<{ params: Promise<{ id: string }> }>(
async (request, { supabase, companyId }, { params }) => {
const { id: employeeId } = await params
const employee = await loadEmployee(supabase, employeeId, companyId)
if (!employee) {
return NextResponse.json({ error: 'Anställd hittades inte' }, { status: 404 })
}
const validation = await validateBody(request, UpsertAbsenceDaySchema)
if (!validation.success) return validation.response
const body = validation.data
// Upsert via DELETE+INSERT on the natural key (employee, date, type) so the
// notes/hours/run-link can be replaced cleanly. The unique index makes ON
// CONFLICT viable too, but Supabase's typed client doesn't expose
// onConflict for our composite key without a named constraint name:
// delete-then-insert keeps the pattern consistent with token-store.ts.
const { error: deleteError } = await supabase
.from('salary_absence_days')
.delete()
.eq('company_id', companyId)
.eq('employee_id', employeeId)
.eq('absence_date', body.absence_date)
.eq('absence_type', body.absence_type)
if (deleteError) {
return NextResponse.json({ error: deleteError.message }, { status: 500 })
}
const { data, error } = await supabase
.from('salary_absence_days')
.insert({
company_id: companyId,
employee_id: employeeId,
const result = await upsertAbsenceDay(supabase, {
companyId,
employeeId,
day: {
absence_date: body.absence_date,
absence_type: body.absence_type,
hours: body.hours,
notes: body.notes ?? null,
salary_run_employee_id: body.salary_run_employee_id ?? null,
})
.select()
.single()
},
})
if (error) {
// The 24h cap trigger raises check_violation when worked + absence > 24h
// for the same date. Surface a clean 409 with the Swedish message.
if (error.message?.includes('Total tid') || error.code === '23514') {
return NextResponse.json({ error: error.message }, { status: 409 })
}
return NextResponse.json({ error: error.message }, { status: 500 })
}
return NextResponse.json({ data }, { status: 201 })
if (!result.ok) return errorResponse(result.code, result.details)
return NextResponse.json({ data: result.data }, { status: 201 })
},
{ requireWrite: true },
)
@@ -132,11 +88,6 @@ export const DELETE = withRouteContext<{ params: Promise<{ id: string }> }>(
async (request, { supabase, companyId }, { params }) => {
const { id: employeeId } = await params
const employee = await loadEmployee(supabase, employeeId, companyId)
if (!employee) {
return NextResponse.json({ error: 'Anställd hittades inte' }, { status: 404 })
}
const query = validateQuery(request, DeleteQuerySchema)
if (!query.success) return query.response
const { date, type, from, to } = query.data
@@ -151,26 +102,16 @@ export const DELETE = withRouteContext<{ params: Promise<{ id: string }> }>(
)
}
let q = supabase
.from('salary_absence_days')
.delete()
.eq('company_id', companyId)
.eq('employee_id', employeeId)
const result = await deleteAbsenceRange(supabase, {
companyId,
employeeId,
from: hasSingle ? date! : from!,
to: hasSingle ? date! : to!,
absenceType: type,
})
if (hasSingle) {
q = q.eq('absence_date', date!)
if (type) q = q.eq('absence_type', type)
} else {
q = q.gte('absence_date', from!).lte('absence_date', to!)
if (type) q = q.eq('absence_type', type)
}
const { error } = await q
if (error) {
return NextResponse.json({ error: error.message }, { status: 500 })
}
return NextResponse.json({ data: { ok: true } })
if (!result.ok) return errorResponse(result.code, result.details)
return NextResponse.json({ data: { ok: true, deleted_count: result.data.deleted_count } })
},
{ requireWrite: true },
)
@@ -0,0 +1,56 @@
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 { OpeningBalancesFieldsSchema } from '@/lib/api/schemas'
import { getOpeningBalances, setOpeningBalancesBulk } from '@/lib/salary/opening-balances'
import { getErrorEntry } from '@/lib/errors/structured-errors'
ensureInitialized()
function errorResponse(code: string, message?: string): NextResponse {
const entry = getErrorEntry(code)
return NextResponse.json(
{ error: message ?? entry?.message_sv ?? 'Något gick fel', code },
{ status: entry?.httpStatus ?? 500 },
)
}
/** Cutover opening balances for one employee (dashboard surface; the v1
* routes and the MCP tool share the same lib/salary/opening-balances
* service). Returns { data: null } when nothing is set: the form treats
* that as an empty editable state, unlike v1's 404. */
export const GET = withRouteContext<{ params: Promise<{ id: string }> }>(
'salary.employees.opening-balances.get',
async (_request, { supabase, companyId }, { params }) => {
const { id: employeeId } = await params
const result = await getOpeningBalances(supabase, { companyId, employeeId })
if (!result.ok) return errorResponse(result.code)
return NextResponse.json({ data: result.data })
},
)
export const PUT = withRouteContext<{ params: Promise<{ id: string }> }>(
'salary.employees.opening-balances.set',
async (request, { supabase, companyId, user }, { params }) => {
const { id: employeeId } = await params
const validation = await validateBody(request, OpeningBalancesFieldsSchema)
if (!validation.success) return validation.response
const result = await setOpeningBalancesBulk(supabase, {
companyId,
userId: user.id,
items: [{ employee_id: employeeId, ...validation.data }],
})
if (!result.ok) {
const itemError = result.itemErrors?.[0]
return errorResponse(itemError?.code ?? result.code, itemError?.message)
}
return NextResponse.json({ data: result.data.rows[0] })
},
{ requireWrite: true },
)
+5
View File
@@ -74,6 +74,8 @@ export const POST = withRouteContext('salary.employees.create', async (request,
employment_start: body.employment_start,
employment_end: body.employment_end || null,
employment_degree: body.employment_degree,
hours_per_week: body.hours_per_week,
workdays_per_week: body.workdays_per_week,
salary_type: body.salary_type,
monthly_salary: body.monthly_salary || null,
hourly_rate: body.hourly_rate || null,
@@ -95,6 +97,9 @@ export const POST = withRouteContext('salary.employees.create', async (request,
vaxa_stod_eligible: body.vaxa_stod_eligible,
vaxa_stod_start: body.vaxa_stod_start || null,
vaxa_stod_end: body.vaxa_stod_end || null,
jamkning_percentage: body.jamkning_percentage ?? null,
jamkning_valid_from: body.jamkning_valid_from ?? null,
jamkning_valid_to: body.jamkning_valid_to ?? null,
// Dimensions PR8: bag for the employee's P&L cost lines at booking.
default_dimensions: body.default_dimensions ?? {},
})
+22
View File
@@ -1,6 +1,7 @@
import { NextResponse } from 'next/server'
import { ensureInitialized } from '@/lib/init'
import { createSalaryRunEntries } from '@/lib/salary/salary-entries'
import { syncVacationLedgerForEmployees } from '@/lib/salary/vacation-ledger'
import { eventBus } from '@/lib/events'
import { withRouteContext } from '@/lib/api/with-route-context'
import { errorResponse, errorResponseFromCode } from '@/lib/errors/get-structured-error'
@@ -74,6 +75,17 @@ export const POST = withRouteContext(
payload: { salaryRunId: id, entryIds: [], userId: user.id, companyId: companyId! },
})
// Vacation ledger sync (non-fatal: the ledger recomputes and self-heals
// on the next booking; a sync bug must never block a booking).
const nollSync = await syncVacationLedgerForEmployees(
supabase,
companyId!,
roster.map((sre) => sre.employee_id),
)
if (!nollSync.ok) {
opLog.warn('vacation ledger sync failed after nollkörning booking', { message: nollSync.message })
}
opLog.info('salary run booked as nollkörning (no journal entries)', { salaryRunId: id })
return NextResponse.json({ data: bookedRun })
@@ -154,6 +166,16 @@ export const POST = withRouteContext(
payload: { salaryRunId: id, entryIds, userId: user.id, companyId: companyId! },
})
// Vacation ledger sync (non-fatal, see the nollkörning branch).
const ledgerSync = await syncVacationLedgerForEmployees(
supabase,
companyId!,
roster.map((sre) => sre.employee_id),
)
if (!ledgerSync.ok) {
opLog.warn('vacation ledger sync failed after booking', { message: ledgerSync.message })
}
return NextResponse.json({ data: bookedRun })
} catch (err) {
if (isBookkeepingError(err)) {
+14 -1
View File
@@ -4,6 +4,7 @@ import { withRouteContext } from '@/lib/api/with-route-context'
import { reverseEntry } from '@/lib/bookkeeping/engine'
import { bookkeepingErrorResponse, EntryAlreadyReversedError } from '@/lib/bookkeeping/errors'
import { revokeLinksForRun } from '@/lib/salary/payslips/links'
import { syncVacationLedgerForEmployees } from '@/lib/salary/vacation-ledger'
ensureInitialized()
@@ -26,7 +27,7 @@ export const POST = withRouteContext<{ params: Promise<{ id: string }> }>(
'salary.run.correct',
async (_request, ctx, { params }) => {
const { id } = await params
const { user, supabase, companyId } = ctx
const { user, supabase, companyId, log } = ctx
// Load the original booked run
const { data: originalRun, error: runError } = await supabase
@@ -156,6 +157,18 @@ export const POST = withRouteContext<{ params: Promise<{ id: string }> }>(
}
}
// Vacation ledger sync: the original run flipped to 'corrected', so its
// vacation_days_taken drop out of the recomputed taken_days. Non-fatal:
// the ledger self-heals when the correction run books.
const ledgerSync = await syncVacationLedgerForEmployees(
supabase,
companyId,
(originalEmployees || []).map((sre) => sre.employee_id as string),
)
if (!ledgerSync.ok) {
log.warn('vacation ledger sync failed after correction', { message: ledgerSync.message })
}
return NextResponse.json({
data: correctionRun,
message: 'Korrigeringskörning skapad. Originalverifikationer har makulerats (storno). Redigera och beräkna om den nya körningen.',
@@ -4,6 +4,8 @@ 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 { removeEmployeeFromRun } from '@/lib/salary/run-employees'
import { getErrorEntry } from '@/lib/errors/structured-errors'
ensureInitialized()
@@ -189,27 +191,18 @@ export const DELETE = withRouteContext<{ params: Promise<{ id: string; employeeI
const { id, employeeId } = await params
const { supabase, companyId } = ctx
// Verify run is draft
const { data: run } = await supabase
.from('salary_runs')
.select('id, status')
.eq('id', id)
.eq('company_id', companyId)
.single()
const result = await removeEmployeeFromRun(supabase, {
companyId,
salaryRunId: id,
employeeId,
})
if (!run) return NextResponse.json({ error: 'Lönekörning hittades inte' }, { status: 404 })
if (run.status !== 'draft') return NextResponse.json({ error: 'Kan bara redigera utkast' }, { status: 400 })
// Delete the salary_run_employee (cascades to salary_line_items via ON DELETE CASCADE)
const { error } = await supabase
.from('salary_run_employees')
.delete()
.eq('salary_run_id', id)
.eq('employee_id', employeeId)
.eq('company_id', companyId)
if (error) {
return NextResponse.json({ error: error.message }, { status: 500 })
if (!result.ok) {
const entry = getErrorEntry(result.code)
return NextResponse.json(
{ error: entry?.message_sv ?? 'Något gick fel', code: result.code },
{ status: entry?.httpStatus ?? 500 },
)
}
return NextResponse.json({ data: { deleted: true } })
+15 -87
View File
@@ -3,8 +3,8 @@ import { ensureInitialized } from '@/lib/init'
import { withRouteContext } from '@/lib/api/with-route-context'
import { validateBody } from '@/lib/api/validate'
import { AddEmployeeToRunSchema } from '@/lib/api/schemas'
import { getLineItemAccount } from '@/lib/salary/account-mapping'
import type { SalaryLineItemType } from '@/types'
import { addEmployeeToRun } from '@/lib/salary/run-employees'
import { getErrorEntry } from '@/lib/errors/structured-errors'
ensureInitialized()
@@ -18,94 +18,22 @@ export const POST = withRouteContext<{ params: Promise<{ id: string }> }>(
if (!validation.success) return validation.response
const body = validation.data
// Verify run is draft
const { data: run, error: runError } = await supabase
.from('salary_runs')
.select('id, status')
.eq('id', id)
.eq('company_id', companyId)
.single()
const result = await addEmployeeToRun(supabase, {
companyId,
salaryRunId: id,
employeeId: body.employee_id,
hoursWorked: body.hours_worked ?? null,
})
if (runError || !run) {
return NextResponse.json({ error: 'Lönekörning hittades inte' }, { status: 404 })
}
if (run.status !== 'draft') {
return NextResponse.json({ error: 'Kan bara lägga till anställda i utkast' }, { status: 400 })
if (!result.ok) {
const entry = getErrorEntry(result.code)
return NextResponse.json(
{ error: entry?.message_sv ?? 'Något gick fel', code: result.code },
{ status: entry?.httpStatus ?? 500 },
)
}
// Verify employee exists and is active
const { data: employee, error: empError } = await supabase
.from('employees')
.select('*')
.eq('id', body.employee_id)
.eq('company_id', companyId)
.eq('is_active', true)
.single()
if (empError || !employee) {
return NextResponse.json({ error: 'Anställd hittades inte' }, { status: 404 })
}
// Check if already added
const { data: existing } = await supabase
.from('salary_run_employees')
.select('id')
.eq('salary_run_id', id)
.eq('employee_id', body.employee_id)
.single()
if (existing) {
return NextResponse.json({ error: 'Anställd redan tillagd i denna lönekörning' }, { status: 409 })
}
// Snapshot employee data
const { data: sre, error: sreError } = await supabase
.from('salary_run_employees')
.insert({
salary_run_id: id,
employee_id: employee.id,
company_id: companyId,
employment_degree: employee.employment_degree,
monthly_salary: employee.monthly_salary || 0,
salary_type: employee.salary_type,
hours_worked: body.hours_worked || null,
tax_table_number: employee.tax_table_number,
tax_column: employee.tax_column,
})
.select()
.single()
if (sreError) {
return NextResponse.json({ error: sreError.message }, { status: 500 })
}
// Auto-create base salary line item
const baseSalaryType: SalaryLineItemType = employee.salary_type === 'monthly' ? 'monthly_salary' : 'hourly_salary'
let baseAmount: number
if (employee.salary_type === 'monthly') {
baseAmount = Math.round((employee.monthly_salary || 0) * (employee.employment_degree / 100) * 100) / 100
} else {
baseAmount = Math.round((employee.hourly_rate || 0) * (body.hours_worked || 0) * 100) / 100
}
await supabase
.from('salary_line_items')
.insert({
salary_run_employee_id: sre.id,
company_id: companyId,
item_type: baseSalaryType,
description: employee.salary_type === 'monthly' ? 'Grundlön' : 'Timlön',
quantity: employee.salary_type === 'hourly' ? body.hours_worked : null,
unit_price: employee.salary_type === 'hourly' ? employee.hourly_rate : null,
amount: baseAmount,
is_taxable: true,
is_avgift_basis: true,
is_vacation_basis: true,
account_number: getLineItemAccount(baseSalaryType, employee.employment_type),
sort_order: 0,
})
return NextResponse.json({ data: sre }, { status: 201 })
return NextResponse.json({ data: result.data }, { status: 201 })
},
{ requireWrite: true },
)
@@ -71,6 +71,9 @@ describe('PATCH /api/salary/runs/[id]/lines/[lineId]', () => {
const { enqueueMany } = authed()
enqueueMany([
{ data: { id: 'run-1', status: 'draft' } }, // salary_runs lookup
// The shared service verifies the line belongs to this run before
// writing (loadLineInRun join check).
{ data: { id: 'line-1', amount: 50, salary_run_employee: { salary_run_id: 'run-1' } } },
{ data: { id: 'line-1', amount: 100 } }, // update returning
])
const response = await PATCH(
@@ -119,6 +122,8 @@ describe('DELETE /api/salary/runs/[id]/lines/[lineId]', () => {
const { enqueueMany } = authed()
enqueueMany([
{ data: { id: 'run-1', status: 'draft' } }, // salary_runs lookup
// Run-membership verification added by the shared service.
{ data: { id: 'line-1', amount: 50, salary_run_employee: { salary_run_id: 'run-1' } } },
{ data: null }, // delete (error null)
])
const response = await DELETE(
@@ -3,49 +3,37 @@ import { ensureInitialized } from '@/lib/init'
import { withRouteContext } from '@/lib/api/with-route-context'
import { validateBody } from '@/lib/api/validate'
import { UpdateSalaryLineItemSchema } from '@/lib/api/schemas'
import { updatePayslipLine, deletePayslipLine } from '@/lib/salary/payslip-lines'
import { getErrorEntry } from '@/lib/errors/structured-errors'
ensureInitialized()
function errorResponse(code: string): NextResponse {
const entry = getErrorEntry(code)
return NextResponse.json(
{ error: entry?.message_sv ?? 'Något gick fel', code },
{ status: entry?.httpStatus ?? 500 },
)
}
export const PATCH = withRouteContext<{ params: Promise<{ id: string; lineId: string }> }>(
'salary.run.line.update',
async (request, ctx, { params }) => {
const { id, lineId } = await params
const { supabase, companyId } = ctx
// Verify run is draft
const { data: run } = await supabase
.from('salary_runs')
.select('id, status')
.eq('id', id)
.eq('company_id', companyId)
.single()
if (!run) return NextResponse.json({ error: 'Lönekörning hittades inte' }, { status: 404 })
if (run.status !== 'draft') return NextResponse.json({ error: 'Kan bara redigera utkast' }, { status: 400 })
const validation = await validateBody(request, UpdateSalaryLineItemSchema)
if (!validation.success) return validation.response
const body = validation.data
// Round amount if provided
const updates = { ...body }
if (updates.amount !== undefined) {
updates.amount = Math.round(updates.amount * 100) / 100
}
const result = await updatePayslipLine(supabase, {
companyId,
salaryRunId: id,
lineId,
patch: validation.data,
})
const { data: updated, error } = await supabase
.from('salary_line_items')
.update(updates)
.eq('id', lineId)
.eq('company_id', companyId)
.select()
.single()
if (error || !updated) {
return NextResponse.json({ error: 'Rad hittades inte' }, { status: 404 })
}
return NextResponse.json({ data: updated })
if (!result.ok) return errorResponse(result.code)
return NextResponse.json({ data: result.data })
},
{ requireWrite: true },
)
@@ -56,27 +44,13 @@ export const DELETE = withRouteContext<{ params: Promise<{ id: string; lineId: s
const { id, lineId } = await params
const { supabase, companyId } = ctx
// Verify run is draft
const { data: run } = await supabase
.from('salary_runs')
.select('id, status')
.eq('id', id)
.eq('company_id', companyId)
.single()
if (!run) return NextResponse.json({ error: 'Lönekörning hittades inte' }, { status: 404 })
if (run.status !== 'draft') return NextResponse.json({ error: 'Kan bara redigera utkast' }, { status: 400 })
const { error } = await supabase
.from('salary_line_items')
.delete()
.eq('id', lineId)
.eq('company_id', companyId)
if (error) {
return NextResponse.json({ error: error.message }, { status: 500 })
}
const result = await deletePayslipLine(supabase, {
companyId,
salaryRunId: id,
lineId,
})
if (!result.ok) return errorResponse(result.code)
return NextResponse.json({ data: { deleted: true } })
},
{ requireWrite: true },
+16 -55
View File
@@ -3,7 +3,8 @@ import { ensureInitialized } from '@/lib/init'
import { withRouteContext } from '@/lib/api/with-route-context'
import { validateBody } from '@/lib/api/validate'
import { CreateSalaryLineItemSchema } from '@/lib/api/schemas'
import { getLineItemAccount } from '@/lib/salary/account-mapping'
import { createPayslipLine } from '@/lib/salary/payslip-lines'
import { getErrorEntry } from '@/lib/errors/structured-errors'
ensureInitialized()
@@ -15,64 +16,24 @@ export const POST = withRouteContext<{ params: Promise<{ id: string }> }>(
const validation = await validateBody(request, CreateSalaryLineItemSchema)
if (!validation.success) return validation.response
const body = validation.data
const { salary_run_employee_id, ...input } = validation.data
// Verify run is draft
const { data: run } = await supabase
.from('salary_runs')
.select('id, status')
.eq('id', id)
.eq('company_id', companyId)
.single()
const result = await createPayslipLine(supabase, {
companyId,
salaryRunId: id,
target: { salaryRunEmployeeId: salary_run_employee_id },
input,
})
if (!run) {
return NextResponse.json({ error: 'Lönekörning hittades inte' }, { status: 404 })
}
if (run.status !== 'draft') {
return NextResponse.json({ error: 'Kan bara redigera utkast' }, { status: 400 })
if (!result.ok) {
const entry = getErrorEntry(result.code)
return NextResponse.json(
{ error: entry?.message_sv ?? 'Något gick fel', code: result.code },
{ status: entry?.httpStatus ?? 500 },
)
}
// Verify salary_run_employee belongs to this run
const { data: sre } = await supabase
.from('salary_run_employees')
.select('id, employee_id')
.eq('id', body.salary_run_employee_id)
.eq('salary_run_id', id)
.single()
if (!sre) {
return NextResponse.json({ error: 'Anställd finns inte i denna lönekörning' }, { status: 404 })
}
// Auto-resolve account if not provided
const accountNumber = body.account_number || getLineItemAccount(body.item_type as never)
const { data: lineItem, error } = await supabase
.from('salary_line_items')
.insert({
salary_run_employee_id: body.salary_run_employee_id,
company_id: companyId,
item_type: body.item_type,
description: body.description,
quantity: body.quantity || null,
unit_price: body.unit_price || null,
amount: Math.round(body.amount * 100) / 100,
is_taxable: body.is_taxable,
is_avgift_basis: body.is_avgift_basis,
is_vacation_basis: body.is_vacation_basis,
is_gross_deduction: body.is_gross_deduction,
is_net_deduction: body.is_net_deduction,
account_number: accountNumber,
sort_order: body.sort_order,
})
.select()
.single()
if (error) {
return NextResponse.json({ error: error.message }, { status: 500 })
}
return NextResponse.json({ data: lineItem }, { status: 201 })
return NextResponse.json({ data: result.data }, { status: 201 })
},
{ requireWrite: true },
)
+49
View File
@@ -0,0 +1,49 @@
import { NextResponse } from 'next/server'
import { ensureInitialized } from '@/lib/init'
import { withRouteContext } from '@/lib/api/with-route-context'
import { roundOre } from '@/lib/money'
ensureInitialized()
/** Open vacation-ledger rows joined with employee names, for the salary
* dashboard's Semester card. Empty until the first booking seeds the ledger
* (payroll gap-closure 3.5). */
export const GET = withRouteContext(
'salary.vacation-balances.list',
async (_request, { supabase, companyId }) => {
const { data, error } = await supabase
.from('employee_vacation_balances')
.select(
'id, employee_id, vacation_year_start, entitled_days, accrued_days, taken_days, saved_days, forced_payout_days, employee:employees(first_name, last_name, is_active)',
)
.eq('company_id', companyId)
.eq('status', 'open')
.order('vacation_year_start', { ascending: false })
if (error) {
return NextResponse.json({ error: error.message }, { status: 500 })
}
const rows = ((data ?? []) as Array<Record<string, unknown>>)
.filter((r) => (r.employee as { is_active?: boolean } | null)?.is_active !== false)
.map((r) => {
const employee = r.employee as { first_name: string; last_name: string } | null
const savedDays = (r.saved_days as Record<string, number> | null) ?? {}
const entitled = (r.entitled_days as number) ?? 0
const taken = (r.taken_days as number) ?? 0
return {
employee_vacation_balance_id: r.id,
employee_id: r.employee_id,
employee_name: employee ? `${employee.first_name} ${employee.last_name}` : '',
vacation_year_start: r.vacation_year_start,
entitled_days: entitled,
taken_days: taken,
remaining_days: roundOre(entitled - taken),
saved_days_total: Object.values(savedDays).reduce((s, d) => s + (Number(d) || 0), 0),
forced_payout_days: r.forced_payout_days ?? 0,
}
})
return NextResponse.json({ data: rows })
},
)
@@ -0,0 +1,76 @@
import { z } from 'zod'
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 {
commitVacationYearClose,
previewVacationYearClose,
} from '@/lib/salary/semesterberedning'
import { getVacationYearBasis } from '@/lib/salary/vacation-ledger'
import { getClosableYearStart } from '@/lib/salary/vacation-year'
import { getErrorEntry } from '@/lib/errors/structured-errors'
ensureInitialized()
const isoDate = z.string().regex(/^\d{4}-\d{2}-\d{2}$/)
const CloseBody = z.object({
vacation_year_start: isoDate.optional(),
book_adjustment: z.boolean().default(true),
/** true = return the review report only, write nothing. The dialog always
* previews before the user confirms (soft-guard convention). */
dry_run: z.boolean().default(false),
})
/** Semesterberedning + semesterårsavslut for the dashboard dialog
* (payroll gap-closure 3.5). Same service as the v1 route and the MCP
* executor. */
export const POST = withRouteContext(
'salary.vacation-year-close',
async (request, { supabase, companyId, user, log }) => {
const validation = await validateBody(request, CloseBody)
if (!validation.success) return validation.response
const body = validation.data
let yearStart = body.vacation_year_start
if (!yearStart) {
const basis = await getVacationYearBasis(supabase, companyId)
yearStart = getClosableYearStart(new Date().toISOString().slice(0, 10), basis)
}
if (body.dry_run) {
const preview = await previewVacationYearClose(supabase, companyId, yearStart)
if (!preview.ok) {
const entry = getErrorEntry(preview.code)
return NextResponse.json(
{ error: entry?.message_sv ?? preview.code, code: preview.code },
{ status: entry?.httpStatus ?? 500 },
)
}
return NextResponse.json({ data: { report: preview.data, committed: false } })
}
const result = await commitVacationYearClose(supabase, companyId, user.id, yearStart, {
bookAdjustment: body.book_adjustment,
})
if (!result.ok) {
const entry = getErrorEntry(result.code)
log.warn('vacation year close failed', { code: result.code })
return NextResponse.json(
{ error: entry?.message_sv ?? result.code, code: result.code, details: result.details },
{ status: entry?.httpStatus ?? 500 },
)
}
return NextResponse.json({
data: {
committed: true,
vacation_year_closure_id: result.data.closure_id,
adjustment_entry_id: result.data.adjustment_entry_id,
report: result.data.report,
},
})
},
{ requireWrite: true },
)
+44
View File
@@ -83,4 +83,48 @@ describe('PUT /api/settings', () => {
expect(status).toBe(200)
expect(body.data.company_name).toBe('New Name')
})
it('blocks a vacation-year basis change while open balances exist', async () => {
enqueueMany([
{ data: { salary_vacation_year_basis: 'calendar', onboarding_complete: true } }, // oldSettings
{ data: null, count: 2 }, // open-rows count
])
const request = createMockRequest('/api/settings', {
method: 'PUT',
body: { salary_vacation_year_basis: 'statutory_apr_mar' },
})
const response = await PUT(request, { params: Promise.resolve({}) })
const { status } = await parseJsonResponse(response)
expect(status).toBe(400)
// The guard consumed the count result and the update never ran.
expect(supabase.from.mock.calls.map(([table]) => table)).toEqual([
'company_settings',
'employee_vacation_balances',
])
})
it('fails closed when the open-balances guard query errors', async () => {
enqueueMany([
{ data: { salary_vacation_year_basis: 'calendar', onboarding_complete: true } }, // oldSettings
{ data: null, count: null, error: { message: 'connection reset' } }, // guard query fails
])
const request = createMockRequest('/api/settings', {
method: 'PUT',
body: { salary_vacation_year_basis: 'statutory_apr_mar' },
})
const response = await PUT(request, { params: Promise.resolve({}) })
const { status } = await parseJsonResponse(response)
expect(status).toBe(500)
// The 500 must come from the guard, not from company_settings.update()
// swallowing the queued error: the guard query ran and no second
// company_settings query followed it.
expect(supabase.from.mock.calls.map(([table]) => table)).toEqual([
'company_settings',
'employee_vacation_balances',
])
})
})
+30 -1
View File
@@ -40,7 +40,7 @@ export const PUT = withRouteContext(
// Fetch current settings to check for tax-relevant changes
const { data: oldSettings } = await supabase
.from('company_settings')
.select('entity_type, moms_period, f_skatt, vat_registered, vat_number, pays_salaries, fiscal_year_start_month, onboarding_complete')
.select('entity_type, moms_period, f_skatt, vat_registered, vat_number, pays_salaries, fiscal_year_start_month, onboarding_complete, salary_vacation_year_basis')
.eq('company_id', companyId)
.single()
@@ -65,6 +65,35 @@ export const PUT = withRouteContext(
)
}
// Vacation year basis (payroll gap-closure 3.1): changing the boundary
// while OPEN vacation-ledger rows exist would orphan them (rows are keyed
// by vacation_year_start). Close the current year first.
if (
body.salary_vacation_year_basis !== undefined &&
body.salary_vacation_year_basis !==
(oldSettings as Record<string, unknown> | null)?.salary_vacation_year_basis
) {
const { count: openRows, error: openRowsError } = await supabase
.from('employee_vacation_balances')
.select('id', { count: 'exact', head: true })
.eq('company_id', companyId)
.eq('status', 'open')
// Fail closed: a failed check must not let the basis change through
// and orphan open vacation-ledger rows.
if (openRowsError) {
return NextResponse.json({ error: openRowsError.message }, { status: 500 })
}
if ((openRows ?? 0) > 0) {
return NextResponse.json(
{
error:
'Semesterårets basis kan inte ändras medan öppna semestersaldon finns. Stäng semesteråret först.',
},
{ status: 400 },
)
}
}
// Validate: VAT-registered must have VAT number (ML 11 kap. 8§) and moms period (SFL 26 kap.)
const effectiveVatRegistered = body.vat_registered ?? oldSettings?.vat_registered
if (effectiveVatRegistered === true) {
@@ -0,0 +1,405 @@
/**
* Tests for the v1 absence endpoints (payroll gap-closure 1.4).
*
* GET/PUT/DELETE /employees/{id}/absence. PUT is the first PUT route on v1:
* the wrapper's REQUIRES_IDEMPOTENCY set was extended to include it, and the
* test-key case below is the regression test for that hole (a test key on a
* PUT must be forced into dry-run, never write through).
*/
import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
beforeAll(() => {
if (process.env.NODE_ENV !== 'test') {
throw new Error(
`absence route tests require NODE_ENV=test (got ${process.env.NODE_ENV ?? 'undefined'})`,
)
}
process.env.NEXT_PUBLIC_SUPABASE_URL ||= 'http://localhost:54321'
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY ||= 'test-anon-key'
})
vi.mock('@/lib/auth/api-keys', async () => {
const actual = await vi.importActual<typeof import('@/lib/auth/api-keys')>('@/lib/auth/api-keys')
return {
...actual,
validateApiKey: vi.fn(),
createServiceClientNoCookies: vi.fn(),
}
})
vi.mock('@supabase/supabase-js', async () => {
const actual = await vi.importActual<typeof import('@supabase/supabase-js')>('@supabase/supabase-js')
return { ...actual, createClient: vi.fn().mockReturnValue({}) }
})
import { validateApiKey, createServiceClientNoCookies } from '@/lib/auth/api-keys'
import { GET as listAbsence, PUT as putAbsence, DELETE as deleteAbsence } from '../route'
const mockValidate = validateApiKey as ReturnType<typeof vi.fn>
const mockServiceClient = createServiceClientNoCookies as ReturnType<typeof vi.fn>
interface TableResp {
data?: unknown
error?: unknown
count?: number | null
}
function makeFlexibleSupabase(byTable: Record<string, TableResp | TableResp[]>) {
const queues = new Map<string, TableResp[]>()
for (const [t, val] of Object.entries(byTable)) {
queues.set(t, Array.isArray(val) ? [...val] : [val])
}
const tableCalls: string[] = []
const buildChain = (table: string): unknown => {
const handler: ProxyHandler<object> = {
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, count: null })
resolve({ count: null, ...next })
}
}
return (..._args: unknown[]) => buildChain(table)
},
}
return new Proxy({}, handler)
}
return {
tableCalls,
from: vi.fn((table: string) => {
tableCalls.push(table)
return buildChain(table)
}),
}
}
const COMPANY_ID = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa'
const EMPLOYEE_ID = 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb'
const USER_ID = 'user-1'
const SAMPLE_DAY = {
id: 'eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeee',
absence_date: '2026-03-03',
absence_type: 'sick',
hours: 8,
notes: null,
salary_run_employee_id: null,
created_at: '2026-03-03T08:00:00Z',
updated_at: '2026-03-03T08:00:00Z',
}
function makeRequest(url: string, init?: RequestInit): Request {
return new Request(url, {
...init,
headers: {
Authorization: 'Bearer test-fixture-not-a-real-key',
...(init?.headers ?? {}),
},
})
}
function absenceParams(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: ['payroll:read', 'payroll:write'],
mode: 'live',
})
})
describe('GET /api/v1/companies/:companyId/employees/:id/absence', () => {
it('lists absence days with qualified ids', async () => {
mockServiceClient.mockReturnValue(
makeFlexibleSupabase({
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
employees: { data: { id: EMPLOYEE_ID }, error: null },
salary_absence_days: { data: [SAMPLE_DAY], error: null },
}),
)
const res = await listAbsence(
makeRequest(
`https://x.test/api/v1/companies/${COMPANY_ID}/employees/${EMPLOYEE_ID}/absence?from=2026-03-01&to=2026-03-31`,
),
absenceParams(COMPANY_ID, EMPLOYEE_ID),
)
expect(res.status).toBe(200)
const body = await res.json()
expect(body.data).toHaveLength(1)
expect(body.data[0].salary_absence_day_id).toBe(SAMPLE_DAY.id)
expect(body.data[0].id).toBeUndefined()
})
it('rejects a missing from/to with 400', async () => {
mockServiceClient.mockReturnValue(
makeFlexibleSupabase({
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
}),
)
const res = await listAbsence(
makeRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/employees/${EMPLOYEE_ID}/absence`),
absenceParams(COMPANY_ID, EMPLOYEE_ID),
)
expect(res.status).toBe(400)
})
it('rejects a reversed range (from > to) with VALIDATION_ERROR', async () => {
mockServiceClient.mockReturnValue(
makeFlexibleSupabase({
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
}),
)
const res = await listAbsence(
makeRequest(
`https://x.test/api/v1/companies/${COMPANY_ID}/employees/${EMPLOYEE_ID}/absence?from=2026-03-31&to=2026-03-01`,
),
absenceParams(COMPANY_ID, EMPLOYEE_ID),
)
expect(res.status).toBe(400)
const body = await res.json()
expect(body.error.code).toBe('VALIDATION_ERROR')
})
it('rejects a range beyond 92 days with ABSENCE_RANGE_TOO_LARGE', async () => {
mockServiceClient.mockReturnValue(
makeFlexibleSupabase({
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
}),
)
const res = await listAbsence(
makeRequest(
`https://x.test/api/v1/companies/${COMPANY_ID}/employees/${EMPLOYEE_ID}/absence?from=2026-01-01&to=2026-12-31`,
),
absenceParams(COMPANY_ID, EMPLOYEE_ID),
)
expect(res.status).toBe(400)
const body = await res.json()
expect(body.error.code).toBe('ABSENCE_RANGE_TOO_LARGE')
})
it('returns 404 EMPLOYEE_NOT_FOUND for an unknown employee', async () => {
mockServiceClient.mockReturnValue(
makeFlexibleSupabase({
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
employees: { data: null, error: null },
}),
)
const res = await listAbsence(
makeRequest(
`https://x.test/api/v1/companies/${COMPANY_ID}/employees/${EMPLOYEE_ID}/absence?from=2026-03-01&to=2026-03-31`,
),
absenceParams(COMPANY_ID, EMPLOYEE_ID),
)
expect(res.status).toBe(404)
const body = await res.json()
expect(body.error.code).toBe('EMPLOYEE_NOT_FOUND')
})
})
describe('PUT /api/v1/companies/:companyId/employees/:id/absence', () => {
const validBody = { from: '2026-03-02', to: '2026-03-06', absence_type: 'sick' }
it('upserts the expanded range (happy path)', async () => {
mockServiceClient.mockReturnValue(
makeFlexibleSupabase({
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
employees: { data: { id: EMPLOYEE_ID }, error: null },
salary_absence_days: [
{ data: [SAMPLE_DAY, { ...SAMPLE_DAY, id: 'ffffffff-ffff-4fff-8fff-ffffffffffff', absence_date: '2026-03-04' }], error: null }, // bulk upsert
],
}),
)
const res = await putAbsence(
makeRequest(
`https://x.test/api/v1/companies/${COMPANY_ID}/employees/${EMPLOYEE_ID}/absence`,
{ method: 'PUT', body: JSON.stringify(validBody) },
),
absenceParams(COMPANY_ID, EMPLOYEE_ID),
)
expect(res.status).toBe(200)
const body = await res.json()
expect(body.data.count).toBe(2)
expect(body.data.days[0].salary_absence_day_id).toBeTruthy()
})
it('rejects from > to with 400', async () => {
mockServiceClient.mockReturnValue(
makeFlexibleSupabase({
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
}),
)
const res = await putAbsence(
makeRequest(
`https://x.test/api/v1/companies/${COMPANY_ID}/employees/${EMPLOYEE_ID}/absence`,
{ method: 'PUT', body: JSON.stringify({ ...validBody, from: '2026-03-10' }) },
),
absenceParams(COMPANY_ID, EMPLOYEE_ID),
)
expect(res.status).toBe(400)
})
it('maps the 24h trigger to 409 ABSENCE_HOURS_CONFLICT', async () => {
mockServiceClient.mockReturnValue(
makeFlexibleSupabase({
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
employees: { data: { id: EMPLOYEE_ID }, error: null },
salary_absence_days: [
{ data: null, error: { code: '23514', message: 'Total tid över 24h' } },
],
}),
)
const res = await putAbsence(
makeRequest(
`https://x.test/api/v1/companies/${COMPANY_ID}/employees/${EMPLOYEE_ID}/absence`,
{ method: 'PUT', body: JSON.stringify(validBody) },
),
absenceParams(COMPANY_ID, EMPLOYEE_ID),
)
expect(res.status).toBe(409)
const body = await res.json()
expect(body.error.code).toBe('ABSENCE_HOURS_CONFLICT')
})
it('returns a dry-run preview without writing', async () => {
const supabaseMock = makeFlexibleSupabase({
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
employees: { data: { id: EMPLOYEE_ID }, error: null },
})
mockServiceClient.mockReturnValue(supabaseMock)
const res = await putAbsence(
makeRequest(
`https://x.test/api/v1/companies/${COMPANY_ID}/employees/${EMPLOYEE_ID}/absence?dry_run=true`,
{ method: 'PUT', body: JSON.stringify(validBody) },
),
absenceParams(COMPANY_ID, EMPLOYEE_ID),
)
expect(res.status).toBe(200)
expect(res.headers.get('X-Dry-Run')).toBe('true')
const body = await res.json()
expect(body.data.preview.count).toBe(5)
expect(supabaseMock.tableCalls).not.toContain('salary_absence_days')
})
it('forces TEST KEYS into dry-run on PUT (wrapper REQUIRES_IDEMPOTENCY regression)', async () => {
// Before the wrapper hardening, PUT was missing from REQUIRES_IDEMPOTENCY:
// a test key would have written through. This test locks the fix in.
mockValidate.mockResolvedValue({
userId: USER_ID,
companyId: COMPANY_ID,
apiKeyId: 'ak_test',
apiKeyName: 'test key',
scopes: ['payroll:read', 'payroll:write'],
mode: 'test',
})
const supabaseMock = makeFlexibleSupabase({
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
employees: { data: { id: EMPLOYEE_ID }, error: null },
})
mockServiceClient.mockReturnValue(supabaseMock)
const res = await putAbsence(
makeRequest(
`https://x.test/api/v1/companies/${COMPANY_ID}/employees/${EMPLOYEE_ID}/absence`,
{ method: 'PUT', body: JSON.stringify(validBody) },
),
absenceParams(COMPANY_ID, EMPLOYEE_ID),
)
expect(res.status).toBe(200)
expect(res.headers.get('X-Dry-Run')).toBe('true')
expect(res.headers.get('X-Gnubok-Mode')).toBe('test')
// The write table was never touched.
expect(supabaseMock.tableCalls).not.toContain('salary_absence_days')
})
it('rejects keys without payroll:write scope', async () => {
mockValidate.mockResolvedValue({
userId: USER_ID,
companyId: COMPANY_ID,
apiKeyId: 'ak_1',
apiKeyName: 'read-only',
scopes: ['payroll:read'],
mode: 'live',
})
mockServiceClient.mockReturnValue(makeFlexibleSupabase({}))
const res = await putAbsence(
makeRequest(
`https://x.test/api/v1/companies/${COMPANY_ID}/employees/${EMPLOYEE_ID}/absence`,
{ method: 'PUT', body: JSON.stringify(validBody) },
),
absenceParams(COMPANY_ID, EMPLOYEE_ID),
)
expect(res.status).toBe(403)
})
it('returns 401 without a bearer token', async () => {
mockValidate.mockResolvedValue(null)
mockServiceClient.mockReturnValue(makeFlexibleSupabase({}))
const res = await putAbsence(
new Request(
`https://x.test/api/v1/companies/${COMPANY_ID}/employees/${EMPLOYEE_ID}/absence`,
{ method: 'PUT', body: JSON.stringify(validBody) },
),
absenceParams(COMPANY_ID, EMPLOYEE_ID),
)
expect(res.status).toBe(401)
})
})
describe('DELETE /api/v1/companies/:companyId/employees/:id/absence', () => {
it('deletes the range and returns deleted_count', async () => {
mockServiceClient.mockReturnValue(
makeFlexibleSupabase({
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
employees: { data: { id: EMPLOYEE_ID }, error: null },
salary_absence_days: { data: null, error: null, count: 3 },
}),
)
const res = await deleteAbsence(
makeRequest(
`https://x.test/api/v1/companies/${COMPANY_ID}/employees/${EMPLOYEE_ID}/absence?from=2026-03-01&to=2026-03-31&type=sick`,
{ method: 'DELETE' },
),
absenceParams(COMPANY_ID, EMPLOYEE_ID),
)
expect(res.status).toBe(200)
const body = await res.json()
expect(body.data.deleted_count).toBe(3)
})
it('requires from and to', async () => {
mockServiceClient.mockReturnValue(
makeFlexibleSupabase({
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
}),
)
const res = await deleteAbsence(
makeRequest(
`https://x.test/api/v1/companies/${COMPANY_ID}/employees/${EMPLOYEE_ID}/absence`,
{ method: 'DELETE' },
),
absenceParams(COMPANY_ID, EMPLOYEE_ID),
)
expect(res.status).toBe(400)
})
})
@@ -0,0 +1,362 @@
/**
* /api/v1/companies/{companyId}/employees/{id}/absence
*
* GET : list absence days in a date range (max 92 days: the range IS the
* pagination, no cursor).
* PUT : range upsert. Expands [from, to] to per-day rows on the natural
* key (employee, date, type). Truly idempotent: retries converge.
* DELETE : range delete (optional type filter). Returns deleted_count.
*
* Storage is per-day (sjuklönelagen karens/day-14 boundaries and AGI 2025+
* per-event Frånvarouppgift derive from day rows); the range payload is API
* ergonomics only. Pre-cutover backfill is legal at any date: imported
* history feeds the sick-segment lookback in the calculation engine.
*/
import { z } from 'zod'
import { ok } from '@/lib/api/v1/response'
import { dryRunPreview } from '@/lib/api/v1/dry-run'
import { registerEndpoint, dataEnvelope, listEnvelope } from '@/lib/api/v1/registry'
import { withApiV1 } from '@/lib/api/v1/with-api-v1'
import { v1ErrorResponseFromCode } from '@/lib/api/v1/errors'
import { AbsenceTypeSchema } from '@/lib/api/schemas'
import {
ABSENCE_RANGE_MAX_DAYS,
deleteAbsenceRange,
listAbsenceDays,
upsertAbsenceRange,
} from '@/lib/salary/absence'
const isoDate = z.string().regex(/^\d{4}-\d{2}-\d{2}$/, 'Expected YYYY-MM-DD date format')
const AbsenceDay = z.object({
salary_absence_day_id: z.string().uuid(),
absence_date: z.string(),
absence_type: AbsenceTypeSchema,
hours: z.number(),
notes: z.string().nullable(),
salary_run_employee_id: z.string().uuid().nullable(),
created_at: z.string(),
updated_at: z.string(),
})
const RangeQuery = z
.object({
from: isoDate,
to: isoDate,
type: AbsenceTypeSchema.optional(),
})
.refine((v) => v.from <= v.to, { message: 'from must be <= to', path: ['from'] })
registerEndpoint({
operation: 'employees.absence.list',
method: 'GET',
path: '/api/v1/companies/:companyId/employees/:id/absence',
summary: 'List absence days for an employee in a date range.',
description:
'Returns per-day absence rows (sick, vab, parental, ...) between ?from and ?to (inclusive, max 92 days). No cursor pagination: the bounded range is the page. Optional ?type filter.',
useWhen:
'You need an employee\'s registered absence: to reconcile with an external time-tracking system, to verify what the salary engine will derive, or to display a calendar.',
doNotUseFor:
'The derived pay impact (karensavdrag, sjuklön lines): that lives on the payslip detail after :calculate. Worked hours for hourly staff: separate register, not on v1 yet.',
pitfalls: [
'Ranges over 92 days return 400 ABSENCE_RANGE_TOO_LARGE: iterate quarters instead.',
'A day can carry multiple rows with different absence_type values (e.g. half-day sick + half-day vab).',
'Rows may reference the salary run that consumed them via salary_run_employee_id.',
],
example: {
response: {
data: [
{
salary_absence_day_id: 'abs_91d2…',
absence_date: '2026-03-03',
absence_type: 'sick',
hours: 8,
notes: null,
},
],
meta: { request_id: 'req_…', api_version: '2026-05-12' },
},
},
scope: 'payroll:read',
risk: 'low',
idempotent: true,
reversible: false,
dryRunSupported: false,
response: { success: listEnvelope(AbsenceDay) },
})
export const GET = withApiV1<{ params: Promise<{ companyId: string; id: string }> }>(
'employees.absence.list',
async (request, ctx, params) => {
const { id } = await params.params
const idParse = z.string().uuid().safeParse(id)
if (!idParse.success) {
return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, {
requestId: ctx.requestId,
details: { field: 'id', message: 'Employee id must be a UUID.' },
})
}
const url = new URL(request.url)
const parsed = RangeQuery.safeParse({
from: url.searchParams.get('from') ?? undefined,
to: url.searchParams.get('to') ?? undefined,
type: url.searchParams.get('type') ?? undefined,
})
if (!parsed.success) {
return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, {
requestId: ctx.requestId,
details: {
issues: parsed.error.issues.map((i) => ({
field: i.path.join('.'),
message: i.message,
})),
},
})
}
const { from, to, type } = parsed.data
// Same cap as writes: the bounded range is the pagination contract.
const spanMs = Date.parse(`${to}T00:00:00Z`) - Date.parse(`${from}T00:00:00Z`)
if (spanMs < 0 || spanMs / 86_400_000 + 1 > ABSENCE_RANGE_MAX_DAYS) {
return v1ErrorResponseFromCode('ABSENCE_RANGE_TOO_LARGE', ctx.log, {
requestId: ctx.requestId,
details: { from, to, max_days: ABSENCE_RANGE_MAX_DAYS },
})
}
const result = await listAbsenceDays(ctx.supabase, {
companyId: ctx.companyId!,
employeeId: idParse.data,
from,
to,
absenceType: type,
})
if (!result.ok) {
return v1ErrorResponseFromCode(result.code, ctx.log, {
requestId: ctx.requestId,
details: result.details,
})
}
return ok(
result.data.map(({ id: rowId, ...rest }) => ({ salary_absence_day_id: rowId, ...rest })),
{ requestId: ctx.requestId },
)
},
)
// ──────────────────────────────────────────────────────────────────
// PUT: range upsert
// ──────────────────────────────────────────────────────────────────
const UpsertRangeBody = z
.object({
from: isoDate,
to: isoDate,
absence_type: AbsenceTypeSchema,
hours_per_day: z.number().positive().max(24).default(8),
notes: z.string().max(2000).optional(),
include_weekends: z.boolean().default(false),
})
.refine((v) => v.from <= v.to, { message: 'from must be <= to', path: ['from'] })
const UpsertRangeResponse = z.object({
count: z.number().int(),
days: z.array(AbsenceDay.partial({ salary_absence_day_id: true, notes: true, salary_run_employee_id: true, created_at: true, updated_at: true })),
})
registerEndpoint({
operation: 'employees.absence.upsert',
method: 'PUT',
path: '/api/v1/companies/:companyId/employees/:id/absence',
summary: 'Register absence for an employee over a date range.',
description:
'Expands [from, to] (max 92 days) to per-day rows and upserts them on the natural key (employee, date, type). Weekends are skipped unless include_weekends=true. Single day = from == to. Idempotent by construction: replaying the same PUT converges on the same rows.',
useWhen:
'"Anna was sick 3-7 March": one call registers the whole event. Also for pre-cutover history backfill when migrating from another payroll system (any past date is legal; imported sick days feed the karensavdrag lookback).',
doNotUseFor:
'Vacation day REQUESTS/approval workflows (out of scope). Editing hours on one existing day inside a range: PUT the single day (from == to) with the new hours.',
pitfalls: [
'Weekends are skipped by default: pass include_weekends=true for schedules that span them.',
'Upsert REPLACES the (date, type) rows in the range: hours/notes are overwritten, not merged.',
'A day whose combined absence + worked hours exceed 24h returns 409 ABSENCE_HOURS_CONFLICT and the whole range is rejected (atomic).',
'Registering absence does not recompute an open salary run: call POST /salary-runs/{id}/calculate afterwards.',
],
example: {
request: { from: '2026-03-03', to: '2026-03-07', absence_type: 'sick' },
response: {
data: { count: 5, days: [{ absence_date: '2026-03-03', absence_type: 'sick', hours: 8 }] },
meta: { request_id: 'req_…', api_version: '2026-05-12' },
},
},
scope: 'payroll:write',
risk: 'low',
idempotent: true,
reversible: true,
dryRunSupported: true,
request: { body: UpsertRangeBody },
response: { success: dataEnvelope(UpsertRangeResponse) },
})
export const PUT = withApiV1<{ params: Promise<{ companyId: string; id: string }> }>(
'employees.absence.upsert',
async (request, ctx, params) => {
const { id } = await params.params
const idParse = z.string().uuid().safeParse(id)
if (!idParse.success) {
return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, {
requestId: ctx.requestId,
details: { field: 'id', message: 'Employee id must be a UUID.' },
})
}
let rawBody: unknown
try {
rawBody = await request.json()
} catch {
return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, {
requestId: ctx.requestId,
details: { field: 'body', message: 'Body is not valid JSON.' },
})
}
const parsed = UpsertRangeBody.safeParse(rawBody)
if (!parsed.success) {
return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, {
requestId: ctx.requestId,
details: {
issues: parsed.error.issues.map((i) => ({
field: i.path.join('.'),
message: i.message,
})),
},
})
}
const body = parsed.data
const result = await upsertAbsenceRange(ctx.supabase, {
companyId: ctx.companyId!,
employeeId: idParse.data,
from: body.from,
to: body.to,
absenceType: body.absence_type,
hoursPerDay: body.hours_per_day,
notes: body.notes ?? null,
includeWeekends: body.include_weekends,
dryRun: ctx.dryRun,
})
if (!result.ok) {
return v1ErrorResponseFromCode(result.code, ctx.log, {
requestId: ctx.requestId,
details: result.details,
})
}
const payload = {
count: result.data.count,
days: result.data.days.map((d) => {
const { id: rowId, ...rest } = d as { id?: string } & Record<string, unknown>
return rowId ? { salary_absence_day_id: rowId, ...rest } : rest
}),
}
if (ctx.dryRun) {
return dryRunPreview(payload, { requestId: ctx.requestId, log: ctx.log })
}
return ok(payload, { requestId: ctx.requestId })
},
{ requireIdempotencyKey: false },
)
// ──────────────────────────────────────────────────────────────────
// DELETE: range delete
// ──────────────────────────────────────────────────────────────────
const DeleteRangeResponse = z.object({ deleted_count: z.number().int() })
registerEndpoint({
operation: 'employees.absence.delete',
method: 'DELETE',
path: '/api/v1/companies/:companyId/employees/:id/absence',
summary: 'Delete absence days for an employee in a date range.',
description:
'Deletes per-day absence rows between ?from and ?to (inclusive), optionally filtered by ?type. Returns deleted_count (200, not 204) so callers can verify how many rows went.',
useWhen:
'An absence event was registered by mistake or ended early: "Anna came back Thursday, delete Thu-Fri sick days".',
doNotUseFor:
'Correcting hours on a day: PUT the day again instead. Rows already consumed by a BOOKED run: deleting them does not un-book the run; use the run correction flow.',
pitfalls: [
'Without ?type, ALL absence types in the range are deleted.',
'deleted_count: 0 with a 200 means nothing matched: not an error.',
],
example: {
response: {
data: { deleted_count: 2 },
meta: { request_id: 'req_…', api_version: '2026-05-12' },
},
},
scope: 'payroll:write',
risk: 'low',
idempotent: true,
reversible: false,
dryRunSupported: true,
response: { success: dataEnvelope(DeleteRangeResponse) },
})
export const DELETE = withApiV1<{ params: Promise<{ companyId: string; id: string }> }>(
'employees.absence.delete',
async (request, ctx, params) => {
const { id } = await params.params
const idParse = z.string().uuid().safeParse(id)
if (!idParse.success) {
return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, {
requestId: ctx.requestId,
details: { field: 'id', message: 'Employee id must be a UUID.' },
})
}
const url = new URL(request.url)
const parsed = RangeQuery.safeParse({
from: url.searchParams.get('from') ?? undefined,
to: url.searchParams.get('to') ?? undefined,
type: url.searchParams.get('type') ?? undefined,
})
if (!parsed.success) {
return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, {
requestId: ctx.requestId,
details: {
issues: parsed.error.issues.map((i) => ({
field: i.path.join('.'),
message: i.message,
})),
},
})
}
const { from, to, type } = parsed.data
const result = await deleteAbsenceRange(ctx.supabase, {
companyId: ctx.companyId!,
employeeId: idParse.data,
from,
to,
absenceType: type,
dryRun: ctx.dryRun,
})
if (!result.ok) {
return v1ErrorResponseFromCode(result.code, ctx.log, {
requestId: ctx.requestId,
details: result.details,
})
}
if (ctx.dryRun) {
return dryRunPreview(result.data, { requestId: ctx.requestId, log: ctx.log })
}
return ok(result.data, { requestId: ctx.requestId })
},
)
@@ -0,0 +1,518 @@
/**
* Tests for the v1 opening-balances endpoints (payroll gap-closure 2.3):
* GET/PUT /employees/{id}/opening-balances + bulk PUT /employees/opening-balances.
*/
import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
beforeAll(() => {
if (process.env.NODE_ENV !== 'test') {
throw new Error(
`opening-balances route tests require NODE_ENV=test (got ${process.env.NODE_ENV ?? 'undefined'})`,
)
}
process.env.NEXT_PUBLIC_SUPABASE_URL ||= 'http://localhost:54321'
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY ||= 'test-anon-key'
})
vi.mock('@/lib/auth/api-keys', async () => {
const actual = await vi.importActual<typeof import('@/lib/auth/api-keys')>('@/lib/auth/api-keys')
return {
...actual,
validateApiKey: vi.fn(),
createServiceClientNoCookies: vi.fn(),
}
})
vi.mock('@supabase/supabase-js', async () => {
const actual = await vi.importActual<typeof import('@supabase/supabase-js')>('@supabase/supabase-js')
return { ...actual, createClient: vi.fn().mockReturnValue({}) }
})
import { validateApiKey, createServiceClientNoCookies } from '@/lib/auth/api-keys'
import { GET as getBalances, PUT as putBalances } from '../route'
import { PUT as putBulk } from '../../../opening-balances/route'
const mockValidate = validateApiKey as ReturnType<typeof vi.fn>
const mockServiceClient = createServiceClientNoCookies as ReturnType<typeof vi.fn>
interface TableResp {
data?: unknown
error?: unknown
}
function makeFlexibleSupabase(byTable: Record<string, TableResp | TableResp[]>) {
const queues = new Map<string, TableResp[]>()
for (const [t, val] of Object.entries(byTable)) {
queues.set(t, Array.isArray(val) ? [...val] : [val])
}
const tableCalls: string[] = []
const buildChain = (table: string): unknown => {
const handler: ProxyHandler<object> = {
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[]) => buildChain(table)
},
}
return new Proxy({}, handler)
}
return {
tableCalls,
from: vi.fn((table: string) => {
tableCalls.push(table)
return buildChain(table)
}),
}
}
const COMPANY_ID = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa'
const EMPLOYEE_ID = 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb'
const ROW_ID = 'cccccccc-cccc-4ccc-8ccc-cccccccccccc'
const RUN_ID = 'dddddddd-dddd-4ddd-8ddd-dddddddddddd'
const USER_ID = 'user-1'
const CURRENT_YEAR = new Date().getFullYear()
const CUTOVER_DATE = `${CURRENT_YEAR}-07-01`
const SAMPLE_ROW = {
id: ROW_ID,
employee_id: EMPLOYEE_ID,
cutover_date: CUTOVER_DATE,
ytd_gross: 210000,
ytd_tax: 48000,
ytd_net: 162000,
vacation_paid_days_remaining: 12.5,
vacation_saved_days_by_year: { [`${CURRENT_YEAR - 1}`]: 5 },
opening_semester_liability: 42000,
opening_semester_liability_avgifter: 13196.4,
karens_periods_adjustment: 1,
created_at: '2026-07-01T08:00:00Z',
updated_at: '2026-07-01T08:00:00Z',
}
const VALID_BODY = {
cutover_date: CUTOVER_DATE,
ytd_gross: 210000,
ytd_tax: 48000,
ytd_net: 162000,
vacation_paid_days_remaining: 12.5,
vacation_saved_days_by_year: { [`${CURRENT_YEAR - 1}`]: 5 },
opening_semester_liability: 42000,
opening_semester_liability_avgifter: 13196.4,
karens_periods_adjustment: 1,
}
function makeRequest(url: string, init?: RequestInit): Request {
return new Request(url, {
...init,
headers: {
Authorization: 'Bearer test-fixture-not-a-real-key',
...(init?.headers ?? {}),
},
})
}
function detailParams(companyId: string, id: string) {
return { params: Promise.resolve({ companyId, id }) }
}
function companyParams(companyId: string) {
return { params: Promise.resolve({ companyId }) }
}
beforeEach(() => {
vi.clearAllMocks()
mockValidate.mockResolvedValue({
userId: USER_ID,
companyId: COMPANY_ID,
apiKeyId: 'ak_1',
apiKeyName: 'CI key',
scopes: ['payroll:read', 'payroll:write'],
mode: 'live',
})
})
describe('GET /employees/:id/opening-balances', () => {
it('returns the row with lock state (happy path, unlocked)', async () => {
mockServiceClient.mockReturnValue(
makeFlexibleSupabase({
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
employees: { data: { id: EMPLOYEE_ID }, error: null },
employee_opening_balances: { data: SAMPLE_ROW, error: null },
salary_run_employees: { data: [], error: null },
}),
)
const res = await getBalances(
makeRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/employees/${EMPLOYEE_ID}/opening-balances`),
detailParams(COMPANY_ID, EMPLOYEE_ID),
)
expect(res.status).toBe(200)
const body = await res.json()
expect(body.data.employee_opening_balances_id).toBe(ROW_ID)
expect(body.data.ytd_gross).toBe(210000)
expect(body.data.locked).toBe(false)
expect(body.data.locked_by_run_id).toBeNull()
expect(body.data.id).toBeUndefined()
})
it('reports locked with the blocking run id', async () => {
mockServiceClient.mockReturnValue(
makeFlexibleSupabase({
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
employees: { data: { id: EMPLOYEE_ID }, error: null },
employee_opening_balances: { data: SAMPLE_ROW, error: null },
salary_run_employees: {
data: [{ employee_id: EMPLOYEE_ID, salary_run: { id: RUN_ID, status: 'booked' } }],
error: null,
},
}),
)
const res = await getBalances(
makeRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/employees/${EMPLOYEE_ID}/opening-balances`),
detailParams(COMPANY_ID, EMPLOYEE_ID),
)
expect(res.status).toBe(200)
const body = await res.json()
expect(body.data.locked).toBe(true)
expect(body.data.locked_by_run_id).toBe(RUN_ID)
})
it('returns 404 when no balances are set', async () => {
mockServiceClient.mockReturnValue(
makeFlexibleSupabase({
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
employees: { data: { id: EMPLOYEE_ID }, error: null },
employee_opening_balances: { data: null, error: null },
}),
)
const res = await getBalances(
makeRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/employees/${EMPLOYEE_ID}/opening-balances`),
detailParams(COMPANY_ID, EMPLOYEE_ID),
)
expect(res.status).toBe(404)
})
it('returns 404 EMPLOYEE_NOT_FOUND for an unknown employee', async () => {
mockServiceClient.mockReturnValue(
makeFlexibleSupabase({
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
employees: { data: null, error: null },
}),
)
const res = await getBalances(
makeRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/employees/${EMPLOYEE_ID}/opening-balances`),
detailParams(COMPANY_ID, EMPLOYEE_ID),
)
expect(res.status).toBe(404)
const body = await res.json()
expect(body.error.code).toBe('EMPLOYEE_NOT_FOUND')
})
})
describe('PUT /employees/:id/opening-balances', () => {
it('upserts and returns the row (happy path)', async () => {
mockServiceClient.mockReturnValue(
makeFlexibleSupabase({
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
employees: {
data: [{ id: EMPLOYEE_ID, employment_start: '2024-01-15', is_active: true }],
error: null,
},
salary_run_employees: { data: [], error: null },
employee_opening_balances: { data: [SAMPLE_ROW], error: null },
}),
)
const res = await putBalances(
makeRequest(
`https://x.test/api/v1/companies/${COMPANY_ID}/employees/${EMPLOYEE_ID}/opening-balances`,
{ method: 'PUT', body: JSON.stringify(VALID_BODY) },
),
detailParams(COMPANY_ID, EMPLOYEE_ID),
)
expect(res.status).toBe(200)
const body = await res.json()
expect(body.data.employee_opening_balances_id).toBe(ROW_ID)
expect(body.data.locked).toBe(false)
})
it('returns 409 OPENING_BALANCES_LOCKED when a booked run exists', async () => {
mockServiceClient.mockReturnValue(
makeFlexibleSupabase({
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
employees: {
data: [{ id: EMPLOYEE_ID, employment_start: '2024-01-15', is_active: true }],
error: null,
},
salary_run_employees: {
data: [{ employee_id: EMPLOYEE_ID, salary_run: { id: RUN_ID, status: 'booked' } }],
error: null,
},
}),
)
const res = await putBalances(
makeRequest(
`https://x.test/api/v1/companies/${COMPANY_ID}/employees/${EMPLOYEE_ID}/opening-balances`,
{ method: 'PUT', body: JSON.stringify(VALID_BODY) },
),
detailParams(COMPANY_ID, EMPLOYEE_ID),
)
expect(res.status).toBe(409)
const body = await res.json()
expect(body.error.code).toBe('OPENING_BALANCES_LOCKED')
})
it('rejects a cutover_date that is not the first of a month', async () => {
mockServiceClient.mockReturnValue(
makeFlexibleSupabase({
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
}),
)
const res = await putBalances(
makeRequest(
`https://x.test/api/v1/companies/${COMPANY_ID}/employees/${EMPLOYEE_ID}/opening-balances`,
{ method: 'PUT', body: JSON.stringify({ ...VALID_BODY, cutover_date: `${CURRENT_YEAR}-07-15` }) },
),
detailParams(COMPANY_ID, EMPLOYEE_ID),
)
expect(res.status).toBe(400)
})
it('rejects ytd_tax above ytd_gross', async () => {
mockServiceClient.mockReturnValue(
makeFlexibleSupabase({
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
}),
)
const res = await putBalances(
makeRequest(
`https://x.test/api/v1/companies/${COMPANY_ID}/employees/${EMPLOYEE_ID}/opening-balances`,
{ method: 'PUT', body: JSON.stringify({ ...VALID_BODY, ytd_gross: 1000, ytd_tax: 2000 }) },
),
detailParams(COMPANY_ID, EMPLOYEE_ID),
)
expect(res.status).toBe(400)
})
it('rejects saved days with an origin year outside the 5-year window', async () => {
mockServiceClient.mockReturnValue(
makeFlexibleSupabase({
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
}),
)
const res = await putBalances(
makeRequest(
`https://x.test/api/v1/companies/${COMPANY_ID}/employees/${EMPLOYEE_ID}/opening-balances`,
{
method: 'PUT',
body: JSON.stringify({
...VALID_BODY,
vacation_saved_days_by_year: { [`${CURRENT_YEAR - 7}`]: 3 },
}),
},
),
detailParams(COMPANY_ID, EMPLOYEE_ID),
)
expect(res.status).toBe(400)
})
it('dry-run validates without writing', async () => {
const supabaseMock = makeFlexibleSupabase({
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
employees: {
data: [{ id: EMPLOYEE_ID, employment_start: '2024-01-15', is_active: true }],
error: null,
},
salary_run_employees: { data: [], error: null },
})
mockServiceClient.mockReturnValue(supabaseMock)
const res = await putBalances(
makeRequest(
`https://x.test/api/v1/companies/${COMPANY_ID}/employees/${EMPLOYEE_ID}/opening-balances?dry_run=true`,
{ method: 'PUT', body: JSON.stringify(VALID_BODY) },
),
detailParams(COMPANY_ID, EMPLOYEE_ID),
)
expect(res.status).toBe(200)
expect(res.headers.get('X-Dry-Run')).toBe('true')
expect(supabaseMock.tableCalls).not.toContain('employee_opening_balances')
})
it('forces test keys into dry-run on this PUT too', async () => {
mockValidate.mockResolvedValue({
userId: USER_ID,
companyId: COMPANY_ID,
apiKeyId: 'ak_test',
apiKeyName: 'test key',
scopes: ['payroll:write'],
mode: 'test',
})
const supabaseMock = makeFlexibleSupabase({
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
employees: {
data: [{ id: EMPLOYEE_ID, employment_start: '2024-01-15', is_active: true }],
error: null,
},
salary_run_employees: { data: [], error: null },
})
mockServiceClient.mockReturnValue(supabaseMock)
const res = await putBalances(
makeRequest(
`https://x.test/api/v1/companies/${COMPANY_ID}/employees/${EMPLOYEE_ID}/opening-balances`,
{ method: 'PUT', body: JSON.stringify(VALID_BODY) },
),
detailParams(COMPANY_ID, EMPLOYEE_ID),
)
expect(res.status).toBe(200)
expect(res.headers.get('X-Dry-Run')).toBe('true')
expect(supabaseMock.tableCalls).not.toContain('employee_opening_balances')
})
})
describe('PUT /employees/opening-balances (bulk)', () => {
it('is atomic: one bad item fails everything with a per-item error list', async () => {
const supabaseMock = makeFlexibleSupabase({
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
// Only the first employee exists.
employees: {
data: [{ id: EMPLOYEE_ID, employment_start: '2024-01-15', is_active: true }],
error: null,
},
salary_run_employees: { data: [], error: null },
})
mockServiceClient.mockReturnValue(supabaseMock)
const otherId = '99999999-9999-4999-8999-999999999999'
const res = await putBulk(
makeRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/employees/opening-balances`, {
method: 'PUT',
body: JSON.stringify({
items: [
{ employee_id: EMPLOYEE_ID, ...VALID_BODY },
{ employee_id: otherId, ...VALID_BODY },
],
}),
}),
companyParams(COMPANY_ID),
)
expect(res.status).toBe(400)
const body = await res.json()
const itemErrors = body.error.details.item_errors as Array<{ index: number; code: string }>
expect(itemErrors).toHaveLength(1)
expect(itemErrors[0].index).toBe(1)
expect(itemErrors[0].code).toBe('EMPLOYEE_NOT_FOUND')
// Zero writes happened.
expect(supabaseMock.tableCalls).not.toContain('employee_opening_balances')
})
it('upserts all items in one call (happy path)', async () => {
const secondId = '99999999-9999-4999-8999-999999999999'
mockServiceClient.mockReturnValue(
makeFlexibleSupabase({
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
employees: {
data: [
{ id: EMPLOYEE_ID, employment_start: '2024-01-15', is_active: true },
{ id: secondId, employment_start: '2025-03-01', is_active: true },
],
error: null,
},
salary_run_employees: { data: [], error: null },
employee_opening_balances: {
data: [SAMPLE_ROW, { ...SAMPLE_ROW, id: '11111111-1111-4111-8111-111111111111', employee_id: secondId }],
error: null,
},
}),
)
const res = await putBulk(
makeRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/employees/opening-balances`, {
method: 'PUT',
body: JSON.stringify({
items: [
{ employee_id: EMPLOYEE_ID, ...VALID_BODY },
{ employee_id: secondId, ...VALID_BODY },
],
}),
}),
companyParams(COMPANY_ID),
)
expect(res.status).toBe(200)
const body = await res.json()
expect(body.data.count).toBe(2)
})
it('rejects duplicate employee_ids in the same request', async () => {
mockServiceClient.mockReturnValue(
makeFlexibleSupabase({
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
}),
)
const res = await putBulk(
makeRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/employees/opening-balances`, {
method: 'PUT',
body: JSON.stringify({
items: [
{ employee_id: EMPLOYEE_ID, ...VALID_BODY },
{ employee_id: EMPLOYEE_ID, ...VALID_BODY },
],
}),
}),
companyParams(COMPANY_ID),
)
expect(res.status).toBe(400)
})
it('rejects keys without payroll:write scope', async () => {
mockValidate.mockResolvedValue({
userId: USER_ID,
companyId: COMPANY_ID,
apiKeyId: 'ak_1',
apiKeyName: 'read-only',
scopes: ['payroll:read'],
mode: 'live',
})
mockServiceClient.mockReturnValue(makeFlexibleSupabase({}))
const res = await putBulk(
makeRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/employees/opening-balances`, {
method: 'PUT',
body: JSON.stringify({ items: [{ employee_id: EMPLOYEE_ID, ...VALID_BODY }] }),
}),
companyParams(COMPANY_ID),
)
expect(res.status).toBe(403)
})
it('returns 401 without a bearer token', async () => {
mockValidate.mockResolvedValue(null)
mockServiceClient.mockReturnValue(makeFlexibleSupabase({}))
const res = await putBulk(
new Request(`https://x.test/api/v1/companies/${COMPANY_ID}/employees/opening-balances`, {
method: 'PUT',
body: JSON.stringify({ items: [{ employee_id: EMPLOYEE_ID, ...VALID_BODY }] }),
}),
companyParams(COMPANY_ID),
)
expect(res.status).toBe(401)
})
})
@@ -0,0 +1,213 @@
/**
* /api/v1/companies/{companyId}/employees/{id}/opening-balances
*
* GET : the employee's cutover opening balances + lock state.
* PUT : full-replace upsert (naturally idempotent). Editable until the
* employee appears in a BOOKED salary run; then 409
* OPENING_BALANCES_LOCKED (self-unlocks if that run is corrected).
*
* This is the payroll cutover surface for mid-year migrations from another
* payroll system: YTD accumulators (payslip continuity), vacation balances
* incl. sparade dagar by origin year, the opening semesterlöneskuld SEK
* (report-only: the 2920/2940 balance arrived via SIE opening balances),
* and the högriskskydd karens-count adjustment. Ongoing sick cases need no
* fields here: import pre-cutover days via PUT /employees/{id}/absence and
* the engine reconstructs segments, återinsjuknande, and karens state.
*/
import { z } from 'zod'
import { ok } from '@/lib/api/v1/response'
import { dryRunPreview } from '@/lib/api/v1/dry-run'
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 { OpeningBalancesFieldsSchema } from '@/lib/api/schemas'
import { getOpeningBalances, setOpeningBalancesBulk } from '@/lib/salary/opening-balances'
const OpeningBalancesResponse = z.object({
employee_opening_balances_id: z.string().uuid().nullable(),
employee_id: z.string().uuid(),
cutover_date: z.string(),
ytd_gross: z.number(),
ytd_tax: z.number(),
ytd_net: z.number(),
vacation_paid_days_remaining: z.number(),
vacation_saved_days_by_year: z.record(z.string(), z.number()),
opening_semester_liability: z.number(),
opening_semester_liability_avgifter: z.number(),
karens_periods_adjustment: z.number(),
locked: z.boolean(),
locked_by_run_id: z.string().uuid().nullable(),
})
registerEndpoint({
operation: 'employees.opening-balances.get',
method: 'GET',
path: '/api/v1/companies/:companyId/employees/:id/opening-balances',
summary: 'Get an employee\'s payroll cutover opening balances.',
description:
'Returns the opening balances set for a mid-year migration (YTD gross/tax/net, vacation balances, opening semesterlöneskuld, karens adjustment) plus the lock state: locked=true once the employee has a booked salary run.',
useWhen:
'Verifying cutover state before the first calculated run, or checking whether balances can still be edited (locked=false).',
doNotUseFor:
'The live vacation liability (GET /reports/vacation-liability includes the opening terms). Pre-cutover absence history: GET /employees/{id}/absence.',
pitfalls: [
'404 NOT_FOUND when no opening balances have been set: distinct from an all-zeros row.',
'locked_by_run_id names the booked run that froze the row; correcting that run unlocks it.',
],
example: {
response: {
data: {
employee_id: 'emp_77b2…',
cutover_date: '2026-07-01',
ytd_gross: 210000,
vacation_paid_days_remaining: 12.5,
locked: false,
},
meta: { request_id: 'req_…', api_version: '2026-05-12' },
},
},
scope: 'payroll:read',
risk: 'low',
idempotent: true,
reversible: false,
dryRunSupported: false,
response: { success: dataEnvelope(OpeningBalancesResponse) },
})
export const GET = withApiV1<{ params: Promise<{ companyId: string; id: string }> }>(
'employees.opening-balances.get',
async (_request, ctx, params) => {
const { id } = await params.params
const idParse = z.string().uuid().safeParse(id)
if (!idParse.success) {
return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, {
requestId: ctx.requestId,
details: { field: 'id', message: 'Employee id must be a UUID.' },
})
}
const result = await getOpeningBalances(ctx.supabase, {
companyId: ctx.companyId!,
employeeId: idParse.data,
})
if (!result.ok) {
return v1ErrorResponseFromCode(result.code, ctx.log, {
requestId: ctx.requestId,
details: result.details,
})
}
if (result.data === null) {
return v1ErrorResponseFromCode('NOT_FOUND', ctx.log, {
requestId: ctx.requestId,
details: { resource: 'employee_opening_balances', employee_id: idParse.data },
})
}
return ok(result.data, { requestId: ctx.requestId })
},
)
registerEndpoint({
operation: 'employees.opening-balances.set',
method: 'PUT',
path: '/api/v1/companies/:companyId/employees/:id/opening-balances',
summary: 'Set an employee\'s payroll cutover opening balances.',
description:
'Full-replace upsert of the cutover state: YTD gross/tax/net for the cutover year, paid vacation days remaining, sparade dagar keyed by origin year (5-year rule), opening semesterlöneskuld SEK (+avgifter), and karens periods not covered by imported absence rows. cutover_date must be the first of a month in the current or previous year, on/after employment_start.',
useWhen:
'Onboarding one employee during a mid-year migration from Fortnox/Visma/etc. For whole-company onboarding, prefer the bulk PUT /employees/opening-balances.',
doNotUseFor:
'SIE opening balances on the LEDGER (2920/2940 arrive via the SIE import). Ongoing sick cases: import pre-cutover days via PUT /employees/{id}/absence instead.',
pitfalls: [
'Full replace: omitted numeric fields reset to 0 (their defaults). Send the complete state every time.',
'409 OPENING_BALANCES_LOCKED once the employee has a booked run; correcting that run unlocks.',
'The opening liability is NOT booked by Accounted: it only feeds the vacation-liability report.',
'YTD affects payslip display and reports only; per-month tax and avgifter caps never read it.',
],
example: {
request: {
cutover_date: '2026-07-01',
ytd_gross: 210000,
ytd_tax: 48000,
ytd_net: 162000,
vacation_paid_days_remaining: 12.5,
vacation_saved_days_by_year: { '2025': 5 },
opening_semester_liability: 42000,
opening_semester_liability_avgifter: 13196.4,
karens_periods_adjustment: 1,
},
response: {
data: { employee_id: 'emp_77b2…', cutover_date: '2026-07-01', locked: false },
meta: { request_id: 'req_…', api_version: '2026-05-12' },
},
},
scope: 'payroll:write',
risk: 'medium',
idempotent: true,
reversible: true,
dryRunSupported: true,
request: { body: OpeningBalancesFieldsSchema },
response: { success: dataEnvelope(OpeningBalancesResponse) },
})
export const PUT = withApiV1<{ params: Promise<{ companyId: string; id: string }> }>(
'employees.opening-balances.set',
async (request, ctx, params) => {
const { id } = await params.params
const idParse = z.string().uuid().safeParse(id)
if (!idParse.success) {
return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, {
requestId: ctx.requestId,
details: { field: 'id', message: 'Employee id must be a UUID.' },
})
}
let rawBody: unknown
try {
rawBody = await request.json()
} catch {
return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, {
requestId: ctx.requestId,
details: { field: 'body', message: 'Body is not valid JSON.' },
})
}
const parsed = OpeningBalancesFieldsSchema.safeParse(rawBody)
if (!parsed.success) {
return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, {
requestId: ctx.requestId,
details: {
issues: parsed.error.issues.map((i) => ({
field: i.path.join('.'),
message: i.message,
})),
},
})
}
// The per-employee PUT is the bulk handler with one item: one validation
// and one upsert path to maintain.
const result = await setOpeningBalancesBulk(ctx.supabase, {
companyId: ctx.companyId!,
userId: ctx.userId,
items: [{ employee_id: idParse.data, ...parsed.data }],
dryRun: ctx.dryRun,
})
if (!result.ok) {
// Single-item calls surface the item's own error code directly (a
// one-element 422 list would just be indirection).
const itemError = result.itemErrors?.[0]
return v1ErrorResponseFromCode(itemError?.code ?? result.code, ctx.log, {
requestId: ctx.requestId,
details: itemError ? { message: itemError.message } : result.details,
})
}
const row = result.data.rows[0]
if (ctx.dryRun) {
return dryRunPreview(row, { requestId: ctx.requestId, log: ctx.log })
}
return ok(row, { requestId: ctx.requestId })
},
)
@@ -41,6 +41,10 @@ const EmployeeDetail = z.object({
employment_start: z.string(),
employment_end: z.string().nullable(),
employment_degree: z.number(),
// Arbetsschema-lite: weekly schedule driving the salary engine's hourly
// (173 at 40h) and daily (21 at 5d) divisors.
hours_per_week: z.number(),
workdays_per_week: z.number(),
salary_type: SalaryType,
monthly_salary: z.number().nullable(),
hourly_rate: z.number().nullable(),
@@ -62,6 +66,12 @@ const EmployeeDetail = z.object({
vaxa_stod_eligible: z.boolean(),
vaxa_stod_start: z.string().nullable(),
vaxa_stod_end: z.string().nullable(),
// Jämkning (Skatteverket beslut om ändrad beräkning av skatteavdrag):
// fixed withholding percentage for a bounded period, overrides the
// tax-table lookup at calculation time (payroll gap-closure 1.5).
jamkning_percentage: z.number().nullable(),
jamkning_valid_from: z.string().nullable(),
jamkning_valid_to: z.string().nullable(),
// Dimensions PR8: bag applied to the employee's P&L cost lines at booking.
default_dimensions: z.record(z.string(), z.string()),
is_active: z.boolean(),
@@ -70,7 +80,7 @@ const EmployeeDetail = z.object({
})
const EMPLOYEE_DETAIL_COLUMNS =
'id, first_name, last_name, personnummer, employment_type, employment_start, employment_end, employment_degree, salary_type, monthly_salary, hourly_rate, tax_table_number, tax_column, tax_municipality, is_sidoinkomst, f_skatt_status, clearing_number, bank_account_number, vacation_rule, vacation_days_per_year, semestertillagg_rate, email, phone, address_line1, postal_code, city, vaxa_stod_eligible, vaxa_stod_start, vaxa_stod_end, default_dimensions, is_active, created_at, updated_at'
'id, first_name, last_name, personnummer, employment_type, employment_start, employment_end, employment_degree, hours_per_week, workdays_per_week, salary_type, monthly_salary, hourly_rate, tax_table_number, tax_column, tax_municipality, is_sidoinkomst, f_skatt_status, clearing_number, bank_account_number, vacation_rule, vacation_days_per_year, semestertillagg_rate, email, phone, address_line1, postal_code, city, vaxa_stod_eligible, vaxa_stod_start, vaxa_stod_end, jamkning_percentage, jamkning_valid_from, jamkning_valid_to, default_dimensions, is_active, created_at, updated_at'
/**
* Shape returned by PATCH (success + dry-run preview) and by no-change PATCH.
@@ -346,6 +356,51 @@ export const PATCH = withApiV1<{ params: Promise<{ companyId: string; id: string
})
}
// Merged-state jämkning check (same pattern as växa-stöd): a non-null
// percentage needs a start date, but the schema can only see the body.
// Also validate merged date ordering when only one of the dates is
// updated. Setting jamkning_percentage to null clears the beslut and
// skips these checks. Only run when the PATCH touches a jamkning field:
// a legacy row with inconsistent jamkning_* state must not block
// unrelated updates (fixing it requires touching those very fields).
const jamkningTouched =
'jamkning_percentage' in updates ||
'jamkning_valid_from' in updates ||
'jamkning_valid_to' in updates
if (jamkningTouched) {
const mergedJamkningPct =
'jamkning_percentage' in updates
? (updates.jamkning_percentage as number | null)
: ((existing as Record<string, unknown>).jamkning_percentage as number | null)
const mergedJamkningFrom =
'jamkning_valid_from' in updates
? (updates.jamkning_valid_from as string | null)
: ((existing as Record<string, unknown>).jamkning_valid_from as string | null)
const mergedJamkningTo =
'jamkning_valid_to' in updates
? (updates.jamkning_valid_to as string | null)
: ((existing as Record<string, unknown>).jamkning_valid_to as string | null)
if (mergedJamkningPct !== null && mergedJamkningPct !== undefined && !mergedJamkningFrom) {
return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, {
requestId: ctx.requestId,
details: {
field: 'jamkning_valid_from',
message:
'Jämkningens startdatum måste anges när jämkningsprocent sätts. Skicka även `jamkning_valid_from` i samma PATCH.',
},
})
}
if (mergedJamkningFrom && mergedJamkningTo && mergedJamkningTo < mergedJamkningFrom) {
return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, {
requestId: ctx.requestId,
details: {
field: 'jamkning_valid_to',
message: 'Jämkningens slutdatum måste vara efter startdatumet.',
},
})
}
}
if (Object.keys(updates).length === 0) {
// GDPR Art.5(1)(c): no-change PATCH still returns a write-shape, so
// mask personnummer just like the POST + PATCH success path.
@@ -0,0 +1,179 @@
/**
* Tests for GET /api/v1/companies/{companyId}/employees/{id}/vacation-balance
* (payroll gap-closure 3.4).
*/
import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
beforeAll(() => {
if (process.env.NODE_ENV !== 'test') {
throw new Error(
`vacation-balance route tests require NODE_ENV=test (got ${process.env.NODE_ENV ?? 'undefined'})`,
)
}
process.env.NEXT_PUBLIC_SUPABASE_URL ||= 'http://localhost:54321'
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY ||= 'test-anon-key'
})
vi.mock('@/lib/auth/api-keys', async () => {
const actual = await vi.importActual<typeof import('@/lib/auth/api-keys')>('@/lib/auth/api-keys')
return {
...actual,
validateApiKey: vi.fn(),
createServiceClientNoCookies: vi.fn(),
}
})
vi.mock('@supabase/supabase-js', async () => {
const actual = await vi.importActual<typeof import('@supabase/supabase-js')>('@supabase/supabase-js')
return { ...actual, createClient: vi.fn().mockReturnValue({}) }
})
import { validateApiKey, createServiceClientNoCookies } from '@/lib/auth/api-keys'
import { GET as getBalance } from '../route'
const mockValidate = validateApiKey as ReturnType<typeof vi.fn>
const mockServiceClient = createServiceClientNoCookies as ReturnType<typeof vi.fn>
function makeFlexibleSupabase(byTable: Record<string, { data?: unknown; error?: unknown }>) {
const buildChain = (table: string): unknown => {
const handler: ProxyHandler<object> = {
get(_target, prop) {
if (prop === 'then') {
return (resolve: (v: unknown) => void) =>
resolve(byTable[table] ?? { data: null, error: null })
}
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 EMPLOYEE_ID = 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb'
const BALANCE_ID = 'cccccccc-cccc-4ccc-8ccc-cccccccccccc'
const EMPLOYEE = {
id: EMPLOYEE_ID,
vacation_rule: 'sammaloneregeln',
vacation_days_per_year: 25,
salary_type: 'monthly',
monthly_salary: 30000,
hourly_rate: null,
hours_per_week: 40,
workdays_per_week: 5,
}
const BALANCE = {
id: BALANCE_ID,
employee_id: EMPLOYEE_ID,
vacation_year_start: '2026-01-01',
entitled_days: 25,
accrued_days: 0,
taken_days: 10,
saved_days: { '2025': 5 },
forced_payout_days: 0,
}
function makeRequest(url: string): Request {
return new Request(url, {
headers: { Authorization: 'Bearer test-fixture-not-a-real-key' },
})
}
function detailParams(companyId: string, id: string) {
return { params: Promise.resolve({ companyId, id }) }
}
beforeEach(() => {
vi.clearAllMocks()
mockValidate.mockResolvedValue({
userId: 'user-1',
companyId: COMPANY_ID,
apiKeyId: 'ak_1',
apiKeyName: 'CI key',
scopes: ['payroll:read'],
mode: 'live',
})
})
describe('GET /employees/:id/vacation-balance', () => {
it('returns the balance with remaining days and a SEK estimate', async () => {
mockServiceClient.mockReturnValue(
makeFlexibleSupabase({
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
employees: { data: EMPLOYEE, error: null },
employee_vacation_balances: { data: BALANCE, error: null },
}),
)
const res = await getBalance(
makeRequest(
`https://x.test/api/v1/companies/${COMPANY_ID}/employees/${EMPLOYEE_ID}/vacation-balance`,
),
detailParams(COMPANY_ID, EMPLOYEE_ID),
)
expect(res.status).toBe(200)
const body = await res.json()
expect(body.data.employee_vacation_balance_id).toBe(BALANCE_ID)
expect(body.data.remaining_days).toBe(15)
expect(body.data.saved_days_total).toBe(5)
// Day value sammalöneregeln: 30000/21 + 30000 x 0.0043 = 1557.57.
// Liability = (15 remaining + 5 saved) x 1557.57 = 31151.4.
expect(body.data.estimated_liability_sek).toBe(31151.4)
expect(body.data.id).toBeUndefined()
})
it('returns 404 VACATION_BALANCE_NOT_FOUND before the ledger seeds', async () => {
mockServiceClient.mockReturnValue(
makeFlexibleSupabase({
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
employees: { data: EMPLOYEE, error: null },
employee_vacation_balances: { data: null, error: null },
}),
)
const res = await getBalance(
makeRequest(
`https://x.test/api/v1/companies/${COMPANY_ID}/employees/${EMPLOYEE_ID}/vacation-balance`,
),
detailParams(COMPANY_ID, EMPLOYEE_ID),
)
expect(res.status).toBe(404)
const body = await res.json()
expect(body.error.code).toBe('VACATION_BALANCE_NOT_FOUND')
})
it('returns 404 EMPLOYEE_NOT_FOUND for an unknown employee', async () => {
mockServiceClient.mockReturnValue(
makeFlexibleSupabase({
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
employees: { data: null, error: null },
}),
)
const res = await getBalance(
makeRequest(
`https://x.test/api/v1/companies/${COMPANY_ID}/employees/${EMPLOYEE_ID}/vacation-balance`,
),
detailParams(COMPANY_ID, EMPLOYEE_ID),
)
expect(res.status).toBe(404)
const body = await res.json()
expect(body.error.code).toBe('EMPLOYEE_NOT_FOUND')
})
it('returns 401 without a bearer token', async () => {
mockValidate.mockResolvedValue(null)
mockServiceClient.mockReturnValue(makeFlexibleSupabase({}))
const res = await getBalance(
new Request(
`https://x.test/api/v1/companies/${COMPANY_ID}/employees/${EMPLOYEE_ID}/vacation-balance`,
),
detailParams(COMPANY_ID, EMPLOYEE_ID),
)
expect(res.status).toBe(401)
})
})
@@ -0,0 +1,174 @@
/**
* GET /api/v1/companies/{companyId}/employees/{id}/vacation-balance
*
* The employee's current OPEN vacation-ledger row: entitled/taken/remaining
* days, sparade dagar by origin year, forced payouts, plus a computed SEK
* estimate of the individual semesterlöneskuld (same day valuation the
* year-close uses).
*/
import { z } from 'zod'
import { ok } from '@/lib/api/v1/response'
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 { roundOre } from '@/lib/money'
import { dailyDivisor } from '@/lib/salary/work-schedule'
const VacationBalanceResponse = z.object({
employee_vacation_balance_id: z.string().uuid(),
employee_id: z.string().uuid(),
vacation_year_start: z.string(),
entitled_days: z.number(),
accrued_days: z.number(),
taken_days: z.number(),
remaining_days: z.number(),
saved_days: z.record(z.string(), z.number()),
saved_days_total: z.number(),
forced_payout_days: z.number(),
estimated_liability_sek: z.number(),
})
registerEndpoint({
operation: 'employees.vacation-balance.get',
method: 'GET',
path: '/api/v1/companies/:companyId/employees/:id/vacation-balance',
summary: 'Get an employee\'s current vacation balance.',
description:
'Returns the open vacation-ledger row (recomputed on every booking): entitled/taken/remaining days, sparade dagar keyed by origin year (Semesterlagen 5-year rule), forced-payout days from expired savings, and a computed SEK estimate of the individual semesterlöneskuld.',
useWhen:
'Answering "how many vacation days does Anna have left", pre-payroll review, or preparing the year-close.',
doNotUseFor:
'The company-wide liability report: GET /reports/vacation-liability. Closing the year: POST /salary/vacation-year-close.',
pitfalls: [
'404 VACATION_BALANCE_NOT_FOUND until the first booking (or year-close) touches the employee: the ledger seeds lazily.',
'remaining_days can go negative if more days were taken than entitled: surface it, do not clamp.',
'The SEK estimate uses the year-close day valuation (simplified BFNAR 2016:10); the booked 2920 is reconciled only at year-close.',
],
example: {
response: {
data: {
employee_id: 'emp_77b2…',
vacation_year_start: '2026-01-01',
entitled_days: 25,
taken_days: 10,
remaining_days: 15,
saved_days: { '2025': 5 },
saved_days_total: 5,
estimated_liability_sek: 31151.4,
},
meta: { request_id: 'req_…', api_version: '2026-05-12' },
},
},
scope: 'payroll:read',
risk: 'low',
idempotent: true,
reversible: false,
dryRunSupported: false,
response: { success: dataEnvelope(VacationBalanceResponse) },
})
export const GET = withApiV1<{ params: Promise<{ companyId: string; id: string }> }>(
'employees.vacation-balance.get',
async (_request, ctx, params) => {
const { id } = await params.params
const idParse = z.string().uuid().safeParse(id)
if (!idParse.success) {
return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, {
requestId: ctx.requestId,
details: { field: 'id', message: 'Employee id must be a UUID.' },
})
}
const { data: employee, error: empErr } = await ctx.supabase
.from('employees')
.select('id, vacation_rule, vacation_days_per_year, salary_type, monthly_salary, hourly_rate, hours_per_week, workdays_per_week')
.eq('id', idParse.data)
.eq('company_id', ctx.companyId!)
.maybeSingle()
if (empErr) {
return v1ErrorResponse(empErr, ctx.log, { requestId: ctx.requestId })
}
if (!employee) {
return v1ErrorResponseFromCode('EMPLOYEE_NOT_FOUND', ctx.log, { requestId: ctx.requestId })
}
const { data: balance, error: balErr } = await ctx.supabase
.from('employee_vacation_balances')
.select('id, employee_id, vacation_year_start, entitled_days, accrued_days, taken_days, saved_days, forced_payout_days')
.eq('company_id', ctx.companyId!)
.eq('employee_id', idParse.data)
.eq('status', 'open')
.order('vacation_year_start', { ascending: false })
.limit(1)
.maybeSingle()
if (balErr) {
return v1ErrorResponse(balErr, ctx.log, { requestId: ctx.requestId })
}
if (!balance) {
return v1ErrorResponseFromCode('VACATION_BALANCE_NOT_FOUND', ctx.log, {
requestId: ctx.requestId,
details: { employee_id: idParse.data },
})
}
const row = balance as {
id: string
employee_id: string
vacation_year_start: string
entitled_days: number
accrued_days: number
taken_days: number
saved_days: Record<string, number> | null
forced_payout_days: number
}
const emp = employee as {
vacation_rule: string
vacation_days_per_year: number
salary_type: string
monthly_salary: number | null
hourly_rate: number | null
hours_per_week: number | null
workdays_per_week: number | null
}
const savedDays = row.saved_days ?? {}
const savedTotal = Object.values(savedDays).reduce((s, d) => s + (Number(d) || 0), 0)
const remaining = roundOre(row.entitled_days - row.taken_days)
// Same simplified BFNAR 2016:10 day valuation the year-close uses.
const rate = emp.vacation_days_per_year >= 30 ? 0.144 : 0.12
let dayValue: number
if (emp.salary_type === 'hourly') {
dayValue = roundOre(
((emp.hourly_rate || 0) * (emp.hours_per_week ?? 40) * 52 * rate) /
Math.max(emp.vacation_days_per_year, 1),
)
} else if (emp.vacation_rule === 'sammaloneregeln') {
const monthly = emp.monthly_salary || 0
dayValue = roundOre(monthly / dailyDivisor(emp.workdays_per_week) + monthly * 0.0043)
} else {
dayValue = roundOre(
((emp.monthly_salary || 0) * 12 * rate) / Math.max(emp.vacation_days_per_year, 1),
)
}
const estimatedLiability = roundOre(Math.max(0, remaining + savedTotal) * dayValue)
return ok(
{
employee_vacation_balance_id: row.id,
employee_id: row.employee_id,
vacation_year_start: row.vacation_year_start,
entitled_days: row.entitled_days,
accrued_days: row.accrued_days,
taken_days: row.taken_days,
remaining_days: remaining,
saved_days: savedDays,
saved_days_total: savedTotal,
forced_payout_days: row.forced_payout_days,
estimated_liability_sek: estimatedLiability,
},
{ requestId: ctx.requestId },
)
},
)
@@ -146,6 +146,9 @@ const SAMPLE_EMPLOYEE = {
vaxa_stod_eligible: false,
vaxa_stod_start: null,
vaxa_stod_end: null,
jamkning_percentage: null,
jamkning_valid_from: null,
jamkning_valid_to: null,
is_active: true,
created_at: '2024-01-15T08:00:00Z',
updated_at: '2024-01-15T08:00:00Z',
@@ -648,6 +651,159 @@ describe('PATCH /api/v1/companies/:companyId/employees/:id', () => {
expect(body.error.details.field).toBe('personnummer')
})
it('sets work-schedule fields (arbetsschema-lite)', async () => {
const updated = { ...SAMPLE_EMPLOYEE, hours_per_week: 32, workdays_per_week: 4 }
mockServiceClient.mockReturnValue(
makeFlexibleSupabase({
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
employees: [{ data: SAMPLE_EMPLOYEE, error: null }, { data: updated, error: null }],
idempotency_keys: { data: null, error: null },
}),
)
const res = await updateEmployee(
makeRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/employees/${EMPLOYEE_ID}`, {
method: 'PATCH',
body: JSON.stringify({ hours_per_week: 32, workdays_per_week: 4 }),
}),
detailParams(COMPANY_ID, EMPLOYEE_ID),
)
expect(res.status).toBe(200)
const body = await res.json()
expect(body.data.hours_per_week).toBe(32)
expect(body.data.workdays_per_week).toBe(4)
})
it('rejects an out-of-range work schedule', async () => {
mockServiceClient.mockReturnValue(
makeFlexibleSupabase({
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
}),
)
const res = await updateEmployee(
makeRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/employees/${EMPLOYEE_ID}`, {
method: 'PATCH',
body: JSON.stringify({ workdays_per_week: 9 }),
}),
detailParams(COMPANY_ID, EMPLOYEE_ID),
)
expect(res.status).toBe(400)
})
it('sets jämkning fields (percentage + validity window)', async () => {
const updated = {
...SAMPLE_EMPLOYEE,
jamkning_percentage: 15,
jamkning_valid_from: '2026-01-01',
jamkning_valid_to: '2026-12-31',
}
mockServiceClient.mockReturnValue(
makeFlexibleSupabase({
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
employees: [{ data: SAMPLE_EMPLOYEE, error: null }, { data: updated, error: null }],
idempotency_keys: { data: null, error: null },
}),
)
const res = await updateEmployee(
makeRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/employees/${EMPLOYEE_ID}`, {
method: 'PATCH',
body: JSON.stringify({
jamkning_percentage: 15,
jamkning_valid_from: '2026-01-01',
jamkning_valid_to: '2026-12-31',
}),
}),
detailParams(COMPANY_ID, EMPLOYEE_ID),
)
expect(res.status).toBe(200)
const body = await res.json()
expect(body.data.jamkning_percentage).toBe(15)
expect(body.data.jamkning_valid_from).toBe('2026-01-01')
})
it('rejects a jämkning percentage without a start date (merged state)', async () => {
// Existing row has no jamkning_valid_from; sending only the percentage
// must fail the route-level merged-state check.
mockServiceClient.mockReturnValue(
makeFlexibleSupabase({
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
employees: { data: SAMPLE_EMPLOYEE, error: null },
idempotency_keys: { data: null, error: null },
}),
)
const res = await updateEmployee(
makeRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/employees/${EMPLOYEE_ID}`, {
method: 'PATCH',
body: JSON.stringify({ jamkning_percentage: 15 }),
}),
detailParams(COMPANY_ID, EMPLOYEE_ID),
)
expect(res.status).toBe(400)
const body = await res.json()
expect(body.error.code).toBe('VALIDATION_ERROR')
expect(body.error.details.field).toBe('jamkning_valid_from')
})
it('rejects jamkning_valid_to before jamkning_valid_from', async () => {
mockServiceClient.mockReturnValue(
makeFlexibleSupabase({
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
employees: { data: SAMPLE_EMPLOYEE, error: null },
idempotency_keys: { data: null, error: null },
}),
)
const res = await updateEmployee(
makeRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/employees/${EMPLOYEE_ID}`, {
method: 'PATCH',
body: JSON.stringify({
jamkning_percentage: 15,
jamkning_valid_from: '2026-06-01',
jamkning_valid_to: '2026-01-01',
}),
}),
detailParams(COMPANY_ID, EMPLOYEE_ID),
)
expect(res.status).toBe(400)
const body = await res.json()
expect(body.error.code).toBe('VALIDATION_ERROR')
})
it('clears the jämkningsbeslut with an explicit null', async () => {
const withJamkning = {
...SAMPLE_EMPLOYEE,
jamkning_percentage: 15,
jamkning_valid_from: '2026-01-01',
jamkning_valid_to: null,
}
const cleared = { ...SAMPLE_EMPLOYEE }
mockServiceClient.mockReturnValue(
makeFlexibleSupabase({
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
employees: [{ data: withJamkning, error: null }, { data: cleared, error: null }],
idempotency_keys: { data: null, error: null },
}),
)
const res = await updateEmployee(
makeRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/employees/${EMPLOYEE_ID}`, {
method: 'PATCH',
body: JSON.stringify({ jamkning_percentage: null }),
}),
detailParams(COMPANY_ID, EMPLOYEE_ID),
)
expect(res.status).toBe(200)
const body = await res.json()
expect(body.data.jamkning_percentage).toBeNull()
})
it('returns a dry-run preview with masked personnummer', async () => {
// GDPR Art.5(1)(c): the dry-run preview is a write-shape so it follows
// the same masking rule as POST and PATCH success. The full value is
@@ -0,0 +1,126 @@
/**
* PUT /api/v1/companies/{companyId}/employees/opening-balances
*
* Bulk full-replace upsert of payroll cutover opening balances: the byrå/
* integrator onboarding surface for mid-year migrations. ATOMIC
* all-or-nothing: every item is validated against live state first
* (employee exists + active, cutover >= employment_start, not locked by a
* booked run); any failure returns the complete per-item error list with
* ZERO writes, so the caller fixes the file and resubmits.
*/
import { z } from 'zod'
import { ok } from '@/lib/api/v1/response'
import { dryRunPreview } from '@/lib/api/v1/dry-run'
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 { OpeningBalancesBulkSchema } from '@/lib/api/schemas'
import { setOpeningBalancesBulk } from '@/lib/salary/opening-balances'
const BulkResponse = z.object({
count: z.number().int(),
rows: z.array(
z.object({
employee_opening_balances_id: z.string().uuid().nullable(),
employee_id: z.string().uuid(),
cutover_date: z.string(),
locked: z.boolean(),
}),
),
})
registerEndpoint({
operation: 'employees.opening-balances.bulk-set',
method: 'PUT',
path: '/api/v1/companies/:companyId/employees/opening-balances',
summary: 'Bulk-set payroll cutover opening balances (atomic).',
description:
'Upserts opening balances for up to 200 employees in one call. Validation is all-or-nothing: any invalid item (unknown/inactive employee, cutover before employment_start, locked by a booked run) fails the WHOLE request with a per-item error list and zero writes.',
useWhen:
'Onboarding a whole company mid-year from another payroll system: one call per migration file instead of N sequential PUTs.',
doNotUseFor:
'Single-employee corrections after go-live: PUT /employees/{id}/opening-balances. Ledger opening balances (SIE import).',
pitfalls: [
'Atomic: one bad item fails everything. The error details carry item_errors[{index, employee_id, code, message}]: fix and resubmit the full set.',
'Full replace per employee: resubmitting with fewer fields resets the omitted ones to 0.',
'Duplicate employee_id within items is rejected outright.',
],
example: {
request: {
items: [
{ employee_id: 'emp_77b2…', cutover_date: '2026-07-01', ytd_gross: 210000, ytd_tax: 48000, ytd_net: 162000 },
],
},
response: {
data: { count: 1, rows: [{ employee_id: 'emp_77b2…', cutover_date: '2026-07-01', locked: false }] },
meta: { request_id: 'req_…', api_version: '2026-05-12' },
},
},
scope: 'payroll:write',
risk: 'medium',
idempotent: true,
reversible: true,
dryRunSupported: true,
request: { body: OpeningBalancesBulkSchema },
response: { success: dataEnvelope(BulkResponse) },
})
export const PUT = withApiV1<{ params: Promise<{ companyId: string }> }>(
'employees.opening-balances.bulk-set',
async (request, ctx) => {
let rawBody: unknown
try {
rawBody = await request.json()
} catch {
return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, {
requestId: ctx.requestId,
details: { field: 'body', message: 'Body is not valid JSON.' },
})
}
const parsed = OpeningBalancesBulkSchema.safeParse(rawBody)
if (!parsed.success) {
return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, {
requestId: ctx.requestId,
details: {
issues: parsed.error.issues.map((i) => ({
field: i.path.join('.'),
message: i.message,
})),
},
})
}
const result = await setOpeningBalancesBulk(ctx.supabase, {
companyId: ctx.companyId!,
userId: ctx.userId,
items: parsed.data.items,
dryRun: ctx.dryRun,
})
if (!result.ok) {
if (result.itemErrors) {
// Atomic contract: full per-item error list, zero writes.
return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, {
requestId: ctx.requestId,
details: { item_errors: result.itemErrors },
})
}
if (result.code === 'INTERNAL_ERROR') {
return v1ErrorResponse(new Error(String(result.details?.message ?? 'bulk upsert failed')), ctx.log, {
requestId: ctx.requestId,
})
}
return v1ErrorResponseFromCode(result.code, ctx.log, {
requestId: ctx.requestId,
details: result.details,
})
}
if (ctx.dryRun) {
return dryRunPreview(result.data, { requestId: ctx.requestId, log: ctx.log })
}
return ok(result.data, { requestId: ctx.requestId })
},
)
@@ -416,6 +416,8 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string }> }>(
employment_start: body.employment_start,
employment_end: body.employment_end ?? null,
employment_degree: body.employment_degree,
hours_per_week: body.hours_per_week,
workdays_per_week: body.workdays_per_week,
salary_type: body.salary_type,
monthly_salary: body.monthly_salary ?? null,
hourly_rate: body.hourly_rate ?? null,
@@ -437,6 +439,9 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string }> }>(
vaxa_stod_eligible: body.vaxa_stod_eligible,
vaxa_stod_start: body.vaxa_stod_start ?? null,
vaxa_stod_end: body.vaxa_stod_end ?? null,
jamkning_percentage: body.jamkning_percentage ?? null,
jamkning_valid_from: body.jamkning_valid_from ?? null,
jamkning_valid_to: body.jamkning_valid_to ?? null,
// Dimensions PR8: bag for the employee's P&L cost lines at booking.
default_dimensions: body.default_dimensions ?? {},
})
@@ -369,6 +369,50 @@ describe('POST /api/v1/companies/:companyId/invoices/:id/mark-paid', () => {
expect(mockPayment).not.toHaveBeenCalled()
})
it('absorbs an öresavrundning overshoot on SEK custom lines (rounded "Att betala")', async () => {
// Invoice stored with öre (1234.75); the PDF shows 1235.00 and the customer
// pays that. The 3740 line carries the residual and the invoice settles in
// full instead of being rejected as an overpayment.
const ORE_INVOICE = {
...SENT_INVOICE,
subtotal: 987.8,
vat_amount: 246.95,
total: 1234.75,
remaining_amount: 1234.75,
}
mockServiceClient.mockReturnValue(
makeFlexibleSupabase({
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
invoices: [
{ data: ORE_INVOICE, error: null },
{ data: { ...ORE_INVOICE, status: 'paid', remaining_amount: 0, paid_amount: 1234.75 }, error: null },
],
company_settings: { data: { accounting_method: 'accrual', entity_type: 'enskild_firma' }, error: null },
transactions: { data: [], 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: 1235, credit_amount: 0 },
{ account_number: '1510', debit_amount: 0, credit_amount: 1234.75 },
{ account_number: '3740', debit_amount: 0, credit_amount: 0.25 },
],
},
),
detailParams(COMPANY_ID, INVOICE_ID),
)
expect(res.status).toBe(200)
const body = await res.json()
expect(body.data.status).toBe('paid')
expect(body.data.remaining_amount).toBe(0)
})
it('returns 400 INVOICE_PAID_NOT_PAYABLE for draft invoices', async () => {
mockServiceClient.mockReturnValue(
makeFlexibleSupabase({
@@ -42,7 +42,7 @@ import { AccountsNotInChartError, isBookkeepingError } from '@/lib/bookkeeping/e
import { getErrorMessage } from '@/lib/errors/get-error-message'
import { eventBus } from '@/lib/events'
import { findDuplicatePaymentCandidatesForInvoice } from '@/lib/invoices/duplicate-payment-candidates'
import { planInvoicePayment } from '@/lib/invoices/apply-invoice-payment'
import { planInvoicePaymentForLines } from '@/lib/invoices/apply-invoice-payment'
import { roundOre } from '@/lib/money'
import type { CreateJournalEntryInput, EntityType, Invoice } from '@/types'
@@ -285,7 +285,18 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string
const paymentAmountInInvoiceCurrency = customLines
? roundOre(paymentAmount / fxRate)
: paymentAmount
const payment = planInvoicePayment(typed, paymentAmountInInvoiceCurrency)
// 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
// (sub-krona partials stay partial, overshoot rejects), mirroring the
// dashboard mark-paid flow. The default path pays the exact remaining, so
// absorption is a no-op there.
const payment = planInvoicePaymentForLines(
typed,
paymentAmountInInvoiceCurrency,
customLines,
typed.currency ?? 'SEK',
)
if (!payment.ok) {
return v1ErrorResponseFromCode('MATCH_AMOUNT_EXCEEDS_REMAINING', ctx.log, {
requestId: ctx.requestId,
@@ -34,6 +34,7 @@ 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 { createSalaryRunEntries } from '@/lib/salary/salary-entries'
import { syncVacationLedgerForEmployees } from '@/lib/salary/vacation-ledger'
import { isBookkeepingError } from '@/lib/bookkeeping/errors'
import { eventBus } from '@/lib/events'
@@ -346,6 +347,17 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string
ctx.log.warn('salary_run.booked emit failed', err as Error)
}
// Vacation ledger sync (non-fatal: the ledger recomputes and self-heals
// on the next booking; a sync bug must never block a booking).
const ledgerSync = await syncVacationLedgerForEmployees(
ctx.supabase,
ctx.companyId!,
(employees as Array<{ employee_id: string }>).map((sre) => sre.employee_id),
)
if (!ledgerSync.ok) {
ctx.log.warn('vacation ledger sync failed after booking', { message: ledgerSync.message })
}
const bookedAt = (bookedRun as { booked_at: string }).booked_at
return ok(
@@ -0,0 +1,150 @@
/**
* POST /api/v1/companies/{companyId}/salary-runs/{id}/employees/{employeeId}/lines
*
* Add a payslip line item (bonus, overtime, deduction, benefit, ...) to one
* employee in a DRAFT salary run. The path addresses the employee by
* employee_id: the route resolves the salary_run_employees join row itself.
*
* Draft-only (BFL 5 kap: once the run advances, its numbers feed a
* verifikation). Line edits do NOT recompute tax/avgifter: call
* POST /salary-runs/{id}/calculate afterwards.
*/
import { z } from 'zod'
import { created } from '@/lib/api/v1/response'
import { dryRunPreview } from '@/lib/api/v1/dry-run'
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 { CreateSalaryLineItemSchema } from '@/lib/api/schemas'
import { createPayslipLine } from '@/lib/salary/payslip-lines'
// The path resolves the employee; the body must not carry the join-row id.
const CreateLineBody = CreateSalaryLineItemSchema.omit({ salary_run_employee_id: true })
const LineItemResponse = z.object({
salary_line_item_id: z.string().uuid().nullable(),
salary_run_employee_id: z.string().uuid(),
item_type: z.string(),
description: z.string(),
quantity: z.number().nullable(),
unit_price: z.number().nullable(),
amount: z.number(),
is_taxable: z.boolean(),
is_avgift_basis: z.boolean(),
is_vacation_basis: z.boolean(),
is_gross_deduction: z.boolean(),
is_net_deduction: z.boolean(),
account_number: z.string().nullable(),
sort_order: z.number(),
})
registerEndpoint({
operation: 'salary-runs.lines.create',
method: 'POST',
path: '/api/v1/companies/:companyId/salary-runs/:id/employees/:employeeId/lines',
summary: 'Add a payslip line to an employee in a draft salary run.',
description:
'Creates a salary_line_items row (bonus, overtime, gross/net deduction, benefit, traktamente, ...) for one employee in a draft run. account_number auto-resolves from item_type when omitted. Amounts are rounded to whole öre.',
useWhen:
'You need to add a one-off pay component before calculating: a bonus, an expense reimbursement, a union fee, or a manual correction line.',
doNotUseFor:
'Editing the base monthly salary (PATCH the run-employee via the internal surface; not on v1 yet). Absence: register absence days instead (PUT /employees/{id}/absence); the engine derives sick/VAB lines itself.',
pitfalls: [
'Draft-only: returns 400 SALARY_RUN_LINE_NOT_DRAFT once the run has advanced.',
'Line edits do not recompute tax or totals: call POST /salary-runs/{id}/calculate afterwards.',
'Engine-derived lines (absence, benefits) are regenerated on every :calculate; manual lines survive.',
],
example: {
request: {
item_type: 'bonus',
description: 'Kvartalsbonus Q2',
amount: 5000,
},
response: {
data: {
salary_line_item_id: 'sli_31c9…',
item_type: 'bonus',
description: 'Kvartalsbonus Q2',
amount: 5000,
account_number: '7210',
},
meta: { request_id: 'req_…', api_version: '2026-05-12' },
},
},
scope: 'payroll:write',
risk: 'low',
idempotent: true,
reversible: true,
dryRunSupported: true,
request: { body: CreateLineBody },
response: { success: dataEnvelope(LineItemResponse) },
})
export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string; employeeId: string }> }>(
'salary-runs.lines.create',
async (request, ctx, params) => {
const { id, employeeId } = await params.params
const runParse = z.string().uuid().safeParse(id)
const empParse = z.string().uuid().safeParse(employeeId)
if (!runParse.success || !empParse.success) {
return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, {
requestId: ctx.requestId,
details: {
field: runParse.success ? 'employeeId' : 'id',
message: 'Path ids must be UUIDs.',
},
})
}
let rawBody: unknown
try {
rawBody = await request.json()
} catch {
return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, {
requestId: ctx.requestId,
details: { field: 'body', message: 'Body is not valid JSON.' },
})
}
const parsed = CreateLineBody.safeParse(rawBody)
if (!parsed.success) {
return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, {
requestId: ctx.requestId,
details: {
issues: parsed.error.issues.map((i) => ({
field: i.path.join('.'),
message: i.message,
})),
},
})
}
const result = await createPayslipLine(ctx.supabase, {
companyId: ctx.companyId!,
salaryRunId: runParse.data,
target: { employeeId: empParse.data },
input: parsed.data,
dryRun: ctx.dryRun,
})
if (!result.ok) {
return v1ErrorResponseFromCode(result.code, ctx.log, {
requestId: ctx.requestId,
details: result.details,
})
}
const { id: lineId, company_id: _companyId, ...rest } = result.data as Record<string, unknown> & {
id: string | null
company_id?: string
}
const payload = { salary_line_item_id: lineId, ...rest }
if (ctx.dryRun) {
return dryRunPreview(payload, { requestId: ctx.requestId, log: ctx.log })
}
return created(payload, { requestId: ctx.requestId })
},
{ requireIdempotencyKey: true },
)
@@ -0,0 +1,301 @@
/**
* /api/v1/companies/{companyId}/salary-runs/{id}/employees/{employeeId}
*
* GET: one employee's full payslip within a salary run: the calculated
* aggregates, every payslip line item, and the step-by-step
* calculation_breakdown the engine recorded.
*
* GDPR Art.5(1)(c): personnummer stays MASKED here. A payslip is a pay
* document, not an identity record; the deliberate identity drill-in is
* GET /employees/{id}.
*/
import { z } from 'zod'
import { ok, noContent } from '@/lib/api/v1/response'
import { dryRunPreview } from '@/lib/api/v1/dry-run'
import { registerEndpoint, dataEnvelope, NoBodyResponse } from '@/lib/api/v1/registry'
import { withApiV1 } from '@/lib/api/v1/with-api-v1'
import { v1ErrorResponse, v1ErrorResponseFromCode } from '@/lib/api/v1/errors'
import { maskPersonnummer } from '@/lib/api/v1/mask-personnummer'
import { decryptPersonnummer } from '@/lib/salary/personnummer'
import { removeEmployeeFromRun } from '@/lib/salary/run-employees'
const PayslipLineItem = z.object({
/** Qualified id of the salary_line_items row. */
salary_line_item_id: z.string().uuid(),
item_type: z.string(),
description: z.string(),
quantity: z.number().nullable(),
unit_price: z.number().nullable(),
amount: z.number(),
is_taxable: z.boolean(),
is_avgift_basis: z.boolean(),
is_vacation_basis: z.boolean(),
is_gross_deduction: z.boolean(),
is_net_deduction: z.boolean(),
account_number: z.string().nullable(),
sort_order: z.number(),
})
const PayslipDetail = z.object({
salary_run_employee_id: z.string().uuid(),
salary_run_id: z.string().uuid(),
employee_id: z.string().uuid(),
first_name: z.string(),
last_name: z.string(),
personnummer_masked: z.string(),
salary_type: z.string(),
employment_degree: z.number(),
monthly_salary: z.number().nullable(),
hours_worked: z.number().nullable(),
gross_salary: z.number(),
gross_deductions: z.number(),
benefit_values: z.number(),
taxable_income: z.number(),
tax_withheld: z.number(),
tax_withheld_override: z.number().nullable(),
net_deductions: z.number(),
net_salary: z.number(),
avgifter_rate: z.number(),
avgifter_basis: z.number(),
avgifter_amount: z.number(),
avgifter_basis_override: z.number().nullable(),
avgifter_amount_override: z.number().nullable(),
avgifter_category: z.string().nullable(),
override_reason: z.string().nullable(),
vacation_accrual: z.number(),
vacation_accrual_avgifter: z.number(),
tax_table_number: z.number().nullable(),
tax_column: z.number().nullable(),
tax_table_year: z.number().nullable(),
sick_days: z.number(),
vab_days: z.number(),
parental_days: z.number(),
vacation_days_taken: z.number(),
ytd_gross: z.number(),
ytd_tax: z.number(),
ytd_net: z.number(),
/** Step-by-step engine breakdown; null until :calculate has run. */
calculation_breakdown: z.unknown().nullable(),
line_items: z.array(PayslipLineItem),
created_at: z.string(),
updated_at: z.string(),
})
const PAYSLIP_DETAIL_COLUMNS =
'id, salary_run_id, employee_id, salary_type, employment_degree, monthly_salary, hours_worked, ' +
'gross_salary, gross_deductions, benefit_values, taxable_income, tax_withheld, tax_withheld_override, ' +
'net_deductions, net_salary, avgifter_rate, avgifter_basis, avgifter_amount, avgifter_basis_override, ' +
'avgifter_amount_override, avgifter_category, override_reason, vacation_accrual, vacation_accrual_avgifter, ' +
'tax_table_number, tax_column, tax_table_year, sick_days, vab_days, parental_days, vacation_days_taken, ' +
'ytd_gross, ytd_tax, ytd_net, calculation_breakdown, created_at, updated_at, ' +
'employee:employees(first_name, last_name, personnummer), ' +
'line_items:salary_line_items(id, item_type, description, quantity, unit_price, amount, is_taxable, is_avgift_basis, is_vacation_basis, is_gross_deduction, is_net_deduction, account_number, sort_order)'
registerEndpoint({
operation: 'salary-runs.employees.get',
method: 'GET',
path: '/api/v1/companies/:companyId/salary-runs/:id/employees/:employeeId',
summary: 'Get one employee\'s payslip in a salary run.',
description:
'Returns the full payslip for one employee in a run: gross/tax/net aggregates, arbetsgivaravgifter with category, vacation accrual, YTD accumulators, every payslip line item (grundlön, tillägg, avdrag, förmåner), and the step-by-step calculation_breakdown recorded by the engine.',
useWhen:
'You need to verify how a specific employee\'s pay was computed: reviewing a run before approval, answering "why is the tax this amount", or rendering a payslip in an external system.',
doNotUseFor:
'The rendered PDF payslip: use GET /salary-runs/{id}/payslips/{employeeId}/pdf. Editing line items: POST/PATCH/DELETE on the lines endpoints.',
pitfalls: [
'calculation_breakdown is null and aggregates are 0 until POST /calculate has run.',
'line_items include engine-derived rows (absence, benefits) that are regenerated on every :calculate; manual rows survive recalculation.',
'The effective tax is COALESCE(tax_withheld_override, tax_withheld); same for avgifter overrides.',
'personnummer is masked here (GDPR Art.5(1)(c)); GET /employees/{id} is the identity drill-in.',
],
example: {
response: {
data: {
salary_run_employee_id: 'sre_a8f1…',
employee_id: 'emp_77b2…',
first_name: 'Anna',
last_name: 'Andersson',
personnummer_masked: 'YYYYMMDDXXXX',
gross_salary: 35000,
tax_withheld: -8200,
net_salary: 26800,
line_items: [
{
salary_line_item_id: 'sli_31c9…',
item_type: 'monthly_salary',
description: 'Grundlön',
amount: 35000,
},
],
},
meta: { request_id: 'req_…', api_version: '2026-05-12' },
},
},
scope: 'payroll:read',
risk: 'low',
idempotent: true,
reversible: false,
dryRunSupported: false,
response: { success: dataEnvelope(PayslipDetail) },
})
export const GET = withApiV1<{ params: Promise<{ companyId: string; id: string; employeeId: string }> }>(
'salary-runs.employees.get',
async (_request, ctx, params) => {
const { id, employeeId } = await params.params
const runParse = z.string().uuid().safeParse(id)
const empParse = z.string().uuid().safeParse(employeeId)
if (!runParse.success || !empParse.success) {
return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, {
requestId: ctx.requestId,
details: {
field: runParse.success ? 'employeeId' : 'id',
message: 'Path ids must be UUIDs.',
},
})
}
const { data, error } = await ctx.supabase
.from('salary_run_employees')
.select(PAYSLIP_DETAIL_COLUMNS)
.eq('company_id', ctx.companyId!)
.eq('salary_run_id', runParse.data)
.eq('employee_id', empParse.data)
.maybeSingle()
if (error) {
return v1ErrorResponse(error, ctx.log, { requestId: ctx.requestId })
}
if (!data) {
// Distinguish "run missing" from "employee not in run" so agents get an
// actionable 404 body either way.
const { data: run } = await ctx.supabase
.from('salary_runs')
.select('id')
.eq('company_id', ctx.companyId!)
.eq('id', runParse.data)
.maybeSingle()
if (!run) {
return v1ErrorResponseFromCode('SALARY_RUN_NOT_FOUND', ctx.log, { requestId: ctx.requestId })
}
return v1ErrorResponseFromCode('NOT_FOUND', ctx.log, {
requestId: ctx.requestId,
details: { resource: 'salary_run_employee', employee_id: empParse.data },
})
}
type Row = {
id: string
salary_run_id: string
employee_id: string
employee: { first_name: string; last_name: string; personnummer: string } | null
line_items: Array<{
id: string
item_type: string
description: string
quantity: number | null
unit_price: number | null
amount: number
is_taxable: boolean
is_avgift_basis: boolean
is_vacation_basis: boolean
is_gross_deduction: boolean
is_net_deduction: boolean
account_number: string | null
sort_order: number
}>
} & Record<string, unknown>
const row = data as unknown as Row
const { employee, line_items: lineItems, id: sreId, ...rest } = row
return ok(
{
...rest,
salary_run_employee_id: sreId,
first_name: employee?.first_name ?? '',
last_name: employee?.last_name ?? '',
personnummer_masked: employee
? maskPersonnummer(decryptPersonnummer(employee.personnummer))
: '',
line_items: (lineItems ?? [])
.slice()
.sort((a, b) => a.sort_order - b.sort_order)
.map(({ id: lineId, ...line }) => ({
salary_line_item_id: lineId,
...line,
})),
},
{ requestId: ctx.requestId },
)
},
)
// ──────────────────────────────────────────────────────────────────
// DELETE: remove an employee from a draft run
// ──────────────────────────────────────────────────────────────────
registerEndpoint({
operation: 'salary-runs.employees.remove',
method: 'DELETE',
path: '/api/v1/companies/:companyId/salary-runs/:id/employees/:employeeId',
summary: 'Remove an employee from a draft salary run.',
description:
'Detaches the employee from the run and cascades away their payslip line items. Draft-only. The employee master record is untouched: this only affects the run roster.',
useWhen:
'An employee should not be paid this period (unpaid leave the whole month, employment ended) but was auto-added when the run was created.',
doNotUseFor:
'Deactivating the employee entirely: DELETE /employees/{id} (soft-delete). Zero-salary months: keep them in the run with a 0 base instead if you want a nollkörning on record.',
pitfalls: [
'Draft-only: 400 SALARY_RUN_EMPLOYEES_NOT_DRAFT once the run has advanced.',
'Cascade-deletes the employee\'s line items in this run, including manual ones.',
'Re-attaching later retakes the pay snapshot from the employee master.',
],
example: { response: { data: null } },
scope: 'payroll:write',
risk: 'low',
idempotent: true,
reversible: true,
dryRunSupported: true,
response: { success: NoBodyResponse },
})
export const DELETE = withApiV1<{ params: Promise<{ companyId: string; id: string; employeeId: string }> }>(
'salary-runs.employees.remove',
async (_request, ctx, params) => {
const { id, employeeId } = await params.params
const runParse = z.string().uuid().safeParse(id)
const empParse = z.string().uuid().safeParse(employeeId)
if (!runParse.success || !empParse.success) {
return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, {
requestId: ctx.requestId,
details: {
field: runParse.success ? 'employeeId' : 'id',
message: 'Path ids must be UUIDs.',
},
})
}
const result = await removeEmployeeFromRun(ctx.supabase, {
companyId: ctx.companyId!,
salaryRunId: runParse.data,
employeeId: empParse.data,
dryRun: ctx.dryRun,
})
if (!result.ok) {
return v1ErrorResponseFromCode(result.code, ctx.log, {
requestId: ctx.requestId,
details: result.details,
})
}
if (ctx.dryRun) {
return dryRunPreview(result.data, { requestId: ctx.requestId, log: ctx.log })
}
return noContent({ requestId: ctx.requestId })
},
{ requireIdempotencyKey: true },
)
@@ -0,0 +1,550 @@
/**
* Tests for the v1 per-employee payslip reads (payroll gap-closure 1.1).
*
* GET /salary-runs/{id}/employees : list per-employee results
* GET /salary-runs/{id}/employees/{empId} : payslip detail (line items + breakdown)
*
* Mirrors the employees-route test pattern: Proxy-backed Supabase mock with
* per-table response queues.
*/
import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
beforeAll(() => {
if (process.env.NODE_ENV !== 'test') {
throw new Error(
`salary-run employees route tests require NODE_ENV=test (got ${process.env.NODE_ENV ?? 'undefined'})`,
)
}
process.env.NEXT_PUBLIC_SUPABASE_URL ||= 'http://localhost:54321'
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY ||= 'test-anon-key'
})
vi.mock('@/lib/auth/api-keys', async () => {
const actual = await vi.importActual<typeof import('@/lib/auth/api-keys')>('@/lib/auth/api-keys')
return {
...actual,
validateApiKey: vi.fn(),
createServiceClientNoCookies: vi.fn(),
}
})
vi.mock('@supabase/supabase-js', async () => {
const actual = await vi.importActual<typeof import('@supabase/supabase-js')>('@supabase/supabase-js')
return { ...actual, createClient: vi.fn().mockReturnValue({}) }
})
import { validateApiKey, createServiceClientNoCookies } from '@/lib/auth/api-keys'
import { GET as listRunEmployees, POST as attachEmployee } from '../route'
import { GET as getPayslip, DELETE as removeEmployee } from '../[employeeId]/route'
const mockValidate = validateApiKey as ReturnType<typeof vi.fn>
const mockServiceClient = createServiceClientNoCookies as ReturnType<typeof vi.fn>
interface TableResp {
data?: unknown
error?: unknown
}
function makeFlexibleSupabase(byTable: Record<string, TableResp | TableResp[]>) {
const queues = new Map<string, TableResp[]>()
for (const [t, val] of Object.entries(byTable)) {
queues.set(t, Array.isArray(val) ? [...val] : [val])
}
const buildChain = (table: string): unknown => {
const handler: ProxyHandler<object> = {
get(_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[]) => buildChain(table)
},
}
return new Proxy({}, handler)
}
return { from: vi.fn((table: string) => buildChain(table)) }
}
const COMPANY_ID = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa'
const RUN_ID = 'cccccccc-cccc-4ccc-8ccc-cccccccccccc'
const EMPLOYEE_ID = 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb'
const SRE_ID = 'dddddddd-dddd-4ddd-8ddd-dddddddddddd'
const LINE_ID = 'eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeee'
const USER_ID = 'user-1'
// Synthetic fixture personnummer (year 1900, zero suffix): must not look
// like production-format PII.
const SAMPLE_PERSONNUMMER = '190001010000'
function makeRequest(url: string): Request {
return new Request(url, {
headers: { Authorization: 'Bearer test-fixture-not-a-real-key' },
})
}
function listParams(companyId: string, id: string) {
return { params: Promise.resolve({ companyId, id }) }
}
function detailParams(companyId: string, id: string, employeeId: string) {
return { params: Promise.resolve({ companyId, id, employeeId }) }
}
const SAMPLE_SRE = {
id: SRE_ID,
salary_run_id: RUN_ID,
employee_id: EMPLOYEE_ID,
salary_type: 'monthly',
employment_degree: 100,
monthly_salary: 35000,
hours_worked: null,
gross_salary: 35000,
gross_deductions: 0,
benefit_values: 0,
taxable_income: 35000,
tax_withheld: 8200,
tax_withheld_override: null,
net_deductions: 0,
net_salary: 26800,
avgifter_rate: 0.3142,
avgifter_basis: 35000,
avgifter_amount: 10997,
avgifter_basis_override: null,
avgifter_amount_override: null,
avgifter_category: 'standard',
override_reason: null,
vacation_accrual: 4200,
vacation_accrual_avgifter: 1319.64,
tax_table_number: 33,
tax_column: 1,
tax_table_year: 2026,
sick_days: 0,
vab_days: 0,
parental_days: 0,
vacation_days_taken: 0,
ytd_gross: 70000,
ytd_tax: 16400,
ytd_net: 53600,
calculation_breakdown: { steps: [{ label: 'Grundlön', formula: '35000 x 100%', output: 35000 }] },
created_at: '2026-05-01T08:00:00Z',
updated_at: '2026-05-01T08:00:00Z',
employee: {
first_name: 'Anna',
last_name: 'Andersson',
personnummer: SAMPLE_PERSONNUMMER,
},
line_items: [
{
id: LINE_ID,
item_type: 'monthly_salary',
description: 'Grundlön',
quantity: null,
unit_price: null,
amount: 35000,
is_taxable: true,
is_avgift_basis: true,
is_vacation_basis: true,
is_gross_deduction: false,
is_net_deduction: false,
account_number: '7210',
sort_order: 0,
},
],
}
beforeEach(() => {
vi.clearAllMocks()
mockValidate.mockResolvedValue({
userId: USER_ID,
companyId: COMPANY_ID,
apiKeyId: 'ak_1',
apiKeyName: 'CI key',
scopes: ['payroll:read', 'payroll:write'],
mode: 'live',
})
})
describe('GET /api/v1/companies/:companyId/salary-runs/:id/employees', () => {
it('returns per-employee rows with masked personnummer and qualified ids', async () => {
mockServiceClient.mockReturnValue(
makeFlexibleSupabase({
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
salary_runs: { data: { id: RUN_ID }, error: null },
salary_run_employees: { data: [SAMPLE_SRE], error: null },
}),
)
const res = await listRunEmployees(
makeRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/salary-runs/${RUN_ID}/employees`),
listParams(COMPANY_ID, RUN_ID),
)
expect(res.status).toBe(200)
const body = await res.json()
expect(body.data).toHaveLength(1)
expect(body.data[0].salary_run_employee_id).toBe(SRE_ID)
expect(body.data[0].employee_id).toBe(EMPLOYEE_ID)
expect(body.data[0].gross_salary).toBe(35000)
expect(body.data[0].net_salary).toBe(26800)
// GDPR Art.5(1)(c): payslip-shaped responses always mask.
expect(body.data[0].personnummer_masked).toBe('19000101XXXX')
expect(JSON.stringify(body)).not.toContain(SAMPLE_PERSONNUMMER)
// The list omits line items: the detail endpoint carries them.
expect(body.data[0].line_items).toBeUndefined()
// paginated() omits next_cursor from meta on the final page.
expect(body.meta.next_cursor ?? null).toBeNull()
})
it('returns 404 SALARY_RUN_NOT_FOUND when the run is missing', async () => {
mockServiceClient.mockReturnValue(
makeFlexibleSupabase({
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
salary_runs: { data: null, error: null },
}),
)
const res = await listRunEmployees(
makeRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/salary-runs/${RUN_ID}/employees`),
listParams(COMPANY_ID, RUN_ID),
)
expect(res.status).toBe(404)
const body = await res.json()
expect(body.error.code).toBe('SALARY_RUN_NOT_FOUND')
})
it('rejects a non-UUID run id with 400 VALIDATION_ERROR', async () => {
mockServiceClient.mockReturnValue(
makeFlexibleSupabase({
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
}),
)
const res = await listRunEmployees(
makeRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/salary-runs/not-a-uuid/employees`),
listParams(COMPANY_ID, 'not-a-uuid'),
)
expect(res.status).toBe(400)
const body = await res.json()
expect(body.error.code).toBe('VALIDATION_ERROR')
})
it('rejects keys without payroll:read scope', async () => {
mockValidate.mockResolvedValue({
userId: USER_ID,
companyId: COMPANY_ID,
apiKeyId: 'ak_1',
apiKeyName: 'wrong scope',
scopes: ['invoices:read'],
mode: 'live',
})
mockServiceClient.mockReturnValue(makeFlexibleSupabase({}))
const res = await listRunEmployees(
makeRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/salary-runs/${RUN_ID}/employees`),
listParams(COMPANY_ID, RUN_ID),
)
expect(res.status).toBe(403)
const body = await res.json()
expect(body.error.code).toBe('INSUFFICIENT_SCOPE')
})
it('returns 401 without a bearer token', async () => {
mockValidate.mockResolvedValue(null)
mockServiceClient.mockReturnValue(makeFlexibleSupabase({}))
const res = await listRunEmployees(
new Request(`https://x.test/api/v1/companies/${COMPANY_ID}/salary-runs/${RUN_ID}/employees`),
listParams(COMPANY_ID, RUN_ID),
)
expect(res.status).toBe(401)
})
it('emits a next_cursor when the page is full', async () => {
// limit=1 with 2 rows returned (limit + 1 fetch convention).
const second = {
...SAMPLE_SRE,
id: 'ffffffff-ffff-4fff-8fff-ffffffffffff',
employee_id: '99999999-9999-4999-8999-999999999999',
created_at: '2026-05-01T09:00:00Z',
}
mockServiceClient.mockReturnValue(
makeFlexibleSupabase({
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
salary_runs: { data: { id: RUN_ID }, error: null },
salary_run_employees: { data: [SAMPLE_SRE, second], error: null },
}),
)
const res = await listRunEmployees(
makeRequest(
`https://x.test/api/v1/companies/${COMPANY_ID}/salary-runs/${RUN_ID}/employees?limit=1`,
),
listParams(COMPANY_ID, RUN_ID),
)
expect(res.status).toBe(200)
const body = await res.json()
expect(body.data).toHaveLength(1)
expect(body.meta.next_cursor).toBeTruthy()
})
})
describe('GET /api/v1/companies/:companyId/salary-runs/:id/employees/:employeeId', () => {
it('returns the payslip detail with line items and calculation breakdown', async () => {
mockServiceClient.mockReturnValue(
makeFlexibleSupabase({
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
salary_run_employees: { data: SAMPLE_SRE, error: null },
}),
)
const res = await getPayslip(
makeRequest(
`https://x.test/api/v1/companies/${COMPANY_ID}/salary-runs/${RUN_ID}/employees/${EMPLOYEE_ID}`,
),
detailParams(COMPANY_ID, RUN_ID, EMPLOYEE_ID),
)
expect(res.status).toBe(200)
const body = await res.json()
expect(body.data.salary_run_employee_id).toBe(SRE_ID)
expect(body.data.employee_id).toBe(EMPLOYEE_ID)
expect(body.data.personnummer_masked).toBe('19000101XXXX')
expect(body.data.line_items).toHaveLength(1)
expect(body.data.line_items[0].salary_line_item_id).toBe(LINE_ID)
expect(body.data.line_items[0].item_type).toBe('monthly_salary')
expect(body.data.calculation_breakdown.steps).toHaveLength(1)
// Raw personnummer never leaks; the raw line id is re-keyed to the
// qualified name (no bare `id` fields in the payload).
expect(JSON.stringify(body)).not.toContain(SAMPLE_PERSONNUMMER)
expect(body.data.line_items[0].id).toBeUndefined()
expect(body.data.id).toBeUndefined()
})
it('returns 404 SALARY_RUN_NOT_FOUND when the run itself is missing', async () => {
mockServiceClient.mockReturnValue(
makeFlexibleSupabase({
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
salary_run_employees: { data: null, error: null },
salary_runs: { data: null, error: null },
}),
)
const res = await getPayslip(
makeRequest(
`https://x.test/api/v1/companies/${COMPANY_ID}/salary-runs/${RUN_ID}/employees/${EMPLOYEE_ID}`,
),
detailParams(COMPANY_ID, RUN_ID, EMPLOYEE_ID),
)
expect(res.status).toBe(404)
const body = await res.json()
expect(body.error.code).toBe('SALARY_RUN_NOT_FOUND')
})
it('returns 404 NOT_FOUND when the run exists but the employee is not in it', async () => {
mockServiceClient.mockReturnValue(
makeFlexibleSupabase({
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
salary_run_employees: { data: null, error: null },
salary_runs: { data: { id: RUN_ID }, error: null },
}),
)
const res = await getPayslip(
makeRequest(
`https://x.test/api/v1/companies/${COMPANY_ID}/salary-runs/${RUN_ID}/employees/${EMPLOYEE_ID}`,
),
detailParams(COMPANY_ID, RUN_ID, EMPLOYEE_ID),
)
expect(res.status).toBe(404)
const body = await res.json()
expect(body.error.code).toBe('NOT_FOUND')
expect(body.error.details.resource).toBe('salary_run_employee')
})
it('rejects a non-UUID employee id with 400 VALIDATION_ERROR', async () => {
mockServiceClient.mockReturnValue(
makeFlexibleSupabase({
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
}),
)
const res = await getPayslip(
makeRequest(
`https://x.test/api/v1/companies/${COMPANY_ID}/salary-runs/${RUN_ID}/employees/nope`,
),
detailParams(COMPANY_ID, RUN_ID, 'nope'),
)
expect(res.status).toBe(400)
const body = await res.json()
expect(body.error.code).toBe('VALIDATION_ERROR')
})
})
describe('POST /api/v1/companies/:companyId/salary-runs/:id/employees', () => {
const withIdempotency = (url: string, body: unknown): Request =>
new Request(url, {
method: 'POST',
headers: {
Authorization: 'Bearer test-fixture-not-a-real-key',
'Idempotency-Key': 'b1aaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa',
},
body: JSON.stringify(body),
})
const SAMPLE_EMPLOYEE_MASTER = {
id: EMPLOYEE_ID,
employment_degree: 100,
monthly_salary: 35000,
hourly_rate: null,
salary_type: 'monthly',
employment_type: 'employee',
tax_table_number: 33,
tax_column: 1,
}
it('attaches an employee to a draft run (happy path, 201)', async () => {
mockServiceClient.mockReturnValue(
makeFlexibleSupabase({
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
salary_runs: { data: { id: RUN_ID, status: 'draft' }, error: null },
employees: { data: SAMPLE_EMPLOYEE_MASTER, error: null },
salary_run_employees: [
{ data: null, error: null }, // duplicate check
{
data: {
id: SRE_ID,
salary_run_id: RUN_ID,
employee_id: EMPLOYEE_ID,
company_id: COMPANY_ID,
employment_degree: 100,
monthly_salary: 35000,
salary_type: 'monthly',
hours_worked: null,
tax_table_number: 33,
tax_column: 1,
created_at: '2026-05-01T08:00:00Z',
updated_at: '2026-05-01T08:00:00Z',
},
error: null,
},
],
salary_line_items: { data: null, error: null },
idempotency_keys: { data: null, error: null },
}),
)
const res = await attachEmployee(
withIdempotency(
`https://x.test/api/v1/companies/${COMPANY_ID}/salary-runs/${RUN_ID}/employees`,
{ employee_id: EMPLOYEE_ID },
),
listParams(COMPANY_ID, RUN_ID),
)
expect(res.status).toBe(201)
const body = await res.json()
expect(body.data.salary_run_employee_id).toBe(SRE_ID)
expect(body.data.employee_id).toBe(EMPLOYEE_ID)
expect(body.data.id).toBeUndefined()
})
it('returns 409 SALARY_RUN_EMPLOYEE_DUPLICATE when already attached', async () => {
mockServiceClient.mockReturnValue(
makeFlexibleSupabase({
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
salary_runs: { data: { id: RUN_ID, status: 'draft' }, error: null },
employees: { data: SAMPLE_EMPLOYEE_MASTER, error: null },
salary_run_employees: { data: { id: SRE_ID }, error: null },
idempotency_keys: { data: null, error: null },
}),
)
const res = await attachEmployee(
withIdempotency(
`https://x.test/api/v1/companies/${COMPANY_ID}/salary-runs/${RUN_ID}/employees`,
{ employee_id: EMPLOYEE_ID },
),
listParams(COMPANY_ID, RUN_ID),
)
expect(res.status).toBe(409)
const body = await res.json()
expect(body.error.code).toBe('SALARY_RUN_EMPLOYEE_DUPLICATE')
})
it('returns 400 SALARY_RUN_EMPLOYEES_NOT_DRAFT once the run advanced', async () => {
mockServiceClient.mockReturnValue(
makeFlexibleSupabase({
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
salary_runs: { data: { id: RUN_ID, status: 'review' }, error: null },
idempotency_keys: { data: null, error: null },
}),
)
const res = await attachEmployee(
withIdempotency(
`https://x.test/api/v1/companies/${COMPANY_ID}/salary-runs/${RUN_ID}/employees`,
{ employee_id: EMPLOYEE_ID },
),
listParams(COMPANY_ID, RUN_ID),
)
expect(res.status).toBe(400)
const body = await res.json()
expect(body.error.code).toBe('SALARY_RUN_EMPLOYEES_NOT_DRAFT')
})
})
describe('DELETE /api/v1/companies/:companyId/salary-runs/:id/employees/:employeeId', () => {
const deleteRequest = (url: string): Request =>
new Request(url, {
method: 'DELETE',
headers: {
Authorization: 'Bearer test-fixture-not-a-real-key',
'Idempotency-Key': 'b2aaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa',
},
})
it('removes an attached employee (204)', async () => {
mockServiceClient.mockReturnValue(
makeFlexibleSupabase({
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
salary_runs: { data: { id: RUN_ID, status: 'draft' }, error: null },
salary_run_employees: [
{ data: { id: SRE_ID }, error: null },
{ data: null, error: null },
],
idempotency_keys: { data: null, error: null },
}),
)
const res = await removeEmployee(
deleteRequest(
`https://x.test/api/v1/companies/${COMPANY_ID}/salary-runs/${RUN_ID}/employees/${EMPLOYEE_ID}`,
),
detailParams(COMPANY_ID, RUN_ID, EMPLOYEE_ID),
)
expect(res.status).toBe(204)
})
it('returns 404 SALARY_RUN_EMPLOYEE_NOT_FOUND when not attached', async () => {
mockServiceClient.mockReturnValue(
makeFlexibleSupabase({
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
salary_runs: { data: { id: RUN_ID, status: 'draft' }, error: null },
salary_run_employees: { data: null, error: null },
idempotency_keys: { data: null, error: null },
}),
)
const res = await removeEmployee(
deleteRequest(
`https://x.test/api/v1/companies/${COMPANY_ID}/salary-runs/${RUN_ID}/employees/${EMPLOYEE_ID}`,
),
detailParams(COMPANY_ID, RUN_ID, EMPLOYEE_ID),
)
expect(res.status).toBe(404)
const body = await res.json()
expect(body.error.code).toBe('SALARY_RUN_EMPLOYEE_NOT_FOUND')
})
})
@@ -0,0 +1,355 @@
/**
* /api/v1/companies/{companyId}/salary-runs/{id}/employees
*
* GET: list the per-employee results of a salary run (one row per
* salary_run_employee). Cursor pagination on (created_at ASC, id ASC).
*
* The row carries the calculated aggregates (gross/tax/net/avgifter/vacation)
* but NOT the payslip line items: drill into
* GET /salary-runs/{id}/employees/{employeeId} for those.
*
* GDPR Art.5(1)(c): personnummer is masked (birthdate visible, last-4 hidden)
* on every payslip-shaped response. A payslip is a pay document, not an
* identity record; the employee master detail endpoint is the deliberate
* drill-in that returns the full value.
*/
import { z } from 'zod'
import { created, paginated } from '@/lib/api/v1/response'
import { dryRunPreview } from '@/lib/api/v1/dry-run'
import {
decodeDefaultCursor,
encodeDefaultCursor,
parsePaginationParams,
} from '@/lib/api/v1/pagination'
import { registerEndpoint, listEnvelope, dataEnvelope } from '@/lib/api/v1/registry'
import { withApiV1 } from '@/lib/api/v1/with-api-v1'
import { v1ErrorResponse, v1ErrorResponseFromCode } from '@/lib/api/v1/errors'
import { maskPersonnummer } from '@/lib/api/v1/mask-personnummer'
import { decryptPersonnummer } from '@/lib/salary/personnummer'
import { AddEmployeeToRunSchema } from '@/lib/api/schemas'
import { addEmployeeToRun } from '@/lib/salary/run-employees'
const RunEmployeeSummary = z.object({
/** Qualified id of the salary_run_employees join row (NOT the employee id). */
salary_run_employee_id: z.string().uuid(),
employee_id: z.string().uuid(),
first_name: z.string(),
last_name: z.string(),
/** Masked: first 8 digits + 'XXXX' (birthdate visible, last-4 hidden). */
personnummer_masked: z.string(),
salary_type: z.string(),
employment_degree: z.number(),
monthly_salary: z.number().nullable(),
hours_worked: z.number().nullable(),
gross_salary: z.number(),
taxable_income: z.number(),
tax_withheld: z.number(),
tax_withheld_override: z.number().nullable(),
net_salary: z.number(),
avgifter_basis: z.number(),
avgifter_amount: z.number(),
avgifter_amount_override: z.number().nullable(),
avgifter_category: z.string().nullable(),
vacation_accrual: z.number(),
sick_days: z.number(),
vab_days: z.number(),
parental_days: z.number(),
vacation_days_taken: z.number(),
created_at: z.string(),
updated_at: z.string(),
})
// Explicit projection: never SELECT *. personnummer is loaded only to serve
// the masked form; the full value never leaves this projection.
const RUN_EMPLOYEE_SUMMARY_COLUMNS =
'id, employee_id, salary_type, employment_degree, monthly_salary, hours_worked, ' +
'gross_salary, taxable_income, tax_withheld, tax_withheld_override, net_salary, ' +
'avgifter_basis, avgifter_amount, avgifter_amount_override, avgifter_category, ' +
'vacation_accrual, sick_days, vab_days, parental_days, vacation_days_taken, ' +
'created_at, updated_at, employee:employees(first_name, last_name, personnummer)'
registerEndpoint({
operation: 'salary-runs.employees.list',
method: 'GET',
path: '/api/v1/companies/:companyId/salary-runs/:id/employees',
summary: 'List per-employee results of a salary run.',
description:
'Returns one row per employee in the run with the calculated aggregates: gross salary, tax withheld, net pay, arbetsgivaravgifter, vacation accrual, and absence day counts. All aggregate fields are 0 until POST /calculate has run. Cursor pagination on (created_at, id).',
useWhen:
'You need the per-employee outcome of a run: to review before approval, to reconcile against an external system, or to pick an employee_id for the payslip drill-in.',
doNotUseFor:
'Payslip line items or the step-by-step calculation breakdown: use GET /salary-runs/{id}/employees/{employeeId}. The employee master record: use GET /employees/{id}.',
pitfalls: [
'Aggregates are 0 until POST /calculate has advanced the run to review.',
'tax_withheld_override / avgifter_amount_override are review-stage manual adjustments; the effective value is COALESCE(override, calculated).',
'personnummer is masked on all payslip-shaped responses (GDPR Art.5(1)(c)); the employee detail endpoint returns the full value.',
],
example: {
response: {
data: [
{
salary_run_employee_id: 'sre_a8f1…',
employee_id: 'emp_77b2…',
first_name: 'Anna',
last_name: 'Andersson',
personnummer_masked: 'YYYYMMDDXXXX',
salary_type: 'monthly',
gross_salary: 35000,
tax_withheld: -8200,
net_salary: 26800,
avgifter_amount: 10997,
},
],
meta: { request_id: 'req_…', api_version: '2026-05-12', next_cursor: null },
},
},
scope: 'payroll:read',
risk: 'low',
idempotent: true,
reversible: false,
dryRunSupported: false,
response: { success: listEnvelope(RunEmployeeSummary) },
})
export const GET = withApiV1<{ params: Promise<{ companyId: string; id: string }> }>(
'salary-runs.employees.list',
async (request, ctx, params) => {
const { id } = await params.params
const idParse = z.string().uuid().safeParse(id)
if (!idParse.success) {
return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, {
requestId: ctx.requestId,
details: { field: 'id', message: 'Salary-run id must be a UUID.' },
})
}
// 404 the run itself first so an empty list unambiguously means
// "run exists, no employees attached".
const { data: run, error: runErr } = await ctx.supabase
.from('salary_runs')
.select('id')
.eq('company_id', ctx.companyId!)
.eq('id', idParse.data)
.maybeSingle()
if (runErr) {
return v1ErrorResponse(runErr, ctx.log, { requestId: ctx.requestId })
}
if (!run) {
return v1ErrorResponseFromCode('SALARY_RUN_NOT_FOUND', ctx.log, { requestId: ctx.requestId })
}
const url = new URL(request.url)
const { limit, cursor } = parsePaginationParams(url)
const decoded = decodeDefaultCursor(cursor)
let query = ctx.supabase
.from('salary_run_employees')
.select(RUN_EMPLOYEE_SUMMARY_COLUMNS)
.eq('company_id', ctx.companyId!)
.eq('salary_run_id', idParse.data)
.order('created_at', { ascending: true })
.order('id', { ascending: true })
.limit(limit + 1)
if (decoded) {
query = query.or(
`created_at.gt.${decoded.ts},and(created_at.eq.${decoded.ts},id.gt.${decoded.id})`,
)
}
const { data, error } = await query
if (error) {
return v1ErrorResponse(error, ctx.log, { requestId: ctx.requestId })
}
type Row = {
id: string
employee_id: string
salary_type: string
employment_degree: number
monthly_salary: number | null
hours_worked: number | null
gross_salary: number
taxable_income: number
tax_withheld: number
tax_withheld_override: number | null
net_salary: number
avgifter_basis: number
avgifter_amount: number
avgifter_amount_override: number | null
avgifter_category: string | null
vacation_accrual: number
sick_days: number
vab_days: number
parental_days: number
vacation_days_taken: number
created_at: string
updated_at: string
employee: { first_name: string; last_name: string; personnummer: string } | null
}
const rows = ((data ?? []) as unknown) as Row[]
const trimmed = rows.slice(0, limit)
const hasMore = rows.length > limit
const items = trimmed.map((r) => ({
salary_run_employee_id: r.id,
employee_id: r.employee_id,
first_name: r.employee?.first_name ?? '',
last_name: r.employee?.last_name ?? '',
personnummer_masked: r.employee
? maskPersonnummer(decryptPersonnummer(r.employee.personnummer))
: '',
salary_type: r.salary_type,
employment_degree: r.employment_degree,
monthly_salary: r.monthly_salary,
hours_worked: r.hours_worked,
gross_salary: r.gross_salary,
taxable_income: r.taxable_income,
tax_withheld: r.tax_withheld,
tax_withheld_override: r.tax_withheld_override,
net_salary: r.net_salary,
avgifter_basis: r.avgifter_basis,
avgifter_amount: r.avgifter_amount,
avgifter_amount_override: r.avgifter_amount_override,
avgifter_category: r.avgifter_category,
vacation_accrual: r.vacation_accrual,
sick_days: r.sick_days,
vab_days: r.vab_days,
parental_days: r.parental_days,
vacation_days_taken: r.vacation_days_taken,
created_at: r.created_at,
updated_at: r.updated_at,
}))
const last = trimmed[trimmed.length - 1]
const nextCursor = hasMore && last
? encodeDefaultCursor({ id: last.id, created_at: last.created_at })
: null
return paginated(items, {
requestId: ctx.requestId,
nextCursor: nextCursor ?? undefined,
})
},
)
// ──────────────────────────────────────────────────────────────────
// POST: attach an employee to a draft run
// ──────────────────────────────────────────────────────────────────
const RunEmployeeAttached = z.object({
salary_run_employee_id: z.string().uuid().nullable(),
employee_id: z.string().uuid(),
salary_type: z.string(),
employment_degree: z.number(),
monthly_salary: z.number(),
hours_worked: z.number().nullable(),
tax_table_number: z.number().nullable(),
tax_column: z.number().nullable(),
})
registerEndpoint({
operation: 'salary-runs.employees.add',
method: 'POST',
path: '/api/v1/companies/:companyId/salary-runs/:id/employees',
summary: 'Add an employee to a draft salary run.',
description:
'Attaches an active employee to a draft run: snapshots their pay configuration (salary, degree, tax table) onto the run and seeds the base salary line (Grundlön/Timlön). For hourly employees, pass hours_worked.',
useWhen:
'The run was created without this employee (e.g. hired after the run was drafted), or you create runs empty and attach employees one by one from an external system.',
doNotUseFor:
'Changing an attached employee\'s pay for this month (internal per-run PATCH; not on v1). Re-attaching after removal is fine: the snapshot is retaken.',
pitfalls: [
'Draft-only: 400 SALARY_RUN_EMPLOYEES_NOT_DRAFT once the run has advanced.',
'Attaching twice returns 409 SALARY_RUN_EMPLOYEE_DUPLICATE.',
'The snapshot freezes salary/degree/tax-table at attach time: later employee edits do not flow into this run.',
'Inactive (soft-deleted) employees cannot be attached: 404 EMPLOYEE_NOT_FOUND.',
],
example: {
request: { employee_id: 'emp_77b2…' },
response: {
data: {
salary_run_employee_id: 'sre_a8f1…',
employee_id: 'emp_77b2…',
salary_type: 'monthly',
monthly_salary: 35000,
},
meta: { request_id: 'req_…', api_version: '2026-05-12' },
},
},
scope: 'payroll:write',
risk: 'low',
idempotent: true,
reversible: true,
dryRunSupported: true,
request: { body: AddEmployeeToRunSchema },
response: { success: dataEnvelope(RunEmployeeAttached) },
})
export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string }> }>(
'salary-runs.employees.add',
async (request, ctx, params) => {
const { id } = await params.params
const idParse = z.string().uuid().safeParse(id)
if (!idParse.success) {
return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, {
requestId: ctx.requestId,
details: { field: 'id', message: 'Salary-run id must be a UUID.' },
})
}
let rawBody: unknown
try {
rawBody = await request.json()
} catch {
return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, {
requestId: ctx.requestId,
details: { field: 'body', message: 'Body is not valid JSON.' },
})
}
const parsed = AddEmployeeToRunSchema.safeParse(rawBody)
if (!parsed.success) {
return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, {
requestId: ctx.requestId,
details: {
issues: parsed.error.issues.map((i) => ({
field: i.path.join('.'),
message: i.message,
})),
},
})
}
const result = await addEmployeeToRun(ctx.supabase, {
companyId: ctx.companyId!,
salaryRunId: idParse.data,
employeeId: parsed.data.employee_id,
hoursWorked: parsed.data.hours_worked ?? null,
dryRun: ctx.dryRun,
})
if (!result.ok) {
return v1ErrorResponseFromCode(result.code, ctx.log, {
requestId: ctx.requestId,
details: result.details,
})
}
const { id: sreId, company_id: _companyId, salary_run_id: _runId, ...rest } =
result.data as Record<string, unknown> & {
id: string | null
company_id?: string
salary_run_id?: string
}
const payload = { salary_run_employee_id: sreId, ...rest }
if (ctx.dryRun) {
return dryRunPreview(payload, { requestId: ctx.requestId, log: ctx.log })
}
return created(payload, { requestId: ctx.requestId })
},
{ requireIdempotencyKey: true },
)
@@ -0,0 +1,227 @@
/**
* /api/v1/companies/{companyId}/salary-runs/{id}/lines/{lineId}
*
* PATCH : update a payslip line (amount, description, quantity, ...) in a
* DRAFT run.
* DELETE : remove a payslip line from a DRAFT run.
*
* Both verify the line belongs to the given run (via its
* salary_run_employees row) so a lineId from another run 404s instead of
* silently mutating.
*/
import { z } from 'zod'
import { ok, noContent } from '@/lib/api/v1/response'
import { dryRunPreview } from '@/lib/api/v1/dry-run'
import { registerEndpoint, dataEnvelope, NoBodyResponse } from '@/lib/api/v1/registry'
import { withApiV1 } from '@/lib/api/v1/with-api-v1'
import { v1ErrorResponseFromCode } from '@/lib/api/v1/errors'
import { UpdateSalaryLineItemSchema } from '@/lib/api/schemas'
import { updatePayslipLine, deletePayslipLine } from '@/lib/salary/payslip-lines'
const LineItemResponse = z.object({
salary_line_item_id: z.string().uuid(),
salary_run_employee_id: z.string().uuid(),
item_type: z.string(),
description: z.string(),
quantity: z.number().nullable(),
unit_price: z.number().nullable(),
amount: z.number(),
is_taxable: z.boolean(),
is_avgift_basis: z.boolean(),
is_vacation_basis: z.boolean(),
is_gross_deduction: z.boolean(),
is_net_deduction: z.boolean(),
account_number: z.string().nullable(),
sort_order: z.number(),
})
registerEndpoint({
operation: 'salary-runs.lines.update',
method: 'PATCH',
path: '/api/v1/companies/:companyId/salary-runs/:id/lines/:lineId',
summary: 'Update a payslip line in a draft salary run.',
description:
'Updates fields on a salary_line_items row (amount, description, quantity, unit_price, flags, account_number) while the run is a draft. Amounts are rounded to whole öre.',
useWhen:
'You spotted a wrong amount or description on a manual line before calculating: fix it in place instead of delete + recreate.',
doNotUseFor:
'Post-calculation tax/avgifter adjustments (review-stage overrides are not on v1). Engine-derived lines (absence/benefits): they are regenerated by :calculate, so edits are overwritten.',
pitfalls: [
'Draft-only: 400 SALARY_RUN_LINE_NOT_DRAFT once the run has advanced.',
'A lineId that belongs to a different run returns 404 SALARY_LINE_NOT_FOUND.',
'Line edits do not recompute tax or totals: call POST /salary-runs/{id}/calculate afterwards.',
],
example: {
request: { amount: 5500 },
response: {
data: { salary_line_item_id: 'sli_31c9…', amount: 5500 },
meta: { request_id: 'req_…', api_version: '2026-05-12' },
},
},
scope: 'payroll:write',
risk: 'low',
idempotent: true,
reversible: true,
dryRunSupported: true,
request: { body: UpdateSalaryLineItemSchema },
response: { success: dataEnvelope(LineItemResponse) },
})
function parsePathIds(id: string, lineId: string):
| { ok: true; runId: string; lineId: string }
| { ok: false; field: string } {
const runParse = z.string().uuid().safeParse(id)
if (!runParse.success) return { ok: false, field: 'id' }
const lineParse = z.string().uuid().safeParse(lineId)
if (!lineParse.success) return { ok: false, field: 'lineId' }
return { ok: true, runId: runParse.data, lineId: lineParse.data }
}
function toResponsePayload(row: Record<string, unknown>): Record<string, unknown> {
const { id, company_id: _companyId, created_at: _c, updated_at: _u, ...rest } = row as {
id: string
company_id?: string
created_at?: string
updated_at?: string
} & Record<string, unknown>
return { salary_line_item_id: id, ...rest }
}
export const PATCH = withApiV1<{ params: Promise<{ companyId: string; id: string; lineId: string }> }>(
'salary-runs.lines.update',
async (request, ctx, params) => {
const { id, lineId } = await params.params
const ids = parsePathIds(id, lineId)
if (!ids.ok) {
return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, {
requestId: ctx.requestId,
details: { field: ids.field, message: 'Path ids must be UUIDs.' },
})
}
let rawBody: unknown
try {
rawBody = await request.json()
} catch {
return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, {
requestId: ctx.requestId,
details: { field: 'body', message: 'Body is not valid JSON.' },
})
}
const parsed = UpdateSalaryLineItemSchema.safeParse(rawBody)
if (!parsed.success) {
return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, {
requestId: ctx.requestId,
details: {
issues: parsed.error.issues.map((i) => ({
field: i.path.join('.'),
message: i.message,
})),
},
})
}
// Zod .default() materializes flags the caller never sent; strip anything
// not explicitly present so a PATCH can't silently reset flags. (Same
// explicit-keys defense as the salary-runs PATCH.)
const POLLUTING_KEYS = new Set(['__proto__', 'constructor', 'prototype'])
const rawKeys =
typeof rawBody === 'object' && rawBody !== null && !Array.isArray(rawBody)
? Object.keys(rawBody).filter((k) => !POLLUTING_KEYS.has(k))
: []
const patch: Record<string, unknown> = {}
for (const [key, value] of Object.entries(parsed.data) as Array<[string, unknown]>) {
if (rawKeys.includes(key) && value !== undefined) {
patch[key] = value
}
}
if (Object.keys(patch).length === 0) {
return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, {
requestId: ctx.requestId,
details: { field: 'body', message: 'At least one updatable field is required.' },
})
}
const result = await updatePayslipLine(ctx.supabase, {
companyId: ctx.companyId!,
salaryRunId: ids.runId,
lineId: ids.lineId,
patch,
dryRun: ctx.dryRun,
})
if (!result.ok) {
return v1ErrorResponseFromCode(result.code, ctx.log, {
requestId: ctx.requestId,
details: result.details,
})
}
const payload = toResponsePayload(result.data as unknown as Record<string, unknown>)
if (ctx.dryRun) {
return dryRunPreview(payload, { requestId: ctx.requestId, log: ctx.log })
}
return ok(payload, { requestId: ctx.requestId })
},
{ requireIdempotencyKey: true },
)
registerEndpoint({
operation: 'salary-runs.lines.delete',
method: 'DELETE',
path: '/api/v1/companies/:companyId/salary-runs/:id/lines/:lineId',
summary: 'Delete a payslip line from a draft salary run.',
description:
'Removes a salary_line_items row while the run is a draft. Engine-derived lines (absence, benefits) reappear on the next :calculate; delete the underlying absence/benefit record instead.',
useWhen:
'A manual line (bonus, deduction) was added by mistake and the run has not been calculated/advanced yet.',
doNotUseFor:
'Removing an employee from the run entirely: DELETE /salary-runs/{id}/employees/{employeeId}. Suppressing engine-derived lines: fix the source data (absence days, benefits).',
pitfalls: [
'Draft-only: 400 SALARY_RUN_LINE_NOT_DRAFT once the run has advanced.',
'Deleting an engine-derived line is futile: :calculate regenerates it from source data.',
],
example: { response: { data: null } },
scope: 'payroll:write',
risk: 'low',
idempotent: true,
reversible: false,
dryRunSupported: true,
response: { success: NoBodyResponse },
})
export const DELETE = withApiV1<{ params: Promise<{ companyId: string; id: string; lineId: string }> }>(
'salary-runs.lines.delete',
async (_request, ctx, params) => {
const { id, lineId } = await params.params
const ids = parsePathIds(id, lineId)
if (!ids.ok) {
return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, {
requestId: ctx.requestId,
details: { field: ids.field, message: 'Path ids must be UUIDs.' },
})
}
const result = await deletePayslipLine(ctx.supabase, {
companyId: ctx.companyId!,
salaryRunId: ids.runId,
lineId: ids.lineId,
dryRun: ctx.dryRun,
})
if (!result.ok) {
return v1ErrorResponseFromCode(result.code, ctx.log, {
requestId: ctx.requestId,
details: result.details,
})
}
if (ctx.dryRun) {
return dryRunPreview(result.data, { requestId: ctx.requestId, log: ctx.log })
}
return noContent({ requestId: ctx.requestId })
},
{ requireIdempotencyKey: true },
)
@@ -0,0 +1,439 @@
/**
* Tests for the v1 payslip line writes (payroll gap-closure 1.2).
*
* POST /salary-runs/{id}/employees/{employeeId}/lines
* PATCH /salary-runs/{id}/lines/{lineId}
* DELETE /salary-runs/{id}/lines/{lineId}
*/
import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
beforeAll(() => {
if (process.env.NODE_ENV !== 'test') {
throw new Error(
`salary line route tests require NODE_ENV=test (got ${process.env.NODE_ENV ?? 'undefined'})`,
)
}
process.env.NEXT_PUBLIC_SUPABASE_URL ||= 'http://localhost:54321'
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY ||= 'test-anon-key'
})
vi.mock('@/lib/auth/api-keys', async () => {
const actual = await vi.importActual<typeof import('@/lib/auth/api-keys')>('@/lib/auth/api-keys')
return {
...actual,
validateApiKey: vi.fn(),
createServiceClientNoCookies: vi.fn(),
}
})
vi.mock('@supabase/supabase-js', async () => {
const actual = await vi.importActual<typeof import('@supabase/supabase-js')>('@supabase/supabase-js')
return { ...actual, createClient: vi.fn().mockReturnValue({}) }
})
import { validateApiKey, createServiceClientNoCookies } from '@/lib/auth/api-keys'
import { POST as createLine } from '../../employees/[employeeId]/lines/route'
import { PATCH as patchLine, DELETE as deleteLine } from '../[lineId]/route'
const mockValidate = validateApiKey as ReturnType<typeof vi.fn>
const mockServiceClient = createServiceClientNoCookies as ReturnType<typeof vi.fn>
interface TableResp {
data?: unknown
error?: unknown
}
function makeFlexibleSupabase(byTable: Record<string, TableResp | TableResp[]>) {
const queues = new Map<string, TableResp[]>()
for (const [t, val] of Object.entries(byTable)) {
queues.set(t, Array.isArray(val) ? [...val] : [val])
}
const tableCalls: string[] = []
const buildChain = (table: string): unknown => {
const handler: ProxyHandler<object> = {
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[]) => buildChain(table)
},
}
return new Proxy({}, handler)
}
return {
tableCalls,
from: vi.fn((table: string) => {
tableCalls.push(table)
return buildChain(table)
}),
}
}
const COMPANY_ID = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa'
const RUN_ID = 'cccccccc-cccc-4ccc-8ccc-cccccccccccc'
const SRE_ID = 'dddddddd-dddd-4ddd-8ddd-dddddddddddd'
const EMPLOYEE_ID = 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb'
const LINE_ID = 'eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeee'
const USER_ID = 'user-1'
const SAMPLE_LINE = {
id: LINE_ID,
salary_run_employee_id: SRE_ID,
company_id: COMPANY_ID,
item_type: 'bonus',
description: 'Kvartalsbonus',
quantity: null,
unit_price: null,
amount: 5000,
is_taxable: true,
is_avgift_basis: true,
is_vacation_basis: true,
is_gross_deduction: false,
is_net_deduction: false,
account_number: '7210',
sort_order: 0,
created_at: '2026-05-01T08:00:00Z',
updated_at: '2026-05-01T08:00:00Z',
}
function makeRequest(url: string, init?: RequestInit): Request {
return new Request(url, {
...init,
headers: {
Authorization: 'Bearer test-fixture-not-a-real-key',
'Idempotency-Key': 'b1aaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa',
...(init?.headers ?? {}),
},
})
}
function createParams(companyId: string, id: string, employeeId: string) {
return { params: Promise.resolve({ companyId, id, employeeId }) }
}
function lineParams(companyId: string, id: string, lineId: string) {
return { params: Promise.resolve({ companyId, id, lineId }) }
}
beforeEach(() => {
vi.clearAllMocks()
mockValidate.mockResolvedValue({
userId: USER_ID,
companyId: COMPANY_ID,
apiKeyId: 'ak_1',
apiKeyName: 'CI key',
scopes: ['payroll:read', 'payroll:write'],
mode: 'live',
})
})
describe('POST /salary-runs/:id/employees/:employeeId/lines', () => {
const validBody = { item_type: 'bonus', description: 'Kvartalsbonus', amount: 5000 }
it('creates a line and returns 201 with the qualified id (happy path)', async () => {
mockServiceClient.mockReturnValue(
makeFlexibleSupabase({
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
salary_runs: { data: { id: RUN_ID, status: 'draft' }, error: null },
salary_run_employees: { data: { id: SRE_ID, employee_id: EMPLOYEE_ID }, error: null },
salary_line_items: { data: SAMPLE_LINE, error: null },
idempotency_keys: { data: null, error: null },
}),
)
const res = await createLine(
makeRequest(
`https://x.test/api/v1/companies/${COMPANY_ID}/salary-runs/${RUN_ID}/employees/${EMPLOYEE_ID}/lines`,
{ method: 'POST', body: JSON.stringify(validBody) },
),
createParams(COMPANY_ID, RUN_ID, EMPLOYEE_ID),
)
expect(res.status).toBe(201)
const body = await res.json()
expect(body.data.salary_line_item_id).toBe(LINE_ID)
expect(body.data.item_type).toBe('bonus')
expect(body.data.id).toBeUndefined()
})
it('returns 400 SALARY_RUN_LINE_NOT_DRAFT when the run has advanced', async () => {
mockServiceClient.mockReturnValue(
makeFlexibleSupabase({
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
salary_runs: { data: { id: RUN_ID, status: 'review' }, error: null },
idempotency_keys: { data: null, error: null },
}),
)
const res = await createLine(
makeRequest(
`https://x.test/api/v1/companies/${COMPANY_ID}/salary-runs/${RUN_ID}/employees/${EMPLOYEE_ID}/lines`,
{ method: 'POST', body: JSON.stringify(validBody) },
),
createParams(COMPANY_ID, RUN_ID, EMPLOYEE_ID),
)
expect(res.status).toBe(400)
const body = await res.json()
expect(body.error.code).toBe('SALARY_RUN_LINE_NOT_DRAFT')
expect(body.error.details.current_status).toBe('review')
})
it('returns 404 SALARY_RUN_EMPLOYEE_NOT_FOUND when the employee is not in the run', async () => {
mockServiceClient.mockReturnValue(
makeFlexibleSupabase({
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
salary_runs: { data: { id: RUN_ID, status: 'draft' }, error: null },
salary_run_employees: { data: null, error: null },
idempotency_keys: { data: null, error: null },
}),
)
const res = await createLine(
makeRequest(
`https://x.test/api/v1/companies/${COMPANY_ID}/salary-runs/${RUN_ID}/employees/${EMPLOYEE_ID}/lines`,
{ method: 'POST', body: JSON.stringify(validBody) },
),
createParams(COMPANY_ID, RUN_ID, EMPLOYEE_ID),
)
expect(res.status).toBe(404)
const body = await res.json()
expect(body.error.code).toBe('SALARY_RUN_EMPLOYEE_NOT_FOUND')
})
it('rejects an unknown item_type with 400 VALIDATION_ERROR', async () => {
mockServiceClient.mockReturnValue(
makeFlexibleSupabase({
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
idempotency_keys: { data: null, error: null },
}),
)
const res = await createLine(
makeRequest(
`https://x.test/api/v1/companies/${COMPANY_ID}/salary-runs/${RUN_ID}/employees/${EMPLOYEE_ID}/lines`,
{ method: 'POST', body: JSON.stringify({ ...validBody, item_type: 'space_travel' }) },
),
createParams(COMPANY_ID, RUN_ID, EMPLOYEE_ID),
)
expect(res.status).toBe(400)
const body = await res.json()
expect(body.error.code).toBe('VALIDATION_ERROR')
})
it('returns a dry-run preview without inserting', async () => {
const supabaseMock = makeFlexibleSupabase({
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
salary_runs: { data: { id: RUN_ID, status: 'draft' }, error: null },
salary_run_employees: { data: { id: SRE_ID, employee_id: EMPLOYEE_ID }, error: null },
idempotency_keys: { data: null, error: null },
})
mockServiceClient.mockReturnValue(supabaseMock)
const res = await createLine(
makeRequest(
`https://x.test/api/v1/companies/${COMPANY_ID}/salary-runs/${RUN_ID}/employees/${EMPLOYEE_ID}/lines?dry_run=true`,
{ method: 'POST', body: JSON.stringify({ ...validBody, amount: 1.005 }) },
),
createParams(COMPANY_ID, RUN_ID, EMPLOYEE_ID),
)
expect(res.status).toBe(200)
expect(res.headers.get('X-Dry-Run')).toBe('true')
const body = await res.json()
expect(body.data.preview.salary_line_item_id).toBeNull()
// roundOre: 1.005 -> 1.01 (the exact-half case naive rounding gets wrong).
expect(body.data.preview.amount).toBe(1.01)
expect(body.data.preview.account_number).toBe('7210')
// No insert happened: salary_line_items was never touched.
expect(supabaseMock.tableCalls).not.toContain('salary_line_items')
})
it('returns 400 when Idempotency-Key is missing', async () => {
mockServiceClient.mockReturnValue(
makeFlexibleSupabase({
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
}),
)
const req = new Request(
`https://x.test/api/v1/companies/${COMPANY_ID}/salary-runs/${RUN_ID}/employees/${EMPLOYEE_ID}/lines`,
{
method: 'POST',
headers: { Authorization: 'Bearer test' },
body: JSON.stringify(validBody),
},
)
const res = await createLine(req, createParams(COMPANY_ID, RUN_ID, EMPLOYEE_ID))
expect(res.status).toBe(400)
})
it('rejects keys without payroll:write scope', async () => {
mockValidate.mockResolvedValue({
userId: USER_ID,
companyId: COMPANY_ID,
apiKeyId: 'ak_1',
apiKeyName: 'read-only',
scopes: ['payroll:read'],
mode: 'live',
})
mockServiceClient.mockReturnValue(makeFlexibleSupabase({}))
const res = await createLine(
makeRequest(
`https://x.test/api/v1/companies/${COMPANY_ID}/salary-runs/${RUN_ID}/employees/${EMPLOYEE_ID}/lines`,
{ method: 'POST', body: JSON.stringify(validBody) },
),
createParams(COMPANY_ID, RUN_ID, EMPLOYEE_ID),
)
expect(res.status).toBe(403)
})
it('returns 401 without a bearer token', async () => {
mockValidate.mockResolvedValue(null)
mockServiceClient.mockReturnValue(makeFlexibleSupabase({}))
const res = await createLine(
new Request(
`https://x.test/api/v1/companies/${COMPANY_ID}/salary-runs/${RUN_ID}/employees/${EMPLOYEE_ID}/lines`,
{ method: 'POST', body: JSON.stringify(validBody) },
),
createParams(COMPANY_ID, RUN_ID, EMPLOYEE_ID),
)
expect(res.status).toBe(401)
})
})
describe('PATCH /salary-runs/:id/lines/:lineId', () => {
it('updates a line (happy path)', async () => {
mockServiceClient.mockReturnValue(
makeFlexibleSupabase({
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
salary_runs: { data: { id: RUN_ID, status: 'draft' }, error: null },
salary_line_items: [
{ data: { ...SAMPLE_LINE, salary_run_employee: { salary_run_id: RUN_ID } }, error: null },
{ data: { ...SAMPLE_LINE, amount: 5500 }, error: null },
],
idempotency_keys: { data: null, error: null },
}),
)
const res = await patchLine(
makeRequest(
`https://x.test/api/v1/companies/${COMPANY_ID}/salary-runs/${RUN_ID}/lines/${LINE_ID}`,
{ method: 'PATCH', body: JSON.stringify({ amount: 5500 }) },
),
lineParams(COMPANY_ID, RUN_ID, LINE_ID),
)
expect(res.status).toBe(200)
const body = await res.json()
expect(body.data.salary_line_item_id).toBe(LINE_ID)
expect(body.data.amount).toBe(5500)
})
it('returns 404 SALARY_LINE_NOT_FOUND for a line from another run', async () => {
mockServiceClient.mockReturnValue(
makeFlexibleSupabase({
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
salary_runs: { data: { id: RUN_ID, status: 'draft' }, error: null },
salary_line_items: {
data: {
...SAMPLE_LINE,
salary_run_employee: { salary_run_id: '99999999-9999-4999-8999-999999999999' },
},
error: null,
},
idempotency_keys: { data: null, error: null },
}),
)
const res = await patchLine(
makeRequest(
`https://x.test/api/v1/companies/${COMPANY_ID}/salary-runs/${RUN_ID}/lines/${LINE_ID}`,
{ method: 'PATCH', body: JSON.stringify({ amount: 5500 }) },
),
lineParams(COMPANY_ID, RUN_ID, LINE_ID),
)
expect(res.status).toBe(404)
const body = await res.json()
expect(body.error.code).toBe('SALARY_LINE_NOT_FOUND')
})
it('rejects an empty patch with 400', async () => {
mockServiceClient.mockReturnValue(
makeFlexibleSupabase({
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
idempotency_keys: { data: null, error: null },
}),
)
const res = await patchLine(
makeRequest(
`https://x.test/api/v1/companies/${COMPANY_ID}/salary-runs/${RUN_ID}/lines/${LINE_ID}`,
{ method: 'PATCH', body: JSON.stringify({}) },
),
lineParams(COMPANY_ID, RUN_ID, LINE_ID),
)
expect(res.status).toBe(400)
const body = await res.json()
expect(body.error.code).toBe('VALIDATION_ERROR')
})
})
describe('DELETE /salary-runs/:id/lines/:lineId', () => {
it('deletes a line and returns 204', async () => {
mockServiceClient.mockReturnValue(
makeFlexibleSupabase({
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
salary_runs: { data: { id: RUN_ID, status: 'draft' }, error: null },
salary_line_items: [
{ data: { ...SAMPLE_LINE, salary_run_employee: { salary_run_id: RUN_ID } }, error: null },
{ data: null, error: null },
],
idempotency_keys: { data: null, error: null },
}),
)
const res = await deleteLine(
makeRequest(
`https://x.test/api/v1/companies/${COMPANY_ID}/salary-runs/${RUN_ID}/lines/${LINE_ID}`,
{ method: 'DELETE' },
),
lineParams(COMPANY_ID, RUN_ID, LINE_ID),
)
expect(res.status).toBe(204)
})
it('returns 400 SALARY_RUN_LINE_NOT_DRAFT once the run has advanced', async () => {
mockServiceClient.mockReturnValue(
makeFlexibleSupabase({
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
salary_runs: { data: { id: RUN_ID, status: 'booked' }, error: null },
idempotency_keys: { data: null, error: null },
}),
)
const res = await deleteLine(
makeRequest(
`https://x.test/api/v1/companies/${COMPANY_ID}/salary-runs/${RUN_ID}/lines/${LINE_ID}`,
{ method: 'DELETE' },
),
lineParams(COMPANY_ID, RUN_ID, LINE_ID),
)
expect(res.status).toBe(400)
const body = await res.json()
expect(body.error.code).toBe('SALARY_RUN_LINE_NOT_DRAFT')
})
})
@@ -0,0 +1,133 @@
/**
* ASVS V8.2.1: the payslip PDF endpoint returns full personnummer-bearing
* payroll data, so the binding between the API key's user and the
* `[companyId]` path segment must be enforced server-side BEFORE any payslip
* data is read. The check lives in withApiV1 (company_members lookup); these
* tests pin it to this concrete route so a wrapper regression or a future
* unwrapped rewrite of the route fails loudly here.
*
* Deliberate convention: the deny case is 404 (not 403) so an unauthorized
* caller cannot probe which company ids exist (see DECISIONS.md).
*/
import { beforeEach, describe, expect, it, vi } from 'vitest'
vi.mock('@/lib/auth/api-keys', async () => {
const actual = await vi.importActual<typeof import('@/lib/auth/api-keys')>('@/lib/auth/api-keys')
return {
...actual,
validateApiKey: vi.fn(),
createServiceClientNoCookies: vi.fn(),
}
})
vi.mock('@/lib/api/idempotency', async () => {
const actual = await vi.importActual<typeof import('@/lib/api/idempotency')>(
'@/lib/api/idempotency',
)
return {
...actual,
checkIdempotencyKey: vi.fn(),
storeIdempotencyResponse: vi.fn(),
}
})
// PDF rendering is irrelevant to the auth surface under test; keep the test
// hermetic (no @react-pdf font/layout machinery).
vi.mock('@react-pdf/renderer', () => ({ renderToBuffer: vi.fn() }))
vi.mock('@/lib/salary/pdf/payslip-template', () => ({ PayslipPDF: vi.fn() }))
vi.mock('@/lib/salary/payslips/build-payslip-data', () => ({
buildPayslipData: vi.fn(),
payslipFileName: vi.fn(() => 'payslip.pdf'),
}))
vi.mock('@/lib/company/context', () => ({ getCompanyDisplayName: vi.fn() }))
import { validateApiKey, createServiceClientNoCookies } from '@/lib/auth/api-keys'
import { GET } from '../route'
const mockValidate = validateApiKey as ReturnType<typeof vi.fn>
const mockServiceClient = createServiceClientNoCookies as ReturnType<typeof vi.fn>
const COMPANY_A = '11111111-1111-4111-8111-111111111111'
const RUN_ID = '22222222-2222-4222-8222-222222222222'
const EMPLOYEE_ID = '33333333-3333-4333-8333-333333333333'
function makeSupabaseStub(membership: { company_id: string; role: string } | null) {
const from = vi.fn().mockReturnValue({
select: vi.fn().mockReturnValue({
eq: vi.fn().mockReturnValue({
eq: vi.fn().mockReturnValue({
maybeSingle: vi.fn().mockResolvedValue({ data: membership, error: null }),
}),
}),
}),
})
return { from }
}
function makeRequest(companyId: string, init?: RequestInit) {
return new Request(
`https://x.test/api/v1/companies/${companyId}/salary-runs/${RUN_ID}/payslips/${EMPLOYEE_ID}/pdf`,
init,
)
}
function makeParams(companyId: string, id: string = RUN_ID, employeeId: string = EMPLOYEE_ID) {
return { params: Promise.resolve({ companyId, id, employeeId }) }
}
beforeEach(() => {
vi.clearAllMocks()
})
describe('GET /api/v1/companies/[companyId]/salary-runs/[id]/payslips/[employeeId]/pdf', () => {
it('returns 401 without a bearer token', async () => {
const res = await GET(makeRequest(COMPANY_A), makeParams(COMPANY_A))
expect(res.status).toBe(401)
const body = await res.json()
expect(body.error.code).toBe('UNAUTHORIZED')
})
it('returns 404 and reads no payslip data when the key user is not a member of the URL company', async () => {
mockValidate.mockResolvedValue({
userId: 'user-1',
apiKeyId: 'key-1',
scopes: ['payroll:read'],
mode: 'live',
})
const stub = makeSupabaseStub(null) // no membership in the URL company
mockServiceClient.mockReturnValue(stub)
const res = await GET(
makeRequest(COMPANY_A, { headers: { Authorization: 'Bearer gnubok_sk_x' } }),
makeParams(COMPANY_A),
)
expect(res.status).toBe(404)
const body = await res.json()
expect(body.error.code).toBe('NOT_FOUND')
// The deny fires in the wrapper: the handler's salary_runs /
// salary_run_employees / companies queries must never have run.
expect(stub.from.mock.calls.map((c) => c[0])).toEqual(['company_members'])
})
it('rejects non-UUID path ids with 400 before touching payroll tables', async () => {
mockValidate.mockResolvedValue({
userId: 'user-1',
apiKeyId: 'key-1',
scopes: ['payroll:read'],
mode: 'live',
})
const stub = makeSupabaseStub({ company_id: COMPANY_A, role: 'owner' })
mockServiceClient.mockReturnValue(stub)
const res = await GET(
makeRequest(COMPANY_A, { headers: { Authorization: 'Bearer gnubok_sk_x' } }),
makeParams(COMPANY_A, 'not-a-uuid'),
)
expect(res.status).toBe(400)
const body = await res.json()
expect(body.error.code).toBe('VALIDATION_ERROR')
expect(stub.from.mock.calls.map((c) => c[0])).toEqual(['company_members'])
})
})
@@ -0,0 +1,153 @@
/**
* GET /api/v1/companies/{companyId}/salary-runs/{id}/payslips/{employeeId}/pdf
*
* Render one employee's payslip as application/pdf. Byte-equivalent to the
* dashboard download: data assembly is shared via
* lib/salary/payslips/build-payslip-data.
*
* Per BFL: payslips are räkenskapsinformation linked to posted journal
* entries (7-year retention). Read-only: no Idempotency-Key, no dry-run.
*/
import { z } from 'zod'
import { renderToBuffer } from '@react-pdf/renderer'
import { PayslipPDF } from '@/lib/salary/pdf/payslip-template'
import { buildPayslipData, payslipFileName } from '@/lib/salary/payslips/build-payslip-data'
import { contentDisposition } from '@/lib/api/content-disposition'
import { getCompanyDisplayName } from '@/lib/company/context'
import { registerEndpoint } from '@/lib/api/v1/registry'
import { withApiV1 } from '@/lib/api/v1/with-api-v1'
import { v1ErrorResponse, v1ErrorResponseFromCode } from '@/lib/api/v1/errors'
registerEndpoint({
operation: 'salary-runs.payslip.pdf',
method: 'GET',
path: '/api/v1/companies/:companyId/salary-runs/:id/payslips/:employeeId/pdf',
summary: 'Download one employee\'s payslip as PDF.',
description:
'Returns the rendered payslip (lönespecifikation) as application/pdf, byte-equivalent to the dashboard download. Content-Disposition is attachment with a filename derived from the period and employee name.',
useWhen:
'You need the payslip document itself: archiving, forwarding to the employee outside the Accounted send flow, or attaching to an external HR system.',
doNotUseFor:
'The payslip DATA (amounts, line items): use GET /salary-runs/{id}/employees/{employeeId}, which is cheaper and structured. Emailing payslips to employees: the send flow is internal-only today.',
pitfalls: [
'The PDF renders whatever the run currently holds: for a draft run that has not been calculated, amounts are 0.',
'PDF rendering takes a few hundred milliseconds; cache on the client if requesting repeatedly.',
],
example: {
response: {
_note: 'Returns application/pdf binary stream.',
},
},
scope: 'payroll:read',
risk: 'low',
idempotent: true,
reversible: false,
dryRunSupported: false,
response: {
success: z.unknown(), // Marker: binary response, see contentType.
contentType: 'application/pdf',
},
})
export const GET = withApiV1<{ params: Promise<{ companyId: string; id: string; employeeId: string }> }>(
'salary-runs.payslip.pdf',
async (_request, ctx, params) => {
const { id, employeeId } = await params.params
const runParse = z.string().uuid().safeParse(id)
const empParse = z.string().uuid().safeParse(employeeId)
if (!runParse.success || !empParse.success) {
return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, {
requestId: ctx.requestId,
details: {
field: runParse.success ? 'employeeId' : 'id',
message: 'Path ids must be UUIDs.',
},
})
}
const { data: run, error: runErr } = await ctx.supabase
.from('salary_runs')
.select('*')
.eq('id', runParse.data)
.eq('company_id', ctx.companyId!)
.maybeSingle()
if (runErr) {
return v1ErrorResponse(runErr, ctx.log, { requestId: ctx.requestId })
}
if (!run) {
return v1ErrorResponseFromCode('SALARY_RUN_NOT_FOUND', ctx.log, { requestId: ctx.requestId })
}
const { data: sre, error: sreErr } = await ctx.supabase
.from('salary_run_employees')
.select(
'*, employee:employees(first_name, last_name, personnummer, personnummer_last4, employment_type, tax_table_number, tax_column, clearing_number, bank_account_number), line_items:salary_line_items(*)',
)
.eq('salary_run_id', runParse.data)
.eq('employee_id', empParse.data)
.eq('company_id', ctx.companyId!)
.maybeSingle()
if (sreErr) {
return v1ErrorResponse(sreErr, ctx.log, { requestId: ctx.requestId })
}
if (!sre) {
return v1ErrorResponseFromCode('NOT_FOUND', ctx.log, {
requestId: ctx.requestId,
details: { resource: 'salary_run_employee', employee_id: empParse.data },
})
}
const { data: company, error: companyErr } = await ctx.supabase
.from('companies')
.select('name, org_number')
.eq('id', ctx.companyId!)
.maybeSingle()
if (companyErr || !company) {
return v1ErrorResponseFromCode('NOT_FOUND', ctx.log, {
requestId: ctx.requestId,
details: { resource: 'company' },
})
}
const emp = sre.employee as {
first_name: string; last_name: string; personnummer: string; personnummer_last4: string;
employment_type: string; tax_table_number: number | null; tax_column: number;
clearing_number: string | null; bank_account_number: string | null;
}
let pdfBuffer: Buffer
let fileName: string
try {
const displayName = await getCompanyDisplayName(ctx.supabase, ctx.companyId!)
const data = buildPayslipData({
run,
sre,
employee: emp,
company: { name: displayName ?? company.name, org_number: company.org_number },
})
fileName = payslipFileName(run, emp)
// eslint-disable-next-line @typescript-eslint/no-explicit-any
pdfBuffer = await renderToBuffer(PayslipPDF({ data }) as any)
} catch (err) {
ctx.log.error('salary-runs.payslip.pdf: render failed', err as Error, {
salaryRunId: runParse.data,
companyId: ctx.companyId,
})
return v1ErrorResponseFromCode('INTERNAL_ERROR', ctx.log, { requestId: ctx.requestId })
}
const uint8Array = new Uint8Array(pdfBuffer)
return new Response(uint8Array, {
status: 200,
headers: {
'Content-Type': 'application/pdf',
// RFC 5987 dual form: employee names with non-Latin-1 characters
// would otherwise make undici reject the header value.
'Content-Disposition': contentDisposition('attachment', fileName),
'Content-Length': String(pdfBuffer.length),
'X-Request-Id': ctx.requestId,
},
})
},
)
@@ -0,0 +1,279 @@
/**
* Tests for GET /api/v1/companies/{companyId}/salary-runs/{id}/payslips/{employeeId}/pdf
* (payroll gap-closure 1.1).
*
* renderToBuffer is mocked (the invoice-pdf test pattern): these tests assert
* routing, auth, 404 paths, and the binary response headers, not PDF pixels.
*/
import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
beforeAll(() => {
if (process.env.NODE_ENV !== 'test') {
throw new Error(
`payslip pdf route tests require NODE_ENV=test (got ${process.env.NODE_ENV ?? 'undefined'})`,
)
}
process.env.NEXT_PUBLIC_SUPABASE_URL ||= 'http://localhost:54321'
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY ||= 'test-anon-key'
})
vi.mock('@/lib/auth/api-keys', async () => {
const actual = await vi.importActual<typeof import('@/lib/auth/api-keys')>('@/lib/auth/api-keys')
return {
...actual,
validateApiKey: vi.fn(),
createServiceClientNoCookies: vi.fn(),
}
})
vi.mock('@supabase/supabase-js', async () => {
const actual = await vi.importActual<typeof import('@supabase/supabase-js')>('@supabase/supabase-js')
return { ...actual, createClient: vi.fn().mockReturnValue({}) }
})
vi.mock('@react-pdf/renderer', () => ({
renderToBuffer: vi.fn().mockResolvedValue(Buffer.from('%PDF-1.7 test')),
// The payslip template imports these primitives at module scope.
Document: () => null,
Page: () => null,
Text: () => null,
View: () => null,
StyleSheet: { create: (s: unknown) => s },
Font: { register: () => undefined },
}))
import { validateApiKey, createServiceClientNoCookies } from '@/lib/auth/api-keys'
import { renderToBuffer } from '@react-pdf/renderer'
import { GET as getPayslipPdf } from '../[employeeId]/pdf/route'
const mockValidate = validateApiKey as ReturnType<typeof vi.fn>
const mockServiceClient = createServiceClientNoCookies as ReturnType<typeof vi.fn>
const mockRender = renderToBuffer as ReturnType<typeof vi.fn>
interface TableResp {
data?: unknown
error?: unknown
}
function makeFlexibleSupabase(byTable: Record<string, TableResp | TableResp[]>) {
const queues = new Map<string, TableResp[]>()
for (const [t, val] of Object.entries(byTable)) {
queues.set(t, Array.isArray(val) ? [...val] : [val])
}
const buildChain = (table: string): unknown => {
const handler: ProxyHandler<object> = {
get(_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[]) => buildChain(table)
},
}
return new Proxy({}, handler)
}
return { from: vi.fn((table: string) => buildChain(table)) }
}
const COMPANY_ID = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa'
const RUN_ID = 'cccccccc-cccc-4ccc-8ccc-cccccccccccc'
const EMPLOYEE_ID = 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb'
const USER_ID = 'user-1'
const SAMPLE_PERSONNUMMER = '190001010000'
const SAMPLE_RUN = {
id: RUN_ID,
period_year: 2026,
period_month: 5,
payment_date: '2026-05-25',
status: 'booked',
}
const SAMPLE_SRE = {
id: 'dddddddd-dddd-4ddd-8ddd-dddddddddddd',
gross_salary: 35000,
tax_withheld: 8200,
tax_withheld_override: null,
avgifter_rate: 0.3142,
avgifter_amount: 10997,
avgifter_basis_override: null,
avgifter_amount_override: null,
override_reason: null,
net_salary: 26800,
vacation_accrual: 4200,
vacation_accrual_avgifter: 1319.64,
ytd_gross: 70000,
ytd_tax: 16400,
ytd_net: 53600,
calculation_breakdown: null,
employee: {
first_name: 'Anna',
last_name: 'Andersson',
personnummer: SAMPLE_PERSONNUMMER,
personnummer_last4: '0000',
employment_type: 'employee',
tax_table_number: 33,
tax_column: 1,
clearing_number: '6000',
bank_account_number: '12345678',
},
line_items: [
{
description: 'Grundlön',
quantity: null,
unit_price: null,
amount: 35000,
sort_order: 0,
},
],
}
function makeRequest(url: string): Request {
return new Request(url, {
headers: { Authorization: 'Bearer test-fixture-not-a-real-key' },
})
}
function pdfParams(companyId: string, id: string, employeeId: string) {
return { params: Promise.resolve({ companyId, id, employeeId }) }
}
beforeEach(() => {
vi.clearAllMocks()
mockRender.mockResolvedValue(Buffer.from('%PDF-1.7 test'))
mockValidate.mockResolvedValue({
userId: USER_ID,
companyId: COMPANY_ID,
apiKeyId: 'ak_1',
apiKeyName: 'CI key',
scopes: ['payroll:read'],
mode: 'live',
})
})
describe('GET /api/v1/companies/:companyId/salary-runs/:id/payslips/:employeeId/pdf', () => {
it('returns the rendered PDF with attachment disposition (happy path)', async () => {
mockServiceClient.mockReturnValue(
makeFlexibleSupabase({
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
salary_runs: { data: SAMPLE_RUN, error: null },
salary_run_employees: { data: SAMPLE_SRE, error: null },
companies: { data: { name: 'Testbolaget AB', org_number: '5560000000' }, error: null },
company_settings: { data: { company_name: 'Testbolaget AB' }, error: null },
}),
)
const res = await getPayslipPdf(
makeRequest(
`https://x.test/api/v1/companies/${COMPANY_ID}/salary-runs/${RUN_ID}/payslips/${EMPLOYEE_ID}/pdf`,
),
pdfParams(COMPANY_ID, RUN_ID, EMPLOYEE_ID),
)
expect(res.status).toBe(200)
expect(res.headers.get('Content-Type')).toBe('application/pdf')
expect(res.headers.get('Content-Disposition')).toContain('attachment')
expect(res.headers.get('Content-Disposition')).toContain('lonespec_Andersson_Anna_2026-05.pdf')
expect(res.headers.get('X-Request-Id')).toBeTruthy()
const buf = Buffer.from(await res.arrayBuffer())
expect(buf.toString()).toContain('%PDF')
})
it('returns 404 SALARY_RUN_NOT_FOUND when the run is missing', async () => {
mockServiceClient.mockReturnValue(
makeFlexibleSupabase({
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
salary_runs: { data: null, error: null },
}),
)
const res = await getPayslipPdf(
makeRequest(
`https://x.test/api/v1/companies/${COMPANY_ID}/salary-runs/${RUN_ID}/payslips/${EMPLOYEE_ID}/pdf`,
),
pdfParams(COMPANY_ID, RUN_ID, EMPLOYEE_ID),
)
expect(res.status).toBe(404)
const body = await res.json()
expect(body.error.code).toBe('SALARY_RUN_NOT_FOUND')
})
it('returns 404 NOT_FOUND when the employee is not in the run', async () => {
mockServiceClient.mockReturnValue(
makeFlexibleSupabase({
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
salary_runs: { data: SAMPLE_RUN, error: null },
salary_run_employees: { data: null, error: null },
}),
)
const res = await getPayslipPdf(
makeRequest(
`https://x.test/api/v1/companies/${COMPANY_ID}/salary-runs/${RUN_ID}/payslips/${EMPLOYEE_ID}/pdf`,
),
pdfParams(COMPANY_ID, RUN_ID, EMPLOYEE_ID),
)
expect(res.status).toBe(404)
const body = await res.json()
expect(body.error.code).toBe('NOT_FOUND')
})
it('rejects keys without payroll:read scope', async () => {
mockValidate.mockResolvedValue({
userId: USER_ID,
companyId: COMPANY_ID,
apiKeyId: 'ak_1',
apiKeyName: 'wrong scope',
scopes: ['invoices:read'],
mode: 'live',
})
mockServiceClient.mockReturnValue(makeFlexibleSupabase({}))
const res = await getPayslipPdf(
makeRequest(
`https://x.test/api/v1/companies/${COMPANY_ID}/salary-runs/${RUN_ID}/payslips/${EMPLOYEE_ID}/pdf`,
),
pdfParams(COMPANY_ID, RUN_ID, EMPLOYEE_ID),
)
expect(res.status).toBe(403)
})
it('returns 401 without a bearer token', async () => {
mockValidate.mockResolvedValue(null)
mockServiceClient.mockReturnValue(makeFlexibleSupabase({}))
const res = await getPayslipPdf(
new Request(
`https://x.test/api/v1/companies/${COMPANY_ID}/salary-runs/${RUN_ID}/payslips/${EMPLOYEE_ID}/pdf`,
),
pdfParams(COMPANY_ID, RUN_ID, EMPLOYEE_ID),
)
expect(res.status).toBe(401)
})
it('maps a render failure to 500 INTERNAL_ERROR rather than crashing', async () => {
mockRender.mockRejectedValue(new Error('font missing'))
mockServiceClient.mockReturnValue(
makeFlexibleSupabase({
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
salary_runs: { data: SAMPLE_RUN, error: null },
salary_run_employees: { data: SAMPLE_SRE, error: null },
companies: { data: { name: 'Testbolaget AB', org_number: '5560000000' }, error: null },
company_settings: { data: { company_name: 'Testbolaget AB' }, error: null },
}),
)
const res = await getPayslipPdf(
makeRequest(
`https://x.test/api/v1/companies/${COMPANY_ID}/salary-runs/${RUN_ID}/payslips/${EMPLOYEE_ID}/pdf`,
),
pdfParams(COMPANY_ID, RUN_ID, EMPLOYEE_ID),
)
expect(res.status).toBe(500)
const body = await res.json()
expect(body.error.code).toBe('INTERNAL_ERROR')
})
})
@@ -65,7 +65,7 @@ registerEndpoint({
useWhen:
'You have a salary_run_id and need its current status: typically to decide which lifecycle verb to call next, or to display the run header in a UI.',
doNotUseFor:
'Per-employee breakdown (Phase 5 PR-1 does not expose the per-employee endpoint on v1; use the internal /api/salary/runs/{id} for that today). Salary journal report: use GET /reports/salary-journal in Phase 5 PR-3.',
'Per-employee breakdown: use GET /salary-runs/{id}/employees (list) or /salary-runs/{id}/employees/{employeeId} (payslip detail). Salary journal report: use GET /reports/salary-journal.',
pitfalls: [
'salary_entry_id / avgifter_entry_id / vacation_entry_id are null until POST /book has run. They reference the journal_entries table.',
'total_* fields are 0 until POST /calculate has run.',
@@ -0,0 +1,242 @@
/**
* Tests for POST /api/v1/companies/{companyId}/salary/vacation-year-close
* and GET /employees/{id}/vacation-balance (payroll gap-closure 3.4).
*
* The close service is mocked: these tests cover auth, validation, dry-run
* preview plumbing, and error mapping. The beredning/reconcile math is
* covered in lib/salary/__tests__/semesterberedning.test.ts.
*/
import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
beforeAll(() => {
if (process.env.NODE_ENV !== 'test') {
throw new Error(
`vacation-year-close route tests require NODE_ENV=test (got ${process.env.NODE_ENV ?? 'undefined'})`,
)
}
process.env.NEXT_PUBLIC_SUPABASE_URL ||= 'http://localhost:54321'
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY ||= 'test-anon-key'
})
vi.mock('@/lib/auth/api-keys', async () => {
const actual = await vi.importActual<typeof import('@/lib/auth/api-keys')>('@/lib/auth/api-keys')
return {
...actual,
validateApiKey: vi.fn(),
createServiceClientNoCookies: vi.fn(),
}
})
vi.mock('@supabase/supabase-js', async () => {
const actual = await vi.importActual<typeof import('@supabase/supabase-js')>('@supabase/supabase-js')
return { ...actual, createClient: vi.fn().mockReturnValue({}) }
})
const mockPreview = vi.fn()
const mockCommit = vi.fn()
vi.mock('@/lib/salary/semesterberedning', () => ({
previewVacationYearClose: (...a: unknown[]) => mockPreview(...a),
commitVacationYearClose: (...a: unknown[]) => mockCommit(...a),
}))
vi.mock('@/lib/salary/vacation-ledger', () => ({
getVacationYearBasis: vi.fn().mockResolvedValue('calendar'),
syncVacationLedgerForEmployees: vi.fn().mockResolvedValue({ ok: true }),
}))
import { validateApiKey, createServiceClientNoCookies } from '@/lib/auth/api-keys'
import { POST as closeYear } from '../route'
const mockValidate = validateApiKey as ReturnType<typeof vi.fn>
const mockServiceClient = createServiceClientNoCookies as ReturnType<typeof vi.fn>
function makeFlexibleSupabase(byTable: Record<string, { data?: unknown; error?: unknown }>) {
const buildChain = (table: string): unknown => {
const handler: ProxyHandler<object> = {
get(_target, prop) {
if (prop === 'then') {
return (resolve: (v: unknown) => void) =>
resolve(byTable[table] ?? { data: null, error: null })
}
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 USER_ID = 'user-1'
const SAMPLE_REPORT = {
vacation_year_start: '2025-01-01',
vacation_year_end: '2025-12-31',
next_year_start: '2026-01-01',
basis: 'calendar',
rows: [],
sek: {
computed_liability: 0,
computed_avgifter: 0,
booked_2920: 0,
booked_2940: 0,
drift_2920: 0,
drift_2940: 0,
adjustment_needed: false,
},
adjustment_date: '2025-12-31',
}
function makeRequest(url: string, body?: unknown): Request {
return new Request(url, {
method: 'POST',
headers: {
Authorization: 'Bearer test-fixture-not-a-real-key',
'Idempotency-Key': 'b1aaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa',
},
body: JSON.stringify(body ?? {}),
})
}
function companyParams(companyId: string) {
return { params: Promise.resolve({ companyId }) }
}
beforeEach(() => {
vi.clearAllMocks()
mockValidate.mockResolvedValue({
userId: USER_ID,
companyId: COMPANY_ID,
apiKeyId: 'ak_1',
apiKeyName: 'CI key',
scopes: ['payroll:read', 'payroll:write'],
mode: 'live',
})
mockServiceClient.mockReturnValue(
makeFlexibleSupabase({
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
idempotency_keys: { data: null, error: null },
}),
)
})
describe('POST /salary/vacation-year-close', () => {
it('commits the close and returns the closure + adjustment ids', async () => {
mockCommit.mockResolvedValue({
ok: true,
data: { closure_id: 'closure-1', adjustment_entry_id: 'je-1', report: SAMPLE_REPORT },
})
const res = await closeYear(
makeRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/salary/vacation-year-close`, {
vacation_year_start: '2025-01-01',
}),
companyParams(COMPANY_ID),
)
expect(res.status).toBe(200)
const body = await res.json()
expect(body.data.vacation_year_closure_id).toBe('closure-1')
expect(body.data.adjustment_entry_id).toBe('je-1')
expect(mockCommit).toHaveBeenCalledWith(
expect.anything(),
COMPANY_ID,
USER_ID,
'2025-01-01',
{ bookAdjustment: true },
)
})
it('defaults the year to the most recently ended one when omitted', async () => {
mockCommit.mockResolvedValue({
ok: true,
data: { closure_id: 'closure-1', adjustment_entry_id: null, report: SAMPLE_REPORT },
})
const res = await closeYear(
makeRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/salary/vacation-year-close`),
companyParams(COMPANY_ID),
)
expect(res.status).toBe(200)
const currentYear = new Date().getFullYear()
expect(mockCommit.mock.calls[0][3]).toBe(`${currentYear - 1}-01-01`)
})
it('dry_run returns the full preview report with zero commits', async () => {
mockPreview.mockResolvedValue({ ok: true, data: SAMPLE_REPORT })
const res = await closeYear(
makeRequest(
`https://x.test/api/v1/companies/${COMPANY_ID}/salary/vacation-year-close?dry_run=true`,
{ vacation_year_start: '2025-01-01' },
),
companyParams(COMPANY_ID),
)
expect(res.status).toBe(200)
expect(res.headers.get('X-Dry-Run')).toBe('true')
const body = await res.json()
expect(body.data.preview.report.vacation_year_start).toBe('2025-01-01')
expect(mockCommit).not.toHaveBeenCalled()
})
it('maps VACATION_YEAR_ALREADY_CLOSED to 409', async () => {
mockCommit.mockResolvedValue({ ok: false, code: 'VACATION_YEAR_ALREADY_CLOSED' })
const res = await closeYear(
makeRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/salary/vacation-year-close`, {
vacation_year_start: '2025-01-01',
}),
companyParams(COMPANY_ID),
)
expect(res.status).toBe(409)
const body = await res.json()
expect(body.error.code).toBe('VACATION_YEAR_ALREADY_CLOSED')
})
it('maps PERIOD_LOCKED without committing anything', async () => {
mockCommit.mockResolvedValue({ ok: false, code: 'PERIOD_LOCKED', details: { reason: 'closed' } })
const res = await closeYear(
makeRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/salary/vacation-year-close`, {
vacation_year_start: '2025-01-01',
}),
companyParams(COMPANY_ID),
)
expect(res.status).toBeGreaterThanOrEqual(400)
const body = await res.json()
expect(body.error.code).toBe('PERIOD_LOCKED')
})
it('rejects keys without payroll:write scope', async () => {
mockValidate.mockResolvedValue({
userId: USER_ID,
companyId: COMPANY_ID,
apiKeyId: 'ak_1',
apiKeyName: 'read-only',
scopes: ['payroll:read'],
mode: 'live',
})
const res = await closeYear(
makeRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/salary/vacation-year-close`),
companyParams(COMPANY_ID),
)
expect(res.status).toBe(403)
})
it('requires an Idempotency-Key', async () => {
const req = new Request(
`https://x.test/api/v1/companies/${COMPANY_ID}/salary/vacation-year-close`,
{
method: 'POST',
headers: { Authorization: 'Bearer test' },
body: JSON.stringify({}),
},
)
const res = await closeYear(req, companyParams(COMPANY_ID))
expect(res.status).toBe(400)
})
})
@@ -0,0 +1,146 @@
/**
* POST /api/v1/companies/{companyId}/salary/vacation-year-close
*
* Semesterberedning + semesterårsavslut in one verb (payroll gap-closure
* 3.4). dry_run returns the FULL review report (per-employee day
* transitions + the SEK reconcile) without writing; the live call closes
* the year's ledger rows, rolls balances into the next year (min-20 floor,
* 5-year expiry -> forced payout), and books one drift-adjustment
* verifikation on 7290/2920 + 7519/2940 when |drift| > 1 kr.
*/
import { z } from 'zod'
import { ok } from '@/lib/api/v1/response'
import { dryRunPreview } from '@/lib/api/v1/dry-run'
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 {
commitVacationYearClose,
previewVacationYearClose,
} from '@/lib/salary/semesterberedning'
import { getVacationYearBasis } from '@/lib/salary/vacation-ledger'
import { getClosableYearStart } from '@/lib/salary/vacation-year'
const isoDate = z.string().regex(/^\d{4}-\d{2}-\d{2}$/, 'Expected YYYY-MM-DD date format')
const CloseBody = z.object({
/** Defaults to the most recently ENDED vacation year per the company's
* basis setting. */
vacation_year_start: isoDate.optional(),
/** Book the 2920/2940 drift adjustment (default true). False = roll the
* days but leave the SEK reconcile for a manual verifikat. */
book_adjustment: z.boolean().default(true),
})
const CloseResponse = z.object({
vacation_year_closure_id: z.string().uuid(),
adjustment_entry_id: z.string().uuid().nullable(),
report: z.unknown(),
})
registerEndpoint({
operation: 'salary.vacation-year-close',
method: 'POST',
path: '/api/v1/companies/:companyId/salary/vacation-year-close',
summary: 'Close a vacation year (semesterberedning + arsavslut).',
description:
'Rolls every active employee\'s vacation balances into the next year (only days above the 20-day must-take floor are saved; saved days older than 5 years become forced payouts) and reconciles the day-valued semesterlöneskuld against the booked 2920/2940, posting one adjustment verifikation when drift exceeds 1 kr. The frozen report is stored with the closure (BFL 7 kap).',
useWhen:
'Once per year after the vacation year ends (Jan for calendar basis, Apr for statutory). ALWAYS dry-run first and review the report: the close is not reversible via API.',
doNotUseFor:
'Mid-year balance corrections (fix the source: absence days, opening balances, or run corrections). Paying out expired days (create a semesterersattning line in the next salary run: the close only flags them).',
pitfalls: [
'dry_run=true returns the full review report with zero writes: treat it as mandatory before the live call.',
'409 VACATION_YEAR_ALREADY_CLOSED on replay: the closure row is the idempotency anchor.',
'423-style PERIOD_LOCKED when the adjustment date falls in a locked period: unlock or close without adjustment (book_adjustment=false) and post manually.',
'Untaken days at or below the 20-day floor are flagged in the report, NOT auto-saved (Semesterlagen 18 §).',
],
example: {
request: { book_adjustment: true },
response: {
data: {
vacation_year_closure_id: 'vyc_a1b2…',
adjustment_entry_id: 'je_c3d4…',
report: { vacation_year_start: '2025-01-01', rows: [], sek: { drift_2920: 8690.84 } },
},
meta: { request_id: 'req_…', api_version: '2026-05-12' },
},
},
scope: 'payroll:write',
risk: 'high',
idempotent: true,
reversible: false,
dryRunSupported: true,
request: { body: CloseBody },
response: { success: dataEnvelope(CloseResponse) },
})
export const POST = withApiV1<{ params: Promise<{ companyId: string }> }>(
'salary.vacation-year-close',
async (request, ctx) => {
let rawBody: unknown = {}
try {
const text = await request.text()
rawBody = text ? JSON.parse(text) : {}
} catch {
return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, {
requestId: ctx.requestId,
details: { field: 'body', message: 'Body is not valid JSON.' },
})
}
const parsed = CloseBody.safeParse(rawBody)
if (!parsed.success) {
return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, {
requestId: ctx.requestId,
details: {
issues: parsed.error.issues.map((i) => ({
field: i.path.join('.'),
message: i.message,
})),
},
})
}
let yearStart = parsed.data.vacation_year_start
if (!yearStart) {
const basis = await getVacationYearBasis(ctx.supabase, ctx.companyId!)
yearStart = getClosableYearStart(new Date().toISOString().slice(0, 10), basis)
}
if (ctx.dryRun) {
const preview = await previewVacationYearClose(ctx.supabase, ctx.companyId!, yearStart)
if (!preview.ok) {
return v1ErrorResponseFromCode(preview.code, ctx.log, {
requestId: ctx.requestId,
details: preview.details,
})
}
return dryRunPreview(
{ vacation_year_closure_id: null, adjustment_entry_id: null, report: preview.data },
{ requestId: ctx.requestId, log: ctx.log },
)
}
const result = await commitVacationYearClose(ctx.supabase, ctx.companyId!, ctx.userId, yearStart, {
bookAdjustment: parsed.data.book_adjustment,
})
if (!result.ok) {
return v1ErrorResponseFromCode(result.code, ctx.log, {
requestId: ctx.requestId,
details: result.details,
})
}
return ok(
{
vacation_year_closure_id: result.data.closure_id,
adjustment_entry_id: result.data.adjustment_entry_id,
report: result.data.report,
},
{ requestId: ctx.requestId },
)
},
{ requireIdempotencyKey: true },
)
+9 -4
View File
@@ -4,9 +4,11 @@ import { Hedvig_Letters_Serif } from "next/font/google";
import Script from "next/script";
import { NextIntlClientProvider } from "next-intl";
import { getLocale, getMessages } from "next-intl/server";
import { SpeedInsights } from "@vercel/speed-insights/next";
import { Toaster } from "@/components/ui/toaster";
import { DeployReloadPrompt } from "@/components/system/DeployReloadPrompt";
import { ThemeProvider } from "@/components/theme-provider";
import { SWRProvider } from "@/components/providers/SWRProvider";
import { RecaptLoader } from "@/components/RecaptLoader";
import { RecaptHideWidget } from "@/components/RecaptHideWidget";
import { ensureInitialized } from "@/lib/init";
@@ -85,12 +87,15 @@ export default async function RootLayout({
enableSystem
disableTransitionOnChange
>
{children}
<Toaster />
<DeployReloadPrompt />
<RecaptHideWidget />
<SWRProvider>
{children}
<Toaster />
<DeployReloadPrompt />
<RecaptHideWidget />
</SWRProvider>
</ThemeProvider>
</NextIntlClientProvider>
<SpeedInsights />
<Script src="/sw-register.js" strategy="afterInteractive" />
</body>
</html>
+12 -3
View File
@@ -11,8 +11,7 @@ import {
Check,
Brain,
} from 'lucide-react'
import ReactMarkdown from 'react-markdown'
import remarkGfm from 'remark-gfm'
import dynamic from 'next/dynamic'
import { Button } from '@/components/ui/button'
import { cn } from '@/lib/utils'
import { useCapability } from '@/contexts/CompanyContext'
@@ -20,6 +19,16 @@ import { CAPABILITY } from '@/lib/entitlements/keys'
import { UpgradeNote } from '@/components/billing/UpgradeNote'
import ApprovalCard from './ApprovalCard'
// Markdown parser loads on the first assistant message instead of with the
// chat surface itself; react-markdown + remark-gfm pull in the whole
// unified/remark tree. The chunk starts fetching as soon as a reply begins
// rendering, well before a human can read it, so a null fallback is invisible
// in practice.
const MarkdownMessage = dynamic(() => import('./MarkdownMessage'), {
ssr: false,
loading: () => null,
})
// Reusable chat surface: used both inside the right-hand AgentSheet and on
// the full-page /chat route. Owns:
// * Message state (rendered list)
@@ -684,7 +693,7 @@ function MessageBubble({
message.text || (streamingTail ? <Cursor /> : '')
) : message.text ? (
<div className="prose prose-sm max-w-none text-foreground [&>*:first-child]:mt-0 [&>*:last-child]:mb-0 prose-headings:font-display prose-headings:font-normal prose-headings:tracking-tight prose-h2:text-base prose-h2:mt-3 prose-h2:mb-2 prose-h3:text-sm prose-h3:mt-3 prose-h3:mb-1 prose-p:my-2 prose-p:leading-6 prose-strong:font-semibold prose-strong:text-foreground prose-ul:my-2 prose-li:my-0.5 prose-blockquote:border-l-2 prose-blockquote:border-foreground/30 prose-blockquote:not-italic prose-blockquote:text-muted-foreground prose-blockquote:pl-3 prose-blockquote:my-2 prose-code:bg-secondary prose-code:rounded prose-code:px-1 prose-code:py-0.5 prose-code:text-xs prose-code:before:content-none prose-code:after:content-none prose-a:text-foreground prose-a:underline prose-a:underline-offset-2 prose-pre:bg-secondary prose-pre:text-foreground prose-pre:border prose-pre:border-border prose-pre:rounded-lg prose-pre:my-2 prose-pre:p-3 prose-pre:text-xs prose-pre:leading-relaxed prose-pre:overflow-x-auto [&_pre_code]:bg-transparent [&_pre_code]:p-0 [&_pre_code]:text-foreground [&_pre_code]:text-xs prose-table:my-2 prose-table:text-xs prose-table:border-collapse [&_table]:w-full [&_th]:border-b [&_th]:border-border [&_th]:py-1.5 [&_th]:px-2 [&_th]:text-left [&_th]:font-medium [&_th]:text-muted-foreground [&_th]:uppercase [&_th]:tracking-wider [&_th]:text-[10px] [&_td]:border-b [&_td]:border-border [&_td]:py-1.5 [&_td]:px-2 [&_td]:align-top [&_tbody_tr:last-child_td]:border-b-0">
<ReactMarkdown remarkPlugins={[remarkGfm]}>{message.text}</ReactMarkdown>
<MarkdownMessage text={message.text} />
</div>
) : streamingTail ? (
<Cursor />
+14
View File
@@ -0,0 +1,14 @@
'use client'
import ReactMarkdown from 'react-markdown'
import remarkGfm from 'remark-gfm'
/**
* Isolated so AgentChat can load the markdown parser (react-markdown +
* remark-gfm and their unified/remark dependency tree) via next/dynamic:
* the chunk is fetched when the first assistant message renders instead of
* being parsed eagerly whenever the chat surface mounts.
*/
export default function MarkdownMessage({ text }: { text: string }) {
return <ReactMarkdown remarkPlugins={[remarkGfm]}>{text}</ReactMarkdown>
}
+5 -2
View File
@@ -75,9 +75,12 @@ function matches(entry: Entry, q: string): boolean {
return q.split(/\s+/).filter(Boolean).every(t => hay.includes(t))
}
export default function CommandPalette() {
export default function CommandPalette({ initialOpen = false }: { initialOpen?: boolean } = {}) {
const router = useRouter()
const [open, setOpen] = useState(false)
// initialOpen: LazyCommandPalette mounts this component on the first ⌘K,
// so the palette must come up already open rather than waiting for a
// second keypress.
const [open, setOpen] = useState(initialOpen)
const [query, setQuery] = useState('')
const [activeIndex, setActiveIndex] = useState(0)
const inputRef = useRef<HTMLInputElement>(null)
+31
View File
@@ -0,0 +1,31 @@
'use client'
import { useEffect, useState } from 'react'
import dynamic from 'next/dynamic'
const CommandPalette = dynamic(() => import('./CommandPalette'), { ssr: false })
/**
* Defers the command palette bundle (Radix dialog + its icon set) until the
* first K / Ctrl+K press. The palette is mounted on every dashboard page but
* used only by keyboard, so eagerly parsing it on initial load was pure cost.
* Once mounted, the palette's own global key listener takes over toggling;
* this wrapper's listener only fires the first time.
*/
export default function LazyCommandPalette() {
const [mounted, setMounted] = useState(false)
useEffect(() => {
if (mounted) return
function onKey(e: KeyboardEvent) {
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'k') {
e.preventDefault()
setMounted(true)
}
}
document.addEventListener('keydown', onKey)
return () => document.removeEventListener('keydown', onKey)
}, [mounted])
return mounted ? <CommandPalette initialOpen /> : null
}
+46 -57
View File
@@ -61,6 +61,7 @@ import AgentAvatar from '@/components/agent/AgentAvatar'
import { useAgentSheet } from '@/components/agent/AgentSheetProvider'
import { useCompany } from '@/contexts/CompanyContext'
import { useRealtimeSupabase } from '@/lib/hooks/use-realtime-supabase'
import { useWorklistBadges } from '@/lib/hooks/use-worklist-badges'
import { EXTENSION_REQUIRED_CAPABILITY, type CapabilityKey } from '@/lib/entitlements/keys'
import type { EntityType } from '@/types'
@@ -83,8 +84,6 @@ interface DashboardNavProps {
// switched on. Drives visibility of the Kostnadsställen & projekt row:
// same mechanism as paysSalaries: fetched by the dashboard layout.
dimensionsEnabled?: boolean
uncategorizedTransactionCount?: number
pendingOperationsCount?: number
isSandbox?: boolean
extensionNavItems?: ExtensionNavItem[]
// Signed-in user's full name + email: drives the bottom-left account
@@ -247,7 +246,7 @@ function accountInitial(name: string | null, email: string | null): string {
return '?'
}
export default function DashboardNav({ companyName: _companyName, entityType, paysSalaries = false, dimensionsEnabled = false, uncategorizedTransactionCount = 0, pendingOperationsCount = 0, isSandbox = false, extensionNavItems = [], userName = null, userEmail = null }: DashboardNavProps) {
export default function DashboardNav({ companyName: _companyName, entityType, paysSalaries = false, dimensionsEnabled = false, isSandbox = false, extensionNavItems = [], userName = null, userEmail = null }: DashboardNavProps) {
const pathname = usePathname()
const router = useRouter()
const supabase = useRealtimeSupabase()
@@ -261,17 +260,25 @@ export default function DashboardNav({ companyName: _companyName, entityType, pa
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false)
const [isClosing, setIsClosing] = useState(false)
const closeTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const [liveUncategorizedTransactionCount, setLiveUncategorizedTransactionCount] = useState(
uncategorizedTransactionCount,
)
const refreshInFlightRef = useRef(false)
const refreshQueuedRef = useRef(false)
// Badge counts load client-side after mount (and revalidate via the
// realtime subscriptions below). They used to arrive as server props, which
// put two head-count queries on the critical path of every dashboard
// navigation for numbers nobody needs before first paint.
const {
uncategorized: uncategorizedCount,
pendingOperations: pendingOpsCount,
refresh: refreshBadges,
} = useWorklistBadges(company?.id)
// Trial countdown for the sidebar touchpoint. Computed in an effect (not
// during render) so server and client markup agree at hydration; an hourly
// tick keeps a long-lived tab from showing yesterday's count.
// tick keeps a long-lived tab from showing yesterday's count. The sync
// setState is that hydration strategy, not derived-state-in-effect (the
// lint only started analyzing this component once the badge-refresh loop
// that made the compiler bail was removed).
const [trialDaysLeft, setTrialDaysLeft] = useState<number | null>(null)
useEffect(() => {
if (!trialEndsAt) {
// eslint-disable-next-line react-hooks/set-state-in-effect
setTrialDaysLeft(null)
return
}
@@ -364,43 +371,17 @@ export default function DashboardNav({ companyName: _companyName, entityType, pa
useEffect(() => {
if (!company?.id) return
let cancelled = false
const refreshUncategorizedCount = async () => {
if (!company?.id || cancelled) return
if (refreshInFlightRef.current) {
refreshQueuedRef.current = true
return
}
refreshInFlightRef.current = true
try {
do {
refreshQueuedRef.current = false
const { count, error } = await supabase
.from('transactions')
.select('id', { count: 'exact', head: true })
.eq('company_id', company.id)
.is('is_business', null)
.eq('is_ignored', false)
if (error) {
console.error('Failed to refresh uncategorized transaction count:', error)
break
}
setLiveUncategorizedTransactionCount(count ?? 0)
} while (refreshQueuedRef.current && !cancelled)
} finally {
refreshInFlightRef.current = false
refreshQueuedRef.current = false
}
// Realtime keeps the badges live; a trailing debounce collapses event
// bursts (bulk booking / bulk approvals emit one event per row) into a
// single SWR revalidation instead of a request stampede.
let debounce: ReturnType<typeof setTimeout> | null = null
const queueRefresh = () => {
if (debounce) clearTimeout(debounce)
debounce = setTimeout(() => void refreshBadges(), 400)
}
void refreshUncategorizedCount()
const channel = supabase
.channel(`dashboard-nav:transactions:${company.id}`)
.channel(`dashboard-nav:badges:${company.id}`)
.on(
'postgres_changes',
{
@@ -409,17 +390,25 @@ export default function DashboardNav({ companyName: _companyName, entityType, pa
table: 'transactions',
filter: `company_id=eq.${company.id}`,
},
() => {
void refreshUncategorizedCount()
queueRefresh,
)
.on(
'postgres_changes',
{
event: '*',
schema: 'public',
table: 'pending_operations',
filter: `company_id=eq.${company.id}`,
},
queueRefresh,
)
.subscribe()
return () => {
cancelled = true
if (debounce) clearTimeout(debounce)
void supabase.removeChannel(channel)
}
}, [company?.id, supabase])
}, [company?.id, supabase, refreshBadges])
const hiddenNavHrefs = new Set(getBranding().hiddenNavHrefs)
@@ -607,10 +596,10 @@ export default function DashboardNav({ companyName: _companyName, entityType, pa
const active = isActive(item.href)
const enabled = isItemEnabled(item.href) && !item.comingSoon
const badge =
item.href === '/transactions' && liveUncategorizedTransactionCount > 0
? liveUncategorizedTransactionCount
: item.href === '/pending' && pendingOperationsCount > 0
? pendingOperationsCount
item.href === '/transactions' && uncategorizedCount > 0
? uncategorizedCount
: item.href === '/pending' && pendingOpsCount > 0
? pendingOpsCount
: null
const decorBadge = renderBadge(item, 'sidebar')
const content = (
@@ -820,8 +809,8 @@ export default function DashboardNav({ companyName: _companyName, entityType, pa
{mobileNavItems.map((item) => {
const active = isActive(item.href)
const enabled = isItemEnabled(item.href)
const badge = item.href === '/transactions' && liveUncategorizedTransactionCount > 0
? liveUncategorizedTransactionCount
const badge = item.href === '/transactions' && uncategorizedCount > 0
? uncategorizedCount
: null
const content = (
@@ -977,10 +966,10 @@ export default function DashboardNav({ companyName: _companyName, entityType, pa
const Icon = item.icon
const active = isActive(item.href)
const enabled = isItemEnabled(item.href) && !item.comingSoon
const badge = item.href === '/transactions' && liveUncategorizedTransactionCount > 0
? liveUncategorizedTransactionCount
: item.href === '/pending' && pendingOperationsCount > 0
? pendingOperationsCount
const badge = item.href === '/transactions' && uncategorizedCount > 0
? uncategorizedCount
: item.href === '/pending' && pendingOpsCount > 0
? pendingOpsCount
: null
const decorBadge = renderBadge(item, 'mobile')
const content = (
+4 -1
View File
@@ -118,7 +118,7 @@ export default function PaymentBookingDialog({
// Fetch company settings
const { data: settings, error: settingsError } = await supabase
.from('company_settings')
.select('accounting_method, entity_type')
.select('accounting_method, entity_type, ore_rounding')
.eq('company_id', company.id)
.maybeSingle()
@@ -153,9 +153,12 @@ export default function PaymentBookingDialog({
vat_treatment: invoice.vat_treatment,
items: invoice.items,
default_dimensions: invoice.default_dimensions,
ore_rounding: invoice.ore_rounding,
},
accountingMethod,
entityType,
companyOreRounding:
typeof settings?.ore_rounding === 'boolean' ? settings.ore_rounding : undefined,
})
setLines(proposed)
+29
View File
@@ -0,0 +1,29 @@
'use client'
import { SWRConfig } from 'swr'
import type { ReactNode } from 'react'
/**
* Global SWR defaults: one shared client-side cache so repeated mounts of the
* same data (company settings, nav badges, reference data) dedupe in-flight
* requests and render instantly from cache on back-navigation, revalidating
* in the background instead of re-showing skeletons on every visit.
*
* String keys are fetched as JSON from our own API routes; hooks that read
* Supabase directly pass an array key with their own fetcher.
*/
export function SWRProvider({ children }: { children: ReactNode }) {
return (
<SWRConfig
value={{
fetcher: async (key: string) => {
const res = await fetch(key)
if (!res.ok) throw new Error(`HTTP ${res.status} for ${key}`)
return res.json()
},
}}
>
{children}
</SWRConfig>
)
}
+441 -201
View File
@@ -4,6 +4,7 @@ import { useCallback, useEffect, useRef, useState } from 'react'
import {
AlertCircle,
CheckCircle2,
Circle,
Download,
ExternalLink,
Link2,
@@ -19,9 +20,11 @@ import { useTranslations } from 'next-intl'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { InfoTooltip } from '@/components/ui/info-tooltip'
import { useToast } from '@/components/ui/use-toast'
import { UpgradeNote } from '@/components/billing/UpgradeNote'
import { useCapability } from '@/contexts/CompanyContext'
import { CAPABILITY } from '@/lib/entitlements/keys'
import type { AgiSubmissionState } from '@/lib/salary/agi-submission-state'
interface AGIPanelProps {
salaryRunId: string
@@ -32,6 +35,13 @@ interface AGIPanelProps {
/** Already-cached run-level signals for showing what step we're at. */
agiGeneratedAt?: string | null
agiSubmittedAt?: string | null
/**
* Per-period submission record, owned by the parent (via useAgiSubmission)
* so the progress rail and hero can render the same state machine.
*/
submission: AgiSubmissionState | null
/** Refetch the submission record after a state-changing action. */
onRefreshSubmission: () => void
/** When true, write actions are hidden. */
readOnly?: boolean
/** Called after a state-changing action so parent can refresh. */
@@ -60,28 +70,6 @@ interface KontrollFinding {
identifierare?: string
}
/**
* Local submission state mirrored in extension_data under
* `agi_submission_{period}`. Matches the `status` enum the index.ts handlers
* write back. Strict superset of what the UI actually keys off.
*/
interface SubmissionState {
status?:
| 'underlag_submitted' // POST /underlag returned an inlamningId
| 'underlag_rejected' // kontrollresultat surfaced stoppande fel
| 'awaiting_signing' // skapaGranskningsunderlag returned a link
| 'signed' // kvittenser shows uuidKvittens for the period
signeringslank?: string
kvittensnummer?: string
signeradAv?: string
signeradTid?: string
inlamningId?: number
tillstand?: string
meddelande?: string
/** ISO timestamp the submission record was last written by the extension. */
updatedAt?: string
}
/** Subset of SkatteverketAGIKontrollresultat we use in the panel. */
interface Kontrollresultat {
status: 'PROCESSING' | 'DONE_SUCCESS' | 'DONE_FAILED' | 'DONE_REJECTED'
@@ -106,6 +94,36 @@ interface Kontrollresultat {
const ENABLED_KEY = 'EXTENSION_DISABLED'
/** One-click chain steps, in execution order. */
const CHAIN_STEPS = ['generate', 'submit', 'kontroll', 'link'] as const
type ChainStep = (typeof CHAIN_STEPS)[number]
interface ChainProgress {
current: ChainStep
failed: boolean
done: boolean
}
/**
* Sentinel for chain aborts where the failing step already surfaced its
* error via setError/setKontroller: the catch block must not overwrite it.
*/
class ChainFailed extends Error {}
/**
* Extract a human message from either the canonical { error: { message } }
* envelope (internal routes) or a plain { error: string } (extension routes).
*/
function errText(data: unknown): string | null {
if (!data || typeof data !== 'object') return null
const err = (data as { error?: unknown }).error
if (typeof err === 'string') return err
if (err && typeof err === 'object' && typeof (err as { message?: unknown }).message === 'string') {
return (err as { message: string }).message
}
return null
}
export function AGIPanel(props: AGIPanelProps) {
const {
salaryRunId,
@@ -113,21 +131,47 @@ export function AGIPanel(props: AGIPanelProps) {
period,
agiGeneratedAt,
agiSubmittedAt,
submission,
onRefreshSubmission,
readOnly,
onChange,
} = props
const t = useTranslations('salary_agi')
const { toast } = useToast()
const hasSkatteverket = useCapability(CAPABILITY.skatteverket)
const [extensionDisabled, setExtensionDisabled] = useState(false)
const [status, setStatus] = useState<ConnectionStatus | null>(null)
const [submission, setSubmission] = useState<SubmissionState | null>(null)
const [kontroller, setKontroller] = useState<KontrollFinding[]>([])
const [loading, setLoading] = useState(true)
const [actionLoading, setActionLoading] = useState<string | null>(null)
const [error, setError] = useState<string | null>(null)
const [success, setSuccess] = useState<string | null>(null)
const [chain, setChain] = useState<ChainProgress | null>(null)
const [showAdvanced, setShowAdvanced] = useState(false)
// "2026-06" for user-facing copy; the period prop is compact YYYYMM.
const prettyPeriod = `${period.slice(0, 4)}-${period.slice(4)}`
// ── Derived filing state (needed by hooks, so derived before any return) ──
const subState = submission?.status
const awaitingSigning = subState === 'awaiting_signing'
const underlagSubmitted = subState === 'underlag_submitted'
const underlagRejected = subState === 'underlag_rejected'
const isSigned = subState === 'signed' || !!agiSubmittedAt
// The submission state is keyed by PERIOD; AGI generation is keyed by RUN.
// If the run's AGI was (re)generated AFTER this signing draft was created,
// the locked underlag at Skatteverket reflects superseded figures and must
// not be signed: surface a warning and steer the user to unlock + resubmit
// rather than presenting it as ready to sign (avoids filing stale amounts).
const draftUpdatedAt = submission?.updatedAt ? new Date(submission.updatedAt) : null
const draftIsStale =
awaitingSigning &&
!!agiGeneratedAt &&
!!draftUpdatedAt &&
!Number.isNaN(draftUpdatedAt.getTime()) &&
new Date(agiGeneratedAt).getTime() > draftUpdatedAt.getTime()
const fetchStatus = useCallback(async () => {
setLoading(true)
@@ -165,24 +209,9 @@ export function AGIPanel(props: AGIPanelProps) {
}
}, [])
const fetchSubmission = useCallback(async () => {
try {
const res = await fetch(
`/api/extensions/ext/skatteverket/agi/status?period=${period}`,
)
if (res.ok) {
const json = await res.json()
setSubmission(json.data ?? null)
}
} catch {
// ignore
}
}, [period])
useEffect(() => {
fetchStatus()
fetchSubmission()
}, [fetchStatus, fetchSubmission])
}, [fetchStatus])
// Handle of the OAuth popup opened by handleConnect: used to verify the
// sender identity of incoming postMessages.
@@ -231,6 +260,20 @@ export function AGIPanel(props: AGIPanelProps) {
)
}, [agiGeneratedAt])
// Loud success when the filing completes: a poll (live timers, tab refocus,
// or the parent's refresh) flips isSigned while the user is on the page.
// The ref starts null so an already-signed run doesn't toast on mount.
const prevSignedRef = useRef<boolean | null>(null)
useEffect(() => {
if (prevSignedRef.current === false && isSigned) {
toast({
title: t('toast_signed_title'),
description: t('toast_signed_description', { period: prettyPeriod }),
})
}
prevSignedRef.current = isSigned
}, [isSigned, toast, t, prettyPeriod])
// Background kvittens-polling timers (see scheduleKvittensPolls below).
// Held in a ref so the unmount-cleanup effect can cancel them if the
// user leaves the page mid-signing.
@@ -263,7 +306,7 @@ export function AGIPanel(props: AGIPanelProps) {
if (!res.ok) return false
const json = await res.json()
const signed = !!json.data?.kvittenser?.[0]?.uuidKvittens
await fetchSubmission()
onRefreshSubmission()
if (signed) {
// Replace any lingering "Granskningsunderlag klart…" / stale error
// with an unambiguous confirmation. Mirrors handleCheckSubmitted.
@@ -275,7 +318,7 @@ export function AGIPanel(props: AGIPanelProps) {
} catch {
return false
}
}, [arbetsgivare, period, fetchSubmission, onChange, t])
}, [arbetsgivare, period, onRefreshSubmission, onChange, t])
/**
* Background-poll /agi/kvittenser at 30s, 2 min, and 5 min after the user
@@ -348,13 +391,13 @@ export function AGIPanel(props: AGIPanelProps) {
}
setSuccess(t('disconnect_success'))
await fetchStatus()
await fetchSubmission()
onRefreshSubmission()
} catch (e) {
setError(e instanceof Error ? e.message : t('disconnect_failed'))
} finally {
setActionLoading(null)
}
}, [fetchStatus, fetchSubmission, t])
}, [fetchStatus, onRefreshSubmission, t])
const handleConnect = () => {
// Open the BankID OAuth flow in a centered popup. The callback page
@@ -418,9 +461,27 @@ export function AGIPanel(props: AGIPanelProps) {
}
/**
* Step 1: POST the stored XML underlag, then poll kontrollresultat until
* status flips out of PROCESSING. Skatteverket's spec says polling is
* usually instantaneous, but we cap at 8 attempts × 1s to be safe.
* The XML must exist in agi_declarations before anything can be submitted.
* The internal xml route both generates and persists it (and stamps
* agi_generated_at); the response body, the downloadable file itself, is
* discarded here: "Ladda ner AGI-fil" remains the way to get a copy.
*/
async function ensureAgiGenerated(): Promise<boolean> {
if (agiGeneratedAt) return true
const res = await fetch(`/api/salary/runs/${salaryRunId}/agi/xml`)
if (!res.ok) {
const data = await res.json().catch(() => ({}))
setError(errText(data) || t('xml_generate_failed'))
return false
}
onChange?.() // parent refetches the run so agiGeneratedAt flips
return true
}
/**
* POST the stored XML underlag, then poll kontrollresultat until status
* flips out of PROCESSING. Skatteverket's spec says polling is usually
* instantaneous, but we cap at 8 attempts × 1s to be safe.
*
* On DONE_SUCCESS the underlag is auto-persisted by SKV: no /spara call.
* Calling /spara when there are no errors returns 400 felkod 20
@@ -431,10 +492,154 @@ export function AGIPanel(props: AGIPanelProps) {
*
* On DONE_REJECTED we surface the validation findings; the user can still
* choose to save (so they can fix it in Mina Sidor) or abort.
*
* Failures surface via setError/setKontroller and return false. Shared by
* the one-click chain and the advanced "Skicka in underlag" button;
* `onKontrollPhase` lets the chain advance its stepper when polling starts.
*/
async function runSubmitUnderlag(onKontrollPhase?: () => void): Promise<boolean> {
setKontroller([])
const submitRes = await fetch('/api/extensions/ext/skatteverket/agi/submit', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ salaryRunId }),
})
const submitJson = await submitRes.json()
if (!submitRes.ok || submitJson.error) {
setError(submitJson.error || t('submit_failed_status', { status: submitRes.status }))
return false
}
const inlamningId = submitJson.data?.inlamningId as number | undefined
if (!inlamningId) {
setError(t('submit_missing_id'))
return false
}
// Poll kontrollresultat until DONE_*
onKontrollPhase?.()
let kr: Kontrollresultat | undefined
for (let attempt = 0; attempt < 8; attempt++) {
const krRes = await fetch(
`/api/extensions/ext/skatteverket/agi/kontrollresultat?inlamningId=${inlamningId}`,
)
const krJson = await krRes.json()
if (!krRes.ok || krJson.error) {
setError(krJson.error || t('kontrollresultat_failed_status', { status: krRes.status }))
return false
}
kr = krJson.data as Kontrollresultat
if (kr.status !== 'PROCESSING') break
await new Promise(r => setTimeout(r, 1000))
}
if (!kr || kr.status === 'PROCESSING') {
setError(t('still_processing'))
return false
}
const findings = extractFindings(kr)
setKontroller(findings)
if (kr.status === 'DONE_SUCCESS') return true
if (kr.status === 'DONE_REJECTED') {
setError(t('underlag_rejected_error', { count: findings.filter(f => f.status === 'STOPP').length }))
} else {
setError(t('underlag_failed'))
}
return false
}
/**
* skapaGranskningsunderlag: returns the Mina Sidor deep-link the user opens
* to sign with BankID. Defaults to `lasPeriod=true` so the period is locked
* while the signing window is open. Returns the link on success ('' when
* the response carried none: the signing-link card renders it after the
* submission refresh) and null on failure (error already surfaced).
*/
async function runCreateSigningLink(): Promise<string | null> {
const res = await fetch(
`/api/extensions/ext/skatteverket/agi/granskningsunderlag?arbetsgivare=${encodeURIComponent(arbetsgivare)}&period=${period}`,
{ method: 'POST' },
)
const json = await res.json()
if (!res.ok || json.error) {
setError(json.error || t('signing_link_failed_status', { status: res.status }))
return null
}
if (json.data?.tillstand === 'INCORRECT_DATA') {
setError(t('incorrect_data_error', { message: json.data.meddelande || t('incorrect_data_fallback') }))
return null
}
// The user typically opens the link, signs in Mina Sidor, then returns
// later (or never). Auto-poll so we capture the kvittens (and stamp
// agi_submitted_at) without forcing the user to click "Hämta kvittens".
scheduleKvittensPolls()
return typeof json.data?.link === 'string' ? json.data.link : ''
}
/**
* One-click filing: generate (if missing) POST underlag poll kontroll
* create signing link hand over to BankID signing in Mina Sidor.
*
* The signing link opens in a tab we open synchronously at click time:
* window.open after the async chain would be popup-blocked. On failure the
* placeholder tab is closed and the error renders in the panel; if the
* popup was blocked outright, the signing-link card (rendered from the
* refreshed submission state) is the fallback path.
*/
const handleSubmitChain = async () => {
setActionLoading('chain')
setError(null)
setSuccess(null)
let signingTab: Window | null = null
try {
signingTab = window.open('', '_blank')
if (signingTab) {
signingTab.document.title = t('chain_tab_title')
signingTab.document.body.textContent = t('chain_tab_body')
}
} catch {
signingTab = null
}
try {
setChain({ current: 'generate', failed: false, done: false })
if (!(await ensureAgiGenerated())) throw new ChainFailed()
setChain({ current: 'submit', failed: false, done: false })
const submitted = await runSubmitUnderlag(() =>
setChain({ current: 'kontroll', failed: false, done: false }),
)
if (!submitted) throw new ChainFailed()
setChain({ current: 'link', failed: false, done: false })
const link = await runCreateSigningLink()
if (link === null) throw new ChainFailed()
setChain({ current: 'link', failed: false, done: true })
setSuccess(t('chain_ready_to_sign'))
if (signingTab && link) {
signingTab.location.replace(link)
signingTab = null // handed over to Skatteverket: don't close it below
} else {
signingTab?.close()
signingTab = null
}
onRefreshSubmission()
onChange?.()
} catch (e) {
signingTab?.close()
setChain(prev => (prev ? { ...prev, failed: true } : prev))
if (!(e instanceof ChainFailed)) {
setError(e instanceof Error ? e.message : t('submit_failed'))
}
onRefreshSubmission()
} finally {
setActionLoading(null)
}
}
// Always-free: generate + download the AGI XML so the user can file manually
// in Skatteverket's e-service. AGI is a mandatory statutory filing, so this
// path must never be paywalled: only the direct API submission below is paid.
// path must never be paywalled: only the direct API submission is paid.
const handleDownloadXml = async () => {
setActionLoading('download')
setError(null)
@@ -442,7 +647,7 @@ export function AGIPanel(props: AGIPanelProps) {
const res = await fetch(`/api/salary/runs/${salaryRunId}/agi/xml`)
if (!res.ok) {
const data = await res.json().catch(() => ({}))
throw new Error(data.error || t('xml_generate_failed'))
throw new Error(errText(data) || t('xml_generate_failed'))
}
const blob = await res.blob()
const url = URL.createObjectURL(blob)
@@ -461,60 +666,16 @@ export function AGIPanel(props: AGIPanelProps) {
}
}
/** Advanced/recovery variant: submit the underlag without continuing the chain. */
const handleSubmit = async () => {
setActionLoading('submit')
setError(null)
setSuccess(null)
setKontroller([])
try {
const submitRes = await fetch('/api/extensions/ext/skatteverket/agi/submit', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ salaryRunId }),
})
const submitJson = await submitRes.json()
if (!submitRes.ok || submitJson.error) {
setError(submitJson.error || t('submit_failed_status', { status: submitRes.status }))
return
}
const inlamningId = submitJson.data?.inlamningId as number | undefined
if (!inlamningId) {
setError(t('submit_missing_id'))
return
}
// Poll kontrollresultat until DONE_*
let kr: Kontrollresultat | undefined
for (let attempt = 0; attempt < 8; attempt++) {
const krRes = await fetch(
`/api/extensions/ext/skatteverket/agi/kontrollresultat?inlamningId=${inlamningId}`,
)
const krJson = await krRes.json()
if (!krRes.ok || krJson.error) {
setError(krJson.error || t('kontrollresultat_failed_status', { status: krRes.status }))
return
}
kr = krJson.data as Kontrollresultat
if (kr.status !== 'PROCESSING') break
await new Promise(r => setTimeout(r, 1000))
}
if (!kr || kr.status === 'PROCESSING') {
setError(t('still_processing'))
return
}
const findings = extractFindings(kr)
setKontroller(findings)
if (kr.status === 'DONE_SUCCESS') {
setSuccess(t('underlag_accepted'))
} else if (kr.status === 'DONE_REJECTED') {
setError(t('underlag_rejected_error', { count: findings.filter(f => f.status === 'STOPP').length }))
} else {
setError(t('underlag_failed'))
}
await fetchSubmission()
if (!(await ensureAgiGenerated())) return
const ok = await runSubmitUnderlag()
if (ok) setSuccess(t('underlag_accepted'))
onRefreshSubmission()
onChange?.()
} catch (e) {
setError(e instanceof Error ? e.message : t('submit_failed'))
@@ -523,36 +684,15 @@ export function AGIPanel(props: AGIPanelProps) {
}
}
/**
* Step 2: skapaGranskningsunderlag: returns the Mina Sidor deep-link the
* user opens to sign with BankID. Defaults to `lasPeriod=true` so the
* period is locked while the signing window is open.
*/
/** Advanced/recovery variant: create the signing link on its own. */
const handleCreateSigningLink = async () => {
setActionLoading('granskning')
setError(null)
setSuccess(null)
try {
const res = await fetch(
`/api/extensions/ext/skatteverket/agi/granskningsunderlag?arbetsgivare=${encodeURIComponent(arbetsgivare)}&period=${period}`,
{ method: 'POST' },
)
const json = await res.json()
if (!res.ok || json.error) {
setError(json.error || t('signing_link_failed_status', { status: res.status }))
return
}
if (json.data?.tillstand === 'INCORRECT_DATA') {
setError(t('incorrect_data_error', { message: json.data.meddelande || t('incorrect_data_fallback') }))
} else {
setSuccess(t('signing_link_ready'))
// The user typically opens the link, signs in Mina Sidor, then
// returns later (or never). Auto-poll so we capture the kvittens
// (and stamp agi_submitted_at) without forcing the user to come
// back and click "Hämta kvittens".
scheduleKvittensPolls()
}
await fetchSubmission()
const link = await runCreateSigningLink()
if (link !== null) setSuccess(t('signing_link_ready'))
onRefreshSubmission()
} catch (e) {
setError(e instanceof Error ? e.message : t('signing_link_failed'))
} finally {
@@ -575,7 +715,7 @@ export function AGIPanel(props: AGIPanelProps) {
return
}
setSuccess(t('unlock_success'))
await fetchSubmission()
onRefreshSubmission()
} catch (e) {
setError(e instanceof Error ? e.message : t('unlock_failed'))
} finally {
@@ -584,7 +724,7 @@ export function AGIPanel(props: AGIPanelProps) {
}
/**
* Step 3 (post-signing): poll /agi/kvittenser to detect that the user has
* Post-signing recovery: poll /agi/kvittenser to detect that the user has
* signed in Mina Sidor. Once a kvittens turns up, the index.ts handler
* mirrors it onto agi_declarations and flips the local submission state
* to 'signed'.
@@ -608,7 +748,7 @@ export function AGIPanel(props: AGIPanelProps) {
} else {
setSuccess(t('no_kvittens_yet'))
}
await fetchSubmission()
onRefreshSubmission()
onChange?.()
} catch (e) {
setError(e instanceof Error ? e.message : t('check_status_failed'))
@@ -673,23 +813,6 @@ export function AGIPanel(props: AGIPanelProps) {
)
}
const subState = submission?.status
const awaitingSigning = subState === 'awaiting_signing'
const underlagSubmitted = subState === 'underlag_submitted'
const underlagRejected = subState === 'underlag_rejected'
const isSigned = subState === 'signed' || !!agiSubmittedAt
// The submission state is keyed by PERIOD; AGI generation is keyed by RUN.
// If the run's AGI was (re)generated AFTER this signing draft was created,
// the locked underlag at Skatteverket reflects superseded figures and must
// not be signed: surface a warning and steer the user to unlock + resubmit
// rather than presenting it as ready to sign (avoids filing stale amounts).
const draftUpdatedAt = submission?.updatedAt ? new Date(submission.updatedAt) : null
const draftIsStale =
awaitingSigning &&
!!agiGeneratedAt &&
!!draftUpdatedAt &&
!Number.isNaN(draftUpdatedAt.getTime()) &&
new Date(agiGeneratedAt).getTime() > draftUpdatedAt.getTime()
// Tokens issued before the agd scope was added to DEFAULT_SCOPES will
// 403 with invalid_scope at submission time: surface that proactively
// so the user reconnects before hitting the deadline rather than at it.
@@ -697,6 +820,23 @@ export function AGIPanel(props: AGIPanelProps) {
typeof status?.scope === 'string' &&
!status.scope.split(/\s+/).filter(Boolean).includes('agd')
// Recovery states expose the advanced actions on their own: the stale-draft
// and error-report guidance below reference them by name.
const forcedAdvanced = draftIsStale || underlagRejected
const advancedOpen = showAdvanced || forcedAdvanced
const signedAtRaw = submission?.signeradTid ?? agiSubmittedAt ?? null
const signedAtText = signedAtRaw ? new Date(signedAtRaw).toLocaleString('sv-SE') : null
const chainStepState = (step: ChainStep): 'done' | 'running' | 'failed' | 'upcoming' => {
if (!chain) return 'upcoming'
const idx = CHAIN_STEPS.indexOf(step)
const currentIdx = CHAIN_STEPS.indexOf(chain.current)
if (idx < currentIdx || (idx === currentIdx && chain.done)) return 'done'
if (idx === currentIdx) return chain.failed ? 'failed' : 'running'
return 'upcoming'
}
return (
<Card>
<CardHeader>
@@ -727,6 +867,40 @@ export function AGIPanel(props: AGIPanelProps) {
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{/* Filed: the terminal state deserves more than a gray status row.
Kvittensnummer + signature metadata come from the submission
record; a run stamped only via agi_submitted_at (e.g. cron
reconciliation with an evicted cache) still gets the card. */}
{isSigned && (
<div className="rounded-md border border-border bg-muted/30 p-4">
<div className="flex items-start gap-3">
<CheckCircle2 className="mt-0.5 h-5 w-5 shrink-0 text-success" />
<div className="space-y-1">
<p className="text-sm font-medium">
{t('success_card_title', { period: prettyPeriod })}
</p>
{submission?.kvittensnummer && (
<p className="text-sm text-muted-foreground tabular-nums">
{t('success_card_kvittens', { kvittens: submission.kvittensnummer })}
</p>
)}
{(submission?.signeradAv || signedAtText) && (
<p className="text-sm text-muted-foreground">
{submission?.signeradAv
? signedAtText
? t('success_card_signed_by_at', {
name: submission.signeradAv,
date: signedAtText,
})
: t('success_card_signed_by', { name: submission.signeradAv })
: t('success_card_signed_at', { date: signedAtText ?? '' })}
</p>
)}
</div>
</div>
</div>
)}
{/* Expired-session banner: the token row exists (so status.connected
is true) but the access token is past expiry and either has no
refresh token or has burned through its 10-refresh budget. The
@@ -919,74 +1093,140 @@ export function AGIPanel(props: AGIPanelProps) {
)}
{!readOnly && !isSigned && (
<div className="flex flex-wrap gap-2">
<Button
size="sm"
variant="outline"
onClick={handleDownloadXml}
disabled={actionLoading === 'download'}
title={t('download_xml_title')}
>
{actionLoading === 'download' ? (
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />
) : (
<Download className="mr-1.5 h-3.5 w-3.5" />
<div className="space-y-3">
{/* Primary path: one click runs the whole filing chain. Hidden
while a signing draft is open at SKV (the period is locked,
so a resubmission would be refused): the signing-link card
above is the CTA then, and the stale-draft recovery goes
through the advanced actions per the guidance text. The
XML download stays free for manual filing regardless. */}
<div className="flex flex-wrap items-center gap-2">
{!awaitingSigning && (
<Button
onClick={handleSubmitChain}
disabled={actionLoading !== null || !hasSkatteverket}
>
{actionLoading === 'chain' ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Send className="mr-2 h-4 w-4" />
)}
{t('chain_button')}
</Button>
)}
{t('download_xml_button')}
</Button>
<Button
size="sm"
variant="outline"
onClick={handleSubmit}
disabled={actionLoading === 'submit' || !hasSkatteverket}
>
{actionLoading === 'submit' ? (
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />
) : (
<Send className="mr-1.5 h-3.5 w-3.5" />
)}
{t('submit_button')}
</Button>
<Button
size="sm"
onClick={handleCreateSigningLink}
disabled={actionLoading === 'granskning' || !underlagSubmitted}
>
{actionLoading === 'granskning' ? (
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />
) : (
<Lock className="mr-1.5 h-3.5 w-3.5" />
)}
{t('signing_link_button')}
</Button>
<Button
size="sm"
variant="ghost"
onClick={handleCheckSubmitted}
disabled={actionLoading === 'check'}
>
{actionLoading === 'check' ? (
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />
) : (
<Download className="mr-1.5 h-3.5 w-3.5" />
)}
{t('check_kvittens_button')}
</Button>
{awaitingSigning && (
<Button
size="sm"
variant="ghost"
onClick={handleUnlock}
disabled={actionLoading === 'unlock'}
variant="outline"
onClick={handleDownloadXml}
disabled={actionLoading === 'download'}
title={t('download_xml_title')}
>
{actionLoading === 'unlock' ? (
{actionLoading === 'download' ? (
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />
) : (
<Unlock className="mr-1.5 h-3.5 w-3.5" />
<Download className="mr-1.5 h-3.5 w-3.5" />
)}
{t('unlock_button')}
{t('download_xml_button')}
</Button>
</div>
{chain && (
<ol className="space-y-1.5 rounded-md border border-border bg-muted/30 p-3">
{CHAIN_STEPS.map(step => {
const state = chainStepState(step)
return (
<li key={step} className="flex items-center gap-2 text-xs">
{state === 'done' ? (
<CheckCircle2 className="h-3.5 w-3.5 shrink-0 text-success" />
) : state === 'running' ? (
<Loader2 className="h-3.5 w-3.5 shrink-0 animate-spin" />
) : state === 'failed' ? (
<AlertCircle className="h-3.5 w-3.5 shrink-0 text-destructive" />
) : (
<Circle className="h-3.5 w-3.5 shrink-0 text-muted-foreground/50" />
)}
<span className={state === 'upcoming' ? 'text-muted-foreground' : ''}>
{t(`chain_step_${step}`)}
</span>
</li>
)
})}
</ol>
)}
{/* Recovery/expert actions: each is one step of the chain above,
for resuming after a partial failure. Auto-expanded when a
recovery state (stale draft, rejected underlag) references
them by name. */}
<div>
{!forcedAdvanced && (
<button
type="button"
onClick={() => setShowAdvanced(v => !v)}
className="text-xs text-muted-foreground transition-colors hover:text-foreground"
>
{advancedOpen ? t('advanced_hide') : t('advanced_show')}
</button>
)}
{advancedOpen && (
<div className="mt-2 flex flex-wrap gap-2">
<Button
size="sm"
variant="outline"
onClick={handleSubmit}
disabled={actionLoading !== null || !hasSkatteverket}
>
{actionLoading === 'submit' ? (
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />
) : (
<Send className="mr-1.5 h-3.5 w-3.5" />
)}
{t('submit_button')}
</Button>
<Button
size="sm"
variant="outline"
onClick={handleCreateSigningLink}
disabled={actionLoading !== null || !underlagSubmitted}
>
{actionLoading === 'granskning' ? (
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />
) : (
<Lock className="mr-1.5 h-3.5 w-3.5" />
)}
{t('signing_link_button')}
</Button>
<Button
size="sm"
variant="ghost"
onClick={handleCheckSubmitted}
disabled={actionLoading !== null}
>
{actionLoading === 'check' ? (
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />
) : (
<Download className="mr-1.5 h-3.5 w-3.5" />
)}
{t('check_kvittens_button')}
</Button>
{awaitingSigning && (
<Button
size="sm"
variant="ghost"
onClick={handleUnlock}
disabled={actionLoading !== null}
>
{actionLoading === 'unlock' ? (
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />
) : (
<Unlock className="mr-1.5 h-3.5 w-3.5" />
)}
{t('unlock_button')}
</Button>
)}
</div>
)}
</div>
</div>
)}
+8
View File
@@ -168,6 +168,8 @@ function NewEmployeeForm({ onCreated, onCancel }: { onCreated: () => void; onCan
employment_start: form.get('employment_start') as string,
employment_end: form.get('employment_end') as string || undefined,
employment_degree: parseFloat(form.get('employment_degree') as string) || 100,
hours_per_week: parseFloat(form.get('hours_per_week') as string) || 40,
workdays_per_week: parseFloat(form.get('workdays_per_week') as string) || 5,
salary_type: salaryType,
monthly_salary: salaryType === 'monthly' ? (parseFloat(form.get('monthly_salary') as string) || undefined) : undefined,
hourly_rate: salaryType === 'hourly' ? (parseFloat(form.get('hourly_rate') as string) || undefined) : undefined,
@@ -293,6 +295,12 @@ function NewEmployeeForm({ onCreated, onCancel }: { onCreated: () => void; onCan
<Field label="Sysselsättningsgrad (%)" htmlFor="employment_degree">
<Input id="employment_degree" name="employment_degree" type="number" defaultValue="100" min="1" max="100" />
</Field>
<Field label="Timmar per vecka" htmlFor="hours_per_week">
<Input id="hours_per_week" name="hours_per_week" type="number" defaultValue="40" min="1" max="80" step="0.5" />
</Field>
<Field label="Arbetsdagar per vecka" htmlFor="workdays_per_week">
<Input id="workdays_per_week" name="workdays_per_week" type="number" defaultValue="5" min="1" max="7" step="1" />
</Field>
<Field label="Löneform" htmlFor="salary_type" required>
<Select value={salaryType} onValueChange={setSalaryType}>
<SelectTrigger id="salary_type">
+271
View File
@@ -0,0 +1,271 @@
'use client'
/**
* Ingående saldon (payroll cutover) panel on the employee editor.
*
* Mid-year switchers from another payroll system enter per-employee state
* here: YTD accumulators, vacation balances (incl. sparade dagar per
* origin year), opening semesterlöneskuld SEK, and the karens adjustment.
* Locked (read-only) once the employee has a booked salary run; the lock
* self-releases if that run is corrected.
*/
import { useEffect, useState } from 'react'
import { useTranslations } from 'next-intl'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Skeleton } from '@/components/ui/skeleton'
import { Save } from 'lucide-react'
import { useToast } from '@/components/ui/use-toast'
import { getErrorMessage } from '@/lib/errors/get-error-message'
interface OpeningBalancesData {
cutover_date: string
ytd_gross: number
ytd_tax: number
ytd_net: number
vacation_paid_days_remaining: number
vacation_saved_days_by_year: Record<string, number>
opening_semester_liability: number
opening_semester_liability_avgifter: number
karens_periods_adjustment: number
locked: boolean
locked_by_run_id: string | null
}
const currentYear = new Date().getFullYear()
/** Sparade dagar origin years: Semesterlagen allows saving max 5 years. */
const SAVED_YEARS = Array.from({ length: 5 }, (_, i) => String(currentYear - 1 - i))
export function OpeningBalancesPanel({ employeeId, canWrite }: { employeeId: string; canWrite: boolean }) {
const t = useTranslations('salary_employee')
const { toast } = useToast()
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
const [locked, setLocked] = useState(false)
const [hasRow, setHasRow] = useState(false)
const [cutoverDate, setCutoverDate] = useState(`${currentYear}-01-01`)
const [ytdGross, setYtdGross] = useState('')
const [ytdTax, setYtdTax] = useState('')
const [ytdNet, setYtdNet] = useState('')
const [daysRemaining, setDaysRemaining] = useState('')
const [savedByYear, setSavedByYear] = useState<Record<string, string>>({})
const [liability, setLiability] = useState('')
const [liabilityAvgifter, setLiabilityAvgifter] = useState('')
const [karens, setKarens] = useState('')
useEffect(() => {
async function load() {
setLoading(true)
const res = await fetch(`/api/salary/employees/${employeeId}/opening-balances`)
if (res.ok) {
const { data } = (await res.json()) as { data: OpeningBalancesData | null }
if (data) {
setHasRow(true)
setLocked(data.locked)
setCutoverDate(data.cutover_date)
setYtdGross(String(data.ytd_gross))
setYtdTax(String(data.ytd_tax))
setYtdNet(String(data.ytd_net))
setDaysRemaining(String(data.vacation_paid_days_remaining))
setSavedByYear(
Object.fromEntries(
Object.entries(data.vacation_saved_days_by_year ?? {}).map(([y, d]) => [y, String(d)]),
),
)
setLiability(String(data.opening_semester_liability))
setLiabilityAvgifter(String(data.opening_semester_liability_avgifter))
setKarens(String(data.karens_periods_adjustment))
}
}
setLoading(false)
}
load()
}, [employeeId])
async function handleSave() {
setSaving(true)
const saved: Record<string, number> = {}
for (const [year, value] of Object.entries(savedByYear)) {
const days = parseFloat(value)
if (Number.isFinite(days) && days > 0) saved[year] = days
}
const res = await fetch(`/api/salary/employees/${employeeId}/opening-balances`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
cutover_date: cutoverDate,
ytd_gross: parseFloat(ytdGross) || 0,
ytd_tax: parseFloat(ytdTax) || 0,
ytd_net: parseFloat(ytdNet) || 0,
vacation_paid_days_remaining: parseFloat(daysRemaining) || 0,
vacation_saved_days_by_year: saved,
opening_semester_liability: parseFloat(liability) || 0,
opening_semester_liability_avgifter: parseFloat(liabilityAvgifter) || 0,
karens_periods_adjustment: parseInt(karens, 10) || 0,
}),
})
if (res.ok) {
setHasRow(true)
toast({ title: t('opening_balances_saved') })
} else {
const result = await res.json()
toast({
title: t('opening_balances_save_failed'),
description: getErrorMessage(result, { statusCode: res.status }),
variant: 'destructive',
})
}
setSaving(false)
}
if (loading) {
return (
<Card>
<CardHeader>
<CardTitle className="text-base">{t('opening_balances_title')}</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<Skeleton className="h-10 w-full" />
<Skeleton className="h-10 w-full" />
</CardContent>
</Card>
)
}
const readOnly = locked || !canWrite
return (
<Card>
<CardHeader>
<CardTitle className="text-base">{t('opening_balances_title')}</CardTitle>
<p className="text-sm text-muted-foreground">{t('opening_balances_description')}</p>
</CardHeader>
<CardContent className="space-y-6">
{locked && (
<p className="text-sm text-muted-foreground border border-border rounded-lg p-3">
{t('opening_balances_locked_notice')}
</p>
)}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="ob-cutover">{t('opening_balances_cutover_date')}</Label>
<Input
id="ob-cutover"
type="date"
value={cutoverDate}
onChange={(e) => setCutoverDate(e.target.value)}
disabled={readOnly}
/>
<p className="text-xs text-muted-foreground">{t('opening_balances_cutover_hint')}</p>
</div>
<div className="space-y-2">
<Label htmlFor="ob-karens">{t('opening_balances_karens')}</Label>
<Input
id="ob-karens"
type="number"
min={0}
max={10}
step={1}
value={karens}
onChange={(e) => setKarens(e.target.value)}
disabled={readOnly}
className="tabular-nums"
/>
<p className="text-xs text-muted-foreground">{t('opening_balances_karens_hint')}</p>
</div>
</div>
<div>
<h2 className="text-sm font-medium uppercase tracking-wider text-muted-foreground mb-3">
{t('opening_balances_ytd_heading')}
</h2>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div className="space-y-2">
<Label htmlFor="ob-ytd-gross">{t('opening_balances_ytd_gross')}</Label>
<Input id="ob-ytd-gross" type="number" min={0} value={ytdGross}
onChange={(e) => setYtdGross(e.target.value)} disabled={readOnly} className="tabular-nums" />
</div>
<div className="space-y-2">
<Label htmlFor="ob-ytd-tax">{t('opening_balances_ytd_tax')}</Label>
<Input id="ob-ytd-tax" type="number" min={0} value={ytdTax}
onChange={(e) => setYtdTax(e.target.value)} disabled={readOnly} className="tabular-nums" />
</div>
<div className="space-y-2">
<Label htmlFor="ob-ytd-net">{t('opening_balances_ytd_net')}</Label>
<Input id="ob-ytd-net" type="number" min={0} value={ytdNet}
onChange={(e) => setYtdNet(e.target.value)} disabled={readOnly} className="tabular-nums" />
</div>
</div>
</div>
<div>
<h2 className="text-sm font-medium uppercase tracking-wider text-muted-foreground mb-3">
{t('opening_balances_vacation_heading')}
</h2>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div className="space-y-2">
<Label htmlFor="ob-days-remaining">{t('opening_balances_days_remaining')}</Label>
<Input id="ob-days-remaining" type="number" min={0} max={40} step={0.5} value={daysRemaining}
onChange={(e) => setDaysRemaining(e.target.value)} disabled={readOnly} className="tabular-nums" />
</div>
<div className="space-y-2">
<Label htmlFor="ob-liability">{t('opening_balances_liability')}</Label>
<Input id="ob-liability" type="number" min={0} value={liability}
onChange={(e) => setLiability(e.target.value)} disabled={readOnly} className="tabular-nums" />
</div>
<div className="space-y-2">
<Label htmlFor="ob-liability-avgifter">{t('opening_balances_liability_avgifter')}</Label>
<Input id="ob-liability-avgifter" type="number" min={0} value={liabilityAvgifter}
onChange={(e) => setLiabilityAvgifter(e.target.value)} disabled={readOnly} className="tabular-nums" />
</div>
</div>
</div>
<div>
<h2 className="text-sm font-medium uppercase tracking-wider text-muted-foreground mb-3">
{t('opening_balances_saved_heading')}
</h2>
<p className="text-xs text-muted-foreground mb-3">{t('opening_balances_saved_hint')}</p>
<div className="grid grid-cols-2 md:grid-cols-5 gap-4">
{SAVED_YEARS.map((year) => (
<div key={year} className="space-y-2">
<Label htmlFor={`ob-saved-${year}`} className="tabular-nums">{year}</Label>
<Input
id={`ob-saved-${year}`}
type="number"
min={0}
max={40}
step={0.5}
value={savedByYear[year] ?? ''}
onChange={(e) => setSavedByYear((prev) => ({ ...prev, [year]: e.target.value }))}
disabled={readOnly}
className="tabular-nums"
/>
</div>
))}
</div>
</div>
{!readOnly && (
<div className="flex justify-end">
<Button onClick={handleSave} disabled={saving}>
<Save className="h-4 w-4 mr-2" />
{saving
? t('opening_balances_saving')
: hasRow
? t('opening_balances_update')
: t('opening_balances_save')}
</Button>
</div>
)}
</CardContent>
</Card>
)
}
+238
View File
@@ -0,0 +1,238 @@
'use client'
/**
* Semester card on the salary dashboard (payroll gap-closure 3.5).
*
* Shows the open vacation-ledger totals (remaining + saved days across
* active employees) and hosts the year-close dialog: dry-run report first,
* a working confirm second (soft-guard convention).
*/
import { useEffect, useState } from 'react'
import { useTranslations } from 'next-intl'
import { Card, CardContent } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Palmtree, Loader2 } from 'lucide-react'
import { useToast } from '@/components/ui/use-toast'
import { formatCurrency } from '@/lib/utils'
interface BalanceRow {
employee_vacation_balance_id: string
employee_name: string
remaining_days: number
saved_days_total: number
forced_payout_days: number
}
interface CloseReportRow {
employee_name: string
remaining_days: number
saveable_days: number
untaken_below_floor_days: number
expiring_days: number
next_year_entitled: number
}
interface CloseReport {
vacation_year_start: string
vacation_year_end: string
rows: CloseReportRow[]
sek: {
computed_liability: number
booked_2920: number
drift_2920: number
adjustment_needed: boolean
}
}
export function VacationBalanceCard({ canWrite }: { canWrite: boolean }) {
const t = useTranslations('salary')
const { toast } = useToast()
const [rows, setRows] = useState<BalanceRow[]>([])
const [loading, setLoading] = useState(true)
const [dialogOpen, setDialogOpen] = useState(false)
const [report, setReport] = useState<CloseReport | null>(null)
const [previewError, setPreviewError] = useState<string | null>(null)
const [previewing, setPreviewing] = useState(false)
const [closing, setClosing] = useState(false)
async function fetchBalances(): Promise<BalanceRow[] | null> {
const res = await fetch('/api/salary/vacation-balances')
if (!res.ok) return null
const { data } = await res.json()
return (data ?? []) as BalanceRow[]
}
useEffect(() => {
let cancelled = false
fetchBalances().then((data) => {
if (cancelled) return
if (data) setRows(data)
setLoading(false)
})
return () => {
cancelled = true
}
}, [])
const totalRemaining = rows.reduce((s, r) => s + r.remaining_days, 0)
const totalSaved = rows.reduce((s, r) => s + r.saved_days_total, 0)
async function openCloseDialog() {
setDialogOpen(true)
setPreviewing(true)
setReport(null)
setPreviewError(null)
const res = await fetch('/api/salary/vacation-year-close', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ dry_run: true }),
})
const body = await res.json()
if (res.ok) {
setReport(body.data.report as CloseReport)
} else {
setPreviewError(body.error ?? t('vacation_close_preview_failed'))
}
setPreviewing(false)
}
async function confirmClose() {
setClosing(true)
const res = await fetch('/api/salary/vacation-year-close', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ book_adjustment: true }),
})
const body = await res.json()
if (res.ok) {
toast({ title: t('vacation_close_done') })
setDialogOpen(false)
const refreshed = await fetchBalances()
if (refreshed) setRows(refreshed)
} else {
toast({
title: t('vacation_close_failed'),
description: body.error,
variant: 'destructive',
})
}
setClosing(false)
}
return (
<Card>
<CardContent className="p-4">
<div className="flex items-center gap-2 mb-2">
<Palmtree className="h-4 w-4 text-muted-foreground" />
<p className="text-xs text-muted-foreground">{t('card_vacation_title')}</p>
</div>
{loading ? (
<p className="text-sm text-muted-foreground"></p>
) : rows.length > 0 ? (
<>
<p className="font-sans text-lg font-medium tabular-nums leading-tight">
{totalRemaining}
</p>
<p className="text-xs text-muted-foreground">
{t('card_vacation_detail', { employees: rows.length, saved: totalSaved })}
</p>
{canWrite && (
<Button variant="outline" size="sm" className="mt-2" onClick={openCloseDialog}>
{t('vacation_close_button')}
</Button>
)}
</>
) : (
<p className="text-sm text-muted-foreground">{t('card_vacation_none')}</p>
)}
</CardContent>
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle>{t('vacation_close_title')}</DialogTitle>
<DialogDescription>{t('vacation_close_description')}</DialogDescription>
</DialogHeader>
{previewing && (
<div className="flex items-center gap-2 py-6 text-sm text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" />
{t('vacation_close_previewing')}
</div>
)}
{previewError && <p className="text-sm text-destructive py-4">{previewError}</p>}
{report && (
<div className="space-y-4">
<p className="text-sm text-muted-foreground">
{t('vacation_close_period', {
from: report.vacation_year_start,
to: report.vacation_year_end,
})}
</p>
<div className="max-h-64 overflow-y-auto border border-border rounded-lg">
<table className="w-full text-sm">
<thead>
<tr className="text-[11px] font-medium uppercase tracking-wider text-muted-foreground">
<th className="text-left px-3 py-2">{t('vacation_close_col_employee')}</th>
<th className="text-right px-3 py-2">{t('vacation_close_col_saved')}</th>
<th className="text-right px-3 py-2">{t('vacation_close_col_flagged')}</th>
<th className="text-right px-3 py-2">{t('vacation_close_col_expiring')}</th>
<th className="text-right px-3 py-2">{t('vacation_close_col_next')}</th>
</tr>
</thead>
<tbody>
{report.rows.map((row, idx) => (
<tr key={idx} className="border-t border-border">
<td className="px-3 py-2">{row.employee_name}</td>
<td className="px-3 py-2 text-right tabular-nums">{row.saveable_days}</td>
<td className="px-3 py-2 text-right tabular-nums">{row.untaken_below_floor_days}</td>
<td className="px-3 py-2 text-right tabular-nums">{row.expiring_days}</td>
<td className="px-3 py-2 text-right tabular-nums">{row.next_year_entitled}</td>
</tr>
))}
</tbody>
</table>
</div>
<div className="text-sm space-y-1">
<p className="tabular-nums">
{t('vacation_close_computed', {
amount: formatCurrency(report.sek.computed_liability),
})}
</p>
<p className="tabular-nums">
{t('vacation_close_booked', { amount: formatCurrency(report.sek.booked_2920) })}
</p>
<p className="tabular-nums">
{report.sek.adjustment_needed
? t('vacation_close_drift', { amount: formatCurrency(report.sek.drift_2920) })
: t('vacation_close_no_drift')}
</p>
</div>
</div>
)}
<DialogFooter>
<Button variant="outline" onClick={() => setDialogOpen(false)} disabled={closing}>
{t('vacation_close_cancel')}
</Button>
<Button onClick={confirmClose} disabled={!report || closing}>
{closing && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
{t('vacation_close_confirm')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</Card>
)
}
+23 -9
View File
@@ -6,6 +6,7 @@ import { ArrowLeftCircle, Eye, FileDown, Loader2, Send } from 'lucide-react'
import { formatDateLong } from '@/lib/utils'
import { useCapability } from '@/contexts/CompanyContext'
import { CAPABILITY } from '@/lib/entitlements/keys'
import type { AgiFilingState } from '@/lib/salary/agi-submission-state'
import type { RunDetail } from './types'
type StepState = 'done' | 'active' | 'upcoming'
@@ -16,6 +17,12 @@ interface RunProgressBarProps {
// True when the run pays out nothing (nollkörning / fully net-deducted): the
// pay step carries no "download the file" hint because there is no file.
noPayout?: boolean
// Real filing state for the AGI step (deriveAgiFilingState): without it the
// rail only knows generated/submitted and mislabels "waiting for BankID
// signature" as "lämna in till Skatteverket".
agiState: AgiFilingState
// Shown on the AGI step once signed: the kvittens is the filing receipt.
agiKvittensnummer?: string | null
canWrite: boolean
actionLoading: string | null
// The single "forward" step for the current status (Beräkna → Skicka till
@@ -43,7 +50,7 @@ const STATUS_RANK: Record<string, number> = {
export function RunProgressBar(props: RunProgressBarProps) {
const t = useTranslations('salary_run')
const locale = useLocale()
const { run, isCalculated, noPayout, canWrite, actionLoading, primaryAction } = props
const { run, isCalculated, noPayout, canWrite, actionLoading, primaryAction, agiState, agiKvittensnummer } = props
const rank = STATUS_RANK[run.status] ?? 0
const busy = !!actionLoading
const deliveries = run.payslip_deliveries_summary
@@ -116,14 +123,21 @@ export function RunProgressBar(props: RunProgressBarProps) {
{
key: 'agi',
label: t('rail_agi'),
state: run.agi_submitted_at ? 'done' : run.status === 'booked' ? 'active' : 'upcoming',
detail: run.agi_submitted_at
? t('rail_agi_submitted')
: run.agi_generated_at
? t('rail_agi_generated')
: run.status === 'booked'
? t('rail_agi_hint')
: undefined,
state: agiState === 'signed' ? 'done' : run.status === 'booked' ? 'active' : 'upcoming',
detail:
agiState === 'signed'
? agiKvittensnummer
? t('rail_agi_submitted_kvittens', { kvittens: agiKvittensnummer })
: t('rail_agi_submitted')
: agiState === 'awaiting_signing'
? t('rail_agi_awaiting_signature')
: agiState === 'underlag_submitted'
? t('rail_agi_underlag_submitted')
: agiState === 'generated'
? t('rail_agi_generated')
: run.status === 'booked'
? t('rail_agi_hint')
: undefined,
},
]
+47 -40
View File
@@ -5,10 +5,9 @@ import {
createElement,
useCallback,
useContext,
useEffect,
useState,
type ReactNode,
} from 'react'
import useSWR from 'swr'
import { createClient } from '@/lib/supabase/client'
import { useCompany } from '@/contexts/CompanyContext'
import type { CompanySettings } from '@/types'
@@ -36,48 +35,56 @@ export interface SettingsState {
*/
export function useCompanySettings(): SettingsState {
const { company } = useCompany()
const [settings, setSettings] = useState<CompanySettings | null>(null)
const [isLoading, setIsLoading] = useState(true)
const [error, setError] = useState(false)
const companyId = company?.id ?? null
const fetchSettings = useCallback(async () => {
if (!company?.id) {
// No active company (the no-company escape hatch). Nothing to load; surface
// a settled empty state rather than a perpetual spinner.
setSettings(null)
setError(false)
setIsLoading(false)
return
}
// SWR-backed: every consumer of the same company's settings shares one
// cache entry, so concurrent mounts dedupe into a single request and
// back-navigation renders from cache (revalidating in the background)
// instead of re-showing a skeleton. Null key = no active company (the
// no-company escape hatch): a settled empty state, never a spinner.
const { data, error: swrError, isLoading, mutate } = useSWR(
companyId ? ['company_settings', companyId] : null,
async ([, id]: [string, string]) => {
const supabase = createClient()
// maybeSingle() so a missing row resolves to { data: null } instead of
// throwing PGRST116: a company created outside the onboarding flow may
// have no company_settings row yet, and that must not be treated as a
// hard error mid-query (it's surfaced as `error` below once settled).
const { data, error: queryError } = await supabase
.from('company_settings')
.select('*')
.eq('company_id', id)
.maybeSingle()
if (queryError) throw queryError
return data as CompanySettings | null
},
)
setIsLoading(true)
setError(false)
const updateSettings = useCallback(
(updates: Partial<CompanySettings>) => {
// Optimistic local patch only: callers persist through their own API
// routes. revalidate: false so the patch isn't immediately overwritten
// by a refetch racing the server-side write.
void mutate((prev) => (prev ? ({ ...prev, ...updates } as CompanySettings) : prev), {
revalidate: false,
})
},
[mutate],
)
const supabase = createClient()
// maybeSingle() so a missing row resolves to { data: null } instead of
// throwing PGRST116: a company created outside the onboarding flow may have
// no company_settings row yet, and that must not be treated as a hard error
// mid-query (it's surfaced as `error` below once the fetch settles).
const { data, error: queryError } = await supabase
.from('company_settings')
.select('*')
.eq('company_id', company.id)
.maybeSingle()
const refetch = useCallback(async () => {
await mutate()
}, [mutate])
setSettings(data)
setError(Boolean(queryError) || !data)
setIsLoading(false)
}, [company?.id])
useEffect(() => {
fetchSettings()
}, [fetchSettings])
const updateSettings = useCallback((updates: Partial<CompanySettings>) => {
setSettings((prev) => (prev ? ({ ...prev, ...updates } as CompanySettings) : prev))
}, [])
return { settings, isLoading, error, updateSettings, refetch: fetchSettings }
return {
settings: data ?? null,
isLoading: companyId ? isLoading : false,
// Same contract as before SWR: error means "settled without a row"
// (query failure or missing company_settings row), never mid-flight.
error: companyId ? !isLoading && (Boolean(swrError) || !data) : false,
updateSettings,
refetch,
}
}
const SettingsContext = createContext<SettingsState | null>(null)
@@ -0,0 +1,199 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
// Entitlement gate passes in these tests; the gate itself is covered by
// capability-gate.test.ts.
vi.mock('@/lib/entitlements/has-capability', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/lib/entitlements/has-capability')>()
return { ...actual, requireCapability: vi.fn() }
})
const { mockStartAuthorization, mockGetPreferredAuthMethod } = vi.hoisted(() => ({
mockStartAuthorization: vi.fn(),
mockGetPreferredAuthMethod: vi.fn(),
}))
vi.mock('../lib/api-client', async (importOriginal) => {
const actual = await importOriginal<typeof import('../lib/api-client')>()
return {
...actual,
startAuthorization: (...args: unknown[]) => mockStartAuthorization(...args),
getPreferredAuthMethod: (...args: unknown[]) => mockGetPreferredAuthMethod(...args),
}
})
import { enableBankingExtension } from '../index'
import { requireCapability } from '@/lib/entitlements/has-capability'
import type { ExtensionContext } from '@/lib/extensions/types'
interface RecordedCall {
method: string
args: unknown[]
}
interface RecordedChain {
_calls: RecordedCall[]
[key: string]: unknown
}
function makeChain(result: { data?: unknown; error?: unknown }): RecordedChain {
const calls: RecordedCall[] = []
const chain: Record<string, unknown> = { _calls: calls }
for (const m of ['select', 'eq', 'in', 'is', 'order', 'limit', 'update', 'delete', 'insert']) {
chain[m] = vi.fn((...args: unknown[]) => {
calls.push({ method: m, args })
return chain
})
}
chain.maybeSingle = vi.fn().mockResolvedValue({ data: result.data ?? null, error: result.error ?? null })
chain.single = vi.fn().mockResolvedValue({ data: result.data ?? null, error: result.error ?? null })
chain.then = (resolve: (v: unknown) => void) => resolve({ data: result.data ?? null, error: result.error ?? null })
return chain as RecordedChain
}
function makeContext(fromImpl: (table: string) => unknown): ExtensionContext {
return {
userId: 'user-1',
companyId: 'company-1',
extensionId: 'enable-banking',
requestId: 'req_test',
supabase: {
auth: {
getUser: vi.fn().mockResolvedValue({ data: { user: { id: 'user-1' } }, error: null }),
},
from: vi.fn(fromImpl),
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} as any,
emit: vi.fn().mockResolvedValue(undefined),
log: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn(), child: vi.fn() },
settings: {
get: vi.fn().mockResolvedValue(null),
set: vi.fn().mockResolvedValue(undefined),
clear: vi.fn().mockResolvedValue(undefined),
},
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} as any
}
function connectRoute() {
const route = enableBankingExtension.apiRoutes?.find(
(r) => r.method === 'POST' && r.path === '/connect',
)
expect(route, 'POST /connect must be registered').toBeDefined()
return route!
}
function makeConnectRequest() {
return new Request('https://test.local/api/extensions/ext/enable-banking/connect', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ aspsp_name: 'Nordea', aspsp_country: 'SE', psu_type: 'business' }),
})
}
describe('POST /connect never-activated row cleanup', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.mocked(requireCapability).mockResolvedValue(null)
mockGetPreferredAuthMethod.mockResolvedValue(undefined)
mockStartAuthorization.mockResolvedValue({
url: 'https://bank.example/auth',
authorization_id: 'auth-1',
})
})
it('deletes stale pending and error zombies instead of parking them in error', async () => {
const chains: RecordedChain[] = []
let call = 0
const ctx = makeContext(() => {
call++
let chain: RecordedChain
if (call === 1) {
// Latest pending row is stale (45s > 30s threshold): not a live attempt.
chain = makeChain({
data: { id: 'stale-1', created_at: new Date(Date.now() - 45_000).toISOString() },
})
} else if (call === 2) {
// The sweep: returns the deleted never-activated rows.
chain = makeChain({ data: [{ id: 'stale-1' }, { id: 'old-error' }] })
} else {
// Insert of the fresh connection row.
chain = makeChain({ data: { id: 'new-conn' } })
}
chains.push(chain)
return chain
})
const response = await connectRoute().handler(makeConnectRequest(), ctx)
expect(response.status).toBe(200)
const body = (await response.json()) as { connection_id: string; authorization_url: string }
expect(body.connection_id).toBe('new-conn')
expect(body.authorization_url).toBe('https://bank.example/auth')
// The sweep DELETEs never-activated rows: stale pendings and error rows
// from failed attempts, guarded so established connections (session_id or
// accounts_data present) are untouched.
const sweep = chains[1]
const methods = sweep._calls.map((c) => c.method)
expect(methods).toContain('delete')
const inCall = sweep._calls.find((c) => c.method === 'in')
expect(inCall?.args).toEqual(['status', ['pending', 'error']])
const isCalls = sweep._calls.filter((c) => c.method === 'is')
expect(isCalls.map((c) => c.args)).toEqual(
expect.arrayContaining([
['session_id', null],
['accounts_data', null],
]),
)
// Nothing gets parked as status='error' anymore: no update on any chain.
for (const chain of chains) {
expect(chain._calls.some((c) => c.method === 'update')).toBe(false)
}
})
it('still rejects a duplicate connect while a recent pending attempt is live', async () => {
const chains: RecordedChain[] = []
const ctx = makeContext(() => {
// Latest pending row is 5s old: the user is mid-redirect at the bank.
const chain = makeChain({
data: { id: 'live-1', created_at: new Date(Date.now() - 5_000).toISOString() },
})
chains.push(chain)
return chain
})
const response = await connectRoute().handler(makeConnectRequest(), ctx)
expect(response.status).toBe(409)
// No sweep while an attempt is live: the live pending row must survive.
for (const chain of chains) {
expect(chain._calls.some((c) => c.method === 'delete')).toBe(false)
}
expect(mockStartAuthorization).not.toHaveBeenCalled()
})
it('sweeps error zombies even when no pending row exists', async () => {
const chains: RecordedChain[] = []
let call = 0
const ctx = makeContext(() => {
call++
let chain: RecordedChain
if (call === 1) {
chain = makeChain({ data: null })
} else if (call === 2) {
chain = makeChain({ data: [{ id: 'old-error' }] })
} else {
chain = makeChain({ data: { id: 'new-conn' } })
}
chains.push(chain)
return chain
})
const response = await connectRoute().handler(makeConnectRequest(), ctx)
expect(response.status).toBe(200)
const sweep = chains[1]
expect(sweep._calls.some((c) => c.method === 'delete')).toBe(true)
})
})
@@ -107,6 +107,11 @@ function makeContext(connection: Record<string, unknown>, updateSpy: Mock, inser
chain.gte = vi.fn(() => chain)
chain.limit = vi.fn(() => chain)
chain.order = vi.fn(() => chain)
// The fresh-connect path sweeps never-activated rows before inserting:
// .delete().eq(...).in(...).is(...).select('id') must chain through.
chain.delete = vi.fn(() => chain)
chain.in = vi.fn(() => chain)
chain.is = vi.fn(() => chain)
chain.insert = vi.fn((payload: unknown) => {
insertSpy?.(payload)
return chain
@@ -2,11 +2,12 @@
import { useState, useEffect, useRef } from 'react'
import Link from 'next/link'
import { useSearchParams } from 'next/navigation'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { useToast } from '@/components/ui/use-toast'
import { DestructiveConfirmDialog, useDestructiveConfirm } from '@/components/ui/destructive-confirm-dialog'
import { AlertTriangle, Loader2, Upload } from 'lucide-react'
import { AlertTriangle, CheckCircle, Loader2, Upload } from 'lucide-react'
import { cn } from '@/lib/utils'
import { createClient } from '@/lib/supabase/client'
import { notifyBankSyncUpdated } from '@/lib/transactions/bank-sync-signal'
@@ -26,6 +27,13 @@ import type { StoredAccount } from '../types'
export default function BankingSettingsPanel() {
const { toast } = useToast()
const supabase = createClient()
// The OAuth callback lands here with ?select_accounts=<id> once a bank is
// successfully connected. Read via useSearchParams (SSR/hydration-safe) so
// the first-load spinner can say "bank connected, fetching accounts"
// instead of an anonymous spinner. The param itself is consumed and
// stripped by the auto-open effect below.
const searchParams = useSearchParams()
const arrivedFromBankCallback = !!searchParams?.get('select_accounts')
const { dialogProps, confirm } = useDestructiveConfirm()
const { company } = useCompany()
@@ -365,6 +373,24 @@ export default function BankingSettingsPanel() {
}
if (isLoading) {
// Coming back from the bank's consent flow the connection already exists,
// so tell the user that instead of showing an anonymous spinner: this is
// the last silent gap between "approved at the bank" and the account
// picker opening.
if (arrivedFromBankCallback) {
return (
<div className="flex h-32 flex-col items-center justify-center gap-3">
<div className="flex items-center gap-2 text-sm font-medium">
<CheckCircle className="h-4 w-4 text-success" />
<span>Banken är ansluten</span>
</div>
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" />
<span>Hämtar dina konton</span>
</div>
</div>
)
}
return (
<div className="flex items-center justify-center h-32">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
+25 -10
View File
@@ -238,18 +238,33 @@ export const enableBankingExtension: Extension = {
{ status: 409 }
)
}
}
// Clean up stale pending connections (older than threshold)
log.info('[enable-banking] Cleaning up stale pending connections', {
stale_id: recentPending.id,
age_ms: pendingAge,
// Sweep failed attempts that never became a live connection:
// stale 'pending' rows (abandoned redirects past the threshold)
// and 'error' rows left by earlier denied/failed connects.
// DELETE instead of marking 'error': a parked 'error' row renders
// forever as an "Åtgärd krävs" card, so a failed attempt followed
// by a successful retry showed up as two connections to the same
// bank. The session_id/accounts_data guards protect established
// connections (anything that ever completed the callback has
// accounts_data); never-activated rows have no dependents, and
// the transactions/cash_accounts FKs are ON DELETE SET NULL.
const { data: sweptRows } = await supabase
.from('bank_connections')
.delete()
.eq('company_id', companyId)
.eq('bank_name', resolvedAspspName)
.in('status', ['pending', 'error'])
.is('session_id', null)
.is('accounts_data', null)
.select('id')
if (sweptRows?.length) {
log.info('[enable-banking] Swept never-activated connection attempts', {
count: sweptRows.length,
bank: resolvedAspspName,
})
await supabase
.from('bank_connections')
.update({ status: 'error', error_message: 'Superseded by new connection attempt', oauth_state: null })
.eq('company_id', companyId)
.eq('bank_name', resolvedAspspName)
.eq('status', 'pending')
}
}
@@ -97,9 +97,22 @@ describe('tools/list payload size guard', () => {
// already-minimal ~24-token description. Headroom before the change was
// under 10 tokens, so even this smallest possible addition crossed;
// other descriptions are at their trimmed floor per the entries above.
// * 45.5K → 50K with the payroll gap-closure (8 tools): 3 reads
// (gnubok_get_employee, gnubok_get_payslip, gnubok_list_absence) + 5
// staged writes (update_payslip_line, register_absence,
// create_employee, update_employee, set_employee_opening_balances).
// create/update_employee carry the full employee-config inputSchema
// (~27 properties each: the whole point is agent-driveable payroll
// onboarding), and every staged write inlines STAGED_OPERATION_SCHEMA
// + _meta. Property descriptions trimmed to enum-only where the name
// is self-evident; the remainder is wire contract, not prose.
// * 50K → 51K with the vacation workflow (gap-closure Phase 3):
// gnubok_get_vacation_balance (ledger read) + gnubok_close_vacation_year
// (staged HIGH semesterårsavslut with STAGED_OPERATION_SCHEMA + _meta).
// Fortnox gap category E closed; both schemas already minimal.
// Long-term answer to growth is leaning harder on gnubok_search_tools: if this
// fires again, prefer trimming descriptions or making a tool opt-in via search
// before bumping further.
expect(approxTokens).toBeLessThan(45_500)
expect(approxTokens).toBeLessThan(51_000)
})
})
@@ -0,0 +1,261 @@
/**
* Tests for the payroll MCP read tools (payroll gap-closure 1.6):
* gnubok_get_employee, gnubok_get_payslip, gnubok_list_absence.
*
* PII rule under test: personnummer is ALWAYS masked on the MCP surface
* (LLM context is a leak surface); there is no full-value drill-in here,
* unlike v1's GET /employees/{id}.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { createQueuedMockSupabase } from '@/tests/helpers'
import { tools } from '../server'
const getEmployee = tools.find((t) => t.name === 'gnubok_get_employee')!
const getPayslip = tools.find((t) => t.name === 'gnubok_get_payslip')!
const listAbsence = tools.find((t) => t.name === 'gnubok_list_absence')!
const getVacationBalance = tools.find((t) => t.name === 'gnubok_get_vacation_balance')!
// Synthetic fixture personnummer (year 1900, zero suffix): must not look
// like production-format PII.
const SAMPLE_PERSONNUMMER = '190001010000'
beforeEach(() => {
vi.clearAllMocks()
})
describe('tool registration', () => {
it('all three read tools exist and are read-only', () => {
for (const tool of [getEmployee, getPayslip, listAbsence]) {
expect(tool).toBeDefined()
expect(tool.annotations?.readOnlyHint).toBe(true)
}
})
})
describe('gnubok_get_employee', () => {
const EMPLOYEE_ROW = {
id: 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb',
first_name: 'Anna',
last_name: 'Andersson',
personnummer: SAMPLE_PERSONNUMMER,
employment_type: 'employee',
employment_start: '2024-01-15',
employment_end: null,
employment_degree: 100,
salary_type: 'monthly',
monthly_salary: 35000,
hourly_rate: null,
tax_table_number: 33,
tax_column: 1,
tax_municipality: 'Stockholm',
is_sidoinkomst: false,
f_skatt_status: 'a_skatt',
f_skatt_verified_at: null,
jamkning_percentage: 15,
jamkning_valid_from: '2026-01-01',
jamkning_valid_to: null,
clearing_number: '6000',
bank_account_number: '12345678',
vacation_rule: 'procentregeln',
vacation_days_per_year: 25,
vacation_days_saved: 3,
semestertillagg_rate: 0.0043,
vaxa_stod_eligible: false,
vaxa_stod_start: null,
vaxa_stod_end: null,
default_dimensions: {},
is_active: true,
}
it('returns the grouped config with masked personnummer and qualified id', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: EMPLOYEE_ROW })
const result = (await getEmployee.execute(
{ employee_id: EMPLOYEE_ROW.id }, 'company-1', 'user-1', supabase as never, { type: 'api_key' },
)) as Record<string, unknown>
expect(result.employee_id).toBe(EMPLOYEE_ROW.id)
expect(result.personnummer_masked).toBe('19000101-XXXX')
expect((result.tax as Record<string, unknown>).jamkning_percentage).toBe(15)
expect((result.vacation as Record<string, unknown>).vacation_days_saved).toBe(3)
// Raw personnummer never leaks anywhere in the payload, and there is no
// bare `id` key (qualified ids only).
expect(JSON.stringify(result)).not.toContain(SAMPLE_PERSONNUMMER)
expect(result.id).toBeUndefined()
})
it('throws for an unknown employee', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: null })
await expect(
getEmployee.execute({ employee_id: 'nope' }, 'company-1', 'user-1', supabase as never, { type: 'api_key' }),
).rejects.toThrow(/not found/i)
})
})
describe('gnubok_get_payslip', () => {
const SRE_ROW = {
id: 'dddddddd-dddd-4ddd-8ddd-dddddddddddd',
gross_salary: 35000,
gross_deductions: 0,
benefit_values: 0,
taxable_income: 35000,
tax_withheld: 8200,
tax_withheld_override: null,
net_deductions: 0,
net_salary: 26800,
avgifter_rate: 0.3142,
avgifter_basis: 35000,
avgifter_amount: 10997,
avgifter_basis_override: null,
avgifter_amount_override: null,
avgifter_category: 'standard',
override_reason: null,
vacation_accrual: 4200,
vacation_accrual_avgifter: 1319.64,
ytd_gross: 70000,
ytd_tax: 16400,
ytd_net: 53600,
sick_days: 0,
vab_days: 0,
parental_days: 0,
vacation_days_taken: 0,
calculation_breakdown: { steps: [] },
employee: { first_name: 'Anna', last_name: 'Andersson', personnummer: SAMPLE_PERSONNUMMER },
line_items: [
{
id: 'eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeee',
item_type: 'monthly_salary',
description: 'Grundlön',
quantity: null,
unit_price: null,
amount: 35000,
is_taxable: true,
is_avgift_basis: true,
is_vacation_basis: true,
is_gross_deduction: false,
is_net_deduction: false,
account_number: '7210',
sort_order: 0,
},
],
}
it('returns the payslip with qualified line ids and masked personnummer', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: SRE_ROW })
const result = (await getPayslip.execute(
{ salary_run_id: 'run-1', employee_id: 'emp-1' },
'company-1', 'user-1', supabase as never, { type: 'api_key' },
)) as {
salary_run_employee_id: string
employee_name: string
personnummer_masked: string
amounts: Record<string, number>
line_items: Array<Record<string, unknown>>
}
expect(result.salary_run_employee_id).toBe(SRE_ROW.id)
expect(result.employee_name).toBe('Anna Andersson')
expect(result.personnummer_masked).toBe('19000101-XXXX')
expect(result.amounts.gross_salary).toBe(35000)
expect(result.line_items).toHaveLength(1)
expect(result.line_items[0].salary_line_item_id).toBe('eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeee')
expect(result.line_items[0].id).toBeUndefined()
expect(JSON.stringify(result)).not.toContain(SAMPLE_PERSONNUMMER)
})
it('throws when the employee is not in the run', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: null })
await expect(
getPayslip.execute(
{ salary_run_id: 'run-1', employee_id: 'emp-x' },
'company-1', 'user-1', supabase as never, { type: 'api_key' },
),
).rejects.toThrow(/not found/i)
})
})
describe('gnubok_get_vacation_balance', () => {
it('returns the open balance with qualified id and remaining days', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({
data: {
id: 'vb-1',
employee_id: 'emp-1',
vacation_year_start: '2026-01-01',
entitled_days: 25,
accrued_days: 0,
taken_days: 10,
saved_days: { '2025': 5 },
forced_payout_days: 0,
},
})
const result = (await getVacationBalance.execute(
{ employee_id: 'emp-1' }, 'company-1', 'user-1', supabase as never, { type: 'api_key' },
)) as Record<string, unknown>
expect(result.employee_vacation_balance_id).toBe('vb-1')
expect(result.remaining_days).toBe(15)
expect(result.saved_days).toEqual({ '2025': 5 })
expect(result.id).toBeUndefined()
})
it('throws before the ledger has seeded', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: null })
await expect(
getVacationBalance.execute(
{ employee_id: 'emp-x' }, 'company-1', 'user-1', supabase as never, { type: 'api_key' },
),
).rejects.toThrow(/No vacation balance/)
})
})
describe('gnubok_list_absence', () => {
it('lists absence days with qualified ids', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
// Service: employees existence check, then the range query.
enqueue({ data: { id: 'emp-1' } })
enqueue({
data: [
{
id: 'ffffffff-ffff-4fff-8fff-ffffffffffff',
absence_date: '2026-03-03',
absence_type: 'sick',
hours: 8,
notes: null,
salary_run_employee_id: null,
created_at: '2026-03-03T08:00:00Z',
updated_at: '2026-03-03T08:00:00Z',
},
],
})
const result = (await listAbsence.execute(
{ employee_id: 'emp-1', from: '2026-03-01', to: '2026-03-31' },
'company-1', 'user-1', supabase as never, { type: 'api_key' },
)) as { absence_days: Array<Record<string, unknown>>; count: number }
expect(result.count).toBe(1)
expect(result.absence_days[0].salary_absence_day_id).toBe('ffffffff-ffff-4fff-8fff-ffffffffffff')
expect(result.absence_days[0].absence_type).toBe('sick')
expect(result.absence_days[0].id).toBeUndefined()
})
it('throws for an unknown employee', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: null })
await expect(
listAbsence.execute(
{ employee_id: 'emp-x', from: '2026-03-01', to: '2026-03-31' },
'company-1', 'user-1', supabase as never, { type: 'api_key' },
),
).rejects.toThrow(/not found/i)
})
})
@@ -0,0 +1,430 @@
/**
* Staging tests for the payroll write MCP tools (payroll gap-closure 1.7):
* gnubok_update_payslip_line + gnubok_register_absence.
*
* Both STAGE a pending_operation (no direct writes); the executors in
* lib/pending-operations/commit.ts are covered separately in
* lib/pending-operations/__tests__/payroll-executors.test.ts.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { createQueuedMockSupabase } from '@/tests/helpers'
const mockPreviewClose = vi.fn()
vi.mock('@/lib/salary/semesterberedning', () => ({
previewVacationYearClose: (...a: unknown[]) => mockPreviewClose(...a),
commitVacationYearClose: vi.fn(),
}))
import { tools } from '../server'
import { decryptPersonnummer } from '@/lib/salary/personnummer'
const updatePayslipLine = tools.find((t) => t.name === 'gnubok_update_payslip_line')!
const registerAbsence = tools.find((t) => t.name === 'gnubok_register_absence')!
const createEmployee = tools.find((t) => t.name === 'gnubok_create_employee')!
const updateEmployee = tools.find((t) => t.name === 'gnubok_update_employee')!
const setOpeningBalances = tools.find((t) => t.name === 'gnubok_set_employee_opening_balances')!
const closeVacationYear = tools.find((t) => t.name === 'gnubok_close_vacation_year')!
// Synthetic fixture personnummer (year 1900, zero suffix).
const SAMPLE_PERSONNUMMER = '190001010000'
/** Flexible per-table mock that also captures insert payloads, so tests can
* assert what stagePendingOperation persisted to pending_operations. */
function makeCapturingSupabase(byTable: Record<string, { data?: unknown; error?: unknown } | Array<{ data?: unknown; error?: unknown }>>) {
const queues = new Map<string, Array<{ data?: unknown; error?: unknown }>>()
for (const [t, val] of Object.entries(byTable)) {
queues.set(t, Array.isArray(val) ? [...val] : [val])
}
const inserts: Record<string, unknown[]> = {}
const buildChain = (table: string): unknown => {
const handler: ProxyHandler<object> = {
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({ count: null, ...next })
}
}
return (...callArgs: unknown[]) => {
if (prop === 'insert') {
;(inserts[table] ??= []).push(callArgs[0])
}
return buildChain(table)
}
},
}
return new Proxy({}, handler)
}
return { inserts, from: vi.fn((table: string) => buildChain(table)) }
}
beforeEach(() => {
vi.clearAllMocks()
})
describe('gnubok_update_payslip_line', () => {
const LINE_ROW = {
id: 'line-1',
salary_run_employee_id: 'sre-1',
company_id: 'company-1',
item_type: 'bonus',
description: 'Kvartalsbonus',
quantity: null,
unit_price: null,
amount: 5000,
is_taxable: true,
is_avgift_basis: true,
is_vacation_basis: true,
is_gross_deduction: false,
is_net_deduction: false,
account_number: '7210',
sort_order: 0,
created_at: '',
updated_at: '',
salary_run_employee: { salary_run_id: 'run-1' },
}
it('stages with a merged preview and a recalculate next-hint', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: { id: 'run-1', status: 'draft' } }) // service draft gate
enqueue({ data: LINE_ROW }) // service loadLineInRun (dry-run preflight)
enqueue({ data: { payment_date: '2026-03-25' } }) // run payment_date for period check
enqueue({ data: null }) // resolvePeriodStatusForDate: company_settings
enqueue({ data: null }) // resolvePeriodStatusForDate: fiscal_periods
enqueue({ data: { id: 'op-1' }, error: null }) // pending_operations insert
const result = (await updatePayslipLine.execute(
{ salary_run_id: 'run-1', salary_line_item_id: 'line-1', amount: 5500 },
'company-1', 'user-1', supabase as never, { type: 'agent_chat' },
)) as {
staged: boolean
risk_level: string
preview: Record<string, unknown>
next?: { tool: string }
}
expect(result.staged).toBe(true)
expect(result.risk_level).toBe('medium')
expect(result.preview.new_amount).toBe(5500)
expect(result.preview.salary_line_item_id).toBe('line-1')
expect(result.next?.tool).toBe('gnubok_calculate_salary_run')
})
it('throws when the run has advanced past draft (preflight)', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: { id: 'run-1', status: 'booked' } })
await expect(
updatePayslipLine.execute(
{ salary_run_id: 'run-1', salary_line_item_id: 'line-1', amount: 5500 },
'company-1', 'user-1', supabase as never, { type: 'agent_chat' },
),
).rejects.toThrow(/SALARY_RUN_LINE_NOT_DRAFT/)
})
it('rejects an empty patch', async () => {
const { supabase } = createQueuedMockSupabase()
await expect(
updatePayslipLine.execute(
{ salary_run_id: 'run-1', salary_line_item_id: 'line-1' },
'company-1', 'user-1', supabase as never, { type: 'agent_chat' },
),
).rejects.toThrow(/At least one/)
})
})
describe('gnubok_register_absence', () => {
it('stages with day-count preview and dateForPeriodCheck', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: { id: 'emp-1' } }) // service assertEmployee (dry-run preflight)
enqueue({ data: { first_name: 'Anna', last_name: 'Andersson' } }) // name for title/preview
enqueue({ data: null }) // resolvePeriodStatusForDate: company_settings
enqueue({ data: null }) // resolvePeriodStatusForDate: fiscal_periods
enqueue({ data: { id: 'op-2' }, error: null }) // pending_operations insert
const result = (await registerAbsence.execute(
{ employee_id: 'emp-1', from: '2026-03-02', to: '2026-03-06', absence_type: 'sick' },
'company-1', 'user-1', supabase as never, { type: 'agent_chat' },
)) as {
staged: boolean
risk_level: string
preview: Record<string, unknown>
}
expect(result.staged).toBe(true)
expect(result.risk_level).toBe('medium')
// 2026-03-02 (Mon) .. 2026-03-06 (Fri) = 5 weekdays.
expect(result.preview.day_count).toBe(5)
expect(result.preview.employee_name).toBe('Anna Andersson')
expect((result.preview.dates_sample as string[])[0]).toBe('2026-03-02')
})
it('throws for a range beyond the cap', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: { id: 'emp-1' } })
await expect(
registerAbsence.execute(
{ employee_id: 'emp-1', from: '2026-01-01', to: '2026-12-31', absence_type: 'sick' },
'company-1', 'user-1', supabase as never, { type: 'agent_chat' },
),
).rejects.toThrow(/ABSENCE_RANGE_TOO_LARGE/)
})
it('throws for an unknown employee', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: null })
await expect(
registerAbsence.execute(
{ employee_id: 'emp-x', from: '2026-03-02', to: '2026-03-06', absence_type: 'sick' },
'company-1', 'user-1', supabase as never, { type: 'agent_chat' },
),
).rejects.toThrow(/EMPLOYEE_NOT_FOUND/)
})
})
describe('gnubok_create_employee', () => {
const validArgs = {
first_name: 'Anna',
last_name: 'Andersson',
personnummer: SAMPLE_PERSONNUMMER,
employment_start: '2026-01-15',
salary_type: 'monthly',
monthly_salary: 35000,
tax_table_number: 33,
tax_municipality: 'Stockholm',
}
it('encrypts personnummer at staging: params never carry the plaintext', async () => {
const supabaseMock = makeCapturingSupabase({
company_settings: { data: { entity_type: 'ab' } }, // entity-type preflight
fiscal_periods: { data: null },
pending_operations: { data: { id: 'op-3' }, error: null },
})
const result = (await createEmployee.execute(
validArgs, 'company-1', 'user-1', supabaseMock as never, { type: 'agent_chat' },
)) as { staged: boolean; risk_level: string; preview: Record<string, unknown> }
expect(result.staged).toBe(true)
expect(result.risk_level).toBe('medium')
expect(result.preview.personnummer_masked).toBe('19000101-XXXX')
// The staged params must carry the ENCRYPTED value + last4, never the
// raw personnummer: pending_operations is a persisted table.
const inserted = supabaseMock.inserts.pending_operations?.[0] as {
params: Record<string, unknown>
title: string
preview_data: Record<string, unknown>
}
expect(inserted).toBeDefined()
expect(inserted.params.personnummer).toBeUndefined()
expect(inserted.params.personnummer_last4).toBe('0000')
expect(decryptPersonnummer(inserted.params.personnummer_encrypted as string)).toBe(SAMPLE_PERSONNUMMER)
expect(JSON.stringify(inserted.params)).not.toContain(SAMPLE_PERSONNUMMER)
expect(JSON.stringify(inserted.preview_data)).not.toContain(SAMPLE_PERSONNUMMER)
expect(inserted.title).not.toContain(SAMPLE_PERSONNUMMER)
})
it('rejects invalid input via CreateEmployeeSchema (missing salary)', async () => {
const { supabase } = createQueuedMockSupabase()
await expect(
createEmployee.execute(
{ ...validArgs, monthly_salary: undefined },
'company-1', 'user-1', supabase as never, { type: 'agent_chat' },
),
).rejects.toThrow(/Invalid employee/)
})
it('blocks EF owners on payroll (entity-type preflight)', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: { entity_type: 'ef' } }) // company_settings
await expect(
createEmployee.execute(
{ ...validArgs, employment_type: 'company_owner' },
'company-1', 'user-1', supabase as never, { type: 'agent_chat' },
),
).rejects.toThrow()
})
})
describe('gnubok_update_employee', () => {
const EXISTING = {
id: 'emp-1',
first_name: 'Anna',
last_name: 'Andersson',
monthly_salary: 35000,
clearing_number: '6000',
bank_account_number: '12345678',
}
it('stages with a field-level changes preview and bank-change flag', async () => {
const supabaseMock = makeCapturingSupabase({
employees: { data: EXISTING },
fiscal_periods: { data: null },
company_settings: { data: null },
pending_operations: { data: { id: 'op-4' }, error: null },
})
const result = (await updateEmployee.execute(
{ employee_id: 'emp-1', monthly_salary: 38000, bank_account_number: '87654321' },
'company-1', 'user-1', supabaseMock as never, { type: 'agent_chat' },
)) as { staged: boolean; preview: { changes: Array<{ field: string; from: unknown; to: unknown }>; bank_details_changed: boolean } }
expect(result.staged).toBe(true)
expect(result.preview.bank_details_changed).toBe(true)
const salaryChange = result.preview.changes.find((c) => c.field === 'monthly_salary')
expect(salaryChange).toEqual({ field: 'monthly_salary', from: 35000, to: 38000 })
})
it('rejects personnummer changes at the tool boundary', async () => {
const { supabase } = createQueuedMockSupabase()
await expect(
updateEmployee.execute(
{ employee_id: 'emp-1', personnummer: '190001029999' },
'company-1', 'user-1', supabase as never, { type: 'agent_chat' },
),
).rejects.toThrow(/immutable/)
})
it('throws for an unknown employee', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: null })
await expect(
updateEmployee.execute(
{ employee_id: 'emp-x', monthly_salary: 38000 },
'company-1', 'user-1', supabase as never, { type: 'agent_chat' },
),
).rejects.toThrow(/not found/i)
})
})
describe('gnubok_set_employee_opening_balances', () => {
const EMPLOYEE_ID = 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb'
const CURRENT_YEAR = new Date().getFullYear()
const validItem = {
employee_id: EMPLOYEE_ID,
cutover_date: `${CURRENT_YEAR}-07-01`,
ytd_gross: 210000,
ytd_tax: 48000,
ytd_net: 162000,
vacation_paid_days_remaining: 12.5,
vacation_saved_days_by_year: { [`${CURRENT_YEAR - 1}`]: 5 },
opening_semester_liability: 42000,
opening_semester_liability_avgifter: 13196.4,
karens_periods_adjustment: 1,
}
it('stages after preflighting the whole batch (happy path)', async () => {
const supabaseMock = makeCapturingSupabase({
employees: {
data: [{ id: EMPLOYEE_ID, employment_start: '2024-01-15', is_active: true }],
},
salary_run_employees: { data: [] },
pending_operations: { data: { id: 'op-5' }, error: null },
})
const result = (await setOpeningBalances.execute(
{ items: [validItem] },
'company-1', 'user-1', supabaseMock as never, { type: 'agent_chat' },
)) as { staged: boolean; risk_level: string; preview: Record<string, unknown> }
expect(result.staged).toBe(true)
expect(result.risk_level).toBe('medium')
expect(result.preview.employee_count).toBe(1)
expect(result.preview.total_ytd_gross).toBe(210000)
// The write table is never touched at staging.
const inserted = supabaseMock.inserts.employee_opening_balances
expect(inserted).toBeUndefined()
})
it('throws with the per-item error list when locked', async () => {
const supabaseMock = makeCapturingSupabase({
employees: {
data: [{ id: EMPLOYEE_ID, employment_start: '2024-01-15', is_active: true }],
},
salary_run_employees: {
data: [{ employee_id: EMPLOYEE_ID, salary_run: { id: 'run-1', status: 'booked' } }],
},
})
await expect(
setOpeningBalances.execute(
{ items: [validItem] },
'company-1', 'user-1', supabaseMock as never, { type: 'agent_chat' },
),
).rejects.toThrow(/Locked by booked salary run/)
})
it('rejects invalid items via the shared Zod schema', async () => {
const { supabase } = createQueuedMockSupabase()
await expect(
setOpeningBalances.execute(
{ items: [{ ...validItem, cutover_date: `${CURRENT_YEAR}-07-15` }] },
'company-1', 'user-1', supabase as never, { type: 'agent_chat' },
),
).rejects.toThrow(/Invalid opening balances/)
})
})
describe('gnubok_close_vacation_year', () => {
const SAMPLE_REPORT = {
vacation_year_start: '2025-01-01',
vacation_year_end: '2025-12-31',
next_year_start: '2026-01-01',
basis: 'calendar',
rows: [
{
employee_id: 'emp-1',
employee_name: 'Anna Andersson',
saveable_days: 5,
expiring_days: 2,
},
],
sek: {
computed_liability: 18690.84,
computed_avgifter: 5872.66,
booked_2920: 10000,
booked_2940: 3142,
drift_2920: 8690.84,
drift_2940: 2730.66,
adjustment_needed: true,
},
adjustment_date: '2025-12-31',
}
it('stages HIGH risk with the review report in the preview', async () => {
mockPreviewClose.mockResolvedValue({ ok: true, data: SAMPLE_REPORT })
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: { salary_vacation_year_basis: 'calendar' } }) // basis lookup (year default)
enqueue({ data: null }) // resolvePeriodStatusForDate: company_settings
enqueue({ data: null }) // resolvePeriodStatusForDate: fiscal_periods
enqueue({ data: { id: 'op-6' }, error: null }) // pending_operations insert
const result = (await closeVacationYear.execute(
{}, 'company-1', 'user-1', supabase as never, { type: 'agent_chat' },
)) as { staged: boolean; risk_level: string; preview: Record<string, unknown> }
expect(result.staged).toBe(true)
expect(result.risk_level).toBe('high')
expect(result.preview.employee_count).toBe(1)
expect(result.preview.drift_2920).toBe(8690.84)
expect(result.preview.adjustment_needed).toBe(true)
})
it('fails staging when the preview refuses (already closed)', async () => {
mockPreviewClose.mockResolvedValue({ ok: false, code: 'VACATION_YEAR_ALREADY_CLOSED' })
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: { salary_vacation_year_basis: 'calendar' } })
await expect(
closeVacationYear.execute(
{ vacation_year_start: '2025-01-01' },
'company-1', 'user-1', supabase as never, { type: 'agent_chat' },
),
).rejects.toThrow(/VACATION_YEAR_ALREADY_CLOSED/)
})
})
@@ -0,0 +1,80 @@
import { describe, it, expect } from 'vitest'
import { assertNoPlaintextPersonnummer } from '../staging-pii-guard'
describe('assertNoPlaintextPersonnummer', () => {
it('throws on a top-level plaintext personnummer key in params', () => {
expect(() =>
assertNoPlaintextPersonnummer(
{ first_name: 'Test', personnummer: '198501011234' },
'params',
),
).toThrow(/params contains plaintext PII key "personnummer"/)
})
it('throws when the key is nested inside a patch object (update-style staging)', () => {
expect(() =>
assertNoPlaintextPersonnummer(
{ employee_id: 'e-1', patch: { personnummer: '198501011234' } },
'params',
),
).toThrow(/plaintext PII key "personnummer"/)
})
it('throws when the key hides inside an array of row objects', () => {
expect(() =>
assertNoPlaintextPersonnummer(
{ rows: [{ amount: 100 }, { ssn: '850101-1234' }] },
'preview_data',
),
).toThrow(/preview_data contains plaintext PII key "ssn"/)
})
it('allows the encrypted/masked derivatives that create_employee stages', () => {
expect(() =>
assertNoPlaintextPersonnummer(
{
first_name: 'Test',
personnummer_encrypted: 'v1:abcdef',
personnummer_last4: '1234',
personnummer_masked: '850101-****',
},
'params',
),
).not.toThrow()
})
it('allows ordinary payloads: UUIDs, dates, aggregates, names', () => {
expect(() =>
assertNoPlaintextPersonnummer(
{
employee_id: 'b7f8d1a2-0000-0000-0000-000000000000',
from: '2026-07-01',
to: '2026-07-05',
absence_type: 'sick',
hours_per_day: 8,
notes: null,
},
'params',
),
).not.toThrow()
})
it('fails closed when the payload nests past the scan depth (PII could hide below)', () => {
// Before the fail-closed change, the scanner silently accepted anything
// past MAX_DEPTH, so this personnummer would have been persisted.
const payload = {
l1: { l2: { l3: { l4: { l5: { l6: { l7: { personnummer: '198501011234' } } } } } } },
}
expect(() => assertNoPlaintextPersonnummer(payload, 'params')).toThrow(
/cannot be scanned for plaintext PII/,
)
})
it('does not value-match: an EF org number equal to a personnummer passes under a business key', () => {
// For enskild firma the org number IS the owner's personnummer; the
// guard is key-based so legitimate counterparty data stays stageable.
expect(() =>
assertNoPlaintextPersonnummer({ org_number: '850101-1234' }, 'params'),
).not.toThrow()
})
})
+788
View File
@@ -59,6 +59,7 @@ import {
} from '@/lib/api/idempotency'
import { toToolError, type NextActionHint } from './tool-result'
import { findSupplierCandidates } from './supplier-candidates'
import { assertNoPlaintextPersonnummer } from './staging-pii-guard'
import { generateBalanceSheet } from '@/lib/reports/balance-sheet'
import { generateGeneralLedger } from '@/lib/reports/general-ledger'
import { decryptPersonnummer, maskPersonnummer } from '@/lib/salary/personnummer'
@@ -291,6 +292,12 @@ async function stagePendingOperation(
period_status?: PeriodStatusForDate
next?: NextActionHint
}> {
// PII chokepoint (ISO 27001 A.8.11): no staged payload may persist a
// plaintext personnummer. Enforced here so every current and future
// staging tool inherits the rule, not just the ones that remembered it.
assertNoPlaintextPersonnummer(params, 'params')
assertNoPlaintextPersonnummer(previewData, 'preview_data')
const riskLevel = getRiskLevel(operationType)
const branding = getBranding().appName.toLowerCase()
@@ -9041,6 +9048,787 @@ export const tools: McpTool[] = [
}
},
},
{
name: 'gnubok_get_employee',
title: 'Get Employee',
description: 'Get one employee\'s full payroll config: salary, tax table/column, jamkning, F-skatt, vacation rule, vaxa-stod, bank details, dimensions. Personnummer masked. Use after gnubok_list_employees to drill into one employee before payroll work.',
inputSchema: {
type: 'object',
additionalProperties: false,
properties: {
employee_id: { type: 'string', description: 'UUID of the employee' },
},
required: ['employee_id'],
},
outputSchema: {
type: 'object',
additionalProperties: false,
properties: {
employee_id: { type: 'string' },
first_name: { type: 'string' },
last_name: { type: 'string' },
personnummer_masked: { type: 'string' },
employment: { type: 'object', description: 'Type, start/end, degree' },
pay: { type: 'object', description: 'Salary type + amounts' },
tax: { type: 'object', description: 'Table, column, municipality, jamkning, F-skatt, sidoinkomst' },
vacation: { type: 'object', description: 'Rule, days per year, saved days, tillagg rate' },
vaxa_stod: { type: 'object', description: 'Eligibility window' },
bank: { type: 'object', description: 'Clearing + account (payment routing)' },
default_dimensions: { type: 'object' },
is_active: { type: 'boolean' },
},
required: ['employee_id', 'first_name', 'last_name', 'personnummer_masked', 'is_active'],
},
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
async execute(args, companyId, _userId, supabase) {
const employeeId = args.employee_id as string
if (!employeeId) throw new Error('employee_id is required')
const { data: e, error } = await supabase
.from('employees')
.select(
'id, first_name, last_name, personnummer, employment_type, employment_start, employment_end, employment_degree, hours_per_week, workdays_per_week, salary_type, monthly_salary, hourly_rate, tax_table_number, tax_column, tax_municipality, is_sidoinkomst, f_skatt_status, f_skatt_verified_at, jamkning_percentage, jamkning_valid_from, jamkning_valid_to, clearing_number, bank_account_number, vacation_rule, vacation_days_per_year, vacation_days_saved, semestertillagg_rate, vaxa_stod_eligible, vaxa_stod_start, vaxa_stod_end, default_dimensions, is_active',
)
.eq('id', employeeId)
.eq('company_id', companyId)
.maybeSingle()
if (error) throw new Error(`Database error: ${error.message}`)
if (!e) throw new Error('Employee not found')
// LLM context is a leak surface: personnummer is ALWAYS masked on MCP,
// there is no full-value drill-in on this surface.
return {
employee_id: e.id,
first_name: e.first_name,
last_name: e.last_name,
personnummer_masked: maskPersonnummer(decryptPersonnummer(e.personnummer as string)),
employment: {
employment_type: e.employment_type,
employment_start: e.employment_start,
employment_end: e.employment_end,
employment_degree: e.employment_degree,
hours_per_week: e.hours_per_week,
workdays_per_week: e.workdays_per_week,
},
pay: {
salary_type: e.salary_type,
monthly_salary: e.monthly_salary,
hourly_rate: e.hourly_rate,
},
tax: {
tax_table_number: e.tax_table_number,
tax_column: e.tax_column,
tax_municipality: e.tax_municipality,
is_sidoinkomst: e.is_sidoinkomst,
f_skatt_status: e.f_skatt_status,
f_skatt_verified_at: e.f_skatt_verified_at,
jamkning_percentage: e.jamkning_percentage,
jamkning_valid_from: e.jamkning_valid_from,
jamkning_valid_to: e.jamkning_valid_to,
},
vacation: {
vacation_rule: e.vacation_rule,
vacation_days_per_year: e.vacation_days_per_year,
vacation_days_saved: e.vacation_days_saved,
semestertillagg_rate: e.semestertillagg_rate,
},
vaxa_stod: {
eligible: e.vaxa_stod_eligible,
start: e.vaxa_stod_start,
end: e.vaxa_stod_end,
},
bank: {
clearing_number: e.clearing_number,
bank_account_number: e.bank_account_number,
},
default_dimensions: e.default_dimensions ?? {},
is_active: e.is_active,
}
},
},
{
name: 'gnubok_get_payslip',
title: 'Get Payslip (Lönebesked)',
description: 'Get one employee\'s payslip in a salary run: gross, tax, avgifter, net, every line item and the step-by-step calculation breakdown. Personnummer masked. Use after gnubok_get_salary_run to verify how one employee\'s pay was computed.',
inputSchema: {
type: 'object',
additionalProperties: false,
properties: {
salary_run_id: { type: 'string', description: 'UUID of the salary run' },
employee_id: { type: 'string', description: 'UUID of the employee' },
},
required: ['salary_run_id', 'employee_id'],
},
outputSchema: {
type: 'object',
additionalProperties: false,
properties: {
salary_run_employee_id: { type: 'string' },
salary_run_id: { type: 'string' },
employee_id: { type: 'string' },
employee_name: { type: 'string' },
personnummer_masked: { type: 'string' },
amounts: { type: 'object', description: 'Gross, taxable, tax, net, avgifter, vacation accrual, YTD' },
overrides: { type: 'object', description: 'Manual tax/avgifter overrides + reason (effective = override ?? calculated)' },
absence_days: { type: 'object', description: 'Sick/vab/parental/vacation day counts' },
line_items: { type: 'array', items: { type: 'object' }, description: 'Each with salary_line_item_id' },
calculation_breakdown: { type: 'object', description: 'Step-by-step engine breakdown; null until calculated' },
},
required: ['salary_run_employee_id', 'salary_run_id', 'employee_id', 'employee_name', 'personnummer_masked', 'amounts', 'line_items'],
},
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
async execute(args, companyId, _userId, supabase) {
const salaryRunId = args.salary_run_id as string
const employeeId = args.employee_id as string
if (!salaryRunId || !employeeId) throw new Error('salary_run_id and employee_id are required')
const { data: sre, error } = await supabase
.from('salary_run_employees')
.select(
'*, employee:employees(first_name, last_name, personnummer), line_items:salary_line_items(id, item_type, description, quantity, unit_price, amount, is_taxable, is_avgift_basis, is_vacation_basis, is_gross_deduction, is_net_deduction, account_number, sort_order)',
)
.eq('salary_run_id', salaryRunId)
.eq('employee_id', employeeId)
.eq('company_id', companyId)
.maybeSingle()
if (error) throw new Error(`Database error: ${error.message}`)
if (!sre) throw new Error('Employee not found in this salary run')
const emp = sre.employee as { first_name: string; last_name: string; personnummer: string } | null
const lineItems = ((sre.line_items ?? []) as Array<Record<string, unknown>>)
.slice()
.sort((a, b) => ((a.sort_order as number) ?? 0) - ((b.sort_order as number) ?? 0))
.map(({ id, ...rest }) => ({ salary_line_item_id: id, ...rest }))
return {
salary_run_employee_id: sre.id,
salary_run_id: salaryRunId,
employee_id: employeeId,
employee_name: emp ? `${emp.first_name} ${emp.last_name}` : '',
personnummer_masked: emp ? maskPersonnummer(decryptPersonnummer(emp.personnummer)) : '',
amounts: {
gross_salary: sre.gross_salary,
gross_deductions: sre.gross_deductions,
benefit_values: sre.benefit_values,
taxable_income: sre.taxable_income,
tax_withheld: sre.tax_withheld,
net_deductions: sre.net_deductions,
net_salary: sre.net_salary,
avgifter_rate: sre.avgifter_rate,
avgifter_basis: sre.avgifter_basis,
avgifter_amount: sre.avgifter_amount,
avgifter_category: sre.avgifter_category,
vacation_accrual: sre.vacation_accrual,
vacation_accrual_avgifter: sre.vacation_accrual_avgifter,
ytd_gross: sre.ytd_gross,
ytd_tax: sre.ytd_tax,
ytd_net: sre.ytd_net,
},
overrides: {
tax_withheld_override: sre.tax_withheld_override,
avgifter_amount_override: sre.avgifter_amount_override,
avgifter_basis_override: sre.avgifter_basis_override,
override_reason: sre.override_reason,
},
absence_days: {
sick_days: sre.sick_days,
vab_days: sre.vab_days,
parental_days: sre.parental_days,
vacation_days_taken: sre.vacation_days_taken,
},
line_items: lineItems,
calculation_breakdown: sre.calculation_breakdown ?? null,
}
},
},
{
name: 'gnubok_list_absence',
title: 'List Absence (Frånvaro)',
description: 'List an employee\'s registered absence days (sick, vab, parental, ...) in a date range, max 92 days. These per-day rows drive karensavdrag and sjuklön at calculation time. Use before gnubok_register_absence to see what is already registered.',
inputSchema: {
type: 'object',
additionalProperties: false,
properties: {
employee_id: { type: 'string', description: 'UUID of the employee' },
from: { type: 'string', description: 'Range start (YYYY-MM-DD, inclusive)' },
to: { type: 'string', description: 'Range end (YYYY-MM-DD, inclusive, max 92 days)' },
absence_type: {
type: 'string',
enum: ['sick', 'vab', 'parental', 'pregnancy', 'care_relative', 'study', 'unpaid_leave', 'other_leave'],
description: 'Optional filter',
},
},
required: ['employee_id', 'from', 'to'],
},
outputSchema: {
type: 'object',
additionalProperties: false,
properties: {
absence_days: { type: 'array', items: { type: 'object' }, description: 'Each with salary_absence_day_id' },
count: { type: 'number' },
},
required: ['absence_days', 'count'],
},
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
async execute(args, companyId, _userId, supabase) {
const { employee_id, from, to, absence_type } = args as {
employee_id: string; from: string; to: string; absence_type?: string
}
if (!employee_id || !from || !to) throw new Error('employee_id, from and to are required')
const { listAbsenceDays } = await import('@/lib/salary/absence')
const result = await listAbsenceDays(supabase, {
companyId,
employeeId: employee_id,
from,
to,
absenceType: absence_type,
})
if (!result.ok) throw new Error(result.code === 'EMPLOYEE_NOT_FOUND' ? 'Employee not found' : `Failed to list absence: ${result.code}`)
const days = result.data.map(({ id, ...rest }) => ({ salary_absence_day_id: id, ...rest }))
return { absence_days: days, count: days.length }
},
},
{
name: 'gnubok_update_payslip_line',
title: 'Update Payslip Line',
description: 'Stage an edit to one payslip line (amount, description, quantity, unit price) in a DRAFT salary run. Commit via gnubok_approve_pending_operation, then re-run gnubok_calculate_salary_run: line edits never recompute tax by themselves.',
inputSchema: {
type: 'object',
additionalProperties: false,
properties: {
salary_run_id: { type: 'string', description: 'UUID of the salary run (must be draft)' },
salary_line_item_id: { type: 'string', description: 'UUID of the payslip line to edit' },
amount: { type: 'number', description: 'New amount (SEK)' },
description: { type: 'string', description: 'New line description' },
quantity: { type: 'number', description: 'New quantity' },
unit_price: { type: 'number', description: 'New unit price (SEK)' },
},
required: ['salary_run_id', 'salary_line_item_id'],
},
outputSchema: STAGED_OPERATION_SCHEMA,
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
async execute(args, companyId, userId, supabase, actor) {
const { salary_run_id, salary_line_item_id, amount, description, quantity, unit_price } = args as {
salary_run_id: string; salary_line_item_id: string
amount?: number; description?: string; quantity?: number; unit_price?: number
}
if (!salary_run_id || !salary_line_item_id) {
throw new Error('salary_run_id and salary_line_item_id are required')
}
const patch: Record<string, unknown> = {}
if (amount !== undefined) patch.amount = amount
if (description !== undefined) patch.description = description
if (quantity !== undefined) patch.quantity = quantity
if (unit_price !== undefined) patch.unit_price = unit_price
if (Object.keys(patch).length === 0) {
throw new Error('At least one of amount, description, quantity, unit_price is required')
}
// Preflight via the shared service in dry-run: verifies draft status and
// that the line belongs to this run, and yields the merged row for the
// preview. No writes here: the commit path re-runs the service for real.
const { updatePayslipLine } = await import('@/lib/salary/payslip-lines')
const preflight = await updatePayslipLine(supabase, {
companyId,
salaryRunId: salary_run_id,
lineId: salary_line_item_id,
patch: patch as never,
dryRun: true,
})
if (!preflight.ok) {
throw new Error(`Cannot update payslip line: ${preflight.code}`)
}
const merged = preflight.data
const { data: run } = await supabase
.from('salary_runs')
.select('payment_date')
.eq('id', salary_run_id)
.eq('company_id', companyId)
.maybeSingle()
return stagePendingOperation(
supabase, companyId, userId, 'update_payslip_line',
`Uppdatera lönebeskedsrad: ${merged.description}`,
{ salary_run_id, salary_line_item_id, patch },
{
salary_run_id,
salary_line_item_id,
item_type: merged.item_type,
description: merged.description,
new_amount: merged.amount,
changes: patch,
},
actor,
{
description: 'After approval, recalculate the run so tax and totals reflect the edit.',
tool: 'gnubok_calculate_salary_run',
},
run?.payment_date ? { dateForPeriodCheck: run.payment_date as string } : {},
)
},
},
{
name: 'gnubok_register_absence',
title: 'Register Absence (Frånvaro)',
description: 'Stage absence registration (sick, vab, parental, ...) for an employee over a date range, max 92 days, weekends skipped unless included. Commit via gnubok_approve_pending_operation; recalculate any open salary run afterwards.',
inputSchema: {
type: 'object',
additionalProperties: false,
properties: {
employee_id: { type: 'string', description: 'UUID of the employee' },
from: { type: 'string', description: 'Range start (YYYY-MM-DD, inclusive)' },
to: { type: 'string', description: 'Range end (YYYY-MM-DD, inclusive; single day = same as from)' },
absence_type: {
type: 'string',
enum: ['sick', 'vab', 'parental', 'pregnancy', 'care_relative', 'study', 'unpaid_leave', 'other_leave'],
description: 'Absence type',
},
hours_per_day: { type: 'number', description: 'Hours per day (default 8; use e.g. 4 for half days)' },
notes: { type: 'string', description: 'Optional note' },
include_weekends: { type: 'boolean', description: 'Also register Saturday/Sunday (default false)' },
},
required: ['employee_id', 'from', 'to', 'absence_type'],
},
outputSchema: STAGED_OPERATION_SCHEMA,
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
async execute(args, companyId, userId, supabase, actor) {
const { employee_id, from, to, absence_type, hours_per_day, notes, include_weekends } = args as {
employee_id: string; from: string; to: string; absence_type: string
hours_per_day?: number; notes?: string; include_weekends?: boolean
}
if (!employee_id || !from || !to || !absence_type) {
throw new Error('employee_id, from, to and absence_type are required')
}
if (!/^\d{4}-\d{2}-\d{2}$/.test(from) || !/^\d{4}-\d{2}-\d{2}$/.test(to)) {
throw new Error('from and to must be YYYY-MM-DD')
}
// Preflight in dry-run: verifies the employee and expands the range so
// the approver sees exactly which days would be written.
const { upsertAbsenceRange } = await import('@/lib/salary/absence')
const preflight = await upsertAbsenceRange(supabase, {
companyId,
employeeId: employee_id,
from,
to,
absenceType: absence_type,
hoursPerDay: hours_per_day,
notes: notes ?? null,
includeWeekends: include_weekends,
dryRun: true,
})
if (!preflight.ok) {
throw new Error(`Cannot register absence: ${preflight.code}`)
}
const { data: emp } = await supabase
.from('employees')
.select('first_name, last_name')
.eq('id', employee_id)
.eq('company_id', companyId)
.maybeSingle()
const employeeName = emp ? `${emp.first_name} ${emp.last_name}` : employee_id
return stagePendingOperation(
supabase, companyId, userId, 'register_absence',
`Registrera frånvaro: ${employeeName}, ${absence_type} ${from}${to !== from ? ` till ${to}` : ''}`,
{ employee_id, from, to, absence_type, hours_per_day: hours_per_day ?? 8, notes: notes ?? null, include_weekends: include_weekends ?? false },
{
employee_id,
employee_name: employeeName,
absence_type,
from,
to,
day_count: preflight.data.count,
hours_per_day: hours_per_day ?? 8,
dates_sample: preflight.data.days.slice(0, 10).map((d) => (d as { absence_date: string }).absence_date),
},
actor,
{
description: 'If a draft salary run covers this period, recalculate it so sjuklön/karensavdrag lines update.',
tool: 'gnubok_calculate_salary_run',
},
{ dateForPeriodCheck: from },
)
},
},
{
name: 'gnubok_create_employee',
title: 'Create Employee',
description: 'Stage creation of a new employee: salary, tax table, bank details, vacation rule. Personnummer is encrypted at staging and never stored in plaintext. Commit via gnubok_approve_pending_operation; then attach to a salary run.',
inputSchema: {
type: 'object',
additionalProperties: false,
properties: {
first_name: { type: 'string' },
last_name: { type: 'string' },
personnummer: { type: 'string', description: '12 digits (YYYYMMDDNNNN). Encrypted at staging.' },
employment_type: { type: 'string', enum: ['employee', 'company_owner', 'board_member'] },
employment_start: { type: 'string' },
employment_end: { type: 'string' },
employment_degree: { type: 'number', description: '1-100 (default 100)' },
hours_per_week: { type: 'number', description: 'Schedule hours/week (default 40; drives hourly divisor)' },
workdays_per_week: { type: 'number', description: 'Schedule days/week (default 5; drives daily divisor)' },
salary_type: { type: 'string', enum: ['monthly', 'hourly'] },
monthly_salary: { type: 'number' },
hourly_rate: { type: 'number' },
tax_table_number: { type: 'number', description: '29-42; required for A-skatt non-sidoinkomst' },
tax_column: { type: 'number' },
tax_municipality: { type: 'string' },
is_sidoinkomst: { type: 'boolean' },
f_skatt_status: { type: 'string', enum: ['a_skatt', 'f_skatt', 'fa_skatt', 'not_verified'] },
clearing_number: { type: 'string' },
bank_account_number: { type: 'string' },
vacation_rule: { type: 'string', enum: ['procentregeln', 'sammaloneregeln', 'semesterersattning', 'none'] },
vacation_days_per_year: { type: 'number' },
email: { type: 'string' },
phone: { type: 'string' },
vaxa_stod_eligible: { type: 'boolean' },
vaxa_stod_start: { type: 'string' },
vaxa_stod_end: { type: 'string' },
jamkning_percentage: { type: 'number' },
jamkning_valid_from: { type: 'string' },
jamkning_valid_to: { type: 'string' },
},
required: ['first_name', 'last_name', 'personnummer', 'employment_start'],
},
outputSchema: STAGED_OPERATION_SCHEMA,
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
async execute(args, companyId, userId, supabase, actor) {
const { CreateEmployeeSchema } = await import('@/lib/api/schemas')
const parsed = CreateEmployeeSchema.safeParse(args)
if (!parsed.success) {
const first = parsed.error.issues[0]
throw new Error(`Invalid employee: ${first ? `${first.path.join('.')}: ${first.message}` : 'validation failed'}`)
}
const body = parsed.data
// Preflight the EF-owner rule so staging fails early with a clean error.
const { getCompanyEntityType } = await import('@/lib/company/context')
const { isEmploymentTypeAllowedForEntity, EF_OWNER_EMPLOYMENT_ERROR } = await import('@/lib/salary/employment-rules')
const entityType = await getCompanyEntityType(supabase, companyId)
if (!isEmploymentTypeAllowedForEntity(entityType, body.employment_type)) {
throw new Error(EF_OWNER_EMPLOYMENT_ERROR)
}
// PII rule: encrypt AT STAGING TIME. pending_operations.params never
// holds the plaintext personnummer; previews and titles carry the
// masked form only.
const { encryptPersonnummer, extractLast4 } = await import('@/lib/salary/personnummer')
const { personnummer, ...fields } = body
const params: Record<string, unknown> = {
...fields,
personnummer_encrypted: encryptPersonnummer(personnummer),
personnummer_last4: extractLast4(personnummer),
}
const masked = maskPersonnummer(personnummer)
return stagePendingOperation(
supabase, companyId, userId, 'create_employee',
`Skapa anställd: ${body.first_name} ${body.last_name}`,
params,
{
first_name: body.first_name,
last_name: body.last_name,
personnummer_masked: masked,
employment_type: body.employment_type,
employment_start: body.employment_start,
salary_type: body.salary_type,
monthly_salary: body.monthly_salary ?? null,
hourly_rate: body.hourly_rate ?? null,
tax_table_number: body.tax_table_number ?? null,
bank_details_provided: !!(body.clearing_number && body.bank_account_number),
},
actor,
{
description: 'After approval, attach the employee to a salary run.',
tool: 'gnubok_create_salary_run',
},
)
},
},
{
name: 'gnubok_update_employee',
title: 'Update Employee',
description: 'Stage an update to an employee\'s payroll config: salary, tax, bank details, vacation rule, jamkning, vaxa-stod. Personnummer cannot be changed. Call gnubok_get_employee first to see current values; commit via gnubok_approve_pending_operation.',
inputSchema: {
type: 'object',
additionalProperties: false,
properties: {
employee_id: { type: 'string', description: 'UUID of the employee' },
first_name: { type: 'string' },
last_name: { type: 'string' },
employment_type: { type: 'string', enum: ['employee', 'company_owner', 'board_member'] },
employment_start: { type: 'string' },
employment_end: { type: 'string' },
employment_degree: { type: 'number' },
hours_per_week: { type: 'number' },
workdays_per_week: { type: 'number' },
salary_type: { type: 'string', enum: ['monthly', 'hourly'] },
monthly_salary: { type: 'number' },
hourly_rate: { type: 'number' },
tax_table_number: { type: 'number' },
tax_column: { type: 'number' },
tax_municipality: { type: 'string' },
is_sidoinkomst: { type: 'boolean' },
f_skatt_status: { type: 'string', enum: ['a_skatt', 'f_skatt', 'fa_skatt', 'not_verified'] },
clearing_number: { type: 'string' },
bank_account_number: { type: 'string' },
vacation_rule: { type: 'string', enum: ['procentregeln', 'sammaloneregeln', 'semesterersattning', 'none'] },
vacation_days_per_year: { type: 'number' },
email: { type: 'string' },
phone: { type: 'string' },
is_active: { type: 'boolean', description: 'false soft-deactivates (BFL retention keeps the row)' },
vaxa_stod_eligible: { type: 'boolean' },
vaxa_stod_start: { type: 'string' },
vaxa_stod_end: { type: 'string' },
jamkning_percentage: { type: ['number', 'null'], description: 'null clears the beslut' },
jamkning_valid_from: { type: ['string', 'null'] },
jamkning_valid_to: { type: ['string', 'null'] },
},
required: ['employee_id'],
},
outputSchema: STAGED_OPERATION_SCHEMA,
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
async execute(args, companyId, userId, supabase, actor) {
const { employee_id, ...rest } = args as { employee_id: string } & Record<string, unknown>
if (!employee_id) throw new Error('employee_id is required')
if ('personnummer' in rest) {
throw new Error('personnummer cannot be changed: identity is immutable post-create')
}
const patch: Record<string, unknown> = {}
for (const [key, value] of Object.entries(rest)) {
if (value !== undefined) patch[key] = value
}
if (Object.keys(patch).length === 0) {
throw new Error('At least one field to update is required')
}
const { data: existing, error } = await supabase
.from('employees')
.select('*')
.eq('id', employee_id)
.eq('company_id', companyId)
.maybeSingle()
if (error) throw new Error(`Database error: ${error.message}`)
if (!existing) throw new Error('Employee not found')
const changes = Object.entries(patch).map(([field, to]) => ({
field,
from: (existing as Record<string, unknown>)[field] ?? null,
to,
}))
const bankChanged = changes.some((c) => c.field === 'clearing_number' || c.field === 'bank_account_number')
return stagePendingOperation(
supabase, companyId, userId, 'update_employee',
`Uppdatera anställd: ${existing.first_name} ${existing.last_name}`,
{ employee_id, patch },
{
employee_id,
employee_name: `${existing.first_name} ${existing.last_name}`,
changes,
// Bank routing changes are the BEC/fraud surface: surface them
// prominently so the approver cannot miss a rerouted payment.
bank_details_changed: bankChanged,
},
actor,
)
},
},
{
name: 'gnubok_set_employee_opening_balances',
title: 'Set Employee Opening Balances (Cutover)',
description: 'Stage payroll cutover state for one or more employees migrating mid-year: YTD gross/tax/net, vacation days remaining, sparade dagar by origin year, opening semesterlöneskuld SEK, karens adjustment. Locked once the employee has a booked run.',
inputSchema: {
type: 'object',
additionalProperties: false,
properties: {
items: {
type: 'array',
minItems: 1,
maxItems: 200,
items: {
type: 'object',
additionalProperties: false,
properties: {
employee_id: { type: 'string', description: 'UUID of the employee' },
cutover_date: { type: 'string', description: 'First day of the first Accounted-run month (YYYY-MM-01)' },
ytd_gross: { type: 'number' },
ytd_tax: { type: 'number' },
ytd_net: { type: 'number' },
vacation_paid_days_remaining: { type: 'number' },
vacation_saved_days_by_year: { type: 'object', description: 'Origin year -> days, e.g. {"2025": 5}' },
opening_semester_liability: { type: 'number', description: 'SEK on 2920 (report-only; booked via SIE)' },
opening_semester_liability_avgifter: { type: 'number', description: 'SEK on 2940' },
karens_periods_adjustment: { type: 'number', description: 'Karens periods last 12 months not imported as absence rows (0-10)' },
},
required: ['employee_id', 'cutover_date'],
},
},
},
required: ['items'],
},
outputSchema: STAGED_OPERATION_SCHEMA,
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false },
async execute(args, companyId, userId, supabase, actor) {
const { OpeningBalancesBulkSchema } = await import('@/lib/api/schemas')
const parsed = OpeningBalancesBulkSchema.safeParse(args)
if (!parsed.success) {
const first = parsed.error.issues[0]
throw new Error(`Invalid opening balances: ${first ? `${first.path.join('.')}: ${first.message}` : 'validation failed'}`)
}
// Preflight via the shared service in dry-run: employee existence,
// employment_start ordering, lock state. Fails staging early with the
// full per-item error list.
const { setOpeningBalancesBulk } = await import('@/lib/salary/opening-balances')
const preflight = await setOpeningBalancesBulk(supabase, {
companyId,
userId,
items: parsed.data.items,
dryRun: true,
})
if (!preflight.ok) {
const itemSummary = preflight.itemErrors
?.map((e) => `${e.employee_id}: ${e.message}`)
.join('; ')
throw new Error(`Cannot set opening balances: ${itemSummary ?? preflight.code}`)
}
return stagePendingOperation(
supabase, companyId, userId, 'set_employee_opening_balances',
`Ingående lönesaldon: ${parsed.data.items.length} anställd(a)`,
{ items: parsed.data.items },
{
employee_count: parsed.data.items.length,
cutover_dates: [...new Set(parsed.data.items.map((i) => i.cutover_date))],
total_ytd_gross: parsed.data.items.reduce((s, i) => s + (i.ytd_gross || 0), 0),
total_opening_liability: parsed.data.items.reduce(
(s, i) => s + (i.opening_semester_liability || 0),
0,
),
},
actor,
{
description: 'After approval, import pre-cutover absence history if needed, then create the first salary run.',
tool: 'gnubok_create_salary_run',
},
)
},
},
{
name: 'gnubok_get_vacation_balance',
title: 'Get Vacation Balance (Semestersaldo)',
description: 'Get one employee\'s current vacation balance: entitled/taken/remaining days, sparade dagar per origin year (5-year rule), forced payouts, and an estimated semesterlöneskuld in SEK. Ledger seeds on first booking. Use before gnubok_close_vacation_year.',
inputSchema: {
type: 'object',
additionalProperties: false,
properties: {
employee_id: { type: 'string', description: 'UUID of the employee' },
},
required: ['employee_id'],
},
outputSchema: {
type: 'object',
additionalProperties: false,
properties: {
employee_vacation_balance_id: { type: 'string' },
employee_id: { type: 'string' },
vacation_year_start: { type: 'string' },
entitled_days: { type: 'number' },
accrued_days: { type: 'number' },
taken_days: { type: 'number' },
remaining_days: { type: 'number' },
saved_days: { type: 'object', description: 'Origin year -> days' },
forced_payout_days: { type: 'number' },
},
required: ['employee_vacation_balance_id', 'employee_id', 'vacation_year_start', 'entitled_days', 'taken_days', 'remaining_days'],
},
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
async execute(args, companyId, _userId, supabase) {
const employeeId = args.employee_id as string
if (!employeeId) throw new Error('employee_id is required')
const { data: balance, error } = await supabase
.from('employee_vacation_balances')
.select('id, employee_id, vacation_year_start, entitled_days, accrued_days, taken_days, saved_days, forced_payout_days')
.eq('company_id', companyId)
.eq('employee_id', employeeId)
.eq('status', 'open')
.order('vacation_year_start', { ascending: false })
.limit(1)
.maybeSingle()
if (error) throw new Error(`Database error: ${error.message}`)
if (!balance) throw new Error('No vacation balance exists for the employee yet (the ledger seeds on first booking)')
const { id, ...rest } = balance as { id: string } & Record<string, unknown>
const entitled = (rest.entitled_days as number) ?? 0
const taken = (rest.taken_days as number) ?? 0
return {
employee_vacation_balance_id: id,
...rest,
remaining_days: roundOre(entitled - taken),
}
},
},
{
name: 'gnubok_close_vacation_year',
title: 'Close Vacation Year (Semesterårsavslut)',
description: 'Stage the vacation year close: rolls balances into the next year (min-20 floor, 5-year expiry to forced payout) and books a 2920/2940 drift adjustment when needed. High risk: review the preview report, then commit via gnubok_approve_pending_operation.',
inputSchema: {
type: 'object',
additionalProperties: false,
properties: {
vacation_year_start: { type: 'string', description: 'YYYY-MM-DD; defaults to the most recently ended vacation year' },
book_adjustment: { type: 'boolean', description: 'Book the 2920/2940 drift verifikat (default true)' },
},
},
outputSchema: STAGED_OPERATION_SCHEMA,
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: false },
async execute(args, companyId, userId, supabase, actor) {
const { previewVacationYearClose } = await import('@/lib/salary/semesterberedning')
const { getVacationYearBasis } = await import('@/lib/salary/vacation-ledger')
const { getClosableYearStart } = await import('@/lib/salary/vacation-year')
let yearStart = args.vacation_year_start as string | undefined
if (!yearStart) {
const basis = await getVacationYearBasis(supabase, companyId)
yearStart = getClosableYearStart(new Date().toISOString().slice(0, 10), basis)
}
const bookAdjustment = args.book_adjustment !== false
// Preflight: the full review report. Staging fails early on
// not-ended / already-closed years, and the approver sees the exact
// day transitions + SEK drift that will commit.
const preview = await previewVacationYearClose(supabase, companyId, yearStart)
if (!preview.ok) {
throw new Error(`Cannot close vacation year: ${preview.code}`)
}
const report = preview.data
return stagePendingOperation(
supabase, companyId, userId, 'vacation_year_close',
`Semesterårsavslut ${yearStart.slice(0, 4)} (${report.rows.length} anställda)`,
{ vacation_year_start: yearStart, book_adjustment: bookAdjustment },
{
vacation_year_start: yearStart,
vacation_year_end: report.vacation_year_end,
employee_count: report.rows.length,
total_saveable_days: report.rows.reduce((s, r) => s + r.saveable_days, 0),
total_expiring_days: report.rows.reduce((s, r) => s + r.expiring_days, 0),
computed_liability: report.sek.computed_liability,
computed_avgifter: report.sek.computed_avgifter,
booked_2920: report.sek.booked_2920,
booked_2940: report.sek.booked_2940,
drift_2920: report.sek.drift_2920,
drift_2940: report.sek.drift_2940,
adjustment_needed: report.sek.adjustment_needed,
},
actor,
{
description: 'After approval, pay out any forced-payout days as semesterersättning in the next salary run.',
tool: 'gnubok_create_salary_run',
},
{ dateForPeriodCheck: report.adjustment_date },
)
},
},
// ── Stream 1 Phase 1: Bookkeeping write (high-risk, always staged) ──
@@ -0,0 +1,76 @@
/**
* PII chokepoint for staged operations (ISO 27001 A.8.11 / GDPR Art.5(1)(c)).
*
* pending_operations.params and .preview_data are persisted verbatim and
* rendered in approval UIs, so no staging payload may carry a plaintext
* personnummer. The one tool that legitimately receives one
* (gnubok_create_employee) encrypts at staging time and stores only
* `personnummer_encrypted` / `personnummer_last4` / `personnummer_masked`.
* This guard runs inside stagePendingOperation, so every current and FUTURE
* staging tool inherits the rule: a tool that forgets to encrypt fails loudly
* at staging instead of silently persisting PII.
*
* Detection is key-based, not value-based: for enskild firma the org number
* IS the owner's personnummer, so value-pattern matching would false-positive
* on legitimate counterparty data. Keys are compared exactly; the derived
* forms above are therefore allowed by construction.
*/
const FORBIDDEN_KEYS = new Set(['personnummer', 'pnr', 'social_security_number', 'ssn'])
/**
* Cycle/degenerate-payload stop: staged payloads are shallow JSON. Anything
* nesting deeper is rejected (fail closed), never silently accepted: a
* payload too deep to scan could hide a forbidden key below the limit.
*/
const MAX_DEPTH = 6
/** Sentinel: the payload nests past MAX_DEPTH, so it cannot be fully scanned. */
const DEPTH_EXCEEDED = Symbol('depth-exceeded')
function findForbiddenKey(
value: unknown,
depth: number,
): string | typeof DEPTH_EXCEEDED | null {
if (value === null || typeof value !== 'object') return null
if (depth > MAX_DEPTH) return DEPTH_EXCEEDED
if (Array.isArray(value)) {
for (const item of value) {
const hit = findForbiddenKey(item, depth + 1)
if (hit) return hit
}
return null
}
for (const [key, nested] of Object.entries(value as Record<string, unknown>)) {
if (FORBIDDEN_KEYS.has(key)) return key
const hit = findForbiddenKey(nested, depth + 1)
if (hit) return hit
}
return null
}
/**
* Throws when `payload` (params or preview_data) carries a plaintext
* personnummer-bearing key at any nesting level, or nests too deep to scan
* (fail closed). `label` names the offending payload in the error so the
* tool author sees exactly what to fix.
*/
export function assertNoPlaintextPersonnummer(
payload: Record<string, unknown>,
label: 'params' | 'preview_data',
): void {
const hit = findForbiddenKey(payload, 0)
if (hit === DEPTH_EXCEEDED) {
throw new Error(
`Staging blocked: ${label} nests deeper than ${MAX_DEPTH} levels and cannot be scanned for plaintext PII. ` +
'Flatten the payload; staged payloads are shallow JSON by design.',
)
}
if (hit) {
throw new Error(
`Staging blocked: ${label} contains plaintext PII key "${hit}". ` +
'pending_operations must never persist a plaintext personnummer; ' +
'encrypt at staging time (see gnubok_create_employee: personnummer_encrypted + personnummer_last4).',
)
}
}
@@ -135,7 +135,7 @@ export async function buildAgiUnderlag(
if (declarationError || !declaration?.xml_content) {
throw new Error(
'AGI-XML saknas. Generera AGI-filen från lönekörningen först (Lön → AGI → Generera).',
'AGI-XML saknas. Generera AGI-filen först: knappen "Lämna in till Skatteverket" på lönekörningen gör det automatiskt, eller klicka "Ladda ner AGI-fil".',
)
}
+164 -1
View File
@@ -1521,6 +1521,10 @@ export const UpdateSettingsSchema = z.object({
.enum(['swedbank', 'seb', 'handelsbanken', 'nordea', 'other'])
.nullable()
.optional(),
// Vacation year basis (payroll gap-closure 3.1): sammanfallande calendar
// year (default) or the statutory Apr 1 - Mar 31 split. The settings route
// blocks changing this while open vacation-ledger rows exist.
salary_vacation_year_basis: z.enum(['calendar', 'statutory_apr_mar']).optional(),
}).refine(
(data) => {
// BFL 3 kap.: Enskild firma must have fiscal year starting January
@@ -1959,6 +1963,11 @@ const EmployeeSchemaBase = z.object({
employment_start: isoDate,
employment_end: isoDate.optional(),
employment_degree: z.number().min(1).max(100).default(100),
// Arbetsschema-lite: weekly schedule driving the hourly/daily divisors
// (legacy 173/21 at the defaults). employment_degree keeps prorating base
// salary; these ONLY drive divisors.
hours_per_week: z.number().positive().max(80).default(40),
workdays_per_week: z.number().min(1).max(7).default(5),
salary_type: SalaryTypeSchema.default('monthly'),
monthly_salary: z.number().nonnegative().optional(),
hourly_rate: z.number().nonnegative().optional(),
@@ -1980,6 +1989,14 @@ const EmployeeSchemaBase = z.object({
vaxa_stod_eligible: z.boolean().default(false),
vaxa_stod_start: isoDate.optional(),
vaxa_stod_end: isoDate.optional(),
// Jämkning (Skatteverket beslut om ändrad beräkning av skatteavdrag):
// overrides the tax-table lookup with a fixed percentage for a bounded
// period. Fields have existed on the employees table since the salary
// module shipped; this exposes the write path (payroll gap-closure 1.5).
// Setting jamkning_percentage to null clears the beslut.
jamkning_percentage: z.number().min(0).max(100).nullable().optional(),
jamkning_valid_from: isoDate.nullable().optional(),
jamkning_valid_to: isoDate.nullable().optional(),
// Dimensions PR8: bag applied to the employee's P&L cost lines when a
// salary run is booked. {} clears (the UI always sends the field).
default_dimensions: DimensionsBagSchema.optional(),
@@ -2046,6 +2063,32 @@ export const CreateEmployeeSchema = EmployeeSchemaBase.superRefine((data, ctx) =
})
}
// Jämkning: a percentage without a start date is meaningless (the engine
// gates on jamkning_valid_from <= payment_date). End date is optional
// (beslut often run until year-end implicitly).
if (
data.jamkning_percentage !== null &&
data.jamkning_percentage !== undefined &&
!data.jamkning_valid_from
) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'Jämkningens startdatum måste anges när jämkningsprocent sätts',
path: ['jamkning_valid_from'],
})
}
if (
data.jamkning_valid_from &&
data.jamkning_valid_to &&
data.jamkning_valid_to < data.jamkning_valid_from
) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'Jämkningens slutdatum måste vara efter startdatumet',
path: ['jamkning_valid_to'],
})
}
// Bank details: validate clearing/kontonummer structure at entry so a typo is
// caught here rather than at Bankgirot LB generation. Both empty is allowed.
// Update path is validated in the PATCH route (only when the fields actually
@@ -2060,7 +2103,28 @@ export const CreateEmployeeSchema = EmployeeSchemaBase.superRefine((data, ctx) =
}
})
export const UpdateEmployeeSchema = EmployeeSchemaBase.partial().superRefine((data, ctx) => {
// PATCH base: the create-schema defaults are stripped first. Zod 4 applies
// .default() even through .partial() (absent key -> default value), which
// would (a) make sparse PATCH bodies fail the salary-type refinement below
// (salary_type materializes as 'monthly' without monthly_salary present) and
// (b) leak default values into routes that spread the parsed body into the
// UPDATE (silently resetting e.g. is_sidoinkomst on unrelated edits).
const EmployeeSchemaPatchBase = EmployeeSchemaBase.extend({
employment_type: EmploymentTypeSchema,
employment_degree: z.number().min(1).max(100),
hours_per_week: z.number().positive().max(80),
workdays_per_week: z.number().min(1).max(7),
salary_type: SalaryTypeSchema,
tax_column: z.number().int().min(1).max(6),
is_sidoinkomst: z.boolean(),
f_skatt_status: FSkattStatusSchema,
vacation_rule: VacationRuleSchema,
vacation_days_per_year: z.number().int().min(25).max(40),
semestertillagg_rate: z.number().min(0).max(0.05),
vaxa_stod_eligible: z.boolean(),
})
export const UpdateEmployeeSchema = EmployeeSchemaPatchBase.partial().superRefine((data, ctx) => {
// Only validate salary when salary_type is being changed in this update
if (data.salary_type === 'monthly' && data.monthly_salary !== undefined && data.monthly_salary <= 0) {
ctx.addIssue({
@@ -2127,6 +2191,23 @@ export const UpdateEmployeeSchema = EmployeeSchemaBase.partial().superRefine((da
path: ['vaxa_stod_end'],
})
}
// Jämkning: same schema-visibility caveat as växa-stöd above. What the
// schema CAN see: a non-null percentage sent WITHOUT any start date in the
// same body is only valid if a start date already exists on the row: the
// route layer does the merged-state check. Within-body date ordering is
// checkable here.
if (
data.jamkning_valid_from &&
data.jamkning_valid_to &&
data.jamkning_valid_to < data.jamkning_valid_from
) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'Jämkningens slutdatum måste vara efter startdatumet',
path: ['jamkning_valid_to'],
})
}
})
export const EmployeeBenefitTypeSchema = z.enum(['bike', 'car', 'meals', 'housing', 'wellness', 'other'])
@@ -2249,6 +2330,88 @@ export const AbsenceRangeQuerySchema = z.object({
path: ['from'],
})
// ── Employee opening balances (payroll cutover) ─────────────────────
//
// Per-employee state a mid-year switcher brings from the previous payroll
// system: YTD accumulators, vacation balances (incl. sparade dagar by origin
// year per the Semesterlagen 5-year rule), the opening semesterlöneskuld SEK
// (feeds vacation-liability report only; the 2920/2940 balance arrived via
// SIE), and the högriskskydd karens-count adjustment. See migration
// 20260713101000.
const openingBalancesShape = {
cutover_date: isoDate,
ytd_gross: z.number().min(0).default(0),
ytd_tax: z.number().min(0).default(0),
ytd_net: z.number().min(0).default(0),
vacation_paid_days_remaining: z.number().min(0).max(40).default(0),
vacation_saved_days_by_year: z
.record(z.string().regex(/^\d{4}$/, 'Nyckel måste vara ett fyrsiffrigt år'), z.number().min(0).max(40))
.default({}),
opening_semester_liability: z.number().min(0).default(0),
opening_semester_liability_avgifter: z.number().min(0).default(0),
karens_periods_adjustment: z.number().int().min(0).max(10).default(0),
}
const openingBalancesRefine = (
data: {
cutover_date: string
ytd_gross: number
ytd_tax: number
vacation_saved_days_by_year: Record<string, number>
},
ctx: z.RefinementCtx,
) => {
if (!data.cutover_date.endsWith('-01')) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'cutover_date måste vara den första dagen i en månad',
path: ['cutover_date'],
})
}
const cutoverYear = Number(data.cutover_date.slice(0, 4))
const currentYear = new Date().getFullYear()
if (cutoverYear < currentYear - 1 || cutoverYear > currentYear) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'cutover_date måste ligga i innevarande eller föregående år',
path: ['cutover_date'],
})
}
if (data.ytd_tax > data.ytd_gross) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'ytd_tax kan inte överstiga ytd_gross',
path: ['ytd_tax'],
})
}
// Sparade dagar: max 5 years back, never the cutover year itself.
for (const yearKey of Object.keys(data.vacation_saved_days_by_year)) {
const originYear = Number(yearKey)
if (originYear < cutoverYear - 5 || originYear > cutoverYear - 1) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: `Sparade dagar för ${yearKey}: ursprungsåret måste ligga inom 5 år före cutover (${cutoverYear - 5}-${cutoverYear - 1})`,
path: ['vacation_saved_days_by_year', yearKey],
})
}
}
}
/** Body for the per-employee PUT (employee id comes from the path). */
export const OpeningBalancesFieldsSchema = z
.object(openingBalancesShape)
.superRefine(openingBalancesRefine)
/** One item in the bulk PUT (employee id inline). */
export const OpeningBalancesItemSchema = z
.object({ employee_id: uuid, ...openingBalancesShape })
.superRefine(openingBalancesRefine)
export const OpeningBalancesBulkSchema = z.object({
items: z.array(OpeningBalancesItemSchema).min(1).max(200),
})
// ── Worked-hours per-day records (hourly employees) ─────────────────
//
// Drives base salary calculation for hourly (timanställd) employees:
+17 -1
View File
@@ -1,13 +1,16 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`v1 spec snapshot > matches the recorded endpoint count > endpoint-count 1`] = `107`;
exports[`v1 spec snapshot > matches the recorded endpoint count > endpoint-count 1`] = `123`;
exports[`v1 spec snapshot > matches the recorded endpoint key set > endpoint-keys 1`] = `
[
"DELETE /api/v1/companies/:companyId/customers/:id",
"DELETE /api/v1/companies/:companyId/dimensions/:id/values/:valueId",
"DELETE /api/v1/companies/:companyId/employees/:id",
"DELETE /api/v1/companies/:companyId/employees/:id/absence",
"DELETE /api/v1/companies/:companyId/salary-runs/:id",
"DELETE /api/v1/companies/:companyId/salary-runs/:id/employees/:employeeId",
"DELETE /api/v1/companies/:companyId/salary-runs/:id/lines/:lineId",
"DELETE /api/v1/companies/:companyId/suppliers/:id",
"DELETE /api/v1/companies/:companyId/webhooks/:id",
"GET /api/v1/companies",
@@ -20,6 +23,9 @@ exports[`v1 spec snapshot > matches the recorded endpoint key set > endpoint-key
"GET /api/v1/companies/:companyId/documents/:id/download",
"GET /api/v1/companies/:companyId/employees",
"GET /api/v1/companies/:companyId/employees/:id",
"GET /api/v1/companies/:companyId/employees/:id/absence",
"GET /api/v1/companies/:companyId/employees/:id/opening-balances",
"GET /api/v1/companies/:companyId/employees/:id/vacation-balance",
"GET /api/v1/companies/:companyId/fiscal-periods",
"GET /api/v1/companies/:companyId/invoices",
"GET /api/v1/companies/:companyId/invoices/:id",
@@ -43,6 +49,9 @@ exports[`v1 spec snapshot > matches the recorded endpoint key set > endpoint-key
"GET /api/v1/companies/:companyId/reports/vat-declaration",
"GET /api/v1/companies/:companyId/salary-runs",
"GET /api/v1/companies/:companyId/salary-runs/:id",
"GET /api/v1/companies/:companyId/salary-runs/:id/employees",
"GET /api/v1/companies/:companyId/salary-runs/:id/employees/:employeeId",
"GET /api/v1/companies/:companyId/salary-runs/:id/payslips/:employeeId/pdf",
"GET /api/v1/companies/:companyId/supplier-invoices",
"GET /api/v1/companies/:companyId/supplier-invoices/:id",
"GET /api/v1/companies/:companyId/suppliers",
@@ -59,6 +68,7 @@ exports[`v1 spec snapshot > matches the recorded endpoint key set > endpoint-key
"PATCH /api/v1/companies/:companyId/employees/:id",
"PATCH /api/v1/companies/:companyId/invoices/:id",
"PATCH /api/v1/companies/:companyId/salary-runs/:id",
"PATCH /api/v1/companies/:companyId/salary-runs/:id/lines/:lineId",
"PATCH /api/v1/companies/:companyId/supplier-invoices/:id",
"PATCH /api/v1/companies/:companyId/suppliers/:id",
"PATCH /api/v1/companies/:companyId/webhooks/:id",
@@ -92,8 +102,11 @@ exports[`v1 spec snapshot > matches the recorded endpoint key set > endpoint-key
"POST /api/v1/companies/:companyId/salary-runs/:id/approve",
"POST /api/v1/companies/:companyId/salary-runs/:id/book",
"POST /api/v1/companies/:companyId/salary-runs/:id/calculate",
"POST /api/v1/companies/:companyId/salary-runs/:id/employees",
"POST /api/v1/companies/:companyId/salary-runs/:id/employees/:employeeId/lines",
"POST /api/v1/companies/:companyId/salary-runs/:id/generate-agi",
"POST /api/v1/companies/:companyId/salary-runs/:id/mark-paid",
"POST /api/v1/companies/:companyId/salary/vacation-year-close",
"POST /api/v1/companies/:companyId/supplier-invoices",
"POST /api/v1/companies/:companyId/supplier-invoices/:id/approve",
"POST /api/v1/companies/:companyId/supplier-invoices/:id/credit",
@@ -111,6 +124,9 @@ exports[`v1 spec snapshot > matches the recorded endpoint key set > endpoint-key
"POST /api/v1/companies/:companyId/webhooks/:id/rotate-secret",
"POST /api/v1/companies/:companyId/webhooks/:id/test",
"POST /api/v1/webhook-deliveries/:id/retry",
"PUT /api/v1/companies/:companyId/employees/:id/absence",
"PUT /api/v1/companies/:companyId/employees/:id/opening-balances",
"PUT /api/v1/companies/:companyId/employees/opening-balances",
]
`;
+20
View File
@@ -97,6 +97,26 @@ import '@/app/api/v1/companies/[companyId]/salary-runs/[id]/mark-paid/route'
import '@/app/api/v1/companies/[companyId]/salary-runs/[id]/book/route'
import '@/app/api/v1/companies/[companyId]/salary-runs/[id]/generate-agi/route'
// Payroll gap-closure 1.1: per-employee payslip reads (list + detail + PDF).
import '@/app/api/v1/companies/[companyId]/salary-runs/[id]/employees/route'
import '@/app/api/v1/companies/[companyId]/salary-runs/[id]/employees/[employeeId]/route'
import '@/app/api/v1/companies/[companyId]/salary-runs/[id]/payslips/[employeeId]/pdf/route'
// Payroll gap-closure 1.2: payslip line writes (draft runs only).
import '@/app/api/v1/companies/[companyId]/salary-runs/[id]/employees/[employeeId]/lines/route'
import '@/app/api/v1/companies/[companyId]/salary-runs/[id]/lines/[lineId]/route'
// Payroll gap-closure 1.4: absence (frånvaro) range endpoints.
import '@/app/api/v1/companies/[companyId]/employees/[id]/absence/route'
// Payroll gap-closure 2.3: cutover opening balances (single + atomic bulk).
import '@/app/api/v1/companies/[companyId]/employees/[id]/opening-balances/route'
import '@/app/api/v1/companies/[companyId]/employees/opening-balances/route'
// Payroll gap-closure 3.4: vacation ledger + year close.
import '@/app/api/v1/companies/[companyId]/employees/[id]/vacation-balance/route'
import '@/app/api/v1/companies/[companyId]/salary/vacation-year-close/route'
// Phase 5 PR-3: Reports + import async. All reports wrap existing
// lib/reports/* generators. Imports run inline today but record their
// progress on the `operations` table for consistent polling-shape. KPI,
+5 -1
View File
@@ -65,7 +65,11 @@ import { API_V1_VERSION, API_V1_VERSION_HEADER } from './version'
const IDEMPOTENCY_HEADER = 'Idempotency-Key'
const DRY_RUN_HEADER = 'X-Dry-Run'
const REQUIRES_IDEMPOTENCY = new Set(['POST', 'PATCH', 'DELETE'])
// Every state-changing method. PUT is included even though most v1 writes are
// POST/PATCH: the set drives THREE behaviors (test-key dry-run forcing,
// idempotency replay, requireIdempotencyKey enforcement), and omitting PUT
// would let test keys write through PUT routes for real.
const REQUIRES_IDEMPOTENCY = new Set(['POST', 'PUT', 'PATCH', 'DELETE'])
export interface ApiV1Context {
/** Stable id for this HTTP request: appears in logs, error envelope, X-Request-Id. */
+19
View File
@@ -102,7 +102,9 @@ export function withRouteContext<P extends DynamicParams = { params: Promise<Rec
let errLog = log
try {
const authStart = Date.now()
const auth = await requireAuth()
const authMs = Date.now() - authStart
if (auth.error) {
log.warn('auth failed', { status: auth.error.status })
// Pass through requireAuth's response unchanged for backwards-compat
@@ -119,11 +121,13 @@ export function withRouteContext<P extends DynamicParams = { params: Promise<Rec
errLog = userLog
let companyId: string | null = null
const companyStart = Date.now()
try {
companyId = await getActiveCompanyId(supabase, user.id)
} catch (err) {
userLog.error('failed to resolve active company', err as Error)
}
const companyMs = Date.now() - companyStart
if (!companyId) {
return errorResponseFromCode('COMPANY_CONTEXT_MISSING', userLog, { requestId })
@@ -152,15 +156,30 @@ export function withRouteContext<P extends DynamicParams = { params: Promise<Rec
}
errLog = ctx.log
const handlerStart = Date.now()
const response = await handler(request, ctx, params)
const handlerMs = Date.now() - handlerStart
if (response instanceof Response && !response.headers.get('X-Request-Id')) {
response.headers.set('X-Request-Id', requestId)
}
// Per-phase breakdown, visible in browser devtools (Timing tab) and in
// the op-completed log: separates the wrapper's own overhead (auth
// round trip + company resolution) from the handler's real work, so
// latency regressions can be attributed without guessing.
if (response instanceof Response && !response.headers.get('Server-Timing')) {
response.headers.set(
'Server-Timing',
`auth;dur=${authMs}, company;dur=${companyMs}, handler;dur=${handlerMs}`,
)
}
ctx.log.info('op completed', {
durationMs: Date.now() - start,
status: response.status,
authMs,
companyMs,
handlerMs,
})
return response
} catch (err) {
+34
View File
@@ -227,6 +227,17 @@ export const TOOL_SCOPE_MAP: Record<string, ApiKeyScope> = {
gnubok_create_salary_run: 'payroll:write',
gnubok_calculate_salary_run: 'payroll:write',
gnubok_generate_agi: 'payroll:write',
// Payroll gap-closure: reads + staged writes (1.6-1.8, 2.4)
gnubok_get_employee: 'payroll:read',
gnubok_get_payslip: 'payroll:read',
gnubok_list_absence: 'payroll:read',
gnubok_update_payslip_line: 'payroll:write',
gnubok_register_absence: 'payroll:write',
gnubok_create_employee: 'payroll:write',
gnubok_update_employee: 'payroll:write',
gnubok_set_employee_opening_balances: 'payroll:write',
gnubok_get_vacation_balance: 'payroll:read',
gnubok_close_vacation_year: 'payroll:write',
// Bookkeeping write (Stream 1 Phase 1): high-risk, always staged
gnubok_close_period: 'bookkeeping:write',
gnubok_lock_period: 'bookkeeping:write',
@@ -283,6 +294,29 @@ export const TOOL_SCOPE_MAP: Record<string, ApiKeyScope> = {
gnubok_agi_status: 'compliance:read',
gnubok_vat_declaration_submit: 'skatteverket:write',
gnubok_agi_submit: 'skatteverket:write',
// ── Audit retrofit (agent-native audit P0: unmapped = default-allow) ──
// These tools shipped without a scope mapping, making them callable by ANY
// authenticated key. Mapping them is accept-the-break by decision
// (2026-07-13): keys that relied on the default-allow hole lose access
// until granted the proper scope. Release-note callout required for the
// four WRITES below.
gnubok_link_invoice_to_voucher: 'invoices:write',
gnubok_undo_sie_import: 'bookkeeping:write',
gnubok_post_annual_depreciation: 'bookkeeping:write',
gnubok_import_rot_rut_beslut: 'invoices:write',
gnubok_list_verifikat_without_documents: 'transactions:read',
gnubok_find_voucher_candidates_for_invoice: 'invoices:read',
gnubok_propose_dispositioner: 'reports:read',
gnubok_propose_accruals: 'reports:read',
gnubok_propose_annual_depreciation: 'reports:read',
gnubok_preview_arsredovisning: 'reports:read',
gnubok_preview_ef_declaration: 'reports:read',
// Deliberately UNSCOPED (available to any authenticated key):
// gnubok_search_tools, gnubok_list_skills, gnubok_load_skill,
// gnubok_feedback. Discovery + static skill bodies + feedback channel
// carry no per-company data; keeping them open is what lets an agent
// orient itself before its key's scopes are known.
}
export function validateScopes(scopes: unknown): ApiKeyScope[] | null {
+24
View File
@@ -180,6 +180,30 @@ export const V1_ENDPOINT_SCOPES: Record<string, ApiKeyScope> = {
'POST /api/v1/companies/:companyId/salary-runs/:id/mark-paid': 'payroll:write',
'POST /api/v1/companies/:companyId/salary-runs/:id/book': 'payroll:write',
'POST /api/v1/companies/:companyId/salary-runs/:id/generate-agi': 'payroll:write',
// Payroll gap-closure 1.1: per-employee payslip reads. Personnummer is
// masked on all payslip-shaped responses (GDPR Art.5(1)(c)); the employee
// detail endpoint is the identity drill-in.
'GET /api/v1/companies/:companyId/salary-runs/:id/employees': 'payroll:read',
'GET /api/v1/companies/:companyId/salary-runs/:id/employees/:employeeId': 'payroll:read',
'GET /api/v1/companies/:companyId/salary-runs/:id/payslips/:employeeId/pdf': 'payroll:read',
// Payroll gap-closure 1.2: payslip line writes (draft runs only).
'POST /api/v1/companies/:companyId/salary-runs/:id/employees/:employeeId/lines': 'payroll:write',
'PATCH /api/v1/companies/:companyId/salary-runs/:id/lines/:lineId': 'payroll:write',
'DELETE /api/v1/companies/:companyId/salary-runs/:id/lines/:lineId': 'payroll:write',
// Payroll gap-closure 1.3: run roster attach/remove (draft runs only).
'POST /api/v1/companies/:companyId/salary-runs/:id/employees': 'payroll:write',
'DELETE /api/v1/companies/:companyId/salary-runs/:id/employees/:employeeId': 'payroll:write',
// Payroll gap-closure 1.4: absence (frånvaro) per-day register via ranges.
'GET /api/v1/companies/:companyId/employees/:id/absence': 'payroll:read',
'PUT /api/v1/companies/:companyId/employees/:id/absence': 'payroll:write',
'DELETE /api/v1/companies/:companyId/employees/:id/absence': 'payroll:write',
// Payroll gap-closure 2.3: cutover opening balances (mid-year migration).
'GET /api/v1/companies/:companyId/employees/:id/opening-balances': 'payroll:read',
'PUT /api/v1/companies/:companyId/employees/:id/opening-balances': 'payroll:write',
'PUT /api/v1/companies/:companyId/employees/opening-balances': 'payroll:write',
// Payroll gap-closure 3.4: vacation ledger + year close.
'GET /api/v1/companies/:companyId/employees/:id/vacation-balance': 'payroll:read',
'POST /api/v1/companies/:companyId/salary/vacation-year-close': 'payroll:write',
// Dimensions (kostnadsställe/projekt): dimensions PR2. Reads ride
// reports:read (registry data feeds report filters/pickers); value creation
@@ -211,6 +211,118 @@ describe('proposePaymentLines', () => {
})
})
describe('proposePaymentLines: öresavrundning (3740)', () => {
it('accrual: rounded-up total → bank leg at "Att betala", 3740 credit carries the residual', () => {
const lines = proposePaymentLines({
invoice: makeInvoiceInput({
total: 1234.75,
subtotal: 987.8,
vat_amount: 246.95,
items: [makeItem({ line_total: 987.8, vat_amount: 246.95, unit_price: 987.8 })],
}),
accountingMethod: 'accrual',
entityType: 'enskild_firma',
companyOreRounding: true,
})
expect(lines).toHaveLength(3)
expect(lines[0]).toMatchObject({ account_number: '1930', debit_amount: '1235' })
expect(lines[1]).toMatchObject({ account_number: '1510', credit_amount: '1234.75' })
expect(lines[2]).toEqual({
account_number: '3740',
debit_amount: '',
credit_amount: '0.25',
line_description: 'Öresavrundning',
})
})
it('accrual: rounded-down total → 3740 debit', () => {
const lines = proposePaymentLines({
invoice: makeInvoiceInput({ total: 1234.25 }),
accountingMethod: 'accrual',
entityType: 'enskild_firma',
companyOreRounding: true,
})
expect(lines).toHaveLength(3)
expect(lines[0]).toMatchObject({ account_number: '1930', debit_amount: '1234' })
expect(lines[1]).toMatchObject({ account_number: '1510', credit_amount: '1234.25' })
expect(lines[2]).toMatchObject({ account_number: '3740', debit_amount: '0.25', credit_amount: '' })
})
it('cash: bank leg is the rounded amount and 3740 balances the exact revenue + VAT credits', () => {
const lines = proposePaymentLines({
invoice: makeInvoiceInput({
total: 1234.75,
subtotal: 987.8,
vat_amount: 246.95,
items: [makeItem({ line_total: 987.8, vat_amount: 246.95, unit_price: 987.8 })],
}),
accountingMethod: 'cash',
entityType: 'enskild_firma',
companyOreRounding: true,
})
expect(lines[0]).toMatchObject({ account_number: '1930', debit_amount: '1235' })
const last = lines[lines.length - 1]
expect(last).toMatchObject({ account_number: '3740', credit_amount: '0.25' })
const debit = lines.reduce((s, l) => s + (parseFloat(l.debit_amount) || 0), 0)
const credit = lines.reduce((s, l) => s + (parseFloat(l.credit_amount) || 0), 0)
expect(Math.round((debit - credit) * 100)).toBe(0)
})
it('company setting off and no invoice override → unchanged 2-line proposal', () => {
const lines = proposePaymentLines({
invoice: makeInvoiceInput({ total: 1234.75 }),
accountingMethod: 'accrual',
entityType: 'enskild_firma',
companyOreRounding: false,
})
expect(lines).toHaveLength(2)
expect(lines[0].debit_amount).toBe('1234.75')
})
it('per-invoice override wins over the company setting', () => {
const lines = proposePaymentLines({
invoice: { ...makeInvoiceInput({ total: 1234.75 }), ore_rounding: true },
accountingMethod: 'accrual',
entityType: 'enskild_firma',
companyOreRounding: false,
})
expect(lines).toHaveLength(3)
expect(lines[2].account_number).toBe('3740')
})
it('whole-krona total → no 3740 line even when rounding is on', () => {
const lines = proposePaymentLines({
invoice: makeInvoiceInput({ total: 12500 }),
accountingMethod: 'accrual',
entityType: 'enskild_firma',
companyOreRounding: true,
})
expect(lines).toHaveLength(2)
})
it('non-SEK invoice → rounding never applies', () => {
const lines = proposePaymentLines({
invoice: makeInvoiceInput({
total: 1000.4,
total_sek: 10004,
currency: 'EUR',
exchange_rate: 10,
}),
accountingMethod: 'accrual',
entityType: 'enskild_firma',
companyOreRounding: true,
})
expect(lines.every((l) => l.account_number !== '3740')).toBe(true)
})
})
describe('proposePaymentLines: dimensions propagation (PR7)', () => {
const bag = { '1': 'KS01', '6': 'P001' }
+49 -6
View File
@@ -7,6 +7,7 @@
import { resolveSekAmount } from './currency-utils'
import { getRevenueAccount, getOutputVatAccount } from './invoice-entries'
import { getVatTreatmentForRate } from '@/lib/invoices/vat-rules'
import { getDisplayTotal } from '@/lib/invoices/rounding'
import type { FormLine } from '@/components/bookkeeping/JournalEntryForm'
import type { EntityType, InvoiceItem, VatTreatment } from '@/types'
@@ -23,6 +24,8 @@ export interface ProposePaymentLinesInput {
exchange_rate?: number | null
vat_treatment: VatTreatment
items?: InvoiceItem[]
/** Per-invoice öresavrundning override; null = inherit the company setting. */
ore_rounding?: boolean | null
/**
* Dimensions PR7: the invoice's default bag. Stamped on every proposed
* line: the payment dialog always submits its (editable) lines, so the
@@ -36,6 +39,13 @@ export interface ProposePaymentLinesInput {
entityType: EntityType
paymentAccount?: string
exchangeRateDifference?: number
/**
* company_settings.ore_rounding. Combined with the per-invoice override via
* getDisplayTotal (SEK only, default-on) to decide whether the proposal
* expects the customer to pay the rounded "Att betala" from the PDF: then
* the bank leg is the rounded amount and 3740 carries the residual.
*/
companyOreRounding?: boolean
}
function toFormAmount(n: number): string {
@@ -73,9 +83,19 @@ export function proposePaymentLines(input: ProposePaymentLinesInput): FormLine[]
const paymentAccount = input.paymentAccount || '1930'
const desc = invoice.invoice_number ? `Betalning faktura ${invoice.invoice_number}` : 'Betalning faktura'
// Öresavrundning: when it applies (SEK, enabled, non-integer total) the
// customer pays the rounded "Att betala" from the PDF, not the stored öre
// total. Propose the bank leg at the rounded amount and let 3740 carry the
// residual, so the default booking matches what actually hits the bank.
// getDisplayTotal returns delta 0 whenever rounding does not apply.
const roundingDelta = getDisplayTotal(
{ total: invoice.total, currency: invoice.currency, ore_rounding: invoice.ore_rounding },
input.companyOreRounding === undefined ? undefined : { ore_rounding: input.companyOreRounding },
).roundingDelta
const lines = accountingMethod === 'accrual'
? proposeAccrualLines(invoice, paymentAccount, desc, exchangeRateDifference)
: proposeCashLines(invoice, paymentAccount, desc, entityType)
? proposeAccrualLines(invoice, paymentAccount, desc, exchangeRateDifference, roundingDelta)
: proposeCashLines(invoice, paymentAccount, desc, entityType, roundingDelta)
// Dimensions PR7: re-propagate the invoice default onto every proposed leg
// (matches createInvoicePaymentJournalEntry/createInvoiceCashEntry).
@@ -86,11 +106,26 @@ export function proposePaymentLines(input: ProposePaymentLinesInput): FormLine[]
return lines
}
/**
* The 3740 (öres- och kronutjämning) residual line. Customer paid over the
* stored total (rounded up) credit (vinst); under (rounded down) debit
* (förlust). Same polarity as buildInvoicePaymentClearingLines.
*/
function oreRoundingLine(roundingDelta: number): FormLine {
return {
account_number: '3740',
debit_amount: roundingDelta < 0 ? toFormAmount(Math.abs(roundingDelta)) : '',
credit_amount: roundingDelta > 0 ? toFormAmount(roundingDelta) : '',
line_description: 'Öresavrundning',
}
}
function proposeAccrualLines(
invoice: ProposePaymentLinesInput['invoice'],
paymentAccount: string,
desc: string,
exchangeRateDifference?: number
exchangeRateDifference?: number,
roundingDelta = 0
): FormLine[] {
const bookedSekAmount = resolveSekAmount(
invoice.total,
@@ -136,7 +171,7 @@ function proposeAccrualLines(
const amount = Math.round(bookedSekAmount * 100) / 100
lines.push({
account_number: paymentAccount,
debit_amount: toFormAmount(amount),
debit_amount: toFormAmount(amount + roundingDelta),
credit_amount: '',
line_description: desc,
})
@@ -146,6 +181,9 @@ function proposeAccrualLines(
credit_amount: toFormAmount(amount),
line_description: desc,
})
if (roundingDelta !== 0) {
lines.push(oreRoundingLine(roundingDelta))
}
}
return lines
@@ -155,7 +193,8 @@ function proposeCashLines(
invoice: ProposePaymentLinesInput['invoice'],
paymentAccount: string,
desc: string,
entityType: EntityType
entityType: EntityType,
roundingDelta = 0
): FormLine[] {
const lines: FormLine[] = []
const isForeign = invoice.currency !== 'SEK'
@@ -264,12 +303,16 @@ function proposeCashLines(
lines.push({
account_number: paymentAccount,
debit_amount: toFormAmount(debitAmount),
debit_amount: toFormAmount(debitAmount + roundingDelta),
credit_amount: '',
line_description: desc,
})
lines.push(...creditLines)
if (roundingDelta !== 0) {
lines.push(oreRoundingLine(roundingDelta))
}
return lines
}
+78 -3
View File
@@ -6,17 +6,22 @@ vi.mock('next/headers', () => ({
cookies: vi.fn(async () => ({ set: mockCookieSet })),
}))
import { setActiveCompany, CompanyContextError, getCompanyDisplayName } from '../context'
import { setActiveCompany, CompanyContextError, getCompanyDisplayName, getActiveCompanyId } from '../context'
type CapturedCall = { table: string; method: string; args: unknown[] }
type TerminalResult = { data?: unknown; error?: unknown }
/**
* Chainable Supabase mock (same approach as actions.test.ts): a chain method
* terminates with `results[table][method]` when seeded, otherwise keeps
* chaining. setActiveCompany ends both its queries on `.single()`, on
* different tables, so seeding `single` per table drives each branch.
* A terminal seeded as an ARRAY is consumed in call order, for functions
* that query the same table twice (getActiveCompanyId's fallback fetch +
* preference validation both end on company_members.maybeSingle()).
*/
function buildSupabase(results: Record<string, Record<string, { data?: unknown; error?: unknown }>>) {
function buildSupabase(results: Record<string, Record<string, TerminalResult | TerminalResult[]>>) {
const calls: CapturedCall[] = []
function makeChain(table: string) {
@@ -25,7 +30,8 @@ function buildSupabase(results: Record<string, Record<string, { data?: unknown;
for (const m of methods) {
chain[m] = (...args: unknown[]) => {
calls.push({ table, method: m, args })
const terminal = results[table]?.[m]
const seeded = results[table]?.[m]
const terminal = Array.isArray(seeded) ? seeded.shift() : seeded
if (terminal) {
return Promise.resolve({ data: terminal.data ?? null, error: terminal.error ?? null })
}
@@ -110,6 +116,75 @@ describe('setActiveCompany', () => {
})
})
describe('getActiveCompanyId', () => {
it('resolves the preferred company with ONE company_members query when it is the first membership', async () => {
const { supabase, calls } = buildSupabase({
user_preferences: { maybeSingle: { data: { active_company_id: 'company-1' } } },
company_members: { maybeSingle: { data: { company_id: 'company-1' } } },
})
const id = await getActiveCompanyId(supabase as never, 'user-1')
expect(id).toBe('company-1')
// The parallel fallback fetch doubles as validation in the common
// single-company case: no second, sequential round trip.
const memberQueries = calls.filter((c) => c.table === 'company_members' && c.method === 'maybeSingle')
expect(memberQueries).toHaveLength(1)
})
it('validates a preference that differs from the first membership', async () => {
const { supabase, calls } = buildSupabase({
user_preferences: { maybeSingle: { data: { active_company_id: 'company-2' } } },
company_members: {
maybeSingle: [
{ data: { company_id: 'company-1' } }, // first membership (parallel fetch)
{ data: { company_id: 'company-2' } }, // validation of the preference
],
},
})
const id = await getActiveCompanyId(supabase as never, 'user-1')
expect(id).toBe('company-2')
const memberQueries = calls.filter((c) => c.table === 'company_members' && c.method === 'maybeSingle')
expect(memberQueries).toHaveLength(2)
})
it('falls back to the first membership when the preference is stale', async () => {
const { supabase } = buildSupabase({
user_preferences: { maybeSingle: { data: { active_company_id: 'company-archived' } } },
company_members: {
maybeSingle: [
{ data: { company_id: 'company-1' } }, // first membership
{ data: null }, // validation: preference archived / membership gone
],
},
})
expect(await getActiveCompanyId(supabase as never, 'user-1')).toBe('company-1')
})
it('falls back to the first membership when there is no preference row', async () => {
const { supabase, calls } = buildSupabase({
user_preferences: { maybeSingle: { data: null } },
company_members: { maybeSingle: { data: { company_id: 'company-1' } } },
})
expect(await getActiveCompanyId(supabase as never, 'user-1')).toBe('company-1')
const memberQueries = calls.filter((c) => c.table === 'company_members' && c.method === 'maybeSingle')
expect(memberQueries).toHaveLength(1)
})
it('returns null when the user has no non-archived memberships', async () => {
const { supabase } = buildSupabase({
user_preferences: { maybeSingle: { data: null } },
company_members: { maybeSingle: { data: null } },
})
expect(await getActiveCompanyId(supabase as never, 'user-1')).toBeNull()
})
})
describe('getCompanyDisplayName', () => {
it('returns company_settings.company_name and never reads companies when set', async () => {
const { supabase, calls } = buildSupabase({
+31 -18
View File
@@ -37,16 +37,38 @@ export async function getActiveCompanyId(
supabase: SupabaseClient,
userId: string
): Promise<string | null> {
// 1. user_preferences: authoritative
const { data: prefs } = await supabase
.from('user_preferences')
.select('active_company_id')
.eq('user_id', userId)
.maybeSingle()
// user_preferences (authoritative) + first membership, fetched in parallel:
// the fallback query result doubles as validation when the preferred
// company happens to be the first membership, which is the common
// single-company case. Most requests pay one round trip instead of two
// sequential ones. This runs on every withRouteContext API request and
// every dashboard layout render, so the sequential version was pure
// wall-clock cost. Mirrors resolveCompanyForMiddleware, minus the
// write-back (read paths shouldn't write).
const [{ data: prefs }, { data: firstCompany }] = await Promise.all([
supabase
.from('user_preferences')
.select('active_company_id')
.eq('user_id', userId)
.maybeSingle(),
supabase
.from('company_members')
.select('company_id, companies!inner(archived_at)')
.eq('user_id', userId)
.is('companies.archived_at', null)
.order('created_at', { ascending: true })
.limit(1)
.maybeSingle(),
])
if (prefs?.active_company_id) {
// Validate the preference still points to a non-archived company the
// user is a member of.
if (firstCompany && prefs.active_company_id === firstCompany.company_id) {
return firstCompany.company_id
}
// Preference points at a different company than the first membership:
// validate it still resolves to a non-archived company the user is a
// member of before trusting it.
const { data: membership } = await supabase
.from('company_members')
.select('company_id, companies!inner(archived_at)')
@@ -58,16 +80,7 @@ export async function getActiveCompanyId(
if (membership) return membership.company_id
}
// 2. Fallback: first non-archived membership by created_at
const { data: firstCompany } = await supabase
.from('company_members')
.select('company_id, companies!inner(archived_at)')
.eq('user_id', userId)
.is('companies.archived_at', null)
.order('created_at', { ascending: true })
.limit(1)
.maybeSingle()
// Fallback: first non-archived membership by created_at (already fetched)
return firstCompany?.company_id ?? null
}
@@ -2,7 +2,7 @@ export const COOKBOOK_PAYROLL_AGI_MD = `# Cookbook: run payroll and generate the
> Drive a Swedish salary run from draft to booked, then generate the arbetsgivardeklaration individnivå (AGI) XML for manual submission to Skatteverket. Five-step lifecycle, every state transition idempotent and dry-runnable (generate-agi is idempotent but not dry-runnable).
This is the operational companion to the [Salary-runs reference](/docs/api/reference/salary-runs). Most of the payroll lifecycle is API-callable, but one step is still dashboard-only: **attaching employees to a run** (the \`/salary-runs/{id}/employees\` v1 endpoint is forthcoming), so a run can't yet be populated end-to-end from the public API. Everything after that calculate, approve, mark paid, book, generate AGI is.
This is the operational companion to the [Salary-runs reference](/docs/api/reference/salary-runs). The whole lifecycle is API-callable end-to-end: create the run, attach employees (\`POST /salary-runs/{id}/employees\`), add manual payslip lines if needed, calculate, approve, mark paid, book, generate AGI. Per-employee results are readable via \`GET /salary-runs/{id}/employees\` (list) and \`GET /salary-runs/{id}/employees/{employeeId}\` (payslip detail incl. line items and the step-by-step calculation breakdown); the rendered payslip PDF is at \`GET /salary-runs/{id}/payslips/{employeeId}/pdf\`.
## What you'll need
@@ -12,7 +12,7 @@ This is the operational companion to the [Salary-runs reference](/docs/api/refer
## 1. Create a salary run (draft)
\`POST /salary-runs\` opens an **empty** run in \`draft\` status: it takes only the period + payment metadata (\`period_year\`, \`period_month\`, \`payment_date\`, optional \`voucher_series\` and \`notes\`). Employees are attached in a separate step (via the dashboard, or the forthcoming \`/salary-runs/{id}/employees\` surface) before you \`:calculate\`.
\`POST /salary-runs\` opens an **empty** run in \`draft\` status: it takes only the period + payment metadata (\`period_year\`, \`period_month\`, \`payment_date\`, optional \`voucher_series\` and \`notes\`). Attach each employee with \`POST /salary-runs/{id}/employees\` (body \`{ "employee_id": "..." }\`, plus \`hours_worked\` for hourly staff) before you \`:calculate\`. The attach snapshots the employee's pay config onto the run and seeds the base salary line; one-off components (bonus, deduction, reimbursement) go through \`POST /salary-runs/{id}/employees/{employeeId}/lines\` while the run is still a draft. Line edits never recompute tax: always \`:calculate\` afterwards.
\`\`\`bash
curl "https://app.gnubok.se/api/v1/companies/$COMPANY_ID/salary-runs" \\
+60
View File
@@ -1792,6 +1792,66 @@ const SALARY: Record<string, StructuredErrorEntry> = {
message_sv: 'Inga aktiva anställda finns i företaget.',
message_en: 'No active employees in the company.',
},
SALARY_RUN_LINE_NOT_DRAFT: {
httpStatus: 400,
message_sv: 'Lönebeskedets rader kan bara redigeras medan lönekörningen är ett utkast.',
message_en: 'Payslip lines can only be edited while the salary run is a draft.',
},
SALARY_RUN_EMPLOYEE_NOT_FOUND: {
httpStatus: 404,
message_sv: 'Anställd finns inte i denna lönekörning.',
message_en: 'Employee is not part of this salary run.',
},
SALARY_LINE_NOT_FOUND: {
httpStatus: 404,
message_sv: 'Lönebeskedsraden kunde inte hittas.',
message_en: 'Payslip line not found.',
},
SALARY_RUN_EMPLOYEE_DUPLICATE: {
httpStatus: 409,
message_sv: 'Den anställda finns redan i lönekörningen.',
message_en: 'Employee is already part of this salary run.',
},
SALARY_RUN_EMPLOYEES_NOT_DRAFT: {
httpStatus: 400,
message_sv: 'Anställda kan bara läggas till eller tas bort medan lönekörningen är ett utkast.',
message_en: 'Employees can only be added or removed while the salary run is a draft.',
},
ABSENCE_RANGE_TOO_LARGE: {
httpStatus: 400,
message_sv: 'Frånvarointervallet är för stort. Max 92 dagar per anrop.',
message_en: 'Absence range too large. Maximum 92 days per request.',
},
ABSENCE_HOURS_CONFLICT: {
httpStatus: 409,
message_sv: 'Total frånvarotid för dagen överstiger 24 timmar.',
message_en: 'Total absence hours for the day exceed 24 hours.',
},
OPENING_BALANCES_LOCKED: {
httpStatus: 409,
message_sv: 'Ingående saldon är låsta: den anställda har en bokförd lönekörning.',
message_en: 'Opening balances are locked: the employee has a booked salary run.',
},
VACATION_YEAR_NOT_ENDED: {
httpStatus: 400,
message_sv: 'Semesteråret kan inte stängas innan det har tagit slut.',
message_en: 'The vacation year cannot be closed before it has ended.',
},
VACATION_YEAR_ALREADY_CLOSED: {
httpStatus: 409,
message_sv: 'Semesteråret är redan stängt.',
message_en: 'The vacation year is already closed.',
},
VACATION_CLOSE_ADJUSTMENT_FAILED: {
httpStatus: 500,
message_sv: 'Semestersaldon rullades men justeringsverifikationen kunde inte bokföras. Bokför justeringen manuellt från rapporten.',
message_en: 'Vacation balances rolled but the adjustment entry failed to post. Book the adjustment manually from the report.',
},
VACATION_BALANCE_NOT_FOUND: {
httpStatus: 404,
message_sv: 'Inget semestersaldo finns för den anställda ännu.',
message_en: 'No vacation balance exists for the employee yet.',
},
SALARY_RUN_TAX_TABLE_MISSING: {
httpStatus: 400,
message_sv: 'Skattetabellen saknas för perioden. Importera skattetabellen först.',
+42
View File
@@ -0,0 +1,42 @@
'use client'
import { useEffect } from 'react'
import { useFetch } from './use-fetch'
import type { AgiSubmissionState } from '@/lib/salary/agi-submission-state'
/**
* Fetches the Skatteverket extension's per-period AGI submission record
* (`agi_submission_{period}`, period = YYYYMM) so the run page, progress
* rail, and salary hero can render the real filing state machine
* (underlag submitted / awaiting BankID signature / signed).
*
* Returns `submission: null` when the extension is disabled (503, e.g.
* self-hosted), the user isn't connected, or nothing has been submitted
* yet: callers fall back to the run row's own agi_* timestamps. Pass a
* null period to skip fetching entirely (e.g. a run that isn't booked).
*
* Refetches when the tab regains focus: the user typically signs in
* Skatteverket's Mina Sidor in another tab (or on their phone) and comes
* back here expecting the state to have caught up.
*/
export function useAgiSubmission(period: string | null): {
submission: AgiSubmissionState | null
refresh: () => void
} {
const { data, refetch } = useFetch<
{ data?: AgiSubmissionState | null },
AgiSubmissionState | null
>(period ? `/api/extensions/ext/skatteverket/agi/status?period=${period}` : null, {
select: body => body?.data ?? null,
})
useEffect(() => {
function onVisible() {
if (document.visibilityState === 'visible') refetch()
}
document.addEventListener('visibilitychange', onVisible)
return () => document.removeEventListener('visibilitychange', onVisible)
}, [refetch])
return { submission: data, refresh: refetch }
}
+56
View File
@@ -0,0 +1,56 @@
'use client'
import useSWR from 'swr'
import { createClient } from '@/lib/supabase/client'
export interface WorklistBadges {
/** Unbooked bank transactions: same predicate as lib/worklist countUnbookedTransactions. */
uncategorized: number
/** Agent-staged operations awaiting review: same predicate as countPendingOperations. */
pendingOperations: number
}
/**
* Client-side nav badge counts. These used to be fetched by the dashboard
* layout on the critical path of every server navigation; two head-count
* queries nobody needs before first paint. Now they load (and revalidate)
* after mount, and SWR dedupes the realtime-triggered refreshes that
* previously stampeded during bulk operations.
*
* The predicates deliberately mirror lib/worklist/categories.ts so the badge
* shows the same number as every other "att göra" surface; RLS scopes both
* tables, and the explicit company_id filter is defense in depth.
*/
export function useWorklistBadges(companyId: string | null | undefined) {
const { data, mutate } = useSWR<WorklistBadges>(
companyId ? ['worklist-badges', companyId] : null,
async ([, id]: [string, string]) => {
const supabase = createClient()
const [tx, ops] = await Promise.all([
supabase
.from('transactions')
.select('id', { count: 'exact', head: true })
.eq('company_id', id)
.is('is_business', null)
.eq('is_ignored', false),
supabase
.from('pending_operations')
.select('id', { count: 'exact', head: true })
.eq('company_id', id)
.eq('status', 'pending'),
])
return {
uncategorized: tx.error ? 0 : (tx.count ?? 0),
pendingOperations: ops.error ? 0 : (ops.count ?? 0),
}
},
)
return {
uncategorized: data?.uncategorized ?? 0,
pendingOperations: data?.pendingOperations ?? 0,
// SWR's bound mutate is referentially stable, so consumers can list it in
// effect deps without re-subscribing on every render.
refresh: mutate,
}
}
@@ -1,6 +1,7 @@
import { describe, it, expect } from 'vitest'
import {
planInvoicePayment,
planInvoicePaymentForLines,
PAYMENT_OVERSHOOT_TOLERANCE,
} from '@/lib/invoices/apply-invoice-payment'
@@ -65,4 +66,184 @@ describe('planInvoicePayment', () => {
it('overshoot tolerance is half an öre', () => {
expect(PAYMENT_OVERSHOOT_TOLERANCE).toBe(0.005)
})
describe('absorbOreRounding', () => {
it('settles in full when the customer paid the rounded-up "Att betala"', () => {
// Invoice total 1234.75, PDF shows 1235.00 (öresavrundning), customer pays that.
const r = planInvoicePayment(
{ total: 1234.75, paid_amount: 0, remaining_amount: 1234.75 },
1235,
{ absorbOreRounding: true },
)
expect(r.ok).toBe(true)
if (r.ok) {
expect(r.plan).toEqual({
newPaidAmount: 1234.75,
newRemaining: 0,
isFullyPaid: true,
newStatus: 'paid',
oreSettled: true,
})
}
})
it('settles a sub-krona short payment in full (rounded-down total)', () => {
const r = planInvoicePayment(
{ total: 1000.4, paid_amount: 0, remaining_amount: 1000.4 },
1000,
{ absorbOreRounding: true },
)
expect(r.ok).toBe(true)
if (r.ok) {
expect(r.plan.newStatus).toBe('paid')
expect(r.plan.newPaidAmount).toBe(1000.4)
expect(r.plan.newRemaining).toBe(0)
expect(r.plan.oreSettled).toBe(true)
}
})
it('still rejects an overshoot beyond the öre band', () => {
const r = planInvoicePayment(
{ total: 1000, paid_amount: 0, remaining_amount: 1000 },
1001.25,
{ absorbOreRounding: true },
)
expect(r.ok).toBe(false)
})
it('rejects an overshoot of exactly 1 kr (band boundary must not over-record)', () => {
const r = planInvoicePayment(
{ total: 1000, paid_amount: 0, remaining_amount: 1000 },
1001,
{ absorbOreRounding: true },
)
expect(r.ok).toBe(false)
})
it('a >=1 kr short payment stays a real partial', () => {
const r = planInvoicePayment(
{ total: 1000, paid_amount: 0, remaining_amount: 1000 },
999,
{ absorbOreRounding: true },
)
expect(r.ok).toBe(true)
if (r.ok) {
expect(r.plan.newStatus).toBe('partially_paid')
expect(r.plan.newRemaining).toBe(1)
expect(r.plan.oreSettled).toBe(false)
}
})
it('without the opt-in the sub-krona overshoot is still rejected', () => {
const r = planInvoicePayment(
{ total: 1234.75, paid_amount: 0, remaining_amount: 1234.75 },
1235,
)
expect(r.ok).toBe(false)
})
})
})
describe('planInvoicePaymentForLines', () => {
const INV = { total: 1234.75, paid_amount: 0, remaining_amount: 1234.75 }
it('absorbs when the lines carry the exact residual on 3740', () => {
const r = planInvoicePaymentForLines(
INV,
1235,
[
{ account_number: '1930', debit_amount: 1235, credit_amount: 0 },
{ account_number: '1510', debit_amount: 0, credit_amount: 1234.75 },
{ account_number: '3740', debit_amount: 0, credit_amount: 0.25 },
],
'SEK',
)
expect(r.ok).toBe(true)
if (r.ok) {
expect(r.plan.newStatus).toBe('paid')
expect(r.plan.newPaidAmount).toBe(1234.75)
expect(r.plan.oreSettled).toBe(true)
}
})
it('short-payment lines with a 3740 debit residual settle in full', () => {
const r = planInvoicePaymentForLines(
{ total: 1000.4, paid_amount: 0, remaining_amount: 1000.4 },
1000,
[
{ account_number: '1930', debit_amount: 1000, credit_amount: 0 },
{ account_number: '1510', debit_amount: 0, credit_amount: 1000.4 },
{ account_number: '3740', debit_amount: 0.4, credit_amount: 0 },
],
'SEK',
)
expect(r.ok).toBe(true)
if (r.ok) expect(r.plan.newStatus).toBe('paid')
})
it('a sub-krona short WITHOUT a 3740 line stays a real partial (no silent write-off)', () => {
// Deliberate partial: user lowered both legs; 1510 is only cleared by the
// paid amount, so flipping to paid would orphan the residual on 1510.
const r = planInvoicePaymentForLines(
INV,
1234,
[
{ account_number: '1930', debit_amount: 1234, credit_amount: 0 },
{ account_number: '1510', debit_amount: 0, credit_amount: 1234 },
],
'SEK',
)
expect(r.ok).toBe(true)
if (r.ok) {
expect(r.plan.newStatus).toBe('partially_paid')
expect(r.plan.newRemaining).toBe(0.75)
expect(r.plan.oreSettled).toBe(false)
}
})
it('a sub-krona overshoot WITHOUT a 3740 line is rejected (1510 would over-credit)', () => {
const r = planInvoicePaymentForLines(
INV,
1235.25,
[
{ account_number: '1930', debit_amount: 1235.25, credit_amount: 0 },
{ account_number: '1510', debit_amount: 0, credit_amount: 1235.25 },
],
'SEK',
)
expect(r.ok).toBe(false)
})
it('a 3740 amount that does not match the residual falls back to the strict plan', () => {
const r = planInvoicePaymentForLines(
INV,
1235.25, // 0.50 over, but the lines only book 0.25 on 3740
[
{ account_number: '1930', debit_amount: 1235.25, credit_amount: 0 },
{ account_number: '1510', debit_amount: 0, credit_amount: 1235 },
{ account_number: '3740', debit_amount: 0, credit_amount: 0.25 },
],
'SEK',
)
expect(r.ok).toBe(false)
})
it('never absorbs for non-SEK invoices', () => {
const r = planInvoicePaymentForLines(
{ total: 100, paid_amount: 0, remaining_amount: 100 },
100.25,
[
{ account_number: '1930', debit_amount: 100.25, credit_amount: 0 },
{ account_number: '1510', debit_amount: 0, credit_amount: 100 },
{ account_number: '3740', debit_amount: 0, credit_amount: 0.25 },
],
'EUR',
)
expect(r.ok).toBe(false)
})
it('without lines it behaves exactly like the strict plan', () => {
expect(planInvoicePaymentForLines(INV, 1234.75, undefined, 'SEK').ok).toBe(true)
expect(planInvoicePaymentForLines(INV, 1235, undefined, 'SEK').ok).toBe(false)
})
})
@@ -1,9 +1,80 @@
import { describe, it, expect } from 'vitest'
import { describe, it, expect, vi, beforeEach } from 'vitest'
import type { SupabaseClient } from '@supabase/supabase-js'
import {
computeNextRunDate,
computeInitialRunDate,
getStockholmDateHour,
executeRecurringSchedule,
} from '@/lib/invoices/recurring-schedule-service'
import { createQueuedMockSupabase, makeCustomer, makeCompanySettings } from '@/tests/helpers'
import { eventBus } from '@/lib/events'
// ── Mocks for the executeRecurringSchedule auto-send path ─────────────
// The pure date-helper tests below don't touch any of these modules.
const mockRenderToBuffer = vi.fn()
vi.mock('@react-pdf/renderer', () => ({
renderToBuffer: (...args: unknown[]) => mockRenderToBuffer(...args),
}))
const mockInvoicePDF = vi.fn()
vi.mock('@/lib/invoices/pdf-template', () => ({
InvoicePDF: (...args: unknown[]) => mockInvoicePDF(...args),
}))
const mockPrepareRender = vi.fn()
const mockSwishQr = vi.fn()
const mockPaymentLinkQr = vi.fn()
vi.mock('@/lib/invoices/pdf-render-helpers', () => ({
prepareInvoicePdfRender: (...args: unknown[]) => mockPrepareRender(...args),
buildSwishQrDataUrl: (...args: unknown[]) => mockSwishQr(...args),
buildPaymentLinkQrDataUrl: (...args: unknown[]) => mockPaymentLinkQr(...args),
}))
const mockApplyPaymentLink = vi.fn()
vi.mock('@/lib/extensions/payment-links', () => ({
applyPaymentLinkToInvoice: (...args: unknown[]) => mockApplyPaymentLink(...args),
}))
const mockSendEmail = vi.fn()
const mockIsConfigured = vi.fn()
vi.mock('@/lib/email/service', () => ({
getEmailService: () => ({
sendEmail: (...args: unknown[]) => mockSendEmail(...args),
isConfigured: () => mockIsConfigured(),
}),
}))
vi.mock('@/lib/email/invoice-templates', () => ({
generateInvoiceEmailHtml: vi.fn().mockReturnValue('<html>Invoice</html>'),
generateInvoiceEmailText: vi.fn().mockReturnValue('Invoice text'),
generateInvoiceEmailSubject: vi.fn().mockReturnValue('Faktura F-1'),
}))
const mockIsSandbox = vi.fn()
vi.mock('@/lib/sandbox/guard', () => ({
isSandboxCompany: (...args: unknown[]) => mockIsSandbox(...args),
}))
const mockHasCapability = vi.fn()
vi.mock('@/lib/entitlements/has-capability', () => ({
hasCapability: (...args: unknown[]) => mockHasCapability(...args),
}))
const mockEnsureNumber = vi.fn()
vi.mock('@/lib/invoices/ensure-invoice-number', () => ({
ensureInvoiceNumber: (...args: unknown[]) => mockEnsureNumber(...args),
}))
const mockCreateJE = vi.fn()
vi.mock('@/lib/bookkeeping/invoice-entries', () => ({
createInvoiceJournalEntry: (...args: unknown[]) => mockCreateJE(...args),
}))
const mockUploadDocument = vi.fn()
vi.mock('@/lib/core/documents/document-service', () => ({
uploadDocument: (...args: unknown[]) => mockUploadDocument(...args),
}))
describe('computeNextRunDate', () => {
it('advances day 15 from January to February', () => {
@@ -98,3 +169,190 @@ describe('getStockholmDateHour', () => {
})
})
})
describe('executeRecurringSchedule auto-send', () => {
const { supabase, enqueue, reset } = createQueuedMockSupabase()
const client = supabase as unknown as SupabaseClient
const today = new Date('2026-07-06T06:30:00Z')
const customer = makeCustomer({ id: 'cust-1', email: 'kund@test.se' })
const company = makeCompanySettings({ accounting_method: 'accrual' })
function makeSchedule() {
return {
id: 'sched-1',
company_id: 'company-1',
user_id: 'user-1',
customer_id: 'cust-1',
name: 'Monthly retainer',
day_of_month: 6,
send_hour: 8,
payment_terms_days: 30,
currency: 'SEK',
your_reference: null,
our_reference: null,
notes: null,
auto_send: true,
status: 'active',
next_run_date: '2026-07-06',
last_run_at: null,
last_invoice_id: null,
last_run_warning: null,
generated_count: 0,
items: [
{
id: 'si-1',
schedule_id: 'sched-1',
sort_order: 0,
description: 'Konsulttimmar',
quantity: 10,
unit: 'tim',
unit_price: 1000,
vat_rate: 25,
},
],
} as unknown as Parameters<typeof executeRecurringSchedule>[1]
}
// Fresh objects per test: ensureInvoiceNumber and applyPaymentLinkToInvoice
// mutate the invoice they receive, so shared fixtures would leak state.
function makeInsertedInvoice() {
return { id: 'inv-1', invoice_number: null, document_type: 'invoice' }
}
function makeCompleteInvoice() {
return {
id: 'inv-1',
invoice_number: 'F-1',
status: 'draft',
document_type: 'invoice',
currency: 'SEK',
total: 12500,
credited_invoice_id: null,
payment_link_url: null,
customer,
items: [{ id: 'item-1', sort_order: 0 }],
}
}
/** Queue for the full happy path (see call order in the service). */
function enqueueHappyPath() {
enqueue({ data: customer, error: null }) // customers select
enqueue({ data: makeInsertedInvoice(), error: null }) // invoices insert
enqueue({ data: null, error: null }) // invoice_items insert
enqueue({ data: makeCompleteInvoice(), error: null }) // re-fetch with relations
enqueue({ data: company, error: null }) // company_settings (auto-send)
enqueue({ data: null, error: null }) // status flip to sent
enqueue({ data: null, error: null }) // journal_entry_id write-back
}
beforeEach(() => {
vi.clearAllMocks()
reset()
eventBus.clear()
mockIsConfigured.mockReturnValue(true)
mockIsSandbox.mockResolvedValue(false)
mockHasCapability.mockResolvedValue(true)
mockApplyPaymentLink.mockResolvedValue({ failure: null })
mockEnsureNumber.mockImplementation(
async (_supabase: unknown, _companyId: unknown, inv: { invoice_number: string | null }) => {
inv.invoice_number = 'F-1'
return 'F-1'
},
)
mockPrepareRender.mockResolvedValue({ branding: {}, company })
mockSwishQr.mockResolvedValue(null)
mockPaymentLinkQr.mockResolvedValue(null)
mockRenderToBuffer.mockResolvedValue(Buffer.from('fake-pdf'))
mockInvoicePDF.mockReturnValue('pdf-element')
mockSendEmail.mockResolvedValue({ success: true, messageId: 'm-1' })
mockCreateJE.mockResolvedValue({ id: 'je-1' })
mockUploadDocument.mockResolvedValue({})
})
it('creates a payment link before rendering and passes its QR to the PDF', async () => {
enqueueHappyPath()
mockApplyPaymentLink.mockImplementation(
async (_s: unknown, _c: unknown, _u: unknown, inv: { payment_link_url: string | null }) => {
inv.payment_link_url = 'https://pay.example/x'
return { failure: null }
},
)
mockPaymentLinkQr.mockResolvedValue('data:image/png;base64,QR')
const result = await executeRecurringSchedule(client, makeSchedule(), today)
expect(result.autoSent).toBe(true)
expect(result.warning).toBeNull()
expect(mockApplyPaymentLink).toHaveBeenCalledTimes(1)
expect(mockApplyPaymentLink).toHaveBeenCalledWith(
expect.anything(),
'company-1',
'user-1',
expect.objectContaining({ id: 'inv-1' }),
expect.anything(),
)
// Link applied BEFORE the render so the email button and PDF QR carry it.
expect(mockApplyPaymentLink.mock.invocationCallOrder[0]).toBeLessThan(
mockRenderToBuffer.mock.invocationCallOrder[0],
)
// QR built from the renderable copy (status overridden to 'sent').
expect(mockPaymentLinkQr).toHaveBeenCalledWith(
expect.objectContaining({ payment_link_url: 'https://pay.example/x', status: 'sent' }),
)
expect(mockInvoicePDF).toHaveBeenCalledWith(
expect.objectContaining({ paymentLinkQrDataUrl: 'data:image/png;base64,QR' }),
)
})
it('a payment link failure never blocks the send', async () => {
enqueueHappyPath()
mockApplyPaymentLink.mockResolvedValue({ failure: 'Stripe nere' })
const result = await executeRecurringSchedule(client, makeSchedule(), today)
expect(result.autoSent).toBe(true)
expect(result.warning).toBeNull()
expect(mockSendEmail).toHaveBeenCalledTimes(1)
})
it('never auto-sends from a sandbox company; invoice stays a numbered draft', async () => {
mockIsSandbox.mockResolvedValue(true)
// Sandbox bails before company_settings/payment-link/render/email, so the
// queue only covers invoice creation.
enqueue({ data: customer, error: null })
enqueue({ data: makeInsertedInvoice(), error: null })
enqueue({ data: null, error: null })
enqueue({ data: makeCompleteInvoice(), error: null })
const result = await executeRecurringSchedule(client, makeSchedule(), today)
expect(result.invoiceId).toBe('inv-1')
expect(result.autoSent).toBe(false)
expect(result.warning).toContain('Auto-utskick misslyckades')
expect(mockSendEmail).not.toHaveBeenCalled()
expect(mockApplyPaymentLink).not.toHaveBeenCalled()
expect(mockCreateJE).not.toHaveBeenCalled()
})
it('route-level suppressAutoSend skips the send path without relying on the internal chokepoint', async () => {
// Defence in depth (ASVS V2.3): the flag comes from the route's own
// isSandboxCompany resolution, so sending is suppressed even before the
// service-internal sandbox check runs. Invoice creation is unaffected.
enqueue({ data: customer, error: null })
enqueue({ data: makeInsertedInvoice(), error: null })
enqueue({ data: null, error: null })
enqueue({ data: makeCompleteInvoice(), error: null })
const result = await executeRecurringSchedule(client, makeSchedule(), today, {
suppressAutoSend: true,
})
expect(result.invoiceId).toBe('inv-1')
expect(result.autoSent).toBe(false)
expect(result.warning).toContain('Auto-utskick misslyckades')
expect(mockSendEmail).not.toHaveBeenCalled()
// The suppress branch bails before the email chokepoint entirely.
expect(mockIsSandbox).not.toHaveBeenCalled()
expect(mockCreateJE).not.toHaveBeenCalled()
})
})
@@ -19,6 +19,7 @@ import {
createInvoicePaymentJournalEntry,
createInvoiceCashEntry,
} from '@/lib/bookkeeping/invoice-entries'
import { createJournalEntry, findFiscalPeriod } from '@/lib/bookkeeping/engine'
import { cancelOrphanedPaymentEntry } from '@/lib/bookkeeping/cancel-orphaned-entry'
import { settleInvoicePayment } from '@/lib/invoices/settle-invoice-payment'
import { eventBus } from '@/lib/events'
@@ -100,6 +101,152 @@ describe('settleInvoicePayment', () => {
expect(vi.mocked(createInvoicePaymentJournalEntry)).not.toHaveBeenCalled()
})
it('absorbs a sub-krona öresavrundning overshoot on SEK custom lines', async () => {
vi.mocked(findFiscalPeriod).mockResolvedValue('fp-1')
vi.mocked(createJournalEntry).mockResolvedValue({ id: 'je-ore' } as never)
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: [{ id: 'inv-1' }] }) // CAS update matched
// Invoice total 1234.75, PDF "Att betala" 1235.00: the customer pays the
// rounded amount and the 3740 line carries the residual.
const invoice = payableInvoice({
total: 1234.75,
remaining_amount: 1234.75,
journal_entry_id: 'je-orig',
} as Partial<Invoice>)
const result = await settleInvoicePayment(
supabase as unknown as SupabaseClient,
'company-1',
'user-1',
{
...BASE_PARAMS,
invoice,
paymentAmountInInvoiceCurrency: 1235,
customLines: [
{ account_number: '1930', debit_amount: 1235, credit_amount: 0 },
{ account_number: '1510', debit_amount: 0, credit_amount: 1234.75 },
{ account_number: '3740', debit_amount: 0, credit_amount: 0.25 },
],
},
)
expect(result).toMatchObject({
ok: true,
newStatus: 'paid',
newPaidAmount: 1234.75,
newRemaining: 0,
journalEntryId: 'je-ore',
})
})
it('keeps a sub-krona short partial WITHOUT a 3740 line partially paid', async () => {
vi.mocked(findFiscalPeriod).mockResolvedValue('fp-1')
vi.mocked(createJournalEntry).mockResolvedValue({ id: 'je-partial' } as never)
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: [{ id: 'inv-1' }] }) // CAS update matched
// Deliberate partial: both legs lowered, no 3740. Absorbing here would
// flip the invoice to paid while 1510 keeps the 0.75 residual.
const invoice = payableInvoice({
total: 1234.75,
remaining_amount: 1234.75,
journal_entry_id: 'je-orig',
} as Partial<Invoice>)
const result = await settleInvoicePayment(
supabase as unknown as SupabaseClient,
'company-1',
'user-1',
{
...BASE_PARAMS,
invoice,
paymentAmountInInvoiceCurrency: 1234,
customLines: [
{ account_number: '1930', debit_amount: 1234, credit_amount: 0 },
{ account_number: '1510', debit_amount: 0, credit_amount: 1234 },
],
},
)
expect(result).toMatchObject({
ok: true,
newStatus: 'partially_paid',
newPaidAmount: 1234,
newRemaining: 0.75,
})
})
it('rejects a sub-krona custom-line overshoot WITHOUT a 3740 line', async () => {
const { supabase } = createQueuedMockSupabase()
const invoice = payableInvoice({
total: 1234.75,
remaining_amount: 1234.75,
} as Partial<Invoice>)
const result = await settleInvoicePayment(
supabase as unknown as SupabaseClient,
'company-1',
'user-1',
{
...BASE_PARAMS,
invoice,
paymentAmountInInvoiceCurrency: 1235.25,
customLines: [
{ account_number: '1930', debit_amount: 1235.25, credit_amount: 0 },
{ account_number: '1510', debit_amount: 0, credit_amount: 1235.25 },
],
},
)
expect(result).toMatchObject({ ok: false, code: 'MATCH_AMOUNT_EXCEEDS_REMAINING' })
expect(vi.mocked(createJournalEntry)).not.toHaveBeenCalled()
})
it('rejects a custom-line overshoot beyond the öre band', async () => {
const { supabase } = createQueuedMockSupabase()
const invoice = payableInvoice({
total: 1234.75,
remaining_amount: 1234.75,
} as Partial<Invoice>)
const result = await settleInvoicePayment(
supabase as unknown as SupabaseClient,
'company-1',
'user-1',
{
...BASE_PARAMS,
invoice,
paymentAmountInInvoiceCurrency: 1236,
customLines: [
{ account_number: '1930', debit_amount: 1236, credit_amount: 0 },
{ account_number: '1510', debit_amount: 0, credit_amount: 1236 },
],
},
)
expect(result).toMatchObject({ ok: false, code: 'MATCH_AMOUNT_EXCEEDS_REMAINING' })
expect(vi.mocked(createJournalEntry)).not.toHaveBeenCalled()
})
it('does not absorb öre overshoot for non-SEK invoices', async () => {
const { supabase } = createQueuedMockSupabase()
const invoice = payableInvoice({
total: 100,
remaining_amount: 100,
currency: 'EUR',
} as Partial<Invoice>)
const result = await settleInvoicePayment(
supabase as unknown as SupabaseClient,
'company-1',
'user-1',
{
...BASE_PARAMS,
invoice,
paymentAmountInInvoiceCurrency: 100.25,
customLines: [
{ account_number: '1930', debit_amount: 100.25, credit_amount: 0 },
{ account_number: '1510', debit_amount: 0, credit_amount: 100.25 },
],
},
)
expect(result).toMatchObject({ ok: false, code: 'MATCH_AMOUNT_EXCEEDS_REMAINING' })
})
it('rejects overpayment before creating any journal entry', async () => {
const { supabase } = createQueuedMockSupabase()
const result = await settleInvoicePayment(
+56 -5
View File
@@ -61,11 +61,18 @@ export function planInvoicePayment(
const currentRemaining =
invoice.remaining_amount ?? invoice.total - (invoice.paid_amount || 0)
// A rounded-up whole-krona payment is not an overpayment: widen the reject
// band to one krona when absorbing öre; otherwise keep the strict half-öre
// float tolerance the three legacy callers rely on.
const overshootTolerance = absorbOre ? ORE_ROUNDING_SETTLEMENT_MAX : PAYMENT_OVERSHOOT_TOLERANCE
if (paymentAmountInInvoiceCurrency > currentRemaining + overshootTolerance) {
// A rounded-up whole-krona payment is not an overpayment: when absorbing öre,
// accept only an overshoot strictly inside the settlement band. The boundary
// must be >= : with a strict > guard an exact 1 kr overshoot passed the guard
// AND missed the |diff| < 1 absorb branch, falling through to record
// paid_amount = total + 1 kr with remaining clamped to 0 (silent over-credit).
// Without absorb, keep the strict half-öre float tolerance the legacy callers
// rely on.
const overshoot = roundOre(paymentAmountInInvoiceCurrency - currentRemaining)
const isOverpayment = absorbOre
? overshoot >= ORE_ROUNDING_SETTLEMENT_MAX
: paymentAmountInInvoiceCurrency > currentRemaining + PAYMENT_OVERSHOOT_TOLERANCE
if (isOverpayment) {
return {
ok: false,
code: 'MATCH_AMOUNT_EXCEEDS_REMAINING',
@@ -109,3 +116,47 @@ export function planInvoicePayment(
},
}
}
/** BAS öres- och kronutjämning: the only account that may carry an absorbed residual. */
const ORE_ROUNDING_ACCOUNT = '3740'
/**
* `planInvoicePayment` for caller-supplied booking lines (the mark-paid
* dialog and the v1 API), where the server does NOT build the verifikat.
*
* Absorbing an öre residual is only safe when the lines actually book it:
* in the server-built bank-match flow `buildInvoicePaymentClearingLines`
* guarantees 1510 is credited the full remaining and 3740 carries the exact
* residual, so plan and GL absorb together. Here the lines are caller-owned,
* so absorption is granted only when the net 3740 amount (debit credit)
* equals the signed residual (remaining payment). Otherwise fall back to
* the strict plan: a sub-krona short payment stays a real partial and a
* sub-krona overshoot is rejected, exactly as before absorption existed.
* Without this gate an invoice could flip to paid while the posted lines
* under-clear 1510, diverging the GL from the AR sub-ledger.
*/
export function planInvoicePaymentForLines(
invoice: InvoicePaymentTotals,
paymentAmountInInvoiceCurrency: number,
lines:
| Array<{ account_number: string; debit_amount: number; credit_amount: number }>
| undefined,
invoiceCurrency: string,
): PlanInvoicePaymentResult {
const absorbEligible = !!lines && invoiceCurrency === 'SEK'
const payment = planInvoicePayment(invoice, paymentAmountInInvoiceCurrency, {
absorbOreRounding: absorbEligible,
})
if (!absorbEligible || !payment.ok || !payment.plan.oreSettled) return payment
const currentRemaining =
invoice.remaining_amount ?? invoice.total - (invoice.paid_amount || 0)
const residual = roundOre(currentRemaining - paymentAmountInInvoiceCurrency)
const net3740 = roundOre(
lines!
.filter((l) => l.account_number === ORE_ROUNDING_ACCOUNT)
.reduce((s, l) => s + l.debit_amount - l.credit_amount, 0),
)
if (net3740 === residual) return payment
return planInvoicePayment(invoice, paymentAmountInInvoiceCurrency)
}

Some files were not shown because too many files have changed in this diff Show More