# app/modules/loyalty/services/points_service.py """ Points service. Merchant-based points operations: - Points earned at any store count toward merchant total - Points can be redeemed at any store within the merchant - Supports voiding points for returns Handles points operations including: - Earning points from purchases - Redeeming points for rewards - Voiding points (for returns) - 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, OrderReferenceRequiredException, StaffPinRequiredException, ) from app.modules.loyalty.models import 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, *, store_id: int, 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 store_id: Store ID (where purchase is being made) 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 (validates it belongs to store's merchant) card = card_service.lookup_card_for_store( db, store_id, 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) # Check if order reference is required from app.modules.loyalty.services.program_service import program_service settings = program_service.get_merchant_settings(db, card.merchant_id) if settings and settings.require_order_reference and not order_reference: raise OrderReferenceRequiredException() # Check minimum purchase amount if program.minimum_purchase_cents > 0 and purchase_amount_cents < program.minimum_purchase_cents: return { "success": True, "message": f"Purchase below minimum of €{program.minimum_purchase_cents/100:.2f}", "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, } # 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, store_id=store_id) # 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, } # Re-fetch with row lock to prevent concurrent modification card = card_service.get_card_for_update(db, card.id) # Add points now = datetime.now(UTC) card.points_balance += points_earned card.total_points_earned += points_earned card.last_points_at = now card.last_activity_at = now # Create transaction transaction = LoyaltyTransaction( merchant_id=card.merchant_id, card_id=card.id, store_id=store_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) # Sync wallet passes with updated points balance from app.modules.loyalty.services.wallet_service import wallet_service wallet_service.sync_card_to_wallets(db, card) logger.info( f"Added {points_earned} points to card {card.id} at store {store_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, "store_id": store_id, } def redeem_points( self, db: Session, *, store_id: int, 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 store_id: Store ID (where redemption is happening) 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 (validates it belongs to store's merchant) card = card_service.lookup_card_for_store( db, store_id, 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 minimum redemption if points_required < program.minimum_redemption_points: raise InvalidRewardException(reward_id) # 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, store_id=store_id) # Re-fetch with row lock to prevent concurrent modification card = card_service.get_card_for_update(db, card.id) # Redeem points now = datetime.now(UTC) card.points_balance -= points_required card.points_redeemed += points_required card.last_redemption_at = now card.last_activity_at = now # Create transaction transaction = LoyaltyTransaction( merchant_id=card.merchant_id, card_id=card.id, store_id=store_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) # Sync wallet passes with updated points balance from app.modules.loyalty.services.wallet_service import wallet_service wallet_service.sync_card_to_wallets(db, card) logger.info( f"Redeemed {points_required} points from card {card.id} at store {store_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, "store_id": store_id, } def void_points( self, db: Session, *, store_id: int, card_id: int | None = None, qr_code: str | None = None, card_number: str | None = None, points_to_void: int | None = None, original_transaction_id: int | None = None, 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: """ Void points for a return. Args: db: Database session store_id: Store ID card_id: Card ID qr_code: QR code data card_number: Card number points_to_void: Number of points to void (if not using original_transaction_id) original_transaction_id: ID of original earn transaction to void order_reference: Order reference (to find original transaction) staff_pin: Staff PIN for verification ip_address: Request IP for audit user_agent: Request user agent for audit notes: Reason for voiding Returns: Dict with operation result """ # Look up the card card = card_service.lookup_card_for_store( db, store_id, card_id=card_id, qr_code=qr_code, card_number=card_number, ) program = card.program # Check if void transactions are allowed from app.modules.loyalty.services.program_service import program_service settings = program_service.get_merchant_settings(db, card.merchant_id) if settings and not settings.allow_void_transactions: 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, store_id=store_id) # Determine points to void original_transaction = None if original_transaction_id: original_transaction = ( db.query(LoyaltyTransaction) .filter( LoyaltyTransaction.id == original_transaction_id, LoyaltyTransaction.card_id == card.id, LoyaltyTransaction.transaction_type == TransactionType.POINTS_EARNED.value, ) .first() ) if original_transaction: points_to_void = original_transaction.points_delta elif order_reference: original_transaction = ( db.query(LoyaltyTransaction) .filter( LoyaltyTransaction.order_reference == order_reference, LoyaltyTransaction.card_id == card.id, LoyaltyTransaction.transaction_type == TransactionType.POINTS_EARNED.value, ) .first() ) if original_transaction: points_to_void = original_transaction.points_delta if not points_to_void or points_to_void <= 0: return { "success": False, "message": "No points to void", "card_id": card.id, "card_number": card.card_number, "points_balance": card.points_balance, } # Re-fetch with row lock to prevent concurrent modification card = card_service.get_card_for_update(db, card.id) # Void the points (can reduce balance below what was earned) now = datetime.now(UTC) actual_voided = min(points_to_void, card.points_balance) card.points_balance = max(0, card.points_balance - points_to_void) card.last_activity_at = now # Create void transaction transaction = LoyaltyTransaction( merchant_id=card.merchant_id, card_id=card.id, store_id=store_id, staff_pin_id=verified_pin.id if verified_pin else None, transaction_type=TransactionType.POINTS_VOIDED.value, points_delta=-actual_voided, stamps_balance_after=card.stamp_count, points_balance_after=card.points_balance, related_transaction_id=original_transaction.id if original_transaction else None, order_reference=order_reference, ip_address=ip_address, user_agent=user_agent, notes=notes or "Points voided for return", transaction_at=now, ) db.add(transaction) db.commit() db.refresh(card) # Sync wallet passes with updated points balance from app.modules.loyalty.services.wallet_service import wallet_service wallet_service.sync_card_to_wallets(db, card) logger.info( f"Voided {actual_voided} points from card {card.id} at store {store_id} " f"(balance: {card.points_balance})" ) return { "success": True, "message": "Points voided successfully", "points_voided": actual_voided, "card_id": card.id, "card_number": card.card_number, "points_balance": card.points_balance, "store_id": store_id, } def adjust_points( self, db: Session, card_id: int, points_delta: int, *, store_id: int | None = None, reason: str, staff_pin: str | None = None, ip_address: str | None = None, user_agent: str | None = None, ) -> dict: """ Manually adjust points (admin/store operation). Args: db: Database session card_id: Card ID points_delta: Points to add (positive) or remove (negative) store_id: Store ID 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 and store provided verified_pin = None if program.require_staff_pin and staff_pin and store_id: verified_pin = pin_service.verify_pin(db, program.id, staff_pin, store_id=store_id) # Re-fetch with row lock to prevent concurrent modification card = card_service.get_card_for_update(db, card.id) # Apply adjustment now = datetime.now(UTC) card.points_balance += points_delta card.last_activity_at = now 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( merchant_id=card.merchant_id, card_id=card.id, store_id=store_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) # Sync wallet passes with updated points balance from app.modules.loyalty.services.wallet_service import wallet_service wallet_service.sync_card_to_wallets(db, 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()