fix(salary): skatteavdrag rounding trio (whole kronor, ,50 table pick, import prefix guard) (#1582)
* fix(salary): state percentage skatteavdrag in whole kronor (SFF 22 kap. 1 §) calculateJamkningTax and calculateSidoinkomstTax returned öre-precision amounts; skatteavdrag is stated in whole kronor with öretal dropped (SFF 2011:1261 22 kap. 1 §), the same rule taxForRate already applies to percent brackets. The two inline flat-30% branches in calculation-engine.ts (unverified F-skatt, no-table fallback) had the same defect and now route through calculateSidoinkomstTax. Computed in integer öre and hundredths of a percent: flooring the raw float product loses a whole krona when float noise lands an exact result just below an integer (1000 * 0.007 === 6.999999999999999). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(salary): pick the lower tax table at exactly ,50 per Skatteverket rule Math.round sent a total municipal rate of 32,50 to table 33; Skatteverket's rule is that a fractional part of at most 50 öre picks the lower table and 51 öre or more the higher. Compared in hundredths so float noise cannot decide the boundary. Latent today (no kommun sits exactly on ,50 for 2026) but the code now matches the comment above it, which already stated the correct rule. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(salary): reject two-week rows in the monthly tax table import parseLine only checked position 3 for B/%, so a two-week table row (14B29) would silently merge into the monthly fallback data if the wrong Skatteverket file were used as input. The day-count prefix must now be 30; a 14-row throws loudly. main() is guarded behind a direct-execution check (same pattern as generate-crontabs.ts) so parseLine is importable by tests. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(salary): truncate toward zero, not floor, in whole-krona skatteavdrag Skeptic refutation: taxable income can go negative when deductions exceed pay, and Math.floor rounds negatives away from zero, so a payslip 1 öre negative would book a full krona of negative withholding (calculateSidoinkomstTax(-0.01) gave -1 instead of -0). Öretal bortfaller truncates toward zero: Math.trunc, with -0 normalized to 0. 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
ce6efdb3dc
commit
9ad3908ed0
@@ -0,0 +1,40 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { parseLine } from '../import-tax-tables'
|
||||
|
||||
// Build a fixed-width SKV 434 record: prefix(5) income_from(7) income_to(7)
|
||||
// then six 5-char columns = 49 chars.
|
||||
function record(prefix: string, from: string, to: string, cols: string[] = ['100', '200', '300', '400', '500', '600']): string {
|
||||
return prefix + from.padStart(7) + to.padStart(7) + cols.map((c) => c.padStart(5)).join('')
|
||||
}
|
||||
|
||||
describe('parseLine', () => {
|
||||
it('parses a monthly B-row', () => {
|
||||
const parsed = parseLine(record('30B29', '20001', '20200'))
|
||||
expect(parsed).toEqual({
|
||||
table: 29,
|
||||
row: [20001, 20200, 100, 200, 300, 400, 500, 600, 0],
|
||||
})
|
||||
})
|
||||
|
||||
it('parses a monthly percent row with a blank open-ended upper bound', () => {
|
||||
const parsed = parseLine(record('30%29', '80001', '', ['32', '30', '32', '28', '33', '34']))
|
||||
expect(parsed).toEqual({
|
||||
table: 29,
|
||||
row: [80001, 0, 32, 30, 32, 28, 33, 34, 1],
|
||||
})
|
||||
})
|
||||
|
||||
it('throws loudly on a two-week 14B row instead of merging it into monthly data', () => {
|
||||
expect(() => parseLine(record('14B29', '20001', '20200'))).toThrow(/Two-week table row/)
|
||||
expect(() => parseLine(record('14%29', '80001', ''))).toThrow(/Two-week table row/)
|
||||
})
|
||||
|
||||
it('skips rows with an unknown day-count prefix', () => {
|
||||
expect(parseLine(record('90B29', '20001', '20200'))).toBeNull()
|
||||
})
|
||||
|
||||
it('skips non-table lines (headers, short lines)', () => {
|
||||
expect(parseLine('RUBRIK ALLMANNA TABELLER 2026')).toBeNull()
|
||||
expect(parseLine('')).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -28,6 +28,7 @@
|
||||
|
||||
import { readFileSync, writeFileSync } from 'fs'
|
||||
import { resolve } from 'path'
|
||||
import { fileURLToPath } from 'url'
|
||||
|
||||
type TaxRow = readonly [number, number, number, number, number, number, number, number, number]
|
||||
|
||||
@@ -49,7 +50,7 @@ function parseArgs(): { year: number } {
|
||||
return { year }
|
||||
}
|
||||
|
||||
function parseLine(line: string): { table: number; row: TaxRow } | null {
|
||||
export 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
|
||||
@@ -58,6 +59,17 @@ function parseLine(line: string): { table: number; row: TaxRow } | 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
|
||||
// The day-count prefix must be "30" (monthly). Skatteverket also publishes
|
||||
// two-week tables whose rows differ only in this prefix ("14B29" vs "30B29"):
|
||||
// a two-week row must fail the import loudly, never merge silently into the
|
||||
// monthly fallback data.
|
||||
const dayCount = prefix.slice(0, 2)
|
||||
if (dayCount === '14') {
|
||||
throw new Error(
|
||||
`Two-week table row (prefix "${prefix}") in monthly import: wrong source file? Line: ${clean}`
|
||||
)
|
||||
}
|
||||
if (dayCount !== '30') return null
|
||||
const isPercent = prefix[2] === '%'
|
||||
|
||||
const tableStr = prefix.slice(3, 5)
|
||||
@@ -216,4 +228,8 @@ function main() {
|
||||
console.log(`Wrote ${outputPath} (${moduleSource.length.toLocaleString()} bytes)`)
|
||||
}
|
||||
|
||||
main()
|
||||
// Run only when executed directly (npx tsx scripts/import-tax-tables.ts), not
|
||||
// when parseLine is imported by tests. Same pattern as generate-crontabs.ts.
|
||||
if (process.argv[1] && fileURLToPath(import.meta.url) === resolve(process.argv[1])) {
|
||||
main()
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user