void: a statementless row for a charge that never posted (migration 0040)
ci / lint-test (push) Successful in 54s

A card notification is sent for an authorisation; a released hold never
reaches a statement. Txn 7951 (a $1,040.41 Airbnb hold, booking cancelled
the next day) sat in the reconcile queue forever and counted as spend.
Neither reconcile (needs a statement twin) nor supersede (needs a survivor)
can express it.

- transactions.voided_at + void_reason; CHECKs: reason mandatory,
  statementless only
- EXCLUDE_RECONCILED_SOURCE excludes voided rows; reconcile queue,
  findAutoMatches, both adoption helpers, annual charges and the manual
  reconcile POST filter it; merge refuses a voided row on either side
- POST/DELETE /api/transactions/<id>/void; refuses reconciled, superseded
  and settled-split rows
- "Never posted" button on /reconcile
- wallet-ingestion deliberately still sees voided rows, so a late twin or a
  prompt_version re-post cannot rebook the hold

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-10-12 18:05:05 +11:00
co-authored by Claude Opus 5.5
parent a1c2eb736f
commit c25c82932d
14 changed files with 459 additions and 5 deletions
+167
View File
@@ -0,0 +1,167 @@
import { describe, it, expect, beforeAll, beforeEach, vi } from "vitest";
import { Pool } from "pg";
import { createPool, resetDB, seedParticipants, insertTransaction } from "./helpers";
import { EXCLUDE_RECONCILED_SOURCE } from "@/lib/analytics-sql";
/**
* A charge that never posted (migration 0040).
*
* Txn 7951: a $1,040.41 Airbnb authorisation hold on Amex, booking cancelled
* the next day, hold released. The wallet lane booked the notification as a
* purchase and no statement line ever arrived, so it sat in the reconcile queue
* forever and counted as spend everywhere. A void has no survivor, which is why
* neither reconcile nor supersede could express it.
*
* As in merge-supersede.test.ts, the refusals matter as much as the happy path:
* a void quietly stops money counting.
*/
let pool: Pool;
let ownerId: number;
let otherId: number;
let voidRoute: typeof import("../../app/api/transactions/[id]/void/route");
let merge: typeof import("../../app/api/transactions/[id]/merge/route").POST;
const req = (body: unknown) => ({ json: async () => body, headers: { get: () => null } }) as never;
const params = (id: number) => ({ params: Promise.resolve({ id: String(id) }) });
const doVoid = (id: number, reason: unknown = "hold released, booking cancelled") =>
voidRoute.POST(req({ reason }), params(id));
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, `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;
}
/** What every "count each purchase once" query sees. */
async function countedIds(): Promise<number[]> {
const r = await pool.query(
`SELECT t.id FROM transactions t WHERE ${EXCLUDE_RECONCILED_SOURCE} ORDER BY t.id`
);
return r.rows.map((x) => x.id);
}
beforeAll(async () => {
pool = createPool();
vi.doMock("@/lib/auth", () => ({
getCurrentUser: async () => ({ id: ownerId, name: "Alice", email: "alice@example.com" }),
}));
voidRoute = await import("../../app/api/transactions/[id]/void/route");
({ 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("voiding a charge that never posted", () => {
it("drops the row out of every figure and the reconcile queue, but keeps it", async () => {
const { getPendingReconciliations } = await import("@/lib/queries");
const hold = await insertTransaction(pool, ownerId, { description: "AIRBNB", amount: 1040.41 });
const real = await insertTransaction(pool, ownerId, { description: "AIRBNB", amount: 529.05 });
expect((await getPendingReconciliations(ownerId)).map((t) => t.id)).toContain(hold);
const res = await doVoid(hold);
expect(res.status ?? 200).toBe(200);
expect(await countedIds()).toEqual([real]);
expect((await getPendingReconciliations(ownerId)).map((t) => t.id)).not.toContain(hold);
const r = await pool.query(`SELECT voided_at, void_reason FROM transactions WHERE id = $1`, [hold]);
expect(r.rows[0].voided_at).not.toBeNull();
expect(r.rows[0].void_reason).toBe("hold released, booking cancelled");
});
it("can be undone", async () => {
const hold = await insertTransaction(pool, ownerId, { amount: 1040.41 });
await doVoid(hold);
const res = await voidRoute.DELETE(req({}), params(hold));
expect(res.status ?? 200).toBe(200);
expect(await countedIds()).toEqual([hold]);
});
it("requires a reason", async () => {
const hold = await insertTransaction(pool, ownerId, { amount: 10 });
expect((await doVoid(hold, " ")).status).toBe(400);
expect((await doVoid(hold, null)).status).toBe(400);
expect(await countedIds()).toEqual([hold]);
});
it("refuses a statement line -- that charge posted", async () => {
const stmt = await statementLine(50, "2026-09-10", "SOMETHING");
expect((await doVoid(stmt)).status).toBe(409);
expect(await countedIds()).toEqual([stmt]);
});
it("refuses a reconciled row -- it found its statement line, so it posted", async () => {
const wallet = await insertTransaction(pool, ownerId, { amount: 50, transaction_date: "2026-09-10" });
const stmt = await statementLine(50, "2026-09-10", "SOMETHING");
await pool.query(`UPDATE transactions SET reconciled_with_id = $1 WHERE id = $2`, [stmt, wallet]);
expect((await doVoid(wallet)).status).toBe(409);
});
it("refuses a row with a settled split -- someone already paid their share", async () => {
const hold = await insertTransaction(pool, ownerId, { amount: 80 });
await pool.query(
`INSERT INTO transaction_splits (transaction_id, participant_id, share_percent, settled)
VALUES ($1, $2, 50, true)`,
[hold, otherId]
);
expect((await doVoid(hold)).status).toBe(409);
expect(await countedIds()).toEqual([hold]);
});
it("is enforced by the database, not only the route", async () => {
const stmt = await statementLine(50, "2026-09-10", "SOMETHING");
await expect(
pool.query(`UPDATE transactions SET voided_at = now(), void_reason = 'x' WHERE id = $1`, [stmt])
).rejects.toThrow(/transactions_void_statementless/);
const manual = await insertTransaction(pool, ownerId, { amount: 5 });
await expect(
pool.query(`UPDATE transactions SET voided_at = now() WHERE id = $1`, [manual])
).rejects.toThrow(/transactions_void_has_reason/);
});
});
describe("the matchers never use a voided row", () => {
it("auto-reconcile does not pair a voided row with a statement line of the same amount", async () => {
const { findAutoMatches } = await import("@/lib/auto-reconcile");
const hold = await insertTransaction(pool, ownerId, { amount: 120, transaction_date: "2026-09-10" });
await statementLine(120, "2026-09-11", "SOMETHING");
expect((await findAutoMatches(ownerId)).map((m) => m.manual_id)).toContain(hold);
await doVoid(hold);
expect(await findAutoMatches(ownerId)).toEqual([]);
});
it("a receipt for a rebooking at the same amount adopts the live row, not the released hold", async () => {
const { findAdoptableTransaction } = await import("@/lib/auto-reconcile");
const hold = await insertTransaction(pool, ownerId, { amount: 300, transaction_date: "2026-09-10" });
const rebooked = await insertTransaction(pool, ownerId, { amount: 300, transaction_date: "2026-09-11" });
// Two candidates: the adopter refuses rather than guess.
expect(await findAdoptableTransaction(ownerId, 300, "2026-09-11")).toBeNull();
await doVoid(hold);
expect(await findAdoptableTransaction(ownerId, 300, "2026-09-11")).toBe(rebooked);
});
it("merge refuses a voided row on either side", async () => {
const hold = await insertTransaction(pool, ownerId, { amount: 70, transaction_date: "2026-09-10" });
const stmt = await statementLine(70, "2026-09-10", "SOMETHING");
await doVoid(hold);
const res = await merge(req({ other_id: stmt, mode: "reconcile" }), params(hold));
expect(res.status).toBe(409);
});
});