diff --git a/DECISIONS.md b/DECISIONS.md index 223ad823..bc265d48 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -1298,6 +1298,8 @@ One line per decision: `[YYYY-MM-DD] : `. Appended by agents and [2026-08-27] New `unlinked_documents` category on the Accounted://attention resource, backed by lib/documents/unlinked-documents.ts. The whole design is the mime ALLOW-LIST, and the naive predicate is a trap: "current version, no journal_entry_id, referenced by none of the eight linking tables" returns 15 806 rows on prod, of which 11 309 are application/json and every single one is named psd2-response__pN.json, the archived PSD2 bank-API responses the integration stores as evidence of each fetch. Those are unlinked BY DESIGN; surfacing them would hand an agent 11 309 items of work it must not action, which is worse than showing nothing. Measured 2026-08-27: application/json was 11 309 of 11 309 psd2, and pdf/png/jpeg/heic were 0 of 4 495, so the split is clean. Chose an allow-list of underlag-shaped mime types over excluding known-bad filenames, so a future machine-payload format (XML, CSV, an audit bundle) stays out by default instead of leaking until someone notices. Real remaining surface: 4 497 documents across 210 companies, median 3 per company, 481 in the preceding week, and NOT agent-specific (2 374 upload_source=api vs 1 623 file_upload from the web UI). Two-pass fetch mirroring fetchPurchasesWithoutUnderlag: indexed column filter, then eight reference lookups that run only when candidates exist, so the common case costs one query. Scan cap is 300 and is set by URL LENGTH, not table size: each candidate id is echoed through eight .in(column, ids) lookups at ~38 bytes per UUID, and a cap in the thousands would exceed the gateway limit, fail the lookups, and the "claims nothing" fallback would turn every candidate into a false positive. A failing lookup is deliberately treated as "claims nothing" (can only ADD a row) rather than dropping the category, so one misbehaving table cannot hide real work. UnlinkedDocument is a type alias not an interface: the resource assigns it into samples: Record[] and an interface has no implicit index signature; vitest does not typecheck so this only fails in npm run build. [2026-08-27] NOT fixed, and recorded so the next person does not act on an inflated number: the agent-facing readers (resources/attention.ts, resources/recent-activity.ts) still test booked-ness with a raw journal_entry_id null check instead of the canonical isTransactionBooked, which misses the bulk-book (transaction_voucher_links) and multi-allocation (invoice_payments / supplier_invoice_payments) cases. Real scale measured on prod 2026-08-27: 4 transactions, in 1 company, out of 567 column-filtered unbooked, all 4 via transaction_voucher_links and 0 via either payments table. Worth fixing as hygiene, but it is a 4-row problem and doing it properly in attention.ts needs the same two-pass treatment plus a decision about count semantics for a tenant with thousands of unbooked rows, so it does not belong bolted onto this change. [2026-08-27] Klarmarkera (markPeriodClosedExternally) gets an undo, reopenExternallyClosedPeriod, allowed only while the closed state still comes from klarmarkera (closed_externally set, no closing entry): that close was a person's control decision without a bokslutsverifikat, so reversing it strands nothing, whereas a closePeriod close keeps its closing entry and stays irreversible here. The reopen clears the lock too, because the reason to reopen is to change the period's contents (Forsslund Systems 2026-08-27: five imported years klarmarkerade, then the prior-year SIE turned out wrong; replace refused the closed year, unlock refused the closed state, no way back). Audit_log row plus period.unlocked event; the MCP staged-op surface (lock/unlock) does not get a reopen op yet, follow-up. +[2026-08-27] Jämkning UI (EmployeeTaxCard) requires both jamkning_valid_from and jamkning_valid_to while the API keeps valid_to optional: isJamkningValid (calculation-engine.ts) never applies a beslut without an end date, so the form enforces what the engine needs; the API/MCP contract is left untouched for compatibility (follow-up: align engine or API, founder call). The legacy PATCH /api/salary/employees/[id] gets the same merged start-date/ordering check as v1 so the UI gets a 400 instead of a silently inert beslut. +[2026-08-27] Jämkning keys reach the employee PATCH body only when the inputs were visible (A-skatt, not sidoinkomst) and edited this session (jamkningPatch + EmployeeTaxValue.jamkning_touched), otherwise omitted like every other sparse field; native required on the two dates is likewise gated on touched: the card reports null for hidden inputs and the route spreads the body, so forwarding those nulls wiped a stored beslut on a sidoinkomst toggle or an FA-skatt phone-number edit (the engine still applies it for FA-skatt), and an API/MCP row without valid_to blocked native form validation on unrelated edits. Untouched-omit over touched-null: the route's own touched-gate already assumes absent keys leave the row alone. [2026-08-27] #1898 line-pattern 3740 rounding leg now lands opposite the business side when the ratios over-allocate (negative diff) and stays on the business side when they under-allocate (positive diff), in buildMultiLineMappingResult AND computeProposalLines in the same change, superseding the 2026-08-25 note that kept business-side placement for both signs: that note assumed the imbalance never reached a user, but since #1894 the prefilled form carries it and the confirm fails with JournalEntryNotBalancedError. Reachable with normalized ratios too (50/50 on 100.03 kr rounds to 50.02 + 50.02), not only with un-normalized patterns. The flip is applied AFTER the mirror on both sides (the engine's businessSide is already mirrored via side(), the proposal's raw side is mirrored first), no new Math.round(x*100)/100 was written (the existing rounded diff is reused, naiveOreRound baseline unchanged), positive-diff bookings are byte-identical to before, and parity is executable: proposal-lines.test.ts sweeps 0.01..50.00 kr against the engine. [2026-08-27] /mfa/enroll leave() always hard-navigates (window.location.assign) instead of router.push+router.refresh: enrolment raises the session to aal2 which middleware only re-evaluates on a document request, and the push/refresh pair raced leaving the user on the QR screen with 2FA already on (#1948); same pattern the file already used for /api/ destinations and /mfa/verify uses for its invite and route-handler paths. The E2E regression cover (spectest/tests/mfa.ts) lands with the spectest suite separately. [2026-08-27] Brand domains gate signup server-side (brands.signup_mode + brand_signup_allowlist), not in the register page: the browser used to call supabase.auth.signUp directly, so any client-side host check would be cosmetic. Email signup moved to POST /api/auth/signup on ALL hosts (byte-identical GoTrue call for open hosts); BankID gates in /bankid/complete; Google gates via the dashboard layout's brand-domain bounce (the account exists after OAuth, but gets no branded experience). Company invites bypass the allowlist because the invite is the authorization. Allowlisted signups' companies attach to the brand's byrå team via create_company_for_brand_signup (allowlist entry = the byrå's standing WL-15 authorization, recorded by an owner/admin); without the attach, WL-01 would home the company on the canonical domain, invisible on the very domain the user signed up on. Rejected a Supabase before-user-created hook: it does not reliably see the originating host and adds dashboard config coupling. diff --git a/app/(dashboard)/salary/employees/[id]/page.tsx b/app/(dashboard)/salary/employees/[id]/page.tsx index e52f10d5..43712f6f 100644 --- a/app/(dashboard)/salary/employees/[id]/page.tsx +++ b/app/(dashboard)/salary/employees/[id]/page.tsx @@ -34,6 +34,7 @@ import type { EmployeeMasked } from '@/types' import { EmployeeBenefitsPanel } from '@/components/salary/EmployeeBenefitsPanel' import { OpeningBalancesPanel } from '@/components/salary/OpeningBalancesPanel' import EmployeeTaxCard, { type EmployeeTaxValue } from '@/components/salary/EmployeeTaxCard' +import { jamkningPatch } from '@/lib/salary/jamkning-patch' import LineDimensionFields from '@/components/dimensions/LineDimensionFields' const EMPLOYMENT_LABEL_KEYS: Record = { @@ -175,6 +176,14 @@ export default function EmployeeDetailPage({ params }: { params: Promise<{ id: s tax_table_number: tax?.tax_table_number ?? undefined, tax_column: tax?.tax_column ?? undefined, tax_municipality: tax?.tax_municipality || undefined, + // Jämkning: the three keys are sent with explicit values (null = clear + // the beslut; the route spreads the body so null reaches the UPDATE) + // only when the inputs were visible (A-skatt, not sidoinkomst) and the + // user edited them. Hidden or untouched, the keys are omitted like any + // other sparse field, so toggling sidoinkomst / FA-skatt or fixing a + // phone number never wipes a stored beslut. Guarded on `tax` so a card + // that has not reported yet sends nothing. + ...(tax ? jamkningPatch(tax) : {}), email: form.get('email') as string || undefined, phone: form.get('phone') as string || undefined, address_line1: form.get('address_line1') as string || undefined, @@ -428,6 +437,24 @@ export default function EmployeeDetailPage({ params }: { params: Promise<{ id: s {employee.tax_column} + + {employee.jamkning_percentage != null ? ( + <> + {employee.jamkning_percentage} % + {employee.jamkning_valid_from && employee.jamkning_valid_to && ( + + {' · '} + {t('tax_jamkning_detail_period', { + from: formatDate(employee.jamkning_valid_from), + to: formatDate(employee.jamkning_valid_to), + })} + + )} + + ) : ( + + )} + @@ -651,6 +678,9 @@ export default function EmployeeDetailPage({ params }: { params: Promise<{ id: s tax_table_number: employee.tax_table_number ?? null, tax_column: employee.tax_column ?? 1, tax_municipality: employee.tax_municipality || '', + jamkning_percentage: employee.jamkning_percentage ?? null, + jamkning_valid_from: employee.jamkning_valid_from ?? null, + jamkning_valid_to: employee.jamkning_valid_to ?? null, }} /> {fSkattVerifiedLabel && ( diff --git a/app/api/salary/employees/[id]/__tests__/route.test.ts b/app/api/salary/employees/[id]/__tests__/route.test.ts index 85c3312e..f4911e78 100644 --- a/app/api/salary/employees/[id]/__tests__/route.test.ts +++ b/app/api/salary/employees/[id]/__tests__/route.test.ts @@ -210,3 +210,130 @@ describe('personnummer contract on /api/salary/employees/[id]', () => { expect(JSON.stringify(body)).not.toContain('"0008"') }) }) + +/** + * Jämkning (Skatteverket beslut om ändrad beräkning av skatteavdrag) on the + * legacy PATCH route, which is the one the employee edit page calls. #1913: + * the merged-state check mirrors the v1 route so a percentage without a + * start date gets a 400 instead of being stored as a silently inert beslut, + * while explicit nulls must survive to the UPDATE (null = clear the beslut). + */ +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_ORDER = 'Jämkningens slutdatum måste vara efter startdatumet' + + function useRow(existing: Record) { + const mock = employeeSupabase(existing) + requireAuthMock.mockResolvedValue({ user: { id: 'user-1' }, supabase: mock.supabase }) + return mock.captured + } + + beforeEach(() => { + vi.clearAllMocks() + reset() + requireWriteMock.mockResolvedValue({ ok: true }) + }) + + it('400 when a percentage is set with an explicit null start date', 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: null, jamkning_valid_to: null }), + params, + ) + const { status, body } = await parseJsonResponse<{ error: string }>(response) + + expect(status).toBe(400) + expect(body.error).toContain(JAMKNING_START_REQUIRED) + expect(captured.updates).toBeNull() + }) + + it('400 when only the percentage is sent and the stored row has no start date (sparse patch)', 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 }), params) + const { status, body } = await parseJsonResponse<{ error: string }>(response) + + expect(status).toBe(400) + expect(body.error).toContain(JAMKNING_START_REQUIRED) + expect(captured.updates).toBeNull() + }) + + it('400 when the end date precedes the start date within the body', async () => { + const captured = useRow({ ...EXISTING_ROW }) + + const response = await PATCH( + patchRequest({ jamkning_percentage: 20, jamkning_valid_from: '2026-06-01', jamkning_valid_to: '2026-01-31' }), + params, + ) + + expect(response.status).toBe(400) + expect(captured.updates).toBeNull() + }) + + it('400 when a new end date precedes the stored start date (merged ordering)', async () => { + const captured = useRow({ + ...EXISTING_ROW, + jamkning_percentage: 20, + jamkning_valid_from: '2026-06-01', + jamkning_valid_to: '2026-12-31', + }) + + const response = await PATCH(patchRequest({ jamkning_valid_to: '2026-01-31' }), params) + const { status, body } = await parseJsonResponse<{ error: string }>(response) + + expect(status).toBe(400) + expect(body.error).toContain(JAMKNING_ORDER) + expect(captured.updates).toBeNull() + }) + + it('200 and writes percentage + both dates (happy path)', 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: '2026-12-31' }), + params, + ) + const { status, body } = await parseJsonResponse<{ data: Record }>(response) + + expect(status).toBe(200) + expect(captured.updates).toEqual({ + jamkning_percentage: 20, + jamkning_valid_from: '2026-01-01', + jamkning_valid_to: '2026-12-31', + }) + expect(body.data.jamkning_percentage).toBe(20) + }) + + it('200 and clears a stored beslut with explicit nulls (nulls must survive, not be dropped)', async () => { + const captured = useRow({ + ...EXISTING_ROW, + jamkning_percentage: 20, + jamkning_valid_from: '2026-01-01', + jamkning_valid_to: '2026-12-31', + }) + + const response = await PATCH( + patchRequest({ jamkning_percentage: null, jamkning_valid_from: null, jamkning_valid_to: null }), + params, + ) + const { status, body } = await parseJsonResponse<{ data: Record }>(response) + + expect(status).toBe(200) + expect(captured.updates).toEqual({ + jamkning_percentage: null, + jamkning_valid_from: null, + jamkning_valid_to: null, + }) + expect(body.data.jamkning_percentage).toBeNull() + }) + + it('200 on an unrelated edit to a legacy row with inconsistent jamkning state (touched-gate)', async () => { + const captured = useRow({ ...EXISTING_ROW, jamkning_percentage: 15, jamkning_valid_from: null, jamkning_valid_to: null }) + + const response = await PATCH(patchRequest({ first_name: 'Ny' }), params) + + expect(response.status).toBe(200) + expect(captured.updates).toEqual({ first_name: 'Ny' }) + }) +}) diff --git a/app/api/salary/employees/[id]/route.ts b/app/api/salary/employees/[id]/route.ts index 2d68ae6c..1cf5b76d 100644 --- a/app/api/salary/employees/[id]/route.ts +++ b/app/api/salary/employees/[id]/route.ts @@ -72,6 +72,29 @@ 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') + } + } 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 bf104494..39a97f42 100644 --- a/app/api/salary/employees/__tests__/route.test.ts +++ b/app/api/salary/employees/__tests__/route.test.ts @@ -132,7 +132,7 @@ describe('POST /api/salary/employees', () => { function supabaseWithInsert(returned: Record) { const single = vi.fn(() => Promise.resolve({ data: returned, error: null })) const select = vi.fn(() => ({ single })) - const insert = vi.fn(() => ({ select })) + const insert = vi.fn((_payload: Record) => ({ select })) return { supabase: { from: vi.fn(() => ({ insert })) }, insert } } @@ -175,4 +175,61 @@ describe('POST /api/salary/employees', () => { expect(JSON.stringify(body)).not.toContain(NEW_PNR) expect(JSON.stringify(body)).not.toContain('9802') }) + + // #1913: the NewEmployeeDialog sends the jämkning beslut on create; pin that + // the insert carries it, and that omitting it inserts nulls (no beslut). + const CREATE_BASE = { + first_name: 'Test', + last_name: 'Testsson', + personnummer: NEW_PNR, + employment_start: '2026-01-01', + monthly_salary: 30000, + tax_table_number: 34, + tax_municipality: 'Stockholm', + } + + function postRequest(body: Record) { + return new Request('https://x.test/api/salary/employees', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }) + } + + it('inserts the jämkning percentage and validity when provided', 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', + jamkning_valid_to: '2026-12-31', + }), + params, + ) + + expect(res.status).toBe(201) + expect(insert).toHaveBeenCalledTimes(1) + expect(insert.mock.calls[0][0]).toMatchObject({ + jamkning_percentage: 12.5, + jamkning_valid_from: '2026-01-01', + jamkning_valid_to: '2026-12-31', + }) + }) + + 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) + + const res = await POST(postRequest(CREATE_BASE), params) + + expect(res.status).toBe(201) + expect(insert.mock.calls[0][0]).toMatchObject({ + jamkning_percentage: null, + jamkning_valid_from: null, + jamkning_valid_to: null, + }) + }) }) diff --git a/components/salary/EmployeeTaxCard.tsx b/components/salary/EmployeeTaxCard.tsx index 06231fa9..7335a75d 100644 --- a/components/salary/EmployeeTaxCard.tsx +++ b/components/salary/EmployeeTaxCard.tsx @@ -16,6 +16,19 @@ export interface EmployeeTaxValue { tax_table_number: number | null tax_column: number tax_municipality: string + /** + * Jämkning (Skatteverket beslut om ändrad beräkning av skatteavdrag). All + * three are null when there is no beslut; a null percentage clears it. + */ + jamkning_percentage: number | null + jamkning_valid_from: string | null + jamkning_valid_to: string | null + /** + * True once the user edited any of the three jämkning inputs this session. + * Hosts use it (via jamkningPatch) to leave a stored beslut alone on + * unrelated edits instead of re-sending or wiping it. + */ + jamkning_touched: boolean } interface EmployeeTaxCardProps { @@ -43,6 +56,9 @@ function RequiredMark() { * an opaque skattetabell (29-42) and kolumn (1-6), it derives both from data we * already have: the folkbokföringskommun fills the tax table, and the * personnummer fills the column. Manual overrides remain for edge cases. + * A jämkning beslut (fixed percentage with a validity period) can be entered + * alongside the table: the engine uses the percentage while the beslut is + * valid and falls back to the table outside that window. */ export default function EmployeeTaxCard({ personnummer, @@ -65,9 +81,38 @@ export default function EmployeeTaxCard({ // Editing an existing employee: respect their saved column. New employee: // let the personnummer drive it until the user picks one. const [columnTouched, setColumnTouched] = useState(initial?.tax_column != null) + // Jämkning is kept as raw input strings so a half-typed value ("12.") does + // not snap; the parsed number is derived below. Not rounded: decimals like + // 12.5 are legal (the API caps at 0..100). + const [jamkningPct, setJamkningPct] = useState( + initial?.jamkning_percentage != null ? String(initial.jamkning_percentage) : '' + ) + const [jamkningFrom, setJamkningFrom] = useState(initial?.jamkning_valid_from ?? '') + const [jamkningTo, setJamkningTo] = useState(initial?.jamkning_valid_to ?? '') + // Set by the three jämkning handlers. Until then the seeded beslut is shown + // as-is: a row stored through the API/MCP without an end date (allowed by + // the schema) must not block the form's native validation on an unrelated + // edit, and must not be re-sent either (see jamkningPatch). + const [jamkningTouched, setJamkningTouched] = useState(false) const requiresTable = fSkatt === 'a_skatt' && !sido + const jamkningValue = (() => { + const n = parseFloat(jamkningPct) + return Number.isFinite(n) ? n : null + })() + // The jämkning inputs are only rendered for A-skatt without sidoinkomst, so + // like tax_table_number they are reported as null otherwise. Hosts must not + // treat that null as "clear the beslut": the edit page routes the value + // through jamkningPatch, which omits the keys whenever the inputs were + // hidden or untouched, so a stored beslut survives toggling the status. + const hasJamkning = requiresTable && jamkningValue !== null + // Seeded beslut missing a date: the engine will not apply it. Shown as a + // non-blocking hint until the user edits the fields, at which point the + // native `required` on both dates takes over. + const jamkningIncomplete = + !jamkningTouched && jamkningValue !== null && (!jamkningFrom || !jamkningTo) + const derivedColumn = useMemo( () => deriveTaxColumn(personnummer, incomeYear), [personnummer, incomeYear] @@ -92,8 +137,24 @@ export default function EmployeeTaxCard({ tax_table_number: requiresTable ? tableNumber : null, tax_column: effectiveColumn, tax_municipality: municipality.trim(), + jamkning_percentage: hasJamkning ? jamkningValue : null, + jamkning_valid_from: hasJamkning ? jamkningFrom || null : null, + jamkning_valid_to: hasJamkning ? jamkningTo || null : null, + jamkning_touched: jamkningTouched, }) - }, [fSkatt, sido, tableNumber, effectiveColumn, municipality, requiresTable]) + }, [ + fSkatt, + sido, + tableNumber, + effectiveColumn, + municipality, + requiresTable, + hasJamkning, + jamkningValue, + jamkningFrom, + jamkningTo, + jamkningTouched, + ]) const body = ( <> @@ -247,6 +308,84 @@ export default function EmployeeTaxCard({ ) : null} + + {/* Jämkning: a Skatteverket beslut overrides the table with a fixed + 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. */} +
+ + { + setJamkningPct(e.target.value) + setJamkningTouched(true) + }} + disabled={disabled} + /> +

