Skv/e2e overview (#515)
* feat(agi): refactor AGI XML generation and data handling - Remove deprecated AGI field codes from field-codes.ts. - Update generate-declaration.ts to include new employee fields and handle absence data with stable specification numbers. - Enhance XML generation in xml-generator.ts to support new flags for housing benefits and adjusted benefits. - Introduce new database migrations to support: - `removed_from_agi` flag for tombstoning individuppgifter. - `benefits_adjusted` flag for tracking adjustments to benefits. - `franvaro_specifikationsnummer` for stable absence event identification. - `housing_benefit_type` to differentiate between housing benefit types. * feat: Implement strict validation for AGI employee data and introduce pre-flight validation schemas - Added Zod schemas for validating employee data in AGI declarations to ensure all required fields are present and correctly typed, preventing silent errors during processing. - Introduced AGI pre-flight validation schemas for Skatteverket endpoints to validate individual and head unit submissions before sending to the API. - Created a new audit log table for tracking all outbound calls to Skatteverket, ensuring compliance and traceability for AGI and moms submissions. - Implemented advisory locks in the database to manage concurrent updates to absence specification numbers, enhancing data integrity. - Added compliance documentation for GDPR processing activities related to AGI and moms submissions, detailing data handling and retention policies. * feat: Extend DELETE RLS policy to protect 'declined' signatures in årsredovisning * fix: Update date handling in salary absence migrations to use immutable year-month key * fix: Update SELECT policy in skatteverket_api_audit_log to use IN clause for company_id * feat: Add skatteverket_api_audit_log and salary_absence_franvaro_audit tables with RLS policies and immutable triggers
This commit is contained in:
@@ -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 <Borttag>1</Borttag> 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';
|
||||
@@ -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';
|
||||
+115
@@ -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';
|
||||
@@ -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';
|
||||
@@ -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';
|
||||
Reference in New Issue
Block a user