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:
Mattsson
2026-08-11 01:27:16 +02:00
committed by GitHub
co-authored by Claude Fable 5
parent e45218bcc6
commit 614b7e60b9
7 changed files with 8449 additions and 6889 deletions
@@ -923,9 +923,10 @@ describe('hardening: invariants', () => {
describe('hardening: tax table lookup (not just flat fallback)', () => {
const taxRates: TaxTableRate[] = [
{ tableYear: 2026, tableNumber: 32, columnNumber: 1, incomeFrom: 0, incomeTo: 20000, taxAmount: 3000 },
{ tableYear: 2026, tableNumber: 32, columnNumber: 1, incomeFrom: 20001, incomeTo: 30000, taxAmount: 5500 },
{ tableYear: 2026, tableNumber: 32, columnNumber: 1, incomeFrom: 30001, incomeTo: 50000, taxAmount: 10000 },
{ tableYear: 2026, tableNumber: 32, columnNumber: 1, incomeFrom: 0, incomeTo: 20000, kind: 'amount', taxAmount: 3000 },
{ tableYear: 2026, tableNumber: 32, columnNumber: 1, incomeFrom: 20001, incomeTo: 30000, kind: 'amount', taxAmount: 5500 },
{ tableYear: 2026, tableNumber: 32, columnNumber: 1, incomeFrom: 30001, incomeTo: 80000, kind: 'amount', taxAmount: 10000 },
{ tableYear: 2026, tableNumber: 32, columnNumber: 1, incomeFrom: 80001, incomeTo: 9999999, kind: 'percent', taxPercent: 35 },
]
it('uses table lookup when taxTableNumber is set', () => {
@@ -936,6 +937,14 @@ describe('hardening: tax table lookup (not just flat fallback)', () => {
expect(r.taxWithheld).toBe(5500)
})
it('applies percent-of-income brackets for salaries above the krona section (over 80 000 kr)', () => {
const r = calculateSalary(
makeBasicInput({ taxTableNumber: 32, taxColumn: 1, monthlySalary: 100000, lineItems: [baseLineItem(100000)] }),
config2026, taxRates
)
expect(r.taxWithheld).toBe(35000)
})
it('semesterersättning pushes brutto into a higher tax bracket', () => {
// Base 25000 + 12% semer = 28000, still in 20001-30000 bracket
const semer = calculateSalary(
+197 -33
View File
@@ -11,12 +11,23 @@ import {
import type { TaxTableRate } from '../tax-tables'
const sampleRates: TaxTableRate[] = [
{ tableYear: 2026, tableNumber: 33, columnNumber: 1, incomeFrom: 0, incomeTo: 2200, taxAmount: 0 },
{ tableYear: 2026, tableNumber: 33, columnNumber: 1, incomeFrom: 2201, incomeTo: 20000, taxAmount: 2800 },
{ tableYear: 2026, tableNumber: 33, columnNumber: 1, incomeFrom: 20001, incomeTo: 30000, taxAmount: 5600 },
{ tableYear: 2026, tableNumber: 33, columnNumber: 1, incomeFrom: 30001, incomeTo: 40000, taxAmount: 8900 },
{ tableYear: 2026, tableNumber: 33, columnNumber: 1, incomeFrom: 40001, incomeTo: 50000, taxAmount: 12500 },
{ tableYear: 2026, tableNumber: 33, columnNumber: 1, incomeFrom: 50001, incomeTo: 60000, taxAmount: 16800 },
{ 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', () => {
@@ -33,7 +44,27 @@ describe('lookupTaxAmount', () => {
expect(lookupTaxAmount(33, 1, 30000, sampleRates)).toBe(5600)
})
it('uses last bracket for income exceeding all brackets', () => {
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)
})
@@ -42,10 +73,22 @@ describe('lookupTaxAmount', () => {
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, taxAmount: 999 },
{ tableYear: 2026, tableNumber: 33, columnNumber: 2, incomeFrom: 0, incomeTo: 50000, kind: 'amount', taxAmount: 999 },
]
expect(lookupTaxAmount(33, 2, 30000, rates)).toBe(999)
})
@@ -97,35 +140,155 @@ describe('fetchTaxTableRates fallback behavior', () => {
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('marks source as api when the API returns data', async () => {
globalThis.fetch = vi.fn().mockResolvedValue({
ok: true,
status: 200,
json: async () => ({
resultCount: 1,
results: [
{
'år': '2026',
'tabellnr': '33',
'inkomst fr.o.m.': '20001',
'inkomst t.o.m.': '20100',
'kolumn 1': '2800',
'kolumn 2': '0',
'kolumn 3': '2500',
'kolumn 4': '100',
'kolumn 5': '2800',
'kolumn 6': '3000',
},
],
}),
} as Response)
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[0].taxAmount).toBe(2800)
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 () => {
@@ -142,8 +305,9 @@ describe('fetchTaxTableRates fallback behavior', () => {
await fetchTaxTableRates(2026, 33, 1)
await fetchTaxTableRates(2026, 33, 1)
// Only one API attempt despite two calls: second hit the cache
expect(fetchSpy).toHaveBeenCalledTimes(1)
// One API attempt (two section queries: 30B + 30%) despite two calls:
// the second call hit the cache
expect(fetchSpy).toHaveBeenCalledTimes(2)
})
})
+1 -8
View File
@@ -717,14 +717,7 @@ export async function runSalaryCalculation(
employmentEnd: emp.employment_end,
},
config,
taxRates.map((r) => ({
tableYear: r.tableYear,
tableNumber: r.tableNumber,
columnNumber: r.columnNumber,
incomeFrom: r.incomeFrom,
incomeTo: r.incomeTo,
taxAmount: r.taxAmount,
})),
taxRates.map((r) => ({ ...r })),
)
// Aggregated absence counts derived from per-day records.
File diff suppressed because it is too large Load Diff
+196 -81
View File
@@ -27,15 +27,27 @@ const KOMMUN_RATES_API = 'https://skatteverket.entryscape.net/rowstore/dataset/c
export type TaxTableSource = 'api' | 'fallback'
export interface TaxTableRate {
interface TaxTableRateBase {
tableYear: number
tableNumber: number
columnNumber: number
incomeFrom: number
incomeTo: number
taxAmount: number
}
/**
* One bracket of a Skatteverket monthly tax table.
*
* Skatteverket's tables have two sections: krona brackets ("30B" rows, incomes
* up to 80 000 kr/month) where the withholding is a fixed SEK amount, and
* percent brackets ("30%" rows, incomes above) where the withholding is the
* given percent of the WHOLE monthly income. The top percent bracket is
* open-ended (no upper bound in the source data).
*/
export type TaxTableRate =
| (TaxTableRateBase & { kind: 'amount'; taxAmount: number })
| (TaxTableRateBase & { kind: 'percent'; taxPercent: number })
export interface TaxTableRatesResult {
rates: TaxTableRate[]
source: TaxTableSource
@@ -113,20 +125,51 @@ export function lookupTaxAmount(
for (const rate of matchingRates) {
if (roundedIncome >= rate.incomeFrom && roundedIncome <= rate.incomeTo) {
return rate.taxAmount
return taxForRate(rate, roundedIncome)
}
}
// Above all brackets: use last bracket (matches Skatteverket's published behavior
// where the top B-row applies until %-rows take over; we only load B-rows)
// Last-resort guard. The top percent bracket is open-ended, so with fully
// loaded rates this is unreachable; hitting it means the percent rows are
// missing and clamping to the last krona bracket UNDER-withholds for high
// incomes. Warn loudly so it surfaces in logs.
const lastRate = matchingRates[matchingRates.length - 1]
if (roundedIncome > lastRate.incomeTo) {
return lastRate.taxAmount
log.warn(
`Income ${roundedIncome} exceeds all loaded brackets for table ${tableNumber} col ${column} ` +
`(top bracket ends at ${lastRate.incomeTo}): percent rows appear to be missing, clamping to last bracket`
)
return taxForRate(lastRate, roundedIncome)
}
// Income above the first bracket's start but inside no bracket means the
// loaded rates have a gap (e.g. partial API data). Withholding 0 there would
// be silent under-withholding: fail loudly instead.
if (roundedIncome > matchingRates[0].incomeFrom) {
throw new TaxTableUnavailableError(
`Tax table ${tableNumber} column ${column} has no bracket covering income ${roundedIncome}: ` +
`loaded rates contain a gap.`,
{ year: matchingRates[0].tableYear, tableNumber, column }
)
}
// Below the first bracket: no withholding due.
return 0
}
/**
* Withholding for one matched bracket. Percent brackets apply their percent to
* the whole monthly income; öre are dropped since skatteavdrag is stated in
* whole kronor (öretal bortfaller: the whole-krona rule in SFF 2011:1261
* 22 kap. 1 § as applied by Skatteverket's tabellavdrag guidance).
*/
function taxForRate(rate: TaxTableRate, roundedIncome: number): number {
if (rate.kind === 'percent') {
return Math.floor((roundedIncome * rate.taxPercent) / 100)
}
return rate.taxAmount
}
/**
* Build TaxTableRate[] for a given year/table/column from the bundled fallback data.
* Returns null when the requested year/table is not present in the fallback module.
@@ -142,16 +185,92 @@ function getFallbackRates(
if (!rows) return null
if (column < 1 || column > 6) return null
// Columns 1-6 map to tuple indices 2-7 ([incomeFrom, incomeTo, col1..col6])
// Columns 1-6 map to tuple indices 2-7 ([incomeFrom, incomeTo, col1..col6, isPercent])
const colIndex = column + 1
return rows.map(row => ({
tableYear: year,
tableNumber,
columnNumber: column,
incomeFrom: row[0],
incomeTo: row[1] || 9999999,
taxAmount: row[colIndex] as number,
}))
return rows.map(row => {
const base = {
tableYear: year,
tableNumber,
columnNumber: column,
incomeFrom: row[0],
incomeTo: row[1] || 9999999,
}
return row[8] === 1
? { ...base, kind: 'percent' as const, taxPercent: row[colIndex] as number }
: { ...base, kind: 'amount' as const, taxAmount: row[colIndex] as number }
})
}
interface ApiTaxRow {
'år': string
'tabellnr': string
'inkomst fr.o.m.': string
'inkomst t.o.m.': string
'kolumn 1': string
'kolumn 2': string
'kolumn 3': string
'kolumn 4': string
'kolumn 5': string
'kolumn 6': string
}
/**
* Fetch all rows of one table section ("30B" = monthly krona amounts,
* "30%" = monthly percent rows) from Skatteverket's rowstore API, following
* pagination. The API field names have Swedish characters: "år",
* "inkomst fr.o.m.", "inkomst t.o.m.".
*/
async function fetchApiRows(
year: number,
tableNumber: number,
antalDgr: '30B' | '30%'
): Promise<ApiTaxRow[]> {
const buildParams = (offset?: number) => {
const params = new URLSearchParams({
'år': year.toString(),
'tabellnr': tableNumber.toString(),
'antal dgr': antalDgr,
'_limit': '500',
})
if (offset !== undefined) params.set('_offset', offset.toString())
return params
}
const response = await fetch(`${TAX_TABLE_API}?${buildParams().toString()}`, {
headers: { 'Accept': 'application/json' },
signal: AbortSignal.timeout(10000),
})
if (!response.ok) {
throw new Error(`Skatteverket API returned ${response.status}`)
}
const data = await response.json() as { results: ApiTaxRow[]; resultCount: number }
// If more than 500 results, fetch remaining pages. A silently skipped page
// would leave a gap in the bracket list, so any page failure fails the whole
// fetch and lets the bundled fallback serve complete data instead.
let allResults = data.results
if (data.resultCount > 500) {
let offset = 500
while (offset < data.resultCount) {
const pageRes = await fetch(`${TAX_TABLE_API}?${buildParams(offset).toString()}`, {
headers: { 'Accept': 'application/json' },
signal: AbortSignal.timeout(10000),
})
if (!pageRes.ok) {
throw new Error(`Skatteverket API returned ${pageRes.status} for page offset ${offset}`)
}
const pageData = await pageRes.json() as { results: ApiTaxRow[] }
if (pageData.results.length === 0) {
throw new Error(`Skatteverket API returned an empty page at offset ${offset}`)
}
allResults = allResults.concat(pageData.results)
offset += 500
}
}
return allResults
}
/**
@@ -176,83 +295,79 @@ export async function fetchTaxTableRates(
}
try {
// Fetch all B-rows (absolute amounts) for this table/year
// The API field names have Swedish characters: "år", "inkomst fr.o.m.", "inkomst t.o.m."
const params = new URLSearchParams({
'år': year.toString(),
'tabellnr': tableNumber.toString(),
'antal dgr': '30B', // Monthly table, absolute amounts
'_limit': '500',
})
const url = `${TAX_TABLE_API}?${params.toString()}`
log.info(`Fetching tax table ${tableNumber} col ${column} for ${year} from Skatteverket API`)
const response = await fetch(url, {
headers: { 'Accept': 'application/json' },
signal: AbortSignal.timeout(10000),
})
// Fetch both sections of the table: "30B" rows (fixed SEK amounts, incomes
// up to 80 000 kr) and "30%" rows (percent of the whole income, above).
// Loading only the B-rows would silently clamp high incomes to the last
// krona bracket and under-withhold.
const [amountRows, percentRows] = await Promise.all([
fetchApiRows(year, tableNumber, '30B'),
fetchApiRows(year, tableNumber, '30%'),
])
if (!response.ok) {
throw new Error(`Skatteverket API returned ${response.status}`)
if (amountRows.length === 0 || percentRows.length === 0) {
// API responded but is missing a section for this year/table: treat like
// failure so the fallback path runs below.
throw new Error(
`Skatteverket API returned no ${amountRows.length === 0 ? 'amount' : 'percent'} rows ` +
`for table ${tableNumber} year ${year}`
)
}
const data = await response.json() as {
results: Array<{
'år': string
'tabellnr': string
'inkomst fr.o.m.': string
'inkomst t.o.m.': string
'kolumn 1': string
'kolumn 2': string
'kolumn 3': string
'kolumn 4': string
'kolumn 5': string
'kolumn 6': string
}>
resultCount: number
}
// If more than 500 results, fetch remaining pages
let allResults = data.results
if (data.resultCount > 500) {
let offset = 500
while (offset < data.resultCount) {
const pageParams = new URLSearchParams({
'år': year.toString(),
'tabellnr': tableNumber.toString(),
'antal dgr': '30B',
'_limit': '500',
'_offset': offset.toString(),
})
const pageRes = await fetch(`${TAX_TABLE_API}?${pageParams.toString()}`, {
headers: { 'Accept': 'application/json' },
signal: AbortSignal.timeout(10000),
})
if (pageRes.ok) {
const pageData = await pageRes.json() as { results: typeof data.results }
allResults = allResults.concat(pageData.results)
}
offset += 500
// Parse results: each row has all 6 columns, we extract the requested one.
// Kolumn values are parsed strictly: a malformed value must not silently
// become 0 kr / 0 % withholding, it fails the fetch so the fallback runs.
const columnKey = `kolumn ${column}` as keyof ApiTaxRow
const parseKolumnValue = (r: ApiTaxRow): number => {
const raw = (r[columnKey] as string | undefined) ?? ''
const cleaned = raw.trim().replace(',', '.')
if (!/^\d+(\.\d+)?$/.test(cleaned)) {
throw new Error(
`Skatteverket API returned unparsable ${String(columnKey)} value "${raw}" ` +
`for table ${tableNumber} year ${year} (income ${r['inkomst fr.o.m.']})`
)
}
return parseFloat(cleaned)
}
if (allResults.length === 0) {
// API responded but has no rows for this year/table: treat like failure
// so the fallback path runs below.
throw new Error(`Skatteverket API returned no rows for table ${tableNumber} year ${year}`)
// Income boundaries get the same strictness: a malformed boundary would
// silently corrupt a bracket range (parseInt accepts "100abc"; || turns
// garbage into 0 or an open-ended bracket). An empty upper bound is legal
// only on percent rows (the open-ended top row).
const parseIncomeBoundary = (raw: string | undefined, allowEmpty: boolean): number => {
const cleaned = (raw ?? '').trim()
if (cleaned === '') {
if (allowEmpty) return 9999999
throw new Error(
`Skatteverket API returned an empty income boundary for table ${tableNumber} year ${year}`
)
}
if (!/^\d+$/.test(cleaned)) {
throw new Error(
`Skatteverket API returned malformed income boundary "${raw}" for table ${tableNumber} year ${year}`
)
}
return parseInt(cleaned, 10)
}
// Parse results: each row has all 6 columns, we extract the requested one
const columnKey = `kolumn ${column}` as keyof typeof allResults[0]
const rates: TaxTableRate[] = allResults.map(r => ({
const parseRow = (r: ApiTaxRow, allowOpenEnd: boolean) => ({
tableYear: year,
tableNumber: tableNumber,
columnNumber: column,
incomeFrom: parseInt(r['inkomst fr.o.m.']) || 0,
incomeTo: parseInt(r['inkomst t.o.m.']) || 9999999,
taxAmount: parseInt(r[columnKey] as string) || 0,
}))
incomeFrom: parseIncomeBoundary(r['inkomst fr.o.m.'], false),
incomeTo: parseIncomeBoundary(r['inkomst t.o.m.'], allowOpenEnd),
})
const rates: TaxTableRate[] = [
...amountRows.map(r => ({
...parseRow(r, false),
kind: 'amount' as const,
taxAmount: parseKolumnValue(r),
})),
...percentRows.map(r => ({
...parseRow(r, true),
kind: 'percent' as const,
taxPercent: parseKolumnValue(r),
})),
]
rateCache.set(cacheKey, { rates, source: 'api', fetchedAt: Date.now() })
log.info(`Fetched ${rates.length} tax brackets for table ${tableNumber} col ${column} (${year})`)