Skip to main content

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:

  1. 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.
  2. 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's sent_at. The reliable key is service date + amount, with the printed invoice number only a tiebreaker.
  3. One PDF yields several checks. The single-column file_fingerprint UNIQUE 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 (anchor coalesce(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-tested allocateCheck consumes — targeting is pure list-ordering; the allocator never changes.
  • Payer → facility set. A new admin-managed billing_payers table (payer pattern → one-or-many facilities, or covers_all_facilities for a management company), seeded from the backfill alias rules. Pure resolvers in src/lib/facility-match. An unrecognized/ambiguous payer is flagged for a manual facility pick — never guessed.
  • Cross-facility apply is already safe. confirm_check_reconciliation looks invoices up by company + id (not facility), so a single check settling invoices across facilities produces a correct per-facility check_payment_applications trail 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 (+ a billing_payers admin screen at /admin/checks/payers) and a one-time bulk loader scripts/import-checks.mjs. Both stage checks as needs_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.mjs rule 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 to needs_review, never a mis-apply.
  • The ±10-day window and sent_at anchor are heuristics tuned to the backfill data; once invoices carry real period_end, the anchor tightens automatically (coalesce prefers 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.