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
+1
View File
@@ -1623,3 +1623,4 @@ One line per decision: `[YYYY-MM-DD] <decision>: <why>`. Appended by agents and
[2026-09-06] Recurring invoice month phase (yearly in February, quarterly Feb/May/Aug/Nov) is exposed as a first/next invoice date (start_date on create, next_run_date on update; web dialog + MCP), not the reporter's "months offset" dropdown: an offset is a derived value relative to now that changes meaning when the interval changes, while the date maps one-to-one onto the next_run_date column that already anchors the phase, so no migration and no per-interval range rules. A date off the day_of_month grid is refused (400) instead of normalized, because the cron advances from the due date and an off-grid first run would drift back to day_of_month on the second run.
[2026-09-06] Voucher series names live on the existing Verifikationsserier list in settings, not a separate group: the list already enumerates the letters in use, and a name belongs next to the letter it names. Rows are the union of used, configured and named letters so a freshly assigned series can be named before its first verifikat.
[2026-09-06] Declined the request to import only SIE accounts with IB, UB or saldo <> 0: an inactive account is a harmless row in the chart, and dropping accounts breaks re-imports of later years that reference them. The chart imports whole.
[2026-09-06] reverseEntry releases the bank rows of a reversed verifikat through one RPC, release_reversed_entry_transactions (migration 20260906172540): a single data-modifying CTE nulls the pointer column (journal_entry_id, is_business, category, reconciliation_method) of every transaction that pointed at the reversed entry AND drops those rows' transaction_voucher_links to other verifikat (a residual booking's role 'other' anchor to the small residual verifikat, #2061, Option B of the issue). Chosen over teaching each role-blind gate (bulk_book_transactions' junction EXISTS, fetchJunctionLinkedTxIds behind the unmatched list, is_transaction_booked(), the reconciliation bridge) the bank_line-only rule: that forks the meaning of "booked" per surface and keeps counting a row as matched while the ledger explains a few kronor of it. First cut was two PostgREST statements (read ids, reset, delete); CodeRabbit's point that a failed read or a link created between the reset and the delete recreates the half-anchored row was right, so the reset and the drop moved into one statement under the UPDATE's row locks. SECURITY INVOKER so RLS and the writer-role trigger apply exactly as to the direct statements. Links to the reversed entry itself stay with the engine's junction cleanup (bulk-book N=1 writes a pointer and a bank_line row to the same entry). The residual verifikat stays posted and surfaces as unmatched, which is honest because its main sibling is gone. The 2026-08-29 bank_line-only re-booking guards stay as defense for rows left behind earlier; measured 2026-09-06 on prod, zero non-bank_line junction rows exist, so nothing needs repair. transaction_voucher_links is a mutable reconciliation index (ON DELETE CASCADE from both sides, plain member delete policy, koppla-bort deletes it freely), not rakenskapsinformation, so a hard delete does not touch the BFL audit chain.