Files
orion/app/modules/loyalty/tasks/point_expiration.py
Samir Boulahtit d8f3338bc8 feat(loyalty): implement Phase 2 - company-wide points system
Complete implementation of loyalty module Phase 2 features:

Database & Models:
- Add company_id to LoyaltyProgram for chain-wide loyalty
- Add company_id to LoyaltyCard for multi-location support
- Add CompanyLoyaltySettings model for admin-controlled settings
- Add points expiration, welcome bonus, and minimum redemption fields
- Add POINTS_EXPIRED, WELCOME_BONUS transaction types

Services:
- Update program_service for company-based queries
- Update card_service with enrollment and welcome bonus
- Update points_service with void_points for returns
- Update stamp_service for company context
- Update pin_service for company-wide operations

API Endpoints:
- Admin: Program listing with stats, company detail views
- Vendor: Terminal operations, card management, settings
- Storefront: Customer card/transactions, self-enrollment

UI Templates:
- Admin: Programs dashboard, company detail, settings
- Vendor: Terminal, cards list, card detail, settings, stats, enrollment
- Storefront: Dashboard, history, enrollment, success pages

Background Tasks:
- Point expiration task (daily, based on inactivity)
- Wallet sync task (hourly)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-05 22:10:27 +01:00

199 lines
5.8 KiB
Python

# 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} (company {program.company_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 company
cards_to_expire = (
db.query(LoyaltyCard)
.filter(
LoyaltyCard.company_id == program.company_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,
company_id=program.company_id,
vendor_id=None, # System action, no vendor
transaction_type=TransactionType.POINTS_EXPIRED.value,
points_delta=-expired_points,
balance_after=0,
stamps_delta=0,
stamps_balance_after=card.stamps_balance,
notes=f"Points expired after {program.points_expiration_days} days of inactivity",
transaction_at=datetime.now(UTC),
)
db.add(transaction)
# Update card balance
card.points_balance = 0
card.total_points_voided = (card.total_points_voided or 0) + 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)