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
+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
}
}