feat(api): Phase 5 PR-1 — payroll registers (employees + salary-runs CRUD) (#479)

* feat(api): Phase 5 PR-1 — payroll registers (employees + salary-runs CRUD)

10 endpoints under /api/v1, 35 integration tests. Mirrors Phase 4 PR-1 size
and review profile. No engine interaction; no period-lock checks. The
lifecycle verbs (calculate / approve / mark-paid / book / generate-agi)
ship in Phase 5 PR-2 after the 557-line internal /calculate orchestration
is extracted into a shared lib/salary/run-calculation.ts helper.

Employees CRUD:
- GET/POST /employees + GET/PATCH/DELETE /{id}
- Soft-delete via is_active=false (BFL 7 kap retention — the employees
  table has no archived_at column, deliberately diverging from suppliers
  and customers)
- PATCH drops personnummer changes — identity is immutable post-create
- GDPR Art.5(1)(c) personnummer masking: list, create response, and
  dry-run preview mask to ÅÅÅÅMMDDXXXX. Detail endpoint (deliberate
  drill-in) returns the full value. EMPLOYEE_DUPLICATE_PERSONNUMMER
  error never echoes back the supplied value.
- Mask helper extracted to lib/api/v1/mask-personnummer.ts

Salary-runs CRUD:
- GET/POST /salary-runs + GET/PATCH/DELETE /{id}
- POST emits salary_run.created
- PATCH + DELETE are draft-only with optimistic-lock guards
  (status filter on the UPDATE / DELETE so a concurrent verb that flips
  status yields a clean 409 rather than a silent no-op)
- PATCH only writes keys explicitly present in the request body to avoid
  Zod-default overwrite (every PATCH would silently reset
  is_sidoinkomst=false otherwise)
- DELETE is hard delete on the salary_runs row — CASCADE on
  salary_run_employees and salary_line_items. Only draft runs can be
  deleted; once :calculate runs the BFL 5 kap immutability applies and
  storno is the only correction path

Scopes:
- Reuses existing payroll:read / payroll:write from the MCP tool surface
- 16 new endpoint patterns registered in V1_ENDPOINT_SCOPES (10 for
  PR-1 + 6 placeholders for PR-2's lifecycle verbs and AGI generation)

Error codes (12 new structured-error entries):
- PR-1 live: EMPLOYEE_NOT_FOUND, EMPLOYEE_DUPLICATE_PERSONNUMMER,
  SALARY_RUN_DUPLICATE_PERIOD, SALARY_RUN_PATCH_NOT_DRAFT,
  SALARY_RUN_DELETE_NOT_DRAFT
- PR-2 pre-registered: SALARY_RUN_CALCULATE_NOT_DRAFT,
  SALARY_RUN_APPROVE_NOT_REVIEW, SALARY_RUN_APPROVE_VALIDATION_FAILED,
  SALARY_RUN_MARK_PAID_NOT_APPROVED, SALARY_RUN_BOOK_NOT_PAID,
  AGI_GENERATE_NOT_BOOKABLE

Tests (35 cases):
- Employees: 18 — list with masked pnr, detail with full pnr, create
  happy path, duplicate-pnr 409 with no echo, dry-run masking, missing
  Idempotency-Key, wrong-length pnr, A-skatt tax-table requirement,
  PATCH happy + 404, identity-change drop, soft-delete + idempotent
  re-delete + 404
- Salary-runs: 17 — list + filter validation + scope rejection, detail
  + 404, create happy + duplicate-period 409 + period_month range +
  missing Idempotency-Key + dry-run, PATCH happy + non-draft 400 + 404
  + voucher_series regex, DELETE draft + non-draft 400 + 404

Plan doc updated to reflect the 4-PR split for Phase 5.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* refactor(api): address PR-479 review — disambiguate 23505, mask PATCH responses, return 400 on personnummer-in-PATCH

Triage of PR-479 review bots:

- **Greptile P1 (`ensureInitialized()` missing on salary-runs/route.ts)** —
  FALSE POSITIVE. The v1 wrapper at `lib/api/v1/with-api-v1.ts:52` calls
  `ensureInitialized()` at module load; every v1 route inherits the
  initialization transitively via the `withApiV1` import. All 10+ existing
  v1 routes that emit events (suppliers, customers, invoices, supplier-
  invoices, etc.) follow the same pattern. The wrapper file's own comment
  documents the centralization. No fix needed; Greptile is applying the
  CLAUDE.md rule literally without checking the wrapper.

- **Greptile P2 (23505 constraint disambiguation)** — FIXED.
  Both employees and salary-runs POST routes previously mapped every
  23505 unique-violation to a single error code (EMPLOYEE_DUPLICATE_
  PERSONNUMMER / SALARY_RUN_DUPLICATE_PERIOD). A future migration adding
  another unique index (e.g. employees(company_id, email)) would have
  produced misleading errors. Now check `error.constraint` and only map
  when the constraint name matches the known column. Substring match
  rather than exact equality so an explicit constraint rename doesn't
  silently fall through.

  Added a defensive test asserting that a hypothetical
  `employees_company_id_email_key` 23505 does NOT get mapped to
  EMPLOYEE_DUPLICATE_PERSONNUMMER.

- **GDPR Art.5(1)(c) — PATCH response + dry-run preview masking** — FIXED.
  Previously the PATCH response and dry-run preview echoed the full
  personnummer back via the EmployeeDetail schema. Now both return
  `personnummer_masked` instead, symmetric with the POST response. Added
  `EmployeeWriteResponse` schema (EmployeeDetail.omit + extend) so the
  OpenAPI spec accurately distinguishes GET (full) from PATCH (masked).
  Added `maskExistingForResponse` helper to drop the raw field and
  substitute the masked form. The GET drill-in endpoint still returns
  the full value (deliberate design — caller already has the id).

- **SOC 2 PI1.3 — silent personnummer drop on PATCH** — FIXED.
  PATCH previously dropped any personnummer field in the body via a
  runtime `delete` after parsing. Caller saw no signal that the
  intent was rejected. Now return explicit 400 VALIDATION_ERROR with
  `field: 'personnummer'` and a remediation message ("DELETE and
  recreate if the natural-person identity has changed"). The Zod
  schema can't enforce this because `UpdateEmployeeSchema` is shared
  with the internal dashboard route (which DOES support personnummer
  updates); the check is route-specific.

- **ISO A.5.34 — real-format personnummer in docs/tests** — FIXED.
  Replaced `198504121234` / `199001019999` / `199012105678` with
  obviously-synthetic `190001010000` / `190001020000` / `190001029999`
  (year 1900, day 1, zero-suffix) across the registerEndpoint examples
  and SAMPLE_PERSONNUMMER test fixture. Still passes the `^\d{12}$`
  schema regex, but no longer looks like a real birthdate that could
  be mistaken for production-format PII in CI artefacts or doc renders.

Findings explicitly NOT addressed in this commit (and rationale):

- **Detail endpoint returns full personnummer + bank account** (multiple
  bots: GDPR Art.5(1)(c), ISO A.8.11, SOC 2 CC6.1). INTENTIONAL design.
  The detail endpoint is the deliberate drill-in for callers who
  already have the id and the `payroll:read` scope. Matches the
  dashboard's internal /api/salary/employees/[id] behavior. Splitting
  into a separate `payroll:admin` scope is a CC6.3 architectural
  decision deferred (same as the Phase 4 `payroll:read` vs
  `payroll:write` split — fine-grained tiers haven't been justified
  by integrator demand yet).

- **calculation_params shape (Art.5(1)(b) / CC2.1)** — DEFERRED to
  Phase 5 PR-2. PR-1 only READS the column; the column is WRITTEN
  by the lifecycle verbs (PR-2's :calculate). PR-2 will define the
  typed shape and revisit whether the public response shape should
  expose it.

- **F-skatt re-verification age-gate (swedish-payroll)** — DEFERRED to
  Phase 5 PR-2. The employees table already carries
  `f_skatt_verified_at` (existing migration). PR-2's :calculate is
  the correct enforcement point.

- **Soft-delete + unique constraint partial index** (swedish-
  accounting-compliance). VALID concern for genuine rehires. Out of
  v1 PR-1 surface — a separate DB migration that touches the
  `employees_company_id_personnummer_key` constraint, with its own
  pg-test for the rehire scenario. Tracked.

- **semestertillagg_rate vs vacation_rule consistency** (swedish-
  payroll). Engine-layer concern. The schema validates the range; the
  rule/rate consistency check belongs in `lib/salary/calculation-
  engine.ts` next to the actual accrual math. Tracked for the engine
  audit alongside Phase 5 PR-2.

- **voucher_series default 'A' vs convention 'N'** (swedish-payroll).
  Worth a stronger doc warning in PR-2's lifecycle verbs (where the
  series actually lands on a verifikation). The CRUD route can default
  to whatever; the warning belongs where the series matters.

- **personnummer_last4 column** (Art.25). Schema design from the
  salary module migration — display-only index for table views. Out
  of v1 scope.

- **Bank account at-rest encryption (CC6.1)** — separate migration
  concern across all tables that carry financial identifiers. Out of
  v1 scope.

Test count: 37 (up from 35). All type-checks clean. Full v1 suite green
(232 tests).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* refactor(api): address PR-479 review round 2 — proto-pollution defense + salary-run JE-orphan guard

Triage of bot re-run on c0d168be:

- **Compliance Swarm V4.5 (prototype pollution in PATCH rawKeys)** — FIXED.
  `Object.keys(rawBody as object)` could include `__proto__` / `constructor`
  as own properties when rawBody comes from JSON.parse (JSON specifically
  treats `__proto__` as a data property, not a prototype assignment). The
  subsequent intersection with Zod-parsed `body` already prevented those
  keys from reaching the DB (Zod's parsed output never contains them), but
  the explicit POLLUTING_KEYS filter makes the intent unambiguous for
  future readers. Defense in depth.

- **Swedish Compliance Review — Salary-run DELETE missing JE FK null
  guard (BFL 5 kap räkenskapsinformation)** — FIXED. The DELETE chain
  previously gated only on `status='draft'`. The lifecycle never advances
  past draft with the JE foreign keys populated, so in practice this was
  safe, but a partial-failure path in PR-2 could hypothetically leave a
  row in status=draft with `salary_entry_id` set. The .is() null guards
  on all three JE foreign keys (salary_entry_id, avgifter_entry_id,
  vacation_entry_id) turn that hypothetical into a clean 400 rather than
  orphaning a verifikation.

  Added a defensive test: a hypothetical state where the pre-flight read
  returns status=draft but the DELETE count comes back 0 (guards
  tripped) must surface SALARY_RUN_DELETE_NOT_DRAFT with reason 'race'.

Findings on this round explicitly NOT addressed:

- **V16.1.1 + Art.5(1)(f) on app/api/bookkeeping/journal-entries/[id]/
  commit/route.ts** — NOT MY FILES. Existing Phase 4 PR-2 code; the bot
  is reporting on the whole repo, not just the diff.

- **V2.2 PostgREST .or() injection (recurring)** — Known false positive.
  Same escaping pattern as suppliers + customers since Phase 2. The
  documented architectural floor per the plan doc.

- **Art.5(1)(c) detail-endpoint full personnummer** — Documented design
  decision (deliberate drill-in, matches dashboard). Same as the
  previous round.

- **Art.25(1) "structured-format personnummer in example"** — Already
  replaced with synthetic 190001010000 in c0d168be. Bot is now
  suggesting a non-numeric placeholder (e.g. 'YYYYMMDDXXXX'). Picky
  preference, oscillation pattern; current value passes the schema's
  ^\d{12}$ regex while being obviously synthetic (year 1900, day 1,
  zero suffix). No change.

- **Swedish bot's F-skatt re-verification + Växa-stöd + semestertillagg
  floor + voucher_series 'N'** — All deferred to Phase 5 PR-2 per the
  previous commit body. The lifecycle verbs are where these belong.

Test count: 38 (+1 for the JE-orphan guard test). 233 total v1 tests
green. Type-check clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* refactor(api): address PR-479 review round 3 — symmetrize PATCH defenses + tighten docs/BFL wording

Compliance Swarm dropped 18 → 15 findings on the round-2 commit; the floor
is narrowing. This commit addresses the remaining actionable items.

- **V2.3 / PI1.3 — salary-run PATCH missing POLLUTING_KEYS filter** — FIXED.
  Same defense as employees PATCH (round 2). Strip __proto__/constructor/
  prototype from rawKeys before constructing the updates object. The
  intersection with the Zod-parsed body already prevented these keys
  from reaching the DB; the filter makes the intent unambiguous.

- **V4.5 — non-object rawBody check** — FIXED in both employees PATCH
  and salary-runs PATCH. After JSON.parse, require typeof === 'object',
  not null, not Array.isArray. Zod would catch a non-object body
  downstream, but the rawKeys Object.keys call uses rawBody directly;
  guarding here makes the contract explicit. (An array body would pass
  `typeof === 'object'` and produce numeric-string keys.)

- **A.5.34 — request-example personnummer too realistic** — FIXED. The
  bot oscillated round-to-round between "use a synthetic value" and
  "use a placeholder pattern". Replaced `'190001010000'` with the
  documented format pattern `'YYYYMMDDNNNN'` in the registerEndpoint
  request examples, and the corresponding masked form `'YYYYMMDDXXXX'`
  in the response examples. The format pattern (already cited in the
  schema's own error message) is self-explanatory documentation and
  cannot be mistaken for production-format PII in generated OpenAPI /
  SDK docs. Test fixtures retain `190001010000` (synthetic but valid-
  format) because they validate actual schema behavior, which the docs
  do not.

- **Swedish bot — BFL 7 kap comment slightly overstates the law** —
  FIXED. The previous comment said "BFL 7 kap requires the row to
  remain for 7 years". BFL retention attaches to the verifikationer
  (räkenskapsinformation), not strictly to the personnummer attribute
  on the master row. Tightened both the file-header comment and the
  registerEndpoint description to reflect this — and flagged that a
  future GDPR Art.17 erasure workflow could pseudonymise the row once
  all referenced verifikationer are outside the 7-year window. The
  practical outcome (soft-delete only via v1) is unchanged.

Findings on this round explicitly NOT addressed:

- **V14.2 / V16.1.1 / Art.5(1)(f) on app/api/bookkeeping/journal-
  entries/[id]/commit/route.ts** — NOT MY FILES (Phase 4 PR-2 surface).

- **V16.1 — no structured audit log on successful PATCH/POST** — The
  withApiV1 wrapper already logs "op completed" with userId, apiKeyId,
  companyId, operation, durationMs, status, dryRun. Bot is asking for
  more detail (entity-level logging) — deferred to a follow-up audit-
  log PR.

- **Art.5(1)(c) / A.8.11 / CC6.3 — detail-endpoint full personnummer**
  — Same documented design decision: deliberate drill-in for callers
  with payroll:read + the id. Mirrors the dashboard. The bots are
  asking for `payroll:pii` / `payroll:read:sensitive` scope splits;
  CC6.3 segregation-of-duties is an architectural decision deferred
  until integrator demand justifies it.

- **C1.1 — bank_account_number masking in GET detail** — Same drill-
  in pattern; separate migration concern (table-level encryption
  across all financial-identifier columns). Out of v1 PR-1 scope.

- **Art.25 — personnummer_last4 column** — Schema design from the
  salary module migration. Display-only index. Out of v1 scope.

- **Swedish bot — vaxa-stöd age gate / sidoinkomst flag / voucher_
  series 'N' / AGI from review** — All Phase 5 PR-2 lifecycle
  concerns. The AGI status gate in particular will live on the
  :generate-agi verb, not on the error-code message; PR-2 will set
  the actual gate.

- **Swedish bot — GDPR Art.17 erasure workflow on soft-deleted
  employees** — Acknowledged in the tightened BFL comment. Concrete
  erasure machinery (cron job that pseudonymises rows whose last
  referenced verifikation is past 7 years) is a separate ISMS / data-
  retention design effort, not a v1 surface PR.

Test count: 38 (unchanged). 233 total v1 tests green. Type-check clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-05-14 13:50:55 +02:00
committed by GitHub
co-authored by Claude Opus 4.7
parent 70fe8cbd80
commit 1f89a71962
10 changed files with 2960 additions and 0 deletions
@@ -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<string, unknown> {
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<string, unknown> = {}
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 })
},
)
@@ -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<typeof import('@/lib/auth/api-keys')>('@/lib/auth/api-keys')
return {
...actual,
validateApiKey: vi.fn(),
createServiceClientNoCookies: vi.fn(),
}
})
vi.mock('@supabase/supabase-js', async () => {
const actual = await vi.importActual<typeof import('@supabase/supabase-js')>('@supabase/supabase-js')
return { ...actual, createClient: vi.fn().mockReturnValue({}) }
})
import { validateApiKey, createServiceClientNoCookies } from '@/lib/auth/api-keys'
import { GET as listEmployees, POST as createEmployee } from '../route'
import {
GET as getEmployee,
PATCH as updateEmployee,
DELETE as deleteEmployee,
} from '../[id]/route'
const mockValidate = validateApiKey as ReturnType<typeof vi.fn>
const mockServiceClient = createServiceClientNoCookies as ReturnType<typeof vi.fn>
interface TableResp {
data?: unknown
error?: unknown
count?: number | null
}
function makeFlexibleSupabase(byTable: Record<string, TableResp | TableResp[]>) {
const queues = new Map<string, TableResp[]>()
for (const [t, val] of Object.entries(byTable)) {
queues.set(t, Array.isArray(val) ? [...val] : [val])
}
const buildChain = (table: string): unknown => {
const handler: ProxyHandler<object> = {
get(_target, prop) {
if (prop === 'then') {
return (resolve: (v: unknown) => void) => {
const q = queues.get(table)
const next = q && q.length > 1 ? q.shift()! : (q?.[0] ?? { data: null, error: null })
resolve(next)
}
}
return (..._args: unknown[]) => buildChain(table)
},
}
return new Proxy({}, handler)
}
return { from: vi.fn((table: string) => buildChain(table)) }
}
const COMPANY_ID = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa'
const 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 `<table>_<columns>_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')
})
})
@@ -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<string, unknown>
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 `<table>_<columns>_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 },
)
@@ -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<string, unknown> = {}
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 })
},
)
@@ -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<typeof import('@/lib/auth/api-keys')>('@/lib/auth/api-keys')
return {
...actual,
validateApiKey: vi.fn(),
createServiceClientNoCookies: vi.fn(),
}
})
vi.mock('@supabase/supabase-js', async () => {
const actual = await vi.importActual<typeof import('@supabase/supabase-js')>('@supabase/supabase-js')
return { ...actual, createClient: vi.fn().mockReturnValue({}) }
})
import { validateApiKey, createServiceClientNoCookies } from '@/lib/auth/api-keys'
import { GET as listSalaryRuns, POST as createSalaryRun } from '../route'
import {
GET as getSalaryRun,
PATCH as updateSalaryRun,
DELETE as deleteSalaryRun,
} from '../[id]/route'
const mockValidate = validateApiKey as ReturnType<typeof vi.fn>
const mockServiceClient = createServiceClientNoCookies as ReturnType<typeof vi.fn>
interface TableResp {
data?: unknown
error?: unknown
count?: number | null
}
function makeFlexibleSupabase(byTable: Record<string, TableResp | TableResp[]>) {
const queues = new Map<string, TableResp[]>()
for (const [t, val] of Object.entries(byTable)) {
queues.set(t, Array.isArray(val) ? [...val] : [val])
}
const buildChain = (table: string): unknown => {
const handler: ProxyHandler<object> = {
get(_target, prop) {
if (prop === 'then') {
return (resolve: (v: unknown) => void) => {
const q = queues.get(table)
const next = q && q.length > 1 ? q.shift()! : (q?.[0] ?? { data: null, error: null })
resolve(next)
}
}
return (..._args: unknown[]) => buildChain(table)
},
}
return new Proxy({}, handler)
}
return { from: vi.fn((table: string) => buildChain(table)) }
}
const COMPANY_ID = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa'
const RUN_ID = 'cccccccc-cccc-4ccc-8ccc-cccccccccccc'
const 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 `<table>_<columns>_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')
})
})
@@ -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<string, unknown>
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 },
)
+9
View File
@@ -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 {}
+21
View File
@@ -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`
}
+23
View File
@@ -128,6 +128,29 @@ export const V1_ENDPOINT_SCOPES: Record<string, ApiKeyScope> = {
'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',
+56
View File
@@ -1314,6 +1314,62 @@ const SALARY: Record<string, StructuredErrorEntry> = {
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<string, StructuredErrorEntry> = {