Salary module improvements (#250)

* 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

* feat: enhance employee management with salary type, tax status, and validation improvements

* feat: Implement AGI submission flow to Skatteverket

- Added AGI submission route to handle the submission process.
- Created AGI client for interacting with Skatteverket's API.
- Introduced AGI mappers to convert salary run data into the required AGI JSON payload format.
- Enhanced API client to support custom base URLs for Skatteverket API requests.
- Added types for AGI submission payload and validation results.
- Implemented tests for AGI mappers to ensure correct payload structure and data handling.

* feat: enhance salary module with Skatteverket integration and update dashboard navigation

* Update app/api/salary/runs/[id]/agi/submit/route.ts

Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>

* Update app/api/salary/runs/[id]/approve/route.ts

Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>

* feat: integrate write permission check and remove Skatteverket extension

---------

Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
This commit is contained in:
Mattsson
2026-04-15 20:55:29 +02:00
committed by GitHub
co-authored by greptile-apps[bot]
parent 24466c6e94
commit bb0db7a588
16 changed files with 1987 additions and 57 deletions
+228 -21
View File
@@ -12,7 +12,6 @@ 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> = {
@@ -21,6 +20,17 @@ const EMPLOYMENT_LABELS: Record<string, string> = {
board_member: 'Styrelseledamot',
}
const F_SKATT_LABELS: Record<string, string> = {
a_skatt: 'A-skatt',
f_skatt: 'F-skatt',
fa_skatt: 'FA-skatt',
not_verified: 'Ej verifierad',
}
function RequiredMark() {
return <span className="text-destructive ml-0.5">*</span>
}
export default function EmployeeDetailPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = use(params)
const router = useRouter()
@@ -30,6 +40,12 @@ export default function EmployeeDetailPage({ params }: { params: Promise<{ id: s
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
const [employmentType, setEmploymentType] = useState('employee')
const [salaryType, setSalaryType] = useState('monthly')
const [fSkattStatus, setFSkattStatus] = useState('a_skatt')
const [isSidoinkomst, setIsSidoinkomst] = useState(false)
const [vacationRule, setVacationRule] = useState('procentregeln')
const requiresTaxTable = fSkattStatus === 'a_skatt' && !isSidoinkomst
useEffect(() => {
async function load() {
@@ -38,6 +54,10 @@ export default function EmployeeDetailPage({ params }: { params: Promise<{ id: s
const { data } = await res.json()
setEmployee(data)
setEmploymentType(data.employment_type)
setSalaryType(data.salary_type || 'monthly')
setFSkattStatus(data.f_skatt_status || 'a_skatt')
setIsSidoinkomst(data.is_sidoinkomst || false)
setVacationRule(data.vacation_rule || 'procentregeln')
}
setLoading(false)
}
@@ -49,19 +69,33 @@ export default function EmployeeDetailPage({ params }: { params: Promise<{ id: s
setSaving(true)
const form = new FormData(e.currentTarget)
const body = {
const body: Record<string, unknown> = {
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,
salary_type: salaryType,
f_skatt_status: fSkattStatus,
is_sidoinkomst: isSidoinkomst,
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,
address_line1: form.get('address_line1') as string || undefined,
postal_code: form.get('postal_code') as string || undefined,
city: form.get('city') as string || undefined,
clearing_number: form.get('clearing_number') as string || undefined,
bank_account_number: form.get('bank_account_number') as string || undefined,
vacation_rule: vacationRule,
vacation_days_per_year: parseInt(form.get('vacation_days_per_year') as string) || 25,
}
// Include salary field matching the current salary_type
if (salaryType === 'monthly') {
body.monthly_salary = parseFloat(form.get('monthly_salary') as string) || undefined
} else {
body.hourly_rate = parseFloat(form.get('hourly_rate') as string) || undefined
}
const res = await fetch(`/api/salary/employees/${id}`, {
@@ -134,22 +168,66 @@ export default function EmployeeDetailPage({ params }: { params: Promise<{ id: s
</div>
<form onSubmit={handleSave} className="space-y-6">
{/* Personal info */}
<Card>
<CardHeader>
<CardTitle className="text-base">Uppgifter</CardTitle>
<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>
<Label htmlFor="first_name">Förnamn<RequiredMark /></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>
<Label htmlFor="last_name">Efternamn<RequiredMark /></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="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} />
<p className="text-xs text-muted-foreground">Krävs för att skicka lönebesked</p>
</div>
<div className="space-y-2">
<Label htmlFor="phone">Telefon</Label>
<Input id="phone" name="phone" defaultValue={employee.phone || ''} disabled={!canWrite} />
</div>
</div>
</CardContent>
</Card>
{/* Address */}
<Card>
<CardHeader>
<CardTitle className="text-base">Adress</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label htmlFor="address_line1">Gatuadress</Label>
<Input id="address_line1" name="address_line1" defaultValue={employee.address_line1 || ''} disabled={!canWrite} />
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="postal_code">Postnummer</Label>
<Input id="postal_code" name="postal_code" defaultValue={employee.postal_code || ''} className="max-w-[160px]" disabled={!canWrite} />
</div>
<div className="space-y-2">
<Label htmlFor="city">Ort</Label>
<Input id="city" name="city" defaultValue={employee.city || ''} disabled={!canWrite} />
</div>
</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-2 gap-4">
<div className="space-y-2">
<Label htmlFor="employment_type">Typ</Label>
<Select value={employmentType} onValueChange={setEmploymentType} disabled={!canWrite}>
@@ -165,37 +243,165 @@ export default function EmployeeDetailPage({ params }: { params: Promise<{ id: s
</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} />
<Input id="employment_degree" name="employment_degree" type="number" defaultValue={employee.employment_degree} min="1" max="100" disabled={!canWrite} />
</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-2 gap-4">
<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} />
<Label htmlFor="salary_type">Löneform<RequiredMark /></Label>
<Select value={salaryType} onValueChange={setSalaryType} disabled={!canWrite}>
<SelectTrigger id="salary_type">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="monthly">Månadslön</SelectItem>
<SelectItem value="hourly">Timlön</SelectItem>
</SelectContent>
</Select>
</div>
{salaryType === 'monthly' ? (
<div className="space-y-2">
<Label htmlFor="monthly_salary">Månadslön (brutto, SEK)<RequiredMark /></Label>
<Input id="monthly_salary" name="monthly_salary" type="number" step="1" min="1" defaultValue={employee.monthly_salary || ''} required disabled={!canWrite} />
</div>
) : (
<div className="space-y-2">
<Label htmlFor="hourly_rate">Timlön (SEK)<RequiredMark /></Label>
<Input id="hourly_rate" name="hourly_rate" type="number" step="0.01" min="0.01" defaultValue={employee.hourly_rate || ''} required disabled={!canWrite} />
</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-2 gap-4">
<div className="space-y-2">
<Label htmlFor="f_skatt_status">Skatteform</Label>
<Select value={fSkattStatus} onValueChange={setFSkattStatus} disabled={!canWrite}>
<SelectTrigger id="f_skatt_status">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="a_skatt">A-skatt</SelectItem>
<SelectItem value="f_skatt">F-skatt</SelectItem>
<SelectItem value="fa_skatt">FA-skatt</SelectItem>
<SelectItem value="not_verified">Ej verifierad</SelectItem>
</SelectContent>
</Select>
{employee.f_skatt_verified_at && (
<p className="text-xs text-muted-foreground">
Verifierad: {new Date(employee.f_skatt_verified_at).toLocaleDateString('sv-SE')}
</p>
)}
</div>
<div className="flex items-end pb-2">
<label className="flex items-center gap-2 text-sm">
<input
type="checkbox"
checked={isSidoinkomst}
onChange={(e) => setIsSidoinkomst(e.target.checked)}
disabled={!canWrite}
className="rounded border-border"
/>
Sidoinkomst (30% skatteavdrag)
</label>
</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} />
<Label htmlFor="tax_table_number">
Skattetabell (29-42){requiresTaxTable && <RequiredMark />}
</Label>
<Input
id="tax_table_number"
name="tax_table_number"
type="number"
min="29"
max="42"
defaultValue={employee.tax_table_number || ''}
required={requiresTaxTable}
disabled={!canWrite}
/>
<p className="text-xs text-muted-foreground">Baseras på folkbokföringskommun</p>
</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} />
<Label htmlFor="tax_column">Kolumn (1-6)</Label>
<Input id="tax_column" name="tax_column" type="number" defaultValue={employee.tax_column} min="1" max="6" disabled={!canWrite} />
<p className="text-xs text-muted-foreground">1 = standard under 66 år</p>
</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} />
<Label htmlFor="tax_municipality">
Folkbokföringskommun{requiresTaxTable && <RequiredMark />}
</Label>
<Input
id="tax_municipality"
name="tax_municipality"
defaultValue={employee.tax_municipality || ''}
required={requiresTaxTable}
disabled={!canWrite}
/>
</div>
</div>
</CardContent>
</Card>
{/* Vacation */}
<Card>
<CardHeader>
<CardTitle className="text-base">Semester</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="email">E-post</Label>
<Input id="email" name="email" type="email" defaultValue={employee.email || ''} disabled={!canWrite} />
<Label htmlFor="vacation_rule">Semesterregel</Label>
<Select value={vacationRule} onValueChange={setVacationRule} disabled={!canWrite}>
<SelectTrigger id="vacation_rule">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="procentregeln">Procentregeln (12%)</SelectItem>
<SelectItem value="sammaloneregeln">Sammalöneregeln</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="phone">Telefon</Label>
<Input id="phone" name="phone" defaultValue={employee.phone || ''} disabled={!canWrite} />
<Label htmlFor="vacation_days_per_year">Semesterdagar per år</Label>
<Input
id="vacation_days_per_year"
name="vacation_days_per_year"
type="number"
min="25"
max="40"
defaultValue={employee.vacation_days_per_year}
disabled={!canWrite}
/>
<p className="text-xs text-muted-foreground">Lagstadgat minimum: 25 dagar</p>
</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>
@@ -206,6 +412,7 @@ export default function EmployeeDetailPage({ params }: { params: Promise<{ id: s
<Input id="bank_account_number" name="bank_account_number" defaultValue={employee.bank_account_number || ''} disabled={!canWrite} />
</div>
</div>
<p className="text-xs text-muted-foreground mt-2">Krävs innan lönekörning kan godkännas</p>
</CardContent>
</Card>
+99 -20
View File
@@ -12,12 +12,20 @@ import { ArrowLeft, Save } from 'lucide-react'
import { useToast } from '@/components/ui/use-toast'
import { getErrorMessage } from '@/lib/errors/get-error-message'
function RequiredMark() {
return <span className="text-destructive ml-0.5">*</span>
}
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')
const [fSkattStatus, setFSkattStatus] = useState('a_skatt')
const [isSidoinkomst, setIsSidoinkomst] = useState(false)
const requiresTaxTable = fSkattStatus === 'a_skatt' && !isSidoinkomst
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault()
@@ -32,13 +40,18 @@ export default function NewEmployeePage() {
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,
monthly_salary: salaryType === 'monthly' ? (parseFloat(form.get('monthly_salary') as string) || undefined) : undefined,
hourly_rate: salaryType === 'hourly' ? (parseFloat(form.get('hourly_rate') as string) || undefined) : undefined,
f_skatt_status: fSkattStatus,
is_sidoinkomst: isSidoinkomst,
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,
address_line1: form.get('address_line1') as string || undefined,
postal_code: form.get('postal_code') as string || undefined,
city: form.get('city') as string || undefined,
clearing_number: form.get('clearing_number') as string || undefined,
bank_account_number: form.get('bank_account_number') as string || undefined,
}
@@ -82,23 +95,24 @@ export default function NewEmployeePage() {
<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>
<Label htmlFor="first_name">Förnamn<RequiredMark /></Label>
<Input id="first_name" name="first_name" required />
</div>
<div className="space-y-2">
<Label htmlFor="last_name">Efternamn</Label>
<Label htmlFor="last_name">Efternamn<RequiredMark /></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>
<Label htmlFor="personnummer">Personnummer (12 siffror)<RequiredMark /></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" />
<p className="text-xs text-muted-foreground">Krävs för att skicka lönebesked</p>
</div>
</div>
<div className="space-y-2">
@@ -108,6 +122,29 @@ export default function NewEmployeePage() {
</CardContent>
</Card>
{/* Address */}
<Card>
<CardHeader>
<CardTitle className="text-base">Adress</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label htmlFor="address_line1">Gatuadress</Label>
<Input id="address_line1" name="address_line1" />
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="postal_code">Postnummer</Label>
<Input id="postal_code" name="postal_code" className="max-w-[160px]" />
</div>
<div className="space-y-2">
<Label htmlFor="city">Ort</Label>
<Input id="city" name="city" />
</div>
</div>
</CardContent>
</Card>
{/* Employment */}
<Card>
<CardHeader>
@@ -129,7 +166,7 @@ export default function NewEmployeePage() {
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="employment_start">Anställningsdatum</Label>
<Label htmlFor="employment_start">Anställningsdatum<RequiredMark /></Label>
<Input id="employment_start" name="employment_start" type="date" required />
</div>
<div className="space-y-2">
@@ -146,9 +183,9 @@ export default function NewEmployeePage() {
<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="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="salary_type">Löneform</Label>
<Label htmlFor="salary_type">Löneform<RequiredMark /></Label>
<Select value={salaryType} onValueChange={setSalaryType}>
<SelectTrigger id="salary_type">
<SelectValue />
@@ -159,14 +196,17 @@ export default function NewEmployeePage() {
</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>
{salaryType === 'monthly' ? (
<div className="space-y-2">
<Label htmlFor="monthly_salary">Månadslön (brutto, SEK)<RequiredMark /></Label>
<Input id="monthly_salary" name="monthly_salary" type="number" step="1" min="1" required />
</div>
) : (
<div className="space-y-2">
<Label htmlFor="hourly_rate">Timlön (SEK)<RequiredMark /></Label>
<Input id="hourly_rate" name="hourly_rate" type="number" step="0.01" min="0.01" required />
</div>
)}
</div>
</CardContent>
</Card>
@@ -177,10 +217,46 @@ export default function NewEmployeePage() {
<CardTitle className="text-base">Skatt</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="f_skatt_status">Skatteform</Label>
<Select value={fSkattStatus} onValueChange={setFSkattStatus}>
<SelectTrigger id="f_skatt_status">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="a_skatt">A-skatt</SelectItem>
<SelectItem value="f_skatt">F-skatt</SelectItem>
<SelectItem value="fa_skatt">FA-skatt</SelectItem>
<SelectItem value="not_verified">Ej verifierad</SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex items-end pb-2">
<label className="flex items-center gap-2 text-sm">
<input
type="checkbox"
checked={isSidoinkomst}
onChange={(e) => setIsSidoinkomst(e.target.checked)}
className="rounded border-border"
/>
Sidoinkomst (30% skatteavdrag)
</label>
</div>
</div>
<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" />
<Label htmlFor="tax_table_number">
Skattetabell (29-42){requiresTaxTable && <RequiredMark />}
</Label>
<Input
id="tax_table_number"
name="tax_table_number"
type="number"
min="29"
max="42"
required={requiresTaxTable}
/>
<p className="text-xs text-muted-foreground">Baseras på folkbokföringskommun</p>
</div>
<div className="space-y-2">
@@ -189,8 +265,10 @@ export default function NewEmployeePage() {
<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" />
<Label htmlFor="tax_municipality">
Folkbokföringskommun{requiresTaxTable && <RequiredMark />}
</Label>
<Input id="tax_municipality" name="tax_municipality" required={requiresTaxTable} />
</div>
</div>
</CardContent>
@@ -212,6 +290,7 @@ export default function NewEmployeePage() {
<Input id="bank_account_number" name="bank_account_number" />
</div>
</div>
<p className="text-xs text-muted-foreground mt-2">Krävs innan lönekörning kan godkännas</p>
</CardContent>
</Card>
+19 -2
View File
@@ -57,10 +57,10 @@ export async function PATCH(
if (!validation.success) return validation.response
const body = validation.data
// Check employee exists
// Load existing employee for merged validation
const { data: existing, error: fetchError } = await supabase
.from('employees')
.select('id')
.select('*')
.eq('id', id)
.eq('company_id', companyId)
.single()
@@ -69,6 +69,23 @@ export async function PATCH(
return NextResponse.json({ error: 'Anställd hittades inte' }, { status: 404 })
}
// Merged validation: combine existing + updates to check full integrity
const merged = { ...existing, ...body }
const mergedErrors: string[] = []
if (merged.salary_type === 'monthly' && (!merged.monthly_salary || merged.monthly_salary <= 0)) {
mergedErrors.push('Månadslön krävs och måste vara större än 0 för månadslöneform')
}
if (merged.salary_type === 'hourly' && (!merged.hourly_rate || merged.hourly_rate <= 0)) {
mergedErrors.push('Timlön krävs och måste vara större än 0 för timlöneform')
}
if (merged.f_skatt_status === 'a_skatt' && !merged.is_sidoinkomst && !merged.tax_table_number) {
mergedErrors.push('Skattetabell krävs för A-skatt anställda')
}
if (mergedErrors.length > 0) {
return NextResponse.json({ error: mergedErrors.join('. ') }, { status: 400 })
}
// Build update object
const updates: Record<string, unknown> = { ...body }
@@ -0,0 +1,264 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import {
createQueuedMockSupabase,
createMockRequest,
parseJsonResponse,
createMockRouteParams,
} from '@/tests/helpers'
// ── Mocks ────────────────────────────────────────────────────
const mockCreateClient = vi.fn()
vi.mock('@/lib/supabase/server', () => ({
createClient: () => mockCreateClient(),
}))
vi.mock('@/lib/init', () => ({
ensureInitialized: vi.fn(),
}))
vi.mock('@/lib/auth/require-write', () => ({
requireWritePermission: vi.fn().mockResolvedValue({ ok: true }),
}))
vi.mock('@/lib/company/context', () => ({
requireCompanyId: vi.fn().mockResolvedValue('company-1'),
}))
vi.mock('@/lib/events', () => ({
eventBus: { emit: vi.fn().mockResolvedValue(undefined) },
}))
// Mock fetch for the internal extension API call
const mockFetch = vi.fn()
vi.stubGlobal('fetch', mockFetch)
import { POST } from '../route'
import { eventBus } from '@/lib/events'
// ── Test data ────────────────────────────────────────────────
const mockUser = { id: 'user-1', email: 'test@test.se' }
const makeSalaryRun = (overrides = {}) => ({
id: 'run-1',
company_id: 'company-1',
period_year: 2026,
period_month: 3,
status: 'approved',
total_gross: 35000,
total_tax: 8000,
total_net: 27000,
total_avgifter: 10997,
total_vacation_accrual: 4200,
total_employer_cost: 50197,
payment_date: '2026-03-25',
agi_generated_at: '2026-03-20T10:00:00Z',
agi_submitted_at: null,
...overrides,
})
const makeAgiDeclaration = (overrides = {}) => ({
id: 'agi-1',
status: 'generated',
...overrides,
})
// ── Tests ────────────────────────────────────────────────────
describe('POST /api/salary/runs/[id]/agi/submit', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('returns 401 when not authenticated', async () => {
mockCreateClient.mockResolvedValue({
auth: { getUser: vi.fn().mockResolvedValue({ data: { user: null } }) },
})
const request = createMockRequest('/api/salary/runs/run-1/agi/submit', { method: 'POST' })
const response = await POST(request, createMockRouteParams({ id: 'run-1' }))
const { status, body } = await parseJsonResponse(response)
expect(status).toBe(401)
expect(body).toEqual({ error: 'Unauthorized' })
})
it('returns 404 when salary run not found', async () => {
const { supabase, enqueueMany } = createQueuedMockSupabase()
supabase.auth = { getUser: vi.fn().mockResolvedValue({ data: { user: mockUser } }) }
mockCreateClient.mockResolvedValue(supabase)
enqueueMany([
{ data: null, error: { message: 'Not found' } }, // salary_runs query
])
const request = createMockRequest('/api/salary/runs/run-1/agi/submit', { method: 'POST' })
const response = await POST(request, createMockRouteParams({ id: 'run-1' }))
const { status, body } = await parseJsonResponse<{ error: string }>(response)
expect(status).toBe(404)
expect(body.error).toContain('hittades inte')
})
it('returns 400 when salary run is in draft status', async () => {
const { supabase, enqueueMany } = createQueuedMockSupabase()
supabase.auth = { getUser: vi.fn().mockResolvedValue({ data: { user: mockUser } }) }
mockCreateClient.mockResolvedValue(supabase)
enqueueMany([
{ data: makeSalaryRun({ status: 'draft' }) }, // salary_runs query
])
const request = createMockRequest('/api/salary/runs/run-1/agi/submit', { method: 'POST' })
const response = await POST(request, createMockRouteParams({ id: 'run-1' }))
const { status, body } = await parseJsonResponse<{ error: string }>(response)
expect(status).toBe(400)
expect(body.error).toContain('efter granskning')
})
it('returns 400 when AGI has not been generated', async () => {
const { supabase, enqueueMany } = createQueuedMockSupabase()
supabase.auth = { getUser: vi.fn().mockResolvedValue({ data: { user: mockUser } }) }
mockCreateClient.mockResolvedValue(supabase)
enqueueMany([
{ data: makeSalaryRun() }, // salary_runs query
{ data: null }, // agi_declarations query (not found)
])
const request = createMockRequest('/api/salary/runs/run-1/agi/submit', { method: 'POST' })
const response = await POST(request, createMockRouteParams({ id: 'run-1' }))
const { status, body } = await parseJsonResponse<{ error: string }>(response)
expect(status).toBe(400)
expect(body.error).toContain('inte genererats')
})
it('returns 409 when AGI has already been submitted', async () => {
const { supabase, enqueueMany } = createQueuedMockSupabase()
supabase.auth = { getUser: vi.fn().mockResolvedValue({ data: { user: mockUser } }) }
mockCreateClient.mockResolvedValue(supabase)
enqueueMany([
{ data: makeSalaryRun() },
{ data: makeAgiDeclaration({ status: 'submitted' }) },
])
const request = createMockRequest('/api/salary/runs/run-1/agi/submit', { method: 'POST' })
const response = await POST(request, createMockRouteParams({ id: 'run-1' }))
const { status, body } = await parseJsonResponse<{ error: string }>(response)
expect(status).toBe(409)
expect(body.error).toContain('redan skickats')
})
it('submits AGI draft and returns success', async () => {
const { supabase, enqueueMany } = createQueuedMockSupabase()
supabase.auth = { getUser: vi.fn().mockResolvedValue({ data: { user: mockUser } }) }
mockCreateClient.mockResolvedValue(supabase)
enqueueMany([
{ data: makeSalaryRun() }, // salary_runs query
{ data: makeAgiDeclaration() }, // agi_declarations query
{ data: null }, // salary_runs update (agi_submitted_at)
])
// Mock the internal fetch to extension endpoint
mockFetch.mockResolvedValue({
ok: true,
status: 200,
json: async () => ({
data: {
inlamningId: 'inl-123',
kontrollresultat: { kontroller: [] },
},
}),
})
const request = createMockRequest('/api/salary/runs/run-1/agi/submit', { method: 'POST' })
const response = await POST(request, createMockRouteParams({ id: 'run-1' }))
const { status, body } = await parseJsonResponse<{ data: Record<string, unknown> }>(response)
expect(status).toBe(200)
expect(body.data.inlamningId).toBe('inl-123')
expect(body.data.salaryRunId).toBe('run-1')
expect(body.data.periodYear).toBe(2026)
expect(body.data.periodMonth).toBe(3)
expect(body.data.message).toContain('utkast')
// Verify the extension endpoint was called correctly
expect(mockFetch).toHaveBeenCalledWith(
expect.stringContaining('/api/extensions/ext/skatteverket/agi/draft'),
expect.objectContaining({
method: 'POST',
body: JSON.stringify({ salaryRunId: 'run-1' }),
})
)
// Verify event emitted
expect(eventBus.emit).toHaveBeenCalledWith(
expect.objectContaining({
type: 'agi.submitted',
payload: expect.objectContaining({
salaryRunId: 'run-1',
periodYear: 2026,
periodMonth: 3,
companyId: 'company-1',
}),
})
)
})
it('returns error when extension draft endpoint fails', async () => {
const { supabase, enqueueMany } = createQueuedMockSupabase()
supabase.auth = { getUser: vi.fn().mockResolvedValue({ data: { user: mockUser } }) }
mockCreateClient.mockResolvedValue(supabase)
enqueueMany([
{ data: makeSalaryRun() },
{ data: makeAgiDeclaration() },
])
mockFetch.mockResolvedValue({
ok: false,
status: 403,
json: async () => ({
error: 'Du har inte behörighet att agera för detta företag',
code: 'BEHORIGHET_SAKNAS',
}),
})
const request = createMockRequest('/api/salary/runs/run-1/agi/submit', { method: 'POST' })
const response = await POST(request, createMockRouteParams({ id: 'run-1' }))
const { status, body } = await parseJsonResponse<{ error: string }>(response)
expect(status).toBe(403)
expect(body.error).toContain('behörighet')
})
it('accepts booked salary runs for submission', async () => {
const { supabase, enqueueMany } = createQueuedMockSupabase()
supabase.auth = { getUser: vi.fn().mockResolvedValue({ data: { user: mockUser } }) }
mockCreateClient.mockResolvedValue(supabase)
enqueueMany([
{ data: makeSalaryRun({ status: 'booked' }) },
{ data: makeAgiDeclaration() },
{ data: null },
])
mockFetch.mockResolvedValue({
ok: true,
status: 200,
json: async () => ({ data: { inlamningId: 'inl-456' } }),
})
const request = createMockRequest('/api/salary/runs/run-1/agi/submit', { method: 'POST' })
const response = await POST(request, createMockRouteParams({ id: 'run-1' }))
const { status } = await parseJsonResponse(response)
expect(status).toBe(200)
})
})
@@ -0,0 +1,146 @@
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()
/**
* Submit AGI to Skatteverket via the extension API.
*
* This route orchestrates the AGI submission flow:
* 1. Validates the salary run is in a submittable state
* 2. Ensures AGI has been generated (in agi_declarations table)
* 3. Calls the Skatteverket extension to save draft + lock for signing
* 4. Returns the signeringslänk for BankID signing
*
* The user then signs on Skatteverket's site. The frontend polls
* GET /api/extensions/ext/skatteverket/agi/submitted to detect completion.
*/
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 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 skickas till Skatteverket efter granskning' },
{ status: 400 }
)
}
// Ensure AGI has been generated
const { data: agiDeclaration } = await supabase
.from('agi_declarations')
.select('id, status')
.eq('company_id', companyId)
.eq('salary_run_id', id)
.single()
if (!agiDeclaration) {
return NextResponse.json(
{ error: 'AGI har inte genererats ännu. Generera AGI XML först.' },
{ status: 400 }
)
}
if (agiDeclaration.status === 'submitted' || agiDeclaration.status === 'accepted') {
return NextResponse.json(
{ error: 'AGI har redan skickats till Skatteverket för denna period' },
{ status: 409 }
)
}
// The actual submission is done via the Skatteverket extension routes.
// This route provides the salary_run_id for the extension to load data from.
// The frontend should call:
// 1. POST /api/extensions/ext/skatteverket/agi/draft { salaryRunId }
// 2. PUT /api/extensions/ext/skatteverket/agi/lock ?arbetsgivare=...&period=...
// 3. User signs with BankID via signeringslänk
// 4. GET /api/extensions/ext/skatteverket/agi/submitted ?arbetsgivare=...&period=...
//
// This endpoint kicks off step 1 and returns the info needed for step 2+.
const appUrl = process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3000'
try {
// Call the extension's draft endpoint internally
const draftResponse = await fetch(
`${appUrl}/api/extensions/ext/skatteverket/agi/draft`,
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Cookie': request.headers.get('Cookie') || '',
},
body: JSON.stringify({ salaryRunId: id }),
}
)
if (!draftResponse.ok) {
const errorData = await draftResponse.json().catch(() => ({ error: 'Okänt fel' }))
return NextResponse.json(
{ error: errorData.error || `Kunde inte spara AGI-utkast (${draftResponse.status})` },
{ status: draftResponse.status }
)
}
const draftData = await draftResponse.json()
// Update submission timestamp on salary run
await supabase
.from('salary_runs')
.update({ agi_submitted_at: new Date().toISOString() })
.eq('id', id)
await eventBus.emit({
type: 'agi.submitted',
payload: {
salaryRunId: id,
periodYear: run.period_year,
periodMonth: run.period_month,
userId: user.id,
companyId,
},
})
return NextResponse.json({
data: {
...draftData.data,
salaryRunId: id,
periodYear: run.period_year,
periodMonth: run.period_month,
message: 'AGI sparad som utkast hos Skatteverket. Lås och signera med BankID för att slutföra.',
},
})
} catch (err) {
console.error('[salary/agi/submit] Error:', err)
return NextResponse.json(
{ error: err instanceof Error ? err.message : 'Kunde inte skicka AGI till Skatteverket' },
{ status: 500 }
)
}
}
+63 -5
View File
@@ -7,7 +7,7 @@ import { eventBus } from '@/lib/events'
ensureInitialized()
/** review → approved (authorization recorded) */
/** review → approved (authorization recorded, with pre-approve validation) */
export async function POST(
request: Request,
{ params }: { params: Promise<{ id: string }> }
@@ -22,7 +22,65 @@ export async function POST(
const companyId = await requireCompanyId(supabase, user.id)
const { data: run, error } = await supabase
// Verify run exists and is in review status
const { data: run, error: runError } = await supabase
.from('salary_runs')
.select('*')
.eq('id', id)
.eq('company_id', companyId)
.eq('status', 'review')
.single()
if (runError || !run) {
return NextResponse.json({ error: 'Lönekörningen måste vara i granskningsstatus' }, { status: 400 })
}
// Load all employees in this run for validation
const { data: runEmployees } = await supabase
.from('salary_run_employees')
.select('*, employee:employees(first_name, last_name, clearing_number, bank_account_number, email)')
.eq('salary_run_id', id)
const validationErrors: string[] = []
const warnings: string[] = []
for (const sre of runEmployees || []) {
const emp = sre.employee as {
first_name: string
last_name: string
clearing_number: string | null
bank_account_number: string | null
email: string | null
} | null
if (!emp) continue
const name = `${emp.first_name} ${emp.last_name}`
// Bank details required for payment
if (!emp.clearing_number || !emp.bank_account_number) {
validationErrors.push(`${name}: Bankuppgifter saknas (clearingnummer och/eller kontonummer)`)
}
// Must have been calculated (calculation_breakdown exists)
if (!sre.calculation_breakdown) {
validationErrors.push(`${name}: Beräkning saknas — kör beräkning först`)
}
// Warning: no email means pay slip cannot be sent
if (!emp.email) {
warnings.push(`${name}: E-post saknas — lönebesked kan inte skickas`)
}
}
if (validationErrors.length > 0) {
return NextResponse.json({
error: 'Valideringsfel — korrigera innan godkännande',
details: validationErrors,
warnings,
}, { status: 400 })
}
// All validation passed — approve
const { data: updatedRun, error } = await supabase
.from('salary_runs')
.update({
status: 'approved',
@@ -35,8 +93,8 @@ export async function POST(
.select()
.single()
if (error || !run) {
return NextResponse.json({ error: 'Lönekörningen måste vara i granskningsstatus' }, { status: 400 })
if (error || !updatedRun) {
return NextResponse.json({ error: 'Kunde inte godkänna lönekörningen' }, { status: 500 })
}
await eventBus.emit({
@@ -44,5 +102,5 @@ export async function POST(
payload: { salaryRunId: id, approvedBy: user.id, userId: user.id, companyId },
})
return NextResponse.json({ data: run })
return NextResponse.json({ data: updatedRun, warnings })
}
@@ -54,6 +54,30 @@ export async function POST(
return NextResponse.json({ error: 'Inga anställda i lönekörningen' }, { status: 400 })
}
// Pre-calculation validation — ensure employees have required data
const validationErrors: string[] = []
for (const sre of runEmployees) {
const emp = sre.employee
if (!emp) continue
const name = `${emp.first_name} ${emp.last_name}`
if (emp.salary_type === 'monthly' && (!emp.monthly_salary || emp.monthly_salary <= 0)) {
validationErrors.push(`${name}: Månadslön saknas eller är 0`)
}
if (emp.salary_type === 'hourly' && (!emp.hourly_rate || emp.hourly_rate <= 0)) {
validationErrors.push(`${name}: Timlön saknas eller är 0`)
}
if (emp.f_skatt_status === 'a_skatt' && !emp.is_sidoinkomst && !emp.tax_table_number) {
validationErrors.push(`${name}: Skattetabell saknas (krävs för A-skatt)`)
}
}
if (validationErrors.length > 0) {
return NextResponse.json({
error: 'Valideringsfel — korrigera anställda innan beräkning',
details: validationErrors,
}, { 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))]