Files
accounted/lib/salary/agi/xml-generator.ts
T
Jakob WennbergandClaude Opus 4.7 f3fd4c0822 feat(salary, skatteverket): per-day absence + AGI Frånvarouppgift + skattekonto + hardening (#388)
* 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>
2026-05-04 19:01:21 +02:00

485 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 { decryptPersonnummer } from '../personnummer'
import { getBranding } from '@/lib/branding/service'
/**
* 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 —
* gnubok tracks hours, not percent.
* - FranvaroSpecifikationsnummer is assigned 1-based per (employee, period),
* ordered by date. Skatteverket replaces a Frånvarouppgift on match of
* (BetalningsmottagarId, FranvaroDatum, FranvaroSpecifikationsnummer,
* RedovisningsPeriod, AgRegistreradId) — for stable replacement across
* re-generations the numbering must persist; if dates are added/removed
* mid-period the indices shift. First-submit is fine; correction
* stability is a follow-up TODO (persist event → number mapping).
* - 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'
/**
* 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
}
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).
fSkattPayment?: number // FK131 KontantErsattningEjUlagSA
benefitCar?: number // FK013 SkatteplBilformanUlagAG
benefitFuel?: number // FK018 DrivmVidBilformanUlagAG
benefitHousing?: number // FK043 BostadsformanEjSmahusUlagAG (non-småhus default)
benefitOther?: number // FK012 SkatteplOvrigaFormanerUlagAG
/** @deprecated Meal benefit element name not verified against schema; kept for snapshot compatibility only (not emitted). */
benefitMeals?: number
/** @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[] = []
const orgNumberDigits = (company.orgNumber || '').replace(/\D/g, '')
// Skatteverket's IDENTITET type requires either 10 digits (AB orgnr, we prefix
// with "16") or 12 digits (personnummer for enskild firma). Any other length
// is a data-entry error that we cannot silently fix.
if (orgNumberDigits.length !== 10 && orgNumberDigits.length !== 12) 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
)
}
}
/**
* 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)
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>${escapeXml(getBranding().appName.toLowerCase())}</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) {
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>`)
// 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>`)
}
// FK013 — Bilförmån (skattepliktig, underlag AG)
if (emp.benefitCar && emp.benefitCar > 0) {
lines.push(` <gem:SkatteplBilformanUlagAG faltkod="013">${formatAmount(emp.benefitCar)}</gem:SkatteplBilformanUlagAG>`)
}
// FK018 — Drivmedel vid bilförmån
if (emp.benefitFuel && emp.benefitFuel > 0) {
lines.push(` <gem:DrivmVidBilformanUlagAG faltkod="018">${formatAmount(emp.benefitFuel)}</gem:DrivmVidBilformanUlagAG>`)
}
// FK043 — Bostadsförmån (ej småhus). TODO: for single-family home use
// BostadsformanSmahusUlagAG (FK041); currently defaults to non-småhus.
if (emp.benefitHousing && emp.benefitHousing > 0) {
lines.push(` <gem:BostadsformanEjSmahusUlagAG faltkod="043">${formatAmount(emp.benefitHousing)}</gem:BostadsformanEjSmahusUlagAG>`)
}
// FK012 — Övriga skattepliktiga förmåner
if (emp.benefitOther && emp.benefitOther > 0) {
lines.push(` <gem:SkatteplOvrigaFormanerUlagAG faltkod="012">${formatAmount(emp.benefitOther)}</gem:SkatteplOvrigaFormanerUlagAG>`)
}
// Meal benefit: element name not verified in the component schema yet.
// Intentionally omitted until we have an authoritative mapping.
void emp.benefitMeals
// 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>`)
}
// 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
let pnr: string
try {
pnr = decryptPersonnummer(emp.personnummer)
} catch {
// Already surfaced as a hard error in the IU loop above; skip silently here.
continue
}
// Stable specifikationsnummer per (employee, period): sort by date,
// then 1-based index. Two events on the same date get sequential
// numbers. The unique key in the Skatteverket spec is
// (BetalningsmottagarId, FranvaroDatum, FranvaroSpecifikationsnummer,
// RedovisningsPeriod, AgRegistreradId), so within one employee+date
// duplicates of the same number replace.
const sorted = [...emp.absenceEvents].sort((a, b) => {
if (a.date < b.date) return -1
if (a.date > b.date) return 1
return 0
})
sorted.forEach((event, idx) => {
const specNumber = idx + 1
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>')
return lines.join('\n')
}
/**
* Build individuppgifter snapshot for storage in agi_declarations table.
* Used for corrections — must reference same FK570.
*/
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,
fk570: emp.specificationNumber,
ruta011: emp.grossSalary,
ruta001: emp.taxWithheld,
ruta020: emp.avgifterBasis,
fk821: emp.sickDays || 0,
fk822: emp.vabDays || 0,
fk823: emp.parentalDays || 0,
}
})
}
// ============================================================
// 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(/\.$/, '')
}