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:
@@ -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.
|
||||
|
||||
@@ -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<string | RemainingRow>; txRows?: TxRow[] } = {},
|
||||
opts: {
|
||||
voucherLinks?: string[]
|
||||
remainingLinks?: Array<string | RemainingRow>
|
||||
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<typeof vi.fn>).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)
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
+30
-10
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.';
|
||||
@@ -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