flexpay-engine
v0.3.5
Published
FlexPay loan servicing engine — pricing, schedules, allocation, delinquency, lock-state
Readme
flexpay-engine
Loan-servicing engine for FlexPay. Owns money truth — pricing, schedules, payment allocation, delinquency, lock-state decisions, banking rails. Consumed by the rest of the FlexPay stack as an npm package today and as a Cloudflare Worker for banking integrations.
Mental model: Stripe-of-loans. This is the engine other services call. Workflow, UI, identity, and device integrations live elsewhere.
New here? Read
docs/roadmap.md— the Upya cutover journey, where we are, and how the pieces connect.
⛔ Upgrading to 0.3.x — apply migrations 0004 and 0005 FIRST
Do not bump a consumer to 0.3.x before applying
drizzle/engine/0004_next_state_change_at.sql
and drizzle/engine/0005_lock_event_actor.sql
to that consumer's ENGINE_DB.
From 0.3.0, recordEvent / recordPayment write
engine_contracts.next_state_change_at and actor fields on
engine_lock_state_events. Without these columns the contract update or event
insert throws — and because engine writes are typically wired non-blocking,
the failure is silent: a ledger gap, not an error the caller sees.
The migrations are additive and safe to apply ahead of the bump. 0004 also
backfills next_state_change_at; 0005 backfills legacy event actors. Guard
re-runs because ADD COLUMN itself is not idempotent.
After deploying the worker with actor-enriched archive version v2, apply
drizzle/engine/0006_requeue_actor_archive.sql
to clear archive markers and rewrite existing R2 objects with actor fields.
Earlier lock-state columns need 0001–0003 for the same reason.
0.3.0 is deprecated — use 0.3.1 or later. It was published from an
unmerged branch and carries a float-drift bug in daysActivated that can add or
drop a day of entitlement, which feeds the lock decision.
paid_through_date is provisional after upgrading
recordEvent maintains paid_through_date, but on a database where it is still
NULL the lock-state bootstrap derives it from the payment date rather than the
contract's real entitlement. Use snapshotSeedContract to seed it from an
authoritative source before anything reads paid_through_date or
next_state_change_at.
What this owns vs what it doesn't
The test for whether something belongs here: does it affect what a customer owes right now?
| Belongs in engine | Does NOT belong in engine | |---|---| | Ledger (contracts, payments, schedules) | Customer profiles / KYC | | Pricing, allocation, delinquency | Phone catalog / inventory | | Lock-state DECISION (locked/unlocked) | Lock-state EXECUTION (Trustonic, Nuovopay APIs) | | Payment rails (BNC, ChinChin) | Notification transport (SMS, email, push) | | Contract state events | Sales flow UI / portal forms | | Reconciliation against banking partners | Agent/store admin tools | | Append-only audit trail | Currency conversion / FX hedging |
System map
┌──────────────────────────────────────┐
│ flexpay-engine (this repo) │
│ ──── money brain ──── │
│ │
│ • Ledger + schedules │
│ • Pricing, allocation, delinquency │
│ • Lock-state DECISION │
│ • Payment rails (BNC; ChinChin TBD) │
│ • R2 audit mirror │
│ • Emits state events │
└────────────┬─────────────────────────┘
│ npm pkg + Service Binding RPC
┌──────────────────┼──────────────────┐
▼ ▼ ▼
┌────────────────────┐ ┌──────────────┐ ┌─────────────────┐
│ flexpay-client- │ │ Supabase │ │ flexpay-backend │
│ worker (limbs) │ │ (catalog + │ │ (mobile) │
│ │ │ identity) │ │ │
│ • Lock execution │ │ │ │ • KYC (Didit) │
│ (Trustonic, │ │ • Profiles │ │ • Customer │
│ Nuovopay) │ │ • SKUs │ │ self-service │
│ • Sales flow │ │ • Deals │ │ • Mobile UX │
│ • Customer CRUD │ │ • Inventory │ │ │
│ • Signature │ │ • Stores │ │ │
│ • Agents / stores │ │ • Agents │ │ │
└────────────────────┘ └──────────────┘ └─────────────────┘
▲ ▲
│ portal │ mobile app
│ │
└──── flexpay-portal (Next.js) ──────┘
flexpay-app (React Native)Capability inventory
State as of 2026-05-22. Tracks what's owned, what's still on Upya, and what's in flight.
Payments — money in
| Capability | Today | Target | Status | |---|---|---|---| | Pago Movil C2P (customer push w/ token) | ChinChin | engine via BNC SendC2P | ✅ sandbox-verified, ⏳ prod cert | | Pago Movil B2P (customer-initiated push) | ChinChin | engine via BNC webhook | ✅ webhook receiver shipped | | Débito Inmediato (recurring auto-debit) | not used | engine via BNC SIMF | ✅ sandbox-verified | | Cash at store | Upya | engine, recorded by portal | ❌ | | FX (USD ↔ VES) | ChinChin | TBD — not engine | ❌ scope decision pending |
Payments — money out
| Capability | Today | Target | Status | |---|---|---|---| | Refunds | manual | engine via BNC SendP2P | ⏳ rail done, refund event TBD | | Store / agent commissions | manual | engine | ❌ |
Contract lifecycle
| Capability | Today | Target | Status |
|---|---|---|---|
| Profile creation | Upya + Supabase | client-worker + Supabase | ❌ |
| KYC (Didit) | backend | unchanged | ✅ |
| Deal catalog | Upya + Supabase | Supabase only | ❌ — Upya sync to remove |
| Deal application (deal → terms) | Upya editTerms | engine originate(dealOptionId) | ⏳ resolver TBD |
| Unit assignment | Upya | client-worker + engine FK | ❌ |
| Pricing calc | engine partial | engine | ✅ |
| Schedule generation | engine | engine | ✅ |
| Signature capture | client-worker + Supabase | unchanged | ✅ |
| Downpayment | Upya hack | engine recordPayment(kind=downpayment) | ⏳ |
| Activation | Upya | engine state derives from data | ❌ |
| Status webhooks (lock/unlock) | Upya | engine emits events | ⏳ next: lock-state module |
| Amendment / re-pricing | Upya editTerms | engine amend() | ❌ |
| Closure (paid_off / repossessed / written_off) | manual + Upya | engine emits | ❌ |
Lock state
| Capability | Today | Target | Status |
|---|---|---|---|
| Decision (should device be locked?) | Upya nextStatusUpdate | engine | ⏳ next — context/specs/2026-05-22-feat-lock-state-decision.md |
| Execution (Trustonic, Nuovopay) | client-worker | client-worker | ✅ |
| Registration (first-time provisioning) | client-worker | client-worker | ✅ |
| Reconciliation (device state vs intent) | implicit / Upya | client-worker cron reading engine | ❌ |
Identity / org
| Capability | Today | Target | Status | |---|---|---|---| | Customer profile CRUD | Supabase via Upya proxy | Supabase via client-worker | ❌ | | Agent directory | Upya | Supabase | ❌ | | Store directory | Supabase | unchanged | ✅ | | Auth (portal + app) | betterauth / supabase-auth | unchanged | ✅ |
Inventory
| Capability | Today | Target | Status | |---|---|---|---| | Phone SKU catalog | Supabase phone_skus | unchanged | ✅ (decouple Upya template dep) | | Store inventory | Supabase store_inventory | unchanged | ✅ | | Unit lifecycle state machine | Upya | client-worker + Supabase | ❌ |
Notifications
| Capability | Today | Target | Status | |---|---|---|---| | Payment received | partial | engine emits → notif worker | ⏳ | | Payment due reminder | scattered | engine emits | ❌ | | Lock-imminent warning | Upya | engine emits | ⏳ falls out of lock-state spec | | KYC reminder | backend | unchanged | ✅ | | Transport (SMS, email, push) | various | dedicated worker | (external) |
Reporting & reconciliation
| Capability | Today | Target | Status | |---|---|---|---| | Internal finance reporting | Upya CSV | engine read replica + report-scribe | ❌ | | Customer receipts | partial | engine event | ❌ | | SUDEBAN regulatory | unknown | engine + accounting overlay | ❌ — confirm requirements | | 3-way reconciliation (engine ↔ BNC ↔ counterparty) | none | engine | ⏳ pre-prod gate | | Daily close (tie-out) | manual | engine | ❌ |
Legend: ✅ shipped · ⏳ in flight · ❌ not started
What's in this repo today
src/
calc/ pricing, schedule, allocation, delinquency, daysActivated
operations/ originate, recordPayment, recordEvent, seedContract
schema/ drizzle schema — npm-exported for consumers
worker/
banking/
bnc/ BNC ESolutions API client (P2P, C2P, Débito, auth, audit)
event/ webhook → queue → idempotent processor + DLQ
schema/ bank_* tables (separate from engine_* tables)
services/ bake-check, archive-sweep, reconcile
lib/ archive (R2), observability, redaction, ulid, encoding
routes/ admin, health, BNC webhook
scheduled.ts cron handlers (key rotation, bake, archive sweep)
index.ts Hono app + queue consumer + scheduled dispatcher
drizzle/
banking/ bank_* migrations
docs/
bnc/ architecture, security, state-of-play, green-light criteria
runbooks/ bnc-banking ops procedures, R2 restore drill
context/
specs/ active and shipped specs
tasks/ per-task progress notesWhat's next
Active specs:
context/specs/2026-05-22-feat-lock-state-decision.md— engine becomes the source of truth for lock state. Highest-priority Upya replacement.docs/bnc/green-light-criteria.md— BNC production certification gates (14-day bake, 3-way reconciliation, prod creds).docs/plans/2026-05-19-consumer-migration-spec.md— CHUNK 17: cut flexpay-backend and flexpay-client-worker off the npm package onto Service Binding RPC.
The Upya cutover, sequenced:
- Lock-state decision in engine (next)
- Move ChinChin into engine alongside BNC (CHUNK A3)
- Strip Upya state polling from client-worker
- Strip Upya CRM calls from sales flow
- Decommission Upya integration
Realistic runway: 6–10 weeks of focused work post-lock-state-decision.
Installation (npm consumers)
bun add flexpay-engineimport { recordPayment, toCents, seedContract } from 'flexpay-engine';
import { engineContracts, enginePayments } from 'flexpay-engine/schema';Publishing a new version
- Bump version in
package.json - Commit:
git commit -am "chore: bump to vX.Y.Z" - Publish:
npm publish --access public --otp=YOUR_CODE - Update consumers:
# In flexpay-client-worker bun add [email protected] # In flexpay-backend bun add [email protected] - Commit lockfile changes in each consumer repo
Versioning
- Patch (0.1.x): Bug fixes, no API changes
- Minor (0.x.0): New features, backwards compatible
- Major (x.0.0): Breaking changes
Development
bun install
bun test # 135+ tests covering calc, banking, lib
bun run typecheckWorker dev
# Local
bun run dev
# Dry-run deploy
bunx wrangler deploy --dry-run
# Deploy to dev
bunx wrangler deploy --env=""
# Tail logs
bunx wrangler tail flexpay-engine-devMigrations
# Banking tables (worker)
bunx wrangler d1 execute flexpay-engine-dev --remote --file=drizzle/banking/XXXX_*.sqlEngine ledger migrations are applied by the consumer that owns ENGINE_DB.
For flexpay-client-worker, the authoritative path is that repository's
drizzle-engine/ directory because its Wrangler config points there. Apply
flexpay-client-worker/drizzle-engine/0003_* and 0004_* there; do not also
apply this package's drizzle/engine/0007_* or 0008_* to the same database.
Other consumers use this package's drizzle/engine/ migrations instead and
must not run the worker copies against that database.
The archive requeue remains an Engine-package migration:
# Deploy the worker containing archive version v2, then:
bunx wrangler d1 execute <engine-db> --remote --file=drizzle/engine/0006_requeue_actor_archive.sql
# The requeue must run after v2 is deployed so the sweep rewrites R2 objects as v2.Live URLs
| Endpoint | Auth | Purpose |
|---|---|---|
| https://flexpay-engine-dev.achilleas-dbe.workers.dev | n/a | Dev worker |
| GET /health/bake-summary | none | Bake-check status (14-day clock) |
| GET /admin/bnc/status | Bearer | Detailed health |
| GET /admin/bnc/ping | Bearer | BNC sandbox connectivity |
| POST /admin/bnc/bake/run | Bearer | Force a bake-check now |
| POST /webhooks/bnc | BNC creds | Inbound BNC webhook |
R2 buckets: bank-audit-archive-{dev,prod} (append-only audit mirror).
Architecture decisions worth knowing
- Single writer per table.
src/writes onlyengine_*tables.worker/banking/writes onlybank_*tables. CI guard enforces (scripts/check-engine-tables.sh). - Append-only ledger. No UPDATE/DELETE on
engine_paymentsexcept via the auditedforceReseedContractpath. Same rule forengine_lock_state_eventsonce shipped. - Cents only. No floats touching D1.
- Idempotency via DB constraints, not error-string matching. UNIQUE indexes +
onConflictDoNothing. - R2 mirror. Every banking row is mirrored to R2 within ~1s of D1 write; hourly sweep retries failures. D1 restore drill executed 2026-05-22.
- Best-effort archive, never blocks. R2 write failures log + retry via sweep; never break the payment path.
- Per-message queue ack/retry. Never throw from the queue handler — that retries the whole batch.
Key docs
| Doc | When to read |
|---|---|
| docs/bnc/README.md | Anything BNC-related |
| docs/bnc/state-of-play.md | What works / doesn't / unknown |
| docs/bnc/security.md | Threat model, crypto, secrets |
| docs/bnc/green-light-criteria.md | When can we cut over to prod |
| docs/runbooks/bnc-banking.md | Ops procedures, R2 drill |
| context/specs/ | Active and shipped specs |
| CLAUDE.md (parent dir) | Cross-repo conventions |
