import { decryptPersonnummer } from '../personnummer' /** * AGI XML generator — Arbetsgivardeklaration per Skatteverket Teknisk beskrivning. * * Generates the XML content for filing employer declarations. * The XML is stored in agi_declarations.xml_content for 7-year retention. */ export interface AGIEmployeeData { personnummer: string // Encrypted — will be decrypted for XML specificationNumber: number // FK570 — MUST stay consistent grossSalary: number // Ruta 011 taxWithheld: number // Ruta 001 avgifterBasis: number // Ruta 020 fSkattPayment?: number // Ruta 131 (F-skatt holders) // Benefits by type benefitCar?: number // Ruta 012 benefitFuel?: number // Ruta 013 benefitHousing?: number // Ruta 014 benefitMeals?: number // Ruta 015 benefitOther?: number // Ruta 019 // Absence (from 2025) sickDays?: number // FK821 vabDays?: number // FK822 parentalDays?: number // FK823 } export interface AGICompanyData { orgNumber: string // NNNNNN-NNNN format companyName: string periodYear: number periodMonth: number contactName: string contactPhone: string contactEmail: string } export interface AGITotals { totalTax: number // Ruta 001 (huvuduppgift) totalAvgifterBasis: number // Ruta 020 (huvuduppgift) avgifterByCategory: { standard?: { basis: number; amount: number } reduced65plus?: { basis: number; amount: number } youth?: { basis: number; amount: number } } } /** * Generate AGI XML for a period. * * CRITICAL: FK570 (specifikationsnummer) must stay consistent per employee. * Using a different number creates a new record instead of correcting. */ export function generateAGIXml( company: AGICompanyData, employees: AGIEmployeeData[], totals: AGITotals, isCorrection: boolean = false ): string { const period = `${company.periodYear}${String(company.periodMonth).padStart(2, '0')}` const lines: string[] = [] lines.push('') lines.push('') lines.push(' ') lines.push(` gnubok`) lines.push(` ${escapeXml(company.orgNumber.replace('-', ''))}`) lines.push(' ') lines.push(` ${escapeXml(company.contactName)}`) lines.push(` ${escapeXml(company.contactPhone)}`) lines.push(` ${escapeXml(company.contactEmail)}`) lines.push(' ') lines.push(' ') lines.push(' ') lines.push(` `) lines.push(` ${escapeXml(company.orgNumber.replace('-', ''))}`) lines.push(` `) lines.push(' ') // Huvuduppgift (employer totals) lines.push(' ') lines.push(' ') lines.push(` ${escapeXml(company.orgNumber.replace('-', ''))}`) lines.push(` ${period}`) if (isCorrection) { lines.push(' J') } lines.push(' ') lines.push(' ') lines.push(' ') // Ruta 001: Total skatteavdrag if (totals.totalTax > 0) { lines.push(` ${formatAmount(totals.totalTax)}`) } // Ruta 020: Total avgifter basis if (totals.totalAvgifterBasis > 0) { lines.push(` ${formatAmount(totals.totalAvgifterBasis)}`) } // Avgifter by category if (totals.avgifterByCategory.standard) { lines.push(` ${formatAmount(totals.avgifterByCategory.standard.basis)}`) } if (totals.avgifterByCategory.reduced65plus) { lines.push(` ${formatAmount(totals.avgifterByCategory.reduced65plus.basis)}`) } if (totals.avgifterByCategory.youth) { lines.push(` ${formatAmount(totals.avgifterByCategory.youth.basis)}`) } lines.push(' ') lines.push(' ') lines.push(' ') // Individuppgifter (per employee) for (const emp of employees) { lines.push(' ') lines.push(' ') lines.push(` ${escapeXml(company.orgNumber.replace('-', ''))}`) lines.push(` ${period}`) if (isCorrection) { lines.push(' J') } lines.push(' ') lines.push(' ') lines.push(' ') // FK215: Personnummer (CRITICAL: must be decrypted for AGI) 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(` ${pnr}`) // FK570: Specifikationsnummer (MUST stay consistent) lines.push(` ${emp.specificationNumber}`) // Ruta 011: Gross salary if (emp.grossSalary > 0) { lines.push(` ${formatAmount(emp.grossSalary)}`) } // Ruta 001: Tax withheld if (emp.taxWithheld > 0) { lines.push(` ${formatAmount(emp.taxWithheld)}`) } // Benefits if (emp.benefitCar && emp.benefitCar > 0) { lines.push(` ${formatAmount(emp.benefitCar)}`) } if (emp.benefitFuel && emp.benefitFuel > 0) { lines.push(` ${formatAmount(emp.benefitFuel)}`) } if (emp.benefitHousing && emp.benefitHousing > 0) { lines.push(` ${formatAmount(emp.benefitHousing)}`) } if (emp.benefitMeals && emp.benefitMeals > 0) { lines.push(` ${formatAmount(emp.benefitMeals)}`) } if (emp.benefitOther && emp.benefitOther > 0) { lines.push(` ${formatAmount(emp.benefitOther)}`) } // Ruta 020: Avgifter basis if (emp.avgifterBasis > 0) { lines.push(` ${formatAmount(emp.avgifterBasis)}`) } // Ruta 131: F-skatt payments if (emp.fSkattPayment && emp.fSkattPayment > 0) { lines.push(` ${formatAmount(emp.fSkattPayment)}`) } // Absence fields (from 2025) if (emp.sickDays && emp.sickDays > 0) { lines.push(` ${Math.round(emp.sickDays)}`) } if (emp.vabDays && emp.vabDays > 0) { lines.push(` ${Math.round(emp.vabDays)}`) } if (emp.parentalDays && emp.parentalDays > 0) { lines.push(` ${Math.round(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() }