Files
accounted/lib/salary/agi/generate-declaration.ts
T
Jakob Wennberg 97bbba323f fix(salary): report F-skatt compensation on FK131 only, not FK011 too (#1402)
* fix(salary): report F-skatt compensation on FK131 only, not FK011 too

An F-skatt payee's cash compensation was passed through as grossSalary
unconditionally while also being routed to fSkattPayment, so the same
payment was double-reported in the AGI individuppgift as both FK011
(KontantErsattningUlagAG, underlag for arbetsgivaravgifter) and FK131
(KontantErsattningEjUlagSA). Per Skatteverket's AGI spec these fields
are mutually exclusive for the same payment stream: F-skatt payments
form no underlag for arbetsgivaravgifter.

Fix at the data layer: generateAgiDeclaration now zeroes grossSalary
for f_skatt payees so FK011 is never emitted for that payment, while
fSkattPayment keeps carrying it to FK131. A generator-side guard was
deliberately not used because an IU can legitimately carry both fields
for genuinely mixed payments.

The empty-IU filter already keeps f_skatt rows via fSkattPayment, so no
individuppgift is dropped; FK487/avgifter totals were already correct
(calculation engine sets avgifter_basis 0 for f_skatt) and are covered
by a regression test. Both the dashboard route and the v1 public route
call this helper, so both are fixed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(salary): exclude f-skatt rows from avgifter override aggregation (#315)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-04 19:33:36 +02:00

707 lines
28 KiB
TypeScript

/**
* 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 { z } from 'zod'
import {
generateAGIXml,
buildIndividuppgifterSnapshot,
AGIIncompleteDataError,
AGIPayloadTooLargeError,
} from './xml-generator'
import type { AGIEmployeeData, AGICompanyData, AGITotals } from './xml-generator'
import { eventBus } from '@/lib/events'
import type { Logger } from '@/lib/logger'
// Strict runtime validation of the joined salary_run_employees row. Without
// this, columns added by recent migrations (removed_from_agi,
// benefits_adjusted, vaxa_stod_eligible, employment_start,
// housing_benefit_type) reaching the mapper as null/undefined would silently
// fall back to Boolean(undefined) = false and mis-emit regulatory flags.
// Zod produces an explicit error instead.
const EmployeeJoinSchema = z
.object({
personnummer: z.string().min(1, 'employee.personnummer saknas'),
specification_number: z.number().int().min(1, 'employee.specification_number måste vara ≥ 1'),
f_skatt_status: z.string(),
monthly_salary: z.number().nullable().optional(),
vaxa_stod_eligible: z.boolean().nullable().optional(),
employment_start: z.string().nullable().optional(),
housing_benefit_type: z.enum(['smahus', 'ej_smahus']).nullable().optional(),
})
.passthrough()
const LineItemSchema = z
.object({
item_type: z.string(),
amount: z.number().nullable().optional(),
quantity: z.number().nullable().optional(),
})
.passthrough()
const SalaryRunEmployeeRowSchema = z
.object({
employee_id: z.string().uuid(),
// Per-run snapshot of the monthly salary (authoritative for this run; the
// engine reads it, not the employee master). Used for the FK499
// sjuklönekostnad daily-rate below.
monthly_salary: z.number().nullable().optional(),
gross_salary: z.number(),
tax_withheld: z.number(),
tax_withheld_override: z.number().nullable().optional(),
avgifter_basis: z.number(),
avgifter_basis_override: z.number().nullable().optional(),
avgifter_amount: z.number(),
avgifter_amount_override: z.number().nullable().optional(),
avgifter_rate: z.number(),
avgifter_category: z.string().nullable().optional(),
removed_from_agi: z.boolean().nullable().optional(),
benefits_adjusted: z.boolean().nullable().optional(),
sick_days: z.number().nullable().optional(),
vab_days: z.number().nullable().optional(),
parental_days: z.number().nullable().optional(),
employee: EmployeeJoinSchema.nullable(),
line_items: z.array(LineItemSchema).nullable().optional(),
})
.passthrough()
type SalaryRunEmployeeRow = z.infer<typeof SalaryRunEmployeeRowSchema>
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<Record<string, unknown>>,
types: string[],
): number {
return lineItems
.filter((li) => types.includes(li.item_type as string))
.reduce((sum, li) => sum + ((li.amount as number) || 0), 0)
}
// Invariant: F-skatt compensation never contributes to the avgifter
// aggregates (per-IU basis, FK061-series categories, FK487, HU totals),
// overrides included. The calculation engine already stores
// avgifter_basis/avgifter_amount = 0 for these rows; a manual advanced-mode
// override must not resurrect them, or the filing would claim social charges
// on pay whose IU simultaneously asserts FK131 (not subject to them).
function isFSkattRow(sre: SalaryRunEmployeeRow): boolean {
return sre.employee?.f_skatt_status === 'f_skatt'
}
export async function generateAgiDeclaration(
args: GenerateAgiDeclarationArgs,
): Promise<GenerateAgiDeclarationResult> {
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('company_name, 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, vaxa_stod_eligible, employment_start, housing_benefit_type), line_items:salary_line_items(*)',
)
.eq('salary_run_id', salaryRunId)
// An empty roster is valid: a registered employer must file a
// nolldeklaration (HU-only, no individuppgifter) for months without payroll.
// Only a genuine query failure (null) is treated as an error here.
if (!runEmployees) {
return { ok: false, code: 'SALARY_RUN_NO_EMPLOYEES' }
}
// 4. Build AGI input shapes.
// Employer name on the arbetsgivardeklaration follows the current company
// name (company_settings.company_name), not the frozen onboarding companies.name.
const companyName = settings?.company_name || company.name
const companyData: AGICompanyData = {
orgNumber: (settings?.org_number || company.org_number || '').trim(),
companyName,
periodYear: run.period_year,
periodMonth: run.period_month,
contactName: (profile?.full_name || companyName || '').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
specifikationsnummer: number
}>
>()
if (employeeIds.length > 0) {
const { data: absenceRows } = await supabase
.from('salary_absence_days')
.select('employee_id, absence_date, absence_type, hours, franvaro_specifikationsnummer')
.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
franvaro_specifikationsnummer: number | null
}>) {
// Row should always have a number for vab/parental (trigger assigns
// on insert + backfill migration covers existing data). Defensive
// fallback: skip rows missing the number rather than emit a bogus 0,
// which would collide with Skatteverket's unique key.
if (row.franvaro_specifikationsnummer == null) continue
const list = absenceByEmployee.get(row.employee_id) ?? []
list.push({
date: row.absence_date,
type: row.absence_type,
hours: Number(row.hours ?? 8),
specifikationsnummer: row.franvaro_specifikationsnummer,
})
absenceByEmployee.set(row.employee_id, list)
}
}
// Validate the joined rows up-front so a malformed Supabase response
// (missing column, wrong type, null specification_number, …) surfaces as
// a clean AGIIncompleteDataError instead of silently emitting wrong
// flags later. See SalaryRunEmployeeRowSchema definition above.
const parsedRows: SalaryRunEmployeeRow[] = (runEmployees as unknown[]).map((raw, idx) => {
const parsed = SalaryRunEmployeeRowSchema.safeParse(raw)
if (!parsed.success) {
const fields = parsed.error.issues.map((iss) => iss.path.join('.')).join(', ')
throw new AGIIncompleteDataError(
`salary_run_employees rad ${idx} har ogiltig form (saknar/felaktiga fält: ${fields}). ` +
'Detta blockerar AGI-generering eftersom Skatteverket annars skulle få bogus värden ' +
'(till exempel emitterade flaggor eller specifikationsnummer = 0).',
['salary_run_employees'],
)
}
return parsed.data
})
// Cutoff for the Växa-stöd FK062/FK063 split: pre-2024-05-01 hires get the
// legacy "första anställda"-flag (FK062); 2024-05-01 and later get the
// utvidgat växa-stöd flag (FK063). Cutoff from Skatteverket spec (Prop.
// 2023/24:80, RAML revisionshistorik 1.19).
const VAXA_STOD_FK063_CUTOFF = '2024-05-01'
const employeeData: AGIEmployeeData[] = parsedRows.map((sre) => {
const emp = sre.employee
const lineItems = (sre.line_items ?? []) as Array<{ item_type: string; amount?: number | null; quantity?: number | null }>
const benefitCar = sumLineItemAmounts(lineItems, ['benefit_car'])
const benefitFuel = sumLineItemAmounts(lineItems, ['benefit_fuel'])
const benefitHousing = sumLineItemAmounts(lineItems, ['benefit_housing'])
// FK015 kostförmån has its own field: never fold into FK012.
// Skatteverket cross-checks the krona-amount against the PBB-schablon.
const benefitMeals = sumLineItemAmounts(lineItems, ['benefit_meals'])
// FK012 SkatteplOvrigaFormanerUlagAG is the catch-all for taxable
// benefits without their own FK code (bike, wellness, "other") PLUS
// the krona-amount for housing (since FK041/FK043 carry only the flag).
const benefitOther = sumLineItemAmounts(lineItems, [
'benefit_bike',
'benefit_wellness',
'benefit_other',
]) + benefitHousing
// Default housing type: if the employee got a housing benefit line
// item but no housing_benefit_type is set, treat as 'ej_smahus' (the
// more common case). NULL with no benefit line item → no flag emitted.
let housingBenefit: 'smahus' | 'ej_smahus' | undefined
if (benefitHousing > 0) {
housingBenefit = emp?.housing_benefit_type ?? 'ej_smahus'
}
const absenceEvents = absenceByEmployee.get(sre.employee_id)
let vaxaStod: 'forsta_anstalld' | 'vaxa_stod' | undefined
if (emp?.vaxa_stod_eligible) {
vaxaStod =
emp.employment_start && emp.employment_start < VAXA_STOD_FK063_CUTOFF
? 'forsta_anstalld'
: 'vaxa_stod'
}
// Växa-stöd (employment-start-gated relief, 10.21 % avgifter) and the
// ungdomsrabatt (age-gated relief, 'youth' avgifter_category) are
// distinct statutory programs and must not be claimed for the same
// employee in the same period. Catching this at generation time
// avoids emitting an FK062/FK063 flag inconsistent with the FK061
// category total.
if (vaxaStod && sre.avgifter_category === 'youth') {
throw new AGIIncompleteDataError(
`Anställd ${emp?.specification_number ?? '?'}: kan inte kombinera växa-stöd ` +
'(FK062/FK063) med ungdomsrabatt (avgifter_category="youth"): programmen är ömsesidigt uteslutande. ' +
'Välj ett av dem under anställdas inställningar.',
['vaxa_stod_eligible', 'avgifter_category'],
)
}
const isFSkatt = isFSkattRow(sre)
// Honor advanced-mode per-employee overrides set during review.
// F-skatt rows ignore avgifter overrides (see isFSkattRow invariant).
const effectiveTax = sre.tax_withheld_override ?? sre.tax_withheld
const effectiveAvgifterBasis = isFSkatt
? 0
: sre.avgifter_basis_override ?? sre.avgifter_basis
return {
personnummer: emp?.personnummer ?? '',
specificationNumber: emp?.specification_number ?? 0,
removed: Boolean(sre.removed_from_agi),
grossSalary: isFSkatt ? 0 : sre.gross_salary,
taxWithheld: effectiveTax,
avgifterBasis: effectiveAvgifterBasis,
fSkattPayment: isFSkatt ? sre.gross_salary : undefined,
// F-skatt payees: cash goes to FK131 ONLY (grossSalary is zeroed so
// FK011 is never emitted for the same payment) and benefits to the
// ej-UlagSA variants (FK132/FK133/FK134/FK137/FK138/FK139). Regular
// employees get FK011 + FK012/FK013/FK015/FK018/FK041/FK043.
benefitsExcludedFromSAUnderlag: isFSkatt ? true : undefined,
benefitCar: benefitCar > 0 ? benefitCar : undefined,
benefitFuel: benefitFuel > 0 ? benefitFuel : undefined,
benefitMeals: benefitMeals > 0 ? benefitMeals : undefined,
housingBenefit,
benefitOther: benefitOther > 0 ? benefitOther : undefined,
benefitsAdjusted: Boolean(sre.benefits_adjusted),
vaxaStod,
sickDays: (sre.sick_days ?? 0) > 0 ? (sre.sick_days ?? 0) : undefined,
vabDays: (sre.vab_days ?? 0) > 0 ? (sre.vab_days ?? 0) : undefined,
parentalDays:
(sre.parental_days ?? 0) > 0 ? (sre.parental_days ?? 0) : undefined,
absenceEvents: absenceEvents && absenceEvents.length > 0 ? absenceEvents : undefined,
}
},
)
// Drop individuppgifter with nothing to report. An employee who took 0 kr
// and had no benefits, tax or absence this month is simply omitted (you
// only file an IU for a person who received something). This yields a clean
// HU-only nolldeklaration for a full nollkörning, and omits zero-paid
// employees in a mixed run. Borttag (removed) tombstones are always kept.
.filter(
(e) =>
e.removed === true ||
(e.grossSalary ?? 0) > 0 ||
(e.taxWithheld ?? 0) > 0 ||
(e.fSkattPayment ?? 0) > 0 ||
(e.benefitCar ?? 0) > 0 ||
(e.benefitFuel ?? 0) > 0 ||
(e.benefitMeals ?? 0) > 0 ||
(e.benefitOther ?? 0) > 0 ||
e.housingBenefit !== undefined ||
(e.sickDays ?? 0) > 0 ||
(e.vabDays ?? 0) > 0 ||
(e.parentalDays ?? 0) > 0 ||
(e.absenceEvents?.length ?? 0) > 0,
)
// 5. Build totals: avgifter by category (with rate-heuristic fallback for legacy runs).
// Removed-from-AGI rows (FK205 borttag) are tombstones: they must not
// contribute to FK497/FK487/FK499 because the prior submission's amounts
// remain on file at Skatteverket; the borttag just removes the IU itself.
const activeEmployees = parsedRows.filter((sre) => !sre.removed_from_agi)
const avgifterByCategory: AGITotals['avgifterByCategory'] = {}
for (const sre of activeEmployees) {
const dbCategory = sre.avgifter_category ?? null
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 as Record<string, { basis: number; amount: number }>)[
category
] || { basis: 0, amount: 0 }
// F-skatt rows contribute 0 regardless of overrides (see isFSkattRow).
cat.basis += isFSkattRow(sre) ? 0 : sre.avgifter_basis_override ?? sre.avgifter_basis
cat.amount += isFSkattRow(sre) ? 0 : sre.avgifter_amount_override ?? sre.avgifter_amount
;(avgifterByCategory as Record<string, { basis: number; amount: number }>)[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<string, unknown>) ?? {}) as {
sjuklonRate?: number
sjuklon_rate?: number
}
const sjuklonRate = calcParams.sjuklonRate ?? calcParams.sjuklon_rate ?? 0.8
let totalSjuklonekostnad = 0
for (const sre of activeEmployees) {
const monthly = sre.monthly_salary ?? 0
if (!monthly) continue
const dailyRate = monthly / 21
const lineItems = (sre.line_items ?? []) as Array<{ item_type: string; amount?: number | null; quantity?: number | null }>
for (const li of lineItems) {
if (li.item_type === 'sick_day2_14') {
const days = li.quantity ?? 0
totalSjuklonekostnad += dailyRate * sjuklonRate * days
}
}
}
// FK497 SummaSkatteavdr must equal the sum of FK001 on active IUs (not
// run.total_tax, which includes removed rows). Same for FK487.
// Coalesce override → computed so manual jämkning/FoU adjustments flow
// into the filed declaration.
const totalTax = activeEmployees.reduce(
(sum, sre) => sum + ((sre.tax_withheld_override ?? sre.tax_withheld) || 0),
0,
)
const totals: AGITotals = {
totalTax: Math.round(totalTax * 100) / 100,
// F-skatt rows contribute 0 regardless of overrides (see isFSkattRow).
totalAvgifterBasis: activeEmployees.reduce(
(s, e) => s + (isFSkattRow(e) ? 0 : (e.avgifter_basis_override ?? e.avgifter_basis) || 0),
0,
),
totalAvgifterAmount: Math.round(totalAvgifterAmount * 100) / 100,
totalSjuklonekostnad: Math.round(totalSjuklonekostnad * 100) / 100,
avgifterByCategory,
}
// Soft AGI deadline check: warn (but don't block) when generating for a
// future period or one whose Skatteverket correction window is clearly
// past. Filing deadline is the 12th (17th in Jan/Aug for small employers)
// of the month after the period; SKV accepts corrections for a long time
// after, but a period > 13 months in the past is almost certainly a
// misclick. Surface via the logger so it lands in the audit log. These are
// warn-level and carry no alert flag, so they do not reach the observability
// sink (lib/observability); they are a breadcrumb, not a page.
{
const now = new Date()
const currentYM = now.getUTCFullYear() * 100 + (now.getUTCMonth() + 1)
const periodYM = run.period_year * 100 + run.period_month
if (periodYM > currentYM) {
opLog.warn('AGI generated for future period', {
companyId,
periodYear: run.period_year,
periodMonth: run.period_month,
})
} else if (currentYM - periodYM > 13) {
opLog.warn('AGI generated for period > 13 months past', {
companyId,
periodYear: run.period_year,
periodMonth: run.period_month,
})
}
}
// 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 },
}
}
if (err instanceof AGIPayloadTooLargeError) {
return {
ok: false,
code: 'AGI_PAYLOAD_TOO_LARGE',
details: {
message: err.message,
size_bytes: err.sizeBytes,
limit_bytes: err.limitBytes,
},
status: 413,
}
}
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)
}
// NOTE: generating the XML deliberately does NOT complete the
// arbetsgivardeklaration deadline. SFL 26 kap. deems the obligation
// satisfied only when the declaration has come in to Skatteverket; the
// Skatteverket extension confirms the deadline on kvittens receipt
// (agi-kvittens-reconcile), and manual filers tick it off themselves.
// Completing here made a generated-but-never-filed AGI silently sail
// past its statutory date.
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,
}
}