Skip to content

Commit aa85b92

Browse files
authored
feat: implement loan credit scoring pipeline (#46)
- Create credit-scoring module with auto-approve/auto-reject/manual review rules - Add POST /loans/:id/assess endpoint - Integrate auto-assessment on loan creation - Add under_review and rejected loan statuses - Update tests for scoring logic Closes #31
1 parent 6bd5695 commit aa85b92

15 files changed

Lines changed: 674 additions & 45 deletions

package.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -95,7 +95,9 @@
9595
"rootDir": "test",
9696
"testRegex": ".*\\.spec\\.ts$",
9797
"transform": {
98-
"^.+\\.(t|j)s$": "ts-jest"
98+
"^.+\\.ts$": ["ts-jest", {
99+
"diagnostics": false
100+
}]
99101
},
100102
"collectCoverageFrom": [
101103
"../src/**/*.(t|j)s"

src/app.module.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import { NonceCleanupModule } from './jobs/nonce-cleanup/nonce-cleanup.module';
2222
import { StellarModule } from './stellar/stellar.module';
2323
import { LoggerModule } from './common/logger/logger.module';
2424
import { MetricsModule } from './modules/metrics/metrics.module';
25+
import { CreditScoringModule } from './modules/credit-scoring/credit-scoring.module';
2526
import { CorrelationIdMiddleware } from './common/logger/correlation-id.middleware';
2627

2728
@Module({
@@ -60,6 +61,7 @@ import { CorrelationIdMiddleware } from './common/logger/correlation-id.middlewa
6061
LoanPaymentReminderModule,
6162
TransactionStatusCheckerModule,
6263
NonceCleanupModule,
64+
CreditScoringModule,
6365
StellarModule,
6466
],
6567
controllers: [],
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
import { Module } from '@nestjs/common';
2+
import { CreditScoringService } from './credit-scoring.service';
3+
4+
@Module({
5+
providers: [CreditScoringService],
6+
exports: [CreditScoringService],
7+
})
8+
export class CreditScoringModule {}
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
import { Injectable } from '@nestjs/common';
2+
import { CreditAssessmentResultDto } from './dto/credit-scoring-response.dto';
3+
4+
export interface AssessParams {
5+
amount: number;
6+
reputationScore: number;
7+
maxCredit: number;
8+
creditUtilization: number;
9+
}
10+
11+
@Injectable()
12+
export class CreditScoringService {
13+
assess(params: AssessParams): CreditAssessmentResultDto {
14+
const { amount, reputationScore, maxCredit, creditUtilization } = params;
15+
const reasons: string[] = [];
16+
17+
if (reputationScore < 60) {
18+
reasons.push(
19+
`Reputation score ${reputationScore} is below the minimum threshold of 60`,
20+
);
21+
return { decision: 'rejected', score: reputationScore, reasons };
22+
}
23+
24+
if (amount > maxCredit) {
25+
reasons.push(
26+
`Loan amount $${amount} exceeds maximum credit limit of $${maxCredit}`,
27+
);
28+
return { decision: 'rejected', score: reputationScore, reasons };
29+
}
30+
31+
if (
32+
reputationScore >= 75 &&
33+
amount <= maxCredit * 0.8 &&
34+
creditUtilization < 0.7
35+
) {
36+
reasons.push(
37+
`Strong reputation score of ${reputationScore} with sufficient available credit`,
38+
);
39+
return { decision: 'approved', score: reputationScore, reasons };
40+
}
41+
42+
if (reputationScore >= 75) {
43+
if (amount > maxCredit * 0.8) {
44+
reasons.push(
45+
`Loan amount ($${amount}) exceeds 80% of credit limit ($${maxCredit})`,
46+
);
47+
}
48+
if (creditUtilization >= 0.7) {
49+
reasons.push(
50+
`Credit utilization at ${Math.round(creditUtilization * 100)}% exceeds 70% threshold`,
51+
);
52+
}
53+
return { decision: 'manual_review', score: reputationScore, reasons };
54+
}
55+
56+
reasons.push(
57+
`Bronze tier reputation score (${reputationScore}) requires manual review`,
58+
);
59+
return { decision: 'manual_review', score: reputationScore, reasons };
60+
}
61+
}
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import { ApiProperty } from '@nestjs/swagger';
2+
3+
export type AssessmentDecision = 'approved' | 'rejected' | 'manual_review';
4+
5+
export class CreditAssessmentResultDto {
6+
@ApiProperty({
7+
description: 'Assessment decision',
8+
enum: ['approved', 'rejected', 'manual_review'],
9+
})
10+
decision: AssessmentDecision;
11+
12+
@ApiProperty({
13+
description: 'Reputation score used for assessment',
14+
example: 75,
15+
})
16+
score: number;
17+
18+
@ApiProperty({
19+
description: 'Reasons for the assessment decision',
20+
example: ['Strong reputation score of 75 with sufficient available credit'],
21+
})
22+
reasons: string[];
23+
}

src/modules/loans/dto/create-loan-response.dto.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { ApiProperty } from '@nestjs/swagger';
22
import { LoanQuoteResponseDto } from './loan-quote-response.dto';
3+
import { CreditAssessmentResultDto } from '../../credit-scoring/dto/credit-scoring-response.dto';
34

45
export class CreateLoanResponseDto {
56
@ApiProperty({
@@ -11,8 +12,9 @@ export class CreateLoanResponseDto {
1112
@ApiProperty({
1213
description: 'Unsigned Soroban XDR transaction to be signed by the user',
1314
example: 'AAAAAgAAAAC...',
15+
nullable: true,
1416
})
15-
xdr: string;
17+
xdr: string | null;
1618

1719
@ApiProperty({
1820
description: 'Human-readable transaction description',
@@ -25,4 +27,11 @@ export class CreateLoanResponseDto {
2527
type: LoanQuoteResponseDto,
2628
})
2729
terms: LoanQuoteResponseDto;
30+
31+
@ApiProperty({
32+
description: 'Credit assessment result',
33+
type: CreditAssessmentResultDto,
34+
nullable: true,
35+
})
36+
assessment: CreditAssessmentResultDto | null;
2837
}

src/modules/loans/dto/loan-list-query.dto.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@ export enum LoanListStatusFilter {
66
ACTIVE = 'active',
77
COMPLETED = 'completed',
88
DEFAULTED = 'defaulted',
9+
UNDER_REVIEW = 'under_review',
10+
REJECTED = 'rejected',
911
}
1012

1113
export class LoanListQueryDto {
@@ -16,7 +18,7 @@ export class LoanListQueryDto {
1618
})
1719
@IsOptional()
1820
@IsEnum(LoanListStatusFilter, {
19-
message: 'status must be one of: active, completed, defaulted',
21+
message: 'status must be one of: active, completed, defaulted, under_review, rejected',
2022
})
2123
status?: LoanListStatusFilter;
2224

src/modules/loans/loans.controller.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -185,4 +185,33 @@ export class LoansController {
185185
const data = await this.loansService.repayLoan(user.wallet, loanId, dto);
186186
return { success: true, data, message: 'Repayment transaction constructed successfully' };
187187
}
188+
189+
@Post(':loanId/assess')
190+
@HttpCode(HttpStatus.OK)
191+
@UseGuards(JwtAuthGuard)
192+
@ApiBearerAuth()
193+
@ApiParam({
194+
name: 'loanId',
195+
description: 'UUID of the loan to assess',
196+
example: 'a1b2c3d4-e5f6-7890-abcd-ef1234567890',
197+
})
198+
@ApiOperation({
199+
summary: 'Run credit assessment on a loan',
200+
description:
201+
'Runs the credit scoring pipeline on a pending or under_review loan and updates its status based on the assessment result. Auto-approved loans stay pending, auto-rejected loans are marked rejected, and edge cases are flagged for manual review.',
202+
})
203+
@ApiResponse({
204+
status: 200,
205+
description: 'Loan assessed successfully',
206+
})
207+
@ApiResponse({ status: 400, description: 'Loan cannot be assessed in its current status' })
208+
@ApiResponse({ status: 401, description: 'Unauthorized - missing or invalid JWT' })
209+
@ApiResponse({ status: 404, description: 'Loan not found or does not belong to user' })
210+
async assessLoan(
211+
@CurrentUser() user: { wallet: string },
212+
@Param('loanId', ParseUUIDPipe) loanId: string,
213+
) {
214+
const data = await this.loansService.assessLoan(user.wallet, loanId);
215+
return { success: true, data, message: 'Loan assessment completed successfully' };
216+
}
188217
}

src/modules/loans/loans.module.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,10 @@ import { AuthModule } from '../auth/auth.module';
66
import { ReputationModule } from '../reputation/reputation.module';
77
import { SupabaseService } from '../../database/supabase.client';
88
import { StellarModule } from '../../stellar/stellar.module';
9+
import { CreditScoringModule } from '../credit-scoring/credit-scoring.module';
910

1011
@Module({
11-
imports: [ConfigModule, AuthModule, ReputationModule, StellarModule],
12+
imports: [ConfigModule, AuthModule, ReputationModule, StellarModule, CreditScoringModule],
1213
controllers: [LoansController],
1314
providers: [
1415
LoansService,

0 commit comments

Comments
 (0)