From eea410c42b92308fa5588d12b9c407b7642605a1 Mon Sep 17 00:00:00 2001 From: Mattsson <111893710+mattssonn@users.noreply.github.com> Date: Tue, 8 Sep 2026 10:34:15 +0200 Subject: [PATCH] fix(bookkeeping): correctEntry moves bank anchors to the correction; deleting the correction returns them (#2406) * fix(bookkeeping): correctEntry moves the original entry's voucher links to the correction Why the problem occurred: a bank row has two anchors, the pointer column (transactions.journal_entry_id) and the transaction_voucher_links junction (bulk-book writes a bank_line row beside the pointer for N=1 and as the only anchor for a samlingsverifikat with N>1). correctEntry re-pointed only the pointer, so the reversed original kept its junction rows and every junction reader (is_transaction_booked, fetchJunctionLinkedTxIds, the bulk_book RPC, the reconciliation bridge) went on treating the row as anchored there. A later storno of the correction released the pointer while the stale link kept the row out of Att bokfora: the split #2061 fixed on the storno path, reproduced on the correction path. Prod holds 7 such links in 3 companies. What was removed or simplified: nothing new is added to the data model. The relink helper now moves both anchors with the same predicate (company and source entry), so the junction follows the pointer and one rule covers the N=1, samlingsverifikat, 1:N slice and residual shapes. A relink failure is surfaced on the result (transactionRelinkError) beside documentRelinkError instead of being logged and forgotten. Why this solution: the issue proposed deleting the original's junction rows. For a samlingsverifikat the junction is the row's only anchor, so deleting it would push rows the corrected verifikat still explains back into the worklist; re-pointing keeps them booked against the live entry. A relink_entry_anchors RPC moving pointer and junction atomically was considered and left for later: it costs a migration plus pg test on a path that is already best-effort across five other statements, and the surfaced warning now makes a partial failure visible if one ever happens. Tests: unit cases on correctEntry for the junction update, its scoping and the surfaced warning; a pg-real suite that runs the two UPDATE statements as the correcting user against real Postgres for the N=1, samlingsverifikat, split-plus-residual and cross-tenant shapes (RLS, the writer-role gate and the immutability triggers do not block the move; role and allocated_amount survive it). Fixes #2364 Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01Efj3hm54wP53zfmoz4RXxE * fix(bookkeeping): deleting a correction returns its bank anchors to the original Found by the skeptic on the previous commit. Once the junction follows the correction, the two-step undo of a rattelse (delete the correction, last in series, then delete the storno, which restores the original to posted) cascaded the links away with the correction (FK ON DELETE CASCADE; the pointer FK is ON DELETE SET NULL). The restored original then explained bank rows nobody pointed at: is_business stayed true, the rows surfaced as bookable in Att bokfora and in bank reconciliation, and a second booking of the same movement was one click away. Before, the links had stayed on the original by accident and the undo happened to be clean. delete_last_voucher (migration 20260908095907) now moves both anchors back to correction_of_id before deleting a correction, the inverse of the move correctEntry makes. Releasing the rows instead would leave the same trap (the restored original still explains them), and a TS pre-step in the DELETE route is not atomic with the RPC's own guards. A link the original already holds (a correction made before the junction followed it) is dropped rather than duplicated. Everything else in the function is byte-for-byte 20260528120600. Applied to staging and recorded under the file's version. pg-real suite covers the N=1, samlingsverifikat, pre-existing-duplicate and plain-voucher shapes; the existing delete_last_voucher and document-immutability suites still pass. Refs #2364 Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01Efj3hm54wP53zfmoz4RXxE * docs(decisions): record the #2364 prod repair as planned, not done CodeRabbit on PR #2406: the entry read as if the seven-link repair had already run. It runs after merge on the founder's go and gets its own dated entry. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01Efj3hm54wP53zfmoz4RXxE --------- Co-authored-by: Claude Fable 5.1 --- DECISIONS.md | 2 + ...correction-returns-bank-anchors.pg.test.ts | 247 +++++++++++++++ .../correction-junction-relink.pg.test.ts | 281 ++++++++++++++++++ .../__tests__/recordate-entry.test.ts | 1 + .../__tests__/storno-service.test.ts | 60 ++++ lib/core/bookkeeping/storno-service.ts | 61 +++- ...lete_last_voucher_returns_bank_anchors.sql | 230 ++++++++++++++ 7 files changed, 872 insertions(+), 10 deletions(-) create mode 100644 lib/bookkeeping/__tests__/delete-correction-returns-bank-anchors.pg.test.ts create mode 100644 lib/core/bookkeeping/__tests__/correction-junction-relink.pg.test.ts create mode 100644 supabase/migrations/20260908095907_delete_last_voucher_returns_bank_anchors.sql diff --git a/DECISIONS.md b/DECISIONS.md index 047d903c..0554e42b 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -1658,3 +1658,5 @@ One line per decision: `[YYYY-MM-DD] : `. Appended by agents and [2026-09-07] PR #2397 skeptic + review pass: the reclaim is refused while any of its invoices sits in a later live begäran (avslag → new file is Skatteverket's retry; booking the refused share onto the customer meanwhile would clear 1513 twice), a reclaimed invoice is blocked from a new begäran (DEDUCTION_RECLAIMED) until the reclaim voucher is reversed, and a storno of the reclaim voucher syncs the invoices and the begäran back (lib/invoices/rot-rut-reclaim-reversal.ts, hooked into reverseEntry next to the payment sync). Per-invoice reopen goes through one idempotent RPC (apply_rot_rut_reclaim_invoice, item marker + invoice row in one transaction) so a failure after the voucher is resumable instead of stuck at ALREADY_DONE. Crediting an invoice with a reclaimed share is refused (reverse the reclaim first): the credit note reverses the issue-time 1510/1513 split. Declined: CONCURRENTLY for the partial unique index (Supabase migrations run in one transaction; same shape as the shipped rot_rut_payout index in 20260904021000, partial predicate on a source_type that few rows match). [2026-09-08] PR #2397 review cycle 3: the reclaim RPCs own the accounting values. apply_rot_rut_reclaim_invoice takes only the refused share and validates it against the locked item, request and invoice (never above the item's requested amount, the beslut's refused total, or the 1513 headroom), then derives remaining_amount and status from the same formula as the INSERT guard (rot_rut_customer_outstanding); revert_rot_rut_reclaim_invoice mirrors it for a reversed reclaim voucher and the request link is cleared only after every leg succeeded. Reason: a SECURITY INVOKER function that accepted caller-supplied remaining/status was an unchecked accounting write for any writer-role member (CWE-862). The 20260907160300 signature is dropped in 20260907160400 rather than edited: the preview branch had already applied it. [2026-09-08] Invoice list gets an 'Ej skickade' view and the PDF download on an unissued document asks first (#2399): both reports came from the same hidden state, a finalized invoice with an F-number whose DB status is still 'draft'. Chosen: split the status in the UI (lib/invoices/invoice-list-tabs.ts, one predicate for rows, counts and sections) and gate the download with a soft dialog whose primary action is the existing manual mark-sent (and book) path, then download. Rejected: a real 'issued' status in the DB (touches MCP, v1 API, reports and SIE for a distinction invoice_number already carries); removing the UTKAST stamp from numbered drafts (an unbooked invoice is not issued, the stamp is right, the flow around it was wrong); naming the tab 'Godkända' as the user asked (there is no attest step, so the label would promise one; ?status=godkanda aliases to the view). +[2026-09-08] correctEntry re-points the original entry's transaction_voucher_links rows to the corrected entry (lib/core/bookkeeping/storno-service.ts relinkTransactionsToEntry) instead of deleting them as issue #2364 proposed. Why: for a samlingsverifikat (bulk-book N>1) the junction is the row's only anchor, so deleting it would push rows the corrected verifikat still explains back into Att bokföra; the pointer column already follows the correction and the junction now follows it the same way, so every reader (is_transaction_booked, fetchJunctionLinkedTxIds, the bulk_book RPC) sees one live anchor. Rejected: a relink_entry_anchors RPC moving pointer and junction atomically (a migration plus pg test for a path that is already best-effort across five other statements; revisit if a partial failure ever shows up in the surfaced transactionRelinkError). Prod repair (planned, runs after merge on the founder's go; completion gets its own dated entry): the 7 stale links (3 companies) all sit on rows whose pointer names a posted entry (4 on a correction chain, 3 from a June 2026 samlingsverifikat storno that predates the junction cleanup and were re-booked 1:1); they will be re-pointed to the pointer's entry, the same rule the fix applies, rather than deleted. +[2026-09-08] delete_last_voucher returns a correction's bank anchors (transactions.journal_entry_id and transaction_voucher_links rows) to correction_of_id before the row is deleted (migration 20260908095907). Why: the #2364 skeptic showed that once the junction follows the correction, the two-step undo (delete the correction, then the storno) cascaded the links away and restored an original that explains bank rows nobody points at, so the rows surfaced as bookable again; before, the links had stayed on the original by accident. Chosen over releasing the rows (the restored original would still explain them, same trap) and over a TS pre-step in the DELETE route (not atomic with the RPC's own guards: a refused delete would leave anchors on a reversed entry). A duplicate of a link the original already holds is dropped, not re-pointed (UNIQUE (transaction_id, journal_entry_id)). diff --git a/lib/bookkeeping/__tests__/delete-correction-returns-bank-anchors.pg.test.ts b/lib/bookkeeping/__tests__/delete-correction-returns-bank-anchors.pg.test.ts new file mode 100644 index 00000000..83eb5bd8 --- /dev/null +++ b/lib/bookkeeping/__tests__/delete-correction-returns-bank-anchors.pg.test.ts @@ -0,0 +1,247 @@ +import { randomUUID } from 'node:crypto' +import type { PoolClient } from 'pg' +import { describe, expect, it } from 'vitest' +import { getPool, withUserContext } from '@/tests/pg/setup' +import { insertBalancedLines, insertTransaction, seedCompany } from '@/tests/pg/fixtures' + +// delete_last_voucher (migration 20260908095907, issue #2364): deleting a +// correction returns the bank anchors correctEntry moved onto it (the pointer +// transactions.journal_entry_id and the transaction_voucher_links rows) to the +// corrected original, so the two-step undo (delete the correction, then the +// storno, which restores the original to posted) leaves the original +// explaining its bank rows instead of stranding them as bookable. + +async function insertPostedEntry(params: { + userId: string + companyId: string + fiscalPeriodId: string + voucherNumber: number + sourceType?: string + reversesId?: string + correctionOfId?: string +}): Promise { + const id = randomUUID() + await getPool().query( + `INSERT INTO public.journal_entries + (id, user_id, company_id, fiscal_period_id, voucher_number, voucher_series, + entry_date, description, source_type, status, reverses_id, correction_of_id) + VALUES ($1, $2, $3, $4, $5, 'A', '2026-06-01', 'Test entry', $6, 'draft', $7, $8)`, + [ + id, + params.userId, + params.companyId, + params.fiscalPeriodId, + params.voucherNumber, + params.sourceType ?? 'manual', + params.reversesId ?? null, + params.correctionOfId ?? null, + ], + ) + await insertBalancedLines(id) + await getPool().query(`UPDATE public.journal_entries SET status = 'posted' WHERE id = $1`, [id]) + return id +} + +async function insertLink(params: { + userId: string + companyId: string + transactionId: string + journalEntryId: string + amount: number + role?: 'bank_line' | 'other' | 'clearing' +}): Promise { + 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)`, + [ + randomUUID(), + params.userId, + params.companyId, + params.transactionId, + params.journalEntryId, + params.amount, + params.role ?? 'bank_line', + ], + ) +} + +/** + * The end state of correctEntry: original A1 reversed by storno A2, correction + * A3 posted with correction_of_id = A1. The bank anchors sit on A3. + */ +async function seedCorrectedEntry() { + const seed = await seedCompany() + const originalId = await insertPostedEntry({ ...seed, voucherNumber: 1 }) + const stornoId = await insertPostedEntry({ + ...seed, + voucherNumber: 2, + sourceType: 'storno', + reversesId: originalId, + }) + const correctionId = await insertPostedEntry({ + ...seed, + voucherNumber: 3, + sourceType: 'correction', + correctionOfId: originalId, + }) + await getPool().query( + `UPDATE public.journal_entries SET status = 'reversed', reversed_by_id = $1 WHERE id = $2`, + [stornoId, originalId], + ) + return { ...seed, originalId, stornoId, correctionId } +} + +interface LinkRow { + journal_entry_id: string + role: string + allocated_amount: string +} + +async function linksOf(client: PoolClient, txId: string): Promise { + const r = await client.query( + `SELECT journal_entry_id, role, allocated_amount::text AS allocated_amount + FROM public.transaction_voucher_links WHERE transaction_id = $1 ORDER BY journal_entry_id`, + [txId], + ) + return r.rows +} + +async function pointerOf(client: PoolClient, txId: string): Promise { + const r = await client.query<{ journal_entry_id: string | null }>( + `SELECT journal_entry_id FROM public.transactions WHERE id = $1`, + [txId], + ) + return r.rows[0]!.journal_entry_id +} + +async function isBooked(client: PoolClient, txId: string): Promise { + const r = await client.query<{ b: boolean }>(`SELECT public.is_transaction_booked($1::uuid) AS b`, [ + txId, + ]) + return r.rows[0]!.b +} + +async function deleteVoucher(client: PoolClient, companyId: string, entryId: string): Promise { + await client.query(`SELECT public.delete_last_voucher($1::uuid, $2::uuid)`, [companyId, entryId]) +} + +describe('delete_last_voucher returns bank anchors to the corrected original (#2364)', () => { + it('bulk-book N=1 shape: pointer and link go back to the original; deleting the storno then leaves the row booked against it', async () => { + const s = await seedCorrectedEntry() + const txId = await insertTransaction({ + userId: s.userId, + companyId: s.companyId, + amount: -1000, + journalEntryId: s.correctionId, + }) + await getPool().query(`UPDATE public.transactions SET is_business = true WHERE id = $1`, [txId]) + await insertLink({ + userId: s.userId, + companyId: s.companyId, + transactionId: txId, + journalEntryId: s.correctionId, + amount: -1000, + }) + + await withUserContext(s.userId, async (client) => { + await deleteVoucher(client, s.companyId, s.correctionId) + expect(await pointerOf(client, txId)).toBe(s.originalId) + expect(await linksOf(client, txId)).toEqual([ + { journal_entry_id: s.originalId, role: 'bank_line', allocated_amount: '-1000.00' }, + ]) + + await deleteVoucher(client, s.companyId, s.stornoId) + const original = await client.query<{ status: string }>( + `SELECT status FROM public.journal_entries WHERE id = $1`, + [s.originalId], + ) + expect(original.rows[0]!.status).toBe('posted') + expect(await isBooked(client, txId)).toBe(true) + }) + }) + + it('samlingsverifikat (N>1, pointer NULL): every link goes back to the original', async () => { + const s = await seedCorrectedEntry() + const txA = await insertTransaction({ userId: s.userId, companyId: s.companyId, amount: -600 }) + const txB = await insertTransaction({ userId: s.userId, companyId: s.companyId, amount: -400 }) + for (const [txId, amount] of [ + [txA, -600], + [txB, -400], + ] as const) { + await insertLink({ + userId: s.userId, + companyId: s.companyId, + transactionId: txId, + journalEntryId: s.correctionId, + amount, + }) + } + + await withUserContext(s.userId, async (client) => { + await deleteVoucher(client, s.companyId, s.correctionId) + expect(await linksOf(client, txA)).toEqual([ + { journal_entry_id: s.originalId, role: 'bank_line', allocated_amount: '-600.00' }, + ]) + expect(await linksOf(client, txB)).toEqual([ + { journal_entry_id: s.originalId, role: 'bank_line', allocated_amount: '-400.00' }, + ]) + expect(await pointerOf(client, txA)).toBeNull() + expect(await isBooked(client, txA)).toBe(true) + expect(await isBooked(client, txB)).toBe(true) + }) + }) + + it('a link the original already holds (correction made before the junction followed it) is not duplicated', async () => { + const s = await seedCorrectedEntry() + const txId = await insertTransaction({ userId: s.userId, companyId: s.companyId, amount: -1000 }) + await insertLink({ + userId: s.userId, + companyId: s.companyId, + transactionId: txId, + journalEntryId: s.originalId, + amount: -1000, + }) + await insertLink({ + userId: s.userId, + companyId: s.companyId, + transactionId: txId, + journalEntryId: s.correctionId, + amount: -1000, + }) + + await withUserContext(s.userId, async (client) => { + // UNIQUE (transaction_id, journal_entry_id) would have rejected a blind + // re-point; the RPC drops the duplicate and keeps the original's row. + await deleteVoucher(client, s.companyId, s.correctionId) + expect(await linksOf(client, txId)).toEqual([ + { journal_entry_id: s.originalId, role: 'bank_line', allocated_amount: '-1000.00' }, + ]) + }) + }) + + it('deleting a plain voucher (no correction_of_id) still lets the FKs release the row', async () => { + const seed = await seedCompany() + const entryId = await insertPostedEntry({ ...seed, voucherNumber: 1 }) + const txId = await insertTransaction({ + userId: seed.userId, + companyId: seed.companyId, + amount: -1000, + journalEntryId: entryId, + }) + await insertLink({ + userId: seed.userId, + companyId: seed.companyId, + transactionId: txId, + journalEntryId: entryId, + amount: -1000, + }) + + await withUserContext(seed.userId, async (client) => { + await deleteVoucher(client, seed.companyId, entryId) + expect(await pointerOf(client, txId)).toBeNull() + expect(await linksOf(client, txId)).toEqual([]) + expect(await isBooked(client, txId)).toBe(false) + }) + }) +}) diff --git a/lib/core/bookkeeping/__tests__/correction-junction-relink.pg.test.ts b/lib/core/bookkeeping/__tests__/correction-junction-relink.pg.test.ts new file mode 100644 index 00000000..79a889f9 --- /dev/null +++ b/lib/core/bookkeeping/__tests__/correction-junction-relink.pg.test.ts @@ -0,0 +1,281 @@ +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, + insertBalancedLines, + insertCompany, + insertCompanyMember, + insertDraftJournalEntry, + insertTransaction, + seedCompany, +} from '@/tests/pg/fixtures' + +// correctEntry (lib/core/bookkeeping/storno-service.ts, relinkTransactionsToEntry) +// re-points BOTH anchors of a bank row from the reversed original to the +// posted correction: the pointer column (transactions.journal_entry_id) and +// the transaction_voucher_links junction (#2364). These tests run the exact +// two UPDATE statements the service issues, as the correcting user, against +// real Postgres: the junction move must not be blocked by RLS, the +// aa_enforce_company_writer_role gate or the immutability triggers (which +// guard journal_entries and document_attachments, not the junction), and +// role + allocated_amount must survive the move. + +const POINTER_RELINK = ` + UPDATE public.transactions + SET journal_entry_id = $3 + WHERE company_id = $1 AND journal_entry_id = $2` + +const JUNCTION_RELINK = ` + UPDATE public.transaction_voucher_links + SET journal_entry_id = $3 + WHERE company_id = $1 AND journal_entry_id = $2` + +interface LinkRow { + journal_entry_id: string + role: string + allocated_amount: string +} + +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 ?? 'bank_line', + ], + ) + return id +} + +async function insertEntryAtStatus(params: { + userId: string + companyId: string + fiscalPeriodId: string + voucherNumber: number + status?: 'posted' | 'reversed' + correctionOfId?: string +}): Promise { + const entryId = await insertDraftJournalEntry({ + userId: params.userId, + companyId: params.companyId, + fiscalPeriodId: params.fiscalPeriodId, + voucherNumber: params.voucherNumber, + }) + await insertBalancedLines(entryId) + if (params.correctionOfId) { + await getPool().query( + `UPDATE public.journal_entries SET correction_of_id = $2 WHERE id = $1`, + [entryId, params.correctionOfId], + ) + } + await getPool().query(`UPDATE public.journal_entries SET status = 'posted' WHERE id = $1`, [entryId]) + if (params.status === 'reversed') { + await getPool().query(`UPDATE public.journal_entries SET status = 'reversed' WHERE id = $1`, [ + entryId, + ]) + } + return entryId +} + +/** A reversed original + its posted correction, mirroring correctEntry()'s end state before the relink. */ +async function seedCorrectionPair(seed: { + userId: string + companyId: string + fiscalPeriodId: string +}): Promise<{ originalId: string; correctionId: string }> { + const originalId = await insertEntryAtStatus({ ...seed, voucherNumber: 1, status: 'reversed' }) + const correctionId = await insertEntryAtStatus({ ...seed, voucherNumber: 2, correctionOfId: originalId }) + return { originalId, correctionId } +} + +async function linksOf(client: PoolClient, txId: string): Promise { + const r = await client.query( + `SELECT journal_entry_id, role, allocated_amount::text AS allocated_amount + FROM public.transaction_voucher_links WHERE transaction_id = $1 ORDER BY journal_entry_id`, + [txId], + ) + return r.rows +} + +async function isBooked(client: PoolClient, txId: string): Promise { + const r = await client.query<{ b: boolean }>(`SELECT public.is_transaction_booked($1::uuid) AS b`, [ + txId, + ]) + return r.rows[0]!.b +} + +async function relinkAsUser( + client: PoolClient, + companyId: string, + fromId: string, + toId: string, +): Promise<{ pointer: number; junction: number }> { + const pointer = await client.query(POINTER_RELINK, [companyId, fromId, toId]) + const junction = await client.query(JUNCTION_RELINK, [companyId, fromId, toId]) + return { pointer: pointer.rowCount ?? 0, junction: junction.rowCount ?? 0 } +} + +describe('correction-junction-relink.pg (#2364)', () => { + it('bulk-book N=1 shape: pointer and bank_line link both move to the correction, role and amount intact', async () => { + const seed = await seedCompany() + const { originalId, correctionId } = await seedCorrectionPair(seed) + const txId = await insertTransaction({ + userId: seed.userId, + companyId: seed.companyId, + amount: -1000, + journalEntryId: originalId, + }) + await insertLink({ + userId: seed.userId, + companyId: seed.companyId, + transactionId: txId, + journalEntryId: originalId, + amount: -1000, + }) + + await withUserContext(seed.userId, async (client) => { + expect(await relinkAsUser(client, seed.companyId, originalId, correctionId)).toEqual({ + pointer: 1, + junction: 1, + }) + const tx = await client.query<{ journal_entry_id: string }>( + `SELECT journal_entry_id FROM public.transactions WHERE id = $1`, + [txId], + ) + expect(tx.rows[0]!.journal_entry_id).toBe(correctionId) + expect(await linksOf(client, txId)).toEqual([ + { journal_entry_id: correctionId, role: 'bank_line', allocated_amount: '-1000.00' }, + ]) + expect(await isBooked(client, txId)).toBe(true) + // Nothing is left anchored to the reversed original. + const stale = await client.query<{ n: string }>( + `SELECT count(*)::text AS n FROM public.transaction_voucher_links WHERE journal_entry_id = $1`, + [originalId], + ) + expect(stale.rows[0]!.n).toBe('0') + }) + }) + + it('samlingsverifikat (N>1, pointer NULL): the junction is the only anchor and follows the correction', async () => { + const seed = await seedCompany() + const { originalId, correctionId } = await seedCorrectionPair(seed) + const txA = await insertTransaction({ userId: seed.userId, companyId: seed.companyId, amount: -600 }) + const txB = await insertTransaction({ userId: seed.userId, companyId: seed.companyId, amount: -400 }) + for (const [txId, amount] of [ + [txA, -600], + [txB, -400], + ] as const) { + await insertLink({ + userId: seed.userId, + companyId: seed.companyId, + transactionId: txId, + journalEntryId: originalId, + amount, + }) + } + + await withUserContext(seed.userId, async (client) => { + expect(await relinkAsUser(client, seed.companyId, originalId, correctionId)).toEqual({ + pointer: 0, + junction: 2, + }) + expect(await linksOf(client, txA)).toEqual([ + { journal_entry_id: correctionId, role: 'bank_line', allocated_amount: '-600.00' }, + ]) + expect(await linksOf(client, txB)).toEqual([ + { journal_entry_id: correctionId, role: 'bank_line', allocated_amount: '-400.00' }, + ]) + // Both rows still read as booked: deleting the links (the issue's + // proposal) would have pushed them back into Att bokföra. + expect(await isBooked(client, txA)).toBe(true) + expect(await isBooked(client, txB)).toBe(true) + }) + }) + + it('1:N split and residual shapes: only the slice on the corrected entry moves, other anchors are untouched', async () => { + const seed = await seedCompany() + const { originalId, correctionId } = await seedCorrectionPair(seed) + const otherId = await insertEntryAtStatus({ ...seed, voucherNumber: 3 }) + const txId = await insertTransaction({ userId: seed.userId, companyId: seed.companyId, amount: -1010 }) + await insertLink({ + userId: seed.userId, + companyId: seed.companyId, + transactionId: txId, + journalEntryId: originalId, + amount: -1000, + }) + await insertLink({ + userId: seed.userId, + companyId: seed.companyId, + transactionId: txId, + journalEntryId: otherId, + amount: -10, + role: 'other', + }) + + await withUserContext(seed.userId, async (client) => { + expect(await relinkAsUser(client, seed.companyId, originalId, correctionId)).toEqual({ + pointer: 0, + junction: 1, + }) + const rows = await linksOf(client, txId) + expect(rows).toHaveLength(2) + expect(rows).toContainEqual({ + journal_entry_id: correctionId, + role: 'bank_line', + allocated_amount: '-1000.00', + }) + expect(rows).toContainEqual({ journal_entry_id: otherId, role: 'other', allocated_amount: '-10.00' }) + }) + }) + + it('is tenant-scoped: a member of another company moves nothing', async () => { + const seed = await seedCompany() + const { originalId, correctionId } = await seedCorrectionPair(seed) + const txId = await insertTransaction({ + userId: seed.userId, + companyId: seed.companyId, + amount: -1000, + journalEntryId: originalId, + }) + await insertLink({ + userId: seed.userId, + companyId: seed.companyId, + transactionId: txId, + journalEntryId: originalId, + amount: -1000, + }) + const outsider = await insertAuthUser() + const otherCompany = await insertCompany({ createdBy: outsider }) + await insertCompanyMember({ companyId: otherCompany, userId: outsider, role: 'owner' }) + + await withUserContext(outsider, async (client) => { + expect(await relinkAsUser(client, seed.companyId, originalId, correctionId)).toEqual({ + pointer: 0, + junction: 0, + }) + }) + await withUserContext(seed.userId, async (client) => { + expect(await linksOf(client, txId)).toEqual([ + { journal_entry_id: originalId, role: 'bank_line', allocated_amount: '-1000.00' }, + ]) + }) + }) +}) diff --git a/lib/core/bookkeeping/__tests__/recordate-entry.test.ts b/lib/core/bookkeeping/__tests__/recordate-entry.test.ts index caa6ec85..4790738c 100644 --- a/lib/core/bookkeeping/__tests__/recordate-entry.test.ts +++ b/lib/core/bookkeeping/__tests__/recordate-entry.test.ts @@ -146,6 +146,7 @@ describe('recordateEntry', () => { { data: null, error: null }, // 8 post corrected { data: [{ id: 'orig-1' }], error: null }, // 9 CAS { data: null, error: null }, // 10 relink transactions + { data: null, error: null }, // 10b relink voucher links (#2364) { data: null, error: null }, // 11 relink documents { data: { ...reversalEntry, lines: [] }, error: null }, // 12 final reversal { data: { ...correctedEntry, lines: [] }, error: null }, // 13 final corrected diff --git a/lib/core/bookkeeping/__tests__/storno-service.test.ts b/lib/core/bookkeeping/__tests__/storno-service.test.ts index fb2032ad..a3296d60 100644 --- a/lib/core/bookkeeping/__tests__/storno-service.test.ts +++ b/lib/core/bookkeeping/__tests__/storno-service.test.ts @@ -107,6 +107,8 @@ describe('correctEntry', () => { { data: [{ id: 'orig-1' }], error: null }, // 9: relink transactions original → corrected (thenable) { data: null, error: null }, + // 9b: relink voucher links original → corrected (thenable, #2364) + { data: null, error: null }, // 10: relink documents original → corrected (thenable) { data: null, error: null }, // 11: fetch final reversal (.single()) @@ -365,6 +367,7 @@ describe('correctEntry', () => { { data: null, error: null }, // 8: post corrected { data: [{ id: 'correction-1' }], error: null }, // 9: CAS update { data: null, error: null }, // 10: relink transactions + { data: null, error: null }, // relink voucher links original → corrected (thenable, #2364) { data: null, error: null }, // 11: relink documents { data: { ...secondReversal, lines: [] }, error: null }, // 12: fetch final reversal { data: { ...secondCorrection, lines: [] }, error: null }, // 13: fetch final corrected @@ -446,6 +449,7 @@ describe('correctEntry', () => { { data: null, error: null }, // 7: post corrected { data: [{ id: 'c3' }], error: null }, // 8: CAS { data: null, error: null }, // 9: relink transactions + { data: null, error: null }, // relink voucher links original → corrected (thenable, #2364) { data: null, error: null }, // 10: relink documents { data: { ...reversalEntry, lines: [] }, error: null }, // 11: final reversal { data: { ...correctedEntry, lines: [] }, error: null }, // 12: final corrected @@ -496,6 +500,7 @@ describe('correctEntry', () => { { data: null, error: null }, // 8: post corrected { data: [{ id: 'orig-1' }], error: null }, // 9: CAS { data: null, error: null }, // 10: relink transactions + { data: null, error: null }, // relink voucher links original → corrected (thenable, #2364) { data: null, error: null }, // 11: relink documents { data: { ...reversalEntry, lines: [] }, error: null }, // 12: final reversal { data: { ...correctedEntry, lines: [] }, error: null }, // 13: final corrected @@ -524,6 +529,60 @@ describe('correctEntry', () => { expect.objectContaining({ userId: 'user-1', companyId: 'company-1' }) ) }) + + // #2364: a bank row has two anchors (transactions.journal_entry_id and the + // transaction_voucher_links junction). Both must follow the correction, or + // the original keeps its link and the row reads double-anchored. + describe('bank anchors follow the correction (#2364)', () => { + type MockClient = ReturnType + + function updatesOn(supabase: MockClient, table: string) { + return supabase.from.mock.calls.flatMap((call, i) => { + if (call[0] !== table) return [] + const b = supabase.from.mock.results[i]!.value as Record> + return b.update.mock.calls.map((u) => ({ payload: u[0], eqs: b.eq.mock.calls })) + }) + } + + it('re-points the original entry\'s transaction_voucher_links rows to the corrected entry, scoped by company', async () => { + setupResults() + const supabase = makeClient() + const result = await correctEntry(supabase as never, 'company-1', 'user-1', 'orig-1', correctedLines) + + const junction = updatesOn(supabase, 'transaction_voucher_links') + expect(junction).toHaveLength(1) + expect(junction[0]!.payload).toEqual({ journal_entry_id: 'corrected-1' }) + expect(junction[0]!.eqs).toEqual([ + ['company_id', 'company-1'], + ['journal_entry_id', 'orig-1'], + ]) + // The pointer column still follows the correction the way it always did. + const pointer = updatesOn(supabase, 'transactions') + expect(pointer).toHaveLength(1) + expect(pointer[0]!.payload).toEqual({ journal_entry_id: 'corrected-1' }) + expect(result.transactionRelinkError).toBeUndefined() + }) + + it('surfaces a junction relink failure on the result instead of throwing (correction chain stays traceable)', async () => { + setupResults() + results[10] = { data: null, error: { message: 'permission denied for table transaction_voucher_links' } } + const supabase = makeClient() + const result = await correctEntry(supabase as never, 'company-1', 'user-1', 'orig-1', correctedLines) + + expect(result.corrected.id).toBe('corrected-1') + expect(result.transactionRelinkError).toBe('permission denied for table transaction_voucher_links') + }) + + it('surfaces a pointer relink failure the same way and still attempts the junction relink', async () => { + setupResults() + results[9] = { data: null, error: { message: 'pointer relink failed' } } + const supabase = makeClient() + const result = await correctEntry(supabase as never, 'company-1', 'user-1', 'orig-1', correctedLines) + + expect(result.transactionRelinkError).toBe('pointer relink failed') + expect(updatesOn(supabase, 'transaction_voucher_links')).toHaveLength(1) + }) + }) }) describe('correctEntry: date/period override (recordate engine)', () => { @@ -562,6 +621,7 @@ describe('correctEntry: date/period override (recordate engine)', () => { { data: null, error: null }, // 8 post corrected { data: [{ id: 'orig-1' }], error: null }, // 9 CAS { data: null, error: null }, // 10 relink transactions + { data: null, error: null }, // relink voucher links original → corrected (thenable, #2364) { data: null, error: null }, // 11 relink documents { data: { ...reversalEntry, lines: [] }, error: null }, // 12 final reversal { data: { ...correctedEntry, lines: [] }, error: null }, // 13 final corrected diff --git a/lib/core/bookkeeping/storno-service.ts b/lib/core/bookkeeping/storno-service.ts index aee7a1ff..d7095b75 100644 --- a/lib/core/bookkeeping/storno-service.ts +++ b/lib/core/bookkeeping/storno-service.ts @@ -151,7 +151,12 @@ export async function correctEntry( */ allowDeepChain?: boolean } -): Promise<{ reversal: JournalEntry; corrected: JournalEntry; documentRelinkError?: string }> { +): Promise<{ + reversal: JournalEntry + corrected: JournalEntry + transactionRelinkError?: string + documentRelinkError?: string +}> { // Validate the corrected lines are balanced const balance = validateBalance(correctedLines) if (!balance.valid) { @@ -457,10 +462,15 @@ export async function correctEntry( // live representation of the affärshändelse, so the transaction row should // keep reading as booked against it (and stay correctable/uncategorizable), // and the underlag should travel with it. Best-effort: the correction_of_id - // chain preserves traceability even if either relink fails, but a document - // relink failure is surfaced on the result so the caller can warn instead - // of silently stranding underlag on the reversed entry. - await relinkTransactionsToEntry(supabase, companyId, originalEntryId, correctedEntry!.id) + // chain preserves traceability even if either relink fails, but a relink + // failure is surfaced on the result so the caller can warn instead of + // silently stranding a bank row or underlag on the reversed entry. + const transactionRelinkError = await relinkTransactionsToEntry( + supabase, + companyId, + originalEntryId, + correctedEntry!.id + ) const documentRelinkError = await relinkDocumentsToEntry( supabase, userId, @@ -484,6 +494,7 @@ export async function correctEntry( const result = { reversal: finalReversal as JournalEntry, corrected: finalCorrected as JournalEntry, + ...(transactionRelinkError ? { transactionRelinkError } : {}), ...(documentRelinkError ? { documentRelinkError } : {}), } @@ -610,25 +621,55 @@ export async function recordateEntry( * Re-point every bank transaction from one entry to another. Used when a * verifikation is corrected so the transaction row keeps reading as booked * against the live (corrected) entry instead of the reversed original. - * Failures are logged, not thrown: the correction chain stays traceable. + * + * A bank row has two anchors and both must follow the correction: the + * pointer column (transactions.journal_entry_id, the 1:1 case) and the + * transaction_voucher_links junction (bulk-book writes a bank_line row beside + * the pointer for N=1 and as the ONLY anchor for a samlingsverifikat with + * N>1; 1:N splits and residual bookings anchor through it too). Every junction + * reader (is_transaction_booked(), fetchJunctionLinkedTxIds, the bulk_book + * RPC, the reconciliation bridge) treats a surviving link as an anchor, so a + * link left on the reversed original keeps the row double-anchored: a later + * storno of the correction releases the pointer while the stale link keeps + * the row out of Att bokföra (#2364, the split #2061 fixed on the storno + * path). The links are re-pointed, not deleted: for a samlingsverifikat the + * junction is the only anchor, and deleting it would push rows the corrected + * verifikat still explains back into the worklist. role and allocated_amount + * describe the bank row's share of the affärshändelse and are unchanged by + * the correction. + * + * Failures are logged, not thrown (the correction chain stays traceable), + * but the message is returned so correctEntry can surface a warning. */ async function relinkTransactionsToEntry( supabase: SupabaseClient, companyId: string, fromEntryId: string, toEntryId: string -): Promise { - const { error } = await supabase +): Promise { + const { error: pointerError } = await supabase .from('transactions') .update({ journal_entry_id: toEntryId }) .eq('company_id', companyId) .eq('journal_entry_id', fromEntryId) - if (error) { + if (pointerError) { console.error( `[storno] relinkTransactionsToEntry: failed to move transactions ${fromEntryId} → ${toEntryId}:`, - error.message + pointerError.message ) } + const { error: junctionError } = await supabase + .from('transaction_voucher_links') + .update({ journal_entry_id: toEntryId }) + .eq('company_id', companyId) + .eq('journal_entry_id', fromEntryId) + if (junctionError) { + console.error( + `[storno] relinkTransactionsToEntry: failed to move voucher links ${fromEntryId} → ${toEntryId}:`, + junctionError.message + ) + } + return pointerError?.message ?? junctionError?.message } /** diff --git a/supabase/migrations/20260908095907_delete_last_voucher_returns_bank_anchors.sql b/supabase/migrations/20260908095907_delete_last_voucher_returns_bank_anchors.sql new file mode 100644 index 00000000..60abac3a --- /dev/null +++ b/supabase/migrations/20260908095907_delete_last_voucher_returns_bank_anchors.sql @@ -0,0 +1,230 @@ +-- delete_last_voucher: deleting a correction returns the bank anchors to the +-- corrected original (issue #2364, regression found by the skeptic). +-- +-- correctEntry (lib/core/bookkeeping/storno-service.ts) moves both anchors of +-- a bank row from the reversed original to its correction: the pointer column +-- transactions.journal_entry_id and the transaction_voucher_links junction +-- rows (bulk-book N=1 writes a bank_line link beside the pointer; a +-- samlingsverifikat with N>1 is anchored by the junction alone). Deleting the +-- correction through this RPC is the undo of that move, and the undo has two +-- steps in the UI: delete the correction (last in series), then delete the +-- storno, which restores the original to posted below. The pointer FK is +-- ON DELETE SET NULL and the junction FK ON DELETE CASCADE, so without this +-- block both anchors vanish with the correction and the restored original +-- explains bank rows that no longer point at anything: they keep +-- is_business = true, surface as bookable in Att bokfora and in bank +-- reconciliation, and a second booking of the same movement is one click +-- away. Before the junction followed the correction the links simply stayed +-- on the original and the undo happened to be clean; this makes it clean on +-- purpose, for the pointer as well. +-- +-- The anchors go back to correction_of_id, mirroring the move that put them +-- on the correction. A link the original already holds (a correction made +-- before the junction followed the correction) is dropped instead of +-- duplicated: UNIQUE (transaction_id, journal_entry_id). Everything else in +-- the function is byte-for-byte 20260528120600. +-- +-- pg-test: lib/bookkeeping/__tests__/delete-correction-returns-bank-anchors.pg.test.ts + +CREATE OR REPLACE FUNCTION public.delete_last_voucher(p_company_id uuid, p_entry_id uuid) + RETURNS jsonb + LANGUAGE plpgsql + SECURITY DEFINER + SET search_path TO 'public' +AS $function$ +DECLARE + v_entry record; + v_period record; + v_max_voucher integer; + v_ref_count integer; + v_caller_role text; + v_snapshot jsonb; + v_lines_snapshot jsonb; + v_is_period_ib boolean := false; +BEGIN + SELECT cm.role INTO v_caller_role + FROM company_members cm + WHERE cm.company_id = p_company_id + AND cm.user_id = auth.uid(); + + IF v_caller_role IS NULL OR v_caller_role NOT IN ('owner', 'admin') THEN + RAISE EXCEPTION 'Only company owners and admins can delete vouchers'; + END IF; + + SELECT * INTO v_entry + FROM journal_entries + WHERE id = p_entry_id + AND company_id = p_company_id + FOR UPDATE; + + IF v_entry IS NULL THEN + RAISE EXCEPTION 'Journal entry not found'; + END IF; + + IF v_entry.status NOT IN ('posted', 'draft') THEN + RAISE EXCEPTION 'Only posted or draft entries can be deleted (current status: %)', v_entry.status; + END IF; + + SELECT jsonb_agg(to_jsonb(l)) INTO v_lines_snapshot + FROM journal_entry_lines l + WHERE l.journal_entry_id = p_entry_id; + + v_snapshot := to_jsonb(v_entry) || jsonb_build_object('lines', COALESCE(v_lines_snapshot, '[]'::jsonb)); + + IF v_entry.status = 'draft' THEN + PERFORM set_config('gnubok.allow_delete', 'true', true); + + UPDATE document_attachments + SET journal_entry_id = NULL + WHERE journal_entry_id = p_entry_id; + + DELETE FROM journal_entries WHERE id = p_entry_id; + + INSERT INTO audit_log (user_id, company_id, action, table_name, record_id, actor_id, old_state, description) + VALUES ( + v_entry.user_id, + p_company_id, + 'DELETE', + 'journal_entries', + p_entry_id, + auth.uid(), + v_snapshot, + 'Deleted draft journal entry (delete_last_voucher RPC, caller: ' || auth.uid() || ')' + ); + + RETURN jsonb_build_object( + 'deleted', true, + 'voucher_series', v_entry.voucher_series, + 'voucher_number', v_entry.voucher_number, + 'was_draft', true + ); + END IF; + + SELECT * INTO v_period + FROM fiscal_periods + WHERE id = v_entry.fiscal_period_id + FOR UPDATE; + + IF v_period.is_closed THEN + RAISE EXCEPTION 'Cannot delete voucher in a closed fiscal period'; + END IF; + + IF v_period.locked_at IS NOT NULL THEN + RAISE EXCEPTION 'Cannot delete voucher in a locked fiscal period'; + END IF; + + PERFORM 1 FROM voucher_sequences + WHERE company_id = p_company_id + AND fiscal_period_id = v_entry.fiscal_period_id + AND voucher_series = v_entry.voucher_series + FOR UPDATE; + + SELECT MAX(voucher_number) INTO v_max_voucher + FROM journal_entries + WHERE company_id = p_company_id + AND fiscal_period_id = v_entry.fiscal_period_id + AND voucher_series = v_entry.voucher_series + AND status NOT IN ('cancelled', 'draft'); + + IF v_entry.voucher_number != v_max_voucher THEN + RAISE EXCEPTION 'Kan bara radera det sista verifikatet i serien. % har nummer % men senaste är %', + v_entry.voucher_series, v_entry.voucher_number, v_max_voucher; + END IF; + + SELECT COUNT(*) INTO v_ref_count + FROM journal_entries + WHERE company_id = p_company_id + AND status != 'cancelled' + AND (reverses_id = p_entry_id OR correction_of_id = p_entry_id); + + IF v_ref_count > 0 THEN + RAISE EXCEPTION 'Cannot delete: other entries reference this voucher (% references)', + v_ref_count; + END IF; + + IF v_entry.reverses_id IS NOT NULL THEN + PERFORM set_config('gnubok.allow_delete', 'true', true); + UPDATE journal_entries + SET status = 'posted', reversed_by_id = NULL + WHERE id = v_entry.reverses_id + AND company_id = p_company_id; + END IF; + + -- #2364: a correction carries the bank anchors correctEntry moved off the + -- original. Return them before the FKs drop them with the row (pointer: + -- ON DELETE SET NULL, junction: ON DELETE CASCADE), so the original, once + -- its storno is deleted too, still explains its bank rows. + IF v_entry.correction_of_id IS NOT NULL THEN + UPDATE transactions + SET journal_entry_id = v_entry.correction_of_id + WHERE company_id = p_company_id + AND journal_entry_id = p_entry_id; + + DELETE FROM transaction_voucher_links l + WHERE l.company_id = p_company_id + AND l.journal_entry_id = p_entry_id + AND EXISTS ( + SELECT 1 FROM transaction_voucher_links x + WHERE x.transaction_id = l.transaction_id + AND x.journal_entry_id = v_entry.correction_of_id + ); + + UPDATE transaction_voucher_links + SET journal_entry_id = v_entry.correction_of_id + WHERE company_id = p_company_id + AND journal_entry_id = p_entry_id; + END IF; + + v_is_period_ib := (v_period.opening_balance_entry_id = p_entry_id); + IF v_is_period_ib THEN + UPDATE fiscal_periods + SET opening_balances_set = false + WHERE id = v_entry.fiscal_period_id; + + UPDATE fiscal_periods + SET opening_balance_entry_id = NULL + WHERE id = v_entry.fiscal_period_id; + END IF; + + UPDATE sie_imports + SET opening_balance_entry_id = NULL + WHERE opening_balance_entry_id = p_entry_id; + + PERFORM set_config('gnubok.allow_delete', 'true', true); + + UPDATE document_attachments + SET journal_entry_id = NULL + WHERE journal_entry_id = p_entry_id; + + DELETE FROM journal_entries WHERE id = p_entry_id; + + UPDATE voucher_sequences + SET last_number = GREATEST(last_number - 1, 0) + WHERE company_id = p_company_id + AND fiscal_period_id = v_entry.fiscal_period_id + AND voucher_series = v_entry.voucher_series; + + INSERT INTO audit_log (user_id, company_id, action, table_name, record_id, actor_id, old_state, description) + VALUES ( + v_entry.user_id, + p_company_id, + 'DELETE', + 'journal_entries', + p_entry_id, + auth.uid(), + v_snapshot, + 'Deleted voucher ' || v_entry.voucher_series || v_entry.voucher_number || + CASE WHEN v_is_period_ib THEN ' (was period IB)' ELSE '' END || + ' (delete_last_voucher RPC, caller: ' || auth.uid() || ')' + ); + + RETURN jsonb_build_object( + 'deleted', true, + 'voucher_series', v_entry.voucher_series, + 'voucher_number', v_entry.voucher_number, + 'was_period_ib', v_is_period_ib + ); +END; +$function$; + +NOTIFY pgrst, 'reload schema';