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:
co-authored by
Claude Fable 5.1
parent
7448490fb7
commit
3c033e466f
@@ -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<string> {
|
||||
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<TxState> {
|
||||
const r = await client.query<TxState>(
|
||||
`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<number> {
|
||||
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<TxState>(
|
||||
`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<TxState>(
|
||||
`SELECT journal_entry_id FROM public.transactions WHERE id = $1`,
|
||||
[txId],
|
||||
)
|
||||
expect(after.rows[0].journal_entry_id).toBe(mainId)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user