Skv/e2e overview (#515)

* feat(agi): refactor AGI XML generation and data handling

- Remove deprecated AGI field codes from field-codes.ts.
- Update generate-declaration.ts to include new employee fields and handle absence data with stable specification numbers.
- Enhance XML generation in xml-generator.ts to support new flags for housing benefits and adjusted benefits.
- Introduce new database migrations to support:
  - `removed_from_agi` flag for tombstoning individuppgifter.
  - `benefits_adjusted` flag for tracking adjustments to benefits.
  - `franvaro_specifikationsnummer` for stable absence event identification.
  - `housing_benefit_type` to differentiate between housing benefit types.

* feat: Implement strict validation for AGI employee data and introduce pre-flight validation schemas

- Added Zod schemas for validating employee data in AGI declarations to ensure all required fields are present and correctly typed, preventing silent errors during processing.
- Introduced AGI pre-flight validation schemas for Skatteverket endpoints to validate individual and head unit submissions before sending to the API.
- Created a new audit log table for tracking all outbound calls to Skatteverket, ensuring compliance and traceability for AGI and moms submissions.
- Implemented advisory locks in the database to manage concurrent updates to absence specification numbers, enhancing data integrity.
- Added compliance documentation for GDPR processing activities related to AGI and moms submissions, detailing data handling and retention policies.

* feat: Extend DELETE RLS policy to protect 'declined' signatures in årsredovisning

* fix: Update date handling in salary absence migrations to use immutable year-month key

* fix: Update SELECT policy in skatteverket_api_audit_log to use IN clause for company_id

* feat: Add skatteverket_api_audit_log and salary_absence_franvaro_audit tables with RLS policies and immutable triggers
This commit is contained in:
Mattsson
2026-05-17 23:52:49 +02:00
committed by GitHub
parent f829c96d8b
commit a652dcae1a
15 changed files with 1497 additions and 201 deletions
+39 -17
View File
@@ -287,17 +287,17 @@ describe('buildIndividuppgifterSnapshot', () => {
expect(snapshot[1].personnummer).toBe('198506159876')
})
it('preserves FK570 for correction reference', () => {
it('preserves specificationNumber for correction reference', () => {
const snapshot = buildIndividuppgifterSnapshot(employees)
expect(snapshot[0].fk570).toBe(1)
expect(snapshot[1].fk570).toBe(2)
expect(snapshot[0].specificationNumber).toBe(1)
expect(snapshot[1].specificationNumber).toBe(2)
})
it('includes all required rutor', () => {
it('includes the core IU amounts', () => {
const snapshot = buildIndividuppgifterSnapshot(employees)
expect(snapshot[0]).toHaveProperty('ruta011', 40000)
expect(snapshot[0]).toHaveProperty('ruta001', 12000)
expect(snapshot[0]).toHaveProperty('ruta020', 40000)
expect(snapshot[0]).toHaveProperty('grossSalary', 40000)
expect(snapshot[0]).toHaveProperty('taxWithheld', 12000)
expect(snapshot[0]).toHaveProperty('avgifterBasis', 40000)
})
})
@@ -312,8 +312,8 @@ describe('generateAGIXml — Frånvarouppgift', () => {
taxWithheld: 12000,
avgifterBasis: 40000,
absenceEvents: [
{ date: '2026-04-15', type: 'vab', hours: 8 },
{ date: '2026-04-16', type: 'vab', hours: 4 },
{ date: '2026-04-15', type: 'vab', hours: 8, specifikationsnummer: 1 },
{ date: '2026-04-16', type: 'vab', hours: 4, specifikationsnummer: 2 },
],
},
{
@@ -323,7 +323,7 @@ describe('generateAGIXml — Frånvarouppgift', () => {
taxWithheld: 10500,
avgifterBasis: 35000,
absenceEvents: [
{ date: '2026-04-20', type: 'parental', hours: 8 },
{ date: '2026-04-20', type: 'parental', hours: 8, specifikationsnummer: 1 },
],
},
]
@@ -352,26 +352,48 @@ describe('generateAGIXml — Frånvarouppgift', () => {
company,
[{
...employeesWithAbsence[1],
absenceEvents: [{ date: '2026-04-20', type: 'parental', hours: 8 }],
absenceEvents: [{ date: '2026-04-20', type: 'parental', hours: 8, specifikationsnummer: 1 }],
}],
totals,
)
expect(xml).not.toMatch(/FranvaroTimmarTFP|FranvaroProcentTFP/)
})
it('uses 1-based stable specifikationsnummer per employee, ordered by date', () => {
it('emits the persisted specifikationsnummer per event (stable across corrections)', () => {
const xml = generateAGIXml(company, employeesWithAbsence, totals)
// emp1 has two events on 2026-04-15 and 2026-04-16
// emp1 has two events with specnummer 1 and 2 (assigned by DB trigger);
// emp2 has one event with specnummer 1. The values come from the
// event object — they are NOT recomputed from array index.
expect(xml).toContain('<gem:FranvaroSpecifikationsnummer faltkod="822">1</gem:FranvaroSpecifikationsnummer>')
expect(xml).toContain('<gem:FranvaroSpecifikationsnummer faltkod="822">2</gem:FranvaroSpecifikationsnummer>')
})
it('preserves the persisted specnummer even when an earlier event is removed', () => {
// Simulates the correction scenario the persistence is designed for:
// the first vab day was deleted, so the remaining day keeps its
// original specnummer (2). Without persistence the index would shift
// to 1 and Skatteverket would treat it as a replacement of the
// already-filed day-1 event.
const xml = generateAGIXml(
company,
[{
...employeesWithAbsence[0],
absenceEvents: [
{ date: '2026-04-16', type: 'vab', hours: 4, specifikationsnummer: 2 },
],
}],
totals,
)
expect(xml).toContain('<gem:FranvaroSpecifikationsnummer faltkod="822">2</gem:FranvaroSpecifikationsnummer>')
expect(xml).not.toContain('<gem:FranvaroSpecifikationsnummer faltkod="822">1</gem:FranvaroSpecifikationsnummer>')
})
it('formats fractional hours with up to 2 decimals', () => {
const xml = generateAGIXml(
company,
[{
...employeesWithAbsence[0],
absenceEvents: [{ date: '2026-04-15', type: 'vab', hours: 4.5 }],
absenceEvents: [{ date: '2026-04-15', type: 'vab', hours: 4.5, specifikationsnummer: 1 }],
}],
totals,
)
@@ -383,7 +405,7 @@ describe('generateAGIXml — Frånvarouppgift', () => {
company,
[{
...employeesWithAbsence[0],
absenceEvents: [{ date: '2026-04-15', type: 'vab', hours: 50 }],
absenceEvents: [{ date: '2026-04-15', type: 'vab', hours: 50, specifikationsnummer: 1 }],
}],
totals,
)
@@ -404,7 +426,7 @@ describe('generateAGIXml — Frånvarouppgift', () => {
{ ...company, periodYear: 2025, periodMonth: 1 },
[{
...employeesWithAbsence[0],
absenceEvents: [{ date: '2025-01-15', type: 'vab', hours: 8 }],
absenceEvents: [{ date: '2025-01-15', type: 'vab', hours: 8, specifikationsnummer: 1 }],
}],
totals,
)
@@ -442,7 +464,7 @@ describe('generateAGIXml — Frånvarouppgift', () => {
company,
[{
...employeesWithAbsence[0],
absenceEvents: [{ date: '2026-04-15', type: 'vab', hours: 8 }],
absenceEvents: [{ date: '2026-04-15', type: 'vab', hours: 8, specifikationsnummer: 1 }],
}],
totals,
)
-100
View File
@@ -1,100 +0,0 @@
/**
* AGI (Arbetsgivardeklaration) field codes per Skatteverket Teknisk beskrivning.
*
* FK = Fältkod (field code)
* Ruta = Box number on the form
*/
// ============================================================
// Huvuduppgift (Employer totals)
// ============================================================
/** Employer-level field codes */
export const HUVUDUPPGIFT_FIELDS = {
/** Total skatteavdrag — sum of all employee tax withholdings */
RUTA_001: '001',
/** Total underlag for arbetsgivaravgifter */
RUTA_020: '020',
/** Arbetsgivaravgifter — standard rate (31.42%) */
RUTA_060: '060',
/** Arbetsgivaravgifter — age-reduced (10.21%, born ≤1959 for 2026) */
RUTA_061: '061',
/** Arbetsgivaravgifter — youth rate (20.81%, vid årets ingång 18–22 år, Apr 2026–Sep 2027; Prop. 2025/26:66) */
RUTA_062: '062',
} as const
// ============================================================
// Individuppgift (Per-employee data)
// ============================================================
/** Per-employee field codes */
export const INDIVID_FIELDS = {
/** Personnummer/samordningsnummer (12 digits, CRITICAL: must be decrypted) */
FK215: '215',
/** Specifikationsnummer — MUST stay consistent per employee for corrections */
FK570: '570',
/** Kontant bruttolön (gross cash salary) */
RUTA_011: '011',
/** Avdragen skatt (withheld preliminary tax) */
RUTA_001: '001',
/** Förmånsvärde — bilförmån */
RUTA_012: '012',
/** Förmånsvärde — drivmedel vid bilförmån */
RUTA_013: '013',
/** Förmånsvärde — bostad */
RUTA_014: '014',
/** Förmånsvärde — kost */
RUTA_015: '015',
/** Förmånsvärde — ränta */
RUTA_016: '016',
/** Förmånsvärde — övriga */
RUTA_019: '019',
/** Underlag för arbetsgivaravgifter */
RUTA_020: '020',
/** Ersättning till mottagare med F-skattsedel (not subject to avgifter) */
RUTA_131: '131',
// Absence fields (from 2025)
/** Sjukfrånvaro — antal dagar */
FK821: '821',
/** VAB — antal dagar */
FK822: '822',
/** Föräldraledighet — antal dagar */
FK823: '823',
/** Graviditetspenning — antal dagar */
FK824: '824',
/** Smittbärarpenning — antal dagar */
FK825: '825',
/** Sjuk-/aktivitetsersättning — antal dagar */
FK826: '826',
/** Rehabilitering — antal dagar */
FK827: '827',
} as const
// ============================================================
// Benefit type to ruta mapping
// ============================================================
/** Map benefit item types to AGI individuppgift rutor */
export const BENEFIT_RUTA_MAP: Record<string, string> = {
benefit_car: INDIVID_FIELDS.RUTA_012,
benefit_housing: INDIVID_FIELDS.RUTA_014,
benefit_meals: INDIVID_FIELDS.RUTA_015,
benefit_wellness: INDIVID_FIELDS.RUTA_019,
benefit_bike: INDIVID_FIELDS.RUTA_019,
benefit_other: INDIVID_FIELDS.RUTA_019,
}
// ============================================================
// Avgifter category to ruta mapping
// ============================================================
export type AvgifterCategory = 'standard' | 'reduced_65plus' | 'youth' | 'vaxa_stod' | 'exempt'
/** Map avgifter categories to huvuduppgift rutor */
export const AVGIFTER_RUTA_MAP: Record<AvgifterCategory, string> = {
standard: HUVUDUPPGIFT_FIELDS.RUTA_060,
reduced_65plus: HUVUDUPPGIFT_FIELDS.RUTA_061,
youth: HUVUDUPPGIFT_FIELDS.RUTA_062,
vaxa_stod: HUVUDUPPGIFT_FIELDS.RUTA_061, // Växa-stöd uses same rate as 65+
exempt: '', // No avgifter
}
+216 -39
View File
@@ -22,15 +22,64 @@
*/
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(),
gross_salary: z.number(),
tax_withheld: z.number(),
avgifter_basis: z.number(),
avgifter_amount: z.number(),
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 {
@@ -124,7 +173,7 @@ export async function generateAgiDeclaration(
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(*)',
'*, 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)
@@ -153,12 +202,17 @@ export async function generateAgiDeclaration(
const absenceByEmployee = new Map<
string,
Array<{ date: string; type: 'vab' | 'parental'; hours: number }>
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')
.select('employee_id, absence_date, absence_type, hours, franvaro_specifikationsnummer')
.eq('company_id', companyId)
.in('absence_type', ['vab', 'parental'])
.gte('absence_date', periodStart)
@@ -169,73 +223,153 @@ export async function generateAgiDeclaration(
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)
}
}
const employeeData: AGIEmployeeData[] = (runEmployees as Array<Record<string, unknown>>).map(
(sre) => {
const emp = sre.employee as {
personnummer: string
specification_number: number
f_skatt_status: string
} | null
const lineItems = (sre.line_items || []) as Array<Record<string, unknown>>
// 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 benefitMeals = sumLineItemAmounts(lineItems, ['benefit_meals'])
const benefitFuel = sumLineItemAmounts(lineItems, ['benefit_fuel'])
const benefitHousing = sumLineItemAmounts(lineItems, ['benefit_housing'])
const benefitOther = sumLineItemAmounts(lineItems, ['benefit_wellness', 'benefit_other'])
const absenceEvents = absenceByEmployee.get(sre.employee_id as string)
// 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 = emp?.f_skatt_status === 'f_skatt'
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,
personnummer: emp?.personnummer ?? '',
specificationNumber: emp?.specification_number ?? 0,
removed: Boolean(sre.removed_from_agi),
grossSalary: sre.gross_salary,
taxWithheld: sre.tax_withheld,
avgifterBasis: sre.avgifter_basis,
fSkattPayment: isFSkatt ? sre.gross_salary : undefined,
// F-skatt payees: cash goes to FK131 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,
benefitHousing: benefitHousing > 0 ? benefitHousing : undefined,
benefitFuel: benefitFuel > 0 ? benefitFuel : undefined,
benefitMeals: benefitMeals > 0 ? benefitMeals : undefined,
housingBenefit,
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,
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 as number) > 0 ? (sre.parental_days as number) : undefined,
(sre.parental_days ?? 0) > 0 ? (sre.parental_days ?? 0) : undefined,
absenceEvents: absenceEvents && absenceEvents.length > 0 ? absenceEvents : undefined,
}
},
)
// 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 runEmployees as Array<Record<string, unknown>>) {
const dbCategory = sre.avgifter_category as string | null
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 as number) <= 0.1022
: sre.avgifter_rate <= 0.1022
? 'reduced65plus'
: (sre.avgifter_rate as number) <= 0.2082
: sre.avgifter_rate <= 0.2082
? 'youth'
: 'standard'
const cat = (avgifterByCategory as Record<string, { basis: number; amount: number }>)[
category
] || { basis: 0, amount: 0 }
cat.basis += sre.avgifter_basis as number
cat.amount += sre.avgifter_amount as number
cat.basis += sre.avgifter_basis
cat.amount += sre.avgifter_amount
;(avgifterByCategory as Record<string, { basis: number; amount: number }>)[category] = cat
}
const totalAvgifterAmount = Object.values(avgifterByCategory).reduce(
@@ -251,24 +385,30 @@ export async function generateAgiDeclaration(
}
const sjuklonRate = calcParams.sjuklonRate ?? calcParams.sjuklon_rate ?? 0.8
let totalSjuklonekostnad = 0
for (const sre of runEmployees as Array<Record<string, unknown>>) {
const monthly =
((sre.employee as { monthly_salary?: number } | null)?.monthly_salary as number) ?? 0
for (const sre of activeEmployees) {
const monthly = sre.employee?.monthly_salary ?? 0
if (!monthly) continue
const dailyRate = monthly / 21
const lineItems = (sre.line_items || []) as Array<Record<string, unknown>>
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 as number) || 0
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.
const totalTax = activeEmployees.reduce(
(sum, sre) => sum + (sre.tax_withheld || 0),
0,
)
const totals: AGITotals = {
totalTax: run.total_tax,
totalAvgifterBasis: (runEmployees as Array<{ avgifter_basis: number }>).reduce(
(s, e) => s + e.avgifter_basis,
totalTax: Math.round(totalTax * 100) / 100,
totalAvgifterBasis: activeEmployees.reduce(
(s, e) => s + (e.avgifter_basis || 0),
0,
),
totalAvgifterAmount: Math.round(totalAvgifterAmount * 100) / 100,
@@ -276,6 +416,31 @@ export async function generateAgiDeclaration(
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 audit log + Sentry both see it.
{
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
@@ -302,6 +467,18 @@ export async function generateAgiDeclaration(
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)
+105
View File
@@ -0,0 +1,105 @@
import { z } from 'zod'
/**
* Zod schemas for Skatteverket AGI pre-flight kontrollera endpoints.
* Matches the v1.7 §7 (HU) and §8 (IU) JSON spec exactly, with strict()
* to reject unknown properties — without this guard, a caller could inject
* fields like agRegistreradId or personnummer overrides into the payload
* we forward verbatim to SKV.
*
* Conventions used by Skatteverket:
* - IDENTITET: 12 digits (orgnr prefixed "16" + 10 digits, or personnummer YYYYMMDDXXXX)
* - redovisningsPeriod: YYYYMM
* - amount fields: integer SEK (no decimals)
* - boolean kryss fields: true/false (JSON) — SKV converts to <FK>1</FK> in XML
*/
// 12-digit IDENTITET pattern. Slightly looser than the XSD regex used in
// xml-generator.ts (we don't re-validate samordningsnummer arithmetic here)
// because SKV will reject malformed values on its end with a clearer
// felmeddelande than we can surface — what matters here is that we don't
// forward an obviously bogus or oversized string.
const IDENTITET = z
.string()
.regex(/^\d{12}$/, 'Förväntat 12-siffrigt IDENTITET (orgnr eller personnummer).')
const REDOVISNINGSPERIOD = z
.string()
.regex(/^\d{6}$/, 'Förväntat YYYYMM.')
// First period the API accepts; same constraint as xml-generator.
.refine((s) => Number.parseInt(s, 10) >= 201807, {
message: 'Redovisningsperioden är tidigare än 201807 (AGI API minimum).',
})
// SEK amount as non-negative integer with a sanity cap matching SKV's
// internal BELOPP10 (10-digit) ceiling. Bigger values are rejected locally
// rather than forwarded.
const AMOUNT = z.number().int().nonnegative().max(9_999_999_999)
const SPEC_NUMBER = z.number().int().min(1).max(999_999_999)
export const AGIKontrolleraHUSchema = z
.object({
agRegistreradId: IDENTITET,
redovisningsPeriod: REDOVISNINGSPERIOD,
summaSkatteavdr: AMOUNT.optional(),
summaArbAvgSlf: AMOUNT.optional(),
totalSjuklonekostnad: AMOUNT.optional(),
})
.strict()
export type AGIKontrolleraHU = z.infer<typeof AGIKontrolleraHUSchema>
export const AGIKontrolleraIUSchema = z
.object({
agRegistreradId: IDENTITET,
redovisningsPeriod: REDOVISNINGSPERIOD,
betalningsmottagarId: IDENTITET,
specifikationsnummer: SPEC_NUMBER,
// Cash + tax — FK011 / FK001
kontantErsattningUlagAG: AMOUNT.optional(),
avdrPrelSkatt: AMOUNT.optional(),
// Benefit amounts (UlagAG variants)
skatteplBilformanUlagAG: AMOUNT.optional(), // FK013
drivmVidBilformanUlagAG: AMOUNT.optional(), // FK018
kostformanUlagAG: AMOUNT.optional(), // FK015
skatteplOvrigaFormanerUlagAG: AMOUNT.optional(), // FK012
// Housing benefit KRYSS flags
bostadsformanSmahusUlagAG: z.boolean().optional(), // FK041
bostadsformanEjSmahusUlagAG: z.boolean().optional(), // FK043
// F-skatt / ej UlagSA variants
kontantErsattningEjUlagSA: AMOUNT.optional(), // FK131
skatteplBilformanEjUlagSA: AMOUNT.optional(), // FK133
drivmVidBilformanEjUlagSA: AMOUNT.optional(), // FK134
kostformanEjUlagSA: AMOUNT.optional(), // FK139
skatteplOvrigaFormanerEjUlagSA: AMOUNT.optional(), // FK132
bostadsformanSmahusEjUlagSA: z.boolean().optional(), // FK137
bostadsformanEjSmahusEjUlagSA: z.boolean().optional(),// FK138
// Flags
formanHarJusterats: z.boolean().optional(), // FK048
forstaAnstalld: z.boolean().optional(), // FK062
vaxaStod: z.boolean().optional(), // FK063
borttag: z.boolean().optional(), // FK205
})
.strict()
.refine(
(iu) => !(iu.forstaAnstalld === true && iu.vaxaStod === true),
{
message: 'FK062 (ForstaAnstalld) och FK063 (VaxaStod) är ömsesidigt uteslutande.',
path: ['vaxaStod'],
},
)
export type AGIKontrolleraIU = z.infer<typeof AGIKontrolleraIUSchema>
/**
* Hard cap on the raw JSON body for kontrollera endpoints. Even a fully
* legal IU is < 4 KB serialised; 64 KB is a generous safety margin that
* still trivially rejects pathological inputs before they reach Zod.
*/
export const AGI_KONTROLLERA_MAX_BYTES = 64 * 1024
+260 -45
View File
@@ -30,13 +30,14 @@ import { getBranding } from '@/lib/branding/service'
* - Hours emitted via FranvaroTimmarTFP (FK825) for VAB or FranvaroTimmarFP
* (FK827) for parental. The procent variants (824/826) are not used —
* gnubok tracks hours, not percent.
* - FranvaroSpecifikationsnummer is assigned 1-based per (employee, period),
* ordered by date. Skatteverket replaces a Frånvarouppgift on match of
* (BetalningsmottagarId, FranvaroDatum, FranvaroSpecifikationsnummer,
* RedovisningsPeriod, AgRegistreradId) — for stable replacement across
* re-generations the numbering must persist; if dates are added/removed
* mid-period the indices shift. First-submit is fine; correction
* stability is a follow-up TODO (persist event → number mapping).
* - FranvaroSpecifikationsnummer is persisted on salary_absence_days
* (column franvaro_specifikationsnummer; assigned by DB trigger on
* INSERT, never re-numbered). Skatteverket replaces a Frånvarouppgift
* on match of (BetalningsmottagarId, FranvaroDatum,
* FranvaroSpecifikationsnummer, RedovisningsPeriod, AgRegistreradId).
* Because the number is stable, corrections survive day deletions:
* remaining events keep their original numbers, and Skatteverket
* matches each event back to its prior submission.
* - Periods before 202501 emit no Frånvarouppgift (Skatteverket rejects).
*
* Per-employee sick days are NOT reported via AGI under any version — they
@@ -62,6 +63,14 @@ export interface AGIAbsenceEvent {
type: 'vab' | 'parental'
/** Hours absent on this date, 0.01–24.00. Defaults to 8 in salary_absence_days. */
hours: number
/**
* FK822 FranvaroSpecifikationsnummer — stable per-(employee, year-month)
* sequence assigned at the DB level (see migration
* 20260517120000_salary_absence_days_franvaro_specifikationsnummer.sql).
* MUST stay constant across corrections — never recompute from array
* index. Persisted on salary_absence_days.franvaro_specifikationsnummer.
*/
specifikationsnummer: number
}
export interface AGIEmployeeData {
@@ -70,13 +79,65 @@ export interface AGIEmployeeData {
grossSalary: number // FK011 KontantErsattningUlagAG
taxWithheld: number // FK001 AvdrPrelSkatt
avgifterBasis: number // Retained for backwards compat; equals grossSalary for standard cases. Not emitted separately (FK011 already captures basis).
/**
* FK205 Borttag — tombstone this IU. When true, the XML emits only the
* identity fields (FK201, FK215, FK570, FK006) plus <Borttag>1</Borttag>;
* amounts and benefits are skipped. Skatteverket then removes the prior
* IU matching (AgRegistreradId, BetalningsmottagarId, RedovisningsPeriod,
* Specifikationsnummer). Only meaningful for periods that already had an
* AGI declaration filed.
*/
removed?: boolean
/**
* Växa-stöd flag — emitted as one of two mutually exclusive boolean fields:
* 'forsta_anstalld' → FK062 ForstaAnstalld (anställd före 2024-05-01)
* 'vaxa_stod' → FK063 VaxaStod (anställd efter 2024-04-30)
* Set when the employer claims växa-stöd reduction (10.21% avgifter rate)
* for this employee in the period. The cutoff date is hard-coded in the
* spec (Prop. 2023/24:80, see Skatteverket FK 1.7 revisionshistorik 1.19).
*/
vaxaStod?: 'forsta_anstalld' | 'vaxa_stod'
/**
* FK048 FormanHarJusterats — set when any benefit value on this IU has
* been adjusted away from the standard schablon. Reflects
* salary_run_employees.benefits_adjusted.
*/
benefitsAdjusted?: boolean
fSkattPayment?: number // FK131 KontantErsattningEjUlagSA
benefitCar?: number // FK013 SkatteplBilformanUlagAG
benefitFuel?: number // FK018 DrivmVidBilformanUlagAG
benefitHousing?: number // FK043 BostadsformanEjSmahusUlagAG (non-småhus default)
benefitOther?: number // FK012 SkatteplOvrigaFormanerUlagAG
/** @deprecated Meal benefit element name not verified against schema; kept for snapshot compatibility only (not emitted). */
benefitCar?: number // FK013 SkatteplBilformanUlagAG (amount, BELOPP7)
benefitFuel?: number // FK018 DrivmVidBilformanUlagAG (amount, BELOPP7)
/**
* FK015 KostformanUlagAG (amount, BELOPP10). Kostförmån has its own
* dedicated field in the AGI spec with a PBB-linked schablon value —
* Skatteverket cross-checks the reported amount against the schablon.
* Aggregating meals into FK012 (övriga förmåner) triggers automated
* discrepancy notices. Always emit FK015 separately when > 0.
*/
benefitMeals?: number
/**
* Housing benefit indicator. FK041 (smahus) and FK043 (ej_smahus) are
* boolean KRYSS flags in the XSD — they just signal that this kind of
* benefit was given. The AMOUNT must be folded into benefitOther
* (FK012). Pass 'smahus' or 'ej_smahus' to set the flag; omit if no
* housing benefit applies.
*/
housingBenefit?: 'smahus' | 'ej_smahus'
/**
* FK012 SkatteplOvrigaFormanerUlagAG (amount, BELOPP10). Catch-all for
* taxable benefits without their own dedicated FK code — bike, wellness,
* "other", AND the full krona-amount for housing (since FK041/FK043
* carry only the flag). Meals go in benefitMeals (FK015), NOT here.
*/
benefitOther?: number
/**
* When true, benefit amounts and housing flags emit as the "ej underlag
* SA" variants (FK132/FK133/FK134/FK137/FK138) instead of the standard
* UlagAG variants (FK012/FK013/FK018/FK041/FK043). Set this for F-skatt
* holders and other payees whose benefits should not form basis for
* arbetsgivaravgifter. Defaults to false. FK131 (cash, ej UlagSA) is
* controlled separately via fSkattPayment.
*/
benefitsExcludedFromSAUnderlag?: boolean
/** @deprecated Per-employee sick days are not reported via AGI (goes to Försäkringskassan separately). Kept for snapshot compatibility. */
sickDays?: number
/** @deprecated VAB is reported via top-level <Franvarouppgift> as per-event records (see absenceEvents), not as an IU day count. Kept for snapshot compatibility. */
@@ -148,6 +209,88 @@ function assertRequiredCompanyData(company: AGICompanyData): void {
}
}
/**
* Smallest period the AGI API accepts, per Skatteverket v1.7 spec §6.3:
* "redovisningsperiod (URI-parameter) — YYYYMM, tidigast 201807".
* Periods before this raise HTTP 404 felkod 31 at SKV's gateway.
*/
const AGI_MIN_PERIOD_YYYYMM = 201807
function assertRequiredPeriod(year: number, month: number): void {
if (!Number.isInteger(year) || !Number.isInteger(month) || month < 1 || month > 12) {
throw new AGIIncompleteDataError(
`Ogiltig redovisningsperiod: ${year}-${month}. Ange ett giltigt år och månad (1–12).`,
['redovisningsperiod'],
)
}
const yyyymm = year * 100 + month
if (yyyymm < AGI_MIN_PERIOD_YYYYMM) {
throw new AGIIncompleteDataError(
`Redovisningsperioden ${year}-${String(month).padStart(2, '0')} är tidigare än ` +
`${Math.floor(AGI_MIN_PERIOD_YYYYMM / 100)}-${String(AGI_MIN_PERIOD_YYYYMM % 100).padStart(2, '0')}, ` +
'som är den tidigaste period Skatteverkets AGI-API accepterar (Tjänstebeskrivning v1.7 §6.3). ' +
'Kontrollera att lönekörningens period är korrekt.',
['redovisningsperiod'],
)
}
}
/**
* File-size ceilings from v1.7 §1: 100 MB on the test environment,
* 300 MB in production. Rejecting locally just means a cleaner Swedish
* error than the 413 felkod 27 SKV would otherwise return.
*/
const AGI_TEST_MAX_BYTES = 100 * 1024 * 1024
const AGI_PROD_MAX_BYTES = 300 * 1024 * 1024
export class AGIPayloadTooLargeError extends Error {
constructor(message: string, public readonly sizeBytes: number, public readonly limitBytes: number) {
super(message)
this.name = 'AGIPayloadTooLargeError'
}
}
/**
* Resolve the Skatteverket environment from a dedicated env var. Default
* to the stricter 'test' bucket when unset/unrecognised — a missing or
* misconfigured value must never silently raise the size ceiling.
*
* Documented in deployment runbook; substring-matching the API URL is
* forbidden (a misconfigured URL containing 'api.test.skatteverket.se'
* would otherwise lower the limit on a production tenant — the inverse
* was equally bad).
*/
function resolveSkatteverketEnv(): 'test' | 'production' {
const raw = process.env.SKATTEVERKET_ENV?.trim().toLowerCase()
if (raw === 'production' || raw === 'prod') return 'production'
if (raw === 'test') return 'test'
if (raw && raw !== '') {
// Unrecognised value — fail closed to test. Logged once so deployments
// catch typos in CI rather than at audit time.
// eslint-disable-next-line no-console
console.warn(
`SKATTEVERKET_ENV='${raw}' is not 'test' | 'production'; defaulting to 'test' (stricter limits).`,
)
}
return 'test'
}
function assertPayloadSize(xml: string): void {
const bytes = Buffer.byteLength(xml, 'utf8')
const env = resolveSkatteverketEnv()
const envLimit = env === 'production' ? AGI_PROD_MAX_BYTES : AGI_TEST_MAX_BYTES
if (bytes > envLimit) {
const mb = (bytes / (1024 * 1024)).toFixed(1)
const limitMb = Math.floor(envLimit / (1024 * 1024))
throw new AGIPayloadTooLargeError(
`AGI XML är för stort (${mb} MB). Skatteverkets gräns för denna miljö är ${limitMb} MB ` +
'(Tjänstebeskrivning v1.7 §1). Dela upp inlämningen i mindre paket per arbetsgivare eller period.',
bytes,
envLimit,
)
}
}
/**
* Skatteverket's IDENTITET pattern (from the AGI XSD). Accepts:
* - 12-digit personnummer YYYYMMDDXXXX (real dates 19xx/20xx, incl. leap days
@@ -211,6 +354,7 @@ export function generateAGIXml(
_isCorrection: boolean = false
): string {
assertRequiredCompanyData(company)
assertRequiredPeriod(company.periodYear, company.periodMonth)
const orgIdentitet = toIdentitet(company.orgNumber)
const period = `${company.periodYear}${String(company.periodMonth).padStart(2, '0')}`
@@ -285,6 +429,19 @@ export function generateAGIXml(
// ── Blankett: Individuppgift (one per employee) ──────────────
for (const emp of employees) {
// FK570 must be ≥ 1 (HELTAL, min 1 per spec). A 0 here would produce a
// STOPP-level rejection at Skatteverket; fail fast with a clearer
// Swedish message pointing at the missing column rather than letting
// bogus XML reach SKV.
if (!Number.isInteger(emp.specificationNumber) || emp.specificationNumber < 1) {
throw new AGIIncompleteDataError(
`Anställd saknar giltigt specifikationsnummer (FK570). ` +
'Specifikationsnumret måste vara ett heltal ≥ 1 och stabilt över korrigeringar. ' +
'Kontrollera fältet specification_number på den anställdes profil.',
['specifikationsnummer'],
)
}
let pnr: string
try {
pnr = decryptPersonnummer(emp.personnummer)
@@ -316,6 +473,17 @@ export function generateAGIXml(
lines.push(` <gem:RedovisningsPeriod faltkod="006">${period}</gem:RedovisningsPeriod>`)
lines.push(` <gem:Specifikationsnummer faltkod="570">${emp.specificationNumber}</gem:Specifikationsnummer>`)
// FK205 Borttag — tombstone this IU. When set, skip all amount/benefit
// fields; only the identity quintuple above (FK201, FK215, FK006, FK570)
// plus this flag are needed for Skatteverket to remove the prior IU.
if (emp.removed) {
lines.push(' <gem:Borttag faltkod="205">1</gem:Borttag>')
lines.push(' </gem:IU>')
lines.push(' </gem:Blankettinnehall>')
lines.push(' </gem:Blankett>')
continue
}
// FK011 — Kontant ersättning, underlag arbetsgivaravgifter (= gross salary)
if (emp.grossSalary > 0) {
lines.push(` <gem:KontantErsattningUlagAG faltkod="011">${formatAmount(emp.grossSalary)}</gem:KontantErsattningUlagAG>`)
@@ -326,36 +494,73 @@ export function generateAGIXml(
lines.push(` <gem:AvdrPrelSkatt faltkod="001">${formatAmount(emp.taxWithheld)}</gem:AvdrPrelSkatt>`)
}
// FK013 — Bilförmån (skattepliktig, underlag AG)
const exclSA = emp.benefitsExcludedFromSAUnderlag === true
// Car benefit AMOUNT: FK013 (UlagAG) or FK133 (ej UlagSA)
if (emp.benefitCar && emp.benefitCar > 0) {
lines.push(` <gem:SkatteplBilformanUlagAG faltkod="013">${formatAmount(emp.benefitCar)}</gem:SkatteplBilformanUlagAG>`)
const code = exclSA ? '133' : '013'
const elem = exclSA ? 'SkatteplBilformanEjUlagSA' : 'SkatteplBilformanUlagAG'
lines.push(` <gem:${elem} faltkod="${code}">${formatAmount(emp.benefitCar)}</gem:${elem}>`)
}
// FK018 — Drivmedel vid bilförmån
// Fuel for car benefit AMOUNT: FK018 (UlagAG) or FK134 (ej UlagSA)
if (emp.benefitFuel && emp.benefitFuel > 0) {
lines.push(` <gem:DrivmVidBilformanUlagAG faltkod="018">${formatAmount(emp.benefitFuel)}</gem:DrivmVidBilformanUlagAG>`)
const code = exclSA ? '134' : '018'
const elem = exclSA ? 'DrivmVidBilformanEjUlagSA' : 'DrivmVidBilformanUlagAG'
lines.push(` <gem:${elem} faltkod="${code}">${formatAmount(emp.benefitFuel)}</gem:${elem}>`)
}
// FK043 — Bostadsförmån (ej småhus). TODO: for single-family home use
// BostadsformanSmahusUlagAG (FK041); currently defaults to non-småhus.
if (emp.benefitHousing && emp.benefitHousing > 0) {
lines.push(` <gem:BostadsformanEjSmahusUlagAG faltkod="043">${formatAmount(emp.benefitHousing)}</gem:BostadsformanEjSmahusUlagAG>`)
// Kostförmån AMOUNT: FK015 (UlagAG) or FK139 (ej UlagSA). Has its own
// field because Skatteverket cross-checks the krona-belopp against the
// PBB-anchored schablon — folding it into FK012 triggers discrepancy
// notices. Always emit separately when > 0.
if (emp.benefitMeals && emp.benefitMeals > 0) {
const code = exclSA ? '139' : '015'
const elem = exclSA ? 'KostformanEjUlagSA' : 'KostformanUlagAG'
lines.push(` <gem:${elem} faltkod="${code}">${formatAmount(emp.benefitMeals)}</gem:${elem}>`)
}
// FK012 — Övriga skattepliktiga förmåner
// Housing benefit FLAGS (KRYSS, no amount on this element). The
// krona-amount belongs in benefitOther (FK012/FK132).
// FK041 BostadsformanSmahusUlagAG | FK137 BostadsformanSmahusEjUlagSA
// FK043 BostadsformanEjSmahusUlagAG | FK138 BostadsformanEjSmahusEjUlagSA
if (emp.housingBenefit === 'smahus') {
const code = exclSA ? '137' : '041'
const elem = exclSA ? 'BostadsformanSmahusEjUlagSA' : 'BostadsformanSmahusUlagAG'
lines.push(` <gem:${elem} faltkod="${code}">1</gem:${elem}>`)
} else if (emp.housingBenefit === 'ej_smahus') {
const code = exclSA ? '138' : '043'
const elem = exclSA ? 'BostadsformanEjSmahusEjUlagSA' : 'BostadsformanEjSmahusUlagAG'
lines.push(` <gem:${elem} faltkod="${code}">1</gem:${elem}>`)
}
// Övriga skattepliktiga förmåner AMOUNT: FK012 (UlagAG) or FK132 (ej UlagSA).
// Includes meals, bike, wellness, "other", and the full housing krona-amount.
if (emp.benefitOther && emp.benefitOther > 0) {
lines.push(` <gem:SkatteplOvrigaFormanerUlagAG faltkod="012">${formatAmount(emp.benefitOther)}</gem:SkatteplOvrigaFormanerUlagAG>`)
const code = exclSA ? '132' : '012'
const elem = exclSA ? 'SkatteplOvrigaFormanerEjUlagSA' : 'SkatteplOvrigaFormanerUlagAG'
lines.push(` <gem:${elem} faltkod="${code}">${formatAmount(emp.benefitOther)}</gem:${elem}>`)
}
// Meal benefit: element name not verified in the component schema yet.
// Intentionally omitted until we have an authoritative mapping.
void emp.benefitMeals
// FK131 — Ersättning till mottagare med F-skattsedel (ej underlag SA)
if (emp.fSkattPayment && emp.fSkattPayment > 0) {
lines.push(` <gem:KontantErsattningEjUlagSA faltkod="131">${formatAmount(emp.fSkattPayment)}</gem:KontantErsattningEjUlagSA>`)
}
// FK048 — FormanHarJusterats (any benefit value adjusted away from schablon)
if (emp.benefitsAdjusted) {
lines.push(' <gem:FormanHarJusterats faltkod="048">1</gem:FormanHarJusterats>')
}
// FK062 / FK063 — Växa-stöd. Mutually exclusive: FK062 for employees
// hired before 2024-05-01 (legacy "första anställda"-reglerna), FK063
// for those hired 2024-05-01 and later (utvidgat växa-stöd).
if (emp.vaxaStod === 'forsta_anstalld') {
lines.push(' <gem:ForstaAnstalld faltkod="062">1</gem:ForstaAnstalld>')
} else if (emp.vaxaStod === 'vaxa_stod') {
lines.push(' <gem:VaxaStod faltkod="063">1</gem:VaxaStod>')
}
// Sjuk/VAB/föräldra-dagar flows elsewhere:
// - Per-employee sick days are reported to Försäkringskassan, not AGI.
// The company-level total goes in HU as TotalSjuklonekostnad (FK499).
@@ -377,6 +582,9 @@ export function generateAGIXml(
if (periodAsNumber >= 202501) {
for (const emp of employees) {
if (!emp.absenceEvents || emp.absenceEvents.length === 0) continue
// Tombstoned IU: skip absence records too. A removed individuppgift
// can't be the parent of frånvarouppgifter for the period.
if (emp.removed) continue
let pnr: string
try {
@@ -386,20 +594,18 @@ export function generateAGIXml(
continue
}
// Stable specifikationsnummer per (employee, period): sort by date,
// then 1-based index. Two events on the same date get sequential
// numbers. The unique key in the Skatteverket spec is
// (BetalningsmottagarId, FranvaroDatum, FranvaroSpecifikationsnummer,
// RedovisningsPeriod, AgRegistreradId), so within one employee+date
// duplicates of the same number replace.
// Sort by date for stable XML output. The specifikationsnummer
// itself comes from salary_absence_days.franvaro_specifikationsnummer
// (assigned by DB trigger on INSERT and never re-numbered) so
// corrections survive day deletions without index shifts.
const sorted = [...emp.absenceEvents].sort((a, b) => {
if (a.date < b.date) return -1
if (a.date > b.date) return 1
return 0
return a.specifikationsnummer - b.specifikationsnummer
})
sorted.forEach((event, idx) => {
const specNumber = idx + 1
sorted.forEach((event) => {
const specNumber = event.specifikationsnummer
const isVab = event.type === 'vab'
const franvaroTyp = isVab ? 'TILLFALLIG_FORALDRAPENNING' : 'FORALDRAPENNING'
const hoursElement = isVab ? 'FranvaroTimmarTFP' : 'FranvaroTimmarFP'
@@ -423,12 +629,23 @@ export function generateAGIXml(
lines.push('</Skatteverket>')
return lines.join('\n')
const xml = lines.join('\n')
assertPayloadSize(xml)
return xml
}
/**
* Build individuppgifter snapshot for storage in agi_declarations table.
* Used for corrections — must reference same FK570.
* Build individuppgifter snapshot for storage in agi_declarations.individuppgifter
* (jsonb). Sole purpose: stabilise FK570 (specifikationsnummer) across
* corrections by recording the (personnummer → specificationNumber) binding
* along with the headline totals that drive a re-issue decision.
*
* GDPR Art.25 (data minimisation): xml_content is the authoritative record
* of what was filed. Storing the full per-benefit breakdown here would
* duplicate sensitive financial detail with no incremental audit value, so
* detailed benefit fields (car/fuel/housing/meals/other/fSkatt) are
* deliberately omitted from the snapshot. Reconstruct them from
* xml_content when needed.
*/
export function buildIndividuppgifterSnapshot(
employees: AGIEmployeeData[]
@@ -443,13 +660,11 @@ export function buildIndividuppgifterSnapshot(
return {
personnummer: pnr,
fk570: emp.specificationNumber,
ruta011: emp.grossSalary,
ruta001: emp.taxWithheld,
ruta020: emp.avgifterBasis,
fk821: emp.sickDays || 0,
fk822: emp.vabDays || 0,
fk823: emp.parentalDays || 0,
specificationNumber: emp.specificationNumber,
grossSalary: emp.grossSalary,
taxWithheld: emp.taxWithheld,
avgifterBasis: emp.avgifterBasis,
removed: emp.removed ?? false,
}
})
}