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 +52 -0
- package/package.json +4 -2
- package/src/calc/pricing.ts +1 -1
- package/src/index.ts +8 -0
- package/src/operations/originate.ts +7 -0
- package/src/operations/recordEvent.ts +253 -0
- package/src/operations/recordPayment.ts +56 -160
- package/src/operations/types.ts +64 -0
- package/src/schema/index.ts +34 -1
- package/src/utils/ulid.ts +36 -0
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
|
|
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": [
|
|
8
|
+
"files": [
|
|
9
|
+
"src"
|
|
10
|
+
],
|
|
9
11
|
"exports": {
|
|
10
12
|
".": {
|
|
11
13
|
"types": "./src/index.ts",
|
package/src/calc/pricing.ts
CHANGED
|
@@ -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 —
|
|
2
|
+
// recordPayment — Thin wrapper over recordEvent (legacy entry point)
|
|
3
3
|
// ============================================================
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
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
|
-
|
|
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
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
note: input.note
|
|
112
|
-
createdAt: now,
|
|
44
|
+
occurredAt: input.paymentDate,
|
|
45
|
+
actorId: input.actorId,
|
|
46
|
+
transactionId: input.transactionId,
|
|
47
|
+
note: input.note,
|
|
113
48
|
};
|
|
114
49
|
|
|
115
|
-
|
|
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
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
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
|
-
|
|
169
|
-
|
|
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,
|
|
175
|
-
totalAllocatedCents:
|
|
176
|
-
overpaymentCents:
|
|
177
|
-
daysActivated:
|
|
178
|
-
newTotalPaidCents,
|
|
179
|
-
newRemainingCents,
|
|
180
|
-
contractFullyPaid:
|
|
181
|
-
nextDueDate:
|
|
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
|
}
|
package/src/operations/types.ts
CHANGED
|
@@ -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
|
// ============================================================
|
package/src/schema/index.ts
CHANGED
|
@@ -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
|
+
}
|