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:
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user