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
@@ -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 },
)