import { decryptPersonnummer } from '../personnummer' /** * AGI XML generator — Arbetsgivardeklaration på individnivå. * * Produces XML conforming to Skatteverket's schema: * http://xmls.skatteverket.se/se/skatteverket/da/instans/schema/1.1 * * The XML can be uploaded on Skatteverket's AGI e-tjänst. For programmatic * submission use the JSON API flow via the skatteverket extension instead. * * Sources verified against Skatteverket's schema + technical description * (SKV 269, teknisk beskrivning 1.1.16): * - Root: * - HU totals: SummaSkatteavdr (497), SummaArbAvgSlf (487), TotalSjuklonekostnad (499) * - IU identity: BetalningsmottagarId (215), Specifikationsnummer (570) * - IU amounts: KontantErsattningUlagAG (011), AvdrPrelSkatt (001) * - Every HU and IU must include AgRegistreradId (201) + RedovisningsPeriod (006) * * CRITICAL: FK570 (specifikationsnummer) must stay consistent per employee. * Corrections are detected by Skatteverket matching the same FK570. * * NOT HANDLED HERE (future work): * - : separate top-level section for parental leave events * (FK821 FranvaroDatum, FK823 FranvaroTyp={TILLFALLIG_FORALDRAPENNING| * FORALDRAPENNING}, etc.). Requires per-event date records, not a simple * day count. Per-employee sick days are NOT reported via AGI at all — * they go to Försäkringskassan separately. */ const INSTANS_NS = 'http://xmls.skatteverket.se/se/skatteverket/da/instans/schema/1.1' const KOMPONENT_NS = 'http://xmls.skatteverket.se/se/skatteverket/da/komponent/schema/1.1' export interface AGIEmployeeData { personnummer: string // Encrypted — decrypted for XML specificationNumber: number // FK570 — MUST stay consistent per employee 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). 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). */ benefitMeals?: number /** @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 as per-event records, not as an IU day count. Kept for snapshot compatibility. */ vabDays?: number /** @deprecated Parental leave is reported via top-level as per-event records, not as an IU day count. Kept for snapshot compatibility. */ parentalDays?: number } export interface AGICompanyData { orgNumber: string // 10 digits after stripping dashes companyName: string periodYear: number periodMonth: number contactName: string contactPhone: string contactEmail: string } export interface AGITotals { totalTax: number // FK497 SummaSkatteavdr totalAvgifterBasis: number // retained for compat (sum of IU underlag) totalAvgifterAmount: number // FK487 SummaArbAvgSlf (sum of calculated avgifter across categories) /** * FK499 TotalSjuklonekostnad — company's total sjuklön cost for the period * (sum of sjuklön paid days 2–14 across all employees). Required per 2025+ rules. * Day 1 is karens (unpaid); day 15+ is Försäkringskassan, not employer. */ totalSjuklonekostnad?: number avgifterByCategory: { standard?: { basis: number; amount: number } reduced65plus?: { basis: number; amount: number } youth?: { basis: number; amount: number } } } /** * Thrown when required AGI data is missing. Caller should surface the message * to the user so they can fill in the missing field (org number, contact info). */ export class AGIIncompleteDataError extends Error { constructor(message: string, public readonly missingFields: string[]) { super(message) this.name = 'AGIIncompleteDataError' } } function assertRequiredCompanyData(company: AGICompanyData): void { const missing: string[] = [] const orgNumberDigits = (company.orgNumber || '').replace(/\D/g, '') // Skatteverket's IDENTITET type requires either 10 digits (AB orgnr, we prefix // with "16") or 12 digits (personnummer for enskild firma). Any other length // is a data-entry error that we cannot silently fix. if (orgNumberDigits.length !== 10 && orgNumberDigits.length !== 12) missing.push('organisationsnummer') if (!company.contactName.trim()) missing.push('kontaktperson (namn)') if (!company.contactPhone.trim()) missing.push('telefon') if (!company.contactEmail.trim()) missing.push('e-post') if (missing.length > 0) { throw new AGIIncompleteDataError( `AGI kan inte genereras — följande uppgifter saknas: ${missing.join(', ')}. ` + 'Fyll i dem under Inställningar → Företag och Inställningar → Lön.', missing ) } } /** * Skatteverket's IDENTITET pattern (from the AGI XSD). Accepts: * - 12-digit personnummer YYYYMMDDXXXX (real dates 19xx/20xx, incl. leap days * and samordningsnummer where day = actual_day + 60) * - 12-digit AB/organisationsnummer: literal "16" + 10-digit orgnr, where the * 3rd digit (first of the 10-digit orgnr) is 1-3, 5, 6, 7, 8 or 9 (NOT 4, * and with specific restrictions) and the 5th is 2-9. * * Mirrored here so we can fail fast with a user-friendly message instead of * emitting XML that Skatteverket's validator will reject cryptically. */ const IDENTITET_PATTERN = /^(((19|20)[0-9][0-9])((((01|03|05|07|08|10|12)(6[1-9]|7[0-9]|8[0-9]|9[0-1]))|((04|06|09|11)(6[1-9]|7[0-9]|8[0-9]|90))|((02)(6[1-9]|7[0-9]|8[0-8])))|00[6-9][0-9]|[0-9][0-9]60)|(((19|20)(04|08|12|16|20|24|28|32|36|40|44|48|52|56|60|64|68|72|76|80|84|88|92|96)(0289))|(20000289)))(00[1-9]|0[1-9][0-9]|[1-9][0-9][0-9])[0-9]|16(1[0-9]|2[0-9]|3[0-9]|5[0-9]|6[0-4]|66|68|7[0-9]|8[0-9]|9[0-9])[2-9]\d{7}|((((19|20)[0-9][0-9])(((01|03|05|07|08|10|12)(0[1-9]|1[0-9]|2[0-9]|3[0-1]))|((04|06|09|11)(0[1-9]|1[0-9]|2[0-9]|30))|((02)(0[1-9]|1[0-9]|2[0-8]))))|(((19|20)(04|08|12|16|20|24|28|32|36|40|44|48|52|56|60|64|68|72|76|80|84|88|92|96)(0229))|(20000229)))(00[1-9]|0[1-9][0-9]|[1-9][0-9][0-9])[0-9]$/ /** * Normalize an org number or personnummer to Skatteverket's 12-character * IDENTITET format, required by the AGI schema for Avsandare/Organisationsnummer, * AgRegistreradId, and Arendeagare. * * - 10-digit orgnr (AB e.g. 5561234567) → prefixed with "16" → 165561234567 * - 12-digit personnummer (EF e.g. 196904206942) → used as-is * * Throws AGIIncompleteDataError if the resulting value cannot match the * IDENTITET pattern — this catches bogus test data (e.g. "420694-2069") before * the file reaches Skatteverket. */ function toIdentitet(raw: string): string { const digits = raw.replace(/\D/g, '') let candidate: string if (digits.length === 12) candidate = digits else if (digits.length === 10) candidate = `16${digits}` else { throw new AGIIncompleteDataError( `Ogiltigt organisations-/personnummer (${digits.length} siffror). ` + 'Ange ett giltigt svenskt organisationsnummer (10 siffror, t.ex. 556123-4567) ' + 'eller fullständigt personnummer (12 siffror, YYYYMMDD-XXXX) under Inställningar → Företag.', ['organisationsnummer'] ) } if (!IDENTITET_PATTERN.test(candidate)) { throw new AGIIncompleteDataError( `Ogiltigt organisationsnummer "${raw}" — värdet är inte ett svenskt organisationsnummer eller personnummer enligt Skatteverkets format. ` + 'Kontrollera värdet under Inställningar → Företag. För AB ska det vara 10 siffror (t.ex. 556123-4567). ' + 'För enskild firma ska det vara ett fullständigt 12-siffrigt personnummer (YYYYMMDD-XXXX).', ['organisationsnummer'] ) } return candidate } /** * Generate AGI XML for a period. * * Throws AGIIncompleteDataError if required fields (orgNumber, contact info) * are missing — we never emit partial XML that Skatteverket would reject. */ export function generateAGIXml( company: AGICompanyData, employees: AGIEmployeeData[], totals: AGITotals, _isCorrection: boolean = false ): string { assertRequiredCompanyData(company) const orgIdentitet = toIdentitet(company.orgNumber) const period = `${company.periodYear}${String(company.periodMonth).padStart(2, '0')}` const createdAt = new Date().toISOString().replace(/\.\d+Z$/, '') const lines: string[] = [] lines.push('') lines.push( `` ) // ── Avsandare (komponent namespace) ────────────────────────── lines.push(' ') lines.push(' gnubok') lines.push(` ${orgIdentitet}`) lines.push(' ') lines.push(` ${escapeXml(company.contactName)}`) lines.push(` ${escapeXml(company.contactPhone)}`) lines.push(` ${escapeXml(company.contactEmail)}`) lines.push(' ') lines.push(` ${createdAt}`) lines.push(' ') // ── Blankettgemensamt (komponent namespace) ────────────────── lines.push(' ') lines.push(' ') lines.push(` ${orgIdentitet}`) lines.push(' ') lines.push(` ${escapeXml(company.contactName)}`) lines.push(` ${escapeXml(company.contactPhone)}`) lines.push(` ${escapeXml(company.contactEmail)}`) lines.push(' ') lines.push(' ') lines.push(' ') // ── Blankett: Huvuduppgift (komponent namespace) ───────────── lines.push(' ') lines.push(' ') lines.push(` ${orgIdentitet}`) lines.push(` ${period}`) lines.push(' ') lines.push(' ') // HU/IU substitute for the abstract gem:Uppgift element in the komponent // namespace (substitution group head). Use the concrete element directly — // gem:Uppgift itself is abstract and cannot appear in an instance document. lines.push(' ') // AgRegistreradId is wrapped in ArbetsgivareHUGROUP, all payload elements // live in the komponent namespace (gem: prefix). lines.push(' ') lines.push(` ${orgIdentitet}`) lines.push(' ') lines.push(` ${period}`) // FK497 — Summa skatteavdrag (total from all IU) if (totals.totalTax > 0) { lines.push(` ${formatAmount(totals.totalTax)}`) } // FK487 — Summa arbetsgivaravgifter och SLF (calculated total, NOT basis) if (totals.totalAvgifterAmount > 0) { lines.push(` ${formatAmount(totals.totalAvgifterAmount)}`) } // FK499 — Total sjuklönekostnad (legal requirement from 2025 when > 0) if (totals.totalSjuklonekostnad && totals.totalSjuklonekostnad > 0) { lines.push(` ${formatAmount(totals.totalSjuklonekostnad)}`) } lines.push(' ') lines.push(' ') lines.push(' ') // ── Blankett: Individuppgift (one per employee) ────────────── for (const emp of employees) { let pnr: string try { pnr = decryptPersonnummer(emp.personnummer) } catch { throw new Error( `Kunde inte dekryptera personnummer för anställd med FK570=${emp.specificationNumber}. ` + 'AGI kan inte genereras utan giltigt personnummer.' ) } lines.push(' ') lines.push(' ') lines.push(` ${orgIdentitet}`) lines.push(` ${period}`) lines.push(' ') lines.push(' ') lines.push(' ') // Identity groups wrap AgRegistreradId and BetalningsmottagarId in IU. lines.push(' ') lines.push(` ${orgIdentitet}`) lines.push(' ') // BetalningsmottagarId must be inside BetalningsmottagareIDChoice (an // xs:choice allowing BetalningsmottagarId | Fodelsetid | AnnatId). lines.push(' ') lines.push(' ') lines.push(` ${pnr}`) lines.push(' ') lines.push(' ') lines.push(` ${period}`) lines.push(` ${emp.specificationNumber}`) // FK011 — Kontant ersättning, underlag arbetsgivaravgifter (= gross salary) if (emp.grossSalary > 0) { lines.push(` ${formatAmount(emp.grossSalary)}`) } // FK001 — Avdragen preliminärskatt if (emp.taxWithheld > 0) { lines.push(` ${formatAmount(emp.taxWithheld)}`) } // FK013 — Bilförmån (skattepliktig, underlag AG) if (emp.benefitCar && emp.benefitCar > 0) { lines.push(` ${formatAmount(emp.benefitCar)}`) } // FK018 — Drivmedel vid bilförmån if (emp.benefitFuel && emp.benefitFuel > 0) { lines.push(` ${formatAmount(emp.benefitFuel)}`) } // 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(` ${formatAmount(emp.benefitHousing)}`) } // FK012 — Övriga skattepliktiga förmåner if (emp.benefitOther && emp.benefitOther > 0) { lines.push(` ${formatAmount(emp.benefitOther)}`) } // 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(` ${formatAmount(emp.fSkattPayment)}`) } // 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). // - VAB and parental leave are reported via the top-level // section (FK820-827) as per-event date records, // not as per-IU day counts. Not implemented in this generator yet. void emp.sickDays void emp.vabDays void emp.parentalDays lines.push(' ') lines.push(' ') lines.push(' ') } lines.push('') return lines.join('\n') } /** * Build individuppgifter snapshot for storage in agi_declarations table. * Used for corrections — must reference same FK570. */ export function buildIndividuppgifterSnapshot( employees: AGIEmployeeData[] ): Record[] { return employees.map(emp => { let pnr: string try { pnr = decryptPersonnummer(emp.personnummer) } catch { pnr = 'DECRYPTION_FAILED' } 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, } }) } // ============================================================ // Helpers // ============================================================ function escapeXml(str: string): string { return str .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"') .replace(/'/g, ''') } function formatAmount(amount: number): string { return Math.round(amount).toString() }