Skip to content

Commit 70c4000

Browse files
fix: consolidate credit tier resolution into CreditScoringService and match contract parameters (#108)
- Remove duplicate mapScoreToCreditTier() from loans.service.ts - Wire getAvailableCredit through CreditScoringService.resolveTier() - Define CREDIT_TIERS as the single source of truth in credit-scoring.service.ts - Match authoritative values from parameters-contract (gold: 10000/4%, silver: 5000/6%, bronze: 2500/8%, starter: 1000/10%) - Reconcile tier naming: 'poor' -> 'starter' across all DTOs, services, and tests - Add contract-alignment test ensuring CREDIT_TIERS never drifts from parameters-contract - Add boundary score tests for 90, 89, 75, 74, 60, 59 - Update ReputationService to share CREDIT_TIERS constants BREAKING CHANGE: API tier name changes from 'poor' to 'starter' in /reputation and /loans/available-credit responses. Credit limits and interest rates updated to match deployed contract parameters (gold 10000/4%, silver 5000/6%, bronze 2500/8%, starter 1000/10%). StepFi-Web and StepFi-App must update tier name references.
1 parent d027b2a commit 70c4000

10 files changed

Lines changed: 251 additions & 111 deletions

File tree

src/modules/credit-scoring/credit-scoring.service.ts

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,47 @@ export interface AssessParams {
88
creditUtilization: number;
99
}
1010

11+
export interface CreditTier {
12+
minScore: number;
13+
tier: 'gold' | 'silver' | 'bronze' | 'starter';
14+
interestRate: number;
15+
maxCredit: number;
16+
}
17+
18+
/**
19+
* Authoritative credit tier definitions matching the parameters-contract
20+
* deployed on Stellar testnet. The contracts enforce these exact values —
21+
* the API must match, not the reverse.
22+
*
23+
* Source: context/architecture-context.md → StepFi-Contracts → Reputation → Credit Tiers
24+
*/
25+
export const CREDIT_TIERS: readonly CreditTier[] = [
26+
{ minScore: 90, tier: 'gold', interestRate: 4, maxCredit: 10_000 },
27+
{ minScore: 75, tier: 'silver', interestRate: 6, maxCredit: 5_000 },
28+
{ minScore: 60, tier: 'bronze', interestRate: 8, maxCredit: 2_500 },
29+
{ minScore: 0, tier: 'starter', interestRate: 10, maxCredit: 1_000 },
30+
] as const;
31+
1132
@Injectable()
1233
export class CreditScoringService {
34+
/**
35+
* Resolves a reputation score to its canonical credit tier, interest rate,
36+
* and credit limit. This is the single source of truth for tier resolution
37+
* across the entire API.
38+
*/
39+
resolveTier(score: number): { tier: CreditTier['tier']; interestRate: number; maxCredit: number } {
40+
const normalizedScore = Math.max(0, Math.min(100, score));
41+
42+
for (const tier of CREDIT_TIERS) {
43+
if (normalizedScore >= tier.minScore) {
44+
return { tier: tier.tier, interestRate: tier.interestRate, maxCredit: tier.maxCredit };
45+
}
46+
}
47+
48+
// Should never reach here, but return starter as the lowest tier
49+
return { tier: 'starter', interestRate: 10, maxCredit: 1_000 };
50+
}
51+
1352
assess(params: AssessParams): CreditAssessmentResultDto {
1453
const { amount, reputationScore, maxCredit, creditUtilization } = params;
1554
const reasons: string[] = [];

src/modules/loans/dto/available-credit-response.dto.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ export class AvailableCreditResponseDto {
1616
@ApiProperty({
1717
description: 'Reputation tier derived from the on-chain score',
1818
example: 'silver',
19-
enum: ['gold', 'silver', 'bronze', 'poor'],
19+
enum: ['gold', 'silver', 'bronze', 'starter'],
2020
})
2121
reputationTier: ReputationTier;
2222

src/modules/loans/loans.service.ts

Lines changed: 1 addition & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -340,7 +340,7 @@ export class LoansService {
340340
});
341341
}
342342

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

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

671671
return this.creditScoringService.assess(params);
672672
}
673-
674-
private mapScoreToCreditTier(score: number): {
675-
tier: ReputationTier;
676-
maxCredit: number;
677-
} {
678-
if (score >= 90) {
679-
return { tier: 'gold', maxCredit: 5000 };
680-
}
681-
682-
if (score >= 75) {
683-
return { tier: 'silver', maxCredit: 3000 };
684-
}
685-
686-
if (score >= 60) {
687-
return { tier: 'bronze', maxCredit: 1500 };
688-
}
689-
690-
return { tier: 'poor', maxCredit: 500 };
691-
}
692673
}

