* 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>
145 lines
6.5 KiB
TypeScript
145 lines
6.5 KiB
TypeScript
import { describe, it, expect } from 'vitest'
|
||
import fs from 'node:fs'
|
||
import path from 'node:path'
|
||
|
||
/**
|
||
* The duplicate-payment warning panel of the invoice-match dialog.
|
||
*
|
||
* Two bugs pinned here:
|
||
*
|
||
* 1. `candidate.amount` is the voucher leg's SEK debit
|
||
* (lib/invoices/duplicate-payment-detection.ts), but the dialog formatted
|
||
* it in the TRANSACTION's currency: an 11 500 kr leg on a EUR bank line
|
||
* printed as "11 500,00 EUR".
|
||
* 2. When the bank line had no stored SEK value the detector's amount test
|
||
* never ran, yet the candidate came back labelled 'exact_amount_*' and the
|
||
* dialog claimed "på samma belopp" for amounts that were never compared.
|
||
* The detector now returns reason 'date_window_only' + amount_verified:
|
||
* false for that shape, and the dialog renders copy that does not claim an
|
||
* amount match.
|
||
*
|
||
* This repo runs Vitest in the `node` environment and never renders
|
||
* components, so like the sibling invoice-match-dialog-fx test these are
|
||
* file-level assertions: the source must not carry the buggy expression, and
|
||
* the strings it renders must exist in both locales.
|
||
*/
|
||
|
||
const DIALOG_SRC = fs.readFileSync(path.resolve(__dirname, '../InvoiceMatchDialog.tsx'), 'utf8')
|
||
const BOOKING_DIALOG_SRC = fs.readFileSync(
|
||
path.resolve(__dirname, '../DuplicateBookingDialog.tsx'),
|
||
'utf8',
|
||
)
|
||
const readMessages = (locale: 'sv' | 'en', namespace: string) =>
|
||
(
|
||
JSON.parse(
|
||
fs.readFileSync(path.resolve(__dirname, `../../../messages/${locale}.json`), 'utf8'),
|
||
) as Record<string, Record<string, string>>
|
||
)[namespace]
|
||
|
||
describe('InvoiceMatchDialog duplicate-candidate rendering', () => {
|
||
it('formats the voucher leg amount as SEK, never in the transaction currency', () => {
|
||
expect(DIALOG_SRC).not.toMatch(/formatCurrency\(candidate\.amount,\s*transaction\.currency\)/)
|
||
expect(DIALOG_SRC).toMatch(/formatCurrency\(candidate\.amount,\s*'SEK'\)/)
|
||
})
|
||
|
||
it('branches to unverified copy on date_window_only / amount_verified false', () => {
|
||
expect(DIALOG_SRC).toContain("'date_window_only'")
|
||
expect(DIALOG_SRC).toContain('amount_verified')
|
||
expect(DIALOG_SRC).toContain("t('duplicate_body_unverified'")
|
||
})
|
||
|
||
it('ships the unverified copy in both locales without claiming an amount match', () => {
|
||
for (const locale of ['sv', 'en'] as const) {
|
||
const messages = readMessages(locale, 'tx_invoice_match')
|
||
const copy = messages.duplicate_body_unverified
|
||
expect(copy).toBeTruthy()
|
||
// next-intl throws on an unsupplied placeholder, so the parameter names
|
||
// must match what the dialog passes.
|
||
expect(copy).toContain('{label}')
|
||
expect(copy).toContain('{amount}')
|
||
expect(copy).toContain('{date}')
|
||
// The whole point of the branch: no "same amount" claim.
|
||
expect(copy).not.toMatch(/samma belopp|same amount/i)
|
||
}
|
||
})
|
||
})
|
||
|
||
describe('DuplicateBookingDialog rateless-sibling warning', () => {
|
||
it('gates the currency-naming warning on candidate.currency being present', () => {
|
||
// t('dialog_duplicate_sek_unavailable', { currency }) interpolates the
|
||
// currency into the sentence; an empty string renders broken Swedish, so
|
||
// the branch must not fire for a null-currency candidate.
|
||
expect(BOOKING_DIALOG_SRC).toMatch(
|
||
/candidate\.amount == null && candidate\.currency &&/,
|
||
)
|
||
expect(BOOKING_DIALOG_SRC).not.toMatch(/candidate\.currency \?\? ''/)
|
||
})
|
||
|
||
it('keeps the warning strings in both locales', () => {
|
||
for (const locale of ['sv', 'en'] as const) {
|
||
const messages = readMessages(locale, 'transactions')
|
||
expect(messages.dialog_duplicate_sek_unavailable).toContain('{currency}')
|
||
expect(messages.dialog_duplicate_amount_unknown).toBeTruthy()
|
||
}
|
||
})
|
||
})
|
||
|
||
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'\)\}<\/>/)
|
||
})
|
||
})
|