# app/modules/loyalty/services/program_service.py """ Loyalty program service. Handles CRUD operations for loyalty programs including: - Program creation and configuration - Program updates - Program activation/deactivation - Statistics retrieval """ import logging from datetime import UTC, datetime from sqlalchemy.orm import Session from app.modules.loyalty.exceptions import ( LoyaltyProgramAlreadyExistsException, LoyaltyProgramNotFoundException, ) from app.modules.loyalty.models import LoyaltyProgram, LoyaltyType from app.modules.loyalty.schemas.program import ( ProgramCreate, ProgramUpdate, ) logger = logging.getLogger(__name__) class ProgramService: """Service for loyalty program operations.""" # ========================================================================= # Read Operations # ========================================================================= def get_program(self, db: Session, program_id: int) -> LoyaltyProgram | None: """Get a loyalty program by ID.""" return ( db.query(LoyaltyProgram) .filter(LoyaltyProgram.id == program_id) .first() ) def get_program_by_vendor(self, db: Session, vendor_id: int) -> LoyaltyProgram | None: """Get a vendor's loyalty program.""" return ( db.query(LoyaltyProgram) .filter(LoyaltyProgram.vendor_id == vendor_id) .first() ) def get_active_program_by_vendor(self, db: Session, vendor_id: int) -> LoyaltyProgram | None: """Get a vendor's active loyalty program.""" return ( db.query(LoyaltyProgram) .filter( LoyaltyProgram.vendor_id == vendor_id, LoyaltyProgram.is_active == True, ) .first() ) def require_program(self, db: Session, program_id: int) -> LoyaltyProgram: """Get a program or raise exception if not found.""" program = self.get_program(db, program_id) if not program: raise LoyaltyProgramNotFoundException(str(program_id)) return program def require_program_by_vendor(self, db: Session, vendor_id: int) -> LoyaltyProgram: """Get a vendor's program or raise exception if not found.""" program = self.get_program_by_vendor(db, vendor_id) if not program: raise LoyaltyProgramNotFoundException(f"vendor:{vendor_id}") return program def list_programs( self, db: Session, *, skip: int = 0, limit: int = 100, is_active: bool | None = None, ) -> tuple[list[LoyaltyProgram], int]: """List all loyalty programs (admin).""" query = db.query(LoyaltyProgram) if is_active is not None: query = query.filter(LoyaltyProgram.is_active == is_active) total = query.count() programs = query.offset(skip).limit(limit).all() return programs, total # ========================================================================= # Write Operations # ========================================================================= def create_program( self, db: Session, vendor_id: int, data: ProgramCreate, ) -> LoyaltyProgram: """ Create a new loyalty program for a vendor. Args: db: Database session vendor_id: Vendor ID data: Program configuration Returns: Created program Raises: LoyaltyProgramAlreadyExistsException: If vendor already has a program """ # Check if vendor already has a program existing = self.get_program_by_vendor(db, vendor_id) if existing: raise LoyaltyProgramAlreadyExistsException(vendor_id) # Convert points_rewards to dict list for JSON storage points_rewards_data = [r.model_dump() for r in data.points_rewards] program = LoyaltyProgram( vendor_id=vendor_id, loyalty_type=data.loyalty_type, # Stamps stamps_target=data.stamps_target, stamps_reward_description=data.stamps_reward_description, stamps_reward_value_cents=data.stamps_reward_value_cents, # Points points_per_euro=data.points_per_euro, points_rewards=points_rewards_data, # Anti-fraud cooldown_minutes=data.cooldown_minutes, max_daily_stamps=data.max_daily_stamps, require_staff_pin=data.require_staff_pin, # Branding card_name=data.card_name, card_color=data.card_color, card_secondary_color=data.card_secondary_color, logo_url=data.logo_url, hero_image_url=data.hero_image_url, # Terms terms_text=data.terms_text, privacy_url=data.privacy_url, # Status is_active=True, activated_at=datetime.now(UTC), ) db.add(program) db.commit() db.refresh(program) logger.info( f"Created loyalty program {program.id} for vendor {vendor_id} " f"(type: {program.loyalty_type})" ) return program def update_program( self, db: Session, program_id: int, data: ProgramUpdate, ) -> LoyaltyProgram: """ Update a loyalty program. Args: db: Database session program_id: Program ID data: Update data Returns: Updated program """ program = self.require_program(db, program_id) update_data = data.model_dump(exclude_unset=True) # Handle points_rewards specially (convert to dict list) if "points_rewards" in update_data and update_data["points_rewards"] is not None: update_data["points_rewards"] = [ r.model_dump() if hasattr(r, "model_dump") else r for r in update_data["points_rewards"] ] for field, value in update_data.items(): setattr(program, field, value) db.commit() db.refresh(program) logger.info(f"Updated loyalty program {program_id}") return program def activate_program(self, db: Session, program_id: int) -> LoyaltyProgram: """Activate a loyalty program.""" program = self.require_program(db, program_id) program.activate() db.commit() db.refresh(program) logger.info(f"Activated loyalty program {program_id}") return program def deactivate_program(self, db: Session, program_id: int) -> LoyaltyProgram: """Deactivate a loyalty program.""" program = self.require_program(db, program_id) program.deactivate() db.commit() db.refresh(program) logger.info(f"Deactivated loyalty program {program_id}") return program def delete_program(self, db: Session, program_id: int) -> None: """Delete a loyalty program and all associated data.""" program = self.require_program(db, program_id) vendor_id = program.vendor_id db.delete(program) db.commit() logger.info(f"Deleted loyalty program {program_id} for vendor {vendor_id}") # ========================================================================= # Statistics # ========================================================================= def get_program_stats(self, db: Session, program_id: int) -> dict: """ Get statistics for a loyalty program. Returns dict with: - total_cards, active_cards - total_stamps_issued, total_stamps_redeemed - total_points_issued, total_points_redeemed - etc. """ from datetime import timedelta from sqlalchemy import func from app.modules.loyalty.models import LoyaltyCard, LoyaltyTransaction program = self.require_program(db, program_id) # Card counts total_cards = ( db.query(func.count(LoyaltyCard.id)) .filter(LoyaltyCard.program_id == program_id) .scalar() or 0 ) active_cards = ( db.query(func.count(LoyaltyCard.id)) .filter( LoyaltyCard.program_id == program_id, LoyaltyCard.is_active == True, ) .scalar() or 0 ) # Stamp totals from cards stamp_stats = ( db.query( func.sum(LoyaltyCard.total_stamps_earned), func.sum(LoyaltyCard.stamps_redeemed), ) .filter(LoyaltyCard.program_id == program_id) .first() ) total_stamps_issued = stamp_stats[0] or 0 total_stamps_redeemed = stamp_stats[1] or 0 # Points totals from cards points_stats = ( db.query( func.sum(LoyaltyCard.total_points_earned), func.sum(LoyaltyCard.points_redeemed), ) .filter(LoyaltyCard.program_id == program_id) .first() ) total_points_issued = points_stats[0] or 0 total_points_redeemed = points_stats[1] or 0 # This month's activity month_start = datetime.now(UTC).replace(day=1, hour=0, minute=0, second=0, microsecond=0) stamps_this_month = ( db.query(func.count(LoyaltyTransaction.id)) .join(LoyaltyCard) .filter( LoyaltyCard.program_id == program_id, LoyaltyTransaction.transaction_type == "stamp_earned", LoyaltyTransaction.transaction_at >= month_start, ) .scalar() or 0 ) redemptions_this_month = ( db.query(func.count(LoyaltyTransaction.id)) .join(LoyaltyCard) .filter( LoyaltyCard.program_id == program_id, LoyaltyTransaction.transaction_type == "stamp_redeemed", LoyaltyTransaction.transaction_at >= month_start, ) .scalar() or 0 ) # 30-day active cards thirty_days_ago = datetime.now(UTC) - timedelta(days=30) cards_with_activity_30d = ( db.query(func.count(func.distinct(LoyaltyTransaction.card_id))) .join(LoyaltyCard) .filter( LoyaltyCard.program_id == program_id, LoyaltyTransaction.transaction_at >= thirty_days_ago, ) .scalar() or 0 ) # Averages avg_stamps = total_stamps_issued / total_cards if total_cards > 0 else 0 avg_points = total_points_issued / total_cards if total_cards > 0 else 0 # Estimated liability (unredeemed value) current_stamps = ( db.query(func.sum(LoyaltyCard.stamp_count)) .filter(LoyaltyCard.program_id == program_id) .scalar() or 0 ) stamp_value = program.stamps_reward_value_cents or 0 current_points = ( db.query(func.sum(LoyaltyCard.points_balance)) .filter(LoyaltyCard.program_id == program_id) .scalar() or 0 ) # Rough estimate: assume 100 points = €1 points_value_cents = current_points // 100 * 100 estimated_liability = ( (current_stamps * stamp_value // program.stamps_target) + points_value_cents ) return { "total_cards": total_cards, "active_cards": active_cards, "total_stamps_issued": total_stamps_issued, "total_stamps_redeemed": total_stamps_redeemed, "stamps_this_month": stamps_this_month, "redemptions_this_month": redemptions_this_month, "total_points_issued": total_points_issued, "total_points_redeemed": total_points_redeemed, "cards_with_activity_30d": cards_with_activity_30d, "average_stamps_per_card": round(avg_stamps, 2), "average_points_per_card": round(avg_points, 2), "estimated_liability_cents": estimated_liability, } # Singleton instance program_service = ProgramService()