fix(storno): return stornoed bank transactions to Att bokfora (#1985)
* fix(storno): return stornoed bank transactions to Att bokfora reverseEntry() unlinked bank transactions from the reversed entry by clearing only journal_entry_id. The worklist's "unbooked" predicate is is_business IS NULL AND is_ignored = false (lib/worklist/types.ts), so the row stayed "handled": absent from Att bokfora and from the nav badge, while the storno dialog (reverse_warning) promised the opposite (#1950). The engine now resets the same triple the uncategorize paths write (journal_entry_id, is_business, category) plus reconciliation_method, scoped to rows linked to the reversed entry. Fixed in the engine so the dashboard, v1 and MCP reverse doors all agree. Closes #1950 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FkUfWtuFCUkNtRAgMQCse2 * fix(storno): release bulk-booked bank rows anchored through transaction_voucher_links The #1950 fix reset transactions scoped by journal_entry_id, but bulk-booked samlingsverifikat (bulk_book_transactions RPC) anchor their N>1 bank rows through transaction_voucher_links only (journal_entry_id stays NULL), so the reset matched nothing there: all rows kept is_business = true against a status='reversed' entry, stayed out of Att bokfora and the nav badge, and is_transaction_booked() still reported them booked. The N=1 variant left a dangling link row that blocked re-booking (BULK_BOOK_TX_ALREADY_BOOKED) and kept the reconciliation bridge bucketing the row as matched. reverseEntry now deletes the reversed entry's junction rows (the same removal koppla-bort performs) and releases is_business, category and reconciliation_method only for rows left with no anchor: a remaining-links read plus journal_entry_id IS NULL guards residual bookings (main verifikat in journal_entry_id, junction row to the residual verifikat) and multi-allocated rows so stornoing one voucher never unbooks a still-booked row. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FkUfWtuFCUkNtRAgMQCse2 * fix(bookkeeping): restore the booked triple in fix-cash-mismatch's transaction relink The widened reverseEntry reset (#1950) now nulls is_business, category and reconciliation_method together with journal_entry_id on the linked transaction, but the fix-cash-mismatch remediation relinked with only journal_entry_id. The repaired row ended up booked (pointer at the posted clearing entry) yet visible in Att bokfora and the nav badge (worklist predicate: is_business IS NULL), the inverted #1950 symptom; booking it from the list would conflict-storno the correct clearing entry and corrupt the AR chain the route just repaired. The relink now restores the full booked triple, mirroring the match-invoice route's final update. New route tests cover auth 401, validation 400, the no-targets path, and assert both relink payloads. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FkUfWtuFCUkNtRAgMQCse2 --------- Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
Jakob Wennberg
parent
533df34369
commit
cb9ae15d46
@@ -875,28 +875,50 @@ describe('reverseEntry: storno guard', () => {
|
||||
|
||||
describe('reverseEntry: bank transaction unlink', () => {
|
||||
// After a reversal the booked bank transaction must return to "Att bokföra"
|
||||
// (journal_entry_id cleared) so the user can book it again. The agent paths
|
||||
// in lib/pending-operations/commit.ts did this manually; the engine now owns
|
||||
// so the user can book it again. The agent paths in
|
||||
// lib/pending-operations/commit.ts did this manually; the engine now owns
|
||||
// it so the dashboard reverse route behaves the same.
|
||||
it('clears transactions.journal_entry_id for rows booked by the reversed entry', async () => {
|
||||
const original = {
|
||||
id: 'entry-1',
|
||||
company_id: 'company-1',
|
||||
status: 'posted',
|
||||
fiscal_period_id: 'period-1',
|
||||
voucher_series: 'A',
|
||||
voucher_number: 7,
|
||||
entry_date: '2026-02-02',
|
||||
description: 'ALMI AB - Innovationslån',
|
||||
source_type: 'manual',
|
||||
source_id: null,
|
||||
lines: [
|
||||
{ account_number: '1930', debit_amount: 1000, credit_amount: 0 },
|
||||
{ account_number: '2350', debit_amount: 0, credit_amount: 1000 },
|
||||
],
|
||||
}
|
||||
const reversal = { id: 'reversal-1', reverses_id: 'entry-1', source_type: 'storno' }
|
||||
//
|
||||
// "Att bokföra" is is_business IS NULL AND is_ignored = false
|
||||
// (lib/worklist/types.ts), not journal_entry_id IS NULL: bulk-booked and
|
||||
// multi-allocated rows keep journal_entry_id NULL while booked. Clearing
|
||||
// only the link therefore left the stornoed row out of the list and the nav
|
||||
// badge (#1950), so the engine resets the same triple the uncategorize
|
||||
// paths write, plus reconciliation_method since the link it described is gone.
|
||||
//
|
||||
// Second anchor: bulk-booked rows (bulk_book_transactions RPC) point at the
|
||||
// verifikat through transaction_voucher_links, and for N>1 that junction is
|
||||
// the only anchor. The engine deletes those link rows and releases the rows
|
||||
// that have no anchor left; a row still anchored elsewhere (residual booking:
|
||||
// main verifikat in journal_entry_id, junction row to the residual verifikat)
|
||||
// stays booked.
|
||||
const original = {
|
||||
id: 'entry-1',
|
||||
company_id: 'company-1',
|
||||
status: 'posted',
|
||||
fiscal_period_id: 'period-1',
|
||||
voucher_series: 'A',
|
||||
voucher_number: 7,
|
||||
entry_date: '2026-02-02',
|
||||
description: 'ALMI AB - Innovationslån',
|
||||
source_type: 'manual',
|
||||
source_id: null,
|
||||
lines: [
|
||||
{ account_number: '1930', debit_amount: 1000, credit_amount: 0 },
|
||||
{ account_number: '2350', debit_amount: 0, credit_amount: 1000 },
|
||||
],
|
||||
}
|
||||
const reversal = { id: 'reversal-1', reverses_id: 'entry-1', source_type: 'storno' }
|
||||
|
||||
type Filter = [op: string, column: string, value: unknown]
|
||||
interface RecordedWrite { payload?: unknown; op?: string; filters: Filter[] }
|
||||
|
||||
/**
|
||||
* Mock for reverseEntry. `voucherLinks` are the transaction ids the junction
|
||||
* holds for entry-1; `remainingLinks` what it still holds for those ids after
|
||||
* the delete (a row anchored to some other verifikat too).
|
||||
*/
|
||||
function setup(opts: { voucherLinks?: string[]; remainingLinks?: string[] } = {}) {
|
||||
let jeCall = 0
|
||||
const jeResults = [
|
||||
{ data: original, error: null }, // fetch original (.single)
|
||||
@@ -915,8 +937,33 @@ describe('reverseEntry: bank transaction unlink', () => {
|
||||
return b
|
||||
}
|
||||
|
||||
const txUpdatePayloads: unknown[] = []
|
||||
const txFilters: Record<string, unknown> = {}
|
||||
const txWrites: RecordedWrite[] = []
|
||||
const linkOps: RecordedWrite[] = []
|
||||
let linkSelectCall = 0
|
||||
|
||||
function recorder(list: RecordedWrite[], resolveWith: (current: RecordedWrite) => unknown) {
|
||||
const b: Record<string, unknown> = {}
|
||||
let current: RecordedWrite = { filters: [] }
|
||||
const start = (op: string) =>
|
||||
vi.fn().mockImplementation((payload?: unknown) => {
|
||||
current = { op, payload, filters: [] }
|
||||
list.push(current)
|
||||
return b
|
||||
})
|
||||
const filter = (op: string) =>
|
||||
vi.fn().mockImplementation((column: string, value: unknown) => {
|
||||
current.filters.push([op, column, value])
|
||||
return b
|
||||
})
|
||||
b.update = start('update')
|
||||
b.select = start('select')
|
||||
b.delete = start('delete')
|
||||
b.eq = filter('eq')
|
||||
b.in = filter('in')
|
||||
b.is = filter('is')
|
||||
b.then = (resolve: (v: unknown) => void) => resolve(resolveWith(current))
|
||||
return b
|
||||
}
|
||||
|
||||
const supabase = {
|
||||
rpc: vi.fn().mockResolvedValue({ data: 8, error: null }),
|
||||
@@ -938,28 +985,112 @@ describe('reverseEntry: bank transaction unlink', () => {
|
||||
if (table === 'journal_entry_lines') {
|
||||
return { insert: vi.fn().mockResolvedValue({ error: null }) }
|
||||
}
|
||||
if (table === 'transactions') {
|
||||
const b: Record<string, unknown> = {}
|
||||
b.update = vi.fn().mockImplementation((payload: unknown) => {
|
||||
txUpdatePayloads.push(payload)
|
||||
return b
|
||||
if (table === 'transactions') return recorder(txWrites, () => ({ error: null }))
|
||||
if (table === 'transaction_voucher_links') {
|
||||
return recorder(linkOps, (current) => {
|
||||
if (current.op !== 'select') return { error: null }
|
||||
const ids = linkSelectCall++ === 0 ? opts.voucherLinks ?? [] : opts.remainingLinks ?? []
|
||||
return { data: ids.map((transaction_id) => ({ transaction_id })), error: null }
|
||||
})
|
||||
b.eq = vi.fn().mockImplementation((col: string, val: unknown) => {
|
||||
txFilters[col] = val
|
||||
return b
|
||||
})
|
||||
b.then = (resolve: (v: unknown) => void) => resolve({ error: null })
|
||||
return b
|
||||
}
|
||||
return createMockChain()
|
||||
}),
|
||||
}
|
||||
|
||||
return { supabase, txWrites, linkOps }
|
||||
}
|
||||
|
||||
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 result = await reverseEntry(supabase as never, 'company-1', 'user-1', 'entry-1')
|
||||
|
||||
expect(result.id).toBe('reversal-1')
|
||||
expect(txUpdatePayloads).toEqual([{ journal_entry_id: null }])
|
||||
expect(txFilters).toMatchObject({ company_id: 'company-1', journal_entry_id: 'entry-1' })
|
||||
expect(txWrites).toHaveLength(1)
|
||||
expect(txWrites[0].payload).toEqual({
|
||||
journal_entry_id: null,
|
||||
is_business: null,
|
||||
category: null,
|
||||
reconciliation_method: null,
|
||||
})
|
||||
// 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'],
|
||||
])
|
||||
// The junction is consulted for this entry only; nothing to delete or
|
||||
// release when it holds no rows.
|
||||
expect(linkOps).toEqual([
|
||||
{
|
||||
op: 'select',
|
||||
payload: 'transaction_id',
|
||||
filters: [
|
||||
['eq', 'company_id', 'company-1'],
|
||||
['eq', 'journal_entry_id', 'entry-1'],
|
||||
],
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
it('deletes transaction_voucher_links rows and releases bulk-booked rows with no anchor left', async () => {
|
||||
// Samlingsverifikat over three bank rows (journal_entry_id NULL on all
|
||||
// three, one junction row each). tx-c is also anchored to another
|
||||
// verifikat through the junction and must stay booked.
|
||||
const { supabase, txWrites, linkOps } = setup({
|
||||
voucherLinks: ['tx-a', 'tx-b', 'tx-c'],
|
||||
remainingLinks: ['tx-c'],
|
||||
})
|
||||
|
||||
await reverseEntry(supabase as never, 'company-1', 'user-1', 'entry-1')
|
||||
|
||||
expect(linkOps).toEqual([
|
||||
{
|
||||
op: 'select',
|
||||
payload: 'transaction_id',
|
||||
filters: [
|
||||
['eq', 'company_id', 'company-1'],
|
||||
['eq', 'journal_entry_id', 'entry-1'],
|
||||
],
|
||||
},
|
||||
{
|
||||
op: 'delete',
|
||||
payload: undefined,
|
||||
filters: [
|
||||
['eq', 'company_id', 'company-1'],
|
||||
['eq', 'journal_entry_id', 'entry-1'],
|
||||
],
|
||||
},
|
||||
{
|
||||
op: 'select',
|
||||
payload: 'transaction_id',
|
||||
filters: [
|
||||
['eq', 'company_id', 'company-1'],
|
||||
['in', 'transaction_id', ['tx-a', 'tx-b', 'tx-c']],
|
||||
],
|
||||
},
|
||||
])
|
||||
|
||||
expect(txWrites).toHaveLength(2)
|
||||
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[1].filters).toEqual([
|
||||
['eq', 'company_id', 'company-1'],
|
||||
['in', 'id', ['tx-a', 'tx-b']],
|
||||
['is', 'journal_entry_id', null],
|
||||
])
|
||||
})
|
||||
|
||||
it('leaves the rows alone when every junction-linked row is still anchored elsewhere', async () => {
|
||||
const { supabase, txWrites, linkOps } = setup({
|
||||
voucherLinks: ['tx-a'],
|
||||
remainingLinks: ['tx-a'],
|
||||
})
|
||||
|
||||
await reverseEntry(supabase as never, 'company-1', 'user-1', 'entry-1')
|
||||
|
||||
expect(linkOps.map((o) => o.op)).toEqual(['select', 'delete', 'select'])
|
||||
expect(txWrites).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -1241,15 +1241,89 @@ export async function reverseEntry(
|
||||
// as bokförd forever, and has no re-booking affordance: the agent paths
|
||||
// (lib/pending-operations/commit.ts) already did this manually after every
|
||||
// reverseEntry call; the dashboard reverse route did not.
|
||||
//
|
||||
// The worklist's "unbooked" predicate is is_business IS NULL (see
|
||||
// lib/worklist/types.ts), not journal_entry_id IS NULL, so clearing only the
|
||||
// link left the stornoed row invisible in Att bokföra and in the nav badge
|
||||
// (#1950) while the storno dialog (reverse_warning) promised the return.
|
||||
// The engine therefore resets the same triple the uncategorize paths write
|
||||
// (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 })
|
||||
.update({
|
||||
journal_entry_id: null,
|
||||
is_business: null,
|
||||
category: null,
|
||||
reconciliation_method: null,
|
||||
})
|
||||
.eq('company_id', companyId)
|
||||
.eq('journal_entry_id', entryId)
|
||||
if (unlinkError) {
|
||||
log.error('failed to unlink transactions from reversed entry', unlinkError, { entryId })
|
||||
}
|
||||
|
||||
// Same promise, second anchor. Bulk-booked rows (bulk_book_transactions RPC)
|
||||
// and residual verifikat (lib/reconciliation/residual.ts) anchor bank lines
|
||||
// through transaction_voucher_links; for a samlingsverifikat with N>1 rows
|
||||
// that junction is the ONLY anchor (journal_entry_id stays NULL), so the
|
||||
// UPDATE above matches nothing and all N rows keep is_business = true
|
||||
// against a status='reversed' entry. The link rows go the way koppla-bort
|
||||
// removes them, and a row returns to Att bokföra only when no anchor is
|
||||
// left: a residual booking keeps the main verifikat in journal_entry_id
|
||||
// while its junction row points at the small residual verifikat, and
|
||||
// stornoing the residual must not put a still-booked row back in the list.
|
||||
const { data: junctionRows, error: junctionReadError } = await supabase
|
||||
.from('transaction_voucher_links')
|
||||
.select('transaction_id')
|
||||
.eq('company_id', companyId)
|
||||
.eq('journal_entry_id', entryId)
|
||||
if (junctionReadError) {
|
||||
log.error('failed to read voucher links of reversed entry', junctionReadError, { entryId })
|
||||
} else if (junctionRows && junctionRows.length > 0) {
|
||||
const junctionTxIds = [
|
||||
...new Set((junctionRows as Array<{ transaction_id: string }>).map((r) => r.transaction_id)),
|
||||
]
|
||||
const { error: junctionDeleteError } = await supabase
|
||||
.from('transaction_voucher_links')
|
||||
.delete()
|
||||
.eq('company_id', companyId)
|
||||
.eq('journal_entry_id', entryId)
|
||||
if (junctionDeleteError) {
|
||||
log.error('failed to delete voucher links of reversed entry', junctionDeleteError, { entryId })
|
||||
} else {
|
||||
const { data: remainingRows, error: remainingError } = await supabase
|
||||
.from('transaction_voucher_links')
|
||||
.select('transaction_id')
|
||||
.eq('company_id', companyId)
|
||||
.in('transaction_id', junctionTxIds)
|
||||
if (remainingError) {
|
||||
log.error('failed to read remaining voucher links after storno', remainingError, {
|
||||
entryId,
|
||||
})
|
||||
} else {
|
||||
const stillAnchored = new Set(
|
||||
((remainingRows ?? []) as Array<{ transaction_id: string }>).map((r) => r.transaction_id),
|
||||
)
|
||||
const releaseIds = junctionTxIds.filter((id) => !stillAnchored.has(id))
|
||||
if (releaseIds.length > 0) {
|
||||
const { error: releaseError } = await supabase
|
||||
.from('transactions')
|
||||
.update({ is_business: null, category: null, reconciliation_method: null })
|
||||
.eq('company_id', companyId)
|
||||
.in('id', releaseIds)
|
||||
.is('journal_entry_id', null)
|
||||
if (releaseError) {
|
||||
log.error('failed to release junction-linked transactions of reversed entry', releaseError, {
|
||||
entryId,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Same hazard one table over: a period whose opening_balance_entry_id still
|
||||
// points at the entry we just reversed. getOpeningBalances() reads the linked
|
||||
// entry's lines directly with no status filter, so the Balansrapport would go
|
||||
|
||||
Reference in New Issue
Block a user