# 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.schemas import ( CardEnrollRequest, CardResponse, ProgramResponse, ) 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 = 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") @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, 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}") card = card_service.enroll_customer_for_store(db, customer_id, store.id) program = card.program 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), ), "wallet_urls": wallet_urls, } # ============================================================================= # 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}