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

@@ -19,6 +19,16 @@ import pytest
from app.modules.loyalty.models import LoyaltyCard, LoyaltyProgram, LoyaltyTransaction
from app.modules.loyalty.models.loyalty_program import LoyaltyType
@pytest.fixture(autouse=True)
def _clear_rate_limiter():
"""Reset the in-memory rate limiter before each test to prevent bleed."""
from middleware.decorators import rate_limiter
rate_limiter.clients.clear()
yield
rate_limiter.clients.clear()
from app.modules.loyalty.models.loyalty_transaction import TransactionType
from app.modules.tenancy.models import User
@@ -467,26 +477,30 @@ class TestStampEarnRedeem:
self, client, stamp_store_headers, stamp_store_setup
):
"""POST /stamp returns 429 once the per-IP cap is exceeded."""
from unittest.mock import patch
from middleware.decorators import rate_limiter
# Reset the in-memory limiter so prior tests don't bleed in
rate_limiter.clients.clear()
card = stamp_store_setup["card"]
# Cap is 60 per minute. Hit it 60 times and expect any 200/4xx but not
# a 429, then the 61st should be 429.
for _ in range(60):
client.post(
# Mock notification dispatch so 60 requests complete fast enough
# to stay within the rate limiter's 60-second window.
with patch(
"app.modules.loyalty.tasks.notifications.send_notification_email"
):
for _ in range(60):
client.post(
f"{BASE}/stamp",
json={"card_id": card.id},
headers=stamp_store_headers,
)
response = client.post(
f"{BASE}/stamp",
json={"card_id": card.id},
headers=stamp_store_headers,
)
response = client.post(
f"{BASE}/stamp",
json={"card_id": card.id},
headers=stamp_store_headers,
)
assert response.status_code == 429
rate_limiter.clients.clear()