Files
accounted/components/transactions/DuplicateBookingDialog.tsx
T
0d3ba5268d fix(transactions): close the booking duplicate guard's blind spots (#1573)
* fix(transactions): close booking duplicate guard blind spots G1-G3

The booking-time duplicate guard missed the most common bank-fee twin
shapes:

- G1: the sibling scan matched on the EXACT date only, so a duplicate
  import with a drifted date (CSV bokforingsdag vs PSD2 valutadag) was
  invisible. The scan now uses a +-3 day window with a deterministic
  ranking where exact-date candidates always outrank drifted ones
  (force=true re-detection stays bound to the reviewed candidate).
- G2: booked-ness required transactions.journal_entry_id, so bulk-booked
  (transaction_voucher_links) and multi-allocated (invoice_payments /
  supplier_invoice_payments) siblings read as unbooked. The scan now
  batch-fetches the anchor rows and resolves the verifikat via
  getPrimaryJournalEntryId (is_transaction_booked semantics).
- G3: the ledger scan excluded every voucher linked to any transaction,
  so a voucher booked from a date-drifted duplicate row escaped BOTH
  halves and the booking proceeded with no warning. A voucher whose
  linking transaction itself matches the target (same ore in the same
  currency, compatible cash account, date in the window) is now returned
  as the twin with transaction_id set.

All candidate picks keep explicit total-order tiebreakers so a force
re-detect returns the same candidate the user reviewed, and the
SEK-or-null amount contract is unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(transactions): offer match/ignore for sibling duplicates and route all 409s into the dialog

