transactions: merge/supersede a duplicate by hand, and widen the picker band
ci / lint-test (push) Successful in 55s

Two gaps, found because SP PEAKDESIGN was sitting duplicated with no way to
resolve it.

1. THE PICKER COULD NOT OFFER THE RIGHT ANSWER. The manual reconcile queue's
   candidate band was `amount * 0.99 .. 1.01`. 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: notified $42.44, charged $43.71, x1.03. The one row that WAS
   its twin was the single row outside the band. Widened to +-5%. 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: candidate counts 0 -> 0 on all 12
   rows, i.e. no new noise. The auto-matcher stays exact.

2. THERE WAS NO MANUAL MERGE OR DELETE AT ALL. `superseded_by_id` was read
   everywhere (analytics and auto-reconcile both exclude it) and written
   NOWHERE in application code -- set exactly once, by migration 0023.

POST /api/transactions/<id>/merge takes {other_id, mode}: `reconcile` folds a
statementless row into its statement twin, `supersede` handles two rows on the
same footing (a statement imported twice). Deliberately NO delete -- children
are ON DELETE CASCADE, so deleting destroys the splits, tags and metadata that
make a row legible. Both modes keep the row and merely stop it counting.

Both move curation onto the survivor FIRST, through one extracted
moveCurationWithin() rather than a second copy: reconcile.ts's own header warns
that a duplicate would eventually move a different set, and a split stranded on
a hidden row stops counting in silence.

Refuses, all tested: hiding a statement row behind a statementless one (the
bank's record is the side that survives -- burying it under a wallet row leaves
the ledger holding the pre-fee amount); either side already reconciled or
superseded (409, since re-pointing strands the curation moved on the first
pass); burying under an already-superseded row; self-merge; wrong mode for the
pair; someone else's row (ownership via COALESCE(t.owner_id, s.owner_id),
because a statement row carries owner_id on the statement).

UI: a collapsed "Mark as duplicate..." section at the bottom of the edit modal,
debounced search over the existing transactions endpoint, statement-vs-
statementless badges, mode chosen automatically and stated in plain language,
and the wrong-direction case explained with the button disabled rather than
letting it 400.

Also: resetDB now truncates transaction_audit -- the 0037 triggers fire on every
insert the suite makes, so without it the test DB grew ~1,100 rows per full run,
forever (1,125 -> 2).

