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.
@@ -0,0 +1,94 @@
1
+ // Incremental lock-state update — applied after every payment is recorded.
2
+ // Pure: takes the contract snapshot + payment delta, returns the new
3
+ // paid-through date, new lock state, and whether a transition fired.
4
+ //
5
+ // recordEvent uses this to maintain engine_contracts.paid_through_date as
6
+ // a cache, and to decide whether to insert an engine_lock_state_events row
7
+ // in the atomic batch.
8
+
9
+ import { decideLockStateFromPaidThrough } from "./decide";
10
+ import type { ClosureReason, LockState, LockStateContract } from "./types";
11
+
12
+ const MS_PER_DAY = 86_400_000;
13
+
14
+ export interface IncrementalLockStateInput {
15
+ contract: {
16
+ contractNumber: string;
17
+ signingDate: string;
18
+ freqDays: number;
19
+ graceDays: number;
20
+ timezone: string;
21
+ numInstallments: number;
22
+ paidThroughDate: string | null;
23
+ lastLockState: string | null;
24
+ closure: string | null;
25
+ closureAt: string | null;
26
+ };
27
+ /** Real-world moment of the payment (ISO timestamp). */
28
+ paymentDate: Date;
29
+ /** Days credited by allocatePayment. Integer. */
30
+ daysActivated: number;
31
+ /** Our wall-clock moment we're computing this at. */
32
+ asOf: Date;
33
+ }
34
+
35
+ export interface IncrementalLockStateResult {
36
+ newPaidThrough: Date;
37
+ newState: LockState;
38
+ reason: string;
39
+ nextStateChangeAt: Date | null;
40
+ /** True if newState !== contract.lastLockState. */
41
+ transitioned: boolean;
42
+ /** The state BEFORE this payment landed (null if first computation). */
43
+ fromState: LockState | null;
44
+ }
45
+
46
+ export function applyPaymentToLockState(
47
+ input: IncrementalLockStateInput,
48
+ ): IncrementalLockStateResult {
49
+ if (!Number.isInteger(input.daysActivated)) {
50
+ throw new Error(
51
+ `applyPaymentToLockState: daysActivated must be an integer, got ${input.daysActivated}`,
52
+ );
53
+ }
54
+
55
+ const paymentDayMs = input.paymentDate.getTime();
56
+ const prevPaidThroughMs = input.contract.paidThroughDate
57
+ ? new Date(input.contract.paidThroughDate).getTime()
58
+ : null;
59
+ const baseMs =
60
+ prevPaidThroughMs != null && prevPaidThroughMs > paymentDayMs
61
+ ? prevPaidThroughMs
62
+ : paymentDayMs;
63
+ const newPaidThrough = new Date(
64
+ baseMs + input.daysActivated * MS_PER_DAY,
65
+ );
66
+
67
+ const lockContractInput: LockStateContract = {
68
+ id: input.contract.contractNumber,
69
+ signedAt: input.contract.signingDate,
70
+ paymentFreqDays: input.contract.freqDays,
71
+ upfrontDays: 0,
72
+ graceDays: input.contract.graceDays,
73
+ timezone: input.contract.timezone,
74
+ totalScheduledPayments: input.contract.numInstallments,
75
+ closure: (input.contract.closure as ClosureReason | null) ?? undefined,
76
+ closureAt: input.contract.closureAt ?? undefined,
77
+ };
78
+
79
+ const output = decideLockStateFromPaidThrough({
80
+ contract: lockContractInput,
81
+ paidThroughDate: newPaidThrough,
82
+ asOf: input.asOf,
83
+ });
84
+
85
+ const fromState = (input.contract.lastLockState as LockState | null) ?? null;
86
+ return {
87
+ newPaidThrough,
88
+ newState: output.state,
89
+ reason: output.reason,
90
+ nextStateChangeAt: output.nextStateChangeAt,
91
+ transitioned: fromState !== output.state,
92
+ fromState,
93
+ };
94
+ }
@@ -0,0 +1,9 @@
1
+ export { decideLockState } from "./decide";
2
+ export type {
3
+ LockState,
4
+ ClosureReason,
5
+ LockStateContract,
6
+ LockStatePayment,
7
+ LockStateInput,
8
+ LockStateOutput,
9
+ } from "./types";
@@ -0,0 +1,62 @@
1
+ // Lock-state decision module types.
2
+ // See context/specs/2026-05-22-feat-lock-state-decision.md for design rationale.
3
+
4
+ export type LockState =
5
+ | "provisioning"
6
+ | "unlocked"
7
+ | "locked"
8
+ | "paid_off"
9
+ | "repossessed"
10
+ | "written_off";
11
+
12
+ export type ClosureReason = "paid_off" | "repossessed" | "written_off";
13
+
14
+ export interface LockStateContract {
15
+ id: string;
16
+ /** ISO timestamp the customer signed the contract. null = unsigned. */
17
+ signedAt: string | null;
18
+ /** Days between recurring payments (typically 7, 14, 15, 30). */
19
+ paymentFreqDays: number;
20
+ /** Days the device is unlocked after downpayment lands (often 1 or = freq). */
21
+ upfrontDays: number;
22
+ /** Grace days after paidThroughDate before locking. 0 today (Venezuela). */
23
+ graceDays: number;
24
+ /** IANA timezone for day-boundary calculations. Default America/Caracas. */
25
+ timezone: string;
26
+ /** Total scheduled payments needed to fully satisfy the contract. */
27
+ totalScheduledPayments: number;
28
+ /** Set by ops via closeContract — absorbing state. */
29
+ closure?: ClosureReason;
30
+ /** ISO timestamp when closure was set. */
31
+ closureAt?: string;
32
+ }
33
+
34
+ export interface LockStatePayment {
35
+ /** ISO timestamp the payment was received. */
36
+ receivedAt: string;
37
+ /** Days Upya (or our engine) credited this payment with. May be 0 for partial. */
38
+ daysActivated: number;
39
+ /** Payment classification. */
40
+ kind: "downpayment" | "regular" | "reversal";
41
+ }
42
+
43
+ export interface LockStateInput {
44
+ contract: LockStateContract;
45
+ payments: ReadonlyArray<LockStatePayment>;
46
+ /** Moment we're evaluating "is the device locked right now". */
47
+ asOf: Date;
48
+ }
49
+
50
+ export interface LockStateOutput {
51
+ state: LockState;
52
+ /** Stable, grep-friendly identifier for the rule that fired. */
53
+ reason: string;
54
+ /** Date through which the customer's coverage extends. null if not derivable. */
55
+ paidThroughDate: Date | null;
56
+ /** When the state would next change without further input. null = on next payment. */
57
+ nextStateChangeAt: Date | null;
58
+ /** Whole days until nextStateChangeAt. null when nextStateChangeAt is null. */
59
+ daysUntilNextChange: number | null;
60
+ /** asOf echoed back so consumers know what timestamp produced this output. */
61
+ computedAt: Date;
62
+ }
@@ -0,0 +1,67 @@
1
+ // getLockState — read-only lock-state query.
2
+ //
3
+ // Derives lock state for a contract from its cached paid_through_date (the
4
+ // authoritative value computed by recordPayment via real payment allocation,
5
+ // see src/lockState/incremental.ts). No mutation.
6
+ //
7
+ // Used by:
8
+ // - the worker admin endpoint (GET /admin/lock-state/:contractNumber)
9
+ // - the getLockState RPC (consumers in shadow mode + post-cutover)
10
+ //
11
+ // Why the cache and not a fresh payment-walk: engine_payments has no per-row
12
+ // days_activated. That integer is produced by allocatePayment at record time
13
+ // and folded into engine_contracts.paid_through_date atomically. Re-deriving
14
+ // it here (e.g. freq-per-payment) would ignore partial payments / overpayment
15
+ // and diverge from what the ledger actually wrote. The cache IS the ledger's
16
+ // answer.
17
+
18
+ import { eq } from "drizzle-orm";
19
+ import { engineContracts } from "../schema";
20
+ import { decideLockStateFromPaidThrough } from "../lockState/decide";
21
+ import type {
22
+ ClosureReason,
23
+ LockStateContract,
24
+ LockStateOutput,
25
+ } from "../lockState/types";
26
+ import { ContractNotFoundError, type EngineDb } from "./types";
27
+
28
+ export interface GetLockStateInput {
29
+ contractNumber: string;
30
+ /** Defaults to now. Pass a fixed time for deterministic reads / backtests. */
31
+ asOf?: Date;
32
+ }
33
+
34
+ export async function getLockState(
35
+ db: EngineDb,
36
+ input: GetLockStateInput,
37
+ ): Promise<LockStateOutput> {
38
+ const contract = await db
39
+ .select()
40
+ .from(engineContracts)
41
+ .where(eq(engineContracts.contractNumber, input.contractNumber))
42
+ .get();
43
+
44
+ if (!contract) {
45
+ throw new ContractNotFoundError(input.contractNumber);
46
+ }
47
+
48
+ const lockContract: LockStateContract = {
49
+ id: contract.contractNumber,
50
+ signedAt: contract.signingDate,
51
+ paymentFreqDays: contract.freqDays,
52
+ upfrontDays: 0,
53
+ graceDays: contract.graceDays,
54
+ timezone: contract.timezone,
55
+ totalScheduledPayments: contract.numInstallments,
56
+ closure: (contract.closure as ClosureReason | null) ?? undefined,
57
+ closureAt: contract.closureAt ?? undefined,
58
+ };
59
+
60
+ return decideLockStateFromPaidThrough({
61
+ contract: lockContract,
62
+ paidThroughDate: contract.paidThroughDate
63
+ ? new Date(contract.paidThroughDate)
64
+ : null,
65
+ asOf: input.asOf ?? new Date(),
66
+ });
67
+ }
@@ -14,13 +14,17 @@
14
14
 
