flexpay-engine 0.3.1 → 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 CHANGED
@@ -3,6 +3,76 @@
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
+
30
+ ## 0.3.2 — 2026-07-31
31
+
32
+ Packaging fix. **No source changes** — but `0.3.1` is effectively uninstallable for
33
+ `flexpay-backend`, so upgrade straight to `0.3.2`.
34
+
35
+ ### Fixed
36
+
37
+ - **`drizzle-orm` no longer appears in `dependencies`.** It was declared in *both*
38
+ `dependencies` and `peerDependencies`. The `dependencies` entry forces a real
39
+ install, so a consumer pinning an older drizzle got a **second, nested copy** —
40
+ and TypeScript treats two copies as unrelated types. Measured on
41
+ `flexpay-backend` (pins `drizzle-orm@^0.41.0`): **40 `error TS` diagnostics**,
42
+ all variants of
43
+
44
+ ```
45
+ Type '.../flexpay-engine/node_modules/drizzle-orm/sql/sql".SQL<unknown>' is not
46
+ assignable to type '.../flexpay-backend/node_modules/drizzle-orm/sql/sql".SQL<unknown>'.
47
+ Types have separate declarations of a private property 'shouldInlineParams'.
48
+ ```
49
+
50
+ With `0.3.2` the nested copy disappears and the same typecheck reports **0 errors**.
51
+ `flexpay-client-worker` was never affected — it pins `^0.44.7`, which satisfies the
52
+ peer range and dedupes.
53
+
54
+ `drizzle-orm` remains a `peerDependency` (`^0.44.7`) — it is the one thing `src/`
55
+ genuinely imports. Note that `flexpay-backend` does **not** satisfy that range today
56
+ (`^0.41.0`); that mismatch predates this release and is unchanged by it.
57
+
58
+ ### Deliberately NOT changed
59
+
60
+ - **`hono` and `@sentry/cloudflare` stay in `dependencies`.** Only `worker/` imports
61
+ them and `worker/` is not in the npm artifact, so moving them to `devDependencies`
62
+ looks correct — but it **breaks the Cloudflare Workers build**, which installs
63
+ production dependencies only:
64
+
65
+ ```
66
+ ✘ [ERROR] Could not resolve "hono"
67
+ worker/routes/webhooks-bnc.ts:4:21
68
+ ```
69
+
70
+ They are also not a consumer hazard: `src/` never imports them, so no type identity
71
+ is shared and no duplicate-copy breakage is possible. This repo is both an npm
72
+ package and a deployed Worker; `dependencies` has to satisfy the Worker build.
73
+ `drizzle-orm` is the one that must NOT be there, and it still resolves for the
74
+ Worker because peer dependencies are auto-installed.
75
+
6
76
  ## 0.3.1 — 2026-07-31
7
77
 
8
78
  Released from `dev`. Supersedes **0.3.0, which must not be used** — 0.3.0 was
@@ -26,8 +96,10 @@ The migration is additive: `ADD COLUMN` (nullable) plus a backfill gated on
26
96
  `paid_through_date IS NOT NULL`. Verified 2026-07-31 — 0 of 970 prod contracts
27
97
  have `paid_through_date` set, so the backfill touches no rows.
28
98
 
29
- State as of 2026-07-31: applied to `flexpay-engine-dev`; **not yet** applied to
30
- `flexpay-engine-prod`.
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.
31
103
 
32
104
  ### ⚠️ `paid_through_date` is provisional after this upgrade
33
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 migration `0004` FIRST
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`. 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.**
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 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.
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
- # Live in src/schema/ as drizzle definitions; migrations TBD per consumer.
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.1",
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,25 +32,26 @@
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",
38
39
  "probe:bnc": "bun run scripts/probe-bnc-sandbox.ts",
39
40
  "ci:verify": "bash scripts/check-deps.sh && bash scripts/check-engine-tables.sh && bun run typecheck && bun run typecheck:worker"
40
41
  },
41
- "dependencies": {
42
- "@sentry/cloudflare": "^10.20.0",
43
- "drizzle-orm": "^0.44.7",
44
- "hono": "^4.6.0"
45
- },
46
42
  "peerDependencies": {
47
43
  "drizzle-orm": "^0.44.7"
48
44
  },
49
45
  "devDependencies": {
50
46
  "@cloudflare/workers-types": "^4.20250101.0",
51
47
  "@types/bun": "latest",
48
+ "drizzle-orm": "^0.44.7",
52
49
  "fast-check": "^3.22.0",
53
50
  "typescript": "^5.7.0",
54
51
  "wrangler": "^4.0.0"
52
+ },
53
+ "dependencies": {
54
+ "@sentry/cloudflare": "^10.20.0",
55
+ "hono": "^4.6.0"
55
56
  }
56
57
  }
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 {
@@ -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) => ({