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
+31
View File
@@ -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;
+2
View File
@@ -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)
+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);
});
});
+10 -1
View File
@@ -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 });
}
+1 -1
View File
@@ -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) {
+34 -1
View File
@@ -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>
);
}
+9 -1
View File
@@ -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.
+1
View File
@@ -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
+7
View File
@@ -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 (
+21
View File
@@ -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],
+4
View File
@@ -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.
+64
View File
@@ -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
);
}