Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
70 changes: 32 additions & 38 deletions backend/src/__tests__/eventIndexer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ const mockGetScoreConfig = jest.fn(() => ({
repaymentDelta: 15,
defaultPenalty: 50,
}));
const mockUpdateUserScoresBulk = jest.fn<() => Promise<void>>().mockResolvedValue(undefined);

jest.unstable_mockModule("../db/connection.js", () => ({
query: mockQuery,
Expand All @@ -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(),
Expand Down Expand Up @@ -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") {
Expand All @@ -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 };
});

Expand Down Expand Up @@ -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);
Expand All @@ -236,10 +238,6 @@ describe("EventIndexer", () => {
};
}

if (sql.includes("INSERT INTO scores")) {
return { rows: [], rowCount: 1 };
}

return { rows: [], rowCount: 0 };
});

Expand Down Expand Up @@ -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 () => {
Expand All @@ -279,47 +279,41 @@ 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")) {
stateWrites.push(Number(params[0]));
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<unknown> }, "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<void> }).pollOnce();
await indexer.start();
indexer.stop();

expect(stateWrites).toEqual([0, 15]);
expect(processChunk).toHaveBeenCalledWith(1, 25);
expect(stateWrites).toContain(25);
});
});
93 changes: 72 additions & 21 deletions frontend/src/app/[locale]/loans/[loanId]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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;
Expand Down Expand Up @@ -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 (
<section className="space-y-6">
{/* Breadcrumb */}
<nav
aria-label="Breadcrumb"
className="flex items-center gap-1.5 text-sm text-zinc-500 dark:text-zinc-400"
>
<Link href="/" className="hover:text-zinc-900 dark:hover:text-zinc-100 transition">
<Link href="/" className="transition hover:text-zinc-900 dark:hover:text-zinc-100">
Home
</Link>
<ChevronRight className="h-3.5 w-3.5" />
<Link href="/loans" className="hover:text-zinc-900 dark:hover:text-zinc-100 transition">
<Link href="/loans" className="transition hover:text-zinc-900 dark:hover:text-zinc-100">
Loans
</Link>
<ChevronRight className="h-3.5 w-3.5" />
<span className="font-medium text-zinc-900 dark:text-zinc-50">Loan #{loanId}</span>
</nav>

{/* Header */}
<header className="rounded-3xl border border-zinc-200 bg-white p-6 shadow-sm shadow-zinc-200/50 dark:border-zinc-800 dark:bg-zinc-950 dark:shadow-none">
<p className="text-sm font-semibold uppercase tracking-[0.2em] text-indigo-600">
Borrower Portal
</p>
<h1 className="mt-3 text-3xl font-bold text-zinc-900 dark:text-zinc-50">Loan #{loanId}</h1>
<p className="mt-2 max-w-2xl text-sm text-zinc-500 dark:text-zinc-400">
Track repayment timing, lender terms, and the current outstanding balance for this loan.
</p>
<div className="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
<div>
<p className="text-sm font-semibold uppercase tracking-[0.2em] text-indigo-600">
Borrower Portal
</p>
<h1 className="mt-3 text-3xl font-bold text-zinc-900 dark:text-zinc-50">
Loan #{loanId}
</h1>
<p className="mt-2 max-w-2xl text-sm text-zinc-500 dark:text-zinc-400">
Track repayment timing, lender terms, and the current outstanding balance for this loan.
</p>
</div>
<Button
variant="outline"
leftIcon={<Download className="h-4 w-4" />}
onClick={handleExport}
>
Export CSV
</Button>
</div>

{/* Loan metadata row */}
<div className="mt-4 flex flex-wrap gap-x-6 gap-y-2 text-sm text-zinc-500 dark:text-zinc-400">
{loan.interestRate > 0 && (
<span>
Expand Down Expand Up @@ -125,7 +181,6 @@ export default function LoanDetailsPage() {
</header>

<div className="grid gap-4 lg:grid-cols-[1.3fr_0.7fr]">
{/* Main content */}
<article className="rounded-3xl border border-zinc-200 bg-white p-6 shadow-sm shadow-zinc-200/50 dark:border-zinc-800 dark:bg-zinc-950 dark:shadow-none">
<h2 className="text-lg font-semibold text-zinc-900 dark:text-zinc-50">Repayment plan</h2>

Expand Down Expand Up @@ -163,9 +218,7 @@ export default function LoanDetailsPage() {
</div>
</article>

{/* Sidebar */}
<aside className="space-y-4">
{/* Due date countdown */}
{loan.status === "active" && daysRemaining !== null && (
<div className="rounded-3xl border border-zinc-200 bg-white p-5 shadow-sm shadow-zinc-200/50 dark:border-zinc-800 dark:bg-zinc-950 dark:shadow-none">
<div className="flex items-center gap-2 text-zinc-700 dark:text-zinc-300">
Expand Down Expand Up @@ -199,7 +252,6 @@ export default function LoanDetailsPage() {
</div>
)}

{/* Action panel */}
<div className="rounded-3xl border border-zinc-200 bg-white p-6 shadow-sm shadow-zinc-200/50 dark:border-zinc-800 dark:bg-zinc-950 dark:shadow-none">
<div className="rounded-2xl bg-indigo-50 p-5 dark:bg-indigo-500/10">
<div className="flex items-center gap-3 text-indigo-700 dark:text-indigo-300">
Expand Down Expand Up @@ -230,7 +282,6 @@ export default function LoanDetailsPage() {
</div>
</div>

{/* Collateral status */}
<div className="rounded-3xl border border-zinc-200 bg-white p-5 shadow-sm shadow-zinc-200/50 dark:border-zinc-800 dark:bg-zinc-950 dark:shadow-none">
<h2 className="text-sm font-semibold text-zinc-900 dark:text-zinc-50">
Collateral status
Expand Down
49 changes: 49 additions & 0 deletions frontend/src/app/utils/csv.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
export type CsvValue = string | number | boolean | null | undefined;

export type CsvRow = Record<string, CsvValue>;

function escapeCsvValue(value: CsvValue): string {
if (value === null || value === undefined) {
return "";
}

const stringValue = String(value);
if (/[",\n]/.test(stringValue)) {
return `"${stringValue.replace(/"/g, '""')}"`;
}

return stringValue;
}

export function serializeCsv(rows: CsvRow[]): string {
if (rows.length === 0) {
return "";
}

const headers = Object.keys(rows[0]);
const lines = [headers.join(",")];

for (const row of rows) {
lines.push(headers.map((header) => escapeCsvValue(row[header])).join(","));
}

return lines.join("\n");
}

export function downloadCsv(filename: string, rows: CsvRow[]): void {
if (typeof window === "undefined" || rows.length === 0) {
return;
}

const csv = serializeCsv(rows);
const blob = new Blob([`\uFEFF${csv}`], { type: "text/csv;charset=utf-8;" });
const url = URL.createObjectURL(blob);
const link = document.createElement("a");

link.href = url;
link.download = filename;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
}
Loading
Loading