Files
accounted/lib/salary/__tests__/agi-xml.test.ts
T
Mattsson 04dbb31d7e Salary module (#245)
* feat: implement salary module with personnummer encryption, salary entries, tax tables, and AGI tracking

- Added personnummer encryption and decryption functions for secure storage.
- Created salary entries handling for journal entries including gross salary, tax withholding, and employer contributions.
- Implemented tax table lookup functionality for calculating tax amounts based on monthly income.
- Developed SQL migration for salary module including tables for payroll configuration, tax rates, employees, salary runs, salary run employees, salary line items, and AGI declarations.
- Established row-level security policies for all new tables to ensure company-scoped access.

* feat: add salary calculation modules for 2026

- Implemented engångsskatt calculation for one-time payments with tax brackets.
- Added löneväxling functionality for salary sacrifice to pension, including employer savings and warnings.
- Created pain.001 generator for salary batch payments in compliance with Swedish banking standards.
- Developed PDF template for payslips, including detailed breakdowns and employer costs.
- Generated seed data for Swedish tax tables for 2026, including SQL insert statements.
- Implemented traktamente calculations for per diem and mileage allowances, adhering to Skatteverket regulations.
- Added seed script for populating tax tables in the database.

* feat: Update meal reduction percentages in traktamente calculation

fix: Remove obsolete seed script for 2026 tax tables

feat: Extend SalaryRunStatus type to include 'corrected' status

feat: Implement KU10 XML generation endpoint for annual employee income statements

feat: Add endpoint for creating corrections to booked salary runs

feat: Implement endpoint for sending payslip PDFs to employees

feat: Create KU10 XML generator for annual reporting

feat: Add salary transaction matcher for auto-linking bank transactions to salary entries

chore: Add database migration for salary correction support

* feat: replace select elements with custom Select component for employment and salary types

* feat: enhance salary calculations with pension entry and avgifter category support
2026-04-15 11:17:39 +02:00

177 lines
6.0 KiB
TypeScript

import { describe, it, expect, vi } from 'vitest'
import { generateAGIXml, buildIndividuppgifterSnapshot } 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,
sickDays: 3,
vabDays: 2,
},
{
personnummer: 'emp2_encrypted',
specificationNumber: 2,
grossSalary: 35000,
taxWithheld: 10500,
avgifterBasis: 35000,
benefitCar: 5000,
},
]
const totals: AGITotals = {
totalTax: 22500,
totalAvgifterBasis: 80000,
avgifterByCategory: {
standard: { basis: 75000, amount: 23565 },
reduced65plus: { basis: 5000, amount: 510.50 },
},
}
describe('generateAGIXml', () => {
it('generates valid XML with correct root element', () => {
const xml = generateAGIXml(company, employees, totals)
expect(xml).toContain('<?xml version="1.0" encoding="UTF-8"?>')
expect(xml).toContain('<Skatteverket')
expect(xml).toContain('</Skatteverket>')
})
it('includes program name "gnubok"', () => {
const xml = generateAGIXml(company, employees, totals)
expect(xml).toContain('<Programnamn>gnubok</Programnamn>')
})
it('includes correct period', () => {
const xml = generateAGIXml(company, employees, totals)
expect(xml).toContain('<Period>202604</Period>')
})
it('includes org number without dash', () => {
const xml = generateAGIXml(company, employees, totals)
expect(xml).toContain('5561234567')
expect(xml).not.toContain('556123-4567')
})
it('includes huvuduppgift with total tax (Ruta 001)', () => {
const xml = generateAGIXml(company, employees, totals)
expect(xml).toContain('<AvdragenSkatt faltkod="001">22500</AvdragenSkatt>')
})
it('includes avgifter categories (Ruta 060, 061)', () => {
const xml = generateAGIXml(company, employees, totals)
expect(xml).toContain('faltkod="060"')
expect(xml).toContain('faltkod="061"')
})
it('decrypts personnummer for FK215', () => {
const xml = generateAGIXml(company, employees, totals)
expect(xml).toContain('<Personnummer faltkod="215">199001011234</Personnummer>')
expect(xml).toContain('<Personnummer faltkod="215">198506159876</Personnummer>')
})
it('includes consistent FK570 specifikationsnummer', () => {
const xml = generateAGIXml(company, employees, totals)
expect(xml).toContain('<Specifikationsnummer faltkod="570">1</Specifikationsnummer>')
expect(xml).toContain('<Specifikationsnummer faltkod="570">2</Specifikationsnummer>')
})
it('includes gross salary (Ruta 011) per employee', () => {
const xml = generateAGIXml(company, employees, totals)
expect(xml).toContain('<KontantBruttoloen faltkod="011">40000</KontantBruttoloen>')
expect(xml).toContain('<KontantBruttoloen faltkod="011">35000</KontantBruttoloen>')
})
it('includes tax withheld (Ruta 001) per employee', () => {
const xml = generateAGIXml(company, employees, totals)
// Both HU and IU have AvdragenSkatt
const matches = xml.match(/AvdragenSkatt/g)
expect(matches!.length).toBeGreaterThanOrEqual(3) // 1 HU + 2 IU
})
it('includes benefit values (Ruta 012 for car)', () => {
const xml = generateAGIXml(company, employees, totals)
expect(xml).toContain('<FormanBil faltkod="012">5000</FormanBil>')
})
it('includes absence fields FK821-FK823', () => {
const xml = generateAGIXml(company, employees, totals)
expect(xml).toContain('<SjukfranvaroDagar faltkod="821">3</SjukfranvaroDagar>')
expect(xml).toContain('<VabDagar faltkod="822">2</VabDagar>')
})
it('omits zero/undefined fields', () => {
const xml = generateAGIXml(company, employees, totals)
// Employee 1 has no car benefit
// Employee 2 has no sick days
// Check that we don't emit empty tags
const lines = xml.split('\n')
for (const line of lines) {
if (line.includes('faltkod')) {
expect(line).not.toContain('>0</')
}
}
})
it('marks corrections with Rattelse flag', () => {
const xml = generateAGIXml(company, employees, totals, true)
expect(xml).toContain('<Rattelse>J</Rattelse>')
})
it('does not include Rattelse flag for initial filing', () => {
const xml = generateAGIXml(company, employees, totals, false)
expect(xml).not.toContain('Rattelse')
})
it('escapes XML special characters in company name', () => {
const specialCompany = { ...company, companyName: 'Test & <Co>' }
const xml = generateAGIXml(specialCompany, employees, totals)
expect(xml).not.toContain('Test & <Co>')
})
})
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)
expect(snapshot[0]).toHaveProperty('fk821', 3)
expect(snapshot[0]).toHaveProperty('fk822', 2)
})
})