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.
- package/README.md +278 -14
- package/package.json +22 -5
- package/src/calc/allocation.ts +32 -17
- package/src/index.ts +30 -1
- package/src/lockState/decide.ts +180 -0
- package/src/lockState/incremental.ts +94 -0
- package/src/lockState/index.ts +9 -0
- package/src/lockState/types.ts +62 -0
- package/src/operations/getLockState.ts +67 -0
- package/src/operations/recordEvent.ts +309 -0
- package/src/operations/recordPayment.ts +54 -166
- package/src/operations/snapshotSeed.ts +377 -0
- package/src/operations/types.ts +64 -0
- package/src/schema/index.ts +85 -0
- package/src/types.ts +2 -13
- package/src/utils/ulid.ts +36 -0
- package/src/utils/cents.test.ts +0 -76
|
@@ -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 —
|
|
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";
|
|
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
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
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
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
.
|
|
52
|
-
|
|
53
|
-
.
|
|
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
|
-
|
|
56
|
-
throw new ContractNotFoundError(input.contractNumber);
|
|
57
|
-
}
|
|
50
|
+
const result = await recordEvent(db, eventInput);
|
|
58
51
|
|
|
59
|
-
if (
|
|
60
|
-
|
|
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 (!
|
|
60
|
+
if (!result.projection) {
|
|
64
61
|
throw new EngineOperationError(
|
|
65
|
-
|
|
66
|
-
"
|
|
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,
|
|
183
|
-
totalAllocatedCents:
|
|
184
|
-
overpaymentCents:
|
|
185
|
-
daysActivated:
|
|
186
|
-
newTotalPaidCents,
|
|
187
|
-
newRemainingCents,
|
|
188
|
-
contractFullyPaid:
|
|
189
|
-
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,
|
|
190
78
|
};
|
|
191
79
|
}
|