Files
orion/app/modules/loyalty/routes/api/storefront.py
Samir Boulahtit 82585b1363 fix(loyalty): fix runtime bugs in storefront routes, point expiration, and enforce settings
- Add total_points_voided column to LoyaltyCard with migration (loyalty_002)
- Fix storefront self_enroll to use correct service method signature and schema fields
- Fix get_my_card/get_my_transactions to use get_card_by_customer_and_merchant
- Fix transaction history field reference (balance_after -> points_balance_after)
- Fix point_expiration task: wrong field names and manual balance update -> card.expire_points()
- Register storefront_router in definition.py and export all routers from __init__.py
- Enforce MerchantLoyaltySettings in storefront enrollment, points, and stamp void operations
- Fix test fixture using non-existent balance_after column
- Suppress intentional architecture validator warnings in templates

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-08 14:20:18 +01:00

233 lines
7.3 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, HTTPException, 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.services import card_service, program_service
from app.modules.loyalty.schemas import (
CardResponse,
CardEnrollRequest,
TransactionListResponse,
TransactionResponse,
ProgramResponse,
)
from app.modules.tenancy.exceptions import StoreNotFoundException
storefront_router = APIRouter()
logger = logging.getLogger(__name__)
# =============================================================================
# Public Endpoints (No Authentication Required)
# =============================================================================
@storefront_router.get("/loyalty/program")
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 = ProgramResponse.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")
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
settings = program_service.get_merchant_settings(db, store.merchant_id)
if settings and not settings.allow_self_enrollment:
raise HTTPException(403, "Self-enrollment is not available")
# Resolve customer_id
customer_id = data.customer_id
if not customer_id and data.email:
from app.modules.customers.models.customer import Customer
customer = (
db.query(Customer)
.filter(Customer.email == data.email, Customer.store_id == store.id)
.first()
)
if not customer:
raise HTTPException(400, "Customer not found with provided email")
customer_id = customer.id
if not customer_id:
raise HTTPException(400, "Either customer_id or email is required")
logger.info(f"Self-enrollment for customer {customer_id} at store {store.subdomain}")
card = card_service.enroll_customer_for_store(db, customer_id, store.id)
return CardResponse.model_validate(card)
# =============================================================================
# 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
from app.modules.tenancy.models import Store as StoreModel
locations = (
db.query(StoreModel)
.filter(StoreModel.merchant_id == program.merchant_id, StoreModel.is_active == True)
.all()
)
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
return {
"card": CardResponse.model_validate(card),
"program": program_response,
"locations": [{"id": v.id, "name": v.name} for v in locations],
}
@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
from app.modules.loyalty.models import LoyaltyTransaction
from app.modules.tenancy.models import Store as StoreModel
query = (
db.query(LoyaltyTransaction)
.filter(LoyaltyTransaction.card_id == card.id)
.order_by(LoyaltyTransaction.transaction_at.desc())
)
total = query.count()
transactions = query.offset(skip).limit(limit).all()
# Build response with store names
tx_responses = []
for tx in transactions:
tx_data = {
"id": tx.id,
"transaction_type": tx.transaction_type.value if hasattr(tx.transaction_type, 'value') else str(tx.transaction_type),
"points_delta": tx.points_delta,
"stamps_delta": tx.stamps_delta,
"points_balance_after": tx.points_balance_after,
"stamps_balance_after": tx.stamps_balance_after,
"transaction_at": tx.transaction_at.isoformat() if tx.transaction_at else None,
"notes": tx.notes,
"store_name": None,
}
if tx.store_id:
store_obj = db.query(StoreModel).filter(StoreModel.id == tx.store_id).first()
if store_obj:
tx_data["store_name"] = store_obj.name
tx_responses.append(tx_data)
return {"transactions": tx_responses, "total": total}