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:
@@ -115,3 +115,38 @@ One line per decision: `[YYYY-MM-DD] <decision>: <why>`. Appended by agents and
|
||||
[2026-07-13] postMessage hardening uses event.source identity (popup handle in a ref) plus a verified rebroadcast CustomEvent ('skatteverket-connection-updated') instead of the nonce the SOC2 finding suggested: a window reference cannot be forged by same-origin scripts, so the source check is strictly stronger than a nonce threaded through the OAuth flow, and pages that never open the popup (salary dashboard) consume the rebroadcast from the component that did verify it.
|
||||
[2026-07-13] Kvittens cron HTTP response drops companyId per row (GDPR minimization) but keeps declarationId: it is an opaque UUID useless without DB access, the endpoint is cron-secret gated + Cache-Control: no-store, and losing it would make per-run ops debugging blind. The extension_data delete race flagged by the swarm is documented-and-accepted: the agi_submission_<period> key is period-scoped by design and agi_declarations is UNIQUE per company+period, so no two declarations share a key.
|
||||
[2026-07-13] signeradAv (personnummer in agi_declarations.response_data) is documented in .compliance/ropa.yaml under the existing agi.submit entry (Art.6(1)(c), BFL 7 kap 2 par retention) rather than moved to a dedicated column with column-level grants: it is part of the SKV kvittens payload preserved verbatim as rakenskapsinformation, and submitted_by is now explicitly documented as the technical submitter with response_data.signeradAv as the authoritative legal signatory.
|
||||
[2026-07-13] One-click AGI submission ("Lamna in till Skatteverket") orchestrates the existing panel endpoints client-side (xml -> submit -> kontrollresultat -> granskningsunderlag) instead of reusing the server-side commitSubmitAgi chain: the MCP commit path carries pending-operation semantics (audit rows, monotonic flips, recoverable codes) that the interactive UI does not want to re-plumb, and the client chain preserves the panel's existing per-step error surfaces. Signing tab is opened synchronously at click (placeholder) and navigated on success to dodge popup blockers.
|
||||
[2026-07-13] Dashboard invoice send route rejects non-drafts (409 INVOICE_ALREADY_SENT) and gates JE/archive/event on winning an optimistic-locked draft->sent flip; on a flip DB error the JE is now DEFERRED (previously posted anyway, and v1 still posts): with the row left in draft, a retry re-runs the pipeline and ends with exactly one verifikat, whereas booking on a failed flip sets up a duplicate JE on retry. Cost: a duplicate customer email on retry, judged cheaper than duplicate revenue.
|
||||
[2026-07-13] Enable Banking OAuth callback streams an interim "Slutfor bankanslutningen" HTML page (shell first, work, then script/meta redirect) instead of redirecting to settings and finalizing via a client-called endpoint: keeps the one-time authorization code server-side in a single round trip, needs no new endpoint or polling, and the global CSP already permits inline script. The blank-tab gap during createSession + cash-account mirroring was the reported "no loading state after redirect".
|
||||
[2026-07-13] Failed bank-connect attempts that never activated (status pending/error, no session_id, no accounts_data) are DELETED, not parked as status=error: parked rows rendered forever as "Atgard kravs" cards next to a successful retry, showing duplicate connections to the same bank. Deletion is safe (transactions/cash_accounts FKs are ON DELETE SET NULL; never-activated rows have no dependents) and the stale-pending cron already deletes such rows. Established connections keep the error/expired card via the accounts_data guard.
|
||||
[2026-07-13] Recurring auto-send sandbox enforcement lives inside sendInvoiceFromSchedule (isSandboxCompany at the email chokepoint, freeze-and-retain: invoice still created as draft) instead of route-level guardSandbox on run-now/create: run-now legitimately generates invoices in the sandbox, only the outbound email is forbidden, and one guard at the chokepoint covers both cron and run-now. Cron failure warnings overwrite (not append to) last_run_warning; the stale roll-forward message wins over the per-attempt failure detail because it carries the actionable state (skipped date + next run + "Skapa faktura nu" hint).
|
||||
[2026-07-13] Payroll gap-closure: personnummer stays MASKED on the v1 payslip detail endpoint (deviation from the "full value on detail" convention): a payslip is a pay document, not an identity record; the employee master GET remains the only full-pnr drill-in. On MCP, personnummer is masked on EVERY tool (LLM context is a leak surface), incl. encrypt-at-staging for create_employee so pending_operations.params never persists plaintext.
|
||||
[2026-07-13] Absence v1 API is range-in/per-day-storage: PUT expands [from,to] server-side (weekends skipped by default, 92-day cap) onto the (employee,date,type) natural key. Per-day rows are non-negotiable (karens/aterinsjuknande/hogriskskydd + AGI Franvarouppgift derive from dates); the range payload is pure ergonomics. PUT added to the v1 wrapper's REQUIRES_IDEMPOTENCY set: without it test keys would write through PUT for real.
|
||||
[2026-07-13] UpdateEmployeeSchema rebuilt on a defaults-stripped base (EmployeeSchemaPatchBase): Zod 4 applies .default() through .partial(), so any sparse PATCH body materialized salary_type='monthly' and failed the byte-till-loneform refinement (latent bug: first surfaced by jamkning-only patches), and routes spreading the parsed body silently reset defaulted columns.
|
||||
[2026-07-13] Scope-map audit retrofit ships accept-the-break (Emil 2026-07-13): 4 previously unmapped write tools (link_invoice_to_voucher, undo_sie_import, post_annual_depreciation, import_rot_rut_beslut) now require their scopes; keys relying on the default-allow hole lose access. No grandfathering migration (unlike gnubok_remember_fact): these were security holes, not granted capabilities. Release-note callout required.
|
||||
[2026-07-13] employee_opening_balances lock is DERIVED (trigger checks for a booked run), not a locked_at flag: cannot drift, needs no hook in the two book routes, and self-unlocks when the only booked run is corrected, which is exactly when re-editing cutover state is legitimate. Ongoing sick cases get NO dedicated fields: imported pre-cutover salary_absence_days rows reconstruct segments exactly; only the karens-period count not covered by imports is a field (over-suppression of karens is the softer error).
|
||||
[2026-07-13] Opening-balances bulk PUT is atomic all-or-nothing (validate every item, 400 with per-item errors and zero writes) rather than 207 partial: byra onboarding wants "all imported or fix the file"; partial success forces the caller to diff. Cutover YTD merges into the engine's ytdByEmployee for display/reports only: verified that AGI is per-run and youth/vaxa avgifter caps are per-month, so no calculation reads YTD.
|
||||
[2026-07-13] runSalaryCalculation opening-balance merge has no dedicated unit test (no existing mock harness for the full orchestrator; building one is ~15 brittle queued queries): covered by type-check + the pure-function karens tests + vacation-liability tests; the pg-real suite and the E2E cutover smoke are the integration net.
|
||||
[2026-07-13] MCP tools/list payload ceiling bumped 45.5K -> 50K for the 8 payroll tools: create/update_employee carry the full employee-config schema by design (agent-driveable onboarding is the point); descriptions trimmed to enum-only where self-evident first.
|
||||
[2026-07-13] Arbetsschema-lite divisors keep the LEGACY constants (173/21) exactly at the default 40h/5d schedule and use the exact 52-week formula only for non-default schedules: switching defaults to exact formulas (173.33/21.67) would shift every running company's monthly-to-hourly derivation ~0.2% and sick/VAB daily deductions ~3% mid-year with zero schedule change. Exact-formula migration deferred to a fiscal-year boundary. Precedence: employment_degree prorates BASE SALARY, hours/workdays per week ONLY drive divisors; deliberately not reconciled.
|
||||
[2026-07-13] Vacation ledger (employee_vacation_balances) is DAYS-ONLY and RECOMPUTED (never incremented) from booked runs on every book/correct, with lazy seeding from cutover opening balances or the legacy vacation_days_saved (attributed to the previous year: expires earlier, never later). SEK stays derived: persisting a parallel SEK column would create a reconciliation obligation with zero new information. Ledger sync is non-fatal in all three hooks: a ledger bug must never block a legally required booking.
|
||||
[2026-07-13] Semesterberedning + arsavslut is ONE two-phase verb (dry-run report, then commit), not two: both act on the same year boundary, the review report is only judgeable with both halves, and two verbs would create half-closed states. Days roll books NOTHING (reclassifying days moves no liability); only the 2920/2940 drift adjustment posts, via createJournalEntry with source_type 'salary_payment' + source_id = closure id (no new source_type: avoids a CHECK expansion). Untaken days at/below the 20-day floor are FLAGGED, not auto-saved (Semesterlagen 18 par.); expired 5-year savings become forced_payout_days paid via a normal run.
|
||||
[2026-07-13] Year-close SEK reconcile uses a day-valued computed liability (simplified BFNAR 2016:10: sammaloneregeln dagslön+tillägg, procentregeln annual basis x 12%/14.4% over entitled days, hourly via hours_per_week) against the trial-balance 2920/2940 closing balances: per-run accruals never relieve 2920 when vacation is taken, so drift accumulates BY DESIGN and the annual adjustment is the correction mechanism. Avgifter on the computed liability use flat 31.42% (per-employee reduced rates called out in the report, not silently applied). vacation_year_closures has NO DELETE policy: the frozen report is the underlag for the adjustment verifikat (BFL 7 kap); reopening is a future explicit feature.
|
||||
[2026-07-13] company_settings.salary_vacation_year_basis (calendar default | statutory_apr_mar) can only change while ZERO open ledger rows exist (settings PUT guard): rows are keyed by vacation_year_start and a basis flip would orphan them. Settings UI deferred; calendar is right for the segment.
|
||||
[2026-07-13] Page-load perf: root cause was Vercel functions in iad1 vs Supabase in eu-north-1 (~100ms per DB/auth round trip, measured 530-1900ms for single-query GETs); fix = "regions": ["arn1"] in vercel.json rather than any code-level caching first. Local JWT verification (getClaims + asymmetric keys) deliberately DEFERRED: the region move collapses getUser() to ~1-3ms, so changing auth semantics (revocation window) is not worth it now.
|
||||
[2026-07-13] VAT declaration aggregation moved into get_vat_declaration_totals RPC (totals + settlement-shape detection + source_type counts in one round trip, SECURITY INVOKER). Account lists stay TS-side parameters (ACCOUNT_RUTA remains the single source of truth; mapping changes must never need a migration). The #984 shape-exclusion unit tests moved to tests/pg/vat-declaration-totals-rpc.pg.test.ts per repo convention (RPC behavior is pg-tested, not mocked).
|
||||
[2026-07-13] Client data caching: chose SWR over TanStack Query (lighter, fetch-shaped hooks fit the codebase; MIT). Converted useCompanySettings + nav badges; pending page realtime stampede fixed with a 400ms trailing debounce instead of a full SWR rewrite of the page.
|
||||
[2026-07-13] next-intl message splitting SKIPPED: ~25 of the main pages are client components, so nearly every namespace is needed client-side; pick()-based splitting would save little and risk MISSING_MESSAGE regressions. Real win would be route-level splitting, deferred until more pages are server components.
|
||||
[2026-07-13] Öresavrundning in mark-paid: absorbing the sub-krona residual is gated on the caller lines actually carrying it on 3740 (planInvoicePaymentForLines); mismatch FALLS BACK to the strict plan instead of rejecting, so deliberate sub-krona partials keep working and no-3740 overshoots keep the pre-change 400. Rejecting outright would have broken the documented v1 partial-payment flow.
|
||||
[2026-07-13] planInvoicePayment absorb-band boundary fixed to >= 1 kr (exactly-1-kr overshoot used to slip past both the guard and the absorb branch and over-record paid_amount). Supplier mirror planSupplierPayment deliberately NOT touched (same hole exists, reachable via match-supplier-invoice; separate change to keep this diff scoped). v1 match-invoice + MCP match paths still reject öre overshoot on bank matches (their line builders lack 3740); also deferred.
|
||||
[2026-07-13] Compliance-report V8.2.1 remediation (403 on cross-company v1 access) REJECTED in favor of the existing 404: withApiV1 already enforces key-user-to-URL-company membership, and 404 deliberately avoids leaking which company ids exist. Pinned with route-level tests on the payslip PDF endpoint instead.
|
||||
[2026-07-13] CSP unsafe-inline (ASVS V3.3): fixed with a per-request nonce CSP on the enable-banking finalize page (mirrors mcp-oauth consent page); removing 'unsafe-inline' from the GLOBAL next.config script-src is deferred, it requires an app-wide middleware nonce pipeline covering the Next.js bootstrap plus recapt/Enable Banking scripts and carries real breakage risk.
|
||||
[2026-07-13] Recurring-invoice sandbox defence-in-depth: cron and run-now routes now resolve isSandboxCompany themselves and pass an explicit suppressAutoSend flag to executeRecurringSchedule, keeping the internal email chokepoint as the second layer. A route-level guardSandbox 403 was rejected: the cron is cross-company (no single company context) and sandbox schedules must still draft invoices (freeze-and-retain).
|
||||
[2026-07-13] pending_operations PII chokepoint (ISO 27001 A.8.11): stagePendingOperation now rejects plaintext personnummer-bearing KEYS in params/preview_data. Key-based, not value-based detection: enskild firma org numbers ARE personnummer, so value-pattern matching would false-positive on legitimate counterparty data.
|
||||
[2026-07-13] Enable-banking callback finalize work decoupled from the response stream (eager promise + next/server after()) so a client disconnect cannot abort session persistence or the consent_granted audit emit. A persistent outbox/dead-letter for audit events was rejected as disproportionate; failed emits now log through the structured logger for alerting (A.8.15).
|
||||
[2026-07-13] Declined CodeRabbit suggestion to rewrite migration 20260713100000 (pending_operations CHECK) as NOT VALID + VALIDATE: the migration is already committed (2084a756) and applied to staging, and modifying shipped migrations is forbidden; pending_operations is small enough that the brief lock is a non-issue.
|
||||
[2026-07-13] Vacation year close: 2940 target now uses per-employee age-tier avgifter (0% born <=1937, 10.21% fyllt 67 vid årets ingång at the settlement year, else 31.42%) instead of flat 31.42% (compliance-review finding): per-run accruals already credit 2940 at each employee's actual rate, so a flat target would "correct" a right booked balance to a wrong one for companies with 67+ staff. The temporary youth discount is deliberately NOT provisioned (payment-month- and cap-dependent, expires Sep 2027; the full rate is the prudent target per ÅRL försiktighetsprincipen), so youth accruals show a top-up drift at close.
|
||||
[2026-07-13] calculateAgeAtYearStart is now birth-year based ((year - 1) - birth year) instead of birthday-inclusive age at Jan 1: Skatteverket applies "vid årets ingång fyllt X" rules as birth-year ranges (2026 youth cohort = born 2003-2007), and the old semantics misclassified employees born exactly on January 1 in both directions (born 2008-01-01 wrongly youth-rated, which AGI validation rejects; born 2003-01-01 wrongly standard-rated; born 1959-01-01 wrongly given the 67+ reduction a year early).
|
||||
[2026-07-13] employee_opening_balances created_by preserved via read-then-upsert, not a DB trigger: a BEFORE UPDATE trigger would need a new migration for a pure audit concern; the extra select is one indexed query and the lock trigger already backstops races.
|
||||
[2026-07-13] Opening balances are authoritative for pre-cutover YTD: runSalaryCalculation now excludes booked runs before the cutover month from the YTD aggregation for employees with opening balances, instead of blocking pre-cutover backdated runs (backfill of history is a supported flow).
|
||||
[2026-07-13] Superseded the 2026-07-13 decline of the NOT VALID suggestion for migration 20260713100000: Emil asked to resolve the PR findings, and the migration is branch-only (verified absent from prod schema_migrations), so the never-modify-shipped-migrations rule does not apply; staging already recorded the versions, so edits only change what prod runs at merge. Implemented as ADD ... NOT VALID in 20260713100000 + 20260713121000 with VALIDATE split into 20260713123000: VALIDATE in the same transaction as ADD would be a no-op since Postgres holds the ACCESS EXCLUSIVE lock until commit; a separate migration file gets its own transaction and validates under SHARE UPDATE EXCLUSIVE. 20260713123000 applied to staging (no-op VALIDATE) and version recorded.
|
||||
|
||||
@@ -7,7 +7,7 @@ import CompanyTabSync from '@/components/dashboard/CompanyTabSync'
|
||||
import { RecaptIdentify } from '@/components/RecaptIdentify'
|
||||
import { AgentSheetProvider } from '@/components/agent/AgentSheetProvider'
|
||||
import AgentTrigger from '@/components/agent/AgentTrigger'
|
||||
import CommandPalette from '@/components/common/CommandPalette'
|
||||
import LazyCommandPalette from '@/components/common/LazyCommandPalette'
|
||||
import { SettingsHotkey } from '@/components/settings/SettingsHotkey'
|
||||
import { SandboxBanner } from '@/components/dashboard/SandboxBanner'
|
||||
import { getExtensionNavItems } from '@/lib/extensions/sectors'
|
||||
@@ -16,7 +16,6 @@ import { getActiveCompanyId } from '@/lib/company/context'
|
||||
import { getCompanyEntitlements } from '@/lib/entitlements/has-capability'
|
||||
import { getBranding } from '@/lib/branding/service'
|
||||
import { ensureSandboxAgentProfile } from '@/lib/sandbox/ensure-agent'
|
||||
import { countPendingOperations, countUnbookedTransactions } from '@/lib/worklist'
|
||||
import type { EntityType, CompanyRole, Team } from '@/types'
|
||||
|
||||
/**
|
||||
@@ -99,8 +98,6 @@ export default async function DashboardLayout({
|
||||
<DashboardNav
|
||||
companyName={getBranding().appName.toLowerCase()}
|
||||
entityType="enskild_firma"
|
||||
uncategorizedTransactionCount={0}
|
||||
pendingOperationsCount={0}
|
||||
isSandbox={false}
|
||||
extensionNavItems={getExtensionNavItems()}
|
||||
/>
|
||||
@@ -131,8 +128,6 @@ export default async function DashboardLayout({
|
||||
{ data: memberRow },
|
||||
{ data: allMemberships },
|
||||
{ data: settings },
|
||||
uncategorizedCount,
|
||||
pendingOpsCount,
|
||||
{ data: agentProfileIdentity },
|
||||
{ data: userProfile },
|
||||
entitlements,
|
||||
@@ -146,11 +141,10 @@ export default async function DashboardLayout({
|
||||
.select('company_name, onboarding_complete, entity_type, pays_salaries, is_sandbox, dimensions_enabled')
|
||||
.eq('company_id', companyId)
|
||||
.single(),
|
||||
// Shared worklist predicates (lib/worklist), the badge must show the
|
||||
// same number as every other "att göra" surface. Notably this excludes
|
||||
// is_ignored rows, which the old inline query here did not.
|
||||
countUnbookedTransactions(supabase, companyId),
|
||||
countPendingOperations(supabase, companyId),
|
||||
// Nav badge counts (unbooked transactions, pending operations) are NOT
|
||||
// fetched here anymore: DashboardNav loads them client-side after mount
|
||||
// (lib/hooks/use-worklist-badges) so two head-count queries stop blocking
|
||||
// first paint on every dashboard navigation.
|
||||
// Agent identity, name + avatar, surfaced on the FAB and chat
|
||||
// surfaces. Null when no agent_profile exists yet (banner CTA path).
|
||||
supabase
|
||||
@@ -203,8 +197,6 @@ export default async function DashboardLayout({
|
||||
<DashboardNav
|
||||
companyName={getBranding().appName.toLowerCase()}
|
||||
entityType="enskild_firma"
|
||||
uncategorizedTransactionCount={0}
|
||||
pendingOperationsCount={0}
|
||||
isSandbox={false}
|
||||
extensionNavItems={getExtensionNavItems()}
|
||||
/>
|
||||
@@ -311,8 +303,6 @@ export default async function DashboardLayout({
|
||||
entityType={entityType}
|
||||
paysSalaries={paysSalaries}
|
||||
dimensionsEnabled={dimensionsEnabled}
|
||||
uncategorizedTransactionCount={uncategorizedCount}
|
||||
pendingOperationsCount={pendingOpsCount}
|
||||
isSandbox={isSandbox}
|
||||
extensionNavItems={getExtensionNavItems()}
|
||||
userName={userProfile?.full_name ?? null}
|
||||
@@ -322,7 +312,7 @@ export default async function DashboardLayout({
|
||||
<MainContainer companyId={companyId}>{children}</MainContainer>
|
||||
</main>
|
||||
<AgentTrigger />
|
||||
<CommandPalette />
|
||||
<LazyCommandPalette />
|
||||
<SettingsHotkey />
|
||||
{settingsModal}
|
||||
</div>
|
||||
|
||||
@@ -714,20 +714,28 @@ export default function PendingOperationsPage() {
|
||||
// in-place) so server-side filtering, sorting, and computed fields stay in
|
||||
// sync with whatever the API route returned. The counts endpoint isn't
|
||||
// pushed by the same trigger, so we also refresh counts on every change.
|
||||
// Trailing debounce: bulk actions emit one event per row, which previously
|
||||
// stampeded 4 requests per event (list + 3 counts); the burst now collapses
|
||||
// into a single refetch after the last event.
|
||||
useEffect(() => {
|
||||
const supabase = createClient()
|
||||
let debounce: ReturnType<typeof setTimeout> | null = null
|
||||
const channel = supabase
|
||||
.channel('pending_operations:list')
|
||||
.on(
|
||||
'postgres_changes',
|
||||
{ event: '*', schema: 'public', table: 'pending_operations' },
|
||||
() => {
|
||||
fetchOperations()
|
||||
fetchAllCounts()
|
||||
if (debounce) clearTimeout(debounce)
|
||||
debounce = setTimeout(() => {
|
||||
fetchOperations()
|
||||
fetchAllCounts()
|
||||
}, 400)
|
||||
}
|
||||
)
|
||||
.subscribe()
|
||||
return () => {
|
||||
if (debounce) clearTimeout(debounce)
|
||||
void supabase.removeChannel(channel)
|
||||
}
|
||||
}, [fetchOperations, fetchAllCounts])
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
} from '@/lib/salary/payment/bank-account'
|
||||
import type { Employee } from '@/types'
|
||||
import { EmployeeBenefitsPanel } from '@/components/salary/EmployeeBenefitsPanel'
|
||||
import { OpeningBalancesPanel } from '@/components/salary/OpeningBalancesPanel'
|
||||
import EmployeeTaxCard, { type EmployeeTaxValue } from '@/components/salary/EmployeeTaxCard'
|
||||
import LineDimensionFields from '@/components/dimensions/LineDimensionFields'
|
||||
|
||||
@@ -124,12 +125,18 @@ export default function EmployeeDetailPage({ params }: { params: Promise<{ id: s
|
||||
employment_type: employmentType,
|
||||
employment_start: form.get('employment_start') as string || undefined,
|
||||
employment_end: form.get('employment_end') as string || undefined,
|
||||
employment_degree: parseFloat(form.get('employment_degree') as string) || 100,
|
||||
// Sparse patch: an empty/cleared field is OMITTED (undefined keys are
|
||||
// dropped by JSON.stringify) so the server's patch schema leaves the
|
||||
// column unchanged. Hardcoded fallbacks here would silently reset real
|
||||
// DB values on submit.
|
||||
employment_degree: parseFloat(form.get('employment_degree') as string) || undefined,
|
||||
hours_per_week: parseFloat(form.get('hours_per_week') as string) || undefined,
|
||||
workdays_per_week: parseFloat(form.get('workdays_per_week') as string) || undefined,
|
||||
salary_type: salaryType,
|
||||
f_skatt_status: tax?.f_skatt_status,
|
||||
is_sidoinkomst: tax?.is_sidoinkomst,
|
||||
tax_table_number: tax?.tax_table_number ?? undefined,
|
||||
tax_column: tax?.tax_column ?? 1,
|
||||
tax_column: tax?.tax_column ?? undefined,
|
||||
tax_municipality: tax?.tax_municipality || undefined,
|
||||
email: form.get('email') as string || undefined,
|
||||
phone: form.get('phone') as string || undefined,
|
||||
@@ -139,7 +146,7 @@ export default function EmployeeDetailPage({ params }: { params: Promise<{ id: s
|
||||
clearing_number: normalizeBankNumber(clearing) || undefined,
|
||||
bank_account_number: normalizeBankNumber(account) || undefined,
|
||||
vacation_rule: vacationRule,
|
||||
vacation_days_per_year: parseInt(form.get('vacation_days_per_year') as string) || 25,
|
||||
vacation_days_per_year: parseInt(form.get('vacation_days_per_year') as string) || undefined,
|
||||
// Always sent: {} clears the employee's default dimensions.
|
||||
default_dimensions: dimensions,
|
||||
}
|
||||
@@ -291,6 +298,15 @@ export default function EmployeeDetailPage({ params }: { params: Promise<{ id: s
|
||||
<Label htmlFor="employment_degree">{t('form_employment_degree')}</Label>
|
||||
<Input id="employment_degree" name="employment_degree" type="number" defaultValue={employee.employment_degree} min="1" max="100" disabled={!canWrite} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="hours_per_week">{t('form_hours_per_week')}</Label>
|
||||
<Input id="hours_per_week" name="hours_per_week" type="number" defaultValue={employee.hours_per_week ?? 40} min="1" max="80" step="0.5" disabled={!canWrite} />
|
||||
<p className="text-xs text-muted-foreground">{t('form_work_schedule_hint')}</p>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="workdays_per_week">{t('form_workdays_per_week')}</Label>
|
||||
<Input id="workdays_per_week" name="workdays_per_week" type="number" defaultValue={employee.workdays_per_week ?? 5} min="1" max="7" step="1" disabled={!canWrite} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="salary_type">{t('form_salary_type')}<RequiredMark /></Label>
|
||||
<Select value={salaryType} onValueChange={setSalaryType} disabled={!canWrite}>
|
||||
@@ -453,6 +469,9 @@ export default function EmployeeDetailPage({ params }: { params: Promise<{ id: s
|
||||
{/* Benefits */}
|
||||
<EmployeeBenefitsPanel employeeId={id} canWrite={canWrite} />
|
||||
|
||||
{/* Ingående saldon (payroll cutover) */}
|
||||
<OpeningBalancesPanel employeeId={id} canWrite={canWrite} />
|
||||
|
||||
{canWrite && (
|
||||
<div className="flex justify-end gap-3">
|
||||
<Button variant="outline" asChild>
|
||||
|
||||
@@ -15,6 +15,9 @@ import { ArrowRight, CalendarClock, CheckCircle2, HandCoins, Loader2, Plus, User
|
||||
import { PageHeader } from '@/components/ui/page-header'
|
||||
import { useToast } from '@/components/ui/use-toast'
|
||||
import { useCanWrite } from '@/lib/hooks/use-can-write'
|
||||
import { VacationBalanceCard } from '@/components/salary/VacationBalanceCard'
|
||||
import { useAgiSubmission } from '@/lib/hooks/use-agi-submission'
|
||||
import { deriveAgiFilingState } from '@/lib/salary/agi-submission-state'
|
||||
import { useCompany } from '@/contexts/CompanyContext'
|
||||
import { getErrorMessage } from '@/lib/errors/get-error-message'
|
||||
import { formatCurrency, formatDate } from '@/lib/utils'
|
||||
@@ -65,50 +68,52 @@ export default function SalaryPage() {
|
||||
const tp = useTranslations('salary_payments')
|
||||
|
||||
const load = useCallback(async () => {
|
||||
const [runsRes, empRes, settingsRes] = await Promise.all([
|
||||
fetch('/api/salary/runs'),
|
||||
fetch('/api/salary/employees'),
|
||||
fetch('/api/settings'),
|
||||
// Everything loads in parallel; the tax-payment fetch is the only
|
||||
// dependent request and chains directly off the runs response instead of
|
||||
// waiting for the whole batch. Was three sequential legs (batch → tax
|
||||
// payment → SKV status), now the longest chain is runs → tax payment.
|
||||
const runsPromise: Promise<SalaryRun[]> = fetch('/api/salary/runs')
|
||||
.then(async res => (res.ok ? (await res.json()).data || [] : []))
|
||||
.catch(() => [])
|
||||
|
||||
// Latest booked run drives the "skatt att betala" card. Resolves to
|
||||
// undefined ("leave state unchanged") when there is no booked run or the
|
||||
// fetch fails, mirroring the old sequential behavior on reloads.
|
||||
const taxPaymentPromise: Promise<TaxPaymentState | null | undefined> = runsPromise
|
||||
.then(async loadedRuns => {
|
||||
const latestBooked = loadedRuns.find(r => r.status === 'booked')
|
||||
if (!latestBooked) return undefined
|
||||
const period = `${latestBooked.period_year}-${String(latestBooked.period_month).padStart(2, '0')}`
|
||||
const txRes = await fetch(`/api/skatteverket/tax-payments/${period}`)
|
||||
if (!txRes.ok) return undefined
|
||||
return (await txRes.json()).data ?? null
|
||||
})
|
||||
.catch(() => undefined)
|
||||
|
||||
const [loadedRuns, taxPaymentData, empRes, settingsRes, skvStatus] = await Promise.all([
|
||||
runsPromise,
|
||||
taxPaymentPromise,
|
||||
fetch('/api/salary/employees').catch(() => null),
|
||||
fetch('/api/settings').catch(() => null),
|
||||
// Connection health for the tax card hint. Only needs_reconsent counts:
|
||||
// the routine short-lived token expiry is normal and must not nag. Any
|
||||
// failure (extension disabled → 503, network) silently means no hint.
|
||||
fetch('/api/extensions/ext/skatteverket/status')
|
||||
.then(res => (res.ok ? res.json() : null))
|
||||
.catch(() => null),
|
||||
])
|
||||
|
||||
let loadedRuns: SalaryRun[] = []
|
||||
if (runsRes.ok) {
|
||||
const { data } = await runsRes.json()
|
||||
loadedRuns = data || []
|
||||
setRuns(loadedRuns)
|
||||
}
|
||||
if (empRes.ok) {
|
||||
setRuns(loadedRuns)
|
||||
if (taxPaymentData !== undefined) setTaxPayment(taxPaymentData)
|
||||
if (empRes?.ok) {
|
||||
const { data } = await empRes.json()
|
||||
setEmployees(data || [])
|
||||
}
|
||||
if (settingsRes.ok) {
|
||||
if (settingsRes?.ok) {
|
||||
const { data } = await settingsRes.json()
|
||||
if (typeof data?.salary_pay_day === 'number') setPayDay(data.salary_pay_day)
|
||||
}
|
||||
|
||||
// Latest booked run drives the "skatt att betala" card.
|
||||
const latestBooked = loadedRuns.find(r => r.status === 'booked')
|
||||
if (latestBooked) {
|
||||
const period = `${latestBooked.period_year}-${String(latestBooked.period_month).padStart(2, '0')}`
|
||||
const txRes = await fetch(`/api/skatteverket/tax-payments/${period}`)
|
||||
if (txRes.ok) {
|
||||
const tx = await txRes.json()
|
||||
setTaxPayment(tx.data)
|
||||
}
|
||||
}
|
||||
|
||||
// Connection health for the tax card hint. Only needs_reconsent counts:
|
||||
// the routine short-lived token expiry is normal and must not nag. Any
|
||||
// failure (extension disabled → 503, network) silently means no hint.
|
||||
try {
|
||||
const statusRes = await fetch('/api/extensions/ext/skatteverket/status')
|
||||
if (statusRes.ok) {
|
||||
const status = await statusRes.json()
|
||||
setSkvNeedsReconsent(status?.needsReconsent === true)
|
||||
}
|
||||
} catch {
|
||||
// Extension unavailable: no hint.
|
||||
}
|
||||
if (skvStatus) setSkvNeedsReconsent(skvStatus.needsReconsent === true)
|
||||
|
||||
setLoading(false)
|
||||
}, [])
|
||||
@@ -151,6 +156,16 @@ export default function SalaryPage() {
|
||||
.then(({ data }) => setAgiDeadline(data ?? null))
|
||||
}, [company])
|
||||
|
||||
// The active run's AGI submission record: lets the hero distinguish
|
||||
// "lämna in till Skatteverket" from "väntar på din BankID-signatur".
|
||||
// Only fetched while a booked run is still unfiled; null otherwise.
|
||||
const activeRun = runs.find(r => r.status !== 'corrected')
|
||||
const { submission: agiSubmission } = useAgiSubmission(
|
||||
activeRun && activeRun.status === 'booked' && !activeRun.agi_submitted_at
|
||||
? `${activeRun.period_year}${String(activeRun.period_month).padStart(2, '0')}`
|
||||
: null,
|
||||
)
|
||||
|
||||
// One-click run creation: the API seeds all active employees, calculates,
|
||||
// and resolves period/pay-date/series defaults from settings.
|
||||
async function startRun() {
|
||||
@@ -244,7 +259,8 @@ export default function SalaryPage() {
|
||||
}
|
||||
|
||||
// ── Hero state machine (first match wins) ────────────────────────────────
|
||||
const activeRun = runs.find(r => r.status !== 'corrected')
|
||||
// activeRun is derived above the loading return (the AGI submission hook
|
||||
// needs it before any early return).
|
||||
const latestBooked = runs.find(r => r.status === 'booked')
|
||||
const periodOf = (r: SalaryRun) => `${r.period_year}-${String(r.period_month).padStart(2, '0')}`
|
||||
|
||||
@@ -315,6 +331,19 @@ export default function SalaryPage() {
|
||||
}
|
||||
}
|
||||
if (activeRun && activeRun.status === 'booked' && !activeRun.agi_submitted_at) {
|
||||
// The underlag may already be at Skatteverket waiting for a BankID
|
||||
// signature: telling the user to "lämna in" something they already
|
||||
// submitted reads as a broken flow. Follow the real filing state.
|
||||
const agiState = deriveAgiFilingState(activeRun, agiSubmission)
|
||||
if (agiState === 'awaiting_signing' || agiState === 'underlag_submitted') {
|
||||
return {
|
||||
kind: 'cta',
|
||||
title: t('hero_agi_signing_title', { period: periodOf(activeRun) }),
|
||||
description: t('hero_agi_signing_description'),
|
||||
label: t('hero_agi_signing_action'),
|
||||
runId: activeRun.id,
|
||||
}
|
||||
}
|
||||
return {
|
||||
kind: 'cta',
|
||||
title: t('hero_agi_title', { period: periodOf(activeRun) }),
|
||||
@@ -409,7 +438,7 @@ export default function SalaryPage() {
|
||||
)}
|
||||
|
||||
{/* Attention cards */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
@@ -504,6 +533,9 @@ export default function SalaryPage() {
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Semester (vacation ledger + year close): payroll gap-closure 3.5 */}
|
||||
<VacationBalanceCard canWrite={canWrite} />
|
||||
</div>
|
||||
|
||||
{/* History */}
|
||||
|
||||
@@ -17,6 +17,8 @@ import {
|
||||
import { AlertTriangle, Download, Loader2 } from 'lucide-react'
|
||||
import { useToast } from '@/components/ui/use-toast'
|
||||
import { useCanWrite } from '@/lib/hooks/use-can-write'
|
||||
import { useAgiSubmission } from '@/lib/hooks/use-agi-submission'
|
||||
import { deriveAgiFilingState } from '@/lib/salary/agi-submission-state'
|
||||
import { getErrorMessage } from '@/lib/errors/get-error-message'
|
||||
import { AGIPanel } from '@/components/salary/AGIPanel'
|
||||
import { PaymentFilePanel } from '@/components/salary/PaymentFilePanel'
|
||||
@@ -55,6 +57,16 @@ export default function SalaryRunPage({ params }: { params: Promise<{ id: string
|
||||
tax_paid_at: string | null
|
||||
} | null>(null)
|
||||
|
||||
// Skatteverket's per-period AGI submission record: drives the AGI step on
|
||||
// the progress rail and the panel's state machine (underlag submitted /
|
||||
// awaiting BankID signature / signed). Only booked runs can file AGI, so
|
||||
// the fetch is skipped (null period) for everything else.
|
||||
const { submission: agiSubmission, refresh: refreshAgiSubmission } = useAgiSubmission(
|
||||
run && run.status === 'booked'
|
||||
? `${run.period_year}${String(run.period_month).padStart(2, '0')}`
|
||||
: null,
|
||||
)
|
||||
|
||||
async function loadRun() {
|
||||
const res = await fetch(`/api/salary/runs/${id}`)
|
||||
if (res.ok) {
|
||||
@@ -494,6 +506,10 @@ export default function SalaryRunPage({ params }: { params: Promise<{ id: string
|
||||
// hidden - mirrors the pain.001 / BG-LB generators, which emit no rows here.
|
||||
const noPayout = isCalculated && Math.round((run.total_net ?? 0) * 100) === 0
|
||||
|
||||
// Real AGI filing state: run-row timestamps + the extension's submission
|
||||
// record. Falls back gracefully when the extension is unavailable.
|
||||
const agiState = deriveAgiFilingState(run, agiSubmission)
|
||||
|
||||
// Advancing a draft to review. For a nollkörning confirm first: an empty
|
||||
// declaration is filed to Skatteverket, which should be deliberate.
|
||||
function handleToReview() {
|
||||
@@ -536,6 +552,8 @@ export default function SalaryRunPage({ params }: { params: Promise<{ id: string
|
||||
run={run}
|
||||
isCalculated={isCalculated}
|
||||
noPayout={noPayout}
|
||||
agiState={agiState}
|
||||
agiKvittensnummer={agiSubmission?.kvittensnummer ?? null}
|
||||
canWrite={canWrite}
|
||||
actionLoading={actionLoading}
|
||||
primaryAction={primaryAction}
|
||||
@@ -623,6 +641,8 @@ export default function SalaryRunPage({ params }: { params: Promise<{ id: string
|
||||
period={`${run.period_year}${String(run.period_month).padStart(2, '0')}`}
|
||||
agiGeneratedAt={run.agi_generated_at}
|
||||
agiSubmittedAt={run.agi_submitted_at}
|
||||
submission={agiSubmission}
|
||||
onRefreshSubmission={refreshAgiSubmission}
|
||||
readOnly={!canWrite}
|
||||
onChange={loadRun}
|
||||
/>
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
import { Suspense } from 'react'
|
||||
import { BankingSettingsContent } from '@/components/settings/sections/BankingSettingsContent'
|
||||
|
||||
// BankingSettingsContent (and the extension panel it hosts) reads
|
||||
// useSearchParams for the OAuth-callback ?select_accounts param; without a
|
||||
// Suspense boundary that de-opts the whole route to client rendering.
|
||||
export default function BankingSettingsPage() {
|
||||
return <BankingSettingsContent />
|
||||
return (
|
||||
<Suspense>
|
||||
<BankingSettingsContent />
|
||||
</Suspense>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -50,7 +50,7 @@ function makeRequest(params: Record<string, string>) {
|
||||
|
||||
function mockChain(result: { data?: unknown; error?: unknown }) {
|
||||
const chain: Record<string, unknown> = {}
|
||||
for (const m of ['select', 'eq', 'in', 'single', 'update', 'order', 'limit']) {
|
||||
for (const m of ['select', 'eq', 'in', 'is', 'single', 'update', 'delete', 'order', 'limit']) {
|
||||
chain[m] = vi.fn().mockReturnValue(chain)
|
||||
}
|
||||
chain.single = vi.fn().mockResolvedValue({ data: result.data ?? null, error: result.error ?? null })
|
||||
@@ -98,14 +98,17 @@ describe('GET /api/extensions/enable-banking/callback', () => {
|
||||
expect(location).toContain('bank_error=invalid_state')
|
||||
})
|
||||
|
||||
it('writes pending_selection and redirects to picker on success', async () => {
|
||||
it('writes pending_selection and streams a finalizing page that redirects to the picker', async () => {
|
||||
const capturedUpdates: Record<string, unknown>[] = []
|
||||
let callIndex = 0
|
||||
mockFrom.mockImplementation(() => {
|
||||
callIndex++
|
||||
if (callIndex === 1) {
|
||||
// Find pending connection by oauth_state
|
||||
return mockChain({ data: { id: 'conn-1', user_id: 'user-1', company_id: 'company-1' }, error: null })
|
||||
return mockChain({
|
||||
data: { id: 'conn-1', user_id: 'user-1', company_id: 'company-1', bank_name: 'TestBank', status: 'pending' },
|
||||
error: null,
|
||||
})
|
||||
}
|
||||
// Update connection: capture the payload, then chain returns the
|
||||
// updated row via .select().single() for the audit event emission.
|
||||
@@ -143,11 +146,27 @@ describe('GET /api/extensions/enable-banking/callback', () => {
|
||||
|
||||
const response = await GET(makeRequest({ code: 'auth-code', state: 'valid-state' }))
|
||||
|
||||
expect(response.status).toBe(307)
|
||||
const location = response.headers.get('location') || ''
|
||||
expect(location).toContain('/settings/banking?')
|
||||
expect(location).toContain('select_accounts=conn-1')
|
||||
expect(location).not.toContain('bank_connected=true')
|
||||
// Success streams an interim page (instant feedback during the session
|
||||
// exchange) that ends with a client-side redirect to the account picker.
|
||||
expect(response.status).toBe(200)
|
||||
expect(response.headers.get('content-type')).toContain('text/html')
|
||||
expect(response.headers.get('cache-control')).toBe('no-store')
|
||||
const body = await response.text()
|
||||
// Shell flushed with the bank name, then the redirect to the picker.
|
||||
expect(body).toContain('TestBank')
|
||||
expect(body).toContain('window.location.replace')
|
||||
expect(body).toContain('select_accounts=conn-1')
|
||||
expect(body).not.toContain('bank_error')
|
||||
|
||||
// ASVS V3.3: inline scripts are nonce-bound. The response-level CSP
|
||||
// declares the nonce and BOTH chunks (shell watchdog + redirect) carry
|
||||
// it; no un-nonced inline script may exist on this page.
|
||||
const csp = response.headers.get('content-security-policy') ?? ''
|
||||
const nonceMatch = /script-src 'nonce-([^']+)'/.exec(csp)
|
||||
expect(nonceMatch).not.toBeNull()
|
||||
const nonce = nonceMatch![1]
|
||||
expect(body.split(`<script nonce="${nonce}">`).length - 1).toBe(2)
|
||||
expect(body).not.toContain('<script>')
|
||||
|
||||
// Verify the update payload: status=pending_selection, no last_synced_at,
|
||||
// and every account defaults to enabled=true so the picker can simply
|
||||
@@ -185,7 +204,10 @@ describe('GET /api/extensions/enable-banking/callback', () => {
|
||||
mockFrom.mockImplementation((table: string) => {
|
||||
callIndex++
|
||||
if (callIndex === 1) {
|
||||
return mockChain({ data: { id: 'conn-1', user_id: 'user-1', company_id: 'company-1' }, error: null })
|
||||
return mockChain({
|
||||
data: { id: 'conn-1', user_id: 'user-1', company_id: 'company-1', bank_name: 'TestBank', status: 'expired' },
|
||||
error: null,
|
||||
})
|
||||
}
|
||||
if (table === 'cash_accounts') {
|
||||
// Already mirrored on a previous connect — acc-1 was remapped to 1935
|
||||
@@ -218,7 +240,9 @@ describe('GET /api/extensions/enable-banking/callback', () => {
|
||||
|
||||
const response = await GET(makeRequest({ code: 'auth-code', state: 'valid-state' }))
|
||||
|
||||
expect(response.status).toBe(307)
|
||||
expect(response.status).toBe(200)
|
||||
const body = await response.text()
|
||||
expect(body).toContain('select_accounts=conn-1')
|
||||
// No allocation for an already-mirrored account; the upsert reuses 1935.
|
||||
expect(mockAllocate).not.toHaveBeenCalled()
|
||||
expect(mockUpsertFromPsd2).toHaveBeenCalledTimes(1)
|
||||
@@ -227,6 +251,77 @@ describe('GET /api/extensions/enable-banking/callback', () => {
|
||||
).toBe('1935')
|
||||
})
|
||||
|
||||
it('deletes the fresh row and streams an error redirect when the session exchange fails', async () => {
|
||||
const deleteCalls: unknown[] = []
|
||||
const updateCalls: unknown[] = []
|
||||
mockFrom.mockImplementation(() => {
|
||||
const chain = mockChain({
|
||||
data: { id: 'conn-1', user_id: 'user-1', company_id: 'company-1', bank_name: 'TestBank', status: 'pending' },
|
||||
error: null,
|
||||
})
|
||||
chain.delete = vi.fn(() => {
|
||||
deleteCalls.push('delete')
|
||||
return chain
|
||||
})
|
||||
chain.update = vi.fn((payload: unknown) => {
|
||||
updateCalls.push(payload)
|
||||
return chain
|
||||
})
|
||||
return chain
|
||||
})
|
||||
|
||||
mockCreateSession.mockRejectedValue(new Error('upstream timeout'))
|
||||
|
||||
const response = await GET(makeRequest({ code: 'auth-code', state: 'valid-state' }))
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
const body = await response.text()
|
||||
// The streamed redirect carries the failure to the settings banner.
|
||||
expect(body).toContain('window.location.replace')
|
||||
expect(body).toContain('bank_error=')
|
||||
expect(body).not.toContain('select_accounts=')
|
||||
|
||||
// A never-activated attempt is deleted, not parked as a zombie 'error'
|
||||
// row that would render next to a successful retry as a duplicate.
|
||||
expect(deleteCalls).toHaveLength(1)
|
||||
expect(updateCalls).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('marks a reconnect row as error (not deleted) when the session exchange fails', async () => {
|
||||
const deleteCalls: unknown[] = []
|
||||
const updateCalls: Record<string, unknown>[] = []
|
||||
mockFrom.mockImplementation(() => {
|
||||
const chain = mockChain({
|
||||
data: { id: 'conn-1', user_id: 'user-1', company_id: 'company-1', bank_name: 'TestBank', status: 'expired' },
|
||||
error: null,
|
||||
})
|
||||
chain.delete = vi.fn(() => {
|
||||
deleteCalls.push('delete')
|
||||
return chain
|
||||
})
|
||||
chain.update = vi.fn((payload: Record<string, unknown>) => {
|
||||
updateCalls.push(payload)
|
||||
return chain
|
||||
})
|
||||
return chain
|
||||
})
|
||||
|
||||
mockCreateSession.mockRejectedValue(new Error('upstream timeout'))
|
||||
|
||||
const response = await GET(makeRequest({ code: 'auth-code', state: 'valid-state' }))
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
const body = await response.text()
|
||||
expect(body).toContain('bank_error=')
|
||||
|
||||
// An established connection keeps its row (history, accounts) and gets
|
||||
// the error surfaced on it instead.
|
||||
expect(deleteCalls).toHaveLength(0)
|
||||
expect(updateCalls).toHaveLength(1)
|
||||
expect(updateCalls[0].status).toBe('error')
|
||||
expect(updateCalls[0].oauth_state).toBeNull()
|
||||
})
|
||||
|
||||
it('redirects with error when bank returns error param (no state)', async () => {
|
||||
const response = await GET(makeRequest({ error: 'access_denied', error_description: 'User cancelled' }))
|
||||
|
||||
@@ -257,10 +352,75 @@ describe('GET /api/extensions/enable-banking/callback', () => {
|
||||
expect(mockFrom).toHaveBeenCalledWith('bank_connections')
|
||||
})
|
||||
|
||||
it('deletes a fresh pending row on bank denial instead of parking it in error', async () => {
|
||||
const deleteCalls: unknown[] = []
|
||||
const updateCalls: unknown[] = []
|
||||
mockFrom.mockImplementation(() => {
|
||||
const chain = mockChain({
|
||||
data: { id: 'conn-1', user_id: 'user-1', bank_name: 'TestBank', psu_type: 'business', status: 'pending' },
|
||||
error: null,
|
||||
})
|
||||
chain.delete = vi.fn(() => {
|
||||
deleteCalls.push('delete')
|
||||
return chain
|
||||
})
|
||||
chain.update = vi.fn((payload: unknown) => {
|
||||
updateCalls.push(payload)
|
||||
return chain
|
||||
})
|
||||
return chain
|
||||
})
|
||||
|
||||
const response = await GET(makeRequest({
|
||||
error: 'access_denied',
|
||||
error_description: 'User cancelled',
|
||||
state: 'pending-state',
|
||||
}))
|
||||
|
||||
expect(response.status).toBe(307)
|
||||
const location = response.headers.get('location') || ''
|
||||
// URLSearchParams encodes spaces as '+', unlike the encodeURIComponent
|
||||
// fallback used when no matching row exists.
|
||||
expect(location).toContain('bank_error=User+cancelled')
|
||||
expect(deleteCalls).toHaveLength(1)
|
||||
expect(updateCalls).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('keeps a reconnect row on bank denial and marks it expired on session-expiry errors', async () => {
|
||||
const deleteCalls: unknown[] = []
|
||||
const updateCalls: Record<string, unknown>[] = []
|
||||
mockFrom.mockImplementation(() => {
|
||||
const chain = mockChain({
|
||||
data: { id: 'conn-1', user_id: 'user-1', bank_name: 'TestBank', psu_type: 'business', status: 'expired' },
|
||||
error: null,
|
||||
})
|
||||
chain.delete = vi.fn(() => {
|
||||
deleteCalls.push('delete')
|
||||
return chain
|
||||
})
|
||||
chain.update = vi.fn((payload: Record<string, unknown>) => {
|
||||
updateCalls.push(payload)
|
||||
return chain
|
||||
})
|
||||
return chain
|
||||
})
|
||||
|
||||
const response = await GET(makeRequest({
|
||||
error: 'server_error',
|
||||
error_description: 'Session expired at ASPSP',
|
||||
state: 'pending-state',
|
||||
}))
|
||||
|
||||
expect(response.status).toBe(307)
|
||||
expect(deleteCalls).toHaveLength(0)
|
||||
expect(updateCalls).toHaveLength(1)
|
||||
expect(updateCalls[0].status).toBe('expired')
|
||||
})
|
||||
|
||||
it('forwards bank_error_code and psu_type when the denied state matches a pending connection', async () => {
|
||||
mockFrom.mockImplementation(() =>
|
||||
mockChain({
|
||||
data: { id: 'conn-1', user_id: 'user-1', bank_name: 'Handelsbanken', psu_type: 'business' },
|
||||
data: { id: 'conn-1', user_id: 'user-1', bank_name: 'Handelsbanken', psu_type: 'business', status: 'pending' },
|
||||
error: null,
|
||||
})
|
||||
)
|
||||
|
||||
@@ -0,0 +1,185 @@
|
||||
/**
|
||||
* Interim "finalizing" page for the Enable Banking OAuth callback.
|
||||
*
|
||||
* The callback has seconds of unavoidable server work between the bank's
|
||||
* redirect and our own (session exchange with Enable Banking, account
|
||||
* mirroring, audit events). A classic 307 would leave the user staring at a
|
||||
* blank browser tab for that whole window, which reads as "the connection
|
||||
* failed" and provokes retries (and, historically, duplicate connections).
|
||||
*
|
||||
* Instead the route streams this page in two chunks:
|
||||
* 1. renderFinalizeShell() - flushed immediately, before any slow work:
|
||||
* branded spinner + "Slutför anslutningen".
|
||||
* 2. renderFinalizeRedirect() - flushed when the work is done: script +
|
||||
* meta-refresh + visible fallback link that
|
||||
* navigates to the settings page.
|
||||
*
|
||||
* The page is standalone HTML (no app bundle), styled to match the editorial
|
||||
* monochrome design system; see app/api/mcp-oauth/authorize for the sibling
|
||||
* standalone page this mirrors. Swedish-only, like the rest of the
|
||||
* enable-banking extension surfaces.
|
||||
*
|
||||
* Inline scripts are nonce-bound (ASVS V3.3): the route generates a
|
||||
* per-request nonce, stamps it on every <script> tag here, and sets a
|
||||
* response-level CSP with script-src 'nonce-...'. The global next.config CSP
|
||||
* (which still carries 'unsafe-inline' for the app bundle) also applies;
|
||||
* browsers enforce the intersection, so an injected inline script without
|
||||
* the nonce is blocked on this response.
|
||||
*/
|
||||
|
||||
function escapeHtml(value: string): string {
|
||||
return value
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''')
|
||||
}
|
||||
|
||||
/**
|
||||
* 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ör anslutningen till ${escapeHtml(bankName)}…`
|
||||
: 'Slutför bankanslutningen…'
|
||||
|
||||
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ö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äftar anslutningen och hämtar dina konton från banken. Du skickas vidare automatiskt.</p>
|
||||
<p class="slow" id="slow-notice" hidden>
|
||||
Det tar längre tid än vanligt. Vänta gärna kvar en stund till,
|
||||
eller <a href="/settings/banking">gå till bankinstä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är om du inte skickas vidare automatiskt</a></div>
|
||||
</body>
|
||||
</html>
|
||||
`
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
import { randomBytes } from 'node:crypto'
|
||||
import { createServiceClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { NextResponse, after } from 'next/server'
|
||||
import { ensureInitialized } from '@/lib/init'
|
||||
import { createLogger } from '@/lib/logger'
|
||||
import { createSession, type AccountInfo } from '@/extensions/general/enable-banking/lib/api-client'
|
||||
import type { StoredAccount } from '@/extensions/general/enable-banking/types'
|
||||
import { eventBus } from '@/lib/events/bus'
|
||||
@@ -9,6 +11,7 @@ import {
|
||||
allocatePsd2LedgerAccount,
|
||||
defaultLedgerForCurrency,
|
||||
} from '@/lib/cash-accounts/service'
|
||||
import { renderFinalizeShell, renderFinalizeRedirect } from './finalize-page'
|
||||
|
||||
// This route emits bank_connection.consent_granted / .cash_account_mirror_failed
|
||||
// (ASVS V16 / GDPR Art.30 audit events). ensureInitialized() must run at module
|
||||
@@ -17,6 +20,26 @@ import {
|
||||
// redirect route is the first event-emitting code path to execute.
|
||||
ensureInitialized()
|
||||
|
||||
// Structured logger for audit-trail failures (ISO 27001 A.8.15): a failed
|
||||
// audit-event emission must be visible to log-based alerting, not just a raw
|
||||
// console line. The stable message below is what monitoring keys on.
|
||||
const log = createLogger('enable-banking/callback')
|
||||
const AUDIT_EMIT_FAILED = 'audit event emit failed'
|
||||
|
||||
type ServiceClient = Awaited<ReturnType<typeof createServiceClient>>
|
||||
|
||||
interface PendingConnection {
|
||||
id: string
|
||||
user_id: string
|
||||
company_id: string
|
||||
bank_name: string | null
|
||||
status: string
|
||||
}
|
||||
|
||||
// Shown in the settings banner when the session exchange/finalize fails.
|
||||
// User-facing, so Swedish (the raw upstream error is in the server log).
|
||||
const FINALIZE_FAILED_MESSAGE =
|
||||
'Anslutningen kunde inte slutföras. Försök igen om en stund.'
|
||||
|
||||
/**
|
||||
* GET /api/extensions/enable-banking/callback
|
||||
@@ -24,6 +47,13 @@ ensureInitialized()
|
||||
* OAuth callback for Enable Banking PSD2 authorization.
|
||||
* Must be a real Next.js route (not extension handler) because
|
||||
* banks redirect to this URL directly.
|
||||
*
|
||||
* Fast outcomes (bank denial, bad params, unknown state) respond with a
|
||||
* classic 307. The success path instead streams an interim "Slutför
|
||||
* bankanslutningen" page while the slow work runs (session exchange with
|
||||
* Enable Banking, cash-account mirroring), then streams a client-side
|
||||
* redirect: without this the user stares at a blank tab for several seconds,
|
||||
* which reads as a failed connection and provokes duplicate retries.
|
||||
*/
|
||||
export async function GET(request: Request) {
|
||||
const { searchParams } = new URL(request.url)
|
||||
@@ -58,7 +88,7 @@ export async function GET(request: Request) {
|
||||
// (which stays 'expired' during the round-trip) is also handled.
|
||||
const { data: pendingConn } = await supabase
|
||||
.from('bank_connections')
|
||||
.select('id, user_id, bank_name, psu_type')
|
||||
.select('id, user_id, bank_name, psu_type, status')
|
||||
.eq('oauth_state', state)
|
||||
.in('status', ['pending', 'expired', 'error'])
|
||||
.single()
|
||||
@@ -72,17 +102,33 @@ export async function GET(request: Request) {
|
||||
error_description: errorDescription,
|
||||
})
|
||||
|
||||
// If the bank reports a session-expiry during authorization itself,
|
||||
// mark the row 'expired' (not generic 'error') so the settings panel
|
||||
// surfaces the reconnect button rather than a dead-end error state.
|
||||
const isSessionExpiry = /session.?expired|expired.?session|closed.?session|session.?closed|invalid.?session|session.?not.?found/i.test(
|
||||
`${error} ${errorDescription ?? ''}`
|
||||
)
|
||||
if (pendingConn.status === 'pending') {
|
||||
// Fresh connect that never became a connection: delete the row
|
||||
// instead of parking it in 'error'. A parked row renders forever
|
||||
// as an "Åtgärd krävs" card, so a failed attempt followed by a
|
||||
// successful retry showed up as two connections to the same bank.
|
||||
// The ?bank_error banner below is the actual failure feedback.
|
||||
await supabase
|
||||
.from('bank_connections')
|
||||
.delete()
|
||||
.eq('id', pendingConn.id)
|
||||
.eq('status', 'pending')
|
||||
} else {
|
||||
// Reconnect of an established connection: keep the row (it holds
|
||||
// accounts/transactions history) and surface the failure on it.
|
||||
// If the bank reports a session-expiry during authorization
|
||||
// itself, mark it 'expired' (not generic 'error') so the settings
|
||||
// panel surfaces the reconnect button rather than a dead-end
|
||||
// error state.
|
||||
const isSessionExpiry = /session.?expired|expired.?session|closed.?session|session.?closed|invalid.?session|session.?not.?found/i.test(
|
||||
`${error} ${errorDescription ?? ''}`
|
||||
)
|
||||
|
||||
await supabase
|
||||
.from('bank_connections')
|
||||
.update({ status: isSessionExpiry ? 'expired' : 'error', error_message: errorMessage, oauth_state: null })
|
||||
.eq('id', pendingConn.id)
|
||||
await supabase
|
||||
.from('bank_connections')
|
||||
.update({ status: isSessionExpiry ? 'expired' : 'error', error_message: errorMessage, oauth_state: null })
|
||||
.eq('id', pendingConn.id)
|
||||
}
|
||||
|
||||
// Include bank name, error code, and psu_type in the redirect so the
|
||||
// UI can render targeted guidance (e.g. PSU-type retry on
|
||||
@@ -118,240 +164,341 @@ export async function GET(request: Request) {
|
||||
|
||||
const supabase = await createServiceClient()
|
||||
|
||||
try {
|
||||
// Look up the connection awaiting this callback by oauth_state (CSRF-safe).
|
||||
// oauth_state is a single-use random token cleared after use, so it uniquely
|
||||
// identifies the row regardless of status. Accept 'expired'/'error' too: an
|
||||
// in-place reconnect keeps the row in 'expired' during the round-trip (so
|
||||
// the nightly stale-'pending' cleanup can't delete an established row).
|
||||
const { data: pendingConnection, error: findError } = await supabase
|
||||
.from('bank_connections')
|
||||
.select('id, user_id, company_id')
|
||||
.eq('oauth_state', state)
|
||||
.in('status', ['pending', 'expired', 'error'])
|
||||
.single()
|
||||
// Look up the connection awaiting this callback by oauth_state (CSRF-safe).
|
||||
// oauth_state is a single-use random token cleared after use, so it uniquely
|
||||
// identifies the row regardless of status. Accept 'expired'/'error' too: an
|
||||
// in-place reconnect keeps the row in 'expired' during the round-trip (so
|
||||
// the nightly stale-'pending' cleanup can't delete an established row).
|
||||
// This lookup is fast, so it runs BEFORE the streamed response: an unknown
|
||||
// state stays a plain redirect.
|
||||
const { data: pendingConnection, error: findError } = await supabase
|
||||
.from('bank_connections')
|
||||
.select('id, user_id, company_id, bank_name, status')
|
||||
.eq('oauth_state', state)
|
||||
.in('status', ['pending', 'expired', 'error'])
|
||||
.single()
|
||||
|
||||
if (findError || !pendingConnection) {
|
||||
console.error('[enable-banking] No pending connection for oauth_state', {
|
||||
findError: findError ? { message: findError.message, code: findError.code, details: findError.details } : null,
|
||||
state,
|
||||
hasCode: !!code,
|
||||
})
|
||||
return NextResponse.redirect(
|
||||
`${baseUrl}/settings/banking?bank_error=${encodeURIComponent('invalid_state')}`
|
||||
)
|
||||
}
|
||||
|
||||
const userId = pendingConnection.user_id
|
||||
|
||||
console.log('[enable-banking] Exchanging code for session', {
|
||||
connectionId: pendingConnection.id,
|
||||
userId,
|
||||
codeLength: code.length,
|
||||
})
|
||||
|
||||
const sessionData = await createSession(code)
|
||||
const { session_id, accounts, access } = sessionData
|
||||
const consentExpiresAt = access.valid_until
|
||||
|
||||
console.log('[enable-banking] Session created successfully', {
|
||||
connectionId: pendingConnection.id,
|
||||
sessionId: '[REDACTED]',
|
||||
accountCount: accounts.length,
|
||||
consentExpiresAt,
|
||||
})
|
||||
|
||||
// GDPR Art.5(1)(c) / Art.25(1): data minimization. We only store the
|
||||
// metadata the user needs to pick which accounts to sync (uid, name, IBAN,
|
||||
// currency). Balances are bank account financial data: we don't fetch
|
||||
// them here. The first sync (after the user enables specific accounts)
|
||||
// populates balance + balance_updated_at via lib/sync.ts. Accounts the
|
||||
// user deselects never have their balance pulled.
|
||||
const accountsMetadata: StoredAccount[] = accounts.map((account: AccountInfo) => ({
|
||||
uid: account.uid,
|
||||
iban: account.account_id?.iban,
|
||||
name: account.name || account.product,
|
||||
currency: account.currency,
|
||||
// Default to enabled. The user is presented with a picker
|
||||
// immediately after this callback to uncheck unwanted accounts
|
||||
// before any transactions are fetched.
|
||||
enabled: true,
|
||||
}))
|
||||
|
||||
// Stay in 'pending_selection' until the user confirms which accounts to sync.
|
||||
// The cron and manual sync routes both skip this status, so no transactions
|
||||
// can be pulled before the user has had a chance to deselect accounts.
|
||||
// Do not set last_synced_at here either: no transactions have been fetched
|
||||
// yet, and setting it would cause the cron's first-sync 90-day backfill
|
||||
// path to be skipped. The first successful sync sets it.
|
||||
const { data: updatedConnection, error: updateError } = await supabase
|
||||
.from('bank_connections')
|
||||
.update({
|
||||
session_id,
|
||||
status: 'pending_selection',
|
||||
accounts_data: accountsMetadata,
|
||||
consent_expires: consentExpiresAt,
|
||||
oauth_state: null, // Clear to prevent replay
|
||||
})
|
||||
.eq('id', pendingConnection.id)
|
||||
.select('id, bank_name, company_id, user_id')
|
||||
.single()
|
||||
|
||||
if (updateError) {
|
||||
console.error('[enable-banking] Failed to update connection after session creation', {
|
||||
connectionId: pendingConnection.id,
|
||||
updateError: { message: updateError.message, code: updateError.code, details: updateError.details },
|
||||
sessionId: '[REDACTED]',
|
||||
})
|
||||
throw new Error(`Failed to update connection: ${updateError.message}`)
|
||||
}
|
||||
|
||||
// Mirror each PSD2 account into cash_accounts so routing decisions read
|
||||
// from the canonical entity table. Accounts already mirrored (reconnect)
|
||||
// keep their ledger_account — re-deriving it here would clobber the
|
||||
// user's remaps. New accounts each get a free BAS class-19 slot: a bank
|
||||
// returning N same-currency accounts must not collide on the UNIQUE
|
||||
// (company_id, ledger_account) constraint by all defaulting to 1930.
|
||||
const { data: mirroredRows } = await supabase
|
||||
.from('cash_accounts')
|
||||
.select('external_uid, ledger_account')
|
||||
.eq('company_id', updatedConnection.company_id)
|
||||
.eq('bank_connection_id', updatedConnection.id)
|
||||
const existingLedgerByUid = new Map(
|
||||
((mirroredRows ?? []) as Array<{ external_uid: string; ledger_account: string }>).map(
|
||||
(r) => [r.external_uid, r.ledger_account],
|
||||
),
|
||||
)
|
||||
const assignedLedgers = new Set<string>(existingLedgerByUid.values())
|
||||
let accountsDataDirty = false
|
||||
|
||||
for (const account of accountsMetadata) {
|
||||
let targetLedger = existingLedgerByUid.get(account.uid)
|
||||
if (!targetLedger) {
|
||||
targetLedger =
|
||||
(await allocatePsd2LedgerAccount(supabase, updatedConnection.company_id, updatedConnection.user_id, {
|
||||
currency: account.currency,
|
||||
accountName: account.name,
|
||||
exclude: assignedLedgers,
|
||||
})) ?? defaultLedgerForCurrency(account.currency)
|
||||
}
|
||||
assignedLedgers.add(targetLedger)
|
||||
if (account.ledger_account !== targetLedger) {
|
||||
account.ledger_account = targetLedger
|
||||
accountsDataDirty = true
|
||||
}
|
||||
try {
|
||||
await upsertFromPsd2(supabase, updatedConnection.company_id, {
|
||||
bank_connection_id: updatedConnection.id,
|
||||
external_uid: account.uid,
|
||||
currency: account.currency,
|
||||
ledger_account: targetLedger,
|
||||
iban: account.iban ?? null,
|
||||
name: account.name ?? null,
|
||||
enabled: account.enabled ?? true,
|
||||
})
|
||||
} catch (cashErr) {
|
||||
const reason = cashErr instanceof Error ? cashErr.message : String(cashErr)
|
||||
console.error('[enable-banking] Failed to mirror cash_account on callback', {
|
||||
connectionId: updatedConnection.id,
|
||||
uid: account.uid,
|
||||
error: reason,
|
||||
})
|
||||
// Persist the failure to event_log so a security review can see that
|
||||
// a PSD2 account returned by the bank was not mirrored into our
|
||||
// routing table; otherwise this is only visible in console output
|
||||
// (ASVS V16 / ISO 27001 A.8.15 / SOC 2 CC7.2).
|
||||
try {
|
||||
await eventBus.emit({
|
||||
type: 'bank_connection.cash_account_mirror_failed',
|
||||
payload: {
|
||||
connectionId: updatedConnection.id,
|
||||
bankName: updatedConnection.bank_name ?? null,
|
||||
accountUid: account.uid,
|
||||
ledgerAccount: targetLedger,
|
||||
currency: account.currency,
|
||||
reason,
|
||||
userId: updatedConnection.user_id,
|
||||
companyId: updatedConnection.company_id,
|
||||
},
|
||||
})
|
||||
} catch (emitError) {
|
||||
console.error('[enable-banking] Failed to emit cash_account_mirror_failed event', {
|
||||
connectionId: updatedConnection.id,
|
||||
error: emitError instanceof Error ? emitError.message : String(emitError),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Persist the allocated ledgers into accounts_data so the AccountPicker
|
||||
// pre-fills the actual assignments instead of colliding currency
|
||||
// defaults. Non-fatal: cash_accounts is the routing source of truth.
|
||||
if (accountsDataDirty) {
|
||||
const { error: accountsDataError } = await supabase
|
||||
.from('bank_connections')
|
||||
.update({ accounts_data: accountsMetadata })
|
||||
.eq('id', updatedConnection.id)
|
||||
if (accountsDataError) {
|
||||
console.warn('[enable-banking] Failed to persist allocated ledgers to accounts_data', {
|
||||
connectionId: updatedConnection.id,
|
||||
error: accountsDataError.message,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Audit trail: PSD2 consent has been exchanged and account metadata stored.
|
||||
// ASVS V16 requires this transition to be logged as a security event; emit
|
||||
// here so the event_log handler persists it (30-day TTL).
|
||||
try {
|
||||
await eventBus.emit({
|
||||
type: 'bank_connection.consent_granted',
|
||||
payload: {
|
||||
connectionId: updatedConnection.id,
|
||||
bankName: updatedConnection.bank_name ?? null,
|
||||
accountCount: accounts.length,
|
||||
consentExpiresAt: consentExpiresAt ?? null,
|
||||
userId: updatedConnection.user_id,
|
||||
companyId: updatedConnection.company_id,
|
||||
},
|
||||
})
|
||||
} catch (emitError) {
|
||||
// Non-fatal: redirect the user even if the audit event fails. Sentry
|
||||
// surfaces the error; the underlying DB write (the source of truth for
|
||||
// the connection state) has already succeeded.
|
||||
console.error('[enable-banking] Failed to emit consent_granted event', {
|
||||
connectionId: updatedConnection.id,
|
||||
error: emitError instanceof Error ? emitError.message : String(emitError),
|
||||
})
|
||||
}
|
||||
|
||||
const connectionId = updatedConnection.id
|
||||
const redirectTarget = `/settings/banking?select_accounts=${connectionId}`
|
||||
|
||||
return NextResponse.redirect(`${baseUrl}${redirectTarget}`)
|
||||
} catch (error) {
|
||||
console.error('[enable-banking] Callback error', {
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
stack: error instanceof Error ? error.stack : undefined,
|
||||
name: error instanceof Error ? error.name : undefined,
|
||||
if (findError || !pendingConnection) {
|
||||
console.error('[enable-banking] No pending connection for oauth_state', {
|
||||
findError: findError ? { message: findError.message, code: findError.code, details: findError.details } : null,
|
||||
state,
|
||||
hasCode: !!code,
|
||||
})
|
||||
|
||||
try {
|
||||
await supabase
|
||||
.from('bank_connections')
|
||||
.update({ status: 'error', error_message: error instanceof Error ? error.message : 'Connection failed', oauth_state: null })
|
||||
.eq('oauth_state', state)
|
||||
.in('status', ['pending', 'expired', 'error'])
|
||||
} catch (cleanupError) {
|
||||
console.error('[enable-banking] Callback cleanup failed', {
|
||||
cleanupError: cleanupError instanceof Error ? cleanupError.message : String(cleanupError),
|
||||
})
|
||||
}
|
||||
|
||||
return NextResponse.redirect(
|
||||
`${baseUrl}/settings/banking?bank_error=${encodeURIComponent('Connection failed')}`
|
||||
`${baseUrl}/settings/banking?bank_error=${encodeURIComponent('invalid_state')}`
|
||||
)
|
||||
}
|
||||
|
||||
// Kick the finalize work off eagerly, decoupled from the response stream:
|
||||
// if the user closes the tab mid-stream, the stream is cancelled but this
|
||||
// promise keeps running, so the session persistence, cash-account mirror
|
||||
// and consent_granted audit emit are not lost (ASVS V16). Never rejects:
|
||||
// failures resolve to the cleanup redirect target.
|
||||
const finalizePromise = (async (): Promise<string> => {
|
||||
try {
|
||||
return await finalizeConnection(supabase, pendingConnection, code)
|
||||
} catch (finalizeError) {
|
||||
console.error('[enable-banking] Callback error', {
|
||||
message: finalizeError instanceof Error ? finalizeError.message : String(finalizeError),
|
||||
stack: finalizeError instanceof Error ? finalizeError.stack : undefined,
|
||||
name: finalizeError instanceof Error ? finalizeError.name : undefined,
|
||||
state,
|
||||
connectionId: pendingConnection.id,
|
||||
})
|
||||
return cleanupFailedFinalize(supabase, pendingConnection)
|
||||
}
|
||||
})()
|
||||
|
||||
// Keep the serverless function alive until the finalize work settles even
|
||||
// if the client disconnects and the platform considers the response done.
|
||||
try {
|
||||
after(() => finalizePromise.then(() => undefined))
|
||||
} catch {
|
||||
// Outside a request scope (unit tests, plain node server): the stream's
|
||||
// own await below still drives the promise to completion.
|
||||
}
|
||||
|
||||
// Per-request CSP nonce for the two inline scripts on the finalize page
|
||||
// (ASVS V3.3): mirrors the mcp-oauth consent page. The global next.config
|
||||
// CSP also applies; the intersection means inline scripts on THIS response
|
||||
// must carry the nonce.
|
||||
const cspNonce = randomBytes(16).toString('base64')
|
||||
const csp = [
|
||||
"default-src 'none'",
|
||||
`script-src 'nonce-${cspNonce}'`,
|
||||
"style-src 'unsafe-inline'",
|
||||
"base-uri 'none'",
|
||||
"form-action 'self'",
|
||||
"frame-ancestors 'none'",
|
||||
].join('; ')
|
||||
|
||||
// Stream: flush the branded "Slutför bankanslutningen" shell immediately,
|
||||
// await the finalize work, then stream a client-side redirect to the
|
||||
// outcome URL. The user sees progress from the first byte instead of a
|
||||
// blank tab.
|
||||
const encoder = new TextEncoder()
|
||||
const stream = new ReadableStream<Uint8Array>({
|
||||
async start(controller) {
|
||||
controller.enqueue(encoder.encode(renderFinalizeShell(pendingConnection.bank_name, cspNonce)))
|
||||
const targetPath = await finalizePromise
|
||||
try {
|
||||
controller.enqueue(encoder.encode(renderFinalizeRedirect(`${baseUrl}${targetPath}`, cspNonce)))
|
||||
controller.close()
|
||||
} catch {
|
||||
// Stream already cancelled (client closed the tab). The finalize
|
||||
// work above completed regardless; there is just no one to redirect.
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
return new Response(stream, {
|
||||
headers: {
|
||||
'Content-Type': 'text/html; charset=utf-8',
|
||||
'Content-Security-Policy': csp,
|
||||
// The body carries a one-time OAuth outcome: never cache, never buffer
|
||||
// (X-Accel-Buffering opts out of proxy buffering so the shell chunk
|
||||
// actually reaches the browser before the work finishes).
|
||||
'Cache-Control': 'no-store',
|
||||
'X-Accel-Buffering': 'no',
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* The slow part of the callback: exchange the authorization code for a PSD2
|
||||
* session, persist the account metadata, mirror accounts into cash_accounts,
|
||||
* and emit the audit event. Returns the app-relative redirect target.
|
||||
* Extracted so the route can run it behind the streamed progress page.
|
||||
*/
|
||||
async function finalizeConnection(
|
||||
supabase: ServiceClient,
|
||||
pendingConnection: PendingConnection,
|
||||
code: string,
|
||||
): Promise<string> {
|
||||
const userId = pendingConnection.user_id
|
||||
|
||||
console.log('[enable-banking] Exchanging code for session', {
|
||||
connectionId: pendingConnection.id,
|
||||
userId,
|
||||
codeLength: code.length,
|
||||
})
|
||||
|
||||
const sessionData = await createSession(code)
|
||||
const { session_id, accounts, access } = sessionData
|
||||
const consentExpiresAt = access.valid_until
|
||||
|
||||
console.log('[enable-banking] Session created successfully', {
|
||||
connectionId: pendingConnection.id,
|
||||
sessionId: '[REDACTED]',
|
||||
accountCount: accounts.length,
|
||||
consentExpiresAt,
|
||||
})
|
||||
|
||||
// GDPR Art.5(1)(c) / Art.25(1): data minimization. We only store the
|
||||
// metadata the user needs to pick which accounts to sync (uid, name, IBAN,
|
||||
// currency). Balances are bank account financial data: we don't fetch
|
||||
// them here. The first sync (after the user enables specific accounts)
|
||||
// populates balance + balance_updated_at via lib/sync.ts. Accounts the
|
||||
// user deselects never have their balance pulled.
|
||||
const accountsMetadata: StoredAccount[] = accounts.map((account: AccountInfo) => ({
|
||||
uid: account.uid,
|
||||
iban: account.account_id?.iban,
|
||||
name: account.name || account.product,
|
||||
currency: account.currency,
|
||||
// Default to enabled. The user is presented with a picker
|
||||
// immediately after this callback to uncheck unwanted accounts
|
||||
// before any transactions are fetched.
|
||||
enabled: true,
|
||||
}))
|
||||
|
||||
// Stay in 'pending_selection' until the user confirms which accounts to sync.
|
||||
// The cron and manual sync routes both skip this status, so no transactions
|
||||
// can be pulled before the user has had a chance to deselect accounts.
|
||||
// Do not set last_synced_at here either: no transactions have been fetched
|
||||
// yet, and setting it would cause the cron's first-sync 90-day backfill
|
||||
// path to be skipped. The first successful sync sets it.
|
||||
const { data: updatedConnection, error: updateError } = await supabase
|
||||
.from('bank_connections')
|
||||
.update({
|
||||
session_id,
|
||||
status: 'pending_selection',
|
||||
accounts_data: accountsMetadata,
|
||||
consent_expires: consentExpiresAt,
|
||||
oauth_state: null, // Clear to prevent replay
|
||||
})
|
||||
.eq('id', pendingConnection.id)
|
||||
.select('id, bank_name, company_id, user_id')
|
||||
.single()
|
||||
|
||||
if (updateError) {
|
||||
console.error('[enable-banking] Failed to update connection after session creation', {
|
||||
connectionId: pendingConnection.id,
|
||||
updateError: { message: updateError.message, code: updateError.code, details: updateError.details },
|
||||
sessionId: '[REDACTED]',
|
||||
})
|
||||
throw new Error(`Failed to update connection: ${updateError.message}`)
|
||||
}
|
||||
|
||||
// Mirror each PSD2 account into cash_accounts so routing decisions read
|
||||
// from the canonical entity table. Accounts already mirrored (reconnect)
|
||||
// keep their ledger_account — re-deriving it here would clobber the
|
||||
// user's remaps. New accounts each get a free BAS class-19 slot: a bank
|
||||
// returning N same-currency accounts must not collide on the UNIQUE
|
||||
// (company_id, ledger_account) constraint by all defaulting to 1930.
|
||||
const { data: mirroredRows } = await supabase
|
||||
.from('cash_accounts')
|
||||
.select('external_uid, ledger_account')
|
||||
.eq('company_id', updatedConnection.company_id)
|
||||
.eq('bank_connection_id', updatedConnection.id)
|
||||
const existingLedgerByUid = new Map(
|
||||
((mirroredRows ?? []) as Array<{ external_uid: string; ledger_account: string }>).map(
|
||||
(r) => [r.external_uid, r.ledger_account],
|
||||
),
|
||||
)
|
||||
const assignedLedgers = new Set<string>(existingLedgerByUid.values())
|
||||
let accountsDataDirty = false
|
||||
|
||||
for (const account of accountsMetadata) {
|
||||
let targetLedger = existingLedgerByUid.get(account.uid)
|
||||
if (!targetLedger) {
|
||||
targetLedger =
|
||||
(await allocatePsd2LedgerAccount(supabase, updatedConnection.company_id, updatedConnection.user_id, {
|
||||
currency: account.currency,
|
||||
accountName: account.name,
|
||||
exclude: assignedLedgers,
|
||||
})) ?? defaultLedgerForCurrency(account.currency)
|
||||
}
|
||||
assignedLedgers.add(targetLedger)
|
||||
if (account.ledger_account !== targetLedger) {
|
||||
account.ledger_account = targetLedger
|
||||
accountsDataDirty = true
|
||||
}
|
||||
try {
|
||||
await upsertFromPsd2(supabase, updatedConnection.company_id, {
|
||||
bank_connection_id: updatedConnection.id,
|
||||
external_uid: account.uid,
|
||||
currency: account.currency,
|
||||
ledger_account: targetLedger,
|
||||
iban: account.iban ?? null,
|
||||
name: account.name ?? null,
|
||||
enabled: account.enabled ?? true,
|
||||
})
|
||||
} catch (cashErr) {
|
||||
const reason = cashErr instanceof Error ? cashErr.message : String(cashErr)
|
||||
console.error('[enable-banking] Failed to mirror cash_account on callback', {
|
||||
connectionId: updatedConnection.id,
|
||||
uid: account.uid,
|
||||
error: reason,
|
||||
})
|
||||
// Persist the failure to event_log so a security review can see that
|
||||
// a PSD2 account returned by the bank was not mirrored into our
|
||||
// routing table; otherwise this is only visible in console output
|
||||
// (ASVS V16 / ISO 27001 A.8.15 / SOC 2 CC7.2).
|
||||
try {
|
||||
await eventBus.emit({
|
||||
type: 'bank_connection.cash_account_mirror_failed',
|
||||
payload: {
|
||||
connectionId: updatedConnection.id,
|
||||
bankName: updatedConnection.bank_name ?? null,
|
||||
accountUid: account.uid,
|
||||
ledgerAccount: targetLedger,
|
||||
currency: account.currency,
|
||||
reason,
|
||||
userId: updatedConnection.user_id,
|
||||
companyId: updatedConnection.company_id,
|
||||
},
|
||||
})
|
||||
} catch (emitError) {
|
||||
// A.8.15: structured error (not bare console) so log-based alerting
|
||||
// catches a dropped security event instead of it vanishing silently.
|
||||
log.error(AUDIT_EMIT_FAILED, emitError as Error, {
|
||||
eventType: 'bank_connection.cash_account_mirror_failed',
|
||||
connectionId: updatedConnection.id,
|
||||
accountUid: account.uid,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Persist the allocated ledgers into accounts_data so the AccountPicker
|
||||
// pre-fills the actual assignments instead of colliding currency
|
||||
// defaults. Non-fatal: cash_accounts is the routing source of truth.
|
||||
if (accountsDataDirty) {
|
||||
const { error: accountsDataError } = await supabase
|
||||
.from('bank_connections')
|
||||
.update({ accounts_data: accountsMetadata })
|
||||
.eq('id', updatedConnection.id)
|
||||
if (accountsDataError) {
|
||||
console.warn('[enable-banking] Failed to persist allocated ledgers to accounts_data', {
|
||||
connectionId: updatedConnection.id,
|
||||
error: accountsDataError.message,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Audit trail: PSD2 consent has been exchanged and account metadata stored.
|
||||
// ASVS V16 requires this transition to be logged as a security event; emit
|
||||
// here so the event_log handler persists it (30-day TTL).
|
||||
try {
|
||||
await eventBus.emit({
|
||||
type: 'bank_connection.consent_granted',
|
||||
payload: {
|
||||
connectionId: updatedConnection.id,
|
||||
bankName: updatedConnection.bank_name ?? null,
|
||||
accountCount: accounts.length,
|
||||
consentExpiresAt: consentExpiresAt ?? null,
|
||||
userId: updatedConnection.user_id,
|
||||
companyId: updatedConnection.company_id,
|
||||
},
|
||||
})
|
||||
} catch (emitError) {
|
||||
// Non-fatal: redirect the user even if the audit event fails. The
|
||||
// structured error record is the alerting channel (A.8.15): production
|
||||
// log monitoring keys on the stable message. The underlying DB write
|
||||
// (the source of truth for the connection state) has already succeeded.
|
||||
log.error(AUDIT_EMIT_FAILED, emitError as Error, {
|
||||
eventType: 'bank_connection.consent_granted',
|
||||
connectionId: updatedConnection.id,
|
||||
})
|
||||
}
|
||||
|
||||
return `/settings/banking?select_accounts=${updatedConnection.id}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Failure cleanup after finalizeConnection threw. A fresh connect (prior
|
||||
* status 'pending') never became a connection: delete the row so it can't
|
||||
* linger as a zombie "Åtgärd krävs" card next to a successful retry. A
|
||||
* reconnect row (established connection) is kept and marked 'error' so the
|
||||
* user retains the renew affordance. Returns the error redirect target.
|
||||
*/
|
||||
async function cleanupFailedFinalize(
|
||||
supabase: ServiceClient,
|
||||
pendingConnection: PendingConnection,
|
||||
): Promise<string> {
|
||||
try {
|
||||
if (pendingConnection.status === 'pending') {
|
||||
await supabase
|
||||
.from('bank_connections')
|
||||
.delete()
|
||||
.eq('id', pendingConnection.id)
|
||||
.eq('status', 'pending')
|
||||
} else {
|
||||
await supabase
|
||||
.from('bank_connections')
|
||||
.update({ status: 'error', error_message: FINALIZE_FAILED_MESSAGE, oauth_state: null })
|
||||
.eq('id', pendingConnection.id)
|
||||
.in('status', ['pending', 'expired', 'error'])
|
||||
}
|
||||
} catch (cleanupError) {
|
||||
console.error('[enable-banking] Callback cleanup failed', {
|
||||
cleanupError: cleanupError instanceof Error ? cleanupError.message : String(cleanupError),
|
||||
})
|
||||
}
|
||||
|
||||
const params = new URLSearchParams({
|
||||
bank_error: FINALIZE_FAILED_MESSAGE,
|
||||
...(pendingConnection.bank_name ? { bank_name: pendingConnection.bank_name } : {}),
|
||||
})
|
||||
return `/settings/banking?${params.toString()}`
|
||||
}
|
||||
|
||||
@@ -475,6 +475,51 @@ describe('POST /api/invoices/[id]/mark-paid', () => {
|
||||
expect(body.paid_at).toBeNull()
|
||||
})
|
||||
|
||||
it('accepts an öresavrundning overshoot: rounded "Att betala" settles the invoice in full', async () => {
|
||||
// Invoice stored with öre (1234.75), PDF shows the rounded 1235.00 and the
|
||||
// customer pays that: the 3740 line carries the 0.25 residual. No customer
|
||||
// → duplicate guard skips.
|
||||
const invoice = makeInvoice({
|
||||
id: 'inv-1',
|
||||
status: 'sent',
|
||||
total: 1234.75,
|
||||
remaining_amount: 1234.75,
|
||||
})
|
||||
|
||||
enqueue({ data: invoice, error: null })
|
||||
enqueue({ data: { accounting_method: 'accrual', entity_type: 'enskild_firma' }, error: null })
|
||||
enqueue({ data: [{ id: 'inv-1' }], error: null }) // CAS update matched
|
||||
|
||||
mockFindFiscalPeriod.mockResolvedValue('fp-1')
|
||||
mockCreateJournalEntry.mockResolvedValue({ id: 'je-ore' })
|
||||
|
||||
const oreLines = [
|
||||
{ account_number: '1930', debit_amount: 1235, credit_amount: 0 },
|
||||
{ account_number: '1510', debit_amount: 0, credit_amount: 1234.75 },
|
||||
{ account_number: '3740', debit_amount: 0, credit_amount: 0.25 },
|
||||
]
|
||||
|
||||
const request = createMockRequest('/api/invoices/inv-1/mark-paid', {
|
||||
method: 'POST',
|
||||
body: { lines: oreLines },
|
||||
})
|
||||
const response = await POST(request, createMockRouteParams({ id: 'inv-1' }))
|
||||
const { status, body } = await parseJsonResponse<{
|
||||
success: boolean
|
||||
status: string
|
||||
paid_amount: number
|
||||
remaining_amount: number
|
||||
journal_entry_id: string
|
||||
}>(response)
|
||||
|
||||
expect(status).toBe(200)
|
||||
expect(body.success).toBe(true)
|
||||
expect(body.status).toBe('paid')
|
||||
expect(body.paid_amount).toBe(1234.75)
|
||||
expect(body.remaining_amount).toBe(0)
|
||||
expect(body.journal_entry_id).toBe('je-ore')
|
||||
})
|
||||
|
||||
it('returns 400 MATCH_AMOUNT_EXCEEDS_REMAINING when custom lines overpay the invoice', async () => {
|
||||
// No customer → duplicate guard skips; the overpayment guard must reject
|
||||
// BEFORE any journal entry is created (planInvoicePayment runs first).
|
||||
|
||||
@@ -164,6 +164,84 @@ describe('POST /api/invoices/[id]/send', () => {
|
||||
expect((body.error as unknown as { code: string }).code).toBe('INVOICE_SEND_CANCELLED')
|
||||
})
|
||||
|
||||
it.each(['sent', 'paid', 'overdue', 'partially_paid', 'credited'] as const)(
|
||||
'returns 409 and posts no journal entry when invoice status is %s',
|
||||
async (issuedStatus) => {
|
||||
const issuedInvoice = makeInvoice({
|
||||
id: 'inv-1',
|
||||
status: issuedStatus,
|
||||
customer,
|
||||
items: [],
|
||||
})
|
||||
enqueue({ data: issuedInvoice, error: null })
|
||||
|
||||
const request = createMockRequest('/api/invoices/inv-1/send', { method: 'POST' })
|
||||
const response = await POST(request, createMockRouteParams({ id: 'inv-1' }))
|
||||
const { status, body } = await parseJsonResponse<{ error: string }>(response)
|
||||
|
||||
expect(status).toBe(409)
|
||||
expect((body.error as unknown as { code: string }).code).toBe('INVOICE_ALREADY_SENT')
|
||||
expect(mockSendEmail).not.toHaveBeenCalled()
|
||||
expect(mockCreateInvoiceJournalEntry).not.toHaveBeenCalled()
|
||||
},
|
||||
)
|
||||
|
||||
it('skips journal entry, archive and event when a concurrent request won the status flip', async () => {
|
||||
enqueue({ data: invoice, error: null })
|
||||
enqueue({ data: company, error: null })
|
||||
|
||||
mockSendEmail.mockResolvedValue({ success: true, messageId: 'msg-race' })
|
||||
|
||||
// Optimistic-locked flip matches 0 rows: another request already sent it.
|
||||
enqueue({ data: [], error: null })
|
||||
|
||||
const emitSpy = vi.spyOn(eventBus, 'emit')
|
||||
|
||||
const request = createMockRequest('/api/invoices/inv-1/send', { method: 'POST' })
|
||||
const response = await POST(request, createMockRouteParams({ id: 'inv-1' }))
|
||||
const { status, body } = await parseJsonResponse<{
|
||||
success: boolean
|
||||
partial?: boolean
|
||||
partial_failures?: Array<{ step: string }>
|
||||
}>(response)
|
||||
|
||||
// The email did go out, so the response is still a (partial) success.
|
||||
expect(status).toBe(200)
|
||||
expect(body.success).toBe(true)
|
||||
expect(body.partial).toBe(true)
|
||||
expect(body.partial_failures?.some((f) => f.step === 'status_update')).toBe(true)
|
||||
// The winning request owns the bookkeeping: no second verifikat here.
|
||||
expect(mockCreateInvoiceJournalEntry).not.toHaveBeenCalled()
|
||||
expect(emitSpy).not.toHaveBeenCalledWith(
|
||||
expect.objectContaining({ type: 'invoice.sent' })
|
||||
)
|
||||
})
|
||||
|
||||
it('defers the journal entry when the status flip errors (row stays draft, retry re-books once)', async () => {
|
||||
enqueue({ data: invoice, error: null })
|
||||
enqueue({ data: company, error: null })
|
||||
|
||||
mockSendEmail.mockResolvedValue({ success: true, messageId: 'msg-fliperr' })
|
||||
|
||||
// Status flip hits a DB error: the invoice remains 'draft'.
|
||||
enqueue({ data: null, error: { message: 'connection reset' } })
|
||||
|
||||
const request = createMockRequest('/api/invoices/inv-1/send', { method: 'POST' })
|
||||
const response = await POST(request, createMockRouteParams({ id: 'inv-1' }))
|
||||
const { status, body } = await parseJsonResponse<{
|
||||
success: boolean
|
||||
partial?: boolean
|
||||
partial_failures?: Array<{ step: string; reason: string }>
|
||||
}>(response)
|
||||
|
||||
expect(status).toBe(200)
|
||||
expect(body.partial).toBe(true)
|
||||
expect(body.partial_failures?.some((f) => f.step === 'status_update')).toBe(true)
|
||||
// No entry now: the retry (invoice still draft) runs the full pipeline
|
||||
// and posts exactly one, instead of this request + the retry posting two.
|
||||
expect(mockCreateInvoiceJournalEntry).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('returns 400 when customer has no email', async () => {
|
||||
const noEmailInvoice = makeInvoice({
|
||||
id: 'inv-1',
|
||||
@@ -201,8 +279,8 @@ describe('POST /api/invoices/[id]/send', () => {
|
||||
mockSendEmail.mockResolvedValue({ success: true, messageId: 'msg-1' })
|
||||
mockCreateInvoiceJournalEntry.mockResolvedValue({ id: 'je-1' })
|
||||
|
||||
// Update invoice status to 'sent'
|
||||
enqueue({ data: null, error: null })
|
||||
// Update invoice status to 'sent' (optimistic lock: returns the matched row)
|
||||
enqueue({ data: [{ id: 'inv-1' }], error: null })
|
||||
// Update invoice with journal_entry_id
|
||||
enqueue({ data: null, error: null })
|
||||
|
||||
@@ -244,7 +322,7 @@ describe('POST /api/invoices/[id]/send', () => {
|
||||
mockSendEmail.mockResolvedValue({ success: true, messageId: 'msg-2' })
|
||||
|
||||
// Update invoice status
|
||||
enqueue({ data: null, error: null })
|
||||
enqueue({ data: [{ id: 'inv-1' }], error: null })
|
||||
|
||||
const request = createMockRequest('/api/invoices/inv-1/send', { method: 'POST' })
|
||||
const response = await POST(request, createMockRouteParams({ id: 'inv-1' }))
|
||||
@@ -263,7 +341,7 @@ describe('POST /api/invoices/[id]/send', () => {
|
||||
mockCreateInvoiceJournalEntry.mockRejectedValue(new Error('Period locked'))
|
||||
|
||||
// Update invoice status
|
||||
enqueue({ data: null, error: null })
|
||||
enqueue({ data: [{ id: 'inv-1' }], error: null })
|
||||
|
||||
const request = createMockRequest('/api/invoices/inv-1/send', { method: 'POST' })
|
||||
const response = await POST(request, createMockRouteParams({ id: 'inv-1' }))
|
||||
@@ -293,7 +371,7 @@ describe('POST /api/invoices/[id]/send', () => {
|
||||
mockCreateInvoiceJournalEntry.mockResolvedValue({ id: 'je-1' })
|
||||
|
||||
// Update status to 'sent'
|
||||
enqueue({ data: null, error: null })
|
||||
enqueue({ data: [{ id: 'inv-1' }], error: null })
|
||||
// Update with journal_entry_id
|
||||
enqueue({ data: null, error: null })
|
||||
|
||||
@@ -325,7 +403,7 @@ describe('POST /api/invoices/[id]/send', () => {
|
||||
mockSendEmail.mockResolvedValue({ success: true, messageId: 'msg-100' })
|
||||
mockCreateInvoiceJournalEntry.mockResolvedValue({ id: 'je-2' })
|
||||
|
||||
enqueue({ data: null, error: null })
|
||||
enqueue({ data: [{ id: 'inv-1' }], error: null })
|
||||
enqueue({ data: null, error: null })
|
||||
|
||||
const request = createMockRequest('/api/invoices/inv-1/send', { method: 'POST' })
|
||||
@@ -388,7 +466,7 @@ describe('POST /api/invoices/[id]/send', () => {
|
||||
mockSendEmail.mockResolvedValue({ success: true, messageId: 'msg-banner' })
|
||||
mockCreateInvoiceJournalEntry.mockResolvedValue({ id: 'je-1' })
|
||||
|
||||
enqueue({ data: null, error: null })
|
||||
enqueue({ data: [{ id: 'inv-1' }], error: null })
|
||||
enqueue({ data: null, error: null })
|
||||
|
||||
const request = createMockRequest('/api/invoices/inv-1/send', { method: 'POST' })
|
||||
|
||||
@@ -60,13 +60,26 @@ export const POST = withRouteContext(
|
||||
}
|
||||
|
||||
// A cancelled invoice keeps its F-series number for compliance with ML 17
|
||||
// kap 24§ but is not a valid faktura: sending it would silently
|
||||
// re-activate it (the .update({ status: 'sent' }) below has no status
|
||||
// guard) and could deliver a "MAKULERAD" PDF as if it were live.
|
||||
// kap 24§ but is not a valid faktura: sending it would deliver a
|
||||
// "MAKULERAD" PDF as if it were live. Checked before the generic draft
|
||||
// guard below for the more specific error message.
|
||||
if (invoice.status === 'cancelled') {
|
||||
return errorResponseFromCode('INVOICE_SEND_CANCELLED', opLog, { requestId })
|
||||
}
|
||||
|
||||
// Only drafts may enter the send pipeline. The UI already hides Send for
|
||||
// non-drafts, but a direct POST against an issued invoice would re-email
|
||||
// the customer and post a SECOND revenue verifikat
|
||||
// (createInvoiceJournalEntry has no dedup), overwriting journal_entry_id
|
||||
// and orphaning the first entry. Mirrors the v1 route and the MCP commit
|
||||
// executor, which both reject non-drafts.
|
||||
if (invoice.status !== 'draft') {
|
||||
return errorResponseFromCode('INVOICE_ALREADY_SENT', opLog, {
|
||||
requestId,
|
||||
details: { currentStatus: invoice.status },
|
||||
})
|
||||
}
|
||||
|
||||
const customer = invoice.customer as Customer
|
||||
if (!customer.email) {
|
||||
return errorResponseFromCode('INVOICE_SEND_NO_CUSTOMER_EMAIL', opLog, {
|
||||
@@ -223,16 +236,36 @@ export const POST = withRouteContext(
|
||||
partialFailures.push({ step: 'payment_link', reason: paymentLinkFailure })
|
||||
}
|
||||
|
||||
// Optimistic-locked flip (draft → sent). Two concurrent sends can both
|
||||
// pass the draft guard above and both email the customer, but only the
|
||||
// request that wins this compare-and-set runs the bookkeeping steps
|
||||
// below: the loser would otherwise post a duplicate revenue verifikat
|
||||
// and archive the PDF twice. PostgREST returns no error for a 0-row
|
||||
// update, so the row count via .select('id') is the actual lock signal.
|
||||
// A genuine update error also skips the follow-ups: the row is still
|
||||
// 'draft', so a later retry re-runs the whole pipeline and ends with
|
||||
// exactly one journal entry (at the cost of a duplicate email).
|
||||
let statusFlipped = false
|
||||
{
|
||||
const { error: updateError } = await supabase
|
||||
const { data: flipRows, error: updateError } = await supabase
|
||||
.from('invoices')
|
||||
.update({ status: 'sent' })
|
||||
.eq('id', id)
|
||||
.eq('company_id', companyId)
|
||||
.eq('status', 'draft')
|
||||
.select('id')
|
||||
|
||||
if (updateError) {
|
||||
opLog.warn('failed to update invoice status to sent', updateError)
|
||||
partialFailures.push({ step: 'status_update', reason: updateError.message })
|
||||
} else if (!flipRows || flipRows.length === 0) {
|
||||
opLog.warn('invoice already flipped to sent by a concurrent request; skipping bookkeeping follow-ups')
|
||||
partialFailures.push({
|
||||
step: 'status_update',
|
||||
reason: 'Fakturan skickades samtidigt av en annan begäran; bokföringen hanterades där.',
|
||||
})
|
||||
} else {
|
||||
statusFlipped = true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -240,7 +273,7 @@ export const POST = withRouteContext(
|
||||
const accountingMethod = (company as Record<string, unknown>).accounting_method as string | undefined
|
||||
let createdJournalEntryId: string | undefined
|
||||
|
||||
if (isRealInvoice && (!accountingMethod || accountingMethod === 'accrual')) {
|
||||
if (statusFlipped && isRealInvoice && (!accountingMethod || accountingMethod === 'accrual')) {
|
||||
try {
|
||||
const journalEntry = await createInvoiceJournalEntry(
|
||||
supabase,
|
||||
@@ -284,7 +317,7 @@ export const POST = withRouteContext(
|
||||
}
|
||||
}
|
||||
|
||||
if (isRealInvoice) {
|
||||
if (statusFlipped && isRealInvoice) {
|
||||
try {
|
||||
const pdfArrayBuffer = new Uint8Array(pdfBuffer).buffer as ArrayBuffer
|
||||
await uploadDocument(supabase, user.id, companyId!, {
|
||||
@@ -304,10 +337,15 @@ export const POST = withRouteContext(
|
||||
}
|
||||
}
|
||||
|
||||
await eventBus.emit({
|
||||
type: 'invoice.sent',
|
||||
payload: { invoice: invoice as Invoice, companyId: companyId!, userId: user.id },
|
||||
})
|
||||
// Gated like the steps above: on a lost race the winning request emits
|
||||
// it; on a flip error the row is still 'draft', so emitting would
|
||||
// contradict DB state and the retry emits it instead.
|
||||
if (statusFlipped) {
|
||||
await eventBus.emit({
|
||||
type: 'invoice.sent',
|
||||
payload: { invoice: invoice as Invoice, companyId: companyId!, userId: user.id },
|
||||
})
|
||||
}
|
||||
|
||||
if (partialFailures.length > 0) {
|
||||
opLog.warn('invoice sent with partial follow-up failures', {
|
||||
|
||||
@@ -3,6 +3,7 @@ import { ensureInitialized } from '@/lib/init'
|
||||
import { withRouteContext } from '@/lib/api/with-route-context'
|
||||
import { errorResponse } from '@/lib/errors/get-structured-error'
|
||||
import { executeRecurringSchedule } from '@/lib/invoices/recurring-schedule-service'
|
||||
import { isSandboxCompany } from '@/lib/sandbox/guard'
|
||||
import type { RecurringInvoiceSchedule, RecurringInvoiceScheduleItem } from '@/types'
|
||||
|
||||
ensureInitialized()
|
||||
@@ -46,8 +47,16 @@ export const POST = withRouteContext(
|
||||
items: RecurringInvoiceScheduleItem[]
|
||||
}
|
||||
|
||||
// Defence in depth (ASVS V2.3): mirror the cron route. The sandbox rule
|
||||
// is enforced inside the service's email chokepoint too; resolving it at
|
||||
// the route level as well means the invariant survives refactors of the
|
||||
// service internals. Invoice creation is unaffected (freeze-and-retain).
|
||||
const suppressAutoSend = typed.auto_send
|
||||
? await isSandboxCompany(supabase, companyId)
|
||||
: false
|
||||
|
||||
try {
|
||||
const result = await executeRecurringSchedule(supabase, typed, new Date())
|
||||
const result = await executeRecurringSchedule(supabase, typed, new Date(), { suppressAutoSend })
|
||||
|
||||
// Record the run for the list view (generated count, last invoice,
|
||||
// warning) but leave next_run_date untouched: the monthly cadence runs
|
||||
|
||||
@@ -3,6 +3,27 @@ import { createQueuedMockSupabase, createMockRequest, parseJsonResponse } from '
|
||||
|
||||
const { supabase: mockSupabase, enqueue, reset } = createQueuedMockSupabase()
|
||||
|
||||
// The queued mock's proxy chain discards call arguments, but these tests need
|
||||
// to assert exactly what the cron writes back to the schedule row (claim
|
||||
// release + failure warning, stale roll-forward warning). Wrap .from() so
|
||||
// every .update() payload is recorded before delegating to the queue chain.
|
||||
const updatePayloads: Array<{ table: string; payload: Record<string, unknown> }> = []
|
||||
const baseFrom = mockSupabase.from.getMockImplementation()!
|
||||
mockSupabase.from.mockImplementation((table: string) => {
|
||||
const chain = baseFrom(table) as Record<string, (...args: unknown[]) => unknown>
|
||||
return new Proxy(chain, {
|
||||
get(target, prop, receiver) {
|
||||
if (prop === 'update') {
|
||||
return (payload: Record<string, unknown>) => {
|
||||
updatePayloads.push({ table, payload })
|
||||
return target.update(payload)
|
||||
}
|
||||
}
|
||||
return Reflect.get(target, prop, receiver)
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
vi.mock('@/lib/supabase/server', () => ({
|
||||
createServiceClient: () => mockSupabase,
|
||||
}))
|
||||
@@ -23,6 +44,12 @@ vi.mock('@/lib/invoices/recurring-schedule-service', async (importActual) => {
|
||||
}
|
||||
})
|
||||
|
||||
// Route-level sandbox resolution (defence in depth, ASVS V2.3).
|
||||
const isSandboxCompany = vi.fn()
|
||||
vi.mock('@/lib/sandbox/guard', () => ({
|
||||
isSandboxCompany: (...args: unknown[]) => isSandboxCompany(...args),
|
||||
}))
|
||||
|
||||
import { GET } from '../route'
|
||||
|
||||
type ResultRow = {
|
||||
@@ -53,6 +80,7 @@ describe('GET /api/invoices/recurring/cron', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
reset()
|
||||
updatePayloads.length = 0
|
||||
vi.useFakeTimers()
|
||||
})
|
||||
afterEach(() => {
|
||||
@@ -77,6 +105,31 @@ describe('GET /api/invoices/recurring/cron', () => {
|
||||
expect(executeRecurringSchedule).toHaveBeenCalledTimes(1)
|
||||
expect(body.succeeded).toBe(1)
|
||||
expect(body.results[0].invoiceId).toBe('inv-1')
|
||||
// No auto_send on the schedule -> no sandbox lookup, no suppression.
|
||||
expect(isSandboxCompany).not.toHaveBeenCalled()
|
||||
expect(executeRecurringSchedule.mock.calls[0][3]).toEqual({ suppressAutoSend: false })
|
||||
})
|
||||
|
||||
it('resolves the sandbox flag at the route level and suppresses auto-send for sandbox companies', async () => {
|
||||
vi.setSystemTime(new Date('2026-07-06T08:30:00Z'))
|
||||
enqueue({ data: [makeSchedule({ send_hour: 8, auto_send: true })], error: null })
|
||||
// Atomic claim wins.
|
||||
enqueue({ data: [{ id: 's-1' }], error: null })
|
||||
isSandboxCompany.mockResolvedValue(true)
|
||||
executeRecurringSchedule.mockResolvedValue({
|
||||
invoiceId: 'inv-1',
|
||||
invoiceNumber: 'F-1',
|
||||
autoSent: false,
|
||||
warning: 'Auto-utskick misslyckades: fakturan finns som utkast och kan skickas manuellt.',
|
||||
})
|
||||
|
||||
const { status } = await parseJsonResponse<CronBody>(await GET(req()))
|
||||
expect(status).toBe(200)
|
||||
// Defence in depth: the route resolved the sandbox state itself and told
|
||||
// the service explicitly, instead of relying only on the chokepoint
|
||||
// inside sendInvoiceFromSchedule.
|
||||
expect(isSandboxCompany).toHaveBeenCalledWith(expect.anything(), 'c-1')
|
||||
expect(executeRecurringSchedule.mock.calls[0][3]).toEqual({ suppressAutoSend: true })
|
||||
})
|
||||
|
||||
it('skips when a concurrent cron run already claimed the schedule', async () => {
|
||||
@@ -110,6 +163,46 @@ describe('GET /api/invoices/recurring/cron', () => {
|
||||
expect(body.results[0].skipReason).toBe('stale_rolled_forward')
|
||||
})
|
||||
|
||||
it('releases the claim AND persists a failure warning when execution throws', async () => {
|
||||
vi.setSystemTime(new Date('2026-07-06T08:30:00Z'))
|
||||
enqueue({ data: [makeSchedule({ send_hour: 8 })], error: null })
|
||||
// Atomic claim wins.
|
||||
enqueue({ data: [{ id: 's-1' }], error: null })
|
||||
executeRecurringSchedule.mockRejectedValue(new Error('VAT rate 25% not allowed'))
|
||||
// Claim release + warning write.
|
||||
enqueue({ data: null, error: null })
|
||||
|
||||
const { body } = await parseJsonResponse<CronBody & { failed: number }>(await GET(req()))
|
||||
expect(body.failed).toBe(1)
|
||||
|
||||
// The release update must restore the pre-claim last_run_at (null here)
|
||||
// so a later cron retries today, and carry a user-visible warning so a
|
||||
// deterministic failure never skips the month silently.
|
||||
const release = updatePayloads.find(
|
||||
(u) => u.table === 'recurring_invoice_schedules' && 'last_run_warning' in u.payload,
|
||||
)
|
||||
expect(release).toBeDefined()
|
||||
expect(release!.payload.last_run_at).toBeNull()
|
||||
expect(release!.payload.last_run_warning).toContain('2026-07-06 misslyckades')
|
||||
expect(release!.payload.last_run_warning).toContain('VAT rate 25% not allowed')
|
||||
})
|
||||
|
||||
it('writes a skip warning when rolling a stale schedule forward', async () => {
|
||||
vi.setSystemTime(new Date('2026-07-06T08:30:00Z'))
|
||||
enqueue({ data: [makeSchedule({ next_run_date: '2026-07-05', day_of_month: 5 })], error: null })
|
||||
// Roll-forward update.
|
||||
enqueue({ data: null, error: null })
|
||||
|
||||
const { body } = await parseJsonResponse<CronBody>(await GET(req()))
|
||||
expect(body.results[0].skipReason).toBe('stale_rolled_forward')
|
||||
|
||||
const roll = updatePayloads.find((u) => u.table === 'recurring_invoice_schedules')
|
||||
expect(roll).toBeDefined()
|
||||
expect(roll!.payload.next_run_date).toBe('2026-08-05')
|
||||
expect(roll!.payload.last_run_warning).toContain('Ingen faktura skapades den 2026-07-05')
|
||||
expect(roll!.payload.last_run_warning).toContain('2026-08-05')
|
||||
})
|
||||
|
||||
it('skips a schedule that already ran earlier today', async () => {
|
||||
vi.setSystemTime(new Date('2026-07-06T08:30:00Z'))
|
||||
enqueue({
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
computeInitialRunDate,
|
||||
getStockholmDateHour,
|
||||
} from '@/lib/invoices/recurring-schedule-service'
|
||||
import { isSandboxCompany } from '@/lib/sandbox/guard'
|
||||
import type {
|
||||
RecurringInvoiceSchedule,
|
||||
RecurringInvoiceScheduleItem,
|
||||
@@ -89,9 +90,16 @@ export const GET = withCronContext('cron.recurring_invoices', async (_request, c
|
||||
// its next date rather than firing a stale one immediately.
|
||||
if (schedule.next_run_date < todayStockholm) {
|
||||
const rolledNext = computeInitialRunDate(stockholmToday, schedule.day_of_month)
|
||||
// Surface the skip on the schedule: a day of failed runs (or a cron
|
||||
// outage) would otherwise roll the month forward with no user-visible
|
||||
// trace. The next successful run overwrites this, and a conscious
|
||||
// reactivation clears it (PATCH route).
|
||||
const { error: rollError } = await supabase
|
||||
.from('recurring_invoice_schedules')
|
||||
.update({ next_run_date: rolledNext })
|
||||
.update({
|
||||
next_run_date: rolledNext,
|
||||
last_run_warning: `Ingen faktura skapades den ${schedule.next_run_date}. Nästa körning: ${rolledNext}. Använd "Skapa faktura nu" om månadens faktura fortfarande behövs.`,
|
||||
})
|
||||
.eq('id', schedule.id)
|
||||
.eq('company_id', schedule.company_id)
|
||||
if (rollError) {
|
||||
@@ -169,14 +177,31 @@ export const GET = withCronContext('cron.recurring_invoices', async (_request, c
|
||||
|
||||
// 5. Spawn the invoice. If it throws after we claimed, release the claim
|
||||
// (restore the prior last_run_at) so a later cron retries today rather
|
||||
// than treating the row as already run.
|
||||
// than treating the row as already run, and persist the failure as a
|
||||
// user-visible warning: a deterministic error (bad VAT rate, missing
|
||||
// items) fails every hourly retry and would otherwise skip the month
|
||||
// silently via the stale roll-forward above. A later successful run
|
||||
// overwrites the warning.
|
||||
// Defence in depth (ASVS V2.3): the email chokepoint inside the schedule
|
||||
// service enforces the sandbox rule on its own; the route additionally
|
||||
// resolves it here and passes an explicit suppress flag, so the invariant
|
||||
// does not hinge on a single check buried in a library function. The
|
||||
// invoice is still generated as a draft (freeze-and-retain).
|
||||
const suppressAutoSend = schedule.auto_send
|
||||
? await isSandboxCompany(supabase, schedule.company_id)
|
||||
: false
|
||||
|
||||
let result: Awaited<ReturnType<typeof executeRecurringSchedule>>
|
||||
try {
|
||||
result = await executeRecurringSchedule(supabase, schedule, now)
|
||||
result = await executeRecurringSchedule(supabase, schedule, now, { suppressAutoSend })
|
||||
} catch (err) {
|
||||
const reason = (err instanceof Error ? err.message : String(err)).slice(0, 300)
|
||||
await supabase
|
||||
.from('recurring_invoice_schedules')
|
||||
.update({ last_run_at: schedule.last_run_at })
|
||||
.update({
|
||||
last_run_at: schedule.last_run_at,
|
||||
last_run_warning: `Körningen ${todayStockholm} misslyckades: ${reason}. Nytt försök görs automatiskt varje timme idag.`,
|
||||
})
|
||||
.eq('id', schedule.id)
|
||||
.eq('company_id', schedule.company_id)
|
||||
.eq('last_run_at', claimTs)
|
||||
|
||||
@@ -67,8 +67,7 @@ describe('POST /api/salary/employees/[id]/absence', () => {
|
||||
|
||||
it('upserts an absence day (happy path)', async () => {
|
||||
enqueue({ data: { id: 'emp-1' } }) // loadEmployee
|
||||
enqueue({ data: null }) // delete existing
|
||||
enqueue({ data: { id: 'abs-1', absence_date: '2026-07-01', absence_type: 'sick', hours: 8 } }) // insert
|
||||
enqueue({ data: { id: 'abs-1', absence_date: '2026-07-01', absence_type: 'sick', hours: 8 } }) // upsert
|
||||
|
||||
const response = await POST(post({ absence_date: '2026-07-01', absence_type: 'sick', hours: 8 }), params)
|
||||
const { status, body } = await parseJsonResponse<{ data: { id: string } }>(response)
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { z } from 'zod'
|
||||
import type { SupabaseClient } from '@supabase/supabase-js'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { ensureInitialized } from '@/lib/init'
|
||||
import { withRouteContext } from '@/lib/api/with-route-context'
|
||||
@@ -9,23 +8,22 @@ import {
|
||||
AbsenceRangeQuerySchema,
|
||||
AbsenceTypeSchema,
|
||||
} from '@/lib/api/schemas'
|
||||
import {
|
||||
listAbsenceDays,
|
||||
upsertAbsenceDay,
|
||||
deleteAbsenceRange,
|
||||
} from '@/lib/salary/absence'
|
||||
import { getErrorEntry } from '@/lib/errors/structured-errors'
|
||||
|
||||
const isoDate = z.string().regex(/^\d{4}-\d{2}-\d{2}$/)
|
||||
|
||||
ensureInitialized()
|
||||
|
||||
async function loadEmployee(
|
||||
supabase: SupabaseClient,
|
||||
employeeId: string,
|
||||
companyId: string,
|
||||
) {
|
||||
const { data } = await supabase
|
||||
.from('employees')
|
||||
.select('id')
|
||||
.eq('id', employeeId)
|
||||
.eq('company_id', companyId)
|
||||
.maybeSingle()
|
||||
return data
|
||||
function errorResponse(code: string, details?: Record<string, unknown>): NextResponse {
|
||||
const entry = getErrorEntry(code)
|
||||
const message =
|
||||
(details?.message as string | undefined) ?? entry?.message_sv ?? 'Något gick fel'
|
||||
return NextResponse.json({ error: message, code }, { status: entry?.httpStatus ?? 500 })
|
||||
}
|
||||
|
||||
export const GET = withRouteContext<{ params: Promise<{ id: string }> }>(
|
||||
@@ -33,28 +31,18 @@ export const GET = withRouteContext<{ params: Promise<{ id: string }> }>(
|
||||
async (request, { supabase, companyId }, { params }) => {
|
||||
const { id: employeeId } = await params
|
||||
|
||||
const employee = await loadEmployee(supabase, employeeId, companyId)
|
||||
if (!employee) {
|
||||
return NextResponse.json({ error: 'Anställd hittades inte' }, { status: 404 })
|
||||
}
|
||||
|
||||
const query = validateQuery(request, AbsenceRangeQuerySchema)
|
||||
if (!query.success) return query.response
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('salary_absence_days')
|
||||
.select('id, absence_date, absence_type, hours, notes, salary_run_employee_id, created_at, updated_at')
|
||||
.eq('company_id', companyId)
|
||||
.eq('employee_id', employeeId)
|
||||
.gte('absence_date', query.data.from)
|
||||
.lte('absence_date', query.data.to)
|
||||
.order('absence_date', { ascending: true })
|
||||
const result = await listAbsenceDays(supabase, {
|
||||
companyId,
|
||||
employeeId,
|
||||
from: query.data.from,
|
||||
to: query.data.to,
|
||||
})
|
||||
|
||||
if (error) {
|
||||
return NextResponse.json({ error: error.message }, { status: 500 })
|
||||
}
|
||||
|
||||
return NextResponse.json({ data })
|
||||
if (!result.ok) return errorResponse(result.code, result.details)
|
||||
return NextResponse.json({ data: result.data })
|
||||
},
|
||||
)
|
||||
|
||||
@@ -63,56 +51,24 @@ export const POST = withRouteContext<{ params: Promise<{ id: string }> }>(
|
||||
async (request, { supabase, companyId }, { params }) => {
|
||||
const { id: employeeId } = await params
|
||||
|
||||
const employee = await loadEmployee(supabase, employeeId, companyId)
|
||||
if (!employee) {
|
||||
return NextResponse.json({ error: 'Anställd hittades inte' }, { status: 404 })
|
||||
}
|
||||
|
||||
const validation = await validateBody(request, UpsertAbsenceDaySchema)
|
||||
if (!validation.success) return validation.response
|
||||
const body = validation.data
|
||||
|
||||
// Upsert via DELETE+INSERT on the natural key (employee, date, type) so the
|
||||
// notes/hours/run-link can be replaced cleanly. The unique index makes ON
|
||||
// CONFLICT viable too, but Supabase's typed client doesn't expose
|
||||
// onConflict for our composite key without a named constraint name:
|
||||
// delete-then-insert keeps the pattern consistent with token-store.ts.
|
||||
const { error: deleteError } = await supabase
|
||||
.from('salary_absence_days')
|
||||
.delete()
|
||||
.eq('company_id', companyId)
|
||||
.eq('employee_id', employeeId)
|
||||
.eq('absence_date', body.absence_date)
|
||||
.eq('absence_type', body.absence_type)
|
||||
|
||||
if (deleteError) {
|
||||
return NextResponse.json({ error: deleteError.message }, { status: 500 })
|
||||
}
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('salary_absence_days')
|
||||
.insert({
|
||||
company_id: companyId,
|
||||
employee_id: employeeId,
|
||||
const result = await upsertAbsenceDay(supabase, {
|
||||
companyId,
|
||||
employeeId,
|
||||
day: {
|
||||
absence_date: body.absence_date,
|
||||
absence_type: body.absence_type,
|
||||
hours: body.hours,
|
||||
notes: body.notes ?? null,
|
||||
salary_run_employee_id: body.salary_run_employee_id ?? null,
|
||||
})
|
||||
.select()
|
||||
.single()
|
||||
},
|
||||
})
|
||||
|
||||
if (error) {
|
||||
// The 24h cap trigger raises check_violation when worked + absence > 24h
|
||||
// for the same date. Surface a clean 409 with the Swedish message.
|
||||
if (error.message?.includes('Total tid') || error.code === '23514') {
|
||||
return NextResponse.json({ error: error.message }, { status: 409 })
|
||||
}
|
||||
return NextResponse.json({ error: error.message }, { status: 500 })
|
||||
}
|
||||
|
||||
return NextResponse.json({ data }, { status: 201 })
|
||||
if (!result.ok) return errorResponse(result.code, result.details)
|
||||
return NextResponse.json({ data: result.data }, { status: 201 })
|
||||
},
|
||||
{ requireWrite: true },
|
||||
)
|
||||
@@ -132,11 +88,6 @@ export const DELETE = withRouteContext<{ params: Promise<{ id: string }> }>(
|
||||
async (request, { supabase, companyId }, { params }) => {
|
||||
const { id: employeeId } = await params
|
||||
|
||||
const employee = await loadEmployee(supabase, employeeId, companyId)
|
||||
if (!employee) {
|
||||
return NextResponse.json({ error: 'Anställd hittades inte' }, { status: 404 })
|
||||
}
|
||||
|
||||
const query = validateQuery(request, DeleteQuerySchema)
|
||||
if (!query.success) return query.response
|
||||
const { date, type, from, to } = query.data
|
||||
@@ -151,26 +102,16 @@ export const DELETE = withRouteContext<{ params: Promise<{ id: string }> }>(
|
||||
)
|
||||
}
|
||||
|
||||
let q = supabase
|
||||
.from('salary_absence_days')
|
||||
.delete()
|
||||
.eq('company_id', companyId)
|
||||
.eq('employee_id', employeeId)
|
||||
const result = await deleteAbsenceRange(supabase, {
|
||||
companyId,
|
||||
employeeId,
|
||||
from: hasSingle ? date! : from!,
|
||||
to: hasSingle ? date! : to!,
|
||||
absenceType: type,
|
||||
})
|
||||
|
||||
if (hasSingle) {
|
||||
q = q.eq('absence_date', date!)
|
||||
if (type) q = q.eq('absence_type', type)
|
||||
} else {
|
||||
q = q.gte('absence_date', from!).lte('absence_date', to!)
|
||||
if (type) q = q.eq('absence_type', type)
|
||||
}
|
||||
|
||||
const { error } = await q
|
||||
if (error) {
|
||||
return NextResponse.json({ error: error.message }, { status: 500 })
|
||||
}
|
||||
|
||||
return NextResponse.json({ data: { ok: true } })
|
||||
if (!result.ok) return errorResponse(result.code, result.details)
|
||||
return NextResponse.json({ data: { ok: true, deleted_count: result.data.deleted_count } })
|
||||
},
|
||||
{ requireWrite: true },
|
||||
)
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { ensureInitialized } from '@/lib/init'
|
||||
import { withRouteContext } from '@/lib/api/with-route-context'
|
||||
import { validateBody } from '@/lib/api/validate'
|
||||
import { OpeningBalancesFieldsSchema } from '@/lib/api/schemas'
|
||||
import { getOpeningBalances, setOpeningBalancesBulk } from '@/lib/salary/opening-balances'
|
||||
import { getErrorEntry } from '@/lib/errors/structured-errors'
|
||||
|
||||
ensureInitialized()
|
||||
|
||||
function errorResponse(code: string, message?: string): NextResponse {
|
||||
const entry = getErrorEntry(code)
|
||||
return NextResponse.json(
|
||||
{ error: message ?? entry?.message_sv ?? 'Något gick fel', code },
|
||||
{ status: entry?.httpStatus ?? 500 },
|
||||
)
|
||||
}
|
||||
|
||||
/** Cutover opening balances for one employee (dashboard surface; the v1
|
||||
* routes and the MCP tool share the same lib/salary/opening-balances
|
||||
* service). Returns { data: null } when nothing is set: the form treats
|
||||
* that as an empty editable state, unlike v1's 404. */
|
||||
export const GET = withRouteContext<{ params: Promise<{ id: string }> }>(
|
||||
'salary.employees.opening-balances.get',
|
||||
async (_request, { supabase, companyId }, { params }) => {
|
||||
const { id: employeeId } = await params
|
||||
|
||||
const result = await getOpeningBalances(supabase, { companyId, employeeId })
|
||||
if (!result.ok) return errorResponse(result.code)
|
||||
return NextResponse.json({ data: result.data })
|
||||
},
|
||||
)
|
||||
|
||||
export const PUT = withRouteContext<{ params: Promise<{ id: string }> }>(
|
||||
'salary.employees.opening-balances.set',
|
||||
async (request, { supabase, companyId, user }, { params }) => {
|
||||
const { id: employeeId } = await params
|
||||
|
||||
const validation = await validateBody(request, OpeningBalancesFieldsSchema)
|
||||
if (!validation.success) return validation.response
|
||||
|
||||
const result = await setOpeningBalancesBulk(supabase, {
|
||||
companyId,
|
||||
userId: user.id,
|
||||
items: [{ employee_id: employeeId, ...validation.data }],
|
||||
})
|
||||
|
||||
if (!result.ok) {
|
||||
const itemError = result.itemErrors?.[0]
|
||||
return errorResponse(itemError?.code ?? result.code, itemError?.message)
|
||||
}
|
||||
|
||||
return NextResponse.json({ data: result.data.rows[0] })
|
||||
},
|
||||
{ requireWrite: true },
|
||||
)
|
||||
@@ -74,6 +74,8 @@ export const POST = withRouteContext('salary.employees.create', async (request,
|
||||
employment_start: body.employment_start,
|
||||
employment_end: body.employment_end || null,
|
||||
employment_degree: body.employment_degree,
|
||||
hours_per_week: body.hours_per_week,
|
||||
workdays_per_week: body.workdays_per_week,
|
||||
salary_type: body.salary_type,
|
||||
monthly_salary: body.monthly_salary || null,
|
||||
hourly_rate: body.hourly_rate || null,
|
||||
@@ -95,6 +97,9 @@ export const POST = withRouteContext('salary.employees.create', async (request,
|
||||
vaxa_stod_eligible: body.vaxa_stod_eligible,
|
||||
vaxa_stod_start: body.vaxa_stod_start || null,
|
||||
vaxa_stod_end: body.vaxa_stod_end || null,
|
||||
jamkning_percentage: body.jamkning_percentage ?? null,
|
||||
jamkning_valid_from: body.jamkning_valid_from ?? null,
|
||||
jamkning_valid_to: body.jamkning_valid_to ?? null,
|
||||
// Dimensions PR8: bag for the employee's P&L cost lines at booking.
|
||||
default_dimensions: body.default_dimensions ?? {},
|
||||
})
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { ensureInitialized } from '@/lib/init'
|
||||
import { createSalaryRunEntries } from '@/lib/salary/salary-entries'
|
||||
import { syncVacationLedgerForEmployees } from '@/lib/salary/vacation-ledger'
|
||||
import { eventBus } from '@/lib/events'
|
||||
import { withRouteContext } from '@/lib/api/with-route-context'
|
||||
import { errorResponse, errorResponseFromCode } from '@/lib/errors/get-structured-error'
|
||||
@@ -74,6 +75,17 @@ export const POST = withRouteContext(
|
||||
payload: { salaryRunId: id, entryIds: [], userId: user.id, companyId: companyId! },
|
||||
})
|
||||
|
||||
// Vacation ledger sync (non-fatal: the ledger recomputes and self-heals
|
||||
// on the next booking; a sync bug must never block a booking).
|
||||
const nollSync = await syncVacationLedgerForEmployees(
|
||||
supabase,
|
||||
companyId!,
|
||||
roster.map((sre) => sre.employee_id),
|
||||
)
|
||||
if (!nollSync.ok) {
|
||||
opLog.warn('vacation ledger sync failed after nollkörning booking', { message: nollSync.message })
|
||||
}
|
||||
|
||||
opLog.info('salary run booked as nollkörning (no journal entries)', { salaryRunId: id })
|
||||
|
||||
return NextResponse.json({ data: bookedRun })
|
||||
@@ -154,6 +166,16 @@ export const POST = withRouteContext(
|
||||
payload: { salaryRunId: id, entryIds, userId: user.id, companyId: companyId! },
|
||||
})
|
||||
|
||||
// Vacation ledger sync (non-fatal, see the nollkörning branch).
|
||||
const ledgerSync = await syncVacationLedgerForEmployees(
|
||||
supabase,
|
||||
companyId!,
|
||||
roster.map((sre) => sre.employee_id),
|
||||
)
|
||||
if (!ledgerSync.ok) {
|
||||
opLog.warn('vacation ledger sync failed after booking', { message: ledgerSync.message })
|
||||
}
|
||||
|
||||
return NextResponse.json({ data: bookedRun })
|
||||
} catch (err) {
|
||||
if (isBookkeepingError(err)) {
|
||||
|
||||
@@ -4,6 +4,7 @@ import { withRouteContext } from '@/lib/api/with-route-context'
|
||||
import { reverseEntry } from '@/lib/bookkeeping/engine'
|
||||
import { bookkeepingErrorResponse, EntryAlreadyReversedError } from '@/lib/bookkeeping/errors'
|
||||
import { revokeLinksForRun } from '@/lib/salary/payslips/links'
|
||||
import { syncVacationLedgerForEmployees } from '@/lib/salary/vacation-ledger'
|
||||
|
||||
ensureInitialized()
|
||||
|
||||
@@ -26,7 +27,7 @@ export const POST = withRouteContext<{ params: Promise<{ id: string }> }>(
|
||||
'salary.run.correct',
|
||||
async (_request, ctx, { params }) => {
|
||||
const { id } = await params
|
||||
const { user, supabase, companyId } = ctx
|
||||
const { user, supabase, companyId, log } = ctx
|
||||
|
||||
// Load the original booked run
|
||||
const { data: originalRun, error: runError } = await supabase
|
||||
@@ -156,6 +157,18 @@ export const POST = withRouteContext<{ params: Promise<{ id: string }> }>(
|
||||
}
|
||||
}
|
||||
|
||||
// Vacation ledger sync: the original run flipped to 'corrected', so its
|
||||
// vacation_days_taken drop out of the recomputed taken_days. Non-fatal:
|
||||
// the ledger self-heals when the correction run books.
|
||||
const ledgerSync = await syncVacationLedgerForEmployees(
|
||||
supabase,
|
||||
companyId,
|
||||
(originalEmployees || []).map((sre) => sre.employee_id as string),
|
||||
)
|
||||
if (!ledgerSync.ok) {
|
||||
log.warn('vacation ledger sync failed after correction', { message: ledgerSync.message })
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
data: correctionRun,
|
||||
message: 'Korrigeringskörning skapad. Originalverifikationer har makulerats (storno). Redigera och beräkna om den nya körningen.',
|
||||
|
||||
@@ -4,6 +4,8 @@ import { withRouteContext } from '@/lib/api/with-route-context'
|
||||
import { validateBody } from '@/lib/api/validate'
|
||||
import { SalaryEmployeeOverrideSchema } from '@/lib/api/schemas'
|
||||
import { decryptPersonnummer, maskPersonnummer } from '@/lib/salary/personnummer'
|
||||
import { removeEmployeeFromRun } from '@/lib/salary/run-employees'
|
||||
import { getErrorEntry } from '@/lib/errors/structured-errors'
|
||||
|
||||
ensureInitialized()
|
||||
|
||||
@@ -189,27 +191,18 @@ export const DELETE = withRouteContext<{ params: Promise<{ id: string; employeeI
|
||||
const { id, employeeId } = await params
|
||||
const { supabase, companyId } = ctx
|
||||
|
||||
// Verify run is draft
|
||||
const { data: run } = await supabase
|
||||
.from('salary_runs')
|
||||
.select('id, status')
|
||||
.eq('id', id)
|
||||
.eq('company_id', companyId)
|
||||
.single()
|
||||
const result = await removeEmployeeFromRun(supabase, {
|
||||
companyId,
|
||||
salaryRunId: id,
|
||||
employeeId,
|
||||
})
|
||||
|
||||
if (!run) return NextResponse.json({ error: 'Lönekörning hittades inte' }, { status: 404 })
|
||||
if (run.status !== 'draft') return NextResponse.json({ error: 'Kan bara redigera utkast' }, { status: 400 })
|
||||
|
||||
// Delete the salary_run_employee (cascades to salary_line_items via ON DELETE CASCADE)
|
||||
const { error } = await supabase
|
||||
.from('salary_run_employees')
|
||||
.delete()
|
||||
.eq('salary_run_id', id)
|
||||
.eq('employee_id', employeeId)
|
||||
.eq('company_id', companyId)
|
||||
|
||||
if (error) {
|
||||
return NextResponse.json({ error: error.message }, { status: 500 })
|
||||
if (!result.ok) {
|
||||
const entry = getErrorEntry(result.code)
|
||||
return NextResponse.json(
|
||||
{ error: entry?.message_sv ?? 'Något gick fel', code: result.code },
|
||||
{ status: entry?.httpStatus ?? 500 },
|
||||
)
|
||||
}
|
||||
|
||||
return NextResponse.json({ data: { deleted: true } })
|
||||
|
||||
@@ -3,8 +3,8 @@ import { ensureInitialized } from '@/lib/init'
|
||||
import { withRouteContext } from '@/lib/api/with-route-context'
|
||||
import { validateBody } from '@/lib/api/validate'
|
||||
import { AddEmployeeToRunSchema } from '@/lib/api/schemas'
|
||||
import { getLineItemAccount } from '@/lib/salary/account-mapping'
|
||||
import type { SalaryLineItemType } from '@/types'
|
||||
import { addEmployeeToRun } from '@/lib/salary/run-employees'
|
||||
import { getErrorEntry } from '@/lib/errors/structured-errors'
|
||||
|
||||
ensureInitialized()
|
||||
|
||||
@@ -18,94 +18,22 @@ export const POST = withRouteContext<{ params: Promise<{ id: string }> }>(
|
||||
if (!validation.success) return validation.response
|
||||
const body = validation.data
|
||||
|
||||
// Verify run is draft
|
||||
const { data: run, error: runError } = await supabase
|
||||
.from('salary_runs')
|
||||
.select('id, status')
|
||||
.eq('id', id)
|
||||
.eq('company_id', companyId)
|
||||
.single()
|
||||
const result = await addEmployeeToRun(supabase, {
|
||||
companyId,
|
||||
salaryRunId: id,
|
||||
employeeId: body.employee_id,
|
||||
hoursWorked: body.hours_worked ?? null,
|
||||
})
|
||||
|
||||
if (runError || !run) {
|
||||
return NextResponse.json({ error: 'Lönekörning hittades inte' }, { status: 404 })
|
||||
}
|
||||
if (run.status !== 'draft') {
|
||||
return NextResponse.json({ error: 'Kan bara lägga till anställda i utkast' }, { status: 400 })
|
||||
if (!result.ok) {
|
||||
const entry = getErrorEntry(result.code)
|
||||
return NextResponse.json(
|
||||
{ error: entry?.message_sv ?? 'Något gick fel', code: result.code },
|
||||
{ status: entry?.httpStatus ?? 500 },
|
||||
)
|
||||
}
|
||||
|
||||
// Verify employee exists and is active
|
||||
const { data: employee, error: empError } = await supabase
|
||||
.from('employees')
|
||||
.select('*')
|
||||
.eq('id', body.employee_id)
|
||||
.eq('company_id', companyId)
|
||||
.eq('is_active', true)
|
||||
.single()
|
||||
|
||||
if (empError || !employee) {
|
||||
return NextResponse.json({ error: 'Anställd hittades inte' }, { status: 404 })
|
||||
}
|
||||
|
||||
// Check if already added
|
||||
const { data: existing } = await supabase
|
||||
.from('salary_run_employees')
|
||||
.select('id')
|
||||
.eq('salary_run_id', id)
|
||||
.eq('employee_id', body.employee_id)
|
||||
.single()
|
||||
|
||||
if (existing) {
|
||||
return NextResponse.json({ error: 'Anställd redan tillagd i denna lönekörning' }, { status: 409 })
|
||||
}
|
||||
|
||||
// Snapshot employee data
|
||||
const { data: sre, error: sreError } = await supabase
|
||||
.from('salary_run_employees')
|
||||
.insert({
|
||||
salary_run_id: id,
|
||||
employee_id: employee.id,
|
||||
company_id: companyId,
|
||||
employment_degree: employee.employment_degree,
|
||||
monthly_salary: employee.monthly_salary || 0,
|
||||
salary_type: employee.salary_type,
|
||||
hours_worked: body.hours_worked || null,
|
||||
tax_table_number: employee.tax_table_number,
|
||||
tax_column: employee.tax_column,
|
||||
})
|
||||
.select()
|
||||
.single()
|
||||
|
||||
if (sreError) {
|
||||
return NextResponse.json({ error: sreError.message }, { status: 500 })
|
||||
}
|
||||
|
||||
// Auto-create base salary line item
|
||||
const baseSalaryType: SalaryLineItemType = employee.salary_type === 'monthly' ? 'monthly_salary' : 'hourly_salary'
|
||||
let baseAmount: number
|
||||
if (employee.salary_type === 'monthly') {
|
||||
baseAmount = Math.round((employee.monthly_salary || 0) * (employee.employment_degree / 100) * 100) / 100
|
||||
} else {
|
||||
baseAmount = Math.round((employee.hourly_rate || 0) * (body.hours_worked || 0) * 100) / 100
|
||||
}
|
||||
|
||||
await supabase
|
||||
.from('salary_line_items')
|
||||
.insert({
|
||||
salary_run_employee_id: sre.id,
|
||||
company_id: companyId,
|
||||
item_type: baseSalaryType,
|
||||
description: employee.salary_type === 'monthly' ? 'Grundlön' : 'Timlön',
|
||||
quantity: employee.salary_type === 'hourly' ? body.hours_worked : null,
|
||||
unit_price: employee.salary_type === 'hourly' ? employee.hourly_rate : null,
|
||||
amount: baseAmount,
|
||||
is_taxable: true,
|
||||
is_avgift_basis: true,
|
||||
is_vacation_basis: true,
|
||||
account_number: getLineItemAccount(baseSalaryType, employee.employment_type),
|
||||
sort_order: 0,
|
||||
})
|
||||
|
||||
return NextResponse.json({ data: sre }, { status: 201 })
|
||||
return NextResponse.json({ data: result.data }, { status: 201 })
|
||||
},
|
||||
{ requireWrite: true },
|
||||
)
|
||||
|
||||
@@ -71,6 +71,9 @@ describe('PATCH /api/salary/runs/[id]/lines/[lineId]', () => {
|
||||
const { enqueueMany } = authed()
|
||||
enqueueMany([
|
||||
{ data: { id: 'run-1', status: 'draft' } }, // salary_runs lookup
|
||||
// The shared service verifies the line belongs to this run before
|
||||
// writing (loadLineInRun join check).
|
||||
{ data: { id: 'line-1', amount: 50, salary_run_employee: { salary_run_id: 'run-1' } } },
|
||||
{ data: { id: 'line-1', amount: 100 } }, // update returning
|
||||
])
|
||||
const response = await PATCH(
|
||||
@@ -119,6 +122,8 @@ describe('DELETE /api/salary/runs/[id]/lines/[lineId]', () => {
|
||||
const { enqueueMany } = authed()
|
||||
enqueueMany([
|
||||
{ data: { id: 'run-1', status: 'draft' } }, // salary_runs lookup
|
||||
// Run-membership verification added by the shared service.
|
||||
{ data: { id: 'line-1', amount: 50, salary_run_employee: { salary_run_id: 'run-1' } } },
|
||||
{ data: null }, // delete (error null)
|
||||
])
|
||||
const response = await DELETE(
|
||||
|
||||
@@ -3,49 +3,37 @@ import { ensureInitialized } from '@/lib/init'
|
||||
import { withRouteContext } from '@/lib/api/with-route-context'
|
||||
import { validateBody } from '@/lib/api/validate'
|
||||
import { UpdateSalaryLineItemSchema } from '@/lib/api/schemas'
|
||||
import { updatePayslipLine, deletePayslipLine } from '@/lib/salary/payslip-lines'
|
||||
import { getErrorEntry } from '@/lib/errors/structured-errors'
|
||||
|
||||
ensureInitialized()
|
||||
|
||||
function errorResponse(code: string): NextResponse {
|
||||
const entry = getErrorEntry(code)
|
||||
return NextResponse.json(
|
||||
{ error: entry?.message_sv ?? 'Något gick fel', code },
|
||||
{ status: entry?.httpStatus ?? 500 },
|
||||
)
|
||||
}
|
||||
|
||||
export const PATCH = withRouteContext<{ params: Promise<{ id: string; lineId: string }> }>(
|
||||
'salary.run.line.update',
|
||||
async (request, ctx, { params }) => {
|
||||
const { id, lineId } = await params
|
||||
const { supabase, companyId } = ctx
|
||||
|
||||
// Verify run is draft
|
||||
const { data: run } = await supabase
|
||||
.from('salary_runs')
|
||||
.select('id, status')
|
||||
.eq('id', id)
|
||||
.eq('company_id', companyId)
|
||||
.single()
|
||||
|
||||
if (!run) return NextResponse.json({ error: 'Lönekörning hittades inte' }, { status: 404 })
|
||||
if (run.status !== 'draft') return NextResponse.json({ error: 'Kan bara redigera utkast' }, { status: 400 })
|
||||
|
||||
const validation = await validateBody(request, UpdateSalaryLineItemSchema)
|
||||
if (!validation.success) return validation.response
|
||||
const body = validation.data
|
||||
|
||||
// Round amount if provided
|
||||
const updates = { ...body }
|
||||
if (updates.amount !== undefined) {
|
||||
updates.amount = Math.round(updates.amount * 100) / 100
|
||||
}
|
||||
const result = await updatePayslipLine(supabase, {
|
||||
companyId,
|
||||
salaryRunId: id,
|
||||
lineId,
|
||||
patch: validation.data,
|
||||
})
|
||||
|
||||
const { data: updated, error } = await supabase
|
||||
.from('salary_line_items')
|
||||
.update(updates)
|
||||
.eq('id', lineId)
|
||||
.eq('company_id', companyId)
|
||||
.select()
|
||||
.single()
|
||||
|
||||
if (error || !updated) {
|
||||
return NextResponse.json({ error: 'Rad hittades inte' }, { status: 404 })
|
||||
}
|
||||
|
||||
return NextResponse.json({ data: updated })
|
||||
if (!result.ok) return errorResponse(result.code)
|
||||
return NextResponse.json({ data: result.data })
|
||||
},
|
||||
{ requireWrite: true },
|
||||
)
|
||||
@@ -56,27 +44,13 @@ export const DELETE = withRouteContext<{ params: Promise<{ id: string; lineId: s
|
||||
const { id, lineId } = await params
|
||||
const { supabase, companyId } = ctx
|
||||
|
||||
// Verify run is draft
|
||||
const { data: run } = await supabase
|
||||
.from('salary_runs')
|
||||
.select('id, status')
|
||||
.eq('id', id)
|
||||
.eq('company_id', companyId)
|
||||
.single()
|
||||
|
||||
if (!run) return NextResponse.json({ error: 'Lönekörning hittades inte' }, { status: 404 })
|
||||
if (run.status !== 'draft') return NextResponse.json({ error: 'Kan bara redigera utkast' }, { status: 400 })
|
||||
|
||||
const { error } = await supabase
|
||||
.from('salary_line_items')
|
||||
.delete()
|
||||
.eq('id', lineId)
|
||||
.eq('company_id', companyId)
|
||||
|
||||
if (error) {
|
||||
return NextResponse.json({ error: error.message }, { status: 500 })
|
||||
}
|
||||
const result = await deletePayslipLine(supabase, {
|
||||
companyId,
|
||||
salaryRunId: id,
|
||||
lineId,
|
||||
})
|
||||
|
||||
if (!result.ok) return errorResponse(result.code)
|
||||
return NextResponse.json({ data: { deleted: true } })
|
||||
},
|
||||
{ requireWrite: true },
|
||||
|
||||
@@ -3,7 +3,8 @@ import { ensureInitialized } from '@/lib/init'
|
||||
import { withRouteContext } from '@/lib/api/with-route-context'
|
||||
import { validateBody } from '@/lib/api/validate'
|
||||
import { CreateSalaryLineItemSchema } from '@/lib/api/schemas'
|
||||
import { getLineItemAccount } from '@/lib/salary/account-mapping'
|
||||
import { createPayslipLine } from '@/lib/salary/payslip-lines'
|
||||
import { getErrorEntry } from '@/lib/errors/structured-errors'
|
||||
|
||||
ensureInitialized()
|
||||
|
||||
@@ -15,64 +16,24 @@ export const POST = withRouteContext<{ params: Promise<{ id: string }> }>(
|
||||
|
||||
const validation = await validateBody(request, CreateSalaryLineItemSchema)
|
||||
if (!validation.success) return validation.response
|
||||
const body = validation.data
|
||||
const { salary_run_employee_id, ...input } = validation.data
|
||||
|
||||
// Verify run is draft
|
||||
const { data: run } = await supabase
|
||||
.from('salary_runs')
|
||||
.select('id, status')
|
||||
.eq('id', id)
|
||||
.eq('company_id', companyId)
|
||||
.single()
|
||||
const result = await createPayslipLine(supabase, {
|
||||
companyId,
|
||||
salaryRunId: id,
|
||||
target: { salaryRunEmployeeId: salary_run_employee_id },
|
||||
input,
|
||||
})
|
||||
|
||||
if (!run) {
|
||||
return NextResponse.json({ error: 'Lönekörning hittades inte' }, { status: 404 })
|
||||
}
|
||||
if (run.status !== 'draft') {
|
||||
return NextResponse.json({ error: 'Kan bara redigera utkast' }, { status: 400 })
|
||||
if (!result.ok) {
|
||||
const entry = getErrorEntry(result.code)
|
||||
return NextResponse.json(
|
||||
{ error: entry?.message_sv ?? 'Något gick fel', code: result.code },
|
||||
{ status: entry?.httpStatus ?? 500 },
|
||||
)
|
||||
}
|
||||
|
||||
// Verify salary_run_employee belongs to this run
|
||||
const { data: sre } = await supabase
|
||||
.from('salary_run_employees')
|
||||
.select('id, employee_id')
|
||||
.eq('id', body.salary_run_employee_id)
|
||||
.eq('salary_run_id', id)
|
||||
.single()
|
||||
|
||||
if (!sre) {
|
||||
return NextResponse.json({ error: 'Anställd finns inte i denna lönekörning' }, { status: 404 })
|
||||
}
|
||||
|
||||
// Auto-resolve account if not provided
|
||||
const accountNumber = body.account_number || getLineItemAccount(body.item_type as never)
|
||||
|
||||
const { data: lineItem, error } = await supabase
|
||||
.from('salary_line_items')
|
||||
.insert({
|
||||
salary_run_employee_id: body.salary_run_employee_id,
|
||||
company_id: companyId,
|
||||
item_type: body.item_type,
|
||||
description: body.description,
|
||||
quantity: body.quantity || null,
|
||||
unit_price: body.unit_price || null,
|
||||
amount: Math.round(body.amount * 100) / 100,
|
||||
is_taxable: body.is_taxable,
|
||||
is_avgift_basis: body.is_avgift_basis,
|
||||
is_vacation_basis: body.is_vacation_basis,
|
||||
is_gross_deduction: body.is_gross_deduction,
|
||||
is_net_deduction: body.is_net_deduction,
|
||||
account_number: accountNumber,
|
||||
sort_order: body.sort_order,
|
||||
})
|
||||
.select()
|
||||
.single()
|
||||
|
||||
if (error) {
|
||||
return NextResponse.json({ error: error.message }, { status: 500 })
|
||||
}
|
||||
|
||||
return NextResponse.json({ data: lineItem }, { status: 201 })
|
||||
return NextResponse.json({ data: result.data }, { status: 201 })
|
||||
},
|
||||
{ requireWrite: true },
|
||||
)
|
||||
|
||||
@@ -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 },
|
||||
)
|
||||
@@ -83,4 +83,48 @@ describe('PUT /api/settings', () => {
|
||||
expect(status).toBe(200)
|
||||
expect(body.data.company_name).toBe('New Name')
|
||||
})
|
||||
|
||||
it('blocks a vacation-year basis change while open balances exist', async () => {
|
||||
enqueueMany([
|
||||
{ data: { salary_vacation_year_basis: 'calendar', onboarding_complete: true } }, // oldSettings
|
||||
{ data: null, count: 2 }, // open-rows count
|
||||
])
|
||||
|
||||
const request = createMockRequest('/api/settings', {
|
||||
method: 'PUT',
|
||||
body: { salary_vacation_year_basis: 'statutory_apr_mar' },
|
||||
})
|
||||
const response = await PUT(request, { params: Promise.resolve({}) })
|
||||
const { status } = await parseJsonResponse(response)
|
||||
|
||||
expect(status).toBe(400)
|
||||
// The guard consumed the count result and the update never ran.
|
||||
expect(supabase.from.mock.calls.map(([table]) => table)).toEqual([
|
||||
'company_settings',
|
||||
'employee_vacation_balances',
|
||||
])
|
||||
})
|
||||
|
||||
it('fails closed when the open-balances guard query errors', async () => {
|
||||
enqueueMany([
|
||||
{ data: { salary_vacation_year_basis: 'calendar', onboarding_complete: true } }, // oldSettings
|
||||
{ data: null, count: null, error: { message: 'connection reset' } }, // guard query fails
|
||||
])
|
||||
|
||||
const request = createMockRequest('/api/settings', {
|
||||
method: 'PUT',
|
||||
body: { salary_vacation_year_basis: 'statutory_apr_mar' },
|
||||
})
|
||||
const response = await PUT(request, { params: Promise.resolve({}) })
|
||||
const { status } = await parseJsonResponse(response)
|
||||
|
||||
expect(status).toBe(500)
|
||||
// The 500 must come from the guard, not from company_settings.update()
|
||||
// swallowing the queued error: the guard query ran and no second
|
||||
// company_settings query followed it.
|
||||
expect(supabase.from.mock.calls.map(([table]) => table)).toEqual([
|
||||
'company_settings',
|
||||
'employee_vacation_balances',
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -40,7 +40,7 @@ export const PUT = withRouteContext(
|
||||
// Fetch current settings to check for tax-relevant changes
|
||||
const { data: oldSettings } = await supabase
|
||||
.from('company_settings')
|
||||
.select('entity_type, moms_period, f_skatt, vat_registered, vat_number, pays_salaries, fiscal_year_start_month, onboarding_complete')
|
||||
.select('entity_type, moms_period, f_skatt, vat_registered, vat_number, pays_salaries, fiscal_year_start_month, onboarding_complete, salary_vacation_year_basis')
|
||||
.eq('company_id', companyId)
|
||||
.single()
|
||||
|
||||
@@ -65,6 +65,35 @@ export const PUT = withRouteContext(
|
||||
)
|
||||
}
|
||||
|
||||
// Vacation year basis (payroll gap-closure 3.1): changing the boundary
|
||||
// while OPEN vacation-ledger rows exist would orphan them (rows are keyed
|
||||
// by vacation_year_start). Close the current year first.
|
||||
if (
|
||||
body.salary_vacation_year_basis !== undefined &&
|
||||
body.salary_vacation_year_basis !==
|
||||
(oldSettings as Record<string, unknown> | null)?.salary_vacation_year_basis
|
||||
) {
|
||||
const { count: openRows, error: openRowsError } = await supabase
|
||||
.from('employee_vacation_balances')
|
||||
.select('id', { count: 'exact', head: true })
|
||||
.eq('company_id', companyId)
|
||||
.eq('status', 'open')
|
||||
// Fail closed: a failed check must not let the basis change through
|
||||
// and orphan open vacation-ledger rows.
|
||||
if (openRowsError) {
|
||||
return NextResponse.json({ error: openRowsError.message }, { status: 500 })
|
||||
}
|
||||
if ((openRows ?? 0) > 0) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error:
|
||||
'Semesterårets basis kan inte ändras medan öppna semestersaldon finns. Stäng semesteråret först.',
|
||||
},
|
||||
{ status: 400 },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Validate: VAT-registered must have VAT number (ML 11 kap. 8§) and moms period (SFL 26 kap.)
|
||||
const effectiveVatRegistered = body.vat_registered ?? oldSettings?.vat_registered
|
||||
if (effectiveVatRegistered === true) {
|
||||
|
||||
@@ -0,0 +1,405 @@
|
||||
/**
|
||||
* Tests for the v1 absence endpoints (payroll gap-closure 1.4).
|
||||
*
|
||||
* GET/PUT/DELETE /employees/{id}/absence. PUT is the first PUT route on v1:
|
||||
* the wrapper's REQUIRES_IDEMPOTENCY set was extended to include it, and the
|
||||
* test-key case below is the regression test for that hole (a test key on a
|
||||
* PUT must be forced into dry-run, never write through).
|
||||
*/
|
||||
|
||||
import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
beforeAll(() => {
|
||||
if (process.env.NODE_ENV !== 'test') {
|
||||
throw new Error(
|
||||
`absence route tests require NODE_ENV=test (got ${process.env.NODE_ENV ?? 'undefined'})`,
|
||||
)
|
||||
}
|
||||
process.env.NEXT_PUBLIC_SUPABASE_URL ||= 'http://localhost:54321'
|
||||
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY ||= 'test-anon-key'
|
||||
})
|
||||
|
||||
vi.mock('@/lib/auth/api-keys', async () => {
|
||||
const actual = await vi.importActual<typeof import('@/lib/auth/api-keys')>('@/lib/auth/api-keys')
|
||||
return {
|
||||
...actual,
|
||||
validateApiKey: vi.fn(),
|
||||
createServiceClientNoCookies: vi.fn(),
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@supabase/supabase-js', async () => {
|
||||
const actual = await vi.importActual<typeof import('@supabase/supabase-js')>('@supabase/supabase-js')
|
||||
return { ...actual, createClient: vi.fn().mockReturnValue({}) }
|
||||
})
|
||||
|
||||
import { validateApiKey, createServiceClientNoCookies } from '@/lib/auth/api-keys'
|
||||
import { GET as listAbsence, PUT as putAbsence, DELETE as deleteAbsence } from '../route'
|
||||
|
||||
const mockValidate = validateApiKey as ReturnType<typeof vi.fn>
|
||||
const mockServiceClient = createServiceClientNoCookies as ReturnType<typeof vi.fn>
|
||||
|
||||
interface TableResp {
|
||||
data?: unknown
|
||||
error?: unknown
|
||||
count?: number | null
|
||||
}
|
||||
|
||||
function makeFlexibleSupabase(byTable: Record<string, TableResp | TableResp[]>) {
|
||||
const queues = new Map<string, TableResp[]>()
|
||||
for (const [t, val] of Object.entries(byTable)) {
|
||||
queues.set(t, Array.isArray(val) ? [...val] : [val])
|
||||
}
|
||||
const tableCalls: string[] = []
|
||||
const buildChain = (table: string): unknown => {
|
||||
const handler: ProxyHandler<object> = {
|
||||
get(_target, prop) {
|
||||
if (prop === 'then') {
|
||||
return (resolve: (v: unknown) => void) => {
|
||||
const q = queues.get(table)
|
||||
const next = q && q.length > 1 ? q.shift()! : (q?.[0] ?? { data: null, error: null, count: null })
|
||||
resolve({ count: null, ...next })
|
||||
}
|
||||
}
|
||||
return (..._args: unknown[]) => buildChain(table)
|
||||
},
|
||||
}
|
||||
return new Proxy({}, handler)
|
||||
}
|
||||
return {
|
||||
tableCalls,
|
||||
from: vi.fn((table: string) => {
|
||||
tableCalls.push(table)
|
||||
return buildChain(table)
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
const COMPANY_ID = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa'
|
||||
const EMPLOYEE_ID = 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb'
|
||||
const USER_ID = 'user-1'
|
||||
|
||||
const SAMPLE_DAY = {
|
||||
id: 'eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeee',
|
||||
absence_date: '2026-03-03',
|
||||
absence_type: 'sick',
|
||||
hours: 8,
|
||||
notes: null,
|
||||
salary_run_employee_id: null,
|
||||
created_at: '2026-03-03T08:00:00Z',
|
||||
updated_at: '2026-03-03T08:00:00Z',
|
||||
}
|
||||
|
||||
function makeRequest(url: string, init?: RequestInit): Request {
|
||||
return new Request(url, {
|
||||
...init,
|
||||
headers: {
|
||||
Authorization: 'Bearer test-fixture-not-a-real-key',
|
||||
...(init?.headers ?? {}),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function absenceParams(companyId: string, id: string) {
|
||||
return { params: Promise.resolve({ companyId, id }) }
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockValidate.mockResolvedValue({
|
||||
userId: USER_ID,
|
||||
companyId: COMPANY_ID,
|
||||
apiKeyId: 'ak_1',
|
||||
apiKeyName: 'CI key',
|
||||
scopes: ['payroll:read', 'payroll:write'],
|
||||
mode: 'live',
|
||||
})
|
||||
})
|
||||
|
||||
describe('GET /api/v1/companies/:companyId/employees/:id/absence', () => {
|
||||
it('lists absence days with qualified ids', async () => {
|
||||
mockServiceClient.mockReturnValue(
|
||||
makeFlexibleSupabase({
|
||||
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
|
||||
employees: { data: { id: EMPLOYEE_ID }, error: null },
|
||||
salary_absence_days: { data: [SAMPLE_DAY], error: null },
|
||||
}),
|
||||
)
|
||||
|
||||
const res = await listAbsence(
|
||||
makeRequest(
|
||||
`https://x.test/api/v1/companies/${COMPANY_ID}/employees/${EMPLOYEE_ID}/absence?from=2026-03-01&to=2026-03-31`,
|
||||
),
|
||||
absenceParams(COMPANY_ID, EMPLOYEE_ID),
|
||||
)
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
const body = await res.json()
|
||||
expect(body.data).toHaveLength(1)
|
||||
expect(body.data[0].salary_absence_day_id).toBe(SAMPLE_DAY.id)
|
||||
expect(body.data[0].id).toBeUndefined()
|
||||
})
|
||||
|
||||
it('rejects a missing from/to with 400', async () => {
|
||||
mockServiceClient.mockReturnValue(
|
||||
makeFlexibleSupabase({
|
||||
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
|
||||
}),
|
||||
)
|
||||
const res = await listAbsence(
|
||||
makeRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/employees/${EMPLOYEE_ID}/absence`),
|
||||
absenceParams(COMPANY_ID, EMPLOYEE_ID),
|
||||
)
|
||||
expect(res.status).toBe(400)
|
||||
})
|
||||
|
||||
it('rejects a reversed range (from > to) with VALIDATION_ERROR', async () => {
|
||||
mockServiceClient.mockReturnValue(
|
||||
makeFlexibleSupabase({
|
||||
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
|
||||
}),
|
||||
)
|
||||
const res = await listAbsence(
|
||||
makeRequest(
|
||||
`https://x.test/api/v1/companies/${COMPANY_ID}/employees/${EMPLOYEE_ID}/absence?from=2026-03-31&to=2026-03-01`,
|
||||
),
|
||||
absenceParams(COMPANY_ID, EMPLOYEE_ID),
|
||||
)
|
||||
expect(res.status).toBe(400)
|
||||
const body = await res.json()
|
||||
expect(body.error.code).toBe('VALIDATION_ERROR')
|
||||
})
|
||||
|
||||
it('rejects a range beyond 92 days with ABSENCE_RANGE_TOO_LARGE', async () => {
|
||||
mockServiceClient.mockReturnValue(
|
||||
makeFlexibleSupabase({
|
||||
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
|
||||
}),
|
||||
)
|
||||
const res = await listAbsence(
|
||||
makeRequest(
|
||||
`https://x.test/api/v1/companies/${COMPANY_ID}/employees/${EMPLOYEE_ID}/absence?from=2026-01-01&to=2026-12-31`,
|
||||
),
|
||||
absenceParams(COMPANY_ID, EMPLOYEE_ID),
|
||||
)
|
||||
expect(res.status).toBe(400)
|
||||
const body = await res.json()
|
||||
expect(body.error.code).toBe('ABSENCE_RANGE_TOO_LARGE')
|
||||
})
|
||||
|
||||
it('returns 404 EMPLOYEE_NOT_FOUND for an unknown employee', async () => {
|
||||
mockServiceClient.mockReturnValue(
|
||||
makeFlexibleSupabase({
|
||||
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
|
||||
employees: { data: null, error: null },
|
||||
}),
|
||||
)
|
||||
const res = await listAbsence(
|
||||
makeRequest(
|
||||
`https://x.test/api/v1/companies/${COMPANY_ID}/employees/${EMPLOYEE_ID}/absence?from=2026-03-01&to=2026-03-31`,
|
||||
),
|
||||
absenceParams(COMPANY_ID, EMPLOYEE_ID),
|
||||
)
|
||||
expect(res.status).toBe(404)
|
||||
const body = await res.json()
|
||||
expect(body.error.code).toBe('EMPLOYEE_NOT_FOUND')
|
||||
})
|
||||
})
|
||||
|
||||
describe('PUT /api/v1/companies/:companyId/employees/:id/absence', () => {
|
||||
const validBody = { from: '2026-03-02', to: '2026-03-06', absence_type: 'sick' }
|
||||
|
||||
it('upserts the expanded range (happy path)', async () => {
|
||||
mockServiceClient.mockReturnValue(
|
||||
makeFlexibleSupabase({
|
||||
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
|
||||
employees: { data: { id: EMPLOYEE_ID }, error: null },
|
||||
salary_absence_days: [
|
||||
{ data: [SAMPLE_DAY, { ...SAMPLE_DAY, id: 'ffffffff-ffff-4fff-8fff-ffffffffffff', absence_date: '2026-03-04' }], error: null }, // bulk upsert
|
||||
],
|
||||
}),
|
||||
)
|
||||
|
||||
const res = await putAbsence(
|
||||
makeRequest(
|
||||
`https://x.test/api/v1/companies/${COMPANY_ID}/employees/${EMPLOYEE_ID}/absence`,
|
||||
{ method: 'PUT', body: JSON.stringify(validBody) },
|
||||
),
|
||||
absenceParams(COMPANY_ID, EMPLOYEE_ID),
|
||||
)
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
const body = await res.json()
|
||||
expect(body.data.count).toBe(2)
|
||||
expect(body.data.days[0].salary_absence_day_id).toBeTruthy()
|
||||
})
|
||||
|
||||
it('rejects from > to with 400', async () => {
|
||||
mockServiceClient.mockReturnValue(
|
||||
makeFlexibleSupabase({
|
||||
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
|
||||
}),
|
||||
)
|
||||
const res = await putAbsence(
|
||||
makeRequest(
|
||||
`https://x.test/api/v1/companies/${COMPANY_ID}/employees/${EMPLOYEE_ID}/absence`,
|
||||
{ method: 'PUT', body: JSON.stringify({ ...validBody, from: '2026-03-10' }) },
|
||||
),
|
||||
absenceParams(COMPANY_ID, EMPLOYEE_ID),
|
||||
)
|
||||
expect(res.status).toBe(400)
|
||||
})
|
||||
|
||||
it('maps the 24h trigger to 409 ABSENCE_HOURS_CONFLICT', async () => {
|
||||
mockServiceClient.mockReturnValue(
|
||||
makeFlexibleSupabase({
|
||||
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
|
||||
employees: { data: { id: EMPLOYEE_ID }, error: null },
|
||||
salary_absence_days: [
|
||||
{ data: null, error: { code: '23514', message: 'Total tid över 24h' } },
|
||||
],
|
||||
}),
|
||||
)
|
||||
|
||||
const res = await putAbsence(
|
||||
makeRequest(
|
||||
`https://x.test/api/v1/companies/${COMPANY_ID}/employees/${EMPLOYEE_ID}/absence`,
|
||||
{ method: 'PUT', body: JSON.stringify(validBody) },
|
||||
),
|
||||
absenceParams(COMPANY_ID, EMPLOYEE_ID),
|
||||
)
|
||||
|
||||
expect(res.status).toBe(409)
|
||||
const body = await res.json()
|
||||
expect(body.error.code).toBe('ABSENCE_HOURS_CONFLICT')
|
||||
})
|
||||
|
||||
it('returns a dry-run preview without writing', async () => {
|
||||
const supabaseMock = makeFlexibleSupabase({
|
||||
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
|
||||
employees: { data: { id: EMPLOYEE_ID }, error: null },
|
||||
})
|
||||
mockServiceClient.mockReturnValue(supabaseMock)
|
||||
|
||||
const res = await putAbsence(
|
||||
makeRequest(
|
||||
`https://x.test/api/v1/companies/${COMPANY_ID}/employees/${EMPLOYEE_ID}/absence?dry_run=true`,
|
||||
{ method: 'PUT', body: JSON.stringify(validBody) },
|
||||
),
|
||||
absenceParams(COMPANY_ID, EMPLOYEE_ID),
|
||||
)
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
expect(res.headers.get('X-Dry-Run')).toBe('true')
|
||||
const body = await res.json()
|
||||
expect(body.data.preview.count).toBe(5)
|
||||
expect(supabaseMock.tableCalls).not.toContain('salary_absence_days')
|
||||
})
|
||||
|
||||
it('forces TEST KEYS into dry-run on PUT (wrapper REQUIRES_IDEMPOTENCY regression)', async () => {
|
||||
// Before the wrapper hardening, PUT was missing from REQUIRES_IDEMPOTENCY:
|
||||
// a test key would have written through. This test locks the fix in.
|
||||
mockValidate.mockResolvedValue({
|
||||
userId: USER_ID,
|
||||
companyId: COMPANY_ID,
|
||||
apiKeyId: 'ak_test',
|
||||
apiKeyName: 'test key',
|
||||
scopes: ['payroll:read', 'payroll:write'],
|
||||
mode: 'test',
|
||||
})
|
||||
const supabaseMock = makeFlexibleSupabase({
|
||||
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
|
||||
employees: { data: { id: EMPLOYEE_ID }, error: null },
|
||||
})
|
||||
mockServiceClient.mockReturnValue(supabaseMock)
|
||||
|
||||
const res = await putAbsence(
|
||||
makeRequest(
|
||||
`https://x.test/api/v1/companies/${COMPANY_ID}/employees/${EMPLOYEE_ID}/absence`,
|
||||
{ method: 'PUT', body: JSON.stringify(validBody) },
|
||||
),
|
||||
absenceParams(COMPANY_ID, EMPLOYEE_ID),
|
||||
)
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
expect(res.headers.get('X-Dry-Run')).toBe('true')
|
||||
expect(res.headers.get('X-Gnubok-Mode')).toBe('test')
|
||||
// The write table was never touched.
|
||||
expect(supabaseMock.tableCalls).not.toContain('salary_absence_days')
|
||||
})
|
||||
|
||||
it('rejects keys without payroll:write scope', async () => {
|
||||
mockValidate.mockResolvedValue({
|
||||
userId: USER_ID,
|
||||
companyId: COMPANY_ID,
|
||||
apiKeyId: 'ak_1',
|
||||
apiKeyName: 'read-only',
|
||||
scopes: ['payroll:read'],
|
||||
mode: 'live',
|
||||
})
|
||||
mockServiceClient.mockReturnValue(makeFlexibleSupabase({}))
|
||||
|
||||
const res = await putAbsence(
|
||||
makeRequest(
|
||||
`https://x.test/api/v1/companies/${COMPANY_ID}/employees/${EMPLOYEE_ID}/absence`,
|
||||
{ method: 'PUT', body: JSON.stringify(validBody) },
|
||||
),
|
||||
absenceParams(COMPANY_ID, EMPLOYEE_ID),
|
||||
)
|
||||
expect(res.status).toBe(403)
|
||||
})
|
||||
|
||||
it('returns 401 without a bearer token', async () => {
|
||||
mockValidate.mockResolvedValue(null)
|
||||
mockServiceClient.mockReturnValue(makeFlexibleSupabase({}))
|
||||
|
||||
const res = await putAbsence(
|
||||
new Request(
|
||||
`https://x.test/api/v1/companies/${COMPANY_ID}/employees/${EMPLOYEE_ID}/absence`,
|
||||
{ method: 'PUT', body: JSON.stringify(validBody) },
|
||||
),
|
||||
absenceParams(COMPANY_ID, EMPLOYEE_ID),
|
||||
)
|
||||
expect(res.status).toBe(401)
|
||||
})
|
||||
})
|
||||
|
||||
describe('DELETE /api/v1/companies/:companyId/employees/:id/absence', () => {
|
||||
it('deletes the range and returns deleted_count', async () => {
|
||||
mockServiceClient.mockReturnValue(
|
||||
makeFlexibleSupabase({
|
||||
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
|
||||
employees: { data: { id: EMPLOYEE_ID }, error: null },
|
||||
salary_absence_days: { data: null, error: null, count: 3 },
|
||||
}),
|
||||
)
|
||||
|
||||
const res = await deleteAbsence(
|
||||
makeRequest(
|
||||
`https://x.test/api/v1/companies/${COMPANY_ID}/employees/${EMPLOYEE_ID}/absence?from=2026-03-01&to=2026-03-31&type=sick`,
|
||||
{ method: 'DELETE' },
|
||||
),
|
||||
absenceParams(COMPANY_ID, EMPLOYEE_ID),
|
||||
)
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
const body = await res.json()
|
||||
expect(body.data.deleted_count).toBe(3)
|
||||
})
|
||||
|
||||
it('requires from and to', async () => {
|
||||
mockServiceClient.mockReturnValue(
|
||||
makeFlexibleSupabase({
|
||||
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
|
||||
}),
|
||||
)
|
||||
const res = await deleteAbsence(
|
||||
makeRequest(
|
||||
`https://x.test/api/v1/companies/${COMPANY_ID}/employees/${EMPLOYEE_ID}/absence`,
|
||||
{ method: 'DELETE' },
|
||||
),
|
||||
absenceParams(COMPANY_ID, EMPLOYEE_ID),
|
||||
)
|
||||
expect(res.status).toBe(400)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,362 @@
|
||||
/**
|
||||
* /api/v1/companies/{companyId}/employees/{id}/absence
|
||||
*
|
||||
* GET : list absence days in a date range (max 92 days: the range IS the
|
||||
* pagination, no cursor).
|
||||
* PUT : range upsert. Expands [from, to] to per-day rows on the natural
|
||||
* key (employee, date, type). Truly idempotent: retries converge.
|
||||
* DELETE : range delete (optional type filter). Returns deleted_count.
|
||||
*
|
||||
* Storage is per-day (sjuklönelagen karens/day-14 boundaries and AGI 2025+
|
||||
* per-event Frånvarouppgift derive from day rows); the range payload is API
|
||||
* ergonomics only. Pre-cutover backfill is legal at any date: imported
|
||||
* history feeds the sick-segment lookback in the calculation engine.
|
||||
*/
|
||||
|
||||
import { z } from 'zod'
|
||||
import { ok } from '@/lib/api/v1/response'
|
||||
import { dryRunPreview } from '@/lib/api/v1/dry-run'
|
||||
import { registerEndpoint, dataEnvelope, listEnvelope } from '@/lib/api/v1/registry'
|
||||
import { withApiV1 } from '@/lib/api/v1/with-api-v1'
|
||||
import { v1ErrorResponseFromCode } from '@/lib/api/v1/errors'
|
||||
import { AbsenceTypeSchema } from '@/lib/api/schemas'
|
||||
import {
|
||||
ABSENCE_RANGE_MAX_DAYS,
|
||||
deleteAbsenceRange,
|
||||
listAbsenceDays,
|
||||
upsertAbsenceRange,
|
||||
} from '@/lib/salary/absence'
|
||||
|
||||
const isoDate = z.string().regex(/^\d{4}-\d{2}-\d{2}$/, 'Expected YYYY-MM-DD date format')
|
||||
|
||||
const AbsenceDay = z.object({
|
||||
salary_absence_day_id: z.string().uuid(),
|
||||
absence_date: z.string(),
|
||||
absence_type: AbsenceTypeSchema,
|
||||
hours: z.number(),
|
||||
notes: z.string().nullable(),
|
||||
salary_run_employee_id: z.string().uuid().nullable(),
|
||||
created_at: z.string(),
|
||||
updated_at: z.string(),
|
||||
})
|
||||
|
||||
const RangeQuery = z
|
||||
.object({
|
||||
from: isoDate,
|
||||
to: isoDate,
|
||||
type: AbsenceTypeSchema.optional(),
|
||||
})
|
||||
.refine((v) => v.from <= v.to, { message: 'from must be <= to', path: ['from'] })
|
||||
|
||||
registerEndpoint({
|
||||
operation: 'employees.absence.list',
|
||||
method: 'GET',
|
||||
path: '/api/v1/companies/:companyId/employees/:id/absence',
|
||||
summary: 'List absence days for an employee in a date range.',
|
||||
description:
|
||||
'Returns per-day absence rows (sick, vab, parental, ...) between ?from and ?to (inclusive, max 92 days). No cursor pagination: the bounded range is the page. Optional ?type filter.',
|
||||
useWhen:
|
||||
'You need an employee\'s registered absence: to reconcile with an external time-tracking system, to verify what the salary engine will derive, or to display a calendar.',
|
||||
doNotUseFor:
|
||||
'The derived pay impact (karensavdrag, sjuklön lines): that lives on the payslip detail after :calculate. Worked hours for hourly staff: separate register, not on v1 yet.',
|
||||
pitfalls: [
|
||||
'Ranges over 92 days return 400 ABSENCE_RANGE_TOO_LARGE: iterate quarters instead.',
|
||||
'A day can carry multiple rows with different absence_type values (e.g. half-day sick + half-day vab).',
|
||||
'Rows may reference the salary run that consumed them via salary_run_employee_id.',
|
||||
],
|
||||
example: {
|
||||
response: {
|
||||
data: [
|
||||
{
|
||||
salary_absence_day_id: 'abs_91d2…',
|
||||
absence_date: '2026-03-03',
|
||||
absence_type: 'sick',
|
||||
hours: 8,
|
||||
notes: null,
|
||||
},
|
||||
],
|
||||
meta: { request_id: 'req_…', api_version: '2026-05-12' },
|
||||
},
|
||||
},
|
||||
scope: 'payroll:read',
|
||||
risk: 'low',
|
||||
idempotent: true,
|
||||
reversible: false,
|
||||
dryRunSupported: false,
|
||||
response: { success: listEnvelope(AbsenceDay) },
|
||||
})
|
||||
|
||||
export const GET = withApiV1<{ params: Promise<{ companyId: string; id: string }> }>(
|
||||
'employees.absence.list',
|
||||
async (request, ctx, params) => {
|
||||
const { id } = await params.params
|
||||
const idParse = z.string().uuid().safeParse(id)
|
||||
if (!idParse.success) {
|
||||
return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, {
|
||||
requestId: ctx.requestId,
|
||||
details: { field: 'id', message: 'Employee id must be a UUID.' },
|
||||
})
|
||||
}
|
||||
|
||||
const url = new URL(request.url)
|
||||
const parsed = RangeQuery.safeParse({
|
||||
from: url.searchParams.get('from') ?? undefined,
|
||||
to: url.searchParams.get('to') ?? undefined,
|
||||
type: url.searchParams.get('type') ?? undefined,
|
||||
})
|
||||
if (!parsed.success) {
|
||||
return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, {
|
||||
requestId: ctx.requestId,
|
||||
details: {
|
||||
issues: parsed.error.issues.map((i) => ({
|
||||
field: i.path.join('.'),
|
||||
message: i.message,
|
||||
})),
|
||||
},
|
||||
})
|
||||
}
|
||||
const { from, to, type } = parsed.data
|
||||
|
||||
// Same cap as writes: the bounded range is the pagination contract.
|
||||
const spanMs = Date.parse(`${to}T00:00:00Z`) - Date.parse(`${from}T00:00:00Z`)
|
||||
if (spanMs < 0 || spanMs / 86_400_000 + 1 > ABSENCE_RANGE_MAX_DAYS) {
|
||||
return v1ErrorResponseFromCode('ABSENCE_RANGE_TOO_LARGE', ctx.log, {
|
||||
requestId: ctx.requestId,
|
||||
details: { from, to, max_days: ABSENCE_RANGE_MAX_DAYS },
|
||||
})
|
||||
}
|
||||
|
||||
const result = await listAbsenceDays(ctx.supabase, {
|
||||
companyId: ctx.companyId!,
|
||||
employeeId: idParse.data,
|
||||
from,
|
||||
to,
|
||||
absenceType: type,
|
||||
})
|
||||
|
||||
if (!result.ok) {
|
||||
return v1ErrorResponseFromCode(result.code, ctx.log, {
|
||||
requestId: ctx.requestId,
|
||||
details: result.details,
|
||||
})
|
||||
}
|
||||
|
||||
return ok(
|
||||
result.data.map(({ id: rowId, ...rest }) => ({ salary_absence_day_id: rowId, ...rest })),
|
||||
{ requestId: ctx.requestId },
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────
|
||||
// PUT: range upsert
|
||||
// ──────────────────────────────────────────────────────────────────
|
||||
|
||||
const UpsertRangeBody = z
|
||||
.object({
|
||||
from: isoDate,
|
||||
to: isoDate,
|
||||
absence_type: AbsenceTypeSchema,
|
||||
hours_per_day: z.number().positive().max(24).default(8),
|
||||
notes: z.string().max(2000).optional(),
|
||||
include_weekends: z.boolean().default(false),
|
||||
})
|
||||
.refine((v) => v.from <= v.to, { message: 'from must be <= to', path: ['from'] })
|
||||
|
||||
const UpsertRangeResponse = z.object({
|
||||
count: z.number().int(),
|
||||
days: z.array(AbsenceDay.partial({ salary_absence_day_id: true, notes: true, salary_run_employee_id: true, created_at: true, updated_at: true })),
|
||||
})
|
||||
|
||||
registerEndpoint({
|
||||
operation: 'employees.absence.upsert',
|
||||
method: 'PUT',
|
||||
path: '/api/v1/companies/:companyId/employees/:id/absence',
|
||||
summary: 'Register absence for an employee over a date range.',
|
||||
description:
|
||||
'Expands [from, to] (max 92 days) to per-day rows and upserts them on the natural key (employee, date, type). Weekends are skipped unless include_weekends=true. Single day = from == to. Idempotent by construction: replaying the same PUT converges on the same rows.',
|
||||
useWhen:
|
||||
'"Anna was sick 3-7 March": one call registers the whole event. Also for pre-cutover history backfill when migrating from another payroll system (any past date is legal; imported sick days feed the karensavdrag lookback).',
|
||||
doNotUseFor:
|
||||
'Vacation day REQUESTS/approval workflows (out of scope). Editing hours on one existing day inside a range: PUT the single day (from == to) with the new hours.',
|
||||
pitfalls: [
|
||||
'Weekends are skipped by default: pass include_weekends=true for schedules that span them.',
|
||||
'Upsert REPLACES the (date, type) rows in the range: hours/notes are overwritten, not merged.',
|
||||
'A day whose combined absence + worked hours exceed 24h returns 409 ABSENCE_HOURS_CONFLICT and the whole range is rejected (atomic).',
|
||||
'Registering absence does not recompute an open salary run: call POST /salary-runs/{id}/calculate afterwards.',
|
||||
],
|
||||
example: {
|
||||
request: { from: '2026-03-03', to: '2026-03-07', absence_type: 'sick' },
|
||||
response: {
|
||||
data: { count: 5, days: [{ absence_date: '2026-03-03', absence_type: 'sick', hours: 8 }] },
|
||||
meta: { request_id: 'req_…', api_version: '2026-05-12' },
|
||||
},
|
||||
},
|
||||
scope: 'payroll:write',
|
||||
risk: 'low',
|
||||
idempotent: true,
|
||||
reversible: true,
|
||||
dryRunSupported: true,
|
||||
request: { body: UpsertRangeBody },
|
||||
response: { success: dataEnvelope(UpsertRangeResponse) },
|
||||
})
|
||||
|
||||
export const PUT = withApiV1<{ params: Promise<{ companyId: string; id: string }> }>(
|
||||
'employees.absence.upsert',
|
||||
async (request, ctx, params) => {
|
||||
const { id } = await params.params
|
||||
const idParse = z.string().uuid().safeParse(id)
|
||||
if (!idParse.success) {
|
||||
return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, {
|
||||
requestId: ctx.requestId,
|
||||
details: { field: 'id', message: 'Employee id must be a UUID.' },
|
||||
})
|
||||
}
|
||||
|
||||
let rawBody: unknown
|
||||
try {
|
||||
rawBody = await request.json()
|
||||
} catch {
|
||||
return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, {
|
||||
requestId: ctx.requestId,
|
||||
details: { field: 'body', message: 'Body is not valid JSON.' },
|
||||
})
|
||||
}
|
||||
|
||||
const parsed = UpsertRangeBody.safeParse(rawBody)
|
||||
if (!parsed.success) {
|
||||
return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, {
|
||||
requestId: ctx.requestId,
|
||||
details: {
|
||||
issues: parsed.error.issues.map((i) => ({
|
||||
field: i.path.join('.'),
|
||||
message: i.message,
|
||||
})),
|
||||
},
|
||||
})
|
||||
}
|
||||
const body = parsed.data
|
||||
|
||||
const result = await upsertAbsenceRange(ctx.supabase, {
|
||||
companyId: ctx.companyId!,
|
||||
employeeId: idParse.data,
|
||||
from: body.from,
|
||||
to: body.to,
|
||||
absenceType: body.absence_type,
|
||||
hoursPerDay: body.hours_per_day,
|
||||
notes: body.notes ?? null,
|
||||
includeWeekends: body.include_weekends,
|
||||
dryRun: ctx.dryRun,
|
||||
})
|
||||
|
||||
if (!result.ok) {
|
||||
return v1ErrorResponseFromCode(result.code, ctx.log, {
|
||||
requestId: ctx.requestId,
|
||||
details: result.details,
|
||||
})
|
||||
}
|
||||
|
||||
const payload = {
|
||||
count: result.data.count,
|
||||
days: result.data.days.map((d) => {
|
||||
const { id: rowId, ...rest } = d as { id?: string } & Record<string, unknown>
|
||||
return rowId ? { salary_absence_day_id: rowId, ...rest } : rest
|
||||
}),
|
||||
}
|
||||
|
||||
if (ctx.dryRun) {
|
||||
return dryRunPreview(payload, { requestId: ctx.requestId, log: ctx.log })
|
||||
}
|
||||
return ok(payload, { requestId: ctx.requestId })
|
||||
},
|
||||
{ requireIdempotencyKey: false },
|
||||
)
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────
|
||||
// DELETE: range delete
|
||||
// ──────────────────────────────────────────────────────────────────
|
||||
|
||||
const DeleteRangeResponse = z.object({ deleted_count: z.number().int() })
|
||||
|
||||
registerEndpoint({
|
||||
operation: 'employees.absence.delete',
|
||||
method: 'DELETE',
|
||||
path: '/api/v1/companies/:companyId/employees/:id/absence',
|
||||
summary: 'Delete absence days for an employee in a date range.',
|
||||
description:
|
||||
'Deletes per-day absence rows between ?from and ?to (inclusive), optionally filtered by ?type. Returns deleted_count (200, not 204) so callers can verify how many rows went.',
|
||||
useWhen:
|
||||
'An absence event was registered by mistake or ended early: "Anna came back Thursday, delete Thu-Fri sick days".',
|
||||
doNotUseFor:
|
||||
'Correcting hours on a day: PUT the day again instead. Rows already consumed by a BOOKED run: deleting them does not un-book the run; use the run correction flow.',
|
||||
pitfalls: [
|
||||
'Without ?type, ALL absence types in the range are deleted.',
|
||||
'deleted_count: 0 with a 200 means nothing matched: not an error.',
|
||||
],
|
||||
example: {
|
||||
response: {
|
||||
data: { deleted_count: 2 },
|
||||
meta: { request_id: 'req_…', api_version: '2026-05-12' },
|
||||
},
|
||||
},
|
||||
scope: 'payroll:write',
|
||||
risk: 'low',
|
||||
idempotent: true,
|
||||
reversible: false,
|
||||
dryRunSupported: true,
|
||||
response: { success: dataEnvelope(DeleteRangeResponse) },
|
||||
})
|
||||
|
||||
export const DELETE = withApiV1<{ params: Promise<{ companyId: string; id: string }> }>(
|
||||
'employees.absence.delete',
|
||||
async (request, ctx, params) => {
|
||||
const { id } = await params.params
|
||||
const idParse = z.string().uuid().safeParse(id)
|
||||
if (!idParse.success) {
|
||||
return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, {
|
||||
requestId: ctx.requestId,
|
||||
details: { field: 'id', message: 'Employee id must be a UUID.' },
|
||||
})
|
||||
}
|
||||
|
||||
const url = new URL(request.url)
|
||||
const parsed = RangeQuery.safeParse({
|
||||
from: url.searchParams.get('from') ?? undefined,
|
||||
to: url.searchParams.get('to') ?? undefined,
|
||||
type: url.searchParams.get('type') ?? undefined,
|
||||
})
|
||||
if (!parsed.success) {
|
||||
return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, {
|
||||
requestId: ctx.requestId,
|
||||
details: {
|
||||
issues: parsed.error.issues.map((i) => ({
|
||||
field: i.path.join('.'),
|
||||
message: i.message,
|
||||
})),
|
||||
},
|
||||
})
|
||||
}
|
||||
const { from, to, type } = parsed.data
|
||||
|
||||
const result = await deleteAbsenceRange(ctx.supabase, {
|
||||
companyId: ctx.companyId!,
|
||||
employeeId: idParse.data,
|
||||
from,
|
||||
to,
|
||||
absenceType: type,
|
||||
dryRun: ctx.dryRun,
|
||||
})
|
||||
|
||||
if (!result.ok) {
|
||||
return v1ErrorResponseFromCode(result.code, ctx.log, {
|
||||
requestId: ctx.requestId,
|
||||
details: result.details,
|
||||
})
|
||||
}
|
||||
|
||||
if (ctx.dryRun) {
|
||||
return dryRunPreview(result.data, { requestId: ctx.requestId, log: ctx.log })
|
||||
}
|
||||
return ok(result.data, { requestId: ctx.requestId })
|
||||
},
|
||||
)
|
||||
+518
@@ -0,0 +1,518 @@
|
||||
/**
|
||||
* Tests for the v1 opening-balances endpoints (payroll gap-closure 2.3):
|
||||
* GET/PUT /employees/{id}/opening-balances + bulk PUT /employees/opening-balances.
|
||||
*/
|
||||
|
||||
import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
beforeAll(() => {
|
||||
if (process.env.NODE_ENV !== 'test') {
|
||||
throw new Error(
|
||||
`opening-balances route tests require NODE_ENV=test (got ${process.env.NODE_ENV ?? 'undefined'})`,
|
||||
)
|
||||
}
|
||||
process.env.NEXT_PUBLIC_SUPABASE_URL ||= 'http://localhost:54321'
|
||||
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY ||= 'test-anon-key'
|
||||
})
|
||||
|
||||
vi.mock('@/lib/auth/api-keys', async () => {
|
||||
const actual = await vi.importActual<typeof import('@/lib/auth/api-keys')>('@/lib/auth/api-keys')
|
||||
return {
|
||||
...actual,
|
||||
validateApiKey: vi.fn(),
|
||||
createServiceClientNoCookies: vi.fn(),
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@supabase/supabase-js', async () => {
|
||||
const actual = await vi.importActual<typeof import('@supabase/supabase-js')>('@supabase/supabase-js')
|
||||
return { ...actual, createClient: vi.fn().mockReturnValue({}) }
|
||||
})
|
||||
|
||||
import { validateApiKey, createServiceClientNoCookies } from '@/lib/auth/api-keys'
|
||||
import { GET as getBalances, PUT as putBalances } from '../route'
|
||||
import { PUT as putBulk } from '../../../opening-balances/route'
|
||||
|
||||
const mockValidate = validateApiKey as ReturnType<typeof vi.fn>
|
||||
const mockServiceClient = createServiceClientNoCookies as ReturnType<typeof vi.fn>
|
||||
|
||||
interface TableResp {
|
||||
data?: unknown
|
||||
error?: unknown
|
||||
}
|
||||
|
||||
function makeFlexibleSupabase(byTable: Record<string, TableResp | TableResp[]>) {
|
||||
const queues = new Map<string, TableResp[]>()
|
||||
for (const [t, val] of Object.entries(byTable)) {
|
||||
queues.set(t, Array.isArray(val) ? [...val] : [val])
|
||||
}
|
||||
const tableCalls: string[] = []
|
||||
const buildChain = (table: string): unknown => {
|
||||
const handler: ProxyHandler<object> = {
|
||||
get(_target, prop) {
|
||||
if (prop === 'then') {
|
||||
return (resolve: (v: unknown) => void) => {
|
||||
const q = queues.get(table)
|
||||
const next = q && q.length > 1 ? q.shift()! : (q?.[0] ?? { data: null, error: null })
|
||||
resolve(next)
|
||||
}
|
||||
}
|
||||
return (..._args: unknown[]) => buildChain(table)
|
||||
},
|
||||
}
|
||||
return new Proxy({}, handler)
|
||||
}
|
||||
return {
|
||||
tableCalls,
|
||||
from: vi.fn((table: string) => {
|
||||
tableCalls.push(table)
|
||||
return buildChain(table)
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
const COMPANY_ID = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa'
|
||||
const EMPLOYEE_ID = 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb'
|
||||
const ROW_ID = 'cccccccc-cccc-4ccc-8ccc-cccccccccccc'
|
||||
const RUN_ID = 'dddddddd-dddd-4ddd-8ddd-dddddddddddd'
|
||||
const USER_ID = 'user-1'
|
||||
|
||||
const CURRENT_YEAR = new Date().getFullYear()
|
||||
const CUTOVER_DATE = `${CURRENT_YEAR}-07-01`
|
||||
|
||||
const SAMPLE_ROW = {
|
||||
id: ROW_ID,
|
||||
employee_id: EMPLOYEE_ID,
|
||||
cutover_date: CUTOVER_DATE,
|
||||
ytd_gross: 210000,
|
||||
ytd_tax: 48000,
|
||||
ytd_net: 162000,
|
||||
vacation_paid_days_remaining: 12.5,
|
||||
vacation_saved_days_by_year: { [`${CURRENT_YEAR - 1}`]: 5 },
|
||||
opening_semester_liability: 42000,
|
||||
opening_semester_liability_avgifter: 13196.4,
|
||||
karens_periods_adjustment: 1,
|
||||
created_at: '2026-07-01T08:00:00Z',
|
||||
updated_at: '2026-07-01T08:00:00Z',
|
||||
}
|
||||
|
||||
const VALID_BODY = {
|
||||
cutover_date: CUTOVER_DATE,
|
||||
ytd_gross: 210000,
|
||||
ytd_tax: 48000,
|
||||
ytd_net: 162000,
|
||||
vacation_paid_days_remaining: 12.5,
|
||||
vacation_saved_days_by_year: { [`${CURRENT_YEAR - 1}`]: 5 },
|
||||
opening_semester_liability: 42000,
|
||||
opening_semester_liability_avgifter: 13196.4,
|
||||
karens_periods_adjustment: 1,
|
||||
}
|
||||
|
||||
function makeRequest(url: string, init?: RequestInit): Request {
|
||||
return new Request(url, {
|
||||
...init,
|
||||
headers: {
|
||||
Authorization: 'Bearer test-fixture-not-a-real-key',
|
||||
...(init?.headers ?? {}),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function detailParams(companyId: string, id: string) {
|
||||
return { params: Promise.resolve({ companyId, id }) }
|
||||
}
|
||||
|
||||
function companyParams(companyId: string) {
|
||||
return { params: Promise.resolve({ companyId }) }
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockValidate.mockResolvedValue({
|
||||
userId: USER_ID,
|
||||
companyId: COMPANY_ID,
|
||||
apiKeyId: 'ak_1',
|
||||
apiKeyName: 'CI key',
|
||||
scopes: ['payroll:read', 'payroll:write'],
|
||||
mode: 'live',
|
||||
})
|
||||
})
|
||||
|
||||
describe('GET /employees/:id/opening-balances', () => {
|
||||
it('returns the row with lock state (happy path, unlocked)', async () => {
|
||||
mockServiceClient.mockReturnValue(
|
||||
makeFlexibleSupabase({
|
||||
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
|
||||
employees: { data: { id: EMPLOYEE_ID }, error: null },
|
||||
employee_opening_balances: { data: SAMPLE_ROW, error: null },
|
||||
salary_run_employees: { data: [], error: null },
|
||||
}),
|
||||
)
|
||||
|
||||
const res = await getBalances(
|
||||
makeRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/employees/${EMPLOYEE_ID}/opening-balances`),
|
||||
detailParams(COMPANY_ID, EMPLOYEE_ID),
|
||||
)
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
const body = await res.json()
|
||||
expect(body.data.employee_opening_balances_id).toBe(ROW_ID)
|
||||
expect(body.data.ytd_gross).toBe(210000)
|
||||
expect(body.data.locked).toBe(false)
|
||||
expect(body.data.locked_by_run_id).toBeNull()
|
||||
expect(body.data.id).toBeUndefined()
|
||||
})
|
||||
|
||||
it('reports locked with the blocking run id', async () => {
|
||||
mockServiceClient.mockReturnValue(
|
||||
makeFlexibleSupabase({
|
||||
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
|
||||
employees: { data: { id: EMPLOYEE_ID }, error: null },
|
||||
employee_opening_balances: { data: SAMPLE_ROW, error: null },
|
||||
salary_run_employees: {
|
||||
data: [{ employee_id: EMPLOYEE_ID, salary_run: { id: RUN_ID, status: 'booked' } }],
|
||||
error: null,
|
||||
},
|
||||
}),
|
||||
)
|
||||
|
||||
const res = await getBalances(
|
||||
makeRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/employees/${EMPLOYEE_ID}/opening-balances`),
|
||||
detailParams(COMPANY_ID, EMPLOYEE_ID),
|
||||
)
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
const body = await res.json()
|
||||
expect(body.data.locked).toBe(true)
|
||||
expect(body.data.locked_by_run_id).toBe(RUN_ID)
|
||||
})
|
||||
|
||||
it('returns 404 when no balances are set', async () => {
|
||||
mockServiceClient.mockReturnValue(
|
||||
makeFlexibleSupabase({
|
||||
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
|
||||
employees: { data: { id: EMPLOYEE_ID }, error: null },
|
||||
employee_opening_balances: { data: null, error: null },
|
||||
}),
|
||||
)
|
||||
const res = await getBalances(
|
||||
makeRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/employees/${EMPLOYEE_ID}/opening-balances`),
|
||||
detailParams(COMPANY_ID, EMPLOYEE_ID),
|
||||
)
|
||||
expect(res.status).toBe(404)
|
||||
})
|
||||
|
||||
it('returns 404 EMPLOYEE_NOT_FOUND for an unknown employee', async () => {
|
||||
mockServiceClient.mockReturnValue(
|
||||
makeFlexibleSupabase({
|
||||
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
|
||||
employees: { data: null, error: null },
|
||||
}),
|
||||
)
|
||||
const res = await getBalances(
|
||||
makeRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/employees/${EMPLOYEE_ID}/opening-balances`),
|
||||
detailParams(COMPANY_ID, EMPLOYEE_ID),
|
||||
)
|
||||
expect(res.status).toBe(404)
|
||||
const body = await res.json()
|
||||
expect(body.error.code).toBe('EMPLOYEE_NOT_FOUND')
|
||||
})
|
||||
})
|
||||
|
||||
describe('PUT /employees/:id/opening-balances', () => {
|
||||
it('upserts and returns the row (happy path)', async () => {
|
||||
mockServiceClient.mockReturnValue(
|
||||
makeFlexibleSupabase({
|
||||
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
|
||||
employees: {
|
||||
data: [{ id: EMPLOYEE_ID, employment_start: '2024-01-15', is_active: true }],
|
||||
error: null,
|
||||
},
|
||||
salary_run_employees: { data: [], error: null },
|
||||
employee_opening_balances: { data: [SAMPLE_ROW], error: null },
|
||||
}),
|
||||
)
|
||||
|
||||
const res = await putBalances(
|
||||
makeRequest(
|
||||
`https://x.test/api/v1/companies/${COMPANY_ID}/employees/${EMPLOYEE_ID}/opening-balances`,
|
||||
{ method: 'PUT', body: JSON.stringify(VALID_BODY) },
|
||||
),
|
||||
detailParams(COMPANY_ID, EMPLOYEE_ID),
|
||||
)
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
const body = await res.json()
|
||||
expect(body.data.employee_opening_balances_id).toBe(ROW_ID)
|
||||
expect(body.data.locked).toBe(false)
|
||||
})
|
||||
|
||||
it('returns 409 OPENING_BALANCES_LOCKED when a booked run exists', async () => {
|
||||
mockServiceClient.mockReturnValue(
|
||||
makeFlexibleSupabase({
|
||||
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
|
||||
employees: {
|
||||
data: [{ id: EMPLOYEE_ID, employment_start: '2024-01-15', is_active: true }],
|
||||
error: null,
|
||||
},
|
||||
salary_run_employees: {
|
||||
data: [{ employee_id: EMPLOYEE_ID, salary_run: { id: RUN_ID, status: 'booked' } }],
|
||||
error: null,
|
||||
},
|
||||
}),
|
||||
)
|
||||
|
||||
const res = await putBalances(
|
||||
makeRequest(
|
||||
`https://x.test/api/v1/companies/${COMPANY_ID}/employees/${EMPLOYEE_ID}/opening-balances`,
|
||||
{ method: 'PUT', body: JSON.stringify(VALID_BODY) },
|
||||
),
|
||||
detailParams(COMPANY_ID, EMPLOYEE_ID),
|
||||
)
|
||||
|
||||
expect(res.status).toBe(409)
|
||||
const body = await res.json()
|
||||
expect(body.error.code).toBe('OPENING_BALANCES_LOCKED')
|
||||
})
|
||||
|
||||
it('rejects a cutover_date that is not the first of a month', async () => {
|
||||
mockServiceClient.mockReturnValue(
|
||||
makeFlexibleSupabase({
|
||||
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
|
||||
}),
|
||||
)
|
||||
const res = await putBalances(
|
||||
makeRequest(
|
||||
`https://x.test/api/v1/companies/${COMPANY_ID}/employees/${EMPLOYEE_ID}/opening-balances`,
|
||||
{ method: 'PUT', body: JSON.stringify({ ...VALID_BODY, cutover_date: `${CURRENT_YEAR}-07-15` }) },
|
||||
),
|
||||
detailParams(COMPANY_ID, EMPLOYEE_ID),
|
||||
)
|
||||
expect(res.status).toBe(400)
|
||||
})
|
||||
|
||||
it('rejects ytd_tax above ytd_gross', async () => {
|
||||
mockServiceClient.mockReturnValue(
|
||||
makeFlexibleSupabase({
|
||||
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
|
||||
}),
|
||||
)
|
||||
const res = await putBalances(
|
||||
makeRequest(
|
||||
`https://x.test/api/v1/companies/${COMPANY_ID}/employees/${EMPLOYEE_ID}/opening-balances`,
|
||||
{ method: 'PUT', body: JSON.stringify({ ...VALID_BODY, ytd_gross: 1000, ytd_tax: 2000 }) },
|
||||
),
|
||||
detailParams(COMPANY_ID, EMPLOYEE_ID),
|
||||
)
|
||||
expect(res.status).toBe(400)
|
||||
})
|
||||
|
||||
it('rejects saved days with an origin year outside the 5-year window', async () => {
|
||||
mockServiceClient.mockReturnValue(
|
||||
makeFlexibleSupabase({
|
||||
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
|
||||
}),
|
||||
)
|
||||
const res = await putBalances(
|
||||
makeRequest(
|
||||
`https://x.test/api/v1/companies/${COMPANY_ID}/employees/${EMPLOYEE_ID}/opening-balances`,
|
||||
{
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({
|
||||
...VALID_BODY,
|
||||
vacation_saved_days_by_year: { [`${CURRENT_YEAR - 7}`]: 3 },
|
||||
}),
|
||||
},
|
||||
),
|
||||
detailParams(COMPANY_ID, EMPLOYEE_ID),
|
||||
)
|
||||
expect(res.status).toBe(400)
|
||||
})
|
||||
|
||||
it('dry-run validates without writing', async () => {
|
||||
const supabaseMock = makeFlexibleSupabase({
|
||||
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
|
||||
employees: {
|
||||
data: [{ id: EMPLOYEE_ID, employment_start: '2024-01-15', is_active: true }],
|
||||
error: null,
|
||||
},
|
||||
salary_run_employees: { data: [], error: null },
|
||||
})
|
||||
mockServiceClient.mockReturnValue(supabaseMock)
|
||||
|
||||
const res = await putBalances(
|
||||
makeRequest(
|
||||
`https://x.test/api/v1/companies/${COMPANY_ID}/employees/${EMPLOYEE_ID}/opening-balances?dry_run=true`,
|
||||
{ method: 'PUT', body: JSON.stringify(VALID_BODY) },
|
||||
),
|
||||
detailParams(COMPANY_ID, EMPLOYEE_ID),
|
||||
)
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
expect(res.headers.get('X-Dry-Run')).toBe('true')
|
||||
expect(supabaseMock.tableCalls).not.toContain('employee_opening_balances')
|
||||
})
|
||||
|
||||
it('forces test keys into dry-run on this PUT too', async () => {
|
||||
mockValidate.mockResolvedValue({
|
||||
userId: USER_ID,
|
||||
companyId: COMPANY_ID,
|
||||
apiKeyId: 'ak_test',
|
||||
apiKeyName: 'test key',
|
||||
scopes: ['payroll:write'],
|
||||
mode: 'test',
|
||||
})
|
||||
const supabaseMock = makeFlexibleSupabase({
|
||||
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
|
||||
employees: {
|
||||
data: [{ id: EMPLOYEE_ID, employment_start: '2024-01-15', is_active: true }],
|
||||
error: null,
|
||||
},
|
||||
salary_run_employees: { data: [], error: null },
|
||||
})
|
||||
mockServiceClient.mockReturnValue(supabaseMock)
|
||||
|
||||
const res = await putBalances(
|
||||
makeRequest(
|
||||
`https://x.test/api/v1/companies/${COMPANY_ID}/employees/${EMPLOYEE_ID}/opening-balances`,
|
||||
{ method: 'PUT', body: JSON.stringify(VALID_BODY) },
|
||||
),
|
||||
detailParams(COMPANY_ID, EMPLOYEE_ID),
|
||||
)
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
expect(res.headers.get('X-Dry-Run')).toBe('true')
|
||||
expect(supabaseMock.tableCalls).not.toContain('employee_opening_balances')
|
||||
})
|
||||
})
|
||||
|
||||
describe('PUT /employees/opening-balances (bulk)', () => {
|
||||
it('is atomic: one bad item fails everything with a per-item error list', async () => {
|
||||
const supabaseMock = makeFlexibleSupabase({
|
||||
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
|
||||
// Only the first employee exists.
|
||||
employees: {
|
||||
data: [{ id: EMPLOYEE_ID, employment_start: '2024-01-15', is_active: true }],
|
||||
error: null,
|
||||
},
|
||||
salary_run_employees: { data: [], error: null },
|
||||
})
|
||||
mockServiceClient.mockReturnValue(supabaseMock)
|
||||
|
||||
const otherId = '99999999-9999-4999-8999-999999999999'
|
||||
const res = await putBulk(
|
||||
makeRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/employees/opening-balances`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({
|
||||
items: [
|
||||
{ employee_id: EMPLOYEE_ID, ...VALID_BODY },
|
||||
{ employee_id: otherId, ...VALID_BODY },
|
||||
],
|
||||
}),
|
||||
}),
|
||||
companyParams(COMPANY_ID),
|
||||
)
|
||||
|
||||
expect(res.status).toBe(400)
|
||||
const body = await res.json()
|
||||
const itemErrors = body.error.details.item_errors as Array<{ index: number; code: string }>
|
||||
expect(itemErrors).toHaveLength(1)
|
||||
expect(itemErrors[0].index).toBe(1)
|
||||
expect(itemErrors[0].code).toBe('EMPLOYEE_NOT_FOUND')
|
||||
// Zero writes happened.
|
||||
expect(supabaseMock.tableCalls).not.toContain('employee_opening_balances')
|
||||
})
|
||||
|
||||
it('upserts all items in one call (happy path)', async () => {
|
||||
const secondId = '99999999-9999-4999-8999-999999999999'
|
||||
mockServiceClient.mockReturnValue(
|
||||
makeFlexibleSupabase({
|
||||
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
|
||||
employees: {
|
||||
data: [
|
||||
{ id: EMPLOYEE_ID, employment_start: '2024-01-15', is_active: true },
|
||||
{ id: secondId, employment_start: '2025-03-01', is_active: true },
|
||||
],
|
||||
error: null,
|
||||
},
|
||||
salary_run_employees: { data: [], error: null },
|
||||
employee_opening_balances: {
|
||||
data: [SAMPLE_ROW, { ...SAMPLE_ROW, id: '11111111-1111-4111-8111-111111111111', employee_id: secondId }],
|
||||
error: null,
|
||||
},
|
||||
}),
|
||||
)
|
||||
|
||||
const res = await putBulk(
|
||||
makeRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/employees/opening-balances`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({
|
||||
items: [
|
||||
{ employee_id: EMPLOYEE_ID, ...VALID_BODY },
|
||||
{ employee_id: secondId, ...VALID_BODY },
|
||||
],
|
||||
}),
|
||||
}),
|
||||
companyParams(COMPANY_ID),
|
||||
)
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
const body = await res.json()
|
||||
expect(body.data.count).toBe(2)
|
||||
})
|
||||
|
||||
it('rejects duplicate employee_ids in the same request', async () => {
|
||||
mockServiceClient.mockReturnValue(
|
||||
makeFlexibleSupabase({
|
||||
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
|
||||
}),
|
||||
)
|
||||
const res = await putBulk(
|
||||
makeRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/employees/opening-balances`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({
|
||||
items: [
|
||||
{ employee_id: EMPLOYEE_ID, ...VALID_BODY },
|
||||
{ employee_id: EMPLOYEE_ID, ...VALID_BODY },
|
||||
],
|
||||
}),
|
||||
}),
|
||||
companyParams(COMPANY_ID),
|
||||
)
|
||||
expect(res.status).toBe(400)
|
||||
})
|
||||
|
||||
it('rejects keys without payroll:write scope', async () => {
|
||||
mockValidate.mockResolvedValue({
|
||||
userId: USER_ID,
|
||||
companyId: COMPANY_ID,
|
||||
apiKeyId: 'ak_1',
|
||||
apiKeyName: 'read-only',
|
||||
scopes: ['payroll:read'],
|
||||
mode: 'live',
|
||||
})
|
||||
mockServiceClient.mockReturnValue(makeFlexibleSupabase({}))
|
||||
|
||||
const res = await putBulk(
|
||||
makeRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/employees/opening-balances`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ items: [{ employee_id: EMPLOYEE_ID, ...VALID_BODY }] }),
|
||||
}),
|
||||
companyParams(COMPANY_ID),
|
||||
)
|
||||
expect(res.status).toBe(403)
|
||||
})
|
||||
|
||||
it('returns 401 without a bearer token', async () => {
|
||||
mockValidate.mockResolvedValue(null)
|
||||
mockServiceClient.mockReturnValue(makeFlexibleSupabase({}))
|
||||
const res = await putBulk(
|
||||
new Request(`https://x.test/api/v1/companies/${COMPANY_ID}/employees/opening-balances`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ items: [{ employee_id: EMPLOYEE_ID, ...VALID_BODY }] }),
|
||||
}),
|
||||
companyParams(COMPANY_ID),
|
||||
)
|
||||
expect(res.status).toBe(401)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,213 @@
|
||||
/**
|
||||
* /api/v1/companies/{companyId}/employees/{id}/opening-balances
|
||||
*
|
||||
* GET : the employee's cutover opening balances + lock state.
|
||||
* PUT : full-replace upsert (naturally idempotent). Editable until the
|
||||
* employee appears in a BOOKED salary run; then 409
|
||||
* OPENING_BALANCES_LOCKED (self-unlocks if that run is corrected).
|
||||
*
|
||||
* This is the payroll cutover surface for mid-year migrations from another
|
||||
* payroll system: YTD accumulators (payslip continuity), vacation balances
|
||||
* incl. sparade dagar by origin year, the opening semesterlöneskuld SEK
|
||||
* (report-only: the 2920/2940 balance arrived via SIE opening balances),
|
||||
* and the högriskskydd karens-count adjustment. Ongoing sick cases need no
|
||||
* fields here: import pre-cutover days via PUT /employees/{id}/absence and
|
||||
* the engine reconstructs segments, återinsjuknande, and karens state.
|
||||
*/
|
||||
|
||||
import { z } from 'zod'
|
||||
import { ok } from '@/lib/api/v1/response'
|
||||
import { dryRunPreview } from '@/lib/api/v1/dry-run'
|
||||
import { registerEndpoint, dataEnvelope } from '@/lib/api/v1/registry'
|
||||
import { withApiV1 } from '@/lib/api/v1/with-api-v1'
|
||||
import { v1ErrorResponseFromCode } from '@/lib/api/v1/errors'
|
||||
import { OpeningBalancesFieldsSchema } from '@/lib/api/schemas'
|
||||
import { getOpeningBalances, setOpeningBalancesBulk } from '@/lib/salary/opening-balances'
|
||||
|
||||
const OpeningBalancesResponse = z.object({
|
||||
employee_opening_balances_id: z.string().uuid().nullable(),
|
||||
employee_id: z.string().uuid(),
|
||||
cutover_date: z.string(),
|
||||
ytd_gross: z.number(),
|
||||
ytd_tax: z.number(),
|
||||
ytd_net: z.number(),
|
||||
vacation_paid_days_remaining: z.number(),
|
||||
vacation_saved_days_by_year: z.record(z.string(), z.number()),
|
||||
opening_semester_liability: z.number(),
|
||||
opening_semester_liability_avgifter: z.number(),
|
||||
karens_periods_adjustment: z.number(),
|
||||
locked: z.boolean(),
|
||||
locked_by_run_id: z.string().uuid().nullable(),
|
||||
})
|
||||
|
||||
registerEndpoint({
|
||||
operation: 'employees.opening-balances.get',
|
||||
method: 'GET',
|
||||
path: '/api/v1/companies/:companyId/employees/:id/opening-balances',
|
||||
summary: 'Get an employee\'s payroll cutover opening balances.',
|
||||
description:
|
||||
'Returns the opening balances set for a mid-year migration (YTD gross/tax/net, vacation balances, opening semesterlöneskuld, karens adjustment) plus the lock state: locked=true once the employee has a booked salary run.',
|
||||
useWhen:
|
||||
'Verifying cutover state before the first calculated run, or checking whether balances can still be edited (locked=false).',
|
||||
doNotUseFor:
|
||||
'The live vacation liability (GET /reports/vacation-liability includes the opening terms). Pre-cutover absence history: GET /employees/{id}/absence.',
|
||||
pitfalls: [
|
||||
'404 NOT_FOUND when no opening balances have been set: distinct from an all-zeros row.',
|
||||
'locked_by_run_id names the booked run that froze the row; correcting that run unlocks it.',
|
||||
],
|
||||
example: {
|
||||
response: {
|
||||
data: {
|
||||
employee_id: 'emp_77b2…',
|
||||
cutover_date: '2026-07-01',
|
||||
ytd_gross: 210000,
|
||||
vacation_paid_days_remaining: 12.5,
|
||||
locked: false,
|
||||
},
|
||||
meta: { request_id: 'req_…', api_version: '2026-05-12' },
|
||||
},
|
||||
},
|
||||
scope: 'payroll:read',
|
||||
risk: 'low',
|
||||
idempotent: true,
|
||||
reversible: false,
|
||||
dryRunSupported: false,
|
||||
response: { success: dataEnvelope(OpeningBalancesResponse) },
|
||||
})
|
||||
|
||||
export const GET = withApiV1<{ params: Promise<{ companyId: string; id: string }> }>(
|
||||
'employees.opening-balances.get',
|
||||
async (_request, ctx, params) => {
|
||||
const { id } = await params.params
|
||||
const idParse = z.string().uuid().safeParse(id)
|
||||
if (!idParse.success) {
|
||||
return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, {
|
||||
requestId: ctx.requestId,
|
||||
details: { field: 'id', message: 'Employee id must be a UUID.' },
|
||||
})
|
||||
}
|
||||
|
||||
const result = await getOpeningBalances(ctx.supabase, {
|
||||
companyId: ctx.companyId!,
|
||||
employeeId: idParse.data,
|
||||
})
|
||||
if (!result.ok) {
|
||||
return v1ErrorResponseFromCode(result.code, ctx.log, {
|
||||
requestId: ctx.requestId,
|
||||
details: result.details,
|
||||
})
|
||||
}
|
||||
if (result.data === null) {
|
||||
return v1ErrorResponseFromCode('NOT_FOUND', ctx.log, {
|
||||
requestId: ctx.requestId,
|
||||
details: { resource: 'employee_opening_balances', employee_id: idParse.data },
|
||||
})
|
||||
}
|
||||
return ok(result.data, { requestId: ctx.requestId })
|
||||
},
|
||||
)
|
||||
|
||||
registerEndpoint({
|
||||
operation: 'employees.opening-balances.set',
|
||||
method: 'PUT',
|
||||
path: '/api/v1/companies/:companyId/employees/:id/opening-balances',
|
||||
summary: 'Set an employee\'s payroll cutover opening balances.',
|
||||
description:
|
||||
'Full-replace upsert of the cutover state: YTD gross/tax/net for the cutover year, paid vacation days remaining, sparade dagar keyed by origin year (5-year rule), opening semesterlöneskuld SEK (+avgifter), and karens periods not covered by imported absence rows. cutover_date must be the first of a month in the current or previous year, on/after employment_start.',
|
||||
useWhen:
|
||||
'Onboarding one employee during a mid-year migration from Fortnox/Visma/etc. For whole-company onboarding, prefer the bulk PUT /employees/opening-balances.',
|
||||
doNotUseFor:
|
||||
'SIE opening balances on the LEDGER (2920/2940 arrive via the SIE import). Ongoing sick cases: import pre-cutover days via PUT /employees/{id}/absence instead.',
|
||||
pitfalls: [
|
||||
'Full replace: omitted numeric fields reset to 0 (their defaults). Send the complete state every time.',
|
||||
'409 OPENING_BALANCES_LOCKED once the employee has a booked run; correcting that run unlocks.',
|
||||
'The opening liability is NOT booked by Accounted: it only feeds the vacation-liability report.',
|
||||
'YTD affects payslip display and reports only; per-month tax and avgifter caps never read it.',
|
||||
],
|
||||
example: {
|
||||
request: {
|
||||
cutover_date: '2026-07-01',
|
||||
ytd_gross: 210000,
|
||||
ytd_tax: 48000,
|
||||
ytd_net: 162000,
|
||||
vacation_paid_days_remaining: 12.5,
|
||||
vacation_saved_days_by_year: { '2025': 5 },
|
||||
opening_semester_liability: 42000,
|
||||
opening_semester_liability_avgifter: 13196.4,
|
||||
karens_periods_adjustment: 1,
|
||||
},
|
||||
response: {
|
||||
data: { employee_id: 'emp_77b2…', cutover_date: '2026-07-01', locked: false },
|
||||
meta: { request_id: 'req_…', api_version: '2026-05-12' },
|
||||
},
|
||||
},
|
||||
scope: 'payroll:write',
|
||||
risk: 'medium',
|
||||
idempotent: true,
|
||||
reversible: true,
|
||||
dryRunSupported: true,
|
||||
request: { body: OpeningBalancesFieldsSchema },
|
||||
response: { success: dataEnvelope(OpeningBalancesResponse) },
|
||||
})
|
||||
|
||||
export const PUT = withApiV1<{ params: Promise<{ companyId: string; id: string }> }>(
|
||||
'employees.opening-balances.set',
|
||||
async (request, ctx, params) => {
|
||||
const { id } = await params.params
|
||||
const idParse = z.string().uuid().safeParse(id)
|
||||
if (!idParse.success) {
|
||||
return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, {
|
||||
requestId: ctx.requestId,
|
||||
details: { field: 'id', message: 'Employee id must be a UUID.' },
|
||||
})
|
||||
}
|
||||
|
||||
let rawBody: unknown
|
||||
try {
|
||||
rawBody = await request.json()
|
||||
} catch {
|
||||
return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, {
|
||||
requestId: ctx.requestId,
|
||||
details: { field: 'body', message: 'Body is not valid JSON.' },
|
||||
})
|
||||
}
|
||||
|
||||
const parsed = OpeningBalancesFieldsSchema.safeParse(rawBody)
|
||||
if (!parsed.success) {
|
||||
return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, {
|
||||
requestId: ctx.requestId,
|
||||
details: {
|
||||
issues: parsed.error.issues.map((i) => ({
|
||||
field: i.path.join('.'),
|
||||
message: i.message,
|
||||
})),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// The per-employee PUT is the bulk handler with one item: one validation
|
||||
// and one upsert path to maintain.
|
||||
const result = await setOpeningBalancesBulk(ctx.supabase, {
|
||||
companyId: ctx.companyId!,
|
||||
userId: ctx.userId,
|
||||
items: [{ employee_id: idParse.data, ...parsed.data }],
|
||||
dryRun: ctx.dryRun,
|
||||
})
|
||||
|
||||
if (!result.ok) {
|
||||
// Single-item calls surface the item's own error code directly (a
|
||||
// one-element 422 list would just be indirection).
|
||||
const itemError = result.itemErrors?.[0]
|
||||
return v1ErrorResponseFromCode(itemError?.code ?? result.code, ctx.log, {
|
||||
requestId: ctx.requestId,
|
||||
details: itemError ? { message: itemError.message } : result.details,
|
||||
})
|
||||
}
|
||||
|
||||
const row = result.data.rows[0]
|
||||
if (ctx.dryRun) {
|
||||
return dryRunPreview(row, { requestId: ctx.requestId, log: ctx.log })
|
||||
}
|
||||
return ok(row, { requestId: ctx.requestId })
|
||||
},
|
||||
)
|
||||
@@ -41,6 +41,10 @@ const EmployeeDetail = z.object({
|
||||
employment_start: z.string(),
|
||||
employment_end: z.string().nullable(),
|
||||
employment_degree: z.number(),
|
||||
// Arbetsschema-lite: weekly schedule driving the salary engine's hourly
|
||||
// (173 at 40h) and daily (21 at 5d) divisors.
|
||||
hours_per_week: z.number(),
|
||||
workdays_per_week: z.number(),
|
||||
salary_type: SalaryType,
|
||||
monthly_salary: z.number().nullable(),
|
||||
hourly_rate: z.number().nullable(),
|
||||
@@ -62,6 +66,12 @@ const EmployeeDetail = z.object({
|
||||
vaxa_stod_eligible: z.boolean(),
|
||||
vaxa_stod_start: z.string().nullable(),
|
||||
vaxa_stod_end: z.string().nullable(),
|
||||
// Jämkning (Skatteverket beslut om ändrad beräkning av skatteavdrag):
|
||||
// fixed withholding percentage for a bounded period, overrides the
|
||||
// tax-table lookup at calculation time (payroll gap-closure 1.5).
|
||||
jamkning_percentage: z.number().nullable(),
|
||||
jamkning_valid_from: z.string().nullable(),
|
||||
jamkning_valid_to: z.string().nullable(),
|
||||
// Dimensions PR8: bag applied to the employee's P&L cost lines at booking.
|
||||
default_dimensions: z.record(z.string(), z.string()),
|
||||
is_active: z.boolean(),
|
||||
@@ -70,7 +80,7 @@ const EmployeeDetail = z.object({
|
||||
})
|
||||
|
||||
const EMPLOYEE_DETAIL_COLUMNS =
|
||||
'id, first_name, last_name, personnummer, employment_type, employment_start, employment_end, employment_degree, salary_type, monthly_salary, hourly_rate, tax_table_number, tax_column, tax_municipality, is_sidoinkomst, f_skatt_status, clearing_number, bank_account_number, vacation_rule, vacation_days_per_year, semestertillagg_rate, email, phone, address_line1, postal_code, city, vaxa_stod_eligible, vaxa_stod_start, vaxa_stod_end, default_dimensions, is_active, created_at, updated_at'
|
||||
'id, first_name, last_name, personnummer, employment_type, employment_start, employment_end, employment_degree, hours_per_week, workdays_per_week, salary_type, monthly_salary, hourly_rate, tax_table_number, tax_column, tax_municipality, is_sidoinkomst, f_skatt_status, clearing_number, bank_account_number, vacation_rule, vacation_days_per_year, semestertillagg_rate, email, phone, address_line1, postal_code, city, vaxa_stod_eligible, vaxa_stod_start, vaxa_stod_end, jamkning_percentage, jamkning_valid_from, jamkning_valid_to, default_dimensions, is_active, created_at, updated_at'
|
||||
|
||||
/**
|
||||
* Shape returned by PATCH (success + dry-run preview) and by no-change PATCH.
|
||||
@@ -346,6 +356,51 @@ export const PATCH = withApiV1<{ params: Promise<{ companyId: string; id: string
|
||||
})
|
||||
}
|
||||
|
||||
// Merged-state jämkning check (same pattern as växa-stöd): a non-null
|
||||
// percentage needs a start date, but the schema can only see the body.
|
||||
// Also validate merged date ordering when only one of the dates is
|
||||
// updated. Setting jamkning_percentage to null clears the beslut and
|
||||
// skips these checks. Only run when the PATCH touches a jamkning field:
|
||||
// a legacy row with inconsistent jamkning_* state must not block
|
||||
// unrelated updates (fixing it requires touching those very fields).
|
||||
const jamkningTouched =
|
||||
'jamkning_percentage' in updates ||
|
||||
'jamkning_valid_from' in updates ||
|
||||
'jamkning_valid_to' in updates
|
||||
if (jamkningTouched) {
|
||||
const mergedJamkningPct =
|
||||
'jamkning_percentage' in updates
|
||||
? (updates.jamkning_percentage as number | null)
|
||||
: ((existing as Record<string, unknown>).jamkning_percentage as number | null)
|
||||
const mergedJamkningFrom =
|
||||
'jamkning_valid_from' in updates
|
||||
? (updates.jamkning_valid_from as string | null)
|
||||
: ((existing as Record<string, unknown>).jamkning_valid_from as string | null)
|
||||
const mergedJamkningTo =
|
||||
'jamkning_valid_to' in updates
|
||||
? (updates.jamkning_valid_to as string | null)
|
||||
: ((existing as Record<string, unknown>).jamkning_valid_to as string | null)
|
||||
if (mergedJamkningPct !== null && mergedJamkningPct !== undefined && !mergedJamkningFrom) {
|
||||
return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, {
|
||||
requestId: ctx.requestId,
|
||||
details: {
|
||||
field: 'jamkning_valid_from',
|
||||
message:
|
||||
'Jämkningens startdatum måste anges när jämkningsprocent sätts. Skicka även `jamkning_valid_from` i samma PATCH.',
|
||||
},
|
||||
})
|
||||
}
|
||||
if (mergedJamkningFrom && mergedJamkningTo && mergedJamkningTo < mergedJamkningFrom) {
|
||||
return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, {
|
||||
requestId: ctx.requestId,
|
||||
details: {
|
||||
field: 'jamkning_valid_to',
|
||||
message: 'Jämkningens slutdatum måste vara efter startdatumet.',
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (Object.keys(updates).length === 0) {
|
||||
// GDPR Art.5(1)(c): no-change PATCH still returns a write-shape, so
|
||||
// mask personnummer just like the POST + PATCH success path.
|
||||
|
||||
+179
@@ -0,0 +1,179 @@
|
||||
/**
|
||||
* Tests for GET /api/v1/companies/{companyId}/employees/{id}/vacation-balance
|
||||
* (payroll gap-closure 3.4).
|
||||
*/
|
||||
|
||||
import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
beforeAll(() => {
|
||||
if (process.env.NODE_ENV !== 'test') {
|
||||
throw new Error(
|
||||
`vacation-balance route tests require NODE_ENV=test (got ${process.env.NODE_ENV ?? 'undefined'})`,
|
||||
)
|
||||
}
|
||||
process.env.NEXT_PUBLIC_SUPABASE_URL ||= 'http://localhost:54321'
|
||||
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY ||= 'test-anon-key'
|
||||
})
|
||||
|
||||
vi.mock('@/lib/auth/api-keys', async () => {
|
||||
const actual = await vi.importActual<typeof import('@/lib/auth/api-keys')>('@/lib/auth/api-keys')
|
||||
return {
|
||||
...actual,
|
||||
validateApiKey: vi.fn(),
|
||||
createServiceClientNoCookies: vi.fn(),
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@supabase/supabase-js', async () => {
|
||||
const actual = await vi.importActual<typeof import('@supabase/supabase-js')>('@supabase/supabase-js')
|
||||
return { ...actual, createClient: vi.fn().mockReturnValue({}) }
|
||||
})
|
||||
|
||||
import { validateApiKey, createServiceClientNoCookies } from '@/lib/auth/api-keys'
|
||||
import { GET as getBalance } from '../route'
|
||||
|
||||
const mockValidate = validateApiKey as ReturnType<typeof vi.fn>
|
||||
const mockServiceClient = createServiceClientNoCookies as ReturnType<typeof vi.fn>
|
||||
|
||||
function makeFlexibleSupabase(byTable: Record<string, { data?: unknown; error?: unknown }>) {
|
||||
const buildChain = (table: string): unknown => {
|
||||
const handler: ProxyHandler<object> = {
|
||||
get(_target, prop) {
|
||||
if (prop === 'then') {
|
||||
return (resolve: (v: unknown) => void) =>
|
||||
resolve(byTable[table] ?? { data: null, error: null })
|
||||
}
|
||||
return (..._args: unknown[]) => buildChain(table)
|
||||
},
|
||||
}
|
||||
return new Proxy({}, handler)
|
||||
}
|
||||
return { from: vi.fn((table: string) => buildChain(table)) }
|
||||
}
|
||||
|
||||
const COMPANY_ID = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa'
|
||||
const EMPLOYEE_ID = 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb'
|
||||
const BALANCE_ID = 'cccccccc-cccc-4ccc-8ccc-cccccccccccc'
|
||||
|
||||
const EMPLOYEE = {
|
||||
id: EMPLOYEE_ID,
|
||||
vacation_rule: 'sammaloneregeln',
|
||||
vacation_days_per_year: 25,
|
||||
salary_type: 'monthly',
|
||||
monthly_salary: 30000,
|
||||
hourly_rate: null,
|
||||
hours_per_week: 40,
|
||||
workdays_per_week: 5,
|
||||
}
|
||||
|
||||
const BALANCE = {
|
||||
id: BALANCE_ID,
|
||||
employee_id: EMPLOYEE_ID,
|
||||
vacation_year_start: '2026-01-01',
|
||||
entitled_days: 25,
|
||||
accrued_days: 0,
|
||||
taken_days: 10,
|
||||
saved_days: { '2025': 5 },
|
||||
forced_payout_days: 0,
|
||||
}
|
||||
|
||||
function makeRequest(url: string): Request {
|
||||
return new Request(url, {
|
||||
headers: { Authorization: 'Bearer test-fixture-not-a-real-key' },
|
||||
})
|
||||
}
|
||||
|
||||
function detailParams(companyId: string, id: string) {
|
||||
return { params: Promise.resolve({ companyId, id }) }
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockValidate.mockResolvedValue({
|
||||
userId: 'user-1',
|
||||
companyId: COMPANY_ID,
|
||||
apiKeyId: 'ak_1',
|
||||
apiKeyName: 'CI key',
|
||||
scopes: ['payroll:read'],
|
||||
mode: 'live',
|
||||
})
|
||||
})
|
||||
|
||||
describe('GET /employees/:id/vacation-balance', () => {
|
||||
it('returns the balance with remaining days and a SEK estimate', async () => {
|
||||
mockServiceClient.mockReturnValue(
|
||||
makeFlexibleSupabase({
|
||||
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
|
||||
employees: { data: EMPLOYEE, error: null },
|
||||
employee_vacation_balances: { data: BALANCE, error: null },
|
||||
}),
|
||||
)
|
||||
|
||||
const res = await getBalance(
|
||||
makeRequest(
|
||||
`https://x.test/api/v1/companies/${COMPANY_ID}/employees/${EMPLOYEE_ID}/vacation-balance`,
|
||||
),
|
||||
detailParams(COMPANY_ID, EMPLOYEE_ID),
|
||||
)
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
const body = await res.json()
|
||||
expect(body.data.employee_vacation_balance_id).toBe(BALANCE_ID)
|
||||
expect(body.data.remaining_days).toBe(15)
|
||||
expect(body.data.saved_days_total).toBe(5)
|
||||
// Day value sammalöneregeln: 30000/21 + 30000 x 0.0043 = 1557.57.
|
||||
// Liability = (15 remaining + 5 saved) x 1557.57 = 31151.4.
|
||||
expect(body.data.estimated_liability_sek).toBe(31151.4)
|
||||
expect(body.data.id).toBeUndefined()
|
||||
})
|
||||
|
||||
it('returns 404 VACATION_BALANCE_NOT_FOUND before the ledger seeds', async () => {
|
||||
mockServiceClient.mockReturnValue(
|
||||
makeFlexibleSupabase({
|
||||
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
|
||||
employees: { data: EMPLOYEE, error: null },
|
||||
employee_vacation_balances: { data: null, error: null },
|
||||
}),
|
||||
)
|
||||
|
||||
const res = await getBalance(
|
||||
makeRequest(
|
||||
`https://x.test/api/v1/companies/${COMPANY_ID}/employees/${EMPLOYEE_ID}/vacation-balance`,
|
||||
),
|
||||
detailParams(COMPANY_ID, EMPLOYEE_ID),
|
||||
)
|
||||
expect(res.status).toBe(404)
|
||||
const body = await res.json()
|
||||
expect(body.error.code).toBe('VACATION_BALANCE_NOT_FOUND')
|
||||
})
|
||||
|
||||
it('returns 404 EMPLOYEE_NOT_FOUND for an unknown employee', async () => {
|
||||
mockServiceClient.mockReturnValue(
|
||||
makeFlexibleSupabase({
|
||||
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
|
||||
employees: { data: null, error: null },
|
||||
}),
|
||||
)
|
||||
const res = await getBalance(
|
||||
makeRequest(
|
||||
`https://x.test/api/v1/companies/${COMPANY_ID}/employees/${EMPLOYEE_ID}/vacation-balance`,
|
||||
),
|
||||
detailParams(COMPANY_ID, EMPLOYEE_ID),
|
||||
)
|
||||
expect(res.status).toBe(404)
|
||||
const body = await res.json()
|
||||
expect(body.error.code).toBe('EMPLOYEE_NOT_FOUND')
|
||||
})
|
||||
|
||||
it('returns 401 without a bearer token', async () => {
|
||||
mockValidate.mockResolvedValue(null)
|
||||
mockServiceClient.mockReturnValue(makeFlexibleSupabase({}))
|
||||
const res = await getBalance(
|
||||
new Request(
|
||||
`https://x.test/api/v1/companies/${COMPANY_ID}/employees/${EMPLOYEE_ID}/vacation-balance`,
|
||||
),
|
||||
detailParams(COMPANY_ID, EMPLOYEE_ID),
|
||||
)
|
||||
expect(res.status).toBe(401)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,174 @@
|
||||
/**
|
||||
* GET /api/v1/companies/{companyId}/employees/{id}/vacation-balance
|
||||
*
|
||||
* The employee's current OPEN vacation-ledger row: entitled/taken/remaining
|
||||
* days, sparade dagar by origin year, forced payouts, plus a computed SEK
|
||||
* estimate of the individual semesterlöneskuld (same day valuation the
|
||||
* year-close uses).
|
||||
*/
|
||||
|
||||
import { z } from 'zod'
|
||||
import { ok } from '@/lib/api/v1/response'
|
||||
import { registerEndpoint, dataEnvelope } from '@/lib/api/v1/registry'
|
||||
import { withApiV1 } from '@/lib/api/v1/with-api-v1'
|
||||
import { v1ErrorResponse, v1ErrorResponseFromCode } from '@/lib/api/v1/errors'
|
||||
import { roundOre } from '@/lib/money'
|
||||
import { dailyDivisor } from '@/lib/salary/work-schedule'
|
||||
|
||||
const VacationBalanceResponse = z.object({
|
||||
employee_vacation_balance_id: z.string().uuid(),
|
||||
employee_id: z.string().uuid(),
|
||||
vacation_year_start: z.string(),
|
||||
entitled_days: z.number(),
|
||||
accrued_days: z.number(),
|
||||
taken_days: z.number(),
|
||||
remaining_days: z.number(),
|
||||
saved_days: z.record(z.string(), z.number()),
|
||||
saved_days_total: z.number(),
|
||||
forced_payout_days: z.number(),
|
||||
estimated_liability_sek: z.number(),
|
||||
})
|
||||
|
||||
registerEndpoint({
|
||||
operation: 'employees.vacation-balance.get',
|
||||
method: 'GET',
|
||||
path: '/api/v1/companies/:companyId/employees/:id/vacation-balance',
|
||||
summary: 'Get an employee\'s current vacation balance.',
|
||||
description:
|
||||
'Returns the open vacation-ledger row (recomputed on every booking): entitled/taken/remaining days, sparade dagar keyed by origin year (Semesterlagen 5-year rule), forced-payout days from expired savings, and a computed SEK estimate of the individual semesterlöneskuld.',
|
||||
useWhen:
|
||||
'Answering "how many vacation days does Anna have left", pre-payroll review, or preparing the year-close.',
|
||||
doNotUseFor:
|
||||
'The company-wide liability report: GET /reports/vacation-liability. Closing the year: POST /salary/vacation-year-close.',
|
||||
pitfalls: [
|
||||
'404 VACATION_BALANCE_NOT_FOUND until the first booking (or year-close) touches the employee: the ledger seeds lazily.',
|
||||
'remaining_days can go negative if more days were taken than entitled: surface it, do not clamp.',
|
||||
'The SEK estimate uses the year-close day valuation (simplified BFNAR 2016:10); the booked 2920 is reconciled only at year-close.',
|
||||
],
|
||||
example: {
|
||||
response: {
|
||||
data: {
|
||||
employee_id: 'emp_77b2…',
|
||||
vacation_year_start: '2026-01-01',
|
||||
entitled_days: 25,
|
||||
taken_days: 10,
|
||||
remaining_days: 15,
|
||||
saved_days: { '2025': 5 },
|
||||
saved_days_total: 5,
|
||||
estimated_liability_sek: 31151.4,
|
||||
},
|
||||
meta: { request_id: 'req_…', api_version: '2026-05-12' },
|
||||
},
|
||||
},
|
||||
scope: 'payroll:read',
|
||||
risk: 'low',
|
||||
idempotent: true,
|
||||
reversible: false,
|
||||
dryRunSupported: false,
|
||||
response: { success: dataEnvelope(VacationBalanceResponse) },
|
||||
})
|
||||
|
||||
export const GET = withApiV1<{ params: Promise<{ companyId: string; id: string }> }>(
|
||||
'employees.vacation-balance.get',
|
||||
async (_request, ctx, params) => {
|
||||
const { id } = await params.params
|
||||
const idParse = z.string().uuid().safeParse(id)
|
||||
if (!idParse.success) {
|
||||
return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, {
|
||||
requestId: ctx.requestId,
|
||||
details: { field: 'id', message: 'Employee id must be a UUID.' },
|
||||
})
|
||||
}
|
||||
|
||||
const { data: employee, error: empErr } = await ctx.supabase
|
||||
.from('employees')
|
||||
.select('id, vacation_rule, vacation_days_per_year, salary_type, monthly_salary, hourly_rate, hours_per_week, workdays_per_week')
|
||||
.eq('id', idParse.data)
|
||||
.eq('company_id', ctx.companyId!)
|
||||
.maybeSingle()
|
||||
if (empErr) {
|
||||
return v1ErrorResponse(empErr, ctx.log, { requestId: ctx.requestId })
|
||||
}
|
||||
if (!employee) {
|
||||
return v1ErrorResponseFromCode('EMPLOYEE_NOT_FOUND', ctx.log, { requestId: ctx.requestId })
|
||||
}
|
||||
|
||||
const { data: balance, error: balErr } = await ctx.supabase
|
||||
.from('employee_vacation_balances')
|
||||
.select('id, employee_id, vacation_year_start, entitled_days, accrued_days, taken_days, saved_days, forced_payout_days')
|
||||
.eq('company_id', ctx.companyId!)
|
||||
.eq('employee_id', idParse.data)
|
||||
.eq('status', 'open')
|
||||
.order('vacation_year_start', { ascending: false })
|
||||
.limit(1)
|
||||
.maybeSingle()
|
||||
if (balErr) {
|
||||
return v1ErrorResponse(balErr, ctx.log, { requestId: ctx.requestId })
|
||||
}
|
||||
if (!balance) {
|
||||
return v1ErrorResponseFromCode('VACATION_BALANCE_NOT_FOUND', ctx.log, {
|
||||
requestId: ctx.requestId,
|
||||
details: { employee_id: idParse.data },
|
||||
})
|
||||
}
|
||||
|
||||
const row = balance as {
|
||||
id: string
|
||||
employee_id: string
|
||||
vacation_year_start: string
|
||||
entitled_days: number
|
||||
accrued_days: number
|
||||
taken_days: number
|
||||
saved_days: Record<string, number> | null
|
||||
forced_payout_days: number
|
||||
}
|
||||
const emp = employee as {
|
||||
vacation_rule: string
|
||||
vacation_days_per_year: number
|
||||
salary_type: string
|
||||
monthly_salary: number | null
|
||||
hourly_rate: number | null
|
||||
hours_per_week: number | null
|
||||
workdays_per_week: number | null
|
||||
}
|
||||
|
||||
const savedDays = row.saved_days ?? {}
|
||||
const savedTotal = Object.values(savedDays).reduce((s, d) => s + (Number(d) || 0), 0)
|
||||
const remaining = roundOre(row.entitled_days - row.taken_days)
|
||||
|
||||
// Same simplified BFNAR 2016:10 day valuation the year-close uses.
|
||||
const rate = emp.vacation_days_per_year >= 30 ? 0.144 : 0.12
|
||||
let dayValue: number
|
||||
if (emp.salary_type === 'hourly') {
|
||||
dayValue = roundOre(
|
||||
((emp.hourly_rate || 0) * (emp.hours_per_week ?? 40) * 52 * rate) /
|
||||
Math.max(emp.vacation_days_per_year, 1),
|
||||
)
|
||||
} else if (emp.vacation_rule === 'sammaloneregeln') {
|
||||
const monthly = emp.monthly_salary || 0
|
||||
dayValue = roundOre(monthly / dailyDivisor(emp.workdays_per_week) + monthly * 0.0043)
|
||||
} else {
|
||||
dayValue = roundOre(
|
||||
((emp.monthly_salary || 0) * 12 * rate) / Math.max(emp.vacation_days_per_year, 1),
|
||||
)
|
||||
}
|
||||
const estimatedLiability = roundOre(Math.max(0, remaining + savedTotal) * dayValue)
|
||||
|
||||
return ok(
|
||||
{
|
||||
employee_vacation_balance_id: row.id,
|
||||
employee_id: row.employee_id,
|
||||
vacation_year_start: row.vacation_year_start,
|
||||
entitled_days: row.entitled_days,
|
||||
accrued_days: row.accrued_days,
|
||||
taken_days: row.taken_days,
|
||||
remaining_days: remaining,
|
||||
saved_days: savedDays,
|
||||
saved_days_total: savedTotal,
|
||||
forced_payout_days: row.forced_payout_days,
|
||||
estimated_liability_sek: estimatedLiability,
|
||||
},
|
||||
{ requestId: ctx.requestId },
|
||||
)
|
||||
},
|
||||
)
|
||||
@@ -146,6 +146,9 @@ const SAMPLE_EMPLOYEE = {
|
||||
vaxa_stod_eligible: false,
|
||||
vaxa_stod_start: null,
|
||||
vaxa_stod_end: null,
|
||||
jamkning_percentage: null,
|
||||
jamkning_valid_from: null,
|
||||
jamkning_valid_to: null,
|
||||
is_active: true,
|
||||
created_at: '2024-01-15T08:00:00Z',
|
||||
updated_at: '2024-01-15T08:00:00Z',
|
||||
@@ -648,6 +651,159 @@ describe('PATCH /api/v1/companies/:companyId/employees/:id', () => {
|
||||
expect(body.error.details.field).toBe('personnummer')
|
||||
})
|
||||
|
||||
it('sets work-schedule fields (arbetsschema-lite)', async () => {
|
||||
const updated = { ...SAMPLE_EMPLOYEE, hours_per_week: 32, workdays_per_week: 4 }
|
||||
mockServiceClient.mockReturnValue(
|
||||
makeFlexibleSupabase({
|
||||
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
|
||||
employees: [{ data: SAMPLE_EMPLOYEE, error: null }, { data: updated, error: null }],
|
||||
idempotency_keys: { data: null, error: null },
|
||||
}),
|
||||
)
|
||||
|
||||
const res = await updateEmployee(
|
||||
makeRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/employees/${EMPLOYEE_ID}`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify({ hours_per_week: 32, workdays_per_week: 4 }),
|
||||
}),
|
||||
detailParams(COMPANY_ID, EMPLOYEE_ID),
|
||||
)
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
const body = await res.json()
|
||||
expect(body.data.hours_per_week).toBe(32)
|
||||
expect(body.data.workdays_per_week).toBe(4)
|
||||
})
|
||||
|
||||
it('rejects an out-of-range work schedule', async () => {
|
||||
mockServiceClient.mockReturnValue(
|
||||
makeFlexibleSupabase({
|
||||
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
|
||||
}),
|
||||
)
|
||||
const res = await updateEmployee(
|
||||
makeRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/employees/${EMPLOYEE_ID}`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify({ workdays_per_week: 9 }),
|
||||
}),
|
||||
detailParams(COMPANY_ID, EMPLOYEE_ID),
|
||||
)
|
||||
expect(res.status).toBe(400)
|
||||
})
|
||||
|
||||
it('sets jämkning fields (percentage + validity window)', async () => {
|
||||
const updated = {
|
||||
...SAMPLE_EMPLOYEE,
|
||||
jamkning_percentage: 15,
|
||||
jamkning_valid_from: '2026-01-01',
|
||||
jamkning_valid_to: '2026-12-31',
|
||||
}
|
||||
mockServiceClient.mockReturnValue(
|
||||
makeFlexibleSupabase({
|
||||
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
|
||||
employees: [{ data: SAMPLE_EMPLOYEE, error: null }, { data: updated, error: null }],
|
||||
idempotency_keys: { data: null, error: null },
|
||||
}),
|
||||
)
|
||||
|
||||
const res = await updateEmployee(
|
||||
makeRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/employees/${EMPLOYEE_ID}`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify({
|
||||
jamkning_percentage: 15,
|
||||
jamkning_valid_from: '2026-01-01',
|
||||
jamkning_valid_to: '2026-12-31',
|
||||
}),
|
||||
}),
|
||||
detailParams(COMPANY_ID, EMPLOYEE_ID),
|
||||
)
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
const body = await res.json()
|
||||
expect(body.data.jamkning_percentage).toBe(15)
|
||||
expect(body.data.jamkning_valid_from).toBe('2026-01-01')
|
||||
})
|
||||
|
||||
it('rejects a jämkning percentage without a start date (merged state)', async () => {
|
||||
// Existing row has no jamkning_valid_from; sending only the percentage
|
||||
// must fail the route-level merged-state check.
|
||||
mockServiceClient.mockReturnValue(
|
||||
makeFlexibleSupabase({
|
||||
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
|
||||
employees: { data: SAMPLE_EMPLOYEE, error: null },
|
||||
idempotency_keys: { data: null, error: null },
|
||||
}),
|
||||
)
|
||||
|
||||
const res = await updateEmployee(
|
||||
makeRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/employees/${EMPLOYEE_ID}`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify({ jamkning_percentage: 15 }),
|
||||
}),
|
||||
detailParams(COMPANY_ID, EMPLOYEE_ID),
|
||||
)
|
||||
|
||||
expect(res.status).toBe(400)
|
||||
const body = await res.json()
|
||||
expect(body.error.code).toBe('VALIDATION_ERROR')
|
||||
expect(body.error.details.field).toBe('jamkning_valid_from')
|
||||
})
|
||||
|
||||
it('rejects jamkning_valid_to before jamkning_valid_from', async () => {
|
||||
mockServiceClient.mockReturnValue(
|
||||
makeFlexibleSupabase({
|
||||
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
|
||||
employees: { data: SAMPLE_EMPLOYEE, error: null },
|
||||
idempotency_keys: { data: null, error: null },
|
||||
}),
|
||||
)
|
||||
|
||||
const res = await updateEmployee(
|
||||
makeRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/employees/${EMPLOYEE_ID}`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify({
|
||||
jamkning_percentage: 15,
|
||||
jamkning_valid_from: '2026-06-01',
|
||||
jamkning_valid_to: '2026-01-01',
|
||||
}),
|
||||
}),
|
||||
detailParams(COMPANY_ID, EMPLOYEE_ID),
|
||||
)
|
||||
|
||||
expect(res.status).toBe(400)
|
||||
const body = await res.json()
|
||||
expect(body.error.code).toBe('VALIDATION_ERROR')
|
||||
})
|
||||
|
||||
it('clears the jämkningsbeslut with an explicit null', async () => {
|
||||
const withJamkning = {
|
||||
...SAMPLE_EMPLOYEE,
|
||||
jamkning_percentage: 15,
|
||||
jamkning_valid_from: '2026-01-01',
|
||||
jamkning_valid_to: null,
|
||||
}
|
||||
const cleared = { ...SAMPLE_EMPLOYEE }
|
||||
mockServiceClient.mockReturnValue(
|
||||
makeFlexibleSupabase({
|
||||
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
|
||||
employees: [{ data: withJamkning, error: null }, { data: cleared, error: null }],
|
||||
idempotency_keys: { data: null, error: null },
|
||||
}),
|
||||
)
|
||||
|
||||
const res = await updateEmployee(
|
||||
makeRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/employees/${EMPLOYEE_ID}`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify({ jamkning_percentage: null }),
|
||||
}),
|
||||
detailParams(COMPANY_ID, EMPLOYEE_ID),
|
||||
)
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
const body = await res.json()
|
||||
expect(body.data.jamkning_percentage).toBeNull()
|
||||
})
|
||||
|
||||
it('returns a dry-run preview with masked personnummer', async () => {
|
||||
// GDPR Art.5(1)(c): the dry-run preview is a write-shape so it follows
|
||||
// the same masking rule as POST and PATCH success. The full value is
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
/**
|
||||
* PUT /api/v1/companies/{companyId}/employees/opening-balances
|
||||
*
|
||||
* Bulk full-replace upsert of payroll cutover opening balances: the byrå/
|
||||
* integrator onboarding surface for mid-year migrations. ATOMIC
|
||||
* all-or-nothing: every item is validated against live state first
|
||||
* (employee exists + active, cutover >= employment_start, not locked by a
|
||||
* booked run); any failure returns the complete per-item error list with
|
||||
* ZERO writes, so the caller fixes the file and resubmits.
|
||||
*/
|
||||
|
||||
import { z } from 'zod'
|
||||
import { ok } from '@/lib/api/v1/response'
|
||||
import { dryRunPreview } from '@/lib/api/v1/dry-run'
|
||||
import { registerEndpoint, dataEnvelope } from '@/lib/api/v1/registry'
|
||||
import { withApiV1 } from '@/lib/api/v1/with-api-v1'
|
||||
import { v1ErrorResponse, v1ErrorResponseFromCode } from '@/lib/api/v1/errors'
|
||||
import { OpeningBalancesBulkSchema } from '@/lib/api/schemas'
|
||||
import { setOpeningBalancesBulk } from '@/lib/salary/opening-balances'
|
||||
|
||||
const BulkResponse = z.object({
|
||||
count: z.number().int(),
|
||||
rows: z.array(
|
||||
z.object({
|
||||
employee_opening_balances_id: z.string().uuid().nullable(),
|
||||
employee_id: z.string().uuid(),
|
||||
cutover_date: z.string(),
|
||||
locked: z.boolean(),
|
||||
}),
|
||||
),
|
||||
})
|
||||
|
||||
registerEndpoint({
|
||||
operation: 'employees.opening-balances.bulk-set',
|
||||
method: 'PUT',
|
||||
path: '/api/v1/companies/:companyId/employees/opening-balances',
|
||||
summary: 'Bulk-set payroll cutover opening balances (atomic).',
|
||||
description:
|
||||
'Upserts opening balances for up to 200 employees in one call. Validation is all-or-nothing: any invalid item (unknown/inactive employee, cutover before employment_start, locked by a booked run) fails the WHOLE request with a per-item error list and zero writes.',
|
||||
useWhen:
|
||||
'Onboarding a whole company mid-year from another payroll system: one call per migration file instead of N sequential PUTs.',
|
||||
doNotUseFor:
|
||||
'Single-employee corrections after go-live: PUT /employees/{id}/opening-balances. Ledger opening balances (SIE import).',
|
||||
pitfalls: [
|
||||
'Atomic: one bad item fails everything. The error details carry item_errors[{index, employee_id, code, message}]: fix and resubmit the full set.',
|
||||
'Full replace per employee: resubmitting with fewer fields resets the omitted ones to 0.',
|
||||
'Duplicate employee_id within items is rejected outright.',
|
||||
],
|
||||
example: {
|
||||
request: {
|
||||
items: [
|
||||
{ employee_id: 'emp_77b2…', cutover_date: '2026-07-01', ytd_gross: 210000, ytd_tax: 48000, ytd_net: 162000 },
|
||||
],
|
||||
},
|
||||
response: {
|
||||
data: { count: 1, rows: [{ employee_id: 'emp_77b2…', cutover_date: '2026-07-01', locked: false }] },
|
||||
meta: { request_id: 'req_…', api_version: '2026-05-12' },
|
||||
},
|
||||
},
|
||||
scope: 'payroll:write',
|
||||
risk: 'medium',
|
||||
idempotent: true,
|
||||
reversible: true,
|
||||
dryRunSupported: true,
|
||||
request: { body: OpeningBalancesBulkSchema },
|
||||
response: { success: dataEnvelope(BulkResponse) },
|
||||
})
|
||||
|
||||
export const PUT = withApiV1<{ params: Promise<{ companyId: string }> }>(
|
||||
'employees.opening-balances.bulk-set',
|
||||
async (request, ctx) => {
|
||||
let rawBody: unknown
|
||||
try {
|
||||
rawBody = await request.json()
|
||||
} catch {
|
||||
return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, {
|
||||
requestId: ctx.requestId,
|
||||
details: { field: 'body', message: 'Body is not valid JSON.' },
|
||||
})
|
||||
}
|
||||
|
||||
const parsed = OpeningBalancesBulkSchema.safeParse(rawBody)
|
||||
if (!parsed.success) {
|
||||
return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, {
|
||||
requestId: ctx.requestId,
|
||||
details: {
|
||||
issues: parsed.error.issues.map((i) => ({
|
||||
field: i.path.join('.'),
|
||||
message: i.message,
|
||||
})),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const result = await setOpeningBalancesBulk(ctx.supabase, {
|
||||
companyId: ctx.companyId!,
|
||||
userId: ctx.userId,
|
||||
items: parsed.data.items,
|
||||
dryRun: ctx.dryRun,
|
||||
})
|
||||
|
||||
if (!result.ok) {
|
||||
if (result.itemErrors) {
|
||||
// Atomic contract: full per-item error list, zero writes.
|
||||
return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, {
|
||||
requestId: ctx.requestId,
|
||||
details: { item_errors: result.itemErrors },
|
||||
})
|
||||
}
|
||||
if (result.code === 'INTERNAL_ERROR') {
|
||||
return v1ErrorResponse(new Error(String(result.details?.message ?? 'bulk upsert failed')), ctx.log, {
|
||||
requestId: ctx.requestId,
|
||||
})
|
||||
}
|
||||
return v1ErrorResponseFromCode(result.code, ctx.log, {
|
||||
requestId: ctx.requestId,
|
||||
details: result.details,
|
||||
})
|
||||
}
|
||||
|
||||
if (ctx.dryRun) {
|
||||
return dryRunPreview(result.data, { requestId: ctx.requestId, log: ctx.log })
|
||||
}
|
||||
return ok(result.data, { requestId: ctx.requestId })
|
||||
},
|
||||
)
|
||||
@@ -416,6 +416,8 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string }> }>(
|
||||
employment_start: body.employment_start,
|
||||
employment_end: body.employment_end ?? null,
|
||||
employment_degree: body.employment_degree,
|
||||
hours_per_week: body.hours_per_week,
|
||||
workdays_per_week: body.workdays_per_week,
|
||||
salary_type: body.salary_type,
|
||||
monthly_salary: body.monthly_salary ?? null,
|
||||
hourly_rate: body.hourly_rate ?? null,
|
||||
@@ -437,6 +439,9 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string }> }>(
|
||||
vaxa_stod_eligible: body.vaxa_stod_eligible,
|
||||
vaxa_stod_start: body.vaxa_stod_start ?? null,
|
||||
vaxa_stod_end: body.vaxa_stod_end ?? null,
|
||||
jamkning_percentage: body.jamkning_percentage ?? null,
|
||||
jamkning_valid_from: body.jamkning_valid_from ?? null,
|
||||
jamkning_valid_to: body.jamkning_valid_to ?? null,
|
||||
// Dimensions PR8: bag for the employee's P&L cost lines at booking.
|
||||
default_dimensions: body.default_dimensions ?? {},
|
||||
})
|
||||
|
||||
@@ -369,6 +369,50 @@ describe('POST /api/v1/companies/:companyId/invoices/:id/mark-paid', () => {
|
||||
expect(mockPayment).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('absorbs an öresavrundning overshoot on SEK custom lines (rounded "Att betala")', async () => {
|
||||
// Invoice stored with öre (1234.75); the PDF shows 1235.00 and the customer
|
||||
// pays that. The 3740 line carries the residual and the invoice settles in
|
||||
// full instead of being rejected as an overpayment.
|
||||
const ORE_INVOICE = {
|
||||
...SENT_INVOICE,
|
||||
subtotal: 987.8,
|
||||
vat_amount: 246.95,
|
||||
total: 1234.75,
|
||||
remaining_amount: 1234.75,
|
||||
}
|
||||
mockServiceClient.mockReturnValue(
|
||||
makeFlexibleSupabase({
|
||||
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
|
||||
invoices: [
|
||||
{ data: ORE_INVOICE, error: null },
|
||||
{ data: { ...ORE_INVOICE, status: 'paid', remaining_amount: 0, paid_amount: 1234.75 }, error: null },
|
||||
],
|
||||
company_settings: { data: { accounting_method: 'accrual', entity_type: 'enskild_firma' }, error: null },
|
||||
transactions: { data: [], error: null },
|
||||
}),
|
||||
)
|
||||
|
||||
const res = await markPaid(
|
||||
makeRequest(
|
||||
`https://x.test/api/v1/companies/${COMPANY_ID}/invoices/${INVOICE_ID}/mark-paid`,
|
||||
{
|
||||
payment_date: '2026-05-12',
|
||||
lines: [
|
||||
{ account_number: '1930', debit_amount: 1235, credit_amount: 0 },
|
||||
{ account_number: '1510', debit_amount: 0, credit_amount: 1234.75 },
|
||||
{ account_number: '3740', debit_amount: 0, credit_amount: 0.25 },
|
||||
],
|
||||
},
|
||||
),
|
||||
detailParams(COMPANY_ID, INVOICE_ID),
|
||||
)
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
const body = await res.json()
|
||||
expect(body.data.status).toBe('paid')
|
||||
expect(body.data.remaining_amount).toBe(0)
|
||||
})
|
||||
|
||||
it('returns 400 INVOICE_PAID_NOT_PAYABLE for draft invoices', async () => {
|
||||
mockServiceClient.mockReturnValue(
|
||||
makeFlexibleSupabase({
|
||||
|
||||
@@ -42,7 +42,7 @@ import { AccountsNotInChartError, isBookkeepingError } from '@/lib/bookkeeping/e
|
||||
import { getErrorMessage } from '@/lib/errors/get-error-message'
|
||||
import { eventBus } from '@/lib/events'
|
||||
import { findDuplicatePaymentCandidatesForInvoice } from '@/lib/invoices/duplicate-payment-candidates'
|
||||
import { planInvoicePayment } from '@/lib/invoices/apply-invoice-payment'
|
||||
import { planInvoicePaymentForLines } from '@/lib/invoices/apply-invoice-payment'
|
||||
import { roundOre } from '@/lib/money'
|
||||
import type { CreateJournalEntryInput, EntityType, Invoice } from '@/types'
|
||||
|
||||
@@ -285,7 +285,18 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string
|
||||
const paymentAmountInInvoiceCurrency = customLines
|
||||
? roundOre(paymentAmount / fxRate)
|
||||
: paymentAmount
|
||||
const payment = planInvoicePayment(typed, paymentAmountInInvoiceCurrency)
|
||||
// Custom-line SEK settlements absorb a sub-krona öresavrundning residual
|
||||
// (rounded "Att betala" vs the stored öre total), but ONLY when the lines
|
||||
// actually carry the residual on 3740: otherwise the strict plan applies
|
||||
// (sub-krona partials stay partial, overshoot rejects), mirroring the
|
||||
// dashboard mark-paid flow. The default path pays the exact remaining, so
|
||||
// absorption is a no-op there.
|
||||
const payment = planInvoicePaymentForLines(
|
||||
typed,
|
||||
paymentAmountInInvoiceCurrency,
|
||||
customLines,
|
||||
typed.currency ?? 'SEK',
|
||||
)
|
||||
if (!payment.ok) {
|
||||
return v1ErrorResponseFromCode('MATCH_AMOUNT_EXCEEDS_REMAINING', ctx.log, {
|
||||
requestId: ctx.requestId,
|
||||
|
||||
@@ -34,6 +34,7 @@ import { withApiV1 } from '@/lib/api/v1/with-api-v1'
|
||||
import { v1ErrorResponse, v1ErrorResponseFromCode } from '@/lib/api/v1/errors'
|
||||
import { checkPeriodLock } from '@/lib/api/v1/check-period-lock'
|
||||
import { createSalaryRunEntries } from '@/lib/salary/salary-entries'
|
||||
import { syncVacationLedgerForEmployees } from '@/lib/salary/vacation-ledger'
|
||||
import { isBookkeepingError } from '@/lib/bookkeeping/errors'
|
||||
import { eventBus } from '@/lib/events'
|
||||
|
||||
@@ -346,6 +347,17 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string
|
||||
ctx.log.warn('salary_run.booked emit failed', err as Error)
|
||||
}
|
||||
|
||||
// Vacation ledger sync (non-fatal: the ledger recomputes and self-heals
|
||||
// on the next booking; a sync bug must never block a booking).
|
||||
const ledgerSync = await syncVacationLedgerForEmployees(
|
||||
ctx.supabase,
|
||||
ctx.companyId!,
|
||||
(employees as Array<{ employee_id: string }>).map((sre) => sre.employee_id),
|
||||
)
|
||||
if (!ledgerSync.ok) {
|
||||
ctx.log.warn('vacation ledger sync failed after booking', { message: ledgerSync.message })
|
||||
}
|
||||
|
||||
const bookedAt = (bookedRun as { booked_at: string }).booked_at
|
||||
|
||||
return ok(
|
||||
|
||||
+150
@@ -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')
|
||||
})
|
||||
})
|
||||
+133
@@ -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,
|
||||
},
|
||||
})
|
||||
},
|
||||
)
|
||||
+279
@@ -0,0 +1,279 @@
|
||||
/**
|
||||
* Tests for GET /api/v1/companies/{companyId}/salary-runs/{id}/payslips/{employeeId}/pdf
|
||||
* (payroll gap-closure 1.1).
|
||||
*
|
||||
* renderToBuffer is mocked (the invoice-pdf test pattern): these tests assert
|
||||
* routing, auth, 404 paths, and the binary response headers, not PDF pixels.
|
||||
*/
|
||||
|
||||
import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
beforeAll(() => {
|
||||
if (process.env.NODE_ENV !== 'test') {
|
||||
throw new Error(
|
||||
`payslip pdf route tests require NODE_ENV=test (got ${process.env.NODE_ENV ?? 'undefined'})`,
|
||||
)
|
||||
}
|
||||
process.env.NEXT_PUBLIC_SUPABASE_URL ||= 'http://localhost:54321'
|
||||
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY ||= 'test-anon-key'
|
||||
})
|
||||
|
||||
vi.mock('@/lib/auth/api-keys', async () => {
|
||||
const actual = await vi.importActual<typeof import('@/lib/auth/api-keys')>('@/lib/auth/api-keys')
|
||||
return {
|
||||
...actual,
|
||||
validateApiKey: vi.fn(),
|
||||
createServiceClientNoCookies: vi.fn(),
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@supabase/supabase-js', async () => {
|
||||
const actual = await vi.importActual<typeof import('@supabase/supabase-js')>('@supabase/supabase-js')
|
||||
return { ...actual, createClient: vi.fn().mockReturnValue({}) }
|
||||
})
|
||||
|
||||
vi.mock('@react-pdf/renderer', () => ({
|
||||
renderToBuffer: vi.fn().mockResolvedValue(Buffer.from('%PDF-1.7 test')),
|
||||
// The payslip template imports these primitives at module scope.
|
||||
Document: () => null,
|
||||
Page: () => null,
|
||||
Text: () => null,
|
||||
View: () => null,
|
||||
StyleSheet: { create: (s: unknown) => s },
|
||||
Font: { register: () => undefined },
|
||||
}))
|
||||
|
||||
import { validateApiKey, createServiceClientNoCookies } from '@/lib/auth/api-keys'
|
||||
import { renderToBuffer } from '@react-pdf/renderer'
|
||||
import { GET as getPayslipPdf } from '../[employeeId]/pdf/route'
|
||||
|
||||
const mockValidate = validateApiKey as ReturnType<typeof vi.fn>
|
||||
const mockServiceClient = createServiceClientNoCookies as ReturnType<typeof vi.fn>
|
||||
const mockRender = renderToBuffer as ReturnType<typeof vi.fn>
|
||||
|
||||
interface TableResp {
|
||||
data?: unknown
|
||||
error?: unknown
|
||||
}
|
||||
|
||||
function makeFlexibleSupabase(byTable: Record<string, TableResp | TableResp[]>) {
|
||||
const queues = new Map<string, TableResp[]>()
|
||||
for (const [t, val] of Object.entries(byTable)) {
|
||||
queues.set(t, Array.isArray(val) ? [...val] : [val])
|
||||
}
|
||||
const buildChain = (table: string): unknown => {
|
||||
const handler: ProxyHandler<object> = {
|
||||
get(_target, prop) {
|
||||
if (prop === 'then') {
|
||||
return (resolve: (v: unknown) => void) => {
|
||||
const q = queues.get(table)
|
||||
const next = q && q.length > 1 ? q.shift()! : (q?.[0] ?? { data: null, error: null })
|
||||
resolve(next)
|
||||
}
|
||||
}
|
||||
return (..._args: unknown[]) => buildChain(table)
|
||||
},
|
||||
}
|
||||
return new Proxy({}, handler)
|
||||
}
|
||||
return { from: vi.fn((table: string) => buildChain(table)) }
|
||||
}
|
||||
|
||||
const COMPANY_ID = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa'
|
||||
const RUN_ID = 'cccccccc-cccc-4ccc-8ccc-cccccccccccc'
|
||||
const EMPLOYEE_ID = 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb'
|
||||
const USER_ID = 'user-1'
|
||||
|
||||
const SAMPLE_PERSONNUMMER = '190001010000'
|
||||
|
||||
const SAMPLE_RUN = {
|
||||
id: RUN_ID,
|
||||
period_year: 2026,
|
||||
period_month: 5,
|
||||
payment_date: '2026-05-25',
|
||||
status: 'booked',
|
||||
}
|
||||
|
||||
const SAMPLE_SRE = {
|
||||
id: 'dddddddd-dddd-4ddd-8ddd-dddddddddddd',
|
||||
gross_salary: 35000,
|
||||
tax_withheld: 8200,
|
||||
tax_withheld_override: null,
|
||||
avgifter_rate: 0.3142,
|
||||
avgifter_amount: 10997,
|
||||
avgifter_basis_override: null,
|
||||
avgifter_amount_override: null,
|
||||
override_reason: null,
|
||||
net_salary: 26800,
|
||||
vacation_accrual: 4200,
|
||||
vacation_accrual_avgifter: 1319.64,
|
||||
ytd_gross: 70000,
|
||||
ytd_tax: 16400,
|
||||
ytd_net: 53600,
|
||||
calculation_breakdown: null,
|
||||
employee: {
|
||||
first_name: 'Anna',
|
||||
last_name: 'Andersson',
|
||||
personnummer: SAMPLE_PERSONNUMMER,
|
||||
personnummer_last4: '0000',
|
||||
employment_type: 'employee',
|
||||
tax_table_number: 33,
|
||||
tax_column: 1,
|
||||
clearing_number: '6000',
|
||||
bank_account_number: '12345678',
|
||||
},
|
||||
line_items: [
|
||||
{
|
||||
description: 'Grundlön',
|
||||
quantity: null,
|
||||
unit_price: null,
|
||||
amount: 35000,
|
||||
sort_order: 0,
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
function makeRequest(url: string): Request {
|
||||
return new Request(url, {
|
||||
headers: { Authorization: 'Bearer test-fixture-not-a-real-key' },
|
||||
})
|
||||
}
|
||||
|
||||
function pdfParams(companyId: string, id: string, employeeId: string) {
|
||||
return { params: Promise.resolve({ companyId, id, employeeId }) }
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockRender.mockResolvedValue(Buffer.from('%PDF-1.7 test'))
|
||||
mockValidate.mockResolvedValue({
|
||||
userId: USER_ID,
|
||||
companyId: COMPANY_ID,
|
||||
apiKeyId: 'ak_1',
|
||||
apiKeyName: 'CI key',
|
||||
scopes: ['payroll:read'],
|
||||
mode: 'live',
|
||||
})
|
||||
})
|
||||
|
||||
describe('GET /api/v1/companies/:companyId/salary-runs/:id/payslips/:employeeId/pdf', () => {
|
||||
it('returns the rendered PDF with attachment disposition (happy path)', async () => {
|
||||
mockServiceClient.mockReturnValue(
|
||||
makeFlexibleSupabase({
|
||||
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
|
||||
salary_runs: { data: SAMPLE_RUN, error: null },
|
||||
salary_run_employees: { data: SAMPLE_SRE, error: null },
|
||||
companies: { data: { name: 'Testbolaget AB', org_number: '5560000000' }, error: null },
|
||||
company_settings: { data: { company_name: 'Testbolaget AB' }, error: null },
|
||||
}),
|
||||
)
|
||||
|
||||
const res = await getPayslipPdf(
|
||||
makeRequest(
|
||||
`https://x.test/api/v1/companies/${COMPANY_ID}/salary-runs/${RUN_ID}/payslips/${EMPLOYEE_ID}/pdf`,
|
||||
),
|
||||
pdfParams(COMPANY_ID, RUN_ID, EMPLOYEE_ID),
|
||||
)
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
expect(res.headers.get('Content-Type')).toBe('application/pdf')
|
||||
expect(res.headers.get('Content-Disposition')).toContain('attachment')
|
||||
expect(res.headers.get('Content-Disposition')).toContain('lonespec_Andersson_Anna_2026-05.pdf')
|
||||
expect(res.headers.get('X-Request-Id')).toBeTruthy()
|
||||
const buf = Buffer.from(await res.arrayBuffer())
|
||||
expect(buf.toString()).toContain('%PDF')
|
||||
})
|
||||
|
||||
it('returns 404 SALARY_RUN_NOT_FOUND when the run is missing', async () => {
|
||||
mockServiceClient.mockReturnValue(
|
||||
makeFlexibleSupabase({
|
||||
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
|
||||
salary_runs: { data: null, error: null },
|
||||
}),
|
||||
)
|
||||
const res = await getPayslipPdf(
|
||||
makeRequest(
|
||||
`https://x.test/api/v1/companies/${COMPANY_ID}/salary-runs/${RUN_ID}/payslips/${EMPLOYEE_ID}/pdf`,
|
||||
),
|
||||
pdfParams(COMPANY_ID, RUN_ID, EMPLOYEE_ID),
|
||||
)
|
||||
expect(res.status).toBe(404)
|
||||
const body = await res.json()
|
||||
expect(body.error.code).toBe('SALARY_RUN_NOT_FOUND')
|
||||
})
|
||||
|
||||
it('returns 404 NOT_FOUND when the employee is not in the run', async () => {
|
||||
mockServiceClient.mockReturnValue(
|
||||
makeFlexibleSupabase({
|
||||
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
|
||||
salary_runs: { data: SAMPLE_RUN, error: null },
|
||||
salary_run_employees: { data: null, error: null },
|
||||
}),
|
||||
)
|
||||
const res = await getPayslipPdf(
|
||||
makeRequest(
|
||||
`https://x.test/api/v1/companies/${COMPANY_ID}/salary-runs/${RUN_ID}/payslips/${EMPLOYEE_ID}/pdf`,
|
||||
),
|
||||
pdfParams(COMPANY_ID, RUN_ID, EMPLOYEE_ID),
|
||||
)
|
||||
expect(res.status).toBe(404)
|
||||
const body = await res.json()
|
||||
expect(body.error.code).toBe('NOT_FOUND')
|
||||
})
|
||||
|
||||
it('rejects keys without payroll:read scope', async () => {
|
||||
mockValidate.mockResolvedValue({
|
||||
userId: USER_ID,
|
||||
companyId: COMPANY_ID,
|
||||
apiKeyId: 'ak_1',
|
||||
apiKeyName: 'wrong scope',
|
||||
scopes: ['invoices:read'],
|
||||
mode: 'live',
|
||||
})
|
||||
mockServiceClient.mockReturnValue(makeFlexibleSupabase({}))
|
||||
|
||||
const res = await getPayslipPdf(
|
||||
makeRequest(
|
||||
`https://x.test/api/v1/companies/${COMPANY_ID}/salary-runs/${RUN_ID}/payslips/${EMPLOYEE_ID}/pdf`,
|
||||
),
|
||||
pdfParams(COMPANY_ID, RUN_ID, EMPLOYEE_ID),
|
||||
)
|
||||
expect(res.status).toBe(403)
|
||||
})
|
||||
|
||||
it('returns 401 without a bearer token', async () => {
|
||||
mockValidate.mockResolvedValue(null)
|
||||
mockServiceClient.mockReturnValue(makeFlexibleSupabase({}))
|
||||
|
||||
const res = await getPayslipPdf(
|
||||
new Request(
|
||||
`https://x.test/api/v1/companies/${COMPANY_ID}/salary-runs/${RUN_ID}/payslips/${EMPLOYEE_ID}/pdf`,
|
||||
),
|
||||
pdfParams(COMPANY_ID, RUN_ID, EMPLOYEE_ID),
|
||||
)
|
||||
expect(res.status).toBe(401)
|
||||
})
|
||||
|
||||
it('maps a render failure to 500 INTERNAL_ERROR rather than crashing', async () => {
|
||||
mockRender.mockRejectedValue(new Error('font missing'))
|
||||
mockServiceClient.mockReturnValue(
|
||||
makeFlexibleSupabase({
|
||||
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
|
||||
salary_runs: { data: SAMPLE_RUN, error: null },
|
||||
salary_run_employees: { data: SAMPLE_SRE, error: null },
|
||||
companies: { data: { name: 'Testbolaget AB', org_number: '5560000000' }, error: null },
|
||||
company_settings: { data: { company_name: 'Testbolaget AB' }, error: null },
|
||||
}),
|
||||
)
|
||||
|
||||
const res = await getPayslipPdf(
|
||||
makeRequest(
|
||||
`https://x.test/api/v1/companies/${COMPANY_ID}/salary-runs/${RUN_ID}/payslips/${EMPLOYEE_ID}/pdf`,
|
||||
),
|
||||
pdfParams(COMPANY_ID, RUN_ID, EMPLOYEE_ID),
|
||||
)
|
||||
expect(res.status).toBe(500)
|
||||
const body = await res.json()
|
||||
expect(body.error.code).toBe('INTERNAL_ERROR')
|
||||
})
|
||||
})
|
||||
@@ -65,7 +65,7 @@ registerEndpoint({
|
||||
useWhen:
|
||||
'You have a salary_run_id and need its current status: typically to decide which lifecycle verb to call next, or to display the run header in a UI.',
|
||||
doNotUseFor:
|
||||
'Per-employee breakdown (Phase 5 PR-1 does not expose the per-employee endpoint on v1; use the internal /api/salary/runs/{id} for that today). Salary journal report: use GET /reports/salary-journal in Phase 5 PR-3.',
|
||||
'Per-employee breakdown: use GET /salary-runs/{id}/employees (list) or /salary-runs/{id}/employees/{employeeId} (payslip detail). Salary journal report: use GET /reports/salary-journal.',
|
||||
pitfalls: [
|
||||
'salary_entry_id / avgifter_entry_id / vacation_entry_id are null until POST /book has run. They reference the journal_entries table.',
|
||||
'total_* fields are 0 until POST /calculate has run.',
|
||||
|
||||
@@ -0,0 +1,242 @@
|
||||
/**
|
||||
* Tests for POST /api/v1/companies/{companyId}/salary/vacation-year-close
|
||||
* and GET /employees/{id}/vacation-balance (payroll gap-closure 3.4).
|
||||
*
|
||||
* The close service is mocked: these tests cover auth, validation, dry-run
|
||||
* preview plumbing, and error mapping. The beredning/reconcile math is
|
||||
* covered in lib/salary/__tests__/semesterberedning.test.ts.
|
||||
*/
|
||||
|
||||
import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
beforeAll(() => {
|
||||
if (process.env.NODE_ENV !== 'test') {
|
||||
throw new Error(
|
||||
`vacation-year-close route tests require NODE_ENV=test (got ${process.env.NODE_ENV ?? 'undefined'})`,
|
||||
)
|
||||
}
|
||||
process.env.NEXT_PUBLIC_SUPABASE_URL ||= 'http://localhost:54321'
|
||||
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY ||= 'test-anon-key'
|
||||
})
|
||||
|
||||
vi.mock('@/lib/auth/api-keys', async () => {
|
||||
const actual = await vi.importActual<typeof import('@/lib/auth/api-keys')>('@/lib/auth/api-keys')
|
||||
return {
|
||||
...actual,
|
||||
validateApiKey: vi.fn(),
|
||||
createServiceClientNoCookies: vi.fn(),
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@supabase/supabase-js', async () => {
|
||||
const actual = await vi.importActual<typeof import('@supabase/supabase-js')>('@supabase/supabase-js')
|
||||
return { ...actual, createClient: vi.fn().mockReturnValue({}) }
|
||||
})
|
||||
|
||||
const mockPreview = vi.fn()
|
||||
const mockCommit = vi.fn()
|
||||
vi.mock('@/lib/salary/semesterberedning', () => ({
|
||||
previewVacationYearClose: (...a: unknown[]) => mockPreview(...a),
|
||||
commitVacationYearClose: (...a: unknown[]) => mockCommit(...a),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/salary/vacation-ledger', () => ({
|
||||
getVacationYearBasis: vi.fn().mockResolvedValue('calendar'),
|
||||
syncVacationLedgerForEmployees: vi.fn().mockResolvedValue({ ok: true }),
|
||||
}))
|
||||
|
||||
import { validateApiKey, createServiceClientNoCookies } from '@/lib/auth/api-keys'
|
||||
import { POST as closeYear } from '../route'
|
||||
|
||||
const mockValidate = validateApiKey as ReturnType<typeof vi.fn>
|
||||
const mockServiceClient = createServiceClientNoCookies as ReturnType<typeof vi.fn>
|
||||
|
||||
function makeFlexibleSupabase(byTable: Record<string, { data?: unknown; error?: unknown }>) {
|
||||
const buildChain = (table: string): unknown => {
|
||||
const handler: ProxyHandler<object> = {
|
||||
get(_target, prop) {
|
||||
if (prop === 'then') {
|
||||
return (resolve: (v: unknown) => void) =>
|
||||
resolve(byTable[table] ?? { data: null, error: null })
|
||||
}
|
||||
return (..._args: unknown[]) => buildChain(table)
|
||||
},
|
||||
}
|
||||
return new Proxy({}, handler)
|
||||
}
|
||||
return { from: vi.fn((table: string) => buildChain(table)) }
|
||||
}
|
||||
|
||||
const COMPANY_ID = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa'
|
||||
const USER_ID = 'user-1'
|
||||
|
||||
const SAMPLE_REPORT = {
|
||||
vacation_year_start: '2025-01-01',
|
||||
vacation_year_end: '2025-12-31',
|
||||
next_year_start: '2026-01-01',
|
||||
basis: 'calendar',
|
||||
rows: [],
|
||||
sek: {
|
||||
computed_liability: 0,
|
||||
computed_avgifter: 0,
|
||||
booked_2920: 0,
|
||||
booked_2940: 0,
|
||||
drift_2920: 0,
|
||||
drift_2940: 0,
|
||||
adjustment_needed: false,
|
||||
},
|
||||
adjustment_date: '2025-12-31',
|
||||
}
|
||||
|
||||
function makeRequest(url: string, body?: unknown): Request {
|
||||
return new Request(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: 'Bearer test-fixture-not-a-real-key',
|
||||
'Idempotency-Key': 'b1aaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa',
|
||||
},
|
||||
body: JSON.stringify(body ?? {}),
|
||||
})
|
||||
}
|
||||
|
||||
function companyParams(companyId: string) {
|
||||
return { params: Promise.resolve({ companyId }) }
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockValidate.mockResolvedValue({
|
||||
userId: USER_ID,
|
||||
companyId: COMPANY_ID,
|
||||
apiKeyId: 'ak_1',
|
||||
apiKeyName: 'CI key',
|
||||
scopes: ['payroll:read', 'payroll:write'],
|
||||
mode: 'live',
|
||||
})
|
||||
mockServiceClient.mockReturnValue(
|
||||
makeFlexibleSupabase({
|
||||
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
|
||||
idempotency_keys: { data: null, error: null },
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
describe('POST /salary/vacation-year-close', () => {
|
||||
it('commits the close and returns the closure + adjustment ids', async () => {
|
||||
mockCommit.mockResolvedValue({
|
||||
ok: true,
|
||||
data: { closure_id: 'closure-1', adjustment_entry_id: 'je-1', report: SAMPLE_REPORT },
|
||||
})
|
||||
|
||||
const res = await closeYear(
|
||||
makeRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/salary/vacation-year-close`, {
|
||||
vacation_year_start: '2025-01-01',
|
||||
}),
|
||||
companyParams(COMPANY_ID),
|
||||
)
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
const body = await res.json()
|
||||
expect(body.data.vacation_year_closure_id).toBe('closure-1')
|
||||
expect(body.data.adjustment_entry_id).toBe('je-1')
|
||||
expect(mockCommit).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
COMPANY_ID,
|
||||
USER_ID,
|
||||
'2025-01-01',
|
||||
{ bookAdjustment: true },
|
||||
)
|
||||
})
|
||||
|
||||
it('defaults the year to the most recently ended one when omitted', async () => {
|
||||
mockCommit.mockResolvedValue({
|
||||
ok: true,
|
||||
data: { closure_id: 'closure-1', adjustment_entry_id: null, report: SAMPLE_REPORT },
|
||||
})
|
||||
|
||||
const res = await closeYear(
|
||||
makeRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/salary/vacation-year-close`),
|
||||
companyParams(COMPANY_ID),
|
||||
)
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
const currentYear = new Date().getFullYear()
|
||||
expect(mockCommit.mock.calls[0][3]).toBe(`${currentYear - 1}-01-01`)
|
||||
})
|
||||
|
||||
it('dry_run returns the full preview report with zero commits', async () => {
|
||||
mockPreview.mockResolvedValue({ ok: true, data: SAMPLE_REPORT })
|
||||
|
||||
const res = await closeYear(
|
||||
makeRequest(
|
||||
`https://x.test/api/v1/companies/${COMPANY_ID}/salary/vacation-year-close?dry_run=true`,
|
||||
{ vacation_year_start: '2025-01-01' },
|
||||
),
|
||||
companyParams(COMPANY_ID),
|
||||
)
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
expect(res.headers.get('X-Dry-Run')).toBe('true')
|
||||
const body = await res.json()
|
||||
expect(body.data.preview.report.vacation_year_start).toBe('2025-01-01')
|
||||
expect(mockCommit).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('maps VACATION_YEAR_ALREADY_CLOSED to 409', async () => {
|
||||
mockCommit.mockResolvedValue({ ok: false, code: 'VACATION_YEAR_ALREADY_CLOSED' })
|
||||
|
||||
const res = await closeYear(
|
||||
makeRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/salary/vacation-year-close`, {
|
||||
vacation_year_start: '2025-01-01',
|
||||
}),
|
||||
companyParams(COMPANY_ID),
|
||||
)
|
||||
expect(res.status).toBe(409)
|
||||
const body = await res.json()
|
||||
expect(body.error.code).toBe('VACATION_YEAR_ALREADY_CLOSED')
|
||||
})
|
||||
|
||||
it('maps PERIOD_LOCKED without committing anything', async () => {
|
||||
mockCommit.mockResolvedValue({ ok: false, code: 'PERIOD_LOCKED', details: { reason: 'closed' } })
|
||||
|
||||
const res = await closeYear(
|
||||
makeRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/salary/vacation-year-close`, {
|
||||
vacation_year_start: '2025-01-01',
|
||||
}),
|
||||
companyParams(COMPANY_ID),
|
||||
)
|
||||
expect(res.status).toBeGreaterThanOrEqual(400)
|
||||
const body = await res.json()
|
||||
expect(body.error.code).toBe('PERIOD_LOCKED')
|
||||
})
|
||||
|
||||
it('rejects keys without payroll:write scope', async () => {
|
||||
mockValidate.mockResolvedValue({
|
||||
userId: USER_ID,
|
||||
companyId: COMPANY_ID,
|
||||
apiKeyId: 'ak_1',
|
||||
apiKeyName: 'read-only',
|
||||
scopes: ['payroll:read'],
|
||||
mode: 'live',
|
||||
})
|
||||
|
||||
const res = await closeYear(
|
||||
makeRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/salary/vacation-year-close`),
|
||||
companyParams(COMPANY_ID),
|
||||
)
|
||||
expect(res.status).toBe(403)
|
||||
})
|
||||
|
||||
it('requires an Idempotency-Key', async () => {
|
||||
const req = new Request(
|
||||
`https://x.test/api/v1/companies/${COMPANY_ID}/salary/vacation-year-close`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { Authorization: 'Bearer test' },
|
||||
body: JSON.stringify({}),
|
||||
},
|
||||
)
|
||||
const res = await closeYear(req, companyParams(COMPANY_ID))
|
||||
expect(res.status).toBe(400)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,146 @@
|
||||
/**
|
||||
* POST /api/v1/companies/{companyId}/salary/vacation-year-close
|
||||
*
|
||||
* Semesterberedning + semesterårsavslut in one verb (payroll gap-closure
|
||||
* 3.4). dry_run returns the FULL review report (per-employee day
|
||||
* transitions + the SEK reconcile) without writing; the live call closes
|
||||
* the year's ledger rows, rolls balances into the next year (min-20 floor,
|
||||
* 5-year expiry -> forced payout), and books one drift-adjustment
|
||||
* verifikation on 7290/2920 + 7519/2940 when |drift| > 1 kr.
|
||||
*/
|
||||
|
||||
import { z } from 'zod'
|
||||
import { ok } from '@/lib/api/v1/response'
|
||||
import { dryRunPreview } from '@/lib/api/v1/dry-run'
|
||||
import { registerEndpoint, dataEnvelope } from '@/lib/api/v1/registry'
|
||||
import { withApiV1 } from '@/lib/api/v1/with-api-v1'
|
||||
import { v1ErrorResponseFromCode } from '@/lib/api/v1/errors'
|
||||
import {
|
||||
commitVacationYearClose,
|
||||
previewVacationYearClose,
|
||||
} from '@/lib/salary/semesterberedning'
|
||||
import { getVacationYearBasis } from '@/lib/salary/vacation-ledger'
|
||||
import { getClosableYearStart } from '@/lib/salary/vacation-year'
|
||||
|
||||
const isoDate = z.string().regex(/^\d{4}-\d{2}-\d{2}$/, 'Expected YYYY-MM-DD date format')
|
||||
|
||||
const CloseBody = z.object({
|
||||
/** Defaults to the most recently ENDED vacation year per the company's
|
||||
* basis setting. */
|
||||
vacation_year_start: isoDate.optional(),
|
||||
/** Book the 2920/2940 drift adjustment (default true). False = roll the
|
||||
* days but leave the SEK reconcile for a manual verifikat. */
|
||||
book_adjustment: z.boolean().default(true),
|
||||
})
|
||||
|
||||
const CloseResponse = z.object({
|
||||
vacation_year_closure_id: z.string().uuid(),
|
||||
adjustment_entry_id: z.string().uuid().nullable(),
|
||||
report: z.unknown(),
|
||||
})
|
||||
|
||||
registerEndpoint({
|
||||
operation: 'salary.vacation-year-close',
|
||||
method: 'POST',
|
||||
path: '/api/v1/companies/:companyId/salary/vacation-year-close',
|
||||
summary: 'Close a vacation year (semesterberedning + arsavslut).',
|
||||
description:
|
||||
'Rolls every active employee\'s vacation balances into the next year (only days above the 20-day must-take floor are saved; saved days older than 5 years become forced payouts) and reconciles the day-valued semesterlöneskuld against the booked 2920/2940, posting one adjustment verifikation when drift exceeds 1 kr. The frozen report is stored with the closure (BFL 7 kap).',
|
||||
useWhen:
|
||||
'Once per year after the vacation year ends (Jan for calendar basis, Apr for statutory). ALWAYS dry-run first and review the report: the close is not reversible via API.',
|
||||
doNotUseFor:
|
||||
'Mid-year balance corrections (fix the source: absence days, opening balances, or run corrections). Paying out expired days (create a semesterersattning line in the next salary run: the close only flags them).',
|
||||
pitfalls: [
|
||||
'dry_run=true returns the full review report with zero writes: treat it as mandatory before the live call.',
|
||||
'409 VACATION_YEAR_ALREADY_CLOSED on replay: the closure row is the idempotency anchor.',
|
||||
'423-style PERIOD_LOCKED when the adjustment date falls in a locked period: unlock or close without adjustment (book_adjustment=false) and post manually.',
|
||||
'Untaken days at or below the 20-day floor are flagged in the report, NOT auto-saved (Semesterlagen 18 §).',
|
||||
],
|
||||
example: {
|
||||
request: { book_adjustment: true },
|
||||
response: {
|
||||
data: {
|
||||
vacation_year_closure_id: 'vyc_a1b2…',
|
||||
adjustment_entry_id: 'je_c3d4…',
|
||||
report: { vacation_year_start: '2025-01-01', rows: [], sek: { drift_2920: 8690.84 } },
|
||||
},
|
||||
meta: { request_id: 'req_…', api_version: '2026-05-12' },
|
||||
},
|
||||
},
|
||||
scope: 'payroll:write',
|
||||
risk: 'high',
|
||||
idempotent: true,
|
||||
reversible: false,
|
||||
dryRunSupported: true,
|
||||
request: { body: CloseBody },
|
||||
response: { success: dataEnvelope(CloseResponse) },
|
||||
})
|
||||
|
||||
export const POST = withApiV1<{ params: Promise<{ companyId: string }> }>(
|
||||
'salary.vacation-year-close',
|
||||
async (request, ctx) => {
|
||||
let rawBody: unknown = {}
|
||||
try {
|
||||
const text = await request.text()
|
||||
rawBody = text ? JSON.parse(text) : {}
|
||||
} catch {
|
||||
return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, {
|
||||
requestId: ctx.requestId,
|
||||
details: { field: 'body', message: 'Body is not valid JSON.' },
|
||||
})
|
||||
}
|
||||
|
||||
const parsed = CloseBody.safeParse(rawBody)
|
||||
if (!parsed.success) {
|
||||
return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, {
|
||||
requestId: ctx.requestId,
|
||||
details: {
|
||||
issues: parsed.error.issues.map((i) => ({
|
||||
field: i.path.join('.'),
|
||||
message: i.message,
|
||||
})),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
let yearStart = parsed.data.vacation_year_start
|
||||
if (!yearStart) {
|
||||
const basis = await getVacationYearBasis(ctx.supabase, ctx.companyId!)
|
||||
yearStart = getClosableYearStart(new Date().toISOString().slice(0, 10), basis)
|
||||
}
|
||||
|
||||
if (ctx.dryRun) {
|
||||
const preview = await previewVacationYearClose(ctx.supabase, ctx.companyId!, yearStart)
|
||||
if (!preview.ok) {
|
||||
return v1ErrorResponseFromCode(preview.code, ctx.log, {
|
||||
requestId: ctx.requestId,
|
||||
details: preview.details,
|
||||
})
|
||||
}
|
||||
return dryRunPreview(
|
||||
{ vacation_year_closure_id: null, adjustment_entry_id: null, report: preview.data },
|
||||
{ requestId: ctx.requestId, log: ctx.log },
|
||||
)
|
||||
}
|
||||
|
||||
const result = await commitVacationYearClose(ctx.supabase, ctx.companyId!, ctx.userId, yearStart, {
|
||||
bookAdjustment: parsed.data.book_adjustment,
|
||||
})
|
||||
if (!result.ok) {
|
||||
return v1ErrorResponseFromCode(result.code, ctx.log, {
|
||||
requestId: ctx.requestId,
|
||||
details: result.details,
|
||||
})
|
||||
}
|
||||
|
||||
return ok(
|
||||
{
|
||||
vacation_year_closure_id: result.data.closure_id,
|
||||
adjustment_entry_id: result.data.adjustment_entry_id,
|
||||
report: result.data.report,
|
||||
},
|
||||
{ requestId: ctx.requestId },
|
||||
)
|
||||
},
|
||||
{ requireIdempotencyKey: true },
|
||||
)
|
||||
+9
-4
@@ -4,9 +4,11 @@ import { Hedvig_Letters_Serif } from "next/font/google";
|
||||
import Script from "next/script";
|
||||
import { NextIntlClientProvider } from "next-intl";
|
||||
import { getLocale, getMessages } from "next-intl/server";
|
||||
import { SpeedInsights } from "@vercel/speed-insights/next";
|
||||
import { Toaster } from "@/components/ui/toaster";
|
||||
import { DeployReloadPrompt } from "@/components/system/DeployReloadPrompt";
|
||||
import { ThemeProvider } from "@/components/theme-provider";
|
||||
import { SWRProvider } from "@/components/providers/SWRProvider";
|
||||
import { RecaptLoader } from "@/components/RecaptLoader";
|
||||
import { RecaptHideWidget } from "@/components/RecaptHideWidget";
|
||||
import { ensureInitialized } from "@/lib/init";
|
||||
@@ -85,12 +87,15 @@ export default async function RootLayout({
|
||||
enableSystem
|
||||
disableTransitionOnChange
|
||||
>
|
||||
{children}
|
||||
<Toaster />
|
||||
<DeployReloadPrompt />
|
||||
<RecaptHideWidget />
|
||||
<SWRProvider>
|
||||
{children}
|
||||
<Toaster />
|
||||
<DeployReloadPrompt />
|
||||
<RecaptHideWidget />
|
||||
</SWRProvider>
|
||||
</ThemeProvider>
|
||||
</NextIntlClientProvider>
|
||||
<SpeedInsights />
|
||||
<Script src="/sw-register.js" strategy="afterInteractive" />
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -11,8 +11,7 @@ import {
|
||||
Check,
|
||||
Brain,
|
||||
} from 'lucide-react'
|
||||
import ReactMarkdown from 'react-markdown'
|
||||
import remarkGfm from 'remark-gfm'
|
||||
import dynamic from 'next/dynamic'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useCapability } from '@/contexts/CompanyContext'
|
||||
@@ -20,6 +19,16 @@ import { CAPABILITY } from '@/lib/entitlements/keys'
|
||||
import { UpgradeNote } from '@/components/billing/UpgradeNote'
|
||||
import ApprovalCard from './ApprovalCard'
|
||||
|
||||
// Markdown parser loads on the first assistant message instead of with the
|
||||
// chat surface itself; react-markdown + remark-gfm pull in the whole
|
||||
// unified/remark tree. The chunk starts fetching as soon as a reply begins
|
||||
// rendering, well before a human can read it, so a null fallback is invisible
|
||||
// in practice.
|
||||
const MarkdownMessage = dynamic(() => import('./MarkdownMessage'), {
|
||||
ssr: false,
|
||||
loading: () => null,
|
||||
})
|
||||
|
||||
// Reusable chat surface: used both inside the right-hand AgentSheet and on
|
||||
// the full-page /chat route. Owns:
|
||||
// * Message state (rendered list)
|
||||
@@ -684,7 +693,7 @@ function MessageBubble({
|
||||
message.text || (streamingTail ? <Cursor /> : '')
|
||||
) : message.text ? (
|
||||
<div className="prose prose-sm max-w-none text-foreground [&>*:first-child]:mt-0 [&>*:last-child]:mb-0 prose-headings:font-display prose-headings:font-normal prose-headings:tracking-tight prose-h2:text-base prose-h2:mt-3 prose-h2:mb-2 prose-h3:text-sm prose-h3:mt-3 prose-h3:mb-1 prose-p:my-2 prose-p:leading-6 prose-strong:font-semibold prose-strong:text-foreground prose-ul:my-2 prose-li:my-0.5 prose-blockquote:border-l-2 prose-blockquote:border-foreground/30 prose-blockquote:not-italic prose-blockquote:text-muted-foreground prose-blockquote:pl-3 prose-blockquote:my-2 prose-code:bg-secondary prose-code:rounded prose-code:px-1 prose-code:py-0.5 prose-code:text-xs prose-code:before:content-none prose-code:after:content-none prose-a:text-foreground prose-a:underline prose-a:underline-offset-2 prose-pre:bg-secondary prose-pre:text-foreground prose-pre:border prose-pre:border-border prose-pre:rounded-lg prose-pre:my-2 prose-pre:p-3 prose-pre:text-xs prose-pre:leading-relaxed prose-pre:overflow-x-auto [&_pre_code]:bg-transparent [&_pre_code]:p-0 [&_pre_code]:text-foreground [&_pre_code]:text-xs prose-table:my-2 prose-table:text-xs prose-table:border-collapse [&_table]:w-full [&_th]:border-b [&_th]:border-border [&_th]:py-1.5 [&_th]:px-2 [&_th]:text-left [&_th]:font-medium [&_th]:text-muted-foreground [&_th]:uppercase [&_th]:tracking-wider [&_th]:text-[10px] [&_td]:border-b [&_td]:border-border [&_td]:py-1.5 [&_td]:px-2 [&_td]:align-top [&_tbody_tr:last-child_td]:border-b-0">
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]}>{message.text}</ReactMarkdown>
|
||||
<MarkdownMessage text={message.text} />
|
||||
</div>
|
||||
) : streamingTail ? (
|
||||
<Cursor />
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
@@ -75,9 +75,12 @@ function matches(entry: Entry, q: string): boolean {
|
||||
return q.split(/\s+/).filter(Boolean).every(t => hay.includes(t))
|
||||
}
|
||||
|
||||
export default function CommandPalette() {
|
||||
export default function CommandPalette({ initialOpen = false }: { initialOpen?: boolean } = {}) {
|
||||
const router = useRouter()
|
||||
const [open, setOpen] = useState(false)
|
||||
// initialOpen: LazyCommandPalette mounts this component on the first ⌘K,
|
||||
// so the palette must come up already open rather than waiting for a
|
||||
// second keypress.
|
||||
const [open, setOpen] = useState(initialOpen)
|
||||
const [query, setQuery] = useState('')
|
||||
const [activeIndex, setActiveIndex] = useState(0)
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -61,6 +61,7 @@ import AgentAvatar from '@/components/agent/AgentAvatar'
|
||||
import { useAgentSheet } from '@/components/agent/AgentSheetProvider'
|
||||
import { useCompany } from '@/contexts/CompanyContext'
|
||||
import { useRealtimeSupabase } from '@/lib/hooks/use-realtime-supabase'
|
||||
import { useWorklistBadges } from '@/lib/hooks/use-worklist-badges'
|
||||
import { EXTENSION_REQUIRED_CAPABILITY, type CapabilityKey } from '@/lib/entitlements/keys'
|
||||
import type { EntityType } from '@/types'
|
||||
|
||||
@@ -83,8 +84,6 @@ interface DashboardNavProps {
|
||||
// switched on. Drives visibility of the Kostnadsställen & projekt row:
|
||||
// same mechanism as paysSalaries: fetched by the dashboard layout.
|
||||
dimensionsEnabled?: boolean
|
||||
uncategorizedTransactionCount?: number
|
||||
pendingOperationsCount?: number
|
||||
isSandbox?: boolean
|
||||
extensionNavItems?: ExtensionNavItem[]
|
||||
// Signed-in user's full name + email: drives the bottom-left account
|
||||
@@ -247,7 +246,7 @@ function accountInitial(name: string | null, email: string | null): string {
|
||||
return '?'
|
||||
}
|
||||
|
||||
export default function DashboardNav({ companyName: _companyName, entityType, paysSalaries = false, dimensionsEnabled = false, uncategorizedTransactionCount = 0, pendingOperationsCount = 0, isSandbox = false, extensionNavItems = [], userName = null, userEmail = null }: DashboardNavProps) {
|
||||
export default function DashboardNav({ companyName: _companyName, entityType, paysSalaries = false, dimensionsEnabled = false, isSandbox = false, extensionNavItems = [], userName = null, userEmail = null }: DashboardNavProps) {
|
||||
const pathname = usePathname()
|
||||
const router = useRouter()
|
||||
const supabase = useRealtimeSupabase()
|
||||
@@ -261,17 +260,25 @@ export default function DashboardNav({ companyName: _companyName, entityType, pa
|
||||
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false)
|
||||
const [isClosing, setIsClosing] = useState(false)
|
||||
const closeTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
const [liveUncategorizedTransactionCount, setLiveUncategorizedTransactionCount] = useState(
|
||||
uncategorizedTransactionCount,
|
||||
)
|
||||
const refreshInFlightRef = useRef(false)
|
||||
const refreshQueuedRef = useRef(false)
|
||||
// Badge counts load client-side after mount (and revalidate via the
|
||||
// realtime subscriptions below). They used to arrive as server props, which
|
||||
// put two head-count queries on the critical path of every dashboard
|
||||
// navigation for numbers nobody needs before first paint.
|
||||
const {
|
||||
uncategorized: uncategorizedCount,
|
||||
pendingOperations: pendingOpsCount,
|
||||
refresh: refreshBadges,
|
||||
} = useWorklistBadges(company?.id)
|
||||
// Trial countdown for the sidebar touchpoint. Computed in an effect (not
|
||||
// during render) so server and client markup agree at hydration; an hourly
|
||||
// tick keeps a long-lived tab from showing yesterday's count.
|
||||
// tick keeps a long-lived tab from showing yesterday's count. The sync
|
||||
// setState is that hydration strategy, not derived-state-in-effect (the
|
||||
// lint only started analyzing this component once the badge-refresh loop
|
||||
// that made the compiler bail was removed).
|
||||
const [trialDaysLeft, setTrialDaysLeft] = useState<number | null>(null)
|
||||
useEffect(() => {
|
||||
if (!trialEndsAt) {
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
setTrialDaysLeft(null)
|
||||
return
|
||||
}
|
||||
@@ -364,43 +371,17 @@ export default function DashboardNav({ companyName: _companyName, entityType, pa
|
||||
useEffect(() => {
|
||||
if (!company?.id) return
|
||||
|
||||
let cancelled = false
|
||||
|
||||
const refreshUncategorizedCount = async () => {
|
||||
if (!company?.id || cancelled) return
|
||||
if (refreshInFlightRef.current) {
|
||||
refreshQueuedRef.current = true
|
||||
return
|
||||
}
|
||||
|
||||
refreshInFlightRef.current = true
|
||||
try {
|
||||
do {
|
||||
refreshQueuedRef.current = false
|
||||
const { count, error } = await supabase
|
||||
.from('transactions')
|
||||
.select('id', { count: 'exact', head: true })
|
||||
.eq('company_id', company.id)
|
||||
.is('is_business', null)
|
||||
.eq('is_ignored', false)
|
||||
|
||||
if (error) {
|
||||
console.error('Failed to refresh uncategorized transaction count:', error)
|
||||
break
|
||||
}
|
||||
|
||||
setLiveUncategorizedTransactionCount(count ?? 0)
|
||||
} while (refreshQueuedRef.current && !cancelled)
|
||||
} finally {
|
||||
refreshInFlightRef.current = false
|
||||
refreshQueuedRef.current = false
|
||||
}
|
||||
// Realtime keeps the badges live; a trailing debounce collapses event
|
||||
// bursts (bulk booking / bulk approvals emit one event per row) into a
|
||||
// single SWR revalidation instead of a request stampede.
|
||||
let debounce: ReturnType<typeof setTimeout> | null = null
|
||||
const queueRefresh = () => {
|
||||
if (debounce) clearTimeout(debounce)
|
||||
debounce = setTimeout(() => void refreshBadges(), 400)
|
||||
}
|
||||
|
||||
void refreshUncategorizedCount()
|
||||
|
||||
const channel = supabase
|
||||
.channel(`dashboard-nav:transactions:${company.id}`)
|
||||
.channel(`dashboard-nav:badges:${company.id}`)
|
||||
.on(
|
||||
'postgres_changes',
|
||||
{
|
||||
@@ -409,17 +390,25 @@ export default function DashboardNav({ companyName: _companyName, entityType, pa
|
||||
table: 'transactions',
|
||||
filter: `company_id=eq.${company.id}`,
|
||||
},
|
||||
() => {
|
||||
void refreshUncategorizedCount()
|
||||
queueRefresh,
|
||||
)
|
||||
.on(
|
||||
'postgres_changes',
|
||||
{
|
||||
event: '*',
|
||||
schema: 'public',
|
||||
table: 'pending_operations',
|
||||
filter: `company_id=eq.${company.id}`,
|
||||
},
|
||||
queueRefresh,
|
||||
)
|
||||
.subscribe()
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
if (debounce) clearTimeout(debounce)
|
||||
void supabase.removeChannel(channel)
|
||||
}
|
||||
}, [company?.id, supabase])
|
||||
}, [company?.id, supabase, refreshBadges])
|
||||
|
||||
const hiddenNavHrefs = new Set(getBranding().hiddenNavHrefs)
|
||||
|
||||
@@ -607,10 +596,10 @@ export default function DashboardNav({ companyName: _companyName, entityType, pa
|
||||
const active = isActive(item.href)
|
||||
const enabled = isItemEnabled(item.href) && !item.comingSoon
|
||||
const badge =
|
||||
item.href === '/transactions' && liveUncategorizedTransactionCount > 0
|
||||
? liveUncategorizedTransactionCount
|
||||
: item.href === '/pending' && pendingOperationsCount > 0
|
||||
? pendingOperationsCount
|
||||
item.href === '/transactions' && uncategorizedCount > 0
|
||||
? uncategorizedCount
|
||||
: item.href === '/pending' && pendingOpsCount > 0
|
||||
? pendingOpsCount
|
||||
: null
|
||||
const decorBadge = renderBadge(item, 'sidebar')
|
||||
const content = (
|
||||
@@ -820,8 +809,8 @@ export default function DashboardNav({ companyName: _companyName, entityType, pa
|
||||
{mobileNavItems.map((item) => {
|
||||
const active = isActive(item.href)
|
||||
const enabled = isItemEnabled(item.href)
|
||||
const badge = item.href === '/transactions' && liveUncategorizedTransactionCount > 0
|
||||
? liveUncategorizedTransactionCount
|
||||
const badge = item.href === '/transactions' && uncategorizedCount > 0
|
||||
? uncategorizedCount
|
||||
: null
|
||||
|
||||
const content = (
|
||||
@@ -977,10 +966,10 @@ export default function DashboardNav({ companyName: _companyName, entityType, pa
|
||||
const Icon = item.icon
|
||||
const active = isActive(item.href)
|
||||
const enabled = isItemEnabled(item.href) && !item.comingSoon
|
||||
const badge = item.href === '/transactions' && liveUncategorizedTransactionCount > 0
|
||||
? liveUncategorizedTransactionCount
|
||||
: item.href === '/pending' && pendingOperationsCount > 0
|
||||
? pendingOperationsCount
|
||||
const badge = item.href === '/transactions' && uncategorizedCount > 0
|
||||
? uncategorizedCount
|
||||
: item.href === '/pending' && pendingOpsCount > 0
|
||||
? pendingOpsCount
|
||||
: null
|
||||
const decorBadge = renderBadge(item, 'mobile')
|
||||
const content = (
|
||||
|
||||
@@ -118,7 +118,7 @@ export default function PaymentBookingDialog({
|
||||
// Fetch company settings
|
||||
const { data: settings, error: settingsError } = await supabase
|
||||
.from('company_settings')
|
||||
.select('accounting_method, entity_type')
|
||||
.select('accounting_method, entity_type, ore_rounding')
|
||||
.eq('company_id', company.id)
|
||||
.maybeSingle()
|
||||
|
||||
@@ -153,9 +153,12 @@ export default function PaymentBookingDialog({
|
||||
vat_treatment: invoice.vat_treatment,
|
||||
items: invoice.items,
|
||||
default_dimensions: invoice.default_dimensions,
|
||||
ore_rounding: invoice.ore_rounding,
|
||||
},
|
||||
accountingMethod,
|
||||
entityType,
|
||||
companyOreRounding:
|
||||
typeof settings?.ore_rounding === 'boolean' ? settings.ore_rounding : undefined,
|
||||
})
|
||||
|
||||
setLines(proposed)
|
||||
|
||||
@@ -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
@@ -4,6 +4,7 @@ import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import {
|
||||
AlertCircle,
|
||||
CheckCircle2,
|
||||
Circle,
|
||||
Download,
|
||||
ExternalLink,
|
||||
Link2,
|
||||
@@ -19,9 +20,11 @@ import { useTranslations } from 'next-intl'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { InfoTooltip } from '@/components/ui/info-tooltip'
|
||||
import { useToast } from '@/components/ui/use-toast'
|
||||
import { UpgradeNote } from '@/components/billing/UpgradeNote'
|
||||
import { useCapability } from '@/contexts/CompanyContext'
|
||||
import { CAPABILITY } from '@/lib/entitlements/keys'
|
||||
import type { AgiSubmissionState } from '@/lib/salary/agi-submission-state'
|
||||
|
||||
interface AGIPanelProps {
|
||||
salaryRunId: string
|
||||
@@ -32,6 +35,13 @@ interface AGIPanelProps {
|
||||
/** Already-cached run-level signals for showing what step we're at. */
|
||||
agiGeneratedAt?: string | null
|
||||
agiSubmittedAt?: string | null
|
||||
/**
|
||||
* Per-period submission record, owned by the parent (via useAgiSubmission)
|
||||
* so the progress rail and hero can render the same state machine.
|
||||
*/
|
||||
submission: AgiSubmissionState | null
|
||||
/** Refetch the submission record after a state-changing action. */
|
||||
onRefreshSubmission: () => void
|
||||
/** When true, write actions are hidden. */
|
||||
readOnly?: boolean
|
||||
/** Called after a state-changing action so parent can refresh. */
|
||||
@@ -60,28 +70,6 @@ interface KontrollFinding {
|
||||
identifierare?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Local submission state mirrored in extension_data under
|
||||
* `agi_submission_{period}`. Matches the `status` enum the index.ts handlers
|
||||
* write back. Strict superset of what the UI actually keys off.
|
||||
*/
|
||||
interface SubmissionState {
|
||||
status?:
|
||||
| 'underlag_submitted' // POST /underlag returned an inlamningId
|
||||
| 'underlag_rejected' // kontrollresultat surfaced stoppande fel
|
||||
| 'awaiting_signing' // skapaGranskningsunderlag returned a link
|
||||
| 'signed' // kvittenser shows uuidKvittens for the period
|
||||
signeringslank?: string
|
||||
kvittensnummer?: string
|
||||
signeradAv?: string
|
||||
signeradTid?: string
|
||||
inlamningId?: number
|
||||
tillstand?: string
|
||||
meddelande?: string
|
||||
/** ISO timestamp the submission record was last written by the extension. */
|
||||
updatedAt?: string
|
||||
}
|
||||
|
||||
/** Subset of SkatteverketAGIKontrollresultat we use in the panel. */
|
||||
interface Kontrollresultat {
|
||||
status: 'PROCESSING' | 'DONE_SUCCESS' | 'DONE_FAILED' | 'DONE_REJECTED'
|
||||
@@ -106,6 +94,36 @@ interface Kontrollresultat {
|
||||
|
||||
const ENABLED_KEY = 'EXTENSION_DISABLED'
|
||||
|
||||
/** One-click chain steps, in execution order. */
|
||||
const CHAIN_STEPS = ['generate', 'submit', 'kontroll', 'link'] as const
|
||||
type ChainStep = (typeof CHAIN_STEPS)[number]
|
||||
|
||||
interface ChainProgress {
|
||||
current: ChainStep
|
||||
failed: boolean
|
||||
done: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Sentinel for chain aborts where the failing step already surfaced its
|
||||
* error via setError/setKontroller: the catch block must not overwrite it.
|
||||
*/
|
||||
class ChainFailed extends Error {}
|
||||
|
||||
/**
|
||||
* Extract a human message from either the canonical { error: { message } }
|
||||
* envelope (internal routes) or a plain { error: string } (extension routes).
|
||||
*/
|
||||
function errText(data: unknown): string | null {
|
||||
if (!data || typeof data !== 'object') return null
|
||||
const err = (data as { error?: unknown }).error
|
||||
if (typeof err === 'string') return err
|
||||
if (err && typeof err === 'object' && typeof (err as { message?: unknown }).message === 'string') {
|
||||
return (err as { message: string }).message
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
export function AGIPanel(props: AGIPanelProps) {
|
||||
const {
|
||||
salaryRunId,
|
||||
@@ -113,21 +131,47 @@ export function AGIPanel(props: AGIPanelProps) {
|
||||
period,
|
||||
agiGeneratedAt,
|
||||
agiSubmittedAt,
|
||||
submission,
|
||||
onRefreshSubmission,
|
||||
readOnly,
|
||||
onChange,
|
||||
} = props
|
||||
|
||||
const t = useTranslations('salary_agi')
|
||||
const { toast } = useToast()
|
||||
const hasSkatteverket = useCapability(CAPABILITY.skatteverket)
|
||||
|
||||
const [extensionDisabled, setExtensionDisabled] = useState(false)
|
||||
const [status, setStatus] = useState<ConnectionStatus | null>(null)
|
||||
const [submission, setSubmission] = useState<SubmissionState | null>(null)
|
||||
const [kontroller, setKontroller] = useState<KontrollFinding[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [actionLoading, setActionLoading] = useState<string | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [success, setSuccess] = useState<string | null>(null)
|
||||
const [chain, setChain] = useState<ChainProgress | null>(null)
|
||||
const [showAdvanced, setShowAdvanced] = useState(false)
|
||||
|
||||
// "2026-06" for user-facing copy; the period prop is compact YYYYMM.
|
||||
const prettyPeriod = `${period.slice(0, 4)}-${period.slice(4)}`
|
||||
|
||||
// ── Derived filing state (needed by hooks, so derived before any return) ──
|
||||
const subState = submission?.status
|
||||
const awaitingSigning = subState === 'awaiting_signing'
|
||||
const underlagSubmitted = subState === 'underlag_submitted'
|
||||
const underlagRejected = subState === 'underlag_rejected'
|
||||
const isSigned = subState === 'signed' || !!agiSubmittedAt
|
||||
// The submission state is keyed by PERIOD; AGI generation is keyed by RUN.
|
||||
// If the run's AGI was (re)generated AFTER this signing draft was created,
|
||||
// the locked underlag at Skatteverket reflects superseded figures and must
|
||||
// not be signed: surface a warning and steer the user to unlock + resubmit
|
||||
// rather than presenting it as ready to sign (avoids filing stale amounts).
|
||||
const draftUpdatedAt = submission?.updatedAt ? new Date(submission.updatedAt) : null
|
||||
const draftIsStale =
|
||||
awaitingSigning &&
|
||||
!!agiGeneratedAt &&
|
||||
!!draftUpdatedAt &&
|
||||
!Number.isNaN(draftUpdatedAt.getTime()) &&
|
||||
new Date(agiGeneratedAt).getTime() > draftUpdatedAt.getTime()
|
||||
|
||||
const fetchStatus = useCallback(async () => {
|
||||
setLoading(true)
|
||||
@@ -165,24 +209,9 @@ export function AGIPanel(props: AGIPanelProps) {
|
||||
}
|
||||
}, [])
|
||||
|
||||
const fetchSubmission = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch(
|
||||
`/api/extensions/ext/skatteverket/agi/status?period=${period}`,
|
||||
)
|
||||
if (res.ok) {
|
||||
const json = await res.json()
|
||||
setSubmission(json.data ?? null)
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}, [period])
|
||||
|
||||
useEffect(() => {
|
||||
fetchStatus()
|
||||
fetchSubmission()
|
||||
}, [fetchStatus, fetchSubmission])
|
||||
}, [fetchStatus])
|
||||
|
||||
// Handle of the OAuth popup opened by handleConnect: used to verify the
|
||||
// sender identity of incoming postMessages.
|
||||
@@ -231,6 +260,20 @@ export function AGIPanel(props: AGIPanelProps) {
|
||||
)
|
||||
}, [agiGeneratedAt])
|
||||
|
||||
// Loud success when the filing completes: a poll (live timers, tab refocus,
|
||||
// or the parent's refresh) flips isSigned while the user is on the page.
|
||||
// The ref starts null so an already-signed run doesn't toast on mount.
|
||||
const prevSignedRef = useRef<boolean | null>(null)
|
||||
useEffect(() => {
|
||||
if (prevSignedRef.current === false && isSigned) {
|
||||
toast({
|
||||
title: t('toast_signed_title'),
|
||||
description: t('toast_signed_description', { period: prettyPeriod }),
|
||||
})
|
||||
}
|
||||
prevSignedRef.current = isSigned
|
||||
}, [isSigned, toast, t, prettyPeriod])
|
||||
|
||||
// Background kvittens-polling timers (see scheduleKvittensPolls below).
|
||||
// Held in a ref so the unmount-cleanup effect can cancel them if the
|
||||
// user leaves the page mid-signing.
|
||||
@@ -263,7 +306,7 @@ export function AGIPanel(props: AGIPanelProps) {
|
||||
if (!res.ok) return false
|
||||
const json = await res.json()
|
||||
const signed = !!json.data?.kvittenser?.[0]?.uuidKvittens
|
||||
await fetchSubmission()
|
||||
onRefreshSubmission()
|
||||
if (signed) {
|
||||
// Replace any lingering "Granskningsunderlag klart…" / stale error
|
||||
// with an unambiguous confirmation. Mirrors handleCheckSubmitted.
|
||||
@@ -275,7 +318,7 @@ export function AGIPanel(props: AGIPanelProps) {
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}, [arbetsgivare, period, fetchSubmission, onChange, t])
|
||||
}, [arbetsgivare, period, onRefreshSubmission, onChange, t])
|
||||
|
||||
/**
|
||||
* Background-poll /agi/kvittenser at 30s, 2 min, and 5 min after the user
|
||||
@@ -348,13 +391,13 @@ export function AGIPanel(props: AGIPanelProps) {
|
||||
}
|
||||
setSuccess(t('disconnect_success'))
|
||||
await fetchStatus()
|
||||
await fetchSubmission()
|
||||
onRefreshSubmission()
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : t('disconnect_failed'))
|
||||
} finally {
|
||||
setActionLoading(null)
|
||||
}
|
||||
}, [fetchStatus, fetchSubmission, t])
|
||||
}, [fetchStatus, onRefreshSubmission, t])
|
||||
|
||||
const handleConnect = () => {
|
||||
// Open the BankID OAuth flow in a centered popup. The callback page
|
||||
@@ -418,9 +461,27 @@ export function AGIPanel(props: AGIPanelProps) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Step 1: POST the stored XML underlag, then poll kontrollresultat until
|
||||
* status flips out of PROCESSING. Skatteverket's spec says polling is
|
||||
* usually instantaneous, but we cap at 8 attempts × 1s to be safe.
|
||||
* The XML must exist in agi_declarations before anything can be submitted.
|
||||
* The internal xml route both generates and persists it (and stamps
|
||||
* agi_generated_at); the response body, the downloadable file itself, is
|
||||
* discarded here: "Ladda ner AGI-fil" remains the way to get a copy.
|
||||
*/
|
||||
async function ensureAgiGenerated(): Promise<boolean> {
|
||||
if (agiGeneratedAt) return true
|
||||
const res = await fetch(`/api/salary/runs/${salaryRunId}/agi/xml`)
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => ({}))
|
||||
setError(errText(data) || t('xml_generate_failed'))
|
||||
return false
|
||||
}
|
||||
onChange?.() // parent refetches the run so agiGeneratedAt flips
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* POST the stored XML underlag, then poll kontrollresultat until status
|
||||
* flips out of PROCESSING. Skatteverket's spec says polling is usually
|
||||
* instantaneous, but we cap at 8 attempts × 1s to be safe.
|
||||
*
|
||||
* On DONE_SUCCESS the underlag is auto-persisted by SKV: no /spara call.
|
||||
* Calling /spara when there are no errors returns 400 felkod 20
|
||||
@@ -431,10 +492,154 @@ export function AGIPanel(props: AGIPanelProps) {
|
||||
*
|
||||
* On DONE_REJECTED we surface the validation findings; the user can still
|
||||
* choose to save (so they can fix it in Mina Sidor) or abort.
|
||||
*
|
||||
* Failures surface via setError/setKontroller and return false. Shared by
|
||||
* the one-click chain and the advanced "Skicka in underlag" button;
|
||||
* `onKontrollPhase` lets the chain advance its stepper when polling starts.
|
||||
*/
|
||||
async function runSubmitUnderlag(onKontrollPhase?: () => void): Promise<boolean> {
|
||||
setKontroller([])
|
||||
const submitRes = await fetch('/api/extensions/ext/skatteverket/agi/submit', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ salaryRunId }),
|
||||
})
|
||||
const submitJson = await submitRes.json()
|
||||
if (!submitRes.ok || submitJson.error) {
|
||||
setError(submitJson.error || t('submit_failed_status', { status: submitRes.status }))
|
||||
return false
|
||||
}
|
||||
const inlamningId = submitJson.data?.inlamningId as number | undefined
|
||||
if (!inlamningId) {
|
||||
setError(t('submit_missing_id'))
|
||||
return false
|
||||
}
|
||||
|
||||
// Poll kontrollresultat until DONE_*
|
||||
onKontrollPhase?.()
|
||||
let kr: Kontrollresultat | undefined
|
||||
for (let attempt = 0; attempt < 8; attempt++) {
|
||||
const krRes = await fetch(
|
||||
`/api/extensions/ext/skatteverket/agi/kontrollresultat?inlamningId=${inlamningId}`,
|
||||
)
|
||||
const krJson = await krRes.json()
|
||||
if (!krRes.ok || krJson.error) {
|
||||
setError(krJson.error || t('kontrollresultat_failed_status', { status: krRes.status }))
|
||||
return false
|
||||
}
|
||||
kr = krJson.data as Kontrollresultat
|
||||
if (kr.status !== 'PROCESSING') break
|
||||
await new Promise(r => setTimeout(r, 1000))
|
||||
}
|
||||
if (!kr || kr.status === 'PROCESSING') {
|
||||
setError(t('still_processing'))
|
||||
return false
|
||||
}
|
||||
|
||||
const findings = extractFindings(kr)
|
||||
setKontroller(findings)
|
||||
|
||||
if (kr.status === 'DONE_SUCCESS') return true
|
||||
if (kr.status === 'DONE_REJECTED') {
|
||||
setError(t('underlag_rejected_error', { count: findings.filter(f => f.status === 'STOPP').length }))
|
||||
} else {
|
||||
setError(t('underlag_failed'))
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* skapaGranskningsunderlag: returns the Mina Sidor deep-link the user opens
|
||||
* to sign with BankID. Defaults to `lasPeriod=true` so the period is locked
|
||||
* while the signing window is open. Returns the link on success ('' when
|
||||
* the response carried none: the signing-link card renders it after the
|
||||
* submission refresh) and null on failure (error already surfaced).
|
||||
*/
|
||||
async function runCreateSigningLink(): Promise<string | null> {
|
||||
const res = await fetch(
|
||||
`/api/extensions/ext/skatteverket/agi/granskningsunderlag?arbetsgivare=${encodeURIComponent(arbetsgivare)}&period=${period}`,
|
||||
{ method: 'POST' },
|
||||
)
|
||||
const json = await res.json()
|
||||
if (!res.ok || json.error) {
|
||||
setError(json.error || t('signing_link_failed_status', { status: res.status }))
|
||||
return null
|
||||
}
|
||||
if (json.data?.tillstand === 'INCORRECT_DATA') {
|
||||
setError(t('incorrect_data_error', { message: json.data.meddelande || t('incorrect_data_fallback') }))
|
||||
return null
|
||||
}
|
||||
// The user typically opens the link, signs in Mina Sidor, then returns
|
||||
// later (or never). Auto-poll so we capture the kvittens (and stamp
|
||||
// agi_submitted_at) without forcing the user to click "Hämta kvittens".
|
||||
scheduleKvittensPolls()
|
||||
return typeof json.data?.link === 'string' ? json.data.link : ''
|
||||
}
|
||||
|
||||
/**
|
||||
* One-click filing: generate (if missing) → POST underlag → poll kontroll →
|
||||
* create signing link → hand over to BankID signing in Mina Sidor.
|
||||
*
|
||||
* The signing link opens in a tab we open synchronously at click time:
|
||||
* window.open after the async chain would be popup-blocked. On failure the
|
||||
* placeholder tab is closed and the error renders in the panel; if the
|
||||
* popup was blocked outright, the signing-link card (rendered from the
|
||||
* refreshed submission state) is the fallback path.
|
||||
*/
|
||||
const handleSubmitChain = async () => {
|
||||
setActionLoading('chain')
|
||||
setError(null)
|
||||
setSuccess(null)
|
||||
let signingTab: Window | null = null
|
||||
try {
|
||||
signingTab = window.open('', '_blank')
|
||||
if (signingTab) {
|
||||
signingTab.document.title = t('chain_tab_title')
|
||||
signingTab.document.body.textContent = t('chain_tab_body')
|
||||
}
|
||||
} catch {
|
||||
signingTab = null
|
||||
}
|
||||
try {
|
||||
setChain({ current: 'generate', failed: false, done: false })
|
||||
if (!(await ensureAgiGenerated())) throw new ChainFailed()
|
||||
|
||||
setChain({ current: 'submit', failed: false, done: false })
|
||||
const submitted = await runSubmitUnderlag(() =>
|
||||
setChain({ current: 'kontroll', failed: false, done: false }),
|
||||
)
|
||||
if (!submitted) throw new ChainFailed()
|
||||
|
||||
setChain({ current: 'link', failed: false, done: false })
|
||||
const link = await runCreateSigningLink()
|
||||
if (link === null) throw new ChainFailed()
|
||||
|
||||
setChain({ current: 'link', failed: false, done: true })
|
||||
setSuccess(t('chain_ready_to_sign'))
|
||||
if (signingTab && link) {
|
||||
signingTab.location.replace(link)
|
||||
signingTab = null // handed over to Skatteverket: don't close it below
|
||||
} else {
|
||||
signingTab?.close()
|
||||
signingTab = null
|
||||
}
|
||||
onRefreshSubmission()
|
||||
onChange?.()
|
||||
} catch (e) {
|
||||
signingTab?.close()
|
||||
setChain(prev => (prev ? { ...prev, failed: true } : prev))
|
||||
if (!(e instanceof ChainFailed)) {
|
||||
setError(e instanceof Error ? e.message : t('submit_failed'))
|
||||
}
|
||||
onRefreshSubmission()
|
||||
} finally {
|
||||
setActionLoading(null)
|
||||
}
|
||||
}
|
||||
|
||||
// Always-free: generate + download the AGI XML so the user can file manually
|
||||
// in Skatteverket's e-service. AGI is a mandatory statutory filing, so this
|
||||
// path must never be paywalled: only the direct API submission below is paid.
|
||||
// path must never be paywalled: only the direct API submission is paid.
|
||||
const handleDownloadXml = async () => {
|
||||
setActionLoading('download')
|
||||
setError(null)
|
||||
@@ -442,7 +647,7 @@ export function AGIPanel(props: AGIPanelProps) {
|
||||
const res = await fetch(`/api/salary/runs/${salaryRunId}/agi/xml`)
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => ({}))
|
||||
throw new Error(data.error || t('xml_generate_failed'))
|
||||
throw new Error(errText(data) || t('xml_generate_failed'))
|
||||
}
|
||||
const blob = await res.blob()
|
||||
const url = URL.createObjectURL(blob)
|
||||
@@ -461,60 +666,16 @@ export function AGIPanel(props: AGIPanelProps) {
|
||||
}
|
||||
}
|
||||
|
||||
/** Advanced/recovery variant: submit the underlag without continuing the chain. */
|
||||
const handleSubmit = async () => {
|
||||
setActionLoading('submit')
|
||||
setError(null)
|
||||
setSuccess(null)
|
||||
setKontroller([])
|
||||
try {
|
||||
const submitRes = await fetch('/api/extensions/ext/skatteverket/agi/submit', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ salaryRunId }),
|
||||
})
|
||||
const submitJson = await submitRes.json()
|
||||
if (!submitRes.ok || submitJson.error) {
|
||||
setError(submitJson.error || t('submit_failed_status', { status: submitRes.status }))
|
||||
return
|
||||
}
|
||||
const inlamningId = submitJson.data?.inlamningId as number | undefined
|
||||
if (!inlamningId) {
|
||||
setError(t('submit_missing_id'))
|
||||
return
|
||||
}
|
||||
|
||||
// Poll kontrollresultat until DONE_*
|
||||
let kr: Kontrollresultat | undefined
|
||||
for (let attempt = 0; attempt < 8; attempt++) {
|
||||
const krRes = await fetch(
|
||||
`/api/extensions/ext/skatteverket/agi/kontrollresultat?inlamningId=${inlamningId}`,
|
||||
)
|
||||
const krJson = await krRes.json()
|
||||
if (!krRes.ok || krJson.error) {
|
||||
setError(krJson.error || t('kontrollresultat_failed_status', { status: krRes.status }))
|
||||
return
|
||||
}
|
||||
kr = krJson.data as Kontrollresultat
|
||||
if (kr.status !== 'PROCESSING') break
|
||||
await new Promise(r => setTimeout(r, 1000))
|
||||
}
|
||||
if (!kr || kr.status === 'PROCESSING') {
|
||||
setError(t('still_processing'))
|
||||
return
|
||||
}
|
||||
|
||||
const findings = extractFindings(kr)
|
||||
setKontroller(findings)
|
||||
|
||||
if (kr.status === 'DONE_SUCCESS') {
|
||||
setSuccess(t('underlag_accepted'))
|
||||
} else if (kr.status === 'DONE_REJECTED') {
|
||||
setError(t('underlag_rejected_error', { count: findings.filter(f => f.status === 'STOPP').length }))
|
||||
} else {
|
||||
setError(t('underlag_failed'))
|
||||
}
|
||||
|
||||
await fetchSubmission()
|
||||
if (!(await ensureAgiGenerated())) return
|
||||
const ok = await runSubmitUnderlag()
|
||||
if (ok) setSuccess(t('underlag_accepted'))
|
||||
onRefreshSubmission()
|
||||
onChange?.()
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : t('submit_failed'))
|
||||
@@ -523,36 +684,15 @@ export function AGIPanel(props: AGIPanelProps) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Step 2: skapaGranskningsunderlag: returns the Mina Sidor deep-link the
|
||||
* user opens to sign with BankID. Defaults to `lasPeriod=true` so the
|
||||
* period is locked while the signing window is open.
|
||||
*/
|
||||
/** Advanced/recovery variant: create the signing link on its own. */
|
||||
const handleCreateSigningLink = async () => {
|
||||
setActionLoading('granskning')
|
||||
setError(null)
|
||||
setSuccess(null)
|
||||
try {
|
||||
const res = await fetch(
|
||||
`/api/extensions/ext/skatteverket/agi/granskningsunderlag?arbetsgivare=${encodeURIComponent(arbetsgivare)}&period=${period}`,
|
||||
{ method: 'POST' },
|
||||
)
|
||||
const json = await res.json()
|
||||
if (!res.ok || json.error) {
|
||||
setError(json.error || t('signing_link_failed_status', { status: res.status }))
|
||||
return
|
||||
}
|
||||
if (json.data?.tillstand === 'INCORRECT_DATA') {
|
||||
setError(t('incorrect_data_error', { message: json.data.meddelande || t('incorrect_data_fallback') }))
|
||||
} else {
|
||||
setSuccess(t('signing_link_ready'))
|
||||
// The user typically opens the link, signs in Mina Sidor, then
|
||||
// returns later (or never). Auto-poll so we capture the kvittens
|
||||
// (and stamp agi_submitted_at) without forcing the user to come
|
||||
// back and click "Hämta kvittens".
|
||||
scheduleKvittensPolls()
|
||||
}
|
||||
await fetchSubmission()
|
||||
const link = await runCreateSigningLink()
|
||||
if (link !== null) setSuccess(t('signing_link_ready'))
|
||||
onRefreshSubmission()
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : t('signing_link_failed'))
|
||||
} finally {
|
||||
@@ -575,7 +715,7 @@ export function AGIPanel(props: AGIPanelProps) {
|
||||
return
|
||||
}
|
||||
setSuccess(t('unlock_success'))
|
||||
await fetchSubmission()
|
||||
onRefreshSubmission()
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : t('unlock_failed'))
|
||||
} finally {
|
||||
@@ -584,7 +724,7 @@ export function AGIPanel(props: AGIPanelProps) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Step 3 (post-signing): poll /agi/kvittenser to detect that the user has
|
||||
* Post-signing recovery: poll /agi/kvittenser to detect that the user has
|
||||
* signed in Mina Sidor. Once a kvittens turns up, the index.ts handler
|
||||
* mirrors it onto agi_declarations and flips the local submission state
|
||||
* to 'signed'.
|
||||
@@ -608,7 +748,7 @@ export function AGIPanel(props: AGIPanelProps) {
|
||||
} else {
|
||||
setSuccess(t('no_kvittens_yet'))
|
||||
}
|
||||
await fetchSubmission()
|
||||
onRefreshSubmission()
|
||||
onChange?.()
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : t('check_status_failed'))
|
||||
@@ -673,23 +813,6 @@ export function AGIPanel(props: AGIPanelProps) {
|
||||
)
|
||||
}
|
||||
|
||||
const subState = submission?.status
|
||||
const awaitingSigning = subState === 'awaiting_signing'
|
||||
const underlagSubmitted = subState === 'underlag_submitted'
|
||||
const underlagRejected = subState === 'underlag_rejected'
|
||||
const isSigned = subState === 'signed' || !!agiSubmittedAt
|
||||
// The submission state is keyed by PERIOD; AGI generation is keyed by RUN.
|
||||
// If the run's AGI was (re)generated AFTER this signing draft was created,
|
||||
// the locked underlag at Skatteverket reflects superseded figures and must
|
||||
// not be signed: surface a warning and steer the user to unlock + resubmit
|
||||
// rather than presenting it as ready to sign (avoids filing stale amounts).
|
||||
const draftUpdatedAt = submission?.updatedAt ? new Date(submission.updatedAt) : null
|
||||
const draftIsStale =
|
||||
awaitingSigning &&
|
||||
!!agiGeneratedAt &&
|
||||
!!draftUpdatedAt &&
|
||||
!Number.isNaN(draftUpdatedAt.getTime()) &&
|
||||
new Date(agiGeneratedAt).getTime() > draftUpdatedAt.getTime()
|
||||
// Tokens issued before the agd scope was added to DEFAULT_SCOPES will
|
||||
// 403 with invalid_scope at submission time: surface that proactively
|
||||
// so the user reconnects before hitting the deadline rather than at it.
|
||||
@@ -697,6 +820,23 @@ export function AGIPanel(props: AGIPanelProps) {
|
||||
typeof status?.scope === 'string' &&
|
||||
!status.scope.split(/\s+/).filter(Boolean).includes('agd')
|
||||
|
||||
// Recovery states expose the advanced actions on their own: the stale-draft
|
||||
// and error-report guidance below reference them by name.
|
||||
const forcedAdvanced = draftIsStale || underlagRejected
|
||||
const advancedOpen = showAdvanced || forcedAdvanced
|
||||
|
||||
const signedAtRaw = submission?.signeradTid ?? agiSubmittedAt ?? null
|
||||
const signedAtText = signedAtRaw ? new Date(signedAtRaw).toLocaleString('sv-SE') : null
|
||||
|
||||
const chainStepState = (step: ChainStep): 'done' | 'running' | 'failed' | 'upcoming' => {
|
||||
if (!chain) return 'upcoming'
|
||||
const idx = CHAIN_STEPS.indexOf(step)
|
||||
const currentIdx = CHAIN_STEPS.indexOf(chain.current)
|
||||
if (idx < currentIdx || (idx === currentIdx && chain.done)) return 'done'
|
||||
if (idx === currentIdx) return chain.failed ? 'failed' : 'running'
|
||||
return 'upcoming'
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
@@ -727,6 +867,40 @@ export function AGIPanel(props: AGIPanelProps) {
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{/* Filed: the terminal state deserves more than a gray status row.
|
||||
Kvittensnummer + signature metadata come from the submission
|
||||
record; a run stamped only via agi_submitted_at (e.g. cron
|
||||
reconciliation with an evicted cache) still gets the card. */}
|
||||
{isSigned && (
|
||||
<div className="rounded-md border border-border bg-muted/30 p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<CheckCircle2 className="mt-0.5 h-5 w-5 shrink-0 text-success" />
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm font-medium">
|
||||
{t('success_card_title', { period: prettyPeriod })}
|
||||
</p>
|
||||
{submission?.kvittensnummer && (
|
||||
<p className="text-sm text-muted-foreground tabular-nums">
|
||||
{t('success_card_kvittens', { kvittens: submission.kvittensnummer })}
|
||||
</p>
|
||||
)}
|
||||
{(submission?.signeradAv || signedAtText) && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{submission?.signeradAv
|
||||
? signedAtText
|
||||
? t('success_card_signed_by_at', {
|
||||
name: submission.signeradAv,
|
||||
date: signedAtText,
|
||||
})
|
||||
: t('success_card_signed_by', { name: submission.signeradAv })
|
||||
: t('success_card_signed_at', { date: signedAtText ?? '' })}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Expired-session banner: the token row exists (so status.connected
|
||||
is true) but the access token is past expiry and either has no
|
||||
refresh token or has burned through its 10-refresh budget. The
|
||||
@@ -919,74 +1093,140 @@ export function AGIPanel(props: AGIPanelProps) {
|
||||
)}
|
||||
|
||||
{!readOnly && !isSigned && (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={handleDownloadXml}
|
||||
disabled={actionLoading === 'download'}
|
||||
title={t('download_xml_title')}
|
||||
>
|
||||
{actionLoading === 'download' ? (
|
||||
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />
|
||||
) : (
|
||||
<Download className="mr-1.5 h-3.5 w-3.5" />
|
||||
<div className="space-y-3">
|
||||
{/* Primary path: one click runs the whole filing chain. Hidden
|
||||
while a signing draft is open at SKV (the period is locked,
|
||||
so a resubmission would be refused): the signing-link card
|
||||
above is the CTA then, and the stale-draft recovery goes
|
||||
through the advanced actions per the guidance text. The
|
||||
XML download stays free for manual filing regardless. */}
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{!awaitingSigning && (
|
||||
<Button
|
||||
onClick={handleSubmitChain}
|
||||
disabled={actionLoading !== null || !hasSkatteverket}
|
||||
>
|
||||
{actionLoading === 'chain' ? (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Send className="mr-2 h-4 w-4" />
|
||||
)}
|
||||
{t('chain_button')}
|
||||
</Button>
|
||||
)}
|
||||
{t('download_xml_button')}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={handleSubmit}
|
||||
disabled={actionLoading === 'submit' || !hasSkatteverket}
|
||||
>
|
||||
{actionLoading === 'submit' ? (
|
||||
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />
|
||||
) : (
|
||||
<Send className="mr-1.5 h-3.5 w-3.5" />
|
||||
)}
|
||||
{t('submit_button')}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handleCreateSigningLink}
|
||||
disabled={actionLoading === 'granskning' || !underlagSubmitted}
|
||||
>
|
||||
{actionLoading === 'granskning' ? (
|
||||
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />
|
||||
) : (
|
||||
<Lock className="mr-1.5 h-3.5 w-3.5" />
|
||||
)}
|
||||
{t('signing_link_button')}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={handleCheckSubmitted}
|
||||
disabled={actionLoading === 'check'}
|
||||
>
|
||||
{actionLoading === 'check' ? (
|
||||
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />
|
||||
) : (
|
||||
<Download className="mr-1.5 h-3.5 w-3.5" />
|
||||
)}
|
||||
{t('check_kvittens_button')}
|
||||
</Button>
|
||||
{awaitingSigning && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={handleUnlock}
|
||||
disabled={actionLoading === 'unlock'}
|
||||
variant="outline"
|
||||
onClick={handleDownloadXml}
|
||||
disabled={actionLoading === 'download'}
|
||||
title={t('download_xml_title')}
|
||||
>
|
||||
{actionLoading === 'unlock' ? (
|
||||
{actionLoading === 'download' ? (
|
||||
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />
|
||||
) : (
|
||||
<Unlock className="mr-1.5 h-3.5 w-3.5" />
|
||||
<Download className="mr-1.5 h-3.5 w-3.5" />
|
||||
)}
|
||||
{t('unlock_button')}
|
||||
{t('download_xml_button')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{chain && (
|
||||
<ol className="space-y-1.5 rounded-md border border-border bg-muted/30 p-3">
|
||||
{CHAIN_STEPS.map(step => {
|
||||
const state = chainStepState(step)
|
||||
return (
|
||||
<li key={step} className="flex items-center gap-2 text-xs">
|
||||
{state === 'done' ? (
|
||||
<CheckCircle2 className="h-3.5 w-3.5 shrink-0 text-success" />
|
||||
) : state === 'running' ? (
|
||||
<Loader2 className="h-3.5 w-3.5 shrink-0 animate-spin" />
|
||||
) : state === 'failed' ? (
|
||||
<AlertCircle className="h-3.5 w-3.5 shrink-0 text-destructive" />
|
||||
) : (
|
||||
<Circle className="h-3.5 w-3.5 shrink-0 text-muted-foreground/50" />
|
||||
)}
|
||||
<span className={state === 'upcoming' ? 'text-muted-foreground' : ''}>
|
||||
{t(`chain_step_${step}`)}
|
||||
</span>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ol>
|
||||
)}
|
||||
|
||||
{/* Recovery/expert actions: each is one step of the chain above,
|
||||
for resuming after a partial failure. Auto-expanded when a
|
||||
recovery state (stale draft, rejected underlag) references
|
||||
them by name. */}
|
||||
<div>
|
||||
{!forcedAdvanced && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowAdvanced(v => !v)}
|
||||
className="text-xs text-muted-foreground transition-colors hover:text-foreground"
|
||||
>
|
||||
{advancedOpen ? t('advanced_hide') : t('advanced_show')}
|
||||
</button>
|
||||
)}
|
||||
{advancedOpen && (
|
||||
<div className="mt-2 flex flex-wrap gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={handleSubmit}
|
||||
disabled={actionLoading !== null || !hasSkatteverket}
|
||||
>
|
||||
{actionLoading === 'submit' ? (
|
||||
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />
|
||||
) : (
|
||||
<Send className="mr-1.5 h-3.5 w-3.5" />
|
||||
)}
|
||||
{t('submit_button')}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={handleCreateSigningLink}
|
||||
disabled={actionLoading !== null || !underlagSubmitted}
|
||||
>
|
||||
{actionLoading === 'granskning' ? (
|
||||
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />
|
||||
) : (
|
||||
<Lock className="mr-1.5 h-3.5 w-3.5" />
|
||||
)}
|
||||
{t('signing_link_button')}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={handleCheckSubmitted}
|
||||
disabled={actionLoading !== null}
|
||||
>
|
||||
{actionLoading === 'check' ? (
|
||||
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />
|
||||
) : (
|
||||
<Download className="mr-1.5 h-3.5 w-3.5" />
|
||||
)}
|
||||
{t('check_kvittens_button')}
|
||||
</Button>
|
||||
{awaitingSigning && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={handleUnlock}
|
||||
disabled={actionLoading !== null}
|
||||
>
|
||||
{actionLoading === 'unlock' ? (
|
||||
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />
|
||||
) : (
|
||||
<Unlock className="mr-1.5 h-3.5 w-3.5" />
|
||||
)}
|
||||
{t('unlock_button')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -168,6 +168,8 @@ function NewEmployeeForm({ onCreated, onCancel }: { onCreated: () => void; onCan
|
||||
employment_start: form.get('employment_start') as string,
|
||||
employment_end: form.get('employment_end') as string || undefined,
|
||||
employment_degree: parseFloat(form.get('employment_degree') as string) || 100,
|
||||
hours_per_week: parseFloat(form.get('hours_per_week') as string) || 40,
|
||||
workdays_per_week: parseFloat(form.get('workdays_per_week') as string) || 5,
|
||||
salary_type: salaryType,
|
||||
monthly_salary: salaryType === 'monthly' ? (parseFloat(form.get('monthly_salary') as string) || undefined) : undefined,
|
||||
hourly_rate: salaryType === 'hourly' ? (parseFloat(form.get('hourly_rate') as string) || undefined) : undefined,
|
||||
@@ -293,6 +295,12 @@ function NewEmployeeForm({ onCreated, onCancel }: { onCreated: () => void; onCan
|
||||
<Field label="Sysselsättningsgrad (%)" htmlFor="employment_degree">
|
||||
<Input id="employment_degree" name="employment_degree" type="number" defaultValue="100" min="1" max="100" />
|
||||
</Field>
|
||||
<Field label="Timmar per vecka" htmlFor="hours_per_week">
|
||||
<Input id="hours_per_week" name="hours_per_week" type="number" defaultValue="40" min="1" max="80" step="0.5" />
|
||||
</Field>
|
||||
<Field label="Arbetsdagar per vecka" htmlFor="workdays_per_week">
|
||||
<Input id="workdays_per_week" name="workdays_per_week" type="number" defaultValue="5" min="1" max="7" step="1" />
|
||||
</Field>
|
||||
<Field label="Löneform" htmlFor="salary_type" required>
|
||||
<Select value={salaryType} onValueChange={setSalaryType}>
|
||||
<SelectTrigger id="salary_type">
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import { ArrowLeftCircle, Eye, FileDown, Loader2, Send } from 'lucide-react'
|
||||
import { formatDateLong } from '@/lib/utils'
|
||||
import { useCapability } from '@/contexts/CompanyContext'
|
||||
import { CAPABILITY } from '@/lib/entitlements/keys'
|
||||
import type { AgiFilingState } from '@/lib/salary/agi-submission-state'
|
||||
import type { RunDetail } from './types'
|
||||
|
||||
type StepState = 'done' | 'active' | 'upcoming'
|
||||
@@ -16,6 +17,12 @@ interface RunProgressBarProps {
|
||||
// True when the run pays out nothing (nollkörning / fully net-deducted): the
|
||||
// pay step carries no "download the file" hint because there is no file.
|
||||
noPayout?: boolean
|
||||
// Real filing state for the AGI step (deriveAgiFilingState): without it the
|
||||
// rail only knows generated/submitted and mislabels "waiting for BankID
|
||||
// signature" as "lämna in till Skatteverket".
|
||||
agiState: AgiFilingState
|
||||
// Shown on the AGI step once signed: the kvittens is the filing receipt.
|
||||
agiKvittensnummer?: string | null
|
||||
canWrite: boolean
|
||||
actionLoading: string | null
|
||||
// The single "forward" step for the current status (Beräkna → Skicka till
|
||||
@@ -43,7 +50,7 @@ const STATUS_RANK: Record<string, number> = {
|
||||
export function RunProgressBar(props: RunProgressBarProps) {
|
||||
const t = useTranslations('salary_run')
|
||||
const locale = useLocale()
|
||||
const { run, isCalculated, noPayout, canWrite, actionLoading, primaryAction } = props
|
||||
const { run, isCalculated, noPayout, canWrite, actionLoading, primaryAction, agiState, agiKvittensnummer } = props
|
||||
const rank = STATUS_RANK[run.status] ?? 0
|
||||
const busy = !!actionLoading
|
||||
const deliveries = run.payslip_deliveries_summary
|
||||
@@ -116,14 +123,21 @@ export function RunProgressBar(props: RunProgressBarProps) {
|
||||
{
|
||||
key: 'agi',
|
||||
label: t('rail_agi'),
|
||||
state: run.agi_submitted_at ? 'done' : run.status === 'booked' ? 'active' : 'upcoming',
|
||||
detail: run.agi_submitted_at
|
||||
? t('rail_agi_submitted')
|
||||
: run.agi_generated_at
|
||||
? t('rail_agi_generated')
|
||||
: run.status === 'booked'
|
||||
? t('rail_agi_hint')
|
||||
: undefined,
|
||||
state: agiState === 'signed' ? 'done' : run.status === 'booked' ? 'active' : 'upcoming',
|
||||
detail:
|
||||
agiState === 'signed'
|
||||
? agiKvittensnummer
|
||||
? t('rail_agi_submitted_kvittens', { kvittens: agiKvittensnummer })
|
||||
: t('rail_agi_submitted')
|
||||
: agiState === 'awaiting_signing'
|
||||
? t('rail_agi_awaiting_signature')
|
||||
: agiState === 'underlag_submitted'
|
||||
? t('rail_agi_underlag_submitted')
|
||||
: agiState === 'generated'
|
||||
? t('rail_agi_generated')
|
||||
: run.status === 'booked'
|
||||
? t('rail_agi_hint')
|
||||
: undefined,
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
@@ -5,10 +5,9 @@ import {
|
||||
createElement,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useState,
|
||||
type ReactNode,
|
||||
} from 'react'
|
||||
import useSWR from 'swr'
|
||||
import { createClient } from '@/lib/supabase/client'
|
||||
import { useCompany } from '@/contexts/CompanyContext'
|
||||
import type { CompanySettings } from '@/types'
|
||||
@@ -36,48 +35,56 @@ export interface SettingsState {
|
||||
*/
|
||||
export function useCompanySettings(): SettingsState {
|
||||
const { company } = useCompany()
|
||||
const [settings, setSettings] = useState<CompanySettings | null>(null)
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [error, setError] = useState(false)
|
||||
const companyId = company?.id ?? null
|
||||
|
||||
const fetchSettings = useCallback(async () => {
|
||||
if (!company?.id) {
|
||||
// No active company (the no-company escape hatch). Nothing to load; surface
|
||||
// a settled empty state rather than a perpetual spinner.
|
||||
setSettings(null)
|
||||
setError(false)
|
||||
setIsLoading(false)
|
||||
return
|
||||
}
|
||||
// SWR-backed: every consumer of the same company's settings shares one
|
||||
// cache entry, so concurrent mounts dedupe into a single request and
|
||||
// back-navigation renders from cache (revalidating in the background)
|
||||
// instead of re-showing a skeleton. Null key = no active company (the
|
||||
// no-company escape hatch): a settled empty state, never a spinner.
|
||||
const { data, error: swrError, isLoading, mutate } = useSWR(
|
||||
companyId ? ['company_settings', companyId] : null,
|
||||
async ([, id]: [string, string]) => {
|
||||
const supabase = createClient()
|
||||
// maybeSingle() so a missing row resolves to { data: null } instead of
|
||||
// throwing PGRST116: a company created outside the onboarding flow may
|
||||
// have no company_settings row yet, and that must not be treated as a
|
||||
// hard error mid-query (it's surfaced as `error` below once settled).
|
||||
const { data, error: queryError } = await supabase
|
||||
.from('company_settings')
|
||||
.select('*')
|
||||
.eq('company_id', id)
|
||||
.maybeSingle()
|
||||
if (queryError) throw queryError
|
||||
return data as CompanySettings | null
|
||||
},
|
||||
)
|
||||
|
||||
setIsLoading(true)
|
||||
setError(false)
|
||||
const updateSettings = useCallback(
|
||||
(updates: Partial<CompanySettings>) => {
|
||||
// Optimistic local patch only: callers persist through their own API
|
||||
// routes. revalidate: false so the patch isn't immediately overwritten
|
||||
// by a refetch racing the server-side write.
|
||||
void mutate((prev) => (prev ? ({ ...prev, ...updates } as CompanySettings) : prev), {
|
||||
revalidate: false,
|
||||
})
|
||||
},
|
||||
[mutate],
|
||||
)
|
||||
|
||||
const supabase = createClient()
|
||||
// maybeSingle() so a missing row resolves to { data: null } instead of
|
||||
// throwing PGRST116: a company created outside the onboarding flow may have
|
||||
// no company_settings row yet, and that must not be treated as a hard error
|
||||
// mid-query (it's surfaced as `error` below once the fetch settles).
|
||||
const { data, error: queryError } = await supabase
|
||||
.from('company_settings')
|
||||
.select('*')
|
||||
.eq('company_id', company.id)
|
||||
.maybeSingle()
|
||||
const refetch = useCallback(async () => {
|
||||
await mutate()
|
||||
}, [mutate])
|
||||
|
||||
setSettings(data)
|
||||
setError(Boolean(queryError) || !data)
|
||||
setIsLoading(false)
|
||||
}, [company?.id])
|
||||
|
||||
useEffect(() => {
|
||||
fetchSettings()
|
||||
}, [fetchSettings])
|
||||
|
||||
const updateSettings = useCallback((updates: Partial<CompanySettings>) => {
|
||||
setSettings((prev) => (prev ? ({ ...prev, ...updates } as CompanySettings) : prev))
|
||||
}, [])
|
||||
|
||||
return { settings, isLoading, error, updateSettings, refetch: fetchSettings }
|
||||
return {
|
||||
settings: data ?? null,
|
||||
isLoading: companyId ? isLoading : false,
|
||||
// Same contract as before SWR: error means "settled without a row"
|
||||
// (query failure or missing company_settings row), never mid-flight.
|
||||
error: companyId ? !isLoading && (Boolean(swrError) || !data) : false,
|
||||
updateSettings,
|
||||
refetch,
|
||||
}
|
||||
}
|
||||
|
||||
const SettingsContext = createContext<SettingsState | null>(null)
|
||||
|
||||
@@ -0,0 +1,199 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
|
||||
// Entitlement gate passes in these tests; the gate itself is covered by
|
||||
// capability-gate.test.ts.
|
||||
vi.mock('@/lib/entitlements/has-capability', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/lib/entitlements/has-capability')>()
|
||||
return { ...actual, requireCapability: vi.fn() }
|
||||
})
|
||||
|
||||
const { mockStartAuthorization, mockGetPreferredAuthMethod } = vi.hoisted(() => ({
|
||||
mockStartAuthorization: vi.fn(),
|
||||
mockGetPreferredAuthMethod: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('../lib/api-client', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('../lib/api-client')>()
|
||||
return {
|
||||
...actual,
|
||||
startAuthorization: (...args: unknown[]) => mockStartAuthorization(...args),
|
||||
getPreferredAuthMethod: (...args: unknown[]) => mockGetPreferredAuthMethod(...args),
|
||||
}
|
||||
})
|
||||
|
||||
import { enableBankingExtension } from '../index'
|
||||
import { requireCapability } from '@/lib/entitlements/has-capability'
|
||||
import type { ExtensionContext } from '@/lib/extensions/types'
|
||||
|
||||
interface RecordedCall {
|
||||
method: string
|
||||
args: unknown[]
|
||||
}
|
||||
|
||||
interface RecordedChain {
|
||||
_calls: RecordedCall[]
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
function makeChain(result: { data?: unknown; error?: unknown }): RecordedChain {
|
||||
const calls: RecordedCall[] = []
|
||||
const chain: Record<string, unknown> = { _calls: calls }
|
||||
for (const m of ['select', 'eq', 'in', 'is', 'order', 'limit', 'update', 'delete', 'insert']) {
|
||||
chain[m] = vi.fn((...args: unknown[]) => {
|
||||
calls.push({ method: m, args })
|
||||
return chain
|
||||
})
|
||||
}
|
||||
chain.maybeSingle = vi.fn().mockResolvedValue({ data: result.data ?? null, error: result.error ?? null })
|
||||
chain.single = vi.fn().mockResolvedValue({ data: result.data ?? null, error: result.error ?? null })
|
||||
chain.then = (resolve: (v: unknown) => void) => resolve({ data: result.data ?? null, error: result.error ?? null })
|
||||
return chain as RecordedChain
|
||||
}
|
||||
|
||||
function makeContext(fromImpl: (table: string) => unknown): ExtensionContext {
|
||||
return {
|
||||
userId: 'user-1',
|
||||
companyId: 'company-1',
|
||||
extensionId: 'enable-banking',
|
||||
requestId: 'req_test',
|
||||
supabase: {
|
||||
auth: {
|
||||
getUser: vi.fn().mockResolvedValue({ data: { user: { id: 'user-1' } }, error: null }),
|
||||
},
|
||||
from: vi.fn(fromImpl),
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
} as any,
|
||||
emit: vi.fn().mockResolvedValue(undefined),
|
||||
log: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn(), child: vi.fn() },
|
||||
settings: {
|
||||
get: vi.fn().mockResolvedValue(null),
|
||||
set: vi.fn().mockResolvedValue(undefined),
|
||||
clear: vi.fn().mockResolvedValue(undefined),
|
||||
},
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
} as any
|
||||
}
|
||||
|
||||
function connectRoute() {
|
||||
const route = enableBankingExtension.apiRoutes?.find(
|
||||
(r) => r.method === 'POST' && r.path === '/connect',
|
||||
)
|
||||
expect(route, 'POST /connect must be registered').toBeDefined()
|
||||
return route!
|
||||
}
|
||||
|
||||
function makeConnectRequest() {
|
||||
return new Request('https://test.local/api/extensions/ext/enable-banking/connect', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ aspsp_name: 'Nordea', aspsp_country: 'SE', psu_type: 'business' }),
|
||||
})
|
||||
}
|
||||
|
||||
describe('POST /connect never-activated row cleanup', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.mocked(requireCapability).mockResolvedValue(null)
|
||||
mockGetPreferredAuthMethod.mockResolvedValue(undefined)
|
||||
mockStartAuthorization.mockResolvedValue({
|
||||
url: 'https://bank.example/auth',
|
||||
authorization_id: 'auth-1',
|
||||
})
|
||||
})
|
||||
|
||||
it('deletes stale pending and error zombies instead of parking them in error', async () => {
|
||||
const chains: RecordedChain[] = []
|
||||
let call = 0
|
||||
const ctx = makeContext(() => {
|
||||
call++
|
||||
let chain: RecordedChain
|
||||
if (call === 1) {
|
||||
// Latest pending row is stale (45s > 30s threshold): not a live attempt.
|
||||
chain = makeChain({
|
||||
data: { id: 'stale-1', created_at: new Date(Date.now() - 45_000).toISOString() },
|
||||
})
|
||||
} else if (call === 2) {
|
||||
// The sweep: returns the deleted never-activated rows.
|
||||
chain = makeChain({ data: [{ id: 'stale-1' }, { id: 'old-error' }] })
|
||||
} else {
|
||||
// Insert of the fresh connection row.
|
||||
chain = makeChain({ data: { id: 'new-conn' } })
|
||||
}
|
||||
chains.push(chain)
|
||||
return chain
|
||||
})
|
||||
|
||||
const response = await connectRoute().handler(makeConnectRequest(), ctx)
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
const body = (await response.json()) as { connection_id: string; authorization_url: string }
|
||||
expect(body.connection_id).toBe('new-conn')
|
||||
expect(body.authorization_url).toBe('https://bank.example/auth')
|
||||
|
||||
// The sweep DELETEs never-activated rows: stale pendings and error rows
|
||||
// from failed attempts, guarded so established connections (session_id or
|
||||
// accounts_data present) are untouched.
|
||||
const sweep = chains[1]
|
||||
const methods = sweep._calls.map((c) => c.method)
|
||||
expect(methods).toContain('delete')
|
||||
const inCall = sweep._calls.find((c) => c.method === 'in')
|
||||
expect(inCall?.args).toEqual(['status', ['pending', 'error']])
|
||||
const isCalls = sweep._calls.filter((c) => c.method === 'is')
|
||||
expect(isCalls.map((c) => c.args)).toEqual(
|
||||
expect.arrayContaining([
|
||||
['session_id', null],
|
||||
['accounts_data', null],
|
||||
]),
|
||||
)
|
||||
|
||||
// Nothing gets parked as status='error' anymore: no update on any chain.
|
||||
for (const chain of chains) {
|
||||
expect(chain._calls.some((c) => c.method === 'update')).toBe(false)
|
||||
}
|
||||
})
|
||||
|
||||
it('still rejects a duplicate connect while a recent pending attempt is live', async () => {
|
||||
const chains: RecordedChain[] = []
|
||||
const ctx = makeContext(() => {
|
||||
// Latest pending row is 5s old: the user is mid-redirect at the bank.
|
||||
const chain = makeChain({
|
||||
data: { id: 'live-1', created_at: new Date(Date.now() - 5_000).toISOString() },
|
||||
})
|
||||
chains.push(chain)
|
||||
return chain
|
||||
})
|
||||
|
||||
const response = await connectRoute().handler(makeConnectRequest(), ctx)
|
||||
|
||||
expect(response.status).toBe(409)
|
||||
// No sweep while an attempt is live: the live pending row must survive.
|
||||
for (const chain of chains) {
|
||||
expect(chain._calls.some((c) => c.method === 'delete')).toBe(false)
|
||||
}
|
||||
expect(mockStartAuthorization).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('sweeps error zombies even when no pending row exists', async () => {
|
||||
const chains: RecordedChain[] = []
|
||||
let call = 0
|
||||
const ctx = makeContext(() => {
|
||||
call++
|
||||
let chain: RecordedChain
|
||||
if (call === 1) {
|
||||
chain = makeChain({ data: null })
|
||||
} else if (call === 2) {
|
||||
chain = makeChain({ data: [{ id: 'old-error' }] })
|
||||
} else {
|
||||
chain = makeChain({ data: { id: 'new-conn' } })
|
||||
}
|
||||
chains.push(chain)
|
||||
return chain
|
||||
})
|
||||
|
||||
const response = await connectRoute().handler(makeConnectRequest(), ctx)
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
const sweep = chains[1]
|
||||
expect(sweep._calls.some((c) => c.method === 'delete')).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -107,6 +107,11 @@ function makeContext(connection: Record<string, unknown>, updateSpy: Mock, inser
|
||||
chain.gte = vi.fn(() => chain)
|
||||
chain.limit = vi.fn(() => chain)
|
||||
chain.order = vi.fn(() => chain)
|
||||
// The fresh-connect path sweeps never-activated rows before inserting:
|
||||
// .delete().eq(...).in(...).is(...).select('id') must chain through.
|
||||
chain.delete = vi.fn(() => chain)
|
||||
chain.in = vi.fn(() => chain)
|
||||
chain.is = vi.fn(() => chain)
|
||||
chain.insert = vi.fn((payload: unknown) => {
|
||||
insertSpy?.(payload)
|
||||
return chain
|
||||
|
||||
@@ -2,11 +2,12 @@
|
||||
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { useSearchParams } from 'next/navigation'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { useToast } from '@/components/ui/use-toast'
|
||||
import { DestructiveConfirmDialog, useDestructiveConfirm } from '@/components/ui/destructive-confirm-dialog'
|
||||
import { AlertTriangle, Loader2, Upload } from 'lucide-react'
|
||||
import { AlertTriangle, CheckCircle, Loader2, Upload } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { createClient } from '@/lib/supabase/client'
|
||||
import { notifyBankSyncUpdated } from '@/lib/transactions/bank-sync-signal'
|
||||
@@ -26,6 +27,13 @@ import type { StoredAccount } from '../types'
|
||||
export default function BankingSettingsPanel() {
|
||||
const { toast } = useToast()
|
||||
const supabase = createClient()
|
||||
// The OAuth callback lands here with ?select_accounts=<id> once a bank is
|
||||
// successfully connected. Read via useSearchParams (SSR/hydration-safe) so
|
||||
// the first-load spinner can say "bank connected, fetching accounts"
|
||||
// instead of an anonymous spinner. The param itself is consumed and
|
||||
// stripped by the auto-open effect below.
|
||||
const searchParams = useSearchParams()
|
||||
const arrivedFromBankCallback = !!searchParams?.get('select_accounts')
|
||||
|
||||
const { dialogProps, confirm } = useDestructiveConfirm()
|
||||
const { company } = useCompany()
|
||||
@@ -365,6 +373,24 @@ export default function BankingSettingsPanel() {
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
// Coming back from the bank's consent flow the connection already exists,
|
||||
// so tell the user that instead of showing an anonymous spinner: this is
|
||||
// the last silent gap between "approved at the bank" and the account
|
||||
// picker opening.
|
||||
if (arrivedFromBankCallback) {
|
||||
return (
|
||||
<div className="flex h-32 flex-col items-center justify-center gap-3">
|
||||
<div className="flex items-center gap-2 text-sm font-medium">
|
||||
<CheckCircle className="h-4 w-4 text-success" />
|
||||
<span>Banken är ansluten</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
<span>Hämtar dina konton…</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<div className="flex items-center justify-center h-32">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||
|
||||
@@ -238,18 +238,33 @@ export const enableBankingExtension: Extension = {
|
||||
{ status: 409 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Clean up stale pending connections (older than threshold)
|
||||
log.info('[enable-banking] Cleaning up stale pending connections', {
|
||||
stale_id: recentPending.id,
|
||||
age_ms: pendingAge,
|
||||
// Sweep failed attempts that never became a live connection:
|
||||
// stale 'pending' rows (abandoned redirects past the threshold)
|
||||
// and 'error' rows left by earlier denied/failed connects.
|
||||
// DELETE instead of marking 'error': a parked 'error' row renders
|
||||
// forever as an "Åtgärd krävs" card, so a failed attempt followed
|
||||
// by a successful retry showed up as two connections to the same
|
||||
// bank. The session_id/accounts_data guards protect established
|
||||
// connections (anything that ever completed the callback has
|
||||
// accounts_data); never-activated rows have no dependents, and
|
||||
// the transactions/cash_accounts FKs are ON DELETE SET NULL.
|
||||
const { data: sweptRows } = await supabase
|
||||
.from('bank_connections')
|
||||
.delete()
|
||||
.eq('company_id', companyId)
|
||||
.eq('bank_name', resolvedAspspName)
|
||||
.in('status', ['pending', 'error'])
|
||||
.is('session_id', null)
|
||||
.is('accounts_data', null)
|
||||
.select('id')
|
||||
|
||||
if (sweptRows?.length) {
|
||||
log.info('[enable-banking] Swept never-activated connection attempts', {
|
||||
count: sweptRows.length,
|
||||
bank: resolvedAspspName,
|
||||
})
|
||||
await supabase
|
||||
.from('bank_connections')
|
||||
.update({ status: 'error', error_message: 'Superseded by new connection attempt', oauth_state: null })
|
||||
.eq('company_id', companyId)
|
||||
.eq('bank_name', resolvedAspspName)
|
||||
.eq('status', 'pending')
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -97,9 +97,22 @@ describe('tools/list payload size guard', () => {
|
||||
// already-minimal ~24-token description. Headroom before the change was
|
||||
// under 10 tokens, so even this smallest possible addition crossed;
|
||||
// other descriptions are at their trimmed floor per the entries above.
|
||||
// * 45.5K → 50K with the payroll gap-closure (8 tools): 3 reads
|
||||
// (gnubok_get_employee, gnubok_get_payslip, gnubok_list_absence) + 5
|
||||
// staged writes (update_payslip_line, register_absence,
|
||||
// create_employee, update_employee, set_employee_opening_balances).
|
||||
// create/update_employee carry the full employee-config inputSchema
|
||||
// (~27 properties each: the whole point is agent-driveable payroll
|
||||
// onboarding), and every staged write inlines STAGED_OPERATION_SCHEMA
|
||||
// + _meta. Property descriptions trimmed to enum-only where the name
|
||||
// is self-evident; the remainder is wire contract, not prose.
|
||||
// * 50K → 51K with the vacation workflow (gap-closure Phase 3):
|
||||
// gnubok_get_vacation_balance (ledger read) + gnubok_close_vacation_year
|
||||
// (staged HIGH semesterårsavslut with STAGED_OPERATION_SCHEMA + _meta).
|
||||
// Fortnox gap category E closed; both schemas already minimal.
|
||||
// Long-term answer to growth is leaning harder on gnubok_search_tools: if this
|
||||
// fires again, prefer trimming descriptions or making a tool opt-in via search
|
||||
// before bumping further.
|
||||
expect(approxTokens).toBeLessThan(45_500)
|
||||
expect(approxTokens).toBeLessThan(51_000)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,261 @@
|
||||
/**
|
||||
* Tests for the payroll MCP read tools (payroll gap-closure 1.6):
|
||||
* gnubok_get_employee, gnubok_get_payslip, gnubok_list_absence.
|
||||
*
|
||||
* PII rule under test: personnummer is ALWAYS masked on the MCP surface
|
||||
* (LLM context is a leak surface); there is no full-value drill-in here,
|
||||
* unlike v1's GET /employees/{id}.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { createQueuedMockSupabase } from '@/tests/helpers'
|
||||
|
||||
import { tools } from '../server'
|
||||
|
||||
const getEmployee = tools.find((t) => t.name === 'gnubok_get_employee')!
|
||||
const getPayslip = tools.find((t) => t.name === 'gnubok_get_payslip')!
|
||||
const listAbsence = tools.find((t) => t.name === 'gnubok_list_absence')!
|
||||
const getVacationBalance = tools.find((t) => t.name === 'gnubok_get_vacation_balance')!
|
||||
|
||||
// Synthetic fixture personnummer (year 1900, zero suffix): must not look
|
||||
// like production-format PII.
|
||||
const SAMPLE_PERSONNUMMER = '190001010000'
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('tool registration', () => {
|
||||
it('all three read tools exist and are read-only', () => {
|
||||
for (const tool of [getEmployee, getPayslip, listAbsence]) {
|
||||
expect(tool).toBeDefined()
|
||||
expect(tool.annotations?.readOnlyHint).toBe(true)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('gnubok_get_employee', () => {
|
||||
const EMPLOYEE_ROW = {
|
||||
id: 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb',
|
||||
first_name: 'Anna',
|
||||
last_name: 'Andersson',
|
||||
personnummer: SAMPLE_PERSONNUMMER,
|
||||
employment_type: 'employee',
|
||||
employment_start: '2024-01-15',
|
||||
employment_end: null,
|
||||
employment_degree: 100,
|
||||
salary_type: 'monthly',
|
||||
monthly_salary: 35000,
|
||||
hourly_rate: null,
|
||||
tax_table_number: 33,
|
||||
tax_column: 1,
|
||||
tax_municipality: 'Stockholm',
|
||||
is_sidoinkomst: false,
|
||||
f_skatt_status: 'a_skatt',
|
||||
f_skatt_verified_at: null,
|
||||
jamkning_percentage: 15,
|
||||
jamkning_valid_from: '2026-01-01',
|
||||
jamkning_valid_to: null,
|
||||
clearing_number: '6000',
|
||||
bank_account_number: '12345678',
|
||||
vacation_rule: 'procentregeln',
|
||||
vacation_days_per_year: 25,
|
||||
vacation_days_saved: 3,
|
||||
semestertillagg_rate: 0.0043,
|
||||
vaxa_stod_eligible: false,
|
||||
vaxa_stod_start: null,
|
||||
vaxa_stod_end: null,
|
||||
default_dimensions: {},
|
||||
is_active: true,
|
||||
}
|
||||
|
||||
it('returns the grouped config with masked personnummer and qualified id', async () => {
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
enqueue({ data: EMPLOYEE_ROW })
|
||||
|
||||
const result = (await getEmployee.execute(
|
||||
{ employee_id: EMPLOYEE_ROW.id }, 'company-1', 'user-1', supabase as never, { type: 'api_key' },
|
||||
)) as Record<string, unknown>
|
||||
|
||||
expect(result.employee_id).toBe(EMPLOYEE_ROW.id)
|
||||
expect(result.personnummer_masked).toBe('19000101-XXXX')
|
||||
expect((result.tax as Record<string, unknown>).jamkning_percentage).toBe(15)
|
||||
expect((result.vacation as Record<string, unknown>).vacation_days_saved).toBe(3)
|
||||
// Raw personnummer never leaks anywhere in the payload, and there is no
|
||||
// bare `id` key (qualified ids only).
|
||||
expect(JSON.stringify(result)).not.toContain(SAMPLE_PERSONNUMMER)
|
||||
expect(result.id).toBeUndefined()
|
||||
})
|
||||
|
||||
it('throws for an unknown employee', async () => {
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
enqueue({ data: null })
|
||||
await expect(
|
||||
getEmployee.execute({ employee_id: 'nope' }, 'company-1', 'user-1', supabase as never, { type: 'api_key' }),
|
||||
).rejects.toThrow(/not found/i)
|
||||
})
|
||||
})
|
||||
|
||||
describe('gnubok_get_payslip', () => {
|
||||
const SRE_ROW = {
|
||||
id: 'dddddddd-dddd-4ddd-8ddd-dddddddddddd',
|
||||
gross_salary: 35000,
|
||||
gross_deductions: 0,
|
||||
benefit_values: 0,
|
||||
taxable_income: 35000,
|
||||
tax_withheld: 8200,
|
||||
tax_withheld_override: null,
|
||||
net_deductions: 0,
|
||||
net_salary: 26800,
|
||||
avgifter_rate: 0.3142,
|
||||
avgifter_basis: 35000,
|
||||
avgifter_amount: 10997,
|
||||
avgifter_basis_override: null,
|
||||
avgifter_amount_override: null,
|
||||
avgifter_category: 'standard',
|
||||
override_reason: null,
|
||||
vacation_accrual: 4200,
|
||||
vacation_accrual_avgifter: 1319.64,
|
||||
ytd_gross: 70000,
|
||||
ytd_tax: 16400,
|
||||
ytd_net: 53600,
|
||||
sick_days: 0,
|
||||
vab_days: 0,
|
||||
parental_days: 0,
|
||||
vacation_days_taken: 0,
|
||||
calculation_breakdown: { steps: [] },
|
||||
employee: { first_name: 'Anna', last_name: 'Andersson', personnummer: SAMPLE_PERSONNUMMER },
|
||||
line_items: [
|
||||
{
|
||||
id: 'eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeee',
|
||||
item_type: 'monthly_salary',
|
||||
description: 'Grundlön',
|
||||
quantity: null,
|
||||
unit_price: null,
|
||||
amount: 35000,
|
||||
is_taxable: true,
|
||||
is_avgift_basis: true,
|
||||
is_vacation_basis: true,
|
||||
is_gross_deduction: false,
|
||||
is_net_deduction: false,
|
||||
account_number: '7210',
|
||||
sort_order: 0,
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
it('returns the payslip with qualified line ids and masked personnummer', async () => {
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
enqueue({ data: SRE_ROW })
|
||||
|
||||
const result = (await getPayslip.execute(
|
||||
{ salary_run_id: 'run-1', employee_id: 'emp-1' },
|
||||
'company-1', 'user-1', supabase as never, { type: 'api_key' },
|
||||
)) as {
|
||||
salary_run_employee_id: string
|
||||
employee_name: string
|
||||
personnummer_masked: string
|
||||
amounts: Record<string, number>
|
||||
line_items: Array<Record<string, unknown>>
|
||||
}
|
||||
|
||||
expect(result.salary_run_employee_id).toBe(SRE_ROW.id)
|
||||
expect(result.employee_name).toBe('Anna Andersson')
|
||||
expect(result.personnummer_masked).toBe('19000101-XXXX')
|
||||
expect(result.amounts.gross_salary).toBe(35000)
|
||||
expect(result.line_items).toHaveLength(1)
|
||||
expect(result.line_items[0].salary_line_item_id).toBe('eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeee')
|
||||
expect(result.line_items[0].id).toBeUndefined()
|
||||
expect(JSON.stringify(result)).not.toContain(SAMPLE_PERSONNUMMER)
|
||||
})
|
||||
|
||||
it('throws when the employee is not in the run', async () => {
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
enqueue({ data: null })
|
||||
await expect(
|
||||
getPayslip.execute(
|
||||
{ salary_run_id: 'run-1', employee_id: 'emp-x' },
|
||||
'company-1', 'user-1', supabase as never, { type: 'api_key' },
|
||||
),
|
||||
).rejects.toThrow(/not found/i)
|
||||
})
|
||||
})
|
||||
|
||||
describe('gnubok_get_vacation_balance', () => {
|
||||
it('returns the open balance with qualified id and remaining days', async () => {
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
enqueue({
|
||||
data: {
|
||||
id: 'vb-1',
|
||||
employee_id: 'emp-1',
|
||||
vacation_year_start: '2026-01-01',
|
||||
entitled_days: 25,
|
||||
accrued_days: 0,
|
||||
taken_days: 10,
|
||||
saved_days: { '2025': 5 },
|
||||
forced_payout_days: 0,
|
||||
},
|
||||
})
|
||||
|
||||
const result = (await getVacationBalance.execute(
|
||||
{ employee_id: 'emp-1' }, 'company-1', 'user-1', supabase as never, { type: 'api_key' },
|
||||
)) as Record<string, unknown>
|
||||
|
||||
expect(result.employee_vacation_balance_id).toBe('vb-1')
|
||||
expect(result.remaining_days).toBe(15)
|
||||
expect(result.saved_days).toEqual({ '2025': 5 })
|
||||
expect(result.id).toBeUndefined()
|
||||
})
|
||||
|
||||
it('throws before the ledger has seeded', async () => {
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
enqueue({ data: null })
|
||||
await expect(
|
||||
getVacationBalance.execute(
|
||||
{ employee_id: 'emp-x' }, 'company-1', 'user-1', supabase as never, { type: 'api_key' },
|
||||
),
|
||||
).rejects.toThrow(/No vacation balance/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('gnubok_list_absence', () => {
|
||||
it('lists absence days with qualified ids', async () => {
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
// Service: employees existence check, then the range query.
|
||||
enqueue({ data: { id: 'emp-1' } })
|
||||
enqueue({
|
||||
data: [
|
||||
{
|
||||
id: 'ffffffff-ffff-4fff-8fff-ffffffffffff',
|
||||
absence_date: '2026-03-03',
|
||||
absence_type: 'sick',
|
||||
hours: 8,
|
||||
notes: null,
|
||||
salary_run_employee_id: null,
|
||||
created_at: '2026-03-03T08:00:00Z',
|
||||
updated_at: '2026-03-03T08:00:00Z',
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
const result = (await listAbsence.execute(
|
||||
{ employee_id: 'emp-1', from: '2026-03-01', to: '2026-03-31' },
|
||||
'company-1', 'user-1', supabase as never, { type: 'api_key' },
|
||||
)) as { absence_days: Array<Record<string, unknown>>; count: number }
|
||||
|
||||
expect(result.count).toBe(1)
|
||||
expect(result.absence_days[0].salary_absence_day_id).toBe('ffffffff-ffff-4fff-8fff-ffffffffffff')
|
||||
expect(result.absence_days[0].absence_type).toBe('sick')
|
||||
expect(result.absence_days[0].id).toBeUndefined()
|
||||
})
|
||||
|
||||
it('throws for an unknown employee', async () => {
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
enqueue({ data: null })
|
||||
await expect(
|
||||
listAbsence.execute(
|
||||
{ employee_id: 'emp-x', from: '2026-03-01', to: '2026-03-31' },
|
||||
'company-1', 'user-1', supabase as never, { type: 'api_key' },
|
||||
),
|
||||
).rejects.toThrow(/not found/i)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,430 @@
|
||||
/**
|
||||
* Staging tests for the payroll write MCP tools (payroll gap-closure 1.7):
|
||||
* gnubok_update_payslip_line + gnubok_register_absence.
|
||||
*
|
||||
* Both STAGE a pending_operation (no direct writes); the executors in
|
||||
* lib/pending-operations/commit.ts are covered separately in
|
||||
* lib/pending-operations/__tests__/payroll-executors.test.ts.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { createQueuedMockSupabase } from '@/tests/helpers'
|
||||
|
||||
const mockPreviewClose = vi.fn()
|
||||
vi.mock('@/lib/salary/semesterberedning', () => ({
|
||||
previewVacationYearClose: (...a: unknown[]) => mockPreviewClose(...a),
|
||||
commitVacationYearClose: vi.fn(),
|
||||
}))
|
||||
|
||||
import { tools } from '../server'
|
||||
import { decryptPersonnummer } from '@/lib/salary/personnummer'
|
||||
|
||||
const updatePayslipLine = tools.find((t) => t.name === 'gnubok_update_payslip_line')!
|
||||
const registerAbsence = tools.find((t) => t.name === 'gnubok_register_absence')!
|
||||
const createEmployee = tools.find((t) => t.name === 'gnubok_create_employee')!
|
||||
const updateEmployee = tools.find((t) => t.name === 'gnubok_update_employee')!
|
||||
const setOpeningBalances = tools.find((t) => t.name === 'gnubok_set_employee_opening_balances')!
|
||||
const closeVacationYear = tools.find((t) => t.name === 'gnubok_close_vacation_year')!
|
||||
|
||||
// Synthetic fixture personnummer (year 1900, zero suffix).
|
||||
const SAMPLE_PERSONNUMMER = '190001010000'
|
||||
|
||||
/** Flexible per-table mock that also captures insert payloads, so tests can
|
||||
* assert what stagePendingOperation persisted to pending_operations. */
|
||||
function makeCapturingSupabase(byTable: Record<string, { data?: unknown; error?: unknown } | Array<{ data?: unknown; error?: unknown }>>) {
|
||||
const queues = new Map<string, Array<{ data?: unknown; error?: unknown }>>()
|
||||
for (const [t, val] of Object.entries(byTable)) {
|
||||
queues.set(t, Array.isArray(val) ? [...val] : [val])
|
||||
}
|
||||
const inserts: Record<string, unknown[]> = {}
|
||||
const buildChain = (table: string): unknown => {
|
||||
const handler: ProxyHandler<object> = {
|
||||
get(_target, prop) {
|
||||
if (prop === 'then') {
|
||||
return (resolve: (v: unknown) => void) => {
|
||||
const q = queues.get(table)
|
||||
const next = q && q.length > 1 ? q.shift()! : (q?.[0] ?? { data: null, error: null })
|
||||
resolve({ count: null, ...next })
|
||||
}
|
||||
}
|
||||
return (...callArgs: unknown[]) => {
|
||||
if (prop === 'insert') {
|
||||
;(inserts[table] ??= []).push(callArgs[0])
|
||||
}
|
||||
return buildChain(table)
|
||||
}
|
||||
},
|
||||
}
|
||||
return new Proxy({}, handler)
|
||||
}
|
||||
return { inserts, from: vi.fn((table: string) => buildChain(table)) }
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('gnubok_update_payslip_line', () => {
|
||||
const LINE_ROW = {
|
||||
id: 'line-1',
|
||||
salary_run_employee_id: 'sre-1',
|
||||
company_id: 'company-1',
|
||||
item_type: 'bonus',
|
||||
description: 'Kvartalsbonus',
|
||||
quantity: null,
|
||||
unit_price: null,
|
||||
amount: 5000,
|
||||
is_taxable: true,
|
||||
is_avgift_basis: true,
|
||||
is_vacation_basis: true,
|
||||
is_gross_deduction: false,
|
||||
is_net_deduction: false,
|
||||
account_number: '7210',
|
||||
sort_order: 0,
|
||||
created_at: '',
|
||||
updated_at: '',
|
||||
salary_run_employee: { salary_run_id: 'run-1' },
|
||||
}
|
||||
|
||||
it('stages with a merged preview and a recalculate next-hint', async () => {
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
enqueue({ data: { id: 'run-1', status: 'draft' } }) // service draft gate
|
||||
enqueue({ data: LINE_ROW }) // service loadLineInRun (dry-run preflight)
|
||||
enqueue({ data: { payment_date: '2026-03-25' } }) // run payment_date for period check
|
||||
enqueue({ data: null }) // resolvePeriodStatusForDate: company_settings
|
||||
enqueue({ data: null }) // resolvePeriodStatusForDate: fiscal_periods
|
||||
enqueue({ data: { id: 'op-1' }, error: null }) // pending_operations insert
|
||||
|
||||
const result = (await updatePayslipLine.execute(
|
||||
{ salary_run_id: 'run-1', salary_line_item_id: 'line-1', amount: 5500 },
|
||||
'company-1', 'user-1', supabase as never, { type: 'agent_chat' },
|
||||
)) as {
|
||||
staged: boolean
|
||||
risk_level: string
|
||||
preview: Record<string, unknown>
|
||||
next?: { tool: string }
|
||||
}
|
||||
|
||||
expect(result.staged).toBe(true)
|
||||
expect(result.risk_level).toBe('medium')
|
||||
expect(result.preview.new_amount).toBe(5500)
|
||||
expect(result.preview.salary_line_item_id).toBe('line-1')
|
||||
expect(result.next?.tool).toBe('gnubok_calculate_salary_run')
|
||||
})
|
||||
|
||||
it('throws when the run has advanced past draft (preflight)', async () => {
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
enqueue({ data: { id: 'run-1', status: 'booked' } })
|
||||
|
||||
await expect(
|
||||
updatePayslipLine.execute(
|
||||
{ salary_run_id: 'run-1', salary_line_item_id: 'line-1', amount: 5500 },
|
||||
'company-1', 'user-1', supabase as never, { type: 'agent_chat' },
|
||||
),
|
||||
).rejects.toThrow(/SALARY_RUN_LINE_NOT_DRAFT/)
|
||||
})
|
||||
|
||||
it('rejects an empty patch', async () => {
|
||||
const { supabase } = createQueuedMockSupabase()
|
||||
await expect(
|
||||
updatePayslipLine.execute(
|
||||
{ salary_run_id: 'run-1', salary_line_item_id: 'line-1' },
|
||||
'company-1', 'user-1', supabase as never, { type: 'agent_chat' },
|
||||
),
|
||||
).rejects.toThrow(/At least one/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('gnubok_register_absence', () => {
|
||||
it('stages with day-count preview and dateForPeriodCheck', async () => {
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
enqueue({ data: { id: 'emp-1' } }) // service assertEmployee (dry-run preflight)
|
||||
enqueue({ data: { first_name: 'Anna', last_name: 'Andersson' } }) // name for title/preview
|
||||
enqueue({ data: null }) // resolvePeriodStatusForDate: company_settings
|
||||
enqueue({ data: null }) // resolvePeriodStatusForDate: fiscal_periods
|
||||
enqueue({ data: { id: 'op-2' }, error: null }) // pending_operations insert
|
||||
|
||||
const result = (await registerAbsence.execute(
|
||||
{ employee_id: 'emp-1', from: '2026-03-02', to: '2026-03-06', absence_type: 'sick' },
|
||||
'company-1', 'user-1', supabase as never, { type: 'agent_chat' },
|
||||
)) as {
|
||||
staged: boolean
|
||||
risk_level: string
|
||||
preview: Record<string, unknown>
|
||||
}
|
||||
|
||||
expect(result.staged).toBe(true)
|
||||
expect(result.risk_level).toBe('medium')
|
||||
// 2026-03-02 (Mon) .. 2026-03-06 (Fri) = 5 weekdays.
|
||||
expect(result.preview.day_count).toBe(5)
|
||||
expect(result.preview.employee_name).toBe('Anna Andersson')
|
||||
expect((result.preview.dates_sample as string[])[0]).toBe('2026-03-02')
|
||||
})
|
||||
|
||||
it('throws for a range beyond the cap', async () => {
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
enqueue({ data: { id: 'emp-1' } })
|
||||
|
||||
await expect(
|
||||
registerAbsence.execute(
|
||||
{ employee_id: 'emp-1', from: '2026-01-01', to: '2026-12-31', absence_type: 'sick' },
|
||||
'company-1', 'user-1', supabase as never, { type: 'agent_chat' },
|
||||
),
|
||||
).rejects.toThrow(/ABSENCE_RANGE_TOO_LARGE/)
|
||||
})
|
||||
|
||||
it('throws for an unknown employee', async () => {
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
enqueue({ data: null })
|
||||
|
||||
await expect(
|
||||
registerAbsence.execute(
|
||||
{ employee_id: 'emp-x', from: '2026-03-02', to: '2026-03-06', absence_type: 'sick' },
|
||||
'company-1', 'user-1', supabase as never, { type: 'agent_chat' },
|
||||
),
|
||||
).rejects.toThrow(/EMPLOYEE_NOT_FOUND/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('gnubok_create_employee', () => {
|
||||
const validArgs = {
|
||||
first_name: 'Anna',
|
||||
last_name: 'Andersson',
|
||||
personnummer: SAMPLE_PERSONNUMMER,
|
||||
employment_start: '2026-01-15',
|
||||
salary_type: 'monthly',
|
||||
monthly_salary: 35000,
|
||||
tax_table_number: 33,
|
||||
tax_municipality: 'Stockholm',
|
||||
}
|
||||
|
||||
it('encrypts personnummer at staging: params never carry the plaintext', async () => {
|
||||
const supabaseMock = makeCapturingSupabase({
|
||||
company_settings: { data: { entity_type: 'ab' } }, // entity-type preflight
|
||||
fiscal_periods: { data: null },
|
||||
pending_operations: { data: { id: 'op-3' }, error: null },
|
||||
})
|
||||
|
||||
const result = (await createEmployee.execute(
|
||||
validArgs, 'company-1', 'user-1', supabaseMock as never, { type: 'agent_chat' },
|
||||
)) as { staged: boolean; risk_level: string; preview: Record<string, unknown> }
|
||||
|
||||
expect(result.staged).toBe(true)
|
||||
expect(result.risk_level).toBe('medium')
|
||||
expect(result.preview.personnummer_masked).toBe('19000101-XXXX')
|
||||
|
||||
// The staged params must carry the ENCRYPTED value + last4, never the
|
||||
// raw personnummer: pending_operations is a persisted table.
|
||||
const inserted = supabaseMock.inserts.pending_operations?.[0] as {
|
||||
params: Record<string, unknown>
|
||||
title: string
|
||||
preview_data: Record<string, unknown>
|
||||
}
|
||||
expect(inserted).toBeDefined()
|
||||
expect(inserted.params.personnummer).toBeUndefined()
|
||||
expect(inserted.params.personnummer_last4).toBe('0000')
|
||||
expect(decryptPersonnummer(inserted.params.personnummer_encrypted as string)).toBe(SAMPLE_PERSONNUMMER)
|
||||
expect(JSON.stringify(inserted.params)).not.toContain(SAMPLE_PERSONNUMMER)
|
||||
expect(JSON.stringify(inserted.preview_data)).not.toContain(SAMPLE_PERSONNUMMER)
|
||||
expect(inserted.title).not.toContain(SAMPLE_PERSONNUMMER)
|
||||
})
|
||||
|
||||
it('rejects invalid input via CreateEmployeeSchema (missing salary)', async () => {
|
||||
const { supabase } = createQueuedMockSupabase()
|
||||
await expect(
|
||||
createEmployee.execute(
|
||||
{ ...validArgs, monthly_salary: undefined },
|
||||
'company-1', 'user-1', supabase as never, { type: 'agent_chat' },
|
||||
),
|
||||
).rejects.toThrow(/Invalid employee/)
|
||||
})
|
||||
|
||||
it('blocks EF owners on payroll (entity-type preflight)', async () => {
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
enqueue({ data: { entity_type: 'ef' } }) // company_settings
|
||||
|
||||
await expect(
|
||||
createEmployee.execute(
|
||||
{ ...validArgs, employment_type: 'company_owner' },
|
||||
'company-1', 'user-1', supabase as never, { type: 'agent_chat' },
|
||||
),
|
||||
).rejects.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
describe('gnubok_update_employee', () => {
|
||||
const EXISTING = {
|
||||
id: 'emp-1',
|
||||
first_name: 'Anna',
|
||||
last_name: 'Andersson',
|
||||
monthly_salary: 35000,
|
||||
clearing_number: '6000',
|
||||
bank_account_number: '12345678',
|
||||
}
|
||||
|
||||
it('stages with a field-level changes preview and bank-change flag', async () => {
|
||||
const supabaseMock = makeCapturingSupabase({
|
||||
employees: { data: EXISTING },
|
||||
fiscal_periods: { data: null },
|
||||
company_settings: { data: null },
|
||||
pending_operations: { data: { id: 'op-4' }, error: null },
|
||||
})
|
||||
|
||||
const result = (await updateEmployee.execute(
|
||||
{ employee_id: 'emp-1', monthly_salary: 38000, bank_account_number: '87654321' },
|
||||
'company-1', 'user-1', supabaseMock as never, { type: 'agent_chat' },
|
||||
)) as { staged: boolean; preview: { changes: Array<{ field: string; from: unknown; to: unknown }>; bank_details_changed: boolean } }
|
||||
|
||||
expect(result.staged).toBe(true)
|
||||
expect(result.preview.bank_details_changed).toBe(true)
|
||||
const salaryChange = result.preview.changes.find((c) => c.field === 'monthly_salary')
|
||||
expect(salaryChange).toEqual({ field: 'monthly_salary', from: 35000, to: 38000 })
|
||||
})
|
||||
|
||||
it('rejects personnummer changes at the tool boundary', async () => {
|
||||
const { supabase } = createQueuedMockSupabase()
|
||||
await expect(
|
||||
updateEmployee.execute(
|
||||
{ employee_id: 'emp-1', personnummer: '190001029999' },
|
||||
'company-1', 'user-1', supabase as never, { type: 'agent_chat' },
|
||||
),
|
||||
).rejects.toThrow(/immutable/)
|
||||
})
|
||||
|
||||
it('throws for an unknown employee', async () => {
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
enqueue({ data: null })
|
||||
await expect(
|
||||
updateEmployee.execute(
|
||||
{ employee_id: 'emp-x', monthly_salary: 38000 },
|
||||
'company-1', 'user-1', supabase as never, { type: 'agent_chat' },
|
||||
),
|
||||
).rejects.toThrow(/not found/i)
|
||||
})
|
||||
})
|
||||
|
||||
describe('gnubok_set_employee_opening_balances', () => {
|
||||
const EMPLOYEE_ID = 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb'
|
||||
const CURRENT_YEAR = new Date().getFullYear()
|
||||
const validItem = {
|
||||
employee_id: EMPLOYEE_ID,
|
||||
cutover_date: `${CURRENT_YEAR}-07-01`,
|
||||
ytd_gross: 210000,
|
||||
ytd_tax: 48000,
|
||||
ytd_net: 162000,
|
||||
vacation_paid_days_remaining: 12.5,
|
||||
vacation_saved_days_by_year: { [`${CURRENT_YEAR - 1}`]: 5 },
|
||||
opening_semester_liability: 42000,
|
||||
opening_semester_liability_avgifter: 13196.4,
|
||||
karens_periods_adjustment: 1,
|
||||
}
|
||||
|
||||
it('stages after preflighting the whole batch (happy path)', async () => {
|
||||
const supabaseMock = makeCapturingSupabase({
|
||||
employees: {
|
||||
data: [{ id: EMPLOYEE_ID, employment_start: '2024-01-15', is_active: true }],
|
||||
},
|
||||
salary_run_employees: { data: [] },
|
||||
pending_operations: { data: { id: 'op-5' }, error: null },
|
||||
})
|
||||
|
||||
const result = (await setOpeningBalances.execute(
|
||||
{ items: [validItem] },
|
||||
'company-1', 'user-1', supabaseMock as never, { type: 'agent_chat' },
|
||||
)) as { staged: boolean; risk_level: string; preview: Record<string, unknown> }
|
||||
|
||||
expect(result.staged).toBe(true)
|
||||
expect(result.risk_level).toBe('medium')
|
||||
expect(result.preview.employee_count).toBe(1)
|
||||
expect(result.preview.total_ytd_gross).toBe(210000)
|
||||
// The write table is never touched at staging.
|
||||
const inserted = supabaseMock.inserts.employee_opening_balances
|
||||
expect(inserted).toBeUndefined()
|
||||
})
|
||||
|
||||
it('throws with the per-item error list when locked', async () => {
|
||||
const supabaseMock = makeCapturingSupabase({
|
||||
employees: {
|
||||
data: [{ id: EMPLOYEE_ID, employment_start: '2024-01-15', is_active: true }],
|
||||
},
|
||||
salary_run_employees: {
|
||||
data: [{ employee_id: EMPLOYEE_ID, salary_run: { id: 'run-1', status: 'booked' } }],
|
||||
},
|
||||
})
|
||||
|
||||
await expect(
|
||||
setOpeningBalances.execute(
|
||||
{ items: [validItem] },
|
||||
'company-1', 'user-1', supabaseMock as never, { type: 'agent_chat' },
|
||||
),
|
||||
).rejects.toThrow(/Locked by booked salary run/)
|
||||
})
|
||||
|
||||
it('rejects invalid items via the shared Zod schema', async () => {
|
||||
const { supabase } = createQueuedMockSupabase()
|
||||
await expect(
|
||||
setOpeningBalances.execute(
|
||||
{ items: [{ ...validItem, cutover_date: `${CURRENT_YEAR}-07-15` }] },
|
||||
'company-1', 'user-1', supabase as never, { type: 'agent_chat' },
|
||||
),
|
||||
).rejects.toThrow(/Invalid opening balances/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('gnubok_close_vacation_year', () => {
|
||||
const SAMPLE_REPORT = {
|
||||
vacation_year_start: '2025-01-01',
|
||||
vacation_year_end: '2025-12-31',
|
||||
next_year_start: '2026-01-01',
|
||||
basis: 'calendar',
|
||||
rows: [
|
||||
{
|
||||
employee_id: 'emp-1',
|
||||
employee_name: 'Anna Andersson',
|
||||
saveable_days: 5,
|
||||
expiring_days: 2,
|
||||
},
|
||||
],
|
||||
sek: {
|
||||
computed_liability: 18690.84,
|
||||
computed_avgifter: 5872.66,
|
||||
booked_2920: 10000,
|
||||
booked_2940: 3142,
|
||||
drift_2920: 8690.84,
|
||||
drift_2940: 2730.66,
|
||||
adjustment_needed: true,
|
||||
},
|
||||
adjustment_date: '2025-12-31',
|
||||
}
|
||||
|
||||
it('stages HIGH risk with the review report in the preview', async () => {
|
||||
mockPreviewClose.mockResolvedValue({ ok: true, data: SAMPLE_REPORT })
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
enqueue({ data: { salary_vacation_year_basis: 'calendar' } }) // basis lookup (year default)
|
||||
enqueue({ data: null }) // resolvePeriodStatusForDate: company_settings
|
||||
enqueue({ data: null }) // resolvePeriodStatusForDate: fiscal_periods
|
||||
enqueue({ data: { id: 'op-6' }, error: null }) // pending_operations insert
|
||||
|
||||
const result = (await closeVacationYear.execute(
|
||||
{}, 'company-1', 'user-1', supabase as never, { type: 'agent_chat' },
|
||||
)) as { staged: boolean; risk_level: string; preview: Record<string, unknown> }
|
||||
|
||||
expect(result.staged).toBe(true)
|
||||
expect(result.risk_level).toBe('high')
|
||||
expect(result.preview.employee_count).toBe(1)
|
||||
expect(result.preview.drift_2920).toBe(8690.84)
|
||||
expect(result.preview.adjustment_needed).toBe(true)
|
||||
})
|
||||
|
||||
it('fails staging when the preview refuses (already closed)', async () => {
|
||||
mockPreviewClose.mockResolvedValue({ ok: false, code: 'VACATION_YEAR_ALREADY_CLOSED' })
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
enqueue({ data: { salary_vacation_year_basis: 'calendar' } })
|
||||
|
||||
await expect(
|
||||
closeVacationYear.execute(
|
||||
{ vacation_year_start: '2025-01-01' },
|
||||
'company-1', 'user-1', supabase as never, { type: 'agent_chat' },
|
||||
),
|
||||
).rejects.toThrow(/VACATION_YEAR_ALREADY_CLOSED/)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,80 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { assertNoPlaintextPersonnummer } from '../staging-pii-guard'
|
||||
|
||||
describe('assertNoPlaintextPersonnummer', () => {
|
||||
it('throws on a top-level plaintext personnummer key in params', () => {
|
||||
expect(() =>
|
||||
assertNoPlaintextPersonnummer(
|
||||
{ first_name: 'Test', personnummer: '198501011234' },
|
||||
'params',
|
||||
),
|
||||
).toThrow(/params contains plaintext PII key "personnummer"/)
|
||||
})
|
||||
|
||||
it('throws when the key is nested inside a patch object (update-style staging)', () => {
|
||||
expect(() =>
|
||||
assertNoPlaintextPersonnummer(
|
||||
{ employee_id: 'e-1', patch: { personnummer: '198501011234' } },
|
||||
'params',
|
||||
),
|
||||
).toThrow(/plaintext PII key "personnummer"/)
|
||||
})
|
||||
|
||||
it('throws when the key hides inside an array of row objects', () => {
|
||||
expect(() =>
|
||||
assertNoPlaintextPersonnummer(
|
||||
{ rows: [{ amount: 100 }, { ssn: '850101-1234' }] },
|
||||
'preview_data',
|
||||
),
|
||||
).toThrow(/preview_data contains plaintext PII key "ssn"/)
|
||||
})
|
||||
|
||||
it('allows the encrypted/masked derivatives that create_employee stages', () => {
|
||||
expect(() =>
|
||||
assertNoPlaintextPersonnummer(
|
||||
{
|
||||
first_name: 'Test',
|
||||
personnummer_encrypted: 'v1:abcdef',
|
||||
personnummer_last4: '1234',
|
||||
personnummer_masked: '850101-****',
|
||||
},
|
||||
'params',
|
||||
),
|
||||
).not.toThrow()
|
||||
})
|
||||
|
||||
it('allows ordinary payloads: UUIDs, dates, aggregates, names', () => {
|
||||
expect(() =>
|
||||
assertNoPlaintextPersonnummer(
|
||||
{
|
||||
employee_id: 'b7f8d1a2-0000-0000-0000-000000000000',
|
||||
from: '2026-07-01',
|
||||
to: '2026-07-05',
|
||||
absence_type: 'sick',
|
||||
hours_per_day: 8,
|
||||
notes: null,
|
||||
},
|
||||
'params',
|
||||
),
|
||||
).not.toThrow()
|
||||
})
|
||||
|
||||
it('fails closed when the payload nests past the scan depth (PII could hide below)', () => {
|
||||
// Before the fail-closed change, the scanner silently accepted anything
|
||||
// past MAX_DEPTH, so this personnummer would have been persisted.
|
||||
const payload = {
|
||||
l1: { l2: { l3: { l4: { l5: { l6: { l7: { personnummer: '198501011234' } } } } } } },
|
||||
}
|
||||
expect(() => assertNoPlaintextPersonnummer(payload, 'params')).toThrow(
|
||||
/cannot be scanned for plaintext PII/,
|
||||
)
|
||||
})
|
||||
|
||||
it('does not value-match: an EF org number equal to a personnummer passes under a business key', () => {
|
||||
// For enskild firma the org number IS the owner's personnummer; the
|
||||
// guard is key-based so legitimate counterparty data stays stageable.
|
||||
expect(() =>
|
||||
assertNoPlaintextPersonnummer({ org_number: '850101-1234' }, 'params'),
|
||||
).not.toThrow()
|
||||
})
|
||||
})
|
||||
@@ -59,6 +59,7 @@ import {
|
||||
} from '@/lib/api/idempotency'
|
||||
import { toToolError, type NextActionHint } from './tool-result'
|
||||
import { findSupplierCandidates } from './supplier-candidates'
|
||||
import { assertNoPlaintextPersonnummer } from './staging-pii-guard'
|
||||
import { generateBalanceSheet } from '@/lib/reports/balance-sheet'
|
||||
import { generateGeneralLedger } from '@/lib/reports/general-ledger'
|
||||
import { decryptPersonnummer, maskPersonnummer } from '@/lib/salary/personnummer'
|
||||
@@ -291,6 +292,12 @@ async function stagePendingOperation(
|
||||
period_status?: PeriodStatusForDate
|
||||
next?: NextActionHint
|
||||
}> {
|
||||
// PII chokepoint (ISO 27001 A.8.11): no staged payload may persist a
|
||||
// plaintext personnummer. Enforced here so every current and future
|
||||
// staging tool inherits the rule, not just the ones that remembered it.
|
||||
assertNoPlaintextPersonnummer(params, 'params')
|
||||
assertNoPlaintextPersonnummer(previewData, 'preview_data')
|
||||
|
||||
const riskLevel = getRiskLevel(operationType)
|
||||
const branding = getBranding().appName.toLowerCase()
|
||||
|
||||
@@ -9041,6 +9048,787 @@ export const tools: McpTool[] = [
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'gnubok_get_employee',
|
||||
title: 'Get Employee',
|
||||
description: 'Get one employee\'s full payroll config: salary, tax table/column, jamkning, F-skatt, vacation rule, vaxa-stod, bank details, dimensions. Personnummer masked. Use after gnubok_list_employees to drill into one employee before payroll work.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
employee_id: { type: 'string', description: 'UUID of the employee' },
|
||||
},
|
||||
required: ['employee_id'],
|
||||
},
|
||||
outputSchema: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
employee_id: { type: 'string' },
|
||||
first_name: { type: 'string' },
|
||||
last_name: { type: 'string' },
|
||||
personnummer_masked: { type: 'string' },
|
||||
employment: { type: 'object', description: 'Type, start/end, degree' },
|
||||
pay: { type: 'object', description: 'Salary type + amounts' },
|
||||
tax: { type: 'object', description: 'Table, column, municipality, jamkning, F-skatt, sidoinkomst' },
|
||||
vacation: { type: 'object', description: 'Rule, days per year, saved days, tillagg rate' },
|
||||
vaxa_stod: { type: 'object', description: 'Eligibility window' },
|
||||
bank: { type: 'object', description: 'Clearing + account (payment routing)' },
|
||||
default_dimensions: { type: 'object' },
|
||||
is_active: { type: 'boolean' },
|
||||
},
|
||||
required: ['employee_id', 'first_name', 'last_name', 'personnummer_masked', 'is_active'],
|
||||
},
|
||||
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
|
||||
async execute(args, companyId, _userId, supabase) {
|
||||
const employeeId = args.employee_id as string
|
||||
if (!employeeId) throw new Error('employee_id is required')
|
||||
const { data: e, error } = await supabase
|
||||
.from('employees')
|
||||
.select(
|
||||
'id, first_name, last_name, personnummer, employment_type, employment_start, employment_end, employment_degree, hours_per_week, workdays_per_week, salary_type, monthly_salary, hourly_rate, tax_table_number, tax_column, tax_municipality, is_sidoinkomst, f_skatt_status, f_skatt_verified_at, jamkning_percentage, jamkning_valid_from, jamkning_valid_to, clearing_number, bank_account_number, vacation_rule, vacation_days_per_year, vacation_days_saved, semestertillagg_rate, vaxa_stod_eligible, vaxa_stod_start, vaxa_stod_end, default_dimensions, is_active',
|
||||
)
|
||||
.eq('id', employeeId)
|
||||
.eq('company_id', companyId)
|
||||
.maybeSingle()
|
||||
if (error) throw new Error(`Database error: ${error.message}`)
|
||||
if (!e) throw new Error('Employee not found')
|
||||
// LLM context is a leak surface: personnummer is ALWAYS masked on MCP,
|
||||
// there is no full-value drill-in on this surface.
|
||||
return {
|
||||
employee_id: e.id,
|
||||
first_name: e.first_name,
|
||||
last_name: e.last_name,
|
||||
personnummer_masked: maskPersonnummer(decryptPersonnummer(e.personnummer as string)),
|
||||
employment: {
|
||||
employment_type: e.employment_type,
|
||||
employment_start: e.employment_start,
|
||||
employment_end: e.employment_end,
|
||||
employment_degree: e.employment_degree,
|
||||
hours_per_week: e.hours_per_week,
|
||||
workdays_per_week: e.workdays_per_week,
|
||||
},
|
||||
pay: {
|
||||
salary_type: e.salary_type,
|
||||
monthly_salary: e.monthly_salary,
|
||||
hourly_rate: e.hourly_rate,
|
||||
},
|
||||
tax: {
|
||||
tax_table_number: e.tax_table_number,
|
||||
tax_column: e.tax_column,
|
||||
tax_municipality: e.tax_municipality,
|
||||
is_sidoinkomst: e.is_sidoinkomst,
|
||||
f_skatt_status: e.f_skatt_status,
|
||||
f_skatt_verified_at: e.f_skatt_verified_at,
|
||||
jamkning_percentage: e.jamkning_percentage,
|
||||
jamkning_valid_from: e.jamkning_valid_from,
|
||||
jamkning_valid_to: e.jamkning_valid_to,
|
||||
},
|
||||
vacation: {
|
||||
vacation_rule: e.vacation_rule,
|
||||
vacation_days_per_year: e.vacation_days_per_year,
|
||||
vacation_days_saved: e.vacation_days_saved,
|
||||
semestertillagg_rate: e.semestertillagg_rate,
|
||||
},
|
||||
vaxa_stod: {
|
||||
eligible: e.vaxa_stod_eligible,
|
||||
start: e.vaxa_stod_start,
|
||||
end: e.vaxa_stod_end,
|
||||
},
|
||||
bank: {
|
||||
clearing_number: e.clearing_number,
|
||||
bank_account_number: e.bank_account_number,
|
||||
},
|
||||
default_dimensions: e.default_dimensions ?? {},
|
||||
is_active: e.is_active,
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'gnubok_get_payslip',
|
||||
title: 'Get Payslip (Lönebesked)',
|
||||
description: 'Get one employee\'s payslip in a salary run: gross, tax, avgifter, net, every line item and the step-by-step calculation breakdown. Personnummer masked. Use after gnubok_get_salary_run to verify how one employee\'s pay was computed.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
salary_run_id: { type: 'string', description: 'UUID of the salary run' },
|
||||
employee_id: { type: 'string', description: 'UUID of the employee' },
|
||||
},
|
||||
required: ['salary_run_id', 'employee_id'],
|
||||
},
|
||||
outputSchema: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
salary_run_employee_id: { type: 'string' },
|
||||
salary_run_id: { type: 'string' },
|
||||
employee_id: { type: 'string' },
|
||||
employee_name: { type: 'string' },
|
||||
personnummer_masked: { type: 'string' },
|
||||
amounts: { type: 'object', description: 'Gross, taxable, tax, net, avgifter, vacation accrual, YTD' },
|
||||
overrides: { type: 'object', description: 'Manual tax/avgifter overrides + reason (effective = override ?? calculated)' },
|
||||
absence_days: { type: 'object', description: 'Sick/vab/parental/vacation day counts' },
|
||||
line_items: { type: 'array', items: { type: 'object' }, description: 'Each with salary_line_item_id' },
|
||||
calculation_breakdown: { type: 'object', description: 'Step-by-step engine breakdown; null until calculated' },
|
||||
},
|
||||
required: ['salary_run_employee_id', 'salary_run_id', 'employee_id', 'employee_name', 'personnummer_masked', 'amounts', 'line_items'],
|
||||
},
|
||||
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
|
||||
async execute(args, companyId, _userId, supabase) {
|
||||
const salaryRunId = args.salary_run_id as string
|
||||
const employeeId = args.employee_id as string
|
||||
if (!salaryRunId || !employeeId) throw new Error('salary_run_id and employee_id are required')
|
||||
const { data: sre, error } = await supabase
|
||||
.from('salary_run_employees')
|
||||
.select(
|
||||
'*, employee:employees(first_name, last_name, personnummer), line_items:salary_line_items(id, item_type, description, quantity, unit_price, amount, is_taxable, is_avgift_basis, is_vacation_basis, is_gross_deduction, is_net_deduction, account_number, sort_order)',
|
||||
)
|
||||
.eq('salary_run_id', salaryRunId)
|
||||
.eq('employee_id', employeeId)
|
||||
.eq('company_id', companyId)
|
||||
.maybeSingle()
|
||||
if (error) throw new Error(`Database error: ${error.message}`)
|
||||
if (!sre) throw new Error('Employee not found in this salary run')
|
||||
const emp = sre.employee as { first_name: string; last_name: string; personnummer: string } | null
|
||||
const lineItems = ((sre.line_items ?? []) as Array<Record<string, unknown>>)
|
||||
.slice()
|
||||
.sort((a, b) => ((a.sort_order as number) ?? 0) - ((b.sort_order as number) ?? 0))
|
||||
.map(({ id, ...rest }) => ({ salary_line_item_id: id, ...rest }))
|
||||
return {
|
||||
salary_run_employee_id: sre.id,
|
||||
salary_run_id: salaryRunId,
|
||||
employee_id: employeeId,
|
||||
employee_name: emp ? `${emp.first_name} ${emp.last_name}` : '',
|
||||
personnummer_masked: emp ? maskPersonnummer(decryptPersonnummer(emp.personnummer)) : '',
|
||||
amounts: {
|
||||
gross_salary: sre.gross_salary,
|
||||
gross_deductions: sre.gross_deductions,
|
||||
benefit_values: sre.benefit_values,
|
||||
taxable_income: sre.taxable_income,
|
||||
tax_withheld: sre.tax_withheld,
|
||||
net_deductions: sre.net_deductions,
|
||||
net_salary: sre.net_salary,
|
||||
avgifter_rate: sre.avgifter_rate,
|
||||
avgifter_basis: sre.avgifter_basis,
|
||||
avgifter_amount: sre.avgifter_amount,
|
||||
avgifter_category: sre.avgifter_category,
|
||||
vacation_accrual: sre.vacation_accrual,
|
||||
vacation_accrual_avgifter: sre.vacation_accrual_avgifter,
|
||||
ytd_gross: sre.ytd_gross,
|
||||
ytd_tax: sre.ytd_tax,
|
||||
ytd_net: sre.ytd_net,
|
||||
},
|
||||
overrides: {
|
||||
tax_withheld_override: sre.tax_withheld_override,
|
||||
avgifter_amount_override: sre.avgifter_amount_override,
|
||||
avgifter_basis_override: sre.avgifter_basis_override,
|
||||
override_reason: sre.override_reason,
|
||||
},
|
||||
absence_days: {
|
||||
sick_days: sre.sick_days,
|
||||
vab_days: sre.vab_days,
|
||||
parental_days: sre.parental_days,
|
||||
vacation_days_taken: sre.vacation_days_taken,
|
||||
},
|
||||
line_items: lineItems,
|
||||
calculation_breakdown: sre.calculation_breakdown ?? null,
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'gnubok_list_absence',
|
||||
title: 'List Absence (Frånvaro)',
|
||||
description: 'List an employee\'s registered absence days (sick, vab, parental, ...) in a date range, max 92 days. These per-day rows drive karensavdrag and sjuklön at calculation time. Use before gnubok_register_absence to see what is already registered.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
employee_id: { type: 'string', description: 'UUID of the employee' },
|
||||
from: { type: 'string', description: 'Range start (YYYY-MM-DD, inclusive)' },
|
||||
to: { type: 'string', description: 'Range end (YYYY-MM-DD, inclusive, max 92 days)' },
|
||||
absence_type: {
|
||||
type: 'string',
|
||||
enum: ['sick', 'vab', 'parental', 'pregnancy', 'care_relative', 'study', 'unpaid_leave', 'other_leave'],
|
||||
description: 'Optional filter',
|
||||
},
|
||||
},
|
||||
required: ['employee_id', 'from', 'to'],
|
||||
},
|
||||
outputSchema: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
absence_days: { type: 'array', items: { type: 'object' }, description: 'Each with salary_absence_day_id' },
|
||||
count: { type: 'number' },
|
||||
},
|
||||
required: ['absence_days', 'count'],
|
||||
},
|
||||
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
|
||||
async execute(args, companyId, _userId, supabase) {
|
||||
const { employee_id, from, to, absence_type } = args as {
|
||||
employee_id: string; from: string; to: string; absence_type?: string
|
||||
}
|
||||
if (!employee_id || !from || !to) throw new Error('employee_id, from and to are required')
|
||||
const { listAbsenceDays } = await import('@/lib/salary/absence')
|
||||
const result = await listAbsenceDays(supabase, {
|
||||
companyId,
|
||||
employeeId: employee_id,
|
||||
from,
|
||||
to,
|
||||
absenceType: absence_type,
|
||||
})
|
||||
if (!result.ok) throw new Error(result.code === 'EMPLOYEE_NOT_FOUND' ? 'Employee not found' : `Failed to list absence: ${result.code}`)
|
||||
const days = result.data.map(({ id, ...rest }) => ({ salary_absence_day_id: id, ...rest }))
|
||||
return { absence_days: days, count: days.length }
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'gnubok_update_payslip_line',
|
||||
title: 'Update Payslip Line',
|
||||
description: 'Stage an edit to one payslip line (amount, description, quantity, unit price) in a DRAFT salary run. Commit via gnubok_approve_pending_operation, then re-run gnubok_calculate_salary_run: line edits never recompute tax by themselves.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
salary_run_id: { type: 'string', description: 'UUID of the salary run (must be draft)' },
|
||||
salary_line_item_id: { type: 'string', description: 'UUID of the payslip line to edit' },
|
||||
amount: { type: 'number', description: 'New amount (SEK)' },
|
||||
description: { type: 'string', description: 'New line description' },
|
||||
quantity: { type: 'number', description: 'New quantity' },
|
||||
unit_price: { type: 'number', description: 'New unit price (SEK)' },
|
||||
},
|
||||
required: ['salary_run_id', 'salary_line_item_id'],
|
||||
},
|
||||
outputSchema: STAGED_OPERATION_SCHEMA,
|
||||
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
|
||||
async execute(args, companyId, userId, supabase, actor) {
|
||||
const { salary_run_id, salary_line_item_id, amount, description, quantity, unit_price } = args as {
|
||||
salary_run_id: string; salary_line_item_id: string
|
||||
amount?: number; description?: string; quantity?: number; unit_price?: number
|
||||
}
|
||||
if (!salary_run_id || !salary_line_item_id) {
|
||||
throw new Error('salary_run_id and salary_line_item_id are required')
|
||||
}
|
||||
const patch: Record<string, unknown> = {}
|
||||
if (amount !== undefined) patch.amount = amount
|
||||
if (description !== undefined) patch.description = description
|
||||
if (quantity !== undefined) patch.quantity = quantity
|
||||
if (unit_price !== undefined) patch.unit_price = unit_price
|
||||
if (Object.keys(patch).length === 0) {
|
||||
throw new Error('At least one of amount, description, quantity, unit_price is required')
|
||||
}
|
||||
|
||||
// Preflight via the shared service in dry-run: verifies draft status and
|
||||
// that the line belongs to this run, and yields the merged row for the
|
||||
// preview. No writes here: the commit path re-runs the service for real.
|
||||
const { updatePayslipLine } = await import('@/lib/salary/payslip-lines')
|
||||
const preflight = await updatePayslipLine(supabase, {
|
||||
companyId,
|
||||
salaryRunId: salary_run_id,
|
||||
lineId: salary_line_item_id,
|
||||
patch: patch as never,
|
||||
dryRun: true,
|
||||
})
|
||||
if (!preflight.ok) {
|
||||
throw new Error(`Cannot update payslip line: ${preflight.code}`)
|
||||
}
|
||||
const merged = preflight.data
|
||||
|
||||
const { data: run } = await supabase
|
||||
.from('salary_runs')
|
||||
.select('payment_date')
|
||||
.eq('id', salary_run_id)
|
||||
.eq('company_id', companyId)
|
||||
.maybeSingle()
|
||||
|
||||
return stagePendingOperation(
|
||||
supabase, companyId, userId, 'update_payslip_line',
|
||||
`Uppdatera lönebeskedsrad: ${merged.description}`,
|
||||
{ salary_run_id, salary_line_item_id, patch },
|
||||
{
|
||||
salary_run_id,
|
||||
salary_line_item_id,
|
||||
item_type: merged.item_type,
|
||||
description: merged.description,
|
||||
new_amount: merged.amount,
|
||||
changes: patch,
|
||||
},
|
||||
actor,
|
||||
{
|
||||
description: 'After approval, recalculate the run so tax and totals reflect the edit.',
|
||||
tool: 'gnubok_calculate_salary_run',
|
||||
},
|
||||
run?.payment_date ? { dateForPeriodCheck: run.payment_date as string } : {},
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'gnubok_register_absence',
|
||||
title: 'Register Absence (Frånvaro)',
|
||||
description: 'Stage absence registration (sick, vab, parental, ...) for an employee over a date range, max 92 days, weekends skipped unless included. Commit via gnubok_approve_pending_operation; recalculate any open salary run afterwards.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
employee_id: { type: 'string', description: 'UUID of the employee' },
|
||||
from: { type: 'string', description: 'Range start (YYYY-MM-DD, inclusive)' },
|
||||
to: { type: 'string', description: 'Range end (YYYY-MM-DD, inclusive; single day = same as from)' },
|
||||
absence_type: {
|
||||
type: 'string',
|
||||
enum: ['sick', 'vab', 'parental', 'pregnancy', 'care_relative', 'study', 'unpaid_leave', 'other_leave'],
|
||||
description: 'Absence type',
|
||||
},
|
||||
hours_per_day: { type: 'number', description: 'Hours per day (default 8; use e.g. 4 for half days)' },
|
||||
notes: { type: 'string', description: 'Optional note' },
|
||||
include_weekends: { type: 'boolean', description: 'Also register Saturday/Sunday (default false)' },
|
||||
},
|
||||
required: ['employee_id', 'from', 'to', 'absence_type'],
|
||||
},
|
||||
outputSchema: STAGED_OPERATION_SCHEMA,
|
||||
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
|
||||
async execute(args, companyId, userId, supabase, actor) {
|
||||
const { employee_id, from, to, absence_type, hours_per_day, notes, include_weekends } = args as {
|
||||
employee_id: string; from: string; to: string; absence_type: string
|
||||
hours_per_day?: number; notes?: string; include_weekends?: boolean
|
||||
}
|
||||
if (!employee_id || !from || !to || !absence_type) {
|
||||
throw new Error('employee_id, from, to and absence_type are required')
|
||||
}
|
||||
if (!/^\d{4}-\d{2}-\d{2}$/.test(from) || !/^\d{4}-\d{2}-\d{2}$/.test(to)) {
|
||||
throw new Error('from and to must be YYYY-MM-DD')
|
||||
}
|
||||
|
||||
// Preflight in dry-run: verifies the employee and expands the range so
|
||||
// the approver sees exactly which days would be written.
|
||||
const { upsertAbsenceRange } = await import('@/lib/salary/absence')
|
||||
const preflight = await upsertAbsenceRange(supabase, {
|
||||
companyId,
|
||||
employeeId: employee_id,
|
||||
from,
|
||||
to,
|
||||
absenceType: absence_type,
|
||||
hoursPerDay: hours_per_day,
|
||||
notes: notes ?? null,
|
||||
includeWeekends: include_weekends,
|
||||
dryRun: true,
|
||||
})
|
||||
if (!preflight.ok) {
|
||||
throw new Error(`Cannot register absence: ${preflight.code}`)
|
||||
}
|
||||
|
||||
const { data: emp } = await supabase
|
||||
.from('employees')
|
||||
.select('first_name, last_name')
|
||||
.eq('id', employee_id)
|
||||
.eq('company_id', companyId)
|
||||
.maybeSingle()
|
||||
const employeeName = emp ? `${emp.first_name} ${emp.last_name}` : employee_id
|
||||
|
||||
return stagePendingOperation(
|
||||
supabase, companyId, userId, 'register_absence',
|
||||
`Registrera frånvaro: ${employeeName}, ${absence_type} ${from}${to !== from ? ` till ${to}` : ''}`,
|
||||
{ employee_id, from, to, absence_type, hours_per_day: hours_per_day ?? 8, notes: notes ?? null, include_weekends: include_weekends ?? false },
|
||||
{
|
||||
employee_id,
|
||||
employee_name: employeeName,
|
||||
absence_type,
|
||||
from,
|
||||
to,
|
||||
day_count: preflight.data.count,
|
||||
hours_per_day: hours_per_day ?? 8,
|
||||
dates_sample: preflight.data.days.slice(0, 10).map((d) => (d as { absence_date: string }).absence_date),
|
||||
},
|
||||
actor,
|
||||
{
|
||||
description: 'If a draft salary run covers this period, recalculate it so sjuklön/karensavdrag lines update.',
|
||||
tool: 'gnubok_calculate_salary_run',
|
||||
},
|
||||
{ dateForPeriodCheck: from },
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'gnubok_create_employee',
|
||||
title: 'Create Employee',
|
||||
description: 'Stage creation of a new employee: salary, tax table, bank details, vacation rule. Personnummer is encrypted at staging and never stored in plaintext. Commit via gnubok_approve_pending_operation; then attach to a salary run.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
first_name: { type: 'string' },
|
||||
last_name: { type: 'string' },
|
||||
personnummer: { type: 'string', description: '12 digits (YYYYMMDDNNNN). Encrypted at staging.' },
|
||||
employment_type: { type: 'string', enum: ['employee', 'company_owner', 'board_member'] },
|
||||
employment_start: { type: 'string' },
|
||||
employment_end: { type: 'string' },
|
||||
employment_degree: { type: 'number', description: '1-100 (default 100)' },
|
||||
hours_per_week: { type: 'number', description: 'Schedule hours/week (default 40; drives hourly divisor)' },
|
||||
workdays_per_week: { type: 'number', description: 'Schedule days/week (default 5; drives daily divisor)' },
|
||||
salary_type: { type: 'string', enum: ['monthly', 'hourly'] },
|
||||
monthly_salary: { type: 'number' },
|
||||
hourly_rate: { type: 'number' },
|
||||
tax_table_number: { type: 'number', description: '29-42; required for A-skatt non-sidoinkomst' },
|
||||
tax_column: { type: 'number' },
|
||||
tax_municipality: { type: 'string' },
|
||||
is_sidoinkomst: { type: 'boolean' },
|
||||
f_skatt_status: { type: 'string', enum: ['a_skatt', 'f_skatt', 'fa_skatt', 'not_verified'] },
|
||||
clearing_number: { type: 'string' },
|
||||
bank_account_number: { type: 'string' },
|
||||
vacation_rule: { type: 'string', enum: ['procentregeln', 'sammaloneregeln', 'semesterersattning', 'none'] },
|
||||
vacation_days_per_year: { type: 'number' },
|
||||
email: { type: 'string' },
|
||||
phone: { type: 'string' },
|
||||
vaxa_stod_eligible: { type: 'boolean' },
|
||||
vaxa_stod_start: { type: 'string' },
|
||||
vaxa_stod_end: { type: 'string' },
|
||||
jamkning_percentage: { type: 'number' },
|
||||
jamkning_valid_from: { type: 'string' },
|
||||
jamkning_valid_to: { type: 'string' },
|
||||
},
|
||||
required: ['first_name', 'last_name', 'personnummer', 'employment_start'],
|
||||
},
|
||||
outputSchema: STAGED_OPERATION_SCHEMA,
|
||||
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
|
||||
async execute(args, companyId, userId, supabase, actor) {
|
||||
const { CreateEmployeeSchema } = await import('@/lib/api/schemas')
|
||||
const parsed = CreateEmployeeSchema.safeParse(args)
|
||||
if (!parsed.success) {
|
||||
const first = parsed.error.issues[0]
|
||||
throw new Error(`Invalid employee: ${first ? `${first.path.join('.')}: ${first.message}` : 'validation failed'}`)
|
||||
}
|
||||
const body = parsed.data
|
||||
|
||||
// Preflight the EF-owner rule so staging fails early with a clean error.
|
||||
const { getCompanyEntityType } = await import('@/lib/company/context')
|
||||
const { isEmploymentTypeAllowedForEntity, EF_OWNER_EMPLOYMENT_ERROR } = await import('@/lib/salary/employment-rules')
|
||||
const entityType = await getCompanyEntityType(supabase, companyId)
|
||||
if (!isEmploymentTypeAllowedForEntity(entityType, body.employment_type)) {
|
||||
throw new Error(EF_OWNER_EMPLOYMENT_ERROR)
|
||||
}
|
||||
|
||||
// PII rule: encrypt AT STAGING TIME. pending_operations.params never
|
||||
// holds the plaintext personnummer; previews and titles carry the
|
||||
// masked form only.
|
||||
const { encryptPersonnummer, extractLast4 } = await import('@/lib/salary/personnummer')
|
||||
const { personnummer, ...fields } = body
|
||||
const params: Record<string, unknown> = {
|
||||
...fields,
|
||||
personnummer_encrypted: encryptPersonnummer(personnummer),
|
||||
personnummer_last4: extractLast4(personnummer),
|
||||
}
|
||||
const masked = maskPersonnummer(personnummer)
|
||||
|
||||
return stagePendingOperation(
|
||||
supabase, companyId, userId, 'create_employee',
|
||||
`Skapa anställd: ${body.first_name} ${body.last_name}`,
|
||||
params,
|
||||
{
|
||||
first_name: body.first_name,
|
||||
last_name: body.last_name,
|
||||
personnummer_masked: masked,
|
||||
employment_type: body.employment_type,
|
||||
employment_start: body.employment_start,
|
||||
salary_type: body.salary_type,
|
||||
monthly_salary: body.monthly_salary ?? null,
|
||||
hourly_rate: body.hourly_rate ?? null,
|
||||
tax_table_number: body.tax_table_number ?? null,
|
||||
bank_details_provided: !!(body.clearing_number && body.bank_account_number),
|
||||
},
|
||||
actor,
|
||||
{
|
||||
description: 'After approval, attach the employee to a salary run.',
|
||||
tool: 'gnubok_create_salary_run',
|
||||
},
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'gnubok_update_employee',
|
||||
title: 'Update Employee',
|
||||
description: 'Stage an update to an employee\'s payroll config: salary, tax, bank details, vacation rule, jamkning, vaxa-stod. Personnummer cannot be changed. Call gnubok_get_employee first to see current values; commit via gnubok_approve_pending_operation.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
employee_id: { type: 'string', description: 'UUID of the employee' },
|
||||
first_name: { type: 'string' },
|
||||
last_name: { type: 'string' },
|
||||
employment_type: { type: 'string', enum: ['employee', 'company_owner', 'board_member'] },
|
||||
employment_start: { type: 'string' },
|
||||
employment_end: { type: 'string' },
|
||||
employment_degree: { type: 'number' },
|
||||
hours_per_week: { type: 'number' },
|
||||
workdays_per_week: { type: 'number' },
|
||||
salary_type: { type: 'string', enum: ['monthly', 'hourly'] },
|
||||
monthly_salary: { type: 'number' },
|
||||
hourly_rate: { type: 'number' },
|
||||
tax_table_number: { type: 'number' },
|
||||
tax_column: { type: 'number' },
|
||||
tax_municipality: { type: 'string' },
|
||||
is_sidoinkomst: { type: 'boolean' },
|
||||
f_skatt_status: { type: 'string', enum: ['a_skatt', 'f_skatt', 'fa_skatt', 'not_verified'] },
|
||||
clearing_number: { type: 'string' },
|
||||
bank_account_number: { type: 'string' },
|
||||
vacation_rule: { type: 'string', enum: ['procentregeln', 'sammaloneregeln', 'semesterersattning', 'none'] },
|
||||
vacation_days_per_year: { type: 'number' },
|
||||
email: { type: 'string' },
|
||||
phone: { type: 'string' },
|
||||
is_active: { type: 'boolean', description: 'false soft-deactivates (BFL retention keeps the row)' },
|
||||
vaxa_stod_eligible: { type: 'boolean' },
|
||||
vaxa_stod_start: { type: 'string' },
|
||||
vaxa_stod_end: { type: 'string' },
|
||||
jamkning_percentage: { type: ['number', 'null'], description: 'null clears the beslut' },
|
||||
jamkning_valid_from: { type: ['string', 'null'] },
|
||||
jamkning_valid_to: { type: ['string', 'null'] },
|
||||
},
|
||||
required: ['employee_id'],
|
||||
},
|
||||
outputSchema: STAGED_OPERATION_SCHEMA,
|
||||
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
|
||||
async execute(args, companyId, userId, supabase, actor) {
|
||||
const { employee_id, ...rest } = args as { employee_id: string } & Record<string, unknown>
|
||||
if (!employee_id) throw new Error('employee_id is required')
|
||||
if ('personnummer' in rest) {
|
||||
throw new Error('personnummer cannot be changed: identity is immutable post-create')
|
||||
}
|
||||
|
||||
const patch: Record<string, unknown> = {}
|
||||
for (const [key, value] of Object.entries(rest)) {
|
||||
if (value !== undefined) patch[key] = value
|
||||
}
|
||||
if (Object.keys(patch).length === 0) {
|
||||
throw new Error('At least one field to update is required')
|
||||
}
|
||||
|
||||
const { data: existing, error } = await supabase
|
||||
.from('employees')
|
||||
.select('*')
|
||||
.eq('id', employee_id)
|
||||
.eq('company_id', companyId)
|
||||
.maybeSingle()
|
||||
if (error) throw new Error(`Database error: ${error.message}`)
|
||||
if (!existing) throw new Error('Employee not found')
|
||||
|
||||
const changes = Object.entries(patch).map(([field, to]) => ({
|
||||
field,
|
||||
from: (existing as Record<string, unknown>)[field] ?? null,
|
||||
to,
|
||||
}))
|
||||
const bankChanged = changes.some((c) => c.field === 'clearing_number' || c.field === 'bank_account_number')
|
||||
|
||||
return stagePendingOperation(
|
||||
supabase, companyId, userId, 'update_employee',
|
||||
`Uppdatera anställd: ${existing.first_name} ${existing.last_name}`,
|
||||
{ employee_id, patch },
|
||||
{
|
||||
employee_id,
|
||||
employee_name: `${existing.first_name} ${existing.last_name}`,
|
||||
changes,
|
||||
// Bank routing changes are the BEC/fraud surface: surface them
|
||||
// prominently so the approver cannot miss a rerouted payment.
|
||||
bank_details_changed: bankChanged,
|
||||
},
|
||||
actor,
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'gnubok_set_employee_opening_balances',
|
||||
title: 'Set Employee Opening Balances (Cutover)',
|
||||
description: 'Stage payroll cutover state for one or more employees migrating mid-year: YTD gross/tax/net, vacation days remaining, sparade dagar by origin year, opening semesterlöneskuld SEK, karens adjustment. Locked once the employee has a booked run.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
items: {
|
||||
type: 'array',
|
||||
minItems: 1,
|
||||
maxItems: 200,
|
||||
items: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
employee_id: { type: 'string', description: 'UUID of the employee' },
|
||||
cutover_date: { type: 'string', description: 'First day of the first Accounted-run month (YYYY-MM-01)' },
|
||||
ytd_gross: { type: 'number' },
|
||||
ytd_tax: { type: 'number' },
|
||||
ytd_net: { type: 'number' },
|
||||
vacation_paid_days_remaining: { type: 'number' },
|
||||
vacation_saved_days_by_year: { type: 'object', description: 'Origin year -> days, e.g. {"2025": 5}' },
|
||||
opening_semester_liability: { type: 'number', description: 'SEK on 2920 (report-only; booked via SIE)' },
|
||||
opening_semester_liability_avgifter: { type: 'number', description: 'SEK on 2940' },
|
||||
karens_periods_adjustment: { type: 'number', description: 'Karens periods last 12 months not imported as absence rows (0-10)' },
|
||||
},
|
||||
required: ['employee_id', 'cutover_date'],
|
||||
},
|
||||
},
|
||||
},
|
||||
required: ['items'],
|
||||
},
|
||||
outputSchema: STAGED_OPERATION_SCHEMA,
|
||||
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false },
|
||||
async execute(args, companyId, userId, supabase, actor) {
|
||||
const { OpeningBalancesBulkSchema } = await import('@/lib/api/schemas')
|
||||
const parsed = OpeningBalancesBulkSchema.safeParse(args)
|
||||
if (!parsed.success) {
|
||||
const first = parsed.error.issues[0]
|
||||
throw new Error(`Invalid opening balances: ${first ? `${first.path.join('.')}: ${first.message}` : 'validation failed'}`)
|
||||
}
|
||||
|
||||
// Preflight via the shared service in dry-run: employee existence,
|
||||
// employment_start ordering, lock state. Fails staging early with the
|
||||
// full per-item error list.
|
||||
const { setOpeningBalancesBulk } = await import('@/lib/salary/opening-balances')
|
||||
const preflight = await setOpeningBalancesBulk(supabase, {
|
||||
companyId,
|
||||
userId,
|
||||
items: parsed.data.items,
|
||||
dryRun: true,
|
||||
})
|
||||
if (!preflight.ok) {
|
||||
const itemSummary = preflight.itemErrors
|
||||
?.map((e) => `${e.employee_id}: ${e.message}`)
|
||||
.join('; ')
|
||||
throw new Error(`Cannot set opening balances: ${itemSummary ?? preflight.code}`)
|
||||
}
|
||||
|
||||
return stagePendingOperation(
|
||||
supabase, companyId, userId, 'set_employee_opening_balances',
|
||||
`Ingående lönesaldon: ${parsed.data.items.length} anställd(a)`,
|
||||
{ items: parsed.data.items },
|
||||
{
|
||||
employee_count: parsed.data.items.length,
|
||||
cutover_dates: [...new Set(parsed.data.items.map((i) => i.cutover_date))],
|
||||
total_ytd_gross: parsed.data.items.reduce((s, i) => s + (i.ytd_gross || 0), 0),
|
||||
total_opening_liability: parsed.data.items.reduce(
|
||||
(s, i) => s + (i.opening_semester_liability || 0),
|
||||
0,
|
||||
),
|
||||
},
|
||||
actor,
|
||||
{
|
||||
description: 'After approval, import pre-cutover absence history if needed, then create the first salary run.',
|
||||
tool: 'gnubok_create_salary_run',
|
||||
},
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'gnubok_get_vacation_balance',
|
||||
title: 'Get Vacation Balance (Semestersaldo)',
|
||||
description: 'Get one employee\'s current vacation balance: entitled/taken/remaining days, sparade dagar per origin year (5-year rule), forced payouts, and an estimated semesterlöneskuld in SEK. Ledger seeds on first booking. Use before gnubok_close_vacation_year.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
employee_id: { type: 'string', description: 'UUID of the employee' },
|
||||
},
|
||||
required: ['employee_id'],
|
||||
},
|
||||
outputSchema: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
employee_vacation_balance_id: { type: 'string' },
|
||||
employee_id: { type: 'string' },
|
||||
vacation_year_start: { type: 'string' },
|
||||
entitled_days: { type: 'number' },
|
||||
accrued_days: { type: 'number' },
|
||||
taken_days: { type: 'number' },
|
||||
remaining_days: { type: 'number' },
|
||||
saved_days: { type: 'object', description: 'Origin year -> days' },
|
||||
forced_payout_days: { type: 'number' },
|
||||
},
|
||||
required: ['employee_vacation_balance_id', 'employee_id', 'vacation_year_start', 'entitled_days', 'taken_days', 'remaining_days'],
|
||||
},
|
||||
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
|
||||
async execute(args, companyId, _userId, supabase) {
|
||||
const employeeId = args.employee_id as string
|
||||
if (!employeeId) throw new Error('employee_id is required')
|
||||
const { data: balance, error } = await supabase
|
||||
.from('employee_vacation_balances')
|
||||
.select('id, employee_id, vacation_year_start, entitled_days, accrued_days, taken_days, saved_days, forced_payout_days')
|
||||
.eq('company_id', companyId)
|
||||
.eq('employee_id', employeeId)
|
||||
.eq('status', 'open')
|
||||
.order('vacation_year_start', { ascending: false })
|
||||
.limit(1)
|
||||
.maybeSingle()
|
||||
if (error) throw new Error(`Database error: ${error.message}`)
|
||||
if (!balance) throw new Error('No vacation balance exists for the employee yet (the ledger seeds on first booking)')
|
||||
const { id, ...rest } = balance as { id: string } & Record<string, unknown>
|
||||
const entitled = (rest.entitled_days as number) ?? 0
|
||||
const taken = (rest.taken_days as number) ?? 0
|
||||
return {
|
||||
employee_vacation_balance_id: id,
|
||||
...rest,
|
||||
remaining_days: roundOre(entitled - taken),
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'gnubok_close_vacation_year',
|
||||
title: 'Close Vacation Year (Semesterårsavslut)',
|
||||
description: 'Stage the vacation year close: rolls balances into the next year (min-20 floor, 5-year expiry to forced payout) and books a 2920/2940 drift adjustment when needed. High risk: review the preview report, then commit via gnubok_approve_pending_operation.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
vacation_year_start: { type: 'string', description: 'YYYY-MM-DD; defaults to the most recently ended vacation year' },
|
||||
book_adjustment: { type: 'boolean', description: 'Book the 2920/2940 drift verifikat (default true)' },
|
||||
},
|
||||
},
|
||||
outputSchema: STAGED_OPERATION_SCHEMA,
|
||||
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: false },
|
||||
async execute(args, companyId, userId, supabase, actor) {
|
||||
const { previewVacationYearClose } = await import('@/lib/salary/semesterberedning')
|
||||
const { getVacationYearBasis } = await import('@/lib/salary/vacation-ledger')
|
||||
const { getClosableYearStart } = await import('@/lib/salary/vacation-year')
|
||||
|
||||
let yearStart = args.vacation_year_start as string | undefined
|
||||
if (!yearStart) {
|
||||
const basis = await getVacationYearBasis(supabase, companyId)
|
||||
yearStart = getClosableYearStart(new Date().toISOString().slice(0, 10), basis)
|
||||
}
|
||||
const bookAdjustment = args.book_adjustment !== false
|
||||
|
||||
// Preflight: the full review report. Staging fails early on
|
||||
// not-ended / already-closed years, and the approver sees the exact
|
||||
// day transitions + SEK drift that will commit.
|
||||
const preview = await previewVacationYearClose(supabase, companyId, yearStart)
|
||||
if (!preview.ok) {
|
||||
throw new Error(`Cannot close vacation year: ${preview.code}`)
|
||||
}
|
||||
const report = preview.data
|
||||
|
||||
return stagePendingOperation(
|
||||
supabase, companyId, userId, 'vacation_year_close',
|
||||
`Semesterårsavslut ${yearStart.slice(0, 4)} (${report.rows.length} anställda)`,
|
||||
{ vacation_year_start: yearStart, book_adjustment: bookAdjustment },
|
||||
{
|
||||
vacation_year_start: yearStart,
|
||||
vacation_year_end: report.vacation_year_end,
|
||||
employee_count: report.rows.length,
|
||||
total_saveable_days: report.rows.reduce((s, r) => s + r.saveable_days, 0),
|
||||
total_expiring_days: report.rows.reduce((s, r) => s + r.expiring_days, 0),
|
||||
computed_liability: report.sek.computed_liability,
|
||||
computed_avgifter: report.sek.computed_avgifter,
|
||||
booked_2920: report.sek.booked_2920,
|
||||
booked_2940: report.sek.booked_2940,
|
||||
drift_2920: report.sek.drift_2920,
|
||||
drift_2940: report.sek.drift_2940,
|
||||
adjustment_needed: report.sek.adjustment_needed,
|
||||
},
|
||||
actor,
|
||||
{
|
||||
description: 'After approval, pay out any forced-payout days as semesterersättning in the next salary run.',
|
||||
tool: 'gnubok_create_salary_run',
|
||||
},
|
||||
{ dateForPeriodCheck: report.adjustment_date },
|
||||
)
|
||||
},
|
||||
},
|
||||
|
||||
// ── Stream 1 Phase 1: Bookkeeping write (high-risk, always staged) ──
|
||||
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
/**
|
||||
* PII chokepoint for staged operations (ISO 27001 A.8.11 / GDPR Art.5(1)(c)).
|
||||
*
|
||||
* pending_operations.params and .preview_data are persisted verbatim and
|
||||
* rendered in approval UIs, so no staging payload may carry a plaintext
|
||||
* personnummer. The one tool that legitimately receives one
|
||||
* (gnubok_create_employee) encrypts at staging time and stores only
|
||||
* `personnummer_encrypted` / `personnummer_last4` / `personnummer_masked`.
|
||||
* This guard runs inside stagePendingOperation, so every current and FUTURE
|
||||
* staging tool inherits the rule: a tool that forgets to encrypt fails loudly
|
||||
* at staging instead of silently persisting PII.
|
||||
*
|
||||
* Detection is key-based, not value-based: for enskild firma the org number
|
||||
* IS the owner's personnummer, so value-pattern matching would false-positive
|
||||
* on legitimate counterparty data. Keys are compared exactly; the derived
|
||||
* forms above are therefore allowed by construction.
|
||||
*/
|
||||
|
||||
const FORBIDDEN_KEYS = new Set(['personnummer', 'pnr', 'social_security_number', 'ssn'])
|
||||
|
||||
/**
|
||||
* Cycle/degenerate-payload stop: staged payloads are shallow JSON. Anything
|
||||
* nesting deeper is rejected (fail closed), never silently accepted: a
|
||||
* payload too deep to scan could hide a forbidden key below the limit.
|
||||
*/
|
||||
const MAX_DEPTH = 6
|
||||
|
||||
/** Sentinel: the payload nests past MAX_DEPTH, so it cannot be fully scanned. */
|
||||
const DEPTH_EXCEEDED = Symbol('depth-exceeded')
|
||||
|
||||
function findForbiddenKey(
|
||||
value: unknown,
|
||||
depth: number,
|
||||
): string | typeof DEPTH_EXCEEDED | null {
|
||||
if (value === null || typeof value !== 'object') return null
|
||||
if (depth > MAX_DEPTH) return DEPTH_EXCEEDED
|
||||
if (Array.isArray(value)) {
|
||||
for (const item of value) {
|
||||
const hit = findForbiddenKey(item, depth + 1)
|
||||
if (hit) return hit
|
||||
}
|
||||
return null
|
||||
}
|
||||
for (const [key, nested] of Object.entries(value as Record<string, unknown>)) {
|
||||
if (FORBIDDEN_KEYS.has(key)) return key
|
||||
const hit = findForbiddenKey(nested, depth + 1)
|
||||
if (hit) return hit
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Throws when `payload` (params or preview_data) carries a plaintext
|
||||
* personnummer-bearing key at any nesting level, or nests too deep to scan
|
||||
* (fail closed). `label` names the offending payload in the error so the
|
||||
* tool author sees exactly what to fix.
|
||||
*/
|
||||
export function assertNoPlaintextPersonnummer(
|
||||
payload: Record<string, unknown>,
|
||||
label: 'params' | 'preview_data',
|
||||
): void {
|
||||
const hit = findForbiddenKey(payload, 0)
|
||||
if (hit === DEPTH_EXCEEDED) {
|
||||
throw new Error(
|
||||
`Staging blocked: ${label} nests deeper than ${MAX_DEPTH} levels and cannot be scanned for plaintext PII. ` +
|
||||
'Flatten the payload; staged payloads are shallow JSON by design.',
|
||||
)
|
||||
}
|
||||
if (hit) {
|
||||
throw new Error(
|
||||
`Staging blocked: ${label} contains plaintext PII key "${hit}". ` +
|
||||
'pending_operations must never persist a plaintext personnummer; ' +
|
||||
'encrypt at staging time (see gnubok_create_employee: personnummer_encrypted + personnummer_last4).',
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -135,7 +135,7 @@ export async function buildAgiUnderlag(
|
||||
|
||||
if (declarationError || !declaration?.xml_content) {
|
||||
throw new Error(
|
||||
'AGI-XML saknas. Generera AGI-filen från lönekörningen först (Lön → AGI → Generera).',
|
||||
'AGI-XML saknas. Generera AGI-filen först: knappen "Lämna in till Skatteverket" på lönekörningen gör det automatiskt, eller klicka "Ladda ner AGI-fil".',
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
+164
-1
@@ -1521,6 +1521,10 @@ export const UpdateSettingsSchema = z.object({
|
||||
.enum(['swedbank', 'seb', 'handelsbanken', 'nordea', 'other'])
|
||||
.nullable()
|
||||
.optional(),
|
||||
// Vacation year basis (payroll gap-closure 3.1): sammanfallande calendar
|
||||
// year (default) or the statutory Apr 1 - Mar 31 split. The settings route
|
||||
// blocks changing this while open vacation-ledger rows exist.
|
||||
salary_vacation_year_basis: z.enum(['calendar', 'statutory_apr_mar']).optional(),
|
||||
}).refine(
|
||||
(data) => {
|
||||
// BFL 3 kap.: Enskild firma must have fiscal year starting January
|
||||
@@ -1959,6 +1963,11 @@ const EmployeeSchemaBase = z.object({
|
||||
employment_start: isoDate,
|
||||
employment_end: isoDate.optional(),
|
||||
employment_degree: z.number().min(1).max(100).default(100),
|
||||
// Arbetsschema-lite: weekly schedule driving the hourly/daily divisors
|
||||
// (legacy 173/21 at the defaults). employment_degree keeps prorating base
|
||||
// salary; these ONLY drive divisors.
|
||||
hours_per_week: z.number().positive().max(80).default(40),
|
||||
workdays_per_week: z.number().min(1).max(7).default(5),
|
||||
salary_type: SalaryTypeSchema.default('monthly'),
|
||||
monthly_salary: z.number().nonnegative().optional(),
|
||||
hourly_rate: z.number().nonnegative().optional(),
|
||||
@@ -1980,6 +1989,14 @@ const EmployeeSchemaBase = z.object({
|
||||
vaxa_stod_eligible: z.boolean().default(false),
|
||||
vaxa_stod_start: isoDate.optional(),
|
||||
vaxa_stod_end: isoDate.optional(),
|
||||
// Jämkning (Skatteverket beslut om ändrad beräkning av skatteavdrag):
|
||||
// overrides the tax-table lookup with a fixed percentage for a bounded
|
||||
// period. Fields have existed on the employees table since the salary
|
||||
// module shipped; this exposes the write path (payroll gap-closure 1.5).
|
||||
// Setting jamkning_percentage to null clears the beslut.
|
||||
jamkning_percentage: z.number().min(0).max(100).nullable().optional(),
|
||||
jamkning_valid_from: isoDate.nullable().optional(),
|
||||
jamkning_valid_to: isoDate.nullable().optional(),
|
||||
// Dimensions PR8: bag applied to the employee's P&L cost lines when a
|
||||
// salary run is booked. {} clears (the UI always sends the field).
|
||||
default_dimensions: DimensionsBagSchema.optional(),
|
||||
@@ -2046,6 +2063,32 @@ export const CreateEmployeeSchema = EmployeeSchemaBase.superRefine((data, ctx) =
|
||||
})
|
||||
}
|
||||
|
||||
// Jämkning: a percentage without a start date is meaningless (the engine
|
||||
// gates on jamkning_valid_from <= payment_date). End date is optional
|
||||
// (beslut often run until year-end implicitly).
|
||||
if (
|
||||
data.jamkning_percentage !== null &&
|
||||
data.jamkning_percentage !== undefined &&
|
||||
!data.jamkning_valid_from
|
||||
) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: 'Jämkningens startdatum måste anges när jämkningsprocent sätts',
|
||||
path: ['jamkning_valid_from'],
|
||||
})
|
||||
}
|
||||
if (
|
||||
data.jamkning_valid_from &&
|
||||
data.jamkning_valid_to &&
|
||||
data.jamkning_valid_to < data.jamkning_valid_from
|
||||
) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: 'Jämkningens slutdatum måste vara efter startdatumet',
|
||||
path: ['jamkning_valid_to'],
|
||||
})
|
||||
}
|
||||
|
||||
// Bank details: validate clearing/kontonummer structure at entry so a typo is
|
||||
// caught here rather than at Bankgirot LB generation. Both empty is allowed.
|
||||
// Update path is validated in the PATCH route (only when the fields actually
|
||||
@@ -2060,7 +2103,28 @@ export const CreateEmployeeSchema = EmployeeSchemaBase.superRefine((data, ctx) =
|
||||
}
|
||||
})
|
||||
|
||||
export const UpdateEmployeeSchema = EmployeeSchemaBase.partial().superRefine((data, ctx) => {
|
||||
// PATCH base: the create-schema defaults are stripped first. Zod 4 applies
|
||||
// .default() even through .partial() (absent key -> default value), which
|
||||
// would (a) make sparse PATCH bodies fail the salary-type refinement below
|
||||
// (salary_type materializes as 'monthly' without monthly_salary present) and
|
||||
// (b) leak default values into routes that spread the parsed body into the
|
||||
// UPDATE (silently resetting e.g. is_sidoinkomst on unrelated edits).
|
||||
const EmployeeSchemaPatchBase = EmployeeSchemaBase.extend({
|
||||
employment_type: EmploymentTypeSchema,
|
||||
employment_degree: z.number().min(1).max(100),
|
||||
hours_per_week: z.number().positive().max(80),
|
||||
workdays_per_week: z.number().min(1).max(7),
|
||||
salary_type: SalaryTypeSchema,
|
||||
tax_column: z.number().int().min(1).max(6),
|
||||
is_sidoinkomst: z.boolean(),
|
||||
f_skatt_status: FSkattStatusSchema,
|
||||
vacation_rule: VacationRuleSchema,
|
||||
vacation_days_per_year: z.number().int().min(25).max(40),
|
||||
semestertillagg_rate: z.number().min(0).max(0.05),
|
||||
vaxa_stod_eligible: z.boolean(),
|
||||
})
|
||||
|
||||
export const UpdateEmployeeSchema = EmployeeSchemaPatchBase.partial().superRefine((data, ctx) => {
|
||||
// Only validate salary when salary_type is being changed in this update
|
||||
if (data.salary_type === 'monthly' && data.monthly_salary !== undefined && data.monthly_salary <= 0) {
|
||||
ctx.addIssue({
|
||||
@@ -2127,6 +2191,23 @@ export const UpdateEmployeeSchema = EmployeeSchemaBase.partial().superRefine((da
|
||||
path: ['vaxa_stod_end'],
|
||||
})
|
||||
}
|
||||
|
||||
// Jämkning: same schema-visibility caveat as växa-stöd above. What the
|
||||
// schema CAN see: a non-null percentage sent WITHOUT any start date in the
|
||||
// same body is only valid if a start date already exists on the row: the
|
||||
// route layer does the merged-state check. Within-body date ordering is
|
||||
// checkable here.
|
||||
if (
|
||||
data.jamkning_valid_from &&
|
||||
data.jamkning_valid_to &&
|
||||
data.jamkning_valid_to < data.jamkning_valid_from
|
||||
) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: 'Jämkningens slutdatum måste vara efter startdatumet',
|
||||
path: ['jamkning_valid_to'],
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
export const EmployeeBenefitTypeSchema = z.enum(['bike', 'car', 'meals', 'housing', 'wellness', 'other'])
|
||||
@@ -2249,6 +2330,88 @@ export const AbsenceRangeQuerySchema = z.object({
|
||||
path: ['from'],
|
||||
})
|
||||
|
||||
// ── Employee opening balances (payroll cutover) ─────────────────────
|
||||
//
|
||||
// Per-employee state a mid-year switcher brings from the previous payroll
|
||||
// system: YTD accumulators, vacation balances (incl. sparade dagar by origin
|
||||
// year per the Semesterlagen 5-year rule), the opening semesterlöneskuld SEK
|
||||
// (feeds vacation-liability report only; the 2920/2940 balance arrived via
|
||||
// SIE), and the högriskskydd karens-count adjustment. See migration
|
||||
// 20260713101000.
|
||||
|
||||
const openingBalancesShape = {
|
||||
cutover_date: isoDate,
|
||||
ytd_gross: z.number().min(0).default(0),
|
||||
ytd_tax: z.number().min(0).default(0),
|
||||
ytd_net: z.number().min(0).default(0),
|
||||
vacation_paid_days_remaining: z.number().min(0).max(40).default(0),
|
||||
vacation_saved_days_by_year: z
|
||||
.record(z.string().regex(/^\d{4}$/, 'Nyckel måste vara ett fyrsiffrigt år'), z.number().min(0).max(40))
|
||||
.default({}),
|
||||
opening_semester_liability: z.number().min(0).default(0),
|
||||
opening_semester_liability_avgifter: z.number().min(0).default(0),
|
||||
karens_periods_adjustment: z.number().int().min(0).max(10).default(0),
|
||||
}
|
||||
|
||||
const openingBalancesRefine = (
|
||||
data: {
|
||||
cutover_date: string
|
||||
ytd_gross: number
|
||||
ytd_tax: number
|
||||
vacation_saved_days_by_year: Record<string, number>
|
||||
},
|
||||
ctx: z.RefinementCtx,
|
||||
) => {
|
||||
if (!data.cutover_date.endsWith('-01')) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: 'cutover_date måste vara den första dagen i en månad',
|
||||
path: ['cutover_date'],
|
||||
})
|
||||
}
|
||||
const cutoverYear = Number(data.cutover_date.slice(0, 4))
|
||||
const currentYear = new Date().getFullYear()
|
||||
if (cutoverYear < currentYear - 1 || cutoverYear > currentYear) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: 'cutover_date måste ligga i innevarande eller föregående år',
|
||||
path: ['cutover_date'],
|
||||
})
|
||||
}
|
||||
if (data.ytd_tax > data.ytd_gross) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: 'ytd_tax kan inte överstiga ytd_gross',
|
||||
path: ['ytd_tax'],
|
||||
})
|
||||
}
|
||||
// Sparade dagar: max 5 years back, never the cutover year itself.
|
||||
for (const yearKey of Object.keys(data.vacation_saved_days_by_year)) {
|
||||
const originYear = Number(yearKey)
|
||||
if (originYear < cutoverYear - 5 || originYear > cutoverYear - 1) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: `Sparade dagar för ${yearKey}: ursprungsåret måste ligga inom 5 år före cutover (${cutoverYear - 5}-${cutoverYear - 1})`,
|
||||
path: ['vacation_saved_days_by_year', yearKey],
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Body for the per-employee PUT (employee id comes from the path). */
|
||||
export const OpeningBalancesFieldsSchema = z
|
||||
.object(openingBalancesShape)
|
||||
.superRefine(openingBalancesRefine)
|
||||
|
||||
/** One item in the bulk PUT (employee id inline). */
|
||||
export const OpeningBalancesItemSchema = z
|
||||
.object({ employee_id: uuid, ...openingBalancesShape })
|
||||
.superRefine(openingBalancesRefine)
|
||||
|
||||
export const OpeningBalancesBulkSchema = z.object({
|
||||
items: z.array(OpeningBalancesItemSchema).min(1).max(200),
|
||||
})
|
||||
|
||||
// ── Worked-hours per-day records (hourly employees) ─────────────────
|
||||
//
|
||||
// Drives base salary calculation for hourly (timanställd) employees:
|
||||
|
||||
+17
-1
@@ -1,13 +1,16 @@
|
||||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
exports[`v1 spec snapshot > matches the recorded endpoint count > endpoint-count 1`] = `107`;
|
||||
exports[`v1 spec snapshot > matches the recorded endpoint count > endpoint-count 1`] = `123`;
|
||||
|
||||
exports[`v1 spec snapshot > matches the recorded endpoint key set > endpoint-keys 1`] = `
|
||||
[
|
||||
"DELETE /api/v1/companies/:companyId/customers/:id",
|
||||
"DELETE /api/v1/companies/:companyId/dimensions/:id/values/:valueId",
|
||||
"DELETE /api/v1/companies/:companyId/employees/:id",
|
||||
"DELETE /api/v1/companies/:companyId/employees/:id/absence",
|
||||
"DELETE /api/v1/companies/:companyId/salary-runs/:id",
|
||||
"DELETE /api/v1/companies/:companyId/salary-runs/:id/employees/:employeeId",
|
||||
"DELETE /api/v1/companies/:companyId/salary-runs/:id/lines/:lineId",
|
||||
"DELETE /api/v1/companies/:companyId/suppliers/:id",
|
||||
"DELETE /api/v1/companies/:companyId/webhooks/:id",
|
||||
"GET /api/v1/companies",
|
||||
@@ -20,6 +23,9 @@ exports[`v1 spec snapshot > matches the recorded endpoint key set > endpoint-key
|
||||
"GET /api/v1/companies/:companyId/documents/:id/download",
|
||||
"GET /api/v1/companies/:companyId/employees",
|
||||
"GET /api/v1/companies/:companyId/employees/:id",
|
||||
"GET /api/v1/companies/:companyId/employees/:id/absence",
|
||||
"GET /api/v1/companies/:companyId/employees/:id/opening-balances",
|
||||
"GET /api/v1/companies/:companyId/employees/:id/vacation-balance",
|
||||
"GET /api/v1/companies/:companyId/fiscal-periods",
|
||||
"GET /api/v1/companies/:companyId/invoices",
|
||||
"GET /api/v1/companies/:companyId/invoices/:id",
|
||||
@@ -43,6 +49,9 @@ exports[`v1 spec snapshot > matches the recorded endpoint key set > endpoint-key
|
||||
"GET /api/v1/companies/:companyId/reports/vat-declaration",
|
||||
"GET /api/v1/companies/:companyId/salary-runs",
|
||||
"GET /api/v1/companies/:companyId/salary-runs/:id",
|
||||
"GET /api/v1/companies/:companyId/salary-runs/:id/employees",
|
||||
"GET /api/v1/companies/:companyId/salary-runs/:id/employees/:employeeId",
|
||||
"GET /api/v1/companies/:companyId/salary-runs/:id/payslips/:employeeId/pdf",
|
||||
"GET /api/v1/companies/:companyId/supplier-invoices",
|
||||
"GET /api/v1/companies/:companyId/supplier-invoices/:id",
|
||||
"GET /api/v1/companies/:companyId/suppliers",
|
||||
@@ -59,6 +68,7 @@ exports[`v1 spec snapshot > matches the recorded endpoint key set > endpoint-key
|
||||
"PATCH /api/v1/companies/:companyId/employees/:id",
|
||||
"PATCH /api/v1/companies/:companyId/invoices/:id",
|
||||
"PATCH /api/v1/companies/:companyId/salary-runs/:id",
|
||||
"PATCH /api/v1/companies/:companyId/salary-runs/:id/lines/:lineId",
|
||||
"PATCH /api/v1/companies/:companyId/supplier-invoices/:id",
|
||||
"PATCH /api/v1/companies/:companyId/suppliers/:id",
|
||||
"PATCH /api/v1/companies/:companyId/webhooks/:id",
|
||||
@@ -92,8 +102,11 @@ exports[`v1 spec snapshot > matches the recorded endpoint key set > endpoint-key
|
||||
"POST /api/v1/companies/:companyId/salary-runs/:id/approve",
|
||||
"POST /api/v1/companies/:companyId/salary-runs/:id/book",
|
||||
"POST /api/v1/companies/:companyId/salary-runs/:id/calculate",
|
||||
"POST /api/v1/companies/:companyId/salary-runs/:id/employees",
|
||||
"POST /api/v1/companies/:companyId/salary-runs/:id/employees/:employeeId/lines",
|
||||
"POST /api/v1/companies/:companyId/salary-runs/:id/generate-agi",
|
||||
"POST /api/v1/companies/:companyId/salary-runs/:id/mark-paid",
|
||||
"POST /api/v1/companies/:companyId/salary/vacation-year-close",
|
||||
"POST /api/v1/companies/:companyId/supplier-invoices",
|
||||
"POST /api/v1/companies/:companyId/supplier-invoices/:id/approve",
|
||||
"POST /api/v1/companies/:companyId/supplier-invoices/:id/credit",
|
||||
@@ -111,6 +124,9 @@ exports[`v1 spec snapshot > matches the recorded endpoint key set > endpoint-key
|
||||
"POST /api/v1/companies/:companyId/webhooks/:id/rotate-secret",
|
||||
"POST /api/v1/companies/:companyId/webhooks/:id/test",
|
||||
"POST /api/v1/webhook-deliveries/:id/retry",
|
||||
"PUT /api/v1/companies/:companyId/employees/:id/absence",
|
||||
"PUT /api/v1/companies/:companyId/employees/:id/opening-balances",
|
||||
"PUT /api/v1/companies/:companyId/employees/opening-balances",
|
||||
]
|
||||
`;
|
||||
|
||||
|
||||
@@ -97,6 +97,26 @@ import '@/app/api/v1/companies/[companyId]/salary-runs/[id]/mark-paid/route'
|
||||
import '@/app/api/v1/companies/[companyId]/salary-runs/[id]/book/route'
|
||||
import '@/app/api/v1/companies/[companyId]/salary-runs/[id]/generate-agi/route'
|
||||
|
||||
// Payroll gap-closure 1.1: per-employee payslip reads (list + detail + PDF).
|
||||
import '@/app/api/v1/companies/[companyId]/salary-runs/[id]/employees/route'
|
||||
import '@/app/api/v1/companies/[companyId]/salary-runs/[id]/employees/[employeeId]/route'
|
||||
import '@/app/api/v1/companies/[companyId]/salary-runs/[id]/payslips/[employeeId]/pdf/route'
|
||||
|
||||
// Payroll gap-closure 1.2: payslip line writes (draft runs only).
|
||||
import '@/app/api/v1/companies/[companyId]/salary-runs/[id]/employees/[employeeId]/lines/route'
|
||||
import '@/app/api/v1/companies/[companyId]/salary-runs/[id]/lines/[lineId]/route'
|
||||
|
||||
// Payroll gap-closure 1.4: absence (frånvaro) range endpoints.
|
||||
import '@/app/api/v1/companies/[companyId]/employees/[id]/absence/route'
|
||||
|
||||
// Payroll gap-closure 2.3: cutover opening balances (single + atomic bulk).
|
||||
import '@/app/api/v1/companies/[companyId]/employees/[id]/opening-balances/route'
|
||||
import '@/app/api/v1/companies/[companyId]/employees/opening-balances/route'
|
||||
|
||||
// Payroll gap-closure 3.4: vacation ledger + year close.
|
||||
import '@/app/api/v1/companies/[companyId]/employees/[id]/vacation-balance/route'
|
||||
import '@/app/api/v1/companies/[companyId]/salary/vacation-year-close/route'
|
||||
|
||||
// Phase 5 PR-3: Reports + import async. All reports wrap existing
|
||||
// lib/reports/* generators. Imports run inline today but record their
|
||||
// progress on the `operations` table for consistent polling-shape. KPI,
|
||||
|
||||
@@ -65,7 +65,11 @@ import { API_V1_VERSION, API_V1_VERSION_HEADER } from './version'
|
||||
|
||||
const IDEMPOTENCY_HEADER = 'Idempotency-Key'
|
||||
const DRY_RUN_HEADER = 'X-Dry-Run'
|
||||
const REQUIRES_IDEMPOTENCY = new Set(['POST', 'PATCH', 'DELETE'])
|
||||
// Every state-changing method. PUT is included even though most v1 writes are
|
||||
// POST/PATCH: the set drives THREE behaviors (test-key dry-run forcing,
|
||||
// idempotency replay, requireIdempotencyKey enforcement), and omitting PUT
|
||||
// would let test keys write through PUT routes for real.
|
||||
const REQUIRES_IDEMPOTENCY = new Set(['POST', 'PUT', 'PATCH', 'DELETE'])
|
||||
|
||||
export interface ApiV1Context {
|
||||
/** Stable id for this HTTP request: appears in logs, error envelope, X-Request-Id. */
|
||||
|
||||
@@ -102,7 +102,9 @@ export function withRouteContext<P extends DynamicParams = { params: Promise<Rec
|
||||
let errLog = log
|
||||
|
||||
try {
|
||||
const authStart = Date.now()
|
||||
const auth = await requireAuth()
|
||||
const authMs = Date.now() - authStart
|
||||
if (auth.error) {
|
||||
log.warn('auth failed', { status: auth.error.status })
|
||||
// Pass through requireAuth's response unchanged for backwards-compat
|
||||
@@ -119,11 +121,13 @@ export function withRouteContext<P extends DynamicParams = { params: Promise<Rec
|
||||
errLog = userLog
|
||||
|
||||
let companyId: string | null = null
|
||||
const companyStart = Date.now()
|
||||
try {
|
||||
companyId = await getActiveCompanyId(supabase, user.id)
|
||||
} catch (err) {
|
||||
userLog.error('failed to resolve active company', err as Error)
|
||||
}
|
||||
const companyMs = Date.now() - companyStart
|
||||
|
||||
if (!companyId) {
|
||||
return errorResponseFromCode('COMPANY_CONTEXT_MISSING', userLog, { requestId })
|
||||
@@ -152,15 +156,30 @@ export function withRouteContext<P extends DynamicParams = { params: Promise<Rec
|
||||
}
|
||||
errLog = ctx.log
|
||||
|
||||
const handlerStart = Date.now()
|
||||
const response = await handler(request, ctx, params)
|
||||
const handlerMs = Date.now() - handlerStart
|
||||
|
||||
if (response instanceof Response && !response.headers.get('X-Request-Id')) {
|
||||
response.headers.set('X-Request-Id', requestId)
|
||||
}
|
||||
// Per-phase breakdown, visible in browser devtools (Timing tab) and in
|
||||
// the op-completed log: separates the wrapper's own overhead (auth
|
||||
// round trip + company resolution) from the handler's real work, so
|
||||
// latency regressions can be attributed without guessing.
|
||||
if (response instanceof Response && !response.headers.get('Server-Timing')) {
|
||||
response.headers.set(
|
||||
'Server-Timing',
|
||||
`auth;dur=${authMs}, company;dur=${companyMs}, handler;dur=${handlerMs}`,
|
||||
)
|
||||
}
|
||||
|
||||
ctx.log.info('op completed', {
|
||||
durationMs: Date.now() - start,
|
||||
status: response.status,
|
||||
authMs,
|
||||
companyMs,
|
||||
handlerMs,
|
||||
})
|
||||
return response
|
||||
} catch (err) {
|
||||
|
||||
@@ -227,6 +227,17 @@ export const TOOL_SCOPE_MAP: Record<string, ApiKeyScope> = {
|
||||
gnubok_create_salary_run: 'payroll:write',
|
||||
gnubok_calculate_salary_run: 'payroll:write',
|
||||
gnubok_generate_agi: 'payroll:write',
|
||||
// Payroll gap-closure: reads + staged writes (1.6-1.8, 2.4)
|
||||
gnubok_get_employee: 'payroll:read',
|
||||
gnubok_get_payslip: 'payroll:read',
|
||||
gnubok_list_absence: 'payroll:read',
|
||||
gnubok_update_payslip_line: 'payroll:write',
|
||||
gnubok_register_absence: 'payroll:write',
|
||||
gnubok_create_employee: 'payroll:write',
|
||||
gnubok_update_employee: 'payroll:write',
|
||||
gnubok_set_employee_opening_balances: 'payroll:write',
|
||||
gnubok_get_vacation_balance: 'payroll:read',
|
||||
gnubok_close_vacation_year: 'payroll:write',
|
||||
// Bookkeeping write (Stream 1 Phase 1): high-risk, always staged
|
||||
gnubok_close_period: 'bookkeeping:write',
|
||||
gnubok_lock_period: 'bookkeeping:write',
|
||||
@@ -283,6 +294,29 @@ export const TOOL_SCOPE_MAP: Record<string, ApiKeyScope> = {
|
||||
gnubok_agi_status: 'compliance:read',
|
||||
gnubok_vat_declaration_submit: 'skatteverket:write',
|
||||
gnubok_agi_submit: 'skatteverket:write',
|
||||
|
||||
// ── Audit retrofit (agent-native audit P0: unmapped = default-allow) ──
|
||||
// These tools shipped without a scope mapping, making them callable by ANY
|
||||
// authenticated key. Mapping them is accept-the-break by decision
|
||||
// (2026-07-13): keys that relied on the default-allow hole lose access
|
||||
// until granted the proper scope. Release-note callout required for the
|
||||
// four WRITES below.
|
||||
gnubok_link_invoice_to_voucher: 'invoices:write',
|
||||
gnubok_undo_sie_import: 'bookkeeping:write',
|
||||
gnubok_post_annual_depreciation: 'bookkeeping:write',
|
||||
gnubok_import_rot_rut_beslut: 'invoices:write',
|
||||
gnubok_list_verifikat_without_documents: 'transactions:read',
|
||||
gnubok_find_voucher_candidates_for_invoice: 'invoices:read',
|
||||
gnubok_propose_dispositioner: 'reports:read',
|
||||
gnubok_propose_accruals: 'reports:read',
|
||||
gnubok_propose_annual_depreciation: 'reports:read',
|
||||
gnubok_preview_arsredovisning: 'reports:read',
|
||||
gnubok_preview_ef_declaration: 'reports:read',
|
||||
// Deliberately UNSCOPED (available to any authenticated key):
|
||||
// gnubok_search_tools, gnubok_list_skills, gnubok_load_skill,
|
||||
// gnubok_feedback. Discovery + static skill bodies + feedback channel
|
||||
// carry no per-company data; keeping them open is what lets an agent
|
||||
// orient itself before its key's scopes are known.
|
||||
}
|
||||
|
||||
export function validateScopes(scopes: unknown): ApiKeyScope[] | null {
|
||||
|
||||
@@ -180,6 +180,30 @@ export const V1_ENDPOINT_SCOPES: Record<string, ApiKeyScope> = {
|
||||
'POST /api/v1/companies/:companyId/salary-runs/:id/mark-paid': 'payroll:write',
|
||||
'POST /api/v1/companies/:companyId/salary-runs/:id/book': 'payroll:write',
|
||||
'POST /api/v1/companies/:companyId/salary-runs/:id/generate-agi': 'payroll:write',
|
||||
// Payroll gap-closure 1.1: per-employee payslip reads. Personnummer is
|
||||
// masked on all payslip-shaped responses (GDPR Art.5(1)(c)); the employee
|
||||
// detail endpoint is the identity drill-in.
|
||||
'GET /api/v1/companies/:companyId/salary-runs/:id/employees': 'payroll:read',
|
||||
'GET /api/v1/companies/:companyId/salary-runs/:id/employees/:employeeId': 'payroll:read',
|
||||
'GET /api/v1/companies/:companyId/salary-runs/:id/payslips/:employeeId/pdf': 'payroll:read',
|
||||
// Payroll gap-closure 1.2: payslip line writes (draft runs only).
|
||||
'POST /api/v1/companies/:companyId/salary-runs/:id/employees/:employeeId/lines': 'payroll:write',
|
||||
'PATCH /api/v1/companies/:companyId/salary-runs/:id/lines/:lineId': 'payroll:write',
|
||||
'DELETE /api/v1/companies/:companyId/salary-runs/:id/lines/:lineId': 'payroll:write',
|
||||
// Payroll gap-closure 1.3: run roster attach/remove (draft runs only).
|
||||
'POST /api/v1/companies/:companyId/salary-runs/:id/employees': 'payroll:write',
|
||||
'DELETE /api/v1/companies/:companyId/salary-runs/:id/employees/:employeeId': 'payroll:write',
|
||||
// Payroll gap-closure 1.4: absence (frånvaro) per-day register via ranges.
|
||||
'GET /api/v1/companies/:companyId/employees/:id/absence': 'payroll:read',
|
||||
'PUT /api/v1/companies/:companyId/employees/:id/absence': 'payroll:write',
|
||||
'DELETE /api/v1/companies/:companyId/employees/:id/absence': 'payroll:write',
|
||||
// Payroll gap-closure 2.3: cutover opening balances (mid-year migration).
|
||||
'GET /api/v1/companies/:companyId/employees/:id/opening-balances': 'payroll:read',
|
||||
'PUT /api/v1/companies/:companyId/employees/:id/opening-balances': 'payroll:write',
|
||||
'PUT /api/v1/companies/:companyId/employees/opening-balances': 'payroll:write',
|
||||
// Payroll gap-closure 3.4: vacation ledger + year close.
|
||||
'GET /api/v1/companies/:companyId/employees/:id/vacation-balance': 'payroll:read',
|
||||
'POST /api/v1/companies/:companyId/salary/vacation-year-close': 'payroll:write',
|
||||
|
||||
// Dimensions (kostnadsställe/projekt): dimensions PR2. Reads ride
|
||||
// reports:read (registry data feeds report filters/pickers); value creation
|
||||
|
||||
@@ -211,6 +211,118 @@ describe('proposePaymentLines', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('proposePaymentLines: öresavrundning (3740)', () => {
|
||||
it('accrual: rounded-up total → bank leg at "Att betala", 3740 credit carries the residual', () => {
|
||||
const lines = proposePaymentLines({
|
||||
invoice: makeInvoiceInput({
|
||||
total: 1234.75,
|
||||
subtotal: 987.8,
|
||||
vat_amount: 246.95,
|
||||
items: [makeItem({ line_total: 987.8, vat_amount: 246.95, unit_price: 987.8 })],
|
||||
}),
|
||||
accountingMethod: 'accrual',
|
||||
entityType: 'enskild_firma',
|
||||
companyOreRounding: true,
|
||||
})
|
||||
|
||||
expect(lines).toHaveLength(3)
|
||||
expect(lines[0]).toMatchObject({ account_number: '1930', debit_amount: '1235' })
|
||||
expect(lines[1]).toMatchObject({ account_number: '1510', credit_amount: '1234.75' })
|
||||
expect(lines[2]).toEqual({
|
||||
account_number: '3740',
|
||||
debit_amount: '',
|
||||
credit_amount: '0.25',
|
||||
line_description: 'Öresavrundning',
|
||||
})
|
||||
})
|
||||
|
||||
it('accrual: rounded-down total → 3740 debit', () => {
|
||||
const lines = proposePaymentLines({
|
||||
invoice: makeInvoiceInput({ total: 1234.25 }),
|
||||
accountingMethod: 'accrual',
|
||||
entityType: 'enskild_firma',
|
||||
companyOreRounding: true,
|
||||
})
|
||||
|
||||
expect(lines).toHaveLength(3)
|
||||
expect(lines[0]).toMatchObject({ account_number: '1930', debit_amount: '1234' })
|
||||
expect(lines[1]).toMatchObject({ account_number: '1510', credit_amount: '1234.25' })
|
||||
expect(lines[2]).toMatchObject({ account_number: '3740', debit_amount: '0.25', credit_amount: '' })
|
||||
})
|
||||
|
||||
it('cash: bank leg is the rounded amount and 3740 balances the exact revenue + VAT credits', () => {
|
||||
const lines = proposePaymentLines({
|
||||
invoice: makeInvoiceInput({
|
||||
total: 1234.75,
|
||||
subtotal: 987.8,
|
||||
vat_amount: 246.95,
|
||||
items: [makeItem({ line_total: 987.8, vat_amount: 246.95, unit_price: 987.8 })],
|
||||
}),
|
||||
accountingMethod: 'cash',
|
||||
entityType: 'enskild_firma',
|
||||
companyOreRounding: true,
|
||||
})
|
||||
|
||||
expect(lines[0]).toMatchObject({ account_number: '1930', debit_amount: '1235' })
|
||||
const last = lines[lines.length - 1]
|
||||
expect(last).toMatchObject({ account_number: '3740', credit_amount: '0.25' })
|
||||
const debit = lines.reduce((s, l) => s + (parseFloat(l.debit_amount) || 0), 0)
|
||||
const credit = lines.reduce((s, l) => s + (parseFloat(l.credit_amount) || 0), 0)
|
||||
expect(Math.round((debit - credit) * 100)).toBe(0)
|
||||
})
|
||||
|
||||
it('company setting off and no invoice override → unchanged 2-line proposal', () => {
|
||||
const lines = proposePaymentLines({
|
||||
invoice: makeInvoiceInput({ total: 1234.75 }),
|
||||
accountingMethod: 'accrual',
|
||||
entityType: 'enskild_firma',
|
||||
companyOreRounding: false,
|
||||
})
|
||||
|
||||
expect(lines).toHaveLength(2)
|
||||
expect(lines[0].debit_amount).toBe('1234.75')
|
||||
})
|
||||
|
||||
it('per-invoice override wins over the company setting', () => {
|
||||
const lines = proposePaymentLines({
|
||||
invoice: { ...makeInvoiceInput({ total: 1234.75 }), ore_rounding: true },
|
||||
accountingMethod: 'accrual',
|
||||
entityType: 'enskild_firma',
|
||||
companyOreRounding: false,
|
||||
})
|
||||
|
||||
expect(lines).toHaveLength(3)
|
||||
expect(lines[2].account_number).toBe('3740')
|
||||
})
|
||||
|
||||
it('whole-krona total → no 3740 line even when rounding is on', () => {
|
||||
const lines = proposePaymentLines({
|
||||
invoice: makeInvoiceInput({ total: 12500 }),
|
||||
accountingMethod: 'accrual',
|
||||
entityType: 'enskild_firma',
|
||||
companyOreRounding: true,
|
||||
})
|
||||
|
||||
expect(lines).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('non-SEK invoice → rounding never applies', () => {
|
||||
const lines = proposePaymentLines({
|
||||
invoice: makeInvoiceInput({
|
||||
total: 1000.4,
|
||||
total_sek: 10004,
|
||||
currency: 'EUR',
|
||||
exchange_rate: 10,
|
||||
}),
|
||||
accountingMethod: 'accrual',
|
||||
entityType: 'enskild_firma',
|
||||
companyOreRounding: true,
|
||||
})
|
||||
|
||||
expect(lines.every((l) => l.account_number !== '3740')).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('proposePaymentLines: dimensions propagation (PR7)', () => {
|
||||
const bag = { '1': 'KS01', '6': 'P001' }
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
import { resolveSekAmount } from './currency-utils'
|
||||
import { getRevenueAccount, getOutputVatAccount } from './invoice-entries'
|
||||
import { getVatTreatmentForRate } from '@/lib/invoices/vat-rules'
|
||||
import { getDisplayTotal } from '@/lib/invoices/rounding'
|
||||
import type { FormLine } from '@/components/bookkeeping/JournalEntryForm'
|
||||
import type { EntityType, InvoiceItem, VatTreatment } from '@/types'
|
||||
|
||||
@@ -23,6 +24,8 @@ export interface ProposePaymentLinesInput {
|
||||
exchange_rate?: number | null
|
||||
vat_treatment: VatTreatment
|
||||
items?: InvoiceItem[]
|
||||
/** Per-invoice öresavrundning override; null = inherit the company setting. */
|
||||
ore_rounding?: boolean | null
|
||||
/**
|
||||
* Dimensions PR7: the invoice's default bag. Stamped on every proposed
|
||||
* line: the payment dialog always submits its (editable) lines, so the
|
||||
@@ -36,6 +39,13 @@ export interface ProposePaymentLinesInput {
|
||||
entityType: EntityType
|
||||
paymentAccount?: string
|
||||
exchangeRateDifference?: number
|
||||
/**
|
||||
* company_settings.ore_rounding. Combined with the per-invoice override via
|
||||
* getDisplayTotal (SEK only, default-on) to decide whether the proposal
|
||||
* expects the customer to pay the rounded "Att betala" from the PDF: then
|
||||
* the bank leg is the rounded amount and 3740 carries the residual.
|
||||
*/
|
||||
companyOreRounding?: boolean
|
||||
}
|
||||
|
||||
function toFormAmount(n: number): string {
|
||||
@@ -73,9 +83,19 @@ export function proposePaymentLines(input: ProposePaymentLinesInput): FormLine[]
|
||||
const paymentAccount = input.paymentAccount || '1930'
|
||||
const desc = invoice.invoice_number ? `Betalning faktura ${invoice.invoice_number}` : 'Betalning faktura'
|
||||
|
||||
// Öresavrundning: when it applies (SEK, enabled, non-integer total) the
|
||||
// customer pays the rounded "Att betala" from the PDF, not the stored öre
|
||||
// total. Propose the bank leg at the rounded amount and let 3740 carry the
|
||||
// residual, so the default booking matches what actually hits the bank.
|
||||
// getDisplayTotal returns delta 0 whenever rounding does not apply.
|
||||
const roundingDelta = getDisplayTotal(
|
||||
{ total: invoice.total, currency: invoice.currency, ore_rounding: invoice.ore_rounding },
|
||||
input.companyOreRounding === undefined ? undefined : { ore_rounding: input.companyOreRounding },
|
||||
).roundingDelta
|
||||
|
||||
const lines = accountingMethod === 'accrual'
|
||||
? proposeAccrualLines(invoice, paymentAccount, desc, exchangeRateDifference)
|
||||
: proposeCashLines(invoice, paymentAccount, desc, entityType)
|
||||
? proposeAccrualLines(invoice, paymentAccount, desc, exchangeRateDifference, roundingDelta)
|
||||
: proposeCashLines(invoice, paymentAccount, desc, entityType, roundingDelta)
|
||||
|
||||
// Dimensions PR7: re-propagate the invoice default onto every proposed leg
|
||||
// (matches createInvoicePaymentJournalEntry/createInvoiceCashEntry).
|
||||
@@ -86,11 +106,26 @@ export function proposePaymentLines(input: ProposePaymentLinesInput): FormLine[]
|
||||
return lines
|
||||
}
|
||||
|
||||
/**
|
||||
* The 3740 (öres- och kronutjämning) residual line. Customer paid over the
|
||||
* stored total (rounded up) → credit (vinst); under (rounded down) → debit
|
||||
* (förlust). Same polarity as buildInvoicePaymentClearingLines.
|
||||
*/
|
||||
function oreRoundingLine(roundingDelta: number): FormLine {
|
||||
return {
|
||||
account_number: '3740',
|
||||
debit_amount: roundingDelta < 0 ? toFormAmount(Math.abs(roundingDelta)) : '',
|
||||
credit_amount: roundingDelta > 0 ? toFormAmount(roundingDelta) : '',
|
||||
line_description: 'Öresavrundning',
|
||||
}
|
||||
}
|
||||
|
||||
function proposeAccrualLines(
|
||||
invoice: ProposePaymentLinesInput['invoice'],
|
||||
paymentAccount: string,
|
||||
desc: string,
|
||||
exchangeRateDifference?: number
|
||||
exchangeRateDifference?: number,
|
||||
roundingDelta = 0
|
||||
): FormLine[] {
|
||||
const bookedSekAmount = resolveSekAmount(
|
||||
invoice.total,
|
||||
@@ -136,7 +171,7 @@ function proposeAccrualLines(
|
||||
const amount = Math.round(bookedSekAmount * 100) / 100
|
||||
lines.push({
|
||||
account_number: paymentAccount,
|
||||
debit_amount: toFormAmount(amount),
|
||||
debit_amount: toFormAmount(amount + roundingDelta),
|
||||
credit_amount: '',
|
||||
line_description: desc,
|
||||
})
|
||||
@@ -146,6 +181,9 @@ function proposeAccrualLines(
|
||||
credit_amount: toFormAmount(amount),
|
||||
line_description: desc,
|
||||
})
|
||||
if (roundingDelta !== 0) {
|
||||
lines.push(oreRoundingLine(roundingDelta))
|
||||
}
|
||||
}
|
||||
|
||||
return lines
|
||||
@@ -155,7 +193,8 @@ function proposeCashLines(
|
||||
invoice: ProposePaymentLinesInput['invoice'],
|
||||
paymentAccount: string,
|
||||
desc: string,
|
||||
entityType: EntityType
|
||||
entityType: EntityType,
|
||||
roundingDelta = 0
|
||||
): FormLine[] {
|
||||
const lines: FormLine[] = []
|
||||
const isForeign = invoice.currency !== 'SEK'
|
||||
@@ -264,12 +303,16 @@ function proposeCashLines(
|
||||
|
||||
lines.push({
|
||||
account_number: paymentAccount,
|
||||
debit_amount: toFormAmount(debitAmount),
|
||||
debit_amount: toFormAmount(debitAmount + roundingDelta),
|
||||
credit_amount: '',
|
||||
line_description: desc,
|
||||
})
|
||||
|
||||
lines.push(...creditLines)
|
||||
|
||||
if (roundingDelta !== 0) {
|
||||
lines.push(oreRoundingLine(roundingDelta))
|
||||
}
|
||||
|
||||
return lines
|
||||
}
|
||||
|
||||
@@ -6,17 +6,22 @@ vi.mock('next/headers', () => ({
|
||||
cookies: vi.fn(async () => ({ set: mockCookieSet })),
|
||||
}))
|
||||
|
||||
import { setActiveCompany, CompanyContextError, getCompanyDisplayName } from '../context'
|
||||
import { setActiveCompany, CompanyContextError, getCompanyDisplayName, getActiveCompanyId } from '../context'
|
||||
|
||||
type CapturedCall = { table: string; method: string; args: unknown[] }
|
||||
|
||||
type TerminalResult = { data?: unknown; error?: unknown }
|
||||
|
||||
/**
|
||||
* Chainable Supabase mock (same approach as actions.test.ts): a chain method
|
||||
* terminates with `results[table][method]` when seeded, otherwise keeps
|
||||
* chaining. setActiveCompany ends both its queries on `.single()`, on
|
||||
* different tables, so seeding `single` per table drives each branch.
|
||||
* A terminal seeded as an ARRAY is consumed in call order, for functions
|
||||
* that query the same table twice (getActiveCompanyId's fallback fetch +
|
||||
* preference validation both end on company_members.maybeSingle()).
|
||||
*/
|
||||
function buildSupabase(results: Record<string, Record<string, { data?: unknown; error?: unknown }>>) {
|
||||
function buildSupabase(results: Record<string, Record<string, TerminalResult | TerminalResult[]>>) {
|
||||
const calls: CapturedCall[] = []
|
||||
|
||||
function makeChain(table: string) {
|
||||
@@ -25,7 +30,8 @@ function buildSupabase(results: Record<string, Record<string, { data?: unknown;
|
||||
for (const m of methods) {
|
||||
chain[m] = (...args: unknown[]) => {
|
||||
calls.push({ table, method: m, args })
|
||||
const terminal = results[table]?.[m]
|
||||
const seeded = results[table]?.[m]
|
||||
const terminal = Array.isArray(seeded) ? seeded.shift() : seeded
|
||||
if (terminal) {
|
||||
return Promise.resolve({ data: terminal.data ?? null, error: terminal.error ?? null })
|
||||
}
|
||||
@@ -110,6 +116,75 @@ describe('setActiveCompany', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('getActiveCompanyId', () => {
|
||||
it('resolves the preferred company with ONE company_members query when it is the first membership', async () => {
|
||||
const { supabase, calls } = buildSupabase({
|
||||
user_preferences: { maybeSingle: { data: { active_company_id: 'company-1' } } },
|
||||
company_members: { maybeSingle: { data: { company_id: 'company-1' } } },
|
||||
})
|
||||
|
||||
const id = await getActiveCompanyId(supabase as never, 'user-1')
|
||||
|
||||
expect(id).toBe('company-1')
|
||||
// The parallel fallback fetch doubles as validation in the common
|
||||
// single-company case: no second, sequential round trip.
|
||||
const memberQueries = calls.filter((c) => c.table === 'company_members' && c.method === 'maybeSingle')
|
||||
expect(memberQueries).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('validates a preference that differs from the first membership', async () => {
|
||||
const { supabase, calls } = buildSupabase({
|
||||
user_preferences: { maybeSingle: { data: { active_company_id: 'company-2' } } },
|
||||
company_members: {
|
||||
maybeSingle: [
|
||||
{ data: { company_id: 'company-1' } }, // first membership (parallel fetch)
|
||||
{ data: { company_id: 'company-2' } }, // validation of the preference
|
||||
],
|
||||
},
|
||||
})
|
||||
|
||||
const id = await getActiveCompanyId(supabase as never, 'user-1')
|
||||
|
||||
expect(id).toBe('company-2')
|
||||
const memberQueries = calls.filter((c) => c.table === 'company_members' && c.method === 'maybeSingle')
|
||||
expect(memberQueries).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('falls back to the first membership when the preference is stale', async () => {
|
||||
const { supabase } = buildSupabase({
|
||||
user_preferences: { maybeSingle: { data: { active_company_id: 'company-archived' } } },
|
||||
company_members: {
|
||||
maybeSingle: [
|
||||
{ data: { company_id: 'company-1' } }, // first membership
|
||||
{ data: null }, // validation: preference archived / membership gone
|
||||
],
|
||||
},
|
||||
})
|
||||
|
||||
expect(await getActiveCompanyId(supabase as never, 'user-1')).toBe('company-1')
|
||||
})
|
||||
|
||||
it('falls back to the first membership when there is no preference row', async () => {
|
||||
const { supabase, calls } = buildSupabase({
|
||||
user_preferences: { maybeSingle: { data: null } },
|
||||
company_members: { maybeSingle: { data: { company_id: 'company-1' } } },
|
||||
})
|
||||
|
||||
expect(await getActiveCompanyId(supabase as never, 'user-1')).toBe('company-1')
|
||||
const memberQueries = calls.filter((c) => c.table === 'company_members' && c.method === 'maybeSingle')
|
||||
expect(memberQueries).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('returns null when the user has no non-archived memberships', async () => {
|
||||
const { supabase } = buildSupabase({
|
||||
user_preferences: { maybeSingle: { data: null } },
|
||||
company_members: { maybeSingle: { data: null } },
|
||||
})
|
||||
|
||||
expect(await getActiveCompanyId(supabase as never, 'user-1')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('getCompanyDisplayName', () => {
|
||||
it('returns company_settings.company_name and never reads companies when set', async () => {
|
||||
const { supabase, calls } = buildSupabase({
|
||||
|
||||
+31
-18
@@ -37,16 +37,38 @@ export async function getActiveCompanyId(
|
||||
supabase: SupabaseClient,
|
||||
userId: string
|
||||
): Promise<string | null> {
|
||||
// 1. user_preferences: authoritative
|
||||
const { data: prefs } = await supabase
|
||||
.from('user_preferences')
|
||||
.select('active_company_id')
|
||||
.eq('user_id', userId)
|
||||
.maybeSingle()
|
||||
// user_preferences (authoritative) + first membership, fetched in parallel:
|
||||
// the fallback query result doubles as validation when the preferred
|
||||
// company happens to be the first membership, which is the common
|
||||
// single-company case. Most requests pay one round trip instead of two
|
||||
// sequential ones. This runs on every withRouteContext API request and
|
||||
// every dashboard layout render, so the sequential version was pure
|
||||
// wall-clock cost. Mirrors resolveCompanyForMiddleware, minus the
|
||||
// write-back (read paths shouldn't write).
|
||||
const [{ data: prefs }, { data: firstCompany }] = await Promise.all([
|
||||
supabase
|
||||
.from('user_preferences')
|
||||
.select('active_company_id')
|
||||
.eq('user_id', userId)
|
||||
.maybeSingle(),
|
||||
supabase
|
||||
.from('company_members')
|
||||
.select('company_id, companies!inner(archived_at)')
|
||||
.eq('user_id', userId)
|
||||
.is('companies.archived_at', null)
|
||||
.order('created_at', { ascending: true })
|
||||
.limit(1)
|
||||
.maybeSingle(),
|
||||
])
|
||||
|
||||
if (prefs?.active_company_id) {
|
||||
// Validate the preference still points to a non-archived company the
|
||||
// user is a member of.
|
||||
if (firstCompany && prefs.active_company_id === firstCompany.company_id) {
|
||||
return firstCompany.company_id
|
||||
}
|
||||
|
||||
// Preference points at a different company than the first membership:
|
||||
// validate it still resolves to a non-archived company the user is a
|
||||
// member of before trusting it.
|
||||
const { data: membership } = await supabase
|
||||
.from('company_members')
|
||||
.select('company_id, companies!inner(archived_at)')
|
||||
@@ -58,16 +80,7 @@ export async function getActiveCompanyId(
|
||||
if (membership) return membership.company_id
|
||||
}
|
||||
|
||||
// 2. Fallback: first non-archived membership by created_at
|
||||
const { data: firstCompany } = await supabase
|
||||
.from('company_members')
|
||||
.select('company_id, companies!inner(archived_at)')
|
||||
.eq('user_id', userId)
|
||||
.is('companies.archived_at', null)
|
||||
.order('created_at', { ascending: true })
|
||||
.limit(1)
|
||||
.maybeSingle()
|
||||
|
||||
// Fallback: first non-archived membership by created_at (already fetched)
|
||||
return firstCompany?.company_id ?? null
|
||||
}
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ export const COOKBOOK_PAYROLL_AGI_MD = `# Cookbook: run payroll and generate the
|
||||
|
||||
> Drive a Swedish salary run from draft to booked, then generate the arbetsgivardeklaration 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
|
||||
|
||||
@@ -12,7 +12,7 @@ This is the operational companion to the [Salary-runs reference](/docs/api/refer
|
||||
|
||||
## 1. Create a salary run (draft)
|
||||
|
||||
\`POST /salary-runs\` opens an **empty** run in \`draft\` status: it takes only the period + payment metadata (\`period_year\`, \`period_month\`, \`payment_date\`, optional \`voucher_series\` and \`notes\`). Employees are attached in a separate step (via the dashboard, or the forthcoming \`/salary-runs/{id}/employees\` surface) before you \`:calculate\`.
|
||||
\`POST /salary-runs\` opens an **empty** run in \`draft\` status: it takes only the period + payment metadata (\`period_year\`, \`period_month\`, \`payment_date\`, optional \`voucher_series\` and \`notes\`). Attach each employee with \`POST /salary-runs/{id}/employees\` (body \`{ "employee_id": "..." }\`, plus \`hours_worked\` for hourly staff) before you \`:calculate\`. The attach snapshots the employee's pay config onto the run and seeds the base salary line; one-off components (bonus, deduction, reimbursement) go through \`POST /salary-runs/{id}/employees/{employeeId}/lines\` while the run is still a draft. Line edits never recompute tax: always \`:calculate\` afterwards.
|
||||
|
||||
\`\`\`bash
|
||||
curl "https://app.gnubok.se/api/v1/companies/$COMPANY_ID/salary-runs" \\
|
||||
|
||||
@@ -1792,6 +1792,66 @@ const SALARY: Record<string, StructuredErrorEntry> = {
|
||||
message_sv: 'Inga aktiva anställda finns i företaget.',
|
||||
message_en: 'No active employees in the company.',
|
||||
},
|
||||
SALARY_RUN_LINE_NOT_DRAFT: {
|
||||
httpStatus: 400,
|
||||
message_sv: 'Lönebeskedets rader kan bara redigeras medan lönekörningen är ett utkast.',
|
||||
message_en: 'Payslip lines can only be edited while the salary run is a draft.',
|
||||
},
|
||||
SALARY_RUN_EMPLOYEE_NOT_FOUND: {
|
||||
httpStatus: 404,
|
||||
message_sv: 'Anställd finns inte i denna lönekörning.',
|
||||
message_en: 'Employee is not part of this salary run.',
|
||||
},
|
||||
SALARY_LINE_NOT_FOUND: {
|
||||
httpStatus: 404,
|
||||
message_sv: 'Lönebeskedsraden kunde inte hittas.',
|
||||
message_en: 'Payslip line not found.',
|
||||
},
|
||||
SALARY_RUN_EMPLOYEE_DUPLICATE: {
|
||||
httpStatus: 409,
|
||||
message_sv: 'Den anställda finns redan i lönekörningen.',
|
||||
message_en: 'Employee is already part of this salary run.',
|
||||
},
|
||||
SALARY_RUN_EMPLOYEES_NOT_DRAFT: {
|
||||
httpStatus: 400,
|
||||
message_sv: 'Anställda kan bara läggas till eller tas bort medan lönekörningen är ett utkast.',
|
||||
message_en: 'Employees can only be added or removed while the salary run is a draft.',
|
||||
},
|
||||
ABSENCE_RANGE_TOO_LARGE: {
|
||||
httpStatus: 400,
|
||||
message_sv: 'Frånvarointervallet är för stort. Max 92 dagar per anrop.',
|
||||
message_en: 'Absence range too large. Maximum 92 days per request.',
|
||||
},
|
||||
ABSENCE_HOURS_CONFLICT: {
|
||||
httpStatus: 409,
|
||||
message_sv: 'Total frånvarotid för dagen överstiger 24 timmar.',
|
||||
message_en: 'Total absence hours for the day exceed 24 hours.',
|
||||
},
|
||||
OPENING_BALANCES_LOCKED: {
|
||||
httpStatus: 409,
|
||||
message_sv: 'Ingående saldon är låsta: den anställda har en bokförd lönekörning.',
|
||||
message_en: 'Opening balances are locked: the employee has a booked salary run.',
|
||||
},
|
||||
VACATION_YEAR_NOT_ENDED: {
|
||||
httpStatus: 400,
|
||||
message_sv: 'Semesteråret kan inte stängas innan det har tagit slut.',
|
||||
message_en: 'The vacation year cannot be closed before it has ended.',
|
||||
},
|
||||
VACATION_YEAR_ALREADY_CLOSED: {
|
||||
httpStatus: 409,
|
||||
message_sv: 'Semesteråret är redan stängt.',
|
||||
message_en: 'The vacation year is already closed.',
|
||||
},
|
||||
VACATION_CLOSE_ADJUSTMENT_FAILED: {
|
||||
httpStatus: 500,
|
||||
message_sv: 'Semestersaldon rullades men justeringsverifikationen kunde inte bokföras. Bokför justeringen manuellt från rapporten.',
|
||||
message_en: 'Vacation balances rolled but the adjustment entry failed to post. Book the adjustment manually from the report.',
|
||||
},
|
||||
VACATION_BALANCE_NOT_FOUND: {
|
||||
httpStatus: 404,
|
||||
message_sv: 'Inget semestersaldo finns för den anställda ännu.',
|
||||
message_en: 'No vacation balance exists for the employee yet.',
|
||||
},
|
||||
SALARY_RUN_TAX_TABLE_MISSING: {
|
||||
httpStatus: 400,
|
||||
message_sv: 'Skattetabellen saknas för perioden. Importera skattetabellen först.',
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
'use client'
|
||||
|
||||
import useSWR from 'swr'
|
||||
import { createClient } from '@/lib/supabase/client'
|
||||
|
||||
export interface WorklistBadges {
|
||||
/** Unbooked bank transactions: same predicate as lib/worklist countUnbookedTransactions. */
|
||||
uncategorized: number
|
||||
/** Agent-staged operations awaiting review: same predicate as countPendingOperations. */
|
||||
pendingOperations: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Client-side nav badge counts. These used to be fetched by the dashboard
|
||||
* layout on the critical path of every server navigation; two head-count
|
||||
* queries nobody needs before first paint. Now they load (and revalidate)
|
||||
* after mount, and SWR dedupes the realtime-triggered refreshes that
|
||||
* previously stampeded during bulk operations.
|
||||
*
|
||||
* The predicates deliberately mirror lib/worklist/categories.ts so the badge
|
||||
* shows the same number as every other "att göra" surface; RLS scopes both
|
||||
* tables, and the explicit company_id filter is defense in depth.
|
||||
*/
|
||||
export function useWorklistBadges(companyId: string | null | undefined) {
|
||||
const { data, mutate } = useSWR<WorklistBadges>(
|
||||
companyId ? ['worklist-badges', companyId] : null,
|
||||
async ([, id]: [string, string]) => {
|
||||
const supabase = createClient()
|
||||
const [tx, ops] = await Promise.all([
|
||||
supabase
|
||||
.from('transactions')
|
||||
.select('id', { count: 'exact', head: true })
|
||||
.eq('company_id', id)
|
||||
.is('is_business', null)
|
||||
.eq('is_ignored', false),
|
||||
supabase
|
||||
.from('pending_operations')
|
||||
.select('id', { count: 'exact', head: true })
|
||||
.eq('company_id', id)
|
||||
.eq('status', 'pending'),
|
||||
])
|
||||
return {
|
||||
uncategorized: tx.error ? 0 : (tx.count ?? 0),
|
||||
pendingOperations: ops.error ? 0 : (ops.count ?? 0),
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
return {
|
||||
uncategorized: data?.uncategorized ?? 0,
|
||||
pendingOperations: data?.pendingOperations ?? 0,
|
||||
// SWR's bound mutate is referentially stable, so consumers can list it in
|
||||
// effect deps without re-subscribing on every render.
|
||||
refresh: mutate,
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import {
|
||||
planInvoicePayment,
|
||||
planInvoicePaymentForLines,
|
||||
PAYMENT_OVERSHOOT_TOLERANCE,
|
||||
} from '@/lib/invoices/apply-invoice-payment'
|
||||
|
||||
@@ -65,4 +66,184 @@ describe('planInvoicePayment', () => {
|
||||
it('overshoot tolerance is half an öre', () => {
|
||||
expect(PAYMENT_OVERSHOOT_TOLERANCE).toBe(0.005)
|
||||
})
|
||||
|
||||
describe('absorbOreRounding', () => {
|
||||
it('settles in full when the customer paid the rounded-up "Att betala"', () => {
|
||||
// Invoice total 1234.75, PDF shows 1235.00 (öresavrundning), customer pays that.
|
||||
const r = planInvoicePayment(
|
||||
{ total: 1234.75, paid_amount: 0, remaining_amount: 1234.75 },
|
||||
1235,
|
||||
{ absorbOreRounding: true },
|
||||
)
|
||||
expect(r.ok).toBe(true)
|
||||
if (r.ok) {
|
||||
expect(r.plan).toEqual({
|
||||
newPaidAmount: 1234.75,
|
||||
newRemaining: 0,
|
||||
isFullyPaid: true,
|
||||
newStatus: 'paid',
|
||||
oreSettled: true,
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
it('settles a sub-krona short payment in full (rounded-down total)', () => {
|
||||
const r = planInvoicePayment(
|
||||
{ total: 1000.4, paid_amount: 0, remaining_amount: 1000.4 },
|
||||
1000,
|
||||
{ absorbOreRounding: true },
|
||||
)
|
||||
expect(r.ok).toBe(true)
|
||||
if (r.ok) {
|
||||
expect(r.plan.newStatus).toBe('paid')
|
||||
expect(r.plan.newPaidAmount).toBe(1000.4)
|
||||
expect(r.plan.newRemaining).toBe(0)
|
||||
expect(r.plan.oreSettled).toBe(true)
|
||||
}
|
||||
})
|
||||
|
||||
it('still rejects an overshoot beyond the öre band', () => {
|
||||
const r = planInvoicePayment(
|
||||
{ total: 1000, paid_amount: 0, remaining_amount: 1000 },
|
||||
1001.25,
|
||||
{ absorbOreRounding: true },
|
||||
)
|
||||
expect(r.ok).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects an overshoot of exactly 1 kr (band boundary must not over-record)', () => {
|
||||
const r = planInvoicePayment(
|
||||
{ total: 1000, paid_amount: 0, remaining_amount: 1000 },
|
||||
1001,
|
||||
{ absorbOreRounding: true },
|
||||
)
|
||||
expect(r.ok).toBe(false)
|
||||
})
|
||||
|
||||
it('a >=1 kr short payment stays a real partial', () => {
|
||||
const r = planInvoicePayment(
|
||||
{ total: 1000, paid_amount: 0, remaining_amount: 1000 },
|
||||
999,
|
||||
{ absorbOreRounding: true },
|
||||
)
|
||||
expect(r.ok).toBe(true)
|
||||
if (r.ok) {
|
||||
expect(r.plan.newStatus).toBe('partially_paid')
|
||||
expect(r.plan.newRemaining).toBe(1)
|
||||
expect(r.plan.oreSettled).toBe(false)
|
||||
}
|
||||
})
|
||||
|
||||
it('without the opt-in the sub-krona overshoot is still rejected', () => {
|
||||
const r = planInvoicePayment(
|
||||
{ total: 1234.75, paid_amount: 0, remaining_amount: 1234.75 },
|
||||
1235,
|
||||
)
|
||||
expect(r.ok).toBe(false)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('planInvoicePaymentForLines', () => {
|
||||
const INV = { total: 1234.75, paid_amount: 0, remaining_amount: 1234.75 }
|
||||
|
||||
it('absorbs when the lines carry the exact residual on 3740', () => {
|
||||
const r = planInvoicePaymentForLines(
|
||||
INV,
|
||||
1235,
|
||||
[
|
||||
{ account_number: '1930', debit_amount: 1235, credit_amount: 0 },
|
||||
{ account_number: '1510', debit_amount: 0, credit_amount: 1234.75 },
|
||||
{ account_number: '3740', debit_amount: 0, credit_amount: 0.25 },
|
||||
],
|
||||
'SEK',
|
||||
)
|
||||
expect(r.ok).toBe(true)
|
||||
if (r.ok) {
|
||||
expect(r.plan.newStatus).toBe('paid')
|
||||
expect(r.plan.newPaidAmount).toBe(1234.75)
|
||||
expect(r.plan.oreSettled).toBe(true)
|
||||
}
|
||||
})
|
||||
|
||||
it('short-payment lines with a 3740 debit residual settle in full', () => {
|
||||
const r = planInvoicePaymentForLines(
|
||||
{ total: 1000.4, paid_amount: 0, remaining_amount: 1000.4 },
|
||||
1000,
|
||||
[
|
||||
{ account_number: '1930', debit_amount: 1000, credit_amount: 0 },
|
||||
{ account_number: '1510', debit_amount: 0, credit_amount: 1000.4 },
|
||||
{ account_number: '3740', debit_amount: 0.4, credit_amount: 0 },
|
||||
],
|
||||
'SEK',
|
||||
)
|
||||
expect(r.ok).toBe(true)
|
||||
if (r.ok) expect(r.plan.newStatus).toBe('paid')
|
||||
})
|
||||
|
||||
it('a sub-krona short WITHOUT a 3740 line stays a real partial (no silent write-off)', () => {
|
||||
// Deliberate partial: user lowered both legs; 1510 is only cleared by the
|
||||
// paid amount, so flipping to paid would orphan the residual on 1510.
|
||||
const r = planInvoicePaymentForLines(
|
||||
INV,
|
||||
1234,
|
||||
[
|
||||
{ account_number: '1930', debit_amount: 1234, credit_amount: 0 },
|
||||
{ account_number: '1510', debit_amount: 0, credit_amount: 1234 },
|
||||
],
|
||||
'SEK',
|
||||
)
|
||||
expect(r.ok).toBe(true)
|
||||
if (r.ok) {
|
||||
expect(r.plan.newStatus).toBe('partially_paid')
|
||||
expect(r.plan.newRemaining).toBe(0.75)
|
||||
expect(r.plan.oreSettled).toBe(false)
|
||||
}
|
||||
})
|
||||
|
||||
it('a sub-krona overshoot WITHOUT a 3740 line is rejected (1510 would over-credit)', () => {
|
||||
const r = planInvoicePaymentForLines(
|
||||
INV,
|
||||
1235.25,
|
||||
[
|
||||
{ account_number: '1930', debit_amount: 1235.25, credit_amount: 0 },
|
||||
{ account_number: '1510', debit_amount: 0, credit_amount: 1235.25 },
|
||||
],
|
||||
'SEK',
|
||||
)
|
||||
expect(r.ok).toBe(false)
|
||||
})
|
||||
|
||||
it('a 3740 amount that does not match the residual falls back to the strict plan', () => {
|
||||
const r = planInvoicePaymentForLines(
|
||||
INV,
|
||||
1235.25, // 0.50 over, but the lines only book 0.25 on 3740
|
||||
[
|
||||
{ account_number: '1930', debit_amount: 1235.25, credit_amount: 0 },
|
||||
{ account_number: '1510', debit_amount: 0, credit_amount: 1235 },
|
||||
{ account_number: '3740', debit_amount: 0, credit_amount: 0.25 },
|
||||
],
|
||||
'SEK',
|
||||
)
|
||||
expect(r.ok).toBe(false)
|
||||
})
|
||||
|
||||
it('never absorbs for non-SEK invoices', () => {
|
||||
const r = planInvoicePaymentForLines(
|
||||
{ total: 100, paid_amount: 0, remaining_amount: 100 },
|
||||
100.25,
|
||||
[
|
||||
{ account_number: '1930', debit_amount: 100.25, credit_amount: 0 },
|
||||
{ account_number: '1510', debit_amount: 0, credit_amount: 100 },
|
||||
{ account_number: '3740', debit_amount: 0, credit_amount: 0.25 },
|
||||
],
|
||||
'EUR',
|
||||
)
|
||||
expect(r.ok).toBe(false)
|
||||
})
|
||||
|
||||
it('without lines it behaves exactly like the strict plan', () => {
|
||||
expect(planInvoicePaymentForLines(INV, 1234.75, undefined, 'SEK').ok).toBe(true)
|
||||
expect(planInvoicePaymentForLines(INV, 1235, undefined, 'SEK').ok).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,9 +1,80 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import type { SupabaseClient } from '@supabase/supabase-js'
|
||||
import {
|
||||
computeNextRunDate,
|
||||
computeInitialRunDate,
|
||||
getStockholmDateHour,
|
||||
executeRecurringSchedule,
|
||||
} from '@/lib/invoices/recurring-schedule-service'
|
||||
import { createQueuedMockSupabase, makeCustomer, makeCompanySettings } from '@/tests/helpers'
|
||||
import { eventBus } from '@/lib/events'
|
||||
|
||||
// ── Mocks for the executeRecurringSchedule auto-send path ─────────────
|
||||
// The pure date-helper tests below don't touch any of these modules.
|
||||
|
||||
const mockRenderToBuffer = vi.fn()
|
||||
vi.mock('@react-pdf/renderer', () => ({
|
||||
renderToBuffer: (...args: unknown[]) => mockRenderToBuffer(...args),
|
||||
}))
|
||||
|
||||
const mockInvoicePDF = vi.fn()
|
||||
vi.mock('@/lib/invoices/pdf-template', () => ({
|
||||
InvoicePDF: (...args: unknown[]) => mockInvoicePDF(...args),
|
||||
}))
|
||||
|
||||
const mockPrepareRender = vi.fn()
|
||||
const mockSwishQr = vi.fn()
|
||||
const mockPaymentLinkQr = vi.fn()
|
||||
vi.mock('@/lib/invoices/pdf-render-helpers', () => ({
|
||||
prepareInvoicePdfRender: (...args: unknown[]) => mockPrepareRender(...args),
|
||||
buildSwishQrDataUrl: (...args: unknown[]) => mockSwishQr(...args),
|
||||
buildPaymentLinkQrDataUrl: (...args: unknown[]) => mockPaymentLinkQr(...args),
|
||||
}))
|
||||
|
||||
const mockApplyPaymentLink = vi.fn()
|
||||
vi.mock('@/lib/extensions/payment-links', () => ({
|
||||
applyPaymentLinkToInvoice: (...args: unknown[]) => mockApplyPaymentLink(...args),
|
||||
}))
|
||||
|
||||
const mockSendEmail = vi.fn()
|
||||
const mockIsConfigured = vi.fn()
|
||||
vi.mock('@/lib/email/service', () => ({
|
||||
getEmailService: () => ({
|
||||
sendEmail: (...args: unknown[]) => mockSendEmail(...args),
|
||||
isConfigured: () => mockIsConfigured(),
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/email/invoice-templates', () => ({
|
||||
generateInvoiceEmailHtml: vi.fn().mockReturnValue('<html>Invoice</html>'),
|
||||
generateInvoiceEmailText: vi.fn().mockReturnValue('Invoice text'),
|
||||
generateInvoiceEmailSubject: vi.fn().mockReturnValue('Faktura F-1'),
|
||||
}))
|
||||
|
||||
const mockIsSandbox = vi.fn()
|
||||
vi.mock('@/lib/sandbox/guard', () => ({
|
||||
isSandboxCompany: (...args: unknown[]) => mockIsSandbox(...args),
|
||||
}))
|
||||
|
||||
const mockHasCapability = vi.fn()
|
||||
vi.mock('@/lib/entitlements/has-capability', () => ({
|
||||
hasCapability: (...args: unknown[]) => mockHasCapability(...args),
|
||||
}))
|
||||
|
||||
const mockEnsureNumber = vi.fn()
|
||||
vi.mock('@/lib/invoices/ensure-invoice-number', () => ({
|
||||
ensureInvoiceNumber: (...args: unknown[]) => mockEnsureNumber(...args),
|
||||
}))
|
||||
|
||||
const mockCreateJE = vi.fn()
|
||||
vi.mock('@/lib/bookkeeping/invoice-entries', () => ({
|
||||
createInvoiceJournalEntry: (...args: unknown[]) => mockCreateJE(...args),
|
||||
}))
|
||||
|
||||
const mockUploadDocument = vi.fn()
|
||||
vi.mock('@/lib/core/documents/document-service', () => ({
|
||||
uploadDocument: (...args: unknown[]) => mockUploadDocument(...args),
|
||||
}))
|
||||
|
||||
describe('computeNextRunDate', () => {
|
||||
it('advances day 15 from January to February', () => {
|
||||
@@ -98,3 +169,190 @@ describe('getStockholmDateHour', () => {
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('executeRecurringSchedule auto-send', () => {
|
||||
const { supabase, enqueue, reset } = createQueuedMockSupabase()
|
||||
const client = supabase as unknown as SupabaseClient
|
||||
const today = new Date('2026-07-06T06:30:00Z')
|
||||
|
||||
const customer = makeCustomer({ id: 'cust-1', email: 'kund@test.se' })
|
||||
const company = makeCompanySettings({ accounting_method: 'accrual' })
|
||||
|
||||
function makeSchedule() {
|
||||
return {
|
||||
id: 'sched-1',
|
||||
company_id: 'company-1',
|
||||
user_id: 'user-1',
|
||||
customer_id: 'cust-1',
|
||||
name: 'Monthly retainer',
|
||||
day_of_month: 6,
|
||||
send_hour: 8,
|
||||
payment_terms_days: 30,
|
||||
currency: 'SEK',
|
||||
your_reference: null,
|
||||
our_reference: null,
|
||||
notes: null,
|
||||
auto_send: true,
|
||||
status: 'active',
|
||||
next_run_date: '2026-07-06',
|
||||
last_run_at: null,
|
||||
last_invoice_id: null,
|
||||
last_run_warning: null,
|
||||
generated_count: 0,
|
||||
items: [
|
||||
{
|
||||
id: 'si-1',
|
||||
schedule_id: 'sched-1',
|
||||
sort_order: 0,
|
||||
description: 'Konsulttimmar',
|
||||
quantity: 10,
|
||||
unit: 'tim',
|
||||
unit_price: 1000,
|
||||
vat_rate: 25,
|
||||
},
|
||||
],
|
||||
} as unknown as Parameters<typeof executeRecurringSchedule>[1]
|
||||
}
|
||||
|
||||
// Fresh objects per test: ensureInvoiceNumber and applyPaymentLinkToInvoice
|
||||
// mutate the invoice they receive, so shared fixtures would leak state.
|
||||
function makeInsertedInvoice() {
|
||||
return { id: 'inv-1', invoice_number: null, document_type: 'invoice' }
|
||||
}
|
||||
function makeCompleteInvoice() {
|
||||
return {
|
||||
id: 'inv-1',
|
||||
invoice_number: 'F-1',
|
||||
status: 'draft',
|
||||
document_type: 'invoice',
|
||||
currency: 'SEK',
|
||||
total: 12500,
|
||||
credited_invoice_id: null,
|
||||
payment_link_url: null,
|
||||
customer,
|
||||
items: [{ id: 'item-1', sort_order: 0 }],
|
||||
}
|
||||
}
|
||||
|
||||
/** Queue for the full happy path (see call order in the service). */
|
||||
function enqueueHappyPath() {
|
||||
enqueue({ data: customer, error: null }) // customers select
|
||||
enqueue({ data: makeInsertedInvoice(), error: null }) // invoices insert
|
||||
enqueue({ data: null, error: null }) // invoice_items insert
|
||||
enqueue({ data: makeCompleteInvoice(), error: null }) // re-fetch with relations
|
||||
enqueue({ data: company, error: null }) // company_settings (auto-send)
|
||||
enqueue({ data: null, error: null }) // status flip to sent
|
||||
enqueue({ data: null, error: null }) // journal_entry_id write-back
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
reset()
|
||||
eventBus.clear()
|
||||
mockIsConfigured.mockReturnValue(true)
|
||||
mockIsSandbox.mockResolvedValue(false)
|
||||
mockHasCapability.mockResolvedValue(true)
|
||||
mockApplyPaymentLink.mockResolvedValue({ failure: null })
|
||||
mockEnsureNumber.mockImplementation(
|
||||
async (_supabase: unknown, _companyId: unknown, inv: { invoice_number: string | null }) => {
|
||||
inv.invoice_number = 'F-1'
|
||||
return 'F-1'
|
||||
},
|
||||
)
|
||||
mockPrepareRender.mockResolvedValue({ branding: {}, company })
|
||||
mockSwishQr.mockResolvedValue(null)
|
||||
mockPaymentLinkQr.mockResolvedValue(null)
|
||||
mockRenderToBuffer.mockResolvedValue(Buffer.from('fake-pdf'))
|
||||
mockInvoicePDF.mockReturnValue('pdf-element')
|
||||
mockSendEmail.mockResolvedValue({ success: true, messageId: 'm-1' })
|
||||
mockCreateJE.mockResolvedValue({ id: 'je-1' })
|
||||
mockUploadDocument.mockResolvedValue({})
|
||||
})
|
||||
|
||||
it('creates a payment link before rendering and passes its QR to the PDF', async () => {
|
||||
enqueueHappyPath()
|
||||
mockApplyPaymentLink.mockImplementation(
|
||||
async (_s: unknown, _c: unknown, _u: unknown, inv: { payment_link_url: string | null }) => {
|
||||
inv.payment_link_url = 'https://pay.example/x'
|
||||
return { failure: null }
|
||||
},
|
||||
)
|
||||
mockPaymentLinkQr.mockResolvedValue('data:image/png;base64,QR')
|
||||
|
||||
const result = await executeRecurringSchedule(client, makeSchedule(), today)
|
||||
|
||||
expect(result.autoSent).toBe(true)
|
||||
expect(result.warning).toBeNull()
|
||||
expect(mockApplyPaymentLink).toHaveBeenCalledTimes(1)
|
||||
expect(mockApplyPaymentLink).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
'company-1',
|
||||
'user-1',
|
||||
expect.objectContaining({ id: 'inv-1' }),
|
||||
expect.anything(),
|
||||
)
|
||||
// Link applied BEFORE the render so the email button and PDF QR carry it.
|
||||
expect(mockApplyPaymentLink.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
mockRenderToBuffer.mock.invocationCallOrder[0],
|
||||
)
|
||||
// QR built from the renderable copy (status overridden to 'sent').
|
||||
expect(mockPaymentLinkQr).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ payment_link_url: 'https://pay.example/x', status: 'sent' }),
|
||||
)
|
||||
expect(mockInvoicePDF).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ paymentLinkQrDataUrl: 'data:image/png;base64,QR' }),
|
||||
)
|
||||
})
|
||||
|
||||
it('a payment link failure never blocks the send', async () => {
|
||||
enqueueHappyPath()
|
||||
mockApplyPaymentLink.mockResolvedValue({ failure: 'Stripe nere' })
|
||||
|
||||
const result = await executeRecurringSchedule(client, makeSchedule(), today)
|
||||
|
||||
expect(result.autoSent).toBe(true)
|
||||
expect(result.warning).toBeNull()
|
||||
expect(mockSendEmail).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('never auto-sends from a sandbox company; invoice stays a numbered draft', async () => {
|
||||
mockIsSandbox.mockResolvedValue(true)
|
||||
// Sandbox bails before company_settings/payment-link/render/email, so the
|
||||
// queue only covers invoice creation.
|
||||
enqueue({ data: customer, error: null })
|
||||
enqueue({ data: makeInsertedInvoice(), error: null })
|
||||
enqueue({ data: null, error: null })
|
||||
enqueue({ data: makeCompleteInvoice(), error: null })
|
||||
|
||||
const result = await executeRecurringSchedule(client, makeSchedule(), today)
|
||||
|
||||
expect(result.invoiceId).toBe('inv-1')
|
||||
expect(result.autoSent).toBe(false)
|
||||
expect(result.warning).toContain('Auto-utskick misslyckades')
|
||||
expect(mockSendEmail).not.toHaveBeenCalled()
|
||||
expect(mockApplyPaymentLink).not.toHaveBeenCalled()
|
||||
expect(mockCreateJE).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('route-level suppressAutoSend skips the send path without relying on the internal chokepoint', async () => {
|
||||
// Defence in depth (ASVS V2.3): the flag comes from the route's own
|
||||
// isSandboxCompany resolution, so sending is suppressed even before the
|
||||
// service-internal sandbox check runs. Invoice creation is unaffected.
|
||||
enqueue({ data: customer, error: null })
|
||||
enqueue({ data: makeInsertedInvoice(), error: null })
|
||||
enqueue({ data: null, error: null })
|
||||
enqueue({ data: makeCompleteInvoice(), error: null })
|
||||
|
||||
const result = await executeRecurringSchedule(client, makeSchedule(), today, {
|
||||
suppressAutoSend: true,
|
||||
})
|
||||
|
||||
expect(result.invoiceId).toBe('inv-1')
|
||||
expect(result.autoSent).toBe(false)
|
||||
expect(result.warning).toContain('Auto-utskick misslyckades')
|
||||
expect(mockSendEmail).not.toHaveBeenCalled()
|
||||
// The suppress branch bails before the email chokepoint entirely.
|
||||
expect(mockIsSandbox).not.toHaveBeenCalled()
|
||||
expect(mockCreateJE).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
createInvoicePaymentJournalEntry,
|
||||
createInvoiceCashEntry,
|
||||
} from '@/lib/bookkeeping/invoice-entries'
|
||||
import { createJournalEntry, findFiscalPeriod } from '@/lib/bookkeeping/engine'
|
||||
import { cancelOrphanedPaymentEntry } from '@/lib/bookkeeping/cancel-orphaned-entry'
|
||||
import { settleInvoicePayment } from '@/lib/invoices/settle-invoice-payment'
|
||||
import { eventBus } from '@/lib/events'
|
||||
@@ -100,6 +101,152 @@ describe('settleInvoicePayment', () => {
|
||||
expect(vi.mocked(createInvoicePaymentJournalEntry)).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('absorbs a sub-krona öresavrundning overshoot on SEK custom lines', async () => {
|
||||
vi.mocked(findFiscalPeriod).mockResolvedValue('fp-1')
|
||||
vi.mocked(createJournalEntry).mockResolvedValue({ id: 'je-ore' } as never)
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
enqueue({ data: [{ id: 'inv-1' }] }) // CAS update matched
|
||||
|
||||
// Invoice total 1234.75, PDF "Att betala" 1235.00: the customer pays the
|
||||
// rounded amount and the 3740 line carries the residual.
|
||||
const invoice = payableInvoice({
|
||||
total: 1234.75,
|
||||
remaining_amount: 1234.75,
|
||||
journal_entry_id: 'je-orig',
|
||||
} as Partial<Invoice>)
|
||||
const result = await settleInvoicePayment(
|
||||
supabase as unknown as SupabaseClient,
|
||||
'company-1',
|
||||
'user-1',
|
||||
{
|
||||
...BASE_PARAMS,
|
||||
invoice,
|
||||
paymentAmountInInvoiceCurrency: 1235,
|
||||
customLines: [
|
||||
{ account_number: '1930', debit_amount: 1235, credit_amount: 0 },
|
||||
{ account_number: '1510', debit_amount: 0, credit_amount: 1234.75 },
|
||||
{ account_number: '3740', debit_amount: 0, credit_amount: 0.25 },
|
||||
],
|
||||
},
|
||||
)
|
||||
|
||||
expect(result).toMatchObject({
|
||||
ok: true,
|
||||
newStatus: 'paid',
|
||||
newPaidAmount: 1234.75,
|
||||
newRemaining: 0,
|
||||
journalEntryId: 'je-ore',
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps a sub-krona short partial WITHOUT a 3740 line partially paid', async () => {
|
||||
vi.mocked(findFiscalPeriod).mockResolvedValue('fp-1')
|
||||
vi.mocked(createJournalEntry).mockResolvedValue({ id: 'je-partial' } as never)
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
enqueue({ data: [{ id: 'inv-1' }] }) // CAS update matched
|
||||
|
||||
// Deliberate partial: both legs lowered, no 3740. Absorbing here would
|
||||
// flip the invoice to paid while 1510 keeps the 0.75 residual.
|
||||
const invoice = payableInvoice({
|
||||
total: 1234.75,
|
||||
remaining_amount: 1234.75,
|
||||
journal_entry_id: 'je-orig',
|
||||
} as Partial<Invoice>)
|
||||
const result = await settleInvoicePayment(
|
||||
supabase as unknown as SupabaseClient,
|
||||
'company-1',
|
||||
'user-1',
|
||||
{
|
||||
...BASE_PARAMS,
|
||||
invoice,
|
||||
paymentAmountInInvoiceCurrency: 1234,
|
||||
customLines: [
|
||||
{ account_number: '1930', debit_amount: 1234, credit_amount: 0 },
|
||||
{ account_number: '1510', debit_amount: 0, credit_amount: 1234 },
|
||||
],
|
||||
},
|
||||
)
|
||||
|
||||
expect(result).toMatchObject({
|
||||
ok: true,
|
||||
newStatus: 'partially_paid',
|
||||
newPaidAmount: 1234,
|
||||
newRemaining: 0.75,
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects a sub-krona custom-line overshoot WITHOUT a 3740 line', async () => {
|
||||
const { supabase } = createQueuedMockSupabase()
|
||||
const invoice = payableInvoice({
|
||||
total: 1234.75,
|
||||
remaining_amount: 1234.75,
|
||||
} as Partial<Invoice>)
|
||||
const result = await settleInvoicePayment(
|
||||
supabase as unknown as SupabaseClient,
|
||||
'company-1',
|
||||
'user-1',
|
||||
{
|
||||
...BASE_PARAMS,
|
||||
invoice,
|
||||
paymentAmountInInvoiceCurrency: 1235.25,
|
||||
customLines: [
|
||||
{ account_number: '1930', debit_amount: 1235.25, credit_amount: 0 },
|
||||
{ account_number: '1510', debit_amount: 0, credit_amount: 1235.25 },
|
||||
],
|
||||
},
|
||||
)
|
||||
expect(result).toMatchObject({ ok: false, code: 'MATCH_AMOUNT_EXCEEDS_REMAINING' })
|
||||
expect(vi.mocked(createJournalEntry)).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects a custom-line overshoot beyond the öre band', async () => {
|
||||
const { supabase } = createQueuedMockSupabase()
|
||||
const invoice = payableInvoice({
|
||||
total: 1234.75,
|
||||
remaining_amount: 1234.75,
|
||||
} as Partial<Invoice>)
|
||||
const result = await settleInvoicePayment(
|
||||
supabase as unknown as SupabaseClient,
|
||||
'company-1',
|
||||
'user-1',
|
||||
{
|
||||
...BASE_PARAMS,
|
||||
invoice,
|
||||
paymentAmountInInvoiceCurrency: 1236,
|
||||
customLines: [
|
||||
{ account_number: '1930', debit_amount: 1236, credit_amount: 0 },
|
||||
{ account_number: '1510', debit_amount: 0, credit_amount: 1236 },
|
||||
],
|
||||
},
|
||||
)
|
||||
expect(result).toMatchObject({ ok: false, code: 'MATCH_AMOUNT_EXCEEDS_REMAINING' })
|
||||
expect(vi.mocked(createJournalEntry)).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not absorb öre overshoot for non-SEK invoices', async () => {
|
||||
const { supabase } = createQueuedMockSupabase()
|
||||
const invoice = payableInvoice({
|
||||
total: 100,
|
||||
remaining_amount: 100,
|
||||
currency: 'EUR',
|
||||
} as Partial<Invoice>)
|
||||
const result = await settleInvoicePayment(
|
||||
supabase as unknown as SupabaseClient,
|
||||
'company-1',
|
||||
'user-1',
|
||||
{
|
||||
...BASE_PARAMS,
|
||||
invoice,
|
||||
paymentAmountInInvoiceCurrency: 100.25,
|
||||
customLines: [
|
||||
{ account_number: '1930', debit_amount: 100.25, credit_amount: 0 },
|
||||
{ account_number: '1510', debit_amount: 0, credit_amount: 100.25 },
|
||||
],
|
||||
},
|
||||
)
|
||||
expect(result).toMatchObject({ ok: false, code: 'MATCH_AMOUNT_EXCEEDS_REMAINING' })
|
||||
})
|
||||
|
||||
it('rejects overpayment before creating any journal entry', async () => {
|
||||
const { supabase } = createQueuedMockSupabase()
|
||||
const result = await settleInvoicePayment(
|
||||
|
||||
@@ -61,11 +61,18 @@ export function planInvoicePayment(
|
||||
const currentRemaining =
|
||||
invoice.remaining_amount ?? invoice.total - (invoice.paid_amount || 0)
|
||||
|
||||
// A rounded-up whole-krona payment is not an overpayment: widen the reject
|
||||
// band to one krona when absorbing öre; otherwise keep the strict half-öre
|
||||
// float tolerance the three legacy callers rely on.
|
||||
const overshootTolerance = absorbOre ? ORE_ROUNDING_SETTLEMENT_MAX : PAYMENT_OVERSHOOT_TOLERANCE
|
||||
if (paymentAmountInInvoiceCurrency > currentRemaining + overshootTolerance) {
|
||||
// A rounded-up whole-krona payment is not an overpayment: when absorbing öre,
|
||||
// accept only an overshoot strictly inside the settlement band. The boundary
|
||||
// must be >= : with a strict > guard an exact 1 kr overshoot passed the guard
|
||||
// AND missed the |diff| < 1 absorb branch, falling through to record
|
||||
// paid_amount = total + 1 kr with remaining clamped to 0 (silent over-credit).
|
||||
// Without absorb, keep the strict half-öre float tolerance the legacy callers
|
||||
// rely on.
|
||||
const overshoot = roundOre(paymentAmountInInvoiceCurrency - currentRemaining)
|
||||
const isOverpayment = absorbOre
|
||||
? overshoot >= ORE_ROUNDING_SETTLEMENT_MAX
|
||||
: paymentAmountInInvoiceCurrency > currentRemaining + PAYMENT_OVERSHOOT_TOLERANCE
|
||||
if (isOverpayment) {
|
||||
return {
|
||||
ok: false,
|
||||
code: 'MATCH_AMOUNT_EXCEEDS_REMAINING',
|
||||
@@ -109,3 +116,47 @@ export function planInvoicePayment(
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/** BAS öres- och kronutjämning: the only account that may carry an absorbed residual. */
|
||||
const ORE_ROUNDING_ACCOUNT = '3740'
|
||||
|
||||
/**
|
||||
* `planInvoicePayment` for caller-supplied booking lines (the mark-paid
|
||||
* dialog and the v1 API), where the server does NOT build the verifikat.
|
||||
*
|
||||
* Absorbing an öre residual is only safe when the lines actually book it:
|
||||
* in the server-built bank-match flow `buildInvoicePaymentClearingLines`
|
||||
* guarantees 1510 is credited the full remaining and 3740 carries the exact
|
||||
* residual, so plan and GL absorb together. Here the lines are caller-owned,
|
||||
* so absorption is granted only when the net 3740 amount (debit − credit)
|
||||
* equals the signed residual (remaining − payment). Otherwise fall back to
|
||||
* the strict plan: a sub-krona short payment stays a real partial and a
|
||||
* sub-krona overshoot is rejected, exactly as before absorption existed.
|
||||
* Without this gate an invoice could flip to paid while the posted lines
|
||||
* under-clear 1510, diverging the GL from the AR sub-ledger.
|
||||
*/
|
||||
export function planInvoicePaymentForLines(
|
||||
invoice: InvoicePaymentTotals,
|
||||
paymentAmountInInvoiceCurrency: number,
|
||||
lines:
|
||||
| Array<{ account_number: string; debit_amount: number; credit_amount: number }>
|
||||
| undefined,
|
||||
invoiceCurrency: string,
|
||||
): PlanInvoicePaymentResult {
|
||||
const absorbEligible = !!lines && invoiceCurrency === 'SEK'
|
||||
const payment = planInvoicePayment(invoice, paymentAmountInInvoiceCurrency, {
|
||||
absorbOreRounding: absorbEligible,
|
||||
})
|
||||
if (!absorbEligible || !payment.ok || !payment.plan.oreSettled) return payment
|
||||
|
||||
const currentRemaining =
|
||||
invoice.remaining_amount ?? invoice.total - (invoice.paid_amount || 0)
|
||||
const residual = roundOre(currentRemaining - paymentAmountInInvoiceCurrency)
|
||||
const net3740 = roundOre(
|
||||
lines!
|
||||
.filter((l) => l.account_number === ORE_ROUNDING_ACCOUNT)
|
||||
.reduce((s, l) => s + l.debit_amount - l.credit_amount, 0),
|
||||
)
|
||||
if (net3740 === residual) return payment
|
||||
return planInvoicePayment(invoice, paymentAmountInInvoiceCurrency)
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user