From 6635c79ae5fe7ea7e53675c6dbccafc36d45c173 Mon Sep 17 00:00:00 2001 From: Levi-Ojukwu Date: Sun, 29 Mar 2026 19:42:23 +0100 Subject: [PATCH 1/7] fix: implemented the startup config validation to catch misconfigured environment variables --- backend/.env.example | 6 ++ backend/.gitignore | 1 + backend/package-lock.json | 21 ----- backend/src/__tests__/loanConfig.test.ts | 66 ++++++++++++++++ backend/src/__tests__/loanEndpoints.test.ts | 12 ++- backend/src/config/loanConfig.ts | 88 +++++++++++++++++++++ backend/src/controllers/loanController.ts | 55 +++---------- backend/src/index.ts | 9 +++ backend/src/routes/loanRoutes.ts | 4 +- backend/src/services/eventStreamService.ts | 1 + 10 files changed, 194 insertions(+), 69 deletions(-) create mode 100644 backend/.gitignore create mode 100644 backend/src/__tests__/loanConfig.test.ts create mode 100644 backend/src/config/loanConfig.ts diff --git a/backend/.env.example b/backend/.env.example index a8f534b5..39ade031 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -15,6 +15,12 @@ POOL_TOKEN_ADDRESS= # Secret key for the on-chain LoanManager admin account (G... / S...) LOAN_MANAGER_ADMIN_SECRET= +# Loan configuration (required) +LOAN_MIN_SCORE=500 +LOAN_MAX_AMOUNT=50000 +LOAN_INTEREST_RATE_PERCENT=12 +CREDIT_SCORE_THRESHOLD=600 + # Indexer Configuration INDEXER_POLL_INTERVAL_MS=30000 INDEXER_BATCH_SIZE=100 diff --git a/backend/.gitignore b/backend/.gitignore new file mode 100644 index 00000000..2eea525d --- /dev/null +++ b/backend/.gitignore @@ -0,0 +1 @@ +.env \ No newline at end of file diff --git a/backend/package-lock.json b/backend/package-lock.json index 2f8427b7..43a9f090 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -128,7 +128,6 @@ "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", @@ -2084,7 +2083,6 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz", "integrity": "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==", "license": "Apache-2.0", - "peer": true, "engines": { "node": ">=8.0.0" } @@ -2106,7 +2104,6 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/context-async-hooks/-/context-async-hooks-2.6.0.tgz", "integrity": "sha512-L8UyDwqpTcbkIK5cgwDRDYDoEhQoj8wp8BwsO19w3LB1Z41yEQm2VJyNfAi9DrLP/YTqXqWpKHyZfR9/tFYo1Q==", "license": "Apache-2.0", - "peer": true, "engines": { "node": "^18.19.0 || >=20.6.0" }, @@ -2119,7 +2116,6 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.6.0.tgz", "integrity": "sha512-HLM1v2cbZ4TgYN6KEOj+Bbj8rAKriOdkF9Ed3tG25FoprSiQl7kYc+RRT6fUZGOvx0oMi5U67GoFdT+XUn8zEg==", "license": "Apache-2.0", - "peer": true, "dependencies": { "@opentelemetry/semantic-conventions": "^1.29.0" }, @@ -2539,7 +2535,6 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.6.0.tgz", "integrity": "sha512-D4y/+OGe3JSuYUCBxtH5T9DSAWNcvCb/nQWIga8HNtXTVPQn59j0nTBAgaAXxUVBDl40mG3Tc76b46wPlZaiJQ==", "license": "Apache-2.0", - "peer": true, "dependencies": { "@opentelemetry/core": "2.6.0", "@opentelemetry/semantic-conventions": "^1.29.0" @@ -2556,7 +2551,6 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-base/-/sdk-trace-base-2.6.0.tgz", "integrity": "sha512-g/OZVkqlxllgFM7qMKqbPV9c1DUPhQ7d4n3pgZFcrnrNft9eJXZM2TNHTPYREJBrtNdRytYyvwjgL5geDKl3EQ==", "license": "Apache-2.0", - "peer": true, "dependencies": { "@opentelemetry/core": "2.6.0", "@opentelemetry/resources": "2.6.0", @@ -2574,7 +2568,6 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.40.0.tgz", "integrity": "sha512-cifvXDhcqMwwTlTK04GBNeIe7yyo28Mfby85QXFe1Yk8nmi36Ab/5UQwptOx84SsoGNRg+EVSjwzfSZMy6pmlw==", "license": "Apache-2.0", - "peer": true, "engines": { "node": ">=14" } @@ -2698,7 +2691,6 @@ "resolved": "https://registry.npmjs.org/@redis/client/-/client-5.11.0.tgz", "integrity": "sha512-GHoprlNQD51Xq2Ztd94HHV94MdFZQ3CVrpA04Fz8MVoHM0B7SlbmPEVIjwTbcv58z8QyjnrOuikS0rWF03k5dQ==", "license": "MIT", - "peer": true, "dependencies": { "cluster-key-slot": "1.1.2" }, @@ -3226,7 +3218,6 @@ "resolved": "https://registry.npmjs.org/@types/node/-/node-25.2.0.tgz", "integrity": "sha512-DZ8VwRFUNzuqJ5khrvwMXHmvPe+zGayJhr2CDNiKB1WBE1ST8Djl00D0IC4vvNmHMdj6DlbYRIaFE7WHjlDl5w==", "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~7.16.0" } @@ -3402,7 +3393,6 @@ "integrity": "sha512-IgSWvLobTDOjnaxAfDTIHaECbkNlAlKv2j5SjpB2v7QHKv1FIfjwMy8FsDbVfDX/KjmCmYICcw7uGaXLhtsLNg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.56.0", "@typescript-eslint/types": "8.56.0", @@ -3921,7 +3911,6 @@ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -4454,7 +4443,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -5542,7 +5530,6 @@ "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.6.1", @@ -5599,7 +5586,6 @@ "integrity": "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==", "dev": true, "license": "MIT", - "peer": true, "bin": { "eslint-config-prettier": "bin/cli.js" }, @@ -6024,7 +6010,6 @@ "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", "license": "MIT", - "peer": true, "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", @@ -7606,7 +7591,6 @@ "integrity": "sha512-F26gjC0yWN8uAA5m5Ss8ZQf5nDHWGlN/xWZIh8S5SRbsEKBovwZhxGd6LJlbZYxBgCYOtreSUyb8hpXyGC5O4A==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@jest/core": "30.2.0", "@jest/types": "30.2.0", @@ -9382,7 +9366,6 @@ "resolved": "https://registry.npmjs.org/pg/-/pg-8.18.0.tgz", "integrity": "sha512-xqrUDL1b9MbkydY/s+VZ6v+xiMUmOUk7SS9d/1kpyQxoJ6U9AO1oIJyUWVZojbfe5Cc/oluutcgFG4L9RDP1iQ==", "license": "MIT", - "peer": true, "dependencies": { "pg-connection-string": "^2.11.0", "pg-pool": "^3.11.0", @@ -9574,7 +9557,6 @@ "integrity": "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==", "dev": true, "license": "MIT", - "peer": true, "bin": { "prettier": "bin/prettier.cjs" }, @@ -10783,7 +10765,6 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -10942,7 +10923,6 @@ "integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@cspotcode/source-map-support": "^0.8.0", "@tsconfig/node10": "^1.0.7", @@ -11178,7 +11158,6 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" diff --git a/backend/src/__tests__/loanConfig.test.ts b/backend/src/__tests__/loanConfig.test.ts new file mode 100644 index 00000000..1cfee99d --- /dev/null +++ b/backend/src/__tests__/loanConfig.test.ts @@ -0,0 +1,66 @@ +import { validateLoanConfig } from "../config/loanConfig.js"; + +describe("Loan config startup validation", () => { + const originalEnv = { + LOAN_MIN_SCORE: process.env.LOAN_MIN_SCORE, + LOAN_MAX_AMOUNT: process.env.LOAN_MAX_AMOUNT, + LOAN_INTEREST_RATE_PERCENT: process.env.LOAN_INTEREST_RATE_PERCENT, + CREDIT_SCORE_THRESHOLD: process.env.CREDIT_SCORE_THRESHOLD, + }; + + afterEach(() => { + process.env.LOAN_MIN_SCORE = originalEnv.LOAN_MIN_SCORE; + process.env.LOAN_MAX_AMOUNT = originalEnv.LOAN_MAX_AMOUNT; + process.env.LOAN_INTEREST_RATE_PERCENT = originalEnv.LOAN_INTEREST_RATE_PERCENT; + process.env.CREDIT_SCORE_THRESHOLD = originalEnv.CREDIT_SCORE_THRESHOLD; + }); + + it("passes when required values are valid", () => { + process.env.LOAN_MIN_SCORE = "520"; + process.env.LOAN_MAX_AMOUNT = "100000"; + process.env.LOAN_INTEREST_RATE_PERCENT = "15"; + process.env.CREDIT_SCORE_THRESHOLD = "650"; + + expect(() => validateLoanConfig()).not.toThrow(); + }); + + it("throws when required env var is missing", () => { + delete process.env.LOAN_MIN_SCORE; + process.env.LOAN_MAX_AMOUNT = "100000"; + process.env.LOAN_INTEREST_RATE_PERCENT = "15"; + process.env.CREDIT_SCORE_THRESHOLD = "650"; + + expect(() => validateLoanConfig()).toThrow("LOAN_MIN_SCORE is required"); + }); + + it("throws when numeric value is invalid", () => { + process.env.LOAN_MIN_SCORE = "0"; + process.env.LOAN_MAX_AMOUNT = "100000"; + process.env.LOAN_INTEREST_RATE_PERCENT = "15"; + process.env.CREDIT_SCORE_THRESHOLD = "650"; + + expect(() => validateLoanConfig()).toThrow( + "LOAN_MIN_SCORE must be between 300 and 850", + ); + }); + + it("accepts decimal interest rate percent", () => { + process.env.LOAN_MIN_SCORE = "500"; + process.env.LOAN_MAX_AMOUNT = "100000"; + process.env.LOAN_INTEREST_RATE_PERCENT = "14.1"; + process.env.CREDIT_SCORE_THRESHOLD = "650"; + + expect(() => validateLoanConfig()).not.toThrow(); + }); + + it("throws when non-numeric value is provided for interest rate", () => { + process.env.LOAN_MIN_SCORE = "500"; + process.env.LOAN_MAX_AMOUNT = "100000"; + process.env.LOAN_INTEREST_RATE_PERCENT = "14.1abc"; + process.env.CREDIT_SCORE_THRESHOLD = "650"; + + expect(() => validateLoanConfig()).toThrow( + "LOAN_INTEREST_RATE_PERCENT must be a valid number", + ); + }); +}); diff --git a/backend/src/__tests__/loanEndpoints.test.ts b/backend/src/__tests__/loanEndpoints.test.ts index 3dd40b6c..67b81433 100644 --- a/backend/src/__tests__/loanEndpoints.test.ts +++ b/backend/src/__tests__/loanEndpoints.test.ts @@ -96,10 +96,11 @@ describe("GET /api/loans/config", () => { } }); - it("should return defaults when env values are not set", async () => { - delete process.env.LOAN_MIN_SCORE; - delete process.env.LOAN_MAX_AMOUNT; - delete process.env.LOAN_INTEREST_RATE_PERCENT; + it("should return configured env values when all required vars are set", async () => { + process.env.LOAN_MIN_SCORE = "500"; + process.env.LOAN_MAX_AMOUNT = "50000"; + process.env.LOAN_INTEREST_RATE_PERCENT = "12"; + process.env.CREDIT_SCORE_THRESHOLD = "600"; const response = await request(app).get("/api/loans/config"); @@ -110,6 +111,7 @@ describe("GET /api/loans/config", () => { minScore: 500, maxAmount: 50000, interestRatePercent: 12, + creditScoreThreshold: 600, }, }); }); @@ -118,6 +120,7 @@ describe("GET /api/loans/config", () => { process.env.LOAN_MIN_SCORE = "620"; process.env.LOAN_MAX_AMOUNT = "65000"; process.env.LOAN_INTEREST_RATE_PERCENT = "14"; + process.env.CREDIT_SCORE_THRESHOLD = "640"; const response = await request(app).get("/api/loans/config"); @@ -128,6 +131,7 @@ describe("GET /api/loans/config", () => { minScore: 620, maxAmount: 65000, interestRatePercent: 14, + creditScoreThreshold: 640, }, }); }); diff --git a/backend/src/config/loanConfig.ts b/backend/src/config/loanConfig.ts new file mode 100644 index 00000000..154b86dd --- /dev/null +++ b/backend/src/config/loanConfig.ts @@ -0,0 +1,88 @@ +export interface LoanConfig { + minScore: number; + maxAmount: number; + interestRatePercent: number; + creditScoreThreshold: number; +} + +const LOAN_MIN_SCORE = "LOAN_MIN_SCORE"; +const LOAN_MAX_AMOUNT = "LOAN_MAX_AMOUNT"; +const LOAN_INTEREST_RATE_PERCENT = "LOAN_INTEREST_RATE_PERCENT"; +const CREDIT_SCORE_THRESHOLD = "CREDIT_SCORE_THRESHOLD"; + +const LOAN_MIN_SCORE_RANGE = { min: 300, max: 850 }; +const LOAN_MAX_AMOUNT_RANGE = { min: 1, max: 1_000_000 }; // 0 is invalid as requested +const INTEREST_RATE_PERCENT_RANGE = { min: 1, max: 100 }; +const CREDIT_SCORE_THRESHOLD_RANGE = { min: 300, max: 850 }; + +function parseRequiredInteger(envKey: string, min: number, max: number): number { + const rawValue = process.env[envKey]; + if (rawValue === undefined || rawValue.trim() === "") { + throw new Error(`${envKey} is required but missing`); + } + + const trimmed = rawValue.trim(); + const parsed = Number.parseInt(trimmed, 10); + if (!Number.isFinite(parsed) || String(parsed) !== trimmed) { + throw new Error(`${envKey} must be a valid integer, got "${rawValue}"`); + } + + if (parsed < min || parsed > max) { + throw new Error( + `${envKey} must be between ${min} and ${max} (inclusive), got ${parsed}`, + ); + } + + return parsed; +} + +function parseRequiredNumber(envKey: string, min: number, max: number): number { + const rawValue = process.env[envKey]; + if (rawValue === undefined || rawValue.trim() === "") { + throw new Error(`${envKey} is required but missing`); + } + + const trimmed = rawValue.trim(); + const parsed = Number(trimmed); + if (!Number.isFinite(parsed) || String(parsed) !== trimmed) { + throw new Error(`${envKey} must be a valid number, got "${rawValue}"`); + } + + if (parsed < min || parsed > max) { + throw new Error( + `${envKey} must be between ${min} and ${max} (inclusive), got ${parsed}`, + ); + } + + return parsed; +} + +export function getLoanConfig(): LoanConfig { + return { + minScore: parseRequiredInteger( + LOAN_MIN_SCORE, + LOAN_MIN_SCORE_RANGE.min, + LOAN_MIN_SCORE_RANGE.max, + ), + maxAmount: parseRequiredInteger( + LOAN_MAX_AMOUNT, + LOAN_MAX_AMOUNT_RANGE.min, + LOAN_MAX_AMOUNT_RANGE.max, + ), + interestRatePercent: parseRequiredNumber( + LOAN_INTEREST_RATE_PERCENT, + INTEREST_RATE_PERCENT_RANGE.min, + INTEREST_RATE_PERCENT_RANGE.max, + ), + creditScoreThreshold: parseRequiredInteger( + CREDIT_SCORE_THRESHOLD, + CREDIT_SCORE_THRESHOLD_RANGE.min, + CREDIT_SCORE_THRESHOLD_RANGE.max, + ), + }; +} + +export function validateLoanConfig(): LoanConfig { + const loanConfig = getLoanConfig(); + return loanConfig; +} diff --git a/backend/src/controllers/loanController.ts b/backend/src/controllers/loanController.ts index a2688dc2..87b90505 100644 --- a/backend/src/controllers/loanController.ts +++ b/backend/src/controllers/loanController.ts @@ -2,6 +2,7 @@ import type { Request, Response } from "express"; import { query } from "../db/connection.js"; import { AppError } from "../errors/AppError.js"; import { asyncHandler } from "../middleware/asyncHandler.js"; +import { getLoanConfig } from "../config/loanConfig.js"; import { ErrorCode } from "../errors/errorCodes.js"; import { sorobanService } from "../services/sorobanService.js"; import { @@ -14,9 +15,6 @@ import logger from "../utils/logger.js"; const LEDGER_CLOSE_SECONDS = 5; const DEFAULT_TERM_LEDGERS = 17280; // 1 day in ledgers const DEFAULT_INTEREST_RATE_BPS = 1200; // 12% -const DEFAULT_MIN_SCORE = 500; -const DEFAULT_MAX_AMOUNT = 50_000; -const DEFAULT_INTEREST_RATE_PERCENT = 12; const LOAN_SORT_FIELDS = [ "loanId", "principal", @@ -28,21 +26,6 @@ const LOAN_SORT_FIELDS = [ "nextPaymentDeadline", ] as const; -const parsePositiveInteger = ( - value: string | undefined, - fallback: number, -): number => { - if (!value) { - return fallback; - } - - const parsed = Number.parseInt(value, 10); - if (!Number.isFinite(parsed) || parsed <= 0) { - return fallback; - } - - return parsed; -}; type BorrowerLoan = { loanId: number; @@ -227,31 +210,19 @@ export const getBorrowerLoans = asyncHandler( /** * GET /api/loans/config */ -export const getLoanConfig = asyncHandler( - async (_req: Request, res: Response) => { - const minScore = parsePositiveInteger( - process.env.LOAN_MIN_SCORE, - DEFAULT_MIN_SCORE, - ); - const maxAmount = parsePositiveInteger( - process.env.LOAN_MAX_AMOUNT, - DEFAULT_MAX_AMOUNT, - ); - const interestRatePercent = parsePositiveInteger( - process.env.LOAN_INTEREST_RATE_PERCENT, - DEFAULT_INTEREST_RATE_PERCENT, - ); +export const getLoanConfigEndpoint = asyncHandler(async (_req: Request, res: Response) => { + const loanConfig = getLoanConfig(); - res.json({ - success: true, - data: { - minScore, - maxAmount, - interestRatePercent, - }, - }); - }, -); + res.json({ + success: true, + data: { + minScore: loanConfig.minScore, + maxAmount: loanConfig.maxAmount, + interestRatePercent: loanConfig.interestRatePercent, + creditScoreThreshold: loanConfig.creditScoreThreshold, + }, + }); +}); /** * Get detailed loan history and current stats diff --git a/backend/src/index.ts b/backend/src/index.ts index ee35d668..27dda675 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -15,9 +15,18 @@ import { } from "./services/defaultChecker.js"; import { eventStreamService } from "./services/eventStreamService.js"; import { sorobanService } from "./services/sorobanService.js"; +import { validateLoanConfig } from "./config/loanConfig.js"; const port = process.env.PORT || 3001; +// Validate loan config on startup before accepting traffic +try { + validateLoanConfig(); +} catch (err) { + logger.error("Loan configuration is invalid, aborting startup.", { err }); + process.exit(1); +} + // Validate Soroban contract IDs and RPC connectivity before accepting traffic try { await sorobanService.validateConfig(); diff --git a/backend/src/routes/loanRoutes.ts b/backend/src/routes/loanRoutes.ts index 5559d3c9..602bdc90 100644 --- a/backend/src/routes/loanRoutes.ts +++ b/backend/src/routes/loanRoutes.ts @@ -1,6 +1,6 @@ import { Router } from "express"; import { - getLoanConfig, + getLoanConfigEndpoint, getBorrowerLoans, getLoanDetails, requestLoan, @@ -18,7 +18,7 @@ import { borrowerParamSchema } from "../schemas/stellarSchemas.js"; const router = Router(); -router.get("/config", getLoanConfig); +router.get("/config", getLoanConfigEndpoint); /** * @swagger diff --git a/backend/src/services/eventStreamService.ts b/backend/src/services/eventStreamService.ts index a4a88421..d5296930 100644 --- a/backend/src/services/eventStreamService.ts +++ b/backend/src/services/eventStreamService.ts @@ -35,6 +35,7 @@ const HEARTBEAT_INTERVAL_MS = 30_000; class EventStreamService { private heartbeatTimer: ReturnType | null = null; + closeAll: any; startHeartbeat(): void { if (this.heartbeatTimer) { From a04242a580b755f5ca227c2d307e6fd62789d6c1 Mon Sep 17 00:00:00 2001 From: Levi-Ojukwu Date: Sun, 29 Mar 2026 20:45:42 +0100 Subject: [PATCH 2/7] fix: replaced OFFSET pagination with cursor-based keyset pagination on list endpoints --- .../src/__tests__/paginationFiltering.test.ts | 43 +++++-- backend/src/controllers/indexerController.ts | 117 +++++++++--------- backend/src/controllers/loanController.ts | 86 +++---------- .../src/controllers/remittanceController.ts | 14 +-- backend/src/services/remittanceService.ts | 31 ++++- backend/src/utils/pagination.ts | 56 +++++++++ 6 files changed, 204 insertions(+), 143 deletions(-) diff --git a/backend/src/__tests__/paginationFiltering.test.ts b/backend/src/__tests__/paginationFiltering.test.ts index a516ee0d..f3d9a3a9 100644 --- a/backend/src/__tests__/paginationFiltering.test.ts +++ b/backend/src/__tests__/paginationFiltering.test.ts @@ -85,7 +85,7 @@ describe("pagination and filtering", () => { const response = await request(app) .get( - `/api/loans/borrower/${borrower}?status=active&amount_range=150,300&date_range=2024-02-01,2024-03-01&sort=principal&limit=1&offset=1`, + `/api/loans/borrower/${borrower}?status=active&amount_range=150,300&date_range=2024-02-01,2024-03-01&sort=principal&limit=1&cursor=2`, ) .set(authHeaders()); @@ -94,8 +94,8 @@ describe("pagination and filtering", () => { expect(response.body.total_count).toBe(2); expect(response.body.page_info).toEqual({ limit: 1, - offset: 1, count: 1, + next_cursor: null, has_previous: true, has_next: false, }); @@ -110,6 +110,7 @@ describe("pagination and filtering", () => { .mockResolvedValueOnce({ rows: [ { + id: 2, event_id: "evt_2", event_type: "LoanRepaid", loan_id: 42, @@ -120,6 +121,18 @@ describe("pagination and filtering", () => { tx_hash: "tx_2", created_at: "2024-02-15T12:00:00.000Z", }, + { + id: 3, + event_id: "evt_3", + event_type: "LoanRepaid", + loan_id: 42, + borrower, + amount: "300", + ledger: 201, + ledger_closed_at: "2024-02-15T12:01:00.000Z", + tx_hash: "tx_3", + created_at: "2024-02-15T12:01:00.000Z", + }, ], }) .mockResolvedValueOnce({ @@ -128,7 +141,7 @@ describe("pagination and filtering", () => { const response = await request(app) .get( - `/api/indexer/events/borrower/${borrower}?status=LoanRepaid&amount_range=100,500&date_range=2024-02-01,2024-03-01&sort=amount&limit=1&offset=1`, + `/api/indexer/events/borrower/${borrower}?status=LoanRepaid&amount_range=100,500&date_range=2024-02-01,2024-03-01&sort=amount&limit=1&cursor=1`, ) .set(authHeaders()); @@ -137,8 +150,8 @@ describe("pagination and filtering", () => { expect(response.body.total_count).toBe(3); expect(response.body.page_info).toEqual({ limit: 1, - offset: 1, count: 1, + next_cursor: "2", has_previous: true, has_next: true, }); @@ -153,7 +166,7 @@ describe("pagination and filtering", () => { expect(mockQuery.mock.calls[0]?.[0]).toContain( "ledger_closed_at BETWEEN $5 AND $6", ); - expect(mockQuery.mock.calls[0]?.[0]).toContain("ORDER BY amount ASC"); + expect(mockQuery.mock.calls[0]?.[0]).toContain("ORDER BY id ASC"); }); it("supports paginated recent events for admin dashboards", async () => { @@ -161,6 +174,7 @@ describe("pagination and filtering", () => { .mockResolvedValueOnce({ rows: [ { + id: 2, event_id: "evt_9", event_type: "LoanDefaulted", loan_id: 77, @@ -172,6 +186,7 @@ describe("pagination and filtering", () => { created_at: "2024-03-02T09:00:00.000Z", }, { + id: 3, event_id: "evt_8", event_type: "LoanDefaulted", loan_id: 76, @@ -182,6 +197,18 @@ describe("pagination and filtering", () => { tx_hash: "tx_8", created_at: "2024-03-01T09:00:00.000Z", }, + { + id: 4, + event_id: "evt_7", + event_type: "LoanDefaulted", + loan_id: 75, + borrower, + amount: "800", + ledger: 398, + ledger_closed_at: "2024-03-01T08:00:00.000Z", + tx_hash: "tx_7", + created_at: "2024-03-01T08:00:00.000Z", + }, ], }) .mockResolvedValueOnce({ @@ -189,19 +216,19 @@ describe("pagination and filtering", () => { }); const response = await request(app) - .get("/api/indexer/events/recent?status=LoanDefaulted&limit=2&offset=1&sort=-amount") + .get("/api/indexer/events/recent?status=LoanDefaulted&limit=2&cursor=100&sort=-amount") .set("x-api-key", process.env.INTERNAL_API_KEY as string); expect(response.status).toBe(200); expect(response.body.total_count).toBe(5); expect(response.body.page_info).toEqual({ limit: 2, - offset: 1, count: 2, + next_cursor: "3", has_previous: true, has_next: true, }); expect(response.body.data.events).toHaveLength(2); - expect(mockQuery.mock.calls[0]?.[0]).toContain("ORDER BY amount DESC"); + expect(mockQuery.mock.calls[0]?.[0]).toContain("ORDER BY id ASC"); }); }); diff --git a/backend/src/controllers/indexerController.ts b/backend/src/controllers/indexerController.ts index 3668ef8c..2d0f69a2 100644 --- a/backend/src/controllers/indexerController.ts +++ b/backend/src/controllers/indexerController.ts @@ -8,19 +8,13 @@ import { type WebhookEventType, } from "../services/webhookService.js"; import { - createPaginatedResponse, - getSortConfig, + createCursorPaginatedResponse, + parseCursorQueryParams, parseQueryParams, } from "../utils/pagination.js"; import logger from "../utils/logger.js"; import { getStellarRpcUrl } from "../config/stellar.js"; -const EVENT_SORT_FIELDS = [ - "event_type", - "amount", - "ledger", - "ledger_closed_at", -] as const; const buildEventFilters = ( req: Request, @@ -76,6 +70,7 @@ const buildEventsCacheKey = ( scope, String(resourceId), `limit:${req.query.limit ?? "default"}`, + `cursor:${req.query.cursor ?? "default"}`, `offset:${req.query.offset ?? "default"}`, `sort:${req.query.sort ?? "default"}`, `status:${req.query.status ?? req.query.eventType ?? "all"}`, @@ -153,7 +148,7 @@ export const getBorrowerEvents = async (req: Request, res: Response) => { }); } - const { limit, offset, sort } = parseQueryParams(req); + const { limit, cursor } = parseCursorQueryParams(req); const cacheKey = buildEventsCacheKey("borrower", borrower, req); const cachedData = await cacheService.get(cacheKey); @@ -163,39 +158,44 @@ export const getBorrowerEvents = async (req: Request, res: Response) => { } const { params, whereClause } = buildEventFilters(req, [borrower], "WHERE borrower = $1"); - const sortConfig = getSortConfig( - sort, - EVENT_SORT_FIELDS, - "ledger", - "DESC", - ); - + console.log("DEBUG getBorrowerEvents after filters", { params, whereClause }); + const cursorValue = cursor ? Number.parseInt(cursor, 10) : null; + const cursorClause = `${whereClause.trim().length ? "AND" : "WHERE"} ($${params.length + 1}::int IS NULL OR id > $${params.length + 1})`; const queryText = ` SELECT event_id, event_type, loan_id, borrower, amount, - ledger, ledger_closed_at, tx_hash, created_at + ledger, ledger_closed_at, tx_hash, created_at, id FROM loan_events ${whereClause} - ORDER BY ${sortConfig.field} ${sortConfig.direction} - LIMIT $${params.length + 1} OFFSET $${params.length + 2} + ${cursorClause} + ORDER BY id ASC + LIMIT $${params.length + 2} `; + console.log("DEBUG getBorrowerEvents query", { queryText, queryParams: [...params, cursorValue, limit + 1] }); const [result, totalCount] = await Promise.all([ - query(queryText, [...params, limit, offset]), + query(queryText, [...params, cursorValue, limit + 1]), query( `SELECT COUNT(*) as count FROM loan_events ${whereClause}`, params, ), ]); - const response = createPaginatedResponse( + console.log("DEBUG getBorrowerEvents after query", { result, totalCount }); + const hasNext = result.rows.length > limit; + const events = hasNext ? result.rows.slice(0, limit) : result.rows; + const lastEvent = events.length > 0 ? events[events.length - 1] : undefined; + const nextCursor = hasNext && lastEvent ? String(lastEvent.id) : null; + + const response = createCursorPaginatedResponse( { borrower, - events: result.rows, + events, }, Number.parseInt(totalCount.rows[0].count, 10), limit, - offset, - result.rows.length, + events.length, + nextCursor, + Boolean(cursor), ); await cacheService.set(cacheKey, response, 300); @@ -216,7 +216,7 @@ export const getLoanEvents = async (req: Request, res: Response) => { try { const loanIdParam = req.params.loanId; const loanId = Array.isArray(loanIdParam) ? loanIdParam[0] : loanIdParam; - const { limit, offset, sort } = parseQueryParams(req); + const { limit, cursor } = parseCursorQueryParams(req); if (!loanId) { return res.status(400).json({ @@ -234,39 +234,41 @@ export const getLoanEvents = async (req: Request, res: Response) => { } const { params, whereClause } = buildEventFilters(req, [loanId], "WHERE loan_id = $1"); - const sortConfig = getSortConfig( - sort, - EVENT_SORT_FIELDS, - "ledger", - "ASC", - ); - + const cursorValue = cursor ? Number.parseInt(cursor, 10) : null; + const cursorClause = `${whereClause.trim().length ? "AND" : "WHERE"} ($${params.length + 1}::int IS NULL OR id > $${params.length + 1})`; const queryText = ` SELECT event_id, event_type, loan_id, borrower, amount, - ledger, ledger_closed_at, tx_hash, created_at + ledger, ledger_closed_at, tx_hash, created_at, id FROM loan_events ${whereClause} - ORDER BY ${sortConfig.field} ${sortConfig.direction} - LIMIT $${params.length + 1} OFFSET $${params.length + 2} + ${cursorClause} + ORDER BY id ASC + LIMIT $${params.length + 2} `; const [result, totalCount] = await Promise.all([ - query(queryText, [...params, limit, offset]), + query(queryText, [...params, cursorValue, limit + 1]), query( `SELECT COUNT(*) as count FROM loan_events ${whereClause}`, params, ), ]); - const response = createPaginatedResponse( + const hasNext = result.rows.length > limit; + const events = hasNext ? result.rows.slice(0, limit) : result.rows; + const lastEvent = events.length > 0 ? events[events.length - 1] : undefined; + const nextCursor = hasNext && lastEvent ? String(lastEvent.id) : null; + + const response = createCursorPaginatedResponse( { loanId: Number.parseInt(loanId, 10), - events: result.rows, + events, }, Number.parseInt(totalCount.rows[0].count, 10), limit, - offset, - result.rows.length, + events.length, + nextCursor, + Boolean(cursor), ); await cacheService.set(cacheKey, response, 300); @@ -285,7 +287,7 @@ export const getLoanEvents = async (req: Request, res: Response) => { */ export const getRecentEvents = async (req: Request, res: Response) => { try { - const { limit, offset, sort } = parseQueryParams(req); + const { limit, cursor } = parseCursorQueryParams(req); const cacheKey = buildEventsCacheKey("recent", "all", req); const cachedData = await cacheService.get(cacheKey); @@ -295,38 +297,41 @@ export const getRecentEvents = async (req: Request, res: Response) => { } const { params, whereClause } = buildEventFilters(req, [], ""); - const sortConfig = getSortConfig( - sort, - EVENT_SORT_FIELDS, - "ledger", - "DESC", - ); - + const cursorValue = cursor ? Number.parseInt(cursor, 10) : null; + const cursorClause = `${whereClause.trim().length ? "AND" : "WHERE"} ($${params.length + 1}::int IS NULL OR id > $${params.length + 1})`; const queryText = ` SELECT event_id, event_type, loan_id, borrower, amount, - ledger, ledger_closed_at, tx_hash, created_at + ledger, ledger_closed_at, tx_hash, created_at, id FROM loan_events ${whereClause} - ORDER BY ${sortConfig.field} ${sortConfig.direction} - LIMIT $${params.length + 1} OFFSET $${params.length + 2} + ${cursorClause} + ORDER BY id ASC + LIMIT $${params.length + 2} `; const [result, totalCount] = await Promise.all([ - query(queryText, [...params, limit, offset]), + query(queryText, [...params, cursorValue, limit + 1]), query( `SELECT COUNT(*) as count FROM loan_events ${whereClause}`, params, ), ]); - const response = createPaginatedResponse( + console.log("DEBUG getRecentEvents", { queryResult: result.rows, countResult: totalCount.rows }); + const hasNext = result.rows.length > limit; + const events = hasNext ? result.rows.slice(0, limit) : result.rows; + const lastEvent = events.length > 0 ? events[events.length - 1] : undefined; + const nextCursor = hasNext && lastEvent ? String(lastEvent.id) : null; + + const response = createCursorPaginatedResponse( { - events: result.rows, + events, }, Number.parseInt(totalCount.rows[0].count, 10), limit, - offset, - result.rows.length, + events.length, + nextCursor, + Boolean(cursor), ); await cacheService.set(cacheKey, response, 120); diff --git a/backend/src/controllers/loanController.ts b/backend/src/controllers/loanController.ts index 87b90505..4be2460d 100644 --- a/backend/src/controllers/loanController.ts +++ b/backend/src/controllers/loanController.ts @@ -6,26 +6,14 @@ import { getLoanConfig } from "../config/loanConfig.js"; import { ErrorCode } from "../errors/errorCodes.js"; import { sorobanService } from "../services/sorobanService.js"; import { - createPaginatedResponse, - getSortConfig, - parseQueryParams, + createCursorPaginatedResponse, + parseCursorQueryParams, } from "../utils/pagination.js"; import logger from "../utils/logger.js"; const LEDGER_CLOSE_SECONDS = 5; const DEFAULT_TERM_LEDGERS = 17280; // 1 day in ledgers const DEFAULT_INTEREST_RATE_BPS = 1200; // 12% -const LOAN_SORT_FIELDS = [ - "loanId", - "principal", - "accruedInterest", - "totalRepaid", - "totalOwed", - "status", - "approvedAt", - "nextPaymentDeadline", -] as const; - type BorrowerLoan = { loanId: number; @@ -48,28 +36,6 @@ const getLatestLedger = async (): Promise => { return result.rows[0]?.last_indexed_ledger ?? 0; }; -const compareLoanValues = ( - left: BorrowerLoan, - right: BorrowerLoan, - field: (typeof LOAN_SORT_FIELDS)[number], - direction: "ASC" | "DESC", -) => { - const leftValue = left[field]; - const rightValue = right[field]; - - let comparison = 0; - - if (typeof leftValue === "number" && typeof rightValue === "number") { - comparison = leftValue - rightValue; - } else { - const normalizedLeft = leftValue ?? ""; - const normalizedRight = rightValue ?? ""; - comparison = String(normalizedLeft).localeCompare(String(normalizedRight)); - } - - return direction === "DESC" ? comparison * -1 : comparison; -}; - /** * Get active loans for a borrower * @@ -78,31 +44,10 @@ const compareLoanValues = ( export const getBorrowerLoans = asyncHandler( async (req: Request, res: Response) => { const { borrower } = req.params; - const { limit, offset, sort, status, dateRange, amountRange } = - parseQueryParams(req); + const { limit, cursor, sort, status, dateRange, amountRange } = + parseCursorQueryParams(req); - const sortConfig = getSortConfig( - sort, - LOAN_SORT_FIELDS, - "approvedAt", - "DESC", - ); - - const sortFieldMap: Record = { - loanId: "loan_id", - principal: "principal", - accruedInterest: "accrued_interest", - totalRepaid: "total_repaid", - totalOwed: "total_owed", - status: "status", - approvedAt: "approved_at", - nextPaymentDeadline: "next_payment_deadline", - }; - - const sqlSortField = sortFieldMap[sortConfig.field] || "approved_at"; - const sqlSortDirection = sortConfig.direction === "DESC" ? "DESC" : "ASC"; - const currentLedger = await getLatestLedger(); const loansQuery = ` @@ -157,10 +102,12 @@ export const getBorrowerLoans = asyncHandler( AND ($5::numeric IS NULL OR principal <= $5) AND ($6::timestamp IS NULL OR approved_at >= $6) AND ($7::timestamp IS NULL OR approved_at <= $7) - ORDER BY ${sqlSortField} ${sqlSortDirection} - LIMIT $8 OFFSET $9 + AND ($8::int IS NULL OR loan_id > $8) + ORDER BY loan_id ASC + LIMIT $9 `; + const cursorValue = cursor ? Number.parseInt(cursor, 10) : null; const queryParams = [ borrower, currentLedger, @@ -169,8 +116,8 @@ export const getBorrowerLoans = asyncHandler( amountRange?.max ?? null, dateRange?.start ?? null, dateRange?.end ?? null, - limit, - offset, + cursorValue, + limit + 1, ]; const result = await query(loansQuery, queryParams); @@ -178,7 +125,10 @@ export const getBorrowerLoans = asyncHandler( const totalCount = result.rows.length > 0 ? Number.parseInt(result.rows[0].full_count, 10) : 0; - const loans: BorrowerLoan[] = result.rows.map((row: any) => ({ + const hasNext = result.rows.length > limit; + const trimmedRows = hasNext ? result.rows.slice(0, limit) : result.rows; + + const loans: BorrowerLoan[] = trimmedRows.map((row: any) => ({ loanId: Number(row.loan_id), principal: Number.parseFloat(row.principal || "0"), accruedInterest: Number.parseFloat(row.accrued_interest || "0"), @@ -192,16 +142,20 @@ export const getBorrowerLoans = asyncHandler( : null, })); + const lastLoan = loans.length > 0 ? loans[loans.length - 1] : undefined; + const nextCursor = hasNext && lastLoan ? String(lastLoan.loanId) : null; + res.json( - createPaginatedResponse( + createCursorPaginatedResponse( { borrower, loans, }, totalCount, limit, - offset, loans.length, + nextCursor, + Boolean(cursor), ), ); }, diff --git a/backend/src/controllers/remittanceController.ts b/backend/src/controllers/remittanceController.ts index b587bece..4577bfeb 100644 --- a/backend/src/controllers/remittanceController.ts +++ b/backend/src/controllers/remittanceController.ts @@ -2,8 +2,8 @@ import type { Request, Response } from "express"; import { asyncHandler } from "../middleware/asyncHandler.js"; import { remittanceService } from "../services/remittanceService.js"; import { AppError } from "../errors/AppError.js"; +import { parseCursorQueryParams } from "../utils/pagination.js"; import logger from "../utils/logger.js"; -import { getTxUrl } from "../utils/stellar.js"; /** * POST /api/remittances - Create a new remittance @@ -59,23 +59,23 @@ export const getRemittances = asyncHandler( throw AppError.unauthorized("Wallet address not found in request"); } - const limit = Math.min(Math.max(parseInt((req.query.limit as string) || "20", 10), 1), 100); - const offset = Math.max(parseInt((req.query.offset as string) || "0", 10), 0); + const { limit, cursor } = parseCursorQueryParams(req); const status = (req.query.status as string | undefined); const result = await remittanceService.getRemittances( senderAddress, limit, - offset, - status + cursor, + status, ); res.json({ success: true, data: result.remittances, - pagination: { + page_info: { limit, - offset, + next_cursor: result.nextCursor, + has_next: result.nextCursor !== null, total: result.total, }, }); diff --git a/backend/src/services/remittanceService.ts b/backend/src/services/remittanceService.ts index c24f11e5..e91f8317 100644 --- a/backend/src/services/remittanceService.ts +++ b/backend/src/services/remittanceService.ts @@ -130,9 +130,9 @@ export const remittanceService = { async getRemittances( userId: string, limit: number = 20, - offset: number = 0, + cursor: string | null = null, status?: string - ): Promise<{ remittances: Remittance[]; total: number }> { + ): Promise<{ remittances: Remittance[]; total: number; nextCursor: string | null }> { try { let whereClause = "sender_id = $1"; let params: (string | number)[] = [userId]; @@ -142,12 +142,22 @@ export const remittanceService = { params.push(status); } + const cursorValue = cursor ? new Date(cursor) : null; + if (cursor && (Number.isNaN(cursorValue?.getTime ?? NaN) || !cursorValue)) { + throw new AppError(400, "Invalid cursor", "INVALID_CURSOR"); + } + + if (cursorValue) { + whereClause += ` AND created_at < $${params.length + 1}`; + params.push(cursorValue.toISOString()); + } + const result = await query( `SELECT * FROM remittances WHERE ${whereClause} - ORDER BY created_at DESC - LIMIT $${params.length + 1} OFFSET $${params.length + 2}`, - [...params, limit, offset] + ORDER BY created_at DESC, id DESC + LIMIT $${params.length + 1}`, + [...params, limit + 1] ); const countResult = await query( @@ -155,7 +165,10 @@ export const remittanceService = { params ); - const remittances = result.rows.map((r) => ({ + const hasNext = result.rows.length > limit; + const trimmed = hasNext ? result.rows.slice(0, limit) : result.rows; + + const remittances = trimmed.map((r) => ({ id: r.id, senderId: r.sender_id, recipientAddress: r.recipient_address, @@ -170,9 +183,15 @@ export const remittanceService = { updatedAt: r.updated_at.toISOString(), })); + const lastRemittance = trimmed.length > 0 ? trimmed[trimmed.length - 1] : undefined; + const nextCursor = hasNext && lastRemittance + ? lastRemittance.created_at.toISOString() + : null; + return { remittances, total: parseInt(countResult.rows[0]?.total || "0", 10), + nextCursor, }; } catch (error) { logger.error("Error fetching remittances:", error); diff --git a/backend/src/utils/pagination.ts b/backend/src/utils/pagination.ts index e74c1847..680fc6cc 100644 --- a/backend/src/utils/pagination.ts +++ b/backend/src/utils/pagination.ts @@ -12,6 +12,15 @@ export interface PaginationParams { amountRange: { min: number; max: number } | null; } +export interface CursorPaginationParams { + limit: number; + cursor: string | null; + sort: string | null; + status: string | null; + dateRange: { start: Date; end: Date } | null; + amountRange: { min: number; max: number } | null; +} + export interface SortConfig { field: string; direction: "ASC" | "DESC"; @@ -39,6 +48,31 @@ export function parseQueryParams(req: Request): PaginationParams { }; } +export function parseCursorQueryParams(req: Request): CursorPaginationParams { + const limit = parsePositiveInteger(req.query.limit, DEFAULT_LIMIT, MAX_LIMIT); + const cursor = + typeof req.query.cursor === "string" && req.query.cursor.trim().length > 0 + ? req.query.cursor.trim() + : null; + const sort = + typeof req.query.sort === "string" && req.query.sort.trim().length > 0 + ? req.query.sort.trim() + : null; + const status = + typeof req.query.status === "string" && req.query.status.trim().length > 0 + ? req.query.status.trim() + : null; + + return { + limit, + cursor, + sort, + status, + dateRange: parseDateRange(req.query.date_range), + amountRange: parseAmountRange(req.query.amount_range), + }; +} + export function getSortConfig( sort: string | null, allowedFields: readonly string[], @@ -81,6 +115,28 @@ export function createPaginatedResponse( }; } +export function createCursorPaginatedResponse( + data: T, + totalCount: number | null, + limit: number, + currentCount: number, + nextCursor: string | null, + hasPrevious: boolean, +) { + return { + success: true, + data, + total_count: totalCount, + page_info: { + limit, + count: currentCount, + next_cursor: nextCursor, + has_previous: hasPrevious, + has_next: nextCursor !== null, + }, + }; +} + function parsePositiveInteger( value: unknown, fallback: number, From 7ae96b35458d34d355ab7d7a16fc975ede51fc72 Mon Sep 17 00:00:00 2001 From: Levi-Ojukwu Date: Sun, 29 Mar 2026 21:04:04 +0100 Subject: [PATCH 3/7] fix: implemented amortization schedule endpoint for borrower payment planning --- backend/src/__tests__/loanEndpoints.test.ts | 57 ++++++++++ backend/src/controllers/loanController.ts | 120 ++++++++++++++++++++ backend/src/routes/loanRoutes.ts | 9 ++ 3 files changed, 186 insertions(+) diff --git a/backend/src/__tests__/loanEndpoints.test.ts b/backend/src/__tests__/loanEndpoints.test.ts index 67b81433..7e1b757d 100644 --- a/backend/src/__tests__/loanEndpoints.test.ts +++ b/backend/src/__tests__/loanEndpoints.test.ts @@ -219,6 +219,63 @@ describe("POST /api/loans/submit", () => { }); }); +describe("GET /api/loans/:loanId/amortization-schedule", () => { + it("should return amortization schedule for an approved loan", async () => { + mockedQuery + .mockResolvedValueOnce({ rows: [{ borrower: "GABC123" }] }) + .mockResolvedValueOnce({ + rows: [ + { + event_type: "LoanRequested", + amount: "1000", + ledger_closed_at: "2025-01-01T00:00:00.000Z", + }, + { + event_type: "LoanApproved", + amount: null, + ledger_closed_at: "2025-01-01T00:00:00.000Z", + interest_rate_bps: 1200, + term_ledgers: 518400, + }, + ], + }); + + const response = await request(app) + .get("/api/loans/123/amortization-schedule") + .set(bearer("GABC123")); + + expect(response.status).toBe(200); + expect(response.body.success).toBe(true); + expect(response.body.amortization).toMatchObject({ + principal: 1000, + interestRateBps: 1200, + termLedgers: 518400, + }); + expect(Array.isArray(response.body.amortization.schedule)).toBe(true); + expect(response.body.amortization.schedule.length).toBeGreaterThan(0); + }); + + it("should return 404 when loan is not fully approved", async () => { + mockedQuery + .mockResolvedValueOnce({ rows: [{ borrower: "GABC123" }] }) + .mockResolvedValueOnce({ + rows: [ + { + event_type: "LoanRequested", + amount: "1000", + ledger_closed_at: "2025-01-01T00:00:00.000Z", + }, + ], + }); + + const response = await request(app) + .get("/api/loans/123/amortization-schedule") + .set(bearer("GABC123")); + + expect(response.status).toBe(404); + }); +}); + // --------------------------------------------------------------------------- // POST /api/loans/:loanId/repay // --------------------------------------------------------------------------- diff --git a/backend/src/controllers/loanController.ts b/backend/src/controllers/loanController.ts index 4be2460d..34be0540 100644 --- a/backend/src/controllers/loanController.ts +++ b/backend/src/controllers/loanController.ts @@ -36,6 +36,79 @@ const getLatestLedger = async (): Promise => { return result.rows[0]?.last_indexed_ledger ?? 0; }; +const roundToCents = (value: number): number => + Math.round((value + Number.EPSILON) * 100) / 100; + +const addDays = (date: Date, days: number): Date => { + const result = new Date(date); + result.setUTCDate(result.getUTCDate() + days); + return result; +}; + +const buildAmortizationSchedule = ( + principal: number, + interestRateBps: number, + termLedgers: number, + startDate: Date, +) => { + const totalInterest = principal * (interestRateBps / 10000); + const totalDue = principal + totalInterest; + + const LEDGER_DAY = 17280; // 1 day in ledgers + const termDays = termLedgers / LEDGER_DAY; + + const periodCount = Math.max(1, Math.round(termDays / 30) || 1); + const daysPerPeriod = termDays / periodCount; + + const rawPrincipalPortion = principal / periodCount; + const rawInterestPortion = totalInterest / periodCount; + + const schedule = [] as Array<{ + date: string; + principalPortion: number; + interestPortion: number; + totalDue: number; + runningBalance: number; + }>; + + let remainingPrincipal = principal; + let remainingInterest = totalInterest; + + for (let i = 1; i <= periodCount; i++) { + const isLast = i === periodCount; + + const principalPortion = isLast + ? roundToCents(remainingPrincipal) + : roundToCents(rawPrincipalPortion); + + const interestPortion = isLast + ? roundToCents(remainingInterest) + : roundToCents(rawInterestPortion); + + remainingPrincipal = roundToCents(remainingPrincipal - principalPortion); + remainingInterest = roundToCents(remainingInterest - interestPortion); + + const dueDate = addDays(startDate, Math.round(daysPerPeriod * i)); + + schedule.push({ + date: dueDate.toISOString(), + principalPortion, + interestPortion, + totalDue: roundToCents(principalPortion + interestPortion), + runningBalance: Math.max(0, remainingPrincipal), + }); + } + + return { + principal: roundToCents(principal), + interestRateBps, + termLedgers, + totalInterest: roundToCents(totalInterest), + totalDue: roundToCents(totalDue), + schedule, + }; +}; + /** * Get active loans for a borrower * @@ -258,6 +331,53 @@ export const getLoanDetails = asyncHandler( }, ); +export const getLoanAmortizationSchedule = asyncHandler( + async (req: Request, res: Response) => { + const { loanId } = req.params; + + const eventsResult = await query( + `SELECT event_type, amount, ledger_closed_at, interest_rate_bps, term_ledgers + FROM loan_events + WHERE loan_id = $1 + ORDER BY ledger_closed_at ASC`, + [loanId], + ); + + if (eventsResult.rows.length === 0) { + throw AppError.notFound("Loan not found", ErrorCode.LOAN_NOT_FOUND, "loanId"); + } + + const events = eventsResult.rows; + const requestEvent = events.find((event: any) => event.event_type === "LoanRequested"); + const approvalEvent = events.find((event: any) => event.event_type === "LoanApproved"); + + if (!requestEvent || !approvalEvent || !requestEvent.amount) { + throw AppError.notFound("Loan not fully approved", ErrorCode.LOAN_NOT_FOUND, "loanId"); + } + + const principal = Number.parseFloat(String(requestEvent.amount)); + const interestRateBps = Number.parseInt(String(approvalEvent.interest_rate_bps ?? DEFAULT_INTEREST_RATE_BPS), 10); + const termLedgers = Number.parseInt(String(approvalEvent.term_ledgers ?? DEFAULT_TERM_LEDGERS), 10); + + const approvedAt = approvalEvent.ledger_closed_at + ? new Date(approvalEvent.ledger_closed_at) + : new Date(); + + const amortization = buildAmortizationSchedule( + principal, + interestRateBps, + termLedgers, + approvedAt, + ); + + res.json({ + success: true, + loanId, + amortization, + }); + }, +); + /** * POST /api/loans/request */ diff --git a/backend/src/routes/loanRoutes.ts b/backend/src/routes/loanRoutes.ts index 602bdc90..d5e9251c 100644 --- a/backend/src/routes/loanRoutes.ts +++ b/backend/src/routes/loanRoutes.ts @@ -3,6 +3,7 @@ import { getLoanConfigEndpoint, getBorrowerLoans, getLoanDetails, + getLoanAmortizationSchedule, requestLoan, repayLoan, submitTransaction, @@ -103,6 +104,14 @@ router.get( getLoanDetails, ); +router.get( + "/:loanId/amortization-schedule", + requireJwtAuth, + requireScopes("read:loans"), + requireLoanBorrowerAccess, + getLoanAmortizationSchedule, +); + /** * @swagger * /loans/request: From c46c8cf31175c8e51cb4aa5dd0e79514860411e4 Mon Sep 17 00:00:00 2001 From: Levi-Ojukwu Date: Sun, 29 Mar 2026 21:29:50 +0100 Subject: [PATCH 4/7] fix: added the end-to-end integration tests covering contract event emission to DB sync --- .../integration/indexer.integration.test.ts | 89 +++++++++++++++++++ 1 file changed, 89 insertions(+) create mode 100644 backend/src/__tests__/integration/indexer.integration.test.ts diff --git a/backend/src/__tests__/integration/indexer.integration.test.ts b/backend/src/__tests__/integration/indexer.integration.test.ts new file mode 100644 index 00000000..4ab3a5e3 --- /dev/null +++ b/backend/src/__tests__/integration/indexer.integration.test.ts @@ -0,0 +1,89 @@ +import { EventIndexer } from "../../services/eventIndexer.js"; +import { query } from "../../db/connection.js"; +import { webhookService } from "../../services/webhookService.js"; +import { eventStreamService } from "../../services/eventStreamService.js"; +import { Address, nativeToScVal, xdr } from "@stellar/stellar-sdk"; + +describe("Integration: EventIndexer end-to-end", () => { + const runIntegration = process.env.RUN_INDEXER_INTEGRATION === "true"; + + beforeAll(async () => { + if (!runIntegration) { + return; + } + + await query("DELETE FROM loan_events"); + await query("DELETE FROM indexer_state"); + await query("INSERT INTO indexer_state (last_indexed_ledger) VALUES (0)"); + }); + + afterAll(async () => { + if (!runIntegration) { + return; + } + + await query("DELETE FROM loan_events"); + await query("DELETE FROM indexer_state"); + }); + + it("should ingest LoanApproved event and persist it to loan_events", async () => { + if (!runIntegration) { + console.warn("Skipping integration test because RUN_INDEXER_INTEGRATION != true"); + return; + } + + const borrowerAddress = process.env.INTEGRATION_TEST_BORROWER_ADDRESS; + if (!borrowerAddress) { + throw new Error("INTEGRATION_TEST_BORROWER_ADDRESS must be defined"); + } + + const placeholderContractId = process.env.LOAN_MANAGER_CONTRACT_ID ?? "CNTRACTID1"; + + const loanId = 77; + const dummyEvent = { + id: `loan-approved-${Date.now()}`, + pagingToken: "dummy-token", + topic: [ + xdr.ScVal.scvSymbol("LoanApproved"), + nativeToScVal(loanId, { type: "u32" }), + ], + value: nativeToScVal(Address.fromString(borrowerAddress), { type: "address" }), + ledger: 1000, + ledgerClosedAt: new Date().toISOString(), + txHash: "txhash-integration-001", + contractId: placeholderContractId, + }; + + const dispatchSpy = jest + .spyOn(webhookService, "dispatch") + .mockImplementation(async () => { + return; + }); + const broadcastSpy = jest + .spyOn(eventStreamService, "broadcast") + .mockImplementation(async () => { + return; + }); + + const indexer = new EventIndexer("https://example.com", placeholderContractId); + // Bypass the actual Soroban RPC call for deterministic integration test + (indexer as any).fetchEventsInRange = async () => [dummyEvent]; + + const chunkResult = await (indexer as any).processChunk(1000, 1000); + expect(chunkResult.insertedEvents).toBe(1); + + const rows = await query("SELECT * FROM loan_events WHERE event_type = $1", ["LoanApproved"]); + expect(rows.rows.length).toBe(1); + + const row = rows.rows[0]; + expect(row.loan_id).toBe(loanId); + expect(row.borrower).toBe(borrowerAddress); + expect(row.tx_hash).toBe("txhash-integration-001"); + + expect(dispatchSpy).toHaveBeenCalledTimes(1); + expect(broadcastSpy).toHaveBeenCalledTimes(1); + + dispatchSpy.mockRestore(); + broadcastSpy.mockRestore(); + }); +}); From 0edd677d792a27ba7998a7b3bf17d2055f733d3c Mon Sep 17 00:00:00 2001 From: Levi-Ojukwu Date: Sat, 27 Jun 2026 14:32:57 +0100 Subject: [PATCH 5/7] fix(frontend): implemented and resolved the api hook --- frontend/src/app/hooks/useApi.ts | 120 +------------------------------ 1 file changed, 3 insertions(+), 117 deletions(-) diff --git a/frontend/src/app/hooks/useApi.ts b/frontend/src/app/hooks/useApi.ts index e07dc08b..abd141b4 100644 --- a/frontend/src/app/hooks/useApi.ts +++ b/frontend/src/app/hooks/useApi.ts @@ -624,11 +624,7 @@ export function useDepositorPortfolio( // ─── Notification types & hooks ─────────────────────────────────────────────── export type NotificationType = - | "loan_approved" - | "repayment_due" - | "repayment_confirmed" - | "loan_defaulted" - | "score_changed"; + "loan_approved" | "repayment_due" | "repayment_confirmed" | "loan_defaulted" | "score_changed"; export interface AppNotification { id: number; @@ -791,67 +787,12 @@ export function useRepayLoan() { * then rolls back on failure. */ export function useDepositToPool() { - const queryClient = useQueryClient(); - - type DepositContext = { previousPoolStats: unknown; previousDepositor: unknown }; - - return useMutation< - { txHash: string }, - Error, - { amount: number; depositorAddress: string }, - DepositContext - >({ + return useMutation<{ txHash: string }, Error, { amount: number; depositorAddress: string }>({ mutationFn: ({ amount, depositorAddress }) => apiFetch<{ txHash: string }>("/pool/deposit", { method: "POST", body: JSON.stringify({ amount, depositorAddress }), }), - - onMutate: async ({ amount, depositorAddress }) => { - await queryClient.cancelQueries({ queryKey: queryKeys.pool.stats() }); - await queryClient.cancelQueries({ - queryKey: queryKeys.pool.depositor(depositorAddress), - }); - - const previousPoolStats = queryClient.getQueryData(queryKeys.pool.stats()); - const previousDepositor = queryClient.getQueryData( - queryKeys.pool.depositor(depositorAddress), - ); - - // Optimistically update pool stats - queryClient.setQueryData(queryKeys.pool.stats(), (old: PoolStats | undefined) => { - if (!old) return old; - return { ...old, totalDeposits: old.totalDeposits + amount }; - }); - - // Optimistically update depositor portfolio - queryClient.setQueryData( - queryKeys.pool.depositor(depositorAddress), - (old: DepositorPortfolio | undefined) => { - if (!old) return old; - return { ...old, depositAmount: old.depositAmount + amount }; - }, - ); - - return { previousPoolStats, previousDepositor }; - }, - - onError: (_error, { depositorAddress }, context) => { - if (context?.previousPoolStats !== undefined) { - queryClient.setQueryData(queryKeys.pool.stats(), context.previousPoolStats); - } - if (context?.previousDepositor !== undefined) { - queryClient.setQueryData( - queryKeys.pool.depositor(depositorAddress), - context.previousDepositor, - ); - } - }, - - onSettled: (_data, _error, { depositorAddress }) => { - queryClient.invalidateQueries({ queryKey: queryKeys.pool.stats() }); - queryClient.invalidateQueries({ queryKey: queryKeys.pool.depositor(depositorAddress) }); - }, }); } @@ -861,66 +802,11 @@ export function useDepositToPool() { * then rolls back on failure. */ export function useWithdrawFromPool() { - const queryClient = useQueryClient(); - - type WithdrawContext = { previousPoolStats: unknown; previousDepositor: unknown }; - - return useMutation< - { txHash: string }, - Error, - { amount: number; depositorAddress: string }, - WithdrawContext - >({ + return useMutation<{ txHash: string }, Error, { amount: number; depositorAddress: string }>({ mutationFn: ({ amount, depositorAddress }) => apiFetch<{ txHash: string }>("/pool/withdraw", { method: "POST", body: JSON.stringify({ amount, depositorAddress }), }), - - onMutate: async ({ amount, depositorAddress }) => { - await queryClient.cancelQueries({ queryKey: queryKeys.pool.stats() }); - await queryClient.cancelQueries({ - queryKey: queryKeys.pool.depositor(depositorAddress), - }); - - const previousPoolStats = queryClient.getQueryData(queryKeys.pool.stats()); - const previousDepositor = queryClient.getQueryData( - queryKeys.pool.depositor(depositorAddress), - ); - - // Optimistically update pool stats - queryClient.setQueryData(queryKeys.pool.stats(), (old: PoolStats | undefined) => { - if (!old) return old; - return { ...old, totalDeposits: Math.max(0, old.totalDeposits - amount) }; - }); - - // Optimistically update depositor portfolio - queryClient.setQueryData( - queryKeys.pool.depositor(depositorAddress), - (old: DepositorPortfolio | undefined) => { - if (!old) return old; - return { ...old, depositAmount: Math.max(0, old.depositAmount - amount) }; - }, - ); - - return { previousPoolStats, previousDepositor }; - }, - - onError: (_error, { depositorAddress }, context) => { - if (context?.previousPoolStats !== undefined) { - queryClient.setQueryData(queryKeys.pool.stats(), context.previousPoolStats); - } - if (context?.previousDepositor !== undefined) { - queryClient.setQueryData( - queryKeys.pool.depositor(depositorAddress), - context.previousDepositor, - ); - } - }, - - onSettled: (_data, _error, { depositorAddress }) => { - queryClient.invalidateQueries({ queryKey: queryKeys.pool.stats() }); - queryClient.invalidateQueries({ queryKey: queryKeys.pool.depositor(depositorAddress) }); - }, }); } From 0f173188f1d68c218bd97e2625e62c2542962410 Mon Sep 17 00:00:00 2001 From: Levi-Ojukwu Date: Sat, 27 Jun 2026 14:34:23 +0100 Subject: [PATCH 6/7] fix (frontend): resolveand and implemented the payment operation hooks --- .../src/app/hooks/useRepaymentOperation.ts | 63 +++++++++++++++++-- 1 file changed, 59 insertions(+), 4 deletions(-) diff --git a/frontend/src/app/hooks/useRepaymentOperation.ts b/frontend/src/app/hooks/useRepaymentOperation.ts index 4d4f3603..e23c4dff 100644 --- a/frontend/src/app/hooks/useRepaymentOperation.ts +++ b/frontend/src/app/hooks/useRepaymentOperation.ts @@ -23,6 +23,7 @@ import { useCallback, useState } from "react"; import { useQueryClient } from "@tanstack/react-query"; import { useTransaction } from "./useOptimisticUI"; +import { queryKeys, type PoolStats, type DepositorPortfolio } from "./useApi"; interface RepaymentOperationOptions { loanId: number; @@ -130,6 +131,25 @@ export function useDepositOperation(options?: { transaction.start("Processing deposit..."); setError(null); + // Save previous state for rollback + const previousPoolStats = queryClient.getQueryData(queryKeys.pool.stats()); + const previousDepositor = queryClient.getQueryData( + queryKeys.pool.depositor(depositorAddress), + ); + + // Optimistically update cache before signing/submission + queryClient.setQueryData(queryKeys.pool.stats(), (old: PoolStats | undefined) => { + if (!old) return old; + return { ...old, totalDeposits: old.totalDeposits + amount }; + }); + queryClient.setQueryData( + queryKeys.pool.depositor(depositorAddress), + (old: DepositorPortfolio | undefined) => { + if (!old) return old; + return { ...old, depositAmount: old.depositAmount + amount }; + }, + ); + try { transaction.updateProgress(25); await new Promise((resolve) => setTimeout(resolve, 300)); @@ -147,16 +167,24 @@ export function useDepositOperation(options?: { transaction.complete(txHash); queryClient.invalidateQueries({ - queryKey: ["pool", "stats"], + queryKey: queryKeys.pool.stats(), }); queryClient.invalidateQueries({ - queryKey: ["pool", "depositor", depositorAddress], + queryKey: queryKeys.pool.depositor(depositorAddress), }); const result = { txHash }; options?.onSuccess?.(result); return result; } catch (err) { + // Rollback optimistic updates on signing/submission failure + if (previousPoolStats !== undefined) { + queryClient.setQueryData(queryKeys.pool.stats(), previousPoolStats); + } + if (previousDepositor !== undefined) { + queryClient.setQueryData(queryKeys.pool.depositor(depositorAddress), previousDepositor); + } + const errorMessage = err instanceof Error ? err.message : "Deposit failed"; transaction.fail(errorMessage); setError(errorMessage); @@ -198,6 +226,25 @@ export function useWithdrawalOperation(options?: { transaction.start("Processing withdrawal..."); setError(null); + // Save previous state for rollback + const previousPoolStats = queryClient.getQueryData(queryKeys.pool.stats()); + const previousDepositor = queryClient.getQueryData( + queryKeys.pool.depositor(depositorAddress), + ); + + // Optimistically update cache before signing/submission + queryClient.setQueryData(queryKeys.pool.stats(), (old: PoolStats | undefined) => { + if (!old) return old; + return { ...old, totalDeposits: Math.max(0, old.totalDeposits - amount) }; + }); + queryClient.setQueryData( + queryKeys.pool.depositor(depositorAddress), + (old: DepositorPortfolio | undefined) => { + if (!old) return old; + return { ...old, depositAmount: Math.max(0, old.depositAmount - amount) }; + }, + ); + try { transaction.updateProgress(25); await new Promise((resolve) => setTimeout(resolve, 300)); @@ -215,16 +262,24 @@ export function useWithdrawalOperation(options?: { transaction.complete(txHash); queryClient.invalidateQueries({ - queryKey: ["pool", "stats"], + queryKey: queryKeys.pool.stats(), }); queryClient.invalidateQueries({ - queryKey: ["pool", "depositor", depositorAddress], + queryKey: queryKeys.pool.depositor(depositorAddress), }); const result = { txHash }; options?.onSuccess?.(result); return result; } catch (err) { + // Rollback optimistic updates on signing/submission failure + if (previousPoolStats !== undefined) { + queryClient.setQueryData(queryKeys.pool.stats(), previousPoolStats); + } + if (previousDepositor !== undefined) { + queryClient.setQueryData(queryKeys.pool.depositor(depositorAddress), previousDepositor); + } + const errorMessage = err instanceof Error ? err.message : "Withdrawal failed"; transaction.fail(errorMessage); setError(errorMessage); From f4d41806d474d852c410742d8fdf9daa572568a0 Mon Sep 17 00:00:00 2001 From: Levi-Ojukwu Date: Sat, 27 Jun 2026 14:35:05 +0100 Subject: [PATCH 7/7] fix: WCAG lang attr, pool optimistic-update timing, remittance filter a11y, and app-shell locale propagation (#1109 #1107 #1110 #1108) --- frontend/src/app/layout.tsx | 8 +++++--- frontend/src/app/remittances/page.tsx | 1 + 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/frontend/src/app/layout.tsx b/frontend/src/app/layout.tsx index 952ac267..989a4f9a 100644 --- a/frontend/src/app/layout.tsx +++ b/frontend/src/app/layout.tsx @@ -8,6 +8,7 @@ import { LevelUpModal } from "./components/gamification/LevelUpModal"; import { GlobalXPGain } from "./components/global_ui/GlobalXPGain"; import { ErrorBoundary } from "./components/global_ui/ErrorBoundary"; import { NextIntlClientProvider } from "next-intl"; +import { getLocale, getMessages } from "next-intl/server"; import { THEME_STORAGE_KEY } from "./lib/theme"; const geistSans = Geist({ @@ -31,10 +32,11 @@ export default async function RootLayout({ }: Readonly<{ children: React.ReactNode; }>) { - const messages = (await import("../../messages/en.json")).default; + const locale = await getLocale(); + const messages = await getMessages(); return ( - +