Files
accounted/components/salary/EmployeeTaxCard.tsx
T
MattssonandClaude Fable 5.1 b07efcafd4 fix(payroll): require jamkning valid_to on every write path (#2058) (#2240)
* fix(payroll): require jamkning valid_to on every write path (#2058)

A jamkningsbeslut saved through the v1 API or MCP with a percentage and a
start date but no end date was stored and returned 200, yet the engine
(isJamkningValid) never applies a beslut without both dates: the payslip
and the AGI carried the table tax while the caller believed the beslut
was live.

One shared validator (lib/salary/jamkning-rules.ts) now requires both
dates whenever a percentage is set and checks their ordering. Every write
path runs it: CreateEmployeeSchema and UpdateEmployeeSchema, the web POST
and PATCH routes, the v1 PATCH route (its private copy is deleted), the
MCP create and update executors in employee-commands, and the MCP update
tool preflights the merged row at staging time so the agent sees the
error before approval. The update paths keep the existing touched gate, so
legacy rows stored without valid_to stay editable in unrelated ways.

The MCP tool descriptions state that both dates are required for the
beslut to apply. scripts/list-incomplete-jamkning.ts lists the existing
rows (percentage set, valid_to null) per company, read-only; setting an
end date or clearing the beslut is decided per company since either
changes the next payslip.

Declined: defaulting valid_to to 31 December of the from-year. It matches
most beslut but silently changes withholding on rows that today do
nothing.

Closes #2058

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

* fix(payroll): keep the jamkning PR inside the type and tools/list budgets

CI on the first push failed on two ratchets this PR itself tripped:

- Typecheck ratchet: the three staging tests added here reused the
  untyped 'agent_chat' actor literal the file already carried, which
  raised that file's error count above its baseline. They now pass
  { type: 'user' }.
- tools/list payload budget: the first jamkning field descriptions on
  gnubok_create_employee and gnubok_update_employee pushed the projected
  catalog to 60 113 tokens against the 60 000 ceiling. The percentage
  fields keep a one-line "needs both dates or never applied" note; the
  date fields drop theirs.

Also acts on the compliance swarm's GDPR Art.32 note: the read-only
lister no longer selects employee names at all (the employee id is what
the per-company decision needs), so the script touches no PII.

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

* docs(mcp): say the jamkning percentage is rejected without both dates

CodeRabbit on #2240: "never applied" described the pre-fix engine
behaviour; the contract now is that a create or update with a percentage
and a missing date is rejected before staging. Same length, so the
tools/list payload budget is unchanged. The concurrency finding is
tracked in #2256 instead of this PR.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(mcp): keep tools/list under budget after proforma landed on main

After merging main (#2254 proforma fields) the projected tools/list
measured 60 010 tokens against the 60 000 ceiling with this PR's two
jamkning field notes. Per the budget test's own rule, demote a read tool
instead of bumping the ceiling: gnubok_list_arsredovisning_versions goes
search-only. Versions exist only once a report is rendered for signing
or filing, which is the same switched-off iXBRL path as its sibling
gnubok_get_arsredovisning_filing_status, already search-only since
2026-09-02. Still reachable via gnubok_call_tool.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-03 20:22:41 +02:00

414 lines
16 KiB
TypeScript

'use client'
import { useState, useEffect, useMemo, useRef } from 'react'
import { useTranslations } from 'next-intl'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { InfoTooltip } from '@/components/ui/info-tooltip'
import MunicipalityCombobox from './MunicipalityCombobox'
import { TAX_COLUMN_OPTIONS, deriveTaxColumn } from '@/lib/salary/tax-column'
export interface EmployeeTaxValue {
f_skatt_status: string
is_sidoinkomst: boolean
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 {
/** Live personnummer (full or masked): drives the column suggestion. */
personnummer: string
initial?: Partial<EmployeeTaxValue>
/** Income year the table/column applies to. Defaults to the current year. */
year?: number
disabled?: boolean
/**
* Render the fields without the Card chrome (no border/header), for hosts
* that lay their own section dividers around it (e.g. the compact
* NewEmployeeDialog). The edit page keeps the default boxed rendering.
*/
flat?: boolean
onChange: (value: EmployeeTaxValue) => void
}
function RequiredMark() {
return <span className="text-destructive ml-0.5">*</span>
}
/**
* The "Skatt" card on the employee form. Instead of asking the user to look up
* 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,
initial,
year,
disabled,
flat,
onChange,
}: EmployeeTaxCardProps) {
const t = useTranslations('salary_employee')
const incomeYear = year ?? new Date().getFullYear()
const [fSkatt, setFSkatt] = useState(initial?.f_skatt_status ?? 'a_skatt')
const [sido, setSido] = useState(initial?.is_sidoinkomst ?? false)
const [municipality, setMunicipality] = useState(initial?.tax_municipality ?? '')
const [tableNumber, setTableNumber] = useState<number | null>(initial?.tax_table_number ?? null)
const [rate, setRate] = useState<number | null>(null)
const [tableManual, setTableManual] = useState(false)
const [column, setColumn] = useState(initial?.tax_column ?? 1)
// 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]
)
const isSenior = personnummer.replace(/\D/g, '').length >= 8 && derivedColumn === null
// The effective column is the user's explicit choice once they've made one,
// otherwise the value suggested from the personnummer (falling back to 1).
// Derived in render: no setState-in-effect needed.
const effectiveColumn = columnTouched ? column : (derivedColumn ?? 1)
// Report the current value up. onChange via ref so an unstable parent callback
// doesn't retrigger the effect (deps are the primitive values only).
const onChangeRef = useRef(onChange)
useEffect(() => {
onChangeRef.current = onChange
})
useEffect(() => {
onChangeRef.current({
f_skatt_status: fSkatt,
is_sidoinkomst: sido,
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,
hasJamkning,
jamkningValue,
jamkningFrom,
jamkningTo,
jamkningTouched,
])
const body = (
<>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="f_skatt_status">
<InfoTooltip content={t('tax_form_tooltip')}>
{t('tax_form_label')}
</InfoTooltip>
</Label>
<Select value={fSkatt} onValueChange={setFSkatt} disabled={disabled}>
<SelectTrigger id="f_skatt_status">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="a_skatt">A-skatt</SelectItem>
<SelectItem value="f_skatt">F-skatt</SelectItem>
<SelectItem value="fa_skatt">FA-skatt</SelectItem>
<SelectItem value="not_verified">{t('tax_status_not_verified')}</SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex items-end pb-2">
<label className="flex items-center gap-2 text-sm">
<input
type="checkbox"
checked={sido}
onChange={(e) => setSido(e.target.checked)}
disabled={disabled}
className="rounded-sm border-border"
/>
<InfoTooltip content={t('tax_sidoinkomst_tooltip')}>
{t('tax_sidoinkomst_label')}
</InfoTooltip>
</label>
</div>
</div>
{requiresTable ? (
<>
<div className="space-y-2">
<Label htmlFor="tax_municipality">
<InfoTooltip content={t('tax_municipality_tooltip')}>
{t('tax_municipality_label')}
</InfoTooltip>
<RequiredMark />
</Label>
<MunicipalityCombobox
id="tax_municipality"
value={municipality}
year={incomeYear}
disabled={disabled}
onChange={(value) => {
setMunicipality(value)
// Clearing the field must clear the derived table/rate too:
// otherwise we'd report an empty kommun alongside a stale
// table number (an inconsistent pair). Manual entry keeps its
// own value.
if (!value && !tableManual) {
setTableNumber(null)
setRate(null)
}
}}
onSelect={(kommun, table, totalRate) => {
setMunicipality(kommun)
setRate(totalRate)
if (!tableManual) setTableNumber(table)
}}
/>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="tax_table_number">
<InfoTooltip content={t('tax_table_tooltip')}>
{t('tax_table_label')}
</InfoTooltip>
<RequiredMark />
</Label>
{tableManual ? (
<Input
id="tax_table_number"
type="number"
min="29"
max="42"
value={tableNumber ?? ''}
onChange={(e) => setTableNumber(parseInt(e.target.value) || null)}
disabled={disabled}
/>
) : tableNumber ? (
<div className="flex items-baseline gap-2 rounded-lg border border-input px-3 py-2">
<span className="font-sans text-xl tabular-nums">{tableNumber}</span>
{(municipality || rate != null) && (
<span className="text-xs text-muted-foreground">
{municipality}
{rate != null ? ` · ${rate.toLocaleString('sv-SE')} %` : ''}
</span>
)}
</div>
) : (
<p className="rounded-lg border border-dashed border-input px-3 py-2 text-sm text-muted-foreground">
{t('tax_table_pick_municipality')}
</p>
)}
{!disabled && (
<button
type="button"
onClick={() => setTableManual((v) => !v)}
className="text-xs text-primary hover:underline underline-offset-4"
>
{tableManual ? t('tax_table_use_municipality') : t('tax_table_enter_manually')}
</button>
)}
</div>
<div className="space-y-2">
<Label htmlFor="tax_column">
<InfoTooltip content={t('tax_column_tooltip')}>
{t('tax_column_label')}
</InfoTooltip>
</Label>
<Select
value={String(effectiveColumn)}
onValueChange={(v) => {
setColumn(parseInt(v))
setColumnTouched(true)
}}
disabled={disabled}
>
<SelectTrigger id="tax_column">
<SelectValue />
</SelectTrigger>
<SelectContent>
{TAX_COLUMN_OPTIONS.map((opt) => (
<SelectItem key={opt.value} value={String(opt.value)}>
{opt.value}. {opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
{!columnTouched && derivedColumn != null ? (
<p className="text-xs text-muted-foreground">
{t('tax_column_suggested_under_66')}
</p>
) : isSenior && !columnTouched ? (
<p className="text-xs text-attn">
{t('tax_column_senior_warning')}
</p>
) : 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: rows
stored before #2058 may still lack valid_to (every write path
now requires it), and such a row 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">
{sido
? t('tax_no_table_sidoinkomst')
: t('tax_no_table_f_skatt')}
</p>
)}
</>
)
if (flat) {
return <div className="space-y-4">{body}</div>
}
return (
<Card>
<CardHeader>
<CardTitle className="text-base">{t('tax_title')}</CardTitle>
</CardHeader>
<CardContent className="space-y-4">{body}</CardContent>
</Card>
)
}