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:
co-authored by
greptile-apps[bot]
parent
24466c6e94
commit
bb0db7a588
@@ -12,7 +12,6 @@ import { ArrowLeft, Save, Trash2 } from 'lucide-react'
|
|||||||
import { useToast } from '@/components/ui/use-toast'
|
import { useToast } from '@/components/ui/use-toast'
|
||||||
import { useCanWrite } from '@/lib/hooks/use-can-write'
|
import { useCanWrite } from '@/lib/hooks/use-can-write'
|
||||||
import { getErrorMessage } from '@/lib/errors/get-error-message'
|
import { getErrorMessage } from '@/lib/errors/get-error-message'
|
||||||
import { formatCurrency } from '@/lib/utils'
|
|
||||||
import type { Employee } from '@/types'
|
import type { Employee } from '@/types'
|
||||||
|
|
||||||
const EMPLOYMENT_LABELS: Record<string, string> = {
|
const EMPLOYMENT_LABELS: Record<string, string> = {
|
||||||
@@ -21,6 +20,17 @@ const EMPLOYMENT_LABELS: Record<string, string> = {
|
|||||||
board_member: 'Styrelseledamot',
|
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 }> }) {
|
export default function EmployeeDetailPage({ params }: { params: Promise<{ id: string }> }) {
|
||||||
const { id } = use(params)
|
const { id } = use(params)
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
@@ -30,6 +40,12 @@ export default function EmployeeDetailPage({ params }: { params: Promise<{ id: s
|
|||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [saving, setSaving] = useState(false)
|
const [saving, setSaving] = useState(false)
|
||||||
const [employmentType, setEmploymentType] = useState('employee')
|
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(() => {
|
useEffect(() => {
|
||||||
async function load() {
|
async function load() {
|
||||||
@@ -38,6 +54,10 @@ export default function EmployeeDetailPage({ params }: { params: Promise<{ id: s
|
|||||||
const { data } = await res.json()
|
const { data } = await res.json()
|
||||||
setEmployee(data)
|
setEmployee(data)
|
||||||
setEmploymentType(data.employment_type)
|
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)
|
setLoading(false)
|
||||||
}
|
}
|
||||||
@@ -49,19 +69,33 @@ export default function EmployeeDetailPage({ params }: { params: Promise<{ id: s
|
|||||||
setSaving(true)
|
setSaving(true)
|
||||||
|
|
||||||
const form = new FormData(e.currentTarget)
|
const form = new FormData(e.currentTarget)
|
||||||
const body = {
|
const body: Record<string, unknown> = {
|
||||||
first_name: form.get('first_name') as string,
|
first_name: form.get('first_name') as string,
|
||||||
last_name: form.get('last_name') as string,
|
last_name: form.get('last_name') as string,
|
||||||
employment_type: employmentType,
|
employment_type: employmentType,
|
||||||
employment_degree: parseFloat(form.get('employment_degree') as string) || 100,
|
employment_degree: parseFloat(form.get('employment_degree') as string) || 100,
|
||||||
monthly_salary: parseFloat(form.get('monthly_salary') as string) || undefined,
|
salary_type: salaryType,
|
||||||
hourly_rate: parseFloat(form.get('hourly_rate') as string) || undefined,
|
f_skatt_status: fSkattStatus,
|
||||||
|
is_sidoinkomst: isSidoinkomst,
|
||||||
tax_table_number: parseInt(form.get('tax_table_number') 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_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,
|
email: form.get('email') as string || undefined,
|
||||||
phone: form.get('phone') 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,
|
clearing_number: form.get('clearing_number') as string || undefined,
|
||||||
bank_account_number: form.get('bank_account_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}`, {
|
const res = await fetch(`/api/salary/employees/${id}`, {
|
||||||
@@ -134,22 +168,66 @@ export default function EmployeeDetailPage({ params }: { params: Promise<{ id: s
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form onSubmit={handleSave} className="space-y-6">
|
<form onSubmit={handleSave} className="space-y-6">
|
||||||
|
{/* Personal info */}
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="text-base">Uppgifter</CardTitle>
|
<CardTitle className="text-base">Personuppgifter</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-4">
|
<CardContent className="space-y-4">
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
<div className="space-y-2">
|
<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} />
|
<Input id="first_name" name="first_name" defaultValue={employee.first_name} required disabled={!canWrite} />
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<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} />
|
<Input id="last_name" name="last_name" defaultValue={employee.last_name} required disabled={!canWrite} />
|
||||||
</div>
|
</div>
|
||||||
</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">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="employment_type">Typ</Label>
|
<Label htmlFor="employment_type">Typ</Label>
|
||||||
<Select value={employmentType} onValueChange={setEmploymentType} disabled={!canWrite}>
|
<Select value={employmentType} onValueChange={setEmploymentType} disabled={!canWrite}>
|
||||||
@@ -165,37 +243,165 @@ export default function EmployeeDetailPage({ params }: { params: Promise<{ id: s
|
|||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="employment_degree">Sysselsättningsgrad (%)</Label>
|
<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>
|
||||||
|
</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">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="monthly_salary">Månadslön</Label>
|
<Label htmlFor="salary_type">Löneform<RequiredMark /></Label>
|
||||||
<Input id="monthly_salary" name="monthly_salary" type="number" defaultValue={employee.monthly_salary || ''} disabled={!canWrite} />
|
<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>
|
</div>
|
||||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="hourly_rate">Timlön</Label>
|
<Label htmlFor="tax_table_number">
|
||||||
<Input id="hourly_rate" name="hourly_rate" type="number" step="0.01" defaultValue={employee.hourly_rate || ''} disabled={!canWrite} />
|
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>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="tax_table_number">Skattetabell</Label>
|
<Label htmlFor="tax_column">Kolumn (1-6)</Label>
|
||||||
<Input id="tax_table_number" name="tax_table_number" type="number" defaultValue={employee.tax_table_number || ''} disabled={!canWrite} />
|
<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>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="tax_column">Kolumn</Label>
|
<Label htmlFor="tax_municipality">
|
||||||
<Input id="tax_column" name="tax_column" type="number" defaultValue={employee.tax_column} disabled={!canWrite} />
|
Folkbokföringskommun{requiresTaxTable && <RequiredMark />}
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="tax_municipality"
|
||||||
|
name="tax_municipality"
|
||||||
|
defaultValue={employee.tax_municipality || ''}
|
||||||
|
required={requiresTaxTable}
|
||||||
|
disabled={!canWrite}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</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="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="email">E-post</Label>
|
<Label htmlFor="vacation_rule">Semesterregel</Label>
|
||||||
<Input id="email" name="email" type="email" defaultValue={employee.email || ''} disabled={!canWrite} />
|
<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>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="phone">Telefon</Label>
|
<Label htmlFor="vacation_days_per_year">Semesterdagar per år</Label>
|
||||||
<Input id="phone" name="phone" defaultValue={employee.phone || ''} disabled={!canWrite} />
|
<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>
|
||||||
</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="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="clearing_number">Clearingnummer</Label>
|
<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} />
|
<Input id="bank_account_number" name="bank_account_number" defaultValue={employee.bank_account_number || ''} disabled={!canWrite} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground mt-2">Krävs innan lönekörning kan godkännas</p>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
|||||||
@@ -12,12 +12,20 @@ import { ArrowLeft, Save } from 'lucide-react'
|
|||||||
import { useToast } from '@/components/ui/use-toast'
|
import { useToast } from '@/components/ui/use-toast'
|
||||||
import { getErrorMessage } from '@/lib/errors/get-error-message'
|
import { getErrorMessage } from '@/lib/errors/get-error-message'
|
||||||
|
|
||||||
|
function RequiredMark() {
|
||||||
|
return <span className="text-destructive ml-0.5">*</span>
|
||||||
|
}
|
||||||
|
|
||||||
export default function NewEmployeePage() {
|
export default function NewEmployeePage() {
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const { toast } = useToast()
|
const { toast } = useToast()
|
||||||
const [saving, setSaving] = useState(false)
|
const [saving, setSaving] = useState(false)
|
||||||
const [employmentType, setEmploymentType] = useState('employee')
|
const [employmentType, setEmploymentType] = useState('employee')
|
||||||
const [salaryType, setSalaryType] = useState('monthly')
|
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>) {
|
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
@@ -32,13 +40,18 @@ export default function NewEmployeePage() {
|
|||||||
employment_start: form.get('employment_start') as string,
|
employment_start: form.get('employment_start') as string,
|
||||||
employment_degree: parseFloat(form.get('employment_degree') as string) || 100,
|
employment_degree: parseFloat(form.get('employment_degree') as string) || 100,
|
||||||
salary_type: salaryType,
|
salary_type: salaryType,
|
||||||
monthly_salary: parseFloat(form.get('monthly_salary') as string) || undefined,
|
monthly_salary: salaryType === 'monthly' ? (parseFloat(form.get('monthly_salary') as string) || undefined) : undefined,
|
||||||
hourly_rate: parseFloat(form.get('hourly_rate') as string) || 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_table_number: parseInt(form.get('tax_table_number') as string) || undefined,
|
||||||
tax_column: parseInt(form.get('tax_column') as string) || 1,
|
tax_column: parseInt(form.get('tax_column') as string) || 1,
|
||||||
tax_municipality: form.get('tax_municipality') as string || undefined,
|
tax_municipality: form.get('tax_municipality') as string || undefined,
|
||||||
email: form.get('email') as string || undefined,
|
email: form.get('email') as string || undefined,
|
||||||
phone: form.get('phone') 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,
|
clearing_number: form.get('clearing_number') as string || undefined,
|
||||||
bank_account_number: form.get('bank_account_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">
|
<CardContent className="space-y-4">
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
<div className="space-y-2">
|
<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 />
|
<Input id="first_name" name="first_name" required />
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<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 />
|
<Input id="last_name" name="last_name" required />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
<div className="space-y-2">
|
<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} />
|
<Input id="personnummer" name="personnummer" placeholder="ÅÅÅÅMMDDNNNN" required maxLength={13} />
|
||||||
<p className="text-xs text-muted-foreground">Krypteras vid lagring</p>
|
<p className="text-xs text-muted-foreground">Krypteras vid lagring</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="email">E-post</Label>
|
<Label htmlFor="email">E-post</Label>
|
||||||
<Input id="email" name="email" type="email" />
|
<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>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
@@ -108,6 +122,29 @@ export default function NewEmployeePage() {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</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 */}
|
{/* Employment */}
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
@@ -129,7 +166,7 @@ export default function NewEmployeePage() {
|
|||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<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 />
|
<Input id="employment_start" name="employment_start" type="date" required />
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
@@ -146,9 +183,9 @@ export default function NewEmployeePage() {
|
|||||||
<CardTitle className="text-base">Lön</CardTitle>
|
<CardTitle className="text-base">Lön</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-4">
|
<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">
|
<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}>
|
<Select value={salaryType} onValueChange={setSalaryType}>
|
||||||
<SelectTrigger id="salary_type">
|
<SelectTrigger id="salary_type">
|
||||||
<SelectValue />
|
<SelectValue />
|
||||||
@@ -159,14 +196,17 @@ export default function NewEmployeePage() {
|
|||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
{salaryType === 'monthly' ? (
|
||||||
<Label htmlFor="monthly_salary">Månadslön (brutto, SEK)</Label>
|
<div className="space-y-2">
|
||||||
<Input id="monthly_salary" name="monthly_salary" type="number" step="1" min="0" />
|
<Label htmlFor="monthly_salary">Månadslön (brutto, SEK)<RequiredMark /></Label>
|
||||||
</div>
|
<Input id="monthly_salary" name="monthly_salary" type="number" step="1" min="1" required />
|
||||||
<div className="space-y-2">
|
</div>
|
||||||
<Label htmlFor="hourly_rate">Timlön (SEK)</Label>
|
) : (
|
||||||
<Input id="hourly_rate" name="hourly_rate" type="number" step="0.01" min="0" />
|
<div className="space-y-2">
|
||||||
</div>
|
<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>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -177,10 +217,46 @@ export default function NewEmployeePage() {
|
|||||||
<CardTitle className="text-base">Skatt</CardTitle>
|
<CardTitle className="text-base">Skatt</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-4">
|
<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="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="tax_table_number">Skattetabell (29-42)</Label>
|
<Label htmlFor="tax_table_number">
|
||||||
<Input id="tax_table_number" name="tax_table_number" type="number" min="29" max="42" />
|
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>
|
<p className="text-xs text-muted-foreground">Baseras på folkbokföringskommun</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<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>
|
<p className="text-xs text-muted-foreground">1 = standard under 66 år</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="tax_municipality">Folkbokföringskommun</Label>
|
<Label htmlFor="tax_municipality">
|
||||||
<Input id="tax_municipality" name="tax_municipality" />
|
Folkbokföringskommun{requiresTaxTable && <RequiredMark />}
|
||||||
|
</Label>
|
||||||
|
<Input id="tax_municipality" name="tax_municipality" required={requiresTaxTable} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
@@ -212,6 +290,7 @@ export default function NewEmployeePage() {
|
|||||||
<Input id="bank_account_number" name="bank_account_number" />
|
<Input id="bank_account_number" name="bank_account_number" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground mt-2">Krävs innan lönekörning kan godkännas</p>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
|||||||
@@ -57,10 +57,10 @@ export async function PATCH(
|
|||||||
if (!validation.success) return validation.response
|
if (!validation.success) return validation.response
|
||||||
const body = validation.data
|
const body = validation.data
|
||||||
|
|
||||||
// Check employee exists
|
// Load existing employee for merged validation
|
||||||
const { data: existing, error: fetchError } = await supabase
|
const { data: existing, error: fetchError } = await supabase
|
||||||
.from('employees')
|
.from('employees')
|
||||||
.select('id')
|
.select('*')
|
||||||
.eq('id', id)
|
.eq('id', id)
|
||||||
.eq('company_id', companyId)
|
.eq('company_id', companyId)
|
||||||
.single()
|
.single()
|
||||||
@@ -69,6 +69,23 @@ export async function PATCH(
|
|||||||
return NextResponse.json({ error: 'Anställd hittades inte' }, { status: 404 })
|
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
|
// Build update object
|
||||||
const updates: Record<string, unknown> = { ...body }
|
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 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,7 +7,7 @@ import { eventBus } from '@/lib/events'
|
|||||||
|
|
||||||
ensureInitialized()
|
ensureInitialized()
|
||||||
|
|
||||||
/** review → approved (authorization recorded) */
|
/** review → approved (authorization recorded, with pre-approve validation) */
|
||||||
export async function POST(
|
export async function POST(
|
||||||
request: Request,
|
request: Request,
|
||||||
{ params }: { params: Promise<{ id: string }> }
|
{ params }: { params: Promise<{ id: string }> }
|
||||||
@@ -22,7 +22,65 @@ export async function POST(
|
|||||||
|
|
||||||
const companyId = await requireCompanyId(supabase, user.id)
|
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')
|
.from('salary_runs')
|
||||||
.update({
|
.update({
|
||||||
status: 'approved',
|
status: 'approved',
|
||||||
@@ -35,8 +93,8 @@ export async function POST(
|
|||||||
.select()
|
.select()
|
||||||
.single()
|
.single()
|
||||||
|
|
||||||
if (error || !run) {
|
if (error || !updatedRun) {
|
||||||
return NextResponse.json({ error: 'Lönekörningen måste vara i granskningsstatus' }, { status: 400 })
|
return NextResponse.json({ error: 'Kunde inte godkänna lönekörningen' }, { status: 500 })
|
||||||
}
|
}
|
||||||
|
|
||||||
await eventBus.emit({
|
await eventBus.emit({
|
||||||
@@ -44,5 +102,5 @@ export async function POST(
|
|||||||
payload: { salaryRunId: id, approvedBy: user.id, userId: user.id, companyId },
|
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 })
|
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
|
// 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 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 columns = [...new Set(runEmployees.filter(e => e.employee?.tax_column).map(e => e.employee.tax_column as number))]
|
||||||
|
|||||||
@@ -79,6 +79,9 @@ const navItems: NavItem[] = [
|
|||||||
{ href: '/bookkeeping', label: 'Bokföring', icon: BookOpen, group: 'redovisning' },
|
{ href: '/bookkeeping', label: 'Bokföring', icon: BookOpen, group: 'redovisning' },
|
||||||
{ href: '/reports', label: 'Rapporter', icon: BarChart3, group: 'redovisning' },
|
{ href: '/reports', label: 'Rapporter', icon: BarChart3, group: 'redovisning' },
|
||||||
{ href: '/import', label: 'Importera', icon: Upload, group: 'redovisning' },
|
{ href: '/import', label: 'Importera', icon: Upload, group: 'redovisning' },
|
||||||
|
// Personal
|
||||||
|
{ href: '/salary', label: 'Löner', icon: HandCoins, group: 'personal', modes: ['aktiebolag'] },
|
||||||
|
{ href: '/salary/employees', label: 'Anställda', icon: Users, group: 'personal', modes: ['aktiebolag'] },
|
||||||
{ href: '/help', label: 'Hjälp', icon: HelpCircle, group: 'övrigt' },
|
{ href: '/help', label: 'Hjälp', icon: HelpCircle, group: 'övrigt' },
|
||||||
{ href: '/settings', label: 'Inställningar', icon: Settings, group: 'övrigt' },
|
{ href: '/settings', label: 'Inställningar', icon: Settings, group: 'övrigt' },
|
||||||
]
|
]
|
||||||
@@ -87,6 +90,7 @@ const groupLabels: Record<string, string> = {
|
|||||||
main: 'Huvudmeny',
|
main: 'Huvudmeny',
|
||||||
försäljning: 'Försäljning',
|
försäljning: 'Försäljning',
|
||||||
inköp: 'Inköp',
|
inköp: 'Inköp',
|
||||||
|
personal: 'Personal',
|
||||||
redovisning: 'Redovisning',
|
redovisning: 'Redovisning',
|
||||||
övrigt: 'Övrigt',
|
övrigt: 'Övrigt',
|
||||||
}
|
}
|
||||||
@@ -130,6 +134,11 @@ export default function DashboardNav({ companyName: _companyName, entityType, un
|
|||||||
if (href === '/') {
|
if (href === '/') {
|
||||||
return pathname === '/'
|
return pathname === '/'
|
||||||
}
|
}
|
||||||
|
// For parent routes that have a sibling sub-route in the nav (e.g. /salary vs /salary/employees),
|
||||||
|
// only match the parent for exact or non-overlapping sub-paths
|
||||||
|
if (href === '/salary') {
|
||||||
|
return pathname === '/salary' || pathname.startsWith('/salary/runs')
|
||||||
|
}
|
||||||
return pathname.startsWith(href)
|
return pathname.startsWith(href)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -158,7 +167,8 @@ export default function DashboardNav({ companyName: _companyName, entityType, un
|
|||||||
const sidebarGroups = [
|
const sidebarGroups = [
|
||||||
{ key: 'försäljning', items: filteredItems.filter(i => i.group === 'försäljning'), spacing: 'mb-4' },
|
{ key: 'försäljning', items: filteredItems.filter(i => i.group === 'försäljning'), spacing: 'mb-4' },
|
||||||
{ key: 'inköp', items: filteredItems.filter(i => i.group === 'inköp'), spacing: 'mb-4' },
|
{ key: 'inköp', items: filteredItems.filter(i => i.group === 'inköp'), spacing: 'mb-4' },
|
||||||
{ key: 'redovisning', items: filteredItems.filter(i => i.group === 'redovisning'), spacing: 'mb-6' },
|
{ key: 'redovisning', items: filteredItems.filter(i => i.group === 'redovisning'), spacing: 'mb-4' },
|
||||||
|
{ key: 'personal', items: filteredItems.filter(i => i.group === 'personal'), spacing: 'mb-6' },
|
||||||
] as const
|
] as const
|
||||||
|
|
||||||
const mobileNavItems = [
|
const mobileNavItems = [
|
||||||
@@ -228,8 +238,8 @@ export default function DashboardNav({ companyName: _companyName, entityType, un
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* AR / AP / Accounting groups */}
|
{/* AR / AP / Personal / Accounting groups */}
|
||||||
{sidebarGroups.map(({ key, items, spacing }) => (
|
{sidebarGroups.filter(({ items }) => items.length > 0).map(({ key, items, spacing }) => (
|
||||||
<div key={key} className={spacing}>
|
<div key={key} className={spacing}>
|
||||||
<p className="px-3 mb-1.5 text-[10px] font-semibold text-muted-foreground uppercase tracking-[0.08em]">
|
<p className="px-3 mb-1.5 text-[10px] font-semibold text-muted-foreground uppercase tracking-[0.08em]">
|
||||||
{groupLabels[key]}
|
{groupLabels[key]}
|
||||||
@@ -553,8 +563,8 @@ export default function DashboardNav({ companyName: _companyName, entityType, un
|
|||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* AR / AP / Accounting groups (mobile) */}
|
{/* AR / AP / Personal / Accounting groups (mobile) */}
|
||||||
{sidebarGroups.map(({ key, items }) => (
|
{sidebarGroups.filter(({ items }) => items.length > 0).map(({ key, items }) => (
|
||||||
<div key={key}>
|
<div key={key}>
|
||||||
<div className="flex items-center gap-3 my-1.5 px-3">
|
<div className="flex items-center gap-3 my-1.5 px-3">
|
||||||
<span className="text-[10px] font-semibold text-muted-foreground/60 uppercase tracking-[0.08em]">{groupLabels[key]}</span>
|
<span className="text-[10px] font-semibold text-muted-foreground/60 uppercase tracking-[0.08em]">{groupLabels[key]}</span>
|
||||||
|
|||||||
@@ -0,0 +1,183 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||||
|
import { buildAGIPayload } from '../lib/agi-mappers'
|
||||||
|
import type { AGIEmployeeData, AGITotals } from '@/lib/salary/agi/xml-generator'
|
||||||
|
|
||||||
|
// Mock personnummer decryption
|
||||||
|
vi.mock('@/lib/salary/personnummer', () => ({
|
||||||
|
decryptPersonnummer: vi.fn((encrypted: string) => {
|
||||||
|
// Simulate decryption: in tests, we use plaintext personnummer
|
||||||
|
if (encrypted === 'INVALID') throw new Error('Decryption failed')
|
||||||
|
return encrypted
|
||||||
|
}),
|
||||||
|
}))
|
||||||
|
|
||||||
|
function makeEmployee(overrides: Partial<AGIEmployeeData> = {}): AGIEmployeeData {
|
||||||
|
return {
|
||||||
|
personnummer: '199001011234',
|
||||||
|
specificationNumber: 1,
|
||||||
|
grossSalary: 35000,
|
||||||
|
taxWithheld: 8000,
|
||||||
|
avgifterBasis: 35000,
|
||||||
|
...overrides,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeTotals(overrides: Partial<AGITotals> = {}): AGITotals {
|
||||||
|
return {
|
||||||
|
totalTax: 8000,
|
||||||
|
totalAvgifterBasis: 35000,
|
||||||
|
avgifterByCategory: {
|
||||||
|
standard: { basis: 35000, amount: 10997 },
|
||||||
|
},
|
||||||
|
...overrides,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('buildAGIPayload', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('builds payload with correct structure', () => {
|
||||||
|
const result = buildAGIPayload([makeEmployee()], makeTotals())
|
||||||
|
|
||||||
|
expect(result).toMatchObject({
|
||||||
|
rattelse: false,
|
||||||
|
huvuduppgift: {
|
||||||
|
avdragenSkatt: 8000,
|
||||||
|
summaArbetsgivaravgifterUnderlag: 35000,
|
||||||
|
avgifterUnderlagStandard: 35000,
|
||||||
|
},
|
||||||
|
individuppgifter: [
|
||||||
|
{
|
||||||
|
personnummer: '199001011234',
|
||||||
|
specifikationsnummer: 1,
|
||||||
|
kontantBruttoloen: 35000,
|
||||||
|
avdragenSkatt: 8000,
|
||||||
|
underlagArbetsgivaravgifter: 35000,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('sets rattelse flag for corrections', () => {
|
||||||
|
const result = buildAGIPayload([makeEmployee()], makeTotals(), true)
|
||||||
|
expect(result.rattelse).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('omits zero-value fields from individuppgift', () => {
|
||||||
|
const emp = makeEmployee({
|
||||||
|
benefitCar: 0,
|
||||||
|
benefitMeals: undefined,
|
||||||
|
sickDays: 0,
|
||||||
|
})
|
||||||
|
const result = buildAGIPayload([emp], makeTotals())
|
||||||
|
const ind = result.individuppgifter[0]
|
||||||
|
|
||||||
|
expect(ind.formanBil).toBeUndefined()
|
||||||
|
expect(ind.formanKost).toBeUndefined()
|
||||||
|
expect(ind.sjukfranvaroDagar).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('includes benefit values when present', () => {
|
||||||
|
const emp = makeEmployee({
|
||||||
|
benefitCar: 4500,
|
||||||
|
benefitHousing: 3000,
|
||||||
|
benefitMeals: 1800,
|
||||||
|
benefitOther: 500,
|
||||||
|
})
|
||||||
|
const result = buildAGIPayload([emp], makeTotals())
|
||||||
|
const ind = result.individuppgifter[0]
|
||||||
|
|
||||||
|
expect(ind.formanBil).toBe(4500)
|
||||||
|
expect(ind.formanBostad).toBe(3000)
|
||||||
|
expect(ind.formanKost).toBe(1800)
|
||||||
|
expect(ind.formanOvrigt).toBe(500)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('includes absence fields when present', () => {
|
||||||
|
const emp = makeEmployee({
|
||||||
|
sickDays: 3,
|
||||||
|
vabDays: 2,
|
||||||
|
parentalDays: 5,
|
||||||
|
})
|
||||||
|
const result = buildAGIPayload([emp], makeTotals())
|
||||||
|
const ind = result.individuppgifter[0]
|
||||||
|
|
||||||
|
expect(ind.sjukfranvaroDagar).toBe(3)
|
||||||
|
expect(ind.vabDagar).toBe(2)
|
||||||
|
expect(ind.foraldraledigDagar).toBe(5)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('includes F-skatt payment field', () => {
|
||||||
|
const emp = makeEmployee({ fSkattPayment: 50000 })
|
||||||
|
const result = buildAGIPayload([emp], makeTotals())
|
||||||
|
|
||||||
|
expect(result.individuppgifter[0].ersattningFSkatt).toBe(50000)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rounds all amounts to whole kronor', () => {
|
||||||
|
const emp = makeEmployee({
|
||||||
|
grossSalary: 35000.75,
|
||||||
|
taxWithheld: 8000.49,
|
||||||
|
avgifterBasis: 35000.5,
|
||||||
|
})
|
||||||
|
const result = buildAGIPayload([emp], makeTotals())
|
||||||
|
const ind = result.individuppgifter[0]
|
||||||
|
|
||||||
|
expect(ind.kontantBruttoloen).toBe(35001)
|
||||||
|
expect(ind.avdragenSkatt).toBe(8000)
|
||||||
|
expect(ind.underlagArbetsgivaravgifter).toBe(35001)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('handles multiple avgifter categories', () => {
|
||||||
|
const totals = makeTotals({
|
||||||
|
avgifterByCategory: {
|
||||||
|
standard: { basis: 70000, amount: 21994 },
|
||||||
|
reduced65plus: { basis: 30000, amount: 3063 },
|
||||||
|
youth: { basis: 25000, amount: 5203 },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
const result = buildAGIPayload([makeEmployee()], totals)
|
||||||
|
const hu = result.huvuduppgift
|
||||||
|
|
||||||
|
expect(hu.avgifterUnderlagStandard).toBe(70000)
|
||||||
|
expect(hu.avgifterUnderlagAlderspension).toBe(30000)
|
||||||
|
expect(hu.avgifterUnderlagUngdom).toBe(25000)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('handles multiple employees', () => {
|
||||||
|
const employees = [
|
||||||
|
makeEmployee({ specificationNumber: 1, grossSalary: 35000 }),
|
||||||
|
makeEmployee({ specificationNumber: 2, personnummer: '199512152345', grossSalary: 28000 }),
|
||||||
|
]
|
||||||
|
const result = buildAGIPayload(employees, makeTotals({ totalTax: 15000, totalAvgifterBasis: 63000 }))
|
||||||
|
|
||||||
|
expect(result.individuppgifter).toHaveLength(2)
|
||||||
|
expect(result.individuppgifter[0].specifikationsnummer).toBe(1)
|
||||||
|
expect(result.individuppgifter[1].specifikationsnummer).toBe(2)
|
||||||
|
expect(result.individuppgifter[1].personnummer).toBe('199512152345')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('throws if personnummer cannot be decrypted', () => {
|
||||||
|
const emp = makeEmployee({ personnummer: 'INVALID' })
|
||||||
|
|
||||||
|
expect(() => buildAGIPayload([emp], makeTotals())).toThrow(
|
||||||
|
/Kunde inte dekryptera personnummer.*FK570=1/
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('omits huvuduppgift fields when zero', () => {
|
||||||
|
const totals: AGITotals = {
|
||||||
|
totalTax: 0,
|
||||||
|
totalAvgifterBasis: 0,
|
||||||
|
avgifterByCategory: {},
|
||||||
|
}
|
||||||
|
const result = buildAGIPayload([makeEmployee({ grossSalary: 0, taxWithheld: 0, avgifterBasis: 0 })], totals)
|
||||||
|
const hu = result.huvuduppgift
|
||||||
|
|
||||||
|
expect(hu.avdragenSkatt).toBeUndefined()
|
||||||
|
expect(hu.summaArbetsgivaravgifterUnderlag).toBeUndefined()
|
||||||
|
expect(hu.avgifterUnderlagStandard).toBeUndefined()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -6,6 +6,9 @@ import { storeTokens, getTokens, deleteTokens } from './lib/token-store'
|
|||||||
import { skvRequest, SkatteverketAuthError } from './lib/api-client'
|
import { skvRequest, SkatteverketAuthError } from './lib/api-client'
|
||||||
import { rutorToMomsuppgift, formatRedovisare, formatRedovisningsperiod } from './lib/mappers'
|
import { rutorToMomsuppgift, formatRedovisare, formatRedovisningsperiod } from './lib/mappers'
|
||||||
import { calculateVatDeclaration } from '@/lib/reports/vat-declaration'
|
import { calculateVatDeclaration } from '@/lib/reports/vat-declaration'
|
||||||
|
import { agiSaveDraft, agiValidate, agiGetSubmission, agiDeleteDraft, agiLockPeriod, agiUnlockPeriod, agiGetSubmitted } from './lib/agi-client'
|
||||||
|
import { buildAGIPayload } from './lib/agi-mappers'
|
||||||
|
import type { AGIEmployeeData, AGITotals } from '@/lib/salary/agi/xml-generator'
|
||||||
import type { VatPeriodType } from '@/types'
|
import type { VatPeriodType } from '@/types'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -546,6 +549,364 @@ export const skatteverketExtension: Extension = {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
// ══════════════════════════════════════════════════════════════
|
||||||
|
// AGI (Arbetsgivardeklaration) routes
|
||||||
|
// ══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
// ── AGI: Validate (dry run) ────────────────────────────────────
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
path: '/agi/validate',
|
||||||
|
handler: async (request: Request, ctx?: ExtensionContext) => {
|
||||||
|
if (!ctx) {
|
||||||
|
return NextResponse.json({ error: 'Extension context required' }, { status: 500 })
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { arbetsgivare, period, payload } = await parseAGIRequest(request, ctx)
|
||||||
|
|
||||||
|
console.log('[skatteverket] AGI validating:', { arbetsgivare, period })
|
||||||
|
|
||||||
|
const result = await agiValidate(ctx.supabase, ctx.companyId, arbetsgivare, period, payload)
|
||||||
|
|
||||||
|
if (!result.ok) {
|
||||||
|
console.error('[skatteverket] AGI validate error:', result.status, result.error)
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: `Skatteverket svarade med ${result.status}: ${result.error}` },
|
||||||
|
{ status: result.status }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({ data: result.data })
|
||||||
|
} catch (err) {
|
||||||
|
return handleSkvError(err)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── AGI: Save draft ────────────────────────────────────────────
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
path: '/agi/draft',
|
||||||
|
handler: async (request: Request, ctx?: ExtensionContext) => {
|
||||||
|
if (!ctx) {
|
||||||
|
return NextResponse.json({ error: 'Extension context required' }, { status: 500 })
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { arbetsgivare, period, payload, salaryRunId } = await parseAGIRequest(request, ctx)
|
||||||
|
|
||||||
|
console.log('[skatteverket] AGI saving draft:', { arbetsgivare, period })
|
||||||
|
|
||||||
|
const result = await agiSaveDraft(ctx.supabase, ctx.companyId, arbetsgivare, period, payload)
|
||||||
|
|
||||||
|
if (!result.ok) {
|
||||||
|
console.error('[skatteverket] AGI draft error:', result.status, result.error)
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: `Skatteverket svarade med ${result.status}: ${result.error}` },
|
||||||
|
{ status: result.status }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Track submission status and inlämningsId
|
||||||
|
const inlamningId = result.data?.inlamningId
|
||||||
|
await ctx.settings.set(
|
||||||
|
`agi_submission_${period}`,
|
||||||
|
JSON.stringify({
|
||||||
|
status: 'draft_saved',
|
||||||
|
arbetsgivare,
|
||||||
|
period,
|
||||||
|
inlamningId,
|
||||||
|
salaryRunId,
|
||||||
|
kontrollresultat: result.data?.kontrollresultat,
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
// Update agi_declarations table with submission status
|
||||||
|
if (salaryRunId) {
|
||||||
|
await ctx.supabase
|
||||||
|
.from('agi_declarations')
|
||||||
|
.update({ status: 'exported' })
|
||||||
|
.eq('salary_run_id', salaryRunId)
|
||||||
|
.eq('company_id', ctx.companyId)
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({ data: result.data })
|
||||||
|
} catch (err) {
|
||||||
|
return handleSkvError(err)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── AGI: Get submission ────────────────────────────────────────
|
||||||
|
{
|
||||||
|
method: 'GET',
|
||||||
|
path: '/agi/submission',
|
||||||
|
handler: async (request: Request, ctx?: ExtensionContext) => {
|
||||||
|
if (!ctx) {
|
||||||
|
return NextResponse.json({ error: 'Extension context required' }, { status: 500 })
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const url = new URL(request.url)
|
||||||
|
const arbetsgivare = url.searchParams.get('arbetsgivare')
|
||||||
|
const period = url.searchParams.get('period')
|
||||||
|
const inlamningId = url.searchParams.get('inlamningId')
|
||||||
|
|
||||||
|
if (!arbetsgivare || !period || !inlamningId) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Saknar parametrar: arbetsgivare, period, inlamningId' },
|
||||||
|
{ status: 400 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await agiGetSubmission(ctx.supabase, ctx.companyId, arbetsgivare, period, inlamningId)
|
||||||
|
|
||||||
|
if (!result.ok) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: `Skatteverket svarade med ${result.status}: ${result.error}` },
|
||||||
|
{ status: result.status }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({ data: result.data })
|
||||||
|
} catch (err) {
|
||||||
|
return handleSkvError(err)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── AGI: Delete draft ──────────────────────────────────────────
|
||||||
|
{
|
||||||
|
method: 'DELETE',
|
||||||
|
path: '/agi/draft',
|
||||||
|
handler: async (request: Request, ctx?: ExtensionContext) => {
|
||||||
|
if (!ctx) {
|
||||||
|
return NextResponse.json({ error: 'Extension context required' }, { status: 500 })
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const url = new URL(request.url)
|
||||||
|
const arbetsgivare = url.searchParams.get('arbetsgivare')
|
||||||
|
const period = url.searchParams.get('period')
|
||||||
|
const inlamningId = url.searchParams.get('inlamningId')
|
||||||
|
|
||||||
|
if (!arbetsgivare || !period || !inlamningId) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Saknar parametrar: arbetsgivare, period, inlamningId' },
|
||||||
|
{ status: 400 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await agiDeleteDraft(ctx.supabase, ctx.companyId, arbetsgivare, period, inlamningId)
|
||||||
|
|
||||||
|
if (!result.ok) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: `Skatteverket svarade med ${result.status}: ${result.error}` },
|
||||||
|
{ status: result.status }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
await ctx.settings.set(`agi_submission_${period}`, null)
|
||||||
|
return NextResponse.json({ success: true })
|
||||||
|
} catch (err) {
|
||||||
|
return handleSkvError(err)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── AGI: Lock period for signing ───────────────────────────────
|
||||||
|
{
|
||||||
|
method: 'PUT',
|
||||||
|
path: '/agi/lock',
|
||||||
|
handler: async (request: Request, ctx?: ExtensionContext) => {
|
||||||
|
if (!ctx) {
|
||||||
|
return NextResponse.json({ error: 'Extension context required' }, { status: 500 })
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const url = new URL(request.url)
|
||||||
|
const arbetsgivare = url.searchParams.get('arbetsgivare')
|
||||||
|
const period = url.searchParams.get('period')
|
||||||
|
|
||||||
|
if (!arbetsgivare || !period) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Saknar parametrar: arbetsgivare, period' },
|
||||||
|
{ status: 400 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await agiLockPeriod(ctx.supabase, ctx.companyId, arbetsgivare, period)
|
||||||
|
|
||||||
|
if (!result.ok) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: `Skatteverket svarade med ${result.status}: ${result.error}` },
|
||||||
|
{ status: result.status }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
await ctx.settings.set(
|
||||||
|
`agi_submission_${period}`,
|
||||||
|
JSON.stringify({
|
||||||
|
status: 'draft_locked',
|
||||||
|
arbetsgivare,
|
||||||
|
period,
|
||||||
|
signeringslank: result.data?.signeringslank,
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
return NextResponse.json({ data: result.data })
|
||||||
|
} catch (err) {
|
||||||
|
return handleSkvError(err)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── AGI: Unlock period ─────────────────────────────────────────
|
||||||
|
{
|
||||||
|
method: 'DELETE',
|
||||||
|
path: '/agi/lock',
|
||||||
|
handler: async (request: Request, ctx?: ExtensionContext) => {
|
||||||
|
if (!ctx) {
|
||||||
|
return NextResponse.json({ error: 'Extension context required' }, { status: 500 })
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const url = new URL(request.url)
|
||||||
|
const arbetsgivare = url.searchParams.get('arbetsgivare')
|
||||||
|
const period = url.searchParams.get('period')
|
||||||
|
|
||||||
|
if (!arbetsgivare || !period) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Saknar parametrar: arbetsgivare, period' },
|
||||||
|
{ status: 400 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await agiUnlockPeriod(ctx.supabase, ctx.companyId, arbetsgivare, period)
|
||||||
|
|
||||||
|
if (!result.ok) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: `Skatteverket svarade med ${result.status}: ${result.error}` },
|
||||||
|
{ status: result.status }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
await ctx.settings.set(
|
||||||
|
`agi_submission_${period}`,
|
||||||
|
JSON.stringify({
|
||||||
|
status: 'draft_saved',
|
||||||
|
arbetsgivare,
|
||||||
|
period,
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true })
|
||||||
|
} catch (err) {
|
||||||
|
return handleSkvError(err)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── AGI: Fetch submitted (after BankID signing) ────────────────
|
||||||
|
{
|
||||||
|
method: 'GET',
|
||||||
|
path: '/agi/submitted',
|
||||||
|
handler: async (request: Request, ctx?: ExtensionContext) => {
|
||||||
|
if (!ctx) {
|
||||||
|
return NextResponse.json({ error: 'Extension context required' }, { status: 500 })
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const url = new URL(request.url)
|
||||||
|
const arbetsgivare = url.searchParams.get('arbetsgivare')
|
||||||
|
const period = url.searchParams.get('period')
|
||||||
|
|
||||||
|
if (!arbetsgivare || !period) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Saknar parametrar: arbetsgivare, period' },
|
||||||
|
{ status: 400 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await agiGetSubmitted(ctx.supabase, ctx.companyId, arbetsgivare, period)
|
||||||
|
|
||||||
|
if (!result.ok) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: `Skatteverket svarade med ${result.status}: ${result.error}` },
|
||||||
|
{ status: result.status }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// If we got a kvittensnummer, the AGI has been signed and submitted
|
||||||
|
if (result.data?.kvittensnummer) {
|
||||||
|
await ctx.settings.set(
|
||||||
|
`agi_submission_${period}`,
|
||||||
|
JSON.stringify({
|
||||||
|
status: 'signed',
|
||||||
|
arbetsgivare,
|
||||||
|
period,
|
||||||
|
kvittensnummer: result.data.kvittensnummer,
|
||||||
|
tidpunkt: result.data.tidpunkt,
|
||||||
|
signerare: result.data.signerare,
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
// Update agi_declarations with submission receipt
|
||||||
|
const periodYear = parseInt(period.slice(0, 4))
|
||||||
|
const periodMonth = parseInt(period.slice(4, 6))
|
||||||
|
await ctx.supabase
|
||||||
|
.from('agi_declarations')
|
||||||
|
.update({
|
||||||
|
status: 'submitted',
|
||||||
|
kvittensnummer: result.data.kvittensnummer,
|
||||||
|
submitted_at: result.data.tidpunkt || new Date().toISOString(),
|
||||||
|
submitted_by: ctx.userId,
|
||||||
|
})
|
||||||
|
.eq('company_id', ctx.companyId)
|
||||||
|
.eq('period_year', periodYear)
|
||||||
|
.eq('period_month', periodMonth)
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({ data: result.data })
|
||||||
|
} catch (err) {
|
||||||
|
return handleSkvError(err)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── AGI: Get submission status (local tracking) ────────────────
|
||||||
|
{
|
||||||
|
method: 'GET',
|
||||||
|
path: '/agi/status',
|
||||||
|
handler: async (request: Request, ctx?: ExtensionContext) => {
|
||||||
|
if (!ctx) {
|
||||||
|
return NextResponse.json({ error: 'Extension context required' }, { status: 500 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = new URL(request.url)
|
||||||
|
const period = url.searchParams.get('period')
|
||||||
|
|
||||||
|
if (!period) {
|
||||||
|
return NextResponse.json({ error: 'Saknar parameter: period' }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const statusJson = await ctx.settings.get<string>(`agi_submission_${period}`)
|
||||||
|
if (!statusJson) {
|
||||||
|
return NextResponse.json({ data: null })
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return NextResponse.json({ data: JSON.parse(statusJson) })
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json({ data: null })
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -624,6 +985,128 @@ function parseQueryParams(
|
|||||||
return { redovisare, redovisningsperiod }
|
return { redovisare, redovisningsperiod }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse and validate AGI submission request body.
|
||||||
|
* Loads salary run data and builds the Skatteverket AGI JSON payload.
|
||||||
|
*/
|
||||||
|
async function parseAGIRequest(
|
||||||
|
request: Request,
|
||||||
|
ctx: ExtensionContext
|
||||||
|
): Promise<{
|
||||||
|
arbetsgivare: string
|
||||||
|
period: string
|
||||||
|
payload: ReturnType<typeof buildAGIPayload>
|
||||||
|
salaryRunId: string
|
||||||
|
}> {
|
||||||
|
const body = await request.json()
|
||||||
|
const { salaryRunId } = body as { salaryRunId: string }
|
||||||
|
|
||||||
|
if (!salaryRunId) {
|
||||||
|
throw new Error('Saknar obligatoriskt fält: salaryRunId')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get company settings for arbetsgivare formatting
|
||||||
|
const { data: settings } = await ctx.supabase
|
||||||
|
.from('company_settings')
|
||||||
|
.select('org_number, entity_type')
|
||||||
|
.eq('company_id', ctx.companyId)
|
||||||
|
.single()
|
||||||
|
|
||||||
|
if (!settings?.org_number) {
|
||||||
|
throw new Error('Organisationsnummer saknas i företagsinställningar')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load salary run
|
||||||
|
const { data: run, error: runError } = await ctx.supabase
|
||||||
|
.from('salary_runs')
|
||||||
|
.select('*')
|
||||||
|
.eq('id', salaryRunId)
|
||||||
|
.eq('company_id', ctx.companyId)
|
||||||
|
.single()
|
||||||
|
|
||||||
|
if (runError || !run) {
|
||||||
|
throw new Error('Lönekörning hittades inte')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!['review', 'approved', 'paid', 'booked'].includes(run.status)) {
|
||||||
|
throw new Error('AGI kan bara skickas efter granskning')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load employees with their data
|
||||||
|
const { data: runEmployees } = await ctx.supabase
|
||||||
|
.from('salary_run_employees')
|
||||||
|
.select('*, employee:employees(personnummer, specification_number, f_skatt_status), line_items:salary_line_items(*)')
|
||||||
|
.eq('salary_run_id', salaryRunId)
|
||||||
|
|
||||||
|
if (!runEmployees || runEmployees.length === 0) {
|
||||||
|
throw new Error('Inga anställda i lönekörningen')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build employee data
|
||||||
|
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>>
|
||||||
|
|
||||||
|
const sumByType = (types: string[]) =>
|
||||||
|
lineItems
|
||||||
|
.filter(li => types.includes(li.item_type as string))
|
||||||
|
.reduce((sum, li) => sum + ((li.amount as number) || 0), 0)
|
||||||
|
|
||||||
|
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: sumByType(['benefit_car']) || undefined,
|
||||||
|
benefitHousing: sumByType(['benefit_housing']) || undefined,
|
||||||
|
benefitMeals: sumByType(['benefit_meals']) || undefined,
|
||||||
|
benefitOther: sumByType(['benefit_wellness', 'benefit_other']) || 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
|
||||||
|
const avgifterByCategory: AGITotals['avgifterByCategory'] = {}
|
||||||
|
for (const sre of runEmployees) {
|
||||||
|
const dbCategory = sre.avgifter_category as string | null
|
||||||
|
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: number, e: { avgifter_basis: number }) => s + e.avgifter_basis, 0),
|
||||||
|
avgifterByCategory,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if this is a correction
|
||||||
|
const { data: existingAgi } = await ctx.supabase
|
||||||
|
.from('agi_declarations')
|
||||||
|
.select('id, status')
|
||||||
|
.eq('company_id', ctx.companyId)
|
||||||
|
.eq('period_year', run.period_year)
|
||||||
|
.eq('period_month', run.period_month)
|
||||||
|
.in('status', ['submitted', 'accepted'])
|
||||||
|
.single()
|
||||||
|
|
||||||
|
const isCorrection = !!existingAgi
|
||||||
|
|
||||||
|
const arbetsgivare = formatRedovisare(settings.org_number, settings.entity_type)
|
||||||
|
const period = formatRedovisningsperiod('monthly', run.period_year, run.period_month)
|
||||||
|
const payload = buildAGIPayload(employeeData, totals, isCorrection)
|
||||||
|
|
||||||
|
return { arbetsgivare, period, payload, salaryRunId }
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Convert Skatteverket errors to appropriate HTTP responses.
|
* Convert Skatteverket errors to appropriate HTTP responses.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -0,0 +1,229 @@
|
|||||||
|
import type { SupabaseClient } from '@supabase/supabase-js'
|
||||||
|
import { skvRequest } from './api-client'
|
||||||
|
import type { SkatteverketAGIInlamning, SkatteverketAGIKontrollresultat } from '../types'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Skatteverket AGI (Arbetsgivardeklaration) API client.
|
||||||
|
*
|
||||||
|
* Follows the same pattern as the Momsdeklaration API:
|
||||||
|
* kontrollera → utkast → lås → (BankID signering) → inlämnat
|
||||||
|
*
|
||||||
|
* Base URL: https://api.skatteverket.se/arbetsgivardeklaration/inlamning/v1
|
||||||
|
*
|
||||||
|
* Endpoint pattern:
|
||||||
|
* /arbetsgivare/{arbetsgivarregistrerad}/redovisningsperioder/{redovisningsperiod}/...
|
||||||
|
*/
|
||||||
|
|
||||||
|
const DEFAULT_AGI_API_BASE_URL =
|
||||||
|
'https://api.test.skatteverket.se/arbetsgivardeklaration/inlamning/v1'
|
||||||
|
|
||||||
|
function getAgiApiBaseUrl(): string {
|
||||||
|
return process.env.SKATTEVERKET_AGI_API_BASE_URL || DEFAULT_AGI_API_BASE_URL
|
||||||
|
}
|
||||||
|
|
||||||
|
function basePath(arbetsgivare: string, period: string): string {
|
||||||
|
return `/arbetsgivare/${arbetsgivare}/redovisningsperioder/${period}`
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate AGI data (dry run) without saving.
|
||||||
|
* Returns validation errors/warnings.
|
||||||
|
*/
|
||||||
|
export async function agiValidate(
|
||||||
|
supabase: SupabaseClient,
|
||||||
|
userId: string,
|
||||||
|
arbetsgivare: string,
|
||||||
|
period: string,
|
||||||
|
payload: SkatteverketAGIInlamning
|
||||||
|
): Promise<{ ok: boolean; status: number; data?: SkatteverketAGIKontrollresultat; error?: string }> {
|
||||||
|
const response = await skvRequest(
|
||||||
|
supabase,
|
||||||
|
userId,
|
||||||
|
'POST',
|
||||||
|
`${basePath(arbetsgivare, period)}/kontrollera`,
|
||||||
|
payload,
|
||||||
|
{ baseUrl: getAgiApiBaseUrl() }
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const text = await response.text()
|
||||||
|
return { ok: false, status: response.status, error: text }
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json()
|
||||||
|
return { ok: true, status: response.status, data }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Save AGI as draft to Skatteverket's "Eget utrymme".
|
||||||
|
* Returns kontrollresultat and inlämningsId.
|
||||||
|
*/
|
||||||
|
export async function agiSaveDraft(
|
||||||
|
supabase: SupabaseClient,
|
||||||
|
userId: string,
|
||||||
|
arbetsgivare: string,
|
||||||
|
period: string,
|
||||||
|
payload: SkatteverketAGIInlamning
|
||||||
|
): Promise<{ ok: boolean; status: number; data?: { inlamningId?: string; kontrollresultat?: SkatteverketAGIKontrollresultat }; error?: string }> {
|
||||||
|
const response = await skvRequest(
|
||||||
|
supabase,
|
||||||
|
userId,
|
||||||
|
'POST',
|
||||||
|
`${basePath(arbetsgivare, period)}/inlamningar`,
|
||||||
|
payload,
|
||||||
|
{ baseUrl: getAgiApiBaseUrl() }
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const text = await response.text()
|
||||||
|
return { ok: false, status: response.status, error: text }
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json()
|
||||||
|
return { ok: true, status: response.status, data }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a specific AGI submission.
|
||||||
|
*/
|
||||||
|
export async function agiGetSubmission(
|
||||||
|
supabase: SupabaseClient,
|
||||||
|
userId: string,
|
||||||
|
arbetsgivare: string,
|
||||||
|
period: string,
|
||||||
|
inlamningId: string
|
||||||
|
): Promise<{ ok: boolean; status: number; data?: unknown; error?: string }> {
|
||||||
|
const response = await skvRequest(
|
||||||
|
supabase,
|
||||||
|
userId,
|
||||||
|
'GET',
|
||||||
|
`${basePath(arbetsgivare, period)}/inlamningar/${inlamningId}`,
|
||||||
|
undefined,
|
||||||
|
{ baseUrl: getAgiApiBaseUrl() }
|
||||||
|
)
|
||||||
|
|
||||||
|
if (response.status === 404) {
|
||||||
|
return { ok: true, status: 404, data: null }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const text = await response.text()
|
||||||
|
return { ok: false, status: response.status, error: text }
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json()
|
||||||
|
return { ok: true, status: response.status, data }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete a draft AGI submission.
|
||||||
|
*/
|
||||||
|
export async function agiDeleteDraft(
|
||||||
|
supabase: SupabaseClient,
|
||||||
|
userId: string,
|
||||||
|
arbetsgivare: string,
|
||||||
|
period: string,
|
||||||
|
inlamningId: string
|
||||||
|
): Promise<{ ok: boolean; status: number; error?: string }> {
|
||||||
|
const response = await skvRequest(
|
||||||
|
supabase,
|
||||||
|
userId,
|
||||||
|
'DELETE',
|
||||||
|
`${basePath(arbetsgivare, period)}/inlamningar/${inlamningId}`,
|
||||||
|
undefined,
|
||||||
|
{ baseUrl: getAgiApiBaseUrl() }
|
||||||
|
)
|
||||||
|
|
||||||
|
if (response.status !== 204 && !response.ok) {
|
||||||
|
const text = await response.text()
|
||||||
|
return { ok: false, status: response.status, error: text }
|
||||||
|
}
|
||||||
|
|
||||||
|
return { ok: true, status: response.status }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lock the reporting period for signing.
|
||||||
|
* Returns a signeringslänk for BankID signing on Skatteverket's site.
|
||||||
|
*/
|
||||||
|
export async function agiLockPeriod(
|
||||||
|
supabase: SupabaseClient,
|
||||||
|
userId: string,
|
||||||
|
arbetsgivare: string,
|
||||||
|
period: string
|
||||||
|
): Promise<{ ok: boolean; status: number; data?: { signeringslank?: string }; error?: string }> {
|
||||||
|
const response = await skvRequest(
|
||||||
|
supabase,
|
||||||
|
userId,
|
||||||
|
'PUT',
|
||||||
|
`${basePath(arbetsgivare, period)}/las`,
|
||||||
|
undefined,
|
||||||
|
{ baseUrl: getAgiApiBaseUrl() }
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const text = await response.text()
|
||||||
|
return { ok: false, status: response.status, error: text }
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json()
|
||||||
|
return { ok: true, status: response.status, data }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unlock a locked reporting period (cancel signing).
|
||||||
|
*/
|
||||||
|
export async function agiUnlockPeriod(
|
||||||
|
supabase: SupabaseClient,
|
||||||
|
userId: string,
|
||||||
|
arbetsgivare: string,
|
||||||
|
period: string
|
||||||
|
): Promise<{ ok: boolean; status: number; error?: string }> {
|
||||||
|
const response = await skvRequest(
|
||||||
|
supabase,
|
||||||
|
userId,
|
||||||
|
'DELETE',
|
||||||
|
`${basePath(arbetsgivare, period)}/las`,
|
||||||
|
undefined,
|
||||||
|
{ baseUrl: getAgiApiBaseUrl() }
|
||||||
|
)
|
||||||
|
|
||||||
|
if (response.status !== 204 && !response.ok) {
|
||||||
|
const text = await response.text()
|
||||||
|
return { ok: false, status: response.status, error: text }
|
||||||
|
}
|
||||||
|
|
||||||
|
return { ok: true, status: response.status }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch submitted AGI (after signing).
|
||||||
|
* Returns kvittensnummer and submission timestamp.
|
||||||
|
*/
|
||||||
|
export async function agiGetSubmitted(
|
||||||
|
supabase: SupabaseClient,
|
||||||
|
userId: string,
|
||||||
|
arbetsgivare: string,
|
||||||
|
period: string
|
||||||
|
): Promise<{ ok: boolean; status: number; data?: { kvittensnummer?: string; tidpunkt?: string; signerare?: string } | null; error?: string }> {
|
||||||
|
const response = await skvRequest(
|
||||||
|
supabase,
|
||||||
|
userId,
|
||||||
|
'GET',
|
||||||
|
`${basePath(arbetsgivare, period)}/inlamnat`,
|
||||||
|
undefined,
|
||||||
|
{ baseUrl: getAgiApiBaseUrl() }
|
||||||
|
)
|
||||||
|
|
||||||
|
if (response.status === 404) {
|
||||||
|
return { ok: true, status: 404, data: null }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const text = await response.text()
|
||||||
|
return { ok: false, status: response.status, error: text }
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json()
|
||||||
|
return { ok: true, status: response.status, data }
|
||||||
|
}
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
import type { AGIEmployeeData, AGITotals } from '@/lib/salary/agi/xml-generator'
|
||||||
|
import type { SkatteverketAGIInlamning, SkatteverketHuvuduppgift, SkatteverketIndividuppgift } from '../types'
|
||||||
|
import { decryptPersonnummer } from '@/lib/salary/personnummer'
|
||||||
|
|
||||||
|
// Re-export shared formatting utilities
|
||||||
|
export { formatRedovisare, formatRedovisningsperiod } from '@/lib/skatteverket/format'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert gnubok salary run data to Skatteverket AGI JSON payload.
|
||||||
|
*
|
||||||
|
* JSON property names are derived from Skatteverket's XML element names,
|
||||||
|
* following the same camelCase convention as the Momsdeklaration API.
|
||||||
|
* The exact names should be verified against the RAML spec on Utvecklarportalen.
|
||||||
|
*
|
||||||
|
* CRITICAL: FK570 (specifikationsnummer) must stay consistent per employee.
|
||||||
|
* Using a different number creates a new record instead of a correction.
|
||||||
|
*/
|
||||||
|
export function buildAGIPayload(
|
||||||
|
employees: AGIEmployeeData[],
|
||||||
|
totals: AGITotals,
|
||||||
|
isCorrection: boolean = false
|
||||||
|
): SkatteverketAGIInlamning {
|
||||||
|
const huvuduppgift = buildHuvuduppgift(totals)
|
||||||
|
const individuppgifter = employees.map(emp => buildIndividuppgift(emp))
|
||||||
|
|
||||||
|
return {
|
||||||
|
rattelse: isCorrection,
|
||||||
|
huvuduppgift,
|
||||||
|
individuppgifter,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildHuvuduppgift(totals: AGITotals): SkatteverketHuvuduppgift {
|
||||||
|
const result: SkatteverketHuvuduppgift = {}
|
||||||
|
|
||||||
|
if (totals.totalTax > 0) {
|
||||||
|
result.avdragenSkatt = Math.round(totals.totalTax)
|
||||||
|
}
|
||||||
|
if (totals.totalAvgifterBasis > 0) {
|
||||||
|
result.summaArbetsgivaravgifterUnderlag = Math.round(totals.totalAvgifterBasis)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Avgifter by category (rutor 060-062)
|
||||||
|
if (totals.avgifterByCategory.standard) {
|
||||||
|
result.avgifterUnderlagStandard = Math.round(totals.avgifterByCategory.standard.basis)
|
||||||
|
}
|
||||||
|
if (totals.avgifterByCategory.reduced65plus) {
|
||||||
|
result.avgifterUnderlagAlderspension = Math.round(totals.avgifterByCategory.reduced65plus.basis)
|
||||||
|
}
|
||||||
|
if (totals.avgifterByCategory.youth) {
|
||||||
|
result.avgifterUnderlagUngdom = Math.round(totals.avgifterByCategory.youth.basis)
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildIndividuppgift(emp: AGIEmployeeData): SkatteverketIndividuppgift {
|
||||||
|
// Decrypt personnummer — must be plaintext for Skatteverket
|
||||||
|
let personnummer: string
|
||||||
|
try {
|
||||||
|
personnummer = decryptPersonnummer(emp.personnummer)
|
||||||
|
} catch {
|
||||||
|
throw new Error(
|
||||||
|
`Kunde inte dekryptera personnummer för anställd med FK570=${emp.specificationNumber}. ` +
|
||||||
|
'AGI kan inte skickas utan giltigt personnummer.'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const result: SkatteverketIndividuppgift = {
|
||||||
|
personnummer,
|
||||||
|
specifikationsnummer: emp.specificationNumber,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only include non-zero values (Skatteverket treats absent fields as 0)
|
||||||
|
if (emp.grossSalary > 0) result.kontantBruttoloen = Math.round(emp.grossSalary)
|
||||||
|
if (emp.taxWithheld > 0) result.avdragenSkatt = Math.round(emp.taxWithheld)
|
||||||
|
if (emp.avgifterBasis > 0) result.underlagArbetsgivaravgifter = Math.round(emp.avgifterBasis)
|
||||||
|
if (emp.fSkattPayment && emp.fSkattPayment > 0) result.ersattningFSkatt = Math.round(emp.fSkattPayment)
|
||||||
|
|
||||||
|
// Benefits (rutor 012-019)
|
||||||
|
if (emp.benefitCar && emp.benefitCar > 0) result.formanBil = Math.round(emp.benefitCar)
|
||||||
|
if (emp.benefitFuel && emp.benefitFuel > 0) result.formanDrivmedel = Math.round(emp.benefitFuel)
|
||||||
|
if (emp.benefitHousing && emp.benefitHousing > 0) result.formanBostad = Math.round(emp.benefitHousing)
|
||||||
|
if (emp.benefitMeals && emp.benefitMeals > 0) result.formanKost = Math.round(emp.benefitMeals)
|
||||||
|
if (emp.benefitOther && emp.benefitOther > 0) result.formanOvrigt = Math.round(emp.benefitOther)
|
||||||
|
|
||||||
|
// Absence fields (from 2025)
|
||||||
|
if (emp.sickDays && emp.sickDays > 0) result.sjukfranvaroDagar = Math.round(emp.sickDays)
|
||||||
|
if (emp.vabDays && emp.vabDays > 0) result.vabDagar = Math.round(emp.vabDays)
|
||||||
|
if (emp.parentalDays && emp.parentalDays > 0) result.foraldraledigDagar = Math.round(emp.parentalDays)
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
@@ -111,13 +111,14 @@ export async function skvRequest(
|
|||||||
userId: string,
|
userId: string,
|
||||||
method: string,
|
method: string,
|
||||||
path: string,
|
path: string,
|
||||||
body?: unknown
|
body?: unknown,
|
||||||
|
options?: { baseUrl?: string }
|
||||||
): Promise<Response> {
|
): Promise<Response> {
|
||||||
const accessToken = await getValidToken(supabase, userId)
|
const accessToken = await getValidToken(supabase, userId)
|
||||||
|
|
||||||
await enforceRateLimit()
|
await enforceRateLimit()
|
||||||
|
|
||||||
const url = `${getApiBaseUrl()}${path}`
|
const url = `${options?.baseUrl || getApiBaseUrl()}${path}`
|
||||||
const headers: Record<string, string> = {
|
const headers: Record<string, string> = {
|
||||||
'Authorization': `Bearer ${accessToken}`,
|
'Authorization': `Bearer ${accessToken}`,
|
||||||
'Client_Id': getApiGwClientId(),
|
'Client_Id': getApiGwClientId(),
|
||||||
|
|||||||
@@ -84,6 +84,72 @@ export type DeclarationStatus =
|
|||||||
| 'signed'
|
| 'signed'
|
||||||
| 'decided'
|
| 'decided'
|
||||||
|
|
||||||
|
// ── AGI (Arbetsgivardeklaration) types ──────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AGI submission payload — sent to Skatteverket inlämning API.
|
||||||
|
*
|
||||||
|
* JSON property names follow the same camelCase convention as the
|
||||||
|
* Momsdeklaration API. Derived from Skatteverket's XML element names
|
||||||
|
* and FK field codes. Verify against the RAML spec on Utvecklarportalen.
|
||||||
|
*/
|
||||||
|
export interface SkatteverketAGIInlamning {
|
||||||
|
rattelse: boolean
|
||||||
|
huvuduppgift: SkatteverketHuvuduppgift
|
||||||
|
individuppgifter: SkatteverketIndividuppgift[]
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Employer-level totals (Huvuduppgift) */
|
||||||
|
export interface SkatteverketHuvuduppgift {
|
||||||
|
/** Ruta 001: Total avdragen skatt */
|
||||||
|
avdragenSkatt?: number
|
||||||
|
/** Ruta 020: Total underlag arbetsgivaravgifter */
|
||||||
|
summaArbetsgivaravgifterUnderlag?: number
|
||||||
|
/** Ruta 060: Avgifter — standard rate (31.42%) */
|
||||||
|
avgifterUnderlagStandard?: number
|
||||||
|
/** Ruta 061: Avgifter — ålderspension only (10.21%, 67+ from 2026) */
|
||||||
|
avgifterUnderlagAlderspension?: number
|
||||||
|
/** Ruta 062: Avgifter — youth rate (20.81%, ages 19-23, Apr 2026–Sep 2027) */
|
||||||
|
avgifterUnderlagUngdom?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Per-employee data (Individuppgift) */
|
||||||
|
export interface SkatteverketIndividuppgift {
|
||||||
|
/** FK215: Personnummer/samordningsnummer (12 digits, plaintext) */
|
||||||
|
personnummer: string
|
||||||
|
/** FK570: Specifikationsnummer — MUST stay consistent per employee */
|
||||||
|
specifikationsnummer: number
|
||||||
|
/** Ruta 011: Kontant bruttolön */
|
||||||
|
kontantBruttoloen?: number
|
||||||
|
/** Ruta 001: Avdragen skatt */
|
||||||
|
avdragenSkatt?: number
|
||||||
|
/** Ruta 012: Förmån bil */
|
||||||
|
formanBil?: number
|
||||||
|
/** Ruta 013: Förmån drivmedel */
|
||||||
|
formanDrivmedel?: number
|
||||||
|
/** Ruta 014: Förmån bostad */
|
||||||
|
formanBostad?: number
|
||||||
|
/** Ruta 015: Förmån kost */
|
||||||
|
formanKost?: number
|
||||||
|
/** Ruta 019: Förmån övrigt */
|
||||||
|
formanOvrigt?: number
|
||||||
|
/** Ruta 020: Underlag arbetsgivaravgifter */
|
||||||
|
underlagArbetsgivaravgifter?: number
|
||||||
|
/** Ruta 131: Ersättning till F-skatt holder */
|
||||||
|
ersattningFSkatt?: number
|
||||||
|
/** FK821: Sjukfrånvaro dagar */
|
||||||
|
sjukfranvaroDagar?: number
|
||||||
|
/** FK822: VAB dagar */
|
||||||
|
vabDagar?: number
|
||||||
|
/** FK823: Föräldraledighet dagar */
|
||||||
|
foraldraledigDagar?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/** AGI validation result from Skatteverket /kontrollera */
|
||||||
|
export interface SkatteverketAGIKontrollresultat {
|
||||||
|
kontroller?: SkatteverketKontroll[]
|
||||||
|
}
|
||||||
|
|
||||||
export interface SkatteverketSubmission {
|
export interface SkatteverketSubmission {
|
||||||
id: string
|
id: string
|
||||||
user_id: string
|
user_id: string
|
||||||
|
|||||||
+71
-2
@@ -636,7 +636,8 @@ export const SalaryLineItemTypeSchema = z.enum([
|
|||||||
'correction', 'other',
|
'correction', 'other',
|
||||||
])
|
])
|
||||||
|
|
||||||
export const CreateEmployeeSchema = z.object({
|
// Base employee object (no refinements — safe for .partial())
|
||||||
|
const EmployeeSchemaBase = z.object({
|
||||||
first_name: z.string().min(1).max(200),
|
first_name: z.string().min(1).max(200),
|
||||||
last_name: z.string().min(1).max(200),
|
last_name: z.string().min(1).max(200),
|
||||||
personnummer: z.string().regex(/^\d{12}$/, 'Personnummer måste vara 12 siffror (ÅÅÅÅMMDDNNNN)'),
|
personnummer: z.string().regex(/^\d{12}$/, 'Personnummer måste vara 12 siffror (ÅÅÅÅMMDDNNNN)'),
|
||||||
@@ -667,7 +668,75 @@ export const CreateEmployeeSchema = z.object({
|
|||||||
vaxa_stod_end: isoDate.optional(),
|
vaxa_stod_end: isoDate.optional(),
|
||||||
})
|
})
|
||||||
|
|
||||||
export const UpdateEmployeeSchema = CreateEmployeeSchema.partial()
|
export const CreateEmployeeSchema = EmployeeSchemaBase.superRefine((data, ctx) => {
|
||||||
|
// Salary amount required based on salary_type
|
||||||
|
if (data.salary_type === 'monthly' && (data.monthly_salary === undefined || data.monthly_salary === null || data.monthly_salary <= 0)) {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: z.ZodIssueCode.custom,
|
||||||
|
message: 'Månadslön krävs och måste vara större än 0 för månadslöneform',
|
||||||
|
path: ['monthly_salary'],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if (data.salary_type === 'hourly' && (data.hourly_rate === undefined || data.hourly_rate === null || data.hourly_rate <= 0)) {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: z.ZodIssueCode.custom,
|
||||||
|
message: 'Timlön krävs och måste vara större än 0 för timlöneform',
|
||||||
|
path: ['hourly_rate'],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tax table required for A-skatt employees (not sidoinkomst)
|
||||||
|
if (data.f_skatt_status === 'a_skatt' && !data.is_sidoinkomst && !data.tax_table_number) {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: z.ZodIssueCode.custom,
|
||||||
|
message: 'Skattetabell krävs för A-skatt anställda (baseras på folkbokföringskommun)',
|
||||||
|
path: ['tax_table_number'],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tax municipality recommended when tax table is set
|
||||||
|
if (data.tax_table_number && !data.tax_municipality) {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: z.ZodIssueCode.custom,
|
||||||
|
message: 'Folkbokföringskommun bör anges för att dokumentera skattetabellens underlag',
|
||||||
|
path: ['tax_municipality'],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
export const UpdateEmployeeSchema = EmployeeSchemaBase.partial().superRefine((data, ctx) => {
|
||||||
|
// Only validate salary when salary_type is being changed in this update
|
||||||
|
if (data.salary_type === 'monthly' && data.monthly_salary !== undefined && data.monthly_salary <= 0) {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: z.ZodIssueCode.custom,
|
||||||
|
message: 'Månadslön måste vara större än 0 för månadslöneform',
|
||||||
|
path: ['monthly_salary'],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if (data.salary_type === 'hourly' && data.hourly_rate !== undefined && data.hourly_rate <= 0) {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: z.ZodIssueCode.custom,
|
||||||
|
message: 'Timlön måste vara större än 0 för timlöneform',
|
||||||
|
path: ['hourly_rate'],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// If setting salary_type, require the corresponding salary field
|
||||||
|
if (data.salary_type === 'monthly' && !('monthly_salary' in data)) {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: z.ZodIssueCode.custom,
|
||||||
|
message: 'Månadslön måste anges vid byte till månadslöneform',
|
||||||
|
path: ['monthly_salary'],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if (data.salary_type === 'hourly' && !('hourly_rate' in data)) {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: z.ZodIssueCode.custom,
|
||||||
|
message: 'Timlön måste anges vid byte till timlöneform',
|
||||||
|
path: ['hourly_rate'],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
export const CreateSalaryRunSchema = z.object({
|
export const CreateSalaryRunSchema = z.object({
|
||||||
period_year: z.number().int().min(2020).max(2100),
|
period_year: z.number().int().min(2020).max(2100),
|
||||||
|
|||||||
@@ -79,6 +79,7 @@ export type CoreEvent =
|
|||||||
| { type: 'salary_run.approved'; payload: { salaryRunId: string; approvedBy: string; userId: string; companyId: string } }
|
| { type: 'salary_run.approved'; payload: { salaryRunId: string; approvedBy: string; userId: string; companyId: string } }
|
||||||
| { type: 'salary_run.booked'; payload: { salaryRunId: string; entryIds: string[]; userId: string; companyId: string } }
|
| { type: 'salary_run.booked'; payload: { salaryRunId: string; entryIds: string[]; userId: string; companyId: string } }
|
||||||
| { type: 'agi.generated'; payload: { agiId: string; periodYear: number; periodMonth: number; userId: string; companyId: string } }
|
| { type: 'agi.generated'; payload: { agiId: string; periodYear: number; periodMonth: number; userId: string; companyId: string } }
|
||||||
|
| { type: 'agi.submitted'; payload: { salaryRunId: string; periodYear: number; periodMonth: number; userId: string; companyId: string } }
|
||||||
// Company & account lifecycle
|
// Company & account lifecycle
|
||||||
| { type: 'company.deleted'; payload: { companyId: string; userId: string; archivedAt: string } }
|
| { type: 'company.deleted'; payload: { companyId: string; userId: string; archivedAt: string } }
|
||||||
| { type: 'account.deleted'; payload: { userId: string; deletedAt: string } }
|
| { type: 'account.deleted'; payload: { userId: string; deletedAt: string } }
|
||||||
|
|||||||
Reference in New Issue
Block a user