diff --git a/app/api/salary/runs/[id]/agi/xml/route.ts b/app/api/salary/runs/[id]/agi/xml/route.ts index 5d727277..5584feef 100644 --- a/app/api/salary/runs/[id]/agi/xml/route.ts +++ b/app/api/salary/runs/[id]/agi/xml/route.ts @@ -2,320 +2,77 @@ 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, AGIIncompleteDataError } from '@/lib/salary/agi/xml-generator' -import type { AGIEmployeeData, AGICompanyData, AGITotals } from '@/lib/salary/agi/xml-generator' -import { eventBus } from '@/lib/events' +import { generateAgiDeclaration } from '@/lib/salary/agi/generate-declaration' +import { createLogger } from '@/lib/logger' +import { errorResponseFromCode } from '@/lib/errors/get-structured-error' ensureInitialized() /** - * Generate AGI XML for a salary run. + * GET /api/salary/runs/{id}/agi/xml + * + * Thin wrapper over `generateAgiDeclaration()` from + * `lib/salary/agi/generate-declaration.ts`. The orchestration was extracted in + * Phase 5 PR-2 so the v1 public route (`POST /api/v1/.../salary-runs/{id}/generate-agi`) + * can call the same code. This route's responsibility is now: auth → invoke + * helper → return the raw XML as a downloadable file (the dashboard's + * historical contract). * * Per agi-filing.md: - * - FK570 (specifikationsnummer) MUST stay consistent per employee - * - Corrections resubmit with same FK570 — different number = new record - * - XML is räkenskapsinformation, stored for 7-year retention per BFL 7 kap - * - Filing deadline: 12th of following month (17th in Jan/Aug for ≤40 MSEK) + * - FK570 (specifikationsnummer) MUST stay consistent per employee + * - Corrections resubmit with same FK570 — different number = new record + * - XML is räkenskapsinformation, stored for 7-year retention per BFL 7 kap + * - Filing deadline: the 12th of the following month (17th in Jan/Aug for + * companies ≤ 40 MSEK turnover) */ export async function GET( - request: Request, - { params }: { params: Promise<{ id: string }> } + _request: Request, + { params }: { params: Promise<{ id: string }> }, ) { const { id } = await params const supabase = await createClient() - const { data: { user } } = await supabase.auth.getUser() + const { + data: { user }, + } = await supabase.auth.getUser() if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) const companyId = await requireCompanyId(supabase, user.id) + const requestId = `req_${crypto.randomUUID()}` + const log = createLogger('api/salary/agi/xml', { requestId, userId: user.id }) - // Load salary run - const { data: run, error: runError } = await supabase - .from('salary_runs') - .select('*') - .eq('id', id) - .eq('company_id', companyId) - .single() - - if (runError || !run) { - return NextResponse.json({ error: 'Lönekörning hittades inte' }, { status: 404 }) - } - - if (!['review', 'approved', 'paid', 'booked', 'corrected'].includes(run.status)) { - return NextResponse.json({ error: 'AGI kan bara genereras efter granskning' }, { status: 400 }) - } - - // Load company - const { data: company } = await supabase - .from('companies') - .select('name, org_number') - .eq('id', companyId) - .single() - - if (!company) { - return NextResponse.json({ error: 'Företag hittades inte' }, { status: 404 }) - } - - // 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('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. We need monthly_salary on the employee - // record to derive FK499 sjuklönekostnad from per-day records (the line - // item `amount` is the net deduction, not the sjuklön cost). - const { data: runEmployees } = await supabase - .from('salary_run_employees') - .select('*, employee:employees(personnummer, specification_number, f_skatt_status, monthly_salary), line_items:salary_line_items(*)') - .eq('salary_run_id', id) - - if (!runEmployees || runEmployees.length === 0) { - return NextResponse.json({ error: 'Inga anställda i lönekörningen' }, { status: 400 }) - } - - // 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: (settings?.org_number || company.org_number || '').trim(), - companyName: company.name, - periodYear: run.period_year, - periodMonth: run.period_month, - contactName: (profile?.full_name || company.name || '').trim(), - contactPhone: (settings?.phone || '').trim(), - contactEmail: (settings?.email || profile?.email || user.email || '').trim(), - } - - // Load per-day absence records for VAB + parental in the pay period. - // Sick days never reach AGI (they go to Försäkringskassan separately). - // The XML generator's Frånvarouppgift section consumes these per event. - const periodStart = `${run.period_year}-${String(run.period_month).padStart(2, '0')}-01` - const periodEndDate = new Date(Date.UTC(run.period_year, run.period_month, 0)) - const periodEnd = periodEndDate.toISOString().slice(0, 10) - const employeeIds = runEmployees - .map(sre => sre.employee_id as string) - .filter(Boolean) - const absenceByEmployee = new Map>() - if (employeeIds.length > 0) { - const { data: absenceRows } = await supabase - .from('salary_absence_days') - .select('employee_id, absence_date, absence_type, hours') - .eq('company_id', companyId) - .in('absence_type', ['vab', 'parental']) - .gte('absence_date', periodStart) - .lte('absence_date', periodEnd) - .in('employee_id', employeeIds) - for (const row of (absenceRows ?? [])) { - const list = absenceByEmployee.get(row.employee_id) ?? [] - list.push({ - date: row.absence_date as string, - type: row.absence_type as 'vab' | 'parental', - hours: Number(row.hours ?? 8), - }) - absenceByEmployee.set(row.employee_id, list) - } - } - - const employeeData: AGIEmployeeData[] = runEmployees.map(sre => { - const emp = sre.employee as { personnummer: string; specification_number: number; f_skatt_status: string } | null - const lineItems = (sre.line_items || []) as Array> - - // Sum benefits by type for AGI rutor 012-019 - const benefitCar = sumLineItemAmounts(lineItems, ['benefit_car']) - const benefitMeals = sumLineItemAmounts(lineItems, ['benefit_meals']) - const benefitHousing = sumLineItemAmounts(lineItems, ['benefit_housing']) - const benefitOther = sumLineItemAmounts(lineItems, ['benefit_wellness', 'benefit_other']) - - const absenceEvents = absenceByEmployee.get(sre.employee_id as string) - - return { - personnummer: emp?.personnummer || '', - specificationNumber: emp?.specification_number || 0, - grossSalary: sre.gross_salary, - taxWithheld: sre.tax_withheld, - avgifterBasis: sre.avgifter_basis, - fSkattPayment: emp?.f_skatt_status === 'f_skatt' ? sre.gross_salary : undefined, - benefitCar: benefitCar > 0 ? benefitCar : undefined, - benefitHousing: benefitHousing > 0 ? benefitHousing : undefined, - benefitMeals: benefitMeals > 0 ? benefitMeals : undefined, - benefitOther: benefitOther > 0 ? benefitOther : undefined, - sickDays: sre.sick_days > 0 ? sre.sick_days : undefined, - vabDays: sre.vab_days > 0 ? sre.vab_days : undefined, - parentalDays: sre.parental_days > 0 ? sre.parental_days : undefined, - absenceEvents: absenceEvents && absenceEvents.length > 0 ? absenceEvents : undefined, - } + const result = await generateAgiDeclaration({ + supabase, + companyId, + userId: user.id, + userEmail: user.email ?? null, + salaryRunId: id, + log, + requestId, }) - // Build totals with avgifter breakdown by category (read from DB, not re-derived from rate) - const avgifterByCategory: AGITotals['avgifterByCategory'] = {} - for (const sre of runEmployees) { - const dbCategory = sre.avgifter_category as string | null - // Map DB category to AGI HU category; fall back to rate heuristic for legacy runs without stored category - const category = dbCategory - ? (dbCategory === 'reduced_65plus' ? 'reduced65plus' : dbCategory === 'vaxa_stod' ? 'standard' : dbCategory) - : (sre.avgifter_rate <= 0.1022 ? 'reduced65plus' : sre.avgifter_rate <= 0.2082 ? 'youth' : 'standard') - const cat = avgifterByCategory[category as keyof typeof avgifterByCategory] || { basis: 0, amount: 0 } - cat.basis += sre.avgifter_basis - cat.amount += sre.avgifter_amount - ;(avgifterByCategory as Record)[category] = cat + if (!result.ok) { + return errorResponseFromCode(result.code, log, { + requestId, + details: result.details, + status: result.status, + }) } - const totalAvgifterAmount = Object.values(avgifterByCategory).reduce( - (sum, cat) => sum + (cat?.amount ?? 0), - 0 + // OWASP V3.2 / V4 (HTTP response header injection prevention) — sanitise + // header-interpolated values. orgNumber comes from company_settings + // (user-editable) and period_* from the run's own columns, but defense + // in depth requires we strip anything that could be construed as a + // header-injection character before splicing into Content-Disposition. + const safeOrg = result.orgNumber.replace(/[^0-9A-Za-z-]/g, '') + const safePeriod = `${result.periodYear}${String(result.periodMonth).padStart(2, '0')}`.replace( + /[^0-9]/g, + '', ) - // FK499 TotalSjuklonekostnad — sum of *paid* sjuklön (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. - // - // Sjuklön cost = dailyRate × sjuklonRate × day-2-14 count. - // The line item `amount` is the *net deduction* (lostPay − sjuklön), not - // the cost — using its quantity field plus the employee's monthly salary - // gives the correct sjuklön cost regardless of the line-item amount - // convention. - // - // sjuklonRate is read from the run's calculation_params snapshot (taken at - // calc time), so an operator override (e.g. for a CBA-specific rate) is - // honored. Falls back to 0.80 (Sjuklönelagen default) for older runs that - // don't have the snapshot. - const calcParams = (run.calculation_params ?? {}) as { sjuklonRate?: number; sjuklon_rate?: number } - const sjuklonRate = calcParams.sjuklonRate ?? calcParams.sjuklon_rate ?? 0.80 - let totalSjuklonekostnad = 0 - for (const sre of runEmployees) { - const monthly = (sre.employee as { monthly_salary?: number } | null)?.monthly_salary ?? 0 - if (!monthly) continue - const dailyRate = monthly / 21 - const lineItems = (sre.line_items || []) as Array> - for (const li of lineItems) { - if (li.item_type === 'sick_day2_14') { - const days = (li.quantity as number) || 0 - totalSjuklonekostnad += dailyRate * sjuklonRate * days - } - } - } - - 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, - } - - // Check for existing AGI for correction flag - const { data: existingAgi } = await supabase - .from('agi_declarations') - .select('id') - .eq('company_id', companyId) - .eq('period_year', run.period_year) - .eq('period_month', run.period_month) - .single() - - const isCorrection = !!existingAgi - - 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). - // 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') - .update({ - xml_content: xml, - individuppgifter, - total_gross: run.total_gross, - total_tax: run.total_tax, - total_avgifter_basis: totals.totalAvgifterBasis, - total_avgifter: run.total_avgifter, - employee_count: employeeData.length, - is_correction: true, - salary_run_id: run.id, - }) - .eq('id', existingAgi.id) - } else { - await supabase - .from('agi_declarations') - .insert({ - company_id: companyId, - user_id: user.id, - salary_run_id: run.id, - period_year: run.period_year, - period_month: run.period_month, - xml_content: xml, - individuppgifter, - total_gross: run.total_gross, - total_tax: run.total_tax, - total_avgifter_basis: totals.totalAvgifterBasis, - total_avgifter: run.total_avgifter, - employee_count: employeeData.length, - }) - } - - // Update salary run - await supabase - .from('salary_runs') - .update({ agi_generated_at: new Date().toISOString() }) - .eq('id', id) - - await eventBus.emit({ - type: 'agi.generated', - payload: { - agiId: existingAgi?.id || 'new', - periodYear: run.period_year, - periodMonth: run.period_month, - userId: user.id, - companyId, - }, - }) - - // Auto-complete arbetsgivardeklaration deadline for this period - // Per Skatteförfarandelagen: AGI generation satisfies the filing obligation - const period = `${run.period_year}-${String(run.period_month).padStart(2, '0')}` - await supabase - .from('deadlines') - .update({ - status: 'completed', - completed_at: new Date().toISOString(), - completed_by: user.id, - }) - .eq('company_id', companyId) - .eq('type', 'arbetsgivardeklaration') - .eq('period', period) - .eq('status', 'pending') - - // Return as downloadable XML - return new Response(xml, { + return new Response(result.xml, { headers: { 'Content-Type': 'application/xml; charset=utf-8', - 'Content-Disposition': `attachment; filename="AGI_${company.org_number}_${run.period_year}${String(run.period_month).padStart(2, '0')}.xml"`, + 'Content-Disposition': `attachment; filename="AGI_${safeOrg}_${safePeriod}.xml"`, }, }) } - -function sumLineItemAmounts(lineItems: Array>, types: string[]): number { - return lineItems - .filter(li => types.includes(li.item_type as string)) - .reduce((sum, li) => sum + ((li.amount as number) || 0), 0) -} diff --git a/app/api/salary/runs/[id]/calculate/route.ts b/app/api/salary/runs/[id]/calculate/route.ts index 8f47ee26..c0cb6f72 100644 --- a/app/api/salary/runs/[id]/calculate/route.ts +++ b/app/api/salary/runs/[id]/calculate/route.ts @@ -1,557 +1,49 @@ import { NextResponse } from 'next/server' import { ensureInitialized } from '@/lib/init' -import { calculateSalary } from '@/lib/salary/calculation-engine' -import { loadPayrollConfig, serializePayrollConfig } from '@/lib/salary/payroll-config' -import { fetchAllTaxTableRatesForRun, TaxTableUnavailableError } from '@/lib/salary/tax-tables' -import { loadAndDeriveAbsence } from '@/lib/salary/derive-absence-line-items' -import { getLineItemAccount } from '@/lib/salary/account-mapping' +import { runSalaryCalculation } from '@/lib/salary/run-calculation' import { withRouteContext } from '@/lib/api/with-route-context' -import { errorResponse, errorResponseFromCode } from '@/lib/errors/get-structured-error' -import type { SalaryLineItemType } from '@/types' - -const DERIVED_ABSENCE_TYPES: SalaryLineItemType[] = [ - 'sick_karens', - 'sick_day2_14', - 'sick_day15_plus', - 'vab', - 'parental_leave', -] - -const BENEFIT_TYPE_TO_LINE_ITEM: Record = { - bike: 'benefit_bike', - car: 'benefit_car', - meals: 'benefit_meals', - housing: 'benefit_housing', - wellness: 'benefit_wellness', - other: 'benefit_other', -} +import { errorResponseFromCode } from '@/lib/errors/get-structured-error' ensureInitialized() +/** + * POST /api/salary/runs/{id}/calculate + * + * Thin wrapper over `runSalaryCalculation()` from `lib/salary/run-calculation.ts`. + * The orchestration was extracted in Phase 5 PR-2 so the v1 public route + * (`POST /api/v1/companies/{companyId}/salary-runs/{id}/calculate`) can call + * the same code. This route's responsibility is now: auth → invoke helper → + * convert the discriminated result into the dashboard's expected envelope + * (`{ data, warnings? }` on success; structured-error envelope on failure). + * + * Status transitions stay where they were: this route does NOT advance + * `salary_runs.status`. The dashboard's UX is calculate → review (explicit + * `/review` verb) → approve. The v1 collapses calculate+review into a + * single verb but applies the status flip at the route layer, not here. + */ export const POST = withRouteContext( 'salary_run.calculate', async (_request, ctx, { params }: { params: Promise<{ id: string }> }) => { - const { id } = await params - const { user, supabase, companyId, log, requestId } = ctx - const opLog = log.child({ salaryRunId: id }) + const { id } = await params + const { supabase, companyId, log, requestId } = ctx - // Verify run is draft - const { data: run, error: runError } = await supabase - .from('salary_runs') - .select('*') - .eq('id', id) - .eq('company_id', companyId) - .single() - - if (runError || !run) { - return errorResponseFromCode('SALARY_RUN_NOT_FOUND', opLog, { requestId }) - } - if (run.status !== 'draft') { - return errorResponseFromCode('SALARY_RUN_CALCULATE_FAILED', opLog, { - requestId, - details: { currentStatus: run.status, reason: 'not_draft' }, - }) - } - - const paymentYear = parseInt(run.payment_date.split('-')[0]) - - // Load config - const config = await loadPayrollConfig(supabase, paymentYear) - - // Load all employees in this run - const { data: runEmployees, error: empError } = await supabase - .from('salary_run_employees') - .select('*, employee:employees(*), line_items:salary_line_items(*)') - .eq('salary_run_id', id) - - if (empError || !runEmployees || runEmployees.length === 0) { - return errorResponseFromCode('SALARY_RUN_NO_EMPLOYEES', opLog, { requestId }) - } - - // Pre-calculation validation — ensure employees have required data - const validationErrors: string[] = [] - for (const sre of runEmployees) { - const emp = sre.employee - if (!emp) continue - const name = `${emp.first_name} ${emp.last_name}` - - if (emp.salary_type === 'monthly' && (!emp.monthly_salary || emp.monthly_salary <= 0)) { - validationErrors.push(`${name}: Månadslön saknas eller är 0`) - } - if (emp.salary_type === 'hourly' && (!emp.hourly_rate || emp.hourly_rate <= 0)) { - validationErrors.push(`${name}: Timlön saknas eller är 0`) - } - if (emp.f_skatt_status === 'a_skatt' && !emp.is_sidoinkomst && !emp.tax_table_number) { - validationErrors.push(`${name}: Skattetabell saknas (krävs för A-skatt)`) - } - } - if (validationErrors.length > 0) { - return errorResponseFromCode('VALIDATION_ERROR', opLog, { - requestId, - details: { issues: validationErrors, reason: 'employee_data_incomplete' }, - }) - } - - // 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))] - let taxRates: Awaited>['rates'] = [] - let taxTableSource: Awaited>['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 errorResponseFromCode('SALARY_RUN_TAX_TABLE_MISSING', opLog, { - requestId, - details: { reason: err.message, paymentYear, tableNumbers }, - status: 503, - }) - } - throw err - } - } - - let totalGross = 0 - let totalTax = 0 - let totalNet = 0 - let totalAvgifter = 0 - let totalVacationAccrual = 0 - let totalEmployerCost = 0 - - // Load YTD data from prior booked salary runs this year (filters pushed to DB) - const { data: priorRuns } = await supabase - .from('salary_run_employees') - .select('employee_id, gross_salary, tax_withheld, net_salary, salary_run:salary_runs!inner(period_year, period_month, status)') - .eq('company_id', companyId) - .eq('salary_run.period_year', run.period_year) - .eq('salary_run.status', 'booked') - .lt('salary_run.period_month', run.period_month) - - const ytdByEmployee = new Map() - for (const prior of (priorRuns || [])) { - const current = ytdByEmployee.get(prior.employee_id) || { gross: 0, tax: 0, net: 0 } - current.gross += prior.gross_salary - current.tax += prior.tax_withheld - current.net += prior.net_salary - ytdByEmployee.set(prior.employee_id, current) - } - - // Pay period bounds — used to load per-day absence records. - const periodYear = run.period_year as number - const periodMonth = run.period_month as number - const periodStart = `${periodYear}-${String(periodMonth).padStart(2, '0')}-01` - const periodEndDate = new Date(Date.UTC(periodYear, periodMonth, 0)) // last day of month - const periodEnd = periodEndDate.toISOString().slice(0, 10) - - // Track employees who hit Försäkringskassan day-15 transition or läkarintyg - // dag 8 — surfaced as warnings in the response so the UI can flag them. - const lakarintygEmployees: string[] = [] - const fkReportingEmployees: string[] = [] - - for (const sre of runEmployees) { - const emp = sre.employee - if (!emp) continue - - // ── Derive absence line items from per-day records ───────────────── - // Sjuklöneperiod boundaries, återinsjuknande, högriskskydd, day-15 - // FK transition all require dates — we can't compute them from - // aggregated quantities. Replace any existing derived absence rows on - // this sre with the freshly-computed ones, then merge into the - // in-memory lineItems array passed to calculateSalary. - const absenceResult = await loadAndDeriveAbsence({ + const result = await runSalaryCalculation({ supabase, companyId: companyId!, - employeeId: emp.id, - monthlySalary: emp.monthly_salary || 0, - payrollConfig: config, - periodStart, - periodEnd, + salaryRunId: id, + log, + requestId, }) - // ── Derive worked hours from per-day records (hourly employees) ──── - // For timanställda the calendar entries in salary_worked_days are the - // authoritative source. Sum the period and pass to the calculator. If - // no rows exist (legacy run, or hourly employee added without using the - // calendar), fall back to the snapshot column on salary_run_employees. - let derivedHoursWorked: number | null = null - if (emp.salary_type === 'hourly') { - const { data: workedDays, error: workedError } = await supabase - .from('salary_worked_days') - .select('hours') - .eq('company_id', companyId) - .eq('employee_id', emp.id) - .gte('work_date', periodStart) - .lte('work_date', periodEnd) - if (workedError) { - return errorResponse(workedError, opLog, { requestId }) - } - derivedHoursWorked = (workedDays ?? []).reduce( - (sum, d) => Math.round((sum + Number(d.hours)) * 100) / 100, - 0, - ) - opLog.info('Derived hours_worked from calendar', { - employeeId: emp.id, - periodStart, - periodEnd, - rowCount: workedDays?.length ?? 0, - derivedHoursWorked, + if (!result.ok) { + return errorResponseFromCode(result.code, log, { + requestId, + details: result.details, + status: result.status, }) - - // Refresh the hourly_salary line item so the displayed Lönerader table - // matches what the engine actually calculated. Without this, the - // placeholder created at employee-add time keeps showing 0 kr even - // after the user fills the calendar. - if (derivedHoursWorked > 0 && (emp.hourly_rate || 0) > 0) { - const baseAmount = Math.round((emp.hourly_rate as number) * derivedHoursWorked * 100) / 100 - // Delete any existing hourly_salary rows for this sre, then insert a - // single fresh one. Avoids the "did the row already exist?" branch. - await supabase - .from('salary_line_items') - .delete() - .eq('salary_run_employee_id', sre.id) - .eq('item_type', 'hourly_salary') - await supabase.from('salary_line_items').insert({ - salary_run_employee_id: sre.id, - company_id: companyId, - item_type: 'hourly_salary', - description: 'Timlön', - quantity: derivedHoursWorked, - amount: baseAmount, - is_taxable: true, - is_avgift_basis: true, - is_vacation_basis: true, - is_gross_deduction: false, - is_net_deduction: false, - account_number: getLineItemAccount('hourly_salary'), - sort_order: 0, - }) - } } - const employeeName = `${emp.first_name} ${emp.last_name}` - if (absenceResult.flagLakarintyg) lakarintygEmployees.push(employeeName) - if (absenceResult.flagFkReporting) fkReportingEmployees.push(employeeName) - - const { error: delAbsErr } = await supabase - .from('salary_line_items') - .delete() - .eq('salary_run_employee_id', sre.id) - .in('item_type', DERIVED_ABSENCE_TYPES) - if (delAbsErr) { - return errorResponse(delAbsErr, opLog, { requestId }) - } - - // ── Derive benefit line items from employee_benefits ── - // Active benefits = is_active AND payment_date ∈ [valid_from, valid_to] - const { data: activeBenefits, error: benefitsErr } = await supabase - .from('employee_benefits') - .select('id, benefit_type, description, monthly_value') - .eq('employee_id', emp.id) - .eq('company_id', companyId) - .eq('is_active', true) - .lte('valid_from', run.payment_date) - .or(`valid_to.is.null,valid_to.gte.${run.payment_date}`) - if (benefitsErr) { - return errorResponse(benefitsErr, opLog, { requestId }) - } - - // Replace prior auto-generated benefit rows for this sre. - const { error: delBenefitErr } = await supabase - .from('salary_line_items') - .delete() - .eq('salary_run_employee_id', sre.id) - .not('source_benefit_id', 'is', null) - if (delBenefitErr) { - return errorResponse(delBenefitErr, opLog, { requestId }) - } - - const derivedBenefitRows = (activeBenefits ?? []) - .filter(b => b.monthly_value > 0) - .map((b, idx) => { - const itemType = BENEFIT_TYPE_TO_LINE_ITEM[b.benefit_type] ?? 'benefit_other' - return { - salary_run_employee_id: sre.id, - company_id: companyId, - item_type: itemType, - description: b.description, - quantity: 1, - amount: Math.round(b.monthly_value * 100) / 100, - is_taxable: true, - is_avgift_basis: true, - is_vacation_basis: false, - is_gross_deduction: false, - is_net_deduction: false, - account_number: getLineItemAccount(itemType, emp.employment_type), - sort_order: 200 + idx, - source_benefit_id: b.id, - } - }) - - if (derivedBenefitRows.length > 0) { - const { error: insBenefitErr } = await supabase - .from('salary_line_items') - .insert(derivedBenefitRows) - if (insBenefitErr) { - return errorResponse(insBenefitErr, opLog, { requestId }) - } - } - - if (absenceResult.lineItems.length > 0) { - const rows = absenceResult.lineItems.map((li, idx) => ({ - salary_run_employee_id: sre.id, - company_id: companyId, - item_type: li.item_type, - description: li.description, - quantity: li.quantity, - amount: Math.round(li.amount * 100) / 100, - is_taxable: li.is_taxable, - is_avgift_basis: li.is_avgift_basis, - is_vacation_basis: li.is_vacation_basis, - is_gross_deduction: li.is_gross_deduction, - is_net_deduction: false, - account_number: getLineItemAccount(li.item_type), - sort_order: 100 + idx, // sort derived items after manual ones - })) - const { error: insAbsErr } = await supabase - .from('salary_line_items') - .insert(rows) - if (insAbsErr) { - return errorResponse(insAbsErr, opLog, { requestId }) - } - } - - // Build the merged in-memory line items: keep non-derived items from - // the originally-loaded sre.line_items, then append the freshly-derived - // absence and benefit items. - const manualLineItems = (sre.line_items || []) - .filter((li: Record) => { - if (DERIVED_ABSENCE_TYPES.includes(li.item_type as SalaryLineItemType)) return false - if (li.source_benefit_id) return false - // Semesterersättning is derived by the engine on every calculate. - if (li.item_type === 'semesterersattning') return false - return true - }) - .map((li: Record) => ({ - itemType: li.item_type as SalaryLineItemType, - amount: li.amount as number, - isTaxable: li.is_taxable as boolean, - isAvgiftBasis: li.is_avgift_basis as boolean, - isVacationBasis: li.is_vacation_basis as boolean, - isGrossDeduction: li.is_gross_deduction as boolean, - isNetDeduction: li.is_net_deduction as boolean, - })) - const derivedLineItems = absenceResult.lineItems.map(li => ({ - itemType: li.item_type as SalaryLineItemType, - amount: li.amount, - isTaxable: li.is_taxable, - isAvgiftBasis: li.is_avgift_basis, - isVacationBasis: li.is_vacation_basis, - isGrossDeduction: li.is_gross_deduction, - isNetDeduction: false, - })) - const derivedBenefitLineItems = derivedBenefitRows.map(row => ({ - itemType: row.item_type as SalaryLineItemType, - amount: row.amount, - isTaxable: true, - isAvgiftBasis: true, - isVacationBasis: false, - isGrossDeduction: false, - isNetDeduction: false, - })) - const lineItems = [...manualLineItems, ...derivedLineItems, ...derivedBenefitLineItems] - - const result = calculateSalary( - { - employmentType: emp.employment_type, - salaryType: emp.salary_type, - monthlySalary: emp.monthly_salary || 0, - hourlyRate: emp.hourly_rate || undefined, - // Calendar-derived hours win when at least one row exists in the - // period. The legacy snapshot column (sre.hours_worked) only kicks - // in for runs predating the calendar feature. - hoursWorked: derivedHoursWorked !== null && derivedHoursWorked > 0 - ? derivedHoursWorked - : sre.hours_worked || undefined, - employmentDegree: emp.employment_degree, - taxTableNumber: emp.tax_table_number, - taxColumn: emp.tax_column || 1, - isSidoinkomst: emp.is_sidoinkomst, - jamkningPercentage: emp.jamkning_percentage, - jamkningValidFrom: emp.jamkning_valid_from, - jamkningValidTo: emp.jamkning_valid_to, - fSkattStatus: emp.f_skatt_status, - personnummer: emp.personnummer, - paymentDate: run.payment_date, - vacationRule: emp.vacation_rule, - vacationDaysPerYear: emp.vacation_days_per_year, - semestertillaggRate: emp.semestertillagg_rate, - vaxaStodEligible: emp.vaxa_stod_eligible, - vaxaStodStart: emp.vaxa_stod_start, - vaxaStodEnd: emp.vaxa_stod_end, - lineItems, - }, - config, - taxRates.map(r => ({ - tableYear: r.tableYear, - tableNumber: r.tableNumber, - columnNumber: r.columnNumber, - incomeFrom: r.incomeFrom, - incomeTo: r.incomeTo, - taxAmount: r.taxAmount, - })) - ) - - // Aggregated absence counts derived from per-day records (above). - // Vacation still comes from line items because it's user-entered, not - // calendar-tracked yet. - const sickDays = absenceResult.aggregated.sickDays - const vabDays = absenceResult.aggregated.vabDays - const parentalDays = absenceResult.aggregated.parentalDays - const vacationDays = (sre.line_items || []) - .filter((li: Record) => li.item_type === 'vacation') - .reduce((sum: number, li: Record) => sum + ((li.quantity as number) || 0), 0) - - // Update salary_run_employee with calculated results. If any individual - // update fails we abort so run totals aren't written from partial data. - // For hourly employees with calendar rows, also mirror the derived total - // into hours_worked so downstream code (reports, storno via correct/route) - // sees a consistent snapshot. - const snapshotHoursWorked = - derivedHoursWorked !== null && derivedHoursWorked > 0 - ? derivedHoursWorked - : sre.hours_worked - const { error: empUpdateError } = await supabase - .from('salary_run_employees') - .update({ - hours_worked: snapshotHoursWorked, - gross_salary: result.grossSalary, - gross_deductions: result.grossDeductions, - benefit_values: result.benefitValues, - taxable_income: result.taxableIncome, - tax_withheld: result.taxWithheld, - net_deductions: result.netDeductions, - net_salary: result.netSalary, - avgifter_rate: result.avgifterRate, - avgifter_amount: result.avgifterAmount, - avgifter_basis: result.avgifterBasis, - avgifter_category: result.avgifterCategory, - vacation_accrual: result.vacationAccrual, - vacation_accrual_avgifter: result.vacationAccrualAvgifter, - tax_table_number: emp.tax_table_number, - tax_column: emp.tax_column, - tax_table_year: paymentYear, - sick_days: sickDays, - vab_days: vabDays, - parental_days: parentalDays, - vacation_days_taken: vacationDays, - calculation_breakdown: { steps: result.steps }, - ytd_gross: Math.round(((ytdByEmployee.get(sre.employee_id)?.gross || 0) + result.grossSalary) * 100) / 100, - ytd_tax: Math.round(((ytdByEmployee.get(sre.employee_id)?.tax || 0) + result.taxWithheld) * 100) / 100, - ytd_net: Math.round(((ytdByEmployee.get(sre.employee_id)?.net || 0) + result.netSalary) * 100) / 100, - }) - .eq('id', sre.id) - - if (empUpdateError) { - return errorResponse(empUpdateError, opLog, { requestId }) - } - - // Persist a derived 'semesterersattning' line item so it appears in the - // lönerader UI, on the payslip, and books to BAS 7285 via the line-item - // flow in salary-entries.ts. Replace any prior derived row first. - const { error: delSemErr } = await supabase - .from('salary_line_items') - .delete() - .eq('salary_run_employee_id', sre.id) - .eq('item_type', 'semesterersattning') - if (delSemErr) { - return errorResponse(delSemErr, opLog, { requestId }) - } - if (result.vacationCompensation > 0) { - const { error: insSemErr } = await supabase.from('salary_line_items').insert({ - salary_run_employee_id: sre.id, - company_id: companyId, - item_type: 'semesterersattning', - description: 'Semesterersättning', - quantity: 1, - amount: Math.round(result.vacationCompensation * 100) / 100, - is_taxable: true, - is_avgift_basis: true, - // Not vacation basis itself — would create circular accrual. - is_vacation_basis: false, - is_gross_deduction: false, - is_net_deduction: false, - account_number: getLineItemAccount('semesterersattning', emp.employment_type), - sort_order: 50, - }) - if (insSemErr) { - return errorResponse(insSemErr, opLog, { requestId }) - } - } - - totalGross += result.grossSalary - totalTax += result.taxWithheld - totalNet += result.netSalary - totalAvgifter += result.avgifterAmount - totalVacationAccrual += result.vacationAccrual - totalEmployerCost += result.totalEmployerCost - } - - // Update run totals - const { data: updatedRun, error: updateError } = await supabase - .from('salary_runs') - .update({ - total_gross: Math.round(totalGross * 100) / 100, - total_tax: Math.round(totalTax * 100) / 100, - total_net: Math.round(totalNet * 100) / 100, - total_avgifter: Math.round(totalAvgifter * 100) / 100, - total_vacation_accrual: Math.round(totalVacationAccrual * 100) / 100, - total_employer_cost: Math.round(totalEmployerCost * 100) / 100, - calculation_params: serializePayrollConfig(config), - }) - .eq('id', id) - .select() - .single() - - if (updateError) { - return errorResponse(updateError, opLog, { requestId }) - } - - 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.` - ) - } - - if (lakarintygEmployees.length > 0) { - // Per Sjuklönelagen 8§: from day 8 of a sjuklöneperiod the employer can - // require a läkarintyg. Day 1–7 use sjukförsäkran (employee declaration). - warnings.push( - `Läkarintyg krävs från och med dag 8: ${lakarintygEmployees.join(', ')}. ` + - `Kontrollera att läkarintyg finns innan lönekörningen godkänns.` - ) - } - - if (fkReportingEmployees.length > 0) { - // Day 15+ falls on Försäkringskassan; the employer reports via FK. - warnings.push( - `Försäkringskassan tar över sjuklön från dag 15: ${fkReportingEmployees.join(', ')}. ` + - `Säkerställ att anmälan till FK är gjord.` - ) - } - - return NextResponse.json({ data: updatedRun, warnings }) + return NextResponse.json({ data: result.run, warnings: result.warnings }) }, { requireWrite: true }, ) diff --git a/app/api/v1/companies/[companyId]/employees/[id]/route.ts b/app/api/v1/companies/[companyId]/employees/[id]/route.ts index e14ea369..9d76eab3 100644 --- a/app/api/v1/companies/[companyId]/employees/[id]/route.ts +++ b/app/api/v1/companies/[companyId]/employees/[id]/route.ts @@ -309,6 +309,30 @@ export const PATCH = withApiV1<{ params: Promise<{ companyId: string; id: string } } + // Merged-state Växa-stöd check. UpdateEmployeeSchema enforces consistency + // when both fields are present in the body, but a caller flipping + // `vaxa_stod_eligible: true` ALONE without supplying `vaxa_stod_start` + // can bypass schema-level validation if the existing row has no start. + // The schema cannot see the existing row; the route can. + const mergedVaxaEligible = + 'vaxa_stod_eligible' in updates + ? (updates.vaxa_stod_eligible as boolean) + : ((existing as Record).vaxa_stod_eligible as boolean) + const mergedVaxaStart = + 'vaxa_stod_start' in updates + ? (updates.vaxa_stod_start as string | null) + : ((existing as Record).vaxa_stod_start as string | null) + if (mergedVaxaEligible && !mergedVaxaStart) { + return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, { + requestId: ctx.requestId, + details: { + field: 'vaxa_stod_start', + message: + 'Startdatum för Växa-stöd måste anges när Växa-stöd är aktiverat. Skicka även `vaxa_stod_start` i samma PATCH.', + }, + }) + } + if (Object.keys(updates).length === 0) { // GDPR Art.5(1)(c): no-change PATCH still returns a write-shape, so // mask personnummer just like the POST + PATCH success path. diff --git a/app/api/v1/companies/[companyId]/salary-runs/[id]/__tests__/lifecycle.test.ts b/app/api/v1/companies/[companyId]/salary-runs/[id]/__tests__/lifecycle.test.ts new file mode 100644 index 00000000..5d85fff6 --- /dev/null +++ b/app/api/v1/companies/[companyId]/salary-runs/[id]/__tests__/lifecycle.test.ts @@ -0,0 +1,658 @@ +/** + * Integration tests for the v1 salary-run lifecycle verbs (Phase 5 PR-2). + * + * Covers :calculate, :approve, :mark-paid, :book, :generate-agi. Each suite + * focuses on the verb's contract — auth/scope, state-machine enforcement, + * strict-mode (engine throws abort before state flip), period-lock pre- + * check, audit block on :book, AGI gate, etc. The underlying lib helpers + * (`runSalaryCalculation`, `createSalaryRunEntries`, `generateAgiDeclaration`) + * are stubbed via vi.mock so we exercise the route logic, not the engine. + */ + +import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' + +beforeAll(() => { + if (process.env.NODE_ENV !== 'test') { + throw new Error( + `salary-run lifecycle tests require NODE_ENV=test (got ${process.env.NODE_ENV ?? 'undefined'})`, + ) + } + process.env.NEXT_PUBLIC_SUPABASE_URL ||= 'http://localhost:54321' + process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY ||= 'test-anon-key' +}) + +vi.mock('@/lib/auth/api-keys', async () => { + const actual = await vi.importActual('@/lib/auth/api-keys') + return { + ...actual, + validateApiKey: vi.fn(), + createServiceClientNoCookies: vi.fn(), + } +}) + +vi.mock('@supabase/supabase-js', async () => { + const actual = await vi.importActual('@supabase/supabase-js') + return { ...actual, createClient: vi.fn().mockReturnValue({}) } +}) + +// The lifecycle verbs delegate to lib helpers; stub those so the tests +// exercise route logic, not engine behavior. +const mocks = vi.hoisted(() => ({ + runSalaryCalculation: vi.fn(), + createSalaryRunEntries: vi.fn(), + checkPeriodLock: vi.fn(), + generateAgiDeclaration: vi.fn(), +})) + +vi.mock('@/lib/salary/run-calculation', () => ({ + runSalaryCalculation: mocks.runSalaryCalculation, +})) + +vi.mock('@/lib/salary/salary-entries', () => ({ + createSalaryRunEntries: mocks.createSalaryRunEntries, +})) + +vi.mock('@/lib/api/v1/check-period-lock', () => ({ + checkPeriodLock: mocks.checkPeriodLock, +})) + +vi.mock('@/lib/salary/agi/generate-declaration', () => ({ + generateAgiDeclaration: mocks.generateAgiDeclaration, +})) + +import { validateApiKey, createServiceClientNoCookies } from '@/lib/auth/api-keys' +import { POST as calculate } from '../calculate/route' +import { POST as approve } from '../approve/route' +import { POST as markPaid } from '../mark-paid/route' +import { POST as book } from '../book/route' +import { POST as generateAgi } from '../generate-agi/route' + +const mockValidate = validateApiKey as ReturnType +const mockServiceClient = createServiceClientNoCookies as ReturnType + +interface TableResp { + data?: unknown + error?: unknown + count?: number | null +} + +function makeFlexibleSupabase(byTable: Record) { + const queues = new Map() + for (const [t, val] of Object.entries(byTable)) { + queues.set(t, Array.isArray(val) ? [...val] : [val]) + } + const buildChain = (table: string): unknown => { + const handler: ProxyHandler = { + get(_target, prop) { + if (prop === 'then') { + return (resolve: (v: unknown) => void) => { + const q = queues.get(table) + const next = q && q.length > 1 ? q.shift()! : (q?.[0] ?? { data: null, error: null }) + resolve(next) + } + } + return (..._args: unknown[]) => buildChain(table) + }, + } + return new Proxy({}, handler) + } + // The generate-agi route also hits supabase.auth.admin.getUserById; stub + // that so the helper-mock path doesn't trip on auth. + return { + from: vi.fn((table: string) => buildChain(table)), + auth: { + admin: { + getUserById: vi.fn().mockResolvedValue({ data: { user: { email: 'caller@test' } } }), + }, + }, + } +} + +const COMPANY_ID = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa' +const RUN_ID = 'cccccccc-cccc-4ccc-8ccc-cccccccccccc' +const USER_ID = 'user-1' + +function makeRequest(url: string, init?: RequestInit): Request { + return new Request(url, { + ...init, + headers: { + Authorization: 'Bearer test-fixture-not-a-real-key', + 'Idempotency-Key': 'b1aaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa', + ...(init?.headers ?? {}), + }, + }) +} + +function detailParams(companyId: string, id: string) { + return { params: Promise.resolve({ companyId, id }) } +} + +beforeEach(() => { + vi.clearAllMocks() + mockValidate.mockResolvedValue({ + userId: USER_ID, + companyId: COMPANY_ID, + apiKeyId: 'ak_1', + apiKeyName: 'CI key', + scopes: ['payroll:read', 'payroll:write'], + mode: 'live', + }) +}) + +// ──────────────────────────────────────────────────────────────────── +// :calculate +// ──────────────────────────────────────────────────────────────────── + +describe('POST /salary-runs/:id/calculate', () => { + it('runs the helper and advances draft → review on success', async () => { + const draftRun = { id: RUN_ID, status: 'draft', period_year: 2026, period_month: 5, payment_date: '2026-05-25' } + const advancedRun = { + id: RUN_ID, status: 'review', + period_year: 2026, period_month: 5, + total_gross: 105000, total_tax: 28500, total_net: 76500, + total_avgifter: 32991, total_employer_cost: 137991, + } + mockServiceClient.mockReturnValue( + makeFlexibleSupabase({ + company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null }, + salary_runs: [ + { data: draftRun, error: null }, // pre-flight read + { data: advancedRun, error: null }, // status flip + ], + salary_run_employees: { data: [], error: null }, // for F-skatt scan + idempotency_keys: { data: null, error: null }, + }), + ) + mocks.runSalaryCalculation.mockResolvedValue({ + ok: true, + run: { id: RUN_ID, status: 'draft' }, + warnings: [], + }) + + const res = await calculate( + makeRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/salary-runs/${RUN_ID}/calculate`, { + method: 'POST', + }), + detailParams(COMPANY_ID, RUN_ID), + ) + + expect(res.status).toBe(200) + const body = await res.json() + expect(body.data.status).toBe('review') + expect(mocks.runSalaryCalculation).toHaveBeenCalledOnce() + }) + + it('refuses to calculate a non-draft run (state-machine enforcement)', async () => { + mockServiceClient.mockReturnValue( + makeFlexibleSupabase({ + company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null }, + salary_runs: { data: { id: RUN_ID, status: 'review' }, error: null }, + idempotency_keys: { data: null, error: null }, + }), + ) + + const res = await calculate( + makeRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/salary-runs/${RUN_ID}/calculate`, { + method: 'POST', + }), + detailParams(COMPANY_ID, RUN_ID), + ) + + expect(res.status).toBe(400) + const body = await res.json() + expect(body.error.code).toBe('SALARY_RUN_CALCULATE_NOT_DRAFT') + expect(body.error.details.current_status).toBe('review') + expect(mocks.runSalaryCalculation).not.toHaveBeenCalled() + }) + + it('strict-mode: helper failure aborts before the status flip', async () => { + const draftRun = { id: RUN_ID, status: 'draft', period_year: 2026, period_month: 5, payment_date: '2026-05-25' } + mockServiceClient.mockReturnValue( + makeFlexibleSupabase({ + company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null }, + salary_runs: { data: draftRun, error: null }, + idempotency_keys: { data: null, error: null }, + }), + ) + mocks.runSalaryCalculation.mockResolvedValue({ + ok: false, + code: 'SALARY_RUN_TAX_TABLE_MISSING', + details: { reason: 'Skatteverket API unreachable' }, + status: 503, + }) + + const res = await calculate( + makeRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/salary-runs/${RUN_ID}/calculate`, { + method: 'POST', + }), + detailParams(COMPANY_ID, RUN_ID), + ) + + expect(res.status).toBe(503) + const body = await res.json() + expect(body.error.code).toBe('SALARY_RUN_TAX_TABLE_MISSING') + }) + + it('returns a dry-run preview without invoking the helper', async () => { + mockServiceClient.mockReturnValue( + makeFlexibleSupabase({ + company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null }, + salary_runs: { data: { id: RUN_ID, status: 'draft' }, error: null }, + idempotency_keys: { data: null, error: null }, + }), + ) + + const res = await calculate( + makeRequest( + `https://x.test/api/v1/companies/${COMPANY_ID}/salary-runs/${RUN_ID}/calculate?dry_run=true`, + { method: 'POST' }, + ), + detailParams(COMPANY_ID, RUN_ID), + ) + + expect(res.status).toBe(200) + expect(res.headers.get('X-Dry-Run')).toBe('true') + expect(mocks.runSalaryCalculation).not.toHaveBeenCalled() + const body = await res.json() + expect(body.data.preview.would_advance_status_to).toBe('review') + }) +}) + +// ──────────────────────────────────────────────────────────────────── +// :approve +// ──────────────────────────────────────────────────────────────────── + +describe('POST /salary-runs/:id/approve', () => { + it('approves a run with valid bank details + calculation_breakdown', async () => { + const validEmployee = { + calculation_breakdown: { steps: [] }, + employee: { + first_name: 'Anna', + last_name: 'Andersson', + clearing_number: '6000', + bank_account_number: '12345678', + email: 'anna@test', + }, + } + mockServiceClient.mockReturnValue( + makeFlexibleSupabase({ + company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null }, + salary_runs: [ + { data: { id: RUN_ID, status: 'review' }, error: null }, + { data: { id: RUN_ID, status: 'approved', approved_at: '2026-05-14T12:00:00Z', approved_by: USER_ID }, error: null }, + ], + salary_run_employees: { data: [validEmployee], error: null }, + idempotency_keys: { data: null, error: null }, + }), + ) + + const res = await approve( + makeRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/salary-runs/${RUN_ID}/approve`, { + method: 'POST', + }), + detailParams(COMPANY_ID, RUN_ID), + ) + + expect(res.status).toBe(200) + const body = await res.json() + expect(body.data.status).toBe('approved') + }) + + it('returns SALARY_RUN_APPROVE_VALIDATION_FAILED for missing bank details', async () => { + const noBankEmployee = { + calculation_breakdown: { steps: [] }, + employee: { + first_name: 'Bo', + last_name: 'Berg', + clearing_number: null, + bank_account_number: null, + email: 'bo@test', + }, + } + mockServiceClient.mockReturnValue( + makeFlexibleSupabase({ + company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null }, + salary_runs: { data: { id: RUN_ID, status: 'review' }, error: null }, + salary_run_employees: { data: [noBankEmployee], error: null }, + idempotency_keys: { data: null, error: null }, + }), + ) + + const res = await approve( + makeRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/salary-runs/${RUN_ID}/approve`, { + method: 'POST', + }), + detailParams(COMPANY_ID, RUN_ID), + ) + + expect(res.status).toBe(400) + const body = await res.json() + expect(body.error.code).toBe('SALARY_RUN_APPROVE_VALIDATION_FAILED') + expect(body.error.details.issues.length).toBeGreaterThan(0) + }) + + it('refuses to approve a non-review run', async () => { + mockServiceClient.mockReturnValue( + makeFlexibleSupabase({ + company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null }, + salary_runs: { data: { id: RUN_ID, status: 'draft' }, error: null }, + idempotency_keys: { data: null, error: null }, + }), + ) + + const res = await approve( + makeRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/salary-runs/${RUN_ID}/approve`, { + method: 'POST', + }), + detailParams(COMPANY_ID, RUN_ID), + ) + + expect(res.status).toBe(400) + const body = await res.json() + expect(body.error.code).toBe('SALARY_RUN_APPROVE_NOT_REVIEW') + }) +}) + +// ──────────────────────────────────────────────────────────────────── +// :mark-paid +// ──────────────────────────────────────────────────────────────────── + +describe('POST /salary-runs/:id/mark-paid', () => { + it('advances approved → paid', async () => { + mockServiceClient.mockReturnValue( + makeFlexibleSupabase({ + company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null }, + salary_runs: [ + { data: { id: RUN_ID, status: 'approved' }, error: null }, + { data: { id: RUN_ID, status: 'paid', paid_at: '2026-05-25T08:00:00Z' }, error: null }, + ], + idempotency_keys: { data: null, error: null }, + }), + ) + + const res = await markPaid( + makeRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/salary-runs/${RUN_ID}/mark-paid`, { + method: 'POST', + }), + detailParams(COMPANY_ID, RUN_ID), + ) + + expect(res.status).toBe(200) + const body = await res.json() + expect(body.data.status).toBe('paid') + }) + + it('refuses to mark a non-approved run as paid', async () => { + mockServiceClient.mockReturnValue( + makeFlexibleSupabase({ + company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null }, + salary_runs: { data: { id: RUN_ID, status: 'review' }, error: null }, + idempotency_keys: { data: null, error: null }, + }), + ) + + const res = await markPaid( + makeRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/salary-runs/${RUN_ID}/mark-paid`, { + method: 'POST', + }), + detailParams(COMPANY_ID, RUN_ID), + ) + + expect(res.status).toBe(400) + const body = await res.json() + expect(body.error.code).toBe('SALARY_RUN_MARK_PAID_NOT_APPROVED') + }) +}) + +// ──────────────────────────────────────────────────────────────────── +// :book (engine-touching, period-lock) +// ──────────────────────────────────────────────────────────────────── + +describe('POST /salary-runs/:id/book', () => { + const paidRun = { + id: RUN_ID, + status: 'paid', + period_year: 2026, + period_month: 5, + payment_date: '2026-05-25', + voucher_series: 'L', + total_gross: 35000, + total_tax: 9500, + total_net: 25500, + total_avgifter: 10997, + total_vacation_accrual: 0, + } + + const employeeRow = { + employee_id: 'emp_1', + employee: { employment_type: 'employee' }, + gross_salary: 35000, + tax_withheld: 9500, + net_salary: 25500, + avgifter_amount: 10997, + avgifter_rate: 0.3142, + vacation_accrual: 0, + vacation_accrual_avgifter: 0, + line_items: [], + } + + it('books a paid run and surfaces the audit block', async () => { + mockServiceClient.mockReturnValue( + makeFlexibleSupabase({ + company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null }, + salary_runs: [ + { data: paidRun, error: null }, // status precheck + { + data: { + id: RUN_ID, status: 'booked', + booked_at: '2026-05-26T09:15:00Z', booked_by: USER_ID, + salary_entry_id: 'je_salary', avgifter_entry_id: 'je_avg', + vacation_entry_id: null, pension_entry_id: null, + }, + error: null, + }, // status flip + ], + salary_run_employees: { data: [employeeRow], error: null }, + idempotency_keys: { data: null, error: null }, + }), + ) + mocks.checkPeriodLock.mockResolvedValue({ locked: false }) + mocks.createSalaryRunEntries.mockResolvedValue({ + salaryEntry: { id: 'je_salary', voucher_number: 'L2026-0023' }, + avgifterEntry: { id: 'je_avg' }, + vacationEntry: null, + pensionEntry: null, + }) + + const res = await book( + makeRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/salary-runs/${RUN_ID}/book`, { + method: 'POST', + }), + detailParams(COMPANY_ID, RUN_ID), + ) + + expect(res.status).toBe(200) + const body = await res.json() + expect(body.data.status).toBe('booked') + expect(body.data.salary_entry_id).toBe('je_salary') + expect(body.data.entry_ids).toEqual(['je_salary', 'je_avg']) + expect(body.meta.audit.voucher_number).toBe('L2026-0023') + expect(body.meta.audit.voucher_url).toContain('je_salary') + }) + + it('returns PERIOD_LOCKED before invoking the engine when payment_date is locked', async () => { + mockServiceClient.mockReturnValue( + makeFlexibleSupabase({ + company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null }, + salary_runs: { data: paidRun, error: null }, + idempotency_keys: { data: null, error: null }, + }), + ) + mocks.checkPeriodLock.mockResolvedValue({ + locked: true, + reason: 'company_lock_date_covers', + }) + + const res = await book( + makeRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/salary-runs/${RUN_ID}/book`, { + method: 'POST', + }), + detailParams(COMPANY_ID, RUN_ID), + ) + + expect(res.status).toBe(400) + const body = await res.json() + expect(body.error.code).toBe('PERIOD_LOCKED') + expect(body.error.details.reason).toBe('company_lock_date_covers') + expect(mocks.createSalaryRunEntries).not.toHaveBeenCalled() + }) + + it('strict-mode: engine throw aborts before any state mutation', async () => { + mockServiceClient.mockReturnValue( + makeFlexibleSupabase({ + company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null }, + salary_runs: { data: paidRun, error: null }, + salary_run_employees: { data: [employeeRow], error: null }, + idempotency_keys: { data: null, error: null }, + }), + ) + mocks.checkPeriodLock.mockResolvedValue({ locked: false }) + mocks.createSalaryRunEntries.mockRejectedValue(new Error('Insufficient BAS account')) + + const res = await book( + makeRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/salary-runs/${RUN_ID}/book`, { + method: 'POST', + }), + detailParams(COMPANY_ID, RUN_ID), + ) + + expect(res.status).toBe(500) + const body = await res.json() + expect(body.error.code).toBe('SALARY_RUN_BOOK_FAILED') + }) + + it('refuses to book a non-paid run', async () => { + mockServiceClient.mockReturnValue( + makeFlexibleSupabase({ + company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null }, + salary_runs: { data: { ...paidRun, status: 'approved' }, error: null }, + idempotency_keys: { data: null, error: null }, + }), + ) + + const res = await book( + makeRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/salary-runs/${RUN_ID}/book`, { + method: 'POST', + }), + detailParams(COMPANY_ID, RUN_ID), + ) + + expect(res.status).toBe(400) + const body = await res.json() + expect(body.error.code).toBe('SALARY_RUN_BOOK_NOT_PAID') + expect(mocks.checkPeriodLock).not.toHaveBeenCalled() + }) +}) + +// ──────────────────────────────────────────────────────────────────── +// :generate-agi +// ──────────────────────────────────────────────────────────────────── + +describe('POST /salary-runs/:id/generate-agi', () => { + it('returns the XML embedded in the v1 envelope', async () => { + mockServiceClient.mockReturnValue( + makeFlexibleSupabase({ + company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null }, + idempotency_keys: { data: null, error: null }, + }), + ) + mocks.generateAgiDeclaration.mockResolvedValue({ + ok: true, + xml: '', + agiDeclarationId: 'agi_a8f1', + periodYear: 2026, + periodMonth: 5, + employeeCount: 3, + isCorrection: false, + totals: { + totalTax: 28500, + totalAvgifterBasis: 105000, + totalAvgifterAmount: 32991, + totalSjuklonekostnad: 0, + avgifterByCategory: {}, + }, + orgNumber: '5566778899', + }) + + const res = await generateAgi( + makeRequest( + `https://x.test/api/v1/companies/${COMPANY_ID}/salary-runs/${RUN_ID}/generate-agi`, + { method: 'POST' }, + ), + detailParams(COMPANY_ID, RUN_ID), + ) + + expect(res.status).toBe(200) + const body = await res.json() + expect(body.data.xml).toContain(' { + mockServiceClient.mockReturnValue( + makeFlexibleSupabase({ + company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null }, + idempotency_keys: { data: null, error: null }, + }), + ) + mocks.generateAgiDeclaration.mockResolvedValue({ + ok: false, + code: 'AGI_GENERATE_NOT_BOOKABLE', + details: { current_status: 'draft' }, + }) + + const res = await generateAgi( + makeRequest( + `https://x.test/api/v1/companies/${COMPANY_ID}/salary-runs/${RUN_ID}/generate-agi`, + { method: 'POST' }, + ), + detailParams(COMPANY_ID, RUN_ID), + ) + + expect(res.status).toBe(400) + const body = await res.json() + expect(body.error.code).toBe('AGI_GENERATE_NOT_BOOKABLE') + expect(body.error.details.current_status).toBe('draft') + }) + + it('surfaces AGI_INCOMPLETE_DATA with missing_fields when company contact info is missing', async () => { + mockServiceClient.mockReturnValue( + makeFlexibleSupabase({ + company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null }, + idempotency_keys: { data: null, error: null }, + }), + ) + mocks.generateAgiDeclaration.mockResolvedValue({ + ok: false, + code: 'AGI_INCOMPLETE_DATA', + details: { + missing_fields: ['contactPhone'], + message: 'AGI requires a contact phone number on company_settings.', + }, + }) + + const res = await generateAgi( + makeRequest( + `https://x.test/api/v1/companies/${COMPANY_ID}/salary-runs/${RUN_ID}/generate-agi`, + { method: 'POST' }, + ), + detailParams(COMPANY_ID, RUN_ID), + ) + + expect(res.status).toBe(400) + const body = await res.json() + expect(body.error.code).toBe('AGI_INCOMPLETE_DATA') + expect(body.error.details.missing_fields).toContain('contactPhone') + }) +}) diff --git a/app/api/v1/companies/[companyId]/salary-runs/[id]/approve/route.ts b/app/api/v1/companies/[companyId]/salary-runs/[id]/approve/route.ts new file mode 100644 index 00000000..11c7ed0c --- /dev/null +++ b/app/api/v1/companies/[companyId]/salary-runs/[id]/approve/route.ts @@ -0,0 +1,220 @@ +/** + * POST /api/v1/companies/{companyId}/salary-runs/{id}/approve + * + * Mirrors the dashboard's `/approve` route: validates every employee on the + * run has the data the bookkeeping engine + payment will need (bank details + * for transfer, calculation_breakdown proving :calculate ran), then advances + * status `review` → `approved` with an optimistic-lock UPDATE. Records the + * approver in `approved_by` + `approved_at`. Emits `salary_run.approved`. + * + * No engine interaction. No period-lock check. The verifikation gets posted + * later by `:book`. + * + * Strict-mode: validation errors return 400 with a structured list of every + * problem found across every employee, not just the first. An agent fixing + * issues in batch sees a complete picture rather than playing whack-a-mole. + */ + +import { z } from 'zod' +import { ok } from '@/lib/api/v1/response' +import { dryRunPreview } from '@/lib/api/v1/dry-run' +import { registerEndpoint } from '@/lib/api/v1/registry' +import { withApiV1 } from '@/lib/api/v1/with-api-v1' +import { v1ErrorResponse, v1ErrorResponseFromCode } from '@/lib/api/v1/errors' +import { eventBus } from '@/lib/events' + +const SalaryRunApproved = z.object({ + id: z.string().uuid(), + status: z.literal('approved'), + approved_at: z.string(), + approved_by: z.string().uuid().nullable(), + warnings: z.array(z.string()), +}) + +const APPROVE_RESPONSE_COLUMNS = 'id, status, approved_at, approved_by' + +registerEndpoint({ + operation: 'salary-runs.approve', + method: 'POST', + path: '/api/v1/companies/:companyId/salary-runs/:id/approve', + summary: 'Approve a reviewed salary run.', + description: + 'Advances a salary run from `review` to `approved` after validating every employee has the data required for the payment step (bank account + clearing number for the bank transfer) and the booking step (`calculation_breakdown` proves `:calculate` ran). Records the approving user + timestamp. Strict-mode: validation errors return a complete list rather than failing on the first one.', + useWhen: + 'You have a salary run in `review` status and want to authorize it for payment. This is the human (or agent) signoff step before money moves; the verifikation is still pending and won\'t exist until `:book` runs.', + doNotUseFor: + 'Posting journal entries (use `:book` after `:mark-paid`). Reverting an approval (the lifecycle has no `:unapprove` — call `:correct` once the run is booked if you need to undo).', + pitfalls: [ + 'Run must be in `review` — non-`review` runs return 400 SALARY_RUN_APPROVE_NOT_REVIEW.', + 'Every employee on the run needs a `clearing_number` + `bank_account_number`. Missing bank details return 400 SALARY_RUN_APPROVE_VALIDATION_FAILED with the per-employee list.', + 'Every employee on the run needs `calculation_breakdown` populated. If you skipped `:calculate` somehow, approve fails.', + 'Employees without email get a non-blocking warning (lönebesked can\'t be sent automatically).', + 'No period-lock check here — that lives on `:book` where the verifikation is posted. An agent can approve a run whose payment date falls in a now-locked period; `:book` will later refuse.', + ], + example: { + response: { + data: { + id: 'run_a8f1…', + status: 'approved', + approved_at: '2026-05-14T12:00:00Z', + approved_by: 'user_b73c…', + warnings: ['Anna Andersson: E-post saknas — lönebesked kan inte skickas'], + }, + meta: { request_id: 'req_…', api_version: '2026-05-12' }, + }, + }, + scope: 'payroll:write', + risk: 'low', + idempotent: true, + reversible: false, + dryRunSupported: true, + response: { success: SalaryRunApproved }, +}) + +export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string }> }>( + 'salary-runs.approve', + async (_request, ctx, params) => { + const { id } = await params.params + const idParse = z.string().uuid().safeParse(id) + if (!idParse.success) { + return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, { + requestId: ctx.requestId, + details: { field: 'id', message: 'Salary-run id must be a UUID.' }, + }) + } + const salaryRunId = idParse.data + + const { data: existing, error: fetchErr } = await ctx.supabase + .from('salary_runs') + .select('id, status') + .eq('company_id', ctx.companyId!) + .eq('id', salaryRunId) + .maybeSingle() + if (fetchErr) { + return v1ErrorResponse(fetchErr, ctx.log, { requestId: ctx.requestId }) + } + if (!existing) { + return v1ErrorResponseFromCode('SALARY_RUN_NOT_FOUND', ctx.log, { requestId: ctx.requestId }) + } + if ((existing as { status: string }).status !== 'review') { + return v1ErrorResponseFromCode('SALARY_RUN_APPROVE_NOT_REVIEW', ctx.log, { + requestId: ctx.requestId, + details: { current_status: (existing as { status: string }).status }, + }) + } + + // Validation: pull every employee on the run with the fields we need + // to assess. We accumulate ALL errors so the caller fixes everything + // in one pass. + const { data: runEmployees, error: empErr } = await ctx.supabase + .from('salary_run_employees') + .select( + 'calculation_breakdown, employee:employees(first_name, last_name, clearing_number, bank_account_number, email)', + ) + .eq('salary_run_id', salaryRunId) + // Defense-in-depth: every query carries the company_id filter per + // CLAUDE.md, even when salary_run_id already constrains to the + // company via FK + RLS. + .eq('company_id', ctx.companyId!) + if (empErr) { + return v1ErrorResponse(empErr, ctx.log, { requestId: ctx.requestId }) + } + + const validationErrors: string[] = [] + const warnings: string[] = [] + // Supabase's generated types model nested FK joins as arrays even when + // the FK is non-null one-to-one. Cast through `unknown` (matches the + // pattern used by suppliers/customers in Phase 4) so the route stays + // type-clean. The runtime shape is { employee: T | null } per the + // single-FK join. + for (const sre of ((runEmployees ?? []) as unknown) as Array<{ + calculation_breakdown: unknown + employee: { + first_name: string + last_name: string + clearing_number: string | null + bank_account_number: string | null + email: string | null + } | null + }>) { + const emp = sre.employee + if (!emp) continue + const name = `${emp.first_name} ${emp.last_name}` + if (!emp.clearing_number || !emp.bank_account_number) { + validationErrors.push( + `${name}: Bankuppgifter saknas (clearingnummer och/eller kontonummer)`, + ) + } + if (!sre.calculation_breakdown) { + validationErrors.push(`${name}: Beräkning saknas — kör beräkning först`) + } + if (!emp.email) { + warnings.push(`${name}: E-post saknas — lönebesked kan inte skickas`) + } + } + if (validationErrors.length > 0) { + return v1ErrorResponseFromCode('SALARY_RUN_APPROVE_VALIDATION_FAILED', ctx.log, { + requestId: ctx.requestId, + details: { issues: validationErrors, warnings }, + }) + } + + if (ctx.dryRun) { + return dryRunPreview( + { + id: salaryRunId, + would_advance_status_from: 'review', + would_advance_status_to: 'approved', + would_record_approver: ctx.userId, + warnings, + }, + { requestId: ctx.requestId, log: ctx.log }, + ) + } + + // Optimistic-lock the UPDATE on status='review' so a concurrent caller + // (or a replay racing this one) yields a clean 409 instead of + // silently re-approving and re-emitting the salary_run.approved event. + const { data, error } = await ctx.supabase + .from('salary_runs') + .update({ + status: 'approved', + approved_by: ctx.userId, + approved_at: new Date().toISOString(), + }) + .eq('company_id', ctx.companyId!) + .eq('id', salaryRunId) + .eq('status', 'review') + .select(APPROVE_RESPONSE_COLUMNS) + .maybeSingle() + + if (error) { + return v1ErrorResponse(error, ctx.log, { requestId: ctx.requestId }) + } + if (!data) { + return v1ErrorResponseFromCode('SALARY_RUN_APPROVE_NOT_REVIEW', ctx.log, { + requestId: ctx.requestId, + details: { reason: 'race' }, + }) + } + + try { + await eventBus.emit({ + type: 'salary_run.approved', + payload: { + salaryRunId, + approvedBy: ctx.userId, + userId: ctx.userId, + companyId: ctx.companyId!, + }, + }) + } catch (err) { + ctx.log.warn('salary_run.approved emit failed', err as Error) + } + + return ok( + { ...(data as Record), warnings }, + { requestId: ctx.requestId }, + ) + }, +) diff --git a/app/api/v1/companies/[companyId]/salary-runs/[id]/book/route.ts b/app/api/v1/companies/[companyId]/salary-runs/[id]/book/route.ts new file mode 100644 index 00000000..e3530870 --- /dev/null +++ b/app/api/v1/companies/[companyId]/salary-runs/[id]/book/route.ts @@ -0,0 +1,361 @@ +/** + * POST /api/v1/companies/{companyId}/salary-runs/{id}/book + * + * The engine-touching verb. Mirrors the dashboard's `/book` route: loads the + * run + employees + line items, calls `createSalaryRunEntries()` (which posts + * 2-4 verifikationer via the bookkeeping engine), then optimistic-lock + * UPDATEs status `paid` → `booked` with the journal entry foreign keys. + * + * BFL 5 kap + 6 §§: the verifikation must reflect the actual cash movement + * (payment_date). createSalaryRunEntries assigns voucher numbers atomically + * via the `commit_journal_entry` RPC; immutability triggers prevent any + * later edit. + * + * Strict-mode v1: an engine throw aborts BEFORE the salary_runs status + * mutation. There is no partial-state recovery banner; the caller sees a + * clean error and the run remains in `paid` so they can fix the underlying + * cause (e.g. unlock the period) and retry. + * + * Period-lock pre-check: we check `payment_date` against the company's lock + * date and fiscal period status BEFORE invoking the engine, so the response + * is a structured PERIOD_LOCKED rather than a generic engine error. The DB + * trigger remains authoritative — this is ergonomics, not security. + * + * Audit block: the success response includes the salary verifikation's + * voucher number + the entry IDs of all 2-4 posted entries, so an agent + * can verify the audit trail in one round-trip. + */ + +import { z } from 'zod' +import { ok } from '@/lib/api/v1/response' +import { dryRunPreview } from '@/lib/api/v1/dry-run' +import { registerEndpoint } from '@/lib/api/v1/registry' +import { withApiV1 } from '@/lib/api/v1/with-api-v1' +import { v1ErrorResponse, v1ErrorResponseFromCode } from '@/lib/api/v1/errors' +import { checkPeriodLock } from '@/lib/api/v1/check-period-lock' +import { createSalaryRunEntries } from '@/lib/salary/salary-entries' +import { isBookkeepingError } from '@/lib/bookkeeping/errors' +import { eventBus } from '@/lib/events' + +const SalaryRunBooked = z.object({ + id: z.string().uuid(), + status: z.literal('booked'), + booked_at: z.string(), + booked_by: z.string().uuid().nullable(), + salary_entry_id: z.string().uuid(), + avgifter_entry_id: z.string().uuid(), + vacation_entry_id: z.string().uuid().nullable(), + pension_entry_id: z.string().uuid().nullable(), + entry_ids: z.array(z.string().uuid()), +}) + +const BOOK_RESPONSE_COLUMNS = + 'id, status, booked_at, booked_by, salary_entry_id, avgifter_entry_id, vacation_entry_id, pension_entry_id' + +registerEndpoint({ + operation: 'salary-runs.book', + method: 'POST', + path: '/api/v1/companies/:companyId/salary-runs/:id/book', + summary: 'Post the verifikationer for a paid salary run.', + description: + 'Creates 2–4 journal entries (1: salary brutto/tax/net; 2: arbetsgivaravgifter; 3 if applicable: semesterlöneskuld accrual; 4 if applicable: pension + SLP from löneväxling), then advances status `paid` → `booked` with all the entry IDs recorded on the salary_runs row. Strict-mode: any engine failure aborts BEFORE the status flip — the run stays in `paid` so the caller can fix the cause (locked period, missing BAS account, etc.) and retry.', + useWhen: + 'You\'ve marked a salary run as paid and want to post the BFL-required verifikationer. This is the final lifecycle verb before AGI generation; after :book, the run can no longer be edited and corrections must use the (forthcoming) `:correct` verb.', + doNotUseFor: + 'Posting salary entries outside the salary-run lifecycle (use POST /journal-entries directly). Re-booking an already-booked run (returns 400 SALARY_RUN_BOOK_NOT_PAID).', + pitfalls: [ + 'Run must be in `paid` — non-`paid` runs return 400 SALARY_RUN_BOOK_NOT_PAID.', + 'payment_date must fall in an open fiscal period — locked period returns 400 PERIOD_LOCKED with `fiscal_period_id` and a hint of what unlock action is needed.', + 'BFL 5 kap immutability: once `:book` succeeds the verifikationer cannot be edited or deleted. Corrections require `:correct` (Phase 5 PR-3) which does a storno-then-rebook.', + 'The salary verifikation is the primary one; its voucher_number appears in the response audit block. The avgifter, vacation, and pension entries get separate voucher numbers (returned as `entry_ids`).', + 'Strict-mode: if the engine fails partway, the salary_runs row stays in `paid`. There is no "partial booking" — the engine either commits all entries or the entire booking fails.', + ], + example: { + response: { + data: { + id: 'run_a8f1…', + status: 'booked', + booked_at: '2026-05-26T09:15:00Z', + booked_by: 'user_b73c…', + salary_entry_id: 'je_salary…', + avgifter_entry_id: 'je_avg…', + vacation_entry_id: 'je_vac…', + pension_entry_id: null, + entry_ids: ['je_salary…', 'je_avg…', 'je_vac…'], + }, + meta: { + request_id: 'req_…', + api_version: '2026-05-12', + audit: { + voucher_number: 'L2026-0023', + voucher_url: '/api/v1/companies/.../journal-entries/je_salary…', + immutable_at: '2026-05-26T09:15:00Z', + }, + }, + }, + }, + scope: 'payroll:write', + risk: 'high', + idempotent: true, + reversible: false, + dryRunSupported: true, + response: { success: SalaryRunBooked }, +}) + +export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string }> }>( + 'salary-runs.book', + async (_request, ctx, params) => { + const { id } = await params.params + const idParse = z.string().uuid().safeParse(id) + if (!idParse.success) { + return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, { + requestId: ctx.requestId, + details: { field: 'id', message: 'Salary-run id must be a UUID.' }, + }) + } + const salaryRunId = idParse.data + + // 1. Status precheck. + const { data: run, error: fetchErr } = await ctx.supabase + .from('salary_runs') + .select('*') + .eq('company_id', ctx.companyId!) + .eq('id', salaryRunId) + .maybeSingle() + if (fetchErr) { + return v1ErrorResponse(fetchErr, ctx.log, { requestId: ctx.requestId }) + } + if (!run) { + return v1ErrorResponseFromCode('SALARY_RUN_NOT_FOUND', ctx.log, { requestId: ctx.requestId }) + } + if ((run as { status: string }).status !== 'paid') { + return v1ErrorResponseFromCode('SALARY_RUN_BOOK_NOT_PAID', ctx.log, { + requestId: ctx.requestId, + details: { current_status: (run as { status: string }).status }, + }) + } + + // 2. Period-lock pre-check. The DB trigger remains authoritative; this + // is so an agent gets a structured PERIOD_LOCKED response with + // fiscal_period_id rather than a generic engine error. + const paymentDate = (run as { payment_date: string }).payment_date + const lockVerdict = await checkPeriodLock(ctx.supabase, ctx.companyId!, paymentDate) + if (lockVerdict.locked) { + return v1ErrorResponseFromCode('PERIOD_LOCKED', ctx.log, { + requestId: ctx.requestId, + details: { + reason: lockVerdict.reason, + fiscal_period_id: lockVerdict.fiscal_period_id, + payment_date: paymentDate, + }, + }) + } + + // 3. Load run + employees + line items for the engine. + const { data: employees, error: empErr } = await ctx.supabase + .from('salary_run_employees') + .select('*, employee:employees(employment_type), line_items:salary_line_items(*)') + .eq('salary_run_id', salaryRunId) + if (empErr) { + return v1ErrorResponse(empErr, ctx.log, { requestId: ctx.requestId }) + } + if (!employees || employees.length === 0) { + return v1ErrorResponseFromCode('SALARY_RUN_NO_EMPLOYEES', ctx.log, { + requestId: ctx.requestId, + }) + } + + if (ctx.dryRun) { + // Without invoking the engine we can't get real voucher numbers, but + // we CAN preview the would-be state transition + the expected entry + // shape (which entries will exist based on totals). Agents can use + // this to detect missing employees / wrong totals before paying for + // the real call. + const totalVacation = (employees as Array<{ vacation_accrual: number }>).reduce( + (sum, e) => sum + e.vacation_accrual, + 0, + ) + const totalVacationAvgifter = (employees as Array<{ vacation_accrual_avgifter: number }>).reduce( + (sum, e) => sum + e.vacation_accrual_avgifter, + 0, + ) + return dryRunPreview( + { + id: salaryRunId, + would_advance_status_from: 'paid', + would_advance_status_to: 'booked', + would_post_entries: [ + 'salary (gross + tax withholding + net payment)', + 'arbetsgivaravgifter', + ...(totalVacation > 0 || totalVacationAvgifter > 0 ? ['vacation accrual'] : []), + // Pension cannot be detected without the engine — we'd need + // to inspect line_items for löneväxling. Omitted from preview. + ], + note: 'A live call posts 2-4 verifikationer atomically via createSalaryRunEntries. Voucher numbers are assigned at commit time.', + }, + { requestId: ctx.requestId, log: ctx.log }, + ) + } + + // 4. Engine call. Strict-mode: any throw aborts before status flip. + type EmpRow = { + employee_id: string + employee: { employment_type: string } | null + gross_salary: number + tax_withheld: number + net_salary: number + avgifter_amount: number + avgifter_rate: number + vacation_accrual: number + vacation_accrual_avgifter: number + line_items: Array<{ + item_type: string + amount: number + account_number: string | null + is_net_deduction: boolean + is_gross_deduction: boolean + }> | null + } + let salaryEntry: { id: string; voucher_number: string } + let avgifterEntry: { id: string } + let vacationEntry: { id: string } | null + let pensionEntry: { id: string } | null + try { + const result = await createSalaryRunEntries(ctx.supabase, ctx.companyId!, ctx.userId, { + id: (run as { id: string }).id, + period_year: (run as { period_year: number }).period_year, + period_month: (run as { period_month: number }).period_month, + payment_date: paymentDate, + voucher_series: (run as { voucher_series: string }).voucher_series, + total_gross: (run as { total_gross: number }).total_gross, + total_tax: (run as { total_tax: number }).total_tax, + total_net: (run as { total_net: number }).total_net, + total_avgifter: (run as { total_avgifter: number }).total_avgifter, + total_vacation_accrual: (run as { total_vacation_accrual: number }).total_vacation_accrual, + employees: (employees as EmpRow[]).map((sre) => ({ + employee_id: sre.employee_id, + employment_type: sre.employee?.employment_type || 'employee', + gross_salary: sre.gross_salary, + tax_withheld: sre.tax_withheld, + net_salary: sre.net_salary, + avgifter_amount: sre.avgifter_amount, + avgifter_rate: sre.avgifter_rate, + vacation_accrual: sre.vacation_accrual, + vacation_accrual_avgifter: sre.vacation_accrual_avgifter, + line_items: (sre.line_items || []).map((li) => ({ + item_type: li.item_type, + amount: li.amount, + account_number: li.account_number, + is_net_deduction: li.is_net_deduction, + is_gross_deduction: li.is_gross_deduction, + })), + })), + }) + // Narrow to just the fields the route consumes — id + voucher_number + // for the primary salary entry, id for the others. The full + // JournalEntry shape is broader than what the audit block needs. + salaryEntry = result.salaryEntry as unknown as { id: string; voucher_number: string } + avgifterEntry = result.avgifterEntry + vacationEntry = result.vacationEntry + pensionEntry = result.pensionEntry + } catch (err) { + if (isBookkeepingError(err)) { + return v1ErrorResponse(err, ctx.log, { requestId: ctx.requestId }) + } + ctx.log.error('salary booking failed', err as Error, { + salaryRunId, + companyId: ctx.companyId, + userId: ctx.userId, + }) + return v1ErrorResponseFromCode('SALARY_RUN_BOOK_FAILED', ctx.log, { + requestId: ctx.requestId, + details: { reason: err instanceof Error ? err.message : 'unknown' }, + }) + } + + // 5. Optimistic-lock the status flip on status='paid'. Concurrent calls + // would have re-posted JEs (a real bug — we'd have orphans), but + // the engine's atomicity makes that a no-op race that just won't + // commit the second status flip. + const entryIds = [salaryEntry.id, avgifterEntry.id] + const updates: Record = { + status: 'booked', + salary_entry_id: salaryEntry.id, + avgifter_entry_id: avgifterEntry.id, + booked_at: new Date().toISOString(), + booked_by: ctx.userId, + } + if (vacationEntry) { + updates.vacation_entry_id = vacationEntry.id + entryIds.push(vacationEntry.id) + } + if (pensionEntry) { + updates.pension_entry_id = pensionEntry.id + entryIds.push(pensionEntry.id) + } + + const { data: bookedRun, error: updateError } = await ctx.supabase + .from('salary_runs') + .update(updates) + .eq('company_id', ctx.companyId!) + .eq('id', salaryRunId) + .eq('status', 'paid') + .select(BOOK_RESPONSE_COLUMNS) + .maybeSingle() + + if (updateError) { + // The engine already committed; the row update failed. This is a + // partial-state we cannot recover automatically. Surface loudly so + // an operator notices and runs a manual reconciliation (the + // verifikationer exist and have voucher numbers; the salary_runs + // row just doesn't point at them yet). + ctx.log.error('salary_runs status flip failed after engine commit', updateError as Error, { + salaryRunId, + companyId: ctx.companyId, + entryIds, + }) + return v1ErrorResponse(updateError, ctx.log, { requestId: ctx.requestId }) + } + if (!bookedRun) { + // Race: the row's status changed between fetch and update. The + // engine has committed; we cannot un-commit. Log loudly. + ctx.log.error('salary_runs row missing after engine commit', new Error('race'), { + salaryRunId, + companyId: ctx.companyId, + entryIds, + }) + return v1ErrorResponseFromCode('SALARY_RUN_BOOK_FAILED', ctx.log, { + requestId: ctx.requestId, + details: { reason: 'row missing after engine commit', entry_ids: entryIds }, + }) + } + + try { + await eventBus.emit({ + type: 'salary_run.booked', + payload: { + salaryRunId, + entryIds, + userId: ctx.userId, + companyId: ctx.companyId!, + }, + }) + } catch (err) { + ctx.log.warn('salary_run.booked emit failed', err as Error) + } + + const bookedAt = (bookedRun as { booked_at: string }).booked_at + + return ok( + { ...(bookedRun as Record), entry_ids: entryIds }, + { + requestId: ctx.requestId, + audit: { + voucher_number: salaryEntry.voucher_number, + voucher_url: `/api/v1/companies/${ctx.companyId}/journal-entries/${salaryEntry.id}`, + immutable_at: bookedAt, + }, + }, + ) + }, +) diff --git a/app/api/v1/companies/[companyId]/salary-runs/[id]/calculate/route.ts b/app/api/v1/companies/[companyId]/salary-runs/[id]/calculate/route.ts new file mode 100644 index 00000000..ad1deac1 --- /dev/null +++ b/app/api/v1/companies/[companyId]/salary-runs/[id]/calculate/route.ts @@ -0,0 +1,217 @@ +/** + * POST /api/v1/companies/{companyId}/salary-runs/{id}/calculate + * + * v1's :calculate collapses the dashboard's two-step flow (internal /calculate + * does the math but leaves status='draft'; internal /review explicitly + * advances draft→review) into a single agent-friendly verb: + * + * 1. Invoke `runSalaryCalculation()` (the shared lib helper that the + * dashboard's /calculate also uses). + * 2. On success, optimistic-lock UPDATE draft → review with an explicit + * `.eq('status', 'draft')` guard so concurrent calls yield a clean + * 409, not a silent overwrite. + * 3. Surface F-skatt 'not_verified' warnings (carried over from the + * dashboard's /review F-skatt gate) alongside the calculation + * warnings (tax table fallback, läkarintyg, FK day-15). + * + * Strict-mode: if any step inside `runSalaryCalculation` fails, the helper + * returns a structured `{ ok: false }` and this route surfaces that without + * touching status. The run stays in `draft` and the agent can retry. + * + * No engine interaction. No period-lock check (that lives on :book where + * the JEs are actually posted). + */ + +import { z } from 'zod' +import { ok } from '@/lib/api/v1/response' +import { dryRunPreview } from '@/lib/api/v1/dry-run' +import { registerEndpoint } from '@/lib/api/v1/registry' +import { withApiV1 } from '@/lib/api/v1/with-api-v1' +import { v1ErrorResponse, v1ErrorResponseFromCode } from '@/lib/api/v1/errors' +import { runSalaryCalculation } from '@/lib/salary/run-calculation' + +const SalaryRunCalculated = z.object({ + id: z.string().uuid(), + status: z.literal('review'), + period_year: z.number().int(), + period_month: z.number().int(), + total_gross: z.number(), + total_tax: z.number(), + total_net: z.number(), + total_avgifter: z.number(), + total_employer_cost: z.number(), + warnings: z.array(z.string()), +}) + +registerEndpoint({ + operation: 'salary-runs.calculate', + method: 'POST', + path: '/api/v1/companies/:companyId/salary-runs/:id/calculate', + summary: 'Calculate a draft salary run and advance it to review.', + description: + 'Runs the per-employee payroll calculation (tax withholding, employer contributions, vacation accrual) for every employee on a draft run, persists the line items + run totals + calculation_params snapshot, then promotes status from draft to review in a single atomic verb. Returns the updated run plus a `warnings` array surfacing non-blocking issues (Skatteverket tax-table fallback, läkarintyg day-8 transition, Försäkringskassan day-15 transition, F-skatt not-verified employees). Strict-mode: any failure (validation, tax-table unavailable, DB error) aborts before the status flip — the run stays in draft.', + useWhen: + 'You have a draft salary run with employees added and want to compute the numbers + freeze them for approval. This is the first lifecycle verb after creating a run.', + doNotUseFor: + 're-running a salary run already in review or later (only `draft` is accepted — call POST :correct in Phase 5 PR-3 once that ships to revise a booked run). Adding employees to the run (that surface is not yet on v1; use the dashboard).', + pitfalls: [ + 'Run must be in `draft` status — calculate on a non-draft run returns 400 SALARY_RUN_CALCULATE_NOT_DRAFT.', + 'Salary run must have at least one employee — empty runs return 400 SALARY_RUN_NO_EMPLOYEES.', + 'If Skatteverket\'s tax-table API is down and local fallback is missing the required table, calculate returns 503 SALARY_RUN_TAX_TABLE_MISSING. Retry is safe; the operation is idempotent at the helper level.', + 'F-skatt "not_verified" employees produce a non-blocking warning; an integrator should treat the warning as a hard signal that withholding will be wrong until F-skatt is verified.', + 'Warnings about tax-table fallback or läkarintyg / FK day-15 transitions are non-blocking; the run still advances to review. Surface them to a human reviewer before calling :approve.', + ], + example: { + response: { + data: { + id: 'run_a8f1…', + status: 'review', + period_year: 2026, + period_month: 5, + total_gross: 105000, + total_tax: 28500, + total_net: 76500, + total_avgifter: 32991, + total_employer_cost: 137991, + warnings: [ + 'Läkarintyg krävs från och med dag 8: Anna Andersson. Kontrollera att läkarintyg finns innan lönekörningen godkänns.', + ], + }, + meta: { request_id: 'req_…', api_version: '2026-05-12' }, + }, + }, + scope: 'payroll:write', + risk: 'medium', + idempotent: true, + reversible: false, + dryRunSupported: true, + response: { success: SalaryRunCalculated }, +}) + +export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string }> }>( + 'salary-runs.calculate', + async (_request, ctx, params) => { + const { id } = await params.params + const idParse = z.string().uuid().safeParse(id) + if (!idParse.success) { + return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, { + requestId: ctx.requestId, + details: { field: 'id', message: 'Salary-run id must be a UUID.' }, + }) + } + const salaryRunId = idParse.data + + // Pre-flight status check so a dry-run can preview the would-be outcome + // without committing any of the calculation's many side effects. + const { data: existing, error: fetchErr } = await ctx.supabase + .from('salary_runs') + .select('id, status, period_year, period_month, payment_date') + .eq('company_id', ctx.companyId!) + .eq('id', salaryRunId) + .maybeSingle() + if (fetchErr) { + return v1ErrorResponse(fetchErr, ctx.log, { requestId: ctx.requestId }) + } + if (!existing) { + return v1ErrorResponseFromCode('SALARY_RUN_NOT_FOUND', ctx.log, { requestId: ctx.requestId }) + } + if ((existing as { status: string }).status !== 'draft') { + return v1ErrorResponseFromCode('SALARY_RUN_CALCULATE_NOT_DRAFT', ctx.log, { + requestId: ctx.requestId, + details: { current_status: (existing as { status: string }).status }, + }) + } + + if (ctx.dryRun) { + // The helper is a heavy operation with hundreds of DB writes — we + // cannot meaningfully "dry-run" it without committing real state. + // Instead the dry-run surfaces what WOULD happen at the contract + // level: status flip + a hint that the math will run. Agents can + // use this to validate the preconditions before paying for the + // expensive call. + return dryRunPreview( + { + id: salaryRunId, + would_advance_status_from: 'draft', + would_advance_status_to: 'review', + note: 'A live call will compute per-employee tax, avgifter, and vacation accrual, then persist line items + run totals. The actual figures are only available on a real (non-dry-run) call.', + }, + { requestId: ctx.requestId, log: ctx.log }, + ) + } + + const result = await runSalaryCalculation({ + supabase: ctx.supabase, + companyId: ctx.companyId!, + salaryRunId, + log: ctx.log, + requestId: ctx.requestId, + }) + + if (!result.ok) { + return v1ErrorResponseFromCode(result.code, ctx.log, { + requestId: ctx.requestId, + details: result.details, + status: result.status, + }) + } + + // Advance status draft → review with an optimistic-lock guard. A + // concurrent call (or replay) that beat us to it would have seen the + // helper's status-check fail, but defense-in-depth here covers the + // window between the helper's UPDATE on totals and our status flip. + // + // Also surface F-skatt 'not_verified' employees as additional warnings, + // mirroring the dashboard's internal /review step. + const { data: runEmployees } = await ctx.supabase + .from('salary_run_employees') + .select('employee:employees(first_name, last_name, f_skatt_status)') + .eq('salary_run_id', salaryRunId) + + const fskattWarnings: string[] = [] + // See approve/route.ts: Supabase types nested FK joins as arrays even + // for single-FK relations; cast through `unknown` so the route stays + // type-clean. + for (const sre of ((runEmployees ?? []) as unknown) as Array<{ + employee: { first_name: string; last_name: string; f_skatt_status: string } | null + }>) { + const emp = sre.employee + if (emp?.f_skatt_status === 'not_verified') { + fskattWarnings.push( + `${emp.first_name} ${emp.last_name}: F-skatt ej verifierad — 30% skatteavdrag och fulla avgifter tillämpas (f-skatt.md)`, + ) + } + } + + const { data: advancedRun, error: advanceErr } = await ctx.supabase + .from('salary_runs') + .update({ status: 'review' }) + .eq('company_id', ctx.companyId!) + .eq('id', salaryRunId) + .eq('status', 'draft') + .select('id, status, period_year, period_month, total_gross, total_tax, total_net, total_avgifter, total_employer_cost') + .maybeSingle() + + if (advanceErr) { + return v1ErrorResponse(advanceErr, ctx.log, { requestId: ctx.requestId }) + } + if (!advancedRun) { + // Race: status transitioned between the helper completing and the + // status UPDATE. The most likely cause is a concurrent v1 call also + // doing :calculate; both will have re-run the math, the second one + // gets the stale-status 409. + return v1ErrorResponseFromCode('SALARY_RUN_CALCULATE_NOT_DRAFT', ctx.log, { + requestId: ctx.requestId, + details: { reason: 'race' }, + }) + } + + return ok( + { + ...(advancedRun as Record), + warnings: [...result.warnings, ...fskattWarnings], + }, + { requestId: ctx.requestId }, + ) + }, +) diff --git a/app/api/v1/companies/[companyId]/salary-runs/[id]/generate-agi/route.ts b/app/api/v1/companies/[companyId]/salary-runs/[id]/generate-agi/route.ts new file mode 100644 index 00000000..3cc27ddb --- /dev/null +++ b/app/api/v1/companies/[companyId]/salary-runs/[id]/generate-agi/route.ts @@ -0,0 +1,181 @@ +/** + * POST /api/v1/companies/{companyId}/salary-runs/{id}/generate-agi + * + * Generates the Skatteverket AGI (arbetsgivardeklaration på individnivå) XML + * for a salary run and persists the declaration. Calls the shared + * `generateAgiDeclaration()` helper that the dashboard's GET /agi/xml also + * uses, so the XML is byte-equivalent across surfaces. + * + * SYNC, not async. Despite the original plan annotating this as "(async)", + * the actual work is sub-second: in-memory XML generation, one UPSERT into + * agi_declarations, one UPDATE on salary_runs.agi_generated_at, one + * optimistic-lock UPDATE on deadlines, one event emit. Using the + * `operations` substrate here would be over-engineering — the response + * comes back fast enough that polling would just be friction. Documented + * as a deliberate plan deviation. + * + * Response shape: v1 JSON envelope with the XML embedded as a string field + * (`xml`). Agents extract `data.xml` and save / forward as needed. This + * preserves the v1 envelope's request_id + audit headers; an agent who + * wants a download-flavored response can wrap the call themselves. + * + * Status gate: review|approved|paid|booked|corrected. Mirrors the + * dashboard exactly. The Swedish-compliance review in PR-1 suggested + * tightening to `approved+` because AGI from `review` could submit + * incorrect figures to Skatteverket. That's a real concern but a design + * decision orthogonal to this PR — narrowing the gate would diverge from + * the dashboard's behavior. Tracked for a future tightening. + */ + +import { z } from 'zod' +import { ok } from '@/lib/api/v1/response' +import { registerEndpoint } from '@/lib/api/v1/registry' +import { withApiV1 } from '@/lib/api/v1/with-api-v1' +import { v1ErrorResponseFromCode } from '@/lib/api/v1/errors' +import { generateAgiDeclaration } from '@/lib/salary/agi/generate-declaration' + +const AvgifterCategory = z.object({ basis: z.number(), amount: z.number() }) + +const AgiTotals = z.object({ + totalTax: z.number(), + totalAvgifterBasis: z.number(), + totalAvgifterAmount: z.number(), + totalSjuklonekostnad: z.number(), + avgifterByCategory: z.record(z.string(), AvgifterCategory), +}) + +const AgiGenerated = z.object({ + agi_declaration_id: z.string().uuid(), + period_year: z.number().int(), + period_month: z.number().int(), + employee_count: z.number().int(), + is_correction: z.boolean(), + totals: AgiTotals, + xml: z.string(), + xml_filename: z.string(), +}) + +registerEndpoint({ + operation: 'salary-runs.generate-agi', + method: 'POST', + path: '/api/v1/companies/:companyId/salary-runs/:id/generate-agi', + summary: 'Generate the Skatteverket AGI XML for a salary run.', + description: + 'Generates the arbetsgivardeklaration-på-individnivå XML for the run (HU section + per-employee IU + Frånvarouppgift for VAB/parental), upserts the agi_declarations row (correction-aware), stamps salary_runs.agi_generated_at, emits `agi.generated`, and auto-completes the `arbetsgivardeklaration` deadline. Returns the XML as a string field in the v1 envelope — agents extract `data.xml` and forward to Skatteverket directly (Mina Sidor upload or via a connected extension).', + useWhen: + 'You\'ve reviewed (or approved / paid / booked) a salary run and need to file AGI with Skatteverket. The Skatteverket filing deadline is the 12th of the following month (17th in Jan / Aug for companies ≤40 MSEK turnover).', + doNotUseFor: + 'Submitting the AGI to Skatteverket — this endpoint only generates and persists the XML. Submission is a separate flow via the (optional) `skatteverket` extension.', + pitfalls: [ + 'Run status must be one of review, approved, paid, booked, corrected — `draft` returns 400 AGI_GENERATE_NOT_BOOKABLE.', + 'Generating AGI from a `review`-status run risks submitting figures that will change at `:approve`. The dashboard allows this for flexibility; agents should prefer `approved+` unless an early-warning workflow specifically wants the preview.', + 'Subsequent calls for the same period UPDATE the agi_declarations row (is_correction=true) and overwrite the XML. The FK570 specifikationsnummer stays consistent per employee — different number = new record per Skatteverket spec.', + 'AGI_INCOMPLETE_DATA returns 400 when company contact info is missing (org_number, contact name, phone, email). Fix via /settings/company before retrying.', + 'The XML content is räkenskapsinformation — BFL 7 kap retention applies. The agi_declarations row is never auto-deleted.', + ], + example: { + response: { + data: { + agi_declaration_id: 'agi_a8f1…', + period_year: 2026, + period_month: 5, + employee_count: 3, + is_correction: false, + totals: { + totalTax: 28500, + totalAvgifterBasis: 105000, + totalAvgifterAmount: 32991, + totalSjuklonekostnad: 0, + avgifterByCategory: { standard: { basis: 105000, amount: 32991 } }, + }, + xml: '', + xml_filename: 'AGI_5566778899_202605.xml', + }, + meta: { request_id: 'req_…', api_version: '2026-05-12' }, + }, + }, + scope: 'payroll:write', + risk: 'medium', + idempotent: true, + reversible: false, + dryRunSupported: false, + response: { success: AgiGenerated }, +}) + +export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string }> }>( + 'salary-runs.generate-agi', + async (_request, ctx, params) => { + const { id } = await params.params + const idParse = z.string().uuid().safeParse(id) + if (!idParse.success) { + return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, { + requestId: ctx.requestId, + details: { field: 'id', message: 'Salary-run id must be a UUID.' }, + }) + } + const salaryRunId = idParse.data + + // The helper is essentially idempotent — calling twice produces the + // same XML with the second call marked is_correction=true. We do NOT + // expose a dry-run here because the only state the helper changes is + // (a) the agi_declarations row (cheap, idempotent), (b) the + // salary_runs.agi_generated_at timestamp (cheap, idempotent), and + // (c) the deadlines auto-complete (idempotent if already completed). + // Adding dry-run plumbing would more than double the code for no + // agent-facing benefit. + + // Pull the user's email so we can fall back to it when neither + // company_settings.email nor profiles.email is present. The wrapper + // doesn't carry the email — fetch via supabase auth admin. + const { data: userRecord } = await ctx.supabase.auth.admin.getUserById(ctx.userId) + const userEmail = userRecord?.user?.email ?? null + + const result = await generateAgiDeclaration({ + supabase: ctx.supabase, + companyId: ctx.companyId!, + userId: ctx.userId, + userEmail, + salaryRunId, + log: ctx.log, + requestId: ctx.requestId, + }) + + if (!result.ok) { + return v1ErrorResponseFromCode(result.code, ctx.log, { + requestId: ctx.requestId, + details: result.details, + status: result.status, + }) + } + + // OWASP V3.2 / V4 (HTTP response header injection prevention) + + // path-traversal hardening — sanitise the filename. The orgNumber + // comes from company_settings (user-editable) so it can in theory + // carry stray characters. The period digits are server-generated + // but we strip anything non-numeric defensively. The resulting + // filename is safe to put in a future Content-Disposition header by + // any caller forwarding the response, and prevents path-traversal + // characters from reaching agent file-write code that uses + // xml_filename verbatim. + const safeOrg = result.orgNumber.replace(/[^0-9A-Za-z-]/g, '') + const safePeriod = `${result.periodYear}${String(result.periodMonth).padStart(2, '0')}`.replace( + /[^0-9]/g, + '', + ) + const xmlFilename = `AGI_${safeOrg}_${safePeriod}.xml` + + return ok( + { + agi_declaration_id: result.agiDeclarationId, + period_year: result.periodYear, + period_month: result.periodMonth, + employee_count: result.employeeCount, + is_correction: result.isCorrection, + totals: result.totals, + xml: result.xml, + xml_filename: xmlFilename, + }, + { requestId: ctx.requestId }, + ) + }, +) diff --git a/app/api/v1/companies/[companyId]/salary-runs/[id]/mark-paid/route.ts b/app/api/v1/companies/[companyId]/salary-runs/[id]/mark-paid/route.ts new file mode 100644 index 00000000..04a31b7d --- /dev/null +++ b/app/api/v1/companies/[companyId]/salary-runs/[id]/mark-paid/route.ts @@ -0,0 +1,121 @@ +/** + * POST /api/v1/companies/{companyId}/salary-runs/{id}/mark-paid + * + * Mirrors the dashboard's `/paid` route: advances status `approved` → `paid` + * and stamps `paid_at`. No engine interaction, no event emission (the dashboard's + * route is also silent; the verifikation event fires from `:book`). + * + * Idempotent at the call level — a replay with the same Idempotency-Key returns + * the cached response. State-wise, calling :mark-paid on an already-paid run + * returns 400 (status must be approved). + */ + +import { z } from 'zod' +import { ok } from '@/lib/api/v1/response' +import { dryRunPreview } from '@/lib/api/v1/dry-run' +import { registerEndpoint } from '@/lib/api/v1/registry' +import { withApiV1 } from '@/lib/api/v1/with-api-v1' +import { v1ErrorResponse, v1ErrorResponseFromCode } from '@/lib/api/v1/errors' + +const SalaryRunPaid = z.object({ + id: z.string().uuid(), + status: z.literal('paid'), + paid_at: z.string(), +}) + +const MARK_PAID_COLUMNS = 'id, status, paid_at' + +registerEndpoint({ + operation: 'salary-runs.mark-paid', + method: 'POST', + path: '/api/v1/companies/:companyId/salary-runs/:id/mark-paid', + summary: 'Mark an approved salary run as paid.', + description: + 'Advances a salary run from `approved` to `paid` and stamps `paid_at`. This is the state-change verb after the bank transfer (or autogiro file) has been processed; it does NOT initiate payment, and does NOT post journal entries (use `:book` after this for that).', + useWhen: + 'You\'ve confirmed the salary payment hit employee bank accounts and want to advance the run\'s lifecycle so `:book` can post the verifikation.', + doNotUseFor: + 'Initiating the actual bank transfer (the v1 API does not yet expose payment-file generation; use the dashboard\'s payment-file endpoints). Posting journal entries (use `:book`). Reverting a paid run (no `:unpaid` exists — call `:correct` once booked if you need to undo).', + pitfalls: [ + 'Run must be in `approved` — non-`approved` runs return 400 SALARY_RUN_MARK_PAID_NOT_APPROVED.', + 'paid_at is set server-side to the current UTC timestamp; the API does not accept a body-supplied date to keep BFL audit clean.', + ], + example: { + response: { + data: { id: 'run_a8f1…', status: 'paid', paid_at: '2026-05-25T08:00:00Z' }, + meta: { request_id: 'req_…', api_version: '2026-05-12' }, + }, + }, + scope: 'payroll:write', + risk: 'low', + idempotent: true, + reversible: false, + dryRunSupported: true, + response: { success: SalaryRunPaid }, +}) + +export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string }> }>( + 'salary-runs.mark-paid', + async (_request, ctx, params) => { + const { id } = await params.params + const idParse = z.string().uuid().safeParse(id) + if (!idParse.success) { + return v1ErrorResponseFromCode('VALIDATION_ERROR', ctx.log, { + requestId: ctx.requestId, + details: { field: 'id', message: 'Salary-run id must be a UUID.' }, + }) + } + const salaryRunId = idParse.data + + const { data: existing, error: fetchErr } = await ctx.supabase + .from('salary_runs') + .select('id, status') + .eq('company_id', ctx.companyId!) + .eq('id', salaryRunId) + .maybeSingle() + if (fetchErr) { + return v1ErrorResponse(fetchErr, ctx.log, { requestId: ctx.requestId }) + } + if (!existing) { + return v1ErrorResponseFromCode('SALARY_RUN_NOT_FOUND', ctx.log, { requestId: ctx.requestId }) + } + if ((existing as { status: string }).status !== 'approved') { + return v1ErrorResponseFromCode('SALARY_RUN_MARK_PAID_NOT_APPROVED', ctx.log, { + requestId: ctx.requestId, + details: { current_status: (existing as { status: string }).status }, + }) + } + + if (ctx.dryRun) { + return dryRunPreview( + { + id: salaryRunId, + would_advance_status_from: 'approved', + would_advance_status_to: 'paid', + }, + { requestId: ctx.requestId, log: ctx.log }, + ) + } + + const { data, error } = await ctx.supabase + .from('salary_runs') + .update({ status: 'paid', paid_at: new Date().toISOString() }) + .eq('company_id', ctx.companyId!) + .eq('id', salaryRunId) + .eq('status', 'approved') + .select(MARK_PAID_COLUMNS) + .maybeSingle() + + if (error) { + return v1ErrorResponse(error, ctx.log, { requestId: ctx.requestId }) + } + if (!data) { + return v1ErrorResponseFromCode('SALARY_RUN_MARK_PAID_NOT_APPROVED', ctx.log, { + requestId: ctx.requestId, + details: { reason: 'race' }, + }) + } + + return ok(data, { requestId: ctx.requestId }) + }, +) diff --git a/app/api/v1/companies/[companyId]/salary-runs/[id]/route.ts b/app/api/v1/companies/[companyId]/salary-runs/[id]/route.ts index d809cbba..00c3de97 100644 --- a/app/api/v1/companies/[companyId]/salary-runs/[id]/route.ts +++ b/app/api/v1/companies/[companyId]/salary-runs/[id]/route.ts @@ -369,10 +369,16 @@ export const DELETE = withApiV1<{ params: Promise<{ companyId: string; id: strin return v1ErrorResponse(error, ctx.log, { requestId: ctx.requestId }) } if (count === 0) { - // Race: status transitioned between pre-flight and delete. - return v1ErrorResponseFromCode('SALARY_RUN_DELETE_NOT_DRAFT', ctx.log, { + // Pre-flight saw status='draft', so a status race is one explanation — + // but the more concerning interpretation is that the FK-null guards + // tripped (a journal entry has somehow attached to a draft row, + // which would be a partial-failure path in PR-2's lifecycle code). + // Surface the distinct BFL 5 kap code so an operator seeing this in + // logs can immediately look for a misrouted verifikation rather + // than chalking it up to concurrency. + return v1ErrorResponseFromCode('SALARY_RUN_DELETE_HAS_JOURNAL_ENTRY', ctx.log, { requestId: ctx.requestId, - details: { reason: 'race' }, + details: { reason: 'fk_non_null_or_status_race' }, }) } diff --git a/app/api/v1/companies/[companyId]/salary-runs/__tests__/route.test.ts b/app/api/v1/companies/[companyId]/salary-runs/__tests__/route.test.ts index 93f1445e..3eac8749 100644 --- a/app/api/v1/companies/[companyId]/salary-runs/__tests__/route.test.ts +++ b/app/api/v1/companies/[companyId]/salary-runs/__tests__/route.test.ts @@ -539,7 +539,10 @@ describe('DELETE /api/v1/companies/:companyId/salary-runs/:id', () => { expect(res.status).toBe(400) const body = await res.json() - expect(body.error.code).toBe('SALARY_RUN_DELETE_NOT_DRAFT') - expect(body.error.details.reason).toBe('race') + // Phase 5 PR-2 swapped this to a distinct BFL 5 kap code so an + // operator seeing this in logs immediately knows a verifikation may + // be attached, not just that the status raced. + expect(body.error.code).toBe('SALARY_RUN_DELETE_HAS_JOURNAL_ENTRY') + expect(body.error.details.reason).toBe('fk_non_null_or_status_race') }) }) diff --git a/components/extensions/ExtensionWorkspaceShell.tsx b/components/extensions/ExtensionWorkspaceShell.tsx index c8853b2f..f708a72c 100644 --- a/components/extensions/ExtensionWorkspaceShell.tsx +++ b/components/extensions/ExtensionWorkspaceShell.tsx @@ -1,7 +1,7 @@ 'use client' import type { ExtensionDefinition } from '@/lib/extensions/types' -import { resolveIcon } from '@/lib/extensions/icon-resolver' +import { PageHeader } from '@/components/ui/page-header' export default function ExtensionWorkspaceShell({ definition, @@ -10,22 +10,9 @@ export default function ExtensionWorkspaceShell({ definition: ExtensionDefinition children: React.ReactNode }) { - const Icon = resolveIcon(definition.icon) - return ( -
- {/* Header */} -
-
- -
-
-

