Salary module (#245)

* feat: implement salary module with personnummer encryption, salary entries, tax tables, and AGI tracking

- Added personnummer encryption and decryption functions for secure storage.
- Created salary entries handling for journal entries including gross salary, tax withholding, and employer contributions.
- Implemented tax table lookup functionality for calculating tax amounts based on monthly income.
- Developed SQL migration for salary module including tables for payroll configuration, tax rates, employees, salary runs, salary run employees, salary line items, and AGI declarations.
- Established row-level security policies for all new tables to ensure company-scoped access.

* feat: add salary calculation modules for 2026

- Implemented engångsskatt calculation for one-time payments with tax brackets.
- Added löneväxling functionality for salary sacrifice to pension, including employer savings and warnings.
- Created pain.001 generator for salary batch payments in compliance with Swedish banking standards.
- Developed PDF template for payslips, including detailed breakdowns and employer costs.
- Generated seed data for Swedish tax tables for 2026, including SQL insert statements.
- Implemented traktamente calculations for per diem and mileage allowances, adhering to Skatteverket regulations.
- Added seed script for populating tax tables in the database.

* feat: Update meal reduction percentages in traktamente calculation

fix: Remove obsolete seed script for 2026 tax tables

feat: Extend SalaryRunStatus type to include 'corrected' status

feat: Implement KU10 XML generation endpoint for annual employee income statements

feat: Add endpoint for creating corrections to booked salary runs

feat: Implement endpoint for sending payslip PDFs to employees

feat: Create KU10 XML generator for annual reporting

feat: Add salary transaction matcher for auto-linking bank transactions to salary entries

chore: Add database migration for salary correction support

* feat: replace select elements with custom Select component for employment and salary types

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