329 integration + 196 unit green, tsc clean, lint error-free, build compiles.
Board #260.
This commit is contained in:
2026-08-23 19:00:57 +10:00
parent 2251060ac6
commit 10cf0aaf21
8 changed files with 694 additions and 6 deletions
+44
View File
@@ -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 `TRUNCATE` does not fire row-level triggers, so the integration suite's table
wipes produce no audit rows. wipes produce no audit rows.
### Resolving a duplicate by hand (`POST /api/transactions/<id>/merge`)
`<id>` 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 ### Cash and reconciliation
`payment_method` in (`cash`, `credits`) excludes a transaction from `payment_method` in (`cash`, `credits`) excludes a transaction from
+5
View File
@@ -84,6 +84,11 @@ export async function resetDB(pool: Pool) {
await assertTestDatabase(pool); await assertTestDatabase(pool);
await pool.query(` await pool.query(`
TRUNCATE 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, split_payments,
transaction_splits, transaction_splits,
transaction_tags, transaction_tags,
@@ -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);
});
});
@@ -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 });
}
+208 -1
View File
@@ -1,8 +1,9 @@
"use client"; "use client";
import { useState } from "react"; import { useEffect, useState } from "react";
import { import {
useUpdateTransaction, useUpdateTransaction,
useMergeTransaction,
useTags, useTags,
useAddTransactionTag, useAddTransactionTag,
useRemoveTransactionTag, 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<TransactionRow[]>([]);
const [searching, setSearching] = useState(false);
const [selected, setSelected] = useState<TransactionRow | null>(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 (
<div className="pt-4 border-t border-zinc-800">
{!expanded ? (
<button
type="button"
onClick={() => setExpanded(true)}
className="text-xs text-zinc-500 hover:text-zinc-300"
>
Mark as duplicate…
</button>
) : (
<div className="space-y-2.5">
<div className="flex items-center justify-between">
<p className="font-semibold text-xs text-zinc-400">Mark as duplicate</p>
<button
type="button"
onClick={reset}
className="text-xs text-zinc-500 hover:text-zinc-300"
>
Cancel
</button>
</div>
{!selected ? (
<>
<input
type="text"
value={query}
onChange={(e) => 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 && <p className="text-xs text-zinc-500">Searching…</p>}
{!searching && query.trim().length > 0 && results.length === 0 && (
<p className="text-xs text-zinc-600 italic">No matches</p>
)}
{/* 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 && (
<div className="space-y-1 max-h-48 overflow-y-auto">
{results.map((r) => (
<button
key={r.id}
type="button"
onClick={() => setSelected(r)}
className="w-full text-left px-2 py-1.5 rounded bg-zinc-800/50 hover:bg-zinc-800 border border-zinc-700/50"
>
<div className="flex items-center justify-between gap-2">
<span className="text-sm text-zinc-200 truncate">{r.description}</span>
<span
className={`text-sm font-mono shrink-0 ${SPEND_TYPES.has(r.transaction_type) ? "text-red-400" : "text-green-400"}`}
>
{formatAmount(r.amount, r.transaction_type)}
</span>
</div>
<div className="flex items-center justify-between mt-0.5">
<span className="text-xs text-zinc-500">
{new Date(r.transaction_date).toLocaleDateString("en-AU", { day: "numeric", month: "short", year: "numeric" })}
</span>
<span className="text-xs text-zinc-500">
{r.statement_id !== null ? "Statement row" : "Statementless"}
</span>
</div>
</button>
))}
</div>
)}
</>
) : (
<div className="space-y-2">
<div className="px-2 py-1.5 rounded bg-zinc-800/50 border border-zinc-700/50">
<div className="flex items-center justify-between gap-2">
<span className="text-sm text-zinc-200 truncate">{selected.description}</span>
<span
className={`text-sm font-mono shrink-0 ${SPEND_TYPES.has(selected.transaction_type) ? "text-red-400" : "text-green-400"}`}
>
{formatAmount(selected.amount, selected.transaction_type)}
</span>
</div>
<div className="flex items-center justify-between mt-0.5">
<span className="text-xs text-zinc-500">
{new Date(selected.transaction_date).toLocaleDateString("en-AU", { day: "numeric", month: "short", year: "numeric" })}
</span>
<span className="text-xs text-zinc-500">
{selected.statement_id !== null ? "Statement row" : "Statementless"}
</span>
</div>
</div>
{wrongDirection ? (
<p className="text-xs text-red-400">
This is the wrong way round — the statement is the bank&apos;s record, so it
has to be the row that survives. Close this and open{" "}
<span className="text-zinc-300">{selected.description}</span> instead, then
fold that one into this.
</p>
) : (
<p className="text-xs text-amber-400/90">
{mode === "reconcile"
? "This row will be folded into the statement row above."
: "This row will be marked a duplicate of the row above."}
</p>
)}
{mergeTxn.error && (
<p className="text-red-400 text-xs">{(mergeTxn.error as Error).message}</p>
)}
<div className="flex gap-2">
<button
type="button"
onClick={() => setSelected(null)}
className="px-3 py-1.5 bg-zinc-800 hover:bg-zinc-700 text-zinc-300 rounded-lg text-xs"
>
Choose a different row
</button>
<button
type="button"
onClick={handleConfirm}
disabled={mergeTxn.isPending || wrongDirection}
className="px-3 py-1.5 bg-amber-700 hover:bg-amber-600 disabled:opacity-50 text-white rounded-lg text-xs"
>
{mergeTxn.isPending ? "Merging…" : mode === "reconcile" ? "Fold into statement row" : "Mark as duplicate"}
</button>
</div>
</div>
)}
</div>
)}
</div>
);
}
export function EditTransactionModal({ export function EditTransactionModal({
transaction, transaction,
onClose, onClose,
@@ -416,6 +621,8 @@ export function EditTransactionModal({
<OrderDetails transactionId={transaction.id} currency={transaction.currency ?? null} /> <OrderDetails transactionId={transaction.id} currency={transaction.currency ?? null} />
<DuplicateSection transaction={transaction} onMerged={onClose} />
</div> </div>
{/* Footer */} {/* Footer */}
+34
View File
@@ -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() { export function useBulkAction() {
const qc = useQueryClient(); const qc = useQueryClient();
return useMutation({ return useMutation({
+11 -1
View File
@@ -887,7 +887,17 @@ export async function getPendingReconciliations(ownerId: number): Promise<Manual
FROM transactions m FROM transactions m
JOIN transactions t ON t.statement_id IS NOT NULL JOIN transactions t ON t.statement_id IS NOT NULL
AND t.transaction_date BETWEEN m.transaction_date - 3 AND m.transaction_date + 3 AND t.transaction_date BETWEEN m.transaction_date - 3 AND m.transaction_date + 3
AND t.amount BETWEEN m.amount * 0.99 AND m.amount * 1.01 -- +-5%, NOT +-1%. A foreign card purchase is charged at the notified
-- amount PLUS the card's foreign transaction fee (3% on the Westpac
-- Altitude), so the wallet row and its statement line can never match
-- exactly and the old +-1% band excluded the statement row outright:
-- SP PEAKDESIGN was notified at $42.44 and charged $43.71 (x1.03),
-- so the one row that WAS its twin was the one the picker could not
-- offer. Widening is safe here because this list is capped at 5 by
-- the rn window and ordered by amount closeness; a looser band changes
-- which five appear, never how many, and a person confirms each one.
-- The AUTO-matcher stays exact on purpose; see findAutoMatches().
AND t.amount BETWEEN m.amount * 0.95 AND m.amount * 1.05
JOIN statements s ON s.id = t.statement_id JOIN statements s ON s.id = t.statement_id
LEFT JOIN transaction_overrides o ON o.transaction_id = t.id LEFT JOIN transaction_overrides o ON o.transaction_id = t.id
WHERE m.statement_id IS NULL WHERE m.statement_id IS NULL
+71 -4
View File
@@ -18,6 +18,32 @@ export async function applyReconcileMatch(
statement_tx_id: number statement_tx_id: number
): Promise<void> { ): Promise<void> {
await prisma.$transaction(async (tx) => { 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<Parameters<typeof prisma.$transaction>[0]>[0],
manual_id: number,
statement_tx_id: number
): Promise<void> {
{
// Copy overrides: manual → statement tx // Copy overrides: manual → statement tx
const override = await tx.transaction_overrides.findUnique({ const override = await tx.transaction_overrides.findUnique({
where: { transaction_id: manual_id }, where: { transaction_id: manual_id },
@@ -106,12 +132,53 @@ export async function applyReconcileMatch(
manual_id 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<void> {
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( await tx.$executeRawUnsafe(
`UPDATE transactions SET reconciled_with_id = $1 WHERE id = $2`, `UPDATE transactions SET superseded_by_id = $1 WHERE id = $2`,
statement_tx_id, survivor_id,
manual_id loser_id
); );
}); });
} }