diff --git a/DECISIONS.md b/DECISIONS.md index 4956655d..acbdc6f5 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -1623,3 +1623,4 @@ One line per decision: `[YYYY-MM-DD] : `. 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. diff --git a/lib/bookkeeping/__tests__/engine.test.ts b/lib/bookkeeping/__tests__/engine.test.ts index 15ebca30..2c630985 100644 --- a/lib/bookkeeping/__tests__/engine.test.ts +++ b/lib/bookkeeping/__tests__/engine.test.ts @@ -922,9 +922,18 @@ describe('reverseEntry: bank transaction unlink', () => { * the delete (a row anchored to some other verifikat too), as ids (one * bank_line row each, no amount) or as full rows. `txRows` is what the * partial-split read returns for the rows that still have anchors. + * `release` is what the release_reversed_entry_transactions RPC answers + * (the pointer reset and the supplementary-link drop live in that RPC since + * #2061; its semantics are pinned in tests/pg/release-reversed-entry- + * transactions.pg.test.ts, here only the call and the fallthrough are). */ function setup( - opts: { voucherLinks?: string[]; remainingLinks?: Array; txRows?: TxRow[] } = {}, + opts: { + voucherLinks?: string[] + remainingLinks?: Array + txRows?: TxRow[] + release?: { data: unknown; error: unknown } + } = {}, ) { let jeCall = 0 const jeResults = [ @@ -968,12 +977,17 @@ describe('reverseEntry: bank transaction unlink', () => { b.eq = filter('eq') b.in = filter('in') b.is = filter('is') + b.neq = filter('neq') b.then = (resolve: (v: unknown) => void) => resolve(resolveWith(current)) return b } const supabase = { - rpc: vi.fn().mockResolvedValue({ data: 8, error: null }), + rpc: vi.fn().mockImplementation(async (name: string) => + name === 'release_reversed_entry_transactions' + ? (opts.release ?? { data: { released: 0, dropped: 0 }, error: null }) + : { data: 8, error: null }, + ), from: vi.fn().mockImplementation((table: string) => { if (table === 'journal_entries') return jeBuilder() if (table === 'chart_of_accounts') { @@ -1017,23 +1031,23 @@ describe('reverseEntry: bank transaction unlink', () => { } it('resets journal_entry_id, is_business and category so the row returns to Att bokföra (#1950)', async () => { - const { supabase, txWrites, linkOps } = setup() + const { supabase, txWrites, linkOps } = setup({ + release: { data: { released: 1, dropped: 1 }, error: null }, + }) const result = await reverseEntry(supabase as never, 'company-1', 'user-1', 'entry-1') expect(result.id).toBe('reversal-1') - expect(txWrites).toHaveLength(1) - expect(txWrites[0].payload).toEqual({ - journal_entry_id: null, - is_business: null, - category: null, - reconciliation_method: null, + // The pointer reset (journal_entry_id, is_business, category, + // reconciliation_method to null) and the drop of the released rows' + // supplementary junction links run inside one RPC statement (#2061), + // scoped to this company and this entry: never a company-wide reset. + expect(supabase.rpc).toHaveBeenCalledWith('release_reversed_entry_transactions', { + p_company_id: 'company-1', + p_entry_id: 'entry-1', }) - // Scoped to rows linked to the reversed entry only: never a company-wide reset. - expect(txWrites[0].filters).toEqual([ - ['eq', 'company_id', 'company-1'], - ['eq', 'journal_entry_id', 'entry-1'], - ]) + // No direct write to transactions is left on this path. + expect(txWrites).toHaveLength(0) // The junction is consulted for this entry only; nothing to delete or // release when it holds no rows. expect(linkOps).toEqual([ @@ -1086,10 +1100,10 @@ describe('reverseEntry: bank transaction unlink', () => { }, ]) - // [0] unlink, [1] the partial-split read for the row still anchored - // (tx-c), [2] the release of the rows with no anchor left. - expect(txWrites).toHaveLength(3) - expect(txWrites[1]).toEqual({ + // [0] the partial-split read for the row still anchored (tx-c), [1] the + // release of the rows with no anchor left. + expect(txWrites).toHaveLength(2) + expect(txWrites[0]).toEqual({ op: 'select', payload: 'id, amount, journal_entry_id', filters: [ @@ -1097,10 +1111,10 @@ describe('reverseEntry: bank transaction unlink', () => { ['in', 'id', ['tx-c']], ], }) - expect(txWrites[2].payload).toEqual({ is_business: null, category: null, reconciliation_method: null }) + expect(txWrites[1].payload).toEqual({ is_business: null, category: null, reconciliation_method: null }) // Only rows with no anchor left, and never a row whose journal_entry_id // still points at another verifikat (residual booking). - expect(txWrites[2].filters).toEqual([ + expect(txWrites[1].filters).toEqual([ ['eq', 'company_id', 'company-1'], ['in', 'id', ['tx-a', 'tx-b']], ['is', 'journal_entry_id', null], @@ -1116,8 +1130,8 @@ describe('reverseEntry: bank transaction unlink', () => { await reverseEntry(supabase as never, 'company-1', 'user-1', 'entry-1') expect(linkOps.map((o) => o.op)).toEqual(['select', 'delete', 'select']) - // The unlink plus the partial-split read; no release. - expect(txWrites.map((w) => w.op)).toEqual(['update', 'select']) + // Only the partial-split read; no release. + expect(txWrites.map((w) => w.op)).toEqual(['select']) }) it('releases a 1:N split whole when one of its verifikat is reversed (#1553): surviving slices dropped', async () => { @@ -1149,9 +1163,10 @@ describe('reverseEntry: bank transaction unlink', () => { it('keeps a row whose surviving slices still sum to its amount, or that carries a non-bank_line anchor', async () => { // tx-full: a bulk-booked-style anchor on another verifikat covering the - // whole amount. tx-res: a residual booking's 'other' row (its main - // verifikat pointer is null here because the storno of THAT verifikat is - // what left it; the residual row is not a slice and is never judged). + // whole amount. tx-res: a residual booking's 'other' row whose main + // verifikat pointer is already null (a row left behind before the main + // storno started dropping such links, #2061); the residual row is not a + // slice and the partial-split judgement never touches it. const { supabase, txWrites, linkOps } = setup({ voucherLinks: ['tx-full', 'tx-res'], remainingLinks: [ @@ -1167,7 +1182,24 @@ describe('reverseEntry: bank transaction unlink', () => { await reverseEntry(supabase as never, 'company-1', 'user-1', 'entry-1') expect(linkOps.map((o) => o.op)).toEqual(['select', 'delete', 'select']) - expect(txWrites.map((w) => w.op)).toEqual(['update', 'select']) + expect(txWrites.map((w) => w.op)).toEqual(['select']) + }) + + it('runs the release RPC before the junction cleanup, and the storno still completes when the RPC fails', async () => { + // The release is best effort like the junction cleanup below it: the + // storno is already posted by then, so an RPC error is logged and the + // entry-scoped junction cleanup still runs. + const { supabase, txWrites, linkOps } = setup({ + release: { data: null, error: { message: 'boom' } }, + }) + + const result = await reverseEntry(supabase as never, 'company-1', 'user-1', 'entry-1') + + expect(result.id).toBe('reversal-1') + const rpcNames = (supabase.rpc as ReturnType).mock.calls.map((c) => c[0]) + expect(rpcNames).toContain('release_reversed_entry_transactions') + expect(linkOps.map((o) => o.op)).toEqual(['select']) + expect(txWrites).toHaveLength(0) }) }) diff --git a/lib/bookkeeping/engine.ts b/lib/bookkeeping/engine.ts index 1cff21a0..41519fd6 100644 --- a/lib/bookkeeping/engine.ts +++ b/lib/bookkeeping/engine.ts @@ -1252,18 +1252,38 @@ export async function reverseEntry( // (is_business, category, journal_entry_id), plus reconciliation_method: // it describes how the link was made, and the link is gone (the koppla-bort // path in lib/reconciliation/bank-reconciliation.ts resets it the same way). - const { error: unlinkError } = await supabase - .from('transactions') - .update({ - journal_entry_id: null, - is_business: null, - category: null, - reconciliation_method: null, - }) - .eq('company_id', companyId) - .eq('journal_entry_id', entryId) + // + // A released row must have no anchor left. A residual booking + // (lib/reconciliation/residual.ts) keeps the main verifikat in the pointer + // column and anchors the small residual verifikat through a junction row of + // role 'other'. With the pointer gone that row would be the only thing left, + // and it explains a few kronor of fee, not the bank amount: every reader + // that counts junction rows (fetchJunctionLinkedTxIds, the bulk_book RPC, + // is_transaction_booked()) would go on calling the row booked while the + // worklist shows it as att bokföra (#2061). The release_reversed_entry_ + // transactions RPC therefore resets the pointer AND drops the released + // rows' links to other verifikat in one statement (one snapshot, the + // UPDATE's row locks): no read-then-write window in which a failed read or + // a concurrent booking leaves the half-anchored row behind. That mirrors + // what koppla-bort removes and what the 1:N partial-split path below drops; + // the residual verifikat stays posted and surfaces as unmatched again, + // which is honest: its main sibling is gone. Links to the reversed entry + // itself are left to the junction cleanup below, which owns them. + const { data: releaseData, error: unlinkError } = await supabase.rpc( + 'release_reversed_entry_transactions', + { p_company_id: companyId, p_entry_id: entryId }, + ) if (unlinkError) { log.error('failed to unlink transactions from reversed entry', unlinkError, { entryId }) + } else { + const counts = (releaseData ?? {}) as { released?: number; dropped?: number } + if ((counts.dropped ?? 0) > 0) { + log.info('dropped supplementary voucher links of released transactions', { + entryId, + released: counts.released ?? 0, + dropped: counts.dropped, + }) + } } // Same promise, second anchor. Bulk-booked rows (bulk_book_transactions RPC) diff --git a/lib/transactions/is-booked.ts b/lib/transactions/is-booked.ts index aec3de5e..67585eb6 100644 --- a/lib/transactions/is-booked.ts +++ b/lib/transactions/is-booked.ts @@ -93,10 +93,12 @@ export function getPrimaryJournalEntryId( * split of issue #1553), so its presence means the row is booked and a * second booking (manualLink, categorize, link-journal-entry) must refuse. * Rows with role 'other' (a residual booking, lib/reconciliation/residual.ts) - * or 'clearing' are supplementary anchors: after a storno of the main - * verifikat nulls the pointer, that leftover row must not strand the - * transaction with no way to re-book it. The list readers (fetchJunction- - * LinkedTxIds, is_transaction_booked()) keep counting every role. + * or 'clearing' are supplementary anchors. Since #2061 the engine drops them + * together with the pointer when the main verifikat is reversed, so a row + * with only a supplementary anchor is a leftover from before that change; + * such a row must still not be stranded with no way to re-book it. The list + * readers (fetchJunctionLinkedTxIds, is_transaction_booked()) keep counting + * every role, and agree with the worklist because no released row keeps one. */ export function hasBankLineJunctionRow( rows: Array<{ role?: string | null }> | null | undefined, diff --git a/supabase/migrations/20260906172540_release_reversed_entry_transactions.sql b/supabase/migrations/20260906172540_release_reversed_entry_transactions.sql new file mode 100644 index 00000000..b9c84753 --- /dev/null +++ b/supabase/migrations/20260906172540_release_reversed_entry_transactions.sql @@ -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.'; diff --git a/tests/pg/release-reversed-entry-transactions.pg.test.ts b/tests/pg/release-reversed-entry-transactions.pg.test.ts new file mode 100644 index 00000000..858eb5cd --- /dev/null +++ b/tests/pg/release-reversed-entry-transactions.pg.test.ts @@ -0,0 +1,242 @@ +import { randomUUID } from 'node:crypto' +import type { PoolClient } from 'pg' +import { describe, expect, it } from 'vitest' +import { getPool, withUserContext } from '@/tests/pg/setup' +import { + insertAuthUser, + insertCompany, + insertCompanyMember, + insertPostedJournalEntry, + insertTransaction, + seedCompany, +} from '@/tests/pg/fixtures' + +// release_reversed_entry_transactions(p_company_id, p_entry_id) is the storno +// helper reverseEntry (lib/bookkeeping/engine.ts) calls right after the CAS +// that marks the original entry reversed (migration +// 20260906172540_release_reversed_entry_transactions.sql, issue #2061). In one +// statement it nulls the pointer column of every transaction that pointed at +// the reversed entry and drops those transactions' transaction_voucher_links +// rows to OTHER entries: the residual booking's supplementary anchor. Links to +// the reversed entry itself are the engine's junction cleanup's business. + +interface TxState { + journal_entry_id: string | null + is_business: boolean | null + category: string | null + reconciliation_method: string | null +} + +async function insertLink(params: { + userId: string + companyId: string + transactionId: string + journalEntryId: string + amount: number + role: 'bank_line' | 'other' | 'clearing' +}): Promise { + const id = randomUUID() + await getPool().query( + `INSERT INTO public.transaction_voucher_links + (id, user_id, company_id, transaction_id, journal_entry_id, allocated_amount, role) + VALUES ($1, $2, $3, $4, $5, $6, $7)`, + [ + id, + params.userId, + params.companyId, + params.transactionId, + params.journalEntryId, + params.amount, + params.role, + ], + ) + return id +} + +// A residual booking as lib/reconciliation/residual.ts leaves it: the bank row +// points at the main verifikat and one 'other' link anchors the residual. +async function seedResidualBooking() { + const { userId, companyId, fiscalPeriodId } = await seedCompany() + const mainId = await insertPostedJournalEntry({ + userId, + companyId, + fiscalPeriodId, + voucherNumber: 1, + lines: [ + { accountNumber: '6212', debitAmount: 1000, creditAmount: 0 }, + { accountNumber: '1930', debitAmount: 0, creditAmount: 1000 }, + ], + }) + const residualId = await insertPostedJournalEntry({ + userId, + companyId, + fiscalPeriodId, + voucherNumber: 2, + lines: [ + { accountNumber: '6570', debitAmount: 10, creditAmount: 0 }, + { accountNumber: '1930', debitAmount: 0, creditAmount: 10 }, + ], + }) + const txId = await insertTransaction({ + userId, + companyId, + amount: -1010, + journalEntryId: mainId, + }) + await getPool().query( + `UPDATE public.transactions + SET is_business = true, category = 'office', reconciliation_method = 'manual' + WHERE id = $1`, + [txId], + ) + const otherLinkId = await insertLink({ + userId, + companyId, + transactionId: txId, + journalEntryId: residualId, + amount: -10, + role: 'other', + }) + return { userId, companyId, fiscalPeriodId, mainId, residualId, txId, otherLinkId } +} + +async function readTx(client: PoolClient, txId: string): Promise { + const r = await client.query( + `SELECT journal_entry_id, is_business, category, reconciliation_method + FROM public.transactions WHERE id = $1`, + [txId], + ) + return r.rows[0] +} + +async function countLinks(client: PoolClient, txId: string): Promise { + const r = await client.query<{ n: string }>( + `SELECT count(*)::text AS n FROM public.transaction_voucher_links WHERE transaction_id = $1`, + [txId], + ) + return Number(r.rows[0].n) +} + +describe('release_reversed_entry_transactions.pg (#2061)', () => { + it('storno of the MAIN verifikat releases the row whole: pointer reset and the residual link dropped', async () => { + const { userId, companyId, mainId, residualId, txId } = await seedResidualBooking() + + await withUserContext(userId, async (client) => { + const r = await client.query<{ out: { released: number; dropped: number } }>( + `SELECT public.release_reversed_entry_transactions($1::uuid, $2::uuid) AS out`, + [companyId, mainId], + ) + expect(r.rows[0].out).toEqual({ released: 1, dropped: 1 }) + + expect(await readTx(client, txId)).toEqual({ + journal_entry_id: null, + is_business: null, + category: null, + reconciliation_method: null, + }) + expect(await countLinks(client, txId)).toBe(0) + // is_transaction_booked() is the SQL twin of the readers that used to + // disagree with the worklist: it must now say unbooked. + const booked = await client.query<{ b: boolean }>( + `SELECT public.is_transaction_booked($1::uuid) AS b`, + [txId], + ) + expect(booked.rows[0].b).toBe(false) + // The residual verifikat is untouched: still posted, lines intact. + const residual = await client.query<{ status: string; n: string }>( + `SELECT je.status, (SELECT count(*)::text FROM public.journal_entry_lines l WHERE l.journal_entry_id = je.id) AS n + FROM public.journal_entries je WHERE je.id = $1`, + [residualId], + ) + expect(residual.rows[0]).toEqual({ status: 'posted', n: '2' }) + }) + }) + + it('storno of the RESIDUAL verifikat touches nothing: the row keeps its pointer and its link', async () => { + const { userId, companyId, mainId, residualId, txId } = await seedResidualBooking() + + await withUserContext(userId, async (client) => { + const r = await client.query<{ out: { released: number; dropped: number } }>( + `SELECT public.release_reversed_entry_transactions($1::uuid, $2::uuid) AS out`, + [companyId, residualId], + ) + expect(r.rows[0].out).toEqual({ released: 0, dropped: 0 }) + const tx = await readTx(client, txId) + expect(tx.journal_entry_id).toBe(mainId) + expect(tx.is_business).toBe(true) + // The 'other' link to the residual is the junction cleanup's job in + // reverseEntry, not this RPC's. + expect(await countLinks(client, txId)).toBe(1) + }) + }) + + it('leaves a bank_line link to the reversed entry itself for the junction cleanup (bulk-book N=1 shape)', async () => { + const { userId, companyId, fiscalPeriodId } = await seedCompany() + const entryId = await insertPostedJournalEntry({ userId, companyId, fiscalPeriodId, voucherNumber: 1 }) + const txId = await insertTransaction({ userId, companyId, amount: 1000, journalEntryId: entryId }) + await getPool().query(`UPDATE public.transactions SET is_business = true WHERE id = $1`, [txId]) + await insertLink({ userId, companyId, transactionId: txId, journalEntryId: entryId, amount: 1000, role: 'bank_line' }) + + await withUserContext(userId, async (client) => { + const r = await client.query<{ out: { released: number; dropped: number } }>( + `SELECT public.release_reversed_entry_transactions($1::uuid, $2::uuid) AS out`, + [companyId, entryId], + ) + expect(r.rows[0].out).toEqual({ released: 1, dropped: 0 }) + expect((await readTx(client, txId)).journal_entry_id).toBeNull() + expect(await countLinks(client, txId)).toBe(1) + }) + }) + + it('is tenant-scoped: a member of another company releases nothing', async () => { + const { companyId, mainId, txId } = await seedResidualBooking() + const outsider = await insertAuthUser() + const otherCompany = await insertCompany({ createdBy: outsider }) + await insertCompanyMember({ companyId: otherCompany, userId: outsider, role: 'owner' }) + + await withUserContext(outsider, async (client) => { + const r = await client.query<{ out: { released: number; dropped: number } }>( + `SELECT public.release_reversed_entry_transactions($1::uuid, $2::uuid) AS out`, + [companyId, mainId], + ) + expect(r.rows[0].out).toEqual({ released: 0, dropped: 0 }) + // RLS hides the row from the outsider; verify on the pool instead. + }) + const after = await getPool().query( + `SELECT journal_entry_id FROM public.transactions WHERE id = $1`, + [txId], + ) + expect(after.rows[0].journal_entry_id).toBe(mainId) + const links = await getPool().query<{ n: string }>( + `SELECT count(*)::text AS n FROM public.transaction_voucher_links WHERE transaction_id = $1`, + [txId], + ) + expect(Number(links.rows[0].n)).toBe(1) + }) + + it('a viewer cannot release: the writer-role gate or RLS stops the write', async () => { + const { companyId, mainId, txId } = await seedResidualBooking() + const viewer = await insertAuthUser() + await insertCompanyMember({ companyId, userId: viewer, role: 'viewer' }) + + let threw = false + let released = -1 + try { + await withUserContext(viewer, async (client) => { + const r = await client.query<{ out: { released: number; dropped: number } }>( + `SELECT public.release_reversed_entry_transactions($1::uuid, $2::uuid) AS out`, + [companyId, mainId], + ) + released = r.rows[0].out.released + }) + } catch { + threw = true + } + if (!threw) expect(released).toBe(0) + const after = await getPool().query( + `SELECT journal_entry_id FROM public.transactions WHERE id = $1`, + [txId], + ) + expect(after.rows[0].journal_entry_id).toBe(mainId) + }) +})