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
+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