23. Check reconciliation
Date: 2026-06-06
Status
Accepted
Context
Facilities pay by mailed check. Step 13 ingests a check image, reads its amount, suggests how it
clears the facility's outstanding invoices, and — only after the admin confirms — applies the
payment. The build plan §3 flagged bugs in the spec's matcher. The schema already had
check_payments (with a unique file_fingerprint for dedup and an ocr_extracted_cents field) and
check_payment_applications.
Decision
- Pure matcher (
src/lib/reconcile, tested):allocateCheck(checkCents, invoices)allocates the check across invoices in the given order (caller passes oldest-first), fully paying each until exhausted. Fixes §3: an invoice isfullyPaidiff the applied amount equals its outstanding balance (so a check that fully pays #1 and partially pays #2 marks #1 paid, #2 partial); all integer cents (no float compares); empty-list and already-paid invoices handled; the unapplied remainder is reported as leftover/credit. - Dedup: the uploaded file's SHA-256 is stored as
file_fingerprint; a re-upload of the same bytes is rejected (pre-check + the unique constraint) so a payment can't be double-credited. - OCR (Claude, key already on file):
extractCheckDetailsreads amount + check number + payer + date + a self-rated confidence from the image/PDF, and is instructed to omit the routing and account numbers — we never store them (only the amount + metadata). Tolerant parse + Zod + one retry. OCR is best-effort and only pre-fills the amount; the admin confirms/corrects it. - Suggest, then confirm: the
/admin/checksreconcile panel previews the allocation live in the browser (the same pure matcher) as the admin edits the amount. Confirm is owner-only; the action re-reads the check's facility, recomputes the allocation authoritatively server-side, and applies it via theconfirm_check_reconciliationRPC. - Atomic apply: the SECURITY DEFINER RPC (service-role only, actor-attributed) inserts each
check_payment_applicationsrow and advances the invoice'samount_paid_cents+ status (paidwhen cleared, elsepartial) under row locks, then marks the checkconfirmed— refusing an already-confirmed check.
Consequences
- Reconciliation reuses the invoice payment lifecycle (ADR-0022): a check applied here flips invoices to partial/paid exactly like a manually-recorded payment, and the dashboard outstanding updates.
- The check image is sent to Claude (the accepted AI-processing posture, ADR-0008); routing/account numbers are omitted by the prompt and never persisted, satisfying the bank-data-minimization rule.
- Deferred: the Gmail inbound check-ingest (DKIM/SPF/DMARC + human-review queue) — Step 12; the OCR prompt should be tuned against a real sample check (it gracefully falls back to manual amount entry until then). Overpayment leftover is surfaced but not auto-recorded as a credit.