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',
|
||||
},
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user