Let everyone on a trip see it, and give payments their scope back
ci / lint-test (push) Successful in 52s

Trips were scoped to trips.owner_id, so Sonu saw no trips at all — despite
having paid for 104 of the tagged rows herself. Her own spending was invisible
on the only page organised around it.

A participant is now anyone with a split on, who paid for, or whose payment is
scoped to, a transaction tagged to the trip. Derived, not stored. A
trip_participants table was designed and rejected: the expenses already carry
the fact, and two records of one fact drift apart. Deriving it also excludes
Singapore + Bangkok 2026 from Sonu for free, which a table would have to be kept
in sync to do. Siddharth 4 trips, Sonu 3, Molina 1.

Everything about a trip is shared except delete. Both trip foreign keys are
ON DELETE SET NULL, so deleting Europe 2026 untags 210 transactions and NULLs
the trip scope on 6 payments — where the hand-derived Europe-first allocation
lives, which nothing recomputes. That stays with the owner.

Trip owed now returns both directions and nets neither. An obligation lives on a
row someone else paid for, so a viewer-as-payer figure can never hold it, and
Sonu's Europe read "you are owed $2,408.24" while omitting the $8,004.04 she
owed. Collapsing the two into a signed net is the tempting next step and would
have corrupted the scope allocation: the grouped-payment allocation cleared each
trip against the one-directional gross, so redefining the debt afterwards turns
$8,004.04 already allocated into an $802.75 over-allocation with household
understated by the same amount. Verified byte-identical — Auckland $1,505.64,
Europe Molina -$816.16, Europe Sonu $0.00, Sonu + Sunny $0.00.

getTransactions gained trip_all_rows so a participant sees the whole trip. It is
opt-in and not implied by trip_id, because the same endpoint backs the main
transactions list and its trip filter must keep owner scoping. Participation is
re-checked in SQL, so passing the flag for someone else's trip returns nothing.

Payments can finally say what they settle. trip_id has existed since migration
0022 but POST never read it and GET never returned it, so every payment made in
the app landed on household and the 9 trip-scoped rows were hand-written SQL.
"Both" needs no new shape — one row per scope sharing a linked_transaction_id.

Three write paths had no authorisation at all and were reachable by any
participant: assignTransactionsToTrip checked nothing, DELETE on a payment
deleted by bare id, and POST accepted any from/to pair. All three now check.

Also fixes the test suite, which was pointing at postgres-pantry: container IPs
move on recreation and 172.22.0.47 stopped being postgres-personal. It only
failed safe because the credentials did not match — resetDB now refuses to
truncate anything not named personal_test.

