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.
- package/README.md +278 -14
- package/package.json +22 -5
- package/src/calc/allocation.ts +32 -17
- package/src/index.ts +30 -1
- package/src/lockState/decide.ts +180 -0
- package/src/lockState/incremental.ts +94 -0
- package/src/lockState/index.ts +9 -0
- package/src/lockState/types.ts +62 -0
- package/src/operations/getLockState.ts +67 -0
- package/src/operations/recordEvent.ts +309 -0
- package/src/operations/recordPayment.ts +54 -166
- package/src/operations/snapshotSeed.ts +377 -0
- package/src/operations/types.ts +64 -0
- package/src/schema/index.ts +85 -0
- package/src/types.ts +2 -13
- package/src/utils/ulid.ts +36 -0
- package/src/utils/cents.test.ts +0 -76
|
@@ -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,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
|
+
}
|