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) => (
| (
|
| {p.name} |
- ${Number(p.owed).toFixed(2)} |
+
+
+ ${Number(p.owed).toFixed(2)}
+
+ {p.unconverted_count > 0 && (
+
+ approx · {p.unconverted_count} unconverted
+
+ )}
+ |
))}
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