diff --git a/CLAUDE.md b/CLAUDE.md index b32b542..6b799e8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -224,6 +224,50 @@ Three details worth knowing before relying on it: `TRUNCATE` does not fire row-level triggers, so the integration suite's table wipes produce no audit rows. +### Resolving a duplicate by hand (`POST /api/transactions//merge`) + +`` is the row that gets **hidden**; `other_id` is the one that **survives**. + +| mode | requires | sets | for | +|---|---|---|---| +| `reconcile` | loser statementless, survivor a statement row | `reconciled_with_id` | the everyday case | +| `supersede` | nothing | `superseded_by_id` | two rows on the same footing, e.g. a statement imported twice | + +**There is no delete, deliberately.** Every child of `transactions` is +`ON DELETE CASCADE`, so removing a row destroys the splits, tags and metadata +that make it legible. Both modes keep the row and its children and merely stop +it counting. Do not add a delete endpoint. + +Both modes **move curation onto the survivor first**, through the single +`moveCurationWithin()` in `lib/reconcile.ts` — because both hide the source row +from every figure, and a split stranded on a hidden row stops counting in +silence. That is also why the two callers share one implementation: the file +header warns that a second copy would eventually move a different set. + +Refusals (all deliberate, all tested in `merge-supersede.test.ts`): a row already +reconciled or superseded on **either** side → 409 (re-pointing it strands the +curation moved on the first pass); superseding under a row that is itself +superseded → 409; a row that is not the caller's → 404. Ownership is checked with +`COALESCE(t.owner_id, s.owner_id)` — a statement row carries `owner_id` on the +statement, so checking `t.owner_id` alone rejects every statement row as "not +found". + +**Why it had to exist.** `superseded_by_id` was read everywhere (analytics and +auto-reconcile both exclude it) but written **nowhere** in application code — set +exactly once, by migration 0023. The reconcile queue was the only merge path, and +it only offers candidates inside an amount band. + +**The band is ±5%, not ±1% (2026-08-23).** A foreign card purchase is charged the +notified amount **plus the card's foreign transaction fee** — 3% on the Westpac +Altitude — so a wallet row and its statement line can never match exactly. +`SP PEAKDESIGN` was notified at **$42.44** and charged **$43.71** (×1.03), which +put the one row that *was* its twin outside the old band: the picker could not +offer the only correct answer. Widening is safe because the list is capped at 5 +by the `rn` window and ordered by amount closeness, so a looser band changes +*which* five appear, never how many, and a person confirms each. Measured on the +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()`. + ### Cash and reconciliation `payment_method` in (`cash`, `credits`) excludes a transaction from diff --git a/src/__tests__/integration/helpers.ts b/src/__tests__/integration/helpers.ts index 99d6e97..b21fb8f 100644 --- a/src/__tests__/integration/helpers.ts +++ b/src/__tests__/integration/helpers.ts @@ -84,6 +84,11 @@ export async function resetDB(pool: Pool) { await assertTestDatabase(pool); await pool.query(` TRUNCATE + -- The audit triggers (migration 0037) fire on every INSERT the suite makes. + -- TRUNCATE does not fire row-level triggers, so nothing here is logged -- + -- but without this line the log is never cleared either, and the table + -- grows by roughly a thousand rows per full run, forever. + transaction_audit, split_payments, transaction_splits, transaction_tags, diff --git a/src/__tests__/integration/merge-supersede.test.ts b/src/__tests__/integration/merge-supersede.test.ts new file mode 100644 index 0000000..c0e6706 --- /dev/null +++ b/src/__tests__/integration/merge-supersede.test.ts @@ -0,0 +1,175 @@ +import { describe, it, expect, beforeAll, beforeEach, vi } from "vitest"; +import { Pool } from "pg"; +import { createPool, resetDB, seedParticipants, insertTransaction } from "./helpers"; + +/** + * Manually resolving a duplicate the automation cannot see. + * + * Until this route existed there was no way to merge or remove a transaction at + * all: `superseded_by_id` was read everywhere and written nowhere in + * application code, having been set exactly once by migration 0023. The only + * merge path was the reconcile queue, whose candidate list is bounded by an + * amount band -- so SP PEAKDESIGN (notified $42.44, charged $43.71, the card's + * 3% foreign transaction fee) had no offerable twin. + * + * The refusals matter more than the happy paths, exactly as in + * auto-reconcile.test.ts: this writes to columns that hide a row from every + * figure, so a wrong call does not error, it quietly stops counting money. + */ + +let pool: Pool; +let ownerId: number; +let otherId: number; +let merge: typeof import("../../app/api/transactions/[id]/merge/route").POST; + +const req = (body: unknown) => ({ json: async () => body, headers: { get: () => null } }) as never; +const call = (id: number, body: unknown) => merge(req(body), { params: Promise.resolve({ id: String(id) }) }); + +// uq_statement_identity is (bank_name, account_number, billing_end_date), so +// every statement in a test needs its own account or the second insert collides. +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, `4444-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; +} + +beforeAll(async () => { + pool = createPool(); + vi.doMock("@/lib/auth", () => ({ + getCurrentUser: async () => ({ id: ownerId, name: "Alice", email: "alice@example.com" }), + })); + ({ 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("reconcile mode", () => { + it("folds a wallet row into its statement twin even though the amounts differ by the FX fee", async () => { + const wallet = await insertTransaction(pool, ownerId, { + description: "SP PEAKDESIGN", amount: 42.44, transaction_date: "2026-08-16", + }); + const stmt = await statementLine(43.71, "2026-08-15", "SP PEAKDESIGN SAN FRANCISCO USA"); + + const res = await call(wallet, { other_id: stmt, mode: "reconcile" }); + expect(res.status ?? 200).toBe(200); + + const r = await pool.query(`SELECT reconciled_with_id FROM transactions WHERE id = $1`, [wallet]); + expect(r.rows[0].reconciled_with_id).toBe(stmt); + }); + + it("refuses when the target is not a statement row", async () => { + const a = await insertTransaction(pool, ownerId, { amount: 10 }); + const b = await insertTransaction(pool, ownerId, { amount: 10 }); + const res = await call(a, { other_id: b, mode: "reconcile" }); + expect(res.status).toBe(400); + }); + + it("refuses when the source is itself a statement row", async () => { + const s1 = await statementLine(10, "2026-08-01", "A"); + const s2 = await statementLine(10, "2026-08-02", "B"); + const res = await call(s1, { other_id: s2, mode: "reconcile" }); + expect(res.status).toBe(400); + }); +}); + +describe("supersede mode", () => { + it("carries splits onto the survivor before hiding the loser", async () => { + // The whole reason supersede moves curation: `superseded_by_id` removes the + // row from every figure, so a split left behind stops counting silently and + // a settled debt reappears as owed. + const loser = await insertTransaction(pool, ownerId, { amount: 80, description: "DUP" }); + const survivor = await insertTransaction(pool, ownerId, { amount: 80, description: "KEEP" }); + await pool.query( + `INSERT INTO transaction_splits (transaction_id, participant_id, share_percent, settled) + VALUES ($1,$2,50,true), ($1,$3,50,false)`, + [loser, ownerId, otherId] + ); + + const res = await call(loser, { other_id: survivor, mode: "supersede" }); + expect(res.status ?? 200).toBe(200); + + const onLoser = await pool.query(`SELECT count(*)::int c FROM transaction_splits WHERE transaction_id=$1`, [loser]); + const onSurvivor = await pool.query( + `SELECT participant_id, share_percent, settled FROM transaction_splits + WHERE transaction_id=$1 ORDER BY participant_id`, [survivor] + ); + expect(onLoser.rows[0].c).toBe(0); + expect(onSurvivor.rows).toHaveLength(2); + // settled must survive the move -- a delete-and-recreate defaults it to false. + expect(onSurvivor.rows.map((r) => r.settled)).toContain(true); + + const marked = await pool.query(`SELECT superseded_by_id FROM transactions WHERE id=$1`, [loser]); + expect(marked.rows[0].superseded_by_id).toBe(survivor); + }); + + it("works between two statement rows, which reconcile cannot express", async () => { + const s1 = await statementLine(25, "2026-08-01", "IMPORTED TWICE"); + const s2 = await statementLine(25, "2026-08-01", "IMPORTED TWICE"); + const res = await call(s1, { other_id: s2, mode: "supersede" }); + expect(res.status ?? 200).toBe(200); + const r = await pool.query(`SELECT superseded_by_id FROM transactions WHERE id=$1`, [s1]); + expect(r.rows[0].superseded_by_id).toBe(s2); + }); + + it("refuses to hide a statement row behind a statementless one", async () => { + // The wrong direction, and silently wrong: a wallet row holds the amount + // BEFORE the card's foreign transaction fee, so burying the statement line + // under it leaves the ledger holding $42.44 for a $43.71 charge. + const stmt = await statementLine(43.71, "2026-08-15", "SP PEAKDESIGN SAN FRANCISCO USA"); + const wallet = await insertTransaction(pool, ownerId, { + description: "SP PEAKDESIGN", amount: 42.44, transaction_date: "2026-08-16", + }); + const res = await call(stmt, { other_id: wallet, mode: "supersede" }); + expect(res.status).toBe(400); + // and the statement row is untouched + const r = await pool.query(`SELECT superseded_by_id FROM transactions WHERE id=$1`, [stmt]); + expect(r.rows[0].superseded_by_id).toBeNull(); + }); + + it("refuses to bury a row under one that is already superseded", async () => { + const a = await insertTransaction(pool, ownerId, { amount: 5 }); + const b = await insertTransaction(pool, ownerId, { amount: 5 }); + const c = await insertTransaction(pool, ownerId, { amount: 5 }); + await call(b, { other_id: c, mode: "supersede" }); // b -> c + const res = await call(a, { other_id: b, mode: "supersede" }); // a -> b, but b is buried + expect(res.status).toBe(409); + }); +}); + +describe("refusals common to both modes", () => { + it("refuses a transaction merging into itself", async () => { + const a = await insertTransaction(pool, ownerId, { amount: 1 }); + const res = await call(a, { other_id: a, mode: "supersede" }); + expect(res.status).toBe(400); + }); + + it("refuses a row that is already reconciled", async () => { + const wallet = await insertTransaction(pool, ownerId, { amount: 12, transaction_date: "2026-08-10" }); + const stmt = await statementLine(12, "2026-08-10", "X"); + await call(wallet, { other_id: stmt, mode: "reconcile" }); + const other = await insertTransaction(pool, ownerId, { amount: 12 }); + const res = await call(wallet, { other_id: other, mode: "supersede" }); + expect(res.status).toBe(409); + }); + + it("refuses a transaction belonging to someone else", async () => { + const mine = await insertTransaction(pool, ownerId, { amount: 7 }); + const theirs = await insertTransaction(pool, otherId, { amount: 7 }); + const res = await call(mine, { other_id: theirs, mode: "supersede" }); + expect(res.status).toBe(404); + }); +}); diff --git a/src/app/api/transactions/[id]/merge/route.ts b/src/app/api/transactions/[id]/merge/route.ts new file mode 100644 index 0000000..72c3e0b --- /dev/null +++ b/src/app/api/transactions/[id]/merge/route.ts @@ -0,0 +1,146 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getCurrentUser } from "@/lib/auth"; +import { queryRaw } from "@/lib/db"; +import { applyReconcileMatch, supersedeTransaction } from "@/lib/reconcile"; + +/** + * Manually resolve a duplicate pair the automation cannot see. + * + * WHY THIS EXISTS: until now there was no way to merge or remove a transaction + * at all. `superseded_by_id` was READ everywhere -- analytics excludes it, + * auto-reconcile excludes it -- but written nowhere in application code; it had + * only ever been set once, by migration 0023. The reconcile queue was the sole + * merge path, and it only offers candidates within a narrow amount band, so a + * pair outside it (SP PEAKDESIGN: notified $42.44, charged $43.71 -- the card's + * 3% foreign transaction fee) was unresolvable from the UI. + * + * There is deliberately NO delete. Every child of `transactions` is + * ON DELETE CASCADE, so removing a row destroys its splits, tags and metadata + * curation. Both modes here keep the row and its children and merely stop it + * counting: + * + * reconcile -- a statementLESS row folded into its statement twin + * (`reconciled_with_id`). The everyday case. + * supersede -- two rows on the same footing, e.g. a statement imported twice + * (`superseded_by_id`). No constraint on either side's origin. + * + * Both move curation onto the survivor FIRST, via one shared implementation in + * lib/reconcile.ts, because both hide the source row from every figure and + * splits stranded on a hidden row silently stop counting. + */ +export async function POST(req: NextRequest, { params }: { params: Promise<{ id: string }> }) { + const user = await getCurrentUser(req); + if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 403 }); + + const { id } = await params; + const loserId = Number(id); + if (!Number.isInteger(loserId)) { + return NextResponse.json({ error: "invalid transaction id" }, { status: 400 }); + } + + let body: { other_id?: number; mode?: string }; + try { + body = await req.json(); + } catch { + return NextResponse.json({ error: "invalid JSON" }, { status: 400 }); + } + + const otherId = Number(body.other_id); + const mode = body.mode ?? "reconcile"; + if (!Number.isInteger(otherId)) { + return NextResponse.json({ error: "other_id is required" }, { status: 400 }); + } + if (loserId === otherId) { + return NextResponse.json({ error: "a transaction cannot merge into itself" }, { status: 400 }); + } + if (mode !== "reconcile" && mode !== "supersede") { + return NextResponse.json({ error: "mode must be 'reconcile' or 'supersede'" }, { status: 400 }); + } + + // Both rows must be the caller's. A statement row carries owner_id on the + // statement rather than the transaction, which is why this reads the same + // COALESCE every scoped query uses -- checking t.owner_id alone would reject + // every statement row as "not found". + const rows = await queryRaw<{ + id: number; + statement_id: number | null; + reconciled_with_id: number | null; + superseded_by_id: number | null; + }>( + `SELECT t.id, t.statement_id, t.reconciled_with_id, t.superseded_by_id + 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`, + [[loserId, otherId], user.id] + ); + if (rows.length !== 2) { + return NextResponse.json({ error: "one or both transactions not found" }, { status: 404 }); + } + const loser = rows.find((r) => r.id === loserId)!; + const other = rows.find((r) => r.id === otherId)!; + + // Refuse a row that is already folded into something else, in either + // direction. Re-pointing it would strand the curation moved on the first + // pass -- and silently, since both columns hide the row from every figure. + for (const [label, row] of [["source", loser], ["target", other]] as const) { + if (row.reconciled_with_id !== null) { + return NextResponse.json( + { error: `${label} transaction ${row.id} is already reconciled with ${row.reconciled_with_id}` }, + { status: 409 } + ); + } + if (row.superseded_by_id !== null) { + return NextResponse.json( + { error: `${label} transaction ${row.id} is already superseded by ${row.superseded_by_id}` }, + { status: 409 } + ); + } + } + + try { + if (mode === "reconcile") { + // applyReconcileMatch's contract, enforced here so a misuse is a 400 and + // not a row quietly linked the wrong way round. + if (loser.statement_id !== null) { + return NextResponse.json( + { error: "reconcile expects the source to be a statementless row; use mode 'supersede'" }, + { status: 400 } + ); + } + if (other.statement_id === null) { + return NextResponse.json( + { error: "reconcile expects the target to be a statement row; use mode 'supersede'" }, + { status: 400 } + ); + } + await applyReconcileMatch(loserId, otherId); + } else { + // Never hide a statement row behind a statementless one. The statement is + // the bank's own record; the statementless row is a notification, a + // receipt or a hand entry, and is the side that can be wrong -- a wallet + // row carries the amount BEFORE the card's foreign transaction fee, so + // burying the statement line under it would leave the ledger holding + // $42.44 for a purchase the bank charged $43.71 for, with the statement + // no longer reconciling against its own rows. The correct move is the + // other direction, which is exactly what `reconcile` does. + // + // Statement-under-statement stays allowed: that is the imported-twice + // pair migration 0023 existed for, and neither side is more true. + if (loser.statement_id !== null && other.statement_id === null) { + return NextResponse.json( + { + error: + "Refusing to hide a statement row behind a statementless one — the statement is the bank's record. " + + `Open transaction ${otherId} instead and fold it into this one.`, + }, + { status: 400 } + ); + } + await supersedeTransaction(loserId, otherId); + } + } catch (e) { + return NextResponse.json({ error: e instanceof Error ? e.message : String(e) }, { status: 400 }); + } + + return NextResponse.json({ ok: true, mode, source: loserId, target: otherId }); +} diff --git a/src/components/edit-transaction-modal.tsx b/src/components/edit-transaction-modal.tsx index 3035544..21bd9b7 100644 --- a/src/components/edit-transaction-modal.tsx +++ b/src/components/edit-transaction-modal.tsx @@ -1,8 +1,9 @@ "use client"; -import { useState } from "react"; +import { useEffect, useState } from "react"; import { useUpdateTransaction, + useMergeTransaction, useTags, useAddTransactionTag, useRemoveTransactionTag, @@ -93,6 +94,210 @@ function InlineTags({ transactionId, initialTags }: { transactionId: number; ini ); } +/** + * Determines which merge mode the backend will accept for (loser, candidate). + * Mirrors the constraint enforced server-side in the merge route: reconcile + * requires a statementless loser folding into a statement survivor; every + * other pairing is a supersede. + */ +function mergeModeFor(loser: TransactionRow, candidate: TransactionRow): "reconcile" | "supersede" { + return loser.statement_id === null && candidate.statement_id !== null ? "reconcile" : "supersede"; +} + +/** + * Collapsed by default, at the bottom, visually separate — this is the one + * control on the modal that changes which rows this transaction can ever be + * again. `transaction` is always the row that will be hidden (the loser); + * the row picked here is the survivor (`other_id`). + */ +function DuplicateSection({ transaction, onMerged }: { transaction: TransactionRow; onMerged: () => void }) { + const [expanded, setExpanded] = useState(false); + const [query, setQuery] = useState(""); + const [results, setResults] = useState([]); + const [searching, setSearching] = useState(false); + const [selected, setSelected] = useState(null); + const mergeTxn = useMergeTransaction(); + + useEffect(() => { + if (!expanded || selected) return; + const q = query.trim(); + // No synchronous setState here: an empty box is handled at render time, and + // `searching` is raised inside the timeout. Clearing state here instead + // cascades a render on every keystroke (react-hooks lint catches it). + if (q.length === 0) return; + const handle = setTimeout(() => { + setSearching(true); + fetch(`/api/transactions?search=${encodeURIComponent(q)}&limit=8`) + .then((res) => res.json()) + .then((body: { data?: TransactionRow[] }) => { + setResults((body.data ?? []).filter((r) => r.id !== transaction.id)); + }) + .catch(() => setResults([])) + .finally(() => setSearching(false)); + }, 300); + return () => clearTimeout(handle); + }, [query, expanded, selected, transaction.id]); + + function reset() { + setExpanded(false); + setQuery(""); + setResults([]); + setSelected(null); + mergeTxn.reset(); + } + + async function handleConfirm() { + if (!selected) return; + try { + await mergeTxn.mutateAsync({ + id: transaction.id, + other_id: selected.id, + mode: mergeModeFor(transaction, selected), + }); + onMerged(); + } catch { + // Surfaced below via mergeTxn.error — section stays open so they can retry. + } + } + + const mode = selected ? mergeModeFor(transaction, selected) : null; + // Hiding a statement row behind a statementless one is always backwards — the + // bank's record is the side that survives. The API refuses it too; catching it + // here just means the button explains itself instead of returning a 400. + const wrongDirection = + selected !== null && transaction.statement_id !== null && selected.statement_id === null; + + return ( +
+ {!expanded ? ( + + ) : ( +
+
+

Mark as duplicate

+ +
+ + {!selected ? ( + <> + setQuery(e.target.value)} + placeholder="Search for the surviving transaction…" + autoFocus + className="w-full bg-zinc-800 border border-zinc-700 rounded px-2 py-1.5 text-sm" + /> + {searching &&

Searching…

} + {!searching && query.trim().length > 0 && results.length === 0 && ( +

No matches

+ )} + {/* Gated on the query too: results are no longer cleared when the + box empties, so without this the last search's rows linger. */} + {query.trim().length > 0 && results.length > 0 && ( +
+ {results.map((r) => ( + + ))} +
+ )} + + ) : ( +
+
+
+ {selected.description} + + {formatAmount(selected.amount, selected.transaction_type)} + +
+
+ + {new Date(selected.transaction_date).toLocaleDateString("en-AU", { day: "numeric", month: "short", year: "numeric" })} + + + {selected.statement_id !== null ? "Statement row" : "Statementless"} + +
+
+ {wrongDirection ? ( +

+ This is the wrong way round — the statement is the bank's record, so it + has to be the row that survives. Close this and open{" "} + {selected.description} instead, then + fold that one into this. +

+ ) : ( +

+ {mode === "reconcile" + ? "This row will be folded into the statement row above." + : "This row will be marked a duplicate of the row above."} +

+ )} + {mergeTxn.error && ( +

{(mergeTxn.error as Error).message}

+ )} +
+ + +
+
+ )} +
+ )} +
+ ); +} + export function EditTransactionModal({ transaction, onClose, @@ -416,6 +621,8 @@ export function EditTransactionModal({ + + {/* Footer */} diff --git a/src/lib/hooks.ts b/src/lib/hooks.ts index 75ec023..1e7286c 100644 --- a/src/lib/hooks.ts +++ b/src/lib/hooks.ts @@ -177,6 +177,40 @@ export function useUpdateTransaction() { }); } +export function useMergeTransaction() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: async ({ + id, + other_id, + mode, + }: { + id: number; + other_id: number; + mode: "reconcile" | "supersede"; + }) => { + const res = await fetch(`/api/transactions/${id}/merge`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ other_id, mode }), + }); + if (!res.ok) { + const body = await res.json().catch(() => ({})); + throw new Error(body.error || "Failed to merge transaction"); + } + return res.json(); + }, + onSuccess: () => { + qc.invalidateQueries({ queryKey: ["transactions"] }); + qc.invalidateQueries({ queryKey: ["transaction"] }); + qc.invalidateQueries({ queryKey: ["splits"] }); + qc.invalidateQueries({ queryKey: ["analytics"] }); + qc.invalidateQueries({ queryKey: ["participant-balances"] }); + qc.invalidateQueries({ queryKey: ["shared-transactions"] }); + }, + }); +} + export function useBulkAction() { const qc = useQueryClient(); return useMutation({ diff --git a/src/lib/queries.ts b/src/lib/queries.ts index f171e99..1156261 100644 --- a/src/lib/queries.ts +++ b/src/lib/queries.ts @@ -887,7 +887,17 @@ export async function getPendingReconciliations(ownerId: number): Promise { await prisma.$transaction(async (tx) => { + await moveCurationWithin(tx, manual_id, statement_tx_id); + + // Mark manual tx as reconciled (link to statement tx) + await tx.$executeRawUnsafe( + `UPDATE transactions SET reconciled_with_id = $1 WHERE id = $2`, + statement_tx_id, + manual_id + ); + }); +} + +/** + * Move overrides, tags, splits and expense_metadata from one transaction to + * another, inside a caller-supplied transaction. + * + * Callers: `applyReconcileMatch` (then sets `reconciled_with_id`) and + * `supersedeTransaction` (then sets `superseded_by_id`). Both hide the source + * row from every figure, so both must carry its curation across first; keeping + * one implementation is what the file header insists on. + */ +async function moveCurationWithin( + tx: Parameters[0]>[0], + manual_id: number, + statement_tx_id: number +): Promise { + { // Copy overrides: manual → statement tx const override = await tx.transaction_overrides.findUnique({ where: { transaction_id: manual_id }, @@ -106,12 +132,53 @@ export async function applyReconcileMatch( manual_id ); } + } +} - // Mark manual tx as reconciled (link to statement tx) +/** + * Manually mark `loser_id` as superseded by `survivor_id`. + * + * The escape hatch for a duplicate that `applyReconcileMatch` cannot express: + * that one merges a statementLESS row into its statement twin, so it has + * nothing to say about two rows on the SAME footing -- the pair left by a + * statement imported twice (migration 0023 superseded 31 such pairs, $42,040.68, + * by hand), or two feed rows for one purchase. + * + * Curation moves FIRST, using the same block `applyReconcileMatch` runs, because + * `superseded_by_id` hides the row from every figure (`analytics-sql.ts`) and + * splits or tags left on it would silently stop counting. That is why this lives + * beside its sibling and calls into the same movement rather than re-deriving + * it: the header of this file warns that a second copy would eventually move a + * different set, and a supersede that forgot splits would resurrect a debt in + * exactly the way `applyReconcileMatch`'s own comment describes. + * + * NOT a delete. Every child of `transactions` is ON DELETE CASCADE, so removing + * the row would destroy the splits, tags and metadata that make it legible -- + * and a duplicate is evidence of how the ledger was assembled, which is worth + * keeping. `transactions_no_self_supersede` blocks a row pointing at itself; the + * survivor is re-checked here anyway so the error is a 400 and not a 500. + */ +export async function supersedeTransaction( + loser_id: number, + survivor_id: number +): Promise { + if (loser_id === survivor_id) throw new Error("A transaction cannot supersede itself"); + await prisma.$transaction(async (tx) => { + // Refuse to bury a row underneath one that is itself already buried, which + // would make the curation we just moved unreachable in a second hop. + const survivor = await tx.transactions.findUnique({ + where: { id: survivor_id }, + select: { superseded_by_id: true }, + }); + if (!survivor) throw new Error(`Survivor transaction ${survivor_id} not found`); + if (survivor.superseded_by_id !== null) { + throw new Error(`Transaction ${survivor_id} is itself superseded; pick the surviving row`); + } + await moveCurationWithin(tx, loser_id, survivor_id); await tx.$executeRawUnsafe( - `UPDATE transactions SET reconciled_with_id = $1 WHERE id = $2`, - statement_tx_id, - manual_id + `UPDATE transactions SET superseded_by_id = $1 WHERE id = $2`, + survivor_id, + loser_id ); }); }