fix: Swedish VAT/SIE compliance, storno hardening, document integrity (#209)

* feat: add INK2 declaration improvements, invoice delivery date, and Swedish compliance skills

Expand INK2 engine with full INK2S/INK2R support and improved SRU generation.
Add delivery_date field to invoices and corresponding PDF/migration support.
Add Claude skills for Swedish asset accounting, invoice compliance, SIE import/export, SRU filing, and tax planning.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: address PR review — map BAS 4500–4899, strip CRLF in SRU, document P3

- Map BAS accounts 4500–4599 (legoarbeten), 4700–4899 (diverse
  varuinköpskostnader) to SRU 7512 so they are not silently dropped
  from INK2R declarations
- Strip \r\n in sanitizeString to prevent CRLF injection in SRU fields
- Document P3 period suffix limitation for brutet räkenskapsår

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: correct BAS 4500-4599, 4700-4899 mapping from 7512 to 7511

Per the official BAS-to-SRU mapping, these account ranges are cost of
goods (legoarbeten, inkurans, svinn) and belong under 7511 (Råvaror
och förnödenheter), not 7512 (Handelsvaror). 7512 remains 4600-4699.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: Swedish VAT compliance — representation VAT, domestic RC, full BAS 26xx mapping, SIE encoding

- Representation expenses now default to reduced_12 VAT (ML 13 kap 24-25 §§);
  income tax deduction was abolished 2017 but VAT deduction at 12% remains
- Domestic reverse charge (byggtjänster etc.) uses 2647 instead of 2645,
  with distinct line descriptions for Swedish vs EU/non-EU RC
- VAT declaration maps all BAS 26xx variant accounts (egna uttag 2612/2622/2632,
  uthyrning 2613/2623/2633, VMB 2616/2626/2636, import 2615/2625/2635,
  domestic RC 2647, frivillig skattskyldighet 2642) and revenue variants
  (3108/3105/3004/3100) to correct momsdeklaration rutor
- SIE parser: remove unreliable #FORMAT PC8 encoding detection (most software
  exports UTF-8 with PC8 header), parse #FLAGGA for import-already-done warning,
  default SIE type to 1 when absent, fix RTRANS/BTRANS documentation
- SIE export: add #RAR -1 (previous fiscal year), fix UB = IB + movements
- Error messages: add pattern matching for locked period trigger errors

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: address Greptile review — update ruta49 JSDoc, use null sentinel in error map

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: harden storno CAS guard, document integrity, and BFNAR archive compliance

- Storno: defer original→reversed until both entries succeed, add CAS guard
  for concurrent reversals, use cancelEntry() instead of delete
- Document: add document.accessed event, enrich archive manifest with metadata,
  add BFNAR 2013:2 systemdokumentation to full archive export
- Verify cron: run daily, configurable batch size, include company_id in audit
- Migrations: integrity audit actions, document version chain, metadata
  immutability, audit deletions, fix immutability for posted/cancelled

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: address Greptile review — allow is_current_version in immutability trigger, log cancelEntry errors

- Remove is_current_version from blocked fields in enforce_document_metadata_immutability
  trigger so create_document_version RPC can supersede documents linked to posted entries
