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:
co-authored by
Claude Fable 5
parent
e45218bcc6
commit
614b7e60b9
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
+7975
-6737
File diff suppressed because it is too large
Load Diff
+196
-81
@@ -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})`)
|
||||
|
||||
Reference in New Issue
Block a user