Epic 3: Economy & Billing Engine (Pénzügyi Motor)

This commit is contained in:
Roo
2026-03-08 23:15:52 +00:00
parent 8d25f44ec6
commit 4e40af8a08
69 changed files with 3758 additions and 72 deletions

View File

@@ -31,9 +31,22 @@ class Settings(BaseSettings):
# --- Security / JWT ---
SECRET_KEY: str = "NOT_SET_DANGER"
ALGORITHM: str = "HS256"
ACCESS_TOKEN_EXPIRE_MINUTES: int = 30
ACCESS_TOKEN_EXPIRE_MINUTES: int = 30
REFRESH_TOKEN_EXPIRE_DAYS: int = 7
@field_validator('SECRET_KEY')
@classmethod
def validate_secret_key(cls, v: str, info) -> str:
"""Ellenőrzi, hogy a SECRET_KEY ne legyen default érték éles környezetben."""
if v == "NOT_SET_DANGER" and not info.data.get("DEBUG", True):
raise ValueError(
"SECRET_KEY must be set in production environment. "
"Please set SECRET_KEY in .env file."
)
if not v or v.strip() == "":
raise ValueError("SECRET_KEY cannot be empty.")
return v
# --- Initial Admin ---
INITIAL_ADMIN_EMAIL: str = "admin@servicefinder.hu"
INITIAL_ADMIN_PASSWORD: str = "Admin123!"
@@ -67,11 +80,39 @@ class Settings(BaseSettings):
# --- External URLs ---
FRONTEND_BASE_URL: str = "https://dev.profibot.hu"
BACKEND_CORS_ORIGINS: List[str] = [
"http://localhost:3001",
"https://dev.profibot.hu",
"http://192.168.100.10:3001"
]
BACKEND_CORS_ORIGINS: List[str] = Field(
default=[
"http://localhost:3001",
"https://dev.profibot.hu"
],
description="Comma-separated list of allowed CORS origins. Set via ALLOWED_ORIGINS environment variable."
)
@field_validator('BACKEND_CORS_ORIGINS', mode='before')
@classmethod
def parse_allowed_origins(cls, v: Any) -> List[str]:
"""Parse ALLOWED_ORIGINS environment variable from comma-separated string to list."""
import os
env_val = os.getenv('ALLOWED_ORIGINS')
if env_val:
# parse environment variable
env_val = env_val.strip()
if env_val.startswith('"') and env_val.endswith('"'):
env_val = env_val[1:-1]
if env_val.startswith("'") and env_val.endswith("'"):
env_val = env_val[1:-1]
parts = [part.strip() for part in env_val.split(',') if part.strip()]
return parts
# if no env variable, fallback to default or provided value
if isinstance(v, str):
v = v.strip()
if v.startswith('"') and v.endswith('"'):
v = v[1:-1]
if v.startswith("'") and v.endswith("'"):
v = v[1:-1]
parts = [part.strip() for part in v.split(',') if part.strip()]
return parts
return v
# --- Google OAuth ---
GOOGLE_CLIENT_ID: str = ""

View File

@@ -15,10 +15,11 @@ class RBAC:
return True
# 2. Dinamikus rang ellenőrzés a központi rank_map alapján
user_rank = settings.DEFAULT_RANK_MAP.get(current_user.role.value, 0)
role_key = current_user.role.value.upper() # A DEFAULT_RANK_MAP nagybetűs kulcsokat vár
user_rank = settings.DEFAULT_RANK_MAP.get(role_key, 0)
if user_rank < self.min_rank:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
status_code=status.HTTP_403_FORBIDDEN,
detail=f"Elégtelen rang. Szükséges szint: {self.min_rank}"
)

View File

