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,377 @@
|
|
|
1
|
+
// ============================================================
|
|
2
|
+
// snapshotSeedContract — Seed an existing contract from Upya's snapshot
|
|
3
|
+
// ============================================================
|
|
4
|
+
//
|
|
5
|
+
// Payments-truth cutover S1 (plan: context/specs/2026-07-11-payments-truth-
|
|
6
|
+
// implementation-plan.md, rows S1/S2, ruling §H1).
|
|
7
|
+
//
|
|
8
|
+
// UNLIKE seedContract (which REPLAYS history through the engine's day-formula),
|
|
9
|
+
// this seeds state DIRECTLY from Upya's authoritative snapshot, because replay
|
|
10
|
+
// diverges from Upya universally (+8.5d median) and next_status_update is
|
|
11
|
+
// path-dependent (only 25.5% reconstructable). Snapshot dissolves that by
|
|
12
|
+
// construction, setting BOTH coupled dimensions:
|
|
13
|
+
//
|
|
14
|
+
// 1. TIME — paid_through_date := Upya.next_status_update (verbatim).
|
|
15
|
+
// Locks match Upya EXACTLY at cutover. NOT derived from day-math.
|
|
16
|
+
// 2. MONEY — FIFO-allocate Upya.total_paid across the derived schedule, so the
|
|
17
|
+
// NEXT real payment lands on the correct installment + paid_off works.
|
|
18
|
+
//
|
|
19
|
+
// No historical replay. One synthetic `seed_snapshot` event (skipped by the W1
|
|
20
|
+
// projector's event_type='payment_recorded' filter, so it never double-posts
|
|
21
|
+
// into MAIN — MAIN already holds the historical payments). No engine_payments /
|
|
22
|
+
// payment_lines rows: a snapshot has no real transaction to trace; the money
|
|
23
|
+
// dimension lives on installment.amount_paid + contract.total_paid.
|
|
24
|
+
//
|
|
25
|
+
// INSERT-only: throws if the contract already exists. Re-seed policy (for the
|
|
26
|
+
// ~902 stale lazy-replay contracts) is the S2 orchestrator's job, not the
|
|
27
|
+
// primitive's. NOT gated on dealOptionId — caller supplies flat pricing inputs
|
|
28
|
+
// derived from a deal_option OR from W3a legacyPricing for deal-less contracts.
|
|
29
|
+
|
|
30
|
+
import { eq } from "drizzle-orm";
|
|
31
|
+
import { calculatePricing } from "../calc/pricing";
|
|
32
|
+
import { generateSchedule } from "../calc/schedule";
|
|
33
|
+
import { allocatePayment } from "../calc/allocation";
|
|
34
|
+
import { decideLockStateFromPaidThrough, lockBoundary } from "../lockState/decide";
|
|
35
|
+
import type { InstallmentState, InstallmentStatus } from "../types";
|
|
36
|
+
import type { LockStateContract } from "../lockState/types";
|
|
37
|
+
import {
|
|
38
|
+
engineContracts,
|
|
39
|
+
engineEvents,
|
|
40
|
+
engineInstallments,
|
|
41
|
+
engineLockStateEvents,
|
|
42
|
+
type NewEngineContract,
|
|
43
|
+
type NewEngineEvent,
|
|
44
|
+
type NewEngineInstallment,
|
|
45
|
+
type NewEngineLockStateEvent,
|
|
46
|
+
} from "../schema";
|
|
47
|
+
import { toCents } from "../utils/cents";
|
|
48
|
+
import { ulid } from "../utils/ulid";
|
|
49
|
+
import {
|
|
50
|
+
EngineOperationError,
|
|
51
|
+
EngineSeedError,
|
|
52
|
+
type EngineDb,
|
|
53
|
+
} from "./types";
|
|
54
|
+
|
|
55
|
+
const DEFAULT_TIMEZONE = "America/Caracas";
|
|
56
|
+
|
|
57
|
+
/** Everything the primitive needs to snapshot-seed one contract. */
|
|
58
|
+
export interface SnapshotSeedInput {
|
|
59
|
+
contractNumber: string;
|
|
60
|
+
profileId: string | null;
|
|
61
|
+
storeId: number | null;
|
|
62
|
+
/** NOT gated — null for deal-less legacy contracts (schedule derived by caller). */
|
|
63
|
+
dealOptionId: number | null;
|
|
64
|
+
|
|
65
|
+
// Flat pricing inputs — from a deal_option snapshot OR W3a legacyPricing.
|
|
66
|
+
basePrice: number;
|
|
67
|
+
markupPct: number;
|
|
68
|
+
downPaymentPct: number;
|
|
69
|
+
numInstallments: number;
|
|
70
|
+
freqDays: number;
|
|
71
|
+
minPaymentDivisor: number;
|
|
72
|
+
signingDate: Date;
|
|
73
|
+
|
|
74
|
+
// Lock config (optional — engine defaults).
|
|
75
|
+
graceDays?: number;
|
|
76
|
+
timezone?: string;
|
|
77
|
+
|
|
78
|
+
/** TIME dimension: paid_through_date is set to this verbatim. Upya's nsu. */
|
|
79
|
+
nextStatusUpdate: Date;
|
|
80
|
+
/** MONEY dimension: FIFO-allocated across the schedule. Dollars (Upya total_paid). */
|
|
81
|
+
totalPaid: number;
|
|
82
|
+
|
|
83
|
+
/** Wall-clock for the lock-state decision. Defaults to now(). */
|
|
84
|
+
asOf?: Date;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Pre-computed rows for one snapshot seed — pure, no I/O. */
|
|
88
|
+
export interface SnapshotSeedPlan {
|
|
89
|
+
contractRow: NewEngineContract;
|
|
90
|
+
installmentRows: NewEngineInstallment[];
|
|
91
|
+
eventRow: NewEngineEvent;
|
|
92
|
+
lockEventRow: NewEngineLockStateEvent;
|
|
93
|
+
summary: {
|
|
94
|
+
contractNumber: string;
|
|
95
|
+
paidThroughDate: string;
|
|
96
|
+
lockState: string;
|
|
97
|
+
totalPaidCents: number;
|
|
98
|
+
numInstallments: number;
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export type SnapshotSeedResult = SnapshotSeedPlan["summary"];
|
|
103
|
+
|
|
104
|
+
function isValidDate(d: unknown): d is Date {
|
|
105
|
+
return d instanceof Date && !Number.isNaN(d.getTime());
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Pure: compute every row a snapshot seed writes. No DB access.
|
|
110
|
+
* Exhaustively unit-tested; the persist wrapper is thin glue.
|
|
111
|
+
*/
|
|
112
|
+
export function planSnapshotSeed(input: SnapshotSeedInput): SnapshotSeedPlan {
|
|
113
|
+
// ---- Validation (NOT dealOptionId — deal-less is legal) ----
|
|
114
|
+
if (input.numInstallments <= 0) {
|
|
115
|
+
throw new EngineSeedError(
|
|
116
|
+
input.contractNumber,
|
|
117
|
+
`invalid numInstallments: ${input.numInstallments}`,
|
|
118
|
+
);
|
|
119
|
+
}
|
|
120
|
+
if (!Number.isFinite(input.basePrice) || input.basePrice <= 0) {
|
|
121
|
+
throw new EngineSeedError(
|
|
122
|
+
input.contractNumber,
|
|
123
|
+
`invalid basePrice: ${input.basePrice}`,
|
|
124
|
+
);
|
|
125
|
+
}
|
|
126
|
+
if (!isValidDate(input.nextStatusUpdate)) {
|
|
127
|
+
throw new EngineSeedError(
|
|
128
|
+
input.contractNumber,
|
|
129
|
+
"nextStatusUpdate is not a valid date",
|
|
130
|
+
);
|
|
131
|
+
}
|
|
132
|
+
if (!isValidDate(input.signingDate)) {
|
|
133
|
+
throw new EngineSeedError(
|
|
134
|
+
input.contractNumber,
|
|
135
|
+
"signingDate is not a valid date",
|
|
136
|
+
);
|
|
137
|
+
}
|
|
138
|
+
if (!Number.isFinite(input.totalPaid) || input.totalPaid < 0) {
|
|
139
|
+
throw new EngineSeedError(
|
|
140
|
+
input.contractNumber,
|
|
141
|
+
`invalid totalPaid: ${input.totalPaid}`,
|
|
142
|
+
);
|
|
143
|
+
}
|
|
144
|
+
if (!Number.isInteger(input.freqDays) || input.freqDays <= 0) {
|
|
145
|
+
// freqDays <= 0 collapses the schedule onto one date + degenerates day-math.
|
|
146
|
+
throw new EngineSeedError(
|
|
147
|
+
input.contractNumber,
|
|
148
|
+
`invalid freqDays: ${input.freqDays}`,
|
|
149
|
+
);
|
|
150
|
+
}
|
|
151
|
+
if (input.numInstallments > 600) {
|
|
152
|
+
// Real terms are <= ~52; bound the batch statement count (D1 limit).
|
|
153
|
+
throw new EngineSeedError(
|
|
154
|
+
input.contractNumber,
|
|
155
|
+
`numInstallments too large: ${input.numInstallments}`,
|
|
156
|
+
);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const asOf = input.asOf ?? new Date();
|
|
160
|
+
const graceDays = input.graceDays ?? 0;
|
|
161
|
+
const timezone = input.timezone ?? DEFAULT_TIMEZONE;
|
|
162
|
+
const nowIso = asOf.toISOString();
|
|
163
|
+
|
|
164
|
+
// ---- 1. Pure pricing + schedule ----
|
|
165
|
+
const pricing = calculatePricing({
|
|
166
|
+
basePrice: input.basePrice,
|
|
167
|
+
markupPct: input.markupPct,
|
|
168
|
+
downPaymentPct: input.downPaymentPct,
|
|
169
|
+
numInstallments: input.numInstallments,
|
|
170
|
+
minPaymentDivisor: input.minPaymentDivisor,
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
const schedule = generateSchedule({
|
|
174
|
+
signingDate: input.signingDate,
|
|
175
|
+
downPayment: pricing.downPayment,
|
|
176
|
+
recurringPayment: pricing.recurringPayment,
|
|
177
|
+
numInstallments: input.numInstallments,
|
|
178
|
+
freqDays: input.freqDays,
|
|
179
|
+
financedAmount: pricing.financedAmount,
|
|
180
|
+
totalCost: pricing.totalCost,
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
// ---- 2. FIFO-allocate the MONEY dimension across the schedule ----
|
|
184
|
+
// Key installments by sequenceNumber (autoincrement ids don't exist pre-insert;
|
|
185
|
+
// a snapshot writes no payment_lines, so sequenceNumber is a sufficient key).
|
|
186
|
+
// (The minPayment=0 exemption this used to pass is gone — allocation no longer
|
|
187
|
+
// gates on amount at all.)
|
|
188
|
+
const calcInstallments: InstallmentState[] = schedule.map((inst) => ({
|
|
189
|
+
id: inst.sequenceNumber,
|
|
190
|
+
sequenceNumber: inst.sequenceNumber,
|
|
191
|
+
amountDue: inst.amountDue,
|
|
192
|
+
amountPaid: 0,
|
|
193
|
+
status: "PENDING" as InstallmentStatus,
|
|
194
|
+
dueDate: inst.dueDate,
|
|
195
|
+
}));
|
|
196
|
+
|
|
197
|
+
const allocation = allocatePayment({
|
|
198
|
+
paymentAmount: input.totalPaid,
|
|
199
|
+
installments: calcInstallments,
|
|
200
|
+
recurringPayment: pricing.recurringPayment,
|
|
201
|
+
freqDays: input.freqDays,
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
const paidBySeq = new Map(
|
|
205
|
+
allocation.allocations.map((a) => [
|
|
206
|
+
a.sequenceNumber,
|
|
207
|
+
{ amountPaid: a.newAmountPaid, status: a.newStatus },
|
|
208
|
+
]),
|
|
209
|
+
);
|
|
210
|
+
|
|
211
|
+
const installmentRows: NewEngineInstallment[] = schedule.map((inst) => {
|
|
212
|
+
const paid = paidBySeq.get(inst.sequenceNumber);
|
|
213
|
+
return {
|
|
214
|
+
contractNumber: input.contractNumber,
|
|
215
|
+
sequenceNumber: inst.sequenceNumber,
|
|
216
|
+
dueDate: inst.dueDate.toISOString(),
|
|
217
|
+
amountDueCents: toCents(inst.amountDue),
|
|
218
|
+
amountPaidCents: paid ? toCents(paid.amountPaid) : 0,
|
|
219
|
+
status: paid ? paid.status : "PENDING",
|
|
220
|
+
};
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
// ---- 3. TIME dimension: paid_through := nsu verbatim ----
|
|
224
|
+
const paidThrough = input.nextStatusUpdate;
|
|
225
|
+
const paidThroughIso = paidThrough.toISOString();
|
|
226
|
+
|
|
227
|
+
// ---- 4. Lock state from the snapshot's paid_through ----
|
|
228
|
+
// A fully-paid snapshot is paid_off — it must NOT derive 'locked' from a past
|
|
229
|
+
// nsu (a completed contract is never locked). Closure short-circuits decide's
|
|
230
|
+
// time classification, keeping status + lock state consistent.
|
|
231
|
+
const fullyPaid = allocation.contractFullyPaid;
|
|
232
|
+
const lockContract: LockStateContract = {
|
|
233
|
+
id: input.contractNumber,
|
|
234
|
+
signedAt: input.signingDate.toISOString(),
|
|
235
|
+
paymentFreqDays: input.freqDays,
|
|
236
|
+
upfrontDays: 0,
|
|
237
|
+
graceDays,
|
|
238
|
+
timezone,
|
|
239
|
+
totalScheduledPayments: input.numInstallments,
|
|
240
|
+
closure: fullyPaid ? "paid_off" : undefined,
|
|
241
|
+
closureAt: fullyPaid ? nowIso : undefined,
|
|
242
|
+
};
|
|
243
|
+
const lock = decideLockStateFromPaidThrough({
|
|
244
|
+
contract: lockContract,
|
|
245
|
+
paidThroughDate: paidThrough,
|
|
246
|
+
asOf,
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
const totalPaidCents = toCents(allocation.newTotalPaid);
|
|
250
|
+
const remainingCents = toCents(allocation.newRemainingDebt);
|
|
251
|
+
const totalCostCents = toCents(pricing.totalCost);
|
|
252
|
+
|
|
253
|
+
const contractRow: NewEngineContract = {
|
|
254
|
+
contractNumber: input.contractNumber,
|
|
255
|
+
profileId: input.profileId,
|
|
256
|
+
storeId: input.storeId,
|
|
257
|
+
dealOptionId: input.dealOptionId,
|
|
258
|
+
totalCostCents,
|
|
259
|
+
downPaymentCents: toCents(pricing.downPayment),
|
|
260
|
+
financedCents: toCents(pricing.financedAmount),
|
|
261
|
+
recurringCents: toCents(pricing.recurringPayment),
|
|
262
|
+
minPaymentCents: toCents(pricing.minPayment),
|
|
263
|
+
numInstallments: input.numInstallments,
|
|
264
|
+
freqDays: input.freqDays,
|
|
265
|
+
signingDate: input.signingDate.toISOString(),
|
|
266
|
+
status: fullyPaid ? "COMPLETED" : "ACTIVE",
|
|
267
|
+
totalPaidCents,
|
|
268
|
+
remainingCents,
|
|
269
|
+
daysActivated: allocation.daysActivated,
|
|
270
|
+
graceDays,
|
|
271
|
+
timezone,
|
|
272
|
+
closure: fullyPaid ? "paid_off" : null,
|
|
273
|
+
closureAt: fullyPaid ? nowIso : null,
|
|
274
|
+
closureReason: fullyPaid ? "seed_snapshot_paid_off" : null,
|
|
275
|
+
lastLockState: lock.state,
|
|
276
|
+
lastLockStateAt: nowIso,
|
|
277
|
+
paidThroughDate: paidThroughIso,
|
|
278
|
+
// Lock-boundary cache (see lockBoundary() + migration 0004): defined
|
|
279
|
+
// whenever paidThrough exists and the contract isn't terminal.
|
|
280
|
+
nextStateChangeAt:
|
|
281
|
+
paidThroughIso && !fullyPaid
|
|
282
|
+
? lockBoundary(new Date(paidThroughIso), graceDays).toISOString()
|
|
283
|
+
: null,
|
|
284
|
+
seededFrom: "upya",
|
|
285
|
+
createdAt: nowIso,
|
|
286
|
+
updatedAt: nowIso,
|
|
287
|
+
};
|
|
288
|
+
|
|
289
|
+
// ---- 5. ONE synthetic seed_snapshot event (projector skips it) ----
|
|
290
|
+
const eventRow: NewEngineEvent = {
|
|
291
|
+
eventId: ulid(asOf.getTime()),
|
|
292
|
+
eventType: "seed_snapshot",
|
|
293
|
+
contractNumber: input.contractNumber,
|
|
294
|
+
source: "legacy_backfill",
|
|
295
|
+
sourceRef: null,
|
|
296
|
+
amountCents: totalPaidCents,
|
|
297
|
+
occurredAt: nowIso,
|
|
298
|
+
ingestedAt: nowIso,
|
|
299
|
+
actorId: null,
|
|
300
|
+
payloadJson: JSON.stringify({
|
|
301
|
+
snapshot: "upya",
|
|
302
|
+
nextStatusUpdate: paidThroughIso,
|
|
303
|
+
totalPaidCents,
|
|
304
|
+
lockState: lock.state,
|
|
305
|
+
}),
|
|
306
|
+
idempotencyKey: `legacy_backfill:${input.contractNumber}`,
|
|
307
|
+
};
|
|
308
|
+
|
|
309
|
+
// ---- 6. Lock-state transition event (null → seeded state) ----
|
|
310
|
+
const lockEventRow: NewEngineLockStateEvent = {
|
|
311
|
+
eventId: ulid(asOf.getTime()),
|
|
312
|
+
contractNumber: input.contractNumber,
|
|
313
|
+
fromState: null,
|
|
314
|
+
toState: lock.state,
|
|
315
|
+
reason: lock.reason,
|
|
316
|
+
computedAt: nowIso,
|
|
317
|
+
paidThroughDate: paidThroughIso,
|
|
318
|
+
nextStateChangeAt: lock.nextStateChangeAt?.toISOString() ?? null,
|
|
319
|
+
trigger: "seed",
|
|
320
|
+
};
|
|
321
|
+
|
|
322
|
+
return {
|
|
323
|
+
contractRow,
|
|
324
|
+
installmentRows,
|
|
325
|
+
eventRow,
|
|
326
|
+
lockEventRow,
|
|
327
|
+
summary: {
|
|
328
|
+
contractNumber: input.contractNumber,
|
|
329
|
+
paidThroughDate: paidThroughIso,
|
|
330
|
+
lockState: lock.state,
|
|
331
|
+
totalPaidCents,
|
|
332
|
+
numInstallments: input.numInstallments,
|
|
333
|
+
},
|
|
334
|
+
};
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/**
|
|
338
|
+
* Snapshot-seed one contract into the engine ledger, atomically.
|
|
339
|
+
*
|
|
340
|
+
* INSERT-only: throws EngineOperationError('CONTRACT_EXISTS') if the contract
|
|
341
|
+
* already exists in engine D1. Re-seed / wipe policy is the S2 orchestrator's
|
|
342
|
+
* responsibility, kept out of the primitive so it stays pure + atomic.
|
|
343
|
+
*/
|
|
344
|
+
export async function snapshotSeedContract(
|
|
345
|
+
db: EngineDb,
|
|
346
|
+
input: SnapshotSeedInput,
|
|
347
|
+
): Promise<SnapshotSeedResult> {
|
|
348
|
+
const existing = await db
|
|
349
|
+
.select({ contractNumber: engineContracts.contractNumber })
|
|
350
|
+
.from(engineContracts)
|
|
351
|
+
.where(eq(engineContracts.contractNumber, input.contractNumber))
|
|
352
|
+
.get();
|
|
353
|
+
|
|
354
|
+
if (existing) {
|
|
355
|
+
throw new EngineOperationError(
|
|
356
|
+
`Contract ${input.contractNumber} already exists in engine ledger — re-seed is the caller's responsibility`,
|
|
357
|
+
"CONTRACT_EXISTS",
|
|
358
|
+
);
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
const plan = planSnapshotSeed(input);
|
|
362
|
+
|
|
363
|
+
// Atomic batch: contract + installments + seed event + lock event.
|
|
364
|
+
// Individual inserts (D1 bound-parameter limit is 100/statement).
|
|
365
|
+
const statements = [
|
|
366
|
+
db.insert(engineContracts).values(plan.contractRow),
|
|
367
|
+
...plan.installmentRows.map((row) =>
|
|
368
|
+
db.insert(engineInstallments).values(row),
|
|
369
|
+
),
|
|
370
|
+
db.insert(engineEvents).values(plan.eventRow),
|
|
371
|
+
db.insert(engineLockStateEvents).values(plan.lockEventRow),
|
|
372
|
+
];
|
|
373
|
+
|
|
374
|
+
await db.batch(statements as unknown as Parameters<typeof db.batch>[0]);
|
|
375
|
+
|
|
376
|
+
return plan.summary;
|
|
377
|
+
}
|
package/src/schema/index.ts
CHANGED
|
@@ -38,12 +38,63 @@ export const engineContracts = sqliteTable("engine_contracts", {
|
|
|
38
38
|
remainingCents: integer("remaining_cents").notNull().default(0),
|
|
39
39
|
daysActivated: integer("days_activated").notNull().default(0),
|
|
40
40
|
|
|
41
|
+
// Lock-state config (see spec 2026-05-22-feat-lock-state-decision)
|
|
42
|
+
graceDays: integer("grace_days").notNull().default(0),
|
|
43
|
+
timezone: text("timezone").notNull().default("America/Caracas"),
|
|
44
|
+
|
|
45
|
+
// Closure — set by closeContract op, absorbing terminal state
|
|
46
|
+
closure: text("closure"), // 'paid_off' | 'repossessed' | 'written_off' | NULL
|
|
47
|
+
closureAt: text("closure_at"),
|
|
48
|
+
closureReason: text("closure_reason"),
|
|
49
|
+
|
|
50
|
+
// Lock-state read cache (truth is engine_lock_state_events)
|
|
51
|
+
lastLockState: text("last_lock_state"),
|
|
52
|
+
lastLockStateAt: text("last_lock_state_at"),
|
|
53
|
+
/** Cumulative paid-through cache. max(prev, paymentDay) + daysActivated. */
|
|
54
|
+
paidThroughDate: text("paid_through_date"),
|
|
55
|
+
/**
|
|
56
|
+
* Denormalized lock boundary: paidThrough + graceDays + 1 day (see
|
|
57
|
+
* lockBoundary() in lockState/decide.ts). Kept even when locked (past
|
|
58
|
+
* date) so bulk `date < now` sweeps can find overdue contracts. NULL for
|
|
59
|
+
* provisioning (no paidThrough) and terminal (closure set) contracts.
|
|
60
|
+
*/
|
|
61
|
+
nextStateChangeAt: text("next_state_change_at"),
|
|
62
|
+
|
|
41
63
|
// Provenance
|
|
42
64
|
seededFrom: text("seeded_from").notNull(), // 'supabase' | 'upya' | 'origination'
|
|
43
65
|
createdAt: text("created_at").notNull(), // ISO 8601
|
|
44
66
|
updatedAt: text("updated_at").notNull(), // ISO 8601
|
|
45
67
|
});
|
|
46
68
|
|
|
69
|
+
// --- Lock-state events (APPEND-ONLY) -------------------------
|
|
70
|
+
//
|
|
71
|
+
// One row per state transition. The decision function reads contracts +
|
|
72
|
+
// payments + computes state; this table records when state changed.
|
|
73
|
+
// R2-mirrored via the existing archive pipeline.
|
|
74
|
+
|
|
75
|
+
export const engineLockStateEvents = sqliteTable(
|
|
76
|
+
"engine_lock_state_events",
|
|
77
|
+
{
|
|
78
|
+
id: integer("id").primaryKey({ autoIncrement: true }),
|
|
79
|
+
eventId: text("event_id").notNull().unique(), // ULID
|
|
80
|
+
contractNumber: text("contract_number")
|
|
81
|
+
.notNull()
|
|
82
|
+
.references(() => engineContracts.contractNumber),
|
|
83
|
+
fromState: text("from_state"), // NULL on first computation
|
|
84
|
+
toState: text("to_state").notNull(),
|
|
85
|
+
reason: text("reason").notNull(),
|
|
86
|
+
computedAt: text("computed_at").notNull(),
|
|
87
|
+
paidThroughDate: text("paid_through_date"),
|
|
88
|
+
nextStateChangeAt: text("next_state_change_at"),
|
|
89
|
+
trigger: text("trigger").notNull(), // 'payment' | 'cron' | 'rpc' | 'origination' | 'closure'
|
|
90
|
+
archivedAt: text("archived_at"), // R2 mirror timestamp; NULL until swept
|
|
91
|
+
},
|
|
92
|
+
(t) => ({
|
|
93
|
+
contractIdx: index("idx_lock_events_contract").on(t.contractNumber, t.computedAt),
|
|
94
|
+
stateIdx: index("idx_lock_events_state").on(t.toState, t.computedAt),
|
|
95
|
+
}),
|
|
96
|
+
);
|
|
97
|
+
|
|
47
98
|
// --- Installments --------------------------------------------
|
|
48
99
|
|
|
49
100
|
export const engineInstallments = sqliteTable(
|
|
@@ -155,3 +206,5 @@ export type EnginePaymentLine = typeof enginePaymentLines.$inferSelect;
|
|
|
155
206
|
export type NewEnginePaymentLine = typeof enginePaymentLines.$inferInsert;
|
|
156
207
|
export type EngineEvent = typeof engineEvents.$inferSelect;
|
|
157
208
|
export type NewEngineEvent = typeof engineEvents.$inferInsert;
|
|
209
|
+
export type EngineLockStateEvent = typeof engineLockStateEvents.$inferSelect;
|
|
210
|
+
export type NewEngineLockStateEvent = typeof engineLockStateEvents.$inferInsert;
|
package/src/types.ts
CHANGED
|
@@ -59,7 +59,8 @@ export type InstallmentStatus =
|
|
|
59
59
|
export interface AllocationInput {
|
|
60
60
|
paymentAmount: number;
|
|
61
61
|
installments: InstallmentState[];
|
|
62
|
-
minPayment:
|
|
62
|
+
// No minPayment: allocation records, it does not gate. The figure lives on
|
|
63
|
+
// PricingResult and engine_contracts.min_payment_cents as quoting data.
|
|
63
64
|
recurringPayment: number;
|
|
64
65
|
freqDays: number;
|
|
65
66
|
}
|
|
@@ -121,18 +122,6 @@ export class LoanEngineError extends Error {
|
|
|
121
122
|
}
|
|
122
123
|
}
|
|
123
124
|
|
|
124
|
-
export class PaymentBelowMinimumError extends LoanEngineError {
|
|
125
|
-
constructor(
|
|
126
|
-
public readonly paymentAmount: number,
|
|
127
|
-
public readonly minPayment: number,
|
|
128
|
-
) {
|
|
129
|
-
super(
|
|
130
|
-
`Payment ${paymentAmount} is below minimum ${minPayment}`,
|
|
131
|
-
"PAYMENT_BELOW_MINIMUM",
|
|
132
|
-
);
|
|
133
|
-
}
|
|
134
|
-
}
|
|
135
|
-
|
|
136
125
|
export class ContractAlreadyPaidOffError extends LoanEngineError {
|
|
137
126
|
constructor() {
|
|
138
127
|
super("Contract is already paid off", "CONTRACT_ALREADY_PAIDOFF");
|
package/src/utils/cents.test.ts
DELETED
|
@@ -1,76 +0,0 @@
|
|
|
1
|
-
import { describe, test, expect } from "bun:test";
|
|
2
|
-
import { toCents, fromCents, formatCents } from "./cents";
|
|
3
|
-
|
|
4
|
-
describe("toCents", () => {
|
|
5
|
-
test("converts basic decimals", () => {
|
|
6
|
-
expect(toCents(16.15)).toBe(1615);
|
|
7
|
-
expect(toCents(0)).toBe(0);
|
|
8
|
-
expect(toCents(1)).toBe(100);
|
|
9
|
-
expect(toCents(100)).toBe(10000);
|
|
10
|
-
});
|
|
11
|
-
|
|
12
|
-
test("handles string input (from DB numeric columns)", () => {
|
|
13
|
-
expect(toCents("16.15")).toBe(1615);
|
|
14
|
-
expect(toCents("196.80")).toBe(19680);
|
|
15
|
-
expect(toCents("0")).toBe(0);
|
|
16
|
-
});
|
|
17
|
-
|
|
18
|
-
test("handles the float precision edge cases", () => {
|
|
19
|
-
// 0.1 + 0.2 === 0.30000000000000004 in IEEE 754
|
|
20
|
-
expect(toCents(0.1 + 0.2)).toBe(30);
|
|
21
|
-
expect(toCents(1.005)).toBe(101); // rounds up correctly
|
|
22
|
-
expect(toCents(196.80000019073486)).toBe(19680); // real incident value
|
|
23
|
-
});
|
|
24
|
-
|
|
25
|
-
test("handles null and undefined gracefully", () => {
|
|
26
|
-
expect(toCents(null)).toBe(0);
|
|
27
|
-
expect(toCents(undefined)).toBe(0);
|
|
28
|
-
});
|
|
29
|
-
|
|
30
|
-
test("throws on invalid input", () => {
|
|
31
|
-
expect(() => toCents(NaN)).toThrow();
|
|
32
|
-
expect(() => toCents(Infinity)).toThrow();
|
|
33
|
-
expect(() => toCents("not a number")).toThrow();
|
|
34
|
-
});
|
|
35
|
-
|
|
36
|
-
test("handles negative values (for reversals)", () => {
|
|
37
|
-
expect(toCents(-16.15)).toBe(-1615);
|
|
38
|
-
expect(toCents(-0.01)).toBe(-1);
|
|
39
|
-
});
|
|
40
|
-
});
|
|
41
|
-
|
|
42
|
-
describe("fromCents", () => {
|
|
43
|
-
test("converts basic cents", () => {
|
|
44
|
-
expect(fromCents(1615)).toBe(16.15);
|
|
45
|
-
expect(fromCents(0)).toBe(0);
|
|
46
|
-
expect(fromCents(100)).toBe(1);
|
|
47
|
-
expect(fromCents(19680)).toBe(196.8);
|
|
48
|
-
});
|
|
49
|
-
|
|
50
|
-
test("handles negative values", () => {
|
|
51
|
-
expect(fromCents(-1615)).toBe(-16.15);
|
|
52
|
-
});
|
|
53
|
-
|
|
54
|
-
test("throws on non-integer input", () => {
|
|
55
|
-
expect(() => fromCents(16.15)).toThrow();
|
|
56
|
-
expect(() => fromCents(1.5)).toThrow();
|
|
57
|
-
});
|
|
58
|
-
});
|
|
59
|
-
|
|
60
|
-
describe("roundtrip", () => {
|
|
61
|
-
test("toCents then fromCents returns original", () => {
|
|
62
|
-
const values = [0, 1, 16.15, 196.8, 1000, 99.99, 0.01, 0.5];
|
|
63
|
-
for (const v of values) {
|
|
64
|
-
expect(fromCents(toCents(v))).toBe(v);
|
|
65
|
-
}
|
|
66
|
-
});
|
|
67
|
-
});
|
|
68
|
-
|
|
69
|
-
describe("formatCents", () => {
|
|
70
|
-
test("formats with 2 decimals", () => {
|
|
71
|
-
expect(formatCents(1615)).toBe("16.15");
|
|
72
|
-
expect(formatCents(0)).toBe("0.00");
|
|
73
|
-
expect(formatCents(100)).toBe("1.00");
|
|
74
|
-
expect(formatCents(19680)).toBe("196.80");
|
|
75
|
-
});
|
|
76
|
-
});
|