flexpay-engine 0.2.1 → 0.3.0
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/README.md +278 -14
- package/package.json +22 -5
- package/src/calc/allocation.ts +32 -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/README.md
CHANGED
|
@@ -1,21 +1,224 @@
|
|
|
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
|
+
## What this owns vs what it doesn't
|
|
17
|
+
|
|
18
|
+
The test for whether something belongs here:
|
|
19
|
+
**does it affect what a customer owes right now?**
|
|
20
|
+
|
|
21
|
+
| Belongs in engine | Does NOT belong in engine |
|
|
22
|
+
|---|---|
|
|
23
|
+
| Ledger (contracts, payments, schedules) | Customer profiles / KYC |
|
|
24
|
+
| Pricing, allocation, delinquency | Phone catalog / inventory |
|
|
25
|
+
| Lock-state DECISION (locked/unlocked) | Lock-state EXECUTION (Trustonic, Nuovopay APIs) |
|
|
26
|
+
| Payment rails (BNC, ChinChin) | Notification transport (SMS, email, push) |
|
|
27
|
+
| Contract state events | Sales flow UI / portal forms |
|
|
28
|
+
| Reconciliation against banking partners | Agent/store admin tools |
|
|
29
|
+
| Append-only audit trail | Currency conversion / FX hedging |
|
|
30
|
+
|
|
31
|
+
---
|
|
32
|
+
|
|
33
|
+
## System map
|
|
34
|
+
|
|
35
|
+
```
|
|
36
|
+
┌──────────────────────────────────────┐
|
|
37
|
+
│ flexpay-engine (this repo) │
|
|
38
|
+
│ ──── money brain ──── │
|
|
39
|
+
│ │
|
|
40
|
+
│ • Ledger + schedules │
|
|
41
|
+
│ • Pricing, allocation, delinquency │
|
|
42
|
+
│ • Lock-state DECISION │
|
|
43
|
+
│ • Payment rails (BNC; ChinChin TBD) │
|
|
44
|
+
│ • R2 audit mirror │
|
|
45
|
+
│ • Emits state events │
|
|
46
|
+
└────────────┬─────────────────────────┘
|
|
47
|
+
│ npm pkg + Service Binding RPC
|
|
48
|
+
┌──────────────────┼──────────────────┐
|
|
49
|
+
▼ ▼ ▼
|
|
50
|
+
┌────────────────────┐ ┌──────────────┐ ┌─────────────────┐
|
|
51
|
+
│ flexpay-client- │ │ Supabase │ │ flexpay-backend │
|
|
52
|
+
│ worker (limbs) │ │ (catalog + │ │ (mobile) │
|
|
53
|
+
│ │ │ identity) │ │ │
|
|
54
|
+
│ • Lock execution │ │ │ │ • KYC (Didit) │
|
|
55
|
+
│ (Trustonic, │ │ • Profiles │ │ • Customer │
|
|
56
|
+
│ Nuovopay) │ │ • SKUs │ │ self-service │
|
|
57
|
+
│ • Sales flow │ │ • Deals │ │ • Mobile UX │
|
|
58
|
+
│ • Customer CRUD │ │ • Inventory │ │ │
|
|
59
|
+
│ • Signature │ │ • Stores │ │ │
|
|
60
|
+
│ • Agents / stores │ │ • Agents │ │ │
|
|
61
|
+
└────────────────────┘ └──────────────┘ └─────────────────┘
|
|
62
|
+
▲ ▲
|
|
63
|
+
│ portal │ mobile app
|
|
64
|
+
│ │
|
|
65
|
+
└──── flexpay-portal (Next.js) ──────┘
|
|
66
|
+
flexpay-app (React Native)
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
---
|
|
70
|
+
|
|
71
|
+
## Capability inventory
|
|
72
|
+
|
|
73
|
+
State as of 2026-05-22. Tracks what's owned, what's still on Upya, and what's
|
|
74
|
+
in flight.
|
|
75
|
+
|
|
76
|
+
### Payments — money in
|
|
77
|
+
|
|
78
|
+
| Capability | Today | Target | Status |
|
|
79
|
+
|---|---|---|---|
|
|
80
|
+
| Pago Movil C2P (customer push w/ token) | ChinChin | engine via BNC SendC2P | ✅ sandbox-verified, ⏳ prod cert |
|
|
81
|
+
| Pago Movil B2P (customer-initiated push) | ChinChin | engine via BNC webhook | ✅ webhook receiver shipped |
|
|
82
|
+
| Débito Inmediato (recurring auto-debit) | not used | engine via BNC SIMF | ✅ sandbox-verified |
|
|
83
|
+
| Cash at store | Upya | engine, recorded by portal | ❌ |
|
|
84
|
+
| FX (USD ↔ VES) | ChinChin | TBD — not engine | ❌ scope decision pending |
|
|
85
|
+
|
|
86
|
+
### Payments — money out
|
|
87
|
+
|
|
88
|
+
| Capability | Today | Target | Status |
|
|
89
|
+
|---|---|---|---|
|
|
90
|
+
| Refunds | manual | engine via BNC SendP2P | ⏳ rail done, refund event TBD |
|
|
91
|
+
| Store / agent commissions | manual | engine | ❌ |
|
|
92
|
+
|
|
93
|
+
### Contract lifecycle
|
|
94
|
+
|
|
95
|
+
| Capability | Today | Target | Status |
|
|
96
|
+
|---|---|---|---|
|
|
97
|
+
| Profile creation | Upya + Supabase | client-worker + Supabase | ❌ |
|
|
98
|
+
| KYC (Didit) | backend | unchanged | ✅ |
|
|
99
|
+
| Deal catalog | Upya + Supabase | Supabase only | ❌ — Upya sync to remove |
|
|
100
|
+
| Deal application (deal → terms) | Upya `editTerms` | engine `originate(dealOptionId)` | ⏳ resolver TBD |
|
|
101
|
+
| Unit assignment | Upya | client-worker + engine FK | ❌ |
|
|
102
|
+
| Pricing calc | engine partial | engine | ✅ |
|
|
103
|
+
| Schedule generation | engine | engine | ✅ |
|
|
104
|
+
| Signature capture | client-worker + Supabase | unchanged | ✅ |
|
|
105
|
+
| Downpayment | Upya hack | engine `recordPayment(kind=downpayment)` | ⏳ |
|
|
106
|
+
| Activation | Upya | engine state derives from data | ❌ |
|
|
107
|
+
| Status webhooks (lock/unlock) | Upya | engine emits events | ⏳ next: lock-state module |
|
|
108
|
+
| Amendment / re-pricing | Upya `editTerms` | engine `amend()` | ❌ |
|
|
109
|
+
| Closure (paid_off / repossessed / written_off) | manual + Upya | engine emits | ❌ |
|
|
110
|
+
|
|
111
|
+
### Lock state
|
|
112
|
+
|
|
113
|
+
| Capability | Today | Target | Status |
|
|
114
|
+
|---|---|---|---|
|
|
115
|
+
| Decision (should device be locked?) | Upya `nextStatusUpdate` | **engine** | ⏳ next — `context/specs/2026-05-22-feat-lock-state-decision.md` |
|
|
116
|
+
| Execution (Trustonic, Nuovopay) | client-worker | client-worker | ✅ |
|
|
117
|
+
| Registration (first-time provisioning) | client-worker | client-worker | ✅ |
|
|
118
|
+
| Reconciliation (device state vs intent) | implicit / Upya | client-worker cron reading engine | ❌ |
|
|
119
|
+
|
|
120
|
+
### Identity / org
|
|
121
|
+
|
|
122
|
+
| Capability | Today | Target | Status |
|
|
123
|
+
|---|---|---|---|
|
|
124
|
+
| Customer profile CRUD | Supabase via Upya proxy | Supabase via client-worker | ❌ |
|
|
125
|
+
| Agent directory | Upya | Supabase | ❌ |
|
|
126
|
+
| Store directory | Supabase | unchanged | ✅ |
|
|
127
|
+
| Auth (portal + app) | betterauth / supabase-auth | unchanged | ✅ |
|
|
128
|
+
|
|
129
|
+
### Inventory
|
|
130
|
+
|
|
131
|
+
| Capability | Today | Target | Status |
|
|
132
|
+
|---|---|---|---|
|
|
133
|
+
| Phone SKU catalog | Supabase phone_skus | unchanged | ✅ (decouple Upya template dep) |
|
|
134
|
+
| Store inventory | Supabase store_inventory | unchanged | ✅ |
|
|
135
|
+
| Unit lifecycle state machine | Upya | client-worker + Supabase | ❌ |
|
|
136
|
+
|
|
137
|
+
### Notifications
|
|
138
|
+
|
|
139
|
+
| Capability | Today | Target | Status |
|
|
140
|
+
|---|---|---|---|
|
|
141
|
+
| Payment received | partial | engine emits → notif worker | ⏳ |
|
|
142
|
+
| Payment due reminder | scattered | engine emits | ❌ |
|
|
143
|
+
| Lock-imminent warning | Upya | engine emits | ⏳ falls out of lock-state spec |
|
|
144
|
+
| KYC reminder | backend | unchanged | ✅ |
|
|
145
|
+
| Transport (SMS, email, push) | various | dedicated worker | (external) |
|
|
146
|
+
|
|
147
|
+
### Reporting & reconciliation
|
|
148
|
+
|
|
149
|
+
| Capability | Today | Target | Status |
|
|
150
|
+
|---|---|---|---|
|
|
151
|
+
| Internal finance reporting | Upya CSV | engine read replica + report-scribe | ❌ |
|
|
152
|
+
| Customer receipts | partial | engine event | ❌ |
|
|
153
|
+
| SUDEBAN regulatory | unknown | engine + accounting overlay | ❌ — confirm requirements |
|
|
154
|
+
| 3-way reconciliation (engine ↔ BNC ↔ counterparty) | none | engine | ⏳ pre-prod gate |
|
|
155
|
+
| Daily close (tie-out) | manual | engine | ❌ |
|
|
156
|
+
|
|
157
|
+
**Legend**: ✅ shipped · ⏳ in flight · ❌ not started
|
|
158
|
+
|
|
159
|
+
---
|
|
160
|
+
|
|
161
|
+
## What's in this repo today
|
|
162
|
+
|
|
163
|
+
```
|
|
164
|
+
src/
|
|
165
|
+
calc/ pricing, schedule, allocation, delinquency, daysActivated
|
|
166
|
+
operations/ originate, recordPayment, recordEvent, seedContract
|
|
167
|
+
schema/ drizzle schema — npm-exported for consumers
|
|
168
|
+
worker/
|
|
169
|
+
banking/
|
|
170
|
+
bnc/ BNC ESolutions API client (P2P, C2P, Débito, auth, audit)
|
|
171
|
+
event/ webhook → queue → idempotent processor + DLQ
|
|
172
|
+
schema/ bank_* tables (separate from engine_* tables)
|
|
173
|
+
services/ bake-check, archive-sweep, reconcile
|
|
174
|
+
lib/ archive (R2), observability, redaction, ulid, encoding
|
|
175
|
+
routes/ admin, health, BNC webhook
|
|
176
|
+
scheduled.ts cron handlers (key rotation, bake, archive sweep)
|
|
177
|
+
index.ts Hono app + queue consumer + scheduled dispatcher
|
|
178
|
+
drizzle/
|
|
179
|
+
banking/ bank_* migrations
|
|
180
|
+
docs/
|
|
181
|
+
bnc/ architecture, security, state-of-play, green-light criteria
|
|
182
|
+
runbooks/ bnc-banking ops procedures, R2 restore drill
|
|
183
|
+
context/
|
|
184
|
+
specs/ active and shipped specs
|
|
185
|
+
tasks/ per-task progress notes
|
|
186
|
+
```
|
|
187
|
+
|
|
188
|
+
---
|
|
189
|
+
|
|
190
|
+
## What's next
|
|
191
|
+
|
|
192
|
+
Active specs:
|
|
193
|
+
|
|
194
|
+
- **`context/specs/2026-05-22-feat-lock-state-decision.md`** — engine becomes the source of truth for lock state. Highest-priority Upya replacement.
|
|
195
|
+
- **`docs/bnc/green-light-criteria.md`** — BNC production certification gates (14-day bake, 3-way reconciliation, prod creds).
|
|
196
|
+
- **`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.
|
|
197
|
+
|
|
198
|
+
The Upya cutover, sequenced:
|
|
199
|
+
|
|
200
|
+
1. Lock-state decision in engine (next)
|
|
201
|
+
2. Move ChinChin into engine alongside BNC (CHUNK A3)
|
|
202
|
+
3. Strip Upya state polling from client-worker
|
|
203
|
+
4. Strip Upya CRM calls from sales flow
|
|
204
|
+
5. Decommission Upya integration
|
|
205
|
+
|
|
206
|
+
Realistic runway: 6–10 weeks of focused work post-lock-state-decision.
|
|
207
|
+
|
|
208
|
+
---
|
|
209
|
+
|
|
210
|
+
## Installation (npm consumers)
|
|
6
211
|
|
|
7
212
|
```bash
|
|
8
213
|
bun add flexpay-engine
|
|
9
214
|
```
|
|
10
215
|
|
|
11
|
-
## Usage
|
|
12
|
-
|
|
13
216
|
```typescript
|
|
14
217
|
import { recordPayment, toCents, seedContract } from 'flexpay-engine';
|
|
15
218
|
import { engineContracts, enginePayments } from 'flexpay-engine/schema';
|
|
16
219
|
```
|
|
17
220
|
|
|
18
|
-
|
|
221
|
+
### Publishing a new version
|
|
19
222
|
|
|
20
223
|
1. Bump version in `package.json`
|
|
21
224
|
2. Commit: `git commit -am "chore: bump to vX.Y.Z"`
|
|
@@ -24,29 +227,90 @@ import { engineContracts, enginePayments } from 'flexpay-engine/schema';
|
|
|
24
227
|
```bash
|
|
25
228
|
# In flexpay-client-worker
|
|
26
229
|
bun add flexpay-engine@X.Y.Z
|
|
27
|
-
|
|
28
230
|
# In flexpay-backend
|
|
29
231
|
bun add flexpay-engine@X.Y.Z
|
|
30
232
|
```
|
|
31
233
|
5. Commit lockfile changes in each consumer repo
|
|
32
234
|
|
|
33
|
-
|
|
235
|
+
### Versioning
|
|
34
236
|
|
|
35
237
|
- **Patch** (0.1.x): Bug fixes, no API changes
|
|
36
238
|
- **Minor** (0.x.0): New features, backwards compatible
|
|
37
239
|
- **Major** (x.0.0): Breaking changes
|
|
38
240
|
|
|
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`.
|
|
241
|
+
---
|
|
45
242
|
|
|
46
243
|
## Development
|
|
47
244
|
|
|
48
245
|
```bash
|
|
49
246
|
bun install
|
|
50
|
-
bun test
|
|
247
|
+
bun test # 135+ tests covering calc, banking, lib
|
|
51
248
|
bun run typecheck
|
|
52
249
|
```
|
|
250
|
+
|
|
251
|
+
### Worker dev
|
|
252
|
+
|
|
253
|
+
```bash
|
|
254
|
+
# Local
|
|
255
|
+
bun run dev
|
|
256
|
+
|
|
257
|
+
# Dry-run deploy
|
|
258
|
+
bunx wrangler deploy --dry-run
|
|
259
|
+
|
|
260
|
+
# Deploy to dev
|
|
261
|
+
bunx wrangler deploy --env=""
|
|
262
|
+
|
|
263
|
+
# Tail logs
|
|
264
|
+
bunx wrangler tail flexpay-engine-dev
|
|
265
|
+
```
|
|
266
|
+
|
|
267
|
+
### Migrations
|
|
268
|
+
|
|
269
|
+
```bash
|
|
270
|
+
# Banking tables (worker)
|
|
271
|
+
bunx wrangler d1 execute flexpay-engine-dev --remote --file=drizzle/banking/XXXX_*.sql
|
|
272
|
+
|
|
273
|
+
# Engine tables (npm package — applied by consumers)
|
|
274
|
+
# Live in src/schema/ as drizzle definitions; migrations TBD per consumer.
|
|
275
|
+
```
|
|
276
|
+
|
|
277
|
+
---
|
|
278
|
+
|
|
279
|
+
## Live URLs
|
|
280
|
+
|
|
281
|
+
| Endpoint | Auth | Purpose |
|
|
282
|
+
|---|---|---|
|
|
283
|
+
| `https://flexpay-engine-dev.achilleas-dbe.workers.dev` | n/a | Dev worker |
|
|
284
|
+
| `GET /health/bake-summary` | none | Bake-check status (14-day clock) |
|
|
285
|
+
| `GET /admin/bnc/status` | Bearer | Detailed health |
|
|
286
|
+
| `GET /admin/bnc/ping` | Bearer | BNC sandbox connectivity |
|
|
287
|
+
| `POST /admin/bnc/bake/run` | Bearer | Force a bake-check now |
|
|
288
|
+
| `POST /webhooks/bnc` | BNC creds | Inbound BNC webhook |
|
|
289
|
+
|
|
290
|
+
R2 buckets: `bank-audit-archive-{dev,prod}` (append-only audit mirror).
|
|
291
|
+
|
|
292
|
+
---
|
|
293
|
+
|
|
294
|
+
## Architecture decisions worth knowing
|
|
295
|
+
|
|
296
|
+
- **Single writer per table.** `src/` writes only `engine_*` tables. `worker/banking/` writes only `bank_*` tables. CI guard enforces (`scripts/check-engine-tables.sh`).
|
|
297
|
+
- **Append-only ledger.** No UPDATE/DELETE on `engine_payments` except via the audited `forceReseedContract` path. Same rule for `engine_lock_state_events` once shipped.
|
|
298
|
+
- **Cents only.** No floats touching D1.
|
|
299
|
+
- **Idempotency via DB constraints**, not error-string matching. UNIQUE indexes + `onConflictDoNothing`.
|
|
300
|
+
- **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.
|
|
301
|
+
- **Best-effort archive, never blocks.** R2 write failures log + retry via sweep; never break the payment path.
|
|
302
|
+
- **Per-message queue ack/retry.** Never throw from the queue handler — that retries the whole batch.
|
|
303
|
+
|
|
304
|
+
---
|
|
305
|
+
|
|
306
|
+
## Key docs
|
|
307
|
+
|
|
308
|
+
| Doc | When to read |
|
|
309
|
+
|---|---|
|
|
310
|
+
| `docs/bnc/README.md` | Anything BNC-related |
|
|
311
|
+
| `docs/bnc/state-of-play.md` | What works / doesn't / unknown |
|
|
312
|
+
| `docs/bnc/security.md` | Threat model, crypto, secrets |
|
|
313
|
+
| `docs/bnc/green-light-criteria.md` | When can we cut over to prod |
|
|
314
|
+
| `docs/runbooks/bnc-banking.md` | Ops procedures, R2 drill |
|
|
315
|
+
| `context/specs/` | Active and shipped specs |
|
|
316
|
+
| `CLAUDE.md` (parent dir) | Cross-repo conventions |
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "flexpay-engine",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "FlexPay loan servicing engine
|
|
3
|
+
"version": "0.3.0",
|
|
4
|
+
"description": "FlexPay loan servicing engine \u2014 pricing, schedules, allocation, delinquency, lock-state",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"main": "src/index.ts",
|
|
7
7
|
"types": "src/index.ts",
|
|
@@ -23,15 +23,32 @@
|
|
|
23
23
|
"scripts": {
|
|
24
24
|
"test": "bun test",
|
|
25
25
|
"test:watch": "bun test --watch",
|
|
26
|
-
"typecheck": "bunx tsc --noEmit"
|
|
26
|
+
"typecheck": "bunx tsc --noEmit",
|
|
27
|
+
"typecheck:worker": "bunx tsc --noEmit -p tsconfig.worker.json",
|
|
28
|
+
"dev": "wrangler dev",
|
|
29
|
+
"deploy:dev": "wrangler deploy",
|
|
30
|
+
"deploy:prod": "wrangler deploy --env prod",
|
|
31
|
+
"db:migrate:dev": "wrangler d1 migrations apply flexpay-engine-dev --remote",
|
|
32
|
+
"db:migrate:prod": "wrangler d1 migrations apply flexpay-engine-prod --remote --env prod",
|
|
33
|
+
"guard:deps": "bash scripts/check-deps.sh",
|
|
34
|
+
"guard:migrations": "bash scripts/check-engine-tables.sh",
|
|
35
|
+
"guard:all": "bun run guard:deps && bun run guard:migrations",
|
|
36
|
+
"probe:bnc": "bun run scripts/probe-bnc-sandbox.ts",
|
|
37
|
+
"ci:verify": "bash scripts/check-deps.sh && bash scripts/check-engine-tables.sh && bun run typecheck && bun run typecheck:worker"
|
|
38
|
+
},
|
|
39
|
+
"dependencies": {
|
|
40
|
+
"@sentry/cloudflare": "^10.20.0",
|
|
41
|
+
"drizzle-orm": "^0.44.7",
|
|
42
|
+
"hono": "^4.6.0"
|
|
27
43
|
},
|
|
28
44
|
"peerDependencies": {
|
|
29
45
|
"drizzle-orm": "^0.44.7"
|
|
30
46
|
},
|
|
31
47
|
"devDependencies": {
|
|
48
|
+
"@cloudflare/workers-types": "^4.20250101.0",
|
|
32
49
|
"@types/bun": "latest",
|
|
33
|
-
"drizzle-orm": "^0.44.7",
|
|
34
50
|
"fast-check": "^3.22.0",
|
|
35
|
-
"typescript": "^5.7.0"
|
|
51
|
+
"typescript": "^5.7.0",
|
|
52
|
+
"wrangler": "^4.0.0"
|
|
36
53
|
}
|
|
37
54
|
}
|
package/src/calc/allocation.ts
CHANGED
|
@@ -3,20 +3,25 @@ import type {
|
|
|
3
3
|
AllocationResult,
|
|
4
4
|
InstallmentAllocation,
|
|
5
5
|
} from "../types";
|
|
6
|
-
import { PaymentBelowMinimumError } from "../types";
|
|
7
6
|
import { round2 } from "./pricing";
|
|
8
7
|
|
|
9
8
|
/**
|
|
10
9
|
* Allocate a payment across installments using FIFO (earliest unpaid first).
|
|
11
10
|
*
|
|
11
|
+
* Records the payment as given; it does not judge whether the amount is enough.
|
|
12
|
+
* Sufficiency is an entitlement question, decided from paidThroughDate.
|
|
13
|
+
* See context/specs/2026-07-30-fix-engine-below-minimum-payment.md
|
|
14
|
+
*
|
|
12
15
|
* Rules:
|
|
13
|
-
* -
|
|
16
|
+
* - Records any positive amount
|
|
14
17
|
* - Fills installments in sequence order
|
|
15
18
|
* - Partial payments create PARTIAL status
|
|
16
19
|
* - Overpayment (beyond all installments) is tracked separately
|
|
17
|
-
* - daysActivated =
|
|
20
|
+
* - daysActivated = the delta between cumulative entitlement before and after,
|
|
21
|
+
* NOT this payment floored on its own — see below
|
|
18
22
|
*
|
|
19
23
|
* INVARIANTS:
|
|
24
|
+
* - fragmentation invariance: any split of the same money grants the same days
|
|
20
25
|
* - totalAllocated + overpayment === paymentAmount
|
|
21
26
|
* - installment.amountPaid <= installment.amountDue (never overpay an installment)
|
|
22
27
|
* - newTotalPaid + newRemainingDebt === totalCost
|
|
@@ -27,14 +32,6 @@ export function allocatePayment(input: AllocationInput): AllocationResult {
|
|
|
27
32
|
.filter((i) => i.status !== "PAID" && i.status !== "WAIVED")
|
|
28
33
|
.sort((a, b) => a.sequenceNumber - b.sequenceNumber);
|
|
29
34
|
|
|
30
|
-
// Only enforce minimum on recurring payments (not deposits, not overpayments).
|
|
31
|
-
// Deposits (sequence 0) may be below minPayment for cheap phones / 0% down promos.
|
|
32
|
-
// Overpayments (all installments paid) should flow through regardless of amount.
|
|
33
|
-
const hasUnpaidRecurring = unpaid.some((i) => i.sequenceNumber > 0);
|
|
34
|
-
if (hasUnpaidRecurring && input.paymentAmount < input.minPayment) {
|
|
35
|
-
throw new PaymentBelowMinimumError(input.paymentAmount, input.minPayment);
|
|
36
|
-
}
|
|
37
|
-
|
|
38
35
|
let remaining = input.paymentAmount;
|
|
39
36
|
const allocations: InstallmentAllocation[] = [];
|
|
40
37
|
|
|
@@ -61,12 +58,6 @@ export function allocatePayment(input: AllocationInput): AllocationResult {
|
|
|
61
58
|
|
|
62
59
|
const totalAllocated = round2(input.paymentAmount - remaining);
|
|
63
60
|
|
|
64
|
-
// Days activated: proportional to payment relative to recurring
|
|
65
|
-
const daysActivated =
|
|
66
|
-
input.recurringPayment > 0
|
|
67
|
-
? Math.floor((totalAllocated / input.recurringPayment) * input.freqDays)
|
|
68
|
-
: 0;
|
|
69
|
-
|
|
70
61
|
// Calculate new totals from installment state
|
|
71
62
|
const currentTotalPaid = input.installments.reduce(
|
|
72
63
|
(sum, i) => sum + i.amountPaid,
|
|
@@ -79,6 +70,30 @@ export function allocatePayment(input: AllocationInput): AllocationResult {
|
|
|
79
70
|
const newTotalPaid = round2(currentTotalPaid + totalAllocated);
|
|
80
71
|
const newRemainingDebt = round2(totalCost - newTotalPaid);
|
|
81
72
|
|
|
73
|
+
// Days activated: the DELTA between cumulative entitlement before and after.
|
|
74
|
+
//
|
|
75
|
+
// This used to floor each payment on its own — and floor() discards the
|
|
76
|
+
// fraction every single time, so paying one installment in pieces granted
|
|
77
|
+
// fewer days than paying it at once. $52 as 52 x $1 granted ZERO days,
|
|
78
|
+
// because floor(1/52 * 15) === 0 each time.
|
|
79
|
+
//
|
|
80
|
+
// That was unreachable while allocation rejected below-minimum payments.
|
|
81
|
+
// Removing the rule made it reachable, and daysActivated feeds
|
|
82
|
+
// paidThroughDate (recordEvent -> applyPaymentToLockState), which is the
|
|
83
|
+
// engine's lock decision. A customer paying in full, in instalments, would
|
|
84
|
+
// have been locked for not paying.
|
|
85
|
+
//
|
|
86
|
+
// Deltas off a cumulative floor telescope, so any split of the same money
|
|
87
|
+
// sums to the same days. Deposit and overpayment behaviour are unchanged:
|
|
88
|
+
// the deposit still earns on the same ratio, and overpayment is excluded
|
|
89
|
+
// because it never enters totalAllocated.
|
|
90
|
+
const cumulativeDays = (total: number) =>
|
|
91
|
+
input.recurringPayment > 0
|
|
92
|
+
? Math.floor((total / input.recurringPayment) * input.freqDays)
|
|
93
|
+
: 0;
|
|
94
|
+
const daysActivated =
|
|
95
|
+
cumulativeDays(newTotalPaid) - cumulativeDays(currentTotalPaid);
|
|
96
|
+
|
|
82
97
|
// Find next due date: first installment that will still be unpaid after allocation
|
|
83
98
|
const paidIds = new Set(
|
|
84
99
|
allocations.filter((a) => a.newStatus === "PAID").map((a) => a.installmentId),
|
package/src/index.ts
CHANGED
|
@@ -9,11 +9,33 @@ export { allocatePayment } from "./calc/allocation";
|
|
|
9
9
|
export { checkDelinquency } from "./calc/delinquency";
|
|
10
10
|
export { computeDaysActivated } from "./calc/daysActivated";
|
|
11
11
|
|
|
12
|
+
// --- Lock-state decision (replaces Upya nextStatusUpdate) ---
|
|
13
|
+
export { decideLockState } from "./lockState/decide";
|
|
14
|
+
export type {
|
|
15
|
+
LockState,
|
|
16
|
+
ClosureReason,
|
|
17
|
+
LockStateContract,
|
|
18
|
+
LockStatePayment,
|
|
19
|
+
LockStateInput,
|
|
20
|
+
LockStateOutput,
|
|
21
|
+
} from "./lockState/types";
|
|
22
|
+
|
|
12
23
|
// --- Operations layer (D1-backed) ---
|
|
13
24
|
export { originateContract } from "./operations/originate";
|
|
14
25
|
export { recordEvent } from "./operations/recordEvent";
|
|
15
26
|
export { recordPayment } from "./operations/recordPayment";
|
|
16
27
|
export { seedContract } from "./operations/seedContract";
|
|
28
|
+
export {
|
|
29
|
+
snapshotSeedContract,
|
|
30
|
+
planSnapshotSeed,
|
|
31
|
+
} from "./operations/snapshotSeed";
|
|
32
|
+
export type {
|
|
33
|
+
SnapshotSeedInput,
|
|
34
|
+
SnapshotSeedPlan,
|
|
35
|
+
SnapshotSeedResult,
|
|
36
|
+
} from "./operations/snapshotSeed";
|
|
37
|
+
export { getLockState } from "./operations/getLockState";
|
|
38
|
+
export type { GetLockStateInput } from "./operations/getLockState";
|
|
17
39
|
|
|
18
40
|
// --- Utilities ---
|
|
19
41
|
export { ulid } from "./utils/ulid";
|
|
@@ -59,7 +81,6 @@ export type {
|
|
|
59
81
|
// --- Errors ---
|
|
60
82
|
export {
|
|
61
83
|
LoanEngineError,
|
|
62
|
-
PaymentBelowMinimumError,
|
|
63
84
|
ContractAlreadyPaidOffError,
|
|
64
85
|
} from "./types";
|
|
65
86
|
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
// Lock-state decision function.
|
|
2
|
+
// Pure — no I/O, no Date.now() reads. Deterministic from input alone.
|
|
3
|
+
// Replaces Upya `nextStatusUpdate`. See spec for design + validation evidence.
|
|
4
|
+
|
|
5
|
+
import type {
|
|
6
|
+
LockStateInput,
|
|
7
|
+
LockStateOutput,
|
|
8
|
+
LockStatePayment,
|
|
9
|
+
LockStateContract,
|
|
10
|
+
} from "./types";
|
|
11
|
+
|
|
12
|
+
const MS_PER_DAY = 86_400_000;
|
|
13
|
+
|
|
14
|
+
export function decideLockState(input: LockStateInput): LockStateOutput {
|
|
15
|
+
const { contract, payments, asOf } = input;
|
|
16
|
+
const computedAt = new Date(asOf.getTime());
|
|
17
|
+
|
|
18
|
+
if (contract.closure) {
|
|
19
|
+
return absorbingClosure(contract, computedAt);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
if (!contract.signedAt) {
|
|
23
|
+
return provisioning("awaiting_signing", computedAt);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const downpayment = payments.find((p) => p.kind === "downpayment");
|
|
27
|
+
if (!downpayment) {
|
|
28
|
+
return provisioning("awaiting_downpayment", computedAt);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const paidThrough = walkPaidThrough(payments);
|
|
32
|
+
if (!paidThrough) {
|
|
33
|
+
// Defensive: downpayment present but no credit applied (e.g. all reversals).
|
|
34
|
+
return provisioning("no_coverage_applied", computedAt);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
return classifyByTime({ paidThrough, contract, asOf, computedAt });
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function provisioning(reason: string, computedAt: Date): LockStateOutput {
|
|
41
|
+
return {
|
|
42
|
+
state: "provisioning",
|
|
43
|
+
reason,
|
|
44
|
+
paidThroughDate: null,
|
|
45
|
+
nextStateChangeAt: null,
|
|
46
|
+
daysUntilNextChange: null,
|
|
47
|
+
computedAt,
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function absorbingClosure(
|
|
52
|
+
contract: LockStateContract,
|
|
53
|
+
computedAt: Date,
|
|
54
|
+
): LockStateOutput {
|
|
55
|
+
return {
|
|
56
|
+
state: contract.closure!,
|
|
57
|
+
reason: `closed_${contract.closure}`,
|
|
58
|
+
paidThroughDate: null,
|
|
59
|
+
nextStateChangeAt: null,
|
|
60
|
+
daysUntilNextChange: null,
|
|
61
|
+
computedAt,
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Walk payments oldest-first, applying:
|
|
67
|
+
* paidThrough(n) = max(paidThrough(n-1), paymentDay(n)) + daysActivated(n)
|
|
68
|
+
*
|
|
69
|
+
* `max(...)` is the late-payment rule: late payments don't retroactively
|
|
70
|
+
* credit the days the device was locked. Reversals subtract via negative
|
|
71
|
+
* daysActivated (callers send a negative value for refunds).
|
|
72
|
+
*
|
|
73
|
+
* Validated against 47 production contracts. Match rate 77% exactly. Known
|
|
74
|
+
* outliers are "ahead of schedule" customers — see
|
|
75
|
+
* docs/lock-state-validation.md.
|
|
76
|
+
*/
|
|
77
|
+
function walkPaidThrough(
|
|
78
|
+
payments: ReadonlyArray<LockStatePayment>,
|
|
79
|
+
): Date | null {
|
|
80
|
+
let paidThrough: Date | null = null;
|
|
81
|
+
for (const p of payments) {
|
|
82
|
+
const paymentDay = new Date(p.receivedAt);
|
|
83
|
+
if (Number.isNaN(paymentDay.getTime())) {
|
|
84
|
+
throw new Error(`decideLockState: invalid receivedAt ${p.receivedAt}`);
|
|
85
|
+
}
|
|
86
|
+
if (!Number.isInteger(p.daysActivated)) {
|
|
87
|
+
throw new Error(
|
|
88
|
+
`decideLockState: daysActivated must be an integer, got ${p.daysActivated}`,
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
const base: Date =
|
|
92
|
+
paidThrough && paidThrough.getTime() > paymentDay.getTime()
|
|
93
|
+
? paidThrough
|
|
94
|
+
: paymentDay;
|
|
95
|
+
paidThrough = new Date(base.getTime() + p.daysActivated * MS_PER_DAY);
|
|
96
|
+
}
|
|
97
|
+
return paidThrough;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* The lock boundary: the instant the unlock window ends (or ended) —
|
|
102
|
+
* paidThrough + graceDays + 1 day, the same formula classifyByTime uses for
|
|
103
|
+
* `nextStateChangeAt` in the unlocked branch.
|
|
104
|
+
*
|
|
105
|
+
* Unlike LockStateOutput.nextStateChangeAt (null once locked — "next change
|
|
106
|
+
* comes from a payment, not a clock"), the boundary is defined whenever
|
|
107
|
+
* paidThrough exists, INCLUDING for locked contracts (where it lies in the
|
|
108
|
+
* past). The denormalized `engine_contracts.next_state_change_at` cache
|
|
109
|
+
* stores this boundary so bulk consumers (projection, enforcement sweeps
|
|
110
|
+
* with `date < now` predicates) can find overdue contracts — an
|
|
111
|
+
* always-null-when-locked cache would hide exactly the contracts a lock
|
|
112
|
+
* sweep needs to see.
|
|
113
|
+
*/
|
|
114
|
+
export function lockBoundary(paidThrough: Date, graceDays: number): Date {
|
|
115
|
+
return new Date(paidThrough.getTime() + graceDays * MS_PER_DAY + MS_PER_DAY);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Same state classification as decideLockState, but accepts a pre-computed
|
|
120
|
+
* paidThroughDate instead of walking a payment list. Use this from places
|
|
121
|
+
* that already maintain a cached paidThroughDate (e.g. recordEvent inside
|
|
122
|
+
* the engine, where each payment incrementally updates the cache).
|
|
123
|
+
*
|
|
124
|
+
* `paidThroughDate === null` is treated as provisioning (no coverage yet).
|
|
125
|
+
*/
|
|
126
|
+
export function decideLockStateFromPaidThrough(input: {
|
|
127
|
+
contract: LockStateContract;
|
|
128
|
+
paidThroughDate: Date | null;
|
|
129
|
+
asOf: Date;
|
|
130
|
+
}): LockStateOutput {
|
|
131
|
+
const { contract, paidThroughDate, asOf } = input;
|
|
132
|
+
const computedAt = new Date(asOf.getTime());
|
|
133
|
+
|
|
134
|
+
if (contract.closure) {
|
|
135
|
+
return absorbingClosure(contract, computedAt);
|
|
136
|
+
}
|
|
137
|
+
if (!contract.signedAt) {
|
|
138
|
+
return provisioning("awaiting_signing", computedAt);
|
|
139
|
+
}
|
|
140
|
+
if (!paidThroughDate) {
|
|
141
|
+
return provisioning("awaiting_downpayment", computedAt);
|
|
142
|
+
}
|
|
143
|
+
return classifyByTime({ paidThrough: paidThroughDate, contract, asOf, computedAt });
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function classifyByTime(args: {
|
|
147
|
+
paidThrough: Date;
|
|
148
|
+
contract: LockStateContract;
|
|
149
|
+
asOf: Date;
|
|
150
|
+
computedAt: Date;
|
|
151
|
+
}): LockStateOutput {
|
|
152
|
+
const { paidThrough, contract, asOf, computedAt } = args;
|
|
153
|
+
const asOfMs = asOf.getTime();
|
|
154
|
+
const graceEndMs = paidThrough.getTime() + contract.graceDays * MS_PER_DAY;
|
|
155
|
+
|
|
156
|
+
if (asOfMs <= graceEndMs) {
|
|
157
|
+
const lockTransitionAt = lockBoundary(paidThrough, contract.graceDays);
|
|
158
|
+
const inGrace = contract.graceDays > 0 && asOfMs > paidThrough.getTime();
|
|
159
|
+
return {
|
|
160
|
+
state: "unlocked",
|
|
161
|
+
reason: inGrace ? "within_grace" : "current",
|
|
162
|
+
paidThroughDate: paidThrough,
|
|
163
|
+
nextStateChangeAt: lockTransitionAt,
|
|
164
|
+
daysUntilNextChange: Math.max(
|
|
165
|
+
0,
|
|
166
|
+
Math.ceil((lockTransitionAt.getTime() - asOfMs) / MS_PER_DAY),
|
|
167
|
+
),
|
|
168
|
+
computedAt,
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
return {
|
|
173
|
+
state: "locked",
|
|
174
|
+
reason: "past_paid_through",
|
|
175
|
+
paidThroughDate: paidThrough,
|
|
176
|
+
nextStateChangeAt: null,
|
|
177
|
+
daysUntilNextChange: null,
|
|
178
|
+
computedAt,
|
|
179
|
+
};
|
|
180
|
+
}
|