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..d8a6a021 100644 --- a/frontend/src/app/[locale]/loans/[loanId]/page.tsx +++ b/frontend/src/app/[locale]/loans/[loanId]/page.tsx @@ -2,12 +2,14 @@ import Link from "next/link"; import { useParams } from "next/navigation"; -import { ChevronRight, Clock, Wallet } from "lucide-react"; +import { ChevronRight, Clock, Download, Wallet } from "lucide-react"; import { LoanDetailSkeleton } from "../../../components/skeletons/LoanDetailSkeleton"; -import { useLoan } from "../../../hooks/useApi"; +import { useLoan, type LoanDetails } from "../../../hooks/useApi"; import { RepaymentProgress } from "../../../components/ui/RepaymentProgress"; import { LoanTimeline } from "../../../components/ui/LoanTimeline"; import { TxHashLink } from "../../../components/ui/TxHashLink"; +import { Button } from "../../../components/ui/Button"; +import { downloadCsv } from "../../../utils/csv"; function formatCurrency(value: number) { return new Intl.NumberFormat("en-US", { style: "currency", currency: "USD" }).format(value); @@ -28,6 +30,44 @@ function getDaysRemaining(deadline: string | undefined): number | null { return Math.ceil(diff / (1000 * 60 * 60 * 24)); } +function formatLoanEventType(type: string): string { + return type + .replace(/([a-z])([A-Z])/g, "$1 $2") + .replace(/_/g, " ") + .trim(); +} + +function getLoanAsset(loan: LoanDetails): string { + const loanWithAsset = loan as LoanDetails & { currency?: string; asset?: string }; + return loanWithAsset.currency ?? loanWithAsset.asset ?? "USD"; +} + +function buildLoanCsvRows(loan: LoanDetails) { + const asset = getLoanAsset(loan); + + if (loan.events.length > 0) { + return loan.events.map((event) => ({ + date: event.timestamp, + type: formatLoanEventType(event.type), + amount: event.amount, + asset, + status: loan.status, + transaction_hash: event.txHash ?? "", + })); + } + + return [ + { + date: loan.requestedAt ?? loan.approvedAt ?? new Date().toISOString(), + type: "Loan Record", + amount: loan.totalOwed, + asset, + status: loan.status, + transaction_hash: "", + }, + ]; +} + export default function LoanDetailsPage() { const params = useParams<{ loanId: string }>(); const loanId = params.loanId; @@ -63,39 +103,55 @@ 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 nextDeadline = (loan as LoanDetails & { nextPaymentDeadline?: string }).nextPaymentDeadline; const daysRemaining = getDaysRemaining(nextDeadline); + const handleExport = () => { + downloadCsv( + `loan-record-${loanId}-${new Date().toISOString().slice(0, 10)}.csv`, + buildLoanCsvRows(loan), + ); + }; + return (
- {/* Breadcrumb */} - {/* Header */}
-

- Borrower Portal -

-

Loan #{loanId}

-

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

+
+
+

+ Borrower Portal +

+

+ Loan #{loanId} +

+

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

+
+ +
- {/* Loan metadata row */}
{loan.interestRate > 0 && ( @@ -125,7 +181,6 @@ export default function LoanDetailsPage() {
- {/* Main content */}

Repayment plan

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