flexpay-engine 0.2.1 → 0.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +154 -0
- package/README.md +310 -14
- package/drizzle/banking/0000_bank_foundation.sql +99 -0
- package/drizzle/banking/0001_archived_at.sql +11 -0
- package/drizzle/engine/0001_lock_state.sql +34 -0
- package/drizzle/engine/0002_paid_through_date.sql +9 -0
- package/drizzle/engine/0003_lock_events_archive.sql +9 -0
- package/drizzle/engine/0004_next_state_change_at.sql +28 -0
- package/package.json +25 -6
- package/src/calc/allocation.ts +42 -17
- package/src/index.ts +22 -1
- package/src/lockState/decide.ts +180 -0
- package/src/lockState/incremental.ts +94 -0
- package/src/lockState/index.ts +9 -0
- package/src/lockState/types.ts +62 -0
- package/src/operations/getLockState.ts +67 -0
- package/src/operations/recordEvent.ts +60 -4
- package/src/operations/snapshotSeed.ts +377 -0
- package/src/schema/index.ts +53 -0
- package/src/types.ts +2 -13
- package/src/utils/cents.test.ts +0 -76
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to `flexpay-engine` (npm package). Only `src/` ships; the
|
|
4
|
+
`worker/` runtime is deployed separately and is not part of the package.
|
|
5
|
+
|
|
6
|
+
## 0.3.1 — 2026-07-31
|
|
7
|
+
|
|
8
|
+
Released from `dev`. Supersedes **0.3.0, which must not be used** — 0.3.0 was
|
|
9
|
+
published from an unmerged PR branch, carries the float-drift `daysActivated`
|
|
10
|
+
bug fixed below, and predates migration `0004`. It is deprecated on npm.
|
|
11
|
+
|
|
12
|
+
An earlier plan to ship this as `0.2.2` (off the 0.2.1 commit, allocation fix
|
|
13
|
+
only) was dropped: `0.3.0` was already published, so a `0.2.2` would have moved
|
|
14
|
+
the `latest` tag backwards. The `v0.2.2` git tag was created by mistake against
|
|
15
|
+
0.3.0 content and has been moved.
|
|
16
|
+
|
|
17
|
+
### ⚠️ REQUIRED before upgrading: migration `0004`
|
|
18
|
+
|
|
19
|
+
`drizzle/engine/0004_next_state_change_at.sql` must be applied to a consumer's
|
|
20
|
+
`ENGINE_DB` **before** it bumps to 0.3.1 and calls `recordEvent`/`recordPayment`,
|
|
21
|
+
or the contract update fails on the missing `engine_contracts.next_state_change_at`
|
|
22
|
+
column. Because engine writes are non-blocking in the backend, that failure is
|
|
23
|
+
*silent* — a ledger gap, not an error the caller sees.
|
|
24
|
+
|
|
25
|
+
The migration is additive: `ADD COLUMN` (nullable) plus a backfill gated on
|
|
26
|
+
`paid_through_date IS NOT NULL`. Verified 2026-07-31 — 0 of 970 prod contracts
|
|
27
|
+
have `paid_through_date` set, so the backfill touches no rows.
|
|
28
|
+
|
|
29
|
+
State as of 2026-07-31: applied to `flexpay-engine-dev`; **not yet** applied to
|
|
30
|
+
`flexpay-engine-prod`.
|
|
31
|
+
|
|
32
|
+
### ⚠️ `paid_through_date` is provisional after this upgrade
|
|
33
|
+
|
|
34
|
+
`recordEvent` now maintains `paid_through_date`, but all 970 prod contracts have
|
|
35
|
+
it NULL, so `applyPaymentToLockState` bootstraps from the *payment date* rather
|
|
36
|
+
than real entitlement. `snapshotSeedContract` (added in this release) exists to
|
|
37
|
+
set it from Upya's `next_status_update` and has **never been run on prod**.
|
|
38
|
+
|
|
39
|
+
This is safe only because nothing reads it yet: the sole consumer,
|
|
40
|
+
`flexpay-client-worker` `lockDateProjectionService`, is gated by
|
|
41
|
+
`SYNC_LOCK_DATE_FROM_ENGINE`, which is `"disabled"` on dev *and* prod.
|
|
42
|
+
**Snapshot-seed prod before that flag is ever flipped to `"enabled"`.**
|
|
43
|
+
|
|
44
|
+
### Added
|
|
45
|
+
|
|
46
|
+
- `snapshotSeedContract` / `planSnapshotSeed` — seed `paid_through_date` / lock
|
|
47
|
+
state from an external snapshot without replaying payment history.
|
|
48
|
+
- `engine_contracts.next_state_change_at` — denormalized lock boundary
|
|
49
|
+
(migration `0004`), maintained by `recordEvent` and `snapshotSeedContract`.
|
|
50
|
+
|
|
51
|
+
### Packaging
|
|
52
|
+
|
|
53
|
+
- **`drizzle/` and `CHANGELOG.md` now ship in the npm artifact.** Previously
|
|
54
|
+
`files` was `["src"]`, so a consumer upgrading through npm received neither the
|
|
55
|
+
migration SQL nor any upgrade notice — they could follow the published install
|
|
56
|
+
instructions and hit the silent ledger gap above. The `0.3.x` prerequisite is
|
|
57
|
+
now also stated at the top of the README, which npm does ship.
|
|
58
|
+
|
|
59
|
+
### ⚠️ BREAKING (permitted on 0.x — SemVer §4)
|
|
60
|
+
|
|
61
|
+
- **`allocatePayment` no longer throws `PaymentBelowMinimumError`.** It records any
|
|
62
|
+
positive amount. The rule refused to write down cash the app, ChinChin, the gateway
|
|
63
|
+
and Upya had all already accepted — a ledger declining money it holds. Sufficiency is
|
|
64
|
+
an entitlement question, decided from `paidThroughDate`.
|
|
65
|
+
- **`PaymentBelowMinimumError` removed** from `src/types.ts` and the package entrypoint.
|
|
66
|
+
- **`AllocationInput.minPayment` removed.** Callers passing it get TS2353; callers
|
|
67
|
+
reading it get TS2339.
|
|
68
|
+
|
|
69
|
+
Known affected consumer: `flexpay-client-worker` `scripts/parity-legacy-seed.ts` on
|
|
70
|
+
branch `feat/legacy-contract-seed-derivation` (imports the class, does `instanceof`,
|
|
71
|
+
and passes `minPayment`). It needs a rewrite. Neither `flexpay-backend/src` nor
|
|
72
|
+
`flexpay-client-worker/src` on their default branches references either symbol.
|
|
73
|
+
|
|
74
|
+
No shim was added. A shim covering only the error class while leaving the type break
|
|
75
|
+
would make the release look non-breaking without being non-breaking.
|
|
76
|
+
|
|
77
|
+
### Fixed
|
|
78
|
+
|
|
79
|
+
- **`daysActivated` is now fragmentation-invariant.** It was floored per payment, and
|
|
80
|
+
`floor()` drops the fraction every time, so paying one installment in pieces granted
|
|
81
|
+
fewer days than paying it at once — `$52` as `52 x $1` granted **zero** days. Days are
|
|
82
|
+
now the delta between cumulative entitlement before and after, which telescopes, so any
|
|
83
|
+
split of the same money sums to the same total. Deposit and overpayment behaviour are
|
|
84
|
+
unchanged.
|
|
85
|
+
|
|
86
|
+
This mattered because removing the minimum-payment rule is what made fragmented
|
|
87
|
+
payments reachable, and `daysActivated` feeds `paidThroughDate` — the engine's lock
|
|
88
|
+
decision. Without this, a customer paying in full by instalments would be locked for
|
|
89
|
+
not paying.
|
|
90
|
+
|
|
91
|
+
The delta is computed in **integer cents**, not floats. An earlier version of this
|
|
92
|
+
fix compared `round2(newTotalPaid)` against a raw unrounded sum of installment
|
|
93
|
+
amounts, so a day could appear or vanish purely from binary representation and land
|
|
94
|
+
on `paidThroughDate`. Verified 2026-07-31: the float path diverges at 272 of 1260
|
|
95
|
+
accumulation points; the shipped version holds fragmentation invariance across 420
|
|
96
|
+
scenarios (realistic recurring amounts — `3.35`, `22.86`, `11.43` — split 2/3/5/7/13/52/100
|
|
97
|
+
ways) with **zero** violations.
|
|
98
|
+
|
|
99
|
+
- `seedContract` no longer aborts mid-replay when a contract's history contains a
|
|
100
|
+
below-minimum payment. This is what made 81 legacy contracts unreplayable.
|
|
101
|
+
|
|
102
|
+
`min_payment_cents` remains on `engine_contracts` and is still written by
|
|
103
|
+
`originate`/`seedContract` — it is a real quoting figure pushed to Upya. It simply no
|
|
104
|
+
longer gates allocation.
|
|
105
|
+
|
|
106
|
+
## 0.3.0 — 2026-06-03
|
|
107
|
+
|
|
108
|
+
Lock-state decision module (L1–L6). Replaces Upya `nextStatusUpdate` as the
|
|
109
|
+
authoritative answer to *"should this device be locked right now?"*. See
|
|
110
|
+
`docs/roadmap.md` and `context/specs/2026-05-22-feat-lock-state-decision.md`.
|
|
111
|
+
|
|
112
|
+
### Added
|
|
113
|
+
|
|
114
|
+
- `decideLockState(input)` — pure, deterministic lock-state decision from
|
|
115
|
+
contract + payments + `asOf`. No I/O, no `Date.now()`.
|
|
116
|
+
- `getLockState(db, { contractNumber, asOf? })` — D1-backed read; derives state
|
|
117
|
+
from the cached `paid_through_date`.
|
|
118
|
+
- Types: `LockState`, `ClosureReason`, `LockStateContract`, `LockStatePayment`,
|
|
119
|
+
`LockStateInput`, `LockStateOutput`, `GetLockStateInput`.
|
|
120
|
+
- Schema: `engine_lock_state_events` table + `EngineLockStateEvent` /
|
|
121
|
+
`NewEngineLockStateEvent` types; new `engine_contracts` columns
|
|
122
|
+
(`grace_days`, `timezone`, `closure`, `closure_at`, `closure_reason`,
|
|
123
|
+
`last_lock_state`, `last_lock_state_at`, `paid_through_date`).
|
|
124
|
+
|
|
125
|
+
### Changed
|
|
126
|
+
|
|
127
|
+
- **`recordPayment` / `recordEvent` now also maintain lock-state.** On each
|
|
128
|
+
payment they update the contract's `paid_through_date` + `last_lock_state`
|
|
129
|
+
cache and, on a state transition, append a row to `engine_lock_state_events`
|
|
130
|
+
— all inside the existing atomic `db.batch`. **This is not behaviorally
|
|
131
|
+
transparent**: a consumer that upgrades to 0.3.0 and calls `recordPayment`
|
|
132
|
+
starts writing these columns.
|
|
133
|
+
|
|
134
|
+
### Migration required before upgrade
|
|
135
|
+
|
|
136
|
+
A consumer's `ENGINE_DB` must have migrations `drizzle/engine/0001`–`0003`
|
|
137
|
+
applied **before** it bumps to 0.3.0 and calls `recordPayment`, or the write
|
|
138
|
+
will fail on the missing columns/table. Migrations are additive (new nullable
|
|
139
|
+
columns + new empty table) and safe to apply ahead of the bump.
|
|
140
|
+
|
|
141
|
+
Applied to `flexpay-engine-dev` and `flexpay-engine-prod` D1 as of 2026-06-03.
|
|
142
|
+
|
|
143
|
+
### Notes
|
|
144
|
+
|
|
145
|
+
- New exports (`decideLockState`, `getLockState`) are inert until called —
|
|
146
|
+
upgrading does not invoke them.
|
|
147
|
+
- 181 tests pass; typecheck clean.
|
|
148
|
+
|
|
149
|
+
## 0.2.1 — 2026-05
|
|
150
|
+
|
|
151
|
+
- Canonical event log + `recordEvent` (first-writer model; engine state is a
|
|
152
|
+
projection of `engine_events`).
|
|
153
|
+
- Pricing, schedule generation, payment allocation, delinquency, days-activated.
|
|
154
|
+
- `originateContract`, `recordPayment`, `seedContract`, cents helpers, ULID.
|
package/README.md
CHANGED
|
@@ -1,21 +1,256 @@
|
|
|
1
1
|
# flexpay-engine
|
|
2
2
|
|
|
3
|
-
FlexPay
|
|
3
|
+
Loan-servicing engine for FlexPay. Owns **money truth** — pricing, schedules,
|
|
4
|
+
payment allocation, delinquency, lock-state decisions, banking rails. Consumed
|
|
5
|
+
by the rest of the FlexPay stack as an npm package today and as a Cloudflare
|
|
6
|
+
Worker for banking integrations.
|
|
4
7
|
|
|
5
|
-
|
|
8
|
+
> **Mental model**: Stripe-of-loans. This is the engine other services call.
|
|
9
|
+
> Workflow, UI, identity, and device integrations live elsewhere.
|
|
10
|
+
|
|
11
|
+
> **New here?** Read [`docs/roadmap.md`](docs/roadmap.md) — the Upya cutover
|
|
12
|
+
> journey, where we are, and how the pieces connect.
|
|
13
|
+
|
|
14
|
+
---
|
|
15
|
+
|
|
16
|
+
## ⛔ Upgrading to 0.3.x — apply migration `0004` FIRST
|
|
17
|
+
|
|
18
|
+
**Do not bump a consumer to `0.3.x` before applying
|
|
19
|
+
[`drizzle/engine/0004_next_state_change_at.sql`](drizzle/engine/0004_next_state_change_at.sql)
|
|
20
|
+
to that consumer's `ENGINE_DB`.**
|
|
21
|
+
|
|
22
|
+
From `0.3.0`, `recordEvent` / `recordPayment` write
|
|
23
|
+
`engine_contracts.next_state_change_at`. Without the column the contract update
|
|
24
|
+
throws — and because engine writes are typically wired non-blocking, **the
|
|
25
|
+
failure is silent: a ledger gap, not an error the caller sees.**
|
|
26
|
+
|
|
27
|
+
The migration is additive and safe to apply ahead of the bump: `ADD COLUMN`
|
|
28
|
+
(nullable) plus a backfill gated on `paid_through_date IS NOT NULL`. It is
|
|
29
|
+
idempotent in effect (recomputes the same value) — but `ADD COLUMN` itself is
|
|
30
|
+
not, so guard re-runs.
|
|
31
|
+
|
|
32
|
+
Earlier lock-state columns need `0001`–`0003` for the same reason.
|
|
33
|
+
|
|
34
|
+
**`0.3.0` is deprecated — use `0.3.1` or later.** It was published from an
|
|
35
|
+
unmerged branch and carries a float-drift bug in `daysActivated` that can add or
|
|
36
|
+
drop a day of entitlement, which feeds the lock decision.
|
|
37
|
+
|
|
38
|
+
### `paid_through_date` is provisional after upgrading
|
|
39
|
+
|
|
40
|
+
`recordEvent` maintains `paid_through_date`, but on a database where it is still
|
|
41
|
+
NULL the lock-state bootstrap derives it from the *payment date* rather than the
|
|
42
|
+
contract's real entitlement. Use `snapshotSeedContract` to seed it from an
|
|
43
|
+
authoritative source **before** anything reads `paid_through_date` or
|
|
44
|
+
`next_state_change_at`.
|
|
45
|
+
|
|
46
|
+
---
|
|
47
|
+
|
|
48
|
+
## What this owns vs what it doesn't
|
|
49
|
+
|
|
50
|
+
The test for whether something belongs here:
|
|
51
|
+
**does it affect what a customer owes right now?**
|
|
52
|
+
|
|
53
|
+
| Belongs in engine | Does NOT belong in engine |
|
|
54
|
+
|---|---|
|
|
55
|
+
| Ledger (contracts, payments, schedules) | Customer profiles / KYC |
|
|
56
|
+
| Pricing, allocation, delinquency | Phone catalog / inventory |
|
|
57
|
+
| Lock-state DECISION (locked/unlocked) | Lock-state EXECUTION (Trustonic, Nuovopay APIs) |
|
|
58
|
+
| Payment rails (BNC, ChinChin) | Notification transport (SMS, email, push) |
|
|
59
|
+
| Contract state events | Sales flow UI / portal forms |
|
|
60
|
+
| Reconciliation against banking partners | Agent/store admin tools |
|
|
61
|
+
| Append-only audit trail | Currency conversion / FX hedging |
|
|
62
|
+
|
|
63
|
+
---
|
|
64
|
+
|
|
65
|
+
## System map
|
|
66
|
+
|
|
67
|
+
```
|
|
68
|
+
┌──────────────────────────────────────┐
|
|
69
|
+
│ flexpay-engine (this repo) │
|
|
70
|
+
│ ──── money brain ──── │
|
|
71
|
+
│ │
|
|
72
|
+
│ • Ledger + schedules │
|
|
73
|
+
│ • Pricing, allocation, delinquency │
|
|
74
|
+
│ • Lock-state DECISION │
|
|
75
|
+
│ • Payment rails (BNC; ChinChin TBD) │
|
|
76
|
+
│ • R2 audit mirror │
|
|
77
|
+
│ • Emits state events │
|
|
78
|
+
└────────────┬─────────────────────────┘
|
|
79
|
+
│ npm pkg + Service Binding RPC
|
|
80
|
+
┌──────────────────┼──────────────────┐
|
|
81
|
+
▼ ▼ ▼
|
|
82
|
+
┌────────────────────┐ ┌──────────────┐ ┌─────────────────┐
|
|
83
|
+
│ flexpay-client- │ │ Supabase │ │ flexpay-backend │
|
|
84
|
+
│ worker (limbs) │ │ (catalog + │ │ (mobile) │
|
|
85
|
+
│ │ │ identity) │ │ │
|
|
86
|
+
│ • Lock execution │ │ │ │ • KYC (Didit) │
|
|
87
|
+
│ (Trustonic, │ │ • Profiles │ │ • Customer │
|
|
88
|
+
│ Nuovopay) │ │ • SKUs │ │ self-service │
|
|
89
|
+
│ • Sales flow │ │ • Deals │ │ • Mobile UX │
|
|
90
|
+
│ • Customer CRUD │ │ • Inventory │ │ │
|
|
91
|
+
│ • Signature │ │ • Stores │ │ │
|
|
92
|
+
│ • Agents / stores │ │ • Agents │ │ │
|
|
93
|
+
└────────────────────┘ └──────────────┘ └─────────────────┘
|
|
94
|
+
▲ ▲
|
|
95
|
+
│ portal │ mobile app
|
|
96
|
+
│ │
|
|
97
|
+
└──── flexpay-portal (Next.js) ──────┘
|
|
98
|
+
flexpay-app (React Native)
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
---
|
|
102
|
+
|
|
103
|
+
## Capability inventory
|
|
104
|
+
|
|
105
|
+
State as of 2026-05-22. Tracks what's owned, what's still on Upya, and what's
|
|
106
|
+
in flight.
|
|
107
|
+
|
|
108
|
+
### Payments — money in
|
|
109
|
+
|
|
110
|
+
| Capability | Today | Target | Status |
|
|
111
|
+
|---|---|---|---|
|
|
112
|
+
| Pago Movil C2P (customer push w/ token) | ChinChin | engine via BNC SendC2P | ✅ sandbox-verified, ⏳ prod cert |
|
|
113
|
+
| Pago Movil B2P (customer-initiated push) | ChinChin | engine via BNC webhook | ✅ webhook receiver shipped |
|
|
114
|
+
| Débito Inmediato (recurring auto-debit) | not used | engine via BNC SIMF | ✅ sandbox-verified |
|
|
115
|
+
| Cash at store | Upya | engine, recorded by portal | ❌ |
|
|
116
|
+
| FX (USD ↔ VES) | ChinChin | TBD — not engine | ❌ scope decision pending |
|
|
117
|
+
|
|
118
|
+
### Payments — money out
|
|
119
|
+
|
|
120
|
+
| Capability | Today | Target | Status |
|
|
121
|
+
|---|---|---|---|
|
|
122
|
+
| Refunds | manual | engine via BNC SendP2P | ⏳ rail done, refund event TBD |
|
|
123
|
+
| Store / agent commissions | manual | engine | ❌ |
|
|
124
|
+
|
|
125
|
+
### Contract lifecycle
|
|
126
|
+
|
|
127
|
+
| Capability | Today | Target | Status |
|
|
128
|
+
|---|---|---|---|
|
|
129
|
+
| Profile creation | Upya + Supabase | client-worker + Supabase | ❌ |
|
|
130
|
+
| KYC (Didit) | backend | unchanged | ✅ |
|
|
131
|
+
| Deal catalog | Upya + Supabase | Supabase only | ❌ — Upya sync to remove |
|
|
132
|
+
| Deal application (deal → terms) | Upya `editTerms` | engine `originate(dealOptionId)` | ⏳ resolver TBD |
|
|
133
|
+
| Unit assignment | Upya | client-worker + engine FK | ❌ |
|
|
134
|
+
| Pricing calc | engine partial | engine | ✅ |
|
|
135
|
+
| Schedule generation | engine | engine | ✅ |
|
|
136
|
+
| Signature capture | client-worker + Supabase | unchanged | ✅ |
|
|
137
|
+
| Downpayment | Upya hack | engine `recordPayment(kind=downpayment)` | ⏳ |
|
|
138
|
+
| Activation | Upya | engine state derives from data | ❌ |
|
|
139
|
+
| Status webhooks (lock/unlock) | Upya | engine emits events | ⏳ next: lock-state module |
|
|
140
|
+
| Amendment / re-pricing | Upya `editTerms` | engine `amend()` | ❌ |
|
|
141
|
+
| Closure (paid_off / repossessed / written_off) | manual + Upya | engine emits | ❌ |
|
|
142
|
+
|
|
143
|
+
### Lock state
|
|
144
|
+
|
|
145
|
+
| Capability | Today | Target | Status |
|
|
146
|
+
|---|---|---|---|
|
|
147
|
+
| Decision (should device be locked?) | Upya `nextStatusUpdate` | **engine** | ⏳ next — `context/specs/2026-05-22-feat-lock-state-decision.md` |
|
|
148
|
+
| Execution (Trustonic, Nuovopay) | client-worker | client-worker | ✅ |
|
|
149
|
+
| Registration (first-time provisioning) | client-worker | client-worker | ✅ |
|
|
150
|
+
| Reconciliation (device state vs intent) | implicit / Upya | client-worker cron reading engine | ❌ |
|
|
151
|
+
|
|
152
|
+
### Identity / org
|
|
153
|
+
|
|
154
|
+
| Capability | Today | Target | Status |
|
|
155
|
+
|---|---|---|---|
|
|
156
|
+
| Customer profile CRUD | Supabase via Upya proxy | Supabase via client-worker | ❌ |
|
|
157
|
+
| Agent directory | Upya | Supabase | ❌ |
|
|
158
|
+
| Store directory | Supabase | unchanged | ✅ |
|
|
159
|
+
| Auth (portal + app) | betterauth / supabase-auth | unchanged | ✅ |
|
|
160
|
+
|
|
161
|
+
### Inventory
|
|
162
|
+
|
|
163
|
+
| Capability | Today | Target | Status |
|
|
164
|
+
|---|---|---|---|
|
|
165
|
+
| Phone SKU catalog | Supabase phone_skus | unchanged | ✅ (decouple Upya template dep) |
|
|
166
|
+
| Store inventory | Supabase store_inventory | unchanged | ✅ |
|
|
167
|
+
| Unit lifecycle state machine | Upya | client-worker + Supabase | ❌ |
|
|
168
|
+
|
|
169
|
+
### Notifications
|
|
170
|
+
|
|
171
|
+
| Capability | Today | Target | Status |
|
|
172
|
+
|---|---|---|---|
|
|
173
|
+
| Payment received | partial | engine emits → notif worker | ⏳ |
|
|
174
|
+
| Payment due reminder | scattered | engine emits | ❌ |
|
|
175
|
+
| Lock-imminent warning | Upya | engine emits | ⏳ falls out of lock-state spec |
|
|
176
|
+
| KYC reminder | backend | unchanged | ✅ |
|
|
177
|
+
| Transport (SMS, email, push) | various | dedicated worker | (external) |
|
|
178
|
+
|
|
179
|
+
### Reporting & reconciliation
|
|
180
|
+
|
|
181
|
+
| Capability | Today | Target | Status |
|
|
182
|
+
|---|---|---|---|
|
|
183
|
+
| Internal finance reporting | Upya CSV | engine read replica + report-scribe | ❌ |
|
|
184
|
+
| Customer receipts | partial | engine event | ❌ |
|
|
185
|
+
| SUDEBAN regulatory | unknown | engine + accounting overlay | ❌ — confirm requirements |
|
|
186
|
+
| 3-way reconciliation (engine ↔ BNC ↔ counterparty) | none | engine | ⏳ pre-prod gate |
|
|
187
|
+
| Daily close (tie-out) | manual | engine | ❌ |
|
|
188
|
+
|
|
189
|
+
**Legend**: ✅ shipped · ⏳ in flight · ❌ not started
|
|
190
|
+
|
|
191
|
+
---
|
|
192
|
+
|
|
193
|
+
## What's in this repo today
|
|
194
|
+
|
|
195
|
+
```
|
|
196
|
+
src/
|
|
197
|
+
calc/ pricing, schedule, allocation, delinquency, daysActivated
|
|
198
|
+
operations/ originate, recordPayment, recordEvent, seedContract
|
|
199
|
+
schema/ drizzle schema — npm-exported for consumers
|
|
200
|
+
worker/
|
|
201
|
+
banking/
|
|
202
|
+
bnc/ BNC ESolutions API client (P2P, C2P, Débito, auth, audit)
|
|
203
|
+
event/ webhook → queue → idempotent processor + DLQ
|
|
204
|
+
schema/ bank_* tables (separate from engine_* tables)
|
|
205
|
+
services/ bake-check, archive-sweep, reconcile
|
|
206
|
+
lib/ archive (R2), observability, redaction, ulid, encoding
|
|
207
|
+
routes/ admin, health, BNC webhook
|
|
208
|
+
scheduled.ts cron handlers (key rotation, bake, archive sweep)
|
|
209
|
+
index.ts Hono app + queue consumer + scheduled dispatcher
|
|
210
|
+
drizzle/
|
|
211
|
+
banking/ bank_* migrations
|
|
212
|
+
docs/
|
|
213
|
+
bnc/ architecture, security, state-of-play, green-light criteria
|
|
214
|
+
runbooks/ bnc-banking ops procedures, R2 restore drill
|
|
215
|
+
context/
|
|
216
|
+
specs/ active and shipped specs
|
|
217
|
+
tasks/ per-task progress notes
|
|
218
|
+
```
|
|
219
|
+
|
|
220
|
+
---
|
|
221
|
+
|
|
222
|
+
## What's next
|
|
223
|
+
|
|
224
|
+
Active specs:
|
|
225
|
+
|
|
226
|
+
- **`context/specs/2026-05-22-feat-lock-state-decision.md`** — engine becomes the source of truth for lock state. Highest-priority Upya replacement.
|
|
227
|
+
- **`docs/bnc/green-light-criteria.md`** — BNC production certification gates (14-day bake, 3-way reconciliation, prod creds).
|
|
228
|
+
- **`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.
|
|
229
|
+
|
|
230
|
+
The Upya cutover, sequenced:
|
|
231
|
+
|
|
232
|
+
1. Lock-state decision in engine (next)
|
|
233
|
+
2. Move ChinChin into engine alongside BNC (CHUNK A3)
|
|
234
|
+
3. Strip Upya state polling from client-worker
|
|
235
|
+
4. Strip Upya CRM calls from sales flow
|
|
236
|
+
5. Decommission Upya integration
|
|
237
|
+
|
|
238
|
+
Realistic runway: 6–10 weeks of focused work post-lock-state-decision.
|
|
239
|
+
|
|
240
|
+
---
|
|
241
|
+
|
|
242
|
+
## Installation (npm consumers)
|
|
6
243
|
|
|
7
244
|
```bash
|
|
8
245
|
bun add flexpay-engine
|
|
9
246
|
```
|
|
10
247
|
|
|
11
|
-
## Usage
|
|
12
|
-
|
|
13
248
|
```typescript
|
|
14
249
|
import { recordPayment, toCents, seedContract } from 'flexpay-engine';
|
|
15
250
|
import { engineContracts, enginePayments } from 'flexpay-engine/schema';
|
|
16
251
|
```
|
|
17
252
|
|
|
18
|
-
|
|
253
|
+
### Publishing a new version
|
|
19
254
|
|
|
20
255
|
1. Bump version in `package.json`
|
|
21
256
|
2. Commit: `git commit -am "chore: bump to vX.Y.Z"`
|
|
@@ -24,29 +259,90 @@ import { engineContracts, enginePayments } from 'flexpay-engine/schema';
|
|
|
24
259
|
```bash
|
|
25
260
|
# In flexpay-client-worker
|
|
26
261
|
bun add flexpay-engine@X.Y.Z
|
|
27
|
-
|
|
28
262
|
# In flexpay-backend
|
|
29
263
|
bun add flexpay-engine@X.Y.Z
|
|
30
264
|
```
|
|
31
265
|
5. Commit lockfile changes in each consumer repo
|
|
32
266
|
|
|
33
|
-
|
|
267
|
+
### Versioning
|
|
34
268
|
|
|
35
269
|
- **Patch** (0.1.x): Bug fixes, no API changes
|
|
36
270
|
- **Minor** (0.x.0): New features, backwards compatible
|
|
37
271
|
- **Major** (x.0.0): Breaking changes
|
|
38
272
|
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
- `flexpay-client-worker` — portal payments (PORTAL source)
|
|
42
|
-
- `flexpay-backend` — ChinChin payments (CHINCHIN source)
|
|
43
|
-
|
|
44
|
-
Both write to shared D1 database `ENGINE_DB`.
|
|
273
|
+
---
|
|
45
274
|
|
|
46
275
|
## Development
|
|
47
276
|
|
|
48
277
|
```bash
|
|
49
278
|
bun install
|
|
50
|
-
bun test
|
|
279
|
+
bun test # 135+ tests covering calc, banking, lib
|
|
51
280
|
bun run typecheck
|
|
52
281
|
```
|
|
282
|
+
|
|
283
|
+
### Worker dev
|
|
284
|
+
|
|
285
|
+
```bash
|
|
286
|
+
# Local
|
|
287
|
+
bun run dev
|
|
288
|
+
|
|
289
|
+
# Dry-run deploy
|
|
290
|
+
bunx wrangler deploy --dry-run
|
|
291
|
+
|
|
292
|
+
# Deploy to dev
|
|
293
|
+
bunx wrangler deploy --env=""
|
|
294
|
+
|
|
295
|
+
# Tail logs
|
|
296
|
+
bunx wrangler tail flexpay-engine-dev
|
|
297
|
+
```
|
|
298
|
+
|
|
299
|
+
### Migrations
|
|
300
|
+
|
|
301
|
+
```bash
|
|
302
|
+
# Banking tables (worker)
|
|
303
|
+
bunx wrangler d1 execute flexpay-engine-dev --remote --file=drizzle/banking/XXXX_*.sql
|
|
304
|
+
|
|
305
|
+
# Engine tables (npm package — applied by consumers)
|
|
306
|
+
# Live in src/schema/ as drizzle definitions; migrations TBD per consumer.
|
|
307
|
+
```
|
|
308
|
+
|
|
309
|
+
---
|
|
310
|
+
|
|
311
|
+
## Live URLs
|
|
312
|
+
|
|
313
|
+
| Endpoint | Auth | Purpose |
|
|
314
|
+
|---|---|---|
|
|
315
|
+
| `https://flexpay-engine-dev.achilleas-dbe.workers.dev` | n/a | Dev worker |
|
|
316
|
+
| `GET /health/bake-summary` | none | Bake-check status (14-day clock) |
|
|
317
|
+
| `GET /admin/bnc/status` | Bearer | Detailed health |
|
|
318
|
+
| `GET /admin/bnc/ping` | Bearer | BNC sandbox connectivity |
|
|
319
|
+
| `POST /admin/bnc/bake/run` | Bearer | Force a bake-check now |
|
|
320
|
+
| `POST /webhooks/bnc` | BNC creds | Inbound BNC webhook |
|
|
321
|
+
|
|
322
|
+
R2 buckets: `bank-audit-archive-{dev,prod}` (append-only audit mirror).
|
|
323
|
+
|
|
324
|
+
---
|
|
325
|
+
|
|
326
|
+
## Architecture decisions worth knowing
|
|
327
|
+
|
|
328
|
+
- **Single writer per table.** `src/` writes only `engine_*` tables. `worker/banking/` writes only `bank_*` tables. CI guard enforces (`scripts/check-engine-tables.sh`).
|
|
329
|
+
- **Append-only ledger.** No UPDATE/DELETE on `engine_payments` except via the audited `forceReseedContract` path. Same rule for `engine_lock_state_events` once shipped.
|
|
330
|
+
- **Cents only.** No floats touching D1.
|
|
331
|
+
- **Idempotency via DB constraints**, not error-string matching. UNIQUE indexes + `onConflictDoNothing`.
|
|
332
|
+
- **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.
|
|
333
|
+
- **Best-effort archive, never blocks.** R2 write failures log + retry via sweep; never break the payment path.
|
|
334
|
+
- **Per-message queue ack/retry.** Never throw from the queue handler — that retries the whole batch.
|
|
335
|
+
|
|
336
|
+
---
|
|
337
|
+
|
|
338
|
+
## Key docs
|
|
339
|
+
|
|
340
|
+
| Doc | When to read |
|
|
341
|
+
|---|---|
|
|
342
|
+
| `docs/bnc/README.md` | Anything BNC-related |
|
|
343
|
+
| `docs/bnc/state-of-play.md` | What works / doesn't / unknown |
|
|
344
|
+
| `docs/bnc/security.md` | Threat model, crypto, secrets |
|
|
345
|
+
| `docs/bnc/green-light-criteria.md` | When can we cut over to prod |
|
|
346
|
+
| `docs/runbooks/bnc-banking.md` | Ops procedures, R2 drill |
|
|
347
|
+
| `context/specs/` | Active and shipped specs |
|
|
348
|
+
| `CLAUDE.md` (parent dir) | Cross-repo conventions |
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
-- Banking foundation tables. New tables only. NEVER touches engine_* tables.
|
|
2
|
+
-- Enforced by scripts/check-engine-tables.sh in CI.
|
|
3
|
+
|
|
4
|
+
CREATE TABLE `bank_working_keys` (
|
|
5
|
+
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
|
6
|
+
`bank_code` text NOT NULL,
|
|
7
|
+
`effective_date` text NOT NULL,
|
|
8
|
+
`key_blob_encrypted` text NOT NULL,
|
|
9
|
+
`key_fingerprint` text NOT NULL,
|
|
10
|
+
`source` text NOT NULL,
|
|
11
|
+
`status` text DEFAULT 'active' NOT NULL,
|
|
12
|
+
`rotated_at` text NOT NULL,
|
|
13
|
+
`expires_at` text,
|
|
14
|
+
`event_version` integer DEFAULT 1 NOT NULL
|
|
15
|
+
);
|
|
16
|
+
CREATE UNIQUE INDEX `idx_bank_working_keys_bank_date` ON `bank_working_keys` (`bank_code`,`effective_date`);
|
|
17
|
+
CREATE INDEX `idx_bank_working_keys_bank_status` ON `bank_working_keys` (`bank_code`,`status`);
|
|
18
|
+
|
|
19
|
+
CREATE TABLE `bank_processed_notifications` (
|
|
20
|
+
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
|
21
|
+
`event_id` text NOT NULL,
|
|
22
|
+
`bank_code` text NOT NULL,
|
|
23
|
+
`payment_type` text NOT NULL,
|
|
24
|
+
`origin_ref` text NOT NULL,
|
|
25
|
+
`destiny_ref` text NOT NULL,
|
|
26
|
+
`origin_bank_code` text,
|
|
27
|
+
`amount_cents` integer NOT NULL,
|
|
28
|
+
`currency_code` text NOT NULL,
|
|
29
|
+
`occurred_at` text NOT NULL,
|
|
30
|
+
`received_at` text NOT NULL,
|
|
31
|
+
`source` text NOT NULL,
|
|
32
|
+
`raw_payload` text NOT NULL,
|
|
33
|
+
`contract_match_status` text DEFAULT 'unmatched' NOT NULL,
|
|
34
|
+
`matched_contract_number` text,
|
|
35
|
+
`event_version` integer DEFAULT 1 NOT NULL
|
|
36
|
+
);
|
|
37
|
+
CREATE UNIQUE INDEX `idx_bank_notifications_idempotency`
|
|
38
|
+
ON `bank_processed_notifications` (`bank_code`,`origin_ref`,`destiny_ref`,`payment_type`);
|
|
39
|
+
CREATE INDEX `idx_bank_notifications_received` ON `bank_processed_notifications` (`received_at`);
|
|
40
|
+
CREATE INDEX `idx_bank_notifications_payment_type` ON `bank_processed_notifications` (`bank_code`,`payment_type`);
|
|
41
|
+
CREATE UNIQUE INDEX `idx_bank_notifications_event_id` ON `bank_processed_notifications` (`event_id`);
|
|
42
|
+
|
|
43
|
+
CREATE TABLE `bank_payment_attempts` (
|
|
44
|
+
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
|
45
|
+
`attempt_id` text NOT NULL UNIQUE,
|
|
46
|
+
`bank_code` text NOT NULL,
|
|
47
|
+
`operation_type` text NOT NULL,
|
|
48
|
+
`operation_ref` text NOT NULL,
|
|
49
|
+
`amount_cents` integer NOT NULL,
|
|
50
|
+
`beneficiary_id` text,
|
|
51
|
+
`beneficiary_phone` text,
|
|
52
|
+
`beneficiary_bank_code` text,
|
|
53
|
+
`status` text NOT NULL,
|
|
54
|
+
`bnc_reference` text,
|
|
55
|
+
`bnc_authorization_code` text,
|
|
56
|
+
`bnc_id_transaction` text,
|
|
57
|
+
`sw_already_sent` integer DEFAULT 0 NOT NULL,
|
|
58
|
+
`error_code` text,
|
|
59
|
+
`error_message` text,
|
|
60
|
+
`attempted_at` text NOT NULL,
|
|
61
|
+
`completed_at` text,
|
|
62
|
+
`retry_count` integer DEFAULT 0 NOT NULL,
|
|
63
|
+
`request_payload` text NOT NULL,
|
|
64
|
+
`response_payload` text,
|
|
65
|
+
`event_version` integer DEFAULT 1 NOT NULL
|
|
66
|
+
);
|
|
67
|
+
CREATE UNIQUE INDEX `idx_bank_attempts_operation_ref` ON `bank_payment_attempts` (`bank_code`,`operation_ref`);
|
|
68
|
+
CREATE INDEX `idx_bank_attempts_status` ON `bank_payment_attempts` (`status`);
|
|
69
|
+
CREATE INDEX `idx_bank_attempts_attempted_at` ON `bank_payment_attempts` (`attempted_at`);
|
|
70
|
+
|
|
71
|
+
CREATE TABLE `bank_api_audit` (
|
|
72
|
+
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
|
73
|
+
`event_id` text NOT NULL,
|
|
74
|
+
`bank_code` text NOT NULL,
|
|
75
|
+
`direction` text NOT NULL,
|
|
76
|
+
`operation` text NOT NULL,
|
|
77
|
+
`http_status` integer,
|
|
78
|
+
`request_summary` text NOT NULL,
|
|
79
|
+
`response_summary` text,
|
|
80
|
+
`duration_ms` integer,
|
|
81
|
+
`occurred_at` text NOT NULL,
|
|
82
|
+
`event_version` integer DEFAULT 1 NOT NULL
|
|
83
|
+
);
|
|
84
|
+
CREATE INDEX `idx_bank_audit_operation` ON `bank_api_audit` (`bank_code`,`operation`,`occurred_at`);
|
|
85
|
+
CREATE INDEX `idx_bank_audit_event_id` ON `bank_api_audit` (`event_id`);
|
|
86
|
+
|
|
87
|
+
CREATE TABLE `bank_webhook_dlq` (
|
|
88
|
+
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
|
89
|
+
`bank_code` text NOT NULL,
|
|
90
|
+
`received_at` text NOT NULL,
|
|
91
|
+
`failure_reason` text NOT NULL,
|
|
92
|
+
`retry_count` integer DEFAULT 0 NOT NULL,
|
|
93
|
+
`raw_payload` text NOT NULL,
|
|
94
|
+
`resolved_at` text,
|
|
95
|
+
`resolved_by` text,
|
|
96
|
+
`event_version` integer DEFAULT 1 NOT NULL
|
|
97
|
+
);
|
|
98
|
+
CREATE INDEX `idx_bank_dlq_bank_received` ON `bank_webhook_dlq` (`bank_code`,`received_at`);
|
|
99
|
+
CREATE INDEX `idx_bank_dlq_resolved` ON `bank_webhook_dlq` (`resolved_at`);
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
-- R2 mirror archived_at column: TIMESTAMP (ISO-8601 string) when the row was
|
|
2
|
+
-- confirmed written to R2. NULL = not yet archived, picked up by hourly sweep.
|
|
3
|
+
ALTER TABLE bank_api_audit ADD COLUMN archived_at TEXT;
|
|
4
|
+
ALTER TABLE bank_processed_notifications ADD COLUMN archived_at TEXT;
|
|
5
|
+
ALTER TABLE bank_payment_attempts ADD COLUMN archived_at TEXT;
|
|
6
|
+
ALTER TABLE bank_webhook_dlq ADD COLUMN archived_at TEXT;
|
|
7
|
+
|
|
8
|
+
CREATE INDEX idx_bank_audit_archived_at ON bank_api_audit (archived_at);
|
|
9
|
+
CREATE INDEX idx_bank_notifications_archived_at ON bank_processed_notifications (archived_at);
|
|
10
|
+
CREATE INDEX idx_bank_attempts_archived_at ON bank_payment_attempts (archived_at);
|
|
11
|
+
CREATE INDEX idx_bank_dlq_archived_at ON bank_webhook_dlq (archived_at);
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
-- Lock-state decision module — schema additions.
|
|
2
|
+
-- See context/specs/2026-05-22-feat-lock-state-decision.md
|
|
3
|
+
--
|
|
4
|
+
-- Consumers (flexpay-client-worker, flexpay-backend) must apply this
|
|
5
|
+
-- migration before upgrading to a flexpay-engine version that uses
|
|
6
|
+
-- decideLockState end-to-end. The npm package's decideLockState function
|
|
7
|
+
-- is pure and does not require these columns to exist for L1 usage.
|
|
8
|
+
|
|
9
|
+
-- Per-contract lock-state config + cache + closure
|
|
10
|
+
ALTER TABLE engine_contracts ADD COLUMN grace_days INTEGER NOT NULL DEFAULT 0;
|
|
11
|
+
ALTER TABLE engine_contracts ADD COLUMN timezone TEXT NOT NULL DEFAULT 'America/Caracas';
|
|
12
|
+
ALTER TABLE engine_contracts ADD COLUMN closure TEXT;
|
|
13
|
+
ALTER TABLE engine_contracts ADD COLUMN closure_at TEXT;
|
|
14
|
+
ALTER TABLE engine_contracts ADD COLUMN closure_reason TEXT;
|
|
15
|
+
ALTER TABLE engine_contracts ADD COLUMN last_lock_state TEXT;
|
|
16
|
+
ALTER TABLE engine_contracts ADD COLUMN last_lock_state_at TEXT;
|
|
17
|
+
|
|
18
|
+
-- Append-only transition log. R2-mirrored by the worker's archive sweep.
|
|
19
|
+
CREATE TABLE engine_lock_state_events (
|
|
20
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
21
|
+
event_id TEXT NOT NULL UNIQUE,
|
|
22
|
+
contract_number TEXT NOT NULL,
|
|
23
|
+
from_state TEXT,
|
|
24
|
+
to_state TEXT NOT NULL,
|
|
25
|
+
reason TEXT NOT NULL,
|
|
26
|
+
computed_at TEXT NOT NULL,
|
|
27
|
+
paid_through_date TEXT,
|
|
28
|
+
next_state_change_at TEXT,
|
|
29
|
+
trigger TEXT NOT NULL,
|
|
30
|
+
FOREIGN KEY (contract_number) REFERENCES engine_contracts (contract_number)
|
|
31
|
+
);
|
|
32
|
+
|
|
33
|
+
CREATE INDEX idx_lock_events_contract ON engine_lock_state_events (contract_number, computed_at DESC);
|
|
34
|
+
CREATE INDEX idx_lock_events_state ON engine_lock_state_events (to_state, computed_at DESC);
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
-- Cached cumulative paid-through-date on engine_contracts. Updated by
|
|
2
|
+
-- recordPayment via the lock-state formula:
|
|
3
|
+
-- paidThrough(n) = max(paidThrough(n-1), payment_day(n)) + days_activated(n)
|
|
4
|
+
--
|
|
5
|
+
-- Storing this on the contract avoids walking the full payment history on
|
|
6
|
+
-- every state computation. The event log (engine_lock_state_events) remains
|
|
7
|
+
-- the source of truth for transitions; this column is a cache.
|
|
8
|
+
|
|
9
|
+
ALTER TABLE engine_contracts ADD COLUMN paid_through_date TEXT;
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
-- Add archived_at to engine_lock_state_events so the hourly archive sweep can
|
|
2
|
+
-- mirror it to R2, matching the durability of the 4 banking tables.
|
|
3
|
+
-- NULL = not yet archived; the sweep retries until set.
|
|
4
|
+
|
|
5
|
+
ALTER TABLE engine_lock_state_events ADD COLUMN archived_at TEXT;
|
|
6
|
+
|
|
7
|
+
CREATE INDEX idx_lock_events_unarchived
|
|
8
|
+
ON engine_lock_state_events (archived_at, computed_at)
|
|
9
|
+
WHERE archived_at IS NULL;
|