fix(payroll): expose jämkning percentage and validity on the employee tax form (#1988)

* fix(payroll): expose jämkning percentage and validity on the employee tax form (#1913)

An employee with a Skatteverket jämkning decision could not have the
adjusted withholding percentage set anywhere in the app: model, API and
engine supported jamkning_percentage / jamkning_valid_from /
jamkning_valid_to end to end, but EmployeeTaxCard never exposed them.

- EmployeeTaxCard: percentage input plus required from/to dates in the
  A-skatt branch; null (= clear the beslut) when emptied or when no
  table applies, mirroring tax_table_number. Both dates are required
  because isJamkningValid only applies a beslut when both are set.
- Edit page: PATCH body sends the three fields as explicit values
  (guarded on the card having reported), card initial seeded from the
  employee, read-only Jämkning row in the tax section.
- NewEmployeeDialog: initial tax state and POST body carry the fields.
- Legacy PATCH /api/salary/employees/[id]: merged-state jämkning check
  (start date required, dates ordered), same rule and messages as v1
  and employee-commands, gated on the PATCH touching a jamkning key.
- lib/api/schemas.ts: truthful comment on the engine's both-dates gate.
- i18n: salary_employee.tax_jamkning_* in sv and en.
- Tests on the legacy PATCH route (400 x4, 200 x3) and the POST route.

The engine is deliberately untouched; the API/MCP contract (valid_to
optional) stays as is, follow-up filed in the PR body.

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

* fix(payroll): jämkning keys reach the employee PATCH only when visible and edited (#1913)

Review findings on #1988: the card reported null for the three jämkning
fields whenever its inputs were hidden (sidoinkomst, F-skatt, FA-skatt,
ej verifierad) and the edit page forwarded those nulls, so toggling
sidoinkomst or fixing a phone number on an FA-skatt employee silently
wiped a stored beslut (which the engine still applies for FA-skatt).
The two date inputs were also natively required whenever a percentage
was present, so a beslut stored via the API/MCP without valid_to
(allowed by the schema) blocked the whole form on unrelated edits.

- lib/salary/jamkning-patch.ts (new): isJamkningEditable() and
  jamkningPatch(); the keys are spread into the PATCH body with explicit
  values (null = clear) only when the inputs were visible and edited,
  otherwise omitted like every other sparse field.
- EmployeeTaxCard: jamkning_touched flag on EmployeeTaxValue, set by the
  three handlers; required on both dates gated on it; non-blocking hint
  (tax_jamkning_incomplete_hint, sv + en) on a seeded beslut missing a
  date.
- Edit page spreads jamkningPatch(tax); NewEmployeeDialog initial state
  carries the flag.
- Tests: lib/salary/__tests__/jamkning-patch.test.ts (keys omitted for
  sidoinkomst / f_skatt / fa_skatt / not_verified / untouched seeded row,
  explicit nulls when cleared, spread shape).
- DECISIONS.md: one line.

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

* test(payroll): type the insert mock's payload so the typecheck ratchet accepts the jamkning tests

vi.fn(() => ...) infers an empty parameter tuple, so insert.mock.calls[0][0]
failed TS2493 under the new check:types gate (#1980) on CI. Declaring the
payload parameter keeps the assertions and makes the tuple indexable.

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

---------

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-08-27 22:24:16 +02:00
committed by GitHub
co-authored by Claude Fable 5 Jakob Wennberg
parent 175bb8bd92
commit 4f939ebb21
12 changed files with 557 additions and 5 deletions
@@ -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<string, unknown>) {
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<string, unknown> }>(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<string, unknown> }>(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' })
})
})
+23
View File
@@ -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 })
}
@@ -132,7 +132,7 @@ describe('POST /api/salary/employees', () => {
function supabaseWithInsert(returned: Record<string, unknown>) {
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<string, unknown>) => ({ 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<string, unknown>) {
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,
})
})
})