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>
This commit is contained in:
@@ -3,8 +3,17 @@
|
||||
Loyalty module Celery tasks.
|
||||
|
||||
Background tasks for:
|
||||
- Point expiration
|
||||
- Wallet synchronization
|
||||
- Point expiration (daily at 02:00)
|
||||
- Wallet synchronization (hourly)
|
||||
|
||||
Task registration is handled by the module definition in definition.py
|
||||
which specifies the task paths and schedules.
|
||||
"""
|
||||
|
||||
__all__: list[str] = []
|
||||
from app.modules.loyalty.tasks.point_expiration import expire_points
|
||||
from app.modules.loyalty.tasks.wallet_sync import sync_wallet_passes
|
||||
|
||||
__all__ = [
|
||||
"expire_points",
|
||||
"sync_wallet_passes",
|
||||
]
|
||||
|
||||
@@ -3,12 +3,20 @@
|
||||
Point expiration task.
|
||||
|
||||
Handles expiring points that are older than the configured
|
||||
expiration period (future enhancement).
|
||||
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__)
|
||||
|
||||
@@ -16,26 +24,175 @@ logger = logging.getLogger(__name__)
|
||||
@shared_task(name="loyalty.expire_points")
|
||||
def expire_points() -> dict:
|
||||
"""
|
||||
Expire points that are past their expiration date.
|
||||
Expire points that are past their expiration date based on card inactivity.
|
||||
|
||||
This is a placeholder for future functionality where points
|
||||
can be configured to expire after a certain period.
|
||||
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
|
||||
"""
|
||||
# Future implementation:
|
||||
# 1. Find programs with point expiration enabled
|
||||
# 2. Find cards with points earned before expiration threshold
|
||||
# 3. Calculate points to expire
|
||||
# 4. Create adjustment transactions
|
||||
# 5. Update card balances
|
||||
# 6. Notify customers (optional)
|
||||
logger.info("Starting point expiration task...")
|
||||
|
||||
logger.info("Point expiration task running (no-op for now)")
|
||||
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",
|
||||
"cards_processed": 0,
|
||||
"points_expired": 0,
|
||||
"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)
|
||||
|
||||
Reference in New Issue
Block a user