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
7 changes: 7 additions & 0 deletions src/modules/budgets/budgets.module.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,19 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { BudgetsController } from './budgets.controller';
import { BudgetsService } from './budgets.service';
import { Transaction } from '../transactions/transaction.entity';
import { NotificationsModule } from '../notifications/notifications.module';

/**
* Budgets Module
* Provides budget management functionality
*/
@Module({
imports: [
TypeOrmModule.forFeature([Transaction]),
NotificationsModule,
],
controllers: [BudgetsController],
providers: [BudgetsService],
exports: [BudgetsService],
Expand Down
92 changes: 92 additions & 0 deletions src/modules/budgets/budgets.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1594,4 +1594,96 @@ describe('BudgetsService', () => {
});
});
});

describe('budget overspend alerts', () => {
let mockNotificationsService: any;
let mockTransactionRepository: any;
let serviceWithAlerts: BudgetsService;
let mockQueryBuilder: any;
const budgetId = '123e4567-e89b-42d3-a456-426614174000';
const userId = '123e4567-e89b-42d3-a456-426614174001';

beforeEach(() => {
mockNotificationsService = {
findOneByCategory: jest.fn(),
createBudgetAlertNotification: jest.fn(),
};

mockQueryBuilder = {
select: jest.fn().mockReturnThis(),
where: jest.fn().mockReturnThis(),
andWhere: jest.fn().mockReturnThis(),
getRawOne: jest.fn(),
};

mockTransactionRepository = {
createQueryBuilder: jest.fn().mockReturnValue(mockQueryBuilder),
};

serviceWithAlerts = new BudgetsService(
mockRepository as unknown as BudgetRepository,
mockNotificationsService,
mockTransactionRepository
);
});

it('should do nothing if notificationsService or transactionRepository is not provided', async () => {
const budget = createTestBudget({ id: budgetId, limit: 100, userId });
mockRepository.findOne.mockResolvedValue(budget);

const result = await service.findById(budget.id);
expect(result).toEqual(budget);
expect(mockTransactionRepository.createQueryBuilder).not.toHaveBeenCalled();
});

it('should trigger 80% budget warning notification when spending >= 80% and < 100%', async () => {
const budget = createTestBudget({ id: budgetId, limit: 100, userId, category: 'groceries', assetCode: 'XLM' });
mockRepository.findOne.mockResolvedValue(budget);
mockQueryBuilder.getRawOne.mockResolvedValue({ sum: 85 });
mockNotificationsService.findOneByCategory.mockResolvedValue(null);

const result = await serviceWithAlerts.findById(budget.id);
expect(result).toEqual(budget);

expect(mockTransactionRepository.createQueryBuilder).toHaveBeenCalledWith('transaction');
expect(mockNotificationsService.findOneByCategory).toHaveBeenCalledWith(userId, `budget-alert-80-${budgetId}`);
expect(mockNotificationsService.createBudgetAlertNotification).toHaveBeenCalledWith(
userId,
budgetId,
'groceries',
100,
80
);
});

it('should trigger 100% budget warning notification when spending >= 100%', async () => {
const budget = createTestBudget({ id: budgetId, limit: 100, userId, category: 'groceries', assetCode: 'XLM' });
mockRepository.findOne.mockResolvedValue(budget);
mockQueryBuilder.getRawOne.mockResolvedValue({ sum: 110 });
mockNotificationsService.findOneByCategory.mockResolvedValue(null);

await serviceWithAlerts.findById(budget.id);

expect(mockNotificationsService.findOneByCategory).toHaveBeenCalledWith(userId, `budget-alert-100-${budgetId}`);
expect(mockNotificationsService.createBudgetAlertNotification).toHaveBeenCalledWith(
userId,
budgetId,
'groceries',
100,
100
);
});

it('should not duplicate notifications on repeated checks', async () => {
const budget = createTestBudget({ id: budgetId, limit: 100, userId, category: 'groceries', assetCode: 'XLM' });
mockRepository.findOne.mockResolvedValue(budget);
mockQueryBuilder.getRawOne.mockResolvedValue({ sum: 90 });
mockNotificationsService.findOneByCategory.mockResolvedValue({ id: 'notif-id' });

await serviceWithAlerts.findById(budget.id);

expect(mockNotificationsService.findOneByCategory).toHaveBeenCalledWith(userId, `budget-alert-80-${budgetId}`);
expect(mockNotificationsService.createBudgetAlertNotification).not.toHaveBeenCalled();
});
});
});
92 changes: 87 additions & 5 deletions src/modules/budgets/budgets.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,12 @@
* Handles business logic for budget management with CRUD operations
*/

import { Injectable, Optional } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { Budget } from '../../common/test-utils/fixtures';
import { Transaction } from '../transactions/transaction.entity';
import { NotificationsService } from '../notifications/notifications.service';

export interface BudgetRepository {
find(): Promise<Budget[]>;
Expand All @@ -30,8 +35,16 @@ export class NotFoundError extends Error {
}
}

