Fix/skv connection flow (#1015)

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Mattsson
2026-07-13 22:54:33 +02:00
committed by GitHub
co-authored by Claude Fable 5
parent e7e3c35f9e
commit b6332e9ff4
154 changed files with 18364 additions and 1867 deletions
@@ -50,7 +50,7 @@ function makeRequest(params: Record<string, string>) {
function mockChain(result: { data?: unknown; error?: unknown }) {
const chain: Record<string, unknown> = {}
for (const m of ['select', 'eq', 'in', 'single', 'update', 'order', 'limit']) {
for (const m of ['select', 'eq', 'in', 'is', 'single', 'update', 'delete', 'order', 'limit']) {
chain[m] = vi.fn().mockReturnValue(chain)
}
chain.single = vi.fn().mockResolvedValue({ data: result.data ?? null, error: result.error ?? null })
@@ -98,14 +98,17 @@ describe('GET /api/extensions/enable-banking/callback', () => {
expect(location).toContain('bank_error=invalid_state')
})
it('writes pending_selection and redirects to picker on success', async () => {
it('writes pending_selection and streams a finalizing page that redirects to the picker', async () => {
const capturedUpdates: Record<string, unknown>[] = []
let callIndex = 0
mockFrom.mockImplementation(() => {
callIndex++
if (callIndex === 1) {
// Find pending connection by oauth_state
return mockChain({ data: { id: 'conn-1', user_id: 'user-1', company_id: 'company-1' }, error: null })
return mockChain({
data: { id: 'conn-1', user_id: 'user-1', company_id: 'company-1', bank_name: 'TestBank', status: 'pending' },
error: null,
})
}
// Update connection: capture the payload, then chain returns the
// updated row via .select().single() for the audit event emission.
@@ -143,11 +146,27 @@ describe('GET /api/extensions/enable-banking/callback', () => {
const response = await GET(makeRequest({ code: 'auth-code', state: 'valid-state' }))
expect(response.status).toBe(307)
const location = response.headers.get('location') || ''
expect(location).toContain('/settings/banking?')
expect(location).toContain('select_accounts=conn-1')
expect(location).not.toContain('bank_connected=true')
// Success streams an interim page (instant feedback during the session
// exchange) that ends with a client-side redirect to the account picker.
expect(response.status).toBe(200)
expect(response.headers.get('content-type')).toContain('text/html')
expect(response.headers.get('cache-control')).toBe('no-store')
const body = await response.text()
// Shell flushed with the bank name, then the redirect to the picker.
expect(body).toContain('TestBank')
expect(body).toContain('window.location.replace')
expect(body).toContain('select_accounts=conn-1')
expect(body).not.toContain('bank_error')
// ASVS V3.3: inline scripts are nonce-bound. The response-level CSP
// declares the nonce and BOTH chunks (shell watchdog + redirect) carry
// it; no un-nonced inline script may exist on this page.
const csp = response.headers.get('content-security-policy') ?? ''
const nonceMatch = /script-src 'nonce-([^']+)'/.exec(csp)
expect(nonceMatch).not.toBeNull()
const nonce = nonceMatch![1]
expect(body.split(`<script nonce="${nonce}">`).length - 1).toBe(2)
expect(body).not.toContain('<script>')
// Verify the update payload: status=pending_selection, no last_synced_at,
// and every account defaults to enabled=true so the picker can simply
@@ -185,7 +204,10 @@ describe('GET /api/extensions/enable-banking/callback', () => {
mockFrom.mockImplementation((table: string) => {
callIndex++
if (callIndex === 1) {
return mockChain({ data: { id: 'conn-1', user_id: 'user-1', company_id: 'company-1' }, error: null })
return mockChain({
data: { id: 'conn-1', user_id: 'user-1', company_id: 'company-1', bank_name: 'TestBank', status: 'expired' },
error: null,
})
}
if (table === 'cash_accounts') {
// Already mirrored on a previous connect — acc-1 was remapped to 1935
@@ -218,7 +240,9 @@ describe('GET /api/extensions/enable-banking/callback', () => {
const response = await GET(makeRequest({ code: 'auth-code', state: 'valid-state' }))
expect(response.status).toBe(307)
expect(response.status).toBe(200)
const body = await response.text()
expect(body).toContain('select_accounts=conn-1')
// No allocation for an already-mirrored account; the upsert reuses 1935.
expect(mockAllocate).not.toHaveBeenCalled()
expect(mockUpsertFromPsd2).toHaveBeenCalledTimes(1)
@@ -227,6 +251,77 @@ describe('GET /api/extensions/enable-banking/callback', () => {
).toBe('1935')
})
it('deletes the fresh row and streams an error redirect when the session exchange fails', async () => {
const deleteCalls: unknown[] = []
const updateCalls: unknown[] = []
mockFrom.mockImplementation(() => {
const chain = mockChain({
data: { id: 'conn-1', user_id: 'user-1', company_id: 'company-1', bank_name: 'TestBank', status: 'pending' },
error: null,
})
chain.delete = vi.fn(() => {
deleteCalls.push('delete')
return chain
})
chain.update = vi.fn((payload: unknown) => {
updateCalls.push(payload)
return chain
})
return chain
})
mockCreateSession.mockRejectedValue(new Error('upstream timeout'))
const response = await GET(makeRequest({ code: 'auth-code', state: 'valid-state' }))
expect(response.status).toBe(200)
const body = await response.text()
// The streamed redirect carries the failure to the settings banner.
expect(body).toContain('window.location.replace')
expect(body).toContain('bank_error=')
expect(body).not.toContain('select_accounts=')
// A never-activated attempt is deleted, not parked as a zombie 'error'
// row that would render next to a successful retry as a duplicate.
expect(deleteCalls).toHaveLength(1)
expect(updateCalls).toHaveLength(0)
})
it('marks a reconnect row as error (not deleted) when the session exchange fails', async () => {
const deleteCalls: unknown[] = []
const updateCalls: Record<string, unknown>[] = []
mockFrom.mockImplementation(() => {
const chain = mockChain({
data: { id: 'conn-1', user_id: 'user-1', company_id: 'company-1', bank_name: 'TestBank', status: 'expired' },
error: null,
})
chain.delete = vi.fn(() => {
deleteCalls.push('delete')
return chain
})
chain.update = vi.fn((payload: Record<string, unknown>) => {
updateCalls.push(payload)
return chain
})
return chain
})
mockCreateSession.mockRejectedValue(new Error('upstream timeout'))
const response = await GET(makeRequest({ code: 'auth-code', state: 'valid-state' }))
expect(response.status).toBe(200)
const body = await response.text()
expect(body).toContain('bank_error=')
// An established connection keeps its row (history, accounts) and gets
// the error surfaced on it instead.
expect(deleteCalls).toHaveLength(0)
expect(updateCalls).toHaveLength(1)
expect(updateCalls[0].status).toBe('error')
expect(updateCalls[0].oauth_state).toBeNull()
})
it('redirects with error when bank returns error param (no state)', async () => {
const response = await GET(makeRequest({ error: 'access_denied', error_description: 'User cancelled' }))
@@ -257,10 +352,75 @@ describe('GET /api/extensions/enable-banking/callback', () => {
expect(mockFrom).toHaveBeenCalledWith('bank_connections')
})
it('deletes a fresh pending row on bank denial instead of parking it in error', async () => {
const deleteCalls: unknown[] = []
const updateCalls: unknown[] = []
mockFrom.mockImplementation(() => {
const chain = mockChain({
data: { id: 'conn-1', user_id: 'user-1', bank_name: 'TestBank', psu_type: 'business', status: 'pending' },
error: null,
})
chain.delete = vi.fn(() => {
deleteCalls.push('delete')
return chain
})
chain.update = vi.fn((payload: unknown) => {
updateCalls.push(payload)
return chain
})
return chain
})
const response = await GET(makeRequest({
error: 'access_denied',
error_description: 'User cancelled',
state: 'pending-state',
}))
expect(response.status).toBe(307)
const location = response.headers.get('location') || ''
// URLSearchParams encodes spaces as '+', unlike the encodeURIComponent
// fallback used when no matching row exists.
expect(location).toContain('bank_error=User+cancelled')
expect(deleteCalls).toHaveLength(1)
expect(updateCalls).toHaveLength(0)
})
it('keeps a reconnect row on bank denial and marks it expired on session-expiry errors', async () => {
const deleteCalls: unknown[] = []
const updateCalls: Record<string, unknown>[] = []
mockFrom.mockImplementation(() => {
const chain = mockChain({
data: { id: 'conn-1', user_id: 'user-1', bank_name: 'TestBank', psu_type: 'business', status: 'expired' },
error: null,
})
chain.delete = vi.fn(() => {
deleteCalls.push('delete')
return chain
})
chain.update = vi.fn((payload: Record<string, unknown>) => {
updateCalls.push(payload)
return chain
})
return chain
})
const response = await GET(makeRequest({
error: 'server_error',
error_description: 'Session expired at ASPSP',
state: 'pending-state',
}))
expect(response.status).toBe(307)
expect(deleteCalls).toHaveLength(0)
expect(updateCalls).toHaveLength(1)
expect(updateCalls[0].status).toBe('expired')
})
it('forwards bank_error_code and psu_type when the denied state matches a pending connection', async () => {
mockFrom.mockImplementation(() =>
mockChain({
data: { id: 'conn-1', user_id: 'user-1', bank_name: 'Handelsbanken', psu_type: 'business' },
data: { id: 'conn-1', user_id: 'user-1', bank_name: 'Handelsbanken', psu_type: 'business', status: 'pending' },
error: null,
})
)
@@ -0,0 +1,185 @@
/**
* Interim "finalizing" page for the Enable Banking OAuth callback.
*
* The callback has seconds of unavoidable server work between the bank's
* redirect and our own (session exchange with Enable Banking, account
* mirroring, audit events). A classic 307 would leave the user staring at a
* blank browser tab for that whole window, which reads as "the connection
* failed" and provokes retries (and, historically, duplicate connections).
*
* Instead the route streams this page in two chunks:
* 1. renderFinalizeShell() - flushed immediately, before any slow work:
* branded spinner + "Slutför anslutningen".
* 2. renderFinalizeRedirect() - flushed when the work is done: script +
* meta-refresh + visible fallback link that
* navigates to the settings page.
*
* The page is standalone HTML (no app bundle), styled to match the editorial
* monochrome design system; see app/api/mcp-oauth/authorize for the sibling
* standalone page this mirrors. Swedish-only, like the rest of the
* enable-banking extension surfaces.
*
* Inline scripts are nonce-bound (ASVS V3.3): the route generates a
* per-request nonce, stamps it on every <script> tag here, and sets a
* response-level CSP with script-src 'nonce-...'. The global next.config CSP
* (which still carries 'unsafe-inline' for the app bundle) also applies;
* browsers enforce the intersection, so an injected inline script without
* the nonce is blocked on this response.
*/
function escapeHtml(value: string): string {
return value
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;')
}
/**
* Opening chunk: full document head, styles, spinner and heading, plus a
* watchdog that reveals an escape hatch if the server work takes abnormally
* long (dead function, hung upstream). Deliberately does NOT close <body>:
* the redirect chunk does. `cspNonce` must match the script-src nonce the
* route puts in the response's Content-Security-Policy header.
*/
export function renderFinalizeShell(bankName: string | null, cspNonce: string): string {
const heading = bankName
? `Slutf&ouml;r anslutningen till ${escapeHtml(bankName)}&hellip;`
: 'Slutf&ouml;r bankanslutningen&hellip;'
return `<!DOCTYPE html>
<html lang="sv">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="robots" content="noindex, nofollow">
<meta name="color-scheme" content="light">
<title>Slutf&ouml;r bankanslutningen</title>
<style>
:root {
--bg: hsl(0 0% 100%);
--border: hsl(45 5% 85%);
--fg: hsl(0 0% 9%);
--fg-muted: hsl(0 0% 40%);
--fg-faint: hsl(0 0% 55%);
--warm-accent: hsl(38 45% 52%);
}
* { margin: 0; padding: 0; box-sizing: border-box; }
html { -webkit-text-size-adjust: 100%; }
body {
font-family: 'Geist', -apple-system, system-ui, 'Segoe UI', sans-serif;
background: var(--bg);
color: var(--fg);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 100vh;
padding: 2rem 1.5rem;
font-size: 14px;
line-height: 1.5;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
main {
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
max-width: 26rem;
}
.spinner {
width: 28px;
height: 28px;
border: 2px solid var(--border);
border-top-color: var(--fg);
border-radius: 50%;
animation: spin 0.8s linear infinite;
margin-bottom: 1.5rem;
}
@keyframes spin { to { transform: rotate(360deg); } }
@media (prefers-reduced-motion: reduce) {
/* Keep a slow spin: a frozen spinner reads as a hung page. */
.spinner { animation-duration: 2.5s; }
}
.eyebrow {
display: inline-flex;
align-items: center;
gap: 0.375rem;
font-size: 0.6875rem;
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--fg-faint);
margin-bottom: 0.875rem;
}
.eyebrow::before {
content: "";
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--warm-accent);
}
h1 {
font-family: 'Hedvig Letters Serif', Georgia, 'Times New Roman', serif;
font-size: 1.625rem;
font-weight: 400;
letter-spacing: -0.018em;
line-height: 1.15;
margin-bottom: 0.625rem;
}
.lede {
font-size: 0.875rem;
color: var(--fg-muted);
line-height: 1.55;
}
.slow {
margin-top: 1.5rem;
font-size: 0.8125rem;
color: var(--fg-muted);
line-height: 1.55;
}
.slow[hidden] { display: none; }
a { color: var(--fg); text-decoration: underline; text-underline-offset: 2px; }
.fallback { margin-top: 1.25rem; font-size: 0.8125rem; }
</style>
</head>
<body>
<main role="main" aria-busy="true">
<div class="spinner" aria-hidden="true"></div>
<div class="eyebrow">Bankanslutning</div>
<h1>${heading}</h1>
<p class="lede">Vi bekr&auml;ftar anslutningen och h&auml;mtar dina konton fr&aring;n banken. Du skickas vidare automatiskt.</p>
<p class="slow" id="slow-notice" hidden>
Det tar l&auml;ngre tid &auml;n vanligt. V&auml;nta g&auml;rna kvar en stund till,
eller <a href="/settings/banking">g&aring; till bankinst&auml;llningarna</a>.
</p>
</main>
<script nonce="${escapeHtml(cspNonce)}">
setTimeout(function () {
var el = document.getElementById('slow-notice');
if (el) el.hidden = false;
}, 30000);
</script>
`
}
/**
* Closing chunk: navigates to `url` the instant it arrives. Three mechanisms,
* most graceful first: location.replace (keeps the callback URL out of
* history so Back cannot re-trigger it), a meta refresh for no-JS, and a
* visible link as the last resort.
*/
export function renderFinalizeRedirect(url: string, cspNonce: string): string {
// <-escape so a "</script>" sequence can never terminate the block
// early, even though our URLs are app-relative and query-encoded.
const jsUrl = JSON.stringify(url).replace(/</g, '\\u003c')
return ` <script nonce="${escapeHtml(cspNonce)}">window.location.replace(${jsUrl});</script>
<noscript><meta http-equiv="refresh" content="0;url=${escapeHtml(url)}"></noscript>
<div class="fallback"><a href="${escapeHtml(url)}">Klicka h&auml;r om du inte skickas vidare automatiskt</a></div>
</body>
</html>
`
}
@@ -1,6 +1,8 @@
import { randomBytes } from 'node:crypto'
import { createServiceClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { NextResponse, after } from 'next/server'
import { ensureInitialized } from '@/lib/init'
import { createLogger } from '@/lib/logger'
import { createSession, type AccountInfo } from '@/extensions/general/enable-banking/lib/api-client'
import type { StoredAccount } from '@/extensions/general/enable-banking/types'
import { eventBus } from '@/lib/events/bus'
@@ -9,6 +11,7 @@ import {
allocatePsd2LedgerAccount,
defaultLedgerForCurrency,
} from '@/lib/cash-accounts/service'
import { renderFinalizeShell, renderFinalizeRedirect } from './finalize-page'
// This route emits bank_connection.consent_granted / .cash_account_mirror_failed
// (ASVS V16 / GDPR Art.30 audit events). ensureInitialized() must run at module
@@ -17,6 +20,26 @@ import {
// redirect route is the first event-emitting code path to execute.
ensureInitialized()
// Structured logger for audit-trail failures (ISO 27001 A.8.15): a failed
// audit-event emission must be visible to log-based alerting, not just a raw
// console line. The stable message below is what monitoring keys on.
const log = createLogger('enable-banking/callback')
const AUDIT_EMIT_FAILED = 'audit event emit failed'
type ServiceClient = Awaited<ReturnType<typeof createServiceClient>>
interface PendingConnection {
id: string
user_id: string
company_id: string
bank_name: string | null
status: string
}
// Shown in the settings banner when the session exchange/finalize fails.
// User-facing, so Swedish (the raw upstream error is in the server log).
const FINALIZE_FAILED_MESSAGE =
'Anslutningen kunde inte slutföras. Försök igen om en stund.'
/**
* GET /api/extensions/enable-banking/callback
@@ -24,6 +47,13 @@ ensureInitialized()
* OAuth callback for Enable Banking PSD2 authorization.
* Must be a real Next.js route (not extension handler) because
* banks redirect to this URL directly.
*
* Fast outcomes (bank denial, bad params, unknown state) respond with a
* classic 307. The success path instead streams an interim "Slutför
* bankanslutningen" page while the slow work runs (session exchange with
* Enable Banking, cash-account mirroring), then streams a client-side
* redirect: without this the user stares at a blank tab for several seconds,
* which reads as a failed connection and provokes duplicate retries.
*/
export async function GET(request: Request) {
const { searchParams } = new URL(request.url)
@@ -58,7 +88,7 @@ export async function GET(request: Request) {
// (which stays 'expired' during the round-trip) is also handled.
const { data: pendingConn } = await supabase
.from('bank_connections')
.select('id, user_id, bank_name, psu_type')
.select('id, user_id, bank_name, psu_type, status')
.eq('oauth_state', state)
.in('status', ['pending', 'expired', 'error'])
.single()
@@ -72,17 +102,33 @@ export async function GET(request: Request) {
error_description: errorDescription,
})
// If the bank reports a session-expiry during authorization itself,
// mark the row 'expired' (not generic 'error') so the settings panel
// surfaces the reconnect button rather than a dead-end error state.
const isSessionExpiry = /session.?expired|expired.?session|closed.?session|session.?closed|invalid.?session|session.?not.?found/i.test(
`${error} ${errorDescription ?? ''}`
)
if (pendingConn.status === 'pending') {
// Fresh connect that never became a connection: delete the row
// instead of parking it in 'error'. A parked row renders forever
// as an "Åtgärd krävs" card, so a failed attempt followed by a
// successful retry showed up as two connections to the same bank.
// The ?bank_error banner below is the actual failure feedback.
await supabase
.from('bank_connections')
.delete()
.eq('id', pendingConn.id)
.eq('status', 'pending')
} else {
// Reconnect of an established connection: keep the row (it holds
// accounts/transactions history) and surface the failure on it.
// If the bank reports a session-expiry during authorization
// itself, mark it 'expired' (not generic 'error') so the settings
// panel surfaces the reconnect button rather than a dead-end
// error state.
const isSessionExpiry = /session.?expired|expired.?session|closed.?session|session.?closed|invalid.?session|session.?not.?found/i.test(
`${error} ${errorDescription ?? ''}`
)
await supabase
.from('bank_connections')
.update({ status: isSessionExpiry ? 'expired' : 'error', error_message: errorMessage, oauth_state: null })
.eq('id', pendingConn.id)
await supabase
.from('bank_connections')
.update({ status: isSessionExpiry ? 'expired' : 'error', error_message: errorMessage, oauth_state: null })
.eq('id', pendingConn.id)
}
// Include bank name, error code, and psu_type in the redirect so the
// UI can render targeted guidance (e.g. PSU-type retry on
@@ -118,240 +164,341 @@ export async function GET(request: Request) {
const supabase = await createServiceClient()
try {
// Look up the connection awaiting this callback by oauth_state (CSRF-safe).
// oauth_state is a single-use random token cleared after use, so it uniquely
// identifies the row regardless of status. Accept 'expired'/'error' too: an
// in-place reconnect keeps the row in 'expired' during the round-trip (so
// the nightly stale-'pending' cleanup can't delete an established row).
const { data: pendingConnection, error: findError } = await supabase
.from('bank_connections')
.select('id, user_id, company_id')
.eq('oauth_state', state)
.in('status', ['pending', 'expired', 'error'])
.single()
// Look up the connection awaiting this callback by oauth_state (CSRF-safe).
// oauth_state is a single-use random token cleared after use, so it uniquely
// identifies the row regardless of status. Accept 'expired'/'error' too: an
// in-place reconnect keeps the row in 'expired' during the round-trip (so
// the nightly stale-'pending' cleanup can't delete an established row).
// This lookup is fast, so it runs BEFORE the streamed response: an unknown
// state stays a plain redirect.
const { data: pendingConnection, error: findError } = await supabase
.from('bank_connections')
.select('id, user_id, company_id, bank_name, status')
.eq('oauth_state', state)
.in('status', ['pending', 'expired', 'error'])
.single()
if (findError || !pendingConnection) {
console.error('[enable-banking] No pending connection for oauth_state', {
findError: findError ? { message: findError.message, code: findError.code, details: findError.details } : null,
state,
hasCode: !!code,
})
return NextResponse.redirect(
`${baseUrl}/settings/banking?bank_error=${encodeURIComponent('invalid_state')}`
)
}
const userId = pendingConnection.user_id
console.log('[enable-banking] Exchanging code for session', {
connectionId: pendingConnection.id,
userId,
codeLength: code.length,
})
const sessionData = await createSession(code)
const { session_id, accounts, access } = sessionData
const consentExpiresAt = access.valid_until
console.log('[enable-banking] Session created successfully', {
connectionId: pendingConnection.id,
sessionId: '[REDACTED]',
accountCount: accounts.length,
consentExpiresAt,
})
// GDPR Art.5(1)(c) / Art.25(1): data minimization. We only store the
// metadata the user needs to pick which accounts to sync (uid, name, IBAN,
// currency). Balances are bank account financial data: we don't fetch
// them here. The first sync (after the user enables specific accounts)
// populates balance + balance_updated_at via lib/sync.ts. Accounts the
// user deselects never have their balance pulled.
const accountsMetadata: StoredAccount[] = accounts.map((account: AccountInfo) => ({
uid: account.uid,
iban: account.account_id?.iban,
name: account.name || account.product,
currency: account.currency,
// Default to enabled. The user is presented with a picker
// immediately after this callback to uncheck unwanted accounts
// before any transactions are fetched.
enabled: true,
}))
// Stay in 'pending_selection' until the user confirms which accounts to sync.
// The cron and manual sync routes both skip this status, so no transactions
// can be pulled before the user has had a chance to deselect accounts.
// Do not set last_synced_at here either: no transactions have been fetched
// yet, and setting it would cause the cron's first-sync 90-day backfill
// path to be skipped. The first successful sync sets it.
const { data: updatedConnection, error: updateError } = await supabase
.from('bank_connections')
.update({
session_id,
status: 'pending_selection',
accounts_data: accountsMetadata,
consent_expires: consentExpiresAt,
oauth_state: null, // Clear to prevent replay
})
.eq('id', pendingConnection.id)
.select('id, bank_name, company_id, user_id')
.single()
if (updateError) {
console.error('[enable-banking] Failed to update connection after session creation', {
connectionId: pendingConnection.id,
updateError: { message: updateError.message, code: updateError.code, details: updateError.details },
sessionId: '[REDACTED]',
})
throw new Error(`Failed to update connection: ${updateError.message}`)
}
// Mirror each PSD2 account into cash_accounts so routing decisions read
// from the canonical entity table. Accounts already mirrored (reconnect)
// keep their ledger_account — re-deriving it here would clobber the
// user's remaps. New accounts each get a free BAS class-19 slot: a bank
// returning N same-currency accounts must not collide on the UNIQUE
// (company_id, ledger_account) constraint by all defaulting to 1930.
const { data: mirroredRows } = await supabase
.from('cash_accounts')
.select('external_uid, ledger_account')
.eq('company_id', updatedConnection.company_id)
.eq('bank_connection_id', updatedConnection.id)
const existingLedgerByUid = new Map(
((mirroredRows ?? []) as Array<{ external_uid: string; ledger_account: string }>).map(
(r) => [r.external_uid, r.ledger_account],
),
)
const assignedLedgers = new Set<string>(existingLedgerByUid.values())
let accountsDataDirty = false
for (const account of accountsMetadata) {
let targetLedger = existingLedgerByUid.get(account.uid)
if (!targetLedger) {
targetLedger =
(await allocatePsd2LedgerAccount(supabase, updatedConnection.company_id, updatedConnection.user_id, {
currency: account.currency,
accountName: account.name,
exclude: assignedLedgers,
})) ?? defaultLedgerForCurrency(account.currency)
}
assignedLedgers.add(targetLedger)
if (account.ledger_account !== targetLedger) {
account.ledger_account = targetLedger
accountsDataDirty = true
}
try {
await upsertFromPsd2(supabase, updatedConnection.company_id, {
bank_connection_id: updatedConnection.id,
external_uid: account.uid,
currency: account.currency,
ledger_account: targetLedger,
iban: account.iban ?? null,
name: account.name ?? null,
enabled: account.enabled ?? true,
})
} catch (cashErr) {
const reason = cashErr instanceof Error ? cashErr.message : String(cashErr)
console.error('[enable-banking] Failed to mirror cash_account on callback', {
connectionId: updatedConnection.id,
uid: account.uid,
error: reason,
})
// Persist the failure to event_log so a security review can see that
// a PSD2 account returned by the bank was not mirrored into our
// routing table; otherwise this is only visible in console output
// (ASVS V16 / ISO 27001 A.8.15 / SOC 2 CC7.2).
try {
await eventBus.emit({
type: 'bank_connection.cash_account_mirror_failed',
payload: {
connectionId: updatedConnection.id,
bankName: updatedConnection.bank_name ?? null,
accountUid: account.uid,
ledgerAccount: targetLedger,
currency: account.currency,
reason,
userId: updatedConnection.user_id,
companyId: updatedConnection.company_id,
},
})
} catch (emitError) {
console.error('[enable-banking] Failed to emit cash_account_mirror_failed event', {
connectionId: updatedConnection.id,
error: emitError instanceof Error ? emitError.message : String(emitError),
})
}
}
}
// Persist the allocated ledgers into accounts_data so the AccountPicker
// pre-fills the actual assignments instead of colliding currency
// defaults. Non-fatal: cash_accounts is the routing source of truth.
if (accountsDataDirty) {
const { error: accountsDataError } = await supabase
.from('bank_connections')
.update({ accounts_data: accountsMetadata })
.eq('id', updatedConnection.id)
if (accountsDataError) {
console.warn('[enable-banking] Failed to persist allocated ledgers to accounts_data', {
connectionId: updatedConnection.id,
error: accountsDataError.message,
})
}
}
// Audit trail: PSD2 consent has been exchanged and account metadata stored.
// ASVS V16 requires this transition to be logged as a security event; emit
// here so the event_log handler persists it (30-day TTL).
try {
await eventBus.emit({
type: 'bank_connection.consent_granted',
payload: {
connectionId: updatedConnection.id,
bankName: updatedConnection.bank_name ?? null,
accountCount: accounts.length,
consentExpiresAt: consentExpiresAt ?? null,
userId: updatedConnection.user_id,
companyId: updatedConnection.company_id,
},
})
} catch (emitError) {
// Non-fatal: redirect the user even if the audit event fails. Sentry
// surfaces the error; the underlying DB write (the source of truth for
// the connection state) has already succeeded.
console.error('[enable-banking] Failed to emit consent_granted event', {
connectionId: updatedConnection.id,
error: emitError instanceof Error ? emitError.message : String(emitError),
})
}
const connectionId = updatedConnection.id
const redirectTarget = `/settings/banking?select_accounts=${connectionId}`
return NextResponse.redirect(`${baseUrl}${redirectTarget}`)
} catch (error) {
console.error('[enable-banking] Callback error', {
message: error instanceof Error ? error.message : String(error),
stack: error instanceof Error ? error.stack : undefined,
name: error instanceof Error ? error.name : undefined,
if (findError || !pendingConnection) {
console.error('[enable-banking] No pending connection for oauth_state', {
findError: findError ? { message: findError.message, code: findError.code, details: findError.details } : null,
state,
hasCode: !!code,
})
try {
await supabase
.from('bank_connections')
.update({ status: 'error', error_message: error instanceof Error ? error.message : 'Connection failed', oauth_state: null })
.eq('oauth_state', state)
.in('status', ['pending', 'expired', 'error'])
} catch (cleanupError) {
console.error('[enable-banking] Callback cleanup failed', {
cleanupError: cleanupError instanceof Error ? cleanupError.message : String(cleanupError),
})
}
return NextResponse.redirect(
`${baseUrl}/settings/banking?bank_error=${encodeURIComponent('Connection failed')}`
`${baseUrl}/settings/banking?bank_error=${encodeURIComponent('invalid_state')}`
)
}
// Kick the finalize work off eagerly, decoupled from the response stream:
// if the user closes the tab mid-stream, the stream is cancelled but this
// promise keeps running, so the session persistence, cash-account mirror
// and consent_granted audit emit are not lost (ASVS V16). Never rejects:
// failures resolve to the cleanup redirect target.
const finalizePromise = (async (): Promise<string> => {
try {
return await finalizeConnection(supabase, pendingConnection, code)
} catch (finalizeError) {
console.error('[enable-banking] Callback error', {
message: finalizeError instanceof Error ? finalizeError.message : String(finalizeError),
stack: finalizeError instanceof Error ? finalizeError.stack : undefined,
name: finalizeError instanceof Error ? finalizeError.name : undefined,
state,
connectionId: pendingConnection.id,
})
return cleanupFailedFinalize(supabase, pendingConnection)
}
})()
// Keep the serverless function alive until the finalize work settles even
// if the client disconnects and the platform considers the response done.
try {
after(() => finalizePromise.then(() => undefined))
} catch {
// Outside a request scope (unit tests, plain node server): the stream's
// own await below still drives the promise to completion.
}
// Per-request CSP nonce for the two inline scripts on the finalize page
// (ASVS V3.3): mirrors the mcp-oauth consent page. The global next.config
// CSP also applies; the intersection means inline scripts on THIS response
// must carry the nonce.
const cspNonce = randomBytes(16).toString('base64')
const csp = [
"default-src 'none'",
`script-src 'nonce-${cspNonce}'`,
"style-src 'unsafe-inline'",
"base-uri 'none'",
"form-action 'self'",
"frame-ancestors 'none'",
].join('; ')
// Stream: flush the branded "Slutför bankanslutningen" shell immediately,
// await the finalize work, then stream a client-side redirect to the
// outcome URL. The user sees progress from the first byte instead of a
// blank tab.
const encoder = new TextEncoder()
const stream = new ReadableStream<Uint8Array>({
async start(controller) {
controller.enqueue(encoder.encode(renderFinalizeShell(pendingConnection.bank_name, cspNonce)))
const targetPath = await finalizePromise
try {
controller.enqueue(encoder.encode(renderFinalizeRedirect(`${baseUrl}${targetPath}`, cspNonce)))
controller.close()
} catch {
// Stream already cancelled (client closed the tab). The finalize
// work above completed regardless; there is just no one to redirect.
}
},
})
return new Response(stream, {
headers: {
'Content-Type': 'text/html; charset=utf-8',
'Content-Security-Policy': csp,
// The body carries a one-time OAuth outcome: never cache, never buffer
// (X-Accel-Buffering opts out of proxy buffering so the shell chunk
// actually reaches the browser before the work finishes).
'Cache-Control': 'no-store',
'X-Accel-Buffering': 'no',
},
})
}
/**
* The slow part of the callback: exchange the authorization code for a PSD2
* session, persist the account metadata, mirror accounts into cash_accounts,
* and emit the audit event. Returns the app-relative redirect target.
* Extracted so the route can run it behind the streamed progress page.
*/
async function finalizeConnection(
supabase: ServiceClient,
pendingConnection: PendingConnection,
code: string,
): Promise<string> {
const userId = pendingConnection.user_id
console.log('[enable-banking] Exchanging code for session', {
connectionId: pendingConnection.id,
userId,
codeLength: code.length,
})
const sessionData = await createSession(code)
const { session_id, accounts, access } = sessionData
const consentExpiresAt = access.valid_until
console.log('[enable-banking] Session created successfully', {
connectionId: pendingConnection.id,
sessionId: '[REDACTED]',
accountCount: accounts.length,
consentExpiresAt,
})
// GDPR Art.5(1)(c) / Art.25(1): data minimization. We only store the
// metadata the user needs to pick which accounts to sync (uid, name, IBAN,
// currency). Balances are bank account financial data: we don't fetch
// them here. The first sync (after the user enables specific accounts)
// populates balance + balance_updated_at via lib/sync.ts. Accounts the
// user deselects never have their balance pulled.
const accountsMetadata: StoredAccount[] = accounts.map((account: AccountInfo) => ({
uid: account.uid,
iban: account.account_id?.iban,
name: account.name || account.product,
currency: account.currency,
// Default to enabled. The user is presented with a picker
// immediately after this callback to uncheck unwanted accounts
// before any transactions are fetched.
enabled: true,
}))
// Stay in 'pending_selection' until the user confirms which accounts to sync.
// The cron and manual sync routes both skip this status, so no transactions
// can be pulled before the user has had a chance to deselect accounts.
// Do not set last_synced_at here either: no transactions have been fetched
// yet, and setting it would cause the cron's first-sync 90-day backfill
// path to be skipped. The first successful sync sets it.
const { data: updatedConnection, error: updateError } = await supabase
.from('bank_connections')
.update({
session_id,
status: 'pending_selection',
accounts_data: accountsMetadata,
consent_expires: consentExpiresAt,
oauth_state: null, // Clear to prevent replay
})
.eq('id', pendingConnection.id)
.select('id, bank_name, company_id, user_id')
.single()
if (updateError) {
console.error('[enable-banking] Failed to update connection after session creation', {
connectionId: pendingConnection.id,
updateError: { message: updateError.message, code: updateError.code, details: updateError.details },
sessionId: '[REDACTED]',
})
throw new Error(`Failed to update connection: ${updateError.message}`)
}
// Mirror each PSD2 account into cash_accounts so routing decisions read
// from the canonical entity table. Accounts already mirrored (reconnect)
// keep their ledger_account — re-deriving it here would clobber the
// user's remaps. New accounts each get a free BAS class-19 slot: a bank
// returning N same-currency accounts must not collide on the UNIQUE
// (company_id, ledger_account) constraint by all defaulting to 1930.
const { data: mirroredRows } = await supabase
.from('cash_accounts')
.select('external_uid, ledger_account')
.eq('company_id', updatedConnection.company_id)
.eq('bank_connection_id', updatedConnection.id)
const existingLedgerByUid = new Map(
((mirroredRows ?? []) as Array<{ external_uid: string; ledger_account: string }>).map(
(r) => [r.external_uid, r.ledger_account],
),
)
const assignedLedgers = new Set<string>(existingLedgerByUid.values())
let accountsDataDirty = false
for (const account of accountsMetadata) {
let targetLedger = existingLedgerByUid.get(account.uid)
if (!targetLedger) {
targetLedger =
(await allocatePsd2LedgerAccount(supabase, updatedConnection.company_id, updatedConnection.user_id, {
currency: account.currency,
accountName: account.name,
exclude: assignedLedgers,
})) ?? defaultLedgerForCurrency(account.currency)
}
assignedLedgers.add(targetLedger)
if (account.ledger_account !== targetLedger) {
account.ledger_account = targetLedger
accountsDataDirty = true
}
try {
await upsertFromPsd2(supabase, updatedConnection.company_id, {
bank_connection_id: updatedConnection.id,
external_uid: account.uid,
currency: account.currency,
ledger_account: targetLedger,
iban: account.iban ?? null,
name: account.name ?? null,
enabled: account.enabled ?? true,
})
} catch (cashErr) {
const reason = cashErr instanceof Error ? cashErr.message : String(cashErr)
console.error('[enable-banking] Failed to mirror cash_account on callback', {
connectionId: updatedConnection.id,
uid: account.uid,
error: reason,
})
// Persist the failure to event_log so a security review can see that
// a PSD2 account returned by the bank was not mirrored into our
// routing table; otherwise this is only visible in console output
// (ASVS V16 / ISO 27001 A.8.15 / SOC 2 CC7.2).
try {
await eventBus.emit({
type: 'bank_connection.cash_account_mirror_failed',
payload: {
connectionId: updatedConnection.id,
bankName: updatedConnection.bank_name ?? null,
accountUid: account.uid,
ledgerAccount: targetLedger,
currency: account.currency,
reason,
userId: updatedConnection.user_id,
companyId: updatedConnection.company_id,
},
})
} catch (emitError) {
// A.8.15: structured error (not bare console) so log-based alerting
// catches a dropped security event instead of it vanishing silently.
log.error(AUDIT_EMIT_FAILED, emitError as Error, {
eventType: 'bank_connection.cash_account_mirror_failed',
connectionId: updatedConnection.id,
accountUid: account.uid,
})
}
}
}
// Persist the allocated ledgers into accounts_data so the AccountPicker
// pre-fills the actual assignments instead of colliding currency
// defaults. Non-fatal: cash_accounts is the routing source of truth.
if (accountsDataDirty) {
const { error: accountsDataError } = await supabase
.from('bank_connections')
.update({ accounts_data: accountsMetadata })
.eq('id', updatedConnection.id)
if (accountsDataError) {
console.warn('[enable-banking] Failed to persist allocated ledgers to accounts_data', {
connectionId: updatedConnection.id,
error: accountsDataError.message,
})
}
}
// Audit trail: PSD2 consent has been exchanged and account metadata stored.
// ASVS V16 requires this transition to be logged as a security event; emit
// here so the event_log handler persists it (30-day TTL).
try {
await eventBus.emit({
type: 'bank_connection.consent_granted',
payload: {
connectionId: updatedConnection.id,
bankName: updatedConnection.bank_name ?? null,
accountCount: accounts.length,
consentExpiresAt: consentExpiresAt ?? null,
userId: updatedConnection.user_id,
companyId: updatedConnection.company_id,
},
})
} catch (emitError) {
// Non-fatal: redirect the user even if the audit event fails. The
// structured error record is the alerting channel (A.8.15): production
// log monitoring keys on the stable message. The underlying DB write
// (the source of truth for the connection state) has already succeeded.
log.error(AUDIT_EMIT_FAILED, emitError as Error, {
eventType: 'bank_connection.consent_granted',
connectionId: updatedConnection.id,
})
}
return `/settings/banking?select_accounts=${updatedConnection.id}`
}
/**
* Failure cleanup after finalizeConnection threw. A fresh connect (prior
* status 'pending') never became a connection: delete the row so it can't
* linger as a zombie "Åtgärd krävs" card next to a successful retry. A
* reconnect row (established connection) is kept and marked 'error' so the
* user retains the renew affordance. Returns the error redirect target.
*/
async function cleanupFailedFinalize(
supabase: ServiceClient,
pendingConnection: PendingConnection,
): Promise<string> {
try {
if (pendingConnection.status === 'pending') {
await supabase
.from('bank_connections')
.delete()
.eq('id', pendingConnection.id)
.eq('status', 'pending')
} else {
await supabase
.from('bank_connections')
.update({ status: 'error', error_message: FINALIZE_FAILED_MESSAGE, oauth_state: null })
.eq('id', pendingConnection.id)
.in('status', ['pending', 'expired', 'error'])
}
} catch (cleanupError) {
console.error('[enable-banking] Callback cleanup failed', {
cleanupError: cleanupError instanceof Error ? cleanupError.message : String(cleanupError),
})
}
const params = new URLSearchParams({
bank_error: FINALIZE_FAILED_MESSAGE,
...(pendingConnection.bank_name ? { bank_name: pendingConnection.bank_name } : {}),
})
return `/settings/banking?${params.toString()}`
}
@@ -475,6 +475,51 @@ describe('POST /api/invoices/[id]/mark-paid', () => {
expect(body.paid_at).toBeNull()
})
it('accepts an öresavrundning overshoot: rounded "Att betala" settles the invoice in full', async () => {
// Invoice stored with öre (1234.75), PDF shows the rounded 1235.00 and the
// customer pays that: the 3740 line carries the 0.25 residual. No customer
// → duplicate guard skips.
const invoice = makeInvoice({
id: 'inv-1',
status: 'sent',
total: 1234.75,
remaining_amount: 1234.75,
})
enqueue({ data: invoice, error: null })
enqueue({ data: { accounting_method: 'accrual', entity_type: 'enskild_firma' }, error: null })
enqueue({ data: [{ id: 'inv-1' }], error: null }) // CAS update matched
mockFindFiscalPeriod.mockResolvedValue('fp-1')
mockCreateJournalEntry.mockResolvedValue({ id: 'je-ore' })
const oreLines = [
{ account_number: '1930', debit_amount: 1235, credit_amount: 0 },
{ account_number: '1510', debit_amount: 0, credit_amount: 1234.75 },
{ account_number: '3740', debit_amount: 0, credit_amount: 0.25 },
]
const request = createMockRequest('/api/invoices/inv-1/mark-paid', {
method: 'POST',
body: { lines: oreLines },
})
const response = await POST(request, createMockRouteParams({ id: 'inv-1' }))
const { status, body } = await parseJsonResponse<{
success: boolean
status: string
paid_amount: number
remaining_amount: number
journal_entry_id: string
}>(response)
expect(status).toBe(200)
expect(body.success).toBe(true)
expect(body.status).toBe('paid')
expect(body.paid_amount).toBe(1234.75)
expect(body.remaining_amount).toBe(0)
expect(body.journal_entry_id).toBe('je-ore')
})
it('returns 400 MATCH_AMOUNT_EXCEEDS_REMAINING when custom lines overpay the invoice', async () => {
// No customer → duplicate guard skips; the overpayment guard must reject
// BEFORE any journal entry is created (planInvoicePayment runs first).
@@ -164,6 +164,84 @@ describe('POST /api/invoices/[id]/send', () => {
expect((body.error as unknown as { code: string }).code).toBe('INVOICE_SEND_CANCELLED')
})
it.each(['sent', 'paid', 'overdue', 'partially_paid', 'credited'] as const)(
'returns 409 and posts no journal entry when invoice status is %s',
async (issuedStatus) => {
const issuedInvoice = makeInvoice({
id: 'inv-1',
status: issuedStatus,
customer,
items: [],
})
enqueue({ data: issuedInvoice, error: null })
const request = createMockRequest('/api/invoices/inv-1/send', { method: 'POST' })
const response = await POST(request, createMockRouteParams({ id: 'inv-1' }))
const { status, body } = await parseJsonResponse<{ error: string }>(response)
expect(status).toBe(409)
expect((body.error as unknown as { code: string }).code).toBe('INVOICE_ALREADY_SENT')
expect(mockSendEmail).not.toHaveBeenCalled()
expect(mockCreateInvoiceJournalEntry).not.toHaveBeenCalled()
},
)
it('skips journal entry, archive and event when a concurrent request won the status flip', async () => {
enqueue({ data: invoice, error: null })
enqueue({ data: company, error: null })
mockSendEmail.mockResolvedValue({ success: true, messageId: 'msg-race' })
// Optimistic-locked flip matches 0 rows: another request already sent it.
enqueue({ data: [], error: null })
const emitSpy = vi.spyOn(eventBus, 'emit')
const request = createMockRequest('/api/invoices/inv-1/send', { method: 'POST' })
const response = await POST(request, createMockRouteParams({ id: 'inv-1' }))
const { status, body } = await parseJsonResponse<{
success: boolean
partial?: boolean
partial_failures?: Array<{ step: string }>
}>(response)
// The email did go out, so the response is still a (partial) success.
expect(status).toBe(200)
expect(body.success).toBe(true)
expect(body.partial).toBe(true)
expect(body.partial_failures?.some((f) => f.step === 'status_update')).toBe(true)
// The winning request owns the bookkeeping: no second verifikat here.
expect(mockCreateInvoiceJournalEntry).not.toHaveBeenCalled()
expect(emitSpy).not.toHaveBeenCalledWith(
expect.objectContaining({ type: 'invoice.sent' })
)
})
it('defers the journal entry when the status flip errors (row stays draft, retry re-books once)', async () => {
enqueue({ data: invoice, error: null })
enqueue({ data: company, error: null })
mockSendEmail.mockResolvedValue({ success: true, messageId: 'msg-fliperr' })
// Status flip hits a DB error: the invoice remains 'draft'.
enqueue({ data: null, error: { message: 'connection reset' } })
const request = createMockRequest('/api/invoices/inv-1/send', { method: 'POST' })
const response = await POST(request, createMockRouteParams({ id: 'inv-1' }))
const { status, body } = await parseJsonResponse<{
success: boolean
partial?: boolean
partial_failures?: Array<{ step: string; reason: string }>
}>(response)
expect(status).toBe(200)
expect(body.partial).toBe(true)
expect(body.partial_failures?.some((f) => f.step === 'status_update')).toBe(true)
// No entry now: the retry (invoice still draft) runs the full pipeline
// and posts exactly one, instead of this request + the retry posting two.
expect(mockCreateInvoiceJournalEntry).not.toHaveBeenCalled()
})
it('returns 400 when customer has no email', async () => {
const noEmailInvoice = makeInvoice({
id: 'inv-1',
@@ -201,8 +279,8 @@ describe('POST /api/invoices/[id]/send', () => {
mockSendEmail.mockResolvedValue({ success: true, messageId: 'msg-1' })
mockCreateInvoiceJournalEntry.mockResolvedValue({ id: 'je-1' })
// Update invoice status to 'sent'
enqueue({ data: null, error: null })
// Update invoice status to 'sent' (optimistic lock: returns the matched row)
enqueue({ data: [{ id: 'inv-1' }], error: null })
// Update invoice with journal_entry_id
enqueue({ data: null, error: null })
@@ -244,7 +322,7 @@ describe('POST /api/invoices/[id]/send', () => {
mockSendEmail.mockResolvedValue({ success: true, messageId: 'msg-2' })
// Update invoice status
enqueue({ data: null, error: null })
enqueue({ data: [{ id: 'inv-1' }], error: null })
const request = createMockRequest('/api/invoices/inv-1/send', { method: 'POST' })
const response = await POST(request, createMockRouteParams({ id: 'inv-1' }))
@@ -263,7 +341,7 @@ describe('POST /api/invoices/[id]/send', () => {
mockCreateInvoiceJournalEntry.mockRejectedValue(new Error('Period locked'))
// Update invoice status
enqueue({ data: null, error: null })
enqueue({ data: [{ id: 'inv-1' }], error: null })
const request = createMockRequest('/api/invoices/inv-1/send', { method: 'POST' })
const response = await POST(request, createMockRouteParams({ id: 'inv-1' }))
@@ -293,7 +371,7 @@ describe('POST /api/invoices/[id]/send', () => {
mockCreateInvoiceJournalEntry.mockResolvedValue({ id: 'je-1' })
// Update status to 'sent'
enqueue({ data: null, error: null })
enqueue({ data: [{ id: 'inv-1' }], error: null })
// Update with journal_entry_id
enqueue({ data: null, error: null })
@@ -325,7 +403,7 @@ describe('POST /api/invoices/[id]/send', () => {
mockSendEmail.mockResolvedValue({ success: true, messageId: 'msg-100' })
mockCreateInvoiceJournalEntry.mockResolvedValue({ id: 'je-2' })
enqueue({ data: null, error: null })
enqueue({ data: [{ id: 'inv-1' }], error: null })
enqueue({ data: null, error: null })
const request = createMockRequest('/api/invoices/inv-1/send', { method: 'POST' })
@@ -388,7 +466,7 @@ describe('POST /api/invoices/[id]/send', () => {
mockSendEmail.mockResolvedValue({ success: true, messageId: 'msg-banner' })
mockCreateInvoiceJournalEntry.mockResolvedValue({ id: 'je-1' })
enqueue({ data: null, error: null })
enqueue({ data: [{ id: 'inv-1' }], error: null })
enqueue({ data: null, error: null })
const request = createMockRequest('/api/invoices/inv-1/send', { method: 'POST' })
+48 -10
View File
@@ -60,13 +60,26 @@ export const POST = withRouteContext(
}
// A cancelled invoice keeps its F-series number for compliance with ML 17
// kap 24§ but is not a valid faktura: sending it would silently
// re-activate it (the .update({ status: 'sent' }) below has no status
// guard) and could deliver a "MAKULERAD" PDF as if it were live.
// kap 24§ but is not a valid faktura: sending it would deliver a
// "MAKULERAD" PDF as if it were live. Checked before the generic draft
// guard below for the more specific error message.
if (invoice.status === 'cancelled') {
return errorResponseFromCode('INVOICE_SEND_CANCELLED', opLog, { requestId })
}
// Only drafts may enter the send pipeline. The UI already hides Send for
// non-drafts, but a direct POST against an issued invoice would re-email
// the customer and post a SECOND revenue verifikat
// (createInvoiceJournalEntry has no dedup), overwriting journal_entry_id
// and orphaning the first entry. Mirrors the v1 route and the MCP commit
// executor, which both reject non-drafts.
if (invoice.status !== 'draft') {
return errorResponseFromCode('INVOICE_ALREADY_SENT', opLog, {
requestId,
details: { currentStatus: invoice.status },
})
}
const customer = invoice.customer as Customer
if (!customer.email) {
return errorResponseFromCode('INVOICE_SEND_NO_CUSTOMER_EMAIL', opLog, {
@@ -223,16 +236,36 @@ export const POST = withRouteContext(
partialFailures.push({ step: 'payment_link', reason: paymentLinkFailure })
}
// Optimistic-locked flip (draft → sent). Two concurrent sends can both
// pass the draft guard above and both email the customer, but only the
// request that wins this compare-and-set runs the bookkeeping steps
// below: the loser would otherwise post a duplicate revenue verifikat
// and archive the PDF twice. PostgREST returns no error for a 0-row
// update, so the row count via .select('id') is the actual lock signal.
// A genuine update error also skips the follow-ups: the row is still
// 'draft', so a later retry re-runs the whole pipeline and ends with
// exactly one journal entry (at the cost of a duplicate email).
let statusFlipped = false
{
const { error: updateError } = await supabase
const { data: flipRows, error: updateError } = await supabase
.from('invoices')
.update({ status: 'sent' })
.eq('id', id)
.eq('company_id', companyId)
.eq('status', 'draft')
.select('id')
if (updateError) {
opLog.warn('failed to update invoice status to sent', updateError)
partialFailures.push({ step: 'status_update', reason: updateError.message })
} else if (!flipRows || flipRows.length === 0) {
opLog.warn('invoice already flipped to sent by a concurrent request; skipping bookkeeping follow-ups')
partialFailures.push({
step: 'status_update',
reason: 'Fakturan skickades samtidigt av en annan begäran; bokföringen hanterades där.',
})
} else {
statusFlipped = true
}
}
@@ -240,7 +273,7 @@ export const POST = withRouteContext(
const accountingMethod = (company as Record<string, unknown>).accounting_method as string | undefined
let createdJournalEntryId: string | undefined
if (isRealInvoice && (!accountingMethod || accountingMethod === 'accrual')) {
if (statusFlipped && isRealInvoice && (!accountingMethod || accountingMethod === 'accrual')) {
try {
const journalEntry = await createInvoiceJournalEntry(
supabase,
@@ -284,7 +317,7 @@ export const POST = withRouteContext(
}
}
if (isRealInvoice) {
if (statusFlipped && isRealInvoice) {
try {
const pdfArrayBuffer = new Uint8Array(pdfBuffer).buffer as ArrayBuffer
await uploadDocument(supabase, user.id, companyId!, {
@@ -304,10 +337,15 @@ export const POST = withRouteContext(
}
}
await eventBus.emit({
type: 'invoice.sent',
payload: { invoice: invoice as Invoice, companyId: companyId!, userId: user.id },
})
// Gated like the steps above: on a lost race the winning request emits
// it; on a flip error the row is still 'draft', so emitting would
// contradict DB state and the retry emits it instead.
if (statusFlipped) {
await eventBus.emit({
type: 'invoice.sent',
payload: { invoice: invoice as Invoice, companyId: companyId!, userId: user.id },
})
}
if (partialFailures.length > 0) {
opLog.warn('invoice sent with partial follow-up failures', {
+10 -1
View File
@@ -3,6 +3,7 @@ import { ensureInitialized } from '@/lib/init'
import { withRouteContext } from '@/lib/api/with-route-context'
import { errorResponse } from '@/lib/errors/get-structured-error'
import { executeRecurringSchedule } from '@/lib/invoices/recurring-schedule-service'
import { isSandboxCompany } from '@/lib/sandbox/guard'
import type { RecurringInvoiceSchedule, RecurringInvoiceScheduleItem } from '@/types'
ensureInitialized()
@@ -46,8 +47,16 @@ export const POST = withRouteContext(
items: RecurringInvoiceScheduleItem[]
}
// Defence in depth (ASVS V2.3): mirror the cron route. The sandbox rule
// is enforced inside the service's email chokepoint too; resolving it at
// the route level as well means the invariant survives refactors of the
// service internals. Invoice creation is unaffected (freeze-and-retain).
const suppressAutoSend = typed.auto_send
? await isSandboxCompany(supabase, companyId)
: false
try {
const result = await executeRecurringSchedule(supabase, typed, new Date())
const result = await executeRecurringSchedule(supabase, typed, new Date(), { suppressAutoSend })
// Record the run for the list view (generated count, last invoice,
// warning) but leave next_run_date untouched: the monthly cadence runs
@@ -3,6 +3,27 @@ import { createQueuedMockSupabase, createMockRequest, parseJsonResponse } from '
const { supabase: mockSupabase, enqueue, reset } = createQueuedMockSupabase()
// The queued mock's proxy chain discards call arguments, but these tests need
// to assert exactly what the cron writes back to the schedule row (claim
// release + failure warning, stale roll-forward warning). Wrap .from() so
// every .update() payload is recorded before delegating to the queue chain.
const updatePayloads: Array<{ table: string; payload: Record<string, unknown> }> = []
const baseFrom = mockSupabase.from.getMockImplementation()!
mockSupabase.from.mockImplementation((table: string) => {
const chain = baseFrom(table) as Record<string, (...args: unknown[]) => unknown>
return new Proxy(chain, {
get(target, prop, receiver) {
if (prop === 'update') {
return (payload: Record<string, unknown>) => {
updatePayloads.push({ table, payload })
return target.update(payload)
}
}
return Reflect.get(target, prop, receiver)
},
})
})
vi.mock('@/lib/supabase/server', () => ({
createServiceClient: () => mockSupabase,
}))
@@ -23,6 +44,12 @@ vi.mock('@/lib/invoices/recurring-schedule-service', async (importActual) => {
}
})
// Route-level sandbox resolution (defence in depth, ASVS V2.3).
const isSandboxCompany = vi.fn()
vi.mock('@/lib/sandbox/guard', () => ({
isSandboxCompany: (...args: unknown[]) => isSandboxCompany(...args),
}))
import { GET } from '../route'
type ResultRow = {
@@ -53,6 +80,7 @@ describe('GET /api/invoices/recurring/cron', () => {
beforeEach(() => {
vi.clearAllMocks()
reset()
updatePayloads.length = 0
vi.useFakeTimers()
})
afterEach(() => {
@@ -77,6 +105,31 @@ describe('GET /api/invoices/recurring/cron', () => {
expect(executeRecurringSchedule).toHaveBeenCalledTimes(1)
expect(body.succeeded).toBe(1)
expect(body.results[0].invoiceId).toBe('inv-1')
// No auto_send on the schedule -> no sandbox lookup, no suppression.
expect(isSandboxCompany).not.toHaveBeenCalled()
expect(executeRecurringSchedule.mock.calls[0][3]).toEqual({ suppressAutoSend: false })
})
it('resolves the sandbox flag at the route level and suppresses auto-send for sandbox companies', async () => {
vi.setSystemTime(new Date('2026-07-06T08:30:00Z'))
enqueue({ data: [makeSchedule({ send_hour: 8, auto_send: true })], error: null })
// Atomic claim wins.
enqueue({ data: [{ id: 's-1' }], error: null })
isSandboxCompany.mockResolvedValue(true)
executeRecurringSchedule.mockResolvedValue({
invoiceId: 'inv-1',
invoiceNumber: 'F-1',
autoSent: false,
warning: 'Auto-utskick misslyckades: fakturan finns som utkast och kan skickas manuellt.',
})
const { status } = await parseJsonResponse<CronBody>(await GET(req()))
expect(status).toBe(200)
// Defence in depth: the route resolved the sandbox state itself and told
// the service explicitly, instead of relying only on the chokepoint
// inside sendInvoiceFromSchedule.
expect(isSandboxCompany).toHaveBeenCalledWith(expect.anything(), 'c-1')
expect(executeRecurringSchedule.mock.calls[0][3]).toEqual({ suppressAutoSend: true })
})
it('skips when a concurrent cron run already claimed the schedule', async () => {
@@ -110,6 +163,46 @@ describe('GET /api/invoices/recurring/cron', () => {
expect(body.results[0].skipReason).toBe('stale_rolled_forward')
})
it('releases the claim AND persists a failure warning when execution throws', async () => {
vi.setSystemTime(new Date('2026-07-06T08:30:00Z'))
enqueue({ data: [makeSchedule({ send_hour: 8 })], error: null })
// Atomic claim wins.
enqueue({ data: [{ id: 's-1' }], error: null })
executeRecurringSchedule.mockRejectedValue(new Error('VAT rate 25% not allowed'))
// Claim release + warning write.
enqueue({ data: null, error: null })
const { body } = await parseJsonResponse<CronBody & { failed: number }>(await GET(req()))
expect(body.failed).toBe(1)
// The release update must restore the pre-claim last_run_at (null here)
// so a later cron retries today, and carry a user-visible warning so a
// deterministic failure never skips the month silently.
const release = updatePayloads.find(
(u) => u.table === 'recurring_invoice_schedules' && 'last_run_warning' in u.payload,
)
expect(release).toBeDefined()
expect(release!.payload.last_run_at).toBeNull()
expect(release!.payload.last_run_warning).toContain('2026-07-06 misslyckades')
expect(release!.payload.last_run_warning).toContain('VAT rate 25% not allowed')
})
it('writes a skip warning when rolling a stale schedule forward', async () => {
vi.setSystemTime(new Date('2026-07-06T08:30:00Z'))
enqueue({ data: [makeSchedule({ next_run_date: '2026-07-05', day_of_month: 5 })], error: null })
// Roll-forward update.
enqueue({ data: null, error: null })
const { body } = await parseJsonResponse<CronBody>(await GET(req()))
expect(body.results[0].skipReason).toBe('stale_rolled_forward')
const roll = updatePayloads.find((u) => u.table === 'recurring_invoice_schedules')
expect(roll).toBeDefined()
expect(roll!.payload.next_run_date).toBe('2026-08-05')
expect(roll!.payload.last_run_warning).toContain('Ingen faktura skapades den 2026-07-05')
expect(roll!.payload.last_run_warning).toContain('2026-08-05')
})
it('skips a schedule that already ran earlier today', async () => {
vi.setSystemTime(new Date('2026-07-06T08:30:00Z'))
enqueue({
+29 -4
View File
@@ -8,6 +8,7 @@ import {
computeInitialRunDate,
getStockholmDateHour,
} from '@/lib/invoices/recurring-schedule-service'
import { isSandboxCompany } from '@/lib/sandbox/guard'
import type {
RecurringInvoiceSchedule,
RecurringInvoiceScheduleItem,
@@ -89,9 +90,16 @@ export const GET = withCronContext('cron.recurring_invoices', async (_request, c
// its next date rather than firing a stale one immediately.
if (schedule.next_run_date < todayStockholm) {
const rolledNext = computeInitialRunDate(stockholmToday, schedule.day_of_month)
// Surface the skip on the schedule: a day of failed runs (or a cron
// outage) would otherwise roll the month forward with no user-visible
// trace. The next successful run overwrites this, and a conscious
// reactivation clears it (PATCH route).
const { error: rollError } = await supabase
.from('recurring_invoice_schedules')
.update({ next_run_date: rolledNext })
.update({
next_run_date: rolledNext,
last_run_warning: `Ingen faktura skapades den ${schedule.next_run_date}. Nästa körning: ${rolledNext}. Använd "Skapa faktura nu" om månadens faktura fortfarande behövs.`,
})
.eq('id', schedule.id)
.eq('company_id', schedule.company_id)
if (rollError) {
@@ -169,14 +177,31 @@ export const GET = withCronContext('cron.recurring_invoices', async (_request, c
// 5. Spawn the invoice. If it throws after we claimed, release the claim
// (restore the prior last_run_at) so a later cron retries today rather
// than treating the row as already run.
// than treating the row as already run, and persist the failure as a
// user-visible warning: a deterministic error (bad VAT rate, missing
// items) fails every hourly retry and would otherwise skip the month
// silently via the stale roll-forward above. A later successful run
// overwrites the warning.
// Defence in depth (ASVS V2.3): the email chokepoint inside the schedule
// service enforces the sandbox rule on its own; the route additionally
// resolves it here and passes an explicit suppress flag, so the invariant
// does not hinge on a single check buried in a library function. The
// invoice is still generated as a draft (freeze-and-retain).
const suppressAutoSend = schedule.auto_send
? await isSandboxCompany(supabase, schedule.company_id)
: false
let result: Awaited<ReturnType<typeof executeRecurringSchedule>>
try {
result = await executeRecurringSchedule(supabase, schedule, now)
result = await executeRecurringSchedule(supabase, schedule, now, { suppressAutoSend })
} catch (err) {
const reason = (err instanceof Error ? err.message : String(err)).slice(0, 300)
await supabase
.from('recurring_invoice_schedules')
.update({ last_run_at: schedule.last_run_at })
.update({
last_run_at: schedule.last_run_at,
last_run_warning: `Körningen ${todayStockholm} misslyckades: ${reason}. Nytt försök görs automatiskt varje timme idag.`,
})
.eq('id', schedule.id)
.eq('company_id', schedule.company_id)
.eq('last_run_at', claimTs)
@@ -67,8 +67,7 @@ describe('POST /api/salary/employees/[id]/absence', () => {
it('upserts an absence day (happy path)', async () => {
enqueue({ data: { id: 'emp-1' } }) // loadEmployee
enqueue({ data: null }) // delete existing
enqueue({ data: { id: 'abs-1', absence_date: '2026-07-01', absence_type: 'sick', hours: 8 } }) // insert
enqueue({ data: { id: 'abs-1', absence_date: '2026-07-01', absence_type: 'sick', hours: 8 } }) // upsert
const response = await POST(post({ absence_date: '2026-07-01', absence_type: 'sick', hours: 8 }), params)
const { status, body } = await parseJsonResponse<{ data: { id: string } }>(response)
+36 -95
View File
@@ -1,5 +1,4 @@
import { z } from 'zod'
import type { SupabaseClient } from '@supabase/supabase-js'
import { NextResponse } from 'next/server'
import { ensureInitialized } from '@/lib/init'
import { withRouteContext } from '@/lib/api/with-route-context'
@@ -9,23 +8,22 @@ import {
AbsenceRangeQuerySchema,
AbsenceTypeSchema,
} from '@/lib/api/schemas'
import {
listAbsenceDays,
upsertAbsenceDay,
deleteAbsenceRange,
} from '@/lib/salary/absence'
import { getErrorEntry } from '@/lib/errors/structured-errors'
const isoDate = z.string().regex(/^\d{4}-\d{2}-\d{2}$/)
ensureInitialized()
async function loadEmployee(
supabase: SupabaseClient,
employeeId: string,
companyId: string,
) {
const { data } = await supabase
.from('employees')
.select('id')
.eq('id', employeeId)
.eq('company_id', companyId)
.maybeSingle()
return data
function errorResponse(code: string, details?: Record<string, unknown>): NextResponse {
const entry = getErrorEntry(code)
const message =
(details?.message as string | undefined) ?? entry?.message_sv ?? 'Något gick fel'
return NextResponse.json({ error: message, code }, { status: entry?.httpStatus ?? 500 })
}
export const GET = withRouteContext<{ params: Promise<{ id: string }> }>(
@@ -33,28 +31,18 @@ export const GET = withRouteContext<{ params: Promise<{ id: string }> }>(
async (request, { supabase, companyId }, { params }) => {
const { id: employeeId } = await params
const employee = await loadEmployee(supabase, employeeId, companyId)
if (!employee) {
return NextResponse.json({ error: 'Anställd hittades inte' }, { status: 404 })
}
const query = validateQuery(request, AbsenceRangeQuerySchema)
if (!query.success) return query.response
const { data, error } = await supabase
.from('salary_absence_days')
.select('id, absence_date, absence_type, hours, notes, salary_run_employee_id, created_at, updated_at')
.eq('company_id', companyId)
.eq('employee_id', employeeId)
.gte('absence_date', query.data.from)
.lte('absence_date', query.data.to)
.order('absence_date', { ascending: true })
const result = await listAbsenceDays(supabase, {
companyId,
employeeId,
from: query.data.from,
to: query.data.to,
})
if (error) {
return NextResponse.json({ error: error.message }, { status: 500 })
}
return NextResponse.json({ data })
if (!result.ok) return errorResponse(result.code, result.details)
return NextResponse.json({ data: result.data })
},
)
@@ -63,56 +51,24 @@ export const POST = withRouteContext<{ params: Promise<{ id: string }> }>(
async (request, { supabase, companyId }, { params }) => {
const { id: employeeId } = await params
const employee = await loadEmployee(supabase, employeeId, companyId)
if (!employee) {
return NextResponse.json({ error: 'Anställd hittades inte' }, { status: 404 })
}
const validation = await validateBody(request, UpsertAbsenceDaySchema)
if (!validation.success) return validation.response
const body = validation.data
// Upsert via DELETE+INSERT on the natural key (employee, date, type) so the
// notes/hours/run-link can be replaced cleanly. The unique index makes ON
// CONFLICT viable too, but Supabase's typed client doesn't expose
// onConflict for our composite key without a named constraint name:
// delete-then-insert keeps the pattern consistent with token-store.ts.
const { error: deleteError } = await supabase
.from('salary_absence_days')
.delete()
.eq('company_id', companyId)
.eq('employee_id', employeeId)
.eq('absence_date', body.absence_date)
.eq('absence_type', body.absence_type)
if (deleteError) {
return NextResponse.json({ error: deleteError.message }, { status: 500 })
}
const { data, error } = await supabase
.from('salary_absence_days')
.insert({
company_id: companyId,
employee_id: employeeId,
const result = await upsertAbsenceDay(supabase, {
companyId,
employeeId,
day: {
absence_date: body.absence_date,
absence_type: body.absence_type,
hours: body.hours,
notes: body.notes ?? null,
salary_run_employee_id: body.salary_run_employee_id ?? null,
})
.select()
.single()
},
})
if (error) {
// The 24h cap trigger raises check_violation when worked + absence > 24h
// for the same date. Surface a clean 409 with the Swedish message.
if (error.message?.includes('Total tid') || error.code === '23514') {
return NextResponse.json({ error: error.message }, { status: 409 })
}
return NextResponse.json({ error: error.message }, { status: 500 })
}
return NextResponse.json({ data }, { status: 201 })
if (!result.ok) return errorResponse(result.code, result.details)
return NextResponse.json({ data: result.data }, { status: 201 })
},
{ requireWrite: true },
)
@@ -132,11 +88,6 @@ export const DELETE = withRouteContext<{ params: Promise<{ id: string }> }>(
async (request, { supabase, companyId }, { params }) => {
const { id: employeeId } = await params
const employee = await loadEmployee(supabase, employeeId, companyId)
if (!employee) {
return NextResponse.json({ error: 'Anställd hittades inte' }, { status: 404 })
}
const query = validateQuery(request, DeleteQuerySchema)
if (!query.success) return query.response
const { date, type, from, to } = query.data
@@ -151,26 +102,16 @@ export const DELETE = withRouteContext<{ params: Promise<{ id: string }> }>(
)
}
let q = supabase
.from('salary_absence_days')
.delete()
.eq('company_id', companyId)
.eq('employee_id', employeeId)
const result = await deleteAbsenceRange(supabase, {
companyId,
employeeId,
from: hasSingle ? date! : from!,
to: hasSingle ? date! : to!,
absenceType: type,
})
if (hasSingle) {
q = q.eq('absence_date', date!)
if (type) q = q.eq('absence_type', type)
} else {
q = q.gte('absence_date', from!).lte('absence_date', to!)
if (type) q = q.eq('absence_type', type)
}
const { error } = await q
if (error) {
return NextResponse.json({ error: error.message }, { status: 500 })
}
return NextResponse.json({ data: { ok: true } })
if (!result.ok) return errorResponse(result.code, result.details)
return NextResponse.json({ data: { ok: true, deleted_count: result.data.deleted_count } })
},
{ requireWrite: true },
)
@@ -0,0 +1,56 @@
import { NextResponse } from 'next/server'
import { ensureInitialized } from '@/lib/init'
import { withRouteContext } from '@/lib/api/with-route-context'
import { validateBody } from '@/lib/api/validate'
import { OpeningBalancesFieldsSchema } from '@/lib/api/schemas'
import { getOpeningBalances, setOpeningBalancesBulk } from '@/lib/salary/opening-balances'
import { getErrorEntry } from '@/lib/errors/structured-errors'
ensureInitialized()
function errorResponse(code: string, message?: string): NextResponse {
const entry = getErrorEntry(code)
return NextResponse.json(
{ error: message ?? entry?.message_sv ?? 'Något gick fel', code },
{ status: entry?.httpStatus ?? 500 },
)
}
/** Cutover opening balances for one employee (dashboard surface; the v1
* routes and the MCP tool share the same lib/salary/opening-balances
* service). Returns { data: null } when nothing is set: the form treats
* that as an empty editable state, unlike v1's 404. */
export const GET = withRouteContext<{ params: Promise<{ id: string }> }>(
'salary.employees.opening-balances.get',
async (_request, { supabase, companyId }, { params }) => {
const { id: employeeId } = await params
const result = await getOpeningBalances(supabase, { companyId, employeeId })
if (!result.ok) return errorResponse(result.code)
return NextResponse.json({ data: result.data })
},
)
export const PUT = withRouteContext<{ params: Promise<{ id: string }> }>(
'salary.employees.opening-balances.set',
async (request, { supabase, companyId, user }, { params }) => {
const { id: employeeId } = await params
const validation = await validateBody(request, OpeningBalancesFieldsSchema)
if (!validation.success) return validation.response
const result = await setOpeningBalancesBulk(supabase, {
companyId,
userId: user.id,
items: [{ employee_id: employeeId, ...validation.data }],
})
if (!result.ok) {
const itemError = result.itemErrors?.[0]
return errorResponse(itemError?.code ?? result.code, itemError?.message)
}
return NextResponse.json({ data: result.data.rows[0] })
},
{ requireWrite: true },
)
+5
View File
@@ -74,6 +74,8 @@ export const POST = withRouteContext('salary.employees.create', async (request,
employment_start: body.employment_start,
employment_end: body.employment_end || null,
employment_degree: body.employment_degree,
hours_per_week: body.hours_per_week,
workdays_per_week: body.workdays_per_week,
salary_type: body.salary_type,
monthly_salary: body.monthly_salary || null,
hourly_rate: body.hourly_rate || null,
@@ -95,6 +97,9 @@ export const POST = withRouteContext('salary.employees.create', async (request,
vaxa_stod_eligible: body.vaxa_stod_eligible,
vaxa_stod_start: body.vaxa_stod_start || null,
vaxa_stod_end: body.vaxa_stod_end || null,
jamkning_percentage: body.jamkning_percentage ?? null,
jamkning_valid_from: body.jamkning_valid_from ?? null,
jamkning_valid_to: body.jamkning_valid_to ?? null,
// Dimensions PR8: bag for the employee's P&L cost lines at booking.
default_dimensions: body.default_dimensions ?? {},
})
+22
View File
@@ -1,6 +1,7 @@
import { NextResponse } from 'next/server'
import { ensureInitialized } from '@/lib/init'
import { createSalaryRunEntries } from '@/lib/salary/salary-entries'
import { syncVacationLedgerForEmployees } from '@/lib/salary/vacation-ledger'
import { eventBus } from '@/lib/events'
import { withRouteContext } from '@/lib/api/with-route-context'
import { errorResponse, errorResponseFromCode } from '@/lib/errors/get-structured-error'
@@ -74,6 +75,17 @@ export const POST = withRouteContext(
payload: { salaryRunId: id, entryIds: [], userId: user.id, companyId: companyId! },
})
// Vacation ledger sync (non-fatal: the ledger recomputes and self-heals
// on the next booking; a sync bug must never block a booking).
const nollSync = await syncVacationLedgerForEmployees(
supabase,
companyId!,
roster.map((sre) => sre.employee_id),
)
if (!nollSync.ok) {
opLog.warn('vacation ledger sync failed after nollkörning booking', { message: nollSync.message })
}
opLog.info('salary run booked as nollkörning (no journal entries)', { salaryRunId: id })
return NextResponse.json({ data: bookedRun })
@@ -154,6 +166,16 @@ export const POST = withRouteContext(
payload: { salaryRunId: id, entryIds, userId: user.id, companyId: companyId! },
})
// Vacation ledger sync (non-fatal, see the nollkörning branch).
const ledgerSync = await syncVacationLedgerForEmployees(
supabase,
companyId!,
roster.map((sre) => sre.employee_id),
)
if (!ledgerSync.ok) {
opLog.warn('vacation ledger sync failed after booking', { message: ledgerSync.message })
}
return NextResponse.json({ data: bookedRun })
} catch (err) {
if (isBookkeepingError(err)) {
+14 -1
View File
@@ -4,6 +4,7 @@ import { withRouteContext } from '@/lib/api/with-route-context'
import { reverseEntry } from '@/lib/bookkeeping/engine'
import { bookkeepingErrorResponse, EntryAlreadyReversedError } from '@/lib/bookkeeping/errors'
import { revokeLinksForRun } from '@/lib/salary/payslips/links'
import { syncVacationLedgerForEmployees } from '@/lib/salary/vacation-ledger'
ensureInitialized()
@@ -26,7 +27,7 @@ export const POST = withRouteContext<{ params: Promise<{ id: string }> }>(
'salary.run.correct',
async (_request, ctx, { params }) => {
const { id } = await params
const { user, supabase, companyId } = ctx
const { user, supabase, companyId, log } = ctx
// Load the original booked run
const { data: originalRun, error: runError } = await supabase
@@ -156,6 +157,18 @@ export const POST = withRouteContext<{ params: Promise<{ id: string }> }>(
}
}
// Vacation ledger sync: the original run flipped to 'corrected', so its
// vacation_days_taken drop out of the recomputed taken_days. Non-fatal:
// the ledger self-heals when the correction run books.
const ledgerSync = await syncVacationLedgerForEmployees(
supabase,
companyId,
(originalEmployees || []).map((sre) => sre.employee_id as string),
)
if (!ledgerSync.ok) {
log.warn('vacation ledger sync failed after correction', { message: ledgerSync.message })
}
return NextResponse.json({
data: correctionRun,
message: 'Korrigeringskörning skapad. Originalverifikationer har makulerats (storno). Redigera och beräkna om den nya körningen.',
@@ -4,6 +4,8 @@ import { withRouteContext } from '@/lib/api/with-route-context'
import { validateBody } from '@/lib/api/validate'
import { SalaryEmployeeOverrideSchema } from '@/lib/api/schemas'
import { decryptPersonnummer, maskPersonnummer } from '@/lib/salary/personnummer'
import { removeEmployeeFromRun } from '@/lib/salary/run-employees'
import { getErrorEntry } from '@/lib/errors/structured-errors'
ensureInitialized()
@@ -189,27 +191,18 @@ export const DELETE = withRouteContext<{ params: Promise<{ id: string; employeeI
const { id, employeeId } = await params
const { supabase, companyId } = ctx
// Verify run is draft
const { data: run } = await supabase
.from('salary_runs')
.select('id, status')
.eq('id', id)
.eq('company_id', companyId)
.single()
const result = await removeEmployeeFromRun(supabase, {
companyId,
salaryRunId: id,
employeeId,
})
if (!run) return NextResponse.json({ error: 'Lönekörning hittades inte' }, { status: 404 })
if (run.status !== 'draft') return NextResponse.json({ error: 'Kan bara redigera utkast' }, { status: 400 })
// Delete the salary_run_employee (cascades to salary_line_items via ON DELETE CASCADE)
const { error } = await supabase
.from('salary_run_employees')
.delete()
.eq('salary_run_id', id)
.eq('employee_id', employeeId)
.eq('company_id', companyId)
if (error) {
return NextResponse.json({ error: error.message }, { status: 500 })
if (!result.ok) {
const entry = getErrorEntry(result.code)
return NextResponse.json(
{ error: entry?.message_sv ?? 'Något gick fel', code: result.code },
{ status: entry?.httpStatus ?? 500 },
)
}
return NextResponse.json({ data: { deleted: true } })
+15 -87
View File
@@ -3,8 +3,8 @@ import { ensureInitialized } from '@/lib/init'
import { withRouteContext } from '@/lib/api/with-route-context'
import { validateBody } from '@/lib/api/validate'
import { AddEmployeeToRunSchema } from '@/lib/api/schemas'
import { getLineItemAccount } from '@/lib/salary/account-mapping'
import type { SalaryLineItemType } from '@/types'
import { addEmployeeToRun } from '@/lib/salary/run-employees'
import { getErrorEntry } from '@/lib/errors/structured-errors'
ensureInitialized()
@@ -18,94 +18,22 @@ export const POST = withRouteContext<{ params: Promise<{ id: string }> }>(
if (!validation.success) return validation.response
const body = validation.data
// Verify run is draft
const { data: run, error: runError } = await supabase
.from('salary_runs')
.select('id, status')
.eq('id', id)
.eq('company_id', companyId)
.single()
const result = await addEmployeeToRun(supabase, {
companyId,
salaryRunId: id,
employeeId: body.employee_id,
hoursWorked: body.hours_worked ?? null,
})
if (runError || !run) {
return NextResponse.json({ error: 'Lönekörning hittades inte' }, { status: 404 })
}
if (run.status !== 'draft') {
return NextResponse.json({ error: 'Kan bara lägga till anställda i utkast' }, { status: 400 })
if (!result.ok) {
const entry = getErrorEntry(result.code)
return NextResponse.json(
{ error: entry?.message_sv ?? 'Något gick fel', code: result.code },
{ status: entry?.httpStatus ?? 500 },
)
}
// Verify employee exists and is active
const { data: employee, error: empError } = await supabase
.from('employees')
.select('*')
.eq('id', body.employee_id)
.eq('company_id', companyId)
.eq('is_active', true)
.single()
if (empError || !employee) {
return NextResponse.json({ error: 'Anställd hittades inte' }, { status: 404 })
}
// Check if already added
const { data: existing } = await supabase
.from('salary_run_employees')
.select('id')
.eq('salary_run_id', id)
.eq('employee_id', body.employee_id)
.single()
if (existing) {
return NextResponse.json({ error: 'Anställd redan tillagd i denna lönekörning' }, { status: 409 })
}
// Snapshot employee data
const { data: sre, error: sreError } = await supabase
.from('salary_run_employees')
.insert({
salary_run_id: id,
employee_id: employee.id,
company_id: companyId,
employment_degree: employee.employment_degree,
monthly_salary: employee.monthly_salary || 0,
salary_type: employee.salary_type,
hours_worked: body.hours_worked || null,
tax_table_number: employee.tax_table_number,
tax_column: employee.tax_column,
})
.select()
.single()
if (sreError) {
return NextResponse.json({ error: sreError.message }, { status: 500 })
}
// Auto-create base salary line item
const baseSalaryType: SalaryLineItemType = employee.salary_type === 'monthly' ? 'monthly_salary' : 'hourly_salary'
let baseAmount: number
if (employee.salary_type === 'monthly') {
baseAmount = Math.round((employee.monthly_salary || 0) * (employee.employment_degree / 100) * 100) / 100
} else {
baseAmount = Math.round((employee.hourly_rate || 0) * (body.hours_worked || 0) * 100) / 100
}
await supabase
.from('salary_line_items')
.insert({
salary_run_employee_id: sre.id,
company_id: companyId,
item_type: baseSalaryType,
description: employee.salary_type === 'monthly' ? 'Grundlön' : 'Timlön',
quantity: employee.salary_type === 'hourly' ? body.hours_worked : null,
unit_price: employee.salary_type === 'hourly' ? employee.hourly_rate : null,
amount: baseAmount,
is_taxable: true,
is_avgift_basis: true,
is_vacation_basis: true,
account_number: getLineItemAccount(baseSalaryType, employee.employment_type),
sort_order: 0,
})
return NextResponse.json({ data: sre }, { status: 201 })
return NextResponse.json({ data: result.data }, { status: 201 })
},
{ requireWrite: true },
)
@@ -71,6 +71,9 @@ describe('PATCH /api/salary/runs/[id]/lines/[lineId]', () => {
const { enqueueMany } = authed()
enqueueMany([
{ data: { id: 'run-1', status: 'draft' } }, // salary_runs lookup
// The shared service verifies the line belongs to this run before
// writing (loadLineInRun join check).
{ data: { id: 'line-1', amount: 50, salary_run_employee: { salary_run_id: 'run-1' } } },
{ data: { id: 'line-1', amount: 100 } }, // update returning
])
const response = await PATCH(
@@ -119,6 +122,8 @@ describe('DELETE /api/salary/runs/[id]/lines/[lineId]', () => {
const { enqueueMany } = authed()
enqueueMany([
{ data: { id: 'run-1', status: 'draft' } }, // salary_runs lookup
// Run-membership verification added by the shared service.
{ data: { id: 'line-1', amount: 50, salary_run_employee: { salary_run_id: 'run-1' } } },
{ data: null }, // delete (error null)
])
const response = await DELETE(
@@ -3,49 +3,37 @@ import { ensureInitialized } from '@/lib/init'
import { withRouteContext } from '@/lib/api/with-route-context'
import { validateBody } from '@/lib/api/validate'
import { UpdateSalaryLineItemSchema } from '@/lib/api/schemas'
import { updatePayslipLine, deletePayslipLine } from '@/lib/salary/payslip-lines'
import { getErrorEntry } from '@/lib/errors/structured-errors'
ensureInitialized()
function errorResponse(code: string): NextResponse {
const entry = getErrorEntry(code)
return NextResponse.json(
{ error: entry?.message_sv ?? 'Något gick fel', code },
{ status: entry?.httpStatus ?? 500 },
)
}
export const PATCH = withRouteContext<{ params: Promise<{ id: string; lineId: string }> }>(
'salary.run.line.update',
async (request, ctx, { params }) => {
const { id, lineId } = await params
const { supabase, companyId } = ctx
// Verify run is draft
const { data: run } = await supabase
.from('salary_runs')
.select('id, status')
.eq('id', id)
.eq('company_id', companyId)
.single()
if (!run) return NextResponse.json({ error: 'Lönekörning hittades inte' }, { status: 404 })
if (run.status !== 'draft') return NextResponse.json({ error: 'Kan bara redigera utkast' }, { status: 400 })
const validation = await validateBody(request, UpdateSalaryLineItemSchema)
if (!validation.success) return validation.response
const body = validation.data
// Round amount if provided
const updates = { ...body }
if (updates.amount !== undefined) {
updates.amount = Math.round(updates.amount * 100) / 100
}
const result = await updatePayslipLine(supabase, {
companyId,
salaryRunId: id,
lineId,
patch: validation.data,
})
const { data: updated, error } = await supabase
.from('salary_line_items')
.update(updates)
.eq('id', lineId)
.eq('company_id', companyId)
.select()
.single()
if (error || !updated) {
return NextResponse.json({ error: 'Rad hittades inte' }, { status: 404 })
}
return NextResponse.json({ data: updated })
if (!result.ok) return errorResponse(result.code)
return NextResponse.json({ data: result.data })
},
{ requireWrite: true },
)
@@ -56,27 +44,13 @@ export const DELETE = withRouteContext<{ params: Promise<{ id: string; lineId: s
const { id, lineId } = await params
const { supabase, companyId } = ctx
// Verify run is draft
const { data: run } = await supabase
.from('salary_runs')
.select('id, status')
.eq('id', id)
.eq('company_id', companyId)
.single()
if (!run) return NextResponse.json({ error: 'Lönekörning hittades inte' }, { status: 404 })
if (run.status !== 'draft') return NextResponse.json({ error: 'Kan bara redigera utkast' }, { status: 400 })
const { error } = await supabase
.from('salary_line_items')
.delete()
.eq('id', lineId)
.eq('company_id', companyId)
if (error) {
return NextResponse.json({ error: error.message }, { status: 500 })
}
const result = await deletePayslipLine(supabase, {
companyId,
salaryRunId: id,
lineId,
})
if (!result.ok) return errorResponse(result.code)
return NextResponse.json({ data: { deleted: true } })
},
{ requireWrite: true },
+16 -55
View File
@@ -3,7 +3,8 @@ import { ensureInitialized } from '@/lib/init'
import { withRouteContext } from '@/lib/api/with-route-context'
import { validateBody } from '@/lib/api/validate'
import { CreateSalaryLineItemSchema } from '@/lib/api/schemas'
import { getLineItemAccount } from '@/lib/salary/account-mapping'
import { createPayslipLine } from '@/lib/salary/payslip-lines'
import { getErrorEntry } from '@/lib/errors/structured-errors'
ensureInitialized()
@@ -15,64 +16,24 @@ export const POST = withRouteContext<{ params: Promise<{ id: string }> }>(
const validation = await validateBody(request, CreateSalaryLineItemSchema)
if (!validation.success) return validation.response
const body = validation.data
const { salary_run_employee_id, ...input } = validation.data
// Verify run is draft
const { data: run } = await supabase
.from('salary_runs')
.select('id, status')
.eq('id', id)
.eq('company_id', companyId)
.single()
const result = await createPayslipLine(supabase, {
companyId,
salaryRunId: id,
target: { salaryRunEmployeeId: salary_run_employee_id },
input,
})
if (!run) {
return NextResponse.json({ error: 'Lönekörning hittades inte' }, { status: 404 })
}
if (run.status !== 'draft') {
return NextResponse.json({ error: 'Kan bara redigera utkast' }, { status: 400 })
if (!result.ok) {
const entry = getErrorEntry(result.code)
return NextResponse.json(
{ error: entry?.message_sv ?? 'Något gick fel', code: result.code },
{ status: entry?.httpStatus ?? 500 },
)
}
// Verify salary_run_employee belongs to this run
const { data: sre } = await supabase
.from('salary_run_employees')
.select('id, employee_id')
.eq('id', body.salary_run_employee_id)
.eq('salary_run_id', id)
.single()
if (!sre) {
return NextResponse.json({ error: 'Anställd finns inte i denna lönekörning' }, { status: 404 })
}
// Auto-resolve account if not provided
const accountNumber = body.account_number || getLineItemAccount(body.item_type as never)
const { data: lineItem, error } = await supabase
.from('salary_line_items')
.insert({
salary_run_employee_id: body.salary_run_employee_id,
company_id: companyId,
item_type: body.item_type,
description: body.description,
quantity: body.quantity || null,
unit_price: body.unit_price || null,
amount: Math.round(body.amount * 100) / 100,
is_taxable: body.is_taxable,
is_avgift_basis: body.is_avgift_basis,
is_vacation_basis: body.is_vacation_basis,
is_gross_deduction: body.is_gross_deduction,
is_net_deduction: body.is_net_deduction,
account_number: accountNumber,
sort_order: body.sort_order,
})
.select()
.single()
if (error) {
return NextResponse.json({ error: error.message }, { status: 500 })
}
return NextResponse.json({ data: lineItem }, { status: 201 })
return NextResponse.json({ data: result.data }, { status: 201 })
},
{ requireWrite: true },
)
+49
View File
@@ -0,0 +1,49 @@
import { NextResponse } from 'next/server'
import { ensureInitialized } from '@/lib/init'
import { withRouteContext } from '@/lib/api/with-route-context'
import { roundOre } from '@/lib/money'
ensureInitialized()
/** Open vacation-ledger rows joined with employee names, for the salary
* dashboard's Semester card. Empty until the first booking seeds the ledger
* (payroll gap-closure 3.5). */
export const GET = withRouteContext(
'salary.vacation-balances.list',
async (_request, { supabase, companyId }) => {
const { data, error } = await supabase
.from('employee_vacation_balances')
.select(
'id, employee_id, vacation_year_start, entitled_days, accrued_days, taken_days, saved_days, forced_payout_days, employee:employees(first_name, last_name, is_active)',
)
.eq('company_id', companyId)
.eq('status', 'open')
.order('vacation_year_start', { ascending: false })
if (error) {
return NextResponse.json({ error: error.message }, { status: 500 })
}
const rows = ((data ?? []) as Array<Record<string, unknown>>)
.filter((r) => (r.employee as { is_active?: boolean } | null)?.is_active !== false)
.map((r) => {
const employee = r.employee as { first_name: string; last_name: string } | null
const savedDays = (r.saved_days as Record<string, number> | null) ?? {}
const entitled = (r.entitled_days as number) ?? 0
const taken = (r.taken_days as number) ?? 0
return {
employee_vacation_balance_id: r.id,
employee_id: r.employee_id,
employee_name: employee ? `${employee.first_name} ${employee.last_name}` : '',
vacation_year_start: r.vacation_year_start,
entitled_days: entitled,
taken_days: taken,
remaining_days: roundOre(entitled - taken),
saved_days_total: Object.values(savedDays).reduce((s, d) => s + (Number(d) || 0), 0),
forced_payout_days: r.forced_payout_days ?? 0,
}
})
return NextResponse.json({ data: rows })
},
)
@@ -0,0 +1,76 @@
import { z } from 'zod'
import { NextResponse } from 'next/server'
import { ensureInitialized } from '@/lib/init'
import { withRouteContext } from '@/lib/api/with-route-context'
import { validateBody } from '@/lib/api/validate'
import {
commitVacationYearClose,
previewVacationYearClose,
} from '@/lib/salary/semesterberedning'
import { getVacationYearBasis } from '@/lib/salary/vacation-ledger'
import { getClosableYearStart } from '@/lib/salary/vacation-year'
import { getErrorEntry } from '@/lib/errors/structured-errors'
ensureInitialized()
const isoDate = z.string().regex(/^\d{4}-\d{2}-\d{2}$/)
const CloseBody = z.object({
vacation_year_start: isoDate.optional(),
book_adjustment: z.boolean().default(true),
/** true = return the review report only, write nothing. The dialog always
* previews before the user confirms (soft-guard convention). */
dry_run: z.boolean().default(false),
})
/** Semesterberedning + semesterårsavslut for the dashboard dialog
* (payroll gap-closure 3.5). Same service as the v1 route and the MCP
* executor. */
export const POST = withRouteContext(
'salary.vacation-year-close',
async (request, { supabase, companyId, user, log }) => {
const validation = await validateBody(request, CloseBody)
if (!validation.success) return validation.response
const body = validation.data
let yearStart = body.vacation_year_start
if (!yearStart) {
const basis = await getVacationYearBasis(supabase, companyId)
yearStart = getClosableYearStart(new Date().toISOString().slice(0, 10), basis)
}
if (body.dry_run) {
const preview = await previewVacationYearClose(supabase, companyId, yearStart)
if (!preview.ok) {
const entry = getErrorEntry(preview.code)
return NextResponse.json(
{ error: entry?.message_sv ?? preview.code, code: preview.code },
{ status: entry?.httpStatus ?? 500 },
)
}
return NextResponse.json({ data: { report: preview.data, committed: false } })
}
const result = await commitVacationYearClose(supabase, companyId, user.id, yearStart, {
bookAdjustment: body.book_adjustment,
})
if (!result.ok) {
const entry = getErrorEntry(result.code)
log.warn('vacation year close failed', { code: result.code })
return NextResponse.json(
{ error: entry?.message_sv ?? result.code, code: result.code, details: result.details },
{ status: entry?.httpStatus ?? 500 },
)
}
return NextResponse.json({
data: {
committed: true,
vacation_year_closure_id: result.data.closure_id,
adjustment_entry_id: result.data.adjustment_entry_id,
report: result.data.report,
},
})
},
{ requireWrite: true },
)
+44
View File
@@ -83,4 +83,48 @@ describe('PUT /api/settings', () => {
expect(status).toBe(200)
expect(body.data.company_name).toBe('New Name')
})
it('blocks a vacation-year basis change while open balances exist', async () => {
enqueueMany([
{ data: { salary_vacation_year_basis: 'calendar', onboarding_complete: true } }, // oldSettings
{ data: null, count: 2 }, // open-rows count
])
const request = createMockRequest('/api/settings', {
method: 'PUT',
body: { salary_vacation_year_basis: 'statutory_apr_mar' },
})
const response = await PUT(request, { params: Promise.resolve({}) })
const { status } = await parseJsonResponse(response)
expect(status).toBe(400)
// The guard consumed the count result and the update never ran.
expect(supabase.from.mock.calls.map(([table]) => table)).toEqual([
'company_settings',
'employee_vacation_balances',
])
})
it('fails closed when the open-balances guard query errors', async () => {
enqueueMany([
{ data: { salary_vacation_year_basis: 'calendar', onboarding_complete: true } }, // oldSettings
{ data: null, count: null, error: { message: 'connection reset' } }, // guard query fails
])
const request = createMockRequest('/api/settings', {
method: 'PUT',
body: { salary_vacation_year_basis: 'statutory_apr_mar' },
})
const response = await PUT(request, { params: Promise.resolve({}) })
const { status } = await parseJsonResponse(response)
expect(status).toBe(500)
// The 500 must come from the guard, not from company_settings.update()
// swallowing the queued error: the guard query ran and no second
// company_settings query followed it.
expect(supabase.from.mock.calls.map(([table]) => table)).toEqual([
'company_settings',
'employee_vacation_balances',
])
})
})
+30 -1
View File
@@ -40,7 +40,7 @@ export const PUT = withRouteContext(
// Fetch current settings to check for tax-relevant changes
const { data: oldSettings } = await supabase
.from('company_settings')
.select('entity_type, moms_period, f_skatt, vat_registered, vat_number, pays_salaries, fiscal_year_start_month, onboarding_complete')
.select('entity_type, moms_period, f_skatt, vat_registered, vat_number, pays_salaries, fiscal_year_start_month, onboarding_complete, salary_vacation_year_basis')
.eq('company_id', companyId)
.single()
@@ -65,6 +65,35 @@ export const PUT = withRouteContext(
)
}
// Vacation year basis (payroll gap-closure 3.1): changing the boundary
// while OPEN vacation-ledger rows exist would orphan them (rows are keyed
// by vacation_year_start). Close the current year first.
if (
body.salary_vacation_year_basis !== undefined &&
body.salary_vacation_year_basis !==
(oldSettings as Record<string, unknown> | null)?.salary_vacation_year_basis
) {
const { count: openRows, error: openRowsError } = await supabase
.from('employee_vacation_balances')
.select('id', { count: 'exact', head: true })
.eq('company_id', companyId)
.eq('status', 'open')
// Fail closed: a failed check must not let the basis change through
// and orphan open vacation-ledger rows.
if (openRowsError) {
return NextResponse.json({ error: openRowsError.message }, { status: 500 })
}
if ((openRows ?? 0) > 0) {
return NextResponse.json(
{
error:
'Semesterårets basis kan inte ändras medan öppna semestersaldon finns. Stäng semesteråret först.',
},
{ status: 400 },
)
}
}
// Validate: VAT-registered must have VAT number (ML 11 kap. 8§) and moms period (SFL 26 kap.)
const effectiveVatRegistered = body.vat_registered ?? oldSettings?.vat_registered
if (effectiveVatRegistered === true) {
@@ -0,0 +1,405 @@
/**
* Tests for the v1 absence endpoints (payroll gap-closure 1.4).
*
* GET/PUT/DELETE /employees/{id}/absence. PUT is the first PUT route on v1:
* the wrapper's REQUIRES_IDEMPOTENCY set was extended to include it, and the
* test-key case below is the regression test for that hole (a test key on a
* PUT must be forced into dry-run, never write through).
*/
import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
beforeAll(() => {
if (process.env.NODE_ENV !== 'test') {
throw new Error(
`absence route tests require NODE_ENV=test (got ${process.env.NODE_ENV ?? 'undefined'})`,
)
}
process.env.NEXT_PUBLIC_SUPABASE_URL ||= 'http://localhost:54321'
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY ||= 'test-anon-key'
})
vi.mock('@/lib/auth/api-keys', async () => {
const actual = await vi.importActual<typeof import('@/lib/auth/api-keys')>('@/lib/auth/api-keys')
return {
...actual,
validateApiKey: vi.fn(),
createServiceClientNoCookies: vi.fn(),
}
})
vi.mock('@supabase/supabase-js', async () => {
const actual = await vi.importActual<typeof import('@supabase/supabase-js')>('@supabase/supabase-js')
return { ...actual, createClient: vi.fn().mockReturnValue({}) }
})
import { validateApiKey, createServiceClientNoCookies } from '@/lib/auth/api-keys'
import { GET as listAbsence, PUT as putAbsence, DELETE as deleteAbsence } from '../route'
const mockValidate = validateApiKey as ReturnType<typeof vi.fn>
const mockServiceClient = createServiceClientNoCookies as ReturnType<typeof vi.fn>
interface TableResp {
data?: unknown
error?: unknown
count?: number | null
}
function makeFlexibleSupabase(byTable: Record<string, TableResp | TableResp[]>) {
const queues = new Map<string, TableResp[]>()
for (const [t, val] of Object.entries(byTable)) {
queues.set(t, Array.isArray(val) ? [...val] : [val])
}
const tableCalls: string[] = []
const buildChain = (table: string): unknown => {
const handler: ProxyHandler<object> = {
get(_target, prop) {
if (prop === 'then') {
return (resolve: (v: unknown) => void) => {
const q = queues.get(table)
const next = q && q.length > 1 ? q.shift()! : (q?.[0] ?? { data: null, error: null, count: null })
resolve({ count: null, ...next })
}
}
return (..._args: unknown[]) => buildChain(table)
},
}
return new Proxy({}, handler)
}
return {
tableCalls,
from: vi.fn((table: string) => {
tableCalls.push(table)
return buildChain(table)
}),
}
}
const COMPANY_ID = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa'
const EMPLOYEE_ID = 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb'
const USER_ID = 'user-1'
const SAMPLE_DAY = {
id: 'eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeee',
absence_date: '2026-03-03',
absence_type: 'sick',
hours: 8,
notes: null,
salary_run_employee_id: null,
created_at: '2026-03-03T08:00:00Z',
updated_at: '2026-03-03T08:00:00Z',
}
function makeRequest(url: string, init?: RequestInit): Request {
return new Request(url, {
...init,
headers: {
Authorization: 'Bearer test-fixture-not-a-real-key',
...(init?.headers ?? {}),
},
})
}
function absenceParams(companyId: string, id: string) {
return { params: Promise.resolve({ companyId, id }) }
}
beforeEach(() => {
vi.clearAllMocks()
mockValidate.mockResolvedValue({
userId: USER_ID,
companyId: COMPANY_ID,
apiKeyId: 'ak_1',
apiKeyName: 'CI key',
scopes: ['payroll:read', 'payroll:write'],
mode: 'live',
})
})
describe('GET /api/v1/companies/:companyId/employees/:id/absence', () => {
it('lists absence days with qualified ids', async () => {
mockServiceClient.mockReturnValue(
makeFlexibleSupabase({
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
employees: { data: { id: EMPLOYEE_ID }, error: null },
salary_absence_days: { data: [SAMPLE_DAY], error: null },
}),
)
const res = await listAbsence(
makeRequest(
`https://x.test/api/v1/companies/${COMPANY_ID}/employees/${EMPLOYEE_ID}/absence?from=2026-03-01&to=2026-03-31`,
),
absenceParams(COMPANY_ID, EMPLOYEE_ID),
)
expect(res.status).toBe(200)
const body = await res.json()
expect(body.data).toHaveLength(1)
expect(body.data[0].salary_absence_day_id).toBe(SAMPLE_DAY.id)
expect(body.data[0].id).toBeUndefined()
})
it('rejects a missing from/to with 400', async () => {
mockServiceClient.mockReturnValue(
makeFlexibleSupabase({
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
}),
)
const res = await listAbsence(
makeRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/employees/${EMPLOYEE_ID}/absence`),
absenceParams(COMPANY_ID, EMPLOYEE_ID),
)
expect(res.status).toBe(400)
})
it('rejects a reversed range (from > to) with VALIDATION_ERROR', async () => {
mockServiceClient.mockReturnValue(
makeFlexibleSupabase({
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
}),
)
const res = await listAbsence(
makeRequest(
`https://x.test/api/v1/companies/${COMPANY_ID}/employees/${EMPLOYEE_ID}/absence?from=2026-03-31&to=2026-03-01`,
),
absenceParams(COMPANY_ID, EMPLOYEE_ID),
)
expect(res.status).toBe(400)
const body = await res.json()
expect(body.error.code).toBe('VALIDATION_ERROR')
})
it('rejects a range beyond 92 days with ABSENCE_RANGE_TOO_LARGE', async () => {
mockServiceClient.mockReturnValue(
makeFlexibleSupabase({
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
}),
)
const res = await listAbsence(
makeRequest(
`https://x.test/api/v1/companies/${COMPANY_ID}/employees/${EMPLOYEE_ID}/absence?from=2026-01-01&to=2026-12-31`,
),
absenceParams(COMPANY_ID, EMPLOYEE_ID),
)
expect(res.status).toBe(400)
const body = await res.json()
expect(body.error.code).toBe('ABSENCE_RANGE_TOO_LARGE')
})
it('returns 404 EMPLOYEE_NOT_FOUND for an unknown employee', async () => {
mockServiceClient.mockReturnValue(
makeFlexibleSupabase({
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
employees: { data: null, error: null },
}),
)
const res = await listAbsence(
makeRequest(
`https://x.test/api/v1/companies/${COMPANY_ID}/employees/${EMPLOYEE_ID}/absence?from=2026-03-01&to=2026-03-31`,
),
absenceParams(COMPANY_ID, EMPLOYEE_ID),
)
expect(res.status).toBe(404)
const body = await res.json()
expect(body.error.code).toBe('EMPLOYEE_NOT_FOUND')
})
})
describe('PUT /api/v1/companies/:companyId/employees/:id/absence', () => {
const validBody = { from: '2026-03-02', to: '2026-03-06', absence_type: 'sick' }
it('upserts the expanded range (happy path)', async () => {
mockServiceClient.mockReturnValue(
makeFlexibleSupabase({
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
employees: { data: { id: EMPLOYEE_ID }, error: null },
salary_absence_days: [
{ data: [SAMPLE_DAY, { ...SAMPLE_DAY, id: 'ffffffff-ffff-4fff-8fff-ffffffffffff', absence_date: '2026-03-04' }], error: null }, // bulk upsert
],
}),
)
const res = await putAbsence(
makeRequest(
`https://x.test/api/v1/companies/${COMPANY_ID}/employees/${EMPLOYEE_ID}/absence`,
{ method: 'PUT', body: JSON.stringify(validBody) },
),
absenceParams(COMPANY_ID, EMPLOYEE_ID),
)
expect(res.status).toBe(200)
const body = await res.json()
expect(body.data.count).toBe(2)
expect(body.data.days[0].salary_absence_day_id).toBeTruthy()
})
it('rejects from > to with 400', async () => {
mockServiceClient.mockReturnValue(
makeFlexibleSupabase({
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
}),
)
const res = await putAbsence(
makeRequest(
`https://x.test/api/v1/companies/${COMPANY_ID}/employees/${EMPLOYEE_ID}/absence`,
{ method: 'PUT', body: JSON.stringify({ ...validBody, from: '2026-03-10' }) },
),
absenceParams(COMPANY_ID, EMPLOYEE_ID),
)
expect(res.status).toBe(400)
})
it('maps the 24h trigger to 409 ABSENCE_HOURS_CONFLICT', async () => {
mockServiceClient.mockReturnValue(
makeFlexibleSupabase({
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
employees: { data: { id: EMPLOYEE_ID }, error: null },
salary_absence_days: [
{ data: null, error: { code: '23514', message: 'Total tid över 24h' } },
],
}),
)
const res = await putAbsence(
makeRequest(
`https://x.test/api/v1/companies/${COMPANY_ID}/employees/${EMPLOYEE_ID}/absence`,
{ method: 'PUT', body: JSON.stringify(validBody) },
),
absenceParams(COMPANY_ID, EMPLOYEE_ID),
)
expect(res.status).toBe(409)
const body = await res.json()
expect(body.error.code).toBe('ABSENCE_HOURS_CONFLICT')
})
it('returns a dry-run preview without writing', async () => {
const supabaseMock = makeFlexibleSupabase({
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
employees: { data: { id: EMPLOYEE_ID }, error: null },
})
mockServiceClient.mockReturnValue(supabaseMock)
const res = await putAbsence(
makeRequest(
`https://x.test/api/v1/companies/${COMPANY_ID}/employees/${EMPLOYEE_ID}/absence?dry_run=true`,
{ method: 'PUT', body: JSON.stringify(validBody) },
),
absenceParams(COMPANY_ID, EMPLOYEE_ID),
)
expect(res.status).toBe(200)
expect(res.headers.get('X-Dry-Run')).toBe('true')
const body = await res.json()
expect(body.data.preview.count).toBe(5)
expect(supabaseMock.tableCalls).not.toContain('salary_absence_days')
})
it('forces TEST KEYS into dry-run on PUT (wrapper REQUIRES_IDEMPOTENCY regression)', async () => {
// Before the wrapper hardening, PUT was missing from REQUIRES_IDEMPOTENCY:
// a test key would have written through. This test locks the fix in.
mockValidate.mockResolvedValue({
userId: USER_ID,
companyId: COMPANY_ID,
apiKeyId: 'ak_test',
apiKeyName: 'test key',
scopes: ['payroll:read', 'payroll:write'],
mode: 'test',
})
const supabaseMock = makeFlexibleSupabase({
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
employees: { data: { id: EMPLOYEE_ID }, error: null },
})
mockServiceClient.mockReturnValue(supabaseMock)
const res = await putAbsence(
makeRequest(
`https://x.test/api/v1/companies/${COMPANY_ID}/employees/${EMPLOYEE_ID}/absence`,
{ method: 'PUT', body: JSON.stringify(validBody) },
),
absenceParams(COMPANY_ID, EMPLOYEE_ID),
)
expect(res.status).toBe(200)
expect(res.headers.get('X-Dry-Run')).toBe('true')
expect(res.headers.get('X-Gnubok-Mode')).toBe('test')
// The write table was never touched.
expect(supabaseMock.tableCalls).not.toContain('salary_absence_days')
})
it('rejects keys without payroll:write scope', async () => {
mockValidate.mockResolvedValue({
userId: USER_ID,
companyId: COMPANY_ID,
apiKeyId: 'ak_1',
apiKeyName: 'read-only',
scopes: ['payroll:read'],
mode: 'live',
})
mockServiceClient.mockReturnValue(makeFlexibleSupabase({}))
const res = await putAbsence(
makeRequest(
`https://x.test/api/v1/companies/${COMPANY_ID}/employees/${EMPLOYEE_ID}/absence`,
{ method: 'PUT', body: JSON.stringify(validBody) },
),
absenceParams(COMPANY_ID, EMPLOYEE_ID),
)
expect(res.status).toBe(403)
})
it('returns 401 without a bearer token', async () => {
mockValidate.mockResolvedValue(null)
mockServiceClient.mockReturnValue(makeFlexibleSupabase({}))
const res = await putAbsence(
new Request(
`https://x.test/api/v1/companies/${COMPANY_ID}/employees/${EMPLOYEE_ID}/absence`,
{ method: 'PUT', body: JSON.stringify(validBody) },
),
absenceParams(COMPANY_ID, EMPLOYEE_ID),
)
expect(res.status).toBe(401)
})
})
describe('DELETE /api/v1/companies/:companyId/employees/:id/absence', () => {
it('deletes the range and returns deleted_count', async () => {
mockServiceClient.mockReturnValue(
makeFlexibleSupabase({
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
employees: { data: { id: EMPLOYEE_ID }, error: null },
salary_absence_days: { data: null, error: null, count: 3 },
}),
)
const res = await deleteAbsence(
makeRequest(
`https://x.test/api/v1/companies/${COMPANY_ID}/employees/${EMPLOYEE_ID}/absence?from=2026-03-01&to=2026-03-31&type=sick`,
{ method: 'DELETE' },
),
absenceParams(COMPANY_ID, EMPLOYEE_ID),
)
expect(res.status).toBe(200)
const body = await res.json()
expect(body.data.deleted_count).toBe(3)
})
it('requires from and to', async () => {
mockServiceClient.mockReturnValue(
makeFlexibleSupabase({
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
}),
)
const res = await deleteAbsence(
makeRequest(
`https://x.test/api/v1/companies/${COMPANY_ID}/employees/${EMPLOYEE_ID}/absence`,
{ method: 'DELETE' },
),
absenceParams(COMPANY_ID, EMPLOYEE_ID),
)
expect(res.status).toBe(400)
})
})
@@ -0,0 +1,362 @@
/**
* /api/v1/companies/{companyId}/employees/{id}/absence
*
* GET : list absence days in a date range (max 92 days: the range IS the
* pagination, no cursor).
* PUT : range upsert. Expands [from, to] to per-day rows on the natural
* key (employee, date, type). Truly idempotent: retries converge.
* DELETE : range delete (optional type filter). Returns deleted_count.
*
* Storage is per-day (sjuklönelagen karens/day-14 boundaries and AGI 2025+
* per-event Frånvarouppgift derive from day rows); the range payload is API
* ergonomics only. Pre-cutover backfill is legal at any date: imported
* history feeds the sick-segment lookback in the calculation engine.
*/
import { z } from 'zod'
import { ok } from '@/lib/api/v1/response'
import { dryRunPreview } from '@/lib/api/v1/dry-run'
import { registerEndpoint, dataEnvelope, listEnvelope } from '@/lib/api/v1/registry'
import { withApiV1 } from '@/lib/api/v1/with-api-v1'
import { v1ErrorResponseFromCode } from '@/lib/api/v1/errors'
import { AbsenceTypeSchema } from '@/lib/api/schemas'
import {
ABSENCE_RANGE_MAX_DAYS,
deleteAbsenceRange,
listAbsenceDays,
upsertAbsenceRange,
} from '@/lib/salary/absence'
const isoDate = z.string().regex(/^\d{4}-\d{2}-\d{2}$/, 'Expected YYYY-MM-DD date format')
const AbsenceDay = z.object({
salary_absence_day_id: z.string().uuid(),
absence_date: z.string(),
absence_type: AbsenceTypeSchema,
hours: z.number(),
notes: z.string().nullable(),
salary_run_employee_id: z.string().uuid().nullable(),
created_at: z.string(),
updated_at: z.string(),
})
const RangeQuery = z
.object({
from: isoDate,
to: isoDate,
type: AbsenceTypeSchema.optional(),
})
.refine((v) => v.from <= v.to, { message: 'from must be <= to', path: ['from'] })
registerEndpoint({
operation: 'employees.absence.list',
method: 'GET',
path: '/api/v1/companies/:companyId/employees/:id/absence',
summary: 'List absence days for an employee in a date range.',
description:
'Returns per-day absence rows (sick, vab, parental, ...) between ?from and ?to (inclusive, max 92 days). No cursor pagination: the bounded range is the page. Optional ?type filter.',
useWhen:
'You need an employee\'s registered absence: to reconcile with an external time-tracking system, to verify what the salary engine will derive, or to display a calendar.',
doNotUseFor:
'The derived pay impact (karensavdrag, sjuklön lines): that lives on the payslip detail after :calculate. Worked hours for hourly staff: separate register, not on v1 yet.',
pitfalls: [
'Ranges over 92 days return 400 ABSENCE_RANGE_TOO_LARGE: iterate quarters instead.',
'A day can carry multiple rows with different absence_type values (e.g. half-day sick + half-day vab).',
'Rows may reference the salary run that consumed them via salary_run_employee_id.',
],
example: {
response: {
data: [
{
salary_absence_day_id: 'abs_91d2…',
absence_date: '2026-03-03',
absence_type: 'sick',
hours: 8,
notes: null,
},
],
meta: { request_id: 'req_…', api_version: '2026-05-12' },
},
},
scope: 'payroll:read',
risk: 'low',
idempotent: true,
reversible: false,
dryRunSupported: false,
response: { success: listEnvelope(AbsenceDay) },
})
export const GET = withApiV1<{ params: Promise<{ companyId: string; id: string }> }>(
'employees.absence.list',
async (request, ctx, params) => {
const { id } = await params.params
const idParse = z.string().uuid().safeParse(id)
if (!idParse.success) {
return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, {
requestId: ctx.requestId,
details: { field: 'id', message: 'Employee id must be a UUID.' },
})
}
const url = new URL(request.url)
const parsed = RangeQuery.safeParse({
from: url.searchParams.get('from') ?? undefined,
to: url.searchParams.get('to') ?? undefined,
type: url.searchParams.get('type') ?? undefined,
})
if (!parsed.success) {
return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, {
requestId: ctx.requestId,
details: {
issues: parsed.error.issues.map((i) => ({
field: i.path.join('.'),
message: i.message,
})),
},
})
}
const { from, to, type } = parsed.data
// Same cap as writes: the bounded range is the pagination contract.
const spanMs = Date.parse(`${to}T00:00:00Z`) - Date.parse(`${from}T00:00:00Z`)
if (spanMs < 0 || spanMs / 86_400_000 + 1 > ABSENCE_RANGE_MAX_DAYS) {
return v1ErrorResponseFromCode('ABSENCE_RANGE_TOO_LARGE', ctx.log, {
requestId: ctx.requestId,
details: { from, to, max_days: ABSENCE_RANGE_MAX_DAYS },
})
}
const result = await listAbsenceDays(ctx.supabase, {
companyId: ctx.companyId!,
employeeId: idParse.data,
from,
to,
absenceType: type,
})
if (!result.ok) {
return v1ErrorResponseFromCode(result.code, ctx.log, {
requestId: ctx.requestId,
details: result.details,
})
}
return ok(
result.data.map(({ id: rowId, ...rest }) => ({ salary_absence_day_id: rowId, ...rest })),
{ requestId: ctx.requestId },
)
},
)
// ──────────────────────────────────────────────────────────────────
// PUT: range upsert
// ──────────────────────────────────────────────────────────────────
const UpsertRangeBody = z
.object({
from: isoDate,
to: isoDate,
absence_type: AbsenceTypeSchema,
hours_per_day: z.number().positive().max(24).default(8),
notes: z.string().max(2000).optional(),
include_weekends: z.boolean().default(false),
})
.refine((v) => v.from <= v.to, { message: 'from must be <= to', path: ['from'] })
const UpsertRangeResponse = z.object({
count: z.number().int(),
days: z.array(AbsenceDay.partial({ salary_absence_day_id: true, notes: true, salary_run_employee_id: true, created_at: true, updated_at: true })),
})
registerEndpoint({
operation: 'employees.absence.upsert',
method: 'PUT',
path: '/api/v1/companies/:companyId/employees/:id/absence',
summary: 'Register absence for an employee over a date range.',
description:
'Expands [from, to] (max 92 days) to per-day rows and upserts them on the natural key (employee, date, type). Weekends are skipped unless include_weekends=true. Single day = from == to. Idempotent by construction: replaying the same PUT converges on the same rows.',
useWhen:
'"Anna was sick 3-7 March": one call registers the whole event. Also for pre-cutover history backfill when migrating from another payroll system (any past date is legal; imported sick days feed the karensavdrag lookback).',
doNotUseFor:
'Vacation day REQUESTS/approval workflows (out of scope). Editing hours on one existing day inside a range: PUT the single day (from == to) with the new hours.',
pitfalls: [
'Weekends are skipped by default: pass include_weekends=true for schedules that span them.',
'Upsert REPLACES the (date, type) rows in the range: hours/notes are overwritten, not merged.',
'A day whose combined absence + worked hours exceed 24h returns 409 ABSENCE_HOURS_CONFLICT and the whole range is rejected (atomic).',
'Registering absence does not recompute an open salary run: call POST /salary-runs/{id}/calculate afterwards.',
],
example: {
request: { from: '2026-03-03', to: '2026-03-07', absence_type: 'sick' },
response: {
data: { count: 5, days: [{ absence_date: '2026-03-03', absence_type: 'sick', hours: 8 }] },
meta: { request_id: 'req_…', api_version: '2026-05-12' },
},
},
scope: 'payroll:write',
risk: 'low',
idempotent: true,
reversible: true,
dryRunSupported: true,
request: { body: UpsertRangeBody },
response: { success: dataEnvelope(UpsertRangeResponse) },
})
export const PUT = withApiV1<{ params: Promise<{ companyId: string; id: string }> }>(
'employees.absence.upsert',
async (request, ctx, params) => {
const { id } = await params.params
const idParse = z.string().uuid().safeParse(id)
if (!idParse.success) {
return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, {
requestId: ctx.requestId,
details: { field: 'id', message: 'Employee id must be a UUID.' },
})
}
let rawBody: unknown
try {
rawBody = await request.json()
} catch {
return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, {
requestId: ctx.requestId,
details: { field: 'body', message: 'Body is not valid JSON.' },
})
}
const parsed = UpsertRangeBody.safeParse(rawBody)
if (!parsed.success) {
return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, {
requestId: ctx.requestId,
details: {
issues: parsed.error.issues.map((i) => ({
field: i.path.join('.'),
message: i.message,
})),
},
})
}
const body = parsed.data
const result = await upsertAbsenceRange(ctx.supabase, {
companyId: ctx.companyId!,
employeeId: idParse.data,
from: body.from,
to: body.to,
absenceType: body.absence_type,
hoursPerDay: body.hours_per_day,
notes: body.notes ?? null,
includeWeekends: body.include_weekends,
dryRun: ctx.dryRun,
})
if (!result.ok) {
return v1ErrorResponseFromCode(result.code, ctx.log, {
requestId: ctx.requestId,
details: result.details,
})
}
const payload = {
count: result.data.count,
days: result.data.days.map((d) => {
const { id: rowId, ...rest } = d as { id?: string } & Record<string, unknown>
return rowId ? { salary_absence_day_id: rowId, ...rest } : rest
}),
}
if (ctx.dryRun) {
return dryRunPreview(payload, { requestId: ctx.requestId, log: ctx.log })
}
return ok(payload, { requestId: ctx.requestId })
},
{ requireIdempotencyKey: false },
)
// ──────────────────────────────────────────────────────────────────
// DELETE: range delete
// ──────────────────────────────────────────────────────────────────
const DeleteRangeResponse = z.object({ deleted_count: z.number().int() })
registerEndpoint({
operation: 'employees.absence.delete',
method: 'DELETE',
path: '/api/v1/companies/:companyId/employees/:id/absence',
summary: 'Delete absence days for an employee in a date range.',
description:
'Deletes per-day absence rows between ?from and ?to (inclusive), optionally filtered by ?type. Returns deleted_count (200, not 204) so callers can verify how many rows went.',
useWhen:
'An absence event was registered by mistake or ended early: "Anna came back Thursday, delete Thu-Fri sick days".',
doNotUseFor:
'Correcting hours on a day: PUT the day again instead. Rows already consumed by a BOOKED run: deleting them does not un-book the run; use the run correction flow.',
pitfalls: [
'Without ?type, ALL absence types in the range are deleted.',
'deleted_count: 0 with a 200 means nothing matched: not an error.',
],
example: {
response: {
data: { deleted_count: 2 },
meta: { request_id: 'req_…', api_version: '2026-05-12' },
},
},
scope: 'payroll:write',
risk: 'low',
idempotent: true,
reversible: false,
dryRunSupported: true,
response: { success: dataEnvelope(DeleteRangeResponse) },
})
export const DELETE = withApiV1<{ params: Promise<{ companyId: string; id: string }> }>(
'employees.absence.delete',
async (request, ctx, params) => {
const { id } = await params.params
const idParse = z.string().uuid().safeParse(id)
if (!idParse.success) {
return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, {
requestId: ctx.requestId,
details: { field: 'id', message: 'Employee id must be a UUID.' },
})
}
const url = new URL(request.url)
const parsed = RangeQuery.safeParse({
from: url.searchParams.get('from') ?? undefined,
to: url.searchParams.get('to') ?? undefined,
type: url.searchParams.get('type') ?? undefined,
})
if (!parsed.success) {
return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, {
requestId: ctx.requestId,
details: {
issues: parsed.error.issues.map((i) => ({
field: i.path.join('.'),
message: i.message,
})),
},
})
}
const { from, to, type } = parsed.data
const result = await deleteAbsenceRange(ctx.supabase, {
companyId: ctx.companyId!,
employeeId: idParse.data,
from,
to,
absenceType: type,
dryRun: ctx.dryRun,
})
if (!result.ok) {
return v1ErrorResponseFromCode(result.code, ctx.log, {
requestId: ctx.requestId,
details: result.details,
})
}
if (ctx.dryRun) {
return dryRunPreview(result.data, { requestId: ctx.requestId, log: ctx.log })
}
return ok(result.data, { requestId: ctx.requestId })
},
)
@@ -0,0 +1,518 @@
/**
* Tests for the v1 opening-balances endpoints (payroll gap-closure 2.3):
* GET/PUT /employees/{id}/opening-balances + bulk PUT /employees/opening-balances.
*/
import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
beforeAll(() => {
if (process.env.NODE_ENV !== 'test') {
throw new Error(
`opening-balances route tests require NODE_ENV=test (got ${process.env.NODE_ENV ?? 'undefined'})`,
)
}
process.env.NEXT_PUBLIC_SUPABASE_URL ||= 'http://localhost:54321'
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY ||= 'test-anon-key'
})
vi.mock('@/lib/auth/api-keys', async () => {
const actual = await vi.importActual<typeof import('@/lib/auth/api-keys')>('@/lib/auth/api-keys')
return {
...actual,
validateApiKey: vi.fn(),
createServiceClientNoCookies: vi.fn(),
}
})
vi.mock('@supabase/supabase-js', async () => {
const actual = await vi.importActual<typeof import('@supabase/supabase-js')>('@supabase/supabase-js')
return { ...actual, createClient: vi.fn().mockReturnValue({}) }
})
import { validateApiKey, createServiceClientNoCookies } from '@/lib/auth/api-keys'
import { GET as getBalances, PUT as putBalances } from '../route'
import { PUT as putBulk } from '../../../opening-balances/route'
const mockValidate = validateApiKey as ReturnType<typeof vi.fn>
const mockServiceClient = createServiceClientNoCookies as ReturnType<typeof vi.fn>
interface TableResp {
data?: unknown
error?: unknown
}
function makeFlexibleSupabase(byTable: Record<string, TableResp | TableResp[]>) {
const queues = new Map<string, TableResp[]>()
for (const [t, val] of Object.entries(byTable)) {
queues.set(t, Array.isArray(val) ? [...val] : [val])
}
const tableCalls: string[] = []
const buildChain = (table: string): unknown => {
const handler: ProxyHandler<object> = {
get(_target, prop) {
if (prop === 'then') {
return (resolve: (v: unknown) => void) => {
const q = queues.get(table)
const next = q && q.length > 1 ? q.shift()! : (q?.[0] ?? { data: null, error: null })
resolve(next)
}
}
return (..._args: unknown[]) => buildChain(table)
},
}
return new Proxy({}, handler)
}
return {
tableCalls,
from: vi.fn((table: string) => {
tableCalls.push(table)
return buildChain(table)
}),
}
}
const COMPANY_ID = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa'
const EMPLOYEE_ID = 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb'
const ROW_ID = 'cccccccc-cccc-4ccc-8ccc-cccccccccccc'
const RUN_ID = 'dddddddd-dddd-4ddd-8ddd-dddddddddddd'
const USER_ID = 'user-1'
const CURRENT_YEAR = new Date().getFullYear()
const CUTOVER_DATE = `${CURRENT_YEAR}-07-01`
const SAMPLE_ROW = {
id: ROW_ID,
employee_id: EMPLOYEE_ID,
cutover_date: CUTOVER_DATE,
ytd_gross: 210000,
ytd_tax: 48000,
ytd_net: 162000,
vacation_paid_days_remaining: 12.5,
vacation_saved_days_by_year: { [`${CURRENT_YEAR - 1}`]: 5 },
opening_semester_liability: 42000,
opening_semester_liability_avgifter: 13196.4,
karens_periods_adjustment: 1,
created_at: '2026-07-01T08:00:00Z',
updated_at: '2026-07-01T08:00:00Z',
}
const VALID_BODY = {
cutover_date: CUTOVER_DATE,
ytd_gross: 210000,
ytd_tax: 48000,
ytd_net: 162000,
vacation_paid_days_remaining: 12.5,
vacation_saved_days_by_year: { [`${CURRENT_YEAR - 1}`]: 5 },
opening_semester_liability: 42000,
opening_semester_liability_avgifter: 13196.4,
karens_periods_adjustment: 1,
}
function makeRequest(url: string, init?: RequestInit): Request {
return new Request(url, {
...init,
headers: {
Authorization: 'Bearer test-fixture-not-a-real-key',
...(init?.headers ?? {}),
},
})
}
function detailParams(companyId: string, id: string) {
return { params: Promise.resolve({ companyId, id }) }
}
function companyParams(companyId: string) {
return { params: Promise.resolve({ companyId }) }
}
beforeEach(() => {
vi.clearAllMocks()
mockValidate.mockResolvedValue({
userId: USER_ID,
companyId: COMPANY_ID,
apiKeyId: 'ak_1',
apiKeyName: 'CI key',
scopes: ['payroll:read', 'payroll:write'],
mode: 'live',
})
})
describe('GET /employees/:id/opening-balances', () => {
it('returns the row with lock state (happy path, unlocked)', async () => {
mockServiceClient.mockReturnValue(
makeFlexibleSupabase({
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
employees: { data: { id: EMPLOYEE_ID }, error: null },
employee_opening_balances: { data: SAMPLE_ROW, error: null },
salary_run_employees: { data: [], error: null },
}),
)
const res = await getBalances(
makeRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/employees/${EMPLOYEE_ID}/opening-balances`),
detailParams(COMPANY_ID, EMPLOYEE_ID),
)
expect(res.status).toBe(200)
const body = await res.json()
expect(body.data.employee_opening_balances_id).toBe(ROW_ID)
expect(body.data.ytd_gross).toBe(210000)
expect(body.data.locked).toBe(false)
expect(body.data.locked_by_run_id).toBeNull()
expect(body.data.id).toBeUndefined()
})
it('reports locked with the blocking run id', async () => {
mockServiceClient.mockReturnValue(
makeFlexibleSupabase({
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
employees: { data: { id: EMPLOYEE_ID }, error: null },
employee_opening_balances: { data: SAMPLE_ROW, error: null },
salary_run_employees: {
data: [{ employee_id: EMPLOYEE_ID, salary_run: { id: RUN_ID, status: 'booked' } }],
error: null,
},
}),
)
const res = await getBalances(
makeRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/employees/${EMPLOYEE_ID}/opening-balances`),
detailParams(COMPANY_ID, EMPLOYEE_ID),
)
expect(res.status).toBe(200)
const body = await res.json()
expect(body.data.locked).toBe(true)
expect(body.data.locked_by_run_id).toBe(RUN_ID)
})
it('returns 404 when no balances are set', async () => {
mockServiceClient.mockReturnValue(
makeFlexibleSupabase({
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
employees: { data: { id: EMPLOYEE_ID }, error: null },
employee_opening_balances: { data: null, error: null },
}),
)
const res = await getBalances(
makeRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/employees/${EMPLOYEE_ID}/opening-balances`),
detailParams(COMPANY_ID, EMPLOYEE_ID),
)
expect(res.status).toBe(404)
})
it('returns 404 EMPLOYEE_NOT_FOUND for an unknown employee', async () => {
mockServiceClient.mockReturnValue(
makeFlexibleSupabase({
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
employees: { data: null, error: null },
}),
)
const res = await getBalances(
makeRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/employees/${EMPLOYEE_ID}/opening-balances`),
detailParams(COMPANY_ID, EMPLOYEE_ID),
)
expect(res.status).toBe(404)
const body = await res.json()
expect(body.error.code).toBe('EMPLOYEE_NOT_FOUND')
})
})
describe('PUT /employees/:id/opening-balances', () => {
it('upserts and returns the row (happy path)', async () => {
mockServiceClient.mockReturnValue(
makeFlexibleSupabase({
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
employees: {
data: [{ id: EMPLOYEE_ID, employment_start: '2024-01-15', is_active: true }],
error: null,
},
salary_run_employees: { data: [], error: null },
employee_opening_balances: { data: [SAMPLE_ROW], error: null },
}),
)
const res = await putBalances(
makeRequest(
`https://x.test/api/v1/companies/${COMPANY_ID}/employees/${EMPLOYEE_ID}/opening-balances`,
{ method: 'PUT', body: JSON.stringify(VALID_BODY) },
),
detailParams(COMPANY_ID, EMPLOYEE_ID),
)
expect(res.status).toBe(200)
const body = await res.json()
expect(body.data.employee_opening_balances_id).toBe(ROW_ID)
expect(body.data.locked).toBe(false)
})
it('returns 409 OPENING_BALANCES_LOCKED when a booked run exists', async () => {
mockServiceClient.mockReturnValue(
makeFlexibleSupabase({
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
employees: {
data: [{ id: EMPLOYEE_ID, employment_start: '2024-01-15', is_active: true }],
error: null,
},
salary_run_employees: {
data: [{ employee_id: EMPLOYEE_ID, salary_run: { id: RUN_ID, status: 'booked' } }],
error: null,
},
}),
)
const res = await putBalances(
makeRequest(
`https://x.test/api/v1/companies/${COMPANY_ID}/employees/${EMPLOYEE_ID}/opening-balances`,
{ method: 'PUT', body: JSON.stringify(VALID_BODY) },
),
detailParams(COMPANY_ID, EMPLOYEE_ID),
)
expect(res.status).toBe(409)
const body = await res.json()
expect(body.error.code).toBe('OPENING_BALANCES_LOCKED')
})
it('rejects a cutover_date that is not the first of a month', async () => {
mockServiceClient.mockReturnValue(
makeFlexibleSupabase({
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
}),
)
const res = await putBalances(
makeRequest(
`https://x.test/api/v1/companies/${COMPANY_ID}/employees/${EMPLOYEE_ID}/opening-balances`,
{ method: 'PUT', body: JSON.stringify({ ...VALID_BODY, cutover_date: `${CURRENT_YEAR}-07-15` }) },
),
detailParams(COMPANY_ID, EMPLOYEE_ID),
)
expect(res.status).toBe(400)
})
it('rejects ytd_tax above ytd_gross', async () => {
mockServiceClient.mockReturnValue(
makeFlexibleSupabase({
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
}),
)
const res = await putBalances(
makeRequest(
`https://x.test/api/v1/companies/${COMPANY_ID}/employees/${EMPLOYEE_ID}/opening-balances`,
{ method: 'PUT', body: JSON.stringify({ ...VALID_BODY, ytd_gross: 1000, ytd_tax: 2000 }) },
),
detailParams(COMPANY_ID, EMPLOYEE_ID),
)
expect(res.status).toBe(400)
})
it('rejects saved days with an origin year outside the 5-year window', async () => {
mockServiceClient.mockReturnValue(
makeFlexibleSupabase({
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
}),
)
const res = await putBalances(
makeRequest(
`https://x.test/api/v1/companies/${COMPANY_ID}/employees/${EMPLOYEE_ID}/opening-balances`,
{
method: 'PUT',
body: JSON.stringify({
...VALID_BODY,
vacation_saved_days_by_year: { [`${CURRENT_YEAR - 7}`]: 3 },
}),
},
),
detailParams(COMPANY_ID, EMPLOYEE_ID),
)
expect(res.status).toBe(400)
})
it('dry-run validates without writing', async () => {
const supabaseMock = makeFlexibleSupabase({
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
employees: {
data: [{ id: EMPLOYEE_ID, employment_start: '2024-01-15', is_active: true }],
error: null,
},
salary_run_employees: { data: [], error: null },
})
mockServiceClient.mockReturnValue(supabaseMock)
const res = await putBalances(
makeRequest(
`https://x.test/api/v1/companies/${COMPANY_ID}/employees/${EMPLOYEE_ID}/opening-balances?dry_run=true`,
{ method: 'PUT', body: JSON.stringify(VALID_BODY) },
),
detailParams(COMPANY_ID, EMPLOYEE_ID),
)
expect(res.status).toBe(200)
expect(res.headers.get('X-Dry-Run')).toBe('true')
expect(supabaseMock.tableCalls).not.toContain('employee_opening_balances')
})
it('forces test keys into dry-run on this PUT too', async () => {
mockValidate.mockResolvedValue({
userId: USER_ID,
companyId: COMPANY_ID,
apiKeyId: 'ak_test',
apiKeyName: 'test key',
scopes: ['payroll:write'],
mode: 'test',
})
const supabaseMock = makeFlexibleSupabase({
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
employees: {
data: [{ id: EMPLOYEE_ID, employment_start: '2024-01-15', is_active: true }],
error: null,
},
salary_run_employees: { data: [], error: null },
})
mockServiceClient.mockReturnValue(supabaseMock)
const res = await putBalances(
makeRequest(
`https://x.test/api/v1/companies/${COMPANY_ID}/employees/${EMPLOYEE_ID}/opening-balances`,
{ method: 'PUT', body: JSON.stringify(VALID_BODY) },
),
detailParams(COMPANY_ID, EMPLOYEE_ID),
)
expect(res.status).toBe(200)
expect(res.headers.get('X-Dry-Run')).toBe('true')
expect(supabaseMock.tableCalls).not.toContain('employee_opening_balances')
})
})
describe('PUT /employees/opening-balances (bulk)', () => {
it('is atomic: one bad item fails everything with a per-item error list', async () => {
const supabaseMock = makeFlexibleSupabase({
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
// Only the first employee exists.
employees: {
data: [{ id: EMPLOYEE_ID, employment_start: '2024-01-15', is_active: true }],
error: null,
},
salary_run_employees: { data: [], error: null },
})
mockServiceClient.mockReturnValue(supabaseMock)
const otherId = '99999999-9999-4999-8999-999999999999'
const res = await putBulk(
makeRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/employees/opening-balances`, {
method: 'PUT',
body: JSON.stringify({
items: [
{ employee_id: EMPLOYEE_ID, ...VALID_BODY },
{ employee_id: otherId, ...VALID_BODY },
],
}),
}),
companyParams(COMPANY_ID),
)
expect(res.status).toBe(400)
const body = await res.json()
const itemErrors = body.error.details.item_errors as Array<{ index: number; code: string }>
expect(itemErrors).toHaveLength(1)
expect(itemErrors[0].index).toBe(1)
expect(itemErrors[0].code).toBe('EMPLOYEE_NOT_FOUND')
// Zero writes happened.
expect(supabaseMock.tableCalls).not.toContain('employee_opening_balances')
})
it('upserts all items in one call (happy path)', async () => {
const secondId = '99999999-9999-4999-8999-999999999999'
mockServiceClient.mockReturnValue(
makeFlexibleSupabase({
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
employees: {
data: [
{ id: EMPLOYEE_ID, employment_start: '2024-01-15', is_active: true },
{ id: secondId, employment_start: '2025-03-01', is_active: true },
],
error: null,
},
salary_run_employees: { data: [], error: null },
employee_opening_balances: {
data: [SAMPLE_ROW, { ...SAMPLE_ROW, id: '11111111-1111-4111-8111-111111111111', employee_id: secondId }],
error: null,
},
}),
)
const res = await putBulk(
makeRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/employees/opening-balances`, {
method: 'PUT',
body: JSON.stringify({
items: [
{ employee_id: EMPLOYEE_ID, ...VALID_BODY },
{ employee_id: secondId, ...VALID_BODY },
],
}),
}),
companyParams(COMPANY_ID),
)
expect(res.status).toBe(200)
const body = await res.json()
expect(body.data.count).toBe(2)
})
it('rejects duplicate employee_ids in the same request', async () => {
mockServiceClient.mockReturnValue(
makeFlexibleSupabase({
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
}),
)
const res = await putBulk(
makeRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/employees/opening-balances`, {
method: 'PUT',
body: JSON.stringify({
items: [
{ employee_id: EMPLOYEE_ID, ...VALID_BODY },
{ employee_id: EMPLOYEE_ID, ...VALID_BODY },
],
}),
}),
companyParams(COMPANY_ID),
)
expect(res.status).toBe(400)
})
it('rejects keys without payroll:write scope', async () => {
mockValidate.mockResolvedValue({
userId: USER_ID,
companyId: COMPANY_ID,
apiKeyId: 'ak_1',
apiKeyName: 'read-only',
scopes: ['payroll:read'],
mode: 'live',
})
mockServiceClient.mockReturnValue(makeFlexibleSupabase({}))
const res = await putBulk(
makeRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/employees/opening-balances`, {
method: 'PUT',
body: JSON.stringify({ items: [{ employee_id: EMPLOYEE_ID, ...VALID_BODY }] }),
}),
companyParams(COMPANY_ID),
)
expect(res.status).toBe(403)
})
it('returns 401 without a bearer token', async () => {
mockValidate.mockResolvedValue(null)
mockServiceClient.mockReturnValue(makeFlexibleSupabase({}))
const res = await putBulk(
new Request(`https://x.test/api/v1/companies/${COMPANY_ID}/employees/opening-balances`, {
method: 'PUT',
body: JSON.stringify({ items: [{ employee_id: EMPLOYEE_ID, ...VALID_BODY }] }),
}),
companyParams(COMPANY_ID),
)
expect(res.status).toBe(401)
})
})
@@ -0,0 +1,213 @@
/**
* /api/v1/companies/{companyId}/employees/{id}/opening-balances
*
* GET : the employee's cutover opening balances + lock state.
* PUT : full-replace upsert (naturally idempotent). Editable until the
* employee appears in a BOOKED salary run; then 409
* OPENING_BALANCES_LOCKED (self-unlocks if that run is corrected).
*
* This is the payroll cutover surface for mid-year migrations from another
* payroll system: YTD accumulators (payslip continuity), vacation balances
* incl. sparade dagar by origin year, the opening semesterlöneskuld SEK
* (report-only: the 2920/2940 balance arrived via SIE opening balances),
* and the högriskskydd karens-count adjustment. Ongoing sick cases need no
* fields here: import pre-cutover days via PUT /employees/{id}/absence and
* the engine reconstructs segments, återinsjuknande, and karens state.
*/
import { z } from 'zod'
import { ok } from '@/lib/api/v1/response'
import { dryRunPreview } from '@/lib/api/v1/dry-run'
import { registerEndpoint, dataEnvelope } from '@/lib/api/v1/registry'
import { withApiV1 } from '@/lib/api/v1/with-api-v1'
import { v1ErrorResponseFromCode } from '@/lib/api/v1/errors'
import { OpeningBalancesFieldsSchema } from '@/lib/api/schemas'
import { getOpeningBalances, setOpeningBalancesBulk } from '@/lib/salary/opening-balances'
const OpeningBalancesResponse = z.object({
employee_opening_balances_id: z.string().uuid().nullable(),
employee_id: z.string().uuid(),
cutover_date: z.string(),
ytd_gross: z.number(),
ytd_tax: z.number(),
ytd_net: z.number(),
vacation_paid_days_remaining: z.number(),
vacation_saved_days_by_year: z.record(z.string(), z.number()),
opening_semester_liability: z.number(),
opening_semester_liability_avgifter: z.number(),
karens_periods_adjustment: z.number(),
locked: z.boolean(),
locked_by_run_id: z.string().uuid().nullable(),
})
registerEndpoint({
operation: 'employees.opening-balances.get',
method: 'GET',
path: '/api/v1/companies/:companyId/employees/:id/opening-balances',
summary: 'Get an employee\'s payroll cutover opening balances.',
description:
'Returns the opening balances set for a mid-year migration (YTD gross/tax/net, vacation balances, opening semesterlöneskuld, karens adjustment) plus the lock state: locked=true once the employee has a booked salary run.',
useWhen:
'Verifying cutover state before the first calculated run, or checking whether balances can still be edited (locked=false).',
doNotUseFor:
'The live vacation liability (GET /reports/vacation-liability includes the opening terms). Pre-cutover absence history: GET /employees/{id}/absence.',
pitfalls: [
'404 NOT_FOUND when no opening balances have been set: distinct from an all-zeros row.',
'locked_by_run_id names the booked run that froze the row; correcting that run unlocks it.',
],
example: {
response: {
data: {
employee_id: 'emp_77b2…',
cutover_date: '2026-07-01',
ytd_gross: 210000,
vacation_paid_days_remaining: 12.5,
locked: false,
},
meta: { request_id: 'req_…', api_version: '2026-05-12' },
},
},
scope: 'payroll:read',
risk: 'low',
idempotent: true,
reversible: false,
dryRunSupported: false,
response: { success: dataEnvelope(OpeningBalancesResponse) },
})
export const GET = withApiV1<{ params: Promise<{ companyId: string; id: string }> }>(
'employees.opening-balances.get',
async (_request, ctx, params) => {
const { id } = await params.params
const idParse = z.string().uuid().safeParse(id)
if (!idParse.success) {
return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, {
requestId: ctx.requestId,
details: { field: 'id', message: 'Employee id must be a UUID.' },
})
}
const result = await getOpeningBalances(ctx.supabase, {
companyId: ctx.companyId!,
employeeId: idParse.data,
})
if (!result.ok) {
return v1ErrorResponseFromCode(result.code, ctx.log, {
requestId: ctx.requestId,
details: result.details,
})
}
if (result.data === null) {
return v1ErrorResponseFromCode('NOT_FOUND', ctx.log, {
requestId: ctx.requestId,
details: { resource: 'employee_opening_balances', employee_id: idParse.data },
})
}
return ok(result.data, { requestId: ctx.requestId })
},
)
registerEndpoint({
operation: 'employees.opening-balances.set',
method: 'PUT',
path: '/api/v1/companies/:companyId/employees/:id/opening-balances',
summary: 'Set an employee\'s payroll cutover opening balances.',
description:
'Full-replace upsert of the cutover state: YTD gross/tax/net for the cutover year, paid vacation days remaining, sparade dagar keyed by origin year (5-year rule), opening semesterlöneskuld SEK (+avgifter), and karens periods not covered by imported absence rows. cutover_date must be the first of a month in the current or previous year, on/after employment_start.',
useWhen:
'Onboarding one employee during a mid-year migration from Fortnox/Visma/etc. For whole-company onboarding, prefer the bulk PUT /employees/opening-balances.',
doNotUseFor:
'SIE opening balances on the LEDGER (2920/2940 arrive via the SIE import). Ongoing sick cases: import pre-cutover days via PUT /employees/{id}/absence instead.',
pitfalls: [
'Full replace: omitted numeric fields reset to 0 (their defaults). Send the complete state every time.',
'409 OPENING_BALANCES_LOCKED once the employee has a booked run; correcting that run unlocks.',
'The opening liability is NOT booked by Accounted: it only feeds the vacation-liability report.',
'YTD affects payslip display and reports only; per-month tax and avgifter caps never read it.',
],
example: {
request: {
cutover_date: '2026-07-01',
ytd_gross: 210000,
ytd_tax: 48000,
ytd_net: 162000,
vacation_paid_days_remaining: 12.5,
vacation_saved_days_by_year: { '2025': 5 },
opening_semester_liability: 42000,
opening_semester_liability_avgifter: 13196.4,
karens_periods_adjustment: 1,
},
response: {
data: { employee_id: 'emp_77b2…', cutover_date: '2026-07-01', locked: false },
meta: { request_id: 'req_…', api_version: '2026-05-12' },
},
},
scope: 'payroll:write',
risk: 'medium',
idempotent: true,
reversible: true,
dryRunSupported: true,
request: { body: OpeningBalancesFieldsSchema },
response: { success: dataEnvelope(OpeningBalancesResponse) },
})
export const PUT = withApiV1<{ params: Promise<{ companyId: string; id: string }> }>(
'employees.opening-balances.set',
async (request, ctx, params) => {
const { id } = await params.params
const idParse = z.string().uuid().safeParse(id)
if (!idParse.success) {
return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, {
requestId: ctx.requestId,
details: { field: 'id', message: 'Employee id must be a UUID.' },
})
}
let rawBody: unknown
try {
rawBody = await request.json()
} catch {
return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, {
requestId: ctx.requestId,
details: { field: 'body', message: 'Body is not valid JSON.' },
})
}
const parsed = OpeningBalancesFieldsSchema.safeParse(rawBody)
if (!parsed.success) {
return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, {
requestId: ctx.requestId,
details: {
issues: parsed.error.issues.map((i) => ({
field: i.path.join('.'),
message: i.message,
})),
},
})
}
// The per-employee PUT is the bulk handler with one item: one validation
// and one upsert path to maintain.
const result = await setOpeningBalancesBulk(ctx.supabase, {
companyId: ctx.companyId!,
userId: ctx.userId,
items: [{ employee_id: idParse.data, ...parsed.data }],
dryRun: ctx.dryRun,
})
if (!result.ok) {
// Single-item calls surface the item's own error code directly (a
// one-element 422 list would just be indirection).
const itemError = result.itemErrors?.[0]
return v1ErrorResponseFromCode(itemError?.code ?? result.code, ctx.log, {
requestId: ctx.requestId,
details: itemError ? { message: itemError.message } : result.details,
})
}
const row = result.data.rows[0]
if (ctx.dryRun) {
return dryRunPreview(row, { requestId: ctx.requestId, log: ctx.log })
}
return ok(row, { requestId: ctx.requestId })
},
)
@@ -41,6 +41,10 @@ const EmployeeDetail = z.object({
employment_start: z.string(),
employment_end: z.string().nullable(),
employment_degree: z.number(),
// Arbetsschema-lite: weekly schedule driving the salary engine's hourly
// (173 at 40h) and daily (21 at 5d) divisors.
hours_per_week: z.number(),
workdays_per_week: z.number(),
salary_type: SalaryType,
monthly_salary: z.number().nullable(),
hourly_rate: z.number().nullable(),
@@ -62,6 +66,12 @@ const EmployeeDetail = z.object({
vaxa_stod_eligible: z.boolean(),
vaxa_stod_start: z.string().nullable(),
vaxa_stod_end: z.string().nullable(),
// Jämkning (Skatteverket beslut om ändrad beräkning av skatteavdrag):
// fixed withholding percentage for a bounded period, overrides the
// tax-table lookup at calculation time (payroll gap-closure 1.5).
jamkning_percentage: z.number().nullable(),
jamkning_valid_from: z.string().nullable(),
jamkning_valid_to: z.string().nullable(),
// Dimensions PR8: bag applied to the employee's P&L cost lines at booking.
default_dimensions: z.record(z.string(), z.string()),
is_active: z.boolean(),
@@ -70,7 +80,7 @@ const EmployeeDetail = z.object({
})
const EMPLOYEE_DETAIL_COLUMNS =
'id, first_name, last_name, personnummer, employment_type, employment_start, employment_end, employment_degree, salary_type, monthly_salary, hourly_rate, tax_table_number, tax_column, tax_municipality, is_sidoinkomst, f_skatt_status, clearing_number, bank_account_number, vacation_rule, vacation_days_per_year, semestertillagg_rate, email, phone, address_line1, postal_code, city, vaxa_stod_eligible, vaxa_stod_start, vaxa_stod_end, default_dimensions, is_active, created_at, updated_at'
'id, first_name, last_name, personnummer, employment_type, employment_start, employment_end, employment_degree, hours_per_week, workdays_per_week, salary_type, monthly_salary, hourly_rate, tax_table_number, tax_column, tax_municipality, is_sidoinkomst, f_skatt_status, clearing_number, bank_account_number, vacation_rule, vacation_days_per_year, semestertillagg_rate, email, phone, address_line1, postal_code, city, vaxa_stod_eligible, vaxa_stod_start, vaxa_stod_end, jamkning_percentage, jamkning_valid_from, jamkning_valid_to, default_dimensions, is_active, created_at, updated_at'
/**
* Shape returned by PATCH (success + dry-run preview) and by no-change PATCH.
@@ -346,6 +356,51 @@ export const PATCH = withApiV1<{ params: Promise<{ companyId: string; id: string
})
}
// Merged-state jämkning check (same pattern as växa-stöd): a non-null
// percentage needs a start date, but the schema can only see the body.
// Also validate merged date ordering when only one of the dates is
// updated. Setting jamkning_percentage to null clears the beslut and
// skips these checks. Only run when the PATCH touches a jamkning field:
// a legacy row with inconsistent jamkning_* state must not block
// unrelated updates (fixing it requires touching those very fields).
const jamkningTouched =
'jamkning_percentage' in updates ||
'jamkning_valid_from' in updates ||
'jamkning_valid_to' in updates
if (jamkningTouched) {
const mergedJamkningPct =
'jamkning_percentage' in updates
? (updates.jamkning_percentage as number | null)
: ((existing as Record<string, unknown>).jamkning_percentage as number | null)
const mergedJamkningFrom =
'jamkning_valid_from' in updates
? (updates.jamkning_valid_from as string | null)
: ((existing as Record<string, unknown>).jamkning_valid_from as string | null)
const mergedJamkningTo =
'jamkning_valid_to' in updates
? (updates.jamkning_valid_to as string | null)
: ((existing as Record<string, unknown>).jamkning_valid_to as string | null)
if (mergedJamkningPct !== null && mergedJamkningPct !== undefined && !mergedJamkningFrom) {
return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, {
requestId: ctx.requestId,
details: {
field: 'jamkning_valid_from',
message:
'Jämkningens startdatum måste anges när jämkningsprocent sätts. Skicka även `jamkning_valid_from` i samma PATCH.',
},
})
}
if (mergedJamkningFrom && mergedJamkningTo && mergedJamkningTo < mergedJamkningFrom) {
return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, {
requestId: ctx.requestId,
details: {
field: 'jamkning_valid_to',
message: 'Jämkningens slutdatum måste vara efter startdatumet.',
},
})
}
}
if (Object.keys(updates).length === 0) {
// GDPR Art.5(1)(c): no-change PATCH still returns a write-shape, so
// mask personnummer just like the POST + PATCH success path.
@@ -0,0 +1,179 @@
/**
* Tests for GET /api/v1/companies/{companyId}/employees/{id}/vacation-balance
* (payroll gap-closure 3.4).
*/
import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
beforeAll(() => {
if (process.env.NODE_ENV !== 'test') {
throw new Error(
`vacation-balance route tests require NODE_ENV=test (got ${process.env.NODE_ENV ?? 'undefined'})`,
)
}
process.env.NEXT_PUBLIC_SUPABASE_URL ||= 'http://localhost:54321'
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY ||= 'test-anon-key'
})
vi.mock('@/lib/auth/api-keys', async () => {
const actual = await vi.importActual<typeof import('@/lib/auth/api-keys')>('@/lib/auth/api-keys')
return {
...actual,
validateApiKey: vi.fn(),
createServiceClientNoCookies: vi.fn(),
}
})
vi.mock('@supabase/supabase-js', async () => {
const actual = await vi.importActual<typeof import('@supabase/supabase-js')>('@supabase/supabase-js')
return { ...actual, createClient: vi.fn().mockReturnValue({}) }
})
import { validateApiKey, createServiceClientNoCookies } from '@/lib/auth/api-keys'
import { GET as getBalance } from '../route'
const mockValidate = validateApiKey as ReturnType<typeof vi.fn>
const mockServiceClient = createServiceClientNoCookies as ReturnType<typeof vi.fn>
function makeFlexibleSupabase(byTable: Record<string, { data?: unknown; error?: unknown }>) {
const buildChain = (table: string): unknown => {
const handler: ProxyHandler<object> = {
get(_target, prop) {
if (prop === 'then') {
return (resolve: (v: unknown) => void) =>
resolve(byTable[table] ?? { data: null, error: null })
}
return (..._args: unknown[]) => buildChain(table)
},
}
return new Proxy({}, handler)
}
return { from: vi.fn((table: string) => buildChain(table)) }
}
const COMPANY_ID = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa'
const EMPLOYEE_ID = 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb'
const BALANCE_ID = 'cccccccc-cccc-4ccc-8ccc-cccccccccccc'
const EMPLOYEE = {
id: EMPLOYEE_ID,
vacation_rule: 'sammaloneregeln',
vacation_days_per_year: 25,
salary_type: 'monthly',
monthly_salary: 30000,
hourly_rate: null,
hours_per_week: 40,
workdays_per_week: 5,
}
const BALANCE = {
id: BALANCE_ID,
employee_id: EMPLOYEE_ID,
vacation_year_start: '2026-01-01',
entitled_days: 25,
accrued_days: 0,
taken_days: 10,
saved_days: { '2025': 5 },
forced_payout_days: 0,
}
function makeRequest(url: string): Request {
return new Request(url, {
headers: { Authorization: 'Bearer test-fixture-not-a-real-key' },
})
}
function detailParams(companyId: string, id: string) {
return { params: Promise.resolve({ companyId, id }) }
}
beforeEach(() => {
vi.clearAllMocks()
mockValidate.mockResolvedValue({
userId: 'user-1',
companyId: COMPANY_ID,
apiKeyId: 'ak_1',
apiKeyName: 'CI key',
scopes: ['payroll:read'],
mode: 'live',
})
})
describe('GET /employees/:id/vacation-balance', () => {
it('returns the balance with remaining days and a SEK estimate', async () => {
mockServiceClient.mockReturnValue(
makeFlexibleSupabase({
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
employees: { data: EMPLOYEE, error: null },
employee_vacation_balances: { data: BALANCE, error: null },
}),
)
const res = await getBalance(
makeRequest(
`https://x.test/api/v1/companies/${COMPANY_ID}/employees/${EMPLOYEE_ID}/vacation-balance`,
),
detailParams(COMPANY_ID, EMPLOYEE_ID),
)
expect(res.status).toBe(200)
const body = await res.json()
expect(body.data.employee_vacation_balance_id).toBe(BALANCE_ID)
expect(body.data.remaining_days).toBe(15)
expect(body.data.saved_days_total).toBe(5)
// Day value sammalöneregeln: 30000/21 + 30000 x 0.0043 = 1557.57.
// Liability = (15 remaining + 5 saved) x 1557.57 = 31151.4.
expect(body.data.estimated_liability_sek).toBe(31151.4)
expect(body.data.id).toBeUndefined()
})
it('returns 404 VACATION_BALANCE_NOT_FOUND before the ledger seeds', async () => {
mockServiceClient.mockReturnValue(
makeFlexibleSupabase({
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
employees: { data: EMPLOYEE, error: null },
employee_vacation_balances: { data: null, error: null },
}),
)
const res = await getBalance(
makeRequest(
`https://x.test/api/v1/companies/${COMPANY_ID}/employees/${EMPLOYEE_ID}/vacation-balance`,
),
detailParams(COMPANY_ID, EMPLOYEE_ID),
)
expect(res.status).toBe(404)
const body = await res.json()
expect(body.error.code).toBe('VACATION_BALANCE_NOT_FOUND')
})
it('returns 404 EMPLOYEE_NOT_FOUND for an unknown employee', async () => {
mockServiceClient.mockReturnValue(
makeFlexibleSupabase({
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
employees: { data: null, error: null },
}),
)
const res = await getBalance(
makeRequest(
`https://x.test/api/v1/companies/${COMPANY_ID}/employees/${EMPLOYEE_ID}/vacation-balance`,
),
detailParams(COMPANY_ID, EMPLOYEE_ID),
)
expect(res.status).toBe(404)
const body = await res.json()
expect(body.error.code).toBe('EMPLOYEE_NOT_FOUND')
})
it('returns 401 without a bearer token', async () => {
mockValidate.mockResolvedValue(null)
mockServiceClient.mockReturnValue(makeFlexibleSupabase({}))
const res = await getBalance(
new Request(
`https://x.test/api/v1/companies/${COMPANY_ID}/employees/${EMPLOYEE_ID}/vacation-balance`,
),
detailParams(COMPANY_ID, EMPLOYEE_ID),
)
expect(res.status).toBe(401)
})
})
@@ -0,0 +1,174 @@
/**
* GET /api/v1/companies/{companyId}/employees/{id}/vacation-balance
*
* The employee's current OPEN vacation-ledger row: entitled/taken/remaining
* days, sparade dagar by origin year, forced payouts, plus a computed SEK
* estimate of the individual semesterlöneskuld (same day valuation the
* year-close uses).
*/
import { z } from 'zod'
import { ok } from '@/lib/api/v1/response'
import { registerEndpoint, dataEnvelope } from '@/lib/api/v1/registry'
import { withApiV1 } from '@/lib/api/v1/with-api-v1'
import { v1ErrorResponse, v1ErrorResponseFromCode } from '@/lib/api/v1/errors'
import { roundOre } from '@/lib/money'
import { dailyDivisor } from '@/lib/salary/work-schedule'
const VacationBalanceResponse = z.object({
employee_vacation_balance_id: z.string().uuid(),
employee_id: z.string().uuid(),
vacation_year_start: z.string(),
entitled_days: z.number(),
accrued_days: z.number(),
taken_days: z.number(),
remaining_days: z.number(),
saved_days: z.record(z.string(), z.number()),
saved_days_total: z.number(),
forced_payout_days: z.number(),
estimated_liability_sek: z.number(),
})
registerEndpoint({
operation: 'employees.vacation-balance.get',
method: 'GET',
path: '/api/v1/companies/:companyId/employees/:id/vacation-balance',
summary: 'Get an employee\'s current vacation balance.',
description:
'Returns the open vacation-ledger row (recomputed on every booking): entitled/taken/remaining days, sparade dagar keyed by origin year (Semesterlagen 5-year rule), forced-payout days from expired savings, and a computed SEK estimate of the individual semesterlöneskuld.',
useWhen:
'Answering "how many vacation days does Anna have left", pre-payroll review, or preparing the year-close.',
doNotUseFor:
'The company-wide liability report: GET /reports/vacation-liability. Closing the year: POST /salary/vacation-year-close.',
pitfalls: [
'404 VACATION_BALANCE_NOT_FOUND until the first booking (or year-close) touches the employee: the ledger seeds lazily.',
'remaining_days can go negative if more days were taken than entitled: surface it, do not clamp.',
'The SEK estimate uses the year-close day valuation (simplified BFNAR 2016:10); the booked 2920 is reconciled only at year-close.',
],
example: {
response: {
data: {
employee_id: 'emp_77b2…',
vacation_year_start: '2026-01-01',
entitled_days: 25,
taken_days: 10,
remaining_days: 15,
saved_days: { '2025': 5 },
saved_days_total: 5,
estimated_liability_sek: 31151.4,
},
meta: { request_id: 'req_…', api_version: '2026-05-12' },
},
},
scope: 'payroll:read',
risk: 'low',
idempotent: true,
reversible: false,
dryRunSupported: false,
response: { success: dataEnvelope(VacationBalanceResponse) },
})
export const GET = withApiV1<{ params: Promise<{ companyId: string; id: string }> }>(
'employees.vacation-balance.get',
async (_request, ctx, params) => {
const { id } = await params.params
const idParse = z.string().uuid().safeParse(id)
if (!idParse.success) {
return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, {
requestId: ctx.requestId,
details: { field: 'id', message: 'Employee id must be a UUID.' },
})
}
const { data: employee, error: empErr } = await ctx.supabase
.from('employees')
.select('id, vacation_rule, vacation_days_per_year, salary_type, monthly_salary, hourly_rate, hours_per_week, workdays_per_week')
.eq('id', idParse.data)
.eq('company_id', ctx.companyId!)
.maybeSingle()
if (empErr) {
return v1ErrorResponse(empErr, ctx.log, { requestId: ctx.requestId })
}
if (!employee) {
return v1ErrorResponseFromCode('EMPLOYEE_NOT_FOUND', ctx.log, { requestId: ctx.requestId })
}
const { data: balance, error: balErr } = await ctx.supabase
.from('employee_vacation_balances')
.select('id, employee_id, vacation_year_start, entitled_days, accrued_days, taken_days, saved_days, forced_payout_days')
.eq('company_id', ctx.companyId!)
.eq('employee_id', idParse.data)
.eq('status', 'open')
.order('vacation_year_start', { ascending: false })
.limit(1)
.maybeSingle()
if (balErr) {
return v1ErrorResponse(balErr, ctx.log, { requestId: ctx.requestId })
}
if (!balance) {
return v1ErrorResponseFromCode('VACATION_BALANCE_NOT_FOUND', ctx.log, {
requestId: ctx.requestId,
details: { employee_id: idParse.data },
})
}
const row = balance as {
id: string
employee_id: string
vacation_year_start: string
entitled_days: number
accrued_days: number
taken_days: number
saved_days: Record<string, number> | null
forced_payout_days: number
}
const emp = employee as {
vacation_rule: string
vacation_days_per_year: number
salary_type: string
monthly_salary: number | null
hourly_rate: number | null
hours_per_week: number | null
workdays_per_week: number | null
}
const savedDays = row.saved_days ?? {}
const savedTotal = Object.values(savedDays).reduce((s, d) => s + (Number(d) || 0), 0)
const remaining = roundOre(row.entitled_days - row.taken_days)
// Same simplified BFNAR 2016:10 day valuation the year-close uses.
const rate = emp.vacation_days_per_year >= 30 ? 0.144 : 0.12
let dayValue: number
if (emp.salary_type === 'hourly') {
dayValue = roundOre(
((emp.hourly_rate || 0) * (emp.hours_per_week ?? 40) * 52 * rate) /
Math.max(emp.vacation_days_per_year, 1),
)
} else if (emp.vacation_rule === 'sammaloneregeln') {
const monthly = emp.monthly_salary || 0
dayValue = roundOre(monthly / dailyDivisor(emp.workdays_per_week) + monthly * 0.0043)
} else {
dayValue = roundOre(
((emp.monthly_salary || 0) * 12 * rate) / Math.max(emp.vacation_days_per_year, 1),
)
}
const estimatedLiability = roundOre(Math.max(0, remaining + savedTotal) * dayValue)
return ok(
{
employee_vacation_balance_id: row.id,
employee_id: row.employee_id,
vacation_year_start: row.vacation_year_start,
entitled_days: row.entitled_days,
accrued_days: row.accrued_days,
taken_days: row.taken_days,
remaining_days: remaining,
saved_days: savedDays,
saved_days_total: savedTotal,
forced_payout_days: row.forced_payout_days,
estimated_liability_sek: estimatedLiability,
},
{ requestId: ctx.requestId },
)
},
)
@@ -146,6 +146,9 @@ const SAMPLE_EMPLOYEE = {
vaxa_stod_eligible: false,
vaxa_stod_start: null,
vaxa_stod_end: null,
jamkning_percentage: null,
jamkning_valid_from: null,
jamkning_valid_to: null,
is_active: true,
created_at: '2024-01-15T08:00:00Z',
updated_at: '2024-01-15T08:00:00Z',
@@ -648,6 +651,159 @@ describe('PATCH /api/v1/companies/:companyId/employees/:id', () => {
expect(body.error.details.field).toBe('personnummer')
})
it('sets work-schedule fields (arbetsschema-lite)', async () => {
const updated = { ...SAMPLE_EMPLOYEE, hours_per_week: 32, workdays_per_week: 4 }
mockServiceClient.mockReturnValue(
makeFlexibleSupabase({
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
employees: [{ data: SAMPLE_EMPLOYEE, error: null }, { data: updated, error: null }],
idempotency_keys: { data: null, error: null },
}),
)
const res = await updateEmployee(
makeRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/employees/${EMPLOYEE_ID}`, {
method: 'PATCH',
body: JSON.stringify({ hours_per_week: 32, workdays_per_week: 4 }),
}),
detailParams(COMPANY_ID, EMPLOYEE_ID),
)
expect(res.status).toBe(200)
const body = await res.json()
expect(body.data.hours_per_week).toBe(32)
expect(body.data.workdays_per_week).toBe(4)
})
it('rejects an out-of-range work schedule', async () => {
mockServiceClient.mockReturnValue(
makeFlexibleSupabase({
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
}),
)
const res = await updateEmployee(
makeRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/employees/${EMPLOYEE_ID}`, {
method: 'PATCH',
body: JSON.stringify({ workdays_per_week: 9 }),
}),
detailParams(COMPANY_ID, EMPLOYEE_ID),
)
expect(res.status).toBe(400)
})
it('sets jämkning fields (percentage + validity window)', async () => {
const updated = {
...SAMPLE_EMPLOYEE,
jamkning_percentage: 15,
jamkning_valid_from: '2026-01-01',
jamkning_valid_to: '2026-12-31',
}
mockServiceClient.mockReturnValue(
makeFlexibleSupabase({
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
employees: [{ data: SAMPLE_EMPLOYEE, error: null }, { data: updated, error: null }],
idempotency_keys: { data: null, error: null },
}),
)
const res = await updateEmployee(
makeRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/employees/${EMPLOYEE_ID}`, {
method: 'PATCH',
body: JSON.stringify({
jamkning_percentage: 15,
jamkning_valid_from: '2026-01-01',
jamkning_valid_to: '2026-12-31',
}),
}),
detailParams(COMPANY_ID, EMPLOYEE_ID),
)
expect(res.status).toBe(200)
const body = await res.json()
expect(body.data.jamkning_percentage).toBe(15)
expect(body.data.jamkning_valid_from).toBe('2026-01-01')
})
it('rejects a jämkning percentage without a start date (merged state)', async () => {
// Existing row has no jamkning_valid_from; sending only the percentage
// must fail the route-level merged-state check.
mockServiceClient.mockReturnValue(
makeFlexibleSupabase({
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
employees: { data: SAMPLE_EMPLOYEE, error: null },
idempotency_keys: { data: null, error: null },
}),
)
const res = await updateEmployee(
makeRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/employees/${EMPLOYEE_ID}`, {
method: 'PATCH',
body: JSON.stringify({ jamkning_percentage: 15 }),
}),
detailParams(COMPANY_ID, EMPLOYEE_ID),
)
expect(res.status).toBe(400)
const body = await res.json()
expect(body.error.code).toBe('VALIDATION_ERROR')
expect(body.error.details.field).toBe('jamkning_valid_from')
})
it('rejects jamkning_valid_to before jamkning_valid_from', async () => {
mockServiceClient.mockReturnValue(
makeFlexibleSupabase({
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
employees: { data: SAMPLE_EMPLOYEE, error: null },
idempotency_keys: { data: null, error: null },
}),
)
const res = await updateEmployee(
makeRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/employees/${EMPLOYEE_ID}`, {
method: 'PATCH',
body: JSON.stringify({
jamkning_percentage: 15,
jamkning_valid_from: '2026-06-01',
jamkning_valid_to: '2026-01-01',
}),
}),
detailParams(COMPANY_ID, EMPLOYEE_ID),
)
expect(res.status).toBe(400)
const body = await res.json()
expect(body.error.code).toBe('VALIDATION_ERROR')
})
it('clears the jämkningsbeslut with an explicit null', async () => {
const withJamkning = {
...SAMPLE_EMPLOYEE,
jamkning_percentage: 15,
jamkning_valid_from: '2026-01-01',
jamkning_valid_to: null,
}
const cleared = { ...SAMPLE_EMPLOYEE }
mockServiceClient.mockReturnValue(
makeFlexibleSupabase({
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
employees: [{ data: withJamkning, error: null }, { data: cleared, error: null }],
idempotency_keys: { data: null, error: null },
}),
)
const res = await updateEmployee(
makeRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/employees/${EMPLOYEE_ID}`, {
method: 'PATCH',
body: JSON.stringify({ jamkning_percentage: null }),
}),
detailParams(COMPANY_ID, EMPLOYEE_ID),
)
expect(res.status).toBe(200)
const body = await res.json()
expect(body.data.jamkning_percentage).toBeNull()
})
it('returns a dry-run preview with masked personnummer', async () => {
// GDPR Art.5(1)(c): the dry-run preview is a write-shape so it follows
// the same masking rule as POST and PATCH success. The full value is
@@ -0,0 +1,126 @@
/**
* PUT /api/v1/companies/{companyId}/employees/opening-balances
*
* Bulk full-replace upsert of payroll cutover opening balances: the byrå/
* integrator onboarding surface for mid-year migrations. ATOMIC
* all-or-nothing: every item is validated against live state first
* (employee exists + active, cutover >= employment_start, not locked by a
* booked run); any failure returns the complete per-item error list with
* ZERO writes, so the caller fixes the file and resubmits.
*/
import { z } from 'zod'
import { ok } from '@/lib/api/v1/response'
import { dryRunPreview } from '@/lib/api/v1/dry-run'
import { registerEndpoint, dataEnvelope } from '@/lib/api/v1/registry'
import { withApiV1 } from '@/lib/api/v1/with-api-v1'
import { v1ErrorResponse, v1ErrorResponseFromCode } from '@/lib/api/v1/errors'
import { OpeningBalancesBulkSchema } from '@/lib/api/schemas'
import { setOpeningBalancesBulk } from '@/lib/salary/opening-balances'
const BulkResponse = z.object({
count: z.number().int(),
rows: z.array(
z.object({
employee_opening_balances_id: z.string().uuid().nullable(),
employee_id: z.string().uuid(),
cutover_date: z.string(),
locked: z.boolean(),
}),
),
})
registerEndpoint({
operation: 'employees.opening-balances.bulk-set',
method: 'PUT',
path: '/api/v1/companies/:companyId/employees/opening-balances',
summary: 'Bulk-set payroll cutover opening balances (atomic).',
description:
'Upserts opening balances for up to 200 employees in one call. Validation is all-or-nothing: any invalid item (unknown/inactive employee, cutover before employment_start, locked by a booked run) fails the WHOLE request with a per-item error list and zero writes.',
useWhen:
'Onboarding a whole company mid-year from another payroll system: one call per migration file instead of N sequential PUTs.',
doNotUseFor:
'Single-employee corrections after go-live: PUT /employees/{id}/opening-balances. Ledger opening balances (SIE import).',
pitfalls: [
'Atomic: one bad item fails everything. The error details carry item_errors[{index, employee_id, code, message}]: fix and resubmit the full set.',
'Full replace per employee: resubmitting with fewer fields resets the omitted ones to 0.',
'Duplicate employee_id within items is rejected outright.',
],
example: {
request: {
items: [
{ employee_id: 'emp_77b2…', cutover_date: '2026-07-01', ytd_gross: 210000, ytd_tax: 48000, ytd_net: 162000 },
],
},
response: {
data: { count: 1, rows: [{ employee_id: 'emp_77b2…', cutover_date: '2026-07-01', locked: false }] },
meta: { request_id: 'req_…', api_version: '2026-05-12' },
},
},
scope: 'payroll:write',
risk: 'medium',
idempotent: true,
reversible: true,
dryRunSupported: true,
request: { body: OpeningBalancesBulkSchema },
response: { success: dataEnvelope(BulkResponse) },
})
export const PUT = withApiV1<{ params: Promise<{ companyId: string }> }>(
'employees.opening-balances.bulk-set',
async (request, ctx) => {
let rawBody: unknown
try {
rawBody = await request.json()
} catch {
return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, {
requestId: ctx.requestId,
details: { field: 'body', message: 'Body is not valid JSON.' },
})
}
const parsed = OpeningBalancesBulkSchema.safeParse(rawBody)
if (!parsed.success) {
return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, {
requestId: ctx.requestId,
details: {
issues: parsed.error.issues.map((i) => ({
field: i.path.join('.'),
message: i.message,
})),
},
})
}
const result = await setOpeningBalancesBulk(ctx.supabase, {
companyId: ctx.companyId!,
userId: ctx.userId,
items: parsed.data.items,
dryRun: ctx.dryRun,
})
if (!result.ok) {
if (result.itemErrors) {
// Atomic contract: full per-item error list, zero writes.
return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, {
requestId: ctx.requestId,
details: { item_errors: result.itemErrors },
})
}
if (result.code === 'INTERNAL_ERROR') {
return v1ErrorResponse(new Error(String(result.details?.message ?? 'bulk upsert failed')), ctx.log, {
requestId: ctx.requestId,
})
}
return v1ErrorResponseFromCode(result.code, ctx.log, {
requestId: ctx.requestId,
details: result.details,
})
}
if (ctx.dryRun) {
return dryRunPreview(result.data, { requestId: ctx.requestId, log: ctx.log })
}
return ok(result.data, { requestId: ctx.requestId })
},
)
@@ -416,6 +416,8 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string }> }>(
employment_start: body.employment_start,
employment_end: body.employment_end ?? null,
employment_degree: body.employment_degree,
hours_per_week: body.hours_per_week,
workdays_per_week: body.workdays_per_week,
salary_type: body.salary_type,
monthly_salary: body.monthly_salary ?? null,
hourly_rate: body.hourly_rate ?? null,
@@ -437,6 +439,9 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string }> }>(
vaxa_stod_eligible: body.vaxa_stod_eligible,
vaxa_stod_start: body.vaxa_stod_start ?? null,
vaxa_stod_end: body.vaxa_stod_end ?? null,
jamkning_percentage: body.jamkning_percentage ?? null,
jamkning_valid_from: body.jamkning_valid_from ?? null,
jamkning_valid_to: body.jamkning_valid_to ?? null,
// Dimensions PR8: bag for the employee's P&L cost lines at booking.
default_dimensions: body.default_dimensions ?? {},
})
@@ -369,6 +369,50 @@ describe('POST /api/v1/companies/:companyId/invoices/:id/mark-paid', () => {
expect(mockPayment).not.toHaveBeenCalled()
})
it('absorbs an öresavrundning overshoot on SEK custom lines (rounded "Att betala")', async () => {
// Invoice stored with öre (1234.75); the PDF shows 1235.00 and the customer
// pays that. The 3740 line carries the residual and the invoice settles in
// full instead of being rejected as an overpayment.
const ORE_INVOICE = {
...SENT_INVOICE,
subtotal: 987.8,
vat_amount: 246.95,
total: 1234.75,
remaining_amount: 1234.75,
}
mockServiceClient.mockReturnValue(
makeFlexibleSupabase({
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
invoices: [
{ data: ORE_INVOICE, error: null },
{ data: { ...ORE_INVOICE, status: 'paid', remaining_amount: 0, paid_amount: 1234.75 }, error: null },
],
company_settings: { data: { accounting_method: 'accrual', entity_type: 'enskild_firma' }, error: null },
transactions: { data: [], error: null },
}),
)
const res = await markPaid(
makeRequest(
`https://x.test/api/v1/companies/${COMPANY_ID}/invoices/${INVOICE_ID}/mark-paid`,
{
payment_date: '2026-05-12',
lines: [
{ account_number: '1930', debit_amount: 1235, credit_amount: 0 },
{ account_number: '1510', debit_amount: 0, credit_amount: 1234.75 },
{ account_number: '3740', debit_amount: 0, credit_amount: 0.25 },
],
},
),
detailParams(COMPANY_ID, INVOICE_ID),
)
expect(res.status).toBe(200)
const body = await res.json()
expect(body.data.status).toBe('paid')
expect(body.data.remaining_amount).toBe(0)
})
it('returns 400 INVOICE_PAID_NOT_PAYABLE for draft invoices', async () => {
mockServiceClient.mockReturnValue(
makeFlexibleSupabase({
@@ -42,7 +42,7 @@ import { AccountsNotInChartError, isBookkeepingError } from '@/lib/bookkeeping/e
import { getErrorMessage } from '@/lib/errors/get-error-message'
import { eventBus } from '@/lib/events'
import { findDuplicatePaymentCandidatesForInvoice } from '@/lib/invoices/duplicate-payment-candidates'
import { planInvoicePayment } from '@/lib/invoices/apply-invoice-payment'
import { planInvoicePaymentForLines } from '@/lib/invoices/apply-invoice-payment'
import { roundOre } from '@/lib/money'
import type { CreateJournalEntryInput, EntityType, Invoice } from '@/types'
@@ -285,7 +285,18 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string
const paymentAmountInInvoiceCurrency = customLines
? roundOre(paymentAmount / fxRate)
: paymentAmount
const payment = planInvoicePayment(typed, paymentAmountInInvoiceCurrency)
// Custom-line SEK settlements absorb a sub-krona öresavrundning residual
// (rounded "Att betala" vs the stored öre total), but ONLY when the lines
// actually carry the residual on 3740: otherwise the strict plan applies
// (sub-krona partials stay partial, overshoot rejects), mirroring the
// dashboard mark-paid flow. The default path pays the exact remaining, so
// absorption is a no-op there.
const payment = planInvoicePaymentForLines(
typed,
paymentAmountInInvoiceCurrency,
customLines,
typed.currency ?? 'SEK',
)
if (!payment.ok) {
return v1ErrorResponseFromCode('MATCH_AMOUNT_EXCEEDS_REMAINING', ctx.log, {
requestId: ctx.requestId,
@@ -34,6 +34,7 @@ import { withApiV1 } from '@/lib/api/v1/with-api-v1'
import { v1ErrorResponse, v1ErrorResponseFromCode } from '@/lib/api/v1/errors'
import { checkPeriodLock } from '@/lib/api/v1/check-period-lock'
import { createSalaryRunEntries } from '@/lib/salary/salary-entries'
import { syncVacationLedgerForEmployees } from '@/lib/salary/vacation-ledger'
import { isBookkeepingError } from '@/lib/bookkeeping/errors'
import { eventBus } from '@/lib/events'
@@ -346,6 +347,17 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string
ctx.log.warn('salary_run.booked emit failed', err as Error)
}
// Vacation ledger sync (non-fatal: the ledger recomputes and self-heals
// on the next booking; a sync bug must never block a booking).
const ledgerSync = await syncVacationLedgerForEmployees(
ctx.supabase,
ctx.companyId!,
(employees as Array<{ employee_id: string }>).map((sre) => sre.employee_id),
)
if (!ledgerSync.ok) {
ctx.log.warn('vacation ledger sync failed after booking', { message: ledgerSync.message })
}
const bookedAt = (bookedRun as { booked_at: string }).booked_at
return ok(
@@ -0,0 +1,150 @@
/**
* POST /api/v1/companies/{companyId}/salary-runs/{id}/employees/{employeeId}/lines
*
* Add a payslip line item (bonus, overtime, deduction, benefit, ...) to one
* employee in a DRAFT salary run. The path addresses the employee by
* employee_id: the route resolves the salary_run_employees join row itself.
*
* Draft-only (BFL 5 kap: once the run advances, its numbers feed a
* verifikation). Line edits do NOT recompute tax/avgifter: call
* POST /salary-runs/{id}/calculate afterwards.
*/
import { z } from 'zod'
import { created } from '@/lib/api/v1/response'
import { dryRunPreview } from '@/lib/api/v1/dry-run'
import { registerEndpoint, dataEnvelope } from '@/lib/api/v1/registry'
import { withApiV1 } from '@/lib/api/v1/with-api-v1'
import { v1ErrorResponseFromCode } from '@/lib/api/v1/errors'
import { CreateSalaryLineItemSchema } from '@/lib/api/schemas'
import { createPayslipLine } from '@/lib/salary/payslip-lines'
// The path resolves the employee; the body must not carry the join-row id.
const CreateLineBody = CreateSalaryLineItemSchema.omit({ salary_run_employee_id: true })
const LineItemResponse = z.object({
salary_line_item_id: z.string().uuid().nullable(),
salary_run_employee_id: z.string().uuid(),
item_type: z.string(),
description: z.string(),
quantity: z.number().nullable(),
unit_price: z.number().nullable(),
amount: z.number(),
is_taxable: z.boolean(),
is_avgift_basis: z.boolean(),
is_vacation_basis: z.boolean(),
is_gross_deduction: z.boolean(),
is_net_deduction: z.boolean(),
account_number: z.string().nullable(),
sort_order: z.number(),
})
registerEndpoint({
operation: 'salary-runs.lines.create',
method: 'POST',
path: '/api/v1/companies/:companyId/salary-runs/:id/employees/:employeeId/lines',
summary: 'Add a payslip line to an employee in a draft salary run.',
description:
'Creates a salary_line_items row (bonus, overtime, gross/net deduction, benefit, traktamente, ...) for one employee in a draft run. account_number auto-resolves from item_type when omitted. Amounts are rounded to whole öre.',
useWhen:
'You need to add a one-off pay component before calculating: a bonus, an expense reimbursement, a union fee, or a manual correction line.',
doNotUseFor:
'Editing the base monthly salary (PATCH the run-employee via the internal surface; not on v1 yet). Absence: register absence days instead (PUT /employees/{id}/absence); the engine derives sick/VAB lines itself.',
pitfalls: [
'Draft-only: returns 400 SALARY_RUN_LINE_NOT_DRAFT once the run has advanced.',
'Line edits do not recompute tax or totals: call POST /salary-runs/{id}/calculate afterwards.',
'Engine-derived lines (absence, benefits) are regenerated on every :calculate; manual lines survive.',
],
example: {
request: {
item_type: 'bonus',
description: 'Kvartalsbonus Q2',
amount: 5000,
},
response: {
data: {
salary_line_item_id: 'sli_31c9…',
item_type: 'bonus',
description: 'Kvartalsbonus Q2',
amount: 5000,
account_number: '7210',
},
meta: { request_id: 'req_…', api_version: '2026-05-12' },
},
},
scope: 'payroll:write',
risk: 'low',
idempotent: true,
reversible: true,
dryRunSupported: true,
request: { body: CreateLineBody },
response: { success: dataEnvelope(LineItemResponse) },
})
export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string; employeeId: string }> }>(
'salary-runs.lines.create',
async (request, ctx, params) => {
const { id, employeeId } = await params.params
const runParse = z.string().uuid().safeParse(id)
const empParse = z.string().uuid().safeParse(employeeId)
if (!runParse.success || !empParse.success) {
return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, {
requestId: ctx.requestId,
details: {
field: runParse.success ? 'employeeId' : 'id',
message: 'Path ids must be UUIDs.',
},
})
}
let rawBody: unknown
try {
rawBody = await request.json()
} catch {
return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, {
requestId: ctx.requestId,
details: { field: 'body', message: 'Body is not valid JSON.' },
})
}
const parsed = CreateLineBody.safeParse(rawBody)
if (!parsed.success) {
return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, {
requestId: ctx.requestId,
details: {
issues: parsed.error.issues.map((i) => ({
field: i.path.join('.'),
message: i.message,
})),
},
})
}
const result = await createPayslipLine(ctx.supabase, {
companyId: ctx.companyId!,
salaryRunId: runParse.data,
target: { employeeId: empParse.data },
input: parsed.data,
dryRun: ctx.dryRun,
})
if (!result.ok) {
return v1ErrorResponseFromCode(result.code, ctx.log, {
requestId: ctx.requestId,
details: result.details,
})
}
const { id: lineId, company_id: _companyId, ...rest } = result.data as Record<string, unknown> & {
id: string | null
company_id?: string
}
const payload = { salary_line_item_id: lineId, ...rest }
if (ctx.dryRun) {
return dryRunPreview(payload, { requestId: ctx.requestId, log: ctx.log })
}
return created(payload, { requestId: ctx.requestId })
},
{ requireIdempotencyKey: true },
)
@@ -0,0 +1,301 @@
/**
* /api/v1/companies/{companyId}/salary-runs/{id}/employees/{employeeId}
*
* GET: one employee's full payslip within a salary run: the calculated
* aggregates, every payslip line item, and the step-by-step
* calculation_breakdown the engine recorded.
*
* GDPR Art.5(1)(c): personnummer stays MASKED here. A payslip is a pay
* document, not an identity record; the deliberate identity drill-in is
* GET /employees/{id}.
*/
import { z } from 'zod'
import { ok, noContent } from '@/lib/api/v1/response'
import { dryRunPreview } from '@/lib/api/v1/dry-run'
import { registerEndpoint, dataEnvelope, NoBodyResponse } from '@/lib/api/v1/registry'
import { withApiV1 } from '@/lib/api/v1/with-api-v1'
import { v1ErrorResponse, v1ErrorResponseFromCode } from '@/lib/api/v1/errors'
import { maskPersonnummer } from '@/lib/api/v1/mask-personnummer'
import { decryptPersonnummer } from '@/lib/salary/personnummer'
import { removeEmployeeFromRun } from '@/lib/salary/run-employees'
const PayslipLineItem = z.object({
/** Qualified id of the salary_line_items row. */
salary_line_item_id: z.string().uuid(),
item_type: z.string(),
description: z.string(),
quantity: z.number().nullable(),
unit_price: z.number().nullable(),
amount: z.number(),
is_taxable: z.boolean(),
is_avgift_basis: z.boolean(),
is_vacation_basis: z.boolean(),
is_gross_deduction: z.boolean(),
is_net_deduction: z.boolean(),
account_number: z.string().nullable(),
sort_order: z.number(),
})
const PayslipDetail = z.object({
salary_run_employee_id: z.string().uuid(),
salary_run_id: z.string().uuid(),
employee_id: z.string().uuid(),
first_name: z.string(),
last_name: z.string(),
personnummer_masked: z.string(),
salary_type: z.string(),
employment_degree: z.number(),
monthly_salary: z.number().nullable(),
hours_worked: z.number().nullable(),
gross_salary: z.number(),
gross_deductions: z.number(),
benefit_values: z.number(),
taxable_income: z.number(),
tax_withheld: z.number(),
tax_withheld_override: z.number().nullable(),
net_deductions: z.number(),
net_salary: z.number(),
avgifter_rate: z.number(),
avgifter_basis: z.number(),
avgifter_amount: z.number(),
avgifter_basis_override: z.number().nullable(),
avgifter_amount_override: z.number().nullable(),
avgifter_category: z.string().nullable(),
override_reason: z.string().nullable(),
vacation_accrual: z.number(),
vacation_accrual_avgifter: z.number(),
tax_table_number: z.number().nullable(),
tax_column: z.number().nullable(),
tax_table_year: z.number().nullable(),
sick_days: z.number(),
vab_days: z.number(),
parental_days: z.number(),
vacation_days_taken: z.number(),
ytd_gross: z.number(),
ytd_tax: z.number(),
ytd_net: z.number(),
/** Step-by-step engine breakdown; null until :calculate has run. */
calculation_breakdown: z.unknown().nullable(),
line_items: z.array(PayslipLineItem),
created_at: z.string(),
updated_at: z.string(),
})
const PAYSLIP_DETAIL_COLUMNS =
'id, salary_run_id, employee_id, salary_type, employment_degree, monthly_salary, hours_worked, ' +
'gross_salary, gross_deductions, benefit_values, taxable_income, tax_withheld, tax_withheld_override, ' +
'net_deductions, net_salary, avgifter_rate, avgifter_basis, avgifter_amount, avgifter_basis_override, ' +
'avgifter_amount_override, avgifter_category, override_reason, vacation_accrual, vacation_accrual_avgifter, ' +
'tax_table_number, tax_column, tax_table_year, sick_days, vab_days, parental_days, vacation_days_taken, ' +
'ytd_gross, ytd_tax, ytd_net, calculation_breakdown, created_at, updated_at, ' +
'employee:employees(first_name, last_name, personnummer), ' +
'line_items:salary_line_items(id, item_type, description, quantity, unit_price, amount, is_taxable, is_avgift_basis, is_vacation_basis, is_gross_deduction, is_net_deduction, account_number, sort_order)'
registerEndpoint({
operation: 'salary-runs.employees.get',
method: 'GET',
path: '/api/v1/companies/:companyId/salary-runs/:id/employees/:employeeId',
summary: 'Get one employee\'s payslip in a salary run.',
description:
'Returns the full payslip for one employee in a run: gross/tax/net aggregates, arbetsgivaravgifter with category, vacation accrual, YTD accumulators, every payslip line item (grundlön, tillägg, avdrag, förmåner), and the step-by-step calculation_breakdown recorded by the engine.',
useWhen:
'You need to verify how a specific employee\'s pay was computed: reviewing a run before approval, answering "why is the tax this amount", or rendering a payslip in an external system.',
doNotUseFor:
'The rendered PDF payslip: use GET /salary-runs/{id}/payslips/{employeeId}/pdf. Editing line items: POST/PATCH/DELETE on the lines endpoints.',
pitfalls: [
'calculation_breakdown is null and aggregates are 0 until POST /calculate has run.',
'line_items include engine-derived rows (absence, benefits) that are regenerated on every :calculate; manual rows survive recalculation.',
'The effective tax is COALESCE(tax_withheld_override, tax_withheld); same for avgifter overrides.',
'personnummer is masked here (GDPR Art.5(1)(c)); GET /employees/{id} is the identity drill-in.',
],
example: {
response: {
data: {
salary_run_employee_id: 'sre_a8f1…',
employee_id: 'emp_77b2…',
first_name: 'Anna',
last_name: 'Andersson',
personnummer_masked: 'YYYYMMDDXXXX',
gross_salary: 35000,
tax_withheld: -8200,
net_salary: 26800,
line_items: [
{
salary_line_item_id: 'sli_31c9…',
item_type: 'monthly_salary',
description: 'Grundlön',
amount: 35000,
},
],
},
meta: { request_id: 'req_…', api_version: '2026-05-12' },
},
},
scope: 'payroll:read',
risk: 'low',
idempotent: true,
reversible: false,
dryRunSupported: false,
response: { success: dataEnvelope(PayslipDetail) },
})
export const GET = withApiV1<{ params: Promise<{ companyId: string; id: string; employeeId: string }> }>(
'salary-runs.employees.get',
async (_request, ctx, params) => {
const { id, employeeId } = await params.params
const runParse = z.string().uuid().safeParse(id)
const empParse = z.string().uuid().safeParse(employeeId)
if (!runParse.success || !empParse.success) {
return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, {
requestId: ctx.requestId,
details: {
field: runParse.success ? 'employeeId' : 'id',
message: 'Path ids must be UUIDs.',
},
})
}
const { data, error } = await ctx.supabase
.from('salary_run_employees')
.select(PAYSLIP_DETAIL_COLUMNS)
.eq('company_id', ctx.companyId!)
.eq('salary_run_id', runParse.data)
.eq('employee_id', empParse.data)
.maybeSingle()
if (error) {
return v1ErrorResponse(error, ctx.log, { requestId: ctx.requestId })
}
if (!data) {
// Distinguish "run missing" from "employee not in run" so agents get an
// actionable 404 body either way.
const { data: run } = await ctx.supabase
.from('salary_runs')
.select('id')
.eq('company_id', ctx.companyId!)
.eq('id', runParse.data)
.maybeSingle()
if (!run) {
return v1ErrorResponseFromCode('SALARY_RUN_NOT_FOUND', ctx.log, { requestId: ctx.requestId })
}
return v1ErrorResponseFromCode('NOT_FOUND', ctx.log, {
requestId: ctx.requestId,
details: { resource: 'salary_run_employee', employee_id: empParse.data },
})
}
type Row = {
id: string
salary_run_id: string
employee_id: string
employee: { first_name: string; last_name: string; personnummer: string } | null
line_items: Array<{
id: string
item_type: string
description: string
quantity: number | null
unit_price: number | null
amount: number
is_taxable: boolean
is_avgift_basis: boolean
is_vacation_basis: boolean
is_gross_deduction: boolean
is_net_deduction: boolean
account_number: string | null
sort_order: number
}>
} & Record<string, unknown>
const row = data as unknown as Row
const { employee, line_items: lineItems, id: sreId, ...rest } = row
return ok(
{
...rest,
salary_run_employee_id: sreId,
first_name: employee?.first_name ?? '',
last_name: employee?.last_name ?? '',
personnummer_masked: employee
? maskPersonnummer(decryptPersonnummer(employee.personnummer))
: '',
line_items: (lineItems ?? [])
.slice()
.sort((a, b) => a.sort_order - b.sort_order)
.map(({ id: lineId, ...line }) => ({
salary_line_item_id: lineId,
...line,
})),
},
{ requestId: ctx.requestId },
)
},
)
// ──────────────────────────────────────────────────────────────────
// DELETE: remove an employee from a draft run
// ──────────────────────────────────────────────────────────────────
registerEndpoint({
operation: 'salary-runs.employees.remove',
method: 'DELETE',
path: '/api/v1/companies/:companyId/salary-runs/:id/employees/:employeeId',
summary: 'Remove an employee from a draft salary run.',
description:
'Detaches the employee from the run and cascades away their payslip line items. Draft-only. The employee master record is untouched: this only affects the run roster.',
useWhen:
'An employee should not be paid this period (unpaid leave the whole month, employment ended) but was auto-added when the run was created.',
doNotUseFor:
'Deactivating the employee entirely: DELETE /employees/{id} (soft-delete). Zero-salary months: keep them in the run with a 0 base instead if you want a nollkörning on record.',
pitfalls: [
'Draft-only: 400 SALARY_RUN_EMPLOYEES_NOT_DRAFT once the run has advanced.',
'Cascade-deletes the employee\'s line items in this run, including manual ones.',
'Re-attaching later retakes the pay snapshot from the employee master.',
],
example: { response: { data: null } },
scope: 'payroll:write',
risk: 'low',
idempotent: true,
reversible: true,
dryRunSupported: true,
response: { success: NoBodyResponse },
})
export const DELETE = withApiV1<{ params: Promise<{ companyId: string; id: string; employeeId: string }> }>(
'salary-runs.employees.remove',
async (_request, ctx, params) => {
const { id, employeeId } = await params.params
const runParse = z.string().uuid().safeParse(id)
const empParse = z.string().uuid().safeParse(employeeId)
if (!runParse.success || !empParse.success) {
return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, {
requestId: ctx.requestId,
details: {
field: runParse.success ? 'employeeId' : 'id',
message: 'Path ids must be UUIDs.',
},
})
}
const result = await removeEmployeeFromRun(ctx.supabase, {
companyId: ctx.companyId!,
salaryRunId: runParse.data,
employeeId: empParse.data,
dryRun: ctx.dryRun,
})
if (!result.ok) {
return v1ErrorResponseFromCode(result.code, ctx.log, {
requestId: ctx.requestId,
details: result.details,
})
}
if (ctx.dryRun) {
return dryRunPreview(result.data, { requestId: ctx.requestId, log: ctx.log })
}
return noContent({ requestId: ctx.requestId })
},
{ requireIdempotencyKey: true },
)
@@ -0,0 +1,550 @@
/**
* Tests for the v1 per-employee payslip reads (payroll gap-closure 1.1).
*
* GET /salary-runs/{id}/employees : list per-employee results
* GET /salary-runs/{id}/employees/{empId} : payslip detail (line items + breakdown)
*
* Mirrors the employees-route test pattern: Proxy-backed Supabase mock with
* per-table response queues.
*/
import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
beforeAll(() => {
if (process.env.NODE_ENV !== 'test') {
throw new Error(
`salary-run employees route tests require NODE_ENV=test (got ${process.env.NODE_ENV ?? 'undefined'})`,
)
}
process.env.NEXT_PUBLIC_SUPABASE_URL ||= 'http://localhost:54321'
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY ||= 'test-anon-key'
})
vi.mock('@/lib/auth/api-keys', async () => {
const actual = await vi.importActual<typeof import('@/lib/auth/api-keys')>('@/lib/auth/api-keys')
return {
...actual,
validateApiKey: vi.fn(),
createServiceClientNoCookies: vi.fn(),
}
})
vi.mock('@supabase/supabase-js', async () => {
const actual = await vi.importActual<typeof import('@supabase/supabase-js')>('@supabase/supabase-js')
return { ...actual, createClient: vi.fn().mockReturnValue({}) }
})
import { validateApiKey, createServiceClientNoCookies } from '@/lib/auth/api-keys'
import { GET as listRunEmployees, POST as attachEmployee } from '../route'
import { GET as getPayslip, DELETE as removeEmployee } from '../[employeeId]/route'
const mockValidate = validateApiKey as ReturnType<typeof vi.fn>
const mockServiceClient = createServiceClientNoCookies as ReturnType<typeof vi.fn>
interface TableResp {
data?: unknown
error?: unknown
}
function makeFlexibleSupabase(byTable: Record<string, TableResp | TableResp[]>) {
const queues = new Map<string, TableResp[]>()
for (const [t, val] of Object.entries(byTable)) {
queues.set(t, Array.isArray(val) ? [...val] : [val])
}
const buildChain = (table: string): unknown => {
const handler: ProxyHandler<object> = {
get(_target, prop) {
if (prop === 'then') {
return (resolve: (v: unknown) => void) => {
const q = queues.get(table)
const next = q && q.length > 1 ? q.shift()! : (q?.[0] ?? { data: null, error: null })
resolve(next)
}
}
return (..._args: unknown[]) => buildChain(table)
},
}
return new Proxy({}, handler)
}
return { from: vi.fn((table: string) => buildChain(table)) }
}
const COMPANY_ID = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa'
const RUN_ID = 'cccccccc-cccc-4ccc-8ccc-cccccccccccc'
const EMPLOYEE_ID = 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb'
const SRE_ID = 'dddddddd-dddd-4ddd-8ddd-dddddddddddd'
const LINE_ID = 'eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeee'
const USER_ID = 'user-1'
// Synthetic fixture personnummer (year 1900, zero suffix): must not look
// like production-format PII.
const SAMPLE_PERSONNUMMER = '190001010000'
function makeRequest(url: string): Request {
return new Request(url, {
headers: { Authorization: 'Bearer test-fixture-not-a-real-key' },
})
}
function listParams(companyId: string, id: string) {
return { params: Promise.resolve({ companyId, id }) }
}
function detailParams(companyId: string, id: string, employeeId: string) {
return { params: Promise.resolve({ companyId, id, employeeId }) }
}
const SAMPLE_SRE = {
id: SRE_ID,
salary_run_id: RUN_ID,
employee_id: EMPLOYEE_ID,
salary_type: 'monthly',
employment_degree: 100,
monthly_salary: 35000,
hours_worked: null,
gross_salary: 35000,
gross_deductions: 0,
benefit_values: 0,
taxable_income: 35000,
tax_withheld: 8200,
tax_withheld_override: null,
net_deductions: 0,
net_salary: 26800,
avgifter_rate: 0.3142,
avgifter_basis: 35000,
avgifter_amount: 10997,
avgifter_basis_override: null,
avgifter_amount_override: null,
avgifter_category: 'standard',
override_reason: null,
vacation_accrual: 4200,
vacation_accrual_avgifter: 1319.64,
tax_table_number: 33,
tax_column: 1,
tax_table_year: 2026,
sick_days: 0,
vab_days: 0,
parental_days: 0,
vacation_days_taken: 0,
ytd_gross: 70000,
ytd_tax: 16400,
ytd_net: 53600,
calculation_breakdown: { steps: [{ label: 'Grundlön', formula: '35000 x 100%', output: 35000 }] },
created_at: '2026-05-01T08:00:00Z',
updated_at: '2026-05-01T08:00:00Z',
employee: {
first_name: 'Anna',
last_name: 'Andersson',
personnummer: SAMPLE_PERSONNUMMER,
},
line_items: [
{
id: LINE_ID,
item_type: 'monthly_salary',
description: 'Grundlön',
quantity: null,
unit_price: null,
amount: 35000,
is_taxable: true,
is_avgift_basis: true,
is_vacation_basis: true,
is_gross_deduction: false,
is_net_deduction: false,
account_number: '7210',
sort_order: 0,
},
],
}
beforeEach(() => {
vi.clearAllMocks()
mockValidate.mockResolvedValue({
userId: USER_ID,
companyId: COMPANY_ID,
apiKeyId: 'ak_1',
apiKeyName: 'CI key',
scopes: ['payroll:read', 'payroll:write'],
mode: 'live',
})
})
describe('GET /api/v1/companies/:companyId/salary-runs/:id/employees', () => {
it('returns per-employee rows with masked personnummer and qualified ids', async () => {
mockServiceClient.mockReturnValue(
makeFlexibleSupabase({
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
salary_runs: { data: { id: RUN_ID }, error: null },
salary_run_employees: { data: [SAMPLE_SRE], error: null },
}),
)
const res = await listRunEmployees(
makeRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/salary-runs/${RUN_ID}/employees`),
listParams(COMPANY_ID, RUN_ID),
)
expect(res.status).toBe(200)
const body = await res.json()
expect(body.data).toHaveLength(1)
expect(body.data[0].salary_run_employee_id).toBe(SRE_ID)
expect(body.data[0].employee_id).toBe(EMPLOYEE_ID)
expect(body.data[0].gross_salary).toBe(35000)
expect(body.data[0].net_salary).toBe(26800)
// GDPR Art.5(1)(c): payslip-shaped responses always mask.
expect(body.data[0].personnummer_masked).toBe('19000101XXXX')
expect(JSON.stringify(body)).not.toContain(SAMPLE_PERSONNUMMER)
// The list omits line items: the detail endpoint carries them.
expect(body.data[0].line_items).toBeUndefined()
// paginated() omits next_cursor from meta on the final page.
expect(body.meta.next_cursor ?? null).toBeNull()
})
it('returns 404 SALARY_RUN_NOT_FOUND when the run is missing', async () => {
mockServiceClient.mockReturnValue(
makeFlexibleSupabase({
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
salary_runs: { data: null, error: null },
}),
)
const res = await listRunEmployees(
makeRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/salary-runs/${RUN_ID}/employees`),
listParams(COMPANY_ID, RUN_ID),
)
expect(res.status).toBe(404)
const body = await res.json()
expect(body.error.code).toBe('SALARY_RUN_NOT_FOUND')
})
it('rejects a non-UUID run id with 400 VALIDATION_ERROR', async () => {
mockServiceClient.mockReturnValue(
makeFlexibleSupabase({
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
}),
)
const res = await listRunEmployees(
makeRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/salary-runs/not-a-uuid/employees`),
listParams(COMPANY_ID, 'not-a-uuid'),
)
expect(res.status).toBe(400)
const body = await res.json()
expect(body.error.code).toBe('VALIDATION_ERROR')
})
it('rejects keys without payroll:read scope', async () => {
mockValidate.mockResolvedValue({
userId: USER_ID,
companyId: COMPANY_ID,
apiKeyId: 'ak_1',
apiKeyName: 'wrong scope',
scopes: ['invoices:read'],
mode: 'live',
})
mockServiceClient.mockReturnValue(makeFlexibleSupabase({}))
const res = await listRunEmployees(
makeRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/salary-runs/${RUN_ID}/employees`),
listParams(COMPANY_ID, RUN_ID),
)
expect(res.status).toBe(403)
const body = await res.json()
expect(body.error.code).toBe('INSUFFICIENT_SCOPE')
})
it('returns 401 without a bearer token', async () => {
mockValidate.mockResolvedValue(null)
mockServiceClient.mockReturnValue(makeFlexibleSupabase({}))
const res = await listRunEmployees(
new Request(`https://x.test/api/v1/companies/${COMPANY_ID}/salary-runs/${RUN_ID}/employees`),
listParams(COMPANY_ID, RUN_ID),
)
expect(res.status).toBe(401)
})
it('emits a next_cursor when the page is full', async () => {
// limit=1 with 2 rows returned (limit + 1 fetch convention).
const second = {
...SAMPLE_SRE,
id: 'ffffffff-ffff-4fff-8fff-ffffffffffff',
employee_id: '99999999-9999-4999-8999-999999999999',
created_at: '2026-05-01T09:00:00Z',
}
mockServiceClient.mockReturnValue(
makeFlexibleSupabase({
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
salary_runs: { data: { id: RUN_ID }, error: null },
salary_run_employees: { data: [SAMPLE_SRE, second], error: null },
}),
)
const res = await listRunEmployees(
makeRequest(
`https://x.test/api/v1/companies/${COMPANY_ID}/salary-runs/${RUN_ID}/employees?limit=1`,
),
listParams(COMPANY_ID, RUN_ID),
)
expect(res.status).toBe(200)
const body = await res.json()
expect(body.data).toHaveLength(1)
expect(body.meta.next_cursor).toBeTruthy()
})
})
describe('GET /api/v1/companies/:companyId/salary-runs/:id/employees/:employeeId', () => {
it('returns the payslip detail with line items and calculation breakdown', async () => {
mockServiceClient.mockReturnValue(
makeFlexibleSupabase({
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
salary_run_employees: { data: SAMPLE_SRE, error: null },
}),
)
const res = await getPayslip(
makeRequest(
`https://x.test/api/v1/companies/${COMPANY_ID}/salary-runs/${RUN_ID}/employees/${EMPLOYEE_ID}`,
),
detailParams(COMPANY_ID, RUN_ID, EMPLOYEE_ID),
)
expect(res.status).toBe(200)
const body = await res.json()
expect(body.data.salary_run_employee_id).toBe(SRE_ID)
expect(body.data.employee_id).toBe(EMPLOYEE_ID)
expect(body.data.personnummer_masked).toBe('19000101XXXX')
expect(body.data.line_items).toHaveLength(1)
expect(body.data.line_items[0].salary_line_item_id).toBe(LINE_ID)
expect(body.data.line_items[0].item_type).toBe('monthly_salary')
expect(body.data.calculation_breakdown.steps).toHaveLength(1)
// Raw personnummer never leaks; the raw line id is re-keyed to the
// qualified name (no bare `id` fields in the payload).
expect(JSON.stringify(body)).not.toContain(SAMPLE_PERSONNUMMER)
expect(body.data.line_items[0].id).toBeUndefined()
expect(body.data.id).toBeUndefined()
})
it('returns 404 SALARY_RUN_NOT_FOUND when the run itself is missing', async () => {
mockServiceClient.mockReturnValue(
makeFlexibleSupabase({
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
salary_run_employees: { data: null, error: null },
salary_runs: { data: null, error: null },
}),
)
const res = await getPayslip(
makeRequest(
`https://x.test/api/v1/companies/${COMPANY_ID}/salary-runs/${RUN_ID}/employees/${EMPLOYEE_ID}`,
),
detailParams(COMPANY_ID, RUN_ID, EMPLOYEE_ID),
)
expect(res.status).toBe(404)
const body = await res.json()
expect(body.error.code).toBe('SALARY_RUN_NOT_FOUND')
})
it('returns 404 NOT_FOUND when the run exists but the employee is not in it', async () => {
mockServiceClient.mockReturnValue(
makeFlexibleSupabase({
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
salary_run_employees: { data: null, error: null },
salary_runs: { data: { id: RUN_ID }, error: null },
}),
)
const res = await getPayslip(
makeRequest(
`https://x.test/api/v1/companies/${COMPANY_ID}/salary-runs/${RUN_ID}/employees/${EMPLOYEE_ID}`,
),
detailParams(COMPANY_ID, RUN_ID, EMPLOYEE_ID),
)
expect(res.status).toBe(404)
const body = await res.json()
expect(body.error.code).toBe('NOT_FOUND')
expect(body.error.details.resource).toBe('salary_run_employee')
})
it('rejects a non-UUID employee id with 400 VALIDATION_ERROR', async () => {
mockServiceClient.mockReturnValue(
makeFlexibleSupabase({
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
}),
)
const res = await getPayslip(
makeRequest(
`https://x.test/api/v1/companies/${COMPANY_ID}/salary-runs/${RUN_ID}/employees/nope`,
),
detailParams(COMPANY_ID, RUN_ID, 'nope'),
)
expect(res.status).toBe(400)
const body = await res.json()
expect(body.error.code).toBe('VALIDATION_ERROR')
})
})
describe('POST /api/v1/companies/:companyId/salary-runs/:id/employees', () => {
const withIdempotency = (url: string, body: unknown): Request =>
new Request(url, {
method: 'POST',
headers: {
Authorization: 'Bearer test-fixture-not-a-real-key',
'Idempotency-Key': 'b1aaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa',
},
body: JSON.stringify(body),
})
const SAMPLE_EMPLOYEE_MASTER = {
id: EMPLOYEE_ID,
employment_degree: 100,
monthly_salary: 35000,
hourly_rate: null,
salary_type: 'monthly',
employment_type: 'employee',
tax_table_number: 33,
tax_column: 1,
}
it('attaches an employee to a draft run (happy path, 201)', async () => {
mockServiceClient.mockReturnValue(
makeFlexibleSupabase({
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
salary_runs: { data: { id: RUN_ID, status: 'draft' }, error: null },
employees: { data: SAMPLE_EMPLOYEE_MASTER, error: null },
salary_run_employees: [
{ data: null, error: null }, // duplicate check
{
data: {
id: SRE_ID,
salary_run_id: RUN_ID,
employee_id: EMPLOYEE_ID,
company_id: COMPANY_ID,
employment_degree: 100,
monthly_salary: 35000,
salary_type: 'monthly',
hours_worked: null,
tax_table_number: 33,
tax_column: 1,
created_at: '2026-05-01T08:00:00Z',
updated_at: '2026-05-01T08:00:00Z',
},
error: null,
},
],
salary_line_items: { data: null, error: null },
idempotency_keys: { data: null, error: null },
}),
)
const res = await attachEmployee(
withIdempotency(
`https://x.test/api/v1/companies/${COMPANY_ID}/salary-runs/${RUN_ID}/employees`,
{ employee_id: EMPLOYEE_ID },
),
listParams(COMPANY_ID, RUN_ID),
)
expect(res.status).toBe(201)
const body = await res.json()
expect(body.data.salary_run_employee_id).toBe(SRE_ID)
expect(body.data.employee_id).toBe(EMPLOYEE_ID)
expect(body.data.id).toBeUndefined()
})
it('returns 409 SALARY_RUN_EMPLOYEE_DUPLICATE when already attached', async () => {
mockServiceClient.mockReturnValue(
makeFlexibleSupabase({
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
salary_runs: { data: { id: RUN_ID, status: 'draft' }, error: null },
employees: { data: SAMPLE_EMPLOYEE_MASTER, error: null },
salary_run_employees: { data: { id: SRE_ID }, error: null },
idempotency_keys: { data: null, error: null },
}),
)
const res = await attachEmployee(
withIdempotency(
`https://x.test/api/v1/companies/${COMPANY_ID}/salary-runs/${RUN_ID}/employees`,
{ employee_id: EMPLOYEE_ID },
),
listParams(COMPANY_ID, RUN_ID),
)
expect(res.status).toBe(409)
const body = await res.json()
expect(body.error.code).toBe('SALARY_RUN_EMPLOYEE_DUPLICATE')
})
it('returns 400 SALARY_RUN_EMPLOYEES_NOT_DRAFT once the run advanced', async () => {
mockServiceClient.mockReturnValue(
makeFlexibleSupabase({
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
salary_runs: { data: { id: RUN_ID, status: 'review' }, error: null },
idempotency_keys: { data: null, error: null },
}),
)
const res = await attachEmployee(
withIdempotency(
`https://x.test/api/v1/companies/${COMPANY_ID}/salary-runs/${RUN_ID}/employees`,
{ employee_id: EMPLOYEE_ID },
),
listParams(COMPANY_ID, RUN_ID),
)
expect(res.status).toBe(400)
const body = await res.json()
expect(body.error.code).toBe('SALARY_RUN_EMPLOYEES_NOT_DRAFT')
})
})
describe('DELETE /api/v1/companies/:companyId/salary-runs/:id/employees/:employeeId', () => {
const deleteRequest = (url: string): Request =>
new Request(url, {
method: 'DELETE',
headers: {
Authorization: 'Bearer test-fixture-not-a-real-key',
'Idempotency-Key': 'b2aaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa',
},
})
it('removes an attached employee (204)', async () => {
mockServiceClient.mockReturnValue(
makeFlexibleSupabase({
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
salary_runs: { data: { id: RUN_ID, status: 'draft' }, error: null },
salary_run_employees: [
{ data: { id: SRE_ID }, error: null },
{ data: null, error: null },
],
idempotency_keys: { data: null, error: null },
}),
)
const res = await removeEmployee(
deleteRequest(
`https://x.test/api/v1/companies/${COMPANY_ID}/salary-runs/${RUN_ID}/employees/${EMPLOYEE_ID}`,
),
detailParams(COMPANY_ID, RUN_ID, EMPLOYEE_ID),
)
expect(res.status).toBe(204)
})
it('returns 404 SALARY_RUN_EMPLOYEE_NOT_FOUND when not attached', async () => {
mockServiceClient.mockReturnValue(
makeFlexibleSupabase({
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
salary_runs: { data: { id: RUN_ID, status: 'draft' }, error: null },
salary_run_employees: { data: null, error: null },
idempotency_keys: { data: null, error: null },
}),
)
const res = await removeEmployee(
deleteRequest(
`https://x.test/api/v1/companies/${COMPANY_ID}/salary-runs/${RUN_ID}/employees/${EMPLOYEE_ID}`,
),
detailParams(COMPANY_ID, RUN_ID, EMPLOYEE_ID),
)
expect(res.status).toBe(404)
const body = await res.json()
expect(body.error.code).toBe('SALARY_RUN_EMPLOYEE_NOT_FOUND')
})
})
@@ -0,0 +1,355 @@
/**
* /api/v1/companies/{companyId}/salary-runs/{id}/employees
*
* GET: list the per-employee results of a salary run (one row per
* salary_run_employee). Cursor pagination on (created_at ASC, id ASC).
*
* The row carries the calculated aggregates (gross/tax/net/avgifter/vacation)
* but NOT the payslip line items: drill into
* GET /salary-runs/{id}/employees/{employeeId} for those.
*
* GDPR Art.5(1)(c): personnummer is masked (birthdate visible, last-4 hidden)
* on every payslip-shaped response. A payslip is a pay document, not an
* identity record; the employee master detail endpoint is the deliberate
* drill-in that returns the full value.
*/
import { z } from 'zod'
import { created, paginated } from '@/lib/api/v1/response'
import { dryRunPreview } from '@/lib/api/v1/dry-run'
import {
decodeDefaultCursor,
encodeDefaultCursor,
parsePaginationParams,
} from '@/lib/api/v1/pagination'
import { registerEndpoint, listEnvelope, dataEnvelope } from '@/lib/api/v1/registry'
import { withApiV1 } from '@/lib/api/v1/with-api-v1'
import { v1ErrorResponse, v1ErrorResponseFromCode } from '@/lib/api/v1/errors'
import { maskPersonnummer } from '@/lib/api/v1/mask-personnummer'
import { decryptPersonnummer } from '@/lib/salary/personnummer'
import { AddEmployeeToRunSchema } from '@/lib/api/schemas'
import { addEmployeeToRun } from '@/lib/salary/run-employees'
const RunEmployeeSummary = z.object({
/** Qualified id of the salary_run_employees join row (NOT the employee id). */
salary_run_employee_id: z.string().uuid(),
employee_id: z.string().uuid(),
first_name: z.string(),
last_name: z.string(),
/** Masked: first 8 digits + 'XXXX' (birthdate visible, last-4 hidden). */
personnummer_masked: z.string(),
salary_type: z.string(),
employment_degree: z.number(),
monthly_salary: z.number().nullable(),
hours_worked: z.number().nullable(),
gross_salary: z.number(),
taxable_income: z.number(),
tax_withheld: z.number(),
tax_withheld_override: z.number().nullable(),
net_salary: z.number(),
avgifter_basis: z.number(),
avgifter_amount: z.number(),
avgifter_amount_override: z.number().nullable(),
avgifter_category: z.string().nullable(),
vacation_accrual: z.number(),
sick_days: z.number(),
vab_days: z.number(),
parental_days: z.number(),
vacation_days_taken: z.number(),
created_at: z.string(),
updated_at: z.string(),
})
// Explicit projection: never SELECT *. personnummer is loaded only to serve
// the masked form; the full value never leaves this projection.
const RUN_EMPLOYEE_SUMMARY_COLUMNS =
'id, employee_id, salary_type, employment_degree, monthly_salary, hours_worked, ' +
'gross_salary, taxable_income, tax_withheld, tax_withheld_override, net_salary, ' +
'avgifter_basis, avgifter_amount, avgifter_amount_override, avgifter_category, ' +
'vacation_accrual, sick_days, vab_days, parental_days, vacation_days_taken, ' +
'created_at, updated_at, employee:employees(first_name, last_name, personnummer)'
registerEndpoint({
operation: 'salary-runs.employees.list',
method: 'GET',
path: '/api/v1/companies/:companyId/salary-runs/:id/employees',
summary: 'List per-employee results of a salary run.',
description:
'Returns one row per employee in the run with the calculated aggregates: gross salary, tax withheld, net pay, arbetsgivaravgifter, vacation accrual, and absence day counts. All aggregate fields are 0 until POST /calculate has run. Cursor pagination on (created_at, id).',
useWhen:
'You need the per-employee outcome of a run: to review before approval, to reconcile against an external system, or to pick an employee_id for the payslip drill-in.',
doNotUseFor:
'Payslip line items or the step-by-step calculation breakdown: use GET /salary-runs/{id}/employees/{employeeId}. The employee master record: use GET /employees/{id}.',
pitfalls: [
'Aggregates are 0 until POST /calculate has advanced the run to review.',
'tax_withheld_override / avgifter_amount_override are review-stage manual adjustments; the effective value is COALESCE(override, calculated).',
'personnummer is masked on all payslip-shaped responses (GDPR Art.5(1)(c)); the employee detail endpoint returns the full value.',
],
example: {
response: {
data: [
{
salary_run_employee_id: 'sre_a8f1…',
employee_id: 'emp_77b2…',
first_name: 'Anna',
last_name: 'Andersson',
personnummer_masked: 'YYYYMMDDXXXX',
salary_type: 'monthly',
gross_salary: 35000,
tax_withheld: -8200,
net_salary: 26800,
avgifter_amount: 10997,
},
],
meta: { request_id: 'req_…', api_version: '2026-05-12', next_cursor: null },
},
},
scope: 'payroll:read',
risk: 'low',
idempotent: true,
reversible: false,
dryRunSupported: false,
response: { success: listEnvelope(RunEmployeeSummary) },
})
export const GET = withApiV1<{ params: Promise<{ companyId: string; id: string }> }>(
'salary-runs.employees.list',
async (request, ctx, params) => {
const { id } = await params.params
const idParse = z.string().uuid().safeParse(id)
if (!idParse.success) {
return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, {
requestId: ctx.requestId,
details: { field: 'id', message: 'Salary-run id must be a UUID.' },
})
}
// 404 the run itself first so an empty list unambiguously means
// "run exists, no employees attached".
const { data: run, error: runErr } = await ctx.supabase
.from('salary_runs')
.select('id')
.eq('company_id', ctx.companyId!)
.eq('id', idParse.data)
.maybeSingle()
if (runErr) {
return v1ErrorResponse(runErr, ctx.log, { requestId: ctx.requestId })
}
if (!run) {
return v1ErrorResponseFromCode('SALARY_RUN_NOT_FOUND', ctx.log, { requestId: ctx.requestId })
}
const url = new URL(request.url)
const { limit, cursor } = parsePaginationParams(url)
const decoded = decodeDefaultCursor(cursor)
let query = ctx.supabase
.from('salary_run_employees')
.select(RUN_EMPLOYEE_SUMMARY_COLUMNS)
.eq('company_id', ctx.companyId!)
.eq('salary_run_id', idParse.data)
.order('created_at', { ascending: true })
.order('id', { ascending: true })
.limit(limit + 1)
if (decoded) {
query = query.or(
`created_at.gt.${decoded.ts},and(created_at.eq.${decoded.ts},id.gt.${decoded.id})`,
)
}
const { data, error } = await query
if (error) {
return v1ErrorResponse(error, ctx.log, { requestId: ctx.requestId })
}
type Row = {
id: string
employee_id: string
salary_type: string
employment_degree: number
monthly_salary: number | null
hours_worked: number | null
gross_salary: number
taxable_income: number
tax_withheld: number
tax_withheld_override: number | null
net_salary: number
avgifter_basis: number
avgifter_amount: number
avgifter_amount_override: number | null
avgifter_category: string | null
vacation_accrual: number
sick_days: number
vab_days: number
parental_days: number
vacation_days_taken: number
created_at: string
updated_at: string
employee: { first_name: string; last_name: string; personnummer: string } | null
}
const rows = ((data ?? []) as unknown) as Row[]
const trimmed = rows.slice(0, limit)
const hasMore = rows.length > limit
const items = trimmed.map((r) => ({
salary_run_employee_id: r.id,
employee_id: r.employee_id,
first_name: r.employee?.first_name ?? '',
last_name: r.employee?.last_name ?? '',
personnummer_masked: r.employee
? maskPersonnummer(decryptPersonnummer(r.employee.personnummer))
: '',
salary_type: r.salary_type,
employment_degree: r.employment_degree,
monthly_salary: r.monthly_salary,
hours_worked: r.hours_worked,
gross_salary: r.gross_salary,
taxable_income: r.taxable_income,
tax_withheld: r.tax_withheld,
tax_withheld_override: r.tax_withheld_override,
net_salary: r.net_salary,
avgifter_basis: r.avgifter_basis,
avgifter_amount: r.avgifter_amount,
avgifter_amount_override: r.avgifter_amount_override,
avgifter_category: r.avgifter_category,
vacation_accrual: r.vacation_accrual,
sick_days: r.sick_days,
vab_days: r.vab_days,
parental_days: r.parental_days,
vacation_days_taken: r.vacation_days_taken,
created_at: r.created_at,
updated_at: r.updated_at,
}))
const last = trimmed[trimmed.length - 1]
const nextCursor = hasMore && last
? encodeDefaultCursor({ id: last.id, created_at: last.created_at })
: null
return paginated(items, {
requestId: ctx.requestId,
nextCursor: nextCursor ?? undefined,
})
},
)
// ──────────────────────────────────────────────────────────────────
// POST: attach an employee to a draft run
// ──────────────────────────────────────────────────────────────────
const RunEmployeeAttached = z.object({
salary_run_employee_id: z.string().uuid().nullable(),
employee_id: z.string().uuid(),
salary_type: z.string(),
employment_degree: z.number(),
monthly_salary: z.number(),
hours_worked: z.number().nullable(),
tax_table_number: z.number().nullable(),
tax_column: z.number().nullable(),
})
registerEndpoint({
operation: 'salary-runs.employees.add',
method: 'POST',
path: '/api/v1/companies/:companyId/salary-runs/:id/employees',
summary: 'Add an employee to a draft salary run.',
description:
'Attaches an active employee to a draft run: snapshots their pay configuration (salary, degree, tax table) onto the run and seeds the base salary line (Grundlön/Timlön). For hourly employees, pass hours_worked.',
useWhen:
'The run was created without this employee (e.g. hired after the run was drafted), or you create runs empty and attach employees one by one from an external system.',
doNotUseFor:
'Changing an attached employee\'s pay for this month (internal per-run PATCH; not on v1). Re-attaching after removal is fine: the snapshot is retaken.',
pitfalls: [
'Draft-only: 400 SALARY_RUN_EMPLOYEES_NOT_DRAFT once the run has advanced.',
'Attaching twice returns 409 SALARY_RUN_EMPLOYEE_DUPLICATE.',
'The snapshot freezes salary/degree/tax-table at attach time: later employee edits do not flow into this run.',
'Inactive (soft-deleted) employees cannot be attached: 404 EMPLOYEE_NOT_FOUND.',
],
example: {
request: { employee_id: 'emp_77b2…' },
response: {
data: {
salary_run_employee_id: 'sre_a8f1…',
employee_id: 'emp_77b2…',
salary_type: 'monthly',
monthly_salary: 35000,
},
meta: { request_id: 'req_…', api_version: '2026-05-12' },
},
},
scope: 'payroll:write',
risk: 'low',
idempotent: true,
reversible: true,
dryRunSupported: true,
request: { body: AddEmployeeToRunSchema },
response: { success: dataEnvelope(RunEmployeeAttached) },
})
export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string }> }>(
'salary-runs.employees.add',
async (request, ctx, params) => {
const { id } = await params.params
const idParse = z.string().uuid().safeParse(id)
if (!idParse.success) {
return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, {
requestId: ctx.requestId,
details: { field: 'id', message: 'Salary-run id must be a UUID.' },
})
}
let rawBody: unknown
try {
rawBody = await request.json()
} catch {
return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, {
requestId: ctx.requestId,
details: { field: 'body', message: 'Body is not valid JSON.' },
})
}
const parsed = AddEmployeeToRunSchema.safeParse(rawBody)
if (!parsed.success) {
return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, {
requestId: ctx.requestId,
details: {
issues: parsed.error.issues.map((i) => ({
field: i.path.join('.'),
message: i.message,
})),
},
})
}
const result = await addEmployeeToRun(ctx.supabase, {
companyId: ctx.companyId!,
salaryRunId: idParse.data,
employeeId: parsed.data.employee_id,
hoursWorked: parsed.data.hours_worked ?? null,
dryRun: ctx.dryRun,
})
if (!result.ok) {
return v1ErrorResponseFromCode(result.code, ctx.log, {
requestId: ctx.requestId,
details: result.details,
})
}
const { id: sreId, company_id: _companyId, salary_run_id: _runId, ...rest } =
result.data as Record<string, unknown> & {
id: string | null
company_id?: string
salary_run_id?: string
}
const payload = { salary_run_employee_id: sreId, ...rest }
if (ctx.dryRun) {
return dryRunPreview(payload, { requestId: ctx.requestId, log: ctx.log })
}
return created(payload, { requestId: ctx.requestId })
},
{ requireIdempotencyKey: true },
)
@@ -0,0 +1,227 @@
/**
* /api/v1/companies/{companyId}/salary-runs/{id}/lines/{lineId}
*
* PATCH : update a payslip line (amount, description, quantity, ...) in a
* DRAFT run.
* DELETE : remove a payslip line from a DRAFT run.
*
* Both verify the line belongs to the given run (via its
* salary_run_employees row) so a lineId from another run 404s instead of
* silently mutating.
*/
import { z } from 'zod'
import { ok, noContent } from '@/lib/api/v1/response'
import { dryRunPreview } from '@/lib/api/v1/dry-run'
import { registerEndpoint, dataEnvelope, NoBodyResponse } from '@/lib/api/v1/registry'
import { withApiV1 } from '@/lib/api/v1/with-api-v1'
import { v1ErrorResponseFromCode } from '@/lib/api/v1/errors'
import { UpdateSalaryLineItemSchema } from '@/lib/api/schemas'
import { updatePayslipLine, deletePayslipLine } from '@/lib/salary/payslip-lines'
const LineItemResponse = z.object({
salary_line_item_id: z.string().uuid(),
salary_run_employee_id: z.string().uuid(),
item_type: z.string(),
description: z.string(),
quantity: z.number().nullable(),
unit_price: z.number().nullable(),
amount: z.number(),
is_taxable: z.boolean(),
is_avgift_basis: z.boolean(),
is_vacation_basis: z.boolean(),
is_gross_deduction: z.boolean(),
is_net_deduction: z.boolean(),
account_number: z.string().nullable(),
sort_order: z.number(),
})
registerEndpoint({
operation: 'salary-runs.lines.update',
method: 'PATCH',
path: '/api/v1/companies/:companyId/salary-runs/:id/lines/:lineId',
summary: 'Update a payslip line in a draft salary run.',
description:
'Updates fields on a salary_line_items row (amount, description, quantity, unit_price, flags, account_number) while the run is a draft. Amounts are rounded to whole öre.',
useWhen:
'You spotted a wrong amount or description on a manual line before calculating: fix it in place instead of delete + recreate.',
doNotUseFor:
'Post-calculation tax/avgifter adjustments (review-stage overrides are not on v1). Engine-derived lines (absence/benefits): they are regenerated by :calculate, so edits are overwritten.',
pitfalls: [
'Draft-only: 400 SALARY_RUN_LINE_NOT_DRAFT once the run has advanced.',
'A lineId that belongs to a different run returns 404 SALARY_LINE_NOT_FOUND.',
'Line edits do not recompute tax or totals: call POST /salary-runs/{id}/calculate afterwards.',
],
example: {
request: { amount: 5500 },
response: {
data: { salary_line_item_id: 'sli_31c9…', amount: 5500 },
meta: { request_id: 'req_…', api_version: '2026-05-12' },
},
},
scope: 'payroll:write',
risk: 'low',
idempotent: true,
reversible: true,
dryRunSupported: true,
request: { body: UpdateSalaryLineItemSchema },
response: { success: dataEnvelope(LineItemResponse) },
})
function parsePathIds(id: string, lineId: string):
| { ok: true; runId: string; lineId: string }
| { ok: false; field: string } {
const runParse = z.string().uuid().safeParse(id)
if (!runParse.success) return { ok: false, field: 'id' }
const lineParse = z.string().uuid().safeParse(lineId)
if (!lineParse.success) return { ok: false, field: 'lineId' }
return { ok: true, runId: runParse.data, lineId: lineParse.data }
}
function toResponsePayload(row: Record<string, unknown>): Record<string, unknown> {
const { id, company_id: _companyId, created_at: _c, updated_at: _u, ...rest } = row as {
id: string
company_id?: string
created_at?: string
updated_at?: string
} & Record<string, unknown>
return { salary_line_item_id: id, ...rest }
}
export const PATCH = withApiV1<{ params: Promise<{ companyId: string; id: string; lineId: string }> }>(
'salary-runs.lines.update',
async (request, ctx, params) => {
const { id, lineId } = await params.params
const ids = parsePathIds(id, lineId)
if (!ids.ok) {
return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, {
requestId: ctx.requestId,
details: { field: ids.field, message: 'Path ids must be UUIDs.' },
})
}
let rawBody: unknown
try {
rawBody = await request.json()
} catch {
return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, {
requestId: ctx.requestId,
details: { field: 'body', message: 'Body is not valid JSON.' },
})
}
const parsed = UpdateSalaryLineItemSchema.safeParse(rawBody)
if (!parsed.success) {
return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, {
requestId: ctx.requestId,
details: {
issues: parsed.error.issues.map((i) => ({
field: i.path.join('.'),
message: i.message,
})),
},
})
}
// Zod .default() materializes flags the caller never sent; strip anything
// not explicitly present so a PATCH can't silently reset flags. (Same
// explicit-keys defense as the salary-runs PATCH.)
const POLLUTING_KEYS = new Set(['__proto__', 'constructor', 'prototype'])
const rawKeys =
typeof rawBody === 'object' && rawBody !== null && !Array.isArray(rawBody)
? Object.keys(rawBody).filter((k) => !POLLUTING_KEYS.has(k))
: []
const patch: Record<string, unknown> = {}
for (const [key, value] of Object.entries(parsed.data) as Array<[string, unknown]>) {
if (rawKeys.includes(key) && value !== undefined) {
patch[key] = value
}
}
if (Object.keys(patch).length === 0) {
return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, {
requestId: ctx.requestId,
details: { field: 'body', message: 'At least one updatable field is required.' },
})
}
const result = await updatePayslipLine(ctx.supabase, {
companyId: ctx.companyId!,
salaryRunId: ids.runId,
lineId: ids.lineId,
patch,
dryRun: ctx.dryRun,
})
if (!result.ok) {
return v1ErrorResponseFromCode(result.code, ctx.log, {
requestId: ctx.requestId,
details: result.details,
})
}
const payload = toResponsePayload(result.data as unknown as Record<string, unknown>)
if (ctx.dryRun) {
return dryRunPreview(payload, { requestId: ctx.requestId, log: ctx.log })
}
return ok(payload, { requestId: ctx.requestId })
},
{ requireIdempotencyKey: true },
)
registerEndpoint({
operation: 'salary-runs.lines.delete',
method: 'DELETE',
path: '/api/v1/companies/:companyId/salary-runs/:id/lines/:lineId',
summary: 'Delete a payslip line from a draft salary run.',
description:
'Removes a salary_line_items row while the run is a draft. Engine-derived lines (absence, benefits) reappear on the next :calculate; delete the underlying absence/benefit record instead.',
useWhen:
'A manual line (bonus, deduction) was added by mistake and the run has not been calculated/advanced yet.',
doNotUseFor:
'Removing an employee from the run entirely: DELETE /salary-runs/{id}/employees/{employeeId}. Suppressing engine-derived lines: fix the source data (absence days, benefits).',
pitfalls: [
'Draft-only: 400 SALARY_RUN_LINE_NOT_DRAFT once the run has advanced.',
'Deleting an engine-derived line is futile: :calculate regenerates it from source data.',
],
example: { response: { data: null } },
scope: 'payroll:write',
risk: 'low',
idempotent: true,
reversible: false,
dryRunSupported: true,
response: { success: NoBodyResponse },
})
export const DELETE = withApiV1<{ params: Promise<{ companyId: string; id: string; lineId: string }> }>(
'salary-runs.lines.delete',
async (_request, ctx, params) => {
const { id, lineId } = await params.params
const ids = parsePathIds(id, lineId)
if (!ids.ok) {
return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, {
requestId: ctx.requestId,
details: { field: ids.field, message: 'Path ids must be UUIDs.' },
})
}
const result = await deletePayslipLine(ctx.supabase, {
companyId: ctx.companyId!,
salaryRunId: ids.runId,
lineId: ids.lineId,
dryRun: ctx.dryRun,
})
if (!result.ok) {
return v1ErrorResponseFromCode(result.code, ctx.log, {
requestId: ctx.requestId,
details: result.details,
})
}
if (ctx.dryRun) {
return dryRunPreview(result.data, { requestId: ctx.requestId, log: ctx.log })
}
return noContent({ requestId: ctx.requestId })
},
{ requireIdempotencyKey: true },
)
@@ -0,0 +1,439 @@
/**
* Tests for the v1 payslip line writes (payroll gap-closure 1.2).
*
* POST /salary-runs/{id}/employees/{employeeId}/lines
* PATCH /salary-runs/{id}/lines/{lineId}
* DELETE /salary-runs/{id}/lines/{lineId}
*/
import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
beforeAll(() => {
if (process.env.NODE_ENV !== 'test') {
throw new Error(
`salary line route tests require NODE_ENV=test (got ${process.env.NODE_ENV ?? 'undefined'})`,
)
}
process.env.NEXT_PUBLIC_SUPABASE_URL ||= 'http://localhost:54321'
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY ||= 'test-anon-key'
})
vi.mock('@/lib/auth/api-keys', async () => {
const actual = await vi.importActual<typeof import('@/lib/auth/api-keys')>('@/lib/auth/api-keys')
return {
...actual,
validateApiKey: vi.fn(),
createServiceClientNoCookies: vi.fn(),
}
})
vi.mock('@supabase/supabase-js', async () => {
const actual = await vi.importActual<typeof import('@supabase/supabase-js')>('@supabase/supabase-js')
return { ...actual, createClient: vi.fn().mockReturnValue({}) }
})
import { validateApiKey, createServiceClientNoCookies } from '@/lib/auth/api-keys'
import { POST as createLine } from '../../employees/[employeeId]/lines/route'
import { PATCH as patchLine, DELETE as deleteLine } from '../[lineId]/route'
const mockValidate = validateApiKey as ReturnType<typeof vi.fn>
const mockServiceClient = createServiceClientNoCookies as ReturnType<typeof vi.fn>
interface TableResp {
data?: unknown
error?: unknown
}
function makeFlexibleSupabase(byTable: Record<string, TableResp | TableResp[]>) {
const queues = new Map<string, TableResp[]>()
for (const [t, val] of Object.entries(byTable)) {
queues.set(t, Array.isArray(val) ? [...val] : [val])
}
const tableCalls: string[] = []
const buildChain = (table: string): unknown => {
const handler: ProxyHandler<object> = {
get(_target, prop) {
if (prop === 'then') {
return (resolve: (v: unknown) => void) => {
const q = queues.get(table)
const next = q && q.length > 1 ? q.shift()! : (q?.[0] ?? { data: null, error: null })
resolve(next)
}
}
return (..._args: unknown[]) => buildChain(table)
},
}
return new Proxy({}, handler)
}
return {
tableCalls,
from: vi.fn((table: string) => {
tableCalls.push(table)
return buildChain(table)
}),
}
}
const COMPANY_ID = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa'
const RUN_ID = 'cccccccc-cccc-4ccc-8ccc-cccccccccccc'
const SRE_ID = 'dddddddd-dddd-4ddd-8ddd-dddddddddddd'
const EMPLOYEE_ID = 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb'
const LINE_ID = 'eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeee'
const USER_ID = 'user-1'
const SAMPLE_LINE = {
id: LINE_ID,
salary_run_employee_id: SRE_ID,
company_id: COMPANY_ID,
item_type: 'bonus',
description: 'Kvartalsbonus',
quantity: null,
unit_price: null,
amount: 5000,
is_taxable: true,
is_avgift_basis: true,
is_vacation_basis: true,
is_gross_deduction: false,
is_net_deduction: false,
account_number: '7210',
sort_order: 0,
created_at: '2026-05-01T08:00:00Z',
updated_at: '2026-05-01T08:00:00Z',
}
function makeRequest(url: string, init?: RequestInit): Request {
return new Request(url, {
...init,
headers: {
Authorization: 'Bearer test-fixture-not-a-real-key',
'Idempotency-Key': 'b1aaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa',
...(init?.headers ?? {}),
},
})
}
function createParams(companyId: string, id: string, employeeId: string) {
return { params: Promise.resolve({ companyId, id, employeeId }) }
}
function lineParams(companyId: string, id: string, lineId: string) {
return { params: Promise.resolve({ companyId, id, lineId }) }
}
beforeEach(() => {
vi.clearAllMocks()
mockValidate.mockResolvedValue({
userId: USER_ID,
companyId: COMPANY_ID,
apiKeyId: 'ak_1',
apiKeyName: 'CI key',
scopes: ['payroll:read', 'payroll:write'],
mode: 'live',
})
})
describe('POST /salary-runs/:id/employees/:employeeId/lines', () => {
const validBody = { item_type: 'bonus', description: 'Kvartalsbonus', amount: 5000 }
it('creates a line and returns 201 with the qualified id (happy path)', async () => {
mockServiceClient.mockReturnValue(
makeFlexibleSupabase({
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
salary_runs: { data: { id: RUN_ID, status: 'draft' }, error: null },
salary_run_employees: { data: { id: SRE_ID, employee_id: EMPLOYEE_ID }, error: null },
salary_line_items: { data: SAMPLE_LINE, error: null },
idempotency_keys: { data: null, error: null },
}),
)
const res = await createLine(
makeRequest(
`https://x.test/api/v1/companies/${COMPANY_ID}/salary-runs/${RUN_ID}/employees/${EMPLOYEE_ID}/lines`,
{ method: 'POST', body: JSON.stringify(validBody) },
),
createParams(COMPANY_ID, RUN_ID, EMPLOYEE_ID),
)
expect(res.status).toBe(201)
const body = await res.json()
expect(body.data.salary_line_item_id).toBe(LINE_ID)
expect(body.data.item_type).toBe('bonus')
expect(body.data.id).toBeUndefined()
})
it('returns 400 SALARY_RUN_LINE_NOT_DRAFT when the run has advanced', async () => {
mockServiceClient.mockReturnValue(
makeFlexibleSupabase({
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
salary_runs: { data: { id: RUN_ID, status: 'review' }, error: null },
idempotency_keys: { data: null, error: null },
}),
)
const res = await createLine(
makeRequest(
`https://x.test/api/v1/companies/${COMPANY_ID}/salary-runs/${RUN_ID}/employees/${EMPLOYEE_ID}/lines`,
{ method: 'POST', body: JSON.stringify(validBody) },
),
createParams(COMPANY_ID, RUN_ID, EMPLOYEE_ID),
)
expect(res.status).toBe(400)
const body = await res.json()
expect(body.error.code).toBe('SALARY_RUN_LINE_NOT_DRAFT')
expect(body.error.details.current_status).toBe('review')
})
it('returns 404 SALARY_RUN_EMPLOYEE_NOT_FOUND when the employee is not in the run', async () => {
mockServiceClient.mockReturnValue(
makeFlexibleSupabase({
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
salary_runs: { data: { id: RUN_ID, status: 'draft' }, error: null },
salary_run_employees: { data: null, error: null },
idempotency_keys: { data: null, error: null },
}),
)
const res = await createLine(
makeRequest(
`https://x.test/api/v1/companies/${COMPANY_ID}/salary-runs/${RUN_ID}/employees/${EMPLOYEE_ID}/lines`,
{ method: 'POST', body: JSON.stringify(validBody) },
),
createParams(COMPANY_ID, RUN_ID, EMPLOYEE_ID),
)
expect(res.status).toBe(404)
const body = await res.json()
expect(body.error.code).toBe('SALARY_RUN_EMPLOYEE_NOT_FOUND')
})
it('rejects an unknown item_type with 400 VALIDATION_ERROR', async () => {
mockServiceClient.mockReturnValue(
makeFlexibleSupabase({
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
idempotency_keys: { data: null, error: null },
}),
)
const res = await createLine(
makeRequest(
`https://x.test/api/v1/companies/${COMPANY_ID}/salary-runs/${RUN_ID}/employees/${EMPLOYEE_ID}/lines`,
{ method: 'POST', body: JSON.stringify({ ...validBody, item_type: 'space_travel' }) },
),
createParams(COMPANY_ID, RUN_ID, EMPLOYEE_ID),
)
expect(res.status).toBe(400)
const body = await res.json()
expect(body.error.code).toBe('VALIDATION_ERROR')
})
it('returns a dry-run preview without inserting', async () => {
const supabaseMock = makeFlexibleSupabase({
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
salary_runs: { data: { id: RUN_ID, status: 'draft' }, error: null },
salary_run_employees: { data: { id: SRE_ID, employee_id: EMPLOYEE_ID }, error: null },
idempotency_keys: { data: null, error: null },
})
mockServiceClient.mockReturnValue(supabaseMock)
const res = await createLine(
makeRequest(
`https://x.test/api/v1/companies/${COMPANY_ID}/salary-runs/${RUN_ID}/employees/${EMPLOYEE_ID}/lines?dry_run=true`,
{ method: 'POST', body: JSON.stringify({ ...validBody, amount: 1.005 }) },
),
createParams(COMPANY_ID, RUN_ID, EMPLOYEE_ID),
)
expect(res.status).toBe(200)
expect(res.headers.get('X-Dry-Run')).toBe('true')
const body = await res.json()
expect(body.data.preview.salary_line_item_id).toBeNull()
// roundOre: 1.005 -> 1.01 (the exact-half case naive rounding gets wrong).
expect(body.data.preview.amount).toBe(1.01)
expect(body.data.preview.account_number).toBe('7210')
// No insert happened: salary_line_items was never touched.
expect(supabaseMock.tableCalls).not.toContain('salary_line_items')
})
it('returns 400 when Idempotency-Key is missing', async () => {
mockServiceClient.mockReturnValue(
makeFlexibleSupabase({
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
}),
)
const req = new Request(
`https://x.test/api/v1/companies/${COMPANY_ID}/salary-runs/${RUN_ID}/employees/${EMPLOYEE_ID}/lines`,
{
method: 'POST',
headers: { Authorization: 'Bearer test' },
body: JSON.stringify(validBody),
},
)
const res = await createLine(req, createParams(COMPANY_ID, RUN_ID, EMPLOYEE_ID))
expect(res.status).toBe(400)
})
it('rejects keys without payroll:write scope', async () => {
mockValidate.mockResolvedValue({
userId: USER_ID,
companyId: COMPANY_ID,
apiKeyId: 'ak_1',
apiKeyName: 'read-only',
scopes: ['payroll:read'],
mode: 'live',
})
mockServiceClient.mockReturnValue(makeFlexibleSupabase({}))
const res = await createLine(
makeRequest(
`https://x.test/api/v1/companies/${COMPANY_ID}/salary-runs/${RUN_ID}/employees/${EMPLOYEE_ID}/lines`,
{ method: 'POST', body: JSON.stringify(validBody) },
),
createParams(COMPANY_ID, RUN_ID, EMPLOYEE_ID),
)
expect(res.status).toBe(403)
})
it('returns 401 without a bearer token', async () => {
mockValidate.mockResolvedValue(null)
mockServiceClient.mockReturnValue(makeFlexibleSupabase({}))
const res = await createLine(
new Request(
`https://x.test/api/v1/companies/${COMPANY_ID}/salary-runs/${RUN_ID}/employees/${EMPLOYEE_ID}/lines`,
{ method: 'POST', body: JSON.stringify(validBody) },
),
createParams(COMPANY_ID, RUN_ID, EMPLOYEE_ID),
)
expect(res.status).toBe(401)
})
})
describe('PATCH /salary-runs/:id/lines/:lineId', () => {
it('updates a line (happy path)', async () => {
mockServiceClient.mockReturnValue(
makeFlexibleSupabase({
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
salary_runs: { data: { id: RUN_ID, status: 'draft' }, error: null },
salary_line_items: [
{ data: { ...SAMPLE_LINE, salary_run_employee: { salary_run_id: RUN_ID } }, error: null },
{ data: { ...SAMPLE_LINE, amount: 5500 }, error: null },
],
idempotency_keys: { data: null, error: null },
}),
)
const res = await patchLine(
makeRequest(
`https://x.test/api/v1/companies/${COMPANY_ID}/salary-runs/${RUN_ID}/lines/${LINE_ID}`,
{ method: 'PATCH', body: JSON.stringify({ amount: 5500 }) },
),
lineParams(COMPANY_ID, RUN_ID, LINE_ID),
)
expect(res.status).toBe(200)
const body = await res.json()
expect(body.data.salary_line_item_id).toBe(LINE_ID)
expect(body.data.amount).toBe(5500)
})
it('returns 404 SALARY_LINE_NOT_FOUND for a line from another run', async () => {
mockServiceClient.mockReturnValue(
makeFlexibleSupabase({
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
salary_runs: { data: { id: RUN_ID, status: 'draft' }, error: null },
salary_line_items: {
data: {
...SAMPLE_LINE,
salary_run_employee: { salary_run_id: '99999999-9999-4999-8999-999999999999' },
},
error: null,
},
idempotency_keys: { data: null, error: null },
}),
)
const res = await patchLine(
makeRequest(
`https://x.test/api/v1/companies/${COMPANY_ID}/salary-runs/${RUN_ID}/lines/${LINE_ID}`,
{ method: 'PATCH', body: JSON.stringify({ amount: 5500 }) },
),
lineParams(COMPANY_ID, RUN_ID, LINE_ID),
)
expect(res.status).toBe(404)
const body = await res.json()
expect(body.error.code).toBe('SALARY_LINE_NOT_FOUND')
})
it('rejects an empty patch with 400', async () => {
mockServiceClient.mockReturnValue(
makeFlexibleSupabase({
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
idempotency_keys: { data: null, error: null },
}),
)
const res = await patchLine(
makeRequest(
`https://x.test/api/v1/companies/${COMPANY_ID}/salary-runs/${RUN_ID}/lines/${LINE_ID}`,
{ method: 'PATCH', body: JSON.stringify({}) },
),
lineParams(COMPANY_ID, RUN_ID, LINE_ID),
)
expect(res.status).toBe(400)
const body = await res.json()
expect(body.error.code).toBe('VALIDATION_ERROR')
})
})
describe('DELETE /salary-runs/:id/lines/:lineId', () => {
it('deletes a line and returns 204', async () => {
mockServiceClient.mockReturnValue(
makeFlexibleSupabase({
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
salary_runs: { data: { id: RUN_ID, status: 'draft' }, error: null },
salary_line_items: [
{ data: { ...SAMPLE_LINE, salary_run_employee: { salary_run_id: RUN_ID } }, error: null },
{ data: null, error: null },
],
idempotency_keys: { data: null, error: null },
}),
)
const res = await deleteLine(
makeRequest(
`https://x.test/api/v1/companies/${COMPANY_ID}/salary-runs/${RUN_ID}/lines/${LINE_ID}`,
{ method: 'DELETE' },
),
lineParams(COMPANY_ID, RUN_ID, LINE_ID),
)
expect(res.status).toBe(204)
})
it('returns 400 SALARY_RUN_LINE_NOT_DRAFT once the run has advanced', async () => {
mockServiceClient.mockReturnValue(
makeFlexibleSupabase({
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
salary_runs: { data: { id: RUN_ID, status: 'booked' }, error: null },
idempotency_keys: { data: null, error: null },
}),
)
const res = await deleteLine(
makeRequest(
`https://x.test/api/v1/companies/${COMPANY_ID}/salary-runs/${RUN_ID}/lines/${LINE_ID}`,
{ method: 'DELETE' },
),
lineParams(COMPANY_ID, RUN_ID, LINE_ID),
)
expect(res.status).toBe(400)
const body = await res.json()
expect(body.error.code).toBe('SALARY_RUN_LINE_NOT_DRAFT')
})
})
@@ -0,0 +1,133 @@
/**
* ASVS V8.2.1: the payslip PDF endpoint returns full personnummer-bearing
* payroll data, so the binding between the API key's user and the
* `[companyId]` path segment must be enforced server-side BEFORE any payslip
* data is read. The check lives in withApiV1 (company_members lookup); these
* tests pin it to this concrete route so a wrapper regression or a future
* unwrapped rewrite of the route fails loudly here.
*
* Deliberate convention: the deny case is 404 (not 403) so an unauthorized
* caller cannot probe which company ids exist (see DECISIONS.md).
*/
import { beforeEach, describe, expect, it, vi } from 'vitest'
vi.mock('@/lib/auth/api-keys', async () => {
const actual = await vi.importActual<typeof import('@/lib/auth/api-keys')>('@/lib/auth/api-keys')
return {
...actual,
validateApiKey: vi.fn(),
createServiceClientNoCookies: vi.fn(),
}
})
vi.mock('@/lib/api/idempotency', async () => {
const actual = await vi.importActual<typeof import('@/lib/api/idempotency')>(
'@/lib/api/idempotency',
)
return {
...actual,
checkIdempotencyKey: vi.fn(),
storeIdempotencyResponse: vi.fn(),
}
})
// PDF rendering is irrelevant to the auth surface under test; keep the test
// hermetic (no @react-pdf font/layout machinery).
vi.mock('@react-pdf/renderer', () => ({ renderToBuffer: vi.fn() }))
vi.mock('@/lib/salary/pdf/payslip-template', () => ({ PayslipPDF: vi.fn() }))
vi.mock('@/lib/salary/payslips/build-payslip-data', () => ({
buildPayslipData: vi.fn(),
payslipFileName: vi.fn(() => 'payslip.pdf'),
}))
vi.mock('@/lib/company/context', () => ({ getCompanyDisplayName: vi.fn() }))
import { validateApiKey, createServiceClientNoCookies } from '@/lib/auth/api-keys'
import { GET } from '../route'
const mockValidate = validateApiKey as ReturnType<typeof vi.fn>
const mockServiceClient = createServiceClientNoCookies as ReturnType<typeof vi.fn>
const COMPANY_A = '11111111-1111-4111-8111-111111111111'
const RUN_ID = '22222222-2222-4222-8222-222222222222'
const EMPLOYEE_ID = '33333333-3333-4333-8333-333333333333'
function makeSupabaseStub(membership: { company_id: string; role: string } | null) {
const from = vi.fn().mockReturnValue({
select: vi.fn().mockReturnValue({
eq: vi.fn().mockReturnValue({
eq: vi.fn().mockReturnValue({
maybeSingle: vi.fn().mockResolvedValue({ data: membership, error: null }),
}),
}),
}),
})
return { from }
}
function makeRequest(companyId: string, init?: RequestInit) {
return new Request(
`https://x.test/api/v1/companies/${companyId}/salary-runs/${RUN_ID}/payslips/${EMPLOYEE_ID}/pdf`,
init,
)
}
function makeParams(companyId: string, id: string = RUN_ID, employeeId: string = EMPLOYEE_ID) {
return { params: Promise.resolve({ companyId, id, employeeId }) }
}
beforeEach(() => {
vi.clearAllMocks()
})
describe('GET /api/v1/companies/[companyId]/salary-runs/[id]/payslips/[employeeId]/pdf', () => {
it('returns 401 without a bearer token', async () => {
const res = await GET(makeRequest(COMPANY_A), makeParams(COMPANY_A))
expect(res.status).toBe(401)
const body = await res.json()
expect(body.error.code).toBe('UNAUTHORIZED')
})
it('returns 404 and reads no payslip data when the key user is not a member of the URL company', async () => {
mockValidate.mockResolvedValue({
userId: 'user-1',
apiKeyId: 'key-1',
scopes: ['payroll:read'],
mode: 'live',
})
const stub = makeSupabaseStub(null) // no membership in the URL company
mockServiceClient.mockReturnValue(stub)
const res = await GET(
makeRequest(COMPANY_A, { headers: { Authorization: 'Bearer gnubok_sk_x' } }),
makeParams(COMPANY_A),
)
expect(res.status).toBe(404)
const body = await res.json()
expect(body.error.code).toBe('NOT_FOUND')
// The deny fires in the wrapper: the handler's salary_runs /
// salary_run_employees / companies queries must never have run.
expect(stub.from.mock.calls.map((c) => c[0])).toEqual(['company_members'])
})
it('rejects non-UUID path ids with 400 before touching payroll tables', async () => {
mockValidate.mockResolvedValue({
userId: 'user-1',
apiKeyId: 'key-1',
scopes: ['payroll:read'],
mode: 'live',
})
const stub = makeSupabaseStub({ company_id: COMPANY_A, role: 'owner' })
mockServiceClient.mockReturnValue(stub)
const res = await GET(
makeRequest(COMPANY_A, { headers: { Authorization: 'Bearer gnubok_sk_x' } }),
makeParams(COMPANY_A, 'not-a-uuid'),
)
expect(res.status).toBe(400)
const body = await res.json()
expect(body.error.code).toBe('VALIDATION_ERROR')
expect(stub.from.mock.calls.map((c) => c[0])).toEqual(['company_members'])
})
})
@@ -0,0 +1,153 @@
/**
* GET /api/v1/companies/{companyId}/salary-runs/{id}/payslips/{employeeId}/pdf
*
* Render one employee's payslip as application/pdf. Byte-equivalent to the
* dashboard download: data assembly is shared via
* lib/salary/payslips/build-payslip-data.
*
* Per BFL: payslips are räkenskapsinformation linked to posted journal
* entries (7-year retention). Read-only: no Idempotency-Key, no dry-run.
*/
import { z } from 'zod'
import { renderToBuffer } from '@react-pdf/renderer'
import { PayslipPDF } from '@/lib/salary/pdf/payslip-template'
import { buildPayslipData, payslipFileName } from '@/lib/salary/payslips/build-payslip-data'
import { contentDisposition } from '@/lib/api/content-disposition'
import { getCompanyDisplayName } from '@/lib/company/context'
import { registerEndpoint } from '@/lib/api/v1/registry'
import { withApiV1 } from '@/lib/api/v1/with-api-v1'
import { v1ErrorResponse, v1ErrorResponseFromCode } from '@/lib/api/v1/errors'
registerEndpoint({
operation: 'salary-runs.payslip.pdf',
method: 'GET',
path: '/api/v1/companies/:companyId/salary-runs/:id/payslips/:employeeId/pdf',
summary: 'Download one employee\'s payslip as PDF.',
description:
'Returns the rendered payslip (lönespecifikation) as application/pdf, byte-equivalent to the dashboard download. Content-Disposition is attachment with a filename derived from the period and employee name.',
useWhen:
'You need the payslip document itself: archiving, forwarding to the employee outside the Accounted send flow, or attaching to an external HR system.',
doNotUseFor:
'The payslip DATA (amounts, line items): use GET /salary-runs/{id}/employees/{employeeId}, which is cheaper and structured. Emailing payslips to employees: the send flow is internal-only today.',
pitfalls: [
'The PDF renders whatever the run currently holds: for a draft run that has not been calculated, amounts are 0.',
'PDF rendering takes a few hundred milliseconds; cache on the client if requesting repeatedly.',
],
example: {
response: {
_note: 'Returns application/pdf binary stream.',
},
},
scope: 'payroll:read',
risk: 'low',
idempotent: true,
reversible: false,
dryRunSupported: false,
response: {
success: z.unknown(), // Marker: binary response, see contentType.
contentType: 'application/pdf',
},
})
export const GET = withApiV1<{ params: Promise<{ companyId: string; id: string; employeeId: string }> }>(
'salary-runs.payslip.pdf',
async (_request, ctx, params) => {
const { id, employeeId } = await params.params
const runParse = z.string().uuid().safeParse(id)
const empParse = z.string().uuid().safeParse(employeeId)
if (!runParse.success || !empParse.success) {
return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, {
requestId: ctx.requestId,
details: {
field: runParse.success ? 'employeeId' : 'id',
message: 'Path ids must be UUIDs.',
},
})
}
const { data: run, error: runErr } = await ctx.supabase
.from('salary_runs')
.select('*')
.eq('id', runParse.data)
.eq('company_id', ctx.companyId!)
.maybeSingle()
if (runErr) {
return v1ErrorResponse(runErr, ctx.log, { requestId: ctx.requestId })
}
if (!run) {
return v1ErrorResponseFromCode('SALARY_RUN_NOT_FOUND', ctx.log, { requestId: ctx.requestId })
}
const { data: sre, error: sreErr } = await ctx.supabase
.from('salary_run_employees')
.select(
'*, employee:employees(first_name, last_name, personnummer, personnummer_last4, employment_type, tax_table_number, tax_column, clearing_number, bank_account_number), line_items:salary_line_items(*)',
)
.eq('salary_run_id', runParse.data)
.eq('employee_id', empParse.data)
.eq('company_id', ctx.companyId!)
.maybeSingle()
if (sreErr) {
return v1ErrorResponse(sreErr, ctx.log, { requestId: ctx.requestId })
}
if (!sre) {
return v1ErrorResponseFromCode('NOT_FOUND', ctx.log, {
requestId: ctx.requestId,
details: { resource: 'salary_run_employee', employee_id: empParse.data },
})
}
const { data: company, error: companyErr } = await ctx.supabase
.from('companies')
.select('name, org_number')
.eq('id', ctx.companyId!)
.maybeSingle()
if (companyErr || !company) {
return v1ErrorResponseFromCode('NOT_FOUND', ctx.log, {
requestId: ctx.requestId,
details: { resource: 'company' },
})
}
const emp = sre.employee as {
first_name: string; last_name: string; personnummer: string; personnummer_last4: string;
employment_type: string; tax_table_number: number | null; tax_column: number;
clearing_number: string | null; bank_account_number: string | null;
}
let pdfBuffer: Buffer
let fileName: string
try {
const displayName = await getCompanyDisplayName(ctx.supabase, ctx.companyId!)
const data = buildPayslipData({
run,
sre,
employee: emp,
company: { name: displayName ?? company.name, org_number: company.org_number },
})
fileName = payslipFileName(run, emp)
// eslint-disable-next-line @typescript-eslint/no-explicit-any
pdfBuffer = await renderToBuffer(PayslipPDF({ data }) as any)
} catch (err) {
ctx.log.error('salary-runs.payslip.pdf: render failed', err as Error, {
salaryRunId: runParse.data,
companyId: ctx.companyId,
})
return v1ErrorResponseFromCode('INTERNAL_ERROR', ctx.log, { requestId: ctx.requestId })
}
const uint8Array = new Uint8Array(pdfBuffer)
return new Response(uint8Array, {
status: 200,
headers: {
'Content-Type': 'application/pdf',
// RFC 5987 dual form: employee names with non-Latin-1 characters
// would otherwise make undici reject the header value.
'Content-Disposition': contentDisposition('attachment', fileName),
'Content-Length': String(pdfBuffer.length),
'X-Request-Id': ctx.requestId,
},
})
},
)
@@ -0,0 +1,279 @@
/**
* Tests for GET /api/v1/companies/{companyId}/salary-runs/{id}/payslips/{employeeId}/pdf
* (payroll gap-closure 1.1).
*
* renderToBuffer is mocked (the invoice-pdf test pattern): these tests assert
* routing, auth, 404 paths, and the binary response headers, not PDF pixels.
*/
import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
beforeAll(() => {
if (process.env.NODE_ENV !== 'test') {
throw new Error(
`payslip pdf route tests require NODE_ENV=test (got ${process.env.NODE_ENV ?? 'undefined'})`,
)
}
process.env.NEXT_PUBLIC_SUPABASE_URL ||= 'http://localhost:54321'
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY ||= 'test-anon-key'
})
vi.mock('@/lib/auth/api-keys', async () => {
const actual = await vi.importActual<typeof import('@/lib/auth/api-keys')>('@/lib/auth/api-keys')
return {
...actual,
validateApiKey: vi.fn(),
createServiceClientNoCookies: vi.fn(),
}
})
vi.mock('@supabase/supabase-js', async () => {
const actual = await vi.importActual<typeof import('@supabase/supabase-js')>('@supabase/supabase-js')
return { ...actual, createClient: vi.fn().mockReturnValue({}) }
})
vi.mock('@react-pdf/renderer', () => ({
renderToBuffer: vi.fn().mockResolvedValue(Buffer.from('%PDF-1.7 test')),
// The payslip template imports these primitives at module scope.
Document: () => null,
Page: () => null,
Text: () => null,
View: () => null,
StyleSheet: { create: (s: unknown) => s },
Font: { register: () => undefined },
}))
import { validateApiKey, createServiceClientNoCookies } from '@/lib/auth/api-keys'
import { renderToBuffer } from '@react-pdf/renderer'
import { GET as getPayslipPdf } from '../[employeeId]/pdf/route'
const mockValidate = validateApiKey as ReturnType<typeof vi.fn>
const mockServiceClient = createServiceClientNoCookies as ReturnType<typeof vi.fn>
const mockRender = renderToBuffer as ReturnType<typeof vi.fn>
interface TableResp {
data?: unknown
error?: unknown
}
function makeFlexibleSupabase(byTable: Record<string, TableResp | TableResp[]>) {
const queues = new Map<string, TableResp[]>()
for (const [t, val] of Object.entries(byTable)) {
queues.set(t, Array.isArray(val) ? [...val] : [val])
}
const buildChain = (table: string): unknown => {
const handler: ProxyHandler<object> = {
get(_target, prop) {
if (prop === 'then') {
return (resolve: (v: unknown) => void) => {
const q = queues.get(table)
const next = q && q.length > 1 ? q.shift()! : (q?.[0] ?? { data: null, error: null })
resolve(next)
}
}
return (..._args: unknown[]) => buildChain(table)
},
}
return new Proxy({}, handler)
}
return { from: vi.fn((table: string) => buildChain(table)) }
}
const COMPANY_ID = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa'
const RUN_ID = 'cccccccc-cccc-4ccc-8ccc-cccccccccccc'
const EMPLOYEE_ID = 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb'
const USER_ID = 'user-1'
const SAMPLE_PERSONNUMMER = '190001010000'
const SAMPLE_RUN = {
id: RUN_ID,
period_year: 2026,
period_month: 5,
payment_date: '2026-05-25',
status: 'booked',
}
const SAMPLE_SRE = {
id: 'dddddddd-dddd-4ddd-8ddd-dddddddddddd',
gross_salary: 35000,
tax_withheld: 8200,
tax_withheld_override: null,
avgifter_rate: 0.3142,
avgifter_amount: 10997,
avgifter_basis_override: null,
avgifter_amount_override: null,
override_reason: null,
net_salary: 26800,
vacation_accrual: 4200,
vacation_accrual_avgifter: 1319.64,
ytd_gross: 70000,
ytd_tax: 16400,
ytd_net: 53600,
calculation_breakdown: null,
employee: {
first_name: 'Anna',
last_name: 'Andersson',
personnummer: SAMPLE_PERSONNUMMER,
personnummer_last4: '0000',
employment_type: 'employee',
tax_table_number: 33,
tax_column: 1,
clearing_number: '6000',
bank_account_number: '12345678',
},
line_items: [
{
description: 'Grundlön',
quantity: null,
unit_price: null,
amount: 35000,
sort_order: 0,
},
],
}
function makeRequest(url: string): Request {
return new Request(url, {
headers: { Authorization: 'Bearer test-fixture-not-a-real-key' },
})
}
function pdfParams(companyId: string, id: string, employeeId: string) {
return { params: Promise.resolve({ companyId, id, employeeId }) }
}
beforeEach(() => {
vi.clearAllMocks()
mockRender.mockResolvedValue(Buffer.from('%PDF-1.7 test'))
mockValidate.mockResolvedValue({
userId: USER_ID,
companyId: COMPANY_ID,
apiKeyId: 'ak_1',
apiKeyName: 'CI key',
scopes: ['payroll:read'],
mode: 'live',
})
})
describe('GET /api/v1/companies/:companyId/salary-runs/:id/payslips/:employeeId/pdf', () => {
it('returns the rendered PDF with attachment disposition (happy path)', async () => {
mockServiceClient.mockReturnValue(
makeFlexibleSupabase({
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
salary_runs: { data: SAMPLE_RUN, error: null },
salary_run_employees: { data: SAMPLE_SRE, error: null },
companies: { data: { name: 'Testbolaget AB', org_number: '5560000000' }, error: null },
company_settings: { data: { company_name: 'Testbolaget AB' }, error: null },
}),
)
const res = await getPayslipPdf(
makeRequest(
`https://x.test/api/v1/companies/${COMPANY_ID}/salary-runs/${RUN_ID}/payslips/${EMPLOYEE_ID}/pdf`,
),
pdfParams(COMPANY_ID, RUN_ID, EMPLOYEE_ID),
)
expect(res.status).toBe(200)
expect(res.headers.get('Content-Type')).toBe('application/pdf')
expect(res.headers.get('Content-Disposition')).toContain('attachment')
expect(res.headers.get('Content-Disposition')).toContain('lonespec_Andersson_Anna_2026-05.pdf')
expect(res.headers.get('X-Request-Id')).toBeTruthy()
const buf = Buffer.from(await res.arrayBuffer())
expect(buf.toString()).toContain('%PDF')
})
it('returns 404 SALARY_RUN_NOT_FOUND when the run is missing', async () => {
mockServiceClient.mockReturnValue(
makeFlexibleSupabase({
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
salary_runs: { data: null, error: null },
}),
)
const res = await getPayslipPdf(
makeRequest(
`https://x.test/api/v1/companies/${COMPANY_ID}/salary-runs/${RUN_ID}/payslips/${EMPLOYEE_ID}/pdf`,
),
pdfParams(COMPANY_ID, RUN_ID, EMPLOYEE_ID),
)
expect(res.status).toBe(404)
const body = await res.json()
expect(body.error.code).toBe('SALARY_RUN_NOT_FOUND')
})
it('returns 404 NOT_FOUND when the employee is not in the run', async () => {
mockServiceClient.mockReturnValue(
makeFlexibleSupabase({
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
salary_runs: { data: SAMPLE_RUN, error: null },
salary_run_employees: { data: null, error: null },
}),
)
const res = await getPayslipPdf(
makeRequest(
`https://x.test/api/v1/companies/${COMPANY_ID}/salary-runs/${RUN_ID}/payslips/${EMPLOYEE_ID}/pdf`,
),
pdfParams(COMPANY_ID, RUN_ID, EMPLOYEE_ID),
)
expect(res.status).toBe(404)
const body = await res.json()
expect(body.error.code).toBe('NOT_FOUND')
})
it('rejects keys without payroll:read scope', async () => {
mockValidate.mockResolvedValue({
userId: USER_ID,
companyId: COMPANY_ID,
apiKeyId: 'ak_1',
apiKeyName: 'wrong scope',
scopes: ['invoices:read'],
mode: 'live',
})
mockServiceClient.mockReturnValue(makeFlexibleSupabase({}))
const res = await getPayslipPdf(
makeRequest(
`https://x.test/api/v1/companies/${COMPANY_ID}/salary-runs/${RUN_ID}/payslips/${EMPLOYEE_ID}/pdf`,
),
pdfParams(COMPANY_ID, RUN_ID, EMPLOYEE_ID),
)
expect(res.status).toBe(403)
})
it('returns 401 without a bearer token', async () => {
mockValidate.mockResolvedValue(null)
mockServiceClient.mockReturnValue(makeFlexibleSupabase({}))
const res = await getPayslipPdf(
new Request(
`https://x.test/api/v1/companies/${COMPANY_ID}/salary-runs/${RUN_ID}/payslips/${EMPLOYEE_ID}/pdf`,
),
pdfParams(COMPANY_ID, RUN_ID, EMPLOYEE_ID),
)
expect(res.status).toBe(401)
})
it('maps a render failure to 500 INTERNAL_ERROR rather than crashing', async () => {
mockRender.mockRejectedValue(new Error('font missing'))
mockServiceClient.mockReturnValue(
makeFlexibleSupabase({
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
salary_runs: { data: SAMPLE_RUN, error: null },
salary_run_employees: { data: SAMPLE_SRE, error: null },
companies: { data: { name: 'Testbolaget AB', org_number: '5560000000' }, error: null },
company_settings: { data: { company_name: 'Testbolaget AB' }, error: null },
}),
)
const res = await getPayslipPdf(
makeRequest(
`https://x.test/api/v1/companies/${COMPANY_ID}/salary-runs/${RUN_ID}/payslips/${EMPLOYEE_ID}/pdf`,
),
pdfParams(COMPANY_ID, RUN_ID, EMPLOYEE_ID),
)
expect(res.status).toBe(500)
const body = await res.json()
expect(body.error.code).toBe('INTERNAL_ERROR')
})
})
@@ -65,7 +65,7 @@ registerEndpoint({
useWhen:
'You have a salary_run_id and need its current status: typically to decide which lifecycle verb to call next, or to display the run header in a UI.',
doNotUseFor:
'Per-employee breakdown (Phase 5 PR-1 does not expose the per-employee endpoint on v1; use the internal /api/salary/runs/{id} for that today). Salary journal report: use GET /reports/salary-journal in Phase 5 PR-3.',
'Per-employee breakdown: use GET /salary-runs/{id}/employees (list) or /salary-runs/{id}/employees/{employeeId} (payslip detail). Salary journal report: use GET /reports/salary-journal.',
pitfalls: [
'salary_entry_id / avgifter_entry_id / vacation_entry_id are null until POST /book has run. They reference the journal_entries table.',
'total_* fields are 0 until POST /calculate has run.',
@@ -0,0 +1,242 @@
/**
* Tests for POST /api/v1/companies/{companyId}/salary/vacation-year-close
* and GET /employees/{id}/vacation-balance (payroll gap-closure 3.4).
*
* The close service is mocked: these tests cover auth, validation, dry-run
* preview plumbing, and error mapping. The beredning/reconcile math is
* covered in lib/salary/__tests__/semesterberedning.test.ts.
*/
import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
beforeAll(() => {
if (process.env.NODE_ENV !== 'test') {
throw new Error(
`vacation-year-close route tests require NODE_ENV=test (got ${process.env.NODE_ENV ?? 'undefined'})`,
)
}
process.env.NEXT_PUBLIC_SUPABASE_URL ||= 'http://localhost:54321'
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY ||= 'test-anon-key'
})
vi.mock('@/lib/auth/api-keys', async () => {
const actual = await vi.importActual<typeof import('@/lib/auth/api-keys')>('@/lib/auth/api-keys')
return {
...actual,
validateApiKey: vi.fn(),
createServiceClientNoCookies: vi.fn(),
}
})
vi.mock('@supabase/supabase-js', async () => {
const actual = await vi.importActual<typeof import('@supabase/supabase-js')>('@supabase/supabase-js')
return { ...actual, createClient: vi.fn().mockReturnValue({}) }
})
const mockPreview = vi.fn()
const mockCommit = vi.fn()
vi.mock('@/lib/salary/semesterberedning', () => ({
previewVacationYearClose: (...a: unknown[]) => mockPreview(...a),
commitVacationYearClose: (...a: unknown[]) => mockCommit(...a),
}))
vi.mock('@/lib/salary/vacation-ledger', () => ({
getVacationYearBasis: vi.fn().mockResolvedValue('calendar'),
syncVacationLedgerForEmployees: vi.fn().mockResolvedValue({ ok: true }),
}))
import { validateApiKey, createServiceClientNoCookies } from '@/lib/auth/api-keys'
import { POST as closeYear } from '../route'
const mockValidate = validateApiKey as ReturnType<typeof vi.fn>
const mockServiceClient = createServiceClientNoCookies as ReturnType<typeof vi.fn>
function makeFlexibleSupabase(byTable: Record<string, { data?: unknown; error?: unknown }>) {
const buildChain = (table: string): unknown => {
const handler: ProxyHandler<object> = {
get(_target, prop) {
if (prop === 'then') {
return (resolve: (v: unknown) => void) =>
resolve(byTable[table] ?? { data: null, error: null })
}
return (..._args: unknown[]) => buildChain(table)
},
}
return new Proxy({}, handler)
}
return { from: vi.fn((table: string) => buildChain(table)) }
}
const COMPANY_ID = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa'
const USER_ID = 'user-1'
const SAMPLE_REPORT = {
vacation_year_start: '2025-01-01',
vacation_year_end: '2025-12-31',
next_year_start: '2026-01-01',
basis: 'calendar',
rows: [],
sek: {
computed_liability: 0,
computed_avgifter: 0,
booked_2920: 0,
booked_2940: 0,
drift_2920: 0,
drift_2940: 0,
adjustment_needed: false,
},
adjustment_date: '2025-12-31',
}
function makeRequest(url: string, body?: unknown): Request {
return new Request(url, {
method: 'POST',
headers: {
Authorization: 'Bearer test-fixture-not-a-real-key',
'Idempotency-Key': 'b1aaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa',
},
body: JSON.stringify(body ?? {}),
})
}
function companyParams(companyId: string) {
return { params: Promise.resolve({ companyId }) }
}
beforeEach(() => {
vi.clearAllMocks()
mockValidate.mockResolvedValue({
userId: USER_ID,
companyId: COMPANY_ID,
apiKeyId: 'ak_1',
apiKeyName: 'CI key',
scopes: ['payroll:read', 'payroll:write'],
mode: 'live',
})
mockServiceClient.mockReturnValue(
makeFlexibleSupabase({
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
idempotency_keys: { data: null, error: null },
}),
)
})
describe('POST /salary/vacation-year-close', () => {
it('commits the close and returns the closure + adjustment ids', async () => {
mockCommit.mockResolvedValue({
ok: true,
data: { closure_id: 'closure-1', adjustment_entry_id: 'je-1', report: SAMPLE_REPORT },
})
const res = await closeYear(
makeRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/salary/vacation-year-close`, {
vacation_year_start: '2025-01-01',
}),
companyParams(COMPANY_ID),
)
expect(res.status).toBe(200)
const body = await res.json()
expect(body.data.vacation_year_closure_id).toBe('closure-1')
expect(body.data.adjustment_entry_id).toBe('je-1')
expect(mockCommit).toHaveBeenCalledWith(
expect.anything(),
COMPANY_ID,
USER_ID,
'2025-01-01',
{ bookAdjustment: true },
)
})
it('defaults the year to the most recently ended one when omitted', async () => {
mockCommit.mockResolvedValue({
ok: true,
data: { closure_id: 'closure-1', adjustment_entry_id: null, report: SAMPLE_REPORT },
})
const res = await closeYear(
makeRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/salary/vacation-year-close`),
companyParams(COMPANY_ID),
)
expect(res.status).toBe(200)
const currentYear = new Date().getFullYear()
expect(mockCommit.mock.calls[0][3]).toBe(`${currentYear - 1}-01-01`)
})
it('dry_run returns the full preview report with zero commits', async () => {
mockPreview.mockResolvedValue({ ok: true, data: SAMPLE_REPORT })
const res = await closeYear(
makeRequest(
`https://x.test/api/v1/companies/${COMPANY_ID}/salary/vacation-year-close?dry_run=true`,
{ vacation_year_start: '2025-01-01' },
),
companyParams(COMPANY_ID),
)
expect(res.status).toBe(200)
expect(res.headers.get('X-Dry-Run')).toBe('true')
const body = await res.json()
expect(body.data.preview.report.vacation_year_start).toBe('2025-01-01')
expect(mockCommit).not.toHaveBeenCalled()
})
it('maps VACATION_YEAR_ALREADY_CLOSED to 409', async () => {
mockCommit.mockResolvedValue({ ok: false, code: 'VACATION_YEAR_ALREADY_CLOSED' })
const res = await closeYear(
makeRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/salary/vacation-year-close`, {
vacation_year_start: '2025-01-01',
}),
companyParams(COMPANY_ID),
)
expect(res.status).toBe(409)
const body = await res.json()
expect(body.error.code).toBe('VACATION_YEAR_ALREADY_CLOSED')
})
it('maps PERIOD_LOCKED without committing anything', async () => {
mockCommit.mockResolvedValue({ ok: false, code: 'PERIOD_LOCKED', details: { reason: 'closed' } })
const res = await closeYear(
makeRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/salary/vacation-year-close`, {
vacation_year_start: '2025-01-01',
}),
companyParams(COMPANY_ID),
)
expect(res.status).toBeGreaterThanOrEqual(400)
const body = await res.json()
expect(body.error.code).toBe('PERIOD_LOCKED')
})
it('rejects keys without payroll:write scope', async () => {
mockValidate.mockResolvedValue({
userId: USER_ID,
companyId: COMPANY_ID,
apiKeyId: 'ak_1',
apiKeyName: 'read-only',
scopes: ['payroll:read'],
mode: 'live',
})
const res = await closeYear(
makeRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/salary/vacation-year-close`),
companyParams(COMPANY_ID),
)
expect(res.status).toBe(403)
})
it('requires an Idempotency-Key', async () => {
const req = new Request(
`https://x.test/api/v1/companies/${COMPANY_ID}/salary/vacation-year-close`,
{
method: 'POST',
headers: { Authorization: 'Bearer test' },
body: JSON.stringify({}),
},
)
const res = await closeYear(req, companyParams(COMPANY_ID))
expect(res.status).toBe(400)
})
})
@@ -0,0 +1,146 @@
/**
* POST /api/v1/companies/{companyId}/salary/vacation-year-close
*
* Semesterberedning + semesterårsavslut in one verb (payroll gap-closure
* 3.4). dry_run returns the FULL review report (per-employee day
* transitions + the SEK reconcile) without writing; the live call closes
* the year's ledger rows, rolls balances into the next year (min-20 floor,
* 5-year expiry -> forced payout), and books one drift-adjustment
* verifikation on 7290/2920 + 7519/2940 when |drift| > 1 kr.
*/
import { z } from 'zod'
import { ok } from '@/lib/api/v1/response'
import { dryRunPreview } from '@/lib/api/v1/dry-run'
import { registerEndpoint, dataEnvelope } from '@/lib/api/v1/registry'
import { withApiV1 } from '@/lib/api/v1/with-api-v1'
import { v1ErrorResponseFromCode } from '@/lib/api/v1/errors'
import {
commitVacationYearClose,
previewVacationYearClose,
} from '@/lib/salary/semesterberedning'
import { getVacationYearBasis } from '@/lib/salary/vacation-ledger'
import { getClosableYearStart } from '@/lib/salary/vacation-year'
const isoDate = z.string().regex(/^\d{4}-\d{2}-\d{2}$/, 'Expected YYYY-MM-DD date format')
const CloseBody = z.object({
/** Defaults to the most recently ENDED vacation year per the company's
* basis setting. */
vacation_year_start: isoDate.optional(),
/** Book the 2920/2940 drift adjustment (default true). False = roll the
* days but leave the SEK reconcile for a manual verifikat. */
book_adjustment: z.boolean().default(true),
})
const CloseResponse = z.object({
vacation_year_closure_id: z.string().uuid(),
adjustment_entry_id: z.string().uuid().nullable(),
report: z.unknown(),
})
registerEndpoint({
operation: 'salary.vacation-year-close',
method: 'POST',
path: '/api/v1/companies/:companyId/salary/vacation-year-close',
summary: 'Close a vacation year (semesterberedning + arsavslut).',
description:
'Rolls every active employee\'s vacation balances into the next year (only days above the 20-day must-take floor are saved; saved days older than 5 years become forced payouts) and reconciles the day-valued semesterlöneskuld against the booked 2920/2940, posting one adjustment verifikation when drift exceeds 1 kr. The frozen report is stored with the closure (BFL 7 kap).',
useWhen:
'Once per year after the vacation year ends (Jan for calendar basis, Apr for statutory). ALWAYS dry-run first and review the report: the close is not reversible via API.',
doNotUseFor:
'Mid-year balance corrections (fix the source: absence days, opening balances, or run corrections). Paying out expired days (create a semesterersattning line in the next salary run: the close only flags them).',
pitfalls: [
'dry_run=true returns the full review report with zero writes: treat it as mandatory before the live call.',
'409 VACATION_YEAR_ALREADY_CLOSED on replay: the closure row is the idempotency anchor.',
'423-style PERIOD_LOCKED when the adjustment date falls in a locked period: unlock or close without adjustment (book_adjustment=false) and post manually.',
'Untaken days at or below the 20-day floor are flagged in the report, NOT auto-saved (Semesterlagen 18 §).',
],
example: {
request: { book_adjustment: true },
response: {
data: {
vacation_year_closure_id: 'vyc_a1b2…',
adjustment_entry_id: 'je_c3d4…',
report: { vacation_year_start: '2025-01-01', rows: [], sek: { drift_2920: 8690.84 } },
},
meta: { request_id: 'req_…', api_version: '2026-05-12' },
},
},
scope: 'payroll:write',
risk: 'high',
idempotent: true,
reversible: false,
dryRunSupported: true,
request: { body: CloseBody },
response: { success: dataEnvelope(CloseResponse) },
})
export const POST = withApiV1<{ params: Promise<{ companyId: string }> }>(
'salary.vacation-year-close',
async (request, ctx) => {
let rawBody: unknown = {}
try {
const text = await request.text()
rawBody = text ? JSON.parse(text) : {}
} catch {
return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, {
requestId: ctx.requestId,
details: { field: 'body', message: 'Body is not valid JSON.' },
})
}
const parsed = CloseBody.safeParse(rawBody)
if (!parsed.success) {
return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, {
requestId: ctx.requestId,
details: {
issues: parsed.error.issues.map((i) => ({
field: i.path.join('.'),
message: i.message,
})),
},
})
}
let yearStart = parsed.data.vacation_year_start
if (!yearStart) {
const basis = await getVacationYearBasis(ctx.supabase, ctx.companyId!)
yearStart = getClosableYearStart(new Date().toISOString().slice(0, 10), basis)
}
if (ctx.dryRun) {
const preview = await previewVacationYearClose(ctx.supabase, ctx.companyId!, yearStart)
if (!preview.ok) {
return v1ErrorResponseFromCode(preview.code, ctx.log, {
requestId: ctx.requestId,
details: preview.details,
})
}
return dryRunPreview(
{ vacation_year_closure_id: null, adjustment_entry_id: null, report: preview.data },
{ requestId: ctx.requestId, log: ctx.log },
)
}
const result = await commitVacationYearClose(ctx.supabase, ctx.companyId!, ctx.userId, yearStart, {
bookAdjustment: parsed.data.book_adjustment,
})
if (!result.ok) {
return v1ErrorResponseFromCode(result.code, ctx.log, {
requestId: ctx.requestId,
details: result.details,
})
}
return ok(
{
vacation_year_closure_id: result.data.closure_id,
adjustment_entry_id: result.data.adjustment_entry_id,
report: result.data.report,
},
{ requestId: ctx.requestId },
)
},
{ requireIdempotencyKey: true },
)