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,72 @@
# app/modules/loyalty/tasks/notifications.py
"""
Async email notification dispatch for loyalty events.
All loyalty notification emails are sent asynchronously via this Celery
task to avoid blocking request handlers. The task opens its own DB
session and calls EmailService.send_template() which handles language
resolution, store overrides, Jinja2 rendering, and EmailLog creation.
"""
import logging
from celery import shared_task
logger = logging.getLogger(__name__)
@shared_task(
name="loyalty.send_notification_email",
bind=True,
max_retries=3,
default_retry_delay=60,
)
def send_notification_email(
self,
template_code: str,
to_email: str,
to_name: str | None = None,
variables: dict | None = None,
store_id: int | None = None,
customer_id: int | None = None,
language: str | None = None,
):
"""
Send a loyalty notification email asynchronously.
Args:
template_code: Email template code (e.g. 'loyalty_enrollment')
to_email: Recipient email address
to_name: Recipient display name
variables: Template variables dict
store_id: Store ID for branding and template overrides
customer_id: Customer ID for language resolution
language: Explicit language override (otherwise auto-resolved)
"""
from app.core.database import SessionLocal
from app.modules.messaging.services.email_service import EmailService
db = SessionLocal()
try:
email_service = EmailService(db)
email_log = email_service.send_template(
template_code=template_code,
to_email=to_email,
to_name=to_name,
language=language,
variables=variables or {},
store_id=store_id,
customer_id=customer_id,
)
logger.info(
f"Loyalty notification sent: {template_code} to {to_email} "
f"(log_id={email_log.id if email_log else 'none'})"
)
except Exception as exc:
logger.error(
f"Loyalty notification failed: {template_code} to {to_email}: {exc}"
)
db.rollback()
raise self.retry(exc=exc)
finally:
db.close()

View File

@@ -109,6 +109,9 @@ def _expire_points_for_program(db: Session, program: LoyaltyProgram) -> tuple[in
"""
Expire points for a specific loyalty program.
Also sends warning emails to cards approaching their expiration date
(14 days before) and expired notifications after points are zeroed.
Args:
db: Database session
program: Loyalty program to process
@@ -119,14 +122,26 @@ def _expire_points_for_program(db: Session, program: LoyaltyProgram) -> tuple[in
if not program.points_expiration_days:
return 0, 0
now = datetime.now(UTC)
# Calculate expiration threshold
expiration_threshold = datetime.now(UTC) - timedelta(days=program.points_expiration_days)
expiration_threshold = now - timedelta(days=program.points_expiration_days)
logger.debug(
f"Processing program {program.id}: expiration after {program.points_expiration_days} days "
f"(threshold: {expiration_threshold})"
)
# --- Phase 1: Send 14-day warning emails ---
warning_days = 14
warning_threshold = now - timedelta(
days=program.points_expiration_days - warning_days
)
_send_expiration_warnings(
db, program, warning_threshold, warning_days, now
)
# --- Phase 2: Expire points ---
# Find cards with:
# - Points balance > 0
# - Last activity before expiration threshold
@@ -168,7 +183,7 @@ def _expire_points_for_program(db: Session, program: LoyaltyProgram) -> tuple[in
stamps_delta=0,
stamps_balance_after=card.stamp_count,
notes=f"Points expired after {program.points_expiration_days} days of inactivity",
transaction_at=datetime.now(UTC),
transaction_at=now,
)
db.add(transaction) # noqa: PERF006
@@ -176,6 +191,19 @@ def _expire_points_for_program(db: Session, program: LoyaltyProgram) -> tuple[in
card.expire_points(expired_points)
# Note: We don't update last_activity_at for expiration
# Send expired notification
try:
from app.modules.loyalty.services.notification_service import (
notification_service,
)
notification_service.send_points_expired(db, card, expired_points)
except Exception:
logger.warning(
f"Failed to queue expiration notification for card {card.id}",
exc_info=True,
)
cards_processed += 1
points_expired += expired_points
@@ -187,6 +215,81 @@ def _expire_points_for_program(db: Session, program: LoyaltyProgram) -> tuple[in
return cards_processed, points_expired
def _send_expiration_warnings(
db: Session,
program: LoyaltyProgram,
warning_threshold: datetime,
warning_days: int,
now: datetime,
) -> int:
"""Send expiration warning emails to cards approaching expiry.
Only sends one warning per expiration cycle (tracked via
last_expiration_warning_at on the card).
Returns:
Number of warnings sent
"""
from sqlalchemy import or_
# Find cards in the warning window:
# - Have points
# - Last activity is past the warning threshold (i.e. will expire in ~14 days)
# - But NOT yet past the full expiration threshold
# - Haven't received a warning yet in this cycle
expiration_threshold = now - timedelta(days=program.points_expiration_days)
cards = (
db.query(LoyaltyCard)
.filter(
LoyaltyCard.merchant_id == program.merchant_id,
LoyaltyCard.points_balance > 0,
LoyaltyCard.is_active == True,
LoyaltyCard.last_activity_at < warning_threshold,
LoyaltyCard.last_activity_at >= expiration_threshold,
or_(
LoyaltyCard.last_expiration_warning_at.is_(None),
LoyaltyCard.last_expiration_warning_at < warning_threshold,
),
)
.all()
)
if not cards:
return 0
warnings_sent = 0
expiration_date = (
now + timedelta(days=warning_days)
).strftime("%Y-%m-%d")
for card in cards:
try:
from app.modules.loyalty.services.notification_service import (
notification_service,
)
notification_service.send_points_expiration_warning(
db,
card,
expiring_points=card.points_balance,
days_remaining=warning_days,
expiration_date=expiration_date,
)
card.last_expiration_warning_at = now
warnings_sent += 1
except Exception:
logger.warning(
f"Failed to queue expiration warning for card {card.id}",
exc_info=True,
)
logger.info(
f"Sent {warnings_sent} expiration warnings for program {program.id}"
)
return warnings_sent
# Allow running directly for testing
if __name__ == "__main__":
import sys