diff --git a/app/modules/loyalty/migrations/versions/loyalty_005_add_expiration_warning_at.py b/app/modules/loyalty/migrations/versions/loyalty_005_add_expiration_warning_at.py new file mode 100644 index 00000000..fb52ce56 --- /dev/null +++ b/app/modules/loyalty/migrations/versions/loyalty_005_add_expiration_warning_at.py @@ -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") diff --git a/app/modules/loyalty/models/loyalty_card.py b/app/modules/loyalty/models/loyalty_card.py index 9862ecbc..b8e6da26 100644 --- a/app/modules/loyalty/models/loyalty_card.py +++ b/app/modules/loyalty/models/loyalty_card.py @@ -223,6 +223,13 @@ class LoyaltyCard(Base, TimestampMixin, SoftDeleteMixin): 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 # ========================================================================= diff --git a/app/modules/loyalty/services/card_service.py b/app/modules/loyalty/services/card_service.py index 60facce3..03ccb7bc 100644 --- a/app/modules/loyalty/services/card_service.py +++ b/app/modules/loyalty/services/card_service.py @@ -624,6 +624,23 @@ class CardService: 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( f"Enrolled customer {customer_id} in merchant {merchant_id} loyalty program " f"(card: {card.card_number}, bonus: {program.welcome_bonus_points} pts)" diff --git a/app/modules/loyalty/services/notification_service.py b/app/modules/loyalty/services/notification_service.py new file mode 100644 index 00000000..853d93d1 --- /dev/null +++ b/app/modules/loyalty/services/notification_service.py @@ -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() diff --git a/app/modules/loyalty/services/stamp_service.py b/app/modules/loyalty/services/stamp_service.py index 9529bb01..3fd757e6 100644 --- a/app/modules/loyalty/services/stamp_service.py +++ b/app/modules/loyalty/services/stamp_service.py @@ -162,6 +162,22 @@ class StampService: 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 logger.info( diff --git a/app/modules/loyalty/tasks/notifications.py b/app/modules/loyalty/tasks/notifications.py new file mode 100644 index 00000000..963f473c --- /dev/null +++ b/app/modules/loyalty/tasks/notifications.py @@ -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() diff --git a/app/modules/loyalty/tasks/point_expiration.py b/app/modules/loyalty/tasks/point_expiration.py index efd84614..e95c5f5a 100644 --- a/app/modules/loyalty/tasks/point_expiration.py +++ b/app/modules/loyalty/tasks/point_expiration.py @@ -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 diff --git a/app/modules/loyalty/tests/integration/test_store_api.py b/app/modules/loyalty/tests/integration/test_store_api.py index dccbd1f4..bc031edb 100644 --- a/app/modules/loyalty/tests/integration/test_store_api.py +++ b/app/modules/loyalty/tests/integration/test_store_api.py @@ -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() diff --git a/app/modules/loyalty/tests/unit/test_notification_service.py b/app/modules/loyalty/tests/unit/test_notification_service.py new file mode 100644 index 00000000..9021d01e --- /dev/null +++ b/app/modules/loyalty/tests/unit/test_notification_service.py @@ -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() diff --git a/scripts/seed/seed_email_templates.py b/scripts/seed/seed_email_templates_core.py similarity index 100% rename from scripts/seed/seed_email_templates.py rename to scripts/seed/seed_email_templates_core.py diff --git a/scripts/seed/seed_email_templates_loyalty.py b/scripts/seed/seed_email_templates_loyalty.py new file mode 100644 index 00000000..d73f9aad --- /dev/null +++ b/scripts/seed/seed_email_templates_loyalty.py @@ -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 = """ + + + + +""" + + +def _wrap(gradient_from, gradient_to, title, body_html): + """Wrap email body in the standard template chrome.""" + return f"""{_HEAD} + +
+

{title}

+
+
+{body_html} +
+
+

Powered by RewardFlow

+
+ +""" + + +# ============================================================================= +# LOYALTY TEMPLATES +# ============================================================================= + +# ── Enrollment ────────────────────────────────────────────────────────────── + +_ENROLLMENT_BODY_EN = """ +

Hi {{ customer_name }},

+

Welcome to {{ program_name }}! Your loyalty card is ready.

+
+

Card Number: {{ card_number }}

+

Store: {{ store_name }}

