From a652dcae1ae30209cd75dbdf1de98ec2d265ed88 Mon Sep 17 00:00:00 2001 From: Mattsson <111893710+mattssonn@users.noreply.github.com> Date: Sun, 17 May 2026 23:52:49 +0200 Subject: [PATCH] Skv/e2e overview (#515) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 --- .compliance/ropa.yaml | 127 +++++++ extensions/general/skatteverket/index.ts | 325 ++++++++++++++++++ .../general/skatteverket/lib/agi-client.ts | 63 ++++ extensions/general/skatteverket/types.ts | 19 + lib/salary/__tests__/agi-xml.test.ts | 56 ++- lib/salary/agi/field-codes.ts | 100 ------ lib/salary/agi/generate-declaration.ts | 255 +++++++++++--- lib/salary/agi/kontrollera-schemas.ts | 105 ++++++ lib/salary/agi/xml-generator.ts | 305 +++++++++++++--- ...00000_salary_run_employees_agi_removed.sql | 17 + ...5000_signature_delete_blocks_declined.sql} | 0 ...salary_run_employees_benefits_adjusted.sql | 17 + ...nce_days_franvaro_specifikationsnummer.sql | 115 +++++++ ...7130000_employees_housing_benefit_type.sql | 18 + ...35000_skatteverket_audit_franvaro_lock.sql | 176 ++++++++++ 15 files changed, 1497 insertions(+), 201 deletions(-) create mode 100644 .compliance/ropa.yaml delete mode 100644 lib/salary/agi/field-codes.ts create mode 100644 lib/salary/agi/kontrollera-schemas.ts create mode 100644 supabase/migrations/20260517100000_salary_run_employees_agi_removed.sql rename supabase/migrations/{20260517100000_signature_delete_blocks_declined.sql => 20260517105000_signature_delete_blocks_declined.sql} (100%) create mode 100644 supabase/migrations/20260517110000_salary_run_employees_benefits_adjusted.sql create mode 100644 supabase/migrations/20260517120000_salary_absence_days_franvaro_specifikationsnummer.sql create mode 100644 supabase/migrations/20260517130000_employees_housing_benefit_type.sql create mode 100644 supabase/migrations/20260517135000_skatteverket_audit_franvaro_lock.sql diff --git a/.compliance/ropa.yaml b/.compliance/ropa.yaml new file mode 100644 index 00000000..5f4e8ed7 --- /dev/null +++ b/.compliance/ropa.yaml @@ -0,0 +1,127 @@ +# Record of Processing Activities (GDPR Art. 30) +# +# Each entry documents one processing activity. Field shape follows EDPB +# Guidelines 9/2022 §3 (controller-side RoPA): purposes, lawful basis, +# data subject + data category, recipients, transfer mechanism, retention, +# security measures. Add an entry whenever a new flow ships that touches +# personal data — onboarding, integrations, support, or regulator filings. + +processing_activities: + + - id: agi.submit + name: AGI inlämning till Skatteverket + purpose: >- + Lämna in lagstadgad arbetsgivardeklaration på individnivå (AGI) till + Skatteverket varje månad (Skatteförfarandelagen 26 kap.). Innefattar + huvuduppgift (HU) och individuppgifter (IU) per anställd. + lawful_basis: art_6_1_c # legal obligation + special_category_basis: null + controller: gnubok-tenant + processor: anthropic-na # software supplier; Skatteverket is recipient, not processor + data_subjects: + - employee + data_categories: + - user.government_id # personnummer + - user.name + - user.financial.employment # gross salary, tax withheld, benefits + - user.financial.tax # avgifter, sjuklönekostnad + recipients: + - name: Skatteverket + country: SE + role: legal_recipient + international_transfers: + applicable: false + mechanism: null + note: >- + Sweden-to-Sweden processor-to-public-authority flow. Adequacy decision + not applicable; no third-country transfer. + retention: + duration: 7y + basis: bfl_7_kap # BFL 7 kap. — räkenskapsinformation + stored_in: + - agi_declarations.xml_content + - agi_declarations.individuppgifter + - skatteverket_api_audit_log + security_measures: + - encryption_at_rest_supabase + - rls_company_scoped + - tls_1_3_to_skatteverket + - bankid_signing_required_for_filing + - immutable_audit_log + - personnummer_at_rest_encryption + + - id: agi.kontrollera.preflight + name: AGI pre-flight kontrollera (HU/IU) + purpose: >- + Skicka enskild HU eller IU som JSON till Skatteverkets kontrollera- + ändpunkt (v1.7 §7/§8) för dry-run-validering av felmeddelanden och + STOPP-/AVVISANDE-status innan ett fullständigt underlag laddas upp. + Skatteverket lagrar inget från denna kontroll; svaret driver vår UI + som låter användaren rätta felaktigheter i lönekörningen lokalt. + lawful_basis: art_6_1_c # legal obligation (ancillary to AGI filing) + special_category_basis: null + controller: gnubok-tenant + processor: anthropic-na + data_subjects: + - employee + data_categories: + - user.government_id # personnummer (BetalningsmottagarId) + - user.financial.employment # gross salary, tax withheld, benefits + recipients: + - name: Skatteverket + country: SE + role: legal_recipient + international_transfers: + applicable: false + mechanism: null + note: Sweden-to-Sweden flow; no third-country transfer. + retention: + # Skatteverket retains nothing for kontrollera calls. Our outbound + # audit row in skatteverket_api_audit_log records who-called-what-when + # but NOT the payload body, minimising at-rest footprint. + duration: 7y + basis: bfl_7_kap + stored_in: + - skatteverket_api_audit_log + security_measures: + - strict_zod_allow_list_input_validation + - request_size_cap_64kb + - rbac_company_member_role_check + - rls_company_scoped + - tls_1_3_to_skatteverket + - immutable_audit_log + - no_payload_body_persisted + + - id: moms.declaration + name: Momsdeklaration till Skatteverket + purpose: >- + Lämna in lagstadgad momsdeklaration (utkast, lås och signering) till + Skatteverkets momsdeklaration-API (Mervärdesskattelagen 17 kap. 24 §). + lawful_basis: art_6_1_c + special_category_basis: null + controller: gnubok-tenant + processor: anthropic-na + data_subjects: + - business_owner + data_categories: + - user.government_id # orgnummer (juridiska personer) eller pnr + - user.financial.tax # rutor 05–62 + recipients: + - name: Skatteverket + country: SE + role: legal_recipient + international_transfers: + applicable: false + mechanism: null + note: Sweden-to-Sweden flow; no third-country transfer. + retention: + duration: 7y + basis: bfl_7_kap + stored_in: + - extension_data (utkast metadata) + - skatteverket_api_audit_log + security_measures: + - rls_company_scoped + - tls_1_3_to_skatteverket + - bankid_signing_required_for_filing + - immutable_audit_log diff --git a/extensions/general/skatteverket/index.ts b/extensions/general/skatteverket/index.ts index 7b8f99dd..49cc0a8d 100644 --- a/extensions/general/skatteverket/index.ts +++ b/extensions/general/skatteverket/index.ts @@ -1,6 +1,11 @@ import crypto from 'crypto' import type { Extension, ExtensionContext } from '@/lib/extensions/types' import { NextResponse } from 'next/server' +import { + AGIKontrolleraHUSchema, + AGIKontrolleraIUSchema, + AGI_KONTROLLERA_MAX_BYTES, +} from '@/lib/salary/agi/kontrollera-schemas' import { TimeoutError } from '@/lib/http/fetch-with-timeout' import { buildAuthorizeUrl, exchangeCodeForTokens, generatePkcePair } from './lib/oauth' import { storeTokens, getTokens, deleteTokens } from './lib/token-store' @@ -17,6 +22,8 @@ import { agiGetKvittenser, agiLasPeriod, agiLasUppPeriod, + agiKontrolleraHU, + agiKontrolleraIU, } from './lib/agi-client' import { syncSkattekonto, SKATTEKONTO_BALANCE_SNAPSHOT_KEY, SKATTEKONTO_LAST_SYNCED_AT_KEY } from './lib/skattekonto-sync' import { bokforSkattekontoTransaction, SkattekontoBookingError } from './lib/skattekonto-booking' @@ -46,6 +53,14 @@ import type { VatPeriodType } from '@/types' * the test-env key in prod) * * Optional: + * - SKATTEVERKET_ENV — 'test' | 'production'. + * Drives security-relevant + * limits (AGI payload size: + * 100 MB test vs 300 MB prod). + * Defaults to 'test' (stricter) + * when unset or unrecognised. + * MUST be set explicitly in + * every deployment manifest. * - SKATTEVERKET_OAUTH_BASE_URL — defaults to test * - SKATTEVERKET_API_BASE_URL — momsdeklaration; defaults to test * - SKATTEVERKET_AGD_INLAMNING_API_BASE_URL — AGI inlämning; defaults to test @@ -81,6 +96,91 @@ import type { VatPeriodType } from '@/types' * The /status endpoint reports which environment is active so the UI can * surface a Testmiljö / Produktion badge. */ + +const AGI_WRITE_ROLES = new Set(['owner', 'admin', 'member']) + +/** + * Defense-in-depth RBAC check for AGI write/validate endpoints. Ctx + * presence alone (set by middleware) only confirms the user is signed in + * and has a resolved company; it does NOT prove they are entitled to + * submit tax declarations for that company. Viewers must be blocked. + * + * Returns null on success, a NextResponse on failure. + */ +async function requireAgiWriteRole(ctx: ExtensionContext): Promise { + const { data, error } = await ctx.supabase + .from('company_members') + .select('role') + .eq('company_id', ctx.companyId) + .eq('user_id', ctx.userId) + .maybeSingle() + + if (error) { + return NextResponse.json( + { error: 'Behörighetskontroll misslyckades.' }, + { status: 500 }, + ) + } + if (!data?.role || !AGI_WRITE_ROLES.has(data.role as string)) { + return NextResponse.json( + { error: 'Otillräcklig behörighet för att lämna in AGI för det här företaget.' }, + { status: 403 }, + ) + } + return null +} + +/** + * Append an immutable row to skatteverket_api_audit_log. Errors are + * swallowed (logged only) so an audit-table outage does not break the + * regulator flow — but a successful primary call without an audit row + * shows up as a noisy console.error for ops to investigate. + */ +async function writeSkatteverketAudit( + ctx: ExtensionContext, + fields: { + endpoint: string + agRegistreradId?: string | null + redovisningsperiod?: string | null + outcome: 'ok' | 'validation_error' | 'skv_error' | 'auth_error' | 'internal_error' + responseStatus?: number | null + skvStatus?: string | null + requestSizeBytes?: number | null + correlationId?: string | null + errorMessage?: string | null + }, +): Promise { + try { + const { error } = await ctx.supabase + .from('skatteverket_api_audit_log') + .insert({ + company_id: ctx.companyId, + user_id: ctx.userId, + endpoint: fields.endpoint, + ag_registered_id: fields.agRegistreradId ?? null, + redovisningsperiod: fields.redovisningsperiod ?? null, + outcome: fields.outcome, + response_status: fields.responseStatus ?? null, + skv_status: fields.skvStatus ?? null, + request_size_bytes: fields.requestSizeBytes ?? null, + correlation_id: fields.correlationId ?? null, + error_message: fields.errorMessage ?? null, + }) + if (error) { + ctx.log.error('skatteverket_api_audit_log insert failed', { + endpoint: fields.endpoint, + outcome: fields.outcome, + error: error.message, + }) + } + } catch (err) { + ctx.log.error('skatteverket_api_audit_log insert threw', { + endpoint: fields.endpoint, + error: err instanceof Error ? err.message : String(err), + }) + } +} + export const skatteverketExtension: Extension = { id: 'skatteverket', name: 'Skatteverket Integration', @@ -1252,6 +1352,231 @@ export const skatteverketExtension: Extension = { }, }, + // ── AGI: Pre-flight kontrollera HU/IU ─────────────────────────── + // Validate a single HU or IU as JSON without saving anything in + // Skatteverket. Lets the UI catch errors per HU/IU before the user + // submits a full XML underlag. Body: the HU or IU as JSON per the + // v1.7 spec §7 / §8 (property names like agRegistreradId, + // redovisningsPeriod, kontantErsattningUlagAG, …). + // + // Response shape (kontrollsvar): + // { status: 'OK' | 'INFO' | 'ARENDE' | 'STOPP' | 'AVVISANDE', + // fel: [{ status, felmeddelande }, …] } + { + method: 'POST', + path: '/agi/kontrollera/hu', + handler: async (request: Request, ctx?: ExtensionContext) => { + if (!ctx) { + return NextResponse.json({ error: 'Extension context required' }, { status: 500 }) + } + const rbac = await requireAgiWriteRole(ctx) + if (rbac) return rbac + + // Read body as bytes first so we can enforce a hard size cap before + // JSON.parse — protects against ~MB payloads that would otherwise + // hit the Zod validator. + const rawBytes = Buffer.byteLength(await request.clone().text(), 'utf8') + if (rawBytes > AGI_KONTROLLERA_MAX_BYTES) { + await writeSkatteverketAudit(ctx, { + endpoint: 'agi.kontrollera.hu', + outcome: 'validation_error', + responseStatus: 413, + requestSizeBytes: rawBytes, + errorMessage: 'payload exceeds 64KB cap', + }) + return NextResponse.json( + { error: 'Payload överstiger maxstorleken för pre-flight kontroll (64 KB).' }, + { status: 413 }, + ) + } + + let parsedBody: unknown + try { + parsedBody = await request.json() + } catch { + await writeSkatteverketAudit(ctx, { + endpoint: 'agi.kontrollera.hu', + outcome: 'validation_error', + responseStatus: 400, + requestSizeBytes: rawBytes, + errorMessage: 'invalid JSON', + }) + return NextResponse.json({ error: 'Ogiltig JSON i förfrågan.' }, { status: 400 }) + } + + const parsed = AGIKontrolleraHUSchema.safeParse(parsedBody) + if (!parsed.success) { + await writeSkatteverketAudit(ctx, { + endpoint: 'agi.kontrollera.hu', + outcome: 'validation_error', + responseStatus: 400, + requestSizeBytes: rawBytes, + errorMessage: parsed.error.issues + .map((iss) => `${iss.path.join('.')}: ${iss.message}`) + .join('; ') + .slice(0, 1000), + }) + return NextResponse.json( + { + error: 'HU-payload matchar inte Skatteverkets v1.7 §7-schema.', + type: 'validation_error', + errors: parsed.error.issues.map((iss) => ({ + field: iss.path.join('.'), + message: iss.message, + code: iss.code, + })), + }, + { status: 400 }, + ) + } + + try { + const result = await agiKontrolleraHU(ctx.supabase, ctx.userId, parsed.data) + if (!result.ok) { + await writeSkatteverketAudit(ctx, { + endpoint: 'agi.kontrollera.hu', + agRegistreradId: parsed.data.agRegistreradId, + redovisningsperiod: parsed.data.redovisningsPeriod, + outcome: result.status === 401 || result.status === 403 ? 'auth_error' : 'skv_error', + responseStatus: result.status, + requestSizeBytes: rawBytes, + errorMessage: result.error, + }) + return NextResponse.json( + { error: result.error, code: result.body?.kod }, + { status: result.status }, + ) + } + await writeSkatteverketAudit(ctx, { + endpoint: 'agi.kontrollera.hu', + agRegistreradId: parsed.data.agRegistreradId, + redovisningsperiod: parsed.data.redovisningsPeriod, + outcome: 'ok', + responseStatus: result.status, + skvStatus: result.data?.status ?? null, + requestSizeBytes: rawBytes, + }) + return NextResponse.json({ data: result.data }) + } catch (err) { + await writeSkatteverketAudit(ctx, { + endpoint: 'agi.kontrollera.hu', + agRegistreradId: parsed.data.agRegistreradId, + redovisningsperiod: parsed.data.redovisningsPeriod, + outcome: 'internal_error', + requestSizeBytes: rawBytes, + errorMessage: err instanceof Error ? err.message : String(err), + }) + return handleSkvError(err) + } + }, + }, + + { + method: 'POST', + path: '/agi/kontrollera/iu', + handler: async (request: Request, ctx?: ExtensionContext) => { + if (!ctx) { + return NextResponse.json({ error: 'Extension context required' }, { status: 500 }) + } + const rbac = await requireAgiWriteRole(ctx) + if (rbac) return rbac + + const rawBytes = Buffer.byteLength(await request.clone().text(), 'utf8') + if (rawBytes > AGI_KONTROLLERA_MAX_BYTES) { + await writeSkatteverketAudit(ctx, { + endpoint: 'agi.kontrollera.iu', + outcome: 'validation_error', + responseStatus: 413, + requestSizeBytes: rawBytes, + errorMessage: 'payload exceeds 64KB cap', + }) + return NextResponse.json( + { error: 'Payload överstiger maxstorleken för pre-flight kontroll (64 KB).' }, + { status: 413 }, + ) + } + + let parsedBody: unknown + try { + parsedBody = await request.json() + } catch { + await writeSkatteverketAudit(ctx, { + endpoint: 'agi.kontrollera.iu', + outcome: 'validation_error', + responseStatus: 400, + requestSizeBytes: rawBytes, + errorMessage: 'invalid JSON', + }) + return NextResponse.json({ error: 'Ogiltig JSON i förfrågan.' }, { status: 400 }) + } + + const parsed = AGIKontrolleraIUSchema.safeParse(parsedBody) + if (!parsed.success) { + await writeSkatteverketAudit(ctx, { + endpoint: 'agi.kontrollera.iu', + outcome: 'validation_error', + responseStatus: 400, + requestSizeBytes: rawBytes, + errorMessage: parsed.error.issues + .map((iss) => `${iss.path.join('.')}: ${iss.message}`) + .join('; ') + .slice(0, 1000), + }) + return NextResponse.json( + { + error: 'IU-payload matchar inte Skatteverkets v1.7 §8-schema.', + type: 'validation_error', + errors: parsed.error.issues.map((iss) => ({ + field: iss.path.join('.'), + message: iss.message, + code: iss.code, + })), + }, + { status: 400 }, + ) + } + + try { + const result = await agiKontrolleraIU(ctx.supabase, ctx.userId, parsed.data) + if (!result.ok) { + await writeSkatteverketAudit(ctx, { + endpoint: 'agi.kontrollera.iu', + agRegistreradId: parsed.data.agRegistreradId, + redovisningsperiod: parsed.data.redovisningsPeriod, + outcome: result.status === 401 || result.status === 403 ? 'auth_error' : 'skv_error', + responseStatus: result.status, + requestSizeBytes: rawBytes, + errorMessage: result.error, + }) + return NextResponse.json( + { error: result.error, code: result.body?.kod }, + { status: result.status }, + ) + } + await writeSkatteverketAudit(ctx, { + endpoint: 'agi.kontrollera.iu', + agRegistreradId: parsed.data.agRegistreradId, + redovisningsperiod: parsed.data.redovisningsPeriod, + outcome: 'ok', + responseStatus: result.status, + skvStatus: result.data?.status ?? null, + requestSizeBytes: rawBytes, + }) + return NextResponse.json({ data: result.data }) + } catch (err) { + await writeSkatteverketAudit(ctx, { + endpoint: 'agi.kontrollera.iu', + agRegistreradId: parsed.data.agRegistreradId, + redovisningsperiod: parsed.data.redovisningsPeriod, + outcome: 'internal_error', + requestSizeBytes: rawBytes, + errorMessage: err instanceof Error ? err.message : String(err), + }) + return handleSkvError(err) + } + }, + }, + // ── AGI: Lås period ───────────────────────────────────────────── // Hantera-API; typically not needed (skapaGranskningsunderlag already // accepts lasPeriod=true). Exposed for recovery / manual control. diff --git a/extensions/general/skatteverket/lib/agi-client.ts b/extensions/general/skatteverket/lib/agi-client.ts index f2c547ff..16257841 100644 --- a/extensions/general/skatteverket/lib/agi-client.ts +++ b/extensions/general/skatteverket/lib/agi-client.ts @@ -4,6 +4,7 @@ import type { SkatteverketAGIErrorBody, SkatteverketAGIGranskningsunderlagResponse, SkatteverketAGIKontrollresultat, + SkatteverketAGIKontrollsvar, SkatteverketAGIKvittenserResponse, SkatteverketAGIUnderlagResponse, } from '../types' @@ -355,3 +356,65 @@ export async function agiLasUppPeriod( const data = await response.json().catch(() => ({})) return { ok: true, status: response.status, data } } + +/** + * POST /underlag/huvuduppgift/kontrollera — pre-flight validation of a single + * HU as JSON without saving anything. Returns the kontrollsvar (OK / INFO / + * ARENDE / STOPP / AVVISANDE) and a list of any fel that fired. + * + * Use this to surface validation errors per HU to the user before they + * generate and submit a full XML underlag. The JSON property names follow + * the v1.7 spec §7 — see lib/salary/agi/huvuduppgift-json.ts for the typed + * builder. + */ +export async function agiKontrolleraHU( + supabase: SupabaseClient, + userId: string, + hu: Record, +): Promise> { + const response = await skvRequest( + supabase, + userId, + 'POST', + '/underlag/huvuduppgift/kontrollera', + hu, + { baseUrl: getInlamningBaseUrl() }, + ) + + if (!response.ok) { + const { error, body } = await readErrorBody(response) + return { ok: false, status: response.status, error, body } + } + + const data = (await response.json()) as SkatteverketAGIKontrollsvar + return { ok: true, status: response.status, data } +} + +/** + * POST /underlag/individuppgift/kontrollera — pre-flight validation of a + * single IU as JSON without saving anything. JSON property names follow + * the v1.7 spec §8 — see lib/salary/agi/individuppgift-json.ts for the + * typed builder. + */ +export async function agiKontrolleraIU( + supabase: SupabaseClient, + userId: string, + iu: Record, +): Promise> { + const response = await skvRequest( + supabase, + userId, + 'POST', + '/underlag/individuppgift/kontrollera', + iu, + { baseUrl: getInlamningBaseUrl() }, + ) + + if (!response.ok) { + const { error, body } = await readErrorBody(response) + return { ok: false, status: response.status, error, body } + } + + const data = (await response.json()) as SkatteverketAGIKontrollsvar + return { ok: true, status: response.status, data } +} diff --git a/extensions/general/skatteverket/types.ts b/extensions/general/skatteverket/types.ts index b14f0740..d05925a2 100644 --- a/extensions/general/skatteverket/types.ts +++ b/extensions/general/skatteverket/types.ts @@ -213,6 +213,25 @@ export interface SkatteverketAGIErrorBody { meddelandeTillUtvecklare?: string } +/** + * Response from POST /underlag/huvuduppgift/kontrollera and + * /underlag/individuppgift/kontrollera (Skatteverket v1.7 §6.6 kontrollsvar). + * + * Validates a single HU or IU as JSON without storing it. `fel` is empty + * when status === 'OK'. AVVISANDE means the payload was malformed enough + * to skip rule evaluation; STOPP is a hard validation failure that would + * reject the underlag. + */ +export interface SkatteverketAGIKontrollsvar { + status: 'OK' | 'INFO' | 'ARENDE' | 'STOPP' | 'AVVISANDE' + fel: SkatteverketAGIKontrollsvarFel[] +} + +export interface SkatteverketAGIKontrollsvarFel { + status: 'OK' | 'INFO' | 'ARENDE' | 'STOPP' | 'AVVISANDE' + felmeddelande?: string +} + export interface SkatteverketSubmission { id: string user_id: string diff --git a/lib/salary/__tests__/agi-xml.test.ts b/lib/salary/__tests__/agi-xml.test.ts index 8afe5690..214c5848 100644 --- a/lib/salary/__tests__/agi-xml.test.ts +++ b/lib/salary/__tests__/agi-xml.test.ts @@ -287,17 +287,17 @@ describe('buildIndividuppgifterSnapshot', () => { expect(snapshot[1].personnummer).toBe('198506159876') }) - it('preserves FK570 for correction reference', () => { + it('preserves specificationNumber for correction reference', () => { const snapshot = buildIndividuppgifterSnapshot(employees) - expect(snapshot[0].fk570).toBe(1) - expect(snapshot[1].fk570).toBe(2) + expect(snapshot[0].specificationNumber).toBe(1) + expect(snapshot[1].specificationNumber).toBe(2) }) - it('includes all required rutor', () => { + it('includes the core IU amounts', () => { const snapshot = buildIndividuppgifterSnapshot(employees) - expect(snapshot[0]).toHaveProperty('ruta011', 40000) - expect(snapshot[0]).toHaveProperty('ruta001', 12000) - expect(snapshot[0]).toHaveProperty('ruta020', 40000) + expect(snapshot[0]).toHaveProperty('grossSalary', 40000) + expect(snapshot[0]).toHaveProperty('taxWithheld', 12000) + expect(snapshot[0]).toHaveProperty('avgifterBasis', 40000) }) }) @@ -312,8 +312,8 @@ describe('generateAGIXml — Frånvarouppgift', () => { taxWithheld: 12000, avgifterBasis: 40000, absenceEvents: [ - { date: '2026-04-15', type: 'vab', hours: 8 }, - { date: '2026-04-16', type: 'vab', hours: 4 }, + { date: '2026-04-15', type: 'vab', hours: 8, specifikationsnummer: 1 }, + { date: '2026-04-16', type: 'vab', hours: 4, specifikationsnummer: 2 }, ], }, { @@ -323,7 +323,7 @@ describe('generateAGIXml — Frånvarouppgift', () => { taxWithheld: 10500, avgifterBasis: 35000, absenceEvents: [ - { date: '2026-04-20', type: 'parental', hours: 8 }, + { date: '2026-04-20', type: 'parental', hours: 8, specifikationsnummer: 1 }, ], }, ] @@ -352,26 +352,48 @@ describe('generateAGIXml — Frånvarouppgift', () => { company, [{ ...employeesWithAbsence[1], - absenceEvents: [{ date: '2026-04-20', type: 'parental', hours: 8 }], + absenceEvents: [{ date: '2026-04-20', type: 'parental', hours: 8, specifikationsnummer: 1 }], }], totals, ) expect(xml).not.toMatch(/FranvaroTimmarTFP|FranvaroProcentTFP/) }) - it('uses 1-based stable specifikationsnummer per employee, ordered by date', () => { + it('emits the persisted specifikationsnummer per event (stable across corrections)', () => { const xml = generateAGIXml(company, employeesWithAbsence, totals) - // emp1 has two events on 2026-04-15 and 2026-04-16 + // emp1 has two events with specnummer 1 and 2 (assigned by DB trigger); + // emp2 has one event with specnummer 1. The values come from the + // event object — they are NOT recomputed from array index. expect(xml).toContain('1') expect(xml).toContain('2') }) + it('preserves the persisted specnummer even when an earlier event is removed', () => { + // Simulates the correction scenario the persistence is designed for: + // the first vab day was deleted, so the remaining day keeps its + // original specnummer (2). Without persistence the index would shift + // to 1 and Skatteverket would treat it as a replacement of the + // already-filed day-1 event. + const xml = generateAGIXml( + company, + [{ + ...employeesWithAbsence[0], + absenceEvents: [ + { date: '2026-04-16', type: 'vab', hours: 4, specifikationsnummer: 2 }, + ], + }], + totals, + ) + expect(xml).toContain('2') + expect(xml).not.toContain('1') + }) + it('formats fractional hours with up to 2 decimals', () => { const xml = generateAGIXml( company, [{ ...employeesWithAbsence[0], - absenceEvents: [{ date: '2026-04-15', type: 'vab', hours: 4.5 }], + absenceEvents: [{ date: '2026-04-15', type: 'vab', hours: 4.5, specifikationsnummer: 1 }], }], totals, ) @@ -383,7 +405,7 @@ describe('generateAGIXml — Frånvarouppgift', () => { company, [{ ...employeesWithAbsence[0], - absenceEvents: [{ date: '2026-04-15', type: 'vab', hours: 50 }], + absenceEvents: [{ date: '2026-04-15', type: 'vab', hours: 50, specifikationsnummer: 1 }], }], totals, ) @@ -404,7 +426,7 @@ describe('generateAGIXml — Frånvarouppgift', () => { { ...company, periodYear: 2025, periodMonth: 1 }, [{ ...employeesWithAbsence[0], - absenceEvents: [{ date: '2025-01-15', type: 'vab', hours: 8 }], + absenceEvents: [{ date: '2025-01-15', type: 'vab', hours: 8, specifikationsnummer: 1 }], }], totals, ) @@ -442,7 +464,7 @@ describe('generateAGIXml — Frånvarouppgift', () => { company, [{ ...employeesWithAbsence[0], - absenceEvents: [{ date: '2026-04-15', type: 'vab', hours: 8 }], + absenceEvents: [{ date: '2026-04-15', type: 'vab', hours: 8, specifikationsnummer: 1 }], }], totals, ) diff --git a/lib/salary/agi/field-codes.ts b/lib/salary/agi/field-codes.ts deleted file mode 100644 index 5742a2dd..00000000 --- a/lib/salary/agi/field-codes.ts +++ /dev/null @@ -1,100 +0,0 @@ -/** - * AGI (Arbetsgivardeklaration) field codes per Skatteverket Teknisk beskrivning. - * - * FK = Fältkod (field code) - * Ruta = Box number on the form - */ - -// ============================================================ -// Huvuduppgift (Employer totals) -// ============================================================ - -/** Employer-level field codes */ -export const HUVUDUPPGIFT_FIELDS = { - /** Total skatteavdrag — sum of all employee tax withholdings */ - RUTA_001: '001', - /** Total underlag for arbetsgivaravgifter */ - RUTA_020: '020', - /** Arbetsgivaravgifter — standard rate (31.42%) */ - RUTA_060: '060', - /** Arbetsgivaravgifter — age-reduced (10.21%, born ≤1959 for 2026) */ - RUTA_061: '061', - /** Arbetsgivaravgifter — youth rate (20.81%, vid årets ingång 18–22 år, Apr 2026–Sep 2027; Prop. 2025/26:66) */ - RUTA_062: '062', -} as const - -// ============================================================ -// Individuppgift (Per-employee data) -// ============================================================ - -/** Per-employee field codes */ -export const INDIVID_FIELDS = { - /** Personnummer/samordningsnummer (12 digits, CRITICAL: must be decrypted) */ - FK215: '215', - /** Specifikationsnummer — MUST stay consistent per employee for corrections */ - FK570: '570', - /** Kontant bruttolön (gross cash salary) */ - RUTA_011: '011', - /** Avdragen skatt (withheld preliminary tax) */ - RUTA_001: '001', - /** Förmånsvärde — bilförmån */ - RUTA_012: '012', - /** Förmånsvärde — drivmedel vid bilförmån */ - RUTA_013: '013', - /** Förmånsvärde — bostad */ - RUTA_014: '014', - /** Förmånsvärde — kost */ - RUTA_015: '015', - /** Förmånsvärde — ränta */ - RUTA_016: '016', - /** Förmånsvärde — övriga */ - RUTA_019: '019', - /** Underlag för arbetsgivaravgifter */ - RUTA_020: '020', - /** Ersättning till mottagare med F-skattsedel (not subject to avgifter) */ - RUTA_131: '131', - // Absence fields (from 2025) - /** Sjukfrånvaro — antal dagar */ - FK821: '821', - /** VAB — antal dagar */ - FK822: '822', - /** Föräldraledighet — antal dagar */ - FK823: '823', - /** Graviditetspenning — antal dagar */ - FK824: '824', - /** Smittbärarpenning — antal dagar */ - FK825: '825', - /** Sjuk-/aktivitetsersättning — antal dagar */ - FK826: '826', - /** Rehabilitering — antal dagar */ - FK827: '827', -} as const - -// ============================================================ -// Benefit type to ruta mapping -// ============================================================ - -/** Map benefit item types to AGI individuppgift rutor */ -export const BENEFIT_RUTA_MAP: Record = { - benefit_car: INDIVID_FIELDS.RUTA_012, - benefit_housing: INDIVID_FIELDS.RUTA_014, - benefit_meals: INDIVID_FIELDS.RUTA_015, - benefit_wellness: INDIVID_FIELDS.RUTA_019, - benefit_bike: INDIVID_FIELDS.RUTA_019, - benefit_other: INDIVID_FIELDS.RUTA_019, -} - -// ============================================================ -// Avgifter category to ruta mapping -// ============================================================ - -export type AvgifterCategory = 'standard' | 'reduced_65plus' | 'youth' | 'vaxa_stod' | 'exempt' - -/** Map avgifter categories to huvuduppgift rutor */ -export const AVGIFTER_RUTA_MAP: Record = { - standard: HUVUDUPPGIFT_FIELDS.RUTA_060, - reduced_65plus: HUVUDUPPGIFT_FIELDS.RUTA_061, - youth: HUVUDUPPGIFT_FIELDS.RUTA_062, - vaxa_stod: HUVUDUPPGIFT_FIELDS.RUTA_061, // Växa-stöd uses same rate as 65+ - exempt: '', // No avgifter -} diff --git a/lib/salary/agi/generate-declaration.ts b/lib/salary/agi/generate-declaration.ts index 3f9225b1..590cc205 100644 --- a/lib/salary/agi/generate-declaration.ts +++ b/lib/salary/agi/generate-declaration.ts @@ -22,15 +22,64 @@ */ import type { SupabaseClient } from '@supabase/supabase-js' +import { z } from 'zod' import { generateAGIXml, buildIndividuppgifterSnapshot, AGIIncompleteDataError, + AGIPayloadTooLargeError, } from './xml-generator' import type { AGIEmployeeData, AGICompanyData, AGITotals } from './xml-generator' import { eventBus } from '@/lib/events' import type { Logger } from '@/lib/logger' +// Strict runtime validation of the joined salary_run_employees row. Without +// this, columns added by recent migrations (removed_from_agi, +// benefits_adjusted, vaxa_stod_eligible, employment_start, +// housing_benefit_type) reaching the mapper as null/undefined would silently +// fall back to Boolean(undefined) = false and mis-emit regulatory flags. +// Zod produces an explicit error instead. +const EmployeeJoinSchema = z + .object({ + personnummer: z.string().min(1, 'employee.personnummer saknas'), + specification_number: z.number().int().min(1, 'employee.specification_number måste vara ≥ 1'), + f_skatt_status: z.string(), + monthly_salary: z.number().nullable().optional(), + vaxa_stod_eligible: z.boolean().nullable().optional(), + employment_start: z.string().nullable().optional(), + housing_benefit_type: z.enum(['smahus', 'ej_smahus']).nullable().optional(), + }) + .passthrough() + +const LineItemSchema = z + .object({ + item_type: z.string(), + amount: z.number().nullable().optional(), + quantity: z.number().nullable().optional(), + }) + .passthrough() + +const SalaryRunEmployeeRowSchema = z + .object({ + employee_id: z.string().uuid(), + gross_salary: z.number(), + tax_withheld: z.number(), + avgifter_basis: z.number(), + avgifter_amount: z.number(), + avgifter_rate: z.number(), + avgifter_category: z.string().nullable().optional(), + removed_from_agi: z.boolean().nullable().optional(), + benefits_adjusted: z.boolean().nullable().optional(), + sick_days: z.number().nullable().optional(), + vab_days: z.number().nullable().optional(), + parental_days: z.number().nullable().optional(), + employee: EmployeeJoinSchema.nullable(), + line_items: z.array(LineItemSchema).nullable().optional(), + }) + .passthrough() + +type SalaryRunEmployeeRow = z.infer + const ELIGIBLE_STATUSES = ['review', 'approved', 'paid', 'booked', 'corrected'] as const export interface GenerateAgiDeclarationArgs { @@ -124,7 +173,7 @@ export async function generateAgiDeclaration( const { data: runEmployees } = await supabase .from('salary_run_employees') .select( - '*, employee:employees(personnummer, specification_number, f_skatt_status, monthly_salary), line_items:salary_line_items(*)', + '*, employee:employees(personnummer, specification_number, f_skatt_status, monthly_salary, vaxa_stod_eligible, employment_start, housing_benefit_type), line_items:salary_line_items(*)', ) .eq('salary_run_id', salaryRunId) @@ -153,12 +202,17 @@ export async function generateAgiDeclaration( const absenceByEmployee = new Map< string, - Array<{ date: string; type: 'vab' | 'parental'; hours: number }> + Array<{ + date: string + type: 'vab' | 'parental' + hours: number + specifikationsnummer: number + }> >() if (employeeIds.length > 0) { const { data: absenceRows } = await supabase .from('salary_absence_days') - .select('employee_id, absence_date, absence_type, hours') + .select('employee_id, absence_date, absence_type, hours, franvaro_specifikationsnummer') .eq('company_id', companyId) .in('absence_type', ['vab', 'parental']) .gte('absence_date', periodStart) @@ -169,73 +223,153 @@ export async function generateAgiDeclaration( absence_date: string absence_type: 'vab' | 'parental' hours: number + franvaro_specifikationsnummer: number | null }>) { + // Row should always have a number for vab/parental (trigger assigns + // on insert + backfill migration covers existing data). Defensive + // fallback: skip rows missing the number rather than emit a bogus 0, + // which would collide with Skatteverket's unique key. + if (row.franvaro_specifikationsnummer == null) continue const list = absenceByEmployee.get(row.employee_id) ?? [] list.push({ date: row.absence_date, type: row.absence_type, hours: Number(row.hours ?? 8), + specifikationsnummer: row.franvaro_specifikationsnummer, }) absenceByEmployee.set(row.employee_id, list) } } - const employeeData: AGIEmployeeData[] = (runEmployees as Array>).map( - (sre) => { - const emp = sre.employee as { - personnummer: string - specification_number: number - f_skatt_status: string - } | null - const lineItems = (sre.line_items || []) as Array> + // Validate the joined rows up-front so a malformed Supabase response + // (missing column, wrong type, null specification_number, …) surfaces as + // a clean AGIIncompleteDataError instead of silently emitting wrong + // flags later. See SalaryRunEmployeeRowSchema definition above. + const parsedRows: SalaryRunEmployeeRow[] = (runEmployees as unknown[]).map((raw, idx) => { + const parsed = SalaryRunEmployeeRowSchema.safeParse(raw) + if (!parsed.success) { + const fields = parsed.error.issues.map((iss) => iss.path.join('.')).join(', ') + throw new AGIIncompleteDataError( + `salary_run_employees rad ${idx} har ogiltig form (saknar/felaktiga fält: ${fields}). ` + + 'Detta blockerar AGI-generering eftersom Skatteverket annars skulle få bogus värden ' + + '(till exempel emitterade flaggor eller specifikationsnummer = 0).', + ['salary_run_employees'], + ) + } + return parsed.data + }) + + // Cutoff for the Växa-stöd FK062/FK063 split: pre-2024-05-01 hires get the + // legacy "första anställda"-flag (FK062); 2024-05-01 and later get the + // utvidgat växa-stöd flag (FK063). Cutoff from Skatteverket spec (Prop. + // 2023/24:80, RAML revisionshistorik 1.19). + const VAXA_STOD_FK063_CUTOFF = '2024-05-01' + + const employeeData: AGIEmployeeData[] = parsedRows.map((sre) => { + const emp = sre.employee + const lineItems = (sre.line_items ?? []) as Array<{ item_type: string; amount?: number | null; quantity?: number | null }> const benefitCar = sumLineItemAmounts(lineItems, ['benefit_car']) - const benefitMeals = sumLineItemAmounts(lineItems, ['benefit_meals']) + const benefitFuel = sumLineItemAmounts(lineItems, ['benefit_fuel']) const benefitHousing = sumLineItemAmounts(lineItems, ['benefit_housing']) - const benefitOther = sumLineItemAmounts(lineItems, ['benefit_wellness', 'benefit_other']) - const absenceEvents = absenceByEmployee.get(sre.employee_id as string) + // FK015 kostförmån has its own field — never fold into FK012. + // Skatteverket cross-checks the krona-amount against the PBB-schablon. + const benefitMeals = sumLineItemAmounts(lineItems, ['benefit_meals']) + // FK012 SkatteplOvrigaFormanerUlagAG is the catch-all for taxable + // benefits without their own FK code (bike, wellness, "other") PLUS + // the krona-amount for housing (since FK041/FK043 carry only the flag). + const benefitOther = sumLineItemAmounts(lineItems, [ + 'benefit_bike', + 'benefit_wellness', + 'benefit_other', + ]) + benefitHousing + // Default housing type: if the employee got a housing benefit line + // item but no housing_benefit_type is set, treat as 'ej_smahus' (the + // more common case). NULL with no benefit line item → no flag emitted. + let housingBenefit: 'smahus' | 'ej_smahus' | undefined + if (benefitHousing > 0) { + housingBenefit = emp?.housing_benefit_type ?? 'ej_smahus' + } + + const absenceEvents = absenceByEmployee.get(sre.employee_id) + + let vaxaStod: 'forsta_anstalld' | 'vaxa_stod' | undefined + if (emp?.vaxa_stod_eligible) { + vaxaStod = + emp.employment_start && emp.employment_start < VAXA_STOD_FK063_CUTOFF + ? 'forsta_anstalld' + : 'vaxa_stod' + } + + // Växa-stöd (employment-start-gated relief, 10.21 % avgifter) and the + // ungdomsrabatt (age-gated relief, 'youth' avgifter_category) are + // distinct statutory programs and must not be claimed for the same + // employee in the same period. Catching this at generation time + // avoids emitting an FK062/FK063 flag inconsistent with the FK061 + // category total. + if (vaxaStod && sre.avgifter_category === 'youth') { + throw new AGIIncompleteDataError( + `Anställd ${emp?.specification_number ?? '?'}: kan inte kombinera växa-stöd ` + + '(FK062/FK063) med ungdomsrabatt (avgifter_category="youth") — programmen är ömsesidigt uteslutande. ' + + 'Välj ett av dem under anställdas inställningar.', + ['vaxa_stod_eligible', 'avgifter_category'], + ) + } + + const isFSkatt = emp?.f_skatt_status === 'f_skatt' return { - personnummer: emp?.personnummer || '', - specificationNumber: emp?.specification_number || 0, - grossSalary: sre.gross_salary as number, - taxWithheld: sre.tax_withheld as number, - avgifterBasis: sre.avgifter_basis as number, - fSkattPayment: - emp?.f_skatt_status === 'f_skatt' ? (sre.gross_salary as number) : undefined, + personnummer: emp?.personnummer ?? '', + specificationNumber: emp?.specification_number ?? 0, + removed: Boolean(sre.removed_from_agi), + grossSalary: sre.gross_salary, + taxWithheld: sre.tax_withheld, + avgifterBasis: sre.avgifter_basis, + fSkattPayment: isFSkatt ? sre.gross_salary : undefined, + // F-skatt payees: cash goes to FK131 and benefits to the ej-UlagSA + // variants (FK132/FK133/FK134/FK137/FK138/FK139). Regular employees + // get FK011 + FK012/FK013/FK015/FK018/FK041/FK043. + benefitsExcludedFromSAUnderlag: isFSkatt ? true : undefined, benefitCar: benefitCar > 0 ? benefitCar : undefined, - benefitHousing: benefitHousing > 0 ? benefitHousing : undefined, + benefitFuel: benefitFuel > 0 ? benefitFuel : undefined, benefitMeals: benefitMeals > 0 ? benefitMeals : undefined, + housingBenefit, benefitOther: benefitOther > 0 ? benefitOther : undefined, - sickDays: (sre.sick_days as number) > 0 ? (sre.sick_days as number) : undefined, - vabDays: (sre.vab_days as number) > 0 ? (sre.vab_days as number) : undefined, + benefitsAdjusted: Boolean(sre.benefits_adjusted), + vaxaStod, + sickDays: (sre.sick_days ?? 0) > 0 ? (sre.sick_days ?? 0) : undefined, + vabDays: (sre.vab_days ?? 0) > 0 ? (sre.vab_days ?? 0) : undefined, parentalDays: - (sre.parental_days as number) > 0 ? (sre.parental_days as number) : undefined, + (sre.parental_days ?? 0) > 0 ? (sre.parental_days ?? 0) : undefined, absenceEvents: absenceEvents && absenceEvents.length > 0 ? absenceEvents : undefined, } }, ) // 5. Build totals: avgifter by category (with rate-heuristic fallback for legacy runs). + // Removed-from-AGI rows (FK205 borttag) are tombstones — they must not + // contribute to FK497/FK487/FK499 because the prior submission's amounts + // remain on file at Skatteverket; the borttag just removes the IU itself. + const activeEmployees = parsedRows.filter((sre) => !sre.removed_from_agi) const avgifterByCategory: AGITotals['avgifterByCategory'] = {} - for (const sre of runEmployees as Array>) { - const dbCategory = sre.avgifter_category as string | null + for (const sre of activeEmployees) { + const dbCategory = sre.avgifter_category ?? null const category = dbCategory ? dbCategory === 'reduced_65plus' ? 'reduced65plus' : dbCategory === 'vaxa_stod' ? 'standard' : dbCategory - : (sre.avgifter_rate as number) <= 0.1022 + : sre.avgifter_rate <= 0.1022 ? 'reduced65plus' - : (sre.avgifter_rate as number) <= 0.2082 + : sre.avgifter_rate <= 0.2082 ? 'youth' : 'standard' const cat = (avgifterByCategory as Record)[ category ] || { basis: 0, amount: 0 } - cat.basis += sre.avgifter_basis as number - cat.amount += sre.avgifter_amount as number + cat.basis += sre.avgifter_basis + cat.amount += sre.avgifter_amount ;(avgifterByCategory as Record)[category] = cat } const totalAvgifterAmount = Object.values(avgifterByCategory).reduce( @@ -251,24 +385,30 @@ export async function generateAgiDeclaration( } const sjuklonRate = calcParams.sjuklonRate ?? calcParams.sjuklon_rate ?? 0.8 let totalSjuklonekostnad = 0 - for (const sre of runEmployees as Array>) { - const monthly = - ((sre.employee as { monthly_salary?: number } | null)?.monthly_salary as number) ?? 0 + for (const sre of activeEmployees) { + const monthly = sre.employee?.monthly_salary ?? 0 if (!monthly) continue const dailyRate = monthly / 21 - const lineItems = (sre.line_items || []) as Array> + const lineItems = (sre.line_items ?? []) as Array<{ item_type: string; amount?: number | null; quantity?: number | null }> for (const li of lineItems) { if (li.item_type === 'sick_day2_14') { - const days = (li.quantity as number) || 0 + const days = li.quantity ?? 0 totalSjuklonekostnad += dailyRate * sjuklonRate * days } } } + // FK497 SummaSkatteavdr must equal the sum of FK001 on active IUs (not + // run.total_tax, which includes removed rows). Same for FK487. + const totalTax = activeEmployees.reduce( + (sum, sre) => sum + (sre.tax_withheld || 0), + 0, + ) + const totals: AGITotals = { - totalTax: run.total_tax, - totalAvgifterBasis: (runEmployees as Array<{ avgifter_basis: number }>).reduce( - (s, e) => s + e.avgifter_basis, + totalTax: Math.round(totalTax * 100) / 100, + totalAvgifterBasis: activeEmployees.reduce( + (s, e) => s + (e.avgifter_basis || 0), 0, ), totalAvgifterAmount: Math.round(totalAvgifterAmount * 100) / 100, @@ -276,6 +416,31 @@ export async function generateAgiDeclaration( avgifterByCategory, } + // Soft AGI deadline check: warn (but don't block) when generating for a + // future period or one whose Skatteverket correction window is clearly + // past. Filing deadline is the 12th (17th in Jan/Aug for small employers) + // of the month after the period; SKV accepts corrections for a long time + // after, but a period > 13 months in the past is almost certainly a + // misclick. Surface via the logger so audit log + Sentry both see it. + { + const now = new Date() + const currentYM = now.getUTCFullYear() * 100 + (now.getUTCMonth() + 1) + const periodYM = run.period_year * 100 + run.period_month + if (periodYM > currentYM) { + opLog.warn('AGI generated for future period', { + companyId, + periodYear: run.period_year, + periodMonth: run.period_month, + }) + } else if (currentYM - periodYM > 13) { + opLog.warn('AGI generated for period > 13 months past', { + companyId, + periodYear: run.period_year, + periodMonth: run.period_month, + }) + } + } + // 6. Existing AGI determines correction status. Use `.maybeSingle()` // because the lookup must tolerate the no-row case without throwing — // that's the FIRST-time generation path. `.single()` would surface a @@ -302,6 +467,18 @@ export async function generateAgiDeclaration( details: { missing_fields: err.missingFields, message: err.message }, } } + if (err instanceof AGIPayloadTooLargeError) { + return { + ok: false, + code: 'AGI_PAYLOAD_TOO_LARGE', + details: { + message: err.message, + size_bytes: err.sizeBytes, + limit_bytes: err.limitBytes, + }, + status: 413, + } + } throw err } const individuppgifter = buildIndividuppgifterSnapshot(employeeData) diff --git a/lib/salary/agi/kontrollera-schemas.ts b/lib/salary/agi/kontrollera-schemas.ts new file mode 100644 index 00000000..4a681da2 --- /dev/null +++ b/lib/salary/agi/kontrollera-schemas.ts @@ -0,0 +1,105 @@ +import { z } from 'zod' + +/** + * Zod schemas for Skatteverket AGI pre-flight kontrollera endpoints. + * Matches the v1.7 §7 (HU) and §8 (IU) JSON spec exactly, with strict() + * to reject unknown properties — without this guard, a caller could inject + * fields like agRegistreradId or personnummer overrides into the payload + * we forward verbatim to SKV. + * + * Conventions used by Skatteverket: + * - IDENTITET: 12 digits (orgnr prefixed "16" + 10 digits, or personnummer YYYYMMDDXXXX) + * - redovisningsPeriod: YYYYMM + * - amount fields: integer SEK (no decimals) + * - boolean kryss fields: true/false (JSON) — SKV converts to 1 in XML + */ + +// 12-digit IDENTITET pattern. Slightly looser than the XSD regex used in +// xml-generator.ts (we don't re-validate samordningsnummer arithmetic here) +// because SKV will reject malformed values on its end with a clearer +// felmeddelande than we can surface — what matters here is that we don't +// forward an obviously bogus or oversized string. +const IDENTITET = z + .string() + .regex(/^\d{12}$/, 'Förväntat 12-siffrigt IDENTITET (orgnr eller personnummer).') + +const REDOVISNINGSPERIOD = z + .string() + .regex(/^\d{6}$/, 'Förväntat YYYYMM.') + // First period the API accepts; same constraint as xml-generator. + .refine((s) => Number.parseInt(s, 10) >= 201807, { + message: 'Redovisningsperioden är tidigare än 201807 (AGI API minimum).', + }) + +// SEK amount as non-negative integer with a sanity cap matching SKV's +// internal BELOPP10 (10-digit) ceiling. Bigger values are rejected locally +// rather than forwarded. +const AMOUNT = z.number().int().nonnegative().max(9_999_999_999) + +const SPEC_NUMBER = z.number().int().min(1).max(999_999_999) + +export const AGIKontrolleraHUSchema = z + .object({ + agRegistreradId: IDENTITET, + redovisningsPeriod: REDOVISNINGSPERIOD, + summaSkatteavdr: AMOUNT.optional(), + summaArbAvgSlf: AMOUNT.optional(), + totalSjuklonekostnad: AMOUNT.optional(), + }) + .strict() + +export type AGIKontrolleraHU = z.infer + +export const AGIKontrolleraIUSchema = z + .object({ + agRegistreradId: IDENTITET, + redovisningsPeriod: REDOVISNINGSPERIOD, + betalningsmottagarId: IDENTITET, + specifikationsnummer: SPEC_NUMBER, + + // Cash + tax — FK011 / FK001 + kontantErsattningUlagAG: AMOUNT.optional(), + avdrPrelSkatt: AMOUNT.optional(), + + // Benefit amounts (UlagAG variants) + skatteplBilformanUlagAG: AMOUNT.optional(), // FK013 + drivmVidBilformanUlagAG: AMOUNT.optional(), // FK018 + kostformanUlagAG: AMOUNT.optional(), // FK015 + skatteplOvrigaFormanerUlagAG: AMOUNT.optional(), // FK012 + + // Housing benefit KRYSS flags + bostadsformanSmahusUlagAG: z.boolean().optional(), // FK041 + bostadsformanEjSmahusUlagAG: z.boolean().optional(), // FK043 + + // F-skatt / ej UlagSA variants + kontantErsattningEjUlagSA: AMOUNT.optional(), // FK131 + skatteplBilformanEjUlagSA: AMOUNT.optional(), // FK133 + drivmVidBilformanEjUlagSA: AMOUNT.optional(), // FK134 + kostformanEjUlagSA: AMOUNT.optional(), // FK139 + skatteplOvrigaFormanerEjUlagSA: AMOUNT.optional(), // FK132 + bostadsformanSmahusEjUlagSA: z.boolean().optional(), // FK137 + bostadsformanEjSmahusEjUlagSA: z.boolean().optional(),// FK138 + + // Flags + formanHarJusterats: z.boolean().optional(), // FK048 + forstaAnstalld: z.boolean().optional(), // FK062 + vaxaStod: z.boolean().optional(), // FK063 + borttag: z.boolean().optional(), // FK205 + }) + .strict() + .refine( + (iu) => !(iu.forstaAnstalld === true && iu.vaxaStod === true), + { + message: 'FK062 (ForstaAnstalld) och FK063 (VaxaStod) är ömsesidigt uteslutande.', + path: ['vaxaStod'], + }, + ) + +export type AGIKontrolleraIU = z.infer + +/** + * Hard cap on the raw JSON body for kontrollera endpoints. Even a fully + * legal IU is < 4 KB serialised; 64 KB is a generous safety margin that + * still trivially rejects pathological inputs before they reach Zod. + */ +export const AGI_KONTROLLERA_MAX_BYTES = 64 * 1024 diff --git a/lib/salary/agi/xml-generator.ts b/lib/salary/agi/xml-generator.ts index 72be785d..f98eb521 100644 --- a/lib/salary/agi/xml-generator.ts +++ b/lib/salary/agi/xml-generator.ts @@ -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 1; + * 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 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(` ${period}`) lines.push(` ${emp.specificationNumber}`) + // 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(' 1') + lines.push(' ') + lines.push(' ') + lines.push(' ') + continue + } + // FK011 — Kontant ersättning, underlag arbetsgivaravgifter (= gross salary) if (emp.grossSalary > 0) { lines.push(` ${formatAmount(emp.grossSalary)}`) @@ -326,36 +494,73 @@ export function generateAGIXml( lines.push(` ${formatAmount(emp.taxWithheld)}`) } - // 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(` ${formatAmount(emp.benefitCar)}`) + const code = exclSA ? '133' : '013' + const elem = exclSA ? 'SkatteplBilformanEjUlagSA' : 'SkatteplBilformanUlagAG' + lines.push(` ${formatAmount(emp.benefitCar)}`) } - // 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(` ${formatAmount(emp.benefitFuel)}`) + const code = exclSA ? '134' : '018' + const elem = exclSA ? 'DrivmVidBilformanEjUlagSA' : 'DrivmVidBilformanUlagAG' + lines.push(` ${formatAmount(emp.benefitFuel)}`) } - // 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(` ${formatAmount(emp.benefitHousing)}`) + // 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(` ${formatAmount(emp.benefitMeals)}`) } - // 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(` 1`) + } else if (emp.housingBenefit === 'ej_smahus') { + const code = exclSA ? '138' : '043' + const elem = exclSA ? 'BostadsformanEjSmahusEjUlagSA' : 'BostadsformanEjSmahusUlagAG' + lines.push(` 1`) + } + + // Ö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(` ${formatAmount(emp.benefitOther)}`) + const code = exclSA ? '132' : '012' + const elem = exclSA ? 'SkatteplOvrigaFormanerEjUlagSA' : 'SkatteplOvrigaFormanerUlagAG' + lines.push(` ${formatAmount(emp.benefitOther)}`) } - // 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(` ${formatAmount(emp.fSkattPayment)}`) } + // FK048 — FormanHarJusterats (any benefit value adjusted away from schablon) + if (emp.benefitsAdjusted) { + lines.push(' 1') + } + + // 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(' 1') + } else if (emp.vaxaStod === 'vaxa_stod') { + lines.push(' 1') + } + // 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('') - 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, } }) } diff --git a/supabase/migrations/20260517100000_salary_run_employees_agi_removed.sql b/supabase/migrations/20260517100000_salary_run_employees_agi_removed.sql new file mode 100644 index 00000000..e76c2c35 --- /dev/null +++ b/supabase/migrations/20260517100000_salary_run_employees_agi_removed.sql @@ -0,0 +1,17 @@ +-- Add `removed_from_agi` flag to salary_run_employees so a correction can +-- tombstone a previously-submitted individuppgift via Skatteverket FK205 +-- Borttag. The xml generator emits the IU with only identity fields +-- (FK201/FK215/FK570/FK006) plus 1 when this is set — +-- Skatteverket matches on (AgRegistreradId, BetalningsmottagarId, +-- RedovisningsPeriod, Specifikationsnummer) and removes the prior IU. +-- +-- Defaults to false. Only meaningful for runs that have an AGI declaration +-- already filed for the period — the UI gates the toggle on that state. + +ALTER TABLE salary_run_employees + ADD COLUMN removed_from_agi BOOLEAN NOT NULL DEFAULT false; + +COMMENT ON COLUMN salary_run_employees.removed_from_agi IS + 'When true, emit this IU as FK205 Borttag in the next AGI XML so Skatteverket removes the prior individuppgift for this employee+period. Use for corrections where an employee should not have been in the run.'; + +NOTIFY pgrst, 'reload schema'; diff --git a/supabase/migrations/20260517100000_signature_delete_blocks_declined.sql b/supabase/migrations/20260517105000_signature_delete_blocks_declined.sql similarity index 100% rename from supabase/migrations/20260517100000_signature_delete_blocks_declined.sql rename to supabase/migrations/20260517105000_signature_delete_blocks_declined.sql diff --git a/supabase/migrations/20260517110000_salary_run_employees_benefits_adjusted.sql b/supabase/migrations/20260517110000_salary_run_employees_benefits_adjusted.sql new file mode 100644 index 00000000..aa66b027 --- /dev/null +++ b/supabase/migrations/20260517110000_salary_run_employees_benefits_adjusted.sql @@ -0,0 +1,17 @@ +-- Flag for AGI FK048 FormanHarJusterats — emitted on an IU when a benefit +-- value has been adjusted away from the schablon (e.g. car benefit reduced +-- because the employee uses the car less than the standard assumption, or +-- a housing benefit set below the typical jämförelsehyra). Skatteverket +-- expects the flag set whenever any förmånsvärde on the IU has been +-- justerat downward — they may follow up with questions. +-- +-- We track it per salary_run_employees row because adjustments are decided +-- and recorded at run time, not on the employee master. + +ALTER TABLE salary_run_employees + ADD COLUMN benefits_adjusted BOOLEAN NOT NULL DEFAULT false; + +COMMENT ON COLUMN salary_run_employees.benefits_adjusted IS + 'When true, the AGI XML emits FK048 FormanHarJusterats=1 on this IU. Set when any förmånsvärde on the row has been adjusted away from the standard schablon.'; + +NOTIFY pgrst, 'reload schema'; diff --git a/supabase/migrations/20260517120000_salary_absence_days_franvaro_specifikationsnummer.sql b/supabase/migrations/20260517120000_salary_absence_days_franvaro_specifikationsnummer.sql new file mode 100644 index 00000000..6cca1af0 --- /dev/null +++ b/supabase/migrations/20260517120000_salary_absence_days_franvaro_specifikationsnummer.sql @@ -0,0 +1,115 @@ +-- Persist FranvaroSpecifikationsnummer per (employee, year-month) for AGI +-- Frånvarouppgift stability across corrections. +-- +-- Before this migration the xml generator computed the number as a 1-based +-- index over sorted absence dates. Skatteverket's spec key for matching a +-- replacement is (BetalningsmottagarId, FranvaroDatum, +-- FranvaroSpecifikationsnummer, RedovisningsPeriod, AgRegistreradId). The +-- date is in the key so adding new days is safe — but deleting a day mid- +-- period would shift every later day's index, causing Skatteverket to see +-- one Frånvarouppgift as a *replacement* of a different earlier one +-- (because the FranvaroDatum changes too) and the earlier one as orphaned. +-- +-- Assigning the number on INSERT and never re-numbering makes the +-- correction path safe: if a day is deleted, its number is freed but +-- nothing else moves; if a day is added, it gets max+1. Pre-existing +-- vab/parental rows are backfilled in date order. + +ALTER TABLE salary_absence_days + ADD COLUMN franvaro_specifikationsnummer INTEGER; + +-- Backfill existing vab/parental rows: sort by absence_date within each +-- (employee, YYYYMM) bucket and assign sequential numbers from 1. Older +-- rows get lower numbers so the first AGI submission for the period (which +-- happens shortly after the month ends) stays consistent with what was +-- already filed before this migration. +-- +-- The year-month key is computed as YEAR*100+MONTH (e.g. 202605) rather +-- than date_trunc('month', …) because expressions used in unique indexes +-- must be IMMUTABLE, and date_trunc(text, date) resolves to the STABLE +-- timestamptz overload on PostgreSQL < 14 (and even on newer versions with +-- some search_path / overload-resolution combinations). extract(year/month +-- from date) is unambiguously IMMUTABLE. +WITH numbered AS ( + SELECT + id, + ROW_NUMBER() OVER ( + PARTITION BY employee_id, (extract(year FROM absence_date)::int * 100 + extract(month FROM absence_date)::int) + ORDER BY absence_date, id + ) AS rn + FROM salary_absence_days + WHERE absence_type IN ('vab', 'parental') +) +UPDATE salary_absence_days sad + SET franvaro_specifikationsnummer = numbered.rn + FROM numbered + WHERE sad.id = numbered.id; + +-- One number per (employee, year-month, type-applicable-row). The partial +-- index lets sick/pregnancy/etc rows leave the column NULL. +CREATE UNIQUE INDEX idx_salary_absence_days_franvaro_specnum + ON salary_absence_days ( + employee_id, + (extract(year FROM absence_date)::int * 100 + extract(month FROM absence_date)::int), + franvaro_specifikationsnummer + ) + WHERE franvaro_specifikationsnummer IS NOT NULL; + +-- Trigger: assign max+1 within the (employee, year-month) bucket on INSERT +-- when the column is NULL and the type warrants a Frånvarouppgift. +-- Skatteverket only reports VAB (TILLFALLIG_FORALDRAPENNING) and parental +-- (FORALDRAPENNING) — sick days go to Försäkringskassan via a separate +-- channel, and the other types (pregnancy, care_relative, study, +-- other_leave) are not reported as Frånvarouppgifter at all. +CREATE OR REPLACE FUNCTION public.assign_franvaro_specifikationsnummer() +RETURNS TRIGGER AS $$ +BEGIN + IF NEW.franvaro_specifikationsnummer IS NULL + AND NEW.absence_type IN ('vab', 'parental') THEN + SELECT COALESCE(MAX(franvaro_specifikationsnummer), 0) + 1 + INTO NEW.franvaro_specifikationsnummer + FROM salary_absence_days + WHERE employee_id = NEW.employee_id + AND (extract(year FROM absence_date)::int * 100 + extract(month FROM absence_date)::int) + = (extract(year FROM NEW.absence_date)::int * 100 + extract(month FROM NEW.absence_date)::int) + AND franvaro_specifikationsnummer IS NOT NULL; + END IF; + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +CREATE TRIGGER salary_absence_days_assign_franvaro_specnum + BEFORE INSERT ON salary_absence_days + FOR EACH ROW EXECUTE FUNCTION public.assign_franvaro_specifikationsnummer(); + +-- If a row's type later changes from sick→vab/parental, assign a number on +-- that UPDATE too (otherwise NULL would emit no Frånvarouppgift). The +-- reverse transition (vab→sick) keeps the number — harmless because we +-- only emit for vab/parental anyway and re-using the slot is fine. +CREATE OR REPLACE FUNCTION public.assign_franvaro_specifikationsnummer_on_update() +RETURNS TRIGGER AS $$ +BEGIN + IF NEW.franvaro_specifikationsnummer IS NULL + AND NEW.absence_type IN ('vab', 'parental') THEN + SELECT COALESCE(MAX(franvaro_specifikationsnummer), 0) + 1 + INTO NEW.franvaro_specifikationsnummer + FROM salary_absence_days + WHERE employee_id = NEW.employee_id + AND (extract(year FROM absence_date)::int * 100 + extract(month FROM absence_date)::int) + = (extract(year FROM NEW.absence_date)::int * 100 + extract(month FROM NEW.absence_date)::int) + AND franvaro_specifikationsnummer IS NOT NULL; + END IF; + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +CREATE TRIGGER salary_absence_days_assign_franvaro_specnum_update + BEFORE UPDATE OF absence_type ON salary_absence_days + FOR EACH ROW + WHEN (NEW.absence_type IN ('vab', 'parental') AND OLD.franvaro_specifikationsnummer IS NULL) + EXECUTE FUNCTION public.assign_franvaro_specifikationsnummer_on_update(); + +COMMENT ON COLUMN salary_absence_days.franvaro_specifikationsnummer IS + 'Stable per-(employee, year-month) sequence number for AGI Frånvarouppgift FK822. Assigned on INSERT for vab/parental rows; never re-numbered. Lets corrections survive day deletions without shifting numbers on remaining days.'; + +NOTIFY pgrst, 'reload schema'; diff --git a/supabase/migrations/20260517130000_employees_housing_benefit_type.sql b/supabase/migrations/20260517130000_employees_housing_benefit_type.sql new file mode 100644 index 00000000..00d35228 --- /dev/null +++ b/supabase/migrations/20260517130000_employees_housing_benefit_type.sql @@ -0,0 +1,18 @@ +-- AGI FK041 BostadsformanSmahusUlagAG vs FK043 BostadsformanEjSmahusUlagAG +-- distinguishes single-family-home (småhus) housing benefits from any other +-- type (lägenhet, hyresrätt, etc.). Both are boolean KRYSS in the XSD — the +-- AMOUNT belongs in FK012 SkatteplOvrigaFormanerUlagAG. +-- +-- Housing benefit type is a long-term arrangement (employer provides this +-- specific dwelling for the employee), so it lives on the employees table +-- rather than per-line-item. NULL means no housing benefit; the column is +-- only consulted when the employee has a benefit_housing line item. + +ALTER TABLE employees + ADD COLUMN housing_benefit_type TEXT + CHECK (housing_benefit_type IS NULL OR housing_benefit_type IN ('smahus', 'ej_smahus')); + +COMMENT ON COLUMN employees.housing_benefit_type IS + 'When the employee receives a housing benefit, distinguishes småhus (FK041) from other dwellings (FK043) in the AGI XML. NULL = no housing benefit. Defaults to ej_smahus interpretation if a benefit_housing line item exists but this column is NULL.'; + +NOTIFY pgrst, 'reload schema'; diff --git a/supabase/migrations/20260517135000_skatteverket_audit_franvaro_lock.sql b/supabase/migrations/20260517135000_skatteverket_audit_franvaro_lock.sql new file mode 100644 index 00000000..8be57dfc --- /dev/null +++ b/supabase/migrations/20260517135000_skatteverket_audit_franvaro_lock.sql @@ -0,0 +1,176 @@ +-- skatteverket_api_audit_log: long-lived audit trail for every outbound call +-- to a Skatteverket regulator endpoint. Separate from audit_log (which is +-- DB-trigger-only and lacks company_id/endpoint columns). Type II reviewers +-- need to reconstruct who submitted what AGI/moms payload to SKV and when. +-- +-- Append-only. RLS by company_id. No retention TTL — these records are part +-- of räkenskapsinformation under BFL 7 kap (7-year minimum). + +CREATE TABLE public.skatteverket_api_audit_log ( + id uuid PRIMARY KEY DEFAULT uuid_generate_v4(), + company_id uuid NOT NULL REFERENCES public.companies(id) ON DELETE RESTRICT, + user_id uuid NOT NULL, -- no cascade: survives user deletion + endpoint text NOT NULL, -- e.g. 'agi.kontrollera.hu', 'agi.submit', 'moms.declaration.lock' + ag_registered_id text, -- 12-digit IDENTITET (orgnr or pnr) when known + redovisningsperiod text, -- YYYYMM when applicable + outcome text NOT NULL CHECK (outcome IN ('ok', 'validation_error', 'skv_error', 'auth_error', 'internal_error')), + response_status integer, -- HTTP status from SKV (or local error code) + skv_status text, -- 'OK' | 'INFO' | 'ARENDE' | 'STOPP' | 'AVVISANDE' for kontrollsvar + request_size_bytes integer, + correlation_id text, -- for cross-system tracing + error_message text, + created_at timestamptz NOT NULL DEFAULT now() +); + +ALTER TABLE public.skatteverket_api_audit_log ENABLE ROW LEVEL SECURITY; + +CREATE POLICY skatteverket_api_audit_log_select ON public.skatteverket_api_audit_log + FOR SELECT USING (company_id IN (SELECT public.user_company_ids())); + +-- No INSERT/UPDATE/DELETE policies for normal roles — service role writes +-- only. Immutability triggers below block all UPDATE/DELETE regardless of role. + +CREATE INDEX idx_skv_audit_company_created ON public.skatteverket_api_audit_log (company_id, created_at DESC); +CREATE INDEX idx_skv_audit_endpoint ON public.skatteverket_api_audit_log (endpoint); +CREATE INDEX idx_skv_audit_user ON public.skatteverket_api_audit_log (user_id); + +CREATE OR REPLACE FUNCTION public.skatteverket_audit_immutable() +RETURNS trigger +LANGUAGE plpgsql +AS $$ +BEGIN + RAISE EXCEPTION 'Skatteverket audit log entries cannot be modified or deleted'; +END; +$$; + +CREATE TRIGGER skv_audit_no_update + BEFORE UPDATE ON public.skatteverket_api_audit_log + FOR EACH ROW EXECUTE FUNCTION public.skatteverket_audit_immutable(); + +CREATE TRIGGER skv_audit_no_delete + BEFORE DELETE ON public.skatteverket_api_audit_log + FOR EACH ROW EXECUTE FUNCTION public.skatteverket_audit_immutable(); + +-- salary_absence_franvaro_audit: SOC 2 CC7.2 — record every assignment of +-- franvaro_specifikationsnummer by the DB trigger so a Type II reviewer can +-- reconstruct the sequence history per (employee, year-month). The trigger +-- runs SECURITY DEFINER (implicit in plpgsql functions that own the table); +-- without this, an auditor has no record of when each number was minted. +CREATE TABLE public.salary_absence_franvaro_audit ( + id uuid PRIMARY KEY DEFAULT uuid_generate_v4(), + absence_day_id uuid NOT NULL, + employee_id uuid NOT NULL, + absence_date date NOT NULL, + year_month text NOT NULL, -- YYYY-MM + old_specifikationsnummer integer, + new_specifikationsnummer integer NOT NULL, + trigger_op text NOT NULL CHECK (trigger_op IN ('insert', 'update')), + assigned_at timestamptz NOT NULL DEFAULT now() +); + +ALTER TABLE public.salary_absence_franvaro_audit ENABLE ROW LEVEL SECURITY; + +CREATE INDEX idx_franvaro_audit_employee_month + ON public.salary_absence_franvaro_audit (employee_id, year_month, assigned_at); + +CREATE OR REPLACE FUNCTION public.franvaro_audit_immutable() +RETURNS trigger +LANGUAGE plpgsql +AS $$ +BEGIN + RAISE EXCEPTION 'Frånvaro audit entries cannot be modified or deleted'; +END; +$$; + +CREATE TRIGGER franvaro_audit_no_update + BEFORE UPDATE ON public.salary_absence_franvaro_audit + FOR EACH ROW EXECUTE FUNCTION public.franvaro_audit_immutable(); + +CREATE TRIGGER franvaro_audit_no_delete + BEFORE DELETE ON public.salary_absence_franvaro_audit + FOR EACH ROW EXECUTE FUNCTION public.franvaro_audit_immutable(); + +-- Replace the trigger functions to (1) take an advisory lock per +-- (employee_id, year-month) so concurrent inserts serialise rather than +-- racing on MAX(...)+1 and surfacing as a unique-violation, and (2) write an +-- audit row capturing the assignment. +-- +-- pg_advisory_xact_lock is released at transaction commit/rollback and is +-- keyed by a 64-bit int built from the employee UUID's low 32 bits XOR'd +-- with the year-month integer. Collisions across employees in different +-- months are harmless (just serialise more than needed); collisions across +-- different employees in the same month are statistically negligible and +-- still correct. +CREATE OR REPLACE FUNCTION public.assign_franvaro_specifikationsnummer() +RETURNS TRIGGER AS $$ +DECLARE + v_lock_key bigint; + v_year_month text; + v_new_num integer; +BEGIN + IF NEW.franvaro_specifikationsnummer IS NULL + AND NEW.absence_type IN ('vab', 'parental') THEN + v_year_month := to_char(NEW.absence_date, 'YYYY-MM'); + v_lock_key := ('x' || substr(replace(NEW.employee_id::text, '-', ''), 1, 8))::bit(32)::bigint + # (extract(year FROM NEW.absence_date)::int * 100 + extract(month FROM NEW.absence_date)::int); + PERFORM pg_advisory_xact_lock(v_lock_key); + + SELECT COALESCE(MAX(franvaro_specifikationsnummer), 0) + 1 + INTO v_new_num + FROM salary_absence_days + WHERE employee_id = NEW.employee_id + AND (extract(year FROM absence_date)::int * 100 + extract(month FROM absence_date)::int) + = (extract(year FROM NEW.absence_date)::int * 100 + extract(month FROM NEW.absence_date)::int) + AND franvaro_specifikationsnummer IS NOT NULL; + + NEW.franvaro_specifikationsnummer := v_new_num; + + INSERT INTO public.salary_absence_franvaro_audit ( + absence_day_id, employee_id, absence_date, year_month, + old_specifikationsnummer, new_specifikationsnummer, trigger_op + ) VALUES ( + NEW.id, NEW.employee_id, NEW.absence_date, v_year_month, + NULL, v_new_num, 'insert' + ); + END IF; + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +CREATE OR REPLACE FUNCTION public.assign_franvaro_specifikationsnummer_on_update() +RETURNS TRIGGER AS $$ +DECLARE + v_lock_key bigint; + v_year_month text; + v_new_num integer; +BEGIN + IF NEW.franvaro_specifikationsnummer IS NULL + AND NEW.absence_type IN ('vab', 'parental') THEN + v_year_month := to_char(NEW.absence_date, 'YYYY-MM'); + v_lock_key := ('x' || substr(replace(NEW.employee_id::text, '-', ''), 1, 8))::bit(32)::bigint + # (extract(year FROM NEW.absence_date)::int * 100 + extract(month FROM NEW.absence_date)::int); + PERFORM pg_advisory_xact_lock(v_lock_key); + + SELECT COALESCE(MAX(franvaro_specifikationsnummer), 0) + 1 + INTO v_new_num + FROM salary_absence_days + WHERE employee_id = NEW.employee_id + AND (extract(year FROM absence_date)::int * 100 + extract(month FROM absence_date)::int) + = (extract(year FROM NEW.absence_date)::int * 100 + extract(month FROM NEW.absence_date)::int) + AND franvaro_specifikationsnummer IS NOT NULL; + + NEW.franvaro_specifikationsnummer := v_new_num; + + INSERT INTO public.salary_absence_franvaro_audit ( + absence_day_id, employee_id, absence_date, year_month, + old_specifikationsnummer, new_specifikationsnummer, trigger_op + ) VALUES ( + NEW.id, NEW.employee_id, NEW.absence_date, v_year_month, + OLD.franvaro_specifikationsnummer, v_new_num, 'update' + ); + END IF; + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +NOTIFY pgrst, 'reload schema';