fix(loyalty): cross-store enrollment, card scoping, i18n flicker
Some checks failed
Some checks failed
Fix duplicate card creation when the same email enrolls at different stores under the same merchant, and implement cross-location-aware enrollment behavior. - Cross-location enabled (default): one card per customer per merchant. Re-enrolling at another store returns the existing card with a "works at all our locations" message + store list. - Cross-location disabled: one card per customer per store. Enrolling at a different store creates a separate card for that store. Changes: - Migration loyalty_004: replace (merchant_id, customer_id) unique index with (enrolled_at_store_id, customer_id). Per-merchant uniqueness enforced at application layer when cross-location enabled. - card_service.resolve_customer_id: cross-store email lookup via merchant_id param to find existing cardholders at other stores. - card_service.enroll_customer: branch duplicate check on allow_cross_location_redemption setting. - card_service.search_card_for_store: cross-store email search when cross-location enabled so staff at store2 can find cards from store1. - card_service.get_card_by_customer_and_store: new service method. - storefront enrollment: catch LoyaltyCardAlreadyExistsException, return existing card with already_enrolled flag, locations, and cross-location context. Server-rendered i18n via Jinja2 tojson. - enroll-success.html: conditional cross-store/single-store messaging, server-rendered translations and context, i18n_modules block added. - dashboard.html, history.html: replace $t() with server-side _() to fix i18n flicker across all storefront templates. - Fix device-mobile icon → phone icon. - 4 new i18n keys in 4 locales (en, fr, de, lb). - Docs: updated data-model, business-logic, production-launch-plan, user-journeys with cross-location behavior and E2E test checklist. - 12 new unit tests + 3 new integration tests (334 total pass). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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}`.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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é.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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"),
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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}")
|
||||
|
||||
# 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,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,8 +551,30 @@ 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)
|
||||
# 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)
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -105,12 +105,12 @@
|
||||
<template x-if="(card?.points_balance || 0) >= reward.points_required">
|
||||
<span class="inline-flex items-center text-sm font-medium text-green-600">
|
||||
<span x-html="$icon('check-circle', 'w-4 h-4 mr-1')"></span>
|
||||
<span x-text="$t('loyalty.storefront.dashboard.ready_to_redeem')"></span>
|
||||
<span>{{ _('loyalty.storefront.dashboard.ready_to_redeem') }}</span>
|
||||
</span>
|
||||
</template>
|
||||
<template x-if="(card?.points_balance || 0) < reward.points_required">
|
||||
<span class="text-sm text-gray-500"
|
||||
x-text="$t('loyalty.storefront.dashboard.x_more_to_go', {count: reward.points_required - (card?.points_balance || 0)})">
|
||||
x-text="'{{ _('loyalty.storefront.dashboard.x_more_to_go') }}'.replace('{count}', reward.points_required - (card?.points_balance || 0))">
|
||||
</span>
|
||||
</template>
|
||||
</div>
|
||||
@@ -208,14 +208,14 @@
|
||||
<template x-if="walletUrls.apple_wallet_url">
|
||||
<a :href="walletUrls.apple_wallet_url" target="_blank" rel="noopener"
|
||||
class="w-full flex items-center justify-center px-4 py-3 bg-black text-white rounded-lg hover:bg-gray-800 transition-colors">
|
||||
<span x-html="$icon('device-mobile', 'w-5 h-5 mr-2')"></span>
|
||||
<span x-html="$icon('phone', 'w-5 h-5 mr-2')"></span>
|
||||
{{ _('loyalty.loyalty.wallet.apple') }}
|
||||
</a>
|
||||
</template>
|
||||
<template x-if="walletUrls.google_wallet_url">
|
||||
<a :href="walletUrls.google_wallet_url" target="_blank" rel="noopener"
|
||||
class="w-full flex items-center justify-center px-4 py-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors">
|
||||
<span x-html="$icon('device-mobile', 'w-5 h-5 mr-2')"></span>
|
||||
<span x-html="$icon('phone', 'w-5 h-5 mr-2')"></span>
|
||||
{{ _('loyalty.loyalty.wallet.google') }}
|
||||
</a>
|
||||
</template>
|
||||
|
||||
@@ -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 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h1 class="text-3xl font-bold text-gray-900 dark:text-white mb-2">{{ _('loyalty.enrollment.success.title') }}</h1>
|
||||
<h1 class="text-3xl font-bold text-gray-900 dark:text-white mb-2"
|
||||
x-text="enrollContext.already_enrolled ? i18nStrings.already_enrolled_title : i18nStrings.success_title">
|
||||
{{ _('loyalty.enrollment.success.title') }}
|
||||
</h1>
|
||||
<p class="text-gray-600 dark:text-gray-400 mb-8">{{ _('loyalty.enrollment.success.message') }}</p>
|
||||
|
||||
<!-- Card Number Display -->
|
||||
@@ -33,14 +37,14 @@
|
||||
<template x-if="walletUrls.apple_wallet_url">
|
||||
<a :href="walletUrls.apple_wallet_url" target="_blank" rel="noopener"
|
||||
class="w-full flex items-center justify-center px-4 py-3 bg-black text-white rounded-lg hover:bg-gray-800 transition-colors">
|
||||
<span x-html="$icon('device-mobile', 'w-5 h-5 mr-2')"></span>
|
||||
<span x-html="$icon('phone', 'w-5 h-5 mr-2')"></span>
|
||||
{{ _('loyalty.loyalty.wallet.apple') }}
|
||||
</a>
|
||||
</template>
|
||||
<template x-if="walletUrls.google_wallet_url">
|
||||
<a :href="walletUrls.google_wallet_url" target="_blank" rel="noopener"
|
||||
class="w-full flex items-center justify-center px-4 py-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors">
|
||||
<span x-html="$icon('device-mobile', 'w-5 h-5 mr-2')"></span>
|
||||
<span x-html="$icon('phone', 'w-5 h-5 mr-2')"></span>
|
||||
{{ _('loyalty.loyalty.wallet.google') }}
|
||||
</a>
|
||||
</template>
|
||||
@@ -48,6 +52,41 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Cross-location info -->
|
||||
<template x-if="enrollContext.already_enrolled || (enrollContext.merchant_locations?.length > 1 && enrollContext.allow_cross_location)">
|
||||
<div class="bg-blue-50 dark:bg-blue-900/30 border border-blue-200 dark:border-blue-800 rounded-lg p-4 mb-8 text-left">
|
||||
<template x-if="enrollContext.already_enrolled && enrollContext.allow_cross_location">
|
||||
<div>
|
||||
<p class="font-medium text-blue-800 dark:text-blue-200 mb-2" x-text="i18nStrings.cross_location_message"></p>
|
||||
<ul class="space-y-1">
|
||||
<template x-for="loc in enrollContext.merchant_locations" :key="loc.id">
|
||||
<li class="flex items-center text-sm text-blue-700 dark:text-blue-300">
|
||||
<span x-html="$icon('map-pin', 'w-4 h-4 mr-2 flex-shrink-0')"></span>
|
||||
<span x-text="loc.name"></span>
|
||||
</li>
|
||||
</template>
|
||||
</ul>
|
||||
</div>
|
||||
</template>
|
||||
<template x-if="enrollContext.already_enrolled && !enrollContext.allow_cross_location">
|
||||
<p class="text-sm text-blue-700 dark:text-blue-300" x-text="i18nStrings.single_location_message.replace('{store_name}', enrollContext.enrolled_at_store_name || '')"></p>
|
||||
</template>
|
||||
<template x-if="!enrollContext.already_enrolled && enrollContext.merchant_locations?.length > 1 && enrollContext.allow_cross_location">
|
||||
<div>
|
||||
<p class="font-medium text-blue-800 dark:text-blue-200 mb-2" x-text="i18nStrings.available_locations"></p>
|
||||
<ul class="space-y-1">
|
||||
<template x-for="loc in enrollContext.merchant_locations" :key="loc.id">
|
||||
<li class="flex items-center text-sm text-blue-700 dark:text-blue-300">
|
||||
<span x-html="$icon('map-pin', 'w-4 h-4 mr-2 flex-shrink-0')"></span>
|
||||
<span x-text="loc.name"></span>
|
||||
</li>
|
||||
</template>
|
||||
</ul>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Next Steps -->
|
||||
<div class="bg-white dark:bg-gray-800 rounded-lg shadow p-6 text-left mb-8">
|
||||
<h2 class="font-semibold text-gray-900 dark:text-white mb-4">{{ _('loyalty.enrollment.success.next_steps_title') }}</h2>
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -93,7 +93,7 @@
|
||||
{{ _('loyalty.storefront.history.previous') }}
|
||||
</button>
|
||||
<span class="text-sm text-gray-500 dark:text-gray-400"
|
||||
x-text="$t('loyalty.storefront.history.page_x_of_y', {page: pagination.page, pages: pagination.pages})">
|
||||
x-text="'{{ _('loyalty.storefront.history.page_x_of_y') }}'.replace('{page}', pagination.page).replace('{pages}', pagination.pages)">
|
||||
</span>
|
||||
<button @click="nextPage()" :disabled="pagination.page >= pagination.pages"
|
||||
class="px-3 py-1 text-sm border border-gray-300 dark:border-gray-600 rounded hover:bg-gray-50 dark:hover:bg-gray-700 disabled:opacity-50">
|
||||
|
||||
@@ -830,3 +830,248 @@ class TestAdjustPointsRoleGate:
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["success"] is True
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Item 4: Cross-store enrollment
|
||||
# ============================================================================
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def cross_store_setup(db, loyalty_platform):
|
||||
"""Setup with two stores under the same merchant for cross-store tests.
|
||||
|
||||
Creates: merchant → store1 + store2 (each with its own user),
|
||||
program, customer at store1 with card.
|
||||
"""
|
||||
from app.modules.customers.models.customer import Customer
|
||||
from app.modules.tenancy.models import Merchant, Store
|
||||
from app.modules.tenancy.models.store import StoreUser
|
||||
from app.modules.tenancy.models.store_platform import StorePlatform
|
||||
from middleware.auth import AuthManager
|
||||
|
||||
auth = AuthManager()
|
||||
uid = uuid.uuid4().hex[:8]
|
||||
|
||||
# Owner for store1
|
||||
owner1 = User(
|
||||
email=f"xs1own_{uid}@test.com",
|
||||
username=f"xs1own_{uid}",
|
||||
hashed_password=auth.hash_password("storepass123"),
|
||||
role="merchant_owner",
|
||||
is_active=True,
|
||||
is_email_verified=True,
|
||||
)
|
||||
db.add(owner1)
|
||||
db.commit()
|
||||
db.refresh(owner1)
|
||||
|
||||
merchant = Merchant(
|
||||
name=f"Cross-Store Merchant {uid}",
|
||||
owner_user_id=owner1.id,
|
||||
contact_email=owner1.email,
|
||||
is_active=True,
|
||||
is_verified=True,
|
||||
)
|
||||
db.add(merchant)
|
||||
db.commit()
|
||||
db.refresh(merchant)
|
||||
|
||||
# Store 1
|
||||
store1 = Store(
|
||||
merchant_id=merchant.id,
|
||||
store_code=f"XS1_{uid.upper()}",
|
||||
subdomain=f"xs1{uid}",
|
||||
name=f"Cross Store 1 {uid}",
|
||||
is_active=True,
|
||||
is_verified=True,
|
||||
)
|
||||
db.add(store1)
|
||||
db.commit()
|
||||
db.refresh(store1)
|
||||
|
||||
su1 = StoreUser(store_id=store1.id, user_id=owner1.id, is_active=True)
|
||||
db.add(su1)
|
||||
sp1 = StorePlatform(store_id=store1.id, platform_id=loyalty_platform.id)
|
||||
db.add(sp1)
|
||||
db.commit()
|
||||
|
||||
# Separate user for store2 (login always binds to user's first store)
|
||||
owner2 = User(
|
||||
email=f"xs2own_{uid}@test.com",
|
||||
username=f"xs2own_{uid}",
|
||||
hashed_password=auth.hash_password("storepass123"),
|
||||
role="merchant_owner",
|
||||
is_active=True,
|
||||
is_email_verified=True,
|
||||
)
|
||||
db.add(owner2)
|
||||
db.commit()
|
||||
db.refresh(owner2)
|
||||
|
||||
# Store 2
|
||||
store2 = Store(
|
||||
merchant_id=merchant.id,
|
||||
store_code=f"XS2_{uid.upper()}",
|
||||
subdomain=f"xs2{uid}",
|
||||
name=f"Cross Store 2 {uid}",
|
||||
is_active=True,
|
||||
is_verified=True,
|
||||
)
|
||||
db.add(store2)
|
||||
db.commit()
|
||||
db.refresh(store2)
|
||||
|
||||
su2 = StoreUser(store_id=store2.id, user_id=owner2.id, is_active=True)
|
||||
db.add(su2)
|
||||
sp2 = StorePlatform(store_id=store2.id, platform_id=loyalty_platform.id)
|
||||
db.add(sp2)
|
||||
db.commit()
|
||||
|
||||
# Customer at store1
|
||||
customer_email = f"xscust_{uid}@test.com"
|
||||
customer = Customer(
|
||||
email=customer_email,
|
||||
first_name="Cross",
|
||||
last_name="StoreCustomer",
|
||||
hashed_password="!unused!", # noqa: SEC001
|
||||
customer_number=f"XSC-{uid.upper()}",
|
||||
store_id=store1.id,
|
||||
is_active=True,
|
||||
)
|
||||
db.add(customer)
|
||||
db.commit()
|
||||
db.refresh(customer)
|
||||
|
||||
# Program
|
||||
program = LoyaltyProgram(
|
||||
merchant_id=merchant.id,
|
||||
loyalty_type=LoyaltyType.POINTS.value,
|
||||
points_per_euro=10,
|
||||
welcome_bonus_points=0,
|
||||
minimum_redemption_points=100,
|
||||
minimum_purchase_cents=0,
|
||||
cooldown_minutes=0,
|
||||
max_daily_stamps=10,
|
||||
require_staff_pin=False,
|
||||
card_name="Cross Store Rewards",
|
||||
card_color="#4F46E5",
|
||||
is_active=True,
|
||||
points_rewards=[],
|
||||
)
|
||||
db.add(program)
|
||||
db.commit()
|
||||
db.refresh(program)
|
||||
|
||||
# Card enrolled at store1
|
||||
card = LoyaltyCard(
|
||||
merchant_id=merchant.id,
|
||||
program_id=program.id,
|
||||
customer_id=customer.id,
|
||||
enrolled_at_store_id=store1.id,
|
||||
is_active=True,
|
||||
last_activity_at=datetime.now(UTC),
|
||||
)
|
||||
db.add(card)
|
||||
db.commit()
|
||||
db.refresh(card)
|
||||
|
||||
return {
|
||||
"owner1": owner1,
|
||||
"owner2": owner2,
|
||||
"merchant": merchant,
|
||||
"store1": store1,
|
||||
"store2": store2,
|
||||
"customer": customer,
|
||||
"customer_email": customer_email,
|
||||
"program": program,
|
||||
"card": card,
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def cross_store_headers_store2(client, cross_store_setup):
|
||||
"""JWT auth headers bound to store2 (via owner2 who only belongs to store2)."""
|
||||
owner2 = cross_store_setup["owner2"]
|
||||
response = client.post(
|
||||
"/api/v1/store/auth/login",
|
||||
json={"email_or_username": owner2.username, "password": "storepass123"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
token = response.json()["access_token"]
|
||||
return {"Authorization": f"Bearer {token}"}
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.api
|
||||
@pytest.mark.loyalty
|
||||
class TestCrossStoreEnrollment:
|
||||
"""Integration tests for enrollment across stores under the same merchant."""
|
||||
|
||||
def test_enroll_same_email_at_store2_returns_409(
|
||||
self, client, cross_store_headers_store2, cross_store_setup
|
||||
):
|
||||
"""With cross-location enabled (default), enrolling the same email
|
||||
at store2 returns 409 because the customer already has a card under
|
||||
this merchant."""
|
||||
response = client.post(
|
||||
f"{BASE}/cards/enroll",
|
||||
json={"email": cross_store_setup["customer_email"]},
|
||||
headers=cross_store_headers_store2,
|
||||
)
|
||||
assert response.status_code == 409
|
||||
|
||||
def test_enroll_new_customer_at_store2_succeeds(
|
||||
self, client, cross_store_headers_store2, cross_store_setup, db
|
||||
):
|
||||
"""A fresh customer at store2 (no existing card) enrolls normally."""
|
||||
from app.modules.customers.models.customer import Customer
|
||||
|
||||
# Pre-create a customer at store2 (store API requires existing customer)
|
||||
store2 = cross_store_setup["store2"]
|
||||
new_customer = Customer(
|
||||
email=f"newcust_{uuid.uuid4().hex[:8]}@test.com",
|
||||
first_name="New",
|
||||
last_name="Customer",
|
||||
hashed_password="!unused!", # noqa: SEC001
|
||||
customer_number=f"NEW-{uuid.uuid4().hex[:6].upper()}",
|
||||
store_id=store2.id,
|
||||
is_active=True,
|
||||
)
|
||||
db.add(new_customer)
|
||||
db.commit()
|
||||
db.refresh(new_customer)
|
||||
|
||||
response = client.post(
|
||||
f"{BASE}/cards/enroll",
|
||||
json={"customer_id": new_customer.id},
|
||||
headers=cross_store_headers_store2,
|
||||
)
|
||||
assert response.status_code == 201
|
||||
data = response.json()
|
||||
assert data["card_number"] is not None
|
||||
|
||||
def test_enroll_cross_location_disabled_allows_store2_card(
|
||||
self, client, cross_store_headers_store2, cross_store_setup, db
|
||||
):
|
||||
"""With cross-location disabled, enrolling the same email at store2
|
||||
creates a second card for that store."""
|
||||
# Disable cross-location
|
||||
from app.modules.loyalty.services.program_service import program_service
|
||||
|
||||
settings = program_service.get_or_create_merchant_settings(
|
||||
db, cross_store_setup["merchant"].id
|
||||
)
|
||||
settings.allow_cross_location_redemption = False
|
||||
db.commit()
|
||||
|
||||
response = client.post(
|
||||
f"{BASE}/cards/enroll",
|
||||
json={"email": cross_store_setup["customer_email"]},
|
||||
headers=cross_store_headers_store2,
|
||||
)
|
||||
assert response.status_code == 201
|
||||
data = response.json()
|
||||
assert data["card_number"] is not None
|
||||
# Different card from the original
|
||||
assert data["id"] != cross_store_setup["card"].id
|
||||
|
||||
@@ -153,6 +153,69 @@ class TestSearchCardForStore:
|
||||
result = self.service.search_card_for_store(db, test_store.id, "nobody@nowhere.com")
|
||||
assert result is None
|
||||
|
||||
def test_search_email_finds_cross_store_card(self, db, loyalty_store_setup):
|
||||
"""Email search at store2 finds a card enrolled at store1 when
|
||||
cross-location is enabled."""
|
||||
setup = loyalty_store_setup
|
||||
customer = setup["customer"]
|
||||
card = setup["card"]
|
||||
merchant = setup["merchant"]
|
||||
|
||||
from app.modules.tenancy.models import Store
|
||||
|
||||
store2 = Store(
|
||||
merchant_id=merchant.id,
|
||||
store_code=f"XSRCH_{uuid.uuid4().hex[:6].upper()}",
|
||||
subdomain=f"xsrch{uuid.uuid4().hex[:6]}",
|
||||
name="Cross Search Store",
|
||||
is_active=True,
|
||||
is_verified=True,
|
||||
)
|
||||
db.add(store2)
|
||||
db.commit()
|
||||
|
||||
# Cross-location is enabled by default — should find the card
|
||||
result = self.service.search_card_for_store(
|
||||
db, store2.id, customer.email
|
||||
)
|
||||
assert result is not None
|
||||
assert result.id == card.id
|
||||
|
||||
def test_search_email_no_cross_store_when_disabled(
|
||||
self, db, loyalty_store_setup
|
||||
):
|
||||
"""Email search at store2 does NOT find cross-store cards when
|
||||
cross-location is disabled."""
|
||||
setup = loyalty_store_setup
|
||||
customer = setup["customer"]
|
||||
merchant = setup["merchant"]
|
||||
|
||||
from app.modules.loyalty.services.program_service import program_service
|
||||
|
||||
settings = program_service.get_or_create_merchant_settings(
|
||||
db, merchant.id
|
||||
)
|
||||
settings.allow_cross_location_redemption = False
|
||||
db.commit()
|
||||
|
||||
from app.modules.tenancy.models import Store
|
||||
|
||||
store2 = Store(
|
||||
merchant_id=merchant.id,
|
||||
store_code=f"NOSRCH_{uuid.uuid4().hex[:6].upper()}",
|
||||
subdomain=f"nosrch{uuid.uuid4().hex[:6]}",
|
||||
name="No Cross Search Store",
|
||||
is_active=True,
|
||||
is_verified=True,
|
||||
)
|
||||
db.add(store2)
|
||||
db.commit()
|
||||
|
||||
result = self.service.search_card_for_store(
|
||||
db, store2.id, customer.email
|
||||
)
|
||||
assert result is None
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.loyalty
|
||||
@@ -405,3 +468,232 @@ class TestReactivateCardAudit:
|
||||
|
||||
card = self.service.reactivate_card(db, test_loyalty_card.id)
|
||||
assert card.is_active is True
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.loyalty
|
||||
class TestGetCardByCustomerAndStore:
|
||||
"""Tests for the per-store card lookup."""
|
||||
|
||||
def setup_method(self):
|
||||
self.service = CardService()
|
||||
|
||||
def test_finds_card_at_store(self, db, loyalty_store_setup):
|
||||
"""Returns card when customer has one at the given store."""
|
||||
setup = loyalty_store_setup
|
||||
result = self.service.get_card_by_customer_and_store(
|
||||
db, setup["customer"].id, setup["store"].id
|
||||
)
|
||||
assert result is not None
|
||||
assert result.id == setup["card"].id
|
||||
|
||||
def test_returns_none_at_different_store(self, db, loyalty_store_setup):
|
||||
"""Returns None when customer has no card at the given store."""
|
||||
from app.modules.tenancy.models import Store
|
||||
|
||||
setup = loyalty_store_setup
|
||||
store2 = Store(
|
||||
merchant_id=setup["merchant"].id,
|
||||
store_code=f"NOCARD_{uuid.uuid4().hex[:6].upper()}",
|
||||
subdomain=f"nocard{uuid.uuid4().hex[:6]}",
|
||||
name="No Card Store",
|
||||
is_active=True,
|
||||
is_verified=True,
|
||||
)
|
||||
db.add(store2)
|
||||
db.commit()
|
||||
|
||||
result = self.service.get_card_by_customer_and_store(
|
||||
db, setup["customer"].id, store2.id
|
||||
)
|
||||
assert result is None
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.loyalty
|
||||
class TestCrossStoreEnrollment:
|
||||
"""
|
||||
Tests for cross-store enrollment with merchant_id-aware resolution.
|
||||
|
||||
The customer model is store-scoped, but loyalty cards are merchant-scoped.
|
||||
When a customer enrolls at store1 and then at store2 (same merchant),
|
||||
resolve_customer_id should find the existing customer from store1 via
|
||||
the cross-store loyalty card lookup.
|
||||
"""
|
||||
|
||||
def setup_method(self):
|
||||
self.service = CardService()
|
||||
|
||||
def test_resolve_finds_existing_cardholder_across_stores(
|
||||
self, db, loyalty_store_setup
|
||||
):
|
||||
"""Same email at a different store returns the original customer_id
|
||||
when merchant_id is provided and they already have a card."""
|
||||
setup = loyalty_store_setup
|
||||
merchant = setup["merchant"]
|
||||
customer = setup["customer"] # Already has a card at store1
|
||||
|
||||
# Create a second store under the same merchant
|
||||
from app.modules.tenancy.models import Store
|
||||
|
||||
store2 = Store(
|
||||
merchant_id=merchant.id,
|
||||
store_code=f"SECOND_{uuid.uuid4().hex[:6].upper()}",
|
||||
subdomain=f"second{uuid.uuid4().hex[:6]}",
|
||||
name="Second Store",
|
||||
is_active=True,
|
||||
is_verified=True,
|
||||
)
|
||||
db.add(store2)
|
||||
db.commit()
|
||||
db.refresh(store2)
|
||||
|
||||
# Resolve with the same email at store2 — should find
|
||||
# the existing customer from store1 via the loyalty card join
|
||||
result = self.service.resolve_customer_id(
|
||||
db,
|
||||
customer_id=None,
|
||||
email=customer.email,
|
||||
store_id=store2.id,
|
||||
merchant_id=merchant.id,
|
||||
create_if_missing=True,
|
||||
)
|
||||
|
||||
assert result == customer.id
|
||||
|
||||
def test_resolve_without_merchant_id_creates_new_customer(
|
||||
self, db, loyalty_store_setup
|
||||
):
|
||||
"""Without merchant_id, the cross-store lookup is skipped and
|
||||
a new customer is created at the new store."""
|
||||
setup = loyalty_store_setup
|
||||
merchant = setup["merchant"]
|
||||
customer = setup["customer"]
|
||||
|
||||
from app.modules.tenancy.models import Store
|
||||
|
||||
store2 = Store(
|
||||
merchant_id=merchant.id,
|
||||
store_code=f"NOMID_{uuid.uuid4().hex[:6].upper()}",
|
||||
subdomain=f"nomid{uuid.uuid4().hex[:6]}",
|
||||
name="No Merchant ID Store",
|
||||
is_active=True,
|
||||
is_verified=True,
|
||||
)
|
||||
db.add(store2)
|
||||
db.commit()
|
||||
|
||||
result = self.service.resolve_customer_id(
|
||||
db,
|
||||
customer_id=None,
|
||||
email=customer.email,
|
||||
store_id=store2.id,
|
||||
# No merchant_id — cross-store lookup skipped
|
||||
create_if_missing=True,
|
||||
customer_name="New Customer",
|
||||
)
|
||||
|
||||
assert result != customer.id # Different customer created
|
||||
|
||||
def test_enroll_cross_location_enabled_rejects_duplicate(
|
||||
self, db, loyalty_store_setup
|
||||
):
|
||||
"""With cross-location enabled (default), enrolling the same
|
||||
customer_id at a different store raises AlreadyExists."""
|
||||
from app.modules.loyalty.exceptions import (
|
||||
LoyaltyCardAlreadyExistsException,
|
||||
)
|
||||
|
||||
setup = loyalty_store_setup
|
||||
merchant = setup["merchant"]
|
||||
customer = setup["customer"] # Already has a card
|
||||
|
||||
from app.modules.tenancy.models import Store
|
||||
|
||||
store2 = Store(
|
||||
merchant_id=merchant.id,
|
||||
store_code=f"DUP_{uuid.uuid4().hex[:6].upper()}",
|
||||
subdomain=f"dup{uuid.uuid4().hex[:6]}",
|
||||
name="Dup Store",
|
||||
is_active=True,
|
||||
is_verified=True,
|
||||
)
|
||||
db.add(store2)
|
||||
db.commit()
|
||||
|
||||
with pytest.raises(LoyaltyCardAlreadyExistsException):
|
||||
self.service.enroll_customer_for_store(
|
||||
db, customer.id, store2.id
|
||||
)
|
||||
|
||||
def test_enroll_cross_location_disabled_allows_second_card(
|
||||
self, db, loyalty_store_setup
|
||||
):
|
||||
"""With cross-location disabled, the same customer can enroll
|
||||
at a different store and get a separate card."""
|
||||
setup = loyalty_store_setup
|
||||
merchant = setup["merchant"]
|
||||
customer = setup["customer"]
|
||||
|
||||
# Disable cross-location
|
||||
from app.modules.loyalty.services.program_service import (
|
||||
program_service,
|
||||
)
|
||||
|
||||
settings = program_service.get_or_create_merchant_settings(
|
||||
db, merchant.id
|
||||
)
|
||||
settings.allow_cross_location_redemption = False
|
||||
db.commit()
|
||||
|
||||
from app.modules.tenancy.models import Store
|
||||
|
||||
store2 = Store(
|
||||
merchant_id=merchant.id,
|
||||
store_code=f"XLOC_{uuid.uuid4().hex[:6].upper()}",
|
||||
subdomain=f"xloc{uuid.uuid4().hex[:6]}",
|
||||
name="Cross-Loc Store",
|
||||
is_active=True,
|
||||
is_verified=True,
|
||||
)
|
||||
db.add(store2)
|
||||
db.commit()
|
||||
db.refresh(store2)
|
||||
|
||||
# Should succeed — different store, cross-location disabled
|
||||
card2 = self.service.enroll_customer_for_store(
|
||||
db, customer.id, store2.id
|
||||
)
|
||||
assert card2.enrolled_at_store_id == store2.id
|
||||
assert card2.merchant_id == merchant.id
|
||||
assert card2.customer_id == customer.id
|
||||
# Original card still exists
|
||||
assert setup["card"].id != card2.id
|
||||
|
||||
def test_enroll_cross_location_disabled_rejects_same_store(
|
||||
self, db, loyalty_store_setup
|
||||
):
|
||||
"""With cross-location disabled, re-enrolling at the SAME store
|
||||
still raises AlreadyExists."""
|
||||
from app.modules.loyalty.exceptions import (
|
||||
LoyaltyCardAlreadyExistsException,
|
||||
)
|
||||
|
||||
setup = loyalty_store_setup
|
||||
merchant = setup["merchant"]
|
||||
customer = setup["customer"]
|
||||
|
||||
from app.modules.loyalty.services.program_service import (
|
||||
program_service,
|
||||
)
|
||||
|
||||
settings = program_service.get_or_create_merchant_settings(
|
||||
db, merchant.id
|
||||
)
|
||||
settings.allow_cross_location_redemption = False
|
||||
db.commit()
|
||||
|
||||
with pytest.raises(LoyaltyCardAlreadyExistsException):
|
||||
self.service.enroll_customer_for_store(
|
||||
db, customer.id, setup["store"].id
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user