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.
This commit is contained in:
@@ -253,3 +253,55 @@ describe("getParticipantBalances", () => {
|
|||||||
expect(bobBalance!.unsettled_count).toBe(2);
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import { TagPicker } from "@/components/tag-picker";
|
|||||||
import { AddTransactionModal } from "@/components/add-transaction-modal";
|
import { AddTransactionModal } from "@/components/add-transaction-modal";
|
||||||
import { EditTransactionModal } from "@/components/edit-transaction-modal";
|
import { EditTransactionModal } from "@/components/edit-transaction-modal";
|
||||||
import { CsvImportModal } from "@/components/csv-import-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";
|
import type { RuleRow } from "@/lib/hooks";
|
||||||
|
|
||||||
function formatDate(d: string) {
|
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() {
|
export default function TransactionsPage() {
|
||||||
return (
|
return (
|
||||||
<Suspense fallback={<p className="text-zinc-500 text-sm">Loading...</p>}>
|
<Suspense fallback={<p className="text-zinc-500 text-sm">Loading...</p>}>
|
||||||
@@ -929,8 +948,19 @@ function TransactionsContent() {
|
|||||||
<td className="p-2 whitespace-nowrap text-zinc-500 text-xs">{formatDate(t.created_at)}</td>
|
<td className="p-2 whitespace-nowrap text-zinc-500 text-xs">{formatDate(t.created_at)}</td>
|
||||||
<td className="p-2 max-w-xs">
|
<td className="p-2 max-w-xs">
|
||||||
<p className="truncate" title={t.description}>{t.description}</p>
|
<p className="truncate" title={t.description}>{t.description}</p>
|
||||||
{t.notes && (
|
{t.notes ? (
|
||||||
<p className="truncate text-xs text-zinc-500 italic mt-0.5" title={t.notes}>{t.notes}</p>
|
<p className="truncate text-xs text-zinc-500 italic mt-0.5" title={t.notes}>{t.notes}</p>
|
||||||
|
) : 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.
|
||||||
|
<p
|
||||||
|
className="truncate text-xs text-zinc-500 italic mt-0.5"
|
||||||
|
title={t.order_route!.map((r) => `${r.label}${r.time ? ` ${r.time}` : ""}: ${r.address}`).join("\n")}
|
||||||
|
>
|
||||||
|
{routeSummary(t.order_route)}
|
||||||
|
</p>
|
||||||
)}
|
)}
|
||||||
</td>
|
</td>
|
||||||
<td className="p-2 max-w-[150px]">
|
<td className="p-2 max-w-[150px]">
|
||||||
|
|||||||
+23
-1
@@ -1,5 +1,11 @@
|
|||||||
import { queryRaw } from "./db";
|
import { queryRaw } from "./db";
|
||||||
|
|
||||||
|
export interface RoutePointRow {
|
||||||
|
label: string;
|
||||||
|
time: string | null;
|
||||||
|
address: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface TagRow {
|
export interface TagRow {
|
||||||
id: number;
|
id: number;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -28,6 +34,9 @@ export interface TransactionRow {
|
|||||||
// How it was paid (migration 0016). NULL = unknown, treated as reconcilable.
|
// How it was paid (migration 0016). NULL = unknown, treated as reconcilable.
|
||||||
// 'cash' and 'credits' are excluded from reconciliation — see needsCardMatch().
|
// 'cash' and 'credits' are excluded from reconciliation — see needsCardMatch().
|
||||||
payment_method: string | null;
|
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
|
// override fields
|
||||||
category_override: string | null;
|
category_override: string | null;
|
||||||
merchant_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.name as trip_name,
|
||||||
tr.color as trip_color,
|
tr.color as trip_color,
|
||||||
txn_tags.tags,
|
txn_tags.tags,
|
||||||
txn_splits.splits
|
txn_splits.splits,
|
||||||
|
order_ctx.route as order_route,
|
||||||
|
order_ctx.platform as order_platform
|
||||||
FROM transactions t
|
FROM transactions t
|
||||||
LEFT JOIN transaction_overrides o ON o.transaction_id = t.id
|
LEFT JOIN transaction_overrides o ON o.transaction_id = t.id
|
||||||
LEFT JOIN statements s ON s.id = t.statement_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 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 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
|
LEFT JOIN trips tr ON tr.id = o.trip_id
|
||||||
|
|||||||
Reference in New Issue
Block a user