import { describe, it, expect, vi } from 'vitest' import { generateAGIXml, buildIndividuppgifterSnapshot, AGIIncompleteDataError, } from '../agi/xml-generator' import type { AGICompanyData, AGIEmployeeData, AGITotals } from '../agi/xml-generator' // Mock personnummer decryption vi.mock('../personnummer', () => ({ decryptPersonnummer: (encrypted: string) => { if (encrypted === 'emp1_encrypted') return '199001011234' if (encrypted === 'emp2_encrypted') return '198506159876' return '000000000000' }, })) const company: AGICompanyData = { orgNumber: '556123-4567', companyName: 'Test AB', periodYear: 2026, periodMonth: 4, contactName: 'Anna Admin', contactPhone: '0701234567', contactEmail: 'anna@test.se', } const employees: AGIEmployeeData[] = [ { personnummer: 'emp1_encrypted', specificationNumber: 1, grossSalary: 40000, taxWithheld: 12000, avgifterBasis: 40000, }, { personnummer: 'emp2_encrypted', specificationNumber: 2, grossSalary: 35000, taxWithheld: 10500, avgifterBasis: 35000, benefitCar: 5000, }, ] const totals: AGITotals = { totalTax: 22500, totalAvgifterBasis: 80000, totalAvgifterAmount: 24075.5, avgifterByCategory: { standard: { basis: 75000, amount: 23565 }, reduced65plus: { basis: 5000, amount: 510.5 }, }, } describe('generateAGIXml: root structure', () => { it('starts with XML declaration', () => { const xml = generateAGIXml(company, employees, totals) expect(xml).toContain('') }) it('uses the Skatteverket AGI namespace (schema 1.1)', () => { const xml = generateAGIXml(company, employees, totals) expect(xml).toContain('xmlns="http://xmls.skatteverket.se/se/skatteverket/da/instans/schema/1.1"') // Declares the komponent namespace for shared building blocks (Avsandare etc.) expect(xml).toContain('xmlns:gem="http://xmls.skatteverket.se/se/skatteverket/da/komponent/schema/1.1"') // Reject the old bogus namespace expect(xml).not.toContain('infoForBeskworksgiv') expect(xml).not.toContain('/ai/instans/') }) it('sets omrade="Arbetsgivardeklaration" on the root element', () => { const xml = generateAGIXml(company, employees, totals) expect(xml).toContain(' { const xml = generateAGIXml(company, employees, totals) expect(xml).toContain('') }) }) describe('generateAGIXml: Avsandare', () => { it('includes program name "gnubok"', () => { const xml = generateAGIXml(company, employees, totals) expect(xml).toContain('gnubok') }) it('emits Organisationsnummer in IDENTITET format (16 + 10-digit AB orgnr)', () => { const xml = generateAGIXml(company, employees, totals) expect(xml).toContain('165561234567') expect(xml).not.toContain('556123-4567') }) it('includes technical contact (name, phone, email)', () => { const xml = generateAGIXml(company, employees, totals) expect(xml).toContain('Anna Admin') expect(xml).toContain('0701234567') expect(xml).toContain('anna@test.se') }) it('emits Avsandare in the komponent namespace (gem: prefix)', () => { const xml = generateAGIXml(company, employees, totals) expect(xml).toContain('') expect(xml).toContain('') }) }) describe('generateAGIXml: Blankettgemensamt', () => { it('includes AgRegistreradId for the employer in IDENTITET format', () => { const xml = generateAGIXml(company, employees, totals) expect(xml).toContain('165561234567') }) it('emits Blankettgemensamt in the komponent namespace', () => { const xml = generateAGIXml(company, employees, totals) expect(xml).toContain('') expect(xml).toContain('') }) }) describe('generateAGIXml: Huvuduppgift (HU)', () => { it('includes AgRegistreradId with FK201 inside HU (IDENTITET format)', () => { const xml = generateAGIXml(company, employees, totals) expect(xml).toContain('165561234567') }) it('includes RedovisningsPeriod with FK006 inside HU', () => { const xml = generateAGIXml(company, employees, totals) expect(xml).toContain('202604') }) it('emits total tax as SummaSkatteavdr FK497 (not AvdragenSkatt FK001)', () => { const xml = generateAGIXml(company, employees, totals) expect(xml).toContain('22500') // Legacy incorrect HU element must not appear expect(xml).not.toMatch(/[\s\S]*/) }) it('emits total employer contributions as SummaArbAvgSlf FK487', () => { const xml = generateAGIXml(company, employees, totals) expect(xml).toContain('24076') }) it('does NOT emit FK060/061/062: those field codes do not exist in HU', () => { const xml = generateAGIXml(company, employees, totals) // Look for those faltkoder inside the HU section const huMatch = xml.match(/[\s\S]*?<\/gem:HU>/) expect(huMatch).not.toBeNull() const hu = huMatch![0] expect(hu).not.toContain('faltkod="060"') expect(hu).not.toContain('faltkod="061"') expect(hu).not.toContain('faltkod="062"') }) it('emits TotalSjuklonekostnad FK499 when sjuklön cost is reported', () => { const withSjuklon = { ...totals, totalSjuklonekostnad: 4200 } const xml = generateAGIXml(company, employees, withSjuklon) expect(xml).toContain('4200') }) it('omits TotalSjuklonekostnad when zero or undefined', () => { const xml = generateAGIXml(company, employees, totals) expect(xml).not.toContain('TotalSjuklonekostnad') const zero = { ...totals, totalSjuklonekostnad: 0 } expect(generateAGIXml(company, employees, zero)).not.toContain('TotalSjuklonekostnad') }) }) describe('generateAGIXml: Individuppgift (IU)', () => { it('uses BetalningsmottagarId FK215 (not Personnummer) for the payment recipient', () => { const xml = generateAGIXml(company, employees, totals) expect(xml).toContain('199001011234') expect(xml).toContain('198506159876') expect(xml).not.toContain('') }) it('wraps BetalningsmottagarId in BetalningsmottagareIUGROUP → BetalningsmottagareIDChoice', () => { const xml = generateAGIXml(company, employees, totals) expect(xml).toContain('') expect(xml).toContain('') expect(xml).toContain('') expect(xml).toContain('') // Ensure correct nesting order (IUGROUP contains IDChoice which contains the id) expect(xml).toMatch(/\s*\s* { const xml = generateAGIXml(company, employees, totals) expect(xml).toContain('') expect(xml).toContain('') }) it('preserves Specifikationsnummer FK570 per employee', () => { const xml = generateAGIXml(company, employees, totals) expect(xml).toContain('1') expect(xml).toContain('2') }) it('uses KontantErsattningUlagAG FK011 (not KontantBruttoloen) for gross salary', () => { const xml = generateAGIXml(company, employees, totals) expect(xml).toContain('40000') expect(xml).toContain('35000') expect(xml).not.toContain('KontantBruttoloen') }) it('uses AvdrPrelSkatt FK001 (not AvdragenSkatt) for withheld tax in IU', () => { const xml = generateAGIXml(company, employees, totals) expect(xml).toContain('12000') expect(xml).toContain('10500') }) it('includes AgRegistreradId and RedovisningsPeriod in every IU', () => { const xml = generateAGIXml(company, employees, totals) // 1 HU + 2 IU = 3 occurrences each const agRegMatches = xml.match(/AgRegistreradId faltkod="201"/g) const periodMatches = xml.match(/RedovisningsPeriod faltkod="006"/g) expect(agRegMatches?.length).toBe(3) expect(periodMatches?.length).toBe(3) }) it('maps benefit_car to SkatteplBilformanUlagAG FK013 (not FormanBil FK012)', () => { const xml = generateAGIXml(company, employees, totals) expect(xml).toContain('5000') expect(xml).not.toContain('FormanBil') }) it('omits empty/zero fields', () => { const xml = generateAGIXml(company, employees, totals) const lines = xml.split('\n') for (const line of lines) { if (line.includes('faltkod')) { expect(line).not.toMatch(/>0<\//) } } }) it('escapes XML special characters in contact info', () => { const specialCompany = { ...company, contactName: 'A&B ' } const xml = generateAGIXml(specialCompany, employees, totals) expect(xml).not.toContain('A&B ') expect(xml).toContain('A&B <Admin>') }) }) describe('generateAGIXml: F-skatt payee IU (FK131)', () => { // Data layer (generate-declaration.ts) zeroes grossSalary for F-skatt // payees and routes the cash to fSkattPayment: the same payment must // never appear as both FK011 (underlag AG) and FK131 (ej underlag SA). const fSkattEmployee: AGIEmployeeData = { personnummer: 'emp2_encrypted', specificationNumber: 3, grossSalary: 0, taxWithheld: 0, avgifterBasis: 0, fSkattPayment: 15000, } it('emits KontantErsattningEjUlagSA FK131 with the payment amount', () => { const xml = generateAGIXml(company, [fSkattEmployee], totals) expect(xml).toContain( '15000' ) }) it('does not emit FK011 or FK001 for the F-skatt payment', () => { const xml = generateAGIXml(company, [fSkattEmployee], totals) expect(xml).not.toContain('faltkod="011"') expect(xml).not.toContain('faltkod="001"') }) }) describe('generateAGIXml: fail-fast on missing data', () => { it('throws AGIIncompleteDataError when org number is missing', () => { const bad = { ...company, orgNumber: '' } expect(() => generateAGIXml(bad, employees, totals)).toThrow(AGIIncompleteDataError) expect(() => generateAGIXml(bad, employees, totals)).toThrow(/organisationsnummer/) }) it('throws when org number has too few digits', () => { const bad = { ...company, orgNumber: '12345' } expect(() => generateAGIXml(bad, employees, totals)).toThrow(AGIIncompleteDataError) }) it('throws when contact phone is missing', () => { const bad = { ...company, contactPhone: '' } expect(() => generateAGIXml(bad, employees, totals)).toThrow(/telefon/) }) it('throws when contact email is missing', () => { const bad = { ...company, contactEmail: '' } expect(() => generateAGIXml(bad, employees, totals)).toThrow(/e-post/) }) it('lists all missing fields on the error', () => { const bad = { ...company, orgNumber: '', contactPhone: '', contactEmail: '' } try { generateAGIXml(bad, employees, totals) throw new Error('should have thrown') } catch (err) { expect(err).toBeInstanceOf(AGIIncompleteDataError) expect((err as AGIIncompleteDataError).missingFields).toEqual( expect.arrayContaining(['organisationsnummer', 'telefon', 'e-post']) ) } }) }) describe('buildIndividuppgifterSnapshot', () => { it('builds snapshot with decrypted personnummer', () => { const snapshot = buildIndividuppgifterSnapshot(employees) expect(snapshot).toHaveLength(2) expect(snapshot[0].personnummer).toBe('199001011234') expect(snapshot[1].personnummer).toBe('198506159876') }) it('preserves specificationNumber for correction reference', () => { const snapshot = buildIndividuppgifterSnapshot(employees) expect(snapshot[0].specificationNumber).toBe(1) expect(snapshot[1].specificationNumber).toBe(2) }) it('includes the core IU amounts', () => { const snapshot = buildIndividuppgifterSnapshot(employees) expect(snapshot[0]).toHaveProperty('grossSalary', 40000) expect(snapshot[0]).toHaveProperty('taxWithheld', 12000) expect(snapshot[0]).toHaveProperty('avgifterBasis', 40000) }) }) // ─── Frånvarouppgift (FK820-827) ──────────────────────────────────── describe('generateAGIXml: Frånvarouppgift', () => { const employeesWithAbsence: AGIEmployeeData[] = [ { personnummer: 'emp1_encrypted', specificationNumber: 1, grossSalary: 40000, taxWithheld: 12000, avgifterBasis: 40000, absenceEvents: [ { date: '2026-04-15', type: 'vab', hours: 8, specifikationsnummer: 1 }, { date: '2026-04-16', type: 'vab', hours: 4, specifikationsnummer: 2 }, ], }, { personnummer: 'emp2_encrypted', specificationNumber: 2, grossSalary: 35000, taxWithheld: 10500, avgifterBasis: 35000, absenceEvents: [ { date: '2026-04-20', type: 'parental', hours: 8, specifikationsnummer: 1 }, ], }, ] it('emits a per absence event', () => { const xml = generateAGIXml(company, employeesWithAbsence, totals) const matches = xml.match(//g) ?? [] expect(matches).toHaveLength(3) }) it('emits TILLFALLIG_FORALDRAPENNING with FranvaroTimmarTFP for VAB', () => { const xml = generateAGIXml(company, employeesWithAbsence, totals) expect(xml).toContain('TILLFALLIG_FORALDRAPENNING') expect(xml).toContain('8') expect(xml).toContain('4') }) it('emits FORALDRAPENNING with FranvaroTimmarFP for parental leave', () => { const xml = generateAGIXml(company, employeesWithAbsence, totals) expect(xml).toContain('FORALDRAPENNING') expect(xml).toContain('8') }) it('does NOT emit TFP hour-fields for parental events', () => { const xml = generateAGIXml( company, [{ ...employeesWithAbsence[1], absenceEvents: [{ date: '2026-04-20', type: 'parental', hours: 8, specifikationsnummer: 1 }], }], totals, ) expect(xml).not.toMatch(/FranvaroTimmarTFP|FranvaroProcentTFP/) }) it('emits the persisted specifikationsnummer per event (stable across corrections)', () => { const xml = generateAGIXml(company, employeesWithAbsence, totals) // 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('1') expect(xml).toContain('2') }) 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('2') expect(xml).not.toContain('1') }) 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, specifikationsnummer: 1 }], }], totals, ) expect(xml).toContain('4.5') }) it('clamps hours into the spec range (0.01-24.00)', () => { const xml = generateAGIXml( company, [{ ...employeesWithAbsence[0], absenceEvents: [{ date: '2026-04-15', type: 'vab', hours: 50, specifikationsnummer: 1 }], }], totals, ) expect(xml).toContain('24') }) it('skips Frånvarouppgift entirely for periods before 202501', () => { const xml = generateAGIXml( { ...company, periodYear: 2024, periodMonth: 12 }, employeesWithAbsence, totals, ) expect(xml).not.toContain('Franvarouppgift') }) it('emits Frånvarouppgift for the boundary period 202501', () => { const xml = generateAGIXml( { ...company, periodYear: 2025, periodMonth: 1 }, [{ ...employeesWithAbsence[0], absenceEvents: [{ date: '2025-01-15', type: 'vab', hours: 8, specifikationsnummer: 1 }], }], totals, ) expect(xml).toContain('') expect(xml).toContain('2025-01-15') }) it('places Frånvarouppgift after IU Blanketts and before ', () => { const xml = generateAGIXml(company, employeesWithAbsence, totals) const lastBlankettClose = xml.lastIndexOf('') const firstFranvaro = xml.indexOf('') const closeRoot = xml.indexOf('') expect(firstFranvaro).toBeGreaterThan(lastBlankettClose) expect(closeRoot).toBeGreaterThan(firstFranvaro) }) it('emits FK820/824/826 absence-removal and percent fields not at all (Accounted always sends timmar)', () => { const xml = generateAGIXml(company, employeesWithAbsence, totals) expect(xml).not.toContain('faltkod="820"') expect(xml).not.toContain('faltkod="824"') expect(xml).not.toContain('faltkod="826"') }) it('preserves date order across employees with mixed types', () => { const xml = generateAGIXml(company, employeesWithAbsence, totals) const idx15 = xml.indexOf('2026-04-15') const idx16 = xml.indexOf('2026-04-16') const idx20 = xml.indexOf('2026-04-20') expect(idx15).toBeLessThan(idx16) expect(idx16).toBeLessThan(idx20) }) it('emits required fields per Frånvarouppgift (AgRegistreradId, RedovisningsPeriod, FranvaroDatum, BetalningsmottagarId, Specifikationsnummer, FranvaroChoice)', () => { const xml = generateAGIXml( company, [{ ...employeesWithAbsence[0], absenceEvents: [{ date: '2026-04-15', type: 'vab', hours: 8, specifikationsnummer: 1 }], }], totals, ) // Single Frånvarouppgift block const block = xml.slice(xml.indexOf(''), xml.indexOf('')) expect(block).toContain('faltkod="201"') // AgRegistreradId expect(block).toContain('faltkod="006"') // RedovisningsPeriod expect(block).toContain('faltkod="821"') // FranvaroDatum expect(block).toContain('faltkod="215"') // BetalningsmottagarId expect(block).toContain('faltkod="822"') // FranvaroSpecifikationsnummer expect(block).toContain('') expect(block).toContain('faltkod="823"') // FranvaroTyp inside choice }) it('omits the section entirely when no employee has absenceEvents', () => { const xml = generateAGIXml(company, employees, totals) expect(xml).not.toContain('Franvarouppgift') }) }) describe('generateAGIXml: nolldeklaration (HU-only, no IU)', () => { const zeroTotals: AGITotals = { totalTax: 0, totalAvgifterBasis: 0, totalAvgifterAmount: 0, totalSjuklonekostnad: 0, avgifterByCategory: {}, } it('produces a valid declaration with an HU but no individuppgifter when the roster is empty', () => { const xml = generateAGIXml(company, [], zeroTotals) // Root + HU present expect(xml).toContain('') expect(xml).toContain('202604') expect(xml).toContain('165561234567') // No individuppgifter and no absence section expect(xml).not.toContain('') expect(xml).not.toContain('Franvarouppgift') }) it('omits every zero HU total field (FK497/FK487/FK499)', () => { const xml = generateAGIXml(company, [], zeroTotals) expect(xml).not.toContain('faltkod="497"') // SummaSkatteavdr expect(xml).not.toContain('faltkod="487"') // SummaArbAvgSlf expect(xml).not.toContain('faltkod="499"') // TotalSjuklonekostnad }) it('still validates required company data for a nolldeklaration', () => { expect(() => generateAGIXml({ ...company, orgNumber: '' }, [], zeroTotals), ).toThrow(AGIIncompleteDataError) }) })