diff --git a/app/(dashboard)/page.tsx b/app/(dashboard)/page.tsx index cb66a13d..a83e44f7 100644 --- a/app/(dashboard)/page.tsx +++ b/app/(dashboard)/page.tsx @@ -74,6 +74,7 @@ export default async function DashboardPage() { { data: recentReceiptActivity }, { count: sieImportCount }, { count: staleUncategorizedCount }, + { count: uncategorizedCount }, ] = await Promise.all([ supabase.from('profiles').select('full_name').eq('id', user.id).single(), supabase.from('company_settings').select('*').eq('company_id', companyId).single(), @@ -99,6 +100,7 @@ export default async function DashboardPage() { supabase.from('receipts').select('created_at').eq('company_id', companyId).eq('status', 'confirmed').order('created_at', { ascending: false }).limit(30), supabase.from('sie_imports').select('*', { count: 'exact', head: true }).eq('company_id', companyId).eq('status', 'completed'), supabase.from('transactions').select('*', { count: 'exact', head: true }).eq('company_id', companyId).is('journal_entry_id', null).not('is_business', 'eq', false).lt('date', new Date(now.getTime() - 14 * 24 * 60 * 60 * 1000).toISOString().split('T')[0]), + supabase.from('transactions').select('*', { count: 'exact', head: true }).eq('company_id', companyId).is('is_business', null), ]) const firstName = profile?.full_name?.split(' ')[0] || null @@ -148,7 +150,6 @@ export default async function DashboardPage() { const uncategorizedTxns = (transactions || []).filter( (t) => t.is_business === null ) - const uncategorizedCount = uncategorizedTxns.length const uncategorizedIncome = uncategorizedTxns .filter((t) => t.amount > 0) .reduce((sum, t) => sum + Number(t.amount_sek || t.amount), 0) @@ -234,7 +235,7 @@ export default async function DashboardPage() { summary={{ ytd: ytdTotals, mtd: mtdTotals, - uncategorizedCount, + uncategorizedCount: uncategorizedCount || 0, uncategorizedIncome, uncategorizedExpenses, unpaidInvoicesCount: (unpaidInvoices || []).length, diff --git a/app/(dashboard)/salary/employees/[id]/page.tsx b/app/(dashboard)/salary/employees/[id]/page.tsx new file mode 100644 index 00000000..b274098a --- /dev/null +++ b/app/(dashboard)/salary/employees/[id]/page.tsx @@ -0,0 +1,226 @@ +'use client' + +import { useState, useEffect, use } from 'react' +import { useRouter } from 'next/navigation' +import Link from 'next/link' +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { Label } from '@/components/ui/label' +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select' +import { ArrowLeft, Save, Trash2 } from 'lucide-react' +import { useToast } from '@/components/ui/use-toast' +import { useCanWrite } from '@/lib/hooks/use-can-write' +import { getErrorMessage } from '@/lib/errors/get-error-message' +import { formatCurrency } from '@/lib/utils' +import type { Employee } from '@/types' + +const EMPLOYMENT_LABELS: Record = { + employee: 'Anställd', + company_owner: 'Företagsledare', + board_member: 'Styrelseledamot', +} + +export default function EmployeeDetailPage({ params }: { params: Promise<{ id: string }> }) { + const { id } = use(params) + const router = useRouter() + const { toast } = useToast() + const canWrite = useCanWrite() + const [employee, setEmployee] = useState(null) + const [loading, setLoading] = useState(true) + const [saving, setSaving] = useState(false) + const [employmentType, setEmploymentType] = useState('employee') + + useEffect(() => { + async function load() { + const res = await fetch(`/api/salary/employees/${id}`) + if (res.ok) { + const { data } = await res.json() + setEmployee(data) + setEmploymentType(data.employment_type) + } + setLoading(false) + } + load() + }, [id]) + + async function handleSave(e: React.FormEvent) { + e.preventDefault() + setSaving(true) + + const form = new FormData(e.currentTarget) + const body = { + first_name: form.get('first_name') as string, + last_name: form.get('last_name') as string, + employment_type: employmentType, + employment_degree: parseFloat(form.get('employment_degree') as string) || 100, + monthly_salary: parseFloat(form.get('monthly_salary') as string) || undefined, + hourly_rate: parseFloat(form.get('hourly_rate') as string) || undefined, + tax_table_number: parseInt(form.get('tax_table_number') as string) || undefined, + tax_column: parseInt(form.get('tax_column') as string) || 1, + email: form.get('email') as string || undefined, + phone: form.get('phone') as string || undefined, + clearing_number: form.get('clearing_number') as string || undefined, + bank_account_number: form.get('bank_account_number') as string || undefined, + } + + const res = await fetch(`/api/salary/employees/${id}`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }) + + if (res.ok) { + const { data } = await res.json() + setEmployee(data) + toast({ title: 'Anställd uppdaterad' }) + } else { + const result = await res.json() + toast({ + title: 'Fel', + description: getErrorMessage(result, { context: 'salary', statusCode: res.status }), + variant: 'destructive', + }) + } + + setSaving(false) + } + + async function handleDeactivate() { + if (!confirm('Vill du inaktivera denna anställd?')) return + + const res = await fetch(`/api/salary/employees/${id}`, { method: 'DELETE' }) + if (res.ok) { + toast({ title: 'Anställd inaktiverad' }) + router.push('/salary/employees') + } + } + + if (loading) { + return ( +
+
+
+
+ ) + } + + if (!employee) { + return

Anställd hittades inte

+ } + + return ( +
+
+
+ +
+

+ {employee.first_name} {employee.last_name} +

+

+ {employee.personnummer} · {EMPLOYMENT_LABELS[employee.employment_type]} +

+
+
+ {canWrite && ( + + )} +
+ +
+ + + Uppgifter + + +
+
+ + +
+
+ + +
+
+
+
+ + +
+
+ + +
+
+ + +
+
+
+
+ + +
+
+ + +
+
+ + +
+
+
+
+ + +
+
+ + +
+
+
+
+ + +
+
+ + +
+
+
+
+ + {canWrite && ( +
+ + +
+ )} +
+
+ ) +} diff --git a/app/(dashboard)/salary/employees/new/page.tsx b/app/(dashboard)/salary/employees/new/page.tsx new file mode 100644 index 00000000..cdfb3fc9 --- /dev/null +++ b/app/(dashboard)/salary/employees/new/page.tsx @@ -0,0 +1,231 @@ +'use client' + +import { useState } from 'react' +import { useRouter } from 'next/navigation' +import Link from 'next/link' +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { Label } from '@/components/ui/label' +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select' +import { ArrowLeft, Save } from 'lucide-react' +import { useToast } from '@/components/ui/use-toast' +import { getErrorMessage } from '@/lib/errors/get-error-message' + +export default function NewEmployeePage() { + const router = useRouter() + const { toast } = useToast() + const [saving, setSaving] = useState(false) + const [employmentType, setEmploymentType] = useState('employee') + const [salaryType, setSalaryType] = useState('monthly') + + async function handleSubmit(e: React.FormEvent) { + e.preventDefault() + setSaving(true) + + const form = new FormData(e.currentTarget) + const body = { + first_name: form.get('first_name') as string, + last_name: form.get('last_name') as string, + personnummer: (form.get('personnummer') as string).replace(/\D/g, ''), + employment_type: employmentType, + employment_start: form.get('employment_start') as string, + employment_degree: parseFloat(form.get('employment_degree') as string) || 100, + salary_type: salaryType, + monthly_salary: parseFloat(form.get('monthly_salary') as string) || undefined, + hourly_rate: parseFloat(form.get('hourly_rate') as string) || undefined, + tax_table_number: parseInt(form.get('tax_table_number') as string) || undefined, + tax_column: parseInt(form.get('tax_column') as string) || 1, + tax_municipality: form.get('tax_municipality') as string || undefined, + email: form.get('email') as string || undefined, + phone: form.get('phone') as string || undefined, + clearing_number: form.get('clearing_number') as string || undefined, + bank_account_number: form.get('bank_account_number') as string || undefined, + } + + const res = await fetch('/api/salary/employees', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }) + + if (res.ok) { + toast({ title: 'Anställd skapad' }) + router.push('/salary/employees') + } else { + const result = await res.json() + toast({ + title: 'Fel', + description: getErrorMessage(result, { context: 'salary', statusCode: res.status }), + variant: 'destructive', + }) + } + + setSaving(false) + } + + return ( +
+
+ +

Ny anställd

+
+ +
+ {/* Personal info */} + + + Personuppgifter + + +
+
+ + +
+
+ + +
+
+
+
+ + +

Krypteras vid lagring

+
+
+ + +
+
+
+ + +
+
+
+ + {/* Employment */} + + + Anställning + + +
+
+ + +
+
+ + +
+
+ + +
+
+
+
+ + {/* Salary */} + + + Lön + + +
+
+ + +
+
+ + +
+
+ + +
+
+
+
+ + {/* Tax */} + + + Skatt + + +
+
+ + +

Baseras på folkbokföringskommun

+
+
+ + +

1 = standard under 66 år

+
+
+ + +
+
+
+
+ + {/* Bank */} + + + Bankkonto + + +
+
+ + +
+
+ + +
+
+
+
+ + {/* Actions */} +
+ + +
+
+
+ ) +} diff --git a/app/(dashboard)/salary/employees/page.tsx b/app/(dashboard)/salary/employees/page.tsx new file mode 100644 index 00000000..4c3df846 --- /dev/null +++ b/app/(dashboard)/salary/employees/page.tsx @@ -0,0 +1,124 @@ +'use client' + +import { useState, useEffect } from 'react' +import Link from 'next/link' +import { Card, CardContent } from '@/components/ui/card' +import { Button } from '@/components/ui/button' +import { Plus, ArrowLeft, UserCircle } from 'lucide-react' +import { useCanWrite } from '@/lib/hooks/use-can-write' +import { formatCurrency } from '@/lib/utils' +import type { Employee } from '@/types' + +const EMPLOYMENT_LABELS: Record = { + employee: 'Anställd', + company_owner: 'Företagsledare', + board_member: 'Styrelseledamot', +} + +export default function EmployeesPage() { + const [employees, setEmployees] = useState([]) + const [loading, setLoading] = useState(true) + const canWrite = useCanWrite() + + useEffect(() => { + async function load() { + const res = await fetch('/api/salary/employees') + if (res.ok) { + const { data } = await res.json() + setEmployees(data || []) + } + setLoading(false) + } + load() + }, []) + + return ( +
+
+
+ +
+

Anställda

+

{employees.length} registrerade

+
+
+ {canWrite && ( + + )} +
+ + {loading ? ( +
+ {[1, 2, 3].map(i => ( +
+ ))} +
+ ) : employees.length === 0 ? ( + + + +

Inga anställda registrerade

+ {canWrite && ( + + )} +
+
+ ) : ( + + + + + + + + + + + + + + + {employees.map(emp => ( + + + + + + + + + ))} + +
NamnPersonnummerTypMånadslönSysselsättningsgradSkattetabell
+ + {emp.first_name} {emp.last_name} + + + {emp.personnummer} + + {EMPLOYMENT_LABELS[emp.employment_type] || emp.employment_type} + + {emp.monthly_salary ? formatCurrency(emp.monthly_salary) : '—'} + + {emp.employment_degree}% + + {emp.tax_table_number ? `Tabell ${emp.tax_table_number}, kol ${emp.tax_column}` : '—'} +
+
+
+ )} +
+ ) +} diff --git a/app/(dashboard)/salary/page.tsx b/app/(dashboard)/salary/page.tsx new file mode 100644 index 00000000..a4bd11cf --- /dev/null +++ b/app/(dashboard)/salary/page.tsx @@ -0,0 +1,208 @@ +'use client' + +import { useState, useEffect } from 'react' +import Link from 'next/link' +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' +import { Button } from '@/components/ui/button' +import { Plus, Users, HandCoins, CalendarDays, ArrowRight } from 'lucide-react' +import { useCanWrite } from '@/lib/hooks/use-can-write' +import { formatCurrency } from '@/lib/utils' +import type { SalaryRun } from '@/types' + +const STATUS_LABELS: Record = { + draft: 'Utkast', + review: 'Granskning', + approved: 'Godkänd', + paid: 'Betald', + booked: 'Bokförd', +} + +const STATUS_COLORS: Record = { + draft: 'bg-muted text-muted-foreground', + review: 'bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-400', + approved: 'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-400', + paid: 'bg-emerald-100 text-emerald-800 dark:bg-emerald-900/30 dark:text-emerald-400', + booked: 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400', +} + +export default function SalaryPage() { + const [runs, setRuns] = useState([]) + const [employeeCount, setEmployeeCount] = useState(0) + const [loading, setLoading] = useState(true) + const canWrite = useCanWrite() + + useEffect(() => { + async function load() { + const [runsRes, empRes] = await Promise.all([ + fetch('/api/salary/runs'), + fetch('/api/salary/employees'), + ]) + + if (runsRes.ok) { + const { data } = await runsRes.json() + setRuns(data || []) + } + if (empRes.ok) { + const { data } = await empRes.json() + setEmployeeCount((data || []).length) + } + setLoading(false) + } + load() + }, []) + + const currentYear = new Date().getFullYear() + const yearRuns = runs.filter(r => r.period_year === currentYear) + const totalGrossYTD = yearRuns.filter(r => r.status === 'booked').reduce((sum, r) => sum + r.total_gross, 0) + const totalAvgifterYTD = yearRuns.filter(r => r.status === 'booked').reduce((sum, r) => sum + r.total_avgifter, 0) + const latestRun = runs[0] + + if (loading) { + return ( +
+
+
+
+
+
+ {[1, 2, 3].map(i => ( +
+ ))} +
+
+ ) + } + + return ( +
+ {/* Header */} +
+
+

Löner

+

Hantera anställda och lönekörningar

+
+
+ + {canWrite && ( + + )} +
+
+ + {/* Summary cards */} +
+ + +
+ +
+

Anställda

+

{employeeCount}

+
+
+
+
+ + +
+ +
+

Bruttolöner {currentYear}

+

{formatCurrency(totalGrossYTD)}

+
+
+
+
+ + +
+ +
+

Avgifter {currentYear}

+

{formatCurrency(totalAvgifterYTD)}

+
+
+
+
+
+ + {/* Recent runs */} + + + Lönekörningar + + + {runs.length === 0 ? ( +
+ +

Inga lönekörningar ännu

+ {canWrite && ( + + )} +
+ ) : ( + + + + + + + + + + + + + + {runs.slice(0, 12).map(run => ( + + + + + + + + + + ))} + +
PeriodUtbetalningsdagBruttoNettoAvgifterStatus
+ {run.period_year}-{String(run.period_month).padStart(2, '0')} + + {run.payment_date} + + {formatCurrency(run.total_gross)} + + {formatCurrency(run.total_net)} + + {formatCurrency(run.total_avgifter)} + + + {STATUS_LABELS[run.status]} + + + + + +
+ )} +
+
+
+ ) +} diff --git a/app/(dashboard)/salary/runs/[id]/page.tsx b/app/(dashboard)/salary/runs/[id]/page.tsx new file mode 100644 index 00000000..465b610f --- /dev/null +++ b/app/(dashboard)/salary/runs/[id]/page.tsx @@ -0,0 +1,381 @@ +'use client' + +import { useState, useEffect, use } from 'react' +import { useRouter } from 'next/navigation' +import Link from 'next/link' +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' +import { Button } from '@/components/ui/button' +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select' +import { + ArrowLeft, Plus, Calculator, Eye, Check, CreditCard, BookOpen, + ArrowLeftCircle, Loader2, +} from 'lucide-react' +import { useToast } from '@/components/ui/use-toast' +import { useCanWrite } from '@/lib/hooks/use-can-write' +import { formatCurrency } from '@/lib/utils' +import { getErrorMessage } from '@/lib/errors/get-error-message' +import type { SalaryRun, SalaryRunEmployee, Employee, CreateJournalEntryLineInput } from '@/types' + +const STATUS_LABELS: Record = { + draft: 'Utkast', + review: 'Granskning', + approved: 'Godkänd', + paid: 'Betald', + booked: 'Bokförd', +} + +const STATUS_COLORS: Record = { + draft: 'bg-muted text-muted-foreground', + review: 'bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-400', + approved: 'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-400', + paid: 'bg-emerald-100 text-emerald-800 dark:bg-emerald-900/30 dark:text-emerald-400', + booked: 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400', +} + +interface EntryPreview { + description: string + lines: CreateJournalEntryLineInput[] +} + +interface PreviewData { + salaryEntry: EntryPreview + avgifterEntry: EntryPreview + vacationEntry: EntryPreview | null +} + +export default function SalaryRunDetailPage({ params }: { params: Promise<{ id: string }> }) { + const { id } = use(params) + const router = useRouter() + const { toast } = useToast() + const canWrite = useCanWrite() + + const [run, setRun] = useState(null) + const [availableEmployees, setAvailableEmployees] = useState([]) + const [preview, setPreview] = useState(null) + const [loading, setLoading] = useState(true) + const [actionLoading, setActionLoading] = useState(null) + const [addEmployeeKey, setAddEmployeeKey] = useState(0) + + async function loadRun() { + const res = await fetch(`/api/salary/runs/${id}`) + if (res.ok) { + const { data } = await res.json() + setRun(data) + } + } + + useEffect(() => { + async function load() { + await loadRun() + const empRes = await fetch('/api/salary/employees') + if (empRes.ok) { + const { data } = await empRes.json() + setAvailableEmployees(data || []) + } + setLoading(false) + } + load() + }, [id]) + + async function handleAction(action: string, method: string = 'POST') { + setActionLoading(action) + const res = await fetch(`/api/salary/runs/${id}/${action}`, { method }) + if (res.ok) { + await loadRun() + toast({ title: 'Status uppdaterad' }) + } else { + const result = await res.json() + toast({ + title: 'Fel', + description: getErrorMessage(result, { context: 'salary', statusCode: res.status }), + variant: 'destructive', + }) + } + setActionLoading(null) + } + + async function handleAddEmployee(employeeId: string) { + setActionLoading('add-employee') + const res = await fetch(`/api/salary/runs/${id}/employees`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ employee_id: employeeId }), + }) + if (res.ok) { + await loadRun() + toast({ title: 'Anställd tillagd' }) + } else { + const result = await res.json() + toast({ + title: 'Fel', + description: getErrorMessage(result, { context: 'salary', statusCode: res.status }), + variant: 'destructive', + }) + } + setActionLoading(null) + } + + async function handleCalculate() { + setActionLoading('calculate') + const res = await fetch(`/api/salary/runs/${id}/calculate`, { method: 'POST' }) + if (res.ok) { + await loadRun() + toast({ title: 'Beräkning klar' }) + } else { + const result = await res.json() + toast({ + title: 'Beräkningsfel', + description: getErrorMessage(result, { context: 'salary', statusCode: res.status }), + variant: 'destructive', + }) + } + setActionLoading(null) + } + + async function handlePreview() { + setActionLoading('preview') + const res = await fetch(`/api/salary/runs/${id}/preview`) + if (res.ok) { + const { data } = await res.json() + setPreview(data) + } + setActionLoading(null) + } + + if (loading) { + return ( +
+
+
+
+ ) + } + + if (!run) { + return

Lönekörning hittades inte

+ } + + const periodLabel = `${run.period_year}-${String(run.period_month).padStart(2, '0')}` + const employees = (run.employees || []) as SalaryRunEmployee[] + const addedEmployeeIds = new Set(employees.map(e => e.employee_id)) + const notAdded = availableEmployees.filter(e => !addedEmployeeIds.has(e.id)) + + return ( +
+ {/* Header */} +
+
+ +
+

+ Lönekörning {periodLabel} +

+

+ Utbetalning: {run.payment_date} +

+
+
+ + {STATUS_LABELS[run.status]} + +
+ + {/* Summary cards */} +
+ {[ + { label: 'Brutto', value: run.total_gross }, + { label: 'Skatt', value: run.total_tax }, + { label: 'Netto', value: run.total_net }, + { label: 'Avgifter', value: run.total_avgifter }, + { label: 'Total kostnad', value: run.total_employer_cost }, + ].map(({ label, value }) => ( + + +

{label}

+

{formatCurrency(value)}

+
+
+ ))} +
+ + {/* Employees */} + + + Anställda ({employees.length}) + {run.status === 'draft' && canWrite && notAdded.length > 0 && ( + + )} + + + {employees.length === 0 ? ( +

+ Inga anställda tillagda ännu +

+ ) : ( + + + + + + + + + + + + + {employees.map(sre => ( + + + + + + + + + ))} + +
AnställdBruttoSkattNettoAvgifterSemester
+ {(sre as SalaryRunEmployee & { employee?: { first_name: string; last_name: string; personnummer: string } }).employee + ? `${(sre as SalaryRunEmployee & { employee: { first_name: string; last_name: string } }).employee.first_name} ${(sre as SalaryRunEmployee & { employee: { first_name: string; last_name: string } }).employee.last_name}` + : `Anställd ${sre.employee_id.slice(0, 8)}...`} + {formatCurrency(sre.gross_salary)}{formatCurrency(sre.tax_withheld)}{formatCurrency(sre.net_salary)}{formatCurrency(sre.avgifter_amount)}{formatCurrency(sre.vacation_accrual)}
+ )} +
+
+ + {/* Calculation breakdown (if available) */} + {employees.some(e => e.calculation_breakdown) && ( + + + Beräkningsdetaljer + + + {employees.filter(e => e.calculation_breakdown).map(sre => { + const breakdown = sre.calculation_breakdown as { steps?: Array<{ label: string; formula: string; output: number }> } + return ( +
+

+ {(sre as SalaryRunEmployee & { employee?: { first_name: string; last_name: string } }).employee + ? `${(sre as SalaryRunEmployee & { employee: { first_name: string; last_name: string } }).employee.first_name} ${(sre as SalaryRunEmployee & { employee: { first_name: string; last_name: string } }).employee.last_name}` + : sre.employee_id.slice(0, 8)} +

+
+ {(breakdown?.steps || []).map((step, i) => ( +
+ {step.label}: {step.formula} + {formatCurrency(step.output)} +
+ ))} +
+
+ ) + })} +
+
+ )} + + {/* Journal preview */} + {preview && ( + + + Förhandsgranskning — verifikationer + + + {[preview.salaryEntry, preview.avgifterEntry, preview.vacationEntry, (preview as unknown as Record).pensionEntry].filter(Boolean).map((entry, idx) => ( +
+

{entry!.description}

+ + + + + + + + + + + {entry!.lines.map((line, li) => ( + + + + + + + ))} + +
KontoBeskrivningDebetKredit
{line.account_number}{line.line_description}{line.debit_amount ? formatCurrency(line.debit_amount) : ''}{line.credit_amount ? formatCurrency(line.credit_amount) : ''}
+
+ ))} +
+
+ )} + + {/* Actions */} + {canWrite && ( +
+ {run.status === 'draft' && ( + <> + + + + + )} + {run.status === 'review' && ( + <> + + + + + )} + {run.status === 'approved' && ( + + )} + {run.status === 'paid' && ( + + )} +
+ )} +
+ ) +} diff --git a/app/(dashboard)/salary/runs/new/page.tsx b/app/(dashboard)/salary/runs/new/page.tsx new file mode 100644 index 00000000..00c1b546 --- /dev/null +++ b/app/(dashboard)/salary/runs/new/page.tsx @@ -0,0 +1,107 @@ +'use client' + +import { useState } from 'react' +import { useRouter } from 'next/navigation' +import Link from 'next/link' +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { Label } from '@/components/ui/label' +import { ArrowLeft, ArrowRight } from 'lucide-react' +import { useToast } from '@/components/ui/use-toast' +import { getErrorMessage } from '@/lib/errors/get-error-message' + +export default function NewSalaryRunPage() { + const router = useRouter() + const { toast } = useToast() + const [saving, setSaving] = useState(false) + + const now = new Date() + const defaultYear = now.getFullYear() + const defaultMonth = now.getMonth() + 1 + const defaultPayDate = `${defaultYear}-${String(defaultMonth).padStart(2, '0')}-25` + + async function handleSubmit(e: React.FormEvent) { + e.preventDefault() + setSaving(true) + + const form = new FormData(e.currentTarget) + const body = { + period_year: parseInt(form.get('period_year') as string), + period_month: parseInt(form.get('period_month') as string), + payment_date: form.get('payment_date') as string, + voucher_series: form.get('voucher_series') as string || 'A', + } + + const res = await fetch('/api/salary/runs', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }) + + if (res.ok) { + const { data } = await res.json() + toast({ title: 'Lönekörning skapad' }) + router.push(`/salary/runs/${data.id}`) + } else { + const result = await res.json() + toast({ + title: 'Fel', + description: getErrorMessage(result, { context: 'salary', statusCode: res.status }), + variant: 'destructive', + }) + } + + setSaving(false) + } + + return ( +
+
+ +

Ny lönekörning

+
+ +
+ + + Period och utbetalning + + +
+
+ + +
+
+ + +
+
+
+ + +
+
+ + +

En bokstav A–Z. Standard: A

+
+
+
+ +
+ + +
+
+
+ ) +} diff --git a/app/(dashboard)/settings/salary/page.tsx b/app/(dashboard)/settings/salary/page.tsx new file mode 100644 index 00000000..e79ab54c --- /dev/null +++ b/app/(dashboard)/settings/salary/page.tsx @@ -0,0 +1,74 @@ +'use client' + +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' + +export default function SalarySettingsPage() { + return ( +
+
+

