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.
@@ -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
- // Stable user-supplied IDs (eventId, transactionId) used as FKs so no
227
- // mid-batch lookups are needed. D1's batch is atomic per call.
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]);
@@ -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
+ }
@@ -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: number;
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");
@@ -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
- });