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) —
categorizeTxFromReceiptfalls back todefaultGlCodeForCategory(parsedReceipt.category)fromlib/accounting/receiptCategoryToGlwhenglAccountCodeis absent. That table always returns a code (conservativeother → 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 existingaccountCodenot overwritten; manualmatch-receiptPOST unchanged (user there chose to link without categorizing). - Codex P1 follow-up (55237f89) —
linkReceiptToBankTx(insidematchTransactionToReceipts) flips Firestoremetadata.paymentMethodtocompany_card, but the in-memoryreceiptstill readreimbursement, 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+ combinedreconcileReceiptMatchesinhooks.server.ts. Admin endpointPOST /api/admin/reconcile-receipt-matches(admin-only, body{subsidiaryId?}defaulterl) + the daily reconciliation cron (pages/api/cron/reconciliation.tscallsreconcileReceiptMatches). 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) —
categorizeTxFromReceiptnow early-returns formatchedInvoices/matchedCoachingPayments/matchedCoachingInvoices/linkedBillingtoo (the reservedcoaching/coachingInvoicesgl keys don't surface as anaccountCode, so they'd have passed the old guard). Defensive — only refuses work, never adds it. - UI (69be2a16) —
PaperClipOutlinedindicator 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.
Related¶
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]]