diff --git a/src/__tests__/integration/reconcile-queue.test.ts b/src/__tests__/integration/reconcile-queue.test.ts new file mode 100644 index 0000000..75e8c5c --- /dev/null +++ b/src/__tests__/integration/reconcile-queue.test.ts @@ -0,0 +1,69 @@ +import { describe, it, expect, beforeAll, beforeEach } from "vitest"; +import { Pool } from "pg"; +import { createPool, resetDB, seedParticipants, insertTransaction } from "./helpers"; +import { getPendingReconciliations } from "../../lib/queries"; + +/** + * The reconcile queue must not offer what is already resolved. + * + * A superseded row is hidden from every figure (EXCLUDE_RECONCILED_SOURCE), so + * listing it is not merely noise: on 2026-10-03 the queue showed the duplicate + * wallet half 7037, superseded under its twin 7038, as a second "Digidirect" + * with the same statement line offered as its match. Accepting it there would + * have spent that line on a hidden row and left the real one unmatched forever. + * findAutoMatches() always had both filters; this query had neither. + */ + +let pool: Pool; +let ownerId: number; +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 ('q.pdf', $3, 'Test Bank', $1::date - 20, $1::date + 10, $2) RETURNING id`, + [date, ownerId, `5555-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(() => { pool = createPool(); }); + +beforeEach(async () => { + await resetDB(pool); + ({ ownerId } = await seedParticipants(pool)); +}); + +describe("getPendingReconciliations", () => { + it("does not list a superseded manual row", async () => { + const survivor = await insertTransaction(pool, ownerId, { description: "DIGIDIRECT", amount: 1180.05, transaction_date: "2026-08-25" }); + const hidden = await insertTransaction(pool, ownerId, { description: "Digidirect", amount: 1180.05, transaction_date: "2026-08-25" }); + await pool.query(`UPDATE transactions SET superseded_by_id = $1 WHERE id = $2`, [survivor, hidden]); + const line = await statementLine(1180.05, "2026-08-25", "SP DIGIDIRECT STRATHFIELD"); + + const queue = await getPendingReconciliations(ownerId); + const ids = queue.map((r) => r.id); + expect(ids).toContain(survivor); + expect(ids).not.toContain(hidden); + // The survivor is still offered its line. + const row = queue.find((r) => r.id === survivor)!; + expect(row.matches.map((m) => m.id)).toContain(line); + }); + + it("does not offer a superseded statement row as a match", async () => { + const manual = await insertTransaction(pool, ownerId, { description: "Grocer", amount: 42.0, transaction_date: "2026-08-10" }); + const live = await statementLine(42.0, "2026-08-10", "GROCER"); + const dupe = await statementLine(42.0, "2026-08-10", "GROCER"); + await pool.query(`UPDATE transactions SET superseded_by_id = $1 WHERE id = $2`, [live, dupe]); + + const [row] = (await getPendingReconciliations(ownerId)).filter((r) => r.id === manual); + const offered = row.matches.map((m) => m.id); + expect(offered).toContain(live); + expect(offered).not.toContain(dupe); + }); +}); diff --git a/src/lib/queries.ts b/src/lib/queries.ts index 1156261..850bb16 100644 --- a/src/lib/queries.ts +++ b/src/lib/queries.ts @@ -851,6 +851,12 @@ export async function getPendingReconciliations(ownerId: number): Promise