- Add error logging to cancelEntry for observability on cleanup failures

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-04-09 16:12:03 +02:00
committed by GitHub
co-authored by Claude Opus 4.6
parent 6ccd4f429c
commit b484e9a7b4
17 changed files with 1214 additions and 47 deletions
@@ -0,0 +1,15 @@
-- Add INTEGRITY_FAILURE to audit_log action CHECK constraint
-- The verify cron (app/api/documents/verify/cron) inserts INTEGRITY_FAILURE
-- but the original CHECK constraint in migration 014 did not include it,
-- causing all integrity failure logging to silently fail.
ALTER TABLE public.audit_log DROP CONSTRAINT audit_log_action_check;
ALTER TABLE public.audit_log ADD CONSTRAINT audit_log_action_check CHECK (action IN (
'INSERT', 'UPDATE', 'DELETE',
'COMMIT', 'REVERSE', 'CORRECT',
'LOCK_PERIOD', 'CLOSE_PERIOD',
'DOCUMENT_DELETE_BLOCKED', 'RETENTION_BLOCK',
'SECURITY_EVENT',
'INTEGRITY_FAILURE'
));
@@ -0,0 +1,116 @@
-- Document version chain: prev_version_hash column + create_document_version RPC
-- Fixes the non-functional document versioning (migration 023 was a placeholder).
-- The createNewVersion() in lib/core/documents/document-service.ts calls this RPC.
-- 1. Add prev_version_hash column for cryptographic version chain
ALTER TABLE public.document_attachments
ADD COLUMN IF NOT EXISTS prev_version_hash text;
-- 2. Atomic version creation RPC
-- Row-locks the current version, inserts a new version with hash chain,
-- and marks the old version as superseded — all in one transaction.
CREATE OR REPLACE FUNCTION public.create_document_version(
p_user_id uuid,
p_original_doc_id uuid,
p_storage_path text,
p_file_name text,
p_file_size_bytes bigint,
p_mime_type text,
p_sha256_hash text
)
RETURNS uuid
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public
AS $$
DECLARE
v_current document_attachments%ROWTYPE;
v_new_id uuid;
v_root_id uuid;
v_next_version integer;
BEGIN
-- Lock the current version row to prevent concurrent versioning
SELECT * INTO v_current
FROM public.document_attachments
WHERE id = p_original_doc_id
AND is_current_version = true
FOR UPDATE;
IF v_current IS NULL THEN
RAISE EXCEPTION 'Document % not found or is not the current version', p_original_doc_id;
END IF;
-- Determine root document and next version number
v_root_id := COALESCE(v_current.original_id, v_current.id);
v_next_version := v_current.version + 1;
-- Insert new version with hash chain link
INSERT INTO public.document_attachments (
user_id, company_id, storage_path, file_name, file_size_bytes,
mime_type, sha256_hash, version, original_id, is_current_version,
uploaded_by, upload_source, digitization_date,
journal_entry_id, journal_entry_line_id, prev_version_hash
) VALUES (
p_user_id, v_current.company_id, p_storage_path, p_file_name,
p_file_size_bytes, p_mime_type, p_sha256_hash, v_next_version,
v_root_id, true, p_user_id, v_current.upload_source, now(),
v_current.journal_entry_id, v_current.journal_entry_line_id,
v_current.sha256_hash -- cryptographic link to previous version
)
RETURNING id INTO v_new_id;
-- Mark old version as superseded
UPDATE public.document_attachments
SET is_current_version = false,
superseded_by_id = v_new_id
WHERE id = p_original_doc_id;
RETURN v_new_id;
END;
$$;
-- 3. Version chain validation function
-- Walks the version chain from newest to oldest and verifies each
-- prev_version_hash matches the prior version's sha256_hash.
CREATE OR REPLACE FUNCTION public.validate_version_chain(p_document_id uuid)
RETURNS TABLE(version integer, document_id uuid, hash_valid boolean)
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public
AS $$
DECLARE
v_root_id uuid;
BEGIN
-- Find root document
SELECT COALESCE(da.original_id, da.id) INTO v_root_id
FROM public.document_attachments da
WHERE da.id = p_document_id;
IF v_root_id IS NULL THEN
RAISE EXCEPTION 'Document % not found', p_document_id;
END IF;
-- Walk chain and verify hashes
RETURN QUERY
WITH chain AS (
SELECT
da.id AS doc_id,
da.version AS ver,
da.sha256_hash,
da.prev_version_hash,
LAG(da.sha256_hash) OVER (ORDER BY da.version) AS expected_prev_hash
FROM public.document_attachments da
WHERE da.id = v_root_id OR da.original_id = v_root_id
ORDER BY da.version
)
SELECT
chain.ver,
chain.doc_id,
CASE
WHEN chain.ver = 1 THEN chain.prev_version_hash IS NULL
ELSE chain.prev_version_hash IS NOT DISTINCT FROM chain.expected_prev_hash
END AS hash_valid
FROM chain
ORDER BY chain.ver;
END;
$$;
@@ -0,0 +1,57 @@
-- Enforce document metadata immutability for documents linked to committed entries
-- BFL 7 kap requires verifikation underlag to be immutable once committed.
-- Existing triggers only block DELETE — this blocks metadata UPDATE.
CREATE OR REPLACE FUNCTION public.enforce_document_metadata_immutability()
RETURNS trigger
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public
AS $$
DECLARE
v_entry_status text;
BEGIN
-- Only enforce on documents already linked to a journal entry
IF OLD.journal_entry_id IS NULL THEN
RETURN NEW;
END IF;
SELECT status INTO v_entry_status
FROM public.journal_entries
WHERE id = OLD.journal_entry_id;
-- Only enforce for committed (posted/reversed) entries
IF v_entry_status IS NULL OR v_entry_status NOT IN ('posted', 'reversed') THEN
RETURN NEW;
END IF;
-- Block changes to immutable fields
-- Allowed: last_integrity_check_at (cron), updated_at (auto-trigger),
-- superseded_by_id (versioning), prev_version_hash (versioning),
-- journal_entry_id/journal_entry_line_id (linking)
IF NEW.file_name IS DISTINCT FROM OLD.file_name
OR NEW.storage_path IS DISTINCT FROM OLD.storage_path
OR NEW.file_size_bytes IS DISTINCT FROM OLD.file_size_bytes
OR NEW.mime_type IS DISTINCT FROM OLD.mime_type
OR NEW.sha256_hash IS DISTINCT FROM OLD.sha256_hash
OR NEW.upload_source IS DISTINCT FROM OLD.upload_source
OR NEW.digitization_date IS DISTINCT FROM OLD.digitization_date
OR NEW.uploaded_by IS DISTINCT FROM OLD.uploaded_by
OR NEW.version IS DISTINCT FROM OLD.version
OR NEW.original_id IS DISTINCT FROM OLD.original_id
THEN
-- Log the blocked attempt
INSERT INTO public.audit_log (user_id, company_id, action, table_name, record_id, description)
VALUES (OLD.user_id, OLD.company_id, 'SECURITY_EVENT', 'document_attachments', OLD.id,
'Blocked metadata modification of document linked to ' || v_entry_status || ' entry ' || OLD.journal_entry_id);
RAISE EXCEPTION 'Cannot modify metadata of document linked to a % journal entry (BFL 7 kap)', v_entry_status;
END IF;
RETURN NEW;
END;
$$;
CREATE TRIGGER enforce_document_metadata_immutability
BEFORE UPDATE ON public.document_attachments
FOR EACH ROW EXECUTE FUNCTION public.enforce_document_metadata_immutability();
@@ -0,0 +1,67 @@
-- Audit all document deletions, including unlinked documents
-- Previously, block_document_deletion() only logged linked document deletion attempts.
-- Unlinked documents were deleted without any trace in the audit log.
CREATE OR REPLACE FUNCTION public.block_document_deletion()
RETURNS trigger
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public
AS $$
DECLARE
v_entry_status text;
v_retention_expires date;
BEGIN
-- Always log document deletion attempts (linked and unlinked)
IF OLD.journal_entry_id IS NULL THEN
INSERT INTO public.audit_log (user_id, company_id, action, table_name, record_id, description, old_state)
VALUES (
OLD.user_id, OLD.company_id, 'DELETE', 'document_attachments', OLD.id,
'Deleted unlinked document "' || OLD.file_name || '"',
to_jsonb(OLD)
);
-- Allow deletion of unlinked documents
RETURN OLD;
END IF;
-- Check if linked to a committed journal entry
SELECT je.status INTO v_entry_status
FROM public.journal_entries je
WHERE je.id = OLD.journal_entry_id;
IF v_entry_status IN ('posted', 'reversed') THEN
-- Log the blocked attempt
INSERT INTO public.audit_log (user_id, company_id, action, table_name, record_id, description)
VALUES (OLD.user_id, OLD.company_id, 'DOCUMENT_DELETE_BLOCKED', 'document_attachments', OLD.id,
'Attempted deletion of document linked to ' || v_entry_status || ' journal entry ' || OLD.journal_entry_id);
RAISE EXCEPTION 'Cannot delete document linked to a % journal entry (Bokforingslagen)',
v_entry_status;
END IF;
-- Check retention window
SELECT fp.retention_expires_at INTO v_retention_expires
FROM public.journal_entries je
JOIN public.fiscal_periods fp ON fp.id = je.fiscal_period_id
WHERE je.id = OLD.journal_entry_id;
IF v_retention_expires IS NOT NULL AND v_retention_expires > CURRENT_DATE THEN
INSERT INTO public.audit_log (user_id, company_id, action, table_name, record_id, description)
VALUES (OLD.user_id, OLD.company_id, 'RETENTION_BLOCK', 'document_attachments', OLD.id,
'Attempted deletion within retention period (expires ' || v_retention_expires || ')');
RAISE EXCEPTION 'Cannot delete document within 7-year retention period (expires %)',
v_retention_expires;
END IF;
-- Log deletion of linked-but-not-committed documents
INSERT INTO public.audit_log (user_id, company_id, action, table_name, record_id, description, old_state)
VALUES (
OLD.user_id, OLD.company_id, 'DELETE', 'document_attachments', OLD.id,
'Deleted document "' || OLD.file_name || '" linked to draft entry ' || OLD.journal_entry_id,
to_jsonb(OLD)
);
RETURN OLD;
END;
$$;
@@ -0,0 +1,35 @@
-- Fix: add posted → cancelled transition for orphaned entry cleanup.
-- The live trigger is missing this transition due to migration 20260319000001
-- being edited after it was applied. engine.ts reverseEntry and storno-service
-- need posted → cancelled for CAS guard cleanup of orphaned concurrent reversals.
CREATE OR REPLACE FUNCTION public.enforce_journal_entry_immutability()
RETURNS trigger LANGUAGE plpgsql AS $$
BEGIN
IF TG_OP = 'DELETE' THEN
-- No exemption for drafts: varaktighet applies from insertion.
-- Application code uses status='cancelled' instead of DELETE.
RAISE EXCEPTION 'Cannot delete journal entries (id: %, status: %). Use cancelled status instead.',
OLD.id, OLD.status;
END IF;
-- Draft can transition to draft (update fields), posted, or cancelled
IF OLD.status = 'draft' AND NEW.status IN ('draft', 'posted', 'cancelled') THEN
RETURN NEW;
END IF;
-- Posted can transition to reversed (storno) or cancelled (orphaned cleanup)
IF OLD.status = 'posted' AND NEW.status IN ('reversed', 'cancelled') THEN
IF NEW.status = 'reversed' THEN
IF NEW.description != OLD.description OR NEW.entry_date != OLD.entry_date
OR NEW.fiscal_period_id != OLD.fiscal_period_id
OR NEW.voucher_number != OLD.voucher_number THEN
RAISE EXCEPTION 'Cannot modify fields of a posted entry during reversal (id: %)', OLD.id;
END IF;
END IF;
RETURN NEW;
END IF;
RAISE EXCEPTION 'Cannot modify a % journal entry (id: %). Committed entries are immutable per Bokforingslagen.',
OLD.status, OLD.id;
END; $$;