From 3bb67f370dacc3125f9d208f33087cfe979b425f Mon Sep 17 00:00:00 2001
From: siddharthd
Date: Mon, 27 Jul 2026 11:43:55 +1000
Subject: [PATCH] feat(orders): show where an Uber trip went, in the list
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Five rows all reading "Order - Uber Trip" are indistinguishable — the list gives
you a date and an amount and nothing to tell one ride from another (user,
2026-07-27). Where the trip went is exactly what separates them, and it was
already stored on expense_metadata.route since this morning; nothing in the list
read it.
getTransactions now joins the receipt (both directions — transaction_id OR
matched_transaction_id, since a card-settled order points at the statement line
instead) and the description cell renders "Terminal 2, Melbourne Airport (MEL)
→ 19 Lady Penrhyn Dr" in the same italic sub-line notes use.
Two deliberate limits:
- **A note the user wrote always wins.** This only fills an empty sub-line; it
never occupies the notes field, which is theirs.
- **Deliveries are excluded.** Their merchant already identifies them, so the
restaurant's street address would be clutter on every food order. Gated on
platform = 'uber'.
The summary keeps the first two comma-segments of each address — a truncation,
not a guess about geography. Uber puts the venue or street first, which is the
identifying part; the full stops with their times stay in the title attribute.
---
src/__tests__/integration/queries.test.ts | 52 +++++++++++++++++++++++
src/app/transactions/page.tsx | 34 ++++++++++++++-
src/lib/queries.ts | 24 ++++++++++-
3 files changed, 107 insertions(+), 3 deletions(-)
diff --git a/src/__tests__/integration/queries.test.ts b/src/__tests__/integration/queries.test.ts
index 0ee01ac..64e41c9 100644
--- a/src/__tests__/integration/queries.test.ts
+++ b/src/__tests__/integration/queries.test.ts
@@ -253,3 +253,55 @@ describe("getParticipantBalances", () => {
expect(bobBalance!.unsettled_count).toBe(2);
});
});
+
+describe("getTransactions — order provenance for the description sub-line", () => {
+ it("carries the route and platform of an order-derived row", async () => {
+ // Five rows all reading "Order - Uber Trip" are indistinguishable in the
+ // list; where the trip went is the only thing that separates them, and it
+ // was already stored.
+ const { ownerId } = await seedParticipants(pool);
+ const txId = await insertTransaction(pool, ownerId, { description: "Order - Uber Trip" });
+ await pool.query(
+ `INSERT INTO expense_metadata (source, order_reference, platform, route, transaction_id)
+ VALUES ('email', $1, 'uber',
+ '[{"label":"Pick-up","time":"7:32 pm","address":"Terminal 2, Melbourne Airport (MEL), Tullamarine VIC 3045, Australia"},
+ {"label":"Drop-off","time":"8:10 pm","address":"19 Lady Penrhyn Dr, Wyndham Vale VIC 3024, Australia"}]'::jsonb,
+ $2)`,
+ [`route-${Date.now()}`, txId]
+ );
+
+ const { data } = await getTransactions(ownerId, { limit: 50, offset: 0 });
+ const row = data.find((r) => r.id === txId)!;
+ expect(row.order_platform).toBe("uber");
+ expect(row.order_route).toHaveLength(2);
+ expect(row.order_route![0].address).toContain("Melbourne Airport");
+ });
+
+ it("resolves from the statement line for a card-settled order", async () => {
+ // A card-settled order creates no transaction of its own (I5) — the
+ // receipt points at the statement line through matched_transaction_id.
+ const { ownerId } = await seedParticipants(pool);
+ const txId = await insertTransaction(pool, ownerId, { description: "UBER *TRIP AUCKLAND" });
+ await pool.query(
+ `INSERT INTO expense_metadata (source, order_reference, platform, route, matched_transaction_id)
+ VALUES ('email', $1, 'uber',
+ '[{"label":"Pick-up","time":null,"address":"64 Federal Street, Auckland 1010, NZ"},
+ {"label":"Drop-off","time":null,"address":"International Terminal, Auckland 2022, New Zealand"}]'::jsonb,
+ $2)`,
+ [`route-card-${Date.now()}`, txId]
+ );
+
+ const { data } = await getTransactions(ownerId, { limit: 50, offset: 0 });
+ const row = data.find((r) => r.id === txId)!;
+ expect(row.order_route).toHaveLength(2);
+ });
+
+ it("leaves an ordinary transaction with no route", async () => {
+ const { ownerId } = await seedParticipants(pool);
+ const txId = await insertTransaction(pool, ownerId, { description: "COLES 1234" });
+ const { data } = await getTransactions(ownerId, { limit: 50, offset: 0 });
+ const row = data.find((r) => r.id === txId)!;
+ expect(row.order_route).toBeNull();
+ expect(row.order_platform).toBeNull();
+ });
+});
diff --git a/src/app/transactions/page.tsx b/src/app/transactions/page.tsx
index 3603516..44acc72 100644
--- a/src/app/transactions/page.tsx
+++ b/src/app/transactions/page.tsx
@@ -9,7 +9,7 @@ import { TagPicker } from "@/components/tag-picker";
import { AddTransactionModal } from "@/components/add-transaction-modal";
import { EditTransactionModal } from "@/components/edit-transaction-modal";
import { CsvImportModal } from "@/components/csv-import-modal";
-import type { TransactionRow } from "@/lib/queries";
+import type { TransactionRow, RoutePointRow } from "@/lib/queries";
import type { RuleRow } from "@/lib/hooks";
function formatDate(d: string) {
@@ -475,6 +475,25 @@ function MultiSelect({
);
}
+/**
+ * "Melbourne Airport (MEL) → Wyndham Vale VIC 3024" from the two stops on an
+ * Uber *trip* receipt. Deliveries are excluded by the caller: their merchant
+ * already identifies them, so the restaurant's street address would be clutter
+ * on every food order.
+ *
+ * Keeps the first two comma-segments of each address — a truncation, not a
+ * guess about geography. The venue or street comes first in Uber's format and
+ * is the identifying part; the full text stays in the title attribute.
+ */
+function routeSummary(route: RoutePointRow[] | null | undefined): string | null {
+ if (!route || route.length < 2) return null;
+ const short = (a: string) => a.split(",").slice(0, 2).join(",").trim();
+ const from = short(route[0].address);
+ const to = short(route[route.length - 1].address);
+ if (!from || !to) return null;
+ return `${from} → ${to}`;
+}
+
export default function TransactionsPage() {
return (
Loading...
}>
@@ -929,8 +948,19 @@ function TransactionsContent() {
{formatDate(t.created_at)} |
{t.description}
- {t.notes && (
+ {t.notes ? (
{t.notes}
+ ) : t.order_platform === "uber" && routeSummary(t.order_route) && (
+ // Five rows all reading "Order - Uber Trip" are
+ // indistinguishable. Where the trip went is what tells
+ // them apart, and it was already stored. A note the user
+ // wrote always wins — this only fills an empty line.
+ `${r.label}${r.time ? ` ${r.time}` : ""}: ${r.address}`).join("\n")}
+ >
+ {routeSummary(t.order_route)}
+
)}
|
diff --git a/src/lib/queries.ts b/src/lib/queries.ts
index 9f92b92..5239727 100644
--- a/src/lib/queries.ts
+++ b/src/lib/queries.ts
@@ -1,5 +1,11 @@
import { queryRaw } from "./db";
+export interface RoutePointRow {
+ label: string;
+ time: string | null;
+ address: string;
+}
+
export interface TagRow {
id: number;
name: string;
@@ -28,6 +34,9 @@ export interface TransactionRow {
// How it was paid (migration 0016). NULL = unknown, treated as reconcilable.
// 'cash' and 'credits' are excluded from reconciliation — see needsCardMatch().
payment_method: string | null;
+ /** Uber pick-up/drop-off, when this row came from an order receipt. */
+ order_route: RoutePointRow[] | null;
+ order_platform: "doordash" | "ubereats" | "uber" | null;
// override fields
category_override: string | null;
merchant_override: string | null;
@@ -244,10 +253,23 @@ export async function getTransactions(ownerId: number, filters: TransactionFilte
tr.name as trip_name,
tr.color as trip_color,
txn_tags.tags,
- txn_splits.splits
+ txn_splits.splits,
+ order_ctx.route as order_route,
+ order_ctx.platform as order_platform
FROM transactions t
LEFT JOIN transaction_overrides o ON o.transaction_id = t.id
LEFT JOIN statements s ON s.id = t.statement_id
+ -- Order provenance, for the sub-line under the description. Five rows all
+ -- reading "Order - Uber Trip" are indistinguishable; where they went is the
+ -- only thing that tells them apart, and it was already stored.
+ -- Both directions, because a card-settled order has no transaction of its
+ -- own and points at the statement line instead (I5).
+ LEFT JOIN LATERAL (
+ SELECT em.route, em.platform
+ FROM expense_metadata em
+ WHERE em.transaction_id = t.id OR em.matched_transaction_id = t.id
+ LIMIT 1
+ ) order_ctx ON true
LEFT JOIN participants p ON p.id = COALESCE(t.owner_id, s.owner_id)
LEFT JOIN transactions src ON src.reconciled_with_id = t.id AND src.statement_id IS NULL
LEFT JOIN trips tr ON tr.id = o.trip_id
|