From 64b71b70cd35ea8af8d526ce03603d4aebe0adf1 Mon Sep 17 00:00:00 2001 From: Oyeleke Amir Date: Fri, 20 Feb 2026 18:59:18 +0100 Subject: [PATCH] implement basic scoring logic --- package-lock.json | 92 ++++++++++++++++++++++++++++++------- package.json | 3 +- src/app.module.ts | 3 +- src/risk/risk.module.ts | 8 ++++ src/risk/risk.service.ts | 99 ++++++++++++++++++++++++++++++++++++++++ src/test-risk.ts | 49 ++++++++++++++++++++ 6 files changed, 235 insertions(+), 19 deletions(-) create mode 100644 src/risk/risk.module.ts create mode 100644 src/risk/risk.service.ts create mode 100644 src/test-risk.ts diff --git a/package-lock.json b/package-lock.json index 753a4a8..059ebfc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,13 +9,15 @@ "version": "0.0.1", "dependencies": { "@nestjs/common": "^10.4.22", + "@nestjs/config": "^3.2.3", "@nestjs/core": "^10.4.22", "@nestjs/platform-express": "^10.4.22", + "@nestjs/typeorm": "^10.0.2", "@stellar/stellar-sdk": "^11.0.0", "pg": "^8.11.0", "reflect-metadata": "^0.2.2", "rxjs": "^7.8.2", - "typeorm": "^0.3.0" + "typeorm": "^0.3.20" }, "devDependencies": { "@nestjs/cli": "^11.0.16", @@ -864,7 +866,6 @@ "resolved": "https://registry.npmjs.org/@nestjs/common/-/common-10.4.22.tgz", "integrity": "sha512-fxJ4v85nDHaqT1PmfNCQ37b/jcv2OojtXTaK1P2uAXhzLf9qq6WNUOFvxBrV4fhQek1EQoT1o9oj5xAZmv3NRw==", "license": "MIT", - "peer": true, "dependencies": { "file-type": "20.4.1", "iterare": "1.2.1", @@ -890,13 +891,45 @@ } } }, + "node_modules/@nestjs/config": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/@nestjs/config/-/config-3.3.0.tgz", + "integrity": "sha512-pdGTp8m9d0ZCrjTpjkUbZx6gyf2IKf+7zlkrPNMsJzYZ4bFRRTpXrnj+556/5uiI6AfL5mMrJc2u7dB6bvM+VA==", + "license": "MIT", + "dependencies": { + "dotenv": "16.4.5", + "dotenv-expand": "10.0.0", + "lodash": "4.17.21" + }, + "peerDependencies": { + "@nestjs/common": "^8.0.0 || ^9.0.0 || ^10.0.0", + "rxjs": "^7.1.0" + } + }, + "node_modules/@nestjs/config/node_modules/dotenv": { + "version": "16.4.5", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.4.5.tgz", + "integrity": "sha512-ZmdL2rui+eB2YwhsWzjInR8LldtZHGDoQ1ugH85ppHKwpUHL7j7rN0Ti9NCnGiQbhaZ11FpR+7ao1dNsmduNUg==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/@nestjs/config/node_modules/lodash": { + "version": "4.17.21", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", + "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", + "license": "MIT" + }, "node_modules/@nestjs/core": { "version": "10.4.22", "resolved": "https://registry.npmjs.org/@nestjs/core/-/core-10.4.22.tgz", "integrity": "sha512-6IX9+VwjiKtCjx+mXVPncpkQ5ZjKfmssOZPFexmT+6T9H9wZ3svpYACAo7+9e7Nr9DZSoRZw3pffkJP7Z0UjaA==", "hasInstallScript": true, "license": "MIT", - "peer": true, "dependencies": { "@nuxtjs/opencollective": "0.3.2", "fast-safe-stringify": "2.1.1", @@ -934,7 +967,6 @@ "resolved": "https://registry.npmjs.org/@nestjs/platform-express/-/platform-express-10.4.22.tgz", "integrity": "sha512-ySSq7Py/DFozzZdNDH67m/vHoeVdphDniWBnl6q5QVoXldDdrZIHLXLRMPayTDh5A95nt7jjJzmD4qpTbNQ6tA==", "license": "MIT", - "peer": true, "dependencies": { "body-parser": "1.20.4", "cors": "2.8.5", @@ -1025,6 +1057,35 @@ "tslib": "^2.1.0" } }, + "node_modules/@nestjs/typeorm": { + "version": "10.0.2", + "resolved": "https://registry.npmjs.org/@nestjs/typeorm/-/typeorm-10.0.2.tgz", + "integrity": "sha512-H738bJyydK4SQkRCTeh1aFBxoO1E9xdL/HaLGThwrqN95os5mEyAtK7BLADOS+vldP4jDZ2VQPLj4epWwRqCeQ==", + "license": "MIT", + "dependencies": { + "uuid": "9.0.1" + }, + "peerDependencies": { + "@nestjs/common": "^8.0.0 || ^9.0.0 || ^10.0.0", + "@nestjs/core": "^8.0.0 || ^9.0.0 || ^10.0.0", + "reflect-metadata": "^0.1.13 || ^0.2.0", + "rxjs": "^7.2.0", + "typeorm": "^0.3.0" + } + }, + "node_modules/@nestjs/typeorm/node_modules/uuid": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", + "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, "node_modules/@nuxtjs/opencollective": { "version": "0.3.2", "resolved": "https://registry.npmjs.org/@nuxtjs/opencollective/-/opencollective-0.3.2.tgz", @@ -1244,7 +1305,6 @@ "integrity": "sha512-m0jEgYlYz+mDJZ2+F4v8D1AyQb+QzsNqRuI7xg1VQX/KlKS0qT9r1Mo16yo5F/MtifXFgaofIFsdFMox2SxIbQ==", "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~7.16.0" } @@ -1478,7 +1538,6 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "devOptional": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -1518,7 +1577,6 @@ "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -1896,7 +1954,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -2068,7 +2125,6 @@ "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "readdirp": "^4.0.1" }, @@ -2582,6 +2638,15 @@ "url": "https://dotenvx.com" } }, + "node_modules/dotenv-expand": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/dotenv-expand/-/dotenv-expand-10.0.0.tgz", + "integrity": "sha512-GopVGCpVS1UKH75VKHGuQFqS1Gusej0z4FyQkPdwjil2gNIv+LNsqBlboOzpJFZKVT95GkCyWJbBSdFEFUWI2A==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + } + }, "node_modules/dunder-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", @@ -4076,7 +4141,6 @@ "resolved": "https://registry.npmjs.org/pg/-/pg-8.18.0.tgz", "integrity": "sha512-xqrUDL1b9MbkydY/s+VZ6v+xiMUmOUk7SS9d/1kpyQxoJ6U9AO1oIJyUWVZojbfe5Cc/oluutcgFG4L9RDP1iQ==", "license": "MIT", - "peer": true, "dependencies": { "pg-connection-string": "^2.11.0", "pg-pool": "^3.11.0", @@ -4360,8 +4424,7 @@ "version": "0.2.2", "resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.2.2.tgz", "integrity": "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==", - "license": "Apache-2.0", - "peer": true + "license": "Apache-2.0" }, "node_modules/require-addon": { "version": "1.2.0", @@ -4431,7 +4494,6 @@ "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz", "integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==", "license": "Apache-2.0", - "peer": true, "dependencies": { "tslib": "^2.1.0" } @@ -4487,7 +4549,6 @@ "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", @@ -5149,7 +5210,6 @@ "integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==", "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "@cspotcode/source-map-support": "^0.8.0", "@tsconfig/node10": "^1.0.7", @@ -5372,7 +5432,6 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "devOptional": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -5558,7 +5617,6 @@ "integrity": "sha512-Qphch25abbMNtekmEGJmeRUhLDbe+QfiWTiqpKYkpCOWY64v9eyl+KRRLmqOFA2AvKPpc9DC6+u2n76tQLBoaA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@types/eslint-scope": "^3.7.7", "@types/estree": "^1.0.8", diff --git a/package.json b/package.json index c9028df..4bed1b8 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,8 @@ "build": "nest build", "start": "nest start", "start:dev": "nest start --watch", - "test": "jest" + "test": "jest", + "test:risk": "ts-node src/test-risk.ts" }, "dependencies": { "@nestjs/common": "^10.4.22", diff --git a/src/app.module.ts b/src/app.module.ts index 66490a7..afcc336 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -2,9 +2,10 @@ import { Module } from '@nestjs/common'; import { AppController } from './app.controller'; import { AppService } from './app.service'; import { HealthModule } from './health/health.module'; +import { RiskModule } from './risk/risk.module'; @Module({ - imports: [HealthModule], + imports: [HealthModule, RiskModule], controllers: [AppController], providers: [AppService], }) diff --git a/src/risk/risk.module.ts b/src/risk/risk.module.ts new file mode 100644 index 0000000..0809923 --- /dev/null +++ b/src/risk/risk.module.ts @@ -0,0 +1,8 @@ +import { Module } from '@nestjs/common'; +import { RiskService } from './risk.service'; + +@Module({ + providers: [RiskService], + exports: [RiskService], +}) +export class RiskModule {} diff --git a/src/risk/risk.service.ts b/src/risk/risk.service.ts new file mode 100644 index 0000000..6df00f5 --- /dev/null +++ b/src/risk/risk.service.ts @@ -0,0 +1,99 @@ +import { Injectable, BadRequestException } from '@nestjs/common'; + +@Injectable() +export class RiskService { + // Linear Congruential Generator constants for pseudo-random number generation + private readonly LCG_A = 1664525; + private readonly LCG_C = 1013904223; + private readonly LCG_M = 4294967296; + + /** + * Calculates the risk score for an invoice. + * Returns a score between 0 and 100. + * Higher score = Lower risk / Greater loan approval likelihood. + * + * @param amount Invoice amount (must be non-negative number) + * @param date Invoice date (must be valid Date object) + * @returns Risk Score (integer between 0 and 100) + */ + calculateScore(amount: number, date: Date): number { + // 1. Input Validation + this.validateInputs(amount, date); + + // 2. Base Score Calculation + let baseScore: number; + + if (amount < 1000) { + baseScore = 95; + } else if (amount > 10000) { + baseScore = 80; + } else { + // Linear interpolation for amount between 1000 and 10000 + // Formula: y = y1 + (x - x1) * (y2 - y1) / (x2 - x1) + // Points: (1000, 95) -> (10000, 80) + const x = amount; + const x1 = 1000; + const y1 = 95; + const x2 = 10000; + const y2 = 80; + + baseScore = y1 + ((x - x1) * (y2 - y1)) / (x2 - x1); + } + + // 3. Randomization (±5 points) + // Seed based on amount and date to ensure reproducibility for the same invoice inputs + const seed = this.generateSeed(amount, date); + const randomFactor = this.seededRandom(seed); // Returns 0 to 1 + + // Map 0..1 to -5..+5 + // value = (random * 10) - 5 + const variability = (randomFactor * 10) - 5; + + let finalScore = baseScore + variability; + + // 4. Clamping (0-100) and Integer Conversion + finalScore = Math.max(0, Math.min(100, finalScore)); + + return Math.round(finalScore); + } + + private validateInputs(amount: number, date: Date): void { + if (amount === null || amount === undefined || typeof amount !== 'number' || isNaN(amount)) { + throw new BadRequestException('Amount must be a valid number.'); + } + if (amount < 0) { + throw new BadRequestException('Amount cannot be negative.'); + } + if (!date || !(date instanceof Date) || isNaN(date.getTime())) { + throw new BadRequestException('Date must be a valid Date object.'); + } + } + + /** + * Generates a deterministic seed based on input parameters. + * This ensures that the same invoice (same amount and date) always gets the same random factor. + */ + private generateSeed(amount: number, date: Date): number { + // Create a string representation to hash + // Using date.getTime() ensures we use the exact timestamp + const inputString = `${amount}-${date.getTime()}`; + + let hash = 0; + for (let i = 0; i < inputString.length; i++) { + const char = inputString.charCodeAt(i); + hash = ((hash << 5) - hash) + char; + hash = hash & hash; // Convert to 32bit integer + } + return Math.abs(hash); + } + + /** + * Simple Linear Congruential Generator for seeded random numbers. + * Returns a number between 0 (inclusive) and 1 (exclusive). + */ + private seededRandom(seed: number): number { + // next = (a * seed + c) % m + const next = (this.LCG_A * seed + this.LCG_C) % this.LCG_M; + return next / this.LCG_M; + } +} diff --git a/src/test-risk.ts b/src/test-risk.ts new file mode 100644 index 0000000..b36dbdd --- /dev/null +++ b/src/test-risk.ts @@ -0,0 +1,49 @@ +import { RiskService } from './risk/risk.service'; + +async function testRiskService() { + const riskService = new RiskService(); + + console.log('--- Testing RiskService ---'); + + // Test Case 1: Low Amount (< 1000) + const lowAmount = 500; + const date1 = new Date('2023-10-01T10:00:00Z'); + const score1 = riskService.calculateScore(lowAmount, date1); + console.log(`Amount: ${lowAmount}, Date: ${date1.toISOString()} -> Score: ${score1} (Expected near 95)`); + + // Test Case 2: High Amount (> 10000) + const highAmount = 15000; + const date2 = new Date('2023-10-02T10:00:00Z'); + const score2 = riskService.calculateScore(highAmount, date2); + console.log(`Amount: ${highAmount}, Date: ${date2.toISOString()} -> Score: ${score2} (Expected near 80)`); + + // Test Case 3: Mid Amount (5500 - midpoint) + // Linear interpolation: 95 + (5500-1000) * (80-95)/(10000-1000) + // = 95 + 4500 * (-15/9000) = 95 + 4500 * (-1/600) = 95 - 7.5 = 87.5 + const midAmount = 5500; + const date3 = new Date('2023-10-03T10:00:00Z'); + const score3 = riskService.calculateScore(midAmount, date3); + console.log(`Amount: ${midAmount}, Date: ${date3.toISOString()} -> Score: ${score3} (Expected near 87-88)`); + + // Test Case 4: Reproducibility + const score4 = riskService.calculateScore(midAmount, date3); + console.log(`Repeated Call -> Score: ${score4} (Expected: ${score3}) - ${score3 === score4 ? 'PASS' : 'FAIL'}`); + + // Test Case 5: Error Handling - Negative Amount + try { + riskService.calculateScore(-100, new Date()); + console.log('Negative Amount Test: FAIL (Should throw error)'); + } catch (e) { + console.log(`Negative Amount Test: PASS (Error: ${e.message})`); + } + + // Test Case 6: Error Handling - Invalid Date + try { + riskService.calculateScore(1000, new Date('invalid-date')); + console.log('Invalid Date Test: FAIL (Should throw error)'); + } catch (e) { + console.log(`Invalid Date Test: PASS (Error: ${e.message})`); + } +} + +testRiskService();