From bb1eddcccf3f13c1474963e31a0ea353b2262452 Mon Sep 17 00:00:00 2001 From: Mattsson <111893710+mattssonn@users.noreply.github.com> Date: Mon, 3 Aug 2026 17:56:09 +0200 Subject: [PATCH] fix(salary): exempt F-skatt compensation from arbetsgivaravgifter (#1372) * fix(salary): exempt F-skatt compensation from arbetsgivaravgifter calculateAvgifterRate() never checked fSkattStatus, so F-skatt earners were charged the standard 31.42% employer contributions even though they pay their own egenavgifter. This overstated employer cost by ~31% and booked incorrect 7510/2731 entries. Add an early return in calculateAvgifterRate for f_skatt (rate 0, category exempt) before any age-based rules, and zero the avgifter basis in calculateSalary so salary reports and AGI totals do not carry a false contribution basis. The FK011/FK131 AGI rendering defect stays scoped to issue #315. Fixes #314 Co-Authored-By: Claude Fable 5 * test(salary): assert full exempt return contract for F-skatt CodeRabbit review: calculateSalary does not read amount/basis from AvgifterCalculation, so the direct-return test must pin both to zero to catch a regression in the exempt early return. Co-Authored-By: Claude Fable 5 --------- Co-authored-by: Claude Fable 5 --- DECISIONS.md | 2 ++ .../__tests__/calculation-engine.test.ts | 27 ++++++++++++++++++- lib/salary/calculation-engine.ts | 12 ++++++++- 3 files changed, 39 insertions(+), 2 deletions(-) diff --git a/DECISIONS.md b/DECISIONS.md index c8e224c4..fccf737c 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -753,3 +753,5 @@ One line per decision: `[YYYY-MM-DD] : `. Appended by agents and [2026-08-03] CI gained a pg-upgrade job: apply the merge-base schema, seed real rows, apply ONLY the PR migrations, assert the data survived. Rationale: pg-real applies all 548 migrations to an EMPTY database, so a NOT NULL / CHECK / unique index / backfill passes against zero rows and can still break prod. Proven locally against supabase/postgres:15.8.1.060 with three bad migrations: a CHECK violating an ore-level row and a NOT NULL on a populated column both exit 0 on empty and exit 3 on seeded. Base migrations are read from the merge-base git tree, not the working tree, so a PR that edits a shipped migration still surfaces here. [2026-08-03] Issue #323 automatic excess depreciation is limited to reconciled IL 18 machinery and equipment with linear book depreciation and posts 8853/2153: buildings, intangible assets, and the 25 percent rest-value method follow separate rules, so calculation fails closed on an incomplete register or unposted planned depreciation. + +[2026-08-03] Issue #314 zeroes the F-skatt avgifter basis at the calculation boundary as well as the rate: a rate-only exemption would stop the 7510/2731 charge but leave a false contribution basis in salary reports and AGI totals; the separate FK011/FK131 XML rendering defect remains scoped to issue #315. diff --git a/lib/salary/__tests__/calculation-engine.test.ts b/lib/salary/__tests__/calculation-engine.test.ts index 78f8cb47..4d239792 100644 --- a/lib/salary/__tests__/calculation-engine.test.ts +++ b/lib/salary/__tests__/calculation-engine.test.ts @@ -152,7 +152,7 @@ describe('calculateSalary', () => { expect(result.taxWithheld).toBe(12000) // 30% of 40000 }) - it('applies f-skatt with 0% withholding', () => { + it('exempts F-skatt compensation from withholding and employer contributions', () => { const result = calculateSalary( makeBasicInput({ fSkattStatus: 'f_skatt' }), config2026, @@ -161,6 +161,12 @@ describe('calculateSalary', () => { expect(result.taxWithheld).toBe(0) expect(result.netSalary).toBe(40000) + expect(result.avgifterRate).toBe(0) + expect(result.avgifterAmount).toBe(0) + expect(result.avgifterBasis).toBe(0) + expect(result.avgifterCategory).toBe('exempt') + expect(result.vacationAccrualAvgifter).toBe(0) + expect(result.totalEmployerCost).toBe(result.grossSalary + result.vacationAccrual) }) it('applies unverified flat 30%', () => { @@ -1050,6 +1056,25 @@ describe('calculateSjuklon', () => { }) describe('calculateAvgifterRate', () => { + it('returns the exempt rate for F-skatt before age-based rules', () => { + const result = calculateAvgifterRate( + makeBasicInput({ fSkattStatus: 'f_skatt', personnummer: 'mock_senior_person' }), + config2026, + 2026 + ) + + expect(result.rate).toBe(0) + expect(result.amount).toBe(0) + expect(result.basis).toBe(0) + expect(result.category).toBe('exempt') + expect(result.steps).toEqual([ + expect.objectContaining({ + label: 'Avgiftskategori', + output: null, + }), + ]) + }) + it('returns standard rate for normal employee', () => { const result = calculateAvgifterRate( makeBasicInput(), diff --git a/lib/salary/calculation-engine.ts b/lib/salary/calculation-engine.ts index e4a7d082..9d23d9e5 100644 --- a/lib/salary/calculation-engine.ts +++ b/lib/salary/calculation-engine.ts @@ -475,7 +475,7 @@ export function calculateSalary( // ─── Step 8: Employer contributions (avgifter) ─── const avgifterCalc = calculateAvgifterRate(input, config, paymentYear) - const avgifterBasis = r(grossSalary + totalBenefits) + const avgifterBasis = input.fSkattStatus === 'f_skatt' ? 0 : r(grossSalary + totalBenefits) // Handle salary caps for youth and växa-stöd: // Reduced rate applies only up to the cap, standard rate on the rest @@ -632,6 +632,16 @@ export function calculateAvgifterRate( ): AvgifterCalculation { const steps: CalculationStep[] = [] + if (input.fSkattStatus === 'f_skatt') { + steps.push({ + label: 'Avgiftskategori', + formula: 'F-skatt: inga arbetsgivaravgifter', + input: {}, + output: null, + }) + return { rate: 0, amount: 0, basis: 0, category: 'exempt', steps } + } + // Decrypt personnummer to calculate age let pnr: string try {