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
14 changes: 13 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,18 @@ The backend exposes an interactive Swagger UI for exploring and testing API endp

Both endpoints are gated to non-production environments (`NODE_ENV !== "production"`).

### Webhooks

RemitLend supports real-time event notifications via webhooks. See the
[Webhook Integration Guide](docs/webhooks.md) for details on subscribing,
event payloads, retry semantics, circuit-breaker behavior, and HMAC signature
verification.

- **Swagger UI**: [http://localhost:3001/docs](http://localhost:3001/docs)
- **OpenAPI JSON**: [http://localhost:3001/docs.json](http://localhost:3001/docs.json)

Both endpoints are gated to non-production environments (`NODE_ENV !== "production"`).

## 🛠 Tech Stack

- **Blockchain**: [Stellar](https://stellar.org) (Soroban Smart Contracts)
Expand All @@ -66,7 +78,7 @@ Both endpoints are gated to non-production environments (`NODE_ENV !== "producti

1. **Clone the repository:**
```bash
git clone https://github.com/your-username/remitlend.git
git clone https://github.com/LabsCrypt/remitlend.git
cd remitlend
```

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
/**
* @type {import('node-pg-migrate').ColumnDefinitions | undefined}
*/
export const shorthands = undefined;

/**
* @param pgm {import('node-pg-migrate').MigrationBuilder}
* @returns {Promise<void> | void}
*/
export const up = (pgm) => {
pgm.addColumns("notifications", {
action_url: {
type: "varchar(500)",
notNull: false,
comment: "Deep-link URL to the relevant entity (loan, remittance, etc.)",
},
});
};

/**
* @param pgm {import('node-pg-migrate').MigrationBuilder}
* @returns {Promise<void> | void}
*/
export const down = (pgm) => {
pgm.dropColumns("notifications", ["action_url"]);
};
1 change: 1 addition & 0 deletions backend/src/__tests__/apiV1Mounts.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ jest.unstable_mockModule("../db/connection.js", () => ({
query: mockQuery,
getClient: jest.fn(),
closePool: jest.fn(),
withTransaction: jest.fn(),
}));

// ── notificationService mock ─────────────────────────────────────────────────
Expand Down
7 changes: 6 additions & 1 deletion backend/src/__tests__/eventIndexer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ const supportedWebhookEventTypes = [
"LoanRepaid",
"LoanDefaulted",
"CollateralLiquidated",
"LoanLiquidated",
"Deposit",
"Withdraw",
"YieldDistributed",
Expand Down Expand Up @@ -175,7 +176,11 @@ function makeRawEvent(params: {
case "LoanRequested":
return {
...base,
topic: [scSymbol("LoanRequested"), scAddress(borrower)],
topic: [
scSymbol("LoanRequested"),
scU32(params.loanId ?? 1),
scAddress(borrower),
],
value: scI128(params.amount ?? 500),
};
case "LoanApproved":
Expand Down
4 changes: 2 additions & 2 deletions backend/src/__tests__/notificationDigest.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -112,7 +112,7 @@ describe("notification digest batching", () => {
mockQuery
.mockResolvedValueOnce({ rows: [{ digest_frequency: "daily" }] })
.mockResolvedValueOnce({ rows: [{ digest_frequency: "weekly" }] })
.mockResolvedValueOnce({ rows: [{ digest_frequency: "off" }] });
.mockResolvedValueOnce({ rows: [{ digest_frequency: "daily" }] });

const notifications = [
{ userId: user1, message: "Loan 1 due", loanId: 1 },
Expand All @@ -125,7 +125,7 @@ describe("notification digest batching", () => {
notifications,
);

expect(grouped.size).toBe(3);
expect(grouped.size).toBe(2);
expect(grouped.get(`${user1}:daily`)).toHaveLength(2);
expect(grouped.get(`${user2}:weekly`)).toHaveLength(1);
});
Expand Down
4 changes: 0 additions & 4 deletions backend/src/__tests__/remittanceService.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -104,15 +104,11 @@ describe("remittanceService.createRemittance", () => {
});

describe("remittanceService.getRemittances with filters", () => {
let mockQuery: jest.MockedFunction<any>;

beforeEach(() => {
jest.clearAllMocks();
mockQuery = jest.fn();
});

it("filters remittances by status", async () => {
const { query: queryModule } = await import("../db/connection.js");
mockQuery.mockResolvedValueOnce({
rows: [
{
Expand Down
2 changes: 2 additions & 0 deletions backend/src/config/swaggerSchemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -415,12 +415,14 @@ export const swaggerSchemas = {
"repayment_due",
"repayment_confirmed",
"loan_defaulted",
"loan_liquidated",
"score_changed",
],
},
title: { type: "string" },
message: { type: "string" },
loanId: { type: "integer" },
actionUrl: { type: "string", nullable: true },
read: { type: "boolean" },
createdAt: { type: "string", format: "date-time" },
},
Expand Down
1 change: 1 addition & 0 deletions backend/src/controllers/remittanceController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -186,6 +186,7 @@ export const submitRemittanceTransaction = asyncHandler(
type: "repayment_confirmed",
title: "Remittance Sent",
message: `Your remittance of ${remittance.amount} ${remittance.fromCurrency} was submitted successfully. Transaction: ${stellarResult.txHash}`,
actionUrl: `/remittances/${remittance.id}`,
});

res.json({
Expand Down
2 changes: 1 addition & 1 deletion backend/src/middleware/validation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ const validateSource = (schema: ZodType, source: ValidationSource) => {
: source === "query"
? req.query
: req.params;
req[source] = schema.parse(data);
schema.parse(data);
next();
} catch (error) {
next(error);
Expand Down
1 change: 1 addition & 0 deletions backend/src/services/__tests__/eventIndexer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -249,6 +249,7 @@ beforeAll(async () => {
"LoanRepaid",
"LoanDefaulted",
"CollateralLiquidated",
"LoanLiquidated",
"Deposit",
"Withdraw",
"YieldDistributed",
Expand Down
134 changes: 134 additions & 0 deletions backend/src/services/__tests__/notificationService.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
import { describe, it, expect, jest, beforeEach } from "@jest/globals";

type QueryResult = { rows: Record<string, unknown>[]; rowCount: number };
const mockQuery = jest.fn<

Check failure on line 4 in backend/src/services/__tests__/notificationService.test.ts

View workflow job for this annotation

GitHub Actions / backend

Replace `·jest.fn<⏎··(sql:·string,·params?:·unknown[])·=>·Promise<QueryResult>⏎` with `⏎··jest.fn<(sql:·string,·params?:·unknown[])·=>·Promise<QueryResult>`
(sql: string, params?: unknown[]) => Promise<QueryResult>
>();

jest.unstable_mockModule("../../db/connection.js", () => ({
query: mockQuery,
}));

jest.unstable_mockModule("twilio", () => ({
default: jest.fn(() => ({ messages: { create: jest.fn() } })),
}));

jest.unstable_mockModule("@sendgrid/mail", () => ({
default: { setApiKey: jest.fn(), send: jest.fn() },
}));

const { notificationService } = await import("../notificationService.js");

describe("notificationService", () => {
beforeEach(() => {
jest.clearAllMocks();
});

describe("createNotification", () => {
it("sets actionUrl from loanId when not explicitly provided", async () => {
mockQuery.mockResolvedValueOnce({
rows: [
{
id: 1,
user_id: "user1",
type: "loan_approved",
title: "Loan Approved",
message: "Your loan has been approved",
loan_id: 42,
action_url: "/loans/42",
read: false,
status: "unread",
created_at: new Date("2026-05-28T12:00:00.000Z"),
},
],
rowCount: 1,
});

mockQuery.mockResolvedValueOnce({
rows: [{ email: null, phone: null, email_enabled: false, sms_enabled: false }],

Check failure on line 48 in backend/src/services/__tests__/notificationService.test.ts

View workflow job for this annotation

GitHub Actions / backend

Replace `{·email:·null,·phone:·null,·email_enabled:·false,·sms_enabled:·false·}` with `⏎··········{⏎············email:·null,⏎············phone:·null,⏎············email_enabled:·false,⏎············sms_enabled:·false,⏎··········},⏎········`
rowCount: 1,
});

const notification = await notificationService.createNotification({
userId: "user1",
type: "loan_approved",
title: "Loan Approved",
message: "Your loan has been approved",
loanId: 42,
});

expect(notification.actionUrl).toBe("/loans/42");
const insertCall = mockQuery.mock.calls[0] as [string, unknown[]];
expect(insertCall[1]).toContain("/loans/42");
});

it("uses explicit actionUrl over loanId when provided", async () => {
mockQuery.mockResolvedValueOnce({
rows: [
{
id: 2,
user_id: "user2",
type: "repayment_confirmed",
title: "Remittance Sent",
message: "Remittance submitted",
loan_id: null,
action_url: "/remittances/99",
read: false,
status: "unread",
created_at: new Date("2026-05-28T12:00:00.000Z"),
},
],
rowCount: 1,
});

mockQuery.mockResolvedValueOnce({
rows: [{ email: null, phone: null, email_enabled: false, sms_enabled: false }],

Check failure on line 85 in backend/src/services/__tests__/notificationService.test.ts

View workflow job for this annotation

GitHub Actions / backend

Replace `{·email:·null,·phone:·null,·email_enabled:·false,·sms_enabled:·false·}` with `⏎··········{⏎············email:·null,⏎············phone:·null,⏎············email_enabled:·false,⏎············sms_enabled:·false,⏎··········},⏎········`
rowCount: 1,
});

const notification = await notificationService.createNotification({
userId: "user2",
type: "repayment_confirmed",
title: "Remittance Sent",
message: "Remittance submitted",
actionUrl: "/remittances/99",
});

expect(notification.actionUrl).toBe("/remittances/99");
});

it("returns null actionUrl when neither loanId nor actionUrl provided", async () => {
mockQuery.mockResolvedValueOnce({
rows: [
{
id: 3,
user_id: "user3",
type: "score_changed",
title: "Score Changed",
message: "Your score changed",
loan_id: null,
action_url: null,
read: false,
status: "unread",
created_at: new Date("2026-05-28T12:00:00.000Z"),
},
],
rowCount: 1,
});

mockQuery.mockResolvedValueOnce({
rows: [{ email: null, phone: null, email_enabled: false, sms_enabled: false }],

Check failure on line 120 in backend/src/services/__tests__/notificationService.test.ts

View workflow job for this annotation

GitHub Actions / backend

Replace `{·email:·null,·phone:·null,·email_enabled:·false,·sms_enabled:·false·}` with `⏎··········{⏎············email:·null,⏎············phone:·null,⏎············email_enabled:·false,⏎············sms_enabled:·false,⏎··········},⏎········`
rowCount: 1,
});

const notification = await notificationService.createNotification({
userId: "user3",
type: "score_changed",
title: "Score Changed",
message: "Your score changed",
});

expect(notification.actionUrl).toBeUndefined();
});
});
});
91 changes: 91 additions & 0 deletions backend/src/services/__tests__/scoreDecayService.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
import { describe, it, expect, jest, beforeEach } from "@jest/globals";

type QueryResult = { rows: unknown[]; rowCount: number };
const mockQuery = jest.fn<

Check failure on line 4 in backend/src/services/__tests__/scoreDecayService.test.ts

View workflow job for this annotation

GitHub Actions / backend

Replace `·jest.fn<⏎··(sql:·string,·params?:·unknown[])·=>·Promise<QueryResult>⏎` with `⏎··jest.fn<(sql:·string,·params?:·unknown[])·=>·Promise<QueryResult>`
(sql: string, params?: unknown[]) => Promise<QueryResult>
>();

jest.unstable_mockModule("../../db/connection.js", () => ({
query: mockQuery,
}));

const { applyScoreDecay } = await import("../scoreDecayService.js");

describe("scoreDecayService", () => {
beforeEach(() => {
jest.clearAllMocks();
mockQuery.mockResolvedValue({ rows: [], rowCount: 1 });
});

describe("applyScoreDecay", () => {
it("decays inactive borrower with no repayment by configured amount", async () => {
const borrower = { id: "user1", score: 700, last_repayment: null };
const newScore = await applyScoreDecay(borrower);

// No last_repayment => monthsInactive = 1 => decay = 1 * 5 = 5
expect(newScore).toBe(695);
expect(mockQuery).toHaveBeenCalledWith(
"UPDATE borrowers SET score = $1 WHERE id = $2",
[695, "user1"],
);
});

it("decays borrower inactive for multiple months", async () => {
// 90 days = exactly 3 30-day months
const ninetyDaysAgo = new Date();
ninetyDaysAgo.setUTCDate(ninetyDaysAgo.getUTCDate() - 90);

const borrower = {
id: "user2",
score: 700,
last_repayment: ninetyDaysAgo.toISOString(),
};
const newScore = await applyScoreDecay(borrower);

// 90 days => floor(90/30) = 3 => max(1, 3) = 3 => decay = 3 * 5 = 15
expect(newScore).toBe(685);
});

it("applies minimum decay of one month even with recent activity", async () => {
const yesterday = new Date();
yesterday.setUTCDate(yesterday.getUTCDate() - 1);

const borrower = {
id: "user3",
score: 700,
last_repayment: yesterday.toISOString(),
};
const newScore = await applyScoreDecay(borrower);

// 1 day => floor(1/30) = 0 => max(1, 0) = 1 => decay = 5
expect(newScore).toBe(695);
});

it("floors score at minimum score", async () => {
const borrower = { id: "user4", score: 304, last_repayment: null };
const newScore = await applyScoreDecay(borrower);

// 304 - 5 = 299, floored to 300
expect(newScore).toBe(300);
});

it("never drops score below minimum even if already below", async () => {
const borrower = { id: "user5", score: 200, last_repayment: null };
const newScore = await applyScoreDecay(borrower);

// max(300, 200 - 5) = 300
expect(newScore).toBe(300);
});

it("is idempotent for identical borrower input", async () => {
const borrower = { id: "user6", score: 700, last_repayment: null };

const first = await applyScoreDecay(borrower);
const second = await applyScoreDecay(borrower);

expect(first).toBe(695);
expect(second).toBe(695);
expect(mockQuery).toHaveBeenCalledTimes(2);
});
});
});
Loading
Loading