diff --git a/backend/.env.example b/backend/.env.example
index 55668982..667dee84 100644
--- a/backend/.env.example
+++ b/backend/.env.example
@@ -44,6 +44,12 @@ SCORE_DELTA_REPAY=15
SCORE_DELTA_DEFAULT=50
SCORE_DELTA_LATE=5
+# 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/src/__tests__/loanEndpoints.test.ts b/backend/src/__tests__/loanEndpoints.test.ts
index f264d0d7..dedc7d61 100644
--- a/backend/src/__tests__/loanEndpoints.test.ts
+++ b/backend/src/__tests__/loanEndpoints.test.ts
@@ -356,7 +356,7 @@ describe('GET /api/loans/:loanId', () => {
describe('GET /api/loans/:loanId/amortization-schedule', () => {
it('should return amortization schedule for an approved loan', async () => {
mockedQuery
- .mockResolvedValueOnce({ rows: [{ address: TEST_BORROWER }] })
+ .mockResolvedValueOnce({ rows: [{ borrower: "GABC123" }] })
.mockResolvedValueOnce({
rows: [
{
@@ -375,8 +375,8 @@ describe('GET /api/loans/:loanId/amortization-schedule', () => {
});
const response = await request(app)
- .get('/api/loans/123/amortization-schedule')
- .set(bearer(TEST_BORROWER));
+ .get("/api/loans/123/amortization-schedule")
+ .set(bearer("GABC123"));
expect(response.status).toBe(200);
expect(response.body.success).toBe(true);
@@ -391,7 +391,7 @@ describe('GET /api/loans/:loanId/amortization-schedule', () => {
it('should return 404 when loan is not fully approved', async () => {
mockedQuery
- .mockResolvedValueOnce({ rows: [{ address: TEST_BORROWER }] })
+ .mockResolvedValueOnce({ rows: [{ borrower: "GABC123" }] })
.mockResolvedValueOnce({
rows: [
{
@@ -403,8 +403,8 @@ describe('GET /api/loans/:loanId/amortization-schedule', () => {
});
const response = await request(app)
- .get('/api/loans/123/amortization-schedule')
- .set(bearer(TEST_BORROWER));
+ .get("/api/loans/123/amortization-schedule")
+ .set(bearer("GABC123"));
expect(response.status).toBe(404);
});
diff --git a/backend/src/app.ts b/backend/src/app.ts
index bb525b76..ccab2039 100644
--- a/backend/src/app.ts
+++ b/backend/src/app.ts
@@ -170,7 +170,11 @@ app.get(
}),
);
-app.get('/metrics', requireApiKey('admin:indexer'), asyncHandler(metricsHandler));
+app.get(
+ "/metrics",
+ requireApiKey("admin:indexer"),
+ asyncHandler(metricsHandler),
+);
/**
* GET /health/deep
diff --git a/backend/src/middleware/__tests__/jwtRevocation.test.ts b/backend/src/middleware/__tests__/jwtRevocation.test.ts
index 185526b3..b6b2ce58 100644
--- a/backend/src/middleware/__tests__/jwtRevocation.test.ts
+++ b/backend/src/middleware/__tests__/jwtRevocation.test.ts
@@ -20,9 +20,8 @@ jest.unstable_mockModule("../../services/cacheService.js", () => ({
},
}));
-const { generateJwtToken, revokeToken, decodeJwtToken } = await import(
- "../../services/authService.js"
-);
+const { generateJwtToken, revokeToken, decodeJwtToken } =
+ await import("../../services/authService.js");
const { requireJwtAuth, requireScopes } = await import("../jwtAuth.js");
const buildApp = () => {
@@ -34,7 +33,11 @@ const buildApp = () => {
(_req, res) => res.status(200).json({ success: true }),
);
app.post("/echo", requireJwtAuth, (req, res) =>
- res.status(200).json({ publicKey: (req as { user?: { publicKey: string } }).user?.publicKey }),
+ res
+ .status(200)
+ .json({
+ publicKey: (req as { user?: { publicKey: string } }).user?.publicKey,
+ }),
);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
app.use((err: any, _req: any, res: any, _next: any) => {
diff --git a/backend/src/middleware/auth.ts b/backend/src/middleware/auth.ts
index 45b13007..ef0b36ad 100644
--- a/backend/src/middleware/auth.ts
+++ b/backend/src/middleware/auth.ts
@@ -59,7 +59,7 @@ export const requireApiKey = (requiredScope?: ApiKeyScope) => {
throw AppError.unauthorized('Unauthorised: missing API key');
}
- const keyStr = Array.isArray(providedKey) ? providedKey[0] : providedKey;
+ const keyStr = Array.isArray(providedKey) ? providedKey[0]! : providedKey;
let valueMatched = false;
const match = configuredKeys.find((k) => {
diff --git a/backend/src/services/__tests__/defaultChecker.test.ts b/backend/src/services/__tests__/defaultChecker.test.ts
index 864edd80..61afc622 100644
--- a/backend/src/services/__tests__/defaultChecker.test.ts
+++ b/backend/src/services/__tests__/defaultChecker.test.ts
@@ -1,10 +1,4 @@
-import {
- jest,
- describe,
- it,
- expect,
- beforeEach,
-} from "@jest/globals";
+import { jest, describe, it, expect, beforeEach } from "@jest/globals";
import { Account, Keypair, StrKey } from "@stellar/stellar-sdk";
type MockQueryResult = { rows: unknown[]; rowCount?: number };
@@ -26,9 +20,8 @@ const fakeServer = {
getAccount: jest.fn<(publicKey: string) => Promise>(),
getLatestLedger: jest.fn<() => Promise<{ sequence: number }>>(),
prepareTransaction: jest.fn<(tx: unknown) => Promise>(),
- sendTransaction: jest.fn<
- (tx: unknown) => Promise<{ hash?: string; status?: string }>
- >(),
+ sendTransaction:
+ jest.fn<(tx: unknown) => Promise<{ hash?: string; status?: string }>>(),
pollTransaction: jest.fn<() => Promise<{ status: string }>>(),
};
@@ -78,8 +71,8 @@ describe("DefaultChecker", () => {
mockQuery.mockResolvedValue(overdueStatsRow());
fakeServer.getLatestLedger.mockResolvedValue({ sequence: 100 });
- fakeServer.getAccount.mockImplementation(async (publicKey: string) =>
- new Account(publicKey, "1"),
+ fakeServer.getAccount.mockImplementation(
+ async (publicKey: string) => new Account(publicKey, "1"),
);
});
@@ -119,7 +112,9 @@ describe("DefaultChecker", () => {
});
it("reports sendTransaction failures as a batch error instead of throwing", async () => {
- fakeServer.prepareTransaction.mockImplementation(async (tx: unknown) => tx);
+ fakeServer.prepareTransaction.mockImplementation(
+ async (tx: unknown) => tx,
+ );
fakeServer.sendTransaction.mockRejectedValue(new Error("network down"));
const checker = new DefaultChecker();
@@ -134,7 +129,9 @@ describe("DefaultChecker", () => {
it("counts successful and failed batches across a multi-batch run", async () => {
process.env.DEFAULT_CHECK_BATCH_SIZE = "1";
- fakeServer.prepareTransaction.mockImplementation(async (tx: unknown) => tx);
+ fakeServer.prepareTransaction.mockImplementation(
+ async (tx: unknown) => tx,
+ );
// First call succeeds, second call fails
fakeServer.sendTransaction
.mockResolvedValueOnce({ hash: "abc", status: "PENDING" })
diff --git a/backend/src/services/__tests__/notificationService.test.ts b/backend/src/services/__tests__/notificationService.test.ts
index c4b060d3..c1ce1175 100644
--- a/backend/src/services/__tests__/notificationService.test.ts
+++ b/backend/src/services/__tests__/notificationService.test.ts
@@ -1,4 +1,11 @@
-import { describe, it, expect, jest, beforeEach, afterEach } from "@jest/globals";
+import {
+ describe,
+ it,
+ expect,
+ jest,
+ beforeEach,
+ afterEach,
+} from "@jest/globals";
type QueryResult = { rows: Record[]; rowCount: number };
const mockQuery = jest.fn<(sql: string, params?: unknown[]) => Promise>();
@@ -186,8 +193,14 @@ describe('notificationService', () => {
delete process.env.ADMIN_EMAIL;
mockQuery
- .mockResolvedValueOnce({ rows: [makeNotificationRow("wallet1", 99)], rowCount: 1 })
- .mockResolvedValueOnce({ rows: [makeNotificationRow("wallet2", 99)], rowCount: 1 });
+ .mockResolvedValueOnce({
+ rows: [makeNotificationRow("wallet1", 99)],
+ rowCount: 1,
+ })
+ .mockResolvedValueOnce({
+ rows: [makeNotificationRow("wallet2", 99)],
+ rowCount: 1,
+ });
await notificationService.notifyAdmins({
title: "Loan Default",
@@ -195,7 +208,9 @@ describe('notificationService', () => {
loanId: 99,
});
- const sqls = (mockQuery.mock.calls as [string, unknown[]][]).map((c) => c[0]);
+ const sqls = (mockQuery.mock.calls as [string, unknown[]][]).map(
+ (c) => c[0],
+ );
expect(sqls.some((s) => s.includes("WHERE role"))).toBe(false);
expect(mockQuery).toHaveBeenCalledTimes(2);
@@ -209,7 +224,10 @@ describe('notificationService', () => {
delete process.env.ADMIN_WALLETS;
delete process.env.ADMIN_EMAIL;
- await notificationService.notifyAdmins({ title: "Test", message: "Test" });
+ await notificationService.notifyAdmins({
+ title: "Test",
+ message: "Test",
+ });
expect(mockQuery).not.toHaveBeenCalled();
});
@@ -218,7 +236,10 @@ describe('notificationService', () => {
process.env.ADMIN_WALLETS = " , , ";
delete process.env.ADMIN_EMAIL;
- await notificationService.notifyAdmins({ title: "Test", message: "Test" });
+ await notificationService.notifyAdmins({
+ title: "Test",
+ message: "Test",
+ });
expect(mockQuery).not.toHaveBeenCalled();
});
diff --git a/backend/src/services/__tests__/rateLimitService.test.ts b/backend/src/services/__tests__/rateLimitService.test.ts
index f3cb9cc5..ee70a0b9 100644
--- a/backend/src/services/__tests__/rateLimitService.test.ts
+++ b/backend/src/services/__tests__/rateLimitService.test.ts
@@ -3,7 +3,8 @@ import { describe, it, expect, jest, beforeEach } from '@jest/globals';
const mockConnect = jest.fn<() => Promise>();
const mockOn = jest.fn();
const mockIncr = jest.fn<(key: string) => Promise>();
-const mockExpire = jest.fn<(key: string, seconds: number) => Promise>();
+const mockExpire =
+ jest.fn<(key: string, seconds: number) => Promise>();
const mockTtl = jest.fn<(key: string) => Promise>();
const mockGet = jest.fn<(key: string) => Promise>();
const mockDel = jest.fn<(key: string) => Promise>();
@@ -20,7 +21,8 @@ jest.unstable_mockModule('redis', () => ({
}),
}));
-const { rateLimitService, SCORE_UPDATE_RATE_LIMIT } = await import('../rateLimitService.js');
+const { rateLimitService, SCORE_UPDATE_RATE_LIMIT } =
+ await import("../rateLimitService.js");
describe('rateLimitService', () => {
beforeEach(() => {
diff --git a/backend/src/services/__tests__/scoreDecayService.test.ts b/backend/src/services/__tests__/scoreDecayService.test.ts
index 2dfbb4a3..7ac12051 100644
--- a/backend/src/services/__tests__/scoreDecayService.test.ts
+++ b/backend/src/services/__tests__/scoreDecayService.test.ts
@@ -7,7 +7,8 @@ jest.unstable_mockModule('../../db/connection.js', () => ({
query: mockQuery,
}));
-const { getInactiveBorrowers, applyScoreDecay } = await import('../scoreDecayService.js');
+const { getInactiveBorrowers, applyScoreDecay } =
+ await import("../scoreDecayService.js");
describe('scoreDecayService', () => {
beforeEach(() => {
diff --git a/backend/src/services/rateLimitService.ts b/backend/src/services/rateLimitService.ts
index 4634841d..0f02bae5 100644
--- a/backend/src/services/rateLimitService.ts
+++ b/backend/src/services/rateLimitService.ts
@@ -73,7 +73,8 @@ class RateLimitService {
const ttlSeconds = await this.client.ttl(key);
const resetTime = new Date(
- Date.now() + (ttlSeconds > 0 ? ttlSeconds : config.windowSeconds) * 1000,
+ Date.now() +
+ (ttlSeconds > 0 ? ttlSeconds : config.windowSeconds) * 1000,
);
const allowed = currentCount <= config.maxRequests;
const remaining = Math.max(0, config.maxRequests - currentCount);
@@ -153,7 +154,8 @@ class RateLimitService {
const ttlSeconds = await this.client.ttl(key);
const resetTime = new Date(
- Date.now() + (ttlSeconds > 0 ? ttlSeconds : config.windowSeconds) * 1000,
+ Date.now() +
+ (ttlSeconds > 0 ? ttlSeconds : config.windowSeconds) * 1000,
);
const remaining = Math.max(0, config.maxRequests - currentCount);
const allowed = currentCount < config.maxRequests;
diff --git a/backend/src/services/scoreDecayService.ts b/backend/src/services/scoreDecayService.ts
index 2aaa40d3..ae90089d 100644
--- a/backend/src/services/scoreDecayService.ts
+++ b/backend/src/services/scoreDecayService.ts
@@ -38,9 +38,9 @@ export async function applyScoreDecay(borrower: InactiveBorrower) {
}
const decay = monthsInactive * DECAY_PER_MONTH;
const newScore = Math.max(MIN_SCORE, borrower.score - decay);
- await query(`UPDATE scores SET score = $1, updated_at = CURRENT_TIMESTAMP WHERE borrower = $2`, [
- newScore,
- borrower.borrower,
- ]);
+ await query(
+ `UPDATE scores SET score = $1, updated_at = CURRENT_TIMESTAMP WHERE borrower = $2`,
+ [newScore, borrower.borrower],
+ );
return newScore;
}
diff --git a/frontend/package-lock.json b/frontend/package-lock.json
index 66ff3895..f12306c6 100644
--- a/frontend/package-lock.json
+++ b/frontend/package-lock.json
@@ -57,6 +57,9 @@
"tailwindcss": "^4",
"ts-jest": "^29.4.6",
"typescript": "^5.9.3"
+ },
+ "engines": {
+ "node": ">=18"
}
},
"node_modules/@adobe/css-tools": {
diff --git a/frontend/src/app/[locale]/page.tsx b/frontend/src/app/[locale]/page.tsx
index 4994b0bb..d408dd40 100644
--- a/frontend/src/app/[locale]/page.tsx
+++ b/frontend/src/app/[locale]/page.tsx
@@ -243,19 +243,21 @@ export default function Home() {
return (
- {([
- t("stats.netWorth"),
- t("stats.activeLoans"),
- t("stats.totalRemitted"),
- t("stats.yieldApy"),
- ] as string[]).map((label, i) => (
+ {(
+ [
+ t("stats.netWorth"),
+ t("stats.activeLoans"),
+ t("stats.totalRemitted"),
+ t("stats.yieldApy"),
+ ] as string[]
+ ).map((label, i) => (
{t("creditScore.label")}
-
+
setSearchQuery(e.target.value)}
className="w-full rounded-lg border border-zinc-200 bg-white pl-10 pr-4 py-2 text-sm text-zinc-900 placeholder:text-zinc-400 focus:border-indigo-500 focus:outline-none dark:border-zinc-800 dark:bg-zinc-900 dark:text-zinc-50"
diff --git a/frontend/src/app/[locale]/ui-demo/page.tsx b/frontend/src/app/[locale]/ui-demo/page.tsx
index e6fd6702..4caabc07 100644
--- a/frontend/src/app/[locale]/ui-demo/page.tsx
+++ b/frontend/src/app/[locale]/ui-demo/page.tsx
@@ -184,7 +184,11 @@ export default function UIDemoPage() {
Focus-trapped, animated, keyboard-dismissible.
- setIsModalOpen(false)} title="Privacy Settings">
+ setIsModalOpen(false)}
+ title="Privacy Settings"
+ >
Are you sure you want to update your privacy settings? This will affect how your
diff --git a/frontend/src/app/components/ui/TransactionStatusTracker.tsx b/frontend/src/app/components/ui/TransactionStatusTracker.tsx
index 231824fe..7eae40e9 100644
--- a/frontend/src/app/components/ui/TransactionStatusTracker.tsx
+++ b/frontend/src/app/components/ui/TransactionStatusTracker.tsx
@@ -4,13 +4,7 @@ import { AlertTriangle, CheckCircle2, Loader2, RefreshCw, XCircle } from "lucide
import { Button } from "./Button";
export type TransactionStatusState =
- | "idle"
- | "signing"
- | "submitting"
- | "polling"
- | "success"
- | "error"
- | "cancelled";
+ "idle" | "signing" | "submitting" | "polling" | "success" | "error" | "cancelled";
interface TransactionStatusTrackerProps {
state: TransactionStatusState;
diff --git a/frontend/src/app/hooks/useApi.ts b/frontend/src/app/hooks/useApi.ts
index 5f1b7b19..737b6751 100644
--- a/frontend/src/app/hooks/useApi.ts
+++ b/frontend/src/app/hooks/useApi.ts
@@ -1317,11 +1317,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;
diff --git a/frontend/src/app/hooks/useNotificationStream.ts b/frontend/src/app/hooks/useNotificationStream.ts
index f0905b8e..e67573bd 100644
--- a/frontend/src/app/hooks/useNotificationStream.ts
+++ b/frontend/src/app/hooks/useNotificationStream.ts
@@ -78,8 +78,7 @@ export function useNotificationStream() {
try {
const payload = JSON.parse(dataStr) as
- | AppNotification
- | { type: "init"; notifications: AppNotification[] };
+ AppNotification | { type: "init"; notifications: AppNotification[] };
queryClient.setQueryData(
queryKeys.notifications.all(),
diff --git a/frontend/src/app/hooks/useOptimisticUI.ts b/frontend/src/app/hooks/useOptimisticUI.ts
index 1cf74429..11764360 100644
--- a/frontend/src/app/hooks/useOptimisticUI.ts
+++ b/frontend/src/app/hooks/useOptimisticUI.ts
@@ -5,13 +5,7 @@ import { devtools } from "zustand/middleware";
import { useState, useEffect } from "react";
export type TransactionStatus =
- | "idle"
- | "pending"
- | "signing"
- | "submitted"
- | "confirming"
- | "confirmed"
- | "failed";
+ "idle" | "pending" | "signing" | "submitted" | "confirming" | "confirmed" | "failed";
export interface TransactionState {
id: string;
@@ -54,20 +48,18 @@ export const useOptimisticUI = create()(
optimisticUpdates: new Set(),
startTransaction: (id, message) =>
- set(
- (state): Partial => ({
- transactions: {
- ...state.transactions,
- [id]: {
- id,
- status: "pending" as TransactionStatus,
- message,
- progress: 0,
- startTime: Date.now(),
- } as TransactionState,
- },
- }),
- ),
+ set((state): Partial => ({
+ transactions: {
+ ...state.transactions,
+ [id]: {
+ id,
+ status: "pending" as TransactionStatus,
+ message,
+ progress: 0,
+ startTime: Date.now(),
+ } as TransactionState,
+ },
+ })),
updateProgress: (id, progress, message) =>
set((state) => {
diff --git a/frontend/src/app/hooks/useRepaymentOperation.ts b/frontend/src/app/hooks/useRepaymentOperation.ts
index 06a40060..1a20106a 100644
--- a/frontend/src/app/hooks/useRepaymentOperation.ts
+++ b/frontend/src/app/hooks/useRepaymentOperation.ts
@@ -29,6 +29,7 @@ import {
usePoolStats,
useWithdrawFromPool,
submitPoolTransaction,
+ queryKeys,
} from "./useApi";
interface RepaymentOperationOptions {
@@ -179,10 +180,10 @@ export function useDepositOperation(options?: {
transaction.complete(txHash, "Deposit successful!");
queryClient.invalidateQueries({
- queryKey: ["pool", "stats"],
+ queryKey: queryKeys.pool.stats(),
});
queryClient.invalidateQueries({
- queryKey: ["pool", "depositor", depositorAddress],
+ queryKey: queryKeys.pool.depositor(depositorAddress),
});
const result = { txHash };
@@ -271,10 +272,10 @@ export function useWithdrawalOperation(options?: {
transaction.complete(txHash, "Withdrawal successful!");
queryClient.invalidateQueries({
- queryKey: ["pool", "stats"],
+ queryKey: queryKeys.pool.stats(),
});
queryClient.invalidateQueries({
- queryKey: ["pool", "depositor", depositorAddress],
+ queryKey: queryKeys.pool.depositor(depositorAddress),
});
const result = { txHash };
diff --git a/frontend/src/app/layout.tsx b/frontend/src/app/layout.tsx
index 61fbbed7..c955605a 100644
--- a/frontend/src/app/layout.tsx
+++ b/frontend/src/app/layout.tsx
@@ -9,6 +9,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 DEFAULT_SITE_URL = "http://localhost:3000";
@@ -45,10 +46,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 (
-
+
-
+
diff --git a/frontend/src/app/stores/useUIStore.ts b/frontend/src/app/stores/useUIStore.ts
index 230388a4..c0de9db8 100644
--- a/frontend/src/app/stores/useUIStore.ts
+++ b/frontend/src/app/stores/useUIStore.ts
@@ -32,11 +32,7 @@ export interface Toast {
/** All modal identifiers in the app. Add new modals here as the app grows. */
export type ModalId =
- | "connectWallet"
- | "confirmLoan"
- | "confirmRemittance"
- | "kycVerification"
- | "transactionDetails";
+ "connectWallet" | "confirmLoan" | "confirmRemittance" | "kycVerification" | "transactionDetails";
export interface ModalState {
isOpen: boolean;
diff --git a/frontend/src/instrumentation.ts b/frontend/src/instrumentation.ts
index 3475254a..2a7e19cc 100644
--- a/frontend/src/instrumentation.ts
+++ b/frontend/src/instrumentation.ts
@@ -13,6 +13,6 @@ export const onRequestError = async (
...args: any[]
) => {
const { captureRequestError } = await import("@sentry/nextjs");
- // @ts-expect-error – captureRequestError accepts spread args matching Next.js internals
- captureRequestError(...args);
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ captureRequestError(...(args as Parameters));
};