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-codeT-114comments across the touched files were updated toT-117to match. Commit messages stay as historicalT-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:
-
Backfill omission (one-off). The T-113 backfill wrote
gl.6520+transaction.status: categorizedvia direct Firestore update, bypassingcategorizeTxFromReceipt. That helper would have ALSO stashedexpenseMetadata.merchant/expenseMetadata.payeefrom the receipt; the direct write skipped it. So even with the GL set, the tx had no merchant token baked in. -
Pipeline gap (structural).
enrichTransactionDisplayNamealready routes receipt-matched txs vialinkedRecordType === 'receipt'and thereceiptdoc-type template — but the token bag it passes in (relatedProjectTitle/payee/invoiceNumber/counterparty/reference/memo/projectId/client+expenseMetadata.*) has no receipt-specific tokens. Even withexpenseMetadata.merchantpopulated, the granular{{merchantCategory}}token (which the configured template references) has no source — the receipt'sparsedReceipt.categoryslug 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 viagetTelegramReceipt(subsidiary-scoped, no cross-DB fan-out) and returnsmerchant,merchantCategory+receiptCategory(both = human label of the AI category slug, e.g."fnb" → "F&B"via the Phase 2categoryLabelForhelper), andreceiptNote(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.ts—enrichTransactionDisplayNameadds alinkedRecordType === 'receipt'branch to the token-loader switch, callinggetReceiptDisplayTokens(transaction).catch(() => null)alongside the existing workspace/gcp/cross-border lanes. Result merges into thetokenDatabag passed togenerateGLAssignmentDisplayName.- 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 intebs-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.merchanton 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 bycategorizeTxFromReceiptfor 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(tip26330aac); 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 --noEmitclean;npx vitest run __tests__/lib/accounting/receiptDisplayTokens.test.ts6/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:
enrichTransactionDisplayNameis called by every read path that lists / surfaces transactions. The new branch only fires whenlinkedRecordType === 'receipt'(i.e. tx hasmatchedReceipts.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.