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,29 @@
"""loyalty 005 - add last_expiration_warning_at to loyalty_cards
Tracks when the last expiration warning email was sent to prevent
duplicate notifications. The expiration task checks this timestamp
and only sends a warning once per expiration cycle.
Revision ID: loyalty_005
Revises: loyalty_004
Create Date: 2026-04-11
"""
import sqlalchemy as sa
from alembic import op
revision = "loyalty_005"
down_revision = "loyalty_004"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column(
"loyalty_cards",
sa.Column("last_expiration_warning_at", sa.DateTime(), nullable=True),
)
def downgrade() -> None:
op.drop_column("loyalty_cards", "last_expiration_warning_at")

View File

@@ -223,6 +223,13 @@ class LoyaltyCard(Base, TimestampMixin, SoftDeleteMixin):
comment="Any activity (for expiration calculation)", comment="Any activity (for expiration calculation)",
) )
# Notification tracking
last_expiration_warning_at = Column(
DateTime(timezone=True),
nullable=True,
comment="When last expiration warning email was sent",
)
# ========================================================================= # =========================================================================
# Status # Status
# ========================================================================= # =========================================================================

View File

@@ -624,6 +624,23 @@ class CardService:
wallet_service.create_wallet_objects(db, card) wallet_service.create_wallet_objects(db, card)
# Send notification emails (async via Celery)
try:
from app.modules.loyalty.services.notification_service import (
notification_service,
)
notification_service.send_enrollment_confirmation(db, card)
if program.welcome_bonus_points > 0:
notification_service.send_welcome_bonus(
db, card, program.welcome_bonus_points
)
except Exception:
logger.warning(
f"Failed to queue enrollment notification for card {card.id}",
exc_info=True,
)
logger.info( logger.info(
f"Enrolled customer {customer_id} in merchant {merchant_id} loyalty program " f"Enrolled customer {customer_id} in merchant {merchant_id} loyalty program "
f"(card: {card.card_number}, bonus: {program.welcome_bonus_points} pts)" f"(card: {card.card_number}, bonus: {program.welcome_bonus_points} pts)"

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()

View File

@@ -162,6 +162,22 @@ class StampService:
wallet_service.sync_card_to_wallets(db, card) wallet_service.sync_card_to_wallets(db, card)
# Notify customer when they've earned a reward
if reward_earned:
try:
from app.modules.loyalty.services.notification_service import (
notification_service,
)
notification_service.send_reward_available(
db, card, program.stamps_reward_description or "Reward"
)
except Exception:
logger.warning(
f"Failed to queue reward notification for card {card.id}",
exc_info=True,
)
stamps_today += 1 stamps_today += 1
logger.info( logger.info(

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. 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: Args:
db: Database session db: Database session
program: Loyalty program to process 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: if not program.points_expiration_days:
return 0, 0 return 0, 0
now = datetime.now(UTC)
# Calculate expiration threshold # 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( logger.debug(
f"Processing program {program.id}: expiration after {program.points_expiration_days} days " f"Processing program {program.id}: expiration after {program.points_expiration_days} days "
f"(threshold: {expiration_threshold})" 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: # Find cards with:
# - Points balance > 0 # - Points balance > 0
# - Last activity before expiration threshold # - Last activity before expiration threshold
@@ -168,7 +183,7 @@ def _expire_points_for_program(db: Session, program: LoyaltyProgram) -> tuple[in
stamps_delta=0, stamps_delta=0,
stamps_balance_after=card.stamp_count, stamps_balance_after=card.stamp_count,
notes=f"Points expired after {program.points_expiration_days} days of inactivity", 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 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) card.expire_points(expired_points)
# Note: We don't update last_activity_at for expiration # 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 cards_processed += 1
points_expired += expired_points points_expired += expired_points
@@ -187,6 +215,81 @@ def _expire_points_for_program(db: Session, program: LoyaltyProgram) -> tuple[in
return cards_processed, points_expired 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 # Allow running directly for testing
if __name__ == "__main__": if __name__ == "__main__":
import sys import sys

View File

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

View File

@@ -0,0 +1,192 @@
"""Unit tests for LoyaltyNotificationService."""
from unittest.mock import MagicMock, patch
import pytest
from app.modules.loyalty.services.notification_service import (
LoyaltyNotificationService,
)
@pytest.fixture
def notification_service():
return LoyaltyNotificationService()
def _make_card_stub(
card_id=1,
customer_id=10,
merchant_id=100,
enrolled_at_store_id=200,
card_number="1234-5678-9012",
):
"""Lightweight card stub for notification tests."""
card = MagicMock()
card.id = card_id
card.customer_id = customer_id
card.merchant_id = merchant_id
card.enrolled_at_store_id = enrolled_at_store_id
card.card_number = card_number
card.program = MagicMock()
card.program.display_name = "Test Rewards"
return card
def _make_customer_stub(email="test@example.com", full_name="Test User"):
customer = MagicMock()
customer.id = 10
customer.email = email
customer.full_name = full_name
return customer
def _make_store_stub(name="Test Store"):
store = MagicMock()
store.id = 200
store.name = name
return store
@pytest.mark.unit
@pytest.mark.loyalty
class TestNotificationServiceDispatch:
"""Verify that each notification method enqueues the correct template."""
@patch("app.modules.loyalty.tasks.notifications.send_notification_email")
@patch("app.modules.tenancy.services.store_service.store_service")
@patch("app.modules.customers.services.customer_service.customer_service")
def test_send_enrollment_confirmation(
self, mock_customer_svc, mock_store_svc, mock_task, notification_service
):
mock_customer_svc.get_customer_by_id.return_value = _make_customer_stub()
mock_store_svc.get_store_by_id_optional.return_value = _make_store_stub()
db = MagicMock()
card = _make_card_stub()
notification_service.send_enrollment_confirmation(db, card)
mock_task.delay.assert_called_once()
call_kwargs = mock_task.delay.call_args[1]
assert call_kwargs["template_code"] == "loyalty_enrollment"
assert call_kwargs["to_email"] == "test@example.com"
assert "card_number" in call_kwargs["variables"]
@patch("app.modules.loyalty.tasks.notifications.send_notification_email")
@patch("app.modules.tenancy.services.store_service.store_service")
@patch("app.modules.customers.services.customer_service.customer_service")
def test_send_welcome_bonus(
self, mock_customer_svc, mock_store_svc, mock_task, notification_service
):
mock_customer_svc.get_customer_by_id.return_value = _make_customer_stub()
mock_store_svc.get_store_by_id_optional.return_value = _make_store_stub()
db = MagicMock()
card = _make_card_stub()
notification_service.send_welcome_bonus(db, card, 50)
mock_task.delay.assert_called_once()
call_kwargs = mock_task.delay.call_args[1]
assert call_kwargs["template_code"] == "loyalty_welcome_bonus"
assert call_kwargs["variables"]["points"] == "50"
@patch("app.modules.loyalty.tasks.notifications.send_notification_email")
@patch("app.modules.tenancy.services.store_service.store_service")
@patch("app.modules.customers.services.customer_service.customer_service")
def test_send_welcome_bonus_skipped_for_zero_points(
self, mock_customer_svc, mock_store_svc, mock_task, notification_service
):
db = MagicMock()
card = _make_card_stub()
notification_service.send_welcome_bonus(db, card, 0)
mock_task.delay.assert_not_called()
@patch("app.modules.loyalty.tasks.notifications.send_notification_email")
@patch("app.modules.tenancy.services.store_service.store_service")
@patch("app.modules.customers.services.customer_service.customer_service")
def test_send_points_expiration_warning(
self, mock_customer_svc, mock_store_svc, mock_task, notification_service
):
mock_customer_svc.get_customer_by_id.return_value = _make_customer_stub()
mock_store_svc.get_store_by_id_optional.return_value = _make_store_stub()
db = MagicMock()
card = _make_card_stub()
notification_service.send_points_expiration_warning(
db, card, expiring_points=200, days_remaining=14, expiration_date="2026-05-01"
)
mock_task.delay.assert_called_once()
call_kwargs = mock_task.delay.call_args[1]
assert call_kwargs["template_code"] == "loyalty_points_expiring"
assert call_kwargs["variables"]["points"] == "200"
assert call_kwargs["variables"]["days_remaining"] == "14"
@patch("app.modules.loyalty.tasks.notifications.send_notification_email")
@patch("app.modules.tenancy.services.store_service.store_service")
@patch("app.modules.customers.services.customer_service.customer_service")
def test_send_points_expired(
self, mock_customer_svc, mock_store_svc, mock_task, notification_service
):
mock_customer_svc.get_customer_by_id.return_value = _make_customer_stub()
mock_store_svc.get_store_by_id_optional.return_value = _make_store_stub()
db = MagicMock()
card = _make_card_stub()
notification_service.send_points_expired(db, card, 150)
mock_task.delay.assert_called_once()
call_kwargs = mock_task.delay.call_args[1]
assert call_kwargs["template_code"] == "loyalty_points_expired"
assert call_kwargs["variables"]["expired_points"] == "150"
@patch("app.modules.loyalty.tasks.notifications.send_notification_email")
@patch("app.modules.tenancy.services.store_service.store_service")
@patch("app.modules.customers.services.customer_service.customer_service")
def test_send_reward_available(
self, mock_customer_svc, mock_store_svc, mock_task, notification_service
):
mock_customer_svc.get_customer_by_id.return_value = _make_customer_stub()
mock_store_svc.get_store_by_id_optional.return_value = _make_store_stub()
db = MagicMock()
card = _make_card_stub()
notification_service.send_reward_available(db, card, "Free Coffee")
mock_task.delay.assert_called_once()
call_kwargs = mock_task.delay.call_args[1]
assert call_kwargs["template_code"] == "loyalty_reward_ready"
assert call_kwargs["variables"]["reward_name"] == "Free Coffee"
@patch("app.modules.loyalty.tasks.notifications.send_notification_email")
@patch("app.modules.tenancy.services.store_service.store_service")
@patch("app.modules.customers.services.customer_service.customer_service")
def test_no_email_skips_dispatch(
self, mock_customer_svc, mock_store_svc, mock_task, notification_service
):
"""Customer without email does not trigger a send."""
no_email_customer = _make_customer_stub(email=None)
mock_customer_svc.get_customer_by_id.return_value = no_email_customer
db = MagicMock()
card = _make_card_stub()
notification_service.send_enrollment_confirmation(db, card)
mock_task.delay.assert_not_called()
@patch("app.modules.loyalty.tasks.notifications.send_notification_email")
@patch("app.modules.tenancy.services.store_service.store_service")
@patch("app.modules.customers.services.customer_service.customer_service")
def test_customer_not_found_skips_dispatch(
self, mock_customer_svc, mock_store_svc, mock_task, notification_service
):
"""Missing customer does not trigger a send."""
mock_customer_svc.get_customer_by_id.return_value = None
db = MagicMock()
card = _make_card_stub()
notification_service.send_enrollment_confirmation(db, card)
mock_task.delay.assert_not_called()

View File

@@ -0,0 +1,497 @@
#!/usr/bin/env python3
"""
Seed loyalty email templates.
Idempotent: safe to run repeatedly — upserts by (code, language).
Run: python scripts/seed/seed_email_templates_loyalty.py
"""
import contextlib
import json
import sys
from pathlib import Path
# Add project root to path
sys.path.insert(0, str(Path(__file__).parent.parent.parent))
for _mod in [
"app.modules.billing.models",
"app.modules.inventory.models",
"app.modules.cart.models",
"app.modules.messaging.models",
"app.modules.loyalty.models",
"app.modules.catalog.models",
"app.modules.customers.models",
"app.modules.orders.models",
"app.modules.marketplace.models",
"app.modules.cms.models",
]:
with contextlib.suppress(ImportError):
__import__(_mod)
from app.core.database import get_db
from app.modules.messaging.models import EmailCategory, EmailTemplate
# =============================================================================
# SHARED HTML STRUCTURE
# =============================================================================
_HEAD = """<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
</head>"""
def _wrap(gradient_from, gradient_to, title, body_html):
"""Wrap email body in the standard template chrome."""
return f"""{_HEAD}
<body style="font-family: Arial, sans-serif; line-height: 1.6; color: #333; max-width: 600px; margin: 0 auto; padding: 20px;">
<div style="background: linear-gradient(135deg, {gradient_from} 0%, {gradient_to} 100%); padding: 30px; border-radius: 10px 10px 0 0;">
<h1 style="color: white; margin: 0; font-size: 24px;">{title}</h1>
</div>
<div style="background: #f9fafb; padding: 30px; border-radius: 0 0 10px 10px;">
{body_html}
</div>
<div style="text-align: center; padding: 20px; color: #9ca3af; font-size: 12px;">
<p>Powered by RewardFlow</p>
</div>
</body>
</html>"""
# =============================================================================
# LOYALTY TEMPLATES
# =============================================================================
# ── Enrollment ──────────────────────────────────────────────────────────────
_ENROLLMENT_BODY_EN = """
<p style="font-size: 16px;">Hi {{ customer_name }},</p>
<p>Welcome to <strong>{{ program_name }}</strong>! Your loyalty card is ready.</p>
<div style="background: white; border-radius: 8px; padding: 20px; margin: 20px 0; border-left: 4px solid #6366f1;">
<p style="margin: 5px 0;"><strong>Card Number:</strong> {{ card_number }}</p>
<p style="margin: 5px 0;"><strong>Store:</strong> {{ store_name }}</p>
</div>
<p>Start earning rewards on every visit!</p>
<p>Best regards,<br><strong>{{ store_name }}</strong></p>"""
_ENROLLMENT_BODY_FR = """
<p style="font-size: 16px;">Bonjour {{ customer_name }},</p>
<p>Bienvenue dans <strong>{{ program_name }}</strong> ! Votre carte de fidélité est prête.</p>
<div style="background: white; border-radius: 8px; padding: 20px; margin: 20px 0; border-left: 4px solid #6366f1;">
<p style="margin: 5px 0;"><strong>Numéro de carte :</strong> {{ card_number }}</p>
<p style="margin: 5px 0;"><strong>Point de vente :</strong> {{ store_name }}</p>
</div>
<p>Commencez à gagner des récompenses à chaque visite !</p>
<p>Cordialement,<br><strong>{{ store_name }}</strong></p>"""
_ENROLLMENT_BODY_DE = """
<p style="font-size: 16px;">Hallo {{ customer_name }},</p>
<p>Willkommen bei <strong>{{ program_name }}</strong>! Ihre Treuekarte ist bereit.</p>
<div style="background: white; border-radius: 8px; padding: 20px; margin: 20px 0; border-left: 4px solid #6366f1;">
<p style="margin: 5px 0;"><strong>Kartennummer:</strong> {{ card_number }}</p>
<p style="margin: 5px 0;"><strong>Filiale:</strong> {{ store_name }}</p>
</div>
<p>Sammeln Sie ab sofort Prämien bei jedem Besuch!</p>
<p>Mit freundlichen Grüßen,<br><strong>{{ store_name }}</strong></p>"""
_ENROLLMENT_BODY_LB = """
<p style="font-size: 16px;">Moien {{ customer_name }},</p>
<p>Wëllkomm bei <strong>{{ program_name }}</strong>! Är Treiekaart ass prett.</p>
<div style="background: white; border-radius: 8px; padding: 20px; margin: 20px 0; border-left: 4px solid #6366f1;">
<p style="margin: 5px 0;"><strong>Kaartenummer:</strong> {{ card_number }}</p>
<p style="margin: 5px 0;"><strong>Geschäft:</strong> {{ store_name }}</p>
</div>
<p>Fänkt un Belounungen ze sammelen bei all Besuch!</p>
<p>Léif Gréiss,<br><strong>{{ store_name }}</strong></p>"""
# ── Welcome Bonus ───────────────────────────────────────────────────────────
_WELCOME_BONUS_BODY_EN = """
<p style="font-size: 16px;">Hi {{ customer_name }},</p>
<p>Great news! You've received <strong>{{ points }} bonus points</strong> as a welcome gift from <strong>{{ program_name }}</strong>.</p>
<div style="background: white; border-radius: 8px; padding: 20px; margin: 20px 0; text-align: center;">
<p style="font-size: 36px; font-weight: bold; color: #6366f1; margin: 0;">+{{ points }}</p>
<p style="color: #6b7280; margin: 5px 0;">bonus points</p>
</div>
<p>These points are already in your balance and ready to use toward rewards.</p>
<p>Best regards,<br><strong>{{ store_name }}</strong></p>"""
_WELCOME_BONUS_BODY_FR = """
<p style="font-size: 16px;">Bonjour {{ customer_name }},</p>
<p>Bonne nouvelle ! Vous avez reçu <strong>{{ points }} points bonus</strong> en cadeau de bienvenue de <strong>{{ program_name }}</strong>.</p>
<div style="background: white; border-radius: 8px; padding: 20px; margin: 20px 0; text-align: center;">
<p style="font-size: 36px; font-weight: bold; color: #6366f1; margin: 0;">+{{ points }}</p>
<p style="color: #6b7280; margin: 5px 0;">points bonus</p>
</div>
<p>Ces points sont déjà sur votre solde et utilisables pour des récompenses.</p>
<p>Cordialement,<br><strong>{{ store_name }}</strong></p>"""
_WELCOME_BONUS_BODY_DE = """
<p style="font-size: 16px;">Hallo {{ customer_name }},</p>
<p>Tolle Neuigkeiten! Sie haben <strong>{{ points }} Bonuspunkte</strong> als Willkommensgeschenk von <strong>{{ program_name }}</strong> erhalten.</p>
<div style="background: white; border-radius: 8px; padding: 20px; margin: 20px 0; text-align: center;">
<p style="font-size: 36px; font-weight: bold; color: #6366f1; margin: 0;">+{{ points }}</p>
<p style="color: #6b7280; margin: 5px 0;">Bonuspunkte</p>
</div>
<p>Diese Punkte sind bereits auf Ihrem Konto und können für Prämien eingesetzt werden.</p>
<p>Mit freundlichen Grüßen,<br><strong>{{ store_name }}</strong></p>"""
_WELCOME_BONUS_BODY_LB = """
<p style="font-size: 16px;">Moien {{ customer_name }},</p>
<p>Gutt Noriichten! Dir hutt <strong>{{ points }} Bonuspunkten</strong> als Wëllkommsgeschenk vu <strong>{{ program_name }}</strong> kritt.</p>
<div style="background: white; border-radius: 8px; padding: 20px; margin: 20px 0; text-align: center;">
<p style="font-size: 36px; font-weight: bold; color: #6366f1; margin: 0;">+{{ points }}</p>
<p style="color: #6b7280; margin: 5px 0;">Bonuspunkten</p>
</div>
<p>Dës Punkten sinn schonn op Ärem Konto a kënne fir Beloununge benotzt ginn.</p>
<p>Léif Gréiss,<br><strong>{{ store_name }}</strong></p>"""
# ── Points Expiring ─────────────────────────────────────────────────────────
_POINTS_EXPIRING_BODY_EN = """
<p style="font-size: 16px;">Hi {{ customer_name }},</p>
<p>This is a friendly reminder that <strong>{{ points }} points</strong> in your <strong>{{ program_name }}</strong> account will expire in <strong>{{ days_remaining }} days</strong> (on {{ expiration_date }}).</p>
<div style="background: #fef3c7; border-radius: 8px; padding: 20px; margin: 20px 0; border-left: 4px solid #f59e0b;">
<p style="margin: 0; font-weight: bold; color: #92400e;">⏳ {{ points }} points expiring on {{ expiration_date }}</p>
</div>
<p>Visit us before then to use your points toward a reward!</p>
<p>Best regards,<br><strong>{{ store_name }}</strong></p>"""
_POINTS_EXPIRING_BODY_FR = """
<p style="font-size: 16px;">Bonjour {{ customer_name }},</p>
<p>Un petit rappel : <strong>{{ points }} points</strong> de votre compte <strong>{{ program_name }}</strong> expireront dans <strong>{{ days_remaining }} jours</strong> (le {{ expiration_date }}).</p>
<div style="background: #fef3c7; border-radius: 8px; padding: 20px; margin: 20px 0; border-left: 4px solid #f59e0b;">
<p style="margin: 0; font-weight: bold; color: #92400e;">⏳ {{ points }} points expirent le {{ expiration_date }}</p>
</div>
<p>Rendez-nous visite avant cette date pour utiliser vos points !</p>
<p>Cordialement,<br><strong>{{ store_name }}</strong></p>"""
_POINTS_EXPIRING_BODY_DE = """
<p style="font-size: 16px;">Hallo {{ customer_name }},</p>
<p>Eine freundliche Erinnerung: <strong>{{ points }} Punkte</strong> auf Ihrem <strong>{{ program_name }}</strong>-Konto verfallen in <strong>{{ days_remaining }} Tagen</strong> (am {{ expiration_date }}).</p>
<div style="background: #fef3c7; border-radius: 8px; padding: 20px; margin: 20px 0; border-left: 4px solid #f59e0b;">
<p style="margin: 0; font-weight: bold; color: #92400e;">⏳ {{ points }} Punkte verfallen am {{ expiration_date }}</p>
</div>
<p>Besuchen Sie uns vorher, um Ihre Punkte einzulösen!</p>
<p>Mit freundlichen Grüßen,<br><strong>{{ store_name }}</strong></p>"""
_POINTS_EXPIRING_BODY_LB = """
<p style="font-size: 16px;">Moien {{ customer_name }},</p>
<p>Eng kleng Erënnerung: <strong>{{ points }} Punkten</strong> op Ärem <strong>{{ program_name }}</strong>-Konto verfalen an <strong>{{ days_remaining }} Deeg</strong> (den {{ expiration_date }}).</p>
<div style="background: #fef3c7; border-radius: 8px; padding: 20px; margin: 20px 0; border-left: 4px solid #f59e0b;">
<p style="margin: 0; font-weight: bold; color: #92400e;">⏳ {{ points }} Punkten verfalen den {{ expiration_date }}</p>
</div>
<p>Besicht eis virun deem Datum fir Är Punkten anzeléisen!</p>
<p>Léif Gréiss,<br><strong>{{ store_name }}</strong></p>"""
# ── Points Expired ──────────────────────────────────────────────────────────
_POINTS_EXPIRED_BODY_EN = """
<p style="font-size: 16px;">Hi {{ customer_name }},</p>
<p>Unfortunately, <strong>{{ expired_points }} points</strong> in your <strong>{{ program_name }}</strong> account have expired.</p>
<div style="background: #fee2e2; border-radius: 8px; padding: 20px; margin: 20px 0; border-left: 4px solid #ef4444;">
<p style="margin: 0; font-weight: bold; color: #991b1b;">{{ expired_points }} points expired</p>
</div>
<p>Don't worry — you can keep earning points on your next visit!</p>
<p>Best regards,<br><strong>{{ store_name }}</strong></p>"""
_POINTS_EXPIRED_BODY_FR = """
<p style="font-size: 16px;">Bonjour {{ customer_name }},</p>
<p>Malheureusement, <strong>{{ expired_points }} points</strong> de votre compte <strong>{{ program_name }}</strong> ont expiré.</p>
<div style="background: #fee2e2; border-radius: 8px; padding: 20px; margin: 20px 0; border-left: 4px solid #ef4444;">
<p style="margin: 0; font-weight: bold; color: #991b1b;">{{ expired_points }} points expirés</p>
</div>
<p>Pas d'inquiétude — vous pouvez continuer à gagner des points lors de votre prochaine visite !</p>
<p>Cordialement,<br><strong>{{ store_name }}</strong></p>"""
_POINTS_EXPIRED_BODY_DE = """
<p style="font-size: 16px;">Hallo {{ customer_name }},</p>
<p>Leider sind <strong>{{ expired_points }} Punkte</strong> auf Ihrem <strong>{{ program_name }}</strong>-Konto verfallen.</p>
<div style="background: #fee2e2; border-radius: 8px; padding: 20px; margin: 20px 0; border-left: 4px solid #ef4444;">
<p style="margin: 0; font-weight: bold; color: #991b1b;">{{ expired_points }} Punkte verfallen</p>
</div>
<p>Keine Sorge — Sie können bei Ihrem nächsten Besuch weiter Punkte sammeln!</p>
<p>Mit freundlichen Grüßen,<br><strong>{{ store_name }}</strong></p>"""
_POINTS_EXPIRED_BODY_LB = """
<p style="font-size: 16px;">Moien {{ customer_name }},</p>
<p>Leider sinn <strong>{{ expired_points }} Punkten</strong> op Ärem <strong>{{ program_name }}</strong>-Konto ofgelaf.</p>
<div style="background: #fee2e2; border-radius: 8px; padding: 20px; margin: 20px 0; border-left: 4px solid #ef4444;">
<p style="margin: 0; font-weight: bold; color: #991b1b;">{{ expired_points }} Punkten ofgelaf</p>
</div>
<p>Keng Suergen — Dir kënnt bei Ärem nächste Besuch weider Punkten sammelen!</p>
<p>Léif Gréiss,<br><strong>{{ store_name }}</strong></p>"""
# ── Reward Ready ────────────────────────────────────────────────────────────
_REWARD_READY_BODY_EN = """
<p style="font-size: 16px;">Hi {{ customer_name }},</p>
<p>Congratulations! You've earned a reward at <strong>{{ program_name }}</strong>! 🎉</p>
<div style="background: #ecfdf5; border-radius: 8px; padding: 20px; margin: 20px 0; border-left: 4px solid #10b981; text-align: center;">
<p style="font-size: 20px; font-weight: bold; color: #065f46; margin: 0;">🎁 {{ reward_name }}</p>
</div>
<p>Visit <strong>{{ store_name }}</strong> to redeem your reward. Just show your loyalty card!</p>
<p>Best regards,<br><strong>{{ store_name }}</strong></p>"""
_REWARD_READY_BODY_FR = """
<p style="font-size: 16px;">Bonjour {{ customer_name }},</p>
<p>Félicitations ! Vous avez gagné une récompense chez <strong>{{ program_name }}</strong> ! 🎉</p>
<div style="background: #ecfdf5; border-radius: 8px; padding: 20px; margin: 20px 0; border-left: 4px solid #10b981; text-align: center;">
<p style="font-size: 20px; font-weight: bold; color: #065f46; margin: 0;">🎁 {{ reward_name }}</p>
</div>
<p>Rendez-vous chez <strong>{{ store_name }}</strong> pour récupérer votre récompense. Montrez simplement votre carte de fidélité !</p>
<p>Cordialement,<br><strong>{{ store_name }}</strong></p>"""
_REWARD_READY_BODY_DE = """
<p style="font-size: 16px;">Hallo {{ customer_name }},</p>
<p>Herzlichen Glückwunsch! Sie haben eine Prämie bei <strong>{{ program_name }}</strong> verdient! 🎉</p>
<div style="background: #ecfdf5; border-radius: 8px; padding: 20px; margin: 20px 0; border-left: 4px solid #10b981; text-align: center;">
<p style="font-size: 20px; font-weight: bold; color: #065f46; margin: 0;">🎁 {{ reward_name }}</p>
</div>
<p>Besuchen Sie <strong>{{ store_name }}</strong> um Ihre Prämie einzulösen. Zeigen Sie einfach Ihre Treuekarte!</p>
<p>Mit freundlichen Grüßen,<br><strong>{{ store_name }}</strong></p>"""
_REWARD_READY_BODY_LB = """
<p style="font-size: 16px;">Moien {{ customer_name }},</p>
<p>Felicitatiounen! Dir hutt eng Belounung bei <strong>{{ program_name }}</strong> verdéngt! 🎉</p>
<div style="background: #ecfdf5; border-radius: 8px; padding: 20px; margin: 20px 0; border-left: 4px solid #10b981; text-align: center;">
<p style="font-size: 20px; font-weight: bold; color: #065f46; margin: 0;">🎁 {{ reward_name }}</p>
</div>
<p>Besicht <strong>{{ store_name }}</strong> fir Är Belounung ofzehuelen. Weist einfach Är Treiekaart!</p>
<p>Léif Gréiss,<br><strong>{{ store_name }}</strong></p>"""
# =============================================================================
# BUILD TEMPLATE LIST
# =============================================================================
def _make_templates():
"""Build the full list of loyalty email templates."""
templates = []
_defs = [
{
"code": "loyalty_enrollment",
"name": {
"en": "Loyalty Enrollment",
"fr": "Inscription fidélité",
"de": "Treue-Anmeldung",
"lb": "Treie-Umeldung",
},
"description": "Sent when a customer enrolls in a loyalty program",
"category": EmailCategory.SYSTEM.value,
"variables": ["customer_name", "program_name", "card_number", "store_name"],
"subject": {
"en": "Welcome to {{ program_name }}!",
"fr": "Bienvenue chez {{ program_name }} !",
"de": "Willkommen bei {{ program_name }}!",
"lb": "Wëllkomm bei {{ program_name }}!",
},
"body_html": {
"en": _wrap("#6366f1", "#8b5cf6", "Welcome!", _ENROLLMENT_BODY_EN),
"fr": _wrap("#6366f1", "#8b5cf6", "Bienvenue !", _ENROLLMENT_BODY_FR),
"de": _wrap("#6366f1", "#8b5cf6", "Willkommen!", _ENROLLMENT_BODY_DE),
"lb": _wrap("#6366f1", "#8b5cf6", "Wëllkomm!", _ENROLLMENT_BODY_LB),
},
"body_text": {
"en": "Hi {{ customer_name }},\n\nWelcome to {{ program_name }}! Your loyalty card number is {{ card_number }}.\n\nStart earning rewards on every visit!\n\nBest regards,\n{{ store_name }}",
"fr": "Bonjour {{ customer_name }},\n\nBienvenue chez {{ program_name }} ! Votre numéro de carte est {{ card_number }}.\n\nCommencez à gagner des récompenses !\n\nCordialement,\n{{ store_name }}",
"de": "Hallo {{ customer_name }},\n\nWillkommen bei {{ program_name }}! Ihre Kartennummer ist {{ card_number }}.\n\nSammeln Sie ab sofort Prämien!\n\nMit freundlichen Grüßen,\n{{ store_name }}",
"lb": "Moien {{ customer_name }},\n\nWëllkomm bei {{ program_name }}! Är Kaartenummer ass {{ card_number }}.\n\nFänkt un Belounungen ze sammelen!\n\nLéif Gréiss,\n{{ store_name }}",
},
},
{
"code": "loyalty_welcome_bonus",
"name": {
"en": "Loyalty Welcome Bonus",
"fr": "Bonus de bienvenue fidélité",
"de": "Treue-Willkommensbonus",
"lb": "Treie-Wëllkommsbonus",
},
"description": "Sent when a customer receives welcome bonus points",
"category": EmailCategory.SYSTEM.value,
"variables": ["customer_name", "program_name", "points", "store_name"],
"subject": {
"en": "You earned {{ points }} bonus points!",
"fr": "Vous avez gagné {{ points }} points bonus !",
"de": "Sie haben {{ points }} Bonuspunkte erhalten!",
"lb": "Dir hutt {{ points }} Bonuspunkten kritt!",
},
"body_html": {
"en": _wrap("#6366f1", "#8b5cf6", "Bonus Points!", _WELCOME_BONUS_BODY_EN),
"fr": _wrap("#6366f1", "#8b5cf6", "Points Bonus !", _WELCOME_BONUS_BODY_FR),
"de": _wrap("#6366f1", "#8b5cf6", "Bonuspunkte!", _WELCOME_BONUS_BODY_DE),
"lb": _wrap("#6366f1", "#8b5cf6", "Bonuspunkten!", _WELCOME_BONUS_BODY_LB),
},
"body_text": {
"en": "Hi {{ customer_name }},\n\nYou've received {{ points }} bonus points as a welcome gift from {{ program_name }}.\n\nThese points are already in your balance.\n\nBest regards,\n{{ store_name }}",
"fr": "Bonjour {{ customer_name }},\n\nVous avez reçu {{ points }} points bonus de {{ program_name }}.\n\nCes points sont déjà sur votre solde.\n\nCordialement,\n{{ store_name }}",
"de": "Hallo {{ customer_name }},\n\nSie haben {{ points }} Bonuspunkte von {{ program_name }} erhalten.\n\nDiese Punkte sind bereits auf Ihrem Konto.\n\nMit freundlichen Grüßen,\n{{ store_name }}",
"lb": "Moien {{ customer_name }},\n\nDir hutt {{ points }} Bonuspunkten vu {{ program_name }} kritt.\n\nDës Punkten sinn schonn op Ärem Konto.\n\nLéif Gréiss,\n{{ store_name }}",
},
},
{
"code": "loyalty_points_expiring",
"name": {
"en": "Points Expiring Warning",
"fr": "Avertissement expiration points",
"de": "Punkteverfall-Warnung",
"lb": "Punkten-Oflaaf-Warnung",
},
"description": "Sent 14 days before loyalty points expire",
"category": EmailCategory.SYSTEM.value,
"variables": ["customer_name", "program_name", "points", "days_remaining", "expiration_date", "store_name"],
"subject": {
"en": "Your {{ points }} points expire in {{ days_remaining }} days",
"fr": "Vos {{ points }} points expirent dans {{ days_remaining }} jours",
"de": "Ihre {{ points }} Punkte verfallen in {{ days_remaining }} Tagen",
"lb": "Är {{ points }} Punkten verfalen an {{ days_remaining }} Deeg",
},
"body_html": {
"en": _wrap("#f59e0b", "#d97706", "Points Expiring Soon", _POINTS_EXPIRING_BODY_EN),
"fr": _wrap("#f59e0b", "#d97706", "Points bientôt expirés", _POINTS_EXPIRING_BODY_FR),
"de": _wrap("#f59e0b", "#d97706", "Punkte verfallen bald", _POINTS_EXPIRING_BODY_DE),
"lb": _wrap("#f59e0b", "#d97706", "Punkten verfalen geschwënn", _POINTS_EXPIRING_BODY_LB),
},
"body_text": {
"en": "Hi {{ customer_name }},\n\n{{ points }} points in your {{ program_name }} account will expire in {{ days_remaining }} days (on {{ expiration_date }}).\n\nVisit us to use your points!\n\nBest regards,\n{{ store_name }}",
"fr": "Bonjour {{ customer_name }},\n\n{{ points }} points de votre compte {{ program_name }} expireront dans {{ days_remaining }} jours (le {{ expiration_date }}).\n\nRendez-nous visite !\n\nCordialement,\n{{ store_name }}",
"de": "Hallo {{ customer_name }},\n\n{{ points }} Punkte auf Ihrem {{ program_name }}-Konto verfallen in {{ days_remaining }} Tagen (am {{ expiration_date }}).\n\nBesuchen Sie uns!\n\nMit freundlichen Grüßen,\n{{ store_name }}",
"lb": "Moien {{ customer_name }},\n\n{{ points }} Punkten op Ärem {{ program_name }}-Konto verfalen an {{ days_remaining }} Deeg (den {{ expiration_date }}).\n\nBesicht eis!\n\nLéif Gréiss,\n{{ store_name }}",
},
},
{
"code": "loyalty_points_expired",
"name": {
"en": "Points Expired",
"fr": "Points expirés",
"de": "Punkte verfallen",
"lb": "Punkten ofgelaf",
},
"description": "Sent when loyalty points have expired",
"category": EmailCategory.SYSTEM.value,
"variables": ["customer_name", "program_name", "expired_points", "store_name"],
"subject": {
"en": "{{ expired_points }} points have expired",
"fr": "{{ expired_points }} points ont expiré",
"de": "{{ expired_points }} Punkte sind verfallen",
"lb": "{{ expired_points }} Punkten sinn ofgelaf",
},
"body_html": {
"en": _wrap("#ef4444", "#dc2626", "Points Expired", _POINTS_EXPIRED_BODY_EN),
"fr": _wrap("#ef4444", "#dc2626", "Points expirés", _POINTS_EXPIRED_BODY_FR),
"de": _wrap("#ef4444", "#dc2626", "Punkte verfallen", _POINTS_EXPIRED_BODY_DE),
"lb": _wrap("#ef4444", "#dc2626", "Punkten ofgelaf", _POINTS_EXPIRED_BODY_LB),
},
"body_text": {
"en": "Hi {{ customer_name }},\n\n{{ expired_points }} points in your {{ program_name }} account have expired.\n\nKeep earning on your next visit!\n\nBest regards,\n{{ store_name }}",
"fr": "Bonjour {{ customer_name }},\n\n{{ expired_points }} points de votre compte {{ program_name }} ont expiré.\n\nContinuez à gagner des points !\n\nCordialement,\n{{ store_name }}",
"de": "Hallo {{ customer_name }},\n\n{{ expired_points }} Punkte auf Ihrem {{ program_name }}-Konto sind verfallen.\n\nSammeln Sie weiter!\n\nMit freundlichen Grüßen,\n{{ store_name }}",
"lb": "Moien {{ customer_name }},\n\n{{ expired_points }} Punkten op Ärem {{ program_name }}-Konto sinn ofgelaf.\n\nSammelt weider!\n\nLéif Gréiss,\n{{ store_name }}",
},
},
{
"code": "loyalty_reward_ready",
"name": {
"en": "Reward Ready",
"fr": "Récompense disponible",
"de": "Prämie bereit",
"lb": "Belounung prett",
},
"description": "Sent when a customer earns enough stamps for a reward",
"category": EmailCategory.MARKETING.value,
"variables": ["customer_name", "program_name", "reward_name", "store_name"],
"subject": {
"en": "You've earned a reward at {{ program_name }}! 🎉",
"fr": "Vous avez gagné une récompense chez {{ program_name }} ! 🎉",
"de": "Sie haben eine Prämie bei {{ program_name }} verdient! 🎉",
"lb": "Dir hutt eng Belounung bei {{ program_name }} verdéngt! 🎉",
},
"body_html": {
"en": _wrap("#10b981", "#059669", "Reward Earned! 🎉", _REWARD_READY_BODY_EN),
"fr": _wrap("#10b981", "#059669", "Récompense gagnée ! 🎉", _REWARD_READY_BODY_FR),
"de": _wrap("#10b981", "#059669", "Prämie verdient! 🎉", _REWARD_READY_BODY_DE),
"lb": _wrap("#10b981", "#059669", "Belounung verdéngt! 🎉", _REWARD_READY_BODY_LB),
},
"body_text": {
"en": "Hi {{ customer_name }},\n\nCongratulations! You've earned a reward: {{ reward_name }}\n\nVisit {{ store_name }} to redeem it!\n\nBest regards,\n{{ store_name }}",
"fr": "Bonjour {{ customer_name }},\n\nFélicitations ! Vous avez gagné : {{ reward_name }}\n\nRendez-vous chez {{ store_name }} !\n\nCordialement,\n{{ store_name }}",
"de": "Hallo {{ customer_name }},\n\nHerzlichen Glückwunsch! Ihre Prämie: {{ reward_name }}\n\nBesuchen Sie {{ store_name }}!\n\nMit freundlichen Grüßen,\n{{ store_name }}",
"lb": "Moien {{ customer_name }},\n\nFelicitatiounen! Är Belounung: {{ reward_name }}\n\nBesicht {{ store_name }}!\n\nLéif Gréiss,\n{{ store_name }}",
},
},
]
for defn in _defs:
for lang in ("en", "fr", "de", "lb"):
templates.append({
"code": defn["code"],
"language": lang,
"name": defn["name"][lang],
"description": defn["description"],
"category": defn["category"],
"variables": json.dumps(defn["variables"]),
"required_variables": json.dumps(defn["variables"]),
"subject": defn["subject"][lang],
"body_html": defn["body_html"][lang],
"body_text": defn["body_text"][lang],
"is_platform_only": False,
})
return templates
TEMPLATES = _make_templates()
# =============================================================================
# SEED FUNCTION
# =============================================================================
def seed_templates():
"""Seed loyalty email templates into database (idempotent)."""
db = next(get_db())
try:
created = 0
updated = 0
for template_data in TEMPLATES:
existing = (
db.query(EmailTemplate)
.filter(
EmailTemplate.code == template_data["code"],
EmailTemplate.language == template_data["language"],
)
.first()
)
if existing:
for key, value in template_data.items():
setattr(existing, key, value)
updated += 1
print(f" Updated: {template_data['code']} ({template_data['language']})")
else:
template = EmailTemplate(**template_data)
db.add(template)
created += 1
print(f" Created: {template_data['code']} ({template_data['language']})")
db.commit()
print(f"\nLoyalty templates — Created: {created}, Updated: {updated}")
except Exception as e:
db.rollback()
print(f"Error: {e}")
raise
finally:
db.close()
if __name__ == "__main__":
seed_templates()