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