Files
accounted/lib/salary/agi/xml-generator.ts
T
Jakob Wennberg 16fbcefbbc feat(invariants): shared format contracts + upgrade-path CI (#1364)
* feat(invariants): centralise shared format contracts, reconcile the org-number paths

The same format rules were written out independently across the codebase, and
where they disagreed the disagreement was invisible until a filing failed.

Worst case, now fixed: four Skatteverket- and Bolagsverket-bound export paths
each had their own idea of a valid organisationsnummer.

  lib/skatteverket/format.ts      strip '-' only      threw on any input with a space
  lib/salary/ku/ku10-generator.ts replace('-', '')    first hyphen only, spaces survived
  lib/salary/agi/xml-generator.ts strip non-digits    stray letters passed the length check
  lib/bokslut/ixbrl/validate      /^\d{6}-?\d{4}$/    rejected the 12-digit form, no Luhn

A company stored with a space or in 12-digit form could file AGI all year and
then fail at the arsredovisning deadline with a message that did not say why.

lib/invariants/ now owns account number, ISO date, four-digit fiscal year and
org number, each with the rationale recorded next to the rule. normalizeOrgNumber
moves here from lib/company-lookup/ and isSaneDateString from lib/utils.ts; both
old paths re-export, so no caller changes. lib/api/schemas.ts builds its
primitives on the module, so ~100 schemas inherit any correction.

The arsredovisning check-digit verdict is a warn, not an error: a wrong Luhn
digit is almost certainly a typo worth surfacing, but whether every org number
Bolagsverket accepts satisfies Luhn is a Swedish domain question we have not
verified against a primary source, and an error there blocks Skicka in. We do
not block a statutory filing on an unverified assumption.

KU10 still passes a 12-digit stored org number through unfolded. That is
pre-existing, and whether the KU10 schema wants 10 or 12 digits is not covered
by the swedish-payroll skill, so it is pinned by a test rather than changed
silently.

Guard 8 (hand-rolled-invariant) tracks the remaining 114 inline copies as a
ratchet that may only go down, same mechanism as the roundOre guard.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* test(ci): add an upgrade-path job that applies new migrations against real data

The pg-real job applies all 548 migrations to an EMPTY database. Empty means
zero rows, so a migration that adds a NOT NULL, adds a CHECK, creates a unique
index or backfills passes trivially in CI and can still fail on production,
where the rows exist. CI proved that a fresh install works; nothing proved that
an existing install upgrades.

The new pg-upgrade job: apply the schema as it stands at the merge base, seed a
small real company (three posted verifikat, balanced lines, one ore-level
amount), then apply ONLY the migrations this PR adds, then assert the data
survived (entries still posted, lines intact, ledger still balances, ore
unchanged, voucher numbers sequential). A PR with no migration no-ops.

Verified locally against supabase/postgres:15.8.1.060 rather than assumed, with
three deliberately bad migrations:

  rescale money on posted lines   empty: would pass   seeded: ERROR (immutability trigger)
  CHECK violating the ore row     empty: exit 0       seeded: exit 3
  NOT NULL on a populated column  empty: exit 0       seeded: exit 3

Base migrations are read out of the merge-base git tree, not the working tree,
so a PR that edits an already-shipped migration still gets the original applied
and the edit surfaces as a failure here.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs: record the invariants and upgrade-CI decisions

Two entries covering what this PR changes and, more importantly, the calls that
are not obvious from the diff: why the arsredovisning check-digit verdict is a
warning rather than an error, why KU10's 12-digit passthrough is pinned instead
of fixed, and why the ROT/RUT brf org-number schemas stay on their own rule.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs(test): mark the upgrade fixture as CI-only, never a production template

The fixture writes posted journal_entries and their lines directly, bypassing
the engine and the atomic commit RPC. That is the only way to hand a migration
pre-existing posted rows to break, and it is safe against a throwaway CI
database, but it reads like a sanctioned pattern to anyone who finds it later.

Says so explicitly, with the reason it is confined here (no voucher sequence to
keep gapless, no retention obligation on a database destroyed with the job) and
a pointer back to Hard Rule 2 for anything touching a real database.

Raised by the Swedish compliance review bot on #1364.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-03 16:30:14 +02:00

710 lines
33 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 { decryptPersonnummer } from '../personnummer'
import { isOrgNumberShaped } from '@/lib/invariants/org-number'
/**
* AGI XML generator: Arbetsgivardeklaration på individnivå.
*
* Produces XML conforming to Skatteverket's schema:
* http://xmls.skatteverket.se/se/skatteverket/da/instans/schema/1.1
*
* The XML can be uploaded on Skatteverket's AGI e-tjänst. For programmatic
* submission use the JSON API flow via the skatteverket extension instead.
*
* Sources verified against Skatteverket's schema + technical description
* (SKV 269, teknisk beskrivning 1.1.16):
* - Root: <Skatteverket omrade="Arbetsgivardeklaration">
* - HU totals: SummaSkatteavdr (497), SummaArbAvgSlf (487), TotalSjuklonekostnad (499)
* - IU identity: BetalningsmottagarId (215), Specifikationsnummer (570)
* - IU amounts: KontantErsattningUlagAG (011), AvdrPrelSkatt (001)
* - Every HU and IU must include AgRegistreradId (201) + RedovisningsPeriod (006)
*
* CRITICAL: FK570 (specifikationsnummer) must stay consistent per employee.
* Corrections are detected by Skatteverket matching the same FK570.
*
* Frånvarouppgift emission is implemented per Skatteverket SKV 4785 + the
* "Frånvarouppgift i samband med Arbetsgivardeklaration" technical doc:
* - One <gem:Franvarouppgift> per (employee, date, specifikationsnummer)
* - Sibling of <gem:Blankett>, top-level under <Skatteverket>
* - FranvaroChoice contains FranvaroTyp (TILLFALLIG_FORALDRAPENNING for VAB,
* FORALDRAPENNING for parental leave): the borttag flow is not used.
* - Hours emitted via FranvaroTimmarTFP (FK825) for VAB or FranvaroTimmarFP
* (FK827) for parental. The procent variants (824/826) are not used:
* Accounted tracks hours, not percent.
* - FranvaroSpecifikationsnummer is persisted on salary_absence_days
* (column franvaro_specifikationsnummer; assigned by DB trigger on
* INSERT, never re-numbered). Skatteverket replaces a Frånvarouppgift
* on match of (BetalningsmottagarId, FranvaroDatum,
* FranvaroSpecifikationsnummer, RedovisningsPeriod, AgRegistreradId).
* Because the number is stable, corrections survive day deletions:
* remaining events keep their original numbers, and Skatteverket
* matches each event back to its prior submission.
* - Periods before 202501 emit no Frånvarouppgift (Skatteverket rejects).
*
* Per-employee sick days are NOT reported via AGI under any version: they
* go to Försäkringskassan separately. The company-level FK499
* TotalSjuklonekostnad in HU is correctly emitted from sick_day2_14 line
* items × dailyRate × 0.80 (see agi/xml/route.ts).
*/
const INSTANS_NS = 'http://xmls.skatteverket.se/se/skatteverket/da/instans/schema/1.1'
const KOMPONENT_NS = 'http://xmls.skatteverket.se/se/skatteverket/da/komponent/schema/1.1'
// Programnamn: software identifier embedded in every AGI submission.
// Free-text per Skatteverket's schema (no vendor registry), but kept stable
// across visual rebrands so the value the tax authority sees never churns.
// Bump only if Skatteverket ever introduces a formal vendor registration
// and 'accounted' is the registered name there.
const AGI_PROGRAMNAMN = 'gnubok'
/**
* One absence event for AGI Frånvarouppgift emission. Loaded from
* salary_absence_days (per-day records). Sick days are NOT included: they
* go to Försäkringskassan, not Skatteverket.
*/
export interface AGIAbsenceEvent {
/** YYYY-MM-DD: emitted as FK821 FranvaroDatum. */
date: string
/** Mapped to FranvaroTyp:
* 'vab' → TILLFALLIG_FORALDRAPENNING (FK825 hours field)
* 'parental' → FORALDRAPENNING (FK827 hours field) */
type: 'vab' | 'parental'
/** Hours absent on this date, 0.01-24.00. Defaults to 8 in salary_absence_days. */
hours: number
/**
* FK822 FranvaroSpecifikationsnummer: stable per-(employee, year-month)
* sequence assigned at the DB level (see migration
* 20260517120000_salary_absence_days_franvaro_specifikationsnummer.sql).
* MUST stay constant across corrections: never recompute from array
* index. Persisted on salary_absence_days.franvaro_specifikationsnummer.
*/
specifikationsnummer: number
}
export interface AGIEmployeeData {
personnummer: string // Encrypted: decrypted for XML
specificationNumber: number // FK570: MUST stay consistent per employee
grossSalary: number // FK011 KontantErsattningUlagAG
taxWithheld: number // FK001 AvdrPrelSkatt
avgifterBasis: number // Retained for backwards compat; equals grossSalary for standard cases. Not emitted separately (FK011 already captures basis).
/**
* FK205 Borttag: tombstone this IU. When true, the XML emits only the
* identity fields (FK201, FK215, FK570, FK006) plus <Borttag>1</Borttag>;
* amounts and benefits are skipped. Skatteverket then removes the prior
* IU matching (AgRegistreradId, BetalningsmottagarId, RedovisningsPeriod,
* Specifikationsnummer). Only meaningful for periods that already had an
* AGI declaration filed.
*/
removed?: boolean
/**
* Växa-stöd flag: emitted as one of two mutually exclusive boolean fields:
* 'forsta_anstalld' → FK062 ForstaAnstalld (anställd före 2024-05-01)
* 'vaxa_stod' → FK063 VaxaStod (anställd efter 2024-04-30)
* Set when the employer claims växa-stöd reduction (10.21% avgifter rate)
* for this employee in the period. The cutoff date is hard-coded in the
* spec (Prop. 2023/24:80, see Skatteverket FK 1.7 revisionshistorik 1.19).
*/
vaxaStod?: 'forsta_anstalld' | 'vaxa_stod'
/**
* FK048 FormanHarJusterats: set when any benefit value on this IU has
* been adjusted away from the standard schablon. Reflects
* salary_run_employees.benefits_adjusted.
*/
benefitsAdjusted?: boolean
fSkattPayment?: number // FK131 KontantErsattningEjUlagSA
benefitCar?: number // FK013 SkatteplBilformanUlagAG (amount, BELOPP7)
benefitFuel?: number // FK018 DrivmVidBilformanUlagAG (amount, BELOPP7)
/**
* FK015 KostformanUlagAG (amount, BELOPP10). Kostförmån has its own
* dedicated field in the AGI spec with a PBB-linked schablon value:
* Skatteverket cross-checks the reported amount against the schablon.
* Aggregating meals into FK012 (övriga förmåner) triggers automated
* discrepancy notices. Always emit FK015 separately when > 0.
*/
benefitMeals?: number
/**
* Housing benefit indicator. FK041 (smahus) and FK043 (ej_smahus) are
* boolean KRYSS flags in the XSD: they just signal that this kind of
* benefit was given. The AMOUNT must be folded into benefitOther
* (FK012). Pass 'smahus' or 'ej_smahus' to set the flag; omit if no
* housing benefit applies.
*/
housingBenefit?: 'smahus' | 'ej_smahus'
/**
* FK012 SkatteplOvrigaFormanerUlagAG (amount, BELOPP10). Catch-all for
* taxable benefits without their own dedicated FK code: bike, wellness,
* "other", AND the full krona-amount for housing (since FK041/FK043
* carry only the flag). Meals go in benefitMeals (FK015), NOT here.
*/
benefitOther?: number
/**
* When true, benefit amounts and housing flags emit as the "ej underlag
* SA" variants (FK132/FK133/FK134/FK137/FK138) instead of the standard
* UlagAG variants (FK012/FK013/FK018/FK041/FK043). Set this for F-skatt
* holders and other payees whose benefits should not form basis for
* arbetsgivaravgifter. Defaults to false. FK131 (cash, ej UlagSA) is
* controlled separately via fSkattPayment.
*/
benefitsExcludedFromSAUnderlag?: boolean
/** @deprecated Per-employee sick days are not reported via AGI (goes to Försäkringskassan separately). Kept for snapshot compatibility. */
sickDays?: number
/** @deprecated VAB is reported via top-level <Franvarouppgift> as per-event records (see absenceEvents), not as an IU day count. Kept for snapshot compatibility. */
vabDays?: number
/** @deprecated Parental leave is reported via top-level <Franvarouppgift> as per-event records (see absenceEvents), not as an IU day count. Kept for snapshot compatibility. */
parentalDays?: number
/**
* Per-event absence records for the period. Drives <gem:Franvarouppgift>
* emission. VAB and parental only: sick days excluded by spec (FK).
*/
absenceEvents?: AGIAbsenceEvent[]
}
export interface AGICompanyData {
orgNumber: string // 10 digits after stripping dashes
companyName: string
periodYear: number
periodMonth: number
contactName: string
contactPhone: string
contactEmail: string
}
export interface AGITotals {
totalTax: number // FK497 SummaSkatteavdr
totalAvgifterBasis: number // retained for compat (sum of IU underlag)
totalAvgifterAmount: number // FK487 SummaArbAvgSlf (sum of calculated avgifter across categories)
/**
* FK499 TotalSjuklonekostnad: company's total sjuklön cost for the period
* (sum of sjuklön paid days 2-14 across all employees). Required per 2025+ rules.
* Day 1 is karens (unpaid); day 15+ is Försäkringskassan, not employer.
*/
totalSjuklonekostnad?: number
avgifterByCategory: {
standard?: { basis: number; amount: number }
reduced65plus?: { basis: number; amount: number }
youth?: { basis: number; amount: number }
}
}
/**
* Thrown when required AGI data is missing. Caller should surface the message
* to the user so they can fill in the missing field (org number, contact info).
*/
export class AGIIncompleteDataError extends Error {
constructor(message: string, public readonly missingFields: string[]) {
super(message)
this.name = 'AGIIncompleteDataError'
}
}
function assertRequiredCompanyData(company: AGICompanyData): void {
const missing: string[] = []
// Skatteverket's IDENTITET type requires either 10 digits (AB orgnr, we prefix
// with "16") or 12 digits (personnummer for enskild firma). Any other shape is
// a data-entry error that we cannot silently fix.
//
// Shared rule (lib/invariants/org-number.ts), not a local digit-strip: the
// previous `replace(/\D/g, '')` also swallowed letters, so a field holding
// stray text still measured 10 digits and passed.
if (!isOrgNumberShaped(company.orgNumber)) missing.push('organisationsnummer')
if (!company.contactName.trim()) missing.push('kontaktperson (namn)')
if (!company.contactPhone.trim()) missing.push('telefon')
if (!company.contactEmail.trim()) missing.push('e-post')
if (missing.length > 0) {
throw new AGIIncompleteDataError(
`AGI kan inte genereras, följande uppgifter saknas: ${missing.join(', ')}. ` +
'Fyll i dem under Inställningar → Företag och Inställningar → Lön.',
missing
)
}
}
/**
* Smallest period the AGI API accepts, per Skatteverket v1.7 spec §6.3:
* "redovisningsperiod (URI-parameter): YYYYMM, tidigast 201807".
* Periods before this raise HTTP 404 felkod 31 at SKV's gateway.
*/
const AGI_MIN_PERIOD_YYYYMM = 201807
function assertRequiredPeriod(year: number, month: number): void {
if (!Number.isInteger(year) || !Number.isInteger(month) || month < 1 || month > 12) {
throw new AGIIncompleteDataError(
`Ogiltig redovisningsperiod: ${year}-${month}. Ange ett giltigt år och månad (1-12).`,
['redovisningsperiod'],
)
}
const yyyymm = year * 100 + month
if (yyyymm < AGI_MIN_PERIOD_YYYYMM) {
throw new AGIIncompleteDataError(
`Redovisningsperioden ${year}-${String(month).padStart(2, '0')} är tidigare än ` +
`${Math.floor(AGI_MIN_PERIOD_YYYYMM / 100)}-${String(AGI_MIN_PERIOD_YYYYMM % 100).padStart(2, '0')}, ` +
'som är den tidigaste period Skatteverkets AGI-API accepterar (Tjänstebeskrivning v1.7 §6.3). ' +
'Kontrollera att lönekörningens period är korrekt.',
['redovisningsperiod'],
)
}
}
/**
* File-size ceilings from v1.7 §1: 100 MB on the test environment,
* 300 MB in production. Rejecting locally just means a cleaner Swedish
* error than the 413 felkod 27 SKV would otherwise return.
*/
const AGI_TEST_MAX_BYTES = 100 * 1024 * 1024
const AGI_PROD_MAX_BYTES = 300 * 1024 * 1024
export class AGIPayloadTooLargeError extends Error {
constructor(message: string, public readonly sizeBytes: number, public readonly limitBytes: number) {
super(message)
this.name = 'AGIPayloadTooLargeError'
}
}
/**
* Resolve the Skatteverket environment from a dedicated env var. Default
* to the stricter 'test' bucket when unset/unrecognised: a missing or
* misconfigured value must never silently raise the size ceiling.
*
* Documented in deployment runbook; substring-matching the API URL is
* forbidden (a misconfigured URL containing 'api.test.skatteverket.se'
* would otherwise lower the limit on a production tenant: the inverse
* was equally bad).
*/
function resolveSkatteverketEnv(): 'test' | 'production' {
const raw = process.env.SKATTEVERKET_ENV?.trim().toLowerCase()
if (raw === 'production' || raw === 'prod') return 'production'
if (raw === 'test') return 'test'
if (raw && raw !== '') {
// Unrecognised value: fail closed to test. Logged once so deployments
// catch typos in CI rather than at audit time.
// eslint-disable-next-line no-console
console.warn(
`SKATTEVERKET_ENV='${raw}' is not 'test' | 'production'; defaulting to 'test' (stricter limits).`,
)
}
return 'test'
}
function assertPayloadSize(xml: string): void {
const bytes = Buffer.byteLength(xml, 'utf8')
const env = resolveSkatteverketEnv()
const envLimit = env === 'production' ? AGI_PROD_MAX_BYTES : AGI_TEST_MAX_BYTES
if (bytes > envLimit) {
const mb = (bytes / (1024 * 1024)).toFixed(1)
const limitMb = Math.floor(envLimit / (1024 * 1024))
throw new AGIPayloadTooLargeError(
`AGI XML är för stort (${mb} MB). Skatteverkets gräns för denna miljö är ${limitMb} MB ` +
'(Tjänstebeskrivning v1.7 §1). Dela upp inlämningen i mindre paket per arbetsgivare eller period.',
bytes,
envLimit,
)
}
}
/**
* Skatteverket's IDENTITET pattern (from the AGI XSD). Accepts:
* - 12-digit personnummer YYYYMMDDXXXX (real dates 19xx/20xx, incl. leap days
* and samordningsnummer where day = actual_day + 60)
* - 12-digit AB/organisationsnummer: literal "16" + 10-digit orgnr, where the
* 3rd digit (first of the 10-digit orgnr) is 1-3, 5, 6, 7, 8 or 9 (NOT 4,
* and with specific restrictions) and the 5th is 2-9.
*
* Mirrored here so we can fail fast with a user-friendly message instead of
* emitting XML that Skatteverket's validator will reject cryptically.
*/
const IDENTITET_PATTERN = /^(((19|20)[0-9][0-9])((((01|03|05|07|08|10|12)(6[1-9]|7[0-9]|8[0-9]|9[0-1]))|((04|06|09|11)(6[1-9]|7[0-9]|8[0-9]|90))|((02)(6[1-9]|7[0-9]|8[0-8])))|00[6-9][0-9]|[0-9][0-9]60)|(((19|20)(04|08|12|16|20|24|28|32|36|40|44|48|52|56|60|64|68|72|76|80|84|88|92|96)(0289))|(20000289)))(00[1-9]|0[1-9][0-9]|[1-9][0-9][0-9])[0-9]|16(1[0-9]|2[0-9]|3[0-9]|5[0-9]|6[0-4]|66|68|7[0-9]|8[0-9]|9[0-9])[2-9]\d{7}|((((19|20)[0-9][0-9])(((01|03|05|07|08|10|12)(0[1-9]|1[0-9]|2[0-9]|3[0-1]))|((04|06|09|11)(0[1-9]|1[0-9]|2[0-9]|30))|((02)(0[1-9]|1[0-9]|2[0-8]))))|(((19|20)(04|08|12|16|20|24|28|32|36|40|44|48|52|56|60|64|68|72|76|80|84|88|92|96)(0229))|(20000229)))(00[1-9]|0[1-9][0-9]|[1-9][0-9][0-9])[0-9]$/
/**
* Normalize an org number or personnummer to Skatteverket's 12-character
* IDENTITET format, required by the AGI schema for Avsandare/Organisationsnummer,
* AgRegistreradId, and Arendeagare.
*
* - 10-digit orgnr (AB e.g. 5561234567) → prefixed with "16" → 165561234567
* - 12-digit personnummer (EF e.g. 196904206942) → used as-is
*
* Throws AGIIncompleteDataError if the resulting value cannot match the
* IDENTITET pattern: this catches bogus test data (e.g. "420694-2069") before
* the file reaches Skatteverket.
*/
function toIdentitet(raw: string): string {
const digits = raw.replace(/\D/g, '')
let candidate: string
if (digits.length === 12) candidate = digits
else if (digits.length === 10) candidate = `16${digits}`
else {
throw new AGIIncompleteDataError(
`Ogiltigt organisations-/personnummer (${digits.length} siffror). ` +
'Ange ett giltigt svenskt organisationsnummer (10 siffror, t.ex. 556123-4567) ' +
'eller fullständigt personnummer (12 siffror, YYYYMMDD-XXXX) under Inställningar → Företag.',
['organisationsnummer']
)
}
if (!IDENTITET_PATTERN.test(candidate)) {
throw new AGIIncompleteDataError(
`Ogiltigt organisationsnummer "${raw}": värdet är inte ett svenskt organisationsnummer eller personnummer enligt Skatteverkets format. ` +
'Kontrollera värdet under Inställningar → Företag. För AB ska det vara 10 siffror (t.ex. 556123-4567). ' +
'För enskild firma ska det vara ett fullständigt 12-siffrigt personnummer (YYYYMMDD-XXXX).',
['organisationsnummer']
)
}
return candidate
}
/**
* Generate AGI XML for a period.
*
* Throws AGIIncompleteDataError if required fields (orgNumber, contact info)
* are missing: we never emit partial XML that Skatteverket would reject.
*/
export function generateAGIXml(
company: AGICompanyData,
employees: AGIEmployeeData[],
totals: AGITotals,
_isCorrection: boolean = false
): string {
assertRequiredCompanyData(company)
assertRequiredPeriod(company.periodYear, company.periodMonth)
const orgIdentitet = toIdentitet(company.orgNumber)
const period = `${company.periodYear}${String(company.periodMonth).padStart(2, '0')}`
const createdAt = new Date().toISOString().replace(/\.\d+Z$/, '')
const lines: string[] = []
lines.push('<?xml version="1.0" encoding="UTF-8"?>')
lines.push(
`<Skatteverket omrade="Arbetsgivardeklaration" xmlns="${INSTANS_NS}" xmlns:gem="${KOMPONENT_NS}">`
)
// ── Avsandare (komponent namespace) ──────────────────────────
lines.push(' <gem:Avsandare>')
lines.push(` <gem:Programnamn>${AGI_PROGRAMNAMN}</gem:Programnamn>`)
lines.push(` <gem:Organisationsnummer>${orgIdentitet}</gem:Organisationsnummer>`)
lines.push(' <gem:TekniskKontaktperson>')
lines.push(` <gem:Namn>${escapeXml(company.contactName)}</gem:Namn>`)
lines.push(` <gem:Telefon>${escapeXml(company.contactPhone)}</gem:Telefon>`)
lines.push(` <gem:Epostadress>${escapeXml(company.contactEmail)}</gem:Epostadress>`)
lines.push(' </gem:TekniskKontaktperson>')
lines.push(` <gem:Skapad>${createdAt}</gem:Skapad>`)
lines.push(' </gem:Avsandare>')
// ── Blankettgemensamt (komponent namespace) ──────────────────
lines.push(' <gem:Blankettgemensamt>')
lines.push(' <gem:Arbetsgivare>')
lines.push(` <gem:AgRegistreradId>${orgIdentitet}</gem:AgRegistreradId>`)
lines.push(' <gem:Kontaktperson>')
lines.push(` <gem:Namn>${escapeXml(company.contactName)}</gem:Namn>`)
lines.push(` <gem:Telefon>${escapeXml(company.contactPhone)}</gem:Telefon>`)
lines.push(` <gem:Epostadress>${escapeXml(company.contactEmail)}</gem:Epostadress>`)
lines.push(' </gem:Kontaktperson>')
lines.push(' </gem:Arbetsgivare>')
lines.push(' </gem:Blankettgemensamt>')
// ── Blankett: Huvuduppgift (komponent namespace) ─────────────
lines.push(' <gem:Blankett>')
lines.push(' <gem:Arendeinformation>')
lines.push(` <gem:Arendeagare>${orgIdentitet}</gem:Arendeagare>`)
lines.push(` <gem:Period>${period}</gem:Period>`)
lines.push(' </gem:Arendeinformation>')
lines.push(' <gem:Blankettinnehall>')
// HU/IU substitute for the abstract gem:Uppgift element in the komponent
// namespace (substitution group head). Use the concrete element directly:
// gem:Uppgift itself is abstract and cannot appear in an instance document.
lines.push(' <gem:HU>')
// AgRegistreradId is wrapped in ArbetsgivareHUGROUP, all payload elements
// live in the komponent namespace (gem: prefix).
lines.push(' <gem:ArbetsgivareHUGROUP>')
lines.push(` <gem:AgRegistreradId faltkod="201">${orgIdentitet}</gem:AgRegistreradId>`)
lines.push(' </gem:ArbetsgivareHUGROUP>')
lines.push(` <gem:RedovisningsPeriod faltkod="006">${period}</gem:RedovisningsPeriod>`)
// FK497: Summa skatteavdrag (total from all IU)
if (totals.totalTax > 0) {
lines.push(` <gem:SummaSkatteavdr faltkod="497">${formatAmount(totals.totalTax)}</gem:SummaSkatteavdr>`)
}
// FK487: Summa arbetsgivaravgifter och SLF (calculated total, NOT basis)
if (totals.totalAvgifterAmount > 0) {
lines.push(` <gem:SummaArbAvgSlf faltkod="487">${formatAmount(totals.totalAvgifterAmount)}</gem:SummaArbAvgSlf>`)
}
// FK499: Total sjuklönekostnad (legal requirement from 2025 when > 0)
if (totals.totalSjuklonekostnad && totals.totalSjuklonekostnad > 0) {
lines.push(` <gem:TotalSjuklonekostnad faltkod="499">${formatAmount(totals.totalSjuklonekostnad)}</gem:TotalSjuklonekostnad>`)
}
lines.push(' </gem:HU>')
lines.push(' </gem:Blankettinnehall>')
lines.push(' </gem:Blankett>')
// ── Blankett: Individuppgift (one per employee) ──────────────
for (const emp of employees) {
// FK570 must be ≥ 1 (HELTAL, min 1 per spec). A 0 here would produce a
// STOPP-level rejection at Skatteverket; fail fast with a clearer
// Swedish message pointing at the missing column rather than letting
// bogus XML reach SKV.
if (!Number.isInteger(emp.specificationNumber) || emp.specificationNumber < 1) {
throw new AGIIncompleteDataError(
`Anställd saknar giltigt specifikationsnummer (FK570). ` +
'Specifikationsnumret måste vara ett heltal ≥ 1 och stabilt över korrigeringar. ' +
'Kontrollera fältet specification_number på den anställdes profil.',
['specifikationsnummer'],
)
}
let pnr: string
try {
pnr = decryptPersonnummer(emp.personnummer)
} catch {
throw new Error(
`Kunde inte dekryptera personnummer för anställd med FK570=${emp.specificationNumber}. ` +
'AGI kan inte genereras utan giltigt personnummer.'
)
}
lines.push(' <gem:Blankett>')
lines.push(' <gem:Arendeinformation>')
lines.push(` <gem:Arendeagare>${orgIdentitet}</gem:Arendeagare>`)
lines.push(` <gem:Period>${period}</gem:Period>`)
lines.push(' </gem:Arendeinformation>')
lines.push(' <gem:Blankettinnehall>')
lines.push(' <gem:IU>')
// Identity groups wrap AgRegistreradId and BetalningsmottagarId in IU.
lines.push(' <gem:ArbetsgivareIUGROUP>')
lines.push(` <gem:AgRegistreradId faltkod="201">${orgIdentitet}</gem:AgRegistreradId>`)
lines.push(' </gem:ArbetsgivareIUGROUP>')
// BetalningsmottagarId must be inside BetalningsmottagareIDChoice (an
// xs:choice allowing BetalningsmottagarId | Fodelsetid | AnnatId).
lines.push(' <gem:BetalningsmottagareIUGROUP>')
lines.push(' <gem:BetalningsmottagareIDChoice>')
lines.push(` <gem:BetalningsmottagarId faltkod="215">${pnr}</gem:BetalningsmottagarId>`)
lines.push(' </gem:BetalningsmottagareIDChoice>')
lines.push(' </gem:BetalningsmottagareIUGROUP>')
lines.push(` <gem:RedovisningsPeriod faltkod="006">${period}</gem:RedovisningsPeriod>`)
lines.push(` <gem:Specifikationsnummer faltkod="570">${emp.specificationNumber}</gem:Specifikationsnummer>`)
// FK205 Borttag: tombstone this IU. When set, skip all amount/benefit
// fields; only the identity quintuple above (FK201, FK215, FK006, FK570)
// plus this flag are needed for Skatteverket to remove the prior IU.
if (emp.removed) {
lines.push(' <gem:Borttag faltkod="205">1</gem:Borttag>')
lines.push(' </gem:IU>')
lines.push(' </gem:Blankettinnehall>')
lines.push(' </gem:Blankett>')
continue
}
// FK011: Kontant ersättning, underlag arbetsgivaravgifter (= gross salary)
if (emp.grossSalary > 0) {
lines.push(` <gem:KontantErsattningUlagAG faltkod="011">${formatAmount(emp.grossSalary)}</gem:KontantErsattningUlagAG>`)
}
// FK001: Avdragen preliminärskatt
if (emp.taxWithheld > 0) {
lines.push(` <gem:AvdrPrelSkatt faltkod="001">${formatAmount(emp.taxWithheld)}</gem:AvdrPrelSkatt>`)
}
const exclSA = emp.benefitsExcludedFromSAUnderlag === true
// Car benefit AMOUNT: FK013 (UlagAG) or FK133 (ej UlagSA)
if (emp.benefitCar && emp.benefitCar > 0) {
const code = exclSA ? '133' : '013'
const elem = exclSA ? 'SkatteplBilformanEjUlagSA' : 'SkatteplBilformanUlagAG'
lines.push(` <gem:${elem} faltkod="${code}">${formatAmount(emp.benefitCar)}</gem:${elem}>`)
}
// Fuel for car benefit AMOUNT: FK018 (UlagAG) or FK134 (ej UlagSA)
if (emp.benefitFuel && emp.benefitFuel > 0) {
const code = exclSA ? '134' : '018'
const elem = exclSA ? 'DrivmVidBilformanEjUlagSA' : 'DrivmVidBilformanUlagAG'
lines.push(` <gem:${elem} faltkod="${code}">${formatAmount(emp.benefitFuel)}</gem:${elem}>`)
}
// Kostförmån AMOUNT: FK015 (UlagAG) or FK139 (ej UlagSA). Has its own
// field because Skatteverket cross-checks the krona-belopp against the
// PBB-anchored schablon: folding it into FK012 triggers discrepancy
// notices. Always emit separately when > 0.
if (emp.benefitMeals && emp.benefitMeals > 0) {
const code = exclSA ? '139' : '015'
const elem = exclSA ? 'KostformanEjUlagSA' : 'KostformanUlagAG'
lines.push(` <gem:${elem} faltkod="${code}">${formatAmount(emp.benefitMeals)}</gem:${elem}>`)
}
// Housing benefit FLAGS (KRYSS, no amount on this element). The
// krona-amount belongs in benefitOther (FK012/FK132).
// FK041 BostadsformanSmahusUlagAG | FK137 BostadsformanSmahusEjUlagSA
// FK043 BostadsformanEjSmahusUlagAG | FK138 BostadsformanEjSmahusEjUlagSA
if (emp.housingBenefit === 'smahus') {
const code = exclSA ? '137' : '041'
const elem = exclSA ? 'BostadsformanSmahusEjUlagSA' : 'BostadsformanSmahusUlagAG'
lines.push(` <gem:${elem} faltkod="${code}">1</gem:${elem}>`)
} else if (emp.housingBenefit === 'ej_smahus') {
const code = exclSA ? '138' : '043'
const elem = exclSA ? 'BostadsformanEjSmahusEjUlagSA' : 'BostadsformanEjSmahusUlagAG'
lines.push(` <gem:${elem} faltkod="${code}">1</gem:${elem}>`)
}
// Övriga skattepliktiga förmåner AMOUNT: FK012 (UlagAG) or FK132 (ej UlagSA).
// Includes meals, bike, wellness, "other", and the full housing krona-amount.
if (emp.benefitOther && emp.benefitOther > 0) {
const code = exclSA ? '132' : '012'
const elem = exclSA ? 'SkatteplOvrigaFormanerEjUlagSA' : 'SkatteplOvrigaFormanerUlagAG'
lines.push(` <gem:${elem} faltkod="${code}">${formatAmount(emp.benefitOther)}</gem:${elem}>`)
}
// FK131: Ersättning till mottagare med F-skattsedel (ej underlag SA)
if (emp.fSkattPayment && emp.fSkattPayment > 0) {
lines.push(` <gem:KontantErsattningEjUlagSA faltkod="131">${formatAmount(emp.fSkattPayment)}</gem:KontantErsattningEjUlagSA>`)
}
// FK048: FormanHarJusterats (any benefit value adjusted away from schablon)
if (emp.benefitsAdjusted) {
lines.push(' <gem:FormanHarJusterats faltkod="048">1</gem:FormanHarJusterats>')
}
// FK062 / FK063: Växa-stöd. Mutually exclusive: FK062 for employees
// hired before 2024-05-01 (legacy "första anställda"-reglerna), FK063
// for those hired 2024-05-01 and later (utvidgat växa-stöd).
if (emp.vaxaStod === 'forsta_anstalld') {
lines.push(' <gem:ForstaAnstalld faltkod="062">1</gem:ForstaAnstalld>')
} else if (emp.vaxaStod === 'vaxa_stod') {
lines.push(' <gem:VaxaStod faltkod="063">1</gem:VaxaStod>')
}
// Sjuk/VAB/föräldra-dagar flows elsewhere:
// - Per-employee sick days are reported to Försäkringskassan, not AGI.
// The company-level total goes in HU as TotalSjuklonekostnad (FK499).
// - VAB and parental leave are reported via the top-level
// <Franvarouppgift> section (FK820-827) as per-event date records,
// not as per-IU day counts. Not implemented in this generator yet.
void emp.sickDays
void emp.vabDays
void emp.parentalDays
lines.push(' </gem:IU>')
lines.push(' </gem:Blankettinnehall>')
lines.push(' </gem:Blankett>')
}
// ── Frånvarouppgift (per-event VAB/parental records, FK820-827) ───────
// Skatteverket only accepts Frånvarouppgift from period 202501 onward.
const periodAsNumber = company.periodYear * 100 + company.periodMonth
if (periodAsNumber >= 202501) {
for (const emp of employees) {
if (!emp.absenceEvents || emp.absenceEvents.length === 0) continue
// Tombstoned IU: skip absence records too. A removed individuppgift
// can't be the parent of frånvarouppgifter for the period.
if (emp.removed) continue
let pnr: string
try {
pnr = decryptPersonnummer(emp.personnummer)
} catch {
// Already surfaced as a hard error in the IU loop above; skip silently here.
continue
}
// Sort by date for stable XML output. The specifikationsnummer
// itself comes from salary_absence_days.franvaro_specifikationsnummer
// (assigned by DB trigger on INSERT and never re-numbered) so
// corrections survive day deletions without index shifts.
const sorted = [...emp.absenceEvents].sort((a, b) => {
if (a.date < b.date) return -1
if (a.date > b.date) return 1
return a.specifikationsnummer - b.specifikationsnummer
})
sorted.forEach((event) => {
const specNumber = event.specifikationsnummer
const isVab = event.type === 'vab'
const franvaroTyp = isVab ? 'TILLFALLIG_FORALDRAPENNING' : 'FORALDRAPENNING'
const hoursElement = isVab ? 'FranvaroTimmarTFP' : 'FranvaroTimmarFP'
const hoursFaltkod = isVab ? '825' : '827'
lines.push(' <gem:Franvarouppgift>')
// Element order follows the spec example file (SKV 4785 doc, section 4).
lines.push(` <gem:AgRegistreradId faltkod="201">${orgIdentitet}</gem:AgRegistreradId>`)
lines.push(` <gem:RedovisningsPeriod faltkod="006">${period}</gem:RedovisningsPeriod>`)
lines.push(` <gem:FranvaroDatum faltkod="821">${event.date}</gem:FranvaroDatum>`)
lines.push(` <gem:BetalningsmottagarId faltkod="215">${pnr}</gem:BetalningsmottagarId>`)
lines.push(` <gem:FranvaroSpecifikationsnummer faltkod="822">${specNumber}</gem:FranvaroSpecifikationsnummer>`)
lines.push(' <gem:FranvaroChoice>')
lines.push(` <gem:FranvaroTyp faltkod="823">${franvaroTyp}</gem:FranvaroTyp>`)
lines.push(' </gem:FranvaroChoice>')
lines.push(` <gem:${hoursElement} faltkod="${hoursFaltkod}">${formatHours(event.hours)}</gem:${hoursElement}>`)
lines.push(' </gem:Franvarouppgift>')
})
}
}
lines.push('</Skatteverket>')
const xml = lines.join('\n')
assertPayloadSize(xml)
return xml
}
/**
* Build individuppgifter snapshot for storage in agi_declarations.individuppgifter
* (jsonb). Sole purpose: stabilise FK570 (specifikationsnummer) across
* corrections by recording the (personnummer → specificationNumber) binding
* along with the headline totals that drive a re-issue decision.
*
* GDPR Art.25 (data minimisation): xml_content is the authoritative record
* of what was filed. Storing the full per-benefit breakdown here would
* duplicate sensitive financial detail with no incremental audit value, so
* detailed benefit fields (car/fuel/housing/meals/other/fSkatt) are
* deliberately omitted from the snapshot. Reconstruct them from
* xml_content when needed.
*/
export function buildIndividuppgifterSnapshot(
employees: AGIEmployeeData[]
): Record<string, unknown>[] {
return employees.map(emp => {
let pnr: string
try {
pnr = decryptPersonnummer(emp.personnummer)
} catch {
pnr = 'DECRYPTION_FAILED'
}
return {
personnummer: pnr,
specificationNumber: emp.specificationNumber,
grossSalary: emp.grossSalary,
taxWithheld: emp.taxWithheld,
avgifterBasis: emp.avgifterBasis,
removed: emp.removed ?? false,
}
})
}
// ============================================================
// Helpers
// ============================================================
function escapeXml(str: string): string {
return str
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&apos;')
}
function formatAmount(amount: number): string {
return Math.round(amount).toString()
}
/**
* Format hours for FranvaroTimmarTFP/FP (FK825/827).
* Spec range: 0.01 - 24.00, up to two decimals. Whole-hour values emit
* without trailing zeros (e.g. 8 → "8") to match Skatteverket's example
* file ("4" not "4.00"); fractional values keep their decimals.
*/
function formatHours(hours: number): string {
const clamped = Math.max(0.01, Math.min(24, hours))
const rounded = Math.round(clamped * 100) / 100
return Number.isInteger(rounded) ? String(rounded) : rounded.toFixed(2).replace(/0+$/, '').replace(/\.$/, '')
}