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.
@@ -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
+ }
@@ -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
  // ============================================================
@@ -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: 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");
@@ -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
+ }