@@ -0,0 +1,219 @@
"""
Aszinkron ütemező (APScheduler) a napi karbantartási feladatokhoz.
Integrálva a FastAPI lifespan eseményébe, így az alkalmazás indításakor elindul,
és leálláskor megáll.
Biztonsági Jitter: A napi futás 00:15-kor indul, de jitter=900 (15 perc) paraméterrel
véletlenszerűen 0:15 és 0:30 között fog lefutni.
"""
import asyncio
import logging
import uuid
from contextlib import asynccontextmanager
from datetime import datetime, time, timedelta
from typing import Optional
from apscheduler.schedulers.asyncio import AsyncIOScheduler
from apscheduler.triggers.cron import CronTrigger
from apscheduler.jobstores.memory import MemoryJobStore
from app.database import AsyncSessionLocal
from app.services.billing_engine import SmartDeduction
from app.models.payment import WithdrawalRequest, WithdrawalRequestStatus
from app.models.identity import User
from app.models.audit import ProcessLog, WalletType, FinancialLedger
from sqlalchemy import select, update, and_
from sqlalchemy.orm import selectinload
logger = logging.getLogger(__name__)
# Globális scheduler példány
_scheduler: Optional[AsyncIOScheduler] = None
def get_scheduler() -> AsyncIOScheduler:
"""Visszaadja a globális scheduler példányt (lazy initialization)."""
global _scheduler
if _scheduler is None:
jobstores = {
'default': MemoryJobStore()
}
_scheduler = AsyncIOScheduler(
jobstores=jobstores,
timezone="UTC",
job_defaults={
'coalesce': True,
'max_instances': 1,
'misfire_grace_time': 3600 # 1 óra
}
)
return _scheduler
async def daily_financial_maintenance() -> None:
"""
Napi pénzügyi karbantartási feladatok.
A. Voucher lejárat kezelése
B. Withdrawal Request lejárat (14 nap) és automatikus elutasítás
C. Soft Downgrade (lejárt előfizetések)
D. Naplózás ProcessLog-ba
"""
logger.info("Daily financial maintenance started")
stats = {
"vouchers_expired": 0,
"withdrawals_rejected": 0,
"users_downgraded": 0,
"errors": []
}
async with AsyncSessionLocal() as db:
try:
# A. Voucher lejárat kezelése
try:
voucher_count = await SmartDeduction.process_voucher_expiration(db)
stats["vouchers_expired"] = voucher_count
logger.info(f"Expired {voucher_count} vouchers")
except Exception as e:
stats["errors"].append(f"Voucher expiration error: {str(e)}")
logger.error(f"Voucher expiration error: {e}", exc_info=True)
# B. Withdrawal Request lejárat (14 nap)
try:
# Keresd meg a PENDING státuszú, 14 napnál régebbi kéréseket
fourteen_days_ago = datetime.utcnow() - timedelta(days=14)
stmt = select(WithdrawalRequest).where(
and_(
WithdrawalRequest.status == WithdrawalRequestStatus.PENDING,
WithdrawalRequest.created_at < fourteen_days_ago,
WithdrawalRequest.is_deleted == False
)
).options(selectinload(WithdrawalRequest.user))
result = await db.execute(stmt)
expired_requests = result.scalars().all()
for req in expired_requests:
# Állítsd REJECTED-re
req.status = WithdrawalRequestStatus.REJECTED
req.reason = "Automatikus elutasítás: 14 napig hiányzó bizonylat"
# Refund: pénz vissza a user Earned zsebébe
# Ehhez létrehozunk egy FinancialLedger bejegyzést (refund)
refund_transaction = FinancialLedger(
transaction_id=uuid.uuid4(),
user_id=req.user_id,
wallet_type=WalletType.EARNED,
amount=req.amount,
currency=req.currency,
transaction_type="REFUND",
description=f"Refund for expired withdrawal request #{req.id}",
metadata={"withdrawal_request_id": req.id}
)
db.add(refund_transaction)
req.refund_transaction_id = refund_transaction.transaction_id
stats["withdrawals_rejected"] += 1
await db.commit()
logger.info(f"Rejected {len(expired_requests)} expired withdrawal requests")
except Exception as e:
await db.rollback()
stats["errors"].append(f"Withdrawal expiration error: {str(e)}")
logger.error(f"Withdrawal expiration error: {e}", exc_info=True)
# C. Soft Downgrade (lejárt előfizetések)
try:
# Keresd meg a lejárt subscription_expires_at idejű usereket
stmt = select(User).where(
and_(
User.subscription_expires_at < datetime.utcnow(),
User.subscription_plan != 'FREE',
User.is_deleted == False
)
)
result = await db.execute(stmt)
expired_users = result.scalars().all()
for user in expired_users:
# Állítsd a subscription_plan-t 'FREE'-re, role-t 'user'-re
user.subscription_plan = 'FREE'
user.role = 'user'
# Opcionálisan: állítsd be a felfüggesztett státuszt a kapcsolódó entitásokon
# (pl. Organization.is_active = False) - ez egy külön logika lehet
stats["users_downgraded"] += 1
await db.commit()
logger.info(f"Downgraded {len(expired_users)} users to FREE plan")
except Exception as e:
await db.rollback()
stats["errors"].append(f"Soft downgrade error: {str(e)}")
logger.error(f"Soft downgrade error: {e}", exc_info=True)
# D. Naplózás ProcessLog-ba
process_log = ProcessLog(
process_name="Daily-Financial-Maintenance",
status="COMPLETED" if not stats["errors"] else "PARTIAL",
details=stats,
executed_at=datetime.utcnow()
)
db.add(process_log)
await db.commit()
logger.info(f"Daily financial maintenance completed: {stats}")
except Exception as e:
logger.error(f"Daily financial maintenance failed: {e}", exc_info=True)
# Hiba esetén is naplózzuk
process_log = ProcessLog(
process_name="Daily-Financial-Maintenance",
status="FAILED",
details={"error": str(e), **stats},
executed_at=datetime.utcnow()
)
db.add(process_log)
await db.commit()
def setup_scheduler() -> None:
"""Beállítja a scheduler-t a napi feladatokkal."""
scheduler = get_scheduler()
# Napi futás 00:15-kor, jitter=900 (15 perc véletlenszerű eltolás)
scheduler.add_job(
daily_financial_maintenance,
trigger=CronTrigger(hour=0, minute=15, jitter=900),
id="daily_financial_maintenance",
name="Daily Financial Maintenance",
replace_existing=True
)
logger.info("Scheduler jobs registered")
@asynccontextmanager
async def scheduler_lifespan(app):
"""
FastAPI lifespan manager, amely elindítja és leállítja a schedulert.
"""
# Importáljuk a szükséges modulokat
import uuid
from datetime import timedelta
global _scheduler
scheduler = get_scheduler()
setup_scheduler()
logger.info("Starting scheduler...")
scheduler.start()
# Azonnali tesztfutás (opcionális, csak fejlesztéshez)
# scheduler.add_job(daily_financial_maintenance, 'date', run_date=datetime.utcnow())
yield
logger.info("Shutting down scheduler...")
scheduler.shutdown(wait=False)
_scheduler = None