diff --git a/DECISIONS.md b/DECISIONS.md index 6001a80b..92830716 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -903,6 +903,7 @@ One line per decision: `[YYYY-MM-DD] : `. Appended by agents and [2026-08-13] The book-route underlag fix landed as a pinned-document leg inside propagateUnderlagForBookedTransaction rather than the planned "extract categorize-core's propagation block into a shared helper": PR #1547 had already done that extraction overnight and wired /book and bulk-book to the shared helper, but the helper only walked matched inbox items, so a document pinned via transactions.document_id with no unconsumed inbox item (direct upload, or item consumed elsewhere) still booked to "Underlag saknas". Anchoring the pin inside the helper fixes /book, categorize, bulk-book and attach-after-book in one place; the pin is read fresh (not from the caller's pre-booking snapshot) so a concurrent attach still anchors, and the bulk-book RPC's own atomic doc-linking makes the leg a no-op there. [2026-08-13] Kontantmetoden cut-off is a year-end readiness blocker with a staged MCP remedy, not a warning or a lock-time gate: BFL 5 kap 2 § requires all unpaid receivables and liabilities at fiscal year end, and a lock-time failure would occur after executeYearEndClosing has already posted its immutable closing entry. The staged preview freezes all cut-off and day-one reversal lines; approval re-collects the reskontra and refuses drift or any existing full or partial marker before posting through the bookkeeping engine. [2026-08-13] v1 categorize/batch-categorize wire the shared underlag propagation after the CAS write rather than inlining anchoring logic, and the route tests mock the helper to assert wiring only (called once per booking the request owns; skipped on partial success and lost CAS races): the helper's own semantics (pin anchoring, never-steal, failure isolation) are unit-tested where they live, and duplicating them at route level is what let the v1 surface drift out of the #1560 fix in the first place. Salvaged from the closed duplicate PR #1559: the attach-after-bulk-book samlingsverifikat test. +[2026-08-13] Whole-krona skatteavdrag fix covers all four percentage paths, not just the plan's two exported functions: the inline flat-30% branches in calculation-engine.ts (fSkattStatus not_verified + no-table fallback) computed öre through r() under the same SFF 22 kap. 1 § rule, so leaving them would keep the defect alive in two live paths. Both now route through calculateSidoinkomstTax. [2026-08-13] Bank import: an explicit format choice that parses 0 rows falls back to auto-detect (info issue names both formats) instead of failing: an explicit bank pick must never underperform Automatisk identifiering, and the fallback result carries the detected format so external_ids equal the auto path. generic_csv is exempt: it is the manual column-mapping escape hatch whose default mapping legitimately parses 0 rows. [2026-08-13] BOM'd bank files decode with utf-8-sig semantics (BOM stripped at byte level, never re-included in the windows-1252 fallback), plus UTF-16 BOM support: kills the mojibake-prefix class that broke exact-match header detectors. [2026-08-13] franvaro audit trigger functions made SECURITY DEFINER (search_path pinned) instead of adding RLS policies on salary_absence_franvaro_audit: the audit table must stay client-unwritable, and an INSERT policy for authenticated would let clients forge audit rows. New DB_PERMISSION_DENIED (500) instead of reusing FORBIDDEN for 42501 in the absence path: an RLS denial of a trigger write is a server misconfig, not a user-permission failure. diff --git a/lib/salary/__tests__/tax-tables.test.ts b/lib/salary/__tests__/tax-tables.test.ts index f03c579b..58593b36 100644 --- a/lib/salary/__tests__/tax-tables.test.ts +++ b/lib/salary/__tests__/tax-tables.test.ts @@ -99,8 +99,27 @@ describe('calculateJamkningTax', () => { expect(calculateJamkningTax(40000, 20)).toBe(8000) }) - it('rounds to 2 decimal places', () => { - expect(calculateJamkningTax(33333, 15.5)).toBe(5166.62) + it('drops öre so the withheld amount is whole kronor (SFF 22 kap. 1 §)', () => { + // 33 333 × 15.5 % = 5 166.615 → 5 166, not 5 166.62 + expect(calculateJamkningTax(33333, 15.5)).toBe(5166) + // 41 999.99 × 25 % = 10 499.9975 → 10 499: öre are dropped, never rounded up + expect(calculateJamkningTax(41999.99, 25)).toBe(10499) + }) + + it('is immune to float noise on exact-krona results', () => { + // 1000 × 0.007 === 6.999999999999999 in floats: a naive Math.floor on the + // raw product would withhold 6 kr where the exact result is 7 kr + expect(calculateJamkningTax(1000, 0.7)).toBe(7) + expect(calculateJamkningTax(5000, 0.7)).toBe(35) + }) + + it('truncates toward zero for negative taxable income (skeptic refutation case)', () => { + // Deductions exceeding pay make taxable income negative; dropping öre + // truncates toward zero, so Math.floor (which would give -543 and -1 + // here) must not add an extra negative krona + expect(calculateJamkningTax(-3500.25, 15.5)).toBe(-542) + expect(calculateSidoinkomstTax(-100.01)).toBe(-30) + expect(calculateSidoinkomstTax(-0.01)).toBe(0) }) }) @@ -109,8 +128,15 @@ describe('calculateSidoinkomstTax', () => { expect(calculateSidoinkomstTax(40000)).toBe(12000) }) - it('rounds to 2 decimal places', () => { - expect(calculateSidoinkomstTax(33333)).toBe(9999.90) + it('drops öre so the withheld amount is whole kronor (SFF 22 kap. 1 §)', () => { + // 33 333 × 30 % = 9 999.90 → 9 999, not 9 999.90 + expect(calculateSidoinkomstTax(33333)).toBe(9999) + // 30 123.45 × 30 % = 9 037.035 → 9 037 + expect(calculateSidoinkomstTax(30123.45)).toBe(9037) + }) + + it('returns exact whole kronor when the product is a whole-krona amount', () => { + expect(calculateSidoinkomstTax(41300)).toBe(12390) }) }) @@ -355,6 +381,24 @@ describe('fetchKommunTaxRates', () => { expect(result.find((r) => r.kommun === 'Stockholm')!.tableNumber).toBe(31) // 30.62 → 31 }) + it('picks the lower table at exactly ,50 and the higher from ,51 (Skatteverket rule)', async () => { + const fetchSpy = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + resultCount: 3, + results: [row('LÅGKÖPING', '32.49'), row('MITTKÖPING', '32.50'), row('HÖGKÖPING', '32.51')], + }), + } as Response) + globalThis.fetch = fetchSpy + + const result = await fetchKommunTaxRates(2026) + + expect(result.find((r) => r.kommun === 'Lågköping')!.tableNumber).toBe(32) + // Skatteverket's own example: 32,50 belongs to table 32, not 33 + expect(result.find((r) => r.kommun === 'Mittköping')!.tableNumber).toBe(32) + expect(result.find((r) => r.kommun === 'Högköping')!.tableNumber).toBe(33) + }) + it('normalizes Skatteverket uppercase names to title case', async () => { const fetchSpy = vi.fn().mockResolvedValue({ ok: true, diff --git a/lib/salary/calculation-engine.ts b/lib/salary/calculation-engine.ts index 9d23d9e5..f86859d9 100644 --- a/lib/salary/calculation-engine.ts +++ b/lib/salary/calculation-engine.ts @@ -415,8 +415,8 @@ export function calculateSalary( output: 0, }) } else if (input.fSkattStatus === 'not_verified') { - // Unverified: flat 30% - taxWithheld = r(taxableIncome * 0.30) + // Unverified: flat 30%, whole kronor (SFF 22 kap. 1 §) + taxWithheld = calculateSidoinkomstTax(taxableIncome) steps.push({ label: 'Skatteavdrag (ej verifierad)', formula: 'skattegrundande inkomst × 30 %', @@ -451,8 +451,8 @@ export function calculateSalary( output: taxWithheld, }) } else { - // Fallback: flat 30% - taxWithheld = r(taxableIncome * 0.30) + // Fallback: flat 30%, whole kronor (SFF 22 kap. 1 §) + taxWithheld = calculateSidoinkomstTax(taxableIncome) steps.push({ label: 'Skatteavdrag (30 % schablon)', formula: 'skattegrundande inkomst × 30 %', diff --git a/lib/salary/tax-tables.ts b/lib/salary/tax-tables.ts index 7910a91d..578372de 100644 --- a/lib/salary/tax-tables.ts +++ b/lib/salary/tax-tables.ts @@ -491,12 +491,16 @@ export async function fetchKommunTaxRates(year: number): Promise= data.resultCount) break } - return Array.from(byKommun.entries()).map(([kommun, rate]) => ({ - kommun, - totalRate: rate, - // Table number: round total rate. ≤0.50 rounds down, ≥0.51 rounds up - tableNumber: Math.round(rate), - })) + return Array.from(byKommun.entries()).map(([kommun, rate]) => { + // Table number per Skatteverket: a fractional part of at most 50 öre picks + // the lower table, 51 öre or more the higher (32,50 gives table 32 but + // 32,51 gives 33). Compare in hundredths so float noise cannot decide the + // boundary (32.51 * 100 === 3250.9999999999995 before rounding). + const hundredths = Math.round(rate * 100) + const tableNumber = + hundredths % 100 <= 50 ? Math.floor(hundredths / 100) : Math.ceil(hundredths / 100) + return { kommun, totalRate: rate, tableNumber } + }) } /** @@ -520,18 +524,37 @@ function normalizeKommunName(raw: string): string { // ── Legacy compatibility (used by calculation-engine.ts) ── +/** + * Percentage-based skatteavdrag stated in whole kronor with öretal dropped + * (the whole-krona rule in SFF 2011:1261 22 kap. 1 §), same rule as + * taxForRate above. Computed in integer öre and hundredths of a percent: + * truncating the raw float product would lose a whole krona when float noise + * lands an exact result just below an integer + * (1000 × 0.007 === 6.999999999999999, so 0.7 % jämkning of 1 000 kr would + * come out as 6 kr instead of 7 kr). + */ +function wholeKronaPercentageTax(monthlyIncome: number, percentage: number): number { + const incomeOre = Math.round(monthlyIncome * 100) + const percentageHundredths = Math.round(percentage * 100) + // Math.trunc, not Math.floor: dropping öre truncates toward zero, and a + // negative taxable income (deductions exceeding pay) must not gain an extra + // negative krona. Normalize the -0 that Math.trunc leaves on small negatives. + const wholeKronor = Math.trunc((incomeOre * percentageHundredths) / 1_000_000) + return wholeKronor === 0 ? 0 : wholeKronor +} + /** * Calculate tax using jämkning (custom percentage from Skatteverket decision). */ export function calculateJamkningTax(monthlyIncome: number, jamkningPercentage: number): number { - return Math.round(monthlyIncome * (jamkningPercentage / 100) * 100) / 100 + return wholeKronaPercentageTax(monthlyIncome, jamkningPercentage) } /** * Calculate tax for sidoinkomst (flat 30%). */ export function calculateSidoinkomstTax(monthlyIncome: number): number { - return Math.round(monthlyIncome * 0.30 * 100) / 100 + return wholeKronaPercentageTax(monthlyIncome, 30) } /** diff --git a/scripts/__tests__/import-tax-tables.test.ts b/scripts/__tests__/import-tax-tables.test.ts new file mode 100644 index 00000000..55717f03 --- /dev/null +++ b/scripts/__tests__/import-tax-tables.test.ts @@ -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() + }) +}) diff --git a/scripts/import-tax-tables.ts b/scripts/import-tax-tables.ts index a78323fa..e3796e7b 100644 --- a/scripts/import-tax-tables.ts +++ b/scripts/import-tax-tables.ts @@ -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() +}