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); }); });