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:
@@ -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 på 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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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 A–Z. 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>
|
||||
)
|
||||
}
|
||||
@@ -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 på Skatteverkets publiceringar.</p>
|
||||
<p>
|
||||
<strong>Aktuellt år:</strong> 2026 — Arbetsgivaravgifter 31,42%, prisbasbelopp 59 200 SEK
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
}
|
||||
@@ -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 } })
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
@@ -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 } })
|
||||
}
|
||||
@@ -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 } })
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
@@ -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',
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -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' },
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 ─────────────────────────────────────
|
||||
|
||||
@@ -612,3 +612,90 @@ export const OpeningBalanceExecuteSchema = z.object({
|
||||
credit_amount: nonNegativeAmount,
|
||||
})).min(2, 'At least two lines are required for double-entry'),
|
||||
})
|
||||
|
||||
// ============================================================
|
||||
// Salary schemas
|
||||
// ============================================================
|
||||
|
||||
export const EmploymentTypeSchema = z.enum(['employee', 'company_owner', 'board_member'])
|
||||
export const SalaryTypeSchema = z.enum(['monthly', 'hourly'])
|
||||
export const FSkattStatusSchema = z.enum(['a_skatt', 'f_skatt', 'fa_skatt', 'not_verified'])
|
||||
export const VacationRuleSchema = z.enum(['procentregeln', 'sammaloneregeln'])
|
||||
export const SalaryRunStatusSchema = z.enum(['draft', 'review', 'approved', 'paid', 'booked', 'corrected'])
|
||||
|
||||
export const SalaryLineItemTypeSchema = z.enum([
|
||||
'monthly_salary', 'hourly_salary', 'overtime', 'bonus', 'commission',
|
||||
'gross_deduction_pension', 'gross_deduction_other',
|
||||
'benefit_car', 'benefit_housing', 'benefit_meals', 'benefit_wellness', 'benefit_other',
|
||||
'sick_karens', 'sick_day2_14', 'sick_day15_plus',
|
||||
'vab', 'parental_leave', 'vacation',
|
||||
'traktamente_taxfree', 'traktamente_taxable',
|
||||
'mileage_taxfree', 'mileage_taxable',
|
||||
'net_deduction_advance', 'net_deduction_union', 'net_deduction_benefit_payment',
|
||||
'net_deduction_other',
|
||||
'correction', 'other',
|
||||
])
|
||||
|
||||
export const CreateEmployeeSchema = z.object({
|
||||
first_name: z.string().min(1).max(200),
|
||||
last_name: z.string().min(1).max(200),
|
||||
personnummer: z.string().regex(/^\d{12}$/, 'Personnummer måste vara 12 siffror (ÅÅÅÅMMDDNNNN)'),
|
||||
employment_type: EmploymentTypeSchema.default('employee'),
|
||||
employment_start: isoDate,
|
||||
employment_end: isoDate.optional(),
|
||||
employment_degree: z.number().min(1).max(100).default(100),
|
||||
salary_type: SalaryTypeSchema.default('monthly'),
|
||||
monthly_salary: z.number().nonnegative().optional(),
|
||||
hourly_rate: z.number().nonnegative().optional(),
|
||||
tax_table_number: z.number().int().min(29).max(42).optional(),
|
||||
tax_column: z.number().int().min(1).max(6).default(1),
|
||||
tax_municipality: z.string().max(100).optional(),
|
||||
is_sidoinkomst: z.boolean().default(false),
|
||||
f_skatt_status: FSkattStatusSchema.default('a_skatt'),
|
||||
clearing_number: z.string().max(10).optional(),
|
||||
bank_account_number: z.string().max(20).optional(),
|
||||
vacation_rule: VacationRuleSchema.default('procentregeln'),
|
||||
vacation_days_per_year: z.number().int().min(25).max(40).default(25),
|
||||
semestertillagg_rate: z.number().min(0).max(0.05).default(0.0043),
|
||||
email: z.string().email().optional(),
|
||||
phone: z.string().max(20).optional(),
|
||||
address_line1: z.string().max(200).optional(),
|
||||
postal_code: z.string().max(10).optional(),
|
||||
city: z.string().max(100).optional(),
|
||||
vaxa_stod_eligible: z.boolean().default(false),
|
||||
vaxa_stod_start: isoDate.optional(),
|
||||
vaxa_stod_end: isoDate.optional(),
|
||||
})
|
||||
|
||||
export const UpdateEmployeeSchema = CreateEmployeeSchema.partial()
|
||||
|
||||
export const CreateSalaryRunSchema = z.object({
|
||||
period_year: z.number().int().min(2020).max(2100),
|
||||
period_month: z.number().int().min(1).max(12),
|
||||
payment_date: isoDate,
|
||||
voucher_series: z.string().regex(/^[A-Z]$/, 'Verifikationsserie måste vara en bokstav A–Z').default('A'),
|
||||
notes: z.string().max(2000).optional(),
|
||||
})
|
||||
|
||||
export const AddEmployeeToRunSchema = z.object({
|
||||
employee_id: uuid,
|
||||
hours_worked: z.number().nonnegative().optional(),
|
||||
})
|
||||
|
||||
export const CreateSalaryLineItemSchema = z.object({
|
||||
salary_run_employee_id: uuid,
|
||||
item_type: SalaryLineItemTypeSchema,
|
||||
description: z.string().min(1).max(500),
|
||||
quantity: z.number().optional(),
|
||||
unit_price: z.number().optional(),
|
||||
amount: z.number(),
|
||||
is_taxable: z.boolean().default(true),
|
||||
is_avgift_basis: z.boolean().default(true),
|
||||
is_vacation_basis: z.boolean().default(true),
|
||||
is_gross_deduction: z.boolean().default(false),
|
||||
is_net_deduction: z.boolean().default(false),
|
||||
account_number: accountNumber.optional(),
|
||||
sort_order: z.number().int().default(0),
|
||||
})
|
||||
|
||||
export const UpdateSalaryLineItemSchema = CreateSalaryLineItemSchema.partial().omit({ salary_run_employee_id: true })
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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.'
|
||||
|
||||
@@ -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 } }
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
import type { SupabaseClient } from '@supabase/supabase-js'
|
||||
|
||||
/**
|
||||
* Arbetsgivaravgiftsunderlag — Employer contribution basis report.
|
||||
*
|
||||
* Monthly breakdown by avgifter rate category:
|
||||
* - Standard (31.42%)
|
||||
* - Reduced 65+ (10.21%)
|
||||
* - Youth (20.81%, Apr 2026–Sep 2027)
|
||||
* - Växa-stöd (10.21%)
|
||||
*
|
||||
* Used for reconciling against AGI filings (Ruta 060-062)
|
||||
* and verifying correct avgifter calculations per social-charges.md.
|
||||
*
|
||||
* Per BFL: Part of räkenskapsinformation, 7-year retention.
|
||||
*/
|
||||
|
||||
export interface AvgifterBasisRow {
|
||||
periodYear: number
|
||||
periodMonth: number
|
||||
category: string
|
||||
categoryLabel: string
|
||||
rate: number
|
||||
basis: number // Underlag (sum of avgifter_basis for employees in this category)
|
||||
amount: number // Avgift (basis × rate)
|
||||
employeeCount: number
|
||||
}
|
||||
|
||||
export interface AvgifterBasisReport {
|
||||
rows: AvgifterBasisRow[]
|
||||
totals: {
|
||||
totalBasis: number
|
||||
totalAmount: number
|
||||
}
|
||||
year: number
|
||||
}
|
||||
|
||||
const CATEGORY_LABELS: Record<string, string> = {
|
||||
standard: 'Standard (31,42%)',
|
||||
reduced_65plus: 'Reducerad 67+ (10,21%)',
|
||||
youth: 'Ungdomsrabatt (20,81%)',
|
||||
vaxa_stod: 'Växa-stöd (10,21%)',
|
||||
exempt: 'Undantagen (0%)',
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate avgifter basis report for a year.
|
||||
*/
|
||||
export async function generateAvgifterBasis(
|
||||
supabase: SupabaseClient,
|
||||
companyId: string,
|
||||
year: number
|
||||
): Promise<AvgifterBasisReport> {
|
||||
const r = (x: number) => Math.round(x * 100) / 100
|
||||
|
||||
// Load all salary run employees for booked runs this year
|
||||
const { data: runEmployees, error } = await supabase
|
||||
.from('salary_run_employees')
|
||||
.select(`
|
||||
avgifter_basis,
|
||||
avgifter_amount,
|
||||
avgifter_rate,
|
||||
salary_run:salary_runs!inner(period_year, period_month, status)
|
||||
`)
|
||||
.eq('company_id', companyId)
|
||||
|
||||
if (error) throw new Error(`Failed to load avgifter data: ${error.message}`)
|
||||
|
||||
// Filter to booked runs for the year
|
||||
const bookedForYear = (runEmployees || []).filter(sre => {
|
||||
const run = sre.salary_run as unknown as { period_year: number; period_month: number; status: string } | null
|
||||
return run && run.period_year === year && run.status === 'booked'
|
||||
})
|
||||
|
||||
// Group by month + rate category
|
||||
const grouped = new Map<string, {
|
||||
periodYear: number
|
||||
periodMonth: number
|
||||
category: string
|
||||
rate: number
|
||||
basis: number
|
||||
amount: number
|
||||
count: number
|
||||
}>()
|
||||
|
||||
for (const sre of bookedForYear) {
|
||||
const run = sre.salary_run as unknown as { period_year: number; period_month: number }
|
||||
const category = rateToCategory(sre.avgifter_rate)
|
||||
const key = `${run.period_month}-${category}`
|
||||
|
||||
const current = grouped.get(key) || {
|
||||
periodYear: year,
|
||||
periodMonth: run.period_month,
|
||||
category,
|
||||
rate: sre.avgifter_rate,
|
||||
basis: 0,
|
||||
amount: 0,
|
||||
count: 0,
|
||||
}
|
||||
current.basis += sre.avgifter_basis
|
||||
current.amount += sre.avgifter_amount
|
||||
current.count++
|
||||
grouped.set(key, current)
|
||||
}
|
||||
|
||||
const rows: AvgifterBasisRow[] = Array.from(grouped.values())
|
||||
.map(g => ({
|
||||
periodYear: g.periodYear,
|
||||
periodMonth: g.periodMonth,
|
||||
category: g.category,
|
||||
categoryLabel: CATEGORY_LABELS[g.category] || g.category,
|
||||
rate: g.rate,
|
||||
basis: r(g.basis),
|
||||
amount: r(g.amount),
|
||||
employeeCount: g.count,
|
||||
}))
|
||||
.sort((a, b) => a.periodMonth - b.periodMonth || a.category.localeCompare(b.category))
|
||||
|
||||
const totals = {
|
||||
totalBasis: r(rows.reduce((s, row) => s + row.basis, 0)),
|
||||
totalAmount: r(rows.reduce((s, row) => s + row.amount, 0)),
|
||||
}
|
||||
|
||||
return { rows, totals, year }
|
||||
}
|
||||
|
||||
function rateToCategory(rate: number): string {
|
||||
if (rate === 0) return 'exempt'
|
||||
if (rate <= 0.1022) return 'reduced_65plus' // 10.21% ± rounding
|
||||
if (rate <= 0.2082) return 'youth' // 20.81%
|
||||
return 'standard' // 31.42%
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
import type { SupabaseClient } from '@supabase/supabase-js'
|
||||
|
||||
/**
|
||||
* Lönejournal — Monthly/annual per-employee salary register.
|
||||
*
|
||||
* Required per BFL as underlag for AGI reconciliation.
|
||||
* Lists gross, tax, net, avgifter, and vacation accrual per employee per period.
|
||||
*
|
||||
* Per BFNAR 2013:2: Must be part of systemdokumentation and producible
|
||||
* on demand for audit. Retained 7 years per BFL 7 kap.
|
||||
*/
|
||||
|
||||
export interface SalaryJournalRow {
|
||||
employeeId: string
|
||||
employeeName: string
|
||||
personnummerLast4: string
|
||||
employmentType: string
|
||||
periodYear: number
|
||||
periodMonth: number
|
||||
paymentDate: string
|
||||
grossSalary: number
|
||||
taxWithheld: number
|
||||
netSalary: number
|
||||
avgifterAmount: number
|
||||
avgifterRate: number
|
||||
vacationAccrual: number
|
||||
vacationAccrualAvgifter: number
|
||||
totalEmployerCost: number
|
||||
sickDays: number
|
||||
vabDays: number
|
||||
parentalDays: number
|
||||
vacationDaysTaken: number
|
||||
salaryRunStatus: string
|
||||
}
|
||||
|
||||
export interface SalaryJournalReport {
|
||||
rows: SalaryJournalRow[]
|
||||
totals: {
|
||||
grossSalary: number
|
||||
taxWithheld: number
|
||||
netSalary: number
|
||||
avgifterAmount: number
|
||||
vacationAccrual: number
|
||||
vacationAccrualAvgifter: number
|
||||
totalEmployerCost: number
|
||||
}
|
||||
period: { year: number; monthFrom?: number; monthTo?: number }
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate lönejournal for a year or specific month range.
|
||||
*/
|
||||
export async function generateSalaryJournal(
|
||||
supabase: SupabaseClient,
|
||||
companyId: string,
|
||||
year: number,
|
||||
monthFrom?: number,
|
||||
monthTo?: number
|
||||
): Promise<SalaryJournalReport> {
|
||||
const query = supabase
|
||||
.from('salary_run_employees')
|
||||
.select(`
|
||||
*,
|
||||
employee:employees(id, first_name, last_name, personnummer_last4, employment_type),
|
||||
salary_run:salary_runs(period_year, period_month, payment_date, status)
|
||||
`)
|
||||
.eq('company_id', companyId)
|
||||
|
||||
// We need to filter by the salary_run's period_year, which requires a join filter
|
||||
// Supabase doesn't support filtering on joined columns directly in .eq(),
|
||||
// so we fetch all and filter client-side for the year
|
||||
const { data, error } = await query.order('created_at')
|
||||
|
||||
if (error) {
|
||||
throw new Error(`Failed to generate salary journal: ${error.message}`)
|
||||
}
|
||||
|
||||
const rows: SalaryJournalRow[] = (data || [])
|
||||
.filter(sre => {
|
||||
const run = sre.salary_run as { period_year: number; period_month: number; status: string } | null
|
||||
if (!run || run.period_year !== year) return false
|
||||
if (run.status !== 'booked') return false // Only booked runs for BFL-compliant lönejournal
|
||||
if (monthFrom && run.period_month < monthFrom) return false
|
||||
if (monthTo && run.period_month > monthTo) return false
|
||||
return true
|
||||
})
|
||||
.map(sre => {
|
||||
const emp = sre.employee as { first_name: string; last_name: string; personnummer_last4: string; employment_type: string } | null
|
||||
const run = sre.salary_run as { period_year: number; period_month: number; payment_date: string; status: string }
|
||||
return {
|
||||
employeeId: sre.employee_id,
|
||||
employeeName: emp ? `${emp.first_name} ${emp.last_name}` : 'Okänd',
|
||||
personnummerLast4: emp?.personnummer_last4 || '????',
|
||||
employmentType: emp?.employment_type || 'employee',
|
||||
periodYear: run.period_year,
|
||||
periodMonth: run.period_month,
|
||||
paymentDate: run.payment_date,
|
||||
grossSalary: sre.gross_salary,
|
||||
taxWithheld: sre.tax_withheld,
|
||||
netSalary: sre.net_salary,
|
||||
avgifterAmount: sre.avgifter_amount,
|
||||
avgifterRate: sre.avgifter_rate,
|
||||
vacationAccrual: sre.vacation_accrual,
|
||||
vacationAccrualAvgifter: sre.vacation_accrual_avgifter,
|
||||
totalEmployerCost: sre.gross_salary + sre.avgifter_amount + sre.vacation_accrual + sre.vacation_accrual_avgifter,
|
||||
sickDays: sre.sick_days,
|
||||
vabDays: sre.vab_days,
|
||||
parentalDays: sre.parental_days,
|
||||
vacationDaysTaken: sre.vacation_days_taken,
|
||||
salaryRunStatus: run.status,
|
||||
}
|
||||
})
|
||||
.sort((a, b) => a.periodMonth - b.periodMonth || a.employeeName.localeCompare(b.employeeName))
|
||||
|
||||
const r = (x: number) => Math.round(x * 100) / 100
|
||||
const totals = {
|
||||
grossSalary: r(rows.reduce((s, r) => s + r.grossSalary, 0)),
|
||||
taxWithheld: r(rows.reduce((s, r) => s + r.taxWithheld, 0)),
|
||||
netSalary: r(rows.reduce((s, r) => s + r.netSalary, 0)),
|
||||
avgifterAmount: r(rows.reduce((s, r) => s + r.avgifterAmount, 0)),
|
||||
vacationAccrual: r(rows.reduce((s, r) => s + r.vacationAccrual, 0)),
|
||||
vacationAccrualAvgifter: r(rows.reduce((s, r) => s + r.vacationAccrualAvgifter, 0)),
|
||||
totalEmployerCost: r(rows.reduce((s, r) => s + r.totalEmployerCost, 0)),
|
||||
}
|
||||
|
||||
return { rows, totals, period: { year, monthFrom, monthTo } }
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
import type { SupabaseClient } from '@supabase/supabase-js'
|
||||
|
||||
/**
|
||||
* Semesterlöneskuld — Vacation liability report per BFNAR 2016:10.
|
||||
*
|
||||
* Per BFNAR 2016:10 kap 16: Vacation liability must be calculated per employee,
|
||||
* not as a lump sum. This report shows earned/taken days, accrued SEK amount
|
||||
* on account 2920, and accrued avgifter on account 2940.
|
||||
*
|
||||
* The report is required for year-end closing and ongoing monthly review.
|
||||
* Per BFL 7 kap: retained 7 years as part of räkenskapsinformation.
|
||||
*/
|
||||
|
||||
export interface VacationLiabilityRow {
|
||||
employeeId: string
|
||||
employeeName: string
|
||||
personnummerLast4: string
|
||||
vacationRule: string
|
||||
vacationDaysEntitled: number
|
||||
vacationDaysTaken: number
|
||||
vacationDaysRemaining: number
|
||||
vacationDaysSaved: number
|
||||
accruedAmount: number // Account 2920
|
||||
accruedAvgifter: number // Account 2940
|
||||
avgifterRate: number
|
||||
totalLiability: number // 2920 + 2940
|
||||
}
|
||||
|
||||
export interface VacationLiabilityReport {
|
||||
rows: VacationLiabilityRow[]
|
||||
totals: {
|
||||
accruedAmount: number // Sum for account 2920
|
||||
accruedAvgifter: number // Sum for account 2940
|
||||
totalLiability: number
|
||||
}
|
||||
asOfDate: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate vacation liability report.
|
||||
*
|
||||
* Aggregates vacation accruals from all booked salary runs in the year
|
||||
* and compares against vacation days taken.
|
||||
*/
|
||||
export async function generateVacationLiability(
|
||||
supabase: SupabaseClient,
|
||||
companyId: string,
|
||||
year: number
|
||||
): Promise<VacationLiabilityReport> {
|
||||
const r = (x: number) => Math.round(x * 100) / 100
|
||||
|
||||
// Load active employees
|
||||
const { data: employees, error: empError } = await supabase
|
||||
.from('employees')
|
||||
.select('id, first_name, last_name, personnummer_last4, vacation_rule, vacation_days_per_year, vacation_days_saved')
|
||||
.eq('company_id', companyId)
|
||||
.eq('is_active', true)
|
||||
.order('last_name')
|
||||
|
||||
if (empError) throw new Error(`Failed to load employees: ${empError.message}`)
|
||||
|
||||
// Load all salary run employees for booked runs this year
|
||||
const { data: runEmployees, error: sreError } = await supabase
|
||||
.from('salary_run_employees')
|
||||
.select(`
|
||||
employee_id,
|
||||
vacation_accrual,
|
||||
vacation_accrual_avgifter,
|
||||
avgifter_rate,
|
||||
vacation_days_taken,
|
||||
salary_run:salary_runs!inner(period_year, status)
|
||||
`)
|
||||
.eq('company_id', companyId)
|
||||
|
||||
if (sreError) throw new Error(`Failed to load salary run data: ${sreError.message}`)
|
||||
|
||||
// Filter to booked runs for the year
|
||||
const bookedForYear = (runEmployees || []).filter(sre => {
|
||||
const run = sre.salary_run as unknown as { period_year: number; status: string } | null
|
||||
return run && run.period_year === year && run.status === 'booked'
|
||||
})
|
||||
|
||||
// Aggregate per employee
|
||||
const accrualsByEmployee = new Map<string, {
|
||||
totalAccrual: number
|
||||
totalAvgifter: number
|
||||
totalDaysTaken: number
|
||||
lastRate: number
|
||||
}>()
|
||||
|
||||
for (const sre of bookedForYear) {
|
||||
const current = accrualsByEmployee.get(sre.employee_id) || {
|
||||
totalAccrual: 0, totalAvgifter: 0, totalDaysTaken: 0, lastRate: 0.3142,
|
||||
}
|
||||
current.totalAccrual += sre.vacation_accrual
|
||||
current.totalAvgifter += sre.vacation_accrual_avgifter
|
||||
current.totalDaysTaken += sre.vacation_days_taken
|
||||
current.lastRate = sre.avgifter_rate
|
||||
accrualsByEmployee.set(sre.employee_id, current)
|
||||
}
|
||||
|
||||
const rows: VacationLiabilityRow[] = (employees || []).map(emp => {
|
||||
const accruals = accrualsByEmployee.get(emp.id)
|
||||
const accruedAmount = r(accruals?.totalAccrual || 0)
|
||||
const accruedAvgifter = r(accruals?.totalAvgifter || 0)
|
||||
const daysTaken = accruals?.totalDaysTaken || 0
|
||||
|
||||
return {
|
||||
employeeId: emp.id,
|
||||
employeeName: `${emp.first_name} ${emp.last_name}`,
|
||||
personnummerLast4: emp.personnummer_last4,
|
||||
vacationRule: emp.vacation_rule,
|
||||
vacationDaysEntitled: emp.vacation_days_per_year,
|
||||
vacationDaysTaken: daysTaken,
|
||||
vacationDaysRemaining: emp.vacation_days_per_year - daysTaken,
|
||||
vacationDaysSaved: emp.vacation_days_saved,
|
||||
accruedAmount,
|
||||
accruedAvgifter,
|
||||
avgifterRate: accruals?.lastRate || 0.3142,
|
||||
totalLiability: r(accruedAmount + accruedAvgifter),
|
||||
}
|
||||
})
|
||||
|
||||
const totals = {
|
||||
accruedAmount: r(rows.reduce((s, row) => s + row.accruedAmount, 0)),
|
||||
accruedAvgifter: r(rows.reduce((s, row) => s + row.accruedAvgifter, 0)),
|
||||
totalLiability: r(rows.reduce((s, row) => s + row.totalLiability, 0)),
|
||||
}
|
||||
|
||||
return {
|
||||
rows,
|
||||
totals,
|
||||
asOfDate: `${year}-12-31`,
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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 & Sons <AB>')
|
||||
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')
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
})
|
||||
})
|
||||
@@ -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,
|
||||
}],
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -0,0 +1,99 @@
|
||||
/**
|
||||
* AGI (Arbetsgivardeklaration) field codes per Skatteverket Teknisk beskrivning.
|
||||
*
|
||||
* FK = Fältkod (field code)
|
||||
* Ruta = Box number on the form
|
||||
*/
|
||||
|
||||
// ============================================================
|
||||
// Huvuduppgift (Employer totals)
|
||||
// ============================================================
|
||||
|
||||
/** Employer-level field codes */
|
||||
export const HUVUDUPPGIFT_FIELDS = {
|
||||
/** Total skatteavdrag — sum of all employee tax withholdings */
|
||||
RUTA_001: '001',
|
||||
/** Total underlag for arbetsgivaravgifter */
|
||||
RUTA_020: '020',
|
||||
/** Arbetsgivaravgifter — standard rate (31.42%) */
|
||||
RUTA_060: '060',
|
||||
/** Arbetsgivaravgifter — age-reduced (10.21%, born ≤1959 for 2026) */
|
||||
RUTA_061: '061',
|
||||
/** Arbetsgivaravgifter — youth rate (20.81%, ages 19-23, Apr 2026–Sep 2027) */
|
||||
RUTA_062: '062',
|
||||
} as const
|
||||
|
||||
// ============================================================
|
||||
// Individuppgift (Per-employee data)
|
||||
// ============================================================
|
||||
|
||||
/** Per-employee field codes */
|
||||
export const INDIVID_FIELDS = {
|
||||
/** Personnummer/samordningsnummer (12 digits, CRITICAL: must be decrypted) */
|
||||
FK215: '215',
|
||||
/** Specifikationsnummer — MUST stay consistent per employee for corrections */
|
||||
FK570: '570',
|
||||
/** Kontant bruttolön (gross cash salary) */
|
||||
RUTA_011: '011',
|
||||
/** Avdragen skatt (withheld preliminary tax) */
|
||||
RUTA_001: '001',
|
||||
/** Förmånsvärde — bilförmån */
|
||||
RUTA_012: '012',
|
||||
/** Förmånsvärde — drivmedel vid bilförmån */
|
||||
RUTA_013: '013',
|
||||
/** Förmånsvärde — bostad */
|
||||
RUTA_014: '014',
|
||||
/** Förmånsvärde — kost */
|
||||
RUTA_015: '015',
|
||||
/** Förmånsvärde — ränta */
|
||||
RUTA_016: '016',
|
||||
/** Förmånsvärde — övriga */
|
||||
RUTA_019: '019',
|
||||
/** Underlag för arbetsgivaravgifter */
|
||||
RUTA_020: '020',
|
||||
/** Ersättning till mottagare med F-skattsedel (not subject to avgifter) */
|
||||
RUTA_131: '131',
|
||||
// Absence fields (from 2025)
|
||||
/** Sjukfrånvaro — antal dagar */
|
||||
FK821: '821',
|
||||
/** VAB — antal dagar */
|
||||
FK822: '822',
|
||||
/** Föräldraledighet — antal dagar */
|
||||
FK823: '823',
|
||||
/** Graviditetspenning — antal dagar */
|
||||
FK824: '824',
|
||||
/** Smittbärarpenning — antal dagar */
|
||||
FK825: '825',
|
||||
/** Sjuk-/aktivitetsersättning — antal dagar */
|
||||
FK826: '826',
|
||||
/** Rehabilitering — antal dagar */
|
||||
FK827: '827',
|
||||
} as const
|
||||
|
||||
// ============================================================
|
||||
// Benefit type to ruta mapping
|
||||
// ============================================================
|
||||
|
||||
/** Map benefit item types to AGI individuppgift rutor */
|
||||
export const BENEFIT_RUTA_MAP: Record<string, string> = {
|
||||
benefit_car: INDIVID_FIELDS.RUTA_012,
|
||||
benefit_housing: INDIVID_FIELDS.RUTA_014,
|
||||
benefit_meals: INDIVID_FIELDS.RUTA_015,
|
||||
benefit_wellness: INDIVID_FIELDS.RUTA_019,
|
||||
benefit_other: INDIVID_FIELDS.RUTA_019,
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Avgifter category to ruta mapping
|
||||
// ============================================================
|
||||
|
||||
export type AvgifterCategory = 'standard' | 'reduced_65plus' | 'youth' | 'vaxa_stod' | 'exempt'
|
||||
|
||||
/** Map avgifter categories to huvuduppgift rutor */
|
||||
export const AVGIFTER_RUTA_MAP: Record<AvgifterCategory, string> = {
|
||||
standard: HUVUDUPPGIFT_FIELDS.RUTA_060,
|
||||
reduced_65plus: HUVUDUPPGIFT_FIELDS.RUTA_061,
|
||||
youth: HUVUDUPPGIFT_FIELDS.RUTA_062,
|
||||
vaxa_stod: HUVUDUPPGIFT_FIELDS.RUTA_061, // Växa-stöd uses same rate as 65+
|
||||
exempt: '', // No avgifter
|
||||
}
|
||||
@@ -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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''')
|
||||
}
|
||||
|
||||
function formatAmount(amount: number): string {
|
||||
return Math.round(amount).toString()
|
||||
}
|
||||
@@ -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,
|
||||
}],
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
@@ -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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''')
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''')
|
||||
}
|
||||
|
||||
/** Format number as decimal with 2 decimal places (ISO 20022 requires dot separator) */
|
||||
function formatDecimal(amount: number): string {
|
||||
return (Math.round(amount * 100) / 100).toFixed(2)
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -0,0 +1,396 @@
|
||||
import { Document, Page, Text, View, StyleSheet } from '@react-pdf/renderer'
|
||||
|
||||
/**
|
||||
* Pay slip PDF template (Lönespecifikation).
|
||||
*
|
||||
* Legally required per BFL as räkenskapsinformation/underlag.
|
||||
* Subject to 7-year retention per BFL 7 kap.
|
||||
*
|
||||
* Contents:
|
||||
* - Company + employee identification
|
||||
* - Line items (salary, absence, benefits, deductions)
|
||||
* - Gross → Tax → Net summary with tax table reference
|
||||
* - Employer cost breakdown (avgifter, vacation accrual) — transparency feature
|
||||
* - YTD totals (cumulative year-to-date)
|
||||
* - Calculation breakdown (optional detail showing every formula step)
|
||||
*/
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
page: {
|
||||
fontFamily: 'Helvetica',
|
||||
fontSize: 9,
|
||||
padding: 40,
|
||||
color: '#1a1a1a',
|
||||
},
|
||||
header: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
marginBottom: 20,
|
||||
},
|
||||
title: {
|
||||
fontSize: 16,
|
||||
fontFamily: 'Helvetica-Bold',
|
||||
marginBottom: 4,
|
||||
},
|
||||
subtitle: {
|
||||
fontSize: 10,
|
||||
color: '#666',
|
||||
},
|
||||
companyName: {
|
||||
fontSize: 12,
|
||||
fontFamily: 'Helvetica-Bold',
|
||||
},
|
||||
companyInfo: {
|
||||
fontSize: 8,
|
||||
color: '#666',
|
||||
marginTop: 2,
|
||||
},
|
||||
section: {
|
||||
marginBottom: 14,
|
||||
},
|
||||
sectionTitle: {
|
||||
fontSize: 10,
|
||||
fontFamily: 'Helvetica-Bold',
|
||||
marginBottom: 6,
|
||||
paddingBottom: 3,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: '#e0e0e0',
|
||||
},
|
||||
row: {
|
||||
flexDirection: 'row',
|
||||
paddingVertical: 3,
|
||||
},
|
||||
rowAlt: {
|
||||
flexDirection: 'row',
|
||||
paddingVertical: 3,
|
||||
backgroundColor: '#f8f8f8',
|
||||
},
|
||||
headerRow: {
|
||||
flexDirection: 'row',
|
||||
paddingVertical: 4,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: '#ccc',
|
||||
marginBottom: 2,
|
||||
},
|
||||
colDesc: { flex: 3.5 },
|
||||
colQty: { flex: 1, textAlign: 'right' as const },
|
||||
colRate: { flex: 1.5, textAlign: 'right' as const },
|
||||
colAmount: { flex: 1.5, textAlign: 'right' as const },
|
||||
headerText: {
|
||||
fontSize: 8,
|
||||
fontFamily: 'Helvetica-Bold',
|
||||
color: '#666',
|
||||
textTransform: 'uppercase' as const,
|
||||
},
|
||||
summaryRow: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
paddingVertical: 3,
|
||||
},
|
||||
summaryLabel: {
|
||||
fontSize: 9,
|
||||
color: '#444',
|
||||
},
|
||||
summaryValue: {
|
||||
fontSize: 9,
|
||||
fontFamily: 'Helvetica-Bold',
|
||||
textAlign: 'right' as const,
|
||||
},
|
||||
totalRow: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
paddingVertical: 6,
|
||||
borderTopWidth: 2,
|
||||
borderTopColor: '#1a1a1a',
|
||||
marginTop: 4,
|
||||
},
|
||||
totalLabel: {
|
||||
fontSize: 11,
|
||||
fontFamily: 'Helvetica-Bold',
|
||||
},
|
||||
totalValue: {
|
||||
fontSize: 11,
|
||||
fontFamily: 'Helvetica-Bold',
|
||||
textAlign: 'right' as const,
|
||||
},
|
||||
infoGrid: {
|
||||
flexDirection: 'row',
|
||||
gap: 20,
|
||||
marginBottom: 14,
|
||||
},
|
||||
infoColumn: {
|
||||
flex: 1,
|
||||
},
|
||||
infoLabel: {
|
||||
fontSize: 7,
|
||||
color: '#999',
|
||||
textTransform: 'uppercase' as const,
|
||||
marginBottom: 1,
|
||||
},
|
||||
infoValue: {
|
||||
fontSize: 9,
|
||||
marginBottom: 6,
|
||||
},
|
||||
breakdownSection: {
|
||||
marginTop: 10,
|
||||
padding: 10,
|
||||
backgroundColor: '#f5f5f5',
|
||||
borderRadius: 3,
|
||||
},
|
||||
breakdownTitle: {
|
||||
fontSize: 9,
|
||||
fontFamily: 'Helvetica-Bold',
|
||||
marginBottom: 6,
|
||||
},
|
||||
breakdownRow: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
paddingVertical: 1.5,
|
||||
},
|
||||
breakdownLabel: {
|
||||
fontSize: 7.5,
|
||||
color: '#555',
|
||||
flex: 3,
|
||||
},
|
||||
breakdownFormula: {
|
||||
fontSize: 7,
|
||||
color: '#888',
|
||||
flex: 3,
|
||||
fontFamily: 'Courier',
|
||||
},
|
||||
breakdownValue: {
|
||||
fontSize: 7.5,
|
||||
textAlign: 'right' as const,
|
||||
flex: 1.5,
|
||||
},
|
||||
footer: {
|
||||
position: 'absolute' as const,
|
||||
bottom: 30,
|
||||
left: 40,
|
||||
right: 40,
|
||||
borderTopWidth: 1,
|
||||
borderTopColor: '#e0e0e0',
|
||||
paddingTop: 6,
|
||||
fontSize: 7,
|
||||
color: '#999',
|
||||
textAlign: 'center' as const,
|
||||
},
|
||||
ytdSection: {
|
||||
marginTop: 10,
|
||||
},
|
||||
ytdRow: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
paddingVertical: 2,
|
||||
},
|
||||
ytdLabel: {
|
||||
fontSize: 8,
|
||||
color: '#666',
|
||||
},
|
||||
ytdValue: {
|
||||
fontSize: 8,
|
||||
color: '#666',
|
||||
textAlign: 'right' as const,
|
||||
},
|
||||
})
|
||||
|
||||
export interface PayslipData {
|
||||
// Company
|
||||
companyName: string
|
||||
companyOrgNumber: string
|
||||
companyAddress?: string
|
||||
|
||||
// Employee
|
||||
employeeName: string
|
||||
personnummerMasked: string // XXXXXXXX-XXXX
|
||||
employmentType: string
|
||||
|
||||
// Period
|
||||
periodYear: number
|
||||
periodMonth: number
|
||||
paymentDate: string
|
||||
|
||||
// Line items
|
||||
lineItems: PayslipLineItem[]
|
||||
|
||||
// Summary
|
||||
grossSalary: number
|
||||
taxWithheld: number
|
||||
netSalary: number
|
||||
taxReference: string // e.g. "Tabell 33, kolumn 1"
|
||||
|
||||
// Employer cost (transparency feature)
|
||||
avgifterRate: number
|
||||
avgifterAmount: number
|
||||
vacationAccrual: number
|
||||
vacationAccrualAvgifter: number
|
||||
totalEmployerCost: number
|
||||
|
||||
// YTD
|
||||
ytdGross: number
|
||||
ytdTax: number
|
||||
ytdNet: number
|
||||
|
||||
// Bank
|
||||
bankAccount?: string // masked
|
||||
|
||||
// Calculation breakdown (optional)
|
||||
breakdownSteps?: { label: string; formula: string; output: number }[]
|
||||
}
|
||||
|
||||
export interface PayslipLineItem {
|
||||
description: string
|
||||
quantity?: number
|
||||
unitPrice?: number
|
||||
amount: number
|
||||
}
|
||||
|
||||
function fmt(amount: number): string {
|
||||
return new Intl.NumberFormat('sv-SE', { minimumFractionDigits: 2, maximumFractionDigits: 2 }).format(amount)
|
||||
}
|
||||
|
||||
const MONTH_NAMES = [
|
||||
'januari', 'februari', 'mars', 'april', 'maj', 'juni',
|
||||
'juli', 'augusti', 'september', 'oktober', 'november', 'december',
|
||||
]
|
||||
|
||||
export function PayslipPDF({ data }: { data: PayslipData }) {
|
||||
const periodLabel = `${MONTH_NAMES[data.periodMonth - 1]} ${data.periodYear}`
|
||||
|
||||
return (
|
||||
<Document>
|
||||
<Page size="A4" style={styles.page}>
|
||||
{/* Header */}
|
||||
<View style={styles.header}>
|
||||
<View>
|
||||
<Text style={styles.title}>Lönespecifikation</Text>
|
||||
<Text style={styles.subtitle}>{periodLabel}</Text>
|
||||
</View>
|
||||
<View style={{ alignItems: 'flex-end' as const }}>
|
||||
<Text style={styles.companyName}>{data.companyName}</Text>
|
||||
<Text style={styles.companyInfo}>Org.nr {data.companyOrgNumber}</Text>
|
||||
{data.companyAddress && <Text style={styles.companyInfo}>{data.companyAddress}</Text>}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Employee + Period info */}
|
||||
<View style={styles.infoGrid}>
|
||||
<View style={styles.infoColumn}>
|
||||
<Text style={styles.infoLabel}>Anställd</Text>
|
||||
<Text style={styles.infoValue}>{data.employeeName}</Text>
|
||||
<Text style={styles.infoLabel}>Personnummer</Text>
|
||||
<Text style={styles.infoValue}>{data.personnummerMasked}</Text>
|
||||
</View>
|
||||
<View style={styles.infoColumn}>
|
||||
<Text style={styles.infoLabel}>Period</Text>
|
||||
<Text style={styles.infoValue}>{periodLabel}</Text>
|
||||
<Text style={styles.infoLabel}>Utbetalningsdag</Text>
|
||||
<Text style={styles.infoValue}>{data.paymentDate}</Text>
|
||||
</View>
|
||||
<View style={styles.infoColumn}>
|
||||
<Text style={styles.infoLabel}>Skattetabell</Text>
|
||||
<Text style={styles.infoValue}>{data.taxReference}</Text>
|
||||
{data.bankAccount && (
|
||||
<>
|
||||
<Text style={styles.infoLabel}>Bankkonto</Text>
|
||||
<Text style={styles.infoValue}>{data.bankAccount}</Text>
|
||||
</>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Line items table */}
|
||||
<View style={styles.section}>
|
||||
<Text style={styles.sectionTitle}>Lönespecifikation</Text>
|
||||
<View style={styles.headerRow}>
|
||||
<Text style={[styles.headerText, styles.colDesc]}>Beskrivning</Text>
|
||||
<Text style={[styles.headerText, styles.colQty]}>Antal</Text>
|
||||
<Text style={[styles.headerText, styles.colRate]}>á-pris</Text>
|
||||
<Text style={[styles.headerText, styles.colAmount]}>Belopp</Text>
|
||||
</View>
|
||||
{data.lineItems.map((item, i) => (
|
||||
<View key={i} style={i % 2 === 1 ? styles.rowAlt : styles.row}>
|
||||
<Text style={styles.colDesc}>{item.description}</Text>
|
||||
<Text style={styles.colQty}>{item.quantity != null ? item.quantity : ''}</Text>
|
||||
<Text style={styles.colRate}>{item.unitPrice != null ? fmt(item.unitPrice) : ''}</Text>
|
||||
<Text style={styles.colAmount}>{fmt(item.amount)}</Text>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
|
||||
{/* Summary: Gross → Tax → Net */}
|
||||
<View style={styles.section}>
|
||||
<View style={styles.summaryRow}>
|
||||
<Text style={styles.summaryLabel}>Bruttolön</Text>
|
||||
<Text style={styles.summaryValue}>{fmt(data.grossSalary)}</Text>
|
||||
</View>
|
||||
<View style={styles.summaryRow}>
|
||||
<Text style={styles.summaryLabel}>Preliminär skatt ({data.taxReference})</Text>
|
||||
<Text style={styles.summaryValue}>−{fmt(data.taxWithheld)}</Text>
|
||||
</View>
|
||||
<View style={styles.totalRow}>
|
||||
<Text style={styles.totalLabel}>Nettolön (utbetalas)</Text>
|
||||
<Text style={styles.totalValue}>{fmt(data.netSalary)}</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Employer cost (transparency feature — our differentiator) */}
|
||||
<View style={styles.section}>
|
||||
<Text style={styles.sectionTitle}>Arbetsgivarkostnad</Text>
|
||||
<View style={styles.summaryRow}>
|
||||
<Text style={styles.summaryLabel}>Arbetsgivaravgifter ({(data.avgifterRate * 100).toFixed(2)}%)</Text>
|
||||
<Text style={styles.summaryValue}>{fmt(data.avgifterAmount)}</Text>
|
||||
</View>
|
||||
<View style={styles.summaryRow}>
|
||||
<Text style={styles.summaryLabel}>Semesteravsättning</Text>
|
||||
<Text style={styles.summaryValue}>{fmt(data.vacationAccrual)}</Text>
|
||||
</View>
|
||||
<View style={styles.summaryRow}>
|
||||
<Text style={styles.summaryLabel}>Sociala avgifter på semester</Text>
|
||||
<Text style={styles.summaryValue}>{fmt(data.vacationAccrualAvgifter)}</Text>
|
||||
</View>
|
||||
<View style={styles.totalRow}>
|
||||
<Text style={styles.totalLabel}>Total arbetsgivarkostnad</Text>
|
||||
<Text style={styles.totalValue}>{fmt(data.totalEmployerCost)}</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* YTD */}
|
||||
<View style={styles.ytdSection}>
|
||||
<Text style={[styles.sectionTitle, { fontSize: 9 }]}>Ackumulerat {data.periodYear}</Text>
|
||||
<View style={styles.ytdRow}>
|
||||
<Text style={styles.ytdLabel}>Brutto</Text>
|
||||
<Text style={styles.ytdValue}>{fmt(data.ytdGross)}</Text>
|
||||
</View>
|
||||
<View style={styles.ytdRow}>
|
||||
<Text style={styles.ytdLabel}>Skatt</Text>
|
||||
<Text style={styles.ytdValue}>{fmt(data.ytdTax)}</Text>
|
||||
</View>
|
||||
<View style={styles.ytdRow}>
|
||||
<Text style={styles.ytdLabel}>Netto</Text>
|
||||
<Text style={styles.ytdValue}>{fmt(data.ytdNet)}</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Calculation breakdown (optional detail page) */}
|
||||
{data.breakdownSteps && data.breakdownSteps.length > 0 && (
|
||||
<View style={styles.breakdownSection}>
|
||||
<Text style={styles.breakdownTitle}>Beräkningsunderlag</Text>
|
||||
{data.breakdownSteps.map((step, i) => (
|
||||
<View key={i} style={styles.breakdownRow}>
|
||||
<Text style={styles.breakdownLabel}>{step.label}</Text>
|
||||
<Text style={styles.breakdownFormula}>{step.formula}</Text>
|
||||
<Text style={styles.breakdownValue}>{fmt(step.output)}</Text>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Footer */}
|
||||
<Text style={styles.footer}>
|
||||
{data.companyName} · Org.nr {data.companyOrgNumber} · Lönespecifikation {periodLabel} · Genererad av gnubok
|
||||
</Text>
|
||||
</Page>
|
||||
</Document>
|
||||
)
|
||||
}
|
||||
@@ -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)}`
|
||||
}
|
||||
@@ -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}`
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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,
|
||||
}],
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user