Files
accounted/scripts/import-tax-tables.ts
T
Jakob Wennberg ec27228a8e style: remove em/en dashes repo-wide, add CLAUDE.md rule against them (#890)
Em dashes (—) and en dashes (–) had spread across comments, docs, tests,
and a few UI strings, reading as AI-generated boilerplate rather than
house style. Replaced each with punctuation matching its context: colon
for explanatory clauses, comma for asides, plain hyphen for numeric/legal
ranges (e.g. "21-23§"), "to"/"till" for date ranges, parentheses for
paired-dash asides. messages/en.json and messages/sv.json were fixed by
hand together to keep sv/en in sync.

Left untouched where the dash is the functional subject rather than
decorative punctuation: date-range-parser.ts's separator regex,
charset-repair.ts's CP1252 byte-mapping table (and its test), the SIE
encoding mojibake docs, generic-csv.ts's minus-sign normalizer, the
agent system-prompt files that already instruct against em dashes, and
a golden iXBRL test fixture compared byte-for-byte.

Also fixes two bugs surfaced along the way: an off-by-one in
ApiKeysPanel's scope-label split (a leftover from an earlier partial
pass), and a charset-repair test that had lost the literal en-dash it
exists to verify.

Regenerated the agent atom seed migration (skills:generate) since 27
SKILL.md files changed. Added a CLAUDE.md rule against em/en dashes,
with an explicit carve-out for the functional-dash cases above.

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-04 15:58:06 +02:00

181 lines
5.5 KiB
TypeScript

/**
* Parse Skatteverket SKV 434 monthly tax tables (fixed-width TXT) and emit a
* TypeScript module used as an emergency fallback when Skatteverket's open-data
* API is unavailable.
*
* Input: data/tax-tables/{year}/allmanna-tabeller-manad.txt
* 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 5-11 (width 7): income_from
* chars 12-18 (width 7): income_to
* chars 19-23 (width 5): column 1 tax amount (SEK)
* 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.
*
* Usage:
* npx tsx scripts/import-tax-tables.ts --year 2026
*/
import { readFileSync, writeFileSync } from 'fs'
import { resolve } from 'path'
type TaxRow = readonly [number, number, number, number, number, number, number, number]
interface ParsedTable {
tableNumber: number
rows: TaxRow[]
}
function parseArgs(): { year: number } {
const args = process.argv.slice(2)
const yearIdx = args.indexOf('--year')
if (yearIdx === -1 || !args[yearIdx + 1]) {
throw new Error('Missing --year argument')
}
const year = parseInt(args[yearIdx + 1], 10)
if (!Number.isInteger(year) || year < 2000 || year > 2100) {
throw new Error(`Invalid year: ${args[yearIdx + 1]}`)
}
return { year }
}
function parseLine(line: string): { table: number; row: TaxRow } | null {
// Strip BOM if present on the first line
const clean = line.replace(/^\uFEFF/, '')
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
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
}
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)
return {
table,
row: [incomeFrom, incomeTo, c1, c2, c3, c4, c5, c6] as const,
}
}
function parseFile(path: string): ParsedTable[] {
const content = readFileSync(path, 'utf-8')
const byTable = new Map<number, TaxRow[]>()
for (const rawLine of content.split(/\r?\n/)) {
if (!rawLine.trim()) continue
const parsed = parseLine(rawLine)
if (!parsed) continue
const existing = byTable.get(parsed.table)
if (existing) {
existing.push(parsed.row)
} else {
byTable.set(parsed.table, [parsed.row])
}
}
const tables = Array.from(byTable.entries())
.map(([tableNumber, rows]) => ({
tableNumber,
rows: rows.sort((a, b) => a[0] - b[0]),
}))
.sort((a, b) => a.tableNumber - b.tableNumber)
return tables
}
function formatRow(row: TaxRow): string {
return `[${row.join(', ')}]`
}
function emitModule(year: number, tables: ParsedTable[]): string {
const totalRows = tables.reduce((sum, t) => sum + t.rows.length, 0)
const tableNumbers = tables.map(t => t.tableNumber).join(', ')
const entries = tables
.map(t => {
const rows = t.rows.map(formatRow).join(',\n ')
return ` ${t.tableNumber}: [\n ${rows},\n ]`
})
.join(',\n')
return `/**
* AUTO-GENERATED: do not edit by hand.
*
* Source: data/tax-tables/${year}/allmanna-tabeller-manad.txt (Skatteverket SKV 434)
* Generator: scripts/import-tax-tables.ts
*
* Emergency fallback for lib/salary/tax-tables.ts when the Skatteverket
* open-data API is unreachable. Do not use as the primary source: the API
* is authoritative.
*
* Rows: ${totalRows} across tables ${tableNumbers}
*/
/** [incomeFrom, incomeTo, col1, col2, col3, col4, col5, col6] */
export type FallbackTaxRow = readonly [
number, number, number, number, number, number, number, number,
]
/** Tables keyed by municipal tax rate number (29-42). */
export type FallbackTaxYear = Readonly<Record<number, readonly FallbackTaxRow[]>>
export const FALLBACK_TAX_TABLES_${year}: FallbackTaxYear = {
${entries},
}
export const FALLBACK_TAX_TABLES: Readonly<Record<number, FallbackTaxYear>> = {
${year}: FALLBACK_TAX_TABLES_${year},
}
export const FALLBACK_TAX_TABLE_YEARS: ReadonlySet<number> = new Set([${year}])
`
}
function main() {
const { year } = parseArgs()
const inputPath = resolve(process.cwd(), `data/tax-tables/${year}/allmanna-tabeller-manad.txt`)
const outputPath = resolve(process.cwd(), 'lib/salary/tax-tables-fallback.ts')
console.log(`Reading ${inputPath}`)
const tables = parseFile(inputPath)
if (tables.length === 0) {
throw new Error('No B-rows parsed: check input file format')
}
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 module = emitModule(year, tables)
writeFileSync(outputPath, module, 'utf-8')
console.log(`Wrote ${outputPath} (${module.length.toLocaleString()} bytes)`)
}
main()