diff --git a/backend/src/__tests__/eventIndexer.test.ts b/backend/src/__tests__/eventIndexer.test.ts index 269bd317..cd280bd1 100644 --- a/backend/src/__tests__/eventIndexer.test.ts +++ b/backend/src/__tests__/eventIndexer.test.ts @@ -9,6 +9,7 @@ const mockGetScoreConfig = jest.fn(() => ({ repaymentDelta: 15, defaultPenalty: 50, })); +const mockUpdateUserScoresBulk = jest.fn<() => Promise>().mockResolvedValue(undefined); jest.unstable_mockModule("../db/connection.js", () => ({ query: mockQuery, @@ -32,6 +33,10 @@ jest.unstable_mockModule("../services/sorobanService.js", () => ({ sorobanService: { getScoreConfig: mockGetScoreConfig }, })); +jest.unstable_mockModule("../services/scoresService.js", () => ({ + updateUserScoresBulk: mockUpdateUserScoresBulk, +})); + jest.unstable_mockModule("../utils/logger.js", () => ({ default: { info: jest.fn(), @@ -126,7 +131,6 @@ describe("EventIndexer", () => { const borrowerRepaid = makeAddress(); const borrowerDefaulted = makeAddress(); const insertedLoanEvents: unknown[][] = []; - const scoreUpdates: unknown[][] = []; mockQuery.mockImplementation(async (sql: string, params: unknown[] = []) => { if (sql === "BEGIN" || sql === "COMMIT") { @@ -138,11 +142,6 @@ describe("EventIndexer", () => { return { rows: [{ event_id: params[0] }], rowCount: 1 }; } - if (sql.includes("INSERT INTO scores")) { - scoreUpdates.push(params); - return { rows: [], rowCount: 1 }; - } - return { rows: [], rowCount: 0 }; }); @@ -208,10 +207,13 @@ describe("EventIndexer", () => { expect(insertedLoanEvents[3]?.[2]).toBe(9); expect(insertedLoanEvents[3]?.[3]).toBe(borrowerDefaulted); - expect(scoreUpdates).toEqual([ - [borrowerRepaid, 515, 15], - [borrowerDefaulted, 450, -50], - ]); + expect(mockUpdateUserScoresBulk).toHaveBeenCalledTimes(1); + expect(mockUpdateUserScoresBulk).toHaveBeenCalledWith( + new Map([ + [borrowerRepaid, 15], + [borrowerDefaulted, -50], + ]), + ); expect(mockGetScoreConfig).toHaveBeenCalledTimes(2); expect(mockDispatch).toHaveBeenCalledTimes(4); expect(mockBroadcast).toHaveBeenCalledTimes(4); @@ -236,10 +238,6 @@ describe("EventIndexer", () => { }; } - if (sql.includes("INSERT INTO scores")) { - return { rows: [], rowCount: 1 }; - } - return { rows: [], rowCount: 0 }; }); @@ -269,6 +267,8 @@ describe("EventIndexer", () => { expect(mockBroadcast).toHaveBeenCalledTimes(1); expect(mockCreateNotification).toHaveBeenCalledTimes(1); expect(mockGetScoreConfig).toHaveBeenCalledTimes(1); + expect(mockUpdateUserScoresBulk).toHaveBeenCalledTimes(1); + expect(mockUpdateUserScoresBulk).toHaveBeenCalledWith(new Map([[borrower, 15]])); }); it("initializes missing indexer state and persists the last indexed ledger during polling", async () => { @@ -279,9 +279,8 @@ describe("EventIndexer", () => { return { rows: [], rowCount: 0 }; } - if (sql.includes("INSERT INTO indexer_state")) { - stateWrites.push(Number(params[0] ?? 0)); - return { rows: [], rowCount: 1 }; + if (sql.includes("INSERT INTO indexer_state") && params.length === 0) { + return { rows: [{ last_indexed_ledger: 0 }], rowCount: 1 }; } if (sql.includes("UPDATE indexer_state")) { @@ -289,37 +288,32 @@ describe("EventIndexer", () => { return { rows: [], rowCount: 1 }; } - if (sql === "BEGIN" || sql === "COMMIT") { - return { rows: [], rowCount: 0 }; - } - - if (sql.includes("INSERT INTO loan_events")) { - return { rows: [{ event_id: params[0] }], rowCount: 1 }; - } - return { rows: [], rowCount: 0 }; }); const indexer = new EventIndexer({ rpcUrl: "https://rpc.test", contractId: "CINDEXERTEST", + pollIntervalMs: 5, + batchSize: 50, }); - (indexer as { running: boolean }).running = true; - (indexer as { - rpc: { - getLatestLedger: () => Promise<{ sequence: number }>; - getEvents: () => Promise<{ events: unknown[] }>; - }; - }).rpc = { - getLatestLedger: jest.fn().mockResolvedValue({ sequence: 15 }), - getEvents: jest.fn().mockResolvedValue({ - events: [makeRawEvent({ id: "evt-poll", ledger: 15, type: "LoanRequested" })], - }), + const processChunk = jest + .spyOn(indexer as unknown as { processChunk: (start: number, end: number) => Promise }, "processChunk") + .mockResolvedValue({ + lastProcessedLedger: 25, + fetchedEvents: 0, + insertedEvents: 0, + }); + + (indexer as { rpc: { getLatestLedger: () => Promise<{ sequence: number }> } }).rpc = { + getLatestLedger: jest.fn().mockResolvedValue({ sequence: 25 }), }; - await (indexer as { pollOnce: () => Promise }).pollOnce(); + await indexer.start(); + indexer.stop(); - expect(stateWrites).toEqual([0, 15]); + expect(processChunk).toHaveBeenCalledWith(1, 25); + expect(stateWrites).toContain(25); }); }); diff --git a/frontend/src/app/[locale]/loans/[loanId]/page.tsx b/frontend/src/app/[locale]/loans/[loanId]/page.tsx index a62b078b..ce4b2976 100644 --- a/frontend/src/app/[locale]/loans/[loanId]/page.tsx +++ b/frontend/src/app/[locale]/loans/[loanId]/page.tsx @@ -2,19 +2,22 @@ import Link from "next/link"; import { useParams } from "next/navigation"; -import { ChevronRight, Clock, Wallet } from "lucide-react"; +import { AlertTriangle, ChevronRight, Clock, ExternalLink, Wallet } from "lucide-react"; import { LoanDetailSkeleton } from "../../../components/skeletons/LoanDetailSkeleton"; import { useLoan } from "../../../hooks/useApi"; import { RepaymentProgress } from "../../../components/ui/RepaymentProgress"; import { LoanTimeline } from "../../../components/ui/LoanTimeline"; import { TxHashLink } from "../../../components/ui/TxHashLink"; +const SUPPORT_URL = "https://t.me/+DOylgFv1jyJlNzM0"; + function formatCurrency(value: number) { return new Intl.NumberFormat("en-US", { style: "currency", currency: "USD" }).format(value); } function formatDate(iso: string | undefined) { if (!iso) return "—"; + return new Date(iso).toLocaleDateString(undefined, { year: "numeric", month: "short", @@ -63,39 +66,40 @@ export default function LoanDetailsPage() { } const latestTxHash = loan.events.find((event) => Boolean(event.txHash))?.txHash; - // Some API responses include nextPaymentDeadline in the extended loan object const nextDeadline = (loan as unknown as { nextPaymentDeadline?: string }).nextPaymentDeadline; const daysRemaining = getDaysRemaining(nextDeadline); + const isDefaulted = loan.status === "defaulted"; + const penaltyFees = Math.max(loan.totalOwed - (loan.principal + loan.accruedInterest), 0); + const collateralSeized = loan.events.some((event) => event.type === "Seized"); return (
- {/* Breadcrumb */} - {/* Header */}

Borrower Portal

-

Loan #{loanId}

+

+ Loan #{loanId} +

Track repayment timing, lender terms, and the current outstanding balance for this loan.

- {/* Loan metadata row */}
{loan.interestRate > 0 && ( @@ -122,12 +126,72 @@ export default function LoanDetailsPage() { )}
+ + {isDefaulted && ( +
+
+
+
+ +
+
+

+ Defaulted loan +

+

+ This loan has entered default. Review the recovery details below and contact + support if you need a repayment plan review or want to raise a dispute. +

+
+
+ + Contact Support + + +
+ +
+
+

+ Outstanding amount +

+

+ {formatCurrency(loan.totalOwed)} +

+
+
+

+ Penalty fees +

+

+ {formatCurrency(penaltyFees)} +

+
+
+

+ Collateral seizure status +

+

+ {collateralSeized + ? "Collateral has been seized." + : "Collateral is still in review while recovery options are evaluated."} +

+
+
+
+ )}
- {/* Main content */}
-

Repayment plan

+

+ Repayment plan +

{[ @@ -163,9 +227,7 @@ export default function LoanDetailsPage() {
- {/* Sidebar */}