feat(loyalty): Phase 2A — transactional email notifications
Some checks failed
CI / ruff (push) Successful in 13s
CI / validate (push) Has been cancelled
CI / dependency-scanning (push) Has been cancelled
CI / docs (push) Has been cancelled
CI / deploy (push) Has been cancelled
CI / pytest (push) Has been cancelled

Add async email notifications for 5 loyalty lifecycle events, using
the existing messaging module infrastructure (EmailService, EmailLog,
store template overrides).

- New seed script: scripts/seed/seed_email_templates_loyalty.py
  Seeds 5 templates × 4 locales (en/fr/de/lb) = 20 rows. Idempotent.
  Renamed existing script to seed_email_templates_core.py.
- Celery task: loyalty.send_notification_email — async dispatch with
  3 retries and 60s backoff. Opens own DB session.
- Notification service: LoyaltyNotificationService with 5 methods
  that resolve customer/card/program into template variables and
  enqueue via Celery (never blocks request handlers).
- Enrollment: sends loyalty_enrollment + loyalty_welcome_bonus (if
  bonus > 0) after card creation commit.
- Stamps: sends loyalty_reward_ready when stamp target reached.
- Expiration task: sends loyalty_points_expiring 14 days before expiry
  (tracked via new last_expiration_warning_at column to prevent dupes),
  and loyalty_points_expired after points are zeroed.
- Migration loyalty_005: adds last_expiration_warning_at to cards.
- 8 new unit tests for notification service dispatch.
- Fix: rate limiter autouse fixture in integration tests to prevent
  state bleed between tests.

Templates: loyalty_enrollment, loyalty_welcome_bonus,
loyalty_points_expiring, loyalty_points_expired, loyalty_reward_ready.
All support store-level overrides via the existing email template UI.

Birthday + re-engagement emails deferred to future marketing module
(cross-platform: OMS, loyalty, hosting).

342 tests pass.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-11 19:11:56 +02:00
parent f804ff8442
commit 52b78ce346
11 changed files with 1097 additions and 13 deletions

View File

@@ -0,0 +1,137 @@
# app/modules/loyalty/services/notification_service.py
"""
Loyalty notification service.
Thin wrapper that resolves customer/card/program data into template
variables and dispatches emails asynchronously via the Celery task.
"""
import logging
from sqlalchemy.orm import Session
from app.modules.loyalty.models import LoyaltyCard
logger = logging.getLogger(__name__)
class LoyaltyNotificationService:
"""Dispatches loyalty email notifications."""
def _resolve_context(self, db: Session, card: LoyaltyCard) -> dict | None:
"""Load customer, store, and program info for a card.
Returns None if the customer has no email (can't send).
"""
from app.modules.customers.services.customer_service import customer_service
from app.modules.tenancy.services.store_service import store_service
customer = customer_service.get_customer_by_id(db, card.customer_id)
if not customer or not customer.email:
return None
store = store_service.get_store_by_id_optional(db, card.enrolled_at_store_id)
program = card.program
return {
"customer": customer,
"store": store,
"program": program,
"to_email": customer.email,
"to_name": customer.full_name,
"store_id": card.enrolled_at_store_id,
"customer_id": customer.id,
"customer_name": customer.full_name,
"program_name": program.display_name if program else "Loyalty Program",
"store_name": store.name if store else "",
}
def _dispatch(
self, template_code: str, ctx: dict, extra_vars: dict | None = None
):
"""Enqueue a notification email via Celery."""
from app.modules.loyalty.tasks.notifications import send_notification_email
variables = {
"customer_name": ctx["customer_name"],
"program_name": ctx["program_name"],
"store_name": ctx["store_name"],
}
if extra_vars:
variables.update(extra_vars)
send_notification_email.delay(
template_code=template_code,
to_email=ctx["to_email"],
to_name=ctx["to_name"],
variables=variables,
store_id=ctx["store_id"],
customer_id=ctx["customer_id"],
)
logger.info(
f"Queued {template_code} for {ctx['to_email']}"
)
def send_enrollment_confirmation(self, db: Session, card: LoyaltyCard):
"""Send enrollment confirmation email."""
ctx = self._resolve_context(db, card)
if not ctx:
return
self._dispatch("loyalty_enrollment", ctx, {
"card_number": card.card_number,
})
def send_welcome_bonus(self, db: Session, card: LoyaltyCard, points: int):
"""Send welcome bonus notification (only if points > 0)."""
if points <= 0:
return
ctx = self._resolve_context(db, card)
if not ctx:
return
self._dispatch("loyalty_welcome_bonus", ctx, {
"points": str(points),
})
def send_points_expiration_warning(
self,
db: Session,
card: LoyaltyCard,
expiring_points: int,
days_remaining: int,
expiration_date: str,
):
"""Send points expiring warning email."""
ctx = self._resolve_context(db, card)
if not ctx:
return
self._dispatch("loyalty_points_expiring", ctx, {
"points": str(expiring_points),
"days_remaining": str(days_remaining),
"expiration_date": expiration_date,
})
def send_points_expired(
self, db: Session, card: LoyaltyCard, expired_points: int
):
"""Send points expired notification email."""
ctx = self._resolve_context(db, card)
if not ctx:
return
self._dispatch("loyalty_points_expired", ctx, {
"expired_points": str(expired_points),
})
def send_reward_available(
self, db: Session, card: LoyaltyCard, reward_name: str
):
"""Send reward earned notification email."""
ctx = self._resolve_context(db, card)
if not ctx:
return
self._dispatch("loyalty_reward_ready", ctx, {
"reward_name": reward_name,
})
# Singleton
notification_service = LoyaltyNotificationService()