Files
accounted/lib/salary/benefits.ts
T
Jakob Wennberg ec27228a8e style: remove em/en dashes repo-wide, add CLAUDE.md rule against them (#890)
Em dashes (—) and en dashes (–) had spread across comments, docs, tests,
and a few UI strings, reading as AI-generated boilerplate rather than
house style. Replaced each with punctuation matching its context: colon
for explanatory clauses, comma for asides, plain hyphen for numeric/legal
ranges (e.g. "21-23§"), "to"/"till" for date ranges, parentheses for
paired-dash asides. messages/en.json and messages/sv.json were fixed by
hand together to keep sv/en in sync.

Left untouched where the dash is the functional subject rather than
decorative punctuation: date-range-parser.ts's separator regex,
charset-repair.ts's CP1252 byte-mapping table (and its test), the SIE
encoding mojibake docs, generic-csv.ts's minus-sign normalizer, the
agent system-prompt files that already instruct against em dashes, and
a golden iXBRL test fixture compared byte-for-byte.

Also fixes two bugs surfaced along the way: an off-by-one in
ApiKeysPanel's scope-label split (a leftover from an earlier partial
pass), and a charset-repair test that had lost the literal en-dash it
exists to verify.

Regenerated the agent atom seed migration (skills:generate) since 27
SKILL.md files changed. Added a CLAUDE.md rule against em/en dashes,
with an explicit carve-out for the functional-dash cases above.

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-04 15:58:06 +02:00

