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