@Injectable()
export class BudgetsService {
constructor(private readonly repository: BudgetRepository) {}
constructor(
private readonly repository: BudgetRepository,
@Optional()
private readonly notificationsService?: NotificationsService,
@Optional()
@InjectRepository(Transaction)
private readonly transactionRepository?: Repository<Transaction>,
) {}

/**
* Retrieves all budgets
Expand All @@ -41,6 +54,63 @@ export class BudgetsService {
return this.repository.find();
}

/**
* Checks the budget usage and triggers alerts at 80% and 100% thresholds
* @param budget - The budget to check usage for
*/
private async checkAndTriggerOverspendAlerts(budget: Budget): Promise<void> {
if (!this.notificationsService || !this.transactionRepository) {
return;
}

try {
const assetCode = budget.assetCode || 'XLM';
const result = (await this.transactionRepository
.createQueryBuilder('transaction')
.select('SUM(transaction.amount)', 'sum')
.where('transaction.userId = :userId', { userId: budget.userId })
.andWhere('transaction.category = :category', { category: budget.category })
.andWhere('transaction.assetCode = :assetCode', { assetCode })
.andWhere('transaction.stellarCreatedAt >= :startDate', { startDate: budget.startDate })
.andWhere('transaction.stellarCreatedAt <= :endDate', { endDate: budget.endDate })
.andWhere('transaction.status = :status', { status: 'completed' })
.getRawOne()) as unknown as { sum: string | number | null } | undefined;

const spent = Number(result?.sum || 0);
const usagePercent = budget.limit > 0 ? (spent / budget.limit) * 100 : 0;

if (usagePercent >= 80) {
const categoryKey = `budget-alert-80-${budget.id}`;
const existing = await this.notificationsService.findOneByCategory(budget.userId, categoryKey);
if (!existing) {
await this.notificationsService.createBudgetAlertNotification(
budget.userId,
budget.id,
budget.category,
budget.limit,
80
);
}
}

if (usagePercent >= 100) {
const categoryKey = `budget-alert-100-${budget.id}`;
const existing = await this.notificationsService.findOneByCategory(budget.userId, categoryKey);
if (!existing) {
await this.notificationsService.createBudgetAlertNotification(
budget.userId,
budget.id,
budget.category,
budget.limit,
100
);
}
}
} catch {
// Catch error to make sure notification/query failure doesn't block critical CRUD flows
}
}

/**
* Finds a budget by ID
* @param id - Budget ID to search for
Expand All @@ -49,7 +119,11 @@ export class BudgetsService {
*/
async findById(id: string): Promise<Budget | null> {
this.validateId(id);
return this.repository.findOne(id);
const budget = await this.repository.findOne(id);
if (budget) {
await this.checkAndTriggerOverspendAlerts(budget);
}
return budget;
}

/**
Expand All @@ -60,7 +134,11 @@ export class BudgetsService {
*/
async findByUserId(userId: string): Promise<Budget[]> {
this.validateUserId(userId);
return this.repository.findByUserId(userId);
const budgets = await this.repository.findByUserId(userId);
for (const budget of budgets) {
await this.checkAndTriggerOverspendAlerts(budget);
}
return budgets;
}

/**
Expand Down Expand Up @@ -100,7 +178,9 @@ export class BudgetsService {
updatedAt: new Date()
};

return this.repository.create(budget);
const created = await this.repository.create(budget);
await this.checkAndTriggerOverspendAlerts(created);
return created;
}

/**
Expand All @@ -125,7 +205,9 @@ export class BudgetsService {
updatedAt: new Date()
};

return this.repository.update(id, updatedBudget);
const updated = await this.repository.update(id, updatedBudget);
await this.checkAndTriggerOverspendAlerts(updated);
return updated;
}

/**
Expand Down
27 changes: 27 additions & 0 deletions src/modules/notifications/notifications.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,33 @@ export class NotificationsService {
});
}

async findOneByCategory(userId: string, category: string): Promise<Notification | null> {
return await this.notificationRepository.findOne({
where: { userId, category },
});
}

async createBudgetAlertNotification(
userId: string,
budgetId: string,
categoryName: string,
limit: number,
usagePercent: 80 | 100
): Promise<Notification> {
const title = usagePercent === 100 ? 'Budget Limit Exceeded! ⚠️' : 'Budget Limit Approaching! ⚠️';
const message = usagePercent === 100
? `You have spent 100% or more of your budget limit ($${limit.toFixed(2)}) for category "${categoryName}".`
: `You have reached 80% or more of your budget limit ($${limit.toFixed(2)}) for category "${categoryName}".`;

return await this.create({
userId,
title,
message,
type: usagePercent === 100 ? 'error' : 'warning',
category: `budget-alert-${usagePercent}-${budgetId}`,
});
}

getStatus() {
return { module: 'Notifications', status: 'Working' };
}
Expand Down
Loading