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>
This commit is contained in:
Jakob Wennberg
2026-08-13 15:21:55 +02:00
committed by GitHub
co-authored by Claude Fable 5 Jakob Wennberg
parent 1b829883ae
commit 0d3ba5268d
13 changed files with 1073 additions and 69 deletions
+74 -1
View File
@@ -20,6 +20,8 @@ import { formatCurrency, formatDate } from '@/lib/utils'
import { formatVoucher } from '@/lib/bookkeeping/voucher-series-resolver'
import { CashAccountSelector } from '@/components/common/CashAccountSelector'
import { MatchVerifikationPicker, type UnlinkedGLLine } from '@/components/reconciliation/MatchVerifikationPicker'
import DuplicateBookingDialog from '@/components/transactions/DuplicateBookingDialog'
import type { BookedDuplicateCandidate } from '@/lib/transactions/booking-duplicate-detection'
import {
DropdownMenu,
DropdownMenuContent,
@@ -241,6 +243,15 @@ export function BankReconciliationView({ periodId, periodBounds, autoRun }: Bank
// Per-verifikat loading for the "Märk som ingående balans" re-tag action.
const [markLoading, setMarkLoading] = useState<string | null>(null)
const [actionLoading, setActionLoading] = useState<string | null>(null)
// Booking-time duplicate guard (TRANSACTION_BOOK_POSSIBLE_DUPLICATE) fired
// for a quick-book: opened as the shared match/ignore/book-anyway dialog
// instead of a dead-end toast; this page's whole purpose is matching.
const [duplicateWarning, setDuplicateWarning] = useState<{
transactionId: string
retry: () => Promise<void>
candidate: BookedDuplicateCandidate
} | null>(null)
const [duplicateProcessing, setDuplicateProcessing] = useState(false)
// Opt-in: also surface vouchers already matched to a bank transaction as
// candidates, so a second/third transaction can be attached to the same
@@ -797,7 +808,13 @@ export function BankReconciliationView({ periodId, periodBounds, autoRun }: Bank
* leg to the transaction's actual settlement account, so this is correct on
* any cash account.
*/
const handleQuickBook = async (transactionId: string, templateId: string) => {
const handleQuickBook = async (
transactionId: string,
templateId: string,
// Set after the user confirmed the duplicate warning: force is bound to
// the reviewed candidate's voucher and re-detected server-side.
forceOpts?: { expectedDuplicateJournalEntryId: string },
) => {
setActionLoading(transactionId)
try {
const res = await fetch(`/api/transactions/${transactionId}/categorize`, {
@@ -807,10 +824,29 @@ export function BankReconciliationView({ periodId, periodBounds, autoRun }: Bank
is_business: true,
template_id: templateId,
confirm_no_match: true,
...(forceOpts
? { force: true, expected_duplicate_journal_entry_id: forceOpts.expectedDuplicateJournalEntryId }
: {}),
}),
})
const result = await res.json()
if (!res.ok || result.error) {
const candidate = result?.error?.details?.candidate as BookedDuplicateCandidate | undefined
if (result?.error?.code === 'TRANSACTION_BOOK_POSSIBLE_DUPLICATE' && candidate) {
// The affärshändelse already looks booked. On the reconciliation
// page the right resolutions (match the voucher, ignore a duplicate
// import, or book anyway) all live in the shared dialog: never
// dead-end in a toast with no way forward.
setDuplicateWarning({
transactionId,
retry: () =>
handleQuickBook(transactionId, templateId, {
expectedDuplicateJournalEntryId: candidate.journal_entry_id,
}),
candidate,
})
return
}
toast({
variant: 'destructive',
title: 'Kunde inte bokföra transaktionen',
@@ -1602,6 +1638,43 @@ export function BankReconciliationView({ periodId, periodBounds, autoRun }: Bank
/>
)}
{duplicateWarning && (
<DuplicateBookingDialog
candidate={duplicateWarning.candidate}
processing={duplicateProcessing}
onCancel={() => setDuplicateWarning(null)}
matchTransaction={{
id: duplicateWarning.transactionId,
// The view is scoped to one ledger account; resolve its cash
// account so the match links on the account being reconciled.
cash_account_id: cashAccounts.find((a) => a.ledger_account === accountNumber)?.id ?? null,
currency:
unmatchedTx.find((t) => t.id === duplicateWarning.transactionId)?.currency ??
accountCurrency,
}}
onMatched={async () => {
setDuplicateWarning(null)
toast({ variant: 'success', title: 'Transaktionen matchades mot verifikatet' })
await fetchAll({ silent: true })
}}
onIgnored={async () => {
setDuplicateWarning(null)
toast({ variant: 'success', title: 'Transaktionen ignorerad' })
await fetchAll({ silent: true })
}}
onBookAnyway={async () => {
const retry = duplicateWarning?.retry
setDuplicateProcessing(true)
try {
setDuplicateWarning(null)
if (retry) await retry()
} finally {
setDuplicateProcessing(false)
}
}}
/>
)}
<DestructiveConfirmDialog {...confirmDialogProps} />
</div>
)
+39 -2
View File
@@ -25,8 +25,10 @@ import { getErrorMessage } from '@/lib/errors/get-error-message'
import { applyTemplate } from '@/lib/bookkeeping/template-library'
import { formatCurrency, formatDate, cn } from '@/lib/utils'
import LineDimensionFields from '@/components/dimensions/LineDimensionFields'
import DuplicateBookingDialog from '@/components/transactions/DuplicateBookingDialog'
import { Loader2, FileText, AlertTriangle, Check, Plus, Trash2, Paperclip } from 'lucide-react'
import type { BookingTemplateLibrary, BookingTemplateLibraryLine } from '@/types'
import type { BookedDuplicateCandidate } from '@/lib/transactions/booking-duplicate-detection'
import type { TransactionWithInvoice } from './transaction-types'
interface BulkBookDialogProps {
@@ -90,6 +92,12 @@ export default function BulkBookDialog({
const [description, setDescription] = useState('')
const [manualLines, setManualLines] = useState<ManualLine[]>([])
const [submitting, setSubmitting] = useState(false)
// Booking-time duplicate guard fired for one of the selected txs
// (TRANSACTION_BOOK_POSSIBLE_DUPLICATE): surface the candidate for review
// with "Bokför ändå" (re-runs the whole batch with force=true) instead of
// dead-ending in a toast. Match/ignore are not offered here: resolving one
// row differently belongs on the transaction list, outside the batch.
const [duplicateCandidate, setDuplicateCandidate] = useState<BookedDuplicateCandidate | null>(null)
// Dimension tagging (kostnadsställe/projekt): the pair renders only when
// company_settings.dimensions_enabled, same gate as JournalEntryForm. One
// header-level default bag applies to both tabs; the server tags the
@@ -384,8 +392,11 @@ export default function BulkBookDialog({
])
}
async function handleConfirm() {
// `opts` is only ever passed by the duplicate-dialog retry; the footer
// button's onClick hands over a click event, which carries no `force`.
async function handleConfirm(opts?: { force?: boolean }) {
if (!canConfirm) return
const force = opts?.force === true
setSubmitting(true)
try {
// Build the payload per the active tab. Template path uses the
@@ -410,6 +421,7 @@ export default function BulkBookDialog({
line_description: l.line_description ?? undefined,
})),
...defaultDimensions,
...(force ? { force: true } : {}),
}
: {
tx_ids: transactions.map((tx) => tx.id),
@@ -417,6 +429,7 @@ export default function BulkBookDialog({
mode,
entry_description: description.trim(),
...defaultDimensions,
...(force ? { force: true } : {}),
}
const response = await fetch('/api/transactions/bulk-book', {
method: 'POST',
@@ -425,6 +438,14 @@ export default function BulkBookDialog({
})
if (!response.ok) {
const body = await response.json().catch(() => null)
const candidate = body?.error?.details?.candidate as BookedDuplicateCandidate | undefined
if (body?.error?.code === 'TRANSACTION_BOOK_POSSIBLE_DUPLICATE' && candidate) {
// One of the selected txs already looks booked: open the review
// dialog instead of a dead-end toast. "Bokför ändå" re-runs the
// batch with force=true.
setDuplicateCandidate(candidate)
return
}
toast({
title: t('error_title'),
description: getErrorMessage(body, { statusCode: response.status }),
@@ -860,12 +881,28 @@ export default function BulkBookDialog({
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={submitting}>
{t('cancel')}
</Button>
<Button onClick={handleConfirm} disabled={!canConfirm}>
<Button onClick={() => handleConfirm()} disabled={!canConfirm}>
{submitting && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
{t('confirm')}
</Button>
</DialogFooter>
</DialogContent>
{/* Booking-time duplicate guard review. Rendered inside the bulk dialog
so cancelling it returns to the batch as-is; "Bokför ändå" re-runs
the whole batch with force=true (the server re-detects and records
the dismissal in behandlingshistorik). */}
{duplicateCandidate && (
<DuplicateBookingDialog
candidate={duplicateCandidate}
processing={submitting}
onCancel={() => setDuplicateCandidate(null)}
onBookAnyway={() => {
setDuplicateCandidate(null)
void handleConfirm({ force: true })
}}
/>
)}
</Dialog>
)
}
@@ -41,9 +41,17 @@ export interface DuplicateMatchTransaction {
* 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.
* "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,
@@ -52,6 +60,7 @@ export default function DuplicateBookingDialog({
onCancel,
matchTransaction,
onMatched,
onIgnored,
}: {
/** The already-booked sibling, or null to keep the dialog closed. */
candidate: BookedDuplicateCandidate | null
@@ -66,17 +75,29 @@ export default function DuplicateBookingDialog({
* 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)
// 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
// 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
@@ -137,7 +158,35 @@ export default function DuplicateBookingDialog({
}
}
const busy = processing || matching
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
@@ -151,7 +200,17 @@ export default function DuplicateBookingDialog({
<DialogTitle>{t('dialog_duplicate_title')}</DialogTitle>
</DialogHeader>
<div className="space-y-4">
<p className="text-sm text-muted-foreground">{t('dialog_duplicate_body')}</p>
{/* 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">
@@ -240,6 +299,16 @@ export default function DuplicateBookingDialog({
<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')}
@@ -41,8 +41,9 @@ describe('transactions page booking feedback', () => {
it('routes every successful booking through it', () => {
// runCategorize (category / catalog template / library template), the
// counterparty-template booking, and the counterparty activate-and-retry.
expect(PAGE_SRC.match(/finishBooking\(\{/g) ?? []).toHaveLength(3)
// counterparty-template booking, the counterparty activate-and-retry, and
// the counterparty duplicate-warning "Bokför ändå" retry.
expect(PAGE_SRC.match(/finishBooking\(\{/g) ?? []).toHaveLength(4)
})
it('no longer ends the counterparty path on a bare exitingIds add', () => {
@@ -74,10 +75,11 @@ describe('transactions page booking feedback', () => {
it('decrements the unbooked count on every path that removes a row', () => {
// finishBooking, handleTransactionBooked (manual booking dialog / voucher
// match), and the three other single-row exits already on the page.
// match), the three other single-row exits already on the page, and the
// duplicate-dialog "Ignorera transaktionen" tail.
expect(
PAGE_SRC.match(/setTotalUncategorizedCount\(\(prev\) => Math\.max\(0, \(prev \?\? 1\) - 1\)\)/g) ?? [],
).toHaveLength(5)
).toHaveLength(6)
})
it('ships the undo strings it renders in both locales', () => {
@@ -96,3 +98,43 @@ describe('transactions page booking feedback', () => {
}
})
})
/**
* Duplicate-guard feedback parity: every client of POST /categorize must route
* a TRANSACTION_BOOK_POSSIBLE_DUPLICATE 409 into DuplicateBookingDialog (which
* offers match / ignore / book-anyway), never into a destructive toast that
* names no way forward. Two clients used to dead-end: the counterparty-
* template branch of handleQuickReviewConfirm, and BankReconciliationView's
* quick-book.
*/
const BANK_RECON_SRC = fs.readFileSync(
path.resolve(__dirname, '../../reports/BankReconciliationView.tsx'),
'utf8',
)
describe('duplicate-guard 409 routing parity', () => {
it('handles the duplicate code on both booking paths of the transactions page', () => {
// runCategorize AND the counterparty-template branch.
expect(
PAGE_SRC.match(/error\?\.code === 'TRANSACTION_BOOK_POSSIBLE_DUPLICATE'/g) ?? [],
).toHaveLength(2)
})
it('opens the dialog from the counterparty branch with a force-bound retry', () => {
// The branch must set the shared duplicateWarning state (the dialog) and
// bind the retry to the reviewed candidate's voucher.
expect(PAGE_SRC).toMatch(
/TRANSACTION_BOOK_POSSIBLE_DUPLICATE'[\s\S]{0,600}cpCategorize\(\{\s*\n?\s*expectedDuplicateJournalEntryId: candidate\.journal_entry_id,/,
)
})
it('routes the quick-book 409 on the reconciliation page into the shared dialog', () => {
expect(BANK_RECON_SRC).toContain("'TRANSACTION_BOOK_POSSIBLE_DUPLICATE'")
expect(BANK_RECON_SRC).toContain('<DuplicateBookingDialog')
expect(BANK_RECON_SRC).toMatch(/setDuplicateWarning\(\{/)
// The retry re-runs the quick-book with force bound to the candidate.
expect(BANK_RECON_SRC).toMatch(
/handleQuickBook\(transactionId, templateId, \{\s*\n?\s*expectedDuplicateJournalEntryId: candidate\.journal_entry_id,/,
)
})
})
@@ -83,3 +83,62 @@ describe('DuplicateBookingDialog rateless-sibling warning', () => {
}
})
})
describe('DuplicateBookingDialog sibling candidates', () => {
it('no longer gates the match action on transaction_id being null', () => {
// manualLink explicitly allows N:1 (a second bank line on one voucher), so
// sibling-transaction candidates must get "Matcha mot verifikatet" too:
// hiding it dead-ended the user in "Bokför ändå".
expect(BOOKING_DIALOG_SRC).toMatch(
/const canMatch = candidate !== null && !!matchTransaction && !!onMatched/,
)
expect(BOOKING_DIALOG_SRC).not.toMatch(/canMatch =[\s\S]{0,120}transaction_id === null/)
})
it('offers Ignorera for sibling candidates via the existing ignore endpoint', () => {
// The sibling shape is very often a duplicate IMPORT of one real movement;
// ignoring the row is then the correct resolution (matching would
// double-count the bank side, booking would double-count the ledger side).
expect(BOOKING_DIALOG_SRC).toMatch(/\/api\/transactions\/\$\{matchTransaction\.id\}\/ignore/)
expect(BOOKING_DIALOG_SRC).toContain("t('dialog_duplicate_ignore')")
// Gated on a sibling candidate: never shown for ledger-only vouchers.
expect(BOOKING_DIALOG_SRC).toMatch(
/canIgnore = isSiblingCandidate && !!matchTransaction && !!onIgnored/,
)
})
it('switches the body copy to the "matcha i stället" question for sibling candidates', () => {
expect(BOOKING_DIALOG_SRC).toContain("t('dialog_duplicate_body_sibling')")
expect(BOOKING_DIALOG_SRC).toMatch(
/isSiblingCandidate \? t\('dialog_duplicate_body_sibling'\) : t\('dialog_duplicate_body'\)/,
)
})
it('ships the sibling strings in both locales, without dashes', () => {
for (const locale of ['sv', 'en'] as const) {
const messages = readMessages(locale, 'transactions')
for (const key of [
'dialog_duplicate_body_sibling',
'dialog_duplicate_ignore',
'dialog_duplicate_ignore_hint',
'dialog_duplicate_ignore_failed',
]) {
expect(messages[key], `${locale}.transactions.${key}`).toBeTruthy()
expect(messages[key]).not.toMatch(/–|—/)
}
// The ignore guidance lives in the gated hint, never in the body: two
// render sites (manual booking form, bulk dialog) show sibling
// candidates without the ignore action, and body copy must not point at
// a button that is not there.
expect(messages.dialog_duplicate_body_sibling).not.toMatch(/ignorera|ignore it/i)
}
// The requested copy pattern: an offer to match instead, in question form.
expect(readMessages('sv', 'transactions').dialog_duplicate_body_sibling).toMatch(
/matcha[\s\S]*i stället/i,
)
})
it('renders the ignore hint only when the ignore action is offered', () => {
expect(BOOKING_DIALOG_SRC).toMatch(/canIgnore && <> \{t\('dialog_duplicate_ignore_hint'\)\}<\/>/)
})
})