reconcile queue: never list or offer a superseded row
ci / lint-test (push) Successful in 58s

getPendingReconciliations filtered neither the manual side nor the candidate
statement side on superseded_by_id. After the 2026-10-03 wallet cleanup the
queue still showed duplicate half 7037 (superseded under 7038) beside its
twin, with the same Digidirect statement line offered to both -- accepting it
on the hidden row would have spent the line and stranded the real one. The
candidate side could likewise offer one of the 31 superseded ANZ rows.
findAutoMatches() always had both filters. First tests for this query;
both fail against the old SQL.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-10-03 17:21:31 +10:00
co-authored by Claude Opus 5.5
parent a3e4e1102a
commit ce0bf0a775
2 changed files with 81 additions and 0 deletions
@@ -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);
});
});
+12
View File
@@ -851,6 +851,12 @@ export async function getPendingReconciliations(ownerId: number): Promise<Manual
WHERE ts.transaction_id = t.id WHERE ts.transaction_id = t.id
) txn_splits ON true ) txn_splits ON true
WHERE t.statement_id IS NULL AND t.owner_id = $1 AND t.reconciled_with_id IS NULL WHERE t.statement_id IS NULL AND t.owner_id = $1 AND t.reconciled_with_id IS NULL
-- A superseded row is already resolved, just not by reconciling. Without
-- this the queue kept offering it: a duplicate wallet half hidden under
-- its twin (txn 7037, 2026-10-03) sat beside that twin with the same
-- statement line as its match, and accepting it on the hidden row would
-- have used the line up and stranded the real one.
AND t.superseded_by_id IS NULL
AND ${needsCardMatch("t")} AND ${needsCardMatch("t")}
ORDER BY t.transaction_date DESC, t.row_index ASC`, ORDER BY t.transaction_date DESC, t.row_index ASC`,
[ownerId] [ownerId]
@@ -903,6 +909,12 @@ export async function getPendingReconciliations(ownerId: number): Promise<Manual
WHERE m.statement_id IS NULL WHERE m.statement_id IS NULL
AND m.owner_id = $1 AND m.owner_id = $1
AND m.reconciled_with_id IS NULL AND m.reconciled_with_id IS NULL
AND m.superseded_by_id IS NULL
-- Nor offer a superseded STATEMENT row as the match: the ANZ
-- statements 107/142/143 left 31 of those, and a manual row
-- reconciled to one inherits its exclusion from every figure.
-- findAutoMatches() has had both filters all along.
AND t.superseded_by_id IS NULL
AND ${needsCardMatch("m")} AND ${needsCardMatch("m")}
AND COALESCE(t.owner_id, s.owner_id) = $1 AND COALESCE(t.owner_id, s.owner_id) = $1
AND NOT EXISTS ( AND NOT EXISTS (