fix(salary): apply percent brackets for monthly incomes above 80 000 kr (#1510)
* fix(salary): apply percent brackets for monthly incomes above 80 000 kr
Skatteverket's monthly tax tables switch from fixed krona amounts to
percent-of-income rows above 80 000 kr/month. The lookup only loaded the
krona ("30B") rows and clamped higher incomes to the last bracket,
under-withholding every salary above 80 000 kr (e.g. 100 000 kr, tabell
31 kolumn 1: 25 294 kr instead of 35 000 kr).
- fetch both 30B and 30% sections from the Skatteverket API; treat a
missing section as API failure so the bundled fallback wins over
incomplete data
- TaxTableRate is a discriminated union; percent brackets withhold
percent of the whole monthly income, ore dropped per SFF 2011:1261
22 kap. 1 (oretal bortfaller)
- fallback generator parses %-rows too; regenerated with 1 232 percent
rows and a guard that every table carries both sections
- keep the old clamp only as a warn-logging last resort
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(salary): fail loudly on incomplete tax table data (review findings)
Address CodeRabbit and Swedish accounting review findings on #1510:
- lookupTaxAmount throws TaxTableUnavailableError when loaded brackets
contain a gap instead of silently withholding 0
- a failed or empty pagination page fails the whole API fetch so the
bundled fallback serves complete data
- kolumn values are parsed strictly (decimal-aware, comma accepted);
malformed values fail the fetch instead of becoming 0 kr / 0 %
- importer rejects malformed column values instead of emitting 0
(regenerated fallback is byte-identical)
- close the bracket gap in the calculation-engine test fixture
- clarify the ore-truncation citation and use an absolute date in
DECISIONS.md
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(salary): validate income boundaries in tax table parsers
Round-2 CodeRabbit finding on #1510: income boundaries were still parsed
with parseInt, which accepts "100abc" and turns garbage into 0 or an
open-ended bracket. Both the importer and the API loader now require
digits-only boundaries; an empty upper bound is legal only on percent
rows (the open-ended top row). Malformed API data fails the fetch so the
bundled fallback runs; malformed TXT data fails the import. Regenerated
fallback is byte-identical.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
e45218bcc6
commit
614b7e60b9
@@ -7,19 +7,20 @@
|
||||
* Output: lib/salary/tax-tables-fallback.ts
|
||||
*
|
||||
* Record format (49 chars per line):
|
||||
* chars 0-4 (width 5): prefix : "30B29" = monthly/belopp, table 29
|
||||
* chars 0-4 (width 5): prefix : "30B29" = monthly/belopp, table 29;
|
||||
* "30%29" = monthly/percent, table 29
|
||||
* chars 5-11 (width 7): income_from
|
||||
* chars 12-18 (width 7): income_to
|
||||
* chars 19-23 (width 5): column 1 tax amount (SEK)
|
||||
* chars 12-18 (width 7): income_to (blank on the open-ended top %-row)
|
||||
* chars 19-23 (width 5): column 1 (SEK on B-rows, percent on %-rows)
|
||||
* chars 24-28 (width 5): column 2
|
||||
* chars 29-33 (width 5): column 3
|
||||
* chars 34-38 (width 5): column 4
|
||||
* chars 39-43 (width 5): column 5
|
||||
* chars 44-48 (width 5): column 6
|
||||
*
|
||||
* We import only B-rows (absolute amounts). %-rows (percentage-based, used for
|
||||
* incomes above the highest B-row bracket) are skipped: matches the behavior
|
||||
* of the Skatteverket API path which also fetches only B-rows.
|
||||
* Both B-rows (absolute amounts, incomes up to 80 000 kr/month) and %-rows
|
||||
* (percent of the whole income, above 80 000 kr/month) are imported. The
|
||||
* emitted tuple carries an isPercent flag as its last element.
|
||||
*
|
||||
* Usage:
|
||||
* npx tsx scripts/import-tax-tables.ts --year 2026
|
||||
@@ -28,7 +29,7 @@
|
||||
import { readFileSync, writeFileSync } from 'fs'
|
||||
import { resolve } from 'path'
|
||||
|
||||
type TaxRow = readonly [number, number, number, number, number, number, number, number]
|
||||
type TaxRow = readonly [number, number, number, number, number, number, number, number, number]
|
||||
|
||||
interface ParsedTable {
|
||||
tableNumber: number
|
||||
@@ -54,32 +55,54 @@ function parseLine(line: string): { table: number; row: TaxRow } | null {
|
||||
if (clean.length < 49) return null
|
||||
|
||||
const prefix = clean.slice(0, 5)
|
||||
// B-rows only (absolute amounts). Skip %-rows.
|
||||
if (prefix[2] !== 'B') return null
|
||||
// B-rows carry absolute SEK amounts, %-rows carry percentages for incomes
|
||||
// above the highest B-row bracket. Both are needed for correct withholding.
|
||||
if (prefix[2] !== 'B' && prefix[2] !== '%') return null
|
||||
const isPercent = prefix[2] === '%'
|
||||
|
||||
const tableStr = prefix.slice(3, 5)
|
||||
const table = parseInt(tableStr, 10)
|
||||
if (!Number.isInteger(table)) return null
|
||||
|
||||
const parseField = (start: number, width: number): number => {
|
||||
const raw = clean.slice(start, start + width).trim()
|
||||
if (raw === '') return 0
|
||||
const n = parseInt(raw, 10)
|
||||
return Number.isFinite(n) ? n : 0
|
||||
// Column values must be well-formed whole numbers. A malformed value falling
|
||||
// back to 0 would bake 0 kr / 0 % withholding into the emitted fallback data,
|
||||
// so fail the import instead.
|
||||
const parseColumn = (start: number): number => {
|
||||
const raw = clean.slice(start, start + 5).trim()
|
||||
if (!/^\d+$/.test(raw)) {
|
||||
throw new Error(`Malformed column value "${raw}" in line: ${clean}`)
|
||||
}
|
||||
return parseInt(raw, 10)
|
||||
}
|
||||
|
||||
const incomeFrom = parseField(5, 7)
|
||||
const incomeTo = parseField(12, 7)
|
||||
const c1 = parseField(19, 5)
|
||||
const c2 = parseField(24, 5)
|
||||
const c3 = parseField(29, 5)
|
||||
const c4 = parseField(34, 5)
|
||||
const c5 = parseField(39, 5)
|
||||
const c6 = parseField(44, 5)
|
||||
// Income boundaries get the same digits-only rule: parseInt would accept
|
||||
// "100abc" and turn other garbage into 0, silently corrupting bracket
|
||||
// ranges. A blank income_to is legal only on the open-ended top %-row
|
||||
// (emitted as 0, mapped to the open-ended sentinel by the loader).
|
||||
const parseIncome = (start: number, allowBlank: boolean): number => {
|
||||
const raw = clean.slice(start, start + 7).trim()
|
||||
if (raw === '') {
|
||||
if (!allowBlank) throw new Error(`Missing income boundary in line: ${clean}`)
|
||||
return 0
|
||||
}
|
||||
if (!/^\d+$/.test(raw)) {
|
||||
throw new Error(`Malformed income boundary "${raw}" in line: ${clean}`)
|
||||
}
|
||||
return parseInt(raw, 10)
|
||||
}
|
||||
|
||||
const incomeFrom = parseIncome(5, false)
|
||||
const incomeTo = parseIncome(12, isPercent)
|
||||
const c1 = parseColumn(19)
|
||||
const c2 = parseColumn(24)
|
||||
const c3 = parseColumn(29)
|
||||
const c4 = parseColumn(34)
|
||||
const c5 = parseColumn(39)
|
||||
const c6 = parseColumn(44)
|
||||
|
||||
return {
|
||||
table,
|
||||
row: [incomeFrom, incomeTo, c1, c2, c3, c4, c5, c6] as const,
|
||||
row: [incomeFrom, incomeTo, c1, c2, c3, c4, c5, c6, isPercent ? 1 : 0] as const,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -137,9 +160,15 @@ function emitModule(year: number, tables: ParsedTable[]): string {
|
||||
* Rows: ${totalRows} across tables ${tableNumbers}
|
||||
*/
|
||||
|
||||
/** [incomeFrom, incomeTo, col1, col2, col3, col4, col5, col6] */
|
||||
/**
|
||||
* [incomeFrom, incomeTo, col1, col2, col3, col4, col5, col6, isPercent]
|
||||
*
|
||||
* isPercent 0: columns are SEK amounts (incomes up to 80 000 kr/month).
|
||||
* isPercent 1: columns are percent of the whole monthly income (above
|
||||
* 80 000 kr/month). incomeTo 0 marks the open-ended top row.
|
||||
*/
|
||||
export type FallbackTaxRow = readonly [
|
||||
number, number, number, number, number, number, number, number,
|
||||
number, number, number, number, number, number, number, number, number,
|
||||
]
|
||||
|
||||
/** Tables keyed by municipal tax rate number (29-42). */
|
||||
@@ -166,11 +195,21 @@ function main() {
|
||||
const tables = parseFile(inputPath)
|
||||
|
||||
if (tables.length === 0) {
|
||||
throw new Error('No B-rows parsed: check input file format')
|
||||
throw new Error('No rows parsed: check input file format')
|
||||
}
|
||||
|
||||
// Every table must have both sections: a B-only table would clamp high
|
||||
// incomes to the last krona bracket and silently under-withhold.
|
||||
for (const t of tables) {
|
||||
const percentRows = t.rows.filter(r => r[8] === 1).length
|
||||
if (percentRows === 0 || percentRows === t.rows.length) {
|
||||
throw new Error(`Table ${t.tableNumber}: expected both B-rows and %-rows, got ${percentRows}/${t.rows.length} percent rows`)
|
||||
}
|
||||
}
|
||||
|
||||
const totalRows = tables.reduce((sum, t) => sum + t.rows.length, 0)
|
||||
console.log(`Parsed ${tables.length} tables (${tables.map(t => t.tableNumber).join(', ')}), ${totalRows} B-rows total`)
|
||||
const percentTotal = tables.reduce((sum, t) => sum + t.rows.filter(r => r[8] === 1).length, 0)
|
||||
console.log(`Parsed ${tables.length} tables (${tables.map(t => t.tableNumber).join(', ')}), ${totalRows} rows total (${percentTotal} percent rows)`)
|
||||
|
||||
const moduleSource = emitModule(year, tables)
|
||||
writeFileSync(outputPath, moduleSource, 'utf-8')
|
||||
|
||||
Reference in New Issue
Block a user