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 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.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": ["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
  /**
@@ -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, sql } from "drizzle-orm";
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 type {
16
- InstallmentState,
17
- InstallmentStatus,
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);
@@ -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