reconcile: automatic pass for unambiguous statement matches (board #252)
ci / lint-test (push) Successful in 56s
ci / lint-test (push) Successful in 56s
Prerequisite for the wallet lane writing a transaction per tap. The queue is worked by hand and already ~177 deep; wallet adds ~63 rows a month, ~767 a year. Filtering those rows back out is explicitly ruled out by the awaitsStatementLine() removal note: "an imported row that has no statement belongs in the queue; volume is solved upstream, never downstream by hiding what is there." The rule is deliberately stricter than the queue's own candidate ranking (top 5 within +/-1% and +/-3 days, for a human to choose between). A machine gets one shot and no judgement, so: exact amount, never a tolerance band; and unique BOTH ways, so two identical $13.50 bakery taps in one window are refused rather than paired arbitrarily. Foreign rows match on the native pair, because `amount` is the bank's own conversion and a wallet row cannot predict it. VALIDATED AGAINST REAL DECISIONS, and that overturned my reasoning. I first required both sides to be non-foreign, arguing a currency mismatch is ambiguous. Checked against the 48 reconciliations a human has actually made: that version matched ZERO of them. All 48 are exactly the shape it refused — a manual row carrying no currency, matched on exact AUD against a statement row that does carry one (EUR, CHF) — because overseas receipts and orders record what was charged in AUD, and only the wallet lane records natively. Reverted; the rule now reproduces all 48 and proposes nothing they did not. The coincidence it was meant to guard against is already covered by unique-both-ways. Extracted the merge into src/lib/reconcile.ts so the manual route and the automatic pass cannot drift — there must be exactly one implementation of something that moves curation off a row reconciliation is about to hide. Fixed a latent bug in that merge while extracting it: splits were recreated WITHOUT `settled`/`settled_at`, and the column defaults to false, so reconciling a settled split silently marked a paid debt owed again. No queue row carries one today, but this path is about to run unattended, where that surfaces as money rather than as an exception. PATCH ?dry=1 reports without writing. Each pair is its own transaction, so one bad pair leaves the rest untouched and in the queue. 11 new integration tests, most of them about refusal. Live dry-run proposes 0 pairs for either owner, which is correct: nothing in the queue has an exact statement twin yet. npm run test:all green (182 + 287).
This commit is contained in:
@@ -0,0 +1,218 @@
|
||||
import { describe, it, expect, beforeAll, beforeEach, vi } from "vitest";
|
||||
import { Pool } from "pg";
|
||||
import { createPool, resetDB, seedParticipants, insertTransaction } from "./helpers";
|
||||
|
||||
/**
|
||||
* The automatic pass over the reconcile queue.
|
||||
*
|
||||
* This exists because the wallet lane writes a transaction for every tap
|
||||
* (DECISIONS.md ING-11), adding ~63 rows a month to a queue already ~177 deep
|
||||
* and worked by hand. Hiding those rows instead is explicitly ruled out --
|
||||
* see the awaitsStatementLine() removal note in queries.ts.
|
||||
*
|
||||
* The cases that must NOT match matter more than the ones that must: this runs
|
||||
* unattended, and a wrong pairing merges one purchase's curation onto another
|
||||
* purchase's row. So most of what follows is about refusal.
|
||||
*/
|
||||
|
||||
let pool: Pool;
|
||||
let ownerId: number;
|
||||
let autoReconcile: typeof import("../../app/api/transactions/reconcile/route").PATCH;
|
||||
|
||||
const userReq = (url = "http://x/api/transactions/reconcile") => ({ url }) as never;
|
||||
|
||||
async function statementLine(
|
||||
amount: number,
|
||||
date: string,
|
||||
description: string,
|
||||
opts: { owner?: number; type?: string; fxAmount?: number; fxCode?: string } = {}
|
||||
) {
|
||||
const owner = opts.owner ?? ownerId;
|
||||
const st = await pool.query(
|
||||
`INSERT INTO statements (filename, account_number, bank_name, billing_start_date, billing_end_date, owner_id)
|
||||
VALUES ('s.pdf', '1234-5678-9012-3302', 'Test Bank', $1::date - 20, $1::date + 10, $2) RETURNING id`,
|
||||
[date, owner]
|
||||
);
|
||||
const t = await pool.query(
|
||||
`INSERT INTO transactions
|
||||
(statement_id, transaction_date, description, amount, transaction_type, owner_id,
|
||||
foreign_currency_amount, foreign_currency_code)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8) RETURNING id`,
|
||||
[st.rows[0].id, date, description, amount, opts.type ?? "debit", owner,
|
||||
opts.fxAmount ?? null, opts.fxCode ?? null]
|
||||
);
|
||||
return t.rows[0].id as number;
|
||||
}
|
||||
|
||||
async function walletRow(
|
||||
amount: number,
|
||||
date: string,
|
||||
opts: { fxAmount?: number; fxCode?: string; paymentMethod?: string } = {}
|
||||
) {
|
||||
const id = await insertTransaction(pool, ownerId, {
|
||||
amount, transaction_date: date, description: "Wallet tap",
|
||||
});
|
||||
await pool.query(
|
||||
`UPDATE transactions
|
||||
SET foreign_currency_amount = $2, foreign_currency_code = $3, payment_method = $4
|
||||
WHERE id = $1`,
|
||||
[id, opts.fxAmount ?? null, opts.fxCode ?? null, opts.paymentMethod ?? null]
|
||||
);
|
||||
return id;
|
||||
}
|
||||
|
||||
const reconciledWith = async (id: number) =>
|
||||
(await pool.query(`SELECT reconciled_with_id FROM transactions WHERE id = $1`, [id]))
|
||||
.rows[0].reconciled_with_id as number | null;
|
||||
|
||||
beforeAll(async () => {
|
||||
pool = createPool();
|
||||
vi.doMock("@/lib/auth", () => ({
|
||||
getCurrentUser: async () => ({ id: ownerId, name: "Alice", email: "alice@example.com" }),
|
||||
}));
|
||||
({ PATCH: autoReconcile } = await import("../../app/api/transactions/reconcile/route"));
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await resetDB(pool);
|
||||
await pool.query("DELETE FROM expense_metadata");
|
||||
({ ownerId } = await seedParticipants(pool));
|
||||
});
|
||||
|
||||
describe("pairs it should apply", () => {
|
||||
it("matches an exact amount two days apart and moves the link", async () => {
|
||||
const manual = await walletRow(13.5, "2026-08-13");
|
||||
const stmt = await statementLine(13.5, "2026-08-15", "BREADWORLD BAKERY");
|
||||
|
||||
const body = await (await autoReconcile(userReq())).json();
|
||||
expect(body.reconciled).toBe(1);
|
||||
expect(await reconciledWith(manual)).toBe(stmt);
|
||||
});
|
||||
|
||||
it("matches a foreign row on the NATIVE pair, not on the AUD amount", async () => {
|
||||
// The bank converts at its own rate, so the wallet row cannot predict the
|
||||
// AUD figure. It can only know THB 500. Deliberately give the statement a
|
||||
// wildly different AUD amount to prove `amount` is not what matched.
|
||||
const manual = await walletRow(999.99, "2026-08-13", { fxAmount: 500, fxCode: "THB" });
|
||||
const stmt = await statementLine(22.85, "2026-08-14", "7-ELEVEN BANGKOK", {
|
||||
fxAmount: 500, fxCode: "THB",
|
||||
});
|
||||
|
||||
const body = await (await autoReconcile(userReq())).json();
|
||||
expect(body.reconciled).toBe(1);
|
||||
expect(await reconciledWith(manual)).toBe(stmt);
|
||||
});
|
||||
|
||||
it("carries a SETTLED split across without resurrecting the debt", async () => {
|
||||
const manual = await walletRow(50, "2026-08-13");
|
||||
const stmt = await statementLine(50, "2026-08-14", "SHARED DINNER");
|
||||
await pool.query(
|
||||
`INSERT INTO transaction_splits (transaction_id, participant_id, share_percent, settled, settled_at)
|
||||
VALUES ($1, $2, 50, true, now())`,
|
||||
[manual, ownerId]
|
||||
);
|
||||
|
||||
expect((await autoReconcile(userReq())).status ?? 200).toBe(200);
|
||||
|
||||
const moved = await pool.query(
|
||||
`SELECT settled, settled_at FROM transaction_splits WHERE transaction_id = $1`, [stmt]
|
||||
);
|
||||
expect(moved.rowCount).toBe(1);
|
||||
// The whole point: a delete-and-recreate defaults `settled` to false, which
|
||||
// silently marks a paid debt owed again. Nothing errors when it regresses.
|
||||
expect(moved.rows[0].settled).toBe(true);
|
||||
expect(moved.rows[0].settled_at).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("pairs it must refuse", () => {
|
||||
it("refuses two identical amounts in the same window (ambiguous both ways)", async () => {
|
||||
// Two $13.50 bakery taps in one week is the ordinary case, not an edge one.
|
||||
const a = await walletRow(13.5, "2026-08-13");
|
||||
const b = await walletRow(13.5, "2026-08-14");
|
||||
await statementLine(13.5, "2026-08-14", "BREADWORLD ONE");
|
||||
await statementLine(13.5, "2026-08-15", "BREADWORLD TWO");
|
||||
|
||||
const body = await (await autoReconcile(userReq())).json();
|
||||
expect(body.reconciled).toBe(0);
|
||||
expect(await reconciledWith(a)).toBeNull();
|
||||
expect(await reconciledWith(b)).toBeNull();
|
||||
});
|
||||
|
||||
it("refuses a near-miss amount — a person should look at that", async () => {
|
||||
const manual = await walletRow(13.5, "2026-08-13");
|
||||
await statementLine(13.55, "2026-08-14", "BREADWORLD BAKERY");
|
||||
|
||||
expect((await (await autoReconcile(userReq())).json()).reconciled).toBe(0);
|
||||
expect(await reconciledWith(manual)).toBeNull();
|
||||
});
|
||||
|
||||
it("refuses a match outside the day window", async () => {
|
||||
const manual = await walletRow(13.5, "2026-08-13");
|
||||
await statementLine(13.5, "2026-08-25", "BREADWORLD BAKERY");
|
||||
|
||||
expect((await (await autoReconcile(userReq())).json()).reconciled).toBe(0);
|
||||
expect(await reconciledWith(manual)).toBeNull();
|
||||
});
|
||||
|
||||
it("refuses a statement row already claimed by another manual row", async () => {
|
||||
const claimed = await statementLine(40, "2026-08-14", "ALREADY TAKEN");
|
||||
const first = await walletRow(40, "2026-08-13");
|
||||
await pool.query(`UPDATE transactions SET reconciled_with_id = $2 WHERE id = $1`, [first, claimed]);
|
||||
const second = await walletRow(40, "2026-08-14");
|
||||
|
||||
expect((await (await autoReconcile(userReq())).json()).reconciled).toBe(0);
|
||||
expect(await reconciledWith(second)).toBeNull();
|
||||
});
|
||||
|
||||
it("refuses a credits-funded row — no card leg is ever coming", async () => {
|
||||
const manual = await walletRow(30, "2026-08-13", { paymentMethod: "credits" });
|
||||
await statementLine(30, "2026-08-14", "COINCIDENTAL AMOUNT");
|
||||
|
||||
expect((await (await autoReconcile(userReq())).json()).reconciled).toBe(0);
|
||||
expect(await reconciledWith(manual)).toBeNull();
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
describe("an AUD row against a foreign statement line", () => {
|
||||
it("DOES match on the AUD figure — this is what every real pair looks like", async () => {
|
||||
// Checked against live data: all 48 reconciliations a human has made are
|
||||
// this exact shape -- manual row with no currency, statement row carrying
|
||||
// EUR or CHF, paired on exact AUD. Overseas receipts and orders record what
|
||||
// was charged in AUD; only the wallet lane records natively. An earlier
|
||||
// version of this rule required both sides to be non-foreign, on the
|
||||
// reasoning that a currency mismatch is ambiguous. It matched 0 of the 48.
|
||||
const manual = await walletRow(22.85, "2026-08-13");
|
||||
const stmt = await statementLine(22.85, "2026-08-14", "7-ELEVEN BANGKOK", {
|
||||
fxAmount: 500, fxCode: "THB",
|
||||
});
|
||||
|
||||
expect((await (await autoReconcile(userReq())).json()).reconciled).toBe(1);
|
||||
expect(await reconciledWith(manual)).toBe(stmt);
|
||||
});
|
||||
|
||||
it("still refuses when a second candidate makes it ambiguous", async () => {
|
||||
// The coincidence this guards against -- a domestic tap whose amount equals
|
||||
// a foreign charge's AUD figure -- is caught by unique-both-ways, not by a
|
||||
// currency test.
|
||||
const manual = await walletRow(22.85, "2026-08-13");
|
||||
await statementLine(22.85, "2026-08-14", "7-ELEVEN BANGKOK", { fxAmount: 500, fxCode: "THB" });
|
||||
await statementLine(22.85, "2026-08-13", "MELBOURNE CAFE");
|
||||
|
||||
expect((await (await autoReconcile(userReq())).json()).reconciled).toBe(0);
|
||||
expect(await reconciledWith(manual)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("dry run", () => {
|
||||
it("reports what it would do and writes nothing", async () => {
|
||||
const manual = await walletRow(13.5, "2026-08-13");
|
||||
await statementLine(13.5, "2026-08-14", "BREADWORLD BAKERY");
|
||||
|
||||
const body = await (await autoReconcile(userReq("http://x/api/transactions/reconcile?dry=1"))).json();
|
||||
expect(body.dry_run).toBe(true);
|
||||
expect(body.would_reconcile).toBe(1);
|
||||
expect(await reconciledWith(manual)).toBeNull();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user