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.
- package/README.md +278 -14
- package/package.json +22 -5
- package/src/calc/allocation.ts +32 -17
- package/src/index.ts +22 -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 +60 -4
- package/src/operations/snapshotSeed.ts +377 -0
- package/src/schema/index.ts +53 -0
- package/src/types.ts +2 -13
- package/src/utils/cents.test.ts +0 -76
|
@@ -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
|
+
}
|
|
@@ -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
|
-
//
|
|
227
|
-
// mid-batch lookups are needed.
|
|
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]);
|