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:
@@ -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