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 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 loan servicing engine — pricing, schedules, allocation, delinquency.
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
- ## Installation
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
- ## Publishing to npm
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
- ## Versioning
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
- ## Consumers
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;