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("disconnected.heading")}

-

- {t("disconnected.subheading")} -

+

+ {t("disconnected.heading")} +

+

{t("disconnected.subheading")}

- {([ - 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 ( - +