* 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>
485 lines
23 KiB
TypeScript
485 lines
23 KiB
TypeScript
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, '&')
|
||
.replace(/</g, '<')
|
||
.replace(/>/g, '>')
|
||
.replace(/"/g, '"')
|
||
.replace(/'/g, ''')
|
||
}
|
||
|
||
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(/\.$/, '')
|
||
}
|