# app/modules/loyalty/services/stamp_service.py """ Stamp service. Merchant-based stamp operations: - Stamps earned at any store count toward merchant total - Stamps can be redeemed at any store within the merchant - Supports voiding stamps for returns Handles stamp operations including: - Adding stamps with anti-fraud checks - Redeeming stamps for rewards - Voiding stamps (for returns) - Daily limit tracking """ import logging from datetime import UTC, datetime, timedelta from sqlalchemy.orm import Session from app.modules.loyalty.exceptions import ( DailyStampLimitException, InsufficientStampsException, LoyaltyCardInactiveException, LoyaltyProgramInactiveException, StaffPinRequiredException, StampCooldownException, ) 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 StampService: """Service for stamp operations.""" def add_stamp( self, db: Session, *, store_id: int, card_id: int | None = None, qr_code: str | None = None, card_number: str | None = None, staff_pin: str | None = None, ip_address: str | None = None, user_agent: str | None = None, notes: str | None = None, ) -> dict: """ Add a stamp to a loyalty card. Performs all anti-fraud checks: - Staff PIN verification (if required) - Cooldown period check - Daily limit check Args: db: Database session store_id: Store ID (where stamp is being added) card_id: Card ID qr_code: QR code data card_number: Card number 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: LoyaltyCardNotFoundException: Card not found LoyaltyCardInactiveException: Card is inactive LoyaltyProgramInactiveException: Program is inactive StaffPinRequiredException: PIN required but not provided InvalidStaffPinException: PIN is invalid StampCooldownException: Cooldown period not elapsed DailyStampLimitException: Daily limit reached """ # 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 stamps are enabled if not program.is_stamps_enabled: logger.warning(f"Stamp attempted on points-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, store_id=store_id) # Check cooldown now = datetime.now(UTC) if card.last_stamp_at: cooldown_ends = card.last_stamp_at + timedelta(minutes=program.cooldown_minutes) if now < cooldown_ends: raise StampCooldownException( cooldown_ends.isoformat(), program.cooldown_minutes, ) # Check daily limit stamps_today = card_service.get_stamps_today(db, card.id) if stamps_today >= program.max_daily_stamps: raise DailyStampLimitException(program.max_daily_stamps, stamps_today) # Re-fetch with row lock to prevent concurrent modification card = card_service.get_card_for_update(db, card.id) # Add the stamp card.stamp_count += 1 card.total_stamps_earned += 1 card.last_stamp_at = now card.last_activity_at = now # Check if reward earned reward_earned = card.stamp_count >= program.stamps_target # 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.STAMP_EARNED.value, stamps_delta=1, stamps_balance_after=card.stamp_count, points_balance_after=card.points_balance, 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 stamp count from app.modules.loyalty.services.wallet_service import wallet_service wallet_service.sync_card_to_wallets(db, card) stamps_today += 1 logger.info( f"Added stamp to card {card.id} at store {store_id} " f"(stamps: {card.stamp_count}/{program.stamps_target}, " f"today: {stamps_today}/{program.max_daily_stamps})" ) # Calculate next stamp availability next_stamp_at = now + timedelta(minutes=program.cooldown_minutes) return { "success": True, "message": "Stamp added successfully", "card_id": card.id, "card_number": card.card_number, "stamp_count": card.stamp_count, "stamps_target": program.stamps_target, "stamps_until_reward": max(0, program.stamps_target - card.stamp_count), "reward_earned": reward_earned, "reward_description": program.stamps_reward_description if reward_earned else None, "next_stamp_available_at": next_stamp_at, "stamps_today": stamps_today, "stamps_remaining_today": max(0, program.max_daily_stamps - stamps_today), "store_id": store_id, } def redeem_stamps( self, db: Session, *, store_id: int, card_id: int | None = None, qr_code: str | None = None, card_number: str | None = None, staff_pin: str | None = None, ip_address: str | None = None, user_agent: str | None = None, notes: str | None = None, ) -> dict: """ Redeem stamps 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 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: LoyaltyCardNotFoundException: Card not found InsufficientStampsException: Not enough stamps StaffPinRequiredException: PIN required but not provided """ # 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 enough stamps if card.stamp_count < program.stamps_target: raise InsufficientStampsException(card.stamp_count, program.stamps_target) # 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 stamps now = datetime.now(UTC) stamps_redeemed = program.stamps_target card.stamp_count -= stamps_redeemed card.stamps_redeemed += 1 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.STAMP_REDEEMED.value, stamps_delta=-stamps_redeemed, stamps_balance_after=card.stamp_count, points_balance_after=card.points_balance, reward_description=program.stamps_reward_description, 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 stamp count from app.modules.loyalty.services.wallet_service import wallet_service wallet_service.sync_card_to_wallets(db, card) logger.info( f"Redeemed stamps from card {card.id} at store {store_id} " f"(reward: {program.stamps_reward_description}, " f"total redemptions: {card.stamps_redeemed})" ) return { "success": True, "message": "Reward redeemed successfully", "card_id": card.id, "card_number": card.card_number, "stamp_count": card.stamp_count, "stamps_target": program.stamps_target, "reward_description": program.stamps_reward_description, "total_redemptions": card.stamps_redeemed, "store_id": store_id, } def void_stamps( self, db: Session, *, store_id: int, card_id: int | None = None, qr_code: str | None = None, card_number: str | None = None, stamps_to_void: int | None = None, original_transaction_id: int | None = None, staff_pin: str | None = None, ip_address: str | None = None, user_agent: str | None = None, notes: str | None = None, ) -> dict: """ Void stamps for a return. Args: db: Database session store_id: Store ID card_id: Card ID qr_code: QR code data card_number: Card number stamps_to_void: Number of stamps to void (if not using original_transaction_id) original_transaction_id: ID of original stamp transaction to void 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 stamps 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.STAMP_EARNED.value, ) .first() ) if original_transaction: stamps_to_void = original_transaction.stamps_delta if not stamps_to_void or stamps_to_void <= 0: return { "success": False, "message": "No stamps to void", "card_id": card.id, "card_number": card.card_number, "stamp_count": card.stamp_count, } # Re-fetch with row lock to prevent concurrent modification card = card_service.get_card_for_update(db, card.id) # Void the stamps (can reduce balance below what was earned) now = datetime.now(UTC) actual_voided = min(stamps_to_void, card.stamp_count) card.stamp_count = max(0, card.stamp_count - stamps_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.STAMP_VOIDED.value, stamps_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, ip_address=ip_address, user_agent=user_agent, notes=notes or "Stamps voided for return", transaction_at=now, ) db.add(transaction) db.commit() db.refresh(card) # Sync wallet passes with updated stamp count from app.modules.loyalty.services.wallet_service import wallet_service wallet_service.sync_card_to_wallets(db, card) logger.info( f"Voided {actual_voided} stamps from card {card.id} at store {store_id} " f"(balance: {card.stamp_count})" ) return { "success": True, "message": "Stamps voided successfully", "stamps_voided": actual_voided, "card_id": card.id, "card_number": card.card_number, "stamp_count": card.stamp_count, "store_id": store_id, } # Singleton instance stamp_service = StampService()