flexpay-engine 0.2.1 → 0.3.1
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 +154 -0
- package/README.md +310 -14
- package/drizzle/banking/0000_bank_foundation.sql +99 -0
- package/drizzle/banking/0001_archived_at.sql +11 -0
- package/drizzle/engine/0001_lock_state.sql +34 -0
- package/drizzle/engine/0002_paid_through_date.sql +9 -0
- package/drizzle/engine/0003_lock_events_archive.sql +9 -0
- package/drizzle/engine/0004_next_state_change_at.sql +28 -0
- package/package.json +25 -6
- package/src/calc/allocation.ts +42 -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,28 @@
|
|
|
1
|
+
-- Denormalized lock boundary on engine_contracts (FLEXPAY-140 cutover T1).
|
|
2
|
+
-- next_state_change_at = paid_through_date + (grace_days + 1) days
|
|
3
|
+
--
|
|
4
|
+
-- This is the bulk-queryable "next lock date" (Upya nextStatusUpdate
|
|
5
|
+
-- equivalent) for the Postgres projection + enforcement predicates. Unlike
|
|
6
|
+
-- LockStateOutput.nextStateChangeAt (null once locked), the column keeps the
|
|
7
|
+
-- boundary for LOCKED contracts too (a past date) — `date < now` sweep
|
|
8
|
+
-- predicates need overdue contracts to remain visible. NULL only for
|
|
9
|
+
-- provisioning (no paid_through_date), terminal (closure set), and fully-paid
|
|
10
|
+
-- (status COMPLETED) contracts — a boundary on a paid-off contract could point
|
|
11
|
+
-- a sweep at a device that must never lock again.
|
|
12
|
+
--
|
|
13
|
+
-- Truth remains engine_events / engine_lock_state_events; this is a cache,
|
|
14
|
+
-- maintained by recordEvent (same atomic batch as the event row) and
|
|
15
|
+
-- snapshotSeed. The backfill below is idempotent (recomputes the same value)
|
|
16
|
+
-- and safe to re-run.
|
|
17
|
+
|
|
18
|
+
ALTER TABLE engine_contracts ADD COLUMN next_state_change_at TEXT;
|
|
19
|
+
|
|
20
|
+
UPDATE engine_contracts
|
|
21
|
+
SET next_state_change_at = strftime(
|
|
22
|
+
'%Y-%m-%dT%H:%M:%fZ',
|
|
23
|
+
paid_through_date,
|
|
24
|
+
'+' || (grace_days + 1) || ' days'
|
|
25
|
+
)
|
|
26
|
+
WHERE closure IS NULL
|
|
27
|
+
AND status != 'COMPLETED'
|
|
28
|
+
AND paid_through_date IS NOT NULL;
|
package/package.json
CHANGED
|
@@ -1,12 +1,14 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "flexpay-engine",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "FlexPay loan servicing engine
|
|
3
|
+
"version": "0.3.1",
|
|
4
|
+
"description": "FlexPay loan servicing engine \u2014 pricing, schedules, allocation, delinquency, lock-state",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"main": "src/index.ts",
|
|
7
7
|
"types": "src/index.ts",
|
|
8
8
|
"files": [
|
|
9
|
-
"src"
|
|
9
|
+
"src",
|
|
10
|
+
"drizzle",
|
|
11
|
+
"CHANGELOG.md"
|
|
10
12
|
],
|
|
11
13
|
"exports": {
|
|
12
14
|
".": {
|
|
@@ -23,15 +25,32 @@
|
|
|
23
25
|
"scripts": {
|
|
24
26
|
"test": "bun test",
|
|
25
27
|
"test:watch": "bun test --watch",
|
|
26
|
-
"typecheck": "bunx tsc --noEmit"
|
|
28
|
+
"typecheck": "bunx tsc --noEmit",
|
|
29
|
+
"typecheck:worker": "bunx tsc --noEmit -p tsconfig.worker.json",
|
|
30
|
+
"dev": "wrangler dev",
|
|
31
|
+
"deploy:dev": "wrangler deploy",
|
|
32
|
+
"deploy:prod": "wrangler deploy --env prod",
|
|
33
|
+
"db:migrate:dev": "wrangler d1 migrations apply flexpay-engine-dev --remote",
|
|
34
|
+
"db:migrate:prod": "wrangler d1 migrations apply flexpay-engine-prod --remote --env prod",
|
|
35
|
+
"guard:deps": "bash scripts/check-deps.sh",
|
|
36
|
+
"guard:migrations": "bash scripts/check-engine-tables.sh",
|
|
37
|
+
"guard:all": "bun run guard:deps && bun run guard:migrations",
|
|
38
|
+
"probe:bnc": "bun run scripts/probe-bnc-sandbox.ts",
|
|
39
|
+
"ci:verify": "bash scripts/check-deps.sh && bash scripts/check-engine-tables.sh && bun run typecheck && bun run typecheck:worker"
|
|
40
|
+
},
|
|
41
|
+
"dependencies": {
|
|
42
|
+
"@sentry/cloudflare": "^10.20.0",
|
|
43
|
+
"drizzle-orm": "^0.44.7",
|
|
44
|
+
"hono": "^4.6.0"
|
|
27
45
|
},
|
|
28
46
|
"peerDependencies": {
|
|
29
47
|
"drizzle-orm": "^0.44.7"
|
|
30
48
|
},
|
|
31
49
|
"devDependencies": {
|
|
50
|
+
"@cloudflare/workers-types": "^4.20250101.0",
|
|
32
51
|
"@types/bun": "latest",
|
|
33
|
-
"drizzle-orm": "^0.44.7",
|
|
34
52
|
"fast-check": "^3.22.0",
|
|
35
|
-
"typescript": "^5.7.0"
|
|
53
|
+
"typescript": "^5.7.0",
|
|
54
|
+
"wrangler": "^4.0.0"
|
|
36
55
|
}
|
|
37
56
|
}
|
package/src/calc/allocation.ts
CHANGED
|
@@ -3,20 +3,26 @@ import type {
|
|
|
3
3
|
AllocationResult,
|
|
4
4
|
InstallmentAllocation,
|
|
5
5
|
} from "../types";
|
|
6
|
-
import { PaymentBelowMinimumError } from "../types";
|
|
7
6
|
import { round2 } from "./pricing";
|
|
7
|
+
import { toCents } from "../utils/cents";
|
|
8
8
|
|
|
9
9
|
/**
|
|
10
10
|
* Allocate a payment across installments using FIFO (earliest unpaid first).
|
|
11
11
|
*
|
|
12
|
+
* Records the payment as given; it does not judge whether the amount is enough.
|
|
13
|
+
* Sufficiency is an entitlement question, decided from paidThroughDate.
|
|
14
|
+
* See context/specs/2026-07-30-fix-engine-below-minimum-payment.md
|
|
15
|
+
*
|
|
12
16
|
* Rules:
|
|
13
|
-
* -
|
|
17
|
+
* - Records any positive amount
|
|
14
18
|
* - Fills installments in sequence order
|
|
15
19
|
* - Partial payments create PARTIAL status
|
|
16
20
|
* - Overpayment (beyond all installments) is tracked separately
|
|
17
|
-
* - daysActivated =
|
|
21
|
+
* - daysActivated = the delta between cumulative entitlement before and after,
|
|
22
|
+
* NOT this payment floored on its own — see below
|
|
18
23
|
*
|
|
19
24
|
* INVARIANTS:
|
|
25
|
+
* - fragmentation invariance: any split of the same money grants the same days
|
|
20
26
|
* - totalAllocated + overpayment === paymentAmount
|
|
21
27
|
* - installment.amountPaid <= installment.amountDue (never overpay an installment)
|
|
22
28
|
* - newTotalPaid + newRemainingDebt === totalCost
|
|
@@ -27,14 +33,6 @@ export function allocatePayment(input: AllocationInput): AllocationResult {
|
|
|
27
33
|
.filter((i) => i.status !== "PAID" && i.status !== "WAIVED")
|
|
28
34
|
.sort((a, b) => a.sequenceNumber - b.sequenceNumber);
|
|
29
35
|
|
|
30
|
-
// Only enforce minimum on recurring payments (not deposits, not overpayments).
|
|
31
|
-
// Deposits (sequence 0) may be below minPayment for cheap phones / 0% down promos.
|
|
32
|
-
// Overpayments (all installments paid) should flow through regardless of amount.
|
|
33
|
-
const hasUnpaidRecurring = unpaid.some((i) => i.sequenceNumber > 0);
|
|
34
|
-
if (hasUnpaidRecurring && input.paymentAmount < input.minPayment) {
|
|
35
|
-
throw new PaymentBelowMinimumError(input.paymentAmount, input.minPayment);
|
|
36
|
-
}
|
|
37
|
-
|
|
38
36
|
let remaining = input.paymentAmount;
|
|
39
37
|
const allocations: InstallmentAllocation[] = [];
|
|
40
38
|
|
|
@@ -61,12 +59,6 @@ export function allocatePayment(input: AllocationInput): AllocationResult {
|
|
|
61
59
|
|
|
62
60
|
const totalAllocated = round2(input.paymentAmount - remaining);
|
|
63
61
|
|
|
64
|
-
// Days activated: proportional to payment relative to recurring
|
|
65
|
-
const daysActivated =
|
|
66
|
-
input.recurringPayment > 0
|
|
67
|
-
? Math.floor((totalAllocated / input.recurringPayment) * input.freqDays)
|
|
68
|
-
: 0;
|
|
69
|
-
|
|
70
62
|
// Calculate new totals from installment state
|
|
71
63
|
const currentTotalPaid = input.installments.reduce(
|
|
72
64
|
(sum, i) => sum + i.amountPaid,
|
|
@@ -79,6 +71,39 @@ export function allocatePayment(input: AllocationInput): AllocationResult {
|
|
|
79
71
|
const newTotalPaid = round2(currentTotalPaid + totalAllocated);
|
|
80
72
|
const newRemainingDebt = round2(totalCost - newTotalPaid);
|
|
81
73
|
|
|
74
|
+
// Days activated: the DELTA between cumulative entitlement before and after.
|
|
75
|
+
//
|
|
76
|
+
// This used to floor each payment on its own — and floor() discards the
|
|
77
|
+
// fraction every single time, so paying one installment in pieces granted
|
|
78
|
+
// fewer days than paying it at once. $52 as 52 x $1 granted ZERO days,
|
|
79
|
+
// because floor(1/52 * 15) === 0 each time.
|
|
80
|
+
//
|
|
81
|
+
// That was unreachable while allocation rejected below-minimum payments.
|
|
82
|
+
// Removing the rule made it reachable, and daysActivated feeds
|
|
83
|
+
// paidThroughDate (recordEvent -> applyPaymentToLockState), which is the
|
|
84
|
+
// engine's lock decision. A customer paying in full, in instalments, would
|
|
85
|
+
// have been locked for not paying.
|
|
86
|
+
//
|
|
87
|
+
// Deltas off a cumulative floor telescope, so any split of the same money
|
|
88
|
+
// sums to the same days. Deposit and overpayment behaviour are unchanged:
|
|
89
|
+
// the deposit still earns on the same ratio, and overpayment is excluded
|
|
90
|
+
// because it never enters totalAllocated.
|
|
91
|
+
// INTEGER CENTS, not floats. `newTotalPaid` is round2()'d but `currentTotalPaid`
|
|
92
|
+
// is a raw sum of installment amounts, so comparing their floors compared a
|
|
93
|
+
// rounded value against an unrounded one. Measured: 474 of 1260 accumulation
|
|
94
|
+
// points diverge — e.g. recurring 3.35 after 13 payments sums to
|
|
95
|
+
// 43.55000000000001 (floor 91) where the previous call stored 43.55 (floor 90).
|
|
96
|
+
// A day appears or vanishes purely from binary representation, and it lands on
|
|
97
|
+
// paidThroughDate. Cents make both terms exact, so the subtraction is honest and
|
|
98
|
+
// the result can never be negative.
|
|
99
|
+
const recurringCents = toCents(input.recurringPayment);
|
|
100
|
+
const cumulativeDays = (total: number) =>
|
|
101
|
+
recurringCents > 0
|
|
102
|
+
? Math.floor((toCents(total) * input.freqDays) / recurringCents)
|
|
103
|
+
: 0;
|
|
104
|
+
const daysActivated =
|
|
105
|
+
cumulativeDays(newTotalPaid) - cumulativeDays(currentTotalPaid);
|
|
106
|
+
|
|
82
107
|
// Find next due date: first installment that will still be unpaid after allocation
|
|
83
108
|
const paidIds = new Set(
|
|
84
109
|
allocations.filter((a) => a.newStatus === "PAID").map((a) => a.installmentId),
|
package/src/index.ts
CHANGED
|
@@ -9,11 +9,33 @@ export { allocatePayment } from "./calc/allocation";
|
|
|
9
9
|
export { checkDelinquency } from "./calc/delinquency";
|
|
10
10
|
export { computeDaysActivated } from "./calc/daysActivated";
|
|
11
11
|
|
|
12
|
+
// --- Lock-state decision (replaces Upya nextStatusUpdate) ---
|
|
13
|
+
export { decideLockState } from "./lockState/decide";
|
|
14
|
+
export type {
|
|
15
|
+
LockState,
|
|
16
|
+
ClosureReason,
|
|
17
|
+
LockStateContract,
|
|
18
|
+
LockStatePayment,
|
|
19
|
+
LockStateInput,
|
|
20
|
+
LockStateOutput,
|
|
21
|
+
} from "./lockState/types";
|
|
22
|
+
|
|
12
23
|
// --- Operations layer (D1-backed) ---
|
|
13
24
|
export { originateContract } from "./operations/originate";
|
|
14
25
|
export { recordEvent } from "./operations/recordEvent";
|
|
15
26
|
export { recordPayment } from "./operations/recordPayment";
|
|
16
27
|
export { seedContract } from "./operations/seedContract";
|
|
28
|
+
export {
|
|
29
|
+
snapshotSeedContract,
|
|
30
|
+
planSnapshotSeed,
|
|
31
|
+
} from "./operations/snapshotSeed";
|
|
32
|
+
export type {
|
|
33
|
+
SnapshotSeedInput,
|
|
34
|
+
SnapshotSeedPlan,
|
|
35
|
+
SnapshotSeedResult,
|
|
36
|
+
} from "./operations/snapshotSeed";
|
|
37
|
+
export { getLockState } from "./operations/getLockState";
|
|
38
|
+
export type { GetLockStateInput } from "./operations/getLockState";
|
|
17
39
|
|
|
18
40
|
// --- Utilities ---
|
|
19
41
|
export { ulid } from "./utils/ulid";
|
|
@@ -59,7 +81,6 @@ export type {
|
|
|
59
81
|
// --- Errors ---
|
|
60
82
|
export {
|
|
61
83
|
LoanEngineError,
|
|
62
|
-
PaymentBelowMinimumError,
|
|
63
84
|
ContractAlreadyPaidOffError,
|
|
64
85
|
} from "./types";
|
|
65
86
|
|
|
@@ -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
|
+
}
|