Files
orion/app/modules/loyalty/routes/api/storefront.py
Samir Boulahtit f804ff8442
Some checks failed
CI / ruff (push) Successful in 16s
CI / validate (push) Has been cancelled
CI / dependency-scanning (push) Has been cancelled
CI / docs (push) Has been cancelled
CI / deploy (push) Has been cancelled
CI / pytest (push) Has been cancelled
fix(loyalty): cross-store enrollment, card scoping, i18n flicker
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>
2026-04-11 18:28:19 +02:00

280 lines
9.6 KiB
Python

# app/modules/loyalty/routes/api/storefront.py
"""
Loyalty Module - Storefront API Routes
Customer-facing endpoints for:
- View loyalty card and balance
- View transaction history
- Self-service enrollment
- Get program information
Uses store from middleware context (StoreContextMiddleware).
"""
import logging
from fastapi import APIRouter, Depends, Query, Request
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,
ProgramResponse,
)
from app.modules.loyalty.schemas.program import StorefrontProgramResponse
from app.modules.loyalty.services import card_service, program_service, wallet_service
from app.modules.tenancy.exceptions import StoreNotFoundException
from middleware.decorators import rate_limit
storefront_router = APIRouter()
logger = logging.getLogger(__name__)
# =============================================================================
# Public Endpoints (No Authentication Required)
# =============================================================================
@storefront_router.get("/loyalty/program")
@rate_limit(max_requests=30, window_seconds=60)
def get_program_info(
request: Request,
db: Session = Depends(get_db),
):
"""
Get loyalty program information for current store.
Public endpoint - no authentication required.
"""
store = getattr(request.state, "store", None)
if not store:
raise StoreNotFoundException("context", identifier_type="subdomain")
program = program_service.get_program_by_store(db, store.id)
if not program:
return None
response = StorefrontProgramResponse.model_validate(program)
response.is_stamps_enabled = program.is_stamps_enabled
response.is_points_enabled = program.is_points_enabled
response.display_name = program.display_name
return response
@storefront_router.post("/loyalty/enroll")
@rate_limit(max_requests=10, window_seconds=60)
def self_enroll(
request: Request,
data: CardEnrollRequest,
db: Session = Depends(get_db),
):
"""
Self-service enrollment.
Public endpoint - customers can enroll via QR code without authentication.
"""
store = getattr(request.state, "store", None)
if not store:
raise StoreNotFoundException("context", identifier_type="subdomain")
# Check if self-enrollment is allowed
program_service.check_self_enrollment_allowed(db, store.merchant_id)
# Resolve customer_id (create customer if not found for self-enrollment)
customer_id = card_service.resolve_customer_id(
db,
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,
customer_birthday=data.customer_birthday,
)
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,
card_number=card.card_number,
customer_id=card.customer_id,
merchant_id=card.merchant_id,
program_id=card.program_id,
enrolled_at_store_id=card.enrolled_at_store_id,
stamp_count=card.stamp_count,
stamps_target=program.stamps_target,
stamps_until_reward=max(0, program.stamps_target - card.stamp_count),
total_stamps_earned=card.total_stamps_earned,
stamps_redeemed=card.stamps_redeemed,
points_balance=card.points_balance,
total_points_earned=card.total_points_earned,
points_redeemed=card.points_redeemed,
is_active=card.is_active,
created_at=card.created_at,
has_google_wallet=bool(card.google_object_id),
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,
}
# =============================================================================
# Authenticated Endpoints
# =============================================================================
@storefront_router.get("/loyalty/card")
def get_my_card(
request: Request,
customer: CustomerContext = Depends(get_current_customer_api),
db: Session = Depends(get_db),
):
"""
Get customer's loyalty card and program info.
Returns card details, program info, and available rewards.
"""
store = getattr(request.state, "store", None)
if not store:
raise StoreNotFoundException("context", identifier_type="subdomain")
logger.debug(f"Getting loyalty card for customer {customer.id}")
# Get program
program = program_service.get_program_by_store(db, store.id)
if not program:
return {"card": None, "program": None, "locations": []}
# Look up card by customer ID and merchant
card = card_service.get_card_by_customer_and_merchant(
db,
customer_id=customer.id,
merchant_id=program.merchant_id,
)
if not card:
return {"card": None, "program": None, "locations": []}
# Get merchant locations
locations = program_service.get_merchant_locations(db, program.merchant_id)
program_response = ProgramResponse.model_validate(program)
program_response.is_stamps_enabled = program.is_stamps_enabled
program_response.is_points_enabled = program.is_points_enabled
program_response.display_name = program.display_name
wallet_urls = wallet_service.get_add_to_wallet_urls(db, card)
return {
"card": CardResponse(
id=card.id,
card_number=card.card_number,
customer_id=card.customer_id,
merchant_id=card.merchant_id,
program_id=card.program_id,
enrolled_at_store_id=card.enrolled_at_store_id,
stamp_count=card.stamp_count,
stamps_target=program.stamps_target,
stamps_until_reward=max(0, program.stamps_target - card.stamp_count),
total_stamps_earned=card.total_stamps_earned,
stamps_redeemed=card.stamps_redeemed,
points_balance=card.points_balance,
total_points_earned=card.total_points_earned,
points_redeemed=card.points_redeemed,
is_active=card.is_active,
created_at=card.created_at,
has_google_wallet=bool(card.google_object_id),
has_apple_wallet=bool(card.apple_serial_number),
),
"program": program_response,
"locations": [{"id": v.id, "name": v.name} for v in locations],
"wallet_urls": wallet_urls,
}
@storefront_router.get("/loyalty/transactions")
def get_my_transactions(
request: Request,
skip: int = Query(0, ge=0),
limit: int = Query(20, ge=1, le=100),
customer: CustomerContext = Depends(get_current_customer_api),
db: Session = Depends(get_db),
):
"""
Get customer's loyalty transaction history.
"""
store = getattr(request.state, "store", None)
if not store:
raise StoreNotFoundException("context", identifier_type="subdomain")
logger.debug(f"Getting transactions for customer {customer.id}")
# Get program
program = program_service.get_program_by_store(db, store.id)
if not program:
return {"transactions": [], "total": 0}
# Get card
card = card_service.get_card_by_customer_and_merchant(
db,
customer_id=customer.id,
merchant_id=program.merchant_id,
)
if not card:
return {"transactions": [], "total": 0}
# Get transactions with store names
tx_responses, total = card_service.get_customer_transactions_with_store_names(
db, card.id, skip=skip, limit=limit
)
return {"transactions": tx_responses, "total": total}