Files
accounted/supabase/migrations/20260824151000_detach_underlag_duplicate_rpc.sql
T
MattssonandClaude Fable 5 174f315d9c fix(documents): anchor underlag at deferred SI booking, sanctioned duplicate detach (#1860)
* 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>
2026-08-24 17:05:00 +02:00

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';