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
+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`,
}
}