# app/modules/loyalty/services/analytics_service.py """ Loyalty analytics service. Advanced analytics beyond basic stats: - Cohort retention (enrollment month → % active per subsequent month) - Churn detection (at-risk cards based on inactivity) - Revenue attribution (loyalty vs non-loyalty per store) """ import logging from datetime import UTC, datetime, timedelta from sqlalchemy import func from sqlalchemy.orm import Session from app.modules.loyalty.models import LoyaltyCard, LoyaltyTransaction logger = logging.getLogger(__name__) class AnalyticsService: """Advanced loyalty analytics.""" def get_cohort_retention( self, db: Session, merchant_id: int, months_back: int = 6, ) -> dict: """ Cohort retention matrix. Groups cards by enrollment month and tracks what % had any transaction in each subsequent month. Returns: { "cohorts": [ { "month": "2026-01", "enrolled": 50, "retention": [100, 80, 65, 55, ...] # % active per month } ] } """ now = datetime.now(UTC) start_date = now - timedelta(days=months_back * 31) # Get enrollment month for each card cards = ( db.query( LoyaltyCard.id, func.date_trunc("month", LoyaltyCard.created_at).label( "enrollment_month" ), ) .filter( LoyaltyCard.merchant_id == merchant_id, LoyaltyCard.created_at >= start_date, ) .all() ) if not cards: return {"cohorts": [], "months_back": months_back} # Group cards by enrollment month cohort_cards: dict[str, list[int]] = {} for card_id, enrollment_month in cards: month_key = enrollment_month.strftime("%Y-%m") cohort_cards.setdefault(month_key, []).append(card_id) # For each cohort, check activity in subsequent months cohorts = [] for month_key in sorted(cohort_cards.keys()): card_ids = cohort_cards[month_key] enrolled_count = len(card_ids) # Calculate months since enrollment cohort_start = datetime.strptime(month_key, "%Y-%m").replace( tzinfo=UTC ) months_since = max( 1, (now.year - cohort_start.year) * 12 + (now.month - cohort_start.month), ) retention = [] for month_offset in range(min(months_since, months_back)): period_start = cohort_start + timedelta(days=month_offset * 30) period_end = period_start + timedelta(days=30) # Count cards with any transaction in this period active_count = ( db.query(func.count(func.distinct(LoyaltyTransaction.card_id))) .filter( LoyaltyTransaction.card_id.in_(card_ids), LoyaltyTransaction.transaction_at >= period_start, LoyaltyTransaction.transaction_at < period_end, ) .scalar() or 0 ) pct = round(active_count / enrolled_count * 100) if enrolled_count else 0 retention.append(pct) cohorts.append( { "month": month_key, "enrolled": enrolled_count, "retention": retention, } ) return {"cohorts": cohorts, "months_back": months_back} def get_at_risk_cards( self, db: Session, merchant_id: int, inactivity_multiplier: float = 2.0, limit: int = 50, ) -> dict: """ Simple churn detection. A card is "at risk" when its inactivity period exceeds `inactivity_multiplier` × its average inter-transaction interval. Falls back to 60 days for cards with fewer than 2 transactions. Returns: { "at_risk_count": int, "cards": [ { "card_id": int, "card_number": str, "customer_name": str, "days_inactive": int, "avg_interval_days": int, "points_balance": int, } ] } """ now = datetime.now(UTC) default_threshold_days = 60 # Get active cards with their last activity cards = ( db.query(LoyaltyCard) .filter( LoyaltyCard.merchant_id == merchant_id, LoyaltyCard.is_active == True, # noqa: E712 LoyaltyCard.last_activity_at.isnot(None), ) .all() ) at_risk = [] for card in cards: days_inactive = (now - card.last_activity_at).days # Calculate average interval from transaction history tx_dates = ( db.query(LoyaltyTransaction.transaction_at) .filter(LoyaltyTransaction.card_id == card.id) .order_by(LoyaltyTransaction.transaction_at) .all() ) if len(tx_dates) >= 2: intervals = [ (tx_dates[i + 1][0] - tx_dates[i][0]).days for i in range(len(tx_dates) - 1) ] avg_interval = sum(intervals) / len(intervals) if intervals else default_threshold_days else: avg_interval = default_threshold_days threshold = avg_interval * inactivity_multiplier if days_inactive > threshold: customer_name = None if card.customer: customer_name = card.customer.full_name at_risk.append( { "card_id": card.id, "card_number": card.card_number, "customer_name": customer_name, "days_inactive": days_inactive, "avg_interval_days": round(avg_interval), "points_balance": card.points_balance, } ) # Sort by days_inactive descending at_risk.sort(key=lambda x: x["days_inactive"], reverse=True) return { "at_risk_count": len(at_risk), "cards": at_risk[:limit], "total_cards_checked": len(cards), } def get_revenue_attribution( self, db: Session, merchant_id: int, months_back: int = 6, ) -> dict: """ Revenue attribution from loyalty point-earning transactions. Compares revenue from transactions with order references (loyalty customers) against total enrollment metrics. Groups by month and store. Returns: { "monthly": [ { "month": "2026-01", "transactions_count": int, "total_points_earned": int, "estimated_revenue_cents": int, "unique_customers": int, } ], "by_store": [ { "store_id": int, "store_name": str, "transactions_count": int, "total_points_earned": int, } ] } """ from app.modules.loyalty.models.loyalty_transaction import TransactionType from app.modules.tenancy.services.store_service import store_service now = datetime.now(UTC) start_date = now - timedelta(days=months_back * 31) # Monthly aggregation of point-earning transactions monthly_rows = ( db.query( func.date_trunc("month", LoyaltyTransaction.transaction_at).label( "month" ), func.count(LoyaltyTransaction.id).label("tx_count"), func.coalesce( func.sum(LoyaltyTransaction.points_delta), 0 ).label("points_earned"), func.count( func.distinct(LoyaltyTransaction.card_id) ).label("unique_cards"), ) .filter( LoyaltyTransaction.merchant_id == merchant_id, LoyaltyTransaction.transaction_at >= start_date, LoyaltyTransaction.transaction_type.in_( [ TransactionType.POINTS_EARNED.value, TransactionType.STAMP_EARNED.value, ] ), LoyaltyTransaction.points_delta > 0, ) .group_by("month") .order_by("month") .all() ) monthly = [] for row in monthly_rows: monthly.append( { "month": row.month.strftime("%Y-%m"), "transactions_count": row.tx_count, "total_points_earned": row.points_earned, "unique_customers": row.unique_cards, } ) # Per-store breakdown store_rows = ( db.query( LoyaltyTransaction.store_id, func.count(LoyaltyTransaction.id).label("tx_count"), func.coalesce( func.sum(LoyaltyTransaction.points_delta), 0 ).label("points_earned"), ) .filter( LoyaltyTransaction.merchant_id == merchant_id, LoyaltyTransaction.transaction_at >= start_date, LoyaltyTransaction.transaction_type.in_( [ TransactionType.POINTS_EARNED.value, TransactionType.STAMP_EARNED.value, ] ), LoyaltyTransaction.points_delta > 0, LoyaltyTransaction.store_id.isnot(None), ) .group_by(LoyaltyTransaction.store_id) .all() ) by_store = [] for row in store_rows: store = store_service.get_store_by_id_optional(db, row.store_id) by_store.append( { "store_id": row.store_id, "store_name": store.name if store else f"Store {row.store_id}", "transactions_count": row.tx_count, "total_points_earned": row.points_earned, } ) return { "monthly": monthly, "by_store": by_store, "months_back": months_back, } # Singleton analytics_service = AnalyticsService()