The duplicate dialog hid its match action for sibling-transaction
candidates (canMatch required transaction_id === null), so the user who
most needed steering saw only 'Bokfor anda'. manualLink explicitly
allows N:1 links, so the match action is now offered for both candidate
kinds. Sibling candidates get question-form body copy ('vill du matcha
mot verifikatet i stallet?') and an additional 'Ignorera transaktionen'
action via the existing POST /api/transactions/[id]/ignore, which is the
correct resolution when the row itself is a duplicate import (matching
would double-count the bank side, booking the ledger side).

Two clients dead-ended the TRANSACTION_BOOK_POSSIBLE_DUPLICATE 409 in a
destructive toast with no way forward:

- the counterparty-template branch of handleQuickReviewConfirm now sets
  the shared duplicateWarning state exactly like runCategorize, with the
  force retry bound to the reviewed candidate's voucher
- BankReconciliationView's quick-book now opens the same dialog, with
  match/ignore refreshing the reconciliation lists

New sv/en strings: dialog_duplicate_body_sibling,
dialog_duplicate_ignore, dialog_duplicate_ignore_failed. File-level
parity tests pin the 409 routing and the dialog affordances.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(transactions): duplicate guard on the bulk-book samlingsverifikation path

/api/transactions/bulk-book never called detectBookingDuplicate, so a
batch containing an already-booked twin minted a second verifikat with
no warning. The route now runs the shared per-tx guard before the RPC,
with intra-batch exclusions (the other selected txs are distinct events
the user picked, and the link-existing target voucher is the batch's own
destination), returning 409 TRANSACTION_BOOK_POSSIBLE_DUPLICATE with the
candidate and the flagged tx id.

BulkBookDialog routes the 409 into DuplicateBookingDialog for review
(view voucher / cancel / book anyway) instead of a dead-end toast;
'Bokfor anda' re-runs the batch with force=true. On force the route
re-detects and records each dismissed candidate as
BankTransactionDuplicateDismissed in behandlingshistorik (BFNAR 2013:2
kap 8), parity with the /categorize bypass. Detection failures stay
fail-open. Note: the MCP RPC twin (gnubok_bulk_book_transactions)
bypasses this route and remains unguarded; guarding inside the RPC needs
a migration and is out of scope here.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(transactions): gate the duplicate-dialog ignore hint on the action being present

The sibling body copy mentioned ignoring the row, but two render sites
(the manual booking form and the bulk dialog) show sibling candidates
without the ignore action. The guidance now lives in a separate
dialog_duplicate_ignore_hint string rendered only when the Ignorera
button itself renders, so copy never points at a button that is not
there.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-13 15:21:55 +02:00

329 lines
15 KiB
TypeScript

'use client'
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 { AlertTriangle, 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
* shares this one's date + amount + bank account. Never a hard block:
* genuinely repeated same-day payments (e.g. several identical Swish transfers)
* are legitimate, so the user can review the existing verifikat or book anyway.
*
* Shared by the /transactions list (runCategorize) and the manual booking
* dialog (JournalEntryForm → /api/transactions/[id]/book). The caller owns the
* retry: "Bokför ändå" must re-issue the request with force=true bound to
* `candidate.journal_entry_id` via `expected_duplicate_journal_entry_id`: it
* 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 (candidate.transaction_id set: the verifikat
* is already linked to ANOTHER bank transaction) get the match action too:
* manualLink (lib/reconciliation/bank-reconciliation.ts) explicitly allows a
* second transaction on one voucher (split settlements), so hiding the action
* dead-ended the user in "Bokför ändå". Their body copy asks "vill du matcha i
* stället?" and, because that shape is very often a duplicate IMPORT of one
* real movement (where matching would double-count the bank side), they
* additionally get "Ignorera transaktionen" via /api/transactions/[id]/ignore
* when the caller supplies `onIgnored`.
*/
export default function DuplicateBookingDialog({
candidate,
processing = false,
onBookAnyway,
onCancel,
matchTransaction,
onMatched,
onIgnored,
}: {
/** 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
/** Called after POST /api/transactions/[id]/ignore succeeds for a
* sibling-transaction candidate (the row is likely a duplicate import).
* The caller owns the refresh and closes the dialog by clearing
* `candidate`. Omit to hide the ignore action. */
onIgnored?: (transactionId: string) => void
}) {
const t = useTranslations('transactions')
const locale = useLocale() as ErrorLocale
const { toast } = useToast()
const [matching, setMatching] = useState(false)
const [ignoring, setIgnoring] = useState(false)
// A sibling-transaction candidate: the twin bank row is already booked, so
// the target is either a duplicate import (ignore), the second leg of an
// N:1 settlement (match), or a genuinely separate identical event (book).
const isSiblingCandidate = candidate !== null && candidate.transaction_id !== null
// Matching links THIS bank line to the existing voucher instead of minting a
// second verifikat (one affärshändelse, one verifikat). Offered for both
// candidate kinds: for ledger-only vouchers it is the right default, and for
// sibling candidates manualLink explicitly permits N:1 links.
const canMatch = candidate !== null && !!matchTransaction && !!onMatched
const canIgnore = isSiblingCandidate && !!matchTransaction && !!onIgnored
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)
}
}
async function handleIgnore() {
if (!candidate || !matchTransaction || !onIgnored || ignoring) return
setIgnoring(true)
try {
const res = await fetch(`/api/transactions/${matchTransaction.id}/ignore`, {
method: 'POST',
})
const result = await res.json().catch(() => null)
if (!res.ok || result?.error) {
toast({
title: t('dialog_duplicate_ignore_failed'),
description: getErrorMessage(result, { context: 'transaction', statusCode: res.status, locale }),
variant: 'destructive',
})
return
}
onIgnored(matchTransaction.id)
} catch {
toast({
title: t('dialog_duplicate_ignore_failed'),
description: getErrorMessage(null, { context: 'transaction', locale }),
variant: 'destructive',
})
} finally {
setIgnoring(false)
}
}
const busy = processing || matching || ignoring
return (
<Dialog
open={candidate !== null}
onOpenChange={(open) => {
if (!open) onCancel()
}}
>
<DialogContent>
<DialogHeader>
<DialogTitle>{t('dialog_duplicate_title')}</DialogTitle>
</DialogHeader>
<div className="space-y-4">
{/* Sibling candidates get the "vill du matcha i stället?" copy: the
generic body's "en annan transaktion eller en befintlig
verifikation" hedge reads as noise once the twin is known. The
ignore hint renders only when the action itself does (callers
without onIgnored, e.g. the manual booking form and the bulk
dialog, must not have copy pointing at a button that is not
there). */}
<p className="text-sm text-muted-foreground">
{isSiblingCandidate ? t('dialog_duplicate_body_sibling') : t('dialog_duplicate_body')}
{canIgnore && <> {t('dialog_duplicate_ignore_hint')}</>}
</p>
{candidate && (
<div className="space-y-1 rounded-md border bg-muted/30 p-3">
<div className="flex items-center justify-between gap-3 text-sm">
<span className="font-medium">
{candidate.voucher_label
? t('dialog_duplicate_voucher_label', { label: candidate.voucher_label })
: t('dialog_duplicate_voucher_generic')}
</span>
{/* candidate.amount is ALWAYS SEK or null, never a foreign
number (booking-duplicate-detection.ts): the explicit 'SEK'
states that contract at the call site. When the sibling is
foreign and carries no stored rate, amount is null and the
honest figure is the sibling's own amount in its own
currency, explicitly labelled. */}
<span className="tabular-nums">
{candidate.amount != null
? formatCurrency(candidate.amount, 'SEK')
: candidate.currency && candidate.amount_in_currency != null
? formatCurrency(candidate.amount_in_currency, candidate.currency)
: t('dialog_duplicate_amount_unknown')}
</span>
</div>
{/* Verified FX sibling: the kr figure above is the sibling's own
booked SEK value; show the foreign original beneath it so the
user can recognise their EUR/USD line at a glance. */}
{candidate.amount != null &&
candidate.currency &&
candidate.amount_in_currency != null && (
<div className="text-right text-xs text-muted-foreground tabular-nums">
{formatCurrency(candidate.amount_in_currency, candidate.currency)}
</div>
)}
<div className="text-xs text-muted-foreground tabular-nums">
{formatDate(candidate.entry_date)}
</div>
{candidate.description && (
<div className="truncate text-xs text-muted-foreground">{candidate.description}</div>
)}
{/* Rateless foreign sibling: the amounts matched exactly in the
shared currency, but no SEK value exists for it, so say that
instead of printing an authoritative-looking kr figure.
Gated on candidate.currency: the sentence names the currency,
and interpolating an empty string renders broken Swedish
("samma belopp i , men..."). A null-currency candidate with a
null amount already shows dialog_duplicate_amount_unknown. */}
{candidate.amount == null && candidate.currency && (
<div className="flex items-start gap-2 pt-1">
<AlertTriangle className="h-3.5 w-3.5 text-attn flex-shrink-0 mt-0.5" />
<p className="text-xs text-attn leading-snug">
{t('dialog_duplicate_sek_unavailable', { currency: candidate.currency })}
</p>
</div>
)}
{/* Ledger-only candidate found for a bank line that has no SEK
value: the kr figure above is the voucher leg's real amount,
but it could not be compared against the transaction, so the
match rests on date + account + direction alone. */}
{candidate.amount != null && !candidate.amount_verified && (
<div className="flex items-start gap-2 pt-1">
<AlertTriangle className="h-3.5 w-3.5 text-attn flex-shrink-0 mt-0.5" />
<p className="text-xs text-attn leading-snug">
{t('dialog_duplicate_amount_unverified')}
</p>
</div>
)}
</div>
)}
<div className="flex flex-col-reverse gap-2 sm:flex-row sm:items-center sm:justify-between">
{candidate && (
<Button asChild variant="ghost" size="sm" className="text-muted-foreground sm:mr-auto">
<a
href={`/bookkeeping/${candidate.journal_entry_id}`}
target="_blank"
rel="noopener noreferrer"
>
{t('dialog_duplicate_view_voucher')}
</a>
</Button>
)}
<div className="flex flex-col-reverse gap-2 sm:flex-row sm:justify-end">
<Button variant="outline" onClick={onCancel} disabled={busy}>
{t('dialog_duplicate_cancel')}
</Button>
{canMatch ? (
<>
<Button variant="outline" onClick={onBookAnyway} disabled={busy}>
{t('dialog_duplicate_book_anyway')}
</Button>
{/* Sibling candidates only: when the row is a duplicate
import of the already-booked twin, ignoring it is the
correct resolution (matching would double-count the bank
side, booking would double-count the ledger side). */}
{canIgnore && (
<Button variant="outline" onClick={handleIgnore} disabled={busy}>
{ignoring && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
{t('dialog_duplicate_ignore')}
</Button>
)}
<Button onClick={handleMatch} disabled={busy}>
{matching && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
{t('dialog_duplicate_match')}
</Button>
</>
) : (
<Button onClick={onBookAnyway} disabled={busy}>
{t('dialog_duplicate_book_anyway')}
</Button>
)}
</div>
</div>
</div>
</DialogContent>
</Dialog>
)
}