39. AI-vision check import + date-based, cross-facility reconciliation
Date: 2026-06-16
Status
Accepted
Context
Facilities mail in scanned payment-check packets (envelope + one or more checks + a remittance stub). The existing check pipeline (ADR-0023 / migration 0018) read a single check's amount and allocated it oldest-first against one facility's invoices. Real packets break three assumptions:
- The payer is not the billed facility. A check from "The Enclave at Port Chester" pays invoices billed to "The Enclave at Rye"; a management company (e.g. New Premier) pays for several facilities on one check; a sibling facility pays on another's behalf. So a payer resolves to a set of facilities, not one.
- Invoice numbers aren't a reliable key. The backfilled Wise invoices have
period_start/period_end= null and remittance dates (service week-ending, a Saturday) sit ~2 days before the invoice'ssent_at. The reliable key is service date + amount, with the printed invoice number only a tiebreaker. - One PDF yields several checks. The single-column
file_fingerprintUNIQUE rejected a packet's second check as a duplicate upload.
Decision
- Date-driven targeting, allocator untouched. A new pure
targetInvoicesByRemittance(src/lib/reconcile/target.ts) matches each remittance line to an outstanding invoice by amount + date (anchorcoalesce(period_end, sent_at), ±10-day window), INV number as a tiebreaker, pooled across the payer's facility set. It returns the order the existing, golden-testedallocateCheckconsumes — targeting is pure list-ordering; the allocator never changes. - Payer → facility set. A new admin-managed
billing_payerstable (payer pattern → one-or-many facilities, orcovers_all_facilitiesfor a management company), seeded from the backfill alias rules. Pure resolvers insrc/lib/facility-match. An unrecognized/ambiguous payer is flagged for a manual facility pick — never guessed. - Cross-facility apply is already safe.
confirm_check_reconciliationlooks invoices up by company + id (not facility), so a single check settling invoices across facilities produces a correct per-facilitycheck_payment_applicationstrail with no RPC change. - Remainder by payer scope (D3). A single-facility payer spreads any leftover oldest-first across that facility; a management-company / multi-facility payer holds the remainder for manual assignment rather than spreading it across the group.
- Vision returns an array.
extractCheckPacket(Anthropic adapter) reads every check + its remittance lines from a multi-page packet. Dedup relaxed to(company_id, file_fingerprint, coalesce(check_number,''))so one PDF can yield several checks. - Two entry points (D1). An in-app upload flow at
/admin/checks(+ abilling_payersadmin screen at/admin/checks/payers) and a one-time bulk loaderscripts/import-checks.mjs. Both stage checks asneeds_review; nothing is applied without the owner confirming (D7), where the authoritative targeting + allocation runs. - Never persist MICR. The vision schema has no field for the bank routing/account (MICR) line; Zod
strips any unknown keys. Defense-in-depth: a
guardrails.mjsrule fails the build if a check-reconciliation file grows a routing/account identifier, plus a unit test proving the schema strips them.
Consequences
- Funds route to the correct facility even when the payer differs, and a single check can clear invoices across several facilities with a correct audit trail.
- New management-company / sibling-payer arrangements are self-serve via
billing_payers, not code changes. A stale or missing mapping degrades safely toneeds_review, never a mis-apply. - The
±10-daywindow andsent_atanchor are heuristics tuned to the backfill data; once invoices carry realperiod_end, the anchor tightens automatically (coalesceprefers it). - AI vision still sends the full check image to Anthropic (consistent with the existing posture in ADR-0026 for contract/check extraction); this is the reviewed third-party processing step.