flexpay-engine 0.1.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/README.md ADDED
@@ -0,0 +1,52 @@
1
+ # flexpay-engine
2
+
3
+ FlexPay loan servicing engine — pricing, schedules, allocation, delinquency.
4
+
5
+ ## Installation
6
+
7
+ ```bash
8
+ bun add flexpay-engine
9
+ ```
10
+
11
+ ## Usage
12
+
13
+ ```typescript
14
+ import { recordPayment, toCents, seedContract } from 'flexpay-engine';
15
+ import { engineContracts, enginePayments } from 'flexpay-engine/schema';
16
+ ```
17
+
18
+ ## Publishing to npm
19
+
20
+ 1. Bump version in `package.json`
21
+ 2. Commit: `git commit -am "chore: bump to vX.Y.Z"`
22
+ 3. Publish: `npm publish --access public --otp=YOUR_CODE`
23
+ 4. Update consumers:
24
+ ```bash
25
+ # In flexpay-client-worker
26
+ bun add flexpay-engine@X.Y.Z
27
+
28
+ # In flexpay-backend
29
+ bun add flexpay-engine@X.Y.Z
30
+ ```
31
+ 5. Commit lockfile changes in each consumer repo
32
+
33
+ ## Versioning
34
+
35
+ - **Patch** (0.1.x): Bug fixes, no API changes
36
+ - **Minor** (0.x.0): New features, backwards compatible
37
+ - **Major** (x.0.0): Breaking changes
38
+
39
+ ## Consumers
40
+
41
+ - `flexpay-client-worker` — portal payments (PORTAL source)
42
+ - `flexpay-backend` — ChinChin payments (CHINCHIN source)
43
+
44
+ Both write to shared D1 database `ENGINE_DB`.
45
+
46
+ ## Development
47
+
48
+ ```bash
49
+ bun install
50
+ bun test
51
+ bun run typecheck
52
+ ```
package/package.json CHANGED
@@ -1,11 +1,13 @@
1
1
  {
2
2
  "name": "flexpay-engine",
3
- "version": "0.1.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",
7
7
  "types": "src/index.ts",
8
- "files": ["src"],
8
+ "files": [
9
+ "src"
10
+ ],
9
11
  "exports": {
10
12
  ".": {
11
13
  "types": "./src/index.ts",
@@ -5,7 +5,7 @@ import type { PricingInput, PricingResult } from "../types";
5
5
  * Uses Number.EPSILON to handle floating-point edge cases (e.g., 1.005).
6
6
  */
7
7
  export function round2(n: number): number {
8
- return Math.round((n + Number.EPSILON) * 100) / 100;
8
+ return Math.round((n + Number.EPSILON * Math.sign(n)) * 100) / 100;
9
9
  }
10
10
 
11
11
  /**
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";
@@ -30,6 +30,13 @@ export async function originateContract(
30
30
  db: EngineDb,
31
31
  input: OriginateContractInput,
32
32
  ): Promise<OriginateContractResult> {
33
+ if (!Number.isFinite(input.basePrice) || input.basePrice <= 0) {
34
+ throw new Error(`Invalid basePrice: ${input.basePrice}`);
35
+ }
36
+ if (input.numInstallments < 0) {
37
+ throw new Error(`Invalid numInstallments: ${input.numInstallments}`);
38
+ }
39
+
33
40
  // 1. Pure calc: pricing
34
41
  const pricing = calculatePricing({
35
42
  basePrice: input.basePrice,
@@ -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,183 +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, sql } 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";
15
- import type {
16
- InstallmentState,
17
- InstallmentStatus,
18
- } from "../types";
19
- import { fromCents, toCents } from "../utils/cents";
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";
20
12
  import {
21
- ContractNotFoundError,
13
+ EngineOperationError,
22
14
  type EngineDb,
15
+ type EngineEventSource,
23
16
  type RecordPaymentInput,
24
17
  type RecordPaymentResult,
25
18
  } from "./types";
26
19
 
27
- /**
28
- * Record a payment against a contract in the engine ledger.
29
- *
30
- * Flow:
31
- * 1. Load contract from D1 (throws ContractNotFoundError if missing)
32
- * 2. Idempotency check: duplicate transactionId → returns { status: "duplicate" }
33
- * 3. Load installments, convert cents→decimals for calc module
34
- * 4. Run pure FIFO allocation
35
- * 5. Pre-compute all D1 writes
36
- * 6. Atomic batch: INSERT payment + payment_lines (append-only),
37
- * UPDATE installments + contract cached totals
38
- *
39
- * Caller is responsible for ensuring the contract exists (call seedContract first).
40
- * This function will throw ContractNotFoundError for unseeded contracts.
41
- */
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
+
42
33
  export async function recordPayment(
43
34
  db: EngineDb,
44
35
  input: RecordPaymentInput,
45
36
  ): Promise<RecordPaymentResult> {
46
- // 1. Load contract
47
- const contract = await db
48
- .select()
49
- .from(engineContracts)
50
- .where(eq(engineContracts.contractNumber, input.contractNumber))
51
- .get();
52
-
53
- if (!contract) {
54
- throw new ContractNotFoundError(input.contractNumber);
55
- }
56
-
57
- // 2. Idempotency check — duplicate transactionId is a no-op success
58
- const existing = await db
59
- .select({ id: enginePayments.id })
60
- .from(enginePayments)
61
- .where(eq(enginePayments.transactionId, input.transactionId))
62
- .get();
63
-
64
- if (existing) {
65
- return { status: "duplicate", existingPaymentId: existing.id };
66
- }
67
-
68
- // 3. Load installments
69
- const installmentRows = await db
70
- .select()
71
- .from(engineInstallments)
72
- .where(eq(engineInstallments.contractNumber, input.contractNumber))
73
- .all();
74
-
75
- if (installmentRows.length === 0) {
76
- throw new ContractNotFoundError(
77
- `${input.contractNumber} has no installments in engine ledger`,
78
- );
79
- }
80
-
81
- // 4. Convert to calc-layer types (cents → decimals at boundary)
82
- const calcInstallments: InstallmentState[] = installmentRows.map((row) => ({
83
- id: row.id,
84
- sequenceNumber: row.sequenceNumber,
85
- amountDue: fromCents(row.amountDueCents),
86
- amountPaid: fromCents(row.amountPaidCents),
87
- status: row.status as InstallmentStatus,
88
- dueDate: new Date(row.dueDate),
89
- }));
90
-
91
- // 5. Pure allocation calc
92
- const allocation = allocatePayment({
93
- paymentAmount: fromCents(input.amountCents),
94
- installments: calcInstallments,
95
- minPayment: fromCents(contract.minPaymentCents),
96
- recurringPayment: fromCents(contract.recurringCents),
97
- freqDays: contract.freqDays,
98
- });
99
-
100
- // 6. Pre-compute all D1 writes
101
- const now = (input.paymentDate ?? new Date()).toISOString();
102
- const updatedAt = new Date().toISOString();
103
-
104
- const paymentRow: NewEnginePayment = {
37
+ const eventInput = {
105
38
  contractNumber: input.contractNumber,
106
- transactionId: input.transactionId,
39
+ eventType: "payment_recorded" as const,
40
+ source: mapLegacySource(input.source),
41
+ idempotencyKey: `txn:${input.transactionId}`,
42
+ sourceRef: input.transactionId,
107
43
  amountCents: input.amountCents,
108
- type: "PAYMENT",
109
- source: input.source,
110
- actorId: input.actorId ?? null,
111
- note: input.note ?? null,
112
- createdAt: now,
44
+ occurredAt: input.paymentDate,
45
+ actorId: input.actorId,
46
+ transactionId: input.transactionId,
47
+ note: input.note,
113
48
  };
114
49
 
115
- // Index allocations by installment id for update statements
116
- const allocationByInstallmentId = new Map(
117
- allocation.allocations.map((a) => [a.installmentId, a]),
118
- );
119
-
120
- // New totals for contract (in cents)
121
- const newTotalPaidCents = toCents(allocation.newTotalPaid);
122
- const newRemainingCents = toCents(allocation.newRemainingDebt);
123
- const newDaysActivated = contract.daysActivated + allocation.daysActivated;
124
- const newContractStatus = allocation.contractFullyPaid
125
- ? "COMPLETED"
126
- : "ACTIVE";
50
+ const result = await recordEvent(db, eventInput);
127
51
 
128
- // 7. Pre-compute all writes, then execute in ONE atomic batch.
129
- // Uses transactionId (not auto-increment id) as payment_lines FK
130
- // so everything fits in a single db.batch() — no orphan risk.
131
- const paymentLineRows: NewEnginePaymentLine[] = allocation.allocations.map(
132
- (a) => ({
133
- transactionId: input.transactionId,
134
- installmentId: a.installmentId,
135
- amountCents: toCents(a.amountApplied),
136
- createdAt: now,
137
- }),
138
- );
139
-
140
- const installmentUpdates = allocation.allocations.map((a) => {
141
- return db
142
- .update(engineInstallments)
143
- .set({
144
- amountPaidCents: toCents(a.newAmountPaid),
145
- status: a.newStatus,
146
- })
147
- .where(eq(engineInstallments.id, a.installmentId));
148
- });
149
-
150
- const contractUpdate = db
151
- .update(engineContracts)
152
- .set({
153
- totalPaidCents: newTotalPaidCents,
154
- remainingCents: newRemainingCents,
155
- daysActivated: newDaysActivated,
156
- status: newContractStatus,
157
- updatedAt,
158
- })
159
- .where(eq(engineContracts.contractNumber, input.contractNumber));
160
-
161
- const statements = [
162
- db.insert(enginePayments).values(paymentRow),
163
- ...paymentLineRows.map((line) => db.insert(enginePaymentLines).values(line)),
164
- ...installmentUpdates,
165
- contractUpdate,
166
- ];
52
+ if (result.status === "duplicate") {
53
+ return {
54
+ status: "duplicate",
55
+ existingPaymentId: 0, // legacy sentinel; transactionId is the lookup
56
+ eventId: result.existingEventId,
57
+ };
58
+ }
167
59
 
168
- await db.batch(
169
- statements as unknown as Parameters<typeof db.batch>[0],
170
- );
60
+ if (!result.projection) {
61
+ throw new EngineOperationError(
62
+ "recordEvent did not return a projection for payment_recorded event",
63
+ "MISSING_PROJECTION",
64
+ );
65
+ }
171
66
 
172
67
  return {
173
68
  status: "recorded",
174
- paymentId: 0, // no auto-increment — use transactionId for lookups
175
- totalAllocatedCents: toCents(allocation.totalAllocated),
176
- overpaymentCents: toCents(allocation.overpayment),
177
- daysActivated: allocation.daysActivated,
178
- newTotalPaidCents,
179
- newRemainingCents,
180
- contractFullyPaid: allocation.contractFullyPaid,
181
- 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,
182
78
  };
183
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
  // ============================================================
@@ -10,7 +10,7 @@
10
10
  // See docs/plans/2026-04-09-native-engine-design.md §3
11
11
  // ============================================================
12
12
 
13
- import { sqliteTable, text, integer, index } from "drizzle-orm/sqlite-core";
13
+ import { sqliteTable, text, integer, index, uniqueIndex } from "drizzle-orm/sqlite-core";
14
14
 
15
15
  // --- Contracts -----------------------------------------------
16
16
 
@@ -61,6 +61,7 @@ export const engineInstallments = sqliteTable(
61
61
  },
62
62
  (t) => ({
63
63
  contractIdx: index("idx_installments_contract").on(t.contractNumber),
64
+ uniqueSeq: uniqueIndex("idx_installments_unique").on(t.contractNumber, t.sequenceNumber),
64
65
  }),
65
66
  );
66
67
 
@@ -112,6 +113,36 @@ export const enginePaymentLines = sqliteTable(
112
113
  }),
113
114
  );
114
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
+
115
146
  // --- Type exports --------------------------------------------
116
147
 
117
148
  export type EngineContract = typeof engineContracts.$inferSelect;
@@ -122,3 +153,5 @@ export type EnginePayment = typeof enginePayments.$inferSelect;
122
153
  export type NewEnginePayment = typeof enginePayments.$inferInsert;
123
154
  export type EnginePaymentLine = typeof enginePaymentLines.$inferSelect;
124
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
+ }