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:
co-authored by
Claude Fable 5
Jakob Wennberg
parent
175bb8bd92
commit
4f939ebb21
@@ -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' })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user