diff --git a/src/app/trips/[id]/page.tsx b/src/app/trips/[id]/page.tsx index 4312623..1e6ccd4 100644 --- a/src/app/trips/[id]/page.tsx +++ b/src/app/trips/[id]/page.tsx @@ -277,7 +277,7 @@ export default function TripDetailPage({ params }: { params: Promise<{ id: strin - {["Person", "Share of this trip"].map((h) => ( + {["Person", "Outstanding on this trip"].map((h) => ( - + ))} diff --git a/src/lib/queries.ts b/src/lib/queries.ts index 563afcf..bd09e93 100644 --- a/src/lib/queries.ts +++ b/src/lib/queries.ts @@ -1,5 +1,5 @@ import { queryRaw } from "./db"; -import { EXCLUDE_RECONCILED_SOURCE, NATIVE_CURRENCY, AMOUNT_UNCONVERTED, ACTIVE_OBLIGATION } from "./analytics-sql"; +import { EXCLUDE_RECONCILED_SOURCE, NATIVE_CURRENCY, AMOUNT_UNCONVERTED, ACTIVE_OBLIGATION, STATEMENTS_JOIN } from "./analytics-sql"; export interface RoutePointRow { label: string; @@ -834,7 +834,14 @@ export interface TripAnalytics { daily_spend: { date: string; amount: number }[]; top_merchants: { merchant: 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 }[]; + participant_splits: { + participant_id: number; + name: string; + /** Their share of this trip, net of payments scoped to it. */ + owed: number; + /** Splits counted at a non-AUD figure because no converted amount exists. */ + unconverted_count: number; + }[]; } export async function getTrips(ownerId: number): Promise { @@ -946,25 +953,36 @@ export async function getTripAnalytics(tripId: number, ownerId: number): Promise // unsettled including trips paid in full. `split_payments.trip_id` // (migration 0022) closes that, so the figure is now real. // - // Two exclusions, both load-bearing: + // Three exclusions, all load-bearing: // - ACTIVE_OBLIGATION drops settled splits, so a closed trip reads zero // rather than its original gross. // - EXCLUDE_RECONCILED_SOURCE drops the manual row a statement line has // superseded. The trip queries never applied it, so a reconciled trip // expense was counted twice here. - queryRaw<{ participant_id: number; name: string; owed: number }>(` + // - AMOUNT_UNCONVERTED counts rows whose AUD value is unknown, the same + // way getParticipantBalances does. They are still summed (at their + // native figure), so a non-zero count means this total is approximate + // and the UI has to say so. A trip is where foreign rows actually live, + // so netting a EUR figure against AUD ones silently is most likely to + // bite exactly here. + // + // `transactions` is aliased `t` so the shared fragments apply directly — + // they assume that alias, and hand-inlining a copy is what let the + // reconciled-row exclusion drift out of the analytics routes to begin with. + queryRaw<{ participant_id: number; name: string; owed: number; unconverted_count: number }>(` WITH owed AS ( SELECT ts.participant_id AS pid, - SUM(ts.share_percent / 100.0 * COALESCE(tx.amount_aud, tx.amount)) AS gross + SUM(ts.share_percent / 100.0 * COALESCE(t.amount_aud, t.amount)) AS gross, + SUM(CASE WHEN ${AMOUNT_UNCONVERTED} THEN 1 ELSE 0 END) AS unconverted FROM transaction_overrides o - JOIN transactions tx ON tx.id = o.transaction_id - LEFT JOIN statements s ON s.id = tx.statement_id - JOIN transaction_splits ts ON ts.transaction_id = tx.id + JOIN transactions t ON t.id = o.transaction_id + ${STATEMENTS_JOIN} + JOIN transaction_splits ts ON ts.transaction_id = t.id WHERE o.trip_id = $1 - AND tx.transaction_type IN ('debit','fee','interest') - AND COALESCE(o.category_override, tx.category, 'other') NOT IN ('transfers', 'investment') + AND t.transaction_type IN ('debit','fee','interest') + AND COALESCE(o.category_override, t.category, 'other') NOT IN ('transfers', 'investment') AND ${ACTIVE_OBLIGATION} - AND NOT (tx.statement_id IS NULL AND tx.reconciled_with_id IS NOT NULL) + AND ${EXCLUDE_RECONCILED_SOURCE} GROUP BY ts.participant_id ), paid AS ( @@ -974,7 +992,8 @@ export async function getTripAnalytics(tripId: number, ownerId: number): Promise GROUP BY sp.from_participant_id ) SELECT p.id AS participant_id, p.name, - (COALESCE(owed.gross, 0) - COALESCE(paid.amt, 0))::float AS owed + (COALESCE(owed.gross, 0) - COALESCE(paid.amt, 0))::float AS owed, + COALESCE(owed.unconverted, 0)::int AS unconverted_count FROM participants p LEFT JOIN owed ON owed.pid = p.id LEFT JOIN paid ON paid.pid = p.id
(
{p.name}${Number(p.owed).toFixed(2)} + + ${Number(p.owed).toFixed(2)} + + {p.unconverted_count > 0 && ( + + approx · {p.unconverted_count} unconverted + + )} +