22 new tests, 276 passing, build clean.
This commit is contained in:
2026-08-02 19:11:19 +10:00
parent 6e179d3a0a
commit cb7665ded1
13 changed files with 904 additions and 78 deletions
+40 -2
View File
@@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from "next/server";
import { getCurrentUser } from "@/lib/auth";
import { queryRaw } from "@/lib/db";
import { prisma } from "@/lib/db";
import { isTripParticipant } from "@/lib/queries";
export async function GET(req: NextRequest) {
const user = await getCurrentUser(req);
@@ -21,15 +22,22 @@ export async function GET(req: NextRequest) {
payment_date: string;
notes: string | null;
linked_transaction_id: number | null;
trip_id: number | null;
trip_name: string | null;
created_at: string;
}>(
// trip_id was stored but never returned, so history could not show which tab
// a payment settled — and a grouped transfer looks like a duplicate until you
// can see that its rows carry different scopes.
`SELECT sp.id, sp.from_participant_id, pf.name as from_name,
sp.to_participant_id, pt.name as to_name,
sp.amount, sp.payment_date, sp.notes,
sp.linked_transaction_id, sp.created_at
sp.linked_transaction_id, sp.trip_id, tr.name as trip_name,
sp.created_at
FROM split_payments sp
JOIN participants pf ON pf.id = sp.from_participant_id
JOIN participants pt ON pt.id = sp.to_participant_id
LEFT JOIN trips tr ON tr.id = sp.trip_id
WHERE (sp.from_participant_id = $1 OR sp.to_participant_id = $1)
AND (sp.from_participant_id = $2 OR sp.to_participant_id = $2)
ORDER BY sp.payment_date DESC, sp.created_at DESC`,
@@ -50,9 +58,10 @@ export async function POST(req: NextRequest) {
payment_date: string;
notes?: string;
linked_transaction_id?: number;
trip_id?: number | null;
};
const { from_participant_id, to_participant_id, amount, payment_date, notes, linked_transaction_id } = body;
const { from_participant_id, to_participant_id, amount, payment_date, notes, linked_transaction_id, trip_id } = body;
if (!from_participant_id || !to_participant_id || !amount || !payment_date) {
return NextResponse.json({ error: "Missing required fields" }, { status: 400 });
@@ -60,6 +69,24 @@ export async function POST(req: NextRequest) {
if (amount <= 0) {
return NextResponse.json({ error: "Amount must be positive" }, { status: 400 });
}
if (from_participant_id !== user.id && to_participant_id !== user.id) {
return NextResponse.json({ error: "A payment must involve you" }, { status: 403 });
}
// Scope. `trip_id` existed in the schema from migration 0022 but this route
// never read it, so every payment recorded in the app landed on the household
// tab and the 9 trip-scoped rows had to be written by hand in SQL.
//
// "Both" needs no extra shape: one transfer becomes one row per scope, all
// carrying the same linked_transaction_id — there is deliberately no unique
// constraint on it. That is how tx 4121's $4,794.06 sits as $1,145.52 against
// Europe — Sonu + Sunny and $3,648.54 against household.
if (trip_id != null && !(await isTripParticipant(trip_id, user.id))) {
return NextResponse.json(
{ error: "Cannot scope a payment to a trip you are not on" },
{ status: 403 }
);
}
const payment = await prisma.split_payments.create({
data: {
@@ -69,6 +96,7 @@ export async function POST(req: NextRequest) {
payment_date: new Date(payment_date),
notes: notes || null,
linked_transaction_id: linked_transaction_id || null,
trip_id: trip_id ?? null,
},
});
@@ -83,6 +111,16 @@ export async function DELETE(req: NextRequest) {
const id = Number(sp.get("id"));
if (!id) return NextResponse.json({ error: "id required" }, { status: 400 });
// This deleted by id with no check at all: any authenticated participant could
// erase any settlement, which silently resurrects a discharged debt — the same
// class of damage as the split rewrite that reset `settled`. Deleting a payment
// must be limited to the two people it is between.
const existing = await prisma.split_payments.findUnique({ where: { id } });
if (!existing) return NextResponse.json({ error: "Not found" }, { status: 404 });
if (existing.from_participant_id !== user.id && existing.to_participant_id !== user.id) {
return NextResponse.json({ error: "Not your payment to delete" }, { status: 403 });
}
await prisma.split_payments.delete({ where: { id } });
return NextResponse.json({ ok: true });
}
@@ -28,10 +28,14 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
});
// Assign all transactions with this tag to the new trip
// The creator owns the new trip, so they participate in it by definition and
// the assignment's participation gate passes. `assigned` is what actually
// moved: rows the creator cannot see are skipped, so a tag spanning someone
// else's transactions converts to a trip holding only the creator's.
const transactionIds = await getTagTransactionIds(tagId);
if (transactionIds.length > 0) {
await assignTransactionsToTrip(trip.id, transactionIds);
}
const assigned = transactionIds.length > 0
? await assignTransactionsToTrip(trip.id, transactionIds, user.id)
: 0;
return NextResponse.json({ trip, assigned: transactionIds.length }, { status: 201 });
return NextResponse.json({ trip, assigned, tagged: transactionIds.length }, { status: 201 });
}
+11 -2
View File
@@ -122,8 +122,17 @@ export async function POST(req: NextRequest) {
if (action === "assign_trip") {
const { trip_id } = body as { ids: number[]; trip_id: number | null };
await assignTransactionsToTrip(trip_id, ids);
return NextResponse.json({ updated: ids.length });
try {
// `updated` is what actually moved, not what was asked for — ids the
// caller cannot see are skipped rather than silently applied.
const updated = await assignTransactionsToTrip(trip_id, ids, user.id);
return NextResponse.json({ updated, requested: ids.length });
} catch (e) {
return NextResponse.json(
{ error: e instanceof Error ? e.message : "Failed to assign" },
{ status: 403 }
);
}
}
return NextResponse.json({ error: "Invalid action" }, { status: 400 });
+1
View File
@@ -28,6 +28,7 @@ export async function GET(req: NextRequest) {
amount_max: sp.get("amount_max") ? Number(sp.get("amount_max")) : undefined,
has_split: sp.get("has_split") || undefined,
trip_id: sp.get("trip_id") || undefined,
trip_all_rows: sp.get("trip_all_rows") === "1" || undefined,
});
return NextResponse.json(result);
+13
View File
@@ -21,10 +21,23 @@ export async function PATCH(req: NextRequest, { params }: { params: Promise<{ id
return NextResponse.json(trip);
}
// Everything else about a trip is shared; delete is not. Both trip foreign keys
// are ON DELETE SET NULL, so this untags every transaction on the trip and drops
// the trip scope from its payments — including the hand-derived Europe-first
// allocation, which nothing recomputes. A participant gets a 403 that says so
// rather than a 404 that pretends the trip is not there.
export async function DELETE(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
const user = await getCurrentUser(req);
if (!user) return NextResponse.json({ error: "Forbidden" }, { status: 403 });
const { id } = await params;
const trip = await getTripById(Number(id), user.id);
if (!trip) return NextResponse.json({ error: "Not found" }, { status: 404 });
if (trip.owner_id !== user.id) {
return NextResponse.json(
{ error: "Only the trip owner can delete a trip. Deleting it would untag every transaction on it and unscope its payments." },
{ status: 403 }
);
}
await deleteTrip(Number(id), user.id);
return new NextResponse(null, { status: 204 });
}
+9 -2
View File
@@ -10,6 +10,13 @@ export async function PATCH(req: NextRequest, { params }: { params: Promise<{ id
if (!Array.isArray(transactionIds) || !transactionIds.length) {
return NextResponse.json({ error: "transactionIds must be a non-empty array" }, { status: 400 });
}
await assignTransactionsToTrip(Number(id), transactionIds);
return NextResponse.json({ ok: true });
try {
const assigned = await assignTransactionsToTrip(Number(id), transactionIds, user.id);
return NextResponse.json({ ok: true, assigned, requested: transactionIds.length });
} catch (e) {
return NextResponse.json(
{ error: e instanceof Error ? e.message : "Failed to assign" },
{ status: 403 }
);
}
}
+29
View File
@@ -11,6 +11,7 @@ import {
useDeletePayment,
useCurrentUser,
useTags,
useTrips,
type SplitPayment,
} from "@/lib/hooks";
import type { SharedTransactionRow } from "@/lib/queries";
@@ -147,6 +148,7 @@ function RecordPaymentModal({
onClose: () => void;
}) {
const record = useRecordPayment();
const { data: trips = [] } = useTrips();
const theyOweMe = currentBalance > 0;
// Default direction matches the debt direction
@@ -155,6 +157,8 @@ function RecordPaymentModal({
const [notes, setNotes] = useState("");
// direction: "received" = they paid me, "sent" = I paid them
const [direction, setDirection] = useState<"received" | "sent">(theyOweMe ? "received" : "sent");
// Which tab this settles. "" = the ongoing household tab (trip_id NULL).
const [tripId, setTripId] = useState("");
const [error, setError] = useState("");
async function handleSave() {
@@ -168,6 +172,7 @@ function RecordPaymentModal({
amount: amt,
payment_date: date,
notes: notes || undefined,
trip_id: tripId ? Number(tripId) : null,
});
onClose();
} catch (e) {
@@ -216,6 +221,24 @@ function RecordPaymentModal({
</div>
</div>
{/* Scope. Until now every payment recorded here landed on the household
tab, because the API dropped trip_id — so a $11k Europe settlement
silently reduced the ongoing household balance instead. */}
<div>
<label className="block text-xs text-zinc-500 mb-1">Settles</label>
<select value={tripId} onChange={(e) => setTripId(e.target.value)}
className="w-full bg-zinc-800 border border-zinc-700 rounded px-2 py-1.5 text-sm">
<option value="">Household (ongoing)</option>
{trips.filter((t) => !t.archived).map((t) => (
<option key={t.id} value={t.id}>{t.name}</option>
))}
</select>
<p className="text-[11px] text-zinc-600 mt-1">
Covering more than one tab? Record it once per tab the parts add back
up to the transfer.
</p>
</div>
<div>
<label className="block text-xs text-zinc-500 mb-1">Notes (optional)</label>
<input value={notes} onChange={(e) => setNotes(e.target.value)}
@@ -259,6 +282,12 @@ function PaymentHistory({ participantId, currentUserId }: { participantId: numbe
{theyPaidMe ? "+" : "-"}${Number(p.amount).toFixed(2)}
</span>
<span className="text-zinc-500">{formatDate(p.payment_date)}</span>
{/* Scope, so a grouped transfer stops looking like a duplicate: two
rows of the same amount and date differ only by which tab they
settle, and that was invisible until the API returned trip_id. */}
<span className="text-[11px] px-1.5 py-0.5 rounded bg-zinc-800 text-zinc-400 flex-shrink-0">
{p.trip_name ?? "Household"}
</span>
{p.notes && <span className="text-zinc-600 truncate flex-1">{p.notes}</span>}
<button
onClick={() => deletePayment.mutate(p.id)}
+65 -20
View File
@@ -21,6 +21,10 @@ function fmtDate(d: string | null) {
return new Date(d).toLocaleDateString("en-AU", { day: "2-digit", month: "short", year: "numeric" });
}
function fmt(n: number) {
return `$${n.toLocaleString("en-AU", { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
}
function StatCard({
label,
value,
@@ -73,7 +77,10 @@ export default function TripDetailPage({ params }: { params: Promise<{ id: strin
const [tab, setTab] = useState<"overview" | "transactions">("overview");
const [editModal, setEditModal] = useState(false);
const { data: txData } = useTransactions({ trip_id: id, limit: 500 });
// A trip is all the expenses on one trip, so a participant sees every row on
// it, not only their own. The server re-checks participation — this flag is a
// request, not a grant.
const { data: txData } = useTransactions({ trip_id: id, limit: 500, trip_all_rows: true });
if (isLoading || !analytics) {
return (
@@ -280,7 +287,7 @@ export default function TripDetailPage({ params }: { params: Promise<{ id: strin
<table className="w-full text-sm">
<thead>
<tr className="border-b border-zinc-800">
{["Person", "Outstanding on this trip"].map((h) => (
{["Person", "They owe you", "You owe them"].map((h) => (
<th
key={h}
className={`px-5 py-2.5 text-xs text-zinc-500 font-medium ${h === "Person" ? "text-left" : "text-right"}`}
@@ -291,30 +298,68 @@ export default function TripDetailPage({ params }: { params: Promise<{ id: strin
</tr>
</thead>
<tbody>
{/* A negative outstanding means they have paid more towards this
trip than their share of it — which reads as a typo unless the
sign is spelled out. Shown as a magnitude plus a word, the same
way Shared does it, so the two pages agree on what a direction
means. */}
{/* Two columns, never one net figure.
The two directions are separate positions, not halves of a
sum: the grouped-payment allocation cleared each trip against
the one-directional debt in the left column, Europe first with
the remainder to household. Netting them here would redefine
that debt after the fact and turn a settled trip into an
overpayment, with household understated by the same amount.
It is also what makes this page true for a non-owner. The
right-hand column is the figure Sonu could never see: her own
obligation lives on rows someone else paid for, so a
viewer-as-payer figure can never contain it.
A negative outstanding still means they have paid more towards
this trip than their share, which reads as a typo unless the
sign is spelled out — so it stays a magnitude plus a word, the
same way Shared does it. */}
{participant_splits.map((p) => {
const owed = Number(p.owed);
const square = Math.abs(owed) < 0.005;
const theyOweMe = owed > 0;
const iOwe = Number(p.i_owe);
const cell = (
net: number,
gross: number,
paid: number,
unconverted: number,
colour: string,
) => {
if (gross < 0.005 && Math.abs(net) < 0.005) {
return <span className="text-zinc-600"></span>;
}
const square = Math.abs(net) < 0.005;
return (
<>
<span className={square ? "text-zinc-500" : net > 0 ? colour : "text-emerald-400"}>
${Math.abs(net).toFixed(2)}
</span>
<span className="block text-[11px] text-zinc-500 mt-0.5 font-sans">
{square
? "settled"
: net < 0
? "overpaid"
: paid > 0.005
? `${fmt(gross)} less ${fmt(paid)} paid`
: "outstanding"}
</span>
{unconverted > 0 && (
<span className="block text-[11px] text-amber-500/80 mt-0.5 font-sans">
approx · {unconverted} unconverted
</span>
)}
</>
);
};
return (
<tr key={p.participant_id} className="border-b border-zinc-800/50 last:border-0">
<td className="px-5 py-3 font-medium">{p.name}</td>
<td className="px-5 py-3 text-right tabular-nums font-mono">
<span className={square ? "text-zinc-500" : theyOweMe ? "text-amber-400" : "text-blue-400"}>
${Math.abs(owed).toFixed(2)}
</span>
<span className="block text-[11px] text-zinc-500 mt-0.5 font-sans">
{square ? "all square" : theyOweMe ? "owes you" : "ahead — you owe them"}
</span>
{p.unconverted_count > 0 && (
<span className="block text-[11px] text-amber-500/80 mt-0.5 font-sans">
approx · {p.unconverted_count} unconverted
</span>
)}
{cell(owed, Number(p.owed_gross), Number(p.paid_to_me), p.unconverted_count, "text-amber-400")}
</td>
<td className="px-5 py-3 text-right tabular-nums font-mono">
{cell(iOwe, Number(p.i_owe_gross), Number(p.paid_by_me), p.i_owe_unconverted_count, "text-blue-400")}
</td>
</tr>
);