transactions: merge/supersede a duplicate by hand, and widen the picker band
ci / lint-test (push) Successful in 55s
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:
@@ -0,0 +1,175 @@
|
||||
import { describe, it, expect, beforeAll, beforeEach, vi } from "vitest";
|
||||
import { Pool } from "pg";
|
||||
import { createPool, resetDB, seedParticipants, insertTransaction } from "./helpers";
|
||||
|
||||
/**
|
||||
* Manually resolving a duplicate the automation cannot see.
|
||||
*
|
||||
* Until this route existed there was no way to merge or remove a transaction at
|
||||
* all: `superseded_by_id` was read everywhere and written nowhere in
|
||||
* application code, having been set exactly once by migration 0023. The only
|
||||
* merge path was the reconcile queue, whose candidate list is bounded by an
|
||||
* amount band -- so SP PEAKDESIGN (notified $42.44, charged $43.71, the card's
|
||||
* 3% foreign transaction fee) had no offerable twin.
|
||||
*
|
||||
* The refusals matter more than the happy paths, exactly as in
|
||||
* auto-reconcile.test.ts: this writes to columns that hide a row from every
|
||||
* figure, so a wrong call does not error, it quietly stops counting money.
|
||||
*/
|
||||
|
||||
let pool: Pool;
|
||||
let ownerId: number;
|
||||
let otherId: number;
|
||||
let merge: typeof import("../../app/api/transactions/[id]/merge/route").POST;
|
||||
|
||||
const req = (body: unknown) => ({ json: async () => body, headers: { get: () => null } }) as never;
|
||||
const call = (id: number, body: unknown) => merge(req(body), { params: Promise.resolve({ id: String(id) }) });
|
||||
|
||||
// uq_statement_identity is (bank_name, account_number, billing_end_date), so
|
||||
// every statement in a test needs its own account or the second insert collides.
|
||||
let acctSeq = 0;
|
||||
|
||||
async function statementLine(amount: number, date: string, description: string) {
|
||||
const st = await pool.query(
|
||||
`INSERT INTO statements (filename, account_number, bank_name, billing_start_date, billing_end_date, owner_id)
|
||||
VALUES ('s.pdf', $3, 'Test Bank', $1::date - 20, $1::date + 10, $2) RETURNING id`,
|
||||
[date, ownerId, `4444-0000-0000-${String(1000 + acctSeq++)}`]
|
||||
);
|
||||
const t = await pool.query(
|
||||
`INSERT INTO transactions (statement_id, transaction_date, description, amount, transaction_type, owner_id)
|
||||
VALUES ($1, $2, $3, $4, 'debit', $5) RETURNING id`,
|
||||
[st.rows[0].id, date, description, amount, ownerId]
|
||||
);
|
||||
return t.rows[0].id as number;
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
pool = createPool();
|
||||
vi.doMock("@/lib/auth", () => ({
|
||||
getCurrentUser: async () => ({ id: ownerId, name: "Alice", email: "alice@example.com" }),
|
||||
}));
|
||||
({ POST: merge } = await import("../../app/api/transactions/[id]/merge/route"));
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await resetDB(pool);
|
||||
await pool.query("DELETE FROM expense_metadata");
|
||||
({ ownerId, otherId } = await seedParticipants(pool));
|
||||
});
|
||||
|
||||
describe("reconcile mode", () => {
|
||||
it("folds a wallet row into its statement twin even though the amounts differ by the FX fee", async () => {
|
||||
const wallet = await insertTransaction(pool, ownerId, {
|
||||
description: "SP PEAKDESIGN", amount: 42.44, transaction_date: "2026-08-16",
|
||||
});
|
||||
const stmt = await statementLine(43.71, "2026-08-15", "SP PEAKDESIGN SAN FRANCISCO USA");
|
||||
|
||||
const res = await call(wallet, { other_id: stmt, mode: "reconcile" });
|
||||
expect(res.status ?? 200).toBe(200);
|
||||
|
||||
const r = await pool.query(`SELECT reconciled_with_id FROM transactions WHERE id = $1`, [wallet]);
|
||||
expect(r.rows[0].reconciled_with_id).toBe(stmt);
|
||||
});
|
||||
|
||||
it("refuses when the target is not a statement row", async () => {
|
||||
const a = await insertTransaction(pool, ownerId, { amount: 10 });
|
||||
const b = await insertTransaction(pool, ownerId, { amount: 10 });
|
||||
const res = await call(a, { other_id: b, mode: "reconcile" });
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it("refuses when the source is itself a statement row", async () => {
|
||||
const s1 = await statementLine(10, "2026-08-01", "A");
|
||||
const s2 = await statementLine(10, "2026-08-02", "B");
|
||||
const res = await call(s1, { other_id: s2, mode: "reconcile" });
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
});
|
||||
|
||||
describe("supersede mode", () => {
|
||||
it("carries splits onto the survivor before hiding the loser", async () => {
|
||||
// The whole reason supersede moves curation: `superseded_by_id` removes the
|
||||
// row from every figure, so a split left behind stops counting silently and
|
||||
// a settled debt reappears as owed.
|
||||
const loser = await insertTransaction(pool, ownerId, { amount: 80, description: "DUP" });
|
||||
const survivor = await insertTransaction(pool, ownerId, { amount: 80, description: "KEEP" });
|
||||
await pool.query(
|
||||
`INSERT INTO transaction_splits (transaction_id, participant_id, share_percent, settled)
|
||||
VALUES ($1,$2,50,true), ($1,$3,50,false)`,
|
||||
[loser, ownerId, otherId]
|
||||
);
|
||||
|
||||
const res = await call(loser, { other_id: survivor, mode: "supersede" });
|
||||
expect(res.status ?? 200).toBe(200);
|
||||
|
||||
const onLoser = await pool.query(`SELECT count(*)::int c FROM transaction_splits WHERE transaction_id=$1`, [loser]);
|
||||
const onSurvivor = await pool.query(
|
||||
`SELECT participant_id, share_percent, settled FROM transaction_splits
|
||||
WHERE transaction_id=$1 ORDER BY participant_id`, [survivor]
|
||||
);
|
||||
expect(onLoser.rows[0].c).toBe(0);
|
||||
expect(onSurvivor.rows).toHaveLength(2);
|
||||
// settled must survive the move -- a delete-and-recreate defaults it to false.
|
||||
expect(onSurvivor.rows.map((r) => r.settled)).toContain(true);
|
||||
|
||||
const marked = await pool.query(`SELECT superseded_by_id FROM transactions WHERE id=$1`, [loser]);
|
||||
expect(marked.rows[0].superseded_by_id).toBe(survivor);
|
||||
});
|
||||
|
||||
it("works between two statement rows, which reconcile cannot express", async () => {
|
||||
const s1 = await statementLine(25, "2026-08-01", "IMPORTED TWICE");
|
||||
const s2 = await statementLine(25, "2026-08-01", "IMPORTED TWICE");
|
||||
const res = await call(s1, { other_id: s2, mode: "supersede" });
|
||||
expect(res.status ?? 200).toBe(200);
|
||||
const r = await pool.query(`SELECT superseded_by_id FROM transactions WHERE id=$1`, [s1]);
|
||||
expect(r.rows[0].superseded_by_id).toBe(s2);
|
||||
});
|
||||
|
||||
it("refuses to hide a statement row behind a statementless one", async () => {
|
||||
// The wrong direction, and silently wrong: a wallet row holds the amount
|
||||
// BEFORE the card's foreign transaction fee, so burying the statement line
|
||||
// under it leaves the ledger holding $42.44 for a $43.71 charge.
|
||||
const stmt = await statementLine(43.71, "2026-08-15", "SP PEAKDESIGN SAN FRANCISCO USA");
|
||||
const wallet = await insertTransaction(pool, ownerId, {
|
||||
description: "SP PEAKDESIGN", amount: 42.44, transaction_date: "2026-08-16",
|
||||
});
|
||||
const res = await call(stmt, { other_id: wallet, mode: "supersede" });
|
||||
expect(res.status).toBe(400);
|
||||
// and the statement row is untouched
|
||||
const r = await pool.query(`SELECT superseded_by_id FROM transactions WHERE id=$1`, [stmt]);
|
||||
expect(r.rows[0].superseded_by_id).toBeNull();
|
||||
});
|
||||
|
||||
it("refuses to bury a row under one that is already superseded", async () => {
|
||||
const a = await insertTransaction(pool, ownerId, { amount: 5 });
|
||||
const b = await insertTransaction(pool, ownerId, { amount: 5 });
|
||||
const c = await insertTransaction(pool, ownerId, { amount: 5 });
|
||||
await call(b, { other_id: c, mode: "supersede" }); // b -> c
|
||||
const res = await call(a, { other_id: b, mode: "supersede" }); // a -> b, but b is buried
|
||||
expect(res.status).toBe(409);
|
||||
});
|
||||
});
|
||||
|
||||
describe("refusals common to both modes", () => {
|
||||
it("refuses a transaction merging into itself", async () => {
|
||||
const a = await insertTransaction(pool, ownerId, { amount: 1 });
|
||||
const res = await call(a, { other_id: a, mode: "supersede" });
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it("refuses a row that is already reconciled", async () => {
|
||||
const wallet = await insertTransaction(pool, ownerId, { amount: 12, transaction_date: "2026-08-10" });
|
||||
const stmt = await statementLine(12, "2026-08-10", "X");
|
||||
await call(wallet, { other_id: stmt, mode: "reconcile" });
|
||||
const other = await insertTransaction(pool, ownerId, { amount: 12 });
|
||||
const res = await call(wallet, { other_id: other, mode: "supersede" });
|
||||
expect(res.status).toBe(409);
|
||||
});
|
||||
|
||||
it("refuses a transaction belonging to someone else", async () => {
|
||||
const mine = await insertTransaction(pool, ownerId, { amount: 7 });
|
||||
const theirs = await insertTransaction(pool, otherId, { amount: 7 });
|
||||
const res = await call(mine, { other_id: theirs, mode: "supersede" });
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user