Receipt → GL categorization — manual-match GL propagation + COA dual-labels + fingerprint auto-match
Renumbered T-111 → T-112 → T-113 (2026-06-24, renumber-on-merge, twice). While this task was in flight on
claude/busy-dirac-QmUdM(PR #807), two other tasks grabbed the next-free UID ahead of it at merge time: first a T-111 ("Task board renders I-NNN tickets"), then a T-112 ("Harden coaching invoice-number reconstruction fallback"). Per the AGENTS.md "whoever merges second renumbers" rule, this task bounced T-111 → T-112 → T-113; the in-code UID comments across the 17 touched files were updated toT-113to match. Commit messages stay historicalT-111/T-112(immutable). README next-free bumped to T-114. (The repeat collision is the known parallel-agent next-free race the board README already flags.)Three-phase task. Phase 1 — manual receipt match propagates GL (fixes the reported tx). Phase 2 — receipt UIs show the COA account name alongside the granular category (dual-label). Phase 3 — persisted receipt fingerprint, editable in the detail drawer, used by the auto-matcher. Each phase is its own commit on the same branch / PR.
Why (owner, 2026-06-24)¶
Owner reported tx iIZAMoL6xbncfE00g2xC (OCBC card debit, "KPAY*SUSANNAS REST"):
matched a receipt via the Match Transaction modal → Receipts tab, expected the
receipt's pre-categorized GL to flow through to the bank tx. Instead the modal
redirected to the GL tab and asked for a manual GL assignment.
"I understand that if fingerprint for Expense Receipts are not in-place, it won't auto-match, but I don't understand how a tx doesn't get matched right away when it's matched to a receipt that carries GL information."
This is the first of three sequenced tickets in the wider receipts restructure conversation (Phase 1 of 3). Subsequent tasks: COA-driven dual labels in receipt UIs (Phase 2), persisted receipt fingerprint + auto-matcher uses it (Phase 3).
Why it's a gap, not design¶
Two co-located bugs:
-
Backend gap.
pages/api/accounting/transactions/[id].tsmatch-receipthandler calledmatchTransactionToReceipts(writesgl.receipts.{receiptId}allocations on the bank tx) but never propagated the receipt'smetadata.glAccountCodeto the tx'saccountCode. The auto-matcher (receiptMatchForTransactionsinlib/accounting/matching/hooks.server.ts) has done this since 2026-06-13 viacategorizeTxFromReceipt— the manual path was the lone outlier. -
Frontend gap.
TransactionLinkingModal's tab-inituseEffect(components/accounting/transactions/TransactionLinkingModal.tsx:743) had branches forhasMatchedInvoicesandhasAssignedAccount, but no branch forhasMatchedReceipts. A receipt-matched debit tx with noaccountCode(because of gap 1) fell through to theisDebit → "gl"default — pushing the bookkeeper to re-pick a GL even when the receipt's GL was supposed to win.
The journal-side consequence: a receipt-matched bank tx ended up with no
journal entry on either side. The receipt's INCURRED entry stops being derived
(filter at derivedJournals.server.ts:739 excludes paymentMethod !==
'reimbursement'), and derivedJournals.server.ts has zero consumers of
gl.receipts.* — so the company-card spend never posted to a P&L account.
After T-113 the tx's accountCode carries the receipt's GL and the existing
gl.{code}.receipts.* allocations land in the right bucket for downstream
reporting.
What shipped¶
- Backend (
lib/accounting/matching/hooks.server.ts) —categorizeTxFromReceiptpromoted from internal helper to exported function. Existing semantics preserved: company-card only, never overrides a tx that's already categorized on another axis (invoices / coaching / billing). - Handler (
pages/api/accounting/transactions/[id].ts) — aftermatchTransactionToReceiptsreturns, iterate the matched receipts, re-read each viagetTelegramReceipt(paymentMethod is'company_card'post-link, socategorizeTxFromReceipt's reimbursement guard doesn't fire), and callcategorizeTxFromReceipt(tx, receipt, by). First-with-GL wins on multi-receipt matches (the function early-returns ontx.accountCodeset — same semantics as the auto-matcher's per-receipt loop). Re-reads the tx between propagations so subsequent calls see the updated state. - Modal (
components/accounting/transactions/TransactionLinkingModal.tsx) — added ahasMatchedReceipts → "receipts"tab branch ahead of theisDebit → "gl"default.hasMatchedReceiptswas already computed at line 724; the branch just consumes it. - Backfill script (
scripts/backfill-receipt-matched-tx-gl.ts) — dry-run- by-default. WalksbankTransactions/*, finds txs withgl.receiptsbut noaccountCodeand no other categorization axis, looks up each matched receipt'sglAccountCode(first-with-GL wins), writes{accountCode, status: 'categorized'}via direct Firestore update (same shape ascategorizeTxFromReceipt's update). Idempotent + skips txs with other categorization sources. - Drive-by tsc fix (
lib/wopc.server.ts) — the T-110 commit6dfb0a1dintroduced two deaddata as WOPCDocumentcasts that tsc rejected post- T-080 cleanup (which dropped the legacy fields from the type). Addedas unknown as WOPCDocumentto silence — runtime is unchanged, the reconstructedreferenceNumberis appended right after so the resulting object IS valid.
Out of scope for T-113¶
- Unmatch → clear accountCode. When a bookkeeper unmatches a receipt,
the propagated
accountCodestays. If they then match a different receipt with a different GL,categorizeTxFromReceiptearly-returns on the pre-existingaccountCodeand the new receipt's GL is NOT applied. Edge case: the bookkeeper can click "Uncategorize" manually to clear it. Proper fix needs a "source of categorization" tracking field on the tx — separate task if it bites in practice. - Multi-receipt mixed-GL split. A single tx covering two receipts with different GLs still posts to ONE GL (the first one). Proper split needs a journal-line redesign — out of scope for the receipts restructure work.
- Phase 2 (COA-driven dual labels) and Phase 3 (persisted fingerprint) ship as their own tickets.
Decision log¶
2026-06-24 — T-113 opened¶
- ✅ Attestation (Accounting (Diagnostics)): read
AGENTS.md(tip26330aac); board scan checked T-066 / T-003 (the priorcategorizeTxFromReceiptwork) for overlap — both shipped the auto-matcher path; this task is the manual-handler counterpart and they don't overlap on files. - Source: Accounting (Diagnostics) · https://claude.ai/code/session_01G58Y71noihrYCDEDMexmea
- Owner direction (verbatim, 2026-06-24):
"(b) we make it so that the Receipts tab (that probably exists as some sort of plug-in) can reference the GL code stored on the receipt, and proceed to match the tx to the receipt and the GL that the receipt is categorized to — properly."
- Proposed by: the owner. Approved by: the owner — sequencing pick ("PR-α first"; option B for category-display granularity, locked for Phase 2).
- Tests + tsc:
npx tsc --noEmitclean (pre-existing wopc.server.ts dead- cast warnings fixed as drive-by);npx vitest run __tests__/lib/accounting114/114 pass. - Sweep run (Phase 4d disposal): still pending. The Phase 4d code merged
in #805 ships only the safety net; the actual
scripts/dispose-terminal- signing-requests.ts --applyrun needs Firebase Admin creds + owner sign-off on the dry-run output. Tracked separately on T-089's deferred follow-ups.
2026-06-24 — Phase 2 shipped: COA-driven category dual-label¶
- ✅ Attestation (Accounting (Diagnostics)): read
AGENTS.md(tip26330aac); continuation of T-113 per owner "Proceed till the end of it all" (2026-06-24). - Source: Accounting (Diagnostics) · https://claude.ai/code/session_01G58Y71noihrYCDEDMexmea
- Owner decision (verbatim, 2026-06-24): picked option B ("best of both
worlds") after a tax-deductibility discussion — keep the COA chart as-is
(the conventional, audit-defensible HK small-biz structure), keep the AI's
granular category as a denormalised hint, and dual-label in the UI:
"Let's do B then, seems to be keeping the best of both worlds"
Rationale recorded in chat: GL account names don't drive deductibility (HK
IRO s.16 reads the actual spend + substantiation, not the account label), and
flattening the COA to AI-category granularity would force re-aggregation for
every external statement. Sub-accounts (6520.1 …) were offered as the future
path if the P&L needs finer granularity — deferred.
- The dual-label: category as the primary (e.g. "F&B"); the GL account it
books to as a muted sub-line (e.g. "Staff Welfare & Events · 6520"). Surfaces
the booking truth the owner wanted visible without losing the granular
category for at-a-glance scanning.
- What shipped:
· Hook (lib/accounting/hooks/useGlAccountNames.ts) — selector over the
existing useAccounts() React Query hook; returns Map<code, name> built
from the COA. One subscription, cached (1-min stale), live-sync-invalidated.
· Pure helpers (lib/accounting/receiptCategoryLabel.ts) — categoryLabelFor
+ glSublineFor. Split out of the component so they're vitest-testable
without the React/antd transform.
· Component (components/receipts/ReceiptCategoryLabel.tsx) — exports a
PURE renderCategoryDualLabel(category, code, map) (tables call the hook
ONCE in the parent and pass the map — no per-row query subscription) and a
self-fetching <ReceiptCategoryLabel> for single-instance spots.
· Wired into 5 surfaces: ReceiptsTab (Category column + narrow merchant
sub-line), ReceiptDetailDrawer (Category field display), ReimbursementsTab
(two receipt tables — folded the standalone GL column into the dual-label),
ExpenseRecordsTab (enriched the existing "GL {code}" sub-line to
"{name} · {code}"), MatchReceiptModal (the surface the owner was using —
merged Category + GL columns into one dual-label).
- Tests + tsc: npx tsc --noEmit clean; npx vitest run __tests__/components/receipts/receiptCategoryLabel.test.ts
7/7; npx vitest run __tests__/lib/accounting 114/114.
- Degradation: non-ERL receipts (or codes the ERL COA doesn't carry) show
the bare GL {code} sub-line; uncategorised receipts (null code) show just
the category — same as before. Never throws.
2026-06-24 — Phase 3 shipped: persisted receipt fingerprint + auto-matcher uses it¶
- ✅ Attestation (Accounting (Diagnostics)): read
AGENTS.md(tip26330aac); final phase of T-113 per owner "Proceed till the end of it all" (2026-06-24). - Source: Accounting (Diagnostics) · https://claude.ai/code/session_01G58Y71noihrYCDEDMexmea
- Owner direction (verbatim, 2026-06-24):
"we make it so that the web app (Receipt Details modal) read and write a fingerprint field on the receipt firestore doc, and modify the receipt auto-matcher to match with the regarding tx with the fingerprint."
Plus, earlier in the same message: "implement fingerprint for receipts, not
at receipt upload time, but allow user to edit it in on the Receipt Detail
side-drawer modal." — so the field is not populated at ingest; it's a
bookkeeper-entered override.
- Why it matters: the receipt's AI-parsed merchant ("Susanna's Restaurant")
frequently differs from what the bank prints ("KPAYSUSANNAS REST"), so the
merchant-substring axis of isReceiptTxCandidate misses. A user-pinned
fingerprint lets the bookkeeper record the actual statement substring so the
auto-matcher can find the tx on the next sync.
- What shipped (full pipeline):
· Type (lib/telegram/receiptStore.ts) — metadata.fingerprint?: string
added; patchParsedReceipt accepts it in metadataFields and treats
null as clear (added to NULLABLE_METADATA_FIELDS).
· Write API (pages/api/receipts/[id].ts) — PATCH accepts fingerprint:
null/'' clears, a string is trimmed + capped at 200 and stored verbatim.
· Read API (pages/api/receipts/index.ts) — fingerprint: string | null
added to the wire row + serializer.
· Client type (components/records/ReceiptsTab.tsx) — ReceiptRow.fingerprint.
· Drawer (components/records/ReceiptDetailDrawer.tsx) — editable
"Fingerprint" field (code-styled when set; "Not set — auto-match uses the
merchant name" hint when empty). Empty saves as null. Tri-state wire mirror
like note.
· Auto-matcher (lib/accounting/matching/hooks.server.ts) —
receiptMerchantFingerprint now delegates to the new pure
pickReceiptFingerprint(fingerprint, merchant): prefers the explicit
fingerprint (≥4 chars normalised), falls back to the merchant. The
normalisation + preference moved to predicates.ts (pure) so it's
unit-testable without importing the server module.
- Tests + tsc: npx tsc --noEmit clean; new receiptFingerprint suite
(7 cases) + receiptCategoryLabel suite (7) pass; full __tests__/lib/accounting
+ __tests__/components/receipts = 128/128.
- Not at upload time (per owner):* neither the Telegram ingest nor the
web-upload commit path sets a fingerprint — the field is absent on new
receipts and the matcher falls back to the merchant until a bookkeeper fills
it in.
2026-06-24 — Codex review fixes (P1 + P2) + backfill corrected + run against prod¶
- ✅ Attestation (Accounting (Diagnostics)): read
AGENTS.md; addressing the Codex automated review on PR #807 per owner "Merge PR #807 and run the backfill for me" (2026-06-24). - Source: Accounting (Diagnostics) · https://claude.ai/code/session_01G58Y71noihrYCDEDMexmea
- Codex P1 — gate GL propagation to single full-amount matches
(
pages/api/accounting/transactions/[id].ts): the Phase 1 handler propagated the FIRST receipt's GL even on partial matches (receipt < tx) or multi-receipt matches with differing GLs — silently posting the remainder / later receipts to the wrong account. Fixed: propagate ONLY whenmatchedReceipts.length === 1AND the allocation covers the full tx amount (amountsMatch, ±0.01) — the exact invariant the auto-matcher'sisReceiptTxCandidatealready enforces. Ambiguous matches are left uncategorised for manual assignment (no regression; just no auto-help). The backfill script carries the same gate. - Codex P2 — write the backfill through the real V2 shape
(
scripts/backfill-receipt-matched-tx-gl.ts): the first cut wrote a top-levelaccountCode+status, but categorization actually lives atgl.<code>(GLAccountData) +transaction.status— the V1 adapter reads the GL code viagetAccountCodeFromGL(gl)and ignores top-levelaccountCode. So the first--applywas INEFFECTIVE (the txs still read as uncategorised). Rewrote the writer to setgl.<code> = {}+transaction.status = 'categorized'+FieldValue.delete()the stray top-levelaccountCode/status— matching exactly whatupdateTransactionproduces. Detection switched from the (wrong) top-levelaccountCodecheck togetAccountCodeFromGL. Also fixed the collection path (accounting/transactions/entries, notbankTransactions) and the amount path (transaction.transactional.amount). - Prod run (corrected): dry-run found 2 candidate txs; after the single-full
gate, 1 real victim:
iIZAMoL6xbncfE00g2xC(the reported tx) →gl.6520+transaction.status: categorized.lWMs3NXPLBWVCa5udMUAwas already correctly categorised togl.6600(the first buggy detection had false-flagged it) — skipped, and its stray top-level fields from the first run were cleaned. Backup:scripts/migration-backups/receipt-matched-tx-gl-2026-06-24T17-58-12-457Z.json. Post-run verification:iIZAMoL→gl=[6520,receipts],transaction.status=categorized, no top-level junk;lWMs3→gl=[6600,receipts], clean. Idempotent re-run: 0. - Tests + tsc:
npx tsc --noEmitclean;__tests__/lib/accounting+__tests__/components/receipts128/128.
Status — DONE (all three phases code-complete + backfill run)¶
- Phase 1 (manual-match GL propagation + tab fix, single-full gate), Phase 2
(COA dual-labels), Phase 3 (persisted fingerprint + auto-matcher) all shipped
on
claude/busy-dirac-QmUdM→ PR #807. Codex P1/P2 addressed. - Backfill: RUN against prod — the reported tx
iIZAMoL6xbncfE00g2xCis now categorised to GL 6520. No remaining historical victims.