+
+

Start earning rewards on every visit!

+

Best regards,
{{ store_name }}

""" + +_ENROLLMENT_BODY_FR = """ +

Bonjour {{ customer_name }},

+

Bienvenue dans {{ program_name }} ! Votre carte de fidélité est prête.

+
+

Numéro de carte : {{ card_number }}

+

Point de vente : {{ store_name }}

+
+

Commencez à gagner des récompenses à chaque visite !

+

Cordialement,
{{ store_name }}

""" + +_ENROLLMENT_BODY_DE = """ +

Hallo {{ customer_name }},

+

Willkommen bei {{ program_name }}! Ihre Treuekarte ist bereit.

+
+

Kartennummer: {{ card_number }}

+

Filiale: {{ store_name }}

+
+

Sammeln Sie ab sofort Prämien bei jedem Besuch!

+

Mit freundlichen Grüßen,
{{ store_name }}

""" + +_ENROLLMENT_BODY_LB = """ +

Moien {{ customer_name }},

+

Wëllkomm bei {{ program_name }}! Är Treiekaart ass prett.

+
+

Kaartenummer: {{ card_number }}

+

Geschäft: {{ store_name }}

+
+

Fänkt un Belounungen ze sammelen bei all Besuch!

+

Léif Gréiss,
{{ store_name }}

""" + +# ── Welcome Bonus ─────────────────────────────────────────────────────────── + +_WELCOME_BONUS_BODY_EN = """ +

Hi {{ customer_name }},

+

Great news! You've received {{ points }} bonus points as a welcome gift from {{ program_name }}.

+
+

+{{ points }}

+

bonus points

+
+

These points are already in your balance and ready to use toward rewards.

+

Best regards,
{{ store_name }}

""" + +_WELCOME_BONUS_BODY_FR = """ +

Bonjour {{ customer_name }},

+

Bonne nouvelle ! Vous avez reçu {{ points }} points bonus en cadeau de bienvenue de {{ program_name }}.

+
+

+{{ points }}

+

points bonus

+
+

Ces points sont déjà sur votre solde et utilisables pour des récompenses.

+

Cordialement,
{{ store_name }}

""" + +_WELCOME_BONUS_BODY_DE = """ +

Hallo {{ customer_name }},

+

Tolle Neuigkeiten! Sie haben {{ points }} Bonuspunkte als Willkommensgeschenk von {{ program_name }} erhalten.

+
+

+{{ points }}

+

Bonuspunkte

+
+

Diese Punkte sind bereits auf Ihrem Konto und können für Prämien eingesetzt werden.

+

Mit freundlichen Grüßen,
{{ store_name }}

""" + +_WELCOME_BONUS_BODY_LB = """ +

Moien {{ customer_name }},

+

Gutt Noriichten! Dir hutt {{ points }} Bonuspunkten als Wëllkommsgeschenk vu {{ program_name }} kritt.

+
+

+{{ points }}

+

Bonuspunkten

+
+

Dës Punkten sinn schonn op Ärem Konto a kënne fir Beloununge benotzt ginn.

+

Léif Gréiss,
{{ store_name }}

""" + +# ── Points Expiring ───────────────────────────────────────────────────────── + +_POINTS_EXPIRING_BODY_EN = """ +

Hi {{ customer_name }},

+

This is a friendly reminder that {{ points }} points in your {{ program_name }} account will expire in {{ days_remaining }} days (on {{ expiration_date }}).

+
+

⏳ {{ points }} points expiring on {{ expiration_date }}

+
+

Visit us before then to use your points toward a reward!

+

Best regards,
{{ store_name }}

""" + +_POINTS_EXPIRING_BODY_FR = """ +

Bonjour {{ customer_name }},

+

Un petit rappel : {{ points }} points de votre compte {{ program_name }} expireront dans {{ days_remaining }} jours (le {{ expiration_date }}).

+
+

⏳ {{ points }} points expirent le {{ expiration_date }}

+
+

Rendez-nous visite avant cette date pour utiliser vos points !

+

Cordialement,
{{ store_name }}

""" + +_POINTS_EXPIRING_BODY_DE = """ +

Hallo {{ customer_name }},

+

Eine freundliche Erinnerung: {{ points }} Punkte auf Ihrem {{ program_name }}-Konto verfallen in {{ days_remaining }} Tagen (am {{ expiration_date }}).

