diff --git a/src/cron/__tests__/scoreDecayJob.test.ts b/src/cron/__tests__/scoreDecayJob.test.ts index 40be955..e3b9f54 100644 --- a/src/cron/__tests__/scoreDecayJob.test.ts +++ b/src/cron/__tests__/scoreDecayJob.test.ts @@ -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> = jest.fn(); const mockApplyScoreDecay: jest.MockedFunction< @@ -13,32 +17,50 @@ jest.unstable_mockModule("../../services/scoreDecayService.js", () => ({ applyScoreDecay: mockApplyScoreDecay, })); +jest.unstable_mockModule("../../services/cacheService.js", () => ({ + cacheService: { + setNotExists: jest.fn<() => Promise>().mockResolvedValue(true), + delete: jest.fn<() => Promise>().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(); }); }); diff --git a/src/cron/scoreDecayJob.ts b/src/cron/scoreDecayJob.ts index e38ca28..31658e2 100644 --- a/src/cron/scoreDecayJob.ts +++ b/src/cron/scoreDecayJob.ts @@ -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 { + 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)"); +} diff --git a/src/index.ts b/src/index.ts index f99f9e4..12cf7a7 100644 --- a/src/index.ts +++ b/src/index.ts @@ -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; @@ -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") => { diff --git a/src/services/README.md b/src/services/README.md index 74ec2e1..3f1531d 100644 --- a/src/services/README.md +++ b/src/services/README.md @@ -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) diff --git a/src/services/scoreDecayService.ts b/src/services/scoreDecayService.ts index 3a56e42..53d30af 100644 --- a/src/services/scoreDecayService.ts +++ b/src/services/scoreDecayService.ts @@ -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 { + 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 { + 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; } diff --git a/src/tests/scoreDecayJob.test.ts b/src/tests/scoreDecayJob.test.ts new file mode 100644 index 0000000..e02a3a4 --- /dev/null +++ b/src/tests/scoreDecayJob.test.ts @@ -0,0 +1,120 @@ +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/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 { cacheService } = await import("../services/cacheService.js"); +const { runScoreDecayJob } = await import("../cron/scoreDecayJob.js"); + +const mockedQuery = query as jest.MockedFunction; +const mockedSetNotExists = cacheService.setNotExists as jest.MockedFunction< + typeof cacheService.setNotExists +>; +const mockedDelete = cacheService.delete as jest.MockedFunction< + typeof cacheService.delete +>; + +describe("scoreDecayJob - runScoreDecayJob", () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it("should skip if lock cannot be acquired", async () => { + mockedSetNotExists.mockResolvedValue(false); + + await runScoreDecayJob(); + + expect(mockedQuery).not.toHaveBeenCalled(); + }); + + it("should apply decay to inactive borrowers with scores above minimum", async () => { + mockedSetNotExists.mockResolvedValue(true); + mockedDelete.mockResolvedValue(undefined as any); + + const thirtyDaysAgo = new Date( + Date.now() - 60 * 24 * 60 * 60 * 1000, + ).toISOString(); + + // First query: getInactiveBorrowers + mockedQuery.mockResolvedValueOnce({ + rows: [ + { user_id: "GA1", current_score: 600, last_repayment: thirtyDaysAgo }, + { user_id: "GA2", current_score: 300, last_repayment: null }, + ], + rowCount: 2, + } as any); + + // Second query: UPDATE for GA1 (GA2 is skipped since score is already at min) + mockedQuery.mockResolvedValueOnce({ rows: [], rowCount: 1 } as any); + + await runScoreDecayJob(); + + // Only 2 queries: SELECT inactive + UPDATE for GA1 + expect(mockedQuery).toHaveBeenCalledTimes(2); + expect(mockedQuery).toHaveBeenLastCalledWith( + expect.stringContaining("UPDATE scores"), + expect.arrayContaining(["GA1"]), + ); + }); + + it("should not update borrowers already at minimum score", async () => { + mockedSetNotExists.mockResolvedValue(true); + mockedDelete.mockResolvedValue(undefined as any); + + mockedQuery.mockResolvedValueOnce({ + rows: [{ user_id: "GB1", current_score: 300, last_repayment: null }], + rowCount: 1, + } as any); + + await runScoreDecayJob(); + + // Only the SELECT query should run, no UPDATE + expect(mockedQuery).toHaveBeenCalledTimes(1); + }); + + it("should calculate decay based on months of inactivity", async () => { + mockedSetNotExists.mockResolvedValue(true); + mockedDelete.mockResolvedValue(undefined as any); + + // 90 days ago = ~3 months inactive => 3 * 5 = 15 points decay + const ninetyDaysAgo = new Date( + Date.now() - 90 * 24 * 60 * 60 * 1000, + ).toISOString(); + + mockedQuery.mockResolvedValueOnce({ + rows: [ + { user_id: "GC1", current_score: 500, last_repayment: ninetyDaysAgo }, + ], + rowCount: 1, + } as any); + + mockedQuery.mockResolvedValueOnce({ rows: [], rowCount: 1 } as any); + + await runScoreDecayJob(); + + // Expected new score: 500 - (3 * 5) = 485 + expect(mockedQuery).toHaveBeenLastCalledWith( + expect.stringContaining("UPDATE scores"), + [485, "GC1"], + ); + }); +});