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
42 changes: 32 additions & 10 deletions src/cron/__tests__/scoreDecayJob.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
import { jest } from "@jest/globals";

// Explicitly type the mocks to match the real function signatures
type Borrower = { id: string; score: number; last_repayment: string | null };
type Borrower = {
user_id: string;
current_score: number;
last_repayment: string | null;
};

const mockGetInactiveBorrowers: jest.MockedFunction<() => Promise<Borrower[]>> =
jest.fn();
const mockApplyScoreDecay: jest.MockedFunction<
Expand All @@ -13,32 +17,50 @@ jest.unstable_mockModule("../../services/scoreDecayService.js", () => ({
applyScoreDecay: mockApplyScoreDecay,
}));

jest.unstable_mockModule("../../services/cacheService.js", () => ({
cacheService: {
setNotExists: jest.fn<() => Promise<boolean>>().mockResolvedValue(true),
delete: jest.fn<() => Promise<void>>().mockResolvedValue(undefined),
},
}));

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

describe("scoreDecayJob", () => {
afterEach(() => {
jest.clearAllMocks();
});

it("should apply score decay to all inactive borrowers", async () => {
const borrowers = [
{ id: "user1", score: 700, last_repayment: "2024-01-01T00:00:00.000Z" },
{ id: "user2", score: 650, last_repayment: null },
it("should apply score decay to inactive borrowers above minimum score", async () => {
const borrowers: Borrower[] = [
{
user_id: "user1",
current_score: 700,
last_repayment: "2024-01-01T00:00:00.000Z",
},
{ user_id: "user2", current_score: 650, last_repayment: null },
];
mockGetInactiveBorrowers.mockResolvedValue(borrowers);
mockApplyScoreDecay.mockResolvedValue(0);

// Import the job after mocks
const { default: runScoreDecayJob } = await import("../scoreDecayJob.js");
const { runScoreDecayJob } = await import("../scoreDecayJob.js");
await runScoreDecayJob();

expect(mockGetInactiveBorrowers).toHaveBeenCalled();
expect(mockApplyScoreDecay).toHaveBeenCalledTimes(borrowers.length);
expect(mockApplyScoreDecay).toHaveBeenCalledTimes(2);
expect(mockApplyScoreDecay).toHaveBeenCalledWith(borrowers[0]);
expect(mockApplyScoreDecay).toHaveBeenCalledWith(borrowers[1]);
});

it("should handle errors gracefully", async () => {
mockGetInactiveBorrowers.mockRejectedValue(new Error("DB error"));
const { default: runScoreDecayJob } = await import("../scoreDecayJob.js");
const { runScoreDecayJob } = await import("../scoreDecayJob.js");
await expect(runScoreDecayJob()).resolves.not.toThrow();
});
});
61 changes: 51 additions & 10 deletions src/cron/scoreDecayJob.ts
Original file line number Diff line number Diff line change
@@ -1,23 +1,64 @@
// Cron job to apply score decay to inactive borrowers
// Run this script periodically (e.g., daily) via a scheduler or as part of backend startup

import cron from "node-cron";
import {
getInactiveBorrowers,
applyScoreDecay,
} from "../services/scoreDecayService.js";
import logger from "../utils/logger.js";
import { cacheService } from "../services/cacheService.js";

const LOCK_KEY = "score_decay_job:running";
const LOCK_TTL_SECONDS = 600; // 10 minutes

export async function runScoreDecayJob(): Promise<void> {
let lockAcquired = false;
try {
const lockValue = `${Date.now()}-${Math.random().toString(16).slice(2)}`;
lockAcquired = await cacheService.setNotExists(
LOCK_KEY,
lockValue,
LOCK_TTL_SECONDS,
);
} catch (error) {
logger.error("Failed to acquire score decay job lock", { error });
}

if (!lockAcquired) {
logger.warn(
"Score decay job skipped - another instance is already running",
);
return;
}

async function runScoreDecayJob() {
try {
logger.info("Running score decay job...");

const borrowers = await getInactiveBorrowers();
let decayedCount = 0;

for (const borrower of borrowers) {
await applyScoreDecay(borrower);
if (borrower.current_score > 300) {
await applyScoreDecay(borrower);
decayedCount++;
}
}
console.log(
`Score decay applied to ${borrowers.length} inactive borrowers.`,

logger.info(
`Score decay job completed. Decayed ${decayedCount} of ${borrowers.length} inactive borrowers.`,
);
} catch (err) {
console.error("Score decay job failed:", err);
} catch (error) {
logger.error("Error in score decay job", { error });
} finally {
try {
await cacheService.delete(LOCK_KEY);
} catch (error) {
logger.error("Failed to release score decay job lock", { error });
}
}
}

export default runScoreDecayJob;
export function startScoreDecayCron(): void {
cron.schedule("0 2 * * *", () => {
void runScoreDecayJob();
});
logger.info("Score decay cron scheduled (daily at 02:00)");
}
4 changes: 4 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ import {
import { sorobanService } from "./services/sorobanService.js";
import { validateLoanConfig } from "./config/loanConfig.js";
import { startLoanDueCheckCron } from "./cron/loanCheckCron.js";
import { startScoreDecayCron } from "./cron/scoreDecayJob.js";

const port = process.env.PORT || 3001;

Expand Down Expand Up @@ -72,6 +73,9 @@ const server = app.listen(port, () => {

// Start loan due check cron
startLoanDueCheckCron();

// Start daily score decay for inactive borrowers
startScoreDecayCron();
});

const shutdown = async (signal: "SIGTERM" | "SIGINT") => {
Expand Down
18 changes: 18 additions & 0 deletions src/services/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -387,6 +387,24 @@ describe("EventIndexer", () => {
- [ ] Event archival to cold storage
- [ ] GraphQL API for complex queries

---

# Score Decay Service

## Decision

Score decay is a **real feature** retained in the codebase. Inactive borrowers (no `LoanRepaid` event in the last 30 days) have their credit score reduced by 5 points per month of inactivity, down to the platform minimum of 300.

## Why

A lending platform must distinguish active, reliable borrowers from dormant accounts. Without decay, a borrower who repaid once years ago retains a high score indefinitely, which misrepresents their current creditworthiness.

## How it works

- `scoreDecayService.ts` queries the `scores` table joined with `contract_events` to find inactive borrowers and applies a per-month decay.
- `scoreDecayJob.ts` runs as a daily cron (02:00 UTC) with a Redis distributed lock to prevent duplicate execution across instances.
- The job is wired into `index.ts` via `startScoreDecayCron()`.

## References

- [Stellar RPC getEvents Documentation](https://developers.stellar.org/docs/data/rpc/api-reference/methods/getEvents)
Expand Down
81 changes: 48 additions & 33 deletions src/services/scoreDecayService.ts
Original file line number Diff line number Diff line change
@@ -1,45 +1,60 @@
// Service for score decay logic
// Provides functions to find inactive borrowers and apply score decay

import { query } from "../db/connection.js";
import logger from "../utils/logger.js";

const DECAY_PER_MONTH = 5;
const MIN_SCORE = 300; // Adjust as needed

// Get borrowers who have not repaid in the last month
export async function getInactiveBorrowers() {
// Example: select borrowers whose last repayment is > 1 month ago
const result = await query(`
SELECT b.id, b.score, MAX(e.ledger_closed_at) AS last_repayment
FROM borrowers b
LEFT JOIN contract_events e ON b.id = e.address AND e.event_type = 'LoanRepaid'
GROUP BY b.id, b.score
HAVING MAX(e.ledger_closed_at) IS NULL OR MAX(e.ledger_closed_at) < NOW() - INTERVAL '1 month'
`);
return result.rows;
}
const DECAY_POINTS_PER_MONTH = 5;
const MIN_SCORE = 300;
const INACTIVITY_THRESHOLD_DAYS = 30;

// Apply score decay to a borrower based on inactivity
export async function applyScoreDecay(borrower: {
id: string;
score: number;
export interface InactiveBorrower {
user_id: string;
current_score: number;
last_repayment: string | null;
}) {
const lastRepayment = borrower.last_repayment;
const now = new Date();
}

export async function getInactiveBorrowers(): Promise<InactiveBorrower[]> {
const result = await query(
`
SELECT s.borrower AS user_id, s.score AS current_score,
MAX(e.ledger_closed_at) AS last_repayment
FROM scores s
LEFT JOIN contract_events e
ON s.borrower = e.address AND e.event_type = 'LoanRepaid'
GROUP BY s.borrower, s.score
HAVING MAX(e.ledger_closed_at) IS NULL
OR MAX(e.ledger_closed_at) < NOW() - INTERVAL '${INACTIVITY_THRESHOLD_DAYS} days'
`,
);
return result.rows as InactiveBorrower[];
}

export async function applyScoreDecay(
borrower: InactiveBorrower,
): Promise<number> {
const now = Date.now();
let monthsInactive = 1;
if (lastRepayment) {
const last = new Date(lastRepayment);

if (borrower.last_repayment) {
const lastMs = new Date(borrower.last_repayment).getTime();
monthsInactive = Math.max(
1,
Math.floor((now.getTime() - last.getTime()) / (30 * 24 * 60 * 60 * 1000)),
Math.floor((now - lastMs) / (30 * 24 * 60 * 60 * 1000)),
);
}
const decay = monthsInactive * DECAY_PER_MONTH;
const newScore = Math.max(MIN_SCORE, borrower.score - decay);
await query(`UPDATE borrowers SET score = $1 WHERE id = $2`, [

const decay = monthsInactive * DECAY_POINTS_PER_MONTH;
const newScore = Math.max(MIN_SCORE, borrower.current_score - decay);

await query(
`UPDATE scores SET score = $1, updated_at = CURRENT_TIMESTAMP WHERE borrower = $2`,
[newScore, borrower.user_id],
);

logger.info("Applied score decay", {
userId: borrower.user_id,
oldScore: borrower.current_score,
newScore,
borrower.id,
]);
monthsInactive,
});

return newScore;
}
Loading
Loading