feat(orders): make an ingested order legible in the transactions view
ci / lint-test (push) Failing after 43s
ci / lint-test (push) Failing after 43s
Four things the view could not tell you, all from reading the rows (user,
2026-07-27).
**Which platform.** The parser has always known — it has to, to read the
template — and then discarded it. "Order - Burger Corner" gives no way to know
whether to open DoorDash or Uber Eats for the detail, and restaurants exist on
both. Now stored on expense_metadata and named in the description:
"Order - Burger Corner (Uber Eats)". Migration 0021 recovers it for the 101
backfilled rows from the order_reference shape — DoorDash receipts carry no id
of their own so ingestion synthesises `msg:<message-id>`, Uber carries a real
trip UUID, which makes the discriminator exact.
**Bank said "Manual".** That label is derived, not stored, and "Manual" reads
as "hand-entered, still awaiting a card line to match". A gift-card order has
no card line coming, ever. It now reads "Gift Card", and — the part that
actually mattered — credits joins cash in needsCardMatch(), so these stop
sitting in the pending-reconciliation queue. All 81 were queued against a match
that could not exist.
**Uber line items were never parsed.** 67 of 101 orders had none. Uber itemises
groceries but not restaurant orders, so some of that is genuine; the rest was
simply unread. Its markup is better than DoorDash's — every cell carries a
data-testid with the item's uuid, so qty/title/amount bind by id rather than by
column position. Sold-out items (0.00) are kept: they are why a total is lower
than what was ordered.
**Uber prints pick-up and delivery addresses on every receipt** and they were
thrown away. Captured as `route` [{label, time, address}], de-duplicated
because the template renders the whole block twice for narrow screens. Wording
is kept as printed ("Pick-up" on some receipts, "Pickup" on others) rather than
normalised, so a template change stays visible. This is the same block a *trip*
receipt uses for start and destination — rides are not ingested today, but the
reader will not need changing when they are.
Also stores source_email_subject/from, which order ingestion had left null on
columns that already existed.
Verified against the captured corpus: route on all 6 Uber fixtures, 5/5 items
on the GLOMARK grocery receipt including the sold-out one. Production data
updated by smarthome:docker/scripts/order-presentation-2026-07-27.sql
(81 descriptions, `backfill` tag, re-run clean). `route` and Uber line items
are parsed from here on only — recovering them for already-ingested orders
means re-reading the mail, which I7 idempotency refuses by design.
This commit is contained in:
+51
-19
@@ -26,7 +26,7 @@ export interface TransactionRow {
|
||||
principal_amount: number | null;
|
||||
interest_amount: number | null;
|
||||
// How it was paid (migration 0016). NULL = unknown, treated as reconcilable.
|
||||
// 'cash' is excluded from reconciliation — see notCash().
|
||||
// 'cash' and 'credits' are excluded from reconciliation — see needsCardMatch().
|
||||
payment_method: string | null;
|
||||
// override fields
|
||||
category_override: string | null;
|
||||
@@ -133,17 +133,24 @@ export async function getTransactions(ownerId: number, filters: TransactionFilte
|
||||
params.push(filters.categories);
|
||||
}
|
||||
if (filters.bank_names?.length) {
|
||||
// "Manual" and "Gift Card" are not banks — they are the two shapes a
|
||||
// statement-less row can take, and bankLabel() decides which. The filter
|
||||
// has to split on the same condition or the chip selects nothing.
|
||||
const hasManual = filters.bank_names.includes("Manual");
|
||||
const bankList = filters.bank_names.filter((b) => b !== "Manual");
|
||||
if (hasManual && bankList.length > 0) {
|
||||
conditions.push(`(t.statement_id IS NULL OR s.bank_name = ANY($${paramIdx++}::text[]))`);
|
||||
params.push(bankList);
|
||||
} else if (hasManual) {
|
||||
conditions.push(`t.statement_id IS NULL`);
|
||||
} else {
|
||||
conditions.push(`s.bank_name = ANY($${paramIdx++}::text[])`);
|
||||
const hasGiftCard = filters.bank_names.includes("Gift Card");
|
||||
const bankList = filters.bank_names.filter((b) => b !== "Manual" && b !== "Gift Card");
|
||||
const alternatives: string[] = [];
|
||||
if (hasManual) {
|
||||
alternatives.push(`(t.statement_id IS NULL AND t.payment_method IS DISTINCT FROM 'credits')`);
|
||||
}
|
||||
if (hasGiftCard) {
|
||||
alternatives.push(`(t.statement_id IS NULL AND t.payment_method = 'credits')`);
|
||||
}
|
||||
if (bankList.length > 0) {
|
||||
alternatives.push(`s.bank_name = ANY($${paramIdx++}::text[])`);
|
||||
params.push(bankList);
|
||||
}
|
||||
conditions.push(`(${alternatives.join(" OR ")})`);
|
||||
}
|
||||
if (filters.tag_ids?.length) {
|
||||
const noTags = filters.tag_ids.includes("untagged");
|
||||
@@ -210,7 +217,7 @@ export async function getTransactions(ownerId: number, filters: TransactionFilte
|
||||
o.category_override, o.merchant_normalized as merchant_override, o.notes, o.my_share_percent,
|
||||
COALESCE(o.category_override, t.category) as effective_category,
|
||||
COALESCE(o.merchant_normalized, t.merchant_normalized, t.merchant_name) as effective_merchant,
|
||||
COALESCE(s.bank_name, 'Manual') as bank_name,
|
||||
${bankLabel()} as bank_name,
|
||||
COALESCE(s.currency, 'AUD') as currency,
|
||||
-- My share, resolved the same way analytics does it (see myShare in
|
||||
-- analytics-sql.ts): explicit split row, then override, then whatever is
|
||||
@@ -294,7 +301,7 @@ export async function getTransactionById(id: number) {
|
||||
o.category_override, o.merchant_normalized as merchant_override, o.notes, o.my_share_percent,
|
||||
COALESCE(o.category_override, t.category) as effective_category,
|
||||
COALESCE(o.merchant_normalized, t.merchant_normalized, t.merchant_name) as effective_merchant,
|
||||
COALESCE(s.bank_name, 'Manual') as bank_name,
|
||||
${bankLabel()} as bank_name,
|
||||
COALESCE(t.owner_id, s.owner_id) as owner_id,
|
||||
p.name as owner_name
|
||||
FROM transactions t
|
||||
@@ -376,12 +383,20 @@ export async function getMerchantSuggestions(search: string) {
|
||||
}
|
||||
|
||||
export async function getBankNames() {
|
||||
const [bankRows, manualCount] = await Promise.all([
|
||||
const [bankRows, statementless] = await Promise.all([
|
||||
queryRaw<{ bank_name: string }>(`SELECT DISTINCT bank_name FROM statements ORDER BY bank_name`),
|
||||
queryRaw<{ count: number }>(`SELECT COUNT(*)::int as count FROM transactions WHERE statement_id IS NULL`),
|
||||
queryRaw<{ label: string }>(
|
||||
`SELECT DISTINCT ${bankLabel("t", "s")} as label
|
||||
FROM transactions t LEFT JOIN statements s ON s.id = t.statement_id
|
||||
WHERE t.statement_id IS NULL`
|
||||
),
|
||||
]);
|
||||
const banks = bankRows.map((r) => r.bank_name);
|
||||
if (manualCount[0]?.count > 0) banks.push("Manual");
|
||||
// Order matters for the filter chips: real banks first, then the
|
||||
// statement-less kinds, in a stable order rather than whatever the DB returns.
|
||||
for (const label of ["Manual", "Gift Card"]) {
|
||||
if (statementless.some((r) => r.label === label)) banks.push(label);
|
||||
}
|
||||
return banks;
|
||||
}
|
||||
|
||||
@@ -533,8 +548,25 @@ export async function batchInsertCSVTransactions(
|
||||
* transaction accounts are imported, and NULL means unknown — both stay
|
||||
* candidates, which preserves the behaviour of every pre-existing row.
|
||||
*/
|
||||
export const notCash = (alias = "t") =>
|
||||
`(${alias}.payment_method IS NULL OR ${alias}.payment_method <> 'cash')`;
|
||||
/**
|
||||
* Payment methods that can still be matched against a card statement line.
|
||||
*
|
||||
* Cash never appears on one. Neither does a credits-funded delivery order: the
|
||||
* gift card already paid it, so there is no card leg coming, ever. Leaving
|
||||
* those in the queue meant 81 orders sat in "pending reconciliation" waiting
|
||||
* for a match that could not exist (user, 2026-07-27).
|
||||
*/
|
||||
export const needsCardMatch = (alias = "t") =>
|
||||
`(${alias}.payment_method IS NULL OR ${alias}.payment_method NOT IN ('cash', 'credits'))`;
|
||||
|
||||
/**
|
||||
* Bank label for a transaction. A row with no statement was not imported from
|
||||
* one, and the label has to say *why*: "Manual" reads as "hand-entered, still
|
||||
* awaiting a card line", which is wrong for a gift-card order — nothing is
|
||||
* awaited. `s` must be the statements alias in scope.
|
||||
*/
|
||||
export const bankLabel = (t = "t", s = "s") =>
|
||||
`COALESCE(${s}.bank_name, CASE WHEN ${t}.payment_method = 'credits' THEN 'Gift Card' ELSE 'Manual' END)`;
|
||||
|
||||
export interface PotentialMatch {
|
||||
id: number;
|
||||
@@ -578,7 +610,7 @@ export async function getPendingReconciliations(ownerId: number): Promise<Manual
|
||||
WHERE ts.transaction_id = t.id
|
||||
) txn_splits ON true
|
||||
WHERE t.statement_id IS NULL AND t.owner_id = $1 AND t.reconciled_with_id IS NULL
|
||||
AND ${notCash("t")}
|
||||
AND ${needsCardMatch("t")}
|
||||
ORDER BY t.transaction_date DESC, t.row_index ASC`,
|
||||
[ownerId]
|
||||
);
|
||||
@@ -620,7 +652,7 @@ export async function getPendingReconciliations(ownerId: number): Promise<Manual
|
||||
WHERE m.statement_id IS NULL
|
||||
AND m.owner_id = $1
|
||||
AND m.reconciled_with_id IS NULL
|
||||
AND ${notCash("m")}
|
||||
AND ${needsCardMatch("m")}
|
||||
AND COALESCE(t.owner_id, s.owner_id) = $1
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM transactions mt WHERE mt.reconciled_with_id = t.id
|
||||
@@ -687,7 +719,7 @@ export async function getSharedTransactions(ownerId: number, tagIds?: number[],
|
||||
o.category_override, o.merchant_normalized as merchant_override, o.notes,
|
||||
COALESCE(o.category_override, t.category) as effective_category,
|
||||
COALESCE(o.merchant_normalized, t.merchant_normalized, t.merchant_name) as effective_merchant,
|
||||
COALESCE(s.bank_name, 'Manual') as bank_name,
|
||||
${bankLabel()} as bank_name,
|
||||
COALESCE(t.owner_id, s.owner_id) as owner_id,
|
||||
p_owner.name as owner_name,
|
||||
COALESCE(src.created_at, t.created_at) as created_at,
|
||||
|
||||
Reference in New Issue
Block a user