# app/modules/loyalty/services/points_service.py """ Points service. Handles points operations including: - Earning points from purchases - Redeeming points for rewards - Points balance management """ import logging from datetime import UTC, datetime from sqlalchemy.orm import Session from app.modules.loyalty.exceptions import ( InsufficientPointsException, InvalidRewardException, LoyaltyCardInactiveException, LoyaltyProgramInactiveException, StaffPinRequiredException, ) from app.modules.loyalty.models import LoyaltyCard, LoyaltyTransaction, TransactionType from app.modules.loyalty.services.card_service import card_service from app.modules.loyalty.services.pin_service import pin_service logger = logging.getLogger(__name__) class PointsService: """Service for points operations.""" def earn_points( self, db: Session, *, card_id: int | None = None, qr_code: str | None = None, card_number: str | None = None, purchase_amount_cents: int, order_reference: str | None = None, staff_pin: str | None = None, ip_address: str | None = None, user_agent: str | None = None, notes: str | None = None, ) -> dict: """ Earn points from a purchase. Points are calculated based on the program's points_per_euro rate. Args: db: Database session card_id: Card ID qr_code: QR code data card_number: Card number purchase_amount_cents: Purchase amount in cents order_reference: Order reference for tracking staff_pin: Staff PIN for verification ip_address: Request IP for audit user_agent: Request user agent for audit notes: Optional notes Returns: Dict with operation result """ # Look up the card card = card_service.lookup_card( db, card_id=card_id, qr_code=qr_code, card_number=card_number, ) # Validate card and program if not card.is_active: raise LoyaltyCardInactiveException(card.id) program = card.program if not program.is_active: raise LoyaltyProgramInactiveException(program.id) # Check if points are enabled if not program.is_points_enabled: logger.warning(f"Points attempted on stamps-only program {program.id}") raise LoyaltyCardInactiveException(card.id) # Verify staff PIN if required verified_pin = None if program.require_staff_pin: if not staff_pin: raise StaffPinRequiredException() verified_pin = pin_service.verify_pin(db, program.id, staff_pin) # Calculate points # points_per_euro is per full euro, so divide cents by 100 purchase_euros = purchase_amount_cents / 100 points_earned = int(purchase_euros * program.points_per_euro) if points_earned <= 0: return { "success": True, "message": "Purchase too small to earn points", "points_earned": 0, "points_per_euro": program.points_per_euro, "purchase_amount_cents": purchase_amount_cents, "card_id": card.id, "card_number": card.card_number, "points_balance": card.points_balance, "total_points_earned": card.total_points_earned, } # Add points now = datetime.now(UTC) card.points_balance += points_earned card.total_points_earned += points_earned card.last_points_at = now # Create transaction transaction = LoyaltyTransaction( card_id=card.id, vendor_id=card.vendor_id, staff_pin_id=verified_pin.id if verified_pin else None, transaction_type=TransactionType.POINTS_EARNED.value, points_delta=points_earned, stamps_balance_after=card.stamp_count, points_balance_after=card.points_balance, purchase_amount_cents=purchase_amount_cents, order_reference=order_reference, ip_address=ip_address, user_agent=user_agent, notes=notes, transaction_at=now, ) db.add(transaction) db.commit() db.refresh(card) logger.info( f"Added {points_earned} points to card {card.id} " f"(purchase: €{purchase_euros:.2f}, balance: {card.points_balance})" ) return { "success": True, "message": "Points earned successfully", "points_earned": points_earned, "points_per_euro": program.points_per_euro, "purchase_amount_cents": purchase_amount_cents, "card_id": card.id, "card_number": card.card_number, "points_balance": card.points_balance, "total_points_earned": card.total_points_earned, } def redeem_points( self, db: Session, *, card_id: int | None = None, qr_code: str | None = None, card_number: str | None = None, reward_id: str, staff_pin: str | None = None, ip_address: str | None = None, user_agent: str | None = None, notes: str | None = None, ) -> dict: """ Redeem points for a reward. Args: db: Database session card_id: Card ID qr_code: QR code data card_number: Card number reward_id: ID of the reward to redeem staff_pin: Staff PIN for verification ip_address: Request IP for audit user_agent: Request user agent for audit notes: Optional notes Returns: Dict with operation result Raises: InvalidRewardException: Reward not found or inactive InsufficientPointsException: Not enough points """ # Look up the card card = card_service.lookup_card( db, card_id=card_id, qr_code=qr_code, card_number=card_number, ) # Validate card and program if not card.is_active: raise LoyaltyCardInactiveException(card.id) program = card.program if not program.is_active: raise LoyaltyProgramInactiveException(program.id) # Find the reward reward = program.get_points_reward(reward_id) if not reward: raise InvalidRewardException(reward_id) if not reward.get("is_active", True): raise InvalidRewardException(reward_id) points_required = reward["points_required"] reward_name = reward["name"] # Check if enough points if card.points_balance < points_required: raise InsufficientPointsException(card.points_balance, points_required) # Verify staff PIN if required verified_pin = None if program.require_staff_pin: if not staff_pin: raise StaffPinRequiredException() verified_pin = pin_service.verify_pin(db, program.id, staff_pin) # Redeem points now = datetime.now(UTC) card.points_balance -= points_required card.points_redeemed += points_required card.last_redemption_at = now # Create transaction transaction = LoyaltyTransaction( card_id=card.id, vendor_id=card.vendor_id, staff_pin_id=verified_pin.id if verified_pin else None, transaction_type=TransactionType.POINTS_REDEEMED.value, points_delta=-points_required, stamps_balance_after=card.stamp_count, points_balance_after=card.points_balance, reward_id=reward_id, reward_description=reward_name, ip_address=ip_address, user_agent=user_agent, notes=notes, transaction_at=now, ) db.add(transaction) db.commit() db.refresh(card) logger.info( f"Redeemed {points_required} points from card {card.id} " f"(reward: {reward_name}, balance: {card.points_balance})" ) return { "success": True, "message": "Reward redeemed successfully", "reward_id": reward_id, "reward_name": reward_name, "points_spent": points_required, "card_id": card.id, "card_number": card.card_number, "points_balance": card.points_balance, "total_points_redeemed": card.points_redeemed, } def adjust_points( self, db: Session, card_id: int, points_delta: int, *, reason: str, staff_pin: str | None = None, ip_address: str | None = None, user_agent: str | None = None, ) -> dict: """ Manually adjust points (admin operation). Args: db: Database session card_id: Card ID points_delta: Points to add (positive) or remove (negative) reason: Reason for adjustment staff_pin: Staff PIN for verification ip_address: Request IP for audit user_agent: Request user agent for audit Returns: Dict with operation result """ card = card_service.require_card(db, card_id) program = card.program # Verify staff PIN if required verified_pin = None if program.require_staff_pin and staff_pin: verified_pin = pin_service.verify_pin(db, program.id, staff_pin) # Apply adjustment now = datetime.now(UTC) card.points_balance += points_delta if points_delta > 0: card.total_points_earned += points_delta else: # Negative adjustment - don't add to redeemed, just reduce balance pass # Ensure balance doesn't go negative if card.points_balance < 0: card.points_balance = 0 # Create transaction transaction = LoyaltyTransaction( card_id=card.id, vendor_id=card.vendor_id, staff_pin_id=verified_pin.id if verified_pin else None, transaction_type=TransactionType.POINTS_ADJUSTMENT.value, points_delta=points_delta, stamps_balance_after=card.stamp_count, points_balance_after=card.points_balance, notes=reason, ip_address=ip_address, user_agent=user_agent, transaction_at=now, ) db.add(transaction) db.commit() db.refresh(card) logger.info( f"Adjusted points for card {card.id} by {points_delta:+d} " f"(reason: {reason}, balance: {card.points_balance})" ) return { "success": True, "message": "Points adjusted successfully", "points_delta": points_delta, "card_id": card.id, "card_number": card.card_number, "points_balance": card.points_balance, } # Singleton instance points_service = PointsService()