# app/modules/loyalty/services/card_service.py """ Loyalty card service. Handles card operations including: - Customer enrollment - Card lookup (by ID, QR code, card number) - Card management (activation, deactivation) """ import logging from datetime import UTC, datetime from sqlalchemy.orm import Session, joinedload from app.modules.loyalty.exceptions import ( LoyaltyCardAlreadyExistsException, LoyaltyCardNotFoundException, LoyaltyProgramInactiveException, LoyaltyProgramNotFoundException, ) from app.modules.loyalty.models import LoyaltyCard, LoyaltyProgram, LoyaltyTransaction, TransactionType logger = logging.getLogger(__name__) class CardService: """Service for loyalty card operations.""" # ========================================================================= # Read Operations # ========================================================================= def get_card(self, db: Session, card_id: int) -> LoyaltyCard | None: """Get a loyalty card by ID.""" return ( db.query(LoyaltyCard) .options(joinedload(LoyaltyCard.program)) .filter(LoyaltyCard.id == card_id) .first() ) def get_card_by_qr_code(self, db: Session, qr_code: str) -> LoyaltyCard | None: """Get a loyalty card by QR code data.""" return ( db.query(LoyaltyCard) .options(joinedload(LoyaltyCard.program)) .filter(LoyaltyCard.qr_code_data == qr_code) .first() ) def get_card_by_number(self, db: Session, card_number: str) -> LoyaltyCard | None: """Get a loyalty card by card number.""" return ( db.query(LoyaltyCard) .options(joinedload(LoyaltyCard.program)) .filter(LoyaltyCard.card_number == card_number) .first() ) def get_card_by_customer_and_program( self, db: Session, customer_id: int, program_id: int, ) -> LoyaltyCard | None: """Get a customer's card for a specific program.""" return ( db.query(LoyaltyCard) .options(joinedload(LoyaltyCard.program)) .filter( LoyaltyCard.customer_id == customer_id, LoyaltyCard.program_id == program_id, ) .first() ) def require_card(self, db: Session, card_id: int) -> LoyaltyCard: """Get a card or raise exception if not found.""" card = self.get_card(db, card_id) if not card: raise LoyaltyCardNotFoundException(str(card_id)) return card def lookup_card( self, db: Session, *, card_id: int | None = None, qr_code: str | None = None, card_number: str | None = None, ) -> LoyaltyCard: """ Look up a card by any identifier. Args: db: Database session card_id: Card ID qr_code: QR code data card_number: Card number Returns: Found card Raises: LoyaltyCardNotFoundException: If no card found """ card = None if card_id: card = self.get_card(db, card_id) elif qr_code: card = self.get_card_by_qr_code(db, qr_code) elif card_number: card = self.get_card_by_number(db, card_number) if not card: identifier = card_id or qr_code or card_number or "unknown" raise LoyaltyCardNotFoundException(str(identifier)) return card def list_cards( self, db: Session, vendor_id: int, *, skip: int = 0, limit: int = 50, is_active: bool | None = None, search: str | None = None, ) -> tuple[list[LoyaltyCard], int]: """ List loyalty cards for a vendor. Args: db: Database session vendor_id: Vendor ID skip: Pagination offset limit: Pagination limit is_active: Filter by active status search: Search by card number or customer email Returns: (cards, total_count) """ from app.modules.customers.models.customer import Customer query = ( db.query(LoyaltyCard) .options(joinedload(LoyaltyCard.customer)) .filter(LoyaltyCard.vendor_id == vendor_id) ) if is_active is not None: query = query.filter(LoyaltyCard.is_active == is_active) if search: query = query.join(Customer).filter( (LoyaltyCard.card_number.ilike(f"%{search}%")) | (Customer.email.ilike(f"%{search}%")) | (Customer.first_name.ilike(f"%{search}%")) | (Customer.last_name.ilike(f"%{search}%")) ) total = query.count() cards = ( query.order_by(LoyaltyCard.created_at.desc()) .offset(skip) .limit(limit) .all() ) return cards, total def list_customer_cards( self, db: Session, customer_id: int, ) -> list[LoyaltyCard]: """List all loyalty cards for a customer.""" return ( db.query(LoyaltyCard) .options(joinedload(LoyaltyCard.program)) .filter(LoyaltyCard.customer_id == customer_id) .all() ) # ========================================================================= # Write Operations # ========================================================================= def enroll_customer( self, db: Session, customer_id: int, vendor_id: int, *, program_id: int | None = None, ) -> LoyaltyCard: """ Enroll a customer in a loyalty program. Args: db: Database session customer_id: Customer ID vendor_id: Vendor ID program_id: Optional program ID (defaults to vendor's program) Returns: Created loyalty card Raises: LoyaltyProgramNotFoundException: If no program exists LoyaltyProgramInactiveException: If program is inactive LoyaltyCardAlreadyExistsException: If customer already enrolled """ # Get the program if program_id: program = ( db.query(LoyaltyProgram) .filter(LoyaltyProgram.id == program_id) .first() ) else: program = ( db.query(LoyaltyProgram) .filter(LoyaltyProgram.vendor_id == vendor_id) .first() ) if not program: raise LoyaltyProgramNotFoundException(f"vendor:{vendor_id}") if not program.is_active: raise LoyaltyProgramInactiveException(program.id) # Check if customer already has a card existing = self.get_card_by_customer_and_program(db, customer_id, program.id) if existing: raise LoyaltyCardAlreadyExistsException(customer_id, program.id) # Create the card card = LoyaltyCard( customer_id=customer_id, program_id=program.id, vendor_id=vendor_id, ) db.add(card) db.flush() # Get the card ID # Create enrollment transaction transaction = LoyaltyTransaction( card_id=card.id, vendor_id=vendor_id, transaction_type=TransactionType.CARD_CREATED.value, transaction_at=datetime.now(UTC), ) db.add(transaction) db.commit() db.refresh(card) logger.info( f"Enrolled customer {customer_id} in loyalty program {program.id} " f"(card: {card.card_number})" ) return card def deactivate_card(self, db: Session, card_id: int) -> LoyaltyCard: """Deactivate a loyalty card.""" card = self.require_card(db, card_id) card.is_active = False # Create deactivation transaction transaction = LoyaltyTransaction( card_id=card.id, vendor_id=card.vendor_id, transaction_type=TransactionType.CARD_DEACTIVATED.value, transaction_at=datetime.now(UTC), ) db.add(transaction) db.commit() db.refresh(card) logger.info(f"Deactivated loyalty card {card_id}") return card def reactivate_card(self, db: Session, card_id: int) -> LoyaltyCard: """Reactivate a deactivated loyalty card.""" card = self.require_card(db, card_id) card.is_active = True db.commit() db.refresh(card) logger.info(f"Reactivated loyalty card {card_id}") return card # ========================================================================= # Helpers # ========================================================================= def get_stamps_today(self, db: Session, card_id: int) -> int: """Get number of stamps earned today for a card.""" from sqlalchemy import func today_start = datetime.now(UTC).replace(hour=0, minute=0, second=0, microsecond=0) count = ( db.query(func.count(LoyaltyTransaction.id)) .filter( LoyaltyTransaction.card_id == card_id, LoyaltyTransaction.transaction_type == TransactionType.STAMP_EARNED.value, LoyaltyTransaction.transaction_at >= today_start, ) .scalar() ) return count or 0 def get_card_transactions( self, db: Session, card_id: int, *, skip: int = 0, limit: int = 50, ) -> tuple[list[LoyaltyTransaction], int]: """Get transaction history for a card.""" 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() return transactions, total # Singleton instance card_service = CardService()