Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 39 additions & 0 deletions src/modules/credit-scoring/credit-scoring.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,47 @@ export interface AssessParams {
creditUtilization: number;
}

export interface CreditTier {
minScore: number;
tier: 'gold' | 'silver' | 'bronze' | 'starter';
interestRate: number;
maxCredit: number;
}

/**
* Authoritative credit tier definitions matching the parameters-contract
* deployed on Stellar testnet. The contracts enforce these exact values —
* the API must match, not the reverse.
*
* Source: context/architecture-context.md → StepFi-Contracts → Reputation → Credit Tiers
*/
export const CREDIT_TIERS: readonly CreditTier[] = [
{ minScore: 90, tier: 'gold', interestRate: 4, maxCredit: 10_000 },
{ minScore: 75, tier: 'silver', interestRate: 6, maxCredit: 5_000 },
{ minScore: 60, tier: 'bronze', interestRate: 8, maxCredit: 2_500 },
{ minScore: 0, tier: 'starter', interestRate: 10, maxCredit: 1_000 },
] as const;

@Injectable()
export class CreditScoringService {
/**
* Resolves a reputation score to its canonical credit tier, interest rate,
* and credit limit. This is the single source of truth for tier resolution
* across the entire API.
*/
resolveTier(score: number): { tier: CreditTier['tier']; interestRate: number; maxCredit: number } {
const normalizedScore = Math.max(0, Math.min(100, score));

for (const tier of CREDIT_TIERS) {
if (normalizedScore >= tier.minScore) {
return { tier: tier.tier, interestRate: tier.interestRate, maxCredit: tier.maxCredit };
}
}

// Should never reach here, but return starter as the lowest tier
return { tier: 'starter', interestRate: 10, maxCredit: 1_000 };
}

assess(params: AssessParams): CreditAssessmentResultDto {
const { amount, reputationScore, maxCredit, creditUtilization } = params;
const reasons: string[] = [];
Expand Down
2 changes: 1 addition & 1 deletion src/modules/loans/dto/available-credit-response.dto.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ export class AvailableCreditResponseDto {
@ApiProperty({
description: 'Reputation tier derived from the on-chain score',
example: 'silver',
enum: ['gold', 'silver', 'bronze', 'poor'],
enum: ['gold', 'silver', 'bronze', 'starter'],
})
reputationTier: ReputationTier;

Expand Down
21 changes: 1 addition & 20 deletions src/modules/loans/loans.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -340,7 +340,7 @@ export class LoansService {
});
}

const { maxCredit, tier } = this.mapScoreToCreditTier(reputationScore);
const { tier, maxCredit } = this.creditScoringService.resolveTier(reputationScore);

const client = this.supabaseService.getServiceRoleClient();
const { data: activeLoans, error } = await client
Expand Down Expand Up @@ -670,23 +670,4 @@ export class LoansService {

return this.creditScoringService.assess(params);
}

private mapScoreToCreditTier(score: number): {
tier: ReputationTier;
maxCredit: number;
} {
if (score >= 90) {
return { tier: 'gold', maxCredit: 5000 };
}

if (score >= 75) {
return { tier: 'silver', maxCredit: 3000 };
}

if (score >= 60) {
return { tier: 'bronze', maxCredit: 1500 };
}

return { tier: 'poor', maxCredit: 500 };
}
}
6 changes: 3 additions & 3 deletions src/modules/reputation/dto/reputation-response.dto.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { ApiProperty } from '@nestjs/swagger';

/** Reputation tier based on on-chain score */
export type ReputationTier = 'gold' | 'silver' | 'bronze' | 'poor';
export type ReputationTier = 'gold' | 'silver' | 'bronze' | 'starter';

/**
* DTO for the reputation score response.
Expand All @@ -24,9 +24,9 @@ export class ReputationResponseDto {
score: number;

@ApiProperty({
description: 'Reputation tier derived from score (gold, silver, bronze, poor)',
description: 'Reputation tier derived from score (gold, silver, bronze, starter)',
example: 'silver',
enum: ['gold', 'silver', 'bronze', 'poor'],
enum: ['gold', 'silver', 'bronze', 'starter'],
})
tier: ReputationTier;

Expand Down
37 changes: 13 additions & 24 deletions src/modules/reputation/reputation.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,12 @@ import { Cache } from 'cache-manager';
import { ConfigService } from '@nestjs/config';
import { SupabaseService } from '../../database/supabase.client';
import { ReputationContractClient } from '../../stellar/contracts/clients/reputation.client';
import { CREDIT_TIERS } from '../credit-scoring/credit-scoring.service';

export interface Reputation {
wallet: string;
score: number;
tier: 'gold' | 'silver' | 'bronze' | 'poor';
tier: 'gold' | 'silver' | 'bronze' | 'starter';
interestRate: number;
maxCredit: number;
lastUpdated: string;
Expand Down Expand Up @@ -94,29 +95,17 @@ export class ReputationService implements OnModuleInit {
}

private mapToReputation(wallet: string, score: number, lastUpdated: string): Reputation {
let tier: 'gold' | 'silver' | 'bronze' | 'poor';
let interestRate: number;
let maxCredit: number;

if (score >= 90) {
tier = 'gold';
interestRate = 5;
maxCredit = 5000;
} else if (score >= 75) {
tier = 'silver';
interestRate = 8;
maxCredit = 3000;
} else if (score >= 60) {
tier = 'bronze';
interestRate = 9;
maxCredit = 1500;
} else {
tier = 'poor';
interestRate = 12;
maxCredit = 500;
}

return { wallet, score, tier, interestRate, maxCredit, lastUpdated };
const normalizedScore = Math.max(0, Math.min(100, score));
const matchedTier = CREDIT_TIERS.find((t) => normalizedScore >= t.minScore) ?? CREDIT_TIERS[CREDIT_TIERS.length - 1];

return {
wallet,
score,
tier: matchedTier.tier,
interestRate: matchedTier.interestRate,
maxCredit: matchedTier.maxCredit,
lastUpdated,
};
}

async invalidateReputation(wallet: string): Promise<void> {
Expand Down
2 changes: 1 addition & 1 deletion test/e2e/modules/loans/loans.e2e-spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -227,7 +227,7 @@ describe('LoansController (e2e)', () => {
jest.spyOn(app.get(ReputationService), 'getReputationScore').mockResolvedValue({
wallet: validWallet,
score: 40,
tier: 'poor',
tier: 'starter',
interestRate: 12,
maxCredit: 500,
lastUpdated: '2026-03-23T00:00:00.000Z',
Expand Down
2 changes: 1 addition & 1 deletion test/e2e/modules/reputation/reputation.e2e-spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,7 @@ describe('ReputationController (e2e)', () => {
const body = JSON.parse(res.payload);
expect(body.success).toBe(true);
expect(body.data.score).toBe(50);
expect(body.data.tier).toBe('poor');
expect(body.data.tier).toBe('starter');
}, 10000);

it('should return 400 for an invalid wallet address', async () => {
Expand Down
Loading
Loading