Files
accounted/components/transactions/__tests__/booking-feedback-parity.test.ts
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

141 lines
5.9 KiB
TypeScript

import { describe, it, expect } from 'vitest'
import fs from 'node:fs'
import path from 'node:path'
/**
* Booking-feedback parity between the two booking paths on the transactions
* page.
*
* The counterparty-template path (Bokför → "Tidigare motparter") used to end a
* successful booking with nothing but `setExitingIds().add(id)`: no "Bokförd"
* toast, no Ångra action, no unbooked-count decrement, and the id was never
* removed from exitingIds again, so an undo would have restored the row's data
* while leaving it filtered out of the inbox. Every other booking path ran
* runCategorize's success tail.
*
* Both now go through one `finishBooking`. This repo runs Vitest in the `node`
* environment and never renders components, so, like the sibling
* invoice-match-dialog tests, these are file-level assertions: the two paths
* must not drift apart again.
*/
const PAGE_SRC = fs.readFileSync(
path.resolve(__dirname, '../../../app/(dashboard)/transactions/page.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('transactions page booking feedback', () => {
it('defines exactly one success tail', () => {
expect(PAGE_SRC).toContain('function finishBooking(')
// One undo implementation, and one place that calls the storno endpoint.
expect(PAGE_SRC.match(/altText="Ångra kategorisering"/g) ?? []).toHaveLength(1)
expect(PAGE_SRC.match(/uncategorize`, \{ method: 'POST' \}/g) ?? []).toHaveLength(1)
})
it('routes every successful booking through it', () => {
// runCategorize (category / catalog template / library template), the
// 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', () => {
// The old tail: setExitingIds(...) immediately followed by `journalEntryId = cpJeId`.
expect(PAGE_SRC).not.toMatch(
/setExitingIds\(\(prev\) => new Set\(prev\)\.add\(id\)\)\s*\n\s*journalEntryId = cpJeId/,
)
})
it('clears the id from exitingIds so an undo puts the row back', () => {
// Without the delete, an undone booking restores is_business: null but the
// row stays filtered out of the inbox (see uncategorizedTransactions).
expect(PAGE_SRC).toMatch(/next\.delete\(id\)/)
})
it('lets a completed undo win over the delayed booked-state patch', () => {
// The 350ms animation timer must not re-apply journal_entry_id after an
// Ångra has already storno-reversed the verifikat server-side.
expect(PAGE_SRC).toMatch(/let undone = false/)
expect(PAGE_SRC).toMatch(/undone = true/)
expect(PAGE_SRC).toMatch(/if \(!undone\) \{/)
})
it('clears only the finished row\'s spinner', () => {
// The shared tail runs for rows that never set processingId; an
// unconditional clear would wipe an unrelated in-flight row.
expect(PAGE_SRC).toMatch(/setProcessingId\(\(prev\) => \(prev === id \? null : prev\)\)/)
})
it('decrements the unbooked count on every path that removes a row', () => {
// finishBooking, handleTransactionBooked (manual booking dialog / voucher
// 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(6)
})
it('ships the undo strings it renders in both locales', () => {
for (const locale of ['sv', 'en'] as const) {
const messages = readMessages(locale, 'transactions')
for (const key of [
'undone_title',
'undone_description',
'undo_failed_title',
'undo_failed_description',
'partially_booked_title',
'partially_booked_description',
]) {
expect(messages[key], `${locale}.transactions.${key}`).toBeTruthy()
}
}
})
})
/**
* 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,/,
)
})
})