# app/modules/loyalty/services/card_service.py """ Loyalty card service. Company-based card operations: - Cards belong to a company's loyalty program - One card per customer per company - Can be used at any vendor within the company Handles card operations including: - Customer enrollment (with welcome bonus) - Card lookup (by ID, QR code, card number, email, phone) - 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.""" # Normalize card number (remove dashes) normalized = card_number.replace("-", "").replace(" ", "") return ( db.query(LoyaltyCard) .options(joinedload(LoyaltyCard.program)) .filter( LoyaltyCard.card_number.replace("-", "") == normalized ) .first() ) def get_card_by_customer_and_company( self, db: Session, customer_id: int, company_id: int, ) -> LoyaltyCard | None: """Get a customer's card for a company's program.""" return ( db.query(LoyaltyCard) .options(joinedload(LoyaltyCard.program)) .filter( LoyaltyCard.customer_id == customer_id, LoyaltyCard.company_id == company_id, ) .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, company_id: int | 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 (with or without dashes) company_id: Optional company filter 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)) # Filter by company if specified if company_id and card.company_id != company_id: raise LoyaltyCardNotFoundException(str(card_id or qr_code or card_number)) return card def lookup_card_for_vendor( self, db: Session, vendor_id: int, *, card_id: int | None = None, qr_code: str | None = None, card_number: str | None = None, ) -> LoyaltyCard: """ Look up a card for a specific vendor (must be in same company). Args: db: Database session vendor_id: Vendor ID (to get company context) card_id: Card ID qr_code: QR code data card_number: Card number Returns: Found card (verified to be in vendor's company) Raises: LoyaltyCardNotFoundException: If no card found or wrong company """ from app.modules.tenancy.models import Vendor vendor = db.query(Vendor).filter(Vendor.id == vendor_id).first() if not vendor: raise LoyaltyCardNotFoundException("vendor not found") return self.lookup_card( db, card_id=card_id, qr_code=qr_code, card_number=card_number, company_id=vendor.company_id, ) def list_cards( self, db: Session, company_id: int, *, vendor_id: int | None = None, skip: int = 0, limit: int = 50, is_active: bool | None = None, search: str | None = None, ) -> tuple[list[LoyaltyCard], int]: """ List loyalty cards for a company. Args: db: Database session company_id: Company ID vendor_id: Optional filter by enrolled vendor skip: Pagination offset limit: Pagination limit is_active: Filter by active status search: Search by card number, email, or name Returns: (cards, total_count) """ from app.modules.customers.models.customer import Customer query = ( db.query(LoyaltyCard) .options(joinedload(LoyaltyCard.customer)) .filter(LoyaltyCard.company_id == company_id) ) if vendor_id: query = query.filter(LoyaltyCard.enrolled_at_vendor_id == vendor_id) if is_active is not None: query = query.filter(LoyaltyCard.is_active == is_active) if search: # Normalize search term for card number matching search_normalized = search.replace("-", "").replace(" ", "") query = query.join(Customer).filter( (LoyaltyCard.card_number.replace("-", "").ilike(f"%{search_normalized}%")) | (Customer.email.ilike(f"%{search}%")) | (Customer.first_name.ilike(f"%{search}%")) | (Customer.last_name.ilike(f"%{search}%")) | (Customer.phone.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), joinedload(LoyaltyCard.company)) .filter(LoyaltyCard.customer_id == customer_id) .all() ) # ========================================================================= # Write Operations # ========================================================================= def enroll_customer( self, db: Session, customer_id: int, company_id: int, *, enrolled_at_vendor_id: int | None = None, ) -> LoyaltyCard: """ Enroll a customer in a company's loyalty program. Args: db: Database session customer_id: Customer ID company_id: Company ID enrolled_at_vendor_id: Vendor where customer enrolled (for analytics) Returns: Created loyalty card Raises: LoyaltyProgramNotFoundException: If no program exists LoyaltyProgramInactiveException: If program is inactive LoyaltyCardAlreadyExistsException: If customer already enrolled """ # Get the program program = ( db.query(LoyaltyProgram) .filter(LoyaltyProgram.company_id == company_id) .first() ) if not program: raise LoyaltyProgramNotFoundException(f"company:{company_id}") if not program.is_active: raise LoyaltyProgramInactiveException(program.id) # Check if customer already has a card existing = self.get_card_by_customer_and_company(db, customer_id, company_id) if existing: raise LoyaltyCardAlreadyExistsException(customer_id, program.id) # Create the card card = LoyaltyCard( company_id=company_id, customer_id=customer_id, program_id=program.id, enrolled_at_vendor_id=enrolled_at_vendor_id, ) db.add(card) db.flush() # Get the card ID # Create enrollment transaction transaction = LoyaltyTransaction( company_id=company_id, card_id=card.id, vendor_id=enrolled_at_vendor_id, transaction_type=TransactionType.CARD_CREATED.value, transaction_at=datetime.now(UTC), ) db.add(transaction) # Award welcome bonus if configured if program.welcome_bonus_points > 0: card.add_points(program.welcome_bonus_points) bonus_transaction = LoyaltyTransaction( company_id=company_id, card_id=card.id, vendor_id=enrolled_at_vendor_id, transaction_type=TransactionType.WELCOME_BONUS.value, points_delta=program.welcome_bonus_points, points_balance_after=card.points_balance, notes="Welcome bonus on enrollment", transaction_at=datetime.now(UTC), ) db.add(bonus_transaction) db.commit() db.refresh(card) logger.info( f"Enrolled customer {customer_id} in company {company_id} loyalty program " f"(card: {card.card_number}, bonus: {program.welcome_bonus_points} pts)" ) return card def enroll_customer_for_vendor( self, db: Session, customer_id: int, vendor_id: int, ) -> LoyaltyCard: """ Enroll a customer through a specific vendor. Looks up the vendor's company and enrolls in the company's program. Args: db: Database session customer_id: Customer ID vendor_id: Vendor ID Returns: Created loyalty card """ from app.modules.tenancy.models import Vendor vendor = db.query(Vendor).filter(Vendor.id == vendor_id).first() if not vendor: raise LoyaltyProgramNotFoundException(f"vendor:{vendor_id}") return self.enroll_customer( db, customer_id, vendor.company_id, enrolled_at_vendor_id=vendor_id, ) def deactivate_card( self, db: Session, card_id: int, *, vendor_id: int | None = None, ) -> LoyaltyCard: """Deactivate a loyalty card.""" card = self.require_card(db, card_id) card.is_active = False # Create deactivation transaction transaction = LoyaltyTransaction( company_id=card.company_id, card_id=card.id, vendor_id=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) .options(joinedload(LoyaltyTransaction.vendor)) .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()