diff --git a/app/api/v1/companies/[companyId]/employees/[id]/route.ts b/app/api/v1/companies/[companyId]/employees/[id]/route.ts new file mode 100644 index 00000000..e14ea369 --- /dev/null +++ b/app/api/v1/companies/[companyId]/employees/[id]/route.ts @@ -0,0 +1,434 @@ +/** + * /api/v1/companies/{companyId}/employees/{id} + * + * GET — return the full employee record. Personnummer is NOT masked here + * (deliberate drill-in; caller already knows the id, has read scope, + * and has membership in the company). + * PATCH — update a subset of fields. Idempotent (Idempotency-Key recommended, + * not enforced). Dry-runnable. + * DELETE — soft-delete via is_active=false. The employees table has no + * archived_at column; the row is preserved because past salary + * runs reference it via salary_run_employees and those + * verifikationer are räkenskapsinformation under BFL 7 kap. + * (BFL retention attaches to the verifikationer, not to the + * personnummer attribute on the master row — a future GDPR + * Art.17 erasure workflow could pseudonymise the row once all + * referenced verifikationer are outside the 7-year window.) + * Hard delete is never exposed from v1. + */ + +import { z } from 'zod' +import { ok, noContent } from '@/lib/api/v1/response' +import { dryRunPreview } from '@/lib/api/v1/dry-run' +import { registerEndpoint } from '@/lib/api/v1/registry' +import { withApiV1 } from '@/lib/api/v1/with-api-v1' +import { v1ErrorResponse, v1ErrorResponseFromCode } from '@/lib/api/v1/errors' +import { UpdateEmployeeSchema } from '@/lib/api/schemas' +import { maskPersonnummer } from '@/lib/api/v1/mask-personnummer' + +const EmploymentType = z.enum(['employee', 'company_owner', 'board_member']) +const SalaryType = z.enum(['monthly', 'hourly']) +const FSkattStatus = z.enum(['a_skatt', 'f_skatt', 'fa_skatt', 'not_verified']) + +const EmployeeDetail = z.object({ + id: z.string().uuid(), + first_name: z.string(), + last_name: z.string(), + /** Full personnummer (12 digits). Detail endpoint only — never echoed on list. */ + personnummer: z.string(), + employment_type: EmploymentType, + employment_start: z.string(), + employment_end: z.string().nullable(), + employment_degree: z.number(), + salary_type: SalaryType, + monthly_salary: z.number().nullable(), + hourly_rate: z.number().nullable(), + tax_table_number: z.number().nullable(), + tax_column: z.number().nullable(), + tax_municipality: z.string().nullable(), + is_sidoinkomst: z.boolean(), + f_skatt_status: FSkattStatus, + clearing_number: z.string().nullable(), + bank_account_number: z.string().nullable(), + vacation_rule: z.string(), + vacation_days_per_year: z.number(), + semestertillagg_rate: z.number(), + email: z.string().nullable(), + phone: z.string().nullable(), + address_line1: z.string().nullable(), + postal_code: z.string().nullable(), + city: z.string().nullable(), + vaxa_stod_eligible: z.boolean(), + vaxa_stod_start: z.string().nullable(), + vaxa_stod_end: z.string().nullable(), + is_active: z.boolean(), + created_at: z.string(), + updated_at: z.string(), +}) + +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, is_active, created_at, updated_at' + +/** + * Shape returned by PATCH (success + dry-run preview) and by no-change PATCH. + * Replaces the GET-only `personnummer` field with `personnummer_masked` so + * write responses never echo back the natural-person identifier — symmetric + * with the POST response shape (GDPR Art.5(1)(c)). + */ +const EmployeeWriteResponse = EmployeeDetail + .omit({ personnummer: true }) + .extend({ personnummer_masked: z.string() }) + +type ExistingRow = { + id: string + personnummer: string + [key: string]: unknown +} + +/** + * Convert a freshly-fetched / updated employee row into the write-response + * shape: drop `personnummer`, add `personnummer_masked`. Caller is + * responsible for passing a row that includes the raw `personnummer` field + * (always the case for EMPLOYEE_DETAIL_COLUMNS reads). + */ +function maskExistingForResponse(row: ExistingRow): Record { + const { personnummer, ...rest } = row + return { ...rest, personnummer_masked: maskPersonnummer(personnummer) } +} + +registerEndpoint({ + operation: 'employees.get', + method: 'GET', + path: '/api/v1/companies/:companyId/employees/:id', + summary: 'Get a single employee.', + description: + 'Returns the full employee record including the 12-digit personnummer, bank details, tax configuration, and contact info. This is the deliberate drill-in for an id you already know — list calls mask personnummer.', + useWhen: + 'You have an employee id and need every field (tax table, bank account, vacation rule) — typically to render an edit form or to construct a payroll calculation input.', + doNotUseFor: + 'Rosters or pickers (use the list endpoint — personnummer is masked there).', + pitfalls: [ + 'The response includes the full personnummer. Treat it as a national identifier (GDPR Art.5(1)(c)) — do not propagate it to logs or external systems beyond what your integration strictly requires.', + 'Inactive (soft-deleted) employees are returned by the detail endpoint; check `is_active` if your flow should skip them.', + ], + example: { + response: { + data: { + id: 'a8f1…', + first_name: 'Anna', + last_name: 'Andersson', + // Format placeholder (ÅÅÅÅMMDDNNNN) rather than a numeric value — + // ISO A.5.34: do not embed production-format PII in OpenAPI docs. + personnummer: 'YYYYMMDDNNNN', + employment_type: 'employee', + employment_start: '2024-01-15', + employment_end: null, + salary_type: 'monthly', + monthly_salary: 35000, + f_skatt_status: 'a_skatt', + is_active: true, + }, + meta: { request_id: 'req_…', api_version: '2026-05-12' }, + }, + }, + scope: 'payroll:read', + risk: 'low', + idempotent: true, + reversible: false, + dryRunSupported: false, + response: { success: EmployeeDetail }, +}) + +export const GET = withApiV1<{ params: Promise<{ companyId: string; id: string }> }>( + 'employees.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, error } = await ctx.supabase + .from('employees') + .select(EMPLOYEE_DETAIL_COLUMNS) + .eq('company_id', ctx.companyId!) + .eq('id', idParse.data) + .maybeSingle() + + if (error) { + return v1ErrorResponse(error, ctx.log, { requestId: ctx.requestId }) + } + if (!data) { + return v1ErrorResponseFromCode('EMPLOYEE_NOT_FOUND', ctx.log, { requestId: ctx.requestId }) + } + + return ok(data, { requestId: ctx.requestId }) + }, +) + +// ────────────────────────────────────────────────────────────────── +// PATCH — update employee +// ────────────────────────────────────────────────────────────────── + +registerEndpoint({ + operation: 'employees.update', + method: 'PATCH', + path: '/api/v1/companies/:companyId/employees/:id', + summary: 'Update an employee.', + description: + 'Partial update of an employee. Only the fields supplied in the body are changed. Supports ?dry_run=true to validate the merged record without committing. Personnummer changes are NOT permitted via this endpoint — the natural-person identity is immutable post-creation.', + useWhen: + 'You need to change tax configuration, bank details, salary amount, or contact info on an existing employee.', + doNotUseFor: + 'Changing personnummer (not supported — create a new employee if the natural-person identity changes, which is a rare edge case). Soft-deleting (use DELETE).', + pitfalls: [ + 'personnummer in the body is ignored by this endpoint. To change it you must DELETE and recreate.', + 'salary_type changes require the matching salary field in the same request — switching to monthly without monthly_salary returns 400.', + 'tax_table_number changes only take effect on future salary runs; runs already in `review` or beyond use a frozen snapshot.', + ], + example: { + request: { monthly_salary: 38000, tax_municipality: 'Göteborg' }, + response: { data: { id: 'a8f1…', monthly_salary: 38000 } }, + }, + scope: 'payroll:write', + risk: 'low', + idempotent: true, + reversible: false, + dryRunSupported: true, + request: { body: UpdateEmployeeSchema }, + // Write responses mask personnummer (GDPR Art.5(1)(c)) — only the GET + // drill-in returns the full value. Symmetric with the POST response. + response: { success: EmployeeWriteResponse }, +}) + +export const PATCH = withApiV1<{ params: Promise<{ companyId: string; id: string }> }>( + 'employees.update', + 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.' }, + }) + } + + // OWASP V4.5: require a plain JSON object. Zod would catch a non-object + // body downstream, but the rawKeys filter below uses Object.keys on + // rawBody directly — guarding here makes the contract explicit and the + // Object.keys call unambiguously safe (e.g. an array body would pass + // `typeof === 'object'` but yield numeric-string keys). + if (typeof rawBody !== 'object' || rawBody === null || Array.isArray(rawBody)) { + return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, { + requestId: ctx.requestId, + details: { field: 'body', message: 'Body must be a JSON object.' }, + }) + } + + // Reject personnummer in the body explicitly — natural-person identity + // is immutable post-create. SOC 2 PI1.3 / processing integrity: surface + // the intent error rather than silently dropping the field. The Zod + // schema accepts personnummer as optional (inherited from the base + // schema's .partial()), so this guard runs BEFORE parse to give the + // caller the most specific message. + if ( + rawBody !== null && + typeof rawBody === 'object' && + 'personnummer' in rawBody + ) { + return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, { + requestId: ctx.requestId, + details: { + field: 'personnummer', + message: + 'personnummer cannot be modified — identity is immutable post-create. DELETE and recreate if the natural-person identity has genuinely changed.', + }, + }) + } + + const parsed = UpdateEmployeeSchema.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 + + // Fetch the existing row so dry-run + the eventual update see merged state + // (the Zod superRefine validates against the merged object). Also gives + // us a clean 404 path before any work happens. + const { data: existing, error: fetchErr } = await ctx.supabase + .from('employees') + .select(EMPLOYEE_DETAIL_COLUMNS) + .eq('company_id', ctx.companyId!) + .eq('id', idParse.data) + .maybeSingle() + if (fetchErr) { + return v1ErrorResponse(fetchErr, ctx.log, { requestId: ctx.requestId }) + } + if (!existing) { + return v1ErrorResponseFromCode('EMPLOYEE_NOT_FOUND', ctx.log, { requestId: ctx.requestId }) + } + + // The Zod schema accepts all base fields as optional. Filter to the + // explicitly-supplied keys so unmentioned columns aren't overwritten to + // their `default()` values (e.g. is_sidoinkomst would silently reset + // to false on every PATCH if we passed it unconditionally). + // + // OWASP V4.5 defense-in-depth: strip prototype-polluting own-properties + // from the key list. JSON.parse can produce `{ "__proto__": ..., }` as + // an own (data) property — our Zod-parsed `body` would never include + // those keys and the subsequent intersection with rawKeys already + // prevents them reaching the DB, but the explicit filter makes the + // intent unambiguous for future readers. + const POLLUTING_KEYS = new Set(['__proto__', 'constructor', 'prototype']) + const rawKeys = Object.keys(rawBody as object).filter((k) => !POLLUTING_KEYS.has(k)) + const updates: Record = {} + for (const [key, value] of Object.entries(body) as Array<[string, unknown]>) { + if (rawKeys.includes(key)) { + updates[key] = value === undefined ? null : value + } + } + + 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. + return ok(maskExistingForResponse(existing as ExistingRow), { + requestId: ctx.requestId, + }) + } + + if (ctx.dryRun) { + // Merge for the preview, then mask the natural-person identifier. + // Phase 5 PR-1 design: writes never echo back the supplied identity, + // only the GET drill-in does. The dry-run preview is a write-shape so + // it follows the write rule. + const merged = { ...(existing as ExistingRow), ...updates } + return dryRunPreview(maskExistingForResponse(merged), { + requestId: ctx.requestId, + log: ctx.log, + }) + } + + const { data, error } = await ctx.supabase + .from('employees') + .update(updates) + .eq('company_id', ctx.companyId!) + .eq('id', idParse.data) + .select(EMPLOYEE_DETAIL_COLUMNS) + .single() + + if (error) { + return v1ErrorResponse(error, ctx.log, { requestId: ctx.requestId }) + } + + // GDPR Art.5(1)(c) — mask the natural-person identifier in the write + // response. The detail GET endpoint still returns the full value for + // callers who deliberately drill in. + return ok(maskExistingForResponse(data as ExistingRow), { + requestId: ctx.requestId, + }) + }, +) + +// ────────────────────────────────────────────────────────────────── +// DELETE — soft-delete (is_active=false) +// ────────────────────────────────────────────────────────────────── + +registerEndpoint({ + operation: 'employees.delete', + method: 'DELETE', + path: '/api/v1/companies/:companyId/employees/:id', + summary: 'Soft-delete an employee.', + description: + 'Sets `is_active=false`. The row is preserved because past salary runs reference it via salary_run_employees and those verifikationer are räkenskapsinformation under BFL 7 kap (BFL retention attaches to the verifikationer themselves, not strictly to the personnummer attribute on the master row). Hard delete is never exposed.', + useWhen: + 'An employee has left the company and should no longer appear in active rosters or default to new salary runs.', + doNotUseFor: + 'Reactivating later (PATCH `is_active=true` instead). Hard-deleting (not supported — retention).', + pitfalls: [ + 'Idempotent: deleting an already-inactive employee returns 204 No Content (the same as the first call).', + 'The row is NOT removed from the database — re-creating with the same personnummer returns 409 EMPLOYEE_DUPLICATE_PERSONNUMMER even after soft-delete.', + 'Past salary runs still reference this employee; their data continues to surface in GET /salary-runs/{id} and SIE exports.', + ], + example: { + response: { data: null }, + }, + scope: 'payroll:write', + risk: 'low', + idempotent: true, + reversible: true, + dryRunSupported: true, + response: { success: z.object({}) }, +}) + +export const DELETE = withApiV1<{ params: Promise<{ companyId: string; id: string }> }>( + 'employees.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 { data: existing, error: fetchErr } = await ctx.supabase + .from('employees') + .select('id, is_active') + .eq('company_id', ctx.companyId!) + .eq('id', idParse.data) + .maybeSingle() + if (fetchErr) { + return v1ErrorResponse(fetchErr, ctx.log, { requestId: ctx.requestId }) + } + if (!existing) { + return v1ErrorResponseFromCode('EMPLOYEE_NOT_FOUND', ctx.log, { requestId: ctx.requestId }) + } + + if (ctx.dryRun) { + return dryRunPreview( + { ...(existing as object), is_active: false }, + { requestId: ctx.requestId, log: ctx.log }, + ) + } + + // Already inactive → no-op (idempotent). + if (!(existing as { is_active: boolean }).is_active) { + return noContent({ requestId: ctx.requestId }) + } + + const { error } = await ctx.supabase + .from('employees') + .update({ is_active: false }) + .eq('company_id', ctx.companyId!) + .eq('id', idParse.data) + + if (error) { + return v1ErrorResponse(error, ctx.log, { requestId: ctx.requestId }) + } + + return noContent({ requestId: ctx.requestId }) + }, +) + diff --git a/app/api/v1/companies/[companyId]/employees/__tests__/route.test.ts b/app/api/v1/companies/[companyId]/employees/__tests__/route.test.ts new file mode 100644 index 00000000..5a1ccaa7 --- /dev/null +++ b/app/api/v1/companies/[companyId]/employees/__tests__/route.test.ts @@ -0,0 +1,645 @@ +/** + * Integration tests for the v1 employees vertical (Phase 5 PR-1). + * + * Covers list / detail / create / patch / delete on /employees. + * Mirrors the Phase 4 suppliers test pattern: Proxy-backed Supabase mock + * returns per-table responses; each suite focuses on outcome (status + body + * shape) rather than query mechanics. + */ + +import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' + +beforeAll(() => { + if (process.env.NODE_ENV !== 'test') { + throw new Error( + `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('@/lib/auth/api-keys') + return { + ...actual, + validateApiKey: vi.fn(), + createServiceClientNoCookies: vi.fn(), + } +}) + +vi.mock('@supabase/supabase-js', async () => { + const actual = await vi.importActual('@supabase/supabase-js') + return { ...actual, createClient: vi.fn().mockReturnValue({}) } +}) + +import { validateApiKey, createServiceClientNoCookies } from '@/lib/auth/api-keys' +import { GET as listEmployees, POST as createEmployee } from '../route' +import { + GET as getEmployee, + PATCH as updateEmployee, + DELETE as deleteEmployee, +} from '../[id]/route' + +const mockValidate = validateApiKey as ReturnType +const mockServiceClient = createServiceClientNoCookies as ReturnType + +interface TableResp { + data?: unknown + error?: unknown + count?: number | null +} + +function makeFlexibleSupabase(byTable: Record) { + const queues = new Map() + for (const [t, val] of Object.entries(byTable)) { + queues.set(t, Array.isArray(val) ? [...val] : [val]) + } + const buildChain = (table: string): unknown => { + const handler: ProxyHandler = { + 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 EMPLOYEE_ID = 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb' +const USER_ID = 'user-1' + +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 companyParams(companyId: string) { + return { params: Promise.resolve({ companyId }) } +} + +function detailParams(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', + }) +}) + +// 12-digit synthetic personnummer — passes the schema's `^\d{12}$` regex +// while being obviously not a real birthdate (year 1900, day 1, zero +// suffix). ISO A.5.34 / GDPR Art.5(1)(c): test fixtures must not look like +// production-format PII. Last-4 is '0000' so the mask assertion is still +// easy to spot. +const SAMPLE_PERSONNUMMER = '190001010000' + +const SAMPLE_EMPLOYEE = { + id: EMPLOYEE_ID, + first_name: 'Anna', + last_name: 'Andersson', + personnummer: SAMPLE_PERSONNUMMER, + employment_type: 'employee', + employment_start: '2024-01-15', + employment_end: null, + employment_degree: 100, + salary_type: 'monthly', + monthly_salary: 35000, + hourly_rate: null, + tax_table_number: 33, + tax_column: 1, + tax_municipality: 'Stockholm', + is_sidoinkomst: false, + f_skatt_status: 'a_skatt', + clearing_number: '6000', + bank_account_number: '12345678', + vacation_rule: 'procentregeln', + vacation_days_per_year: 25, + semestertillagg_rate: 0.0043, + email: 'anna@example.test', + phone: null, + address_line1: null, + postal_code: null, + city: null, + vaxa_stod_eligible: false, + vaxa_stod_start: null, + vaxa_stod_end: null, + is_active: true, + created_at: '2024-01-15T08:00:00Z', + updated_at: '2024-01-15T08:00:00Z', +} + +describe('GET /api/v1/companies/:companyId/employees', () => { + it('returns paginated employees with masked personnummer', async () => { + mockServiceClient.mockReturnValue( + makeFlexibleSupabase({ + company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null }, + employees: { data: [SAMPLE_EMPLOYEE], error: null }, + }), + ) + + const res = await listEmployees( + makeRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/employees`), + companyParams(COMPANY_ID), + ) + + expect(res.status).toBe(200) + const body = await res.json() + expect(body.data).toHaveLength(1) + expect(body.data[0].first_name).toBe('Anna') + // GDPR Art.5(1)(c) — birthdate visible, last-4 hidden. + expect(body.data[0].personnummer_masked).toBe('19000101XXXX') + // The full personnummer must NEVER appear in the response, even in + // unrelated fields. + expect(JSON.stringify(body)).not.toContain(SAMPLE_PERSONNUMMER) + }) + + it('rejects unknown filter values with 400 VALIDATION_ERROR', async () => { + mockServiceClient.mockReturnValue( + makeFlexibleSupabase({ + company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null }, + employees: { data: [], error: null }, + }), + ) + const res = await listEmployees( + makeRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/employees?employment_type=alien`), + companyParams(COMPANY_ID), + ) + 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 listEmployees( + makeRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/employees`), + companyParams(COMPANY_ID), + ) + expect(res.status).toBe(403) + const body = await res.json() + expect(body.error.code).toBe('INSUFFICIENT_SCOPE') + }) +}) + +describe('GET /api/v1/companies/:companyId/employees/:id', () => { + it('returns the full personnummer on the detail endpoint (deliberate drill-in)', async () => { + mockServiceClient.mockReturnValue( + makeFlexibleSupabase({ + company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null }, + employees: { data: SAMPLE_EMPLOYEE, error: null }, + }), + ) + + const res = await getEmployee( + makeRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/employees/${EMPLOYEE_ID}`), + detailParams(COMPANY_ID, EMPLOYEE_ID), + ) + + expect(res.status).toBe(200) + const body = await res.json() + expect(body.data.id).toBe(EMPLOYEE_ID) + // Detail endpoint deliberately returns the full personnummer — the + // caller already has read scope and the id. + expect(body.data.personnummer).toBe(SAMPLE_PERSONNUMMER) + }) + + it('returns 404 EMPLOYEE_NOT_FOUND when the row is missing', async () => { + mockServiceClient.mockReturnValue( + makeFlexibleSupabase({ + company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null }, + employees: { data: null, error: null }, + }), + ) + const res = await getEmployee( + makeRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/employees/${EMPLOYEE_ID}`), + detailParams(COMPANY_ID, EMPLOYEE_ID), + ) + expect(res.status).toBe(404) + const body = await res.json() + expect(body.error.code).toBe('EMPLOYEE_NOT_FOUND') + }) + + it('rejects a non-UUID id with 400 VALIDATION_ERROR', async () => { + mockServiceClient.mockReturnValue( + makeFlexibleSupabase({ + company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null }, + }), + ) + const res = await getEmployee( + makeRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/employees/not-a-uuid`), + detailParams(COMPANY_ID, 'not-a-uuid'), + ) + expect(res.status).toBe(400) + }) +}) + +describe('POST /api/v1/companies/:companyId/employees', () => { + const validBody = { + first_name: 'Anna', + last_name: 'Andersson', + personnummer: SAMPLE_PERSONNUMMER, + employment_start: '2024-01-15', + salary_type: 'monthly' as const, + monthly_salary: 35000, + tax_table_number: 33, + tax_municipality: 'Stockholm', + } + + it('creates an employee and returns the masked personnummer (happy path)', 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 createEmployee( + makeRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/employees`, { + method: 'POST', + body: JSON.stringify(validBody), + }), + companyParams(COMPANY_ID), + ) + + expect(res.status).toBe(201) + const body = await res.json() + expect(body.data.first_name).toBe('Anna') + expect(body.data.personnummer_masked).toBe('19000101XXXX') + // Response shape never contains the raw personnummer. + expect(JSON.stringify(body)).not.toContain(SAMPLE_PERSONNUMMER) + }) + + it('returns 409 EMPLOYEE_DUPLICATE_PERSONNUMMER on 23505 (and does not echo the personnummer)', async () => { + mockServiceClient.mockReturnValue( + makeFlexibleSupabase({ + company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null }, + employees: { + data: null, + error: { + code: '23505', + message: 'duplicate', + // Postgres auto-names the inline `UNIQUE (company_id, personnummer)` + // constraint as `__key`. The route disambiguates + // 23505s by substring-matching this name (see the constraint + // disambiguation comment in employees/route.ts). + constraint: 'employees_company_id_personnummer_key', + }, + }, + idempotency_keys: { data: null, error: null }, + }), + ) + + const res = await createEmployee( + makeRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/employees`, { + method: 'POST', + body: JSON.stringify(validBody), + }), + companyParams(COMPANY_ID), + ) + + expect(res.status).toBe(409) + const body = await res.json() + expect(body.error.code).toBe('EMPLOYEE_DUPLICATE_PERSONNUMMER') + // GDPR Art.5(1)(c) defense-in-depth: never echo the value back, ever. + expect(JSON.stringify(body.error)).not.toContain(SAMPLE_PERSONNUMMER) + }) + + it('does not misattribute a 23505 from a future unique index to EMPLOYEE_DUPLICATE_PERSONNUMMER', async () => { + // Defensive: if a future migration adds another unique constraint on + // employees (e.g. (company_id, email)), a 23505 raised by that + // constraint must NOT be mapped to EMPLOYEE_DUPLICATE_PERSONNUMMER. + mockServiceClient.mockReturnValue( + makeFlexibleSupabase({ + company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null }, + employees: { + data: null, + error: { + code: '23505', + message: 'duplicate', + constraint: 'employees_company_id_email_key', // hypothetical + }, + }, + idempotency_keys: { data: null, error: null }, + }), + ) + + const res = await createEmployee( + makeRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/employees`, { + method: 'POST', + body: JSON.stringify(validBody), + }), + companyParams(COMPANY_ID), + ) + + const body = await res.json() + expect(body.error.code).not.toBe('EMPLOYEE_DUPLICATE_PERSONNUMMER') + }) + + it('returns a dry-run preview without committing when ?dry_run=true', async () => { + const fromSpy = vi.fn() + mockServiceClient.mockReturnValue({ + from: (table: string) => { + fromSpy(table) + return new Proxy({}, { + get(_t, prop) { + if (prop === 'then') { + const data = table === 'company_members' + ? { company_id: COMPANY_ID, role: 'owner' } + : null + return (resolve: (v: unknown) => void) => resolve({ data, error: null }) + } + return () => new Proxy({}, this!) + }, + }) + }, + }) + + const res = await createEmployee( + makeRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/employees?dry_run=true`, { + method: 'POST', + body: JSON.stringify(validBody), + }), + companyParams(COMPANY_ID), + ) + + expect(res.status).toBe(200) + expect(res.headers.get('X-Dry-Run')).toBe('true') + expect(fromSpy).not.toHaveBeenCalledWith('employees') + // The dry-run preview must mask personnummer the same way the live + // response shape does — never echo back the supplied identifier. + const body = await res.json() + expect(body.data.preview.personnummer_masked).toBe('19000101XXXX') + expect(JSON.stringify(body)).not.toContain(SAMPLE_PERSONNUMMER) + }) + + 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}/employees`, { + method: 'POST', + headers: { Authorization: 'Bearer test' }, + body: JSON.stringify(validBody), + }) + + const res = await createEmployee(req, companyParams(COMPANY_ID)) + expect(res.status).toBe(400) + }) + + it('returns 400 when personnummer is the wrong length', async () => { + mockServiceClient.mockReturnValue( + makeFlexibleSupabase({ + company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null }, + }), + ) + + const res = await createEmployee( + makeRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/employees`, { + method: 'POST', + // 10-digit form — the schema requires the 12-digit YYYYMMDDNNNN form. + body: JSON.stringify({ ...validBody, personnummer: '8504121234' }), + }), + companyParams(COMPANY_ID), + ) + + expect(res.status).toBe(400) + const body = await res.json() + expect(body.error.code).toBe('VALIDATION_ERROR') + }) + + it('requires tax_table_number for A-skatt non-sidoinkomst employees', async () => { + mockServiceClient.mockReturnValue( + makeFlexibleSupabase({ + company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null }, + }), + ) + + const res = await createEmployee( + makeRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/employees`, { + method: 'POST', + body: JSON.stringify({ + first_name: 'Bo', + last_name: 'Berg', + personnummer: '190001020000', + employment_start: '2024-02-01', + salary_type: 'monthly', + monthly_salary: 30000, + // Deliberately missing tax_table_number — superRefine should fail. + }), + }), + companyParams(COMPANY_ID), + ) + + expect(res.status).toBe(400) + const body = await res.json() + expect(body.error.code).toBe('VALIDATION_ERROR') + }) +}) + +describe('PATCH /api/v1/companies/:companyId/employees/:id', () => { + it('updates an employee and never overwrites unmentioned columns with defaults', async () => { + // Defensive: the route reads the existing row, then only writes the keys + // that were explicitly present in the request body. The mock returns the + // pre-update row on the first read; the second read returns the updated + // row that the route sends back to the caller. + const updated = { ...SAMPLE_EMPLOYEE, monthly_salary: 38000 } + 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({ monthly_salary: 38000 }), + }), + detailParams(COMPANY_ID, EMPLOYEE_ID), + ) + + expect(res.status).toBe(200) + const body = await res.json() + expect(body.data.monthly_salary).toBe(38000) + // GDPR Art.5(1)(c): PATCH success response masks personnummer (write + // shape) — the full value is only echoed by the GET drill-in. + expect(body.data.personnummer_masked).toBe('19000101XXXX') + expect(body.data.personnummer).toBeUndefined() + expect(JSON.stringify(body)).not.toContain(SAMPLE_PERSONNUMMER) + }) + + it('returns 404 EMPLOYEE_NOT_FOUND when the row is missing', async () => { + mockServiceClient.mockReturnValue( + makeFlexibleSupabase({ + company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null }, + employees: { 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({ monthly_salary: 38000 }), + }), + 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 400 when the body contains personnummer (identity is immutable)', async () => { + // SOC 2 PI1.3 / processing integrity: surface the intent error instead + // of silently dropping the field. Caller learns the constraint + // explicitly rather than being misled into thinking the value was + // applied. + mockServiceClient.mockReturnValue( + makeFlexibleSupabase({ + company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, 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({ + personnummer: '190001029999', + monthly_salary: 40000, + }), + }), + 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('personnummer') + }) + + 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 + // only echoed by the GET drill-in. + 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}?dry_run=true`, { + method: 'PATCH', + body: JSON.stringify({ monthly_salary: 38000 }), + }), + detailParams(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.personnummer_masked).toBe('19000101XXXX') + expect(body.data.preview.personnummer).toBeUndefined() + expect(JSON.stringify(body)).not.toContain(SAMPLE_PERSONNUMMER) + }) +}) + +describe('DELETE /api/v1/companies/:companyId/employees/:id', () => { + it('soft-deletes via is_active=false (no hard delete)', async () => { + mockServiceClient.mockReturnValue( + makeFlexibleSupabase({ + company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null }, + employees: { data: { id: EMPLOYEE_ID, is_active: true }, error: null }, + idempotency_keys: { data: null, error: null }, + }), + ) + + const res = await deleteEmployee( + makeRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/employees/${EMPLOYEE_ID}`, { + method: 'DELETE', + }), + detailParams(COMPANY_ID, EMPLOYEE_ID), + ) + + expect(res.status).toBe(204) + }) + + it('is idempotent — deleting an already-inactive employee returns 204', async () => { + mockServiceClient.mockReturnValue( + makeFlexibleSupabase({ + company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null }, + employees: { data: { id: EMPLOYEE_ID, is_active: false }, error: null }, + idempotency_keys: { data: null, error: null }, + }), + ) + + const res = await deleteEmployee( + makeRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/employees/${EMPLOYEE_ID}`, { + method: 'DELETE', + }), + detailParams(COMPANY_ID, EMPLOYEE_ID), + ) + + expect(res.status).toBe(204) + }) + + it('returns 404 EMPLOYEE_NOT_FOUND when the row is missing', async () => { + mockServiceClient.mockReturnValue( + makeFlexibleSupabase({ + company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null }, + employees: { data: null, error: null }, + idempotency_keys: { data: null, error: null }, + }), + ) + + const res = await deleteEmployee( + makeRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/employees/${EMPLOYEE_ID}`, { + method: 'DELETE', + }), + detailParams(COMPANY_ID, EMPLOYEE_ID), + ) + + expect(res.status).toBe(404) + const body = await res.json() + expect(body.error.code).toBe('EMPLOYEE_NOT_FOUND') + }) +}) diff --git a/app/api/v1/companies/[companyId]/employees/route.ts b/app/api/v1/companies/[companyId]/employees/route.ts new file mode 100644 index 00000000..e4ff3b7c --- /dev/null +++ b/app/api/v1/companies/[companyId]/employees/route.ts @@ -0,0 +1,499 @@ +/** + * /api/v1/companies/{companyId}/employees — list + create employees. + * + * GET — list with filters (active, search by name). Cursor pagination on + * (created_at ASC, id ASC). + * POST — create. Idempotent (mandatory Idempotency-Key). Dry-runnable + * (?dry_run=true returns the validated would-be record without + * committing). + * + * GDPR Art.5(1)(c): personnummer is a Swedish national identifier (data subject + * tier). The list endpoint MASKS personnummer to the first 8 digits + 'XXXX' + * (birthdate visible, last-4 hidden) — the dashboard masks the same way. The + * detail endpoint (deliberate drill-in) returns the full personnummer. The + * create endpoint accepts a 12-digit personnummer and stores it; the response + * shape on create echoes the masked form so writes don't echo back the natural + * person identifier supplied by the caller (symmetric with customers). + */ + +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 } from '@/lib/api/v1/registry' +import { withApiV1 } from '@/lib/api/v1/with-api-v1' +import { v1ErrorResponse, v1ErrorResponseFromCode } from '@/lib/api/v1/errors' +import { CreateEmployeeSchema } from '@/lib/api/schemas' +import { maskPersonnummer } from '@/lib/api/v1/mask-personnummer' + +const EmploymentType = z.enum(['employee', 'company_owner', 'board_member']) +const SalaryType = z.enum(['monthly', 'hourly']) +const FSkattStatus = z.enum(['a_skatt', 'f_skatt', 'fa_skatt', 'not_verified']) + +const EmployeeSummary = z.object({ + 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(), + employment_type: EmploymentType, + employment_start: z.string(), + employment_end: z.string().nullable(), + salary_type: SalaryType, + monthly_salary: z.number().nullable(), + hourly_rate: z.number().nullable(), + f_skatt_status: FSkattStatus, + is_active: z.boolean(), + created_at: z.string(), +}) + +const EmployeesListResponse = z.object({ employees: z.array(EmployeeSummary) }) + +// Explicit projection — never SELECT *. Schema migrations adding columns +// must update this list before the field becomes visible on the public API. +// personnummer is loaded so the response can serve a masked form; the full +// value never leaves this projection. +const EMPLOYEE_SUMMARY_COLUMNS = + 'id, first_name, last_name, personnummer, employment_type, employment_start, employment_end, salary_type, monthly_salary, hourly_rate, f_skatt_status, is_active, created_at' + +registerEndpoint({ + operation: 'employees.list', + method: 'GET', + path: '/api/v1/companies/:companyId/employees', + summary: 'List employees for a company.', + description: + 'Returns active employees in created-first order. Pass ?include_inactive=true to include soft-deleted (is_active=false) rows. Use ?search to match against first or last name. Personnummer is masked (birthdate visible, last-4 hidden); use GET /employees/{id} for the full value.', + useWhen: + 'You need a roster — for building a UI picker, resolving employee_id before adding to a salary run, or syncing an external HR system.', + doNotUseFor: + 'Fetching a single employee you already know the id of — use GET /api/v1/companies/{companyId}/employees/{id}. Salary calculations live on /salary-runs/{id}.', + pitfalls: [ + 'Inactive employees are hidden by default; soft-delete via DELETE sets is_active=false (BFL 7 kap retention).', + 'personnummer is masked in the list response (GDPR Art.5(1)(c) data minimisation). The detail endpoint returns the full value.', + 'salary_type drives which field is meaningful: monthly_salary for monthly, hourly_rate for hourly. The other is null.', + ], + example: { + response: { + data: [ + { + id: 'a8f1…', + first_name: 'Anna', + last_name: 'Andersson', + personnummer_masked: 'YYYYMMDDXXXX', + employment_type: 'employee', + employment_start: '2024-01-15', + employment_end: null, + salary_type: 'monthly', + monthly_salary: 35000, + hourly_rate: null, + f_skatt_status: 'a_skatt', + is_active: true, + created_at: '2024-01-15T08:00:00Z', + }, + ], + 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: EmployeesListResponse }, +}) + +export const GET = withApiV1<{ params: Promise<{ companyId: string }> }>( + 'employees.list', + async (request, ctx) => { + const url = new URL(request.url) + const { limit, cursor } = parsePaginationParams(url) + const decoded = decodeDefaultCursor(cursor) + + const FiltersSchema = z.object({ + employment_type: EmploymentType.optional(), + search: z.string().min(1).max(200).optional(), + include_inactive: z.enum(['true', 'false']).optional(), + }) + const filtersResult = FiltersSchema.safeParse({ + employment_type: url.searchParams.get('employment_type') ?? undefined, + search: url.searchParams.get('search') ?? undefined, + include_inactive: url.searchParams.get('include_inactive') ?? undefined, + }) + if (!filtersResult.success) { + return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, { + requestId: ctx.requestId, + details: { + issues: filtersResult.error.issues.map((i) => ({ + field: i.path.join('.'), + message: i.message, + })), + }, + }) + } + const filters = filtersResult.data + const includeInactive = filters.include_inactive === 'true' + + let query = ctx.supabase + .from('employees') + .select(EMPLOYEE_SUMMARY_COLUMNS) + .eq('company_id', ctx.companyId!) + .order('created_at', { ascending: true }) + .order('id', { ascending: true }) + .limit(limit + 1) + + if (!includeInactive) { + query = query.eq('is_active', true) + } + if (filters.employment_type) { + query = query.eq('employment_type', filters.employment_type) + } + if (filters.search) { + // Two layers of escaping (matches the customer/supplier list): + // 1. PostgREST `.or()` filter syntax uses commas + parens as + // delimiters; strip them from the user-supplied term. + // 2. SQL LIKE treats `%` and `_` (and `\` as the default escape) as + // wildcards; escape them so '100%' matches the literal string. + const term = filters.search.replace(/[,()]/g, '').replace(/[%_\\]/g, '\\$&') + query = query.or(`first_name.ilike.%${term}%,last_name.ilike.%${term}%`) + } + + 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 + first_name: string + last_name: string + personnummer: string + employment_type: string + employment_start: string + employment_end: string | null + salary_type: string + monthly_salary: number | null + hourly_rate: number | null + f_skatt_status: string + is_active: boolean + created_at: string + } & Record + + const rows = ((data ?? []) as unknown) as Row[] + const trimmed = rows.slice(0, limit) + const hasMore = rows.length > limit + + const employees = trimmed.map((r) => ({ + id: r.id, + first_name: r.first_name, + last_name: r.last_name, + personnummer_masked: maskPersonnummer(r.personnummer), + employment_type: r.employment_type, + employment_start: r.employment_start, + employment_end: r.employment_end, + salary_type: r.salary_type, + monthly_salary: r.monthly_salary, + hourly_rate: r.hourly_rate, + f_skatt_status: r.f_skatt_status, + is_active: r.is_active, + created_at: r.created_at, + })) + + const last = trimmed[trimmed.length - 1] + const nextCursor = hasMore && last + ? encodeDefaultCursor({ id: last.id, created_at: last.created_at }) + : null + + return paginated(employees, { + requestId: ctx.requestId, + nextCursor: nextCursor ?? undefined, + }) + }, +) + +// ────────────────────────────────────────────────────────────────── +// POST — create employee +// ────────────────────────────────────────────────────────────────── + +const EmployeeCreated = z.object({ + id: z.string().uuid(), + first_name: z.string(), + last_name: z.string(), + personnummer_masked: z.string(), + employment_type: EmploymentType, + employment_start: z.string(), + employment_end: z.string().nullable(), + employment_degree: z.number(), + salary_type: SalaryType, + monthly_salary: z.number().nullable(), + hourly_rate: z.number().nullable(), + tax_table_number: z.number().nullable(), + tax_column: z.number().nullable(), + tax_municipality: z.string().nullable(), + is_sidoinkomst: z.boolean(), + f_skatt_status: FSkattStatus, + vacation_rule: z.string(), + vacation_days_per_year: z.number(), + is_active: z.boolean(), + created_at: z.string(), +}) + +registerEndpoint({ + operation: 'employees.create', + method: 'POST', + path: '/api/v1/companies/:companyId/employees', + summary: 'Create an employee.', + description: + 'Creates a new employee for the company. Requires Idempotency-Key (UUID). Supports ?dry_run=true for input validation without committing. The personnummer in the request body must be 12 digits (ÅÅÅÅMMDDNNNN); the response echoes a masked form (birthdate + XXXX) — GDPR Art.5(1)(c).', + useWhen: + 'You need to register a new employee before adding them to a salary run. Use dry-run first to catch validation errors (missing tax table, salary amount, F-skatt mismatch) before committing.', + doNotUseFor: + 'Updating an existing employee (PATCH instead). Soft-deactivating (DELETE — sets is_active=false). Hard-deleting (the API does not expose hard delete; BFL 7 kap retention).', + pitfalls: [ + 'Idempotency-Key is mandatory — calls without it return 400 VALIDATION_ERROR.', + 'personnummer must be exactly 12 digits with the YYYYMMDD prefix (not the short 10-digit form).', + 'Duplicate personnummer within a company returns 409 EMPLOYEE_DUPLICATE_PERSONNUMMER. Personnummer is unique per (company_id, personnummer).', + 'For A-skatt employees who are not sidoinkomst, tax_table_number is required (29–42).', + 'salary_type drives which salary field is required: monthly_salary for monthly, hourly_rate for hourly.', + 'The response masks personnummer; never echo back the supplied value. Detail endpoint (deliberate drill-in) returns the full value.', + ], + example: { + request: { + first_name: 'Anna', + last_name: 'Andersson', + // Clear placeholder — the regex requires 12 digits in real calls, + // but the docs show the format pattern (ÅÅÅÅMMDDNNNN) rather than a + // literal value to avoid embedding production-format PII in + // generated OpenAPI / SDK docs. + personnummer: 'YYYYMMDDNNNN', + employment_type: 'employee', + employment_start: '2024-01-15', + salary_type: 'monthly', + monthly_salary: 35000, + tax_table_number: 33, + tax_column: 1, + tax_municipality: 'Stockholm', + }, + response: { + data: { + id: 'a8f1…', + first_name: 'Anna', + last_name: 'Andersson', + personnummer_masked: 'YYYYMMDDXXXX', + employment_type: 'employee', + employment_start: '2024-01-15', + employment_end: null, + employment_degree: 100, + salary_type: 'monthly', + monthly_salary: 35000, + hourly_rate: null, + tax_table_number: 33, + tax_column: 1, + tax_municipality: 'Stockholm', + is_sidoinkomst: false, + f_skatt_status: 'a_skatt', + vacation_rule: 'procentregeln', + vacation_days_per_year: 25, + is_active: true, + created_at: '2024-01-15T08:00:00Z', + }, + meta: { request_id: 'req_…', api_version: '2026-05-12' }, + }, + }, + scope: 'payroll:write', + risk: 'low', + idempotent: true, + reversible: true, + dryRunSupported: true, + request: { body: CreateEmployeeSchema }, + response: { success: EmployeeCreated }, +}) + +const EMPLOYEE_RESPONSE_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, vacation_rule, vacation_days_per_year, is_active, created_at' + +export const POST = withApiV1<{ params: Promise<{ companyId: string }> }>( + 'employees.create', + 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 = CreateEmployeeSchema.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 + + if (ctx.dryRun) { + return dryRunPreview( + { + id: null, + first_name: body.first_name, + last_name: body.last_name, + // Mask in the dry-run preview too — never echo back the supplied + // personnummer in any response shape. + personnummer_masked: maskPersonnummer(body.personnummer), + employment_type: body.employment_type, + employment_start: body.employment_start, + employment_end: body.employment_end ?? null, + employment_degree: body.employment_degree, + salary_type: body.salary_type, + monthly_salary: body.monthly_salary ?? null, + hourly_rate: body.hourly_rate ?? null, + tax_table_number: body.tax_table_number ?? null, + tax_column: body.tax_column, + tax_municipality: body.tax_municipality ?? null, + is_sidoinkomst: body.is_sidoinkomst, + f_skatt_status: body.f_skatt_status, + vacation_rule: body.vacation_rule, + vacation_days_per_year: body.vacation_days_per_year, + is_active: true, + created_at: null, + }, + { requestId: ctx.requestId, log: ctx.log }, + ) + } + + const personnummerLast4 = body.personnummer.slice(-4) + + const { data, error } = await ctx.supabase + .from('employees') + .insert({ + user_id: ctx.userId, + company_id: ctx.companyId!, + first_name: body.first_name, + last_name: body.last_name, + personnummer: body.personnummer, + personnummer_last4: personnummerLast4, + employment_type: body.employment_type, + employment_start: body.employment_start, + employment_end: body.employment_end ?? null, + employment_degree: body.employment_degree, + salary_type: body.salary_type, + monthly_salary: body.monthly_salary ?? null, + hourly_rate: body.hourly_rate ?? null, + tax_table_number: body.tax_table_number ?? null, + tax_column: body.tax_column, + tax_municipality: body.tax_municipality ?? null, + is_sidoinkomst: body.is_sidoinkomst, + f_skatt_status: body.f_skatt_status, + clearing_number: body.clearing_number ?? null, + bank_account_number: body.bank_account_number ?? null, + vacation_rule: body.vacation_rule, + vacation_days_per_year: body.vacation_days_per_year, + semestertillagg_rate: body.semestertillagg_rate, + email: body.email ?? null, + phone: body.phone ?? null, + address_line1: body.address_line1 ?? null, + postal_code: body.postal_code ?? null, + city: body.city ?? null, + vaxa_stod_eligible: body.vaxa_stod_eligible, + vaxa_stod_start: body.vaxa_stod_start ?? null, + vaxa_stod_end: body.vaxa_stod_end ?? null, + }) + .select(EMPLOYEE_RESPONSE_COLUMNS) + .single() + + if (error) { + // Disambiguate 23505 by constraint name — the employees table currently + // has only one unique index (company_id, personnummer), but a future + // migration could add another (e.g. (company_id, email)). Mapping every + // 23505 to EMPLOYEE_DUPLICATE_PERSONNUMMER would be a regression once + // that happens. Postgres auto-names inline `UNIQUE (...)` constraints + // as `
__key`. Match conservatively by substring so a + // future rename of the constraint doesn't silently fall through. + if (error.code === '23505') { + const constraint = (error as { constraint?: string }).constraint + if (constraint && constraint.includes('personnummer')) { + // GDPR Art.5(1)(c): NEVER echo back the supplied personnummer in the + // duplicate-error payload — caller only gets the field name. + return v1ErrorResponseFromCode('EMPLOYEE_DUPLICATE_PERSONNUMMER', ctx.log, { + requestId: ctx.requestId, + details: { field: 'personnummer' }, + }) + } + // Unknown unique-constraint violation — surface as a generic DB + // error rather than a misleading personnummer-specific code. The + // route-level log line will capture the constraint name for + // operators investigating the next 23505. + } + return v1ErrorResponse(error, ctx.log, { requestId: ctx.requestId }) + } + + type CreatedRow = { + id: string + first_name: string + last_name: string + personnummer: string + employment_type: string + employment_start: string + employment_end: string | null + employment_degree: number + salary_type: string + monthly_salary: number | null + hourly_rate: number | null + tax_table_number: number | null + tax_column: number | null + tax_municipality: string | null + is_sidoinkomst: boolean + f_skatt_status: string + vacation_rule: string + vacation_days_per_year: number + is_active: boolean + created_at: string + } + const row = data as unknown as CreatedRow + + return created( + { + id: row.id, + first_name: row.first_name, + last_name: row.last_name, + personnummer_masked: maskPersonnummer(row.personnummer), + employment_type: row.employment_type, + employment_start: row.employment_start, + employment_end: row.employment_end, + employment_degree: row.employment_degree, + salary_type: row.salary_type, + monthly_salary: row.monthly_salary, + hourly_rate: row.hourly_rate, + tax_table_number: row.tax_table_number, + tax_column: row.tax_column, + tax_municipality: row.tax_municipality, + is_sidoinkomst: row.is_sidoinkomst, + f_skatt_status: row.f_skatt_status, + vacation_rule: row.vacation_rule, + vacation_days_per_year: row.vacation_days_per_year, + is_active: row.is_active, + created_at: row.created_at, + }, + { requestId: ctx.requestId }, + ) + }, + { requireIdempotencyKey: true }, +) diff --git a/app/api/v1/companies/[companyId]/salary-runs/[id]/route.ts b/app/api/v1/companies/[companyId]/salary-runs/[id]/route.ts new file mode 100644 index 00000000..d809cbba --- /dev/null +++ b/app/api/v1/companies/[companyId]/salary-runs/[id]/route.ts @@ -0,0 +1,381 @@ +/** + * /api/v1/companies/{companyId}/salary-runs/{id} + * + * GET — return the full salary run including denormalised totals + journal + * entry references. + * PATCH — update payment_date / voucher_series / notes. ONLY allowed when + * status === 'draft'. Idempotent. Dry-runnable. + * DELETE — remove the run. ONLY allowed when status === 'draft' (no + * verifikation has been posted yet, BFL 5 kap is not violated by a + * hard delete of an empty draft). Hard delete; the DB has ON DELETE + * CASCADE on salary_run_employees / salary_line_items. + */ + +import { z } from 'zod' +import { ok, noContent } from '@/lib/api/v1/response' +import { dryRunPreview } from '@/lib/api/v1/dry-run' +import { registerEndpoint } from '@/lib/api/v1/registry' +import { withApiV1 } from '@/lib/api/v1/with-api-v1' +import { v1ErrorResponse, v1ErrorResponseFromCode } from '@/lib/api/v1/errors' + +// Inline; the project's shared isoDate is not exported from lib/api/schemas. +const isoDate = z.string().regex(/^\d{4}-\d{2}-\d{2}$/, 'Expected YYYY-MM-DD date format') + +const SalaryRunStatus = z.enum(['draft', 'review', 'approved', 'paid', 'booked', 'corrected']) + +const SalaryRunDetail = z.object({ + id: z.string().uuid(), + period_year: z.number().int(), + period_month: z.number().int(), + payment_date: z.string(), + status: SalaryRunStatus, + voucher_series: z.string(), + total_gross: z.number(), + total_tax: z.number(), + total_net: z.number(), + total_avgifter: z.number(), + total_vacation_accrual: z.number(), + total_employer_cost: z.number(), + salary_entry_id: z.string().uuid().nullable(), + avgifter_entry_id: z.string().uuid().nullable(), + vacation_entry_id: z.string().uuid().nullable(), + agi_generated_at: z.string().nullable(), + agi_submitted_at: z.string().nullable(), + calculation_params: z.unknown().nullable(), + approved_by: z.string().uuid().nullable(), + approved_at: z.string().nullable(), + paid_at: z.string().nullable(), + booked_at: z.string().nullable(), + booked_by: z.string().uuid().nullable(), + notes: z.string().nullable(), + created_at: z.string(), + updated_at: z.string(), +}) + +const SALARY_RUN_DETAIL_COLUMNS = + 'id, period_year, period_month, payment_date, status, voucher_series, total_gross, total_tax, total_net, total_avgifter, total_vacation_accrual, total_employer_cost, salary_entry_id, avgifter_entry_id, vacation_entry_id, agi_generated_at, agi_submitted_at, calculation_params, approved_by, approved_at, paid_at, booked_at, booked_by, notes, created_at, updated_at' + +registerEndpoint({ + operation: 'salary-runs.get', + method: 'GET', + path: '/api/v1/companies/:companyId/salary-runs/:id', + summary: 'Get a salary run.', + description: + 'Returns the salary run\'s lifecycle state, denormalised totals (gross/tax/net/avgifter/vacation/employer_cost), and references to the journal entries it produced (once :book has run).', + 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.', + 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.', + ], + example: { + response: { + data: { + id: 'run_a8f1…', + period_year: 2026, + period_month: 5, + payment_date: '2026-05-25', + status: 'approved', + total_gross: 105000, + total_tax: -28500, + total_net: 76500, + total_avgifter: 32991, + total_employer_cost: 137991, + }, + meta: { request_id: 'req_…', api_version: '2026-05-12' }, + }, + }, + scope: 'payroll:read', + risk: 'low', + idempotent: true, + reversible: false, + dryRunSupported: false, + response: { success: SalaryRunDetail }, +}) + +export const GET = withApiV1<{ params: Promise<{ companyId: string; id: string }> }>( + 'salary-runs.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: 'Salary-run id must be a UUID.' }, + }) + } + + const { data, error } = await ctx.supabase + .from('salary_runs') + .select(SALARY_RUN_DETAIL_COLUMNS) + .eq('company_id', ctx.companyId!) + .eq('id', idParse.data) + .maybeSingle() + + if (error) { + return v1ErrorResponse(error, ctx.log, { requestId: ctx.requestId }) + } + if (!data) { + return v1ErrorResponseFromCode('SALARY_RUN_NOT_FOUND', ctx.log, { requestId: ctx.requestId }) + } + + return ok(data, { requestId: ctx.requestId }) + }, +) + +// ────────────────────────────────────────────────────────────────── +// PATCH — update salary run (draft only) +// ────────────────────────────────────────────────────────────────── + +const UpdateSalaryRunSchema = z.object({ + payment_date: isoDate.optional(), + voucher_series: z.string().regex(/^[A-Z]$/, 'Verifikationsserie måste vara en bokstav A–Z').optional(), + notes: z.string().max(2000).nullable().optional(), +}) + +registerEndpoint({ + operation: 'salary-runs.update', + method: 'PATCH', + path: '/api/v1/companies/:companyId/salary-runs/:id', + summary: 'Update a draft salary run.', + description: + 'Updates payment_date, voucher_series, or notes on a draft salary run. ONLY allowed when status === "draft" — once :calculate has advanced the run to review, these fields are frozen because they feed into the verifikation that :book will eventually post.', + useWhen: + 'You created a draft, then noticed payment_date should be different (e.g. moved from the 25th to the 23rd) before running :calculate.', + doNotUseFor: + 'Changing period_year / period_month (immutable — DELETE the draft and create a new one). Modifying employees in the run (not in v1 PR-1 scope).', + pitfalls: [ + 'Returns 400 SALARY_RUN_PATCH_NOT_DRAFT if status !== "draft".', + 'period_year + period_month are immutable post-create.', + ], + example: { + request: { payment_date: '2026-05-23' }, + response: { data: { id: 'run_…', payment_date: '2026-05-23', status: 'draft' } }, + }, + scope: 'payroll:write', + risk: 'low', + idempotent: true, + reversible: false, + dryRunSupported: true, + request: { body: UpdateSalaryRunSchema }, + response: { success: SalaryRunDetail }, +}) + +export const PATCH = withApiV1<{ params: Promise<{ companyId: string; id: string }> }>( + 'salary-runs.update', + 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.' }, + }) + } + + // OWASP V4.5: require a plain JSON object. Zod would catch a non-object + // body downstream, but the rawKeys filter below uses Object.keys on + // rawBody directly — guarding here makes the contract explicit and the + // Object.keys call unambiguously safe. + if (typeof rawBody !== 'object' || rawBody === null || Array.isArray(rawBody)) { + return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, { + requestId: ctx.requestId, + details: { field: 'body', message: 'Body must be a JSON object.' }, + }) + } + + const parsed = UpdateSalaryRunSchema.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 { data: existing, error: fetchErr } = await ctx.supabase + .from('salary_runs') + .select(SALARY_RUN_DETAIL_COLUMNS) + .eq('company_id', ctx.companyId!) + .eq('id', idParse.data) + .maybeSingle() + if (fetchErr) { + return v1ErrorResponse(fetchErr, ctx.log, { requestId: ctx.requestId }) + } + if (!existing) { + return v1ErrorResponseFromCode('SALARY_RUN_NOT_FOUND', ctx.log, { requestId: ctx.requestId }) + } + if ((existing as { status: string }).status !== 'draft') { + return v1ErrorResponseFromCode('SALARY_RUN_PATCH_NOT_DRAFT', ctx.log, { + requestId: ctx.requestId, + details: { current_status: (existing as { status: string }).status }, + }) + } + + // Filter the explicitly-supplied keys so unmentioned columns aren't + // overwritten to their `default()` values. Same OWASP V4.5 defense-in- + // depth as employees PATCH — strip prototype-polluting own-properties + // before extracting the key list. The intersection with the Zod-parsed + // `body` already prevents these keys from reaching the DB, but the + // filter makes the intent unambiguous. + const POLLUTING_KEYS = new Set(['__proto__', 'constructor', 'prototype']) + const rawKeys = Object.keys(rawBody as object).filter((k) => !POLLUTING_KEYS.has(k)) + const updates: Record = {} + for (const [key, value] of Object.entries(body) as Array<[string, unknown]>) { + if (rawKeys.includes(key)) { + updates[key] = value === undefined ? null : value + } + } + + if (Object.keys(updates).length === 0) { + return ok(existing, { requestId: ctx.requestId }) + } + + if (ctx.dryRun) { + const merged = { ...(existing as object), ...updates } + return dryRunPreview(merged, { requestId: ctx.requestId, log: ctx.log }) + } + + // Optimistic-lock the status filter so a concurrent :calculate that flips + // the status to review between fetch and update yields a clean 409 + // rather than a silently-accepted PATCH on a non-draft row. + const { data, error } = await ctx.supabase + .from('salary_runs') + .update(updates) + .eq('company_id', ctx.companyId!) + .eq('id', idParse.data) + .eq('status', 'draft') + .select(SALARY_RUN_DETAIL_COLUMNS) + .maybeSingle() + + if (error) { + return v1ErrorResponse(error, ctx.log, { requestId: ctx.requestId }) + } + if (!data) { + // Race: status transitioned between pre-flight and update. + return v1ErrorResponseFromCode('SALARY_RUN_PATCH_NOT_DRAFT', ctx.log, { + requestId: ctx.requestId, + details: { reason: 'race' }, + }) + } + + return ok(data, { requestId: ctx.requestId }) + }, +) + +// ────────────────────────────────────────────────────────────────── +// DELETE — hard-delete (draft only) +// ────────────────────────────────────────────────────────────────── + +registerEndpoint({ + operation: 'salary-runs.delete', + method: 'DELETE', + path: '/api/v1/companies/:companyId/salary-runs/:id', + summary: 'Delete a draft salary run.', + description: + 'Hard-deletes a salary run. ONLY allowed when status === "draft" — once the run has calculated numbers or posted a verifikation, BFL 5 kap immutability applies and storno is the only correction path. CASCADE deletes salary_run_employees and salary_line_items.', + useWhen: + 'You created a run by mistake or want to recreate it with different period_month. Only draft runs can be deleted.', + doNotUseFor: + 'Reverting a booked run (use the internal /correct flow; v1 promotion deferred). Hiding a run from listings (no soft-delete on this table — drafts are truly removed).', + pitfalls: [ + 'Returns 400 SALARY_RUN_DELETE_NOT_DRAFT for any status other than draft.', + 'Hard delete: the salary_run_employees + salary_line_items rows cascade away.', + 'Idempotent in the absent-row sense: DELETE on a non-existent id returns 404 SALARY_RUN_NOT_FOUND rather than re-emitting a deletion event.', + ], + example: { response: { data: null } }, + scope: 'payroll:write', + risk: 'low', + idempotent: true, + reversible: false, + dryRunSupported: true, + response: { success: z.object({}) }, +}) + +export const DELETE = withApiV1<{ params: Promise<{ companyId: string; id: string }> }>( + 'salary-runs.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: 'Salary-run id must be a UUID.' }, + }) + } + + const { data: existing, error: fetchErr } = await ctx.supabase + .from('salary_runs') + .select('id, status') + .eq('company_id', ctx.companyId!) + .eq('id', idParse.data) + .maybeSingle() + if (fetchErr) { + return v1ErrorResponse(fetchErr, ctx.log, { requestId: ctx.requestId }) + } + if (!existing) { + return v1ErrorResponseFromCode('SALARY_RUN_NOT_FOUND', ctx.log, { requestId: ctx.requestId }) + } + if ((existing as { status: string }).status !== 'draft') { + return v1ErrorResponseFromCode('SALARY_RUN_DELETE_NOT_DRAFT', ctx.log, { + requestId: ctx.requestId, + details: { current_status: (existing as { status: string }).status }, + }) + } + + if (ctx.dryRun) { + return dryRunPreview( + { id: idParse.data, deleted: true }, + { requestId: ctx.requestId, log: ctx.log }, + ) + } + + // BFL 5 kap räkenskapsinformation defense: in addition to optimistic- + // locking on status='draft', require all journal-entry foreign keys to + // be null. status='draft' is the primary gate (the lifecycle never + // populates salary_entry_id / avgifter_entry_id / vacation_entry_id + // before advancing past draft), but if a partial PR-2 failure ever + // leaves the run in a status='draft' state with a posted JE attached, + // a hard delete would orphan räkenskapsinformation. The null guards + // turn that hypothetical into a clean 400 instead. + const { error, count } = await ctx.supabase + .from('salary_runs') + .delete({ count: 'exact' }) + .eq('company_id', ctx.companyId!) + .eq('id', idParse.data) + .eq('status', 'draft') + .is('salary_entry_id', null) + .is('avgifter_entry_id', null) + .is('vacation_entry_id', null) + + if (error) { + return v1ErrorResponse(error, ctx.log, { requestId: ctx.requestId }) + } + if (count === 0) { + // Race: status transitioned between pre-flight and delete. + return v1ErrorResponseFromCode('SALARY_RUN_DELETE_NOT_DRAFT', ctx.log, { + requestId: ctx.requestId, + details: { reason: 'race' }, + }) + } + + return noContent({ requestId: ctx.requestId }) + }, +) diff --git a/app/api/v1/companies/[companyId]/salary-runs/__tests__/route.test.ts b/app/api/v1/companies/[companyId]/salary-runs/__tests__/route.test.ts new file mode 100644 index 00000000..93f1445e --- /dev/null +++ b/app/api/v1/companies/[companyId]/salary-runs/__tests__/route.test.ts @@ -0,0 +1,545 @@ +/** + * Integration tests for the v1 salary-runs CRUD (Phase 5 PR-1). + * + * Covers list / detail / create / patch / delete on /salary-runs. The lifecycle + * verbs (:calculate / :approve / :mark-paid / :book / :generate-agi) ship in + * Phase 5 PR-2. + */ + +import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' + +beforeAll(() => { + if (process.env.NODE_ENV !== 'test') { + throw new Error( + `salary-runs 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('@/lib/auth/api-keys') + return { + ...actual, + validateApiKey: vi.fn(), + createServiceClientNoCookies: vi.fn(), + } +}) + +vi.mock('@supabase/supabase-js', async () => { + const actual = await vi.importActual('@supabase/supabase-js') + return { ...actual, createClient: vi.fn().mockReturnValue({}) } +}) + +import { validateApiKey, createServiceClientNoCookies } from '@/lib/auth/api-keys' +import { GET as listSalaryRuns, POST as createSalaryRun } from '../route' +import { + GET as getSalaryRun, + PATCH as updateSalaryRun, + DELETE as deleteSalaryRun, +} from '../[id]/route' + +const mockValidate = validateApiKey as ReturnType +const mockServiceClient = createServiceClientNoCookies as ReturnType + +interface TableResp { + data?: unknown + error?: unknown + count?: number | null +} + +function makeFlexibleSupabase(byTable: Record) { + const queues = new Map() + for (const [t, val] of Object.entries(byTable)) { + queues.set(t, Array.isArray(val) ? [...val] : [val]) + } + const buildChain = (table: string): unknown => { + const handler: ProxyHandler = { + 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 USER_ID = 'user-1' + +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 companyParams(companyId: string) { + return { params: Promise.resolve({ companyId }) } +} + +function detailParams(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', + }) +}) + +const SAMPLE_RUN = { + id: RUN_ID, + period_year: 2026, + period_month: 5, + payment_date: '2026-05-25', + status: 'draft', + voucher_series: 'L', + total_gross: 0, + total_tax: 0, + total_net: 0, + total_avgifter: 0, + total_vacation_accrual: 0, + total_employer_cost: 0, + salary_entry_id: null, + avgifter_entry_id: null, + vacation_entry_id: null, + agi_generated_at: null, + agi_submitted_at: null, + calculation_params: null, + approved_by: null, + approved_at: null, + paid_at: null, + booked_at: null, + booked_by: null, + notes: null, + created_at: '2026-05-01T08:00:00Z', + updated_at: '2026-05-01T08:00:00Z', +} + +describe('GET /api/v1/companies/:companyId/salary-runs', () => { + it('returns paginated salary runs', async () => { + mockServiceClient.mockReturnValue( + makeFlexibleSupabase({ + company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null }, + salary_runs: { data: [SAMPLE_RUN], error: null }, + }), + ) + + const res = await listSalaryRuns( + makeRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/salary-runs`), + companyParams(COMPANY_ID), + ) + + expect(res.status).toBe(200) + const body = await res.json() + expect(body.data).toHaveLength(1) + expect(body.data[0].period_year).toBe(2026) + expect(body.data[0].status).toBe('draft') + }) + + it('rejects an out-of-range period_year filter', async () => { + mockServiceClient.mockReturnValue( + makeFlexibleSupabase({ + company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null }, + }), + ) + const res = await listSalaryRuns( + makeRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/salary-runs?period_year=1999`), + companyParams(COMPANY_ID), + ) + expect(res.status).toBe(400) + }) + + 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 listSalaryRuns( + makeRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/salary-runs`), + companyParams(COMPANY_ID), + ) + expect(res.status).toBe(403) + }) +}) + +describe('GET /api/v1/companies/:companyId/salary-runs/:id', () => { + it('returns the salary run', async () => { + mockServiceClient.mockReturnValue( + makeFlexibleSupabase({ + company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null }, + salary_runs: { data: SAMPLE_RUN, error: null }, + }), + ) + + const res = await getSalaryRun( + makeRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/salary-runs/${RUN_ID}`), + detailParams(COMPANY_ID, RUN_ID), + ) + + expect(res.status).toBe(200) + const body = await res.json() + expect(body.data.id).toBe(RUN_ID) + }) + + it('returns 404 SALARY_RUN_NOT_FOUND when 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 getSalaryRun( + makeRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/salary-runs/${RUN_ID}`), + detailParams(COMPANY_ID, RUN_ID), + ) + + expect(res.status).toBe(404) + const body = await res.json() + expect(body.error.code).toBe('SALARY_RUN_NOT_FOUND') + }) +}) + +describe('POST /api/v1/companies/:companyId/salary-runs', () => { + const validBody = { + period_year: 2026, + period_month: 5, + payment_date: '2026-05-25', + voucher_series: 'L', + } + + it('creates a salary run and emits salary_run.created (happy path)', async () => { + mockServiceClient.mockReturnValue( + makeFlexibleSupabase({ + company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null }, + salary_runs: { data: SAMPLE_RUN, error: null }, + idempotency_keys: { data: null, error: null }, + }), + ) + + const res = await createSalaryRun( + makeRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/salary-runs`, { + method: 'POST', + body: JSON.stringify(validBody), + }), + companyParams(COMPANY_ID), + ) + + expect(res.status).toBe(201) + const body = await res.json() + expect(body.data.id).toBe(RUN_ID) + expect(body.data.status).toBe('draft') + }) + + it('returns 409 SALARY_RUN_DUPLICATE_PERIOD on unique-constraint violation', async () => { + mockServiceClient.mockReturnValue( + makeFlexibleSupabase({ + company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null }, + salary_runs: { + data: null, + error: { + code: '23505', + message: 'duplicate', + // The inline `UNIQUE (company_id, period_year, period_month)` + // constraint is auto-named `
__key`. The route + // disambiguates 23505s by substring-matching on the columns. + constraint: 'salary_runs_company_id_period_year_period_month_key', + }, + }, + idempotency_keys: { data: null, error: null }, + }), + ) + + const res = await createSalaryRun( + makeRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/salary-runs`, { + method: 'POST', + body: JSON.stringify(validBody), + }), + companyParams(COMPANY_ID), + ) + + expect(res.status).toBe(409) + const body = await res.json() + expect(body.error.code).toBe('SALARY_RUN_DUPLICATE_PERIOD') + }) + + it('returns 400 for period_month out of range', async () => { + mockServiceClient.mockReturnValue( + makeFlexibleSupabase({ + company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null }, + }), + ) + const res = await createSalaryRun( + makeRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/salary-runs`, { + method: 'POST', + body: JSON.stringify({ ...validBody, period_month: 13 }), + }), + companyParams(COMPANY_ID), + ) + expect(res.status).toBe(400) + }) + + 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`, { + method: 'POST', + headers: { Authorization: 'Bearer test' }, + body: JSON.stringify(validBody), + }) + + const res = await createSalaryRun(req, companyParams(COMPANY_ID)) + expect(res.status).toBe(400) + }) + + it('returns a dry-run preview without committing when ?dry_run=true', async () => { + const fromSpy = vi.fn() + mockServiceClient.mockReturnValue({ + from: (table: string) => { + fromSpy(table) + return new Proxy({}, { + get(_t, prop) { + if (prop === 'then') { + const data = table === 'company_members' + ? { company_id: COMPANY_ID, role: 'owner' } + : null + return (resolve: (v: unknown) => void) => resolve({ data, error: null }) + } + return () => new Proxy({}, this!) + }, + }) + }, + }) + + const res = await createSalaryRun( + makeRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/salary-runs?dry_run=true`, { + method: 'POST', + body: JSON.stringify(validBody), + }), + companyParams(COMPANY_ID), + ) + + expect(res.status).toBe(200) + expect(res.headers.get('X-Dry-Run')).toBe('true') + expect(fromSpy).not.toHaveBeenCalledWith('salary_runs') + }) +}) + +describe('PATCH /api/v1/companies/:companyId/salary-runs/:id', () => { + it('updates a draft salary run', async () => { + const updated = { ...SAMPLE_RUN, payment_date: '2026-05-23' } + mockServiceClient.mockReturnValue( + makeFlexibleSupabase({ + company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null }, + salary_runs: [{ data: SAMPLE_RUN, error: null }, { data: updated, error: null }], + idempotency_keys: { data: null, error: null }, + }), + ) + + const res = await updateSalaryRun( + makeRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/salary-runs/${RUN_ID}`, { + method: 'PATCH', + body: JSON.stringify({ payment_date: '2026-05-23' }), + }), + detailParams(COMPANY_ID, RUN_ID), + ) + + expect(res.status).toBe(200) + const body = await res.json() + expect(body.data.payment_date).toBe('2026-05-23') + }) + + it('returns 400 SALARY_RUN_PATCH_NOT_DRAFT for non-draft status', async () => { + const approved = { ...SAMPLE_RUN, status: 'approved' } + mockServiceClient.mockReturnValue( + makeFlexibleSupabase({ + company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null }, + salary_runs: { data: approved, error: null }, + idempotency_keys: { data: null, error: null }, + }), + ) + + const res = await updateSalaryRun( + makeRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/salary-runs/${RUN_ID}`, { + method: 'PATCH', + body: JSON.stringify({ payment_date: '2026-05-23' }), + }), + detailParams(COMPANY_ID, RUN_ID), + ) + + expect(res.status).toBe(400) + const body = await res.json() + expect(body.error.code).toBe('SALARY_RUN_PATCH_NOT_DRAFT') + expect(body.error.details.current_status).toBe('approved') + }) + + it('returns 404 SALARY_RUN_NOT_FOUND when row missing', async () => { + mockServiceClient.mockReturnValue( + makeFlexibleSupabase({ + company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null }, + salary_runs: { data: null, error: null }, + idempotency_keys: { data: null, error: null }, + }), + ) + + const res = await updateSalaryRun( + makeRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/salary-runs/${RUN_ID}`, { + method: 'PATCH', + body: JSON.stringify({ payment_date: '2026-05-23' }), + }), + detailParams(COMPANY_ID, RUN_ID), + ) + + expect(res.status).toBe(404) + }) + + it('rejects voucher_series that is not a single A-Z letter', async () => { + mockServiceClient.mockReturnValue( + makeFlexibleSupabase({ + company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null }, + salary_runs: { data: SAMPLE_RUN, error: null }, + idempotency_keys: { data: null, error: null }, + }), + ) + + const res = await updateSalaryRun( + makeRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/salary-runs/${RUN_ID}`, { + method: 'PATCH', + body: JSON.stringify({ voucher_series: 'AB' }), + }), + detailParams(COMPANY_ID, RUN_ID), + ) + + expect(res.status).toBe(400) + }) +}) + +describe('DELETE /api/v1/companies/:companyId/salary-runs/:id', () => { + it('deletes a draft salary run', async () => { + mockServiceClient.mockReturnValue( + makeFlexibleSupabase({ + company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null }, + salary_runs: [ + // First read: existing row check + { data: { id: RUN_ID, status: 'draft' }, error: null }, + // Second op: the DELETE call + { error: null, count: 1 }, + ], + idempotency_keys: { data: null, error: null }, + }), + ) + + const res = await deleteSalaryRun( + makeRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/salary-runs/${RUN_ID}`, { + method: 'DELETE', + }), + detailParams(COMPANY_ID, RUN_ID), + ) + + expect(res.status).toBe(204) + }) + + it('refuses to delete a non-draft salary run (BFL 5 kap immutability)', 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 deleteSalaryRun( + makeRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/salary-runs/${RUN_ID}`, { + method: 'DELETE', + }), + detailParams(COMPANY_ID, RUN_ID), + ) + + expect(res.status).toBe(400) + const body = await res.json() + expect(body.error.code).toBe('SALARY_RUN_DELETE_NOT_DRAFT') + expect(body.error.details.current_status).toBe('booked') + }) + + it('returns 404 SALARY_RUN_NOT_FOUND for unknown ids', async () => { + mockServiceClient.mockReturnValue( + makeFlexibleSupabase({ + company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null }, + salary_runs: { data: null, error: null }, + idempotency_keys: { data: null, error: null }, + }), + ) + + const res = await deleteSalaryRun( + makeRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/salary-runs/${RUN_ID}`, { + method: 'DELETE', + }), + detailParams(COMPANY_ID, RUN_ID), + ) + + expect(res.status).toBe(404) + }) + + it('refuses to delete a draft that already has journal-entry foreign keys set (BFL 5 kap)', async () => { + // Defense in depth: if a hypothetical partial failure ever left a row + // in status=draft with non-null salary_entry_id, the DELETE must NOT + // orphan the verifikation. The route's .is('salary_entry_id', null) + // filter trips, count comes back 0, and we surface the race-style + // 400 SALARY_RUN_DELETE_NOT_DRAFT. + mockServiceClient.mockReturnValue( + makeFlexibleSupabase({ + company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null }, + salary_runs: [ + // First read: row is status=draft (passes the pre-flight) + { data: { id: RUN_ID, status: 'draft' }, error: null }, + // DELETE: the FK-null guards trip, count=0 + { error: null, count: 0 }, + ], + idempotency_keys: { data: null, error: null }, + }), + ) + + const res = await deleteSalaryRun( + makeRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/salary-runs/${RUN_ID}`, { + method: 'DELETE', + }), + detailParams(COMPANY_ID, RUN_ID), + ) + + expect(res.status).toBe(400) + const body = await res.json() + expect(body.error.code).toBe('SALARY_RUN_DELETE_NOT_DRAFT') + expect(body.error.details.reason).toBe('race') + }) +}) diff --git a/app/api/v1/companies/[companyId]/salary-runs/route.ts b/app/api/v1/companies/[companyId]/salary-runs/route.ts new file mode 100644 index 00000000..22a911b7 --- /dev/null +++ b/app/api/v1/companies/[companyId]/salary-runs/route.ts @@ -0,0 +1,347 @@ +/** + * /api/v1/companies/{companyId}/salary-runs — list + create salary runs. + * + * GET — list with filters (period_year, status). Cursor pagination on + * (created_at ASC, id ASC). + * POST — create a new monthly salary run. New runs start in `draft` status. + * The line items and per-employee calculations are populated by + * POST /salary-runs/{id}/calculate. Idempotent (mandatory Idempotency-Key). + * Dry-runnable. + * + * The `(company_id, period_year, period_month)` tuple is uniquely indexed at + * the DB layer; duplicate creation returns 409 SALARY_RUN_DUPLICATE_PERIOD. + */ + +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 } from '@/lib/api/v1/registry' +import { withApiV1 } from '@/lib/api/v1/with-api-v1' +import { v1ErrorResponse, v1ErrorResponseFromCode } from '@/lib/api/v1/errors' +import { CreateSalaryRunSchema } from '@/lib/api/schemas' +import { eventBus } from '@/lib/events' + +const SalaryRunStatus = z.enum(['draft', 'review', 'approved', 'paid', 'booked', 'corrected']) + +const SalaryRunSummary = z.object({ + id: z.string().uuid(), + period_year: z.number().int(), + period_month: z.number().int(), + payment_date: z.string(), + status: SalaryRunStatus, + voucher_series: z.string(), + total_gross: z.number(), + total_tax: z.number(), + total_net: z.number(), + total_avgifter: z.number(), + total_employer_cost: z.number(), + agi_generated_at: z.string().nullable(), + agi_submitted_at: z.string().nullable(), + approved_at: z.string().nullable(), + paid_at: z.string().nullable(), + booked_at: z.string().nullable(), + created_at: z.string(), +}) + +const SalaryRunsListResponse = z.object({ salary_runs: z.array(SalaryRunSummary) }) + +const SALARY_RUN_SUMMARY_COLUMNS = + 'id, period_year, period_month, payment_date, status, voucher_series, total_gross, total_tax, total_net, total_avgifter, total_employer_cost, agi_generated_at, agi_submitted_at, approved_at, paid_at, booked_at, created_at' + +registerEndpoint({ + operation: 'salary-runs.list', + method: 'GET', + path: '/api/v1/companies/:companyId/salary-runs', + summary: 'List salary runs.', + description: + 'Returns salary runs in created-first order with their lifecycle status (draft|review|approved|paid|booked|corrected) and denormalised totals. Filters: ?period_year=YYYY, ?status=draft.', + useWhen: + 'You need an overview of payroll activity — for building a list view, finding the current open run, or resolving a salary_run_id before invoking a lifecycle verb.', + doNotUseFor: + 'Per-employee details (those live on the detail endpoint). Salary journal report (use GET /reports/salary-journal in Phase 5 PR-3).', + pitfalls: [ + 'A company has at most one salary run per (period_year, period_month). The unique constraint is at the DB layer.', + 'Totals are denormalised: they are 0 until POST /calculate runs.', + '`corrected` status is reached via the internal /correct route (not yet exposed on v1) — Phase 5 PR-1 ships create/calculate/approve/mark-paid/book/generate-agi only.', + ], + example: { + response: { + data: [ + { + id: 'run_a8f1…', + period_year: 2026, + period_month: 5, + payment_date: '2026-05-25', + status: 'draft', + voucher_series: 'A', + total_gross: 0, + total_tax: 0, + total_net: 0, + total_avgifter: 0, + total_employer_cost: 0, + }, + ], + 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: SalaryRunsListResponse }, +}) + +export const GET = withApiV1<{ params: Promise<{ companyId: string }> }>( + 'salary-runs.list', + async (request, ctx) => { + const url = new URL(request.url) + const { limit, cursor } = parsePaginationParams(url) + const decoded = decodeDefaultCursor(cursor) + + const FiltersSchema = z.object({ + period_year: z.coerce.number().int().min(2020).max(2100).optional(), + status: SalaryRunStatus.optional(), + }) + const filtersResult = FiltersSchema.safeParse({ + period_year: url.searchParams.get('period_year') ?? undefined, + status: url.searchParams.get('status') ?? undefined, + }) + if (!filtersResult.success) { + return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, { + requestId: ctx.requestId, + details: { + issues: filtersResult.error.issues.map((i) => ({ + field: i.path.join('.'), + message: i.message, + })), + }, + }) + } + const filters = filtersResult.data + + let query = ctx.supabase + .from('salary_runs') + .select(SALARY_RUN_SUMMARY_COLUMNS) + .eq('company_id', ctx.companyId!) + .order('created_at', { ascending: true }) + .order('id', { ascending: true }) + .limit(limit + 1) + + if (filters.period_year !== undefined) { + query = query.eq('period_year', filters.period_year) + } + if (filters.status) { + query = query.eq('status', filters.status) + } + + 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 + created_at: string + } & Record + + const rows = ((data ?? []) as unknown) as Row[] + const trimmed = rows.slice(0, limit) + const hasMore = rows.length > limit + + const last = trimmed[trimmed.length - 1] + const nextCursor = hasMore && last + ? encodeDefaultCursor({ id: last.id, created_at: last.created_at }) + : null + + return paginated(trimmed, { + requestId: ctx.requestId, + nextCursor: nextCursor ?? undefined, + }) + }, +) + +// ────────────────────────────────────────────────────────────────── +// POST — create salary run +// ────────────────────────────────────────────────────────────────── + +const SalaryRunCreated = SalaryRunSummary.extend({ + notes: z.string().nullable(), + calculation_params: z.unknown().nullable(), + updated_at: z.string(), +}) + +const SALARY_RUN_DETAIL_COLUMNS = + 'id, period_year, period_month, payment_date, status, voucher_series, total_gross, total_tax, total_net, total_avgifter, total_vacation_accrual, total_employer_cost, salary_entry_id, avgifter_entry_id, vacation_entry_id, agi_generated_at, agi_submitted_at, calculation_params, approved_by, approved_at, paid_at, booked_at, booked_by, notes, created_at, updated_at' + +registerEndpoint({ + operation: 'salary-runs.create', + method: 'POST', + path: '/api/v1/companies/:companyId/salary-runs', + summary: 'Create a salary run.', + description: + 'Creates a draft salary run for the given period (period_year, period_month). The run starts empty — add employees via the internal /salary/runs/{id}/employees endpoints, then POST /salary-runs/{id}/calculate. Requires Idempotency-Key. Dry-runnable.', + useWhen: + 'You are starting a new month\'s payroll. Use dry-run first to validate the period + voucher_series choice without committing.', + doNotUseFor: + 'Adding employees to an existing run (that is a separate surface — see internal /salary/runs/{id}/employees for Phase 5 PR-1; promoting it to v1 is deferred to a follow-up).', + pitfalls: [ + 'Idempotency-Key is mandatory.', + 'Duplicate (period_year, period_month) for the same company returns 409 SALARY_RUN_DUPLICATE_PERIOD.', + 'period_month is 1–12. The DB CHECK enforces this — a 0 or 13 returns 400 VALIDATION_ERROR before reaching the DB.', + 'voucher_series defaults to "A". If the company uses a dedicated salary voucher series, set it explicitly.', + 'A newly-created run has no employees — :calculate without employees returns 400 SALARY_RUN_NO_EMPLOYEES.', + ], + example: { + request: { + period_year: 2026, + period_month: 5, + payment_date: '2026-05-25', + voucher_series: 'L', + }, + response: { + data: { + id: 'run_a8f1…', + period_year: 2026, + period_month: 5, + payment_date: '2026-05-25', + status: 'draft', + voucher_series: 'L', + }, + meta: { request_id: 'req_…', api_version: '2026-05-12' }, + }, + }, + scope: 'payroll:write', + risk: 'low', + idempotent: true, + reversible: true, + dryRunSupported: true, + request: { body: CreateSalaryRunSchema }, + response: { success: SalaryRunCreated }, +}) + +export const POST = withApiV1<{ params: Promise<{ companyId: string }> }>( + 'salary-runs.create', + 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 = CreateSalaryRunSchema.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 + + if (ctx.dryRun) { + return dryRunPreview( + { + id: null, + period_year: body.period_year, + period_month: body.period_month, + payment_date: body.payment_date, + status: 'draft' as const, + voucher_series: body.voucher_series, + total_gross: 0, + total_tax: 0, + total_net: 0, + total_avgifter: 0, + total_vacation_accrual: 0, + total_employer_cost: 0, + notes: body.notes ?? null, + calculation_params: null, + approved_by: null, + approved_at: null, + paid_at: null, + booked_at: null, + booked_by: null, + agi_generated_at: null, + agi_submitted_at: null, + created_at: null, + updated_at: null, + }, + { requestId: ctx.requestId, log: ctx.log }, + ) + } + + const { data, error } = await ctx.supabase + .from('salary_runs') + .insert({ + user_id: ctx.userId, + company_id: ctx.companyId!, + period_year: body.period_year, + period_month: body.period_month, + payment_date: body.payment_date, + voucher_series: body.voucher_series, + notes: body.notes ?? null, + status: 'draft', + }) + .select(SALARY_RUN_DETAIL_COLUMNS) + .single() + + if (error) { + // Disambiguate 23505 by constraint name. The salary_runs table has one + // unique index today: (company_id, period_year, period_month). A future + // migration could add another; mapping every 23505 here to + // SALARY_RUN_DUPLICATE_PERIOD would be misleading once that happens. + if (error.code === '23505') { + const constraint = (error as { constraint?: string }).constraint + if (constraint && constraint.includes('period_year')) { + return v1ErrorResponseFromCode('SALARY_RUN_DUPLICATE_PERIOD', ctx.log, { + requestId: ctx.requestId, + details: { + field: 'period', + period_year: body.period_year, + period_month: body.period_month, + }, + }) + } + } + return v1ErrorResponse(error, ctx.log, { requestId: ctx.requestId }) + } + + try { + await eventBus.emit({ + type: 'salary_run.created', + payload: { + salaryRunId: (data as { id: string }).id, + periodYear: body.period_year, + periodMonth: body.period_month, + userId: ctx.userId, + companyId: ctx.companyId!, + }, + }) + } catch (err) { + ctx.log.warn('salary_run.created emit failed', err as Error) + } + + return created(data, { requestId: ctx.requestId }) + }, + { requireIdempotencyKey: true }, +) diff --git a/lib/api/v1/load-routes.ts b/lib/api/v1/load-routes.ts index 8dfa016e..79bdca4a 100644 --- a/lib/api/v1/load-routes.ts +++ b/lib/api/v1/load-routes.ts @@ -81,4 +81,13 @@ import '@/app/api/v1/companies/[companyId]/supplier-invoices/[id]/approve/route' import '@/app/api/v1/companies/[companyId]/supplier-invoices/[id]/mark-paid/route' import '@/app/api/v1/companies/[companyId]/supplier-invoices/[id]/credit/route' +// Phase 5 PR-1 — Payroll registers: employees + salary-runs CRUD. +// Lifecycle verbs (calculate / approve / mark-paid / book / generate-agi) +// ship in Phase 5 PR-2 after the internal /calculate orchestration is +// extracted into a shared lib/salary/run-calculation.ts helper. +import '@/app/api/v1/companies/[companyId]/employees/route' +import '@/app/api/v1/companies/[companyId]/employees/[id]/route' +import '@/app/api/v1/companies/[companyId]/salary-runs/route' +import '@/app/api/v1/companies/[companyId]/salary-runs/[id]/route' + export {} diff --git a/lib/api/v1/mask-personnummer.ts b/lib/api/v1/mask-personnummer.ts new file mode 100644 index 00000000..2739189b --- /dev/null +++ b/lib/api/v1/mask-personnummer.ts @@ -0,0 +1,21 @@ +/** + * Personnummer masking for v1 list/create responses. + * + * GDPR Art.5(1)(c) — data minimisation. A Swedish personnummer is a + * national identifier; the list endpoint and create-response shape mask + * the last 4 digits (the gender + checksum) so a roster scan or a + * mistaken response log doesn't leak a natural-person identifier. The + * detail endpoint (deliberate drill-in) returns the full value. + * + * Format: ÅÅÅÅMMDDNNNN → ÅÅÅÅMMDDXXXX. + * + * Defensive behavior: if the input is not exactly 12 digits, the full + * value is redacted to all-X. A short-form (10-digit) personnummer + * should never reach the database (the schema regex rejects it), but + * legacy rows or test fixtures might; redacting entirely is safer than + * leaking a partially-masked legacy value. + */ +export function maskPersonnummer(pnr: string | null | undefined): string { + if (!pnr || !/^\d{12}$/.test(pnr)) return 'XXXXXXXXXXXX' + return `${pnr.slice(0, 8)}XXXX` +} diff --git a/lib/auth/scopes.ts b/lib/auth/scopes.ts index 09f2ac11..afac0204 100644 --- a/lib/auth/scopes.ts +++ b/lib/auth/scopes.ts @@ -128,6 +128,29 @@ export const V1_ENDPOINT_SCOPES: Record = { 'POST /api/v1/companies/:companyId/reconciliation/bank/run': 'transactions:write', 'GET /api/v1/companies/:companyId/reconciliation/bank/status': 'transactions:read', + // Phase 5 PR-1 — Payroll vertical (employees + salary-runs + lifecycle verbs). + // Reuses the pre-existing `payroll:read` / `payroll:write` scopes already + // defined for the MCP tool surface (gnubok_list_employees, gnubok_create_salary_run, ...). + // Employees (soft-delete via is_active — no archived_at column). + 'GET /api/v1/companies/:companyId/employees': 'payroll:read', + 'GET /api/v1/companies/:companyId/employees/:id': 'payroll:read', + 'POST /api/v1/companies/:companyId/employees': 'payroll:write', + 'PATCH /api/v1/companies/:companyId/employees/:id': 'payroll:write', + 'DELETE /api/v1/companies/:companyId/employees/:id': 'payroll:write', + // Salary runs (state machine: draft → review → approved → paid → booked). + 'GET /api/v1/companies/:companyId/salary-runs': 'payroll:read', + 'GET /api/v1/companies/:companyId/salary-runs/:id': 'payroll:read', + 'POST /api/v1/companies/:companyId/salary-runs': 'payroll:write', + 'PATCH /api/v1/companies/:companyId/salary-runs/:id': 'payroll:write', + 'DELETE /api/v1/companies/:companyId/salary-runs/:id': 'payroll:write', + // Salary-run lifecycle verbs — v1 :calculate collapses internal /calculate + // (math) + /review (state advance) so an agent has one verb per logical step. + 'POST /api/v1/companies/:companyId/salary-runs/:id/calculate': 'payroll:write', + 'POST /api/v1/companies/:companyId/salary-runs/:id/approve': 'payroll:write', + 'POST /api/v1/companies/:companyId/salary-runs/:id/mark-paid': 'payroll:write', + 'POST /api/v1/companies/:companyId/salary-runs/:id/book': 'payroll:write', + 'POST /api/v1/companies/:companyId/salary-runs/:id/generate-agi': 'payroll:write', + // Webhooks (Phase 6 — placeholder so the catalogue is complete) 'GET /api/v1/companies/:companyId/webhooks': 'webhooks:manage', 'POST /api/v1/companies/:companyId/webhooks': 'webhooks:manage', diff --git a/lib/errors/structured-errors.ts b/lib/errors/structured-errors.ts index 4a7d0876..f11bfa64 100644 --- a/lib/errors/structured-errors.ts +++ b/lib/errors/structured-errors.ts @@ -1314,6 +1314,62 @@ const SALARY: Record = { message_sv: 'AGI-deklarationen kunde inte genereras.', message_en: 'Failed to generate AGI declaration.', }, + // Phase 5 PR-1 — v1 REST surface error codes. + EMPLOYEE_NOT_FOUND: { + httpStatus: 404, + message_sv: 'Den anställda kunde inte hittas.', + message_en: 'Employee not found.', + }, + EMPLOYEE_DUPLICATE_PERSONNUMMER: { + httpStatus: 409, + message_sv: 'En anställd med samma personnummer finns redan.', + message_en: 'An employee with that personnummer already exists.', + }, + SALARY_RUN_DUPLICATE_PERIOD: { + httpStatus: 409, + message_sv: 'En lönekörning för perioden finns redan.', + message_en: 'A salary run for that period already exists.', + }, + SALARY_RUN_PATCH_NOT_DRAFT: { + httpStatus: 400, + message_sv: 'Endast utkast (draft) kan uppdateras.', + message_en: 'Only draft salary runs can be patched.', + }, + SALARY_RUN_DELETE_NOT_DRAFT: { + httpStatus: 400, + message_sv: 'Endast utkast (draft) kan raderas.', + message_en: 'Only draft salary runs can be deleted.', + }, + SALARY_RUN_CALCULATE_NOT_DRAFT: { + httpStatus: 400, + message_sv: 'Lönekörningen måste vara i status draft för beräkning.', + message_en: 'Salary run must be in draft status to calculate.', + }, + SALARY_RUN_APPROVE_NOT_REVIEW: { + httpStatus: 400, + message_sv: 'Lönekörningen måste vara i status review för godkännande.', + message_en: 'Salary run must be in review status to approve.', + }, + SALARY_RUN_APPROVE_VALIDATION_FAILED: { + httpStatus: 400, + message_sv: 'Valideringsfel — korrigera innan godkännande.', + message_en: 'Validation failed — fix issues before approving.', + }, + SALARY_RUN_MARK_PAID_NOT_APPROVED: { + httpStatus: 400, + message_sv: 'Lönekörningen måste vara godkänd för att markeras som betald.', + message_en: 'Salary run must be approved before it can be marked paid.', + }, + SALARY_RUN_BOOK_NOT_PAID: { + httpStatus: 400, + message_sv: 'Lönekörningen måste vara markerad som betald för bokföring.', + message_en: 'Salary run must be marked paid before booking.', + }, + AGI_GENERATE_NOT_BOOKABLE: { + httpStatus: 400, + message_sv: 'AGI kan endast genereras för lönekörningar i status review, approved, paid, booked eller corrected.', + message_en: 'AGI can only be generated for salary runs in review, approved, paid, booked, or corrected status.', + }, } const COMPANY: Record = {