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
44 changes: 44 additions & 0 deletions backend/application/services/budget_notification_service.py
Original file line number Diff line number Diff line change
@@ -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.",
)
13 changes: 12 additions & 1 deletion backend/application/use_cases/manage_budgets.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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."""
Expand Down Expand Up @@ -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."""
Expand Down
27 changes: 27 additions & 0 deletions backend/tests/unit/services/test_budget_notification_service.py
Original file line number Diff line number Diff line change
@@ -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
Loading