diff --git a/app/modules/loyalty/docs/business-logic.md b/app/modules/loyalty/docs/business-logic.md index dc0e9798..ceba079a 100644 --- a/app/modules/loyalty/docs/business-logic.md +++ b/app/modules/loyalty/docs/business-logic.md @@ -214,14 +214,25 @@ Uses PKCS#7 signed `.pkpass` files and APNs push notifications. ## Cross-Store Redemption -When `allow_cross_location_redemption` is enabled in merchant settings: +The `allow_cross_location_redemption` merchant setting controls both card scoping and enrollment behavior: -- Cards are scoped to the **merchant** (not individual stores) +### When enabled (default) + +- **One card per customer per merchant** — enforced at the application layer - Customer can earn stamps at Store A and redeem at Store B - Each transaction records which `store_id` it occurred at - The `enrolled_at_store_id` field tracks where the customer first enrolled +- If a customer tries to enroll at a second store, the system returns their existing card with a message showing all available locations -When disabled, stamp/point operations are restricted to the enrollment store. +### When disabled + +- **One card per customer per store** — each store under the merchant issues its own card +- Stamp/point operations are restricted to the card's enrollment store +- A customer can hold separate cards at different stores under the same merchant +- Re-enrolling at the **same** store returns the existing card +- Enrolling at a **different** store creates a new card scoped to that store + +**Database constraint:** Unique index on `(enrolled_at_store_id, customer_id)` prevents duplicate cards at the same store regardless of the cross-location setting. ## Enrollment Flow @@ -229,21 +240,25 @@ When disabled, stamp/point operations are restricted to the enrollment store. Staff enrolls customer via terminal: 1. Enter customer email (and optional name) -2. System resolves or creates customer record -3. Creates loyalty card with unique card number and QR code -4. Creates `CARD_CREATED` transaction -5. Awards welcome bonus points (if configured) via `WELCOME_BONUS` transaction -6. Creates Google Wallet object and Apple Wallet serial -7. Returns card details with "Add to Wallet" URLs +2. System resolves customer — checks the current store first, then searches across all stores under the merchant for an existing cardholder with the same email +3. If the customer already has a card (per-merchant or per-store, depending on the cross-location setting), raises `LoyaltyCardAlreadyExistsException` +4. Otherwise creates loyalty card with unique card number and QR code +5. Creates `CARD_CREATED` transaction +6. Awards welcome bonus points (if configured) via `WELCOME_BONUS` transaction +7. Creates Google Wallet object and Apple Wallet serial +8. Returns card details with "Add to Wallet" URLs ### Self-Enrollment (Public) Customer enrolls via public page (if `allow_self_enrollment` enabled): 1. Customer visits `/loyalty/join` page -2. Enters email and name -3. System creates customer + card -4. Redirected to success page with card number -5. Can add to Google/Apple Wallet from success page +2. Enters email, name, and optional birthday +3. System resolves customer (cross-store lookup for existing cardholders under the same merchant) +4. If already enrolled: returns existing card with success page showing location info + - Cross-location enabled: "Your card works at all our locations" + store list + - Cross-location disabled: "Your card is registered at {original_store}" +5. If new: creates customer + card, redirected to success page with card number +6. Can add to Google/Apple Wallet from success page ## Scheduled Tasks diff --git a/app/modules/loyalty/docs/data-model.md b/app/modules/loyalty/docs/data-model.md index b1f07ed4..8df9e707 100644 --- a/app/modules/loyalty/docs/data-model.md +++ b/app/modules/loyalty/docs/data-model.md @@ -120,14 +120,21 @@ Merchant-wide loyalty program configuration. One program per merchant, shared ac ### LoyaltyCard -Customer loyalty card linking a customer to a merchant's program. One card per customer per merchant. +Customer loyalty card linking a customer to a merchant's program. + +**Card uniqueness depends on the `allow_cross_location_redemption` merchant setting:** + +- **Cross-location enabled (default):** One card per customer per merchant. The application layer enforces this by checking all stores under the merchant before creating a card. Re-enrolling at another store returns the existing card. +- **Cross-location disabled:** One card per customer per store. A customer can hold separate cards at different stores under the same merchant, each scoped to its enrollment store. + +**Database constraint:** Unique index on `(enrolled_at_store_id, customer_id)` — always enforced. The per-merchant uniqueness (cross-location enabled) is enforced at the application layer in `card_service.enroll_customer`. | Field | Type | Description | |-------|------|-------------| | `merchant_id` | FK | Links to program's merchant | | `customer_id` | FK | Card owner | | `program_id` | FK | Associated program | -| `enrolled_at_store_id` | FK | Store where customer enrolled | +| `enrolled_at_store_id` | FK | Store where customer enrolled (part of unique constraint) | | `card_number` | String (unique) | Formatted XXXX-XXXX-XXXX | | `qr_code_data` | String (unique) | URL-safe token for QR codes | | `stamp_count` | Integer | Current stamp count | diff --git a/app/modules/loyalty/docs/production-launch-plan.md b/app/modules/loyalty/docs/production-launch-plan.md index bb184907..2d3596b4 100644 --- a/app/modules/loyalty/docs/production-launch-plan.md +++ b/app/modules/loyalty/docs/production-launch-plan.md @@ -40,11 +40,14 @@ This is the active execution plan for taking the Loyalty module to production. I ``` loyalty_002 (existing) loyalty_003 — CHECK constraints (points_balance >= 0, stamp_count >= 0, etc.) -loyalty_004 — seed 28 notification email templates -loyalty_005 — add columns: last_expiration_warning_at, last_reengagement_at on cards; +loyalty_004 — relax card uniqueness: replace (merchant_id, customer_id) unique index + with (enrolled_at_store_id, customer_id) for cross-location support +loyalty_005 — seed 28 notification email templates +loyalty_006 — add columns: last_expiration_warning_at, last_reengagement_at on cards; acting_admin_id on transactions -loyalty_006 — terms_cms_page_slug on programs -loyalty_007 — birth_date on customers (P0 — Phase 1.4 fix, dropped data bug) +loyalty_007 — terms_cms_page_slug on programs + +customers_003 — birth_date on customers (Phase 1.4 fix, dropped data bug) ``` --- @@ -82,7 +85,7 @@ All 8 decisions locked. No external blockers. #### 1.4 Fix dropped birthday data (P0 bug) **Background:** The enrollment form (`enroll.html:87`) collects a birthday, the schema accepts `customer_birthday` (`schemas/card.py:34`), and `card_service.resolve_customer_id` has the parameter (`card_service.py:172`) — but the call to `customer_service.create_customer_for_enrollment` at `card_service.py:215-222` does not pass it, and the `Customer` model has no `birth_date` column at all. Every enrollment silently loses the birthday. Not live yet, so no backfill needed. -- New migration `loyalty_007_add_customer_birth_date.py` (or place under customers module if that's the convention) adds `birth_date: Date | None` to `customers`. +- Migration `customers_003_add_birth_date.py` adds `birth_date: Date | None` to `customers`. - Update `customer_service.create_customer_for_enrollment` to accept and persist `birth_date`. - Update `card_service.py:215-222` to pass `customer_birthday` through, with `date.fromisoformat()` parsing and validation (must be a real past date, sensible age range). - Update `customer_service.update_customer` to allow backfill. @@ -114,7 +117,7 @@ All 8 decisions locked. No external blockers. - New `app/modules/loyalty/tasks/notifications.py` with `@shared_task(name="loyalty.send_notification_email", bind=True, max_retries=3, default_retry_delay=60)`. - Opens fresh `SessionLocal`, calls `EmailService(db).send_template(...)`, retries on SMTP errors. -#### 2.3 Seed templates `loyalty_004` +#### 2.3 Seed templates `loyalty_005` - 7 templates × 4 locales (en, fr, de, lb) = **28 rows** in `email_templates`. - Template codes: `loyalty_enrollment`, `loyalty_welcome_bonus`, `loyalty_points_expiring`, `loyalty_points_expired`, `loyalty_reward_ready`, `loyalty_birthday`, `loyalty_reengagement`. - **Copywriting needs sign-off** before applying to prod. @@ -123,7 +126,7 @@ All 8 decisions locked. No external blockers. - In `card_service.enroll_customer_for_store` (~lines 480-540), call notification service **after** `db.commit()`. #### 2.5 Wire expiration warning into expiration task -- Migration `loyalty_005` adds `last_expiration_warning_at` to prevent duplicates. +- Migration `loyalty_006` adds `last_expiration_warning_at` to prevent duplicates. - In rewritten `tasks/point_expiration.py` (see 3.1), find cards 14 days from expiry, fire warning, stamp timestamp. - **Validation:** time-mocked test — fires once at 14-day mark. @@ -137,7 +140,7 @@ All 8 decisions locked. No external blockers. #### 2.8 Re-engagement Celery beat task - Weekly schedule. Finds cards inactive > N days (default 60, configurable). -- Throttled via `last_reengagement_at` (added in `loyalty_005`) — once per quarter per card. +- Throttled via `last_reengagement_at` (added in `loyalty_006`) — once per quarter per card. --- @@ -163,7 +166,7 @@ All 8 decisions locked. No external blockers. ### Phase 4 — Accessibility & T&C *(2d)* #### 4.1 T&C via store CMS integration -- Migration `loyalty_006`: add `terms_cms_page_slug: str | None` to `loyalty_programs`. +- Migration `loyalty_007`: add `terms_cms_page_slug: str | None` to `loyalty_programs`. - Update `schemas/program.py` (`ProgramCreate`, `ProgramUpdate`). - `program-form.html:251` — CMS page picker scoped to the program's owning store. - `enroll.html:99-160` — resolve slug to CMS page URL/content; legacy `terms_text` fallback. @@ -224,7 +227,7 @@ All 8 decisions locked. No external blockers. - **Admin "act on behalf"** (`routes/api/admin.py`): - `POST /admin/loyalty/merchants/{merchant_id}/cards/bulk/deactivate` (etc.) - Shared service layer; route stamps `acting_admin_id` in audit log -- New `loyalty_transactions.acting_admin_id` column in `loyalty_005`. +- New `loyalty_transactions.acting_admin_id` column in `loyalty_006`. #### 6.5 Manual override: restore expired points - `POST /admin/loyalty/cards/{card_id}/restore_points` with `{points, reason}`. diff --git a/app/modules/loyalty/docs/user-journeys.md b/app/modules/loyalty/docs/user-journeys.md index 9adc0e03..ca7fc3cd 100644 --- a/app/modules/loyalty/docs/user-journeys.md +++ b/app/modules/loyalty/docs/user-journeys.md @@ -792,3 +792,109 @@ flowchart TD There is no feature gating on loyalty program creation — you can test them in either order. Journey 0 is listed second because domain setup is about URL presentation, not a functional prerequisite for the loyalty module. + +--- + +## Pre-Launch E2E Test Checklist (Fashion Group) + +Manual end-to-end checklist using Fashion Group (merchant 2: FASHIONHUB + FASHIONOUTLET). +Covers all customer-facing flows including the cross-store enrollment and redemption features +added in the Phase 1 production launch hardening. + +### Pre-requisite: Program Setup (Journey 1) + +If Fashion Group doesn't have a loyalty program yet: + +1. Login as `jane.owner@fashiongroup.com` at `http://localhost:8000/platforms/loyalty/store/FASHIONHUB/login` +2. Navigate to: `http://localhost:8000/platforms/loyalty/store/FASHIONHUB/loyalty/settings` +3. Create program (hybrid or points), set welcome bonus, enable self-enrollment +4. Verify Cross-Location Redemption is **enabled** in merchant settings + +### Test 1: Customer Self-Enrollment (Journey 4) + +| Step | Action | Expected Result | +|------|--------|-----------------| +| 1.1 | Visit `http://localhost:8000/platforms/loyalty/storefront/FASHIONHUB/loyalty/join` | Enrollment form loads, no console errors | +| 1.2 | Fill in: fresh email, name, **birthday** → Submit | Redirected to success page with card number | +| 1.3 | Check DB: `SELECT birth_date FROM customers WHERE email = '...'` | `birth_date` is set (not NULL) | +| 1.4 | Enroll **without** birthday (different email) | Success, `birth_date` is NULL (no crash) | + +### Test 2: Cross-Store Re-Enrollment (Cross-Location Enabled) + +| Step | Action | Expected Result | +|------|--------|-----------------| +| 2.1 | Visit `http://localhost:8000/platforms/loyalty/storefront/FASHIONOUTLET/loyalty/join` | Enrollment form loads | +| 2.2 | Submit with the **same email** from Test 1 | Success page shows **"You're already a member!"** | +| 2.3 | Check: store list shown | Blue box: "Your card works at all our locations:" with Fashion Hub + Fashion Outlet listed | +| 2.4 | Check: same card number as Test 1 | Card number matches (no duplicate created) | +| 2.5 | Check DB: `SELECT COUNT(*) FROM loyalty_cards WHERE customer_id = ...` | Exactly 1 card | +| 2.6 | Re-enroll at FASHIONHUB (same store as original) | Same behavior: "already a member" + locations | +| 2.7 | Refresh the success page | Message persists, no flicker, no untranslated i18n keys | + +### Test 3: Staff Operations — Stamps/Points (Journeys 2 & 3) + +| Step | Action | Expected Result | +|------|--------|-----------------| +| 3.1 | Login as `jane.owner@fashiongroup.com` at FASHIONHUB | Login succeeds | +| 3.2 | Open terminal: `.../store/FASHIONHUB/loyalty/terminal` | Terminal loads | +| 3.3 | Look up card by card number | Card found, balance displayed | +| 3.4 | Look up card by customer email | Card found (same result) | +| 3.5 | Add stamp (or earn points with purchase amount) | Count/balance updates | +| 3.6 | Add stamp again immediately (within cooldown) | Rejected: cooldown active | + +### Test 4: Cross-Store Redemption (Journey 8) + +| Step | Action | Expected Result | +|------|--------|-----------------| +| 4.1 | Staff at FASHIONHUB adds stamps/points to the card | Balance updated | +| 4.2 | Login as staff at FASHIONOUTLET (e.g., `diana.stylist@fashiongroup.com` or `jane.owner`) | Login succeeds | +| 4.3 | Open terminal: `.../store/FASHIONOUTLET/loyalty/terminal` | Terminal loads | +| 4.4 | Look up card **by email** | Card found (cross-store email search) | +| 4.5 | Look up card **by card number** | Card found | +| 4.6 | Redeem reward (if enough stamps/points) | Redemption succeeds | +| 4.7 | View card detail | Transaction history shows entries from both FASHIONHUB and FASHIONOUTLET | + +### Test 5: Customer Views Status (Journey 5) + +| Step | Action | Expected Result | +|------|--------|-----------------| +| 5.1 | Login as the customer at storefront | Customer dashboard loads | +| 5.2 | Dashboard: `.../storefront/FASHIONHUB/account/loyalty` | Shows balance, available rewards | +| 5.3 | History: `.../storefront/FASHIONHUB/account/loyalty/history` | Shows transactions from both stores | + +### Test 6: Void/Return (Journey 7) + +| Step | Action | Expected Result | +|------|--------|-----------------| +| 6.1 | Staff at FASHIONHUB opens terminal, looks up card | Card found | +| 6.2 | Void a stamp or points transaction | Balance adjusted | +| 6.3 | Check transaction history | Void transaction appears, linked to original | + +### Test 7: Admin Oversight (Journey 6) + +| Step | Action | Expected Result | +|------|--------|-----------------| +| 7.1 | Login as `samir.boulahtit@gmail.com` (admin) | Admin dashboard loads | +| 7.2 | Programs: `.../admin/loyalty/programs` | Fashion Group program visible | +| 7.3 | Fashion Group detail: `.../admin/loyalty/merchants/2` | Cards, transactions, stats appear correctly | +| 7.4 | Fashion Group settings: `.../admin/loyalty/merchants/2/settings` | Cross-location toggle visible and correct | + +### Test 8: Cross-Location Disabled Behavior + +| Step | Action | Expected Result | +|------|--------|-----------------| +| 8.1 | Admin disables Cross-Location Redemption for Fashion Group | Setting saved | +| 8.2 | Enroll a **new email** at FASHIONHUB | New card created for FASHIONHUB | +| 8.3 | Enroll **same email** at FASHIONOUTLET | **New card created** for FASHIONOUTLET (separate card) | +| 8.4 | Enroll **same email** at FASHIONHUB again | "Already a member" — shows "Your card is registered at Fashion Hub" (single store, no list) | +| 8.5 | Staff at FASHIONOUTLET searches by email | Only finds the FASHIONOUTLET card (no cross-store search) | +| 8.6 | Re-enable Cross-Location Redemption when done | Restore default state | + +### Key Things to Watch + +- [ ] Birthday persisted after enrollment (check DB) +- [ ] No i18n flicker or console warnings on success page +- [ ] Cross-store email search works in the terminal (cross-location enabled) +- [ ] "Already a member" message shows correct locations/store based on cross-location setting +- [ ] No duplicate cards created under same merchant (when cross-location enabled) +- [ ] Rate limiting: rapid-fire stamp calls eventually return 429 diff --git a/app/modules/loyalty/locales/de.json b/app/modules/loyalty/locales/de.json index ce9d0308..9b6c5f9e 100644 --- a/app/modules/loyalty/locales/de.json +++ b/app/modules/loyalty/locales/de.json @@ -135,6 +135,10 @@ "view_dashboard": "Mein Treue-Dashboard anzeigen", "continue_shopping": "Weiter einkaufen" }, + "already_enrolled_title": "Sie sind bereits Mitglied!", + "cross_location_message": "Ihre Karte gilt an allen unseren Standorten:", + "single_location_message": "Ihre Karte ist bei {store_name} registriert", + "available_locations": "Nutzen Sie Ihre Karte an allen unseren Standorten:", "errors": { "load_failed": "Programminformationen konnten nicht geladen werden", "email_exists": "Diese E-Mail ist bereits in unserem Treueprogramm registriert.", diff --git a/app/modules/loyalty/locales/en.json b/app/modules/loyalty/locales/en.json index 403cde9a..949eecdd 100644 --- a/app/modules/loyalty/locales/en.json +++ b/app/modules/loyalty/locales/en.json @@ -135,6 +135,10 @@ "view_dashboard": "View My Loyalty Dashboard", "continue_shopping": "Continue Shopping" }, + "already_enrolled_title": "You're already a member!", + "cross_location_message": "Your card works at all our locations:", + "single_location_message": "Your card is registered at {store_name}", + "available_locations": "Use your card at all our locations:", "errors": { "load_failed": "Failed to load program information", "email_exists": "This email is already registered in our loyalty program.", diff --git a/app/modules/loyalty/locales/fr.json b/app/modules/loyalty/locales/fr.json index 8e9f963f..27a1969e 100644 --- a/app/modules/loyalty/locales/fr.json +++ b/app/modules/loyalty/locales/fr.json @@ -135,6 +135,10 @@ "view_dashboard": "Voir mon tableau de bord fidélité", "continue_shopping": "Continuer mes achats" }, + "already_enrolled_title": "Vous êtes déjà membre !", + "cross_location_message": "Votre carte est valable dans tous nos points de vente :", + "single_location_message": "Votre carte est enregistrée chez {store_name}", + "available_locations": "Utilisez votre carte dans tous nos points de vente :", "errors": { "load_failed": "Impossible de charger les informations du programme", "email_exists": "Cet e-mail est déjà inscrit dans notre programme de fidélité.", diff --git a/app/modules/loyalty/locales/lb.json b/app/modules/loyalty/locales/lb.json index f1269d1c..0c4e0ab0 100644 --- a/app/modules/loyalty/locales/lb.json +++ b/app/modules/loyalty/locales/lb.json @@ -135,6 +135,10 @@ "view_dashboard": "Mäin Treie-Dashboard kucken", "continue_shopping": "Weider akafen" }, + "already_enrolled_title": "Dir sidd schonn Member!", + "cross_location_message": "Är Kaart gëllt an all eise Standuerter:", + "single_location_message": "Är Kaart ass bei {store_name} registréiert", + "available_locations": "Benotzt Är Kaart an all eise Standuerter:", "errors": { "load_failed": "Programminformatiounen konnten net gelueden ginn", "email_exists": "Dës E-Mail ass schonn an eisem Treieprogramm registréiert.", diff --git a/app/modules/loyalty/migrations/versions/loyalty_004_relax_card_uniqueness.py b/app/modules/loyalty/migrations/versions/loyalty_004_relax_card_uniqueness.py new file mode 100644 index 00000000..34bc2c00 --- /dev/null +++ b/app/modules/loyalty/migrations/versions/loyalty_004_relax_card_uniqueness.py @@ -0,0 +1,59 @@ +"""loyalty 004 - relax card uniqueness for cross-location support + +Replace the (merchant_id, customer_id) and (customer_id, program_id) +unique indexes with (enrolled_at_store_id, customer_id). This allows +merchants with cross-location redemption DISABLED to issue one card per +store per customer, while merchants with it ENABLED enforce the +per-merchant constraint in the application layer. + +Revision ID: loyalty_004 +Revises: loyalty_003 +Create Date: 2026-04-10 +""" +from alembic import op + +revision = "loyalty_004" +down_revision = "loyalty_003" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + # Drop the old per-merchant unique indexes + op.drop_index("idx_loyalty_card_merchant_customer", table_name="loyalty_cards") + op.drop_index("idx_loyalty_card_customer_program", table_name="loyalty_cards") + + # Keep a non-unique index on (merchant_id, customer_id) for lookups + op.create_index( + "idx_loyalty_card_merchant_customer", + "loyalty_cards", + ["merchant_id", "customer_id"], + unique=False, + ) + + # New unique constraint: one card per customer per store (always valid) + op.create_index( + "idx_loyalty_card_store_customer", + "loyalty_cards", + ["enrolled_at_store_id", "customer_id"], + unique=True, + ) + + +def downgrade() -> None: + op.drop_index("idx_loyalty_card_store_customer", table_name="loyalty_cards") + op.drop_index("idx_loyalty_card_merchant_customer", table_name="loyalty_cards") + + # Restore original unique indexes + op.create_index( + "idx_loyalty_card_merchant_customer", + "loyalty_cards", + ["merchant_id", "customer_id"], + unique=True, + ) + op.create_index( + "idx_loyalty_card_customer_program", + "loyalty_cards", + ["customer_id", "program_id"], + unique=True, + ) diff --git a/app/modules/loyalty/models/loyalty_card.py b/app/modules/loyalty/models/loyalty_card.py index a077cb11..9862ecbc 100644 --- a/app/modules/loyalty/models/loyalty_card.py +++ b/app/modules/loyalty/models/loyalty_card.py @@ -255,11 +255,14 @@ class LoyaltyCard(Base, TimestampMixin, SoftDeleteMixin): cascade="all, delete-orphan", ) - # Indexes - one card per customer per merchant + # Indexes + # One card per customer per store (always enforced at DB level). + # Per-merchant uniqueness (when cross-location is enabled) is enforced + # by the application layer in enroll_customer(). __table_args__ = ( - Index("idx_loyalty_card_merchant_customer", "merchant_id", "customer_id", unique=True), + Index("idx_loyalty_card_store_customer", "enrolled_at_store_id", "customer_id", unique=True), + Index("idx_loyalty_card_merchant_customer", "merchant_id", "customer_id"), Index("idx_loyalty_card_merchant_active", "merchant_id", "is_active"), - Index("idx_loyalty_card_customer_program", "customer_id", "program_id", unique=True), # Balances must never go negative — guards against direct SQL writes # bypassing the service layer's clamping logic. CheckConstraint("points_balance >= 0", name="ck_loyalty_cards_points_balance_nonneg"), diff --git a/app/modules/loyalty/routes/api/store.py b/app/modules/loyalty/routes/api/store.py index a40a28f3..2ed9add3 100644 --- a/app/modules/loyalty/routes/api/store.py +++ b/app/modules/loyalty/routes/api/store.py @@ -540,11 +540,18 @@ def enroll_customer( """ store_id = current_user.token_store_id + # Resolve merchant_id for cross-store customer lookup + from app.modules.tenancy.services.store_service import store_service + + store = store_service.get_store_by_id_optional(db, store_id) + merchant_id = store.merchant_id if store else None + customer_id = card_service.resolve_customer_id( db, customer_id=data.customer_id, email=data.email, store_id=store_id, + merchant_id=merchant_id, ) card = card_service.enroll_customer_for_store(db, customer_id, store_id) diff --git a/app/modules/loyalty/routes/api/storefront.py b/app/modules/loyalty/routes/api/storefront.py index ecaffecf..97cce4ab 100644 --- a/app/modules/loyalty/routes/api/storefront.py +++ b/app/modules/loyalty/routes/api/storefront.py @@ -19,6 +19,7 @@ from sqlalchemy.orm import Session from app.api.deps import get_current_customer_api from app.core.database import get_db from app.modules.customers.schemas import CustomerContext +from app.modules.loyalty.exceptions import LoyaltyCardAlreadyExistsException from app.modules.loyalty.schemas import ( CardEnrollRequest, CardResponse, @@ -88,6 +89,7 @@ def self_enroll( customer_id=data.customer_id, email=data.email, store_id=store.id, + merchant_id=store.merchant_id, create_if_missing=True, customer_name=data.customer_name, customer_phone=data.customer_phone, @@ -96,10 +98,45 @@ def self_enroll( logger.info(f"Self-enrollment for customer {customer_id} at store {store.subdomain}") - card = card_service.enroll_customer_for_store(db, customer_id, store.id) + # Build merchant context for the response (locations, cross-location flag) + settings = program_service.get_merchant_settings(db, store.merchant_id) + allow_cross_location = ( + settings.allow_cross_location_redemption if settings else True + ) + locations = program_service.get_merchant_locations(db, store.merchant_id) + location_list = [ + {"id": loc.id, "name": loc.name} + for loc in locations + ] + + already_enrolled = False + try: + card = card_service.enroll_customer_for_store(db, customer_id, store.id) + except LoyaltyCardAlreadyExistsException: + # Customer already has a card — return it instead of erroring out. + # For cross-location=true this is the normal re-enroll-at-another-store + # path; for cross-location=false this is a same-store re-enroll. + already_enrolled = True + if allow_cross_location: + card = card_service.get_card_by_customer_and_merchant( + db, customer_id, store.merchant_id + ) + else: + card = card_service.get_card_by_customer_and_store( + db, customer_id, store.id + ) + program = card.program wallet_urls = wallet_service.get_add_to_wallet_urls(db, card) + # Resolve the name of the original enrollment store + enrolled_at_store_name = None + if card.enrolled_at_store_id: + for loc in locations: + if loc.id == card.enrolled_at_store_id: + enrolled_at_store_name = loc.name + break + return { "card": CardResponse( id=card.id, @@ -122,6 +159,10 @@ def self_enroll( has_apple_wallet=bool(card.apple_serial_number), ), "wallet_urls": wallet_urls, + "already_enrolled": already_enrolled, + "allow_cross_location": allow_cross_location, + "enrolled_at_store_name": enrolled_at_store_name, + "merchant_locations": location_list, } diff --git a/app/modules/loyalty/routes/pages/storefront.py b/app/modules/loyalty/routes/pages/storefront.py index 2327c672..2536fa1e 100644 --- a/app/modules/loyalty/routes/pages/storefront.py +++ b/app/modules/loyalty/routes/pages/storefront.py @@ -133,6 +133,7 @@ async def loyalty_self_enrollment( async def loyalty_enrollment_success( request: Request, card: str = Query(None, description="Card number"), + already: str = Query(None, description="Already enrolled flag"), db: Session = Depends(get_db), ): """ @@ -149,6 +150,27 @@ async def loyalty_enrollment_success( context = get_storefront_context(request, db=db) context["enrolled_card_number"] = card + + # Provide merchant locations and cross-location flag server-side so + # the template doesn't depend on sessionStorage surviving refreshes. + store = getattr(request.state, "store", None) + if store: + from app.modules.loyalty.services import program_service + + settings = program_service.get_merchant_settings(db, store.merchant_id) + locations = program_service.get_merchant_locations(db, store.merchant_id) + context["server_already_enrolled"] = already == "1" + context["server_allow_cross_location"] = ( + settings.allow_cross_location_redemption if settings else True + ) + context["server_merchant_locations"] = [ + {"id": loc.id, "name": loc.name} for loc in locations + ] + else: + context["server_already_enrolled"] = False + context["server_allow_cross_location"] = True + context["server_merchant_locations"] = [] + return templates.TemplateResponse( "loyalty/storefront/enroll-success.html", context, diff --git a/app/modules/loyalty/services/card_service.py b/app/modules/loyalty/services/card_service.py index 38b70741..60facce3 100644 --- a/app/modules/loyalty/services/card_service.py +++ b/app/modules/loyalty/services/card_service.py @@ -119,6 +119,23 @@ class CardService: .first() ) + def get_card_by_customer_and_store( + self, + db: Session, + customer_id: int, + store_id: int, + ) -> LoyaltyCard | None: + """Get a customer's card for a specific store.""" + return ( + db.query(LoyaltyCard) + .options(joinedload(LoyaltyCard.program)) + .filter( + LoyaltyCard.customer_id == customer_id, + LoyaltyCard.enrolled_at_store_id == store_id, + ) + .first() + ) + def get_card_by_customer_and_program( self, db: Session, @@ -166,6 +183,7 @@ class CardService: customer_id: int | None, email: str | None, store_id: int, + merchant_id: int | None = None, create_if_missing: bool = False, customer_name: str | None = None, customer_phone: str | None = None, @@ -179,6 +197,7 @@ class CardService: customer_id: Direct customer ID (used if provided) email: Customer email to look up store_id: Store ID for scoping the email lookup + merchant_id: Merchant ID for cross-store loyalty card lookup create_if_missing: If True, create customer when email not found (used for self-enrollment) customer_name: Full name for customer creation @@ -196,6 +215,9 @@ class CardService: return customer_id if email: + from app.modules.customers.models.customer import ( + Customer as CustomerModel, + ) from app.modules.customers.services.customer_service import ( customer_service, ) @@ -210,6 +232,29 @@ class CardService: db.flush() return customer.id + # Customers are store-scoped, but loyalty cards are merchant-scoped. + # Check if this email already has a card under the same merchant at + # a different store — if so, reuse that customer_id so the duplicate + # check in enroll_customer() fires correctly. + if merchant_id: + existing_cardholder = ( + db.query(CustomerModel) + .join( + LoyaltyCard, + CustomerModel.id == LoyaltyCard.customer_id, + ) + .filter( + CustomerModel.email == email.lower(), + LoyaltyCard.merchant_id == merchant_id, + ) + .first() + ) + if existing_cardholder: + if customer_birthday and not existing_cardholder.birth_date: + existing_cardholder.birth_date = customer_birthday + db.flush() + return existing_cardholder.id + if create_if_missing: # Parse name into first/last first_name = customer_name or "" @@ -347,18 +392,45 @@ class CardService: merchant_id = store.merchant_id - # Try card number + # Try card number — always merchant-scoped card = self.get_card_by_number(db, query) if card and card.merchant_id == merchant_id: return card - # Try customer email + # Try customer email — first at this store customer = customer_service.get_customer_by_email(db, store_id, query) if customer: card = self.get_card_by_customer_and_merchant(db, customer.id, merchant_id) if card: return card + # Cross-store email search: the customer may have enrolled at a + # different store under the same merchant. Only search when + # cross-location redemption is enabled. + from app.modules.customers.models.customer import Customer as CustomerModel + from app.modules.loyalty.services.program_service import program_service + + settings = program_service.get_merchant_settings(db, merchant_id) + cross_location_enabled = ( + settings.allow_cross_location_redemption if settings else True + ) + if cross_location_enabled: + cross_store_customer = ( + db.query(CustomerModel) + .join(LoyaltyCard, CustomerModel.id == LoyaltyCard.customer_id) + .filter( + CustomerModel.email == query.lower(), + LoyaltyCard.merchant_id == merchant_id, + ) + .first() + ) + if cross_store_customer: + card = self.get_card_by_customer_and_merchant( + db, cross_store_customer.id, merchant_id + ) + if card: + return card + return None def list_cards( @@ -479,10 +551,32 @@ class CardService: if not program.is_active: raise LoyaltyProgramInactiveException(program.id) - # Check if customer already has a card - existing = self.get_card_by_customer_and_merchant(db, customer_id, merchant_id) - if existing: - raise LoyaltyCardAlreadyExistsException(customer_id, program.id) + # Check for duplicate enrollment — the scope depends on whether + # cross-location redemption is enabled for this merchant. + from app.modules.loyalty.services.program_service import program_service + + settings = program_service.get_merchant_settings(db, merchant_id) + if settings and not settings.allow_cross_location_redemption: + # Per-store cards: only block if the customer already has a card + # at THIS specific store. Cards at other stores are allowed. + if enrolled_at_store_id: + existing = ( + db.query(LoyaltyCard) + .filter( + LoyaltyCard.customer_id == customer_id, + LoyaltyCard.enrolled_at_store_id == enrolled_at_store_id, + ) + .first() + ) + if existing: + raise LoyaltyCardAlreadyExistsException(customer_id, program.id) + else: + # Cross-location enabled (default): one card per merchant + existing = self.get_card_by_customer_and_merchant( + db, customer_id, merchant_id + ) + if existing: + raise LoyaltyCardAlreadyExistsException(customer_id, program.id) # Create the card card = LoyaltyCard( diff --git a/app/modules/loyalty/static/storefront/js/loyalty-enroll.js b/app/modules/loyalty/static/storefront/js/loyalty-enroll.js index 64d8d488..8ad2e834 100644 --- a/app/modules/loyalty/static/storefront/js/loyalty-enroll.js +++ b/app/modules/loyalty/static/storefront/js/loyalty-enroll.js @@ -81,10 +81,21 @@ function customerLoyaltyEnroll() { sessionStorage.setItem('loyalty_wallet_urls', JSON.stringify(response.wallet_urls)); } - // Redirect to success page + // Store enrollment context for the success page + sessionStorage.setItem('loyalty_enroll_context', JSON.stringify({ + already_enrolled: response.already_enrolled || false, + allow_cross_location: response.allow_cross_location ?? true, + enrolled_at_store_name: response.enrolled_at_store_name || null, + merchant_locations: response.merchant_locations || [], + })); + + // Redirect to success page — pass already_enrolled in the + // URL so the message survives page refreshes (sessionStorage + // is supplementary for the location list). const currentPath = window.location.pathname; + const alreadyFlag = response.already_enrolled ? '&already=1' : ''; const successUrl = currentPath.replace(/\/join\/?$/, '/join/success') + - '?card=' + encodeURIComponent(cardNumber); + '?card=' + encodeURIComponent(cardNumber) + alreadyFlag; window.location.href = successUrl; } } catch (error) { diff --git a/app/modules/loyalty/templates/loyalty/storefront/dashboard.html b/app/modules/loyalty/templates/loyalty/storefront/dashboard.html index 4e70cecd..098fa351 100644 --- a/app/modules/loyalty/templates/loyalty/storefront/dashboard.html +++ b/app/modules/loyalty/templates/loyalty/storefront/dashboard.html @@ -105,12 +105,12 @@ @@ -208,14 +208,14 @@ diff --git a/app/modules/loyalty/templates/loyalty/storefront/enroll-success.html b/app/modules/loyalty/templates/loyalty/storefront/enroll-success.html index 6ef2d917..92332dd8 100644 --- a/app/modules/loyalty/templates/loyalty/storefront/enroll-success.html +++ b/app/modules/loyalty/templates/loyalty/storefront/enroll-success.html @@ -3,6 +3,7 @@ {% block title %}{{ _('loyalty.enrollment.success.title') }} - {{ store.name }}{% endblock %} +{% block i18n_modules %}['loyalty']{% endblock %} {% block alpine_data %}customerLoyaltyEnrollSuccess(){% endblock %} {% block content %} @@ -16,7 +17,10 @@ -

