# app/modules/loyalty/tasks/point_expiration.py """ Point expiration task. Handles expiring points that are older than the configured expiration period based on card inactivity. Runs daily at 02:00 via the scheduled task configuration in definition.py. """ import logging from datetime import UTC, datetime, timedelta from celery import shared_task from sqlalchemy.orm import Session from app.core.database import SessionLocal from app.modules.loyalty.models import LoyaltyCard, LoyaltyProgram, LoyaltyTransaction from app.modules.loyalty.models.loyalty_transaction import TransactionType logger = logging.getLogger(__name__) @shared_task(name="loyalty.expire_points") def expire_points() -> dict: """ Expire points that are past their expiration date based on card inactivity. For each program with points_expiration_days configured: 1. Find cards that haven't had activity in the expiration period 2. Expire all points on those cards 3. Create POINTS_EXPIRED transaction records 4. Update card balances Returns: Summary of expired points """ logger.info("Starting point expiration task...") db: Session = SessionLocal() try: result = _process_point_expiration(db) db.commit() logger.info( f"Point expiration complete: {result['cards_processed']} cards, " f"{result['points_expired']} points expired" ) return result except Exception as e: db.rollback() logger.error(f"Point expiration task failed: {e}", exc_info=True) return { "status": "error", "error": str(e), "cards_processed": 0, "points_expired": 0, } finally: db.close() def _process_point_expiration(db: Session) -> dict: """ Process point expiration for all programs. Args: db: Database session Returns: Summary of expired points """ total_cards_processed = 0 total_points_expired = 0 programs_processed = 0 # Find all active programs with point expiration configured programs = ( db.query(LoyaltyProgram) .filter( LoyaltyProgram.is_active == True, LoyaltyProgram.points_expiration_days.isnot(None), LoyaltyProgram.points_expiration_days > 0, ) .all() ) logger.info(f"Found {len(programs)} programs with point expiration configured") for program in programs: cards_count, points_count = _expire_points_for_program(db, program) total_cards_processed += cards_count total_points_expired += points_count programs_processed += 1 logger.debug( f"Program {program.id} (merchant {program.merchant_id}): " f"{cards_count} cards, {points_count} points expired" ) return { "status": "success", "programs_processed": programs_processed, "cards_processed": total_cards_processed, "points_expired": total_points_expired, } def _expire_points_for_program(db: Session, program: LoyaltyProgram) -> tuple[int, int]: """ Expire points for a specific loyalty program. Args: db: Database session program: Loyalty program to process Returns: Tuple of (cards_processed, points_expired) """ if not program.points_expiration_days: return 0, 0 # Calculate expiration threshold expiration_threshold = datetime.now(UTC) - timedelta(days=program.points_expiration_days) logger.debug( f"Processing program {program.id}: expiration after {program.points_expiration_days} days " f"(threshold: {expiration_threshold})" ) # Find cards with: # - Points balance > 0 # - Last activity before expiration threshold # - Belonging to this program's merchant cards_to_expire = ( db.query(LoyaltyCard) .filter( LoyaltyCard.merchant_id == program.merchant_id, LoyaltyCard.points_balance > 0, LoyaltyCard.last_activity_at < expiration_threshold, LoyaltyCard.is_active == True, ) .all() ) if not cards_to_expire: logger.debug(f"No cards to expire for program {program.id}") return 0, 0 logger.info(f"Found {len(cards_to_expire)} cards to expire for program {program.id}") cards_processed = 0 points_expired = 0 for card in cards_to_expire: if card.points_balance <= 0: continue expired_points = card.points_balance # Create expiration transaction transaction = LoyaltyTransaction( card_id=card.id, merchant_id=program.merchant_id, store_id=None, # System action, no store transaction_type=TransactionType.POINTS_EXPIRED.value, points_delta=-expired_points, points_balance_after=0, stamps_delta=0, stamps_balance_after=card.stamp_count, notes=f"Points expired after {program.points_expiration_days} days of inactivity", transaction_at=datetime.now(UTC), ) db.add(transaction) # noqa: PERF006 # Update card balance and voided tracking card.expire_points(expired_points) # Note: We don't update last_activity_at for expiration cards_processed += 1 points_expired += expired_points logger.debug( f"Expired {expired_points} points from card {card.id} " f"(last activity: {card.last_activity_at})" ) return cards_processed, points_expired # Allow running directly for testing if __name__ == "__main__": import sys logging.basicConfig(level=logging.DEBUG) result = expire_points() print(f"Result: {result}") sys.exit(0 if result["status"] == "success" else 1)