Feat/skv integration full (#284)

* feat: add script to import Skatteverket monthly tax tables as fallback TypeScript module

- Implemented a new script `import-tax-tables.ts` to parse fixed-width TXT tax tables from Skatteverket (SKV 434).
- The script generates a TypeScript module for emergency fallback when the Skatteverket open-data API is unavailable.
- Supports command-line argument for specifying the year and handles parsing of B-rows only.
- Outputs a structured TypeScript file containing tax data for specified years.

* feat: gate salary module behind dev-only flag

Temporarily disable the Lön module in production while the feature is
being completed. Sidebar entries ("Löner", "Anställda") still render but
are not clickable and show a "Kommer snart" badge. Middleware redirects
/salary* to / and returns 404 on /api/salary/* so the feature can't be
reached by direct URL. All gates check NODE_ENV === 'development' so
local dev keeps full access for continued development.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat: refactor bank file import wizard to streamline column mapping and enhance CSV handling

* fix: bump migration timestamp to avoid collision with logos_bucket

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat: enhance AGI generation and salary entry calculations with improved status checks and error handling

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Mattsson
2026-04-20 21:03:23 +02:00
committed by GitHub
parent 7a0214c053
commit 11621bb79f
23 changed files with 16227 additions and 340 deletions
+8 -22
View File
@@ -37,7 +37,6 @@ import AccountMappingStep from '@/components/import/AccountMappingStep'
import ImportReviewStep, { type ImportExecuteOptions } from '@/components/import/ImportReviewStep'
import ImportResultStep from '@/components/import/ImportResultStep'
import { applyMappingOverride } from '@/lib/import/account-mapper'
import { getCSVHeaders, getCSVPreview } from '@/lib/import/bank-file/formats/generic-csv'
import type { BankFileParseResult, BankFileFormatId, GenericCSVColumnMapping } from '@/lib/import/bank-file/types'
import type { IngestResult } from '@/lib/transactions/ingest'
import type {
@@ -64,7 +63,7 @@ const MigrationWizard = dynamic(
type BankFileStep = 'upload' | 'preview' | 'column_mapping' | 'confirm' | 'result'
const BANK_STEPS: BankFileStep[] = ['upload', 'preview', 'confirm', 'result']
const BANK_STEPS_WITH_MAPPING: BankFileStep[] = ['upload', 'preview', 'column_mapping', 'confirm', 'result']
const BANK_STEPS_WITH_MAPPING: BankFileStep[] = ['upload', 'column_mapping', 'confirm', 'result']
const BANK_STEP_LABELS: Record<BankFileStep, string> = {
upload: 'Ladda upp',
@@ -89,10 +88,6 @@ function BankFileImportWizard() {
const [filename, setFilename] = useState<string>('')
const [rawFileContent, setRawFileContent] = useState<string>('')
// Column mapping for generic CSV
const [csvHeaders, setCsvHeaders] = useState<string[]>([])
const [csvPreview, setCsvPreview] = useState<string[][]>([])
// Import result
const [ingestResult, setIngestResult] = useState<IngestResult | null>(null)
@@ -132,28 +127,22 @@ function BankFileImportWizard() {
setDetectedFormatName(data.data.detected_format_name)
setFileHash(data.data.file_hash)
setFilename(data.data.filename)
// Store headers for generic CSV mapping
if (data.data.headers) {
setCsvHeaders(data.data.headers)
}
// Read raw file content for CSV preview
const text = await file.text()
setRawFileContent(text)
if (data.data.parse_result.format === 'generic_csv') {
setCsvHeaders(getCSVHeaders(text))
setCsvPreview(getCSVPreview(text, ',', 6))
}
const txCount = data.data.parse_result.transactions.length
if (txCount > 0) {
if (data.data.parse_result.format === 'generic_csv') {
// Auto-detect failed or user picked "Annan CSV" — always route to manual column mapping.
// Default mapping rarely matches, so advance regardless of tx count.
setBankStep('column_mapping')
} else if (txCount > 0) {
setBankStep('preview')
toast({
title: 'Fil analyserad',
description: `${txCount} transaktioner hittades`,
})
} else if (data.data.parse_result.format === 'generic_csv' || !data.data.detected_format) {
setBankError('Kunde inte identifiera bankformatet. Välj bank manuellt eller använd "Annan CSV".')
} else {
// Format detected but no transactions parsed — parser couldn't extract rows
setBankError('Filen kunde läsas men inga transaktioner hittades. Kontrollera att filen innehåller transaktionsdata och inte bara rubriker.')
@@ -222,8 +211,6 @@ function BankFileImportWizard() {
setFilename('')
setIngestResult(null)
setBankError(null)
setCsvHeaders([])
setCsvPreview([])
setRawFileContent('')
}
@@ -281,10 +268,9 @@ function BankFileImportWizard() {
{bankStep === 'column_mapping' && (
<BankFileColumnMappingStep
headers={csvHeaders}
previewRows={csvPreview}
rawFileContent={rawFileContent}
onConfirm={handleColumnMappingConfirm}
onBack={() => setBankStep('preview')}
onBack={() => setBankStep('upload')}
/>
)}
+143 -4
View File
@@ -8,7 +8,7 @@ import { Button } from '@/components/ui/button'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import {
ArrowLeft, Plus, Calculator, Eye, Check, CreditCard, BookOpen,
ArrowLeftCircle, Loader2,
ArrowLeftCircle, Loader2, Download, Send, CheckCircle2,
} from 'lucide-react'
import { useToast } from '@/components/ui/use-toast'
import { useCanWrite } from '@/lib/hooks/use-can-write'
@@ -22,6 +22,7 @@ const STATUS_LABELS: Record<string, string> = {
approved: 'Godkänd',
paid: 'Betald',
booked: 'Bokförd',
corrected: 'Korrigerad',
}
const STATUS_COLORS: Record<string, string> = {
@@ -30,6 +31,7 @@ const STATUS_COLORS: Record<string, string> = {
approved: 'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-400',
paid: 'bg-emerald-100 text-emerald-800 dark:bg-emerald-900/30 dark:text-emerald-400',
booked: 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400',
corrected: 'bg-muted text-muted-foreground',
}
interface EntryPreview {
@@ -142,6 +144,73 @@ export default function SalaryRunDetailPage({ params }: { params: Promise<{ id:
setActionLoading(null)
}
async function handleDownloadAgi() {
setActionLoading('agi-download')
const res = await fetch(`/api/salary/runs/${id}/agi/xml`)
if (!res.ok) {
const result = await res.json().catch(() => ({ error: 'Kunde inte generera AGI-fil' }))
toast({
title: 'AGI-fil kunde inte genereras',
description: getErrorMessage(result, { context: 'salary', statusCode: res.status }),
variant: 'destructive',
})
setActionLoading(null)
return
}
const blob = await res.blob()
const url = URL.createObjectURL(blob)
const periodLabel = `${run!.period_year}${String(run!.period_month).padStart(2, '0')}`
const a = document.createElement('a')
a.href = url
a.download = `AGI_${periodLabel}.xml`
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
URL.revokeObjectURL(url)
await loadRun()
toast({ title: 'AGI-fil nedladdad' })
setActionLoading(null)
}
async function handleSubmitAgi() {
setActionLoading('agi-submit')
// Generate AGI XML first if it hasn't been generated yet.
if (!run?.agi_generated_at) {
const xmlRes = await fetch(`/api/salary/runs/${id}/agi/xml`)
if (!xmlRes.ok) {
const result = await xmlRes.json().catch(() => ({ error: 'Kunde inte generera AGI-fil' }))
toast({
title: 'AGI kunde inte genereras',
description: getErrorMessage(result, { context: 'salary', statusCode: xmlRes.status }),
variant: 'destructive',
})
setActionLoading(null)
return
}
}
const res = await fetch(`/api/salary/runs/${id}/agi/submit`, { method: 'POST' })
const payload = await res.json().catch(() => ({}))
if (res.ok) {
await loadRun()
toast({
title: 'AGI skickad till Skatteverket',
description:
(payload?.data?.message as string | undefined) ??
'Signera med BankID hos Skatteverket för att slutföra inlämningen.',
})
} else {
toast({
title: 'Kunde inte skicka till Skatteverket',
description: getErrorMessage(payload, { context: 'salary', statusCode: res.status }),
variant: 'destructive',
})
}
setActionLoading(null)
}
if (loading) {
return (
<div className="space-y-6">
@@ -269,7 +338,7 @@ export default function SalaryRunDetailPage({ params }: { params: Promise<{ id:
</CardHeader>
<CardContent className="space-y-4">
{employees.filter(e => e.calculation_breakdown).map(sre => {
const breakdown = sre.calculation_breakdown as { steps?: Array<{ label: string; formula: string; output: number }> }
const breakdown = sre.calculation_breakdown as { steps?: Array<{ label: string; formula: string; output: number | null }> }
return (
<div key={sre.id} className="space-y-2">
<h4 className="text-sm font-medium">
@@ -279,9 +348,13 @@ export default function SalaryRunDetailPage({ params }: { params: Promise<{ id:
</h4>
<div className="text-xs space-y-1 bg-muted/50 rounded-lg p-3">
{(breakdown?.steps || []).map((step, i) => (
<div key={i} className="flex justify-between">
<span className="text-muted-foreground">{step.label}: <span className="font-mono">{step.formula}</span></span>
<div key={i} className="flex justify-between gap-4">
<span className="text-muted-foreground">
{step.label}: <span className="font-mono">{step.formula}</span>
</span>
{step.output !== null && (
<span className="font-medium tabular-nums">{formatCurrency(step.output)}</span>
)}
</div>
))}
</div>
@@ -328,6 +401,72 @@ export default function SalaryRunDetailPage({ params }: { params: Promise<{ id:
</Card>
)}
{/* AGI (Arbetsgivardeklaration) — available once the run is booked */}
{run.status === 'booked' && (
<Card>
<CardHeader>
<CardTitle className="text-base">Arbetsgivardeklaration (AGI)</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-1.5 text-sm">
<div className="flex items-center gap-2">
{run.agi_generated_at ? (
<>
<CheckCircle2 className="h-4 w-4 text-emerald-600" />
<span className="text-muted-foreground">
AGI-fil genererad {new Date(run.agi_generated_at).toLocaleString('sv-SE')}
</span>
</>
) : (
<span className="text-muted-foreground">AGI-fil har inte genererats ännu.</span>
)}
</div>
<div className="flex items-center gap-2">
{run.agi_submitted_at ? (
<>
<CheckCircle2 className="h-4 w-4 text-emerald-600" />
<span className="text-muted-foreground">
Skickad till Skatteverket {new Date(run.agi_submitted_at).toLocaleString('sv-SE')}
</span>
</>
) : (
<span className="text-muted-foreground">
Inte skickad till Skatteverket ännu. Deadline: 12:e i månaden efter utbetalning.
</span>
)}
</div>
</div>
{canWrite && (
<div className="flex flex-wrap gap-3">
<Button
variant="outline"
onClick={handleDownloadAgi}
disabled={!!actionLoading}
>
{actionLoading === 'agi-download' ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Download className="mr-2 h-4 w-4" />
)}
Ladda ner AGI-fil
</Button>
<Button
onClick={handleSubmitAgi}
disabled={!!actionLoading || !!run.agi_submitted_at}
>
{actionLoading === 'agi-submit' ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Send className="mr-2 h-4 w-4" />
)}
Skicka till Skatteverket
</Button>
</div>
)}
</CardContent>
</Card>
)}
{/* Actions */}
{canWrite && (
<div className="flex flex-wrap gap-3 justify-end">
+11 -5
View File
@@ -42,10 +42,16 @@ export async function GET(
const { data: settings } = await supabase
.from('company_settings')
.select('contact_name, contact_phone, contact_email')
.select('org_number, phone, email')
.eq('company_id', companyId)
.single()
const { data: profile } = await supabase
.from('profiles')
.select('full_name, email')
.eq('id', user.id)
.single()
// Load all booked salary run employees for the year, grouped by employee
const { data: runEmployees, error } = await supabase
.from('salary_run_employees')
@@ -114,12 +120,12 @@ export async function GET(
}
const companyData: KU10CompanyData = {
orgNumber: company.org_number || '',
orgNumber: (settings?.org_number || company.org_number || '').trim(),
companyName: company.name,
year: yearNum,
contactName: settings?.contact_name || company.name,
contactPhone: settings?.contact_phone || '',
contactEmail: settings?.contact_email || '',
contactName: (profile?.full_name || company.name || '').trim(),
contactPhone: (settings?.phone || '').trim(),
contactEmail: (settings?.email || profile?.email || user.email || '').trim(),
}
const r = (x: number) => Math.round(x * 100) / 100
+53 -12
View File
@@ -2,7 +2,7 @@ import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { ensureInitialized } from '@/lib/init'
import { requireCompanyId } from '@/lib/company/context'
import { generateAGIXml, buildIndividuppgifterSnapshot } from '@/lib/salary/agi/xml-generator'
import { generateAGIXml, buildIndividuppgifterSnapshot, AGIIncompleteDataError } from '@/lib/salary/agi/xml-generator'
import type { AGIEmployeeData, AGICompanyData, AGITotals } from '@/lib/salary/agi/xml-generator'
import { eventBus } from '@/lib/events'
@@ -40,7 +40,7 @@ export async function GET(
return NextResponse.json({ error: 'Lönekörning hittades inte' }, { status: 404 })
}
if (!['review', 'approved', 'paid', 'booked'].includes(run.status)) {
if (!['review', 'approved', 'paid', 'booked', 'corrected'].includes(run.status)) {
return NextResponse.json({ error: 'AGI kan bara genereras efter granskning' }, { status: 400 })
}
@@ -55,13 +55,22 @@ export async function GET(
return NextResponse.json({ error: 'Företag hittades inte' }, { status: 404 })
}
// Load company settings for contact info
// Load company-level phone/email/org from settings (user-editable under /settings/company).
// Note: the schema has `phone` and `email` — there is no separate `contact_*` column.
const { data: settings } = await supabase
.from('company_settings')
.select('contact_name, contact_phone, contact_email')
.select('org_number, phone, email')
.eq('company_id', companyId)
.single()
// Technical contact name comes from the signed-in user's profile (the person
// generating the file), falling back to the company name.
const { data: profile } = await supabase
.from('profiles')
.select('full_name, email')
.eq('id', user.id)
.single()
// Load employees with their data
const { data: runEmployees } = await supabase
.from('salary_run_employees')
@@ -72,15 +81,17 @@ export async function GET(
return NextResponse.json({ error: 'Inga anställda i lönekörningen' }, { status: 400 })
}
// Build AGI data
// Build AGI data.
// org_number: prefer the user-editable company_settings.org_number, fall
// back to companies.org_number (set during onboarding).
const companyData: AGICompanyData = {
orgNumber: company.org_number || '',
orgNumber: (settings?.org_number || company.org_number || '').trim(),
companyName: company.name,
periodYear: run.period_year,
periodMonth: run.period_month,
contactName: settings?.contact_name || company.name,
contactPhone: settings?.contact_phone || '',
contactEmail: settings?.contact_email || '',
contactName: (profile?.full_name || company.name || '').trim(),
contactPhone: (settings?.phone || '').trim(),
contactEmail: (settings?.email || profile?.email || user.email || '').trim(),
}
const employeeData: AGIEmployeeData[] = runEmployees.map(sre => {
@@ -124,9 +135,29 @@ export async function GET(
;(avgifterByCategory as Record<string, { basis: number; amount: number }>)[category] = cat
}
const totalAvgifterAmount = Object.values(avgifterByCategory).reduce(
(sum, cat) => sum + (cat?.amount ?? 0),
0
)
// FK499 TotalSjuklonekostnad — sum of sjuklön paid (days 214) across all
// employees. Day 1 is karens (unpaid); day 15+ is Försäkringskassan, so
// neither counts as an employer sjuklön cost.
let totalSjuklonekostnad = 0
for (const sre of runEmployees) {
const lineItems = (sre.line_items || []) as Array<Record<string, unknown>>
for (const li of lineItems) {
if (li.item_type === 'sick_day2_14') {
totalSjuklonekostnad += Math.abs((li.amount as number) || 0)
}
}
}
const totals: AGITotals = {
totalTax: run.total_tax,
totalAvgifterBasis: runEmployees.reduce((s, e) => s + e.avgifter_basis, 0),
totalAvgifterAmount: Math.round(totalAvgifterAmount * 100) / 100,
totalSjuklonekostnad: Math.round(totalSjuklonekostnad * 100) / 100,
avgifterByCategory,
}
@@ -141,10 +172,21 @@ export async function GET(
const isCorrection = !!existingAgi
const xml = generateAGIXml(companyData, employeeData, totals, isCorrection)
let xml: string
try {
xml = generateAGIXml(companyData, employeeData, totals, isCorrection)
} catch (err) {
if (err instanceof AGIIncompleteDataError) {
return NextResponse.json({ error: err.message, missingFields: err.missingFields }, { status: 400 })
}
throw err
}
const individuppgifter = buildIndividuppgifterSnapshot(employeeData)
// Store AGI declaration (upsert for corrections per unique constraint)
// Store AGI declaration (upsert for corrections per unique constraint).
// In-place update: leave corrects_agi_id null — a record must not reference
// itself as the declaration it corrects. When a true correction chain is
// needed, create a new row pointing to the original instead.
if (existingAgi) {
await supabase
.from('agi_declarations')
@@ -157,7 +199,6 @@ export async function GET(
total_avgifter: run.total_avgifter,
employee_count: employeeData.length,
is_correction: true,
corrects_agi_id: existingAgi.id,
salary_run_id: run.id,
})
.eq('id', existingAgi.id)
+4
View File
@@ -5,6 +5,9 @@ import { requireCompanyId } from '@/lib/company/context'
import { requireWritePermission } from '@/lib/auth/require-write'
import { createSalaryRunEntries } from '@/lib/salary/salary-entries'
import { eventBus } from '@/lib/events'
import { createLogger } from '@/lib/logger'
const log = createLogger('salary-book-route')
ensureInitialized()
@@ -120,6 +123,7 @@ export async function POST(
return NextResponse.json({ data: bookedRun })
} catch (err) {
const message = err instanceof Error ? err.message : 'Bokföring misslyckades'
log.error(`Booking failed for salary run ${id}: ${message}`, err instanceof Error ? err.stack : err)
return NextResponse.json({ error: message }, { status: 500 })
}
}
+38 -7
View File
@@ -5,7 +5,7 @@ import { requireCompanyId } from '@/lib/company/context'
import { requireWritePermission } from '@/lib/auth/require-write'
import { calculateSalary } from '@/lib/salary/calculation-engine'
import { loadPayrollConfig, serializePayrollConfig } from '@/lib/salary/payroll-config'
import { fetchAllTaxTableRatesForRun } from '@/lib/salary/tax-tables'
import { fetchAllTaxTableRatesForRun, TaxTableUnavailableError } from '@/lib/salary/tax-tables'
import type { SalaryLineItemType } from '@/types'
ensureInitialized()
@@ -81,9 +81,24 @@ export async function POST(
// Fetch tax table rates from Skatteverket API for all needed tables/columns
const tableNumbers = [...new Set(runEmployees.filter(e => e.employee?.tax_table_number).map(e => e.employee.tax_table_number as number))]
const columns = [...new Set(runEmployees.filter(e => e.employee?.tax_column).map(e => e.employee.tax_column as number))]
const taxRates = tableNumbers.length > 0
? await fetchAllTaxTableRatesForRun(paymentYear, tableNumbers, columns.length > 0 ? columns : [1])
: []
let taxRates: Awaited<ReturnType<typeof fetchAllTaxTableRatesForRun>>['rates'] = []
let taxTableSource: Awaited<ReturnType<typeof fetchAllTaxTableRatesForRun>>['source'] = 'api'
if (tableNumbers.length > 0) {
try {
const result = await fetchAllTaxTableRatesForRun(
paymentYear,
tableNumbers,
columns.length > 0 ? columns : [1]
)
taxRates = result.rates
taxTableSource = result.source
} catch (err) {
if (err instanceof TaxTableUnavailableError) {
return NextResponse.json({ error: err.message }, { status: 503 })
}
throw err
}
}
let totalGross = 0
let totalTax = 0
@@ -172,8 +187,9 @@ export async function POST(
const parentalDays = sumQuantity(['parental_leave'])
const vacationDays = sumQuantity(['vacation'])
// Update salary_run_employee with calculated results
await supabase
// Update salary_run_employee with calculated results. If any individual
// update fails we abort so run totals aren't written from partial data.
const { error: empUpdateError } = await supabase
.from('salary_run_employees')
.update({
gross_salary: result.grossSalary,
@@ -203,6 +219,10 @@ export async function POST(
})
.eq('id', sre.id)
if (empUpdateError) {
return NextResponse.json({ error: empUpdateError.message }, { status: 500 })
}
totalGross += result.grossSalary
totalTax += result.taxWithheld
totalNet += result.netSalary
@@ -231,5 +251,16 @@ export async function POST(
return NextResponse.json({ error: updateError.message }, { status: 500 })
}
return NextResponse.json({ data: updatedRun })
const warnings: string[] = []
if (taxTableSource === 'fallback') {
warnings.push(
`Skatteverkets skattetabell-API är inte nåbart — beräkningen använder lokal reservdata för ${paymentYear}. Kontrollera att Skatteverket inte publicerat ändringar innan lönekörningen bokförs.`
)
} else if (taxTableSource === 'mixed') {
warnings.push(
`Skatteverkets skattetabell-API svarade bara delvis — vissa skattetabeller kommer från lokal reservdata för ${paymentYear}. Kontrollera att Skatteverket inte publicerat ändringar innan lönekörningen bokförs.`
)
}
return NextResponse.json({ data: updatedRun, warnings })
}
+10 -4
View File
@@ -1,6 +1,6 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { lookupTaxFromApi } from '@/lib/salary/tax-tables'
import { lookupTaxFromApi, TaxTableUnavailableError } from '@/lib/salary/tax-tables'
export async function GET(request: Request) {
const supabase = await createClient()
@@ -17,8 +17,8 @@ export async function GET(request: Request) {
return NextResponse.json({ error: 'Skattetabell måste vara 29-42' }, { status: 400 })
}
const taxAmount = await lookupTaxFromApi(tableNumber, column, income, year)
try {
const { taxAmount, source } = await lookupTaxFromApi(tableNumber, column, income, year)
return NextResponse.json({
data: {
year,
@@ -26,7 +26,13 @@ export async function GET(request: Request) {
column,
income,
taxAmount,
source: 'skatteverket_api',
source: source === 'api' ? 'skatteverket_api' : 'local_fallback',
},
})
} catch (err) {
if (err instanceof TaxTableUnavailableError) {
return NextResponse.json({ error: err.message }, { status: 503 })
}
throw err
}
}
+18 -8
View File
@@ -56,6 +56,7 @@ interface NavItem {
group: string
modes?: EntityType[] // If set, only visible for these entity types. If not set, visible to all.
hidden?: boolean // Temporarily hide from sidebar
comingSoon?: boolean // Visible but disabled; shows "Kommer snart" badge
}
// All nav items for sidebar and mobile drawer
@@ -77,9 +78,10 @@ const navItems: NavItem[] = [
{ href: '/bookkeeping', label: 'Bokföring', icon: BookOpen, group: 'redovisning' },
{ href: '/reports', label: 'Rapporter', icon: BarChart3, 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'] },
// Personal — temporarily disabled in production pending feature completion.
// Still clickable in local dev (NODE_ENV === 'development') so we can test.
{ href: '/salary', label: 'Löner', icon: HandCoins, group: 'personal', modes: ['aktiebolag'], comingSoon: process.env.NODE_ENV !== 'development' },
{ href: '/salary/employees', label: 'Anställda', icon: Users, group: 'personal', modes: ['aktiebolag'], comingSoon: process.env.NODE_ENV !== 'development' },
{ href: '/help', label: 'Hjälp', icon: HelpCircle, group: 'övrigt' },
{ href: '/settings', label: 'Inställningar', icon: Settings, group: 'övrigt' },
]
@@ -246,7 +248,7 @@ export default function DashboardNav({ companyName: _companyName, entityType, un
{items.map((item) => {
const Icon = item.icon
const active = isActive(item.href)
const enabled = isItemEnabled(item.href)
const enabled = isItemEnabled(item.href) && !item.comingSoon
const badge = item.href === '/transactions' && uncategorizedTransactionCount > 0
? uncategorizedTransactionCount
: item.href === '/pending' && pendingOperationsCount > 0
@@ -259,7 +261,11 @@ export default function DashboardNav({ companyName: _companyName, entityType, un
active ? "text-primary" : "text-muted-foreground group-hover:text-foreground"
)} />
<span className="flex-1">{item.label}</span>
{badge !== null && (
{item.comingSoon ? (
<span className="ml-auto rounded-full bg-muted/60 text-muted-foreground/70 text-[9px] font-medium uppercase tracking-wider px-1.5 py-0.5">
Kommer snart
</span>
) : badge !== null && (
<span className="ml-auto min-w-[18px] h-[18px] flex items-center justify-center rounded-full bg-primary/15 text-primary text-[10px] font-semibold px-1">
{badge > 99 ? '99+' : badge}
</span>
@@ -286,7 +292,7 @@ export default function DashboardNav({ companyName: _companyName, entityType, un
key={item.href}
className={baseClass}
aria-disabled="true"
title="Lägg till ett företag för att aktivera"
title={item.comingSoon ? 'Kommer snart' : 'Lägg till ett företag för att aktivera'}
>
{content}
</div>
@@ -572,7 +578,7 @@ export default function DashboardNav({ companyName: _companyName, entityType, un
{items.map((item) => {
const Icon = item.icon
const active = isActive(item.href)
const enabled = isItemEnabled(item.href)
const enabled = isItemEnabled(item.href) && !item.comingSoon
const badge = item.href === '/transactions' && uncategorizedTransactionCount > 0
? uncategorizedTransactionCount
: item.href === '/pending' && pendingOperationsCount > 0
@@ -582,7 +588,11 @@ export default function DashboardNav({ companyName: _companyName, entityType, un
<>
<Icon className={cn("h-[18px] w-[18px] flex-shrink-0", active ? "text-primary" : "text-muted-foreground")} />
<span className="text-sm flex-1">{item.label}</span>
{badge !== null && (
{item.comingSoon ? (
<span className="rounded-full bg-muted/60 text-muted-foreground/70 text-[9px] font-medium uppercase tracking-wider px-1.5 py-0.5">
Kommer snart
</span>
) : badge !== null && (
<span className="min-w-[20px] h-[20px] flex items-center justify-center rounded-full bg-primary/15 text-primary text-[10px] font-semibold px-1.5">
{badge > 99 ? '99+' : badge}
</span>
+87 -10
View File
@@ -1,6 +1,6 @@
'use client'
import { useState } from 'react'
import { useEffect, useMemo, useState } from 'react'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import {
@@ -11,6 +11,7 @@ import {
SelectValue,
} from '@/components/ui/select'
import { Label } from '@/components/ui/label'
import { Switch } from '@/components/ui/switch'
import {
Table,
TableBody,
@@ -21,18 +22,17 @@ import {
} from '@/components/ui/table'
import { ArrowLeft, ArrowRight, Columns3 } from 'lucide-react'
import { formatCurrency } from '@/lib/utils'
import { getCSVPreview } from '@/lib/import/bank-file/formats/generic-csv'
import type { GenericCSVColumnMapping } from '@/lib/import/bank-file/types'
interface BankFileColumnMappingStepProps {
headers: string[]
previewRows: string[][]
rawFileContent: string
onConfirm: (mapping: GenericCSVColumnMapping) => void
onBack: () => void
}
export default function BankFileColumnMappingStep({
headers,
previewRows,
rawFileContent,
onConfirm,
onBack,
}: BankFileColumnMappingStepProps) {
@@ -42,10 +42,76 @@ export default function BankFileColumnMappingStep({
const [referenceCol, setReferenceCol] = useState<number>(-1)
const [counterpartyCol, setCounterpartyCol] = useState<number>(-1)
const [balanceCol, setBalanceCol] = useState<number>(-1)
const [delimiter, setDelimiter] = useState<string>(',')
// Auto-detect the most likely delimiter by counting field splits on the first line.
// Runs once per file. Users can still override via the dropdown.
const detectedDelimiter = useMemo(() => {
const firstLine = rawFileContent.split(/\r?\n/).find((l) => l.trim() !== '') ?? ''
const candidates: Array<{ d: string; count: number }> = [
{ d: ',', count: getCSVPreview(firstLine, ',', 1)[0]?.length ?? 0 },
{ d: ';', count: getCSVPreview(firstLine, ';', 1)[0]?.length ?? 0 },
{ d: '\t', count: getCSVPreview(firstLine, '\t', 1)[0]?.length ?? 0 },
]
const best = candidates.reduce((a, b) => (b.count > a.count ? b : a))
return best.count > 1 ? best.d : ','
}, [rawFileContent])
const [delimiter, setDelimiter] = useState<string>(detectedDelimiter)
const [decimalSep, setDecimalSep] = useState<',' | '.'>(',')
const [dateFormat, setDateFormat] = useState<string>('YYYY-MM-DD')
// Re-parse headers and preview whenever delimiter or file content changes
const parsedRows = useMemo(
() => getCSVPreview(rawFileContent, delimiter, 10),
[rawFileContent, delimiter]
)
// Auto-detect whether the first row is a header: if any cell on row 0 looks
// like a date (YYYY-MM-DD, DD.MM.YYYY, DD/MM/YYYY, YYYYMMDD), it's data, not a header.
// Users can still override via the switch.
const DATE_PATTERNS = [/^\d{4}-\d{2}-\d{2}$/, /^\d{2}[./]\d{2}[./]\d{4}$/, /^\d{8}$/]
const detectedHasHeader = useMemo(() => {
const firstRow = parsedRows[0]
if (!firstRow) return true
const hasDateCell = firstRow.some((cell) =>
DATE_PATTERNS.some((re) => re.test(cell.trim()))
)
return !hasDateCell
}, [parsedRows])
const [hasHeaderOverride, setHasHeaderOverride] = useState<boolean | null>(null)
const hasHeader = hasHeaderOverride ?? detectedHasHeader
const columnHeaders = useMemo(() => {
if (hasHeader && parsedRows[0]) return parsedRows[0]
const count = parsedRows[0]?.length ?? 0
return Array.from({ length: count }, (_, i) => `Kolumn ${i + 1}`)
}, [parsedRows, hasHeader])
const dataRows = hasHeader ? parsedRows.slice(1) : parsedRows
// Auto-guess date/description/amount columns from the first data row.
// Only used as initial defaults — user can override any pick.
const AMOUNT_RE = /^-?\d+([.,]\d+)?$/
useEffect(() => {
if (dateCol !== -1 || descCol !== -1 || amountCol !== -1) return
const sample = dataRows[0]
if (!sample || sample.length === 0) return
const dateIdx = sample.findIndex((cell) =>
DATE_PATTERNS.some((re) => re.test(cell.trim()))
)
const amountIdx = sample
.map((cell, i) => ({ i, cell: cell.trim().replace(/\s/g, '') }))
.reverse()
.find(({ cell, i }) => AMOUNT_RE.test(cell) && i !== dateIdx)?.i ?? -1
const descIdx = sample.findIndex((_, i) => i !== dateIdx && i !== amountIdx)
if (dateIdx >= 0) setDateCol(dateIdx)
if (descIdx >= 0) setDescCol(descIdx)
if (amountIdx >= 0) setAmountCol(amountIdx)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [dataRows])
const isValid = dateCol >= 0 && descCol >= 0 && amountCol >= 0
const handleConfirm = () => {
@@ -58,13 +124,13 @@ export default function BankFileColumnMappingStep({
...(balanceCol >= 0 && { balance: balanceCol }),
delimiter,
decimal_separator: decimalSep,
skip_rows: 1, // Skip header
skip_rows: hasHeader ? 1 : 0,
date_format: dateFormat,
}
onConfirm(mapping)
}
const columnOptions = headers.map((h, i) => ({ label: `${i + 1}: ${h}`, value: i }))
const columnOptions = columnHeaders.map((h, i) => ({ label: `${i + 1}: ${h}`, value: i }))
return (
<div className="space-y-6">
@@ -79,6 +145,17 @@ export default function BankFileColumnMappingStep({
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
{/* Header row toggle */}
<div className="flex items-center justify-between rounded-md border p-4">
<div className="space-y-0.5">
<Label htmlFor="has-header">Har filen rubrikrad?</Label>
<p className="text-xs text-muted-foreground">
Slå av om filen saknar rubrikrad och första raden redan innehåller transaktionsdata.
</p>
</div>
<Switch id="has-header" checked={hasHeader} onCheckedChange={setHasHeaderOverride} />
</div>
{/* Delimiter, decimal, and date format settings */}
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
<div className="space-y-2">
@@ -254,7 +331,7 @@ export default function BankFileColumnMappingStep({
</Card>
{/* Live preview */}
{isValid && previewRows.length > 1 && (
{isValid && dataRows.length > 0 && (
<Card>
<CardHeader>
<CardTitle className="text-base">Förhandsgranskning</CardTitle>
@@ -273,7 +350,7 @@ export default function BankFileColumnMappingStep({
</TableRow>
</TableHeader>
<TableBody>
{previewRows.slice(1, 6).map((row, i) => {
{dataRows.slice(0, 5).map((row, i) => {
const amountStr = row[amountCol] || '0'
const amount = decimalSep === ','
? parseFloat(amountStr.replace(/\s/g, '').replace(',', '.'))
File diff suppressed because it is too large Load Diff
@@ -26,6 +26,7 @@ function makeTotals(overrides: Partial<AGITotals> = {}): AGITotals {
return {
totalTax: 8000,
totalAvgifterBasis: 35000,
totalAvgifterAmount: 10997,
avgifterByCategory: {
standard: { basis: 35000, amount: 10997 },
},
@@ -171,6 +172,7 @@ describe('buildAGIPayload', () => {
const totals: AGITotals = {
totalTax: 0,
totalAvgifterBasis: 0,
totalAvgifterAmount: 0,
avgifterByCategory: {},
}
const result = buildAGIPayload([makeEmployee({ grossSalary: 0, taxWithheld: 0, avgifterBasis: 0 })], totals)
+33 -3
View File
@@ -96,6 +96,7 @@ export const skatteverketExtension: Extension = {
// Exchange code FIRST — 5-minute expiry, do this before anything else
const { createClient } = await import('@/lib/supabase/server')
const { requireCompanyId } = await import('@/lib/company/context')
const supabase = await createClient()
// Verify user session (browser should still have cookies)
@@ -106,11 +107,22 @@ export const skatteverketExtension: Extension = {
)
}
// Resolve the active company — state/redirect_uri were stored keyed on company_id
// by ctx.settings.set() in the /authorize handler.
let companyId: string
try {
companyId = await requireCompanyId(supabase, user.id)
} catch {
return NextResponse.redirect(
`${appUrl}/reports?tab=vat-declaration&skv_error=${encodeURIComponent('Inget företag valt')}`
)
}
// Validate CSRF state
const { data: settingsData } = await supabase
.from('extension_data')
.select('value')
.eq('company_id', user.id)
.eq('company_id', companyId)
.eq('extension_id', 'skatteverket')
.eq('key', 'oauth_state')
.single()
@@ -125,7 +137,7 @@ export const skatteverketExtension: Extension = {
const { data: redirectData } = await supabase
.from('extension_data')
.select('value')
.eq('company_id', user.id)
.eq('company_id', companyId)
.eq('extension_id', 'skatteverket')
.eq('key', 'oauth_redirect_uri')
.single()
@@ -141,7 +153,7 @@ export const skatteverketExtension: Extension = {
await supabase
.from('extension_data')
.delete()
.eq('company_id', user.id)
.eq('company_id', companyId)
.eq('extension_id', 'skatteverket')
.eq('key', 'oauth_state')
@@ -1082,9 +1094,27 @@ async function parseAGIRequest(
;(avgifterByCategory as Record<string, { basis: number; amount: number }>)[category] = cat
}
const totalAvgifterAmount = Object.values(avgifterByCategory).reduce(
(sum, cat) => sum + (cat?.amount ?? 0),
0
)
// FK499 — sjuklönekostnad summed from sick_day2_14 line items
let totalSjuklonekostnad = 0
for (const sre of runEmployees) {
const lineItems = (sre.line_items || []) as Array<Record<string, unknown>>
for (const li of lineItems) {
if (li.item_type === 'sick_day2_14') {
totalSjuklonekostnad += Math.abs((li.amount as number) || 0)
}
}
}
const totals: AGITotals = {
totalTax: run.total_tax,
totalAvgifterBasis: runEmployees.reduce((s: number, e: { avgifter_basis: number }) => s + e.avgifter_basis, 0),
totalAvgifterAmount: Math.round(totalAvgifterAmount * 100) / 100,
totalSjuklonekostnad: Math.round(totalSjuklonekostnad * 100) / 100,
avgifterByCategory,
}
+184 -58
View File
@@ -1,5 +1,9 @@
import { describe, it, expect, vi } from 'vitest'
import { generateAGIXml, buildIndividuppgifterSnapshot } from '../agi/xml-generator'
import {
generateAGIXml,
buildIndividuppgifterSnapshot,
AGIIncompleteDataError,
} from '../agi/xml-generator'
import type { AGICompanyData, AGIEmployeeData, AGITotals } from '../agi/xml-generator'
// Mock personnummer decryption
@@ -28,8 +32,6 @@ const employees: AGIEmployeeData[] = [
grossSalary: 40000,
taxWithheld: 12000,
avgifterBasis: 40000,
sickDays: 3,
vabDays: 2,
},
{
personnummer: 'emp2_encrypted',
@@ -44,110 +46,236 @@ const employees: AGIEmployeeData[] = [
const totals: AGITotals = {
totalTax: 22500,
totalAvgifterBasis: 80000,
totalAvgifterAmount: 24075.5,
avgifterByCategory: {
standard: { basis: 75000, amount: 23565 },
reduced65plus: { basis: 5000, amount: 510.50 },
reduced65plus: { basis: 5000, amount: 510.5 },
},
}
describe('generateAGIXml', () => {
it('generates valid XML with correct root element', () => {
describe('generateAGIXml — root structure', () => {
it('starts with XML declaration', () => {
const xml = generateAGIXml(company, employees, totals)
expect(xml).toContain('<?xml version="1.0" encoding="UTF-8"?>')
expect(xml).toContain('<Skatteverket')
})
it('uses the Skatteverket AGI namespace (schema 1.1)', () => {
const xml = generateAGIXml(company, employees, totals)
expect(xml).toContain('xmlns="http://xmls.skatteverket.se/se/skatteverket/da/instans/schema/1.1"')
// Declares the komponent namespace for shared building blocks (Avsandare etc.)
expect(xml).toContain('xmlns:gem="http://xmls.skatteverket.se/se/skatteverket/da/komponent/schema/1.1"')
// Reject the old bogus namespace
expect(xml).not.toContain('infoForBeskworksgiv')
expect(xml).not.toContain('/ai/instans/')
})
it('sets omrade="Arbetsgivardeklaration" on the root element', () => {
const xml = generateAGIXml(company, employees, totals)
expect(xml).toContain('<Skatteverket omrade="Arbetsgivardeklaration"')
})
it('closes the Skatteverket element', () => {
const xml = generateAGIXml(company, employees, totals)
expect(xml).toContain('</Skatteverket>')
})
})
describe('generateAGIXml — Avsandare', () => {
it('includes program name "gnubok"', () => {
const xml = generateAGIXml(company, employees, totals)
expect(xml).toContain('<Programnamn>gnubok</Programnamn>')
expect(xml).toContain('<gem:Programnamn>gnubok</gem:Programnamn>')
})
it('includes correct period', () => {
it('emits Organisationsnummer in IDENTITET format (16 + 10-digit AB orgnr)', () => {
const xml = generateAGIXml(company, employees, totals)
expect(xml).toContain('<Period>202604</Period>')
})
it('includes org number without dash', () => {
const xml = generateAGIXml(company, employees, totals)
expect(xml).toContain('5561234567')
expect(xml).toContain('<gem:Organisationsnummer>165561234567</gem:Organisationsnummer>')
expect(xml).not.toContain('556123-4567')
})
it('includes huvuduppgift with total tax (Ruta 001)', () => {
it('includes technical contact (name, phone, email)', () => {
const xml = generateAGIXml(company, employees, totals)
expect(xml).toContain('<AvdragenSkatt faltkod="001">22500</AvdragenSkatt>')
expect(xml).toContain('<gem:Namn>Anna Admin</gem:Namn>')
expect(xml).toContain('<gem:Telefon>0701234567</gem:Telefon>')
expect(xml).toContain('<gem:Epostadress>anna@test.se</gem:Epostadress>')
})
it('includes avgifter categories (Ruta 060, 061)', () => {
it('emits Avsandare in the komponent namespace (gem: prefix)', () => {
const xml = generateAGIXml(company, employees, totals)
expect(xml).toContain('faltkod="060"')
expect(xml).toContain('faltkod="061"')
expect(xml).toContain('<gem:Avsandare>')
expect(xml).toContain('</gem:Avsandare>')
})
})
it('decrypts personnummer for FK215', () => {
describe('generateAGIXml — Blankettgemensamt', () => {
it('includes AgRegistreradId for the employer in IDENTITET format', () => {
const xml = generateAGIXml(company, employees, totals)
expect(xml).toContain('<Personnummer faltkod="215">199001011234</Personnummer>')
expect(xml).toContain('<Personnummer faltkod="215">198506159876</Personnummer>')
expect(xml).toContain('<gem:AgRegistreradId>165561234567</gem:AgRegistreradId>')
})
it('includes consistent FK570 specifikationsnummer', () => {
it('emits Blankettgemensamt in the komponent namespace', () => {
const xml = generateAGIXml(company, employees, totals)
expect(xml).toContain('<Specifikationsnummer faltkod="570">1</Specifikationsnummer>')
expect(xml).toContain('<Specifikationsnummer faltkod="570">2</Specifikationsnummer>')
expect(xml).toContain('<gem:Blankettgemensamt>')
expect(xml).toContain('</gem:Blankettgemensamt>')
})
})
it('includes gross salary (Ruta 011) per employee', () => {
describe('generateAGIXml — Huvuduppgift (HU)', () => {
it('includes AgRegistreradId with FK201 inside HU (IDENTITET format)', () => {
const xml = generateAGIXml(company, employees, totals)
expect(xml).toContain('<KontantBruttoloen faltkod="011">40000</KontantBruttoloen>')
expect(xml).toContain('<KontantBruttoloen faltkod="011">35000</KontantBruttoloen>')
expect(xml).toContain('<gem:AgRegistreradId faltkod="201">165561234567</gem:AgRegistreradId>')
})
it('includes tax withheld (Ruta 001) per employee', () => {
it('includes RedovisningsPeriod with FK006 inside HU', () => {
const xml = generateAGIXml(company, employees, totals)
// Both HU and IU have AvdragenSkatt
const matches = xml.match(/AvdragenSkatt/g)
expect(matches!.length).toBeGreaterThanOrEqual(3) // 1 HU + 2 IU
expect(xml).toContain('<gem:RedovisningsPeriod faltkod="006">202604</gem:RedovisningsPeriod>')
})
it('includes benefit values (Ruta 012 for car)', () => {
it('emits total tax as SummaSkatteavdr FK497 (not AvdragenSkatt FK001)', () => {
const xml = generateAGIXml(company, employees, totals)
expect(xml).toContain('<FormanBil faltkod="012">5000</FormanBil>')
expect(xml).toContain('<gem:SummaSkatteavdr faltkod="497">22500</gem:SummaSkatteavdr>')
// Legacy incorrect HU element must not appear
expect(xml).not.toMatch(/<gem:HU>[\s\S]*<AvdragenSkatt[\s\S]*<\/gem:HU>/)
})
it('includes absence fields FK821-FK823', () => {
it('emits total employer contributions as SummaArbAvgSlf FK487', () => {
const xml = generateAGIXml(company, employees, totals)
expect(xml).toContain('<SjukfranvaroDagar faltkod="821">3</SjukfranvaroDagar>')
expect(xml).toContain('<VabDagar faltkod="822">2</VabDagar>')
expect(xml).toContain('<gem:SummaArbAvgSlf faltkod="487">24076</gem:SummaArbAvgSlf>')
})
it('omits zero/undefined fields', () => {
it('does NOT emit FK060/061/062 — those field codes do not exist in HU', () => {
const xml = generateAGIXml(company, employees, totals)
// Look for those faltkoder inside the HU section
const huMatch = xml.match(/<gem:HU>[\s\S]*?<\/gem:HU>/)
expect(huMatch).not.toBeNull()
const hu = huMatch![0]
expect(hu).not.toContain('faltkod="060"')
expect(hu).not.toContain('faltkod="061"')
expect(hu).not.toContain('faltkod="062"')
})
it('emits TotalSjuklonekostnad FK499 when sjuklön cost is reported', () => {
const withSjuklon = { ...totals, totalSjuklonekostnad: 4200 }
const xml = generateAGIXml(company, employees, withSjuklon)
expect(xml).toContain('<gem:TotalSjuklonekostnad faltkod="499">4200</gem:TotalSjuklonekostnad>')
})
it('omits TotalSjuklonekostnad when zero or undefined', () => {
const xml = generateAGIXml(company, employees, totals)
expect(xml).not.toContain('TotalSjuklonekostnad')
const zero = { ...totals, totalSjuklonekostnad: 0 }
expect(generateAGIXml(company, employees, zero)).not.toContain('TotalSjuklonekostnad')
})
})
describe('generateAGIXml — Individuppgift (IU)', () => {
it('uses BetalningsmottagarId FK215 (not Personnummer) for the payment recipient', () => {
const xml = generateAGIXml(company, employees, totals)
expect(xml).toContain('<gem:BetalningsmottagarId faltkod="215">199001011234</gem:BetalningsmottagarId>')
expect(xml).toContain('<gem:BetalningsmottagarId faltkod="215">198506159876</gem:BetalningsmottagarId>')
expect(xml).not.toContain('<Personnummer faltkod="215">')
})
it('wraps BetalningsmottagarId in BetalningsmottagareIUGROUP → BetalningsmottagareIDChoice', () => {
const xml = generateAGIXml(company, employees, totals)
expect(xml).toContain('<gem:BetalningsmottagareIUGROUP>')
expect(xml).toContain('<gem:BetalningsmottagareIDChoice>')
expect(xml).toContain('</gem:BetalningsmottagareIDChoice>')
expect(xml).toContain('</gem:BetalningsmottagareIUGROUP>')
// Ensure correct nesting order (IUGROUP contains IDChoice which contains the id)
expect(xml).toMatch(/<gem:BetalningsmottagareIUGROUP>\s*<gem:BetalningsmottagareIDChoice>\s*<gem:BetalningsmottagarId/)
})
it('wraps AgRegistreradId in ArbetsgivareIUGROUP inside IU', () => {
const xml = generateAGIXml(company, employees, totals)
expect(xml).toContain('<gem:ArbetsgivareIUGROUP>')
expect(xml).toContain('</gem:ArbetsgivareIUGROUP>')
})
it('preserves Specifikationsnummer FK570 per employee', () => {
const xml = generateAGIXml(company, employees, totals)
expect(xml).toContain('<gem:Specifikationsnummer faltkod="570">1</gem:Specifikationsnummer>')
expect(xml).toContain('<gem:Specifikationsnummer faltkod="570">2</gem:Specifikationsnummer>')
})
it('uses KontantErsattningUlagAG FK011 (not KontantBruttoloen) for gross salary', () => {
const xml = generateAGIXml(company, employees, totals)
expect(xml).toContain('<gem:KontantErsattningUlagAG faltkod="011">40000</gem:KontantErsattningUlagAG>')
expect(xml).toContain('<gem:KontantErsattningUlagAG faltkod="011">35000</gem:KontantErsattningUlagAG>')
expect(xml).not.toContain('KontantBruttoloen')
})
it('uses AvdrPrelSkatt FK001 (not AvdragenSkatt) for withheld tax in IU', () => {
const xml = generateAGIXml(company, employees, totals)
expect(xml).toContain('<gem:AvdrPrelSkatt faltkod="001">12000</gem:AvdrPrelSkatt>')
expect(xml).toContain('<gem:AvdrPrelSkatt faltkod="001">10500</gem:AvdrPrelSkatt>')
})
it('includes AgRegistreradId and RedovisningsPeriod in every IU', () => {
const xml = generateAGIXml(company, employees, totals)
// 1 HU + 2 IU = 3 occurrences each
const agRegMatches = xml.match(/AgRegistreradId faltkod="201"/g)
const periodMatches = xml.match(/RedovisningsPeriod faltkod="006"/g)
expect(agRegMatches?.length).toBe(3)
expect(periodMatches?.length).toBe(3)
})
it('maps benefit_car to SkatteplBilformanUlagAG FK013 (not FormanBil FK012)', () => {
const xml = generateAGIXml(company, employees, totals)
expect(xml).toContain('<gem:SkatteplBilformanUlagAG faltkod="013">5000</gem:SkatteplBilformanUlagAG>')
expect(xml).not.toContain('FormanBil')
})
it('omits empty/zero fields', () => {
const xml = generateAGIXml(company, employees, totals)
// Employee 1 has no car benefit
// Employee 2 has no sick days
// Check that we don't emit empty tags
const lines = xml.split('\n')
for (const line of lines) {
if (line.includes('faltkod')) {
expect(line).not.toContain('>0</')
expect(line).not.toMatch(/>0<\//)
}
}
})
it('marks corrections with Rattelse flag', () => {
const xml = generateAGIXml(company, employees, totals, true)
expect(xml).toContain('<Rattelse>J</Rattelse>')
})
it('does not include Rattelse flag for initial filing', () => {
const xml = generateAGIXml(company, employees, totals, false)
expect(xml).not.toContain('Rattelse')
})
it('escapes XML special characters in company name', () => {
const specialCompany = { ...company, companyName: 'Test & <Co>' }
it('escapes XML special characters in contact info', () => {
const specialCompany = { ...company, contactName: 'A&B <Admin>' }
const xml = generateAGIXml(specialCompany, employees, totals)
expect(xml).not.toContain('Test & <Co>')
expect(xml).not.toContain('A&B <Admin>')
expect(xml).toContain('A&amp;B &lt;Admin&gt;')
})
})
describe('generateAGIXml — fail-fast on missing data', () => {
it('throws AGIIncompleteDataError when org number is missing', () => {
const bad = { ...company, orgNumber: '' }
expect(() => generateAGIXml(bad, employees, totals)).toThrow(AGIIncompleteDataError)
expect(() => generateAGIXml(bad, employees, totals)).toThrow(/organisationsnummer/)
})
it('throws when org number has too few digits', () => {
const bad = { ...company, orgNumber: '12345' }
expect(() => generateAGIXml(bad, employees, totals)).toThrow(AGIIncompleteDataError)
})
it('throws when contact phone is missing', () => {
const bad = { ...company, contactPhone: '' }
expect(() => generateAGIXml(bad, employees, totals)).toThrow(/telefon/)
})
it('throws when contact email is missing', () => {
const bad = { ...company, contactEmail: '' }
expect(() => generateAGIXml(bad, employees, totals)).toThrow(/e-post/)
})
it('lists all missing fields on the error', () => {
const bad = { ...company, orgNumber: '', contactPhone: '', contactEmail: '' }
try {
generateAGIXml(bad, employees, totals)
throw new Error('should have thrown')
} catch (err) {
expect(err).toBeInstanceOf(AGIIncompleteDataError)
expect((err as AGIIncompleteDataError).missingFields).toEqual(
expect.arrayContaining(['organisationsnummer', 'telefon', 'e-post'])
)
}
})
})
@@ -170,7 +298,5 @@ describe('buildIndividuppgifterSnapshot', () => {
expect(snapshot[0]).toHaveProperty('ruta011', 40000)
expect(snapshot[0]).toHaveProperty('ruta001', 12000)
expect(snapshot[0]).toHaveProperty('ruta020', 40000)
expect(snapshot[0]).toHaveProperty('fk821', 3)
expect(snapshot[0]).toHaveProperty('fk822', 2)
})
})
+88 -4
View File
@@ -1,5 +1,12 @@
import { describe, it, expect } from 'vitest'
import { lookupTaxAmount, calculateJamkningTax, calculateSidoinkomstTax } from '../tax-tables'
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
import {
lookupTaxAmount,
calculateJamkningTax,
calculateSidoinkomstTax,
fetchTaxTableRates,
clearTaxTableCache,
TaxTableUnavailableError,
} from '../tax-tables'
import type { TaxTableRate } from '../tax-tables'
const sampleRates: TaxTableRate[] = [
@@ -29,8 +36,9 @@ describe('lookupTaxAmount', () => {
expect(lookupTaxAmount(33, 1, 100000, sampleRates)).toBe(16800)
})
it('falls back to 30% when table not found', () => {
expect(lookupTaxAmount(99, 1, 40000, sampleRates)).toBe(12000)
it('throws TaxTableUnavailableError when no matching rates exist (no silent 30% fallback)', () => {
expect(() => lookupTaxAmount(99, 1, 40000, sampleRates)).toThrow(TaxTableUnavailableError)
expect(() => lookupTaxAmount(99, 1, 40000, sampleRates)).toThrow(/table 99/)
})
it('filters by correct column', () => {
@@ -61,3 +69,79 @@ describe('calculateSidoinkomstTax', () => {
expect(calculateSidoinkomstTax(33333)).toBe(9999.90)
})
})
describe('fetchTaxTableRates fallback behavior', () => {
const originalFetch = globalThis.fetch
beforeEach(() => {
clearTaxTableCache()
})
afterEach(() => {
globalThis.fetch = originalFetch
clearTaxTableCache()
})
it("falls back to local data when the Skatteverket API fails for a supported year", async () => {
// Mock fetch to simulate API outage
globalThis.fetch = vi.fn().mockRejectedValue(new Error('network down'))
const result = await fetchTaxTableRates(2026, 33, 1)
expect(result.source).toBe('fallback')
expect(result.rates.length).toBeGreaterThan(0)
// Every rate should match the requested table/column/year
for (const r of result.rates) {
expect(r.tableYear).toBe(2026)
expect(r.tableNumber).toBe(33)
expect(r.columnNumber).toBe(1)
}
})
it('marks source as api when the API returns data', async () => {
globalThis.fetch = vi.fn().mockResolvedValue({
ok: true,
status: 200,
json: async () => ({
resultCount: 1,
results: [
{
'år': '2026',
'tabellnr': '33',
'inkomst fr.o.m.': '20001',
'inkomst t.o.m.': '20100',
'kolumn 1': '2800',
'kolumn 2': '0',
'kolumn 3': '2500',
'kolumn 4': '100',
'kolumn 5': '2800',
'kolumn 6': '3000',
},
],
}),
} as Response)
const result = await fetchTaxTableRates(2026, 33, 1)
expect(result.source).toBe('api')
expect(result.rates[0].taxAmount).toBe(2800)
})
it('throws TaxTableUnavailableError when API fails and year has no fallback', async () => {
globalThis.fetch = vi.fn().mockRejectedValue(new Error('network down'))
await expect(fetchTaxTableRates(2020, 33, 1)).rejects.toBeInstanceOf(TaxTableUnavailableError)
await expect(fetchTaxTableRates(2020, 33, 1)).rejects.toThrow(/2020/)
})
it('caches the fallback result so repeat calls do not re-trigger the failed API', async () => {
const fetchSpy = vi.fn().mockRejectedValue(new Error('network down'))
globalThis.fetch = fetchSpy
await fetchTaxTableRates(2026, 33, 1)
await fetchTaxTableRates(2026, 33, 1)
// Only one API attempt despite two calls — second hit the cache
expect(fetchSpy).toHaveBeenCalledTimes(1)
})
})
+255 -117
View File
@@ -1,33 +1,59 @@
import { decryptPersonnummer } from '../personnummer'
/**
* AGI XML generator — Arbetsgivardeklaration per Skatteverket Teknisk beskrivning.
* AGI XML generator — Arbetsgivardeklaration på individnivå.
*
* Generates the XML content for filing employer declarations.
* The XML is stored in agi_declarations.xml_content for 7-year retention.
* Produces XML conforming to Skatteverket's schema:
* http://xmls.skatteverket.se/se/skatteverket/da/instans/schema/1.1
*
* The XML can be uploaded on Skatteverket's AGI e-tjänst. For programmatic
* submission use the JSON API flow via the skatteverket extension instead.
*
* Sources verified against Skatteverket's schema + technical description
* (SKV 269, teknisk beskrivning 1.1.16):
* - Root: <Skatteverket omrade="Arbetsgivardeklaration">
* - HU totals: SummaSkatteavdr (497), SummaArbAvgSlf (487), TotalSjuklonekostnad (499)
* - IU identity: BetalningsmottagarId (215), Specifikationsnummer (570)
* - IU amounts: KontantErsattningUlagAG (011), AvdrPrelSkatt (001)
* - Every HU and IU must include AgRegistreradId (201) + RedovisningsPeriod (006)
*
* CRITICAL: FK570 (specifikationsnummer) must stay consistent per employee.
* Corrections are detected by Skatteverket matching the same FK570.
*
* NOT HANDLED HERE (future work):
* - <Franvarouppgift>: separate top-level section for parental leave events
* (FK821 FranvaroDatum, FK823 FranvaroTyp={TILLFALLIG_FORALDRAPENNING|
* FORALDRAPENNING}, etc.). Requires per-event date records, not a simple
* day count. Per-employee sick days are NOT reported via AGI at all —
* they go to Försäkringskassan separately.
*/
const INSTANS_NS = 'http://xmls.skatteverket.se/se/skatteverket/da/instans/schema/1.1'
const KOMPONENT_NS = 'http://xmls.skatteverket.se/se/skatteverket/da/komponent/schema/1.1'
export interface AGIEmployeeData {
personnummer: string // Encrypted — will be decrypted for XML
specificationNumber: number // FK570 — MUST stay consistent
grossSalary: number // Ruta 011
taxWithheld: number // Ruta 001
avgifterBasis: number // Ruta 020
fSkattPayment?: number // Ruta 131 (F-skatt holders)
// Benefits by type
benefitCar?: number // Ruta 012
benefitFuel?: number // Ruta 013
benefitHousing?: number // Ruta 014
benefitMeals?: number // Ruta 015
benefitOther?: number // Ruta 019
// Absence (from 2025)
sickDays?: number // FK821
vabDays?: number // FK822
parentalDays?: number // FK823
personnummer: string // Encrypted — decrypted for XML
specificationNumber: number // FK570 — MUST stay consistent per employee
grossSalary: number // FK011 KontantErsattningUlagAG
taxWithheld: number // FK001 AvdrPrelSkatt
avgifterBasis: number // Retained for backwards compat; equals grossSalary for standard cases. Not emitted separately (FK011 already captures basis).
fSkattPayment?: number // FK131 KontantErsattningEjUlagSA
benefitCar?: number // FK013 SkatteplBilformanUlagAG
benefitFuel?: number // FK018 DrivmVidBilformanUlagAG
benefitHousing?: number // FK043 BostadsformanEjSmahusUlagAG (non-småhus default)
benefitOther?: number // FK012 SkatteplOvrigaFormanerUlagAG
/** @deprecated Meal benefit element name not verified against schema; kept for snapshot compatibility only (not emitted). */
benefitMeals?: number
/** @deprecated Per-employee sick days are not reported via AGI (goes to Försäkringskassan separately). Kept for snapshot compatibility. */
sickDays?: number
/** @deprecated VAB is reported via top-level <Franvarouppgift> as per-event records, not as an IU day count. Kept for snapshot compatibility. */
vabDays?: number
/** @deprecated Parental leave is reported via top-level <Franvarouppgift> as per-event records, not as an IU day count. Kept for snapshot compatibility. */
parentalDays?: number
}
export interface AGICompanyData {
orgNumber: string // NNNNNN-NNNN format
orgNumber: string // 10 digits after stripping dashes
companyName: string
periodYear: number
periodMonth: number
@@ -37,8 +63,15 @@ export interface AGICompanyData {
}
export interface AGITotals {
totalTax: number // Ruta 001 (huvuduppgift)
totalAvgifterBasis: number // Ruta 020 (huvuduppgift)
totalTax: number // FK497 SummaSkatteavdr
totalAvgifterBasis: number // retained for compat (sum of IU underlag)
totalAvgifterAmount: number // FK487 SummaArbAvgSlf (sum of calculated avgifter across categories)
/**
* FK499 TotalSjuklonekostnad — company's total sjuklön cost for the period
* (sum of sjuklön paid days 214 across all employees). Required per 2025+ rules.
* Day 1 is karens (unpaid); day 15+ is Försäkringskassan, not employer.
*/
totalSjuklonekostnad?: number
avgifterByCategory: {
standard?: { basis: number; amount: number }
reduced65plus?: { basis: number; amount: number }
@@ -46,153 +79,258 @@ export interface AGITotals {
}
}
/**
* Thrown when required AGI data is missing. Caller should surface the message
* to the user so they can fill in the missing field (org number, contact info).
*/
export class AGIIncompleteDataError extends Error {
constructor(message: string, public readonly missingFields: string[]) {
super(message)
this.name = 'AGIIncompleteDataError'
}
}
function assertRequiredCompanyData(company: AGICompanyData): void {
const missing: string[] = []
const orgNumberDigits = (company.orgNumber || '').replace(/\D/g, '')
// Skatteverket's IDENTITET type requires either 10 digits (AB orgnr, we prefix
// with "16") or 12 digits (personnummer for enskild firma). Any other length
// is a data-entry error that we cannot silently fix.
if (orgNumberDigits.length !== 10 && orgNumberDigits.length !== 12) missing.push('organisationsnummer')
if (!company.contactName.trim()) missing.push('kontaktperson (namn)')
if (!company.contactPhone.trim()) missing.push('telefon')
if (!company.contactEmail.trim()) missing.push('e-post')
if (missing.length > 0) {
throw new AGIIncompleteDataError(
`AGI kan inte genereras — följande uppgifter saknas: ${missing.join(', ')}. ` +
'Fyll i dem under Inställningar → Företag och Inställningar → Lön.',
missing
)
}
}
/**
* Skatteverket's IDENTITET pattern (from the AGI XSD). Accepts:
* - 12-digit personnummer YYYYMMDDXXXX (real dates 19xx/20xx, incl. leap days
* and samordningsnummer where day = actual_day + 60)
* - 12-digit AB/organisationsnummer: literal "16" + 10-digit orgnr, where the
* 3rd digit (first of the 10-digit orgnr) is 1-3, 5, 6, 7, 8 or 9 (NOT 4,
* and with specific restrictions) and the 5th is 2-9.
*
* Mirrored here so we can fail fast with a user-friendly message instead of
* emitting XML that Skatteverket's validator will reject cryptically.
*/
const IDENTITET_PATTERN = /^(((19|20)[0-9][0-9])((((01|03|05|07|08|10|12)(6[1-9]|7[0-9]|8[0-9]|9[0-1]))|((04|06|09|11)(6[1-9]|7[0-9]|8[0-9]|90))|((02)(6[1-9]|7[0-9]|8[0-8])))|00[6-9][0-9]|[0-9][0-9]60)|(((19|20)(04|08|12|16|20|24|28|32|36|40|44|48|52|56|60|64|68|72|76|80|84|88|92|96)(0289))|(20000289)))(00[1-9]|0[1-9][0-9]|[1-9][0-9][0-9])[0-9]|16(1[0-9]|2[0-9]|3[0-9]|5[0-9]|6[0-4]|66|68|7[0-9]|8[0-9]|9[0-9])[2-9]\d{7}|((((19|20)[0-9][0-9])(((01|03|05|07|08|10|12)(0[1-9]|1[0-9]|2[0-9]|3[0-1]))|((04|06|09|11)(0[1-9]|1[0-9]|2[0-9]|30))|((02)(0[1-9]|1[0-9]|2[0-8]))))|(((19|20)(04|08|12|16|20|24|28|32|36|40|44|48|52|56|60|64|68|72|76|80|84|88|92|96)(0229))|(20000229)))(00[1-9]|0[1-9][0-9]|[1-9][0-9][0-9])[0-9]$/
/**
* Normalize an org number or personnummer to Skatteverket's 12-character
* IDENTITET format, required by the AGI schema for Avsandare/Organisationsnummer,
* AgRegistreradId, and Arendeagare.
*
* - 10-digit orgnr (AB e.g. 5561234567) → prefixed with "16" → 165561234567
* - 12-digit personnummer (EF e.g. 196904206942) → used as-is
*
* Throws AGIIncompleteDataError if the resulting value cannot match the
* IDENTITET pattern — this catches bogus test data (e.g. "420694-2069") before
* the file reaches Skatteverket.
*/
function toIdentitet(raw: string): string {
const digits = raw.replace(/\D/g, '')
let candidate: string
if (digits.length === 12) candidate = digits
else if (digits.length === 10) candidate = `16${digits}`
else {
throw new AGIIncompleteDataError(
`Ogiltigt organisations-/personnummer (${digits.length} siffror). ` +
'Ange ett giltigt svenskt organisationsnummer (10 siffror, t.ex. 556123-4567) ' +
'eller fullständigt personnummer (12 siffror, YYYYMMDD-XXXX) under Inställningar → Företag.',
['organisationsnummer']
)
}
if (!IDENTITET_PATTERN.test(candidate)) {
throw new AGIIncompleteDataError(
`Ogiltigt organisationsnummer "${raw}" — värdet är inte ett svenskt organisationsnummer eller personnummer enligt Skatteverkets format. ` +
'Kontrollera värdet under Inställningar → Företag. För AB ska det vara 10 siffror (t.ex. 556123-4567). ' +
'För enskild firma ska det vara ett fullständigt 12-siffrigt personnummer (YYYYMMDD-XXXX).',
['organisationsnummer']
)
}
return candidate
}
/**
* Generate AGI XML for a period.
*
* CRITICAL: FK570 (specifikationsnummer) must stay consistent per employee.
* Using a different number creates a new record instead of correcting.
* Throws AGIIncompleteDataError if required fields (orgNumber, contact info)
* are missing — we never emit partial XML that Skatteverket would reject.
*/
export function generateAGIXml(
company: AGICompanyData,
employees: AGIEmployeeData[],
totals: AGITotals,
isCorrection: boolean = false
_isCorrection: boolean = false
): string {
assertRequiredCompanyData(company)
const orgIdentitet = toIdentitet(company.orgNumber)
const period = `${company.periodYear}${String(company.periodMonth).padStart(2, '0')}`
const createdAt = new Date().toISOString().replace(/\.\d+Z$/, '')
const lines: string[] = []
lines.push('<?xml version="1.0" encoding="UTF-8"?>')
lines.push('<Skatteverket xmlns="http://xmls.skatteverket.se/se/skatteverket/ai/instans/infoForBeskworksgiv662/1.0"')
lines.push(' xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">')
lines.push(' <Avsandare>')
lines.push(` <Programnamn>gnubok</Programnamn>`)
lines.push(` <Organisationsnummer>${escapeXml(company.orgNumber.replace('-', ''))}</Organisationsnummer>`)
lines.push(' <TekniskKontaktperson>')
lines.push(` <Namn>${escapeXml(company.contactName)}</Namn>`)
lines.push(` <Telefon>${escapeXml(company.contactPhone)}</Telefon>`)
lines.push(` <Epostadress>${escapeXml(company.contactEmail)}</Epostadress>`)
lines.push(' </TekniskKontaktperson>')
lines.push(' </Avsandare>')
lines.push(
`<Skatteverket omrade="Arbetsgivardeklaration" xmlns="${INSTANS_NS}" xmlns:gem="${KOMPONENT_NS}">`
)
lines.push(' <Blankettgemensamt>')
lines.push(` <Arbetsgivare>`)
lines.push(` <AgRegistreradId>${escapeXml(company.orgNumber.replace('-', ''))}</AgRegistreradId>`)
lines.push(` </Arbetsgivare>`)
lines.push(' </Blankettgemensamt>')
// ── Avsandare (komponent namespace) ──────────────────────────
lines.push(' <gem:Avsandare>')
lines.push(' <gem:Programnamn>gnubok</gem:Programnamn>')
lines.push(` <gem:Organisationsnummer>${orgIdentitet}</gem:Organisationsnummer>`)
lines.push(' <gem:TekniskKontaktperson>')
lines.push(` <gem:Namn>${escapeXml(company.contactName)}</gem:Namn>`)
lines.push(` <gem:Telefon>${escapeXml(company.contactPhone)}</gem:Telefon>`)
lines.push(` <gem:Epostadress>${escapeXml(company.contactEmail)}</gem:Epostadress>`)
lines.push(' </gem:TekniskKontaktperson>')
lines.push(` <gem:Skapad>${createdAt}</gem:Skapad>`)
lines.push(' </gem:Avsandare>')
// Huvuduppgift (employer totals)
lines.push(' <Blankett>')
lines.push(' <Arendeinformation>')
lines.push(` <Arendeagare>${escapeXml(company.orgNumber.replace('-', ''))}</Arendeagare>`)
lines.push(` <Period>${period}</Period>`)
if (isCorrection) {
lines.push(' <Rattelse>J</Rattelse>')
}
lines.push(' </Arendeinformation>')
lines.push(' <Blankettinnehall>')
lines.push(' <HU>')
// ── Blankettgemensamt (komponent namespace) ──────────────────
lines.push(' <gem:Blankettgemensamt>')
lines.push(' <gem:Arbetsgivare>')
lines.push(` <gem:AgRegistreradId>${orgIdentitet}</gem:AgRegistreradId>`)
lines.push(' <gem:Kontaktperson>')
lines.push(` <gem:Namn>${escapeXml(company.contactName)}</gem:Namn>`)
lines.push(` <gem:Telefon>${escapeXml(company.contactPhone)}</gem:Telefon>`)
lines.push(` <gem:Epostadress>${escapeXml(company.contactEmail)}</gem:Epostadress>`)
lines.push(' </gem:Kontaktperson>')
lines.push(' </gem:Arbetsgivare>')
lines.push(' </gem:Blankettgemensamt>')
// Ruta 001: Total skatteavdrag
// ── Blankett: Huvuduppgift (komponent namespace) ─────────────
lines.push(' <gem:Blankett>')
lines.push(' <gem:Arendeinformation>')
lines.push(` <gem:Arendeagare>${orgIdentitet}</gem:Arendeagare>`)
lines.push(` <gem:Period>${period}</gem:Period>`)
lines.push(' </gem:Arendeinformation>')
lines.push(' <gem:Blankettinnehall>')
// HU/IU substitute for the abstract gem:Uppgift element in the komponent
// namespace (substitution group head). Use the concrete element directly —
// gem:Uppgift itself is abstract and cannot appear in an instance document.
lines.push(' <gem:HU>')
// AgRegistreradId is wrapped in ArbetsgivareHUGROUP, all payload elements
// live in the komponent namespace (gem: prefix).
lines.push(' <gem:ArbetsgivareHUGROUP>')
lines.push(` <gem:AgRegistreradId faltkod="201">${orgIdentitet}</gem:AgRegistreradId>`)
lines.push(' </gem:ArbetsgivareHUGROUP>')
lines.push(` <gem:RedovisningsPeriod faltkod="006">${period}</gem:RedovisningsPeriod>`)
// FK497 — Summa skatteavdrag (total from all IU)
if (totals.totalTax > 0) {
lines.push(` <AvdragenSkatt faltkod="001">${formatAmount(totals.totalTax)}</AvdragenSkatt>`)
lines.push(` <gem:SummaSkatteavdr faltkod="497">${formatAmount(totals.totalTax)}</gem:SummaSkatteavdr>`)
}
// Ruta 020: Total avgifter basis
if (totals.totalAvgifterBasis > 0) {
lines.push(` <SummaArbAvg>${formatAmount(totals.totalAvgifterBasis)}</SummaArbAvg>`)
// FK487 — Summa arbetsgivaravgifter och SLF (calculated total, NOT basis)
if (totals.totalAvgifterAmount > 0) {
lines.push(` <gem:SummaArbAvgSlf faltkod="487">${formatAmount(totals.totalAvgifterAmount)}</gem:SummaArbAvgSlf>`)
}
// Avgifter by category
if (totals.avgifterByCategory.standard) {
lines.push(` <AvgUnderlagStandardRate faltkod="060">${formatAmount(totals.avgifterByCategory.standard.basis)}</AvgUnderlagStandardRate>`)
}
if (totals.avgifterByCategory.reduced65plus) {
lines.push(` <AvgUnderlagAlderspension faltkod="061">${formatAmount(totals.avgifterByCategory.reduced65plus.basis)}</AvgUnderlagAlderspension>`)
}
if (totals.avgifterByCategory.youth) {
lines.push(` <AvgUnderlagUngdom faltkod="062">${formatAmount(totals.avgifterByCategory.youth.basis)}</AvgUnderlagUngdom>`)
// FK499 — Total sjuklönekostnad (legal requirement from 2025 when > 0)
if (totals.totalSjuklonekostnad && totals.totalSjuklonekostnad > 0) {
lines.push(` <gem:TotalSjuklonekostnad faltkod="499">${formatAmount(totals.totalSjuklonekostnad)}</gem:TotalSjuklonekostnad>`)
}
lines.push(' </HU>')
lines.push(' </Blankettinnehall>')
lines.push(' </Blankett>')
lines.push(' </gem:HU>')
lines.push(' </gem:Blankettinnehall>')
lines.push(' </gem:Blankett>')
// Individuppgifter (per employee)
// ── Blankett: Individuppgift (one per employee) ──────────────
for (const emp of employees) {
lines.push(' <Blankett>')
lines.push(' <Arendeinformation>')
lines.push(` <Arendeagare>${escapeXml(company.orgNumber.replace('-', ''))}</Arendeagare>`)
lines.push(` <Period>${period}</Period>`)
if (isCorrection) {
lines.push(' <Rattelse>J</Rattelse>')
}
lines.push(' </Arendeinformation>')
lines.push(' <Blankettinnehall>')
lines.push(' <IU>')
// FK215: Personnummer (CRITICAL: must be decrypted for AGI)
let pnr: string
try {
pnr = decryptPersonnummer(emp.personnummer)
} catch {
throw new Error(`Kunde inte dekryptera personnummer för anställd med FK570=${emp.specificationNumber}. AGI kan inte genereras utan giltigt personnummer.`)
throw new Error(
`Kunde inte dekryptera personnummer för anställd med FK570=${emp.specificationNumber}. ` +
'AGI kan inte genereras utan giltigt personnummer.'
)
}
lines.push(` <Personnummer faltkod="215">${pnr}</Personnummer>`)
// FK570: Specifikationsnummer (MUST stay consistent)
lines.push(` <Specifikationsnummer faltkod="570">${emp.specificationNumber}</Specifikationsnummer>`)
lines.push(' <gem:Blankett>')
lines.push(' <gem:Arendeinformation>')
lines.push(` <gem:Arendeagare>${orgIdentitet}</gem:Arendeagare>`)
lines.push(` <gem:Period>${period}</gem:Period>`)
lines.push(' </gem:Arendeinformation>')
lines.push(' <gem:Blankettinnehall>')
lines.push(' <gem:IU>')
// Identity groups wrap AgRegistreradId and BetalningsmottagarId in IU.
lines.push(' <gem:ArbetsgivareIUGROUP>')
lines.push(` <gem:AgRegistreradId faltkod="201">${orgIdentitet}</gem:AgRegistreradId>`)
lines.push(' </gem:ArbetsgivareIUGROUP>')
// BetalningsmottagarId must be inside BetalningsmottagareIDChoice (an
// xs:choice allowing BetalningsmottagarId | Fodelsetid | AnnatId).
lines.push(' <gem:BetalningsmottagareIUGROUP>')
lines.push(' <gem:BetalningsmottagareIDChoice>')
lines.push(` <gem:BetalningsmottagarId faltkod="215">${pnr}</gem:BetalningsmottagarId>`)
lines.push(' </gem:BetalningsmottagareIDChoice>')
lines.push(' </gem:BetalningsmottagareIUGROUP>')
lines.push(` <gem:RedovisningsPeriod faltkod="006">${period}</gem:RedovisningsPeriod>`)
lines.push(` <gem:Specifikationsnummer faltkod="570">${emp.specificationNumber}</gem:Specifikationsnummer>`)
// Ruta 011: Gross salary
// FK011 — Kontant ersättning, underlag arbetsgivaravgifter (= gross salary)
if (emp.grossSalary > 0) {
lines.push(` <KontantBruttoloen faltkod="011">${formatAmount(emp.grossSalary)}</KontantBruttoloen>`)
lines.push(` <gem:KontantErsattningUlagAG faltkod="011">${formatAmount(emp.grossSalary)}</gem:KontantErsattningUlagAG>`)
}
// Ruta 001: Tax withheld
// FK001 — Avdragen preliminärskatt
if (emp.taxWithheld > 0) {
lines.push(` <AvdragenSkatt faltkod="001">${formatAmount(emp.taxWithheld)}</AvdragenSkatt>`)
lines.push(` <gem:AvdrPrelSkatt faltkod="001">${formatAmount(emp.taxWithheld)}</gem:AvdrPrelSkatt>`)
}
// Benefits
// FK013 — Bilförmån (skattepliktig, underlag AG)
if (emp.benefitCar && emp.benefitCar > 0) {
lines.push(` <FormanBil faltkod="012">${formatAmount(emp.benefitCar)}</FormanBil>`)
lines.push(` <gem:SkatteplBilformanUlagAG faltkod="013">${formatAmount(emp.benefitCar)}</gem:SkatteplBilformanUlagAG>`)
}
// FK018 — Drivmedel vid bilförmån
if (emp.benefitFuel && emp.benefitFuel > 0) {
lines.push(` <FormanDrivmedel faltkod="013">${formatAmount(emp.benefitFuel)}</FormanDrivmedel>`)
lines.push(` <gem:DrivmVidBilformanUlagAG faltkod="018">${formatAmount(emp.benefitFuel)}</gem:DrivmVidBilformanUlagAG>`)
}
// FK043 — Bostadsförmån (ej småhus). TODO: for single-family home use
// BostadsformanSmahusUlagAG (FK041); currently defaults to non-småhus.
if (emp.benefitHousing && emp.benefitHousing > 0) {
lines.push(` <FormanBostad faltkod="014">${formatAmount(emp.benefitHousing)}</FormanBostad>`)
}
if (emp.benefitMeals && emp.benefitMeals > 0) {
lines.push(` <FormanKost faltkod="015">${formatAmount(emp.benefitMeals)}</FormanKost>`)
lines.push(` <gem:BostadsformanEjSmahusUlagAG faltkod="043">${formatAmount(emp.benefitHousing)}</gem:BostadsformanEjSmahusUlagAG>`)
}
// FK012 — Övriga skattepliktiga förmåner
if (emp.benefitOther && emp.benefitOther > 0) {
lines.push(` <FormanOvrigt faltkod="019">${formatAmount(emp.benefitOther)}</FormanOvrigt>`)
lines.push(` <gem:SkatteplOvrigaFormanerUlagAG faltkod="012">${formatAmount(emp.benefitOther)}</gem:SkatteplOvrigaFormanerUlagAG>`)
}
// Ruta 020: Avgifter basis
if (emp.avgifterBasis > 0) {
lines.push(` <UnderlagArbAvg faltkod="020">${formatAmount(emp.avgifterBasis)}</UnderlagArbAvg>`)
}
// Meal benefit: element name not verified in the component schema yet.
// Intentionally omitted until we have an authoritative mapping.
void emp.benefitMeals
// Ruta 131: F-skatt payments
// FK131 — Ersättning till mottagare med F-skattsedel (ej underlag SA)
if (emp.fSkattPayment && emp.fSkattPayment > 0) {
lines.push(` <ErsattningFSkatt faltkod="131">${formatAmount(emp.fSkattPayment)}</ErsattningFSkatt>`)
lines.push(` <gem:KontantErsattningEjUlagSA faltkod="131">${formatAmount(emp.fSkattPayment)}</gem:KontantErsattningEjUlagSA>`)
}
// Absence fields (from 2025)
if (emp.sickDays && emp.sickDays > 0) {
lines.push(` <SjukfranvaroDagar faltkod="821">${Math.round(emp.sickDays)}</SjukfranvaroDagar>`)
}
if (emp.vabDays && emp.vabDays > 0) {
lines.push(` <VabDagar faltkod="822">${Math.round(emp.vabDays)}</VabDagar>`)
}
if (emp.parentalDays && emp.parentalDays > 0) {
lines.push(` <ForaldraledigDagar faltkod="823">${Math.round(emp.parentalDays)}</ForaldraledigDagar>`)
}
// Sjuk/VAB/föräldra-dagar flows elsewhere:
// - Per-employee sick days are reported to Försäkringskassan, not AGI.
// The company-level total goes in HU as TotalSjuklonekostnad (FK499).
// - VAB and parental leave are reported via the top-level
// <Franvarouppgift> section (FK820-827) as per-event date records,
// not as per-IU day counts. Not implemented in this generator yet.
void emp.sickDays
void emp.vabDays
void emp.parentalDays
lines.push(' </IU>')
lines.push(' </Blankettinnehall>')
lines.push(' </Blankett>')
lines.push(' </gem:IU>')
lines.push(' </gem:Blankettinnehall>')
lines.push(' </gem:Blankett>')
}
lines.push('</Skatteverket>')
+74 -45
View File
@@ -58,7 +58,8 @@ export interface CalculationStep {
label: string
formula: string
input: Record<string, number | string>
output: number
/** Numeric result for the step. `null` for context-only rows (e.g. avgiftskategori) that describe a rule, not a calculation. */
output: number | null
}
export interface SalaryCalculationResult {
@@ -88,13 +89,36 @@ export interface AvgifterCalculation {
}
// ============================================================
// Rounding helper
// Rounding / formatting helpers
// ============================================================
function r(x: number): number {
return Math.round(x * 100) / 100
}
/**
* Format a rate (0.2081) as a Swedish percentage string ("20,81 %").
* Strips trailing zeros, uses Swedish comma as decimal separator, and rounds
* to avoid JS floating-point noise like "20.810000000000002".
*/
function fmtPct(decimal: number, decimals = 2): string {
const pct = decimal * 100
const rounded = Math.round(pct * 10 ** decimals) / 10 ** decimals
const str = rounded
.toFixed(decimals)
.replace(/\.?0+$/, '')
.replace('.', ',')
return `${str} %`
}
/**
* Format an integer amount with Swedish thousand-separators and "kr" suffix,
* for embedding inside formula descriptions ("25 000 kr").
*/
function fmtKr(amount: number): string {
return `${Math.round(amount).toLocaleString('sv-SE')} kr`
}
// ============================================================
// Main calculation
// ============================================================
@@ -126,7 +150,7 @@ export function calculateSalary(
baseSalary = r(input.monthlySalary * (input.employmentDegree / 100))
steps.push({
label: 'Grundlön',
formula: 'monthly_salary × (employment_degree / 100)',
formula: 'månadslön × (sysselsättningsgrad / 100)',
input: { monthly_salary: input.monthlySalary, employment_degree: input.employmentDegree },
output: baseSalary,
})
@@ -136,7 +160,7 @@ export function calculateSalary(
baseSalary = r(rate * hours)
steps.push({
label: 'Grundlön (timavlönad)',
formula: 'hourly_rate × hours_worked',
formula: 'timlön × arbetade timmar',
input: { hourly_rate: rate, hours_worked: hours },
output: baseSalary,
})
@@ -150,7 +174,7 @@ export function calculateSalary(
if (totalAdditions > 0) {
steps.push({
label: 'Tillägg (övertid, bonus, provision)',
formula: 'sum(additions)',
formula: 'summa tillägg',
input: { count: additions.length },
output: totalAdditions,
})
@@ -164,7 +188,7 @@ export function calculateSalary(
if (totalAbsence !== 0) {
steps.push({
label: 'Frånvaro (sjuk, VAB, semester, föräldraledig)',
formula: 'sum(absence_items)',
formula: 'summa frånvaroposter',
input: { count: absenceItems.length },
output: totalAbsence,
})
@@ -176,7 +200,7 @@ export function calculateSalary(
if (totalGrossDeductions > 0) {
steps.push({
label: 'Bruttolöneavdrag',
formula: 'sum(gross_deductions)',
formula: 'summa bruttoavdrag',
input: { count: grossDeductionItems.length },
output: -totalGrossDeductions,
})
@@ -186,7 +210,7 @@ export function calculateSalary(
const grossSalary = r(baseSalary + totalAdditions + totalAbsence - totalGrossDeductions)
steps.push({
label: 'Bruttolön',
formula: 'base + additions + absence - gross_deductions',
formula: 'grundlön + tillägg + frånvaro bruttoavdrag',
input: { base: baseSalary, additions: totalAdditions, absence: totalAbsence, gross_deductions: totalGrossDeductions },
output: grossSalary,
})
@@ -199,7 +223,7 @@ export function calculateSalary(
if (totalBenefits > 0) {
steps.push({
label: 'Förmånsvärden',
formula: 'sum(benefit_values)',
formula: 'summa förmåner',
input: { count: benefitItems.length },
output: totalBenefits,
})
@@ -208,7 +232,7 @@ export function calculateSalary(
const taxableIncome = r(grossSalary + totalBenefits)
steps.push({
label: 'Skattegrundande inkomst',
formula: 'gross_salary + benefit_values',
formula: 'bruttolön + förmåner',
input: { gross_salary: grossSalary, benefit_values: totalBenefits },
output: taxableIncome,
})
@@ -222,7 +246,7 @@ export function calculateSalary(
taxWithheld = 0
steps.push({
label: 'Skatteavdrag (F-skatt)',
formula: '0 (F-skattsedel, inget avdrag)',
formula: 'F-skattsedel inget skatteavdrag görs',
input: {},
output: 0,
})
@@ -231,7 +255,7 @@ export function calculateSalary(
taxWithheld = r(taxableIncome * 0.30)
steps.push({
label: 'Skatteavdrag (ej verifierad)',
formula: 'taxable_income × 30%',
formula: 'skattegrundande inkomst × 30 %',
input: { taxable_income: taxableIncome },
output: taxWithheld,
})
@@ -240,7 +264,7 @@ export function calculateSalary(
taxWithheld = calculateSidoinkomstTax(taxableIncome)
steps.push({
label: 'Skatteavdrag (sidoinkomst 30 %)',
formula: 'taxable_income × 30%',
formula: 'skattegrundande inkomst × 30 %',
input: { taxable_income: taxableIncome },
output: taxWithheld,
})
@@ -249,7 +273,7 @@ export function calculateSalary(
taxWithheld = calculateJamkningTax(taxableIncome, input.jamkningPercentage)
steps.push({
label: `Skatteavdrag (jämkning ${input.jamkningPercentage} %)`,
formula: 'taxable_income × jamkning_percentage / 100',
formula: `skattegrundande inkomst × ${input.jamkningPercentage} %`,
input: { taxable_income: taxableIncome, jamkning_percentage: input.jamkningPercentage },
output: taxWithheld,
})
@@ -258,7 +282,7 @@ export function calculateSalary(
taxWithheld = lookupTaxAmount(input.taxTableNumber, input.taxColumn, taxableIncome, taxRates)
steps.push({
label: `Skatteavdrag (tabell ${input.taxTableNumber}, kolumn ${input.taxColumn})`,
formula: `lookup(table=${input.taxTableNumber}, column=${input.taxColumn}, income=${Math.round(taxableIncome)})`,
formula: `skattetabell ${input.taxTableNumber}, kolumn ${input.taxColumn}, inkomst ${fmtKr(taxableIncome)}`,
input: { table: input.taxTableNumber, column: input.taxColumn, taxable_income: taxableIncome },
output: taxWithheld,
})
@@ -267,7 +291,7 @@ export function calculateSalary(
taxWithheld = r(taxableIncome * 0.30)
steps.push({
label: 'Skatteavdrag (30 % schablon)',
formula: 'taxable_income × 30%',
formula: 'skattegrundande inkomst × 30 %',
input: { taxable_income: taxableIncome },
output: taxWithheld,
})
@@ -280,7 +304,7 @@ export function calculateSalary(
const netSalary = r(grossSalary - taxWithheld - totalNetDeductions)
steps.push({
label: 'Nettolön',
formula: 'gross - tax - net_deductions',
formula: 'bruttolön skatt nettoavdrag',
input: { gross: grossSalary, tax: taxWithheld, net_deductions: totalNetDeductions },
output: netSalary,
})
@@ -299,7 +323,7 @@ export function calculateSalary(
steps.push(...avgifterCalc.steps)
steps.push({
label: 'Arbetsgivaravgifter (ungdomsrabatt med tak)',
formula: `${config.avgifterYouthSalaryCap} × ${avgifterCalc.rate} + ${r(avgifterBasis - config.avgifterYouthSalaryCap)} × ${config.avgifterTotal}`,
formula: `${fmtKr(config.avgifterYouthSalaryCap)} × ${fmtPct(avgifterCalc.rate)} + ${fmtKr(avgifterBasis - config.avgifterYouthSalaryCap)} × ${fmtPct(config.avgifterTotal)}`,
input: { cap: config.avgifterYouthSalaryCap, reduced: reducedPart, standard: standardPart },
output: avgifterAmount,
})
@@ -310,7 +334,7 @@ export function calculateSalary(
steps.push(...avgifterCalc.steps)
steps.push({
label: 'Arbetsgivaravgifter (växa-stöd med tak)',
formula: `${config.avgifterVaxaStodCap} × ${avgifterCalc.rate} + ${r(avgifterBasis - config.avgifterVaxaStodCap)} × ${config.avgifterTotal}`,
formula: `${fmtKr(config.avgifterVaxaStodCap)} × ${fmtPct(avgifterCalc.rate)} + ${fmtKr(avgifterBasis - config.avgifterVaxaStodCap)} × ${fmtPct(config.avgifterTotal)}`,
input: { cap: config.avgifterVaxaStodCap, reduced: reducedPart, standard: standardPart },
output: avgifterAmount,
})
@@ -319,7 +343,7 @@ export function calculateSalary(
steps.push(...avgifterCalc.steps)
steps.push({
label: 'Arbetsgivaravgifter',
formula: 'avgifter_basis × rate',
formula: `avgiftsunderlag × ${fmtPct(avgifterCalc.rate)}`,
input: { avgifter_basis: avgifterBasis, rate: avgifterCalc.rate },
output: avgifterAmount,
})
@@ -335,8 +359,8 @@ export function calculateSalary(
const rate = input.vacationDaysPerYear >= 30 ? 0.144 : 0.12
vacationAccrual = r(vacationBasis * rate)
steps.push({
label: `Semesteravsättning (procentregeln ${rate * 100}%)`,
formula: 'vacation_basis × rate',
label: `Semesteravsättning (procentregeln ${fmtPct(rate)})`,
formula: `semesterunderlag × ${fmtPct(rate)}`,
input: { vacation_basis: vacationBasis, rate },
output: vacationAccrual,
})
@@ -350,8 +374,8 @@ export function calculateSalary(
const tillagg = r(dailyRate * input.semestertillaggRate * input.vacationDaysPerYear)
vacationAccrual = tillagg
steps.push({
label: `Semesteravsättning (sammalöneregeln, tillägg ${(input.semestertillaggRate * 100).toFixed(2)}%)`,
formula: 'daily_rate × semestertillagg_rate × vacation_days',
label: `Semesteravsättning (sammalöneregeln, tillägg ${fmtPct(input.semestertillaggRate)})`,
formula: `dagslön × ${fmtPct(input.semestertillaggRate)} × semesterdagar`,
input: { daily_rate: dailyRate, semestertillagg_rate: input.semestertillaggRate, vacation_days: input.vacationDaysPerYear },
output: vacationAccrual,
})
@@ -361,7 +385,7 @@ export function calculateSalary(
const vacationAccrualAvgifter = r(vacationAccrual * avgifterCalc.rate)
steps.push({
label: 'Arbetsgivaravgifter på semesteravsättning',
formula: 'vacation_accrual × avgifter_rate',
formula: `semesteravsättning × ${fmtPct(avgifterCalc.rate)}`,
input: { vacation_accrual: vacationAccrual, avgifter_rate: avgifterCalc.rate },
output: vacationAccrualAvgifter,
})
@@ -369,7 +393,7 @@ export function calculateSalary(
const totalEmployerCost = r(grossSalary + avgifterAmount + vacationAccrual + vacationAccrualAvgifter)
steps.push({
label: 'Total arbetsgivarkostnad',
formula: 'gross + avgifter + vacation_accrual + vacation_avgifter',
formula: 'bruttolön + avgifter + semesteravsättning + avgifter på semester',
input: { gross: grossSalary, avgifter: avgifterAmount, vacation_accrual: vacationAccrual, vacation_avgifter: vacationAccrualAvgifter },
output: totalEmployerCost,
})
@@ -418,7 +442,12 @@ export function calculateAvgifterRate(
amount: 0,
basis: 0,
category: 'standard',
steps: [{ label: 'Avgiftskategori', formula: 'standard (personnummer ej dekrypterbart)', input: {}, output: config.avgifterTotal }],
steps: [{
label: 'Avgiftskategori',
formula: `Standard ${fmtPct(config.avgifterTotal)} (personnummer kunde inte dekrypteras)`,
input: {},
output: null,
}],
}
}
@@ -429,9 +458,9 @@ export function calculateAvgifterRate(
if (birthYear <= 1937) {
steps.push({
label: 'Avgiftskategori',
formula: 'Född 1937: 0%',
formula: 'Född 1937 eller tidigare — inga arbetsgivaravgifter',
input: { birth_year: birthYear },
output: 0,
output: null,
})
return { rate: 0, amount: 0, basis: 0, category: 'exempt', steps }
}
@@ -440,9 +469,9 @@ export function calculateAvgifterRate(
if (ageAtYearStart >= config.reducedAvgiftAge) {
steps.push({
label: 'Avgiftskategori',
formula: `Ålder ${ageAtYearStart} ${config.reducedAvgiftAge}: reducerad (${config.avgifterReduced65plus * 100}%)`,
formula: `Ålder ${ageAtYearStart} år: reducerad avgift ${fmtPct(config.avgifterReduced65plus)} (endast ålderspensionsavgift)`,
input: { age: ageAtYearStart, threshold: config.reducedAvgiftAge },
output: config.avgifterReduced65plus,
output: null,
})
return { rate: config.avgifterReduced65plus, amount: 0, basis: 0, category: 'reduced_65plus', steps }
}
@@ -453,9 +482,9 @@ export function calculateAvgifterRate(
if (payDate >= input.vaxaStodStart && payDate <= input.vaxaStodEnd && config.avgifterVaxaStodRate !== null) {
steps.push({
label: 'Avgiftskategori',
formula: `Växa-stöd: ${(config.avgifterVaxaStodRate ?? 0) * 100}% på första ${config.avgifterVaxaStodCap} SEK`,
formula: `Växa-stöd ${fmtPct(config.avgifterVaxaStodRate ?? 0)} på första ${fmtKr(config.avgifterVaxaStodCap ?? 0)}`,
input: { vaxa_cap: config.avgifterVaxaStodCap ?? 0 },
output: config.avgifterVaxaStodRate ?? 0,
output: null,
})
return { rate: config.avgifterVaxaStodRate ?? config.avgifterTotal, amount: 0, basis: 0, category: 'vaxa_stod', steps }
}
@@ -470,9 +499,9 @@ export function calculateAvgifterRate(
if (isYouthPeriod) {
steps.push({
label: 'Avgiftskategori',
formula: `Ungdomsrabatt (${ageAtYearStart} år): ${config.avgifterYouthRate * 100}% på första ${config.avgifterYouthSalaryCap} SEK`,
formula: `Ungdomsrabatt (${ageAtYearStart} år): ${fmtPct(config.avgifterYouthRate)} på första ${fmtKr(config.avgifterYouthSalaryCap ?? 0)}`,
input: { age: ageAtYearStart, cap: config.avgifterYouthSalaryCap ?? 0 },
output: config.avgifterYouthRate,
output: null,
})
return { rate: config.avgifterYouthRate, amount: 0, basis: 0, category: 'youth', steps }
}
@@ -481,9 +510,9 @@ export function calculateAvgifterRate(
// Standard rate
steps.push({
label: 'Avgiftskategori',
formula: `Standard: ${config.avgifterTotal * 100}%`,
formula: `Standard ${fmtPct(config.avgifterTotal)}`,
input: { age: ageAtYearStart },
output: config.avgifterTotal,
output: null,
})
return { rate: config.avgifterTotal, amount: 0, basis: 0, category: 'standard', steps }
}
@@ -517,7 +546,7 @@ export function calculateSjuklon(
const karensavdrag = calculateKarensavdrag(monthlySalary, config)
steps.push({
label: 'Karensavdrag',
formula: '20% × (monthly × 12/52 × 80%)',
formula: `20 % × (månadslön × 12/52 × ${fmtPct(config.sjuklonRate)})`,
input: { monthly_salary: monthlySalary },
output: karensavdrag,
})
@@ -526,8 +555,8 @@ export function calculateSjuklon(
const sjuklonDays = Math.min(Math.max(sickDays - 1, 0), 13)
const sjuklon = r(dailyRate * config.sjuklonRate * sjuklonDays)
steps.push({
label: 'Sjuklön dag 2-14',
formula: 'daily_rate × 80% × (sick_days - 1)',
label: 'Sjuklön dag 214',
formula: `dagslön × ${fmtPct(config.sjuklonRate)} × (sjukdagar 1)`,
input: { daily_rate: dailyRate, sjuklon_rate: config.sjuklonRate, days: sjuklonDays },
output: sjuklon,
})
@@ -537,7 +566,7 @@ export function calculateSjuklon(
const totalDeduction = r(-(fullPayForPeriod - sjuklon + karensavdrag))
steps.push({
label: 'Netto sjukavdrag',
formula: '-(full_pay - sjuklon + karensavdrag)',
formula: '(full lön sjuklön + karensavdrag)',
input: { full_pay: fullPayForPeriod, sjuklon, karensavdrag },
output: totalDeduction,
})
@@ -561,8 +590,8 @@ export function calculateVacationAccrual(params: {
const rate = params.vacationDaysPerYear >= 30 ? 0.144 : 0.12
const accrual = r(params.vacationBasis * rate)
steps.push({
label: `Semesteravsättning (procentregeln ${rate * 100}%)`,
formula: 'vacation_basis × rate',
label: `Semesteravsättning (procentregeln ${fmtPct(rate)})`,
formula: `semesterunderlag × ${fmtPct(rate)}`,
input: { vacation_basis: params.vacationBasis, rate },
output: accrual,
})
@@ -571,8 +600,8 @@ export function calculateVacationAccrual(params: {
const dailyRate = r(params.monthlySalary / 21)
const accrual = r(dailyRate * params.semestertillaggRate * params.vacationDaysPerYear)
steps.push({
label: `Semesteravsättning (sammalöneregeln ${params.semestertillaggRate * 100}%)`,
formula: 'daily_rate × semestertillagg_rate × vacation_days',
label: `Semesteravsättning (sammalöneregeln ${fmtPct(params.semestertillaggRate)})`,
formula: `dagslön × ${fmtPct(params.semestertillaggRate)} × semesterdagar`,
input: { daily_rate: dailyRate, rate: params.semestertillaggRate, days: params.vacationDaysPerYear },
output: accrual,
})
+98 -3
View File
@@ -1,4 +1,5 @@
import { createJournalEntry, findFiscalPeriod } from '@/lib/bookkeeping/engine'
import { getBASReference } from '@/lib/bookkeeping/bas-reference'
import { createLogger } from '@/lib/logger'
import { SALARY_ACCOUNTS, getLineItemAccount } from './account-mapping'
import type { SupabaseClient } from '@supabase/supabase-js'
@@ -77,6 +78,8 @@ export async function createSalaryRunEntries(
const periodLabel = `${run.period_year}-${String(run.period_month).padStart(2, '0')}`
const desc = `Lön ${periodLabel}`
await ensureSalaryAccountsExist(supabase, companyId, userId, run)
// ─── Entry 1: Salary (brutto, skatt, netto) ───
const salaryEntry = await createSalaryEntry(
supabase, companyId, userId, run, fiscalPeriodId, desc
@@ -140,18 +143,25 @@ async function createSalaryEntry(
// Förmånsvärden (benefits) are excluded — they affect the tax base but
// have no cash flow and should not appear as expense lines in the journal.
const BENEFIT_TYPES = ['benefit_car', 'benefit_housing', 'benefit_meals', 'benefit_wellness', 'benefit_other']
let lineItemTotal = 0
for (const li of emp.line_items) {
if (li.is_net_deduction || li.is_gross_deduction) continue
if (BENEFIT_TYPES.includes(li.item_type)) continue // No cash flow for förmånsvärden
const account = li.account_number || getLineItemAccount(li.item_type as never, emp.employment_type)
const current = expenseByAccount.get(account) || 0
expenseByAccount.set(account, current + li.amount)
lineItemTotal += li.amount
}
// If no specific line items resolved, use gross salary on default account
if (emp.line_items.length === 0) {
// Ensure the debit side always equals gross_salary (minus gross deductions,
// which the credit side doesn't book either). If line items don't cover the
// full gross amount, book the remainder to the default salary account so the
// entry balances. Without this, an employee with overtime line items but no
// base-salary line item would fail the check_journal_entry_balance() trigger.
const baseRemainder = Math.round((emp.gross_salary - lineItemTotal) * 100) / 100
if (baseRemainder !== 0) {
const current = expenseByAccount.get(salaryAccount) || 0
expenseByAccount.set(salaryAccount, current + emp.gross_salary)
expenseByAccount.set(salaryAccount, current + baseRemainder)
}
}
@@ -410,6 +420,91 @@ function getEmployeeSalaryAccount(employmentType: string): string {
}
}
/**
* Ensure every BAS account referenced by the salary run exists in
* chart_of_accounts. Users who seeded the minimal chart via
* seed_chart_of_accounts will be missing many 7xxx/29xx accounts — we
* auto-create them from BAS reference data on first salary booking.
*/
async function ensureSalaryAccountsExist(
supabase: SupabaseClient,
companyId: string,
userId: string,
run: SalaryRunData
): Promise<void> {
const needed = new Set<string>()
for (const account of Object.values(SALARY_ACCOUNTS)) needed.add(account)
for (const emp of run.employees) {
needed.add(getEmployeeSalaryAccount(emp.employment_type))
for (const li of emp.line_items) {
const account = li.account_number || getLineItemAccount(li.item_type as never, emp.employment_type)
if (account) needed.add(account)
}
}
if (needed.size === 0) return
const { data: existing, error } = await supabase
.from('chart_of_accounts')
.select('account_number')
.eq('company_id', companyId)
.in('account_number', [...needed])
if (error) {
throw new Error(`Kunde inte läsa kontoplanen: ${error.message}`)
}
const existingSet = new Set((existing || []).map(a => a.account_number))
const missing = [...needed].filter(num => !existingSet.has(num))
if (missing.length === 0) return
const inserts = missing.map(accountNumber => {
const basRef = getBASReference(accountNumber)
if (basRef) {
return {
user_id: userId,
company_id: companyId,
account_number: accountNumber,
account_name: basRef.account_name,
account_class: basRef.account_class,
account_group: basRef.account_group,
account_type: basRef.account_type,
normal_balance: basRef.normal_balance,
sru_code: basRef.sru_code,
k2_excluded: basRef.k2_excluded,
plan_type: 'full_bas',
is_active: true,
is_system_account: false,
}
}
// Fallback — shouldn't happen for salary accounts, but keeps us safe.
const classNum = parseInt(accountNumber.charAt(0), 10)
const group = accountNumber.substring(0, 2)
return {
user_id: userId,
company_id: companyId,
account_number: accountNumber,
account_name: `Konto ${accountNumber}`,
account_class: classNum,
account_group: group,
account_type: classNum >= 4 ? 'expense' : classNum === 2 ? 'liability' : 'asset',
normal_balance: classNum <= 1 || classNum >= 4 ? 'debit' : 'credit',
plan_type: 'full_bas',
is_active: true,
is_system_account: false,
}
})
const { error: insertError } = await supabase.from('chart_of_accounts').insert(inserts)
if (insertError && !insertError.message.includes('duplicate')) {
throw new Error(`Kunde inte skapa saknade konton: ${insertError.message}`)
}
log.info(`Auto-created ${missing.length} missing salary accounts: ${missing.join(', ')}`)
}
function accountLabel(account: string): string {
const labels: Record<string, string> = {
'7210': 'Löner tjänstemän',
File diff suppressed because it is too large Load Diff
+131 -24
View File
@@ -1,25 +1,32 @@
/**
* Tax table lookup via Skatteverket's open data API.
*
* Uses the free, public EntryScape rowstore API no authentication required.
* Endpoints:
* Primary source: Skatteverket EntryScape rowstore API (no authentication).
* - Tax tables: https://skatteverket.entryscape.net/rowstore/dataset/88320397-5c32-4c16-ae79-d36d95b17b95
* - Kommun rates: https://skatteverket.entryscape.net/rowstore/dataset/c67b320b-ffee-4876-b073-dd9236cd2a99
*
* Emergency fallback: lib/salary/tax-tables-fallback.ts (generated from
* Skatteverket's published TXT file — used only if the API is unreachable).
*
* Per Skatteförfarandelagen: Tax withholding must use the correct table/column
* for each employee based on their folkbokföringskommun.
* for each employee based on their folkbokföringskommun. No silent percentage
* fallback is used — if neither the API nor the local fallback can serve the
* requested data, the lookup throws. This prevents silent under-withholding.
*
* Results are cached in-memory per salary run calculation to avoid redundant
* API calls (one call fetches all brackets for a table/column combination).
*/
import { createLogger } from '@/lib/logger'
import { FALLBACK_TAX_TABLES, FALLBACK_TAX_TABLE_YEARS } from './tax-tables-fallback'
const log = createLogger('tax-tables')
const TAX_TABLE_API = 'https://skatteverket.entryscape.net/rowstore/dataset/88320397-5c32-4c16-ae79-d36d95b17b95'
const KOMMUN_RATES_API = 'https://skatteverket.entryscape.net/rowstore/dataset/c67b320b-ffee-4876-b073-dd9236cd2a99'
export type TaxTableSource = 'api' | 'fallback'
export interface TaxTableRate {
tableYear: number
tableNumber: number
@@ -29,28 +36,58 @@ export interface TaxTableRate {
taxAmount: number
}
export interface TaxTableRatesResult {
rates: TaxTableRate[]
source: TaxTableSource
}
// In-memory cache: "year-table-column" → rates
const rateCache = new Map<string, { rates: TaxTableRate[]; fetchedAt: number }>()
interface CachedEntry {
rates: TaxTableRate[]
source: TaxTableSource
fetchedAt: number
}
const rateCache = new Map<string, CachedEntry>()
const CACHE_TTL_MS = 60 * 60 * 1000 // 1 hour
/**
* Thrown when tax table data is unavailable from both the API and local fallback.
* Payroll calculation must fail loudly rather than silently under-withhold.
*/
export class TaxTableUnavailableError extends Error {
constructor(
message: string,
public readonly context: { year: number; tableNumber: number; column: number }
) {
super(message)
this.name = 'TaxTableUnavailableError'
}
}
/**
* Look up tax amount for a given monthly income using Skatteverket's API.
*
* Fetches the matching bracket from the API (or cache) and returns the
* tax amount in SEK for the given income.
* Returns the tax amount in SEK and the data source used.
*/
export async function lookupTaxFromApi(
tableNumber: number,
column: number,
monthlyIncome: number,
year: number = new Date().getFullYear()
): Promise<number> {
const rates = await fetchTaxTableRates(year, tableNumber, column)
return lookupTaxAmount(tableNumber, column, monthlyIncome, rates)
): Promise<{ taxAmount: number; source: TaxTableSource }> {
const { rates, source } = await fetchTaxTableRates(year, tableNumber, column)
return {
taxAmount: lookupTaxAmount(tableNumber, column, monthlyIncome, rates),
source,
}
}
/**
* Look up the tax amount from pre-loaded rates (pure function, no API call).
*
* Throws TaxTableUnavailableError if no rates match the requested table/column.
* Callers must ensure rates have been loaded via fetchTaxTableRates first —
* we deliberately avoid a silent percentage fallback because silent wrong
* withholding is worse than loud failure.
*/
export function lookupTaxAmount(
tableNumber: number,
@@ -65,8 +102,11 @@ export function lookupTaxAmount(
)
if (matchingRates.length === 0) {
// Fallback: 30% flat rate if table not found
return Math.round(roundedIncome * 0.30 * 100) / 100
throw new TaxTableUnavailableError(
`No tax table rates found for table ${tableNumber}, column ${column}. ` +
`Ensure fetchTaxTableRates succeeded before calling lookupTaxAmount.`,
{ year: rates[0]?.tableYear ?? 0, tableNumber, column }
)
}
matchingRates.sort((a, b) => a.incomeFrom - b.incomeFrom)
@@ -77,7 +117,8 @@ export function lookupTaxAmount(
}
}
// Above all brackets — use last bracket
// Above all brackets — use last bracket (matches Skatteverket's published behavior
// where the top B-row applies until %-rows take over; we only load B-rows)
const lastRate = matchingRates[matchingRates.length - 1]
if (roundedIncome > lastRate.incomeTo) {
return lastRate.taxAmount
@@ -86,20 +127,52 @@ export function lookupTaxAmount(
return 0
}
/**
* Build TaxTableRate[] for a given year/table/column from the bundled fallback data.
* Returns null when the requested year/table is not present in the fallback module.
*/
function getFallbackRates(
year: number,
tableNumber: number,
column: number
): TaxTableRate[] | null {
const yearTables = FALLBACK_TAX_TABLES[year]
if (!yearTables) return null
const rows = yearTables[tableNumber]
if (!rows) return null
if (column < 1 || column > 6) return null
// Columns 1-6 map to tuple indices 2-7 ([incomeFrom, incomeTo, col1..col6])
const colIndex = column + 1
return rows.map(row => ({
tableYear: year,
tableNumber,
columnNumber: column,
incomeFrom: row[0],
incomeTo: row[1] || 9999999,
taxAmount: row[colIndex] as number,
}))
}
/**
* Fetch tax table rates from Skatteverket's open data API.
* Returns all brackets for a specific year/table/column combination.
*
* On API failure, falls back to bundled Skatteverket TXT data (see
* tax-tables-fallback.ts). If neither source has the requested year,
* throws TaxTableUnavailableError so payroll calculation fails loudly.
*
* Results are cached in-memory for 1 hour.
*/
export async function fetchTaxTableRates(
year: number,
tableNumber: number,
column: number
): Promise<TaxTableRate[]> {
): Promise<TaxTableRatesResult> {
const cacheKey = `${year}-${tableNumber}-${column}`
const cached = rateCache.get(cacheKey)
if (cached && Date.now() - cached.fetchedAt < CACHE_TTL_MS) {
return cached.rates
return { rates: cached.rates, source: cached.source }
}
try {
@@ -164,6 +237,12 @@ export async function fetchTaxTableRates(
}
}
if (allResults.length === 0) {
// API responded but has no rows for this year/table — treat like failure
// so the fallback path runs below.
throw new Error(`Skatteverket API returned no rows for table ${tableNumber} year ${year}`)
}
// Parse results — each row has all 6 columns, we extract the requested one
const columnKey = `kolumn ${column}` as keyof typeof allResults[0]
const rates: TaxTableRate[] = allResults.map(r => ({
@@ -175,28 +254,51 @@ export async function fetchTaxTableRates(
taxAmount: parseInt(r[columnKey] as string) || 0,
}))
// Cache the results
rateCache.set(cacheKey, { rates, fetchedAt: Date.now() })
rateCache.set(cacheKey, { rates, source: 'api', fetchedAt: Date.now() })
log.info(`Fetched ${rates.length} tax brackets for table ${tableNumber} col ${column} (${year})`)
return rates
return { rates, source: 'api' }
} catch (err) {
log.warn(`Failed to fetch tax table from API: ${err instanceof Error ? err.message : 'unknown'}. Falling back to 30%.`)
return []
const apiErr = err instanceof Error ? err.message : 'unknown'
const fallbackRates = getFallbackRates(year, tableNumber, column)
if (fallbackRates) {
log.warn(
`Skatteverket API unavailable (${apiErr}) — using bundled fallback for table ${tableNumber} col ${column} (${year})`
)
rateCache.set(cacheKey, {
rates: fallbackRates,
source: 'fallback',
fetchedAt: Date.now(),
})
return { rates: fallbackRates, source: 'fallback' }
}
const supportedYears = Array.from(FALLBACK_TAX_TABLE_YEARS).join(', ') || 'none'
throw new TaxTableUnavailableError(
`Kunde inte hämta skattetabell ${tableNumber} kolumn ${column} för ${year} från Skatteverket ` +
`(${apiErr}). Ingen lokal reservdata finns för året ${year} (reservdata finns för: ${supportedYears}).`,
{ year, tableNumber, column }
)
}
}
/**
* Fetch all tax table rates for a year (all tables/columns for a salary run).
* Used by the calculate route for bulk lookups.
*
* Returns { rates, source } where source is 'api' if every table came from the
* API, 'fallback' if every table came from the local fallback, and 'mixed' if
* some came from each (indicates partial API outage).
*/
export async function fetchAllTaxTableRatesForRun(
year: number,
tableNumbers: number[],
columns: number[]
): Promise<TaxTableRate[]> {
): Promise<{ rates: TaxTableRate[]; source: TaxTableSource | 'mixed' }> {
const allRates: TaxTableRate[] = []
const uniquePairs = new Set<string>()
let sawApi = false
let sawFallback = false
for (const table of tableNumbers) {
for (const col of columns) {
@@ -204,12 +306,17 @@ export async function fetchAllTaxTableRatesForRun(
if (uniquePairs.has(key)) continue
uniquePairs.add(key)
const rates = await fetchTaxTableRates(year, table, col)
const { rates, source } = await fetchTaxTableRates(year, table, col)
allRates.push(...rates)
if (source === 'api') sawApi = true
else if (source === 'fallback') sawFallback = true
}
}
return allRates
const source: TaxTableSource | 'mixed' =
sawApi && sawFallback ? 'mixed' : sawFallback ? 'fallback' : 'api'
return { rates: allRates, source }
}
/**
+12
View File
@@ -42,6 +42,18 @@ export async function updateSession(request: NextRequest) {
// Get the pathname
const pathname = request.nextUrl.pathname
// Salary feature is temporarily disabled in production — block page and API
// routes. Sidebar links render as "Kommer snart" and direct URL access is
// blocked. Local dev (NODE_ENV === 'development') keeps everything enabled
// so we can continue building the feature.
const SALARY_DISABLED = process.env.NODE_ENV !== 'development'
if (SALARY_DISABLED && (pathname === '/salary' || pathname.startsWith('/salary/') || pathname.startsWith('/api/salary/'))) {
if (pathname.startsWith('/api/')) {
return NextResponse.json({ error: 'Lön är inte tillgängligt ännu.' }, { status: 404 })
}
return NextResponse.redirect(new URL('/', request.url))
}
// If the refresh token is stale/invalid, clear the session cookies
// so the browser stops sending them on every request.
// Skip on auth routes — the callback needs PKCE cookies intact.
+3
View File
@@ -16,5 +16,8 @@ export const config = {
* - Static assets (images, scripts, manifest, icons, etc.)
*/
'/((?!_next/static|_next/image|favicon.ico|api|\\.well-known|sw\\.js|sw-register\\.js|manifest\\.json|icons/|.*\\.(?:svg|png|jpg|jpeg|gif|webp|ico|js|json)$).*)',
// Also run on /api/salary/* so the feature-gate block reaches those
// routes (they're disabled while the salary module is "Kommer snart").
'/api/salary/:path*',
],
}
+180
View File
@@ -0,0 +1,180 @@
/**
* Parse Skatteverket SKV 434 monthly tax tables (fixed-width TXT) and emit a
* TypeScript module used as an emergency fallback when Skatteverket's open-data
* API is unavailable.
*
* Input: data/tax-tables/{year}/allmanna-tabeller-manad.txt
* Output: lib/salary/tax-tables-fallback.ts
*
* Record format (49 chars per line):
* chars 0-4 (width 5): prefix — "30B29" = monthly/belopp, table 29
* chars 5-11 (width 7): income_from
* chars 12-18 (width 7): income_to
* chars 19-23 (width 5): column 1 tax amount (SEK)
* chars 24-28 (width 5): column 2
* chars 29-33 (width 5): column 3
* chars 34-38 (width 5): column 4
* chars 39-43 (width 5): column 5
* chars 44-48 (width 5): column 6
*
* We import only B-rows (absolute amounts). %-rows (percentage-based, used for
* incomes above the highest B-row bracket) are skipped — matches the behavior
* of the Skatteverket API path which also fetches only B-rows.
*
* Usage:
* npx tsx scripts/import-tax-tables.ts --year 2026
*/
import { readFileSync, writeFileSync } from 'fs'
import { resolve } from 'path'
type TaxRow = readonly [number, number, number, number, number, number, number, number]
interface ParsedTable {
tableNumber: number
rows: TaxRow[]
}
function parseArgs(): { year: number } {
const args = process.argv.slice(2)
const yearIdx = args.indexOf('--year')
if (yearIdx === -1 || !args[yearIdx + 1]) {
throw new Error('Missing --year argument')
}
const year = parseInt(args[yearIdx + 1], 10)
if (!Number.isInteger(year) || year < 2000 || year > 2100) {
throw new Error(`Invalid year: ${args[yearIdx + 1]}`)
}
return { year }
}
function parseLine(line: string): { table: number; row: TaxRow } | null {
// Strip BOM if present on the first line
const clean = line.replace(/^\uFEFF/, '')
if (clean.length < 49) return null
const prefix = clean.slice(0, 5)
// B-rows only (absolute amounts). Skip %-rows.
if (prefix[2] !== 'B') return null
const tableStr = prefix.slice(3, 5)
const table = parseInt(tableStr, 10)
if (!Number.isInteger(table)) return null
const parseField = (start: number, width: number): number => {
const raw = clean.slice(start, start + width).trim()
if (raw === '') return 0
const n = parseInt(raw, 10)
return Number.isFinite(n) ? n : 0
}
const incomeFrom = parseField(5, 7)
const incomeTo = parseField(12, 7)
const c1 = parseField(19, 5)
const c2 = parseField(24, 5)
const c3 = parseField(29, 5)
const c4 = parseField(34, 5)
const c5 = parseField(39, 5)
const c6 = parseField(44, 5)
return {
table,
row: [incomeFrom, incomeTo, c1, c2, c3, c4, c5, c6] as const,
}
}
function parseFile(path: string): ParsedTable[] {
const content = readFileSync(path, 'utf-8')
const byTable = new Map<number, TaxRow[]>()
for (const rawLine of content.split(/\r?\n/)) {
if (!rawLine.trim()) continue
const parsed = parseLine(rawLine)
if (!parsed) continue
const existing = byTable.get(parsed.table)
if (existing) {
existing.push(parsed.row)
} else {
byTable.set(parsed.table, [parsed.row])
}
}
const tables = Array.from(byTable.entries())
.map(([tableNumber, rows]) => ({
tableNumber,
rows: rows.sort((a, b) => a[0] - b[0]),
}))
.sort((a, b) => a.tableNumber - b.tableNumber)
return tables
}
function formatRow(row: TaxRow): string {
return `[${row.join(', ')}]`
}
function emitModule(year: number, tables: ParsedTable[]): string {
const totalRows = tables.reduce((sum, t) => sum + t.rows.length, 0)
const tableNumbers = tables.map(t => t.tableNumber).join(', ')
const entries = tables
.map(t => {
const rows = t.rows.map(formatRow).join(',\n ')
return ` ${t.tableNumber}: [\n ${rows},\n ]`
})
.join(',\n')
return `/**
* AUTO-GENERATED — do not edit by hand.
*
* Source: data/tax-tables/${year}/allmanna-tabeller-manad.txt (Skatteverket SKV 434)
* Generator: scripts/import-tax-tables.ts
*
* Emergency fallback for lib/salary/tax-tables.ts when the Skatteverket
* open-data API is unreachable. Do not use as the primary source — the API
* is authoritative.
*
* Rows: ${totalRows} across tables ${tableNumbers}
*/
/** [incomeFrom, incomeTo, col1, col2, col3, col4, col5, col6] */
export type FallbackTaxRow = readonly [
number, number, number, number, number, number, number, number,
]
/** Tables keyed by municipal tax rate number (2942). */
export type FallbackTaxYear = Readonly<Record<number, readonly FallbackTaxRow[]>>
export const FALLBACK_TAX_TABLES_${year}: FallbackTaxYear = {
${entries},
}
export const FALLBACK_TAX_TABLES: Readonly<Record<number, FallbackTaxYear>> = {
${year}: FALLBACK_TAX_TABLES_${year},
}
export const FALLBACK_TAX_TABLE_YEARS: ReadonlySet<number> = new Set([${year}])
`
}
function main() {
const { year } = parseArgs()
const inputPath = resolve(process.cwd(), `data/tax-tables/${year}/allmanna-tabeller-manad.txt`)
const outputPath = resolve(process.cwd(), 'lib/salary/tax-tables-fallback.ts')
console.log(`Reading ${inputPath}`)
const tables = parseFile(inputPath)
if (tables.length === 0) {
throw new Error('No B-rows parsed — check input file format')
}
const totalRows = tables.reduce((sum, t) => sum + t.rows.length, 0)
console.log(`Parsed ${tables.length} tables (${tables.map(t => t.tableNumber).join(', ')}), ${totalRows} B-rows total`)
const module = emitModule(year, tables)
writeFileSync(outputPath, module, 'utf-8')
console.log(`Wrote ${outputPath} (${module.length.toLocaleString()} bytes)`)
}
main()
@@ -0,0 +1,24 @@
-- Drop phantom 4-argument overload of commit_journal_entry.
--
-- The canonical definition in 20260402100200_atomic_commit_entry.sql has the
-- signature (p_company_id uuid, p_entry_id uuid). A 4-argument overload
-- (p_company_id uuid, p_entry_id uuid, p_commit_method text, p_rubric_version text)
-- was observed in at least one database but has never existed in source
-- control. It is orphaned — no code calls it and no migration creates it.
--
-- Its presence causes the 2-argument RPC call in lib/bookkeeping/engine.ts
-- (`supabase.rpc('commit_journal_entry', { p_company_id, p_entry_id })`)
-- to fail with:
-- "Could not choose the best candidate function between:
-- public.commit_journal_entry(p_company_id => uuid, p_entry_id => uuid),
-- public.commit_journal_entry(p_company_id => uuid, p_entry_id => uuid,
-- p_commit_method => text, p_rubric_version => text)"
-- because PostgREST's named-argument dispatch is ambiguous when both overloads
-- are reachable.
--
-- This migration drops the orphaned overload. IF EXISTS makes it a no-op on
-- databases that never had the phantom function.
DROP FUNCTION IF EXISTS public.commit_journal_entry(uuid, uuid, text, text);
NOTIFY pgrst, 'reload schema';