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
@@ -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