flexpay-engine 0.3.2 → 0.3.3
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 +28 -2
- package/README.md +17 -9
- package/drizzle/engine/0005_lock_event_actor.sql +29 -0
- package/drizzle/engine/0006_requeue_actor_archive.sql +9 -0
- package/package.json +2 -1
- package/src/index.ts +2 -0
- package/src/lockState/actor.ts +25 -0
- package/src/operations/recordEvent.ts +4 -0
- package/src/operations/snapshotSeed.ts +2 -0
- package/src/schema/index.ts +6 -0
package/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,30 @@
|
|
|
3
3
|
All notable changes to `flexpay-engine` (npm package). Only `src/` ships; the
|
|
4
4
|
`worker/` runtime is deployed separately and is not part of the package.
|
|
5
5
|
|
|
6
|
+
## 0.3.3 — 2026-08-29
|
|
7
|
+
|
|
8
|
+
### Migration `0005` — lock-event actor (FLEXPAY-240)
|
|
9
|
+
|
|
10
|
+
`drizzle/engine/0005_lock_event_actor.sql` adds `actor_id` / `actor_name` /
|
|
11
|
+
`actor_role` (all nullable) to `engine_lock_state_events` and backfills existing
|
|
12
|
+
rows to `system:engine` / `System` / `system`. Additive on an append-only table:
|
|
13
|
+
no rewrite, no downtime, safe to apply ahead of the code bump.
|
|
14
|
+
|
|
15
|
+
**Must be applied before the FLEXPAY-240 code deploys.** `worker/lockState/sweep.ts`
|
|
16
|
+
INSERTs those three columns literally; the daily cron sweep fails at runtime if the
|
|
17
|
+
columns are absent.
|
|
18
|
+
|
|
19
|
+
State as of 2026-08-28: **applied to `flexpay-engine-dev` (55 rows backfilled) AND
|
|
20
|
+
`flexpay-engine-prod` (1992 rows backfilled)**; 0 NULL `actor_id` on either. Verified
|
|
21
|
+
live via `PRAGMA table_info` + a NULL-actor count.
|
|
22
|
+
|
|
23
|
+
### Migration `0006` — requeue actor-enriched lock archives (FLEXPAY-240)
|
|
24
|
+
|
|
25
|
+
`drizzle/engine/0006_requeue_actor_archive.sql` clears `archived_at` on existing
|
|
26
|
+
lock-state rows so the archive sweep rewrites their R2 objects with actor fields.
|
|
27
|
+
The sweep uses archive version `v2` for the actor-enriched event shape. Deploy the
|
|
28
|
+
worker containing `v2`, then apply `0006` after `0005`.
|
|
29
|
+
|
|
6
30
|
## 0.3.2 — 2026-07-31
|
|
7
31
|
|
|
8
32
|
Packaging fix. **No source changes** — but `0.3.1` is effectively uninstallable for
|
|
@@ -72,8 +96,10 @@ The migration is additive: `ADD COLUMN` (nullable) plus a backfill gated on
|
|
|
72
96
|
`paid_through_date IS NOT NULL`. Verified 2026-07-31 — 0 of 970 prod contracts
|
|
73
97
|
have `paid_through_date` set, so the backfill touches no rows.
|
|
74
98
|
|
|
75
|
-
State
|
|
76
|
-
|
|
99
|
+
State: applied to `flexpay-engine-dev` AND `flexpay-engine-prod`. Verified live
|
|
100
|
+
2026-08-28 — `engine_contracts.next_state_change_at` present on both
|
|
101
|
+
(`PRAGMA table_info`). The earlier "not yet applied to prod" note (2026-07-31) is
|
|
102
|
+
superseded.
|
|
77
103
|
|
|
78
104
|
### ⚠️ `paid_through_date` is provisional after this upgrade
|
|
79
105
|
|
package/README.md
CHANGED
|
@@ -13,21 +13,26 @@ Worker for banking integrations.
|
|
|
13
13
|
|
|
14
14
|
---
|
|
15
15
|
|
|
16
|
-
## ⛔ Upgrading to 0.3.x — apply
|
|
16
|
+
## ⛔ Upgrading to 0.3.x — apply migrations `0004` and `0005` FIRST
|
|
17
17
|
|
|
18
18
|
**Do not bump a consumer to `0.3.x` before applying
|
|
19
19
|
[`drizzle/engine/0004_next_state_change_at.sql`](drizzle/engine/0004_next_state_change_at.sql)
|
|
20
|
+
and [`drizzle/engine/0005_lock_event_actor.sql`](drizzle/engine/0005_lock_event_actor.sql)
|
|
20
21
|
to that consumer's `ENGINE_DB`.**
|
|
21
22
|
|
|
22
23
|
From `0.3.0`, `recordEvent` / `recordPayment` write
|
|
23
|
-
`engine_contracts.next_state_change_at
|
|
24
|
-
|
|
25
|
-
|
|
24
|
+
`engine_contracts.next_state_change_at` and actor fields on
|
|
25
|
+
`engine_lock_state_events`. Without these columns the contract update or event
|
|
26
|
+
insert throws — and because engine writes are typically wired non-blocking,
|
|
27
|
+
**the failure is silent: a ledger gap, not an error the caller sees.**
|
|
26
28
|
|
|
27
|
-
The
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
29
|
+
The migrations are additive and safe to apply ahead of the bump. `0004` also
|
|
30
|
+
backfills `next_state_change_at`; `0005` backfills legacy event actors. Guard
|
|
31
|
+
re-runs because `ADD COLUMN` itself is not idempotent.
|
|
32
|
+
|
|
33
|
+
After deploying the worker with actor-enriched archive version `v2`, apply
|
|
34
|
+
[`drizzle/engine/0006_requeue_actor_archive.sql`](drizzle/engine/0006_requeue_actor_archive.sql)
|
|
35
|
+
to clear archive markers and rewrite existing R2 objects with actor fields.
|
|
31
36
|
|
|
32
37
|
Earlier lock-state columns need `0001`–`0003` for the same reason.
|
|
33
38
|
|
|
@@ -303,7 +308,10 @@ bunx wrangler tail flexpay-engine-dev
|
|
|
303
308
|
bunx wrangler d1 execute flexpay-engine-dev --remote --file=drizzle/banking/XXXX_*.sql
|
|
304
309
|
|
|
305
310
|
# Engine tables (npm package — applied by consumers)
|
|
306
|
-
|
|
311
|
+
bunx wrangler d1 execute <engine-db> --remote --file=drizzle/engine/0005_lock_event_actor.sql
|
|
312
|
+
# Deploy the worker containing archive version v2, then:
|
|
313
|
+
bunx wrangler d1 execute <engine-db> --remote --file=drizzle/engine/0006_requeue_actor_archive.sql
|
|
314
|
+
# The requeue must run after v2 is deployed so the sweep rewrites R2 objects as v2.
|
|
307
315
|
```
|
|
308
316
|
|
|
309
317
|
---
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
-- Who changed the lock state (FLEXPAY-240, feeds the FLEXPAY-239 activity feed).
|
|
2
|
+
--
|
|
3
|
+
-- engine_lock_state_events records WHAT changed (from_state -> to_state, reason,
|
|
4
|
+
-- trigger) but not WHO. It was written for the cron, which has no user. A human
|
|
5
|
+
-- clicking Lock in the portal is therefore indistinguishable from the overdue
|
|
6
|
+
-- sweep, and the contract activity feed cannot answer "who locked this phone".
|
|
7
|
+
--
|
|
8
|
+
-- actor_role: 'owner' | 'admin' | 'agent' | 'system'.
|
|
9
|
+
--
|
|
10
|
+
-- Additive and nullable on an append-only table: no rewrite, no downtime.
|
|
11
|
+
|
|
12
|
+
ALTER TABLE engine_lock_state_events ADD COLUMN actor_id TEXT;
|
|
13
|
+
ALTER TABLE engine_lock_state_events ADD COLUMN actor_name TEXT;
|
|
14
|
+
ALTER TABLE engine_lock_state_events ADD COLUMN actor_role TEXT;
|
|
15
|
+
|
|
16
|
+
-- Backfill is a provable claim, not a default. The only writers of this table
|
|
17
|
+
-- are operations/recordEvent.ts and operations/snapshotSeed.ts, both
|
|
18
|
+
-- engine-internal. The portal lock route (flexpay-client-worker
|
|
19
|
+
-- routes/portal/contracts.ts, PUT /:contractNumber/lock-status) writes Upya +
|
|
20
|
+
-- the lock provider + MAIN Postgres and never touches ENGINE_DB. So every row
|
|
21
|
+
-- that exists today was computed by the engine itself, and stamping it 'system'
|
|
22
|
+
-- asserts nothing we cannot show.
|
|
23
|
+
--
|
|
24
|
+
-- Idempotent: re-running matches no rows once the columns are populated.
|
|
25
|
+
UPDATE engine_lock_state_events
|
|
26
|
+
SET actor_id = 'system:engine',
|
|
27
|
+
actor_name = 'System',
|
|
28
|
+
actor_role = 'system'
|
|
29
|
+
WHERE actor_id IS NULL;
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
-- Requeue lock-state rows after the FLEXPAY-240 actor backfill.
|
|
2
|
+
--
|
|
3
|
+
-- Migration 0005 changed the serialized event body, but archived_at remained set
|
|
4
|
+
-- on rows whose R2 object still contains the pre-actor shape. Clear the marker so
|
|
5
|
+
-- the archive sweep writes the actor-enriched body before treating the row as
|
|
6
|
+
-- durable again. The update is safe to re-run and does not touch the ledger row.
|
|
7
|
+
UPDATE engine_lock_state_events
|
|
8
|
+
SET archived_at = NULL
|
|
9
|
+
WHERE archived_at IS NOT NULL;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "flexpay-engine",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.3",
|
|
4
4
|
"description": "FlexPay loan servicing engine \u2014 pricing, schedules, allocation, delinquency, lock-state",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"main": "src/index.ts",
|
|
@@ -32,6 +32,7 @@
|
|
|
32
32
|
"deploy:prod": "wrangler deploy --env prod",
|
|
33
33
|
"db:migrate:dev": "wrangler d1 migrations apply flexpay-engine-dev --remote",
|
|
34
34
|
"db:migrate:prod": "wrangler d1 migrations apply flexpay-engine-prod --remote --env prod",
|
|
35
|
+
"check:deployment-authority": "bun run scripts/check-deployment-authority.ts",
|
|
35
36
|
"guard:deps": "bash scripts/check-deps.sh",
|
|
36
37
|
"guard:migrations": "bash scripts/check-engine-tables.sh",
|
|
37
38
|
"guard:all": "bun run guard:deps && bun run guard:migrations",
|
package/src/index.ts
CHANGED
|
@@ -11,6 +11,8 @@ export { computeDaysActivated } from "./calc/daysActivated";
|
|
|
11
11
|
|
|
12
12
|
// --- Lock-state decision (replaces Upya nextStatusUpdate) ---
|
|
13
13
|
export { decideLockState } from "./lockState/decide";
|
|
14
|
+
export { SYSTEM_ACTOR } from "./lockState/actor";
|
|
15
|
+
export type { LockActor, LockActorRole } from "./lockState/actor";
|
|
14
16
|
export type {
|
|
15
17
|
LockState,
|
|
16
18
|
ClosureReason,
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
// Who caused a lock-state transition (FLEXPAY-240).
|
|
2
|
+
//
|
|
3
|
+
// The engine decides state on its own: a payment lands, the boundary moves,
|
|
4
|
+
// the device unlocks. Nobody clicked anything, so the actor is the engine —
|
|
5
|
+
// and the person who made the payment is already the actor on the payment
|
|
6
|
+
// row, which renders as its own line in the contract activity feed. Copying
|
|
7
|
+
// them onto the transition too would attribute a decision to someone who did
|
|
8
|
+
// not make it, and print the same name twice.
|
|
9
|
+
//
|
|
10
|
+
// A human appears here only when they override state by hand from the portal.
|
|
11
|
+
|
|
12
|
+
export type LockActorRole = "owner" | "admin" | "agent" | "system";
|
|
13
|
+
|
|
14
|
+
export interface LockActor {
|
|
15
|
+
actorId: string;
|
|
16
|
+
actorName: string;
|
|
17
|
+
actorRole: LockActorRole;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** The engine acting on its own — payment-driven, cron sweep, seeding. */
|
|
21
|
+
export const SYSTEM_ACTOR: LockActor = {
|
|
22
|
+
actorId: "system:engine",
|
|
23
|
+
actorName: "System",
|
|
24
|
+
actorRole: "system",
|
|
25
|
+
};
|
|
@@ -35,6 +35,7 @@ import {
|
|
|
35
35
|
} from "../types";
|
|
36
36
|
import { fromCents, toCents } from "../utils/cents";
|
|
37
37
|
import { ulid } from "../utils/ulid";
|
|
38
|
+
import { SYSTEM_ACTOR } from "../lockState/actor";
|
|
38
39
|
import {
|
|
39
40
|
ContractNotFoundError,
|
|
40
41
|
EngineOperationError,
|
|
@@ -228,6 +229,9 @@ export async function recordEvent(
|
|
|
228
229
|
paidThroughDate: lock.newPaidThrough.toISOString(),
|
|
229
230
|
nextStateChangeAt: lock.nextStateChangeAt?.toISOString() ?? null,
|
|
230
231
|
trigger: "payment",
|
|
232
|
+
// The engine decided this, not input.actorId — that person made a
|
|
233
|
+
// payment, which is its own row with its own actor. See lockState/actor.
|
|
234
|
+
...SYSTEM_ACTOR,
|
|
231
235
|
}
|
|
232
236
|
: null;
|
|
233
237
|
// ---------------------------------------------------------------------
|
|
@@ -46,6 +46,7 @@ import {
|
|
|
46
46
|
} from "../schema";
|
|
47
47
|
import { toCents } from "../utils/cents";
|
|
48
48
|
import { ulid } from "../utils/ulid";
|
|
49
|
+
import { SYSTEM_ACTOR } from "../lockState/actor";
|
|
49
50
|
import {
|
|
50
51
|
EngineOperationError,
|
|
51
52
|
EngineSeedError,
|
|
@@ -317,6 +318,7 @@ export function planSnapshotSeed(input: SnapshotSeedInput): SnapshotSeedPlan {
|
|
|
317
318
|
paidThroughDate: paidThroughIso,
|
|
318
319
|
nextStateChangeAt: lock.nextStateChangeAt?.toISOString() ?? null,
|
|
319
320
|
trigger: "seed",
|
|
321
|
+
...SYSTEM_ACTOR,
|
|
320
322
|
};
|
|
321
323
|
|
|
322
324
|
return {
|
package/src/schema/index.ts
CHANGED
|
@@ -87,6 +87,12 @@ export const engineLockStateEvents = sqliteTable(
|
|
|
87
87
|
paidThroughDate: text("paid_through_date"),
|
|
88
88
|
nextStateChangeAt: text("next_state_change_at"),
|
|
89
89
|
trigger: text("trigger").notNull(), // 'payment' | 'cron' | 'rpc' | 'origination' | 'closure'
|
|
90
|
+
// Who caused the transition (FLEXPAY-240). The engine computes state on its
|
|
91
|
+
// own, so 'system:engine' is the norm; a human only appears when the portal
|
|
92
|
+
// overrides a lock by hand. Without this, a sweep and a person are the same row.
|
|
93
|
+
actorId: text("actor_id"), // 'system:engine' | member id
|
|
94
|
+
actorName: text("actor_name"),
|
|
95
|
+
actorRole: text("actor_role"), // 'owner' | 'admin' | 'agent' | 'system'
|
|
90
96
|
archivedAt: text("archived_at"), // R2 mirror timestamp; NULL until swept
|
|
91
97
|
},
|
|
92
98
|
(t) => ({
|