diff --git a/DECISIONS.md b/DECISIONS.md index c9ca0253..ae93eb23 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -1533,6 +1533,7 @@ One line per decision: `[YYYY-MM-DD] : `. Appended by agents and [2026-09-03] SCB name search is a picker, never a lookup: the user chooses among SCB's matches and the chosen org number is recorded as a fact with source user before any fetch; one match is shown, not auto-picked, because a trade name is not an identity (Adobe Systems Software resolves to an Irish entity and a Swedish one) [2026-09-03] AGI redovisningsperiod = the payout month (agiReportingPeriod on payment_date), not salary_runs.period_*: Skatteverket files per the month the pay went out (kontantprincipen), so lön i efterskott (August work paid 25 September) is declared in September. The in-period payment-date guard (dashboard PATCH, lib/salary/update-run.ts, v1 PATCH, RunHeader min/max) is lifted rather than widened: its only stated reason was that the AGI keyed on period_*, and any residual month window would bite the next efterskott variant. Existing agi_declarations rows keep their stored period (no backfill): a declaration already filed under the earned month is a real-world correction with Skatteverket, not a re-key. New AGI_PERIOD_CONFLICT (409) refuses to overwrite a live run's declaration for the same payout month, since one month's AGI must cover every payment that month and the generator cannot merge runs. Issue #2191. [2026-09-03] The cursor:// deeplink is its own allowlist provider (cursor_deeplink) rendered "Din egen dator" and never "Verifierad", after the skeptic, CodeRabbit and Superagent all made the same point: a custom scheme can be claimed by any local app (RFC 8252 section 8.4), so it carries loopback trust, not vendor trust, and the consent page must not say otherwise; https://www.cursor.com/... keeps the verified label. Same pass fixed the consent-page CSP for custom schemes: new URL('cursor://...').origin is the string "null", so form-action became `'self' null` and Chromium would have blocked the post-consent 303 (correctness skeptic refutation); the header now uses the scheme-source (`cursor:`) when the origin is opaque. Not done: rejecting a missing code_challenge at /authorize. A code minted without one is unexchangeable (verifyPkce against an empty challenge is always false, now pinned by a test), so it is fail-closed; making it fail earlier is a separate change touching every client. +[2026-09-03] Jämkning valid_to is required on every write path (web, v1, MCP staging and executors, both Zod schemas) through one shared validator (lib/salary/jamkning-rules.ts), closing #2058: the engine never applies a beslut without an end date, so the previously accepted percentage+valid_from shape stored an inert beslut behind a 200. Declined the alternative of defaulting valid_to to 31 December of the from-year: it matches most beslut but silently changes withholding on rows that today do nothing. Existing incomplete rows are listed by scripts/list-incomplete-jamkning.ts and decided per company (set an end date or clear the beslut), since either changes the next payslip. [2026-09-03] The Lucide Building2 glyph is retired app-wide (founder, from the register walkthrough). Suppliers use Truck, companies and company-scoped things use Briefcase, banks use Landmark; the extension manifest icon name changed with it [2026-09-03] settleInvoicePayment writes the invoice_payments row BEFORE the CAS status update and removes it in both failure branches, instead of inserting after the update: the kontantmetod cut-off reads invoice_payments only, so a paid invoice without a row is the #2019 defect itself; failing closed on the insert (voucher storno + INVOICE_PAID_BOOK_FAILED) keeps GL, sub-ledger and invoice status in step. The #2019 backfill inserts only where exactly one posted payment voucher exists (invoice_paid / invoice_cash_payment with source_id = invoice); zero or several vouchers are reported, never guessed, and every row is tagged backfill:#2019 in notes so one DELETE reverts a run. [2026-09-03] #2019 skeptic round: the invoice_payments row is written by one helper (lib/invoices/invoice-payment-row.ts) from all four transaction-less settlement paths (dashboard, v1, MCP mark-paid, Stripe), with amount = applied amount (new paid_amount minus prior) rather than cash received, so a 3740 öre absorption never produces a negative fordran in the cut-off or a wrong storno restore. A payment row with transaction_id NULL does NOT count as "reconciled to a bank line" in the two duplicate detectors: the bank line for a manual settlement arrives later and the voucher must still be offered as a twin. The backfill dates rows from the voucher entry_date (paid_at was wall-clock before #1332), refuses rows that disagree with the voucher's 1510 credit / settlement debit, reports partially covered invoices (rows_short) instead of patching them, and records each executed run in behandlingshistorik (new event type InvoicePaymentRowBackfilled, migration 20260903180000). Not done here, pre-existing: the bank-match and pending-operations match paths still store cash received as the row amount, and the kontantmetod cut-off ignores ROT/RUT deduction_total (1513 share shows as outstanding); both filed as follow-ups. diff --git a/app/api/salary/employees/[id]/__tests__/route.test.ts b/app/api/salary/employees/[id]/__tests__/route.test.ts index f4911e78..b3d67674 100644 --- a/app/api/salary/employees/[id]/__tests__/route.test.ts +++ b/app/api/salary/employees/[id]/__tests__/route.test.ts @@ -220,6 +220,7 @@ describe('personnummer contract on /api/salary/employees/[id]', () => { */ describe('jämkning on PATCH /api/salary/employees/[id]', () => { const JAMKNING_START_REQUIRED = 'Jämkningens startdatum måste anges när jämkningsprocent sätts' + const JAMKNING_END_REQUIRED = 'Jämkningens slutdatum måste anges när jämkningsprocent sätts' const JAMKNING_ORDER = 'Jämkningens slutdatum måste vara efter startdatumet' function useRow(existing: Record) { @@ -259,6 +260,20 @@ describe('jämkning on PATCH /api/salary/employees/[id]', () => { expect(captured.updates).toBeNull() }) + it('400 when a percentage is set with a start date but no end date (#2058)', async () => { + const captured = useRow({ ...EXISTING_ROW, jamkning_percentage: null, jamkning_valid_from: null, jamkning_valid_to: null }) + + const response = await PATCH( + patchRequest({ jamkning_percentage: 20, jamkning_valid_from: '2026-01-01', jamkning_valid_to: null }), + params, + ) + const { status, body } = await parseJsonResponse<{ error: string }>(response) + + expect(status).toBe(400) + expect(body.error).toContain(JAMKNING_END_REQUIRED) + expect(captured.updates).toBeNull() + }) + it('400 when the end date precedes the start date within the body', async () => { const captured = useRow({ ...EXISTING_ROW }) diff --git a/app/api/salary/employees/[id]/route.ts b/app/api/salary/employees/[id]/route.ts index 1cf5b76d..f22f2e45 100644 --- a/app/api/salary/employees/[id]/route.ts +++ b/app/api/salary/employees/[id]/route.ts @@ -7,6 +7,7 @@ import { getCompanyEntityType } from '@/lib/company/context' import { encryptPersonnummer, extractLast4, maskEmployeeForResponse, validatePersonnummer } from '@/lib/salary/personnummer' import { isEmploymentTypeAllowedForEntity, EF_OWNER_EMPLOYMENT_ERROR } from '@/lib/salary/employment-rules' import { validateEmployeeBankAccount } from '@/lib/salary/payment/bank-account' +import { touchesJamkning, validateJamkning } from '@/lib/salary/jamkning-rules' import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message' ensureInitialized() @@ -72,28 +73,15 @@ export const PATCH = withRouteContext<{ params: Promise<{ id: string }> }>( if (merged.f_skatt_status === 'a_skatt' && !merged.is_sidoinkomst && !merged.tax_table_number) { mergedErrors.push('Skattetabell krävs för A-skatt anställda') } - // Merged-state jämkning check (same rule as the v1 route and - // employee-commands): a non-null percentage needs a start date, and the - // dates must be ordered, but the schema can only see the body. Only run - // when the PATCH touches a jamkning field: a legacy row with inconsistent - // jamkning_* state must not block unrelated updates (fixing it requires - // touching those very fields). `body` is the parsed patch: absent keys are - // absent, explicit nulls survive. - const jamkningTouched = - 'jamkning_percentage' in body || - 'jamkning_valid_from' in body || - 'jamkning_valid_to' in body - if (jamkningTouched) { - if (merged.jamkning_percentage != null && !merged.jamkning_valid_from) { - mergedErrors.push('Jämkningens startdatum måste anges när jämkningsprocent sätts') - } - if ( - merged.jamkning_valid_from && - merged.jamkning_valid_to && - merged.jamkning_valid_to < merged.jamkning_valid_from - ) { - mergedErrors.push('Jämkningens slutdatum måste vara efter startdatumet') - } + // Merged-state jämkning check through the shared validator (same rule as + // the v1 route and employee-commands): a non-null percentage needs both + // dates, and the dates must be ordered, but the schema can only see the + // body. Only run when the PATCH touches a jamkning field: a legacy row + // with inconsistent jamkning_* state must not block unrelated updates + // (fixing it requires touching those very fields). `body` is the parsed + // patch: absent keys are absent, explicit nulls survive. #2058 + if (touchesJamkning(body)) { + for (const issue of validateJamkning(merged)) mergedErrors.push(issue.message) } if (mergedErrors.length > 0) { return NextResponse.json({ error: mergedErrors.join('. ') }, { status: 400 }) diff --git a/app/api/salary/employees/__tests__/route.test.ts b/app/api/salary/employees/__tests__/route.test.ts index b26f5187..ae8a9371 100644 --- a/app/api/salary/employees/__tests__/route.test.ts +++ b/app/api/salary/employees/__tests__/route.test.ts @@ -219,6 +219,19 @@ describe('POST /api/salary/employees', () => { }) }) + it('returns 400 on a jämkning percentage without an end date, without inserting (#2058)', async () => { + const { supabase, insert } = supabaseWithInsert({ id: 'emp-new', personnummer: encryptPersonnummer(NEW_PNR) }) + authed(supabase) + + const res = await POST( + postRequest({ ...CREATE_BASE, jamkning_percentage: 12.5, jamkning_valid_from: '2026-01-01' }), + params, + ) + + expect(res.status).toBe(400) + expect(insert).not.toHaveBeenCalled() + }) + it('inserts null jämkning fields when the body omits them', async () => { const { supabase, insert } = supabaseWithInsert({ id: 'emp-new', personnummer: encryptPersonnummer(NEW_PNR) }) authed(supabase) diff --git a/app/api/v1/companies/[companyId]/employees/[id]/route.ts b/app/api/v1/companies/[companyId]/employees/[id]/route.ts index c24eb9a7..f56dc6af 100644 --- a/app/api/v1/companies/[companyId]/employees/[id]/route.ts +++ b/app/api/v1/companies/[companyId]/employees/[id]/route.ts @@ -27,6 +27,7 @@ import { readV1JsonBody } from '@/lib/api/v1/body' import { UpdateEmployeeSchema } from '@/lib/api/schemas' import { maskPersonnummer } from '@/lib/api/v1/mask-personnummer' import { decryptPersonnummer } from '@/lib/salary/personnummer' +import { JAMKNING_ORDER, touchesJamkning, validateJamkning, type JamkningFields } from '@/lib/salary/jamkning-rules' const EmploymentType = z.enum(['employee', 'company_owner', 'board_member']) const SalaryType = z.enum(['monthly', 'hourly']) @@ -341,46 +342,25 @@ export const PATCH = withApiV1<{ params: Promise<{ companyId: string; id: string }) } - // Merged-state jämkning check (same pattern as växa-stöd): a non-null - // percentage needs a start date, but the schema can only see the body. - // Also validate merged date ordering when only one of the dates is - // updated. Setting jamkning_percentage to null clears the beslut and - // skips these checks. Only run when the PATCH touches a jamkning field: - // a legacy row with inconsistent jamkning_* state must not block - // unrelated updates (fixing it requires touching those very fields). - const jamkningTouched = - 'jamkning_percentage' in updates || - 'jamkning_valid_from' in updates || - 'jamkning_valid_to' in updates - if (jamkningTouched) { - const mergedJamkningPct = - 'jamkning_percentage' in updates - ? (updates.jamkning_percentage as number | null) - : ((existing as Record).jamkning_percentage as number | null) - const mergedJamkningFrom = - 'jamkning_valid_from' in updates - ? (updates.jamkning_valid_from as string | null) - : ((existing as Record).jamkning_valid_from as string | null) - const mergedJamkningTo = - 'jamkning_valid_to' in updates - ? (updates.jamkning_valid_to as string | null) - : ((existing as Record).jamkning_valid_to as string | null) - if (mergedJamkningPct !== null && mergedJamkningPct !== undefined && !mergedJamkningFrom) { + // Merged-state jämkning check through the shared validator (same pattern + // as växa-stöd): a non-null percentage needs both dates, but the schema + // can only see the body. Setting jamkning_percentage to null clears the + // beslut and skips these checks. Only run when the PATCH touches a + // jamkning field: a legacy row with inconsistent jamkning_* state must + // not block unrelated updates (fixing it requires touching those very + // fields). #2058 + if (touchesJamkning(updates)) { + const mergedJamkning = { ...(existing as Record), ...updates } as JamkningFields + const [issue] = validateJamkning(mergedJamkning) + if (issue) { return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, { requestId: ctx.requestId, details: { - field: 'jamkning_valid_from', + field: issue.field, message: - 'Jämkningens startdatum måste anges när jämkningsprocent sätts. Skicka även `jamkning_valid_from` i samma PATCH.', - }, - }) - } - if (mergedJamkningFrom && mergedJamkningTo && mergedJamkningTo < mergedJamkningFrom) { - return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, { - requestId: ctx.requestId, - details: { - field: 'jamkning_valid_to', - message: 'Jämkningens slutdatum måste vara efter startdatumet.', + issue.message === JAMKNING_ORDER + ? `${issue.message}.` + : `${issue.message}. Skicka även \`${issue.field}\` i samma PATCH.`, }, }) } diff --git a/app/api/v1/companies/[companyId]/employees/__tests__/route.test.ts b/app/api/v1/companies/[companyId]/employees/__tests__/route.test.ts index baa84bed..184519be 100644 --- a/app/api/v1/companies/[companyId]/employees/__tests__/route.test.ts +++ b/app/api/v1/companies/[companyId]/employees/__tests__/route.test.ts @@ -353,6 +353,60 @@ describe('POST /api/v1/companies/:companyId/employees', () => { expect(JSON.stringify(body)).not.toContain(SAMPLE_PERSONNUMMER) }) + it('rejects a jämkning percentage without an end date (#2058)', async () => { + // The engine applies a beslut only when BOTH dates are set; the API used + // to accept this shape and store an inert beslut. + 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, + jamkning_percentage: 15, + jamkning_valid_from: '2026-01-01', + }), + }), + companyParams(COMPANY_ID), + ) + + expect(res.status).toBe(400) + const body = await res.json() + expect(body.error.code).toBe('VALIDATION_ERROR') + expect(JSON.stringify(body.error)).toContain('jamkning_valid_to') + }) + + it('accepts a complete jämkning beslut on create', 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, + jamkning_percentage: 15, + jamkning_valid_from: '2026-01-01', + jamkning_valid_to: '2026-12-31', + }), + }), + companyParams(COMPANY_ID), + ) + + expect(res.status).toBe(201) + }) + it('returns 409 EMPLOYEE_DUPLICATE_PERSONNUMMER on 23505 (and does not echo the personnummer)', async () => { mockServiceClient.mockReturnValue( makeFlexibleSupabase({ @@ -799,6 +853,87 @@ describe('PATCH /api/v1/companies/:companyId/employees/:id', () => { expect(body.error.details.field).toBe('jamkning_valid_from') }) + it('rejects a jämkning percentage without an end date (merged state, #2058)', async () => { + // Percentage + start date only: the engine would never apply it, so the + // route must refuse rather than store an inert beslut. + 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}`, { + method: 'PATCH', + body: JSON.stringify({ jamkning_percentage: 15, jamkning_valid_from: '2026-01-01' }), + }), + 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('jamkning_valid_to') + expect(body.error.details.message).toContain('slutdatum') + }) + + it('rejects clearing only the end date of a stored beslut', async () => { + const withJamkning = { + ...SAMPLE_EMPLOYEE, + jamkning_percentage: 15, + jamkning_valid_from: '2026-01-01', + jamkning_valid_to: '2026-12-31', + } + mockServiceClient.mockReturnValue( + makeFlexibleSupabase({ + company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null }, + employees: { data: withJamkning, 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({ jamkning_valid_to: null }), + }), + detailParams(COMPANY_ID, EMPLOYEE_ID), + ) + + expect(res.status).toBe(400) + const body = await res.json() + expect(body.error.details.field).toBe('jamkning_valid_to') + }) + + it('leaves a legacy row without valid_to editable in unrelated ways (touched gate)', async () => { + const legacy = { + ...SAMPLE_EMPLOYEE, + jamkning_percentage: 15, + jamkning_valid_from: '2026-01-01', + jamkning_valid_to: null, + } + const updated = { ...legacy, monthly_salary: 38000 } + mockServiceClient.mockReturnValue( + makeFlexibleSupabase({ + company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null }, + employees: [{ data: legacy, 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) + }) + it('rejects jamkning_valid_to before jamkning_valid_from', async () => { mockServiceClient.mockReturnValue( makeFlexibleSupabase({ diff --git a/components/salary/EmployeeTaxCard.tsx b/components/salary/EmployeeTaxCard.tsx index 7335a75d..25305c18 100644 --- a/components/salary/EmployeeTaxCard.tsx +++ b/components/salary/EmployeeTaxCard.tsx @@ -313,10 +313,11 @@ export default function EmployeeTaxCard({ percentage for a bounded period. The engine only applies it when BOTH dates are set, so both are required as soon as the user edits the beslut (native `required`: both hosts render this - inside a
). A seeded beslut is never blocked on: the API - keeps valid_to optional, so a row stored that way must stay - editable elsewhere. The table fields stay visible above: they - apply again once the beslut expires. */} + inside a ). A seeded beslut is never blocked on: rows + stored before #2058 may still lack valid_to (every write path + now requires it), and such a row must stay editable elsewhere. + The table fields stay visible above: they apply again once the + beslut expires. */}