fix(trips): stop reporting a settlement breakdown that cannot be computed
ci / lint-test (push) Successful in 35s
ci / lint-test (push) Successful in 35s
The trip view showed Total Owed / Settled / Unsettled per participant, with the last two derived from transaction_splits.settled. Nothing sets that flag - its only writer was /api/splits/settle, which no UI calls - so it is false on all 673 splits and every trip reported 100% unsettled, including trips already paid in full. Molina has paid $20,782.79 against $19,556.07 of splits and the Europe trip still showed her entire share outstanding. A correct per-trip figure is not computable either: split_payments records only from, to, amount and date, so a payment cannot be attributed to a trip. The trip view now shows each participant's share and points at Shared for what is actually owed, which is where settlement genuinely lives. Also removes /api/splits/settle. It was unreachable from the UI but live on its URL, and a single call with participant_id would mark every one of that person's splits settled - writing a flag nothing reads. Settlement will be reintroduced against settlement contexts (docs/shared-expenses-design.md). getParticipantBalances is deliberately untouched: it computes splits minus payments, which is coherent. Excluding settled splits there while still subtracting the payments that settled them would double-count.
This commit is contained in:
@@ -1,46 +0,0 @@
|
|||||||
import { NextRequest, NextResponse } from "next/server";
|
|
||||||
import { queryRaw } from "@/lib/db";
|
|
||||||
import { getCurrentUser } from "@/lib/auth";
|
|
||||||
|
|
||||||
// A split may be settled by the transaction's effective owner or by the
|
|
||||||
// participant the split belongs to.
|
|
||||||
const SCOPE = `
|
|
||||||
AND EXISTS (
|
|
||||||
SELECT 1 FROM transactions t
|
|
||||||
LEFT JOIN statements s ON s.id = t.statement_id
|
|
||||||
WHERE t.id = transaction_splits.transaction_id
|
|
||||||
AND (COALESCE(t.owner_id, s.owner_id) = $2 OR transaction_splits.participant_id = $2)
|
|
||||||
)`;
|
|
||||||
|
|
||||||
export async function POST(req: NextRequest) {
|
|
||||||
const user = await getCurrentUser(req);
|
|
||||||
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 403 });
|
|
||||||
|
|
||||||
const body = await req.json();
|
|
||||||
const { participant_id, split_ids } = body as {
|
|
||||||
participant_id?: number;
|
|
||||||
split_ids?: number[];
|
|
||||||
};
|
|
||||||
|
|
||||||
if (participant_id) {
|
|
||||||
const rows = await queryRaw<{ id: number }>(
|
|
||||||
`UPDATE transaction_splits SET settled = true, settled_at = NOW()
|
|
||||||
WHERE participant_id = $1 AND settled = false ${SCOPE}
|
|
||||||
RETURNING id`,
|
|
||||||
[participant_id, user.id]
|
|
||||||
);
|
|
||||||
return NextResponse.json({ settled: rows.length });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (split_ids?.length) {
|
|
||||||
const rows = await queryRaw<{ id: number }>(
|
|
||||||
`UPDATE transaction_splits SET settled = true, settled_at = NOW()
|
|
||||||
WHERE id = ANY($1::int[]) AND settled = false ${SCOPE}
|
|
||||||
RETURNING id`,
|
|
||||||
[split_ids, user.id]
|
|
||||||
);
|
|
||||||
return NextResponse.json({ settled: rows.length });
|
|
||||||
}
|
|
||||||
|
|
||||||
return NextResponse.json({ error: "participant_id or split_ids required" }, { status: 400 });
|
|
||||||
}
|
|
||||||
@@ -277,7 +277,7 @@ export default function TripDetailPage({ params }: { params: Promise<{ id: strin
|
|||||||
<table className="w-full text-sm">
|
<table className="w-full text-sm">
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="border-b border-zinc-800">
|
<tr className="border-b border-zinc-800">
|
||||||
{["Person", "Total Owed", "Settled", "Unsettled"].map((h) => (
|
{["Person", "Share of this trip"].map((h) => (
|
||||||
<th
|
<th
|
||||||
key={h}
|
key={h}
|
||||||
className={`px-5 py-2.5 text-xs text-zinc-500 font-medium ${h === "Person" ? "text-left" : "text-right"}`}
|
className={`px-5 py-2.5 text-xs text-zinc-500 font-medium ${h === "Person" ? "text-left" : "text-right"}`}
|
||||||
@@ -292,14 +292,20 @@ export default function TripDetailPage({ params }: { params: Promise<{ id: strin
|
|||||||
<tr key={p.participant_id} className="border-b border-zinc-800/50 last:border-0">
|
<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 font-medium">{p.name}</td>
|
||||||
<td className="px-5 py-3 text-right tabular-nums font-mono">${Number(p.owed).toFixed(2)}</td>
|
<td className="px-5 py-3 text-right tabular-nums font-mono">${Number(p.owed).toFixed(2)}</td>
|
||||||
<td className="px-5 py-3 text-right tabular-nums font-mono text-emerald-500">${Number(p.settled).toFixed(2)}</td>
|
|
||||||
<td className={`px-5 py-3 text-right tabular-nums font-mono ${p.unsettled > 0 ? "text-amber-400" : "text-zinc-600"}`}>
|
|
||||||
${Number(p.unsettled).toFixed(2)}
|
|
||||||
</td>
|
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
{/* Settled/unsettled was reported from transaction_splits.settled,
|
||||||
|
which nothing sets — so every trip showed 100% unsettled forever,
|
||||||
|
including ones already paid in full. Settlement is tracked across
|
||||||
|
the whole relationship, not per trip: payments carry no trip
|
||||||
|
attribution, so a per-trip figure cannot be computed. */}
|
||||||
|
<p className="px-5 py-2.5 text-xs text-zinc-500 border-t border-zinc-800">
|
||||||
|
Settlement is tracked across all shared expenses, not per trip —
|
||||||
|
see <Link href="/shared" className="text-zinc-400 hover:text-zinc-200 underline">Shared</Link> for
|
||||||
|
what is actually owed.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
+11
-5
@@ -753,7 +753,7 @@ export interface TripAnalytics {
|
|||||||
daily_spend: { date: string; amount: number }[];
|
daily_spend: { date: string; amount: number }[];
|
||||||
top_merchants: { merchant: string; amount: number; count: number }[];
|
top_merchants: { merchant: string; amount: number; count: number }[];
|
||||||
tag_breakdown: { tag_id: number; name: string; color: string; amount: number; count: number }[];
|
tag_breakdown: { tag_id: number; name: string; color: string; amount: number; count: number }[];
|
||||||
participant_splits: { participant_id: number; name: string; owed: number; settled: number; unsettled: number }[];
|
participant_splits: { participant_id: number; name: string; owed: number }[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getTrips(ownerId: number): Promise<TripRow[]> {
|
export async function getTrips(ownerId: number): Promise<TripRow[]> {
|
||||||
@@ -857,13 +857,19 @@ export async function getTripAnalytics(tripId: number, ownerId: number): Promise
|
|||||||
ORDER BY 4 DESC
|
ORDER BY 4 DESC
|
||||||
`, [tripId]),
|
`, [tripId]),
|
||||||
|
|
||||||
queryRaw<{ participant_id: number; name: string; owed: number; settled: number; unsettled: number }>(`
|
// No settled/unsettled breakdown here. It was computed from
|
||||||
|
// transaction_splits.settled, which only /api/splits/settle writes and
|
||||||
|
// nothing in the UI calls — so it is false on all 673 splits and every trip
|
||||||
|
// reported 100% unsettled, including trips paid in full. A real per-trip
|
||||||
|
// figure is not computable either: split_payments carries no trip
|
||||||
|
// attribution, so a payment cannot be assigned to a trip. Settlement is a
|
||||||
|
// property of the whole relationship until settlement contexts exist
|
||||||
|
// (see docs/shared-expenses-design.md).
|
||||||
|
queryRaw<{ participant_id: number; name: string; owed: number }>(`
|
||||||
SELECT
|
SELECT
|
||||||
p.id AS participant_id,
|
p.id AS participant_id,
|
||||||
p.name,
|
p.name,
|
||||||
SUM(ts.share_percent / 100.0 * COALESCE(tx.amount_aud, tx.amount))::float AS owed,
|
SUM(ts.share_percent / 100.0 * COALESCE(tx.amount_aud, tx.amount))::float AS owed
|
||||||
SUM(CASE WHEN ts.settled THEN ts.share_percent / 100.0 * COALESCE(tx.amount_aud, tx.amount) ELSE 0 END)::float AS settled,
|
|
||||||
SUM(CASE WHEN NOT ts.settled THEN ts.share_percent / 100.0 * COALESCE(tx.amount_aud, tx.amount) ELSE 0 END)::float AS unsettled
|
|
||||||
FROM transaction_overrides o
|
FROM transaction_overrides o
|
||||||
JOIN transactions tx ON tx.id = o.transaction_id
|
JOIN transactions tx ON tx.id = o.transaction_id
|
||||||
JOIN transaction_splits ts ON ts.transaction_id = tx.id
|
JOIN transaction_splits ts ON ts.transaction_id = tx.id
|
||||||
|
|||||||
Reference in New Issue
Block a user