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:
@@ -275,6 +275,37 @@ by the `rn` window and ordered by amount closeness, so a looser band changes
|
||||
live queue at the time of the change: candidate counts went **0 → 0** on all 12
|
||||
rows, i.e. no new noise. **The auto-matcher stays exact** — see `findAutoMatches()`.
|
||||
|
||||
### A charge that never posted (`POST /api/transactions/<id>/void`, migration 0040)
|
||||
|
||||
The third way a row stops counting, for a case with **no survivor**: a card
|
||||
notification is sent for an *authorisation*, and when the merchant releases the
|
||||
hold no statement line ever arrives. Txn 7951 — a $1,040.41 Airbnb hold on Amex,
|
||||
booking cancelled the next day — sat in the reconcile queue with nothing to
|
||||
match and counted as spend everywhere. `reconcile` needs a statement twin,
|
||||
`supersede` needs a survivor (and `transactions_no_self_supersede` blocks
|
||||
pointing a row at itself), so it gets its own columns: `voided_at` +
|
||||
`void_reason` (reason mandatory, by CHECK). Body `{reason}`; `DELETE` undoes it.
|
||||
UI: **Never posted** on every row of `/reconcile`.
|
||||
|
||||
- **In `EXCLUDE_RECONCILED_SOURCE`**, so every "count each purchase once" query
|
||||
drops it without being edited; the queue, `findAutoMatches`, both adoption
|
||||
helpers, annual charges and the manual reconcile POST filter it explicitly.
|
||||
- **Statementless only** — `transactions_void_statementless` enforces it in the
|
||||
database. A statement line is the bank's record of a posted charge; a wrong
|
||||
one is a supersede.
|
||||
- **Refused** on a reconciled row (it posted), a superseded row, and a row with a
|
||||
*settled* split (someone already paid their share — voiding would quietly turn
|
||||
that into a credit). `merge` refuses a voided row on either side.
|
||||
- **The wallet lane deliberately still SEES voided rows.** `alreadyIngested()` and
|
||||
the issuer/Wallet pairing in `wallet-ingestion.ts` do not filter `voided_at`:
|
||||
the voided row is the sink that stops a late twin notification, or the next
|
||||
`prompt_version` re-post, from booking the hold again. Same for the intake
|
||||
router's idempotency lookup. Do not "fix" those.
|
||||
- **Outside this repo, nothing filters it**: Hermes' `agent_ro` views
|
||||
(`cashflow_monthly` etc.) and ingestion-engine's `instalment_matcher` already
|
||||
ignore `superseded_by_id`/`reconciled_with_id` too, so a voided row is no worse
|
||||
there than any other hidden row.
|
||||
|
||||
### Upcoming annual charges (`GET /api/annual-charges`, migration 0038)
|
||||
|
||||
Card annual fees, loan package fees and annual subscriptions, derived from
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
-- A statementless row for a charge that never posted.
|
||||
--
|
||||
-- WHY: the wallet lane books every card notification as a purchase, and a card
|
||||
-- notification is sent for an AUTHORISATION, not a settlement. When the
|
||||
-- merchant releases the hold the bank never posts the charge, so no statement
|
||||
-- line ever arrives: the row sits in the reconcile queue forever and counts as
|
||||
-- spend in every figure. Txn 7951: Airbnb HMXAHAW49Y, $1,040.41 held on Amex
|
||||
-- 1000 on 2026-09-24, booking cancelled 25 Sep inside the free window, hold
|
||||
-- released ("We removed the $1,040.41 (AUD) payment hold"). Statement 219
|
||||
-- (11 Sep - 10 Oct) has no such line and reconciles to its header without it.
|
||||
--
|
||||
-- Neither existing exclusion fits. reconciled_with_id needs a statement twin
|
||||
-- and superseded_by_id needs a surviving row (and may not point at itself,
|
||||
-- transactions_no_self_supersede). There is no survivor: nothing happened.
|
||||
--
|
||||
-- Not a DELETE: every child of transactions cascades, and the row is also the
|
||||
-- sink the wallet lane checks before re-posting an event. Deleting it would let
|
||||
-- the next prompt_version bump re-create it.
|
||||
|
||||
ALTER TABLE transactions
|
||||
ADD COLUMN IF NOT EXISTS voided_at timestamptz,
|
||||
ADD COLUMN IF NOT EXISTS void_reason text;
|
||||
|
||||
COMMENT ON COLUMN transactions.voided_at IS
|
||||
'Set when this statementless row records a charge that never posted (e.g. a released authorisation hold). Excluded from every figure and from the reconcile queue, kept for its children and audit trail. NULL = live.';
|
||||
|
||||
-- A reason is mandatory: a void with no explanation is indistinguishable from
|
||||
-- a mistake six months later.
|
||||
ALTER TABLE transactions
|
||||
ADD CONSTRAINT transactions_void_has_reason
|
||||
CHECK ((voided_at IS NULL AND void_reason IS NULL)
|
||||
OR (voided_at IS NOT NULL AND btrim(coalesce(void_reason, '')) <> ''));
|
||||
|
||||
-- Statement rows cannot be voided. A statement line is the bank's own record
|
||||
-- of a posted charge, and the statement's rows must keep summing to its
|
||||
-- header; a wrong statement row is a supersede, not a void.
|
||||
ALTER TABLE transactions
|
||||
ADD CONSTRAINT transactions_void_statementless
|
||||
CHECK (voided_at IS NULL OR statement_id IS NULL);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_transactions_voided
|
||||
ON transactions (voided_at)
|
||||
WHERE voided_at IS NOT NULL;
|
||||
@@ -190,6 +190,8 @@ model transactions {
|
||||
owner_id Int?
|
||||
reconciled_with_id Int?
|
||||
superseded_by_id Int?
|
||||
voided_at DateTime? @db.Timestamptz(6) // a charge that never posted (migration 0040)
|
||||
void_reason String?
|
||||
principal_amount Decimal? @db.Decimal(12, 2)
|
||||
interest_amount Decimal? @db.Decimal(12, 2)
|
||||
source String? // feed this row came from, e.g. "frollo"; NULL = statement/manual (migration 0028)
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -66,8 +66,9 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
|
||||
statement_id: number | null;
|
||||
reconciled_with_id: number | null;
|
||||
superseded_by_id: number | null;
|
||||
voided_at: Date | null;
|
||||
}>(
|
||||
`SELECT t.id, t.statement_id, t.reconciled_with_id, t.superseded_by_id
|
||||
`SELECT t.id, t.statement_id, t.reconciled_with_id, t.superseded_by_id, t.voided_at
|
||||
FROM transactions t
|
||||
LEFT JOIN statements s ON s.id = t.statement_id
|
||||
WHERE t.id = ANY($1::int[]) AND COALESCE(t.owner_id, s.owner_id) = $2`,
|
||||
@@ -95,6 +96,14 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
|
||||
{ status: 409 }
|
||||
);
|
||||
}
|
||||
// A voided row is a charge that never posted. As a source it is already
|
||||
// out of every figure; as a survivor it would take the other row out too.
|
||||
if (row.voided_at !== null) {
|
||||
return NextResponse.json(
|
||||
{ error: `${label} transaction ${row.id} is voided (a charge that never posted)` },
|
||||
{ status: 409 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { getCurrentUser } from "@/lib/auth";
|
||||
import { queryRaw } from "@/lib/db";
|
||||
import { unvoidTransaction, voidTransaction } from "@/lib/reconcile";
|
||||
|
||||
/**
|
||||
* POST /api/transactions/<id>/void { reason } -- a charge that never posted
|
||||
* DELETE /api/transactions/<id>/void -- undo it
|
||||
*
|
||||
* For a statementless row whose charge never reached a statement: an
|
||||
* authorisation hold the merchant released (txn 7951, a cancelled Airbnb
|
||||
* booking). The row stays, with its children and audit trail, and drops out of
|
||||
* every figure and the reconcile queue. Rules and refusals: `voidTransaction`
|
||||
* in lib/reconcile.ts.
|
||||
*/
|
||||
|
||||
async function ownedRow(req: NextRequest, idParam: string) {
|
||||
const user = await getCurrentUser(req);
|
||||
if (!user) return { error: NextResponse.json({ error: "Unauthorized" }, { status: 403 }) };
|
||||
const id = Number(idParam);
|
||||
if (!Number.isInteger(id)) {
|
||||
return { error: NextResponse.json({ error: "invalid transaction id" }, { status: 400 }) };
|
||||
}
|
||||
// Same owner scope as every other route; a statement row carries owner_id on
|
||||
// the statement. (Statement rows are refused later, but must 400, not 404.)
|
||||
const rows = await queryRaw<{ id: number }>(
|
||||
`SELECT t.id FROM transactions t
|
||||
LEFT JOIN statements s ON s.id = t.statement_id
|
||||
WHERE t.id = $1 AND COALESCE(t.owner_id, s.owner_id) = $2`,
|
||||
[id, user.id]
|
||||
);
|
||||
if (rows.length === 0) {
|
||||
return { error: NextResponse.json({ error: "transaction not found" }, { status: 404 }) };
|
||||
}
|
||||
return { id };
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||
const owned = await ownedRow(req, (await params).id);
|
||||
if (owned.error) return owned.error;
|
||||
|
||||
let body: { reason?: unknown };
|
||||
try {
|
||||
body = await req.json();
|
||||
} catch {
|
||||
return NextResponse.json({ error: "invalid JSON" }, { status: 400 });
|
||||
}
|
||||
const reason = typeof body.reason === "string" ? body.reason.trim() : "";
|
||||
if (!reason) return NextResponse.json({ error: "reason is required" }, { status: 400 });
|
||||
|
||||
try {
|
||||
await voidTransaction(owned.id, reason);
|
||||
} catch (e) {
|
||||
return NextResponse.json({ error: e instanceof Error ? e.message : String(e) }, { status: 409 });
|
||||
}
|
||||
return NextResponse.json({ ok: true, id: owned.id, voided: true });
|
||||
}
|
||||
|
||||
export async function DELETE(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||
const owned = await ownedRow(req, (await params).id);
|
||||
if (owned.error) return owned.error;
|
||||
await unvoidTransaction(owned.id);
|
||||
return NextResponse.json({ ok: true, id: owned.id, voided: false });
|
||||
}
|
||||
@@ -19,7 +19,7 @@ export async function POST(req: NextRequest) {
|
||||
// Verify all manual_ids belong to this user
|
||||
const manualIds = body.matches.map((m) => m.manual_id);
|
||||
const owned = await queryRaw<{ id: number }>(
|
||||
`SELECT id FROM transactions WHERE id = ANY($1::int[]) AND statement_id IS NULL AND owner_id = $2`,
|
||||
`SELECT id FROM transactions WHERE id = ANY($1::int[]) AND statement_id IS NULL AND voided_at IS NULL AND owner_id = $2`,
|
||||
[manualIds, user.id]
|
||||
);
|
||||
if (owned.length !== manualIds.length) {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { usePendingReconciliations, useReconcile } from "@/lib/hooks";
|
||||
import { usePendingReconciliations, useReconcile, useVoidTransaction } from "@/lib/hooks";
|
||||
import type { ManualTxWithMatches, PotentialMatch } from "@/lib/queries";
|
||||
|
||||
function formatDate(d: string | Date) {
|
||||
@@ -156,6 +156,7 @@ export default function ReconcilePage() {
|
||||
<span key={tag.id} className="w-2 h-2 rounded-full" style={{ backgroundColor: tag.color }} title={tag.name} />
|
||||
))}
|
||||
</div>
|
||||
<VoidButton id={tx.id} amount={tx.amount} onError={setError} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@@ -243,6 +244,7 @@ function ReconcileRow({
|
||||
Clear
|
||||
</button>
|
||||
)}
|
||||
<VoidButton id={tx.id} amount={tx.amount} />
|
||||
{!isSkipped && (
|
||||
<button onClick={onSkip} className="text-xs text-zinc-500 hover:text-zinc-300 px-2 py-1 rounded hover:bg-zinc-800">
|
||||
Skip (no match)
|
||||
@@ -296,3 +298,34 @@ function MatchRow({
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* "Never posted": a released authorisation hold, e.g. a booking cancelled
|
||||
* before the charge settled. Drops the row out of every figure and this queue
|
||||
* (migration 0040); the row and its history are kept.
|
||||
*/
|
||||
function VoidButton({ id, amount, onError }: { id: number; amount: number; onError?: (msg: string) => void }) {
|
||||
const voidTx = useVoidTransaction();
|
||||
async function handle() {
|
||||
const reason = window.prompt(
|
||||
`Mark this $${Number(amount).toFixed(2)} charge as never posted? It will stop counting everywhere.\n\nReason (e.g. "hold released, booking cancelled"):`
|
||||
);
|
||||
if (!reason?.trim()) return;
|
||||
try {
|
||||
await voidTx.mutateAsync({ id, reason });
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : "Void failed";
|
||||
if (onError) onError(msg); else window.alert(msg);
|
||||
}
|
||||
}
|
||||
return (
|
||||
<button
|
||||
onClick={handle}
|
||||
disabled={voidTx.isPending}
|
||||
title="The charge never posted (e.g. a released hold) — stop counting it"
|
||||
className="text-xs text-zinc-500 hover:text-red-300 px-2 py-1 rounded hover:bg-zinc-800 disabled:opacity-50 flex-shrink-0"
|
||||
>
|
||||
{voidTx.isPending ? "Voiding…" : "Never posted"}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -71,9 +71,17 @@ export const STATEMENTS_JOIN = `LEFT JOIN statements s ON s.id = t.statement_id`
|
||||
* without being edited. Anything summing transactions without this fragment
|
||||
* still double-counts — that is the same gap that let reconciled rows into the
|
||||
* analytics routes in the first place.
|
||||
*
|
||||
* **3. The charge that never posted** (`voided_at`, migration 0040).
|
||||
*
|
||||
* A card notification is sent for an authorisation, and a released hold never
|
||||
* reaches a statement (txn 7951: a $1,040.41 Airbnb hold on a booking cancelled
|
||||
* the next day). There is no survivor to point at, so it gets its own column —
|
||||
* and it lives in this fragment for the same reason case 2 does.
|
||||
*/
|
||||
export const EXCLUDE_RECONCILED_SOURCE = `NOT (t.statement_id IS NULL AND t.reconciled_with_id IS NOT NULL)
|
||||
AND t.superseded_by_id IS NULL`;
|
||||
AND t.superseded_by_id IS NULL
|
||||
AND t.voided_at IS NULL`;
|
||||
|
||||
/**
|
||||
* The currency `t.amount` is actually denominated in.
|
||||
|
||||
@@ -221,6 +221,7 @@ export async function getUpcomingAnnualCharges(ownerId: number): Promise<AnnualC
|
||||
FROM transactions t
|
||||
LEFT JOIN statements s ON s.id = t.statement_id
|
||||
WHERE t.superseded_by_id IS NULL AND t.reconciled_with_id IS NULL
|
||||
AND t.voided_at IS NULL
|
||||
AND t.transaction_type = 'debit' AND t.amount > 0
|
||||
AND COALESCE(t.owner_id, s.owner_id) = $1
|
||||
AND t.transaction_type = 'debit' AND t.description !~* $2
|
||||
|
||||
@@ -102,6 +102,7 @@ export async function findAutoMatches(
|
||||
AND m.owner_id = $1
|
||||
AND m.reconciled_with_id IS NULL
|
||||
AND m.superseded_by_id IS NULL
|
||||
AND m.voided_at IS NULL
|
||||
AND (m.payment_method IS NULL OR m.payment_method NOT IN ('cash', 'credits'))
|
||||
AND t.superseded_by_id IS NULL
|
||||
AND COALESCE(t.owner_id, s.owner_id) = $1
|
||||
@@ -188,6 +189,9 @@ export async function findWalletAdoptableTransaction(
|
||||
AND t.transaction_date BETWEEN $3::date - $4::int AND $3::date + $4::int
|
||||
AND t.reconciled_with_id IS NULL
|
||||
AND t.superseded_by_id IS NULL
|
||||
-- A voided row is a charge that never posted; a real charge for the same
|
||||
-- amount (a rebooking after a released hold) must not fold into it.
|
||||
AND t.voided_at IS NULL
|
||||
-- A wallet row is the OTHER HALF of this tap, not a purchase to fold
|
||||
-- into; findPairedRow() merges those, with different rules.
|
||||
AND COALESCE(t.source, '') <> 'wallet'
|
||||
@@ -213,6 +217,9 @@ export async function findAdoptableTransaction(
|
||||
AND t.transaction_date BETWEEN $3::date - $4::int AND $3::date + $4::int
|
||||
AND t.reconciled_with_id IS NULL
|
||||
AND t.superseded_by_id IS NULL
|
||||
-- A voided row is a charge that never posted; a real charge for the same
|
||||
-- amount (a rebooking after a released hold) must not fold into it.
|
||||
AND t.voided_at IS NULL
|
||||
-- Never adopt a row that another source already fully described; that
|
||||
-- is a different purchase that happens to share an amount.
|
||||
AND NOT EXISTS (
|
||||
|
||||
@@ -1162,6 +1162,27 @@ export function useReconcile() {
|
||||
});
|
||||
}
|
||||
|
||||
/** Mark a statementless row as a charge that never posted (migration 0040). */
|
||||
export function useVoidTransaction() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async ({ id, reason }: { id: number; reason: string }) => {
|
||||
const res = await fetch(`/api/transactions/${id}/void`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ reason }),
|
||||
});
|
||||
if (!res.ok) throw new Error((await res.json()).error || "Void failed");
|
||||
return res.json();
|
||||
},
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ["transactions"] });
|
||||
qc.invalidateQueries({ queryKey: ["reconcile-pending"] });
|
||||
qc.invalidateQueries({ queryKey: ["analytics"] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useMerchantTransactions(merchant: string | null) {
|
||||
return useQuery<{ transactions: MerchantTxnRow[] }>({
|
||||
queryKey: ["analytics", "merchant-txns", merchant],
|
||||
|
||||
@@ -857,6 +857,9 @@ export async function getPendingReconciliations(ownerId: number): Promise<Manual
|
||||
-- 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
|
||||
-- A voided row is resolved too: the charge never posted, so no statement
|
||||
-- line will ever arrive for it (migration 0040).
|
||||
AND t.voided_at IS NULL
|
||||
AND ${needsCardMatch("t")}
|
||||
ORDER BY t.transaction_date DESC, t.row_index ASC`,
|
||||
[ownerId]
|
||||
@@ -920,6 +923,7 @@ export async function getPendingReconciliations(ownerId: number): Promise<Manual
|
||||
AND m.owner_id = $1
|
||||
AND m.reconciled_with_id IS NULL
|
||||
AND m.superseded_by_id IS NULL
|
||||
AND m.voided_at 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.
|
||||
|
||||
@@ -182,3 +182,67 @@ export async function supersedeTransaction(
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark a statementless row as a charge that never posted (migration 0040).
|
||||
*
|
||||
* The case neither sibling above can express: a card notification is sent for
|
||||
* an AUTHORISATION, and when the merchant releases the hold no statement line
|
||||
* ever arrives. Txn 7951 was a $1,040.41 Airbnb hold on a booking cancelled the
|
||||
* next day -- it sat in the reconcile queue with nothing to match and counted as
|
||||
* spend in every figure. There is no survivor to move curation onto, so nothing
|
||||
* moves: the row and its children stay, and `EXCLUDE_RECONCILED_SOURCE` stops
|
||||
* them counting.
|
||||
*
|
||||
* Refusals, all deliberate:
|
||||
* - a statement row: that is the bank's record of a posted charge, and the
|
||||
* statement must keep summing to its header (`transactions_void_statementless`
|
||||
* enforces this in the database too). A wrong statement row is a supersede.
|
||||
* - a row already reconciled or superseded: it is already out of every figure,
|
||||
* and a reconciled one evidently DID post.
|
||||
* - a row with a SETTLED split: someone has already paid their share of it.
|
||||
* Voiding would silently turn that payment into a credit on their balance;
|
||||
* that needs a person to look at it, not a side effect.
|
||||
*/
|
||||
export async function voidTransaction(id: number, reason: string): Promise<void> {
|
||||
const why = reason.trim();
|
||||
if (!why) throw new Error("A void needs a reason");
|
||||
await prisma.$transaction(async (tx) => {
|
||||
const rows = await tx.$queryRawUnsafe<
|
||||
{ statement_id: number | null; reconciled_with_id: number | null; superseded_by_id: number | null; settled: number }[]
|
||||
>(
|
||||
`SELECT t.statement_id, t.reconciled_with_id, t.superseded_by_id,
|
||||
(SELECT count(*)::int FROM transaction_splits ts
|
||||
WHERE ts.transaction_id = t.id AND ts.settled) AS settled
|
||||
FROM transactions t WHERE t.id = $1 FOR UPDATE`,
|
||||
id
|
||||
);
|
||||
const row = rows[0];
|
||||
if (!row) throw new Error(`Transaction ${id} not found`);
|
||||
if (row.statement_id !== null) {
|
||||
throw new Error(`Transaction ${id} is a statement line; a posted charge cannot be voided (supersede it instead)`);
|
||||
}
|
||||
if (row.reconciled_with_id !== null) {
|
||||
throw new Error(`Transaction ${id} is reconciled with ${row.reconciled_with_id}, so it posted`);
|
||||
}
|
||||
if (row.superseded_by_id !== null) {
|
||||
throw new Error(`Transaction ${id} is already superseded by ${row.superseded_by_id}`);
|
||||
}
|
||||
if (row.settled > 0) {
|
||||
throw new Error(`Transaction ${id} has ${row.settled} settled split(s); resolve those before voiding`);
|
||||
}
|
||||
await tx.$executeRawUnsafe(
|
||||
`UPDATE transactions SET voided_at = now(), void_reason = $1 WHERE id = $2 AND voided_at IS NULL`,
|
||||
why,
|
||||
id
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/** Undo `voidTransaction` -- e.g. the "released" hold posted after all. */
|
||||
export async function unvoidTransaction(id: number): Promise<void> {
|
||||
await prisma.$executeRawUnsafe(
|
||||
`UPDATE transactions SET voided_at = NULL, void_reason = NULL WHERE id = $1`,
|
||||
id
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user