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

@@ -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