* feat(salary): per-day absence tracking with calendar UX Replace aggregated-day absence counts with per-day records so payroll calculations can correctly enforce Swedish legal rules that depend on actual dates: karensavdrag once per sjuklöneperiod, återinsjuknande within 5 calendar days, allmänt högriskskydd cap of 10 karensavdrag per rolling 12 months, day-8 läkarintyg flag, day-15 transition to Försäkringskassan. Adds: - salary_absence_days table (RLS, dedup unique on employee+date+type) - /api/salary/employees/[id]/absence CRUD route - deriveAbsenceLineItems helper that walks per-day records into sjuklöneperioder and emits correctly-classified line items, with the existing absence-calculator formulas reused for VAB / parental - Per-employee pay-spec detail page with month-grid AbsenceCalendar - Calculate route now derives line items from the calendar before running the salary engine, replacing the prior sumQuantity model - Salary run GET surfaces the formatted Skatteverket arbetsgivare ID so downstream UI can build extension URLs without a second round-trip - GET /salary/runs/[id]/employees/[employeeId] for the detail page Tests: 15 new unit tests covering segment merge, återinsjuknande within 5 days, högriskskydd cap, FK transition flag, läkarintyg flag, VAB/parental semesterlönegrundande ceilings. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(skatteverket): harden API client + add NEXT_PUBLIC_SKATTEVERKET_ENABLED feature flag Three hardening fixes from the prior audit, plus a runtime extension toggle for phased rollout. api-client.ts: - Map 429 to a new SkatteverketAuthError code RATE_LIMITED with a Swedish user message. The 4 req/sec local rate limiter normally prevents this, but the per-consumer gateway quota can still hit. - Extend the error union with TOKEN_CORRUPTED for the token-store fix below. token-store.ts: - Surface decryption failures instead of silently returning null. A rotated key or tampered ciphertext used to look like "not connected"; callers now get TOKEN_CORRUPTED with a clear "anslut igen med BankID" message and a structured log line for ops. Extension dispatcher (app/api/extensions/ext/[...path]/route.ts): - Per-extension feature flag table. When NEXT_PUBLIC_SKATTEVERKET_ENABLED is not exactly "true", the dispatcher returns 503 with code EXTENSION_DISABLED, letting ops disable a single integration mid- rollout without redeploying or removing it from extensions.config.json. UI panels (SkatteverketPanel, AGIPanel) detect the 503 and render an empty state. Tests: 7 api-client cases (401/403/403-Behörighet/429/5xx/200/auth-error codes) + 2 token-store cases (no-row → null, corrupted → TOKEN_CORRUPTED). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(salary): emit AGI Frånvarouppgift per SKV 4785, add AGIPanel for one-click submission AGI XML upgrade: - Emit <gem:Franvarouppgift> top-level blocks for VAB and parental leave events sourced from salary_absence_days, per SKV 4785 + technical doc. Element order matches the spec example file. TILLFALLIG_FORALDRAPENNING for VAB / FORALDRAPENNING for parental, with FranvaroTimmarTFP (FK825) or FranvaroTimmarFP (FK827) for hours. Stable 1-based specifikationsnummer per (employee, period), date-sorted. Skipped entirely for periods before 202501. - Sick days are NOT emitted (they go to Försäkringskassan). - FK499 TotalSjuklonekostnad now derived from sick_day2_14.quantity × dailyRate × 0.80 instead of Math.abs(amount). The line-item amount is the net deduction (lostPay − sjuklon), not the cost, so the prior formula understated by a factor of four. AGI submission UI: - New AGIPanel mirroring SkatteverketPanel's validate → draft → lock → BankID-sign → poll-submitted flow. Detects 503 EXTENSION_DISABLED and renders a clear empty state. Replaces the bare "Skicka till Skatteverket" button on /salary/runs/[id], keeping the AGI XML download as a sibling for archival / manual upload fallback. - Salary run rows now link to the per-employee detail page added in the previous commit. Tests: 14 new agi-xml cases covering element order, type↔hour-field mapping, specifikationsnummer ordering, fractional-hour formatting, range clamping (0.01-24.00), period guard at 202501 boundary, placement after Blankett blocks, multi-employee date ordering, required-fields invariant, omission when no events. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(skatteverket): skattekonto integration — read-only saldo + transactions, daily sync, per-row bokför Adds read-only Skattekonto v2.1 access via the existing BankID OAuth flow (extends the OAuth scope with `skattekonto`). Daily background sync pulls saldo + transactions, dedupes on (company_id, dedup_key), and surfaces the data in a /skattekonto dashboard plus a settings panel for connection management. Backend: - skattekonto-client.ts: GET /skattekonton/{omfragad}/saldo and /transaktioner. Felkod 1–5 mapped to Swedish messages via dedicated SkatteverketSkattekontoError. - skattekonto-sync.ts: parallel saldo + transaktioner fetch, UPSERT on (company_id, dedup_key) so kommande rows graduate to tidigare in place. Dedup key uses transaktionsidentitet when available, else sha256 of (date|amount|text). Caches saldo snapshot in extension_data. Emits skattekonto.synced / balance.changed (sign flip) / transaction.upcoming (first appearance) / connection.expired. - skattekonto-booking.ts: keyword→counter-account rules with AB/EF differentiation (2510 vs 2012 for preliminärskatt; 2731/2710/2650 for arbetsgivaravgifter/avdragen skatt/moms; 8423/8313 for kostnads-/intäktsränta). Creates a draft journal entry against BAS 1630, leaves it for the user to review and commit. Throws NO_COUNTER_ACCOUNT instead of guessing when no rule matches. - Daily cron at 0 4 * * * (Swedish 06:00). Double-gated by CRON_SECRET and NEXT_PUBLIC_SKATTEVERKET_ENABLED. Per-company cooldown of 1 hour, time budget 50s, distinct `expired` status for token-exhaustion separate from generic errors. Database: - skattekonto_transactions: company-scoped with RLS, unique (company_id, dedup_key), indexed on (company_id, date DESC) and (company_id, status). journal_entry_id FK with ON DELETE SET NULL so a row can be re-bokförd after entry deletion. Frontend: - /skattekonto/page.tsx: dashboard with saldo card, transactions list (booked + upcoming), per-row "Bokför" action. - /settings/skatteverket: connection panel showing scope/expiry. - Extension toggle in SettingsSidebar (gated by ENABLED_EXTENSION_IDS). Tests: 9 booking-rule cases (counter-account guessing, AB/EF divergence, no-match throw) + 7 mapper cases (dedup key stability, sign convention, kommande→tidigare graduation). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix: address PR review findings Build: - Fix Next.js build failure: Zod refuses .partial() on a refined schema. Replace AbsenceRangeQuerySchema.partial().extend(...) in the absence DELETE handler with a fresh z.object that defines its own optional fields. Greptile findings (PR #388): - skattekonto_transactions UPDATE policy was missing WITH CHECK; without it a user could mutate company_id to one they don't belong to. Edit the original migration for fresh applies + add a follow-up migration that drops/recreates the policy with both clauses (already applied to prod via Supabase MCP). - FK499 TotalSjuklonekostnad now reads sjuklonRate from run.calculation_params (snapshot taken at calc time) instead of a hardcoded 0.80, so an operator override (e.g. CBA-specific rate) is honored. Falls back to 0.80 for older runs without the snapshot. - Rename NEXT_PUBLIC_SKATTEVERKET_ENABLED → SKATTEVERKET_ENABLED so the flag is server-side only. NEXT_PUBLIC_* vars are inlined into the client bundle at build time, which would create split-brain (server 503 vs client still rendering enabled flow) on a flag flip without redeploy. UI panels detect 503 by response code, not by reading the env directly, so no client-visible change is needed. - Add pg-real RLS smoke tests for both new tables (salary_absence_days and skattekonto_transactions): tenant SELECT isolation, UPDATE WITH CHECK enforcement, unique-constraint enforcement, cross-tenant dedup key allowed. Swedish compliance review: - Document the högriskskydd cap interpretation in derive-absence-line-items.ts. We count *sjuklöneperioder* in the rolling 12-month window, matching the law's plain reading ("från och med den 11:e sjukperioden ... görs inget karensavdrag"). An alternative reading counts only periods that actually had karens deducted; that requires persisting per-period karens-deduction state, which gnubok doesn't yet do. The period-count reading can over- suppress, never under-suppress, so it's the safer default. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(test): inline skattekonto fixtures so core-only CI runs without dev_docs dev_docs/ is gitignored, so the skattekonto-mappers test failed in CI when it tried to readFileSync from dev_docs/skattekonto(2.1.0)/examples/. Inline the saldoResponse + transaktionerResponse fixtures verbatim from the spec; the test still verifies our mappers + dedup-key logic against the same shape. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
465 lines
18 KiB
TypeScript
465 lines
18 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)
|
||
})
|
||
})
|
||
|
||
// ─── 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 },
|
||
{ date: '2026-04-16', type: 'vab', hours: 4 },
|
||
],
|
||
},
|
||
{
|
||
personnummer: 'emp2_encrypted',
|
||
specificationNumber: 2,
|
||
grossSalary: 35000,
|
||
taxWithheld: 10500,
|
||
avgifterBasis: 35000,
|
||
absenceEvents: [
|
||
{ date: '2026-04-20', type: 'parental', hours: 8 },
|
||
],
|
||
},
|
||
]
|
||
|
||
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 }],
|
||
}],
|
||
totals,
|
||
)
|
||
expect(xml).not.toMatch(/FranvaroTimmarTFP|FranvaroProcentTFP/)
|
||
})
|
||
|
||
it('uses 1-based stable specifikationsnummer per employee, ordered by date', () => {
|
||
const xml = generateAGIXml(company, employeesWithAbsence, totals)
|
||
// emp1 has two events on 2026-04-15 and 2026-04-16
|
||
expect(xml).toContain('<gem:FranvaroSpecifikationsnummer faltkod="822">1</gem:FranvaroSpecifikationsnummer>')
|
||
expect(xml).toContain('<gem:FranvaroSpecifikationsnummer faltkod="822">2</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 }],
|
||
}],
|
||
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 }],
|
||
}],
|
||
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 }],
|
||
}],
|
||
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 (gnubok 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 }],
|
||
}],
|
||
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')
|
||
})
|
||
})
|