15
15
  import { eq } from "drizzle-orm";
16
16
  import { allocatePayment } from "../calc/allocation";
17
+ import { applyPaymentToLockState } from "../lockState/incremental";
18
+ import { lockBoundary } from "../lockState/decide";
17
19
  import {
18
20
  engineContracts,
19
21
  engineEvents,
20
22
  engineInstallments,
23
+ engineLockStateEvents,
21
24
  enginePayments,
22
25
  enginePaymentLines,
23
26
  type NewEngineEvent,
27
+ type NewEngineLockStateEvent,
24
28
  type NewEnginePayment,
25
29
  type NewEnginePaymentLine,
26
30
  } from "../schema";
@@ -149,7 +153,6 @@ export async function recordEvent(
149
153
  const allocation = allocatePayment({
150
154
  paymentAmount: fromCents(input.amountCents),
151
155
  installments: calcInstallments,
152
- minPayment: fromCents(contract.minPaymentCents),
153
156
  recurringPayment: fromCents(contract.recurringCents),
154
157
  freqDays: contract.freqDays,
155
158
  });
@@ -192,6 +195,43 @@ export async function recordEvent(
192
195
  ? "COMPLETED"
193
196
  : "ACTIVE";
194
197
 
198
+ // ----- Lock-state hook ------------------------------------------------
199
+ // Maintain engine_contracts.paid_through_date as a cache and emit a
200
+ // lock-state event in the same atomic batch on any transition. Pure
201
+ // helper — see src/lockState/incremental.ts.
202
+ const lock = applyPaymentToLockState({
203
+ contract: {
204
+ contractNumber: contract.contractNumber,
205
+ signingDate: contract.signingDate,
206
+ freqDays: contract.freqDays,
207
+ graceDays: contract.graceDays,
208
+ timezone: contract.timezone,
209
+ numInstallments: contract.numInstallments,
210
+ paidThroughDate: contract.paidThroughDate,
211
+ lastLockState: contract.lastLockState,
212
+ closure: contract.closure,
213
+ closureAt: contract.closureAt,
214
+ },
215
+ paymentDate: new Date(occurredAt),
216
+ daysActivated: allocation.daysActivated,
217
+ asOf: new Date(ingestedAt),
218
+ });
219
+
220
+ const lockEventRow: NewEngineLockStateEvent | null = lock.transitioned
221
+ ? {
222
+ eventId: ulid(),
223
+ contractNumber: input.contractNumber,
224
+ fromState: lock.fromState,
225
+ toState: lock.newState,
226
+ reason: lock.reason,
227
+ computedAt: ingestedAt,
228
+ paidThroughDate: lock.newPaidThrough.toISOString(),
229
+ nextStateChangeAt: lock.nextStateChangeAt?.toISOString() ?? null,
230
+ trigger: "payment",
231
+ }
232
+ : null;
233
+ // ---------------------------------------------------------------------
234
+
195
235
  const paymentLineRows: NewEnginePaymentLine[] = allocation.allocations.map(
196
236
  (a) => ({
197
237
  transactionId: input.transactionId!,
@@ -219,12 +259,25 @@ export async function recordEvent(
219
259
  daysActivated: newDaysActivated,
220
260
  status: newContractStatus,
221
261
  updatedAt: ingestedAt,
262
+ paidThroughDate: lock.newPaidThrough.toISOString(),
263
+ // Lock boundary cache — kept even when newState is locked (past date),
264
+ // so `date < now` bulk sweeps still see overdue contracts; NULLed when
265
+ // this payment completes the contract (fully paid ≈ terminal — a
266
+ // boundary here could point a sweep at a paid-off device). See
267
+ // lockBoundary() + migration 0004.
268
+ nextStateChangeAt:
269
+ newContractStatus === "COMPLETED"
270
+ ? null
271
+ : lockBoundary(lock.newPaidThrough, contract.graceDays).toISOString(),
272
+ lastLockState: lock.newState,
273
+ lastLockStateAt: ingestedAt,
222
274
  })
223
275
  .where(eq(engineContracts.contractNumber, input.contractNumber));
224
276
 
225
- // 8. Single atomic batch — event log INSERT first, then projection writes.
226
- // Stable user-supplied IDs (eventId, transactionId) used as FKs so no
227
- // mid-batch lookups are needed. D1's batch is atomic per call.
277
+ // 8. Single atomic batch — event log INSERT first, then projection writes,
278
+ // then (optional) lock-state transition event. Stable user-supplied IDs
279
+ // (eventId, transactionId) used as FKs so no mid-batch lookups are needed.
280
+ // D1's batch is atomic per call.
228
281
  const statements = [
229
282
  db.insert(engineEvents).values(eventRow),
230
283
  db.insert(enginePayments).values(paymentRow),
@@ -233,6 +286,9 @@ export async function recordEvent(
233
286
  ),
234
287
  ...installmentUpdates,
235
288
  contractUpdate,
289
+ ...(lockEventRow
290
+ ? [db.insert(engineLockStateEvents).values(lockEventRow)]
291
+ : []),
236
292
  ];
237
293
 
238
294
  await db.batch(statements as unknown as Parameters<typeof db.batch>[0]);