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);
});
});
141 changes: 116 additions & 25 deletions frontend/src/app/[locale]/loans/[loanId]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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 (
<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>
<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>

{/* 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 All @@ -122,12 +126,72 @@ export default function LoanDetailsPage() {
</span>
)}
</div>

{isDefaulted && (
<div className="mt-5 rounded-2xl border border-amber-300 bg-amber-50 p-5 dark:border-amber-900/60 dark:bg-amber-950/20">
<div className="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
<div className="flex items-start gap-3">
<div className="rounded-full bg-amber-100 p-2 text-amber-700 dark:bg-amber-500/15 dark:text-amber-300">
<AlertTriangle className="h-5 w-5" />
</div>
<div>
<p className="text-sm font-semibold uppercase tracking-wide text-amber-800 dark:text-amber-300">
Defaulted loan
</p>
<p className="mt-2 text-sm leading-6 text-amber-900/85 dark:text-amber-100/85">
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.
</p>
</div>
</div>
<a
href={SUPPORT_URL}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-2 rounded-full bg-amber-600 px-4 py-2 text-sm font-semibold text-white transition hover:bg-amber-500"
>
Contact Support
<ExternalLink className="h-4 w-4" />
</a>
</div>

<div className="mt-4 grid gap-3 sm:grid-cols-3">
<div className="rounded-xl bg-white p-4 dark:bg-zinc-950/70">
<p className="text-xs font-semibold uppercase tracking-wide text-amber-700 dark:text-amber-300">
Outstanding amount
</p>
<p className="mt-2 text-lg font-semibold text-zinc-900 dark:text-zinc-50">
{formatCurrency(loan.totalOwed)}
</p>
</div>
<div className="rounded-xl bg-white p-4 dark:bg-zinc-950/70">
<p className="text-xs font-semibold uppercase tracking-wide text-amber-700 dark:text-amber-300">
Penalty fees
</p>
<p className="mt-2 text-lg font-semibold text-zinc-900 dark:text-zinc-50">
{formatCurrency(penaltyFees)}
</p>
</div>
<div className="rounded-xl bg-white p-4 dark:bg-zinc-950/70">
<p className="text-xs font-semibold uppercase tracking-wide text-amber-700 dark:text-amber-300">
Collateral seizure status
</p>
<p className="mt-2 text-sm font-medium text-zinc-900 dark:text-zinc-50">
{collateralSeized
? "Collateral has been seized."
: "Collateral is still in review while recovery options are evaluated."}
</p>
</div>
</div>
</div>
)}
</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>
<h2 className="text-lg font-semibold text-zinc-900 dark:text-zinc-50">
Repayment plan
</h2>

<div className="mt-5 grid gap-4 sm:grid-cols-2">
{[
Expand Down Expand Up @@ -163,9 +227,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,45 +261,74 @@ 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">
<div
className={`rounded-2xl p-5 ${
isDefaulted
? "bg-amber-50 text-amber-900 dark:bg-amber-500/10 dark:text-amber-100"
: "bg-indigo-50 text-indigo-700 dark:bg-indigo-500/10 dark:text-indigo-200"
}`}
>
<div className="flex items-center gap-3">
<Wallet className="h-5 w-5" />
<h2 className="text-lg font-semibold">Next action</h2>
<h2 className="text-lg font-semibold">
{isDefaulted ? "Recovery options" : "Next action"}
</h2>
</div>
<p className="mt-3 text-sm leading-6 text-indigo-700/80 dark:text-indigo-200">
Make a repayment before the next due date to keep your score trending upward.
<p className="mt-3 text-sm leading-6">
{isDefaulted
? "Support can help review repayment options, dispute next steps, and explain what happens to your collateral."
: "Make a repayment before the next due date to keep your score trending upward."}
</p>
{loan.status !== "repaid" && (
{isDefaulted ? (
<div className="mt-4 flex flex-wrap gap-2">
<a
href={SUPPORT_URL}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-2 rounded-full bg-amber-600 px-4 py-2 text-sm font-semibold text-white transition hover:bg-amber-500"
>
Contact Support
<ExternalLink className="h-4 w-4" />
</a>
<a
href={SUPPORT_URL}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-2 rounded-full border border-amber-300 px-4 py-2 text-sm font-semibold text-amber-900 transition hover:bg-amber-100 dark:border-amber-800 dark:text-amber-200 dark:hover:bg-amber-950/30"
>
Open Dispute Flow
<ExternalLink className="h-4 w-4" />
</a>
</div>
) : loan.status !== "repaid" ? (
<Link
href={`/repay/${loanId}`}
className="mt-4 inline-flex items-center gap-2 rounded-full bg-indigo-600 px-4 py-2 text-sm font-semibold text-white transition hover:bg-indigo-500"
>
Make Payment
<ChevronRight className="h-4 w-4" />
</Link>
)}
) : null}

{latestTxHash && (
<div className="mt-3">
<p className="mb-1 text-xs font-medium text-indigo-700/70 dark:text-indigo-300/70">
Latest transaction
</p>
<p className="mb-1 text-xs font-medium opacity-75">Latest transaction</p>
<TxHashLink txHash={latestTxHash} />
</div>
)}
</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
</h2>
<p className="mt-1 text-sm text-zinc-500 dark:text-zinc-400">
{loan.status === "defaulted"
? "Collateral has been seized."
? collateralSeized
? "Collateral has been seized."
: "Collateral remains locked while the default review is in progress."
: loan.status === "repaid"
? "Collateral released — loan fully repaid."
: "Collateral is held in escrow for the duration of this loan."}
Expand Down
Loading
Loading