Files
accounted/lib/salary/employee-commands.ts
T
2d927349d3 fix(payroll): enforce the jamkning both-dates invariant with a CHECK constraint (#2279)
* fix(payroll): enforce the jamkning both-dates invariant with a database trigger

Root cause: PR #2240 made every application write path refuse a
jamkning_percentage without both jamkning_valid_from and
jamkning_valid_to (validateJamkning), but the rule lived only in
application code. Two writes could still store the inert shape the
engine never applies: (1) concurrent PATCHes, where both handlers
validate a fetched snapshot and then issue an unconditional partial
update, so a { jamkning_valid_to: null } that committed last left a
percentage without an end date; (2) direct SQL and service-role writes,
which bypass the validator entirely.

Fix: migration 20260904120000 adds trg_enforce_employee_jamkning_dates,
BEFORE INSERT OR UPDATE OF jamkning_percentage, jamkning_valid_from,
jamkning_valid_to ON employees. It mirrors validateJamkning: a non-null
percentage needs both dates, and valid_to may not precede valid_from.
On INSERT it always checks; on UPDATE it checks only when one of the
three columns actually changes (IS DISTINCT FROM on OLD vs NEW), so a
legacy incomplete row stored before #2240 stays editable in unrelated
ways, including by a route that writes the whole row back. The error is
SQLSTATE 23514 with the stable prefix "JAMKNING_INCOMPLETE: " followed by
the same Swedish sentence the validator produces. The function is
SECURITY INVOKER with search_path pinned. No backfill: existing
incomplete rows are listed by scripts/list-incomplete-jamkning.ts and
decided per company.

App side, jamkningIssueFromDbError in lib/salary/jamkning-rules.ts
recognises the trigger rejection, and the three update paths (dashboard
PATCH, v1 PATCH, MCP update_employee executor) answer it with the same
400 / VALIDATION_ERROR and sentence as the merged-state check, instead
of a generic 500 / INTERNAL_ERROR.

Tests: tests/pg/employees-jamkning-trigger.pg.test.ts (25 cases against
real Postgres, including the interleaved two-transaction race), unit
tests for the helper, and one race test per update path.

Fixes #2256

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015qgLgdt4mLmha1ZLFMwq1u

* fix(payroll): enforce the jamkning both-dates invariant with a CHECK constraint

Root cause: PR #2240 made every application write path refuse a
jamkning_percentage without both jamkning_valid_from and
jamkning_valid_to (validateJamkning), but the rule lived only in
application code, across many writers. Two concurrent PATCHes that each
validated a fetched snapshot and then wrote unconditionally could leave
a percentage without an end date (the engine never applies such a
beslut, so the payslip and AGI silently carry the table tax), and
direct SQL or service-role writes never saw the validator at all.

Fix, from first principles: the invariant is a row-level fact, so it is
declared as a row-level CHECK constraint, employees_jamkning_dates_check
(migration 20260904120000), added NOT VALID so the migration cannot
fail on production because of rows stored incomplete before #2240.
From now on every INSERT and every UPDATE of any row is checked. This
replaces the trigger the issue proposed: no plpgsql function, no
per-column change detection, no custom message convention, and the
rule is visible in the schema.

One behavioural difference from the proposal: a legacy incomplete row
is refused on its next edit, related or not, until the beslut is
completed (both dates) or cleared (percentage null). The application
maps that rejection (SQLSTATE 23514 naming the constraint) to the
validator's own Swedish sentence in the three update paths (dashboard
PATCH, v1 PATCH, MCP update_employee executor), so the user is told
exactly what to complete; a rejection the merged row cannot explain
(a concurrent change) gets an umbrella sentence. No backfill: those
rows are listed by scripts/list-incomplete-jamkning.ts and decided per
company.

Tests: tests/pg/employees-jamkning-check.pg.test.ts against real
Postgres (constraint shape, INSERT and UPDATE rejections and
acceptances, the interleaved two-transaction race, the legacy
consequence), unit tests for the mapping, and race plus legacy tests
per update path. The PostgREST error shape was verified against a real
PostgREST: the constraint name is in `message`, `details` carries the
failing row and is never forwarded.

Fixes #2256

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015qgLgdt4mLmha1ZLFMwq1u

---------

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-04 17:01:34 +02:00

319 lines
11 KiB
TypeScript

