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
@@ -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(
|
||||
|
||||
@@ -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})`)
|
||||
|
||||
@@ -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')
|
||||
|
||||
Reference in New Issue
Block a user