Files
accounted/lib/salary/__tests__/jamkning-patch.test.ts
T
Jakob Wennberg 4f939ebb21 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>
2026-08-27 22:24:16 +02:00

102 lines
3.7 KiB
TypeScript

import { describe, it, expect } from 'vitest'
import { isJamkningEditable, jamkningPatch, type JamkningFormState } from '../jamkning-patch'
function state(overrides: Partial<JamkningFormState> = {}): 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',
])
})
})