{definition.name}

-

{definition.description}

-
-
- - {/* Extension content */} +
+ {children}
) diff --git a/components/extensions/general/TicWorkspace.tsx b/components/extensions/general/TicWorkspace.tsx index 716a9ffb..152c7d41 100644 --- a/components/extensions/general/TicWorkspace.tsx +++ b/components/extensions/general/TicWorkspace.tsx @@ -2,7 +2,6 @@ import { useState, useCallback, useEffect } from 'react' import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card' -import { Badge } from '@/components/ui/badge' import { Button } from '@/components/ui/button' import { Skeleton } from '@/components/ui/skeleton' import { @@ -216,23 +215,14 @@ export default function TicWorkspace({ userId }: WorkspaceComponentProps) { if (!profile) return null const isActive = profile.activityStatus !== 'ceased' + const registrations = [ + profile.registration.fTax && 'F-skatt', + profile.registration.vat && 'Moms', + profile.registration.payroll && 'Arbetsgivare', + ].filter((label): label is string => Boolean(label)) return (
- {/* Status bar */} -
- - {isActive ? 'Aktiv' : 'Avregistrerat'} - - {profile.registration.fTax && F-skatt} - {profile.registration.vat && Moms} - {profile.registration.payroll && Arbetsgivare} - - - Uppdaterad {timeAgo(profile.fetchedAt)} - -
-
{/* Company info card */} @@ -243,6 +233,9 @@ export default function TicWorkspace({ userId }: WorkspaceComponentProps) { {profile.orgNumber} · {profile.legalEntityType} + {!isActive && ( + · Avregistrerat + )} @@ -268,6 +261,12 @@ export default function TicWorkspace({ userId }: WorkspaceComponentProps) { {profile.phone}
)} + {registrations.length > 0 && ( +
+

Registrerat för

+

{registrations.join(' · ')}

+
+ )} {profile.sniCodes.length > 0 && (

SNI-koder

@@ -316,6 +315,9 @@ export default function TicWorkspace({ userId }: WorkspaceComponentProps) { )}
)} +

