From ae23b03d5d43e0806026a7e9940e19474fe42894 Mon Sep 17 00:00:00 2001 From: siddharthd Date: Mon, 27 Jul 2026 23:15:28 +1000 Subject: [PATCH] fix(trips): carry the currency and reconcile rules into the trip figure MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit a4ab543 landed six hours ago and this branch rewrote one of the queries it had just fixed, quietly dropping both of its guarantees. That commit made EXCLUDE_RECONCILED_SOURCE "one fragment both sides import" because an inlined copy is how the reconciled-row exclusion drifted out of the analytics routes and double-counted 48 rows / $4,474.79. The trip owed query here had hand-inlined its own copy โ€” the fragment assumes the alias `t` and this query used `tx`, so the path of least resistance was to re-create exactly the divergence that was being removed. Aliased to `t` so the fragments apply directly. The same commit made balances count rows whose AUD value is unknown rather than netting a foreign figure against AUD ones. The trip figure had no equivalent โ€” on the query where it matters most, because a trip is where foreign rows actually live. A Europe total silently mixing EUR into AUD is the whole failure that fix was written to prevent. The column header still read "Share of this trip" while the number is now net of payments, which is the same class of drift a4ab543 set out to fix. It reads "Outstanding on this trip", carries the approx/unconverted caveat the Shared cards use, and greys a settled zero. --- src/app/trips/[id]/page.tsx | 13 +++++++++-- src/lib/queries.ts | 43 ++++++++++++++++++++++++++----------- 2 files changed, 42 insertions(+), 14 deletions(-) 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 + + )} +