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:
Jakob Wennberg
2026-08-27 22:24:55 +02:00
committed by GitHub
co-authored by Claude Fable 5 Jakob Wennberg
parent 533df34369
commit cb9ae15d46
5 changed files with 473 additions and 38 deletions
+166 -35
View File
@@ -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)
})
})
+75 -1
View File
@@ -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