diff --git a/src/modules/budgets/budgets.module.ts b/src/modules/budgets/budgets.module.ts index e257714..6014b8b 100644 --- a/src/modules/budgets/budgets.module.ts +++ b/src/modules/budgets/budgets.module.ts @@ -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], diff --git a/src/modules/budgets/budgets.service.spec.ts b/src/modules/budgets/budgets.service.spec.ts index 9f192cb..58d7947 100644 --- a/src/modules/budgets/budgets.service.spec.ts +++ b/src/modules/budgets/budgets.service.spec.ts @@ -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(); + }); + }); }); diff --git a/src/modules/budgets/budgets.service.ts b/src/modules/budgets/budgets.service.ts index 06e14d0..f57ab61 100644 --- a/src/modules/budgets/budgets.service.ts +++ b/src/modules/budgets/budgets.service.ts @@ -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; @@ -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, + ) {} /** * Retrieves all budgets @@ -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 { + 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 @@ -49,7 +119,11 @@ export class BudgetsService { */ async findById(id: string): Promise { this.validateId(id); - return this.repository.findOne(id); + const budget = await this.repository.findOne(id); + if (budget) { + await this.checkAndTriggerOverspendAlerts(budget); + } + return budget; } /** @@ -60,7 +134,11 @@ export class BudgetsService { */ async findByUserId(userId: string): Promise { 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; } /** @@ -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; } /** @@ -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; } /** diff --git a/src/modules/notifications/notifications.service.ts b/src/modules/notifications/notifications.service.ts index 619dc3c..79170cb 100644 --- a/src/modules/notifications/notifications.service.ts +++ b/src/modules/notifications/notifications.service.ts @@ -84,6 +84,33 @@ export class NotificationsService { }); } + async findOneByCategory(userId: string, category: string): Promise { + return await this.notificationRepository.findOne({ + where: { userId, category }, + }); + } + + async createBudgetAlertNotification( + userId: string, + budgetId: string, + categoryName: string, + limit: number, + usagePercent: 80 | 100 + ): Promise { + 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' }; }