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:
@@ -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%
|
||||
}
|
||||
@@ -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 } }
|
||||
}
|
||||
@@ -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`,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user