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
co-authored by Claude Fable 5
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] 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] 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] 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 { RecaptIdentify } from '@/components/RecaptIdentify'
import { AgentSheetProvider } from '@/components/agent/AgentSheetProvider' import { AgentSheetProvider } from '@/components/agent/AgentSheetProvider'
import AgentTrigger from '@/components/agent/AgentTrigger' 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 { SettingsHotkey } from '@/components/settings/SettingsHotkey'
import { SandboxBanner } from '@/components/dashboard/SandboxBanner' import { SandboxBanner } from '@/components/dashboard/SandboxBanner'
import { getExtensionNavItems } from '@/lib/extensions/sectors' import { getExtensionNavItems } from '@/lib/extensions/sectors'
@@ -16,7 +16,6 @@ import { getActiveCompanyId } from '@/lib/company/context'
import { getCompanyEntitlements } from '@/lib/entitlements/has-capability' import { getCompanyEntitlements } from '@/lib/entitlements/has-capability'
import { getBranding } from '@/lib/branding/service' import { getBranding } from '@/lib/branding/service'
import { ensureSandboxAgentProfile } from '@/lib/sandbox/ensure-agent' import { ensureSandboxAgentProfile } from '@/lib/sandbox/ensure-agent'
import { countPendingOperations, countUnbookedTransactions } from '@/lib/worklist'
import type { EntityType, CompanyRole, Team } from '@/types' import type { EntityType, CompanyRole, Team } from '@/types'
/** /**
@@ -99,8 +98,6 @@ export default async function DashboardLayout({
<DashboardNav <DashboardNav
companyName={getBranding().appName.toLowerCase()} companyName={getBranding().appName.toLowerCase()}
entityType="enskild_firma" entityType="enskild_firma"
uncategorizedTransactionCount={0}
pendingOperationsCount={0}
isSandbox={false} isSandbox={false}
extensionNavItems={getExtensionNavItems()} extensionNavItems={getExtensionNavItems()}
/> />
@@ -131,8 +128,6 @@ export default async function DashboardLayout({
{ data: memberRow }, { data: memberRow },
{ data: allMemberships }, { data: allMemberships },
{ data: settings }, { data: settings },
uncategorizedCount,
pendingOpsCount,
{ data: agentProfileIdentity }, { data: agentProfileIdentity },
{ data: userProfile }, { data: userProfile },
entitlements, entitlements,
@@ -146,11 +141,10 @@ export default async function DashboardLayout({
.select('company_name, onboarding_complete, entity_type, pays_salaries, is_sandbox, dimensions_enabled') .select('company_name, onboarding_complete, entity_type, pays_salaries, is_sandbox, dimensions_enabled')
.eq('company_id', companyId) .eq('company_id', companyId)
.single(), .single(),
// Shared worklist predicates (lib/worklist), the badge must show the // Nav badge counts (unbooked transactions, pending operations) are NOT
// same number as every other "att göra" surface. Notably this excludes // fetched here anymore: DashboardNav loads them client-side after mount
// is_ignored rows, which the old inline query here did not. // (lib/hooks/use-worklist-badges) so two head-count queries stop blocking
countUnbookedTransactions(supabase, companyId), // first paint on every dashboard navigation.
countPendingOperations(supabase, companyId),
// Agent identity, name + avatar, surfaced on the FAB and chat // Agent identity, name + avatar, surfaced on the FAB and chat
// surfaces. Null when no agent_profile exists yet (banner CTA path). // surfaces. Null when no agent_profile exists yet (banner CTA path).
supabase supabase
@@ -203,8 +197,6 @@ export default async function DashboardLayout({
<DashboardNav <DashboardNav
companyName={getBranding().appName.toLowerCase()} companyName={getBranding().appName.toLowerCase()}
entityType="enskild_firma" entityType="enskild_firma"
uncategorizedTransactionCount={0}
pendingOperationsCount={0}
isSandbox={false} isSandbox={false}
extensionNavItems={getExtensionNavItems()} extensionNavItems={getExtensionNavItems()}
/> />
@@ -311,8 +303,6 @@ export default async function DashboardLayout({
entityType={entityType} entityType={entityType}
paysSalaries={paysSalaries} paysSalaries={paysSalaries}
dimensionsEnabled={dimensionsEnabled} dimensionsEnabled={dimensionsEnabled}
uncategorizedTransactionCount={uncategorizedCount}
pendingOperationsCount={pendingOpsCount}
isSandbox={isSandbox} isSandbox={isSandbox}
extensionNavItems={getExtensionNavItems()} extensionNavItems={getExtensionNavItems()}
userName={userProfile?.full_name ?? null} userName={userProfile?.full_name ?? null}
@@ -322,7 +312,7 @@ export default async function DashboardLayout({
<MainContainer companyId={companyId}>{children}</MainContainer> <MainContainer companyId={companyId}>{children}</MainContainer>
</main> </main>
<AgentTrigger /> <AgentTrigger />
<CommandPalette /> <LazyCommandPalette />
<SettingsHotkey /> <SettingsHotkey />
{settingsModal} {settingsModal}
</div> </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 // in-place) so server-side filtering, sorting, and computed fields stay in
// sync with whatever the API route returned. The counts endpoint isn't // 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. // 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(() => { useEffect(() => {
const supabase = createClient() const supabase = createClient()
let debounce: ReturnType<typeof setTimeout> | null = null
const channel = supabase const channel = supabase
.channel('pending_operations:list') .channel('pending_operations:list')
.on( .on(
'postgres_changes', 'postgres_changes',
{ event: '*', schema: 'public', table: 'pending_operations' }, { event: '*', schema: 'public', table: 'pending_operations' },
() => { () => {
fetchOperations() if (debounce) clearTimeout(debounce)
fetchAllCounts() debounce = setTimeout(() => {
fetchOperations()
fetchAllCounts()
}, 400)
} }
) )
.subscribe() .subscribe()
return () => { return () => {
if (debounce) clearTimeout(debounce)
void supabase.removeChannel(channel) void supabase.removeChannel(channel)
} }
}, [fetchOperations, fetchAllCounts]) }, [fetchOperations, fetchAllCounts])
+22 -3
View File
@@ -24,6 +24,7 @@ import {
} from '@/lib/salary/payment/bank-account' } from '@/lib/salary/payment/bank-account'
import type { Employee } from '@/types' import type { Employee } from '@/types'
import { EmployeeBenefitsPanel } from '@/components/salary/EmployeeBenefitsPanel' import { EmployeeBenefitsPanel } from '@/components/salary/EmployeeBenefitsPanel'
import { OpeningBalancesPanel } from '@/components/salary/OpeningBalancesPanel'
import EmployeeTaxCard, { type EmployeeTaxValue } from '@/components/salary/EmployeeTaxCard' import EmployeeTaxCard, { type EmployeeTaxValue } from '@/components/salary/EmployeeTaxCard'
import LineDimensionFields from '@/components/dimensions/LineDimensionFields' import LineDimensionFields from '@/components/dimensions/LineDimensionFields'
@@ -124,12 +125,18 @@ export default function EmployeeDetailPage({ params }: { params: Promise<{ id: s
employment_type: employmentType, employment_type: employmentType,
employment_start: form.get('employment_start') as string || undefined, employment_start: form.get('employment_start') as string || undefined,
employment_end: form.get('employment_end') 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, salary_type: salaryType,
f_skatt_status: tax?.f_skatt_status, f_skatt_status: tax?.f_skatt_status,
is_sidoinkomst: tax?.is_sidoinkomst, is_sidoinkomst: tax?.is_sidoinkomst,
tax_table_number: tax?.tax_table_number ?? undefined, 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, tax_municipality: tax?.tax_municipality || undefined,
email: form.get('email') as string || undefined, email: form.get('email') as string || undefined,
phone: form.get('phone') 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, clearing_number: normalizeBankNumber(clearing) || undefined,
bank_account_number: normalizeBankNumber(account) || undefined, bank_account_number: normalizeBankNumber(account) || undefined,
vacation_rule: vacationRule, 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. // Always sent: {} clears the employee's default dimensions.
default_dimensions: 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> <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} /> <Input id="employment_degree" name="employment_degree" type="number" defaultValue={employee.employment_degree} min="1" max="100" disabled={!canWrite} />
</div> </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"> <div className="space-y-2">
<Label htmlFor="salary_type">{t('form_salary_type')}<RequiredMark /></Label> <Label htmlFor="salary_type">{t('form_salary_type')}<RequiredMark /></Label>
<Select value={salaryType} onValueChange={setSalaryType} disabled={!canWrite}> <Select value={salaryType} onValueChange={setSalaryType} disabled={!canWrite}>
@@ -453,6 +469,9 @@ export default function EmployeeDetailPage({ params }: { params: Promise<{ id: s
{/* Benefits */} {/* Benefits */}
<EmployeeBenefitsPanel employeeId={id} canWrite={canWrite} /> <EmployeeBenefitsPanel employeeId={id} canWrite={canWrite} />
{/* Ingående saldon (payroll cutover) */}
<OpeningBalancesPanel employeeId={id} canWrite={canWrite} />
{canWrite && ( {canWrite && (
<div className="flex justify-end gap-3"> <div className="flex justify-end gap-3">
<Button variant="outline" asChild> <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 { PageHeader } from '@/components/ui/page-header'
import { useToast } from '@/components/ui/use-toast' import { useToast } from '@/components/ui/use-toast'
import { useCanWrite } from '@/lib/hooks/use-can-write' 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 { useCompany } from '@/contexts/CompanyContext'
import { getErrorMessage } from '@/lib/errors/get-error-message' import { getErrorMessage } from '@/lib/errors/get-error-message'
import { formatCurrency, formatDate } from '@/lib/utils' import { formatCurrency, formatDate } from '@/lib/utils'
@@ -65,50 +68,52 @@ export default function SalaryPage() {
const tp = useTranslations('salary_payments') const tp = useTranslations('salary_payments')
const load = useCallback(async () => { const load = useCallback(async () => {
const [runsRes, empRes, settingsRes] = await Promise.all([ // Everything loads in parallel; the tax-payment fetch is the only
fetch('/api/salary/runs'), // dependent request and chains directly off the runs response instead of
fetch('/api/salary/employees'), // waiting for the whole batch. Was three sequential legs (batch → tax
fetch('/api/settings'), // 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[] = [] setRuns(loadedRuns)
if (runsRes.ok) { if (taxPaymentData !== undefined) setTaxPayment(taxPaymentData)
const { data } = await runsRes.json() if (empRes?.ok) {
loadedRuns = data || []
setRuns(loadedRuns)
}
if (empRes.ok) {
const { data } = await empRes.json() const { data } = await empRes.json()
setEmployees(data || []) setEmployees(data || [])
} }
if (settingsRes.ok) { if (settingsRes?.ok) {
const { data } = await settingsRes.json() const { data } = await settingsRes.json()
if (typeof data?.salary_pay_day === 'number') setPayDay(data.salary_pay_day) if (typeof data?.salary_pay_day === 'number') setPayDay(data.salary_pay_day)
} }
if (skvStatus) setSkvNeedsReconsent(skvStatus.needsReconsent === true)
// 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.
}
setLoading(false) setLoading(false)
}, []) }, [])
@@ -151,6 +156,16 @@ export default function SalaryPage() {
.then(({ data }) => setAgiDeadline(data ?? null)) .then(({ data }) => setAgiDeadline(data ?? null))
}, [company]) }, [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, // One-click run creation: the API seeds all active employees, calculates,
// and resolves period/pay-date/series defaults from settings. // and resolves period/pay-date/series defaults from settings.
async function startRun() { async function startRun() {
@@ -244,7 +259,8 @@ export default function SalaryPage() {
} }
// ── Hero state machine (first match wins) ──────────────────────────────── // ── 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 latestBooked = runs.find(r => r.status === 'booked')
const periodOf = (r: SalaryRun) => `${r.period_year}-${String(r.period_month).padStart(2, '0')}` 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) { 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 { return {
kind: 'cta', kind: 'cta',
title: t('hero_agi_title', { period: periodOf(activeRun) }), title: t('hero_agi_title', { period: periodOf(activeRun) }),
@@ -409,7 +438,7 @@ export default function SalaryPage() {
)} )}
{/* Attention cards */} {/* 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> <Card>
<CardContent className="p-4"> <CardContent className="p-4">
<div className="flex items-center gap-2 mb-2"> <div className="flex items-center gap-2 mb-2">
@@ -504,6 +533,9 @@ export default function SalaryPage() {
)} )}
</CardContent> </CardContent>
</Card> </Card>
{/* Semester (vacation ledger + year close): payroll gap-closure 3.5 */}
<VacationBalanceCard canWrite={canWrite} />
</div> </div>
{/* History */} {/* History */}
+20
View File
@@ -17,6 +17,8 @@ import {
import { AlertTriangle, Download, Loader2 } from 'lucide-react' import { AlertTriangle, Download, Loader2 } from 'lucide-react'
import { useToast } from '@/components/ui/use-toast' import { useToast } from '@/components/ui/use-toast'
import { useCanWrite } from '@/lib/hooks/use-can-write' 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 { getErrorMessage } from '@/lib/errors/get-error-message'
import { AGIPanel } from '@/components/salary/AGIPanel' import { AGIPanel } from '@/components/salary/AGIPanel'
import { PaymentFilePanel } from '@/components/salary/PaymentFilePanel' import { PaymentFilePanel } from '@/components/salary/PaymentFilePanel'
@@ -55,6 +57,16 @@ export default function SalaryRunPage({ params }: { params: Promise<{ id: string
tax_paid_at: string | null tax_paid_at: string | null
} | null>(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() { async function loadRun() {
const res = await fetch(`/api/salary/runs/${id}`) const res = await fetch(`/api/salary/runs/${id}`)
if (res.ok) { 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. // hidden - mirrors the pain.001 / BG-LB generators, which emit no rows here.
const noPayout = isCalculated && Math.round((run.total_net ?? 0) * 100) === 0 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 // Advancing a draft to review. For a nollkörning confirm first: an empty
// declaration is filed to Skatteverket, which should be deliberate. // declaration is filed to Skatteverket, which should be deliberate.
function handleToReview() { function handleToReview() {
@@ -536,6 +552,8 @@ export default function SalaryRunPage({ params }: { params: Promise<{ id: string
run={run} run={run}
isCalculated={isCalculated} isCalculated={isCalculated}
noPayout={noPayout} noPayout={noPayout}
agiState={agiState}
agiKvittensnummer={agiSubmission?.kvittensnummer ?? null}
canWrite={canWrite} canWrite={canWrite}
actionLoading={actionLoading} actionLoading={actionLoading}
primaryAction={primaryAction} 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')}`} period={`${run.period_year}${String(run.period_month).padStart(2, '0')}`}
agiGeneratedAt={run.agi_generated_at} agiGeneratedAt={run.agi_generated_at}
agiSubmittedAt={run.agi_submitted_at} agiSubmittedAt={run.agi_submitted_at}
submission={agiSubmission}
onRefreshSubmission={refreshAgiSubmission}
readOnly={!canWrite} readOnly={!canWrite}
onChange={loadRun} onChange={loadRun}
/> />
+9 -1
View File
@@ -1,5 +1,13 @@
import { Suspense } from 'react'
import { BankingSettingsContent } from '@/components/settings/sections/BankingSettingsContent' 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() { 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 }) { function mockChain(result: { data?: unknown; error?: unknown }) {
const chain: Record<string, 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[m] = vi.fn().mockReturnValue(chain)
} }
chain.single = vi.fn().mockResolvedValue({ data: result.data ?? null, error: result.error ?? null }) 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') 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>[] = [] const capturedUpdates: Record<string, unknown>[] = []
let callIndex = 0 let callIndex = 0
mockFrom.mockImplementation(() => { mockFrom.mockImplementation(() => {
callIndex++ callIndex++
if (callIndex === 1) { if (callIndex === 1) {
// Find pending connection by oauth_state // 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 // Update connection: capture the payload, then chain returns the
// updated row via .select().single() for the audit event emission. // 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' })) const response = await GET(makeRequest({ code: 'auth-code', state: 'valid-state' }))
expect(response.status).toBe(307) // Success streams an interim page (instant feedback during the session
const location = response.headers.get('location') || '' // exchange) that ends with a client-side redirect to the account picker.
expect(location).toContain('/settings/banking?') expect(response.status).toBe(200)
expect(location).toContain('select_accounts=conn-1') expect(response.headers.get('content-type')).toContain('text/html')
expect(location).not.toContain('bank_connected=true') 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, // Verify the update payload: status=pending_selection, no last_synced_at,
// and every account defaults to enabled=true so the picker can simply // 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) => { mockFrom.mockImplementation((table: string) => {
callIndex++ callIndex++
if (callIndex === 1) { 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') { if (table === 'cash_accounts') {
// Already mirrored on a previous connect — acc-1 was remapped to 1935 // 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' })) 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. // No allocation for an already-mirrored account; the upsert reuses 1935.
expect(mockAllocate).not.toHaveBeenCalled() expect(mockAllocate).not.toHaveBeenCalled()
expect(mockUpsertFromPsd2).toHaveBeenCalledTimes(1) expect(mockUpsertFromPsd2).toHaveBeenCalledTimes(1)
@@ -227,6 +251,77 @@ describe('GET /api/extensions/enable-banking/callback', () => {
).toBe('1935') ).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 () => { it('redirects with error when bank returns error param (no state)', async () => {
const response = await GET(makeRequest({ error: 'access_denied', error_description: 'User cancelled' })) 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') 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 () => { it('forwards bank_error_code and psu_type when the denied state matches a pending connection', async () => {
mockFrom.mockImplementation(() => mockFrom.mockImplementation(() =>
mockChain({ 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, 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 { createServiceClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server' import { NextResponse, after } from 'next/server'
import { ensureInitialized } from '@/lib/init' import { ensureInitialized } from '@/lib/init'
import { createLogger } from '@/lib/logger'
import { createSession, type AccountInfo } from '@/extensions/general/enable-banking/lib/api-client' import { createSession, type AccountInfo } from '@/extensions/general/enable-banking/lib/api-client'
import type { StoredAccount } from '@/extensions/general/enable-banking/types' import type { StoredAccount } from '@/extensions/general/enable-banking/types'
import { eventBus } from '@/lib/events/bus' import { eventBus } from '@/lib/events/bus'
@@ -9,6 +11,7 @@ import {
allocatePsd2LedgerAccount, allocatePsd2LedgerAccount,
defaultLedgerForCurrency, defaultLedgerForCurrency,
} from '@/lib/cash-accounts/service' } from '@/lib/cash-accounts/service'
import { renderFinalizeShell, renderFinalizeRedirect } from './finalize-page'
// This route emits bank_connection.consent_granted / .cash_account_mirror_failed // This route emits bank_connection.consent_granted / .cash_account_mirror_failed
// (ASVS V16 / GDPR Art.30 audit events). ensureInitialized() must run at module // (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. // redirect route is the first event-emitting code path to execute.
ensureInitialized() 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 * GET /api/extensions/enable-banking/callback
@@ -24,6 +47,13 @@ ensureInitialized()
* OAuth callback for Enable Banking PSD2 authorization. * OAuth callback for Enable Banking PSD2 authorization.
* Must be a real Next.js route (not extension handler) because * Must be a real Next.js route (not extension handler) because
* banks redirect to this URL directly. * 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) { export async function GET(request: Request) {
const { searchParams } = new URL(request.url) 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. // (which stays 'expired' during the round-trip) is also handled.
const { data: pendingConn } = await supabase const { data: pendingConn } = await supabase
.from('bank_connections') .from('bank_connections')
.select('id, user_id, bank_name, psu_type') .select('id, user_id, bank_name, psu_type, status')
.eq('oauth_state', state) .eq('oauth_state', state)
.in('status', ['pending', 'expired', 'error']) .in('status', ['pending', 'expired', 'error'])
.single() .single()
@@ -72,17 +102,33 @@ export async function GET(request: Request) {
error_description: errorDescription, error_description: errorDescription,
}) })
// If the bank reports a session-expiry during authorization itself, if (pendingConn.status === 'pending') {
// mark the row 'expired' (not generic 'error') so the settings panel // Fresh connect that never became a connection: delete the row
// surfaces the reconnect button rather than a dead-end error state. // instead of parking it in 'error'. A parked row renders forever
const isSessionExpiry = /session.?expired|expired.?session|closed.?session|session.?closed|invalid.?session|session.?not.?found/i.test( // as an "Åtgärd krävs" card, so a failed attempt followed by a
`${error} ${errorDescription ?? ''}` // 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 await supabase
.from('bank_connections') .from('bank_connections')
.update({ status: isSessionExpiry ? 'expired' : 'error', error_message: errorMessage, oauth_state: null }) .update({ status: isSessionExpiry ? 'expired' : 'error', error_message: errorMessage, oauth_state: null })
.eq('id', pendingConn.id) .eq('id', pendingConn.id)
}
// Include bank name, error code, and psu_type in the redirect so the // Include bank name, error code, and psu_type in the redirect so the
// UI can render targeted guidance (e.g. PSU-type retry on // 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() const supabase = await createServiceClient()
try { // Look up the connection awaiting this callback by oauth_state (CSRF-safe).
// 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
// 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
// identifies the row regardless of status. Accept 'expired'/'error' too: an // in-place reconnect keeps the row in 'expired' during the round-trip (so
// in-place reconnect keeps the row in 'expired' during the round-trip (so // the nightly stale-'pending' cleanup can't delete an established row).
// the nightly stale-'pending' cleanup can't delete an established row). // This lookup is fast, so it runs BEFORE the streamed response: an unknown
const { data: pendingConnection, error: findError } = await supabase // state stays a plain redirect.
.from('bank_connections') const { data: pendingConnection, error: findError } = await supabase
.select('id, user_id, company_id') .from('bank_connections')
.eq('oauth_state', state) .select('id, user_id, company_id, bank_name, status')
.in('status', ['pending', 'expired', 'error']) .eq('oauth_state', state)
.single() .in('status', ['pending', 'expired', 'error'])
.single()
if (findError || !pendingConnection) { if (findError || !pendingConnection) {
console.error('[enable-banking] No pending connection for oauth_state', { console.error('[enable-banking] No pending connection for oauth_state', {
findError: findError ? { message: findError.message, code: findError.code, details: findError.details } : null, 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,
state, state,
hasCode: !!code, 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( 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() 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 () => { it('returns 400 MATCH_AMOUNT_EXCEEDS_REMAINING when custom lines overpay the invoice', async () => {
// No customer → duplicate guard skips; the overpayment guard must reject // No customer → duplicate guard skips; the overpayment guard must reject
// BEFORE any journal entry is created (planInvoicePayment runs first). // 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') 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 () => { it('returns 400 when customer has no email', async () => {
const noEmailInvoice = makeInvoice({ const noEmailInvoice = makeInvoice({
id: 'inv-1', id: 'inv-1',
@@ -201,8 +279,8 @@ describe('POST /api/invoices/[id]/send', () => {
mockSendEmail.mockResolvedValue({ success: true, messageId: 'msg-1' }) mockSendEmail.mockResolvedValue({ success: true, messageId: 'msg-1' })
mockCreateInvoiceJournalEntry.mockResolvedValue({ id: 'je-1' }) mockCreateInvoiceJournalEntry.mockResolvedValue({ id: 'je-1' })
// Update invoice status to 'sent' // Update invoice status to 'sent' (optimistic lock: returns the matched row)
enqueue({ data: null, error: null }) enqueue({ data: [{ id: 'inv-1' }], error: null })
// Update invoice with journal_entry_id // Update invoice with journal_entry_id
enqueue({ data: null, error: null }) enqueue({ data: null, error: null })
@@ -244,7 +322,7 @@ describe('POST /api/invoices/[id]/send', () => {
mockSendEmail.mockResolvedValue({ success: true, messageId: 'msg-2' }) mockSendEmail.mockResolvedValue({ success: true, messageId: 'msg-2' })
// Update invoice status // 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 request = createMockRequest('/api/invoices/inv-1/send', { method: 'POST' })
const response = await POST(request, createMockRouteParams({ id: 'inv-1' })) 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')) mockCreateInvoiceJournalEntry.mockRejectedValue(new Error('Period locked'))
// Update invoice status // 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 request = createMockRequest('/api/invoices/inv-1/send', { method: 'POST' })
const response = await POST(request, createMockRouteParams({ id: 'inv-1' })) const response = await POST(request, createMockRouteParams({ id: 'inv-1' }))
@@ -293,7 +371,7 @@ describe('POST /api/invoices/[id]/send', () => {
mockCreateInvoiceJournalEntry.mockResolvedValue({ id: 'je-1' }) mockCreateInvoiceJournalEntry.mockResolvedValue({ id: 'je-1' })
// Update status to 'sent' // Update status to 'sent'
enqueue({ data: null, error: null }) enqueue({ data: [{ id: 'inv-1' }], error: null })
// Update with journal_entry_id // Update with journal_entry_id
enqueue({ data: null, error: null }) enqueue({ data: null, error: null })
@@ -325,7 +403,7 @@ describe('POST /api/invoices/[id]/send', () => {
mockSendEmail.mockResolvedValue({ success: true, messageId: 'msg-100' }) mockSendEmail.mockResolvedValue({ success: true, messageId: 'msg-100' })
mockCreateInvoiceJournalEntry.mockResolvedValue({ id: 'je-2' }) mockCreateInvoiceJournalEntry.mockResolvedValue({ id: 'je-2' })
enqueue({ data: null, error: null }) enqueue({ data: [{ id: 'inv-1' }], error: null })
enqueue({ data: null, error: null }) enqueue({ data: null, error: null })
const request = createMockRequest('/api/invoices/inv-1/send', { method: 'POST' }) 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' }) mockSendEmail.mockResolvedValue({ success: true, messageId: 'msg-banner' })
mockCreateInvoiceJournalEntry.mockResolvedValue({ id: 'je-1' }) mockCreateInvoiceJournalEntry.mockResolvedValue({ id: 'je-1' })
enqueue({ data: null, error: null }) enqueue({ data: [{ id: 'inv-1' }], error: null })
enqueue({ data: null, error: null }) enqueue({ data: null, error: null })
const request = createMockRequest('/api/invoices/inv-1/send', { method: 'POST' }) 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 // 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 // kap 24§ but is not a valid faktura: sending it would deliver a
// re-activate it (the .update({ status: 'sent' }) below has no status // "MAKULERAD" PDF as if it were live. Checked before the generic draft
// guard) and could deliver a "MAKULERAD" PDF as if it were live. // guard below for the more specific error message.
if (invoice.status === 'cancelled') { if (invoice.status === 'cancelled') {
return errorResponseFromCode('INVOICE_SEND_CANCELLED', opLog, { requestId }) 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 const customer = invoice.customer as Customer
if (!customer.email) { if (!customer.email) {
return errorResponseFromCode('INVOICE_SEND_NO_CUSTOMER_EMAIL', opLog, { return errorResponseFromCode('INVOICE_SEND_NO_CUSTOMER_EMAIL', opLog, {
@@ -223,16 +236,36 @@ export const POST = withRouteContext(
partialFailures.push({ step: 'payment_link', reason: paymentLinkFailure }) 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') .from('invoices')
.update({ status: 'sent' }) .update({ status: 'sent' })
.eq('id', id) .eq('id', id)
.eq('company_id', companyId) .eq('company_id', companyId)
.eq('status', 'draft')
.select('id')
if (updateError) { if (updateError) {
opLog.warn('failed to update invoice status to sent', updateError) opLog.warn('failed to update invoice status to sent', updateError)
partialFailures.push({ step: 'status_update', reason: updateError.message }) 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 const accountingMethod = (company as Record<string, unknown>).accounting_method as string | undefined
let createdJournalEntryId: string | undefined let createdJournalEntryId: string | undefined
if (isRealInvoice && (!accountingMethod || accountingMethod === 'accrual')) { if (statusFlipped && isRealInvoice && (!accountingMethod || accountingMethod === 'accrual')) {
try { try {
const journalEntry = await createInvoiceJournalEntry( const journalEntry = await createInvoiceJournalEntry(
supabase, supabase,
@@ -284,7 +317,7 @@ export const POST = withRouteContext(
} }
} }
if (isRealInvoice) { if (statusFlipped && isRealInvoice) {
try { try {
const pdfArrayBuffer = new Uint8Array(pdfBuffer).buffer as ArrayBuffer const pdfArrayBuffer = new Uint8Array(pdfBuffer).buffer as ArrayBuffer
await uploadDocument(supabase, user.id, companyId!, { await uploadDocument(supabase, user.id, companyId!, {
@@ -304,10 +337,15 @@ export const POST = withRouteContext(
} }
} }
await eventBus.emit({ // Gated like the steps above: on a lost race the winning request emits
type: 'invoice.sent', // it; on a flip error the row is still 'draft', so emitting would
payload: { invoice: invoice as Invoice, companyId: companyId!, userId: user.id }, // 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) { if (partialFailures.length > 0) {
opLog.warn('invoice sent with partial follow-up failures', { 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 { withRouteContext } from '@/lib/api/with-route-context'
import { errorResponse } from '@/lib/errors/get-structured-error' import { errorResponse } from '@/lib/errors/get-structured-error'
import { executeRecurringSchedule } from '@/lib/invoices/recurring-schedule-service' import { executeRecurringSchedule } from '@/lib/invoices/recurring-schedule-service'
import { isSandboxCompany } from '@/lib/sandbox/guard'
import type { RecurringInvoiceSchedule, RecurringInvoiceScheduleItem } from '@/types' import type { RecurringInvoiceSchedule, RecurringInvoiceScheduleItem } from '@/types'
ensureInitialized() ensureInitialized()
@@ -46,8 +47,16 @@ export const POST = withRouteContext(
items: RecurringInvoiceScheduleItem[] 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 { 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, // Record the run for the list view (generated count, last invoice,
// warning) but leave next_run_date untouched: the monthly cadence runs // 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() 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', () => ({ vi.mock('@/lib/supabase/server', () => ({
createServiceClient: () => mockSupabase, 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' import { GET } from '../route'
type ResultRow = { type ResultRow = {
@@ -53,6 +80,7 @@ describe('GET /api/invoices/recurring/cron', () => {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks() vi.clearAllMocks()
reset() reset()
updatePayloads.length = 0
vi.useFakeTimers() vi.useFakeTimers()
}) })
afterEach(() => { afterEach(() => {
@@ -77,6 +105,31 @@ describe('GET /api/invoices/recurring/cron', () => {
expect(executeRecurringSchedule).toHaveBeenCalledTimes(1) expect(executeRecurringSchedule).toHaveBeenCalledTimes(1)
expect(body.succeeded).toBe(1) expect(body.succeeded).toBe(1)
expect(body.results[0].invoiceId).toBe('inv-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 () => { 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') 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 () => { it('skips a schedule that already ran earlier today', async () => {
vi.setSystemTime(new Date('2026-07-06T08:30:00Z')) vi.setSystemTime(new Date('2026-07-06T08:30:00Z'))
enqueue({ enqueue({
+29 -4
View File
@@ -8,6 +8,7 @@ import {
computeInitialRunDate, computeInitialRunDate,
getStockholmDateHour, getStockholmDateHour,
} from '@/lib/invoices/recurring-schedule-service' } from '@/lib/invoices/recurring-schedule-service'
import { isSandboxCompany } from '@/lib/sandbox/guard'
import type { import type {
RecurringInvoiceSchedule, RecurringInvoiceSchedule,
RecurringInvoiceScheduleItem, 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. // its next date rather than firing a stale one immediately.
if (schedule.next_run_date < todayStockholm) { if (schedule.next_run_date < todayStockholm) {
const rolledNext = computeInitialRunDate(stockholmToday, schedule.day_of_month) 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 const { error: rollError } = await supabase
.from('recurring_invoice_schedules') .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('id', schedule.id)
.eq('company_id', schedule.company_id) .eq('company_id', schedule.company_id)
if (rollError) { 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 // 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 // (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>> let result: Awaited<ReturnType<typeof executeRecurringSchedule>>
try { try {
result = await executeRecurringSchedule(supabase, schedule, now) result = await executeRecurringSchedule(supabase, schedule, now, { suppressAutoSend })
} catch (err) { } catch (err) {
const reason = (err instanceof Error ? err.message : String(err)).slice(0, 300)
await supabase await supabase
.from('recurring_invoice_schedules') .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('id', schedule.id)
.eq('company_id', schedule.company_id) .eq('company_id', schedule.company_id)
.eq('last_run_at', claimTs) .eq('last_run_at', claimTs)
@@ -67,8 +67,7 @@ describe('POST /api/salary/employees/[id]/absence', () => {
it('upserts an absence day (happy path)', async () => { it('upserts an absence day (happy path)', async () => {
enqueue({ data: { id: 'emp-1' } }) // loadEmployee 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 } }) // upsert
enqueue({ data: { id: 'abs-1', absence_date: '2026-07-01', absence_type: 'sick', hours: 8 } }) // insert
const response = await POST(post({ absence_date: '2026-07-01', absence_type: 'sick', hours: 8 }), params) const response = await POST(post({ absence_date: '2026-07-01', absence_type: 'sick', hours: 8 }), params)
const { status, body } = await parseJsonResponse<{ data: { id: string } }>(response) const { status, body } = await parseJsonResponse<{ data: { id: string } }>(response)
+36 -95
View File
@@ -1,5 +1,4 @@
import { z } from 'zod' import { z } from 'zod'
import type { SupabaseClient } from '@supabase/supabase-js'
import { NextResponse } from 'next/server' import { NextResponse } from 'next/server'
import { ensureInitialized } from '@/lib/init' import { ensureInitialized } from '@/lib/init'
import { withRouteContext } from '@/lib/api/with-route-context' import { withRouteContext } from '@/lib/api/with-route-context'
@@ -9,23 +8,22 @@ import {
AbsenceRangeQuerySchema, AbsenceRangeQuerySchema,
AbsenceTypeSchema, AbsenceTypeSchema,
} from '@/lib/api/schemas' } 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}$/) const isoDate = z.string().regex(/^\d{4}-\d{2}-\d{2}$/)
ensureInitialized() ensureInitialized()
async function loadEmployee( function errorResponse(code: string, details?: Record<string, unknown>): NextResponse {
supabase: SupabaseClient, const entry = getErrorEntry(code)
employeeId: string, const message =
companyId: string, (details?.message as string | undefined) ?? entry?.message_sv ?? 'Något gick fel'
) { return NextResponse.json({ error: message, code }, { status: entry?.httpStatus ?? 500 })
const { data } = await supabase
.from('employees')
.select('id')
.eq('id', employeeId)
.eq('company_id', companyId)
.maybeSingle()
return data
} }
export const GET = withRouteContext<{ params: Promise<{ id: string }> }>( 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 }) => { async (request, { supabase, companyId }, { params }) => {
const { id: employeeId } = await 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) const query = validateQuery(request, AbsenceRangeQuerySchema)
if (!query.success) return query.response if (!query.success) return query.response
const { data, error } = await supabase const result = await listAbsenceDays(supabase, {
.from('salary_absence_days') companyId,
.select('id, absence_date, absence_type, hours, notes, salary_run_employee_id, created_at, updated_at') employeeId,
.eq('company_id', companyId) from: query.data.from,
.eq('employee_id', employeeId) to: query.data.to,
.gte('absence_date', query.data.from) })
.lte('absence_date', query.data.to)
.order('absence_date', { ascending: true })
if (error) { if (!result.ok) return errorResponse(result.code, result.details)
return NextResponse.json({ error: error.message }, { status: 500 }) return NextResponse.json({ data: result.data })
}
return NextResponse.json({ data })
}, },
) )
@@ -63,56 +51,24 @@ export const POST = withRouteContext<{ params: Promise<{ id: string }> }>(
async (request, { supabase, companyId }, { params }) => { async (request, { supabase, companyId }, { params }) => {
const { id: employeeId } = await 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) const validation = await validateBody(request, UpsertAbsenceDaySchema)
if (!validation.success) return validation.response if (!validation.success) return validation.response
const body = validation.data const body = validation.data
// Upsert via DELETE+INSERT on the natural key (employee, date, type) so the const result = await upsertAbsenceDay(supabase, {
// notes/hours/run-link can be replaced cleanly. The unique index makes ON companyId,
// CONFLICT viable too, but Supabase's typed client doesn't expose employeeId,
// onConflict for our composite key without a named constraint name: day: {
// delete-then-insert keeps the pattern consistent with token-store.ts.
const { error: deleteError } = await supabase
.from('salary_absence_days')
.delete()
.eq('company_id', companyId)
.eq('employee_id', employeeId)
.eq('absence_date', body.absence_date)
.eq('absence_type', body.absence_type)
if (deleteError) {
return NextResponse.json({ error: deleteError.message }, { status: 500 })
}
const { data, error } = await supabase
.from('salary_absence_days')
.insert({
company_id: companyId,
employee_id: employeeId,
absence_date: body.absence_date, absence_date: body.absence_date,
absence_type: body.absence_type, absence_type: body.absence_type,
hours: body.hours, hours: body.hours,
notes: body.notes ?? null, notes: body.notes ?? null,
salary_run_employee_id: body.salary_run_employee_id ?? null, salary_run_employee_id: body.salary_run_employee_id ?? null,
}) },
.select() })
.single()
if (error) { if (!result.ok) return errorResponse(result.code, result.details)
// The 24h cap trigger raises check_violation when worked + absence > 24h return NextResponse.json({ data: result.data }, { status: 201 })
// for the same date. Surface a clean 409 with the Swedish message.
if (error.message?.includes('Total tid') || error.code === '23514') {
return NextResponse.json({ error: error.message }, { status: 409 })
}
return NextResponse.json({ error: error.message }, { status: 500 })
}
return NextResponse.json({ data }, { status: 201 })
}, },
{ requireWrite: true }, { requireWrite: true },
) )
@@ -132,11 +88,6 @@ export const DELETE = withRouteContext<{ params: Promise<{ id: string }> }>(
async (request, { supabase, companyId }, { params }) => { async (request, { supabase, companyId }, { params }) => {
const { id: employeeId } = await 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) const query = validateQuery(request, DeleteQuerySchema)
if (!query.success) return query.response if (!query.success) return query.response
const { date, type, from, to } = query.data const { date, type, from, to } = query.data
@@ -151,26 +102,16 @@ export const DELETE = withRouteContext<{ params: Promise<{ id: string }> }>(
) )
} }
let q = supabase const result = await deleteAbsenceRange(supabase, {
.from('salary_absence_days') companyId,
.delete() employeeId,
.eq('company_id', companyId) from: hasSingle ? date! : from!,
.eq('employee_id', employeeId) to: hasSingle ? date! : to!,
absenceType: type,
})
if (hasSingle) { if (!result.ok) return errorResponse(result.code, result.details)
q = q.eq('absence_date', date!) return NextResponse.json({ data: { ok: true, deleted_count: result.data.deleted_count } })
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 } })
}, },
{ requireWrite: true }, { 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_start: body.employment_start,
employment_end: body.employment_end || null, employment_end: body.employment_end || null,
employment_degree: body.employment_degree, employment_degree: body.employment_degree,
hours_per_week: body.hours_per_week,
workdays_per_week: body.workdays_per_week,
salary_type: body.salary_type, salary_type: body.salary_type,
monthly_salary: body.monthly_salary || null, monthly_salary: body.monthly_salary || null,
hourly_rate: body.hourly_rate || 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_eligible: body.vaxa_stod_eligible,
vaxa_stod_start: body.vaxa_stod_start || null, vaxa_stod_start: body.vaxa_stod_start || null,
vaxa_stod_end: body.vaxa_stod_end || 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. // Dimensions PR8: bag for the employee's P&L cost lines at booking.
default_dimensions: body.default_dimensions ?? {}, default_dimensions: body.default_dimensions ?? {},
}) })
+22
View File
@@ -1,6 +1,7 @@
import { NextResponse } from 'next/server' import { NextResponse } from 'next/server'
import { ensureInitialized } from '@/lib/init' import { ensureInitialized } from '@/lib/init'
import { createSalaryRunEntries } from '@/lib/salary/salary-entries' import { createSalaryRunEntries } from '@/lib/salary/salary-entries'
import { syncVacationLedgerForEmployees } from '@/lib/salary/vacation-ledger'
import { eventBus } from '@/lib/events' import { eventBus } from '@/lib/events'
import { withRouteContext } from '@/lib/api/with-route-context' import { withRouteContext } from '@/lib/api/with-route-context'
import { errorResponse, errorResponseFromCode } from '@/lib/errors/get-structured-error' 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! }, 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 }) opLog.info('salary run booked as nollkörning (no journal entries)', { salaryRunId: id })
return NextResponse.json({ data: bookedRun }) return NextResponse.json({ data: bookedRun })
@@ -154,6 +166,16 @@ export const POST = withRouteContext(
payload: { salaryRunId: id, entryIds, userId: user.id, companyId: companyId! }, 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 }) return NextResponse.json({ data: bookedRun })
} catch (err) { } catch (err) {
if (isBookkeepingError(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 { reverseEntry } from '@/lib/bookkeeping/engine'
import { bookkeepingErrorResponse, EntryAlreadyReversedError } from '@/lib/bookkeeping/errors' import { bookkeepingErrorResponse, EntryAlreadyReversedError } from '@/lib/bookkeeping/errors'
import { revokeLinksForRun } from '@/lib/salary/payslips/links' import { revokeLinksForRun } from '@/lib/salary/payslips/links'
import { syncVacationLedgerForEmployees } from '@/lib/salary/vacation-ledger'
ensureInitialized() ensureInitialized()
@@ -26,7 +27,7 @@ export const POST = withRouteContext<{ params: Promise<{ id: string }> }>(
'salary.run.correct', 'salary.run.correct',
async (_request, ctx, { params }) => { async (_request, ctx, { params }) => {
const { id } = await params const { id } = await params
const { user, supabase, companyId } = ctx const { user, supabase, companyId, log } = ctx
// Load the original booked run // Load the original booked run
const { data: originalRun, error: runError } = await supabase 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({ return NextResponse.json({
data: correctionRun, data: correctionRun,
message: 'Korrigeringskörning skapad. Originalverifikationer har makulerats (storno). Redigera och beräkna om den nya körningen.', 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 { validateBody } from '@/lib/api/validate'
import { SalaryEmployeeOverrideSchema } from '@/lib/api/schemas' import { SalaryEmployeeOverrideSchema } from '@/lib/api/schemas'
import { decryptPersonnummer, maskPersonnummer } from '@/lib/salary/personnummer' import { decryptPersonnummer, maskPersonnummer } from '@/lib/salary/personnummer'
import { removeEmployeeFromRun } from '@/lib/salary/run-employees'
import { getErrorEntry } from '@/lib/errors/structured-errors'
ensureInitialized() ensureInitialized()
@@ -189,27 +191,18 @@ export const DELETE = withRouteContext<{ params: Promise<{ id: string; employeeI
const { id, employeeId } = await params const { id, employeeId } = await params
const { supabase, companyId } = ctx const { supabase, companyId } = ctx
// Verify run is draft const result = await removeEmployeeFromRun(supabase, {
const { data: run } = await supabase companyId,
.from('salary_runs') salaryRunId: id,
.select('id, status') employeeId,
.eq('id', id) })
.eq('company_id', companyId)
.single()
if (!run) return NextResponse.json({ error: 'Lönekörning hittades inte' }, { status: 404 }) if (!result.ok) {
if (run.status !== 'draft') return NextResponse.json({ error: 'Kan bara redigera utkast' }, { status: 400 }) const entry = getErrorEntry(result.code)
return NextResponse.json(
// Delete the salary_run_employee (cascades to salary_line_items via ON DELETE CASCADE) { error: entry?.message_sv ?? 'Något gick fel', code: result.code },
const { error } = await supabase { status: entry?.httpStatus ?? 500 },
.from('salary_run_employees') )
.delete()
.eq('salary_run_id', id)
.eq('employee_id', employeeId)
.eq('company_id', companyId)
if (error) {
return NextResponse.json({ error: error.message }, { status: 500 })
} }
return NextResponse.json({ data: { deleted: true } }) return NextResponse.json({ data: { deleted: true } })
+15 -87
View File
@@ -3,8 +3,8 @@ import { ensureInitialized } from '@/lib/init'
import { withRouteContext } from '@/lib/api/with-route-context' import { withRouteContext } from '@/lib/api/with-route-context'
import { validateBody } from '@/lib/api/validate' import { validateBody } from '@/lib/api/validate'
import { AddEmployeeToRunSchema } from '@/lib/api/schemas' import { AddEmployeeToRunSchema } from '@/lib/api/schemas'
import { getLineItemAccount } from '@/lib/salary/account-mapping' import { addEmployeeToRun } from '@/lib/salary/run-employees'
import type { SalaryLineItemType } from '@/types' import { getErrorEntry } from '@/lib/errors/structured-errors'
ensureInitialized() ensureInitialized()
@@ -18,94 +18,22 @@ export const POST = withRouteContext<{ params: Promise<{ id: string }> }>(
if (!validation.success) return validation.response if (!validation.success) return validation.response
const body = validation.data const body = validation.data
// Verify run is draft const result = await addEmployeeToRun(supabase, {
const { data: run, error: runError } = await supabase companyId,
.from('salary_runs') salaryRunId: id,
.select('id, status') employeeId: body.employee_id,
.eq('id', id) hoursWorked: body.hours_worked ?? null,
.eq('company_id', companyId) })
.single()
if (runError || !run) { if (!result.ok) {
return NextResponse.json({ error: 'Lönekörning hittades inte' }, { status: 404 }) const entry = getErrorEntry(result.code)
} return NextResponse.json(
if (run.status !== 'draft') { { error: entry?.message_sv ?? 'Något gick fel', code: result.code },
return NextResponse.json({ error: 'Kan bara lägga till anställda i utkast' }, { status: 400 }) { status: entry?.httpStatus ?? 500 },
)
} }
// Verify employee exists and is active return NextResponse.json({ data: result.data }, { status: 201 })
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 })
}, },
{ requireWrite: true }, { requireWrite: true },
) )
@@ -71,6 +71,9 @@ describe('PATCH /api/salary/runs/[id]/lines/[lineId]', () => {
const { enqueueMany } = authed() const { enqueueMany } = authed()
enqueueMany([ enqueueMany([
{ data: { id: 'run-1', status: 'draft' } }, // salary_runs lookup { 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 { data: { id: 'line-1', amount: 100 } }, // update returning
]) ])
const response = await PATCH( const response = await PATCH(
@@ -119,6 +122,8 @@ describe('DELETE /api/salary/runs/[id]/lines/[lineId]', () => {
const { enqueueMany } = authed() const { enqueueMany } = authed()
enqueueMany([ enqueueMany([
{ data: { id: 'run-1', status: 'draft' } }, // salary_runs lookup { 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) { data: null }, // delete (error null)
]) ])
const response = await DELETE( const response = await DELETE(
@@ -3,49 +3,37 @@ import { ensureInitialized } from '@/lib/init'
import { withRouteContext } from '@/lib/api/with-route-context' import { withRouteContext } from '@/lib/api/with-route-context'
import { validateBody } from '@/lib/api/validate' import { validateBody } from '@/lib/api/validate'
import { UpdateSalaryLineItemSchema } from '@/lib/api/schemas' import { UpdateSalaryLineItemSchema } from '@/lib/api/schemas'
import { updatePayslipLine, deletePayslipLine } from '@/lib/salary/payslip-lines'
import { getErrorEntry } from '@/lib/errors/structured-errors'
ensureInitialized() 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 }> }>( export const PATCH = withRouteContext<{ params: Promise<{ id: string; lineId: string }> }>(
'salary.run.line.update', 'salary.run.line.update',
async (request, ctx, { params }) => { async (request, ctx, { params }) => {
const { id, lineId } = await params const { id, lineId } = await params
const { supabase, companyId } = ctx 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) const validation = await validateBody(request, UpdateSalaryLineItemSchema)
if (!validation.success) return validation.response if (!validation.success) return validation.response
const body = validation.data
// Round amount if provided const result = await updatePayslipLine(supabase, {
const updates = { ...body } companyId,
if (updates.amount !== undefined) { salaryRunId: id,
updates.amount = Math.round(updates.amount * 100) / 100 lineId,
} patch: validation.data,
})
const { data: updated, error } = await supabase if (!result.ok) return errorResponse(result.code)
.from('salary_line_items') return NextResponse.json({ data: result.data })
.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 })
}, },
{ requireWrite: true }, { requireWrite: true },
) )
@@ -56,27 +44,13 @@ export const DELETE = withRouteContext<{ params: Promise<{ id: string; lineId: s
const { id, lineId } = await params const { id, lineId } = await params
const { supabase, companyId } = ctx const { supabase, companyId } = ctx
// Verify run is draft const result = await deletePayslipLine(supabase, {
const { data: run } = await supabase companyId,
.from('salary_runs') salaryRunId: id,
.select('id, status') lineId,
.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 })
}
if (!result.ok) return errorResponse(result.code)
return NextResponse.json({ data: { deleted: true } }) return NextResponse.json({ data: { deleted: true } })
}, },
{ requireWrite: 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 { withRouteContext } from '@/lib/api/with-route-context'
import { validateBody } from '@/lib/api/validate' import { validateBody } from '@/lib/api/validate'
import { CreateSalaryLineItemSchema } from '@/lib/api/schemas' 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() ensureInitialized()
@@ -15,64 +16,24 @@ export const POST = withRouteContext<{ params: Promise<{ id: string }> }>(
const validation = await validateBody(request, CreateSalaryLineItemSchema) const validation = await validateBody(request, CreateSalaryLineItemSchema)
if (!validation.success) return validation.response if (!validation.success) return validation.response
const body = validation.data const { salary_run_employee_id, ...input } = validation.data
// Verify run is draft const result = await createPayslipLine(supabase, {
const { data: run } = await supabase companyId,
.from('salary_runs') salaryRunId: id,
.select('id, status') target: { salaryRunEmployeeId: salary_run_employee_id },
.eq('id', id) input,
.eq('company_id', companyId) })
.single()
if (!run) { if (!result.ok) {
return NextResponse.json({ error: 'Lönekörning hittades inte' }, { status: 404 }) const entry = getErrorEntry(result.code)
} return NextResponse.json(
if (run.status !== 'draft') { { error: entry?.message_sv ?? 'Något gick fel', code: result.code },
return NextResponse.json({ error: 'Kan bara redigera utkast' }, { status: 400 }) { status: entry?.httpStatus ?? 500 },
)
} }
// Verify salary_run_employee belongs to this run return NextResponse.json({ data: result.data }, { status: 201 })
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 })
}, },
{ requireWrite: true }, { 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(status).toBe(200)
expect(body.data.company_name).toBe('New Name') 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 // Fetch current settings to check for tax-relevant changes
const { data: oldSettings } = await supabase const { data: oldSettings } = await supabase
.from('company_settings') .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) .eq('company_id', companyId)
.single() .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.) // 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 const effectiveVatRegistered = body.vat_registered ?? oldSettings?.vat_registered
if (effectiveVatRegistered === true) { 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_start: z.string(),
employment_end: z.string().nullable(), employment_end: z.string().nullable(),
employment_degree: z.number(), 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, salary_type: SalaryType,
monthly_salary: z.number().nullable(), monthly_salary: z.number().nullable(),
hourly_rate: z.number().nullable(), hourly_rate: z.number().nullable(),
@@ -62,6 +66,12 @@ const EmployeeDetail = z.object({
vaxa_stod_eligible: z.boolean(), vaxa_stod_eligible: z.boolean(),
vaxa_stod_start: z.string().nullable(), vaxa_stod_start: z.string().nullable(),
vaxa_stod_end: 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. // Dimensions PR8: bag applied to the employee's P&L cost lines at booking.
default_dimensions: z.record(z.string(), z.string()), default_dimensions: z.record(z.string(), z.string()),
is_active: z.boolean(), is_active: z.boolean(),
@@ -70,7 +80,7 @@ const EmployeeDetail = z.object({
}) })
const EMPLOYEE_DETAIL_COLUMNS = 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. * 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) { if (Object.keys(updates).length === 0) {
// GDPR Art.5(1)(c): no-change PATCH still returns a write-shape, so // GDPR Art.5(1)(c): no-change PATCH still returns a write-shape, so
// mask personnummer just like the POST + PATCH success path. // 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_eligible: false,
vaxa_stod_start: null, vaxa_stod_start: null,
vaxa_stod_end: null, vaxa_stod_end: null,
jamkning_percentage: null,
jamkning_valid_from: null,
jamkning_valid_to: null,
is_active: true, is_active: true,
created_at: '2024-01-15T08:00:00Z', created_at: '2024-01-15T08:00:00Z',
updated_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') 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 () => { 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 // 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 // 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_start: body.employment_start,
employment_end: body.employment_end ?? null, employment_end: body.employment_end ?? null,
employment_degree: body.employment_degree, employment_degree: body.employment_degree,
hours_per_week: body.hours_per_week,
workdays_per_week: body.workdays_per_week,
salary_type: body.salary_type, salary_type: body.salary_type,
monthly_salary: body.monthly_salary ?? null, monthly_salary: body.monthly_salary ?? null,
hourly_rate: body.hourly_rate ?? 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_eligible: body.vaxa_stod_eligible,
vaxa_stod_start: body.vaxa_stod_start ?? null, vaxa_stod_start: body.vaxa_stod_start ?? null,
vaxa_stod_end: body.vaxa_stod_end ?? 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. // Dimensions PR8: bag for the employee's P&L cost lines at booking.
default_dimensions: body.default_dimensions ?? {}, default_dimensions: body.default_dimensions ?? {},
}) })
@@ -369,6 +369,50 @@ describe('POST /api/v1/companies/:companyId/invoices/:id/mark-paid', () => {
expect(mockPayment).not.toHaveBeenCalled() 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 () => { it('returns 400 INVOICE_PAID_NOT_PAYABLE for draft invoices', async () => {
mockServiceClient.mockReturnValue( mockServiceClient.mockReturnValue(
makeFlexibleSupabase({ makeFlexibleSupabase({
@@ -42,7 +42,7 @@ import { AccountsNotInChartError, isBookkeepingError } from '@/lib/bookkeeping/e
import { getErrorMessage } from '@/lib/errors/get-error-message' import { getErrorMessage } from '@/lib/errors/get-error-message'
import { eventBus } from '@/lib/events' import { eventBus } from '@/lib/events'
import { findDuplicatePaymentCandidatesForInvoice } from '@/lib/invoices/duplicate-payment-candidates' 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 { roundOre } from '@/lib/money'
import type { CreateJournalEntryInput, EntityType, Invoice } from '@/types' import type { CreateJournalEntryInput, EntityType, Invoice } from '@/types'
@@ -285,7 +285,18 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string
const paymentAmountInInvoiceCurrency = customLines const paymentAmountInInvoiceCurrency = customLines
? roundOre(paymentAmount / fxRate) ? roundOre(paymentAmount / fxRate)
: paymentAmount : 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) { if (!payment.ok) {
return v1ErrorResponseFromCode('MATCH_AMOUNT_EXCEEDS_REMAINING', ctx.log, { return v1ErrorResponseFromCode('MATCH_AMOUNT_EXCEEDS_REMAINING', ctx.log, {
requestId: ctx.requestId, 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 { v1ErrorResponse, v1ErrorResponseFromCode } from '@/lib/api/v1/errors'
import { checkPeriodLock } from '@/lib/api/v1/check-period-lock' import { checkPeriodLock } from '@/lib/api/v1/check-period-lock'
import { createSalaryRunEntries } from '@/lib/salary/salary-entries' import { createSalaryRunEntries } from '@/lib/salary/salary-entries'
import { syncVacationLedgerForEmployees } from '@/lib/salary/vacation-ledger'
import { isBookkeepingError } from '@/lib/bookkeeping/errors' import { isBookkeepingError } from '@/lib/bookkeeping/errors'
import { eventBus } from '@/lib/events' 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) 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 const bookedAt = (bookedRun as { booked_at: string }).booked_at
return ok( 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: 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.', '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: 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: [ pitfalls: [
'salary_entry_id / avgifter_entry_id / vacation_entry_id are null until POST /book has run. They reference the journal_entries table.', '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.', '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 Script from "next/script";
import { NextIntlClientProvider } from "next-intl"; import { NextIntlClientProvider } from "next-intl";
import { getLocale, getMessages } from "next-intl/server"; import { getLocale, getMessages } from "next-intl/server";
import { SpeedInsights } from "@vercel/speed-insights/next";
import { Toaster } from "@/components/ui/toaster"; import { Toaster } from "@/components/ui/toaster";
import { DeployReloadPrompt } from "@/components/system/DeployReloadPrompt"; import { DeployReloadPrompt } from "@/components/system/DeployReloadPrompt";
import { ThemeProvider } from "@/components/theme-provider"; import { ThemeProvider } from "@/components/theme-provider";
import { SWRProvider } from "@/components/providers/SWRProvider";
import { RecaptLoader } from "@/components/RecaptLoader"; import { RecaptLoader } from "@/components/RecaptLoader";
import { RecaptHideWidget } from "@/components/RecaptHideWidget"; import { RecaptHideWidget } from "@/components/RecaptHideWidget";
import { ensureInitialized } from "@/lib/init"; import { ensureInitialized } from "@/lib/init";
@@ -85,12 +87,15 @@ export default async function RootLayout({
enableSystem enableSystem
disableTransitionOnChange disableTransitionOnChange
> >
{children} <SWRProvider>
<Toaster /> {children}
<DeployReloadPrompt /> <Toaster />
<RecaptHideWidget /> <DeployReloadPrompt />
<RecaptHideWidget />
</SWRProvider>
</ThemeProvider> </ThemeProvider>
</NextIntlClientProvider> </NextIntlClientProvider>
<SpeedInsights />
<Script src="/sw-register.js" strategy="afterInteractive" /> <Script src="/sw-register.js" strategy="afterInteractive" />
</body> </body>
</html> </html>
+12 -3
View File
@@ -11,8 +11,7 @@ import {
Check, Check,
Brain, Brain,
} from 'lucide-react' } from 'lucide-react'
import ReactMarkdown from 'react-markdown' import dynamic from 'next/dynamic'
import remarkGfm from 'remark-gfm'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { useCapability } from '@/contexts/CompanyContext' import { useCapability } from '@/contexts/CompanyContext'
@@ -20,6 +19,16 @@ import { CAPABILITY } from '@/lib/entitlements/keys'
import { UpgradeNote } from '@/components/billing/UpgradeNote' import { UpgradeNote } from '@/components/billing/UpgradeNote'
import ApprovalCard from './ApprovalCard' 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 // Reusable chat surface: used both inside the right-hand AgentSheet and on
// the full-page /chat route. Owns: // the full-page /chat route. Owns:
// * Message state (rendered list) // * Message state (rendered list)
@@ -684,7 +693,7 @@ function MessageBubble({
message.text || (streamingTail ? <Cursor /> : '') message.text || (streamingTail ? <Cursor /> : '')
) : message.text ? ( ) : 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"> <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> </div>
) : streamingTail ? ( ) : streamingTail ? (
<Cursor /> <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)) 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 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 [query, setQuery] = useState('')
const [activeIndex, setActiveIndex] = useState(0) const [activeIndex, setActiveIndex] = useState(0)
const inputRef = useRef<HTMLInputElement>(null) 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 { useAgentSheet } from '@/components/agent/AgentSheetProvider'
import { useCompany } from '@/contexts/CompanyContext' import { useCompany } from '@/contexts/CompanyContext'
import { useRealtimeSupabase } from '@/lib/hooks/use-realtime-supabase' 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 { EXTENSION_REQUIRED_CAPABILITY, type CapabilityKey } from '@/lib/entitlements/keys'
import type { EntityType } from '@/types' import type { EntityType } from '@/types'
@@ -83,8 +84,6 @@ interface DashboardNavProps {
// switched on. Drives visibility of the Kostnadsställen & projekt row: // switched on. Drives visibility of the Kostnadsställen & projekt row:
// same mechanism as paysSalaries: fetched by the dashboard layout. // same mechanism as paysSalaries: fetched by the dashboard layout.
dimensionsEnabled?: boolean dimensionsEnabled?: boolean
uncategorizedTransactionCount?: number
pendingOperationsCount?: number
isSandbox?: boolean isSandbox?: boolean
extensionNavItems?: ExtensionNavItem[] extensionNavItems?: ExtensionNavItem[]
// Signed-in user's full name + email: drives the bottom-left account // 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 '?' 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 pathname = usePathname()
const router = useRouter() const router = useRouter()
const supabase = useRealtimeSupabase() const supabase = useRealtimeSupabase()
@@ -261,17 +260,25 @@ export default function DashboardNav({ companyName: _companyName, entityType, pa
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false) const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false)
const [isClosing, setIsClosing] = useState(false) const [isClosing, setIsClosing] = useState(false)
const closeTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null) const closeTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const [liveUncategorizedTransactionCount, setLiveUncategorizedTransactionCount] = useState( // Badge counts load client-side after mount (and revalidate via the
uncategorizedTransactionCount, // realtime subscriptions below). They used to arrive as server props, which
) // put two head-count queries on the critical path of every dashboard
const refreshInFlightRef = useRef(false) // navigation for numbers nobody needs before first paint.
const refreshQueuedRef = useRef(false) const {
uncategorized: uncategorizedCount,
pendingOperations: pendingOpsCount,
refresh: refreshBadges,
} = useWorklistBadges(company?.id)
// Trial countdown for the sidebar touchpoint. Computed in an effect (not // Trial countdown for the sidebar touchpoint. Computed in an effect (not
// during render) so server and client markup agree at hydration; an hourly // 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) const [trialDaysLeft, setTrialDaysLeft] = useState<number | null>(null)
useEffect(() => { useEffect(() => {
if (!trialEndsAt) { if (!trialEndsAt) {
// eslint-disable-next-line react-hooks/set-state-in-effect
setTrialDaysLeft(null) setTrialDaysLeft(null)
return return
} }
@@ -364,43 +371,17 @@ export default function DashboardNav({ companyName: _companyName, entityType, pa
useEffect(() => { useEffect(() => {
if (!company?.id) return if (!company?.id) return
let cancelled = false // Realtime keeps the badges live; a trailing debounce collapses event
// bursts (bulk booking / bulk approvals emit one event per row) into a
const refreshUncategorizedCount = async () => { // single SWR revalidation instead of a request stampede.
if (!company?.id || cancelled) return let debounce: ReturnType<typeof setTimeout> | null = null
if (refreshInFlightRef.current) { const queueRefresh = () => {
refreshQueuedRef.current = true if (debounce) clearTimeout(debounce)
return debounce = setTimeout(() => void refreshBadges(), 400)
}
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
}
} }
void refreshUncategorizedCount()
const channel = supabase const channel = supabase
.channel(`dashboard-nav:transactions:${company.id}`) .channel(`dashboard-nav:badges:${company.id}`)
.on( .on(
'postgres_changes', 'postgres_changes',
{ {
@@ -409,17 +390,25 @@ export default function DashboardNav({ companyName: _companyName, entityType, pa
table: 'transactions', table: 'transactions',
filter: `company_id=eq.${company.id}`, filter: `company_id=eq.${company.id}`,
}, },
() => { queueRefresh,
void refreshUncategorizedCount() )
.on(
'postgres_changes',
{
event: '*',
schema: 'public',
table: 'pending_operations',
filter: `company_id=eq.${company.id}`,
}, },
queueRefresh,
) )
.subscribe() .subscribe()
return () => { return () => {
cancelled = true if (debounce) clearTimeout(debounce)
void supabase.removeChannel(channel) void supabase.removeChannel(channel)
} }
}, [company?.id, supabase]) }, [company?.id, supabase, refreshBadges])
const hiddenNavHrefs = new Set(getBranding().hiddenNavHrefs) const hiddenNavHrefs = new Set(getBranding().hiddenNavHrefs)
@@ -607,10 +596,10 @@ export default function DashboardNav({ companyName: _companyName, entityType, pa
const active = isActive(item.href) const active = isActive(item.href)
const enabled = isItemEnabled(item.href) && !item.comingSoon const enabled = isItemEnabled(item.href) && !item.comingSoon
const badge = const badge =
item.href === '/transactions' && liveUncategorizedTransactionCount > 0 item.href === '/transactions' && uncategorizedCount > 0
? liveUncategorizedTransactionCount ? uncategorizedCount
: item.href === '/pending' && pendingOperationsCount > 0 : item.href === '/pending' && pendingOpsCount > 0
? pendingOperationsCount ? pendingOpsCount
: null : null
const decorBadge = renderBadge(item, 'sidebar') const decorBadge = renderBadge(item, 'sidebar')
const content = ( const content = (
@@ -820,8 +809,8 @@ export default function DashboardNav({ companyName: _companyName, entityType, pa
{mobileNavItems.map((item) => { {mobileNavItems.map((item) => {
const active = isActive(item.href) const active = isActive(item.href)
const enabled = isItemEnabled(item.href) const enabled = isItemEnabled(item.href)
const badge = item.href === '/transactions' && liveUncategorizedTransactionCount > 0 const badge = item.href === '/transactions' && uncategorizedCount > 0
? liveUncategorizedTransactionCount ? uncategorizedCount
: null : null
const content = ( const content = (
@@ -977,10 +966,10 @@ export default function DashboardNav({ companyName: _companyName, entityType, pa
const Icon = item.icon const Icon = item.icon
const active = isActive(item.href) const active = isActive(item.href)
const enabled = isItemEnabled(item.href) && !item.comingSoon const enabled = isItemEnabled(item.href) && !item.comingSoon
const badge = item.href === '/transactions' && liveUncategorizedTransactionCount > 0 const badge = item.href === '/transactions' && uncategorizedCount > 0
? liveUncategorizedTransactionCount ? uncategorizedCount
: item.href === '/pending' && pendingOperationsCount > 0 : item.href === '/pending' && pendingOpsCount > 0
? pendingOperationsCount ? pendingOpsCount
: null : null
const decorBadge = renderBadge(item, 'mobile') const decorBadge = renderBadge(item, 'mobile')
const content = ( const content = (
+4 -1
View File
@@ -118,7 +118,7 @@ export default function PaymentBookingDialog({
// Fetch company settings // Fetch company settings
const { data: settings, error: settingsError } = await supabase const { data: settings, error: settingsError } = await supabase
.from('company_settings') .from('company_settings')
.select('accounting_method, entity_type') .select('accounting_method, entity_type, ore_rounding')
.eq('company_id', company.id) .eq('company_id', company.id)
.maybeSingle() .maybeSingle()
@@ -153,9 +153,12 @@ export default function PaymentBookingDialog({
vat_treatment: invoice.vat_treatment, vat_treatment: invoice.vat_treatment,
items: invoice.items, items: invoice.items,
default_dimensions: invoice.default_dimensions, default_dimensions: invoice.default_dimensions,
ore_rounding: invoice.ore_rounding,
}, },
accountingMethod, accountingMethod,
entityType, entityType,
companyOreRounding:
typeof settings?.ore_rounding === 'boolean' ? settings.ore_rounding : undefined,
}) })
setLines(proposed) 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 { import {
AlertCircle, AlertCircle,
CheckCircle2, CheckCircle2,
Circle,
Download, Download,
ExternalLink, ExternalLink,
Link2, Link2,
@@ -19,9 +20,11 @@ import { useTranslations } from 'next-intl'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { InfoTooltip } from '@/components/ui/info-tooltip' import { InfoTooltip } from '@/components/ui/info-tooltip'
import { useToast } from '@/components/ui/use-toast'
import { UpgradeNote } from '@/components/billing/UpgradeNote' import { UpgradeNote } from '@/components/billing/UpgradeNote'
import { useCapability } from '@/contexts/CompanyContext' import { useCapability } from '@/contexts/CompanyContext'
import { CAPABILITY } from '@/lib/entitlements/keys' import { CAPABILITY } from '@/lib/entitlements/keys'
import type { AgiSubmissionState } from '@/lib/salary/agi-submission-state'
interface AGIPanelProps { interface AGIPanelProps {
salaryRunId: string salaryRunId: string
@@ -32,6 +35,13 @@ interface AGIPanelProps {
/** Already-cached run-level signals for showing what step we're at. */ /** Already-cached run-level signals for showing what step we're at. */
agiGeneratedAt?: string | null agiGeneratedAt?: string | null
agiSubmittedAt?: 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. */ /** When true, write actions are hidden. */
readOnly?: boolean readOnly?: boolean
/** Called after a state-changing action so parent can refresh. */ /** Called after a state-changing action so parent can refresh. */
@@ -60,28 +70,6 @@ interface KontrollFinding {
identifierare?: string 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. */ /** Subset of SkatteverketAGIKontrollresultat we use in the panel. */
interface Kontrollresultat { interface Kontrollresultat {
status: 'PROCESSING' | 'DONE_SUCCESS' | 'DONE_FAILED' | 'DONE_REJECTED' status: 'PROCESSING' | 'DONE_SUCCESS' | 'DONE_FAILED' | 'DONE_REJECTED'
@@ -106,6 +94,36 @@ interface Kontrollresultat {
const ENABLED_KEY = 'EXTENSION_DISABLED' 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) { export function AGIPanel(props: AGIPanelProps) {
const { const {
salaryRunId, salaryRunId,
@@ -113,21 +131,47 @@ export function AGIPanel(props: AGIPanelProps) {
period, period,
agiGeneratedAt, agiGeneratedAt,
agiSubmittedAt, agiSubmittedAt,
submission,
onRefreshSubmission,
readOnly, readOnly,
onChange, onChange,
} = props } = props
const t = useTranslations('salary_agi') const t = useTranslations('salary_agi')
const { toast } = useToast()
const hasSkatteverket = useCapability(CAPABILITY.skatteverket) const hasSkatteverket = useCapability(CAPABILITY.skatteverket)
const [extensionDisabled, setExtensionDisabled] = useState(false) const [extensionDisabled, setExtensionDisabled] = useState(false)
const [status, setStatus] = useState<ConnectionStatus | null>(null) const [status, setStatus] = useState<ConnectionStatus | null>(null)
const [submission, setSubmission] = useState<SubmissionState | null>(null)
const [kontroller, setKontroller] = useState<KontrollFinding[]>([]) const [kontroller, setKontroller] = useState<KontrollFinding[]>([])
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [actionLoading, setActionLoading] = useState<string | null>(null) const [actionLoading, setActionLoading] = useState<string | null>(null)
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
const [success, setSuccess] = 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 () => { const fetchStatus = useCallback(async () => {
setLoading(true) 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(() => { useEffect(() => {
fetchStatus() fetchStatus()
fetchSubmission() }, [fetchStatus])
}, [fetchStatus, fetchSubmission])
// Handle of the OAuth popup opened by handleConnect: used to verify the // Handle of the OAuth popup opened by handleConnect: used to verify the
// sender identity of incoming postMessages. // sender identity of incoming postMessages.
@@ -231,6 +260,20 @@ export function AGIPanel(props: AGIPanelProps) {
) )
}, [agiGeneratedAt]) }, [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). // Background kvittens-polling timers (see scheduleKvittensPolls below).
// Held in a ref so the unmount-cleanup effect can cancel them if the // Held in a ref so the unmount-cleanup effect can cancel them if the
// user leaves the page mid-signing. // user leaves the page mid-signing.
@@ -263,7 +306,7 @@ export function AGIPanel(props: AGIPanelProps) {
if (!res.ok) return false if (!res.ok) return false
const json = await res.json() const json = await res.json()
const signed = !!json.data?.kvittenser?.[0]?.uuidKvittens const signed = !!json.data?.kvittenser?.[0]?.uuidKvittens
await fetchSubmission() onRefreshSubmission()
if (signed) { if (signed) {
// Replace any lingering "Granskningsunderlag klart…" / stale error // Replace any lingering "Granskningsunderlag klart…" / stale error
// with an unambiguous confirmation. Mirrors handleCheckSubmitted. // with an unambiguous confirmation. Mirrors handleCheckSubmitted.
@@ -275,7 +318,7 @@ export function AGIPanel(props: AGIPanelProps) {
} catch { } catch {
return false 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 * 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')) setSuccess(t('disconnect_success'))
await fetchStatus() await fetchStatus()
await fetchSubmission() onRefreshSubmission()
} catch (e) { } catch (e) {
setError(e instanceof Error ? e.message : t('disconnect_failed')) setError(e instanceof Error ? e.message : t('disconnect_failed'))
} finally { } finally {
setActionLoading(null) setActionLoading(null)
} }
}, [fetchStatus, fetchSubmission, t]) }, [fetchStatus, onRefreshSubmission, t])
const handleConnect = () => { const handleConnect = () => {
// Open the BankID OAuth flow in a centered popup. The callback page // 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 * The XML must exist in agi_declarations before anything can be submitted.
* status flips out of PROCESSING. Skatteverket's spec says polling is * The internal xml route both generates and persists it (and stamps
* usually instantaneous, but we cap at 8 attempts × 1s to be safe. * 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. * On DONE_SUCCESS the underlag is auto-persisted by SKV: no /spara call.
* Calling /spara when there are no errors returns 400 felkod 20 * 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 * 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. * 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 // 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 // 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 () => { const handleDownloadXml = async () => {
setActionLoading('download') setActionLoading('download')
setError(null) setError(null)
@@ -442,7 +647,7 @@ export function AGIPanel(props: AGIPanelProps) {
const res = await fetch(`/api/salary/runs/${salaryRunId}/agi/xml`) const res = await fetch(`/api/salary/runs/${salaryRunId}/agi/xml`)
if (!res.ok) { if (!res.ok) {
const data = await res.json().catch(() => ({})) 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 blob = await res.blob()
const url = URL.createObjectURL(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 () => { const handleSubmit = async () => {
setActionLoading('submit') setActionLoading('submit')
setError(null) setError(null)
setSuccess(null) setSuccess(null)
setKontroller([])
try { try {
const submitRes = await fetch('/api/extensions/ext/skatteverket/agi/submit', { if (!(await ensureAgiGenerated())) return
method: 'POST', const ok = await runSubmitUnderlag()
headers: { 'Content-Type': 'application/json' }, if (ok) setSuccess(t('underlag_accepted'))
body: JSON.stringify({ salaryRunId }), onRefreshSubmission()
})
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()
onChange?.() onChange?.()
} catch (e) { } catch (e) {
setError(e instanceof Error ? e.message : t('submit_failed')) setError(e instanceof Error ? e.message : t('submit_failed'))
@@ -523,36 +684,15 @@ export function AGIPanel(props: AGIPanelProps) {
} }
} }
/** /** Advanced/recovery variant: create the signing link on its own. */
* 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.
*/
const handleCreateSigningLink = async () => { const handleCreateSigningLink = async () => {
setActionLoading('granskning') setActionLoading('granskning')
setError(null) setError(null)
setSuccess(null) setSuccess(null)
try { try {
const res = await fetch( const link = await runCreateSigningLink()
`/api/extensions/ext/skatteverket/agi/granskningsunderlag?arbetsgivare=${encodeURIComponent(arbetsgivare)}&period=${period}`, if (link !== null) setSuccess(t('signing_link_ready'))
{ method: 'POST' }, onRefreshSubmission()
)
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()
} catch (e) { } catch (e) {
setError(e instanceof Error ? e.message : t('signing_link_failed')) setError(e instanceof Error ? e.message : t('signing_link_failed'))
} finally { } finally {
@@ -575,7 +715,7 @@ export function AGIPanel(props: AGIPanelProps) {
return return
} }
setSuccess(t('unlock_success')) setSuccess(t('unlock_success'))
await fetchSubmission() onRefreshSubmission()
} catch (e) { } catch (e) {
setError(e instanceof Error ? e.message : t('unlock_failed')) setError(e instanceof Error ? e.message : t('unlock_failed'))
} finally { } 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 * signed in Mina Sidor. Once a kvittens turns up, the index.ts handler
* mirrors it onto agi_declarations and flips the local submission state * mirrors it onto agi_declarations and flips the local submission state
* to 'signed'. * to 'signed'.
@@ -608,7 +748,7 @@ export function AGIPanel(props: AGIPanelProps) {
} else { } else {
setSuccess(t('no_kvittens_yet')) setSuccess(t('no_kvittens_yet'))
} }
await fetchSubmission() onRefreshSubmission()
onChange?.() onChange?.()
} catch (e) { } catch (e) {
setError(e instanceof Error ? e.message : t('check_status_failed')) 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 // Tokens issued before the agd scope was added to DEFAULT_SCOPES will
// 403 with invalid_scope at submission time: surface that proactively // 403 with invalid_scope at submission time: surface that proactively
// so the user reconnects before hitting the deadline rather than at it. // 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' && typeof status?.scope === 'string' &&
!status.scope.split(/\s+/).filter(Boolean).includes('agd') !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 ( return (
<Card> <Card>
<CardHeader> <CardHeader>
@@ -727,6 +867,40 @@ export function AGIPanel(props: AGIPanelProps) {
</CardTitle> </CardTitle>
</CardHeader> </CardHeader>
<CardContent className="space-y-4"> <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 {/* Expired-session banner: the token row exists (so status.connected
is true) but the access token is past expiry and either has no is true) but the access token is past expiry and either has no
refresh token or has burned through its 10-refresh budget. The refresh token or has burned through its 10-refresh budget. The
@@ -919,74 +1093,140 @@ export function AGIPanel(props: AGIPanelProps) {
)} )}
{!readOnly && !isSigned && ( {!readOnly && !isSigned && (
<div className="flex flex-wrap gap-2"> <div className="space-y-3">
<Button {/* Primary path: one click runs the whole filing chain. Hidden
size="sm" while a signing draft is open at SKV (the period is locked,
variant="outline" so a resubmission would be refused): the signing-link card
onClick={handleDownloadXml} above is the CTA then, and the stale-draft recovery goes
disabled={actionLoading === 'download'} through the advanced actions per the guidance text. The
title={t('download_xml_title')} XML download stays free for manual filing regardless. */}
> <div className="flex flex-wrap items-center gap-2">
{actionLoading === 'download' ? ( {!awaitingSigning && (
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" /> <Button
) : ( onClick={handleSubmitChain}
<Download className="mr-1.5 h-3.5 w-3.5" /> 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 <Button
size="sm" size="sm"
variant="ghost" variant="outline"
onClick={handleUnlock} onClick={handleDownloadXml}
disabled={actionLoading === 'unlock'} 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" /> <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> </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> </div>
)} )}
+8
View File
@@ -168,6 +168,8 @@ function NewEmployeeForm({ onCreated, onCancel }: { onCreated: () => void; onCan
employment_start: form.get('employment_start') as string, employment_start: form.get('employment_start') as string,
employment_end: form.get('employment_end') as string || undefined, employment_end: form.get('employment_end') as string || undefined,
employment_degree: parseFloat(form.get('employment_degree') as string) || 100, 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, salary_type: salaryType,
monthly_salary: salaryType === 'monthly' ? (parseFloat(form.get('monthly_salary') as string) || undefined) : undefined, 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, 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"> <Field label="Sysselsättningsgrad (%)" htmlFor="employment_degree">
<Input id="employment_degree" name="employment_degree" type="number" defaultValue="100" min="1" max="100" /> <Input id="employment_degree" name="employment_degree" type="number" defaultValue="100" min="1" max="100" />
</Field> </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> <Field label="Löneform" htmlFor="salary_type" required>
<Select value={salaryType} onValueChange={setSalaryType}> <Select value={salaryType} onValueChange={setSalaryType}>
<SelectTrigger id="salary_type"> <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 { formatDateLong } from '@/lib/utils'
import { useCapability } from '@/contexts/CompanyContext' import { useCapability } from '@/contexts/CompanyContext'
import { CAPABILITY } from '@/lib/entitlements/keys' import { CAPABILITY } from '@/lib/entitlements/keys'
import type { AgiFilingState } from '@/lib/salary/agi-submission-state'
import type { RunDetail } from './types' import type { RunDetail } from './types'
type StepState = 'done' | 'active' | 'upcoming' type StepState = 'done' | 'active' | 'upcoming'
@@ -16,6 +17,12 @@ interface RunProgressBarProps {
// True when the run pays out nothing (nollkörning / fully net-deducted): the // 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. // pay step carries no "download the file" hint because there is no file.
noPayout?: boolean 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 canWrite: boolean
actionLoading: string | null actionLoading: string | null
// The single "forward" step for the current status (Beräkna → Skicka till // 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) { export function RunProgressBar(props: RunProgressBarProps) {
const t = useTranslations('salary_run') const t = useTranslations('salary_run')
const locale = useLocale() 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 rank = STATUS_RANK[run.status] ?? 0
const busy = !!actionLoading const busy = !!actionLoading
const deliveries = run.payslip_deliveries_summary const deliveries = run.payslip_deliveries_summary
@@ -116,14 +123,21 @@ export function RunProgressBar(props: RunProgressBarProps) {
{ {
key: 'agi', key: 'agi',
label: t('rail_agi'), label: t('rail_agi'),
state: run.agi_submitted_at ? 'done' : run.status === 'booked' ? 'active' : 'upcoming', state: agiState === 'signed' ? 'done' : run.status === 'booked' ? 'active' : 'upcoming',
detail: run.agi_submitted_at detail:
? t('rail_agi_submitted') agiState === 'signed'
: run.agi_generated_at ? agiKvittensnummer
? t('rail_agi_generated') ? t('rail_agi_submitted_kvittens', { kvittens: agiKvittensnummer })
: run.status === 'booked' : t('rail_agi_submitted')
? t('rail_agi_hint') : agiState === 'awaiting_signing'
: undefined, ? 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, createElement,
useCallback, useCallback,
useContext, useContext,
useEffect,
useState,
type ReactNode, type ReactNode,
} from 'react' } from 'react'
import useSWR from 'swr'
import { createClient } from '@/lib/supabase/client' import { createClient } from '@/lib/supabase/client'
import { useCompany } from '@/contexts/CompanyContext' import { useCompany } from '@/contexts/CompanyContext'
import type { CompanySettings } from '@/types' import type { CompanySettings } from '@/types'
@@ -36,48 +35,56 @@ export interface SettingsState {
*/ */
export function useCompanySettings(): SettingsState { export function useCompanySettings(): SettingsState {
const { company } = useCompany() const { company } = useCompany()
const [settings, setSettings] = useState<CompanySettings | null>(null) const companyId = company?.id ?? null
const [isLoading, setIsLoading] = useState(true)
const [error, setError] = useState(false)
const fetchSettings = useCallback(async () => { // SWR-backed: every consumer of the same company's settings shares one
if (!company?.id) { // cache entry, so concurrent mounts dedupe into a single request and
// No active company (the no-company escape hatch). Nothing to load; surface // back-navigation renders from cache (revalidating in the background)
// a settled empty state rather than a perpetual spinner. // instead of re-showing a skeleton. Null key = no active company (the
setSettings(null) // no-company escape hatch): a settled empty state, never a spinner.
setError(false) const { data, error: swrError, isLoading, mutate } = useSWR(
setIsLoading(false) companyId ? ['company_settings', companyId] : null,
return 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) const updateSettings = useCallback(
setError(false) (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() const refetch = useCallback(async () => {
// maybeSingle() so a missing row resolves to { data: null } instead of await mutate()
// throwing PGRST116: a company created outside the onboarding flow may have }, [mutate])
// 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()
setSettings(data) return {
setError(Boolean(queryError) || !data) settings: data ?? null,
setIsLoading(false) isLoading: companyId ? isLoading : false,
}, [company?.id]) // Same contract as before SWR: error means "settled without a row"
// (query failure or missing company_settings row), never mid-flight.
useEffect(() => { error: companyId ? !isLoading && (Boolean(swrError) || !data) : false,
fetchSettings() updateSettings,
}, [fetchSettings]) refetch,
}
const updateSettings = useCallback((updates: Partial<CompanySettings>) => {
setSettings((prev) => (prev ? ({ ...prev, ...updates } as CompanySettings) : prev))
}, [])
return { settings, isLoading, error, updateSettings, refetch: fetchSettings }
} }
const SettingsContext = createContext<SettingsState | null>(null) 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.gte = vi.fn(() => chain)
chain.limit = vi.fn(() => chain) chain.limit = vi.fn(() => chain)
chain.order = 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) => { chain.insert = vi.fn((payload: unknown) => {
insertSpy?.(payload) insertSpy?.(payload)
return chain return chain
@@ -2,11 +2,12 @@
import { useState, useEffect, useRef } from 'react' import { useState, useEffect, useRef } from 'react'
import Link from 'next/link' import Link from 'next/link'
import { useSearchParams } from 'next/navigation'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { useToast } from '@/components/ui/use-toast' import { useToast } from '@/components/ui/use-toast'
import { DestructiveConfirmDialog, useDestructiveConfirm } from '@/components/ui/destructive-confirm-dialog' 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 { cn } from '@/lib/utils'
import { createClient } from '@/lib/supabase/client' import { createClient } from '@/lib/supabase/client'
import { notifyBankSyncUpdated } from '@/lib/transactions/bank-sync-signal' import { notifyBankSyncUpdated } from '@/lib/transactions/bank-sync-signal'
@@ -26,6 +27,13 @@ import type { StoredAccount } from '../types'
export default function BankingSettingsPanel() { export default function BankingSettingsPanel() {
const { toast } = useToast() const { toast } = useToast()
const supabase = createClient() 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 { dialogProps, confirm } = useDestructiveConfirm()
const { company } = useCompany() const { company } = useCompany()
@@ -365,6 +373,24 @@ export default function BankingSettingsPanel() {
} }
if (isLoading) { 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 ( return (
<div className="flex items-center justify-center h-32"> <div className="flex items-center justify-center h-32">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" /> <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 } { status: 409 }
) )
} }
}
// Clean up stale pending connections (older than threshold) // Sweep failed attempts that never became a live connection:
log.info('[enable-banking] Cleaning up stale pending connections', { // stale 'pending' rows (abandoned redirects past the threshold)
stale_id: recentPending.id, // and 'error' rows left by earlier denied/failed connects.
age_ms: pendingAge, // 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 // already-minimal ~24-token description. Headroom before the change was
// under 10 tokens, so even this smallest possible addition crossed; // under 10 tokens, so even this smallest possible addition crossed;
// other descriptions are at their trimmed floor per the entries above. // 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 // 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 // fires again, prefer trimming descriptions or making a tool opt-in via search
// before bumping further. // 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' } from '@/lib/api/idempotency'
import { toToolError, type NextActionHint } from './tool-result' import { toToolError, type NextActionHint } from './tool-result'
import { findSupplierCandidates } from './supplier-candidates' import { findSupplierCandidates } from './supplier-candidates'
import { assertNoPlaintextPersonnummer } from './staging-pii-guard'
import { generateBalanceSheet } from '@/lib/reports/balance-sheet' import { generateBalanceSheet } from '@/lib/reports/balance-sheet'
import { generateGeneralLedger } from '@/lib/reports/general-ledger' import { generateGeneralLedger } from '@/lib/reports/general-ledger'
import { decryptPersonnummer, maskPersonnummer } from '@/lib/salary/personnummer' import { decryptPersonnummer, maskPersonnummer } from '@/lib/salary/personnummer'
@@ -291,6 +292,12 @@ async function stagePendingOperation(
period_status?: PeriodStatusForDate period_status?: PeriodStatusForDate
next?: NextActionHint 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 riskLevel = getRiskLevel(operationType)
const branding = getBranding().appName.toLowerCase() 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) ── // ── 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) { if (declarationError || !declaration?.xml_content) {
throw new Error( 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']) .enum(['swedbank', 'seb', 'handelsbanken', 'nordea', 'other'])
.nullable() .nullable()
.optional(), .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( }).refine(
(data) => { (data) => {
// BFL 3 kap.: Enskild firma must have fiscal year starting January // BFL 3 kap.: Enskild firma must have fiscal year starting January
@@ -1959,6 +1963,11 @@ const EmployeeSchemaBase = z.object({
employment_start: isoDate, employment_start: isoDate,
employment_end: isoDate.optional(), employment_end: isoDate.optional(),
employment_degree: z.number().min(1).max(100).default(100), 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'), salary_type: SalaryTypeSchema.default('monthly'),
monthly_salary: z.number().nonnegative().optional(), monthly_salary: z.number().nonnegative().optional(),
hourly_rate: 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_eligible: z.boolean().default(false),
vaxa_stod_start: isoDate.optional(), vaxa_stod_start: isoDate.optional(),
vaxa_stod_end: 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 // 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). // salary run is booked. {} clears (the UI always sends the field).
default_dimensions: DimensionsBagSchema.optional(), 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 // Bank details: validate clearing/kontonummer structure at entry so a typo is
// caught here rather than at Bankgirot LB generation. Both empty is allowed. // 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 // 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 // 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) { if (data.salary_type === 'monthly' && data.monthly_salary !== undefined && data.monthly_salary <= 0) {
ctx.addIssue({ ctx.addIssue({
@@ -2127,6 +2191,23 @@ export const UpdateEmployeeSchema = EmployeeSchemaBase.partial().superRefine((da
path: ['vaxa_stod_end'], 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']) export const EmployeeBenefitTypeSchema = z.enum(['bike', 'car', 'meals', 'housing', 'wellness', 'other'])
@@ -2249,6 +2330,88 @@ export const AbsenceRangeQuerySchema = z.object({
path: ['from'], 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) ───────────────── // ── Worked-hours per-day records (hourly employees) ─────────────────
// //
// Drives base salary calculation for hourly (timanställd) 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 // 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`] = ` 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/customers/:id",
"DELETE /api/v1/companies/:companyId/dimensions/:id/values/:valueId", "DELETE /api/v1/companies/:companyId/dimensions/:id/values/:valueId",
"DELETE /api/v1/companies/:companyId/employees/:id", "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",
"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/suppliers/:id",
"DELETE /api/v1/companies/:companyId/webhooks/:id", "DELETE /api/v1/companies/:companyId/webhooks/:id",
"GET /api/v1/companies", "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/documents/:id/download",
"GET /api/v1/companies/:companyId/employees", "GET /api/v1/companies/:companyId/employees",
"GET /api/v1/companies/:companyId/employees/:id", "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/fiscal-periods",
"GET /api/v1/companies/:companyId/invoices", "GET /api/v1/companies/:companyId/invoices",
"GET /api/v1/companies/:companyId/invoices/:id", "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/reports/vat-declaration",
"GET /api/v1/companies/:companyId/salary-runs", "GET /api/v1/companies/:companyId/salary-runs",
"GET /api/v1/companies/:companyId/salary-runs/:id", "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",
"GET /api/v1/companies/:companyId/supplier-invoices/:id", "GET /api/v1/companies/:companyId/supplier-invoices/:id",
"GET /api/v1/companies/:companyId/suppliers", "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/employees/:id",
"PATCH /api/v1/companies/:companyId/invoices/:id", "PATCH /api/v1/companies/:companyId/invoices/:id",
"PATCH /api/v1/companies/:companyId/salary-runs/: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/supplier-invoices/:id",
"PATCH /api/v1/companies/:companyId/suppliers/:id", "PATCH /api/v1/companies/:companyId/suppliers/:id",
"PATCH /api/v1/companies/:companyId/webhooks/: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/approve",
"POST /api/v1/companies/:companyId/salary-runs/:id/book", "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/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/generate-agi",
"POST /api/v1/companies/:companyId/salary-runs/:id/mark-paid", "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",
"POST /api/v1/companies/:companyId/supplier-invoices/:id/approve", "POST /api/v1/companies/:companyId/supplier-invoices/:id/approve",
"POST /api/v1/companies/:companyId/supplier-invoices/:id/credit", "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/rotate-secret",
"POST /api/v1/companies/:companyId/webhooks/:id/test", "POST /api/v1/companies/:companyId/webhooks/:id/test",
"POST /api/v1/webhook-deliveries/:id/retry", "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]/book/route'
import '@/app/api/v1/companies/[companyId]/salary-runs/[id]/generate-agi/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 // Phase 5 PR-3: Reports + import async. All reports wrap existing
// lib/reports/* generators. Imports run inline today but record their // lib/reports/* generators. Imports run inline today but record their
// progress on the `operations` table for consistent polling-shape. KPI, // 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 IDEMPOTENCY_HEADER = 'Idempotency-Key'
const DRY_RUN_HEADER = 'X-Dry-Run' 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 { export interface ApiV1Context {
/** Stable id for this HTTP request: appears in logs, error envelope, X-Request-Id. */ /** 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 let errLog = log
try { try {
const authStart = Date.now()
const auth = await requireAuth() const auth = await requireAuth()
const authMs = Date.now() - authStart
if (auth.error) { if (auth.error) {
log.warn('auth failed', { status: auth.error.status }) log.warn('auth failed', { status: auth.error.status })
// Pass through requireAuth's response unchanged for backwards-compat // Pass through requireAuth's response unchanged for backwards-compat
@@ -119,11 +121,13 @@ export function withRouteContext<P extends DynamicParams = { params: Promise<Rec
errLog = userLog errLog = userLog
let companyId: string | null = null let companyId: string | null = null
const companyStart = Date.now()
try { try {
companyId = await getActiveCompanyId(supabase, user.id) companyId = await getActiveCompanyId(supabase, user.id)
} catch (err) { } catch (err) {
userLog.error('failed to resolve active company', err as Error) userLog.error('failed to resolve active company', err as Error)
} }
const companyMs = Date.now() - companyStart
if (!companyId) { if (!companyId) {
return errorResponseFromCode('COMPANY_CONTEXT_MISSING', userLog, { requestId }) return errorResponseFromCode('COMPANY_CONTEXT_MISSING', userLog, { requestId })
@@ -152,15 +156,30 @@ export function withRouteContext<P extends DynamicParams = { params: Promise<Rec
} }
errLog = ctx.log errLog = ctx.log
const handlerStart = Date.now()
const response = await handler(request, ctx, params) const response = await handler(request, ctx, params)
const handlerMs = Date.now() - handlerStart
if (response instanceof Response && !response.headers.get('X-Request-Id')) { if (response instanceof Response && !response.headers.get('X-Request-Id')) {
response.headers.set('X-Request-Id', requestId) 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', { ctx.log.info('op completed', {
durationMs: Date.now() - start, durationMs: Date.now() - start,
status: response.status, status: response.status,
authMs,
companyMs,
handlerMs,
}) })
return response return response
} catch (err) { } catch (err) {
+34
View File
@@ -227,6 +227,17 @@ export const TOOL_SCOPE_MAP: Record<string, ApiKeyScope> = {
gnubok_create_salary_run: 'payroll:write', gnubok_create_salary_run: 'payroll:write',
gnubok_calculate_salary_run: 'payroll:write', gnubok_calculate_salary_run: 'payroll:write',
gnubok_generate_agi: '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 // Bookkeeping write (Stream 1 Phase 1): high-risk, always staged
gnubok_close_period: 'bookkeeping:write', gnubok_close_period: 'bookkeeping:write',
gnubok_lock_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_agi_status: 'compliance:read',
gnubok_vat_declaration_submit: 'skatteverket:write', gnubok_vat_declaration_submit: 'skatteverket:write',
gnubok_agi_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 { 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/mark-paid': 'payroll:write',
'POST /api/v1/companies/:companyId/salary-runs/:id/book': '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', '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 // Dimensions (kostnadsställe/projekt): dimensions PR2. Reads ride
// reports:read (registry data feeds report filters/pickers); value creation // 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)', () => { describe('proposePaymentLines: dimensions propagation (PR7)', () => {
const bag = { '1': 'KS01', '6': 'P001' } const bag = { '1': 'KS01', '6': 'P001' }
+49 -6
View File
@@ -7,6 +7,7 @@
import { resolveSekAmount } from './currency-utils' import { resolveSekAmount } from './currency-utils'
import { getRevenueAccount, getOutputVatAccount } from './invoice-entries' import { getRevenueAccount, getOutputVatAccount } from './invoice-entries'
import { getVatTreatmentForRate } from '@/lib/invoices/vat-rules' import { getVatTreatmentForRate } from '@/lib/invoices/vat-rules'
import { getDisplayTotal } from '@/lib/invoices/rounding'
import type { FormLine } from '@/components/bookkeeping/JournalEntryForm' import type { FormLine } from '@/components/bookkeeping/JournalEntryForm'
import type { EntityType, InvoiceItem, VatTreatment } from '@/types' import type { EntityType, InvoiceItem, VatTreatment } from '@/types'
@@ -23,6 +24,8 @@ export interface ProposePaymentLinesInput {
exchange_rate?: number | null exchange_rate?: number | null
vat_treatment: VatTreatment vat_treatment: VatTreatment
items?: InvoiceItem[] 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 * Dimensions PR7: the invoice's default bag. Stamped on every proposed
* line: the payment dialog always submits its (editable) lines, so the * line: the payment dialog always submits its (editable) lines, so the
@@ -36,6 +39,13 @@ export interface ProposePaymentLinesInput {
entityType: EntityType entityType: EntityType
paymentAccount?: string paymentAccount?: string
exchangeRateDifference?: number 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 { function toFormAmount(n: number): string {
@@ -73,9 +83,19 @@ export function proposePaymentLines(input: ProposePaymentLinesInput): FormLine[]
const paymentAccount = input.paymentAccount || '1930' const paymentAccount = input.paymentAccount || '1930'
const desc = invoice.invoice_number ? `Betalning faktura ${invoice.invoice_number}` : 'Betalning faktura' 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' const lines = accountingMethod === 'accrual'
? proposeAccrualLines(invoice, paymentAccount, desc, exchangeRateDifference) ? proposeAccrualLines(invoice, paymentAccount, desc, exchangeRateDifference, roundingDelta)
: proposeCashLines(invoice, paymentAccount, desc, entityType) : proposeCashLines(invoice, paymentAccount, desc, entityType, roundingDelta)
// Dimensions PR7: re-propagate the invoice default onto every proposed leg // Dimensions PR7: re-propagate the invoice default onto every proposed leg
// (matches createInvoicePaymentJournalEntry/createInvoiceCashEntry). // (matches createInvoicePaymentJournalEntry/createInvoiceCashEntry).
@@ -86,11 +106,26 @@ export function proposePaymentLines(input: ProposePaymentLinesInput): FormLine[]
return lines 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( function proposeAccrualLines(
invoice: ProposePaymentLinesInput['invoice'], invoice: ProposePaymentLinesInput['invoice'],
paymentAccount: string, paymentAccount: string,
desc: string, desc: string,
exchangeRateDifference?: number exchangeRateDifference?: number,
roundingDelta = 0
): FormLine[] { ): FormLine[] {
const bookedSekAmount = resolveSekAmount( const bookedSekAmount = resolveSekAmount(
invoice.total, invoice.total,
@@ -136,7 +171,7 @@ function proposeAccrualLines(
const amount = Math.round(bookedSekAmount * 100) / 100 const amount = Math.round(bookedSekAmount * 100) / 100
lines.push({ lines.push({
account_number: paymentAccount, account_number: paymentAccount,
debit_amount: toFormAmount(amount), debit_amount: toFormAmount(amount + roundingDelta),
credit_amount: '', credit_amount: '',
line_description: desc, line_description: desc,
}) })
@@ -146,6 +181,9 @@ function proposeAccrualLines(
credit_amount: toFormAmount(amount), credit_amount: toFormAmount(amount),
line_description: desc, line_description: desc,
}) })
if (roundingDelta !== 0) {
lines.push(oreRoundingLine(roundingDelta))
}
} }
return lines return lines
@@ -155,7 +193,8 @@ function proposeCashLines(
invoice: ProposePaymentLinesInput['invoice'], invoice: ProposePaymentLinesInput['invoice'],
paymentAccount: string, paymentAccount: string,
desc: string, desc: string,
entityType: EntityType entityType: EntityType,
roundingDelta = 0
): FormLine[] { ): FormLine[] {
const lines: FormLine[] = [] const lines: FormLine[] = []
const isForeign = invoice.currency !== 'SEK' const isForeign = invoice.currency !== 'SEK'
@@ -264,12 +303,16 @@ function proposeCashLines(
lines.push({ lines.push({
account_number: paymentAccount, account_number: paymentAccount,
debit_amount: toFormAmount(debitAmount), debit_amount: toFormAmount(debitAmount + roundingDelta),
credit_amount: '', credit_amount: '',
line_description: desc, line_description: desc,
}) })
lines.push(...creditLines) lines.push(...creditLines)
if (roundingDelta !== 0) {
lines.push(oreRoundingLine(roundingDelta))
}
return lines return lines
} }
+78 -3
View File
@@ -6,17 +6,22 @@ vi.mock('next/headers', () => ({
cookies: vi.fn(async () => ({ set: mockCookieSet })), 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 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 * Chainable Supabase mock (same approach as actions.test.ts): a chain method
* terminates with `results[table][method]` when seeded, otherwise keeps * terminates with `results[table][method]` when seeded, otherwise keeps
* chaining. setActiveCompany ends both its queries on `.single()`, on * chaining. setActiveCompany ends both its queries on `.single()`, on
* different tables, so seeding `single` per table drives each branch. * 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[] = [] const calls: CapturedCall[] = []
function makeChain(table: string) { function makeChain(table: string) {
@@ -25,7 +30,8 @@ function buildSupabase(results: Record<string, Record<string, { data?: unknown;
for (const m of methods) { for (const m of methods) {
chain[m] = (...args: unknown[]) => { chain[m] = (...args: unknown[]) => {
calls.push({ table, method: m, args }) 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) { if (terminal) {
return Promise.resolve({ data: terminal.data ?? null, error: terminal.error ?? null }) 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', () => { describe('getCompanyDisplayName', () => {
it('returns company_settings.company_name and never reads companies when set', async () => { it('returns company_settings.company_name and never reads companies when set', async () => {
const { supabase, calls } = buildSupabase({ const { supabase, calls } = buildSupabase({
+31 -18
View File
@@ -37,16 +37,38 @@ export async function getActiveCompanyId(
supabase: SupabaseClient, supabase: SupabaseClient,
userId: string userId: string
): Promise<string | null> { ): Promise<string | null> {
// 1. user_preferences: authoritative // user_preferences (authoritative) + first membership, fetched in parallel:
const { data: prefs } = await supabase // the fallback query result doubles as validation when the preferred
.from('user_preferences') // company happens to be the first membership, which is the common
.select('active_company_id') // single-company case. Most requests pay one round trip instead of two
.eq('user_id', userId) // sequential ones. This runs on every withRouteContext API request and
.maybeSingle() // 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) { if (prefs?.active_company_id) {
// Validate the preference still points to a non-archived company the if (firstCompany && prefs.active_company_id === firstCompany.company_id) {
// user is a member of. 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 const { data: membership } = await supabase
.from('company_members') .from('company_members')
.select('company_id, companies!inner(archived_at)') .select('company_id, companies!inner(archived_at)')
@@ -58,16 +80,7 @@ export async function getActiveCompanyId(
if (membership) return membership.company_id if (membership) return membership.company_id
} }
// 2. Fallback: first non-archived membership by created_at // Fallback: first non-archived membership by created_at (already fetched)
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()
return firstCompany?.company_id ?? null 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 på 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). > Drive a Swedish salary run from draft to booked, then generate the arbetsgivardeklaration på 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 ## 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) ## 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 \`\`\`bash
curl "https://app.gnubok.se/api/v1/companies/$COMPANY_ID/salary-runs" \\ 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_sv: 'Inga aktiva anställda finns i företaget.',
message_en: 'No active employees in the company.', 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: { SALARY_RUN_TAX_TABLE_MISSING: {
httpStatus: 400, httpStatus: 400,
message_sv: 'Skattetabellen saknas för perioden. Importera skattetabellen först.', 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 { describe, it, expect } from 'vitest'
import { import {
planInvoicePayment, planInvoicePayment,
planInvoicePaymentForLines,
PAYMENT_OVERSHOOT_TOLERANCE, PAYMENT_OVERSHOOT_TOLERANCE,
} from '@/lib/invoices/apply-invoice-payment' } from '@/lib/invoices/apply-invoice-payment'
@@ -65,4 +66,184 @@ describe('planInvoicePayment', () => {
it('overshoot tolerance is half an öre', () => { it('overshoot tolerance is half an öre', () => {
expect(PAYMENT_OVERSHOOT_TOLERANCE).toBe(0.005) 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 { import {
computeNextRunDate, computeNextRunDate,
computeInitialRunDate, computeInitialRunDate,
getStockholmDateHour, getStockholmDateHour,
executeRecurringSchedule,
} from '@/lib/invoices/recurring-schedule-service' } 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', () => { describe('computeNextRunDate', () => {
it('advances day 15 from January to February', () => { 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, createInvoicePaymentJournalEntry,
createInvoiceCashEntry, createInvoiceCashEntry,
} from '@/lib/bookkeeping/invoice-entries' } from '@/lib/bookkeeping/invoice-entries'
import { createJournalEntry, findFiscalPeriod } from '@/lib/bookkeeping/engine'
import { cancelOrphanedPaymentEntry } from '@/lib/bookkeeping/cancel-orphaned-entry' import { cancelOrphanedPaymentEntry } from '@/lib/bookkeeping/cancel-orphaned-entry'
import { settleInvoicePayment } from '@/lib/invoices/settle-invoice-payment' import { settleInvoicePayment } from '@/lib/invoices/settle-invoice-payment'
import { eventBus } from '@/lib/events' import { eventBus } from '@/lib/events'
@@ -100,6 +101,152 @@ describe('settleInvoicePayment', () => {
expect(vi.mocked(createInvoicePaymentJournalEntry)).not.toHaveBeenCalled() 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 () => { it('rejects overpayment before creating any journal entry', async () => {
const { supabase } = createQueuedMockSupabase() const { supabase } = createQueuedMockSupabase()
const result = await settleInvoicePayment( const result = await settleInvoicePayment(
+56 -5
View File
@@ -61,11 +61,18 @@ export function planInvoicePayment(
const currentRemaining = const currentRemaining =
invoice.remaining_amount ?? invoice.total - (invoice.paid_amount || 0) invoice.remaining_amount ?? invoice.total - (invoice.paid_amount || 0)
// A rounded-up whole-krona payment is not an overpayment: widen the reject // A rounded-up whole-krona payment is not an overpayment: when absorbing öre,
// band to one krona when absorbing öre; otherwise keep the strict half-öre // accept only an overshoot strictly inside the settlement band. The boundary
// float tolerance the three legacy callers rely on. // must be >= : with a strict > guard an exact 1 kr overshoot passed the guard
const overshootTolerance = absorbOre ? ORE_ROUNDING_SETTLEMENT_MAX : PAYMENT_OVERSHOOT_TOLERANCE // AND missed the |diff| < 1 absorb branch, falling through to record
if (paymentAmountInInvoiceCurrency > currentRemaining + overshootTolerance) { // 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 { return {
ok: false, ok: false,
code: 'MATCH_AMOUNT_EXCEEDS_REMAINING', 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