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
+2
View File
@@ -859,3 +859,5 @@ One line per decision: `[YYYY-MM-DD] <decision>: <why>`. Appended by agents and
[2026-08-10] Supplier pain.001 dialect corrected against a real Swedbank Validex run (MIG 1.0, eken.validex.net), which is stricter than the Bankforeningen appendix the generator was built from. Four generator changes: (1) MIG character set enforced by transliteration (a-acute to a, ampersand to plus, Swedish aao survive; leftovers become '?', matching LB padText), because rule 214 rejects e-acute in names outright; (2) InitgPty/Dbtr OrgId is now mandatory and the batch service refuses companies without organisationsnummer (rule 002), reading settings.org_number before the write-once companies.org_number; (3) BGNR creditors debit the company bankgiro when one exists, in their own PmtInf per (date, debit-form) group, because rule 219 demands BGNR-to-BGNR, with Cdtr PstlAdr/Ctry SE always present so IBAN-debited giro payments stay valid under rule 020 (v1 is domestic-only); (4) Strd carries RfrdDocAmt/RmtdAmt = the instructed amount (rule 217). Consequence accepted: the byte-identical re-download contract holds per generator version, not across dialect fixes; MsgId is unchanged so bank-side dedup still keys correctly. Validex account creation has a reCAPTCHA that was deliberately not circumvented; the founder registers and uploads.
[2026-08-10] Validex round 2 (1 fatal left): a present PstlAdr must carry TwnNm from November 2026 (rule 222), and the round-1 fix had added Ctry-only creditor addresses everywhere. Resolution follows Swedbank's own conditionality instead of stuffing TwnNm in unconditionally: BGNR-to-BGNR payments carry NO creditor address (rule 020 does not require one there), IBAN-debited payments carry the supplier's town (new nullable supplier_payment_batch_items.payee_city snapshot) plus Ctry SE, and the debtor gains PstlAdr TwnNm/Ctry from company_settings.city, which also clears the info-level rule 236. Rule 183 (BIC must be SWEDSESS) fires only because the sandbox debtor is a fake SEB account; real Swedbank customers resolve SWEDSESS from their own settings.
[2026-08-10] Validex round 3 falsified the round-2 reading: rule 237 (creditor PstlAdr mandatory) fires on the BGNR-to-BGNR path too, so rule 020's "required unless DbtrAcct is BGNR" only governs the Ctry element, not the address as a whole. Creditor PstlAdr is now emitted on every payment, TwnNm from the payee_city snapshot when known, and the preview warns (payee_city_missing) when the supplier register lacks a city, since 237 + 222 together make a town effectively mandatory at Swedbank and a Ctry-only address is rejected today, not from November.
[2026-08-10] Tax table percent rows (>80 000 kr/month) drop ore via Math.floor: whole-krona rule (oretal bortfaller) per SFF 2011:1261 22 kap. 1 § as applied by Skatteverket's tabellavdrag guidance (the statute governs stated amounts; Skatteverket's tables and guidance apply the same truncation to computed skatteavdrag); an API response missing either table section (30B or 30%) is treated as API failure so the bundled fallback serves complete data instead of clamping. Incomplete bracket data (gaps, failed pagination pages, malformed kolumn values) fails loudly rather than withholding 0.
[2026-08-10] Staging DB reconcile (metjnjrhvujscngnpzdv): the tracker had skipped everything from 20260721101500 to 2026-08-10 (105 local-only versions) while 25 rows existed only remotely. Renamed 10 remote rows to their repo versions (same name, MCP apply-time version drift: sandbox-cleanup consolidation, shopify, tax-depreciation, JEL index), deleted 7 superseded sandbox-iteration rows with no local file, and left 8 rows from unmerged branches (white-label brands/teams, vacation columns, agent-atom product tier) untouched since their content is deliberately live for the byra rigs. Older seed_agent_atom_bodies files register version-only: each seed is a full idempotent upsert with a version guard, so only the newest seed's content needs to run.
@@ -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})`)
+66 -27
View File
@@ -7,19 +7,20 @@
* Output: lib/salary/tax-tables-fallback.ts
*
* Record format (49 chars per line):
* chars 0-4 (width 5): prefix : "30B29" = monthly/belopp, table 29
* chars 0-4 (width 5): prefix : "30B29" = monthly/belopp, table 29;
* "30%29" = monthly/percent, table 29
* chars 5-11 (width 7): income_from
* chars 12-18 (width 7): income_to
* chars 19-23 (width 5): column 1 tax amount (SEK)
* chars 12-18 (width 7): income_to (blank on the open-ended top %-row)
* chars 19-23 (width 5): column 1 (SEK on B-rows, percent on %-rows)
* chars 24-28 (width 5): column 2
* chars 29-33 (width 5): column 3
* chars 34-38 (width 5): column 4
* chars 39-43 (width 5): column 5
* chars 44-48 (width 5): column 6
*
* We import only B-rows (absolute amounts). %-rows (percentage-based, used for
* incomes above the highest B-row bracket) are skipped: matches the behavior
* of the Skatteverket API path which also fetches only B-rows.
* Both B-rows (absolute amounts, incomes up to 80 000 kr/month) and %-rows
* (percent of the whole income, above 80 000 kr/month) are imported. The
* emitted tuple carries an isPercent flag as its last element.
*
* Usage:
* npx tsx scripts/import-tax-tables.ts --year 2026
@@ -28,7 +29,7 @@
import { readFileSync, writeFileSync } from 'fs'
import { resolve } from 'path'
type TaxRow = readonly [number, number, number, number, number, number, number, number]
type TaxRow = readonly [number, number, number, number, number, number, number, number, number]
interface ParsedTable {
tableNumber: number
@@ -54,32 +55,54 @@ function parseLine(line: string): { table: number; row: TaxRow } | null {
if (clean.length < 49) return null
const prefix = clean.slice(0, 5)
// B-rows only (absolute amounts). Skip %-rows.
if (prefix[2] !== 'B') return 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
const isPercent = prefix[2] === '%'
const tableStr = prefix.slice(3, 5)
const table = parseInt(tableStr, 10)
if (!Number.isInteger(table)) return null
const parseField = (start: number, width: number): number => {
const raw = clean.slice(start, start + width).trim()
if (raw === '') return 0
const n = parseInt(raw, 10)
return Number.isFinite(n) ? n : 0
// Column values must be well-formed whole numbers. A malformed value falling
// back to 0 would bake 0 kr / 0 % withholding into the emitted fallback data,
// so fail the import instead.
const parseColumn = (start: number): number => {
const raw = clean.slice(start, start + 5).trim()
if (!/^\d+$/.test(raw)) {
throw new Error(`Malformed column value "${raw}" in line: ${clean}`)
}
return parseInt(raw, 10)
}
const incomeFrom = parseField(5, 7)
const incomeTo = parseField(12, 7)
const c1 = parseField(19, 5)
const c2 = parseField(24, 5)
const c3 = parseField(29, 5)
const c4 = parseField(34, 5)
const c5 = parseField(39, 5)
const c6 = parseField(44, 5)
// Income boundaries get the same digits-only rule: parseInt would accept
// "100abc" and turn other garbage into 0, silently corrupting bracket
// ranges. A blank income_to is legal only on the open-ended top %-row
// (emitted as 0, mapped to the open-ended sentinel by the loader).
const parseIncome = (start: number, allowBlank: boolean): number => {
const raw = clean.slice(start, start + 7).trim()
if (raw === '') {
if (!allowBlank) throw new Error(`Missing income boundary in line: ${clean}`)
return 0
}
if (!/^\d+$/.test(raw)) {
throw new Error(`Malformed income boundary "${raw}" in line: ${clean}`)
}
return parseInt(raw, 10)
}
const incomeFrom = parseIncome(5, false)
const incomeTo = parseIncome(12, isPercent)
const c1 = parseColumn(19)
const c2 = parseColumn(24)
const c3 = parseColumn(29)
const c4 = parseColumn(34)
const c5 = parseColumn(39)
const c6 = parseColumn(44)
return {
table,
row: [incomeFrom, incomeTo, c1, c2, c3, c4, c5, c6] as const,
row: [incomeFrom, incomeTo, c1, c2, c3, c4, c5, c6, isPercent ? 1 : 0] as const,
}
}
@@ -137,9 +160,15 @@ function emitModule(year: number, tables: ParsedTable[]): string {
* Rows: ${totalRows} across tables ${tableNumbers}
*/
/** [incomeFrom, incomeTo, col1, col2, col3, col4, col5, col6] */
/**
* [incomeFrom, incomeTo, col1, col2, col3, col4, col5, col6, isPercent]
*
* isPercent 0: columns are SEK amounts (incomes up to 80 000 kr/month).
* isPercent 1: columns are percent of the whole monthly income (above
* 80 000 kr/month). incomeTo 0 marks the open-ended top row.
*/
export type FallbackTaxRow = readonly [
number, number, number, number, number, number, number, number,
number, number, number, number, number, number, number, number, number,
]
/** Tables keyed by municipal tax rate number (29-42). */
@@ -166,11 +195,21 @@ function main() {
const tables = parseFile(inputPath)
if (tables.length === 0) {
throw new Error('No B-rows parsed: check input file format')
throw new Error('No rows parsed: check input file format')
}
// Every table must have both sections: a B-only table would clamp high
// incomes to the last krona bracket and silently under-withhold.
for (const t of tables) {
const percentRows = t.rows.filter(r => r[8] === 1).length
if (percentRows === 0 || percentRows === t.rows.length) {
throw new Error(`Table ${t.tableNumber}: expected both B-rows and %-rows, got ${percentRows}/${t.rows.length} percent rows`)
}
}
const totalRows = tables.reduce((sum, t) => sum + t.rows.length, 0)
console.log(`Parsed ${tables.length} tables (${tables.map(t => t.tableNumber).join(', ')}), ${totalRows} B-rows total`)
const percentTotal = tables.reduce((sum, t) => sum + t.rows.filter(r => r[8] === 1).length, 0)
console.log(`Parsed ${tables.length} tables (${tables.map(t => t.tableNumber).join(', ')}), ${totalRows} rows total (${percentTotal} percent rows)`)
const moduleSource = emitModule(year, tables)
writeFileSync(outputPath, moduleSource, 'utf-8')