fix(trips): carry the currency and reconcile rules into the trip figure
ci / lint-test (pull_request) Failing after 43s
ci / lint-test (push) Failing after 43s

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.
This commit is contained in:
2026-07-27 23:15:28 +10:00
parent 689fadc8b9
commit ae23b03d5d
2 changed files with 42 additions and 14 deletions
+11 -2
View File
@@ -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", "Share of this trip"].map((h) => ( {["Person", "Outstanding on 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"}`}
@@ -291,7 +291,16 @@ export default function TripDetailPage({ params }: { params: Promise<{ id: strin
{participant_splits.map((p) => ( {participant_splits.map((p) => (
<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">
<span className={Math.abs(Number(p.owed)) < 0.005 ? "text-zinc-500" : ""}>
${Number(p.owed).toFixed(2)}
</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>
)}
</td>
</tr> </tr>
))} ))}
</tbody> </tbody>
+31 -12
View File
@@ -1,5 +1,5 @@
import { queryRaw } from "./db"; 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 { export interface RoutePointRow {
label: string; label: string;
@@ -834,7 +834,14 @@ 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 }[]; 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<TripRow[]> { export async function getTrips(ownerId: number): Promise<TripRow[]> {
@@ -946,25 +953,36 @@ export async function getTripAnalytics(tripId: number, ownerId: number): Promise
// unsettled including trips paid in full. `split_payments.trip_id` // unsettled including trips paid in full. `split_payments.trip_id`
// (migration 0022) closes that, so the figure is now real. // (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 // - ACTIVE_OBLIGATION drops settled splits, so a closed trip reads zero
// rather than its original gross. // rather than its original gross.
// - EXCLUDE_RECONCILED_SOURCE drops the manual row a statement line has // - EXCLUDE_RECONCILED_SOURCE drops the manual row a statement line has
// superseded. The trip queries never applied it, so a reconciled trip // superseded. The trip queries never applied it, so a reconciled trip
// expense was counted twice here. // 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 ( WITH owed AS (
SELECT ts.participant_id AS pid, 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 FROM transaction_overrides o
JOIN transactions tx ON tx.id = o.transaction_id JOIN transactions t ON t.id = o.transaction_id
LEFT JOIN statements s ON s.id = tx.statement_id ${STATEMENTS_JOIN}
JOIN transaction_splits ts ON ts.transaction_id = tx.id JOIN transaction_splits ts ON ts.transaction_id = t.id
WHERE o.trip_id = $1 WHERE o.trip_id = $1
AND tx.transaction_type IN ('debit','fee','interest') AND t.transaction_type IN ('debit','fee','interest')
AND COALESCE(o.category_override, tx.category, 'other') NOT IN ('transfers', 'investment') AND COALESCE(o.category_override, t.category, 'other') NOT IN ('transfers', 'investment')
AND ${ACTIVE_OBLIGATION} 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 GROUP BY ts.participant_id
), ),
paid AS ( paid AS (
@@ -974,7 +992,8 @@ export async function getTripAnalytics(tripId: number, ownerId: number): Promise
GROUP BY sp.from_participant_id GROUP BY sp.from_participant_id
) )
SELECT p.id AS participant_id, p.name, 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 FROM participants p
LEFT JOIN owed ON owed.pid = p.id LEFT JOIN owed ON owed.pid = p.id
LEFT JOIN paid ON paid.pid = p.id LEFT JOIN paid ON paid.pid = p.id