fix(salary): skatteavdrag rounding trio (whole kronor, ,50 table pick, import prefix guard) (#1582)
* fix(salary): state percentage skatteavdrag in whole kronor (SFF 22 kap. 1 §) calculateJamkningTax and calculateSidoinkomstTax returned öre-precision amounts; skatteavdrag is stated in whole kronor with öretal dropped (SFF 2011:1261 22 kap. 1 §), the same rule taxForRate already applies to percent brackets. The two inline flat-30% branches in calculation-engine.ts (unverified F-skatt, no-table fallback) had the same defect and now route through calculateSidoinkomstTax. Computed in integer öre and hundredths of a percent: flooring the raw float product loses a whole krona when float noise lands an exact result just below an integer (1000 * 0.007 === 6.999999999999999). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(salary): pick the lower tax table at exactly ,50 per Skatteverket rule Math.round sent a total municipal rate of 32,50 to table 33; Skatteverket's rule is that a fractional part of at most 50 öre picks the lower table and 51 öre or more the higher. Compared in hundredths so float noise cannot decide the boundary. Latent today (no kommun sits exactly on ,50 for 2026) but the code now matches the comment above it, which already stated the correct rule. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(salary): reject two-week rows in the monthly tax table import parseLine only checked position 3 for B/%, so a two-week table row (14B29) would silently merge into the monthly fallback data if the wrong Skatteverket file were used as input. The day-count prefix must now be 30; a 14-row throws loudly. main() is guarded behind a direct-execution check (same pattern as generate-crontabs.ts) so parseLine is importable by tests. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(salary): truncate toward zero, not floor, in whole-krona skatteavdrag Skeptic refutation: taxable income can go negative when deductions exceed pay, and Math.floor rounds negatives away from zero, so a payslip 1 öre negative would book a full krona of negative withholding (calculateSidoinkomstTax(-0.01) gave -1 instead of -0). Öretal bortfaller truncates toward zero: Math.trunc, with -0 normalized to 0. 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
ce6efdb3dc
commit
9ad3908ed0
@@ -99,8 +99,27 @@ describe('calculateJamkningTax', () => {
|
||||
expect(calculateJamkningTax(40000, 20)).toBe(8000)
|
||||
})
|
||||
|
||||
it('rounds to 2 decimal places', () => {
|
||||
expect(calculateJamkningTax(33333, 15.5)).toBe(5166.62)
|
||||
it('drops öre so the withheld amount is whole kronor (SFF 22 kap. 1 §)', () => {
|
||||
// 33 333 × 15.5 % = 5 166.615 → 5 166, not 5 166.62
|
||||
expect(calculateJamkningTax(33333, 15.5)).toBe(5166)
|
||||
// 41 999.99 × 25 % = 10 499.9975 → 10 499: öre are dropped, never rounded up
|
||||
expect(calculateJamkningTax(41999.99, 25)).toBe(10499)
|
||||
})
|
||||
|
||||
it('is immune to float noise on exact-krona results', () => {
|
||||
// 1000 × 0.007 === 6.999999999999999 in floats: a naive Math.floor on the
|
||||
// raw product would withhold 6 kr where the exact result is 7 kr
|
||||
expect(calculateJamkningTax(1000, 0.7)).toBe(7)
|
||||
expect(calculateJamkningTax(5000, 0.7)).toBe(35)
|
||||
})
|
||||
|
||||
it('truncates toward zero for negative taxable income (skeptic refutation case)', () => {
|
||||
// Deductions exceeding pay make taxable income negative; dropping öre
|
||||
// truncates toward zero, so Math.floor (which would give -543 and -1
|
||||
// here) must not add an extra negative krona
|
||||
expect(calculateJamkningTax(-3500.25, 15.5)).toBe(-542)
|
||||
expect(calculateSidoinkomstTax(-100.01)).toBe(-30)
|
||||
expect(calculateSidoinkomstTax(-0.01)).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -109,8 +128,15 @@ describe('calculateSidoinkomstTax', () => {
|
||||
expect(calculateSidoinkomstTax(40000)).toBe(12000)
|
||||
})
|
||||
|
||||
it('rounds to 2 decimal places', () => {
|
||||
expect(calculateSidoinkomstTax(33333)).toBe(9999.90)
|
||||
it('drops öre so the withheld amount is whole kronor (SFF 22 kap. 1 §)', () => {
|
||||
// 33 333 × 30 % = 9 999.90 → 9 999, not 9 999.90
|
||||
expect(calculateSidoinkomstTax(33333)).toBe(9999)
|
||||
// 30 123.45 × 30 % = 9 037.035 → 9 037
|
||||
expect(calculateSidoinkomstTax(30123.45)).toBe(9037)
|
||||
})
|
||||
|
||||
it('returns exact whole kronor when the product is a whole-krona amount', () => {
|
||||
expect(calculateSidoinkomstTax(41300)).toBe(12390)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -355,6 +381,24 @@ describe('fetchKommunTaxRates', () => {
|
||||
expect(result.find((r) => r.kommun === 'Stockholm')!.tableNumber).toBe(31) // 30.62 → 31
|
||||
})
|
||||
|
||||
it('picks the lower table at exactly ,50 and the higher from ,51 (Skatteverket rule)', async () => {
|
||||
const fetchSpy = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
resultCount: 3,
|
||||
results: [row('LÅGKÖPING', '32.49'), row('MITTKÖPING', '32.50'), row('HÖGKÖPING', '32.51')],
|
||||
}),
|
||||
} as Response)
|
||||
globalThis.fetch = fetchSpy
|
||||
|
||||
const result = await fetchKommunTaxRates(2026)
|
||||
|
||||
expect(result.find((r) => r.kommun === 'Lågköping')!.tableNumber).toBe(32)
|
||||
// Skatteverket's own example: 32,50 belongs to table 32, not 33
|
||||
expect(result.find((r) => r.kommun === 'Mittköping')!.tableNumber).toBe(32)
|
||||
expect(result.find((r) => r.kommun === 'Högköping')!.tableNumber).toBe(33)
|
||||
})
|
||||
|
||||
it('normalizes Skatteverket uppercase names to title case', async () => {
|
||||
const fetchSpy = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
|
||||
@@ -415,8 +415,8 @@ export function calculateSalary(
|
||||
output: 0,
|
||||
})
|
||||
} else if (input.fSkattStatus === 'not_verified') {
|
||||
// Unverified: flat 30%
|
||||
taxWithheld = r(taxableIncome * 0.30)
|
||||
// Unverified: flat 30%, whole kronor (SFF 22 kap. 1 §)
|
||||
taxWithheld = calculateSidoinkomstTax(taxableIncome)
|
||||
steps.push({
|
||||
label: 'Skatteavdrag (ej verifierad)',
|
||||
formula: 'skattegrundande inkomst × 30 %',
|
||||
@@ -451,8 +451,8 @@ export function calculateSalary(
|
||||
output: taxWithheld,
|
||||
})
|
||||
} else {
|
||||
// Fallback: flat 30%
|
||||
taxWithheld = r(taxableIncome * 0.30)
|
||||
// Fallback: flat 30%, whole kronor (SFF 22 kap. 1 §)
|
||||
taxWithheld = calculateSidoinkomstTax(taxableIncome)
|
||||
steps.push({
|
||||
label: 'Skatteavdrag (30 % schablon)',
|
||||
formula: 'skattegrundande inkomst × 30 %',
|
||||
|
||||
@@ -491,12 +491,16 @@ export async function fetchKommunTaxRates(year: number): Promise<Array<{
|
||||
if (typeof data.resultCount === 'number' && offset >= data.resultCount) break
|
||||
}
|
||||
|
||||
return Array.from(byKommun.entries()).map(([kommun, rate]) => ({
|
||||
kommun,
|
||||
totalRate: rate,
|
||||
// Table number: round total rate. ≤0.50 rounds down, ≥0.51 rounds up
|
||||
tableNumber: Math.round(rate),
|
||||
}))
|
||||
return Array.from(byKommun.entries()).map(([kommun, rate]) => {
|
||||
// Table number per Skatteverket: a fractional part of at most 50 öre picks
|
||||
// the lower table, 51 öre or more the higher (32,50 gives table 32 but
|
||||
// 32,51 gives 33). Compare in hundredths so float noise cannot decide the
|
||||
// boundary (32.51 * 100 === 3250.9999999999995 before rounding).
|
||||
const hundredths = Math.round(rate * 100)
|
||||
const tableNumber =
|
||||
hundredths % 100 <= 50 ? Math.floor(hundredths / 100) : Math.ceil(hundredths / 100)
|
||||
return { kommun, totalRate: rate, tableNumber }
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -520,18 +524,37 @@ function normalizeKommunName(raw: string): string {
|
||||
|
||||
// ── Legacy compatibility (used by calculation-engine.ts) ──
|
||||
|
||||
/**
|
||||
* Percentage-based skatteavdrag stated in whole kronor with öretal dropped
|
||||
* (the whole-krona rule in SFF 2011:1261 22 kap. 1 §), same rule as
|
||||
* taxForRate above. Computed in integer öre and hundredths of a percent:
|
||||
* truncating the raw float product would lose a whole krona when float noise
|
||||
* lands an exact result just below an integer
|
||||
* (1000 × 0.007 === 6.999999999999999, so 0.7 % jämkning of 1 000 kr would
|
||||
* come out as 6 kr instead of 7 kr).
|
||||
*/
|
||||
function wholeKronaPercentageTax(monthlyIncome: number, percentage: number): number {
|
||||
const incomeOre = Math.round(monthlyIncome * 100)
|
||||
const percentageHundredths = Math.round(percentage * 100)
|
||||
// Math.trunc, not Math.floor: dropping öre truncates toward zero, and a
|
||||
// negative taxable income (deductions exceeding pay) must not gain an extra
|
||||
// negative krona. Normalize the -0 that Math.trunc leaves on small negatives.
|
||||
const wholeKronor = Math.trunc((incomeOre * percentageHundredths) / 1_000_000)
|
||||
return wholeKronor === 0 ? 0 : wholeKronor
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate tax using jämkning (custom percentage from Skatteverket decision).
|
||||
*/
|
||||
export function calculateJamkningTax(monthlyIncome: number, jamkningPercentage: number): number {
|
||||
return Math.round(monthlyIncome * (jamkningPercentage / 100) * 100) / 100
|
||||
return wholeKronaPercentageTax(monthlyIncome, jamkningPercentage)
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate tax for sidoinkomst (flat 30%).
|
||||
*/
|
||||
export function calculateSidoinkomstTax(monthlyIncome: number): number {
|
||||
return Math.round(monthlyIncome * 0.30 * 100) / 100
|
||||
return wholeKronaPercentageTax(monthlyIncome, 30)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user