src/modules/reputation/dto/reputation-response.dto.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { ApiProperty } from '@nestjs/swagger';
22

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

66
/**
77
* DTO for the reputation score response.
@@ -24,9 +24,9 @@ export class ReputationResponseDto {
2424
score: number;
2525

2626
@ApiProperty({
27-
description: 'Reputation tier derived from score (gold, silver, bronze, poor)',
27+
description: 'Reputation tier derived from score (gold, silver, bronze, starter)',
2828
example: 'silver',
29-
enum: ['gold', 'silver', 'bronze', 'poor'],
29+
enum: ['gold', 'silver', 'bronze', 'starter'],
3030
})
3131
tier: ReputationTier;
3232

src/modules/reputation/reputation.service.ts

Lines changed: 13 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,12 @@ import { Cache } from 'cache-manager';
44
import { ConfigService } from '@nestjs/config';
55
import { SupabaseService } from '../../database/supabase.client';
66
import { ReputationContractClient } from '../../stellar/contracts/clients/reputation.client';
7+
import { CREDIT_TIERS } from '../credit-scoring/credit-scoring.service';
78

89
export interface Reputation {
910
wallet: string;
1011
score: number;
11-
tier: 'gold' | 'silver' | 'bronze' | 'poor';
12+
tier: 'gold' | 'silver' | 'bronze' | 'starter';
1213
interestRate: number;
1314
maxCredit: number;
1415
lastUpdated: string;
@@ -94,29 +95,17 @@ export class ReputationService implements OnModuleInit {
9495
}
9596

9697
private mapToReputation(wallet: string, score: number, lastUpdated: string): Reputation {
97-
let tier: 'gold' | 'silver' | 'bronze' | 'poor';
98-
let interestRate: number;
99-
let maxCredit: number;
100-
101-
if (score >= 90) {
102-
tier = 'gold';
103-
interestRate = 5;
104-
maxCredit = 5000;
105-
} else if (score >= 75) {
106-
tier = 'silver';
107-
interestRate = 8;
108-
maxCredit = 3000;
109-
} else if (score >= 60) {
110-
tier = 'bronze';
111-
interestRate = 9;
112-
maxCredit = 1500;
113-
} else {
114-
tier = 'poor';
115-
interestRate = 12;
116-
maxCredit = 500;
117-
}
118-
119-
return { wallet, score, tier, interestRate, maxCredit, lastUpdated };
98+
const normalizedScore = Math.max(0, Math.min(100, score));
99+
const matchedTier = CREDIT_TIERS.find((t) => normalizedScore >= t.minScore) ?? CREDIT_TIERS[CREDIT_TIERS.length - 1];
100+
101+
return {
102+
wallet,
103+
score,
104+
tier: matchedTier.tier,
105+
interestRate: matchedTier.interestRate,
106+
maxCredit: matchedTier.maxCredit,
107+
lastUpdated,
108+
};
120109
}
121110

122111
async invalidateReputation(wallet: string): Promise<void> {

test/e2e/modules/loans/loans.e2e-spec.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -227,7 +227,7 @@ describe('LoansController (e2e)', () => {
227227
jest.spyOn(app.get(ReputationService), 'getReputationScore').mockResolvedValue({
228228
wallet: validWallet,
229229
score: 40,
230-
tier: 'poor',
230+
tier: 'starter',
231231
interestRate: 12,
232232
maxCredit: 500,
233233
lastUpdated: '2026-03-23T00:00:00.000Z',

test/e2e/modules/reputation/reputation.e2e-spec.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -92,7 +92,7 @@ describe('ReputationController (e2e)', () => {
9292
const body = JSON.parse(res.payload);
9393
expect(body.success).toBe(true);
9494
expect(body.data.score).toBe(50);
95-
expect(body.data.tier).toBe('poor');
95+
expect(body.data.tier).toBe('starter');
9696
}, 10000);
9797

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

0 commit comments

Comments
 (0)