pénzügyi modul továbbfejlesztése (csomagkezelés)

This commit is contained in:
Roo
2026-07-29 09:46:10 +00:00
parent 6f28d3e70d
commit 75904cd2f8
50 changed files with 5851 additions and 312 deletions

View 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