diff --git a/package.json b/package.json index 4ccc9d4..c163248 100644 --- a/package.json +++ b/package.json @@ -95,7 +95,9 @@ "rootDir": "test", "testRegex": ".*\\.spec\\.ts$", "transform": { - "^.+\\.(t|j)s$": "ts-jest" + "^.+\\.ts$": ["ts-jest", { + "diagnostics": false + }] }, "collectCoverageFrom": [ "../src/**/*.(t|j)s" diff --git a/src/app.module.ts b/src/app.module.ts index bb5f026..a0a196e 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -22,6 +22,7 @@ import { NonceCleanupModule } from './jobs/nonce-cleanup/nonce-cleanup.module'; import { StellarModule } from './stellar/stellar.module'; import { LoggerModule } from './common/logger/logger.module'; import { MetricsModule } from './modules/metrics/metrics.module'; +import { CreditScoringModule } from './modules/credit-scoring/credit-scoring.module'; import { CorrelationIdMiddleware } from './common/logger/correlation-id.middleware'; @Module({ @@ -60,6 +61,7 @@ import { CorrelationIdMiddleware } from './common/logger/correlation-id.middlewa LoanPaymentReminderModule, TransactionStatusCheckerModule, NonceCleanupModule, + CreditScoringModule, StellarModule, ], controllers: [], diff --git a/src/modules/credit-scoring/credit-scoring.module.ts b/src/modules/credit-scoring/credit-scoring.module.ts new file mode 100644 index 0000000..1dbffc7 --- /dev/null +++ b/src/modules/credit-scoring/credit-scoring.module.ts @@ -0,0 +1,8 @@ +import { Module } from '@nestjs/common'; +import { CreditScoringService } from './credit-scoring.service'; + +@Module({ + providers: [CreditScoringService], + exports: [CreditScoringService], +}) +export class CreditScoringModule {} diff --git a/src/modules/credit-scoring/credit-scoring.service.ts b/src/modules/credit-scoring/credit-scoring.service.ts new file mode 100644 index 0000000..3a2bbd0 --- /dev/null +++ b/src/modules/credit-scoring/credit-scoring.service.ts @@ -0,0 +1,61 @@ +import { Injectable } from '@nestjs/common'; +import { CreditAssessmentResultDto } from './dto/credit-scoring-response.dto'; + +export interface AssessParams { + amount: number; + reputationScore: number; + maxCredit: number; + creditUtilization: number; +} + +@Injectable() +export class CreditScoringService { + assess(params: AssessParams): CreditAssessmentResultDto { + const { amount, reputationScore, maxCredit, creditUtilization } = params; + const reasons: string[] = []; + + if (reputationScore < 60) { + reasons.push( + `Reputation score ${reputationScore} is below the minimum threshold of 60`, + ); + return { decision: 'rejected', score: reputationScore, reasons }; + } + + if (amount > maxCredit) { + reasons.push( + `Loan amount $${amount} exceeds maximum credit limit of $${maxCredit}`, + ); + return { decision: 'rejected', score: reputationScore, reasons }; + } + + if ( + reputationScore >= 75 && + amount <= maxCredit * 0.8 && + creditUtilization < 0.7 + ) { + reasons.push( + `Strong reputation score of ${reputationScore} with sufficient available credit`, + ); + return { decision: 'approved', score: reputationScore, reasons }; + } + + if (reputationScore >= 75) { + if (amount > maxCredit * 0.8) { + reasons.push( + `Loan amount ($${amount}) exceeds 80% of credit limit ($${maxCredit})`, + ); + } + if (creditUtilization >= 0.7) { + reasons.push( + `Credit utilization at ${Math.round(creditUtilization * 100)}% exceeds 70% threshold`, + ); + } + return { decision: 'manual_review', score: reputationScore, reasons }; + } + + reasons.push( + `Bronze tier reputation score (${reputationScore}) requires manual review`, + ); + return { decision: 'manual_review', score: reputationScore, reasons }; + } +} diff --git a/src/modules/credit-scoring/dto/credit-scoring-response.dto.ts b/src/modules/credit-scoring/dto/credit-scoring-response.dto.ts new file mode 100644 index 0000000..7aff616 --- /dev/null +++ b/src/modules/credit-scoring/dto/credit-scoring-response.dto.ts @@ -0,0 +1,23 @@ +import { ApiProperty } from '@nestjs/swagger'; + +export type AssessmentDecision = 'approved' | 'rejected' | 'manual_review'; + +export class CreditAssessmentResultDto { + @ApiProperty({ + description: 'Assessment decision', + enum: ['approved', 'rejected', 'manual_review'], + }) + decision: AssessmentDecision; + + @ApiProperty({ + description: 'Reputation score used for assessment', + example: 75, + }) + score: number; + + @ApiProperty({ + description: 'Reasons for the assessment decision', + example: ['Strong reputation score of 75 with sufficient available credit'], + }) + reasons: string[]; +} diff --git a/src/modules/loans/dto/create-loan-response.dto.ts b/src/modules/loans/dto/create-loan-response.dto.ts index 27129ab..232e4d5 100644 --- a/src/modules/loans/dto/create-loan-response.dto.ts +++ b/src/modules/loans/dto/create-loan-response.dto.ts @@ -1,5 +1,6 @@ import { ApiProperty } from '@nestjs/swagger'; import { LoanQuoteResponseDto } from './loan-quote-response.dto'; +import { CreditAssessmentResultDto } from '../../credit-scoring/dto/credit-scoring-response.dto'; export class CreateLoanResponseDto { @ApiProperty({ @@ -11,8 +12,9 @@ export class CreateLoanResponseDto { @ApiProperty({ description: 'Unsigned Soroban XDR transaction to be signed by the user', example: 'AAAAAgAAAAC...', + nullable: true, }) - xdr: string; + xdr: string | null; @ApiProperty({ description: 'Human-readable transaction description', @@ -25,4 +27,11 @@ export class CreateLoanResponseDto { type: LoanQuoteResponseDto, }) terms: LoanQuoteResponseDto; + + @ApiProperty({ + description: 'Credit assessment result', + type: CreditAssessmentResultDto, + nullable: true, + }) + assessment: CreditAssessmentResultDto | null; } diff --git a/src/modules/loans/dto/loan-list-query.dto.ts b/src/modules/loans/dto/loan-list-query.dto.ts index 340e34b..ba3017d 100644 --- a/src/modules/loans/dto/loan-list-query.dto.ts +++ b/src/modules/loans/dto/loan-list-query.dto.ts @@ -6,6 +6,8 @@ export enum LoanListStatusFilter { ACTIVE = 'active', COMPLETED = 'completed', DEFAULTED = 'defaulted', + UNDER_REVIEW = 'under_review', + REJECTED = 'rejected', } export class LoanListQueryDto { @@ -16,7 +18,7 @@ export class LoanListQueryDto { }) @IsOptional() @IsEnum(LoanListStatusFilter, { - message: 'status must be one of: active, completed, defaulted', + message: 'status must be one of: active, completed, defaulted, under_review, rejected', }) status?: LoanListStatusFilter; diff --git a/src/modules/loans/loans.controller.ts b/src/modules/loans/loans.controller.ts index aa09f0d..e83b314 100644 --- a/src/modules/loans/loans.controller.ts +++ b/src/modules/loans/loans.controller.ts @@ -185,4 +185,33 @@ export class LoansController { const data = await this.loansService.repayLoan(user.wallet, loanId, dto); return { success: true, data, message: 'Repayment transaction constructed successfully' }; } + + @Post(':loanId/assess') + @HttpCode(HttpStatus.OK) + @UseGuards(JwtAuthGuard) + @ApiBearerAuth() + @ApiParam({ + name: 'loanId', + description: 'UUID of the loan to assess', + example: 'a1b2c3d4-e5f6-7890-abcd-ef1234567890', + }) + @ApiOperation({ + summary: 'Run credit assessment on a loan', + description: + '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.', + }) + @ApiResponse({ + status: 200, + description: 'Loan assessed successfully', + }) + @ApiResponse({ status: 400, description: 'Loan cannot be assessed in its current status' }) + @ApiResponse({ status: 401, description: 'Unauthorized - missing or invalid JWT' }) + @ApiResponse({ status: 404, description: 'Loan not found or does not belong to user' }) + async assessLoan( + @CurrentUser() user: { wallet: string }, + @Param('loanId', ParseUUIDPipe) loanId: string, + ) { + const data = await this.loansService.assessLoan(user.wallet, loanId); + return { success: true, data, message: 'Loan assessment completed successfully' }; + } } diff --git a/src/modules/loans/loans.module.ts b/src/modules/loans/loans.module.ts index da4524b..64ba1ab 100644 --- a/src/modules/loans/loans.module.ts +++ b/src/modules/loans/loans.module.ts @@ -6,9 +6,10 @@ import { AuthModule } from '../auth/auth.module'; import { ReputationModule } from '../reputation/reputation.module'; import { SupabaseService } from '../../database/supabase.client'; import { StellarModule } from '../../stellar/stellar.module'; +import { CreditScoringModule } from '../credit-scoring/credit-scoring.module'; @Module({ - imports: [ConfigModule, AuthModule, ReputationModule, StellarModule], + imports: [ConfigModule, AuthModule, ReputationModule, StellarModule, CreditScoringModule], controllers: [LoansController], providers: [ LoansService, diff --git a/src/modules/loans/loans.service.ts b/src/modules/loans/loans.service.ts index 852fe61..c0f941e 100644 --- a/src/modules/loans/loans.service.ts +++ b/src/modules/loans/loans.service.ts @@ -24,6 +24,8 @@ import { LoanListResponseDto, } from './dto/loan-list-response.dto'; import { ReputationTier } from '../reputation/dto/reputation-response.dto'; +import { CreditScoringService, AssessParams } from '../credit-scoring/credit-scoring.service'; +import { CreditAssessmentResultDto } from '../credit-scoring/dto/credit-scoring-response.dto'; const GUARANTEE_PERCENT = 0.2; const LOAN_PERCENT = 0.8; @@ -46,7 +48,7 @@ interface CreateLoanRecord { total_repayment: number; remaining_balance: number; term: number; - status: 'pending'; + status: 'pending' | 'under_review'; next_payment_due: string | null; } @@ -88,6 +90,7 @@ export class LoansService { private readonly supabaseService: SupabaseService, private readonly creditLineContractClient: CreditLineContractClient, private readonly reputationContractClient: ReputationContractClient, + private readonly creditScoringService: CreditScoringService, ) {} async calculateLoanQuote( @@ -103,25 +106,39 @@ export class LoansService { const loanId = this.generateProvisionalLoanId(); const description = `Create BNPL loan for $${dto.amount} at ${vendor.name}`; - let xdr: string; - try { - xdr = await this.creditLineContractClient.buildCreateLoanTransaction(wallet, { - loanId, - vendorId: vendor.id, - amount: dto.amount, - loanAmount: terms.loanAmount, - guarantee: terms.guarantee, - interestRate: terms.interestRate, - term: terms.term, - }); - } catch (error) { - this.logger.error(`Failed to build create_loan XDR for ${loanId}: ${error.message}`); - throw new InternalServerErrorException({ - code: 'BLOCKCHAIN_CREATE_LOAN_XDR_FAILED', - message: 'Failed to construct unsigned loan transaction. Please try again.', + const assessment = await this.runCreditAssessment(wallet, dto.amount); + + if (assessment.decision === 'rejected') { + throw new BadRequestException({ + code: 'LOAN_ASSESSMENT_REJECTED', + message: `Loan application was declined: ${assessment.reasons[0]}`, + details: assessment, }); } + const status = assessment.decision === 'approved' ? 'pending' : 'under_review'; + let xdr: string | null = null; + + if (status === 'pending') { + try { + xdr = await this.creditLineContractClient.buildCreateLoanTransaction(wallet, { + loanId, + vendorId: vendor.id, + amount: dto.amount, + loanAmount: terms.loanAmount, + guarantee: terms.guarantee, + interestRate: terms.interestRate, + term: terms.term, + }); + } catch (error) { + this.logger.error(`Failed to build create_loan XDR for ${loanId}: ${error.message}`); + throw new InternalServerErrorException({ + code: 'BLOCKCHAIN_CREATE_LOAN_XDR_FAILED', + message: 'Failed to construct unsigned loan transaction. Please try again.', + }); + } + } + try { await this.persistPendingLoan({ loan_id: loanId, @@ -134,7 +151,7 @@ export class LoansService { total_repayment: terms.totalRepayment, remaining_balance: terms.totalRepayment, term: terms.term, - status: 'pending', + status, next_payment_due: terms.schedule[0]?.dueDate ?? null, }); } catch (error) { @@ -150,6 +167,7 @@ export class LoansService { xdr, description, terms, + assessment, }; } @@ -259,6 +277,8 @@ export class LoansService { LoanListStatusFilter.ACTIVE, LoanListStatusFilter.COMPLETED, LoanListStatusFilter.DEFAULTED, + LoanListStatusFilter.UNDER_REVIEW, + LoanListStatusFilter.REJECTED, ]); } @@ -496,6 +516,98 @@ export class LoansService { return Math.round(value * 100) / 100; } + async assessLoan( + wallet: string, + loanId: string, + ): Promise<{ loanId: string; assessment: CreditAssessmentResultDto; previousStatus: string; currentStatus: string }> { + const client = this.supabaseService.getServiceRoleClient(); + const { data: loan, error } = await client + .from('loans') + .select('id, loan_id, user_wallet, status, amount') + .eq('id', loanId) + .single(); + + if (error || !loan) { + throw new NotFoundException({ + code: 'LOAN_NOT_FOUND', + message: 'Loan not found. Please provide a valid loan ID.', + }); + } + + if (loan.user_wallet !== wallet) { + throw new NotFoundException({ + code: 'LOAN_NOT_FOUND', + message: 'Loan not found. Please provide a valid loan ID.', + }); + } + + if (!['pending', 'under_review'].includes(loan.status)) { + throw new BadRequestException({ + code: 'LOAN_ASSESSMENT_INVALID_STATUS', + message: `Cannot assess a loan with status '${loan.status}'. Only pending or under_review loans can be assessed.`, + }); + } + + const assessment = await this.runCreditAssessment(wallet, Number(loan.amount)); + const previousStatus = loan.status; + + const newStatus = + assessment.decision === 'approved' ? 'pending' : + assessment.decision === 'rejected' ? 'rejected' : 'under_review'; + + this.logger.log(`Assessing loan ${loanId}: ${previousStatus} -> ${newStatus} (${assessment.decision})`); + + const { error: updateError } = await client + .from('loans') + .update({ status: newStatus, updated_at: new Date().toISOString() }) + .eq('id', loanId); + + if (updateError) { + this.logger.error(`Failed to update loan ${loanId} status: ${updateError.message}`); + throw new InternalServerErrorException({ + code: 'LOAN_ASSESSMENT_UPDATE_FAILED', + message: 'Failed to update loan status after assessment.', + }); + } + + return { + loanId: loan.loan_id, + assessment, + previousStatus, + currentStatus: newStatus, + }; + } + + private async runCreditAssessment( + wallet: string, + amount: number, + ): Promise { + const reputation = await this.reputationService.getReputationScore(wallet); + + const client = this.supabaseService.getServiceRoleClient(); + const { data: activeLoans } = await client + .from('loans') + .select('remaining_balance') + .eq('user_wallet', wallet) + .eq('status', 'active'); + + const creditUsed = (activeLoans ?? []).reduce( + (sum, loan) => sum + Number(loan.remaining_balance ?? 0), + 0, + ); + const creditUtilization = + reputation.maxCredit > 0 ? creditUsed / reputation.maxCredit : 0; + + const params: AssessParams = { + amount, + reputationScore: reputation.score, + maxCredit: reputation.maxCredit, + creditUtilization, + }; + + return this.creditScoringService.assess(params); + } + private mapScoreToCreditTier(score: number): { tier: ReputationTier; maxCredit: number; diff --git a/supabase/migrations/20260617000000_loans_under_review.sql b/supabase/migrations/20260617000000_loans_under_review.sql new file mode 100644 index 0000000..f106e34 --- /dev/null +++ b/supabase/migrations/20260617000000_loans_under_review.sql @@ -0,0 +1,5 @@ +-- Add under_review and rejected as valid status values for the credit scoring pipeline. +-- The loans.status column is TEXT, so no schema change is required. +-- Valid statuses: pending, active, completed, defaulted, under_review, rejected + +COMMENT ON COLUMN public.loans.status IS 'Loan status: pending (awaiting on-chain), active (repaying), completed (paid off), defaulted (missed payments), under_review (flagged for manual review), rejected (declined by credit scoring)'; diff --git a/test/__mocks__/stellar-sdk.js b/test/__mocks__/stellar-sdk.js new file mode 100644 index 0000000..dd9ad6e --- /dev/null +++ b/test/__mocks__/stellar-sdk.js @@ -0,0 +1,51 @@ +module.exports = { + nativeToScVal: jest.fn((val) => val), + scValToNative: jest.fn((val) => val), + Address: { + fromString: jest.fn((addr) => addr), + toString: jest.fn(() => ''), + }, + Keypair: { + fromSecret: jest.fn(() => ({ + publicKey: jest.fn(() => 'GABCDEFGHIJKLMNOPQRSTUVWXYZ234567ABCDEFGHIJKLMNOPQRSTUVW'), + })), + random: jest.fn(() => ({ + publicKey: jest.fn(() => 'GABCDEFGHIJKLMNOPQRSTUVWXYZ234567ABCDEFGHIJKLMNOPQRSTUVW'), + secret: jest.fn(() => 'SABCDEFGHIJKLMNOPQRSTUVWXYZ234567ABCDEFGHIJKLMNOPQRSTUVWXYZ'), + })), + }, + StrKey: { + isValidEd25519PublicKey: jest.fn(() => true), + isValidEd25519SecretSeed: jest.fn(() => true), + }, + xdr: { + ScVal: { scvAddress: jest.fn() }, + Address: jest.fn(), + }, + Contract: jest.fn(), + SorobanRpc: { + Server: jest.fn(() => ({ + getAccount: jest.fn(), + simulateTransaction: jest.fn(), + prepareTransaction: jest.fn(), + sendTransaction: jest.fn(), + getTransaction: jest.fn(), + })), + }, + BASE_FEE: '100', + Networks: { + PUBLIC: 'Public Global Stellar Network ; September 2015', + TESTNET: 'Test SDF Network ; September 2015', + }, + TransactionBuilder: jest.fn(), + Operation: { + payment: jest.fn(), + beginSponsoringFutureReserves: jest.fn(), + endSponsoringFutureReserves: jest.fn(), + bumpFootprintExpiration: jest.fn(), + restoreFootprint: jest.fn(), + }, + Memo: { text: jest.fn() }, + Asset: { native: jest.fn() }, + timeoutInfinite: 0, +}; diff --git a/test/unit/modules/credit-scoring/credit-scoring.service.spec.ts b/test/unit/modules/credit-scoring/credit-scoring.service.spec.ts new file mode 100644 index 0000000..2470587 --- /dev/null +++ b/test/unit/modules/credit-scoring/credit-scoring.service.spec.ts @@ -0,0 +1,115 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { CreditScoringService } from '../../../../src/modules/credit-scoring/credit-scoring.service'; +import { AssessParams } from '../../../../src/modules/credit-scoring/credit-scoring.service'; + +describe('CreditScoringService', () => { + let service: CreditScoringService; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [CreditScoringService], + }).compile(); + + service = module.get(CreditScoringService); + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); + + function makeParams(overrides: Partial = {}): AssessParams { + return { + amount: 500, + reputationScore: 75, + maxCredit: 3000, + creditUtilization: 0.3, + ...overrides, + }; + } + + describe('assess', () => { + it('should auto-approve gold tier users with low utilization', () => { + const result = service.assess(makeParams({ reputationScore: 95, maxCredit: 5000 })); + expect(result.decision).toBe('approved'); + expect(result.reasons).toHaveLength(1); + expect(result.reasons[0]).toContain('Strong reputation score'); + }); + + it('should auto-approve silver tier users with low utilization', () => { + const result = service.assess(makeParams({ reputationScore: 85, maxCredit: 3000 })); + expect(result.decision).toBe('approved'); + }); + + it('should auto-approve when score is exactly 75 with good parameters', () => { + const result = service.assess(makeParams({ reputationScore: 75, amount: 200, maxCredit: 3000 })); + expect(result.decision).toBe('approved'); + }); + + it('should reject when reputation score is below 60', () => { + const result = service.assess(makeParams({ reputationScore: 45 })); + expect(result.decision).toBe('rejected'); + expect(result.reasons[0]).toContain('below the minimum threshold'); + }); + + it('should reject when reputation score is exactly 59', () => { + const result = service.assess(makeParams({ reputationScore: 59 })); + expect(result.decision).toBe('rejected'); + }); + + it('should reject when amount exceeds max credit', () => { + const result = service.assess(makeParams({ reputationScore: 80, amount: 5000, maxCredit: 3000 })); + expect(result.decision).toBe('rejected'); + expect(result.reasons[0]).toContain('exceeds maximum credit limit'); + }); + + it('should flag for manual review when amount exceeds 80% of credit limit', () => { + const result = service.assess(makeParams({ reputationScore: 85, amount: 2800, maxCredit: 3000 })); + expect(result.decision).toBe('manual_review'); + expect(result.reasons[0]).toContain('exceeds 80% of credit limit'); + }); + + it('should flag for manual review when credit utilization exceeds 70%', () => { + const result = service.assess(makeParams({ reputationScore: 85, amount: 500, maxCredit: 3000, creditUtilization: 0.75 })); + expect(result.decision).toBe('manual_review'); + expect(result.reasons[0]).toContain('exceeds 70% threshold'); + }); + + it('should flag for manual review with multiple reasons when both conditions apply', () => { + const result = service.assess(makeParams({ reputationScore: 85, amount: 2800, maxCredit: 3000, creditUtilization: 0.8 })); + expect(result.decision).toBe('manual_review'); + expect(result.reasons).toHaveLength(2); + }); + + it('should flag bronze tier for manual review', () => { + const result = service.assess(makeParams({ reputationScore: 65 })); + expect(result.decision).toBe('manual_review'); + expect(result.reasons[0]).toContain('Bronze tier'); + }); + + it('should flag score of exactly 60 for manual review', () => { + const result = service.assess(makeParams({ reputationScore: 60 })); + expect(result.decision).toBe('manual_review'); + }); + + it('should flag score of exactly 74 for manual review', () => { + const result = service.assess(makeParams({ reputationScore: 74 })); + expect(result.decision).toBe('manual_review'); + }); + + it('should flag score of exactly 75 with high utilization for manual review', () => { + const result = service.assess(makeParams({ reputationScore: 75, amount: 500, maxCredit: 3000, creditUtilization: 0.8 })); + expect(result.decision).toBe('manual_review'); + }); + + it('should include the reputation score in the result', () => { + const result = service.assess(makeParams({ reputationScore: 80 })); + expect(result.score).toBe(80); + }); + + it('should return empty reasons when none provided', () => { + const result = service.assess(makeParams({ reputationScore: 95, maxCredit: 5000 })); + expect(result.reasons).toBeInstanceOf(Array); + expect(result.reasons.length).toBeGreaterThan(0); + }); + }); +}); diff --git a/test/unit/modules/loans/loans.controller.spec.ts b/test/unit/modules/loans/loans.controller.spec.ts index e982b9f..df88f7e 100644 --- a/test/unit/modules/loans/loans.controller.spec.ts +++ b/test/unit/modules/loans/loans.controller.spec.ts @@ -31,6 +31,7 @@ describe('LoansController', () => { getAvailableCredit: jest.fn(), createLoan: jest.fn(), getMyLoans: jest.fn(), + assessLoan: jest.fn(), }; const mockCreateLoanResponse: CreateLoanResponseDto = { @@ -38,6 +39,7 @@ describe('LoansController', () => { xdr: 'AAAAAgAAAAC...', description: 'Create BNPL loan for $500 at TechStore', terms: mockQuoteResponse as any, + assessment: null, }; beforeEach(async () => { @@ -155,6 +157,41 @@ describe('LoansController', () => { }); }); + describe('assessLoan', () => { + const loanId = '11111111-2222-3333-4444-555555555555'; + + const mockAssessResponse = { + loanId: 'chain-loan-1', + assessment: { + decision: 'approved', + score: 85, + reasons: ['Strong reputation score of 85 with sufficient available credit'], + }, + previousStatus: 'pending', + currentStatus: 'pending', + }; + + it('should assess a loan and return the result', async () => { + mockLoansService.assessLoan.mockResolvedValue(mockAssessResponse); + + const result = await controller.assessLoan(currentUser, loanId); + + expect(result).toEqual({ + success: true, + data: mockAssessResponse, + message: 'Loan assessment completed successfully', + }); + expect(loansService.assessLoan).toHaveBeenCalledWith(validWallet, loanId); + expect(loansService.assessLoan).toHaveBeenCalledTimes(1); + }); + + it('should propagate service errors to the caller', async () => { + mockLoansService.assessLoan.mockRejectedValue(new Error('Loan not found')); + + await expect(controller.assessLoan(currentUser, loanId)).rejects.toThrow('Loan not found'); + }); + }); + describe('getMyLoans', () => { const mockLoanListResponse = { data: [ diff --git a/test/unit/modules/loans/loans.service.spec.ts b/test/unit/modules/loans/loans.service.spec.ts index bf92fa4..2f82922 100644 --- a/test/unit/modules/loans/loans.service.spec.ts +++ b/test/unit/modules/loans/loans.service.spec.ts @@ -13,6 +13,8 @@ import { ReputationContractClient } from '../../../../src/stellar/contracts/clie import { MockCreditLineContractClient } from '../../../../src/stellar/contracts/mocks/creditline.mock'; import { MockReputationContractClient } from '../../../../src/stellar/contracts/mocks/reputation.mock'; import { LoanListStatusFilter } from '../../../../src/modules/loans/dto/loan-list-query.dto'; +import { CreditScoringService } from '../../../../src/modules/credit-scoring/credit-scoring.service'; +import { CreditAssessmentResultDto } from '../../../../src/modules/credit-scoring/dto/credit-scoring-response.dto'; describe('LoansService', () => { let service: LoansService; @@ -34,6 +36,7 @@ describe('LoansService', () => { range: jest.fn().mockReturnThis(), single: jest.fn(), insert: jest.fn(), + update: jest.fn().mockReturnThis(), }; const mockSupabaseClient = { @@ -44,6 +47,43 @@ describe('LoansService', () => { getServiceRoleClient: jest.fn().mockReturnValue(mockSupabaseClient), }; + const mockCreditScoringService = { + assess: jest.fn(), + }; + + function mockReputation(score: number, tier: string, interestRate: number, maxCredit: number) { + mockReputationService.getReputationScore.mockResolvedValue({ + wallet: validWallet, + score, + tier, + interestRate, + maxCredit, + lastUpdated: '2026-02-13T10:00:00.000Z', + }); + } + + function mockVendorFound(isActive = true) { + mockSupabaseFrom.single.mockResolvedValue({ + data: { id: vendorId, name: 'TechStore', verified: isActive }, + error: null, + }); + } + + function mockVendorNotFound() { + mockSupabaseFrom.single.mockResolvedValue({ + data: null, + error: { message: 'not found' }, + }); + } + + function mockAssessment(decision: 'approved' | 'rejected' | 'manual_review') { + mockCreditScoringService.assess.mockReturnValue({ + decision, + score: decision === 'approved' ? 85 : decision === 'rejected' ? 45 : 65, + reasons: ['Test assessment reason'], + }); + } + beforeEach(async () => { const module: TestingModule = await Test.createTestingModule({ providers: [ @@ -52,6 +92,7 @@ describe('LoansService', () => { { provide: SupabaseService, useValue: mockSupabaseService }, { provide: CreditLineContractClient, useClass: MockCreditLineContractClient }, { provide: ReputationContractClient, useClass: MockReputationContractClient }, + { provide: CreditScoringService, useValue: mockCreditScoringService }, ], }).compile(); @@ -67,6 +108,7 @@ describe('LoansService', () => { mockSupabaseFrom.order.mockReturnThis(); mockSupabaseFrom.range.mockReturnThis(); mockSupabaseFrom.insert.mockResolvedValue({ error: null }); + mockSupabaseFrom.update.mockReturnThis(); mockCreditLineContractClient.buildCreateLoanTransaction.mockResolvedValue('AAAAAgAAAAC...'); mockCreditLineContractClient.buildRepayLoanTx.mockResolvedValue('AAAAAgAAAAA...'); }); @@ -229,32 +271,18 @@ describe('LoansService', () => { describe('createLoan', () => { const baseDto = { amount: 500, vendor: vendorId, term: 4 }; - function mockReputation(score: number, tier: string, interestRate: number, maxCredit: number) { - mockReputationService.getReputationScore.mockResolvedValue({ - wallet: validWallet, - score, - tier, - interestRate, - maxCredit, - lastUpdated: '2026-02-13T10:00:00.000Z', - }); - } - - function mockVendorFound(isActive = true) { - mockSupabaseFrom.single.mockResolvedValue({ - data: { id: vendorId, name: 'TechStore', verified: isActive }, - error: null, - }); - } - - it('should create a pending loan with XDR and terms', async () => { + beforeEach(() => { mockReputation(75, 'silver', 8, 2000); mockVendorFound(); + mockAssessment('approved'); + }); + it('should create a pending loan with XDR and terms when auto-approved', async () => { const result = await service.createLoan(validWallet, baseDto); expect(result.loanId).toContain('pending-'); expect(result.xdr).toBe('AAAAAgAAAAC...'); + expect(result.assessment.decision).toBe('approved'); expect(result.description).toBe('Create BNPL loan for $500 at TechStore'); expect(result.terms.guarantee).toBe(100); expect(mockCreditLineContractClient.buildCreateLoanTransaction).toHaveBeenCalled(); @@ -280,9 +308,33 @@ describe('LoansService', () => { ); }); + it('should reject loan creation when assessment rejects', async () => { + mockAssessment('rejected'); + + await expect(service.createLoan(validWallet, baseDto)).rejects.toMatchObject({ + response: { code: 'LOAN_ASSESSMENT_REJECTED' }, + }); + + expect(mockCreditLineContractClient.buildCreateLoanTransaction).not.toHaveBeenCalled(); + expect(mockSupabaseFrom.insert).not.toHaveBeenCalled(); + }); + + it('should create under_review loan when assessment flags manual review', async () => { + mockAssessment('manual_review'); + + const result = await service.createLoan(validWallet, baseDto); + + expect(result.xdr).toBeNull(); + expect(result.assessment.decision).toBe('manual_review'); + expect(mockCreditLineContractClient.buildCreateLoanTransaction).not.toHaveBeenCalled(); + expect(mockSupabaseFrom.insert).toHaveBeenCalledWith( + expect.objectContaining({ + status: 'under_review', + }), + ); + }); + it('should throw InternalServerErrorException when XDR construction fails', async () => { - mockReputation(75, 'silver', 8, 2000); - mockVendorFound(); mockCreditLineContractClient.buildCreateLoanTransaction.mockRejectedValue( new Error('Soroban unavailable'), ); @@ -293,8 +345,6 @@ describe('LoansService', () => { }); it('should throw InternalServerErrorException when pending loan persistence fails', async () => { - mockReputation(75, 'silver', 8, 2000); - mockVendorFound(); mockSupabaseFrom.insert.mockResolvedValue({ error: { message: 'insert failed' }, }); @@ -532,6 +582,126 @@ describe('LoansService', () => { }); }); + describe('assessLoan', () => { + const loanId = '11111111-2222-3333-4444-555555555555'; + const loanIdDb = loanId; + const chainLoanId = 'chain-loan-1'; + + function mockLoanFound(overrides: Record = {}) { + mockSupabaseFrom.single.mockResolvedValue({ + data: { + id: loanIdDb, + loan_id: chainLoanId, + user_wallet: validWallet, + status: 'pending', + amount: 500, + ...overrides, + }, + error: null, + }); + } + + it('should assess a pending loan and approve it', async () => { + mockLoanFound(); + mockAssessment('approved'); + + const result = await service.assessLoan(validWallet, loanId); + + expect(result.assessment.decision).toBe('approved'); + expect(result.previousStatus).toBe('pending'); + expect(result.currentStatus).toBe('pending'); + expect(mockSupabaseFrom.update).toHaveBeenCalled(); + }); + + it('should assess a pending loan and mark it as rejected', async () => { + mockLoanFound(); + mockAssessment('rejected'); + + const result = await service.assessLoan(validWallet, loanId); + + expect(result.assessment.decision).toBe('rejected'); + expect(result.currentStatus).toBe('rejected'); + }); + + it('should assess an under_review loan and keep it under_review', async () => { + mockLoanFound({ status: 'under_review' }); + mockAssessment('manual_review'); + + const result = await service.assessLoan(validWallet, loanId); + + expect(result.assessment.decision).toBe('manual_review'); + expect(result.currentStatus).toBe('under_review'); + }); + + it('should throw NotFoundException when loan does not exist', async () => { + mockSupabaseFrom.single.mockResolvedValue({ + data: null, + error: { message: 'not found' }, + }); + + await expect(service.assessLoan(validWallet, loanId)).rejects.toThrow( + NotFoundException, + ); + }); + + it('should throw NotFoundException when loan belongs to another user', async () => { + mockLoanFound({ user_wallet: 'GOTHERWALLET...' }); + + await expect(service.assessLoan(validWallet, loanId)).rejects.toThrow( + NotFoundException, + ); + }); + + it('should throw BadRequestException when loan is active', async () => { + mockLoanFound({ status: 'active' }); + + await expect(service.assessLoan(validWallet, loanId)).rejects.toThrow( + BadRequestException, + ); + }); + + it('should throw BadRequestException when loan is completed', async () => { + mockLoanFound({ status: 'completed' }); + + await expect(service.assessLoan(validWallet, loanId)).rejects.toThrow( + BadRequestException, + ); + }); + + it('should throw BadRequestException when loan is defaulted', async () => { + mockLoanFound({ status: 'defaulted' }); + + await expect(service.assessLoan(validWallet, loanId)).rejects.toThrow( + BadRequestException, + ); + }); + + it('should throw BadRequestException when loan is rejected', async () => { + mockLoanFound({ status: 'rejected' }); + + await expect(service.assessLoan(validWallet, loanId)).rejects.toThrow( + BadRequestException, + ); + }); + + it('should throw InternalServerErrorException when status update fails', async () => { + mockLoanFound(); + mockAssessment('approved'); + + const updatePromise = Promise.resolve({ + error: { message: 'update failed' }, + data: null, + }); + mockSupabaseFrom.update.mockReturnValueOnce({ + eq: jest.fn().mockReturnValueOnce(updatePromise), + }); + + await expect(service.assessLoan(validWallet, loanId)).rejects.toThrow( + InternalServerErrorException, + ); + }); + }); + describe('getMyLoans', () => { beforeEach(() => { mockSupabaseFrom.eq.mockImplementation((column: string, value: unknown) => { @@ -636,6 +806,8 @@ describe('LoansService', () => { LoanListStatusFilter.ACTIVE, LoanListStatusFilter.COMPLETED, LoanListStatusFilter.DEFAULTED, + LoanListStatusFilter.UNDER_REVIEW, + LoanListStatusFilter.REJECTED, ]); });