* chore: repo-wide bloat sweep, remove dead code and fold duplicate helpers Remove 33 dead files, ~270 unreferenced exports/types, 13 dead i18n namespaces and 4 unused dependencies; fold byte-identical helper copies into one canonical home each (lib/utils chunk/sleep/utcDateStamp, lib/dates/iso, lib/invariants/uuid, lib/xml/escape, lib/reports/sru/format, lib/pdf/number-text, lib/browser/panel-request, lib/api/v1/body + v1ValidationError rolled out to ~55 v1 routes, booking-template schemas). No behaviour change: v1 bodies and status codes, MCP tool schemas, DB writes and money math are untouched. Naive ore rounding was deliberately not swapped for roundOre; see DECISIONS.md 2026-09-02 for the full list of things left alone on purpose. tsc, lint, 19588 unit tests and check:guards green; antipattern baseline ratcheted (naive-ore-round 622 -> 620, hand-rolled-invariant 115 -> 113). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * test(transactions): import RawTransaction from @/types after the ingest re-export removal CI's type ratchet (check:types, full tsconfig) caught the one test file that still imported the type through lib/transactions/ingest. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> --------- Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
162 lines
5.0 KiB
TypeScript
162 lines
5.0 KiB
TypeScript
import type { PayrollConfig } from './payroll-config'
|
||
import type { MileageVehicleType } from '@/types'
|
||
|
||
/**
|
||
* Traktamente (per diem) and milersättning (mileage) calculations.
|
||
* Implements Skatteverket's schablonbelopp and tremånadersregeln.
|
||
*/
|
||
|
||
export interface TraktamenteStep {
|
||
label: string
|
||
formula: string
|
||
input: Record<string, number | string>
|
||
output: number
|
||
}
|
||
|
||
export type TripType = 'full_day' | 'half_day' | 'night'
|
||
export type MealsProvided = 'none' | 'breakfast' | 'lunch' | 'dinner' | 'lunch_dinner' | 'all'
|
||
|
||
/**
|
||
* Calculate traktamente for a domestic trip.
|
||
*
|
||
* Tax-free schabloner 2026:
|
||
* - Full day (>24h): 300 SEK
|
||
* - Half day (>6h): 150 SEK
|
||
* - Night: 150 SEK
|
||
*
|
||
* Tremånadersregeln:
|
||
* - After 3 consecutive months at same location: 70% of max
|
||
* - After 2 years: 50% of max
|
||
* - Break ≥4 weeks resets counter
|
||
*/
|
||
export function calculateTraktamente(params: {
|
||
tripType: TripType
|
||
days: number
|
||
mealsProvided: MealsProvided
|
||
consecutiveMonths: number // 0 = no reduction
|
||
config: PayrollConfig
|
||
}): { taxFree: number; taxable: number; totalPaid: number; steps: TraktamenteStep[] } {
|
||
const r = (x: number) => Math.round(x * 100) / 100
|
||
const steps: TraktamenteStep[] = []
|
||
|
||
// Base rate
|
||
let baseRate: number
|
||
switch (params.tripType) {
|
||
case 'full_day': baseRate = params.config.traktamenteHeldag; break
|
||
case 'half_day': baseRate = params.config.traktamenteHalvdag; break
|
||
case 'night': baseRate = params.config.traktamenteNatt; break
|
||
}
|
||
|
||
// Tremånadersregeln reduction
|
||
let reductionFactor = 1.0
|
||
if (params.consecutiveMonths >= 24) {
|
||
reductionFactor = 0.50
|
||
} else if (params.consecutiveMonths >= 3) {
|
||
reductionFactor = 0.70
|
||
}
|
||
|
||
const maxTaxFreePerDay = r(baseRate * reductionFactor)
|
||
|
||
if (reductionFactor < 1.0) {
|
||
steps.push({
|
||
label: `Tremånadersregeln (${params.consecutiveMonths} mån)`,
|
||
formula: `base × ${reductionFactor * 100}%`,
|
||
input: { base_rate: baseRate, months: params.consecutiveMonths },
|
||
output: maxTaxFreePerDay,
|
||
})
|
||
}
|
||
|
||
// Meal reductions per Skatteverket (from max tax-free amount)
|
||
// Breakfast: 15%, Lunch: 35%, Dinner: 35%, All three: 85%
|
||
let mealReduction = 0
|
||
if (params.tripType === 'full_day') {
|
||
switch (params.mealsProvided) {
|
||
case 'breakfast': mealReduction = r(baseRate * 0.15); break
|
||
case 'lunch': case 'dinner': mealReduction = r(baseRate * 0.35); break
|
||
case 'lunch_dinner': mealReduction = r(baseRate * 0.70); break
|
||
case 'all': mealReduction = r(baseRate * 0.85); break
|
||
}
|
||
}
|
||
|
||
const taxFreePerDay = r(Math.max(maxTaxFreePerDay - mealReduction, 0))
|
||
const taxFree = r(taxFreePerDay * params.days)
|
||
|
||
steps.push({
|
||
label: 'Skattefritt traktamente',
|
||
formula: '(max_tax_free - meal_reduction) × days',
|
||
input: {
|
||
max_per_day: maxTaxFreePerDay,
|
||
meal_reduction: mealReduction,
|
||
days: params.days,
|
||
},
|
||
output: taxFree,
|
||
})
|
||
|
||
// If employer pays more than tax-free amount, excess is taxable
|
||
const totalPaid = r(baseRate * params.days) // Employer typically pays full rate
|
||
const taxable = r(Math.max(totalPaid - taxFree, 0))
|
||
|
||
if (taxable > 0) {
|
||
steps.push({
|
||
label: 'Skattepliktigt traktamente',
|
||
formula: 'total_paid - tax_free',
|
||
input: { total_paid: totalPaid, tax_free: taxFree },
|
||
output: taxable,
|
||
})
|
||
}
|
||
|
||
return { taxFree, taxable, totalPaid, steps }
|
||
}
|
||
|
||
// ============================================================
|
||
// Milersättning (Mileage Allowance)
|
||
// ============================================================
|
||
|
||
export type VehicleType = MileageVehicleType
|
||
|
||
/**
|
||
* Calculate milersättning.
|
||
*
|
||
* Tax-free rates (2024-2026, unchanged):
|
||
* - Own car: 25 SEK/mil (2.50 SEK/km)
|
||
* - Company car (fossil): 12 SEK/mil
|
||
* - Company car (electric/hybrid): 9.50 SEK/mil
|
||
*
|
||
* Amount exceeding tax-free = taxable + full avgifter.
|
||
* Requires körjournal (7-year retention).
|
||
*/
|
||
export function calculateMileageAllowance(params: {
|
||
mil: number // Swedish mil (1 mil = 10 km)
|
||
vehicleType: VehicleType
|
||
paidPerMil: number // What employer actually pays per mil
|
||
config: PayrollConfig
|
||
}): { taxFree: number; taxable: number; steps: TraktamenteStep[] } {
|
||
const r = (x: number) => Math.round(x * 100) / 100
|
||
|
||
let taxFreeRate: number
|
||
switch (params.vehicleType) {
|
||
case 'own_car': taxFreeRate = params.config.milersattningEgenBil; break
|
||
case 'company_car_fossil': taxFreeRate = params.config.milersattningFormansbilFossil; break
|
||
case 'company_car_electric': taxFreeRate = params.config.milersattningFormansbilEl; break
|
||
}
|
||
|
||
const taxFree = r(Math.min(params.paidPerMil, taxFreeRate) * params.mil)
|
||
const totalPaid = r(params.paidPerMil * params.mil)
|
||
const taxable = r(Math.max(totalPaid - taxFree, 0))
|
||
|
||
return {
|
||
taxFree,
|
||
taxable,
|
||
steps: [{
|
||
label: 'Milersättning',
|
||
formula: `${params.mil} mil × ${params.paidPerMil} SEK/mil (skattefritt max ${taxFreeRate})`,
|
||
input: {
|
||
mil: params.mil,
|
||
paid_per_mil: params.paidPerMil,
|
||
tax_free_rate: taxFreeRate,
|
||
},
|
||
output: totalPaid,
|
||
}],
|
||
}
|
||
}
|