diff --git a/DECISIONS.md b/DECISIONS.md index 78a3ed22..ba51db2d 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -1500,6 +1500,8 @@ One line per decision: `[YYYY-MM-DD] : `. Appended by agents and [2026-09-02] parties phase 0, golden set stays out of git: the labelling sample is prod voucher text with person names (salary, expense claims) and the repo is public, so the draw SQL is versioned but the rows and labels live in gitignored dev_docs/parties/golden/. [2026-09-02] parties substrate: customers and suppliers keep their tables and gain party_id; parties dedupe on normalised org number only, never on name at insert time. A name merge is a recorded human decision because the July measurement showed a majority of generic keys still map to one real vendor, so an automatic name merge would fuse unrelated suppliers. [2026-09-02] MCP eager-auth flag (`auth=required`) on the claude.ai connector links instead of reverting lazy auth: claude.ai's two-step Add-custom-connector dialog probes the URL without credentials and pre-fills Authentication "None" when the lazy handshake answers 200, which blocks the sign-in later; per Anthropic's docs a 401 is the only answer it reads as OAuth. The flag lives in the URL, so the links we control (Settings, onboarding checklist, both docs pages, website) get OAuth detected while the bare URL keeps lazy auth for Claude Code, the plugin, Cursor and ChatGPT, and existing connector records stay untouched. Rejected: keying eager auth off `client=claude-connector` (documented as telemetry-only) and sniffing the probe's user agent (fragile, undocumented). +[2026-09-02] Recurring gross deductions keep is_vacation_basis=false on the deduction row (the semester base is NOT reduced): matches the common loneväxling agreement where vacation pay stays on the pre-exchange salary, and diverges deliberately from the manual-line default which follows the row flags as entered. Flagged by review on #2044; change requires a per-line toggle, not a different default. +[2026-09-02] Recurring 'other' additions removed from #2042/#2044 scope: calculateSalary only treats ADDITION_TYPES as additions, so a recurring taxable addition would render on the payslip without entering gross/tax/AGA or AGI. Re-add only together with engine support and engine tests. [2026-09-02] Offert detail actions: Acceptera + Skapa faktura in the header, Avboj in the overflow menu: convention 9 (one obvious next step, alternatives behind the caret); declining is the rarer branch. [2026-09-02] Quotes (offert) get their own number series (company_settings.next_quote_number, generate_quote_number, OF-nnn allocated at insert) instead of an OF- prefix on generate_invoice_number: proformas share the F-counter today, so PF-042 leaves F-042 unused, and quotes are far more numerous, so every declined quote would have punched a hole in the faktura series. Delivery notes already use the own-counter pattern. The column next_quote_number pre-existed on prod and staging with no migration file; 20260902220000 adopts it (backfill NULLs, DEFAULT 1, NOT NULL). [2026-09-02] Quote expiry is derived, not stored: quote_status stays open/accepted/declined and the UI/API compute expired as open AND valid_until < today (lib/invoices/quote-status.ts). Rejected the nightly cron from the original plan: it needed a route, a vercel.json entry, a Docker cron gap and un-expire logic when a user extends valid_until; the derived form needs none of that and cannot race the clock. diff --git a/app/(dashboard)/salary/employees/[id]/page.tsx b/app/(dashboard)/salary/employees/[id]/page.tsx index 43712f6f..2a1b53c5 100644 --- a/app/(dashboard)/salary/employees/[id]/page.tsx +++ b/app/(dashboard)/salary/employees/[id]/page.tsx @@ -32,6 +32,7 @@ import { } from '@/lib/salary/payment/bank-account' import type { EmployeeMasked } from '@/types' import { EmployeeBenefitsPanel } from '@/components/salary/EmployeeBenefitsPanel' +import { EmployeeRecurringLinesPanel } from '@/components/salary/EmployeeRecurringLinesPanel' import { OpeningBalancesPanel } from '@/components/salary/OpeningBalancesPanel' import EmployeeTaxCard, { type EmployeeTaxValue } from '@/components/salary/EmployeeTaxCard' import { jamkningPatch } from '@/lib/salary/jamkning-patch' @@ -491,6 +492,7 @@ export default function EmployeeDetailPage({ params }: { params: Promise<{ id: s benefits and opening balances write to their own endpoints, so the "Spara ändringar" in the edit dialog never touches them. */} + diff --git a/app/api/salary/employees/[id]/recurring-lines/[lineId]/__tests__/route.test.ts b/app/api/salary/employees/[id]/recurring-lines/[lineId]/__tests__/route.test.ts new file mode 100644 index 00000000..f76dd8f0 --- /dev/null +++ b/app/api/salary/employees/[id]/recurring-lines/[lineId]/__tests__/route.test.ts @@ -0,0 +1,226 @@ +/** + * Auth-wiring tests for /api/salary/employees/[id]/recurring-lines/[lineId] + * (PATCH update, DELETE remove). Runs the routes through the real + * withRouteContext wrapper; mocks auth/company/write and injects a queued + * Supabase mock via requireAuth. + */ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { NextResponse } from 'next/server' +import { createQueuedMockSupabase, createMockRequest, parseJsonResponse } from '@/tests/helpers' + +const { supabase, enqueue, reset, findCall } = createQueuedMockSupabase() + +const requireAuthMock = vi.fn() +vi.mock('@/lib/auth/require-auth', () => ({ + requireAuth: (...args: unknown[]) => requireAuthMock(...args), +})) + +vi.mock('@/lib/company/context', () => ({ + getActiveCompanyId: vi.fn().mockResolvedValue('company-1'), + requireCompanyId: vi.fn().mockResolvedValue('company-1'), +})) + +const requireWriteMock = vi.fn() +vi.mock('@/lib/auth/require-write', () => ({ + requireWritePermission: (...args: unknown[]) => requireWriteMock(...args), +})) + +vi.mock('@/lib/init', () => ({ ensureInitialized: vi.fn() })) + +import { PATCH, DELETE } from '../route' + +const params = { params: Promise.resolve({ id: 'emp-1', lineId: 'line-1' }) } as never + +function patch(body: unknown) { + return createMockRequest('/api/salary/employees/emp-1/recurring-lines/line-1', { + method: 'PATCH', + body, + }) +} + +const storedLine = { + item_type: 'gross_deduction_other', + valid_from: '2026-01-01', + valid_to: null, +} + +describe('PATCH /api/salary/employees/[id]/recurring-lines/[lineId]', () => { + beforeEach(() => { + vi.clearAllMocks() + reset() + requireAuthMock.mockResolvedValue({ user: { id: 'user-1' }, supabase }) + requireWriteMock.mockResolvedValue({ ok: true }) + }) + + it('returns 401 when unauthenticated', async () => { + requireAuthMock.mockResolvedValue({ + user: null, + supabase, + error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }), + }) + + const response = await PATCH(patch({ amount: -700 }), params) + expect(response.status).toBe(401) + }) + + it('returns 403 for a viewer (no write permission)', async () => { + requireWriteMock.mockResolvedValue({ + ok: false, + response: NextResponse.json({ error: 'Forbidden' }, { status: 403 }), + }) + + const response = await PATCH(patch({ amount: -700 }), params) + expect(response.status).toBe(403) + }) + + it('updates the amount (happy path)', async () => { + enqueue({ data: storedLine }) // fetch existing + enqueue({ data: { id: 'line-1', amount: -700 } }) // update + + const response = await PATCH(patch({ amount: -700 }), params) + const { status, body } = await parseJsonResponse<{ data: { amount: number } }>(response) + + expect(status).toBe(200) + expect(body.data.amount).toBe(-700) + }) + + it('writes exactly the patchable columns and nothing else', async () => { + // Scoped assertion for the merged-updates payload: it is assembled + // conditionally, so the phantom-column scanner cannot resolve it. This + // pins the column set the route can ever write. + enqueue({ data: storedLine }) // fetch existing + enqueue({ data: { id: 'line-1' } }) // update + + await PATCH( + patch({ + description: 'Förmånscykel', + amount: -700, + account_number: '7399', + valid_from: '2026-02-01', + valid_to: '2026-12-31', + metadata: { source: 'test' }, + is_active: false, + }), + params, + ) + + const update = findCall('employee_recurring_lines', 'update') + expect(Object.keys(update?.[0] as Record).sort()).toEqual([ + 'account_number', + 'amount', + 'description', + 'is_active', + 'metadata', + 'valid_from', + 'valid_to', + ]) + }) + + it('returns 404 when the line does not exist', async () => { + enqueue({ data: null, error: { code: 'PGRST116', message: 'zero rows' } }) + + const response = await PATCH(patch({ amount: -700 }), params) + expect(response.status).toBe(404) + }) + + it('rejects an amount whose sign contradicts the stored item_type', async () => { + enqueue({ data: storedLine }) // fetch existing: a gross deduction + + const response = await PATCH(patch({ amount: 700 }), params) + expect(response.status).toBe(400) + }) + + it('rejects a merged period where the patched valid_to lands before the stored valid_from', async () => { + enqueue({ data: { ...storedLine, valid_from: '2026-06-01' } }) + + const response = await PATCH(patch({ valid_to: '2026-05-31' }), params) + const { status, body } = await parseJsonResponse<{ error: string }>(response) + + expect(status).toBe(400) + expect(body.error).toContain('Gäller till') + }) + + it('maps a check_violation on the write to 400, not 404', async () => { + enqueue({ data: storedLine }) + enqueue({ data: null, error: { code: '23514', message: 'violates check constraint' } }) + + const response = await PATCH(patch({ valid_from: '2026-02-01' }), params) + expect(response.status).toBe(400) + }) + + it('reports a transport failure on the fetch as 500, not 404', async () => { + enqueue({ data: null, error: { code: '08006', message: 'connection failure' } }) + + const response = await PATCH(patch({ amount: -700 }), params) + expect(response.status).toBe(500) + }) +}) + +describe('DELETE /api/salary/employees/[id]/recurring-lines/[lineId]', () => { + beforeEach(() => { + vi.clearAllMocks() + reset() + requireAuthMock.mockResolvedValue({ user: { id: 'user-1' }, supabase }) + requireWriteMock.mockResolvedValue({ ok: true }) + }) + + it('returns 401 when unauthenticated', async () => { + requireAuthMock.mockResolvedValue({ + user: null, + supabase, + error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }), + }) + + const request = createMockRequest('/api/salary/employees/emp-1/recurring-lines/line-1', { + method: 'DELETE', + }) + const response = await DELETE(request, params) + expect(response.status).toBe(401) + }) + + it('hard-deletes a line that has never been derived into a run', async () => { + enqueue({ data: { id: 'line-1' } }) // delete returns the removed row + + const request = createMockRequest('/api/salary/employees/emp-1/recurring-lines/line-1', { + method: 'DELETE', + }) + const response = await DELETE(request, params) + const { status, body } = await parseJsonResponse<{ data: { deleted: boolean } }>(response) + + expect(status).toBe(200) + expect(body.data.deleted).toBe(true) + }) + + it('returns 404 when the delete matches no row', async () => { + // Unknown id, or a line belonging to another company: the filtered + // delete reports no error and no row. + enqueue({ data: null }) + + const request = createMockRequest('/api/salary/employees/emp-1/recurring-lines/nope', { + method: 'DELETE', + }) + const response = await DELETE(request, { + params: Promise.resolve({ id: 'emp-1', lineId: 'nope' }), + } as never) + expect(response.status).toBe(404) + }) + + it('deactivates instead of deleting when derived rows reference the line', async () => { + // The FK is NO ACTION: the delete itself fails with 23503 and the route + // falls back to deactivation, race-free by construction. + enqueue({ error: { code: '23503', message: 'violates foreign key constraint' } }) + enqueue({ data: null }) // is_active=false update resolves + + const request = createMockRequest('/api/salary/employees/emp-1/recurring-lines/line-1', { + method: 'DELETE', + }) + const response = await DELETE(request, params) + const { status, body } = await parseJsonResponse<{ + data: { deleted: boolean; deactivated?: boolean } + }>(response) + + expect(status).toBe(200) + expect(body.data.deleted).toBe(false) + expect(body.data.deactivated).toBe(true) + }) +}) diff --git a/app/api/salary/employees/[id]/recurring-lines/[lineId]/route.ts b/app/api/salary/employees/[id]/recurring-lines/[lineId]/route.ts new file mode 100644 index 00000000..564bd425 --- /dev/null +++ b/app/api/salary/employees/[id]/recurring-lines/[lineId]/route.ts @@ -0,0 +1,164 @@ +import { NextResponse } from 'next/server' +import { ensureInitialized } from '@/lib/init' +import { withRouteContext } from '@/lib/api/with-route-context' +import { validateBody } from '@/lib/api/validate' +import { + UpdateEmployeeRecurringLineSchema, + RECURRING_LINE_PERIOD_ORDER_MESSAGE, +} from '@/lib/api/schemas' +import { + validateRecurringLineAmount, + type RecurringLineItemType, +} from '@/lib/salary/recurring-lines' +import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message' + +ensureInitialized() + +export const PATCH = withRouteContext<{ params: Promise<{ id: string; lineId: string }> }>( + 'salary.employees.recurring_lines.update', + async (request, { supabase, companyId }, { params }) => { + const { id, lineId } = await params + + const validation = await validateBody(request, UpdateEmployeeRecurringLineSchema) + if (!validation.success) return validation.response + const body = validation.data + + const { data: existing, error: fetchError } = await supabase + .from('employee_recurring_lines') + .select('item_type, valid_from, valid_to') + .eq('id', lineId) + .eq('employee_id', id) + .eq('company_id', companyId) + .single() + + // Only zero rows (PGRST116) means the line really isn't there. A + // transport/DB failure is not a missing record and must not be reported as + // one. + if (fetchError && fetchError.code !== 'PGRST116') { + return NextResponse.json({ error: getUserErrorMessage(fetchError) }, { status: 500 }) + } + if (!existing) { + return NextResponse.json({ error: 'Raden hittades inte' }, { status: 404 }) + } + + // Amount sign against the stored item_type: the partial schema cannot + // check this because item_type is not patchable and never in the body. + if (body.amount !== undefined) { + const signError = validateRecurringLineAmount( + existing.item_type as RecurringLineItemType, + body.amount, + ) + if (signError) { + return NextResponse.json({ error: signError }, { status: 400 }) + } + } + + // Validity period against the MERGED state: the partial schema can only + // compare the two dates when the body carries both; when only one is + // patched, the other half lives on the row we just fetched. Inclusive + // bound, and a null/cleared valid_to stays legal. + const mergedValidFrom = (body.valid_from ?? existing.valid_from ?? null) as string | null + const mergedValidTo = ( + body.valid_to !== undefined ? body.valid_to : existing.valid_to ?? null + ) as string | null + if (mergedValidFrom !== null && mergedValidTo !== null && mergedValidTo < mergedValidFrom) { + return NextResponse.json({ error: RECURRING_LINE_PERIOD_ORDER_MESSAGE }, { status: 400 }) + } + + // Explicit literal keys (not a body spread) so the phantom-column + // scanner can verify every column this update can touch. + const updates: Record = {} + if (body.description !== undefined) updates.description = body.description + if (body.amount !== undefined) updates.amount = body.amount + if (body.account_number !== undefined) updates.account_number = body.account_number + if (body.valid_from !== undefined) updates.valid_from = body.valid_from + if (body.valid_to !== undefined) updates.valid_to = body.valid_to + if (body.metadata !== undefined) updates.metadata = body.metadata + if (body.is_active !== undefined) updates.is_active = body.is_active + + const { data, error } = await supabase + .from('employee_recurring_lines') + .update(updates) + .eq('id', lineId) + .eq('employee_id', id) + .eq('company_id', companyId) + .select() + .single() + + if (error) { + // The row's existence was already established above, so `error` here is + // a write failure, not a lookup miss. PGRST116 (zero rows) is the only + // shape that still means not-found: the row was deleted or moved out of + // the company between fetch and update. + if (error.code === 'PGRST116') { + return NextResponse.json({ error: 'Raden hittades inte' }, { status: 404 }) + } + // Both the amount sign and the merged period were validated above, so a + // check_violation on UPDATE is a concurrent write that moved the other + // half of a constraint after our check. The period is the plausible one; + // answer 400 with the same copy the schema uses. + if (error.code === '23514') { + return NextResponse.json({ error: RECURRING_LINE_PERIOD_ORDER_MESSAGE }, { status: 400 }) + } + return NextResponse.json({ error: getUserErrorMessage(error) }, { status: 500 }) + } + + if (!data) { + return NextResponse.json({ error: 'Raden hittades inte' }, { status: 404 }) + } + + return NextResponse.json({ data }) + }, + { requireWrite: true }, +) + +export const DELETE = withRouteContext<{ params: Promise<{ id: string; lineId: string }> }>( + 'salary.employees.recurring_lines.delete', + async (_request, { supabase, companyId }, { params }) => { + const { id, lineId } = await params + + // Delete-first, no pre-check: the FK from salary_line_items is NO + // ACTION, so the database itself refuses (23503) whenever any derived + // row references the line, including one inserted by a calculation + // racing this request. A referenced line is deactivated instead: the + // provenance link stays intact, the next recalculation drops draft + // derived rows and never re-derives. + // Selecting the deleted row separates "deleted" from "matched nothing": + // a filtered DELETE reports no error when the id is unknown or belongs to + // another company, which would otherwise answer 200 deleted: true. + const { data: deleted, error } = await supabase + .from('employee_recurring_lines') + .delete() + .eq('id', lineId) + .eq('employee_id', id) + .eq('company_id', companyId) + .select('id') + .maybeSingle() + + if (error && error.code !== '23503') { + return NextResponse.json({ error: getUserErrorMessage(error) }, { status: 500 }) + } + + if (error) { + const { error: deactivateError } = await supabase + .from('employee_recurring_lines') + .update({ is_active: false }) + .eq('id', lineId) + .eq('employee_id', id) + .eq('company_id', companyId) + + if (deactivateError) { + return NextResponse.json({ error: getUserErrorMessage(deactivateError) }, { status: 500 }) + } + + return NextResponse.json({ data: { id: lineId, deleted: false, deactivated: true } }) + } + + if (!deleted) { + return NextResponse.json({ error: 'Raden hittades inte' }, { status: 404 }) + } + + return NextResponse.json({ data: { id: lineId, deleted: true } }) + }, + { requireWrite: true }, +) diff --git a/app/api/salary/employees/[id]/recurring-lines/__tests__/route.test.ts b/app/api/salary/employees/[id]/recurring-lines/__tests__/route.test.ts new file mode 100644 index 00000000..97d22650 --- /dev/null +++ b/app/api/salary/employees/[id]/recurring-lines/__tests__/route.test.ts @@ -0,0 +1,183 @@ +/** + * Auth-wiring tests for /api/salary/employees/[id]/recurring-lines (POST + * create). Runs the route through the real withRouteContext wrapper; mocks + * auth/company/write and injects a queued Supabase mock via requireAuth. + * Covers 401, 403 (viewer), the POST happy path, and the schema mirrors of + * the table CHECKs (amount sign, validity period). + */ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { NextResponse } from 'next/server' +import { createQueuedMockSupabase, createMockRequest, parseJsonResponse } from '@/tests/helpers' + +const { supabase, enqueue, reset } = createQueuedMockSupabase() + +const requireAuthMock = vi.fn() +vi.mock('@/lib/auth/require-auth', () => ({ + requireAuth: (...args: unknown[]) => requireAuthMock(...args), +})) + +vi.mock('@/lib/company/context', () => ({ + getActiveCompanyId: vi.fn().mockResolvedValue('company-1'), + requireCompanyId: vi.fn().mockResolvedValue('company-1'), +})) + +const requireWriteMock = vi.fn() +vi.mock('@/lib/auth/require-write', () => ({ + requireWritePermission: (...args: unknown[]) => requireWriteMock(...args), +})) + +vi.mock('@/lib/init', () => ({ ensureInitialized: vi.fn() })) + +import { POST } from '../route' + +const params = { params: Promise.resolve({ id: 'emp-1' }) } as never + +function post(body: unknown) { + return createMockRequest('/api/salary/employees/emp-1/recurring-lines', { method: 'POST', body }) +} + +const validLine = { + item_type: 'gross_deduction_other', + description: 'Förmånscykel bruttolöneavdrag', + amount: -670.17, + valid_from: '2026-01-01', +} + +describe('POST /api/salary/employees/[id]/recurring-lines', () => { + beforeEach(() => { + vi.clearAllMocks() + reset() + requireAuthMock.mockResolvedValue({ user: { id: 'user-1' }, supabase }) + requireWriteMock.mockResolvedValue({ ok: true }) + }) + + it('returns 401 when unauthenticated', async () => { + requireAuthMock.mockResolvedValue({ + user: null, + supabase, + error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }), + }) + + const response = await POST(post(validLine), params) + expect(response.status).toBe(401) + }) + + it('returns 403 for a viewer (no write permission)', async () => { + requireWriteMock.mockResolvedValue({ + ok: false, + response: NextResponse.json({ error: 'Forbidden' }, { status: 403 }), + }) + + const response = await POST(post(validLine), params) + expect(response.status).toBe(403) + }) + + it('creates a recurring line (happy path)', async () => { + enqueue({ data: { id: 'emp-1' } }) // employee ownership check + enqueue({ data: { id: 'line-1', item_type: 'gross_deduction_other', amount: -670.17 } }) // insert + + const response = await POST(post(validLine), params) + const { status, body } = await parseJsonResponse<{ data: { id: string } }>(response) + + expect(status).toBe(201) + expect(body.data.id).toBe('line-1') + }) + + it('returns 404 when the employee is not in the company', async () => { + enqueue({ data: null }) // employee ownership check → zero rows, no error + + const response = await POST(post(validLine), params) + expect(response.status).toBe(404) + }) + + it('reports an employee-lookup failure as 500, not 404', async () => { + enqueue({ data: null, error: { code: '08006', message: 'connection failure' } }) + + const response = await POST(post(validLine), params) + expect(response.status).toBe(500) + }) + + // Amount sign: mirrors the employee_recurring_lines_amount_sign CHECK. + describe('amount sign', () => { + it('rejects a positive amount on a deduction type with a field-level 400', async () => { + const response = await POST(post({ ...validLine, amount: 670.17 }), params) + const { status, body } = await parseJsonResponse<{ + errors: { field: string }[] + }>(response) + + expect(status).toBe(400) + expect(body.errors).toEqual( + expect.arrayContaining([expect.objectContaining({ field: 'amount' })]), + ) + expect(supabase.from).not.toHaveBeenCalled() + }) + + it("rejects the removed 'other' addition type", async () => { + const response = await POST( + post({ ...validLine, item_type: 'other', amount: 500 }), + params, + ) + expect(response.status).toBe(400) + }) + + it('rejects zero for every item type', async () => { + const response = await POST(post({ ...validLine, amount: 0 }), params) + expect(response.status).toBe(400) + }) + }) + + // Validity period: mirrors CHECK (valid_to IS NULL OR valid_to >= valid_from). + describe('valid_from / valid_to ordering', () => { + it('rejects valid_to before valid_from with an actionable 400', async () => { + const response = await POST( + post({ ...validLine, valid_from: '2026-06-01', valid_to: '2026-05-31' }), + params, + ) + const { status, body } = await parseJsonResponse<{ + error: string + errors: { field: string; message: string }[] + }>(response) + + expect(status).toBe(400) + expect(body.errors).toEqual( + expect.arrayContaining([expect.objectContaining({ field: 'valid_to' })]), + ) + expect(supabase.from).not.toHaveBeenCalled() + }) + + it('accepts valid_to equal to valid_from (the bound is inclusive)', async () => { + enqueue({ data: { id: 'emp-1' } }) + enqueue({ data: { id: 'line-1' } }) + + const response = await POST( + post({ ...validLine, valid_from: '2026-06-01', valid_to: '2026-06-01' }), + params, + ) + expect(response.status).toBe(201) + }) + + it('accepts an omitted valid_to (open-ended line stays legal)', async () => { + enqueue({ data: { id: 'emp-1' } }) + enqueue({ data: { id: 'line-1' } }) + + const response = await POST(post(validLine), params) + expect(response.status).toBe(201) + }) + }) + + it('maps a check_violation from the insert to 400, not 500', async () => { + enqueue({ data: { id: 'emp-1' } }) + enqueue({ data: null, error: { code: '23514', message: 'violates check constraint' } }) + + const response = await POST(post(validLine), params) + expect(response.status).toBe(400) + }) + + it('still reports a genuine DB failure as 500', async () => { + enqueue({ data: { id: 'emp-1' } }) + enqueue({ data: null, error: { code: '08006', message: 'connection failure' } }) + + const response = await POST(post(validLine), params) + expect(response.status).toBe(500) + }) +}) diff --git a/app/api/salary/employees/[id]/recurring-lines/route.ts b/app/api/salary/employees/[id]/recurring-lines/route.ts new file mode 100644 index 00000000..c1a0d610 --- /dev/null +++ b/app/api/salary/employees/[id]/recurring-lines/route.ts @@ -0,0 +1,81 @@ +import { NextResponse } from 'next/server' +import { ensureInitialized } from '@/lib/init' +import { withRouteContext } from '@/lib/api/with-route-context' +import { validateBody } from '@/lib/api/validate' +import { CreateEmployeeRecurringLineSchema } from '@/lib/api/schemas' +import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message' +import { roundOre } from '@/lib/money' + +ensureInitialized() + +export const GET = withRouteContext<{ params: Promise<{ id: string }> }>( + 'salary.employees.recurring_lines.list', + async (_request, { supabase, companyId }, { params }) => { + const { id } = await params + + const { data, error } = await supabase + .from('employee_recurring_lines') + .select('*') + .eq('employee_id', id) + .eq('company_id', companyId) + .order('valid_from', { ascending: false }) + + if (error) return NextResponse.json({ error: getUserErrorMessage(error) }, { status: 500 }) + + return NextResponse.json({ data }) + }, +) + +export const POST = withRouteContext<{ params: Promise<{ id: string }> }>( + 'salary.employees.recurring_lines.create', + async (request, { supabase, companyId, user }, { params }) => { + const { id } = await params + + const validation = await validateBody(request, CreateEmployeeRecurringLineSchema) + if (!validation.success) return validation.response + const body = validation.data + + // Confirm employee belongs to the company. maybeSingle separates the two + // empty outcomes: a lookup failure is a 500, only zero rows is a 404. + const { data: emp, error: empError } = await supabase + .from('employees') + .select('id') + .eq('id', id) + .eq('company_id', companyId) + .maybeSingle() + if (empError) { + return NextResponse.json({ error: getUserErrorMessage(empError) }, { status: 500 }) + } + if (!emp) return NextResponse.json({ error: 'Anställd hittades inte' }, { status: 404 }) + + const { data, error } = await supabase + .from('employee_recurring_lines') + .insert({ + employee_id: id, + company_id: companyId, + user_id: user.id, + item_type: body.item_type, + description: body.description, + amount: roundOre(body.amount), + account_number: body.account_number ?? null, + valid_from: body.valid_from, + valid_to: body.valid_to ?? null, + metadata: body.metadata ?? {}, + is_active: body.is_active ?? true, + }) + .select() + .single() + + if (error) { + // The create schema mirrors every CHECK on the table (item_type + // whitelist, amount sign, account format, valid_to >= valid_from), so a + // check_violation here is only the backstop for non-schema callers: bad + // input, not a server fault. + const status = error.code === '23514' ? 400 : 500 + return NextResponse.json({ error: getUserErrorMessage(error) }, { status }) + } + + return NextResponse.json({ data }, { status: 201 }) + }, + { requireWrite: true }, +) diff --git a/app/api/salary/runs/[id]/correct/route.ts b/app/api/salary/runs/[id]/correct/route.ts index a40828ff..3487d7a5 100644 --- a/app/api/salary/runs/[id]/correct/route.ts +++ b/app/api/salary/runs/[id]/correct/route.ts @@ -155,6 +155,11 @@ export const POST = withRouteContext<{ params: Promise<{ id: string }> }>( is_net_deduction: li.is_net_deduction, account_number: li.account_number, sort_order: li.sort_order, + // Provenance must survive the copy: without these back-links a + // recalculation of the correction run treats the copied derived + // rows as manual and step 8d/8d3 derives them a second time. + source_benefit_id: li.source_benefit_id ?? null, + source_recurring_line_id: li.source_recurring_line_id ?? null, }) } } diff --git a/components/salary/EmployeeRecurringLinesPanel.tsx b/components/salary/EmployeeRecurringLinesPanel.tsx new file mode 100644 index 00000000..3d649c34 --- /dev/null +++ b/components/salary/EmployeeRecurringLinesPanel.tsx @@ -0,0 +1,299 @@ +'use client' + +import { useEffect, useRef, useState } from 'react' +import { useTranslations } from 'next-intl' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { Label } from '@/components/ui/label' +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select' +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog' +import { DetailSection } from '@/components/ui/detail-section' +import { HelpPopover } from '@/components/ui/help-popover' +import { Skeleton } from '@/components/ui/skeleton' +import { Loader2 } from 'lucide-react' +import { useToast } from '@/components/ui/use-toast' +import { formatCurrency, formatDate } from '@/lib/utils' +import { getErrorMessage } from '@/lib/errors/get-error-message' + +type RecurringLineType = + | 'gross_deduction_pension' + | 'gross_deduction_other' + | 'net_deduction_union' + | 'net_deduction_benefit_payment' + | 'net_deduction_other' + +interface EmployeeRecurringLine { + id: string + item_type: RecurringLineType + description: string + amount: number + account_number: string | null + valid_from: string + valid_to: string | null + metadata: Record + is_active: boolean +} + +// Swedish defaults written to the DB when the description is left empty: +// stored data stays Swedish regardless of the viewer's UI locale. +const LINE_LABELS: Record = { + gross_deduction_pension: 'Bruttolöneavdrag pension (löneväxling)', + gross_deduction_other: 'Bruttolöneavdrag', + net_deduction_union: 'Fackavgift', + net_deduction_benefit_payment: 'Nettolöneavdrag förmån', + net_deduction_other: 'Nettolöneavdrag', +} + +// In-row text action: same idiom as EmployeeBenefitsPanel. +const ROW_ACTION_CLASS = + 'text-xs text-muted-foreground underline decoration-border underline-offset-4 transition-colors duration-150 hover:text-foreground hover:decoration-foreground disabled:opacity-50' + +export function EmployeeRecurringLinesPanel({ employeeId, canWrite }: { employeeId: string; canWrite: boolean }) { + const t = useTranslations('salary_employee') + const { toast } = useToast() + const [lines, setLines] = useState([]) + const [loading, setLoading] = useState(true) + const [adding, setAdding] = useState(false) + const [submitting, setSubmitting] = useState(false) + // Monotonic request id: a reload issued after a create/delete must not be + // overwritten by an earlier, slower in-flight load resolving late. + const loadSeq = useRef(0) + + const [type, setType] = useState('gross_deduction_other') + const [description, setDescription] = useState('') + const [amount, setAmount] = useState('') + const [validFrom, setValidFrom] = useState(() => new Date().toISOString().slice(0, 10)) + const [validTo, setValidTo] = useState('') + + + async function load() { + const seq = ++loadSeq.current + setLoading(true) + try { + const res = await fetch(`/api/salary/employees/${employeeId}/recurring-lines`) + if (res.ok) { + const { data } = await res.json() + if (seq === loadSeq.current) setLines(data || []) + } + } catch { + // Network failure: keep whatever is shown; the empty/stale list plus + // the still-enabled actions let the user retry. + } finally { + if (seq === loadSeq.current) setLoading(false) + } + } + + useEffect(() => { + load() + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [employeeId]) + + function reset() { + setType('gross_deduction_other') + setDescription('') + setAmount('') + setValidFrom(new Date().toISOString().slice(0, 10)) + setValidTo('') + setAdding(false) + } + + async function handleAdd() { + setSubmitting(true) + try { + // The field takes a positive number; every recurring line is a + // deduction and is stored negative so the payslip math reads the sign + // from the row. + const magnitude = Math.abs(parseFloat(amount) || 0) + const body: Record = { + item_type: type, + description: description || LINE_LABELS[type], + amount: -magnitude, + valid_from: validFrom, + } + if (validTo) body.valid_to = validTo + + const res = await fetch(`/api/salary/employees/${employeeId}/recurring-lines`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }) + + if (res.ok) { + toast({ title: t('recurring_added') }) + reset() + await load() + } else { + const result = await res.json() + toast({ + title: t('recurring_save_failed'), + description: getErrorMessage(result, { statusCode: res.status }), + variant: 'destructive', + }) + } + } catch { + toast({ title: t('recurring_save_failed'), variant: 'destructive' }) + } finally { + setSubmitting(false) + } + } + + async function handleDelete(id: string) { + try { + const res = await fetch(`/api/salary/employees/${employeeId}/recurring-lines/${id}`, { method: 'DELETE' }) + if (res.ok) { + toast({ title: t('recurring_removed') }) + await load() + } else { + toast({ title: t('recurring_remove_failed'), variant: 'destructive' }) + } + } catch { + toast({ title: t('recurring_remove_failed'), variant: 'destructive' }) + } + } + + return ( + {t('recurring_help')}} + aside={ + canWrite ? ( + + ) : undefined + } + > + {loading ? ( +
+ + +
+ ) : lines.filter((l) => l.is_active).length === 0 ? ( +

{t('recurring_empty')}

+ ) : ( +
    + {/* Deactivated lines are hidden: "Ta bort" soft-deactivates a line + that has already been derived into a run, and showing it again + would read as the delete having failed. */} + {lines.filter((l) => l.is_active).map((l) => { + const typeLabel = t(`recurring_type_${l.item_type}`) + // The description defaults to the Swedish type label when left + // empty on creation; repeating it next to the type says nothing. + const showDescription = + !!l.description && + l.description !== typeLabel && + l.description !== LINE_LABELS[l.item_type] + return ( +
  • + + {typeLabel} + {showDescription && ( + {' · '}{l.description} + )} + + + {formatDate(l.valid_from)} → {l.valid_to ? formatDate(l.valid_to) : t('recurring_ongoing')} + + + {formatCurrency(l.amount)} + {t('recurring_per_month')} + + {canWrite && ( + + )} +
  • + ) + })} +
+ )} + + {/* Add dialog (convention 13: centered modal for create). Closing by + Escape or backdrop is the same as Avbryt; both are held while a + save is in flight. */} + { + if (!open && !submitting) reset() + }} + > + + + {t('recurring_add')} + {t('recurring_help')} + + +
+
+
+ + +
+
+ + setDescription(e.target.value)} + placeholder={t(`recurring_type_${type}`)} + /> +
+
+ +
+ + setAmount(e.target.value)} + placeholder={t('recurring_amount_placeholder')} + className="max-w-xs" + /> +

