308 lines
11 KiB
Python
308 lines
11 KiB
Python
"""
|
|
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
|