Skv/e2e overview (#515)
* feat(agi): refactor AGI XML generation and data handling - Remove deprecated AGI field codes from field-codes.ts. - Update generate-declaration.ts to include new employee fields and handle absence data with stable specification numbers. - Enhance XML generation in xml-generator.ts to support new flags for housing benefits and adjusted benefits. - Introduce new database migrations to support: - `removed_from_agi` flag for tombstoning individuppgifter. - `benefits_adjusted` flag for tracking adjustments to benefits. - `franvaro_specifikationsnummer` for stable absence event identification. - `housing_benefit_type` to differentiate between housing benefit types. * feat: Implement strict validation for AGI employee data and introduce pre-flight validation schemas - Added Zod schemas for validating employee data in AGI declarations to ensure all required fields are present and correctly typed, preventing silent errors during processing. - Introduced AGI pre-flight validation schemas for Skatteverket endpoints to validate individual and head unit submissions before sending to the API. - Created a new audit log table for tracking all outbound calls to Skatteverket, ensuring compliance and traceability for AGI and moms submissions. - Implemented advisory locks in the database to manage concurrent updates to absence specification numbers, enhancing data integrity. - Added compliance documentation for GDPR processing activities related to AGI and moms submissions, detailing data handling and retention policies. * feat: Extend DELETE RLS policy to protect 'declined' signatures in årsredovisning * fix: Update date handling in salary absence migrations to use immutable year-month key * fix: Update SELECT policy in skatteverket_api_audit_log to use IN clause for company_id * feat: Add skatteverket_api_audit_log and salary_absence_franvaro_audit tables with RLS policies and immutable triggers
This commit is contained in:
+260
-45
@@ -30,13 +30,14 @@ import { getBranding } from '@/lib/branding/service'
|
||||
* - 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).
|
||||
* - 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
|
||||
@@ -62,6 +63,14 @@ export interface AGIAbsenceEvent {
|
||||
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 {
|
||||
@@ -70,13 +79,65 @@ export interface AGIEmployeeData {
|
||||
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
|
||||
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). */
|
||||
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. */
|
||||
@@ -148,6 +209,88 @@ function assertRequiredCompanyData(company: AGICompanyData): void {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
@@ -211,6 +354,7 @@ export function generateAGIXml(
|
||||
_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')}`
|
||||
@@ -285,6 +429,19 @@ export function generateAGIXml(
|
||||
|
||||
// ── 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)
|
||||
@@ -316,6 +473,17 @@ export function generateAGIXml(
|
||||
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>`)
|
||||
@@ -326,36 +494,73 @@ export function generateAGIXml(
|
||||
lines.push(` <gem:AvdrPrelSkatt faltkod="001">${formatAmount(emp.taxWithheld)}</gem:AvdrPrelSkatt>`)
|
||||
}
|
||||
|
||||
// FK013 — Bilförmån (skattepliktig, underlag AG)
|
||||
const exclSA = emp.benefitsExcludedFromSAUnderlag === true
|
||||
|
||||
// Car benefit AMOUNT: FK013 (UlagAG) or FK133 (ej UlagSA)
|
||||
if (emp.benefitCar && emp.benefitCar > 0) {
|
||||
lines.push(` <gem:SkatteplBilformanUlagAG faltkod="013">${formatAmount(emp.benefitCar)}</gem:SkatteplBilformanUlagAG>`)
|
||||
const code = exclSA ? '133' : '013'
|
||||
const elem = exclSA ? 'SkatteplBilformanEjUlagSA' : 'SkatteplBilformanUlagAG'
|
||||
lines.push(` <gem:${elem} faltkod="${code}">${formatAmount(emp.benefitCar)}</gem:${elem}>`)
|
||||
}
|
||||
|
||||
// FK018 — Drivmedel vid bilförmån
|
||||
// Fuel for car benefit AMOUNT: FK018 (UlagAG) or FK134 (ej UlagSA)
|
||||
if (emp.benefitFuel && emp.benefitFuel > 0) {
|
||||
lines.push(` <gem:DrivmVidBilformanUlagAG faltkod="018">${formatAmount(emp.benefitFuel)}</gem:DrivmVidBilformanUlagAG>`)
|
||||
const code = exclSA ? '134' : '018'
|
||||
const elem = exclSA ? 'DrivmVidBilformanEjUlagSA' : 'DrivmVidBilformanUlagAG'
|
||||
lines.push(` <gem:${elem} faltkod="${code}">${formatAmount(emp.benefitFuel)}</gem:${elem}>`)
|
||||
}
|
||||
|
||||
// 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>`)
|
||||
// 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}>`)
|
||||
}
|
||||
|
||||
// FK012 — Övriga skattepliktiga förmåner
|
||||
// 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) {
|
||||
lines.push(` <gem:SkatteplOvrigaFormanerUlagAG faltkod="012">${formatAmount(emp.benefitOther)}</gem:SkatteplOvrigaFormanerUlagAG>`)
|
||||
const code = exclSA ? '132' : '012'
|
||||
const elem = exclSA ? 'SkatteplOvrigaFormanerEjUlagSA' : 'SkatteplOvrigaFormanerUlagAG'
|
||||
lines.push(` <gem:${elem} faltkod="${code}">${formatAmount(emp.benefitOther)}</gem:${elem}>`)
|
||||
}
|
||||
|
||||
// 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>`)
|
||||
}
|
||||
|
||||
// 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).
|
||||
@@ -377,6 +582,9 @@ export function generateAGIXml(
|
||||
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 {
|
||||
@@ -386,20 +594,18 @@ export function generateAGIXml(
|
||||
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.
|
||||
// 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 0
|
||||
return a.specifikationsnummer - b.specifikationsnummer
|
||||
})
|
||||
|
||||
sorted.forEach((event, idx) => {
|
||||
const specNumber = idx + 1
|
||||
sorted.forEach((event) => {
|
||||
const specNumber = event.specifikationsnummer
|
||||
const isVab = event.type === 'vab'
|
||||
const franvaroTyp = isVab ? 'TILLFALLIG_FORALDRAPENNING' : 'FORALDRAPENNING'
|
||||
const hoursElement = isVab ? 'FranvaroTimmarTFP' : 'FranvaroTimmarFP'
|
||||
@@ -423,12 +629,23 @@ export function generateAGIXml(
|
||||
|
||||
lines.push('</Skatteverket>')
|
||||
|
||||
return lines.join('\n')
|
||||
const xml = lines.join('\n')
|
||||
assertPayloadSize(xml)
|
||||
return xml
|
||||
}
|
||||
|
||||
/**
|
||||
* Build individuppgifter snapshot for storage in agi_declarations table.
|
||||
* Used for corrections — must reference same FK570.
|
||||
* 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[]
|
||||
@@ -443,13 +660,11 @@ export function buildIndividuppgifterSnapshot(
|
||||
|
||||
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,
|
||||
specificationNumber: emp.specificationNumber,
|
||||
grossSalary: emp.grossSalary,
|
||||
taxWithheld: emp.taxWithheld,
|
||||
avgifterBasis: emp.avgifterBasis,
|
||||
removed: emp.removed ?? false,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user