From 63ee224f03b97eddc10d1b2572732f1b69d4984a Mon Sep 17 00:00:00 2001 From: Gaurav Shinde <151384180+gaurav-shinde-07@users.noreply.github.com> Date: Tue, 27 Jan 2026 20:18:40 +0530 Subject: [PATCH] feat(budget): add email notifications for 80% and 100% usage thresholds --- .../services/budget_notification_service.py | 44 +++++++++++++++++++ .../application/use_cases/manage_budgets.py | 13 +++++- .../test_budget_notification_service.py | 27 ++++++++++++ 3 files changed, 83 insertions(+), 1 deletion(-) create mode 100644 backend/application/services/budget_notification_service.py create mode 100644 backend/tests/unit/services/test_budget_notification_service.py diff --git a/backend/application/services/budget_notification_service.py b/backend/application/services/budget_notification_service.py new file mode 100644 index 0000000..85309d8 --- /dev/null +++ b/backend/application/services/budget_notification_service.py @@ -0,0 +1,44 @@ +import smtplib +from email.message import EmailMessage + +from backend.core.config import settings +from backend.domain.models import Budget + + +class BudgetNotificationService: + """Handles budget threshold email notifications.""" + + WARNING_THRESHOLD = 80 + CRITICAL_THRESHOLD = 100 + + def notify_if_needed(self, budget: Budget) -> None: + """Send alert email if budget crosses thresholds.""" + percent = budget.usage_percent + + if percent < self.WARNING_THRESHOLD: + return + + subject, body = self._build_message(percent) + + msg = EmailMessage() + msg["From"] = settings.ALERT_FROM_EMAIL + msg["To"] = "admin@tensorwall.local" # placeholder; can be app/org email later + msg["Subject"] = subject + msg.set_content(body) + + with smtplib.SMTP(settings.SMTP_HOST, settings.SMTP_PORT) as server: + if settings.SMTP_USER: + server.starttls() + server.login(settings.SMTP_USER, settings.SMTP_PASSWORD) + server.send_message(msg) + + def _build_message(self, percent: float) -> tuple[str, str]: + if percent >= self.CRITICAL_THRESHOLD: + return ( + "Budget Limit Reached", + f"Your budget has reached {percent:.0f}% usage. Spending may be blocked.", + ) + return ( + "Budget Usage Warning", + f"Your budget has reached {percent:.0f}% usage.", + ) diff --git a/backend/application/use_cases/manage_budgets.py b/backend/application/use_cases/manage_budgets.py index 7112ee1..496d0c5 100644 --- a/backend/application/use_cases/manage_budgets.py +++ b/backend/application/use_cases/manage_budgets.py @@ -8,6 +8,10 @@ from backend.domain.models import Budget, BudgetPeriod from backend.domain.budget import BudgetChecker, BudgetStatus from backend.ports.budget_repository import BudgetRepositoryPort +from backend.application.services.budget_notification_service import ( + BudgetNotificationService, +) + @dataclass @@ -62,9 +66,12 @@ def __init__( self, budget_repository: BudgetRepositoryPort, budget_checker: BudgetChecker, + notification_service: BudgetNotificationService | None = None, ): self.budget_repository = budget_repository self.budget_checker = budget_checker + self.notification_service = notification_service + def _to_dto(self, budget: Budget) -> BudgetDTO: """Convertit un Budget en DTO.""" @@ -131,7 +138,11 @@ async def check_budget(self, command: BudgetCheckCommand) -> BudgetStatus: app_id=command.app_id, org_id=command.org_id, ) - return self.budget_checker.check(budgets, command.estimated_cost_usd) + status = self.budget_checker.check(budgets, command.estimated_cost_usd) + + for budget in budgets: + self.notification_service.notify_if_needed(budget) + return status async def delete_budget(self, budget_id: str) -> bool: """Supprime un budget.""" diff --git a/backend/tests/unit/services/test_budget_notification_service.py b/backend/tests/unit/services/test_budget_notification_service.py new file mode 100644 index 0000000..fff0665 --- /dev/null +++ b/backend/tests/unit/services/test_budget_notification_service.py @@ -0,0 +1,27 @@ +from unittest.mock import patch, MagicMock + +from backend.application.services.budget_notification_service import ( + BudgetNotificationService, +) + + +def test_email_triggered_at_80_percent(): + service = BudgetNotificationService() + + mock_budget = MagicMock() + mock_budget.usage_percent = 85 + + with patch("smtplib.SMTP") as mock_smtp: + service.notify_if_needed(mock_budget) + assert mock_smtp.called + + +def test_no_email_below_threshold(): + service = BudgetNotificationService() + + mock_budget = MagicMock() + mock_budget.usage_percent = 50 + + with patch("smtplib.SMTP") as mock_smtp: + service.notify_if_needed(mock_budget) + assert not mock_smtp.called