flexpay-engine 0.3.0 → 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 +32 -0
- 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 +4 -2
- package/src/calc/allocation.ts +12 -2
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
|
@@ -13,6 +13,38 @@ Worker for banking integrations.
|
|
|
13
13
|
|
|
14
14
|
---
|
|
15
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
|
+
|
|
16
48
|
## What this owns vs what it doesn't
|
|
17
49
|
|
|
18
50
|
The test for whether something belongs here:
|
|
@@ -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;
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
-- Denormalized lock boundary on engine_contracts (FLEXPAY-140 cutover T1).
|
|
2
|
+
-- next_state_change_at = paid_through_date + (grace_days + 1) days
|
|
3
|
+
--
|
|
4
|
+
-- This is the bulk-queryable "next lock date" (Upya nextStatusUpdate
|
|
5
|
+
-- equivalent) for the Postgres projection + enforcement predicates. Unlike
|
|
6
|
+
-- LockStateOutput.nextStateChangeAt (null once locked), the column keeps the
|
|
7
|
+
-- boundary for LOCKED contracts too (a past date) — `date < now` sweep
|
|
8
|
+
-- predicates need overdue contracts to remain visible. NULL only for
|
|
9
|
+
-- provisioning (no paid_through_date), terminal (closure set), and fully-paid
|
|
10
|
+
-- (status COMPLETED) contracts — a boundary on a paid-off contract could point
|
|
11
|
+
-- a sweep at a device that must never lock again.
|
|
12
|
+
--
|
|
13
|
+
-- Truth remains engine_events / engine_lock_state_events; this is a cache,
|
|
14
|
+
-- maintained by recordEvent (same atomic batch as the event row) and
|
|
15
|
+
-- snapshotSeed. The backfill below is idempotent (recomputes the same value)
|
|
16
|
+
-- and safe to re-run.
|
|
17
|
+
|
|
18
|
+
ALTER TABLE engine_contracts ADD COLUMN next_state_change_at TEXT;
|
|
19
|
+
|
|
20
|
+
UPDATE engine_contracts
|
|
21
|
+
SET next_state_change_at = strftime(
|
|
22
|
+
'%Y-%m-%dT%H:%M:%fZ',
|
|
23
|
+
paid_through_date,
|
|
24
|
+
'+' || (grace_days + 1) || ' days'
|
|
25
|
+
)
|
|
26
|
+
WHERE closure IS NULL
|
|
27
|
+
AND status != 'COMPLETED'
|
|
28
|
+
AND paid_through_date IS NOT NULL;
|
package/package.json
CHANGED
|
@@ -1,12 +1,14 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "flexpay-engine",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.1",
|
|
4
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",
|
|
8
8
|
"files": [
|
|
9
|
-
"src"
|
|
9
|
+
"src",
|
|
10
|
+
"drizzle",
|
|
11
|
+
"CHANGELOG.md"
|
|
10
12
|
],
|
|
11
13
|
"exports": {
|
|
12
14
|
".": {
|
package/src/calc/allocation.ts
CHANGED
|
@@ -4,6 +4,7 @@ import type {
|
|
|
4
4
|
InstallmentAllocation,
|
|
5
5
|
} from "../types";
|
|
6
6
|
import { round2 } from "./pricing";
|
|
7
|
+
import { toCents } from "../utils/cents";
|
|
7
8
|
|
|
8
9
|
/**
|
|
9
10
|
* Allocate a payment across installments using FIFO (earliest unpaid first).
|
|
@@ -87,9 +88,18 @@ export function allocatePayment(input: AllocationInput): AllocationResult {
|
|
|
87
88
|
// sums to the same days. Deposit and overpayment behaviour are unchanged:
|
|
88
89
|
// the deposit still earns on the same ratio, and overpayment is excluded
|
|
89
90
|
// because it never enters totalAllocated.
|
|
91
|
+
// INTEGER CENTS, not floats. `newTotalPaid` is round2()'d but `currentTotalPaid`
|
|
92
|
+
// is a raw sum of installment amounts, so comparing their floors compared a
|
|
93
|
+
// rounded value against an unrounded one. Measured: 474 of 1260 accumulation
|
|
94
|
+
// points diverge — e.g. recurring 3.35 after 13 payments sums to
|
|
95
|
+
// 43.55000000000001 (floor 91) where the previous call stored 43.55 (floor 90).
|
|
96
|
+
// A day appears or vanishes purely from binary representation, and it lands on
|
|
97
|
+
// paidThroughDate. Cents make both terms exact, so the subtraction is honest and
|
|
98
|
+
// the result can never be negative.
|
|
99
|
+
const recurringCents = toCents(input.recurringPayment);
|
|
90
100
|
const cumulativeDays = (total: number) =>
|
|
91
|
-
|
|
92
|
-
? Math.floor((total
|
|
101
|
+
recurringCents > 0
|
|
102
|
+
? Math.floor((toCents(total) * input.freqDays) / recurringCents)
|
|
93
103
|
: 0;
|
|
94
104
|
const daysActivated =
|
|
95
105
|
cumulativeDays(newTotalPaid) - cumulativeDays(currentTotalPaid);
|