Files
service-finder/backend/app/workers/system/subscription_worker.py
2026-03-10 07:34:01 +00:00

140 lines
5.2 KiB
Python

#!/usr/bin/env python3
"""
🤖 Subscription Lifecycle Worker (Robot-20)
A 20-as Gitea kártya implementációja: Lejárt előfizetések automatikus kezelése.
Folyamat:
1. Lekérdezés: Azokat a User-eket, ahol subscription_expires_at < NOW() ÉS subscription_plan != 'FREE'
2. Downgrade: subscription_plan = "FREE", is_vip = False
3. Naplózás: Főkönyvi bejegyzés (SUBSCRIPTION_EXPIRED)
4. Értesítés: NotificationService küldése a downgrade-ről
Futtatás:
- Napi egyszer (cron) vagy manuálisan: docker exec sf_api python -m app.workers.system.subscription_worker
"""
import asyncio
import logging
from datetime import datetime, timezone
from typing import List
from sqlalchemy import select, update, and_
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import AsyncSessionLocal
from app.models.identity import User
from app.models.audit import FinancialLedger, LedgerEntryType, WalletType
from app.services.billing_engine import record_ledger_entry
from app.services.notification_service import NotificationService
logger = logging.getLogger("subscription-worker")
async def process_expired_subscriptions(db: AsyncSession) -> dict:
"""
Atomikus tranzakcióban feldolgozza a lejárt előfizetéseket.
Returns:
dict: Statisztikák (processed_count, downgraded_users, errors)
"""
now = datetime.now(timezone.utc)
# 1. Lekérdezés: Lejárt előfizetések, amelyek még nem FREE-ek
stmt = select(User).where(
and_(
User.subscription_expires_at < now,
User.subscription_plan != 'FREE',
User.is_deleted == False,
User.is_active == True
)
).with_for_update(skip_locked=True) # Atomikus zárolás
result = await db.execute(stmt)
expired_users: List[User] = result.scalars().all()
stats = {
"processed_count": len(expired_users),
"downgraded_users": [],
"errors": []
}
for user in expired_users:
try:
# 2. Downgrade
old_plan = user.subscription_plan
user.subscription_plan = "FREE"
user.is_vip = False
# subscription_expires_at marad null vagy régi érték (lejárt)
# 3. Főkönyvi bejegyzés (SUBSCRIPTION_EXPIRED)
# Megjegyzés: amount = 0, mert nem történik pénzmozgás, csak naplózás
ledger_entry = await record_ledger_entry(
db=db,
user_id=user.id,
amount=0.0,
entry_type=LedgerEntryType.DEBIT,
wallet_type=WalletType.SYSTEM,
transaction_type="SUBSCRIPTION_EXPIRED",
description=f"Előfizetés lejárt: {old_plan} → FREE",
reference_type="subscription",
reference_id=user.id
)
# 4. Értesítés küldése
await NotificationService.send_notification(
db=db,
user_id=user.id,
title="Előfizetésed lejárt",
message=f"A(z) {old_plan} előfizetésed lejárt, ezért átállítottuk a FREE csomagra. További előnyökért frissíts előfizetést!",
category="billing",
priority="medium",
data={
"old_plan": old_plan,
"new_plan": "FREE",
"user_id": user.id,
"expired_at": user.subscription_expires_at.isoformat() if user.subscription_expires_at else None
},
send_email=True,
email_template="subscription_expired",
email_vars={
"recipient": user.email,
"first_name": user.person.first_name if user.person else "Partnerünk",
"old_plan": old_plan,
"new_plan": "FREE",
"lang": user.preferred_language
}
)
stats["downgraded_users"].append({
"user_id": user.id,
"email": user.email,
"old_plan": old_plan,
"new_plan": "FREE"
})
logger.info(f"User {user.id} ({user.email}) subscription downgraded from {old_plan} to FREE")
except Exception as e:
logger.error(f"Error processing user {user.id}: {e}", exc_info=True)
stats["errors"].append({"user_id": user.id, "error": str(e)})
# Commit a tranzakció
await db.commit()
return stats
async def main():
"""Fő futtató függvény, amelyet a cron hív."""
logger.info("Starting subscription worker...")
async with AsyncSessionLocal() as db:
try:
stats = await process_expired_subscriptions(db)
logger.info(f"Subscription worker completed. Stats: {stats}")
print(f"✅ Subscription worker completed. Processed: {stats['processed_count']}, Downgraded: {len(stats['downgraded_users'])}")
except Exception as e:
logger.error(f"Subscription worker failed: {e}", exc_info=True)
await db.rollback()
raise
if __name__ == "__main__":
asyncio.run(main())