{{ _('loyalty.enrollment.success.title') }}

+

+ {{ _('loyalty.enrollment.success.title') }} +

{{ _('loyalty.enrollment.success.message') }}

@@ -33,14 +37,14 @@ @@ -48,6 +52,41 @@ + + +

{{ _('loyalty.enrollment.success.next_steps_title') }}

@@ -89,6 +128,20 @@ function customerLoyaltyEnrollSuccess() { return { ...storefrontLayoutData(), walletUrls: { google_wallet_url: null, apple_wallet_url: null }, + // Server-rendered context — no flicker, survives refreshes + enrollContext: { + already_enrolled: {{ server_already_enrolled|tojson }}, + allow_cross_location: {{ server_allow_cross_location|tojson }}, + enrolled_at_store_name: null, + merchant_locations: {{ server_merchant_locations|tojson }}, + }, + i18nStrings: { + success_title: {{ _('loyalty.enrollment.success.title')|tojson }}, + already_enrolled_title: {{ _('loyalty.enrollment.already_enrolled_title')|tojson }}, + cross_location_message: {{ _('loyalty.enrollment.cross_location_message')|tojson }}, + single_location_message: {{ _('loyalty.enrollment.single_location_message')|tojson }}, + available_locations: {{ _('loyalty.enrollment.available_locations')|tojson }}, + }, init() { // Read wallet URLs saved during enrollment (no auth needed) @@ -101,6 +154,20 @@ function customerLoyaltyEnrollSuccess() { } catch (e) { console.log('Could not load wallet URLs:', e.message); } + + // Merge sessionStorage context (has enrolled_at_store_name from + // the enrollment API response) into the server-rendered defaults + try { + const ctx = sessionStorage.getItem('loyalty_enroll_context'); + if (ctx) { + const parsed = JSON.parse(ctx); + if (parsed.enrolled_at_store_name) { + this.enrollContext.enrolled_at_store_name = parsed.enrolled_at_store_name; + } + } + } catch (e) { + console.log('Could not load enroll context:', e.message); + } } }; } diff --git a/app/modules/loyalty/templates/loyalty/storefront/history.html b/app/modules/loyalty/templates/loyalty/storefront/history.html index 91faa089..083fa845 100644 --- a/app/modules/loyalty/templates/loyalty/storefront/history.html +++ b/app/modules/loyalty/templates/loyalty/storefront/history.html @@ -93,7 +93,7 @@ {{ _('loyalty.storefront.history.previous') }} + x-text="'{{ _('loyalty.storefront.history.page_x_of_y') }}'.replace('{page}', pagination.page).replace('{pages}', pagination.pages)">