fix(trips): carry the currency and reconcile rules into the trip figure
a4ab543landed 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 drifta4ab543set 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:
@@ -277,7 +277,7 @@ export default function TripDetailPage({ params }: { params: Promise<{ id: strin
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-zinc-800">
|
||||
{["Person", "Share of this trip"].map((h) => (
|
||||
{["Person", "Outstanding on this trip"].map((h) => (
|
||||
<th
|
||||
key={h}
|
||||
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) => (
|
||||
<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 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>
|
||||
))}
|
||||
</tbody>
|
||||
|
||||
+31
-12
@@ -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<TripRow[]> {
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user