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,309 @@
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 { applyPaymentToLockState } from "../lockState/incremental";
18
+ import { lockBoundary } from "../lockState/decide";
19
+ import {
20
+ engineContracts,
21
+ engineEvents,
22
+ engineInstallments,
23
+ engineLockStateEvents,
24
+ enginePayments,
25
+ enginePaymentLines,
26
+ type NewEngineEvent,
27
+ type NewEngineLockStateEvent,
28
+ type NewEnginePayment,
29
+ type NewEnginePaymentLine,
30
+ } from "../schema";
31
+ import {
32
+ ContractAlreadyPaidOffError,
33
+ type InstallmentState,
34
+ type InstallmentStatus,
35
+ } from "../types";
36
+ import { fromCents, toCents } from "../utils/cents";
37
+ import { ulid } from "../utils/ulid";
38
+ import {
39
+ ContractNotFoundError,
40
+ EngineOperationError,
41
+ type EngineDb,
42
+ type RecordEventInput,
43
+ type RecordEventResult,
44
+ } from "./types";
45
+
46
+ // engine_events.source (lowercase) → engine_payments.source (legacy enum)
47
+ function mapProjectionSource(
48
+ source: RecordEventInput["source"],
49
+ ): "CHINCHIN" | "PORTAL" | "HISTORICAL" {
50
+ switch (source) {
51
+ case "chinchin":
52
+ return "CHINCHIN";
53
+ case "portal":
54
+ case "cash":
55
+ return "PORTAL";
56
+ case "upya_legacy":
57
+ case "manual_correction":
58
+ case "legacy_backfill":
59
+ return "HISTORICAL";
60
+ }
61
+ }
62
+
63
+ // Idempotency key must be namespaced as `<source>:<ref>` to prevent
64
+ // implicit collisions between channels. Enforced at the boundary so
65
+ // every caller picks an unambiguous key.
66
+ const IDEMPOTENCY_KEY_PATTERN = /^[a-z_]+:.+$/;
67
+
68
+ export async function recordEvent(
69
+ db: EngineDb,
70
+ input: RecordEventInput,
71
+ ): Promise<RecordEventResult> {
72
+ if (!IDEMPOTENCY_KEY_PATTERN.test(input.idempotencyKey)) {
73
+ throw new EngineOperationError(
74
+ `idempotencyKey must match '<source>:<ref>' (got: ${input.idempotencyKey})`,
75
+ "INVALID_IDEMPOTENCY_KEY",
76
+ );
77
+ }
78
+
79
+ // 1. Idempotency check — caller-supplied key dedupes retries
80
+ const existing = await db
81
+ .select({ eventId: engineEvents.eventId })
82
+ .from(engineEvents)
83
+ .where(eq(engineEvents.idempotencyKey, input.idempotencyKey))
84
+ .get();
85
+
86
+ if (existing) {
87
+ return { status: "duplicate", existingEventId: existing.eventId };
88
+ }
89
+
90
+ // 2. PoC scope guard — only payment_recorded triggers projection writes
91
+ if (input.eventType !== "payment_recorded") {
92
+ throw new EngineOperationError(
93
+ `Event type '${input.eventType}' not yet supported in recordEvent`,
94
+ "EVENT_TYPE_UNSUPPORTED",
95
+ );
96
+ }
97
+
98
+ // 3. Validate payment-specific inputs
99
+ if (
100
+ typeof input.amountCents !== "number" ||
101
+ !Number.isInteger(input.amountCents) ||
102
+ input.amountCents <= 0
103
+ ) {
104
+ throw new EngineOperationError(
105
+ `Invalid amountCents: ${input.amountCents}`,
106
+ "INVALID_AMOUNT",
107
+ );
108
+ }
109
+ if (!input.transactionId) {
110
+ throw new EngineOperationError(
111
+ "transactionId is required for payment_recorded events",
112
+ "MISSING_TRANSACTION_ID",
113
+ );
114
+ }
115
+
116
+ // 4. Load contract
117
+ const contract = await db
118
+ .select()
119
+ .from(engineContracts)
120
+ .where(eq(engineContracts.contractNumber, input.contractNumber))
121
+ .get();
122
+
123
+ if (!contract) {
124
+ throw new ContractNotFoundError(input.contractNumber);
125
+ }
126
+ if (contract.status === "COMPLETED") {
127
+ throw new ContractAlreadyPaidOffError();
128
+ }
129
+
130
+ // 5. Load installments
131
+ const installmentRows = await db
132
+ .select()
133
+ .from(engineInstallments)
134
+ .where(eq(engineInstallments.contractNumber, input.contractNumber))
135
+ .all();
136
+
137
+ if (installmentRows.length === 0) {
138
+ throw new ContractNotFoundError(
139
+ `${input.contractNumber} has no installments in engine ledger`,
140
+ );
141
+ }
142
+
143
+ // 6. Pure allocation calc (cents → decimals at boundary)
144
+ const calcInstallments: InstallmentState[] = installmentRows.map((row) => ({
145
+ id: row.id,
146
+ sequenceNumber: row.sequenceNumber,
147
+ amountDue: fromCents(row.amountDueCents),
148
+ amountPaid: fromCents(row.amountPaidCents),
149
+ status: row.status as InstallmentStatus,
150
+ dueDate: new Date(row.dueDate),
151
+ }));
152
+
153
+ const allocation = allocatePayment({
154
+ paymentAmount: fromCents(input.amountCents),
155
+ installments: calcInstallments,
156
+ recurringPayment: fromCents(contract.recurringCents),
157
+ freqDays: contract.freqDays,
158
+ });
159
+
160
+ // 7. Pre-compute writes
161
+ const occurredAt = (input.occurredAt ?? new Date()).toISOString();
162
+ const ingestedAt = new Date().toISOString();
163
+ const eventId = ulid(input.occurredAt?.getTime());
164
+ const projectionSource = mapProjectionSource(input.source);
165
+
166
+ const eventRow: NewEngineEvent = {
167
+ eventId,
168
+ eventType: "payment_recorded",
169
+ contractNumber: input.contractNumber,
170
+ source: input.source,
171
+ sourceRef: input.sourceRef ?? null,
172
+ amountCents: input.amountCents,
173
+ occurredAt,
174
+ ingestedAt,
175
+ actorId: input.actorId ?? null,
176
+ payloadJson: input.payload ? JSON.stringify(input.payload) : null,
177
+ idempotencyKey: input.idempotencyKey,
178
+ };
179
+
180
+ const paymentRow: NewEnginePayment = {
181
+ contractNumber: input.contractNumber,
182
+ transactionId: input.transactionId,
183
+ amountCents: input.amountCents,
184
+ type: "PAYMENT",
185
+ source: projectionSource,
186
+ actorId: input.actorId ?? null,
187
+ note: input.note ?? null,
188
+ createdAt: occurredAt,
189
+ };
190
+
191
+ const newTotalPaidCents = toCents(allocation.newTotalPaid);
192
+ const newRemainingCents = toCents(allocation.newRemainingDebt);
193
+ const newDaysActivated = contract.daysActivated + allocation.daysActivated;
194
+ const newContractStatus = allocation.contractFullyPaid
195
+ ? "COMPLETED"
196
+ : "ACTIVE";
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
+
235
+ const paymentLineRows: NewEnginePaymentLine[] = allocation.allocations.map(
236
+ (a) => ({
237
+ transactionId: input.transactionId!,
238
+ installmentId: a.installmentId,
239
+ amountCents: toCents(a.amountApplied),
240
+ createdAt: occurredAt,
241
+ }),
242
+ );
243
+
244
+ const installmentUpdates = allocation.allocations.map((a) =>
245
+ db
246
+ .update(engineInstallments)
247
+ .set({
248
+ amountPaidCents: toCents(a.newAmountPaid),
249
+ status: a.newStatus,
250
+ })
251
+ .where(eq(engineInstallments.id, a.installmentId)),
252
+ );
253
+
254
+ const contractUpdate = db
255
+ .update(engineContracts)
256
+ .set({
257
+ totalPaidCents: newTotalPaidCents,
258
+ remainingCents: newRemainingCents,
259
+ daysActivated: newDaysActivated,
260
+ status: newContractStatus,
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,
274
+ })
275
+ .where(eq(engineContracts.contractNumber, input.contractNumber));
276
+
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.
281
+ const statements = [
282
+ db.insert(engineEvents).values(eventRow),
283
+ db.insert(enginePayments).values(paymentRow),
284
+ ...paymentLineRows.map((line) =>
285
+ db.insert(enginePaymentLines).values(line),
286
+ ),
287
+ ...installmentUpdates,
288
+ contractUpdate,
289
+ ...(lockEventRow
290
+ ? [db.insert(engineLockStateEvents).values(lockEventRow)]
291
+ : []),
292
+ ];
293
+
294
+ await db.batch(statements as unknown as Parameters<typeof db.batch>[0]);
295
+
296
+ return {
297
+ status: "recorded",
298
+ eventId,
299
+ projection: {
300
+ totalAllocatedCents: toCents(allocation.totalAllocated),
301
+ overpaymentCents: toCents(allocation.overpayment),
302
+ daysActivated: allocation.daysActivated,
303
+ newTotalPaidCents,
304
+ newRemainingCents,
305
+ contractFullyPaid: allocation.contractFullyPaid,
306
+ nextDueDate: allocation.nextDueDate?.toISOString() ?? null,
307
+ },
308
+ };
309
+ }
@@ -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
  }