Skip to content
Merged
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
6 changes: 6 additions & 0 deletions backend/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
12 changes: 6 additions & 6 deletions backend/src/__tests__/loanEndpoints.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: [
{
Expand All @@ -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);
Expand All @@ -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: [
{
Expand All @@ -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);
});
Expand Down
6 changes: 5 additions & 1 deletion backend/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
11 changes: 7 additions & 4 deletions backend/src/middleware/__tests__/jwtRevocation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = () => {
Expand All @@ -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) => {
Expand Down
2 changes: 1 addition & 1 deletion backend/src/middleware/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand Down
25 changes: 11 additions & 14 deletions backend/src/services/__tests__/defaultChecker.test.ts
Original file line number Diff line number Diff line change
@@ -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 };
Expand All @@ -26,9 +20,8 @@ const fakeServer = {
getAccount: jest.fn<(publicKey: string) => Promise<Account>>(),
getLatestLedger: jest.fn<() => Promise<{ sequence: number }>>(),
prepareTransaction: jest.fn<(tx: unknown) => Promise<unknown>>(),
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 }>>(),
};

Expand Down Expand Up @@ -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"),
);
});

Expand Down Expand Up @@ -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();

Expand All @@ -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" })
Expand Down
33 changes: 27 additions & 6 deletions backend/src/services/__tests__/notificationService.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>[]; rowCount: number };
const mockQuery = jest.fn<(sql: string, params?: unknown[]) => Promise<QueryResult>>();
Expand Down Expand Up @@ -186,16 +193,24 @@ 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",
message: "A loan has defaulted",
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);

Expand All @@ -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();
});
Expand All @@ -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();
});
Expand Down
6 changes: 4 additions & 2 deletions backend/src/services/__tests__/rateLimitService.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,8 @@ import { describe, it, expect, jest, beforeEach } from '@jest/globals';
const mockConnect = jest.fn<() => Promise<void>>();
const mockOn = jest.fn();
const mockIncr = jest.fn<(key: string) => Promise<number>>();
const mockExpire = jest.fn<(key: string, seconds: number) => Promise<boolean>>();
const mockExpire =
jest.fn<(key: string, seconds: number) => Promise<boolean>>();
const mockTtl = jest.fn<(key: string) => Promise<number>>();
const mockGet = jest.fn<(key: string) => Promise<string | null>>();
const mockDel = jest.fn<(key: string) => Promise<number>>();
Expand All @@ -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(() => {
Expand Down
3 changes: 2 additions & 1 deletion backend/src/services/__tests__/scoreDecayService.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(() => {
Expand Down
6 changes: 4 additions & 2 deletions backend/src/services/rateLimitService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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;
Expand Down
8 changes: 4 additions & 4 deletions backend/src/services/scoreDecayService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
3 changes: 3 additions & 0 deletions frontend/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

27 changes: 13 additions & 14 deletions frontend/src/app/[locale]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -243,19 +243,21 @@ export default function Home() {
return (
<main className="space-y-8 min-h-screen p-8 lg:p-12 max-w-7xl mx-auto animate-in fade-in duration-500">
<header>
<h1 className="text-2xl font-bold text-zinc-900 dark:text-zinc-50">{t("disconnected.heading")}</h1>
<p className="text-zinc-500 dark:text-zinc-400">
{t("disconnected.subheading")}
</p>
<h1 className="text-2xl font-bold text-zinc-900 dark:text-zinc-50">
{t("disconnected.heading")}
</h1>
<p className="text-zinc-500 dark:text-zinc-400">{t("disconnected.subheading")}</p>
</header>

<section className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4 opacity-50 grayscale-[0.5]">
{([
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) => (
<div
key={i}
className="rounded-xl border border-zinc-200 bg-white p-6 dark:border-zinc-800 dark:bg-zinc-950"
Expand Down Expand Up @@ -507,10 +509,7 @@ export default function Home() {
<h3 className="text-sm font-semibold text-zinc-900 dark:text-zinc-50">
{t("creditScore.label")}
</h3>
<Tooltip
content={t("creditScore.tooltip")}
label={t("creditScore.tooltipLabel")}
/>
<Tooltip content={t("creditScore.tooltip")} label={t("creditScore.tooltipLabel")} />
</div>
<CreditScoreGauge
score={currentCreditScore ?? 300}
Expand Down
1 change: 1 addition & 0 deletions frontend/src/app/[locale]/remittances/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -235,6 +235,7 @@ export default function RemittancesPage() {
<input
type="text"
placeholder="Search by recipient or currency..."
aria-label="Search remittances"
value={searchQuery}
onChange={(e) => 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"
Expand Down
6 changes: 5 additions & 1 deletion frontend/src/app/[locale]/ui-demo/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -184,7 +184,11 @@ export default function UIDemoPage() {
Focus-trapped, animated, keyboard-dismissible.
</p>
<Button onClick={() => setIsModalOpen(true)}>Open Demo Modal</Button>
<Modal isOpen={isModalOpen} onClose={() => setIsModalOpen(false)} title="Privacy Settings">
<Modal
isOpen={isModalOpen}
onClose={() => setIsModalOpen(false)}
title="Privacy Settings"
>
<div className="space-y-4">
<p className="text-sm text-gray-500 dark:text-zinc-400">
Are you sure you want to update your privacy settings? This will affect how your
Expand Down
8 changes: 1 addition & 7 deletions frontend/src/app/components/ui/TransactionStatusTracker.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
6 changes: 1 addition & 5 deletions frontend/src/app/hooks/useApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
3 changes: 1 addition & 2 deletions frontend/src/app/hooks/useNotificationStream.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down
Loading
Loading