/**
* Shared employee master-data commands for the MCP staged-operation
* executors (create_employee / update_employee).
*
* PII contract: these functions NEVER receive a plaintext personnummer. The
* staging tool validates the caller's input with CreateEmployeeSchema,
* encrypts the personnummer at staging time, and pending_operations.params
* carries only { personnummer_encrypted, personnummer_last4 }. Masked forms
* for previews/results are derived by decrypting in-process.
*
* The internal dashboard route (app/api/salary/employees) and the v1 routes
* have their own request-shaped handlers today; this module is the executor-
* facing command layer. (Future dedup opportunity noted in the gap-closure
* plan: fold all three onto this service.)
*/
import type { SupabaseClient } from '@supabase/supabase-js'
import { decryptPersonnummer, maskPersonnummer } from '@/lib/salary/personnummer'
import { getCompanyEntityType } from '@/lib/company/context'
import { isEmploymentTypeAllowedForEntity, EF_OWNER_EMPLOYMENT_ERROR } from '@/lib/salary/employment-rules'
import { validateEmployeeBankAccount } from '@/lib/salary/payment/bank-account'
import { jamkningIssueFromDbError, touchesJamkning, validateJamkning, type JamkningFields } from '@/lib/salary/jamkning-rules'
export type EmployeeCommandResult<T> =
| { ok: true; data: T }
| { ok: false; code: string; details?: Record<string, unknown> }
export interface EmployeeSummaryResult {
employee_id: string
first_name: string
last_name: string
personnummer_masked: string
is_active: boolean
}
/** Columns an executor is allowed to write. Anything else in params is
* ignored (defense in depth against a tampered pending_operations row). */
const WRITABLE_COLUMNS = new Set([
'first_name',
'last_name',
'employment_type',
'employment_start',
'employment_end',
'employment_degree',
'hours_per_week',
'workdays_per_week',
'salary_type',
'monthly_salary',
'hourly_rate',
'tax_table_number',
'tax_column',
'tax_municipality',
'is_sidoinkomst',
'f_skatt_status',
'clearing_number',
'bank_account_number',
'vacation_rule',
'vacation_days_per_year',
'semestertillagg_rate',
'email',
'phone',
'address_line1',
'postal_code',
'city',
'vaxa_stod_eligible',
'vaxa_stod_start',
'vaxa_stod_end',
'jamkning_percentage',
'jamkning_valid_from',
'jamkning_valid_to',
'default_dimensions',
])
function pickWritable(fields: Record<string, unknown>): Record<string, unknown> {
const out: Record<string, unknown> = {}
for (const [key, value] of Object.entries(fields)) {
if (WRITABLE_COLUMNS.has(key) && value !== undefined) {
out[key] = value
}
}
return out
}
export async function createEmployee(
supabase: SupabaseClient,
args: {
companyId: string
userId: string
/** Validated CreateEmployeeSchema fields, personnummer replaced by the
* encrypted pair at staging time. */
input: Record<string, unknown> & {
personnummer_encrypted: string
personnummer_last4: string
}
},
): Promise<EmployeeCommandResult<EmployeeSummaryResult>> {
const { personnummer_encrypted, personnummer_last4, ...rest } = args.input
if (!personnummer_encrypted || !personnummer_last4) {
return { ok: false, code: 'VALIDATION_ERROR', details: { field: 'personnummer_encrypted' } }
}
const fields = pickWritable(rest)
if (!fields.first_name || !fields.last_name || !fields.employment_start) {
return {
ok: false,
code: 'VALIDATION_ERROR',
details: { message: 'first_name, last_name and employment_start are required' },
}
}
// EF owners cannot be on payroll (egna uttag, not lön). The DB trigger is
// the all-paths backstop; checking here gives a clean error. #782
const entityType = await getCompanyEntityType(supabase, args.companyId)
const employmentType = (fields.employment_type as string | undefined) ?? 'employee'
if (!isEmploymentTypeAllowedForEntity(entityType, employmentType as never)) {
return {
ok: false,
code: 'VALIDATION_ERROR',
details: { field: 'employment_type', message: EF_OWNER_EMPLOYMENT_ERROR },
}
}
const bankIssues = validateEmployeeBankAccount(
fields.clearing_number as string | undefined,
fields.bank_account_number as string | undefined,
)
if (bankIssues.length > 0) {
return {
ok: false,
code: 'VALIDATION_ERROR',
details: { issues: bankIssues.map((i) => ({ field: i.field, message: i.message })) },
}
}
// The staging tool already ran CreateEmployeeSchema, but the executor is the
// last gate before the row exists: an incomplete beslut must never be
// stored, since the engine silently ignores it (#2058).
const jamkningIssues = validateJamkning(fields)
if (jamkningIssues.length > 0) {
return {
ok: false,
code: 'VALIDATION_ERROR',
details: {
field: jamkningIssues[0].field,
message: jamkningIssues.map((i) => i.message).join('. '),
},
}
}
const { data, error } = await supabase
.from('employees')
.insert({
company_id: args.companyId,
user_id: args.userId,
personnummer: personnummer_encrypted,
personnummer_last4,
employment_type: employmentType,
...fields,
})
.select('id, first_name, last_name, personnummer, is_active')
.single()
if (error) {
if (error.code === '23505') {
const constraint = (error as { constraint?: string }).constraint
if (!constraint || constraint.includes('personnummer')) {
return { ok: false, code: 'EMPLOYEE_DUPLICATE_PERSONNUMMER' }
}
}
return { ok: false, code: 'INTERNAL_ERROR', details: { message: error.message } }
}
const row = data as { id: string; first_name: string; last_name: string; personnummer: string; is_active: boolean }
return {
ok: true,
data: {
employee_id: row.id,
first_name: row.first_name,
last_name: row.last_name,
personnummer_masked: maskPersonnummer(decryptPersonnummer(row.personnummer)),
is_active: row.is_active,
},
}
}
export async function updateEmployee(
supabase: SupabaseClient,
args: {
companyId: string
employeeId: string
/** Validated UpdateEmployeeSchema fields. Personnummer is rejected at the
* tool boundary (identity immutable); it is never accepted here either. */
patch: Record<string, unknown>
},
): Promise<EmployeeCommandResult<EmployeeSummaryResult>> {
if ('personnummer' in args.patch || 'personnummer_encrypted' in args.patch) {
return {
ok: false,
code: 'VALIDATION_ERROR',
details: { field: 'personnummer', message: 'Identity is immutable post-create.' },
}
}
const updates = pickWritable(args.patch)
if (Object.keys(updates).length === 0) {
return {
ok: false,
code: 'VALIDATION_ERROR',
details: { message: 'At least one updatable field is required.' },
}
}
const { data: existing, error: fetchError } = await supabase
.from('employees')
.select('*')
.eq('id', args.employeeId)
.eq('company_id', args.companyId)
.maybeSingle()
if (fetchError) {
return { ok: false, code: 'INTERNAL_ERROR', details: { message: fetchError.message } }
}
if (!existing) {
return { ok: false, code: 'EMPLOYEE_NOT_FOUND' }
}
// Merged-state validation (same rules as the internal PATCH route).
const merged = { ...(existing as Record<string, unknown>), ...updates }
const issues: string[] = []
if (merged.salary_type === 'monthly' && (!merged.monthly_salary || (merged.monthly_salary as number) <= 0)) {
issues.push('Månadslön krävs och måste vara större än 0 för månadslöneform')
}
if (merged.salary_type === 'hourly' && (!merged.hourly_rate || (merged.hourly_rate as number) <= 0)) {
issues.push('Timlön krävs och måste vara större än 0 för timlöneform')
}
if (merged.f_skatt_status === 'a_skatt' && !merged.is_sidoinkomst && !merged.tax_table_number) {
issues.push('Skattetabell krävs för A-skatt anställda')
}
if (merged.vaxa_stod_eligible && !merged.vaxa_stod_start) {
issues.push('Startdatum för Växa-stöd måste anges när Växa-stöd är aktiverat')
}
// Jämkning is validated on the merged row through the shared validator,
// and only when the patch touches a jämkning key: a legacy row stored with
// an incomplete beslut must stay editable in unrelated ways. #2058
if (touchesJamkning(updates)) {
for (const issue of validateJamkning(merged)) issues.push(issue.message)
}
if (issues.length > 0) {
return { ok: false, code: 'VALIDATION_ERROR', details: { message: issues.join('. ') } }
}
const clearingChanged =
'clearing_number' in updates && updates.clearing_number !== (existing as Record<string, unknown>).clearing_number
const accountChanged =
'bank_account_number' in updates &&
updates.bank_account_number !== (existing as Record<string, unknown>).bank_account_number
if (clearingChanged || accountChanged) {
const bankIssues = validateEmployeeBankAccount(
merged.clearing_number as string | undefined,
merged.bank_account_number as string | undefined,
)
if (bankIssues.length > 0) {
return {
ok: false,
code: 'VALIDATION_ERROR',
details: { issues: bankIssues.map((i) => ({ field: i.field, message: i.message })) },
}
}
}
if ('employment_type' in updates) {
const entityType = await getCompanyEntityType(supabase, args.companyId)
if (!isEmploymentTypeAllowedForEntity(entityType, updates.employment_type as never)) {
return {
ok: false,
code: 'VALIDATION_ERROR',
details: { field: 'employment_type', message: EF_OWNER_EMPLOYMENT_ERROR },
}
}
}
const { data, error } = await supabase
.from('employees')
.update(updates)
.eq('id', args.employeeId)
.eq('company_id', args.companyId)
.select('id, first_name, last_name, personnummer, is_active')
.single()
if (error) {
// The CHECK constraint refused the row (#2256): either the merged-state
// check above passed against a snapshot another request has since
// changed, or this is a legacy incomplete row (stored before #2240) whose
// next edit must complete or clear the beslut. Same VALIDATION_ERROR and
// sentence as that check, derived from the merged row.
const jamkningIssue = jamkningIssueFromDbError(error, merged as JamkningFields)
if (jamkningIssue) {
return {
ok: false,
code: 'VALIDATION_ERROR',
details: { field: jamkningIssue.field, message: jamkningIssue.message },
}
}
return { ok: false, code: 'INTERNAL_ERROR', details: { message: error.message } }
}
const row = data as { id: string; first_name: string; last_name: string; personnummer: string; is_active: boolean }
return {
ok: true,
data: {
employee_id: row.id,
first_name: row.first_name,
last_name: row.last_name,
personnummer_masked: maskPersonnummer(decryptPersonnummer(row.personnummer)),
is_active: row.is_active,
},
}
}