* feat: add script to import Skatteverket monthly tax tables as fallback TypeScript module
- Implemented a new script `import-tax-tables.ts` to parse fixed-width TXT tax tables from Skatteverket (SKV 434).
- The script generates a TypeScript module for emergency fallback when the Skatteverket open-data API is unavailable.
- Supports command-line argument for specifying the year and handles parsing of B-rows only.
- Outputs a structured TypeScript file containing tax data for specified years.
* feat: gate salary module behind dev-only flag
Temporarily disable the Lön module in production while the feature is
being completed. Sidebar entries ("Löner", "Anställda") still render but
are not clickable and show a "Kommer snart" badge. Middleware redirects
/salary* to / and returns 404 on /api/salary/* so the feature can't be
reached by direct URL. All gates check NODE_ENV === 'development' so
local dev keeps full access for continued development.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat: refactor bank file import wizard to streamline column mapping and enhance CSV handling
* fix: bump migration timestamp to avoid collision with logos_bucket
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat: enhance AGI generation and salary entry calculations with improved status checks and error handling
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
303 lines
12 KiB
TypeScript
303 lines
12 KiB
TypeScript
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', () => {
|
|
const xml = generateAGIXml(company, employees, totals)
|
|
expect(xml).toContain('<gem:SummaArbAvgSlf faltkod="487">24076</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&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)
|
|
})
|
|
})
|