diff --git a/src/modules/loans/dto/loan-stats-response.dto.ts b/src/modules/loans/dto/loan-stats-response.dto.ts new file mode 100644 index 0000000..c48cb5e --- /dev/null +++ b/src/modules/loans/dto/loan-stats-response.dto.ts @@ -0,0 +1,26 @@ +import { ApiProperty } from '@nestjs/swagger'; + +export class LoanStatsResponseDto { + @ApiProperty({ description: 'Total number of loans across all statuses', example: 120 }) + totalLoans: number; + + @ApiProperty({ description: 'Number of currently active loans', example: 45 }) + activeLoans: number; + + @ApiProperty({ description: 'Number of fully repaid loans', example: 70 }) + repaidLoans: number; + + @ApiProperty({ description: 'Number of defaulted loans', example: 5 }) + defaultedLoans: number; + + @ApiProperty({ description: 'Total loan volume disbursed in USD', example: 98500.0 }) + totalVolume: number; + + @ApiProperty({ + description: 'Percentage of loans repaid on time (0-100)', + example: 93.5, + minimum: 0, + maximum: 100, + }) + onTimeRepaymentRate: number; +} diff --git a/src/modules/loans/loans.controller.ts b/src/modules/loans/loans.controller.ts index aa09f0d..f973a12 100644 --- a/src/modules/loans/loans.controller.ts +++ b/src/modules/loans/loans.controller.ts @@ -28,6 +28,7 @@ import { LoanPaymentResponseDto } from './dto/loan-payment-response.dto'; import { AvailableCreditResponseDto } from './dto/available-credit-response.dto'; import { LoanListQueryDto, LoanListStatusFilter } from './dto/loan-list-query.dto'; import { LoanListResponseDto } from './dto/loan-list-response.dto'; +import { LoanStatsResponseDto } from './dto/loan-stats-response.dto'; import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard'; import { CurrentUser } from '../../common/decorators/current-user.decorator'; @@ -61,6 +62,22 @@ export class LoansController { return { success: true, data, message: 'Loan quote calculated successfully' }; } + @Get('stats') + @ApiOperation({ + summary: 'Get protocol-wide loan statistics', + description: + 'Returns aggregated loan counts and volume across all users. No authentication required.', + }) + @ApiResponse({ + status: 200, + description: 'Loan statistics retrieved successfully', + type: LoanStatsResponseDto, + }) + async getStats() { + const data = await this.loansService.getStats(); + return { success: true, data, message: 'Loan statistics retrieved successfully' }; + } + @Get('my-loans') @UseGuards(JwtAuthGuard) @ApiBearerAuth() diff --git a/src/modules/loans/loans.service.ts b/src/modules/loans/loans.service.ts index 852fe61..fbbbfc9 100644 --- a/src/modules/loans/loans.service.ts +++ b/src/modules/loans/loans.service.ts @@ -23,6 +23,7 @@ import { LoanListVendorDto, LoanListResponseDto, } from './dto/loan-list-response.dto'; +import { LoanStatsResponseDto } from './dto/loan-stats-response.dto'; import { ReputationTier } from '../reputation/dto/reputation-response.dto'; const GUARANTEE_PERCENT = 0.2; @@ -326,6 +327,44 @@ export class LoansService { }; } + async getStats(): Promise { + const client = this.supabaseService.getServiceRoleClient(); + const { data, error } = await client + .from('loans') + .select('status, loan_amount, completed_at, next_payment_due'); + + if (error) { + this.logger.error(`Failed to fetch loan stats: ${error.message}`); + throw new InternalServerErrorException({ + code: 'LOAN_STATS_QUERY_FAILED', + message: 'Failed to retrieve loan statistics. Please try again later.', + }); + } + + const loans = data ?? []; + const totalLoans = loans.length; + const activeLoans = loans.filter((l) => l.status === 'active').length; + const repaidLoans = loans.filter((l) => l.status === 'completed').length; + const defaultedLoans = loans.filter((l) => l.status === 'defaulted').length; + const totalVolume = this.roundCurrency( + loans.reduce((sum, l) => sum + Number(l.loan_amount ?? 0), 0), + ); + + const repaidOnTime = loans.filter( + (l) => + l.status === 'completed' && + l.completed_at != null && + l.next_payment_due != null && + new Date(l.completed_at) <= new Date(l.next_payment_due), + ).length; + const onTimeRepaymentRate = + repaidLoans > 0 + ? Math.round((repaidOnTime / repaidLoans) * 10_000) / 100 + : 0; + + return { totalLoans, activeLoans, repaidLoans, defaultedLoans, totalVolume, onTimeRepaymentRate }; + } + private async prepareLoanPreview( wallet: string, dto: LoanQuoteRequestDto,