Epic 3: Economy & Billing Engine (Pénzügyi Motor)
This commit is contained in:
@@ -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 = ""
|
||||
|
||||
@@ -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}"
|
||||
)
|
||||
|
||||
|
||||
219
backend/app/core/scheduler.py
Normal file
219
backend/app/core/scheduler.py
Normal 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
|
||||
Reference in New Issue
Block a user