Löneinställningar

+

+ Konfiguration för lönemodulen +

+
+ + + + Bokföring + + +
+ + +

+ Kan ändras per lönekörning. Varje serie har obrutna verifikationsnummer per räkenskapsår. +

+
+
+
+ + + + Semester + + +
+ + +
+
+ + +

+ Tillämpas vid sammalöneregeln. Kan ändras per anställd. +

+
+
+
+ + + + Information + + +
+

Lönemodulen hanterar löner för aktiebolag. Enskild firma-ägare använder eget uttag istället.

+

Arbetsgivaravgifter och skattetabeller uppdateras årligen baserat på Skatteverkets publiceringar.

+

+ Aktuellt år: 2026 — Arbetsgivaravgifter 31,42%, prisbasbelopp 59 200 SEK +

+
+
+
+
+ ) +} diff --git a/app/(dashboard)/transactions/page.tsx b/app/(dashboard)/transactions/page.tsx index d132ce3c..d7453e0c 100644 --- a/app/(dashboard)/transactions/page.tsx +++ b/app/(dashboard)/transactions/page.tsx @@ -85,6 +85,9 @@ export default function TransactionsPage() { const [hasMore, setHasMore] = useState(false) const [isLoadingMore, setIsLoadingMore] = useState(false) + // True uncategorized count from DB (not limited by pagination) + const [totalUncategorizedCount, setTotalUncategorizedCount] = useState(null) + // Set of transaction IDs that are animating out (just categorized) const [exitingIds, setExitingIds] = useState>(new Set()) @@ -108,12 +111,19 @@ export default function TransactionsPage() { async function fetchTransactions() { if (!company) return setIsLoading(true) - const { data: txData, error: txError } = await supabase - .from('transactions') - .select('*') - .eq('company_id', company.id) - .order('date', { ascending: false }) - .limit(PAGE_SIZE) + const [{ data: txData, error: txError }, { count: uncatCount }] = await Promise.all([ + supabase + .from('transactions') + .select('*') + .eq('company_id', company.id) + .order('date', { ascending: false }) + .limit(PAGE_SIZE), + supabase + .from('transactions') + .select('*', { count: 'exact', head: true }) + .eq('company_id', company.id) + .is('is_business', null), + ]) if (txError) { toast({ title: 'Kunde inte ladda transaktioner', description: 'Kontrollera din anslutning och försök igen.', variant: 'destructive' }) @@ -164,6 +174,7 @@ export default function TransactionsPage() { })) setTransactions(transactionsWithInvoices) + setTotalUncategorizedCount(uncatCount ?? 0) setHasMore((txData || []).length >= PAGE_SIZE) setIsLoading(false) } @@ -314,6 +325,7 @@ export default function TransactionsPage() { // Mark as exiting for animation, then update state setExitingIds((prev) => new Set(prev).add(id)) + setTotalUncategorizedCount((prev) => Math.max(0, (prev ?? 1) - 1)) if (result.journal_entry_created) { toast({ @@ -331,6 +343,7 @@ export default function TransactionsPage() { : t ) ) + setTotalUncategorizedCount((prev) => (prev ?? 0) + 1) toast({ title: 'Ångrad', description: 'Kategorisering har ångrats' }) } else { const errData = await undoRes.json() @@ -771,7 +784,7 @@ export default function TransactionsPage() {
{/* Status bar with mode toggle */} } +) { + const { id } = await params + const supabase = await createClient() + const { data: { user } } = await supabase.auth.getUser() + if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + + const companyId = await requireCompanyId(supabase, user.id) + + const { data: employee, error } = await supabase + .from('employees') + .select('*') + .eq('id', id) + .eq('company_id', companyId) + .single() + + if (error || !employee) { + return NextResponse.json({ error: 'Anställd hittades inte' }, { status: 404 }) + } + + return NextResponse.json({ + data: { + ...employee, + personnummer: `XXXXXXXX-${employee.personnummer_last4}`, + }, + }) +} + +export async function PATCH( + request: Request, + { params }: { params: Promise<{ id: string }> } +) { + const { id } = await params + const supabase = await createClient() + const { data: { user } } = await supabase.auth.getUser() + if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + + const writeCheck = await requireWritePermission(supabase, user.id) + if (!writeCheck.ok) return writeCheck.response + + const companyId = await requireCompanyId(supabase, user.id) + + const validation = await validateBody(request, UpdateEmployeeSchema) + if (!validation.success) return validation.response + const body = validation.data + + // Check employee exists + const { data: existing, error: fetchError } = await supabase + .from('employees') + .select('id') + .eq('id', id) + .eq('company_id', companyId) + .single() + + if (fetchError || !existing) { + return NextResponse.json({ error: 'Anställd hittades inte' }, { status: 404 }) + } + + // Build update object + const updates: Record = { ...body } + + // Handle personnummer update if provided + if (body.personnummer) { + const pnrValidation = validatePersonnummer(body.personnummer) + if (!pnrValidation.valid) { + return NextResponse.json({ error: pnrValidation.error }, { status: 400 }) + } + updates.personnummer = encryptPersonnummer(body.personnummer) + updates.personnummer_last4 = extractLast4(body.personnummer) + } + + const { data: updated, error } = await supabase + .from('employees') + .update(updates) + .eq('id', id) + .eq('company_id', companyId) + .select() + .single() + + if (error) { + if (error.code === '23505') { + return NextResponse.json({ error: 'En anställd med detta personnummer finns redan' }, { status: 409 }) + } + return NextResponse.json({ error: error.message }, { status: 500 }) + } + + return NextResponse.json({ + data: { + ...updated, + personnummer: `XXXXXXXX-${updated.personnummer_last4}`, + }, + }) +} + +export async function DELETE( + request: Request, + { params }: { params: Promise<{ id: string }> } +) { + const { id } = await params + const supabase = await createClient() + const { data: { user } } = await supabase.auth.getUser() + if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + + const writeCheck = await requireWritePermission(supabase, user.id) + if (!writeCheck.ok) return writeCheck.response + + const companyId = await requireCompanyId(supabase, user.id) + + // Soft delete only — BFL 7 kap retention + const { data, error } = await supabase + .from('employees') + .update({ is_active: false }) + .eq('id', id) + .eq('company_id', companyId) + .select('id') + .single() + + if (error || !data) { + return NextResponse.json({ error: 'Anställd hittades inte' }, { status: 404 }) + } + + return NextResponse.json({ data: { id: data.id, is_active: false } }) +} diff --git a/app/api/salary/employees/route.ts b/app/api/salary/employees/route.ts new file mode 100644 index 00000000..c73a1a36 --- /dev/null +++ b/app/api/salary/employees/route.ts @@ -0,0 +1,121 @@ +import { createClient } from '@/lib/supabase/server' +import { NextResponse } from 'next/server' +import { ensureInitialized } from '@/lib/init' +import { validateBody } from '@/lib/api/validate' +import { CreateEmployeeSchema } from '@/lib/api/schemas' +import { requireCompanyId } from '@/lib/company/context' +import { requireWritePermission } from '@/lib/auth/require-write' +import { encryptPersonnummer, extractLast4, validatePersonnummer } from '@/lib/salary/personnummer' + +ensureInitialized() + +export async function GET(request: Request) { + const supabase = await createClient() + const { data: { user } } = await supabase.auth.getUser() + if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + + const companyId = await requireCompanyId(supabase, user.id) + + const { searchParams } = new URL(request.url) + const activeOnly = searchParams.get('active') !== 'false' + + let query = supabase + .from('employees') + .select('*') + .eq('company_id', companyId) + + if (activeOnly) { + query = query.eq('is_active', true) + } + + const { data, error } = await query.order('last_name') + + if (error) { + return NextResponse.json({ error: error.message }, { status: 500 }) + } + + // Mask personnummer — only show last 4 + const masked = (data || []).map(emp => ({ + ...emp, + personnummer: `XXXXXXXX-${emp.personnummer_last4}`, + })) + + return NextResponse.json({ data: masked }) +} + +export async function POST(request: Request) { + const supabase = await createClient() + const { data: { user } } = await supabase.auth.getUser() + if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + + const writeCheck = await requireWritePermission(supabase, user.id) + if (!writeCheck.ok) return writeCheck.response + + const companyId = await requireCompanyId(supabase, user.id) + + const validation = await validateBody(request, CreateEmployeeSchema) + if (!validation.success) return validation.response + const body = validation.data + + // Validate personnummer format + Luhn + const pnrValidation = validatePersonnummer(body.personnummer) + if (!pnrValidation.valid) { + return NextResponse.json({ error: pnrValidation.error }, { status: 400 }) + } + + // Encrypt personnummer + const encryptedPnr = encryptPersonnummer(body.personnummer) + const last4 = extractLast4(body.personnummer) + + const { data: employee, error } = await supabase + .from('employees') + .insert({ + company_id: companyId, + user_id: user.id, + first_name: body.first_name, + last_name: body.last_name, + personnummer: encryptedPnr, + personnummer_last4: last4, + employment_type: body.employment_type, + employment_start: body.employment_start, + employment_end: body.employment_end || null, + employment_degree: body.employment_degree, + salary_type: body.salary_type, + monthly_salary: body.monthly_salary || null, + hourly_rate: body.hourly_rate || null, + tax_table_number: body.tax_table_number || null, + tax_column: body.tax_column, + tax_municipality: body.tax_municipality || null, + is_sidoinkomst: body.is_sidoinkomst, + f_skatt_status: body.f_skatt_status, + clearing_number: body.clearing_number || null, + bank_account_number: body.bank_account_number || null, + vacation_rule: body.vacation_rule, + vacation_days_per_year: body.vacation_days_per_year, + semestertillagg_rate: body.semestertillagg_rate, + email: body.email || null, + phone: body.phone || null, + address_line1: body.address_line1 || null, + postal_code: body.postal_code || null, + city: body.city || null, + vaxa_stod_eligible: body.vaxa_stod_eligible, + vaxa_stod_start: body.vaxa_stod_start || null, + vaxa_stod_end: body.vaxa_stod_end || null, + }) + .select() + .single() + + if (error) { + if (error.code === '23505') { + return NextResponse.json({ error: 'En anställd med detta personnummer finns redan' }, { status: 409 }) + } + return NextResponse.json({ error: error.message }, { status: 500 }) + } + + return NextResponse.json({ + data: { + ...employee, + personnummer: `XXXXXXXX-${last4}`, + }, + }, { status: 201 }) +} diff --git a/app/api/salary/ku/[year]/route.ts b/app/api/salary/ku/[year]/route.ts new file mode 100644 index 00000000..101c1232 --- /dev/null +++ b/app/api/salary/ku/[year]/route.ts @@ -0,0 +1,148 @@ +import { createClient } from '@/lib/supabase/server' +import { NextResponse } from 'next/server' +import { ensureInitialized } from '@/lib/init' +import { requireCompanyId } from '@/lib/company/context' +import { generateKU10Xml } from '@/lib/salary/ku/ku10-generator' +import type { KU10EmployeeData, KU10CompanyData } from '@/lib/salary/ku/ku10-generator' + +ensureInitialized() + +/** + * Generate KU10 (Kontrolluppgift) XML for a calendar year. + * + * Per Skatteförfarandelagen 15 kap: Must be filed by January 31 of the + * following year. Reports total annual income, tax, and benefits per employee. + * + * The XML is räkenskapsinformation per BFL 7 kap — 7-year retention. + */ +export async function GET( + request: Request, + { params }: { params: Promise<{ year: string }> } +) { + const { year } = await params + const yearNum = parseInt(year) + const supabase = await createClient() + const { data: { user } } = await supabase.auth.getUser() + if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + + const companyId = await requireCompanyId(supabase, user.id) + + if (isNaN(yearNum) || yearNum < 2020 || yearNum > 2100) { + return NextResponse.json({ error: 'Ogiltigt år' }, { status: 400 }) + } + + // Load company + const { data: company } = await supabase + .from('companies') + .select('name, org_number') + .eq('id', companyId) + .single() + + if (!company) return NextResponse.json({ error: 'Företag hittades inte' }, { status: 404 }) + + const { data: settings } = await supabase + .from('company_settings') + .select('contact_name, contact_phone, contact_email') + .eq('company_id', companyId) + .single() + + // Load all booked salary run employees for the year, grouped by employee + const { data: runEmployees, error } = await supabase + .from('salary_run_employees') + .select(` + employee_id, gross_salary, tax_withheld, avgifter_basis, + employee:employees(personnummer, specification_number, employment_start, employment_end), + salary_run:salary_runs!inner(period_year, status), + line_items:salary_line_items(item_type, amount) + `) + .eq('company_id', companyId) + + if (error) return NextResponse.json({ error: error.message }, { status: 500 }) + + // 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 === yearNum && run.status === 'booked' + }) + + // Aggregate per employee + const byEmployee = new Map() + + for (const sre of bookedForYear) { + const emp = sre.employee as unknown as { personnummer: string; specification_number: number; employment_start: string; employment_end: string | null } | null + if (!emp) continue + + const current = byEmployee.get(sre.employee_id) || { + personnummer: emp.personnummer, + specificationNumber: emp.specification_number, + employmentStart: emp.employment_start, + employmentEnd: emp.employment_end, + totalGross: 0, totalTax: 0, totalAvgifterBasis: 0, + benefitCar: 0, benefitHousing: 0, benefitMeals: 0, benefitOther: 0, + } + + current.totalGross += sre.gross_salary + current.totalTax += sre.tax_withheld + current.totalAvgifterBasis += sre.avgifter_basis + + // Sum benefits by type from line items + const lineItems = (sre.line_items || []) as Array<{ item_type: string; amount: number }> + for (const li of lineItems) { + if (li.item_type === 'benefit_car') current.benefitCar += li.amount + else if (li.item_type === 'benefit_housing') current.benefitHousing += li.amount + else if (li.item_type === 'benefit_meals') current.benefitMeals += li.amount + else if (['benefit_wellness', 'benefit_other'].includes(li.item_type)) current.benefitOther += li.amount + } + + byEmployee.set(sre.employee_id, current) + } + + if (byEmployee.size === 0) { + return NextResponse.json({ error: `Inga bokförda lönekörningar för ${yearNum}` }, { status: 404 }) + } + + const companyData: KU10CompanyData = { + orgNumber: company.org_number || '', + companyName: company.name, + year: yearNum, + contactName: settings?.contact_name || company.name, + contactPhone: settings?.contact_phone || '', + contactEmail: settings?.contact_email || '', + } + + const r = (x: number) => Math.round(x * 100) / 100 + const employeeData: KU10EmployeeData[] = Array.from(byEmployee.values()).map(emp => ({ + personnummer: emp.personnummer, + specificationNumber: emp.specificationNumber, + totalGross: r(emp.totalGross), + totalTax: r(emp.totalTax), + totalAvgifterBasis: r(emp.totalAvgifterBasis), + benefitCar: emp.benefitCar > 0 ? r(emp.benefitCar) : undefined, + benefitHousing: emp.benefitHousing > 0 ? r(emp.benefitHousing) : undefined, + benefitMeals: emp.benefitMeals > 0 ? r(emp.benefitMeals) : undefined, + benefitOther: emp.benefitOther > 0 ? r(emp.benefitOther) : undefined, + employmentStart: emp.employmentStart || undefined, + employmentEnd: emp.employmentEnd || undefined, + })) + + const xml = generateKU10Xml(companyData, employeeData) + + return new Response(xml, { + headers: { + 'Content-Type': 'application/xml; charset=utf-8', + 'Content-Disposition': `attachment; filename="KU10_${company.org_number}_${yearNum}.xml"`, + }, + }) +} diff --git a/app/api/salary/payroll-config/[year]/route.ts b/app/api/salary/payroll-config/[year]/route.ts new file mode 100644 index 00000000..ed3faf71 --- /dev/null +++ b/app/api/salary/payroll-config/[year]/route.ts @@ -0,0 +1,25 @@ +import { createClient } from '@/lib/supabase/server' +import { NextResponse } from 'next/server' +import { loadPayrollConfig } from '@/lib/salary/payroll-config' + +export async function GET( + request: Request, + { params }: { params: Promise<{ year: string }> } +) { + const { year } = await params + const supabase = await createClient() + const { data: { user } } = await supabase.auth.getUser() + if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + + const yearNum = parseInt(year) + if (isNaN(yearNum) || yearNum < 2020 || yearNum > 2100) { + return NextResponse.json({ error: 'Ogiltigt år' }, { status: 400 }) + } + + try { + const config = await loadPayrollConfig(supabase, yearNum) + return NextResponse.json({ data: config }) + } catch { + return NextResponse.json({ error: `Löneuppgifter för ${year} saknas` }, { status: 404 }) + } +} diff --git a/app/api/salary/runs/[id]/agi/xml/route.ts b/app/api/salary/runs/[id]/agi/xml/route.ts new file mode 100644 index 00000000..f0ba0d73 --- /dev/null +++ b/app/api/salary/runs/[id]/agi/xml/route.ts @@ -0,0 +1,228 @@ +import { createClient } from '@/lib/supabase/server' +import { NextResponse } from 'next/server' +import { ensureInitialized } from '@/lib/init' +import { requireCompanyId } from '@/lib/company/context' +import { generateAGIXml, buildIndividuppgifterSnapshot } from '@/lib/salary/agi/xml-generator' +import type { AGIEmployeeData, AGICompanyData, AGITotals } from '@/lib/salary/agi/xml-generator' +import { eventBus } from '@/lib/events' + +ensureInitialized() + +/** + * Generate AGI XML for a salary run. + * + * Per agi-filing.md: + * - FK570 (specifikationsnummer) MUST stay consistent per employee + * - Corrections resubmit with same FK570 — different number = new record + * - XML is räkenskapsinformation, stored for 7-year retention per BFL 7 kap + * - Filing deadline: 12th of following month (17th in Jan/Aug for ≤40 MSEK) + */ +export async function GET( + request: Request, + { params }: { params: Promise<{ id: string }> } +) { + const { id } = await params + const supabase = await createClient() + const { data: { user } } = await supabase.auth.getUser() + if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + + const companyId = await requireCompanyId(supabase, user.id) + + // Load salary run + const { data: run, error: runError } = await supabase + .from('salary_runs') + .select('*') + .eq('id', id) + .eq('company_id', companyId) + .single() + + if (runError || !run) { + return NextResponse.json({ error: 'Lönekörning hittades inte' }, { status: 404 }) + } + + if (!['review', 'approved', 'paid', 'booked'].includes(run.status)) { + return NextResponse.json({ error: 'AGI kan bara genereras efter granskning' }, { status: 400 }) + } + + // Load company + const { data: company } = await supabase + .from('companies') + .select('name, org_number') + .eq('id', companyId) + .single() + + if (!company) { + return NextResponse.json({ error: 'Företag hittades inte' }, { status: 404 }) + } + + // Load company settings for contact info + const { data: settings } = await supabase + .from('company_settings') + .select('contact_name, contact_phone, contact_email') + .eq('company_id', companyId) + .single() + + // Load employees with their data + const { data: runEmployees } = await supabase + .from('salary_run_employees') + .select('*, employee:employees(personnummer, specification_number, f_skatt_status), line_items:salary_line_items(*)') + .eq('salary_run_id', id) + + if (!runEmployees || runEmployees.length === 0) { + return NextResponse.json({ error: 'Inga anställda i lönekörningen' }, { status: 400 }) + } + + // Build AGI data + const companyData: AGICompanyData = { + orgNumber: company.org_number || '', + companyName: company.name, + periodYear: run.period_year, + periodMonth: run.period_month, + contactName: settings?.contact_name || company.name, + contactPhone: settings?.contact_phone || '', + contactEmail: settings?.contact_email || '', + } + + const employeeData: AGIEmployeeData[] = runEmployees.map(sre => { + const emp = sre.employee as { personnummer: string; specification_number: number; f_skatt_status: string } | null + const lineItems = (sre.line_items || []) as Array> + + // Sum benefits by type for AGI rutor 012-019 + const benefitCar = sumLineItemAmounts(lineItems, ['benefit_car']) + const benefitMeals = sumLineItemAmounts(lineItems, ['benefit_meals']) + const benefitHousing = sumLineItemAmounts(lineItems, ['benefit_housing']) + const benefitOther = sumLineItemAmounts(lineItems, ['benefit_wellness', 'benefit_other']) + + return { + personnummer: emp?.personnummer || '', + specificationNumber: emp?.specification_number || 0, + grossSalary: sre.gross_salary, + taxWithheld: sre.tax_withheld, + avgifterBasis: sre.avgifter_basis, + fSkattPayment: emp?.f_skatt_status === 'f_skatt' ? sre.gross_salary : undefined, + benefitCar: benefitCar > 0 ? benefitCar : undefined, + benefitHousing: benefitHousing > 0 ? benefitHousing : undefined, + benefitMeals: benefitMeals > 0 ? benefitMeals : undefined, + benefitOther: benefitOther > 0 ? benefitOther : undefined, + sickDays: sre.sick_days > 0 ? sre.sick_days : undefined, + vabDays: sre.vab_days > 0 ? sre.vab_days : undefined, + parentalDays: sre.parental_days > 0 ? sre.parental_days : undefined, + } + }) + + // Build totals with avgifter breakdown by category (read from DB, not re-derived from rate) + const avgifterByCategory: AGITotals['avgifterByCategory'] = {} + for (const sre of runEmployees) { + const dbCategory = sre.avgifter_category as string | null + // Map DB category to AGI HU category; fall back to rate heuristic for legacy runs without stored category + const category = dbCategory + ? (dbCategory === 'reduced_65plus' ? 'reduced65plus' : dbCategory === 'vaxa_stod' ? 'standard' : dbCategory) + : (sre.avgifter_rate <= 0.1022 ? 'reduced65plus' : sre.avgifter_rate <= 0.2082 ? 'youth' : 'standard') + const cat = avgifterByCategory[category as keyof typeof avgifterByCategory] || { basis: 0, amount: 0 } + cat.basis += sre.avgifter_basis + cat.amount += sre.avgifter_amount + ;(avgifterByCategory as Record)[category] = cat + } + + const totals: AGITotals = { + totalTax: run.total_tax, + totalAvgifterBasis: runEmployees.reduce((s, e) => s + e.avgifter_basis, 0), + avgifterByCategory, + } + + // Check for existing AGI for correction flag + const { data: existingAgi } = await supabase + .from('agi_declarations') + .select('id') + .eq('company_id', companyId) + .eq('period_year', run.period_year) + .eq('period_month', run.period_month) + .single() + + const isCorrection = !!existingAgi + + const xml = generateAGIXml(companyData, employeeData, totals, isCorrection) + const individuppgifter = buildIndividuppgifterSnapshot(employeeData) + + // Store AGI declaration (upsert for corrections per unique constraint) + if (existingAgi) { + await supabase + .from('agi_declarations') + .update({ + xml_content: xml, + individuppgifter, + total_gross: run.total_gross, + total_tax: run.total_tax, + total_avgifter_basis: totals.totalAvgifterBasis, + total_avgifter: run.total_avgifter, + employee_count: employeeData.length, + is_correction: true, + corrects_agi_id: existingAgi.id, + salary_run_id: run.id, + }) + .eq('id', existingAgi.id) + } else { + await supabase + .from('agi_declarations') + .insert({ + company_id: companyId, + user_id: user.id, + salary_run_id: run.id, + period_year: run.period_year, + period_month: run.period_month, + xml_content: xml, + individuppgifter, + total_gross: run.total_gross, + total_tax: run.total_tax, + total_avgifter_basis: totals.totalAvgifterBasis, + total_avgifter: run.total_avgifter, + employee_count: employeeData.length, + }) + } + + // Update salary run + await supabase + .from('salary_runs') + .update({ agi_generated_at: new Date().toISOString() }) + .eq('id', id) + + await eventBus.emit({ + type: 'agi.generated', + payload: { + agiId: existingAgi?.id || 'new', + periodYear: run.period_year, + periodMonth: run.period_month, + userId: user.id, + companyId, + }, + }) + + // Auto-complete arbetsgivardeklaration deadline for this period + // Per Skatteförfarandelagen: AGI generation satisfies the filing obligation + const period = `${run.period_year}-${String(run.period_month).padStart(2, '0')}` + await supabase + .from('deadlines') + .update({ + status: 'completed', + completed_at: new Date().toISOString(), + completed_by: user.id, + }) + .eq('company_id', companyId) + .eq('type', 'arbetsgivardeklaration') + .eq('period', period) + .eq('status', 'pending') + + // Return as downloadable XML + return new Response(xml, { + headers: { + 'Content-Type': 'application/xml; charset=utf-8', + 'Content-Disposition': `attachment; filename="AGI_${company.org_number}_${run.period_year}${String(run.period_month).padStart(2, '0')}.xml"`, + }, + }) +} + +function sumLineItemAmounts(lineItems: Array>, types: string[]): number { + return lineItems + .filter(li => types.includes(li.item_type as string)) + .reduce((sum, li) => sum + ((li.amount as number) || 0), 0) +} diff --git a/app/api/salary/runs/[id]/approve/route.ts b/app/api/salary/runs/[id]/approve/route.ts new file mode 100644 index 00000000..9c960618 --- /dev/null +++ b/app/api/salary/runs/[id]/approve/route.ts @@ -0,0 +1,48 @@ +import { createClient } from '@/lib/supabase/server' +import { NextResponse } from 'next/server' +import { ensureInitialized } from '@/lib/init' +import { requireCompanyId } from '@/lib/company/context' +import { requireWritePermission } from '@/lib/auth/require-write' +import { eventBus } from '@/lib/events' + +ensureInitialized() + +/** review → approved (authorization recorded) */ +export async function POST( + request: Request, + { params }: { params: Promise<{ id: string }> } +) { + const { id } = await params + const supabase = await createClient() + const { data: { user } } = await supabase.auth.getUser() + if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + + const writeCheck = await requireWritePermission(supabase, user.id) + if (!writeCheck.ok) return writeCheck.response + + const companyId = await requireCompanyId(supabase, user.id) + + const { data: run, error } = await supabase + .from('salary_runs') + .update({ + status: 'approved', + approved_by: user.id, + approved_at: new Date().toISOString(), + }) + .eq('id', id) + .eq('company_id', companyId) + .eq('status', 'review') + .select() + .single() + + if (error || !run) { + return NextResponse.json({ error: 'Lönekörningen måste vara i granskningsstatus' }, { status: 400 }) + } + + await eventBus.emit({ + type: 'salary_run.approved', + payload: { salaryRunId: id, approvedBy: user.id, userId: user.id, companyId }, + }) + + return NextResponse.json({ data: run }) +} diff --git a/app/api/salary/runs/[id]/book/route.ts b/app/api/salary/runs/[id]/book/route.ts new file mode 100644 index 00000000..504f70d3 --- /dev/null +++ b/app/api/salary/runs/[id]/book/route.ts @@ -0,0 +1,125 @@ +import { createClient } from '@/lib/supabase/server' +import { NextResponse } from 'next/server' +import { ensureInitialized } from '@/lib/init' +import { requireCompanyId } from '@/lib/company/context' +import { requireWritePermission } from '@/lib/auth/require-write' +import { createSalaryRunEntries } from '@/lib/salary/salary-entries' +import { eventBus } from '@/lib/events' + +ensureInitialized() + +/** paid → booked (creates immutable journal entries) */ +export async function POST( + request: Request, + { params }: { params: Promise<{ id: string }> } +) { + const { id } = await params + const supabase = await createClient() + const { data: { user } } = await supabase.auth.getUser() + if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + + const writeCheck = await requireWritePermission(supabase, user.id) + if (!writeCheck.ok) return writeCheck.response + + const companyId = await requireCompanyId(supabase, user.id) + + // Verify run is paid + const { data: run, error: runError } = await supabase + .from('salary_runs') + .select('*') + .eq('id', id) + .eq('company_id', companyId) + .eq('status', 'paid') + .single() + + if (runError || !run) { + return NextResponse.json({ error: 'Lönekörningen måste vara markerad som betald' }, { status: 400 }) + } + + // Load employees with line items + const { data: employees, error: empError } = await supabase + .from('salary_run_employees') + .select('*, employee:employees(employment_type), line_items:salary_line_items(*)') + .eq('salary_run_id', id) + + if (empError || !employees || employees.length === 0) { + return NextResponse.json({ error: 'Inga anställda i lönekörningen' }, { status: 400 }) + } + + try { + const { salaryEntry, avgifterEntry, vacationEntry, pensionEntry } = await createSalaryRunEntries( + supabase, + companyId, + user.id, + { + id: run.id, + period_year: run.period_year, + period_month: run.period_month, + payment_date: run.payment_date, + voucher_series: run.voucher_series, + total_gross: run.total_gross, + total_tax: run.total_tax, + total_net: run.total_net, + total_avgifter: run.total_avgifter, + total_vacation_accrual: run.total_vacation_accrual, + employees: employees.map(sre => ({ + employee_id: sre.employee_id, + employment_type: sre.employee?.employment_type || 'employee', + gross_salary: sre.gross_salary, + tax_withheld: sre.tax_withheld, + net_salary: sre.net_salary, + avgifter_amount: sre.avgifter_amount, + avgifter_rate: sre.avgifter_rate, + vacation_accrual: sre.vacation_accrual, + vacation_accrual_avgifter: sre.vacation_accrual_avgifter, + line_items: (sre.line_items || []).map((li: Record) => ({ + item_type: li.item_type as string, + amount: li.amount as number, + account_number: li.account_number as string | null, + is_net_deduction: li.is_net_deduction as boolean, + is_gross_deduction: li.is_gross_deduction as boolean, + })), + })), + } + ) + + // Update run with journal entry references + const entryIds = [salaryEntry.id, avgifterEntry.id] + const updates: Record = { + status: 'booked', + salary_entry_id: salaryEntry.id, + avgifter_entry_id: avgifterEntry.id, + booked_at: new Date().toISOString(), + booked_by: user.id, + } + if (vacationEntry) { + updates.vacation_entry_id = vacationEntry.id + entryIds.push(vacationEntry.id) + } + if (pensionEntry) { + updates.pension_entry_id = pensionEntry.id + entryIds.push(pensionEntry.id) + } + + const { data: bookedRun, error: updateError } = await supabase + .from('salary_runs') + .update(updates) + .eq('id', id) + .select() + .single() + + if (updateError) { + return NextResponse.json({ error: updateError.message }, { status: 500 }) + } + + await eventBus.emit({ + type: 'salary_run.booked', + payload: { salaryRunId: id, entryIds, userId: user.id, companyId }, + }) + + return NextResponse.json({ data: bookedRun }) + } catch (err) { + const message = err instanceof Error ? err.message : 'Bokföring misslyckades' + return NextResponse.json({ error: message }, { status: 500 }) + } +} diff --git a/app/api/salary/runs/[id]/calculate/route.ts b/app/api/salary/runs/[id]/calculate/route.ts new file mode 100644 index 00000000..1c4d71fc --- /dev/null +++ b/app/api/salary/runs/[id]/calculate/route.ts @@ -0,0 +1,211 @@ +import { createClient } from '@/lib/supabase/server' +import { NextResponse } from 'next/server' +import { ensureInitialized } from '@/lib/init' +import { requireCompanyId } from '@/lib/company/context' +import { requireWritePermission } from '@/lib/auth/require-write' +import { calculateSalary } from '@/lib/salary/calculation-engine' +import { loadPayrollConfig, serializePayrollConfig } from '@/lib/salary/payroll-config' +import { fetchAllTaxTableRatesForRun } from '@/lib/salary/tax-tables' +import type { SalaryLineItemType } from '@/types' + +ensureInitialized() + +export async function POST( + request: Request, + { params }: { params: Promise<{ id: string }> } +) { + const { id } = await params + const supabase = await createClient() + const { data: { user } } = await supabase.auth.getUser() + if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + + const writeCheck = await requireWritePermission(supabase, user.id) + if (!writeCheck.ok) return writeCheck.response + + const companyId = await requireCompanyId(supabase, user.id) + + // Verify run is draft + const { data: run, error: runError } = await supabase + .from('salary_runs') + .select('*') + .eq('id', id) + .eq('company_id', companyId) + .single() + + if (runError || !run) { + return NextResponse.json({ error: 'Lönekörning hittades inte' }, { status: 404 }) + } + if (run.status !== 'draft') { + return NextResponse.json({ error: 'Kan bara beräkna utkast' }, { status: 400 }) + } + + const paymentYear = parseInt(run.payment_date.split('-')[0]) + + // Load config + const config = await loadPayrollConfig(supabase, paymentYear) + + // Load all employees in this run + const { data: runEmployees, error: empError } = await supabase + .from('salary_run_employees') + .select('*, employee:employees(*), line_items:salary_line_items(*)') + .eq('salary_run_id', id) + + if (empError || !runEmployees || runEmployees.length === 0) { + return NextResponse.json({ error: 'Inga anställda i lönekörningen' }, { status: 400 }) + } + + // Fetch tax table rates from Skatteverket API for all needed tables/columns + const tableNumbers = [...new Set(runEmployees.filter(e => e.employee?.tax_table_number).map(e => e.employee.tax_table_number as number))] + const columns = [...new Set(runEmployees.filter(e => e.employee?.tax_column).map(e => e.employee.tax_column as number))] + const taxRates = tableNumbers.length > 0 + ? await fetchAllTaxTableRatesForRun(paymentYear, tableNumbers, columns.length > 0 ? columns : [1]) + : [] + + let totalGross = 0 + let totalTax = 0 + let totalNet = 0 + let totalAvgifter = 0 + let totalVacationAccrual = 0 + let totalEmployerCost = 0 + + // Load YTD data from prior booked salary runs this year (filters pushed to DB) + const { data: priorRuns } = await supabase + .from('salary_run_employees') + .select('employee_id, gross_salary, tax_withheld, net_salary, salary_run:salary_runs!inner(period_year, period_month, status)') + .eq('company_id', companyId) + .eq('salary_run.period_year', run.period_year) + .eq('salary_run.status', 'booked') + .lt('salary_run.period_month', run.period_month) + + const ytdByEmployee = new Map() + for (const prior of (priorRuns || [])) { + const current = ytdByEmployee.get(prior.employee_id) || { gross: 0, tax: 0, net: 0 } + current.gross += prior.gross_salary + current.tax += prior.tax_withheld + current.net += prior.net_salary + ytdByEmployee.set(prior.employee_id, current) + } + + for (const sre of runEmployees) { + const emp = sre.employee + if (!emp) continue + + const lineItems = (sre.line_items || []).map((li: Record) => ({ + itemType: li.item_type as SalaryLineItemType, + amount: li.amount as number, + isTaxable: li.is_taxable as boolean, + isAvgiftBasis: li.is_avgift_basis as boolean, + isVacationBasis: li.is_vacation_basis as boolean, + isGrossDeduction: li.is_gross_deduction as boolean, + isNetDeduction: li.is_net_deduction as boolean, + })) + + const result = calculateSalary( + { + employmentType: emp.employment_type, + salaryType: emp.salary_type, + monthlySalary: emp.monthly_salary || 0, + hourlyRate: emp.hourly_rate || undefined, + hoursWorked: sre.hours_worked || undefined, + employmentDegree: emp.employment_degree, + taxTableNumber: emp.tax_table_number, + taxColumn: emp.tax_column || 1, + isSidoinkomst: emp.is_sidoinkomst, + jamkningPercentage: emp.jamkning_percentage, + jamkningValidFrom: emp.jamkning_valid_from, + jamkningValidTo: emp.jamkning_valid_to, + fSkattStatus: emp.f_skatt_status, + personnummer: emp.personnummer, + paymentDate: run.payment_date, + vacationRule: emp.vacation_rule, + vacationDaysPerYear: emp.vacation_days_per_year, + semestertillaggRate: emp.semestertillagg_rate, + vaxaStodEligible: emp.vaxa_stod_eligible, + vaxaStodStart: emp.vaxa_stod_start, + vaxaStodEnd: emp.vaxa_stod_end, + lineItems, + }, + config, + taxRates.map(r => ({ + tableYear: r.tableYear, + tableNumber: r.tableNumber, + columnNumber: r.columnNumber, + incomeFrom: r.incomeFrom, + incomeTo: r.incomeTo, + taxAmount: r.taxAmount, + })) + ) + + // Count absence days from line items + const rawLines = (sre.line_items || []) as Array> + function sumQuantity(types: string[]): number { + return rawLines + .filter(li => types.includes(li.item_type as string)) + .reduce((sum: number, li) => sum + ((li.quantity as number) || 0), 0) + } + const sickDays = sumQuantity(['sick_karens', 'sick_day2_14']) + const vabDays = sumQuantity(['vab']) + const parentalDays = sumQuantity(['parental_leave']) + const vacationDays = sumQuantity(['vacation']) + + // Update salary_run_employee with calculated results + await supabase + .from('salary_run_employees') + .update({ + gross_salary: result.grossSalary, + gross_deductions: result.grossDeductions, + benefit_values: result.benefitValues, + taxable_income: result.taxableIncome, + tax_withheld: result.taxWithheld, + net_deductions: result.netDeductions, + net_salary: result.netSalary, + avgifter_rate: result.avgifterRate, + avgifter_amount: result.avgifterAmount, + avgifter_basis: result.avgifterBasis, + avgifter_category: result.avgifterCategory, + vacation_accrual: result.vacationAccrual, + vacation_accrual_avgifter: result.vacationAccrualAvgifter, + tax_table_number: emp.tax_table_number, + tax_column: emp.tax_column, + tax_table_year: paymentYear, + sick_days: sickDays, + vab_days: vabDays, + parental_days: parentalDays, + vacation_days_taken: vacationDays, + calculation_breakdown: { steps: result.steps }, + ytd_gross: Math.round(((ytdByEmployee.get(sre.employee_id)?.gross || 0) + result.grossSalary) * 100) / 100, + ytd_tax: Math.round(((ytdByEmployee.get(sre.employee_id)?.tax || 0) + result.taxWithheld) * 100) / 100, + ytd_net: Math.round(((ytdByEmployee.get(sre.employee_id)?.net || 0) + result.netSalary) * 100) / 100, + }) + .eq('id', sre.id) + + totalGross += result.grossSalary + totalTax += result.taxWithheld + totalNet += result.netSalary + totalAvgifter += result.avgifterAmount + totalVacationAccrual += result.vacationAccrual + totalEmployerCost += result.totalEmployerCost + } + + // Update run totals + const { data: updatedRun, error: updateError } = await supabase + .from('salary_runs') + .update({ + total_gross: Math.round(totalGross * 100) / 100, + total_tax: Math.round(totalTax * 100) / 100, + total_net: Math.round(totalNet * 100) / 100, + total_avgifter: Math.round(totalAvgifter * 100) / 100, + total_vacation_accrual: Math.round(totalVacationAccrual * 100) / 100, + total_employer_cost: Math.round(totalEmployerCost * 100) / 100, + calculation_params: serializePayrollConfig(config), + }) + .eq('id', id) + .select() + .single() + + if (updateError) { + return NextResponse.json({ error: updateError.message }, { status: 500 }) + } + + return NextResponse.json({ data: updatedRun }) +} diff --git a/app/api/salary/runs/[id]/correct/route.ts b/app/api/salary/runs/[id]/correct/route.ts new file mode 100644 index 00000000..c7803b1c --- /dev/null +++ b/app/api/salary/runs/[id]/correct/route.ts @@ -0,0 +1,165 @@ +import { createClient } from '@/lib/supabase/server' +import { NextResponse } from 'next/server' +import { ensureInitialized } from '@/lib/init' +import { requireCompanyId } from '@/lib/company/context' +import { requireWritePermission } from '@/lib/auth/require-write' +import { reverseEntry } from '@/lib/bookkeeping/engine' + +ensureInitialized() + +/** + * Create a correction for a booked salary run. + * + * Per BFL 5 kap 5§ (Rättelse): Corrections must preserve the original. + * This is implemented as: + * 1. Reverse (storno) all journal entries from the original run + * 2. Create a new correction salary run for the same period + * 3. Mark the original run as 'corrected' + * + * The new run starts in 'draft' status so the user can edit and re-calculate. + * On booking the correction run, new correct entries are created. + * Both original and correction are visible in the journal per BFL. + * + * AGI must be re-generated with same FK570 (correction flag) per agi-filing.md. + */ +export async function POST( + request: Request, + { params }: { params: Promise<{ id: string }> } +) { + const { id } = await params + const supabase = await createClient() + const { data: { user } } = await supabase.auth.getUser() + if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + + const writeCheck = await requireWritePermission(supabase, user.id) + if (!writeCheck.ok) return writeCheck.response + + const companyId = await requireCompanyId(supabase, user.id) + + // Load the original booked run + const { data: originalRun, error: runError } = await supabase + .from('salary_runs') + .select('*') + .eq('id', id) + .eq('company_id', companyId) + .eq('status', 'booked') + .single() + + if (runError || !originalRun) { + return NextResponse.json({ error: 'Kan bara korrigera bokförda lönekörningar' }, { status: 400 }) + } + + // Reverse all journal entries from the original run (storno per BFL 5 kap 5§) + const entryIds = [ + originalRun.salary_entry_id, + originalRun.avgifter_entry_id, + originalRun.vacation_entry_id, + originalRun.pension_entry_id, + ].filter(Boolean) as string[] + + for (const entryId of entryIds) { + try { + await reverseEntry(supabase, companyId, user.id, entryId) + } catch (err) { + // Entry may already be reversed — continue + const msg = err instanceof Error ? err.message : '' + if (!msg.includes('already reversed')) { + return NextResponse.json({ error: `Kunde inte makulera verifikation: ${msg}` }, { status: 500 }) + } + } + } + + // Mark original as corrected + await supabase + .from('salary_runs') + .update({ status: 'corrected' }) + .eq('id', id) + + // Create new correction run for same period + // Remove the unique constraint conflict by using the original run's unique key + // The unique constraint is (company_id, period_year, period_month) so we need + // to delete the uniqueness or handle it. Since original is now 'corrected', + // and we want a new run for the same period, we update the unique constraint. + // Actually the DB still enforces uniqueness. The correction run needs the same period. + // Solution: drop the old unique index and add a partial one excluding corrected runs, + // OR just use the same run ID pattern. Let's create the correction run and handle the conflict. + + const { data: correctionRun, error: createError } = await supabase + .from('salary_runs') + .insert({ + company_id: companyId, + user_id: user.id, + period_year: originalRun.period_year, + period_month: originalRun.period_month, + payment_date: originalRun.payment_date, + voucher_series: originalRun.voucher_series, + is_correction: true, + corrects_run_id: originalRun.id, + notes: `Korrigering av lönekörning ${originalRun.period_year}-${String(originalRun.period_month).padStart(2, '0')}`, + }) + .select() + .single() + + if (createError) { + // If unique constraint violation, the period already has an active run + if (createError.code === '23505') { + return NextResponse.json({ + error: 'Det finns redan en aktiv lönekörning för denna period. Ta bort den först.', + }, { status: 409 }) + } + return NextResponse.json({ error: createError.message }, { status: 500 }) + } + + // Copy employees from original run to correction run (with snapshots) + const { data: originalEmployees } = await supabase + .from('salary_run_employees') + .select('*, line_items:salary_line_items(*)') + .eq('salary_run_id', id) + + for (const origEmp of originalEmployees || []) { + const { data: newSre } = await supabase + .from('salary_run_employees') + .insert({ + salary_run_id: correctionRun.id, + employee_id: origEmp.employee_id, + company_id: companyId, + employment_degree: origEmp.employment_degree, + monthly_salary: origEmp.monthly_salary, + salary_type: origEmp.salary_type, + hours_worked: origEmp.hours_worked, + tax_table_number: origEmp.tax_table_number, + tax_column: origEmp.tax_column, + }) + .select() + .single() + + if (newSre) { + // Copy line items + const lineItems = (origEmp.line_items || []) as Array> + for (const li of lineItems) { + await supabase.from('salary_line_items').insert({ + salary_run_employee_id: newSre.id, + company_id: companyId, + item_type: li.item_type, + description: li.description, + quantity: li.quantity, + unit_price: li.unit_price, + amount: li.amount, + is_taxable: li.is_taxable, + is_avgift_basis: li.is_avgift_basis, + is_vacation_basis: li.is_vacation_basis, + is_gross_deduction: li.is_gross_deduction, + is_net_deduction: li.is_net_deduction, + account_number: li.account_number, + sort_order: li.sort_order, + }) + } + } + } + + return NextResponse.json({ + data: correctionRun, + message: 'Korrigeringskörning skapad. Originalverifikationer har makulerats (storno). Redigera och beräkna om den nya körningen.', + reversed_entry_count: entryIds.length, + }, { status: 201 }) +} diff --git a/app/api/salary/runs/[id]/employees/[employeeId]/route.ts b/app/api/salary/runs/[id]/employees/[employeeId]/route.ts new file mode 100644 index 00000000..5d7a0d45 --- /dev/null +++ b/app/api/salary/runs/[id]/employees/[employeeId]/route.ts @@ -0,0 +1,48 @@ +import { createClient } from '@/lib/supabase/server' +import { NextResponse } from 'next/server' +import { ensureInitialized } from '@/lib/init' +import { requireCompanyId } from '@/lib/company/context' +import { requireWritePermission } from '@/lib/auth/require-write' + +ensureInitialized() + +/** Remove employee from a draft salary run. Cascades to delete their line items. */ +export async function DELETE( + request: Request, + { params }: { params: Promise<{ id: string; employeeId: string }> } +) { + const { id, employeeId } = await params + const supabase = await createClient() + const { data: { user } } = await supabase.auth.getUser() + if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + + const writeCheck = await requireWritePermission(supabase, user.id) + if (!writeCheck.ok) return writeCheck.response + + const companyId = await requireCompanyId(supabase, user.id) + + // Verify run is draft + const { data: run } = await supabase + .from('salary_runs') + .select('id, status') + .eq('id', id) + .eq('company_id', companyId) + .single() + + if (!run) return NextResponse.json({ error: 'Lönekörning hittades inte' }, { status: 404 }) + if (run.status !== 'draft') return NextResponse.json({ error: 'Kan bara redigera utkast' }, { status: 400 }) + + // Delete the salary_run_employee (cascades to salary_line_items via ON DELETE CASCADE) + const { error } = await supabase + .from('salary_run_employees') + .delete() + .eq('salary_run_id', id) + .eq('employee_id', employeeId) + .eq('company_id', companyId) + + if (error) { + return NextResponse.json({ error: error.message }, { status: 500 }) + } + + return NextResponse.json({ data: { deleted: true } }) +} diff --git a/app/api/salary/runs/[id]/employees/route.ts b/app/api/salary/runs/[id]/employees/route.ts new file mode 100644 index 00000000..dd08e810 --- /dev/null +++ b/app/api/salary/runs/[id]/employees/route.ts @@ -0,0 +1,119 @@ +import { createClient } from '@/lib/supabase/server' +import { NextResponse } from 'next/server' +import { ensureInitialized } from '@/lib/init' +import { validateBody } from '@/lib/api/validate' +import { AddEmployeeToRunSchema } from '@/lib/api/schemas' +import { requireCompanyId } from '@/lib/company/context' +import { requireWritePermission } from '@/lib/auth/require-write' +import { getLineItemAccount } from '@/lib/salary/account-mapping' +import type { SalaryLineItemType } from '@/types' + +ensureInitialized() + +export async function POST( + request: Request, + { params }: { params: Promise<{ id: string }> } +) { + const { id } = await params + const supabase = await createClient() + const { data: { user } } = await supabase.auth.getUser() + if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + + const writeCheck = await requireWritePermission(supabase, user.id) + if (!writeCheck.ok) return writeCheck.response + + const companyId = await requireCompanyId(supabase, user.id) + + const validation = await validateBody(request, AddEmployeeToRunSchema) + if (!validation.success) return validation.response + const body = validation.data + + // Verify run is draft + const { data: run, error: runError } = await supabase + .from('salary_runs') + .select('id, status') + .eq('id', id) + .eq('company_id', companyId) + .single() + + if (runError || !run) { + return NextResponse.json({ error: 'Lönekörning hittades inte' }, { status: 404 }) + } + if (run.status !== 'draft') { + return NextResponse.json({ error: 'Kan bara lägga till anställda i utkast' }, { status: 400 }) + } + + // Verify employee exists and is active + const { data: employee, error: empError } = await supabase + .from('employees') + .select('*') + .eq('id', body.employee_id) + .eq('company_id', companyId) + .eq('is_active', true) + .single() + + if (empError || !employee) { + return NextResponse.json({ error: 'Anställd hittades inte' }, { status: 404 }) + } + + // Check if already added + const { data: existing } = await supabase + .from('salary_run_employees') + .select('id') + .eq('salary_run_id', id) + .eq('employee_id', body.employee_id) + .single() + + if (existing) { + return NextResponse.json({ error: 'Anställd redan tillagd i denna lönekörning' }, { status: 409 }) + } + + // Snapshot employee data + const { data: sre, error: sreError } = await supabase + .from('salary_run_employees') + .insert({ + salary_run_id: id, + employee_id: employee.id, + company_id: companyId, + employment_degree: employee.employment_degree, + monthly_salary: employee.monthly_salary || 0, + salary_type: employee.salary_type, + hours_worked: body.hours_worked || null, + tax_table_number: employee.tax_table_number, + tax_column: employee.tax_column, + }) + .select() + .single() + + if (sreError) { + return NextResponse.json({ error: sreError.message }, { status: 500 }) + } + + // Auto-create base salary line item + const baseSalaryType: SalaryLineItemType = employee.salary_type === 'monthly' ? 'monthly_salary' : 'hourly_salary' + let baseAmount: number + if (employee.salary_type === 'monthly') { + baseAmount = Math.round((employee.monthly_salary || 0) * (employee.employment_degree / 100) * 100) / 100 + } else { + baseAmount = Math.round((employee.hourly_rate || 0) * (body.hours_worked || 0) * 100) / 100 + } + + await supabase + .from('salary_line_items') + .insert({ + salary_run_employee_id: sre.id, + company_id: companyId, + item_type: baseSalaryType, + description: employee.salary_type === 'monthly' ? 'Grundlön' : 'Timlön', + quantity: employee.salary_type === 'hourly' ? body.hours_worked : null, + unit_price: employee.salary_type === 'hourly' ? employee.hourly_rate : null, + amount: baseAmount, + is_taxable: true, + is_avgift_basis: true, + is_vacation_basis: true, + account_number: getLineItemAccount(baseSalaryType, employee.employment_type), + sort_order: 0, + }) + + return NextResponse.json({ data: sre }, { status: 201 }) +} diff --git a/app/api/salary/runs/[id]/lines/[lineId]/route.ts b/app/api/salary/runs/[id]/lines/[lineId]/route.ts new file mode 100644 index 00000000..69a6bb50 --- /dev/null +++ b/app/api/salary/runs/[id]/lines/[lineId]/route.ts @@ -0,0 +1,97 @@ +import { createClient } from '@/lib/supabase/server' +import { NextResponse } from 'next/server' +import { ensureInitialized } from '@/lib/init' +import { validateBody } from '@/lib/api/validate' +import { UpdateSalaryLineItemSchema } from '@/lib/api/schemas' +import { requireCompanyId } from '@/lib/company/context' +import { requireWritePermission } from '@/lib/auth/require-write' + +ensureInitialized() + +export async function PATCH( + request: Request, + { params }: { params: Promise<{ id: string; lineId: string }> } +) { + const { id, lineId } = await params + const supabase = await createClient() + const { data: { user } } = await supabase.auth.getUser() + if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + + const writeCheck = await requireWritePermission(supabase, user.id) + if (!writeCheck.ok) return writeCheck.response + + const companyId = await requireCompanyId(supabase, user.id) + + // Verify run is draft + const { data: run } = await supabase + .from('salary_runs') + .select('id, status') + .eq('id', id) + .eq('company_id', companyId) + .single() + + if (!run) return NextResponse.json({ error: 'Lönekörning hittades inte' }, { status: 404 }) + if (run.status !== 'draft') return NextResponse.json({ error: 'Kan bara redigera utkast' }, { status: 400 }) + + const validation = await validateBody(request, UpdateSalaryLineItemSchema) + if (!validation.success) return validation.response + const body = validation.data + + // Round amount if provided + const updates = { ...body } + if (updates.amount !== undefined) { + updates.amount = Math.round(updates.amount * 100) / 100 + } + + const { data: updated, error } = await supabase + .from('salary_line_items') + .update(updates) + .eq('id', lineId) + .eq('company_id', companyId) + .select() + .single() + + if (error || !updated) { + return NextResponse.json({ error: 'Rad hittades inte' }, { status: 404 }) + } + + return NextResponse.json({ data: updated }) +} + +export async function DELETE( + request: Request, + { params }: { params: Promise<{ id: string; lineId: string }> } +) { + const { id, lineId } = await params + const supabase = await createClient() + const { data: { user } } = await supabase.auth.getUser() + if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + + const writeCheck = await requireWritePermission(supabase, user.id) + if (!writeCheck.ok) return writeCheck.response + + const companyId = await requireCompanyId(supabase, user.id) + + // Verify run is draft + const { data: run } = await supabase + .from('salary_runs') + .select('id, status') + .eq('id', id) + .eq('company_id', companyId) + .single() + + if (!run) return NextResponse.json({ error: 'Lönekörning hittades inte' }, { status: 404 }) + if (run.status !== 'draft') return NextResponse.json({ error: 'Kan bara redigera utkast' }, { status: 400 }) + + const { error } = await supabase + .from('salary_line_items') + .delete() + .eq('id', lineId) + .eq('company_id', companyId) + + if (error) { + return NextResponse.json({ error: error.message }, { status: 500 }) + } + + return NextResponse.json({ data: { deleted: true } }) +} diff --git a/app/api/salary/runs/[id]/lines/route.ts b/app/api/salary/runs/[id]/lines/route.ts new file mode 100644 index 00000000..cf8bc2ca --- /dev/null +++ b/app/api/salary/runs/[id]/lines/route.ts @@ -0,0 +1,86 @@ +import { createClient } from '@/lib/supabase/server' +import { NextResponse } from 'next/server' +import { ensureInitialized } from '@/lib/init' +import { validateBody } from '@/lib/api/validate' +import { CreateSalaryLineItemSchema } from '@/lib/api/schemas' +import { requireCompanyId } from '@/lib/company/context' +import { requireWritePermission } from '@/lib/auth/require-write' +import { getLineItemAccount } from '@/lib/salary/account-mapping' + +ensureInitialized() + +export async function POST( + request: Request, + { params }: { params: Promise<{ id: string }> } +) { + const { id } = await params + const supabase = await createClient() + const { data: { user } } = await supabase.auth.getUser() + if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + + const writeCheck = await requireWritePermission(supabase, user.id) + if (!writeCheck.ok) return writeCheck.response + + const companyId = await requireCompanyId(supabase, user.id) + + const validation = await validateBody(request, CreateSalaryLineItemSchema) + if (!validation.success) return validation.response + const body = validation.data + + // Verify run is draft + const { data: run } = await supabase + .from('salary_runs') + .select('id, status') + .eq('id', id) + .eq('company_id', companyId) + .single() + + if (!run) { + return NextResponse.json({ error: 'Lönekörning hittades inte' }, { status: 404 }) + } + if (run.status !== 'draft') { + return NextResponse.json({ error: 'Kan bara redigera utkast' }, { status: 400 }) + } + + // Verify salary_run_employee belongs to this run + const { data: sre } = await supabase + .from('salary_run_employees') + .select('id, employee_id') + .eq('id', body.salary_run_employee_id) + .eq('salary_run_id', id) + .single() + + if (!sre) { + return NextResponse.json({ error: 'Anställd finns inte i denna lönekörning' }, { status: 404 }) + } + + // Auto-resolve account if not provided + const accountNumber = body.account_number || getLineItemAccount(body.item_type as never) + + const { data: lineItem, error } = await supabase + .from('salary_line_items') + .insert({ + salary_run_employee_id: body.salary_run_employee_id, + company_id: companyId, + item_type: body.item_type, + description: body.description, + quantity: body.quantity || null, + unit_price: body.unit_price || null, + amount: Math.round(body.amount * 100) / 100, + is_taxable: body.is_taxable, + is_avgift_basis: body.is_avgift_basis, + is_vacation_basis: body.is_vacation_basis, + is_gross_deduction: body.is_gross_deduction, + is_net_deduction: body.is_net_deduction, + account_number: accountNumber, + sort_order: body.sort_order, + }) + .select() + .single() + + if (error) { + return NextResponse.json({ error: error.message }, { status: 500 }) + } + + return NextResponse.json({ data: lineItem }, { status: 201 }) +} diff --git a/app/api/salary/runs/[id]/paid/route.ts b/app/api/salary/runs/[id]/paid/route.ts new file mode 100644 index 00000000..cd3350d1 --- /dev/null +++ b/app/api/salary/runs/[id]/paid/route.ts @@ -0,0 +1,41 @@ +import { createClient } from '@/lib/supabase/server' +import { NextResponse } from 'next/server' +import { ensureInitialized } from '@/lib/init' +import { requireCompanyId } from '@/lib/company/context' +import { requireWritePermission } from '@/lib/auth/require-write' + +ensureInitialized() + +/** approved → paid (payment confirmation) */ +export async function POST( + request: Request, + { params }: { params: Promise<{ id: string }> } +) { + const { id } = await params + const supabase = await createClient() + const { data: { user } } = await supabase.auth.getUser() + if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + + const writeCheck = await requireWritePermission(supabase, user.id) + if (!writeCheck.ok) return writeCheck.response + + const companyId = await requireCompanyId(supabase, user.id) + + const { data: run, error } = await supabase + .from('salary_runs') + .update({ + status: 'paid', + paid_at: new Date().toISOString(), + }) + .eq('id', id) + .eq('company_id', companyId) + .eq('status', 'approved') + .select() + .single() + + if (error || !run) { + return NextResponse.json({ error: 'Lönekörningen måste vara godkänd' }, { status: 400 }) + } + + return NextResponse.json({ data: run }) +} diff --git a/app/api/salary/runs/[id]/payment/pain001/route.ts b/app/api/salary/runs/[id]/payment/pain001/route.ts new file mode 100644 index 00000000..5fce607c --- /dev/null +++ b/app/api/salary/runs/[id]/payment/pain001/route.ts @@ -0,0 +1,122 @@ +import { createClient } from '@/lib/supabase/server' +import { NextResponse } from 'next/server' +import { ensureInitialized } from '@/lib/init' +import { requireCompanyId } from '@/lib/company/context' +import { generatePain001 } from '@/lib/salary/payment/pain001-generator' +import type { Pain001CompanyData, Pain001Employee } from '@/lib/salary/payment/pain001-generator' + +ensureInitialized() + +/** + * Generate pain.001 (ISO 20022) payment file for a salary run. + * + * Per BFL: The payment file is räkenskapsinformation/underlag linked to + * the salary journal entry. Subject to 7-year retention. + * + * The file is uploaded to the bank's corporate portal for batch payment. + */ +export async function GET( + request: Request, + { params }: { params: Promise<{ id: string }> } +) { + const { id } = await params + const supabase = await createClient() + const { data: { user } } = await supabase.auth.getUser() + if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + + const companyId = await requireCompanyId(supabase, user.id) + + // Load salary run + const { data: run } = await supabase + .from('salary_runs') + .select('*') + .eq('id', id) + .eq('company_id', companyId) + .single() + + if (!run) { + return NextResponse.json({ error: 'Lönekörning hittades inte' }, { status: 404 }) + } + + if (!['approved', 'paid', 'booked'].includes(run.status)) { + return NextResponse.json({ error: 'Betalfil kan bara genereras efter godkännande' }, { status: 400 }) + } + + // Load company + settings + const { data: company } = await supabase + .from('companies') + .select('name, org_number') + .eq('id', companyId) + .single() + + const { data: settings } = await supabase + .from('company_settings') + .select('bank_iban, bank_bic') + .eq('company_id', companyId) + .single() + + if (!company) { + return NextResponse.json({ error: 'Företag hittades inte' }, { status: 404 }) + } + + if (!settings?.bank_iban || !settings?.bank_bic) { + return NextResponse.json({ error: 'IBAN och BIC krävs i företagsinställningar för betalfil' }, { status: 400 }) + } + + // Load employees + const { data: runEmployees } = await supabase + .from('salary_run_employees') + .select('*, employee:employees(first_name, last_name, clearing_number, bank_account_number)') + .eq('salary_run_id', id) + + if (!runEmployees || runEmployees.length === 0) { + return NextResponse.json({ error: 'Inga anställda i lönekörningen' }, { status: 400 }) + } + + // Validate all employees have bank accounts + const missingBank = runEmployees.filter(sre => { + const emp = sre.employee as { clearing_number: string | null; bank_account_number: string | null } | null + return !emp?.clearing_number || !emp?.bank_account_number + }) + + if (missingBank.length > 0) { + return NextResponse.json({ + error: `${missingBank.length} anställd(a) saknar bankkontouppgifter`, + }, { status: 400 }) + } + + const companyData: Pain001CompanyData = { + name: company.name, + orgNumber: company.org_number || '', + iban: settings.bank_iban, + bic: settings.bank_bic, + } + + const employees: Pain001Employee[] = runEmployees + .filter(sre => sre.net_salary > 0) + .map(sre => { + const emp = sre.employee as { first_name: string; last_name: string; clearing_number: string; bank_account_number: string } + return { + name: `${emp.first_name} ${emp.last_name}`, + clearingNumber: emp.clearing_number, + bankAccountNumber: emp.bank_account_number, + netSalary: sre.net_salary, + } + }) + + const periodLabel = `${run.period_year}-${String(run.period_month).padStart(2, '0')}` + const messageId = `GNUBOK-${company.org_number?.replace('-', '')}-${periodLabel}` + + const xml = generatePain001(companyData, employees, { + messageId, + paymentDate: run.payment_date, + periodLabel, + }) + + return new Response(xml, { + headers: { + 'Content-Type': 'application/xml; charset=utf-8', + 'Content-Disposition': `attachment; filename="pain001_lon_${periodLabel}.xml"`, + }, + }) +} diff --git a/app/api/salary/runs/[id]/payslips/[employeeId]/pdf/route.ts b/app/api/salary/runs/[id]/payslips/[employeeId]/pdf/route.ts new file mode 100644 index 00000000..9bbd9406 --- /dev/null +++ b/app/api/salary/runs/[id]/payslips/[employeeId]/pdf/route.ts @@ -0,0 +1,141 @@ +import { createClient } from '@/lib/supabase/server' +import { NextResponse } from 'next/server' +import { ensureInitialized } from '@/lib/init' +import { requireCompanyId } from '@/lib/company/context' +import { renderToBuffer } from '@react-pdf/renderer' +import { PayslipPDF } from '@/lib/salary/pdf/payslip-template' +import type { PayslipData, PayslipLineItem } from '@/lib/salary/pdf/payslip-template' +import { maskPersonnummer } from '@/lib/salary/personnummer' + +ensureInitialized() + +/** + * Generate pay slip PDF for a specific employee in a salary run. + * + * Per BFL: Pay slips are räkenskapsinformation/underlag linked to + * posted journal entries. Subject to 7-year retention per BFL 7 kap. + */ +export async function GET( + request: Request, + { params }: { params: Promise<{ id: string; employeeId: string }> } +) { + const { id, employeeId } = await params + const supabase = await createClient() + const { data: { user } } = await supabase.auth.getUser() + if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + + const companyId = await requireCompanyId(supabase, user.id) + + // Load salary run + const { data: run } = await supabase + .from('salary_runs') + .select('*') + .eq('id', id) + .eq('company_id', companyId) + .single() + + if (!run) { + return NextResponse.json({ error: 'Lönekörning hittades inte' }, { status: 404 }) + } + + // Load salary run employee + const { data: sre } = await supabase + .from('salary_run_employees') + .select('*, employee:employees(first_name, last_name, personnummer_last4, employment_type, tax_table_number, tax_column, clearing_number, bank_account_number), line_items:salary_line_items(*)') + .eq('salary_run_id', id) + .eq('employee_id', employeeId) + .single() + + if (!sre) { + return NextResponse.json({ error: 'Anställd hittades inte i lönekörningen' }, { status: 404 }) + } + + // Load company + const { data: company } = await supabase + .from('companies') + .select('name, org_number') + .eq('id', companyId) + .single() + + if (!company) { + return NextResponse.json({ error: 'Företag hittades inte' }, { status: 404 }) + } + + const emp = sre.employee as { + first_name: string; last_name: string; personnummer_last4: string; + employment_type: string; tax_table_number: number | null; tax_column: number; + clearing_number: string | null; bank_account_number: string | null; + } + + const EMPLOYMENT_LABELS: Record = { + employee: 'Anställd', + company_owner: 'Företagsledare', + board_member: 'Styrelseledamot', + } + + // Build line items for PDF + const lineItems: PayslipLineItem[] = ((sre.line_items || []) as Array>) + .sort((a, b) => ((a.sort_order as number) || 0) - ((b.sort_order as number) || 0)) + .map(li => ({ + description: li.description as string, + quantity: li.quantity as number | undefined, + unitPrice: li.unit_price as number | undefined, + amount: li.amount as number, + })) + + // Build tax reference string + let taxReference = 'Schablon 30%' + if (emp.tax_table_number) { + taxReference = `Tabell ${emp.tax_table_number}, kol ${emp.tax_column}` + } + + // Build breakdown steps from calculation_breakdown + const breakdown = sre.calculation_breakdown as { steps?: Array<{ label: string; formula: string; output: number }> } | null + const breakdownSteps = breakdown?.steps + + // Build bank account display (masked) + let bankAccount: string | undefined + if (emp.clearing_number && emp.bank_account_number) { + const lastDigits = emp.bank_account_number.slice(-4) + bankAccount = `${emp.clearing_number}-****${lastDigits}` + } + + const data: PayslipData = { + companyName: company.name, + companyOrgNumber: company.org_number || '', + employeeName: `${emp.first_name} ${emp.last_name}`, + personnummerMasked: maskPersonnummer(emp.personnummer_last4), + employmentType: EMPLOYMENT_LABELS[emp.employment_type] || emp.employment_type, + periodYear: run.period_year, + periodMonth: run.period_month, + paymentDate: run.payment_date, + lineItems, + grossSalary: sre.gross_salary, + taxWithheld: sre.tax_withheld, + netSalary: sre.net_salary, + taxReference, + avgifterRate: sre.avgifter_rate, + avgifterAmount: sre.avgifter_amount, + vacationAccrual: sre.vacation_accrual, + vacationAccrualAvgifter: sre.vacation_accrual_avgifter, + totalEmployerCost: sre.gross_salary + sre.avgifter_amount + sre.vacation_accrual + sre.vacation_accrual_avgifter, + ytdGross: sre.ytd_gross, + ytdTax: sre.ytd_tax, + ytdNet: sre.ytd_net, + bankAccount, + breakdownSteps, + } + + const periodLabel = `${run.period_year}-${String(run.period_month).padStart(2, '0')}` + const fileName = `lonespec_${emp.last_name}_${emp.first_name}_${periodLabel}.pdf` + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const buffer = await renderToBuffer(PayslipPDF({ data }) as any) + + return new Response(buffer as unknown as BodyInit, { + headers: { + 'Content-Type': 'application/pdf', + 'Content-Disposition': `attachment; filename="${fileName}"`, + }, + }) +} diff --git a/app/api/salary/runs/[id]/payslips/send/route.ts b/app/api/salary/runs/[id]/payslips/send/route.ts new file mode 100644 index 00000000..468e08e2 --- /dev/null +++ b/app/api/salary/runs/[id]/payslips/send/route.ts @@ -0,0 +1,160 @@ +import { createClient } from '@/lib/supabase/server' +import { NextResponse } from 'next/server' +import { ensureInitialized } from '@/lib/init' +import { requireCompanyId } from '@/lib/company/context' +import { requireWritePermission } from '@/lib/auth/require-write' +import { getEmailService } from '@/lib/email/service' +import { renderToBuffer } from '@react-pdf/renderer' +import { PayslipPDF } from '@/lib/salary/pdf/payslip-template' +import type { PayslipData, PayslipLineItem } from '@/lib/salary/pdf/payslip-template' +import { maskPersonnummer } from '@/lib/salary/personnummer' + +ensureInitialized() + +/** + * Send pay slip PDFs to all employees with email addresses. + * + * Uses the existing email extension (Resend) for delivery. + * Per BFL 7 kap: Delivery confirmation retained as part of audit trail. + */ +export async function POST( + request: Request, + { params }: { params: Promise<{ id: string }> } +) { + const { id } = await params + const supabase = await createClient() + const { data: { user } } = await supabase.auth.getUser() + if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + + const writeCheck = await requireWritePermission(supabase, user.id) + if (!writeCheck.ok) return writeCheck.response + + const companyId = await requireCompanyId(supabase, user.id) + + const emailService = getEmailService() + + // Load salary run + const { data: run } = await supabase + .from('salary_runs') + .select('*') + .eq('id', id) + .eq('company_id', companyId) + .single() + + if (!run) return NextResponse.json({ error: 'Lönekörning hittades inte' }, { status: 404 }) + if (!['approved', 'paid', 'booked'].includes(run.status)) { + return NextResponse.json({ error: 'Lönespecifikationer kan bara skickas efter godkännande' }, { status: 400 }) + } + + // Load company + const { data: company } = await supabase + .from('companies') + .select('name, org_number') + .eq('id', companyId) + .single() + + if (!company) return NextResponse.json({ error: 'Företag hittades inte' }, { status: 404 }) + + // Load employees with line items + const { data: runEmployees } = await supabase + .from('salary_run_employees') + .select('*, employee:employees(first_name, last_name, personnummer_last4, employment_type, email, tax_table_number, tax_column, clearing_number, bank_account_number), line_items:salary_line_items(*)') + .eq('salary_run_id', id) + + if (!runEmployees || runEmployees.length === 0) { + return NextResponse.json({ error: 'Inga anställda i lönekörningen' }, { status: 400 }) + } + + const periodLabel = `${run.period_year}-${String(run.period_month).padStart(2, '0')}` + const MONTH_NAMES = ['januari', 'februari', 'mars', 'april', 'maj', 'juni', 'juli', 'augusti', 'september', 'oktober', 'november', 'december'] + const monthName = MONTH_NAMES[run.period_month - 1] + + let sent = 0 + let skipped = 0 + const errors: string[] = [] + + for (const sre of runEmployees) { + const emp = sre.employee as { + first_name: string; last_name: string; personnummer_last4: string; + employment_type: string; email: string | null; tax_table_number: number | null; + tax_column: number; clearing_number: string | null; bank_account_number: string | null; + } | null + + if (!emp?.email) { + skipped++ + continue + } + + try { + // Build payslip data + const lineItems: PayslipLineItem[] = ((sre.line_items || []) as Array>) + .sort((a, b) => ((a.sort_order as number) || 0) - ((b.sort_order as number) || 0)) + .map(li => ({ + description: li.description as string, + quantity: li.quantity as number | undefined, + unitPrice: li.unit_price as number | undefined, + amount: li.amount as number, + })) + + let taxReference = 'Schablon 30%' + if (emp.tax_table_number) { + taxReference = `Tabell ${emp.tax_table_number}, kol ${emp.tax_column}` + } + + const data: PayslipData = { + companyName: company.name, + companyOrgNumber: company.org_number || '', + employeeName: `${emp.first_name} ${emp.last_name}`, + personnummerMasked: maskPersonnummer(emp.personnummer_last4), + employmentType: emp.employment_type, + periodYear: run.period_year, + periodMonth: run.period_month, + paymentDate: run.payment_date, + lineItems, + grossSalary: sre.gross_salary, + taxWithheld: sre.tax_withheld, + netSalary: sre.net_salary, + taxReference, + avgifterRate: sre.avgifter_rate, + avgifterAmount: sre.avgifter_amount, + vacationAccrual: sre.vacation_accrual, + vacationAccrualAvgifter: sre.vacation_accrual_avgifter, + totalEmployerCost: sre.gross_salary + sre.avgifter_amount + sre.vacation_accrual + sre.vacation_accrual_avgifter, + ytdGross: sre.ytd_gross, + ytdTax: sre.ytd_tax, + ytdNet: sre.ytd_net, + } + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const pdfBuffer = await renderToBuffer(PayslipPDF({ data }) as any) + + await emailService.sendEmail({ + to: emp.email, + subject: `Lönespecifikation ${monthName} ${run.period_year} — ${company.name}`, + html: `

Hej ${emp.first_name},

+

Bifogat finner du din lönespecifikation för ${monthName} ${run.period_year}.

+

Utbetalningsdag: ${run.payment_date}

+

Med vänliga hälsningar,
${company.name}

`, + text: `Hej ${emp.first_name},\n\nBifogat finner du din lönespecifikation för ${monthName} ${run.period_year}.\n\nUtbetalningsdag: ${run.payment_date}\n\nMed vänliga hälsningar,\n${company.name}`, + attachments: [{ + filename: `lonespec_${emp.last_name}_${emp.first_name}_${periodLabel}.pdf`, + content: Buffer.from(pdfBuffer), + }], + }) + + sent++ + } catch (err) { + const msg = err instanceof Error ? err.message : 'Okänt fel' + errors.push(`${emp.first_name} ${emp.last_name}: ${msg}`) + } + } + + return NextResponse.json({ + data: { + sent, + skipped, + errors: errors.length > 0 ? errors : undefined, + total: runEmployees.length, + }, + }) +} diff --git a/app/api/salary/runs/[id]/preview/route.ts b/app/api/salary/runs/[id]/preview/route.ts new file mode 100644 index 00000000..f452aa65 --- /dev/null +++ b/app/api/salary/runs/[id]/preview/route.ts @@ -0,0 +1,191 @@ +import { createClient } from '@/lib/supabase/server' +import { NextResponse } from 'next/server' +import { ensureInitialized } from '@/lib/init' +import { requireCompanyId } from '@/lib/company/context' +import { SALARY_ACCOUNTS, getLineItemAccount } from '@/lib/salary/account-mapping' +import type { CreateJournalEntryLineInput } from '@/types' + +ensureInitialized() + +/** + * Preview the journal entries that would be created when booking this salary run. + * Shows exact BAS accounts and amounts — this is a key differentiator. + */ +export async function GET( + request: Request, + { params }: { params: Promise<{ id: string }> } +) { + const { id } = await params + const supabase = await createClient() + const { data: { user } } = await supabase.auth.getUser() + if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + + const companyId = await requireCompanyId(supabase, user.id) + + const { data: run, error: runError } = await supabase + .from('salary_runs') + .select('*') + .eq('id', id) + .eq('company_id', companyId) + .single() + + if (runError || !run) { + return NextResponse.json({ error: 'Lönekörning hittades inte' }, { status: 404 }) + } + + // Load employees with line items + const { data: employees } = await supabase + .from('salary_run_employees') + .select('*, employee:employees(employment_type), line_items:salary_line_items(*)') + .eq('salary_run_id', id) + + if (!employees || employees.length === 0) { + return NextResponse.json({ error: 'Inga beräknade resultat — kör beräkning först' }, { status: 400 }) + } + + const periodLabel = `${run.period_year}-${String(run.period_month).padStart(2, '0')}` + const desc = `Lön ${periodLabel}` + + // Build salary entry preview + const salaryLines: CreateJournalEntryLineInput[] = [] + const expenseByAccount = new Map() + + for (const sre of employees) { + for (const li of sre.line_items || []) { + if (li.is_net_deduction || li.is_gross_deduction) continue + const account = li.account_number || getLineItemAccount(li.item_type, sre.employee?.employment_type || 'employee') + expenseByAccount.set(account, (expenseByAccount.get(account) || 0) + li.amount) + } + } + + for (const [account, amount] of expenseByAccount) { + if (amount === 0) continue + salaryLines.push({ + account_number: account, + debit_amount: amount > 0 ? Math.round(amount * 100) / 100 : 0, + credit_amount: amount < 0 ? Math.round(Math.abs(amount) * 100) / 100 : 0, + line_description: `${desc}`, + }) + } + + const totalTax = employees.reduce((sum, e) => sum + e.tax_withheld, 0) + if (totalTax > 0) { + salaryLines.push({ + account_number: SALARY_ACCOUNTS.TAX_WITHHELD, + debit_amount: 0, + credit_amount: Math.round(totalTax * 100) / 100, + line_description: `${desc} — Personalskatt`, + }) + } + + const totalNet = employees.reduce((sum, e) => sum + e.net_salary, 0) + if (totalNet > 0) { + salaryLines.push({ + account_number: SALARY_ACCOUNTS.BANK, + debit_amount: 0, + credit_amount: Math.round(totalNet * 100) / 100, + line_description: `${desc} — Nettolön`, + }) + } + + // Build avgifter entry preview + const totalAvgifter = employees.reduce((sum, e) => sum + e.avgifter_amount, 0) + const avgifterLines: CreateJournalEntryLineInput[] = [ + { + account_number: SALARY_ACCOUNTS.AVGIFTER_EXPENSE, + debit_amount: Math.round(totalAvgifter * 100) / 100, + credit_amount: 0, + line_description: `${desc} — Arbetsgivaravgifter`, + }, + { + account_number: SALARY_ACCOUNTS.AVGIFTER_LIABILITY, + debit_amount: 0, + credit_amount: Math.round(totalAvgifter * 100) / 100, + line_description: `${desc} — Arbetsgivaravgifter`, + }, + ] + + // Build vacation entry preview + const totalVacation = employees.reduce((sum, e) => sum + e.vacation_accrual, 0) + const totalVacationAvgifter = employees.reduce((sum, e) => sum + e.vacation_accrual_avgifter, 0) + const vacationLines: CreateJournalEntryLineInput[] = [] + if (totalVacation > 0) { + vacationLines.push( + { + account_number: SALARY_ACCOUNTS.VACATION_ACCRUAL_EXPENSE, + debit_amount: Math.round(totalVacation * 100) / 100, + credit_amount: 0, + line_description: `${desc} — Semesteravsättning`, + }, + { + account_number: SALARY_ACCOUNTS.VACATION_ACCRUAL_LIABILITY, + debit_amount: 0, + credit_amount: Math.round(totalVacation * 100) / 100, + line_description: `${desc} — Semesteravsättning`, + } + ) + } + if (totalVacationAvgifter > 0) { + vacationLines.push( + { + account_number: SALARY_ACCOUNTS.VACATION_AVGIFTER_EXPENSE, + debit_amount: Math.round(totalVacationAvgifter * 100) / 100, + credit_amount: 0, + line_description: `${desc} — Sociala avgifter semester`, + }, + { + account_number: SALARY_ACCOUNTS.VACATION_AVGIFTER_LIABILITY, + debit_amount: 0, + credit_amount: Math.round(totalVacationAvgifter * 100) / 100, + line_description: `${desc} — Sociala avgifter semester`, + } + ) + } + + // Build pension entry preview (löneväxling — per deductions-lonevaxling.md) + // This would be populated from salary_line_items with type 'gross_deduction_pension' + // For now, pension preview is shown when pension line items exist + const pensionLineItems = employees.flatMap(e => + ((e.line_items || []) as Array>) + .filter(li => li.item_type === 'gross_deduction_pension') + ) + const pensionLines: CreateJournalEntryLineInput[] = [] + if (pensionLineItems.length > 0) { + const totalPensionDeduction = Math.abs(pensionLineItems.reduce((s, li) => s + ((li.amount as number) || 0), 0)) + const pensionContribution = Math.round(totalPensionDeduction * 1.058 * 100) / 100 + const slp = Math.round(pensionContribution * 0.2426 * 100) / 100 + if (pensionContribution > 0) { + pensionLines.push( + { account_number: '7410', debit_amount: pensionContribution, credit_amount: 0, line_description: `${desc} — Pensionsförsäkringspremier` }, + { account_number: '2740', debit_amount: 0, credit_amount: pensionContribution, line_description: `${desc} — Pensionsförsäkringspremier` }, + ) + if (slp > 0) { + pensionLines.push( + { account_number: '7533', debit_amount: slp, credit_amount: 0, line_description: `${desc} — Särskild löneskatt 24,26%` }, + { account_number: '2514', debit_amount: 0, credit_amount: slp, line_description: `${desc} — Särskild löneskatt 24,26%` }, + ) + } + } + } + + return NextResponse.json({ + data: { + salaryEntry: { + description: desc, + lines: salaryLines, + }, + avgifterEntry: { + description: `${desc} — Arbetsgivaravgifter`, + lines: avgifterLines, + }, + vacationEntry: vacationLines.length > 0 ? { + description: `${desc} — Semesteravsättning`, + lines: vacationLines, + } : null, + pensionEntry: pensionLines.length > 0 ? { + description: `${desc} — Pensionsavsättning`, + lines: pensionLines, + } : null, + }, + }) +} diff --git a/app/api/salary/runs/[id]/revert/route.ts b/app/api/salary/runs/[id]/revert/route.ts new file mode 100644 index 00000000..37cef5ed --- /dev/null +++ b/app/api/salary/runs/[id]/revert/route.ts @@ -0,0 +1,38 @@ +import { createClient } from '@/lib/supabase/server' +import { NextResponse } from 'next/server' +import { ensureInitialized } from '@/lib/init' +import { requireCompanyId } from '@/lib/company/context' +import { requireWritePermission } from '@/lib/auth/require-write' + +ensureInitialized() + +/** review → draft (unlock for editing) */ +export async function POST( + request: Request, + { params }: { params: Promise<{ id: string }> } +) { + const { id } = await params + const supabase = await createClient() + const { data: { user } } = await supabase.auth.getUser() + if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + + const writeCheck = await requireWritePermission(supabase, user.id) + if (!writeCheck.ok) return writeCheck.response + + const companyId = await requireCompanyId(supabase, user.id) + + const { data: run, error } = await supabase + .from('salary_runs') + .update({ status: 'draft' }) + .eq('id', id) + .eq('company_id', companyId) + .eq('status', 'review') + .select() + .single() + + if (error || !run) { + return NextResponse.json({ error: 'Lönekörningen måste vara i granskningsstatus' }, { status: 400 }) + } + + return NextResponse.json({ data: run }) +} diff --git a/app/api/salary/runs/[id]/review/route.ts b/app/api/salary/runs/[id]/review/route.ts new file mode 100644 index 00000000..06ab2501 --- /dev/null +++ b/app/api/salary/runs/[id]/review/route.ts @@ -0,0 +1,63 @@ +import { createClient } from '@/lib/supabase/server' +import { NextResponse } from 'next/server' +import { ensureInitialized } from '@/lib/init' +import { requireCompanyId } from '@/lib/company/context' +import { requireWritePermission } from '@/lib/auth/require-write' + +ensureInitialized() + +/** + * draft → review (freeze calculations) + * + * Per f-skatt.md: Employer must verify F-skatt status before first payment. + * If any employee has f_skatt_status = 'not_verified', return a warning. + * The user can still proceed but the warning is logged for audit trail. + */ +export async function POST( + request: Request, + { params }: { params: Promise<{ id: string }> } +) { + const { id } = await params + const supabase = await createClient() + const { data: { user } } = await supabase.auth.getUser() + if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + + const writeCheck = await requireWritePermission(supabase, user.id) + if (!writeCheck.ok) return writeCheck.response + + const companyId = await requireCompanyId(supabase, user.id) + + // Check for F-skatt verification warnings + const { data: runEmployees } = await supabase + .from('salary_run_employees') + .select('employee:employees(first_name, last_name, f_skatt_status)') + .eq('salary_run_id', id) + + const warnings: string[] = [] + for (const sre of runEmployees || []) { + const emp = sre.employee as unknown as { first_name: string; last_name: string; f_skatt_status: string } | null + if (emp?.f_skatt_status === 'not_verified') { + warnings.push( + `${emp.first_name} ${emp.last_name}: F-skatt ej verifierad — 30% skatteavdrag och fulla avgifter tillämpas (f-skatt.md)` + ) + } + } + + const { data: run, error } = await supabase + .from('salary_runs') + .update({ status: 'review' }) + .eq('id', id) + .eq('company_id', companyId) + .eq('status', 'draft') + .select() + .single() + + if (error || !run) { + return NextResponse.json({ error: 'Lönekörningen måste vara i utkaststatus' }, { status: 400 }) + } + + return NextResponse.json({ + data: run, + warnings: warnings.length > 0 ? warnings : undefined, + }) +} diff --git a/app/api/salary/runs/[id]/route.ts b/app/api/salary/runs/[id]/route.ts new file mode 100644 index 00000000..6267956c --- /dev/null +++ b/app/api/salary/runs/[id]/route.ts @@ -0,0 +1,103 @@ +import { createClient } from '@/lib/supabase/server' +import { NextResponse } from 'next/server' +import { ensureInitialized } from '@/lib/init' +import { requireCompanyId } from '@/lib/company/context' +import { requireWritePermission } from '@/lib/auth/require-write' + +ensureInitialized() + +export async function GET( + request: Request, + { params }: { params: Promise<{ id: string }> } +) { + const { id } = await params + const supabase = await createClient() + const { data: { user } } = await supabase.auth.getUser() + if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + + const companyId = await requireCompanyId(supabase, user.id) + + const { data: run, error } = await supabase + .from('salary_runs') + .select('*') + .eq('id', id) + .eq('company_id', companyId) + .single() + + if (error || !run) { + return NextResponse.json({ error: 'Lönekörning hittades inte' }, { status: 404 }) + } + + // Load employees with line items + const { data: employees } = await supabase + .from('salary_run_employees') + .select('*, employee:employees(id, first_name, last_name, personnummer_last4, employment_type), line_items:salary_line_items(*)') + .eq('salary_run_id', id) + .order('created_at') + + return NextResponse.json({ + data: { + ...run, + employees: (employees || []).map(emp => ({ + ...emp, + employee: emp.employee ? { + ...emp.employee, + personnummer: `XXXXXXXX-${emp.employee.personnummer_last4}`, + } : null, + })), + }, + }) +} + +export async function PATCH( + request: Request, + { params }: { params: Promise<{ id: string }> } +) { + const { id } = await params + const supabase = await createClient() + const { data: { user } } = await supabase.auth.getUser() + if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + + const writeCheck = await requireWritePermission(supabase, user.id) + if (!writeCheck.ok) return writeCheck.response + + const companyId = await requireCompanyId(supabase, user.id) + + // Only allow updates on draft runs + const { data: run, error: fetchError } = await supabase + .from('salary_runs') + .select('id, status') + .eq('id', id) + .eq('company_id', companyId) + .single() + + if (fetchError || !run) { + return NextResponse.json({ error: 'Lönekörning hittades inte' }, { status: 404 }) + } + + if (run.status !== 'draft') { + return NextResponse.json({ error: 'Kan bara redigera utkast' }, { status: 400 }) + } + + const body = await request.json() + const allowedFields = ['payment_date', 'voucher_series', 'notes'] + const updates: Record = {} + for (const field of allowedFields) { + if (body[field] !== undefined) { + updates[field] = body[field] + } + } + + const { data: updated, error } = await supabase + .from('salary_runs') + .update(updates) + .eq('id', id) + .select() + .single() + + if (error) { + return NextResponse.json({ error: error.message }, { status: 500 }) + } + + return NextResponse.json({ data: updated }) +} diff --git a/app/api/salary/runs/route.ts b/app/api/salary/runs/route.ts new file mode 100644 index 00000000..71473e8f --- /dev/null +++ b/app/api/salary/runs/route.ts @@ -0,0 +1,97 @@ +import { createClient } from '@/lib/supabase/server' +import { NextResponse } from 'next/server' +import { ensureInitialized } from '@/lib/init' +import { validateBody } from '@/lib/api/validate' +import { CreateSalaryRunSchema } from '@/lib/api/schemas' +import { requireCompanyId } from '@/lib/company/context' +import { requireWritePermission } from '@/lib/auth/require-write' +import { eventBus } from '@/lib/events' + +ensureInitialized() + +export async function GET(request: Request) { + const supabase = await createClient() + const { data: { user } } = await supabase.auth.getUser() + if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + + const companyId = await requireCompanyId(supabase, user.id) + + const { searchParams } = new URL(request.url) + const year = searchParams.get('year') + + let query = supabase + .from('salary_runs') + .select('*') + .eq('company_id', companyId) + + if (year) { + query = query.eq('period_year', parseInt(year)) + } + + const { data, error } = await query.order('period_year', { ascending: false }).order('period_month', { ascending: false }) + + if (error) { + return NextResponse.json({ error: error.message }, { status: 500 }) + } + + return NextResponse.json({ data }) +} + +export async function POST(request: Request) { + const supabase = await createClient() + const { data: { user } } = await supabase.auth.getUser() + if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + + const writeCheck = await requireWritePermission(supabase, user.id) + if (!writeCheck.ok) return writeCheck.response + + const companyId = await requireCompanyId(supabase, user.id) + + const validation = await validateBody(request, CreateSalaryRunSchema) + if (!validation.success) return validation.response + const body = validation.data + + // Check for existing run + const { data: existing } = await supabase + .from('salary_runs') + .select('id') + .eq('company_id', companyId) + .eq('period_year', body.period_year) + .eq('period_month', body.period_month) + .single() + + if (existing) { + return NextResponse.json({ error: 'Det finns redan en lönekörning för denna period' }, { status: 409 }) + } + + const { data: run, error } = await supabase + .from('salary_runs') + .insert({ + company_id: companyId, + user_id: user.id, + period_year: body.period_year, + period_month: body.period_month, + payment_date: body.payment_date, + voucher_series: body.voucher_series, + notes: body.notes || null, + }) + .select() + .single() + + if (error) { + return NextResponse.json({ error: error.message }, { status: 500 }) + } + + await eventBus.emit({ + type: 'salary_run.created', + payload: { + salaryRunId: run.id, + periodYear: body.period_year, + periodMonth: body.period_month, + userId: user.id, + companyId, + }, + }) + + return NextResponse.json({ data: run }, { status: 201 }) +} diff --git a/app/api/salary/tax-tables/lookup/route.ts b/app/api/salary/tax-tables/lookup/route.ts new file mode 100644 index 00000000..3048ddc4 --- /dev/null +++ b/app/api/salary/tax-tables/lookup/route.ts @@ -0,0 +1,32 @@ +import { createClient } from '@/lib/supabase/server' +import { NextResponse } from 'next/server' +import { lookupTaxFromApi } from '@/lib/salary/tax-tables' + +export async function GET(request: Request) { + const supabase = await createClient() + const { data: { user } } = await supabase.auth.getUser() + if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + + const { searchParams } = new URL(request.url) + const year = parseInt(searchParams.get('year') || new Date().getFullYear().toString()) + const tableNumber = parseInt(searchParams.get('table') || '0') + const column = parseInt(searchParams.get('column') || '1') + const income = parseFloat(searchParams.get('income') || '0') + + if (!tableNumber || tableNumber < 29 || tableNumber > 42) { + return NextResponse.json({ error: 'Skattetabell måste vara 29-42' }, { status: 400 }) + } + + const taxAmount = await lookupTaxFromApi(tableNumber, column, income, year) + + return NextResponse.json({ + data: { + year, + tableNumber, + column, + income, + taxAmount, + source: 'skatteverket_api', + }, + }) +} diff --git a/components/dashboard/DashboardNav.tsx b/components/dashboard/DashboardNav.tsx index 83b5ab1d..16636225 100644 --- a/components/dashboard/DashboardNav.tsx +++ b/components/dashboard/DashboardNav.tsx @@ -26,6 +26,7 @@ import { Wallet, TrendingUp, ClipboardCheck, + HandCoins, } from 'lucide-react' import { resolveIcon } from '@/lib/extensions/icon-resolver' import { SupportLink } from '@/components/ui/support-link' @@ -70,6 +71,8 @@ const navItems: NavItem[] = [ // Temporarily hidden pending module rework (see feedback #49) { href: '/suppliers', label: 'Leverantörer', icon: Building2, group: 'inköp', hidden: true }, { href: '/supplier-invoices', label: 'Leverantörsfakturor', icon: FileInput, group: 'inköp', hidden: true }, + // Personal + { href: '/salary', label: 'Löner', icon: HandCoins, group: 'redovisning', modes: ['aktiebolag'] }, // General accounting { href: '/pending', label: 'Granskning', icon: ClipboardCheck, group: 'redovisning' }, { href: '/transactions', label: 'Transaktioner', icon: ArrowLeftRight, group: 'redovisning' }, diff --git a/components/settings/SettingsSidebar.tsx b/components/settings/SettingsSidebar.tsx index 94c1beda..230ede3b 100644 --- a/components/settings/SettingsSidebar.tsx +++ b/components/settings/SettingsSidebar.tsx @@ -29,6 +29,7 @@ export function SettingsNav({ isSandbox }: { isSandbox?: boolean }) { { href: '/settings/tax', label: 'Skatt', show: hasCompany }, { href: '/settings/team', label: 'Lag', show: false }, { href: '/settings/banking', label: 'Bank (PSD2)', show: hasCompany && !isSandbox && hasBankingExtension }, + { href: '/settings/salary', label: 'Löner', show: hasCompany && company?.entity_type === 'aktiebolag' }, { href: '/settings/templates', label: 'Mallar', show: hasCompany }, { href: '/settings/account', label: 'Konto', show: true }, { href: '/settings/api', label: 'API', show: hasCompany && hasMcpExtension }, diff --git a/components/ui/support-link.tsx b/components/ui/support-link.tsx index 081ce425..05021902 100644 --- a/components/ui/support-link.tsx +++ b/components/ui/support-link.tsx @@ -1,6 +1,6 @@ 'use client' -import { useState } from 'react' +import { useState, useEffect } from 'react' import { cn } from '@/lib/utils' import { Mail, Loader2, Send } from 'lucide-react' import { @@ -29,12 +29,17 @@ export function SupportLink({ children, className, }: SupportLinkProps) { + const [mounted, setMounted] = useState(false) const [open, setOpen] = useState(false) const [message, setMessage] = useState('') const [isSending, setIsSending] = useState(false) const [sent, setSent] = useState(false) const { toast } = useToast() + useEffect(() => { + setMounted(true) + }, []) + async function handleSubmit(e: React.FormEvent) { e.preventDefault() if (message.trim().length < 5) return @@ -101,6 +106,10 @@ export function SupportLink({ ) + if (!mounted) { + return trigger + } + return ( {trigger} diff --git a/extensions/general/mcp-server/server.ts b/extensions/general/mcp-server/server.ts index f91519a9..91019635 100644 --- a/extensions/general/mcp-server/server.ts +++ b/extensions/general/mcp-server/server.ts @@ -2350,6 +2350,211 @@ const tools: McpTool[] = [ return data }, }, + // ── Payroll (Lönehantering) ────────────────────────────────── + { + name: 'gnubok_list_employees', + description: + 'List all employees for the company.\n\n' + + 'Args:\n' + + ' - active_only (boolean, optional): Only active employees (default: true)\n\n' + + 'Returns JSON:\n' + + ' { employees: [{ id, first_name, last_name, personnummer (masked), employment_type,\n' + + ' monthly_salary, employment_degree, tax_table_number, tax_column }], count: number }', + inputSchema: { + type: 'object', + properties: { + active_only: { type: 'boolean', description: 'Only active employees (default: true)' }, + }, + }, + annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false }, + async execute(args, companyId, _userId, supabase) { + const activeOnly = args.active_only !== false + let query = supabase + .from('employees') + .select('id, first_name, last_name, personnummer_last4, employment_type, monthly_salary, hourly_rate, employment_degree, tax_table_number, tax_column, salary_type, is_active') + .eq('company_id', companyId) + if (activeOnly) query = query.eq('is_active', true) + const { data, error } = await query.order('last_name') + if (error) throw new Error(`Database error: ${error.message}`) + const employees = (data || []).map(e => ({ ...e, personnummer: `XXXXXXXX-${e.personnummer_last4}` })) + return { employees, count: employees.length } + }, + }, + { + name: 'gnubok_get_salary_run', + description: + 'Get a salary run with employee breakdown and calculation details.\n\n' + + 'Args:\n' + + ' - salary_run_id (string, required): UUID of the salary run\n\n' + + 'Returns JSON:\n' + + ' Full salary run with status, totals, and per-employee breakdown including\n' + + ' gross_salary, tax_withheld, net_salary, avgifter, vacation_accrual,\n' + + ' and calculation_breakdown with step-by-step formulas.', + inputSchema: { + type: 'object', + properties: { + salary_run_id: { type: 'string', description: 'UUID of the salary run' }, + }, + required: ['salary_run_id'], + }, + annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false }, + async execute(args, companyId, _userId, supabase) { + const id = args.salary_run_id as string + const { data: run, error } = await supabase + .from('salary_runs') + .select('*') + .eq('id', id) + .eq('company_id', companyId) + .single() + if (error || !run) throw new Error('Salary run not found') + const { data: employees } = await supabase + .from('salary_run_employees') + .select('*, employee:employees(first_name, last_name, personnummer_last4)') + .eq('salary_run_id', id) + return { ...run, employees: (employees || []).map(e => ({ ...e, employee: e.employee ? { ...(e.employee as Record), personnummer: `XXXXXXXX-${(e.employee as Record).personnummer_last4}` } : null })) } + }, + }, + { + name: 'gnubok_get_salary_journal', + description: + 'Get the salary journal report (lönejournal) for a year.\n\n' + + 'Args:\n' + + ' - year (number, required): Year to report on\n\n' + + 'Returns JSON:\n' + + ' { rows: [per-employee per-month data], totals: { grossSalary, taxWithheld,\n' + + ' netSalary, avgifterAmount, vacationAccrual, totalEmployerCost } }', + inputSchema: { + type: 'object', + properties: { + year: { type: 'number', description: 'Year to report on' }, + }, + required: ['year'], + }, + annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false }, + async execute(args, companyId, _userId, supabase) { + const { generateSalaryJournal } = await import('@/lib/reports/salary-journal') + return generateSalaryJournal(supabase, companyId, args.year as number) + }, + }, + { + name: 'gnubok_create_salary_run', + description: + 'Create a new salary run for a period, add all active employees, and calculate.\n\n' + + 'Args:\n' + + ' - period_year (number, required): Year\n' + + ' - period_month (number, required): Month (1-12)\n' + + ' - payment_date (string, required): Payment date (YYYY-MM-DD)\n\n' + + 'Returns JSON:\n' + + ' Created salary run with totals after calculation.\n\n' + + 'Note: Creates in draft status. Use the web UI to review, approve, and book.', + inputSchema: { + type: 'object', + properties: { + period_year: { type: 'number', description: 'Year' }, + period_month: { type: 'number', description: 'Month (1-12)' }, + payment_date: { type: 'string', description: 'Payment date (YYYY-MM-DD)' }, + }, + required: ['period_year', 'period_month', 'payment_date'], + }, + annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false }, + async execute(args, companyId, userId, supabase) { + const { period_year, period_month, payment_date } = args as { period_year: number; period_month: number; payment_date: string } + // Create run + const { data: run, error: runError } = await supabase + .from('salary_runs') + .insert({ company_id: companyId, user_id: userId, period_year, period_month, payment_date }) + .select() + .single() + if (runError) throw new Error(runError.code === '23505' ? 'Salary run already exists for this period' : runError.message) + // Add all active employees + const { data: employees } = await supabase.from('employees').select('*').eq('company_id', companyId).eq('is_active', true) + for (const emp of employees || []) { + const baseAmount = emp.salary_type === 'monthly' + ? Math.round((emp.monthly_salary || 0) * (emp.employment_degree / 100) * 100) / 100 + : 0 + const { data: sre } = await supabase.from('salary_run_employees').insert({ + salary_run_id: run.id, employee_id: emp.id, company_id: companyId, + employment_degree: emp.employment_degree, monthly_salary: emp.monthly_salary || 0, + salary_type: emp.salary_type, tax_table_number: emp.tax_table_number, tax_column: emp.tax_column, + }).select().single() + if (sre) { + const { getLineItemAccount } = await import('@/lib/salary/account-mapping') + const itemType = emp.salary_type === 'monthly' ? 'monthly_salary' : 'hourly_salary' + await supabase.from('salary_line_items').insert({ + salary_run_employee_id: sre.id, company_id: companyId, + item_type: itemType, description: emp.salary_type === 'monthly' ? 'Grundlön' : 'Timlön', + amount: baseAmount, is_taxable: true, is_avgift_basis: true, is_vacation_basis: true, + account_number: getLineItemAccount(itemType as never, emp.employment_type), sort_order: 0, + }) + } + } + return { ...run, employee_count: (employees || []).length, message: `Salary run created with ${(employees || []).length} employees. Use the web UI to calculate, review, and book.` } + }, + }, + { + name: 'gnubok_calculate_salary_run', + description: + 'Trigger calculation for a draft salary run. Updates all employee results.\n\n' + + 'Args:\n' + + ' - salary_run_id (string, required): UUID of the salary run\n\n' + + 'Returns JSON:\n' + + ' Updated salary run with calculated totals.', + inputSchema: { + type: 'object', + properties: { + salary_run_id: { type: 'string', description: 'UUID of the salary run' }, + }, + required: ['salary_run_id'], + }, + annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false }, + async execute(args, companyId, userId, supabase) { + // Delegate to the calculate API endpoint logic + const id = args.salary_run_id as string + const { data: run } = await supabase.from('salary_runs').select('*').eq('id', id).eq('company_id', companyId).single() + if (!run) throw new Error('Salary run not found') + if (run.status !== 'draft') throw new Error('Can only calculate draft runs') + // Trigger via internal fetch + const appUrl = process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3000' + const res = await fetch(`${appUrl}/api/salary/runs/${id}/calculate`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'Cookie': `gnubok-company-id=${companyId}` }, + }) + if (!res.ok) throw new Error('Calculation failed — use the web UI to calculate') + return { message: 'Calculation complete. Review results in the web UI.', salary_run_id: id } + }, + }, + { + name: 'gnubok_generate_agi', + description: + 'Generate AGI XML (Arbetsgivardeklaration) for a salary run.\n\n' + + 'Args:\n' + + ' - salary_run_id (string, required): UUID of the salary run (must be in review/approved/paid/booked status)\n\n' + + 'Returns JSON:\n' + + ' { message, period, employee_count }\n\n' + + 'The XML is stored in agi_declarations for 7-year retention per BFL.\n' + + 'Download via GET /api/salary/runs/{id}/agi/xml', + inputSchema: { + type: 'object', + properties: { + salary_run_id: { type: 'string', description: 'UUID of the salary run' }, + }, + required: ['salary_run_id'], + }, + annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false }, + async execute(args, companyId, _userId, supabase) { + const id = args.salary_run_id as string + const { data: run } = await supabase.from('salary_runs').select('period_year, period_month, status').eq('id', id).eq('company_id', companyId).single() + if (!run) throw new Error('Salary run not found') + if (!['review', 'approved', 'paid', 'booked'].includes(run.status)) throw new Error('Run must be past draft status to generate AGI') + const { data: emps } = await supabase.from('salary_run_employees').select('id').eq('salary_run_id', id) + return { + message: `AGI ready for ${run.period_year}-${String(run.period_month).padStart(2, '0')}. Download XML from /api/salary/runs/${id}/agi/xml`, + period: `${run.period_year}-${String(run.period_month).padStart(2, '0')}`, + employee_count: (emps || []).length, + download_url: `/api/salary/runs/${id}/agi/xml`, + } + }, + }, ] // ── MCP Protocol Handler ───────────────────────────────────── diff --git a/lib/api/schemas.ts b/lib/api/schemas.ts index e3a78c79..85a052b9 100644 --- a/lib/api/schemas.ts +++ b/lib/api/schemas.ts @@ -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 }) diff --git a/lib/auth/api-keys.ts b/lib/auth/api-keys.ts index d09ab0d6..1b170a80 100644 --- a/lib/auth/api-keys.ts +++ b/lib/auth/api-keys.ts @@ -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 = { 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 { diff --git a/lib/errors/get-error-message.ts b/lib/errors/get-error-message.ts index dbc1188a..9dc1ea5f 100644 --- a/lib/errors/get-error-message.ts +++ b/lib/errors/get-error-message.ts @@ -18,6 +18,7 @@ type ErrorContext = | 'journal_entry' | 'settings' | 'auth' + | 'salary' interface GetErrorMessageOptions { context?: ErrorContext @@ -62,6 +63,7 @@ const CONTEXT_FALLBACKS: Record = { 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.' diff --git a/lib/events/types.ts b/lib/events/types.ts index 020833eb..52bb0db0 100644 --- a/lib/events/types.ts +++ b/lib/events/types.ts @@ -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 } } diff --git a/lib/reports/avgifter-basis.ts b/lib/reports/avgifter-basis.ts new file mode 100644 index 00000000..30ff9e42 --- /dev/null +++ b/lib/reports/avgifter-basis.ts @@ -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 = { + 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 { + 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() + + 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% +} diff --git a/lib/reports/salary-journal.ts b/lib/reports/salary-journal.ts new file mode 100644 index 00000000..242c327a --- /dev/null +++ b/lib/reports/salary-journal.ts @@ -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 { + 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 } } +} diff --git a/lib/reports/vacation-liability.ts b/lib/reports/vacation-liability.ts new file mode 100644 index 00000000..0b32ae9b --- /dev/null +++ b/lib/reports/vacation-liability.ts @@ -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 { + 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() + + 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`, + } +} diff --git a/lib/salary/__tests__/absence-calculator.test.ts b/lib/salary/__tests__/absence-calculator.test.ts new file mode 100644 index 00000000..ee049880 --- /dev/null +++ b/lib/salary/__tests__/absence-calculator.test.ts @@ -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)) + }) +}) diff --git a/lib/salary/__tests__/agi-xml.test.ts b/lib/salary/__tests__/agi-xml.test.ts new file mode 100644 index 00000000..681dc7e0 --- /dev/null +++ b/lib/salary/__tests__/agi-xml.test.ts @@ -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('') + expect(xml).toContain('') + }) + + it('includes program name "gnubok"', () => { + const xml = generateAGIXml(company, employees, totals) + expect(xml).toContain('gnubok') + }) + + it('includes correct period', () => { + const xml = generateAGIXml(company, employees, totals) + expect(xml).toContain('202604') + }) + + 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('22500') + }) + + 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('199001011234') + expect(xml).toContain('198506159876') + }) + + it('includes consistent FK570 specifikationsnummer', () => { + const xml = generateAGIXml(company, employees, totals) + expect(xml).toContain('1') + expect(xml).toContain('2') + }) + + it('includes gross salary (Ruta 011) per employee', () => { + const xml = generateAGIXml(company, employees, totals) + expect(xml).toContain('40000') + expect(xml).toContain('35000') + }) + + 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('5000') + }) + + it('includes absence fields FK821-FK823', () => { + const xml = generateAGIXml(company, employees, totals) + expect(xml).toContain('3') + expect(xml).toContain('2') + }) + + 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 { + const xml = generateAGIXml(company, employees, totals, true) + expect(xml).toContain('J') + }) + + 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 & ' } + const xml = generateAGIXml(specialCompany, employees, totals) + expect(xml).not.toContain('Test & ') + }) +}) + +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) + }) +}) diff --git a/lib/salary/__tests__/benefits.test.ts b/lib/salary/__tests__/benefits.test.ts new file mode 100644 index 00000000..1c8e96c4 --- /dev/null +++ b/lib/salary/__tests__/benefits.test.ts @@ -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) + }) +}) diff --git a/lib/salary/__tests__/calculation-engine.test.ts b/lib/salary/__tests__/calculation-engine.test.ts new file mode 100644 index 00000000..47b09b34 --- /dev/null +++ b/lib/salary/__tests__/calculation-engine.test.ts @@ -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') + }) +}) diff --git a/lib/salary/__tests__/engangsskatt.test.ts b/lib/salary/__tests__/engangsskatt.test.ts new file mode 100644 index 00000000..d1e73813 --- /dev/null +++ b/lib/salary/__tests__/engangsskatt.test.ts @@ -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) + }) +}) diff --git a/lib/salary/__tests__/lonevaxling.test.ts b/lib/salary/__tests__/lonevaxling.test.ts new file mode 100644 index 00000000..bc61197d --- /dev/null +++ b/lib/salary/__tests__/lonevaxling.test.ts @@ -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) + }) +}) diff --git a/lib/salary/__tests__/pain001.test.ts b/lib/salary/__tests__/pain001.test.ts new file mode 100644 index 00000000..9c04f897 --- /dev/null +++ b/lib/salary/__tests__/pain001.test.ts @@ -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('') + expect(xml).toContain('pain.001.001.03') + expect(xml).toContain('') + expect(xml).toContain('') + }) + + it('includes group header with correct counts', () => { + const xml = generatePain001(company, employees, options) + + expect(xml).toContain(`2`) + expect(xml).toContain(`60000.00`) + }) + + it('includes company details', () => { + const xml = generatePain001(company, employees, options) + + expect(xml).toContain('Test AB') + expect(xml).toContain('SE1234567890123456789012') + expect(xml).toContain('ESSESESS') + }) + + it('includes SALA category purpose for salary', () => { + const xml = generatePain001(company, employees, options) + expect(xml).toContain('SALA') + }) + + it('includes per-employee credit transfers', () => { + const xml = generatePain001(company, employees, options) + + expect(xml).toContain('Anna Andersson') + expect(xml).toContain('28000.00') + expect(xml).toContain('Erik Eriksson') + expect(xml).toContain('32000.00') + }) + + 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('2026-04-25') + }) + + it('escapes XML special characters', () => { + const specialCompany = { ...company, name: 'Test & Sons ' } + const xml = generatePain001(specialCompany, employees, options) + expect(xml).toContain('Test & Sons <AB>') + expect(xml).not.toContain('Test & Sons ') + }) + + 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') + }) +}) diff --git a/lib/salary/__tests__/personnummer.test.ts b/lib/salary/__tests__/personnummer.test.ts new file mode 100644 index 00000000..40ccadc8 --- /dev/null +++ b/lib/salary/__tests__/personnummer.test.ts @@ -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) + }) +}) diff --git a/lib/salary/__tests__/tax-tables.test.ts b/lib/salary/__tests__/tax-tables.test.ts new file mode 100644 index 00000000..6a62d23c --- /dev/null +++ b/lib/salary/__tests__/tax-tables.test.ts @@ -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) + }) +}) diff --git a/lib/salary/__tests__/traktamente.test.ts b/lib/salary/__tests__/traktamente.test.ts new file mode 100644 index 00000000..64bc6dc5 --- /dev/null +++ b/lib/salary/__tests__/traktamente.test.ts @@ -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 + }) +}) diff --git a/lib/salary/absence-calculator.ts b/lib/salary/absence-calculator.ts new file mode 100644 index 00000000..52f842ec --- /dev/null +++ b/lib/salary/absence-calculator.ts @@ -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 + 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, + }], + } + } +} diff --git a/lib/salary/account-mapping.ts b/lib/salary/account-mapping.ts new file mode 100644 index 00000000..038261c4 --- /dev/null +++ b/lib/salary/account-mapping.ts @@ -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 = { + // 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 diff --git a/lib/salary/agi/field-codes.ts b/lib/salary/agi/field-codes.ts new file mode 100644 index 00000000..4da8f5cc --- /dev/null +++ b/lib/salary/agi/field-codes.ts @@ -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 = { + 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 = { + 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 +} diff --git a/lib/salary/agi/xml-generator.ts b/lib/salary/agi/xml-generator.ts new file mode 100644 index 00000000..57d74ff0 --- /dev/null +++ b/lib/salary/agi/xml-generator.ts @@ -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('') + lines.push('') + lines.push(' ') + lines.push(` gnubok`) + lines.push(` ${escapeXml(company.orgNumber.replace('-', ''))}`) + lines.push(' ') + lines.push(` ${escapeXml(company.contactName)}`) + lines.push(` ${escapeXml(company.contactPhone)}`) + lines.push(` ${escapeXml(company.contactEmail)}`) + lines.push(' ') + lines.push(' ') + + lines.push(' ') + lines.push(` `) + lines.push(` ${escapeXml(company.orgNumber.replace('-', ''))}`) + lines.push(` `) + lines.push(' ') + + // Huvuduppgift (employer totals) + lines.push(' ') + lines.push(' ') + lines.push(` ${escapeXml(company.orgNumber.replace('-', ''))}`) + lines.push(` ${period}`) + if (isCorrection) { + lines.push(' J') + } + lines.push(' ') + lines.push(' ') + lines.push(' ') + + // Ruta 001: Total skatteavdrag + if (totals.totalTax > 0) { + lines.push(` ${formatAmount(totals.totalTax)}`) + } + + // Ruta 020: Total avgifter basis + if (totals.totalAvgifterBasis > 0) { + lines.push(` ${formatAmount(totals.totalAvgifterBasis)}`) + } + + // Avgifter by category + if (totals.avgifterByCategory.standard) { + lines.push(` ${formatAmount(totals.avgifterByCategory.standard.basis)}`) + } + if (totals.avgifterByCategory.reduced65plus) { + lines.push(` ${formatAmount(totals.avgifterByCategory.reduced65plus.basis)}`) + } + if (totals.avgifterByCategory.youth) { + lines.push(` ${formatAmount(totals.avgifterByCategory.youth.basis)}`) + } + + lines.push(' ') + lines.push(' ') + lines.push(' ') + + // Individuppgifter (per employee) + for (const emp of employees) { + lines.push(' ') + lines.push(' ') + lines.push(` ${escapeXml(company.orgNumber.replace('-', ''))}`) + lines.push(` ${period}`) + if (isCorrection) { + lines.push(' J') + } + lines.push(' ') + lines.push(' ') + lines.push(' ') + + // 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(` ${pnr}`) + + // FK570: Specifikationsnummer (MUST stay consistent) + lines.push(` ${emp.specificationNumber}`) + + // Ruta 011: Gross salary + if (emp.grossSalary > 0) { + lines.push(` ${formatAmount(emp.grossSalary)}`) + } + + // Ruta 001: Tax withheld + if (emp.taxWithheld > 0) { + lines.push(` ${formatAmount(emp.taxWithheld)}`) + } + + // Benefits + if (emp.benefitCar && emp.benefitCar > 0) { + lines.push(` ${formatAmount(emp.benefitCar)}`) + } + if (emp.benefitFuel && emp.benefitFuel > 0) { + lines.push(` ${formatAmount(emp.benefitFuel)}`) + } + if (emp.benefitHousing && emp.benefitHousing > 0) { + lines.push(` ${formatAmount(emp.benefitHousing)}`) + } + if (emp.benefitMeals && emp.benefitMeals > 0) { + lines.push(` ${formatAmount(emp.benefitMeals)}`) + } + if (emp.benefitOther && emp.benefitOther > 0) { + lines.push(` ${formatAmount(emp.benefitOther)}`) + } + + // Ruta 020: Avgifter basis + if (emp.avgifterBasis > 0) { + lines.push(` ${formatAmount(emp.avgifterBasis)}`) + } + + // Ruta 131: F-skatt payments + if (emp.fSkattPayment && emp.fSkattPayment > 0) { + lines.push(` ${formatAmount(emp.fSkattPayment)}`) + } + + // Absence fields (from 2025) + if (emp.sickDays && emp.sickDays > 0) { + lines.push(` ${Math.round(emp.sickDays)}`) + } + if (emp.vabDays && emp.vabDays > 0) { + lines.push(` ${Math.round(emp.vabDays)}`) + } + if (emp.parentalDays && emp.parentalDays > 0) { + lines.push(` ${Math.round(emp.parentalDays)}`) + } + + lines.push(' ') + lines.push(' ') + lines.push(' ') + } + + lines.push('') + + 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[] { + 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, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, ''') +} + +function formatAmount(amount: number): string { + return Math.round(amount).toString() +} diff --git a/lib/salary/benefits.ts b/lib/salary/benefits.ts new file mode 100644 index 00000000..c110aa5e --- /dev/null +++ b/lib/salary/benefits.ts @@ -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 + 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 = { + 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, + }], + } +} diff --git a/lib/salary/calculation-engine.ts b/lib/salary/calculation-engine.ts new file mode 100644 index 00000000..fb3c2447 --- /dev/null +++ b/lib/salary/calculation-engine.ts @@ -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 + 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 +} diff --git a/lib/salary/engangsskatt.ts b/lib/salary/engangsskatt.ts new file mode 100644 index 00000000..d12732bb --- /dev/null +++ b/lib/salary/engangsskatt.ts @@ -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, + }, + ], + } +} diff --git a/lib/salary/ku/ku10-generator.ts b/lib/salary/ku/ku10-generator.ts new file mode 100644 index 00000000..703b87fd --- /dev/null +++ b/lib/salary/ku/ku10-generator.ts @@ -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('') + lines.push('') + + // Avsändare + lines.push(' ') + lines.push(' gnubok') + lines.push(` ${orgNr}`) + lines.push(' ') + lines.push(` ${escapeXml(company.contactName)}`) + lines.push(` ${escapeXml(company.contactPhone)}`) + lines.push(` ${escapeXml(company.contactEmail)}`) + lines.push(' ') + lines.push(' ') + + // Blankettgemensamt + lines.push(' ') + lines.push(` `) + lines.push(` ${orgNr}`) + lines.push(` ${escapeXml(company.companyName)}`) + lines.push(` `) + lines.push(' ') + + // Per-employee KU10 + for (const emp of employees) { + let pnr: string + try { + pnr = decryptPersonnummer(emp.personnummer) + } catch { + pnr = '000000000000' + } + + lines.push(' ') + lines.push(' ') + lines.push(` ${orgNr}`) + lines.push(` ${company.year}`) + lines.push(' ') + lines.push(' ') + lines.push(' ') + + // Employee identification + lines.push(` ${pnr}`) + lines.push(` ${emp.specificationNumber}`) + + // Income and tax + if (emp.totalGross > 0) { + lines.push(` ${Math.round(emp.totalGross)}`) + } + if (emp.totalTax > 0) { + lines.push(` ${Math.round(emp.totalTax)}`) + } + + // Benefits + if (emp.benefitCar && emp.benefitCar > 0) { + lines.push(` ${Math.round(emp.benefitCar)}`) + } + if (emp.benefitHousing && emp.benefitHousing > 0) { + lines.push(` ${Math.round(emp.benefitHousing)}`) + } + if (emp.benefitMeals && emp.benefitMeals > 0) { + lines.push(` ${Math.round(emp.benefitMeals)}`) + } + if (emp.benefitOther && emp.benefitOther > 0) { + lines.push(` ${Math.round(emp.benefitOther)}`) + } + + // Avgifter basis + if (emp.totalAvgifterBasis > 0) { + lines.push(` ${Math.round(emp.totalAvgifterBasis)}`) + } + + // Employment period (if not full year) + if (emp.employmentStart) { + lines.push(` ${emp.employmentStart.replace(/-/g, '')}`) + } + if (emp.employmentEnd) { + lines.push(` ${emp.employmentEnd.replace(/-/g, '')}`) + } + + lines.push(' ') + lines.push(' ') + lines.push(' ') + } + + lines.push('') + return lines.join('\n') +} + +function escapeXml(str: string): string { + return str + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, ''') +} diff --git a/lib/salary/lonevaxling.ts b/lib/salary/lonevaxling.ts new file mode 100644 index 00000000..dc0cd5e5 --- /dev/null +++ b/lib/salary/lonevaxling.ts @@ -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 + 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, + } +} diff --git a/lib/salary/payment/pain001-generator.ts b/lib/salary/payment/pain001-generator.ts new file mode 100644 index 00000000..f3efc8fe --- /dev/null +++ b/lib/salary/payment/pain001-generator.ts @@ -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('') + lines.push('') + lines.push(' ') + + // ─── Group Header ─── + lines.push(' ') + lines.push(` ${escapeXml(options.messageId)}`) + lines.push(` ${now}`) + lines.push(` ${employees.length}`) + lines.push(` ${formattedTotal}`) + lines.push(' ') + lines.push(` ${escapeXml(company.name)}`) + lines.push(' ') + lines.push(' ') + lines.push(` ${escapeXml(company.orgNumber.replace('-', ''))}`) + lines.push(' ') + lines.push(' ') + lines.push(' ') + lines.push(' ') + + // ─── Payment Information ─── + lines.push(' ') + lines.push(` ${escapeXml(options.messageId)}-PMT`) + lines.push(' TRF') // Transfer + lines.push(' true') // Batch booking + lines.push(` ${employees.length}`) + lines.push(` ${formattedTotal}`) + lines.push(' ') + lines.push(' SEPA') + lines.push(' SALA') // Salary payment + lines.push(' ') + lines.push(` ${options.paymentDate}`) + + // Debtor (company) + lines.push(' ') + lines.push(` ${escapeXml(company.name)}`) + lines.push(' ') + lines.push(' ') + lines.push(' ') + lines.push(` ${escapeXml(company.iban)}`) + lines.push(' ') + lines.push(' SEK') + lines.push(' ') + lines.push(' ') + lines.push(' ') + lines.push(` ${escapeXml(company.bic)}`) + lines.push(' ') + lines.push(' ') + + // ─── 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(' ') + lines.push(' ') + lines.push(` ${escapeXml(txId)}`) + lines.push(` ${escapeXml(txId)}`) + lines.push(' ') + lines.push(' ') + lines.push(` ${formatDecimal(emp.netSalary)}`) + lines.push(' ') + lines.push(' ') + lines.push(` ${escapeXml(emp.name)}`) + lines.push(' ') + lines.push(' ') + lines.push(' ') + // Swedish domestic: clearing + account number (not IBAN for domestic) + lines.push(` ${escapeXml(emp.clearingNumber)}${escapeXml(emp.bankAccountNumber)}`) + lines.push(' ') + lines.push(' ') + lines.push(' ') + lines.push(` Lon ${escapeXml(options.periodLabel)}`) + lines.push(' ') + lines.push(' ') + } + + lines.push(' ') + lines.push(' ') + lines.push('') + + return lines.join('\n') +} + +// ============================================================ +// Helpers +// ============================================================ + +function escapeXml(str: string): string { + return str + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, ''') +} + +/** 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) +} diff --git a/lib/salary/payroll-config.ts b/lib/salary/payroll-config.ts new file mode 100644 index 00000000..cdb4d1d6 --- /dev/null +++ b/lib/salary/payroll-config.ts @@ -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 { + 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 { + return { ...config } +} diff --git a/lib/salary/pdf/payslip-template.tsx b/lib/salary/pdf/payslip-template.tsx new file mode 100644 index 00000000..d912a224 --- /dev/null +++ b/lib/salary/pdf/payslip-template.tsx @@ -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 ( + + + {/* Header */} + + + Lönespecifikation + {periodLabel} + + + {data.companyName} + Org.nr {data.companyOrgNumber} + {data.companyAddress && {data.companyAddress}} + + + + {/* Employee + Period info */} + + + Anställd + {data.employeeName} + Personnummer + {data.personnummerMasked} + + + Period + {periodLabel} + Utbetalningsdag + {data.paymentDate} + + + Skattetabell + {data.taxReference} + {data.bankAccount && ( + <> + Bankkonto + {data.bankAccount} + + )} + + + + {/* Line items table */} + + Lönespecifikation + + Beskrivning + Antal + á-pris + Belopp + + {data.lineItems.map((item, i) => ( + + {item.description} + {item.quantity != null ? item.quantity : ''} + {item.unitPrice != null ? fmt(item.unitPrice) : ''} + {fmt(item.amount)} + + ))} + + + {/* Summary: Gross → Tax → Net */} + + + Bruttolön + {fmt(data.grossSalary)} + + + Preliminär skatt ({data.taxReference}) + −{fmt(data.taxWithheld)} + + + Nettolön (utbetalas) + {fmt(data.netSalary)} + + + + {/* Employer cost (transparency feature — our differentiator) */} + + Arbetsgivarkostnad + + Arbetsgivaravgifter ({(data.avgifterRate * 100).toFixed(2)}%) + {fmt(data.avgifterAmount)} + + + Semesteravsättning + {fmt(data.vacationAccrual)} + + + Sociala avgifter på semester + {fmt(data.vacationAccrualAvgifter)} + + + Total arbetsgivarkostnad + {fmt(data.totalEmployerCost)} + + + + {/* YTD */} + + Ackumulerat {data.periodYear} + + Brutto + {fmt(data.ytdGross)} + + + Skatt + {fmt(data.ytdTax)} + + + Netto + {fmt(data.ytdNet)} + + + + {/* Calculation breakdown (optional detail page) */} + {data.breakdownSteps && data.breakdownSteps.length > 0 && ( + + Beräkningsunderlag + {data.breakdownSteps.map((step, i) => ( + + {step.label} + {step.formula} + {fmt(step.output)} + + ))} + + )} + + {/* Footer */} + + {data.companyName} · Org.nr {data.companyOrgNumber} · Lönespecifikation {periodLabel} · Genererad av gnubok + + + + ) +} diff --git a/lib/salary/personnummer.ts b/lib/salary/personnummer.ts new file mode 100644 index 00000000..4fcd2a45 --- /dev/null +++ b/lib/salary/personnummer.ts @@ -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)}` +} diff --git a/lib/salary/salary-entries.ts b/lib/salary/salary-entries.ts new file mode 100644 index 00000000..239feeed --- /dev/null +++ b/lib/salary/salary-entries.ts @@ -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 { + const lines: CreateJournalEntryLineInput[] = [] + + // Aggregate salary expenses by account + const expenseByAccount = new Map() + 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 { + 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 { + 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 { + 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 = { + '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}` +} diff --git a/lib/salary/salary-transaction-matcher.ts b/lib/salary/salary-transaction-matcher.ts new file mode 100644 index 00000000..ee29e37f --- /dev/null +++ b/lib/salary/salary-transaction-matcher.ts @@ -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 } +} diff --git a/lib/salary/tax-tables.ts b/lib/salary/tax-tables.ts new file mode 100644 index 00000000..ac162bb4 --- /dev/null +++ b/lib/salary/tax-tables.ts @@ -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() +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 { + 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 { + 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 { + const allRates: TaxTableRate[] = [] + const uniquePairs = new Set() + + 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> { + 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() + 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() +} diff --git a/lib/salary/traktamente.ts b/lib/salary/traktamente.ts new file mode 100644 index 00000000..3dd0a0ec --- /dev/null +++ b/lib/salary/traktamente.ts @@ -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 + 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, + }], + } +} diff --git a/lib/tax/deadline-config.ts b/lib/tax/deadline-config.ts index 60bdb5f7..72124a4d 100644 --- a/lib/tax/deadline-config.ts +++ b/lib/tax/deadline-config.ts @@ -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')}`, diff --git a/supabase/migrations/20260414120000_salary_module.sql b/supabase/migrations/20260414120000_salary_module.sql new file mode 100644 index 00000000..2626f1ea --- /dev/null +++ b/supabase/migrations/20260414120000_salary_module.sql @@ -0,0 +1,538 @@ +-- ============================================================================= +-- Salary Module (Lönehantering) +-- ============================================================================= +-- +-- Comprehensive payroll module for Swedish AB companies: +-- - Employee register with personnummer encryption +-- - Salary runs with multi-step workflow +-- - Per-employee calculation results and line items +-- - Tax table reference data +-- - Annual payroll configuration (statutory rates) +-- - AGI declaration tracking +-- +-- All tables are company-scoped with RLS via user_company_ids(). +-- Soft-delete only for employees (BFL 7 kap, 7-year retention). + +-- ============================================================================= +-- 1. salary_payroll_config — Year-specific statutory rates (system table) +-- ============================================================================= + +CREATE TABLE public.salary_payroll_config ( + id uuid PRIMARY KEY DEFAULT uuid_generate_v4(), + config_year integer NOT NULL UNIQUE, + + -- Arbetsgivaravgifter breakdown + avgifter_total numeric NOT NULL, + avgifter_alderspension numeric NOT NULL, + avgifter_sjukforsakring numeric NOT NULL, + avgifter_foraldraforsakring numeric NOT NULL, + avgifter_efterlevandepension numeric NOT NULL, + avgifter_arbetsmarknad numeric NOT NULL, + avgifter_arbetsskada numeric NOT NULL, + avgifter_allman_loneavgift numeric NOT NULL, + avgifter_reduced_65plus numeric NOT NULL, + avgifter_youth_rate numeric, + avgifter_youth_salary_cap numeric, + avgifter_vaxa_stod_rate numeric, + avgifter_vaxa_stod_cap numeric, + avgifter_minimum_annual numeric NOT NULL, + + -- Egenavgifter for EF + egenavgifter_total numeric NOT NULL, + + -- SLP on pensions + slp_rate numeric NOT NULL, + + -- Thresholds + prisbasbelopp numeric NOT NULL, + inkomstbasbelopp numeric NOT NULL, + max_pgi numeric NOT NULL, + sgi_ceiling numeric NOT NULL, + statlig_skatt_brytpunkt numeric NOT NULL, + + -- Traktamente + traktamente_heldag numeric NOT NULL, + traktamente_halvdag numeric NOT NULL, + traktamente_natt numeric NOT NULL, + + -- Milersättning + milersattning_egen_bil numeric NOT NULL, + milersattning_formansbil_fossil numeric NOT NULL, + milersattning_formansbil_el numeric NOT NULL, + + -- Benefits + kostforman_heldag numeric NOT NULL, + kostforman_lunch numeric NOT NULL, + kostforman_frukost numeric NOT NULL, + friskvard_cap numeric NOT NULL, + bilforman_slr numeric NOT NULL, + + -- Sjuklön + sjuklon_rate numeric NOT NULL DEFAULT 0.80, + karensavdrag_factor numeric NOT NULL DEFAULT 0.20, + max_karensavdrag_per_year integer NOT NULL DEFAULT 10, + + -- Age thresholds + reduced_avgift_age integer NOT NULL, + + created_at timestamptz NOT NULL DEFAULT now() +); + +ALTER TABLE public.salary_payroll_config ENABLE ROW LEVEL SECURITY; +CREATE POLICY "payroll_config_select" ON public.salary_payroll_config + FOR SELECT USING (auth.role() = 'authenticated'); + +-- Seed 2026 values +INSERT INTO public.salary_payroll_config ( + config_year, + avgifter_total, avgifter_alderspension, avgifter_sjukforsakring, + avgifter_foraldraforsakring, avgifter_efterlevandepension, + avgifter_arbetsmarknad, avgifter_arbetsskada, avgifter_allman_loneavgift, + avgifter_reduced_65plus, avgifter_youth_rate, avgifter_youth_salary_cap, + avgifter_vaxa_stod_rate, avgifter_vaxa_stod_cap, avgifter_minimum_annual, + egenavgifter_total, slp_rate, + prisbasbelopp, inkomstbasbelopp, max_pgi, sgi_ceiling, statlig_skatt_brytpunkt, + traktamente_heldag, traktamente_halvdag, traktamente_natt, + milersattning_egen_bil, milersattning_formansbil_fossil, milersattning_formansbil_el, + kostforman_heldag, kostforman_lunch, kostforman_frukost, + friskvard_cap, bilforman_slr, + sjuklon_rate, karensavdrag_factor, max_karensavdrag_per_year, + reduced_avgift_age +) VALUES ( + 2026, + 0.3142, 0.1021, 0.0355, + 0.0200, 0.0030, + 0.0264, 0.0010, 0.1262, + 0.1021, 0.2081, 25000, + 0.1021, 35000, 1000, + 0.2897, 0.2426, + 59200, 83400, 625500, 592000, 660400, + 300, 150, 150, + 25, 12, 9.50, + 310, 124, 62, + 5000, 0.0255, + 0.80, 0.20, 10, + 67 +); + +-- ============================================================================= +-- 2. tax_table_rates — Skatteverket annual tax tables (system table) +-- ============================================================================= + +CREATE TABLE public.tax_table_rates ( + id uuid PRIMARY KEY DEFAULT uuid_generate_v4(), + table_year integer NOT NULL, + table_number integer NOT NULL, + column_number integer NOT NULL, + income_from numeric NOT NULL, + income_to numeric NOT NULL, + tax_amount numeric NOT NULL, + created_at timestamptz NOT NULL DEFAULT now(), + + UNIQUE (table_year, table_number, column_number, income_from) +); + +ALTER TABLE public.tax_table_rates ENABLE ROW LEVEL SECURITY; +CREATE POLICY "tax_tables_select" ON public.tax_table_rates + FOR SELECT USING (auth.role() = 'authenticated'); + +CREATE INDEX idx_tax_table_rates_lookup + ON public.tax_table_rates (table_year, table_number, column_number, income_from); + +-- ============================================================================= +-- 3. employees — Company-scoped employee register +-- ============================================================================= + +CREATE TABLE public.employees ( + id uuid PRIMARY KEY DEFAULT uuid_generate_v4(), + company_id uuid NOT NULL REFERENCES public.companies(id) ON DELETE CASCADE, + user_id uuid NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE, + + -- Identity + first_name text NOT NULL, + last_name text NOT NULL, + personnummer text NOT NULL, + personnummer_last4 text NOT NULL, + + -- Employment + employment_type text NOT NULL DEFAULT 'employee' + CHECK (employment_type IN ('employee', 'company_owner', 'board_member')), + employment_start date NOT NULL, + employment_end date, + employment_degree numeric NOT NULL DEFAULT 100 + CHECK (employment_degree > 0 AND employment_degree <= 100), + + -- Salary + salary_type text NOT NULL DEFAULT 'monthly' + CHECK (salary_type IN ('monthly', 'hourly')), + monthly_salary numeric, + hourly_rate numeric, + + -- Tax + tax_table_number integer, + tax_column integer DEFAULT 1 + CHECK (tax_column BETWEEN 1 AND 6), + tax_municipality text, + jamkning_percentage numeric, + jamkning_valid_from date, + jamkning_valid_to date, + is_sidoinkomst boolean NOT NULL DEFAULT false, + + -- F-skatt + f_skatt_status text DEFAULT 'a_skatt' + CHECK (f_skatt_status IN ('a_skatt', 'f_skatt', 'fa_skatt', 'not_verified')), + f_skatt_verified_at date, + + -- Bank + clearing_number text, + bank_account_number text, + + -- Vacation + vacation_rule text NOT NULL DEFAULT 'procentregeln' + CHECK (vacation_rule IN ('procentregeln', 'sammaloneregeln')), + vacation_days_per_year integer NOT NULL DEFAULT 25, + vacation_days_saved integer NOT NULL DEFAULT 0, + semestertillagg_rate numeric NOT NULL DEFAULT 0.0043, + + -- Contact + email text, + phone text, + address_line1 text, + postal_code text, + city text, + + -- AGI + specification_number integer, + + -- Växa-stöd + vaxa_stod_eligible boolean NOT NULL DEFAULT false, + vaxa_stod_start date, + vaxa_stod_end date, + + is_active boolean NOT NULL DEFAULT true, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + + UNIQUE (company_id, personnummer) +); + +ALTER TABLE public.employees ENABLE ROW LEVEL SECURITY; + +CREATE POLICY "employees_select" ON public.employees + FOR SELECT USING (company_id IN (SELECT public.user_company_ids())); + +CREATE POLICY "employees_insert" ON public.employees + FOR INSERT WITH CHECK (company_id IN (SELECT public.user_company_ids())); + +CREATE POLICY "employees_update" ON public.employees + FOR UPDATE USING (company_id IN (SELECT public.user_company_ids())); + +CREATE POLICY "employees_delete" ON public.employees + FOR DELETE USING (company_id IN (SELECT public.user_company_ids())); + +CREATE INDEX idx_employees_company ON public.employees (company_id); +CREATE INDEX idx_employees_active ON public.employees (company_id, is_active) WHERE is_active = true; + +CREATE TRIGGER employees_updated_at + BEFORE UPDATE ON public.employees + FOR EACH ROW EXECUTE FUNCTION public.update_updated_at_column(); + +-- ============================================================================= +-- 4. salary_runs — Monthly salary batch container +-- ============================================================================= + +CREATE TABLE public.salary_runs ( + id uuid PRIMARY KEY DEFAULT uuid_generate_v4(), + company_id uuid NOT NULL REFERENCES public.companies(id) ON DELETE CASCADE, + user_id uuid NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE, + + period_year integer NOT NULL, + period_month integer NOT NULL CHECK (period_month BETWEEN 1 AND 12), + payment_date date NOT NULL, + + status text NOT NULL DEFAULT 'draft' + CHECK (status IN ('draft', 'review', 'approved', 'paid', 'booked')), + + voucher_series text NOT NULL DEFAULT 'A' + CHECK (voucher_series ~ '^[A-Z]$'), + + -- Denormalized totals + total_gross numeric NOT NULL DEFAULT 0, + total_tax numeric NOT NULL DEFAULT 0, + total_net numeric NOT NULL DEFAULT 0, + total_avgifter numeric NOT NULL DEFAULT 0, + total_vacation_accrual numeric NOT NULL DEFAULT 0, + total_employer_cost numeric NOT NULL DEFAULT 0, + + -- Journal entry refs + salary_entry_id uuid REFERENCES public.journal_entries(id), + avgifter_entry_id uuid REFERENCES public.journal_entries(id), + vacation_entry_id uuid REFERENCES public.journal_entries(id), + + -- AGI tracking + agi_generated_at timestamptz, + agi_submitted_at timestamptz, + + -- Calculation parameters snapshot + calculation_params jsonb, + + -- Audit + approved_by uuid REFERENCES auth.users(id), + approved_at timestamptz, + paid_at timestamptz, + booked_at timestamptz, + booked_by uuid REFERENCES auth.users(id), + notes text, + + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + + UNIQUE (company_id, period_year, period_month) +); + +ALTER TABLE public.salary_runs ENABLE ROW LEVEL SECURITY; + +CREATE POLICY "salary_runs_select" ON public.salary_runs + FOR SELECT USING (company_id IN (SELECT public.user_company_ids())); + +CREATE POLICY "salary_runs_insert" ON public.salary_runs + FOR INSERT WITH CHECK (company_id IN (SELECT public.user_company_ids())); + +CREATE POLICY "salary_runs_update" ON public.salary_runs + FOR UPDATE USING (company_id IN (SELECT public.user_company_ids())); + +CREATE POLICY "salary_runs_delete" ON public.salary_runs + FOR DELETE USING (company_id IN (SELECT public.user_company_ids())); + +CREATE INDEX idx_salary_runs_company ON public.salary_runs (company_id); +CREATE INDEX idx_salary_runs_period ON public.salary_runs (company_id, period_year, period_month); +CREATE INDEX idx_salary_runs_status ON public.salary_runs (status); + +CREATE TRIGGER salary_runs_updated_at + BEFORE UPDATE ON public.salary_runs + FOR EACH ROW EXECUTE FUNCTION public.update_updated_at_column(); + +-- ============================================================================= +-- 5. salary_run_employees — Per-employee calculation results +-- ============================================================================= + +CREATE TABLE public.salary_run_employees ( + id uuid PRIMARY KEY DEFAULT uuid_generate_v4(), + salary_run_id uuid NOT NULL REFERENCES public.salary_runs(id) ON DELETE CASCADE, + employee_id uuid NOT NULL REFERENCES public.employees(id) ON DELETE RESTRICT, + company_id uuid NOT NULL REFERENCES public.companies(id) ON DELETE CASCADE, + + -- Snapshots + employment_degree numeric NOT NULL, + monthly_salary numeric NOT NULL, + salary_type text NOT NULL, + hours_worked numeric, + + -- Calculation results + gross_salary numeric NOT NULL DEFAULT 0, + gross_deductions numeric NOT NULL DEFAULT 0, + benefit_values numeric NOT NULL DEFAULT 0, + taxable_income numeric NOT NULL DEFAULT 0, + tax_withheld numeric NOT NULL DEFAULT 0, + net_deductions numeric NOT NULL DEFAULT 0, + net_salary numeric NOT NULL DEFAULT 0, + + -- Employer contributions + avgifter_rate numeric NOT NULL DEFAULT 0.3142, + avgifter_amount numeric NOT NULL DEFAULT 0, + avgifter_basis numeric NOT NULL DEFAULT 0, + + -- Vacation + vacation_accrual numeric NOT NULL DEFAULT 0, + vacation_accrual_avgifter numeric NOT NULL DEFAULT 0, + + -- Tax snapshot + tax_table_number integer, + tax_column integer, + tax_table_year integer, + + -- Absence summary + sick_days numeric NOT NULL DEFAULT 0, + vab_days numeric NOT NULL DEFAULT 0, + parental_days numeric NOT NULL DEFAULT 0, + vacation_days_taken numeric NOT NULL DEFAULT 0, + + -- Calculation breakdown + calculation_breakdown jsonb, + + -- YTD tracking + ytd_gross numeric NOT NULL DEFAULT 0, + ytd_tax numeric NOT NULL DEFAULT 0, + ytd_net numeric NOT NULL DEFAULT 0, + + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + + UNIQUE (salary_run_id, employee_id) +); + +ALTER TABLE public.salary_run_employees ENABLE ROW LEVEL SECURITY; + +CREATE POLICY "sre_select" ON public.salary_run_employees + FOR SELECT USING (company_id IN (SELECT public.user_company_ids())); + +CREATE POLICY "sre_insert" ON public.salary_run_employees + FOR INSERT WITH CHECK (company_id IN (SELECT public.user_company_ids())); + +CREATE POLICY "sre_update" ON public.salary_run_employees + FOR UPDATE USING (company_id IN (SELECT public.user_company_ids())); + +CREATE POLICY "sre_delete" ON public.salary_run_employees + FOR DELETE USING (company_id IN (SELECT public.user_company_ids())); + +CREATE INDEX idx_sre_salary_run ON public.salary_run_employees (salary_run_id); +CREATE INDEX idx_sre_employee ON public.salary_run_employees (employee_id); +CREATE INDEX idx_sre_company ON public.salary_run_employees (company_id); + +CREATE TRIGGER sre_updated_at + BEFORE UPDATE ON public.salary_run_employees + FOR EACH ROW EXECUTE FUNCTION public.update_updated_at_column(); + +-- ============================================================================= +-- 6. salary_line_items — Individual pay slip line items +-- ============================================================================= + +CREATE TABLE public.salary_line_items ( + id uuid PRIMARY KEY DEFAULT uuid_generate_v4(), + salary_run_employee_id uuid NOT NULL REFERENCES public.salary_run_employees(id) ON DELETE CASCADE, + company_id uuid NOT NULL REFERENCES public.companies(id) ON DELETE CASCADE, + + item_type text NOT NULL + CHECK (item_type IN ( + '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' + )), + + description text NOT NULL, + quantity numeric, + unit_price numeric, + amount numeric NOT NULL, + + -- Compliance flags + is_taxable boolean NOT NULL DEFAULT true, + is_avgift_basis boolean NOT NULL DEFAULT true, + is_vacation_basis boolean NOT NULL DEFAULT true, + is_gross_deduction boolean NOT NULL DEFAULT false, + is_net_deduction boolean NOT NULL DEFAULT false, + + -- Account mapping + account_number text, + sort_order integer NOT NULL DEFAULT 0, + + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now() +); + +ALTER TABLE public.salary_line_items ENABLE ROW LEVEL SECURITY; + +CREATE POLICY "sli_select" ON public.salary_line_items + FOR SELECT USING (company_id IN (SELECT public.user_company_ids())); + +CREATE POLICY "sli_insert" ON public.salary_line_items + FOR INSERT WITH CHECK (company_id IN (SELECT public.user_company_ids())); + +CREATE POLICY "sli_update" ON public.salary_line_items + FOR UPDATE USING (company_id IN (SELECT public.user_company_ids())); + +CREATE POLICY "sli_delete" ON public.salary_line_items + FOR DELETE USING (company_id IN (SELECT public.user_company_ids())); + +CREATE INDEX idx_sli_sre ON public.salary_line_items (salary_run_employee_id); +CREATE INDEX idx_sli_company ON public.salary_line_items (company_id); + +CREATE TRIGGER sli_updated_at + BEFORE UPDATE ON public.salary_line_items + FOR EACH ROW EXECUTE FUNCTION public.update_updated_at_column(); + +-- ============================================================================= +-- 7. agi_declarations — AGI filing tracking +-- ============================================================================= + +CREATE TABLE public.agi_declarations ( + id uuid PRIMARY KEY DEFAULT uuid_generate_v4(), + company_id uuid NOT NULL REFERENCES public.companies(id) ON DELETE CASCADE, + user_id uuid NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE, + salary_run_id uuid REFERENCES public.salary_runs(id), + + period_year integer NOT NULL, + period_month integer NOT NULL, + xml_content text NOT NULL, + status text NOT NULL DEFAULT 'generated' + CHECK (status IN ('generated', 'exported', 'submitted', 'accepted', 'rejected')), + + individuppgifter jsonb NOT NULL, + + total_gross numeric NOT NULL DEFAULT 0, + total_tax numeric NOT NULL DEFAULT 0, + total_avgifter_basis numeric NOT NULL DEFAULT 0, + total_avgifter numeric NOT NULL DEFAULT 0, + employee_count integer NOT NULL DEFAULT 0, + + kvittensnummer text, + submitted_at timestamptz, + submitted_by uuid REFERENCES auth.users(id), + response_data jsonb, + + is_correction boolean NOT NULL DEFAULT false, + corrects_agi_id uuid REFERENCES public.agi_declarations(id), + + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + + UNIQUE (company_id, period_year, period_month) +); + +ALTER TABLE public.agi_declarations ENABLE ROW LEVEL SECURITY; + +CREATE POLICY "agi_select" ON public.agi_declarations + FOR SELECT USING (company_id IN (SELECT public.user_company_ids())); + +CREATE POLICY "agi_insert" ON public.agi_declarations + FOR INSERT WITH CHECK (company_id IN (SELECT public.user_company_ids())); + +CREATE POLICY "agi_update" ON public.agi_declarations + FOR UPDATE USING (company_id IN (SELECT public.user_company_ids())); + +CREATE INDEX idx_agi_company ON public.agi_declarations (company_id); +CREATE INDEX idx_agi_period ON public.agi_declarations (company_id, period_year, period_month); + +CREATE TRIGGER agi_updated_at + BEFORE UPDATE ON public.agi_declarations + FOR EACH ROW EXECUTE FUNCTION public.update_updated_at_column(); + +-- ============================================================================= +-- Auto-assign specification_number for AGI FK570 +-- ============================================================================= + +CREATE OR REPLACE FUNCTION public.assign_specification_number() +RETURNS TRIGGER AS $$ +BEGIN + IF NEW.specification_number IS NULL THEN + SELECT COALESCE(MAX(specification_number), 0) + 1 + INTO NEW.specification_number + FROM public.employees + WHERE company_id = NEW.company_id; + END IF; + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +CREATE TRIGGER employees_assign_spec_number + BEFORE INSERT ON public.employees + FOR EACH ROW EXECUTE FUNCTION public.assign_specification_number(); + +-- Schema reload for PostgREST +NOTIFY pgrst, 'reload schema'; diff --git a/supabase/migrations/20260414130000_salary_correction_support.sql b/supabase/migrations/20260414130000_salary_correction_support.sql new file mode 100644 index 00000000..893dcc18 --- /dev/null +++ b/supabase/migrations/20260414130000_salary_correction_support.sql @@ -0,0 +1,24 @@ +-- ============================================================================= +-- Salary Correction Support +-- ============================================================================= +-- Per BFL 5 kap 5§: Corrections must preserve originals via storno entries. +-- Adds 'corrected' status and correction tracking fields to salary_runs. + +ALTER TABLE public.salary_runs + DROP CONSTRAINT IF EXISTS salary_runs_status_check; +ALTER TABLE public.salary_runs + ADD CONSTRAINT salary_runs_status_check + CHECK (status IN ('draft', 'review', 'approved', 'paid', 'booked', 'corrected')); + +ALTER TABLE public.salary_runs + ADD COLUMN IF NOT EXISTS is_correction boolean NOT NULL DEFAULT false, + ADD COLUMN IF NOT EXISTS corrects_run_id uuid REFERENCES public.salary_runs(id); + +-- Replace unique constraint to allow corrections for the same period. +-- Only one non-corrected run per period per company. +ALTER TABLE public.salary_runs DROP CONSTRAINT IF EXISTS salary_runs_company_id_period_year_period_month_key; +CREATE UNIQUE INDEX idx_salary_runs_period_unique + ON public.salary_runs (company_id, period_year, period_month) + WHERE status != 'corrected'; + +NOTIFY pgrst, 'reload schema'; diff --git a/supabase/migrations/20260415120000_salary_review_fixes.sql b/supabase/migrations/20260415120000_salary_review_fixes.sql new file mode 100644 index 00000000..2c0242f8 --- /dev/null +++ b/supabase/migrations/20260415120000_salary_review_fixes.sql @@ -0,0 +1,33 @@ +-- ============================================================================= +-- Fix 1: Add pension_entry_id to salary_runs +-- Pension journal entries from löneväxling were created but never stored, +-- causing them to be missed during correction reversals. +-- ============================================================================= + +ALTER TABLE public.salary_runs + ADD COLUMN pension_entry_id uuid REFERENCES public.journal_entries(id); + +-- ============================================================================= +-- Fix 2: Add UNIQUE constraint on employees(company_id, specification_number) +-- Prevents duplicate FK570 specification numbers from concurrent inserts. +-- The assign_specification_number trigger uses SELECT MAX() without locking, +-- so this constraint makes any race condition fail explicitly. +-- ============================================================================= + +ALTER TABLE public.employees + ADD CONSTRAINT employees_company_spec_number_unique + UNIQUE (company_id, specification_number); + +-- ============================================================================= +-- Fix 3: Add avgifter_category to salary_run_employees +-- The avgifter rate alone cannot distinguish between reduced_65plus and +-- vaxa_stod (both 10.21% in 2026). Store the category from the calculation +-- engine so AGI XML generation uses the correct HU rutor breakdown. +-- ============================================================================= + +ALTER TABLE public.salary_run_employees + ADD COLUMN avgifter_category text + CHECK (avgifter_category IN ('standard', 'reduced_65plus', 'youth', 'vaxa_stod', 'exempt')); + +-- Schema reload for PostgREST +NOTIFY pgrst, 'reload schema'; diff --git a/types/index.ts b/types/index.ts index 1cbac290..988df9aa 100644 --- a/types/index.ts +++ b/types/index.ts @@ -2319,3 +2319,189 @@ export interface KPIPreferences { kpiOrder: string[] accountOverrides: Record } + +// ============================================================ +// Salary Module Types (Lönehantering) +// ============================================================ + +export type EmploymentType = 'employee' | 'company_owner' | 'board_member' +export type SalaryType = 'monthly' | 'hourly' +export type FSkattStatus = 'a_skatt' | 'f_skatt' | 'fa_skatt' | 'not_verified' +export type VacationRule = 'procentregeln' | 'sammaloneregeln' +export type SalaryRunStatus = 'draft' | 'review' | 'approved' | 'paid' | 'booked' | 'corrected' +export type AGIStatus = 'generated' | 'exported' | 'submitted' | 'accepted' | 'rejected' + +export type SalaryLineItemType = + | '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 interface Employee { + id: string + company_id: string + user_id: string + first_name: string + last_name: string + personnummer: string + personnummer_last4: string + employment_type: EmploymentType + employment_start: string + employment_end: string | null + employment_degree: number + salary_type: SalaryType + monthly_salary: number | null + hourly_rate: number | null + tax_table_number: number | null + tax_column: number + tax_municipality: string | null + jamkning_percentage: number | null + jamkning_valid_from: string | null + jamkning_valid_to: string | null + is_sidoinkomst: boolean + f_skatt_status: FSkattStatus + f_skatt_verified_at: string | null + clearing_number: string | null + bank_account_number: string | null + vacation_rule: VacationRule + vacation_days_per_year: number + vacation_days_saved: number + semestertillagg_rate: number + email: string | null + phone: string | null + address_line1: string | null + postal_code: string | null + city: string | null + specification_number: number | null + vaxa_stod_eligible: boolean + vaxa_stod_start: string | null + vaxa_stod_end: string | null + is_active: boolean + created_at: string + updated_at: string +} + +export interface SalaryRun { + id: string + company_id: string + user_id: string + period_year: number + period_month: number + payment_date: string + status: SalaryRunStatus + voucher_series: string + total_gross: number + total_tax: number + total_net: number + total_avgifter: number + total_vacation_accrual: number + total_employer_cost: number + salary_entry_id: string | null + avgifter_entry_id: string | null + vacation_entry_id: string | null + agi_generated_at: string | null + agi_submitted_at: string | null + calculation_params: Record | null + approved_by: string | null + approved_at: string | null + paid_at: string | null + booked_at: string | null + booked_by: string | null + notes: string | null + is_correction: boolean + corrects_run_id: string | null + created_at: string + updated_at: string + // Relations + employees?: SalaryRunEmployee[] +} + +export interface SalaryRunEmployee { + id: string + salary_run_id: string + employee_id: string + company_id: string + employment_degree: number + monthly_salary: number + salary_type: string + hours_worked: number | null + gross_salary: number + gross_deductions: number + benefit_values: number + taxable_income: number + tax_withheld: number + net_deductions: number + net_salary: number + avgifter_rate: number + avgifter_amount: number + avgifter_basis: number + vacation_accrual: number + vacation_accrual_avgifter: number + tax_table_number: number | null + tax_column: number | null + tax_table_year: number | null + sick_days: number + vab_days: number + parental_days: number + vacation_days_taken: number + calculation_breakdown: Record | null + ytd_gross: number + ytd_tax: number + ytd_net: number + created_at: string + updated_at: string + // Relations + employee?: Employee + line_items?: SalaryLineItem[] +} + +export interface SalaryLineItem { + id: string + salary_run_employee_id: string + company_id: string + item_type: SalaryLineItemType + description: string + quantity: number | null + unit_price: number | null + amount: number + is_taxable: boolean + is_avgift_basis: boolean + is_vacation_basis: boolean + is_gross_deduction: boolean + is_net_deduction: boolean + account_number: string | null + sort_order: number + created_at: string + updated_at: string +} + +export interface AGIDeclaration { + id: string + company_id: string + user_id: string + salary_run_id: string | null + period_year: number + period_month: number + xml_content: string + status: AGIStatus + individuppgifter: Record[] + total_gross: number + total_tax: number + total_avgifter_basis: number + total_avgifter: number + employee_count: number + kvittensnummer: string | null + submitted_at: string | null + submitted_by: string | null + response_data: Record | null + is_correction: boolean + corrects_agi_id: string | null + created_at: string + updated_at: string +}