Files
accounted/scripts/list-incomplete-jamkning.ts
T
Mattsson 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

105 lines
4.0 KiB
TypeScript

/**
* Read-only lister for #2058: employees whose jämkningsbeslut is stored but
* can never be applied because jamkning_valid_to is null.
*
* WHY: until #2058 the v1 API and MCP accepted jamkning_percentage +
* jamkning_valid_from without jamkning_valid_to. isJamkningValid in
* lib/salary/calculation-engine.ts applies a beslut only when BOTH dates are
* set, so these rows fall back to the tax table on every payslip and AGI
* while the stored beslut says otherwise. Every write path now rejects the
* shape; this script finds the rows that were stored before that.
*
* WHAT IT DOES: lists the rows per company with employee id, percentage and
* start date. No names or other PII are selected: the employee page shows the
* name once you open the id. It writes NOTHING. The repair is a per-company
* decision (set an end date, or clear the beslut): either changes the next
* payslip, so it is made by a human through the employee page or the API,
* not by this script.
*
* Usage:
* npx tsx scripts/list-incomplete-jamkning.ts
*
* Reads NEXT_PUBLIC_SUPABASE_URL + SUPABASE_SERVICE_ROLE_KEY from .env.local.
* Treat .env.local as pointing at PRODUCTION (read-only here, still: be sure
* which project you are looking at).
*/
import { createClient } from '@supabase/supabase-js'
import { config as dotenv } from 'dotenv'
import { resolve } from 'node:path'
dotenv({ path: resolve(process.cwd(), '.env.local') })
const SUPABASE_URL = process.env.NEXT_PUBLIC_SUPABASE_URL
const SERVICE_KEY = process.env.SUPABASE_SERVICE_ROLE_KEY
if (!SUPABASE_URL || !SERVICE_KEY) {
console.error('Missing NEXT_PUBLIC_SUPABASE_URL or SUPABASE_SERVICE_ROLE_KEY in .env.local')
process.exit(1)
}
const supabase = createClient(SUPABASE_URL, SERVICE_KEY, { auth: { persistSession: false } })
interface Row {
id: string
company_id: string
is_active: boolean
jamkning_percentage: number
jamkning_valid_from: string | null
jamkning_valid_to: string | null
}
async function main() {
const PAGE = 1000
const rows: Row[] = []
for (let from = 0; ; from += PAGE) {
const { data, error } = await supabase
.from('employees')
.select('id, company_id, is_active, jamkning_percentage, jamkning_valid_from, jamkning_valid_to')
.not('jamkning_percentage', 'is', null)
.is('jamkning_valid_to', null)
.order('company_id')
.order('id')
.range(from, from + PAGE - 1)
if (error) {
console.error('employees query failed:', error.message)
process.exit(1)
}
rows.push(...((data ?? []) as Row[]))
if (!data || data.length < PAGE) break
}
if (rows.length === 0) {
console.log('No employees with a jämkning percentage but no valid_to. Nothing to decide.')
return
}
const companyIds = [...new Set(rows.map((r) => r.company_id))]
const { data: companies, error: companiesError } = await supabase
.from('companies')
.select('id, name')
.in('id', companyIds)
if (companiesError) {
console.error('companies query failed:', companiesError.message)
process.exit(1)
}
const companyName = new Map((companies ?? []).map((c) => [c.id as string, c.name as string]))
console.log(`${rows.length} employee row(s) across ${companyIds.length} company(ies) with an inert jämkningsbeslut:\n`)
for (const companyId of companyIds) {
console.log(`${companyName.get(companyId) ?? '(unknown company)'} ${companyId}`)
for (const r of rows.filter((x) => x.company_id === companyId)) {
const state = r.is_active ? 'active ' : 'inactive'
const from = r.jamkning_valid_from ?? '(no start date)'
console.log(` ${r.id} ${state} ${r.jamkning_percentage} % from ${from} to (null)`)
}
console.log('')
}
console.log('Decide per company: set jamkning_valid_to (a beslut normally runs to 31 December of')
console.log('the from-year) or clear jamkning_percentage. Either changes the next payslip; the')
console.log('engine ignores these rows until then. Inactive employees can usually be cleared.')
}
main().catch((err) => {
console.error(err)
process.exit(1)
})