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.
This commit is contained in:
2026-08-23 19:00:57 +10:00
parent 2251060ac6
commit 10cf0aaf21
8 changed files with 694 additions and 6 deletions
+44
View File
@@ -224,6 +224,50 @@ Three details worth knowing before relying on it:
`TRUNCATE` does not fire row-level triggers, so the integration suite's table
wipes produce no audit rows.
### Resolving a duplicate by hand (`POST /api/transactions/<id>/merge`)
`<id>` is the row that gets **hidden**; `other_id` is the one that **survives**.
| mode | requires | sets | for |
|---|---|---|---|
| `reconcile` | loser statementless, survivor a statement row | `reconciled_with_id` | the everyday case |
| `supersede` | nothing | `superseded_by_id` | two rows on the same footing, e.g. a statement imported twice |
**There is no delete, deliberately.** Every child of `transactions` is
`ON DELETE CASCADE`, so removing a row destroys the splits, tags and metadata
that make it legible. Both modes keep the row and its children and merely stop
it counting. Do not add a delete endpoint.
Both modes **move curation onto the survivor first**, through the single
`moveCurationWithin()` in `lib/reconcile.ts` — because both hide the source row
from every figure, and a split stranded on a hidden row stops counting in
silence. That is also why the two callers share one implementation: the file
header warns that a second copy would eventually move a different set.
Refusals (all deliberate, all tested in `merge-supersede.test.ts`): a row already
reconciled or superseded on **either** side → 409 (re-pointing it strands the
curation moved on the first pass); superseding under a row that is itself
superseded → 409; a row that is not the caller's → 404. Ownership is checked with
`COALESCE(t.owner_id, s.owner_id)` — a statement row carries `owner_id` on the
statement, so checking `t.owner_id` alone rejects every statement row as "not
found".
**Why it had to exist.** `superseded_by_id` was read everywhere (analytics and
auto-reconcile both exclude it) but written **nowhere** in application code — set
exactly once, by migration 0023. The reconcile queue was the only merge path, and
it only offers candidates inside an amount band.
**The band is ±5%, not ±1% (2026-08-23).** 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.
`SP PEAKDESIGN` was notified at **$42.44** and charged **$43.71** (×1.03), which
put the one row that *was* its twin outside the old band: the picker could not
offer the only correct answer. Widening is 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 at the time of the change: candidate counts went **0 → 0** on all 12
rows, i.e. no new noise. **The auto-matcher stays exact** — see `findAutoMatches()`.
### Cash and reconciliation
`payment_method` in (`cash`, `credits`) excludes a transaction from