fix(transactions): steer duplicate guard to matching for ledger-only vouchers (#958)

When the booking-time duplicate guard flags a ledger-only voucher
candidate (transaction_id null: a verifikat from an SIE import, a paid
invoice, a salary payout), the dialog's primary action is now "Matcha
mot verifikatet": it links the bank transaction to the existing voucher
via /api/reconciliation/bank/link (the same path MatchVoucherDialog
uses) instead of double-booking the same affarshandelse. "Bokfor anda"
stays available but demoted to an outline button. Sibling-transaction
candidates keep today's layout: matching a second bank line onto a
voucher that already has one is the N:1 edge case, not the default.

Wired through both call sites: the transactions page (runCategorize)
reuses handleVoucherLinked's refresh, and TransactionBookingDialog
(JournalEntryForm, /api/transactions/[id]/book path) reuses the booked
flow so in-dialog attached documents land on the matched verifikat and
the row leaves the list, with a "Bankhandelsen kopplad" toast instead
of "Bokford". No lib change: the candidate payload already carries the
transaction_id discriminator, covered by existing detection tests.

Closes #919.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-07-09 22:08:44 +02:00
committed by GitHub
co-authored by Claude Fable 5
parent d7e110b6b2
commit 82c66739d7
9 changed files with 200 additions and 25 deletions
@@ -76,6 +76,7 @@ describe('detectBookedDuplicateTransaction', () => {
entry_date: '2025-12-19',
description: 'TELENOR SVERIGE AB',
amount: -1616,
account_number: null,
})
})
@@ -251,6 +252,9 @@ describe('detectLedgerDuplicateVoucher', () => {
entry_date: '2026-03-30',
description: 'Inbetalning kundfaktura 2026001',
amount: 98565,
// The matched leg's account rides along so the dialog's match action
// links on the exact 19xx the voucher was booked to (issue #919).
account_number: '1930',
})
})
@@ -270,6 +274,7 @@ describe('detectLedgerDuplicateVoucher', () => {
expect(result?.journal_entry_id).toBe('je-3')
expect(result?.transaction_id).toBeNull()
expect(result?.amount).toBe(16609)
expect(result?.account_number).toBe('1930')
})
it('does NOT flag an inbound receipt against a credit-only voucher (wrong direction)', async () => {
@@ -46,6 +46,14 @@ export interface BookedDuplicateCandidate {
entry_date: string
description: string | null
amount: number
/**
* The 19xx settlement account of the voucher leg that matched, set for
* ledger-only candidates so the match action can link on the exact account
* the voucher was booked to (a legacy transaction without cash_account_id
* would otherwise resolve by currency and can pick the wrong 19xx). Null for
* sibling-transaction candidates, whose legs are not fetched.
*/
account_number: string | null
}
/** Minimal shape of the transaction about to be booked. */
@@ -162,6 +170,7 @@ export async function detectBookedDuplicateTransaction(
entry_date: entryDate,
description: best.description,
amount: roundOre(Number(best.amount)),
account_number: null,
}
}
@@ -327,6 +336,7 @@ export async function detectLedgerDuplicateVoucher(
entry_date: best.journal_entry.entry_date,
description: best.journal_entry.description,
amount: roundOre(Number(inbound ? best.debit_amount : best.credit_amount)),
account_number: best.account_number,
}
}