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