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:
@@ -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')}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 2–14) 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)
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
@@ -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&B <Admin>')
|
||||
})
|
||||
})
|
||||
|
||||
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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
@@ -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 2–14 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>')
|
||||
|
||||
@@ -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 2–14',
|
||||
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,
|
||||
})
|
||||
|
||||
@@ -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
@@ -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 }
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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*',
|
||||
],
|
||||
}
|
||||
|
||||
@@ -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 (29–42). */
|
||||
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';
|
||||
Reference in New Issue
Block a user