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:
co-authored by
Claude Opus 4.7
parent
70fe8cbd80
commit
1f89a71962
@@ -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 },
|
||||
)
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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`
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -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> = {
|
||||
|
||||
Reference in New Issue
Block a user