void: a statementless row for a charge that never posted (migration 0040)
ci / lint-test (push) Successful in 54s
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:
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user