{t('recurring_deduction_hint')}

+
+ +
+
+ + setValidFrom(e.target.value)} /> +
+
+ + setValidTo(e.target.value)} /> +
+
+

{t('recurring_window_hint')}

+
+ + + + + +
+
+
+ ) +} diff --git a/lib/api/schemas.ts b/lib/api/schemas.ts index 0b802f5e..9a974287 100644 --- a/lib/api/schemas.ts +++ b/lib/api/schemas.ts @@ -3327,6 +3327,83 @@ export const UpdateEmployeeBenefitSchema = z.object({ } }) +export const RecurringLineItemTypeSchema = z.enum([ + 'gross_deduction_pension', + 'gross_deduction_other', + 'net_deduction_union', + 'net_deduction_benefit_payment', + 'net_deduction_other', +]) + +/** Same inclusive-bound semantics as BENEFIT_PERIOD_ORDER_MESSAGE, for + * employee_recurring_lines (migration 20260902140000). */ +export const RECURRING_LINE_PERIOD_ORDER_MESSAGE = + '"Gäller till" måste vara samma dag som eller efter "Gäller från". Lämna fältet tomt för en löpande rad.' + +const recurringLineAmountIssue = ( + data: { item_type?: string; amount?: number }, + ctx: z.RefinementCtx, +) => { + // Mirrors the employee_recurring_lines_amount_sign CHECK: every supported + // type is a deduction and must be negative. Kept in the schema so the + // violation is a field-level 400 instead of a Postgres 23514. + if (data.amount === undefined || data.item_type === undefined) return + const bad = data.amount >= 0 + if (bad) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: 'Avdragsrader måste ha negativt belopp och tilläggsrader positivt belopp.', + path: ['amount'], + }) + } +} + +export const CreateEmployeeRecurringLineSchema = z.object({ + item_type: RecurringLineItemTypeSchema, + description: z.string().min(1).max(200), + amount: z.number(), + account_number: accountNumberSchema.optional(), + valid_from: isoDate, + valid_to: isoDate.optional(), + metadata: z.record(z.string(), z.unknown()).optional(), + is_active: z.boolean().optional(), +}).superRefine((data, ctx) => { + recurringLineAmountIssue(data, ctx) + if (data.valid_to !== undefined && data.valid_to < data.valid_from) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: RECURRING_LINE_PERIOD_ORDER_MESSAGE, + path: ['valid_to'], + }) + } +}) + +/** item_type is not patchable (like benefit_type): the sign rule and derived + * flags key off it, so changing kind means delete + recreate. The route + * re-checks the amount sign and merged date pair against the stored row. */ +export const UpdateEmployeeRecurringLineSchema = z.object({ + description: z.string().min(1).max(200).optional(), + amount: z.number().optional(), + account_number: accountNumberSchema.nullable().optional(), + valid_from: isoDate.optional(), + valid_to: isoDate.nullable().optional(), + metadata: z.record(z.string(), z.unknown()).optional(), + is_active: z.boolean().optional(), +}).superRefine((data, ctx) => { + if ( + data.valid_from !== undefined && + data.valid_to !== undefined && + data.valid_to !== null && + data.valid_to < data.valid_from + ) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: RECURRING_LINE_PERIOD_ORDER_MESSAGE, + path: ['valid_to'], + }) + } +}) + export const CreateSalaryRunSchema = z.object({ period_year: z.number().int().min(2020).max(2100), period_month: z.number().int().min(1).max(12), diff --git a/lib/reports/full-archive-export.ts b/lib/reports/full-archive-export.ts index 63c26061..315cba71 100644 --- a/lib/reports/full-archive-export.ts +++ b/lib/reports/full-archive-export.ts @@ -1089,6 +1089,7 @@ export const MASTER_DATA_DUMP_TABLES: MasterDataTableSpec[] = [ // Salary (räkenskapsinformation with 7-year retention) { name: 'employees', file: 'employees.json', orderBy: 'created_at' }, { name: 'employee_benefits', file: 'employee_benefits.json', orderBy: 'created_at' }, + { name: 'employee_recurring_lines', file: 'employee_recurring_lines.json', orderBy: 'created_at' }, { name: 'salary_runs', file: 'salary_runs.json', orderBy: 'created_at' }, { name: 'salary_run_employees', file: 'salary_run_employees.json', orderBy: 'created_at' }, { name: 'salary_line_items', file: 'salary_line_items.json', orderBy: 'created_at' }, diff --git a/lib/salary/__tests__/calculation-engine.test.ts b/lib/salary/__tests__/calculation-engine.test.ts index c797f2e3..70ef87ac 100644 --- a/lib/salary/__tests__/calculation-engine.test.ts +++ b/lib/salary/__tests__/calculation-engine.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest' +import { describe, it, expect, vi } from 'vitest' import { calculateSalary, calculateKarensavdrag, @@ -8,6 +8,7 @@ import { prorateBaseSalaryForPeriod, } from '../calculation-engine' import { calculateVacationPay } from '../absence-calculator' +import { recurringLineFlags } from '../recurring-lines' import type { PayrollConfig } from '../payroll-config' import type { TaxTableRate } from '../tax-tables' @@ -1493,3 +1494,66 @@ describe('öresavrundning (roundNetToWholeKrona)', () => { ) }) }) + +describe('recurring line flags through the engine', () => { + // Pins the payroll math for derived recurring rows: the flags produced by + // recurringLineFlags must actually move gross, the tax base and the AGA + // base when run through calculateSalary (regression guard for #2044). + it('a recurring gross deduction reduces gross, tax base and AGA base, not the semester base', () => { + const flags = recurringLineFlags('gross_deduction_other') + const base = calculateSalary(makeBasicInput(), config2026, emptyTaxRates) + const withDeduction = calculateSalary( + makeBasicInput({ + lineItems: [ + { + itemType: 'gross_deduction_other' as const, + amount: -500, + isTaxable: flags.is_taxable, + isAvgiftBasis: flags.is_avgift_basis, + isVacationBasis: flags.is_vacation_basis, + isGrossDeduction: flags.is_gross_deduction, + isNetDeduction: flags.is_net_deduction, + }, + ], + }), + config2026, + emptyTaxRates, + ) + + expect(withDeduction.grossDeductions).toBe(500) + expect(withDeduction.grossSalary).toBe(base.grossSalary - 500) + expect(withDeduction.taxableIncome).toBe(base.taxableIncome - 500) + expect(withDeduction.avgifterBasis).toBe(base.avgifterBasis - 500) + expect(withDeduction.avgifterAmount).toBeLessThan(base.avgifterAmount) + // The DECISIONS.md judgment call: the semester base stays untouched. + expect(withDeduction.vacationAccrual).toBe(base.vacationAccrual) + }) + + it('a recurring net deduction reduces only the paid-out net', () => { + const flags = recurringLineFlags('net_deduction_union') + const base = calculateSalary(makeBasicInput(), config2026, emptyTaxRates) + const withDeduction = calculateSalary( + makeBasicInput({ + lineItems: [ + { + itemType: 'net_deduction_union' as const, + amount: -300, + isTaxable: flags.is_taxable, + isAvgiftBasis: flags.is_avgift_basis, + isVacationBasis: flags.is_vacation_basis, + isGrossDeduction: flags.is_gross_deduction, + isNetDeduction: flags.is_net_deduction, + }, + ], + }), + config2026, + emptyTaxRates, + ) + + expect(withDeduction.grossSalary).toBe(base.grossSalary) + expect(withDeduction.taxableIncome).toBe(base.taxableIncome) + expect(withDeduction.avgifterBasis).toBe(base.avgifterBasis) + expect(withDeduction.netDeductions).toBe(300) + expect(withDeduction.netSalary).toBe(base.netSalary - 300) + }) +}) diff --git a/lib/salary/__tests__/recurring-lines.test.ts b/lib/salary/__tests__/recurring-lines.test.ts new file mode 100644 index 00000000..2678d630 --- /dev/null +++ b/lib/salary/__tests__/recurring-lines.test.ts @@ -0,0 +1,73 @@ +import { describe, it, expect } from 'vitest' +import { + RECURRING_LINE_ITEM_TYPES, + RECURRING_LINE_AMOUNT_SIGN_MESSAGE, + recurringLineFlags, + validateRecurringLineAmount, +} from '../recurring-lines' + +describe('recurringLineFlags', () => { + it('marks gross deductions as taxable, avgift-basis gross deductions', () => { + for (const t of ['gross_deduction_pension', 'gross_deduction_other'] as const) { + expect(recurringLineFlags(t)).toEqual({ + is_taxable: true, + is_avgift_basis: true, + is_vacation_basis: false, + is_gross_deduction: true, + is_net_deduction: false, + }) + } + }) + + it('marks net deductions as after-tax only', () => { + for (const t of [ + 'net_deduction_union', + 'net_deduction_benefit_payment', + 'net_deduction_other', + ] as const) { + expect(recurringLineFlags(t)).toEqual({ + is_taxable: false, + is_avgift_basis: false, + is_vacation_basis: false, + is_gross_deduction: false, + is_net_deduction: true, + }) + } + }) + + it('every supported type is a deduction with exactly one deduction flag set', () => { + // 'other' (recurring addition) is deliberately absent: the engine does + // not treat generic taxable rows as additions, see recurring-lines.ts. + expect(RECURRING_LINE_ITEM_TYPES).not.toContain('other') + for (const t of RECURRING_LINE_ITEM_TYPES) { + const flags = recurringLineFlags(t) + expect(flags.is_gross_deduction && flags.is_net_deduction).toBe(false) + expect(flags.is_gross_deduction || flags.is_net_deduction).toBe(true) + } + }) +}) + +describe('validateRecurringLineAmount', () => { + it('requires deductions to be negative', () => { + expect(validateRecurringLineAmount('gross_deduction_other', -670.17)).toBeNull() + expect(validateRecurringLineAmount('gross_deduction_other', 670.17)).toBe( + RECURRING_LINE_AMOUNT_SIGN_MESSAGE, + ) + expect(validateRecurringLineAmount('net_deduction_union', -100)).toBeNull() + expect(validateRecurringLineAmount('net_deduction_union', 100)).toBe( + RECURRING_LINE_AMOUNT_SIGN_MESSAGE, + ) + }) + + it('never accepts zero, positive or non-finite amounts', () => { + expect(validateRecurringLineAmount('gross_deduction_other', 0)).toBe( + RECURRING_LINE_AMOUNT_SIGN_MESSAGE, + ) + expect(validateRecurringLineAmount('net_deduction_other', Number.NaN)).toBe( + RECURRING_LINE_AMOUNT_SIGN_MESSAGE, + ) + expect(validateRecurringLineAmount('gross_deduction_pension', Number.POSITIVE_INFINITY)).toBe( + RECURRING_LINE_AMOUNT_SIGN_MESSAGE, + ) + }) +}) diff --git a/lib/salary/recurring-lines.ts b/lib/salary/recurring-lines.ts new file mode 100644 index 00000000..f1df59ba --- /dev/null +++ b/lib/salary/recurring-lines.ts @@ -0,0 +1,85 @@ +import type { SalaryLineItemType } from '@/types' + +/** + * Recurring payroll lines (issue #2042): standing per-employee payslip rows + * derived into every salary run inside their validity window, e.g. a benefit + * bike bruttolöneavdrag of -670,17 kr/month. + * + * This module is pure: item-type whitelist, flag derivation and amount-sign + * validation. The DB derivation lives in run-calculation.ts (step 8d3) and + * mirrors the employee_benefits step 8d lifecycle via + * salary_line_items.source_recurring_line_id. + */ + +/** Item types a recurring line may use. Mirrors the table CHECK constraint. */ +export const RECURRING_LINE_ITEM_TYPES = [ + 'gross_deduction_pension', + 'gross_deduction_other', + 'net_deduction_union', + 'net_deduction_benefit_payment', + 'net_deduction_other', +] as const satisfies readonly SalaryLineItemType[] + +export type RecurringLineItemType = (typeof RECURRING_LINE_ITEM_TYPES)[number] + +export interface RecurringLineFlags { + is_taxable: boolean + is_avgift_basis: boolean + is_vacation_basis: boolean + is_gross_deduction: boolean + is_net_deduction: boolean +} + +/** + * Derive the salary_line_items flags from the item type instead of storing + * them: a gross deduction that is not tax-reducing (or a net deduction that + * is) cannot be expressed, so the payslip math stays consistent by + * construction. + * + * Gross deductions carry the sick-karens convention: negative amount with + * is_taxable + is_avgift_basis true, so they reduce both the tax base and the + * arbetsgivaravgift base. Net deductions only move money after tax. Neither + * touches the semester base (is_vacation_basis false on the deduction row: + * the loneväxling-style choice recorded in DECISIONS.md). + * + * Recurring ADDITIONS ('other') are deliberately not supported: the engine's + * calculateSalary only treats ADDITION_TYPES as additions and never reads a + * generic taxable row into gross/tax/AGA, so a recurring 'other' would show + * on the payslip without being paid or declared. Teach the engine first. + */ +export function recurringLineFlags(itemType: RecurringLineItemType): RecurringLineFlags { + if (itemType === 'gross_deduction_pension' || itemType === 'gross_deduction_other') { + return { + is_taxable: true, + is_avgift_basis: true, + is_vacation_basis: false, + is_gross_deduction: true, + is_net_deduction: false, + } + } + return { + is_taxable: false, + is_avgift_basis: false, + is_vacation_basis: false, + is_gross_deduction: false, + is_net_deduction: true, + } +} + +/** Shared 400 copy: schema, route backstop and UI hint say the same thing. */ +export const RECURRING_LINE_AMOUNT_SIGN_MESSAGE = + 'Återkommande rader är avdrag och måste ha negativt belopp.' + +/** + * Validate the amount sign for an item type. Returns an error message or + * null. Mirrors the employee_recurring_lines_amount_sign CHECK so bad input + * is a 400 with field-level feedback rather than a Postgres 23514. + */ +export function validateRecurringLineAmount( + itemType: RecurringLineItemType, + amount: number, +): string | null { + void itemType // every supported type is a deduction; kept for call-site shape + if (!Number.isFinite(amount) || amount >= 0) return RECURRING_LINE_AMOUNT_SIGN_MESSAGE + return null +} diff --git a/lib/salary/run-calculation.ts b/lib/salary/run-calculation.ts index afad6c11..d8c01894 100644 --- a/lib/salary/run-calculation.ts +++ b/lib/salary/run-calculation.ts @@ -31,6 +31,7 @@ import { loadPayrollConfig, serializePayrollConfig } from './payroll-config' import { fetchAllTaxTableRatesForRun, TaxTableUnavailableError } from './tax-tables' import { loadAndDeriveAbsence } from './derive-absence-line-items' import { getLineItemAccount } from './account-mapping' +import { recurringLineFlags, type RecurringLineItemType } from './recurring-lines' import { computePremiumLines } from './shift-premium-engine' import { roundOre } from '@/lib/money' import { computePriorYtd, loadOpeningBalances } from './ytd' @@ -502,6 +503,66 @@ export async function runSalaryCalculation( } } + // 8d3. Derive recurring line items from employee_recurring_lines: same + // lifecycle as the benefit rows (delete by back-link, re-derive for + // rows whose validity window covers the payment date). Flags come + // from the item type so a stored row can never contradict the + // payslip math. + // valid_to is filtered in JS rather than with a dynamic .or() so the + // phantom-column scanner can resolve every expression in this query. + const { data: recurringRows, error: recurringErr } = await supabase + .from('employee_recurring_lines') + .select('id, item_type, description, amount, account_number, valid_to') + .eq('employee_id', emp.id) + .eq('company_id', companyId) + .eq('is_active', true) + .lte('valid_from', run.payment_date) + if (recurringErr) { + return { ok: false, code: 'DATABASE_ERROR', details: recurringErr } + } + const activeRecurring = (recurringRows ?? []).filter( + (r) => !r.valid_to || r.valid_to >= run.payment_date, + ) + + const { error: delRecurringErr } = await supabase + .from('salary_line_items') + .delete() + .eq('salary_run_employee_id', sre.id) + .not('source_recurring_line_id', 'is', null) + if (delRecurringErr) { + return { ok: false, code: 'DATABASE_ERROR', details: delRecurringErr } + } + + const derivedRecurringRows = activeRecurring.map((r, idx) => { + const itemType = r.item_type as RecurringLineItemType + const flags = recurringLineFlags(itemType) + return { + salary_run_employee_id: sre.id, + company_id: companyId, + item_type: itemType, + description: r.description, + quantity: 1, + amount: roundOre(r.amount), + is_taxable: flags.is_taxable, + is_avgift_basis: flags.is_avgift_basis, + is_vacation_basis: flags.is_vacation_basis, + is_gross_deduction: flags.is_gross_deduction, + is_net_deduction: flags.is_net_deduction, + account_number: r.account_number || getLineItemAccount(itemType, emp.employment_type), + sort_order: 250 + idx, + source_recurring_line_id: r.id, + } + }) + + if (derivedRecurringRows.length > 0) { + const { error: insRecurringErr } = await supabase + .from('salary_line_items') + .insert(derivedRecurringRows) + if (insRecurringErr) { + return { ok: false, code: 'DATABASE_ERROR', details: insRecurringErr } + } + } + if (absenceResult.lineItems.length > 0) { const rows = absenceResult.lineItems.map((li, idx) => ({ salary_run_employee_id: sre.id, @@ -606,6 +667,7 @@ export async function runSalaryCalculation( if (DERIVED_ABSENCE_TYPES.includes(li.item_type as SalaryLineItemType)) return false if (DERIVED_PREMIUM_TYPES.includes(li.item_type as ShiftPremiumItemType)) return false if (li.source_benefit_id) return false + if (li.source_recurring_line_id) return false if (li.item_type === 'semesterersattning') return false if (li.item_type === 'oresavrundning') return false return true @@ -646,7 +708,22 @@ export async function runSalaryCalculation( isGrossDeduction: false, isNetDeduction: false, })) - const lineItems = [...manualLineItems, ...derivedLineItems, ...derivedBenefitLineItems, ...derivedPremiumLineItems] + const derivedRecurringLineItems = derivedRecurringRows.map((row) => ({ + itemType: row.item_type as SalaryLineItemType, + amount: row.amount, + isTaxable: row.is_taxable, + isAvgiftBasis: row.is_avgift_basis, + isVacationBasis: row.is_vacation_basis, + isGrossDeduction: row.is_gross_deduction, + isNetDeduction: row.is_net_deduction, + })) + const lineItems = [ + ...manualLineItems, + ...derivedLineItems, + ...derivedBenefitLineItems, + ...derivedPremiumLineItems, + ...derivedRecurringLineItems, + ] // 8f. Run the engine for this employee. const result = calculateSalary( diff --git a/messages/en.json b/messages/en.json index 76a3fe3e..0212b15f 100644 --- a/messages/en.json +++ b/messages/en.json @@ -7306,6 +7306,30 @@ "benefits_monthly_value": "Monthly benefit value (SEK)", "benefits_valid_from": "Valid from", "benefits_valid_to": "Valid to (optional)", + "recurring_title": "Recurring payroll lines", + "recurring_help": "Active lines are added automatically to every salary run, e.g. a benefit-bike gross salary deduction.", + "recurring_add": "Add line", + "recurring_empty": "No recurring payroll lines.", + "recurring_ongoing": "ongoing", + "recurring_per_month": "/mo", + "recurring_remove": "Remove", + "recurring_added": "Line added", + "recurring_save_failed": "Could not save the line", + "recurring_removed": "Line removed", + "recurring_remove_failed": "Could not remove the line", + "recurring_type": "Type", + "recurring_type_gross_deduction_pension": "Gross deduction, pension (salary exchange)", + "recurring_type_gross_deduction_other": "Gross salary deduction", + "recurring_type_net_deduction_union": "Union fee", + "recurring_type_net_deduction_benefit_payment": "Net deduction, benefit co-payment", + "recurring_type_net_deduction_other": "Net salary deduction", + "recurring_description": "Description", + "recurring_amount_deduction": "Deduction per month (SEK)", + "recurring_amount_placeholder": "e.g. 670.17", + "recurring_deduction_hint": "Enter as a positive amount; it is deducted from every month's pay. Gross deductions reduce the tax and employer-fee bases; net deductions come out after tax.", + "recurring_window_hint": "The line is included in salary runs whose payment date falls inside the period (bounds inclusive). No proration for partial months.", + "recurring_valid_from": "Valid from", + "recurring_valid_to": "Valid to (optional)", "kommun_search_placeholder": "Search municipality…", "kommun_table_number": "Table {number}", "kommun_load_failed": "Could not fetch the municipality list — type the municipality name and enter the tax table (skattetabell) manually.", diff --git a/messages/sv.json b/messages/sv.json index 3abf81dc..93a430b2 100644 --- a/messages/sv.json +++ b/messages/sv.json @@ -7306,6 +7306,30 @@ "benefits_monthly_value": "Månatligt förmånsvärde (SEK)", "benefits_valid_from": "Gäller från", "benefits_valid_to": "Gäller till (valfritt)", + "recurring_title": "Återkommande lönerader", + "recurring_help": "Aktiva rader läggs till automatiskt vid varje lönekörning, t.ex. ett bruttolöneavdrag för förmånscykel.", + "recurring_add": "Lägg till rad", + "recurring_empty": "Inga återkommande lönerader.", + "recurring_ongoing": "tills vidare", + "recurring_per_month": "/mån", + "recurring_remove": "Ta bort", + "recurring_added": "Rad tillagd", + "recurring_save_failed": "Kunde inte spara raden", + "recurring_removed": "Rad borttagen", + "recurring_remove_failed": "Kunde inte ta bort raden", + "recurring_type": "Typ", + "recurring_type_gross_deduction_pension": "Bruttolöneavdrag pension (löneväxling)", + "recurring_type_gross_deduction_other": "Bruttolöneavdrag", + "recurring_type_net_deduction_union": "Fackavgift", + "recurring_type_net_deduction_benefit_payment": "Nettolöneavdrag förmån", + "recurring_type_net_deduction_other": "Nettolöneavdrag", + "recurring_description": "Beskrivning", + "recurring_amount_deduction": "Avdrag per månad (SEK)", + "recurring_amount_placeholder": "t.ex. 670,17", + "recurring_deduction_hint": "Anges som positivt belopp och dras från lönen varje månad. Bruttolöneavdrag sänker skatte- och avgiftsunderlaget; nettolöneavdrag dras efter skatt.", + "recurring_window_hint": "Raden tas med i lönekörningar vars utbetalningsdatum ligger inom perioden (inklusive gränsdagarna). Ingen proportionering görs för delmånader.", + "recurring_valid_from": "Gäller från", + "recurring_valid_to": "Gäller till (valfritt)", "kommun_search_placeholder": "Sök kommun…", "kommun_table_number": "Tabell {number}", "kommun_load_failed": "Kunde inte hämta kommunlistan — skriv kommunnamnet och ange skattetabellen manuellt.", diff --git a/supabase/migrations/20260902140000_employee_recurring_lines.sql b/supabase/migrations/20260902140000_employee_recurring_lines.sql new file mode 100644 index 00000000..ccda8e2a --- /dev/null +++ b/supabase/migrations/20260902140000_employee_recurring_lines.sql @@ -0,0 +1,129 @@ +-- Recurring payroll lines per employee (issue #2042). +-- +-- A standard Swedish payroll setup is a benefit bike paid via bruttolöneavdrag: +-- the payslip carries the same signed line every month (e.g. "Förmånscykel +-- bruttolöneavdrag -670,17 kr"). employee_benefits only derives positive +-- taxable benefit rows, so recurring deductions had to be re-added by hand on +-- every run: easy to forget and silently wrong (overstated gross, tax, AGA). +-- +-- This table mirrors the employee_benefits pattern: an active row inside its +-- validity window is derived into salary_line_items on every calculation, +-- marked with source_recurring_line_id so recalculation replaces derived rows +-- without touching manual ones. + +-- Same-company integrity by construction (the dimensions pattern): the +-- composite FK below binds employee_id to the row's company_id, so an +-- RLS-authorized member of one company can never point a recurring line at +-- another company's employee (IDOR guard, CWE-639). +-- Idempotent: #2145 (expense claims) adds the same key, so whichever PR +-- merges second must not collide. +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint + WHERE conname = 'employees_id_company_id_key' + AND conrelid = 'public.employees'::regclass + ) THEN + ALTER TABLE public.employees ADD CONSTRAINT employees_id_company_id_key UNIQUE (id, company_id); + END IF; +END $$; + +CREATE TABLE public.employee_recurring_lines ( + id uuid DEFAULT gen_random_uuid() PRIMARY KEY, + -- No single-column employees FK: the composite FK below carries both the + -- integrity and the cascade (the dimensions-tables convention). + employee_id uuid NOT NULL, + company_id uuid NOT NULL REFERENCES public.companies(id) ON DELETE CASCADE, + FOREIGN KEY (employee_id, company_id) + REFERENCES public.employees(id, company_id) ON DELETE CASCADE, + user_id uuid NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE, + + -- Reuses existing salary_line_items item types; no CHECK expansion needed + -- there. Only deductions: the engine does not treat a generic 'other' + -- addition as pay, so recurring additions are excluded until it does. + item_type text NOT NULL CHECK (item_type IN ( + 'gross_deduction_pension', + 'gross_deduction_other', + 'net_deduction_union', + 'net_deduction_benefit_payment', + 'net_deduction_other' + )), + description text NOT NULL, + amount numeric NOT NULL, + -- Optional BAS account override; NULL falls back to the engine's + -- LINE_ITEM_ACCOUNTS mapping for the item type. + account_number text, + + valid_from date NOT NULL, + valid_to date, + metadata jsonb NOT NULL DEFAULT '{}'::jsonb, + is_active boolean NOT NULL DEFAULT true, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + + CHECK (valid_to IS NULL OR valid_to >= valid_from), + CONSTRAINT employee_recurring_lines_amount_sign CHECK (amount < 0), + CONSTRAINT employee_recurring_lines_account_format CHECK ( + account_number IS NULL OR account_number ~ '^[0-9]{4}$' + ) +); + +ALTER TABLE public.employee_recurring_lines ENABLE ROW LEVEL SECURITY; + +CREATE POLICY "view own-company employee_recurring_lines" + ON public.employee_recurring_lines FOR SELECT + USING (company_id IN (SELECT user_company_ids())); +-- Membership predicates like every sibling table; the read-only viewer is +-- kept out by the aa_enforce_company_writer_role trigger below, which is the +-- gate 20260902093000 put on every company-scoped table (it also fires +-- inside SECURITY DEFINER bodies, where RLS does not apply). +CREATE POLICY "insert own-company employee_recurring_lines" + ON public.employee_recurring_lines FOR INSERT + WITH CHECK (company_id IN (SELECT user_company_ids())); +CREATE POLICY "update own-company employee_recurring_lines" + ON public.employee_recurring_lines FOR UPDATE + USING (company_id IN (SELECT user_company_ids())); +CREATE POLICY "delete own-company employee_recurring_lines" + ON public.employee_recurring_lines FOR DELETE + USING (company_id IN (SELECT user_company_ids())); + +CREATE INDEX idx_employee_recurring_lines_company + ON public.employee_recurring_lines (company_id); +CREATE INDEX idx_employee_recurring_lines_employee + ON public.employee_recurring_lines (employee_id); +CREATE INDEX idx_employee_recurring_lines_active + ON public.employee_recurring_lines (employee_id, valid_from, valid_to) + WHERE is_active = true; + +-- The table-level writer guard 20260902093000 attaches to every +-- company-scoped table: it also fires inside SECURITY DEFINER bodies, where +-- RLS does not apply. This migration is versioned after that one so the +-- function exists when a fresh database replays the folder in order. +CREATE TRIGGER aa_enforce_company_writer_role + BEFORE INSERT OR UPDATE OR DELETE ON public.employee_recurring_lines + FOR EACH ROW EXECUTE FUNCTION public.enforce_company_writer_role(); + +CREATE TRIGGER set_updated_at_employee_recurring_lines + BEFORE UPDATE ON public.employee_recurring_lines + FOR EACH ROW EXECUTE FUNCTION public.update_updated_at_column(); + +CREATE TRIGGER audit_employee_recurring_lines + AFTER INSERT OR UPDATE OR DELETE ON public.employee_recurring_lines + FOR EACH ROW EXECUTE FUNCTION public.write_audit_log(); + +-- Back-link so the calculation can replace derived rows idempotently, +-- mirroring salary_line_items.source_benefit_id but with NO ACTION instead +-- of SET NULL: a deletion racing a concurrent derivation must fail (23503) +-- rather than silently orphan the derived row into an apparent manual row. +-- The DELETE route catches 23503 and falls back to deactivating the line. +-- Company deletion still cascades cleanly: NO ACTION defers the check to +-- statement end, by which time the same cascade removed both sides. +ALTER TABLE public.salary_line_items + ADD COLUMN source_recurring_line_id uuid + REFERENCES public.employee_recurring_lines(id); + +CREATE INDEX idx_salary_line_items_source_recurring_line + ON public.salary_line_items (source_recurring_line_id) + WHERE source_recurring_line_id IS NOT NULL; + +NOTIFY pgrst, 'reload schema'; diff --git a/tests/pg/employee-recurring-lines.pg.test.ts b/tests/pg/employee-recurring-lines.pg.test.ts new file mode 100644 index 00000000..900c236e --- /dev/null +++ b/tests/pg/employee-recurring-lines.pg.test.ts @@ -0,0 +1,208 @@ +import { randomUUID } from 'node:crypto' +import { describe, it, expect } from 'vitest' +import { getPool, withUserContext } from './setup' +import { seedCompany, insertAuthUser, insertCompanyMember } from './fixtures' + +// pg-real coverage for 20260902140000_employee_recurring_lines: RLS (member +// read, stranger blind, viewers denied every write), the composite FK +// (cross-company insert refused), the CHECKs (deduction-only item types, +// negative amount, period order, account format) and the NO ACTION back-link +// FK from salary_line_items (delete of a derived-into line fails with 23503). + +async function seedEmployee(): Promise<{ userId: string; companyId: string; employeeId: string }> { + const { userId, companyId } = await seedCompany() + const employeeId = randomUUID() + await getPool().query( + `INSERT INTO public.employees + (id, company_id, user_id, first_name, last_name, personnummer, personnummer_last4, employment_start) + VALUES ($1, $2, $3, 'Test', 'Testsson', 'enc-payload', '0000', '2026-01-01')`, + [employeeId, companyId, userId], + ) + return { userId, companyId, employeeId } +} + +async function insertLine( + companyId: string, + employeeId: string, + userId: string, + overrides: Partial<{ itemType: string; amount: number; validTo: string | null; account: string | null }> = {}, +): Promise { + const id = randomUUID() + await getPool().query( + `INSERT INTO public.employee_recurring_lines + (id, employee_id, company_id, user_id, item_type, description, amount, account_number, valid_from, valid_to) + VALUES ($1, $2, $3, $4, $5, 'Förmånscykel bruttolöneavdrag', $6, $7, '2026-01-01', $8)`, + [ + id, + employeeId, + companyId, + userId, + overrides.itemType ?? 'gross_deduction_other', + overrides.amount ?? -670.17, + overrides.account ?? null, + overrides.validTo === undefined ? null : overrides.validTo, + ], + ) + return id +} + +describe('employee_recurring_lines RLS', () => { + it('lets company members read, strangers see nothing', async () => { + const { userId, companyId, employeeId } = await seedEmployee() + const lineId = await insertLine(companyId, employeeId, userId) + const stranger = await insertAuthUser() + + const memberView = await withUserContext(userId, (client) => + client.query<{ id: string }>(`SELECT id FROM public.employee_recurring_lines WHERE id = $1`, [lineId]), + ) + expect(memberView.rows).toHaveLength(1) + + const strangerView = await withUserContext(stranger, (client) => + client.query<{ id: string }>(`SELECT id FROM public.employee_recurring_lines WHERE id = $1`, [lineId]), + ) + expect(strangerView.rows).toHaveLength(0) + }) + + it('viewers read but are denied insert, update and delete', async () => { + // The write policies AND in current_user_can_write(), so a read-only + // viewer cannot write straight through PostgREST even though the route's + // requireWrite would also refuse. + const { userId, companyId, employeeId } = await seedEmployee() + const lineId = await insertLine(companyId, employeeId, userId) + const viewer = await insertAuthUser() + await insertCompanyMember({ companyId, userId: viewer, role: 'viewer' }) + + const viewerRead = await withUserContext(viewer, (client) => + client.query<{ id: string }>(`SELECT id FROM public.employee_recurring_lines WHERE id = $1`, [lineId]), + ) + expect(viewerRead.rows).toHaveLength(1) + + await expect( + withUserContext(viewer, (client) => + client.query( + `INSERT INTO public.employee_recurring_lines + (employee_id, company_id, user_id, item_type, description, amount, valid_from) + VALUES ($1, $2, $3, 'net_deduction_union', 'Fackavgift', -100, '2026-01-01')`, + [employeeId, companyId, viewer], + ), + ), + ).rejects.toMatchObject({ code: '42501' }) + + await expect( + withUserContext(viewer, (client) => + client.query(`UPDATE public.employee_recurring_lines SET amount = -1 WHERE id = $1`, [lineId]), + ), + ).rejects.toMatchObject({ code: '42501' }) + await expect( + withUserContext(viewer, (client) => + client.query(`DELETE FROM public.employee_recurring_lines WHERE id = $1`, [lineId]), + ), + ).rejects.toMatchObject({ code: '42501' }) + + const survived = await getPool().query( + `SELECT amount FROM public.employee_recurring_lines WHERE id = $1`, + [lineId], + ) + expect(survived.rows).toHaveLength(1) + expect(Number(survived.rows[0].amount)).toBeCloseTo(-670.17, 2) + }) + + it('non-members cannot write at all', async () => { + const { userId, companyId, employeeId } = await seedEmployee() + const lineId = await insertLine(companyId, employeeId, userId) + const stranger = await insertAuthUser() + + await expect( + withUserContext(stranger, (client) => + client.query( + `INSERT INTO public.employee_recurring_lines + (employee_id, company_id, user_id, item_type, description, amount, valid_from) + VALUES ($1, $2, $3, 'net_deduction_union', 'Fackavgift', -100, '2026-01-01')`, + [employeeId, companyId, stranger], + ), + ), + ).rejects.toThrow(/row-level security|permission|privilege/i) + + const del = await withUserContext(stranger, (client) => + client.query(`DELETE FROM public.employee_recurring_lines WHERE id = $1`, [lineId]), + ) + expect(del.rowCount).toBe(0) + }) + + it('the composite FK refuses pointing a line at another company employee', async () => { + const a = await seedEmployee() + const b = await seedEmployee() + + // Insert as superuser (bypasses RLS): the composite (employee_id, + // company_id) FK is what must refuse the cross-company pair. + await expect( + getPool().query( + `INSERT INTO public.employee_recurring_lines + (employee_id, company_id, user_id, item_type, description, amount, valid_from) + VALUES ($1, $2, $3, 'gross_deduction_other', 'IDOR', -100, '2026-01-01')`, + [b.employeeId, a.companyId, a.userId], + ), + ).rejects.toThrow(/foreign key/) + }) +}) + +describe('employee_recurring_lines constraints', () => { + it('rejects non-deduction item types (other) and positive amounts', async () => { + const { userId, companyId, employeeId } = await seedEmployee() + await expect( + insertLine(companyId, employeeId, userId, { itemType: 'other', amount: -500 }), + ).rejects.toThrow(/item_type/) + await expect( + insertLine(companyId, employeeId, userId, { amount: 500 }), + ).rejects.toThrow(/amount_sign/) + }) + + it('rejects valid_to before valid_from and malformed account overrides', async () => { + const { userId, companyId, employeeId } = await seedEmployee() + await expect( + insertLine(companyId, employeeId, userId, { validTo: '2025-12-31' }), + ).rejects.toThrow(/check constraint/i) + await expect( + insertLine(companyId, employeeId, userId, { account: '73' }), + ).rejects.toThrow(/account_format/) + }) +}) + +describe('salary_line_items back-link FK', () => { + it('NO ACTION blocks deleting a line that has been derived into a run', async () => { + const { userId, companyId, employeeId } = await seedEmployee() + const lineId = await insertLine(companyId, employeeId, userId) + + const runId = randomUUID() + await getPool().query( + `INSERT INTO public.salary_runs (id, company_id, user_id, period_year, period_month, payment_date, status) + VALUES ($1, $2, $3, 2026, 8, '2026-08-25', 'draft')`, + [runId, companyId, userId], + ) + const sreId = randomUUID() + await getPool().query( + `INSERT INTO public.salary_run_employees (id, salary_run_id, employee_id, company_id, salary_type, monthly_salary, employment_degree) + VALUES ($1, $2, $3, $4, 'monthly', 35000, 100)`, + [sreId, runId, employeeId, companyId], + ) + await getPool().query( + `INSERT INTO public.salary_line_items + (id, salary_run_employee_id, company_id, item_type, description, quantity, amount, + is_taxable, is_avgift_basis, is_vacation_basis, is_gross_deduction, is_net_deduction, source_recurring_line_id) + VALUES ($1, $2, $3, 'gross_deduction_other', 'Förmånscykel bruttolöneavdrag', 1, -670.17, + true, true, false, true, false, $4)`, + [randomUUID(), sreId, companyId, lineId], + ) + + await expect( + getPool().query(`DELETE FROM public.employee_recurring_lines WHERE id = $1`, [lineId]), + ).rejects.toThrow(/foreign key/) + + // Deactivation (the DELETE route's fallback) still works. + const deactivate = await getPool().query( + `UPDATE public.employee_recurring_lines SET is_active = false WHERE id = $1`, + [lineId], + ) + expect(deactivate.rowCount).toBe(1) + }) +}) diff --git a/tests/schema/no-phantom-columns.test.ts b/tests/schema/no-phantom-columns.test.ts index d762ac51..a806c9ca 100644 --- a/tests/schema/no-phantom-columns.test.ts +++ b/tests/schema/no-phantom-columns.test.ts @@ -130,6 +130,18 @@ const KNOWN_STALE_ON_CONFLICT: Record = {} * same patch-shape rationale as webshop-orders ingest: one literal per key * combination is not viable. The field set is pinned by validatePatch and * covered by payroll-executors.test.ts; both selects around it are literals. + * + * 2026-08-30 recurring payroll lines (#2042): +2 for the same two shapes the + * employee_benefits code already carries: the step-8d3 derived-rows insert + * (rows built in a .map with literal keys, opaque to the scanner) and the + * PATCH route's merged-updates payload (explicit literal keys, but assembled + * conditionally into a variable). Both carry scoped assertions instead: + * employee-recurring-lines.pg.test.ts inserts the derived-row shape against + * the real table, and the PATCH route test pins the exact writable column + * set ("writes exactly the patchable columns and nothing else"). Making + * either literal would cost a real property: the PATCH would have to write + * every column on every request, turning a partial update into + * last-write-wins. */ // 2026-08-20: +1 for lib/connect/instance/sync.ts, whose capability_grants // upsert is a per-company x per-scope row array built at runtime (one chunked @@ -153,7 +165,9 @@ const KNOWN_STALE_ON_CONFLICT: Record = {} // lines as a row array built from one literal mapper (toInsertRow). Every // header/line update in the module is an object literal. Merged with main // (parties phase 1, #2162/#2168/#2169) at 395: 397. -const UNRESOLVED_CEILING = 397 +// 2026-09-04: +2 recurring lines (#2044, see the 2026-08-30 recurring payroll +// lines note above); merged with main (#2141/#2164/#2170/#2192) at 397: 399. +const UNRESOLVED_CEILING = 399 /** * Floor on statically resolved column references. Guards the guard: if a change