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:
co-authored by
Claude Fable 5
parent
e7e3c35f9e
commit
b6332e9ff4
@@ -0,0 +1,405 @@
|
||||
/**
|
||||
* Tests for the v1 absence endpoints (payroll gap-closure 1.4).
|
||||
*
|
||||
* GET/PUT/DELETE /employees/{id}/absence. PUT is the first PUT route on v1:
|
||||
* the wrapper's REQUIRES_IDEMPOTENCY set was extended to include it, and the
|
||||
* test-key case below is the regression test for that hole (a test key on a
|
||||
* PUT must be forced into dry-run, never write through).
|
||||
*/
|
||||
|
||||
import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
beforeAll(() => {
|
||||
if (process.env.NODE_ENV !== 'test') {
|
||||
throw new Error(
|
||||
`absence route tests require NODE_ENV=test (got ${process.env.NODE_ENV ?? 'undefined'})`,
|
||||
)
|
||||
}
|
||||
process.env.NEXT_PUBLIC_SUPABASE_URL ||= 'http://localhost:54321'
|
||||
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY ||= 'test-anon-key'
|
||||
})
|
||||
|
||||
vi.mock('@/lib/auth/api-keys', async () => {
|
||||
const actual = await vi.importActual<typeof import('@/lib/auth/api-keys')>('@/lib/auth/api-keys')
|
||||
return {
|
||||
...actual,
|
||||
validateApiKey: vi.fn(),
|
||||
createServiceClientNoCookies: vi.fn(),
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@supabase/supabase-js', async () => {
|
||||
const actual = await vi.importActual<typeof import('@supabase/supabase-js')>('@supabase/supabase-js')
|
||||
return { ...actual, createClient: vi.fn().mockReturnValue({}) }
|
||||
})
|
||||
|
||||
import { validateApiKey, createServiceClientNoCookies } from '@/lib/auth/api-keys'
|
||||
import { GET as listAbsence, PUT as putAbsence, DELETE as deleteAbsence } from '../route'
|
||||
|
||||
const mockValidate = validateApiKey as ReturnType<typeof vi.fn>
|
||||
const mockServiceClient = createServiceClientNoCookies as ReturnType<typeof vi.fn>
|
||||
|
||||
interface TableResp {
|
||||
data?: unknown
|
||||
error?: unknown
|
||||
count?: number | null
|
||||
}
|
||||
|
||||
function makeFlexibleSupabase(byTable: Record<string, TableResp | TableResp[]>) {
|
||||
const queues = new Map<string, TableResp[]>()
|
||||
for (const [t, val] of Object.entries(byTable)) {
|
||||
queues.set(t, Array.isArray(val) ? [...val] : [val])
|
||||
}
|
||||
const tableCalls: string[] = []
|
||||
const buildChain = (table: string): unknown => {
|
||||
const handler: ProxyHandler<object> = {
|
||||
get(_target, prop) {
|
||||
if (prop === 'then') {
|
||||
return (resolve: (v: unknown) => void) => {
|
||||
const q = queues.get(table)
|
||||
const next = q && q.length > 1 ? q.shift()! : (q?.[0] ?? { data: null, error: null, count: null })
|
||||
resolve({ count: null, ...next })
|
||||
}
|
||||
}
|
||||
return (..._args: unknown[]) => buildChain(table)
|
||||
},
|
||||
}
|
||||
return new Proxy({}, handler)
|
||||
}
|
||||
return {
|
||||
tableCalls,
|
||||
from: vi.fn((table: string) => {
|
||||
tableCalls.push(table)
|
||||
return buildChain(table)
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
const COMPANY_ID = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa'
|
||||
const EMPLOYEE_ID = 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb'
|
||||
const USER_ID = 'user-1'
|
||||
|
||||
const SAMPLE_DAY = {
|
||||
id: 'eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeee',
|
||||
absence_date: '2026-03-03',
|
||||
absence_type: 'sick',
|
||||
hours: 8,
|
||||
notes: null,
|
||||
salary_run_employee_id: null,
|
||||
created_at: '2026-03-03T08:00:00Z',
|
||||
updated_at: '2026-03-03T08:00:00Z',
|
||||
}
|
||||
|
||||
function makeRequest(url: string, init?: RequestInit): Request {
|
||||
return new Request(url, {
|
||||
...init,
|
||||
headers: {
|
||||
Authorization: 'Bearer test-fixture-not-a-real-key',
|
||||
...(init?.headers ?? {}),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function absenceParams(companyId: string, id: string) {
|
||||
return { params: Promise.resolve({ companyId, id }) }
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockValidate.mockResolvedValue({
|
||||
userId: USER_ID,
|
||||
companyId: COMPANY_ID,
|
||||
apiKeyId: 'ak_1',
|
||||
apiKeyName: 'CI key',
|
||||
scopes: ['payroll:read', 'payroll:write'],
|
||||
mode: 'live',
|
||||
})
|
||||
})
|
||||
|
||||
describe('GET /api/v1/companies/:companyId/employees/:id/absence', () => {
|
||||
it('lists absence days with qualified ids', async () => {
|
||||
mockServiceClient.mockReturnValue(
|
||||
makeFlexibleSupabase({
|
||||
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
|
||||
employees: { data: { id: EMPLOYEE_ID }, error: null },
|
||||
salary_absence_days: { data: [SAMPLE_DAY], error: null },
|
||||
}),
|
||||
)
|
||||
|
||||
const res = await listAbsence(
|
||||
makeRequest(
|
||||
`https://x.test/api/v1/companies/${COMPANY_ID}/employees/${EMPLOYEE_ID}/absence?from=2026-03-01&to=2026-03-31`,
|
||||
),
|
||||
absenceParams(COMPANY_ID, EMPLOYEE_ID),
|
||||
)
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
const body = await res.json()
|
||||
expect(body.data).toHaveLength(1)
|
||||
expect(body.data[0].salary_absence_day_id).toBe(SAMPLE_DAY.id)
|
||||
expect(body.data[0].id).toBeUndefined()
|
||||
})
|
||||
|
||||
it('rejects a missing from/to with 400', async () => {
|
||||
mockServiceClient.mockReturnValue(
|
||||
makeFlexibleSupabase({
|
||||
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
|
||||
}),
|
||||
)
|
||||
const res = await listAbsence(
|
||||
makeRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/employees/${EMPLOYEE_ID}/absence`),
|
||||
absenceParams(COMPANY_ID, EMPLOYEE_ID),
|
||||
)
|
||||
expect(res.status).toBe(400)
|
||||
})
|
||||
|
||||
it('rejects a reversed range (from > to) with VALIDATION_ERROR', async () => {
|
||||
mockServiceClient.mockReturnValue(
|
||||
makeFlexibleSupabase({
|
||||
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
|
||||
}),
|
||||
)
|
||||
const res = await listAbsence(
|
||||
makeRequest(
|
||||
`https://x.test/api/v1/companies/${COMPANY_ID}/employees/${EMPLOYEE_ID}/absence?from=2026-03-31&to=2026-03-01`,
|
||||
),
|
||||
absenceParams(COMPANY_ID, EMPLOYEE_ID),
|
||||
)
|
||||
expect(res.status).toBe(400)
|
||||
const body = await res.json()
|
||||
expect(body.error.code).toBe('VALIDATION_ERROR')
|
||||
})
|
||||
|
||||
it('rejects a range beyond 92 days with ABSENCE_RANGE_TOO_LARGE', async () => {
|
||||
mockServiceClient.mockReturnValue(
|
||||
makeFlexibleSupabase({
|
||||
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
|
||||
}),
|
||||
)
|
||||
const res = await listAbsence(
|
||||
makeRequest(
|
||||
`https://x.test/api/v1/companies/${COMPANY_ID}/employees/${EMPLOYEE_ID}/absence?from=2026-01-01&to=2026-12-31`,
|
||||
),
|
||||
absenceParams(COMPANY_ID, EMPLOYEE_ID),
|
||||
)
|
||||
expect(res.status).toBe(400)
|
||||
const body = await res.json()
|
||||
expect(body.error.code).toBe('ABSENCE_RANGE_TOO_LARGE')
|
||||
})
|
||||
|
||||
it('returns 404 EMPLOYEE_NOT_FOUND for an unknown employee', async () => {
|
||||
mockServiceClient.mockReturnValue(
|
||||
makeFlexibleSupabase({
|
||||
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
|
||||
employees: { data: null, error: null },
|
||||
}),
|
||||
)
|
||||
const res = await listAbsence(
|
||||
makeRequest(
|
||||
`https://x.test/api/v1/companies/${COMPANY_ID}/employees/${EMPLOYEE_ID}/absence?from=2026-03-01&to=2026-03-31`,
|
||||
),
|
||||
absenceParams(COMPANY_ID, EMPLOYEE_ID),
|
||||
)
|
||||
expect(res.status).toBe(404)
|
||||
const body = await res.json()
|
||||
expect(body.error.code).toBe('EMPLOYEE_NOT_FOUND')
|
||||
})
|
||||
})
|
||||
|
||||
describe('PUT /api/v1/companies/:companyId/employees/:id/absence', () => {
|
||||
const validBody = { from: '2026-03-02', to: '2026-03-06', absence_type: 'sick' }
|
||||
|
||||
it('upserts the expanded range (happy path)', async () => {
|
||||
mockServiceClient.mockReturnValue(
|
||||
makeFlexibleSupabase({
|
||||
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
|
||||
employees: { data: { id: EMPLOYEE_ID }, error: null },
|
||||
salary_absence_days: [
|
||||
{ data: [SAMPLE_DAY, { ...SAMPLE_DAY, id: 'ffffffff-ffff-4fff-8fff-ffffffffffff', absence_date: '2026-03-04' }], error: null }, // bulk upsert
|
||||
],
|
||||
}),
|
||||
)
|
||||
|
||||
const res = await putAbsence(
|
||||
makeRequest(
|
||||
`https://x.test/api/v1/companies/${COMPANY_ID}/employees/${EMPLOYEE_ID}/absence`,
|
||||
{ method: 'PUT', body: JSON.stringify(validBody) },
|
||||
),
|
||||
absenceParams(COMPANY_ID, EMPLOYEE_ID),
|
||||
)
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
const body = await res.json()
|
||||
expect(body.data.count).toBe(2)
|
||||
expect(body.data.days[0].salary_absence_day_id).toBeTruthy()
|
||||
})
|
||||
|
||||
it('rejects from > to with 400', async () => {
|
||||
mockServiceClient.mockReturnValue(
|
||||
makeFlexibleSupabase({
|
||||
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
|
||||
}),
|
||||
)
|
||||
const res = await putAbsence(
|
||||
makeRequest(
|
||||
`https://x.test/api/v1/companies/${COMPANY_ID}/employees/${EMPLOYEE_ID}/absence`,
|
||||
{ method: 'PUT', body: JSON.stringify({ ...validBody, from: '2026-03-10' }) },
|
||||
),
|
||||
absenceParams(COMPANY_ID, EMPLOYEE_ID),
|
||||
)
|
||||
expect(res.status).toBe(400)
|
||||
})
|
||||
|
||||
it('maps the 24h trigger to 409 ABSENCE_HOURS_CONFLICT', async () => {
|
||||
mockServiceClient.mockReturnValue(
|
||||
makeFlexibleSupabase({
|
||||
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
|
||||
employees: { data: { id: EMPLOYEE_ID }, error: null },
|
||||
salary_absence_days: [
|
||||
{ data: null, error: { code: '23514', message: 'Total tid över 24h' } },
|
||||
],
|
||||
}),
|
||||
)
|
||||
|
||||
const res = await putAbsence(
|
||||
makeRequest(
|
||||
`https://x.test/api/v1/companies/${COMPANY_ID}/employees/${EMPLOYEE_ID}/absence`,
|
||||
{ method: 'PUT', body: JSON.stringify(validBody) },
|
||||
),
|
||||
absenceParams(COMPANY_ID, EMPLOYEE_ID),
|
||||
)
|
||||
|
||||
expect(res.status).toBe(409)
|
||||
const body = await res.json()
|
||||
expect(body.error.code).toBe('ABSENCE_HOURS_CONFLICT')
|
||||
})
|
||||
|
||||
it('returns a dry-run preview without writing', async () => {
|
||||
const supabaseMock = makeFlexibleSupabase({
|
||||
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
|
||||
employees: { data: { id: EMPLOYEE_ID }, error: null },
|
||||
})
|
||||
mockServiceClient.mockReturnValue(supabaseMock)
|
||||
|
||||
const res = await putAbsence(
|
||||
makeRequest(
|
||||
`https://x.test/api/v1/companies/${COMPANY_ID}/employees/${EMPLOYEE_ID}/absence?dry_run=true`,
|
||||
{ method: 'PUT', body: JSON.stringify(validBody) },
|
||||
),
|
||||
absenceParams(COMPANY_ID, EMPLOYEE_ID),
|
||||
)
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
expect(res.headers.get('X-Dry-Run')).toBe('true')
|
||||
const body = await res.json()
|
||||
expect(body.data.preview.count).toBe(5)
|
||||
expect(supabaseMock.tableCalls).not.toContain('salary_absence_days')
|
||||
})
|
||||
|
||||
it('forces TEST KEYS into dry-run on PUT (wrapper REQUIRES_IDEMPOTENCY regression)', async () => {
|
||||
// Before the wrapper hardening, PUT was missing from REQUIRES_IDEMPOTENCY:
|
||||
// a test key would have written through. This test locks the fix in.
|
||||
mockValidate.mockResolvedValue({
|
||||
userId: USER_ID,
|
||||
companyId: COMPANY_ID,
|
||||
apiKeyId: 'ak_test',
|
||||
apiKeyName: 'test key',
|
||||
scopes: ['payroll:read', 'payroll:write'],
|
||||
mode: 'test',
|
||||
})
|
||||
const supabaseMock = makeFlexibleSupabase({
|
||||
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
|
||||
employees: { data: { id: EMPLOYEE_ID }, error: null },
|
||||
})
|
||||
mockServiceClient.mockReturnValue(supabaseMock)
|
||||
|
||||
const res = await putAbsence(
|
||||
makeRequest(
|
||||
`https://x.test/api/v1/companies/${COMPANY_ID}/employees/${EMPLOYEE_ID}/absence`,
|
||||
{ method: 'PUT', body: JSON.stringify(validBody) },
|
||||
),
|
||||
absenceParams(COMPANY_ID, EMPLOYEE_ID),
|
||||
)
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
expect(res.headers.get('X-Dry-Run')).toBe('true')
|
||||
expect(res.headers.get('X-Gnubok-Mode')).toBe('test')
|
||||
// The write table was never touched.
|
||||
expect(supabaseMock.tableCalls).not.toContain('salary_absence_days')
|
||||
})
|
||||
|
||||
it('rejects keys without payroll:write scope', async () => {
|
||||
mockValidate.mockResolvedValue({
|
||||
userId: USER_ID,
|
||||
companyId: COMPANY_ID,
|
||||
apiKeyId: 'ak_1',
|
||||
apiKeyName: 'read-only',
|
||||
scopes: ['payroll:read'],
|
||||
mode: 'live',
|
||||
})
|
||||
mockServiceClient.mockReturnValue(makeFlexibleSupabase({}))
|
||||
|
||||
const res = await putAbsence(
|
||||
makeRequest(
|
||||
`https://x.test/api/v1/companies/${COMPANY_ID}/employees/${EMPLOYEE_ID}/absence`,
|
||||
{ method: 'PUT', body: JSON.stringify(validBody) },
|
||||
),
|
||||
absenceParams(COMPANY_ID, EMPLOYEE_ID),
|
||||
)
|
||||
expect(res.status).toBe(403)
|
||||
})
|
||||
|
||||
it('returns 401 without a bearer token', async () => {
|
||||
mockValidate.mockResolvedValue(null)
|
||||
mockServiceClient.mockReturnValue(makeFlexibleSupabase({}))
|
||||
|
||||
const res = await putAbsence(
|
||||
new Request(
|
||||
`https://x.test/api/v1/companies/${COMPANY_ID}/employees/${EMPLOYEE_ID}/absence`,
|
||||
{ method: 'PUT', body: JSON.stringify(validBody) },
|
||||
),
|
||||
absenceParams(COMPANY_ID, EMPLOYEE_ID),
|
||||
)
|
||||
expect(res.status).toBe(401)
|
||||
})
|
||||
})
|
||||
|
||||
describe('DELETE /api/v1/companies/:companyId/employees/:id/absence', () => {
|
||||
it('deletes the range and returns deleted_count', async () => {
|
||||
mockServiceClient.mockReturnValue(
|
||||
makeFlexibleSupabase({
|
||||
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
|
||||
employees: { data: { id: EMPLOYEE_ID }, error: null },
|
||||
salary_absence_days: { data: null, error: null, count: 3 },
|
||||
}),
|
||||
)
|
||||
|
||||
const res = await deleteAbsence(
|
||||
makeRequest(
|
||||
`https://x.test/api/v1/companies/${COMPANY_ID}/employees/${EMPLOYEE_ID}/absence?from=2026-03-01&to=2026-03-31&type=sick`,
|
||||
{ method: 'DELETE' },
|
||||
),
|
||||
absenceParams(COMPANY_ID, EMPLOYEE_ID),
|
||||
)
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
const body = await res.json()
|
||||
expect(body.data.deleted_count).toBe(3)
|
||||
})
|
||||
|
||||
it('requires from and to', async () => {
|
||||
mockServiceClient.mockReturnValue(
|
||||
makeFlexibleSupabase({
|
||||
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
|
||||
}),
|
||||
)
|
||||
const res = await deleteAbsence(
|
||||
makeRequest(
|
||||
`https://x.test/api/v1/companies/${COMPANY_ID}/employees/${EMPLOYEE_ID}/absence`,
|
||||
{ method: 'DELETE' },
|
||||
),
|
||||
absenceParams(COMPANY_ID, EMPLOYEE_ID),
|
||||
)
|
||||
expect(res.status).toBe(400)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,362 @@
|
||||
/**
|
||||
* /api/v1/companies/{companyId}/employees/{id}/absence
|
||||
*
|
||||
* GET : list absence days in a date range (max 92 days: the range IS the
|
||||
* pagination, no cursor).
|
||||
* PUT : range upsert. Expands [from, to] to per-day rows on the natural
|
||||
* key (employee, date, type). Truly idempotent: retries converge.
|
||||
* DELETE : range delete (optional type filter). Returns deleted_count.
|
||||
*
|
||||
* Storage is per-day (sjuklönelagen karens/day-14 boundaries and AGI 2025+
|
||||
* per-event Frånvarouppgift derive from day rows); the range payload is API
|
||||
* ergonomics only. Pre-cutover backfill is legal at any date: imported
|
||||
* history feeds the sick-segment lookback in the calculation engine.
|
||||
*/
|
||||
|
||||
import { z } from 'zod'
|
||||
import { ok } from '@/lib/api/v1/response'
|
||||
import { dryRunPreview } from '@/lib/api/v1/dry-run'
|
||||
import { registerEndpoint, dataEnvelope, listEnvelope } from '@/lib/api/v1/registry'
|
||||
import { withApiV1 } from '@/lib/api/v1/with-api-v1'
|
||||
import { v1ErrorResponseFromCode } from '@/lib/api/v1/errors'
|
||||
import { AbsenceTypeSchema } from '@/lib/api/schemas'
|
||||
import {
|
||||
ABSENCE_RANGE_MAX_DAYS,
|
||||
deleteAbsenceRange,
|
||||
listAbsenceDays,
|
||||
upsertAbsenceRange,
|
||||
} from '@/lib/salary/absence'
|
||||
|
||||
const isoDate = z.string().regex(/^\d{4}-\d{2}-\d{2}$/, 'Expected YYYY-MM-DD date format')
|
||||
|
||||
const AbsenceDay = z.object({
|
||||
salary_absence_day_id: z.string().uuid(),
|
||||
absence_date: z.string(),
|
||||
absence_type: AbsenceTypeSchema,
|
||||
hours: z.number(),
|
||||
notes: z.string().nullable(),
|
||||
salary_run_employee_id: z.string().uuid().nullable(),
|
||||
created_at: z.string(),
|
||||
updated_at: z.string(),
|
||||
})
|
||||
|
||||
const RangeQuery = z
|
||||
.object({
|
||||
from: isoDate,
|
||||
to: isoDate,
|
||||
type: AbsenceTypeSchema.optional(),
|
||||
})
|
||||
.refine((v) => v.from <= v.to, { message: 'from must be <= to', path: ['from'] })
|
||||
|
||||
registerEndpoint({
|
||||
operation: 'employees.absence.list',
|
||||
method: 'GET',
|
||||
path: '/api/v1/companies/:companyId/employees/:id/absence',
|
||||
summary: 'List absence days for an employee in a date range.',
|
||||
description:
|
||||
'Returns per-day absence rows (sick, vab, parental, ...) between ?from and ?to (inclusive, max 92 days). No cursor pagination: the bounded range is the page. Optional ?type filter.',
|
||||
useWhen:
|
||||
'You need an employee\'s registered absence: to reconcile with an external time-tracking system, to verify what the salary engine will derive, or to display a calendar.',
|
||||
doNotUseFor:
|
||||
'The derived pay impact (karensavdrag, sjuklön lines): that lives on the payslip detail after :calculate. Worked hours for hourly staff: separate register, not on v1 yet.',
|
||||
pitfalls: [
|
||||
'Ranges over 92 days return 400 ABSENCE_RANGE_TOO_LARGE: iterate quarters instead.',
|
||||
'A day can carry multiple rows with different absence_type values (e.g. half-day sick + half-day vab).',
|
||||
'Rows may reference the salary run that consumed them via salary_run_employee_id.',
|
||||
],
|
||||
example: {
|
||||
response: {
|
||||
data: [
|
||||
{
|
||||
salary_absence_day_id: 'abs_91d2…',
|
||||
absence_date: '2026-03-03',
|
||||
absence_type: 'sick',
|
||||
hours: 8,
|
||||
notes: null,
|
||||
},
|
||||
],
|
||||
meta: { request_id: 'req_…', api_version: '2026-05-12' },
|
||||
},
|
||||
},
|
||||
scope: 'payroll:read',
|
||||
risk: 'low',
|
||||
idempotent: true,
|
||||
reversible: false,
|
||||
dryRunSupported: false,
|
||||
response: { success: listEnvelope(AbsenceDay) },
|
||||
})
|
||||
|
||||
export const GET = withApiV1<{ params: Promise<{ companyId: string; id: string }> }>(
|
||||
'employees.absence.list',
|
||||
async (request, ctx, params) => {
|
||||
const { id } = await params.params
|
||||
const idParse = z.string().uuid().safeParse(id)
|
||||
if (!idParse.success) {
|
||||
return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, {
|
||||
requestId: ctx.requestId,
|
||||
details: { field: 'id', message: 'Employee id must be a UUID.' },
|
||||
})
|
||||
}
|
||||
|
||||
const url = new URL(request.url)
|
||||
const parsed = RangeQuery.safeParse({
|
||||
from: url.searchParams.get('from') ?? undefined,
|
||||
to: url.searchParams.get('to') ?? undefined,
|
||||
type: url.searchParams.get('type') ?? undefined,
|
||||
})
|
||||
if (!parsed.success) {
|
||||
return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, {
|
||||
requestId: ctx.requestId,
|
||||
details: {
|
||||
issues: parsed.error.issues.map((i) => ({
|
||||
field: i.path.join('.'),
|
||||
message: i.message,
|
||||
})),
|
||||
},
|
||||
})
|
||||
}
|
||||
const { from, to, type } = parsed.data
|
||||
|
||||
// Same cap as writes: the bounded range is the pagination contract.
|
||||
const spanMs = Date.parse(`${to}T00:00:00Z`) - Date.parse(`${from}T00:00:00Z`)
|
||||
if (spanMs < 0 || spanMs / 86_400_000 + 1 > ABSENCE_RANGE_MAX_DAYS) {
|
||||
return v1ErrorResponseFromCode('ABSENCE_RANGE_TOO_LARGE', ctx.log, {
|
||||
requestId: ctx.requestId,
|
||||
details: { from, to, max_days: ABSENCE_RANGE_MAX_DAYS },
|
||||
})
|
||||
}
|
||||
|
||||
const result = await listAbsenceDays(ctx.supabase, {
|
||||
companyId: ctx.companyId!,
|
||||
employeeId: idParse.data,
|
||||
from,
|
||||
to,
|
||||
absenceType: type,
|
||||
})
|
||||
|
||||
if (!result.ok) {
|
||||
return v1ErrorResponseFromCode(result.code, ctx.log, {
|
||||
requestId: ctx.requestId,
|
||||
details: result.details,
|
||||
})
|
||||
}
|
||||
|
||||
return ok(
|
||||
result.data.map(({ id: rowId, ...rest }) => ({ salary_absence_day_id: rowId, ...rest })),
|
||||
{ requestId: ctx.requestId },
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────
|
||||
// PUT: range upsert
|
||||
// ──────────────────────────────────────────────────────────────────
|
||||
|
||||
const UpsertRangeBody = z
|
||||
.object({
|
||||
from: isoDate,
|
||||
to: isoDate,
|
||||
absence_type: AbsenceTypeSchema,
|
||||
hours_per_day: z.number().positive().max(24).default(8),
|
||||
notes: z.string().max(2000).optional(),
|
||||
include_weekends: z.boolean().default(false),
|
||||
})
|
||||
.refine((v) => v.from <= v.to, { message: 'from must be <= to', path: ['from'] })
|
||||
|
||||
const UpsertRangeResponse = z.object({
|
||||
count: z.number().int(),
|
||||
days: z.array(AbsenceDay.partial({ salary_absence_day_id: true, notes: true, salary_run_employee_id: true, created_at: true, updated_at: true })),
|
||||
})
|
||||
|
||||
registerEndpoint({
|
||||
operation: 'employees.absence.upsert',
|
||||
method: 'PUT',
|
||||
path: '/api/v1/companies/:companyId/employees/:id/absence',
|
||||
summary: 'Register absence for an employee over a date range.',
|
||||
description:
|
||||
'Expands [from, to] (max 92 days) to per-day rows and upserts them on the natural key (employee, date, type). Weekends are skipped unless include_weekends=true. Single day = from == to. Idempotent by construction: replaying the same PUT converges on the same rows.',
|
||||
useWhen:
|
||||
'"Anna was sick 3-7 March": one call registers the whole event. Also for pre-cutover history backfill when migrating from another payroll system (any past date is legal; imported sick days feed the karensavdrag lookback).',
|
||||
doNotUseFor:
|
||||
'Vacation day REQUESTS/approval workflows (out of scope). Editing hours on one existing day inside a range: PUT the single day (from == to) with the new hours.',
|
||||
pitfalls: [
|
||||
'Weekends are skipped by default: pass include_weekends=true for schedules that span them.',
|
||||
'Upsert REPLACES the (date, type) rows in the range: hours/notes are overwritten, not merged.',
|
||||
'A day whose combined absence + worked hours exceed 24h returns 409 ABSENCE_HOURS_CONFLICT and the whole range is rejected (atomic).',
|
||||
'Registering absence does not recompute an open salary run: call POST /salary-runs/{id}/calculate afterwards.',
|
||||
],
|
||||
example: {
|
||||
request: { from: '2026-03-03', to: '2026-03-07', absence_type: 'sick' },
|
||||
response: {
|
||||
data: { count: 5, days: [{ absence_date: '2026-03-03', absence_type: 'sick', hours: 8 }] },
|
||||
meta: { request_id: 'req_…', api_version: '2026-05-12' },
|
||||
},
|
||||
},
|
||||
scope: 'payroll:write',
|
||||
risk: 'low',
|
||||
idempotent: true,
|
||||
reversible: true,
|
||||
dryRunSupported: true,
|
||||
request: { body: UpsertRangeBody },
|
||||
response: { success: dataEnvelope(UpsertRangeResponse) },
|
||||
})
|
||||
|
||||
export const PUT = withApiV1<{ params: Promise<{ companyId: string; id: string }> }>(
|
||||
'employees.absence.upsert',
|
||||
async (request, ctx, params) => {
|
||||
const { id } = await params.params
|
||||
const idParse = z.string().uuid().safeParse(id)
|
||||
if (!idParse.success) {
|
||||
return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, {
|
||||
requestId: ctx.requestId,
|
||||
details: { field: 'id', message: 'Employee id must be a UUID.' },
|
||||
})
|
||||
}
|
||||
|
||||
let rawBody: unknown
|
||||
try {
|
||||
rawBody = await request.json()
|
||||
} catch {
|
||||
return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, {
|
||||
requestId: ctx.requestId,
|
||||
details: { field: 'body', message: 'Body is not valid JSON.' },
|
||||
})
|
||||
}
|
||||
|
||||
const parsed = UpsertRangeBody.safeParse(rawBody)
|
||||
if (!parsed.success) {
|
||||
return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, {
|
||||
requestId: ctx.requestId,
|
||||
details: {
|
||||
issues: parsed.error.issues.map((i) => ({
|
||||
field: i.path.join('.'),
|
||||
message: i.message,
|
||||
})),
|
||||
},
|
||||
})
|
||||
}
|
||||
const body = parsed.data
|
||||
|
||||
const result = await upsertAbsenceRange(ctx.supabase, {
|
||||
companyId: ctx.companyId!,
|
||||
employeeId: idParse.data,
|
||||
from: body.from,
|
||||
to: body.to,
|
||||
absenceType: body.absence_type,
|
||||
hoursPerDay: body.hours_per_day,
|
||||
notes: body.notes ?? null,
|
||||
includeWeekends: body.include_weekends,
|
||||
dryRun: ctx.dryRun,
|
||||
})
|
||||
|
||||
if (!result.ok) {
|
||||
return v1ErrorResponseFromCode(result.code, ctx.log, {
|
||||
requestId: ctx.requestId,
|
||||
details: result.details,
|
||||
})
|
||||
}
|
||||
|
||||
const payload = {
|
||||
count: result.data.count,
|
||||
days: result.data.days.map((d) => {
|
||||
const { id: rowId, ...rest } = d as { id?: string } & Record<string, unknown>
|
||||
return rowId ? { salary_absence_day_id: rowId, ...rest } : rest
|
||||
}),
|
||||
}
|
||||
|
||||
if (ctx.dryRun) {
|
||||
return dryRunPreview(payload, { requestId: ctx.requestId, log: ctx.log })
|
||||
}
|
||||
return ok(payload, { requestId: ctx.requestId })
|
||||
},
|
||||
{ requireIdempotencyKey: false },
|
||||
)
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────
|
||||
// DELETE: range delete
|
||||
// ──────────────────────────────────────────────────────────────────
|
||||
|
||||
const DeleteRangeResponse = z.object({ deleted_count: z.number().int() })
|
||||
|
||||
registerEndpoint({
|
||||
operation: 'employees.absence.delete',
|
||||
method: 'DELETE',
|
||||
path: '/api/v1/companies/:companyId/employees/:id/absence',
|
||||
summary: 'Delete absence days for an employee in a date range.',
|
||||
description:
|
||||
'Deletes per-day absence rows between ?from and ?to (inclusive), optionally filtered by ?type. Returns deleted_count (200, not 204) so callers can verify how many rows went.',
|
||||
useWhen:
|
||||
'An absence event was registered by mistake or ended early: "Anna came back Thursday, delete Thu-Fri sick days".',
|
||||
doNotUseFor:
|
||||
'Correcting hours on a day: PUT the day again instead. Rows already consumed by a BOOKED run: deleting them does not un-book the run; use the run correction flow.',
|
||||
pitfalls: [
|
||||
'Without ?type, ALL absence types in the range are deleted.',
|
||||
'deleted_count: 0 with a 200 means nothing matched: not an error.',
|
||||
],
|
||||
example: {
|
||||
response: {
|
||||
data: { deleted_count: 2 },
|
||||
meta: { request_id: 'req_…', api_version: '2026-05-12' },
|
||||
},
|
||||
},
|
||||
scope: 'payroll:write',
|
||||
risk: 'low',
|
||||
idempotent: true,
|
||||
reversible: false,
|
||||
dryRunSupported: true,
|
||||
response: { success: dataEnvelope(DeleteRangeResponse) },
|
||||
})
|
||||
|
||||
export const DELETE = withApiV1<{ params: Promise<{ companyId: string; id: string }> }>(
|
||||
'employees.absence.delete',
|
||||
async (request, ctx, params) => {
|
||||
const { id } = await params.params
|
||||
const idParse = z.string().uuid().safeParse(id)
|
||||
if (!idParse.success) {
|
||||
return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, {
|
||||
requestId: ctx.requestId,
|
||||
details: { field: 'id', message: 'Employee id must be a UUID.' },
|
||||
})
|
||||
}
|
||||
|
||||
const url = new URL(request.url)
|
||||
const parsed = RangeQuery.safeParse({
|
||||
from: url.searchParams.get('from') ?? undefined,
|
||||
to: url.searchParams.get('to') ?? undefined,
|
||||
type: url.searchParams.get('type') ?? undefined,
|
||||
})
|
||||
if (!parsed.success) {
|
||||
return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, {
|
||||
requestId: ctx.requestId,
|
||||
details: {
|
||||
issues: parsed.error.issues.map((i) => ({
|
||||
field: i.path.join('.'),
|
||||
message: i.message,
|
||||
})),
|
||||
},
|
||||
})
|
||||
}
|
||||
const { from, to, type } = parsed.data
|
||||
|
||||
const result = await deleteAbsenceRange(ctx.supabase, {
|
||||
companyId: ctx.companyId!,
|
||||
employeeId: idParse.data,
|
||||
from,
|
||||
to,
|
||||
absenceType: type,
|
||||
dryRun: ctx.dryRun,
|
||||
})
|
||||
|
||||
if (!result.ok) {
|
||||
return v1ErrorResponseFromCode(result.code, ctx.log, {
|
||||
requestId: ctx.requestId,
|
||||
details: result.details,
|
||||
})
|
||||
}
|
||||
|
||||
if (ctx.dryRun) {
|
||||
return dryRunPreview(result.data, { requestId: ctx.requestId, log: ctx.log })
|
||||
}
|
||||
return ok(result.data, { requestId: ctx.requestId })
|
||||
},
|
||||
)
|
||||
+518
@@ -0,0 +1,518 @@
|
||||
/**
|
||||
* Tests for the v1 opening-balances endpoints (payroll gap-closure 2.3):
|
||||
* GET/PUT /employees/{id}/opening-balances + bulk PUT /employees/opening-balances.
|
||||
*/
|
||||
|
||||
import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
beforeAll(() => {
|
||||
if (process.env.NODE_ENV !== 'test') {
|
||||
throw new Error(
|
||||
`opening-balances route tests require NODE_ENV=test (got ${process.env.NODE_ENV ?? 'undefined'})`,
|
||||
)
|
||||
}
|
||||
process.env.NEXT_PUBLIC_SUPABASE_URL ||= 'http://localhost:54321'
|
||||
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY ||= 'test-anon-key'
|
||||
})
|
||||
|
||||
vi.mock('@/lib/auth/api-keys', async () => {
|
||||
const actual = await vi.importActual<typeof import('@/lib/auth/api-keys')>('@/lib/auth/api-keys')
|
||||
return {
|
||||
...actual,
|
||||
validateApiKey: vi.fn(),
|
||||
createServiceClientNoCookies: vi.fn(),
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@supabase/supabase-js', async () => {
|
||||
const actual = await vi.importActual<typeof import('@supabase/supabase-js')>('@supabase/supabase-js')
|
||||
return { ...actual, createClient: vi.fn().mockReturnValue({}) }
|
||||
})
|
||||
|
||||
import { validateApiKey, createServiceClientNoCookies } from '@/lib/auth/api-keys'
|
||||
import { GET as getBalances, PUT as putBalances } from '../route'
|
||||
import { PUT as putBulk } from '../../../opening-balances/route'
|
||||
|
||||
const mockValidate = validateApiKey as ReturnType<typeof vi.fn>
|
||||
const mockServiceClient = createServiceClientNoCookies as ReturnType<typeof vi.fn>
|
||||
|
||||
interface TableResp {
|
||||
data?: unknown
|
||||
error?: unknown
|
||||
}
|
||||
|
||||
function makeFlexibleSupabase(byTable: Record<string, TableResp | TableResp[]>) {
|
||||
const queues = new Map<string, TableResp[]>()
|
||||
for (const [t, val] of Object.entries(byTable)) {
|
||||
queues.set(t, Array.isArray(val) ? [...val] : [val])
|
||||
}
|
||||
const tableCalls: string[] = []
|
||||
const buildChain = (table: string): unknown => {
|
||||
const handler: ProxyHandler<object> = {
|
||||
get(_target, prop) {
|
||||
if (prop === 'then') {
|
||||
return (resolve: (v: unknown) => void) => {
|
||||
const q = queues.get(table)
|
||||
const next = q && q.length > 1 ? q.shift()! : (q?.[0] ?? { data: null, error: null })
|
||||
resolve(next)
|
||||
}
|
||||
}
|
||||
return (..._args: unknown[]) => buildChain(table)
|
||||
},
|
||||
}
|
||||
return new Proxy({}, handler)
|
||||
}
|
||||
return {
|
||||
tableCalls,
|
||||
from: vi.fn((table: string) => {
|
||||
tableCalls.push(table)
|
||||
return buildChain(table)
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
const COMPANY_ID = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa'
|
||||
const EMPLOYEE_ID = 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb'
|
||||
const ROW_ID = 'cccccccc-cccc-4ccc-8ccc-cccccccccccc'
|
||||
const RUN_ID = 'dddddddd-dddd-4ddd-8ddd-dddddddddddd'
|
||||
const USER_ID = 'user-1'
|
||||
|
||||
const CURRENT_YEAR = new Date().getFullYear()
|
||||
const CUTOVER_DATE = `${CURRENT_YEAR}-07-01`
|
||||
|
||||
const SAMPLE_ROW = {
|
||||
id: ROW_ID,
|
||||
employee_id: EMPLOYEE_ID,
|
||||
cutover_date: CUTOVER_DATE,
|
||||
ytd_gross: 210000,
|
||||
ytd_tax: 48000,
|
||||
ytd_net: 162000,
|
||||
vacation_paid_days_remaining: 12.5,
|
||||
vacation_saved_days_by_year: { [`${CURRENT_YEAR - 1}`]: 5 },
|
||||
opening_semester_liability: 42000,
|
||||
opening_semester_liability_avgifter: 13196.4,
|
||||
karens_periods_adjustment: 1,
|
||||
created_at: '2026-07-01T08:00:00Z',
|
||||
updated_at: '2026-07-01T08:00:00Z',
|
||||
}
|
||||
|
||||
const VALID_BODY = {
|
||||
cutover_date: CUTOVER_DATE,
|
||||
ytd_gross: 210000,
|
||||
ytd_tax: 48000,
|
||||
ytd_net: 162000,
|
||||
vacation_paid_days_remaining: 12.5,
|
||||
vacation_saved_days_by_year: { [`${CURRENT_YEAR - 1}`]: 5 },
|
||||
opening_semester_liability: 42000,
|
||||
opening_semester_liability_avgifter: 13196.4,
|
||||
karens_periods_adjustment: 1,
|
||||
}
|
||||
|
||||
function makeRequest(url: string, init?: RequestInit): Request {
|
||||
return new Request(url, {
|
||||
...init,
|
||||
headers: {
|
||||
Authorization: 'Bearer test-fixture-not-a-real-key',
|
||||
...(init?.headers ?? {}),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function detailParams(companyId: string, id: string) {
|
||||
return { params: Promise.resolve({ companyId, id }) }
|
||||
}
|
||||
|
||||
function companyParams(companyId: string) {
|
||||
return { params: Promise.resolve({ companyId }) }
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockValidate.mockResolvedValue({
|
||||
userId: USER_ID,
|
||||
companyId: COMPANY_ID,
|
||||
apiKeyId: 'ak_1',
|
||||
apiKeyName: 'CI key',
|
||||
scopes: ['payroll:read', 'payroll:write'],
|
||||
mode: 'live',
|
||||
})
|
||||
})
|
||||
|
||||
describe('GET /employees/:id/opening-balances', () => {
|
||||
it('returns the row with lock state (happy path, unlocked)', async () => {
|
||||
mockServiceClient.mockReturnValue(
|
||||
makeFlexibleSupabase({
|
||||
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
|
||||
employees: { data: { id: EMPLOYEE_ID }, error: null },
|
||||
employee_opening_balances: { data: SAMPLE_ROW, error: null },
|
||||
salary_run_employees: { data: [], error: null },
|
||||
}),
|
||||
)
|
||||
|
||||
const res = await getBalances(
|
||||
makeRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/employees/${EMPLOYEE_ID}/opening-balances`),
|
||||
detailParams(COMPANY_ID, EMPLOYEE_ID),
|
||||
)
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
const body = await res.json()
|
||||
expect(body.data.employee_opening_balances_id).toBe(ROW_ID)
|
||||
expect(body.data.ytd_gross).toBe(210000)
|
||||
expect(body.data.locked).toBe(false)
|
||||
expect(body.data.locked_by_run_id).toBeNull()
|
||||
expect(body.data.id).toBeUndefined()
|
||||
})
|
||||
|
||||
it('reports locked with the blocking run id', async () => {
|
||||
mockServiceClient.mockReturnValue(
|
||||
makeFlexibleSupabase({
|
||||
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
|
||||
employees: { data: { id: EMPLOYEE_ID }, error: null },
|
||||
employee_opening_balances: { data: SAMPLE_ROW, error: null },
|
||||
salary_run_employees: {
|
||||
data: [{ employee_id: EMPLOYEE_ID, salary_run: { id: RUN_ID, status: 'booked' } }],
|
||||
error: null,
|
||||
},
|
||||
}),
|
||||
)
|
||||
|
||||
const res = await getBalances(
|
||||
makeRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/employees/${EMPLOYEE_ID}/opening-balances`),
|
||||
detailParams(COMPANY_ID, EMPLOYEE_ID),
|
||||
)
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
const body = await res.json()
|
||||
expect(body.data.locked).toBe(true)
|
||||
expect(body.data.locked_by_run_id).toBe(RUN_ID)
|
||||
})
|
||||
|
||||
it('returns 404 when no balances are set', async () => {
|
||||
mockServiceClient.mockReturnValue(
|
||||
makeFlexibleSupabase({
|
||||
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
|
||||
employees: { data: { id: EMPLOYEE_ID }, error: null },
|
||||
employee_opening_balances: { data: null, error: null },
|
||||
}),
|
||||
)
|
||||
const res = await getBalances(
|
||||
makeRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/employees/${EMPLOYEE_ID}/opening-balances`),
|
||||
detailParams(COMPANY_ID, EMPLOYEE_ID),
|
||||
)
|
||||
expect(res.status).toBe(404)
|
||||
})
|
||||
|
||||
it('returns 404 EMPLOYEE_NOT_FOUND for an unknown employee', async () => {
|
||||
mockServiceClient.mockReturnValue(
|
||||
makeFlexibleSupabase({
|
||||
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
|
||||
employees: { data: null, error: null },
|
||||
}),
|
||||
)
|
||||
const res = await getBalances(
|
||||
makeRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/employees/${EMPLOYEE_ID}/opening-balances`),
|
||||
detailParams(COMPANY_ID, EMPLOYEE_ID),
|
||||
)
|
||||
expect(res.status).toBe(404)
|
||||
const body = await res.json()
|
||||
expect(body.error.code).toBe('EMPLOYEE_NOT_FOUND')
|
||||
})
|
||||
})
|
||||
|
||||
describe('PUT /employees/:id/opening-balances', () => {
|
||||
it('upserts and returns the row (happy path)', async () => {
|
||||
mockServiceClient.mockReturnValue(
|
||||
makeFlexibleSupabase({
|
||||
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
|
||||
employees: {
|
||||
data: [{ id: EMPLOYEE_ID, employment_start: '2024-01-15', is_active: true }],
|
||||
error: null,
|
||||
},
|
||||
salary_run_employees: { data: [], error: null },
|
||||
employee_opening_balances: { data: [SAMPLE_ROW], error: null },
|
||||
}),
|
||||
)
|
||||
|
||||
const res = await putBalances(
|
||||
makeRequest(
|
||||
`https://x.test/api/v1/companies/${COMPANY_ID}/employees/${EMPLOYEE_ID}/opening-balances`,
|
||||
{ method: 'PUT', body: JSON.stringify(VALID_BODY) },
|
||||
),
|
||||
detailParams(COMPANY_ID, EMPLOYEE_ID),
|
||||
)
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
const body = await res.json()
|
||||
expect(body.data.employee_opening_balances_id).toBe(ROW_ID)
|
||||
expect(body.data.locked).toBe(false)
|
||||
})
|
||||
|
||||
it('returns 409 OPENING_BALANCES_LOCKED when a booked run exists', async () => {
|
||||
mockServiceClient.mockReturnValue(
|
||||
makeFlexibleSupabase({
|
||||
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
|
||||
employees: {
|
||||
data: [{ id: EMPLOYEE_ID, employment_start: '2024-01-15', is_active: true }],
|
||||
error: null,
|
||||
},
|
||||
salary_run_employees: {
|
||||
data: [{ employee_id: EMPLOYEE_ID, salary_run: { id: RUN_ID, status: 'booked' } }],
|
||||
error: null,
|
||||
},
|
||||
}),
|
||||
)
|
||||
|
||||
const res = await putBalances(
|
||||
makeRequest(
|
||||
`https://x.test/api/v1/companies/${COMPANY_ID}/employees/${EMPLOYEE_ID}/opening-balances`,
|
||||
{ method: 'PUT', body: JSON.stringify(VALID_BODY) },
|
||||
),
|
||||
detailParams(COMPANY_ID, EMPLOYEE_ID),
|
||||
)
|
||||
|
||||
expect(res.status).toBe(409)
|
||||
const body = await res.json()
|
||||
expect(body.error.code).toBe('OPENING_BALANCES_LOCKED')
|
||||
})
|
||||
|
||||
it('rejects a cutover_date that is not the first of a month', async () => {
|
||||
mockServiceClient.mockReturnValue(
|
||||
makeFlexibleSupabase({
|
||||
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
|
||||
}),
|
||||
)
|
||||
const res = await putBalances(
|
||||
makeRequest(
|
||||
`https://x.test/api/v1/companies/${COMPANY_ID}/employees/${EMPLOYEE_ID}/opening-balances`,
|
||||
{ method: 'PUT', body: JSON.stringify({ ...VALID_BODY, cutover_date: `${CURRENT_YEAR}-07-15` }) },
|
||||
),
|
||||
detailParams(COMPANY_ID, EMPLOYEE_ID),
|
||||
)
|
||||
expect(res.status).toBe(400)
|
||||
})
|
||||
|
||||
it('rejects ytd_tax above ytd_gross', async () => {
|
||||
mockServiceClient.mockReturnValue(
|
||||
makeFlexibleSupabase({
|
||||
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
|
||||
}),
|
||||
)
|
||||
const res = await putBalances(
|
||||
makeRequest(
|
||||
`https://x.test/api/v1/companies/${COMPANY_ID}/employees/${EMPLOYEE_ID}/opening-balances`,
|
||||
{ method: 'PUT', body: JSON.stringify({ ...VALID_BODY, ytd_gross: 1000, ytd_tax: 2000 }) },
|
||||
),
|
||||
detailParams(COMPANY_ID, EMPLOYEE_ID),
|
||||
)
|
||||
expect(res.status).toBe(400)
|
||||
})
|
||||
|
||||
it('rejects saved days with an origin year outside the 5-year window', async () => {
|
||||
mockServiceClient.mockReturnValue(
|
||||
makeFlexibleSupabase({
|
||||
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
|
||||
}),
|
||||
)
|
||||
const res = await putBalances(
|
||||
makeRequest(
|
||||
`https://x.test/api/v1/companies/${COMPANY_ID}/employees/${EMPLOYEE_ID}/opening-balances`,
|
||||
{
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({
|
||||
...VALID_BODY,
|
||||
vacation_saved_days_by_year: { [`${CURRENT_YEAR - 7}`]: 3 },
|
||||
}),
|
||||
},
|
||||
),
|
||||
detailParams(COMPANY_ID, EMPLOYEE_ID),
|
||||
)
|
||||
expect(res.status).toBe(400)
|
||||
})
|
||||
|
||||
it('dry-run validates without writing', async () => {
|
||||
const supabaseMock = makeFlexibleSupabase({
|
||||
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
|
||||
employees: {
|
||||
data: [{ id: EMPLOYEE_ID, employment_start: '2024-01-15', is_active: true }],
|
||||
error: null,
|
||||
},
|
||||
salary_run_employees: { data: [], error: null },
|
||||
})
|
||||
mockServiceClient.mockReturnValue(supabaseMock)
|
||||
|
||||
const res = await putBalances(
|
||||
makeRequest(
|
||||
`https://x.test/api/v1/companies/${COMPANY_ID}/employees/${EMPLOYEE_ID}/opening-balances?dry_run=true`,
|
||||
{ method: 'PUT', body: JSON.stringify(VALID_BODY) },
|
||||
),
|
||||
detailParams(COMPANY_ID, EMPLOYEE_ID),
|
||||
)
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
expect(res.headers.get('X-Dry-Run')).toBe('true')
|
||||
expect(supabaseMock.tableCalls).not.toContain('employee_opening_balances')
|
||||
})
|
||||
|
||||
it('forces test keys into dry-run on this PUT too', async () => {
|
||||
mockValidate.mockResolvedValue({
|
||||
userId: USER_ID,
|
||||
companyId: COMPANY_ID,
|
||||
apiKeyId: 'ak_test',
|
||||
apiKeyName: 'test key',
|
||||
scopes: ['payroll:write'],
|
||||
mode: 'test',
|
||||
})
|
||||
const supabaseMock = makeFlexibleSupabase({
|
||||
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
|
||||
employees: {
|
||||
data: [{ id: EMPLOYEE_ID, employment_start: '2024-01-15', is_active: true }],
|
||||
error: null,
|
||||
},
|
||||
salary_run_employees: { data: [], error: null },
|
||||
})
|
||||
mockServiceClient.mockReturnValue(supabaseMock)
|
||||
|
||||
const res = await putBalances(
|
||||
makeRequest(
|
||||
`https://x.test/api/v1/companies/${COMPANY_ID}/employees/${EMPLOYEE_ID}/opening-balances`,
|
||||
{ method: 'PUT', body: JSON.stringify(VALID_BODY) },
|
||||
),
|
||||
detailParams(COMPANY_ID, EMPLOYEE_ID),
|
||||
)
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
expect(res.headers.get('X-Dry-Run')).toBe('true')
|
||||
expect(supabaseMock.tableCalls).not.toContain('employee_opening_balances')
|
||||
})
|
||||
})
|
||||
|
||||
describe('PUT /employees/opening-balances (bulk)', () => {
|
||||
it('is atomic: one bad item fails everything with a per-item error list', async () => {
|
||||
const supabaseMock = makeFlexibleSupabase({
|
||||
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
|
||||
// Only the first employee exists.
|
||||
employees: {
|
||||
data: [{ id: EMPLOYEE_ID, employment_start: '2024-01-15', is_active: true }],
|
||||
error: null,
|
||||
},
|
||||
salary_run_employees: { data: [], error: null },
|
||||
})
|
||||
mockServiceClient.mockReturnValue(supabaseMock)
|
||||
|
||||
const otherId = '99999999-9999-4999-8999-999999999999'
|
||||
const res = await putBulk(
|
||||
makeRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/employees/opening-balances`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({
|
||||
items: [
|
||||
{ employee_id: EMPLOYEE_ID, ...VALID_BODY },
|
||||
{ employee_id: otherId, ...VALID_BODY },
|
||||
],
|
||||
}),
|
||||
}),
|
||||
companyParams(COMPANY_ID),
|
||||
)
|
||||
|
||||
expect(res.status).toBe(400)
|
||||
const body = await res.json()
|
||||
const itemErrors = body.error.details.item_errors as Array<{ index: number; code: string }>
|
||||
expect(itemErrors).toHaveLength(1)
|
||||
expect(itemErrors[0].index).toBe(1)
|
||||
expect(itemErrors[0].code).toBe('EMPLOYEE_NOT_FOUND')
|
||||
// Zero writes happened.
|
||||
expect(supabaseMock.tableCalls).not.toContain('employee_opening_balances')
|
||||
})
|
||||
|
||||
it('upserts all items in one call (happy path)', async () => {
|
||||
const secondId = '99999999-9999-4999-8999-999999999999'
|
||||
mockServiceClient.mockReturnValue(
|
||||
makeFlexibleSupabase({
|
||||
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
|
||||
employees: {
|
||||
data: [
|
||||
{ id: EMPLOYEE_ID, employment_start: '2024-01-15', is_active: true },
|
||||
{ id: secondId, employment_start: '2025-03-01', is_active: true },
|
||||
],
|
||||
error: null,
|
||||
},
|
||||
salary_run_employees: { data: [], error: null },
|
||||
employee_opening_balances: {
|
||||
data: [SAMPLE_ROW, { ...SAMPLE_ROW, id: '11111111-1111-4111-8111-111111111111', employee_id: secondId }],
|
||||
error: null,
|
||||
},
|
||||
}),
|
||||
)
|
||||
|
||||
const res = await putBulk(
|
||||
makeRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/employees/opening-balances`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({
|
||||
items: [
|
||||
{ employee_id: EMPLOYEE_ID, ...VALID_BODY },
|
||||
{ employee_id: secondId, ...VALID_BODY },
|
||||
],
|
||||
}),
|
||||
}),
|
||||
companyParams(COMPANY_ID),
|
||||
)
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
const body = await res.json()
|
||||
expect(body.data.count).toBe(2)
|
||||
})
|
||||
|
||||
it('rejects duplicate employee_ids in the same request', async () => {
|
||||
mockServiceClient.mockReturnValue(
|
||||
makeFlexibleSupabase({
|
||||
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
|
||||
}),
|
||||
)
|
||||
const res = await putBulk(
|
||||
makeRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/employees/opening-balances`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({
|
||||
items: [
|
||||
{ employee_id: EMPLOYEE_ID, ...VALID_BODY },
|
||||
{ employee_id: EMPLOYEE_ID, ...VALID_BODY },
|
||||
],
|
||||
}),
|
||||
}),
|
||||
companyParams(COMPANY_ID),
|
||||
)
|
||||
expect(res.status).toBe(400)
|
||||
})
|
||||
|
||||
it('rejects keys without payroll:write scope', async () => {
|
||||
mockValidate.mockResolvedValue({
|
||||
userId: USER_ID,
|
||||
companyId: COMPANY_ID,
|
||||
apiKeyId: 'ak_1',
|
||||
apiKeyName: 'read-only',
|
||||
scopes: ['payroll:read'],
|
||||
mode: 'live',
|
||||
})
|
||||
mockServiceClient.mockReturnValue(makeFlexibleSupabase({}))
|
||||
|
||||
const res = await putBulk(
|
||||
makeRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/employees/opening-balances`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ items: [{ employee_id: EMPLOYEE_ID, ...VALID_BODY }] }),
|
||||
}),
|
||||
companyParams(COMPANY_ID),
|
||||
)
|
||||
expect(res.status).toBe(403)
|
||||
})
|
||||
|
||||
it('returns 401 without a bearer token', async () => {
|
||||
mockValidate.mockResolvedValue(null)
|
||||
mockServiceClient.mockReturnValue(makeFlexibleSupabase({}))
|
||||
const res = await putBulk(
|
||||
new Request(`https://x.test/api/v1/companies/${COMPANY_ID}/employees/opening-balances`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ items: [{ employee_id: EMPLOYEE_ID, ...VALID_BODY }] }),
|
||||
}),
|
||||
companyParams(COMPANY_ID),
|
||||
)
|
||||
expect(res.status).toBe(401)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,213 @@
|
||||
/**
|
||||
* /api/v1/companies/{companyId}/employees/{id}/opening-balances
|
||||
*
|
||||
* GET : the employee's cutover opening balances + lock state.
|
||||
* PUT : full-replace upsert (naturally idempotent). Editable until the
|
||||
* employee appears in a BOOKED salary run; then 409
|
||||
* OPENING_BALANCES_LOCKED (self-unlocks if that run is corrected).
|
||||
*
|
||||
* This is the payroll cutover surface for mid-year migrations from another
|
||||
* payroll system: YTD accumulators (payslip continuity), vacation balances
|
||||
* incl. sparade dagar by origin year, the opening semesterlöneskuld SEK
|
||||
* (report-only: the 2920/2940 balance arrived via SIE opening balances),
|
||||
* and the högriskskydd karens-count adjustment. Ongoing sick cases need no
|
||||
* fields here: import pre-cutover days via PUT /employees/{id}/absence and
|
||||
* the engine reconstructs segments, återinsjuknande, and karens state.
|
||||
*/
|
||||
|
||||
import { z } from 'zod'
|
||||
import { ok } from '@/lib/api/v1/response'
|
||||
import { dryRunPreview } from '@/lib/api/v1/dry-run'
|
||||
import { registerEndpoint, dataEnvelope } from '@/lib/api/v1/registry'
|
||||
import { withApiV1 } from '@/lib/api/v1/with-api-v1'
|
||||
import { v1ErrorResponseFromCode } from '@/lib/api/v1/errors'
|
||||
import { OpeningBalancesFieldsSchema } from '@/lib/api/schemas'
|
||||
import { getOpeningBalances, setOpeningBalancesBulk } from '@/lib/salary/opening-balances'
|
||||
|
||||
const OpeningBalancesResponse = z.object({
|
||||
employee_opening_balances_id: z.string().uuid().nullable(),
|
||||
employee_id: z.string().uuid(),
|
||||
cutover_date: z.string(),
|
||||
ytd_gross: z.number(),
|
||||
ytd_tax: z.number(),
|
||||
ytd_net: z.number(),
|
||||
vacation_paid_days_remaining: z.number(),
|
||||
vacation_saved_days_by_year: z.record(z.string(), z.number()),
|
||||
opening_semester_liability: z.number(),
|
||||
opening_semester_liability_avgifter: z.number(),
|
||||
karens_periods_adjustment: z.number(),
|
||||
locked: z.boolean(),
|
||||
locked_by_run_id: z.string().uuid().nullable(),
|
||||
})
|
||||
|
||||
registerEndpoint({
|
||||
operation: 'employees.opening-balances.get',
|
||||
method: 'GET',
|
||||
path: '/api/v1/companies/:companyId/employees/:id/opening-balances',
|
||||
summary: 'Get an employee\'s payroll cutover opening balances.',
|
||||
description:
|
||||
'Returns the opening balances set for a mid-year migration (YTD gross/tax/net, vacation balances, opening semesterlöneskuld, karens adjustment) plus the lock state: locked=true once the employee has a booked salary run.',
|
||||
useWhen:
|
||||
'Verifying cutover state before the first calculated run, or checking whether balances can still be edited (locked=false).',
|
||||
doNotUseFor:
|
||||
'The live vacation liability (GET /reports/vacation-liability includes the opening terms). Pre-cutover absence history: GET /employees/{id}/absence.',
|
||||
pitfalls: [
|
||||
'404 NOT_FOUND when no opening balances have been set: distinct from an all-zeros row.',
|
||||
'locked_by_run_id names the booked run that froze the row; correcting that run unlocks it.',
|
||||
],
|
||||
example: {
|
||||
response: {
|
||||
data: {
|
||||
employee_id: 'emp_77b2…',
|
||||
cutover_date: '2026-07-01',
|
||||
ytd_gross: 210000,
|
||||
vacation_paid_days_remaining: 12.5,
|
||||
locked: false,
|
||||
},
|
||||
meta: { request_id: 'req_…', api_version: '2026-05-12' },
|
||||
},
|
||||
},
|
||||
scope: 'payroll:read',
|
||||
risk: 'low',
|
||||
idempotent: true,
|
||||
reversible: false,
|
||||
dryRunSupported: false,
|
||||
response: { success: dataEnvelope(OpeningBalancesResponse) },
|
||||
})
|
||||
|
||||
export const GET = withApiV1<{ params: Promise<{ companyId: string; id: string }> }>(
|
||||
'employees.opening-balances.get',
|
||||
async (_request, ctx, params) => {
|
||||
const { id } = await params.params
|
||||
const idParse = z.string().uuid().safeParse(id)
|
||||
if (!idParse.success) {
|
||||
return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, {
|
||||
requestId: ctx.requestId,
|
||||
details: { field: 'id', message: 'Employee id must be a UUID.' },
|
||||
})
|
||||
}
|
||||
|
||||
const result = await getOpeningBalances(ctx.supabase, {
|
||||
companyId: ctx.companyId!,
|
||||
employeeId: idParse.data,
|
||||
})
|
||||
if (!result.ok) {
|
||||
return v1ErrorResponseFromCode(result.code, ctx.log, {
|
||||
requestId: ctx.requestId,
|
||||
details: result.details,
|
||||
})
|
||||
}
|
||||
if (result.data === null) {
|
||||
return v1ErrorResponseFromCode('NOT_FOUND', ctx.log, {
|
||||
requestId: ctx.requestId,
|
||||
details: { resource: 'employee_opening_balances', employee_id: idParse.data },
|
||||
})
|
||||
}
|
||||
return ok(result.data, { requestId: ctx.requestId })
|
||||
},
|
||||
)
|
||||
|
||||
registerEndpoint({
|
||||
operation: 'employees.opening-balances.set',
|
||||
method: 'PUT',
|
||||
path: '/api/v1/companies/:companyId/employees/:id/opening-balances',
|
||||
summary: 'Set an employee\'s payroll cutover opening balances.',
|
||||
description:
|
||||
'Full-replace upsert of the cutover state: YTD gross/tax/net for the cutover year, paid vacation days remaining, sparade dagar keyed by origin year (5-year rule), opening semesterlöneskuld SEK (+avgifter), and karens periods not covered by imported absence rows. cutover_date must be the first of a month in the current or previous year, on/after employment_start.',
|
||||
useWhen:
|
||||
'Onboarding one employee during a mid-year migration from Fortnox/Visma/etc. For whole-company onboarding, prefer the bulk PUT /employees/opening-balances.',
|
||||
doNotUseFor:
|
||||
'SIE opening balances on the LEDGER (2920/2940 arrive via the SIE import). Ongoing sick cases: import pre-cutover days via PUT /employees/{id}/absence instead.',
|
||||
pitfalls: [
|
||||
'Full replace: omitted numeric fields reset to 0 (their defaults). Send the complete state every time.',
|
||||
'409 OPENING_BALANCES_LOCKED once the employee has a booked run; correcting that run unlocks.',
|
||||
'The opening liability is NOT booked by Accounted: it only feeds the vacation-liability report.',
|
||||
'YTD affects payslip display and reports only; per-month tax and avgifter caps never read it.',
|
||||
],
|
||||
example: {
|
||||
request: {
|
||||
cutover_date: '2026-07-01',
|
||||
ytd_gross: 210000,
|
||||
ytd_tax: 48000,
|
||||
ytd_net: 162000,
|
||||
vacation_paid_days_remaining: 12.5,
|
||||
vacation_saved_days_by_year: { '2025': 5 },
|
||||
opening_semester_liability: 42000,
|
||||
opening_semester_liability_avgifter: 13196.4,
|
||||
karens_periods_adjustment: 1,
|
||||
},
|
||||
response: {
|
||||
data: { employee_id: 'emp_77b2…', cutover_date: '2026-07-01', locked: false },
|
||||
meta: { request_id: 'req_…', api_version: '2026-05-12' },
|
||||
},
|
||||
},
|
||||
scope: 'payroll:write',
|
||||
risk: 'medium',
|
||||
idempotent: true,
|
||||
reversible: true,
|
||||
dryRunSupported: true,
|
||||
request: { body: OpeningBalancesFieldsSchema },
|
||||
response: { success: dataEnvelope(OpeningBalancesResponse) },
|
||||
})
|
||||
|
||||
export const PUT = withApiV1<{ params: Promise<{ companyId: string; id: string }> }>(
|
||||
'employees.opening-balances.set',
|
||||
async (request, ctx, params) => {
|
||||
const { id } = await params.params
|
||||
const idParse = z.string().uuid().safeParse(id)
|
||||
if (!idParse.success) {
|
||||
return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, {
|
||||
requestId: ctx.requestId,
|
||||
details: { field: 'id', message: 'Employee id must be a UUID.' },
|
||||
})
|
||||
}
|
||||
|
||||
let rawBody: unknown
|
||||
try {
|
||||
rawBody = await request.json()
|
||||
} catch {
|
||||
return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, {
|
||||
requestId: ctx.requestId,
|
||||
details: { field: 'body', message: 'Body is not valid JSON.' },
|
||||
})
|
||||
}
|
||||
|
||||
const parsed = OpeningBalancesFieldsSchema.safeParse(rawBody)
|
||||
if (!parsed.success) {
|
||||
return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, {
|
||||
requestId: ctx.requestId,
|
||||
details: {
|
||||
issues: parsed.error.issues.map((i) => ({
|
||||
field: i.path.join('.'),
|
||||
message: i.message,
|
||||
})),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// The per-employee PUT is the bulk handler with one item: one validation
|
||||
// and one upsert path to maintain.
|
||||
const result = await setOpeningBalancesBulk(ctx.supabase, {
|
||||
companyId: ctx.companyId!,
|
||||
userId: ctx.userId,
|
||||
items: [{ employee_id: idParse.data, ...parsed.data }],
|
||||
dryRun: ctx.dryRun,
|
||||
})
|
||||
|
||||
if (!result.ok) {
|
||||
// Single-item calls surface the item's own error code directly (a
|
||||
// one-element 422 list would just be indirection).
|
||||
const itemError = result.itemErrors?.[0]
|
||||
return v1ErrorResponseFromCode(itemError?.code ?? result.code, ctx.log, {
|
||||
requestId: ctx.requestId,
|
||||
details: itemError ? { message: itemError.message } : result.details,
|
||||
})
|
||||
}
|
||||
|
||||
const row = result.data.rows[0]
|
||||
if (ctx.dryRun) {
|
||||
return dryRunPreview(row, { requestId: ctx.requestId, log: ctx.log })
|
||||
}
|
||||
return ok(row, { requestId: ctx.requestId })
|
||||
},
|
||||
)
|
||||
@@ -41,6 +41,10 @@ const EmployeeDetail = z.object({
|
||||
employment_start: z.string(),
|
||||
employment_end: z.string().nullable(),
|
||||
employment_degree: z.number(),
|
||||
// Arbetsschema-lite: weekly schedule driving the salary engine's hourly
|
||||
// (173 at 40h) and daily (21 at 5d) divisors.
|
||||
hours_per_week: z.number(),
|
||||
workdays_per_week: z.number(),
|
||||
salary_type: SalaryType,
|
||||
monthly_salary: z.number().nullable(),
|
||||
hourly_rate: z.number().nullable(),
|
||||
@@ -62,6 +66,12 @@ const EmployeeDetail = z.object({
|
||||
vaxa_stod_eligible: z.boolean(),
|
||||
vaxa_stod_start: z.string().nullable(),
|
||||
vaxa_stod_end: z.string().nullable(),
|
||||
// Jämkning (Skatteverket beslut om ändrad beräkning av skatteavdrag):
|
||||
// fixed withholding percentage for a bounded period, overrides the
|
||||
// tax-table lookup at calculation time (payroll gap-closure 1.5).
|
||||
jamkning_percentage: z.number().nullable(),
|
||||
jamkning_valid_from: z.string().nullable(),
|
||||
jamkning_valid_to: z.string().nullable(),
|
||||
// Dimensions PR8: bag applied to the employee's P&L cost lines at booking.
|
||||
default_dimensions: z.record(z.string(), z.string()),
|
||||
is_active: z.boolean(),
|
||||
@@ -70,7 +80,7 @@ const EmployeeDetail = z.object({
|
||||
})
|
||||
|
||||
const EMPLOYEE_DETAIL_COLUMNS =
|
||||
'id, first_name, last_name, personnummer, employment_type, employment_start, employment_end, employment_degree, salary_type, monthly_salary, hourly_rate, tax_table_number, tax_column, tax_municipality, is_sidoinkomst, f_skatt_status, clearing_number, bank_account_number, vacation_rule, vacation_days_per_year, semestertillagg_rate, email, phone, address_line1, postal_code, city, vaxa_stod_eligible, vaxa_stod_start, vaxa_stod_end, default_dimensions, is_active, created_at, updated_at'
|
||||
'id, first_name, last_name, personnummer, employment_type, employment_start, employment_end, employment_degree, hours_per_week, workdays_per_week, salary_type, monthly_salary, hourly_rate, tax_table_number, tax_column, tax_municipality, is_sidoinkomst, f_skatt_status, clearing_number, bank_account_number, vacation_rule, vacation_days_per_year, semestertillagg_rate, email, phone, address_line1, postal_code, city, vaxa_stod_eligible, vaxa_stod_start, vaxa_stod_end, jamkning_percentage, jamkning_valid_from, jamkning_valid_to, default_dimensions, is_active, created_at, updated_at'
|
||||
|
||||
/**
|
||||
* Shape returned by PATCH (success + dry-run preview) and by no-change PATCH.
|
||||
@@ -346,6 +356,51 @@ export const PATCH = withApiV1<{ params: Promise<{ companyId: string; id: string
|
||||
})
|
||||
}
|
||||
|
||||
// Merged-state jämkning check (same pattern as växa-stöd): a non-null
|
||||
// percentage needs a start date, but the schema can only see the body.
|
||||
// Also validate merged date ordering when only one of the dates is
|
||||
// updated. Setting jamkning_percentage to null clears the beslut and
|
||||
// skips these checks. Only run when the PATCH touches a jamkning field:
|
||||
// a legacy row with inconsistent jamkning_* state must not block
|
||||
// unrelated updates (fixing it requires touching those very fields).
|
||||
const jamkningTouched =
|
||||
'jamkning_percentage' in updates ||
|
||||
'jamkning_valid_from' in updates ||
|
||||
'jamkning_valid_to' in updates
|
||||
if (jamkningTouched) {
|
||||
const mergedJamkningPct =
|
||||
'jamkning_percentage' in updates
|
||||
? (updates.jamkning_percentage as number | null)
|
||||
: ((existing as Record<string, unknown>).jamkning_percentage as number | null)
|
||||
const mergedJamkningFrom =
|
||||
'jamkning_valid_from' in updates
|
||||
? (updates.jamkning_valid_from as string | null)
|
||||
: ((existing as Record<string, unknown>).jamkning_valid_from as string | null)
|
||||
const mergedJamkningTo =
|
||||
'jamkning_valid_to' in updates
|
||||
? (updates.jamkning_valid_to as string | null)
|
||||
: ((existing as Record<string, unknown>).jamkning_valid_to as string | null)
|
||||
if (mergedJamkningPct !== null && mergedJamkningPct !== undefined && !mergedJamkningFrom) {
|
||||
return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, {
|
||||
requestId: ctx.requestId,
|
||||
details: {
|
||||
field: 'jamkning_valid_from',
|
||||
message:
|
||||
'Jämkningens startdatum måste anges när jämkningsprocent sätts. Skicka även `jamkning_valid_from` i samma PATCH.',
|
||||
},
|
||||
})
|
||||
}
|
||||
if (mergedJamkningFrom && mergedJamkningTo && mergedJamkningTo < mergedJamkningFrom) {
|
||||
return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, {
|
||||
requestId: ctx.requestId,
|
||||
details: {
|
||||
field: 'jamkning_valid_to',
|
||||
message: 'Jämkningens slutdatum måste vara efter startdatumet.',
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (Object.keys(updates).length === 0) {
|
||||
// GDPR Art.5(1)(c): no-change PATCH still returns a write-shape, so
|
||||
// mask personnummer just like the POST + PATCH success path.
|
||||
|
||||
+179
@@ -0,0 +1,179 @@
|
||||
/**
|
||||
* Tests for GET /api/v1/companies/{companyId}/employees/{id}/vacation-balance
|
||||
* (payroll gap-closure 3.4).
|
||||
*/
|
||||
|
||||
import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
beforeAll(() => {
|
||||
if (process.env.NODE_ENV !== 'test') {
|
||||
throw new Error(
|
||||
`vacation-balance route tests require NODE_ENV=test (got ${process.env.NODE_ENV ?? 'undefined'})`,
|
||||
)
|
||||
}
|
||||
process.env.NEXT_PUBLIC_SUPABASE_URL ||= 'http://localhost:54321'
|
||||
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY ||= 'test-anon-key'
|
||||
})
|
||||
|
||||
vi.mock('@/lib/auth/api-keys', async () => {
|
||||
const actual = await vi.importActual<typeof import('@/lib/auth/api-keys')>('@/lib/auth/api-keys')
|
||||
return {
|
||||
...actual,
|
||||
validateApiKey: vi.fn(),
|
||||
createServiceClientNoCookies: vi.fn(),
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@supabase/supabase-js', async () => {
|
||||
const actual = await vi.importActual<typeof import('@supabase/supabase-js')>('@supabase/supabase-js')
|
||||
return { ...actual, createClient: vi.fn().mockReturnValue({}) }
|
||||
})
|
||||
|
||||
import { validateApiKey, createServiceClientNoCookies } from '@/lib/auth/api-keys'
|
||||
import { GET as getBalance } from '../route'
|
||||
|
||||
const mockValidate = validateApiKey as ReturnType<typeof vi.fn>
|
||||
const mockServiceClient = createServiceClientNoCookies as ReturnType<typeof vi.fn>
|
||||
|
||||
function makeFlexibleSupabase(byTable: Record<string, { data?: unknown; error?: unknown }>) {
|
||||
const buildChain = (table: string): unknown => {
|
||||
const handler: ProxyHandler<object> = {
|
||||
get(_target, prop) {
|
||||
if (prop === 'then') {
|
||||
return (resolve: (v: unknown) => void) =>
|
||||
resolve(byTable[table] ?? { data: null, error: null })
|
||||
}
|
||||
return (..._args: unknown[]) => buildChain(table)
|
||||
},
|
||||
}
|
||||
return new Proxy({}, handler)
|
||||
}
|
||||
return { from: vi.fn((table: string) => buildChain(table)) }
|
||||
}
|
||||
|
||||
const COMPANY_ID = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa'
|
||||
const EMPLOYEE_ID = 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb'
|
||||
const BALANCE_ID = 'cccccccc-cccc-4ccc-8ccc-cccccccccccc'
|
||||
|
||||
const EMPLOYEE = {
|
||||
id: EMPLOYEE_ID,
|
||||
vacation_rule: 'sammaloneregeln',
|
||||
vacation_days_per_year: 25,
|
||||
salary_type: 'monthly',
|
||||
monthly_salary: 30000,
|
||||
hourly_rate: null,
|
||||
hours_per_week: 40,
|
||||
workdays_per_week: 5,
|
||||
}
|
||||
|
||||
const BALANCE = {
|
||||
id: BALANCE_ID,
|
||||
employee_id: EMPLOYEE_ID,
|
||||
vacation_year_start: '2026-01-01',
|
||||
entitled_days: 25,
|
||||
accrued_days: 0,
|
||||
taken_days: 10,
|
||||
saved_days: { '2025': 5 },
|
||||
forced_payout_days: 0,
|
||||
}
|
||||
|
||||
function makeRequest(url: string): Request {
|
||||
return new Request(url, {
|
||||
headers: { Authorization: 'Bearer test-fixture-not-a-real-key' },
|
||||
})
|
||||
}
|
||||
|
||||
function detailParams(companyId: string, id: string) {
|
||||
return { params: Promise.resolve({ companyId, id }) }
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockValidate.mockResolvedValue({
|
||||
userId: 'user-1',
|
||||
companyId: COMPANY_ID,
|
||||
apiKeyId: 'ak_1',
|
||||
apiKeyName: 'CI key',
|
||||
scopes: ['payroll:read'],
|
||||
mode: 'live',
|
||||
})
|
||||
})
|
||||
|
||||
describe('GET /employees/:id/vacation-balance', () => {
|
||||
it('returns the balance with remaining days and a SEK estimate', async () => {
|
||||
mockServiceClient.mockReturnValue(
|
||||
makeFlexibleSupabase({
|
||||
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
|
||||
employees: { data: EMPLOYEE, error: null },
|
||||
employee_vacation_balances: { data: BALANCE, error: null },
|
||||
}),
|
||||
)
|
||||
|
||||
const res = await getBalance(
|
||||
makeRequest(
|
||||
`https://x.test/api/v1/companies/${COMPANY_ID}/employees/${EMPLOYEE_ID}/vacation-balance`,
|
||||
),
|
||||
detailParams(COMPANY_ID, EMPLOYEE_ID),
|
||||
)
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
const body = await res.json()
|
||||
expect(body.data.employee_vacation_balance_id).toBe(BALANCE_ID)
|
||||
expect(body.data.remaining_days).toBe(15)
|
||||
expect(body.data.saved_days_total).toBe(5)
|
||||
// Day value sammalöneregeln: 30000/21 + 30000 x 0.0043 = 1557.57.
|
||||
// Liability = (15 remaining + 5 saved) x 1557.57 = 31151.4.
|
||||
expect(body.data.estimated_liability_sek).toBe(31151.4)
|
||||
expect(body.data.id).toBeUndefined()
|
||||
})
|
||||
|
||||
it('returns 404 VACATION_BALANCE_NOT_FOUND before the ledger seeds', async () => {
|
||||
mockServiceClient.mockReturnValue(
|
||||
makeFlexibleSupabase({
|
||||
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
|
||||
employees: { data: EMPLOYEE, error: null },
|
||||
employee_vacation_balances: { data: null, error: null },
|
||||
}),
|
||||
)
|
||||
|
||||
const res = await getBalance(
|
||||
makeRequest(
|
||||
`https://x.test/api/v1/companies/${COMPANY_ID}/employees/${EMPLOYEE_ID}/vacation-balance`,
|
||||
),
|
||||
detailParams(COMPANY_ID, EMPLOYEE_ID),
|
||||
)
|
||||
expect(res.status).toBe(404)
|
||||
const body = await res.json()
|
||||
expect(body.error.code).toBe('VACATION_BALANCE_NOT_FOUND')
|
||||
})
|
||||
|
||||
it('returns 404 EMPLOYEE_NOT_FOUND for an unknown employee', async () => {
|
||||
mockServiceClient.mockReturnValue(
|
||||
makeFlexibleSupabase({
|
||||
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
|
||||
employees: { data: null, error: null },
|
||||
}),
|
||||
)
|
||||
const res = await getBalance(
|
||||
makeRequest(
|
||||
`https://x.test/api/v1/companies/${COMPANY_ID}/employees/${EMPLOYEE_ID}/vacation-balance`,
|
||||
),
|
||||
detailParams(COMPANY_ID, EMPLOYEE_ID),
|
||||
)
|
||||
expect(res.status).toBe(404)
|
||||
const body = await res.json()
|
||||
expect(body.error.code).toBe('EMPLOYEE_NOT_FOUND')
|
||||
})
|
||||
|
||||
it('returns 401 without a bearer token', async () => {
|
||||
mockValidate.mockResolvedValue(null)
|
||||
mockServiceClient.mockReturnValue(makeFlexibleSupabase({}))
|
||||
const res = await getBalance(
|
||||
new Request(
|
||||
`https://x.test/api/v1/companies/${COMPANY_ID}/employees/${EMPLOYEE_ID}/vacation-balance`,
|
||||
),
|
||||
detailParams(COMPANY_ID, EMPLOYEE_ID),
|
||||
)
|
||||
expect(res.status).toBe(401)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,174 @@
|
||||
/**
|
||||
* GET /api/v1/companies/{companyId}/employees/{id}/vacation-balance
|
||||
*
|
||||
* The employee's current OPEN vacation-ledger row: entitled/taken/remaining
|
||||
* days, sparade dagar by origin year, forced payouts, plus a computed SEK
|
||||
* estimate of the individual semesterlöneskuld (same day valuation the
|
||||
* year-close uses).
|
||||
*/
|
||||
|
||||
import { z } from 'zod'
|
||||
import { ok } from '@/lib/api/v1/response'
|
||||
import { registerEndpoint, dataEnvelope } from '@/lib/api/v1/registry'
|
||||
import { withApiV1 } from '@/lib/api/v1/with-api-v1'
|
||||
import { v1ErrorResponse, v1ErrorResponseFromCode } from '@/lib/api/v1/errors'
|
||||
import { roundOre } from '@/lib/money'
|
||||
import { dailyDivisor } from '@/lib/salary/work-schedule'
|
||||
|
||||
const VacationBalanceResponse = z.object({
|
||||
employee_vacation_balance_id: z.string().uuid(),
|
||||
employee_id: z.string().uuid(),
|
||||
vacation_year_start: z.string(),
|
||||
entitled_days: z.number(),
|
||||
accrued_days: z.number(),
|
||||
taken_days: z.number(),
|
||||
remaining_days: z.number(),
|
||||
saved_days: z.record(z.string(), z.number()),
|
||||
saved_days_total: z.number(),
|
||||
forced_payout_days: z.number(),
|
||||
estimated_liability_sek: z.number(),
|
||||
})
|
||||
|
||||
registerEndpoint({
|
||||
operation: 'employees.vacation-balance.get',
|
||||
method: 'GET',
|
||||
path: '/api/v1/companies/:companyId/employees/:id/vacation-balance',
|
||||
summary: 'Get an employee\'s current vacation balance.',
|
||||
description:
|
||||
'Returns the open vacation-ledger row (recomputed on every booking): entitled/taken/remaining days, sparade dagar keyed by origin year (Semesterlagen 5-year rule), forced-payout days from expired savings, and a computed SEK estimate of the individual semesterlöneskuld.',
|
||||
useWhen:
|
||||
'Answering "how many vacation days does Anna have left", pre-payroll review, or preparing the year-close.',
|
||||
doNotUseFor:
|
||||
'The company-wide liability report: GET /reports/vacation-liability. Closing the year: POST /salary/vacation-year-close.',
|
||||
pitfalls: [
|
||||
'404 VACATION_BALANCE_NOT_FOUND until the first booking (or year-close) touches the employee: the ledger seeds lazily.',
|
||||
'remaining_days can go negative if more days were taken than entitled: surface it, do not clamp.',
|
||||
'The SEK estimate uses the year-close day valuation (simplified BFNAR 2016:10); the booked 2920 is reconciled only at year-close.',
|
||||
],
|
||||
example: {
|
||||
response: {
|
||||
data: {
|
||||
employee_id: 'emp_77b2…',
|
||||
vacation_year_start: '2026-01-01',
|
||||
entitled_days: 25,
|
||||
taken_days: 10,
|
||||
remaining_days: 15,
|
||||
saved_days: { '2025': 5 },
|
||||
saved_days_total: 5,
|
||||
estimated_liability_sek: 31151.4,
|
||||
},
|
||||
meta: { request_id: 'req_…', api_version: '2026-05-12' },
|
||||
},
|
||||
},
|
||||
scope: 'payroll:read',
|
||||
risk: 'low',
|
||||
idempotent: true,
|
||||
reversible: false,
|
||||
dryRunSupported: false,
|
||||
response: { success: dataEnvelope(VacationBalanceResponse) },
|
||||
})
|
||||
|
||||
export const GET = withApiV1<{ params: Promise<{ companyId: string; id: string }> }>(
|
||||
'employees.vacation-balance.get',
|
||||
async (_request, ctx, params) => {
|
||||
const { id } = await params.params
|
||||
const idParse = z.string().uuid().safeParse(id)
|
||||
if (!idParse.success) {
|
||||
return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, {
|
||||
requestId: ctx.requestId,
|
||||
details: { field: 'id', message: 'Employee id must be a UUID.' },
|
||||
})
|
||||
}
|
||||
|
||||
const { data: employee, error: empErr } = await ctx.supabase
|
||||
.from('employees')
|
||||
.select('id, vacation_rule, vacation_days_per_year, salary_type, monthly_salary, hourly_rate, hours_per_week, workdays_per_week')
|
||||
.eq('id', idParse.data)
|
||||
.eq('company_id', ctx.companyId!)
|
||||
.maybeSingle()
|
||||
if (empErr) {
|
||||
return v1ErrorResponse(empErr, ctx.log, { requestId: ctx.requestId })
|
||||
}
|
||||
if (!employee) {
|
||||
return v1ErrorResponseFromCode('EMPLOYEE_NOT_FOUND', ctx.log, { requestId: ctx.requestId })
|
||||
}
|
||||
|
||||
const { data: balance, error: balErr } = await ctx.supabase
|
||||
.from('employee_vacation_balances')
|
||||
.select('id, employee_id, vacation_year_start, entitled_days, accrued_days, taken_days, saved_days, forced_payout_days')
|
||||
.eq('company_id', ctx.companyId!)
|
||||
.eq('employee_id', idParse.data)
|
||||
.eq('status', 'open')
|
||||
.order('vacation_year_start', { ascending: false })
|
||||
.limit(1)
|
||||
.maybeSingle()
|
||||
if (balErr) {
|
||||
return v1ErrorResponse(balErr, ctx.log, { requestId: ctx.requestId })
|
||||
}
|
||||
if (!balance) {
|
||||
return v1ErrorResponseFromCode('VACATION_BALANCE_NOT_FOUND', ctx.log, {
|
||||
requestId: ctx.requestId,
|
||||
details: { employee_id: idParse.data },
|
||||
})
|
||||
}
|
||||
|
||||
const row = balance as {
|
||||
id: string
|
||||
employee_id: string
|
||||
vacation_year_start: string
|
||||
entitled_days: number
|
||||
accrued_days: number
|
||||
taken_days: number
|
||||
saved_days: Record<string, number> | null
|
||||
forced_payout_days: number
|
||||
}
|
||||
const emp = employee as {
|
||||
vacation_rule: string
|
||||
vacation_days_per_year: number
|
||||
salary_type: string
|
||||
monthly_salary: number | null
|
||||
hourly_rate: number | null
|
||||
hours_per_week: number | null
|
||||
workdays_per_week: number | null
|
||||
}
|
||||
|
||||
const savedDays = row.saved_days ?? {}
|
||||
const savedTotal = Object.values(savedDays).reduce((s, d) => s + (Number(d) || 0), 0)
|
||||
const remaining = roundOre(row.entitled_days - row.taken_days)
|
||||
|
||||
// Same simplified BFNAR 2016:10 day valuation the year-close uses.
|
||||
const rate = emp.vacation_days_per_year >= 30 ? 0.144 : 0.12
|
||||
let dayValue: number
|
||||
if (emp.salary_type === 'hourly') {
|
||||
dayValue = roundOre(
|
||||
((emp.hourly_rate || 0) * (emp.hours_per_week ?? 40) * 52 * rate) /
|
||||
Math.max(emp.vacation_days_per_year, 1),
|
||||
)
|
||||
} else if (emp.vacation_rule === 'sammaloneregeln') {
|
||||
const monthly = emp.monthly_salary || 0
|
||||
dayValue = roundOre(monthly / dailyDivisor(emp.workdays_per_week) + monthly * 0.0043)
|
||||
} else {
|
||||
dayValue = roundOre(
|
||||
((emp.monthly_salary || 0) * 12 * rate) / Math.max(emp.vacation_days_per_year, 1),
|
||||
)
|
||||
}
|
||||
const estimatedLiability = roundOre(Math.max(0, remaining + savedTotal) * dayValue)
|
||||
|
||||
return ok(
|
||||
{
|
||||
employee_vacation_balance_id: row.id,
|
||||
employee_id: row.employee_id,
|
||||
vacation_year_start: row.vacation_year_start,
|
||||
entitled_days: row.entitled_days,
|
||||
accrued_days: row.accrued_days,
|
||||
taken_days: row.taken_days,
|
||||
remaining_days: remaining,
|
||||
saved_days: savedDays,
|
||||
saved_days_total: savedTotal,
|
||||
forced_payout_days: row.forced_payout_days,
|
||||
estimated_liability_sek: estimatedLiability,
|
||||
},
|
||||
{ requestId: ctx.requestId },
|
||||
)
|
||||
},
|
||||
)
|
||||
@@ -146,6 +146,9 @@ const SAMPLE_EMPLOYEE = {
|
||||
vaxa_stod_eligible: false,
|
||||
vaxa_stod_start: null,
|
||||
vaxa_stod_end: null,
|
||||
jamkning_percentage: null,
|
||||
jamkning_valid_from: null,
|
||||
jamkning_valid_to: null,
|
||||
is_active: true,
|
||||
created_at: '2024-01-15T08:00:00Z',
|
||||
updated_at: '2024-01-15T08:00:00Z',
|
||||
@@ -648,6 +651,159 @@ describe('PATCH /api/v1/companies/:companyId/employees/:id', () => {
|
||||
expect(body.error.details.field).toBe('personnummer')
|
||||
})
|
||||
|
||||
it('sets work-schedule fields (arbetsschema-lite)', async () => {
|
||||
const updated = { ...SAMPLE_EMPLOYEE, hours_per_week: 32, workdays_per_week: 4 }
|
||||
mockServiceClient.mockReturnValue(
|
||||
makeFlexibleSupabase({
|
||||
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
|
||||
employees: [{ data: SAMPLE_EMPLOYEE, error: null }, { data: updated, error: null }],
|
||||
idempotency_keys: { data: null, error: null },
|
||||
}),
|
||||
)
|
||||
|
||||
const res = await updateEmployee(
|
||||
makeRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/employees/${EMPLOYEE_ID}`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify({ hours_per_week: 32, workdays_per_week: 4 }),
|
||||
}),
|
||||
detailParams(COMPANY_ID, EMPLOYEE_ID),
|
||||
)
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
const body = await res.json()
|
||||
expect(body.data.hours_per_week).toBe(32)
|
||||
expect(body.data.workdays_per_week).toBe(4)
|
||||
})
|
||||
|
||||
it('rejects an out-of-range work schedule', async () => {
|
||||
mockServiceClient.mockReturnValue(
|
||||
makeFlexibleSupabase({
|
||||
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
|
||||
}),
|
||||
)
|
||||
const res = await updateEmployee(
|
||||
makeRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/employees/${EMPLOYEE_ID}`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify({ workdays_per_week: 9 }),
|
||||
}),
|
||||
detailParams(COMPANY_ID, EMPLOYEE_ID),
|
||||
)
|
||||
expect(res.status).toBe(400)
|
||||
})
|
||||
|
||||
it('sets jämkning fields (percentage + validity window)', async () => {
|
||||
const updated = {
|
||||
...SAMPLE_EMPLOYEE,
|
||||
jamkning_percentage: 15,
|
||||
jamkning_valid_from: '2026-01-01',
|
||||
jamkning_valid_to: '2026-12-31',
|
||||
}
|
||||
mockServiceClient.mockReturnValue(
|
||||
makeFlexibleSupabase({
|
||||
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
|
||||
employees: [{ data: SAMPLE_EMPLOYEE, error: null }, { data: updated, error: null }],
|
||||
idempotency_keys: { data: null, error: null },
|
||||
}),
|
||||
)
|
||||
|
||||
const res = await updateEmployee(
|
||||
makeRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/employees/${EMPLOYEE_ID}`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify({
|
||||
jamkning_percentage: 15,
|
||||
jamkning_valid_from: '2026-01-01',
|
||||
jamkning_valid_to: '2026-12-31',
|
||||
}),
|
||||
}),
|
||||
detailParams(COMPANY_ID, EMPLOYEE_ID),
|
||||
)
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
const body = await res.json()
|
||||
expect(body.data.jamkning_percentage).toBe(15)
|
||||
expect(body.data.jamkning_valid_from).toBe('2026-01-01')
|
||||
})
|
||||
|
||||
it('rejects a jämkning percentage without a start date (merged state)', async () => {
|
||||
// Existing row has no jamkning_valid_from; sending only the percentage
|
||||
// must fail the route-level merged-state check.
|
||||
mockServiceClient.mockReturnValue(
|
||||
makeFlexibleSupabase({
|
||||
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
|
||||
employees: { data: SAMPLE_EMPLOYEE, error: null },
|
||||
idempotency_keys: { data: null, error: null },
|
||||
}),
|
||||
)
|
||||
|
||||
const res = await updateEmployee(
|
||||
makeRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/employees/${EMPLOYEE_ID}`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify({ jamkning_percentage: 15 }),
|
||||
}),
|
||||
detailParams(COMPANY_ID, EMPLOYEE_ID),
|
||||
)
|
||||
|
||||
expect(res.status).toBe(400)
|
||||
const body = await res.json()
|
||||
expect(body.error.code).toBe('VALIDATION_ERROR')
|
||||
expect(body.error.details.field).toBe('jamkning_valid_from')
|
||||
})
|
||||
|
||||
it('rejects jamkning_valid_to before jamkning_valid_from', async () => {
|
||||
mockServiceClient.mockReturnValue(
|
||||
makeFlexibleSupabase({
|
||||
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
|
||||
employees: { data: SAMPLE_EMPLOYEE, error: null },
|
||||
idempotency_keys: { data: null, error: null },
|
||||
}),
|
||||
)
|
||||
|
||||
const res = await updateEmployee(
|
||||
makeRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/employees/${EMPLOYEE_ID}`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify({
|
||||
jamkning_percentage: 15,
|
||||
jamkning_valid_from: '2026-06-01',
|
||||
jamkning_valid_to: '2026-01-01',
|
||||
}),
|
||||
}),
|
||||
detailParams(COMPANY_ID, EMPLOYEE_ID),
|
||||
)
|
||||
|
||||
expect(res.status).toBe(400)
|
||||
const body = await res.json()
|
||||
expect(body.error.code).toBe('VALIDATION_ERROR')
|
||||
})
|
||||
|
||||
it('clears the jämkningsbeslut with an explicit null', async () => {
|
||||
const withJamkning = {
|
||||
...SAMPLE_EMPLOYEE,
|
||||
jamkning_percentage: 15,
|
||||
jamkning_valid_from: '2026-01-01',
|
||||
jamkning_valid_to: null,
|
||||
}
|
||||
const cleared = { ...SAMPLE_EMPLOYEE }
|
||||
mockServiceClient.mockReturnValue(
|
||||
makeFlexibleSupabase({
|
||||
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
|
||||
employees: [{ data: withJamkning, error: null }, { data: cleared, error: null }],
|
||||
idempotency_keys: { data: null, error: null },
|
||||
}),
|
||||
)
|
||||
|
||||
const res = await updateEmployee(
|
||||
makeRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/employees/${EMPLOYEE_ID}`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify({ jamkning_percentage: null }),
|
||||
}),
|
||||
detailParams(COMPANY_ID, EMPLOYEE_ID),
|
||||
)
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
const body = await res.json()
|
||||
expect(body.data.jamkning_percentage).toBeNull()
|
||||
})
|
||||
|
||||
it('returns a dry-run preview with masked personnummer', async () => {
|
||||
// GDPR Art.5(1)(c): the dry-run preview is a write-shape so it follows
|
||||
// the same masking rule as POST and PATCH success. The full value is
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
/**
|
||||
* PUT /api/v1/companies/{companyId}/employees/opening-balances
|
||||
*
|
||||
* Bulk full-replace upsert of payroll cutover opening balances: the byrå/
|
||||
* integrator onboarding surface for mid-year migrations. ATOMIC
|
||||
* all-or-nothing: every item is validated against live state first
|
||||
* (employee exists + active, cutover >= employment_start, not locked by a
|
||||
* booked run); any failure returns the complete per-item error list with
|
||||
* ZERO writes, so the caller fixes the file and resubmits.
|
||||
*/
|
||||
|
||||
import { z } from 'zod'
|
||||
import { ok } from '@/lib/api/v1/response'
|
||||
import { dryRunPreview } from '@/lib/api/v1/dry-run'
|
||||
import { registerEndpoint, dataEnvelope } from '@/lib/api/v1/registry'
|
||||
import { withApiV1 } from '@/lib/api/v1/with-api-v1'
|
||||
import { v1ErrorResponse, v1ErrorResponseFromCode } from '@/lib/api/v1/errors'
|
||||
import { OpeningBalancesBulkSchema } from '@/lib/api/schemas'
|
||||
import { setOpeningBalancesBulk } from '@/lib/salary/opening-balances'
|
||||
|
||||
const BulkResponse = z.object({
|
||||
count: z.number().int(),
|
||||
rows: z.array(
|
||||
z.object({
|
||||
employee_opening_balances_id: z.string().uuid().nullable(),
|
||||
employee_id: z.string().uuid(),
|
||||
cutover_date: z.string(),
|
||||
locked: z.boolean(),
|
||||
}),
|
||||
),
|
||||
})
|
||||
|
||||
registerEndpoint({
|
||||
operation: 'employees.opening-balances.bulk-set',
|
||||
method: 'PUT',
|
||||
path: '/api/v1/companies/:companyId/employees/opening-balances',
|
||||
summary: 'Bulk-set payroll cutover opening balances (atomic).',
|
||||
description:
|
||||
'Upserts opening balances for up to 200 employees in one call. Validation is all-or-nothing: any invalid item (unknown/inactive employee, cutover before employment_start, locked by a booked run) fails the WHOLE request with a per-item error list and zero writes.',
|
||||
useWhen:
|
||||
'Onboarding a whole company mid-year from another payroll system: one call per migration file instead of N sequential PUTs.',
|
||||
doNotUseFor:
|
||||
'Single-employee corrections after go-live: PUT /employees/{id}/opening-balances. Ledger opening balances (SIE import).',
|
||||
pitfalls: [
|
||||
'Atomic: one bad item fails everything. The error details carry item_errors[{index, employee_id, code, message}]: fix and resubmit the full set.',
|
||||
'Full replace per employee: resubmitting with fewer fields resets the omitted ones to 0.',
|
||||
'Duplicate employee_id within items is rejected outright.',
|
||||
],
|
||||
example: {
|
||||
request: {
|
||||
items: [
|
||||
{ employee_id: 'emp_77b2…', cutover_date: '2026-07-01', ytd_gross: 210000, ytd_tax: 48000, ytd_net: 162000 },
|
||||
],
|
||||
},
|
||||
response: {
|
||||
data: { count: 1, rows: [{ employee_id: 'emp_77b2…', cutover_date: '2026-07-01', locked: false }] },
|
||||
meta: { request_id: 'req_…', api_version: '2026-05-12' },
|
||||
},
|
||||
},
|
||||
scope: 'payroll:write',
|
||||
risk: 'medium',
|
||||
idempotent: true,
|
||||
reversible: true,
|
||||
dryRunSupported: true,
|
||||
request: { body: OpeningBalancesBulkSchema },
|
||||
response: { success: dataEnvelope(BulkResponse) },
|
||||
})
|
||||
|
||||
export const PUT = withApiV1<{ params: Promise<{ companyId: string }> }>(
|
||||
'employees.opening-balances.bulk-set',
|
||||
async (request, ctx) => {
|
||||
let rawBody: unknown
|
||||
try {
|
||||
rawBody = await request.json()
|
||||
} catch {
|
||||
return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, {
|
||||
requestId: ctx.requestId,
|
||||
details: { field: 'body', message: 'Body is not valid JSON.' },
|
||||
})
|
||||
}
|
||||
|
||||
const parsed = OpeningBalancesBulkSchema.safeParse(rawBody)
|
||||
if (!parsed.success) {
|
||||
return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, {
|
||||
requestId: ctx.requestId,
|
||||
details: {
|
||||
issues: parsed.error.issues.map((i) => ({
|
||||
field: i.path.join('.'),
|
||||
message: i.message,
|
||||
})),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const result = await setOpeningBalancesBulk(ctx.supabase, {
|
||||
companyId: ctx.companyId!,
|
||||
userId: ctx.userId,
|
||||
items: parsed.data.items,
|
||||
dryRun: ctx.dryRun,
|
||||
})
|
||||
|
||||
if (!result.ok) {
|
||||
if (result.itemErrors) {
|
||||
// Atomic contract: full per-item error list, zero writes.
|
||||
return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, {
|
||||
requestId: ctx.requestId,
|
||||
details: { item_errors: result.itemErrors },
|
||||
})
|
||||
}
|
||||
if (result.code === 'INTERNAL_ERROR') {
|
||||
return v1ErrorResponse(new Error(String(result.details?.message ?? 'bulk upsert failed')), ctx.log, {
|
||||
requestId: ctx.requestId,
|
||||
})
|
||||
}
|
||||
return v1ErrorResponseFromCode(result.code, ctx.log, {
|
||||
requestId: ctx.requestId,
|
||||
details: result.details,
|
||||
})
|
||||
}
|
||||
|
||||
if (ctx.dryRun) {
|
||||
return dryRunPreview(result.data, { requestId: ctx.requestId, log: ctx.log })
|
||||
}
|
||||
return ok(result.data, { requestId: ctx.requestId })
|
||||
},
|
||||
)
|
||||
@@ -416,6 +416,8 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string }> }>(
|
||||
employment_start: body.employment_start,
|
||||
employment_end: body.employment_end ?? null,
|
||||
employment_degree: body.employment_degree,
|
||||
hours_per_week: body.hours_per_week,
|
||||
workdays_per_week: body.workdays_per_week,
|
||||
salary_type: body.salary_type,
|
||||
monthly_salary: body.monthly_salary ?? null,
|
||||
hourly_rate: body.hourly_rate ?? null,
|
||||
@@ -437,6 +439,9 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string }> }>(
|
||||
vaxa_stod_eligible: body.vaxa_stod_eligible,
|
||||
vaxa_stod_start: body.vaxa_stod_start ?? null,
|
||||
vaxa_stod_end: body.vaxa_stod_end ?? null,
|
||||
jamkning_percentage: body.jamkning_percentage ?? null,
|
||||
jamkning_valid_from: body.jamkning_valid_from ?? null,
|
||||
jamkning_valid_to: body.jamkning_valid_to ?? null,
|
||||
// Dimensions PR8: bag for the employee's P&L cost lines at booking.
|
||||
default_dimensions: body.default_dimensions ?? {},
|
||||
})
|
||||
|
||||
@@ -369,6 +369,50 @@ describe('POST /api/v1/companies/:companyId/invoices/:id/mark-paid', () => {
|
||||
expect(mockPayment).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('absorbs an öresavrundning overshoot on SEK custom lines (rounded "Att betala")', async () => {
|
||||
// Invoice stored with öre (1234.75); the PDF shows 1235.00 and the customer
|
||||
// pays that. The 3740 line carries the residual and the invoice settles in
|
||||
// full instead of being rejected as an overpayment.
|
||||
const ORE_INVOICE = {
|
||||
...SENT_INVOICE,
|
||||
subtotal: 987.8,
|
||||
vat_amount: 246.95,
|
||||
total: 1234.75,
|
||||
remaining_amount: 1234.75,
|
||||
}
|
||||
mockServiceClient.mockReturnValue(
|
||||
makeFlexibleSupabase({
|
||||
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
|
||||
invoices: [
|
||||
{ data: ORE_INVOICE, error: null },
|
||||
{ data: { ...ORE_INVOICE, status: 'paid', remaining_amount: 0, paid_amount: 1234.75 }, error: null },
|
||||
],
|
||||
company_settings: { data: { accounting_method: 'accrual', entity_type: 'enskild_firma' }, error: null },
|
||||
transactions: { data: [], error: null },
|
||||
}),
|
||||
)
|
||||
|
||||
const res = await markPaid(
|
||||
makeRequest(
|
||||
`https://x.test/api/v1/companies/${COMPANY_ID}/invoices/${INVOICE_ID}/mark-paid`,
|
||||
{
|
||||
payment_date: '2026-05-12',
|
||||
lines: [
|
||||
{ account_number: '1930', debit_amount: 1235, credit_amount: 0 },
|
||||
{ account_number: '1510', debit_amount: 0, credit_amount: 1234.75 },
|
||||
{ account_number: '3740', debit_amount: 0, credit_amount: 0.25 },
|
||||
],
|
||||
},
|
||||
),
|
||||
detailParams(COMPANY_ID, INVOICE_ID),
|
||||
)
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
const body = await res.json()
|
||||
expect(body.data.status).toBe('paid')
|
||||
expect(body.data.remaining_amount).toBe(0)
|
||||
})
|
||||
|
||||
it('returns 400 INVOICE_PAID_NOT_PAYABLE for draft invoices', async () => {
|
||||
mockServiceClient.mockReturnValue(
|
||||
makeFlexibleSupabase({
|
||||
|
||||
@@ -42,7 +42,7 @@ import { AccountsNotInChartError, isBookkeepingError } from '@/lib/bookkeeping/e
|
||||
import { getErrorMessage } from '@/lib/errors/get-error-message'
|
||||
import { eventBus } from '@/lib/events'
|
||||
import { findDuplicatePaymentCandidatesForInvoice } from '@/lib/invoices/duplicate-payment-candidates'
|
||||
import { planInvoicePayment } from '@/lib/invoices/apply-invoice-payment'
|
||||
import { planInvoicePaymentForLines } from '@/lib/invoices/apply-invoice-payment'
|
||||
import { roundOre } from '@/lib/money'
|
||||
import type { CreateJournalEntryInput, EntityType, Invoice } from '@/types'
|
||||
|
||||
@@ -285,7 +285,18 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string
|
||||
const paymentAmountInInvoiceCurrency = customLines
|
||||
? roundOre(paymentAmount / fxRate)
|
||||
: paymentAmount
|
||||
const payment = planInvoicePayment(typed, paymentAmountInInvoiceCurrency)
|
||||
// Custom-line SEK settlements absorb a sub-krona öresavrundning residual
|
||||
// (rounded "Att betala" vs the stored öre total), but ONLY when the lines
|
||||
// actually carry the residual on 3740: otherwise the strict plan applies
|
||||
// (sub-krona partials stay partial, overshoot rejects), mirroring the
|
||||
// dashboard mark-paid flow. The default path pays the exact remaining, so
|
||||
// absorption is a no-op there.
|
||||
const payment = planInvoicePaymentForLines(
|
||||
typed,
|
||||
paymentAmountInInvoiceCurrency,
|
||||
customLines,
|
||||
typed.currency ?? 'SEK',
|
||||
)
|
||||
if (!payment.ok) {
|
||||
return v1ErrorResponseFromCode('MATCH_AMOUNT_EXCEEDS_REMAINING', ctx.log, {
|
||||
requestId: ctx.requestId,
|
||||
|
||||
@@ -34,6 +34,7 @@ import { withApiV1 } from '@/lib/api/v1/with-api-v1'
|
||||
import { v1ErrorResponse, v1ErrorResponseFromCode } from '@/lib/api/v1/errors'
|
||||
import { checkPeriodLock } from '@/lib/api/v1/check-period-lock'
|
||||
import { createSalaryRunEntries } from '@/lib/salary/salary-entries'
|
||||
import { syncVacationLedgerForEmployees } from '@/lib/salary/vacation-ledger'
|
||||
import { isBookkeepingError } from '@/lib/bookkeeping/errors'
|
||||
import { eventBus } from '@/lib/events'
|
||||
|
||||
@@ -346,6 +347,17 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string
|
||||
ctx.log.warn('salary_run.booked emit failed', err as Error)
|
||||
}
|
||||
|
||||
// Vacation ledger sync (non-fatal: the ledger recomputes and self-heals
|
||||
// on the next booking; a sync bug must never block a booking).
|
||||
const ledgerSync = await syncVacationLedgerForEmployees(
|
||||
ctx.supabase,
|
||||
ctx.companyId!,
|
||||
(employees as Array<{ employee_id: string }>).map((sre) => sre.employee_id),
|
||||
)
|
||||
if (!ledgerSync.ok) {
|
||||
ctx.log.warn('vacation ledger sync failed after booking', { message: ledgerSync.message })
|
||||
}
|
||||
|
||||
const bookedAt = (bookedRun as { booked_at: string }).booked_at
|
||||
|
||||
return ok(
|
||||
|
||||
+150
@@ -0,0 +1,150 @@
|
||||
/**
|
||||
* POST /api/v1/companies/{companyId}/salary-runs/{id}/employees/{employeeId}/lines
|
||||
*
|
||||
* Add a payslip line item (bonus, overtime, deduction, benefit, ...) to one
|
||||
* employee in a DRAFT salary run. The path addresses the employee by
|
||||
* employee_id: the route resolves the salary_run_employees join row itself.
|
||||
*
|
||||
* Draft-only (BFL 5 kap: once the run advances, its numbers feed a
|
||||
* verifikation). Line edits do NOT recompute tax/avgifter: call
|
||||
* POST /salary-runs/{id}/calculate afterwards.
|
||||
*/
|
||||
|
||||
import { z } from 'zod'
|
||||
import { created } from '@/lib/api/v1/response'
|
||||
import { dryRunPreview } from '@/lib/api/v1/dry-run'
|
||||
import { registerEndpoint, dataEnvelope } from '@/lib/api/v1/registry'
|
||||
import { withApiV1 } from '@/lib/api/v1/with-api-v1'
|
||||
import { v1ErrorResponseFromCode } from '@/lib/api/v1/errors'
|
||||
import { CreateSalaryLineItemSchema } from '@/lib/api/schemas'
|
||||
import { createPayslipLine } from '@/lib/salary/payslip-lines'
|
||||
|
||||
// The path resolves the employee; the body must not carry the join-row id.
|
||||
const CreateLineBody = CreateSalaryLineItemSchema.omit({ salary_run_employee_id: true })
|
||||
|
||||
const LineItemResponse = z.object({
|
||||
salary_line_item_id: z.string().uuid().nullable(),
|
||||
salary_run_employee_id: z.string().uuid(),
|
||||
item_type: z.string(),
|
||||
description: z.string(),
|
||||
quantity: z.number().nullable(),
|
||||
unit_price: z.number().nullable(),
|
||||
amount: z.number(),
|
||||
is_taxable: z.boolean(),
|
||||
is_avgift_basis: z.boolean(),
|
||||
is_vacation_basis: z.boolean(),
|
||||
is_gross_deduction: z.boolean(),
|
||||
is_net_deduction: z.boolean(),
|
||||
account_number: z.string().nullable(),
|
||||
sort_order: z.number(),
|
||||
})
|
||||
|
||||
registerEndpoint({
|
||||
operation: 'salary-runs.lines.create',
|
||||
method: 'POST',
|
||||
path: '/api/v1/companies/:companyId/salary-runs/:id/employees/:employeeId/lines',
|
||||
summary: 'Add a payslip line to an employee in a draft salary run.',
|
||||
description:
|
||||
'Creates a salary_line_items row (bonus, overtime, gross/net deduction, benefit, traktamente, ...) for one employee in a draft run. account_number auto-resolves from item_type when omitted. Amounts are rounded to whole öre.',
|
||||
useWhen:
|
||||
'You need to add a one-off pay component before calculating: a bonus, an expense reimbursement, a union fee, or a manual correction line.',
|
||||
doNotUseFor:
|
||||
'Editing the base monthly salary (PATCH the run-employee via the internal surface; not on v1 yet). Absence: register absence days instead (PUT /employees/{id}/absence); the engine derives sick/VAB lines itself.',
|
||||
pitfalls: [
|
||||
'Draft-only: returns 400 SALARY_RUN_LINE_NOT_DRAFT once the run has advanced.',
|
||||
'Line edits do not recompute tax or totals: call POST /salary-runs/{id}/calculate afterwards.',
|
||||
'Engine-derived lines (absence, benefits) are regenerated on every :calculate; manual lines survive.',
|
||||
],
|
||||
example: {
|
||||
request: {
|
||||
item_type: 'bonus',
|
||||
description: 'Kvartalsbonus Q2',
|
||||
amount: 5000,
|
||||
},
|
||||
response: {
|
||||
data: {
|
||||
salary_line_item_id: 'sli_31c9…',
|
||||
item_type: 'bonus',
|
||||
description: 'Kvartalsbonus Q2',
|
||||
amount: 5000,
|
||||
account_number: '7210',
|
||||
},
|
||||
meta: { request_id: 'req_…', api_version: '2026-05-12' },
|
||||
},
|
||||
},
|
||||
scope: 'payroll:write',
|
||||
risk: 'low',
|
||||
idempotent: true,
|
||||
reversible: true,
|
||||
dryRunSupported: true,
|
||||
request: { body: CreateLineBody },
|
||||
response: { success: dataEnvelope(LineItemResponse) },
|
||||
})
|
||||
|
||||
export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string; employeeId: string }> }>(
|
||||
'salary-runs.lines.create',
|
||||
async (request, ctx, params) => {
|
||||
const { id, employeeId } = await params.params
|
||||
const runParse = z.string().uuid().safeParse(id)
|
||||
const empParse = z.string().uuid().safeParse(employeeId)
|
||||
if (!runParse.success || !empParse.success) {
|
||||
return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, {
|
||||
requestId: ctx.requestId,
|
||||
details: {
|
||||
field: runParse.success ? 'employeeId' : 'id',
|
||||
message: 'Path ids must be UUIDs.',
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
let rawBody: unknown
|
||||
try {
|
||||
rawBody = await request.json()
|
||||
} catch {
|
||||
return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, {
|
||||
requestId: ctx.requestId,
|
||||
details: { field: 'body', message: 'Body is not valid JSON.' },
|
||||
})
|
||||
}
|
||||
|
||||
const parsed = CreateLineBody.safeParse(rawBody)
|
||||
if (!parsed.success) {
|
||||
return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, {
|
||||
requestId: ctx.requestId,
|
||||
details: {
|
||||
issues: parsed.error.issues.map((i) => ({
|
||||
field: i.path.join('.'),
|
||||
message: i.message,
|
||||
})),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const result = await createPayslipLine(ctx.supabase, {
|
||||
companyId: ctx.companyId!,
|
||||
salaryRunId: runParse.data,
|
||||
target: { employeeId: empParse.data },
|
||||
input: parsed.data,
|
||||
dryRun: ctx.dryRun,
|
||||
})
|
||||
|
||||
if (!result.ok) {
|
||||
return v1ErrorResponseFromCode(result.code, ctx.log, {
|
||||
requestId: ctx.requestId,
|
||||
details: result.details,
|
||||
})
|
||||
}
|
||||
|
||||
const { id: lineId, company_id: _companyId, ...rest } = result.data as Record<string, unknown> & {
|
||||
id: string | null
|
||||
company_id?: string
|
||||
}
|
||||
const payload = { salary_line_item_id: lineId, ...rest }
|
||||
|
||||
if (ctx.dryRun) {
|
||||
return dryRunPreview(payload, { requestId: ctx.requestId, log: ctx.log })
|
||||
}
|
||||
return created(payload, { requestId: ctx.requestId })
|
||||
},
|
||||
{ requireIdempotencyKey: true },
|
||||
)
|
||||
@@ -0,0 +1,301 @@
|
||||
/**
|
||||
* /api/v1/companies/{companyId}/salary-runs/{id}/employees/{employeeId}
|
||||
*
|
||||
* GET: one employee's full payslip within a salary run: the calculated
|
||||
* aggregates, every payslip line item, and the step-by-step
|
||||
* calculation_breakdown the engine recorded.
|
||||
*
|
||||
* GDPR Art.5(1)(c): personnummer stays MASKED here. A payslip is a pay
|
||||
* document, not an identity record; the deliberate identity drill-in is
|
||||
* GET /employees/{id}.
|
||||
*/
|
||||
|
||||
import { z } from 'zod'
|
||||
import { ok, noContent } from '@/lib/api/v1/response'
|
||||
import { dryRunPreview } from '@/lib/api/v1/dry-run'
|
||||
import { registerEndpoint, dataEnvelope, NoBodyResponse } from '@/lib/api/v1/registry'
|
||||
import { withApiV1 } from '@/lib/api/v1/with-api-v1'
|
||||
import { v1ErrorResponse, v1ErrorResponseFromCode } from '@/lib/api/v1/errors'
|
||||
import { maskPersonnummer } from '@/lib/api/v1/mask-personnummer'
|
||||
import { decryptPersonnummer } from '@/lib/salary/personnummer'
|
||||
import { removeEmployeeFromRun } from '@/lib/salary/run-employees'
|
||||
|
||||
const PayslipLineItem = z.object({
|
||||
/** Qualified id of the salary_line_items row. */
|
||||
salary_line_item_id: z.string().uuid(),
|
||||
item_type: z.string(),
|
||||
description: z.string(),
|
||||
quantity: z.number().nullable(),
|
||||
unit_price: z.number().nullable(),
|
||||
amount: z.number(),
|
||||
is_taxable: z.boolean(),
|
||||
is_avgift_basis: z.boolean(),
|
||||
is_vacation_basis: z.boolean(),
|
||||
is_gross_deduction: z.boolean(),
|
||||
is_net_deduction: z.boolean(),
|
||||
account_number: z.string().nullable(),
|
||||
sort_order: z.number(),
|
||||
})
|
||||
|
||||
const PayslipDetail = z.object({
|
||||
salary_run_employee_id: z.string().uuid(),
|
||||
salary_run_id: z.string().uuid(),
|
||||
employee_id: z.string().uuid(),
|
||||
first_name: z.string(),
|
||||
last_name: z.string(),
|
||||
personnummer_masked: z.string(),
|
||||
salary_type: z.string(),
|
||||
employment_degree: z.number(),
|
||||
monthly_salary: z.number().nullable(),
|
||||
hours_worked: z.number().nullable(),
|
||||
gross_salary: z.number(),
|
||||
gross_deductions: z.number(),
|
||||
benefit_values: z.number(),
|
||||
taxable_income: z.number(),
|
||||
tax_withheld: z.number(),
|
||||
tax_withheld_override: z.number().nullable(),
|
||||
net_deductions: z.number(),
|
||||
net_salary: z.number(),
|
||||
avgifter_rate: z.number(),
|
||||
avgifter_basis: z.number(),
|
||||
avgifter_amount: z.number(),
|
||||
avgifter_basis_override: z.number().nullable(),
|
||||
avgifter_amount_override: z.number().nullable(),
|
||||
avgifter_category: z.string().nullable(),
|
||||
override_reason: z.string().nullable(),
|
||||
vacation_accrual: z.number(),
|
||||
vacation_accrual_avgifter: z.number(),
|
||||
tax_table_number: z.number().nullable(),
|
||||
tax_column: z.number().nullable(),
|
||||
tax_table_year: z.number().nullable(),
|
||||
sick_days: z.number(),
|
||||
vab_days: z.number(),
|
||||
parental_days: z.number(),
|
||||
vacation_days_taken: z.number(),
|
||||
ytd_gross: z.number(),
|
||||
ytd_tax: z.number(),
|
||||
ytd_net: z.number(),
|
||||
/** Step-by-step engine breakdown; null until :calculate has run. */
|
||||
calculation_breakdown: z.unknown().nullable(),
|
||||
line_items: z.array(PayslipLineItem),
|
||||
created_at: z.string(),
|
||||
updated_at: z.string(),
|
||||
})
|
||||
|
||||
const PAYSLIP_DETAIL_COLUMNS =
|
||||
'id, salary_run_id, employee_id, salary_type, employment_degree, monthly_salary, hours_worked, ' +
|
||||
'gross_salary, gross_deductions, benefit_values, taxable_income, tax_withheld, tax_withheld_override, ' +
|
||||
'net_deductions, net_salary, avgifter_rate, avgifter_basis, avgifter_amount, avgifter_basis_override, ' +
|
||||
'avgifter_amount_override, avgifter_category, override_reason, vacation_accrual, vacation_accrual_avgifter, ' +
|
||||
'tax_table_number, tax_column, tax_table_year, sick_days, vab_days, parental_days, vacation_days_taken, ' +
|
||||
'ytd_gross, ytd_tax, ytd_net, calculation_breakdown, created_at, updated_at, ' +
|
||||
'employee:employees(first_name, last_name, personnummer), ' +
|
||||
'line_items:salary_line_items(id, item_type, description, quantity, unit_price, amount, is_taxable, is_avgift_basis, is_vacation_basis, is_gross_deduction, is_net_deduction, account_number, sort_order)'
|
||||
|
||||
registerEndpoint({
|
||||
operation: 'salary-runs.employees.get',
|
||||
method: 'GET',
|
||||
path: '/api/v1/companies/:companyId/salary-runs/:id/employees/:employeeId',
|
||||
summary: 'Get one employee\'s payslip in a salary run.',
|
||||
description:
|
||||
'Returns the full payslip for one employee in a run: gross/tax/net aggregates, arbetsgivaravgifter with category, vacation accrual, YTD accumulators, every payslip line item (grundlön, tillägg, avdrag, förmåner), and the step-by-step calculation_breakdown recorded by the engine.',
|
||||
useWhen:
|
||||
'You need to verify how a specific employee\'s pay was computed: reviewing a run before approval, answering "why is the tax this amount", or rendering a payslip in an external system.',
|
||||
doNotUseFor:
|
||||
'The rendered PDF payslip: use GET /salary-runs/{id}/payslips/{employeeId}/pdf. Editing line items: POST/PATCH/DELETE on the lines endpoints.',
|
||||
pitfalls: [
|
||||
'calculation_breakdown is null and aggregates are 0 until POST /calculate has run.',
|
||||
'line_items include engine-derived rows (absence, benefits) that are regenerated on every :calculate; manual rows survive recalculation.',
|
||||
'The effective tax is COALESCE(tax_withheld_override, tax_withheld); same for avgifter overrides.',
|
||||
'personnummer is masked here (GDPR Art.5(1)(c)); GET /employees/{id} is the identity drill-in.',
|
||||
],
|
||||
example: {
|
||||
response: {
|
||||
data: {
|
||||
salary_run_employee_id: 'sre_a8f1…',
|
||||
employee_id: 'emp_77b2…',
|
||||
first_name: 'Anna',
|
||||
last_name: 'Andersson',
|
||||
personnummer_masked: 'YYYYMMDDXXXX',
|
||||
gross_salary: 35000,
|
||||
tax_withheld: -8200,
|
||||
net_salary: 26800,
|
||||
line_items: [
|
||||
{
|
||||
salary_line_item_id: 'sli_31c9…',
|
||||
item_type: 'monthly_salary',
|
||||
description: 'Grundlön',
|
||||
amount: 35000,
|
||||
},
|
||||
],
|
||||
},
|
||||
meta: { request_id: 'req_…', api_version: '2026-05-12' },
|
||||
},
|
||||
},
|
||||
scope: 'payroll:read',
|
||||
risk: 'low',
|
||||
idempotent: true,
|
||||
reversible: false,
|
||||
dryRunSupported: false,
|
||||
response: { success: dataEnvelope(PayslipDetail) },
|
||||
})
|
||||
|
||||
export const GET = withApiV1<{ params: Promise<{ companyId: string; id: string; employeeId: string }> }>(
|
||||
'salary-runs.employees.get',
|
||||
async (_request, ctx, params) => {
|
||||
const { id, employeeId } = await params.params
|
||||
const runParse = z.string().uuid().safeParse(id)
|
||||
const empParse = z.string().uuid().safeParse(employeeId)
|
||||
if (!runParse.success || !empParse.success) {
|
||||
return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, {
|
||||
requestId: ctx.requestId,
|
||||
details: {
|
||||
field: runParse.success ? 'employeeId' : 'id',
|
||||
message: 'Path ids must be UUIDs.',
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const { data, error } = await ctx.supabase
|
||||
.from('salary_run_employees')
|
||||
.select(PAYSLIP_DETAIL_COLUMNS)
|
||||
.eq('company_id', ctx.companyId!)
|
||||
.eq('salary_run_id', runParse.data)
|
||||
.eq('employee_id', empParse.data)
|
||||
.maybeSingle()
|
||||
|
||||
if (error) {
|
||||
return v1ErrorResponse(error, ctx.log, { requestId: ctx.requestId })
|
||||
}
|
||||
if (!data) {
|
||||
// Distinguish "run missing" from "employee not in run" so agents get an
|
||||
// actionable 404 body either way.
|
||||
const { data: run } = await ctx.supabase
|
||||
.from('salary_runs')
|
||||
.select('id')
|
||||
.eq('company_id', ctx.companyId!)
|
||||
.eq('id', runParse.data)
|
||||
.maybeSingle()
|
||||
if (!run) {
|
||||
return v1ErrorResponseFromCode('SALARY_RUN_NOT_FOUND', ctx.log, { requestId: ctx.requestId })
|
||||
}
|
||||
return v1ErrorResponseFromCode('NOT_FOUND', ctx.log, {
|
||||
requestId: ctx.requestId,
|
||||
details: { resource: 'salary_run_employee', employee_id: empParse.data },
|
||||
})
|
||||
}
|
||||
|
||||
type Row = {
|
||||
id: string
|
||||
salary_run_id: string
|
||||
employee_id: string
|
||||
employee: { first_name: string; last_name: string; personnummer: string } | null
|
||||
line_items: Array<{
|
||||
id: string
|
||||
item_type: string
|
||||
description: string
|
||||
quantity: number | null
|
||||
unit_price: number | null
|
||||
amount: number
|
||||
is_taxable: boolean
|
||||
is_avgift_basis: boolean
|
||||
is_vacation_basis: boolean
|
||||
is_gross_deduction: boolean
|
||||
is_net_deduction: boolean
|
||||
account_number: string | null
|
||||
sort_order: number
|
||||
}>
|
||||
} & Record<string, unknown>
|
||||
|
||||
const row = data as unknown as Row
|
||||
|
||||
const { employee, line_items: lineItems, id: sreId, ...rest } = row
|
||||
|
||||
return ok(
|
||||
{
|
||||
...rest,
|
||||
salary_run_employee_id: sreId,
|
||||
first_name: employee?.first_name ?? '',
|
||||
last_name: employee?.last_name ?? '',
|
||||
personnummer_masked: employee
|
||||
? maskPersonnummer(decryptPersonnummer(employee.personnummer))
|
||||
: '',
|
||||
line_items: (lineItems ?? [])
|
||||
.slice()
|
||||
.sort((a, b) => a.sort_order - b.sort_order)
|
||||
.map(({ id: lineId, ...line }) => ({
|
||||
salary_line_item_id: lineId,
|
||||
...line,
|
||||
})),
|
||||
},
|
||||
{ requestId: ctx.requestId },
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────
|
||||
// DELETE: remove an employee from a draft run
|
||||
// ──────────────────────────────────────────────────────────────────
|
||||
|
||||
registerEndpoint({
|
||||
operation: 'salary-runs.employees.remove',
|
||||
method: 'DELETE',
|
||||
path: '/api/v1/companies/:companyId/salary-runs/:id/employees/:employeeId',
|
||||
summary: 'Remove an employee from a draft salary run.',
|
||||
description:
|
||||
'Detaches the employee from the run and cascades away their payslip line items. Draft-only. The employee master record is untouched: this only affects the run roster.',
|
||||
useWhen:
|
||||
'An employee should not be paid this period (unpaid leave the whole month, employment ended) but was auto-added when the run was created.',
|
||||
doNotUseFor:
|
||||
'Deactivating the employee entirely: DELETE /employees/{id} (soft-delete). Zero-salary months: keep them in the run with a 0 base instead if you want a nollkörning on record.',
|
||||
pitfalls: [
|
||||
'Draft-only: 400 SALARY_RUN_EMPLOYEES_NOT_DRAFT once the run has advanced.',
|
||||
'Cascade-deletes the employee\'s line items in this run, including manual ones.',
|
||||
'Re-attaching later retakes the pay snapshot from the employee master.',
|
||||
],
|
||||
example: { response: { data: null } },
|
||||
scope: 'payroll:write',
|
||||
risk: 'low',
|
||||
idempotent: true,
|
||||
reversible: true,
|
||||
dryRunSupported: true,
|
||||
response: { success: NoBodyResponse },
|
||||
})
|
||||
|
||||
export const DELETE = withApiV1<{ params: Promise<{ companyId: string; id: string; employeeId: string }> }>(
|
||||
'salary-runs.employees.remove',
|
||||
async (_request, ctx, params) => {
|
||||
const { id, employeeId } = await params.params
|
||||
const runParse = z.string().uuid().safeParse(id)
|
||||
const empParse = z.string().uuid().safeParse(employeeId)
|
||||
if (!runParse.success || !empParse.success) {
|
||||
return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, {
|
||||
requestId: ctx.requestId,
|
||||
details: {
|
||||
field: runParse.success ? 'employeeId' : 'id',
|
||||
message: 'Path ids must be UUIDs.',
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const result = await removeEmployeeFromRun(ctx.supabase, {
|
||||
companyId: ctx.companyId!,
|
||||
salaryRunId: runParse.data,
|
||||
employeeId: empParse.data,
|
||||
dryRun: ctx.dryRun,
|
||||
})
|
||||
|
||||
if (!result.ok) {
|
||||
return v1ErrorResponseFromCode(result.code, ctx.log, {
|
||||
requestId: ctx.requestId,
|
||||
details: result.details,
|
||||
})
|
||||
}
|
||||
|
||||
if (ctx.dryRun) {
|
||||
return dryRunPreview(result.data, { requestId: ctx.requestId, log: ctx.log })
|
||||
}
|
||||
return noContent({ requestId: ctx.requestId })
|
||||
},
|
||||
{ requireIdempotencyKey: true },
|
||||
)
|
||||
@@ -0,0 +1,550 @@
|
||||
/**
|
||||
* Tests for the v1 per-employee payslip reads (payroll gap-closure 1.1).
|
||||
*
|
||||
* GET /salary-runs/{id}/employees : list per-employee results
|
||||
* GET /salary-runs/{id}/employees/{empId} : payslip detail (line items + breakdown)
|
||||
*
|
||||
* Mirrors the employees-route test pattern: Proxy-backed Supabase mock with
|
||||
* per-table response queues.
|
||||
*/
|
||||
|
||||
import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
beforeAll(() => {
|
||||
if (process.env.NODE_ENV !== 'test') {
|
||||
throw new Error(
|
||||
`salary-run employees route tests require NODE_ENV=test (got ${process.env.NODE_ENV ?? 'undefined'})`,
|
||||
)
|
||||
}
|
||||
process.env.NEXT_PUBLIC_SUPABASE_URL ||= 'http://localhost:54321'
|
||||
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY ||= 'test-anon-key'
|
||||
})
|
||||
|
||||
vi.mock('@/lib/auth/api-keys', async () => {
|
||||
const actual = await vi.importActual<typeof import('@/lib/auth/api-keys')>('@/lib/auth/api-keys')
|
||||
return {
|
||||
...actual,
|
||||
validateApiKey: vi.fn(),
|
||||
createServiceClientNoCookies: vi.fn(),
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@supabase/supabase-js', async () => {
|
||||
const actual = await vi.importActual<typeof import('@supabase/supabase-js')>('@supabase/supabase-js')
|
||||
return { ...actual, createClient: vi.fn().mockReturnValue({}) }
|
||||
})
|
||||
|
||||
import { validateApiKey, createServiceClientNoCookies } from '@/lib/auth/api-keys'
|
||||
import { GET as listRunEmployees, POST as attachEmployee } from '../route'
|
||||
import { GET as getPayslip, DELETE as removeEmployee } from '../[employeeId]/route'
|
||||
|
||||
const mockValidate = validateApiKey as ReturnType<typeof vi.fn>
|
||||
const mockServiceClient = createServiceClientNoCookies as ReturnType<typeof vi.fn>
|
||||
|
||||
interface TableResp {
|
||||
data?: unknown
|
||||
error?: unknown
|
||||
}
|
||||
|
||||
function makeFlexibleSupabase(byTable: Record<string, TableResp | TableResp[]>) {
|
||||
const queues = new Map<string, TableResp[]>()
|
||||
for (const [t, val] of Object.entries(byTable)) {
|
||||
queues.set(t, Array.isArray(val) ? [...val] : [val])
|
||||
}
|
||||
const buildChain = (table: string): unknown => {
|
||||
const handler: ProxyHandler<object> = {
|
||||
get(_target, prop) {
|
||||
if (prop === 'then') {
|
||||
return (resolve: (v: unknown) => void) => {
|
||||
const q = queues.get(table)
|
||||
const next = q && q.length > 1 ? q.shift()! : (q?.[0] ?? { data: null, error: null })
|
||||
resolve(next)
|
||||
}
|
||||
}
|
||||
return (..._args: unknown[]) => buildChain(table)
|
||||
},
|
||||
}
|
||||
return new Proxy({}, handler)
|
||||
}
|
||||
return { from: vi.fn((table: string) => buildChain(table)) }
|
||||
}
|
||||
|
||||
const COMPANY_ID = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa'
|
||||
const RUN_ID = 'cccccccc-cccc-4ccc-8ccc-cccccccccccc'
|
||||
const EMPLOYEE_ID = 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb'
|
||||
const SRE_ID = 'dddddddd-dddd-4ddd-8ddd-dddddddddddd'
|
||||
const LINE_ID = 'eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeee'
|
||||
const USER_ID = 'user-1'
|
||||
|
||||
// Synthetic fixture personnummer (year 1900, zero suffix): must not look
|
||||
// like production-format PII.
|
||||
const SAMPLE_PERSONNUMMER = '190001010000'
|
||||
|
||||
function makeRequest(url: string): Request {
|
||||
return new Request(url, {
|
||||
headers: { Authorization: 'Bearer test-fixture-not-a-real-key' },
|
||||
})
|
||||
}
|
||||
|
||||
function listParams(companyId: string, id: string) {
|
||||
return { params: Promise.resolve({ companyId, id }) }
|
||||
}
|
||||
|
||||
function detailParams(companyId: string, id: string, employeeId: string) {
|
||||
return { params: Promise.resolve({ companyId, id, employeeId }) }
|
||||
}
|
||||
|
||||
const SAMPLE_SRE = {
|
||||
id: SRE_ID,
|
||||
salary_run_id: RUN_ID,
|
||||
employee_id: EMPLOYEE_ID,
|
||||
salary_type: 'monthly',
|
||||
employment_degree: 100,
|
||||
monthly_salary: 35000,
|
||||
hours_worked: null,
|
||||
gross_salary: 35000,
|
||||
gross_deductions: 0,
|
||||
benefit_values: 0,
|
||||
taxable_income: 35000,
|
||||
tax_withheld: 8200,
|
||||
tax_withheld_override: null,
|
||||
net_deductions: 0,
|
||||
net_salary: 26800,
|
||||
avgifter_rate: 0.3142,
|
||||
avgifter_basis: 35000,
|
||||
avgifter_amount: 10997,
|
||||
avgifter_basis_override: null,
|
||||
avgifter_amount_override: null,
|
||||
avgifter_category: 'standard',
|
||||
override_reason: null,
|
||||
vacation_accrual: 4200,
|
||||
vacation_accrual_avgifter: 1319.64,
|
||||
tax_table_number: 33,
|
||||
tax_column: 1,
|
||||
tax_table_year: 2026,
|
||||
sick_days: 0,
|
||||
vab_days: 0,
|
||||
parental_days: 0,
|
||||
vacation_days_taken: 0,
|
||||
ytd_gross: 70000,
|
||||
ytd_tax: 16400,
|
||||
ytd_net: 53600,
|
||||
calculation_breakdown: { steps: [{ label: 'Grundlön', formula: '35000 x 100%', output: 35000 }] },
|
||||
created_at: '2026-05-01T08:00:00Z',
|
||||
updated_at: '2026-05-01T08:00:00Z',
|
||||
employee: {
|
||||
first_name: 'Anna',
|
||||
last_name: 'Andersson',
|
||||
personnummer: SAMPLE_PERSONNUMMER,
|
||||
},
|
||||
line_items: [
|
||||
{
|
||||
id: LINE_ID,
|
||||
item_type: 'monthly_salary',
|
||||
description: 'Grundlön',
|
||||
quantity: null,
|
||||
unit_price: null,
|
||||
amount: 35000,
|
||||
is_taxable: true,
|
||||
is_avgift_basis: true,
|
||||
is_vacation_basis: true,
|
||||
is_gross_deduction: false,
|
||||
is_net_deduction: false,
|
||||
account_number: '7210',
|
||||
sort_order: 0,
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockValidate.mockResolvedValue({
|
||||
userId: USER_ID,
|
||||
companyId: COMPANY_ID,
|
||||
apiKeyId: 'ak_1',
|
||||
apiKeyName: 'CI key',
|
||||
scopes: ['payroll:read', 'payroll:write'],
|
||||
mode: 'live',
|
||||
})
|
||||
})
|
||||
|
||||
describe('GET /api/v1/companies/:companyId/salary-runs/:id/employees', () => {
|
||||
it('returns per-employee rows with masked personnummer and qualified ids', async () => {
|
||||
mockServiceClient.mockReturnValue(
|
||||
makeFlexibleSupabase({
|
||||
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
|
||||
salary_runs: { data: { id: RUN_ID }, error: null },
|
||||
salary_run_employees: { data: [SAMPLE_SRE], error: null },
|
||||
}),
|
||||
)
|
||||
|
||||
const res = await listRunEmployees(
|
||||
makeRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/salary-runs/${RUN_ID}/employees`),
|
||||
listParams(COMPANY_ID, RUN_ID),
|
||||
)
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
const body = await res.json()
|
||||
expect(body.data).toHaveLength(1)
|
||||
expect(body.data[0].salary_run_employee_id).toBe(SRE_ID)
|
||||
expect(body.data[0].employee_id).toBe(EMPLOYEE_ID)
|
||||
expect(body.data[0].gross_salary).toBe(35000)
|
||||
expect(body.data[0].net_salary).toBe(26800)
|
||||
// GDPR Art.5(1)(c): payslip-shaped responses always mask.
|
||||
expect(body.data[0].personnummer_masked).toBe('19000101XXXX')
|
||||
expect(JSON.stringify(body)).not.toContain(SAMPLE_PERSONNUMMER)
|
||||
// The list omits line items: the detail endpoint carries them.
|
||||
expect(body.data[0].line_items).toBeUndefined()
|
||||
// paginated() omits next_cursor from meta on the final page.
|
||||
expect(body.meta.next_cursor ?? null).toBeNull()
|
||||
})
|
||||
|
||||
it('returns 404 SALARY_RUN_NOT_FOUND when the run is missing', async () => {
|
||||
mockServiceClient.mockReturnValue(
|
||||
makeFlexibleSupabase({
|
||||
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
|
||||
salary_runs: { data: null, error: null },
|
||||
}),
|
||||
)
|
||||
const res = await listRunEmployees(
|
||||
makeRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/salary-runs/${RUN_ID}/employees`),
|
||||
listParams(COMPANY_ID, RUN_ID),
|
||||
)
|
||||
expect(res.status).toBe(404)
|
||||
const body = await res.json()
|
||||
expect(body.error.code).toBe('SALARY_RUN_NOT_FOUND')
|
||||
})
|
||||
|
||||
it('rejects a non-UUID run id with 400 VALIDATION_ERROR', async () => {
|
||||
mockServiceClient.mockReturnValue(
|
||||
makeFlexibleSupabase({
|
||||
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
|
||||
}),
|
||||
)
|
||||
const res = await listRunEmployees(
|
||||
makeRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/salary-runs/not-a-uuid/employees`),
|
||||
listParams(COMPANY_ID, 'not-a-uuid'),
|
||||
)
|
||||
expect(res.status).toBe(400)
|
||||
const body = await res.json()
|
||||
expect(body.error.code).toBe('VALIDATION_ERROR')
|
||||
})
|
||||
|
||||
it('rejects keys without payroll:read scope', async () => {
|
||||
mockValidate.mockResolvedValue({
|
||||
userId: USER_ID,
|
||||
companyId: COMPANY_ID,
|
||||
apiKeyId: 'ak_1',
|
||||
apiKeyName: 'wrong scope',
|
||||
scopes: ['invoices:read'],
|
||||
mode: 'live',
|
||||
})
|
||||
mockServiceClient.mockReturnValue(makeFlexibleSupabase({}))
|
||||
|
||||
const res = await listRunEmployees(
|
||||
makeRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/salary-runs/${RUN_ID}/employees`),
|
||||
listParams(COMPANY_ID, RUN_ID),
|
||||
)
|
||||
expect(res.status).toBe(403)
|
||||
const body = await res.json()
|
||||
expect(body.error.code).toBe('INSUFFICIENT_SCOPE')
|
||||
})
|
||||
|
||||
it('returns 401 without a bearer token', async () => {
|
||||
mockValidate.mockResolvedValue(null)
|
||||
mockServiceClient.mockReturnValue(makeFlexibleSupabase({}))
|
||||
|
||||
const res = await listRunEmployees(
|
||||
new Request(`https://x.test/api/v1/companies/${COMPANY_ID}/salary-runs/${RUN_ID}/employees`),
|
||||
listParams(COMPANY_ID, RUN_ID),
|
||||
)
|
||||
expect(res.status).toBe(401)
|
||||
})
|
||||
|
||||
it('emits a next_cursor when the page is full', async () => {
|
||||
// limit=1 with 2 rows returned (limit + 1 fetch convention).
|
||||
const second = {
|
||||
...SAMPLE_SRE,
|
||||
id: 'ffffffff-ffff-4fff-8fff-ffffffffffff',
|
||||
employee_id: '99999999-9999-4999-8999-999999999999',
|
||||
created_at: '2026-05-01T09:00:00Z',
|
||||
}
|
||||
mockServiceClient.mockReturnValue(
|
||||
makeFlexibleSupabase({
|
||||
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
|
||||
salary_runs: { data: { id: RUN_ID }, error: null },
|
||||
salary_run_employees: { data: [SAMPLE_SRE, second], error: null },
|
||||
}),
|
||||
)
|
||||
|
||||
const res = await listRunEmployees(
|
||||
makeRequest(
|
||||
`https://x.test/api/v1/companies/${COMPANY_ID}/salary-runs/${RUN_ID}/employees?limit=1`,
|
||||
),
|
||||
listParams(COMPANY_ID, RUN_ID),
|
||||
)
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
const body = await res.json()
|
||||
expect(body.data).toHaveLength(1)
|
||||
expect(body.meta.next_cursor).toBeTruthy()
|
||||
})
|
||||
})
|
||||
|
||||
describe('GET /api/v1/companies/:companyId/salary-runs/:id/employees/:employeeId', () => {
|
||||
it('returns the payslip detail with line items and calculation breakdown', async () => {
|
||||
mockServiceClient.mockReturnValue(
|
||||
makeFlexibleSupabase({
|
||||
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
|
||||
salary_run_employees: { data: SAMPLE_SRE, error: null },
|
||||
}),
|
||||
)
|
||||
|
||||
const res = await getPayslip(
|
||||
makeRequest(
|
||||
`https://x.test/api/v1/companies/${COMPANY_ID}/salary-runs/${RUN_ID}/employees/${EMPLOYEE_ID}`,
|
||||
),
|
||||
detailParams(COMPANY_ID, RUN_ID, EMPLOYEE_ID),
|
||||
)
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
const body = await res.json()
|
||||
expect(body.data.salary_run_employee_id).toBe(SRE_ID)
|
||||
expect(body.data.employee_id).toBe(EMPLOYEE_ID)
|
||||
expect(body.data.personnummer_masked).toBe('19000101XXXX')
|
||||
expect(body.data.line_items).toHaveLength(1)
|
||||
expect(body.data.line_items[0].salary_line_item_id).toBe(LINE_ID)
|
||||
expect(body.data.line_items[0].item_type).toBe('monthly_salary')
|
||||
expect(body.data.calculation_breakdown.steps).toHaveLength(1)
|
||||
// Raw personnummer never leaks; the raw line id is re-keyed to the
|
||||
// qualified name (no bare `id` fields in the payload).
|
||||
expect(JSON.stringify(body)).not.toContain(SAMPLE_PERSONNUMMER)
|
||||
expect(body.data.line_items[0].id).toBeUndefined()
|
||||
expect(body.data.id).toBeUndefined()
|
||||
})
|
||||
|
||||
it('returns 404 SALARY_RUN_NOT_FOUND when the run itself is missing', async () => {
|
||||
mockServiceClient.mockReturnValue(
|
||||
makeFlexibleSupabase({
|
||||
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
|
||||
salary_run_employees: { data: null, error: null },
|
||||
salary_runs: { data: null, error: null },
|
||||
}),
|
||||
)
|
||||
const res = await getPayslip(
|
||||
makeRequest(
|
||||
`https://x.test/api/v1/companies/${COMPANY_ID}/salary-runs/${RUN_ID}/employees/${EMPLOYEE_ID}`,
|
||||
),
|
||||
detailParams(COMPANY_ID, RUN_ID, EMPLOYEE_ID),
|
||||
)
|
||||
expect(res.status).toBe(404)
|
||||
const body = await res.json()
|
||||
expect(body.error.code).toBe('SALARY_RUN_NOT_FOUND')
|
||||
})
|
||||
|
||||
it('returns 404 NOT_FOUND when the run exists but the employee is not in it', async () => {
|
||||
mockServiceClient.mockReturnValue(
|
||||
makeFlexibleSupabase({
|
||||
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
|
||||
salary_run_employees: { data: null, error: null },
|
||||
salary_runs: { data: { id: RUN_ID }, error: null },
|
||||
}),
|
||||
)
|
||||
const res = await getPayslip(
|
||||
makeRequest(
|
||||
`https://x.test/api/v1/companies/${COMPANY_ID}/salary-runs/${RUN_ID}/employees/${EMPLOYEE_ID}`,
|
||||
),
|
||||
detailParams(COMPANY_ID, RUN_ID, EMPLOYEE_ID),
|
||||
)
|
||||
expect(res.status).toBe(404)
|
||||
const body = await res.json()
|
||||
expect(body.error.code).toBe('NOT_FOUND')
|
||||
expect(body.error.details.resource).toBe('salary_run_employee')
|
||||
})
|
||||
|
||||
it('rejects a non-UUID employee id with 400 VALIDATION_ERROR', async () => {
|
||||
mockServiceClient.mockReturnValue(
|
||||
makeFlexibleSupabase({
|
||||
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
|
||||
}),
|
||||
)
|
||||
const res = await getPayslip(
|
||||
makeRequest(
|
||||
`https://x.test/api/v1/companies/${COMPANY_ID}/salary-runs/${RUN_ID}/employees/nope`,
|
||||
),
|
||||
detailParams(COMPANY_ID, RUN_ID, 'nope'),
|
||||
)
|
||||
expect(res.status).toBe(400)
|
||||
const body = await res.json()
|
||||
expect(body.error.code).toBe('VALIDATION_ERROR')
|
||||
})
|
||||
})
|
||||
|
||||
describe('POST /api/v1/companies/:companyId/salary-runs/:id/employees', () => {
|
||||
const withIdempotency = (url: string, body: unknown): Request =>
|
||||
new Request(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: 'Bearer test-fixture-not-a-real-key',
|
||||
'Idempotency-Key': 'b1aaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa',
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
|
||||
const SAMPLE_EMPLOYEE_MASTER = {
|
||||
id: EMPLOYEE_ID,
|
||||
employment_degree: 100,
|
||||
monthly_salary: 35000,
|
||||
hourly_rate: null,
|
||||
salary_type: 'monthly',
|
||||
employment_type: 'employee',
|
||||
tax_table_number: 33,
|
||||
tax_column: 1,
|
||||
}
|
||||
|
||||
it('attaches an employee to a draft run (happy path, 201)', async () => {
|
||||
mockServiceClient.mockReturnValue(
|
||||
makeFlexibleSupabase({
|
||||
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
|
||||
salary_runs: { data: { id: RUN_ID, status: 'draft' }, error: null },
|
||||
employees: { data: SAMPLE_EMPLOYEE_MASTER, error: null },
|
||||
salary_run_employees: [
|
||||
{ data: null, error: null }, // duplicate check
|
||||
{
|
||||
data: {
|
||||
id: SRE_ID,
|
||||
salary_run_id: RUN_ID,
|
||||
employee_id: EMPLOYEE_ID,
|
||||
company_id: COMPANY_ID,
|
||||
employment_degree: 100,
|
||||
monthly_salary: 35000,
|
||||
salary_type: 'monthly',
|
||||
hours_worked: null,
|
||||
tax_table_number: 33,
|
||||
tax_column: 1,
|
||||
created_at: '2026-05-01T08:00:00Z',
|
||||
updated_at: '2026-05-01T08:00:00Z',
|
||||
},
|
||||
error: null,
|
||||
},
|
||||
],
|
||||
salary_line_items: { data: null, error: null },
|
||||
idempotency_keys: { data: null, error: null },
|
||||
}),
|
||||
)
|
||||
|
||||
const res = await attachEmployee(
|
||||
withIdempotency(
|
||||
`https://x.test/api/v1/companies/${COMPANY_ID}/salary-runs/${RUN_ID}/employees`,
|
||||
{ employee_id: EMPLOYEE_ID },
|
||||
),
|
||||
listParams(COMPANY_ID, RUN_ID),
|
||||
)
|
||||
|
||||
expect(res.status).toBe(201)
|
||||
const body = await res.json()
|
||||
expect(body.data.salary_run_employee_id).toBe(SRE_ID)
|
||||
expect(body.data.employee_id).toBe(EMPLOYEE_ID)
|
||||
expect(body.data.id).toBeUndefined()
|
||||
})
|
||||
|
||||
it('returns 409 SALARY_RUN_EMPLOYEE_DUPLICATE when already attached', async () => {
|
||||
mockServiceClient.mockReturnValue(
|
||||
makeFlexibleSupabase({
|
||||
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
|
||||
salary_runs: { data: { id: RUN_ID, status: 'draft' }, error: null },
|
||||
employees: { data: SAMPLE_EMPLOYEE_MASTER, error: null },
|
||||
salary_run_employees: { data: { id: SRE_ID }, error: null },
|
||||
idempotency_keys: { data: null, error: null },
|
||||
}),
|
||||
)
|
||||
|
||||
const res = await attachEmployee(
|
||||
withIdempotency(
|
||||
`https://x.test/api/v1/companies/${COMPANY_ID}/salary-runs/${RUN_ID}/employees`,
|
||||
{ employee_id: EMPLOYEE_ID },
|
||||
),
|
||||
listParams(COMPANY_ID, RUN_ID),
|
||||
)
|
||||
|
||||
expect(res.status).toBe(409)
|
||||
const body = await res.json()
|
||||
expect(body.error.code).toBe('SALARY_RUN_EMPLOYEE_DUPLICATE')
|
||||
})
|
||||
|
||||
it('returns 400 SALARY_RUN_EMPLOYEES_NOT_DRAFT once the run advanced', async () => {
|
||||
mockServiceClient.mockReturnValue(
|
||||
makeFlexibleSupabase({
|
||||
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
|
||||
salary_runs: { data: { id: RUN_ID, status: 'review' }, error: null },
|
||||
idempotency_keys: { data: null, error: null },
|
||||
}),
|
||||
)
|
||||
|
||||
const res = await attachEmployee(
|
||||
withIdempotency(
|
||||
`https://x.test/api/v1/companies/${COMPANY_ID}/salary-runs/${RUN_ID}/employees`,
|
||||
{ employee_id: EMPLOYEE_ID },
|
||||
),
|
||||
listParams(COMPANY_ID, RUN_ID),
|
||||
)
|
||||
|
||||
expect(res.status).toBe(400)
|
||||
const body = await res.json()
|
||||
expect(body.error.code).toBe('SALARY_RUN_EMPLOYEES_NOT_DRAFT')
|
||||
})
|
||||
})
|
||||
|
||||
describe('DELETE /api/v1/companies/:companyId/salary-runs/:id/employees/:employeeId', () => {
|
||||
const deleteRequest = (url: string): Request =>
|
||||
new Request(url, {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
Authorization: 'Bearer test-fixture-not-a-real-key',
|
||||
'Idempotency-Key': 'b2aaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa',
|
||||
},
|
||||
})
|
||||
|
||||
it('removes an attached employee (204)', async () => {
|
||||
mockServiceClient.mockReturnValue(
|
||||
makeFlexibleSupabase({
|
||||
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
|
||||
salary_runs: { data: { id: RUN_ID, status: 'draft' }, error: null },
|
||||
salary_run_employees: [
|
||||
{ data: { id: SRE_ID }, error: null },
|
||||
{ data: null, error: null },
|
||||
],
|
||||
idempotency_keys: { data: null, error: null },
|
||||
}),
|
||||
)
|
||||
|
||||
const res = await removeEmployee(
|
||||
deleteRequest(
|
||||
`https://x.test/api/v1/companies/${COMPANY_ID}/salary-runs/${RUN_ID}/employees/${EMPLOYEE_ID}`,
|
||||
),
|
||||
detailParams(COMPANY_ID, RUN_ID, EMPLOYEE_ID),
|
||||
)
|
||||
expect(res.status).toBe(204)
|
||||
})
|
||||
|
||||
it('returns 404 SALARY_RUN_EMPLOYEE_NOT_FOUND when not attached', async () => {
|
||||
mockServiceClient.mockReturnValue(
|
||||
makeFlexibleSupabase({
|
||||
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
|
||||
salary_runs: { data: { id: RUN_ID, status: 'draft' }, error: null },
|
||||
salary_run_employees: { data: null, error: null },
|
||||
idempotency_keys: { data: null, error: null },
|
||||
}),
|
||||
)
|
||||
|
||||
const res = await removeEmployee(
|
||||
deleteRequest(
|
||||
`https://x.test/api/v1/companies/${COMPANY_ID}/salary-runs/${RUN_ID}/employees/${EMPLOYEE_ID}`,
|
||||
),
|
||||
detailParams(COMPANY_ID, RUN_ID, EMPLOYEE_ID),
|
||||
)
|
||||
expect(res.status).toBe(404)
|
||||
const body = await res.json()
|
||||
expect(body.error.code).toBe('SALARY_RUN_EMPLOYEE_NOT_FOUND')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,355 @@
|
||||
/**
|
||||
* /api/v1/companies/{companyId}/salary-runs/{id}/employees
|
||||
*
|
||||
* GET: list the per-employee results of a salary run (one row per
|
||||
* salary_run_employee). Cursor pagination on (created_at ASC, id ASC).
|
||||
*
|
||||
* The row carries the calculated aggregates (gross/tax/net/avgifter/vacation)
|
||||
* but NOT the payslip line items: drill into
|
||||
* GET /salary-runs/{id}/employees/{employeeId} for those.
|
||||
*
|
||||
* GDPR Art.5(1)(c): personnummer is masked (birthdate visible, last-4 hidden)
|
||||
* on every payslip-shaped response. A payslip is a pay document, not an
|
||||
* identity record; the employee master detail endpoint is the deliberate
|
||||
* drill-in that returns the full value.
|
||||
*/
|
||||
|
||||
import { z } from 'zod'
|
||||
import { created, paginated } from '@/lib/api/v1/response'
|
||||
import { dryRunPreview } from '@/lib/api/v1/dry-run'
|
||||
import {
|
||||
decodeDefaultCursor,
|
||||
encodeDefaultCursor,
|
||||
parsePaginationParams,
|
||||
} from '@/lib/api/v1/pagination'
|
||||
import { registerEndpoint, listEnvelope, dataEnvelope } from '@/lib/api/v1/registry'
|
||||
import { withApiV1 } from '@/lib/api/v1/with-api-v1'
|
||||
import { v1ErrorResponse, v1ErrorResponseFromCode } from '@/lib/api/v1/errors'
|
||||
import { maskPersonnummer } from '@/lib/api/v1/mask-personnummer'
|
||||
import { decryptPersonnummer } from '@/lib/salary/personnummer'
|
||||
import { AddEmployeeToRunSchema } from '@/lib/api/schemas'
|
||||
import { addEmployeeToRun } from '@/lib/salary/run-employees'
|
||||
|
||||
const RunEmployeeSummary = z.object({
|
||||
/** Qualified id of the salary_run_employees join row (NOT the employee id). */
|
||||
salary_run_employee_id: z.string().uuid(),
|
||||
employee_id: z.string().uuid(),
|
||||
first_name: z.string(),
|
||||
last_name: z.string(),
|
||||
/** Masked: first 8 digits + 'XXXX' (birthdate visible, last-4 hidden). */
|
||||
personnummer_masked: z.string(),
|
||||
salary_type: z.string(),
|
||||
employment_degree: z.number(),
|
||||
monthly_salary: z.number().nullable(),
|
||||
hours_worked: z.number().nullable(),
|
||||
gross_salary: z.number(),
|
||||
taxable_income: z.number(),
|
||||
tax_withheld: z.number(),
|
||||
tax_withheld_override: z.number().nullable(),
|
||||
net_salary: z.number(),
|
||||
avgifter_basis: z.number(),
|
||||
avgifter_amount: z.number(),
|
||||
avgifter_amount_override: z.number().nullable(),
|
||||
avgifter_category: z.string().nullable(),
|
||||
vacation_accrual: z.number(),
|
||||
sick_days: z.number(),
|
||||
vab_days: z.number(),
|
||||
parental_days: z.number(),
|
||||
vacation_days_taken: z.number(),
|
||||
created_at: z.string(),
|
||||
updated_at: z.string(),
|
||||
})
|
||||
|
||||
// Explicit projection: never SELECT *. personnummer is loaded only to serve
|
||||
// the masked form; the full value never leaves this projection.
|
||||
const RUN_EMPLOYEE_SUMMARY_COLUMNS =
|
||||
'id, employee_id, salary_type, employment_degree, monthly_salary, hours_worked, ' +
|
||||
'gross_salary, taxable_income, tax_withheld, tax_withheld_override, net_salary, ' +
|
||||
'avgifter_basis, avgifter_amount, avgifter_amount_override, avgifter_category, ' +
|
||||
'vacation_accrual, sick_days, vab_days, parental_days, vacation_days_taken, ' +
|
||||
'created_at, updated_at, employee:employees(first_name, last_name, personnummer)'
|
||||
|
||||
registerEndpoint({
|
||||
operation: 'salary-runs.employees.list',
|
||||
method: 'GET',
|
||||
path: '/api/v1/companies/:companyId/salary-runs/:id/employees',
|
||||
summary: 'List per-employee results of a salary run.',
|
||||
description:
|
||||
'Returns one row per employee in the run with the calculated aggregates: gross salary, tax withheld, net pay, arbetsgivaravgifter, vacation accrual, and absence day counts. All aggregate fields are 0 until POST /calculate has run. Cursor pagination on (created_at, id).',
|
||||
useWhen:
|
||||
'You need the per-employee outcome of a run: to review before approval, to reconcile against an external system, or to pick an employee_id for the payslip drill-in.',
|
||||
doNotUseFor:
|
||||
'Payslip line items or the step-by-step calculation breakdown: use GET /salary-runs/{id}/employees/{employeeId}. The employee master record: use GET /employees/{id}.',
|
||||
pitfalls: [
|
||||
'Aggregates are 0 until POST /calculate has advanced the run to review.',
|
||||
'tax_withheld_override / avgifter_amount_override are review-stage manual adjustments; the effective value is COALESCE(override, calculated).',
|
||||
'personnummer is masked on all payslip-shaped responses (GDPR Art.5(1)(c)); the employee detail endpoint returns the full value.',
|
||||
],
|
||||
example: {
|
||||
response: {
|
||||
data: [
|
||||
{
|
||||
salary_run_employee_id: 'sre_a8f1…',
|
||||
employee_id: 'emp_77b2…',
|
||||
first_name: 'Anna',
|
||||
last_name: 'Andersson',
|
||||
personnummer_masked: 'YYYYMMDDXXXX',
|
||||
salary_type: 'monthly',
|
||||
gross_salary: 35000,
|
||||
tax_withheld: -8200,
|
||||
net_salary: 26800,
|
||||
avgifter_amount: 10997,
|
||||
},
|
||||
],
|
||||
meta: { request_id: 'req_…', api_version: '2026-05-12', next_cursor: null },
|
||||
},
|
||||
},
|
||||
scope: 'payroll:read',
|
||||
risk: 'low',
|
||||
idempotent: true,
|
||||
reversible: false,
|
||||
dryRunSupported: false,
|
||||
response: { success: listEnvelope(RunEmployeeSummary) },
|
||||
})
|
||||
|
||||
export const GET = withApiV1<{ params: Promise<{ companyId: string; id: string }> }>(
|
||||
'salary-runs.employees.list',
|
||||
async (request, ctx, params) => {
|
||||
const { id } = await params.params
|
||||
const idParse = z.string().uuid().safeParse(id)
|
||||
if (!idParse.success) {
|
||||
return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, {
|
||||
requestId: ctx.requestId,
|
||||
details: { field: 'id', message: 'Salary-run id must be a UUID.' },
|
||||
})
|
||||
}
|
||||
|
||||
// 404 the run itself first so an empty list unambiguously means
|
||||
// "run exists, no employees attached".
|
||||
const { data: run, error: runErr } = await ctx.supabase
|
||||
.from('salary_runs')
|
||||
.select('id')
|
||||
.eq('company_id', ctx.companyId!)
|
||||
.eq('id', idParse.data)
|
||||
.maybeSingle()
|
||||
if (runErr) {
|
||||
return v1ErrorResponse(runErr, ctx.log, { requestId: ctx.requestId })
|
||||
}
|
||||
if (!run) {
|
||||
return v1ErrorResponseFromCode('SALARY_RUN_NOT_FOUND', ctx.log, { requestId: ctx.requestId })
|
||||
}
|
||||
|
||||
const url = new URL(request.url)
|
||||
const { limit, cursor } = parsePaginationParams(url)
|
||||
const decoded = decodeDefaultCursor(cursor)
|
||||
|
||||
let query = ctx.supabase
|
||||
.from('salary_run_employees')
|
||||
.select(RUN_EMPLOYEE_SUMMARY_COLUMNS)
|
||||
.eq('company_id', ctx.companyId!)
|
||||
.eq('salary_run_id', idParse.data)
|
||||
.order('created_at', { ascending: true })
|
||||
.order('id', { ascending: true })
|
||||
.limit(limit + 1)
|
||||
|
||||
if (decoded) {
|
||||
query = query.or(
|
||||
`created_at.gt.${decoded.ts},and(created_at.eq.${decoded.ts},id.gt.${decoded.id})`,
|
||||
)
|
||||
}
|
||||
|
||||
const { data, error } = await query
|
||||
if (error) {
|
||||
return v1ErrorResponse(error, ctx.log, { requestId: ctx.requestId })
|
||||
}
|
||||
|
||||
type Row = {
|
||||
id: string
|
||||
employee_id: string
|
||||
salary_type: string
|
||||
employment_degree: number
|
||||
monthly_salary: number | null
|
||||
hours_worked: number | null
|
||||
gross_salary: number
|
||||
taxable_income: number
|
||||
tax_withheld: number
|
||||
tax_withheld_override: number | null
|
||||
net_salary: number
|
||||
avgifter_basis: number
|
||||
avgifter_amount: number
|
||||
avgifter_amount_override: number | null
|
||||
avgifter_category: string | null
|
||||
vacation_accrual: number
|
||||
sick_days: number
|
||||
vab_days: number
|
||||
parental_days: number
|
||||
vacation_days_taken: number
|
||||
created_at: string
|
||||
updated_at: string
|
||||
employee: { first_name: string; last_name: string; personnummer: string } | null
|
||||
}
|
||||
|
||||
const rows = ((data ?? []) as unknown) as Row[]
|
||||
const trimmed = rows.slice(0, limit)
|
||||
const hasMore = rows.length > limit
|
||||
|
||||
const items = trimmed.map((r) => ({
|
||||
salary_run_employee_id: r.id,
|
||||
employee_id: r.employee_id,
|
||||
first_name: r.employee?.first_name ?? '',
|
||||
last_name: r.employee?.last_name ?? '',
|
||||
personnummer_masked: r.employee
|
||||
? maskPersonnummer(decryptPersonnummer(r.employee.personnummer))
|
||||
: '',
|
||||
salary_type: r.salary_type,
|
||||
employment_degree: r.employment_degree,
|
||||
monthly_salary: r.monthly_salary,
|
||||
hours_worked: r.hours_worked,
|
||||
gross_salary: r.gross_salary,
|
||||
taxable_income: r.taxable_income,
|
||||
tax_withheld: r.tax_withheld,
|
||||
tax_withheld_override: r.tax_withheld_override,
|
||||
net_salary: r.net_salary,
|
||||
avgifter_basis: r.avgifter_basis,
|
||||
avgifter_amount: r.avgifter_amount,
|
||||
avgifter_amount_override: r.avgifter_amount_override,
|
||||
avgifter_category: r.avgifter_category,
|
||||
vacation_accrual: r.vacation_accrual,
|
||||
sick_days: r.sick_days,
|
||||
vab_days: r.vab_days,
|
||||
parental_days: r.parental_days,
|
||||
vacation_days_taken: r.vacation_days_taken,
|
||||
created_at: r.created_at,
|
||||
updated_at: r.updated_at,
|
||||
}))
|
||||
|
||||
const last = trimmed[trimmed.length - 1]
|
||||
const nextCursor = hasMore && last
|
||||
? encodeDefaultCursor({ id: last.id, created_at: last.created_at })
|
||||
: null
|
||||
|
||||
return paginated(items, {
|
||||
requestId: ctx.requestId,
|
||||
nextCursor: nextCursor ?? undefined,
|
||||
})
|
||||
},
|
||||
)
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────
|
||||
// POST: attach an employee to a draft run
|
||||
// ──────────────────────────────────────────────────────────────────
|
||||
|
||||
const RunEmployeeAttached = z.object({
|
||||
salary_run_employee_id: z.string().uuid().nullable(),
|
||||
employee_id: z.string().uuid(),
|
||||
salary_type: z.string(),
|
||||
employment_degree: z.number(),
|
||||
monthly_salary: z.number(),
|
||||
hours_worked: z.number().nullable(),
|
||||
tax_table_number: z.number().nullable(),
|
||||
tax_column: z.number().nullable(),
|
||||
})
|
||||
|
||||
registerEndpoint({
|
||||
operation: 'salary-runs.employees.add',
|
||||
method: 'POST',
|
||||
path: '/api/v1/companies/:companyId/salary-runs/:id/employees',
|
||||
summary: 'Add an employee to a draft salary run.',
|
||||
description:
|
||||
'Attaches an active employee to a draft run: snapshots their pay configuration (salary, degree, tax table) onto the run and seeds the base salary line (Grundlön/Timlön). For hourly employees, pass hours_worked.',
|
||||
useWhen:
|
||||
'The run was created without this employee (e.g. hired after the run was drafted), or you create runs empty and attach employees one by one from an external system.',
|
||||
doNotUseFor:
|
||||
'Changing an attached employee\'s pay for this month (internal per-run PATCH; not on v1). Re-attaching after removal is fine: the snapshot is retaken.',
|
||||
pitfalls: [
|
||||
'Draft-only: 400 SALARY_RUN_EMPLOYEES_NOT_DRAFT once the run has advanced.',
|
||||
'Attaching twice returns 409 SALARY_RUN_EMPLOYEE_DUPLICATE.',
|
||||
'The snapshot freezes salary/degree/tax-table at attach time: later employee edits do not flow into this run.',
|
||||
'Inactive (soft-deleted) employees cannot be attached: 404 EMPLOYEE_NOT_FOUND.',
|
||||
],
|
||||
example: {
|
||||
request: { employee_id: 'emp_77b2…' },
|
||||
response: {
|
||||
data: {
|
||||
salary_run_employee_id: 'sre_a8f1…',
|
||||
employee_id: 'emp_77b2…',
|
||||
salary_type: 'monthly',
|
||||
monthly_salary: 35000,
|
||||
},
|
||||
meta: { request_id: 'req_…', api_version: '2026-05-12' },
|
||||
},
|
||||
},
|
||||
scope: 'payroll:write',
|
||||
risk: 'low',
|
||||
idempotent: true,
|
||||
reversible: true,
|
||||
dryRunSupported: true,
|
||||
request: { body: AddEmployeeToRunSchema },
|
||||
response: { success: dataEnvelope(RunEmployeeAttached) },
|
||||
})
|
||||
|
||||
export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string }> }>(
|
||||
'salary-runs.employees.add',
|
||||
async (request, ctx, params) => {
|
||||
const { id } = await params.params
|
||||
const idParse = z.string().uuid().safeParse(id)
|
||||
if (!idParse.success) {
|
||||
return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, {
|
||||
requestId: ctx.requestId,
|
||||
details: { field: 'id', message: 'Salary-run id must be a UUID.' },
|
||||
})
|
||||
}
|
||||
|
||||
let rawBody: unknown
|
||||
try {
|
||||
rawBody = await request.json()
|
||||
} catch {
|
||||
return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, {
|
||||
requestId: ctx.requestId,
|
||||
details: { field: 'body', message: 'Body is not valid JSON.' },
|
||||
})
|
||||
}
|
||||
|
||||
const parsed = AddEmployeeToRunSchema.safeParse(rawBody)
|
||||
if (!parsed.success) {
|
||||
return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, {
|
||||
requestId: ctx.requestId,
|
||||
details: {
|
||||
issues: parsed.error.issues.map((i) => ({
|
||||
field: i.path.join('.'),
|
||||
message: i.message,
|
||||
})),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const result = await addEmployeeToRun(ctx.supabase, {
|
||||
companyId: ctx.companyId!,
|
||||
salaryRunId: idParse.data,
|
||||
employeeId: parsed.data.employee_id,
|
||||
hoursWorked: parsed.data.hours_worked ?? null,
|
||||
dryRun: ctx.dryRun,
|
||||
})
|
||||
|
||||
if (!result.ok) {
|
||||
return v1ErrorResponseFromCode(result.code, ctx.log, {
|
||||
requestId: ctx.requestId,
|
||||
details: result.details,
|
||||
})
|
||||
}
|
||||
|
||||
const { id: sreId, company_id: _companyId, salary_run_id: _runId, ...rest } =
|
||||
result.data as Record<string, unknown> & {
|
||||
id: string | null
|
||||
company_id?: string
|
||||
salary_run_id?: string
|
||||
}
|
||||
const payload = { salary_run_employee_id: sreId, ...rest }
|
||||
|
||||
if (ctx.dryRun) {
|
||||
return dryRunPreview(payload, { requestId: ctx.requestId, log: ctx.log })
|
||||
}
|
||||
return created(payload, { requestId: ctx.requestId })
|
||||
},
|
||||
{ requireIdempotencyKey: true },
|
||||
)
|
||||
@@ -0,0 +1,227 @@
|
||||
/**
|
||||
* /api/v1/companies/{companyId}/salary-runs/{id}/lines/{lineId}
|
||||
*
|
||||
* PATCH : update a payslip line (amount, description, quantity, ...) in a
|
||||
* DRAFT run.
|
||||
* DELETE : remove a payslip line from a DRAFT run.
|
||||
*
|
||||
* Both verify the line belongs to the given run (via its
|
||||
* salary_run_employees row) so a lineId from another run 404s instead of
|
||||
* silently mutating.
|
||||
*/
|
||||
|
||||
import { z } from 'zod'
|
||||
import { ok, noContent } from '@/lib/api/v1/response'
|
||||
import { dryRunPreview } from '@/lib/api/v1/dry-run'
|
||||
import { registerEndpoint, dataEnvelope, NoBodyResponse } from '@/lib/api/v1/registry'
|
||||
import { withApiV1 } from '@/lib/api/v1/with-api-v1'
|
||||
import { v1ErrorResponseFromCode } from '@/lib/api/v1/errors'
|
||||
import { UpdateSalaryLineItemSchema } from '@/lib/api/schemas'
|
||||
import { updatePayslipLine, deletePayslipLine } from '@/lib/salary/payslip-lines'
|
||||
|
||||
const LineItemResponse = z.object({
|
||||
salary_line_item_id: z.string().uuid(),
|
||||
salary_run_employee_id: z.string().uuid(),
|
||||
item_type: z.string(),
|
||||
description: z.string(),
|
||||
quantity: z.number().nullable(),
|
||||
unit_price: z.number().nullable(),
|
||||
amount: z.number(),
|
||||
is_taxable: z.boolean(),
|
||||
is_avgift_basis: z.boolean(),
|
||||
is_vacation_basis: z.boolean(),
|
||||
is_gross_deduction: z.boolean(),
|
||||
is_net_deduction: z.boolean(),
|
||||
account_number: z.string().nullable(),
|
||||
sort_order: z.number(),
|
||||
})
|
||||
|
||||
registerEndpoint({
|
||||
operation: 'salary-runs.lines.update',
|
||||
method: 'PATCH',
|
||||
path: '/api/v1/companies/:companyId/salary-runs/:id/lines/:lineId',
|
||||
summary: 'Update a payslip line in a draft salary run.',
|
||||
description:
|
||||
'Updates fields on a salary_line_items row (amount, description, quantity, unit_price, flags, account_number) while the run is a draft. Amounts are rounded to whole öre.',
|
||||
useWhen:
|
||||
'You spotted a wrong amount or description on a manual line before calculating: fix it in place instead of delete + recreate.',
|
||||
doNotUseFor:
|
||||
'Post-calculation tax/avgifter adjustments (review-stage overrides are not on v1). Engine-derived lines (absence/benefits): they are regenerated by :calculate, so edits are overwritten.',
|
||||
pitfalls: [
|
||||
'Draft-only: 400 SALARY_RUN_LINE_NOT_DRAFT once the run has advanced.',
|
||||
'A lineId that belongs to a different run returns 404 SALARY_LINE_NOT_FOUND.',
|
||||
'Line edits do not recompute tax or totals: call POST /salary-runs/{id}/calculate afterwards.',
|
||||
],
|
||||
example: {
|
||||
request: { amount: 5500 },
|
||||
response: {
|
||||
data: { salary_line_item_id: 'sli_31c9…', amount: 5500 },
|
||||
meta: { request_id: 'req_…', api_version: '2026-05-12' },
|
||||
},
|
||||
},
|
||||
scope: 'payroll:write',
|
||||
risk: 'low',
|
||||
idempotent: true,
|
||||
reversible: true,
|
||||
dryRunSupported: true,
|
||||
request: { body: UpdateSalaryLineItemSchema },
|
||||
response: { success: dataEnvelope(LineItemResponse) },
|
||||
})
|
||||
|
||||
function parsePathIds(id: string, lineId: string):
|
||||
| { ok: true; runId: string; lineId: string }
|
||||
| { ok: false; field: string } {
|
||||
const runParse = z.string().uuid().safeParse(id)
|
||||
if (!runParse.success) return { ok: false, field: 'id' }
|
||||
const lineParse = z.string().uuid().safeParse(lineId)
|
||||
if (!lineParse.success) return { ok: false, field: 'lineId' }
|
||||
return { ok: true, runId: runParse.data, lineId: lineParse.data }
|
||||
}
|
||||
|
||||
function toResponsePayload(row: Record<string, unknown>): Record<string, unknown> {
|
||||
const { id, company_id: _companyId, created_at: _c, updated_at: _u, ...rest } = row as {
|
||||
id: string
|
||||
company_id?: string
|
||||
created_at?: string
|
||||
updated_at?: string
|
||||
} & Record<string, unknown>
|
||||
return { salary_line_item_id: id, ...rest }
|
||||
}
|
||||
|
||||
export const PATCH = withApiV1<{ params: Promise<{ companyId: string; id: string; lineId: string }> }>(
|
||||
'salary-runs.lines.update',
|
||||
async (request, ctx, params) => {
|
||||
const { id, lineId } = await params.params
|
||||
const ids = parsePathIds(id, lineId)
|
||||
if (!ids.ok) {
|
||||
return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, {
|
||||
requestId: ctx.requestId,
|
||||
details: { field: ids.field, message: 'Path ids must be UUIDs.' },
|
||||
})
|
||||
}
|
||||
|
||||
let rawBody: unknown
|
||||
try {
|
||||
rawBody = await request.json()
|
||||
} catch {
|
||||
return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, {
|
||||
requestId: ctx.requestId,
|
||||
details: { field: 'body', message: 'Body is not valid JSON.' },
|
||||
})
|
||||
}
|
||||
|
||||
const parsed = UpdateSalaryLineItemSchema.safeParse(rawBody)
|
||||
if (!parsed.success) {
|
||||
return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, {
|
||||
requestId: ctx.requestId,
|
||||
details: {
|
||||
issues: parsed.error.issues.map((i) => ({
|
||||
field: i.path.join('.'),
|
||||
message: i.message,
|
||||
})),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// Zod .default() materializes flags the caller never sent; strip anything
|
||||
// not explicitly present so a PATCH can't silently reset flags. (Same
|
||||
// explicit-keys defense as the salary-runs PATCH.)
|
||||
const POLLUTING_KEYS = new Set(['__proto__', 'constructor', 'prototype'])
|
||||
const rawKeys =
|
||||
typeof rawBody === 'object' && rawBody !== null && !Array.isArray(rawBody)
|
||||
? Object.keys(rawBody).filter((k) => !POLLUTING_KEYS.has(k))
|
||||
: []
|
||||
const patch: Record<string, unknown> = {}
|
||||
for (const [key, value] of Object.entries(parsed.data) as Array<[string, unknown]>) {
|
||||
if (rawKeys.includes(key) && value !== undefined) {
|
||||
patch[key] = value
|
||||
}
|
||||
}
|
||||
if (Object.keys(patch).length === 0) {
|
||||
return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, {
|
||||
requestId: ctx.requestId,
|
||||
details: { field: 'body', message: 'At least one updatable field is required.' },
|
||||
})
|
||||
}
|
||||
|
||||
const result = await updatePayslipLine(ctx.supabase, {
|
||||
companyId: ctx.companyId!,
|
||||
salaryRunId: ids.runId,
|
||||
lineId: ids.lineId,
|
||||
patch,
|
||||
dryRun: ctx.dryRun,
|
||||
})
|
||||
|
||||
if (!result.ok) {
|
||||
return v1ErrorResponseFromCode(result.code, ctx.log, {
|
||||
requestId: ctx.requestId,
|
||||
details: result.details,
|
||||
})
|
||||
}
|
||||
|
||||
const payload = toResponsePayload(result.data as unknown as Record<string, unknown>)
|
||||
if (ctx.dryRun) {
|
||||
return dryRunPreview(payload, { requestId: ctx.requestId, log: ctx.log })
|
||||
}
|
||||
return ok(payload, { requestId: ctx.requestId })
|
||||
},
|
||||
{ requireIdempotencyKey: true },
|
||||
)
|
||||
|
||||
registerEndpoint({
|
||||
operation: 'salary-runs.lines.delete',
|
||||
method: 'DELETE',
|
||||
path: '/api/v1/companies/:companyId/salary-runs/:id/lines/:lineId',
|
||||
summary: 'Delete a payslip line from a draft salary run.',
|
||||
description:
|
||||
'Removes a salary_line_items row while the run is a draft. Engine-derived lines (absence, benefits) reappear on the next :calculate; delete the underlying absence/benefit record instead.',
|
||||
useWhen:
|
||||
'A manual line (bonus, deduction) was added by mistake and the run has not been calculated/advanced yet.',
|
||||
doNotUseFor:
|
||||
'Removing an employee from the run entirely: DELETE /salary-runs/{id}/employees/{employeeId}. Suppressing engine-derived lines: fix the source data (absence days, benefits).',
|
||||
pitfalls: [
|
||||
'Draft-only: 400 SALARY_RUN_LINE_NOT_DRAFT once the run has advanced.',
|
||||
'Deleting an engine-derived line is futile: :calculate regenerates it from source data.',
|
||||
],
|
||||
example: { response: { data: null } },
|
||||
scope: 'payroll:write',
|
||||
risk: 'low',
|
||||
idempotent: true,
|
||||
reversible: false,
|
||||
dryRunSupported: true,
|
||||
response: { success: NoBodyResponse },
|
||||
})
|
||||
|
||||
export const DELETE = withApiV1<{ params: Promise<{ companyId: string; id: string; lineId: string }> }>(
|
||||
'salary-runs.lines.delete',
|
||||
async (_request, ctx, params) => {
|
||||
const { id, lineId } = await params.params
|
||||
const ids = parsePathIds(id, lineId)
|
||||
if (!ids.ok) {
|
||||
return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, {
|
||||
requestId: ctx.requestId,
|
||||
details: { field: ids.field, message: 'Path ids must be UUIDs.' },
|
||||
})
|
||||
}
|
||||
|
||||
const result = await deletePayslipLine(ctx.supabase, {
|
||||
companyId: ctx.companyId!,
|
||||
salaryRunId: ids.runId,
|
||||
lineId: ids.lineId,
|
||||
dryRun: ctx.dryRun,
|
||||
})
|
||||
|
||||
if (!result.ok) {
|
||||
return v1ErrorResponseFromCode(result.code, ctx.log, {
|
||||
requestId: ctx.requestId,
|
||||
details: result.details,
|
||||
})
|
||||
}
|
||||
|
||||
if (ctx.dryRun) {
|
||||
return dryRunPreview(result.data, { requestId: ctx.requestId, log: ctx.log })
|
||||
}
|
||||
return noContent({ requestId: ctx.requestId })
|
||||
},
|
||||
{ requireIdempotencyKey: true },
|
||||
)
|
||||
@@ -0,0 +1,439 @@
|
||||
/**
|
||||
* Tests for the v1 payslip line writes (payroll gap-closure 1.2).
|
||||
*
|
||||
* POST /salary-runs/{id}/employees/{employeeId}/lines
|
||||
* PATCH /salary-runs/{id}/lines/{lineId}
|
||||
* DELETE /salary-runs/{id}/lines/{lineId}
|
||||
*/
|
||||
|
||||
import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
beforeAll(() => {
|
||||
if (process.env.NODE_ENV !== 'test') {
|
||||
throw new Error(
|
||||
`salary line route tests require NODE_ENV=test (got ${process.env.NODE_ENV ?? 'undefined'})`,
|
||||
)
|
||||
}
|
||||
process.env.NEXT_PUBLIC_SUPABASE_URL ||= 'http://localhost:54321'
|
||||
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY ||= 'test-anon-key'
|
||||
})
|
||||
|
||||
vi.mock('@/lib/auth/api-keys', async () => {
|
||||
const actual = await vi.importActual<typeof import('@/lib/auth/api-keys')>('@/lib/auth/api-keys')
|
||||
return {
|
||||
...actual,
|
||||
validateApiKey: vi.fn(),
|
||||
createServiceClientNoCookies: vi.fn(),
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@supabase/supabase-js', async () => {
|
||||
const actual = await vi.importActual<typeof import('@supabase/supabase-js')>('@supabase/supabase-js')
|
||||
return { ...actual, createClient: vi.fn().mockReturnValue({}) }
|
||||
})
|
||||
|
||||
import { validateApiKey, createServiceClientNoCookies } from '@/lib/auth/api-keys'
|
||||
import { POST as createLine } from '../../employees/[employeeId]/lines/route'
|
||||
import { PATCH as patchLine, DELETE as deleteLine } from '../[lineId]/route'
|
||||
|
||||
const mockValidate = validateApiKey as ReturnType<typeof vi.fn>
|
||||
const mockServiceClient = createServiceClientNoCookies as ReturnType<typeof vi.fn>
|
||||
|
||||
interface TableResp {
|
||||
data?: unknown
|
||||
error?: unknown
|
||||
}
|
||||
|
||||
function makeFlexibleSupabase(byTable: Record<string, TableResp | TableResp[]>) {
|
||||
const queues = new Map<string, TableResp[]>()
|
||||
for (const [t, val] of Object.entries(byTable)) {
|
||||
queues.set(t, Array.isArray(val) ? [...val] : [val])
|
||||
}
|
||||
const tableCalls: string[] = []
|
||||
const buildChain = (table: string): unknown => {
|
||||
const handler: ProxyHandler<object> = {
|
||||
get(_target, prop) {
|
||||
if (prop === 'then') {
|
||||
return (resolve: (v: unknown) => void) => {
|
||||
const q = queues.get(table)
|
||||
const next = q && q.length > 1 ? q.shift()! : (q?.[0] ?? { data: null, error: null })
|
||||
resolve(next)
|
||||
}
|
||||
}
|
||||
return (..._args: unknown[]) => buildChain(table)
|
||||
},
|
||||
}
|
||||
return new Proxy({}, handler)
|
||||
}
|
||||
return {
|
||||
tableCalls,
|
||||
from: vi.fn((table: string) => {
|
||||
tableCalls.push(table)
|
||||
return buildChain(table)
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
const COMPANY_ID = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa'
|
||||
const RUN_ID = 'cccccccc-cccc-4ccc-8ccc-cccccccccccc'
|
||||
const SRE_ID = 'dddddddd-dddd-4ddd-8ddd-dddddddddddd'
|
||||
const EMPLOYEE_ID = 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb'
|
||||
const LINE_ID = 'eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeee'
|
||||
const USER_ID = 'user-1'
|
||||
|
||||
const SAMPLE_LINE = {
|
||||
id: LINE_ID,
|
||||
salary_run_employee_id: SRE_ID,
|
||||
company_id: COMPANY_ID,
|
||||
item_type: 'bonus',
|
||||
description: 'Kvartalsbonus',
|
||||
quantity: null,
|
||||
unit_price: null,
|
||||
amount: 5000,
|
||||
is_taxable: true,
|
||||
is_avgift_basis: true,
|
||||
is_vacation_basis: true,
|
||||
is_gross_deduction: false,
|
||||
is_net_deduction: false,
|
||||
account_number: '7210',
|
||||
sort_order: 0,
|
||||
created_at: '2026-05-01T08:00:00Z',
|
||||
updated_at: '2026-05-01T08:00:00Z',
|
||||
}
|
||||
|
||||
function makeRequest(url: string, init?: RequestInit): Request {
|
||||
return new Request(url, {
|
||||
...init,
|
||||
headers: {
|
||||
Authorization: 'Bearer test-fixture-not-a-real-key',
|
||||
'Idempotency-Key': 'b1aaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa',
|
||||
...(init?.headers ?? {}),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function createParams(companyId: string, id: string, employeeId: string) {
|
||||
return { params: Promise.resolve({ companyId, id, employeeId }) }
|
||||
}
|
||||
|
||||
function lineParams(companyId: string, id: string, lineId: string) {
|
||||
return { params: Promise.resolve({ companyId, id, lineId }) }
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockValidate.mockResolvedValue({
|
||||
userId: USER_ID,
|
||||
companyId: COMPANY_ID,
|
||||
apiKeyId: 'ak_1',
|
||||
apiKeyName: 'CI key',
|
||||
scopes: ['payroll:read', 'payroll:write'],
|
||||
mode: 'live',
|
||||
})
|
||||
})
|
||||
|
||||
describe('POST /salary-runs/:id/employees/:employeeId/lines', () => {
|
||||
const validBody = { item_type: 'bonus', description: 'Kvartalsbonus', amount: 5000 }
|
||||
|
||||
it('creates a line and returns 201 with the qualified id (happy path)', async () => {
|
||||
mockServiceClient.mockReturnValue(
|
||||
makeFlexibleSupabase({
|
||||
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
|
||||
salary_runs: { data: { id: RUN_ID, status: 'draft' }, error: null },
|
||||
salary_run_employees: { data: { id: SRE_ID, employee_id: EMPLOYEE_ID }, error: null },
|
||||
salary_line_items: { data: SAMPLE_LINE, error: null },
|
||||
idempotency_keys: { data: null, error: null },
|
||||
}),
|
||||
)
|
||||
|
||||
const res = await createLine(
|
||||
makeRequest(
|
||||
`https://x.test/api/v1/companies/${COMPANY_ID}/salary-runs/${RUN_ID}/employees/${EMPLOYEE_ID}/lines`,
|
||||
{ method: 'POST', body: JSON.stringify(validBody) },
|
||||
),
|
||||
createParams(COMPANY_ID, RUN_ID, EMPLOYEE_ID),
|
||||
)
|
||||
|
||||
expect(res.status).toBe(201)
|
||||
const body = await res.json()
|
||||
expect(body.data.salary_line_item_id).toBe(LINE_ID)
|
||||
expect(body.data.item_type).toBe('bonus')
|
||||
expect(body.data.id).toBeUndefined()
|
||||
})
|
||||
|
||||
it('returns 400 SALARY_RUN_LINE_NOT_DRAFT when the run has advanced', async () => {
|
||||
mockServiceClient.mockReturnValue(
|
||||
makeFlexibleSupabase({
|
||||
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
|
||||
salary_runs: { data: { id: RUN_ID, status: 'review' }, error: null },
|
||||
idempotency_keys: { data: null, error: null },
|
||||
}),
|
||||
)
|
||||
|
||||
const res = await createLine(
|
||||
makeRequest(
|
||||
`https://x.test/api/v1/companies/${COMPANY_ID}/salary-runs/${RUN_ID}/employees/${EMPLOYEE_ID}/lines`,
|
||||
{ method: 'POST', body: JSON.stringify(validBody) },
|
||||
),
|
||||
createParams(COMPANY_ID, RUN_ID, EMPLOYEE_ID),
|
||||
)
|
||||
|
||||
expect(res.status).toBe(400)
|
||||
const body = await res.json()
|
||||
expect(body.error.code).toBe('SALARY_RUN_LINE_NOT_DRAFT')
|
||||
expect(body.error.details.current_status).toBe('review')
|
||||
})
|
||||
|
||||
it('returns 404 SALARY_RUN_EMPLOYEE_NOT_FOUND when the employee is not in the run', async () => {
|
||||
mockServiceClient.mockReturnValue(
|
||||
makeFlexibleSupabase({
|
||||
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
|
||||
salary_runs: { data: { id: RUN_ID, status: 'draft' }, error: null },
|
||||
salary_run_employees: { data: null, error: null },
|
||||
idempotency_keys: { data: null, error: null },
|
||||
}),
|
||||
)
|
||||
|
||||
const res = await createLine(
|
||||
makeRequest(
|
||||
`https://x.test/api/v1/companies/${COMPANY_ID}/salary-runs/${RUN_ID}/employees/${EMPLOYEE_ID}/lines`,
|
||||
{ method: 'POST', body: JSON.stringify(validBody) },
|
||||
),
|
||||
createParams(COMPANY_ID, RUN_ID, EMPLOYEE_ID),
|
||||
)
|
||||
|
||||
expect(res.status).toBe(404)
|
||||
const body = await res.json()
|
||||
expect(body.error.code).toBe('SALARY_RUN_EMPLOYEE_NOT_FOUND')
|
||||
})
|
||||
|
||||
it('rejects an unknown item_type with 400 VALIDATION_ERROR', async () => {
|
||||
mockServiceClient.mockReturnValue(
|
||||
makeFlexibleSupabase({
|
||||
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
|
||||
idempotency_keys: { data: null, error: null },
|
||||
}),
|
||||
)
|
||||
|
||||
const res = await createLine(
|
||||
makeRequest(
|
||||
`https://x.test/api/v1/companies/${COMPANY_ID}/salary-runs/${RUN_ID}/employees/${EMPLOYEE_ID}/lines`,
|
||||
{ method: 'POST', body: JSON.stringify({ ...validBody, item_type: 'space_travel' }) },
|
||||
),
|
||||
createParams(COMPANY_ID, RUN_ID, EMPLOYEE_ID),
|
||||
)
|
||||
|
||||
expect(res.status).toBe(400)
|
||||
const body = await res.json()
|
||||
expect(body.error.code).toBe('VALIDATION_ERROR')
|
||||
})
|
||||
|
||||
it('returns a dry-run preview without inserting', async () => {
|
||||
const supabaseMock = makeFlexibleSupabase({
|
||||
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
|
||||
salary_runs: { data: { id: RUN_ID, status: 'draft' }, error: null },
|
||||
salary_run_employees: { data: { id: SRE_ID, employee_id: EMPLOYEE_ID }, error: null },
|
||||
idempotency_keys: { data: null, error: null },
|
||||
})
|
||||
mockServiceClient.mockReturnValue(supabaseMock)
|
||||
|
||||
const res = await createLine(
|
||||
makeRequest(
|
||||
`https://x.test/api/v1/companies/${COMPANY_ID}/salary-runs/${RUN_ID}/employees/${EMPLOYEE_ID}/lines?dry_run=true`,
|
||||
{ method: 'POST', body: JSON.stringify({ ...validBody, amount: 1.005 }) },
|
||||
),
|
||||
createParams(COMPANY_ID, RUN_ID, EMPLOYEE_ID),
|
||||
)
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
expect(res.headers.get('X-Dry-Run')).toBe('true')
|
||||
const body = await res.json()
|
||||
expect(body.data.preview.salary_line_item_id).toBeNull()
|
||||
// roundOre: 1.005 -> 1.01 (the exact-half case naive rounding gets wrong).
|
||||
expect(body.data.preview.amount).toBe(1.01)
|
||||
expect(body.data.preview.account_number).toBe('7210')
|
||||
// No insert happened: salary_line_items was never touched.
|
||||
expect(supabaseMock.tableCalls).not.toContain('salary_line_items')
|
||||
})
|
||||
|
||||
it('returns 400 when Idempotency-Key is missing', async () => {
|
||||
mockServiceClient.mockReturnValue(
|
||||
makeFlexibleSupabase({
|
||||
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
|
||||
}),
|
||||
)
|
||||
|
||||
const req = new Request(
|
||||
`https://x.test/api/v1/companies/${COMPANY_ID}/salary-runs/${RUN_ID}/employees/${EMPLOYEE_ID}/lines`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { Authorization: 'Bearer test' },
|
||||
body: JSON.stringify(validBody),
|
||||
},
|
||||
)
|
||||
const res = await createLine(req, createParams(COMPANY_ID, RUN_ID, EMPLOYEE_ID))
|
||||
expect(res.status).toBe(400)
|
||||
})
|
||||
|
||||
it('rejects keys without payroll:write scope', async () => {
|
||||
mockValidate.mockResolvedValue({
|
||||
userId: USER_ID,
|
||||
companyId: COMPANY_ID,
|
||||
apiKeyId: 'ak_1',
|
||||
apiKeyName: 'read-only',
|
||||
scopes: ['payroll:read'],
|
||||
mode: 'live',
|
||||
})
|
||||
mockServiceClient.mockReturnValue(makeFlexibleSupabase({}))
|
||||
|
||||
const res = await createLine(
|
||||
makeRequest(
|
||||
`https://x.test/api/v1/companies/${COMPANY_ID}/salary-runs/${RUN_ID}/employees/${EMPLOYEE_ID}/lines`,
|
||||
{ method: 'POST', body: JSON.stringify(validBody) },
|
||||
),
|
||||
createParams(COMPANY_ID, RUN_ID, EMPLOYEE_ID),
|
||||
)
|
||||
expect(res.status).toBe(403)
|
||||
})
|
||||
|
||||
it('returns 401 without a bearer token', async () => {
|
||||
mockValidate.mockResolvedValue(null)
|
||||
mockServiceClient.mockReturnValue(makeFlexibleSupabase({}))
|
||||
|
||||
const res = await createLine(
|
||||
new Request(
|
||||
`https://x.test/api/v1/companies/${COMPANY_ID}/salary-runs/${RUN_ID}/employees/${EMPLOYEE_ID}/lines`,
|
||||
{ method: 'POST', body: JSON.stringify(validBody) },
|
||||
),
|
||||
createParams(COMPANY_ID, RUN_ID, EMPLOYEE_ID),
|
||||
)
|
||||
expect(res.status).toBe(401)
|
||||
})
|
||||
})
|
||||
|
||||
describe('PATCH /salary-runs/:id/lines/:lineId', () => {
|
||||
it('updates a line (happy path)', async () => {
|
||||
mockServiceClient.mockReturnValue(
|
||||
makeFlexibleSupabase({
|
||||
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
|
||||
salary_runs: { data: { id: RUN_ID, status: 'draft' }, error: null },
|
||||
salary_line_items: [
|
||||
{ data: { ...SAMPLE_LINE, salary_run_employee: { salary_run_id: RUN_ID } }, error: null },
|
||||
{ data: { ...SAMPLE_LINE, amount: 5500 }, error: null },
|
||||
],
|
||||
idempotency_keys: { data: null, error: null },
|
||||
}),
|
||||
)
|
||||
|
||||
const res = await patchLine(
|
||||
makeRequest(
|
||||
`https://x.test/api/v1/companies/${COMPANY_ID}/salary-runs/${RUN_ID}/lines/${LINE_ID}`,
|
||||
{ method: 'PATCH', body: JSON.stringify({ amount: 5500 }) },
|
||||
),
|
||||
lineParams(COMPANY_ID, RUN_ID, LINE_ID),
|
||||
)
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
const body = await res.json()
|
||||
expect(body.data.salary_line_item_id).toBe(LINE_ID)
|
||||
expect(body.data.amount).toBe(5500)
|
||||
})
|
||||
|
||||
it('returns 404 SALARY_LINE_NOT_FOUND for a line from another run', async () => {
|
||||
mockServiceClient.mockReturnValue(
|
||||
makeFlexibleSupabase({
|
||||
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
|
||||
salary_runs: { data: { id: RUN_ID, status: 'draft' }, error: null },
|
||||
salary_line_items: {
|
||||
data: {
|
||||
...SAMPLE_LINE,
|
||||
salary_run_employee: { salary_run_id: '99999999-9999-4999-8999-999999999999' },
|
||||
},
|
||||
error: null,
|
||||
},
|
||||
idempotency_keys: { data: null, error: null },
|
||||
}),
|
||||
)
|
||||
|
||||
const res = await patchLine(
|
||||
makeRequest(
|
||||
`https://x.test/api/v1/companies/${COMPANY_ID}/salary-runs/${RUN_ID}/lines/${LINE_ID}`,
|
||||
{ method: 'PATCH', body: JSON.stringify({ amount: 5500 }) },
|
||||
),
|
||||
lineParams(COMPANY_ID, RUN_ID, LINE_ID),
|
||||
)
|
||||
|
||||
expect(res.status).toBe(404)
|
||||
const body = await res.json()
|
||||
expect(body.error.code).toBe('SALARY_LINE_NOT_FOUND')
|
||||
})
|
||||
|
||||
it('rejects an empty patch with 400', async () => {
|
||||
mockServiceClient.mockReturnValue(
|
||||
makeFlexibleSupabase({
|
||||
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
|
||||
idempotency_keys: { data: null, error: null },
|
||||
}),
|
||||
)
|
||||
|
||||
const res = await patchLine(
|
||||
makeRequest(
|
||||
`https://x.test/api/v1/companies/${COMPANY_ID}/salary-runs/${RUN_ID}/lines/${LINE_ID}`,
|
||||
{ method: 'PATCH', body: JSON.stringify({}) },
|
||||
),
|
||||
lineParams(COMPANY_ID, RUN_ID, LINE_ID),
|
||||
)
|
||||
|
||||
expect(res.status).toBe(400)
|
||||
const body = await res.json()
|
||||
expect(body.error.code).toBe('VALIDATION_ERROR')
|
||||
})
|
||||
})
|
||||
|
||||
describe('DELETE /salary-runs/:id/lines/:lineId', () => {
|
||||
it('deletes a line and returns 204', async () => {
|
||||
mockServiceClient.mockReturnValue(
|
||||
makeFlexibleSupabase({
|
||||
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
|
||||
salary_runs: { data: { id: RUN_ID, status: 'draft' }, error: null },
|
||||
salary_line_items: [
|
||||
{ data: { ...SAMPLE_LINE, salary_run_employee: { salary_run_id: RUN_ID } }, error: null },
|
||||
{ data: null, error: null },
|
||||
],
|
||||
idempotency_keys: { data: null, error: null },
|
||||
}),
|
||||
)
|
||||
|
||||
const res = await deleteLine(
|
||||
makeRequest(
|
||||
`https://x.test/api/v1/companies/${COMPANY_ID}/salary-runs/${RUN_ID}/lines/${LINE_ID}`,
|
||||
{ method: 'DELETE' },
|
||||
),
|
||||
lineParams(COMPANY_ID, RUN_ID, LINE_ID),
|
||||
)
|
||||
|
||||
expect(res.status).toBe(204)
|
||||
})
|
||||
|
||||
it('returns 400 SALARY_RUN_LINE_NOT_DRAFT once the run has advanced', async () => {
|
||||
mockServiceClient.mockReturnValue(
|
||||
makeFlexibleSupabase({
|
||||
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
|
||||
salary_runs: { data: { id: RUN_ID, status: 'booked' }, error: null },
|
||||
idempotency_keys: { data: null, error: null },
|
||||
}),
|
||||
)
|
||||
|
||||
const res = await deleteLine(
|
||||
makeRequest(
|
||||
`https://x.test/api/v1/companies/${COMPANY_ID}/salary-runs/${RUN_ID}/lines/${LINE_ID}`,
|
||||
{ method: 'DELETE' },
|
||||
),
|
||||
lineParams(COMPANY_ID, RUN_ID, LINE_ID),
|
||||
)
|
||||
|
||||
expect(res.status).toBe(400)
|
||||
const body = await res.json()
|
||||
expect(body.error.code).toBe('SALARY_RUN_LINE_NOT_DRAFT')
|
||||
})
|
||||
})
|
||||
+133
@@ -0,0 +1,133 @@
|
||||
/**
|
||||
* ASVS V8.2.1: the payslip PDF endpoint returns full personnummer-bearing
|
||||
* payroll data, so the binding between the API key's user and the
|
||||
* `[companyId]` path segment must be enforced server-side BEFORE any payslip
|
||||
* data is read. The check lives in withApiV1 (company_members lookup); these
|
||||
* tests pin it to this concrete route so a wrapper regression or a future
|
||||
* unwrapped rewrite of the route fails loudly here.
|
||||
*
|
||||
* Deliberate convention: the deny case is 404 (not 403) so an unauthorized
|
||||
* caller cannot probe which company ids exist (see DECISIONS.md).
|
||||
*/
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
vi.mock('@/lib/auth/api-keys', async () => {
|
||||
const actual = await vi.importActual<typeof import('@/lib/auth/api-keys')>('@/lib/auth/api-keys')
|
||||
return {
|
||||
...actual,
|
||||
validateApiKey: vi.fn(),
|
||||
createServiceClientNoCookies: vi.fn(),
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/lib/api/idempotency', async () => {
|
||||
const actual = await vi.importActual<typeof import('@/lib/api/idempotency')>(
|
||||
'@/lib/api/idempotency',
|
||||
)
|
||||
return {
|
||||
...actual,
|
||||
checkIdempotencyKey: vi.fn(),
|
||||
storeIdempotencyResponse: vi.fn(),
|
||||
}
|
||||
})
|
||||
|
||||
// PDF rendering is irrelevant to the auth surface under test; keep the test
|
||||
// hermetic (no @react-pdf font/layout machinery).
|
||||
vi.mock('@react-pdf/renderer', () => ({ renderToBuffer: vi.fn() }))
|
||||
vi.mock('@/lib/salary/pdf/payslip-template', () => ({ PayslipPDF: vi.fn() }))
|
||||
vi.mock('@/lib/salary/payslips/build-payslip-data', () => ({
|
||||
buildPayslipData: vi.fn(),
|
||||
payslipFileName: vi.fn(() => 'payslip.pdf'),
|
||||
}))
|
||||
vi.mock('@/lib/company/context', () => ({ getCompanyDisplayName: vi.fn() }))
|
||||
|
||||
import { validateApiKey, createServiceClientNoCookies } from '@/lib/auth/api-keys'
|
||||
import { GET } from '../route'
|
||||
|
||||
const mockValidate = validateApiKey as ReturnType<typeof vi.fn>
|
||||
const mockServiceClient = createServiceClientNoCookies as ReturnType<typeof vi.fn>
|
||||
|
||||
const COMPANY_A = '11111111-1111-4111-8111-111111111111'
|
||||
const RUN_ID = '22222222-2222-4222-8222-222222222222'
|
||||
const EMPLOYEE_ID = '33333333-3333-4333-8333-333333333333'
|
||||
|
||||
function makeSupabaseStub(membership: { company_id: string; role: string } | null) {
|
||||
const from = vi.fn().mockReturnValue({
|
||||
select: vi.fn().mockReturnValue({
|
||||
eq: vi.fn().mockReturnValue({
|
||||
eq: vi.fn().mockReturnValue({
|
||||
maybeSingle: vi.fn().mockResolvedValue({ data: membership, error: null }),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
})
|
||||
return { from }
|
||||
}
|
||||
|
||||
function makeRequest(companyId: string, init?: RequestInit) {
|
||||
return new Request(
|
||||
`https://x.test/api/v1/companies/${companyId}/salary-runs/${RUN_ID}/payslips/${EMPLOYEE_ID}/pdf`,
|
||||
init,
|
||||
)
|
||||
}
|
||||
|
||||
function makeParams(companyId: string, id: string = RUN_ID, employeeId: string = EMPLOYEE_ID) {
|
||||
return { params: Promise.resolve({ companyId, id, employeeId }) }
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('GET /api/v1/companies/[companyId]/salary-runs/[id]/payslips/[employeeId]/pdf', () => {
|
||||
it('returns 401 without a bearer token', async () => {
|
||||
const res = await GET(makeRequest(COMPANY_A), makeParams(COMPANY_A))
|
||||
expect(res.status).toBe(401)
|
||||
const body = await res.json()
|
||||
expect(body.error.code).toBe('UNAUTHORIZED')
|
||||
})
|
||||
|
||||
it('returns 404 and reads no payslip data when the key user is not a member of the URL company', async () => {
|
||||
mockValidate.mockResolvedValue({
|
||||
userId: 'user-1',
|
||||
apiKeyId: 'key-1',
|
||||
scopes: ['payroll:read'],
|
||||
mode: 'live',
|
||||
})
|
||||
const stub = makeSupabaseStub(null) // no membership in the URL company
|
||||
mockServiceClient.mockReturnValue(stub)
|
||||
|
||||
const res = await GET(
|
||||
makeRequest(COMPANY_A, { headers: { Authorization: 'Bearer gnubok_sk_x' } }),
|
||||
makeParams(COMPANY_A),
|
||||
)
|
||||
|
||||
expect(res.status).toBe(404)
|
||||
const body = await res.json()
|
||||
expect(body.error.code).toBe('NOT_FOUND')
|
||||
// The deny fires in the wrapper: the handler's salary_runs /
|
||||
// salary_run_employees / companies queries must never have run.
|
||||
expect(stub.from.mock.calls.map((c) => c[0])).toEqual(['company_members'])
|
||||
})
|
||||
|
||||
it('rejects non-UUID path ids with 400 before touching payroll tables', async () => {
|
||||
mockValidate.mockResolvedValue({
|
||||
userId: 'user-1',
|
||||
apiKeyId: 'key-1',
|
||||
scopes: ['payroll:read'],
|
||||
mode: 'live',
|
||||
})
|
||||
const stub = makeSupabaseStub({ company_id: COMPANY_A, role: 'owner' })
|
||||
mockServiceClient.mockReturnValue(stub)
|
||||
|
||||
const res = await GET(
|
||||
makeRequest(COMPANY_A, { headers: { Authorization: 'Bearer gnubok_sk_x' } }),
|
||||
makeParams(COMPANY_A, 'not-a-uuid'),
|
||||
)
|
||||
|
||||
expect(res.status).toBe(400)
|
||||
const body = await res.json()
|
||||
expect(body.error.code).toBe('VALIDATION_ERROR')
|
||||
expect(stub.from.mock.calls.map((c) => c[0])).toEqual(['company_members'])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,153 @@
|
||||
/**
|
||||
* GET /api/v1/companies/{companyId}/salary-runs/{id}/payslips/{employeeId}/pdf
|
||||
*
|
||||
* Render one employee's payslip as application/pdf. Byte-equivalent to the
|
||||
* dashboard download: data assembly is shared via
|
||||
* lib/salary/payslips/build-payslip-data.
|
||||
*
|
||||
* Per BFL: payslips are räkenskapsinformation linked to posted journal
|
||||
* entries (7-year retention). Read-only: no Idempotency-Key, no dry-run.
|
||||
*/
|
||||
|
||||
import { z } from 'zod'
|
||||
import { renderToBuffer } from '@react-pdf/renderer'
|
||||
import { PayslipPDF } from '@/lib/salary/pdf/payslip-template'
|
||||
import { buildPayslipData, payslipFileName } from '@/lib/salary/payslips/build-payslip-data'
|
||||
import { contentDisposition } from '@/lib/api/content-disposition'
|
||||
import { getCompanyDisplayName } from '@/lib/company/context'
|
||||
import { registerEndpoint } from '@/lib/api/v1/registry'
|
||||
import { withApiV1 } from '@/lib/api/v1/with-api-v1'
|
||||
import { v1ErrorResponse, v1ErrorResponseFromCode } from '@/lib/api/v1/errors'
|
||||
|
||||
registerEndpoint({
|
||||
operation: 'salary-runs.payslip.pdf',
|
||||
method: 'GET',
|
||||
path: '/api/v1/companies/:companyId/salary-runs/:id/payslips/:employeeId/pdf',
|
||||
summary: 'Download one employee\'s payslip as PDF.',
|
||||
description:
|
||||
'Returns the rendered payslip (lönespecifikation) as application/pdf, byte-equivalent to the dashboard download. Content-Disposition is attachment with a filename derived from the period and employee name.',
|
||||
useWhen:
|
||||
'You need the payslip document itself: archiving, forwarding to the employee outside the Accounted send flow, or attaching to an external HR system.',
|
||||
doNotUseFor:
|
||||
'The payslip DATA (amounts, line items): use GET /salary-runs/{id}/employees/{employeeId}, which is cheaper and structured. Emailing payslips to employees: the send flow is internal-only today.',
|
||||
pitfalls: [
|
||||
'The PDF renders whatever the run currently holds: for a draft run that has not been calculated, amounts are 0.',
|
||||
'PDF rendering takes a few hundred milliseconds; cache on the client if requesting repeatedly.',
|
||||
],
|
||||
example: {
|
||||
response: {
|
||||
_note: 'Returns application/pdf binary stream.',
|
||||
},
|
||||
},
|
||||
scope: 'payroll:read',
|
||||
risk: 'low',
|
||||
idempotent: true,
|
||||
reversible: false,
|
||||
dryRunSupported: false,
|
||||
response: {
|
||||
success: z.unknown(), // Marker: binary response, see contentType.
|
||||
contentType: 'application/pdf',
|
||||
},
|
||||
})
|
||||
|
||||
export const GET = withApiV1<{ params: Promise<{ companyId: string; id: string; employeeId: string }> }>(
|
||||
'salary-runs.payslip.pdf',
|
||||
async (_request, ctx, params) => {
|
||||
const { id, employeeId } = await params.params
|
||||
const runParse = z.string().uuid().safeParse(id)
|
||||
const empParse = z.string().uuid().safeParse(employeeId)
|
||||
if (!runParse.success || !empParse.success) {
|
||||
return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, {
|
||||
requestId: ctx.requestId,
|
||||
details: {
|
||||
field: runParse.success ? 'employeeId' : 'id',
|
||||
message: 'Path ids must be UUIDs.',
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const { data: run, error: runErr } = await ctx.supabase
|
||||
.from('salary_runs')
|
||||
.select('*')
|
||||
.eq('id', runParse.data)
|
||||
.eq('company_id', ctx.companyId!)
|
||||
.maybeSingle()
|
||||
if (runErr) {
|
||||
return v1ErrorResponse(runErr, ctx.log, { requestId: ctx.requestId })
|
||||
}
|
||||
if (!run) {
|
||||
return v1ErrorResponseFromCode('SALARY_RUN_NOT_FOUND', ctx.log, { requestId: ctx.requestId })
|
||||
}
|
||||
|
||||
const { data: sre, error: sreErr } = await ctx.supabase
|
||||
.from('salary_run_employees')
|
||||
.select(
|
||||
'*, employee:employees(first_name, last_name, personnummer, personnummer_last4, employment_type, tax_table_number, tax_column, clearing_number, bank_account_number), line_items:salary_line_items(*)',
|
||||
)
|
||||
.eq('salary_run_id', runParse.data)
|
||||
.eq('employee_id', empParse.data)
|
||||
.eq('company_id', ctx.companyId!)
|
||||
.maybeSingle()
|
||||
if (sreErr) {
|
||||
return v1ErrorResponse(sreErr, ctx.log, { requestId: ctx.requestId })
|
||||
}
|
||||
if (!sre) {
|
||||
return v1ErrorResponseFromCode('NOT_FOUND', ctx.log, {
|
||||
requestId: ctx.requestId,
|
||||
details: { resource: 'salary_run_employee', employee_id: empParse.data },
|
||||
})
|
||||
}
|
||||
|
||||
const { data: company, error: companyErr } = await ctx.supabase
|
||||
.from('companies')
|
||||
.select('name, org_number')
|
||||
.eq('id', ctx.companyId!)
|
||||
.maybeSingle()
|
||||
if (companyErr || !company) {
|
||||
return v1ErrorResponseFromCode('NOT_FOUND', ctx.log, {
|
||||
requestId: ctx.requestId,
|
||||
details: { resource: 'company' },
|
||||
})
|
||||
}
|
||||
|
||||
const emp = sre.employee as {
|
||||
first_name: string; last_name: string; personnummer: string; personnummer_last4: string;
|
||||
employment_type: string; tax_table_number: number | null; tax_column: number;
|
||||
clearing_number: string | null; bank_account_number: string | null;
|
||||
}
|
||||
|
||||
let pdfBuffer: Buffer
|
||||
let fileName: string
|
||||
try {
|
||||
const displayName = await getCompanyDisplayName(ctx.supabase, ctx.companyId!)
|
||||
const data = buildPayslipData({
|
||||
run,
|
||||
sre,
|
||||
employee: emp,
|
||||
company: { name: displayName ?? company.name, org_number: company.org_number },
|
||||
})
|
||||
fileName = payslipFileName(run, emp)
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
pdfBuffer = await renderToBuffer(PayslipPDF({ data }) as any)
|
||||
} catch (err) {
|
||||
ctx.log.error('salary-runs.payslip.pdf: render failed', err as Error, {
|
||||
salaryRunId: runParse.data,
|
||||
companyId: ctx.companyId,
|
||||
})
|
||||
return v1ErrorResponseFromCode('INTERNAL_ERROR', ctx.log, { requestId: ctx.requestId })
|
||||
}
|
||||
|
||||
const uint8Array = new Uint8Array(pdfBuffer)
|
||||
return new Response(uint8Array, {
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': 'application/pdf',
|
||||
// RFC 5987 dual form: employee names with non-Latin-1 characters
|
||||
// would otherwise make undici reject the header value.
|
||||
'Content-Disposition': contentDisposition('attachment', fileName),
|
||||
'Content-Length': String(pdfBuffer.length),
|
||||
'X-Request-Id': ctx.requestId,
|
||||
},
|
||||
})
|
||||
},
|
||||
)
|
||||
+279
@@ -0,0 +1,279 @@
|
||||
/**
|
||||
* Tests for GET /api/v1/companies/{companyId}/salary-runs/{id}/payslips/{employeeId}/pdf
|
||||
* (payroll gap-closure 1.1).
|
||||
*
|
||||
* renderToBuffer is mocked (the invoice-pdf test pattern): these tests assert
|
||||
* routing, auth, 404 paths, and the binary response headers, not PDF pixels.
|
||||
*/
|
||||
|
||||
import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
beforeAll(() => {
|
||||
if (process.env.NODE_ENV !== 'test') {
|
||||
throw new Error(
|
||||
`payslip pdf route tests require NODE_ENV=test (got ${process.env.NODE_ENV ?? 'undefined'})`,
|
||||
)
|
||||
}
|
||||
process.env.NEXT_PUBLIC_SUPABASE_URL ||= 'http://localhost:54321'
|
||||
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY ||= 'test-anon-key'
|
||||
})
|
||||
|
||||
vi.mock('@/lib/auth/api-keys', async () => {
|
||||
const actual = await vi.importActual<typeof import('@/lib/auth/api-keys')>('@/lib/auth/api-keys')
|
||||
return {
|
||||
...actual,
|
||||
validateApiKey: vi.fn(),
|
||||
createServiceClientNoCookies: vi.fn(),
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@supabase/supabase-js', async () => {
|
||||
const actual = await vi.importActual<typeof import('@supabase/supabase-js')>('@supabase/supabase-js')
|
||||
return { ...actual, createClient: vi.fn().mockReturnValue({}) }
|
||||
})
|
||||
|
||||
vi.mock('@react-pdf/renderer', () => ({
|
||||
renderToBuffer: vi.fn().mockResolvedValue(Buffer.from('%PDF-1.7 test')),
|
||||
// The payslip template imports these primitives at module scope.
|
||||
Document: () => null,
|
||||
Page: () => null,
|
||||
Text: () => null,
|
||||
View: () => null,
|
||||
StyleSheet: { create: (s: unknown) => s },
|
||||
Font: { register: () => undefined },
|
||||
}))
|
||||
|
||||
import { validateApiKey, createServiceClientNoCookies } from '@/lib/auth/api-keys'
|
||||
import { renderToBuffer } from '@react-pdf/renderer'
|
||||
import { GET as getPayslipPdf } from '../[employeeId]/pdf/route'
|
||||
|
||||
const mockValidate = validateApiKey as ReturnType<typeof vi.fn>
|
||||
const mockServiceClient = createServiceClientNoCookies as ReturnType<typeof vi.fn>
|
||||
const mockRender = renderToBuffer as ReturnType<typeof vi.fn>
|
||||
|
||||
interface TableResp {
|
||||
data?: unknown
|
||||
error?: unknown
|
||||
}
|
||||
|
||||
function makeFlexibleSupabase(byTable: Record<string, TableResp | TableResp[]>) {
|
||||
const queues = new Map<string, TableResp[]>()
|
||||
for (const [t, val] of Object.entries(byTable)) {
|
||||
queues.set(t, Array.isArray(val) ? [...val] : [val])
|
||||
}
|
||||
const buildChain = (table: string): unknown => {
|
||||
const handler: ProxyHandler<object> = {
|
||||
get(_target, prop) {
|
||||
if (prop === 'then') {
|
||||
return (resolve: (v: unknown) => void) => {
|
||||
const q = queues.get(table)
|
||||
const next = q && q.length > 1 ? q.shift()! : (q?.[0] ?? { data: null, error: null })
|
||||
resolve(next)
|
||||
}
|
||||
}
|
||||
return (..._args: unknown[]) => buildChain(table)
|
||||
},
|
||||
}
|
||||
return new Proxy({}, handler)
|
||||
}
|
||||
return { from: vi.fn((table: string) => buildChain(table)) }
|
||||
}
|
||||
|
||||
const COMPANY_ID = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa'
|
||||
const RUN_ID = 'cccccccc-cccc-4ccc-8ccc-cccccccccccc'
|
||||
const EMPLOYEE_ID = 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb'
|
||||
const USER_ID = 'user-1'
|
||||
|
||||
const SAMPLE_PERSONNUMMER = '190001010000'
|
||||
|
||||
const SAMPLE_RUN = {
|
||||
id: RUN_ID,
|
||||
period_year: 2026,
|
||||
period_month: 5,
|
||||
payment_date: '2026-05-25',
|
||||
status: 'booked',
|
||||
}
|
||||
|
||||
const SAMPLE_SRE = {
|
||||
id: 'dddddddd-dddd-4ddd-8ddd-dddddddddddd',
|
||||
gross_salary: 35000,
|
||||
tax_withheld: 8200,
|
||||
tax_withheld_override: null,
|
||||
avgifter_rate: 0.3142,
|
||||
avgifter_amount: 10997,
|
||||
avgifter_basis_override: null,
|
||||
avgifter_amount_override: null,
|
||||
override_reason: null,
|
||||
net_salary: 26800,
|
||||
vacation_accrual: 4200,
|
||||
vacation_accrual_avgifter: 1319.64,
|
||||
ytd_gross: 70000,
|
||||
ytd_tax: 16400,
|
||||
ytd_net: 53600,
|
||||
calculation_breakdown: null,
|
||||
employee: {
|
||||
first_name: 'Anna',
|
||||
last_name: 'Andersson',
|
||||
personnummer: SAMPLE_PERSONNUMMER,
|
||||
personnummer_last4: '0000',
|
||||
employment_type: 'employee',
|
||||
tax_table_number: 33,
|
||||
tax_column: 1,
|
||||
clearing_number: '6000',
|
||||
bank_account_number: '12345678',
|
||||
},
|
||||
line_items: [
|
||||
{
|
||||
description: 'Grundlön',
|
||||
quantity: null,
|
||||
unit_price: null,
|
||||
amount: 35000,
|
||||
sort_order: 0,
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
function makeRequest(url: string): Request {
|
||||
return new Request(url, {
|
||||
headers: { Authorization: 'Bearer test-fixture-not-a-real-key' },
|
||||
})
|
||||
}
|
||||
|
||||
function pdfParams(companyId: string, id: string, employeeId: string) {
|
||||
return { params: Promise.resolve({ companyId, id, employeeId }) }
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockRender.mockResolvedValue(Buffer.from('%PDF-1.7 test'))
|
||||
mockValidate.mockResolvedValue({
|
||||
userId: USER_ID,
|
||||
companyId: COMPANY_ID,
|
||||
apiKeyId: 'ak_1',
|
||||
apiKeyName: 'CI key',
|
||||
scopes: ['payroll:read'],
|
||||
mode: 'live',
|
||||
})
|
||||
})
|
||||
|
||||
describe('GET /api/v1/companies/:companyId/salary-runs/:id/payslips/:employeeId/pdf', () => {
|
||||
it('returns the rendered PDF with attachment disposition (happy path)', async () => {
|
||||
mockServiceClient.mockReturnValue(
|
||||
makeFlexibleSupabase({
|
||||
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
|
||||
salary_runs: { data: SAMPLE_RUN, error: null },
|
||||
salary_run_employees: { data: SAMPLE_SRE, error: null },
|
||||
companies: { data: { name: 'Testbolaget AB', org_number: '5560000000' }, error: null },
|
||||
company_settings: { data: { company_name: 'Testbolaget AB' }, error: null },
|
||||
}),
|
||||
)
|
||||
|
||||
const res = await getPayslipPdf(
|
||||
makeRequest(
|
||||
`https://x.test/api/v1/companies/${COMPANY_ID}/salary-runs/${RUN_ID}/payslips/${EMPLOYEE_ID}/pdf`,
|
||||
),
|
||||
pdfParams(COMPANY_ID, RUN_ID, EMPLOYEE_ID),
|
||||
)
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
expect(res.headers.get('Content-Type')).toBe('application/pdf')
|
||||
expect(res.headers.get('Content-Disposition')).toContain('attachment')
|
||||
expect(res.headers.get('Content-Disposition')).toContain('lonespec_Andersson_Anna_2026-05.pdf')
|
||||
expect(res.headers.get('X-Request-Id')).toBeTruthy()
|
||||
const buf = Buffer.from(await res.arrayBuffer())
|
||||
expect(buf.toString()).toContain('%PDF')
|
||||
})
|
||||
|
||||
it('returns 404 SALARY_RUN_NOT_FOUND when the run is missing', async () => {
|
||||
mockServiceClient.mockReturnValue(
|
||||
makeFlexibleSupabase({
|
||||
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
|
||||
salary_runs: { data: null, error: null },
|
||||
}),
|
||||
)
|
||||
const res = await getPayslipPdf(
|
||||
makeRequest(
|
||||
`https://x.test/api/v1/companies/${COMPANY_ID}/salary-runs/${RUN_ID}/payslips/${EMPLOYEE_ID}/pdf`,
|
||||
),
|
||||
pdfParams(COMPANY_ID, RUN_ID, EMPLOYEE_ID),
|
||||
)
|
||||
expect(res.status).toBe(404)
|
||||
const body = await res.json()
|
||||
expect(body.error.code).toBe('SALARY_RUN_NOT_FOUND')
|
||||
})
|
||||
|
||||
it('returns 404 NOT_FOUND when the employee is not in the run', async () => {
|
||||
mockServiceClient.mockReturnValue(
|
||||
makeFlexibleSupabase({
|
||||
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
|
||||
salary_runs: { data: SAMPLE_RUN, error: null },
|
||||
salary_run_employees: { data: null, error: null },
|
||||
}),
|
||||
)
|
||||
const res = await getPayslipPdf(
|
||||
makeRequest(
|
||||
`https://x.test/api/v1/companies/${COMPANY_ID}/salary-runs/${RUN_ID}/payslips/${EMPLOYEE_ID}/pdf`,
|
||||
),
|
||||
pdfParams(COMPANY_ID, RUN_ID, EMPLOYEE_ID),
|
||||
)
|
||||
expect(res.status).toBe(404)
|
||||
const body = await res.json()
|
||||
expect(body.error.code).toBe('NOT_FOUND')
|
||||
})
|
||||
|
||||
it('rejects keys without payroll:read scope', async () => {
|
||||
mockValidate.mockResolvedValue({
|
||||
userId: USER_ID,
|
||||
companyId: COMPANY_ID,
|
||||
apiKeyId: 'ak_1',
|
||||
apiKeyName: 'wrong scope',
|
||||
scopes: ['invoices:read'],
|
||||
mode: 'live',
|
||||
})
|
||||
mockServiceClient.mockReturnValue(makeFlexibleSupabase({}))
|
||||
|
||||
const res = await getPayslipPdf(
|
||||
makeRequest(
|
||||
`https://x.test/api/v1/companies/${COMPANY_ID}/salary-runs/${RUN_ID}/payslips/${EMPLOYEE_ID}/pdf`,
|
||||
),
|
||||
pdfParams(COMPANY_ID, RUN_ID, EMPLOYEE_ID),
|
||||
)
|
||||
expect(res.status).toBe(403)
|
||||
})
|
||||
|
||||
it('returns 401 without a bearer token', async () => {
|
||||
mockValidate.mockResolvedValue(null)
|
||||
mockServiceClient.mockReturnValue(makeFlexibleSupabase({}))
|
||||
|
||||
const res = await getPayslipPdf(
|
||||
new Request(
|
||||
`https://x.test/api/v1/companies/${COMPANY_ID}/salary-runs/${RUN_ID}/payslips/${EMPLOYEE_ID}/pdf`,
|
||||
),
|
||||
pdfParams(COMPANY_ID, RUN_ID, EMPLOYEE_ID),
|
||||
)
|
||||
expect(res.status).toBe(401)
|
||||
})
|
||||
|
||||
it('maps a render failure to 500 INTERNAL_ERROR rather than crashing', async () => {
|
||||
mockRender.mockRejectedValue(new Error('font missing'))
|
||||
mockServiceClient.mockReturnValue(
|
||||
makeFlexibleSupabase({
|
||||
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
|
||||
salary_runs: { data: SAMPLE_RUN, error: null },
|
||||
salary_run_employees: { data: SAMPLE_SRE, error: null },
|
||||
companies: { data: { name: 'Testbolaget AB', org_number: '5560000000' }, error: null },
|
||||
company_settings: { data: { company_name: 'Testbolaget AB' }, error: null },
|
||||
}),
|
||||
)
|
||||
|
||||
const res = await getPayslipPdf(
|
||||
makeRequest(
|
||||
`https://x.test/api/v1/companies/${COMPANY_ID}/salary-runs/${RUN_ID}/payslips/${EMPLOYEE_ID}/pdf`,
|
||||
),
|
||||
pdfParams(COMPANY_ID, RUN_ID, EMPLOYEE_ID),
|
||||
)
|
||||
expect(res.status).toBe(500)
|
||||
const body = await res.json()
|
||||
expect(body.error.code).toBe('INTERNAL_ERROR')
|
||||
})
|
||||
})
|
||||
@@ -65,7 +65,7 @@ registerEndpoint({
|
||||
useWhen:
|
||||
'You have a salary_run_id and need its current status: typically to decide which lifecycle verb to call next, or to display the run header in a UI.',
|
||||
doNotUseFor:
|
||||
'Per-employee breakdown (Phase 5 PR-1 does not expose the per-employee endpoint on v1; use the internal /api/salary/runs/{id} for that today). Salary journal report: use GET /reports/salary-journal in Phase 5 PR-3.',
|
||||
'Per-employee breakdown: use GET /salary-runs/{id}/employees (list) or /salary-runs/{id}/employees/{employeeId} (payslip detail). Salary journal report: use GET /reports/salary-journal.',
|
||||
pitfalls: [
|
||||
'salary_entry_id / avgifter_entry_id / vacation_entry_id are null until POST /book has run. They reference the journal_entries table.',
|
||||
'total_* fields are 0 until POST /calculate has run.',
|
||||
|
||||
@@ -0,0 +1,242 @@
|
||||
/**
|
||||
* Tests for POST /api/v1/companies/{companyId}/salary/vacation-year-close
|
||||
* and GET /employees/{id}/vacation-balance (payroll gap-closure 3.4).
|
||||
*
|
||||
* The close service is mocked: these tests cover auth, validation, dry-run
|
||||
* preview plumbing, and error mapping. The beredning/reconcile math is
|
||||
* covered in lib/salary/__tests__/semesterberedning.test.ts.
|
||||
*/
|
||||
|
||||
import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
beforeAll(() => {
|
||||
if (process.env.NODE_ENV !== 'test') {
|
||||
throw new Error(
|
||||
`vacation-year-close route tests require NODE_ENV=test (got ${process.env.NODE_ENV ?? 'undefined'})`,
|
||||
)
|
||||
}
|
||||
process.env.NEXT_PUBLIC_SUPABASE_URL ||= 'http://localhost:54321'
|
||||
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY ||= 'test-anon-key'
|
||||
})
|
||||
|
||||
vi.mock('@/lib/auth/api-keys', async () => {
|
||||
const actual = await vi.importActual<typeof import('@/lib/auth/api-keys')>('@/lib/auth/api-keys')
|
||||
return {
|
||||
...actual,
|
||||
validateApiKey: vi.fn(),
|
||||
createServiceClientNoCookies: vi.fn(),
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@supabase/supabase-js', async () => {
|
||||
const actual = await vi.importActual<typeof import('@supabase/supabase-js')>('@supabase/supabase-js')
|
||||
return { ...actual, createClient: vi.fn().mockReturnValue({}) }
|
||||
})
|
||||
|
||||
const mockPreview = vi.fn()
|
||||
const mockCommit = vi.fn()
|
||||
vi.mock('@/lib/salary/semesterberedning', () => ({
|
||||
previewVacationYearClose: (...a: unknown[]) => mockPreview(...a),
|
||||
commitVacationYearClose: (...a: unknown[]) => mockCommit(...a),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/salary/vacation-ledger', () => ({
|
||||
getVacationYearBasis: vi.fn().mockResolvedValue('calendar'),
|
||||
syncVacationLedgerForEmployees: vi.fn().mockResolvedValue({ ok: true }),
|
||||
}))
|
||||
|
||||
import { validateApiKey, createServiceClientNoCookies } from '@/lib/auth/api-keys'
|
||||
import { POST as closeYear } from '../route'
|
||||
|
||||
const mockValidate = validateApiKey as ReturnType<typeof vi.fn>
|
||||
const mockServiceClient = createServiceClientNoCookies as ReturnType<typeof vi.fn>
|
||||
|
||||
function makeFlexibleSupabase(byTable: Record<string, { data?: unknown; error?: unknown }>) {
|
||||
const buildChain = (table: string): unknown => {
|
||||
const handler: ProxyHandler<object> = {
|
||||
get(_target, prop) {
|
||||
if (prop === 'then') {
|
||||
return (resolve: (v: unknown) => void) =>
|
||||
resolve(byTable[table] ?? { data: null, error: null })
|
||||
}
|
||||
return (..._args: unknown[]) => buildChain(table)
|
||||
},
|
||||
}
|
||||
return new Proxy({}, handler)
|
||||
}
|
||||
return { from: vi.fn((table: string) => buildChain(table)) }
|
||||
}
|
||||
|
||||
const COMPANY_ID = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa'
|
||||
const USER_ID = 'user-1'
|
||||
|
||||
const SAMPLE_REPORT = {
|
||||
vacation_year_start: '2025-01-01',
|
||||
vacation_year_end: '2025-12-31',
|
||||
next_year_start: '2026-01-01',
|
||||
basis: 'calendar',
|
||||
rows: [],
|
||||
sek: {
|
||||
computed_liability: 0,
|
||||
computed_avgifter: 0,
|
||||
booked_2920: 0,
|
||||
booked_2940: 0,
|
||||
drift_2920: 0,
|
||||
drift_2940: 0,
|
||||
adjustment_needed: false,
|
||||
},
|
||||
adjustment_date: '2025-12-31',
|
||||
}
|
||||
|
||||
function makeRequest(url: string, body?: unknown): Request {
|
||||
return new Request(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: 'Bearer test-fixture-not-a-real-key',
|
||||
'Idempotency-Key': 'b1aaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa',
|
||||
},
|
||||
body: JSON.stringify(body ?? {}),
|
||||
})
|
||||
}
|
||||
|
||||
function companyParams(companyId: string) {
|
||||
return { params: Promise.resolve({ companyId }) }
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockValidate.mockResolvedValue({
|
||||
userId: USER_ID,
|
||||
companyId: COMPANY_ID,
|
||||
apiKeyId: 'ak_1',
|
||||
apiKeyName: 'CI key',
|
||||
scopes: ['payroll:read', 'payroll:write'],
|
||||
mode: 'live',
|
||||
})
|
||||
mockServiceClient.mockReturnValue(
|
||||
makeFlexibleSupabase({
|
||||
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
|
||||
idempotency_keys: { data: null, error: null },
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
describe('POST /salary/vacation-year-close', () => {
|
||||
it('commits the close and returns the closure + adjustment ids', async () => {
|
||||
mockCommit.mockResolvedValue({
|
||||
ok: true,
|
||||
data: { closure_id: 'closure-1', adjustment_entry_id: 'je-1', report: SAMPLE_REPORT },
|
||||
})
|
||||
|
||||
const res = await closeYear(
|
||||
makeRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/salary/vacation-year-close`, {
|
||||
vacation_year_start: '2025-01-01',
|
||||
}),
|
||||
companyParams(COMPANY_ID),
|
||||
)
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
const body = await res.json()
|
||||
expect(body.data.vacation_year_closure_id).toBe('closure-1')
|
||||
expect(body.data.adjustment_entry_id).toBe('je-1')
|
||||
expect(mockCommit).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
COMPANY_ID,
|
||||
USER_ID,
|
||||
'2025-01-01',
|
||||
{ bookAdjustment: true },
|
||||
)
|
||||
})
|
||||
|
||||
it('defaults the year to the most recently ended one when omitted', async () => {
|
||||
mockCommit.mockResolvedValue({
|
||||
ok: true,
|
||||
data: { closure_id: 'closure-1', adjustment_entry_id: null, report: SAMPLE_REPORT },
|
||||
})
|
||||
|
||||
const res = await closeYear(
|
||||
makeRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/salary/vacation-year-close`),
|
||||
companyParams(COMPANY_ID),
|
||||
)
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
const currentYear = new Date().getFullYear()
|
||||
expect(mockCommit.mock.calls[0][3]).toBe(`${currentYear - 1}-01-01`)
|
||||
})
|
||||
|
||||
it('dry_run returns the full preview report with zero commits', async () => {
|
||||
mockPreview.mockResolvedValue({ ok: true, data: SAMPLE_REPORT })
|
||||
|
||||
const res = await closeYear(
|
||||
makeRequest(
|
||||
`https://x.test/api/v1/companies/${COMPANY_ID}/salary/vacation-year-close?dry_run=true`,
|
||||
{ vacation_year_start: '2025-01-01' },
|
||||
),
|
||||
companyParams(COMPANY_ID),
|
||||
)
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
expect(res.headers.get('X-Dry-Run')).toBe('true')
|
||||
const body = await res.json()
|
||||
expect(body.data.preview.report.vacation_year_start).toBe('2025-01-01')
|
||||
expect(mockCommit).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('maps VACATION_YEAR_ALREADY_CLOSED to 409', async () => {
|
||||
mockCommit.mockResolvedValue({ ok: false, code: 'VACATION_YEAR_ALREADY_CLOSED' })
|
||||
|
||||
const res = await closeYear(
|
||||
makeRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/salary/vacation-year-close`, {
|
||||
vacation_year_start: '2025-01-01',
|
||||
}),
|
||||
companyParams(COMPANY_ID),
|
||||
)
|
||||
expect(res.status).toBe(409)
|
||||
const body = await res.json()
|
||||
expect(body.error.code).toBe('VACATION_YEAR_ALREADY_CLOSED')
|
||||
})
|
||||
|
||||
it('maps PERIOD_LOCKED without committing anything', async () => {
|
||||
mockCommit.mockResolvedValue({ ok: false, code: 'PERIOD_LOCKED', details: { reason: 'closed' } })
|
||||
|
||||
const res = await closeYear(
|
||||
makeRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/salary/vacation-year-close`, {
|
||||
vacation_year_start: '2025-01-01',
|
||||
}),
|
||||
companyParams(COMPANY_ID),
|
||||
)
|
||||
expect(res.status).toBeGreaterThanOrEqual(400)
|
||||
const body = await res.json()
|
||||
expect(body.error.code).toBe('PERIOD_LOCKED')
|
||||
})
|
||||
|
||||
it('rejects keys without payroll:write scope', async () => {
|
||||
mockValidate.mockResolvedValue({
|
||||
userId: USER_ID,
|
||||
companyId: COMPANY_ID,
|
||||
apiKeyId: 'ak_1',
|
||||
apiKeyName: 'read-only',
|
||||
scopes: ['payroll:read'],
|
||||
mode: 'live',
|
||||
})
|
||||
|
||||
const res = await closeYear(
|
||||
makeRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/salary/vacation-year-close`),
|
||||
companyParams(COMPANY_ID),
|
||||
)
|
||||
expect(res.status).toBe(403)
|
||||
})
|
||||
|
||||
it('requires an Idempotency-Key', async () => {
|
||||
const req = new Request(
|
||||
`https://x.test/api/v1/companies/${COMPANY_ID}/salary/vacation-year-close`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { Authorization: 'Bearer test' },
|
||||
body: JSON.stringify({}),
|
||||
},
|
||||
)
|
||||
const res = await closeYear(req, companyParams(COMPANY_ID))
|
||||
expect(res.status).toBe(400)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,146 @@
|
||||
/**
|
||||
* POST /api/v1/companies/{companyId}/salary/vacation-year-close
|
||||
*
|
||||
* Semesterberedning + semesterårsavslut in one verb (payroll gap-closure
|
||||
* 3.4). dry_run returns the FULL review report (per-employee day
|
||||
* transitions + the SEK reconcile) without writing; the live call closes
|
||||
* the year's ledger rows, rolls balances into the next year (min-20 floor,
|
||||
* 5-year expiry -> forced payout), and books one drift-adjustment
|
||||
* verifikation on 7290/2920 + 7519/2940 when |drift| > 1 kr.
|
||||
*/
|
||||
|
||||
import { z } from 'zod'
|
||||
import { ok } from '@/lib/api/v1/response'
|
||||
import { dryRunPreview } from '@/lib/api/v1/dry-run'
|
||||
import { registerEndpoint, dataEnvelope } from '@/lib/api/v1/registry'
|
||||
import { withApiV1 } from '@/lib/api/v1/with-api-v1'
|
||||
import { v1ErrorResponseFromCode } from '@/lib/api/v1/errors'
|
||||
import {
|
||||
commitVacationYearClose,
|
||||
previewVacationYearClose,
|
||||
} from '@/lib/salary/semesterberedning'
|
||||
import { getVacationYearBasis } from '@/lib/salary/vacation-ledger'
|
||||
import { getClosableYearStart } from '@/lib/salary/vacation-year'
|
||||
|
||||
const isoDate = z.string().regex(/^\d{4}-\d{2}-\d{2}$/, 'Expected YYYY-MM-DD date format')
|
||||
|
||||
const CloseBody = z.object({
|
||||
/** Defaults to the most recently ENDED vacation year per the company's
|
||||
* basis setting. */
|
||||
vacation_year_start: isoDate.optional(),
|
||||
/** Book the 2920/2940 drift adjustment (default true). False = roll the
|
||||
* days but leave the SEK reconcile for a manual verifikat. */
|
||||
book_adjustment: z.boolean().default(true),
|
||||
})
|
||||
|
||||
const CloseResponse = z.object({
|
||||
vacation_year_closure_id: z.string().uuid(),
|
||||
adjustment_entry_id: z.string().uuid().nullable(),
|
||||
report: z.unknown(),
|
||||
})
|
||||
|
||||
registerEndpoint({
|
||||
operation: 'salary.vacation-year-close',
|
||||
method: 'POST',
|
||||
path: '/api/v1/companies/:companyId/salary/vacation-year-close',
|
||||
summary: 'Close a vacation year (semesterberedning + arsavslut).',
|
||||
description:
|
||||
'Rolls every active employee\'s vacation balances into the next year (only days above the 20-day must-take floor are saved; saved days older than 5 years become forced payouts) and reconciles the day-valued semesterlöneskuld against the booked 2920/2940, posting one adjustment verifikation when drift exceeds 1 kr. The frozen report is stored with the closure (BFL 7 kap).',
|
||||
useWhen:
|
||||
'Once per year after the vacation year ends (Jan for calendar basis, Apr for statutory). ALWAYS dry-run first and review the report: the close is not reversible via API.',
|
||||
doNotUseFor:
|
||||
'Mid-year balance corrections (fix the source: absence days, opening balances, or run corrections). Paying out expired days (create a semesterersattning line in the next salary run: the close only flags them).',
|
||||
pitfalls: [
|
||||
'dry_run=true returns the full review report with zero writes: treat it as mandatory before the live call.',
|
||||
'409 VACATION_YEAR_ALREADY_CLOSED on replay: the closure row is the idempotency anchor.',
|
||||
'423-style PERIOD_LOCKED when the adjustment date falls in a locked period: unlock or close without adjustment (book_adjustment=false) and post manually.',
|
||||
'Untaken days at or below the 20-day floor are flagged in the report, NOT auto-saved (Semesterlagen 18 §).',
|
||||
],
|
||||
example: {
|
||||
request: { book_adjustment: true },
|
||||
response: {
|
||||
data: {
|
||||
vacation_year_closure_id: 'vyc_a1b2…',
|
||||
adjustment_entry_id: 'je_c3d4…',
|
||||
report: { vacation_year_start: '2025-01-01', rows: [], sek: { drift_2920: 8690.84 } },
|
||||
},
|
||||
meta: { request_id: 'req_…', api_version: '2026-05-12' },
|
||||
},
|
||||
},
|
||||
scope: 'payroll:write',
|
||||
risk: 'high',
|
||||
idempotent: true,
|
||||
reversible: false,
|
||||
dryRunSupported: true,
|
||||
request: { body: CloseBody },
|
||||
response: { success: dataEnvelope(CloseResponse) },
|
||||
})
|
||||
|
||||
export const POST = withApiV1<{ params: Promise<{ companyId: string }> }>(
|
||||
'salary.vacation-year-close',
|
||||
async (request, ctx) => {
|
||||
let rawBody: unknown = {}
|
||||
try {
|
||||
const text = await request.text()
|
||||
rawBody = text ? JSON.parse(text) : {}
|
||||
} catch {
|
||||
return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, {
|
||||
requestId: ctx.requestId,
|
||||
details: { field: 'body', message: 'Body is not valid JSON.' },
|
||||
})
|
||||
}
|
||||
|
||||
const parsed = CloseBody.safeParse(rawBody)
|
||||
if (!parsed.success) {
|
||||
return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, {
|
||||
requestId: ctx.requestId,
|
||||
details: {
|
||||
issues: parsed.error.issues.map((i) => ({
|
||||
field: i.path.join('.'),
|
||||
message: i.message,
|
||||
})),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
let yearStart = parsed.data.vacation_year_start
|
||||
if (!yearStart) {
|
||||
const basis = await getVacationYearBasis(ctx.supabase, ctx.companyId!)
|
||||
yearStart = getClosableYearStart(new Date().toISOString().slice(0, 10), basis)
|
||||
}
|
||||
|
||||
if (ctx.dryRun) {
|
||||
const preview = await previewVacationYearClose(ctx.supabase, ctx.companyId!, yearStart)
|
||||
if (!preview.ok) {
|
||||
return v1ErrorResponseFromCode(preview.code, ctx.log, {
|
||||
requestId: ctx.requestId,
|
||||
details: preview.details,
|
||||
})
|
||||
}
|
||||
return dryRunPreview(
|
||||
{ vacation_year_closure_id: null, adjustment_entry_id: null, report: preview.data },
|
||||
{ requestId: ctx.requestId, log: ctx.log },
|
||||
)
|
||||
}
|
||||
|
||||
const result = await commitVacationYearClose(ctx.supabase, ctx.companyId!, ctx.userId, yearStart, {
|
||||
bookAdjustment: parsed.data.book_adjustment,
|
||||
})
|
||||
if (!result.ok) {
|
||||
return v1ErrorResponseFromCode(result.code, ctx.log, {
|
||||
requestId: ctx.requestId,
|
||||
details: result.details,
|
||||
})
|
||||
}
|
||||
|
||||
return ok(
|
||||
{
|
||||
vacation_year_closure_id: result.data.closure_id,
|
||||
adjustment_entry_id: result.data.adjustment_entry_id,
|
||||
report: result.data.report,
|
||||
},
|
||||
{ requestId: ctx.requestId },
|
||||
)
|
||||
},
|
||||
{ requireIdempotencyKey: true },
|
||||
)
|
||||
Reference in New Issue
Block a user