Files
accounted/lib/salary/__tests__/agi-xml.test.ts
T
MattssonandClaude Fable 5 4e14182a00 fix(salary): declare, book and pay AGI in whole kronor (SKV per-sats computation) (#1611)
* fix(salary): declare, book and pay AGI in whole kronor (SKV per-sats computation)

A user's first lönekörning surfaced öre amounts in the AGI payable while
Skatteverket deals in whole kronor. Three connected defects:

- the AGI XML rounded amounts (Math.round); öretal bortfaller (SFF
  2011:1261 22 kap. 1 §) requires truncation, and FK487 must be
  Skatteverket's own per-sats computation on the whole-krona underlag sums
  (IK587, kontroll B_006), not a truncation of the öre-exact engine sum
- the salary booking credited 2731 with exact öre, leaving a residual
  after the whole-krona skattekonto draw; 2731 now carries the declared
  amount with the remainder on 3740 (Öres- och kronutjämning)
- the LB payment file and TaxPaymentPanel paid/showed öre; they now use
  the declared whole-krona totals stored on agi_declarations (which also
  lets skattekonto auto-settlement match the draw); legacy öre rows keep
  paying öre-exact so pre-deploy bookings still clear 2731

New lib/salary/declared-avgifter.ts implements the SKV computation (per-IU
whole-krona underlag, per-sats sums, youth/växa cap splits, exact integer
math) shared by the AGI generator, the booking split and the preview.
Review overrides route all legs through the same per-category truncation;
basis overrides are inert on money totals (they never reach the filed
IUs); the v1 book route gains override parity with book-run; F-skatt rows
ignore avgifter overrides on every surface. Booked runs show their posted
verifikat instead of a recomputed projection. tax_withheld_override
requires whole kronor. Adversarially verified over three /skeptic rounds.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* chore: merge origin/main and re-ratchet the öre-round baseline

The merge brought #1609 (net-pay öresavrundning) whose two new
Math.round(x*100)/100 occurrences are counted against the baseline this
branch had tightened from 637 to 629; 631 keeps the net -6 improvement
without policing already-merged code.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(salary): address PR review (hybrid override computation, legacy youth cap, robustness)

CodeRabbit round on #1611, all findings in one pass:

- computeDeclaredAvgifterWithOverrides: one shared hybrid for the AGI
  generator AND the booking split. Overridden rows contribute their manual
  amounts per category; colleagues keep the SKV-exact per-sats underlag
  computation (a FoU override on one employee no longer costs the rest of
  the roster kronor of declared accuracy)
- youth cap keys on the RESOLVED category so legacy null-category rows
  classified as youth by the rate heuristic still get the 25k split
- F-skatt rows zero their avgifter_basis on both booking surfaces and in
  the preview, matching the AGI's isFSkattRow invariant
- preview route: posted-voucher lookup errors return 500 instead of
  masquerading as a booked run with no vouchers; 400/500 tests added
- run page clears stale AGI totals when the tax-payment fetch fails
- SalaryOverridePanel truncates the tax override to whole kronor so the
  schema's .int() cannot bounce a decimal input with a 400
- v1 book route override parity pinned by a lifecycle test
- DECISIONS.md format fixes + superseded entry marked; exempt category
  mapped explicitly; unified truncation-drift band with rationale

Declined (recorded): dating the decision entries 2026-08-13 (bot assumed
UTC; the decisions were made after midnight local time).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(salary): round-2 review nits (shared F-skatt helper, test hygiene)

- isFSkattStatus in declared-avgifter.ts: single source for the F-skatt
  exclusion, consumed by book-run, the v1 book route, the preview route and
  the AGI generator, per the Swedish review's drift-risk finding
- declared-avgifter test suite gets the standard beforeEach cleanup

Declined (recorded for the summary): auto-generated correction voucher for
regenerated legacy periods (data-repair follow-up needing Emil's go); SFF
22 kap. 1 par. citation doubt (verified against lagen.nu and already shipped
in tax-tables.ts); 3740 scope doubt (BAS generic utjamning account, Visma
praxis, matches the user's reference voucher).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-14 02:22:07 +02:00

577 lines
23 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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('<?xml version="1.0" encoding="UTF-8"?>')
})
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('<Skatteverket omrade="Arbetsgivardeklaration"')
})
it('closes the Skatteverket element', () => {
const xml = generateAGIXml(company, employees, totals)
expect(xml).toContain('</Skatteverket>')
})
})
describe('generateAGIXml: Avsandare', () => {
it('includes program name "gnubok"', () => {
const xml = generateAGIXml(company, employees, totals)
expect(xml).toContain('<gem:Programnamn>gnubok</gem:Programnamn>')
})
it('emits Organisationsnummer in IDENTITET format (16 + 10-digit AB orgnr)', () => {
const xml = generateAGIXml(company, employees, totals)
expect(xml).toContain('<gem:Organisationsnummer>165561234567</gem:Organisationsnummer>')
expect(xml).not.toContain('556123-4567')
})
it('includes technical contact (name, phone, email)', () => {
const xml = generateAGIXml(company, employees, totals)
expect(xml).toContain('<gem:Namn>Anna Admin</gem:Namn>')
expect(xml).toContain('<gem:Telefon>0701234567</gem:Telefon>')
expect(xml).toContain('<gem:Epostadress>anna@test.se</gem:Epostadress>')
})
it('emits Avsandare in the komponent namespace (gem: prefix)', () => {
const xml = generateAGIXml(company, employees, totals)
expect(xml).toContain('<gem:Avsandare>')
expect(xml).toContain('</gem:Avsandare>')
})
})
describe('generateAGIXml: Blankettgemensamt', () => {
it('includes AgRegistreradId for the employer in IDENTITET format', () => {
const xml = generateAGIXml(company, employees, totals)
expect(xml).toContain('<gem:AgRegistreradId>165561234567</gem:AgRegistreradId>')
})
it('emits Blankettgemensamt in the komponent namespace', () => {
const xml = generateAGIXml(company, employees, totals)
expect(xml).toContain('<gem:Blankettgemensamt>')
expect(xml).toContain('</gem:Blankettgemensamt>')
})
})
describe('generateAGIXml: Huvuduppgift (HU)', () => {
it('includes AgRegistreradId with FK201 inside HU (IDENTITET format)', () => {
const xml = generateAGIXml(company, employees, totals)
expect(xml).toContain('<gem:AgRegistreradId faltkod="201">165561234567</gem:AgRegistreradId>')
})
it('includes RedovisningsPeriod with FK006 inside HU', () => {
const xml = generateAGIXml(company, employees, totals)
expect(xml).toContain('<gem:RedovisningsPeriod faltkod="006">202604</gem:RedovisningsPeriod>')
})
it('emits total tax as SummaSkatteavdr FK497 (not AvdragenSkatt FK001)', () => {
const xml = generateAGIXml(company, employees, totals)
expect(xml).toContain('<gem:SummaSkatteavdr faltkod="497">22500</gem:SummaSkatteavdr>')
// Legacy incorrect HU element must not appear
expect(xml).not.toMatch(/<gem:HU>[\s\S]*<AvdragenSkatt[\s\S]*<\/gem:HU>/)
})
it('emits total employer contributions as SummaArbAvgSlf FK487 with öre truncated', () => {
const xml = generateAGIXml(company, employees, totals)
// 24 075,50 declares as 24 075: öretal bortfaller (SFF 2011:1261
// 22 kap. 1 §), truncation, never rounding. Math.round would declare
// 1 kr more than Skatteverket draws from the skattekonto.
expect(xml).toContain('<gem:SummaArbAvgSlf faltkod="487">24075</gem:SummaArbAvgSlf>')
})
it('truncates öre on every HU amount, even at ,99', () => {
const oreTotals: AGITotals = {
...totals,
totalTax: 12268,
totalAvgifterAmount: 16073.84,
totalSjuklonekostnad: 1234.99,
}
const xml = generateAGIXml(company, employees, oreTotals)
expect(xml).toContain('<gem:SummaSkatteavdr faltkod="497">12268</gem:SummaSkatteavdr>')
expect(xml).toContain('<gem:SummaArbAvgSlf faltkod="487">16073</gem:SummaArbAvgSlf>')
expect(xml).toContain('<gem:TotalSjuklonekostnad faltkod="499">1234</gem:TotalSjuklonekostnad>')
})
it('does not let IEEE drift under an exact krona lose it in truncation', () => {
// 51 158 × 0,3142 = 16 073.843600000001 in floats; a raw Math.trunc of
// a value like 16 074 stored as 16 073.999999999 would drop a whole
// krona. truncateToWholeKronor rounds to öre first.
const driftTotals: AGITotals = {
...totals,
totalAvgifterAmount: 16073.999999999998,
}
const xml = generateAGIXml(company, employees, driftTotals)
expect(xml).toContain('<gem:SummaArbAvgSlf faltkod="487">16074</gem:SummaArbAvgSlf>')
})
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(/<gem:HU>[\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('<gem:TotalSjuklonekostnad faltkod="499">4200</gem:TotalSjuklonekostnad>')
})
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('<gem:BetalningsmottagarId faltkod="215">199001011234</gem:BetalningsmottagarId>')
expect(xml).toContain('<gem:BetalningsmottagarId faltkod="215">198506159876</gem:BetalningsmottagarId>')
expect(xml).not.toContain('<Personnummer faltkod="215">')
})
it('wraps BetalningsmottagarId in BetalningsmottagareIUGROUP → BetalningsmottagareIDChoice', () => {
const xml = generateAGIXml(company, employees, totals)
expect(xml).toContain('<gem:BetalningsmottagareIUGROUP>')
expect(xml).toContain('<gem:BetalningsmottagareIDChoice>')
expect(xml).toContain('</gem:BetalningsmottagareIDChoice>')
expect(xml).toContain('</gem:BetalningsmottagareIUGROUP>')
// Ensure correct nesting order (IUGROUP contains IDChoice which contains the id)
expect(xml).toMatch(/<gem:BetalningsmottagareIUGROUP>\s*<gem:BetalningsmottagareIDChoice>\s*<gem:BetalningsmottagarId/)
})
it('wraps AgRegistreradId in ArbetsgivareIUGROUP inside IU', () => {
const xml = generateAGIXml(company, employees, totals)
expect(xml).toContain('<gem:ArbetsgivareIUGROUP>')
expect(xml).toContain('</gem:ArbetsgivareIUGROUP>')
})
it('preserves Specifikationsnummer FK570 per employee', () => {
const xml = generateAGIXml(company, employees, totals)
expect(xml).toContain('<gem:Specifikationsnummer faltkod="570">1</gem:Specifikationsnummer>')
expect(xml).toContain('<gem:Specifikationsnummer faltkod="570">2</gem:Specifikationsnummer>')
})
it('uses KontantErsattningUlagAG FK011 (not KontantBruttoloen) for gross salary', () => {
const xml = generateAGIXml(company, employees, totals)
expect(xml).toContain('<gem:KontantErsattningUlagAG faltkod="011">40000</gem:KontantErsattningUlagAG>')
expect(xml).toContain('<gem:KontantErsattningUlagAG faltkod="011">35000</gem:KontantErsattningUlagAG>')
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('<gem:AvdrPrelSkatt faltkod="001">12000</gem:AvdrPrelSkatt>')
expect(xml).toContain('<gem:AvdrPrelSkatt faltkod="001">10500</gem:AvdrPrelSkatt>')
})
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('<gem:SkatteplBilformanUlagAG faltkod="013">5000</gem:SkatteplBilformanUlagAG>')
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 <Admin>' }
const xml = generateAGIXml(specialCompany, employees, totals)
expect(xml).not.toContain('A&B <Admin>')
expect(xml).toContain('A&amp;B &lt;Admin&gt;')
})
})
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(
'<gem:KontantErsattningEjUlagSA faltkod="131">15000</gem:KontantErsattningEjUlagSA>'
)
})
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 <gem:Franvarouppgift> per absence event', () => {
const xml = generateAGIXml(company, employeesWithAbsence, totals)
const matches = xml.match(/<gem:Franvarouppgift>/g) ?? []
expect(matches).toHaveLength(3)
})
it('emits TILLFALLIG_FORALDRAPENNING with FranvaroTimmarTFP for VAB', () => {
const xml = generateAGIXml(company, employeesWithAbsence, totals)
expect(xml).toContain('<gem:FranvaroTyp faltkod="823">TILLFALLIG_FORALDRAPENNING</gem:FranvaroTyp>')
expect(xml).toContain('<gem:FranvaroTimmarTFP faltkod="825">8</gem:FranvaroTimmarTFP>')
expect(xml).toContain('<gem:FranvaroTimmarTFP faltkod="825">4</gem:FranvaroTimmarTFP>')
})
it('emits FORALDRAPENNING with FranvaroTimmarFP for parental leave', () => {
const xml = generateAGIXml(company, employeesWithAbsence, totals)
expect(xml).toContain('<gem:FranvaroTyp faltkod="823">FORALDRAPENNING</gem:FranvaroTyp>')
expect(xml).toContain('<gem:FranvaroTimmarFP faltkod="827">8</gem:FranvaroTimmarFP>')
})
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('<gem:FranvaroSpecifikationsnummer faltkod="822">1</gem:FranvaroSpecifikationsnummer>')
expect(xml).toContain('<gem:FranvaroSpecifikationsnummer faltkod="822">2</gem:FranvaroSpecifikationsnummer>')
})
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('<gem:FranvaroSpecifikationsnummer faltkod="822">2</gem:FranvaroSpecifikationsnummer>')
expect(xml).not.toContain('<gem:FranvaroSpecifikationsnummer faltkod="822">1</gem:FranvaroSpecifikationsnummer>')
})
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('<gem:FranvaroTimmarTFP faltkod="825">4.5</gem:FranvaroTimmarTFP>')
})
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('<gem:FranvaroTimmarTFP faltkod="825">24</gem:FranvaroTimmarTFP>')
})
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('<gem:Franvarouppgift>')
expect(xml).toContain('<gem:FranvaroDatum faltkod="821">2025-01-15</gem:FranvaroDatum>')
})
it('places Frånvarouppgift after IU Blanketts and before </Skatteverket>', () => {
const xml = generateAGIXml(company, employeesWithAbsence, totals)
const lastBlankettClose = xml.lastIndexOf('</gem:Blankett>')
const firstFranvaro = xml.indexOf('<gem:Franvarouppgift>')
const closeRoot = xml.indexOf('</Skatteverket>')
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('<gem:Franvarouppgift>'), xml.indexOf('</gem:Franvarouppgift>'))
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('<gem:FranvaroChoice>')
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('<Skatteverket omrade="Arbetsgivardeklaration"')
expect(xml).toContain('</Skatteverket>')
expect(xml).toContain('<gem:RedovisningsPeriod faltkod="006">202604</gem:RedovisningsPeriod>')
expect(xml).toContain('<gem:AgRegistreradId faltkod="201">165561234567</gem:AgRegistreradId>')
// No individuppgifter and no absence section
expect(xml).not.toContain('<gem:IU>')
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)
})
})