# app/modules/loyalty/routes/api/vendor.py """ Loyalty module vendor routes. Company-based vendor endpoints for: - Program management (company-wide, managed by vendor) - Staff PINs (per-vendor) - Card operations (stamps, points, redemptions, voids) - Customer cards lookup - Dashboard stats All operations are scoped to the vendor's company. Cards can be used at any vendor within the same company. """ 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, PointsVoidRequest, PointsVoidResponse, PointsAdjustRequest, PointsAdjustResponse, ProgramCreate, ProgramResponse, ProgramStatsResponse, ProgramUpdate, CompanyStatsResponse, StampRedeemRequest, StampRedeemResponse, StampRequest, StampResponse, StampVoidRequest, StampVoidResponse, TransactionListResponse, TransactionResponse, ) from app.modules.loyalty.services import ( card_service, pin_service, points_service, program_service, stamp_service, wallet_service, ) from app.modules.enums import FrontendType from app.modules.tenancy.models import User, Vendor logger = logging.getLogger(__name__) # Vendor router with module access control vendor_router = APIRouter( prefix="/loyalty", dependencies=[Depends(require_module_access("loyalty", FrontendType.VENDOR))], ) 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 def get_vendor_company_id(db: Session, vendor_id: int) -> int: """Get the company ID for a vendor.""" vendor = db.query(Vendor).filter(Vendor.id == vendor_id).first() if not vendor: raise HTTPException(status_code=404, detail="Vendor not found") return vendor.company_id # ============================================================================= # 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 company'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 company.""" vendor_id = current_user.token_vendor_id company_id = get_vendor_company_id(db, vendor_id) try: program = program_service.create_program(db, company_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 company'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) @vendor_router.get("/stats/company", response_model=CompanyStatsResponse) def get_company_stats( current_user: User = Depends(get_current_vendor_api), db: Session = Depends(get_db), ): """Get company-wide loyalty statistics across all locations.""" vendor_id = current_user.token_vendor_id company_id = get_vendor_company_id(db, vendor_id) stats = program_service.get_company_stats(db, company_id) if "error" in stats: raise HTTPException(status_code=404, detail=stats["error"]) return CompanyStatsResponse(**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 staff PINs for this vendor location.""" 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") # List PINs for this vendor only pins = pin_service.list_pins(db, program.id, vendor_id=vendor_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 for this vendor location.""" 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), enrolled_here: bool = Query(False, description="Only show cards enrolled at this location"), current_user: User = Depends(get_current_vendor_api), db: Session = Depends(get_db), ): """ List loyalty cards for the company. By default lists all cards in the company's loyalty program. Use enrolled_here=true to filter to cards enrolled at this location. """ vendor_id = current_user.token_vendor_id company_id = get_vendor_company_id(db, 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") # Filter by enrolled_at_vendor_id if requested filter_vendor_id = vendor_id if enrolled_here else None cards, total = card_service.list_cards( db, company_id, vendor_id=filter_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, company_id=card.company_id, program_id=card.program_id, enrolled_at_vendor_id=card.enrolled_at_vendor_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. Card must belong to the same company as the vendor. """ vendor_id = current_user.token_vendor_id try: # Uses lookup_card_for_vendor which validates company membership card = card_service.lookup_card_for_vendor( db, vendor_id, card_id=card_id, qr_code=qr_code, card_number=card_number, ) except LoyaltyCardNotFoundException: 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) # Get available points rewards available_rewards = [] for reward in program.points_rewards or []: if reward.get("is_active", True) and card.points_balance >= reward.get("points_required", 0): available_rewards.append(reward) 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 "", company_id=card.company_id, company_name=card.company.name if card.company else None, 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, available_rewards=available_rewards, 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 company's loyalty program. The card will be associated with the company and track which vendor enrolled them. """ 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_for_vendor(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, company_id=card.company_id, program_id=card.program_id, enrolled_at_vendor_id=card.enrolled_at_vendor_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, ) @vendor_router.get("/cards/{card_id}/transactions", response_model=TransactionListResponse) def get_card_transactions( card_id: int = Path(..., gt=0), skip: int = Query(0, ge=0), limit: int = Query(50, ge=1, le=100), current_user: User = Depends(get_current_vendor_api), db: Session = Depends(get_db), ): """Get transaction history for a card.""" vendor_id = current_user.token_vendor_id # Verify card belongs to this company try: card = card_service.lookup_card_for_vendor(db, vendor_id, card_id=card_id) except LoyaltyCardNotFoundException: raise HTTPException(status_code=404, detail="Card not found") transactions, total = card_service.get_card_transactions( db, card_id, skip=skip, limit=limit ) return TransactionListResponse( transactions=[TransactionResponse.model_validate(t) for t in transactions], total=total, ) # ============================================================================= # 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.""" vendor_id = current_user.token_vendor_id ip, user_agent = get_client_info(request) try: result = stamp_service.add_stamp( db, vendor_id=vendor_id, 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.""" vendor_id = current_user.token_vendor_id ip, user_agent = get_client_info(request) try: result = stamp_service.redeem_stamps( db, vendor_id=vendor_id, 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) @vendor_router.post("/stamp/void", response_model=StampVoidResponse) def void_stamps( request: Request, data: StampVoidRequest, current_user: User = Depends(get_current_vendor_api), db: Session = Depends(get_db), ): """Void stamps for a return.""" vendor_id = current_user.token_vendor_id ip, user_agent = get_client_info(request) try: result = stamp_service.void_stamps( db, vendor_id=vendor_id, card_id=data.card_id, qr_code=data.qr_code, card_number=data.card_number, stamps_to_void=data.stamps_to_void, original_transaction_id=data.original_transaction_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 StampVoidResponse(**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.""" vendor_id = current_user.token_vendor_id ip, user_agent = get_client_info(request) try: result = points_service.earn_points( db, vendor_id=vendor_id, 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.""" vendor_id = current_user.token_vendor_id ip, user_agent = get_client_info(request) try: result = points_service.redeem_points( db, vendor_id=vendor_id, 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) @vendor_router.post("/points/void", response_model=PointsVoidResponse) def void_points( request: Request, data: PointsVoidRequest, current_user: User = Depends(get_current_vendor_api), db: Session = Depends(get_db), ): """Void points for a return.""" vendor_id = current_user.token_vendor_id ip, user_agent = get_client_info(request) try: result = points_service.void_points( db, vendor_id=vendor_id, card_id=data.card_id, qr_code=data.qr_code, card_number=data.card_number, points_to_void=data.points_to_void, original_transaction_id=data.original_transaction_id, 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 PointsVoidResponse(**result) @vendor_router.post("/cards/{card_id}/points/adjust", response_model=PointsAdjustResponse) def adjust_points( request: Request, data: PointsAdjustRequest, card_id: int = Path(..., gt=0), current_user: User = Depends(get_current_vendor_api), db: Session = Depends(get_db), ): """Manually adjust points (vendor operation).""" vendor_id = current_user.token_vendor_id ip, user_agent = get_client_info(request) try: result = points_service.adjust_points( db, card_id=card_id, points_delta=data.points_delta, vendor_id=vendor_id, reason=data.reason, staff_pin=data.staff_pin, ip_address=ip, user_agent=user_agent, ) except LoyaltyException as e: raise HTTPException(status_code=e.status_code, detail=e.message) return PointsAdjustResponse(**result)