Files
accounted/lib/salary/__tests__/tax-tables.test.ts
T
MattssonandClaude Fable 5 614b7e60b9 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>
2026-08-11 01:27:16 +02:00

377 lines
15 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
import {
lookupTaxAmount,
calculateJamkningTax,
calculateSidoinkomstTax,
fetchTaxTableRates,
fetchKommunTaxRates,
clearTaxTableCache,
TaxTableUnavailableError,
} from '../tax-tables'
import type { TaxTableRate } from '../tax-tables'
const sampleRates: TaxTableRate[] = [
{ tableYear: 2026, tableNumber: 33, columnNumber: 1, incomeFrom: 0, incomeTo: 2200, kind: 'amount', taxAmount: 0 },
{ tableYear: 2026, tableNumber: 33, columnNumber: 1, incomeFrom: 2201, incomeTo: 20000, kind: 'amount', taxAmount: 2800 },
{ tableYear: 2026, tableNumber: 33, columnNumber: 1, incomeFrom: 20001, incomeTo: 30000, kind: 'amount', taxAmount: 5600 },
{ tableYear: 2026, tableNumber: 33, columnNumber: 1, incomeFrom: 30001, incomeTo: 40000, kind: 'amount', taxAmount: 8900 },
{ tableYear: 2026, tableNumber: 33, columnNumber: 1, incomeFrom: 40001, incomeTo: 50000, kind: 'amount', taxAmount: 12500 },
{ tableYear: 2026, tableNumber: 33, columnNumber: 1, incomeFrom: 50001, incomeTo: 60000, kind: 'amount', taxAmount: 16800 },
]
// Mirrors the real structure of table 31 for 2026: krona brackets up to
// 80 000 kr/month, percent-of-income brackets above, open-ended top row.
const table31Rates: TaxTableRate[] = [
{ tableYear: 2026, tableNumber: 31, columnNumber: 1, incomeFrom: 0, incomeTo: 79800, kind: 'amount', taxAmount: 25192 },
{ tableYear: 2026, tableNumber: 31, columnNumber: 1, incomeFrom: 79801, incomeTo: 80000, kind: 'amount', taxAmount: 25294 },
{ tableYear: 2026, tableNumber: 31, columnNumber: 1, incomeFrom: 80001, incomeTo: 80600, kind: 'percent', taxPercent: 32 },
{ tableYear: 2026, tableNumber: 31, columnNumber: 1, incomeFrom: 80601, incomeTo: 99800, kind: 'percent', taxPercent: 34 },
{ tableYear: 2026, tableNumber: 31, columnNumber: 1, incomeFrom: 99801, incomeTo: 100000, kind: 'percent', taxPercent: 35 },
{ tableYear: 2026, tableNumber: 31, columnNumber: 1, incomeFrom: 100001, incomeTo: 9999999, kind: 'percent', taxPercent: 50 },
]
describe('lookupTaxAmount', () => {
it('returns 0 for income below minimum bracket', () => {
expect(lookupTaxAmount(33, 1, 1500, sampleRates)).toBe(0)
})
it('matches correct bracket for mid-range income', () => {
expect(lookupTaxAmount(33, 1, 25000, sampleRates)).toBe(5600)
})
it('matches bracket boundary exactly', () => {
expect(lookupTaxAmount(33, 1, 20001, sampleRates)).toBe(5600)
expect(lookupTaxAmount(33, 1, 30000, sampleRates)).toBe(5600)
})
it('applies percent of the whole income above the last krona bracket (reported bug: 100 000 kr, tabell 31 kol 1)', () => {
expect(lookupTaxAmount(31, 1, 100000, table31Rates)).toBe(35000)
})
it('switches from krona amount to percent exactly at the 80 000/80 001 boundary', () => {
expect(lookupTaxAmount(31, 1, 80000, table31Rates)).toBe(25294)
// 80 001 × 32 % = 25 600.32 → öre dropped (SFF 22 kap. 1 §)
expect(lookupTaxAmount(31, 1, 80001, table31Rates)).toBe(25600)
})
it('drops öre when the percent of income is not a whole krona amount', () => {
// 80 123 × 32 % = 25 639.36 → 25 639
expect(lookupTaxAmount(31, 1, 80123, table31Rates)).toBe(25639)
})
it('applies the open-ended top percent bracket to arbitrarily high incomes', () => {
expect(lookupTaxAmount(31, 1, 2000000, table31Rates)).toBe(1000000)
})
it('clamps to the last loaded bracket only as a last resort when percent rows are missing', () => {
// sampleRates has no percent rows: legacy guard keeps withholding non-zero
expect(lookupTaxAmount(33, 1, 100000, sampleRates)).toBe(16800)
})
it('throws TaxTableUnavailableError when no matching rates exist (no silent 30% fallback)', () => {
expect(() => lookupTaxAmount(99, 1, 40000, sampleRates)).toThrow(TaxTableUnavailableError)
expect(() => lookupTaxAmount(99, 1, 40000, sampleRates)).toThrow(/table 99/)
})
it('throws when the loaded brackets have a gap instead of silently withholding 0', () => {
const gappyRates: TaxTableRate[] = [
{ tableYear: 2026, tableNumber: 33, columnNumber: 1, incomeFrom: 1, incomeTo: 20000, kind: 'amount', taxAmount: 2800 },
// gap: 20001-29999 missing (e.g. partial API data)
{ tableYear: 2026, tableNumber: 33, columnNumber: 1, incomeFrom: 30000, incomeTo: 9999999, kind: 'percent', taxPercent: 35 },
]
expect(() => lookupTaxAmount(33, 1, 25000, gappyRates)).toThrow(TaxTableUnavailableError)
expect(() => lookupTaxAmount(33, 1, 25000, gappyRates)).toThrow(/gap/)
// Below the first bracket is not a gap: no withholding due
expect(lookupTaxAmount(33, 1, 0, gappyRates)).toBe(0)
})
it('filters by correct column', () => {
const rates: TaxTableRate[] = [
...sampleRates,
{ tableYear: 2026, tableNumber: 33, columnNumber: 2, incomeFrom: 0, incomeTo: 50000, kind: 'amount', taxAmount: 999 },
]
expect(lookupTaxAmount(33, 2, 30000, rates)).toBe(999)
})
})
describe('calculateJamkningTax', () => {
it('calculates tax using custom percentage', () => {
expect(calculateJamkningTax(40000, 20)).toBe(8000)
})
it('rounds to 2 decimal places', () => {
expect(calculateJamkningTax(33333, 15.5)).toBe(5166.62)
})
})
describe('calculateSidoinkomstTax', () => {
it('calculates flat 30%', () => {
expect(calculateSidoinkomstTax(40000)).toBe(12000)
})
it('rounds to 2 decimal places', () => {
expect(calculateSidoinkomstTax(33333)).toBe(9999.90)
})
})
describe('fetchTaxTableRates fallback behavior', () => {
const originalFetch = globalThis.fetch
beforeEach(() => {
clearTaxTableCache()
})
afterEach(() => {
globalThis.fetch = originalFetch
clearTaxTableCache()
})
it("falls back to local data when the Skatteverket API fails for a supported year", async () => {
// Mock fetch to simulate API outage
globalThis.fetch = vi.fn().mockRejectedValue(new Error('network down'))
const result = await fetchTaxTableRates(2026, 33, 1)
expect(result.source).toBe('fallback')
expect(result.rates.length).toBeGreaterThan(0)
// Every rate should match the requested table/column/year
for (const r of result.rates) {
expect(r.tableYear).toBe(2026)
expect(r.tableNumber).toBe(33)
expect(r.columnNumber).toBe(1)
}
// Both sections must be present: krona brackets and percent brackets
expect(result.rates.some((r) => r.kind === 'amount')).toBe(true)
expect(result.rates.some((r) => r.kind === 'percent')).toBe(true)
})
it('resolves the reported bug through the fallback data: 100 000 kr, tabell 31 kol 1 → 35 000 kr', async () => {
globalThis.fetch = vi.fn().mockRejectedValue(new Error('network down'))
const result = await fetchTaxTableRates(2026, 31, 1)
expect(result.source).toBe('fallback')
expect(lookupTaxAmount(31, 1, 100000, result.rates)).toBe(35000)
// Last krona bracket still applies at 80 000 (the pre-fix clamp value)
expect(lookupTaxAmount(31, 1, 80000, result.rates)).toBe(25294)
})
function apiRow(from: string, to: string, col1: string) {
return {
'år': '2026',
'tabellnr': '33',
'inkomst fr.o.m.': from,
'inkomst t.o.m.': to,
'kolumn 1': col1,
'kolumn 2': '0',
'kolumn 3': '2500',
'kolumn 4': '100',
'kolumn 5': '2800',
'kolumn 6': '3000',
}
}
it('marks source as api and combines krona and percent rows when the API returns data', async () => {
// The B-row and %-row sections are fetched as separate filtered queries
globalThis.fetch = vi.fn(async (input: string | URL | Request) => {
const url = String(input)
const isPercentQuery = url.includes(encodeURIComponent('30%'))
return {
ok: true,
status: 200,
json: async () => ({
resultCount: 1,
results: isPercentQuery
? [apiRow('80001', '', '32')]
: [apiRow('20001', '20100', '2800')],
}),
} as Response
}) as typeof fetch
const result = await fetchTaxTableRates(2026, 33, 1)
expect(result.source).toBe('api')
expect(result.rates).toContainEqual(
expect.objectContaining({ kind: 'amount', incomeFrom: 20001, incomeTo: 20100, taxAmount: 2800 })
)
// Empty upper bound on the top percent row maps to the open-ended sentinel
expect(result.rates).toContainEqual(
expect.objectContaining({ kind: 'percent', incomeFrom: 80001, incomeTo: 9999999, taxPercent: 32 })
)
})
it('falls back when the API serves krona rows but no percent rows', async () => {
globalThis.fetch = vi.fn(async (input: string | URL | Request) => {
const url = String(input)
const isPercentQuery = url.includes(encodeURIComponent('30%'))
return {
ok: true,
status: 200,
json: async () => ({
resultCount: isPercentQuery ? 0 : 1,
results: isPercentQuery ? [] : [apiRow('20001', '20100', '2800')],
}),
} as Response
}) as typeof fetch
const result = await fetchTaxTableRates(2026, 33, 1)
// Krona rows alone would clamp high incomes: incomplete API data must not win
expect(result.source).toBe('fallback')
expect(result.rates.some((r) => r.kind === 'percent')).toBe(true)
})
it('falls back when a pagination page fails (no silent gap in the bracket list)', async () => {
globalThis.fetch = vi.fn(async (input: string | URL | Request) => {
const url = String(input)
if (url.includes('_offset=500')) {
return { ok: false, status: 502 } as Response
}
const isPercentQuery = url.includes(encodeURIComponent('30%'))
return {
ok: true,
status: 200,
json: async () => ({
// First page claims more rows exist, forcing the paginated path
resultCount: isPercentQuery ? 1 : 600,
results: isPercentQuery
? [apiRow('80001', '', '32')]
: Array.from({ length: 500 }, (_, i) =>
apiRow(String(i * 100 + 1), String((i + 1) * 100), '1000')
),
}),
} as Response
}) as typeof fetch
const result = await fetchTaxTableRates(2026, 33, 1)
expect(result.source).toBe('fallback')
})
it('falls back when the API returns a malformed income boundary', async () => {
globalThis.fetch = vi.fn(async (input: string | URL | Request) => {
const url = String(input)
const isPercentQuery = url.includes(encodeURIComponent('30%'))
return {
ok: true,
status: 200,
json: async () => ({
resultCount: 1,
results: isPercentQuery
? [apiRow('80001', '', '32')]
// Malformed upper bound on a krona row: parseInt would read 20100
: [apiRow('20001', '20100abc', '2800')],
}),
} as Response
}) as typeof fetch
const result = await fetchTaxTableRates(2026, 33, 1)
expect(result.source).toBe('fallback')
})
it('falls back when the API returns a malformed kolumn value instead of storing 0', async () => {
globalThis.fetch = vi.fn(async (input: string | URL | Request) => {
const url = String(input)
const isPercentQuery = url.includes(encodeURIComponent('30%'))
return {
ok: true,
status: 200,
json: async () => ({
resultCount: 1,
results: isPercentQuery
? [apiRow('80001', '', 'N/A')]
: [apiRow('20001', '20100', '2800')],
}),
} as Response
}) as typeof fetch
const result = await fetchTaxTableRates(2026, 33, 1)
expect(result.source).toBe('fallback')
})
it('throws TaxTableUnavailableError when API fails and year has no fallback', async () => {
globalThis.fetch = vi.fn().mockRejectedValue(new Error('network down'))
await expect(fetchTaxTableRates(2020, 33, 1)).rejects.toBeInstanceOf(TaxTableUnavailableError)
await expect(fetchTaxTableRates(2020, 33, 1)).rejects.toThrow(/2020/)
})
it('caches the fallback result so repeat calls do not re-trigger the failed API', async () => {
const fetchSpy = vi.fn().mockRejectedValue(new Error('network down'))
globalThis.fetch = fetchSpy
await fetchTaxTableRates(2026, 33, 1)
await fetchTaxTableRates(2026, 33, 1)
// One API attempt (two section queries: 30B + 30%) despite two calls:
// the second call hit the cache
expect(fetchSpy).toHaveBeenCalledTimes(2)
})
})
describe('fetchKommunTaxRates', () => {
const originalFetch = globalThis.fetch
afterEach(() => {
globalThis.fetch = originalFetch
})
function row(kommun: string, rate: string) {
return { kommun, 'summa, exkl. kyrkoavgift': rate }
}
it('pages through every församling row and dedupes to one entry per kommun', async () => {
// The dataset returns one row per församling. Page 1 is full (so the loop
// continues); page 2 is short (so it stops). Göteborg only appears on page 2:
// the bug this guards against was a single 500-row page dropping it.
const page1 = Array.from({ length: 500 }, () => row('STOCKHOLM', '30.62'))
const page2 = [
row('GÖTEBORG', '32.892'),
row('GÖTEBORG', '33.50'), // duplicate församling: must not override the first
row('UPPLANDS VÄSBY', '32.042'),
]
const fetchSpy = vi
.fn()
.mockResolvedValueOnce({ ok: true, json: async () => ({ resultCount: 503, results: page1 }) } as Response)
.mockResolvedValueOnce({ ok: true, json: async () => ({ resultCount: 503, results: page2 }) } as Response)
globalThis.fetch = fetchSpy
const result = await fetchKommunTaxRates(2026)
expect(fetchSpy).toHaveBeenCalledTimes(2)
// Second call must advance the offset past the first page.
expect((fetchSpy.mock.calls[1][0] as string)).toContain('_offset=500')
const goteborg = result.find((r) => r.kommun === 'Göteborg')
expect(goteborg).toBeDefined()
expect(goteborg!.totalRate).toBe(32.892)
expect(goteborg!.tableNumber).toBe(33) // 32.892 → round → 33
// Deduped: Stockholm appears once despite 500 rows.
expect(result.filter((r) => r.kommun === 'Stockholm')).toHaveLength(1)
expect(result.find((r) => r.kommun === 'Stockholm')!.tableNumber).toBe(31) // 30.62 → 31
})
it('normalizes Skatteverket uppercase names to title case', async () => {
const fetchSpy = vi.fn().mockResolvedValue({
ok: true,
json: async () => ({ resultCount: 2, results: [row('UPPLANDS VÄSBY', '32.042'), row('MALMÖ', '32.10')] }),
} as Response)
globalThis.fetch = fetchSpy
const result = await fetchKommunTaxRates(2026)
const names = result.map((r) => r.kommun)
expect(names).toContain('Upplands Väsby')
expect(names).toContain('Malmö')
})
it('throws when the API returns a non-ok status', async () => {
globalThis.fetch = vi.fn().mockResolvedValue({ ok: false, status: 502 } as Response)
await expect(fetchKommunTaxRates(2026)).rejects.toThrow(/502/)
})
})