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
+140 -1
View File
@@ -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}
</div>
</div>
{/* 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 <form>). 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. */}
<div className="space-y-2">
<Label htmlFor="jamkning_percentage">
<InfoTooltip content={t('tax_jamkning_tooltip')}>
{t('tax_jamkning_label')}
</InfoTooltip>
</Label>
<Input
id="jamkning_percentage"
type="number"
min="0"
max="100"
step="any"
inputMode="decimal"
value={jamkningPct}
onChange={(e) => {
setJamkningPct(e.target.value)
setJamkningTouched(true)
}}
disabled={disabled}
/>
<p className="text-xs text-muted-foreground">{t('tax_jamkning_hint')}</p>
</div>
{jamkningValue !== null && (
<div className="space-y-2">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="jamkning_valid_from">
{t('tax_jamkning_valid_from')}
<RequiredMark />
</Label>
<Input
id="jamkning_valid_from"
type="date"
value={jamkningFrom}
onChange={(e) => {
setJamkningFrom(e.target.value)
setJamkningTouched(true)
}}
required={jamkningTouched}
disabled={disabled}
/>
</div>
<div className="space-y-2">
<Label htmlFor="jamkning_valid_to">
{t('tax_jamkning_valid_to')}
<RequiredMark />
</Label>
<Input
id="jamkning_valid_to"
type="date"
value={jamkningTo}
min={jamkningFrom || undefined}
onChange={(e) => {
setJamkningTo(e.target.value)
setJamkningTouched(true)
}}
required={jamkningTouched}
disabled={disabled}
/>
</div>
</div>
{jamkningIncomplete ? (
<p className="text-xs text-attn">{t('tax_jamkning_incomplete_hint')}</p>
) : (
<p className="text-xs text-muted-foreground">{t('tax_jamkning_dates_hint')}</p>
)}
</div>
)}
</>
) : (
<p className="rounded-lg border border-dashed border-input px-3 py-3 text-sm text-muted-foreground">
+7
View File
@@ -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,