# app/modules/loyalty/routes/api/vendor.py """ Loyalty module vendor routes. Store/vendor endpoints for: - Program management - Staff PINs - Card operations (stamps, points, redemptions) - Customer cards lookup - Dashboard stats """ import logging from fastapi import APIRouter, Depends, HTTPException, Path, Query, Request from sqlalchemy.orm import Session from app.api.deps import get_current_vendor_api, require_module_access from app.core.database import get_db from app.modules.loyalty.exceptions import ( LoyaltyCardNotFoundException, LoyaltyException, LoyaltyProgramNotFoundException, ) from app.modules.loyalty.schemas import ( CardDetailResponse, CardEnrollRequest, CardListResponse, CardLookupResponse, CardResponse, PinCreate, PinListResponse, PinResponse, PinUpdate, PointsEarnRequest, PointsEarnResponse, PointsRedeemRequest, PointsRedeemResponse, ProgramCreate, ProgramResponse, ProgramStatsResponse, ProgramUpdate, StampRedeemRequest, StampRedeemResponse, StampRequest, StampResponse, ) from app.modules.loyalty.services import ( card_service, pin_service, points_service, program_service, stamp_service, wallet_service, ) from models.database.user import User logger = logging.getLogger(__name__) # Vendor router with module access control vendor_router = APIRouter( prefix="/loyalty", dependencies=[Depends(require_module_access("loyalty"))], ) def get_client_info(request: Request) -> tuple[str | None, str | None]: """Extract client IP and user agent from request.""" ip = request.client.host if request.client else None user_agent = request.headers.get("user-agent") return ip, user_agent # ============================================================================= # Program Management # ============================================================================= @vendor_router.get("/program", response_model=ProgramResponse) def get_program( current_user: User = Depends(get_current_vendor_api), db: Session = Depends(get_db), ): """Get the vendor's loyalty program.""" vendor_id = current_user.token_vendor_id program = program_service.get_program_by_vendor(db, vendor_id) if not program: raise HTTPException(status_code=404, detail="No loyalty program configured") 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 @vendor_router.post("/program", response_model=ProgramResponse, status_code=201) def create_program( data: ProgramCreate, current_user: User = Depends(get_current_vendor_api), db: Session = Depends(get_db), ): """Create a loyalty program for the vendor.""" vendor_id = current_user.token_vendor_id try: program = program_service.create_program(db, vendor_id, data) except LoyaltyException as e: raise HTTPException(status_code=e.status_code, detail=e.message) 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 @vendor_router.patch("/program", response_model=ProgramResponse) def update_program( data: ProgramUpdate, current_user: User = Depends(get_current_vendor_api), db: Session = Depends(get_db), ): """Update the vendor's loyalty program.""" vendor_id = current_user.token_vendor_id program = program_service.get_program_by_vendor(db, vendor_id) if not program: raise HTTPException(status_code=404, detail="No loyalty program configured") program = program_service.update_program(db, program.id, data) 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 @vendor_router.get("/stats", response_model=ProgramStatsResponse) def get_stats( current_user: User = Depends(get_current_vendor_api), db: Session = Depends(get_db), ): """Get loyalty program statistics.""" vendor_id = current_user.token_vendor_id program = program_service.get_program_by_vendor(db, vendor_id) if not program: raise HTTPException(status_code=404, detail="No loyalty program configured") stats = program_service.get_program_stats(db, program.id) return ProgramStatsResponse(**stats) # ============================================================================= # Staff PINs # ============================================================================= @vendor_router.get("/pins", response_model=PinListResponse) def list_pins( current_user: User = Depends(get_current_vendor_api), db: Session = Depends(get_db), ): """List all staff PINs for the loyalty program.""" vendor_id = current_user.token_vendor_id program = program_service.get_program_by_vendor(db, vendor_id) if not program: raise HTTPException(status_code=404, detail="No loyalty program configured") pins = pin_service.list_pins(db, program.id) return PinListResponse( pins=[PinResponse.model_validate(pin) for pin in pins], total=len(pins), ) @vendor_router.post("/pins", response_model=PinResponse, status_code=201) def create_pin( data: PinCreate, current_user: User = Depends(get_current_vendor_api), db: Session = Depends(get_db), ): """Create a new staff PIN.""" vendor_id = current_user.token_vendor_id program = program_service.get_program_by_vendor(db, vendor_id) if not program: raise HTTPException(status_code=404, detail="No loyalty program configured") pin = pin_service.create_pin(db, program.id, vendor_id, data) return PinResponse.model_validate(pin) @vendor_router.patch("/pins/{pin_id}", response_model=PinResponse) def update_pin( pin_id: int = Path(..., gt=0), data: PinUpdate = None, current_user: User = Depends(get_current_vendor_api), db: Session = Depends(get_db), ): """Update a staff PIN.""" pin = pin_service.update_pin(db, pin_id, data) return PinResponse.model_validate(pin) @vendor_router.delete("/pins/{pin_id}", status_code=204) def delete_pin( pin_id: int = Path(..., gt=0), current_user: User = Depends(get_current_vendor_api), db: Session = Depends(get_db), ): """Delete a staff PIN.""" pin_service.delete_pin(db, pin_id) @vendor_router.post("/pins/{pin_id}/unlock", response_model=PinResponse) def unlock_pin( pin_id: int = Path(..., gt=0), current_user: User = Depends(get_current_vendor_api), db: Session = Depends(get_db), ): """Unlock a locked staff PIN.""" pin = pin_service.unlock_pin(db, pin_id) return PinResponse.model_validate(pin) # ============================================================================= # Card Management # ============================================================================= @vendor_router.get("/cards", response_model=CardListResponse) def list_cards( skip: int = Query(0, ge=0), limit: int = Query(50, ge=1, le=100), is_active: bool | None = Query(None), search: str | None = Query(None, max_length=100), current_user: User = Depends(get_current_vendor_api), db: Session = Depends(get_db), ): """List loyalty cards for the vendor.""" vendor_id = current_user.token_vendor_id program = program_service.get_program_by_vendor(db, vendor_id) if not program: raise HTTPException(status_code=404, detail="No loyalty program configured") cards, total = card_service.list_cards( db, vendor_id, skip=skip, limit=limit, is_active=is_active, search=search, ) card_responses = [] for card in cards: response = CardResponse( id=card.id, card_number=card.card_number, customer_id=card.customer_id, vendor_id=card.vendor_id, program_id=card.program_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), ) card_responses.append(response) return CardListResponse(cards=card_responses, total=total) @vendor_router.post("/cards/lookup", response_model=CardLookupResponse) def lookup_card( request: Request, card_id: int | None = Query(None), qr_code: str | None = Query(None), card_number: str | None = Query(None), current_user: User = Depends(get_current_vendor_api), db: Session = Depends(get_db), ): """Look up a card by ID, QR code, or card number.""" vendor_id = current_user.token_vendor_id try: card = card_service.lookup_card( db, card_id=card_id, qr_code=qr_code, card_number=card_number, ) except LoyaltyCardNotFoundException: raise HTTPException(status_code=404, detail="Card not found") # Verify card belongs to this vendor if card.vendor_id != vendor_id: raise HTTPException(status_code=404, detail="Card not found") program = card.program # Check cooldown can_stamp, _ = card.can_stamp(program.cooldown_minutes) cooldown_ends = None if not can_stamp and card.last_stamp_at: from datetime import timedelta cooldown_ends = card.last_stamp_at + timedelta(minutes=program.cooldown_minutes) # Get stamps today stamps_today = card_service.get_stamps_today(db, card.id) return CardLookupResponse( card_id=card.id, card_number=card.card_number, customer_id=card.customer_id, customer_name=card.customer.full_name if card.customer else None, customer_email=card.customer.email if card.customer else "", stamp_count=card.stamp_count, stamps_target=program.stamps_target, stamps_until_reward=max(0, program.stamps_target - card.stamp_count), points_balance=card.points_balance, can_redeem_stamps=card.stamp_count >= program.stamps_target, stamp_reward_description=program.stamps_reward_description, can_stamp=can_stamp, cooldown_ends_at=cooldown_ends, stamps_today=stamps_today, max_daily_stamps=program.max_daily_stamps, can_earn_more_stamps=stamps_today < program.max_daily_stamps, ) @vendor_router.post("/cards/enroll", response_model=CardResponse, status_code=201) def enroll_customer( data: CardEnrollRequest, current_user: User = Depends(get_current_vendor_api), db: Session = Depends(get_db), ): """Enroll a customer in the loyalty program.""" vendor_id = current_user.token_vendor_id if not data.customer_id: raise HTTPException(status_code=400, detail="customer_id is required") try: card = card_service.enroll_customer(db, data.customer_id, vendor_id) except LoyaltyException as e: raise HTTPException(status_code=e.status_code, detail=e.message) program = card.program return CardResponse( id=card.id, card_number=card.card_number, customer_id=card.customer_id, vendor_id=card.vendor_id, program_id=card.program_id, stamp_count=card.stamp_count, stamps_target=program.stamps_target, stamps_until_reward=program.stamps_target, 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, ) # ============================================================================= # Stamp Operations # ============================================================================= @vendor_router.post("/stamp", response_model=StampResponse) def add_stamp( request: Request, data: StampRequest, current_user: User = Depends(get_current_vendor_api), db: Session = Depends(get_db), ): """Add a stamp to a loyalty card.""" ip, user_agent = get_client_info(request) try: result = stamp_service.add_stamp( db, card_id=data.card_id, qr_code=data.qr_code, card_number=data.card_number, staff_pin=data.staff_pin, ip_address=ip, user_agent=user_agent, notes=data.notes, ) except LoyaltyException as e: raise HTTPException(status_code=e.status_code, detail=e.message) return StampResponse(**result) @vendor_router.post("/stamp/redeem", response_model=StampRedeemResponse) def redeem_stamps( request: Request, data: StampRedeemRequest, current_user: User = Depends(get_current_vendor_api), db: Session = Depends(get_db), ): """Redeem stamps for a reward.""" ip, user_agent = get_client_info(request) try: result = stamp_service.redeem_stamps( db, card_id=data.card_id, qr_code=data.qr_code, card_number=data.card_number, staff_pin=data.staff_pin, ip_address=ip, user_agent=user_agent, notes=data.notes, ) except LoyaltyException as e: raise HTTPException(status_code=e.status_code, detail=e.message) return StampRedeemResponse(**result) # ============================================================================= # Points Operations # ============================================================================= @vendor_router.post("/points", response_model=PointsEarnResponse) def earn_points( request: Request, data: PointsEarnRequest, current_user: User = Depends(get_current_vendor_api), db: Session = Depends(get_db), ): """Earn points from a purchase.""" ip, user_agent = get_client_info(request) try: result = points_service.earn_points( db, card_id=data.card_id, qr_code=data.qr_code, card_number=data.card_number, purchase_amount_cents=data.purchase_amount_cents, order_reference=data.order_reference, staff_pin=data.staff_pin, ip_address=ip, user_agent=user_agent, notes=data.notes, ) except LoyaltyException as e: raise HTTPException(status_code=e.status_code, detail=e.message) return PointsEarnResponse(**result) @vendor_router.post("/points/redeem", response_model=PointsRedeemResponse) def redeem_points( request: Request, data: PointsRedeemRequest, current_user: User = Depends(get_current_vendor_api), db: Session = Depends(get_db), ): """Redeem points for a reward.""" ip, user_agent = get_client_info(request) try: result = points_service.redeem_points( db, card_id=data.card_id, qr_code=data.qr_code, card_number=data.card_number, reward_id=data.reward_id, staff_pin=data.staff_pin, ip_address=ip, user_agent=user_agent, notes=data.notes, ) except LoyaltyException as e: raise HTTPException(status_code=e.status_code, detail=e.message) return PointsRedeemResponse(**result)