{t('tax_jamkning_hint')}

+
+ + {jamkningValue !== null && ( +
+
+
+ + { + setJamkningFrom(e.target.value) + setJamkningTouched(true) + }} + required={jamkningTouched} + disabled={disabled} + /> +
+
+ + { + setJamkningTo(e.target.value) + setJamkningTouched(true) + }} + required={jamkningTouched} + disabled={disabled} + /> +
+
+ {jamkningIncomplete ? ( +

{t('tax_jamkning_incomplete_hint')}

+ ) : ( +

{t('tax_jamkning_dates_hint')}

+ )} +
+ )} ) : (

diff --git a/components/salary/NewEmployeeDialog.tsx b/components/salary/NewEmployeeDialog.tsx index f06dd579..945ec25a 100644 --- a/components/salary/NewEmployeeDialog.tsx +++ b/components/salary/NewEmployeeDialog.tsx @@ -126,6 +126,10 @@ function NewEmployeeForm({ onCreated, onCancel }: { onCreated: () => void; onCan tax_table_number: null, tax_column: 1, tax_municipality: '', + jamkning_percentage: null, + jamkning_valid_from: null, + jamkning_valid_to: null, + jamkning_touched: false, }) function setDimension(dimNo: string, code: string | null) { @@ -173,6 +177,9 @@ function NewEmployeeForm({ onCreated, onCancel }: { onCreated: () => void; onCan tax_table_number: tax.tax_table_number ?? undefined, tax_column: tax.tax_column, tax_municipality: tax.tax_municipality || undefined, + jamkning_percentage: tax.jamkning_percentage ?? undefined, + jamkning_valid_from: tax.jamkning_valid_from ?? undefined, + jamkning_valid_to: tax.jamkning_valid_to ?? undefined, email: form.get('email') as string || undefined, phone: form.get('phone') as string || undefined, address_line1: form.get('address_line1') as string || undefined, diff --git a/lib/api/schemas.ts b/lib/api/schemas.ts index f98c4bd7..91fddedf 100644 --- a/lib/api/schemas.ts +++ b/lib/api/schemas.ts @@ -2873,9 +2873,10 @@ export const CreateEmployeeSchema = EmployeeSchemaBase.superRefine((data, ctx) = }) } - // Jämkning: a percentage without a start date is meaningless (the engine - // gates on jamkning_valid_from <= payment_date). End date is optional - // (beslut often run until year-end implicitly). + // Jämkning: a percentage without a start date is meaningless. Note that the + // engine (isJamkningValid in lib/salary/calculation-engine.ts) applies the + // beslut only when BOTH dates are set; the API keeps valid_to optional for + // compatibility and the UI requires it. if ( data.jamkning_percentage !== null && data.jamkning_percentage !== undefined && diff --git a/lib/salary/__tests__/jamkning-patch.test.ts b/lib/salary/__tests__/jamkning-patch.test.ts new file mode 100644 index 00000000..c0b8f032 --- /dev/null +++ b/lib/salary/__tests__/jamkning-patch.test.ts @@ -0,0 +1,101 @@ +import { describe, it, expect } from 'vitest' +import { isJamkningEditable, jamkningPatch, type JamkningFormState } from '../jamkning-patch' + +function state(overrides: Partial = {}): JamkningFormState { + return { + f_skatt_status: 'a_skatt', + is_sidoinkomst: false, + jamkning_percentage: 20, + jamkning_valid_from: '2026-01-01', + jamkning_valid_to: '2026-12-31', + jamkning_touched: true, + ...overrides, + } +} + +describe('isJamkningEditable', () => { + it('is true only for A-skatt without sidoinkomst', () => { + expect(isJamkningEditable({ f_skatt_status: 'a_skatt', is_sidoinkomst: false })).toBe(true) + expect(isJamkningEditable({ f_skatt_status: 'a_skatt', is_sidoinkomst: true })).toBe(false) + expect(isJamkningEditable({ f_skatt_status: 'f_skatt', is_sidoinkomst: false })).toBe(false) + expect(isJamkningEditable({ f_skatt_status: 'fa_skatt', is_sidoinkomst: false })).toBe(false) + expect(isJamkningEditable({ f_skatt_status: 'not_verified', is_sidoinkomst: false })).toBe(false) + }) +}) + +describe('jamkningPatch', () => { + it('sends the three keys with explicit values when the fields were visible and edited', () => { + expect(jamkningPatch(state())).toEqual({ + jamkning_percentage: 20, + jamkning_valid_from: '2026-01-01', + jamkning_valid_to: '2026-12-31', + }) + }) + + it('sends explicit nulls when the user cleared the percentage (null = clear the beslut)', () => { + const patch = jamkningPatch( + state({ jamkning_percentage: null, jamkning_valid_from: null, jamkning_valid_to: null }) + ) + expect(patch).toEqual({ + jamkning_percentage: null, + jamkning_valid_from: null, + jamkning_valid_to: null, + }) + expect(Object.keys(patch)).toEqual(['jamkning_percentage', 'jamkning_valid_from', 'jamkning_valid_to']) + }) + + it('omits the keys entirely when sidoinkomst is ticked, even though the card reports nulls', () => { + // The card hides the inputs and reports null/null/null in this branch; the + // stored beslut must survive so it applies again when sidoinkomst is unticked. + const patch = jamkningPatch( + state({ + is_sidoinkomst: true, + jamkning_percentage: null, + jamkning_valid_from: null, + jamkning_valid_to: null, + }) + ) + expect(patch).toEqual({}) + expect('jamkning_percentage' in patch).toBe(false) + expect('jamkning_valid_from' in patch).toBe(false) + expect('jamkning_valid_to' in patch).toBe(false) + }) + + it.each(['f_skatt', 'fa_skatt', 'not_verified'])( + 'omits the keys for %s so an unrelated edit never wipes a stored beslut', + (status) => { + const patch = jamkningPatch( + state({ + f_skatt_status: status, + jamkning_percentage: null, + jamkning_valid_from: null, + jamkning_valid_to: null, + }) + ) + expect(patch).toEqual({}) + expect('jamkning_percentage' in patch).toBe(false) + } + ) + + it('omits the keys when the fields were visible but never touched (seeded from the row)', () => { + // A row stored via the v1 API / MCP with valid_to = null is seeded into the + // form as-is; an edit elsewhere on the page must not re-send (or alter) it. + const patch = jamkningPatch( + state({ jamkning_touched: false, jamkning_valid_to: null }) + ) + expect(patch).toEqual({}) + expect('jamkning_valid_to' in patch).toBe(false) + }) + + it('spreads cleanly into a sparse body', () => { + const hidden = { first_name: 'A', ...jamkningPatch(state({ is_sidoinkomst: true })) } + expect(Object.keys(hidden)).toEqual(['first_name']) + const edited = { first_name: 'A', ...jamkningPatch(state()) } + expect(Object.keys(edited)).toEqual([ + 'first_name', + 'jamkning_percentage', + 'jamkning_valid_from', + 'jamkning_valid_to', + ]) + }) +}) diff --git a/lib/salary/jamkning-patch.ts b/lib/salary/jamkning-patch.ts new file mode 100644 index 00000000..4039d5fe --- /dev/null +++ b/lib/salary/jamkning-patch.ts @@ -0,0 +1,49 @@ +/** + * Decides whether an employee edit form sends the three jämkning keys + * (jamkning_percentage / jamkning_valid_from / jamkning_valid_to) in its + * PATCH body. + * + * The PATCH routes spread the body into the UPDATE, so an explicit null + * clears the stored beslut. That is what we want when the user emptied the + * percentage on screen, and exactly what we must avoid when the fields were + * hidden (F-skatt, FA-skatt, ej verifierad, sidoinkomst) or simply not + * touched: a beslut entered from a Skatteverket paper decision, possibly via + * the API or MCP, must survive an unrelated edit. The engine still applies a + * stored beslut for FA-skatt, and for A-skatt it applies again as soon as + * sidoinkomst is unticked, so the row is left alone in every hidden case. + */ + +export interface JamkningFormState { + f_skatt_status: string + is_sidoinkomst: boolean + jamkning_percentage: number | null + jamkning_valid_from: string | null + jamkning_valid_to: string | null + /** True once the user edited any of the three jämkning inputs this session. */ + jamkning_touched: boolean +} + +export interface JamkningPatch { + jamkning_percentage: number | null + jamkning_valid_from: string | null + jamkning_valid_to: string | null +} + +/** The jämkning inputs are rendered only in this branch of the tax card. */ +export function isJamkningEditable(state: Pick): boolean { + return state.f_skatt_status === 'a_skatt' && !state.is_sidoinkomst +} + +/** + * Returns the three keys (explicit values, null = clear) when the fields were + * both visible and edited; otherwise an empty object so the sparse patch + * leaves the stored beslut untouched. Spread the result into the body. + */ +export function jamkningPatch(state: JamkningFormState): JamkningPatch | Record { + if (!isJamkningEditable(state) || !state.jamkning_touched) return {} + return { + jamkning_percentage: state.jamkning_percentage, + jamkning_valid_from: state.jamkning_valid_from, + jamkning_valid_to: state.jamkning_valid_to, + } +} diff --git a/messages/en.json b/messages/en.json index 42ad9d7e..e2d1d83e 100644 --- a/messages/en.json +++ b/messages/en.json @@ -7523,6 +7523,14 @@ "tax_column_senior_warning": "The person has turned 66 — select the column manually (salary = column 3, pension = column 2).", "tax_no_table_sidoinkomst": "Side income (sidoinkomst): a flat 30 % tax is withheld — no tax table is needed.", "tax_no_table_f_skatt": "With F-skatt or FA-skatt the person handles their own tax — no tax is withheld and no tax table is needed.", + "tax_jamkning_label": "Adjusted withholding (jämkning, Skatteverket decision)", + "tax_jamkning_tooltip": "Enter the percentage from the Skatteverket decision on adjusted withholding. While it is valid, tax is withheld at this percentage instead of according to the tax table.", + "tax_jamkning_hint": "Leave empty if there is no decision. The table applies again once the decision expires.", + "tax_jamkning_valid_from": "Valid from", + "tax_jamkning_valid_to": "Valid to", + "tax_jamkning_dates_hint": "Both dates are required: the decision is only applied within its validity period.", + "tax_jamkning_incomplete_hint": "The decision is missing a start or end date and is not applied until both are set.", + "tax_jamkning_detail_period": "{from} to {to}", "benefits_title": "Benefits", "benefits_add": "Add benefit", "benefits_empty": "No benefits registered.", diff --git a/messages/sv.json b/messages/sv.json index aaaf4318..c16fa43e 100644 --- a/messages/sv.json +++ b/messages/sv.json @@ -7523,6 +7523,14 @@ "tax_column_senior_warning": "Personen har fyllt 66 år — välj kolumn manuellt (lön = kolumn 3, pension = kolumn 2).", "tax_no_table_sidoinkomst": "Sidoinkomst: ett fast skatteavdrag på 30 % görs — ingen skattetabell behövs.", "tax_no_table_f_skatt": "Med F-skatt eller FA-skatt sköter personen sin egen skatt — inget skatteavdrag görs och ingen skattetabell behövs.", + "tax_jamkning_label": "Jämkning (beslut från Skatteverket)", + "tax_jamkning_tooltip": "Ange procentsatsen i beslutet om ändrad beräkning av skatteavdrag. Under giltighetstiden dras skatt med denna procent i stället för enligt skattetabellen.", + "tax_jamkning_hint": "Lämna tomt om det inte finns något beslut. Tabellen gäller igen när beslutet löper ut.", + "tax_jamkning_valid_from": "Gäller från", + "tax_jamkning_valid_to": "Gäller till", + "tax_jamkning_dates_hint": "Båda datumen krävs: beslutet tillämpas bara inom giltighetstiden.", + "tax_jamkning_incomplete_hint": "Beslutet saknar start- eller slutdatum och tillämpas inte förrän båda är angivna.", + "tax_jamkning_detail_period": "{from} till {to}", "benefits_title": "Förmåner", "benefits_add": "Lägg till förmån", "benefits_empty": "Inga förmåner registrerade.",