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
@@ -28,6 +28,9 @@ interface TransactionBookingDialogProps {
transactionId: string,
journalEntryId: string,
attachedDocumentId?: string | null,
/** True when the transaction was LINKED to an existing voucher via the
* duplicate guard's match action: no new verifikat was created. */
matched?: boolean,
) => void
preselectedTemplate?: BookingTemplateLibrary | null
}
@@ -154,7 +157,7 @@ export default function TransactionBookingDialog({
const isIncome = transaction.amount > 0
const handleBooked = async (transactionId: string, journalEntryId: string) => {
const handleBooked = async (transactionId: string, journalEntryId: string, matched = false) => {
// Link any attached documents to the new journal entry: freshly uploaded
// files, and existing inbox documents picked via InboxDocumentPicker. For
// picked docs, inbox_item_id stamps the inbox item as consumed so it drops
@@ -221,7 +224,7 @@ export default function TransactionBookingDialog({
setUploadedFiles([])
setPickedInboxDocs([])
onBooked(transactionId, journalEntryId, pinnedDocId)
onBooked(transactionId, journalEntryId, pinnedDocId, matched)
}
// The receipt to show beside the form. A transaction may arrive with a
@@ -377,6 +380,15 @@ export default function TransactionBookingDialog({
sourceType="bank_transaction"
sourceId={transaction.id}
onEntryCreated={(entryId) => handleBooked(transaction.id, entryId)}
duplicateMatchTransaction={{
id: transaction.id,
cash_account_id: transaction.cash_account_id ?? null,
currency: transaction.currency ?? 'SEK',
}}
// Duplicate guard match: the bank line was linked to an existing
// voucher (no new entry). Reuse the booked flow so attached
// documents land on that verifikat and the row leaves the list.
onDuplicateMatched={(journalEntryId) => handleBooked(transaction.id, journalEntryId, true)}
/>
)}
</div>