From 82c66739d726e27f80ff8f1a46bf56c88e1de24c Mon Sep 17 00:00:00 2001 From: Jakob Wennberg <149234542+jakobwennberg@users.noreply.github.com> Date: Thu, 9 Jul 2026 22:08:44 +0200 Subject: [PATCH] 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 --- DECISIONS.md | 1 + app/(dashboard)/transactions/page.tsx | 43 +++--- components/bookkeeping/JournalEntryForm.tsx | 20 ++- .../transactions/DuplicateBookingDialog.tsx | 126 +++++++++++++++++- .../transactions/TransactionBookingDialog.tsx | 16 ++- .../booking-duplicate-detection.test.ts | 5 + .../booking-duplicate-detection.ts | 10 ++ messages/en.json | 2 + messages/sv.json | 2 + 9 files changed, 200 insertions(+), 25 deletions(-) diff --git a/DECISIONS.md b/DECISIONS.md index 4cc64959..026419c0 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -51,3 +51,4 @@ One line per decision: `[YYYY-MM-DD] : `. Appended by agents and [2026-07-08] Pinned @anthropic-ai/bedrock-sdk to exact 0.29.1 (dependabot #884 auto-bumped it to 0.32.0, which broke Bedrock streaming in prod: empty stream / "request ended without sending any chunks"). Guarded three ways against accidental re-bump: exact pin in package.json, dependabot ignore, and a pinned-dep check in scripts/checks/no-new-antipatterns.mjs (check:guards). Unpin only once 0.32.x streaming is verified against Bedrock. [2026-07-09] Issue #916 (disconnect orphans ledger accounts): release claims by demoting cash_accounts rows to manual (bank_connection_id = null), never deleting: transactions.cash_account_id and ledger history reference the rows, and upsertFromPsd2 promotes a manual holder in place on reconnect so the bank lands back on its original BAS slot. Orphans predating the fix self-heal via a revoked-status filter in the allocator + collision guard (not data repair). When a promote collides with a duplicate row for the same connection+uid (callback mirrored onto an overflow slot pre-fix), the duplicate is deleted only if it has zero linked transactions, otherwise demoted: preserves FK links while freeing the slot. Picker-save rejections now render inline in the picker instead of routing to the sync-progress modal, whose parent-unmount-on-close made every save outcome invisible. [2026-07-09] #917 fix scoped to the current-year suggestion: "Sedan räkenskapsårets början" now resolves from the fiscal_periods row containing today, but the "Föregående räkenskapsårets start" custom option still derives from the recurring fiscal_year_start_month: the issue only covers the current-year date and a first-year company has no previous period row to resolve against. +[2026-07-09] Issue #919 (duplicate guard should steer to matching): the match action lives INSIDE DuplicateBookingDialog (fetch to /api/reconciliation/bank/link + account resolution via /api/cash-accounts + resolveAccount, exactly the MatchVoucherDialog path) rather than in each call site or a new endpoint: both call sites (transactions page runCategorize + TransactionBookingDialog/JournalEntryForm) share one implementation and pass only the transaction context + an onMatched callback mirroring onLinked. Match is primary ONLY for ledger-only candidates (transaction_id null, the SIE-import case); sibling-transaction candidates keep "Bokför ändå" primary since N:1 matching is the edge case. No lib change: the candidate already carries the transaction_id discriminator, covered by existing tests. diff --git a/app/(dashboard)/transactions/page.tsx b/app/(dashboard)/transactions/page.tsx index eca527a9..30c47811 100644 --- a/app/(dashboard)/transactions/page.tsx +++ b/app/(dashboard)/transactions/page.tsx @@ -45,6 +45,7 @@ import TransactionAttachDocumentDialog from '@/components/transactions/Transacti import QuickReviewDialog from '@/components/transactions/QuickReviewDialog' import EditTransactionTitleDialog from '@/components/transactions/EditTransactionTitleDialog' import DuplicateBookingDialog from '@/components/transactions/DuplicateBookingDialog' +import type { BookedDuplicateCandidate } from '@/lib/transactions/booking-duplicate-detection' import TemplatePicker from '@/components/transactions/TemplatePicker' import { getDefaultAccountForCategory, getDefaultVatTreatmentForCategory } from '@/lib/bookkeeping/category-mapping' @@ -244,14 +245,7 @@ export default function TransactionsPage() { const [duplicateWarning, setDuplicateWarning] = useState<{ transactionId: string retry: () => Promise - candidate: { - transaction_id: string | null - journal_entry_id: string - voucher_label: string - entry_date: string - description: string | null - amount: number - } + candidate: BookedDuplicateCandidate } | null>(null) const [duplicateProcessing, setDuplicateProcessing] = useState(false) @@ -889,14 +883,7 @@ export default function TransactionsPage() { // merely says "book anyway" with no way to do so: open a dialog with // the already-booked sibling and let the user confirm. "Bokför ändå" // re-runs with force bound to this candidate (server re-detects it). - const candidate = result.error.details.candidate as { - transaction_id: string | null - journal_entry_id: string - voucher_label: string - entry_date: string - description: string | null - amount: number - } + const candidate = result.error.details.candidate as BookedDuplicateCandidate setDuplicateWarning({ transactionId: id, retry: () => @@ -1657,6 +1644,9 @@ export default function TransactionsPage() { transactionId: string, journalEntryId: string, attachedDocumentId?: string | null, + // True when the duplicate guard's match action LINKED the transaction to + // an existing voucher instead of creating a new one. + matched?: boolean, ) { setExitingIds((prev) => new Set(prev).add(transactionId)) setTimeout(() => { @@ -1683,7 +1673,11 @@ export default function TransactionsPage() { setBookingDialogOpen(false) setBookingDialogTransaction(null) setBookingDialogTemplate(null) - toast({ title: 'Bokförd' }) + if (matched) { + toast({ title: 'Bankhändelsen kopplad', description: 'Ingen ny bokföring skapad.' }) + } else { + toast({ title: 'Bokförd' }) + } } function openAttachDocumentDialog(transaction: TransactionWithInvoice) { @@ -2675,6 +2669,21 @@ export default function TransactionsPage() { candidate={duplicateWarning?.candidate ?? null} processing={duplicateProcessing} onCancel={() => setDuplicateWarning(null)} + // Ledger-only candidate (transaction_id null, e.g. a verifikat from an + // SIE import): the primary action links the bank line to the existing + // voucher instead of double-booking it. Success refreshes the same + // state a MatchVoucherDialog link does. + matchTransaction={ + duplicateWarning + ? transactions.find((tx) => tx.id === duplicateWarning.transactionId) ?? { + id: duplicateWarning.transactionId, + } + : null + } + onMatched={(transactionId, journalEntryId, voucherLabel) => { + setDuplicateWarning(null) + handleVoucherLinked(transactionId, journalEntryId, voucherLabel) + }} onBookAnyway={async () => { const retry = duplicateWarning?.retry setDuplicateProcessing(true) diff --git a/components/bookkeeping/JournalEntryForm.tsx b/components/bookkeeping/JournalEntryForm.tsx index e786f57a..6adce5cb 100644 --- a/components/bookkeeping/JournalEntryForm.tsx +++ b/components/bookkeeping/JournalEntryForm.tsx @@ -26,7 +26,7 @@ import { TemplateForm } from '@/components/settings/TemplateForm' import CreatePeriodDialog from '@/components/bookkeeping/CreatePeriodDialog' import { ActivateAccountsDialog } from '@/components/bookkeeping/ActivateAccountsDialog' import { AddAccountDialog } from '@/components/bookkeeping/AddAccountDialog' -import DuplicateBookingDialog from '@/components/transactions/DuplicateBookingDialog' +import DuplicateBookingDialog, { type DuplicateMatchTransaction } from '@/components/transactions/DuplicateBookingDialog' import { Skeleton } from '@/components/ui/skeleton' import { useSubmitWithAccountActivation, @@ -82,6 +82,13 @@ interface Props { editEntryId?: string /** Fired after a successful draft edit (editEntryId path). */ onUpdated?: () => void + /** The bank transaction being booked (set by TransactionBookingDialog). + * Enables the duplicate guard's "Matcha mot verifikatet" action for + * ledger-only voucher candidates. */ + duplicateMatchTransaction?: DuplicateMatchTransaction + /** Fired after the duplicate guard's match action links the transaction to + * the existing voucher (no new entry was created). */ + onDuplicateMatched?: (journalEntryId: string) => void } const BLANK_LINE: FormLine = { account_number: '', debit_amount: '', credit_amount: '', line_description: '' } @@ -101,6 +108,8 @@ export default function JournalEntryForm({ bare, editEntryId, onUpdated, + duplicateMatchTransaction, + onDuplicateMatched, }: Props) { const { canWrite } = useCanWrite() const { toast } = useToast() @@ -1939,6 +1948,15 @@ export default function JournalEntryForm({ processing={isSubmitting} onCancel={() => setDuplicateCandidate(null)} onBookAnyway={handleBookAnyway} + matchTransaction={duplicateMatchTransaction ?? null} + onMatched={ + onDuplicateMatched + ? (_transactionId, journalEntryId) => { + setDuplicateCandidate(null) + onDuplicateMatched(journalEntryId) + } + : undefined + } /> ) diff --git a/components/transactions/DuplicateBookingDialog.tsx b/components/transactions/DuplicateBookingDialog.tsx index 0502e16a..4d2432f8 100644 --- a/components/transactions/DuplicateBookingDialog.tsx +++ b/components/transactions/DuplicateBookingDialog.tsx @@ -1,11 +1,25 @@ 'use client' -import { useTranslations } from 'next-intl' +import { useState } from 'react' +import { useTranslations, useLocale } from 'next-intl' import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog' import { Button } from '@/components/ui/button' +import { useToast } from '@/components/ui/use-toast' +import { Loader2 } from 'lucide-react' import { formatCurrency, formatDate } from '@/lib/utils' +import { getErrorMessage, type ErrorLocale } from '@/lib/errors/get-error-message' +import { resolveAccount } from '@/lib/cash-accounts/resolve-account' +import type { CashAccount } from '@/types' import type { BookedDuplicateCandidate } from '@/lib/transactions/booking-duplicate-detection' +/** The bank transaction being booked, as much as the caller knows about it. + * Enables the "Matcha mot verifikatet" action for ledger-only candidates. */ +export interface DuplicateMatchTransaction { + id: string + cash_account_id?: string | null + currency?: string | null +} + /** * Soft warning shown when the booking-time duplicate guard fires * (TRANSACTION_BOOK_POSSIBLE_DUPLICATE): another already-booked transaction @@ -20,20 +34,110 @@ import type { BookedDuplicateCandidate } from '@/lib/transactions/booking-duplic * is present on both candidate kinds (a sibling-transaction candidate and a * ledger-only voucher candidate, which has no transaction_id), and the server * re-detects it so a stale id can't wave the guard away. + * + * Ledger-only candidates (candidate.transaction_id === null: the voucher exists + * but no bank transaction is linked to it, e.g. a verifikat from an SIE import + * or an invoice marked paid) get "Matcha mot verifikatet" as the PRIMARY action + * when the caller supplies `matchTransaction` + `onMatched`: it links the bank + * line to the existing voucher via /api/reconciliation/bank/link (the same path + * MatchVoucherDialog uses) instead of double-booking the affärshändelse. + * "Bokför ändå" stays available but demoted. Sibling-transaction candidates + * keep booking as the primary action: matching a second bank line onto a + * voucher that already has one is the N:1 edge case, not the default. */ export default function DuplicateBookingDialog({ candidate, processing = false, onBookAnyway, onCancel, + matchTransaction, + onMatched, }: { /** The already-booked sibling, or null to keep the dialog closed. */ candidate: BookedDuplicateCandidate | null processing?: boolean onBookAnyway: () => void onCancel: () => void + /** The transaction being booked; required for the match action. */ + matchTransaction?: DuplicateMatchTransaction | null + /** Called after /api/reconciliation/bank/link succeeds. Mirrors + * MatchVoucherDialog's onLinked signature: everything the refresh needs is + * passed in, so a mid-request dialog close can't strand the update. The + * caller owns the success toast and state refresh (and closes the dialog by + * clearing `candidate`). */ + onMatched?: (transactionId: string, journalEntryId: string, voucherLabel: string) => void }) { const t = useTranslations('transactions') + const locale = useLocale() as ErrorLocale + const { toast } = useToast() + const [matching, setMatching] = useState(false) + + // The match action is offered only for ledger-only voucher candidates: the + // voucher has no bank transaction linked yet, so linking THIS one to it is + // the right default (one affärshändelse, one verifikat). + const canMatch = + candidate !== null && candidate.transaction_id === null && !!matchTransaction && !!onMatched + + async function handleMatch() { + if (!candidate || !matchTransaction || !onMatched || matching) return + setMatching(true) + try { + // Link on the exact 19xx account the candidate voucher was booked to + // when the guard reported it: a legacy transaction without a + // cash_account_id would otherwise resolve by currency and can pick a + // different 19xx than the voucher's leg, dead-ending the link. Fall back + // to resolving from the company's cash accounts, same as + // MatchVoucherDialog: the link route validates the voucher has a leg on + // this account and that the transaction belongs to it. + let account = candidate.account_number ?? '1930' + if (!candidate.account_number) { + try { + const caRes = await fetch('/api/cash-accounts') + if (caRes.ok) { + const caJson = await caRes.json() + const accounts = (caJson.data ?? []) as CashAccount[] + account = resolveAccount( + accounts, + matchTransaction.cash_account_id ?? null, + matchTransaction.currency ?? 'SEK', + ).account + } + } catch { + // Network hiccup: fall back to 1930; the link route re-validates. + } + } + + const res = await fetch('/api/reconciliation/bank/link', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + transaction_id: matchTransaction.id, + journal_entry_id: candidate.journal_entry_id, + account_number: account, + }), + }) + const result = await res.json() + if (!res.ok || result.error) { + toast({ + title: t('dialog_duplicate_match_failed'), + description: getErrorMessage(result, { context: 'transaction', statusCode: res.status, locale }), + variant: 'destructive', + }) + return + } + onMatched(matchTransaction.id, candidate.journal_entry_id, candidate.voucher_label) + } catch { + toast({ + title: t('dialog_duplicate_match_failed'), + description: getErrorMessage(null, { context: 'transaction', locale }), + variant: 'destructive', + }) + } finally { + setMatching(false) + } + } + + const busy = processing || matching return ( )}
- - + {canMatch ? ( + <> + + + + ) : ( + + )}
diff --git a/components/transactions/TransactionBookingDialog.tsx b/components/transactions/TransactionBookingDialog.tsx index 91686af1..829eff7e 100644 --- a/components/transactions/TransactionBookingDialog.tsx +++ b/components/transactions/TransactionBookingDialog.tsx @@ -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)} /> )} diff --git a/lib/transactions/__tests__/booking-duplicate-detection.test.ts b/lib/transactions/__tests__/booking-duplicate-detection.test.ts index f4d1a38c..042103cb 100644 --- a/lib/transactions/__tests__/booking-duplicate-detection.test.ts +++ b/lib/transactions/__tests__/booking-duplicate-detection.test.ts @@ -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 () => { diff --git a/lib/transactions/booking-duplicate-detection.ts b/lib/transactions/booking-duplicate-detection.ts index c40680bb..198f06e9 100644 --- a/lib/transactions/booking-duplicate-detection.ts +++ b/lib/transactions/booking-duplicate-detection.ts @@ -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, } } diff --git a/messages/en.json b/messages/en.json index 1d81ed3b..06106062 100644 --- a/messages/en.json +++ b/messages/en.json @@ -3872,6 +3872,8 @@ "dialog_duplicate_voucher_label": "Voucher {label}", "dialog_duplicate_voucher_generic": "Existing voucher", "dialog_duplicate_view_voucher": "View the voucher", + "dialog_duplicate_match": "Match to the voucher", + "dialog_duplicate_match_failed": "Could not match", "dialog_duplicate_book_anyway": "Book anyway", "dialog_duplicate_cancel": "Cancel", "load_failed_title": "Could not load transactions", diff --git a/messages/sv.json b/messages/sv.json index db4f5fc7..8c480844 100644 --- a/messages/sv.json +++ b/messages/sv.json @@ -3872,6 +3872,8 @@ "dialog_duplicate_voucher_label": "Verifikat {label}", "dialog_duplicate_voucher_generic": "Befintlig verifikation", "dialog_duplicate_view_voucher": "Visa verifikatet", + "dialog_duplicate_match": "Matcha mot verifikatet", + "dialog_duplicate_match_failed": "Kunde inte matcha", "dialog_duplicate_book_anyway": "Bokför ändå", "dialog_duplicate_cancel": "Avbryt", "load_failed_title": "Kunde inte ladda transaktioner",