4f939ebb21
* 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>
102 lines
3.7 KiB
TypeScript
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',
|
|
])
|
|
})
|
|
})
|