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:
co-authored by
Claude Opus 4.6
parent
6ccd4f429c
commit
b484e9a7b4
@@ -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; $$;
|
||||
Reference in New Issue
Block a user