Skip to content

Receipt-matched tx display name — read merchant + category from the matched receipt

Renumbered T-114 → T-117 (2026-06-25, renumber-on-merge). While this task was in flight on claude/busy-dirac-QmUdM (PR #808), parallel agents grabbed T-114 (Records — durable Invoice Number hyperlink) and T-116 (NAS deploy speed-up) on main first. Per the AGENTS.md "whoever merges second renumbers" rule, this task moved to T-117 at merge time; the in-code T-114 comments across the touched files were updated to T-117 to match. Commit messages stay as historical T-114 (immutable). Sibling T-115 → T-118.

Why (owner, 2026-06-25)

Follow-up to T-113. After the receipt → GL backfill ran for tx iIZAMoL6xbncfE00g2xC, the tx was correctly categorised to gl.6520 and the Records / Accounting tabs surfaced the GL tag — but the display name rendered with blank placeholders where the configured receipt template ("Settings → Routing → Doc Types → receipt") expected {{merchant}} and {{merchantCategory}}.

"now that [the tx is] married to the receipt, but the display name doesn't seem to be displaying information as configured on the template in settings > routing"

Why it's a gap, not design

Two layers contributed:

  1. Backfill omission (one-off). The T-113 backfill wrote gl.6520 + transaction.status: categorized via direct Firestore update, bypassing categorizeTxFromReceipt. That helper would have ALSO stashed expenseMetadata.merchant / expenseMetadata.payee from the receipt; the direct write skipped it. So even with the GL set, the tx had no merchant token baked in.

  2. Pipeline gap (structural). enrichTransactionDisplayName already routes receipt-matched txs via linkedRecordType === 'receipt' and the receipt doc-type template — but the token bag it passes in (relatedProjectTitle / payee / invoiceNumber / counterparty / reference / memo / projectId / client + expenseMetadata.*) has no receipt-specific tokens. Even with expenseMetadata.merchant populated, the granular {{merchantCategory}} token (which the configured template references) has no source — the receipt's parsedReceipt.category slug never reached the renderer. Vendor invoices, GCP invoices, and the cross-border fee lane all have their own token loaders (getBillingDisplayTokens / getCrossBorderFeeTokens); receipts didn't.

Both fixes ship together — the pipeline change makes the data fix unnecessary going forward (tokens are derived AT READ TIME from the matched receipt, so older / future backfills don't have to bake them in).

What shipped

  • lib/accounting/receiptDisplayTokens.server.ts (new) — getReceiptDisplayTokens(tx) reads the FIRST matched receipt via getTelegramReceipt (subsidiary-scoped, no cross-DB fan-out) and returns merchant, merchantCategory + receiptCategory (both = human label of the AI category slug, e.g. "fnb" → "F&B" via the Phase 2 categoryLabelFor helper), and receiptNote (DIPN-21 substantiation text, useful for future templates). ONLY defined fields returned so existing tx-side values are never clobbered with blanks.
  • lib/accounting/transactions.tsenrichTransactionDisplayName adds a linkedRecordType === 'receipt' branch to the token-loader switch, calling getReceiptDisplayTokens(transaction).catch(() => null) alongside the existing workspace/gcp/cross-border lanes. Result merges into the tokenData bag passed to generateGLAssignmentDisplayName.
  • Verified live for iIZAMoL6xbncfE00g2xC: the configured template [{{accountCode}} - {{accountName}}] {{merchantCategory}} - {{merchant}} now renders as [6520 - Staff Welfare & Events] F&B - 蘇珊娜餐廳 (against the actual receipt doc in tebs-erl).
  • Tests (__tests__/lib/accounting/receiptDisplayTokens.test.ts) — 6 cases: no allocations → {}, DB miss → {}, full merchant + category
  • note round-trip, defined-fields-only (no blank clobbering), first-of- multi-receipt wins, subsidiaryId forwarded to skip cross-DB scan.

Out of scope

  • Re-bake expenseMetadata.merchant on historical receipt-matched txs. Not needed — the new read-time path resolves the tokens from the receipt doc directly. The tx-side metadata still gets written by categorizeTxFromReceipt for new matches (defence in depth), but the pipeline no longer depends on it.

Decision log

2026-06-25 — T-117 opened + shipped

  • Attestation (Accounting (Diagnostics)): read AGENTS.md (tip 26330aac); board scanned by scope (T-113 just closed; T-066 / T-003 prior receipt-matcher work) — no overlap, this is a new structural gap surfaced by the T-113 backfill.
  • Source: Accounting (Diagnostics) · https://claude.ai/code/session_01G58Y71noihrYCDEDMexmea
  • Owner direction (verbatim, 2026-06-25):

    "the follow-up issue I'd like you to help me look into regarding the tx iIZAMoL6xbncfE00g2xC is that, now that it's married to the receipt, but the display name doesn't seem to be displaying information as configured on the template in settings > routing"

  • Tests + tsc: npx tsc --noEmit clean; npx vitest run __tests__/lib/accounting/receiptDisplayTokens.test.ts 6/6.
  • Live verification: the configured receipt template + the new token loader render the expected name for iIZAMoL6xbncfE00g2xC (via direct-test script against prod, no write).
  • Blast radius: enrichTransactionDisplayName is called by every read path that lists / surfaces transactions. The new branch only fires when linkedRecordType === 'receipt' (i.e. tx has matchedReceipts.length > 0); zero effect on other tx kinds. The loader uses .catch(() => null) so a transient receipt-read failure degrades to the pre-T-117 rendering (template tokens render as literal {{merchant}} placeholders), never crashes the listing.