Skip to content
Merged
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
32 changes: 32 additions & 0 deletions backend/src/modules/savings/dto/recommendation-response.dto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { ApiProperty } from '@nestjs/swagger';

export class ProductRecommendationDto {
@ApiProperty({ description: 'Product ID' })
productId: string;

@ApiProperty({ example: 'High-Yield Locked Savings' })
productName: string;

@ApiProperty({
example: 0.92,
description: 'Match score from 0 to 1',
})
matchScore: number;

@ApiProperty({
example: 'Matches your 12-month emergency fund goal',
description: 'Human-readable reason for the recommendation',
})
reason: string;

@ApiProperty({
example: 1250.0,
description: 'Projected earnings based on user behavior',
})
projectedEarnings: number;
}

export class RecommendationResponseDto {
@ApiProperty({ type: [ProductRecommendationDto] })
recommendations: ProductRecommendationDto[];
}
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ describe('SavingsController (Enhanced)', () => {
{ provide: getRepositoryToken(Transaction), useValue: {} },
{ provide: BlockchainSavingsService, useValue: {} },
{ provide: PredictiveEvaluatorService, useValue: {} },
{ provide: RecommendationService, useValue: { getRecommendations: jest.fn() } },
{ provide: ConfigService, useValue: { get: jest.fn() } },
{ provide: CACHE_MANAGER, useValue: { del: jest.fn() } },
{ provide: 'THROTTLER:MODULE_OPTIONS', useValue: {} },
Expand Down
7 changes: 6 additions & 1 deletion backend/src/modules/savings/savings.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,9 +34,11 @@ import { CreateGoalDto } from './dto/create-goal.dto';
import { UpdateGoalDto } from './dto/update-goal.dto';
import { SavingsProductDto } from './dto/savings-product.dto';
import { ProductDetailsDto } from './dto/product-details.dto';
import { RecommendationResponseDto } from './dto/recommendation-response.dto';
import { JwtAuthGuard } from '../../auth/guards/jwt-auth.guard';
import { CurrentUser } from '../../common/decorators/current-user.decorator';
import { RpcThrottleGuard } from '../../common/guards/rpc-throttle.guard';
import { RecommendationService } from './services/recommendation.service';
import {
SavingsGoalProgress,
UserSubscriptionWithLiveBalance,
Expand All @@ -45,7 +47,10 @@ import {
@ApiTags('savings')
@Controller('savings')
export class SavingsController {
constructor(private readonly savingsService: SavingsService) {}
constructor(
private readonly savingsService: SavingsService,
private readonly recommendationService: RecommendationService,
) {}

@Get('products')
@UseInterceptors(CacheInterceptor)
Expand Down
1 change: 1 addition & 0 deletions backend/src/modules/savings/savings.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { TypeOrmModule } from '@nestjs/typeorm';
import { SavingsController } from './savings.controller';
import { SavingsService } from './savings.service';
import { PredictiveEvaluatorService } from './services/predictive-evaluator.service';
import { RecommendationService } from './services/recommendation.service';
import { SavingsProduct } from './entities/savings-product.entity';
import { UserSubscription } from './entities/user-subscription.entity';
import { SavingsGoal } from './entities/savings-goal.entity';
Expand Down
273 changes: 273 additions & 0 deletions backend/src/modules/savings/services/recommendation.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,273 @@
import { Injectable, Logger } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import {
SavingsProduct,
SavingsProductType,
} from '../entities/savings-product.entity';
import {
UserSubscription,
SubscriptionStatus,
} from '../entities/user-subscription.entity';
import { SavingsGoal, SavingsGoalStatus } from '../entities/savings-goal.entity';
import { Transaction, TxType } from '../../transactions/entities/transaction.entity';
import { ProductRecommendationDto } from '../dto/recommendation-response.dto';

interface UserProfile {
avgTransactionAmount: number;
transactionCount: number;
depositFrequency: number;
riskTolerance: 'low' | 'medium' | 'high';
activeSubscriptionTypes: SavingsProductType[];
totalInvested: number;
hasGoals: boolean;
longestGoalMonths: number;
}

@Injectable()
export class RecommendationService {
private readonly logger = new Logger(RecommendationService.name);

constructor(
@InjectRepository(SavingsProduct)
private readonly productRepository: Repository<SavingsProduct>,
@InjectRepository(UserSubscription)
private readonly subscriptionRepository: Repository<UserSubscription>,
@InjectRepository(SavingsGoal)
private readonly goalRepository: Repository<SavingsGoal>,
@InjectRepository(Transaction)
private readonly transactionRepository: Repository<Transaction>,
) {}

async getRecommendations(
userId: string,
): Promise<ProductRecommendationDto[]> {
const [profile, products] = await Promise.all([
this.buildUserProfile(userId),
this.productRepository.find({ where: { isActive: true } }),
]);

if (!products.length) {
return [];
}

const scored = products.map((product) => ({
product,
score: this.scoreProduct(product, profile),
reason: this.generateReason(product, profile),
projectedEarnings: this.projectEarnings(product, profile),
}));

// Sort by score descending, return top 5
scored.sort((a, b) => b.score - a.score);

return scored.slice(0, 5).map((s) => ({
productId: s.product.id,
productName: s.product.name,
matchScore: Number(s.score.toFixed(2)),
reason: s.reason,
projectedEarnings: Number(s.projectedEarnings.toFixed(2)),
}));
}

private async buildUserProfile(userId: string): Promise<UserProfile> {
const [subscriptions, goals, transactions] = await Promise.all([
this.subscriptionRepository.find({
where: { userId },
relations: ['product'],
}),
this.goalRepository.find({ where: { userId } }),
this.transactionRepository.find({
where: { userId },
order: { createdAt: 'DESC' },
take: 100,
}),
]);

const deposits = transactions.filter((t) => t.type === TxType.DEPOSIT);
const avgTransactionAmount =
deposits.length > 0
? deposits.reduce((sum, t) => sum + Number(t.amount), 0) /
deposits.length
: 0;

// Estimate deposit frequency: deposits per month over last 90 days
const ninetyDaysAgo = new Date();
ninetyDaysAgo.setDate(ninetyDaysAgo.getDate() - 90);
const recentDeposits = deposits.filter(
(t) => new Date(t.createdAt) >= ninetyDaysAgo,
);
const depositFrequency = recentDeposits.length / 3; // per month

const activeSubscriptions = subscriptions.filter(
(s) => s.status === SubscriptionStatus.ACTIVE,
);
const totalInvested = activeSubscriptions.reduce(
(sum, s) => sum + Number(s.amount),
0,
);
const activeSubscriptionTypes = [
...new Set(activeSubscriptions.map((s) => s.product?.type).filter(Boolean)),
] as SavingsProductType[];

// Risk tolerance: based on product mix and withdrawal history
const withdrawals = transactions.filter(
(t) => t.type === TxType.WITHDRAW,
);
const hasLockedProducts = activeSubscriptionTypes.includes(
SavingsProductType.FIXED,
);
const withdrawalRatio =
transactions.length > 0 ? withdrawals.length / transactions.length : 0;

let riskTolerance: 'low' | 'medium' | 'high' = 'medium';
if (hasLockedProducts && withdrawalRatio < 0.1) {
riskTolerance = 'high';
} else if (withdrawalRatio > 0.3 || !hasLockedProducts) {
riskTolerance = 'low';
}

// Longest goal horizon in months
const activeGoals = goals.filter(
(g) => g.status === SavingsGoalStatus.IN_PROGRESS,
);
let longestGoalMonths = 0;
for (const goal of activeGoals) {
const months =
(new Date(goal.targetDate).getTime() - Date.now()) /
(1000 * 60 * 60 * 24 * 30);
if (months > longestGoalMonths) {
longestGoalMonths = months;
}
}

return {
avgTransactionAmount,
transactionCount: transactions.length,
depositFrequency,
riskTolerance,
activeSubscriptionTypes,
totalInvested,
hasGoals: activeGoals.length > 0,
longestGoalMonths: Math.max(0, Math.round(longestGoalMonths)),
};
}

private scoreProduct(
product: SavingsProduct,
profile: UserProfile,
): number {
let score = 0.5; // base score

// Risk alignment (+0.2)
if (
profile.riskTolerance === 'high' &&
product.type === SavingsProductType.FIXED
) {
score += 0.2;
} else if (
profile.riskTolerance === 'low' &&
product.type === SavingsProductType.FLEXIBLE
) {
score += 0.2;
} else if (profile.riskTolerance === 'medium') {
score += 0.1;
}

// Goal alignment (+0.15)
if (profile.hasGoals) {
if (
profile.longestGoalMonths >= 6 &&
product.type === SavingsProductType.FIXED &&
product.tenureMonths &&
product.tenureMonths <= profile.longestGoalMonths
) {
score += 0.15;
} else if (
profile.longestGoalMonths < 6 &&
product.type === SavingsProductType.FLEXIBLE
) {
score += 0.15;
}
}

// APY boost — higher rate products get a bump (+0.1)
const interestRate = Number(product.interestRate);
if (interestRate >= 5) {
score += 0.1;
} else if (interestRate >= 2) {
score += 0.05;
}

// Diversification: boost products the user doesn't already hold (+0.1)
if (!profile.activeSubscriptionTypes.includes(product.type)) {
score += 0.1;
}

// Amount fit: product min/max fits user's average transaction (+0.05)
if (
profile.avgTransactionAmount >= Number(product.minAmount) &&
profile.avgTransactionAmount <= Number(product.maxAmount)
) {
score += 0.05;
}

return Math.min(1, score);
}

private generateReason(
product: SavingsProduct,
profile: UserProfile,
): string {
if (
profile.hasGoals &&
profile.longestGoalMonths >= 6 &&
product.type === SavingsProductType.FIXED
) {
return `Matches your long-term savings goal with a ${product.tenureMonths}-month lock period`;
}

if (
!profile.activeSubscriptionTypes.includes(product.type) &&
product.type === SavingsProductType.FIXED
) {
return 'Diversify your portfolio with a locked savings product for higher returns';
}

if (
!profile.activeSubscriptionTypes.includes(product.type) &&
product.type === SavingsProductType.FLEXIBLE
) {
return 'Add flexibility to your portfolio with instant access to your funds';
}

if (Number(product.interestRate) >= 5) {
return `High yield opportunity at ${product.interestRate}% APY`;
}

if (profile.riskTolerance === 'low') {
return 'Low-risk option suited to your conservative savings pattern';
}

return `Recommended based on your savings profile and ${product.interestRate}% APY`;
}

private projectEarnings(
product: SavingsProduct,
profile: UserProfile,
): number {
// Project based on user's average deposit or current invested amount
const principal = profile.avgTransactionAmount > 0
? profile.avgTransactionAmount
: Number(product.minAmount);

const rate = Number(product.interestRate) / 100;
const months = product.tenureMonths || 12;

// Compound interest: P * (1 + r/12)^months - P
const compounded =
principal * Math.pow(1 + rate / 12, months) - principal;

return Math.max(0, compounded);
}
}
Loading