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:
co-authored by
Claude Opus 4.7
parent
7a0214c053
commit
11621bb79f
@@ -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>
|
||||
<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">
|
||||
|
||||
@@ -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,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
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user