203 lines
6.5 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import type { PayrollConfig } from './payroll-config'
/**
* Swedish benefit value calculations (förmånsbeskattning).
* Implements Skatteverket's rules for taxable benefits.
*/
export interface BenefitStep {
label: string
formula: string
input: Record<string, number | string>
output: number
}
// ============================================================
// Car Benefit (Bilförmån): Generation 3 (≥July 1, 2021)
// ============================================================
export interface CarBenefitParams {
nybilspris: number // New car price including options
fordonsskatt: number // Annual vehicle tax
isEnvironmental: boolean // Elbil/laddhybrid/gasbil
environmentalType?: 'electric' | 'plugin_hybrid' | 'gas'
highMileage: boolean // ≥30,000 km/year (25% reduction)
}
/**
* Calculate monthly car benefit value (förmånsvärde bilförmån).
*
* Gen3 formula (≥July 2021):
* annual = 0.29 × PBB + nybilspris × (0.70 × SLR + 0.01) + 0.13 × nybilspris + fordonsskatt
*
* Environmental reductions on nybilspris:
* - Elbil/vätgas: -350,000 (max 50% of nybilspris)
* - Laddhybrid: -140,000 (max 50%)
* - Gasbil: -100,000 (max 50%)
*
* High mileage (≥30,000 km/year): 25% reduction on total
*/
export function calculateCarBenefit(
params: CarBenefitParams,
config: PayrollConfig
): { monthlyValue: number; annualValue: number; steps: BenefitStep[] } {
const r = (x: number) => Math.round(x * 100) / 100
const steps: BenefitStep[] = []
let adjustedPrice = params.nybilspris
// Environmental reduction
if (params.isEnvironmental && params.environmentalType) {
const reductions: Record<string, number> = {
electric: 350000,
plugin_hybrid: 140000,
gas: 100000,
}
const maxReduction = Math.min(reductions[params.environmentalType] || 0, params.nybilspris * 0.5)
adjustedPrice = params.nybilspris - maxReduction
steps.push({
label: `Miljöbilsreduktion (${params.environmentalType})`,
formula: 'nybilspris - reduction (max 50%)',
input: { nybilspris: params.nybilspris, reduction: maxReduction },
output: adjustedPrice,
})
}
const pbbComponent = r(0.29 * config.prisbasbelopp)
const rateComponent = r(adjustedPrice * (0.70 * config.bilformanSlr + 0.01))
const percentComponent = r(0.13 * adjustedPrice)
const annualValue = r(pbbComponent + rateComponent + percentComponent + params.fordonsskatt)
steps.push({
label: 'Bilförmån (Gen3)',
formula: '0.29×PBB + pris×(0.70×SLR+0.01) + 0.13×pris + fordonsskatt',
input: {
pbb: config.prisbasbelopp,
slr: config.bilformanSlr,
adjusted_price: adjustedPrice,
fordonsskatt: params.fordonsskatt,
},
output: annualValue,
})
let finalAnnual = annualValue
if (params.highMileage) {
finalAnnual = r(annualValue * 0.75)
steps.push({
label: 'Milreduktion (≥30 000 km/år)',
formula: 'annual × 75%',
input: { annual: annualValue },
output: finalAnnual,
})
}
const monthlyValue = r(finalAnnual / 12)
steps.push({
label: 'Månatligt förmånsvärde',
formula: 'annual / 12',
input: { annual: finalAnnual },
output: monthlyValue,
})
return { monthlyValue, annualValue: finalAnnual, steps }
}
// ============================================================
// Meal Benefit (Kostförmån)
// ============================================================
export type MealType = 'full_day' | 'lunch' | 'breakfast'
/**
* Get meal benefit value (schablonvärde).
* If employee pays ≥ schablonvärde via nettolöneavdrag, benefit is eliminated.
*/
export function getMealBenefitValue(mealType: MealType, config: PayrollConfig): number {
switch (mealType) {
case 'full_day': return config.kostformanHeldag
case 'lunch': return config.kostformanLunch
case 'breakfast': return config.kostformanFrukost
}
}
// ============================================================
// Bike Benefit (Cykelförmån)
// ============================================================
/**
* Annual tax-free allowance for cykelförmån, in SEK.
* Per Skatteverket schablon effective from 2022: the first 3 000 kr/year of
* a bike benefit is tax-free; the excess is taxable. The employer must offer
* the benefit on equal terms to all employees.
*/
export const BIKE_BENEFIT_TAX_FREE_ALLOWANCE = 3000
/**
* Calculate monthly taxable cykelförmån from an annual market value.
*
* taxable_annual = max(0, annual_market_value - 3000)
* taxable_monthly = taxable_annual / 12
*
* `annualMarketValue` is the yearly värde av förmånen, typically the
* marknadsmässiga hyran or the subscription cost the employer pays.
*/
export function calculateBikeBenefit(annualMarketValue: number): {
monthlyValue: number
annualTaxable: number
taxFreePortion: number
steps: BenefitStep[]
} {
const r = (x: number) => Math.round(x * 100) / 100
const annual = Math.max(0, annualMarketValue)
const taxFreePortion = Math.min(annual, BIKE_BENEFIT_TAX_FREE_ALLOWANCE)
const annualTaxable = r(annual - taxFreePortion)
const monthlyValue = r(annualTaxable / 12)
return {
monthlyValue,
annualTaxable,
taxFreePortion,
steps: [{
label: 'Cykelförmån (schablon)',
formula: 'max(0, årligt marknadsvärde 3 000 kr skattefritt) / 12',
input: {
annual_market_value: annual,
tax_free_allowance: BIKE_BENEFIT_TAX_FREE_ALLOWANCE,
annual_taxable: annualTaxable,
},
output: monthlyValue,
}],
}
}
// ============================================================
// Wellness Benefit (Friskvårdsbidrag)
// ============================================================
/**
* Check wellness benefit tax status.
* Tax-free if total ≤ cap (5,000 SEK/year).
* If exceeded, ENTIRE amount becomes taxable (not just excess).
*/
export function calculateWellnessBenefit(
amount: number,
ytdWellness: number,
config: PayrollConfig
): { taxable: boolean; taxableAmount: number; steps: BenefitStep[] } {
const totalYtd = ytdWellness + amount
const taxable = totalYtd > config.friskvardCap
return {
taxable,
taxableAmount: taxable ? totalYtd : 0, // Entire amount if exceeded
steps: [{
label: 'Friskvårdsbidrag',
formula: taxable
? `YTD ${totalYtd} > ${config.friskvardCap}: hela beloppet skattepliktigt`
: `YTD ${totalYtd}${config.friskvardCap}: skattefritt`,
input: { amount, ytd: ytdWellness, cap: config.friskvardCap },
output: taxable ? totalYtd : 0,
}],
}
}