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)
})
})