Files
accounted/extensions/general/skatteverket/lib/agi-client.ts
T
Mattsson a652dcae1a 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
2026-05-17 23:52:49 +02:00

421 lines
14 KiB
TypeScript

import type { SupabaseClient } from '@supabase/supabase-js'
import { skvRequest } from './api-client'
import type {
SkatteverketAGIErrorBody,
SkatteverketAGIGranskningsunderlagResponse,
SkatteverketAGIKontrollresultat,
SkatteverketAGIKontrollsvar,
SkatteverketAGIKvittenserResponse,
SkatteverketAGIUnderlagResponse,
} from '../types'
/**
* Skatteverket AGI (Arbetsgivardeklaration) client.
*
* Two RAMLs back this surface:
* • inlamning v1.7.7 — XML ingest + JSON status reads
* base: /arbetsgivardeklaration/inlamning/v1
* • hanteraredovisningsperiod v1.2.8 — period lock + receipts
* base: /arbetsgivardeklaration/hanteraredovisningsperiod/v1
*
* Filing flow:
* 1. POST /underlag (XML body, returns inlamningId)
* 2. GET /underlag/{inlamningId}/kontrollresultat (poll until status != PROCESSING)
* 3a. POST /underlag/{inlamningId}/spara (move into Eget utrymme)
* 3b. DELETE /underlag/{inlamningId} (abort if user wants to retry)
* 4. POST /arbetsgivare/{x}/redovisningsperioder/{y}/skapaGranskningsunderlag?lasPeriod=true
* → returns Mina Sidor deep-link for BankID signing
* 5. GET /arbetsgivare/{x}/redovisningsperioder/{y}/kvittenser (after user signs)
*
* Optional period management on the hantera API:
* POST /arbetsgivare/{x}/redovisningsperioder/{y}/las (lock)
* POST /arbetsgivare/{x}/redovisningsperioder/{y}/lasUpp (unlock)
*
* Cleanup paths (both on the inlämning API, NOT hantera):
* DELETE /underlag/{inlamningId}
* — abort an unsaved underlag (use agiAvbrytUnderlag)
* DELETE /arbetsgivare/{x}/redovisningsperioder/{y}/inlamningar/{inlamningId}
* — remove a SAVED underlag from Eget utrymme (use agiTaBortSparadInlamning)
*
* Note: skvRequest already maps 401/403/429 to SkatteverketAuthError. AGI
* 400/404/409 carry the SkatteverketAGIErrorBody envelope, which we surface
* via Result.error so callers can render meddelandeTillAnvandare verbatim.
*/
const DEFAULT_INLAMNING_BASE_URL =
'https://api.test.skatteverket.se/arbetsgivardeklaration/inlamning/v1'
const DEFAULT_HANTERA_BASE_URL =
'https://api.test.skatteverket.se/arbetsgivardeklaration/hanteraredovisningsperiod/v1'
function getInlamningBaseUrl(): string {
return process.env.SKATTEVERKET_AGD_INLAMNING_API_BASE_URL || DEFAULT_INLAMNING_BASE_URL
}
function getHanteraBaseUrl(): string {
return process.env.SKATTEVERKET_AGD_PERIOD_API_BASE_URL || DEFAULT_HANTERA_BASE_URL
}
function periodPath(arbetsgivare: string, period: string): string {
return `/arbetsgivare/${arbetsgivare}/redovisningsperioder/${period}`
}
interface Ok<T> { ok: true; status: number; data: T }
interface Err { ok: false; status: number; error: string; body?: SkatteverketAGIErrorBody }
type Result<T> = Ok<T> | Err
async function readErrorBody(response: Response): Promise<{ error: string; body?: SkatteverketAGIErrorBody }> {
try {
const body = (await response.json()) as SkatteverketAGIErrorBody
if (body && typeof body.meddelandeTillAnvandare === 'string') {
return { error: body.meddelandeTillAnvandare, body }
}
return { error: JSON.stringify(body) }
} catch {
return { error: `Skatteverket svarade med ${response.status}` }
}
}
/**
* POST /underlag — XML body, returns the new inlämningsId.
*
* The xml is whatever generateAGIXml() produced. We don't validate it
* locally; Skatteverket replies 415 if the schema fails parsing, or 400
* with felkod 38 if required fields are missing.
*/
export async function agiPostUnderlag(
supabase: SupabaseClient,
userId: string,
xml: string,
): Promise<Result<SkatteverketAGIUnderlagResponse>> {
const response = await skvRequest(
supabase,
userId,
'POST',
'/underlag',
xml,
{
baseUrl: getInlamningBaseUrl(),
contentType: 'application/xml',
},
)
if (!response.ok) {
const { error, body } = await readErrorBody(response)
return { ok: false, status: response.status, error, body }
}
const data = (await response.json()) as SkatteverketAGIUnderlagResponse
return { ok: true, status: response.status, data }
}
/**
* GET /underlag/{inlamningId}/kontrollresultat.
*
* Returns immediately with the current status. Callers should poll while
* status === 'PROCESSING' (Skatteverket's typical processing time is sub-
* second but the spec doesn't guarantee it).
*/
export async function agiGetKontrollresultat(
supabase: SupabaseClient,
userId: string,
inlamningId: number,
): Promise<Result<SkatteverketAGIKontrollresultat>> {
const response = await skvRequest(
supabase,
userId,
'GET',
`/underlag/${inlamningId}/kontrollresultat`,
undefined,
{ 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 SkatteverketAGIKontrollresultat
return { ok: true, status: response.status, data }
}
/**
* POST /underlag/{inlamningId}/spara — commit the underlag to Eget utrymme.
*
* Allowed even when kontrollresultat reports DONE_REJECTED — SKV will keep
* the rejected underlag in Eget utrymme as a record. Mina Sidor does NOT
* expose in-place editing of saved underlag; correcting a rejected AGI
* means generating new XML (with the same FK570 specifikationsnummer per
* employee) and resubmitting it as a rättelse via the same /underlag flow.
* The current AGIPanel doesn't auto-spara on rejection; it surfaces the
* findings and leaves recovery (re-generate + re-submit) to the user.
*
* Returns 400 felkod 20 if the underlag was already saved or had no
* errors to fix.
*/
export async function agiSparaUnderlag(
supabase: SupabaseClient,
userId: string,
inlamningId: number,
): Promise<Result<unknown>> {
const response = await skvRequest(
supabase,
userId,
'POST',
`/underlag/${inlamningId}/spara`,
undefined,
{ baseUrl: getInlamningBaseUrl() },
)
if (!response.ok) {
const { error, body } = await readErrorBody(response)
return { ok: false, status: response.status, error, body }
}
const data = await response.json().catch(() => ({}))
return { ok: true, status: response.status, data }
}
/**
* DELETE /underlag/{inlamningId} — avbryt en inlämning som ännu inte sparats.
*
* Use this to discard an underlag whose kontrollresultat showed errors
* before the user has clicked "spara". For *saved* underlag use
* agiTaBortSparadInlamning() below.
*/
export async function agiAvbrytUnderlag(
supabase: SupabaseClient,
userId: string,
inlamningId: number,
): Promise<Result<unknown>> {
const response = await skvRequest(
supabase,
userId,
'DELETE',
`/underlag/${inlamningId}`,
undefined,
{ baseUrl: getInlamningBaseUrl() },
)
if (response.status === 204) return { ok: true, status: 204, data: {} }
if (!response.ok) {
const { error, body } = await readErrorBody(response)
return { ok: false, status: response.status, error, body }
}
const data = await response.json().catch(() => ({}))
return { ok: true, status: response.status, data }
}
/**
* DELETE a saved underlag for an arbetsgivare + period. Distinct from
* agiAvbrytUnderlag — this targets the saved copy in Eget utrymme.
*/
export async function agiTaBortSparadInlamning(
supabase: SupabaseClient,
userId: string,
arbetsgivare: string,
period: string,
inlamningId: number,
): Promise<Result<unknown>> {
const response = await skvRequest(
supabase,
userId,
'DELETE',
`${periodPath(arbetsgivare, period)}/inlamningar/${inlamningId}`,
undefined,
{ baseUrl: getInlamningBaseUrl() },
)
if (response.status === 204) return { ok: true, status: 204, data: {} }
if (!response.ok) {
const { error, body } = await readErrorBody(response)
return { ok: false, status: response.status, error, body }
}
return { ok: true, status: response.status, data: {} }
}
/**
* POST /arbetsgivare/{x}/redovisningsperioder/{y}/skapaGranskningsunderlag.
*
* Returns a Mina Sidor deep-link the user opens in a new tab to sign with
* BankID. `lasPeriod=true` locks the period for changes during signing —
* recommended for the happy path. Caller can later POST .../las or .../lasUpp
* on the hantera API to flip the lock without regenerating the granskning.
*/
export async function agiSkapaGranskningsunderlag(
supabase: SupabaseClient,
userId: string,
arbetsgivare: string,
period: string,
options: { lasPeriod?: boolean } = {},
): Promise<Result<SkatteverketAGIGranskningsunderlagResponse>> {
const qs = options.lasPeriod ? '?lasPeriod=true' : ''
const response = await skvRequest(
supabase,
userId,
'POST',
`${periodPath(arbetsgivare, period)}/skapaGranskningsunderlag${qs}`,
undefined,
{ baseUrl: getInlamningBaseUrl() },
)
// 409 INCORRECT_DATA returns the same shape as 200 (with a felrapport
// link) — surface it as data rather than an error so the UI can route the
// user to fix the rejected underlag.
if (response.status === 409) {
const data = (await response.json()) as SkatteverketAGIGranskningsunderlagResponse
return { ok: true, status: 409, data }
}
if (!response.ok) {
const { error, body } = await readErrorBody(response)
return { ok: false, status: response.status, error, body }
}
const data = (await response.json()) as SkatteverketAGIGranskningsunderlagResponse
return { ok: true, status: response.status, data }
}
/**
* GET /arbetsgivare/{x}/redovisningsperioder/{y}/kvittenser
* (hanteraredovisningsperiod API).
*
* Returns an empty kvittenser array until the user has signed in Mina Sidor.
* Once signed, each receipt carries uuidKvittens + signeradAv + signeradTid.
*/
export async function agiGetKvittenser(
supabase: SupabaseClient,
userId: string,
arbetsgivare: string,
period: string,
): Promise<Result<SkatteverketAGIKvittenserResponse>> {
const response = await skvRequest(
supabase,
userId,
'GET',
`${periodPath(arbetsgivare, period)}/kvittenser`,
undefined,
{ baseUrl: getHanteraBaseUrl() },
)
if (!response.ok) {
const { error, body } = await readErrorBody(response)
return { ok: false, status: response.status, error, body }
}
const data = (await response.json()) as SkatteverketAGIKvittenserResponse
return { ok: true, status: response.status, data }
}
/**
* POST /arbetsgivare/{x}/redovisningsperioder/{y}/las (hantera API).
* Locks the period for changes — typically called automatically by
* skapaGranskningsunderlag with lasPeriod=true.
*/
export async function agiLasPeriod(
supabase: SupabaseClient,
userId: string,
arbetsgivare: string,
period: string,
): Promise<Result<unknown>> {
const response = await skvRequest(
supabase,
userId,
'POST',
`${periodPath(arbetsgivare, period)}/las`,
undefined,
{ baseUrl: getHanteraBaseUrl() },
)
if (!response.ok) {
const { error, body } = await readErrorBody(response)
return { ok: false, status: response.status, error, body }
}
const data = await response.json().catch(() => ({}))
return { ok: true, status: response.status, data }
}
/** POST /arbetsgivare/{x}/redovisningsperioder/{y}/lasUpp (hantera API). */
export async function agiLasUppPeriod(
supabase: SupabaseClient,
userId: string,
arbetsgivare: string,
period: string,
): Promise<Result<unknown>> {
const response = await skvRequest(
supabase,
userId,
'POST',
`${periodPath(arbetsgivare, period)}/lasUpp`,
undefined,
{ baseUrl: getHanteraBaseUrl() },
)
if (!response.ok) {
const { error, body } = await readErrorBody(response)
return { ok: false, status: response.status, error, body }
}
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<string, unknown>,
): Promise<Result<SkatteverketAGIKontrollsvar>> {
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<string, unknown>,
): Promise<Result<SkatteverketAGIKontrollsvar>> {
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 }
}