fix(bookkeeping): storno of a residual booking's main verifikat releases the bank row whole (#2348)

* fix(bookkeeping): storno of a residual booking's main verifikat releases the bank row whole

A residual booking anchors a bank row twice: the pointer column holds the
main verifikat and one transaction_voucher_links row of role 'other' holds
the small residual verifikat. reverseEntry reset the pointer unconditionally
and left the 'other' row behind, so the row split across surfaces: the
worklist showed it as att bokfora (is_business IS NULL) while every reader
that counts junction rows (the unmatched list behind BookDirectlyDialog, the
bulk_book_transactions RPC, is_transaction_booked(), the reconciliation
bridge) went on calling it booked. Bulk-book refused it with
BULK_BOOK_TX_ALREADY_BOOKED on a row displayed as unbooked.

reverseEntry now reads the rows whose pointer it is about to reset and drops
their junction rows to any other verifikat right after the reset, before the
existing cleanup of the reversed entry's own junction rows. No anchor
survives, so every reader agrees without a role fork or a migration; the
residual verifikat stays posted and surfaces as unmatched, which is honest
because its main sibling is gone. This mirrors what koppla-bort and the 1:N
partial-split path already do.

Tests: engine.test.ts gains the residual case and the no-pointer case and
pins the pointer read before the reset; the opening-balance mock learns the
read. The bank_line-only re-booking guards from #2029 stay as defense for
rows left behind before this change (prod holds zero such rows).

Fixes #2061

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016xry8E1FuYbbedbwvZAxLv

* fix(bookkeeping): release reversed-entry transactions and drop their supplementary links in one RPC statement

Review finding on #2348 (CodeRabbit, Swedish review note): the pointer read,
the pointer reset and the supplementary-link delete were three PostgREST
statements. A failed read left the links behind with the pointer already
reset, the exact half-anchored row #2061 describes, and a link created
between the reset and the delete would have been removed from a stale id set.

release_reversed_entry_transactions(p_company_id, p_entry_id) does both in a
single data-modifying CTE under the UPDATE's row locks and one snapshot: the
DELETE only sees links that existed when the statement started and only for
the rows the UPDATE actually released. SECURITY INVOKER, so RLS and the
writer-role trigger apply exactly as they did to the direct statements.
Links to the reversed entry itself are still left to the engine's junction
cleanup (bulk-book N=1 writes a pointer and a bank_line row to the same
entry).

Migration 20260906172540 applied to staging and covered by
tests/pg/release-reversed-entry-transactions.pg.test.ts (main storno releases
whole, residual storno touches nothing, bank_line-to-self left for the
junction cleanup, tenant scope, viewer refused). Engine unit tests pin the
RPC call and the best-effort fallthrough on RPC error.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016xry8E1FuYbbedbwvZAxLv

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Mattsson
2026-09-06 19:45:03 +02:00
committed by GitHub
co-authored by Claude Fable 5.1
parent 7448490fb7
commit 3c033e466f
6 changed files with 398 additions and 40 deletions
@@ -0,0 +1,61 @@
-- Release the bank transactions a reversed verifikat explained, atomically.
--
-- Issue #2061. reverseEntry (lib/bookkeeping/engine.ts) reset the pointer
-- column of every transaction pointing at the reversed entry and then, as a
-- second statement, dropped the supplementary transaction_voucher_links rows
-- of those transactions (a residual booking's role 'other' anchor to the small
-- residual verifikat, lib/reconciliation/residual.ts). Two statements leave a
-- window: a failed read before the reset, or a link created between the reset
-- and the delete, and the row is back in the half-anchored state the issue
-- describes (the worklist says att bokfora, the junction readers say booked).
--
-- One data-modifying CTE does both under the UPDATE's row locks and a single
-- snapshot: the DELETE sees only the links that existed when the statement
-- started, and only for the rows the UPDATE actually released. Links to the
-- reversed entry itself are left alone on purpose: the engine's junction
-- cleanup owns them (bulk-book N=1 writes a pointer AND a bank_line row to the
-- same entry, and that cleanup is what releases samlingsverifikat rows).
--
-- SECURITY INVOKER on purpose: RLS and the aa_enforce_company_writer_role
-- trigger apply exactly as they did to the two direct statements, for user
-- sessions and for the service role alike. No new privilege is minted.
--
-- pg-test: tests/pg/release-reversed-entry-transactions.pg.test.ts
CREATE OR REPLACE FUNCTION public.release_reversed_entry_transactions(
p_company_id uuid,
p_entry_id uuid
)
RETURNS jsonb
LANGUAGE sql
SECURITY INVOKER
SET search_path TO 'public'
AS $$
WITH released AS (
UPDATE public.transactions
SET journal_entry_id = NULL,
is_business = NULL,
category = NULL,
reconciliation_method = NULL
WHERE company_id = p_company_id
AND journal_entry_id = p_entry_id
RETURNING id
),
dropped AS (
DELETE FROM public.transaction_voucher_links l
WHERE l.company_id = p_company_id
AND l.journal_entry_id <> p_entry_id
AND l.transaction_id IN (SELECT id FROM released)
RETURNING l.id
)
SELECT jsonb_build_object(
'released', (SELECT count(*) FROM released),
'dropped', (SELECT count(*) FROM dropped)
);
$$;
REVOKE ALL ON FUNCTION public.release_reversed_entry_transactions(uuid, uuid) FROM PUBLIC, anon;
GRANT EXECUTE ON FUNCTION public.release_reversed_entry_transactions(uuid, uuid) TO authenticated, service_role;
COMMENT ON FUNCTION public.release_reversed_entry_transactions(uuid, uuid) IS
'Storno helper (#2061): in one statement, null the pointer column of every transaction that pointed at the reversed entry and drop those transactions'' transaction_voucher_links rows to OTHER entries (a residual booking''s supplementary anchor). Links to the reversed entry itself are left for the engine''s junction cleanup. Returns {released, dropped} counts.';