b6332e9ff4
* feat(salary): one-click AGI submission with filing state machine and success feedback The AGI panel required users to know that "Ladda ner AGI-fil" was the generate step, then click submit, signing link, and kvittens manually. A nollkorning filing stalled on "AGI-XML saknas" pointing at a UI path that does not exist. - New primary button "Lamna in till Skatteverket" chains the existing endpoints client-side: generate XML if missing, POST underlag, poll kontrollresultat, create signing link, open Mina Sidor in a tab opened synchronously at click (popup-blocker safe). Inline stepper shows each step; the four old buttons become collapsed advanced/recovery actions, auto-expanded in stale-draft and rejected states. XML download stays visible and free for manual filing. - deriveAgiFilingState() + useAgiSubmission() lift the per-period submission record to the run page: the progress rail and salary hero now render the real state machine (generated, underlag inskickat, vantar pa BankID-signatur, inlamnad med kvittensnummer) instead of telling users to "lamna in" an already-submitted declaration. - Success card with kvittensnummer and signature metadata once signed, plus a toast when a poll flips the state while the page is open. - AGI kvittens cron every 15 min instead of every 2 h so filings signed on another device get stamped and emailed promptly. - Advanced submit also auto-generates, and the stale "Lon -> AGI -> Generera" error text now points at the real buttons. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(enable-banking): instant OAuth callback feedback and dead-attempt cleanup The bank redirect landed on a blank page for the several seconds the callback spent exchanging the PSD2 session and mirroring accounts, and every failed connect attempt left a status='error' row that rendered forever as an "Atgard kravs" card next to a successful retry, showing duplicate connections to the same bank. - Stream a branded "Slutfor bankanslutningen" progress page from the callback: the shell flushes before the session exchange starts and a script/meta redirect follows when the work completes, with a 30s slow-work escape hatch. Fast outcomes (denial, bad params, unknown state) keep their plain redirects. - Delete never-activated connection rows (no session_id, no accounts_data) on denial or exchange failure, and sweep leftovers for the same bank on the next connect. Established connections keep their "Atgard krävs" card via the accounts_data guard; FKs are ON DELETE SET NULL so deletion has no dependents. - Show "Banken ar ansluten: hamtar dina konton" while the settings panel loads after the callback instead of an anonymous spinner. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(invoices): reject re-send of issued invoices and gate bookkeeping on the sent flip A direct POST to /api/invoices/[id]/send against an already-issued invoice re-emailed the customer and posted a second revenue verifikat (createInvoiceJournalEntry has no dedup), overwriting journal_entry_id and orphaning the first entry. Only the UI hid the button; the v1 route and the MCP commit executor already rejected non-drafts. - Non-draft invoices now return 409 INVOICE_ALREADY_SENT. - The draft to sent status flip is an optimistic lock (status guard plus row-count check); journal entry, accrual schedules, PDF archival and the invoice.sent event only run for the request that won the flip. - On a flip failure the journal entry is deferred: the row stays draft and a retry re-runs the pipeline, ending with exactly one verifikat. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(invoices): payment links, failure visibility and sandbox guard for recurring auto-send - sendInvoiceFromSchedule now auto-creates an online payment link via applyPaymentLinkToInvoice before rendering and passes the payment link QR to the PDF: parity with the dashboard and v1 send routes, which recurring invoices silently lacked. - The recurring cron persists last_run_warning both when a claimed run throws (hourly retries stay visible on the schedule) and when a stale schedule is rolled forward, so a deterministic failure can no longer skip a month silently. - Auto-send is blocked for sandbox companies at the email chokepoint (freeze-and-retain: the invoice is still generated as a draft), covering both the cron and the run-now route with one guard. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(salary): close the Fortnox payroll API gaps (phases 1-4) Payroll now runs end-to-end through the open API, including onboarding a client from another payroll system, with every write staged for approval. - v1: per-employee payslips (list/detail/PDF), payslip line writes, run roster attach/remove, absence ranges (per-day storage), jamkning fields, cutover opening balances (single + atomic bulk PUT), vacation balance + vacation-year-close. PUT added to the wrapper's idempotency/ test-key set (test keys could otherwise write through PUT). - MCP: 10 new tools (get_employee/get_payslip/list_absence/ get_vacation_balance reads + staged update_payslip_line, register_absence, create_employee, update_employee, set_employee_opening_balances, close_vacation_year), executors, risk tiers, op-type CHECK expansions. create_employee encrypts personnummer at staging: pending_operations never holds plaintext. - Scope-map audit retrofit: 11 formerly unmapped tools now scoped; BREAKING for keys that relied on the 4 default-allow writes. - Cutover: employee_opening_balances (derived lock trigger, self-unlocks on run correction), engine YTD/karens/liability integration, Ingaende saldon section in the employee editor. - Arbetsschema-lite: employees.hours_per_week/workdays_per_week drive the hourly/daily divisors; legacy 173/21 preserved exactly at defaults so existing pay math is byte-identical. - Vacation ledger + semesterberedning/arsavslut: recomputed per-year day balances (synced on book/correct, non-fatal), year-close with the min-20 floor, 5-year sparade-dagar expiry to forced payout, and a 2920/2940 drift adjustment via the bookkeeping engine; Semester dashboard card with preview-then-confirm dialog. - Fix: Zod 4 defaults leak through .partial(), which made every sparse employee PATCH fail validation and reset defaulted columns. Migrations 20260713100000/101000/110000/121000/122000 (applied to staging with version rows; prod via merge). vacation_ledger renamed from 20260713120000 to avoid colliding with vat_declaration_totals_rpc. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * perf: cut dashboard page-load latency (region, round trips, caching, VAT RPC) The dominant cost was infrastructure: Vercel functions ran in iad1 (Washington D.C.) while Supabase (DB + auth) lives in eu-north-1 (Stockholm), so every request paid 4-5 transatlantic round trips of auth + company resolution before doing any real work (measured 530-1900ms for single-query GETs in prod logs). Pin functions to arn1 and cut the redundant work on top: - vercel.json: functions to arn1, same city as the database - getActiveCompanyId: preference + first-membership queries run in parallel; the fallback result doubles as validation in the common single-company case (one round trip instead of two sequential) - withRouteContext: Server-Timing header and authMs/companyMs/handlerMs in the op-completed log, so latency is attributable per phase - dashboard layout: nav badge counts off the critical path; DashboardNav loads them client-side via the new use-worklist-badges SWR hook with debounced realtime revalidation - swr (new dependency, approved): global provider; useCompanySettings shares one cache entry across consumers and renders from cache on back-navigation instead of re-showing skeletons - /pending: realtime refetch debounced; bulk operations previously fired 4 requests per row-change event - VAT declaration: new get_vat_declaration_totals RPC returns per-account totals, settlement-shape detection (#984) and source_type counts in ONE round trip instead of paging every entry+line through PostgREST. Account lists stay TS-side parameters so ACCOUNT_RUTA remains the single source of truth. Shape-exclusion coverage moved to tests/pg/vat-declaration-totals-rpc.pg.test.ts; DDL already applied to staging. - bundle: CommandPalette lazy-mounts on first Ctrl/Cmd+K, AgentChat dynamic-imports the markdown parser, @vercel/speed-insights (new dependency, approved) added for real-user timings The /salary fetch-waterfall fix from the same effort already landed inside 2084a756. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(invoices): settle öre-rounded payments from the mark-paid flow An invoice with öresavrundning shows a rounded "Att betala" on the PDF; the customer pays that amount (up to 50 öre off the stored öre total) and the invoice-page mark-paid flow rejected it with MATCH_AMOUNT_EXCEEDS_REMAINING: a dead end, while the bank-transaction match flow already absorbed the residual to 3740. - PaymentBookingDialog now proposes the rounded bank leg plus the 3740 residual line (credit when rounded up, debit when rounded down), resolved via getDisplayTotal from the per-invoice override and company_settings.ore_rounding. - settleInvoicePayment and the v1 mark-paid route absorb the sub-krona residual, gated by planInvoicePaymentForLines: absorption applies ONLY when the caller lines carry the exact residual on 3740; otherwise the strict plan applies (sub-krona partials stay partial, no-3740 overshoots keep the 400), so the GL can never diverge from the AR sub-ledger. - planInvoicePayment absorb-band boundary tightened to >= 1 kr: an exactly-1-kr overshoot used to slip past both the guard and the absorb branch and silently over-record paid_amount (pre-existing on the bank-match path). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(security): resolve all 7 PR compliance findings - ASVS V3.3: per-request CSP nonce on the enable-banking finalize page (mirrors the mcp-oauth consent page); inline scripts are nonce-bound - ASVS V16: decouple callback finalize work from the response stream (eager promise + next/server after()) so a client disconnect cannot drop session persistence or the consent_granted audit emit - ISO 27001 A.8.15: failed audit-event emits log through the structured logger with a stable message for log-based alerting - ASVS V2.3: recurring-invoice cron and run-now routes resolve isSandboxCompany themselves and pass an explicit suppressAutoSend flag (defence in depth around the email chokepoint, freeze-and-retain kept) - ISO 27001 A.8.11: stagePendingOperation rejects plaintext personnummer-bearing keys in params/preview_data (key-based guard; EF org numbers make value-matching unsafe) - ASVS V4.5: employee PATCH body is truly sparse; cleared number fields are omitted instead of resetting DB values to hardcoded fallbacks - ASVS V8.2.1: route-level tests pin the v1 cross-company deny (404 by convention, not 403) on the payslip PDF endpoint Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat: implement vacation-year basis change validation and error handling - Added tests to block vacation-year basis changes when open balances exist. - Implemented error handling for open-balances guard query failures in the settings route. - Enhanced absence route to reject reversed date ranges with a validation error. - Updated absence handling to use atomic upserts instead of delete+insert for better performance and reliability. - Refactored salary calculation logic to correctly handle age-based avgifter rates according to Skatteverket's rules. - Improved error messaging for vacation year closure adjustments. - Adjusted employee opening balances handling to preserve audit information during upserts. * feat(settings): add validation to block vacation-year basis change with open balances feat(absence): reject reversed date ranges in absence queries fix(absence): update absence handling to use atomic upserts instead of delete+insert fix(employee): improve validation for jamkning dates in employee updates fix(opening-balances): ensure created_by field is preserved during upserts test(absence): enhance tests for absence range and date validations test(calculation): add tests for age-based avgifter rates and edge cases test(semesterberedning): validate vacation year closure adjustments and error handling test(employee-opening-balances): update tests to reflect changes in salary_run_employees schema * fix(migrations): implement NOT VALID constraints for pending_operations and add validation migration --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
841 lines
32 KiB
TypeScript
841 lines
32 KiB
TypeScript
import type { PayrollConfig } from './payroll-config'
|
||
import type { TaxTableRate } from './tax-tables'
|
||
import { lookupTaxAmount, calculateJamkningTax, calculateSidoinkomstTax } from './tax-tables'
|
||
import { calculateAgeAtYearStart, decryptPersonnummer } from './personnummer'
|
||
import type { SalaryLineItemType } from '@/types'
|
||
|
||
// ============================================================
|
||
// Types
|
||
// ============================================================
|
||
|
||
export interface SalaryCalculationInput {
|
||
/** Employee data */
|
||
employmentType: 'employee' | 'company_owner' | 'board_member'
|
||
salaryType: 'monthly' | 'hourly'
|
||
monthlySalary: number
|
||
hourlyRate?: number
|
||
hoursWorked?: number
|
||
employmentDegree: number // 1-100
|
||
|
||
/** Tax */
|
||
taxTableNumber: number | null
|
||
taxColumn: number
|
||
isSidoinkomst: boolean
|
||
jamkningPercentage: number | null
|
||
jamkningValidFrom: string | null
|
||
jamkningValidTo: string | null
|
||
fSkattStatus: string
|
||
|
||
/** Age (from personnummer) */
|
||
personnummer: string // encrypted, will be decrypted for age calc
|
||
paymentDate: string
|
||
|
||
/** Vacation */
|
||
vacationRule: 'procentregeln' | 'sammaloneregeln' | 'none' | 'semesterersattning'
|
||
vacationDaysPerYear: number
|
||
semestertillaggRate: number
|
||
/** Work-schedule daily-rate divisor (arbetsschema-lite). Defaults to the
|
||
* legacy 21 (5-day week); callers with a part-time schedule pass
|
||
* dailyDivisor(workdays_per_week) from lib/salary/work-schedule. Used by
|
||
* the sammalöneregeln day valuation. */
|
||
dailyDivisor?: number
|
||
|
||
/** Växa-stöd */
|
||
vaxaStodEligible: boolean
|
||
vaxaStodStart: string | null
|
||
vaxaStodEnd: string | null
|
||
|
||
/** Line items */
|
||
lineItems: CalculationLineItem[]
|
||
|
||
/**
|
||
* Pay period bounds (YYYY-MM-DD). Together with employmentStart/employmentEnd
|
||
* they drive partial-month proration: an employee hired mid-period or
|
||
* terminated mid-period receives only the workday-fraction of base salary.
|
||
* When omitted, proration is skipped (ratio = 1).
|
||
*/
|
||
periodStart?: string
|
||
periodEnd?: string
|
||
employmentStart?: string
|
||
employmentEnd?: string | null
|
||
}
|
||
|
||
export interface CalculationLineItem {
|
||
itemType: SalaryLineItemType
|
||
amount: number
|
||
isTaxable: boolean
|
||
isAvgiftBasis: boolean
|
||
isVacationBasis: boolean
|
||
isGrossDeduction: boolean
|
||
isNetDeduction: boolean
|
||
}
|
||
|
||
export interface CalculationStep {
|
||
label: string
|
||
formula: string
|
||
input: Record<string, number | string>
|
||
/** Numeric result for the step. `null` for context-only rows (e.g. avgiftskategori) that describe a rule, not a calculation. */
|
||
output: number | null
|
||
}
|
||
|
||
export interface SalaryCalculationResult {
|
||
grossSalary: number
|
||
grossDeductions: number
|
||
benefitValues: number
|
||
taxableIncome: number
|
||
taxWithheld: number
|
||
netDeductions: number
|
||
netSalary: number
|
||
avgifterRate: number
|
||
avgifterAmount: number
|
||
avgifterBasis: number
|
||
avgifterCategory: AvgifterCalculation['category']
|
||
vacationAccrual: number
|
||
vacationAccrualAvgifter: number
|
||
/** Semesterersättning paid out directly (vacation_rule = 'semesterersattning'). 0 otherwise. */
|
||
vacationCompensation: number
|
||
totalEmployerCost: number
|
||
steps: CalculationStep[]
|
||
}
|
||
|
||
export interface AvgifterCalculation {
|
||
rate: number
|
||
amount: number
|
||
basis: number
|
||
category: 'standard' | 'reduced_65plus' | 'youth' | 'vaxa_stod' | 'exempt'
|
||
steps: CalculationStep[]
|
||
}
|
||
|
||
// ============================================================
|
||
// Rounding / formatting helpers
|
||
// ============================================================
|
||
|
||
function r(x: number): number {
|
||
return Math.round(x * 100) / 100
|
||
}
|
||
|
||
/**
|
||
* Sum vacation-basis line items that ADD to base salary (overtime, bonus,
|
||
* etc). Excludes monthly_salary/hourly_salary because those line items mirror
|
||
* the engine's own baseSalary computation: counting them would double the
|
||
* vacation basis.
|
||
*/
|
||
function vacationBasisAdditions(lineItems: CalculationLineItem[]): number {
|
||
return lineItems
|
||
.filter(li => li.isVacationBasis)
|
||
.filter(li => li.itemType !== 'monthly_salary' && li.itemType !== 'hourly_salary')
|
||
.reduce((sum, li) => sum + li.amount, 0)
|
||
}
|
||
|
||
/**
|
||
* Format a rate (0.2081) as a Swedish percentage string ("20,81 %").
|
||
* Strips trailing zeros, uses Swedish comma as decimal separator, and rounds
|
||
* to avoid JS floating-point noise like "20.810000000000002".
|
||
*/
|
||
function fmtPct(decimal: number, decimals = 2): string {
|
||
const pct = decimal * 100
|
||
const rounded = Math.round(pct * 10 ** decimals) / 10 ** decimals
|
||
const str = rounded
|
||
.toFixed(decimals)
|
||
.replace(/\.?0+$/, '')
|
||
.replace('.', ',')
|
||
return `${str} %`
|
||
}
|
||
|
||
/**
|
||
* Format an integer amount with Swedish thousand-separators and "kr" suffix,
|
||
* for embedding inside formula descriptions ("25 000 kr").
|
||
*/
|
||
function fmtKr(amount: number): string {
|
||
return `${Math.round(amount).toLocaleString('sv-SE')} kr`
|
||
}
|
||
|
||
// ============================================================
|
||
// Partial-month proration
|
||
// ============================================================
|
||
|
||
const DAY_MS = 24 * 60 * 60 * 1000
|
||
|
||
function parseIsoDateUtc(s: string): Date {
|
||
return new Date(`${s}T00:00:00Z`)
|
||
}
|
||
|
||
function maxDate(a: string, b: string): string {
|
||
return a >= b ? a : b
|
||
}
|
||
|
||
function minDate(a: string, b: string): string {
|
||
return a <= b ? a : b
|
||
}
|
||
|
||
/**
|
||
* Count Mon-Fri days inclusive between start and end (YYYY-MM-DD). Returns 0
|
||
* when start > end. Swedish bank holidays are NOT excluded: the engine uses
|
||
* the same 21-workday convention used elsewhere (monthlySalary / 21), so a
|
||
* variable workday count that excluded holidays would diverge from the
|
||
* baseline daily rate convention.
|
||
*/
|
||
function countWorkdaysInclusive(start: string, end: string): number {
|
||
if (start > end) return 0
|
||
const startMs = parseIsoDateUtc(start).getTime()
|
||
const endMs = parseIsoDateUtc(end).getTime()
|
||
const totalDays = Math.round((endMs - startMs) / DAY_MS) + 1
|
||
let workdays = 0
|
||
for (let i = 0; i < totalDays; i++) {
|
||
const d = new Date(startMs + i * DAY_MS)
|
||
const dow = d.getUTCDay() // 0 = Sun, 6 = Sat
|
||
if (dow >= 1 && dow <= 5) workdays += 1
|
||
}
|
||
return workdays
|
||
}
|
||
|
||
/**
|
||
* Fraction of the pay period the employee was actually employed, measured in
|
||
* Mon-Fri workdays. Returns 1 when the employee was employed for the full
|
||
* period (or when employment dates / period bounds are missing). Returns 0
|
||
* when the employee was not employed at all during the period.
|
||
*
|
||
* This is the standard Swedish payroll convention for partial-month proration:
|
||
* an employee hired 2026-05-15 gets workdays-in-(May 15-31) / workdays-in-May.
|
||
* Hourly employees are not prorated here: they are paid for actually-worked
|
||
* hours, so the calling code passes salaryType='monthly' to gate this.
|
||
*/
|
||
export function prorateBaseSalaryForPeriod(
|
||
employmentStart: string | undefined,
|
||
employmentEnd: string | null | undefined,
|
||
periodStart: string | undefined,
|
||
periodEnd: string | undefined,
|
||
): number {
|
||
if (!periodStart || !periodEnd) return 1
|
||
if (!employmentStart) return 1
|
||
const effectiveStart = maxDate(employmentStart, periodStart)
|
||
const effectiveEnd = employmentEnd ? minDate(employmentEnd, periodEnd) : periodEnd
|
||
if (effectiveStart > effectiveEnd) return 0
|
||
// Fast path: employment fully covers the period.
|
||
if (employmentStart <= periodStart && (!employmentEnd || employmentEnd >= periodEnd)) {
|
||
return 1
|
||
}
|
||
const overlap = countWorkdaysInclusive(effectiveStart, effectiveEnd)
|
||
const total = countWorkdaysInclusive(periodStart, periodEnd)
|
||
if (total === 0) return 1
|
||
const ratio = overlap / total
|
||
if (ratio < 0) return 0
|
||
if (ratio > 1) return 1
|
||
return ratio
|
||
}
|
||
|
||
// ============================================================
|
||
// Main calculation
|
||
// ============================================================
|
||
|
||
/**
|
||
* Calculate salary for one employee in a salary run.
|
||
* Follows the legally mandated processing order:
|
||
* 1. Base salary
|
||
* 2. Add additions (overtime, bonus, etc.)
|
||
* 3. Subtract absence deductions
|
||
* 4. Apply bruttolöneavdrag (MUST be before tax)
|
||
* 5. Add förmånsvärden to tax base
|
||
* 6. Tax withholding
|
||
* 7. Net salary
|
||
* 8. Employer contributions (avgifter)
|
||
* 9. Vacation accrual
|
||
* 10. Avgifter on vacation accrual
|
||
*/
|
||
export function calculateSalary(
|
||
input: SalaryCalculationInput,
|
||
config: PayrollConfig,
|
||
taxRates: TaxTableRate[]
|
||
): SalaryCalculationResult {
|
||
const steps: CalculationStep[] = []
|
||
|
||
// ─── Step 1: Base salary ───
|
||
let baseSalary: number
|
||
if (input.salaryType === 'monthly') {
|
||
const degreeAdjusted = r(input.monthlySalary * (input.employmentDegree / 100))
|
||
const prorationRatio = prorateBaseSalaryForPeriod(
|
||
input.employmentStart,
|
||
input.employmentEnd,
|
||
input.periodStart,
|
||
input.periodEnd,
|
||
)
|
||
if (prorationRatio < 1 && input.periodStart && input.periodEnd) {
|
||
baseSalary = r(degreeAdjusted * prorationRatio)
|
||
const overlapStart = input.employmentStart && input.employmentStart > input.periodStart
|
||
? input.employmentStart
|
||
: input.periodStart
|
||
const overlapEnd = input.employmentEnd && input.employmentEnd < input.periodEnd
|
||
? input.employmentEnd
|
||
: input.periodEnd
|
||
steps.push({
|
||
label: 'Grundlön (proportionerad anställningsperiod)',
|
||
formula: 'månadslön × (sysselsättningsgrad / 100) × (arbetsdagar i anställning / arbetsdagar i period)',
|
||
input: {
|
||
monthly_salary: input.monthlySalary,
|
||
employment_degree: input.employmentDegree,
|
||
degree_adjusted: degreeAdjusted,
|
||
overlap_start: overlapStart,
|
||
overlap_end: overlapEnd,
|
||
proration_ratio: Math.round(prorationRatio * 10000) / 10000,
|
||
},
|
||
output: baseSalary,
|
||
})
|
||
} else {
|
||
baseSalary = degreeAdjusted
|
||
steps.push({
|
||
label: 'Grundlön',
|
||
formula: 'månadslön × (sysselsättningsgrad / 100)',
|
||
input: { monthly_salary: input.monthlySalary, employment_degree: input.employmentDegree },
|
||
output: baseSalary,
|
||
})
|
||
}
|
||
} else {
|
||
const hours = input.hoursWorked || 0
|
||
const rate = input.hourlyRate || 0
|
||
baseSalary = r(rate * hours)
|
||
steps.push({
|
||
label: 'Grundlön (timavlönad)',
|
||
formula: 'timlön × arbetade timmar',
|
||
input: { hourly_rate: rate, hours_worked: hours },
|
||
output: baseSalary,
|
||
})
|
||
}
|
||
|
||
// ─── Step 2: Add additions ───
|
||
// OB-tillägg + tiered overtime are treated as additions to gross salary on
|
||
// top of the base salary. They were already computed in cash terms by the
|
||
// shift-premium engine before the calc engine ran, so we just sum them in.
|
||
const ADDITION_TYPES: SalaryLineItemType[] = [
|
||
'overtime', 'overtime_50', 'overtime_100',
|
||
'ob_weekday_evening', 'ob_weekend', 'ob_night', 'ob_holiday',
|
||
'bonus', 'commission',
|
||
]
|
||
const additions = input.lineItems.filter(
|
||
li => ADDITION_TYPES.includes(li.itemType) && li.amount > 0
|
||
)
|
||
const totalAdditions = r(additions.reduce((sum, li) => sum + li.amount, 0))
|
||
if (totalAdditions > 0) {
|
||
steps.push({
|
||
label: 'Tillägg (övertid, OB, bonus, provision)',
|
||
formula: 'summa tillägg',
|
||
input: { count: additions.length },
|
||
output: totalAdditions,
|
||
})
|
||
}
|
||
|
||
// ─── Step 3: Subtract absence deductions ───
|
||
const absenceItems = input.lineItems.filter(
|
||
li => ['sick_karens', 'sick_day2_14', 'sick_day15_plus', 'vab', 'parental_leave', 'unpaid_leave', 'vacation'].includes(li.itemType)
|
||
)
|
||
const totalAbsence = r(absenceItems.reduce((sum, li) => sum + li.amount, 0))
|
||
if (totalAbsence !== 0) {
|
||
steps.push({
|
||
label: 'Frånvaro (sjuk, VAB, semester, föräldraledig)',
|
||
formula: 'summa frånvaroposter',
|
||
input: { count: absenceItems.length },
|
||
output: totalAbsence,
|
||
})
|
||
}
|
||
|
||
// ─── Step 4: Bruttolöneavdrag (MUST be before tax) ───
|
||
const grossDeductionItems = input.lineItems.filter(li => li.isGrossDeduction)
|
||
const totalGrossDeductions = r(Math.abs(grossDeductionItems.reduce((sum, li) => sum + li.amount, 0)))
|
||
if (totalGrossDeductions > 0) {
|
||
steps.push({
|
||
label: 'Bruttolöneavdrag',
|
||
formula: 'summa bruttoavdrag',
|
||
input: { count: grossDeductionItems.length },
|
||
output: -totalGrossDeductions,
|
||
})
|
||
}
|
||
|
||
// ─── Step 4b: Semesterersättning (paid out directly per cycle) ───
|
||
// When vacation_rule = 'semesterersattning' the employer pays 12% (or 14.4%
|
||
// for 30+ days) on top of each paycheck instead of accruing semesterlöneskuld.
|
||
// It's part of bruttolön and counts for both tax and avgifter basis.
|
||
let vacationCompensation = 0
|
||
if (input.vacationRule === 'semesterersattning') {
|
||
const rate = input.vacationDaysPerYear >= 30 ? 0.144 : 0.12
|
||
const compensationBasis = r(baseSalary + vacationBasisAdditions(input.lineItems))
|
||
vacationCompensation = r(compensationBasis * rate)
|
||
steps.push({
|
||
label: `Semesterersättning (${fmtPct(rate)})`,
|
||
formula: `semesterunderlag × ${fmtPct(rate)} (betalas ut, ingen avsättning)`,
|
||
input: { compensation_basis: compensationBasis, rate },
|
||
output: vacationCompensation,
|
||
})
|
||
}
|
||
|
||
// Gross salary = base + additions + absence (may be negative for deductions) + semesterersättning - gross deductions
|
||
const grossSalary = r(baseSalary + totalAdditions + totalAbsence + vacationCompensation - totalGrossDeductions)
|
||
steps.push({
|
||
label: 'Bruttolön',
|
||
formula: vacationCompensation > 0
|
||
? 'grundlön + tillägg + frånvaro + semesterersättning − bruttoavdrag'
|
||
: 'grundlön + tillägg + frånvaro − bruttoavdrag',
|
||
input: { base: baseSalary, additions: totalAdditions, absence: totalAbsence, vacation_compensation: vacationCompensation, gross_deductions: totalGrossDeductions },
|
||
output: grossSalary,
|
||
})
|
||
|
||
// ─── Step 5: Add förmånsvärden to tax base ───
|
||
const benefitItems = input.lineItems.filter(
|
||
li => ['benefit_car', 'benefit_housing', 'benefit_meals', 'benefit_wellness', 'benefit_bike', 'benefit_other'].includes(li.itemType)
|
||
)
|
||
const totalBenefits = r(benefitItems.reduce((sum, li) => sum + li.amount, 0))
|
||
if (totalBenefits > 0) {
|
||
steps.push({
|
||
label: 'Förmånsvärden',
|
||
formula: 'summa förmåner',
|
||
input: { count: benefitItems.length },
|
||
output: totalBenefits,
|
||
})
|
||
}
|
||
|
||
const taxableIncome = r(grossSalary + totalBenefits)
|
||
steps.push({
|
||
label: 'Skattegrundande inkomst',
|
||
formula: 'bruttolön + förmåner',
|
||
input: { gross_salary: grossSalary, benefit_values: totalBenefits },
|
||
output: taxableIncome,
|
||
})
|
||
|
||
// ─── Step 6: Tax withholding ───
|
||
let taxWithheld: number
|
||
const paymentYear = parseInt(input.paymentDate.split('-')[0])
|
||
|
||
if (input.fSkattStatus === 'f_skatt') {
|
||
// F-skatt holder: no withholding
|
||
taxWithheld = 0
|
||
steps.push({
|
||
label: 'Skatteavdrag (F-skatt)',
|
||
formula: 'F-skattsedel: inget skatteavdrag görs',
|
||
input: {},
|
||
output: 0,
|
||
})
|
||
} else if (input.fSkattStatus === 'not_verified') {
|
||
// Unverified: flat 30%
|
||
taxWithheld = r(taxableIncome * 0.30)
|
||
steps.push({
|
||
label: 'Skatteavdrag (ej verifierad)',
|
||
formula: 'skattegrundande inkomst × 30 %',
|
||
input: { taxable_income: taxableIncome },
|
||
output: taxWithheld,
|
||
})
|
||
} else if (input.isSidoinkomst) {
|
||
// Sidoinkomst: flat 30%
|
||
taxWithheld = calculateSidoinkomstTax(taxableIncome)
|
||
steps.push({
|
||
label: 'Skatteavdrag (sidoinkomst 30 %)',
|
||
formula: 'skattegrundande inkomst × 30 %',
|
||
input: { taxable_income: taxableIncome },
|
||
output: taxWithheld,
|
||
})
|
||
} else if (input.jamkningPercentage !== null && isJamkningValid(input.jamkningValidFrom, input.jamkningValidTo, input.paymentDate)) {
|
||
// Jämkning
|
||
taxWithheld = calculateJamkningTax(taxableIncome, input.jamkningPercentage)
|
||
steps.push({
|
||
label: `Skatteavdrag (jämkning ${input.jamkningPercentage} %)`,
|
||
formula: `skattegrundande inkomst × ${input.jamkningPercentage} %`,
|
||
input: { taxable_income: taxableIncome, jamkning_percentage: input.jamkningPercentage },
|
||
output: taxWithheld,
|
||
})
|
||
} else if (input.taxTableNumber) {
|
||
// Normal tax table lookup
|
||
taxWithheld = lookupTaxAmount(input.taxTableNumber, input.taxColumn, taxableIncome, taxRates)
|
||
steps.push({
|
||
label: `Skatteavdrag (tabell ${input.taxTableNumber}, kolumn ${input.taxColumn})`,
|
||
formula: `skattetabell ${input.taxTableNumber}, kolumn ${input.taxColumn}, inkomst ${fmtKr(taxableIncome)}`,
|
||
input: { table: input.taxTableNumber, column: input.taxColumn, taxable_income: taxableIncome },
|
||
output: taxWithheld,
|
||
})
|
||
} else {
|
||
// Fallback: flat 30%
|
||
taxWithheld = r(taxableIncome * 0.30)
|
||
steps.push({
|
||
label: 'Skatteavdrag (30 % schablon)',
|
||
formula: 'skattegrundande inkomst × 30 %',
|
||
input: { taxable_income: taxableIncome },
|
||
output: taxWithheld,
|
||
})
|
||
}
|
||
|
||
// ─── Step 7: Net salary ───
|
||
const netDeductionItems = input.lineItems.filter(li => li.isNetDeduction)
|
||
const totalNetDeductions = r(Math.abs(netDeductionItems.reduce((sum, li) => sum + li.amount, 0)))
|
||
|
||
const netSalary = r(grossSalary - taxWithheld - totalNetDeductions)
|
||
steps.push({
|
||
label: 'Nettolön',
|
||
formula: 'bruttolön − skatt − nettoavdrag',
|
||
input: { gross: grossSalary, tax: taxWithheld, net_deductions: totalNetDeductions },
|
||
output: netSalary,
|
||
})
|
||
|
||
// ─── Step 8: Employer contributions (avgifter) ───
|
||
const avgifterCalc = calculateAvgifterRate(input, config, paymentYear)
|
||
const avgifterBasis = r(grossSalary + totalBenefits)
|
||
|
||
// Handle salary caps for youth and växa-stöd:
|
||
// Reduced rate applies only up to the cap, standard rate on the rest
|
||
let avgifterAmount: number
|
||
if (avgifterCalc.category === 'youth' && config.avgifterYouthSalaryCap && avgifterBasis > config.avgifterYouthSalaryCap) {
|
||
const reducedPart = r(config.avgifterYouthSalaryCap * avgifterCalc.rate)
|
||
const standardPart = r((avgifterBasis - config.avgifterYouthSalaryCap) * config.avgifterTotal)
|
||
avgifterAmount = r(reducedPart + standardPart)
|
||
steps.push(...avgifterCalc.steps)
|
||
steps.push({
|
||
label: 'Arbetsgivaravgifter (ungdomsrabatt med tak)',
|
||
formula: `${fmtKr(config.avgifterYouthSalaryCap)} × ${fmtPct(avgifterCalc.rate)} + ${fmtKr(avgifterBasis - config.avgifterYouthSalaryCap)} × ${fmtPct(config.avgifterTotal)}`,
|
||
input: { cap: config.avgifterYouthSalaryCap, reduced: reducedPart, standard: standardPart },
|
||
output: avgifterAmount,
|
||
})
|
||
} else if (avgifterCalc.category === 'vaxa_stod' && config.avgifterVaxaStodCap && avgifterBasis > config.avgifterVaxaStodCap) {
|
||
const reducedPart = r(config.avgifterVaxaStodCap * avgifterCalc.rate)
|
||
const standardPart = r((avgifterBasis - config.avgifterVaxaStodCap) * config.avgifterTotal)
|
||
avgifterAmount = r(reducedPart + standardPart)
|
||
steps.push(...avgifterCalc.steps)
|
||
steps.push({
|
||
label: 'Arbetsgivaravgifter (växa-stöd med tak)',
|
||
formula: `${fmtKr(config.avgifterVaxaStodCap)} × ${fmtPct(avgifterCalc.rate)} + ${fmtKr(avgifterBasis - config.avgifterVaxaStodCap)} × ${fmtPct(config.avgifterTotal)}`,
|
||
input: { cap: config.avgifterVaxaStodCap, reduced: reducedPart, standard: standardPart },
|
||
output: avgifterAmount,
|
||
})
|
||
} else {
|
||
avgifterAmount = r(avgifterBasis * avgifterCalc.rate)
|
||
steps.push(...avgifterCalc.steps)
|
||
steps.push({
|
||
label: 'Arbetsgivaravgifter',
|
||
formula: `avgiftsunderlag × ${fmtPct(avgifterCalc.rate)}`,
|
||
input: { avgifter_basis: avgifterBasis, rate: avgifterCalc.rate },
|
||
output: avgifterAmount,
|
||
})
|
||
}
|
||
|
||
// ─── Step 9: Vacation accrual ───
|
||
// Vacation basis = baseSalary (computed at the top) + any *additional*
|
||
// vacation-basis line items (overtime, bonus, etc). We must NOT add
|
||
// monthly_salary/hourly_salary line items here: those are auto-created at
|
||
// employee-add time and represent the same baseSalary already accounted for.
|
||
const vacationBasis = r(baseSalary + vacationBasisAdditions(input.lineItems))
|
||
let vacationAccrual: number
|
||
if (input.vacationRule === 'none') {
|
||
vacationAccrual = 0
|
||
steps.push({
|
||
label: 'Semesteravsättning (avstängd)',
|
||
formula: 'ingen semesteravsättning bokas: semester ingår i månadslönen',
|
||
input: {},
|
||
output: 0,
|
||
})
|
||
} else if (input.vacationRule === 'semesterersattning') {
|
||
vacationAccrual = 0
|
||
steps.push({
|
||
label: 'Semesteravsättning (semesterersättning betald direkt)',
|
||
formula: 'ingen avsättning: 12 % betalas ut på varje lön',
|
||
input: {},
|
||
output: 0,
|
||
})
|
||
} else if (input.vacationRule === 'procentregeln') {
|
||
const rate = input.vacationDaysPerYear >= 30 ? 0.144 : 0.12
|
||
vacationAccrual = r(vacationBasis * rate)
|
||
steps.push({
|
||
label: `Semesteravsättning (procentregeln ${fmtPct(rate)})`,
|
||
formula: `semesterunderlag × ${fmtPct(rate)}`,
|
||
input: { vacation_basis: vacationBasis, rate },
|
||
output: vacationAccrual,
|
||
})
|
||
} else {
|
||
// Sammalöneregeln (§16a): employee keeps regular salary during vacation
|
||
// + semestertillägg per day (min 0.43%, often 0.8% per CBA)
|
||
// Accrual = tillägg only (salary cost is already in normal monthly expense)
|
||
// The liability (2920) for sammalöneregeln is the tillägg portion,
|
||
// since the base salary is expensed monthly regardless of vacation.
|
||
// Use baseSalary (degree-adjusted): a 50% part-timer's tillägg should be
|
||
// half a full-timer's, not the same.
|
||
const dailyRate = r(baseSalary / (input.dailyDivisor ?? 21))
|
||
const tillagg = r(dailyRate * input.semestertillaggRate * input.vacationDaysPerYear)
|
||
vacationAccrual = tillagg
|
||
steps.push({
|
||
label: `Semesteravsättning (sammalöneregeln, tillägg ${fmtPct(input.semestertillaggRate)})`,
|
||
formula: `dagslön × ${fmtPct(input.semestertillaggRate)} × semesterdagar`,
|
||
input: { daily_rate: dailyRate, semestertillagg_rate: input.semestertillaggRate, vacation_days: input.vacationDaysPerYear },
|
||
output: vacationAccrual,
|
||
})
|
||
}
|
||
|
||
// ─── Step 10: Avgifter on vacation accrual ───
|
||
const vacationAccrualAvgifter = r(vacationAccrual * avgifterCalc.rate)
|
||
steps.push({
|
||
label: 'Arbetsgivaravgifter på semesteravsättning',
|
||
formula: `semesteravsättning × ${fmtPct(avgifterCalc.rate)}`,
|
||
input: { vacation_accrual: vacationAccrual, avgifter_rate: avgifterCalc.rate },
|
||
output: vacationAccrualAvgifter,
|
||
})
|
||
|
||
const totalEmployerCost = r(grossSalary + avgifterAmount + vacationAccrual + vacationAccrualAvgifter)
|
||
steps.push({
|
||
label: 'Total arbetsgivarkostnad',
|
||
formula: 'bruttolön + avgifter + semesteravsättning + avgifter på semester',
|
||
input: { gross: grossSalary, avgifter: avgifterAmount, vacation_accrual: vacationAccrual, vacation_avgifter: vacationAccrualAvgifter },
|
||
output: totalEmployerCost,
|
||
})
|
||
|
||
return {
|
||
grossSalary,
|
||
grossDeductions: totalGrossDeductions,
|
||
benefitValues: totalBenefits,
|
||
taxableIncome,
|
||
taxWithheld,
|
||
netDeductions: totalNetDeductions,
|
||
netSalary,
|
||
avgifterRate: avgifterCalc.rate,
|
||
avgifterAmount,
|
||
avgifterBasis,
|
||
avgifterCategory: avgifterCalc.category,
|
||
vacationAccrual,
|
||
vacationAccrualAvgifter,
|
||
vacationCompensation,
|
||
totalEmployerCost,
|
||
steps,
|
||
}
|
||
}
|
||
|
||
// ============================================================
|
||
// Avgifter calculation
|
||
// ============================================================
|
||
|
||
/**
|
||
* Determine arbetsgivaravgifter rate based on employee age, växa-stöd, etc.
|
||
*/
|
||
export function calculateAvgifterRate(
|
||
input: SalaryCalculationInput,
|
||
config: PayrollConfig,
|
||
paymentYear: number
|
||
): AvgifterCalculation {
|
||
const steps: CalculationStep[] = []
|
||
|
||
// Decrypt personnummer to calculate age
|
||
let pnr: string
|
||
try {
|
||
pnr = decryptPersonnummer(input.personnummer)
|
||
} catch {
|
||
// If decryption fails, assume standard rate
|
||
return {
|
||
rate: config.avgifterTotal,
|
||
amount: 0,
|
||
basis: 0,
|
||
category: 'standard',
|
||
steps: [{
|
||
label: 'Avgiftskategori',
|
||
formula: `Standard ${fmtPct(config.avgifterTotal)} (personnummer kunde inte dekrypteras)`,
|
||
input: {},
|
||
output: null,
|
||
}],
|
||
}
|
||
}
|
||
|
||
const ageAtYearStart = calculateAgeAtYearStart(pnr, paymentYear)
|
||
|
||
// Born ≤1937: 0%
|
||
const birthYear = parseInt(pnr.slice(0, 4))
|
||
if (birthYear <= 1937) {
|
||
steps.push({
|
||
label: 'Avgiftskategori',
|
||
formula: 'Född 1937 eller tidigare: inga arbetsgivaravgifter',
|
||
input: { birth_year: birthYear },
|
||
output: null,
|
||
})
|
||
return { rate: 0, amount: 0, basis: 0, category: 'exempt', steps }
|
||
}
|
||
|
||
// 67+ at year start (reduced: only ålderspension)
|
||
if (ageAtYearStart >= config.reducedAvgiftAge) {
|
||
steps.push({
|
||
label: 'Avgiftskategori',
|
||
formula: `Ålder ${ageAtYearStart} år: reducerad avgift ${fmtPct(config.avgifterReduced65plus)} (endast ålderspensionsavgift)`,
|
||
input: { age: ageAtYearStart, threshold: config.reducedAvgiftAge },
|
||
output: null,
|
||
})
|
||
return { rate: config.avgifterReduced65plus, amount: 0, basis: 0, category: 'reduced_65plus', steps }
|
||
}
|
||
|
||
// Växa-stöd eligible
|
||
if (input.vaxaStodEligible && input.vaxaStodStart && input.vaxaStodEnd) {
|
||
const payDate = input.paymentDate
|
||
if (payDate >= input.vaxaStodStart && payDate <= input.vaxaStodEnd && config.avgifterVaxaStodRate !== null) {
|
||
steps.push({
|
||
label: 'Avgiftskategori',
|
||
formula: `Växa-stöd ${fmtPct(config.avgifterVaxaStodRate ?? 0)} på första ${fmtKr(config.avgifterVaxaStodCap ?? 0)}`,
|
||
input: { vaxa_cap: config.avgifterVaxaStodCap ?? 0 },
|
||
output: null,
|
||
})
|
||
return { rate: config.avgifterVaxaStodRate ?? config.avgifterTotal, amount: 0, basis: 0, category: 'vaxa_stod', steps }
|
||
}
|
||
}
|
||
|
||
// 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.
|
||
// calculateAgeAtYearStart is birth-year based (2026: born 2003-2007), so
|
||
// January 1 birthdays land in the correct Skatteverket cohort.
|
||
// 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)
|
||
const isYouthPeriod = (paymentYear === 2026 && month >= 4) || (paymentYear === 2027 && month <= 9)
|
||
if (isYouthPeriod) {
|
||
steps.push({
|
||
label: 'Avgiftskategori',
|
||
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 }
|
||
}
|
||
}
|
||
|
||
// Standard rate
|
||
steps.push({
|
||
label: 'Avgiftskategori',
|
||
formula: `Standard ${fmtPct(config.avgifterTotal)}`,
|
||
input: { age: ageAtYearStart },
|
||
output: null,
|
||
})
|
||
return { rate: config.avgifterTotal, amount: 0, basis: 0, category: 'standard', steps }
|
||
}
|
||
|
||
// ============================================================
|
||
// Sjuklön helpers
|
||
// ============================================================
|
||
|
||
/**
|
||
* Calculate karensavdrag (sick leave deduction day 1).
|
||
* Formula: 20% × (monthly_salary × 12 / 52 × sjuklön_rate)
|
||
*/
|
||
export function calculateKarensavdrag(monthlySalary: number, config: PayrollConfig): number {
|
||
const weeklySjuklon = r(monthlySalary * 12 / 52 * config.sjuklonRate)
|
||
return r(weeklySjuklon * config.karensavdragFactor)
|
||
}
|
||
|
||
/**
|
||
* Calculate sjuklön for days 2-14.
|
||
* Formula: 80% × daily_rate × (sick_days - 1)
|
||
*/
|
||
export function calculateSjuklon(
|
||
monthlySalary: number,
|
||
sickDays: number,
|
||
config: PayrollConfig,
|
||
// Arbetsschema-lite: legacy 21 unless the employee's schedule differs.
|
||
dailyDivisor: number = 21
|
||
): { karensavdrag: number; sjuklon: number; totalDeduction: number; steps: CalculationStep[] } {
|
||
const steps: CalculationStep[] = []
|
||
const dailyRate = r(monthlySalary / dailyDivisor)
|
||
|
||
// Karensavdrag
|
||
const karensavdrag = calculateKarensavdrag(monthlySalary, config)
|
||
steps.push({
|
||
label: 'Karensavdrag',
|
||
formula: `20 % × (månadslön × 12/52 × ${fmtPct(config.sjuklonRate)})`,
|
||
input: { monthly_salary: monthlySalary },
|
||
output: karensavdrag,
|
||
})
|
||
|
||
// Sjuklön day 2-14
|
||
const sjuklonDays = Math.min(Math.max(sickDays - 1, 0), 13)
|
||
const sjuklon = r(dailyRate * config.sjuklonRate * sjuklonDays)
|
||
steps.push({
|
||
label: 'Sjuklön dag 2-14',
|
||
formula: `dagslön × ${fmtPct(config.sjuklonRate)} × (sjukdagar − 1)`,
|
||
input: { daily_rate: dailyRate, sjuklon_rate: config.sjuklonRate, days: sjuklonDays },
|
||
output: sjuklon,
|
||
})
|
||
|
||
// Total deduction from pay = salary they would have earned - sjuklön they get
|
||
const fullPayForPeriod = r(dailyRate * sickDays)
|
||
const totalDeduction = r(-(fullPayForPeriod - sjuklon + karensavdrag))
|
||
steps.push({
|
||
label: 'Netto sjukavdrag',
|
||
formula: '−(full lön − sjuklön + karensavdrag)',
|
||
input: { full_pay: fullPayForPeriod, sjuklon, karensavdrag },
|
||
output: totalDeduction,
|
||
})
|
||
|
||
return { karensavdrag, sjuklon, totalDeduction, steps }
|
||
}
|
||
|
||
/**
|
||
* Calculate vacation accrual.
|
||
*/
|
||
export function calculateVacationAccrual(params: {
|
||
monthlySalary: number
|
||
vacationRule: 'procentregeln' | 'sammaloneregeln' | 'none' | 'semesterersattning'
|
||
vacationDaysPerYear: number
|
||
semestertillaggRate: number
|
||
vacationBasis: number
|
||
/** Arbetsschema-lite daily-rate divisor; legacy 21 when omitted. */
|
||
dailyDivisor?: number
|
||
}): { accrual: number; steps: CalculationStep[] } {
|
||
const steps: CalculationStep[] = []
|
||
|
||
if (params.vacationRule === 'none') {
|
||
steps.push({
|
||
label: 'Semesteravsättning (avstängd)',
|
||
formula: 'ingen semesteravsättning',
|
||
input: {},
|
||
output: 0,
|
||
})
|
||
return { accrual: 0, steps }
|
||
}
|
||
|
||
if (params.vacationRule === 'semesterersattning') {
|
||
steps.push({
|
||
label: 'Semesteravsättning (semesterersättning betald direkt)',
|
||
formula: 'ingen avsättning: 12 % betalas ut på varje lön',
|
||
input: {},
|
||
output: 0,
|
||
})
|
||
return { accrual: 0, steps }
|
||
}
|
||
|
||
if (params.vacationRule === 'procentregeln') {
|
||
const rate = params.vacationDaysPerYear >= 30 ? 0.144 : 0.12
|
||
const accrual = r(params.vacationBasis * rate)
|
||
steps.push({
|
||
label: `Semesteravsättning (procentregeln ${fmtPct(rate)})`,
|
||
formula: `semesterunderlag × ${fmtPct(rate)}`,
|
||
input: { vacation_basis: params.vacationBasis, rate },
|
||
output: accrual,
|
||
})
|
||
return { accrual, steps }
|
||
} else {
|
||
// Sammalöneregeln: tillägg per vacation day. Use vacationBasis as the
|
||
// degree-adjusted reference: callers must pass the part-time-adjusted
|
||
// monthly amount, never the raw full-time monthlySalary.
|
||
const dailyRate = r(params.vacationBasis / (params.dailyDivisor ?? 21))
|
||
const accrual = r(dailyRate * params.semestertillaggRate * params.vacationDaysPerYear)
|
||
steps.push({
|
||
label: `Semesteravsättning (sammalöneregeln ${fmtPct(params.semestertillaggRate)})`,
|
||
formula: `dagslön × ${fmtPct(params.semestertillaggRate)} × semesterdagar`,
|
||
input: { daily_rate: dailyRate, rate: params.semestertillaggRate, days: params.vacationDaysPerYear },
|
||
output: accrual,
|
||
})
|
||
return { accrual, steps }
|
||
}
|
||
}
|
||
|
||
// ============================================================
|
||
// Helpers
|
||
// ============================================================
|
||
|
||
function isJamkningValid(
|
||
validFrom: string | null,
|
||
validTo: string | null,
|
||
paymentDate: string
|
||
): boolean {
|
||
if (!validFrom || !validTo) return false
|
||
return paymentDate >= validFrom && paymentDate <= validTo
|
||
}
|