From 993b3f596283182e20d344726450ebaadb0b75a2 Mon Sep 17 00:00:00 2001 From: Mattsson <111893710+mattssonn@users.noreply.github.com> Date: Sat, 9 May 2026 23:55:50 +0200 Subject: [PATCH] Fix/remove disabled fields (#426) * fix(dashboard): enable salary features with "Beta" badge for testing * fix(errors): enhance Swedish error message patterns for better user feedback fix(salary): update Nordea Personkonto handling in account encoding logic * fix(agi): implement feature flag for AGI transmission and update button states * fix(swedish-payroll): update youth rate eligibility criteria and enhance documentation * fix(agi-panel): remove feature flag for AGI transmission and simplify button states --- .claude/skills/swedish-payroll/SKILL.md | 2 +- .../references/social-charges.md | 2 +- lib/errors/get-error-message.ts | 10 ++ .../__tests__/calculation-engine.test.ts | 129 ++++++++++++++++++ lib/salary/agi/field-codes.ts | 2 +- lib/salary/calculation-engine.ts | 16 ++- lib/salary/payment/bg-lb-generator.ts | 12 ++ 7 files changed, 165 insertions(+), 8 deletions(-) diff --git a/.claude/skills/swedish-payroll/SKILL.md b/.claude/skills/swedish-payroll/SKILL.md index b953f366..1ad29643 100644 --- a/.claude/skills/swedish-payroll/SKILL.md +++ b/.claude/skills/swedish-payroll/SKILL.md @@ -40,7 +40,7 @@ Total rate unchanged since 2009. Calculated on full gross salary + taxable benef | Born 1937 or earlier | 0% | | Turned 66+ at year start (67+ from 2026) | 10.21% (only ålderspensionsavgift) | | Standard (all others) | 31.42% | -| Temporary youth 19-23 (Apr 2026 - Sep 2027) | 20.81% on salary up to 25,000 SEK/month | +| Temporary youth (Apr 2026 – Sep 2027) | 20.81% on salary up to 25,000 SEK/month. Eligible: vid årets ingång fyllt 18, inte 23 (i.e. age 18–22 at Jan 1; born 2003–2007 in 2026, 2004–2008 in 2027). NOT during-year age — Skatteverket rejects 23-year-olds at year start. Source: Prop. 2025/26:66 | No avgifter required if total annual compensation from one employer < 1,000 SEK. diff --git a/.claude/skills/swedish-payroll/references/social-charges.md b/.claude/skills/swedish-payroll/references/social-charges.md index 792a038f..9a7f3546 100644 --- a/.claude/skills/swedish-payroll/references/social-charges.md +++ b/.claude/skills/swedish-payroll/references/social-charges.md @@ -22,7 +22,7 @@ Check each employee's birth year against calendar year: - Born 1937 or earlier: 0% (no avgifter) - Turned 66 at year's start (born ≤1958 for 2024, ≤1959 for 2025; threshold rises to 67 in 2026): only ålderspensionsavgift = 10.21% - Youth discount: abolished January 1, 2024 -- New temporary reduction (Apr 1, 2026 - Sep 30, 2027): 20.81% on salary up to 25,000 SEK/month for ages 19-23 +- New temporary reduction (Apr 1, 2026 – Sep 30, 2027): 20.81% on salary up to 25,000 SEK/month per individual per calendar month. Eligibility: at årets ingång (1 Jan) the employee must have **turned 18 but NOT turned 23** — i.e. ages 18–22 at year start (born 2003–2007 for tax year 2026, 2004–2008 for 2027). Statutory wording: *"personer som vid årets ingång har fyllt 18 men inte 23 år"* (Prop. 2025/26:66 / Lag om särskild beräkning av arbetsgivaravgifter och allmän löneavgift för personer som vid årets ingång har fyllt 18 men inte 23 år). The Riksdag betänkande title "19–23-åringar" describes the during-year age and is NOT the eligibility test — Skatteverket's AGI validator rejects 23-year-olds at year start. No avgifter required if total annual compensation from a single employer < 1,000 SEK. diff --git a/lib/errors/get-error-message.ts b/lib/errors/get-error-message.ts index 2b0170cb..63f47ae2 100644 --- a/lib/errors/get-error-message.ts +++ b/lib/errors/get-error-message.ts @@ -152,6 +152,16 @@ function isSwedishUserMessage(message: string): boolean { /förfrågan/i, /obligatorisk/i, /bokföringen är låst/i, + /fält/i, + /värde/i, + /felaktig/i, + /för (lång|kort|stor|liten|många|få)/i, + /bankgiro/i, + /personnummer/i, + /kontonummer/i, + /clearingnummer/i, + /nummer är/i, + /tillgängligt/i, ] return swedishPatterns.some((p) => p.test(message)) } diff --git a/lib/salary/__tests__/calculation-engine.test.ts b/lib/salary/__tests__/calculation-engine.test.ts index 47b09b34..cf460efd 100644 --- a/lib/salary/__tests__/calculation-engine.test.ts +++ b/lib/salary/__tests__/calculation-engine.test.ts @@ -10,6 +10,11 @@ vi.mock('../personnummer', () => ({ if (encrypted === 'mock_old_person') return '193501011234' if (encrypted === 'mock_young_person') return '200301011234' if (encrypted === 'mock_senior_person') return '195801011234' + // Generic helper: 'mock_born_YYYY' resolves to a Jan-1 birth in YYYY. + // Used by ungdomsrabatt boundary tests so each case names its own year + // explicitly rather than depending on a global mock alias. + const m = /^mock_born_(\d{4})$/.exec(encrypted) + if (m) return `${m[1]}01011234` return '199001011234' // Default: born 1990 }, calculateAgeAtYearStart: (pnr: string, year: number) => { @@ -367,4 +372,128 @@ describe('calculateAvgifterRate', () => { expect(result.rate).toBe(0.1021) expect(result.category).toBe('vaxa_stod') }) + + // Ungdomsrabatt 2026-2027 (Prop. 2025/26:66). Eligibility test is + // age >= 18 AND age < 23 at årets ingång. Cases below pin all four age + // boundaries plus the period-window edges. + describe('youth rate (ungdomsrabatt 2026-2027)', () => { + it('NOT eligible — age 17 at year start (too young)', () => { + const result = calculateAvgifterRate( + makeBasicInput({ personnummer: 'mock_born_2009', paymentDate: '2026-05-25' }), + config2026, + 2026, + ) + expect(result.category).toBe('standard') + expect(result.rate).toBe(0.3142) + }) + + it('eligible — age 18 at year start (lower boundary)', () => { + const result = calculateAvgifterRate( + makeBasicInput({ personnummer: 'mock_born_2008', paymentDate: '2026-05-25' }), + config2026, + 2026, + ) + expect(result.category).toBe('youth') + expect(result.rate).toBe(0.2081) + }) + + it('eligible — age 22 at year start (upper boundary)', () => { + const result = calculateAvgifterRate( + makeBasicInput({ personnummer: 'mock_born_2004', paymentDate: '2026-05-25' }), + config2026, + 2026, + ) + expect(result.category).toBe('youth') + expect(result.rate).toBe(0.2081) + }) + + // Regression: this is the case Skatteverket's AGI validator rejected. + // The previous implementation incorrectly accepted age 23 at year start. + it('NOT eligible — age 23 at year start (just over)', () => { + const result = calculateAvgifterRate( + makeBasicInput({ personnummer: 'mock_born_2003', paymentDate: '2026-05-25' }), + config2026, + 2026, + ) + expect(result.category).toBe('standard') + expect(result.rate).toBe(0.3142) + }) + + it('NOT eligible — age 22 but paid March 2026 (before period starts)', () => { + const result = calculateAvgifterRate( + makeBasicInput({ personnummer: 'mock_born_2004', paymentDate: '2026-03-15' }), + config2026, + 2026, + ) + expect(result.category).toBe('standard') + expect(result.rate).toBe(0.3142) + }) + + it('NOT eligible — age 22 but paid October 2027 (after period ends)', () => { + const config2027: PayrollConfig = { ...config2026, configYear: 2027 } + const result = calculateAvgifterRate( + makeBasicInput({ personnummer: 'mock_born_2005', paymentDate: '2027-10-10' }), + config2027, + 2027, + ) + expect(result.category).toBe('standard') + expect(result.rate).toBe(0.3142) + }) + + it('eligible — payment exactly April 1 2026 (period start edge)', () => { + const result = calculateAvgifterRate( + makeBasicInput({ personnummer: 'mock_born_2004', paymentDate: '2026-04-01' }), + config2026, + 2026, + ) + expect(result.category).toBe('youth') + expect(result.rate).toBe(0.2081) + }) + + it('eligible — payment exactly September 30 2027 (period end edge)', () => { + const config2027: PayrollConfig = { ...config2026, configYear: 2027 } + const result = calculateAvgifterRate( + makeBasicInput({ personnummer: 'mock_born_2005', paymentDate: '2027-09-30' }), + config2027, + 2027, + ) + expect(result.category).toBe('youth') + expect(result.rate).toBe(0.2081) + }) + }) +}) + +describe('calculateSalary — youth cap', () => { + // The 25 000 SEK monthly cap is applied by calculateSalary (not + // calculateAvgifterRate) so it has to be exercised through the integration + // path. Salary above the cap: discounted portion at 20.81%, excess at 31.42%. + it('applies 20.81% on first 25 000 SEK and 31.42% on the excess', () => { + const result = calculateSalary( + makeBasicInput({ + personnummer: 'mock_born_2004', // age 22 at year start 2026 + paymentDate: '2026-06-25', + monthlySalary: 30000, + }), + config2026, + [], + ) + // 25 000 × 0.2081 + 5 000 × 0.3142 = 5 202.50 + 1 571.00 = 6 773.50 + expect(result.avgifterAmount).toBeCloseTo(6773.5, 1) + expect(result.avgifterCategory).toBe('youth') + }) + + it('applies pure 20.81% when salary is at or below the cap', () => { + const result = calculateSalary( + makeBasicInput({ + personnummer: 'mock_born_2004', + paymentDate: '2026-06-25', + monthlySalary: 20000, + }), + config2026, + [], + ) + // 20 000 × 0.2081 = 4 162.00 + expect(result.avgifterAmount).toBeCloseTo(4162, 1) + expect(result.avgifterCategory).toBe('youth') + }) }) diff --git a/lib/salary/agi/field-codes.ts b/lib/salary/agi/field-codes.ts index 4da8f5cc..debbaf43 100644 --- a/lib/salary/agi/field-codes.ts +++ b/lib/salary/agi/field-codes.ts @@ -19,7 +19,7 @@ export const HUVUDUPPGIFT_FIELDS = { RUTA_060: '060', /** Arbetsgivaravgifter — age-reduced (10.21%, born ≤1959 for 2026) */ RUTA_061: '061', - /** Arbetsgivaravgifter — youth rate (20.81%, ages 19-23, Apr 2026–Sep 2027) */ + /** Arbetsgivaravgifter — youth rate (20.81%, vid årets ingång 18–22 år, Apr 2026–Sep 2027; Prop. 2025/26:66) */ RUTA_062: '062', } as const diff --git a/lib/salary/calculation-engine.ts b/lib/salary/calculation-engine.ts index 70b14eb7..47a3d329 100644 --- a/lib/salary/calculation-engine.ts +++ b/lib/salary/calculation-engine.ts @@ -490,17 +490,23 @@ export function calculateAvgifterRate( } } - // Youth rate (2026: ages 19-23, Apr-Sep only) - if (config.avgifterYouthRate !== null && ageAtYearStart >= 19 && ageAtYearStart <= 23) { + // Youth rate (ungdomsrabatt 2026-2027, Prop. 2025/26:66): + // "personer som vid årets ingång har fyllt 18 men inte 23 år" + // → eligible at årets ingång: age >= 18 AND age < 23 (i.e. age ≤ 22 on Jan 1). + // The Riksdag betänkande's "19-23-åringar" wording is colloquial — those + // eligible at year start (18-22) become 19-23 during the year. We test the + // year-start age, not the during-year age. Skatteverket's AGI validator + // rejects 23-year-olds at year start as not eligible. + // Active period: 1 April 2026 - 30 September 2027. + if (config.avgifterYouthRate !== null && ageAtYearStart >= 18 && ageAtYearStart <= 22) { const [, monthStr] = input.paymentDate.split('-') const month = parseInt(monthStr) - // Youth rate valid Apr 2026 - Sep 2027 const isYouthPeriod = (paymentYear === 2026 && month >= 4) || (paymentYear === 2027 && month <= 9) if (isYouthPeriod) { steps.push({ label: 'Avgiftskategori', - formula: `Ungdomsrabatt (${ageAtYearStart} år): ${fmtPct(config.avgifterYouthRate)} på första ${fmtKr(config.avgifterYouthSalaryCap ?? 0)}`, - input: { age: ageAtYearStart, cap: config.avgifterYouthSalaryCap ?? 0 }, + formula: `Ungdomsrabatt (vid årets ingång ${ageAtYearStart} år): ${fmtPct(config.avgifterYouthRate)} på första ${fmtKr(config.avgifterYouthSalaryCap ?? 0)}/mån`, + input: { age_at_year_start: ageAtYearStart, cap: config.avgifterYouthSalaryCap ?? 0 }, output: null, }) return { rate: config.avgifterYouthRate, amount: 0, basis: 0, category: 'youth', steps } diff --git a/lib/salary/payment/bg-lb-generator.ts b/lib/salary/payment/bg-lb-generator.ts index c4771afe..f8ea5427 100644 --- a/lib/salary/payment/bg-lb-generator.ts +++ b/lib/salary/payment/bg-lb-generator.ts @@ -309,6 +309,13 @@ function pad(value: string, length: number): string { * * For 5-digit clearings starting with "8": digits 1-4 go to the clearing field, * the 5th digit becomes the first digit of the 10-position account field. + * + * Nordea Personkonto with 11-digit account number: the displayed account + * already includes the 4-digit clearing as its leading digits (e.g. clearing + * 1708 + account 17082042825). The 10-digit BG-LB account field cannot fit + * 11 digits, so we strip the redundant clearing prefix and zero-pad the + * remaining 7 digits to 10. The receiving bank reconstructs the full + * personkonto from clearing + account. */ function encodeReceiverAccount( clearingInput: string, @@ -318,6 +325,11 @@ function encodeReceiverAccount( const account = accountInput.replace(/\D/g, '') if (clearing.length === 4) { + // Nordea Personkonto: account is 11 digits and starts with the clearing. + // Strip the redundant clearing prefix so it fits the 10-digit account field. + if (account.length === 11 && account.startsWith(clearing)) { + return { clearing4: clearing, accountWithSwedbankPrefix: account.slice(4) } + } return { clearing4: clearing, accountWithSwedbankPrefix: account } }