Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
56 changes: 43 additions & 13 deletions src/cron/loanCheckCron.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,14 @@ import { cacheService } from "../services/cacheService.js";
const LOCK_KEY = "loan_due_check_cron:running";
const LOCK_TTL_SECONDS = 300; // 5 minutes

const LEDGER_CLOSE_SECONDS = 5;
const DEFAULT_TERM_LEDGERS = 17280; // 1 day in ledgers
const NOTIFICATION_WINDOW_SECONDS = 24 * 60 * 60; // 24 hours

function notificationCacheKey(loanId: number): string {
return `loan_due_notified:${loanId}`;
}

export async function runLoanDueCheck(): Promise<void> {
let lockAcquired = false;
try {
Expand All @@ -30,31 +38,53 @@ export async function runLoanDueCheck(): Promise<void> {
try {
logger.info("Running loan due check cron...");

// Find loans where a repayment is due in the next 24 hours
// This is a simplified query; in a real app, you'd check against a repayment schedule table
const result = await query(`
SELECT le.loan_id, le.address, le.amount
SELECT le.loan_id, le.address, le.amount,
le.ledger_closed_at AS approved_at,
COALESCE(le.term_ledgers, ${DEFAULT_TERM_LEDGERS}) AS term_ledgers
FROM contract_events le
WHERE le.event_type = 'LoanApproved'
AND NOT EXISTS (
SELECT 1 FROM contract_events re
SELECT 1 FROM contract_events re
WHERE re.loan_id = le.loan_id AND re.event_type = 'LoanRepaid'
)
AND le.ledger_closed_at < NOW() - INTERVAL '30 days' -- Simplified due logic
AND (le.ledger_closed_at + (COALESCE(le.term_ledgers, ${DEFAULT_TERM_LEDGERS}) * ${LEDGER_CLOSE_SECONDS} || ' seconds')::interval) <= NOW() + INTERVAL '24 hours'
`);

let notifiedCount = 0;

for (const loan of result.rows) {
await notificationService.createNotification({
userId: loan.address,
type: "repayment_due",
title: "Repayment Due Soon",
message: `Your repayment for loan #${loan.loan_id} of ${loan.amount} is due.`,
loanId: loan.loan_id,
});
const cacheKey = notificationCacheKey(loan.loan_id);
const alreadyNotified = await cacheService.setNotExists(
cacheKey,
"1",
NOTIFICATION_WINDOW_SECONDS,
);

if (!alreadyNotified) {
continue;
}

try {
await notificationService.createNotification({
userId: loan.address,
type: "repayment_due",
title: "Repayment Due Soon",
message: `Your repayment for loan #${loan.loan_id} of ${loan.amount} is due.`,
loanId: loan.loan_id,
});
notifiedCount++;
} catch (err) {
logger.error("Failed to send notification, clearing dedup key", {
loanId: loan.loan_id,
error: err,
});
await cacheService.delete(cacheKey).catch(() => {});
}
}

logger.info(
`Loan due check completed. Notified ${result.rows.length} borrowers.`,
`Loan due check completed. Notified ${notifiedCount} borrowers (${result.rows.length} due loans found).`,
);
} catch (error) {
logger.error("Error in loan due check cron", { error });
Expand Down
192 changes: 192 additions & 0 deletions src/tests/loanCheckCron.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,192 @@
import { jest } from "@jest/globals";

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

jest.unstable_mockModule("../services/notificationService.js", () => ({
notificationService: {
createNotification: jest.fn(),
},
}));

jest.unstable_mockModule("../services/cacheService.js", () => ({
cacheService: {
setNotExists: jest.fn(),
delete: jest.fn(),
},
}));

jest.unstable_mockModule("../utils/logger.js", () => ({
default: {
info: jest.fn(),
warn: jest.fn(),
error: jest.fn(),
},
}));

const { query } = await import("../db/connection.js");
const { notificationService } =
await import("../services/notificationService.js");
const { cacheService } = await import("../services/cacheService.js");
const { runLoanDueCheck } = await import("../cron/loanCheckCron.js");

const mockedQuery = query as jest.MockedFunction<typeof query>;
const mockedSetNotExists = cacheService.setNotExists as jest.MockedFunction<
typeof cacheService.setNotExists
>;
const mockedDelete = cacheService.delete as jest.MockedFunction<
typeof cacheService.delete
>;
const mockedCreateNotification =
notificationService.createNotification as jest.MockedFunction<
typeof notificationService.createNotification
>;

describe("loanCheckCron - runLoanDueCheck", () => {
beforeEach(() => {
jest.clearAllMocks();
});

it("should skip if lock cannot be acquired", async () => {
mockedSetNotExists.mockResolvedValue(false);

await runLoanDueCheck();

expect(mockedQuery).not.toHaveBeenCalled();
expect(mockedCreateNotification).not.toHaveBeenCalled();
});

it("should notify a borrower for a due loan", async () => {
// First call: acquire cron lock
// Second call: dedup guard for loan (returns true = key was set = not yet notified)
mockedSetNotExists.mockResolvedValueOnce(true).mockResolvedValueOnce(true);

mockedQuery.mockResolvedValue({
rows: [
{
loan_id: 42,
address: "GBORROWER1",
amount: "1000",
approved_at: new Date().toISOString(),
term_ledgers: 17280,
},
],
rowCount: 1,
} as any);

mockedCreateNotification.mockResolvedValue({} as any);
mockedDelete.mockResolvedValue(undefined as any);

await runLoanDueCheck();

expect(mockedCreateNotification).toHaveBeenCalledTimes(1);
expect(mockedCreateNotification).toHaveBeenCalledWith({
userId: "GBORROWER1",
type: "repayment_due",
title: "Repayment Due Soon",
message: "Your repayment for loan #42 of 1000 is due.",
loanId: 42,
});
});

it("should not re-notify a borrower already notified within the window", async () => {
// First call: acquire cron lock (true)
// Second call: dedup guard (false = key already exists = already notified)
mockedSetNotExists.mockResolvedValueOnce(true).mockResolvedValueOnce(false);

mockedQuery.mockResolvedValue({
rows: [
{
loan_id: 42,
address: "GBORROWER1",
amount: "1000",
approved_at: new Date().toISOString(),
term_ledgers: 17280,
},
],
rowCount: 1,
} as any);

mockedDelete.mockResolvedValue(undefined as any);

await runLoanDueCheck();

expect(mockedCreateNotification).not.toHaveBeenCalled();
});

it("should handle multiple loans and only notify those not yet notified", async () => {
// Cron lock + 3 dedup guards: loan 1 not notified, loan 2 already notified, loan 3 not notified
mockedSetNotExists
.mockResolvedValueOnce(true) // cron lock
.mockResolvedValueOnce(true) // loan 1: new
.mockResolvedValueOnce(false) // loan 2: already notified
.mockResolvedValueOnce(true); // loan 3: new

mockedQuery.mockResolvedValue({
rows: [
{
loan_id: 1,
address: "GA",
amount: "100",
approved_at: new Date().toISOString(),
term_ledgers: 17280,
},
{
loan_id: 2,
address: "GB",
amount: "200",
approved_at: new Date().toISOString(),
term_ledgers: 17280,
},
{
loan_id: 3,
address: "GC",
amount: "300",
approved_at: new Date().toISOString(),
term_ledgers: 17280,
},
],
rowCount: 3,
} as any);

mockedCreateNotification.mockResolvedValue({} as any);
mockedDelete.mockResolvedValue(undefined as any);

await runLoanDueCheck();

expect(mockedCreateNotification).toHaveBeenCalledTimes(2);
expect(mockedCreateNotification).toHaveBeenCalledWith(
expect.objectContaining({ loanId: 1 }),
);
expect(mockedCreateNotification).toHaveBeenCalledWith(
expect.objectContaining({ loanId: 3 }),
);
});

it("should delete dedup key when notification fails so it can be retried", async () => {
mockedSetNotExists.mockResolvedValueOnce(true).mockResolvedValueOnce(true);

mockedQuery.mockResolvedValue({
rows: [
{
loan_id: 99,
address: "GFAIL",
amount: "500",
approved_at: new Date().toISOString(),
term_ledgers: 17280,
},
],
rowCount: 1,
} as any);

mockedCreateNotification.mockRejectedValueOnce(new Error("send failed"));
mockedDelete.mockResolvedValue(undefined as any);

await runLoanDueCheck();

expect(mockedDelete).toHaveBeenCalledWith("loan_due_notified:99");
});
});
Loading