Skip to content

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 to T-113 to match. Commit messages stay historical T-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:

  1. Backend gap. pages/api/accounting/transactions/[id].ts match-receipt handler called matchTransactionToReceipts (writes gl.receipts.{receiptId} allocations on the bank tx) but never propagated the receipt's metadata.glAccountCode to the tx's accountCode. The auto-matcher (receiptMatchForTransactions in lib/accounting/matching/hooks.server.ts) has done this since 2026-06-13 via categorizeTxFromReceipt — the manual path was the lone outlier.

  2. Frontend gap. TransactionLinkingModal's tab-init useEffect (components/accounting/transactions/TransactionLinkingModal.tsx:743) had branches for hasMatchedInvoices and hasAssignedAccount, but no branch for hasMatchedReceipts. A receipt-matched debit tx with no accountCode (because of gap 1) fell through to the isDebit → "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)categorizeTxFromReceipt promoted 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) — after matchTransactionToReceipts returns, iterate the matched receipts, re-read each via getTelegramReceipt (paymentMethod is 'company_card' post-link, so categorizeTxFromReceipt's reimbursement guard doesn't fire), and call categorizeTxFromReceipt(tx, receipt, by). First-with-GL wins on multi-receipt matches (the function early-returns on tx.accountCode set — 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 a hasMatchedReceipts → "receipts" tab branch ahead of the isDebit → "gl" default. hasMatchedReceipts was already computed at line 724; the branch just consumes it.
  • Backfill script (scripts/backfill-receipt-matched-tx-gl.ts) — dry-run- by-default. Walks bankTransactions/*, finds txs with gl.receipts but no accountCode and no other categorization axis, looks up each matched receipt's glAccountCode (first-with-GL wins), writes {accountCode, status: 'categorized'} via direct Firestore update (same shape as categorizeTxFromReceipt's update). Idempotent + skips txs with other categorization sources.
  • Drive-by tsc fix (lib/wopc.server.ts) — the T-110 commit 6dfb0a1d introduced two dead data as WOPCDocument casts that tsc rejected post- T-080 cleanup (which dropped the legacy fields from the type). Added as unknown as WOPCDocument to silence — runtime is unchanged, the reconstructed referenceNumber is 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 accountCode stays. If they then match a different receipt with a different GL, categorizeTxFromReceipt early-returns on the pre-existing accountCode and 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 (tip 26330aac); board scan checked T-066 / T-003 (the prior categorizeTxFromReceipt work) 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 --noEmit clean (pre-existing wopc.server.ts dead- cast warnings fixed as drive-by); npx vitest run __tests__/lib/accounting 114/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 --apply run 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 (tip 26330aac); 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 (tip 26330aac); 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 when matchedReceipts.length === 1 AND the allocation covers the full tx amount (amountsMatch, ±0.01) — the exact invariant the auto-matcher's isReceiptTxCandidate already 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-level accountCode + status, but categorization actually lives at gl.<code> (GLAccountData) + transaction.status — the V1 adapter reads the GL code via getAccountCodeFromGL(gl) and ignores top-level accountCode. So the first --apply was INEFFECTIVE (the txs still read as uncategorised). Rewrote the writer to set gl.<code> = {} + transaction.status = 'categorized' + FieldValue.delete() the stray top-level accountCode/status — matching exactly what updateTransaction produces. Detection switched from the (wrong) top-level accountCode check to getAccountCodeFromGL. Also fixed the collection path (accounting/transactions/entries, not bankTransactions) 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. lWMs3NXPLBWVCa5udMUA was already correctly categorised to gl.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: iIZAMoLgl=[6520,receipts], transaction.status=categorized, no top-level junk; lWMs3gl=[6600,receipts], clean. Idempotent re-run: 0.
  • Tests + tsc: npx tsc --noEmit clean; __tests__/lib/accounting + __tests__/components/receipts 128/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 iIZAMoL6xbncfE00g2xC is now categorised to GL 6520. No remaining historical victims.