flexpay-engine 0.2.0 → 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,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
+ }
@@ -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
+ }