Feat/skv integration full (#284)

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

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

* feat: gate salary module behind dev-only flag

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

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

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

* fix: bump migration timestamp to avoid collision with logos_bucket

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

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

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Mattsson
2026-04-20 21:03:23 +02:00
committed by GitHub
co-authored by Claude Opus 4.7
parent 7a0214c053
commit 11621bb79f
23 changed files with 16227 additions and 340 deletions
+8 -22
View File
@@ -37,7 +37,6 @@ import AccountMappingStep from '@/components/import/AccountMappingStep'
import ImportReviewStep, { type ImportExecuteOptions } from '@/components/import/ImportReviewStep'
import ImportResultStep from '@/components/import/ImportResultStep'
import { applyMappingOverride } from '@/lib/import/account-mapper'
import { getCSVHeaders, getCSVPreview } from '@/lib/import/bank-file/formats/generic-csv'
import type { BankFileParseResult, BankFileFormatId, GenericCSVColumnMapping } from '@/lib/import/bank-file/types'
import type { IngestResult } from '@/lib/transactions/ingest'
import type {
@@ -64,7 +63,7 @@ const MigrationWizard = dynamic(
type BankFileStep = 'upload' | 'preview' | 'column_mapping' | 'confirm' | 'result'
const BANK_STEPS: BankFileStep[] = ['upload', 'preview', 'confirm', 'result']
const BANK_STEPS_WITH_MAPPING: BankFileStep[] = ['upload', 'preview', 'column_mapping', 'confirm', 'result']
const BANK_STEPS_WITH_MAPPING: BankFileStep[] = ['upload', 'column_mapping', 'confirm', 'result']
const BANK_STEP_LABELS: Record<BankFileStep, string> = {
upload: 'Ladda upp',
@@ -89,10 +88,6 @@ function BankFileImportWizard() {
const [filename, setFilename] = useState<string>('')
const [rawFileContent, setRawFileContent] = useState<string>('')
// Column mapping for generic CSV
const [csvHeaders, setCsvHeaders] = useState<string[]>([])
const [csvPreview, setCsvPreview] = useState<string[][]>([])
// Import result
const [ingestResult, setIngestResult] = useState<IngestResult | null>(null)
@@ -132,28 +127,22 @@ function BankFileImportWizard() {
setDetectedFormatName(data.data.detected_format_name)
setFileHash(data.data.file_hash)
setFilename(data.data.filename)
// Store headers for generic CSV mapping
if (data.data.headers) {
setCsvHeaders(data.data.headers)
}
// Read raw file content for CSV preview
const text = await file.text()
setRawFileContent(text)
if (data.data.parse_result.format === 'generic_csv') {
setCsvHeaders(getCSVHeaders(text))
setCsvPreview(getCSVPreview(text, ',', 6))
}
const txCount = data.data.parse_result.transactions.length
if (txCount > 0) {
if (data.data.parse_result.format === 'generic_csv') {
// Auto-detect failed or user picked "Annan CSV" — always route to manual column mapping.
// Default mapping rarely matches, so advance regardless of tx count.
setBankStep('column_mapping')
} else if (txCount > 0) {
setBankStep('preview')
toast({
title: 'Fil analyserad',
description: `${txCount} transaktioner hittades`,
})
} else if (data.data.parse_result.format === 'generic_csv' || !data.data.detected_format) {
setBankError('Kunde inte identifiera bankformatet. Välj bank manuellt eller använd "Annan CSV".')
} else {
// Format detected but no transactions parsed — parser couldn't extract rows
setBankError('Filen kunde läsas men inga transaktioner hittades. Kontrollera att filen innehåller transaktionsdata och inte bara rubriker.')
@@ -222,8 +211,6 @@ function BankFileImportWizard() {
setFilename('')
setIngestResult(null)
setBankError(null)
setCsvHeaders([])
setCsvPreview([])
setRawFileContent('')
}
@@ -281,10 +268,9 @@ function BankFileImportWizard() {
{bankStep === 'column_mapping' && (
<BankFileColumnMappingStep
headers={csvHeaders}
previewRows={csvPreview}
rawFileContent={rawFileContent}
onConfirm={handleColumnMappingConfirm}
onBack={() => setBankStep('preview')}
onBack={() => setBankStep('upload')}
/>
)}
+144 -5
View File
@@ -8,7 +8,7 @@ import { Button } from '@/components/ui/button'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import {
ArrowLeft, Plus, Calculator, Eye, Check, CreditCard, BookOpen,
ArrowLeftCircle, Loader2,
ArrowLeftCircle, Loader2, Download, Send, CheckCircle2,
} from 'lucide-react'
import { useToast } from '@/components/ui/use-toast'
import { useCanWrite } from '@/lib/hooks/use-can-write'
@@ -22,6 +22,7 @@ const STATUS_LABELS: Record<string, string> = {
approved: 'Godkänd',
paid: 'Betald',
booked: 'Bokförd',
corrected: 'Korrigerad',
}
const STATUS_COLORS: Record<string, string> = {
@@ -30,6 +31,7 @@ const STATUS_COLORS: Record<string, string> = {
approved: 'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-400',
paid: 'bg-emerald-100 text-emerald-800 dark:bg-emerald-900/30 dark:text-emerald-400',
booked: 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400',
corrected: 'bg-muted text-muted-foreground',
}
interface EntryPreview {
@@ -142,6 +144,73 @@ export default function SalaryRunDetailPage({ params }: { params: Promise<{ id:
setActionLoading(null)
}
async function handleDownloadAgi() {
setActionLoading('agi-download')
const res = await fetch(`/api/salary/runs/${id}/agi/xml`)
if (!res.ok) {
const result = await res.json().catch(() => ({ error: 'Kunde inte generera AGI-fil' }))
toast({
title: 'AGI-fil kunde inte genereras',
description: getErrorMessage(result, { context: 'salary', statusCode: res.status }),
variant: 'destructive',
})
setActionLoading(null)
return
}
const blob = await res.blob()
const url = URL.createObjectURL(blob)
const periodLabel = `${run!.period_year}${String(run!.period_month).padStart(2, '0')}`
const a = document.createElement('a')
a.href = url
a.download = `AGI_${periodLabel}.xml`
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
URL.revokeObjectURL(url)
await loadRun()
toast({ title: 'AGI-fil nedladdad' })
setActionLoading(null)
}
async function handleSubmitAgi() {
setActionLoading('agi-submit')
// Generate AGI XML first if it hasn't been generated yet.
if (!run?.agi_generated_at) {
const xmlRes = await fetch(`/api/salary/runs/${id}/agi/xml`)
if (!xmlRes.ok) {
const result = await xmlRes.json().catch(() => ({ error: 'Kunde inte generera AGI-fil' }))
toast({
title: 'AGI kunde inte genereras',
description: getErrorMessage(result, { context: 'salary', statusCode: xmlRes.status }),
variant: 'destructive',
})
setActionLoading(null)
return
}
}
const res = await fetch(`/api/salary/runs/${id}/agi/submit`, { method: 'POST' })
const payload = await res.json().catch(() => ({}))
if (res.ok) {
await loadRun()
toast({
title: 'AGI skickad till Skatteverket',
description:
(payload?.data?.message as string | undefined) ??
'Signera med BankID hos Skatteverket för att slutföra inlämningen.',
})
} else {
toast({
title: 'Kunde inte skicka till Skatteverket',
description: getErrorMessage(payload, { context: 'salary', statusCode: res.status }),
variant: 'destructive',
})
}
setActionLoading(null)
}
if (loading) {
return (
<div className="space-y-6">
@@ -269,7 +338,7 @@ export default function SalaryRunDetailPage({ params }: { params: Promise<{ id:
</CardHeader>
<CardContent className="space-y-4">
{employees.filter(e => e.calculation_breakdown).map(sre => {
const breakdown = sre.calculation_breakdown as { steps?: Array<{ label: string; formula: string; output: number }> }
const breakdown = sre.calculation_breakdown as { steps?: Array<{ label: string; formula: string; output: number | null }> }
return (
<div key={sre.id} className="space-y-2">
<h4 className="text-sm font-medium">
@@ -279,9 +348,13 @@ export default function SalaryRunDetailPage({ params }: { params: Promise<{ id:
</h4>
<div className="text-xs space-y-1 bg-muted/50 rounded-lg p-3">
{(breakdown?.steps || []).map((step, i) => (
<div key={i} className="flex justify-between">
<span className="text-muted-foreground">{step.label}: <span className="font-mono">{step.formula}</span></span>
<span className="font-medium tabular-nums">{formatCurrency(step.output)}</span>
<div key={i} className="flex justify-between gap-4">
<span className="text-muted-foreground">
{step.label}: <span className="font-mono">{step.formula}</span>
</span>
{step.output !== null && (
<span className="font-medium tabular-nums">{formatCurrency(step.output)}</span>
)}
</div>
))}
</div>
@@ -328,6 +401,72 @@ export default function SalaryRunDetailPage({ params }: { params: Promise<{ id:
</Card>
)}
{/* AGI (Arbetsgivardeklaration) — available once the run is booked */}
{run.status === 'booked' && (
<Card>
<CardHeader>
<CardTitle className="text-base">Arbetsgivardeklaration (AGI)</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-1.5 text-sm">
<div className="flex items-center gap-2">
{run.agi_generated_at ? (
<>
<CheckCircle2 className="h-4 w-4 text-emerald-600" />
<span className="text-muted-foreground">
AGI-fil genererad {new Date(run.agi_generated_at).toLocaleString('sv-SE')}
</span>
</>
) : (
<span className="text-muted-foreground">AGI-fil har inte genererats ännu.</span>
)}
</div>
<div className="flex items-center gap-2">
{run.agi_submitted_at ? (
<>
<CheckCircle2 className="h-4 w-4 text-emerald-600" />
<span className="text-muted-foreground">
Skickad till Skatteverket {new Date(run.agi_submitted_at).toLocaleString('sv-SE')}
</span>
</>
) : (
<span className="text-muted-foreground">
Inte skickad till Skatteverket ännu. Deadline: 12:e i månaden efter utbetalning.
</span>
)}
</div>
</div>
{canWrite && (
<div className="flex flex-wrap gap-3">
<Button
variant="outline"
onClick={handleDownloadAgi}
disabled={!!actionLoading}
>
{actionLoading === 'agi-download' ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Download className="mr-2 h-4 w-4" />
)}
Ladda ner AGI-fil
</Button>
<Button
onClick={handleSubmitAgi}
disabled={!!actionLoading || !!run.agi_submitted_at}
>
{actionLoading === 'agi-submit' ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Send className="mr-2 h-4 w-4" />
)}
Skicka till Skatteverket
</Button>
</div>
)}
</CardContent>
</Card>
)}
{/* Actions */}
{canWrite && (
<div className="flex flex-wrap gap-3 justify-end">
+11 -5
View File
@@ -42,10 +42,16 @@ export async function GET(
const { data: settings } = await supabase
.from('company_settings')
.select('contact_name, contact_phone, contact_email')
.select('org_number, phone, email')
.eq('company_id', companyId)
.single()
const { data: profile } = await supabase
.from('profiles')
.select('full_name, email')
.eq('id', user.id)
.single()
// Load all booked salary run employees for the year, grouped by employee
const { data: runEmployees, error } = await supabase
.from('salary_run_employees')
@@ -114,12 +120,12 @@ export async function GET(
}
const companyData: KU10CompanyData = {
orgNumber: company.org_number || '',
orgNumber: (settings?.org_number || company.org_number || '').trim(),
companyName: company.name,
year: yearNum,
contactName: settings?.contact_name || company.name,
contactPhone: settings?.contact_phone || '',
contactEmail: settings?.contact_email || '',
contactName: (profile?.full_name || company.name || '').trim(),
contactPhone: (settings?.phone || '').trim(),
contactEmail: (settings?.email || profile?.email || user.email || '').trim(),
}
const r = (x: number) => Math.round(x * 100) / 100
+53 -12
View File
@@ -2,7 +2,7 @@ import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { ensureInitialized } from '@/lib/init'
import { requireCompanyId } from '@/lib/company/context'
import { generateAGIXml, buildIndividuppgifterSnapshot } from '@/lib/salary/agi/xml-generator'
import { generateAGIXml, buildIndividuppgifterSnapshot, AGIIncompleteDataError } from '@/lib/salary/agi/xml-generator'
import type { AGIEmployeeData, AGICompanyData, AGITotals } from '@/lib/salary/agi/xml-generator'
import { eventBus } from '@/lib/events'
@@ -40,7 +40,7 @@ export async function GET(
return NextResponse.json({ error: 'Lönekörning hittades inte' }, { status: 404 })
}
if (!['review', 'approved', 'paid', 'booked'].includes(run.status)) {
if (!['review', 'approved', 'paid', 'booked', 'corrected'].includes(run.status)) {
return NextResponse.json({ error: 'AGI kan bara genereras efter granskning' }, { status: 400 })
}
@@ -55,13 +55,22 @@ export async function GET(
return NextResponse.json({ error: 'Företag hittades inte' }, { status: 404 })
}
// Load company settings for contact info
// Load company-level phone/email/org from settings (user-editable under /settings/company).
// Note: the schema has `phone` and `email` — there is no separate `contact_*` column.
const { data: settings } = await supabase
.from('company_settings')
.select('contact_name, contact_phone, contact_email')
.select('org_number, phone, email')
.eq('company_id', companyId)
.single()
// Technical contact name comes from the signed-in user's profile (the person
// generating the file), falling back to the company name.
const { data: profile } = await supabase
.from('profiles')
.select('full_name, email')
.eq('id', user.id)
.single()
// Load employees with their data
const { data: runEmployees } = await supabase
.from('salary_run_employees')
@@ -72,15 +81,17 @@ export async function GET(
return NextResponse.json({ error: 'Inga anställda i lönekörningen' }, { status: 400 })
}
// Build AGI data
// Build AGI data.
// org_number: prefer the user-editable company_settings.org_number, fall
// back to companies.org_number (set during onboarding).
const companyData: AGICompanyData = {
orgNumber: company.org_number || '',
orgNumber: (settings?.org_number || company.org_number || '').trim(),
companyName: company.name,
periodYear: run.period_year,
periodMonth: run.period_month,
contactName: settings?.contact_name || company.name,
contactPhone: settings?.contact_phone || '',
contactEmail: settings?.contact_email || '',
contactName: (profile?.full_name || company.name || '').trim(),
contactPhone: (settings?.phone || '').trim(),
contactEmail: (settings?.email || profile?.email || user.email || '').trim(),
}
const employeeData: AGIEmployeeData[] = runEmployees.map(sre => {
@@ -124,9 +135,29 @@ export async function GET(
;(avgifterByCategory as Record<string, { basis: number; amount: number }>)[category] = cat
}
const totalAvgifterAmount = Object.values(avgifterByCategory).reduce(
(sum, cat) => sum + (cat?.amount ?? 0),
0
)
// FK499 TotalSjuklonekostnad — sum of sjuklön paid (days 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)
+4
View File
@@ -5,6 +5,9 @@ import { requireCompanyId } from '@/lib/company/context'
import { requireWritePermission } from '@/lib/auth/require-write'
import { createSalaryRunEntries } from '@/lib/salary/salary-entries'
import { eventBus } from '@/lib/events'
import { createLogger } from '@/lib/logger'
const log = createLogger('salary-book-route')
ensureInitialized()
@@ -120,6 +123,7 @@ export async function POST(
return NextResponse.json({ data: bookedRun })
} catch (err) {
const message = err instanceof Error ? err.message : 'Bokföring misslyckades'
log.error(`Booking failed for salary run ${id}: ${message}`, err instanceof Error ? err.stack : err)
return NextResponse.json({ error: message }, { status: 500 })
}
}
+38 -7
View File
@@ -5,7 +5,7 @@ import { requireCompanyId } from '@/lib/company/context'
import { requireWritePermission } from '@/lib/auth/require-write'
import { calculateSalary } from '@/lib/salary/calculation-engine'
import { loadPayrollConfig, serializePayrollConfig } from '@/lib/salary/payroll-config'
import { fetchAllTaxTableRatesForRun } from '@/lib/salary/tax-tables'
import { fetchAllTaxTableRatesForRun, TaxTableUnavailableError } from '@/lib/salary/tax-tables'
import type { SalaryLineItemType } from '@/types'
ensureInitialized()
@@ -81,9 +81,24 @@ export async function POST(
// Fetch tax table rates from Skatteverket API for all needed tables/columns
const tableNumbers = [...new Set(runEmployees.filter(e => e.employee?.tax_table_number).map(e => e.employee.tax_table_number as number))]
const columns = [...new Set(runEmployees.filter(e => e.employee?.tax_column).map(e => e.employee.tax_column as number))]
const taxRates = tableNumbers.length > 0
? await fetchAllTaxTableRatesForRun(paymentYear, tableNumbers, columns.length > 0 ? columns : [1])
: []
let taxRates: Awaited<ReturnType<typeof fetchAllTaxTableRatesForRun>>['rates'] = []
let taxTableSource: Awaited<ReturnType<typeof fetchAllTaxTableRatesForRun>>['source'] = 'api'
if (tableNumbers.length > 0) {
try {
const result = await fetchAllTaxTableRatesForRun(
paymentYear,
tableNumbers,
columns.length > 0 ? columns : [1]
)
taxRates = result.rates
taxTableSource = result.source
} catch (err) {
if (err instanceof TaxTableUnavailableError) {
return NextResponse.json({ error: err.message }, { status: 503 })
}
throw err
}
}
let totalGross = 0
let totalTax = 0
@@ -172,8 +187,9 @@ export async function POST(
const parentalDays = sumQuantity(['parental_leave'])
const vacationDays = sumQuantity(['vacation'])
// Update salary_run_employee with calculated results
await supabase
// Update salary_run_employee with calculated results. If any individual
// update fails we abort so run totals aren't written from partial data.
const { error: empUpdateError } = await supabase
.from('salary_run_employees')
.update({
gross_salary: result.grossSalary,
@@ -203,6 +219,10 @@ export async function POST(
})
.eq('id', sre.id)
if (empUpdateError) {
return NextResponse.json({ error: empUpdateError.message }, { status: 500 })
}
totalGross += result.grossSalary
totalTax += result.taxWithheld
totalNet += result.netSalary
@@ -231,5 +251,16 @@ export async function POST(
return NextResponse.json({ error: updateError.message }, { status: 500 })
}
return NextResponse.json({ data: updatedRun })
const warnings: string[] = []
if (taxTableSource === 'fallback') {
warnings.push(
`Skatteverkets skattetabell-API är inte nåbart — beräkningen använder lokal reservdata för ${paymentYear}. Kontrollera att Skatteverket inte publicerat ändringar innan lönekörningen bokförs.`
)
} else if (taxTableSource === 'mixed') {
warnings.push(
`Skatteverkets skattetabell-API svarade bara delvis — vissa skattetabeller kommer från lokal reservdata för ${paymentYear}. Kontrollera att Skatteverket inte publicerat ändringar innan lönekörningen bokförs.`
)
}
return NextResponse.json({ data: updatedRun, warnings })
}
+19 -13
View File
@@ -1,6 +1,6 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { lookupTaxFromApi } from '@/lib/salary/tax-tables'
import { lookupTaxFromApi, TaxTableUnavailableError } from '@/lib/salary/tax-tables'
export async function GET(request: Request) {
const supabase = await createClient()
@@ -17,16 +17,22 @@ 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)
return NextResponse.json({
data: {
year,
tableNumber,
column,
income,
taxAmount,
source: 'skatteverket_api',
},
})
try {
const { taxAmount, source } = await lookupTaxFromApi(tableNumber, column, income, year)
return NextResponse.json({
data: {
year,
tableNumber,
column,
income,
taxAmount,
source: source === 'api' ? 'skatteverket_api' : 'local_fallback',
},
})
} catch (err) {
if (err instanceof TaxTableUnavailableError) {
return NextResponse.json({ error: err.message }, { status: 503 })
}
throw err
}
}