* fix(documents): anchor underlag at deferred SI booking, sanctioned duplicate detach Support case 2026-08-24: a verifikat booked from a leverantorsfaktura/utlagg stayed under 'Saknar underlag' with the PDF attached, and a twice-uploaded underlag could only be replaced, never removed. - POST /api/supplier-invoices/[id]/book now calls anchorSupplierInvoiceDocument() after the CAS link: the deferred (#967) flow was the last booking surface that never anchored the invoice's retained source document, so every missing-underlag surface kept flagging the registration verifikat until payment. - Repair migration 20260824150000 re-runs the 20260727180000 sweep for rows created since (idempotent, open unlocked periods only). - New detach_underlag_duplicate RPC (migration 20260824151000): the one sanctioned path to detach a redundant duplicate underlag from a posted verifikat. Guarded: writer role, open unlocked period, company lock date, at least one other anchored underlag must remain (BFL 5 kap 7 par), pinned docs (transactions/supplier_invoices.document_id) stay replace-only. Audit-logged first, transaction-local gnubok.allow_delete carve-out. The file is never deleted: it returns to the unlinked pool. - POST /api/documents/[id]/detach + 'Koppla bort dubblett' in the verifikat attachments blocked-dialog when the entry keeps 2+ direct docs (sv+en). - Tests: book-route anchor assertions, detach route unit tests, pg-real suite for the RPC incl. the direct-UPDATE-stays-blocked invariant. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(documents): harden detach_underlag_duplicate per skeptic findings - Require sha256 identity: detach only when a remaining anchored sibling carries the same immutable sha256_hash, so only byte-identical duplicates ever leave a verifikat (two different handlingar both stay behind the WORM guards). UI gates the button on the same condition. - Enforce the documented posted-status guard (reversed/cancelled verifikat refuse detach). - Set company_id on the RPC's audit_log row: the SELECT policy filters on company_id, so the provenance row was invisible to every reader (same defect 20260528120600 fixed for delete_last_voucher). - Swedish 403 message on the tenant guard (CodeRabbit). - pg tests: closed-period case now seeds open and closes via UPDATE (the period-lock trigger blocks seeding into a closed period), duplicate pairs share a hash, added non-duplicate and reversed-entry refusals, audit assertion pins the RPC's own row (description + company_id + actor). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
211 lines
9.2 KiB
PL/PgSQL
211 lines
9.2 KiB
PL/PgSQL
-- detach_underlag_duplicate: the ONE sanctioned path for removing a redundant
|
|
-- duplicate underlag from a posted verifikation.
|
|
--
|
|
-- Problem (support case 2026-08-24): a user uploaded the same underlag twice
|
|
-- to one verifikat. Both uploads anchored (document_attachments.journal_entry_id
|
|
-- set), which puts them behind the WORM guards: block_document_deletion blocks
|
|
-- DELETE and enforce_document_journal_entry_immutability blocks clearing the
|
|
-- anchor. The UI could therefore only offer "Ersatt med ny version", a dead
|
|
-- end for a plain duplicate.
|
|
--
|
|
-- Legal analysis: BFL 5 kap 7 par requires the verifikation to reference its
|
|
-- underlag, and BFL 7 kap 2 par protects rakenskapsinformation for 7 years.
|
|
-- Neither requires TWO copies of the SAME underlag to stay bound to the
|
|
-- verifikat. That identity condition is enforced, not assumed: the detached
|
|
-- document must have a remaining anchored sibling with an identical
|
|
-- sha256_hash (the hash is immutable per 20260506150000), so only a
|
|
-- byte-identical duplicate ever leaves the verifikat. Two DIFFERENT
|
|
-- handlingar on one verifikation (faktura + betalkvitto) are each
|
|
-- rakenskapsinformation and both stay behind the WORM guards. The detached
|
|
-- file itself is NOT deleted: it returns to the company's unlinked document
|
|
-- pool (storage object and version chain untouched), where the ordinary
|
|
-- deleteDocument() rules apply. The operation is recorded in the append-only
|
|
-- audit_log before the write, mirroring correct_entry_metadata's log-first
|
|
-- ordering.
|
|
--
|
|
-- Guards, in order:
|
|
-- 1. caller must be an owner/admin/member of the company (JWT paths verify
|
|
-- membership via caller_is_company_member; p_user_id is only honored for
|
|
-- service-role callers, which authenticate the user application-side);
|
|
-- 2. the document must belong to the company, be the current version, and
|
|
-- be anchored to a POSTED journal entry of the same company (a reversed
|
|
-- or cancelled verifikat is corrected through storno, never edited);
|
|
-- 3. the entry's fiscal period must be open and unlocked, and the entry
|
|
-- date must be after the company lock date (same rattelse window as
|
|
-- inline rattelse: past a lock, storno is the only path);
|
|
-- 4. at least one OTHER current-version document must remain anchored to
|
|
-- the same journal entry (the verifikat never loses its last underlag),
|
|
-- and at least one of those siblings must carry the SAME sha256_hash
|
|
-- (only true duplicates are detachable);
|
|
-- 5. the document must not be pinned as transactions.document_id or
|
|
-- supplier_invoices.document_id: those pins have their own immutability
|
|
-- rules and consumers, so a pinned doc is replaced, never detached.
|
|
--
|
|
-- The gnubok.allow_delete carve-out is transaction-local and only set after
|
|
-- every guard has passed and the audit row is written, identical in spirit to
|
|
-- delete_last_voucher (20260506140000).
|
|
|
|
CREATE OR REPLACE FUNCTION public.detach_underlag_duplicate(
|
|
p_company_id uuid,
|
|
p_document_id uuid,
|
|
p_user_id uuid DEFAULT NULL
|
|
)
|
|
RETURNS jsonb
|
|
LANGUAGE plpgsql
|
|
SECURITY DEFINER
|
|
SET search_path TO 'public'
|
|
AS $function$
|
|
DECLARE
|
|
v_jwt_role text := coalesce(nullif(current_setting('request.jwt.claims', true), '')::jsonb ->> 'role', '');
|
|
v_actor uuid := COALESCE(p_user_id, auth.uid());
|
|
v_caller_role text;
|
|
v_doc record;
|
|
v_entry record;
|
|
v_is_closed boolean;
|
|
v_locked_at timestamptz;
|
|
v_lock_date date;
|
|
v_siblings integer;
|
|
v_duplicates integer;
|
|
v_pinned_tx uuid;
|
|
v_pinned_si uuid;
|
|
BEGIN
|
|
IF v_jwt_role IN ('anon', 'authenticated') THEN
|
|
IF NOT public.caller_is_company_member(p_company_id) THEN
|
|
RAISE EXCEPTION 'unauthorized: caller is not a member of company %', p_company_id
|
|
USING ERRCODE = '42501';
|
|
END IF;
|
|
-- A JWT caller can never act as someone else: p_user_id is only for
|
|
-- service-role paths, which authenticate the user application-side.
|
|
v_actor := auth.uid();
|
|
END IF;
|
|
|
|
SELECT cm.role INTO v_caller_role
|
|
FROM company_members cm
|
|
WHERE cm.company_id = p_company_id AND cm.user_id = v_actor;
|
|
|
|
IF v_caller_role IS NULL OR v_caller_role NOT IN ('owner', 'admin', 'member') THEN
|
|
RAISE EXCEPTION 'Endast användare med skrivbehörighet kan koppla bort underlag.';
|
|
END IF;
|
|
|
|
SELECT d.id, d.company_id, d.journal_entry_id, d.file_name, d.is_current_version, d.sha256_hash
|
|
INTO v_doc
|
|
FROM public.document_attachments d
|
|
WHERE d.id = p_document_id
|
|
FOR UPDATE OF d;
|
|
|
|
IF NOT FOUND OR v_doc.company_id <> p_company_id THEN
|
|
RAISE EXCEPTION 'Underlaget hittades inte.';
|
|
END IF;
|
|
IF v_doc.journal_entry_id IS NULL THEN
|
|
RAISE EXCEPTION 'Underlaget är inte kopplat till någon verifikation.';
|
|
END IF;
|
|
IF v_doc.is_current_version IS DISTINCT FROM true THEN
|
|
RAISE EXCEPTION 'Endast den aktuella versionen av ett underlag kan kopplas bort.';
|
|
END IF;
|
|
|
|
SELECT je.id, je.entry_date, je.status, je.fiscal_period_id, je.company_id AS entry_company_id
|
|
INTO v_entry
|
|
FROM public.journal_entries je
|
|
WHERE je.id = v_doc.journal_entry_id
|
|
FOR UPDATE OF je;
|
|
|
|
IF NOT FOUND OR v_entry.entry_company_id <> p_company_id THEN
|
|
RAISE EXCEPTION 'Verifikationen hittades inte.';
|
|
END IF;
|
|
|
|
IF v_entry.status <> 'posted' THEN
|
|
RAISE EXCEPTION 'Underlag kan bara kopplas bort från bokförda verifikat.';
|
|
END IF;
|
|
|
|
SELECT fp.is_closed, fp.locked_at
|
|
INTO v_is_closed, v_locked_at
|
|
FROM public.fiscal_periods fp
|
|
WHERE fp.id = v_entry.fiscal_period_id;
|
|
|
|
IF v_is_closed OR v_locked_at IS NOT NULL THEN
|
|
RAISE EXCEPTION 'Perioden är stängd eller låst: underlaget kan inte kopplas bort.';
|
|
END IF;
|
|
|
|
SELECT cs.bookkeeping_locked_through INTO v_lock_date
|
|
FROM public.company_settings cs
|
|
WHERE cs.company_id = p_company_id;
|
|
|
|
IF v_lock_date IS NOT NULL AND v_entry.entry_date <= v_lock_date THEN
|
|
RAISE EXCEPTION 'Bokföringen är låst t.o.m. %: underlaget kan inte kopplas bort.', v_lock_date;
|
|
END IF;
|
|
|
|
-- The verifikat must keep at least one anchored underlag (BFL 5 kap 7 par),
|
|
-- and only a byte-identical duplicate may leave: a remaining sibling must
|
|
-- carry the same immutable sha256_hash. Two different handlingar on one
|
|
-- verifikation are each rakenskapsinformation and both stay.
|
|
SELECT count(*),
|
|
count(*) FILTER (WHERE d.sha256_hash = v_doc.sha256_hash)
|
|
INTO v_siblings, v_duplicates
|
|
FROM public.document_attachments d
|
|
WHERE d.journal_entry_id = v_doc.journal_entry_id
|
|
AND d.company_id = p_company_id
|
|
AND d.is_current_version = true
|
|
AND d.id <> v_doc.id;
|
|
|
|
IF v_siblings = 0 THEN
|
|
RAISE EXCEPTION 'Verifikationen skulle stå utan underlag: det sista underlaget kan inte kopplas bort. Ersätt det med en ny version i stället.';
|
|
END IF;
|
|
|
|
IF v_duplicates = 0 THEN
|
|
RAISE EXCEPTION 'Underlaget är inte en dubblett: ingen identisk kopia finns kvar på verifikationen. Bara dubbletter kan kopplas bort.';
|
|
END IF;
|
|
|
|
-- A doc pinned to a bank transaction or serving as a supplier invoice's
|
|
-- retained source document is replaced through those flows, never detached.
|
|
SELECT t.id INTO v_pinned_tx
|
|
FROM public.transactions t
|
|
WHERE t.document_id = v_doc.id AND t.company_id = p_company_id
|
|
LIMIT 1;
|
|
IF v_pinned_tx IS NOT NULL THEN
|
|
RAISE EXCEPTION 'Underlaget är kopplat till en banktransaktion och kan inte kopplas bort här. Byt transaktionens underlag i stället.';
|
|
END IF;
|
|
|
|
SELECT si.id INTO v_pinned_si
|
|
FROM public.supplier_invoices si
|
|
WHERE si.document_id = v_doc.id AND si.company_id = p_company_id
|
|
LIMIT 1;
|
|
IF v_pinned_si IS NOT NULL THEN
|
|
RAISE EXCEPTION 'Underlaget är leverantörsfakturans originalunderlag och kan inte kopplas bort. Ersätt det med en ny version i stället.';
|
|
END IF;
|
|
|
|
-- Append-only audit FIRST: the carve-out below is only ever exercised in a
|
|
-- transaction that has already recorded who detached what from where.
|
|
-- company_id must be set explicitly: the audit_log SELECT policy filters on
|
|
-- company_id IN user_company_ids(), so a NULL row is invisible to every
|
|
-- reader (the exact delete_last_voucher defect 20260528120600 fixed).
|
|
INSERT INTO public.audit_log
|
|
(user_id, company_id, action, table_name, record_id, actor_id, old_state, new_state, description)
|
|
VALUES
|
|
(v_actor, p_company_id, 'UPDATE', 'document_attachments', v_doc.id, v_actor,
|
|
jsonb_build_object('journal_entry_id', v_doc.journal_entry_id, 'company_id', p_company_id, 'file_name', v_doc.file_name),
|
|
jsonb_build_object('journal_entry_id', NULL, 'company_id', p_company_id, 'file_name', v_doc.file_name),
|
|
'Dubblett-underlag frånkopplat från verifikation (annat underlag kvarstår)');
|
|
|
|
PERFORM set_config('gnubok.allow_delete', 'true', true);
|
|
|
|
UPDATE public.document_attachments
|
|
SET journal_entry_id = NULL,
|
|
journal_entry_line_id = NULL
|
|
WHERE id = v_doc.id;
|
|
|
|
PERFORM set_config('gnubok.allow_delete', 'false', true);
|
|
|
|
RETURN jsonb_build_object(
|
|
'detached', true,
|
|
'document_id', v_doc.id,
|
|
'journal_entry_id', v_doc.journal_entry_id,
|
|
'remaining_documents', v_siblings
|
|
);
|
|
END;
|
|
$function$;
|
|
|
|
REVOKE ALL ON FUNCTION public.detach_underlag_duplicate(uuid, uuid, uuid) FROM PUBLIC, anon;
|
|
GRANT EXECUTE ON FUNCTION public.detach_underlag_duplicate(uuid, uuid, uuid) TO authenticated, service_role;
|
|
|
|
NOTIFY pgrst, 'reload schema';
|