Skip to content

Commit 23f3038

Browse files
Merge pull request #64 from Junirezz/fix/19-build-tenant-credit-score-aggregator
[#19] Build Tenant_Credit_Score_Aggregator
2 parents 2e42e20 + ba5fc47 commit 23f3038

File tree

3 files changed

+296
-54
lines changed

3 files changed

+296
-54
lines changed

index.js

Lines changed: 29 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ const fs = require('fs');
4949
const sharp = require('sharp');
5050
const app = express();
5151
const port = 3000;
52+
const creditScoreAggregator = new TenantCreditScoreAggregator();
5253
const listings = [];
5354
const HORIZON_URL = process.env.HORIZON_URL || 'https://horizon.stellar.org';
5455

@@ -376,6 +377,7 @@ app.get('/', (req, res) => {
376377
}
377378
});
378379

380+
app.post('/tenant-credit-score', (req, res) => {
379381
return app;
380382
}
381383

@@ -415,33 +417,23 @@ app.post('/listings', async (req, res) => {
415417
// Availability endpoints
416418
app.get('/api/asset/:id/availability', async (req, res) => {
417419
try {
418-
const { id } = req.params;
419-
420-
if (!id || isNaN(id)) {
421-
return res.status(400).json({
422-
error: 'Invalid asset ID. Must be a number.',
423-
code: 'INVALID_ASSET_ID'
424-
});
425-
}
426-
427-
const availability = await availabilityService.getAssetAvailability(id);
428-
429-
res.json({
430-
success: true,
431-
data: availability
432-
});
433-
420+
const { tenantId, metrics = {}, cacheTtlSeconds } = req.body || {};
421+
const result = creditScoreAggregator.getOrCompute(tenantId, metrics, cacheTtlSeconds);
422+
res.status(200).json(result);
434423
} catch (error) {
435-
console.error(`Error fetching availability for asset ${req.params.id}:`, error);
424+
res.status(400).json({ error: error.message });
425+
}
426+
});
436427

437-
res.status(500).json({
438-
error: 'Failed to fetch asset availability',
439-
code: 'FETCH_ERROR',
440-
details: process.env.NODE_ENV === 'development' ? error.message : undefined
441-
});
428+
app.get('/tenant-credit-score/:tenantId', (req, res) => {
429+
const cached = creditScoreAggregator.getCached(req.params.tenantId);
430+
if (!cached) {
431+
return res.status(404).json({ error: 'No cached score found for tenant' });
442432
}
433+
return res.status(200).json(cached);
443434
});
444435

436+
app.post('/tenant-credit-score/share-token', (req, res) => {
445437
// Error handling
446438
app.use((err, req, res, next) => {
447439
console.error('[App] Unhandled Error:', err);
@@ -450,41 +442,24 @@ app.use((err, req, res, next) => {
450442

451443
app.get('/api/assets/availability', async (req, res) => {
452444
try {
453-
const { ids } = req.query;
454-
455-
if (ids) {
456-
const assetIds = ids.split(',').map(id => id.trim()).filter(id => id && !isNaN(id));
457-
458-
if (assetIds.length === 0) {
459-
return res.status(400).json({
460-
error: 'No valid asset IDs provided',
461-
code: 'INVALID_ASSET_IDS'
462-
});
463-
}
464-
465-
const availability = await availabilityService.getMultipleAssetAvailability(assetIds);
466-
467-
res.json({
468-
success: true,
469-
data: availability
470-
});
471-
} else {
472-
const availability = await availabilityService.getAllAssetsAvailability();
445+
const { tenantId, tokenTtlSeconds } = req.body || {};
446+
const result = creditScoreAggregator.generateShareToken(tenantId, tokenTtlSeconds);
447+
res.status(200).json(result);
448+
} catch (error) {
449+
res.status(400).json({ error: error.message });
450+
}
451+
});
473452

474-
res.json({
475-
success: true,
476-
data: availability
477-
});
453+
app.post('/tenant-credit-score/verify-token', (req, res) => {
454+
try {
455+
const { token } = req.body || {};
456+
if (!token) {
457+
throw new Error('token is required');
478458
}
479-
459+
const payload = creditScoreAggregator.verifyShareToken(token);
460+
res.status(200).json({ valid: true, payload });
480461
} catch (error) {
481-
console.error('Error fetching assets availability:', error);
482-
483-
res.status(500).json({
484-
error: 'Failed to fetch assets availability',
485-
code: 'FETCH_ERROR',
486-
details: process.env.NODE_ENV === 'development' ? error.message : undefined
487-
});
462+
res.status(400).json({ valid: false, error: error.message });
488463
}
489464
});
490465

tenantCreditScoreAggregator.js

Lines changed: 209 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,209 @@
1+
const crypto = require('crypto');
2+
3+
const WEIGHTS = {
4+
onTimePayments: 0.5,
5+
leaseCompletion: 0.3,
6+
successfulDepositReturns: 0.2
7+
};
8+
9+
const SCORE_RANGE = {
10+
min: 300,
11+
max: 850
12+
};
13+
14+
const DEFAULT_CACHE_TTL_SECONDS = 60 * 60;
15+
const DEFAULT_TOKEN_TTL_SECONDS = 60 * 30;
16+
17+
function safeRatio(numerator, denominator) {
18+
if (!Number.isFinite(numerator) || !Number.isFinite(denominator) || denominator <= 0) {
19+
return 0;
20+
}
21+
return Math.max(0, Math.min(1, numerator / denominator));
22+
}
23+
24+
function getBreakdown(metrics) {
25+
const onTimePaymentsRatio = safeRatio(metrics.onTimePayments, metrics.totalPayments);
26+
const leaseCompletionRatio = safeRatio(metrics.completedLeases, metrics.totalLeases);
27+
const successfulDepositReturnsRatio = safeRatio(
28+
metrics.successfulDepositReturns,
29+
metrics.totalDepositReturns
30+
);
31+
32+
return {
33+
onTimePayments: Math.round(onTimePaymentsRatio * 100),
34+
leaseCompletion: Math.round(leaseCompletionRatio * 100),
35+
successfulDepositReturns: Math.round(successfulDepositReturnsRatio * 100)
36+
};
37+
}
38+
39+
function calculateScore(metrics) {
40+
const breakdown = getBreakdown(metrics);
41+
const weightedPercent =
42+
breakdown.onTimePayments * WEIGHTS.onTimePayments +
43+
breakdown.leaseCompletion * WEIGHTS.leaseCompletion +
44+
breakdown.successfulDepositReturns * WEIGHTS.successfulDepositReturns;
45+
46+
const spread = SCORE_RANGE.max - SCORE_RANGE.min;
47+
const score = Math.round(SCORE_RANGE.min + (weightedPercent / 100) * spread);
48+
49+
return {
50+
score,
51+
breakdown
52+
};
53+
}
54+
55+
function parseDurationSeconds(value, fallback) {
56+
if (!Number.isFinite(value) || value <= 0) {
57+
return fallback;
58+
}
59+
return Math.floor(value);
60+
}
61+
62+
function toBase64Url(value) {
63+
return Buffer.from(value)
64+
.toString('base64')
65+
.replace(/=/g, '')
66+
.replace(/\+/g, '-')
67+
.replace(/\//g, '_');
68+
}
69+
70+
function fromBase64Url(value) {
71+
const normalized = value.replace(/-/g, '+').replace(/_/g, '/');
72+
const padded = normalized + '==='.slice((normalized.length + 3) % 4);
73+
return Buffer.from(padded, 'base64').toString('utf8');
74+
}
75+
76+
function createSignedToken(payload, secret) {
77+
const header = { alg: 'HS256', typ: 'JWT' };
78+
const encodedHeader = toBase64Url(JSON.stringify(header));
79+
const encodedPayload = toBase64Url(JSON.stringify(payload));
80+
const content = `${encodedHeader}.${encodedPayload}`;
81+
const signature = crypto
82+
.createHmac('sha256', secret)
83+
.update(content)
84+
.digest('base64')
85+
.replace(/=/g, '')
86+
.replace(/\+/g, '-')
87+
.replace(/\//g, '_');
88+
return `${content}.${signature}`;
89+
}
90+
91+
function verifySignedToken(token, secret) {
92+
const parts = String(token || '').split('.');
93+
if (parts.length !== 3) {
94+
throw new Error('Invalid token format');
95+
}
96+
97+
const [encodedHeader, encodedPayload, signature] = parts;
98+
const content = `${encodedHeader}.${encodedPayload}`;
99+
const expectedSignature = crypto
100+
.createHmac('sha256', secret)
101+
.update(content)
102+
.digest('base64')
103+
.replace(/=/g, '')
104+
.replace(/\+/g, '-')
105+
.replace(/\//g, '_');
106+
107+
const expected = Buffer.from(expectedSignature);
108+
const received = Buffer.from(signature);
109+
if (expected.length !== received.length || !crypto.timingSafeEqual(expected, received)) {
110+
throw new Error('Invalid token signature');
111+
}
112+
113+
return JSON.parse(fromBase64Url(encodedPayload));
114+
}
115+
116+
class TenantCreditScoreAggregator {
117+
constructor(options = {}) {
118+
this.cacheTtlSeconds = parseDurationSeconds(
119+
options.cacheTtlSeconds,
120+
DEFAULT_CACHE_TTL_SECONDS
121+
);
122+
this.tokenTtlSeconds = parseDurationSeconds(
123+
options.tokenTtlSeconds,
124+
DEFAULT_TOKEN_TTL_SECONDS
125+
);
126+
this.signingSecret = options.signingSecret || process.env.SHARE_TOKEN_SECRET || 'leaseflow-dev-secret';
127+
this.cache = new Map();
128+
}
129+
130+
getCached(tenantId) {
131+
const key = String(tenantId || '');
132+
const item = this.cache.get(key);
133+
if (!item) return null;
134+
135+
if (Date.now() >= item.expiresAtMs) {
136+
this.cache.delete(key);
137+
return null;
138+
}
139+
140+
return {
141+
tenantId: key,
142+
score: item.score,
143+
breakdown: item.breakdown,
144+
expiresAt: new Date(item.expiresAtMs).toISOString()
145+
};
146+
}
147+
148+
computeAndCache(tenantId, metrics, ttlSeconds) {
149+
const key = String(tenantId || '').trim();
150+
if (!key) {
151+
throw new Error('tenantId is required');
152+
}
153+
154+
const { score, breakdown } = calculateScore(metrics);
155+
const ttl = parseDurationSeconds(ttlSeconds, this.cacheTtlSeconds);
156+
const expiresAtMs = Date.now() + ttl * 1000;
157+
this.cache.set(key, { score, breakdown, expiresAtMs });
158+
159+
return {
160+
tenantId: key,
161+
score,
162+
breakdown,
163+
expiresAt: new Date(expiresAtMs).toISOString()
164+
};
165+
}
166+
167+
getOrCompute(tenantId, metrics, ttlSeconds) {
168+
const cached = this.getCached(tenantId);
169+
if (cached) {
170+
return { ...cached, cached: true };
171+
}
172+
const computed = this.computeAndCache(tenantId, metrics, ttlSeconds);
173+
return { ...computed, cached: false };
174+
}
175+
176+
generateShareToken(tenantId, tokenTtlSeconds) {
177+
const cached = this.getCached(tenantId);
178+
if (!cached) {
179+
throw new Error('No cached score found for tenant');
180+
}
181+
182+
const nowSeconds = Math.floor(Date.now() / 1000);
183+
const ttl = parseDurationSeconds(tokenTtlSeconds, this.tokenTtlSeconds);
184+
const payload = {
185+
tenantId: cached.tenantId,
186+
score: cached.score,
187+
breakdown: cached.breakdown,
188+
iat: nowSeconds,
189+
exp: nowSeconds + ttl
190+
};
191+
192+
const token = createSignedToken(payload, this.signingSecret);
193+
return { token, payload };
194+
}
195+
196+
verifyShareToken(token) {
197+
const payload = verifySignedToken(token, this.signingSecret);
198+
const nowSeconds = Math.floor(Date.now() / 1000);
199+
if (!Number.isFinite(payload.exp) || payload.exp < nowSeconds) {
200+
throw new Error('Token expired');
201+
}
202+
return payload;
203+
}
204+
}
205+
206+
module.exports = {
207+
TenantCreditScoreAggregator,
208+
calculateScore
209+
};

tests/index.test.js

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -545,4 +545,62 @@ describe('LeaseFlow Backend API', () => {
545545
expect(response.status).toBe(401);
546546
expect(response.body.error).toBe('Authentication required');
547547
});
548+
549+
it('should compute and cache tenant credit score', async () => {
550+
const payload = {
551+
tenantId: 'tenant-123',
552+
metrics: {
553+
onTimePayments: 9,
554+
totalPayments: 10,
555+
completedLeases: 3,
556+
totalLeases: 4,
557+
successfulDepositReturns: 2,
558+
totalDepositReturns: 2
559+
}
560+
};
561+
562+
const first = await request(app).post('/tenant-credit-score').send(payload);
563+
expect(first.status).toBe(200);
564+
expect(first.body.tenantId).toBe('tenant-123');
565+
expect(first.body.cached).toBe(false);
566+
expect(first.body.score).toBe(781);
567+
expect(first.body.breakdown).toEqual({
568+
onTimePayments: 90,
569+
leaseCompletion: 75,
570+
successfulDepositReturns: 100
571+
});
572+
expect(typeof first.body.expiresAt).toBe('string');
573+
574+
const second = await request(app).post('/tenant-credit-score').send(payload);
575+
expect(second.status).toBe(200);
576+
expect(second.body.cached).toBe(true);
577+
expect(second.body.score).toBe(781);
578+
});
579+
580+
it('should return cached score for tenant id', async () => {
581+
const response = await request(app).get('/tenant-credit-score/tenant-123');
582+
expect(response.status).toBe(200);
583+
expect(response.body.tenantId).toBe('tenant-123');
584+
expect(response.body.score).toBe(781);
585+
});
586+
587+
it('should generate and verify a signed share token', async () => {
588+
const tokenResponse = await request(app)
589+
.post('/tenant-credit-score/share-token')
590+
.send({ tenantId: 'tenant-123' });
591+
592+
expect(tokenResponse.status).toBe(200);
593+
expect(typeof tokenResponse.body.token).toBe('string');
594+
expect(tokenResponse.body.payload.tenantId).toBe('tenant-123');
595+
expect(tokenResponse.body.payload.score).toBe(781);
596+
597+
const verifyResponse = await request(app)
598+
.post('/tenant-credit-score/verify-token')
599+
.send({ token: tokenResponse.body.token });
600+
601+
expect(verifyResponse.status).toBe(200);
602+
expect(verifyResponse.body.valid).toBe(true);
603+
expect(verifyResponse.body.payload.tenantId).toBe('tenant-123');
604+
expect(verifyResponse.body.payload.score).toBe(781);
605+
});
548606
});

0 commit comments

Comments
 (0)