Skip to content

Receipt auto-matcher — categorize via category default GL + drift reconciler

What / Why

Resolves the receipt auto-matcher "no status change" issue surfaced under T-064: owner reported tx lWMs3NXPLBWVCa5udMUA showed an exact receipt link in the Match modal's Receipts tab but the tx stayed "unmatched" (badge unchanged, journal unposted). Diagnosis (local agent, confirmed from source): the auto-engine WAS working (exact = hooks.server.ts signature) and matchTransactionToReceipts links the receipt (gl.receipts) but never sets status. The cloud agent found the precise mechanism: the engine ALREADY had a categorizeTxFromReceipt step meant to flip unmatched → categorized, but it only fired when receipt.metadata.glAccountCode was set — and that field is filled at receipt-confirm time, not at auto-extract time, so raw auto-extracted receipts silently no-op'd. Owner decision 2026-06-11: a receipt match should categorize the tx via its category's default GL (the categorize-via-GL route, not a bare status flip — a receipt is expense-side evidence).

DONE (origin/nightly, NOT main)

  • Core fix (69be2a16)categorizeTxFromReceipt falls back to defaultGlCodeForCategory(parsedReceipt.category) from lib/accounting/receiptCategoryToGl when glAccountCode is absent. That table always returns a code (conservative other → 6300), so the categorize step never silently no-ops. Returns the applied GL so the log note shows the real code. Preserved: reimbursement receipts skipped; txs with an existing accountCode not overwritten; manual match-receipt POST unchanged (user there chose to link without categorizing).
  • Codex P1 follow-up (55237f89)linkReceiptToBankTx (inside matchTransactionToReceipts) flips Firestore metadata.paymentMethod to company_card, but the in-memory receipt still read reimbursement, so the categorize guard short-circuited → the fix was dead code for the exact auto-match case. Fixed by reflecting the post-link state on the in-memory object at both call sites. (Subtle runtime bug tsc can't catch — good review catch.)
  • Drift reconciler (1994fb5b, 3d7b821a; Codex P1 d0b6f587) — new reconcileLinkedReceipts + reconcileFingerprintMatches + combined reconcileReceiptMatches in hooks.server.ts. Admin endpoint POST /api/admin/reconcile-receipt-matches (admin-only, body {subsidiaryId?} default erl) + the daily reconciliation cron (pages/api/cron/reconciliation.ts calls reconcileReceiptMatches). Two passes: (1) re-scan fingerprint matcher against current unmatched txs + open receipts; (2) drift-repair txs linked-to-receipt-but-no-GL (the pre-fix silent-no-op backlog). Codex restricted the drift pass to the single-receipt full-coverage shape.
  • Already-matched guard (e69d340f)categorizeTxFromReceipt now early-returns for matchedInvoices / matchedCoachingPayments / matchedCoachingInvoices / linkedBilling too (the reserved coaching/coachingInvoices gl keys don't surface as an accountCode, so they'd have passed the old guard). Defensive — only refuses work, never adds it.
  • UI (69be2a16)PaperClipOutlined indicator next to the status tag in the BankTransactions table when a tx has receipts attached but is still uncategorised (the manual-link-without-categorize case). Click opens the detail drawer to finish.

tsc clean on current nightly (the 4a4e5fb3 "OOM" was a CI flake, not a code error).

Note for owner

lWMs3NXPLBWVCa5udMUA was linked before this fix shipped, so the going-forward categorize won't retroactively heal it — it gets categorized by the daily cron or an on-demand POST /api/admin/reconcile-receipt-matches (drift pass). Worth running the endpoint once to clear the existing backlog rather than waiting for the cron.

T-064 (where this was surfaced as the receipt-matcher blocker) · T-067 (unified ExpenseCategory taxonomy — the GL-mapping side) · [[project_receipts_subsystem_state]] · [[feedback_evidence_only_matching]]