Salary module (#245)

* 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
This commit is contained in:
Mattsson
2026-04-15 11:17:39 +02:00
committed by GitHub
parent b387a77bfd
commit 04dbb31d7e
78 changed files with 10529 additions and 14 deletions
+87
View File
@@ -612,3 +612,90 @@ export const OpeningBalanceExecuteSchema = z.object({
credit_amount: nonNegativeAmount,
})).min(2, 'At least two lines are required for double-entry'),
})
// ============================================================
// Salary schemas
// ============================================================
export const EmploymentTypeSchema = z.enum(['employee', 'company_owner', 'board_member'])
export const SalaryTypeSchema = z.enum(['monthly', 'hourly'])
export const FSkattStatusSchema = z.enum(['a_skatt', 'f_skatt', 'fa_skatt', 'not_verified'])
export const VacationRuleSchema = z.enum(['procentregeln', 'sammaloneregeln'])
export const SalaryRunStatusSchema = z.enum(['draft', 'review', 'approved', 'paid', 'booked', 'corrected'])
export const SalaryLineItemTypeSchema = z.enum([
'monthly_salary', 'hourly_salary', 'overtime', 'bonus', 'commission',
'gross_deduction_pension', 'gross_deduction_other',
'benefit_car', 'benefit_housing', 'benefit_meals', 'benefit_wellness', 'benefit_other',
'sick_karens', 'sick_day2_14', 'sick_day15_plus',
'vab', 'parental_leave', 'vacation',
'traktamente_taxfree', 'traktamente_taxable',
'mileage_taxfree', 'mileage_taxable',
'net_deduction_advance', 'net_deduction_union', 'net_deduction_benefit_payment',
'net_deduction_other',
'correction', 'other',
])
export const CreateEmployeeSchema = z.object({
first_name: z.string().min(1).max(200),
last_name: z.string().min(1).max(200),
personnummer: z.string().regex(/^\d{12}$/, 'Personnummer måste vara 12 siffror (ÅÅÅÅMMDDNNNN)'),
employment_type: EmploymentTypeSchema.default('employee'),
employment_start: isoDate,
employment_end: isoDate.optional(),
employment_degree: z.number().min(1).max(100).default(100),
salary_type: SalaryTypeSchema.default('monthly'),
monthly_salary: z.number().nonnegative().optional(),
hourly_rate: z.number().nonnegative().optional(),
tax_table_number: z.number().int().min(29).max(42).optional(),
tax_column: z.number().int().min(1).max(6).default(1),
tax_municipality: z.string().max(100).optional(),
is_sidoinkomst: z.boolean().default(false),
f_skatt_status: FSkattStatusSchema.default('a_skatt'),
clearing_number: z.string().max(10).optional(),
bank_account_number: z.string().max(20).optional(),
vacation_rule: VacationRuleSchema.default('procentregeln'),
vacation_days_per_year: z.number().int().min(25).max(40).default(25),
semestertillagg_rate: z.number().min(0).max(0.05).default(0.0043),
email: z.string().email().optional(),
phone: z.string().max(20).optional(),
address_line1: z.string().max(200).optional(),
postal_code: z.string().max(10).optional(),
city: z.string().max(100).optional(),
vaxa_stod_eligible: z.boolean().default(false),
vaxa_stod_start: isoDate.optional(),
vaxa_stod_end: isoDate.optional(),
})
export const UpdateEmployeeSchema = CreateEmployeeSchema.partial()
export const CreateSalaryRunSchema = z.object({
period_year: z.number().int().min(2020).max(2100),
period_month: z.number().int().min(1).max(12),
payment_date: isoDate,
voucher_series: z.string().regex(/^[A-Z]$/, 'Verifikationsserie måste vara en bokstav A–Z').default('A'),
notes: z.string().max(2000).optional(),
})
export const AddEmployeeToRunSchema = z.object({
employee_id: uuid,
hours_worked: z.number().nonnegative().optional(),
})
export const CreateSalaryLineItemSchema = z.object({
salary_run_employee_id: uuid,
item_type: SalaryLineItemTypeSchema,
description: z.string().min(1).max(500),
quantity: z.number().optional(),
unit_price: z.number().optional(),
amount: z.number(),
is_taxable: z.boolean().default(true),
is_avgift_basis: z.boolean().default(true),
is_vacation_basis: z.boolean().default(true),
is_gross_deduction: z.boolean().default(false),
is_net_deduction: z.boolean().default(false),
account_number: accountNumber.optional(),
sort_order: z.number().int().default(0),
})
export const UpdateSalaryLineItemSchema = CreateSalaryLineItemSchema.partial().omit({ salary_run_employee_id: true })
+10
View File
@@ -14,6 +14,8 @@ export const API_KEY_SCOPES = {
'invoices:write': { label: 'Fakturor — skriv', description: 'Skapa, skicka, markera betald/skickad (4 verktyg)' },
'suppliers:read': { label: 'Leverantörer — läs', description: 'Lista leverantörer och leverantörsfakturor (2 verktyg)' },
'reports:read': { label: 'Rapporter — läs', description: 'Kontoplan, huvudbok, balansräkning, resultaträkning, moms, KPI, reskontra, perioder, bankavstämning (11 verktyg)' },
'payroll:read': { label: 'Löner — läs', description: 'Lista anställda, lönekörningar, lönejournal (3 verktyg)' },
'payroll:write': { label: 'Löner — skriv', description: 'Skapa lönekörning, beräkna, generera AGI (3 verktyg)' },
} as const
export type ApiKeyScope = keyof typeof API_KEY_SCOPES
@@ -36,6 +38,7 @@ export const SCOPE_GROUPS = [
{ domain: 'invoices', label: 'Fakturor', read: 'invoices:read' as const, write: 'invoices:write' as const },
{ domain: 'suppliers', label: 'Leverantörer', read: 'suppliers:read' as const, write: null },
{ domain: 'reports', label: 'Rapporter', read: 'reports:read' as const, write: null },
{ domain: 'payroll', label: 'Löner', read: 'payroll:read' as const, write: 'payroll:write' as const },
] as const
/** Map MCP tool name → required scope */
@@ -75,6 +78,13 @@ export const TOOL_SCOPE_MAP: Record<string, ApiKeyScope> = {
gnubok_upload_document: 'transactions:write',
gnubok_list_inbox_items: 'transactions:read',
gnubok_get_inbox_item: 'transactions:read',
// Payroll
gnubok_list_employees: 'payroll:read',
gnubok_get_salary_run: 'payroll:read',
gnubok_get_salary_journal: 'payroll:read',
gnubok_create_salary_run: 'payroll:write',
gnubok_calculate_salary_run: 'payroll:write',
gnubok_generate_agi: 'payroll:write',
}
export function validateScopes(scopes: unknown): ApiKeyScope[] | null {
+2
View File
@@ -18,6 +18,7 @@ type ErrorContext =
| 'journal_entry'
| 'settings'
| 'auth'
| 'salary'
interface GetErrorMessageOptions {
context?: ErrorContext
@@ -62,6 +63,7 @@ const CONTEXT_FALLBACKS: Record<ErrorContext, string> = {
journal_entry: 'Kunde inte hantera verifikationen. Försök igen.',
settings: 'Kunde inte spara inställningarna. Försök igen.',
auth: 'Ett fel uppstod vid inloggningen. Försök igen.',
salary: 'Kunde inte hantera löneuppgifterna. Försök igen.',
}
const GENERIC_FALLBACK = 'Något gick fel. Försök igen.'
+5
View File
@@ -73,6 +73,11 @@ export type CoreEvent =
| { type: 'supplier_invoice.received'; payload: { inboxItem: InvoiceInboxItem; userId: string; companyId: string } }
| { type: 'supplier_invoice.extracted'; payload: { inboxItem: InvoiceInboxItem; confidence: number; userId: string; companyId: string } }
| { type: 'supplier_invoice.confirmed'; payload: { inboxItem: InvoiceInboxItem; supplierInvoice: SupplierInvoice; userId: string; companyId: string } }
// Salary
| { type: 'salary_run.created'; payload: { salaryRunId: string; periodYear: number; periodMonth: number; userId: string; companyId: string } }
| { type: 'salary_run.approved'; payload: { salaryRunId: string; approvedBy: string; userId: string; companyId: string } }
| { type: 'salary_run.booked'; payload: { salaryRunId: string; entryIds: string[]; userId: string; companyId: string } }
| { type: 'agi.generated'; payload: { agiId: string; periodYear: number; periodMonth: number; userId: string; companyId: string } }
// Company & account lifecycle
| { type: 'company.deleted'; payload: { companyId: string; userId: string; archivedAt: string } }
| { type: 'account.deleted'; payload: { userId: string; deletedAt: string } }
+132
View File
@@ -0,0 +1,132 @@
import type { SupabaseClient } from '@supabase/supabase-js'
/**
* Arbetsgivaravgiftsunderlag — Employer contribution basis report.
*
* Monthly breakdown by avgifter rate category:
* - Standard (31.42%)
* - Reduced 65+ (10.21%)
* - Youth (20.81%, Apr 2026–Sep 2027)
* - Växa-stöd (10.21%)
*
* Used for reconciling against AGI filings (Ruta 060-062)
* and verifying correct avgifter calculations per social-charges.md.
*
* Per BFL: Part of räkenskapsinformation, 7-year retention.
*/
export interface AvgifterBasisRow {
periodYear: number
periodMonth: number
category: string
categoryLabel: string
rate: number
basis: number // Underlag (sum of avgifter_basis for employees in this category)
amount: number // Avgift (basis × rate)
employeeCount: number
}
export interface AvgifterBasisReport {
rows: AvgifterBasisRow[]
totals: {
totalBasis: number
totalAmount: number
}
year: number
}
const CATEGORY_LABELS: Record<string, string> = {
standard: 'Standard (31,42%)',
reduced_65plus: 'Reducerad 67+ (10,21%)',
youth: 'Ungdomsrabatt (20,81%)',
vaxa_stod: 'Växa-stöd (10,21%)',
exempt: 'Undantagen (0%)',
}
/**
* Generate avgifter basis report for a year.
*/
export async function generateAvgifterBasis(
supabase: SupabaseClient,
companyId: string,
year: number
): Promise<AvgifterBasisReport> {
const r = (x: number) => Math.round(x * 100) / 100
// Load all salary run employees for booked runs this year
const { data: runEmployees, error } = await supabase
.from('salary_run_employees')
.select(`
avgifter_basis,
avgifter_amount,
avgifter_rate,
salary_run:salary_runs!inner(period_year, period_month, status)
`)
.eq('company_id', companyId)
if (error) throw new Error(`Failed to load avgifter data: ${error.message}`)
// Filter to booked runs for the year
const bookedForYear = (runEmployees || []).filter(sre => {
const run = sre.salary_run as unknown as { period_year: number; period_month: number; status: string } | null
return run && run.period_year === year && run.status === 'booked'
})
// Group by month + rate category
const grouped = new Map<string, {
periodYear: number
periodMonth: number
category: string
rate: number
basis: number
amount: number
count: number
}>()
for (const sre of bookedForYear) {
const run = sre.salary_run as unknown as { period_year: number; period_month: number }
const category = rateToCategory(sre.avgifter_rate)
const key = `${run.period_month}-${category}`
const current = grouped.get(key) || {
periodYear: year,
periodMonth: run.period_month,
category,
rate: sre.avgifter_rate,
basis: 0,
amount: 0,
count: 0,
}
current.basis += sre.avgifter_basis
current.amount += sre.avgifter_amount
current.count++
grouped.set(key, current)
}
const rows: AvgifterBasisRow[] = Array.from(grouped.values())
.map(g => ({
periodYear: g.periodYear,
periodMonth: g.periodMonth,
category: g.category,
categoryLabel: CATEGORY_LABELS[g.category] || g.category,
rate: g.rate,
basis: r(g.basis),
amount: r(g.amount),
employeeCount: g.count,
}))
.sort((a, b) => a.periodMonth - b.periodMonth || a.category.localeCompare(b.category))
const totals = {
totalBasis: r(rows.reduce((s, row) => s + row.basis, 0)),
totalAmount: r(rows.reduce((s, row) => s + row.amount, 0)),
}
return { rows, totals, year }
}
function rateToCategory(rate: number): string {
if (rate === 0) return 'exempt'
if (rate <= 0.1022) return 'reduced_65plus' // 10.21% ± rounding
if (rate <= 0.2082) return 'youth' // 20.81%
return 'standard' // 31.42%
}
+127
View File
@@ -0,0 +1,127 @@
import type { SupabaseClient } from '@supabase/supabase-js'
/**
* Lönejournal — Monthly/annual per-employee salary register.
*
* Required per BFL as underlag for AGI reconciliation.
* Lists gross, tax, net, avgifter, and vacation accrual per employee per period.
*
* Per BFNAR 2013:2: Must be part of systemdokumentation and producible
* on demand for audit. Retained 7 years per BFL 7 kap.
*/
export interface SalaryJournalRow {
employeeId: string
employeeName: string
personnummerLast4: string
employmentType: string
periodYear: number
periodMonth: number
paymentDate: string
grossSalary: number
taxWithheld: number
netSalary: number
avgifterAmount: number
avgifterRate: number
vacationAccrual: number
vacationAccrualAvgifter: number
totalEmployerCost: number
sickDays: number
vabDays: number
parentalDays: number
vacationDaysTaken: number
salaryRunStatus: string
}
export interface SalaryJournalReport {
rows: SalaryJournalRow[]
totals: {
grossSalary: number
taxWithheld: number
netSalary: number
avgifterAmount: number
vacationAccrual: number
vacationAccrualAvgifter: number
totalEmployerCost: number
}
period: { year: number; monthFrom?: number; monthTo?: number }
}
/**
* Generate lönejournal for a year or specific month range.
*/
export async function generateSalaryJournal(
supabase: SupabaseClient,
companyId: string,
year: number,
monthFrom?: number,
monthTo?: number
): Promise<SalaryJournalReport> {
const query = supabase
.from('salary_run_employees')
.select(`
*,
employee:employees(id, first_name, last_name, personnummer_last4, employment_type),
salary_run:salary_runs(period_year, period_month, payment_date, status)
`)
.eq('company_id', companyId)
// We need to filter by the salary_run's period_year, which requires a join filter
// Supabase doesn't support filtering on joined columns directly in .eq(),
// so we fetch all and filter client-side for the year
const { data, error } = await query.order('created_at')
if (error) {
throw new Error(`Failed to generate salary journal: ${error.message}`)
}
const rows: SalaryJournalRow[] = (data || [])
.filter(sre => {
const run = sre.salary_run as { period_year: number; period_month: number; status: string } | null
if (!run || run.period_year !== year) return false
if (run.status !== 'booked') return false // Only booked runs for BFL-compliant lönejournal
if (monthFrom && run.period_month < monthFrom) return false
if (monthTo && run.period_month > monthTo) return false
return true
})
.map(sre => {
const emp = sre.employee as { first_name: string; last_name: string; personnummer_last4: string; employment_type: string } | null
const run = sre.salary_run as { period_year: number; period_month: number; payment_date: string; status: string }
return {
employeeId: sre.employee_id,
employeeName: emp ? `${emp.first_name} ${emp.last_name}` : 'Okänd',
personnummerLast4: emp?.personnummer_last4 || '????',
employmentType: emp?.employment_type || 'employee',
periodYear: run.period_year,
periodMonth: run.period_month,
paymentDate: run.payment_date,
grossSalary: sre.gross_salary,
taxWithheld: sre.tax_withheld,
netSalary: sre.net_salary,
avgifterAmount: sre.avgifter_amount,
avgifterRate: sre.avgifter_rate,
vacationAccrual: sre.vacation_accrual,
vacationAccrualAvgifter: sre.vacation_accrual_avgifter,
totalEmployerCost: sre.gross_salary + sre.avgifter_amount + sre.vacation_accrual + sre.vacation_accrual_avgifter,
sickDays: sre.sick_days,
vabDays: sre.vab_days,
parentalDays: sre.parental_days,
vacationDaysTaken: sre.vacation_days_taken,
salaryRunStatus: run.status,
}
})
.sort((a, b) => a.periodMonth - b.periodMonth || a.employeeName.localeCompare(b.employeeName))
const r = (x: number) => Math.round(x * 100) / 100
const totals = {
grossSalary: r(rows.reduce((s, r) => s + r.grossSalary, 0)),
taxWithheld: r(rows.reduce((s, r) => s + r.taxWithheld, 0)),
netSalary: r(rows.reduce((s, r) => s + r.netSalary, 0)),
avgifterAmount: r(rows.reduce((s, r) => s + r.avgifterAmount, 0)),
vacationAccrual: r(rows.reduce((s, r) => s + r.vacationAccrual, 0)),
vacationAccrualAvgifter: r(rows.reduce((s, r) => s + r.vacationAccrualAvgifter, 0)),
totalEmployerCost: r(rows.reduce((s, r) => s + r.totalEmployerCost, 0)),
}
return { rows, totals, period: { year, monthFrom, monthTo } }
}
+135
View File
@@ -0,0 +1,135 @@
import type { SupabaseClient } from '@supabase/supabase-js'
/**
* Semesterlöneskuld — Vacation liability report per BFNAR 2016:10.
*
* Per BFNAR 2016:10 kap 16: Vacation liability must be calculated per employee,
* not as a lump sum. This report shows earned/taken days, accrued SEK amount
* on account 2920, and accrued avgifter on account 2940.
*
* The report is required for year-end closing and ongoing monthly review.
* Per BFL 7 kap: retained 7 years as part of räkenskapsinformation.
*/
export interface VacationLiabilityRow {
employeeId: string
employeeName: string
personnummerLast4: string
vacationRule: string
vacationDaysEntitled: number
vacationDaysTaken: number
vacationDaysRemaining: number
vacationDaysSaved: number
accruedAmount: number // Account 2920
accruedAvgifter: number // Account 2940
avgifterRate: number
totalLiability: number // 2920 + 2940
}
export interface VacationLiabilityReport {
rows: VacationLiabilityRow[]
totals: {
accruedAmount: number // Sum for account 2920
accruedAvgifter: number // Sum for account 2940
totalLiability: number
}
asOfDate: string
}
/**
* Generate vacation liability report.
*
* Aggregates vacation accruals from all booked salary runs in the year
* and compares against vacation days taken.
*/
export async function generateVacationLiability(
supabase: SupabaseClient,
companyId: string,
year: number
): Promise<VacationLiabilityReport> {
const r = (x: number) => Math.round(x * 100) / 100
// Load active employees
const { data: employees, error: empError } = await supabase
.from('employees')
.select('id, first_name, last_name, personnummer_last4, vacation_rule, vacation_days_per_year, vacation_days_saved')
.eq('company_id', companyId)
.eq('is_active', true)
.order('last_name')
if (empError) throw new Error(`Failed to load employees: ${empError.message}`)
// Load all salary run employees for booked runs this year
const { data: runEmployees, error: sreError } = await supabase
.from('salary_run_employees')
.select(`
employee_id,
vacation_accrual,
vacation_accrual_avgifter,
avgifter_rate,
vacation_days_taken,
salary_run:salary_runs!inner(period_year, status)
`)
.eq('company_id', companyId)
if (sreError) throw new Error(`Failed to load salary run data: ${sreError.message}`)
// Filter to booked runs for the year
const bookedForYear = (runEmployees || []).filter(sre => {
const run = sre.salary_run as unknown as { period_year: number; status: string } | null
return run && run.period_year === year && run.status === 'booked'
})
// Aggregate per employee
const accrualsByEmployee = new Map<string, {
totalAccrual: number
totalAvgifter: number
totalDaysTaken: number
lastRate: number
}>()
for (const sre of bookedForYear) {
const current = accrualsByEmployee.get(sre.employee_id) || {
totalAccrual: 0, totalAvgifter: 0, totalDaysTaken: 0, lastRate: 0.3142,
}
current.totalAccrual += sre.vacation_accrual
current.totalAvgifter += sre.vacation_accrual_avgifter
current.totalDaysTaken += sre.vacation_days_taken
current.lastRate = sre.avgifter_rate
accrualsByEmployee.set(sre.employee_id, current)
}
const rows: VacationLiabilityRow[] = (employees || []).map(emp => {
const accruals = accrualsByEmployee.get(emp.id)
const accruedAmount = r(accruals?.totalAccrual || 0)
const accruedAvgifter = r(accruals?.totalAvgifter || 0)
const daysTaken = accruals?.totalDaysTaken || 0
return {
employeeId: emp.id,
employeeName: `${emp.first_name} ${emp.last_name}`,
personnummerLast4: emp.personnummer_last4,
vacationRule: emp.vacation_rule,
vacationDaysEntitled: emp.vacation_days_per_year,
vacationDaysTaken: daysTaken,
vacationDaysRemaining: emp.vacation_days_per_year - daysTaken,
vacationDaysSaved: emp.vacation_days_saved,
accruedAmount,
accruedAvgifter,
avgifterRate: accruals?.lastRate || 0.3142,
totalLiability: r(accruedAmount + accruedAvgifter),
}
})
const totals = {
accruedAmount: r(rows.reduce((s, row) => s + row.accruedAmount, 0)),
accruedAvgifter: r(rows.reduce((s, row) => s + row.accruedAvgifter, 0)),
totalLiability: r(rows.reduce((s, row) => s + row.totalLiability, 0)),
}
return {
rows,
totals,
asOfDate: `${year}-12-31`,
}
}
@@ -0,0 +1,187 @@
import { describe, it, expect } from 'vitest'
import { calculateSjuklon, calculateVabDeduction, calculateParentalLeaveDeduction, calculateVacationPay } from '../absence-calculator'
import type { PayrollConfig } from '../payroll-config'
const config: PayrollConfig = {
configYear: 2026,
avgifterTotal: 0.3142,
avgifterAlderspension: 0.1021,
avgifterSjukforsakring: 0.0355,
avgifterForaldraforsakring: 0.0200,
avgifterEfterlevandepension: 0.0030,
avgifterArbetsmarknad: 0.0264,
avgifterArbetsskada: 0.0010,
avgifterAllmanLoneavgift: 0.1262,
avgifterReduced65plus: 0.1021,
avgifterYouthRate: 0.2081,
avgifterYouthSalaryCap: 25000,
avgifterVaxaStodRate: 0.1021,
avgifterVaxaStodCap: 35000,
avgifterMinimumAnnual: 1000,
egenavgifterTotal: 0.2897,
slpRate: 0.2426,
prisbasbelopp: 59200,
inkomstbasbelopp: 83400,
maxPgi: 625500,
sgiCeiling: 592000,
statligSkattBrytpunkt: 660400,
traktamenteHeldag: 300,
traktamenteHalvdag: 150,
traktamenteNatt: 150,
milersattningEgenBil: 25,
milersattningFormansbilFossil: 12,
milersattningFormansbilEl: 9.50,
kostformanHeldag: 310,
kostformanLunch: 124,
kostformanFrukost: 62,
friskvardCap: 5000,
bilformanSlr: 0.0255,
sjuklonRate: 0.80,
karensavdragFactor: 0.20,
maxKarensavdragPerYear: 10,
reducedAvgiftAge: 67,
}
const r = (x: number) => Math.round(x * 100) / 100
describe('calculateSjuklon', () => {
it('calculates karensavdrag correctly (20% of weekly sjuklön)', () => {
const result = calculateSjuklon(30000, 1, config)
// weekly = 30000 × 12/52 × 0.80 = 5538.46
// karens = 5538.46 × 0.20 = 1107.69
const expectedWeekly = r(30000 * 12 / 52 * 0.80)
const expectedKarens = r(expectedWeekly * 0.20)
expect(result.karensavdrag).toBe(expectedKarens)
})
it('calculates sjuklön day 2-14 at 80%', () => {
const result = calculateSjuklon(30000, 5, config)
const dailyRate = r(30000 / 21)
// 4 sjuklön days (day 2-5)
const expectedSjuklon = r(dailyRate * 0.80 * 4)
expect(result.sjuklonDays).toBe(4)
expect(result.sjuklonAmount).toBe(expectedSjuklon)
})
it('caps sjuklön days at 13 (day 2-14)', () => {
const result = calculateSjuklon(30000, 20, config)
expect(result.sjuklonDays).toBe(13)
})
it('handles 1-day sickness (karens only, no sjuklön)', () => {
const result = calculateSjuklon(30000, 1, config)
expect(result.sjuklonDays).toBe(0)
expect(result.sjuklonAmount).toBe(0)
expect(result.karensavdrag).toBeGreaterThan(0)
})
it('skips karensavdrag for återinsjuknande (within 5 days)', () => {
const result = calculateSjuklon(30000, 3, config, true)
expect(result.karensavdrag).toBe(0)
// Sjuklön from day 1 since no karensavdrag
expect(result.sjuklonDays).toBe(3)
})
it('returns calculation steps for transparency', () => {
const result = calculateSjuklon(30000, 5, config)
expect(result.steps.length).toBeGreaterThanOrEqual(4)
expect(result.steps.some(s => s.label === 'Karensavdrag')).toBe(true)
expect(result.steps.some(s => s.label === 'Sjuklön dag 2-14')).toBe(true)
})
it('calculates total deduction correctly', () => {
const result = calculateSjuklon(30000, 5, config)
const dailyRate = r(30000 / 21)
const normalPay = r(dailyRate * 5)
// totalDeduction = normalPay - sjuklön + karens
expect(result.totalDeduction).toBe(r(normalPay - result.sjuklonAmount + result.karensavdrag))
})
})
describe('calculateVabDeduction', () => {
it('calculates daily rate deduction', () => {
const result = calculateVabDeduction(30000, 3)
const expectedDeduction = r(30000 / 21 * 3)
expect(result.deduction).toBe(expectedDeduction)
})
it('marks as semesterlönegrundande within 120 days', () => {
const result = calculateVabDeduction(30000, 5, 100)
expect(result.semesterGrundande).toBe(true) // 100 + 5 = 105 ≤ 120
})
it('marks as not semesterlönegrundande after 120 days', () => {
const result = calculateVabDeduction(30000, 5, 118)
expect(result.semesterGrundande).toBe(false) // 118 + 5 = 123 > 120
})
})
describe('calculateParentalLeaveDeduction', () => {
it('calculates daily rate deduction', () => {
const result = calculateParentalLeaveDeduction(30000, 10)
// Daily rate is rounded first, then multiplied: r(r(30000/21) * 10)
const dailyRate = r(30000 / 21)
const expectedDeduction = r(dailyRate * 10)
expect(result.deduction).toBe(expectedDeduction)
})
it('marks as semesterlönegrundande within 120 days per pregnancy', () => {
const result = calculateParentalLeaveDeduction(30000, 10, 100)
expect(result.semesterGrundande).toBe(true) // 100 + 10 = 110 ≤ 120
})
})
describe('calculateVacationPay', () => {
it('calculates sammalöneregeln tillägg', () => {
const result = calculateVacationPay({
monthlySalary: 40000,
vacationDaysTaken: 5,
vacationRule: 'sammaloneregeln',
semestertillaggRate: 0.0043,
vacationDaysPerYear: 25,
})
const expectedTillagg = r(40000 * 0.0043 * 5)
expect(result.tillagg).toBe(expectedTillagg)
expect(result.amount).toBe(expectedTillagg)
})
it('uses 0.8% CBA rate when specified', () => {
const result = calculateVacationPay({
monthlySalary: 40000,
vacationDaysTaken: 5,
vacationRule: 'sammaloneregeln',
semestertillaggRate: 0.008,
vacationDaysPerYear: 25,
})
const expectedTillagg = r(40000 * 0.008 * 5)
expect(result.tillagg).toBe(expectedTillagg)
})
it('calculates procentregeln at 12%', () => {
const result = calculateVacationPay({
monthlySalary: 30000,
vacationDaysTaken: 5,
vacationRule: 'procentregeln',
semestertillaggRate: 0.0043,
vacationDaysPerYear: 25,
})
const annualBasis = r(30000 * 12)
const totalVacPay = r(annualBasis * 0.12)
const perDay = r(totalVacPay / 25)
expect(result.amount).toBe(r(perDay * 5))
})
it('uses 14.4% for 30+ vacation days', () => {
const result = calculateVacationPay({
monthlySalary: 30000,
vacationDaysTaken: 5,
vacationRule: 'procentregeln',
semestertillaggRate: 0.0043,
vacationDaysPerYear: 30,
})
const annualBasis = r(30000 * 12)
const totalVacPay = r(annualBasis * 0.144)
const perDay = r(totalVacPay / 30)
expect(result.amount).toBe(r(perDay * 5))
})
})
+176
View File
@@ -0,0 +1,176 @@
import { describe, it, expect, vi } from 'vitest'
import { generateAGIXml, buildIndividuppgifterSnapshot } from '../agi/xml-generator'
import type { AGICompanyData, AGIEmployeeData, AGITotals } from '../agi/xml-generator'
// Mock personnummer decryption
vi.mock('../personnummer', () => ({
decryptPersonnummer: (encrypted: string) => {
if (encrypted === 'emp1_encrypted') return '199001011234'
if (encrypted === 'emp2_encrypted') return '198506159876'
return '000000000000'
},
}))
const company: AGICompanyData = {
orgNumber: '556123-4567',
companyName: 'Test AB',
periodYear: 2026,
periodMonth: 4,
contactName: 'Anna Admin',
contactPhone: '0701234567',
contactEmail: 'anna@test.se',
}
const employees: AGIEmployeeData[] = [
{
personnummer: 'emp1_encrypted',
specificationNumber: 1,
grossSalary: 40000,
taxWithheld: 12000,
avgifterBasis: 40000,
sickDays: 3,
vabDays: 2,
},
{
personnummer: 'emp2_encrypted',
specificationNumber: 2,
grossSalary: 35000,
taxWithheld: 10500,
avgifterBasis: 35000,
benefitCar: 5000,
},
]
const totals: AGITotals = {
totalTax: 22500,
totalAvgifterBasis: 80000,
avgifterByCategory: {
standard: { basis: 75000, amount: 23565 },
reduced65plus: { basis: 5000, amount: 510.50 },
},
}
describe('generateAGIXml', () => {
it('generates valid XML with correct root element', () => {
const xml = generateAGIXml(company, employees, totals)
expect(xml).toContain('<?xml version="1.0" encoding="UTF-8"?>')
expect(xml).toContain('<Skatteverket')
expect(xml).toContain('</Skatteverket>')
})
it('includes program name "gnubok"', () => {
const xml = generateAGIXml(company, employees, totals)
expect(xml).toContain('<Programnamn>gnubok</Programnamn>')
})
it('includes correct period', () => {
const xml = generateAGIXml(company, employees, totals)
expect(xml).toContain('<Period>202604</Period>')
})
it('includes org number without dash', () => {
const xml = generateAGIXml(company, employees, totals)
expect(xml).toContain('5561234567')
expect(xml).not.toContain('556123-4567')
})
it('includes huvuduppgift with total tax (Ruta 001)', () => {
const xml = generateAGIXml(company, employees, totals)
expect(xml).toContain('<AvdragenSkatt faltkod="001">22500</AvdragenSkatt>')
})
it('includes avgifter categories (Ruta 060, 061)', () => {
const xml = generateAGIXml(company, employees, totals)
expect(xml).toContain('faltkod="060"')
expect(xml).toContain('faltkod="061"')
})
it('decrypts personnummer for FK215', () => {
const xml = generateAGIXml(company, employees, totals)
expect(xml).toContain('<Personnummer faltkod="215">199001011234</Personnummer>')
expect(xml).toContain('<Personnummer faltkod="215">198506159876</Personnummer>')
})
it('includes consistent FK570 specifikationsnummer', () => {
const xml = generateAGIXml(company, employees, totals)
expect(xml).toContain('<Specifikationsnummer faltkod="570">1</Specifikationsnummer>')
expect(xml).toContain('<Specifikationsnummer faltkod="570">2</Specifikationsnummer>')
})
it('includes gross salary (Ruta 011) per employee', () => {
const xml = generateAGIXml(company, employees, totals)
expect(xml).toContain('<KontantBruttoloen faltkod="011">40000</KontantBruttoloen>')
expect(xml).toContain('<KontantBruttoloen faltkod="011">35000</KontantBruttoloen>')
})
it('includes tax withheld (Ruta 001) per employee', () => {
const xml = generateAGIXml(company, employees, totals)
// Both HU and IU have AvdragenSkatt
const matches = xml.match(/AvdragenSkatt/g)
expect(matches!.length).toBeGreaterThanOrEqual(3) // 1 HU + 2 IU
})
it('includes benefit values (Ruta 012 for car)', () => {
const xml = generateAGIXml(company, employees, totals)
expect(xml).toContain('<FormanBil faltkod="012">5000</FormanBil>')
})
it('includes absence fields FK821-FK823', () => {
const xml = generateAGIXml(company, employees, totals)
expect(xml).toContain('<SjukfranvaroDagar faltkod="821">3</SjukfranvaroDagar>')
expect(xml).toContain('<VabDagar faltkod="822">2</VabDagar>')
})
it('omits zero/undefined fields', () => {
const xml = generateAGIXml(company, employees, totals)
// Employee 1 has no car benefit
// Employee 2 has no sick days
// Check that we don't emit empty tags
const lines = xml.split('\n')
for (const line of lines) {
if (line.includes('faltkod')) {
expect(line).not.toContain('>0</')
}
}
})
it('marks corrections with Rattelse flag', () => {
const xml = generateAGIXml(company, employees, totals, true)
expect(xml).toContain('<Rattelse>J</Rattelse>')
})
it('does not include Rattelse flag for initial filing', () => {
const xml = generateAGIXml(company, employees, totals, false)
expect(xml).not.toContain('Rattelse')
})
it('escapes XML special characters in company name', () => {
const specialCompany = { ...company, companyName: 'Test & <Co>' }
const xml = generateAGIXml(specialCompany, employees, totals)
expect(xml).not.toContain('Test & <Co>')
})
})
describe('buildIndividuppgifterSnapshot', () => {
it('builds snapshot with decrypted personnummer', () => {
const snapshot = buildIndividuppgifterSnapshot(employees)
expect(snapshot).toHaveLength(2)
expect(snapshot[0].personnummer).toBe('199001011234')
expect(snapshot[1].personnummer).toBe('198506159876')
})
it('preserves FK570 for correction reference', () => {
const snapshot = buildIndividuppgifterSnapshot(employees)
expect(snapshot[0].fk570).toBe(1)
expect(snapshot[1].fk570).toBe(2)
})
it('includes all required rutor', () => {
const snapshot = buildIndividuppgifterSnapshot(employees)
expect(snapshot[0]).toHaveProperty('ruta011', 40000)
expect(snapshot[0]).toHaveProperty('ruta001', 12000)
expect(snapshot[0]).toHaveProperty('ruta020', 40000)
expect(snapshot[0]).toHaveProperty('fk821', 3)
expect(snapshot[0]).toHaveProperty('fk822', 2)
})
})
+153
View File
@@ -0,0 +1,153 @@
import { describe, it, expect } from 'vitest'
import { calculateCarBenefit, getMealBenefitValue, calculateWellnessBenefit } from '../benefits'
import type { PayrollConfig } from '../payroll-config'
const config: PayrollConfig = {
configYear: 2026,
avgifterTotal: 0.3142,
avgifterAlderspension: 0.1021,
avgifterSjukforsakring: 0.0355,
avgifterForaldraforsakring: 0.0200,
avgifterEfterlevandepension: 0.0030,
avgifterArbetsmarknad: 0.0264,
avgifterArbetsskada: 0.0010,
avgifterAllmanLoneavgift: 0.1262,
avgifterReduced65plus: 0.1021,
avgifterYouthRate: 0.2081,
avgifterYouthSalaryCap: 25000,
avgifterVaxaStodRate: 0.1021,
avgifterVaxaStodCap: 35000,
avgifterMinimumAnnual: 1000,
egenavgifterTotal: 0.2897,
slpRate: 0.2426,
prisbasbelopp: 59200,
inkomstbasbelopp: 83400,
maxPgi: 625500,
sgiCeiling: 592000,
statligSkattBrytpunkt: 660400,
traktamenteHeldag: 300,
traktamenteHalvdag: 150,
traktamenteNatt: 150,
milersattningEgenBil: 25,
milersattningFormansbilFossil: 12,
milersattningFormansbilEl: 9.50,
kostformanHeldag: 310,
kostformanLunch: 124,
kostformanFrukost: 62,
friskvardCap: 5000,
bilformanSlr: 0.0255,
sjuklonRate: 0.80,
karensavdragFactor: 0.20,
maxKarensavdragPerYear: 10,
reducedAvgiftAge: 67,
}
describe('calculateCarBenefit', () => {
it('calculates Gen3 car benefit', () => {
const result = calculateCarBenefit({
nybilspris: 350000,
fordonsskatt: 3600,
isEnvironmental: false,
highMileage: false,
}, config)
// 0.29 × 59200 = 17168
// 350000 × (0.70 × 0.0255 + 0.01) = 350000 × 0.02785 = 9747.5
// 0.13 × 350000 = 45500
// + 3600 fordonsskatt
// = 17168 + 9747.5 + 45500 + 3600 = 76015.5
// monthly = 76015.5 / 12 ≈ 6334.63
expect(result.annualValue).toBeGreaterThan(0)
expect(result.monthlyValue).toBe(Math.round(result.annualValue / 12 * 100) / 100)
expect(result.steps.length).toBeGreaterThanOrEqual(2)
})
it('applies environmental reduction for electric cars', () => {
const standard = calculateCarBenefit({
nybilspris: 500000,
fordonsskatt: 3600,
isEnvironmental: false,
highMileage: false,
}, config)
const electric = calculateCarBenefit({
nybilspris: 500000,
fordonsskatt: 360,
isEnvironmental: true,
environmentalType: 'electric',
highMileage: false,
}, config)
// Electric reduces nybilspris by 350,000 (max 50% of 500,000 = 250,000)
// So reduction is capped at 250,000
expect(electric.annualValue).toBeLessThan(standard.annualValue)
})
it('caps environmental reduction at 50% of nybilspris', () => {
const result = calculateCarBenefit({
nybilspris: 400000,
fordonsskatt: 3600,
isEnvironmental: true,
environmentalType: 'electric', // Would reduce by 350,000 but cap at 200,000
highMileage: false,
}, config)
// Reduction: min(350000, 400000 × 0.5) = 200000
// Adjusted price: 200000
expect(result.steps.some(s => s.label.includes('Miljöbils'))).toBe(true)
})
it('applies 25% high-mileage reduction', () => {
const normal = calculateCarBenefit({
nybilspris: 350000,
fordonsskatt: 3600,
isEnvironmental: false,
highMileage: false,
}, config)
const highMileage = calculateCarBenefit({
nybilspris: 350000,
fordonsskatt: 3600,
isEnvironmental: false,
highMileage: true,
}, config)
expect(highMileage.annualValue).toBe(Math.round(normal.annualValue * 0.75 * 100) / 100)
})
})
describe('getMealBenefitValue', () => {
it('returns correct schablonvärde for 2026', () => {
expect(getMealBenefitValue('full_day', config)).toBe(310)
expect(getMealBenefitValue('lunch', config)).toBe(124)
expect(getMealBenefitValue('breakfast', config)).toBe(62)
})
})
describe('calculateWellnessBenefit', () => {
it('marks as tax-free when within cap', () => {
const result = calculateWellnessBenefit(3000, 0, config)
expect(result.taxable).toBe(false)
expect(result.taxableAmount).toBe(0)
})
it('marks ENTIRE amount as taxable when cap exceeded', () => {
const result = calculateWellnessBenefit(3000, 3000, config)
// YTD = 3000 + 3000 = 6000 > 5000 cap
expect(result.taxable).toBe(true)
expect(result.taxableAmount).toBe(6000) // Full amount, not just excess
})
it('handles exact cap boundary', () => {
const result = calculateWellnessBenefit(2500, 2500, config)
// YTD = 5000 = cap — still tax-free
expect(result.taxable).toBe(false)
})
it('handles single amount exceeding cap', () => {
const result = calculateWellnessBenefit(6000, 0, config)
expect(result.taxable).toBe(true)
expect(result.taxableAmount).toBe(6000)
})
})
@@ -0,0 +1,370 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { calculateSalary, calculateKarensavdrag, calculateSjuklon, calculateAvgifterRate } from '../calculation-engine'
import type { PayrollConfig } from '../payroll-config'
import type { TaxTableRate } from '../tax-tables'
// Mock personnummer module
vi.mock('../personnummer', () => ({
decryptPersonnummer: (encrypted: string) => {
// Return mock personnummer based on encrypted value
if (encrypted === 'mock_old_person') return '193501011234'
if (encrypted === 'mock_young_person') return '200301011234'
if (encrypted === 'mock_senior_person') return '195801011234'
return '199001011234' // Default: born 1990
},
calculateAgeAtYearStart: (pnr: string, year: number) => {
const birthYear = parseInt(pnr.slice(0, 4))
return year - birthYear
},
}))
const config2026: PayrollConfig = {
configYear: 2026,
avgifterTotal: 0.3142,
avgifterAlderspension: 0.1021,
avgifterSjukforsakring: 0.0355,
avgifterForaldraforsakring: 0.0200,
avgifterEfterlevandepension: 0.0030,
avgifterArbetsmarknad: 0.0264,
avgifterArbetsskada: 0.0010,
avgifterAllmanLoneavgift: 0.1262,
avgifterReduced65plus: 0.1021,
avgifterYouthRate: 0.2081,
avgifterYouthSalaryCap: 25000,
avgifterVaxaStodRate: 0.1021,
avgifterVaxaStodCap: 35000,
avgifterMinimumAnnual: 1000,
egenavgifterTotal: 0.2897,
slpRate: 0.2426,
prisbasbelopp: 59200,
inkomstbasbelopp: 83400,
maxPgi: 625500,
sgiCeiling: 592000,
statligSkattBrytpunkt: 660400,
traktamenteHeldag: 300,
traktamenteHalvdag: 150,
traktamenteNatt: 150,
milersattningEgenBil: 25,
milersattningFormansbilFossil: 12,
milersattningFormansbilEl: 9.50,
kostformanHeldag: 310,
kostformanLunch: 124,
kostformanFrukost: 62,
friskvardCap: 5000,
bilformanSlr: 0.0255,
sjuklonRate: 0.80,
karensavdragFactor: 0.20,
maxKarensavdragPerYear: 10,
reducedAvgiftAge: 67,
}
const emptyTaxRates: TaxTableRate[] = []
function makeBasicInput(overrides = {}) {
return {
employmentType: 'employee' as const,
salaryType: 'monthly' as const,
monthlySalary: 40000,
employmentDegree: 100,
taxTableNumber: null,
taxColumn: 1,
isSidoinkomst: false,
jamkningPercentage: null,
jamkningValidFrom: null,
jamkningValidTo: null,
fSkattStatus: 'a_skatt',
personnummer: 'mock_standard',
paymentDate: '2026-04-25',
vacationRule: 'procentregeln' as const,
vacationDaysPerYear: 25,
semestertillaggRate: 0.0043,
vaxaStodEligible: false,
vaxaStodStart: null,
vaxaStodEnd: null,
lineItems: [],
...overrides,
}
}
describe('calculateSalary', () => {
it('calculates basic monthly salary correctly', () => {
const result = calculateSalary(makeBasicInput(), config2026, emptyTaxRates)
expect(result.grossSalary).toBe(40000)
// With no tax table, falls back to 30%
expect(result.taxWithheld).toBe(12000)
expect(result.netSalary).toBe(28000)
expect(result.avgifterRate).toBe(0.3142)
expect(result.avgifterAmount).toBe(Math.round(40000 * 0.3142 * 100) / 100)
expect(result.vacationAccrual).toBe(Math.round(40000 * 0.12 * 100) / 100)
expect(result.steps.length).toBeGreaterThan(0)
})
it('applies employment degree', () => {
const result = calculateSalary(
makeBasicInput({ employmentDegree: 50 }),
config2026,
emptyTaxRates
)
expect(result.grossSalary).toBe(20000)
expect(result.taxWithheld).toBe(6000) // 30% of 20000
expect(result.netSalary).toBe(14000)
})
it('calculates hourly salary', () => {
const result = calculateSalary(
makeBasicInput({
salaryType: 'hourly',
monthlySalary: 0,
hourlyRate: 250,
hoursWorked: 160,
}),
config2026,
emptyTaxRates
)
expect(result.grossSalary).toBe(40000) // 250 * 160
})
it('applies sidoinkomst flat 30%', () => {
const result = calculateSalary(
makeBasicInput({ isSidoinkomst: true }),
config2026,
emptyTaxRates
)
expect(result.taxWithheld).toBe(12000) // 30% of 40000
})
it('applies f-skatt with 0% withholding', () => {
const result = calculateSalary(
makeBasicInput({ fSkattStatus: 'f_skatt' }),
config2026,
emptyTaxRates
)
expect(result.taxWithheld).toBe(0)
expect(result.netSalary).toBe(40000)
})
it('applies unverified flat 30%', () => {
const result = calculateSalary(
makeBasicInput({ fSkattStatus: 'not_verified' }),
config2026,
emptyTaxRates
)
expect(result.taxWithheld).toBe(12000)
})
it('applies jämkning when valid', () => {
const result = calculateSalary(
makeBasicInput({
jamkningPercentage: 15,
jamkningValidFrom: '2026-01-01',
jamkningValidTo: '2026-12-31',
}),
config2026,
emptyTaxRates
)
expect(result.taxWithheld).toBe(6000) // 15% of 40000
})
it('does not apply jämkning when outside date range', () => {
const result = calculateSalary(
makeBasicInput({
jamkningPercentage: 15,
jamkningValidFrom: '2025-01-01',
jamkningValidTo: '2025-12-31',
paymentDate: '2026-04-25',
}),
config2026,
emptyTaxRates
)
// Should fall back to 30% since jämkning expired
expect(result.taxWithheld).toBe(12000)
})
it('handles line item additions', () => {
const result = calculateSalary(
makeBasicInput({
lineItems: [
{ itemType: 'bonus', amount: 5000, isTaxable: true, isAvgiftBasis: true, isVacationBasis: true, isGrossDeduction: false, isNetDeduction: false },
],
}),
config2026,
emptyTaxRates
)
expect(result.grossSalary).toBe(45000) // 40000 + 5000
expect(result.taxWithheld).toBe(13500) // 30% of 45000
})
it('applies gross deductions before tax', () => {
const result = calculateSalary(
makeBasicInput({
lineItems: [
{ itemType: 'gross_deduction_pension', amount: -5000, isTaxable: true, isAvgiftBasis: true, isVacationBasis: false, isGrossDeduction: true, isNetDeduction: false },
],
}),
config2026,
emptyTaxRates
)
expect(result.grossSalary).toBe(35000) // 40000 - 5000
expect(result.grossDeductions).toBe(5000)
expect(result.taxWithheld).toBe(10500) // 30% of 35000 (tax on reduced amount)
})
it('applies net deductions after tax', () => {
const result = calculateSalary(
makeBasicInput({
lineItems: [
{ itemType: 'net_deduction_advance', amount: -3000, isTaxable: false, isAvgiftBasis: false, isVacationBasis: false, isGrossDeduction: false, isNetDeduction: true },
],
}),
config2026,
emptyTaxRates
)
expect(result.grossSalary).toBe(40000) // Unaffected
expect(result.taxWithheld).toBe(12000) // 30% of 40000 (tax on full amount)
expect(result.netDeductions).toBe(3000)
expect(result.netSalary).toBe(25000) // 40000 - 12000 - 3000
})
it('calculates vacation accrual with procentregeln', () => {
const result = calculateSalary(
makeBasicInput({ vacationRule: 'procentregeln', vacationDaysPerYear: 25 }),
config2026,
emptyTaxRates
)
expect(result.vacationAccrual).toBe(Math.round(40000 * 0.12 * 100) / 100)
})
it('uses 14.4% for 30+ vacation days', () => {
const result = calculateSalary(
makeBasicInput({ vacationRule: 'procentregeln', vacationDaysPerYear: 30 }),
config2026,
emptyTaxRates
)
expect(result.vacationAccrual).toBe(Math.round(40000 * 0.144 * 100) / 100)
})
it('adds benefit values to tax base but not gross', () => {
const result = calculateSalary(
makeBasicInput({
lineItems: [
{ itemType: 'benefit_car', amount: 3000, isTaxable: true, isAvgiftBasis: true, isVacationBasis: false, isGrossDeduction: false, isNetDeduction: false },
],
}),
config2026,
emptyTaxRates
)
expect(result.grossSalary).toBe(40000) // Benefits don't add to gross
expect(result.benefitValues).toBe(3000)
expect(result.taxableIncome).toBe(43000) // gross + benefits
expect(result.taxWithheld).toBe(12900) // 30% of 43000
expect(result.netSalary).toBe(27100) // 40000 - 12900
})
it('includes employer cost calculation', () => {
const result = calculateSalary(makeBasicInput(), config2026, emptyTaxRates)
const expectedAvgifter = Math.round(40000 * 0.3142 * 100) / 100
const expectedVacation = Math.round(40000 * 0.12 * 100) / 100
const expectedVacationAvgifter = Math.round(expectedVacation * 0.3142 * 100) / 100
const expectedCost = Math.round((40000 + expectedAvgifter + expectedVacation + expectedVacationAvgifter) * 100) / 100
expect(result.totalEmployerCost).toBe(expectedCost)
})
})
describe('calculateKarensavdrag', () => {
it('calculates 20% of weekly sjuklön', () => {
// Formula: 20% × (40000 × 12/52 × 0.80)
const expected = Math.round(0.20 * (40000 * 12 / 52 * 0.80) * 100) / 100
expect(calculateKarensavdrag(40000, config2026)).toBe(expected)
})
})
describe('calculateSjuklon', () => {
it('calculates karensavdrag + sjuklön for sick days', () => {
const result = calculateSjuklon(40000, 5, config2026)
expect(result.karensavdrag).toBeGreaterThan(0)
expect(result.sjuklon).toBeGreaterThan(0)
expect(result.steps.length).toBeGreaterThan(0)
})
it('handles 1-day sick leave (karens only)', () => {
const result = calculateSjuklon(40000, 1, config2026)
expect(result.karensavdrag).toBeGreaterThan(0)
expect(result.sjuklon).toBe(0) // No sjuklön for day 1
})
it('caps at 13 sjuklön days (day 2-14)', () => {
const result14 = calculateSjuklon(40000, 14, config2026)
const result20 = calculateSjuklon(40000, 20, config2026)
// sjuklön should be the same for 14 and 20 days (capped at day 14)
expect(result14.sjuklon).toBe(result20.sjuklon)
})
})
describe('calculateAvgifterRate', () => {
it('returns standard rate for normal employee', () => {
const result = calculateAvgifterRate(
makeBasicInput(),
config2026,
2026
)
expect(result.rate).toBe(0.3142)
expect(result.category).toBe('standard')
})
it('returns reduced rate for 67+ employee', () => {
const result = calculateAvgifterRate(
makeBasicInput({ personnummer: 'mock_senior_person' }),
config2026,
2026
)
expect(result.rate).toBe(0.1021)
expect(result.category).toBe('reduced_65plus')
})
it('returns 0% for born ≤1937', () => {
const result = calculateAvgifterRate(
makeBasicInput({ personnummer: 'mock_old_person' }),
config2026,
2026
)
expect(result.rate).toBe(0)
expect(result.category).toBe('exempt')
})
it('returns växa-stöd rate when eligible', () => {
const result = calculateAvgifterRate(
makeBasicInput({
vaxaStodEligible: true,
vaxaStodStart: '2025-01-01',
vaxaStodEnd: '2026-12-31',
}),
config2026,
2026
)
expect(result.rate).toBe(0.1021)
expect(result.category).toBe('vaxa_stod')
})
})
+46
View File
@@ -0,0 +1,46 @@
import { describe, it, expect } from 'vitest'
import { calculateEngangsskatt } from '../engangsskatt'
describe('calculateEngangsskatt', () => {
it('calculates 0% tax for very low annual income', () => {
const result = calculateEngangsskatt(5000, 1000)
// Annual: 1000 × 12 + 5000 = 17,000 → bracket 0-20,000 → 0%
expect(result.taxRate).toBe(0.00)
expect(result.taxAmount).toBe(0)
})
it('calculates tax for moderate annual income', () => {
const result = calculateEngangsskatt(10000, 25000)
// Annual: 25000 × 12 + 10000 = 310,000 → bracket 200,001-350,000 → 30%
expect(result.taxRate).toBe(0.30)
expect(result.taxAmount).toBe(3000)
})
it('calculates higher tax when annual income crosses state tax threshold', () => {
const result = calculateEngangsskatt(50000, 55000)
// Annual: 55000 × 12 + 50000 = 710,000 → bracket 660,401-950,000 → 52%
expect(result.taxRate).toBe(0.52)
expect(result.taxAmount).toBe(26000)
})
it('uses total annual income including bonus for bracket lookup', () => {
const result = calculateEngangsskatt(100000, 40000)
// Annual: 40000 × 12 + 100000 = 580,000 → bracket 500,001-660,400 → 34%
expect(result.taxRate).toBe(0.34)
expect(result.taxAmount).toBe(34000)
})
it('returns calculation steps for transparency', () => {
const result = calculateEngangsskatt(10000, 30000)
expect(result.steps.length).toBe(2)
expect(result.steps[0].label).toBe('Beräknad årsinkomst')
expect(result.annualIncomeEstimate).toBe(370000) // 30000 × 12 + 10000
})
it('handles very high income bracket', () => {
const result = calculateEngangsskatt(200000, 120000)
// Annual: 120000 × 12 + 200000 = 1,640,000 → bracket ≥1,500,001 → 57%
expect(result.taxRate).toBe(0.57)
expect(result.taxAmount).toBe(114000)
})
})
+97
View File
@@ -0,0 +1,97 @@
import { describe, it, expect } from 'vitest'
import { calculateLoneVaxling } from '../lonevaxling'
import type { PayrollConfig } from '../payroll-config'
const config: PayrollConfig = {
configYear: 2026,
avgifterTotal: 0.3142,
avgifterAlderspension: 0.1021,
avgifterSjukforsakring: 0.0355,
avgifterForaldraforsakring: 0.0200,
avgifterEfterlevandepension: 0.0030,
avgifterArbetsmarknad: 0.0264,
avgifterArbetsskada: 0.0010,
avgifterAllmanLoneavgift: 0.1262,
avgifterReduced65plus: 0.1021,
avgifterYouthRate: 0.2081,
avgifterYouthSalaryCap: 25000,
avgifterVaxaStodRate: 0.1021,
avgifterVaxaStodCap: 35000,
avgifterMinimumAnnual: 1000,
egenavgifterTotal: 0.2897,
slpRate: 0.2426,
prisbasbelopp: 59200,
inkomstbasbelopp: 83400,
maxPgi: 625500,
sgiCeiling: 592000,
statligSkattBrytpunkt: 660400,
traktamenteHeldag: 300,
traktamenteHalvdag: 150,
traktamenteNatt: 150,
milersattningEgenBil: 25,
milersattningFormansbilFossil: 12,
milersattningFormansbilEl: 9.50,
kostformanHeldag: 310,
kostformanLunch: 124,
kostformanFrukost: 62,
friskvardCap: 5000,
bilformanSlr: 0.0255,
sjuklonRate: 0.80,
karensavdragFactor: 0.20,
maxKarensavdragPerYear: 10,
reducedAvgiftAge: 67,
}
const r = (x: number) => Math.round(x * 100) / 100
describe('calculateLoneVaxling', () => {
it('applies 1.058 factor to pension contribution', () => {
const result = calculateLoneVaxling(5000, 60000, config)
expect(result.pensionContribution).toBe(r(5000 * 1.058))
})
it('reduces salary by exact reduction amount', () => {
const result = calculateLoneVaxling(5000, 60000, config)
expect(result.postReductionSalary).toBe(55000)
})
it('calculates saved avgifter', () => {
const result = calculateLoneVaxling(5000, 60000, config)
expect(result.savedAvgifter).toBe(r(5000 * 0.3142))
})
it('calculates SLP on pension at 24.26%', () => {
const result = calculateLoneVaxling(5000, 60000, config)
const expectedSlp = r(5000 * 1.058 * 0.2426)
expect(result.slpOnPension).toBe(expectedSlp)
})
it('warns when post-reduction salary drops below PGI floor', () => {
// PGI floor = 8.07 × 83400 / 12 ≈ 56,088.50
const result = calculateLoneVaxling(20000, 60000, config)
// Post-reduction: 40,000 < 56,088
expect(result.warnings.length).toBeGreaterThan(0)
expect(result.warnings[0]).toContain('PGI-golv')
})
it('does not warn when salary stays above PGI floor', () => {
const result = calculateLoneVaxling(3000, 80000, config)
// Post-reduction: 77,000 > PGI floor
const pgiWarnings = result.warnings.filter(w => w.includes('PGI'))
expect(pgiWarnings.length).toBe(0)
})
it('warns when annual pension exceeds 10 × PBB cap', () => {
// Cap = 10 × 59200 = 592,000 SEK/year
// Monthly contribution = 50000 × 1.058 = 52,900 → annual = 634,800 > 592,000
const result = calculateLoneVaxling(50000, 100000, config)
expect(result.warnings.some(w => w.includes('PBB'))).toBe(true)
})
it('returns calculation steps for transparency', () => {
const result = calculateLoneVaxling(5000, 60000, config)
expect(result.steps.length).toBeGreaterThanOrEqual(5)
expect(result.steps.some(s => s.label === 'Pensionsavsättning')).toBe(true)
expect(result.steps.some(s => s.label === 'Särskild löneskatt på pension')).toBe(true)
})
})
+92
View File
@@ -0,0 +1,92 @@
import { describe, it, expect } from 'vitest'
import { generatePain001 } from '../payment/pain001-generator'
describe('generatePain001', () => {
const company = {
name: 'Test AB',
orgNumber: '556123-4567',
iban: 'SE1234567890123456789012',
bic: 'ESSESESS',
}
const employees = [
{ name: 'Anna Andersson', clearingNumber: '5678', bankAccountNumber: '1234567890', netSalary: 28000 },
{ name: 'Erik Eriksson', clearingNumber: '1234', bankAccountNumber: '9876543210', netSalary: 32000 },
]
const options = {
messageId: 'GNUBOK-5561234567-2026-04',
paymentDate: '2026-04-25',
periodLabel: '2026-04',
}
it('generates valid XML structure', () => {
const xml = generatePain001(company, employees, options)
expect(xml).toContain('<?xml version="1.0" encoding="UTF-8"?>')
expect(xml).toContain('pain.001.001.03')
expect(xml).toContain('<CstmrCdtTrfInitn>')
expect(xml).toContain('</Document>')
})
it('includes group header with correct counts', () => {
const xml = generatePain001(company, employees, options)
expect(xml).toContain(`<NbOfTxs>2</NbOfTxs>`)
expect(xml).toContain(`<CtrlSum>60000.00</CtrlSum>`)
})
it('includes company details', () => {
const xml = generatePain001(company, employees, options)
expect(xml).toContain('<Nm>Test AB</Nm>')
expect(xml).toContain('SE1234567890123456789012')
expect(xml).toContain('ESSESESS')
})
it('includes SALA category purpose for salary', () => {
const xml = generatePain001(company, employees, options)
expect(xml).toContain('<Cd>SALA</Cd>')
})
it('includes per-employee credit transfers', () => {
const xml = generatePain001(company, employees, options)
expect(xml).toContain('<Nm>Anna Andersson</Nm>')
expect(xml).toContain('<InstdAmt Ccy="SEK">28000.00</InstdAmt>')
expect(xml).toContain('<Nm>Erik Eriksson</Nm>')
expect(xml).toContain('<InstdAmt Ccy="SEK">32000.00</InstdAmt>')
})
it('includes bank account details per employee', () => {
const xml = generatePain001(company, employees, options)
expect(xml).toContain('56781234567890') // Anna's clearing + account
expect(xml).toContain('12349876543210') // Erik's clearing + account
})
it('includes remittance info with period label', () => {
const xml = generatePain001(company, employees, options)
expect(xml).toContain('Lon 2026-04')
})
it('includes payment date', () => {
const xml = generatePain001(company, employees, options)
expect(xml).toContain('<ReqdExctnDt>2026-04-25</ReqdExctnDt>')
})
it('escapes XML special characters', () => {
const specialCompany = { ...company, name: 'Test & Sons <AB>' }
const xml = generatePain001(specialCompany, employees, options)
expect(xml).toContain('Test &amp; Sons &lt;AB&gt;')
expect(xml).not.toContain('Test & Sons <AB>')
})
it('formats amounts with 2 decimal places', () => {
const empWithDecimals = [
{ name: 'Test', clearingNumber: '1234', bankAccountNumber: '5678', netSalary: 28333.33 },
]
const xml = generatePain001(company, empWithDecimals, options)
expect(xml).toContain('28333.33')
})
})
+111
View File
@@ -0,0 +1,111 @@
import { describe, it, expect } from 'vitest'
import {
validatePersonnummer,
extractLast4,
extractBirthDate,
calculateAge,
calculateAgeAtYearStart,
maskPersonnummer,
formatPersonnummer,
encryptPersonnummer,
decryptPersonnummer,
} from '../personnummer'
describe('validatePersonnummer', () => {
it('accepts valid 12-digit personnummer', () => {
// Valid test personnummer (checksum matches)
const result = validatePersonnummer('199001019802')
expect(result.valid).toBe(true)
})
it('rejects non-12-digit input', () => {
const result = validatePersonnummer('9001019802')
expect(result.valid).toBe(false)
expect(result.error).toContain('12 siffror')
})
it('rejects invalid month', () => {
const result = validatePersonnummer('199013019802')
expect(result.valid).toBe(false)
expect(result.error).toContain('månad')
})
it('rejects invalid day', () => {
const result = validatePersonnummer('199001329802')
expect(result.valid).toBe(false)
expect(result.error).toContain('dag')
})
it('strips non-digits before validation', () => {
const result = validatePersonnummer('19900101-9802')
expect(result.valid).toBe(true)
})
})
describe('extractLast4', () => {
it('extracts last 4 digits', () => {
expect(extractLast4('199001019802')).toBe('9802')
})
it('handles dash-formatted input', () => {
expect(extractLast4('19900101-9802')).toBe('9802')
})
})
describe('extractBirthDate', () => {
it('extracts birth date from 12-digit personnummer', () => {
const result = extractBirthDate('199001019802')
expect(result.year).toBe(1990)
expect(result.month).toBe(1)
expect(result.day).toBe(1)
})
})
describe('calculateAge', () => {
it('calculates age at a given date', () => {
expect(calculateAge('199001019802', '2026-04-14')).toBe(36)
})
it('returns age minus one before birthday', () => {
expect(calculateAge('199006159802', '2026-06-14')).toBe(35)
expect(calculateAge('199006159802', '2026-06-15')).toBe(36)
})
})
describe('calculateAgeAtYearStart', () => {
it('calculates age at January 1 of given year', () => {
expect(calculateAgeAtYearStart('199001019802', 2026)).toBe(36)
expect(calculateAgeAtYearStart('199012319802', 2026)).toBe(35)
})
})
describe('maskPersonnummer', () => {
it('masks with XXXXXXXX-XXXX format', () => {
expect(maskPersonnummer('9802')).toBe('XXXXXXXX-9802')
})
})
describe('formatPersonnummer', () => {
it('formats with dash', () => {
expect(formatPersonnummer('199001019802')).toBe('19900101-9802')
})
})
describe('encryption roundtrip', () => {
it('encrypts and decrypts correctly', () => {
const pnr = '199001019802'
const encrypted = encryptPersonnummer(pnr)
expect(encrypted).not.toBe(pnr)
expect(encrypted.length).toBeGreaterThan(pnr.length)
const decrypted = decryptPersonnummer(encrypted)
expect(decrypted).toBe(pnr)
})
it('produces different ciphertexts for same input (random IV)', () => {
const pnr = '199001019802'
const a = encryptPersonnummer(pnr)
const b = encryptPersonnummer(pnr)
expect(a).not.toBe(b)
})
})
+63
View File
@@ -0,0 +1,63 @@
import { describe, it, expect } from 'vitest'
import { lookupTaxAmount, calculateJamkningTax, calculateSidoinkomstTax } from '../tax-tables'
import type { TaxTableRate } from '../tax-tables'
const sampleRates: TaxTableRate[] = [
{ tableYear: 2026, tableNumber: 33, columnNumber: 1, incomeFrom: 0, incomeTo: 2200, taxAmount: 0 },
{ tableYear: 2026, tableNumber: 33, columnNumber: 1, incomeFrom: 2201, incomeTo: 20000, taxAmount: 2800 },
{ tableYear: 2026, tableNumber: 33, columnNumber: 1, incomeFrom: 20001, incomeTo: 30000, taxAmount: 5600 },
{ tableYear: 2026, tableNumber: 33, columnNumber: 1, incomeFrom: 30001, incomeTo: 40000, taxAmount: 8900 },
{ tableYear: 2026, tableNumber: 33, columnNumber: 1, incomeFrom: 40001, incomeTo: 50000, taxAmount: 12500 },
{ tableYear: 2026, tableNumber: 33, columnNumber: 1, incomeFrom: 50001, incomeTo: 60000, taxAmount: 16800 },
]
describe('lookupTaxAmount', () => {
it('returns 0 for income below minimum bracket', () => {
expect(lookupTaxAmount(33, 1, 1500, sampleRates)).toBe(0)
})
it('matches correct bracket for mid-range income', () => {
expect(lookupTaxAmount(33, 1, 25000, sampleRates)).toBe(5600)
})
it('matches bracket boundary exactly', () => {
expect(lookupTaxAmount(33, 1, 20001, sampleRates)).toBe(5600)
expect(lookupTaxAmount(33, 1, 30000, sampleRates)).toBe(5600)
})
it('uses last bracket for income exceeding all brackets', () => {
expect(lookupTaxAmount(33, 1, 100000, sampleRates)).toBe(16800)
})
it('falls back to 30% when table not found', () => {
expect(lookupTaxAmount(99, 1, 40000, sampleRates)).toBe(12000)
})
it('filters by correct column', () => {
const rates: TaxTableRate[] = [
...sampleRates,
{ tableYear: 2026, tableNumber: 33, columnNumber: 2, incomeFrom: 0, incomeTo: 50000, taxAmount: 999 },
]
expect(lookupTaxAmount(33, 2, 30000, rates)).toBe(999)
})
})
describe('calculateJamkningTax', () => {
it('calculates tax using custom percentage', () => {
expect(calculateJamkningTax(40000, 20)).toBe(8000)
})
it('rounds to 2 decimal places', () => {
expect(calculateJamkningTax(33333, 15.5)).toBe(5166.62)
})
})
describe('calculateSidoinkomstTax', () => {
it('calculates flat 30%', () => {
expect(calculateSidoinkomstTax(40000)).toBe(12000)
})
it('rounds to 2 decimal places', () => {
expect(calculateSidoinkomstTax(33333)).toBe(9999.90)
})
})
+163
View File
@@ -0,0 +1,163 @@
import { describe, it, expect } from 'vitest'
import { calculateTraktamente, calculateMileageAllowance } from '../traktamente'
import type { PayrollConfig } from '../payroll-config'
const config: PayrollConfig = {
configYear: 2026,
avgifterTotal: 0.3142,
avgifterAlderspension: 0.1021,
avgifterSjukforsakring: 0.0355,
avgifterForaldraforsakring: 0.0200,
avgifterEfterlevandepension: 0.0030,
avgifterArbetsmarknad: 0.0264,
avgifterArbetsskada: 0.0010,
avgifterAllmanLoneavgift: 0.1262,
avgifterReduced65plus: 0.1021,
avgifterYouthRate: 0.2081,
avgifterYouthSalaryCap: 25000,
avgifterVaxaStodRate: 0.1021,
avgifterVaxaStodCap: 35000,
avgifterMinimumAnnual: 1000,
egenavgifterTotal: 0.2897,
slpRate: 0.2426,
prisbasbelopp: 59200,
inkomstbasbelopp: 83400,
maxPgi: 625500,
sgiCeiling: 592000,
statligSkattBrytpunkt: 660400,
traktamenteHeldag: 300,
traktamenteHalvdag: 150,
traktamenteNatt: 150,
milersattningEgenBil: 25,
milersattningFormansbilFossil: 12,
milersattningFormansbilEl: 9.50,
kostformanHeldag: 310,
kostformanLunch: 124,
kostformanFrukost: 62,
friskvardCap: 5000,
bilformanSlr: 0.0255,
sjuklonRate: 0.80,
karensavdragFactor: 0.20,
maxKarensavdragPerYear: 10,
reducedAvgiftAge: 67,
}
describe('calculateTraktamente', () => {
it('calculates full-day traktamente at 300 SEK', () => {
const result = calculateTraktamente({
tripType: 'full_day',
days: 3,
mealsProvided: 'none',
consecutiveMonths: 0,
config,
})
expect(result.taxFree).toBe(900) // 300 × 3
expect(result.taxable).toBe(0)
})
it('calculates half-day at 150 SEK', () => {
const result = calculateTraktamente({
tripType: 'half_day',
days: 2,
mealsProvided: 'none',
consecutiveMonths: 0,
config,
})
expect(result.taxFree).toBe(300) // 150 × 2
})
it('applies tremånadersregeln — 70% after 3 months', () => {
const result = calculateTraktamente({
tripType: 'full_day',
days: 1,
mealsProvided: 'none',
consecutiveMonths: 4,
config,
})
expect(result.taxFree).toBe(Math.round(300 * 0.70 * 100) / 100) // 210
// If employer pays full rate (300), excess is taxable
expect(result.taxable).toBe(Math.round((300 - 210) * 100) / 100) // 90
})
it('applies tremånadersregeln — 50% after 2 years', () => {
const result = calculateTraktamente({
tripType: 'full_day',
days: 1,
mealsProvided: 'none',
consecutiveMonths: 25,
config,
})
expect(result.taxFree).toBe(150) // 300 × 50%
expect(result.taxable).toBe(150) // 300 - 150
})
it('reduces for meals provided — lunch reduces by 35%', () => {
const result = calculateTraktamente({
tripType: 'full_day',
days: 1,
mealsProvided: 'lunch',
consecutiveMonths: 0,
config,
})
const mealReduction = Math.round(300 * 0.35 * 100) / 100
expect(result.taxFree).toBe(Math.round((300 - mealReduction) * 100) / 100)
})
it('reduces for all meals — 85%', () => {
const result = calculateTraktamente({
tripType: 'full_day',
days: 1,
mealsProvided: 'all',
consecutiveMonths: 0,
config,
})
const mealReduction = Math.round(300 * 0.85 * 100) / 100
expect(result.taxFree).toBe(Math.round((300 - mealReduction) * 100) / 100)
})
})
describe('calculateMileageAllowance', () => {
it('calculates own car at 25 SEK/mil', () => {
const result = calculateMileageAllowance({
mil: 10,
vehicleType: 'own_car',
paidPerMil: 25,
config,
})
expect(result.taxFree).toBe(250) // 25 × 10
expect(result.taxable).toBe(0)
})
it('calculates company car fossil at 12 SEK/mil', () => {
const result = calculateMileageAllowance({
mil: 10,
vehicleType: 'company_car_fossil',
paidPerMil: 12,
config,
})
expect(result.taxFree).toBe(120)
expect(result.taxable).toBe(0)
})
it('calculates company car electric at 9.50 SEK/mil', () => {
const result = calculateMileageAllowance({
mil: 10,
vehicleType: 'company_car_electric',
paidPerMil: 9.50,
config,
})
expect(result.taxFree).toBe(95)
expect(result.taxable).toBe(0)
})
it('marks excess as taxable when paid above tax-free rate', () => {
const result = calculateMileageAllowance({
mil: 10,
vehicleType: 'own_car',
paidPerMil: 30, // 5 above tax-free
config,
})
expect(result.taxFree).toBe(250) // 25 × 10
expect(result.taxable).toBe(50) // (30-25) × 10
})
})
+225
View File
@@ -0,0 +1,225 @@
import type { PayrollConfig } from './payroll-config'
/**
* Absence calculation for Swedish payroll.
* Implements Sjuklönelagen (SjLL), Semesterlagen, and Föräldraledighetslagen.
*/
export interface SjuklonResult {
karensavdrag: number
sjuklonDays: number
sjuklonAmount: number
dailyRate: number
weeklyRate: number
totalDeduction: number // Net reduction from normal pay
steps: AbsenceStep[]
}
export interface AbsenceStep {
label: string
formula: string
input: Record<string, number | string>
output: number
}
/**
* Calculate sjuklön for a sick period.
*
* Day 1: Karensavdrag = 20% × (monthly × 12/52 × 80%)
* Day 2-14: 80% of daily rate
* Day 15+: Försäkringskassan pays (not employer's cost)
*
* Återinsjuknande: If employee returns and falls sick again within 5 calendar
* days, it counts as the same sjuklöneperiod (no new karensavdrag).
*/
export function calculateSjuklon(
monthlySalary: number,
sickDays: number,
config: PayrollConfig,
isAterinsjuknande: boolean = false
): SjuklonResult {
const steps: AbsenceStep[] = []
const r = (x: number) => Math.round(x * 100) / 100
// Daily rate = monthly / 21 working days
const dailyRate = r(monthlySalary / 21)
steps.push({
label: 'Dagslön',
formula: 'monthly_salary / 21',
input: { monthly_salary: monthlySalary },
output: dailyRate,
})
// Weekly sjuklön = monthly × 12 / 52 × 80%
const weeklyRate = r(monthlySalary * 12 / 52 * config.sjuklonRate)
steps.push({
label: 'Veckosjuklön',
formula: 'monthly × 12/52 × 80%',
input: { monthly_salary: monthlySalary, sjuklon_rate: config.sjuklonRate },
output: weeklyRate,
})
// Karensavdrag (only if not återinsjuknande within 5 days)
let karensavdrag = 0
if (!isAterinsjuknande) {
karensavdrag = r(weeklyRate * config.karensavdragFactor)
steps.push({
label: 'Karensavdrag',
formula: 'veckosjuklön × 20%',
input: { weekly_sjuklon: weeklyRate, factor: config.karensavdragFactor },
output: karensavdrag,
})
} else {
steps.push({
label: 'Karensavdrag (återinsjuknande)',
formula: '0 (inom 5 kalenderdagar)',
input: {},
output: 0,
})
}
// Sjuklön day 2-14: 80% of daily rate
const sjuklonDays = Math.min(Math.max(sickDays - (isAterinsjuknande ? 0 : 1), 0), 13)
const sjuklonAmount = r(dailyRate * config.sjuklonRate * sjuklonDays)
steps.push({
label: 'Sjuklön dag 2-14',
formula: 'dagslön × 80% × sjukdagar',
input: { daily_rate: dailyRate, sjuklon_rate: config.sjuklonRate, days: sjuklonDays },
output: sjuklonAmount,
})
// Total deduction = what employee loses vs normal pay
// Normal pay for period = dailyRate × sickDays
// They get: sjuklön - karensavdrag (karensavdrag reduces their sjuklön)
const normalPay = r(dailyRate * sickDays)
const totalDeduction = r(normalPay - sjuklonAmount + karensavdrag)
steps.push({
label: 'Löneavdrag sjukfrånvaro',
formula: 'normal_pay - sjuklön + karensavdrag',
input: { normal_pay: normalPay, sjuklon: sjuklonAmount, karensavdrag },
output: totalDeduction,
})
return {
karensavdrag,
sjuklonDays,
sjuklonAmount,
dailyRate,
weeklyRate,
totalDeduction,
steps,
}
}
/**
* Calculate VAB (vård av barn) deduction.
* Full daily rate deduction — Försäkringskassan compensates the parent.
* Semesterlönegrundande for first 120 days (180 for sole custody) per §17b.
*/
export function calculateVabDeduction(
monthlySalary: number,
vabDays: number,
totalVabDaysThisYear: number = 0
): { deduction: number; semesterGrundande: boolean; steps: AbsenceStep[] } {
const r = (x: number) => Math.round(x * 100) / 100
const dailyRate = r(monthlySalary / 21)
const deduction = r(dailyRate * vabDays)
const semesterGrundande = totalVabDaysThisYear + vabDays <= 120
return {
deduction,
semesterGrundande,
steps: [{
label: 'VAB-avdrag',
formula: 'dagslön × vab_dagar',
input: { daily_rate: dailyRate, vab_days: vabDays, ytd_days: totalVabDaysThisYear },
output: deduction,
}],
}
}
/**
* Calculate parental leave deduction.
* Semesterlönegrundande for first 120 days per pregnancy per §17a.
*/
export function calculateParentalLeaveDeduction(
monthlySalary: number,
parentalDays: number,
totalParentalDaysThisPregnancy: number = 0
): { deduction: number; semesterGrundande: boolean; steps: AbsenceStep[] } {
const r = (x: number) => Math.round(x * 100) / 100
const dailyRate = r(monthlySalary / 21)
const deduction = r(dailyRate * parentalDays)
const semesterGrundande = totalParentalDaysThisPregnancy + parentalDays <= 120
return {
deduction,
semesterGrundande,
steps: [{
label: 'Föräldraledigavdrag',
formula: 'dagslön × föräldradagar',
input: { daily_rate: dailyRate, parental_days: parentalDays },
output: deduction,
}],
}
}
/**
* Calculate vacation pay for taken vacation days.
*
* Sammalöneregeln (§16a): Regular pay continues + semestertillägg per day
* Procentregeln (§16): 12% of semesterlönegrundande (14.4% for 30 days)
*/
export function calculateVacationPay(params: {
monthlySalary: number
vacationDaysTaken: number
vacationRule: 'procentregeln' | 'sammaloneregeln'
semestertillaggRate: number
vacationDaysPerYear: number
}): { amount: number; tillagg: number; steps: AbsenceStep[] } {
const r = (x: number) => Math.round(x * 100) / 100
const dailyRate = r(params.monthlySalary / 21)
if (params.vacationRule === 'sammaloneregeln') {
// Sammalöneregeln: regular pay + semestertillägg per day
const tillagg = r(params.monthlySalary * params.semestertillaggRate * params.vacationDaysTaken)
return {
amount: tillagg, // Regular pay continues, only tillägg is extra
tillagg,
steps: [{
label: 'Semestertillägg (sammalöneregeln)',
formula: 'monthly × tillagg_rate × vacation_days',
input: {
monthly_salary: params.monthlySalary,
rate: params.semestertillaggRate,
days: params.vacationDaysTaken,
},
output: tillagg,
}],
}
} else {
// Procentregeln: daily vacation pay based on 12% of annual basis
// This is typically used for hourly workers; the daily rate comes from their accrued pool
const rate = params.vacationDaysPerYear >= 30 ? 0.144 : 0.12
const annualBasis = r(params.monthlySalary * 12)
const totalVacationPay = r(annualBasis * rate)
const perDay = r(totalVacationPay / params.vacationDaysPerYear)
const amount = r(perDay * params.vacationDaysTaken)
return {
amount,
tillagg: 0,
steps: [{
label: `Semesterlön (procentregeln ${rate * 100}%)`,
formula: '(annual_basis × rate / entitled_days) × taken_days',
input: {
annual_basis: annualBasis,
rate,
entitled_days: params.vacationDaysPerYear,
taken_days: params.vacationDaysTaken,
},
output: amount,
}],
}
}
}
+102
View File
@@ -0,0 +1,102 @@
import type { SalaryLineItemType } from '@/types'
/**
* Salary account mapping — maps line item types and calculation results
* to BAS accounts per Swedish chart of accounts standards.
*/
/** Default BAS account for each salary line item type */
const LINE_ITEM_ACCOUNTS: Record<SalaryLineItemType, string> = {
// Salary components
monthly_salary: '7210',
hourly_salary: '7210',
overtime: '7210',
bonus: '7210',
commission: '7210',
// Gross deductions
gross_deduction_pension: '7218',
gross_deduction_other: '7210',
// Benefits (förmånsvärden — not a cash payment, just tax base)
benefit_car: '7385',
benefit_housing: '7381',
benefit_meals: '7382',
benefit_wellness: '7699',
benefit_other: '7389',
// Absence
sick_karens: '7281',
sick_day2_14: '7281',
sick_day15_plus: '7281',
vab: '7210',
parental_leave: '7210',
vacation: '7285',
// Travel
traktamente_taxfree: '7321',
traktamente_taxable: '7322',
mileage_taxfree: '7331',
mileage_taxable: '7332',
// Net deductions
net_deduction_advance: '7210',
net_deduction_union: '7210',
net_deduction_benefit_payment: '7385',
net_deduction_other: '7210',
// Other
correction: '7210',
other: '7210',
}
/**
* Get the BAS account number for a salary line item type.
* Can be overridden per line item via account_number field.
*/
export function getLineItemAccount(
itemType: SalaryLineItemType,
employmentType: string = 'employee'
): string {
// Company owner uses 7220 instead of 7210
if (employmentType === 'company_owner') {
const baseAccount = LINE_ITEM_ACCOUNTS[itemType]
if (baseAccount === '7210') return '7220'
if (baseAccount === '7281') return '7282'
if (baseAccount === '7285') return '7286'
}
// Board member uses 7240
if (employmentType === 'board_member') {
const baseAccount = LINE_ITEM_ACCOUNTS[itemType]
if (baseAccount === '7210') return '7240'
}
return LINE_ITEM_ACCOUNTS[itemType]
}
/** Journal entry accounts for salary booking */
export const SALARY_ACCOUNTS = {
// Salary expense (debit)
SALARY_EMPLOYEE: '7210', // Löner till tjänstemän
SALARY_OWNER: '7220', // Löner till företagsledare
SALARY_BOARD: '7240', // Styrelsearvoden
SICK_PAY: '7281', // Sjuklöner
VACATION_PAY: '7285', // Semesterlöner
// Tax withholding (credit)
TAX_WITHHELD: '2710', // Personalskatt
// Bank / payment (credit)
BANK: '1930', // Företagskonto
// Employer contributions
AVGIFTER_EXPENSE: '7510', // Lagstadgade sociala avgifter (debit)
AVGIFTER_LIABILITY: '2731', // Avräkning sociala avgifter (credit)
// Vacation accrual
VACATION_ACCRUAL_EXPENSE: '7290', // Förändring semesterlöneskuld (debit)
VACATION_ACCRUAL_LIABILITY: '2920', // Upplupna semesterlöner (credit)
// Vacation accrual avgifter
VACATION_AVGIFTER_EXPENSE: '7519', // Sociala avgifter semester (debit)
VACATION_AVGIFTER_LIABILITY: '2940', // Upplupna sociala avgifter (credit)
// Pension provisions (löneväxling)
PENSION_EXPENSE: '7410', // Pensionsförsäkringspremier (debit)
PENSION_LIABILITY: '2740', // Skuld pensionsförsäkringar (credit)
SLP_EXPENSE: '7533', // Särskild löneskatt på pensionskostnader (debit)
SLP_LIABILITY: '2514', // Beräknad särskild löneskatt (credit)
} as const
+99
View File
@@ -0,0 +1,99 @@
/**
* AGI (Arbetsgivardeklaration) field codes per Skatteverket Teknisk beskrivning.
*
* FK = Fältkod (field code)
* Ruta = Box number on the form
*/
// ============================================================
// Huvuduppgift (Employer totals)
// ============================================================
/** Employer-level field codes */
export const HUVUDUPPGIFT_FIELDS = {
/** Total skatteavdrag — sum of all employee tax withholdings */
RUTA_001: '001',
/** Total underlag for arbetsgivaravgifter */
RUTA_020: '020',
/** Arbetsgivaravgifter — standard rate (31.42%) */
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) */
RUTA_062: '062',
} as const
// ============================================================
// Individuppgift (Per-employee data)
// ============================================================
/** Per-employee field codes */
export const INDIVID_FIELDS = {
/** Personnummer/samordningsnummer (12 digits, CRITICAL: must be decrypted) */
FK215: '215',
/** Specifikationsnummer — MUST stay consistent per employee for corrections */
FK570: '570',
/** Kontant bruttolön (gross cash salary) */
RUTA_011: '011',
/** Avdragen skatt (withheld preliminary tax) */
RUTA_001: '001',
/** Förmånsvärde — bilförmån */
RUTA_012: '012',
/** Förmånsvärde — drivmedel vid bilförmån */
RUTA_013: '013',
/** Förmånsvärde — bostad */
RUTA_014: '014',
/** Förmånsvärde — kost */
RUTA_015: '015',
/** Förmånsvärde — ränta */
RUTA_016: '016',
/** Förmånsvärde — övriga */
RUTA_019: '019',
/** Underlag för arbetsgivaravgifter */
RUTA_020: '020',
/** Ersättning till mottagare med F-skattsedel (not subject to avgifter) */
RUTA_131: '131',
// Absence fields (from 2025)
/** Sjukfrånvaro — antal dagar */
FK821: '821',
/** VAB — antal dagar */
FK822: '822',
/** Föräldraledighet — antal dagar */
FK823: '823',
/** Graviditetspenning — antal dagar */
FK824: '824',
/** Smittbärarpenning — antal dagar */
FK825: '825',
/** Sjuk-/aktivitetsersättning — antal dagar */
FK826: '826',
/** Rehabilitering — antal dagar */
FK827: '827',
} as const
// ============================================================
// Benefit type to ruta mapping
// ============================================================
/** Map benefit item types to AGI individuppgift rutor */
export const BENEFIT_RUTA_MAP: Record<string, string> = {
benefit_car: INDIVID_FIELDS.RUTA_012,
benefit_housing: INDIVID_FIELDS.RUTA_014,
benefit_meals: INDIVID_FIELDS.RUTA_015,
benefit_wellness: INDIVID_FIELDS.RUTA_019,
benefit_other: INDIVID_FIELDS.RUTA_019,
}
// ============================================================
// Avgifter category to ruta mapping
// ============================================================
export type AvgifterCategory = 'standard' | 'reduced_65plus' | 'youth' | 'vaxa_stod' | 'exempt'
/** Map avgifter categories to huvuduppgift rutor */
export const AVGIFTER_RUTA_MAP: Record<AvgifterCategory, string> = {
standard: HUVUDUPPGIFT_FIELDS.RUTA_060,
reduced_65plus: HUVUDUPPGIFT_FIELDS.RUTA_061,
youth: HUVUDUPPGIFT_FIELDS.RUTA_062,
vaxa_stod: HUVUDUPPGIFT_FIELDS.RUTA_061, // Växa-stöd uses same rate as 65+
exempt: '', // No avgifter
}
+246
View File
@@ -0,0 +1,246 @@
import { decryptPersonnummer } from '../personnummer'
/**
* AGI XML generator — Arbetsgivardeklaration per Skatteverket Teknisk beskrivning.
*
* Generates the XML content for filing employer declarations.
* The XML is stored in agi_declarations.xml_content for 7-year retention.
*/
export interface AGIEmployeeData {
personnummer: string // Encrypted — will be decrypted for XML
specificationNumber: number // FK570 — MUST stay consistent
grossSalary: number // Ruta 011
taxWithheld: number // Ruta 001
avgifterBasis: number // Ruta 020
fSkattPayment?: number // Ruta 131 (F-skatt holders)
// Benefits by type
benefitCar?: number // Ruta 012
benefitFuel?: number // Ruta 013
benefitHousing?: number // Ruta 014
benefitMeals?: number // Ruta 015
benefitOther?: number // Ruta 019
// Absence (from 2025)
sickDays?: number // FK821
vabDays?: number // FK822
parentalDays?: number // FK823
}
export interface AGICompanyData {
orgNumber: string // NNNNNN-NNNN format
companyName: string
periodYear: number
periodMonth: number
contactName: string
contactPhone: string
contactEmail: string
}
export interface AGITotals {
totalTax: number // Ruta 001 (huvuduppgift)
totalAvgifterBasis: number // Ruta 020 (huvuduppgift)
avgifterByCategory: {
standard?: { basis: number; amount: number }
reduced65plus?: { basis: number; amount: number }
youth?: { basis: number; amount: number }
}
}
/**
* Generate AGI XML for a period.
*
* CRITICAL: FK570 (specifikationsnummer) must stay consistent per employee.
* Using a different number creates a new record instead of correcting.
*/
export function generateAGIXml(
company: AGICompanyData,
employees: AGIEmployeeData[],
totals: AGITotals,
isCorrection: boolean = false
): string {
const period = `${company.periodYear}${String(company.periodMonth).padStart(2, '0')}`
const lines: string[] = []
lines.push('<?xml version="1.0" encoding="UTF-8"?>')
lines.push('<Skatteverket xmlns="http://xmls.skatteverket.se/se/skatteverket/ai/instans/infoForBeskworksgiv662/1.0"')
lines.push(' xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">')
lines.push(' <Avsandare>')
lines.push(` <Programnamn>gnubok</Programnamn>`)
lines.push(` <Organisationsnummer>${escapeXml(company.orgNumber.replace('-', ''))}</Organisationsnummer>`)
lines.push(' <TekniskKontaktperson>')
lines.push(` <Namn>${escapeXml(company.contactName)}</Namn>`)
lines.push(` <Telefon>${escapeXml(company.contactPhone)}</Telefon>`)
lines.push(` <Epostadress>${escapeXml(company.contactEmail)}</Epostadress>`)
lines.push(' </TekniskKontaktperson>')
lines.push(' </Avsandare>')
lines.push(' <Blankettgemensamt>')
lines.push(` <Arbetsgivare>`)
lines.push(` <AgRegistreradId>${escapeXml(company.orgNumber.replace('-', ''))}</AgRegistreradId>`)
lines.push(` </Arbetsgivare>`)
lines.push(' </Blankettgemensamt>')
// Huvuduppgift (employer totals)
lines.push(' <Blankett>')
lines.push(' <Arendeinformation>')
lines.push(` <Arendeagare>${escapeXml(company.orgNumber.replace('-', ''))}</Arendeagare>`)
lines.push(` <Period>${period}</Period>`)
if (isCorrection) {
lines.push(' <Rattelse>J</Rattelse>')
}
lines.push(' </Arendeinformation>')
lines.push(' <Blankettinnehall>')
lines.push(' <HU>')
// Ruta 001: Total skatteavdrag
if (totals.totalTax > 0) {
lines.push(` <AvdragenSkatt faltkod="001">${formatAmount(totals.totalTax)}</AvdragenSkatt>`)
}
// Ruta 020: Total avgifter basis
if (totals.totalAvgifterBasis > 0) {
lines.push(` <SummaArbAvg>${formatAmount(totals.totalAvgifterBasis)}</SummaArbAvg>`)
}
// Avgifter by category
if (totals.avgifterByCategory.standard) {
lines.push(` <AvgUnderlagStandardRate faltkod="060">${formatAmount(totals.avgifterByCategory.standard.basis)}</AvgUnderlagStandardRate>`)
}
if (totals.avgifterByCategory.reduced65plus) {
lines.push(` <AvgUnderlagAlderspension faltkod="061">${formatAmount(totals.avgifterByCategory.reduced65plus.basis)}</AvgUnderlagAlderspension>`)
}
if (totals.avgifterByCategory.youth) {
lines.push(` <AvgUnderlagUngdom faltkod="062">${formatAmount(totals.avgifterByCategory.youth.basis)}</AvgUnderlagUngdom>`)
}
lines.push(' </HU>')
lines.push(' </Blankettinnehall>')
lines.push(' </Blankett>')
// Individuppgifter (per employee)
for (const emp of employees) {
lines.push(' <Blankett>')
lines.push(' <Arendeinformation>')
lines.push(` <Arendeagare>${escapeXml(company.orgNumber.replace('-', ''))}</Arendeagare>`)
lines.push(` <Period>${period}</Period>`)
if (isCorrection) {
lines.push(' <Rattelse>J</Rattelse>')
}
lines.push(' </Arendeinformation>')
lines.push(' <Blankettinnehall>')
lines.push(' <IU>')
// FK215: Personnummer (CRITICAL: must be decrypted for AGI)
let pnr: string
try {
pnr = decryptPersonnummer(emp.personnummer)
} catch {
throw new Error(`Kunde inte dekryptera personnummer för anställd med FK570=${emp.specificationNumber}. AGI kan inte genereras utan giltigt personnummer.`)
}
lines.push(` <Personnummer faltkod="215">${pnr}</Personnummer>`)
// FK570: Specifikationsnummer (MUST stay consistent)
lines.push(` <Specifikationsnummer faltkod="570">${emp.specificationNumber}</Specifikationsnummer>`)
// Ruta 011: Gross salary
if (emp.grossSalary > 0) {
lines.push(` <KontantBruttoloen faltkod="011">${formatAmount(emp.grossSalary)}</KontantBruttoloen>`)
}
// Ruta 001: Tax withheld
if (emp.taxWithheld > 0) {
lines.push(` <AvdragenSkatt faltkod="001">${formatAmount(emp.taxWithheld)}</AvdragenSkatt>`)
}
// Benefits
if (emp.benefitCar && emp.benefitCar > 0) {
lines.push(` <FormanBil faltkod="012">${formatAmount(emp.benefitCar)}</FormanBil>`)
}
if (emp.benefitFuel && emp.benefitFuel > 0) {
lines.push(` <FormanDrivmedel faltkod="013">${formatAmount(emp.benefitFuel)}</FormanDrivmedel>`)
}
if (emp.benefitHousing && emp.benefitHousing > 0) {
lines.push(` <FormanBostad faltkod="014">${formatAmount(emp.benefitHousing)}</FormanBostad>`)
}
if (emp.benefitMeals && emp.benefitMeals > 0) {
lines.push(` <FormanKost faltkod="015">${formatAmount(emp.benefitMeals)}</FormanKost>`)
}
if (emp.benefitOther && emp.benefitOther > 0) {
lines.push(` <FormanOvrigt faltkod="019">${formatAmount(emp.benefitOther)}</FormanOvrigt>`)
}
// Ruta 020: Avgifter basis
if (emp.avgifterBasis > 0) {
lines.push(` <UnderlagArbAvg faltkod="020">${formatAmount(emp.avgifterBasis)}</UnderlagArbAvg>`)
}
// Ruta 131: F-skatt payments
if (emp.fSkattPayment && emp.fSkattPayment > 0) {
lines.push(` <ErsattningFSkatt faltkod="131">${formatAmount(emp.fSkattPayment)}</ErsattningFSkatt>`)
}
// Absence fields (from 2025)
if (emp.sickDays && emp.sickDays > 0) {
lines.push(` <SjukfranvaroDagar faltkod="821">${Math.round(emp.sickDays)}</SjukfranvaroDagar>`)
}
if (emp.vabDays && emp.vabDays > 0) {
lines.push(` <VabDagar faltkod="822">${Math.round(emp.vabDays)}</VabDagar>`)
}
if (emp.parentalDays && emp.parentalDays > 0) {
lines.push(` <ForaldraledigDagar faltkod="823">${Math.round(emp.parentalDays)}</ForaldraledigDagar>`)
}
lines.push(' </IU>')
lines.push(' </Blankettinnehall>')
lines.push(' </Blankett>')
}
lines.push('</Skatteverket>')
return lines.join('\n')
}
/**
* Build individuppgifter snapshot for storage in agi_declarations table.
* Used for corrections — must reference same FK570.
*/
export function buildIndividuppgifterSnapshot(
employees: AGIEmployeeData[]
): Record<string, unknown>[] {
return employees.map(emp => {
let pnr: string
try {
pnr = decryptPersonnummer(emp.personnummer)
} catch {
pnr = 'DECRYPTION_FAILED'
}
return {
personnummer: pnr,
fk570: emp.specificationNumber,
ruta011: emp.grossSalary,
ruta001: emp.taxWithheld,
ruta020: emp.avgifterBasis,
fk821: emp.sickDays || 0,
fk822: emp.vabDays || 0,
fk823: emp.parentalDays || 0,
}
})
}
// ============================================================
// Helpers
// ============================================================
function escapeXml(str: string): string {
return str
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&apos;')
}
function formatAmount(amount: number): string {
return Math.round(amount).toString()
}
+152
View File
@@ -0,0 +1,152 @@
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
}
}
// ============================================================
// 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,
}],
}
}
+594
View File
@@ -0,0 +1,594 @@
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'
vacationDaysPerYear: number
semestertillaggRate: number
/** Växa-stöd */
vaxaStodEligible: boolean
vaxaStodStart: string | null
vaxaStodEnd: string | null
/** Line items */
lineItems: CalculationLineItem[]
}
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>
output: number
}
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
totalEmployerCost: number
steps: CalculationStep[]
}
export interface AvgifterCalculation {
rate: number
amount: number
basis: number
category: 'standard' | 'reduced_65plus' | 'youth' | 'vaxa_stod' | 'exempt'
steps: CalculationStep[]
}
// ============================================================
// Rounding helper
// ============================================================
function r(x: number): number {
return Math.round(x * 100) / 100
}
// ============================================================
// 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') {
baseSalary = r(input.monthlySalary * (input.employmentDegree / 100))
steps.push({
label: 'Grundlön',
formula: 'monthly_salary × (employment_degree / 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: 'hourly_rate × hours_worked',
input: { hourly_rate: rate, hours_worked: hours },
output: baseSalary,
})
}
// ─── Step 2: Add additions ───
const additions = input.lineItems.filter(
li => ['overtime', 'bonus', 'commission'].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, bonus, provision)',
formula: 'sum(additions)',
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', '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: 'sum(absence_items)',
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: 'sum(gross_deductions)',
input: { count: grossDeductionItems.length },
output: -totalGrossDeductions,
})
}
// Gross salary = base + additions + absence (may be negative for deductions) - gross deductions
const grossSalary = r(baseSalary + totalAdditions + totalAbsence - totalGrossDeductions)
steps.push({
label: 'Bruttolön',
formula: 'base + additions + absence - gross_deductions',
input: { base: baseSalary, additions: totalAdditions, absence: totalAbsence, 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_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: 'sum(benefit_values)',
input: { count: benefitItems.length },
output: totalBenefits,
})
}
const taxableIncome = r(grossSalary + totalBenefits)
steps.push({
label: 'Skattegrundande inkomst',
formula: 'gross_salary + benefit_values',
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: '0 (F-skattsedel, inget avdrag)',
input: {},
output: 0,
})
} else if (input.fSkattStatus === 'not_verified') {
// Unverified: flat 30%
taxWithheld = r(taxableIncome * 0.30)
steps.push({
label: 'Skatteavdrag (ej verifierad)',
formula: 'taxable_income × 30%',
input: { taxable_income: taxableIncome },
output: taxWithheld,
})
} else if (input.isSidoinkomst) {
// Sidoinkomst: flat 30%
taxWithheld = calculateSidoinkomstTax(taxableIncome)
steps.push({
label: 'Skatteavdrag (sidoinkomst 30%)',
formula: 'taxable_income × 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: 'taxable_income × jamkning_percentage / 100',
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: `lookup(table=${input.taxTableNumber}, column=${input.taxColumn}, income=${Math.round(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: 'taxable_income × 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: 'gross - tax - net_deductions',
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: `${config.avgifterYouthSalaryCap} × ${avgifterCalc.rate} + ${r(avgifterBasis - config.avgifterYouthSalaryCap)} × ${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: `${config.avgifterVaxaStodCap} × ${avgifterCalc.rate} + ${r(avgifterBasis - config.avgifterVaxaStodCap)} × ${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: 'avgifter_basis × rate',
input: { avgifter_basis: avgifterBasis, rate: avgifterCalc.rate },
output: avgifterAmount,
})
}
// ─── Step 9: Vacation accrual ───
const vacationBasisItems = input.lineItems.filter(li => li.isVacationBasis)
const vacationBasis = r(
baseSalary + vacationBasisItems.reduce((sum, li) => sum + li.amount, 0)
)
let vacationAccrual: number
if (input.vacationRule === 'procentregeln') {
const rate = input.vacationDaysPerYear >= 30 ? 0.144 : 0.12
vacationAccrual = r(vacationBasis * rate)
steps.push({
label: `Semesteravsättning (procentregeln ${rate * 100}%)`,
formula: 'vacation_basis × 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.
const dailyRate = r(input.monthlySalary / 21)
const tillagg = r(dailyRate * input.semestertillaggRate * input.vacationDaysPerYear)
vacationAccrual = tillagg
steps.push({
label: `Semesteravsättning (sammalöneregeln, tillägg ${(input.semestertillaggRate * 100).toFixed(2)}%)`,
formula: 'daily_rate × semestertillagg_rate × vacation_days',
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: 'vacation_accrual × avgifter_rate',
input: { vacation_accrual: vacationAccrual, avgifter_rate: avgifterCalc.rate },
output: vacationAccrualAvgifter,
})
const totalEmployerCost = r(grossSalary + avgifterAmount + vacationAccrual + vacationAccrualAvgifter)
steps.push({
label: 'Total arbetsgivarkostnad',
formula: 'gross + avgifter + vacation_accrual + vacation_avgifter',
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,
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 (personnummer ej dekrypterbart)', input: {}, output: config.avgifterTotal }],
}
}
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: 0%',
input: { birth_year: birthYear },
output: 0,
})
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} ≥ ${config.reducedAvgiftAge}: reducerad (${config.avgifterReduced65plus * 100}%)`,
input: { age: ageAtYearStart, threshold: config.reducedAvgiftAge },
output: config.avgifterReduced65plus,
})
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: ${(config.avgifterVaxaStodRate ?? 0) * 100}% på första ${config.avgifterVaxaStodCap} SEK`,
input: { vaxa_cap: config.avgifterVaxaStodCap ?? 0 },
output: config.avgifterVaxaStodRate ?? 0,
})
return { rate: config.avgifterVaxaStodRate ?? config.avgifterTotal, amount: 0, basis: 0, category: 'vaxa_stod', steps }
}
}
// Youth rate (2026: ages 19-23, Apr-Sep only)
if (config.avgifterYouthRate !== null && ageAtYearStart >= 19 && ageAtYearStart <= 23) {
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): ${config.avgifterYouthRate * 100}% på första ${config.avgifterYouthSalaryCap} SEK`,
input: { age: ageAtYearStart, cap: config.avgifterYouthSalaryCap ?? 0 },
output: config.avgifterYouthRate,
})
return { rate: config.avgifterYouthRate, amount: 0, basis: 0, category: 'youth', steps }
}
}
// Standard rate
steps.push({
label: 'Avgiftskategori',
formula: `Standard: ${config.avgifterTotal * 100}%`,
input: { age: ageAtYearStart },
output: config.avgifterTotal,
})
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
): { karensavdrag: number; sjuklon: number; totalDeduction: number; steps: CalculationStep[] } {
const steps: CalculationStep[] = []
const dailyRate = r(monthlySalary / 21)
// Karensavdrag
const karensavdrag = calculateKarensavdrag(monthlySalary, config)
steps.push({
label: 'Karensavdrag',
formula: '20% × (monthly × 12/52 × 80%)',
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: 'daily_rate × 80% × (sick_days - 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_pay - sjuklon + 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'
vacationDaysPerYear: number
semestertillaggRate: number
vacationBasis: number
}): { accrual: number; steps: CalculationStep[] } {
const steps: CalculationStep[] = []
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 ${rate * 100}%)`,
formula: 'vacation_basis × rate',
input: { vacation_basis: params.vacationBasis, rate },
output: accrual,
})
return { accrual, steps }
} else {
const dailyRate = r(params.monthlySalary / 21)
const accrual = r(dailyRate * params.semestertillaggRate * params.vacationDaysPerYear)
steps.push({
label: `Semesteravsättning (sammalöneregeln ${params.semestertillaggRate * 100}%)`,
formula: 'daily_rate × semestertillagg_rate × vacation_days',
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
}
+85
View File
@@ -0,0 +1,85 @@
/**
* Engångsskatt — tax on one-time payments (bonuses, retroactive pay, etc.)
*
* Per Skatteverket: One-time payments use a percentage-based tax table,
* not the regular monthly tax table. The rate depends on the employee's
* estimated annual income level.
*
* Skatteverket publishes "Tabell för beräkning av skatteavdrag på
* engångsbelopp" annually.
*
* Simplified 2026 brackets (based on published rates):
*/
export interface EngangsskattResult {
taxRate: number
taxAmount: number
annualIncomeEstimate: number
steps: { label: string; formula: string; output: number }[]
}
/**
* 2026 engångsskatt brackets.
* Rate includes both kommunalskatt and statlig skatt.
* Based on average kommunalskatt (~32.5%).
*/
const ENGANGSSKATT_BRACKETS_2026: Array<{ fromAnnual: number; toAnnual: number; rate: number }> = [
{ fromAnnual: 0, toAnnual: 20000, rate: 0.00 },
{ fromAnnual: 20001, toAnnual: 50000, rate: 0.10 },
{ fromAnnual: 50001, toAnnual: 100000, rate: 0.20 },
{ fromAnnual: 100001, toAnnual: 200000, rate: 0.25 },
{ fromAnnual: 200001, toAnnual: 350000, rate: 0.30 },
{ fromAnnual: 350001, toAnnual: 500000, rate: 0.32 },
{ fromAnnual: 500001, toAnnual: 660400, rate: 0.34 },
{ fromAnnual: 660401, toAnnual: 950000, rate: 0.52 }, // State tax kicks in
{ fromAnnual: 950001, toAnnual: 1500000, rate: 0.55 },
{ fromAnnual: 1500001, toAnnual: Infinity, rate: 0.57 },
]
/**
* Calculate engångsskatt for a one-time payment.
*
* @param oneTimeAmount - The bonus/one-time payment amount
* @param monthlySalary - Employee's regular monthly salary (for estimating annual income)
* @param monthsWorkedThisYear - Months already worked (for pro-rata annual estimate)
*/
export function calculateEngangsskatt(
oneTimeAmount: number,
monthlySalary: number,
monthsWorkedThisYear: number = 12
): EngangsskattResult {
const r = (x: number) => Math.round(x * 100) / 100
// Estimate annual income = regular salary × 12 + one-time amount
const annualRegular = monthlySalary * 12
const annualIncomeEstimate = r(annualRegular + oneTimeAmount)
// Find the bracket based on total annual income including the one-time payment
let taxRate = 0.30 // default fallback
for (const bracket of ENGANGSSKATT_BRACKETS_2026) {
if (annualIncomeEstimate >= bracket.fromAnnual && annualIncomeEstimate <= bracket.toAnnual) {
taxRate = bracket.rate
break
}
}
const taxAmount = r(oneTimeAmount * taxRate)
return {
taxRate,
taxAmount,
annualIncomeEstimate,
steps: [
{
label: 'Beräknad årsinkomst',
formula: 'monthly × 12 + engångsbelopp',
output: annualIncomeEstimate,
},
{
label: `Engångsskatt (${(taxRate * 100).toFixed(0)}%)`,
formula: `engångsbelopp × ${(taxRate * 100).toFixed(0)}%`,
output: taxAmount,
},
],
}
}
+148
View File
@@ -0,0 +1,148 @@
import { decryptPersonnummer } from '../personnummer'
/**
* KU10 (Kontrolluppgift) — Annual employee income statement.
*
* Per Skatteförfarandelagen 15 kap: Every employer must file KU10 for each
* employee by January 31 of the following year. Reports total income, tax
* withheld, and benefit values for the calendar year.
*
* The KU10 is filed electronically via Skatteverket's Filöverföring or API.
* XML format follows Skatteverket Teknisk beskrivning for KU.
*
* Penalties: Late filing = 500 SEK per KU per commenced 5-day period
* (max 5,000 SEK per KU or 500,000 SEK total per filing deadline).
*/
export interface KU10EmployeeData {
personnummer: string // Encrypted — will be decrypted
specificationNumber: number // FK570
totalGross: number // Ruta 011: Total kontant bruttolön for year
totalTax: number // Ruta 001: Total avdragen skatt for year
totalAvgifterBasis: number // Ruta 020: Total avgiftsunderlag
benefitCar?: number // Ruta 012: Total bilförmån
benefitHousing?: number // Ruta 014: Total bostadsförmån
benefitMeals?: number // Ruta 015: Total kostförmån
benefitOther?: number // Ruta 019: Total övrigt
sickDays?: number // Total sjukdagar
employmentStart?: string // YYYY-MM-DD
employmentEnd?: string // YYYY-MM-DD (if terminated during year)
}
export interface KU10CompanyData {
orgNumber: string
companyName: string
year: number
contactName: string
contactPhone: string
contactEmail: string
}
/**
* Generate KU10 XML for all employees for a calendar year.
*
* Per BFL 7 kap: The KU10 file is räkenskapsinformation, retained 7 years.
*/
export function generateKU10Xml(
company: KU10CompanyData,
employees: KU10EmployeeData[]
): string {
const lines: string[] = []
const orgNr = company.orgNumber.replace('-', '')
lines.push('<?xml version="1.0" encoding="UTF-8"?>')
lines.push('<Skatteverket xmlns="http://xmls.skatteverket.se/se/skatteverket/ai/instans/infoForBeskworksgivku/1.0"')
lines.push(' xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">')
// Avsändare
lines.push(' <Avsandare>')
lines.push(' <Programnamn>gnubok</Programnamn>')
lines.push(` <Organisationsnummer>${orgNr}</Organisationsnummer>`)
lines.push(' <TekniskKontaktperson>')
lines.push(` <Namn>${escapeXml(company.contactName)}</Namn>`)
lines.push(` <Telefon>${escapeXml(company.contactPhone)}</Telefon>`)
lines.push(` <Epostadress>${escapeXml(company.contactEmail)}</Epostadress>`)
lines.push(' </TekniskKontaktperson>')
lines.push(' </Avsandare>')
// Blankettgemensamt
lines.push(' <Blankettgemensamt>')
lines.push(` <Uppgiftslamnare>`)
lines.push(` <UppgijftslamnareId>${orgNr}</UppgijftslamnareId>`)
lines.push(` <NamnUppgiftslamnare>${escapeXml(company.companyName)}</NamnUppgiftslamnare>`)
lines.push(` </Uppgiftslamnare>`)
lines.push(' </Blankettgemensamt>')
// Per-employee KU10
for (const emp of employees) {
let pnr: string
try {
pnr = decryptPersonnummer(emp.personnummer)
} catch {
pnr = '000000000000'
}
lines.push(' <Blankett>')
lines.push(' <Arendeinformation>')
lines.push(` <Arendeagare>${orgNr}</Arendeagare>`)
lines.push(` <Period>${company.year}</Period>`)
lines.push(' </Arendeinformation>')
lines.push(' <Blankettinnehall>')
lines.push(' <KU10>')
// Employee identification
lines.push(` <Personnummer faltkod="215">${pnr}</Personnummer>`)
lines.push(` <Specifikationsnummer faltkod="570">${emp.specificationNumber}</Specifikationsnummer>`)
// Income and tax
if (emp.totalGross > 0) {
lines.push(` <KontantBruttoloen faltkod="011">${Math.round(emp.totalGross)}</KontantBruttoloen>`)
}
if (emp.totalTax > 0) {
lines.push(` <AvdragenSkatt faltkod="001">${Math.round(emp.totalTax)}</AvdragenSkatt>`)
}
// Benefits
if (emp.benefitCar && emp.benefitCar > 0) {
lines.push(` <FormanBil faltkod="012">${Math.round(emp.benefitCar)}</FormanBil>`)
}
if (emp.benefitHousing && emp.benefitHousing > 0) {
lines.push(` <FormanBostad faltkod="014">${Math.round(emp.benefitHousing)}</FormanBostad>`)
}
if (emp.benefitMeals && emp.benefitMeals > 0) {
lines.push(` <FormanKost faltkod="015">${Math.round(emp.benefitMeals)}</FormanKost>`)
}
if (emp.benefitOther && emp.benefitOther > 0) {
lines.push(` <FormanOvrigt faltkod="019">${Math.round(emp.benefitOther)}</FormanOvrigt>`)
}
// Avgifter basis
if (emp.totalAvgifterBasis > 0) {
lines.push(` <UnderlagArbAvg faltkod="020">${Math.round(emp.totalAvgifterBasis)}</UnderlagArbAvg>`)
}
// Employment period (if not full year)
if (emp.employmentStart) {
lines.push(` <Anstallningsdatum faltkod="008">${emp.employmentStart.replace(/-/g, '')}</Anstallningsdatum>`)
}
if (emp.employmentEnd) {
lines.push(` <Avgangsdatum faltkod="009">${emp.employmentEnd.replace(/-/g, '')}</Avgangsdatum>`)
}
lines.push(' </KU10>')
lines.push(' </Blankettinnehall>')
lines.push(' </Blankett>')
}
lines.push('</Skatteverket>')
return lines.join('\n')
}
function escapeXml(str: string): string {
return str
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&apos;')
}
+125
View File
@@ -0,0 +1,125 @@
import type { PayrollConfig } from './payroll-config'
/**
* Löneväxling till pension — salary sacrifice to occupational pension.
*
* The 1.058 factor: For every 1 SEK salary reduction, pension contribution
* should be 1.058 SEK to make the employee roughly cost-neutral
* (because reduced salary → reduced avgifter for employer).
*
* Warnings:
* - Post-reduction salary < 8.07 × IBB / 12 reduces PGI and SGI
* - Employer pension cap: 35% of pensionsmedförande lön or 10 × PBB/year
*/
export interface LoneVaxlingResult {
salaryReduction: number
pensionContribution: number
preReductionSalary: number
postReductionSalary: number
savedAvgifter: number
slpOnPension: number
netEmployerSaving: number
warnings: string[]
steps: LoneVaxlingStep[]
}
export interface LoneVaxlingStep {
label: string
formula: string
input: Record<string, number | string>
output: number
}
/**
* Calculate löneväxling impact.
*
* @param reductionAmount - Monthly bruttolöneavdrag amount
* @param currentMonthlySalary - Current gross monthly salary before reduction
* @param config - Payroll config for the year
* @param avgifterRate - Employee's applicable avgifter rate (may differ from standard)
*/
export function calculateLoneVaxling(
reductionAmount: number,
currentMonthlySalary: number,
config: PayrollConfig,
avgifterRate: number = 0.3142
): LoneVaxlingResult {
const r = (x: number) => Math.round(x * 100) / 100
const steps: LoneVaxlingStep[] = []
const warnings: string[] = []
const factor = 1.058
const pensionContribution = r(reductionAmount * factor)
steps.push({
label: 'Pensionsavsättning',
formula: 'reduction × 1.058',
input: { reduction: reductionAmount, factor },
output: pensionContribution,
})
const postReductionSalary = r(currentMonthlySalary - reductionAmount)
steps.push({
label: 'Ny bruttolön',
formula: 'current - reduction',
input: { current: currentMonthlySalary, reduction: reductionAmount },
output: postReductionSalary,
})
// Avgifter savings
const savedAvgifter = r(reductionAmount * avgifterRate)
steps.push({
label: 'Sparade arbetsgivaravgifter',
formula: 'reduction × avgifter_rate',
input: { reduction: reductionAmount, avgifter_rate: avgifterRate },
output: savedAvgifter,
})
// SLP (Särskild löneskatt) on pension: 24.26%
const slpOnPension = r(pensionContribution * config.slpRate)
steps.push({
label: 'Särskild löneskatt på pension',
formula: 'pension × SLP_rate',
input: { pension: pensionContribution, slp_rate: config.slpRate },
output: slpOnPension,
})
// Net employer cost difference
const netEmployerSaving = r(savedAvgifter - pensionContribution - slpOnPension + reductionAmount)
steps.push({
label: 'Nettoresultat arbetsgivare',
formula: 'saved_avgifter - pension - slp + reduction',
input: { saved_avgifter: savedAvgifter, pension: pensionContribution, slp: slpOnPension, reduction: reductionAmount },
output: netEmployerSaving,
})
// Warning: PGI/SGI floor
const pgiFloor = r(8.07 * config.inkomstbasbelopp / 12)
if (postReductionSalary < pgiFloor) {
warnings.push(
`Varning: Ny lön ${r(postReductionSalary)} SEK < PGI-golv ${r(pgiFloor)} SEK/mån. ` +
`Reducerad allmän pension och sjukpenninggrundande inkomst (SGI).`
)
}
// Warning: Pension cap
const annualPensionCap = r(10 * config.prisbasbelopp)
const annualContribution = r(pensionContribution * 12)
if (annualContribution > annualPensionCap) {
warnings.push(
`Varning: Årsvis pensionsavsättning ${r(annualContribution)} SEK överstiger taket ${r(annualPensionCap)} SEK (10 × PBB).`
)
}
return {
salaryReduction: reductionAmount,
pensionContribution,
preReductionSalary: currentMonthlySalary,
postReductionSalary,
savedAvgifter,
slpOnPension,
netEmployerSaving,
warnings,
steps,
}
}
+151
View File
@@ -0,0 +1,151 @@
/**
* pain.001 (ISO 20022) payment file generator for salary batch payments.
*
* Swedish banks (SEB, Handelsbanken, Swedbank, Nordea) accept
* pain.001.001.03 for credit transfer initiation.
*
* The generated file is uploaded to the bank's corporate portal.
*/
export interface Pain001CompanyData {
name: string
orgNumber: string // NNNNNN-NNNN
iban: string // SE + 22 digits
bic: string // SWIFT/BIC code
}
export interface Pain001Employee {
name: string
clearingNumber: string
bankAccountNumber: string
netSalary: number // Amount to pay
}
export interface Pain001Options {
messageId: string // Unique message ID
paymentDate: string // YYYY-MM-DD requested execution date
periodLabel: string // e.g. "2026-04" for remittance info
}
/**
* Generate pain.001.001.03 XML for salary batch payment.
*
* Structure:
* Document > CstmrCdtTrfInitn > GrpHdr + PmtInf (one per batch)
* PmtInf contains CdtTrfTxInf per employee
*
* Per BFL: The generated file is räkenskapsinformation (underlag)
* linked to the salary journal entry. Subject to 7-year retention.
*/
export function generatePain001(
company: Pain001CompanyData,
employees: Pain001Employee[],
options: Pain001Options
): string {
const now = new Date().toISOString().replace(/\.\d{3}Z$/, 'Z')
const totalAmount = employees.reduce((sum, e) => sum + e.netSalary, 0)
const formattedTotal = formatDecimal(totalAmount)
const lines: string[] = []
lines.push('<?xml version="1.0" encoding="UTF-8"?>')
lines.push('<Document xmlns="urn:iso:std:iso:20022:tech:xsd:pain.001.001.03"')
lines.push(' xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">')
lines.push(' <CstmrCdtTrfInitn>')
// ─── Group Header ───
lines.push(' <GrpHdr>')
lines.push(` <MsgId>${escapeXml(options.messageId)}</MsgId>`)
lines.push(` <CreDtTm>${now}</CreDtTm>`)
lines.push(` <NbOfTxs>${employees.length}</NbOfTxs>`)
lines.push(` <CtrlSum>${formattedTotal}</CtrlSum>`)
lines.push(' <InitgPty>')
lines.push(` <Nm>${escapeXml(company.name)}</Nm>`)
lines.push(' <Id>')
lines.push(' <OrgId>')
lines.push(` <Othr><Id>${escapeXml(company.orgNumber.replace('-', ''))}</Id></Othr>`)
lines.push(' </OrgId>')
lines.push(' </Id>')
lines.push(' </InitgPty>')
lines.push(' </GrpHdr>')
// ─── Payment Information ───
lines.push(' <PmtInf>')
lines.push(` <PmtInfId>${escapeXml(options.messageId)}-PMT</PmtInfId>`)
lines.push(' <PmtMtd>TRF</PmtMtd>') // Transfer
lines.push(' <BtchBookg>true</BtchBookg>') // Batch booking
lines.push(` <NbOfTxs>${employees.length}</NbOfTxs>`)
lines.push(` <CtrlSum>${formattedTotal}</CtrlSum>`)
lines.push(' <PmtTpInf>')
lines.push(' <SvcLvl><Cd>SEPA</Cd></SvcLvl>')
lines.push(' <CtgyPurp><Cd>SALA</Cd></CtgyPurp>') // Salary payment
lines.push(' </PmtTpInf>')
lines.push(` <ReqdExctnDt>${options.paymentDate}</ReqdExctnDt>`)
// Debtor (company)
lines.push(' <Dbtr>')
lines.push(` <Nm>${escapeXml(company.name)}</Nm>`)
lines.push(' </Dbtr>')
lines.push(' <DbtrAcct>')
lines.push(' <Id>')
lines.push(` <IBAN>${escapeXml(company.iban)}</IBAN>`)
lines.push(' </Id>')
lines.push(' <Ccy>SEK</Ccy>')
lines.push(' </DbtrAcct>')
lines.push(' <DbtrAgt>')
lines.push(' <FinInstnId>')
lines.push(` <BIC>${escapeXml(company.bic)}</BIC>`)
lines.push(' </FinInstnId>')
lines.push(' </DbtrAgt>')
// ─── Per-employee credit transfers ───
for (let i = 0; i < employees.length; i++) {
const emp = employees[i]
const txId = `${options.messageId}-TX${String(i + 1).padStart(4, '0')}`
lines.push(' <CdtTrfTxInf>')
lines.push(' <PmtId>')
lines.push(` <InstrId>${escapeXml(txId)}</InstrId>`)
lines.push(` <EndToEndId>${escapeXml(txId)}</EndToEndId>`)
lines.push(' </PmtId>')
lines.push(' <Amt>')
lines.push(` <InstdAmt Ccy="SEK">${formatDecimal(emp.netSalary)}</InstdAmt>`)
lines.push(' </Amt>')
lines.push(' <Cdtr>')
lines.push(` <Nm>${escapeXml(emp.name)}</Nm>`)
lines.push(' </Cdtr>')
lines.push(' <CdtrAcct>')
lines.push(' <Id>')
// Swedish domestic: clearing + account number (not IBAN for domestic)
lines.push(` <Othr><Id>${escapeXml(emp.clearingNumber)}${escapeXml(emp.bankAccountNumber)}</Id></Othr>`)
lines.push(' </Id>')
lines.push(' </CdtrAcct>')
lines.push(' <RmtInf>')
lines.push(` <Ustrd>Lon ${escapeXml(options.periodLabel)}</Ustrd>`)
lines.push(' </RmtInf>')
lines.push(' </CdtTrfTxInf>')
}
lines.push(' </PmtInf>')
lines.push(' </CstmrCdtTrfInitn>')
lines.push('</Document>')
return lines.join('\n')
}
// ============================================================
// Helpers
// ============================================================
function escapeXml(str: string): string {
return str
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&apos;')
}
/** Format number as decimal with 2 decimal places (ISO 20022 requires dot separator) */
function formatDecimal(amount: number): string {
return (Math.round(amount * 100) / 100).toFixed(2)
}
+106
View File
@@ -0,0 +1,106 @@
import type { SupabaseClient } from '@supabase/supabase-js'
export interface PayrollConfig {
configYear: number
avgifterTotal: number
avgifterAlderspension: number
avgifterSjukforsakring: number
avgifterForaldraforsakring: number
avgifterEfterlevandepension: number
avgifterArbetsmarknad: number
avgifterArbetsskada: number
avgifterAllmanLoneavgift: number
avgifterReduced65plus: number
avgifterYouthRate: number | null
avgifterYouthSalaryCap: number | null
avgifterVaxaStodRate: number | null
avgifterVaxaStodCap: number | null
avgifterMinimumAnnual: number
egenavgifterTotal: number
slpRate: number
prisbasbelopp: number
inkomstbasbelopp: number
maxPgi: number
sgiCeiling: number
statligSkattBrytpunkt: number
traktamenteHeldag: number
traktamenteHalvdag: number
traktamenteNatt: number
milersattningEgenBil: number
milersattningFormansbilFossil: number
milersattningFormansbilEl: number
kostformanHeldag: number
kostformanLunch: number
kostformanFrukost: number
friskvardCap: number
bilformanSlr: number
sjuklonRate: number
karensavdragFactor: number
maxKarensavdragPerYear: number
reducedAvgiftAge: number
}
/**
* Load payroll configuration for a given year.
*/
export async function loadPayrollConfig(
supabase: SupabaseClient,
year: number
): Promise<PayrollConfig> {
const { data, error } = await supabase
.from('salary_payroll_config')
.select('*')
.eq('config_year', year)
.single()
if (error || !data) {
throw new Error(`Payroll configuration not found for year ${year}`)
}
return {
configYear: data.config_year,
avgifterTotal: data.avgifter_total,
avgifterAlderspension: data.avgifter_alderspension,
avgifterSjukforsakring: data.avgifter_sjukforsakring,
avgifterForaldraforsakring: data.avgifter_foraldraforsakring,
avgifterEfterlevandepension: data.avgifter_efterlevandepension,
avgifterArbetsmarknad: data.avgifter_arbetsmarknad,
avgifterArbetsskada: data.avgifter_arbetsskada,
avgifterAllmanLoneavgift: data.avgifter_allman_loneavgift,
avgifterReduced65plus: data.avgifter_reduced_65plus,
avgifterYouthRate: data.avgifter_youth_rate,
avgifterYouthSalaryCap: data.avgifter_youth_salary_cap,
avgifterVaxaStodRate: data.avgifter_vaxa_stod_rate,
avgifterVaxaStodCap: data.avgifter_vaxa_stod_cap,
avgifterMinimumAnnual: data.avgifter_minimum_annual,
egenavgifterTotal: data.egenavgifter_total,
slpRate: data.slp_rate,
prisbasbelopp: data.prisbasbelopp,
inkomstbasbelopp: data.inkomstbasbelopp,
maxPgi: data.max_pgi,
sgiCeiling: data.sgi_ceiling,
statligSkattBrytpunkt: data.statlig_skatt_brytpunkt,
traktamenteHeldag: data.traktamente_heldag,
traktamenteHalvdag: data.traktamente_halvdag,
traktamenteNatt: data.traktamente_natt,
milersattningEgenBil: data.milersattning_egen_bil,
milersattningFormansbilFossil: data.milersattning_formansbil_fossil,
milersattningFormansbilEl: data.milersattning_formansbil_el,
kostformanHeldag: data.kostforman_heldag,
kostformanLunch: data.kostforman_lunch,
kostformanFrukost: data.kostforman_frukost,
friskvardCap: data.friskvard_cap,
bilformanSlr: data.bilforman_slr,
sjuklonRate: data.sjuklon_rate,
karensavdragFactor: data.karensavdrag_factor,
maxKarensavdragPerYear: data.max_karensavdrag_per_year,
reducedAvgiftAge: data.reduced_avgift_age,
}
}
/**
* Serialize payroll config for snapshot storage in salary_runs.calculation_params.
*/
export function serializePayrollConfig(config: PayrollConfig): Record<string, unknown> {
return { ...config }
}
+396
View File
@@ -0,0 +1,396 @@
import { Document, Page, Text, View, StyleSheet } from '@react-pdf/renderer'
/**
* Pay slip PDF template (Lönespecifikation).
*
* Legally required per BFL as räkenskapsinformation/underlag.
* Subject to 7-year retention per BFL 7 kap.
*
* Contents:
* - Company + employee identification
* - Line items (salary, absence, benefits, deductions)
* - Gross → Tax → Net summary with tax table reference
* - Employer cost breakdown (avgifter, vacation accrual) — transparency feature
* - YTD totals (cumulative year-to-date)
* - Calculation breakdown (optional detail showing every formula step)
*/
const styles = StyleSheet.create({
page: {
fontFamily: 'Helvetica',
fontSize: 9,
padding: 40,
color: '#1a1a1a',
},
header: {
flexDirection: 'row',
justifyContent: 'space-between',
marginBottom: 20,
},
title: {
fontSize: 16,
fontFamily: 'Helvetica-Bold',
marginBottom: 4,
},
subtitle: {
fontSize: 10,
color: '#666',
},
companyName: {
fontSize: 12,
fontFamily: 'Helvetica-Bold',
},
companyInfo: {
fontSize: 8,
color: '#666',
marginTop: 2,
},
section: {
marginBottom: 14,
},
sectionTitle: {
fontSize: 10,
fontFamily: 'Helvetica-Bold',
marginBottom: 6,
paddingBottom: 3,
borderBottomWidth: 1,
borderBottomColor: '#e0e0e0',
},
row: {
flexDirection: 'row',
paddingVertical: 3,
},
rowAlt: {
flexDirection: 'row',
paddingVertical: 3,
backgroundColor: '#f8f8f8',
},
headerRow: {
flexDirection: 'row',
paddingVertical: 4,
borderBottomWidth: 1,
borderBottomColor: '#ccc',
marginBottom: 2,
},
colDesc: { flex: 3.5 },
colQty: { flex: 1, textAlign: 'right' as const },
colRate: { flex: 1.5, textAlign: 'right' as const },
colAmount: { flex: 1.5, textAlign: 'right' as const },
headerText: {
fontSize: 8,
fontFamily: 'Helvetica-Bold',
color: '#666',
textTransform: 'uppercase' as const,
},
summaryRow: {
flexDirection: 'row',
justifyContent: 'space-between',
paddingVertical: 3,
},
summaryLabel: {
fontSize: 9,
color: '#444',
},
summaryValue: {
fontSize: 9,
fontFamily: 'Helvetica-Bold',
textAlign: 'right' as const,
},
totalRow: {
flexDirection: 'row',
justifyContent: 'space-between',
paddingVertical: 6,
borderTopWidth: 2,
borderTopColor: '#1a1a1a',
marginTop: 4,
},
totalLabel: {
fontSize: 11,
fontFamily: 'Helvetica-Bold',
},
totalValue: {
fontSize: 11,
fontFamily: 'Helvetica-Bold',
textAlign: 'right' as const,
},
infoGrid: {
flexDirection: 'row',
gap: 20,
marginBottom: 14,
},
infoColumn: {
flex: 1,
},
infoLabel: {
fontSize: 7,
color: '#999',
textTransform: 'uppercase' as const,
marginBottom: 1,
},
infoValue: {
fontSize: 9,
marginBottom: 6,
},
breakdownSection: {
marginTop: 10,
padding: 10,
backgroundColor: '#f5f5f5',
borderRadius: 3,
},
breakdownTitle: {
fontSize: 9,
fontFamily: 'Helvetica-Bold',
marginBottom: 6,
},
breakdownRow: {
flexDirection: 'row',
justifyContent: 'space-between',
paddingVertical: 1.5,
},
breakdownLabel: {
fontSize: 7.5,
color: '#555',
flex: 3,
},
breakdownFormula: {
fontSize: 7,
color: '#888',
flex: 3,
fontFamily: 'Courier',
},
breakdownValue: {
fontSize: 7.5,
textAlign: 'right' as const,
flex: 1.5,
},
footer: {
position: 'absolute' as const,
bottom: 30,
left: 40,
right: 40,
borderTopWidth: 1,
borderTopColor: '#e0e0e0',
paddingTop: 6,
fontSize: 7,
color: '#999',
textAlign: 'center' as const,
},
ytdSection: {
marginTop: 10,
},
ytdRow: {
flexDirection: 'row',
justifyContent: 'space-between',
paddingVertical: 2,
},
ytdLabel: {
fontSize: 8,
color: '#666',
},
ytdValue: {
fontSize: 8,
color: '#666',
textAlign: 'right' as const,
},
})
export interface PayslipData {
// Company
companyName: string
companyOrgNumber: string
companyAddress?: string
// Employee
employeeName: string
personnummerMasked: string // XXXXXXXX-XXXX
employmentType: string
// Period
periodYear: number
periodMonth: number
paymentDate: string
// Line items
lineItems: PayslipLineItem[]
// Summary
grossSalary: number
taxWithheld: number
netSalary: number
taxReference: string // e.g. "Tabell 33, kolumn 1"
// Employer cost (transparency feature)
avgifterRate: number
avgifterAmount: number
vacationAccrual: number
vacationAccrualAvgifter: number
totalEmployerCost: number
// YTD
ytdGross: number
ytdTax: number
ytdNet: number
// Bank
bankAccount?: string // masked
// Calculation breakdown (optional)
breakdownSteps?: { label: string; formula: string; output: number }[]
}
export interface PayslipLineItem {
description: string
quantity?: number
unitPrice?: number
amount: number
}
function fmt(amount: number): string {
return new Intl.NumberFormat('sv-SE', { minimumFractionDigits: 2, maximumFractionDigits: 2 }).format(amount)
}
const MONTH_NAMES = [
'januari', 'februari', 'mars', 'april', 'maj', 'juni',
'juli', 'augusti', 'september', 'oktober', 'november', 'december',
]
export function PayslipPDF({ data }: { data: PayslipData }) {
const periodLabel = `${MONTH_NAMES[data.periodMonth - 1]} ${data.periodYear}`
return (
<Document>
<Page size="A4" style={styles.page}>
{/* Header */}
<View style={styles.header}>
<View>
<Text style={styles.title}>Lönespecifikation</Text>
<Text style={styles.subtitle}>{periodLabel}</Text>
</View>
<View style={{ alignItems: 'flex-end' as const }}>
<Text style={styles.companyName}>{data.companyName}</Text>
<Text style={styles.companyInfo}>Org.nr {data.companyOrgNumber}</Text>
{data.companyAddress && <Text style={styles.companyInfo}>{data.companyAddress}</Text>}
</View>
</View>
{/* Employee + Period info */}
<View style={styles.infoGrid}>
<View style={styles.infoColumn}>
<Text style={styles.infoLabel}>Anställd</Text>
<Text style={styles.infoValue}>{data.employeeName}</Text>
<Text style={styles.infoLabel}>Personnummer</Text>
<Text style={styles.infoValue}>{data.personnummerMasked}</Text>
</View>
<View style={styles.infoColumn}>
<Text style={styles.infoLabel}>Period</Text>
<Text style={styles.infoValue}>{periodLabel}</Text>
<Text style={styles.infoLabel}>Utbetalningsdag</Text>
<Text style={styles.infoValue}>{data.paymentDate}</Text>
</View>
<View style={styles.infoColumn}>
<Text style={styles.infoLabel}>Skattetabell</Text>
<Text style={styles.infoValue}>{data.taxReference}</Text>
{data.bankAccount && (
<>
<Text style={styles.infoLabel}>Bankkonto</Text>
<Text style={styles.infoValue}>{data.bankAccount}</Text>
</>
)}
</View>
</View>
{/* Line items table */}
<View style={styles.section}>
<Text style={styles.sectionTitle}>Lönespecifikation</Text>
<View style={styles.headerRow}>
<Text style={[styles.headerText, styles.colDesc]}>Beskrivning</Text>
<Text style={[styles.headerText, styles.colQty]}>Antal</Text>
<Text style={[styles.headerText, styles.colRate]}>á-pris</Text>
<Text style={[styles.headerText, styles.colAmount]}>Belopp</Text>
</View>
{data.lineItems.map((item, i) => (
<View key={i} style={i % 2 === 1 ? styles.rowAlt : styles.row}>
<Text style={styles.colDesc}>{item.description}</Text>
<Text style={styles.colQty}>{item.quantity != null ? item.quantity : ''}</Text>
<Text style={styles.colRate}>{item.unitPrice != null ? fmt(item.unitPrice) : ''}</Text>
<Text style={styles.colAmount}>{fmt(item.amount)}</Text>
</View>
))}
</View>
{/* Summary: Gross → Tax → Net */}
<View style={styles.section}>
<View style={styles.summaryRow}>
<Text style={styles.summaryLabel}>Bruttolön</Text>
<Text style={styles.summaryValue}>{fmt(data.grossSalary)}</Text>
</View>
<View style={styles.summaryRow}>
<Text style={styles.summaryLabel}>Preliminär skatt ({data.taxReference})</Text>
<Text style={styles.summaryValue}>−{fmt(data.taxWithheld)}</Text>
</View>
<View style={styles.totalRow}>
<Text style={styles.totalLabel}>Nettolön (utbetalas)</Text>
<Text style={styles.totalValue}>{fmt(data.netSalary)}</Text>
</View>
</View>
{/* Employer cost (transparency feature — our differentiator) */}
<View style={styles.section}>
<Text style={styles.sectionTitle}>Arbetsgivarkostnad</Text>
<View style={styles.summaryRow}>
<Text style={styles.summaryLabel}>Arbetsgivaravgifter ({(data.avgifterRate * 100).toFixed(2)}%)</Text>
<Text style={styles.summaryValue}>{fmt(data.avgifterAmount)}</Text>
</View>
<View style={styles.summaryRow}>
<Text style={styles.summaryLabel}>Semesteravsättning</Text>
<Text style={styles.summaryValue}>{fmt(data.vacationAccrual)}</Text>
</View>
<View style={styles.summaryRow}>
<Text style={styles.summaryLabel}>Sociala avgifter på semester</Text>
<Text style={styles.summaryValue}>{fmt(data.vacationAccrualAvgifter)}</Text>
</View>
<View style={styles.totalRow}>
<Text style={styles.totalLabel}>Total arbetsgivarkostnad</Text>
<Text style={styles.totalValue}>{fmt(data.totalEmployerCost)}</Text>
</View>
</View>
{/* YTD */}
<View style={styles.ytdSection}>
<Text style={[styles.sectionTitle, { fontSize: 9 }]}>Ackumulerat {data.periodYear}</Text>
<View style={styles.ytdRow}>
<Text style={styles.ytdLabel}>Brutto</Text>
<Text style={styles.ytdValue}>{fmt(data.ytdGross)}</Text>
</View>
<View style={styles.ytdRow}>
<Text style={styles.ytdLabel}>Skatt</Text>
<Text style={styles.ytdValue}>{fmt(data.ytdTax)}</Text>
</View>
<View style={styles.ytdRow}>
<Text style={styles.ytdLabel}>Netto</Text>
<Text style={styles.ytdValue}>{fmt(data.ytdNet)}</Text>
</View>
</View>
{/* Calculation breakdown (optional detail page) */}
{data.breakdownSteps && data.breakdownSteps.length > 0 && (
<View style={styles.breakdownSection}>
<Text style={styles.breakdownTitle}>Beräkningsunderlag</Text>
{data.breakdownSteps.map((step, i) => (
<View key={i} style={styles.breakdownRow}>
<Text style={styles.breakdownLabel}>{step.label}</Text>
<Text style={styles.breakdownFormula}>{step.formula}</Text>
<Text style={styles.breakdownValue}>{fmt(step.output)}</Text>
</View>
))}
</View>
)}
{/* Footer */}
<Text style={styles.footer}>
{data.companyName} · Org.nr {data.companyOrgNumber} · Lönespecifikation {periodLabel} · Genererad av gnubok
</Text>
</Page>
</Document>
)
}
+166
View File
@@ -0,0 +1,166 @@
import { createCipheriv, createDecipheriv, randomBytes, scryptSync } from 'crypto'
const ALGORITHM = 'aes-256-gcm'
const IV_LENGTH = 12
const TAG_LENGTH = 16
/**
* Get the encryption key from environment.
* Falls back to a dev-only key for local development.
*/
function getEncryptionKey(): Buffer {
const envKey = process.env.PERSONNUMMER_ENCRYPTION_KEY
if (!envKey) {
if (process.env.NODE_ENV === 'production') {
throw new Error('PERSONNUMMER_ENCRYPTION_KEY is required in production')
}
// Dev-only deterministic key (NOT safe for production)
return scryptSync('dev-only-key', 'gnubok-dev-salt', 32)
}
// Use scrypt to derive a 32-byte key from the env var
return scryptSync(envKey, 'gnubok-pnr-salt', 32)
}
/**
* Encrypt a personnummer for storage.
* Returns a hex string: iv + ciphertext + authTag
*/
export function encryptPersonnummer(personnummer: string): string {
const key = getEncryptionKey()
const iv = randomBytes(IV_LENGTH)
const cipher = createCipheriv(ALGORITHM, key, iv)
let encrypted = cipher.update(personnummer, 'utf8', 'hex')
encrypted += cipher.final('hex')
const authTag = cipher.getAuthTag()
return iv.toString('hex') + encrypted + authTag.toString('hex')
}
/**
* Decrypt a personnummer from storage.
*/
export function decryptPersonnummer(encrypted: string): string {
const key = getEncryptionKey()
const ivHex = encrypted.slice(0, IV_LENGTH * 2)
const authTagHex = encrypted.slice(-TAG_LENGTH * 2)
const ciphertext = encrypted.slice(IV_LENGTH * 2, -TAG_LENGTH * 2)
const iv = Buffer.from(ivHex, 'hex')
const authTag = Buffer.from(authTagHex, 'hex')
const decipher = createDecipheriv(ALGORITHM, key, iv)
decipher.setAuthTag(authTag)
let decrypted = decipher.update(ciphertext, 'hex', 'utf8')
decrypted += decipher.final('utf8')
return decrypted
}
/**
* Extract the last 4 digits of a personnummer for display.
*/
export function extractLast4(personnummer: string): string {
const digits = personnummer.replace(/\D/g, '')
return digits.slice(-4)
}
/**
* Validate a Swedish personnummer (12-digit format: YYYYMMDDNNNN).
* Checks format + Luhn checksum on last 10 digits.
*/
export function validatePersonnummer(personnummer: string): { valid: boolean; error?: string } {
const digits = personnummer.replace(/\D/g, '')
if (digits.length !== 12) {
return { valid: false, error: 'Personnummer måste vara 12 siffror (ÅÅÅÅMMDDNNNN)' }
}
const year = parseInt(digits.slice(0, 4))
const month = parseInt(digits.slice(4, 6))
const day = parseInt(digits.slice(6, 8))
if (year < 1900 || year > 2100) {
return { valid: false, error: 'Ogiltigt år' }
}
if (month < 1 || month > 12) {
return { valid: false, error: 'Ogiltig månad' }
}
if (day < 1 || day > 31) {
return { valid: false, error: 'Ogiltig dag' }
}
// Luhn check on digits 3-12 (YYMMDDNNNN, 10 digits)
const luhnDigits = digits.slice(2)
if (!luhnCheck(luhnDigits)) {
return { valid: false, error: 'Ogiltigt kontrollnummer (Luhn)' }
}
return { valid: true }
}
/**
* Luhn checksum validation for 10-digit string.
*/
function luhnCheck(digits: string): boolean {
let sum = 0
for (let i = 0; i < digits.length; i++) {
let d = parseInt(digits[i])
// Multiply every other digit by 2, starting from the first
if (i % 2 === 0) {
d *= 2
if (d > 9) d -= 9
}
sum += d
}
return sum % 10 === 0
}
/**
* Extract birth date from a 12-digit personnummer.
*/
export function extractBirthDate(personnummer: string): { year: number; month: number; day: number } {
const digits = personnummer.replace(/\D/g, '')
return {
year: parseInt(digits.slice(0, 4)),
month: parseInt(digits.slice(4, 6)),
day: parseInt(digits.slice(6, 8)),
}
}
/**
* Calculate age at a given date from a personnummer.
*/
export function calculateAge(personnummer: string, atDate: string): number {
const birth = extractBirthDate(personnummer)
const [refYear, refMonth, refDay] = atDate.split('-').map(Number)
let age = refYear - birth.year
if (refMonth < birth.month || (refMonth === birth.month && refDay < birth.day)) {
age--
}
return age
}
/**
* Calculate age at the start of a given year.
* Used for avgifter age tier determination.
*/
export function calculateAgeAtYearStart(personnummer: string, year: number): number {
return calculateAge(personnummer, `${year}-01-01`)
}
/**
* Mask personnummer for display: XXXXXXXX-XXXX
*/
export function maskPersonnummer(last4: string): string {
return `XXXXXXXX-${last4}`
}
/**
* Format personnummer with dash: YYYYMMDD-NNNN
*/
export function formatPersonnummer(personnummer: string): string {
const digits = personnummer.replace(/\D/g, '')
return `${digits.slice(0, 8)}-${digits.slice(8)}`
}
+426
View File
@@ -0,0 +1,426 @@
import { createJournalEntry, findFiscalPeriod } from '@/lib/bookkeeping/engine'
import { createLogger } from '@/lib/logger'
import { SALARY_ACCOUNTS, getLineItemAccount } from './account-mapping'
import type { SupabaseClient } from '@supabase/supabase-js'
import type {
CreateJournalEntryInput,
CreateJournalEntryLineInput,
JournalEntry,
} from '@/types'
const log = createLogger('salary-entries')
interface SalaryRunEmployee {
employee_id: string
employment_type: string
gross_salary: number
tax_withheld: number
net_salary: number
avgifter_amount: number
avgifter_rate: number
vacation_accrual: number
vacation_accrual_avgifter: number
cost_center?: string
project?: string
line_items: Array<{
item_type: string
amount: number
account_number: string | null
is_net_deduction: boolean
is_gross_deduction: boolean
}>
// Löneväxling pension (if applicable)
pension_contribution?: number
pension_slp?: number
}
interface SalaryRunData {
id: string
period_year: number
period_month: number
payment_date: string
voucher_series: string
total_gross: number
total_tax: number
total_net: number
total_avgifter: number
total_vacation_accrual: number
employees: SalaryRunEmployee[]
}
/**
* Create all journal entries for a salary run.
* Creates 3 entries:
* 1. Salary entry: gross salary expenses, tax withholding, net payment
* 2. Avgifter entry: employer contributions expense + liability
* 3. Vacation entry: vacation accrual expense + liability + avgifter on accrual
*
* All entries use source_type: 'salary_payment' and source_id: salaryRun.id
*/
export async function createSalaryRunEntries(
supabase: SupabaseClient,
companyId: string,
userId: string,
run: SalaryRunData
): Promise<{
salaryEntry: JournalEntry
avgifterEntry: JournalEntry
vacationEntry: JournalEntry | null
pensionEntry: JournalEntry | null
}> {
const entryDate = run.payment_date
const fiscalPeriodId = await findFiscalPeriod(supabase, companyId, entryDate)
if (!fiscalPeriodId) {
throw new Error(`Ingen öppen räkenskapsperiod för datum ${entryDate}`)
}
const periodLabel = `${run.period_year}-${String(run.period_month).padStart(2, '0')}`
const desc = `Lön ${periodLabel}`
// ─── Entry 1: Salary (brutto, skatt, netto) ───
const salaryEntry = await createSalaryEntry(
supabase, companyId, userId, run, fiscalPeriodId, desc
)
// ─── Entry 2: Arbetsgivaravgifter ───
const avgifterEntry = await createAvgifterEntry(
supabase, companyId, userId, run, fiscalPeriodId, desc
)
// ─── Entry 3: Vacation accrual (if any) ───
let vacationEntry: JournalEntry | null = null
const totalVacation = run.employees.reduce((sum, e) => sum + e.vacation_accrual, 0)
const totalVacationAvgifter = run.employees.reduce((sum, e) => sum + e.vacation_accrual_avgifter, 0)
if (totalVacation > 0 || totalVacationAvgifter > 0) {
vacationEntry = await createVacationEntry(
supabase, companyId, userId, run, fiscalPeriodId, desc, totalVacation, totalVacationAvgifter
)
}
// ─── Entry 4: Pension provisions + SLP (if löneväxling) ───
// Per deductions-lonevaxling.md: pension = löneväxling × 1.058, SLP = pension × 24.26%
// Debit 7410 Pensionsförsäkringspremier / Credit 2740 Skuld pensionsförsäkringar
// Debit 7533 Särskild löneskatt / Credit 2514 Beräknad särskild löneskatt
let pensionEntry: JournalEntry | null = null
const totalPension = run.employees.reduce((sum, e) => sum + (e.pension_contribution || 0), 0)
const totalSlp = run.employees.reduce((sum, e) => sum + (e.pension_slp || 0), 0)
if (totalPension > 0) {
pensionEntry = await createPensionEntry(
supabase, companyId, userId, run, fiscalPeriodId, desc, totalPension, totalSlp
)
}
return { salaryEntry, avgifterEntry, vacationEntry, pensionEntry }
}
/**
* Entry 1: Salary booking.
*
* Debit: 7210/7220/7240 Löner (per employee by type)
* Credit: 2710 Personalskatt (total tax withheld)
* Credit: 1930 Företagskonto (total net salary)
*/
async function createSalaryEntry(
supabase: SupabaseClient,
companyId: string,
userId: string,
run: SalaryRunData,
fiscalPeriodId: string,
desc: string
): Promise<JournalEntry> {
const lines: CreateJournalEntryLineInput[] = []
// Aggregate salary expenses by account
const expenseByAccount = new Map<string, number>()
for (const emp of run.employees) {
// Base salary and additions go to the employee-type account
const salaryAccount = getEmployeeSalaryAccount(emp.employment_type)
// Add salary line items that are cash expenses
// Förmånsvärden (benefits) are excluded — they affect the tax base but
// have no cash flow and should not appear as expense lines in the journal.
const BENEFIT_TYPES = ['benefit_car', 'benefit_housing', 'benefit_meals', 'benefit_wellness', 'benefit_other']
for (const li of emp.line_items) {
if (li.is_net_deduction || li.is_gross_deduction) continue
if (BENEFIT_TYPES.includes(li.item_type)) continue // No cash flow for förmånsvärden
const account = li.account_number || getLineItemAccount(li.item_type as never, emp.employment_type)
const current = expenseByAccount.get(account) || 0
expenseByAccount.set(account, current + li.amount)
}
// If no specific line items resolved, use gross salary on default account
if (emp.line_items.length === 0) {
const current = expenseByAccount.get(salaryAccount) || 0
expenseByAccount.set(salaryAccount, current + emp.gross_salary)
}
}
// Debit: Salary expense accounts
for (const [account, amount] of expenseByAccount) {
if (amount === 0) continue
if (amount > 0) {
lines.push({
account_number: account,
debit_amount: Math.round(amount * 100) / 100,
credit_amount: 0,
line_description: `${desc} — ${accountLabel(account)}`,
})
} else {
// Negative amounts (deductions) become credits
lines.push({
account_number: account,
debit_amount: 0,
credit_amount: Math.round(Math.abs(amount) * 100) / 100,
line_description: `${desc} — ${accountLabel(account)}`,
})
}
}
// Credit: Tax withholding
const totalTax = run.employees.reduce((sum, e) => sum + e.tax_withheld, 0)
if (totalTax > 0) {
lines.push({
account_number: SALARY_ACCOUNTS.TAX_WITHHELD,
debit_amount: 0,
credit_amount: Math.round(totalTax * 100) / 100,
line_description: `${desc} — Personalskatt`,
})
}
// Credit: Net salary to bank
const totalNet = run.employees.reduce((sum, e) => sum + e.net_salary, 0)
if (totalNet > 0) {
lines.push({
account_number: SALARY_ACCOUNTS.BANK,
debit_amount: 0,
credit_amount: Math.round(totalNet * 100) / 100,
line_description: `${desc} — Nettolön`,
})
}
const input: CreateJournalEntryInput = {
fiscal_period_id: fiscalPeriodId,
entry_date: run.payment_date,
description: desc,
source_type: 'salary_payment',
source_id: run.id,
voucher_series: run.voucher_series,
lines,
}
log.info(`Creating salary entry for ${desc}: ${lines.length} lines`)
return createJournalEntry(supabase, companyId, userId, input)
}
/**
* Entry 2: Arbetsgivaravgifter.
*
* Debit: 7510 Lagstadgade sociala avgifter
* Credit: 2731 Avräkning sociala avgifter
*/
async function createAvgifterEntry(
supabase: SupabaseClient,
companyId: string,
userId: string,
run: SalaryRunData,
fiscalPeriodId: string,
desc: string
): Promise<JournalEntry> {
const totalAvgifter = run.employees.reduce((sum, e) => sum + e.avgifter_amount, 0)
const roundedAvgifter = Math.round(totalAvgifter * 100) / 100
const lines: CreateJournalEntryLineInput[] = [
{
account_number: SALARY_ACCOUNTS.AVGIFTER_EXPENSE,
debit_amount: roundedAvgifter,
credit_amount: 0,
line_description: `${desc} — Arbetsgivaravgifter`,
},
{
account_number: SALARY_ACCOUNTS.AVGIFTER_LIABILITY,
debit_amount: 0,
credit_amount: roundedAvgifter,
line_description: `${desc} — Arbetsgivaravgifter`,
},
]
const input: CreateJournalEntryInput = {
fiscal_period_id: fiscalPeriodId,
entry_date: run.payment_date,
description: `${desc} — Arbetsgivaravgifter`,
source_type: 'salary_payment',
source_id: run.id,
voucher_series: run.voucher_series,
lines,
}
log.info(`Creating avgifter entry for ${desc}: ${roundedAvgifter} SEK`)
return createJournalEntry(supabase, companyId, userId, input)
}
/**
* Entry 3: Vacation accrual.
*
* Debit: 7290 Förändring semesterlöneskuld
* Credit: 2920 Upplupna semesterlöner
* Debit: 7519 Sociala avgifter semester
* Credit: 2940 Upplupna sociala avgifter
*/
async function createVacationEntry(
supabase: SupabaseClient,
companyId: string,
userId: string,
run: SalaryRunData,
fiscalPeriodId: string,
desc: string,
totalVacation: number,
totalVacationAvgifter: number
): Promise<JournalEntry> {
const roundedVacation = Math.round(totalVacation * 100) / 100
const roundedAvgifter = Math.round(totalVacationAvgifter * 100) / 100
const lines: CreateJournalEntryLineInput[] = []
if (roundedVacation > 0) {
lines.push(
{
account_number: SALARY_ACCOUNTS.VACATION_ACCRUAL_EXPENSE,
debit_amount: roundedVacation,
credit_amount: 0,
line_description: `${desc} — Semesteravsättning`,
},
{
account_number: SALARY_ACCOUNTS.VACATION_ACCRUAL_LIABILITY,
debit_amount: 0,
credit_amount: roundedVacation,
line_description: `${desc} — Semesteravsättning`,
}
)
}
if (roundedAvgifter > 0) {
lines.push(
{
account_number: SALARY_ACCOUNTS.VACATION_AVGIFTER_EXPENSE,
debit_amount: roundedAvgifter,
credit_amount: 0,
line_description: `${desc} — Sociala avgifter på semester`,
},
{
account_number: SALARY_ACCOUNTS.VACATION_AVGIFTER_LIABILITY,
debit_amount: 0,
credit_amount: roundedAvgifter,
line_description: `${desc} — Sociala avgifter på semester`,
}
)
}
const input: CreateJournalEntryInput = {
fiscal_period_id: fiscalPeriodId,
entry_date: run.payment_date,
description: `${desc} — Semesteravsättning`,
source_type: 'salary_payment',
source_id: run.id,
voucher_series: run.voucher_series,
lines,
}
log.info(`Creating vacation entry for ${desc}: ${roundedVacation} SEK + ${roundedAvgifter} SEK avgifter`)
return createJournalEntry(supabase, companyId, userId, input)
}
/**
* Entry 4: Pension provisions + SLP (löneväxling).
*
* Debit: 7410 Pensionsförsäkringspremier
* Credit: 2740 Skuld pensionsförsäkringar
* Debit: 7533 Särskild löneskatt på pensionskostnader (24.26%)
* Credit: 2514 Beräknad särskild löneskatt
*
* Per deductions-lonevaxling.md: pension = löneväxling × 1.058
*/
async function createPensionEntry(
supabase: SupabaseClient,
companyId: string,
userId: string,
run: SalaryRunData,
fiscalPeriodId: string,
desc: string,
totalPension: number,
totalSlp: number
): Promise<JournalEntry> {
const roundedPension = Math.round(totalPension * 100) / 100
const roundedSlp = Math.round(totalSlp * 100) / 100
const lines: CreateJournalEntryLineInput[] = [
{
account_number: SALARY_ACCOUNTS.PENSION_EXPENSE,
debit_amount: roundedPension,
credit_amount: 0,
line_description: `${desc} — Pensionsförsäkringspremier`,
},
{
account_number: SALARY_ACCOUNTS.PENSION_LIABILITY,
debit_amount: 0,
credit_amount: roundedPension,
line_description: `${desc} — Pensionsförsäkringspremier`,
},
]
if (roundedSlp > 0) {
lines.push(
{
account_number: SALARY_ACCOUNTS.SLP_EXPENSE,
debit_amount: roundedSlp,
credit_amount: 0,
line_description: `${desc} — Särskild löneskatt 24,26%`,
},
{
account_number: SALARY_ACCOUNTS.SLP_LIABILITY,
debit_amount: 0,
credit_amount: roundedSlp,
line_description: `${desc} — Särskild löneskatt 24,26%`,
}
)
}
const input: CreateJournalEntryInput = {
fiscal_period_id: fiscalPeriodId,
entry_date: run.payment_date,
description: `${desc} — Pensionsavsättning`,
source_type: 'salary_payment',
source_id: run.id,
voucher_series: run.voucher_series,
lines,
}
log.info(`Creating pension entry for ${desc}: ${roundedPension} SEK pension + ${roundedSlp} SEK SLP`)
return createJournalEntry(supabase, companyId, userId, input)
}
// ============================================================
// Helpers
// ============================================================
function getEmployeeSalaryAccount(employmentType: string): string {
switch (employmentType) {
case 'company_owner': return SALARY_ACCOUNTS.SALARY_OWNER
case 'board_member': return SALARY_ACCOUNTS.SALARY_BOARD
default: return SALARY_ACCOUNTS.SALARY_EMPLOYEE
}
}
function accountLabel(account: string): string {
const labels: Record<string, string> = {
'7210': 'Löner tjänstemän',
'7220': 'Löner företagsledare',
'7240': 'Styrelsearvoden',
'7281': 'Sjuklöner',
'7285': 'Semesterlöner',
'7321': 'Traktamenten skattefria',
'7322': 'Traktamenten skattepliktiga',
'7331': 'Bilersättningar skattefria',
'7332': 'Bilersättningar skattepliktiga',
}
return labels[account] || `Konto ${account}`
}
+97
View File
@@ -0,0 +1,97 @@
import type { SupabaseClient } from '@supabase/supabase-js'
import { createLogger } from '@/lib/logger'
const log = createLogger('salary-transaction-matcher')
/**
* Auto-match salary payment bank transactions to salary journal entries.
*
* When bank transactions arrive (via Enable Banking sync or CSV import),
* this matcher looks for transactions that correspond to salary net payments:
* - Transaction date matches salary run payment_date
* - Transaction amount matches -total_net (negative = outgoing payment)
* - Transaction is not already categorized
*
* On match, links the transaction to the salary journal entry via
* the existing reconciliation system.
*
* Per BFL 5 kap: Bank statement reconciliation is required.
* Per BFNAR 2013:2: Automated matching must be logged.
*/
export async function matchSalaryTransactions(
supabase: SupabaseClient,
companyId: string,
transactionIds: string[]
): Promise<{ matched: number }> {
if (transactionIds.length === 0) return { matched: 0 }
// Load unmatched transactions
const { data: transactions, error: txError } = await supabase
.from('transactions')
.select('id, date, amount, description')
.eq('company_id', companyId)
.in('id', transactionIds)
.is('journal_entry_id', null)
.lt('amount', 0) // Only outgoing payments
if (txError || !transactions || transactions.length === 0) return { matched: 0 }
// Load booked salary runs for matching
const { data: salaryRuns, error: srError } = await supabase
.from('salary_runs')
.select('id, payment_date, total_net, salary_entry_id, status')
.eq('company_id', companyId)
.eq('status', 'booked')
if (srError || !salaryRuns || salaryRuns.length === 0) return { matched: 0 }
let matched = 0
for (const tx of transactions) {
// Look for salary run where:
// - payment_date matches transaction date
// - total_net matches |transaction.amount| (within 1 SEK tolerance for öresavrundning)
const txAmount = Math.abs(tx.amount)
const matchingRun = salaryRuns.find(run => {
if (run.payment_date !== tx.date) return false
const diff = Math.abs(run.total_net - txAmount)
return diff <= 1 // 1 SEK tolerance for rounding
})
if (matchingRun && matchingRun.salary_entry_id) {
// Link transaction to the salary journal entry
const { error: linkError } = await supabase
.from('transactions')
.update({
journal_entry_id: matchingRun.salary_entry_id,
is_business: true,
category: 'salary',
})
.eq('id', tx.id)
.is('journal_entry_id', null) // CAS guard
if (!linkError) {
matched++
log.info(`Matched salary transaction ${tx.id} to run ${matchingRun.id} (${txAmount} SEK)`)
// Log the match in payment_match_log for audit trail
await supabase.from('payment_match_log').insert({
company_id: companyId,
transaction_id: tx.id,
match_type: 'salary_payment',
matched_entity_id: matchingRun.id,
matched_entity_type: 'salary_run',
amount: txAmount,
auto_matched: true,
})
}
}
}
if (matched > 0) {
log.info(`Auto-matched ${matched} salary transaction(s) for company ${companyId}`)
}
return { matched }
}
+282
View File
@@ -0,0 +1,282 @@
/**
* Tax table lookup via Skatteverket's open data API.
*
* Uses the free, public EntryScape rowstore API — no authentication required.
* Endpoints:
* - Tax tables: https://skatteverket.entryscape.net/rowstore/dataset/88320397-5c32-4c16-ae79-d36d95b17b95
* - Kommun rates: https://skatteverket.entryscape.net/rowstore/dataset/c67b320b-ffee-4876-b073-dd9236cd2a99
*
* Per Skatteförfarandelagen: Tax withholding must use the correct table/column
* for each employee based on their folkbokföringskommun.
*
* Results are cached in-memory per salary run calculation to avoid redundant
* API calls (one call fetches all brackets for a table/column combination).
*/
import { createLogger } from '@/lib/logger'
const log = createLogger('tax-tables')
const TAX_TABLE_API = 'https://skatteverket.entryscape.net/rowstore/dataset/88320397-5c32-4c16-ae79-d36d95b17b95'
const KOMMUN_RATES_API = 'https://skatteverket.entryscape.net/rowstore/dataset/c67b320b-ffee-4876-b073-dd9236cd2a99'
export interface TaxTableRate {
tableYear: number
tableNumber: number
columnNumber: number
incomeFrom: number
incomeTo: number
taxAmount: number
}
// In-memory cache: "year-table-column" → rates
const rateCache = new Map<string, { rates: TaxTableRate[]; fetchedAt: number }>()
const CACHE_TTL_MS = 60 * 60 * 1000 // 1 hour
/**
* Look up tax amount for a given monthly income using Skatteverket's API.
*
* Fetches the matching bracket from the API (or cache) and returns the
* tax amount in SEK for the given income.
*/
export async function lookupTaxFromApi(
tableNumber: number,
column: number,
monthlyIncome: number,
year: number = new Date().getFullYear()
): Promise<number> {
const rates = await fetchTaxTableRates(year, tableNumber, column)
return lookupTaxAmount(tableNumber, column, monthlyIncome, rates)
}
/**
* Look up the tax amount from pre-loaded rates (pure function, no API call).
*/
export function lookupTaxAmount(
tableNumber: number,
column: number,
monthlyIncome: number,
rates: TaxTableRate[]
): number {
const roundedIncome = Math.round(monthlyIncome)
const matchingRates = rates.filter(
r => r.tableNumber === tableNumber && r.columnNumber === column
)
if (matchingRates.length === 0) {
// Fallback: 30% flat rate if table not found
return Math.round(roundedIncome * 0.30 * 100) / 100
}
matchingRates.sort((a, b) => a.incomeFrom - b.incomeFrom)
for (const rate of matchingRates) {
if (roundedIncome >= rate.incomeFrom && roundedIncome <= rate.incomeTo) {
return rate.taxAmount
}
}
// Above all brackets — use last bracket
const lastRate = matchingRates[matchingRates.length - 1]
if (roundedIncome > lastRate.incomeTo) {
return lastRate.taxAmount
}
return 0
}
/**
* Fetch tax table rates from Skatteverket's open data API.
* Returns all brackets for a specific year/table/column combination.
* Results are cached in-memory for 1 hour.
*/
export async function fetchTaxTableRates(
year: number,
tableNumber: number,
column: number
): Promise<TaxTableRate[]> {
const cacheKey = `${year}-${tableNumber}-${column}`
const cached = rateCache.get(cacheKey)
if (cached && Date.now() - cached.fetchedAt < CACHE_TTL_MS) {
return cached.rates
}
try {
// Fetch all B-rows (absolute amounts) for this table/year
// The API field names have Swedish characters: "år", "inkomst fr.o.m.", "inkomst t.o.m."
const params = new URLSearchParams({
'år': year.toString(),
'tabellnr': tableNumber.toString(),
'antal dgr': '30B', // Monthly table, absolute amounts
'_limit': '500',
})
const url = `${TAX_TABLE_API}?${params.toString()}`
log.info(`Fetching tax table ${tableNumber} col ${column} for ${year} from Skatteverket API`)
const response = await fetch(url, {
headers: { 'Accept': 'application/json' },
signal: AbortSignal.timeout(10000),
})
if (!response.ok) {
throw new Error(`Skatteverket API returned ${response.status}`)
}
const data = await response.json() as {
results: Array<{
'år': string
'tabellnr': string
'inkomst fr.o.m.': string
'inkomst t.o.m.': string
'kolumn 1': string
'kolumn 2': string
'kolumn 3': string
'kolumn 4': string
'kolumn 5': string
'kolumn 6': string
}>
resultCount: number
}
// If more than 500 results, fetch remaining pages
let allResults = data.results
if (data.resultCount > 500) {
let offset = 500
while (offset < data.resultCount) {
const pageParams = new URLSearchParams({
'år': year.toString(),
'tabellnr': tableNumber.toString(),
'antal dgr': '30B',
'_limit': '500',
'_offset': offset.toString(),
})
const pageRes = await fetch(`${TAX_TABLE_API}?${pageParams.toString()}`, {
headers: { 'Accept': 'application/json' },
signal: AbortSignal.timeout(10000),
})
if (pageRes.ok) {
const pageData = await pageRes.json() as { results: typeof data.results }
allResults = allResults.concat(pageData.results)
}
offset += 500
}
}
// Parse results — each row has all 6 columns, we extract the requested one
const columnKey = `kolumn ${column}` as keyof typeof allResults[0]
const rates: TaxTableRate[] = allResults.map(r => ({
tableYear: year,
tableNumber: tableNumber,
columnNumber: column,
incomeFrom: parseInt(r['inkomst fr.o.m.']) || 0,
incomeTo: parseInt(r['inkomst t.o.m.']) || 9999999,
taxAmount: parseInt(r[columnKey] as string) || 0,
}))
// Cache the results
rateCache.set(cacheKey, { rates, fetchedAt: Date.now() })
log.info(`Fetched ${rates.length} tax brackets for table ${tableNumber} col ${column} (${year})`)
return rates
} catch (err) {
log.warn(`Failed to fetch tax table from API: ${err instanceof Error ? err.message : 'unknown'}. Falling back to 30%.`)
return []
}
}
/**
* Fetch all tax table rates for a year (all tables/columns for a salary run).
* Used by the calculate route for bulk lookups.
*/
export async function fetchAllTaxTableRatesForRun(
year: number,
tableNumbers: number[],
columns: number[]
): Promise<TaxTableRate[]> {
const allRates: TaxTableRate[] = []
const uniquePairs = new Set<string>()
for (const table of tableNumbers) {
for (const col of columns) {
const key = `${table}-${col}`
if (uniquePairs.has(key)) continue
uniquePairs.add(key)
const rates = await fetchTaxTableRates(year, table, col)
allRates.push(...rates)
}
}
return allRates
}
/**
* Fetch kommun → tax table number mapping from Skatteverket's open data API.
*/
export async function fetchKommunTaxRates(year: number): Promise<Array<{
kommun: string
totalRate: number
tableNumber: number
}>> {
const params = new URLSearchParams({
'år': year.toString(),
'_limit': '500',
})
const response = await fetch(`${KOMMUN_RATES_API}?${params.toString()}`, {
headers: { 'Accept': 'application/json' },
signal: AbortSignal.timeout(10000),
})
if (!response.ok) {
throw new Error(`Kommun rates API returned ${response.status}`)
}
const data = await response.json() as {
results: Array<{
'kommun': string
'summa, exkl. kyrkoavgift': string
}>
}
// Deduplicate by kommun (multiple församlingar per kommun)
const byKommun = new Map<string, number>()
for (const r of data.results) {
const rate = parseFloat(r['summa, exkl. kyrkoavgift'])
if (!byKommun.has(r.kommun)) {
byKommun.set(r.kommun, rate)
}
}
return Array.from(byKommun.entries()).map(([kommun, rate]) => ({
kommun,
totalRate: rate,
// Table number: round total rate. ≤0.50 rounds down, ≥0.51 rounds up
tableNumber: Math.round(rate),
}))
}
// ── Legacy compatibility (used by calculation-engine.ts) ──
/**
* Calculate tax using jämkning (custom percentage from Skatteverket decision).
*/
export function calculateJamkningTax(monthlyIncome: number, jamkningPercentage: number): number {
return Math.round(monthlyIncome * (jamkningPercentage / 100) * 100) / 100
}
/**
* Calculate tax for sidoinkomst (flat 30%).
*/
export function calculateSidoinkomstTax(monthlyIncome: number): number {
return Math.round(monthlyIncome * 0.30 * 100) / 100
}
/**
* Clear the in-memory tax table cache. Used in tests.
*/
export function clearTaxTableCache(): void {
rateCache.clear()
}
+160
View File
@@ -0,0 +1,160 @@
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,
}],
}
}
+9 -4
View File
@@ -117,22 +117,27 @@ export const TAX_DEADLINE_CONFIGS: TaxDeadlineConfig[] = [
},
},
// Arbetsgivardeklaration (monthly, AB with employees)
// Arbetsgivardeklaration (monthly, any employer with employees — AB or EF)
// Per Skatteförfarandelagen: every employer paying salary must file AGI monthly.
// Deadline: 12th of following month (17th in Jan/Aug for turnover ≤40 MSEK per agi-filing.md)
{
type: 'arbetsgivardeklaration',
titleTemplate: 'Arbetsgivardeklaration {periodLabel}',
description: 'Arbetsgivardeklaration för aktiebolag med anställda',
condition: (s) => s.entity_type === 'aktiebolag' && s.pays_salaries,
description: 'Arbetsgivardeklaration för arbetsgivare med anställda',
condition: (s) => s.pays_salaries,
priority: 'important',
linkedReportType: null,
generateDates: (year) => {
const instances: DeadlineInstance[] = []
// Due on the 12th of the following month
// Exception: January (for Dec) and August (for Jul) = 17th for ≤40 MSEK turnover
for (let month = 0; month < 12; month++) {
const deadlineMonth = (month + 1) % 12
const deadlineYear = month === 11 ? year + 1 : year
// Jan (deadlineMonth=0) and Aug (deadlineMonth=7) get 17th
const day = (deadlineMonth === 0 || deadlineMonth === 7) ? 17 : 12
instances.push({
day: 12,
day,
month: deadlineMonth,
year: deadlineYear,
period: `${year}-${String(month + 1).padStart(2, '0')}`,