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
This commit is contained in:
Mattsson
2026-05-09 23:55:50 +02:00
committed by GitHub
parent 27969c5260
commit 993b3f5962
7 changed files with 165 additions and 8 deletions
+1 -1
View File
@@ -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.
@@ -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.
+10
View File
@@ -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))
}
@@ -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')
})
})
+1 -1
View File
@@ -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
+11 -5
View File
@@ -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 }
+12
View File
@@ -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 }
}