04dbb31d7e
* feat: implement salary module with personnummer encryption, salary entries, tax tables, and AGI tracking - Added personnummer encryption and decryption functions for secure storage. - Created salary entries handling for journal entries including gross salary, tax withholding, and employer contributions. - Implemented tax table lookup functionality for calculating tax amounts based on monthly income. - Developed SQL migration for salary module including tables for payroll configuration, tax rates, employees, salary runs, salary run employees, salary line items, and AGI declarations. - Established row-level security policies for all new tables to ensure company-scoped access. * feat: add salary calculation modules for 2026 - Implemented engångsskatt calculation for one-time payments with tax brackets. - Added löneväxling functionality for salary sacrifice to pension, including employer savings and warnings. - Created pain.001 generator for salary batch payments in compliance with Swedish banking standards. - Developed PDF template for payslips, including detailed breakdowns and employer costs. - Generated seed data for Swedish tax tables for 2026, including SQL insert statements. - Implemented traktamente calculations for per diem and mileage allowances, adhering to Skatteverket regulations. - Added seed script for populating tax tables in the database. * feat: Update meal reduction percentages in traktamente calculation fix: Remove obsolete seed script for 2026 tax tables feat: Extend SalaryRunStatus type to include 'corrected' status feat: Implement KU10 XML generation endpoint for annual employee income statements feat: Add endpoint for creating corrections to booked salary runs feat: Implement endpoint for sending payslip PDFs to employees feat: Create KU10 XML generator for annual reporting feat: Add salary transaction matcher for auto-linking bank transactions to salary entries chore: Add database migration for salary correction support * feat: replace select elements with custom Select component for employment and salary types * feat: enhance salary calculations with pension entry and avgifter category support
161 lines
5.0 KiB
TypeScript
161 lines
5.0 KiB
TypeScript
import type { PayrollConfig } from './payroll-config'
|
||
|
||
/**
|
||
* 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 = 'own_car' | 'company_car_fossil' | 'company_car_electric'
|
||
|
||
/**
|
||
* 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,
|
||
}],
|
||
}
|
||
}
|