Commit Graph
1 Commits
Author SHA1 Message Date
siddharthd 10cf0aaf21 transactions: merge/supersede a duplicate by hand, and widen the picker band
ci / lint-test (push) Successful in 55s
Two gaps, found because SP PEAKDESIGN was sitting duplicated with no way to
resolve it.

1. THE PICKER COULD NOT OFFER THE RIGHT ANSWER. The manual reconcile queue's
   candidate band was `amount * 0.99 .. 1.01`. A foreign card purchase is
   charged the notified amount PLUS the card's foreign transaction fee -- 3% on
   the Westpac Altitude -- so a wallet row and its statement line can never
   match exactly: notified $42.44, charged $43.71, x1.03. The one row that WAS
   its twin was the single row outside the band. Widened to +-5%. Safe because
   the list is capped at 5 by the rn window and ordered by amount closeness, so
   a looser band changes which five appear, never how many, and a person
   confirms each. Measured on the live queue: candidate counts 0 -> 0 on all 12
   rows, i.e. no new noise. The auto-matcher stays exact.

2. THERE WAS NO MANUAL MERGE OR DELETE AT ALL. `superseded_by_id` was read
   everywhere (analytics and auto-reconcile both exclude it) and written
   NOWHERE in application code -- set exactly once, by migration 0023.

POST /api/transactions/<id>/merge takes {other_id, mode}: `reconcile` folds a
statementless row into its statement twin, `supersede` handles two rows on the
same footing (a statement imported twice). Deliberately NO delete -- children
are ON DELETE CASCADE, so deleting destroys the splits, tags and metadata that
make a row legible. Both modes keep the row and merely stop it counting.

Both move curation onto the survivor FIRST, through one extracted
moveCurationWithin() rather than a second copy: reconcile.ts's own header warns
that a duplicate would eventually move a different set, and a split stranded on
a hidden row stops counting in silence.

Refuses, all tested: hiding a statement row behind a statementless one (the
bank's record is the side that survives -- burying it under a wallet row leaves
the ledger holding the pre-fee amount); either side already reconciled or
superseded (409, since re-pointing strands the curation moved on the first
pass); burying under an already-superseded row; self-merge; wrong mode for the
pair; someone else's row (ownership via COALESCE(t.owner_id, s.owner_id),
because a statement row carries owner_id on the statement).

UI: a collapsed "Mark as duplicate..." section at the bottom of the edit modal,
debounced search over the existing transactions endpoint, statement-vs-
statementless badges, mode chosen automatically and stated in plain language,
and the wrong-direction case explained with the button disabled rather than
letting it 400.

Also: resetDB now truncates transaction_audit -- the 0037 triggers fire on every
insert the suite makes, so without it the test DB grew ~1,100 rows per full run,
forever (1,125 -> 2).

329 integration + 196 unit green, tsc clean, lint error-free, build compiles.
Board #260.
2026-08-23 19:00:57 +10:00