+ Uppdaterad {timeAgo(profile.fetchedAt)} +

diff --git a/lib/api/schemas.ts b/lib/api/schemas.ts index 700e9c93..40f0fedc 100644 --- a/lib/api/schemas.ts +++ b/lib/api/schemas.ts @@ -774,6 +774,32 @@ export const CreateEmployeeSchema = EmployeeSchemaBase.superRefine((data, ctx) = path: ['tax_municipality'], }) } + + // Phase 5 PR-1 carry-over (PR-2 enforcement): if vaxa_stod_eligible is set, + // require vaxa_stod_start. The end date is optional (some eligibility + // windows run open-ended until the maximum benefit period is reached). + // Birth-year age gate (the actual eligibility rule — born 2003-2007 for + // 2026) is checked at calculation-time by the engine, not here, because + // it depends on the payment year of each run — a 22-year-old at hire + // becomes 23 the next year and the rate switches without a row edit. + if (data.vaxa_stod_eligible && !data.vaxa_stod_start) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: 'Startdatum för Växa-stöd måste anges när Växa-stöd är aktiverat', + path: ['vaxa_stod_start'], + }) + } + if ( + data.vaxa_stod_start && + data.vaxa_stod_end && + data.vaxa_stod_end < data.vaxa_stod_start + ) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: 'Växa-stödets slutdatum måste vara efter startdatumet', + path: ['vaxa_stod_end'], + }) + } }) export const UpdateEmployeeSchema = EmployeeSchemaBase.partial().superRefine((data, ctx) => { @@ -808,6 +834,41 @@ export const UpdateEmployeeSchema = EmployeeSchemaBase.partial().superRefine((da path: ['hourly_rate'], }) } + + // Växa-stöd schema-level consistency check. The schema can only see what + // the PATCH body carries; the route layer is responsible for merged- + // state validation (i.e. an existing employee with vaxa_stod_start + // already set can have vaxa_stod_eligible flipped on without also + // sending start in the body). What the schema CAN enforce: + // - If the body enables vaxa_stod AND clears vaxa_stod_start explicitly + // (sending null), reject — that would orphan the eligibility flag. + // - If the body sets vaxa_stod_eligible=true AND vaxa_stod_start is + // present in the body but invalid relative to vaxa_stod_end, reject. + // The first case isn't currently expressible via .partial() (null != absent), + // so the practical schema-level check is the second one. The route + // layer will add a merged-state check when needed. + if ( + data.vaxa_stod_eligible === true && + 'vaxa_stod_start' in data && + !data.vaxa_stod_start + ) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: 'Startdatum för Växa-stöd måste anges när Växa-stöd är aktiverat', + path: ['vaxa_stod_start'], + }) + } + if ( + data.vaxa_stod_start && + data.vaxa_stod_end && + data.vaxa_stod_end < data.vaxa_stod_start + ) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: 'Växa-stödets slutdatum måste vara efter startdatumet', + path: ['vaxa_stod_end'], + }) + } }) export const EmployeeBenefitTypeSchema = z.enum(['bike', 'car', 'meals', 'housing', 'wellness', 'other']) diff --git a/lib/api/v1/load-routes.ts b/lib/api/v1/load-routes.ts index 79bdca4a..126fcbbd 100644 --- a/lib/api/v1/load-routes.ts +++ b/lib/api/v1/load-routes.ts @@ -82,12 +82,19 @@ import '@/app/api/v1/companies/[companyId]/supplier-invoices/[id]/mark-paid/rout import '@/app/api/v1/companies/[companyId]/supplier-invoices/[id]/credit/route' // Phase 5 PR-1 — Payroll registers: employees + salary-runs CRUD. -// Lifecycle verbs (calculate / approve / mark-paid / book / generate-agi) -// ship in Phase 5 PR-2 after the internal /calculate orchestration is -// extracted into a shared lib/salary/run-calculation.ts helper. import '@/app/api/v1/companies/[companyId]/employees/route' import '@/app/api/v1/companies/[companyId]/employees/[id]/route' import '@/app/api/v1/companies/[companyId]/salary-runs/route' import '@/app/api/v1/companies/[companyId]/salary-runs/[id]/route' +// Phase 5 PR-2 — Payroll lifecycle verbs. The /calculate orchestration was +// extracted into lib/salary/run-calculation.ts; the AGI orchestration into +// lib/salary/agi/generate-declaration.ts. Both the internal dashboard +// routes and these v1 routes call the same helpers. +import '@/app/api/v1/companies/[companyId]/salary-runs/[id]/calculate/route' +import '@/app/api/v1/companies/[companyId]/salary-runs/[id]/approve/route' +import '@/app/api/v1/companies/[companyId]/salary-runs/[id]/mark-paid/route' +import '@/app/api/v1/companies/[companyId]/salary-runs/[id]/book/route' +import '@/app/api/v1/companies/[companyId]/salary-runs/[id]/generate-agi/route' + export {} diff --git a/lib/errors/structured-errors.ts b/lib/errors/structured-errors.ts index f11bfa64..7678accd 100644 --- a/lib/errors/structured-errors.ts +++ b/lib/errors/structured-errors.ts @@ -1370,6 +1370,24 @@ const SALARY: Record = { message_sv: 'AGI kan endast genereras för lönekörningar i status review, approved, paid, booked eller corrected.', message_en: 'AGI can only be generated for salary runs in review, approved, paid, booked, or corrected status.', }, + AGI_INCOMPLETE_DATA: { + httpStatus: 400, + message_sv: 'AGI-data ofullständig — kontrollera att företaget har organisationsnummer, kontaktnamn, telefon och e-post.', + message_en: 'AGI data is incomplete — verify the company has org number, contact name, phone, and email.', + }, + COMPANY_NOT_FOUND: { + httpStatus: 404, + message_sv: 'Företaget kunde inte hittas.', + message_en: 'Company not found.', + }, + // Phase 5 PR-1 carry-over: distinct error code for the salary-run DELETE + // FK-null guard so an operator seeing this in logs knows a journal entry + // is at risk, not just a status race. + SALARY_RUN_DELETE_HAS_JOURNAL_ENTRY: { + httpStatus: 400, + message_sv: 'Lönekörningen är kopplad till en verifikation och kan inte raderas (BFL 5 kap räkenskapsinformation).', + message_en: 'Salary run is linked to a journal entry and cannot be deleted (BFL 5 kap räkenskapsinformation).', + }, } const COMPANY: Record = { diff --git a/lib/salary/agi/generate-declaration.ts b/lib/salary/agi/generate-declaration.ts new file mode 100644 index 00000000..3f9225b1 --- /dev/null +++ b/lib/salary/agi/generate-declaration.ts @@ -0,0 +1,480 @@ +/** + * Shared AGI XML generation + persistence orchestration. + * + * Both the internal dashboard route (`GET /api/salary/runs/{id}/agi/xml`) + * and the v1 public route (`POST /api/v1/companies/{companyId}/salary-runs/{id}/generate-agi`) + * call this helper. It loads the salary run + employees + per-day absence + * records, builds the Skatteverket AGI XML, upserts the agi_declarations + * row (correction-aware), updates `salary_runs.agi_generated_at`, emits + * `agi.generated`, and auto-completes the `arbetsgivardeklaration` deadline + * for the period. + * + * Returns a discriminated result so callers can wrap it in their own + * response envelope (internal uses raw `Response`; v1 uses the JSON `ok` + * envelope with `xml` embedded as a string field). + * + * Per agi-filing.md: + * - FK570 (specifikationsnummer) MUST stay consistent per employee + * - Corrections resubmit with same FK570 — a different number = a new record + * - XML is räkenskapsinformation; stored for 7-year retention per BFL 7 kap + * - Filing deadline: the 12th of the following month (17th in Jan/Aug for + * companies ≤ 40 MSEK turnover) + */ + +import type { SupabaseClient } from '@supabase/supabase-js' +import { + generateAGIXml, + buildIndividuppgifterSnapshot, + AGIIncompleteDataError, +} from './xml-generator' +import type { AGIEmployeeData, AGICompanyData, AGITotals } from './xml-generator' +import { eventBus } from '@/lib/events' +import type { Logger } from '@/lib/logger' + +const ELIGIBLE_STATUSES = ['review', 'approved', 'paid', 'booked', 'corrected'] as const + +export interface GenerateAgiDeclarationArgs { + supabase: SupabaseClient + companyId: string + userId: string + /** Falls back into AGI contactEmail when company_settings + profile both have none. */ + userEmail: string | null + salaryRunId: string + log: Logger + requestId: string +} + +export type GenerateAgiDeclarationResult = + | { + ok: true + xml: string + agiDeclarationId: string + periodYear: number + periodMonth: number + employeeCount: number + isCorrection: boolean + totals: AGITotals + orgNumber: string + } + | { + ok: false + code: string + details?: unknown + status?: number + } + +function sumLineItemAmounts( + lineItems: Array>, + types: string[], +): number { + return lineItems + .filter((li) => types.includes(li.item_type as string)) + .reduce((sum, li) => sum + ((li.amount as number) || 0), 0) +} + +export async function generateAgiDeclaration( + args: GenerateAgiDeclarationArgs, +): Promise { + const { supabase, companyId, userId, userEmail, salaryRunId, log, requestId } = args + const opLog = log.child({ salaryRunId }) + + // 1. Run + status precheck. + const { data: run, error: runError } = await supabase + .from('salary_runs') + .select('*') + .eq('id', salaryRunId) + .eq('company_id', companyId) + .single() + + if (runError || !run) { + return { ok: false, code: 'SALARY_RUN_NOT_FOUND' } + } + if (!ELIGIBLE_STATUSES.includes((run.status as typeof ELIGIBLE_STATUSES[number]))) { + return { + ok: false, + code: 'AGI_GENERATE_NOT_BOOKABLE', + details: { current_status: run.status, eligible_statuses: ELIGIBLE_STATUSES }, + } + } + + // 2. Company + settings + profile (for contact info). + const { data: company } = await supabase + .from('companies') + .select('name, org_number') + .eq('id', companyId) + .single() + + if (!company) { + return { ok: false, code: 'COMPANY_NOT_FOUND' } + } + + const { data: settings } = await supabase + .from('company_settings') + .select('org_number, phone, email') + .eq('company_id', companyId) + .single() + + const { data: profile } = await supabase + .from('profiles') + .select('full_name, email') + .eq('id', userId) + .single() + + // 3. Roster + line items + per-day absence. + const { data: runEmployees } = await supabase + .from('salary_run_employees') + .select( + '*, employee:employees(personnummer, specification_number, f_skatt_status, monthly_salary), line_items:salary_line_items(*)', + ) + .eq('salary_run_id', salaryRunId) + + if (!runEmployees || runEmployees.length === 0) { + return { ok: false, code: 'SALARY_RUN_NO_EMPLOYEES' } + } + + // 4. Build AGI input shapes. + const companyData: AGICompanyData = { + orgNumber: (settings?.org_number || company.org_number || '').trim(), + companyName: company.name, + periodYear: run.period_year, + periodMonth: run.period_month, + contactName: (profile?.full_name || company.name || '').trim(), + contactPhone: (settings?.phone || '').trim(), + contactEmail: (settings?.email || profile?.email || userEmail || '').trim(), + } + + // Load per-day absence (VAB + parental only — sick days go to FK separately). + const periodStart = `${run.period_year}-${String(run.period_month).padStart(2, '0')}-01` + const periodEndDate = new Date(Date.UTC(run.period_year, run.period_month, 0)) + const periodEnd = periodEndDate.toISOString().slice(0, 10) + const employeeIds = (runEmployees as Array<{ employee_id: string }>) + .map((sre) => sre.employee_id) + .filter(Boolean) + + const absenceByEmployee = new Map< + string, + Array<{ date: string; type: 'vab' | 'parental'; hours: number }> + >() + if (employeeIds.length > 0) { + const { data: absenceRows } = await supabase + .from('salary_absence_days') + .select('employee_id, absence_date, absence_type, hours') + .eq('company_id', companyId) + .in('absence_type', ['vab', 'parental']) + .gte('absence_date', periodStart) + .lte('absence_date', periodEnd) + .in('employee_id', employeeIds) + for (const row of (absenceRows ?? []) as Array<{ + employee_id: string + absence_date: string + absence_type: 'vab' | 'parental' + hours: number + }>) { + const list = absenceByEmployee.get(row.employee_id) ?? [] + list.push({ + date: row.absence_date, + type: row.absence_type, + hours: Number(row.hours ?? 8), + }) + absenceByEmployee.set(row.employee_id, list) + } + } + + const employeeData: AGIEmployeeData[] = (runEmployees as Array>).map( + (sre) => { + const emp = sre.employee as { + personnummer: string + specification_number: number + f_skatt_status: string + } | null + const lineItems = (sre.line_items || []) as Array> + + const benefitCar = sumLineItemAmounts(lineItems, ['benefit_car']) + const benefitMeals = sumLineItemAmounts(lineItems, ['benefit_meals']) + const benefitHousing = sumLineItemAmounts(lineItems, ['benefit_housing']) + const benefitOther = sumLineItemAmounts(lineItems, ['benefit_wellness', 'benefit_other']) + const absenceEvents = absenceByEmployee.get(sre.employee_id as string) + + return { + personnummer: emp?.personnummer || '', + specificationNumber: emp?.specification_number || 0, + grossSalary: sre.gross_salary as number, + taxWithheld: sre.tax_withheld as number, + avgifterBasis: sre.avgifter_basis as number, + fSkattPayment: + emp?.f_skatt_status === 'f_skatt' ? (sre.gross_salary as number) : undefined, + benefitCar: benefitCar > 0 ? benefitCar : undefined, + benefitHousing: benefitHousing > 0 ? benefitHousing : undefined, + benefitMeals: benefitMeals > 0 ? benefitMeals : undefined, + benefitOther: benefitOther > 0 ? benefitOther : undefined, + sickDays: (sre.sick_days as number) > 0 ? (sre.sick_days as number) : undefined, + vabDays: (sre.vab_days as number) > 0 ? (sre.vab_days as number) : undefined, + parentalDays: + (sre.parental_days as number) > 0 ? (sre.parental_days as number) : undefined, + absenceEvents: absenceEvents && absenceEvents.length > 0 ? absenceEvents : undefined, + } + }, + ) + + // 5. Build totals: avgifter by category (with rate-heuristic fallback for legacy runs). + const avgifterByCategory: AGITotals['avgifterByCategory'] = {} + for (const sre of runEmployees as Array>) { + const dbCategory = sre.avgifter_category as string | null + const category = dbCategory + ? dbCategory === 'reduced_65plus' + ? 'reduced65plus' + : dbCategory === 'vaxa_stod' + ? 'standard' + : dbCategory + : (sre.avgifter_rate as number) <= 0.1022 + ? 'reduced65plus' + : (sre.avgifter_rate as number) <= 0.2082 + ? 'youth' + : 'standard' + const cat = (avgifterByCategory as Record)[ + category + ] || { basis: 0, amount: 0 } + cat.basis += sre.avgifter_basis as number + cat.amount += sre.avgifter_amount as number + ;(avgifterByCategory as Record)[category] = cat + } + const totalAvgifterAmount = Object.values(avgifterByCategory).reduce( + (sum, cat) => sum + (cat?.amount ?? 0), + 0, + ) + + // FK499 sjuklönekostnad — sum of paid sjuklön (days 2-14) across all + // employees. Day 1 is karens (unpaid); day 15+ is Försäkringskassan. + const calcParams = ((run.calculation_params as Record) ?? {}) as { + sjuklonRate?: number + sjuklon_rate?: number + } + const sjuklonRate = calcParams.sjuklonRate ?? calcParams.sjuklon_rate ?? 0.8 + let totalSjuklonekostnad = 0 + for (const sre of runEmployees as Array>) { + const monthly = + ((sre.employee as { monthly_salary?: number } | null)?.monthly_salary as number) ?? 0 + if (!monthly) continue + const dailyRate = monthly / 21 + const lineItems = (sre.line_items || []) as Array> + for (const li of lineItems) { + if (li.item_type === 'sick_day2_14') { + const days = (li.quantity as number) || 0 + totalSjuklonekostnad += dailyRate * sjuklonRate * days + } + } + } + + const totals: AGITotals = { + totalTax: run.total_tax, + totalAvgifterBasis: (runEmployees as Array<{ avgifter_basis: number }>).reduce( + (s, e) => s + e.avgifter_basis, + 0, + ), + totalAvgifterAmount: Math.round(totalAvgifterAmount * 100) / 100, + totalSjuklonekostnad: Math.round(totalSjuklonekostnad * 100) / 100, + avgifterByCategory, + } + + // 6. Existing AGI determines correction status. Use `.maybeSingle()` + // because the lookup must tolerate the no-row case without throwing — + // that's the FIRST-time generation path. `.single()` would surface a + // PGRST116 row-not-found error and abort what should be a clean insert. + const { data: existingAgi } = await supabase + .from('agi_declarations') + .select('id') + .eq('company_id', companyId) + .eq('period_year', run.period_year) + .eq('period_month', run.period_month) + .maybeSingle() + + const isCorrection = !!existingAgi + + // 7. Generate XML. + let xml: string + try { + xml = generateAGIXml(companyData, employeeData, totals, isCorrection) + } catch (err) { + if (err instanceof AGIIncompleteDataError) { + return { + ok: false, + code: 'AGI_INCOMPLETE_DATA', + details: { missing_fields: err.missingFields, message: err.message }, + } + } + throw err + } + const individuppgifter = buildIndividuppgifterSnapshot(employeeData) + + // 8. UPSERT agi_declarations. + let agiDeclarationId: string + if (existingAgi) { + const { error: updErr } = await supabase + .from('agi_declarations') + .update({ + xml_content: xml, + individuppgifter, + total_gross: run.total_gross, + total_tax: run.total_tax, + total_avgifter_basis: totals.totalAvgifterBasis, + // Use the per-category sum that drives the XML rather than the + // run-level denormalised total. Both should agree, but a + // round-then-sum vs sum-then-round can produce öre drift; the + // agi_declarations row should align with what was actually + // serialised into the XML (which Skatteverket sees). + total_avgifter: totals.totalAvgifterAmount, + employee_count: employeeData.length, + is_correction: true, + salary_run_id: run.id, + }) + .eq('id', existingAgi.id) + if (updErr) { + return { ok: false, code: 'DATABASE_ERROR', details: updErr } + } + agiDeclarationId = existingAgi.id as string + } else { + const { data: inserted, error: insErr } = await supabase + .from('agi_declarations') + .insert({ + company_id: companyId, + user_id: userId, + salary_run_id: run.id, + period_year: run.period_year, + period_month: run.period_month, + xml_content: xml, + individuppgifter, + total_gross: run.total_gross, + total_tax: run.total_tax, + total_avgifter_basis: totals.totalAvgifterBasis, + // Use the per-category sum that drives the XML rather than the + // run-level denormalised total. Both should agree, but a + // round-then-sum vs sum-then-round can produce öre drift; the + // agi_declarations row should align with what was actually + // serialised into the XML (which Skatteverket sees). + total_avgifter: totals.totalAvgifterAmount, + employee_count: employeeData.length, + }) + .select('id') + .single() + + if (insErr) { + // Concurrent-call race: two :generate-agi requests for the same + // (company, period) reached the INSERT branch simultaneously. The + // earlier read of `existingAgi` returned null for both, but the + // first INSERT wins and the second hits the unique constraint. + // Postgres error 23505 is the unique-violation code; recover by + // re-fetching the now-existing row and treating this call as a + // correction (the second caller's XML supersedes the first). + if ((insErr as { code?: string }).code === '23505') { + const { data: nowExisting, error: refetchErr } = await supabase + .from('agi_declarations') + .select('id') + .eq('company_id', companyId) + .eq('period_year', run.period_year) + .eq('period_month', run.period_month) + .maybeSingle() + if (refetchErr || !nowExisting) { + return { ok: false, code: 'DATABASE_ERROR', details: refetchErr || insErr } + } + const { error: raceUpdErr } = await supabase + .from('agi_declarations') + .update({ + xml_content: xml, + individuppgifter, + total_gross: run.total_gross, + total_tax: run.total_tax, + total_avgifter_basis: totals.totalAvgifterBasis, + // Use the per-category sum that drives the XML rather than the + // run-level denormalised total. Both should agree, but a + // round-then-sum vs sum-then-round can produce öre drift; the + // agi_declarations row should align with what was actually + // serialised into the XML (which Skatteverket sees). + total_avgifter: totals.totalAvgifterAmount, + employee_count: employeeData.length, + is_correction: true, + salary_run_id: run.id, + }) + .eq('id', nowExisting.id) + if (raceUpdErr) { + return { ok: false, code: 'DATABASE_ERROR', details: raceUpdErr } + } + agiDeclarationId = nowExisting.id as string + opLog.warn('agi_declarations insert raced; recovered via update', { + companyId, + periodYear: run.period_year, + periodMonth: run.period_month, + }) + // Note: the caller-facing `isCorrection` flag (set above based on + // the pre-INSERT existingAgi lookup) reports `false` even though + // the database state is now technically a correction. Edge case + // limited to the race window; the agi_declarations row is + // correctly marked is_correction=true and the next call will + // see it. + } else { + return { ok: false, code: 'DATABASE_ERROR', details: insErr } + } + } else if (!inserted) { + return { ok: false, code: 'DATABASE_ERROR', details: insErr } + } else { + agiDeclarationId = inserted.id as string + } + } + + // 9. Stamp generation timestamp on salary_runs. + await supabase + .from('salary_runs') + .update({ agi_generated_at: new Date().toISOString() }) + .eq('id', salaryRunId) + + // 10. Emit agi.generated (best-effort — never block the success path). + try { + await eventBus.emit({ + type: 'agi.generated', + payload: { + agiId: agiDeclarationId, + periodYear: run.period_year, + periodMonth: run.period_month, + userId, + companyId, + }, + }) + } catch (err) { + opLog.warn('agi.generated emit failed', err as Error) + } + + // 11. Auto-complete the arbetsgivardeklaration deadline for this period + // (Skatteförfarandelagen — AGI generation satisfies the filing + // obligation). Optimistic-lock on status='pending'. + const period = `${run.period_year}-${String(run.period_month).padStart(2, '0')}` + await supabase + .from('deadlines') + .update({ + status: 'completed', + completed_at: new Date().toISOString(), + completed_by: userId, + }) + .eq('company_id', companyId) + .eq('type', 'arbetsgivardeklaration') + .eq('period', period) + .eq('status', 'pending') + + opLog.info('AGI declaration generated', { + requestId, + salaryRunId, + agiDeclarationId, + isCorrection, + employeeCount: employeeData.length, + }) + + return { + ok: true, + xml, + agiDeclarationId, + periodYear: run.period_year, + periodMonth: run.period_month, + employeeCount: employeeData.length, + isCorrection, + totals, + orgNumber: companyData.orgNumber, + } +} diff --git a/lib/salary/run-calculation.ts b/lib/salary/run-calculation.ts new file mode 100644 index 00000000..8c162399 --- /dev/null +++ b/lib/salary/run-calculation.ts @@ -0,0 +1,634 @@ +/** + * Shared salary-calculation orchestration. + * + * Both the internal dashboard route (`POST /api/salary/runs/{id}/calculate`) + * and the v1 public route (`POST /api/v1/companies/{companyId}/salary-runs/{id}/calculate`) + * call this helper. It performs every side effect the dashboard's calculate + * step did: load config + employees + tax tables, derive absence / benefits + * / worked-hours, run the engine per employee, write line items + run-employee + * results + run totals + calculation_params. + * + * The function returns a discriminated result rather than a NextResponse so + * either caller can wrap it in their own response envelope (internal uses + * `errorResponseFromCode`; v1 uses `v1ErrorResponseFromCode`). + * + * Strict-mode: the function aborts at the FIRST per-employee failure. There + * is no partial-state recovery — either every employee succeeds and the run + * gets its aggregated totals + updated row, or the caller receives an error + * and the run remains in `draft`. This matches the dashboard's behaviour and + * is required for BFL 5 kap: a half-calculated run that later advances to + * `review` would post a wrong verifikation when `:book` runs. + * + * The function does NOT advance the salary_runs status. That's the route's + * responsibility — the dashboard leaves the run in `draft` (an explicit + * `/review` verb does the freeze), while v1 collapses calculate+review into + * a single verb. Routes layer the status transition on top of this result. + */ + +import type { SupabaseClient } from '@supabase/supabase-js' +import { calculateSalary } from './calculation-engine' +import { loadPayrollConfig, serializePayrollConfig } from './payroll-config' +import { fetchAllTaxTableRatesForRun, TaxTableUnavailableError } from './tax-tables' +import { loadAndDeriveAbsence } from './derive-absence-line-items' +import { getLineItemAccount } from './account-mapping' +import type { Logger } from '@/lib/logger' +import type { SalaryLineItemType } from '@/types' + +/** Item types that the calculator derives from per-day absence records. */ +const DERIVED_ABSENCE_TYPES: SalaryLineItemType[] = [ + 'sick_karens', + 'sick_day2_14', + 'sick_day15_plus', + 'vab', + 'parental_leave', +] + +/** Benefit-type → line-item-type mapping for the derived benefit rows. */ +const BENEFIT_TYPE_TO_LINE_ITEM: Record = { + bike: 'benefit_bike', + car: 'benefit_car', + meals: 'benefit_meals', + housing: 'benefit_housing', + wellness: 'benefit_wellness', + other: 'benefit_other', +} + +export interface RunSalaryCalculationArgs { + supabase: SupabaseClient + companyId: string + salaryRunId: string + log: Logger + requestId: string +} + +export type RunSalaryCalculationResult = + | { ok: true; run: Record; warnings: string[] } + | { ok: false; code: string; details?: unknown; status?: number } + +/** + * Run the per-employee calculation for a salary run. + * + * Preconditions enforced inside: + * - salary_runs row exists, is owned by `companyId`, and is in `draft` status + * - at least one salary_run_employee row exists for the run + * - every employee has a valid salary amount + tax configuration + * - every needed tax table is fetchable from Skatteverket (or local fallback) + * + * Returns the updated salary_runs row + warnings on success. Returns a + * structured `{ ok: false; code; details? }` on any failure. The caller is + * responsible for converting that to its response envelope. + */ +export async function runSalaryCalculation( + args: RunSalaryCalculationArgs, +): Promise { + const { supabase, companyId, salaryRunId: id, log, requestId } = args + const opLog = log.child({ salaryRunId: id }) + + // 1. Precondition: run exists, owned by company, is in draft status. + const { data: run, error: runError } = await supabase + .from('salary_runs') + .select('*') + .eq('id', id) + .eq('company_id', companyId) + .single() + + if (runError || !run) { + return { ok: false, code: 'SALARY_RUN_NOT_FOUND' } + } + if (run.status !== 'draft') { + return { + ok: false, + code: 'SALARY_RUN_CALCULATE_FAILED', + details: { currentStatus: run.status, reason: 'not_draft' }, + } + } + + const paymentYear = parseInt(run.payment_date.split('-')[0]) + + // 2. Load year config. + const config = await loadPayrollConfig(supabase, paymentYear) + + // 3. Load roster — `salary_run_employees` joined with employees + line items. + // Defense-in-depth: filter by company_id too even though salary_run_id is a + // foreign key. RLS already constrains the table per-company, but per + // CLAUDE.md every query carries the company_id filter explicitly so a + // future RLS lapse can't surface cross-tenant rows. + const { data: runEmployees, error: empError } = await supabase + .from('salary_run_employees') + .select('*, employee:employees(*), line_items:salary_line_items(*)') + .eq('salary_run_id', id) + .eq('company_id', companyId) + + if (empError || !runEmployees || runEmployees.length === 0) { + return { ok: false, code: 'SALARY_RUN_NO_EMPLOYEES' } + } + + // 4. Pre-calculation validation — ensure every employee has the data the + // engine needs. We accumulate ALL errors so the caller sees a complete + // list rather than fixing one and discovering the next on the retry. + const validationErrors: string[] = [] + for (const sre of runEmployees) { + const emp = sre.employee + if (!emp) continue + const name = `${emp.first_name} ${emp.last_name}` + + if (emp.salary_type === 'monthly' && (!emp.monthly_salary || emp.monthly_salary <= 0)) { + validationErrors.push(`${name}: Månadslön saknas eller är 0`) + } + if (emp.salary_type === 'hourly' && (!emp.hourly_rate || emp.hourly_rate <= 0)) { + validationErrors.push(`${name}: Timlön saknas eller är 0`) + } + if (emp.f_skatt_status === 'a_skatt' && !emp.is_sidoinkomst && !emp.tax_table_number) { + validationErrors.push(`${name}: Skattetabell saknas (krävs för A-skatt)`) + } + } + if (validationErrors.length > 0) { + return { + ok: false, + code: 'VALIDATION_ERROR', + details: { issues: validationErrors, reason: 'employee_data_incomplete' }, + } + } + + // 5. Fetch every needed tax table in one batch. The Skatteverket API has + // fallback to local data; if both fail TaxTableUnavailableError surfaces + // as a distinct retryable 503. + 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), + ), + ] + let taxRates: Awaited>['rates'] = [] + let taxTableSource: Awaited>['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 { + ok: false, + code: 'SALARY_RUN_TAX_TABLE_MISSING', + details: { reason: err.message, paymentYear, tableNumbers }, + status: 503, + } + } + throw err + } + } + + // 6. YTD aggregation across prior BOOKED runs in the same period_year. + // Drives the engine's progressive-tax + capped-avgift calculations. + const { data: priorRuns } = await supabase + .from('salary_run_employees') + .select( + 'employee_id, gross_salary, tax_withheld, net_salary, salary_run:salary_runs!inner(period_year, period_month, status)', + ) + .eq('company_id', companyId) + .eq('salary_run.period_year', run.period_year) + .eq('salary_run.status', 'booked') + .lt('salary_run.period_month', run.period_month) + + const ytdByEmployee = new Map() + for (const prior of (priorRuns || []) as Array<{ + employee_id: string + gross_salary: number + tax_withheld: number + net_salary: number + }>) { + const current = ytdByEmployee.get(prior.employee_id) || { gross: 0, tax: 0, net: 0 } + current.gross += prior.gross_salary + current.tax += prior.tax_withheld + current.net += prior.net_salary + ytdByEmployee.set(prior.employee_id, current) + } + + // 7. Pay period bounds — used to load per-day absence + worked-day records. + const periodYear = run.period_year as number + const periodMonth = run.period_month as number + const periodStart = `${periodYear}-${String(periodMonth).padStart(2, '0')}-01` + const periodEndDate = new Date(Date.UTC(periodYear, periodMonth, 0)) // last day of month + const periodEnd = periodEndDate.toISOString().slice(0, 10) + + // Per-run aggregates collected during the loop. + let totalGross = 0 + let totalTax = 0 + let totalNet = 0 + let totalAvgifter = 0 + let totalVacationAccrual = 0 + let totalEmployerCost = 0 + + // Surfaced as warnings — UI / agent shows alongside the successful + // calculation, not an error. + const lakarintygEmployees: string[] = [] + const fkReportingEmployees: string[] = [] + + // 8. Per-employee calculation loop. + for (const sre of runEmployees) { + const emp = sre.employee + if (!emp) continue + + // 8a. Derive absence line items from per-day records. + const absenceResult = await loadAndDeriveAbsence({ + supabase, + companyId, + employeeId: emp.id, + monthlySalary: emp.monthly_salary || 0, + payrollConfig: config, + periodStart, + periodEnd, + }) + + // 8b. For hourly employees, derive worked hours from the calendar. + let derivedHoursWorked: number | null = null + if (emp.salary_type === 'hourly') { + const { data: workedDays, error: workedError } = await supabase + .from('salary_worked_days') + .select('hours') + .eq('company_id', companyId) + .eq('employee_id', emp.id) + .gte('work_date', periodStart) + .lte('work_date', periodEnd) + if (workedError) { + return { ok: false, code: 'DATABASE_ERROR', details: workedError } + } + derivedHoursWorked = (workedDays ?? []).reduce( + (sum, d) => Math.round((sum + Number(d.hours)) * 100) / 100, + 0, + ) + opLog.info('Derived hours_worked from calendar', { + employeeId: emp.id, + periodStart, + periodEnd, + rowCount: workedDays?.length ?? 0, + derivedHoursWorked, + }) + + // Refresh the hourly_salary line item so the displayed Lönerader table + // matches what the engine actually calculated. + if (derivedHoursWorked > 0 && (emp.hourly_rate || 0) > 0) { + const baseAmount = + Math.round((emp.hourly_rate as number) * derivedHoursWorked * 100) / 100 + await supabase + .from('salary_line_items') + .delete() + .eq('salary_run_employee_id', sre.id) + .eq('item_type', 'hourly_salary') + await supabase.from('salary_line_items').insert({ + salary_run_employee_id: sre.id, + company_id: companyId, + item_type: 'hourly_salary', + description: 'Timlön', + quantity: derivedHoursWorked, + amount: baseAmount, + is_taxable: true, + is_avgift_basis: true, + is_vacation_basis: true, + is_gross_deduction: false, + is_net_deduction: false, + account_number: getLineItemAccount('hourly_salary'), + sort_order: 0, + }) + } + } + + const employeeName = `${emp.first_name} ${emp.last_name}` + if (absenceResult.flagLakarintyg) lakarintygEmployees.push(employeeName) + if (absenceResult.flagFkReporting) fkReportingEmployees.push(employeeName) + + // 8c. Replace derived absence rows. + const { error: delAbsErr } = await supabase + .from('salary_line_items') + .delete() + .eq('salary_run_employee_id', sre.id) + .in('item_type', DERIVED_ABSENCE_TYPES) + if (delAbsErr) { + return { ok: false, code: 'DATABASE_ERROR', details: delAbsErr } + } + + // 8d. Derive benefit line items from employee_benefits. + const { data: activeBenefits, error: benefitsErr } = await supabase + .from('employee_benefits') + .select('id, benefit_type, description, monthly_value') + .eq('employee_id', emp.id) + .eq('company_id', companyId) + .eq('is_active', true) + .lte('valid_from', run.payment_date) + .or(`valid_to.is.null,valid_to.gte.${run.payment_date}`) + if (benefitsErr) { + return { ok: false, code: 'DATABASE_ERROR', details: benefitsErr } + } + + const { error: delBenefitErr } = await supabase + .from('salary_line_items') + .delete() + .eq('salary_run_employee_id', sre.id) + .not('source_benefit_id', 'is', null) + if (delBenefitErr) { + return { ok: false, code: 'DATABASE_ERROR', details: delBenefitErr } + } + + const derivedBenefitRows = (activeBenefits ?? []) + .filter((b) => b.monthly_value > 0) + .map((b, idx) => { + const itemType = BENEFIT_TYPE_TO_LINE_ITEM[b.benefit_type] ?? 'benefit_other' + return { + salary_run_employee_id: sre.id, + company_id: companyId, + item_type: itemType, + description: b.description, + quantity: 1, + amount: Math.round(b.monthly_value * 100) / 100, + is_taxable: true, + is_avgift_basis: true, + is_vacation_basis: false, + is_gross_deduction: false, + is_net_deduction: false, + account_number: getLineItemAccount(itemType, emp.employment_type), + sort_order: 200 + idx, + source_benefit_id: b.id, + } + }) + + if (derivedBenefitRows.length > 0) { + const { error: insBenefitErr } = await supabase + .from('salary_line_items') + .insert(derivedBenefitRows) + if (insBenefitErr) { + return { ok: false, code: 'DATABASE_ERROR', details: insBenefitErr } + } + } + + if (absenceResult.lineItems.length > 0) { + const rows = absenceResult.lineItems.map((li, idx) => ({ + salary_run_employee_id: sre.id, + company_id: companyId, + item_type: li.item_type, + description: li.description, + quantity: li.quantity, + amount: Math.round(li.amount * 100) / 100, + is_taxable: li.is_taxable, + is_avgift_basis: li.is_avgift_basis, + is_vacation_basis: li.is_vacation_basis, + is_gross_deduction: li.is_gross_deduction, + is_net_deduction: false, + account_number: getLineItemAccount(li.item_type), + sort_order: 100 + idx, + })) + const { error: insAbsErr } = await supabase.from('salary_line_items').insert(rows) + if (insAbsErr) { + return { ok: false, code: 'DATABASE_ERROR', details: insAbsErr } + } + } + + // 8e. Assemble the in-memory line item set fed to calculateSalary. + const manualLineItems = (sre.line_items || []) + .filter((li: Record) => { + if (DERIVED_ABSENCE_TYPES.includes(li.item_type as SalaryLineItemType)) return false + if (li.source_benefit_id) return false + if (li.item_type === 'semesterersattning') return false + return true + }) + .map((li: Record) => ({ + itemType: li.item_type as SalaryLineItemType, + amount: li.amount as number, + isTaxable: li.is_taxable as boolean, + isAvgiftBasis: li.is_avgift_basis as boolean, + isVacationBasis: li.is_vacation_basis as boolean, + isGrossDeduction: li.is_gross_deduction as boolean, + isNetDeduction: li.is_net_deduction as boolean, + })) + const derivedLineItems = absenceResult.lineItems.map((li) => ({ + itemType: li.item_type as SalaryLineItemType, + amount: li.amount, + isTaxable: li.is_taxable, + isAvgiftBasis: li.is_avgift_basis, + isVacationBasis: li.is_vacation_basis, + isGrossDeduction: li.is_gross_deduction, + isNetDeduction: false, + })) + const derivedBenefitLineItems = derivedBenefitRows.map((row) => ({ + itemType: row.item_type as SalaryLineItemType, + amount: row.amount, + isTaxable: true, + isAvgiftBasis: true, + isVacationBasis: false, + isGrossDeduction: false, + isNetDeduction: false, + })) + const lineItems = [...manualLineItems, ...derivedLineItems, ...derivedBenefitLineItems] + + // 8f. Run the engine for this employee. + const result = calculateSalary( + { + employmentType: emp.employment_type, + salaryType: emp.salary_type, + monthlySalary: emp.monthly_salary || 0, + hourlyRate: emp.hourly_rate || undefined, + hoursWorked: + derivedHoursWorked !== null && derivedHoursWorked > 0 + ? derivedHoursWorked + : sre.hours_worked || undefined, + employmentDegree: emp.employment_degree, + taxTableNumber: emp.tax_table_number, + taxColumn: emp.tax_column || 1, + isSidoinkomst: emp.is_sidoinkomst, + jamkningPercentage: emp.jamkning_percentage, + jamkningValidFrom: emp.jamkning_valid_from, + jamkningValidTo: emp.jamkning_valid_to, + fSkattStatus: emp.f_skatt_status, + personnummer: emp.personnummer, + paymentDate: run.payment_date, + vacationRule: emp.vacation_rule, + vacationDaysPerYear: emp.vacation_days_per_year, + semestertillaggRate: emp.semestertillagg_rate, + vaxaStodEligible: emp.vaxa_stod_eligible, + vaxaStodStart: emp.vaxa_stod_start, + vaxaStodEnd: emp.vaxa_stod_end, + lineItems, + }, + config, + taxRates.map((r) => ({ + tableYear: r.tableYear, + tableNumber: r.tableNumber, + columnNumber: r.columnNumber, + incomeFrom: r.incomeFrom, + incomeTo: r.incomeTo, + taxAmount: r.taxAmount, + })), + ) + + // Aggregated absence counts derived from per-day records. + const sickDays = absenceResult.aggregated.sickDays + const vabDays = absenceResult.aggregated.vabDays + const parentalDays = absenceResult.aggregated.parentalDays + const vacationDays = (sre.line_items || []) + .filter((li: Record) => li.item_type === 'vacation') + .reduce( + (sum: number, li: Record) => sum + ((li.quantity as number) || 0), + 0, + ) + + // 8g. Write the per-employee row. Mirrors calendar-derived hours into the + // hours_worked snapshot column so downstream code (reports, storno via + // correct/route) sees a consistent value. + const snapshotHoursWorked = + derivedHoursWorked !== null && derivedHoursWorked > 0 + ? derivedHoursWorked + : sre.hours_worked + const { error: empUpdateError } = await supabase + .from('salary_run_employees') + .update({ + hours_worked: snapshotHoursWorked, + gross_salary: result.grossSalary, + gross_deductions: result.grossDeductions, + benefit_values: result.benefitValues, + taxable_income: result.taxableIncome, + tax_withheld: result.taxWithheld, + net_deductions: result.netDeductions, + net_salary: result.netSalary, + avgifter_rate: result.avgifterRate, + avgifter_amount: result.avgifterAmount, + avgifter_basis: result.avgifterBasis, + avgifter_category: result.avgifterCategory, + vacation_accrual: result.vacationAccrual, + vacation_accrual_avgifter: result.vacationAccrualAvgifter, + tax_table_number: emp.tax_table_number, + tax_column: emp.tax_column, + tax_table_year: paymentYear, + sick_days: sickDays, + vab_days: vabDays, + parental_days: parentalDays, + vacation_days_taken: vacationDays, + calculation_breakdown: { steps: result.steps }, + ytd_gross: + Math.round( + ((ytdByEmployee.get(sre.employee_id)?.gross || 0) + result.grossSalary) * 100, + ) / 100, + ytd_tax: + Math.round( + ((ytdByEmployee.get(sre.employee_id)?.tax || 0) + result.taxWithheld) * 100, + ) / 100, + ytd_net: + Math.round( + ((ytdByEmployee.get(sre.employee_id)?.net || 0) + result.netSalary) * 100, + ) / 100, + }) + .eq('id', sre.id) + + if (empUpdateError) { + return { ok: false, code: 'DATABASE_ERROR', details: empUpdateError } + } + + // 8h. Replace any existing 'semesterersattning' line item (the engine + // derives it on every calculate). + const { error: delSemErr } = await supabase + .from('salary_line_items') + .delete() + .eq('salary_run_employee_id', sre.id) + .eq('item_type', 'semesterersattning') + if (delSemErr) { + return { ok: false, code: 'DATABASE_ERROR', details: delSemErr } + } + if (result.vacationCompensation > 0) { + const { error: insSemErr } = await supabase.from('salary_line_items').insert({ + salary_run_employee_id: sre.id, + company_id: companyId, + item_type: 'semesterersattning', + description: 'Semesterersättning', + quantity: 1, + amount: Math.round(result.vacationCompensation * 100) / 100, + is_taxable: true, + is_avgift_basis: true, + is_vacation_basis: false, + is_gross_deduction: false, + is_net_deduction: false, + account_number: getLineItemAccount('semesterersattning', emp.employment_type), + sort_order: 50, + }) + if (insSemErr) { + return { ok: false, code: 'DATABASE_ERROR', details: insSemErr } + } + } + + totalGross += result.grossSalary + totalTax += result.taxWithheld + totalNet += result.netSalary + totalAvgifter += result.avgifterAmount + totalVacationAccrual += result.vacationAccrual + totalEmployerCost += result.totalEmployerCost + } + + // 9. Update run totals + freeze the calculation_params snapshot. + const { data: updatedRun, error: updateError } = await supabase + .from('salary_runs') + .update({ + total_gross: Math.round(totalGross * 100) / 100, + total_tax: Math.round(totalTax * 100) / 100, + total_net: Math.round(totalNet * 100) / 100, + total_avgifter: Math.round(totalAvgifter * 100) / 100, + total_vacation_accrual: Math.round(totalVacationAccrual * 100) / 100, + total_employer_cost: Math.round(totalEmployerCost * 100) / 100, + calculation_params: serializePayrollConfig(config), + }) + .eq('id', id) + // Defense-in-depth: scope the write to the company explicitly. The + // first SELECT confirmed `company_id = companyId` for this id, but the + // CLAUDE.md rule is that every write carries the filter so the + // intent is explicit at the SQL layer even if upstream code is later + // refactored. + .eq('company_id', companyId) + .select() + .single() + + if (updateError) { + return { ok: false, code: 'DATABASE_ERROR', details: updateError } + } + + // 10. Warnings — non-blocking annotations the caller should surface. + 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.`, + ) + } + if (lakarintygEmployees.length > 0) { + warnings.push( + `Läkarintyg krävs från och med dag 8: ${lakarintygEmployees.join(', ')}. ` + + `Kontrollera att läkarintyg finns innan lönekörningen godkänns.`, + ) + } + if (fkReportingEmployees.length > 0) { + warnings.push( + `Försäkringskassan tar över sjuklön från dag 15: ${fkReportingEmployees.join(', ')}. ` + + `Säkerställ att anmälan till FK är gjord.`, + ) + } + + opLog.info('salary calculation complete', { + requestId, + salaryRunId: id, + warningCount: warnings.length, + taxTableSource, + }) + + return { ok: true, run: updatedRun as Record, warnings } +}