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 — 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 FK570 for correction reference', () => { const snapshot = buildIndividuppgifterSnapshot(employees) expect(snapshot[0].fk570).toBe(1) expect(snapshot[1].fk570).toBe(2) }) it('includes all required rutor', () => { const snapshot = buildIndividuppgifterSnapshot(employees) expect(snapshot[0]).toHaveProperty('ruta011', 40000) expect(snapshot[0]).toHaveProperty('ruta001', 12000) expect(snapshot[0]).toHaveProperty('ruta020', 40000) }) })