flexpay-engine 0.2.0 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "flexpay-engine",
3
- "version": "0.2.0",
3
+ "version": "0.2.1",
4
4
  "description": "FlexPay loan servicing engine — pricing, schedules, allocation, delinquency",
5
5
  "license": "MIT",
6
6
  "main": "src/index.ts",
package/src/index.ts CHANGED
@@ -11,9 +11,13 @@ export { computeDaysActivated } from "./calc/daysActivated";
11
11
 
12
12
  // --- Operations layer (D1-backed) ---
13
13
  export { originateContract } from "./operations/originate";
14
+ export { recordEvent } from "./operations/recordEvent";
14
15
  export { recordPayment } from "./operations/recordPayment";
15
16
  export { seedContract } from "./operations/seedContract";
16
17
 
18
+ // --- Utilities ---
19
+ export { ulid } from "./utils/ulid";
20
+
17
21
  // --- Money helpers ---
18
22
  export { toCents, fromCents, formatCents } from "./utils/cents";
19
23
 
@@ -36,8 +40,12 @@ export type {
36
40
  // --- Operation types ---
37
41
  export type {
38
42
  EngineDb,
43
+ EngineEventSource,
44
+ EngineEventType,
39
45
  OriginateContractInput,
40
46
  OriginateContractResult,
47
+ RecordEventInput,
48
+ RecordEventResult,
41
49
  RecordPaymentInput,
42
50
  RecordPaymentResult,
43
51
  } from "./operations/types";
@@ -0,0 +1,253 @@
1
+ // ============================================================
2
+ // recordEvent — Canonical event log writer (first-writer)
3
+ // ============================================================
4
+ //
5
+ // Inverts the prior model: engine_events is the source of truth,
6
+ // engine_contracts/installments/payments/lines are projections of it.
7
+ // Single atomic batch: event INSERT + projection writes.
8
+ //
9
+ // PoC scope: only event_type='payment_recorded' triggers projection
10
+ // updates. Other event types throw EVENT_TYPE_UNSUPPORTED until
11
+ // follow-up specs (refunds, originations) implement them.
12
+ //
13
+ // Spec: context/specs/2026-04-30-feat-canonical-engine-event-log.md
14
+
15
+ import { eq } from "drizzle-orm";
16
+ import { allocatePayment } from "../calc/allocation";
17
+ import {
18
+ engineContracts,
19
+ engineEvents,
20
+ engineInstallments,
21
+ enginePayments,
22
+ enginePaymentLines,
23
+ type NewEngineEvent,
24
+ type NewEnginePayment,
25
+ type NewEnginePaymentLine,
26
+ } from "../schema";
27
+ import {
28
+ ContractAlreadyPaidOffError,
29
+ type InstallmentState,
30
+ type InstallmentStatus,
31
+ } from "../types";
32
+ import { fromCents, toCents } from "../utils/cents";
33
+ import { ulid } from "../utils/ulid";
34
+ import {
35
+ ContractNotFoundError,
36
+ EngineOperationError,
37
+ type EngineDb,
38
+ type RecordEventInput,
39
+ type RecordEventResult,
40
+ } from "./types";
41
+
42
+ // engine_events.source (lowercase) → engine_payments.source (legacy enum)
43
+ function mapProjectionSource(
44
+ source: RecordEventInput["source"],
45
+ ): "CHINCHIN" | "PORTAL" | "HISTORICAL" {
46
+ switch (source) {
47
+ case "chinchin":
48
+ return "CHINCHIN";
49
+ case "portal":
50
+ case "cash":
51
+ return "PORTAL";
52
+ case "upya_legacy":
53
+ case "manual_correction":
54
+ case "legacy_backfill":
55
+ return "HISTORICAL";
56
+ }
57
+ }
58
+
59
+ // Idempotency key must be namespaced as `<source>:<ref>` to prevent
60
+ // implicit collisions between channels. Enforced at the boundary so
61
+ // every caller picks an unambiguous key.
62
+ const IDEMPOTENCY_KEY_PATTERN = /^[a-z_]+:.+$/;
63
+
64
+ export async function recordEvent(
65
+ db: EngineDb,
66
+ input: RecordEventInput,
67
+ ): Promise<RecordEventResult> {
68
+ if (!IDEMPOTENCY_KEY_PATTERN.test(input.idempotencyKey)) {
69
+ throw new EngineOperationError(
70
+ `idempotencyKey must match '<source>:<ref>' (got: ${input.idempotencyKey})`,
71
+ "INVALID_IDEMPOTENCY_KEY",
72
+ );
73
+ }
74
+
75
+ // 1. Idempotency check — caller-supplied key dedupes retries
76
+ const existing = await db
77
+ .select({ eventId: engineEvents.eventId })
78
+ .from(engineEvents)
79
+ .where(eq(engineEvents.idempotencyKey, input.idempotencyKey))
80
+ .get();
81
+
82
+ if (existing) {
83
+ return { status: "duplicate", existingEventId: existing.eventId };
84
+ }
85
+
86
+ // 2. PoC scope guard — only payment_recorded triggers projection writes
87
+ if (input.eventType !== "payment_recorded") {
88
+ throw new EngineOperationError(
89
+ `Event type '${input.eventType}' not yet supported in recordEvent`,
90
+ "EVENT_TYPE_UNSUPPORTED",
91
+ );
92
+ }
93
+
94
+ // 3. Validate payment-specific inputs
95
+ if (
96
+ typeof input.amountCents !== "number" ||
97
+ !Number.isInteger(input.amountCents) ||
98
+ input.amountCents <= 0
99
+ ) {
100
+ throw new EngineOperationError(
101
+ `Invalid amountCents: ${input.amountCents}`,
102
+ "INVALID_AMOUNT",
103
+ );
104
+ }
105
+ if (!input.transactionId) {
106
+ throw new EngineOperationError(
107
+ "transactionId is required for payment_recorded events",
108
+ "MISSING_TRANSACTION_ID",
109
+ );
110
+ }
111
+
112
+ // 4. Load contract
113
+ const contract = await db
114
+ .select()
115
+ .from(engineContracts)
116
+ .where(eq(engineContracts.contractNumber, input.contractNumber))
117
+ .get();
118
+
119
+ if (!contract) {
120
+ throw new ContractNotFoundError(input.contractNumber);
121
+ }
122
+ if (contract.status === "COMPLETED") {
123
+ throw new ContractAlreadyPaidOffError();
124
+ }
125
+
126
+ // 5. Load installments
127
+ const installmentRows = await db
128
+ .select()
129
+ .from(engineInstallments)
130
+ .where(eq(engineInstallments.contractNumber, input.contractNumber))
131
+ .all();
132
+
133
+ if (installmentRows.length === 0) {
134
+ throw new ContractNotFoundError(
135
+ `${input.contractNumber} has no installments in engine ledger`,
136
+ );
137
+ }
138
+
139
+ // 6. Pure allocation calc (cents → decimals at boundary)
140
+ const calcInstallments: InstallmentState[] = installmentRows.map((row) => ({
141
+ id: row.id,
142
+ sequenceNumber: row.sequenceNumber,
143
+ amountDue: fromCents(row.amountDueCents),
144
+ amountPaid: fromCents(row.amountPaidCents),
145
+ status: row.status as InstallmentStatus,
146
+ dueDate: new Date(row.dueDate),
147
+ }));
148
+
149
+ const allocation = allocatePayment({
150
+ paymentAmount: fromCents(input.amountCents),
151
+ installments: calcInstallments,
152
+ minPayment: fromCents(contract.minPaymentCents),
153
+ recurringPayment: fromCents(contract.recurringCents),
154
+ freqDays: contract.freqDays,
155
+ });
156
+
157
+ // 7. Pre-compute writes
158
+ const occurredAt = (input.occurredAt ?? new Date()).toISOString();
159
+ const ingestedAt = new Date().toISOString();
160
+ const eventId = ulid(input.occurredAt?.getTime());
161
+ const projectionSource = mapProjectionSource(input.source);
162
+
163
+ const eventRow: NewEngineEvent = {
164
+ eventId,
165
+ eventType: "payment_recorded",
166
+ contractNumber: input.contractNumber,
167
+ source: input.source,
168
+ sourceRef: input.sourceRef ?? null,
169
+ amountCents: input.amountCents,
170
+ occurredAt,
171
+ ingestedAt,
172
+ actorId: input.actorId ?? null,
173
+ payloadJson: input.payload ? JSON.stringify(input.payload) : null,
174
+ idempotencyKey: input.idempotencyKey,
175
+ };
176
+
177
+ const paymentRow: NewEnginePayment = {
178
+ contractNumber: input.contractNumber,
179
+ transactionId: input.transactionId,
180
+ amountCents: input.amountCents,
181
+ type: "PAYMENT",
182
+ source: projectionSource,
183
+ actorId: input.actorId ?? null,
184
+ note: input.note ?? null,
185
+ createdAt: occurredAt,
186
+ };
187
+
188
+ const newTotalPaidCents = toCents(allocation.newTotalPaid);
189
+ const newRemainingCents = toCents(allocation.newRemainingDebt);
190
+ const newDaysActivated = contract.daysActivated + allocation.daysActivated;
191
+ const newContractStatus = allocation.contractFullyPaid
192
+ ? "COMPLETED"
193
+ : "ACTIVE";
194
+
195
+ const paymentLineRows: NewEnginePaymentLine[] = allocation.allocations.map(
196
+ (a) => ({
197
+ transactionId: input.transactionId!,
198
+ installmentId: a.installmentId,
199
+ amountCents: toCents(a.amountApplied),
200
+ createdAt: occurredAt,
201
+ }),
202
+ );
203
+
204
+ const installmentUpdates = allocation.allocations.map((a) =>
205
+ db
206
+ .update(engineInstallments)
207
+ .set({
208
+ amountPaidCents: toCents(a.newAmountPaid),
209
+ status: a.newStatus,
210
+ })
211
+ .where(eq(engineInstallments.id, a.installmentId)),
212
+ );
213
+
214
+ const contractUpdate = db
215
+ .update(engineContracts)
216
+ .set({
217
+ totalPaidCents: newTotalPaidCents,
218
+ remainingCents: newRemainingCents,
219
+ daysActivated: newDaysActivated,
220
+ status: newContractStatus,
221
+ updatedAt: ingestedAt,
222
+ })
223
+ .where(eq(engineContracts.contractNumber, input.contractNumber));
224
+
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.
228
+ const statements = [
229
+ db.insert(engineEvents).values(eventRow),
230
+ db.insert(enginePayments).values(paymentRow),
231
+ ...paymentLineRows.map((line) =>
232
+ db.insert(enginePaymentLines).values(line),
233
+ ),
234
+ ...installmentUpdates,
235
+ contractUpdate,
236
+ ];
237
+
238
+ await db.batch(statements as unknown as Parameters<typeof db.batch>[0]);
239
+
240
+ return {
241
+ status: "recorded",
242
+ eventId,
243
+ projection: {
244
+ totalAllocatedCents: toCents(allocation.totalAllocated),
245
+ overpaymentCents: toCents(allocation.overpayment),
246
+ daysActivated: allocation.daysActivated,
247
+ newTotalPaidCents,
248
+ newRemainingCents,
249
+ contractFullyPaid: allocation.contractFullyPaid,
250
+ nextDueDate: allocation.nextDueDate?.toISOString() ?? null,
251
+ },
252
+ };
253
+ }
@@ -1,191 +1,79 @@
1
1
  // ============================================================
2
- // recordPayment — Allocate a payment against an engine contract
2
+ // recordPayment — Thin wrapper over recordEvent (legacy entry point)
3
3
  // ============================================================
4
-
5
- import { eq } from "drizzle-orm";
6
- import { allocatePayment } from "../calc/allocation";
7
- import {
8
- engineContracts,
9
- engineInstallments,
10
- enginePayments,
11
- enginePaymentLines,
12
- type NewEnginePayment,
13
- type NewEnginePaymentLine,
14
- } from "../schema";
4
+ //
5
+ // Existed before the canonical event log. Now delegates to recordEvent
6
+ // with a derived idempotency key so legacy callers get event-log
7
+ // participation for free.
8
+ //
9
+ // New callers should prefer recordEvent directly.
10
+
11
+ import { recordEvent } from "./recordEvent";
15
12
  import {
16
- ContractAlreadyPaidOffError,
17
- type InstallmentState,
18
- type InstallmentStatus,
19
- } from "../types";
20
- import { fromCents, toCents } from "../utils/cents";
21
- import {
22
- ContractNotFoundError,
23
13
  EngineOperationError,
24
14
  type EngineDb,
15
+ type EngineEventSource,
25
16
  type RecordPaymentInput,
26
17
  type RecordPaymentResult,
27
18
  } from "./types";
28
19
 
29
- /**
30
- * Record a payment against a contract in the engine ledger.
31
- *
32
- * Flow:
33
- * 1. Load contract from D1 (throws ContractNotFoundError if missing)
34
- * 2. Idempotency check: duplicate transactionId → returns { status: "duplicate" }
35
- * 3. Load installments, convert cents→decimals for calc module
36
- * 4. Run pure FIFO allocation
37
- * 5. Pre-compute all D1 writes
38
- * 6. Atomic batch: INSERT payment + payment_lines (append-only),
39
- * UPDATE installments + contract cached totals
40
- *
41
- * Caller is responsible for ensuring the contract exists (call seedContract first).
42
- * This function will throw ContractNotFoundError for unseeded contracts.
43
- */
20
+ function mapLegacySource(
21
+ source: RecordPaymentInput["source"],
22
+ ): EngineEventSource {
23
+ switch (source) {
24
+ case "CHINCHIN":
25
+ return "chinchin";
26
+ case "PORTAL":
27
+ return "portal";
28
+ case "HISTORICAL":
29
+ return "legacy_backfill";
30
+ }
31
+ }
32
+
44
33
  export async function recordPayment(
45
34
  db: EngineDb,
46
35
  input: RecordPaymentInput,
47
36
  ): Promise<RecordPaymentResult> {
48
- // 1. Load contract
49
- const contract = await db
50
- .select()
51
- .from(engineContracts)
52
- .where(eq(engineContracts.contractNumber, input.contractNumber))
53
- .get();
37
+ const eventInput = {
38
+ contractNumber: input.contractNumber,
39
+ eventType: "payment_recorded" as const,
40
+ source: mapLegacySource(input.source),
41
+ idempotencyKey: `txn:${input.transactionId}`,
42
+ sourceRef: input.transactionId,
43
+ amountCents: input.amountCents,
44
+ occurredAt: input.paymentDate,
45
+ actorId: input.actorId,
46
+ transactionId: input.transactionId,
47
+ note: input.note,
48
+ };
54
49
 
55
- if (!contract) {
56
- throw new ContractNotFoundError(input.contractNumber);
57
- }
50
+ const result = await recordEvent(db, eventInput);
58
51
 
59
- if (contract.status === "COMPLETED") {
60
- throw new ContractAlreadyPaidOffError();
52
+ if (result.status === "duplicate") {
53
+ return {
54
+ status: "duplicate",
55
+ existingPaymentId: 0, // legacy sentinel; transactionId is the lookup
56
+ eventId: result.existingEventId,
57
+ };
61
58
  }
62
59
 
63
- if (!Number.isInteger(input.amountCents) || input.amountCents <= 0) {
60
+ if (!result.projection) {
64
61
  throw new EngineOperationError(
65
- `Invalid amountCents: ${input.amountCents}`,
66
- "INVALID_AMOUNT",
67
- );
68
- }
69
-
70
- // 2. Idempotency check — duplicate transactionId is a no-op success
71
- const existing = await db
72
- .select({ id: enginePayments.id })
73
- .from(enginePayments)
74
- .where(eq(enginePayments.transactionId, input.transactionId))
75
- .get();
76
-
77
- if (existing) {
78
- return { status: "duplicate", existingPaymentId: existing.id };
79
- }
80
-
81
- // 3. Load installments
82
- const installmentRows = await db
83
- .select()
84
- .from(engineInstallments)
85
- .where(eq(engineInstallments.contractNumber, input.contractNumber))
86
- .all();
87
-
88
- if (installmentRows.length === 0) {
89
- throw new ContractNotFoundError(
90
- `${input.contractNumber} has no installments in engine ledger`,
62
+ "recordEvent did not return a projection for payment_recorded event",
63
+ "MISSING_PROJECTION",
91
64
  );
92
65
  }
93
66
 
94
- // 4. Convert to calc-layer types (cents → decimals at boundary)
95
- const calcInstallments: InstallmentState[] = installmentRows.map((row) => ({
96
- id: row.id,
97
- sequenceNumber: row.sequenceNumber,
98
- amountDue: fromCents(row.amountDueCents),
99
- amountPaid: fromCents(row.amountPaidCents),
100
- status: row.status as InstallmentStatus,
101
- dueDate: new Date(row.dueDate),
102
- }));
103
-
104
- // 5. Pure allocation calc
105
- const allocation = allocatePayment({
106
- paymentAmount: fromCents(input.amountCents),
107
- installments: calcInstallments,
108
- minPayment: fromCents(contract.minPaymentCents),
109
- recurringPayment: fromCents(contract.recurringCents),
110
- freqDays: contract.freqDays,
111
- });
112
-
113
- // 6. Pre-compute all D1 writes
114
- const now = (input.paymentDate ?? new Date()).toISOString();
115
- const updatedAt = new Date().toISOString();
116
-
117
- const paymentRow: NewEnginePayment = {
118
- contractNumber: input.contractNumber,
119
- transactionId: input.transactionId,
120
- amountCents: input.amountCents,
121
- type: "PAYMENT",
122
- source: input.source,
123
- actorId: input.actorId ?? null,
124
- note: input.note ?? null,
125
- createdAt: now,
126
- };
127
-
128
- // New totals for contract (in cents)
129
- const newTotalPaidCents = toCents(allocation.newTotalPaid);
130
- const newRemainingCents = toCents(allocation.newRemainingDebt);
131
- const newDaysActivated = contract.daysActivated + allocation.daysActivated;
132
- const newContractStatus = allocation.contractFullyPaid
133
- ? "COMPLETED"
134
- : "ACTIVE";
135
-
136
- // 7. Pre-compute all writes, then execute in ONE atomic batch.
137
- // Uses transactionId (not auto-increment id) as payment_lines FK
138
- // so everything fits in a single db.batch() — no orphan risk.
139
- const paymentLineRows: NewEnginePaymentLine[] = allocation.allocations.map(
140
- (a) => ({
141
- transactionId: input.transactionId,
142
- installmentId: a.installmentId,
143
- amountCents: toCents(a.amountApplied),
144
- createdAt: now,
145
- }),
146
- );
147
-
148
- const installmentUpdates = allocation.allocations.map((a) => {
149
- return db
150
- .update(engineInstallments)
151
- .set({
152
- amountPaidCents: toCents(a.newAmountPaid),
153
- status: a.newStatus,
154
- })
155
- .where(eq(engineInstallments.id, a.installmentId));
156
- });
157
-
158
- const contractUpdate = db
159
- .update(engineContracts)
160
- .set({
161
- totalPaidCents: newTotalPaidCents,
162
- remainingCents: newRemainingCents,
163
- daysActivated: newDaysActivated,
164
- status: newContractStatus,
165
- updatedAt,
166
- })
167
- .where(eq(engineContracts.contractNumber, input.contractNumber));
168
-
169
- const statements = [
170
- db.insert(enginePayments).values(paymentRow),
171
- ...paymentLineRows.map((line) => db.insert(enginePaymentLines).values(line)),
172
- ...installmentUpdates,
173
- contractUpdate,
174
- ];
175
-
176
- await db.batch(
177
- statements as unknown as Parameters<typeof db.batch>[0],
178
- );
179
-
180
67
  return {
181
68
  status: "recorded",
182
- paymentId: 0, // no auto-increment — use transactionId for lookups
183
- totalAllocatedCents: toCents(allocation.totalAllocated),
184
- overpaymentCents: toCents(allocation.overpayment),
185
- daysActivated: allocation.daysActivated,
186
- newTotalPaidCents,
187
- newRemainingCents,
188
- contractFullyPaid: allocation.contractFullyPaid,
189
- nextDueDate: allocation.nextDueDate?.toISOString() ?? null,
69
+ paymentId: 0,
70
+ totalAllocatedCents: result.projection.totalAllocatedCents,
71
+ overpaymentCents: result.projection.overpaymentCents,
72
+ daysActivated: result.projection.daysActivated,
73
+ newTotalPaidCents: result.projection.newTotalPaidCents,
74
+ newRemainingCents: result.projection.newRemainingCents,
75
+ contractFullyPaid: result.projection.contractFullyPaid,
76
+ nextDueDate: result.projection.nextDueDate,
77
+ eventId: result.eventId,
190
78
  };
191
79
  }
@@ -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
  // ============================================================
@@ -113,6 +113,36 @@ export const enginePaymentLines = sqliteTable(
113
113
  }),
114
114
  );
115
115
 
116
+ // --- Events (APPEND-ONLY canonical log) ----------------------
117
+ //
118
+ // First-writer event log per spec 2026-04-30. Engine state
119
+ // (contracts, installments, payments, lines) is a projection
120
+ // of this table. Never UPDATE or DELETE rows.
121
+ // Reversals/corrections are NEW events referencing the original
122
+ // via payload_json.original_event_id.
123
+
124
+ export const engineEvents = sqliteTable(
125
+ "engine_events",
126
+ {
127
+ eventId: text("event_id").primaryKey(), // ULID — sortable, globally unique
128
+ eventType: text("event_type").notNull(), // payment_recorded | payment_reversed | contract_originated | ...
129
+ contractNumber: text("contract_number").notNull(),
130
+ source: text("source").notNull(), // chinchin | portal | cash | upya_legacy | manual_correction | legacy_backfill
131
+ sourceRef: text("source_ref"), // upstream id: chinchin order_id, etc.
132
+ amountCents: integer("amount_cents"), // null for non-payment events
133
+ occurredAt: text("occurred_at").notNull(), // real-world time, ISO 8601
134
+ ingestedAt: text("ingested_at").notNull(), // when we received it, ISO 8601
135
+ actorId: text("actor_id"),
136
+ payloadJson: text("payload_json"), // event-type-specific structured data
137
+ idempotencyKey: text("idempotency_key").notNull().unique(), // dedupe key
138
+ },
139
+ (t) => ({
140
+ contractIdx: index("idx_events_contract").on(t.contractNumber),
141
+ occurredIdx: index("idx_events_occurred").on(t.occurredAt),
142
+ sourceIdx: index("idx_events_source").on(t.source),
143
+ }),
144
+ );
145
+
116
146
  // --- Type exports --------------------------------------------
117
147
 
118
148
  export type EngineContract = typeof engineContracts.$inferSelect;
@@ -123,3 +153,5 @@ export type EnginePayment = typeof enginePayments.$inferSelect;
123
153
  export type NewEnginePayment = typeof enginePayments.$inferInsert;
124
154
  export type EnginePaymentLine = typeof enginePaymentLines.$inferSelect;
125
155
  export type NewEnginePaymentLine = typeof enginePaymentLines.$inferInsert;
156
+ export type EngineEvent = typeof engineEvents.$inferSelect;
157
+ export type NewEngineEvent = typeof engineEvents.$inferInsert;
@@ -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
+ }