""" Downgrade Executor — Background service that activates pending downgrades. Processes subscriptions where: - pending_tier_id IS NOT NULL (a downgrade was requested) - valid_until < NOW() (the current paid period has expired) When both conditions are met: 1. Deactivates the current subscription (is_active = False) 2. Activates the pending tier (creates a new UserSubscription/OrgSubscription) 3. Clears the pending_tier_id and pending_activated_at fields THOUGHT PROCESS: - Designed to be called from a CRON job or scheduled task. - Uses atomic SELECT ... FOR UPDATE SKIP LOCKED to prevent duplicate activation when multiple workers run concurrently. - Updates User.subscription_plan and User.subscription_expires_at for backward compatibility. - All operations are logged at INFO level for audit trail. """ import logging from datetime import datetime, timedelta from typing import List, Tuple, Optional from sqlalchemy import select, update, text from sqlalchemy.ext.asyncio import AsyncSession from app.models.core_logic import ( SubscriptionTier, UserSubscription, OrganizationSubscription, ) from app.models.identity.identity import User logger = logging.getLogger("downgrade-executor") class DowngradeExecutor: """ Background service that activates pending downgrades after the current subscription period expires. Usage: executor = DowngradeExecutor() count = await executor.process_pending_downgrades(db) """ # ────────────────────────────────────────────────────────────────────────── # Public API # ────────────────────────────────────────────────────────────────────────── async def process_pending_downgrades( self, db: AsyncSession, batch_size: int = 50, ) -> Tuple[int, int]: """ Process all pending downgrades where the current subscription has expired. Uses atomic SELECT ... FOR UPDATE SKIP LOCKED to safely handle concurrent execution. Args: db: Database session. batch_size: Maximum number of downgrades to process in one call. Returns: Tuple of (processed_count, error_count). """ now = datetime.utcnow() processed = 0 errors = 0 # ── 1. Process UserSubscription pending downgrades ────────────── user_subs = await self._fetch_expired_user_pending(db, now, batch_size) for sub in user_subs: try: await self._activate_user_pending(db, sub) processed += 1 logger.info( "User pending downgrade activated: user_id=%d " "from_tier_id=%d to_tier_id=%d", sub.user_id, sub.tier_id, sub.pending_tier_id, ) except Exception as e: errors += 1 logger.exception( "Failed to activate user pending downgrade: sub_id=%d " "user_id=%d error=%s", sub.id, sub.user_id, str(e), ) # ── 2. Process OrganizationSubscription pending downgrades ───── org_subs = await self._fetch_expired_org_pending(db, now, batch_size) for sub in org_subs: try: await self._activate_org_pending(db, sub) processed += 1 logger.info( "Org pending downgrade activated: org_id=%d " "from_tier_id=%d to_tier_id=%d", sub.org_id, sub.tier_id, sub.pending_tier_id, ) except Exception as e: errors += 1 logger.exception( "Failed to activate org pending downgrade: sub_id=%d " "org_id=%d error=%s", sub.id, sub.org_id, str(e), ) if processed > 0 or errors > 0: logger.info( "DowngradeExecutor finished: processed=%d errors=%d", processed, errors, ) return (processed, errors) # ────────────────────────────────────────────────────────────────────────── # Internal: Fetch expired pending subscriptions # ────────────────────────────────────────────────────────────────────────── async def _fetch_expired_user_pending( self, db: AsyncSession, now: datetime, limit: int, ) -> List[UserSubscription]: """ Fetch UserSubscription records where: - pending_tier_id IS NOT NULL - valid_until < now (expired) - is_active == True Uses FOR UPDATE SKIP LOCKED for atomic processing. """ stmt = ( select(UserSubscription) .where( UserSubscription.pending_tier_id.is_not(None), UserSubscription.valid_until.is_not(None), UserSubscription.valid_until < now, UserSubscription.is_active == True, ) .order_by(UserSubscription.valid_until.asc()) .limit(limit) .with_for_update(skip_locked=True) ) result = await db.execute(stmt) return list(result.scalars().all()) async def _fetch_expired_org_pending( self, db: AsyncSession, now: datetime, limit: int, ) -> List[OrganizationSubscription]: """ Fetch OrganizationSubscription records where: - pending_tier_id IS NOT NULL - valid_until < now (expired) - is_active == True Uses FOR UPDATE SKIP LOCKED for atomic processing. """ stmt = ( select(OrganizationSubscription) .where( OrganizationSubscription.pending_tier_id.is_not(None), OrganizationSubscription.valid_until.is_not(None), OrganizationSubscription.valid_until < now, OrganizationSubscription.is_active == True, ) .order_by(OrganizationSubscription.valid_until.asc()) .limit(limit) .with_for_update(skip_locked=True) ) result = await db.execute(stmt) return list(result.scalars().all()) # ────────────────────────────────────────────────────────────────────────── # Internal: Activate pending downgrades # ────────────────────────────────────────────────────────────────────────── async def _activate_user_pending( self, db: AsyncSession, current_sub: UserSubscription, ) -> UserSubscription: """ Activate a pending user-level downgrade. 1. Deactivates the current subscription 2. Fetches the pending tier to get duration info 3. Creates a new UserSubscription for the pending tier 4. Updates User.subscription_plan for backward compat 5. Clears pending fields on the old subscription """ target_tier_id = current_sub.pending_tier_id if target_tier_id is None: raise ValueError(f"Subscription {current_sub.id} has no pending_tier_id") # Fetch the target tier for duration info tier_stmt = select(SubscriptionTier).where(SubscriptionTier.id == target_tier_id) tier_result = await db.execute(tier_stmt) tier = tier_result.scalar_one_or_none() if not tier: raise ValueError(f"Pending tier {target_tier_id} not found") # Resolve duration from tier.rules duration_days = 30 if tier.rules: duration_config = tier.rules.get("duration", {}) if isinstance(duration_config, dict): days = duration_config.get("days", 30) if isinstance(days, (int, float)) and days > 0: duration_days = int(days) now = datetime.utcnow() valid_until = now + timedelta(days=duration_days) # Deactivate current subscription current_sub.is_active = False # Create new subscription for the pending tier new_sub = UserSubscription( user_id=current_sub.user_id, tier_id=target_tier_id, valid_from=now, valid_until=valid_until, is_active=True, ) db.add(new_sub) # Update User.subscription_plan for backward compat user_stmt = select(User).where(User.id == current_sub.user_id) user_result = await db.execute(user_stmt) user = user_result.scalar_one_or_none() if user: user.subscription_plan = tier.name user.subscription_expires_at = valid_until # Clear pending fields on old subscription current_sub.pending_tier_id = None current_sub.pending_activated_at = None await db.flush() await db.refresh(new_sub) return new_sub async def _activate_org_pending( self, db: AsyncSession, current_sub: OrganizationSubscription, ) -> OrganizationSubscription: """ Activate a pending org-level downgrade. Same logic as _activate_user_pending but for org subscriptions. """ target_tier_id = current_sub.pending_tier_id if target_tier_id is None: raise ValueError(f"Subscription {current_sub.id} has no pending_tier_id") # Fetch the target tier tier_stmt = select(SubscriptionTier).where(SubscriptionTier.id == target_tier_id) tier_result = await db.execute(tier_stmt) tier = tier_result.scalar_one_or_none() if not tier: raise ValueError(f"Pending tier {target_tier_id} not found") # Resolve duration duration_days = 30 if tier.rules: duration_config = tier.rules.get("duration", {}) if isinstance(duration_config, dict): days = duration_config.get("days", 30) if isinstance(days, (int, float)) and days > 0: duration_days = int(days) now = datetime.utcnow() valid_until = now + timedelta(days=duration_days) # Deactivate current current_sub.is_active = False # Create new subscription new_sub = OrganizationSubscription( org_id=current_sub.org_id, tier_id=target_tier_id, valid_from=now, valid_until=valid_until, is_active=True, ) db.add(new_sub) # Clear pending fields current_sub.pending_tier_id = None current_sub.pending_activated_at = None await db.flush() await db.refresh(new_sub) return new_sub