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 <noreply@anthropic.com> 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 <noreply@anthropic.com> 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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Efj3hm54wP53zfmoz4RXxE --------- Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5.1
parent
25b969183c
commit
eea410c42b
@@ -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<string> {
|
||||
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<void> {
|
||||
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<LinkRow[]> {
|
||||
const r = await client.query<LinkRow>(
|
||||
`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<string | null> {
|
||||
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<boolean> {
|
||||
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<void> {
|
||||
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)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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<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 ?? 'bank_line',
|
||||
],
|
||||
)
|
||||
return id
|
||||
}
|
||||
|
||||
async function insertEntryAtStatus(params: {
|
||||
userId: string
|
||||
companyId: string
|
||||
fiscalPeriodId: string
|
||||
voucherNumber: number
|
||||
status?: 'posted' | 'reversed'
|
||||
correctionOfId?: string
|
||||
}): Promise<string> {
|
||||
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<LinkRow[]> {
|
||||
const r = await client.query<LinkRow>(
|
||||
`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<boolean> {
|
||||
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' },
|
||||
])
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
|
||||
@@ -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<typeof makeClient>
|
||||
|
||||
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<string, ReturnType<typeof vi.fn>>
|
||||
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
|
||||
|
||||
@@ -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<void> {
|
||||
const { error } = await supabase
|
||||
): Promise<string | undefined> {
|
||||
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
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user