pénzügyi modul továbbfejlesztése (csomagkezelés)
This commit is contained in:
307
backend/app/services/downgrade_executor.py
Normal file
307
backend/app/services/downgrade_executor.py
Normal file
@@ -0,0 +1,307 @@
|
||||
"""
|
||||
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
|
||||
Reference in New Issue
Block a user