flexpay-engine 0.1.0 → 0.2.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 +52 -0
- package/package.json +4 -2
- package/src/calc/pricing.ts +1 -1
- package/src/operations/originate.ts +7 -0
- package/src/operations/recordPayment.ts +17 -9
- package/src/schema/index.ts +2 -1
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.
|
|
3
|
+
"version": "0.2.0",
|
|
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
|
/**
|
|
@@ -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,
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// recordPayment — Allocate a payment against an engine contract
|
|
3
3
|
// ============================================================
|
|
4
4
|
|
|
5
|
-
import { eq
|
|
5
|
+
import { eq } from "drizzle-orm";
|
|
6
6
|
import { allocatePayment } from "../calc/allocation";
|
|
7
7
|
import {
|
|
8
8
|
engineContracts,
|
|
@@ -12,13 +12,15 @@ import {
|
|
|
12
12
|
type NewEnginePayment,
|
|
13
13
|
type NewEnginePaymentLine,
|
|
14
14
|
} from "../schema";
|
|
15
|
-
import
|
|
16
|
-
|
|
17
|
-
|
|
15
|
+
import {
|
|
16
|
+
ContractAlreadyPaidOffError,
|
|
17
|
+
type InstallmentState,
|
|
18
|
+
type InstallmentStatus,
|
|
18
19
|
} from "../types";
|
|
19
20
|
import { fromCents, toCents } from "../utils/cents";
|
|
20
21
|
import {
|
|
21
22
|
ContractNotFoundError,
|
|
23
|
+
EngineOperationError,
|
|
22
24
|
type EngineDb,
|
|
23
25
|
type RecordPaymentInput,
|
|
24
26
|
type RecordPaymentResult,
|
|
@@ -54,6 +56,17 @@ export async function recordPayment(
|
|
|
54
56
|
throw new ContractNotFoundError(input.contractNumber);
|
|
55
57
|
}
|
|
56
58
|
|
|
59
|
+
if (contract.status === "COMPLETED") {
|
|
60
|
+
throw new ContractAlreadyPaidOffError();
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
if (!Number.isInteger(input.amountCents) || input.amountCents <= 0) {
|
|
64
|
+
throw new EngineOperationError(
|
|
65
|
+
`Invalid amountCents: ${input.amountCents}`,
|
|
66
|
+
"INVALID_AMOUNT",
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
|
|
57
70
|
// 2. Idempotency check — duplicate transactionId is a no-op success
|
|
58
71
|
const existing = await db
|
|
59
72
|
.select({ id: enginePayments.id })
|
|
@@ -112,11 +125,6 @@ export async function recordPayment(
|
|
|
112
125
|
createdAt: now,
|
|
113
126
|
};
|
|
114
127
|
|
|
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
128
|
// New totals for contract (in cents)
|
|
121
129
|
const newTotalPaidCents = toCents(allocation.newTotalPaid);
|
|
122
130
|
const newRemainingCents = toCents(allocation.newRemainingDebt);
|
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
|
|