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,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/operations/types.ts
CHANGED
|
@@ -58,10 +58,74 @@ export type RecordPaymentResult =
|
|
|
58
58
|
newRemainingCents: number;
|
|
59
59
|
contractFullyPaid: boolean;
|
|
60
60
|
nextDueDate: string | null;
|
|
61
|
+
/** ULID of the canonical event log entry. */
|
|
62
|
+
eventId: string;
|
|
61
63
|
}
|
|
62
64
|
| {
|
|
63
65
|
status: "duplicate";
|
|
64
66
|
existingPaymentId: number;
|
|
67
|
+
eventId: string;
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
// ============================================================
|
|
71
|
+
// recordEvent — canonical event log writer
|
|
72
|
+
// ============================================================
|
|
73
|
+
|
|
74
|
+
/** Source channel for an engine event. */
|
|
75
|
+
export type EngineEventSource =
|
|
76
|
+
| "chinchin"
|
|
77
|
+
| "portal"
|
|
78
|
+
| "cash"
|
|
79
|
+
| "upya_legacy"
|
|
80
|
+
| "manual_correction"
|
|
81
|
+
| "legacy_backfill";
|
|
82
|
+
|
|
83
|
+
/** Event type. PoC only supports 'payment_recorded'. */
|
|
84
|
+
export type EngineEventType =
|
|
85
|
+
| "payment_recorded"
|
|
86
|
+
| "payment_reversed"
|
|
87
|
+
| "contract_originated";
|
|
88
|
+
|
|
89
|
+
/** Inputs for recording an event in the canonical log. */
|
|
90
|
+
export interface RecordEventInput {
|
|
91
|
+
contractNumber: string;
|
|
92
|
+
eventType: EngineEventType;
|
|
93
|
+
source: EngineEventSource;
|
|
94
|
+
/** Caller-supplied dedupe key. UNIQUE on engine_events. */
|
|
95
|
+
idempotencyKey: string;
|
|
96
|
+
/** Upstream id for traceability — e.g. chinchin order_id, upya transactionNumber. */
|
|
97
|
+
sourceRef?: string;
|
|
98
|
+
/** Required for 'payment_recorded'. Cents. */
|
|
99
|
+
amountCents?: number;
|
|
100
|
+
/** Real-world time. Defaults to now(). */
|
|
101
|
+
occurredAt?: Date;
|
|
102
|
+
actorId?: string;
|
|
103
|
+
/** Event-type-specific structured data. */
|
|
104
|
+
payload?: Record<string, unknown>;
|
|
105
|
+
/** For 'payment_recorded' — the engine_payments.transaction_id. UNIQUE. */
|
|
106
|
+
transactionId?: string;
|
|
107
|
+
note?: string;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** Result of recording an event. */
|
|
111
|
+
export type RecordEventResult =
|
|
112
|
+
| {
|
|
113
|
+
status: "recorded";
|
|
114
|
+
eventId: string;
|
|
115
|
+
/** Projection updates applied alongside event INSERT. Only present for payment events. */
|
|
116
|
+
projection?: {
|
|
117
|
+
totalAllocatedCents: number;
|
|
118
|
+
overpaymentCents: number;
|
|
119
|
+
daysActivated: number;
|
|
120
|
+
newTotalPaidCents: number;
|
|
121
|
+
newRemainingCents: number;
|
|
122
|
+
contractFullyPaid: boolean;
|
|
123
|
+
nextDueDate: string | null;
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
| {
|
|
127
|
+
status: "duplicate";
|
|
128
|
+
existingEventId: string;
|
|
65
129
|
};
|
|
66
130
|
|
|
67
131
|
// ============================================================
|
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(
|
|
@@ -113,6 +164,36 @@ export const enginePaymentLines = sqliteTable(
|
|
|
113
164
|
}),
|
|
114
165
|
);
|
|
115
166
|
|
|
167
|
+
// --- Events (APPEND-ONLY canonical log) ----------------------
|
|
168
|
+
//
|
|
169
|
+
// First-writer event log per spec 2026-04-30. Engine state
|
|
170
|
+
// (contracts, installments, payments, lines) is a projection
|
|
171
|
+
// of this table. Never UPDATE or DELETE rows.
|
|
172
|
+
// Reversals/corrections are NEW events referencing the original
|
|
173
|
+
// via payload_json.original_event_id.
|
|
174
|
+
|
|
175
|
+
export const engineEvents = sqliteTable(
|
|
176
|
+
"engine_events",
|
|
177
|
+
{
|
|
178
|
+
eventId: text("event_id").primaryKey(), // ULID — sortable, globally unique
|
|
179
|
+
eventType: text("event_type").notNull(), // payment_recorded | payment_reversed | contract_originated | ...
|
|
180
|
+
contractNumber: text("contract_number").notNull(),
|
|
181
|
+
source: text("source").notNull(), // chinchin | portal | cash | upya_legacy | manual_correction | legacy_backfill
|
|
182
|
+
sourceRef: text("source_ref"), // upstream id: chinchin order_id, etc.
|
|
183
|
+
amountCents: integer("amount_cents"), // null for non-payment events
|
|
184
|
+
occurredAt: text("occurred_at").notNull(), // real-world time, ISO 8601
|
|
185
|
+
ingestedAt: text("ingested_at").notNull(), // when we received it, ISO 8601
|
|
186
|
+
actorId: text("actor_id"),
|
|
187
|
+
payloadJson: text("payload_json"), // event-type-specific structured data
|
|
188
|
+
idempotencyKey: text("idempotency_key").notNull().unique(), // dedupe key
|
|
189
|
+
},
|
|
190
|
+
(t) => ({
|
|
191
|
+
contractIdx: index("idx_events_contract").on(t.contractNumber),
|
|
192
|
+
occurredIdx: index("idx_events_occurred").on(t.occurredAt),
|
|
193
|
+
sourceIdx: index("idx_events_source").on(t.source),
|
|
194
|
+
}),
|
|
195
|
+
);
|
|
196
|
+
|
|
116
197
|
// --- Type exports --------------------------------------------
|
|
117
198
|
|
|
118
199
|
export type EngineContract = typeof engineContracts.$inferSelect;
|
|
@@ -123,3 +204,7 @@ export type EnginePayment = typeof enginePayments.$inferSelect;
|
|
|
123
204
|
export type NewEnginePayment = typeof enginePayments.$inferInsert;
|
|
124
205
|
export type EnginePaymentLine = typeof enginePaymentLines.$inferSelect;
|
|
125
206
|
export type NewEnginePaymentLine = typeof enginePaymentLines.$inferInsert;
|
|
207
|
+
export type EngineEvent = typeof engineEvents.$inferSelect;
|
|
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");
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
// ULID — Crockford-base32, 48-bit timestamp + 80-bit randomness.
|
|
2
|
+
// 26 chars, lexicographically sortable by time. No deps.
|
|
3
|
+
// Spec: https://github.com/ulid/spec
|
|
4
|
+
//
|
|
5
|
+
// Randomness from crypto.getRandomValues (Workers/V8 globalThis.crypto).
|
|
6
|
+
// Within the same millisecond, ULIDs are NOT guaranteed monotonic — two
|
|
7
|
+
// calls in the same ms may sort by random suffix, not insertion order.
|
|
8
|
+
// For payment events at chinchin/portal volume, sub-ms collisions are
|
|
9
|
+
// vanishingly rare; if monotonicity is needed later, add a per-ms counter.
|
|
10
|
+
|
|
11
|
+
const ENCODING = "0123456789ABCDEFGHJKMNPQRSTVWXYZ"; // Crockford base32
|
|
12
|
+
|
|
13
|
+
function encodeTime(now: number, len: number): string {
|
|
14
|
+
let out = "";
|
|
15
|
+
for (let i = len - 1; i >= 0; i--) {
|
|
16
|
+
const mod = now % 32;
|
|
17
|
+
out = ENCODING[mod] + out;
|
|
18
|
+
now = (now - mod) / 32;
|
|
19
|
+
}
|
|
20
|
+
return out;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function encodeRandom(len: number): string {
|
|
24
|
+
const bytes = new Uint8Array(len);
|
|
25
|
+
// crypto.getRandomValues is available in Cloudflare Workers and Node 20+.
|
|
26
|
+
(globalThis as unknown as { crypto: Crypto }).crypto.getRandomValues(bytes);
|
|
27
|
+
let out = "";
|
|
28
|
+
for (let i = 0; i < len; i++) {
|
|
29
|
+
out += ENCODING[bytes[i]! % 32];
|
|
30
|
+
}
|
|
31
|
+
return out;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function ulid(now: number = Date.now()): string {
|
|
35
|
+
return encodeTime(now, 10) + encodeRandom(16);
|
|
36
|
+
}
|