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