+
+

⏳ {{ points }} Punkte verfallen am {{ expiration_date }}

+
+

Besuchen Sie uns vorher, um Ihre Punkte einzulösen!

+

Mit freundlichen Grüßen,
{{ store_name }}

""" + +_POINTS_EXPIRING_BODY_LB = """ +

Moien {{ customer_name }},

+

Eng kleng Erënnerung: {{ points }} Punkten op Ärem {{ program_name }}-Konto verfalen an {{ days_remaining }} Deeg (den {{ expiration_date }}).

+
+

⏳ {{ points }} Punkten verfalen den {{ expiration_date }}

+
+

Besicht eis virun deem Datum fir Är Punkten anzeléisen!

+

Léif Gréiss,
{{ store_name }}

""" + +# ── Points Expired ────────────────────────────────────────────────────────── + +_POINTS_EXPIRED_BODY_EN = """ +

Hi {{ customer_name }},

+

Unfortunately, {{ expired_points }} points in your {{ program_name }} account have expired.

+
+

{{ expired_points }} points expired

+
+

Don't worry — you can keep earning points on your next visit!

+

Best regards,
{{ store_name }}

""" + +_POINTS_EXPIRED_BODY_FR = """ +

Bonjour {{ customer_name }},

+

Malheureusement, {{ expired_points }} points de votre compte {{ program_name }} ont expiré.

+
+

{{ expired_points }} points expirés

+
+

Pas d'inquiétude — vous pouvez continuer à gagner des points lors de votre prochaine visite !

+

Cordialement,
{{ store_name }}

""" + +_POINTS_EXPIRED_BODY_DE = """ +

Hallo {{ customer_name }},

+

Leider sind {{ expired_points }} Punkte auf Ihrem {{ program_name }}-Konto verfallen.

+
+

{{ expired_points }} Punkte verfallen

+
+

Keine Sorge — Sie können bei Ihrem nächsten Besuch weiter Punkte sammeln!

+

Mit freundlichen Grüßen,
{{ store_name }}

""" + +_POINTS_EXPIRED_BODY_LB = """ +

Moien {{ customer_name }},

+

Leider sinn {{ expired_points }} Punkten op Ärem {{ program_name }}-Konto ofgelaf.

+
+

{{ expired_points }} Punkten ofgelaf

+
+

Keng Suergen — Dir kënnt bei Ärem nächste Besuch weider Punkten sammelen!

+

Léif Gréiss,
{{ store_name }}

""" + +# ── Reward Ready ──────────────────────────────────────────────────────────── + +_REWARD_READY_BODY_EN = """ +

Hi {{ customer_name }},

+

Congratulations! You've earned a reward at {{ program_name }}! 🎉

+
+

🎁 {{ reward_name }}

+
+

Visit {{ store_name }} to redeem your reward. Just show your loyalty card!

+

Best regards,
{{ store_name }}

""" + +_REWARD_READY_BODY_FR = """ +

Bonjour {{ customer_name }},

+

Félicitations ! Vous avez gagné une récompense chez {{ program_name }} ! 🎉

+
+

🎁 {{ reward_name }}

+
+

Rendez-vous chez {{ store_name }} pour récupérer votre récompense. Montrez simplement votre carte de fidélité !

+

Cordialement,
{{ store_name }}

""" + +_REWARD_READY_BODY_DE = """ +

Hallo {{ customer_name }},

+

Herzlichen Glückwunsch! Sie haben eine Prämie bei {{ program_name }} verdient! 🎉

+
+

🎁 {{ reward_name }}

+
+

Besuchen Sie {{ store_name }} um Ihre Prämie einzulösen. Zeigen Sie einfach Ihre Treuekarte!

+

Mit freundlichen Grüßen,
{{ store_name }}

""" + +_REWARD_READY_BODY_LB = """ +

Moien {{ customer_name }},

+

Felicitatiounen! Dir hutt eng Belounung bei {{ program_name }} verdéngt! 🎉

+
+

🎁 {{ reward_name }}

+
+

Besicht {{ store_name }} fir Är Belounung ofzehuelen. Weist einfach Är Treiekaart!

+

Léif Gréiss,
{{ store_name }}

""" + + +# ============================================================================= +# 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()