Epic 3: Economy & Billing Engine (Pénzügyi Motor)
This commit is contained in:
@@ -127,7 +127,9 @@ def check_min_rank(role_key: str):
|
||||
db, "rbac_rank_matrix", default=DEFAULT_RANK_MAP
|
||||
)
|
||||
|
||||
required_rank = ranks.get(role_key, 0)
|
||||
# A DEFAULT_RANK_MAP nagybetűs kulcsokat vár, ezért átalakítjuk
|
||||
role_key_upper = role_key.upper()
|
||||
required_rank = ranks.get(role_key_upper, 0)
|
||||
user_rank = payload.get("rank", 0)
|
||||
|
||||
if user_rank < required_rank:
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
# /opt/docker/dev/service_finder/backend/app/api/v1/api.py
|
||||
from fastapi import APIRouter
|
||||
from app.api.v1.endpoints import (
|
||||
auth, catalog, assets, organizations, documents,
|
||||
services, admin, expenses, evidence, social
|
||||
auth, catalog, assets, organizations, documents,
|
||||
services, admin, expenses, evidence, social, security,
|
||||
billing
|
||||
)
|
||||
|
||||
api_router = APIRouter()
|
||||
@@ -17,4 +18,5 @@ api_router.include_router(documents.router, prefix="/documents", tags=["Document
|
||||
api_router.include_router(admin.router, prefix="/admin", tags=["Admin Control Center (Sentinel)"])
|
||||
api_router.include_router(evidence.router, prefix="/evidence", tags=["Evidence & OCR (Robot 3)"])
|
||||
api_router.include_router(expenses.router, prefix="/expenses", tags=["Fleet Expenses (TCO)"])
|
||||
api_router.include_router(social.router, prefix="/social", tags=["Social & Leaderboard"])
|
||||
api_router.include_router(social.router, prefix="/social", tags=["Social & Leaderboard"])
|
||||
api_router.include_router(security.router, prefix="/security", tags=["Dual Control (Security)"])
|
||||
@@ -21,11 +21,12 @@ async def login(db: AsyncSession = Depends(get_db), form_data: OAuth2PasswordReq
|
||||
|
||||
ranks = await settings.get_db_setting(db, "rbac_rank_matrix", default=DEFAULT_RANK_MAP)
|
||||
role_name = user.role.value if hasattr(user.role, 'value') else str(user.role)
|
||||
role_key = role_name.upper() # A DEFAULT_RANK_MAP nagybetűs kulcsokat vár
|
||||
|
||||
token_data = {
|
||||
"sub": str(user.id),
|
||||
"role": role_name,
|
||||
"rank": ranks.get(role_name, 10),
|
||||
"rank": ranks.get(role_key, 10),
|
||||
"scope_level": user.scope_level or "individual",
|
||||
"scope_id": str(user.scope_id) if user.scope_id else str(user.id)
|
||||
}
|
||||
|
||||
@@ -1,13 +1,20 @@
|
||||
# backend/app/api/v1/endpoints/billing.py
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from fastapi import APIRouter, Depends, HTTPException, status, Request, Header
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select
|
||||
from typing import Optional, Dict, Any
|
||||
import logging
|
||||
|
||||
from app.api.deps import get_db, get_current_user
|
||||
from app.models.identity import User, Wallet, UserRole
|
||||
from app.models.audit import FinancialLedger
|
||||
from app.models.audit import FinancialLedger, WalletType
|
||||
from app.models.payment import PaymentIntent, PaymentIntentStatus
|
||||
from app.services.config_service import config
|
||||
from app.services.payment_router import PaymentRouter
|
||||
from app.services.stripe_adapter import stripe_adapter
|
||||
|
||||
router = APIRouter()
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@router.post("/upgrade")
|
||||
async def upgrade_account(target_package: str, db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user)):
|
||||
@@ -60,4 +67,291 @@ async def upgrade_account(target_package: str, db: AsyncSession = Depends(get_db
|
||||
))
|
||||
|
||||
await db.commit()
|
||||
return {"status": "success", "package": target_package, "rank_granted": pkg_info["rank"]}
|
||||
return {"status": "success", "package": target_package, "rank_granted": pkg_info["rank"]}
|
||||
|
||||
|
||||
@router.post("/payment-intent/create")
|
||||
async def create_payment_intent(
|
||||
request: Dict[str, Any],
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""
|
||||
PaymentIntent létrehozása (Prior Intent - Kettős Lakat 1. lépés).
|
||||
|
||||
Body:
|
||||
- net_amount: float (kötelező)
|
||||
- handling_fee: float (alapértelmezett: 0)
|
||||
- target_wallet_type: string (EARNED, PURCHASED, SERVICE_COINS, VOUCHER)
|
||||
- beneficiary_id: int (opcionális)
|
||||
- currency: string (alapértelmezett: "EUR")
|
||||
- metadata: dict (opcionális)
|
||||
"""
|
||||
try:
|
||||
# Adatok kinyerése
|
||||
net_amount = request.get("net_amount")
|
||||
handling_fee = request.get("handling_fee", 0.0)
|
||||
target_wallet_type_str = request.get("target_wallet_type")
|
||||
beneficiary_id = request.get("beneficiary_id")
|
||||
currency = request.get("currency", "EUR")
|
||||
metadata = request.get("metadata", {})
|
||||
|
||||
# Validáció
|
||||
if net_amount is None or net_amount <= 0:
|
||||
raise HTTPException(status_code=400, detail="net_amount pozitív szám kell legyen")
|
||||
|
||||
if handling_fee < 0:
|
||||
raise HTTPException(status_code=400, detail="handling_fee nem lehet negatív")
|
||||
|
||||
try:
|
||||
target_wallet_type = WalletType(target_wallet_type_str)
|
||||
except ValueError:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Érvénytelen target_wallet_type: {target_wallet_type_str}. Használd: {[wt.value for wt in WalletType]}"
|
||||
)
|
||||
|
||||
# PaymentIntent létrehozása
|
||||
payment_intent = await PaymentRouter.create_payment_intent(
|
||||
db=db,
|
||||
payer_id=current_user.id,
|
||||
net_amount=net_amount,
|
||||
handling_fee=handling_fee,
|
||||
target_wallet_type=target_wallet_type,
|
||||
beneficiary_id=beneficiary_id,
|
||||
currency=currency,
|
||||
metadata=metadata
|
||||
)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"payment_intent_id": payment_intent.id,
|
||||
"intent_token": str(payment_intent.intent_token),
|
||||
"net_amount": float(payment_intent.net_amount),
|
||||
"handling_fee": float(payment_intent.handling_fee),
|
||||
"gross_amount": float(payment_intent.gross_amount),
|
||||
"currency": payment_intent.currency,
|
||||
"status": payment_intent.status.value,
|
||||
"expires_at": payment_intent.expires_at.isoformat() if payment_intent.expires_at else None,
|
||||
}
|
||||
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
except Exception as e:
|
||||
logger.error(f"PaymentIntent létrehozási hiba: {e}")
|
||||
raise HTTPException(status_code=500, detail=f"Belső hiba: {str(e)}")
|
||||
|
||||
|
||||
@router.post("/payment-intent/{payment_intent_id}/stripe-checkout")
|
||||
async def initiate_stripe_checkout(
|
||||
payment_intent_id: int,
|
||||
request: Dict[str, Any],
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""
|
||||
Stripe Checkout Session indítása PaymentIntent alapján.
|
||||
|
||||
Body:
|
||||
- success_url: string (kötelező)
|
||||
- cancel_url: string (kötelező)
|
||||
"""
|
||||
try:
|
||||
success_url = request.get("success_url")
|
||||
cancel_url = request.get("cancel_url")
|
||||
|
||||
if not success_url or not cancel_url:
|
||||
raise HTTPException(status_code=400, detail="success_url és cancel_url kötelező")
|
||||
|
||||
# Ellenőrizzük, hogy a PaymentIntent a felhasználóhoz tartozik-e
|
||||
stmt = select(PaymentIntent).where(
|
||||
PaymentIntent.id == payment_intent_id,
|
||||
PaymentIntent.payer_id == current_user.id
|
||||
)
|
||||
result = await db.execute(stmt)
|
||||
payment_intent = result.scalar_one_or_none()
|
||||
|
||||
if not payment_intent:
|
||||
raise HTTPException(status_code=404, detail="PaymentIntent nem található vagy nincs hozzáférésed")
|
||||
|
||||
# Stripe Checkout indítása
|
||||
session_data = await PaymentRouter.initiate_stripe_payment(
|
||||
db=db,
|
||||
payment_intent_id=payment_intent_id,
|
||||
success_url=success_url,
|
||||
cancel_url=cancel_url
|
||||
)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"checkout_url": session_data["checkout_url"],
|
||||
"stripe_session_id": session_data["stripe_session_id"],
|
||||
"expires_at": session_data["expires_at"],
|
||||
}
|
||||
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
except Exception as e:
|
||||
logger.error(f"Stripe Checkout indítási hiba: {e}")
|
||||
raise HTTPException(status_code=500, detail=f"Belső hiba: {str(e)}")
|
||||
|
||||
|
||||
@router.post("/payment-intent/{payment_intent_id}/process-internal")
|
||||
async def process_internal_payment(
|
||||
payment_intent_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""
|
||||
Belső ajándékozás feldolgozása (SmartDeduction használatával).
|
||||
Csak akkor engedélyezett, ha a PaymentIntent PENDING státuszú és a felhasználó a payer.
|
||||
"""
|
||||
try:
|
||||
# Ellenőrizzük, hogy a PaymentIntent a felhasználóhoz tartozik-e
|
||||
stmt = select(PaymentIntent).where(
|
||||
PaymentIntent.id == payment_intent_id,
|
||||
PaymentIntent.payer_id == current_user.id,
|
||||
PaymentIntent.status == PaymentIntentStatus.PENDING
|
||||
)
|
||||
result = await db.execute(stmt)
|
||||
payment_intent = result.scalar_one_or_none()
|
||||
|
||||
if not payment_intent:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail="PaymentIntent nem található, nincs hozzáférésed, vagy nem PENDING státuszú"
|
||||
)
|
||||
|
||||
# Belső fizetés feldolgozása
|
||||
result = await PaymentRouter.process_internal_payment(db, payment_intent_id)
|
||||
|
||||
if not result["success"]:
|
||||
raise HTTPException(status_code=400, detail=result.get("error", "Ismeretlen hiba"))
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"transaction_id": result.get("transaction_id"),
|
||||
"used_amounts": result.get("used_amounts"),
|
||||
"beneficiary_credited": result.get("beneficiary_credited", False),
|
||||
}
|
||||
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
except Exception as e:
|
||||
logger.error(f"Belső fizetés feldolgozási hiba: {e}")
|
||||
raise HTTPException(status_code=500, detail=f"Belső hiba: {str(e)}")
|
||||
|
||||
|
||||
@router.post("/stripe-webhook")
|
||||
async def stripe_webhook(
|
||||
request: Request,
|
||||
stripe_signature: Optional[str] = Header(None),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Stripe webhook végpont a Kettős Lakat validációval.
|
||||
|
||||
Stripe a következő header-t küldi: Stripe-Signature
|
||||
"""
|
||||
if not stripe_signature:
|
||||
raise HTTPException(status_code=400, detail="Missing Stripe-Signature header")
|
||||
|
||||
try:
|
||||
# Request body kiolvasása
|
||||
payload = await request.body()
|
||||
|
||||
# Webhook feldolgozása
|
||||
result = await PaymentRouter.process_stripe_webhook(
|
||||
db=db,
|
||||
payload=payload,
|
||||
signature=stripe_signature
|
||||
)
|
||||
|
||||
if not result.get("success", False):
|
||||
error_msg = result.get("error", "Unknown error")
|
||||
logger.error(f"Stripe webhook feldolgozás sikertelen: {error_msg}")
|
||||
raise HTTPException(status_code=400, detail=error_msg)
|
||||
|
||||
return result
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Stripe webhook végpont hiba: {e}")
|
||||
raise HTTPException(status_code=500, detail=f"Belső hiba: {str(e)}")
|
||||
|
||||
|
||||
@router.get("/payment-intent/{payment_intent_id}/status")
|
||||
async def get_payment_intent_status(
|
||||
payment_intent_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""
|
||||
PaymentIntent státusz lekérdezése.
|
||||
"""
|
||||
try:
|
||||
# Ellenőrizzük, hogy a PaymentIntent a felhasználóhoz tartozik-e
|
||||
stmt = select(PaymentIntent).where(
|
||||
PaymentIntent.id == payment_intent_id,
|
||||
PaymentIntent.payer_id == current_user.id
|
||||
)
|
||||
result = await db.execute(stmt)
|
||||
payment_intent = result.scalar_one_or_none()
|
||||
|
||||
if not payment_intent:
|
||||
raise HTTPException(status_code=404, detail="PaymentIntent nem található vagy nincs hozzáférésed")
|
||||
|
||||
return {
|
||||
"id": payment_intent.id,
|
||||
"intent_token": str(payment_intent.intent_token),
|
||||
"net_amount": float(payment_intent.net_amount),
|
||||
"handling_fee": float(payment_intent.handling_fee),
|
||||
"gross_amount": float(payment_intent.gross_amount),
|
||||
"currency": payment_intent.currency,
|
||||
"status": payment_intent.status.value,
|
||||
"target_wallet_type": payment_intent.target_wallet_type.value,
|
||||
"beneficiary_id": payment_intent.beneficiary_id,
|
||||
"stripe_session_id": payment_intent.stripe_session_id,
|
||||
"transaction_id": str(payment_intent.transaction_id) if payment_intent.transaction_id else None,
|
||||
"created_at": payment_intent.created_at.isoformat(),
|
||||
"updated_at": payment_intent.updated_at.isoformat(),
|
||||
"completed_at": payment_intent.completed_at.isoformat() if payment_intent.completed_at else None,
|
||||
"expires_at": payment_intent.expires_at.isoformat() if payment_intent.expires_at else None,
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"PaymentIntent státusz lekérdezési hiba: {e}")
|
||||
raise HTTPException(status_code=500, detail=f"Belső hiba: {str(e)}")
|
||||
|
||||
|
||||
@router.get("/wallet/balance")
|
||||
async def get_wallet_balance(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""
|
||||
Felhasználó pénztárca egyenlegének lekérdezése.
|
||||
"""
|
||||
try:
|
||||
stmt = select(Wallet).where(Wallet.user_id == current_user.id)
|
||||
result = await db.execute(stmt)
|
||||
wallet = result.scalar_one_or_none()
|
||||
|
||||
if not wallet:
|
||||
raise HTTPException(status_code=404, detail="Pénztárca nem található")
|
||||
|
||||
return {
|
||||
"earned": float(wallet.earned_credits),
|
||||
"purchased": float(wallet.purchased_credits),
|
||||
"service_coins": float(wallet.service_coins),
|
||||
"total": float(
|
||||
wallet.earned_credits +
|
||||
wallet.purchased_credits +
|
||||
wallet.service_coins
|
||||
),
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Pénztárca egyenleg lekérdezési hiba: {e}")
|
||||
raise HTTPException(status_code=500, detail=f"Belső hiba: {str(e)}")
|
||||
173
backend/app/api/v1/endpoints/security.py
Normal file
173
backend/app/api/v1/endpoints/security.py
Normal file
@@ -0,0 +1,173 @@
|
||||
# /opt/docker/dev/service_finder/backend/app/api/v1/endpoints/security.py
|
||||
"""
|
||||
Dual Control (Négy szem elv) API végpontok.
|
||||
Kiemelt műveletek jóváhagyási folyamata.
|
||||
"""
|
||||
import logging
|
||||
from typing import List, Optional
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.api.deps import get_db, get_current_user
|
||||
from app.models.identity import User, UserRole
|
||||
from app.services.security_service import security_service
|
||||
from app.schemas.security import (
|
||||
PendingActionCreate, PendingActionResponse, PendingActionApprove, PendingActionReject
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@router.post("/request", response_model=PendingActionResponse, status_code=status.HTTP_201_CREATED)
|
||||
async def request_action(
|
||||
request: PendingActionCreate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""
|
||||
Dual Control: Jóváhagyási kérelem indítása kiemelt művelethez.
|
||||
|
||||
Engedélyezett művelettípusok:
|
||||
- CHANGE_ROLE: Felhasználó szerepkörének módosítása
|
||||
- SET_VIP: VIP státusz beállítása
|
||||
- WALLET_ADJUST: Pénztár egyenleg módosítása (nagy összeg)
|
||||
- SOFT_DELETE_USER: Felhasználó soft delete
|
||||
- ORGANIZATION_TRANSFER: Szervezet tulajdonjog átadása
|
||||
"""
|
||||
# Csak admin és superadmin kezdeményezhet kiemelt műveleteket
|
||||
if current_user.role not in [UserRole.admin, UserRole.superadmin]:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Csak adminisztrátorok kezdeményezhetnek Dual Control műveleteket."
|
||||
)
|
||||
|
||||
try:
|
||||
action = await security_service.request_action(
|
||||
db, requester_id=current_user.id,
|
||||
action_type=request.action_type,
|
||||
payload=request.payload,
|
||||
reason=request.reason
|
||||
)
|
||||
return PendingActionResponse.from_orm(action)
|
||||
except Exception as e:
|
||||
logger.error(f"Dual Control request error: {e}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Hiba a kérelem létrehozásakor: {str(e)}"
|
||||
)
|
||||
|
||||
@router.get("/pending", response_model=List[PendingActionResponse])
|
||||
async def list_pending_actions(
|
||||
action_type: Optional[str] = None,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""
|
||||
Függőben lévő Dual Control műveletek listázása.
|
||||
|
||||
Admin és superadmin látja az összes függőben lévő műveletet.
|
||||
Egyéb felhasználók csak a sajátjaikat láthatják.
|
||||
"""
|
||||
if current_user.role in [UserRole.admin, UserRole.superadmin]:
|
||||
user_id = None
|
||||
else:
|
||||
user_id = current_user.id
|
||||
|
||||
actions = await security_service.get_pending_actions(db, user_id=user_id, action_type=action_type)
|
||||
return [PendingActionResponse.from_orm(action) for action in actions]
|
||||
|
||||
@router.post("/approve/{action_id}", response_model=PendingActionResponse)
|
||||
async def approve_action(
|
||||
action_id: int,
|
||||
approve_data: PendingActionApprove,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""
|
||||
Dual Control: Művelet jóváhagyása.
|
||||
|
||||
Csak admin/superadmin hagyhat jóvá, és nem lehet a saját kérése.
|
||||
"""
|
||||
if current_user.role not in [UserRole.admin, UserRole.superadmin]:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Csak adminisztrátorok hagyhatnak jóvá műveleteket."
|
||||
)
|
||||
|
||||
try:
|
||||
await security_service.approve_action(db, approver_id=current_user.id, action_id=action_id)
|
||||
# Frissített művelet lekérdezése
|
||||
from sqlalchemy import select
|
||||
from app.models.security import PendingAction
|
||||
stmt = select(PendingAction).where(PendingAction.id == action_id)
|
||||
action = (await db.execute(stmt)).scalar_one()
|
||||
return PendingActionResponse.from_orm(action)
|
||||
except Exception as e:
|
||||
logger.error(f"Dual Control approve error: {e}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=str(e)
|
||||
)
|
||||
|
||||
@router.post("/reject/{action_id}", response_model=PendingActionResponse)
|
||||
async def reject_action(
|
||||
action_id: int,
|
||||
reject_data: PendingActionReject,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""
|
||||
Dual Control: Művelet elutasítása.
|
||||
|
||||
Csak admin/superadmin utasíthat el, és nem lehet a saját kérése.
|
||||
"""
|
||||
if current_user.role not in [UserRole.admin, UserRole.superadmin]:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Csak adminisztrátorok utasíthatnak el műveleteket."
|
||||
)
|
||||
|
||||
try:
|
||||
await security_service.reject_action(
|
||||
db, approver_id=current_user.id,
|
||||
action_id=action_id, reason=reject_data.reason
|
||||
)
|
||||
# Frissített művelet lekérdezése
|
||||
from sqlalchemy import select
|
||||
from app.models.security import PendingAction
|
||||
stmt = select(PendingAction).where(PendingAction.id == action_id)
|
||||
action = (await db.execute(stmt)).scalar_one()
|
||||
return PendingActionResponse.from_orm(action)
|
||||
except Exception as e:
|
||||
logger.error(f"Dual Control reject error: {e}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=str(e)
|
||||
)
|
||||
|
||||
@router.get("/{action_id}", response_model=PendingActionResponse)
|
||||
async def get_action(
|
||||
action_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""
|
||||
Egy konkrét Dual Control művelet lekérdezése.
|
||||
|
||||
Csak a művelet létrehozója vagy admin/superadmin érheti el.
|
||||
"""
|
||||
from sqlalchemy import select
|
||||
from app.models.security import PendingAction
|
||||
stmt = select(PendingAction).where(PendingAction.id == action_id)
|
||||
action = (await db.execute(stmt)).scalar_one_or_none()
|
||||
if not action:
|
||||
raise HTTPException(status_code=404, detail="Művelet nem található.")
|
||||
|
||||
if current_user.role not in [UserRole.admin, UserRole.superadmin] and action.requester_id != current_user.id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Nincs jogosultságod ehhez a művelethez."
|
||||
)
|
||||
|
||||
return PendingActionResponse.from_orm(action)
|
||||
@@ -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
|
||||
@@ -8,10 +8,11 @@ from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from starlette.middleware.sessions import SessionMiddleware
|
||||
|
||||
from app.api.v1.api import api_router
|
||||
from app.api.v1.api import api_router
|
||||
from app.core.config import settings
|
||||
from app.database import AsyncSessionLocal
|
||||
from app.services.translation_service import translation_service
|
||||
from app.core.scheduler import scheduler_lifespan
|
||||
|
||||
# --- LOGGING KONFIGURÁCIÓ ---
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
@@ -20,8 +21,8 @@ logger = logging.getLogger("Sentinel-Main")
|
||||
# --- LIFESPAN (Startup/Shutdown események) ---
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
"""
|
||||
A rendszer 'ébredési' folyamata.
|
||||
"""
|
||||
A rendszer 'ébredési' folyamata.
|
||||
Hiba esetén ENG alapértelmezésre vált a rendszer.
|
||||
"""
|
||||
logger.info("🛰️ Sentinel Master System ébredése...")
|
||||
@@ -39,9 +40,13 @@ async def lifespan(app: FastAPI):
|
||||
os.makedirs(settings.STATIC_DIR, exist_ok=True)
|
||||
os.makedirs(os.path.join(settings.STATIC_DIR, "previews"), exist_ok=True)
|
||||
|
||||
yield
|
||||
|
||||
logger.info("💤 Sentinel Master System leállítása...")
|
||||
# 2. Scheduler indítása
|
||||
async with scheduler_lifespan(app):
|
||||
logger.info("⏰ Cron‑job ütemező aktiválva.")
|
||||
|
||||
yield
|
||||
|
||||
logger.info("💤 Sentinel Master System leállítása...")
|
||||
|
||||
# --- APP INICIALIZÁLÁS ---
|
||||
app = FastAPI(
|
||||
|
||||
@@ -19,6 +19,7 @@ from .asset import Asset, AssetCatalog, AssetCost, AssetEvent, AssetFinancials,
|
||||
|
||||
# 6. Üzleti logika és előfizetések
|
||||
from .core_logic import SubscriptionTier, OrganizationSubscription, CreditTransaction, ServiceSpecialty
|
||||
from .payment import PaymentIntent, PaymentIntentStatus
|
||||
|
||||
# 7. Szolgáltatások és staging
|
||||
from .service import ServiceProfile, ExpertiseTag, ServiceExpertise, ServiceStaging, DiscoveryParameter
|
||||
@@ -56,10 +57,12 @@ __all__ = [
|
||||
|
||||
"Document", "Translation", "PendingAction",
|
||||
"SubscriptionTier", "OrganizationSubscription", "CreditTransaction", "ServiceSpecialty",
|
||||
"PaymentIntent", "PaymentIntentStatus",
|
||||
"AuditLog", "VehicleOwnership", "LogSeverity",
|
||||
"SecurityAuditLog", "ProcessLog", "FinancialLedger",
|
||||
"ServiceProfile", "ExpertiseTag", "ServiceExpertise", "ServiceStaging", "DiscoveryParameter",
|
||||
"Vehicle", "UserVehicle", "VehicleCatalog", "ServiceRecord", "VehicleModelDefinition",
|
||||
"VehicleType", "FeatureDefinition", "ModelFeatureMap", "LegalDocument", "LegalAcceptance",
|
||||
"Location", "LocationType"
|
||||
]
|
||||
]
|
||||
from app.models.payment import PaymentIntent, WithdrawalRequest
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
# /opt/docker/dev/service_finder/backend/app/models/audit.py
|
||||
import enum
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from typing import Any, Optional
|
||||
from sqlalchemy import String, DateTime, JSON, ForeignKey, text, Numeric, Boolean, BigInteger, Integer
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
from sqlalchemy.sql import func
|
||||
from sqlalchemy.dialects.postgresql import UUID as PG_UUID, ENUM as PG_ENUM
|
||||
from app.database import Base
|
||||
|
||||
class SecurityAuditLog(Base):
|
||||
@@ -48,6 +51,19 @@ class ProcessLog(Base):
|
||||
details: Mapped[Any] = mapped_column(JSON, server_default=text("'{}'::jsonb"))
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
|
||||
|
||||
class LedgerEntryType(str, enum.Enum):
|
||||
DEBIT = "DEBIT"
|
||||
CREDIT = "CREDIT"
|
||||
|
||||
|
||||
class WalletType(str, enum.Enum):
|
||||
EARNED = "EARNED"
|
||||
PURCHASED = "PURCHASED"
|
||||
SERVICE_COINS = "SERVICE_COINS"
|
||||
VOUCHER = "VOUCHER"
|
||||
|
||||
|
||||
class FinancialLedger(Base):
|
||||
""" Minden pénz- és kreditmozgás központi naplója. Billing Engine alapja. """
|
||||
__tablename__ = "financial_ledger"
|
||||
@@ -56,8 +72,21 @@ class FinancialLedger(Base):
|
||||
user_id: Mapped[Optional[int]] = mapped_column(Integer, ForeignKey("identity.users.id"))
|
||||
person_id: Mapped[Optional[int]] = mapped_column(BigInteger, ForeignKey("identity.persons.id"))
|
||||
amount: Mapped[float] = mapped_column(Numeric(18, 4), nullable=False)
|
||||
currency: Mapped[Optional[str]] = mapped_column(String(10))
|
||||
transaction_type: Mapped[Optional[str]] = mapped_column(String(50))
|
||||
currency: Mapped[Optional[str]] = mapped_column(String(10))
|
||||
transaction_type: Mapped[Optional[str]] = mapped_column(String(50))
|
||||
related_agent_id: Mapped[Optional[int]] = mapped_column(Integer, ForeignKey("identity.users.id"))
|
||||
details: Mapped[Any] = mapped_column(JSON, server_default=text("'{}'::jsonb"))
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
|
||||
# Új mezők double‑entry és okos levonáshoz
|
||||
entry_type: Mapped[LedgerEntryType] = mapped_column(
|
||||
PG_ENUM(LedgerEntryType, name="ledger_entry_type", schema="audit"),
|
||||
nullable=False
|
||||
)
|
||||
balance_after: Mapped[Optional[float]] = mapped_column(Numeric(18, 4))
|
||||
wallet_type: Mapped[Optional[WalletType]] = mapped_column(
|
||||
PG_ENUM(WalletType, name="wallet_type", schema="audit")
|
||||
)
|
||||
transaction_id: Mapped[uuid.UUID] = mapped_column(
|
||||
PG_UUID(as_uuid=True), default=uuid.uuid4, nullable=False, index=True
|
||||
)
|
||||
@@ -124,6 +124,19 @@ class User(Base):
|
||||
owned_organizations: Mapped[List["Organization"]] = relationship("Organization", back_populates="owner")
|
||||
stats: Mapped[Optional["UserStats"]] = relationship("UserStats", back_populates="user", uselist=False, cascade="all, delete-orphan")
|
||||
ownership_history: Mapped[List["VehicleOwnership"]] = relationship("VehicleOwnership", back_populates="user")
|
||||
|
||||
# PaymentIntent kapcsolatok
|
||||
payment_intents_as_payer: Mapped[List["PaymentIntent"]] = relationship(
|
||||
"PaymentIntent",
|
||||
foreign_keys="[PaymentIntent.payer_id]",
|
||||
back_populates="payer"
|
||||
)
|
||||
withdrawal_requests: Mapped[List["WithdrawalRequest"]] = relationship("WithdrawalRequest", foreign_keys="[WithdrawalRequest.user_id]", back_populates="user", cascade="all, delete-orphan")
|
||||
payment_intents_as_beneficiary: Mapped[List["PaymentIntent"]] = relationship(
|
||||
"PaymentIntent",
|
||||
foreign_keys="[PaymentIntent.beneficiary_id]",
|
||||
back_populates="beneficiary"
|
||||
)
|
||||
|
||||
@property
|
||||
def tier_name(self) -> str:
|
||||
@@ -143,6 +156,7 @@ class Wallet(Base):
|
||||
|
||||
currency: Mapped[str] = mapped_column(String(3), default="HUF")
|
||||
user: Mapped["User"] = relationship("User", back_populates="wallet")
|
||||
active_vouchers: Mapped[List["ActiveVoucher"]] = relationship("ActiveVoucher", back_populates="wallet", cascade="all, delete-orphan")
|
||||
|
||||
class VerificationToken(Base):
|
||||
__tablename__ = "verification_tokens"
|
||||
@@ -171,4 +185,20 @@ class SocialAccount(Base):
|
||||
extra_data: Mapped[Any] = mapped_column(JSON, server_default=text("'{}'::jsonb"))
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
|
||||
user: Mapped["User"] = relationship("User", back_populates="social_accounts")
|
||||
user: Mapped["User"] = relationship("User", back_populates="social_accounts")
|
||||
|
||||
|
||||
class ActiveVoucher(Base):
|
||||
"""Aktív, le nem járt voucher-ek tárolása FIFO elv szerint."""
|
||||
__tablename__ = "active_vouchers"
|
||||
__table_args__ = {"schema": "identity"}
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
|
||||
wallet_id: Mapped[int] = mapped_column(Integer, ForeignKey("identity.wallets.id", ondelete="CASCADE"), nullable=False)
|
||||
amount: Mapped[float] = mapped_column(Numeric(18, 4), nullable=False)
|
||||
original_amount: Mapped[float] = mapped_column(Numeric(18, 4), nullable=False)
|
||||
expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
|
||||
# Kapcsolatok
|
||||
wallet: Mapped["Wallet"] = relationship("Wallet", back_populates="active_vouchers")
|
||||
224
backend/app/models/payment.py
Normal file
224
backend/app/models/payment.py
Normal file
@@ -0,0 +1,224 @@
|
||||
# /opt/docker/dev/service_finder/backend/app/models/payment.py
|
||||
"""
|
||||
Payment Intent modell a Stripe integrációhoz és belső fizetésekhez.
|
||||
Kettős Lakat (Double Lock) biztonságot valósít meg.
|
||||
"""
|
||||
|
||||
import enum
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from typing import Any, Optional
|
||||
from sqlalchemy import String, DateTime, JSON, ForeignKey, Numeric, Boolean, Integer, text
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
from sqlalchemy.dialects.postgresql import UUID as PG_UUID, ENUM as PG_ENUM
|
||||
from sqlalchemy.sql import func
|
||||
|
||||
from app.database import Base
|
||||
from app.models.audit import WalletType
|
||||
|
||||
|
||||
class PaymentIntentStatus(str, enum.Enum):
|
||||
"""PaymentIntent státuszok."""
|
||||
PENDING = "PENDING" # Létrehozva, vár Stripe fizetésre
|
||||
PROCESSING = "PROCESSING" # Fizetés folyamatban (belső ajándékozás)
|
||||
COMPLETED = "COMPLETED" # Sikeresen teljesítve
|
||||
FAILED = "FAILED" # Sikertelen (pl. Stripe hiba)
|
||||
CANCELLED = "CANCELLED" # Felhasználó által törölve
|
||||
EXPIRED = "EXPIRED" # Lejárt (pl. Stripe session timeout)
|
||||
|
||||
|
||||
class PaymentIntent(Base):
|
||||
"""
|
||||
Fizetési szándék (Prior Intent) a Kettős Lakat biztonsághoz.
|
||||
|
||||
Minden külső (Stripe) vagy belső fizetés előtt létre kell hozni egy PENDING
|
||||
státuszú PaymentIntent-et. A Stripe metadata tartalmazza az intent_token-t,
|
||||
így a webhook validáció során vissza lehet keresni.
|
||||
|
||||
Fontos mezők:
|
||||
- net_amount: A kedvezményezett által kapott összeg (pénztárcába kerül)
|
||||
- handling_fee: Kényelmi díj (rendszer bevétele)
|
||||
- gross_amount: net_amount + handling_fee (Stripe-nak küldött összeg)
|
||||
"""
|
||||
__tablename__ = "payment_intents"
|
||||
__table_args__ = {"schema": "audit"}
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
|
||||
# Egyedi token a Stripe metadata számára
|
||||
intent_token: Mapped[uuid.UUID] = mapped_column(
|
||||
PG_UUID(as_uuid=True), default=uuid.uuid4, unique=True, nullable=False, index=True
|
||||
)
|
||||
|
||||
# Fizető felhasználó
|
||||
payer_id: Mapped[int] = mapped_column(Integer, ForeignKey("identity.users.id"), nullable=False)
|
||||
payer: Mapped["User"] = relationship("User", foreign_keys=[payer_id], back_populates="payment_intents_as_payer")
|
||||
|
||||
# Kedvezményezett felhasználó (opcionális, ha None, akkor a rendszernek fizet)
|
||||
beneficiary_id: Mapped[Optional[int]] = mapped_column(Integer, ForeignKey("identity.users.id"))
|
||||
beneficiary: Mapped[Optional["User"]] = relationship("User", foreign_keys=[beneficiary_id], back_populates="payment_intents_as_beneficiary")
|
||||
|
||||
# Cél pénztárca típusa
|
||||
target_wallet_type: Mapped[WalletType] = mapped_column(
|
||||
PG_ENUM(WalletType, name="wallet_type", schema="audit"),
|
||||
nullable=False
|
||||
)
|
||||
|
||||
# Összeg mezők (javított a kényelmi díj kezelésére)
|
||||
net_amount: Mapped[float] = mapped_column(Numeric(18, 4), nullable=False, comment="Kedvezményezett által kapott összeg")
|
||||
handling_fee: Mapped[float] = mapped_column(Numeric(18, 4), default=0.0, comment="Kényelmi díj")
|
||||
gross_amount: Mapped[float] = mapped_column(Numeric(18, 4), nullable=False, comment="Fizetendő összeg (net + fee)")
|
||||
|
||||
currency: Mapped[str] = mapped_column(String(10), default="EUR", nullable=False)
|
||||
|
||||
# Státusz
|
||||
status: Mapped[PaymentIntentStatus] = mapped_column(
|
||||
PG_ENUM(PaymentIntentStatus, name="payment_intent_status", schema="audit"),
|
||||
default=PaymentIntentStatus.PENDING,
|
||||
nullable=False,
|
||||
index=True
|
||||
)
|
||||
|
||||
# Stripe információk (külső fizetés esetén)
|
||||
stripe_session_id: Mapped[Optional[str]] = mapped_column(String(255), unique=True, index=True)
|
||||
stripe_payment_intent_id: Mapped[Optional[str]] = mapped_column(String(255), index=True)
|
||||
stripe_customer_id: Mapped[Optional[str]] = mapped_column(String(255))
|
||||
|
||||
# Metaadatok (metadata foglalt név SQLAlchemy-ban, ezért meta_data)
|
||||
meta_data: Mapped[Any] = mapped_column(JSON, server_default=text("'{}'::jsonb"), name="metadata")
|
||||
|
||||
# Időbélyegek
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
|
||||
completed_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True))
|
||||
expires_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), comment="Stripe session lejárati ideje")
|
||||
|
||||
# Tranzakció kapcsolat
|
||||
transaction_id: Mapped[Optional[uuid.UUID]] = mapped_column(PG_UUID(as_uuid=True), comment="Kapcsolódó FinancialLedger transaction_id")
|
||||
|
||||
# Soft delete
|
||||
is_deleted: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
deleted_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True))
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<PaymentIntent {self.id}: {self.status} {self.gross_amount}{self.currency}>"
|
||||
|
||||
def mark_completed(self, transaction_id: Optional[uuid.UUID] = None) -> None:
|
||||
"""PaymentIntent befejezése sikeres fizetés után."""
|
||||
self.status = PaymentIntentStatus.COMPLETED
|
||||
self.completed_at = datetime.utcnow()
|
||||
if transaction_id:
|
||||
self.transaction_id = transaction_id
|
||||
|
||||
def mark_failed(self, reason: Optional[str] = None) -> None:
|
||||
"""PaymentIntent sikertelen státuszba helyezése."""
|
||||
self.status = PaymentIntentStatus.FAILED
|
||||
if reason and self.meta_data:
|
||||
self.meta_data = {**self.meta_data, "failure_reason": reason}
|
||||
|
||||
def is_valid_for_webhook(self) -> bool:
|
||||
"""Ellenőrzi, hogy a PaymentIntent érvényes-e webhook feldolgozásra."""
|
||||
return (
|
||||
self.status == PaymentIntentStatus.PENDING
|
||||
and not self.is_deleted
|
||||
and (self.expires_at is None or self.expires_at > datetime.utcnow())
|
||||
)
|
||||
|
||||
|
||||
# Import User modell a relationship-ekhez (circular import elkerülésére)
|
||||
from app.models.identity import User
|
||||
|
||||
|
||||
class WithdrawalPayoutMethod(str, enum.Enum):
|
||||
"""Kifizetési módok."""
|
||||
FIAT_BANK = "FIAT_BANK" # Banki átutalás (SEPA)
|
||||
CRYPTO_USDT = "CRYPTO_USDT" # USDT (ERC20/TRC20)
|
||||
|
||||
|
||||
class WithdrawalRequestStatus(str, enum.Enum):
|
||||
"""Kifizetési kérelem státuszai."""
|
||||
PENDING = "PENDING" # Beküldve, admin ellenőrzésre vár
|
||||
APPROVED = "APPROVED" # Jóváhagyva, kifizetés folyamatban
|
||||
REJECTED = "REJECTED" # Elutasítva (pl. hiányzó bizonylat)
|
||||
COMPLETED = "COMPLETED" # Kifizetés teljesítve
|
||||
CANCELLED = "CANCELLED" # Felhasználó által visszavonva
|
||||
|
||||
|
||||
class WithdrawalRequest(Base):
|
||||
"""
|
||||
Kifizetési kérelem (Withdrawal Request) a felhasználók Earned zsebéből való pénzkivételhez.
|
||||
|
||||
A felhasználó beküld egy kérést, amely admin jóváhagyást igényel.
|
||||
Ha 14 napon belül nem kerül jóváhagyásra, automatikusan REJECTED lesz és a pénz visszakerül a Earned zsebbe.
|
||||
"""
|
||||
__tablename__ = "withdrawal_requests"
|
||||
__table_args__ = {"schema": "audit"}
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
|
||||
# Felhasználó aki a kérést benyújtotta
|
||||
user_id: Mapped[int] = mapped_column(Integer, ForeignKey("identity.users.id"), nullable=False)
|
||||
user: Mapped["User"] = relationship("User", back_populates="withdrawal_requests", foreign_keys=[user_id])
|
||||
|
||||
# Összeg és pénznem
|
||||
amount: Mapped[float] = mapped_column(Numeric(18, 4), nullable=False)
|
||||
currency: Mapped[str] = mapped_column(String(10), default="EUR", nullable=False)
|
||||
|
||||
# Kifizetési mód
|
||||
payout_method: Mapped[WithdrawalPayoutMethod] = mapped_column(
|
||||
PG_ENUM(WithdrawalPayoutMethod, name="withdrawal_payout_method", schema="audit"),
|
||||
nullable=False
|
||||
)
|
||||
|
||||
# Státusz
|
||||
status: Mapped[WithdrawalRequestStatus] = mapped_column(
|
||||
PG_ENUM(WithdrawalRequestStatus, name="withdrawal_request_status", schema="audit"),
|
||||
default=WithdrawalRequestStatus.PENDING,
|
||||
nullable=False,
|
||||
index=True
|
||||
)
|
||||
|
||||
# Okozata (pl. admin megjegyzés vagy automatikus elutasítás oka)
|
||||
reason: Mapped[Optional[str]] = mapped_column(String(500))
|
||||
|
||||
# Admin információk
|
||||
approved_by_id: Mapped[Optional[int]] = mapped_column(Integer, ForeignKey("identity.users.id"))
|
||||
approved_by: Mapped[Optional["User"]] = relationship("User", foreign_keys=[approved_by_id])
|
||||
approved_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True))
|
||||
|
||||
# Tranzakció kapcsolat (ha a pénz visszakerül a zsebbe)
|
||||
refund_transaction_id: Mapped[Optional[uuid.UUID]] = mapped_column(PG_UUID(as_uuid=True))
|
||||
|
||||
# Időbélyegek
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
|
||||
|
||||
# Soft delete
|
||||
is_deleted: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
deleted_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True))
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<WithdrawalRequest {self.id}: {self.status} {self.amount}{self.currency}>"
|
||||
|
||||
def approve(self, admin_user_id: int) -> None:
|
||||
"""Admin jóváhagyás."""
|
||||
self.status = WithdrawalRequestStatus.APPROVED
|
||||
self.approved_by_id = admin_user_id
|
||||
self.approved_at = datetime.utcnow()
|
||||
self.reason = None
|
||||
|
||||
def reject(self, reason: str) -> None:
|
||||
"""Admin elutasítás."""
|
||||
self.status = WithdrawalRequestStatus.REJECTED
|
||||
self.reason = reason
|
||||
|
||||
def cancel(self) -> None:
|
||||
"""Felhasználó visszavonja a kérést."""
|
||||
self.status = WithdrawalRequestStatus.CANCELLED
|
||||
self.reason = "User cancelled"
|
||||
|
||||
def is_expired(self, days: int = 14) -> bool:
|
||||
"""Ellenőrzi, hogy a kérelem lejárt-e (14 nap)."""
|
||||
from datetime import timedelta
|
||||
expiry_date = self.created_at + timedelta(days=days)
|
||||
return datetime.utcnow() > expiry_date
|
||||
65
backend/app/schemas/security.py
Normal file
65
backend/app/schemas/security.py
Normal file
@@ -0,0 +1,65 @@
|
||||
# /opt/docker/dev/service_finder/backend/app/schemas/security.py
|
||||
"""
|
||||
Dual Control (Négy szem elv) sémák.
|
||||
"""
|
||||
from datetime import datetime
|
||||
from typing import Optional, Dict, Any
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from app.models.security import ActionStatus
|
||||
|
||||
# --- Request schemas ---
|
||||
|
||||
class PendingActionCreate(BaseModel):
|
||||
""" Dual Control kérelem létrehozása. """
|
||||
action_type: str = Field(..., description="Művelettípus (pl. CHANGE_ROLE, SET_VIP)")
|
||||
payload: Dict[str, Any] = Field(..., description="Művelet specifikus adatok")
|
||||
reason: Optional[str] = Field(None, description="Indoklás a kérelemhez")
|
||||
|
||||
class PendingActionApprove(BaseModel):
|
||||
""" Művelet jóváhagyása. """
|
||||
comment: Optional[str] = Field(None, description="Opcionális megjegyzés")
|
||||
|
||||
class PendingActionReject(BaseModel):
|
||||
""" Művelet elutasítása. """
|
||||
reason: str = Field(..., description="Elutasítás oka")
|
||||
|
||||
# --- Response schemas ---
|
||||
|
||||
class UserLite(BaseModel):
|
||||
""" Felhasználó alapvető adatai. """
|
||||
id: int
|
||||
email: str
|
||||
role: str
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
class PendingActionResponse(BaseModel):
|
||||
""" Dual Control művelet válasza. """
|
||||
id: int
|
||||
requester_id: int
|
||||
approver_id: Optional[int]
|
||||
status: ActionStatus
|
||||
action_type: str
|
||||
payload: Dict[str, Any]
|
||||
reason: Optional[str]
|
||||
created_at: datetime
|
||||
expires_at: datetime
|
||||
processed_at: Optional[datetime]
|
||||
|
||||
# Kapcsolatok
|
||||
requester: Optional[UserLite] = None
|
||||
approver: Optional[UserLite] = None
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
# --- List response ---
|
||||
|
||||
class PendingActionList(BaseModel):
|
||||
""" Dual Control műveletek listája. """
|
||||
items: list[PendingActionResponse]
|
||||
total: int
|
||||
page: int
|
||||
size: int
|
||||
@@ -59,10 +59,34 @@ class SecurityService:
|
||||
if action.requester_id == approver_id:
|
||||
raise Exception("Saját kérést nem hagyhatsz jóvá!")
|
||||
|
||||
# Üzleti logika (pl. Role változtatás)
|
||||
# Üzleti logika a művelettípus alapján
|
||||
if action.action_type == "CHANGE_ROLE":
|
||||
target_user = (await db.execute(select(User).where(User.id == action.payload.get("user_id")))).scalar_one_or_none()
|
||||
if target_user: target_user.role = action.payload.get("new_role")
|
||||
|
||||
elif action.action_type == "SET_VIP":
|
||||
target_user = (await db.execute(select(User).where(User.id == action.payload.get("user_id")))).scalar_one_or_none()
|
||||
if target_user: target_user.is_vip = action.payload.get("is_vip", True)
|
||||
|
||||
elif action.action_type == "WALLET_ADJUST":
|
||||
from app.models.identity import Wallet
|
||||
wallet = (await db.execute(select(Wallet).where(Wallet.user_id == action.payload.get("user_id")))).scalar_one_or_none()
|
||||
if wallet:
|
||||
amount = action.payload.get("amount", 0)
|
||||
wallet.balance += amount
|
||||
|
||||
elif action.action_type == "SOFT_DELETE_USER":
|
||||
target_user = (await db.execute(select(User).where(User.id == action.payload.get("user_id")))).scalar_one_or_none()
|
||||
if target_user:
|
||||
target_user.is_deleted = True
|
||||
target_user.is_active = False
|
||||
|
||||
# Audit log
|
||||
await self.log_event(
|
||||
db, user_id=approver_id, action=f"APPROVE_{action.action_type}",
|
||||
severity=LogSeverity.info, target_type="PendingAction", target_id=str(action_id),
|
||||
new_data={"action_id": action_id, "action_type": action.action_type}
|
||||
)
|
||||
|
||||
action.status = ActionStatus.approved
|
||||
action.approver_id = approver_id
|
||||
@@ -84,6 +108,40 @@ class SecurityService:
|
||||
return False
|
||||
return True
|
||||
|
||||
async def reject_action(self, db: AsyncSession, approver_id: int, action_id: int, reason: str = None):
|
||||
""" Művelet elutasítása. """
|
||||
stmt = select(PendingAction).where(PendingAction.id == action_id)
|
||||
action = (await db.execute(stmt)).scalar_one_or_none()
|
||||
|
||||
if not action or action.status != ActionStatus.pending:
|
||||
raise Exception("Művelet nem található.")
|
||||
if action.requester_id == approver_id:
|
||||
raise Exception("Saját kérést nem utasíthatod el!")
|
||||
|
||||
action.status = ActionStatus.rejected
|
||||
action.approver_id = approver_id
|
||||
action.processed_at = datetime.now(timezone.utc)
|
||||
if reason:
|
||||
action.reason = f"Elutasítva: {reason}"
|
||||
|
||||
await self.log_event(
|
||||
db, user_id=approver_id, action=f"REJECT_{action.action_type}",
|
||||
severity=LogSeverity.warning, target_type="PendingAction", target_id=str(action_id),
|
||||
new_data={"action_id": action_id, "reason": reason}
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
async def get_pending_actions(self, db: AsyncSession, user_id: int = None, action_type: str = None):
|
||||
""" Függőben lévő műveletek lekérdezése. """
|
||||
stmt = select(PendingAction).where(PendingAction.status == ActionStatus.pending)
|
||||
if user_id:
|
||||
stmt = stmt.where(PendingAction.requester_id == user_id)
|
||||
if action_type:
|
||||
stmt = stmt.where(PendingAction.action_type == action_type)
|
||||
stmt = stmt.order_by(PendingAction.created_at.desc())
|
||||
result = await db.execute(stmt)
|
||||
return result.scalars().all()
|
||||
|
||||
async def _execute_emergency_lock(self, db: AsyncSession, user_id: int, reason: str):
|
||||
if not user_id: return
|
||||
user = (await db.execute(select(User).where(User.id == user_id))).scalar_one_or_none()
|
||||
|
||||
236
backend/app/services/stripe_adapter.py
Normal file
236
backend/app/services/stripe_adapter.py
Normal file
@@ -0,0 +1,236 @@
|
||||
# /opt/docker/dev/service_finder/backend/app/services/stripe_adapter.py
|
||||
"""
|
||||
Stripe integrációs adapter a Payment Router számára.
|
||||
Kezeli a Stripe Checkout Session létrehozását és a webhook validációt.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import Dict, Any, Optional, Tuple
|
||||
from datetime import datetime, timedelta
|
||||
from decimal import Decimal
|
||||
|
||||
from app.core.config import settings
|
||||
from app.models.payment import PaymentIntent, PaymentIntentStatus
|
||||
from app.models.audit import WalletType
|
||||
|
||||
logger = logging.getLogger("stripe-adapter")
|
||||
|
||||
# Try to import stripe, but handle the case when it's not installed
|
||||
try:
|
||||
import stripe
|
||||
STRIPE_AVAILABLE = True
|
||||
except ImportError:
|
||||
stripe = None
|
||||
STRIPE_AVAILABLE = False
|
||||
logger.warning("Stripe module not installed. Stripe functionality will be disabled.")
|
||||
|
||||
|
||||
class StripeAdapter:
|
||||
"""Stripe API adapter a fizetési gateway integrációhoz."""
|
||||
|
||||
def __init__(self):
|
||||
"""Inicializálja a Stripe klienst a konfigurációból."""
|
||||
# Use getattr with defaults for missing settings
|
||||
self.stripe_api_key = getattr(settings, 'STRIPE_SECRET_KEY', None)
|
||||
self.webhook_secret = getattr(settings, 'STRIPE_WEBHOOK_SECRET', None)
|
||||
self.currency = getattr(settings, 'STRIPE_CURRENCY', "EUR")
|
||||
|
||||
# Check if stripe module is available
|
||||
if not STRIPE_AVAILABLE:
|
||||
logger.warning("Stripe Python module not installed. Stripe adapter disabled.")
|
||||
self.stripe_available = False
|
||||
elif not self.stripe_api_key:
|
||||
logger.warning("STRIPE_SECRET_KEY nincs beállítva, Stripe adapter nem működik")
|
||||
self.stripe_available = False
|
||||
else:
|
||||
stripe.api_key = self.stripe_api_key
|
||||
self.stripe_available = True
|
||||
logger.info(f"Stripe adapter inicializálva currency={self.currency}")
|
||||
|
||||
async def create_checkout_session(
|
||||
self,
|
||||
payment_intent: PaymentIntent,
|
||||
success_url: str,
|
||||
cancel_url: str,
|
||||
metadata: Optional[Dict[str, Any]] = None
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Stripe Checkout Session létrehozása a PaymentIntent alapján.
|
||||
|
||||
Args:
|
||||
payment_intent: A PaymentIntent objektum
|
||||
success_url: Sikeres fizetés után átirányítási URL
|
||||
cancel_url: Megszakított fizetés után átirányítási URL
|
||||
metadata: Extra metadata a Stripe számára
|
||||
|
||||
Returns:
|
||||
Dict: Stripe Checkout Session adatai
|
||||
"""
|
||||
if not self.stripe_available:
|
||||
raise ValueError("Stripe nem elérhető, STRIPE_SECRET_KEY hiányzik")
|
||||
|
||||
if payment_intent.status != PaymentIntentStatus.PENDING:
|
||||
raise ValueError(f"PaymentIntent nem PENDING státuszú: {payment_intent.status}")
|
||||
|
||||
# Alap metadata (kötelező: intent_token)
|
||||
base_metadata = {
|
||||
"intent_token": str(payment_intent.intent_token),
|
||||
"payment_intent_id": payment_intent.id,
|
||||
"payer_id": payment_intent.payer_id,
|
||||
"target_wallet_type": payment_intent.target_wallet_type.value,
|
||||
}
|
||||
|
||||
if payment_intent.beneficiary_id:
|
||||
base_metadata["beneficiary_id"] = payment_intent.beneficiary_id
|
||||
|
||||
# Egyesített metadata
|
||||
final_metadata = {**base_metadata, **(metadata or {})}
|
||||
|
||||
try:
|
||||
# Stripe Checkout Session létrehozása
|
||||
session = stripe.checkout.Session.create(
|
||||
payment_method_types=["card"],
|
||||
line_items=[
|
||||
{
|
||||
"price_data": {
|
||||
"currency": self.currency.lower(),
|
||||
"product_data": {
|
||||
"name": f"Service Finder - {payment_intent.target_wallet_type.value} feltöltés",
|
||||
"description": f"Net: {payment_intent.net_amount} {self.currency}, Fee: {payment_intent.handling_fee} {self.currency}",
|
||||
},
|
||||
"unit_amount": int(payment_intent.gross_amount * 100), # Stripe centben várja
|
||||
},
|
||||
"quantity": 1,
|
||||
}
|
||||
],
|
||||
mode="payment",
|
||||
success_url=success_url,
|
||||
cancel_url=cancel_url,
|
||||
client_reference_id=str(payment_intent.id),
|
||||
metadata=final_metadata,
|
||||
expires_at=int((datetime.utcnow() + timedelta(hours=24)).timestamp()),
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f"Stripe Checkout Session létrehozva: {session.id}, "
|
||||
f"amount={payment_intent.gross_amount}{self.currency}, "
|
||||
f"intent_token={payment_intent.intent_token}"
|
||||
)
|
||||
|
||||
return {
|
||||
"session_id": session.id,
|
||||
"url": session.url,
|
||||
"payment_intent_id": session.payment_intent,
|
||||
"expires_at": datetime.fromtimestamp(session.expires_at),
|
||||
"metadata": final_metadata,
|
||||
}
|
||||
|
||||
except stripe.error.StripeError as e:
|
||||
logger.error(f"Stripe hiba Checkout Session létrehozásakor: {e}")
|
||||
raise ValueError(f"Stripe hiba: {e.user_message if hasattr(e, 'user_message') else str(e)}")
|
||||
|
||||
async def verify_webhook_signature(
|
||||
self,
|
||||
payload: bytes,
|
||||
signature: str
|
||||
) -> Tuple[bool, Optional[Dict[str, Any]]]:
|
||||
"""
|
||||
Stripe webhook aláírás validálása (Kettős Lakat - 1. lépés).
|
||||
|
||||
Args:
|
||||
payload: A nyers HTTP request body
|
||||
signature: A Stripe-Signature header értéke
|
||||
|
||||
Returns:
|
||||
Tuple: (sikeres validáció, event adatok vagy None)
|
||||
"""
|
||||
if not self.webhook_secret:
|
||||
logger.error("STRIPE_WEBHOOK_SECRET nincs beállítva, webhook validáció sikertelen")
|
||||
return False, None
|
||||
|
||||
try:
|
||||
event = stripe.Webhook.construct_event(
|
||||
payload, signature, self.webhook_secret
|
||||
)
|
||||
logger.info(f"Stripe webhook validálva: {event.type} (id: {event.id})")
|
||||
return True, event
|
||||
|
||||
except stripe.error.SignatureVerificationError as e:
|
||||
logger.error(f"Stripe webhook aláírás érvénytelen: {e}")
|
||||
return False, None
|
||||
except Exception as e:
|
||||
logger.error(f"Stripe webhook feldolgozási hiba: {e}")
|
||||
return False, None
|
||||
|
||||
async def handle_checkout_completed(
|
||||
self,
|
||||
event: Dict[str, Any]
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
checkout.session.completed esemény feldolgozása.
|
||||
|
||||
Args:
|
||||
event: Stripe webhook event
|
||||
|
||||
Returns:
|
||||
Dict: Feldolgozási eredmény
|
||||
"""
|
||||
session = event["data"]["object"]
|
||||
|
||||
# Metadata kinyerése
|
||||
metadata = session.get("metadata", {})
|
||||
intent_token = metadata.get("intent_token")
|
||||
|
||||
if not intent_token:
|
||||
logger.error("Stripe session metadata nem tartalmaz intent_token-t")
|
||||
return {"success": False, "error": "Missing intent_token in metadata"}
|
||||
|
||||
# Összeg ellenőrzése (cent -> valuta)
|
||||
amount_total = session.get("amount_total", 0) / 100.0 # Centből valuta
|
||||
|
||||
logger.info(
|
||||
f"Stripe checkout completed: session={session['id']}, "
|
||||
f"amount={amount_total}, intent_token={intent_token}"
|
||||
)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"session_id": session["id"],
|
||||
"payment_intent_id": session.get("payment_intent"),
|
||||
"amount_total": amount_total,
|
||||
"currency": session.get("currency", "eur").upper(),
|
||||
"metadata": metadata,
|
||||
"intent_token": intent_token,
|
||||
}
|
||||
|
||||
async def handle_payment_intent_succeeded(
|
||||
self,
|
||||
event: Dict[str, Any]
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
payment_intent.succeeded esemény feldolgozása.
|
||||
|
||||
Args:
|
||||
event: Stripe webhook event
|
||||
|
||||
Returns:
|
||||
Dict: Feldolgozási eredmény
|
||||
"""
|
||||
payment_intent = event["data"]["object"]
|
||||
|
||||
logger.info(
|
||||
f"Stripe payment intent succeeded: {payment_intent['id']}, "
|
||||
f"amount={payment_intent['amount']/100}"
|
||||
)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"payment_intent_id": payment_intent["id"],
|
||||
"amount": payment_intent["amount"] / 100.0,
|
||||
"currency": payment_intent.get("currency", "eur").upper(),
|
||||
"status": payment_intent.get("status"),
|
||||
}
|
||||
|
||||
|
||||
# Globális példány
|
||||
stripe_adapter = StripeAdapter()
|
||||
209
backend/app/test_billing_engine.py
Normal file
209
backend/app/test_billing_engine.py
Normal file
@@ -0,0 +1,209 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Billing Engine tesztelő szkript.
|
||||
Ellenőrzi, hogy a billing_engine.py fájl helyesen működik-e.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import sys
|
||||
import os
|
||||
|
||||
# Add the parent directory to the path
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
from app.core.config import settings
|
||||
from app.services.billing_engine import PricingCalculator, SmartDeduction, AtomicTransactionManager
|
||||
from app.models.identity import UserRole
|
||||
|
||||
|
||||
async def test_pricing_calculator():
|
||||
"""Árképzési számoló tesztelése."""
|
||||
print("=== PricingCalculator teszt ===")
|
||||
|
||||
# Mock database session (nem használjuk valódi adatbázist)
|
||||
class MockSession:
|
||||
pass
|
||||
|
||||
db = MockSession()
|
||||
|
||||
# Alap teszt
|
||||
base_amount = 100.0
|
||||
|
||||
# 1. Alapár (HU, user)
|
||||
final_price = await PricingCalculator.calculate_final_price(
|
||||
db, base_amount, "HU", UserRole.user
|
||||
)
|
||||
print(f"HU, user: {base_amount} -> {final_price} (várt: 100.0)")
|
||||
assert abs(final_price - 100.0) < 0.01
|
||||
|
||||
# 2. UK árszorzó
|
||||
final_price = await PricingCalculator.calculate_final_price(
|
||||
db, base_amount, "GB", UserRole.user
|
||||
)
|
||||
print(f"GB, user: {base_amount} -> {final_price} (várt: 120.0)")
|
||||
assert abs(final_price - 120.0) < 0.01
|
||||
|
||||
# 3. admin kedvezmény (30%)
|
||||
final_price = await PricingCalculator.calculate_final_price(
|
||||
db, base_amount, "HU", UserRole.admin
|
||||
)
|
||||
print(f"HU, admin: {base_amount} -> {final_price} (várt: 70.0)")
|
||||
assert abs(final_price - 70.0) < 0.01
|
||||
|
||||
# 4. Kombinált (UK + superadmin - 50%)
|
||||
final_price = await PricingCalculator.calculate_final_price(
|
||||
db, base_amount, "GB", UserRole.superadmin
|
||||
)
|
||||
print(f"GB, superadmin: {base_amount} -> {final_price} (várt: 60.0)")
|
||||
assert abs(final_price - 60.0) < 0.01
|
||||
|
||||
# 5. Egyedi kedvezmények
|
||||
discounts = [
|
||||
{"type": "percentage", "value": 10}, # 10% kedvezmény
|
||||
{"type": "fixed", "value": 5}, # 5 egység kedvezmény
|
||||
]
|
||||
final_price = await PricingCalculator.calculate_final_price(
|
||||
db, base_amount, "HU", UserRole.user, discounts
|
||||
)
|
||||
print(f"HU, user + discounts: {base_amount} -> {final_price} (várt: 85.0)")
|
||||
assert abs(final_price - 85.0) < 0.01
|
||||
|
||||
print("✓ PricingCalculator teszt sikeres!\n")
|
||||
|
||||
|
||||
async def test_smart_deduction_logic():
|
||||
"""Intelligens levonás logikájának tesztelése (mock adatokkal)."""
|
||||
print("=== SmartDeduction logika teszt ===")
|
||||
|
||||
# Mock wallet objektum
|
||||
class MockWallet:
|
||||
def __init__(self):
|
||||
self.earned_balance = 50.0
|
||||
self.purchased_balance = 30.0
|
||||
self.service_coins_balance = 20.0
|
||||
self.id = 1
|
||||
|
||||
# Mock database session
|
||||
class MockSession:
|
||||
async def commit(self):
|
||||
pass
|
||||
|
||||
async def execute(self, stmt):
|
||||
class MockResult:
|
||||
def scalar_one_or_none(self):
|
||||
return MockWallet()
|
||||
|
||||
return MockResult()
|
||||
|
||||
db = MockSession()
|
||||
|
||||
print("SmartDeduction osztály metódusai:")
|
||||
print(f"- calculate_final_price: {'van' if hasattr(PricingCalculator, 'calculate_final_price') else 'nincs'}")
|
||||
print(f"- deduct_from_wallets: {'van' if hasattr(SmartDeduction, 'deduct_from_wallets') else 'nincs'}")
|
||||
print(f"- process_voucher_expiration: {'van' if hasattr(SmartDeduction, 'process_voucher_expiration') else 'nincs'}")
|
||||
|
||||
print("✓ SmartDeduction struktúra ellenőrizve!\n")
|
||||
|
||||
|
||||
async def test_atomic_transaction_manager():
|
||||
"""Atomikus tranzakciókezelő struktúrájának ellenőrzése."""
|
||||
print("=== AtomicTransactionManager struktúra teszt ===")
|
||||
|
||||
print("AtomicTransactionManager osztály metódusai:")
|
||||
print(f"- atomic_billing_transaction: {'van' if hasattr(AtomicTransactionManager, 'atomic_billing_transaction') else 'nincs'}")
|
||||
print(f"- get_transaction_history: {'van' if hasattr(AtomicTransactionManager, 'get_transaction_history') else 'nincs'}")
|
||||
|
||||
# Ellenőrizzük, hogy a szükséges importok megvannak-e
|
||||
try:
|
||||
from app.models.audit import LedgerEntryType, WalletType
|
||||
print(f"- LedgerEntryType importálva: {LedgerEntryType}")
|
||||
print(f"- WalletType importálva: {WalletType}")
|
||||
except ImportError as e:
|
||||
print(f"✗ Import hiba: {e}")
|
||||
|
||||
print("✓ AtomicTransactionManager struktúra ellenőrizve!\n")
|
||||
|
||||
|
||||
async def test_file_completeness():
|
||||
"""Fájl teljességének ellenőrzése."""
|
||||
print("=== billing_engine.py fájl teljesség teszt ===")
|
||||
|
||||
file_path = "backend/app/services/billing_engine.py"
|
||||
|
||||
if not os.path.exists(file_path):
|
||||
print(f"✗ A fájl nem létezik: {file_path}")
|
||||
return
|
||||
|
||||
with open(file_path, 'r', encoding='utf-8') as f:
|
||||
content = f.read()
|
||||
|
||||
# Ellenőrizzük a kulcsszavakat
|
||||
checks = [
|
||||
("class PricingCalculator", "PricingCalculator osztály"),
|
||||
("class SmartDeduction", "SmartDeduction osztály"),
|
||||
("class AtomicTransactionManager", "AtomicTransactionManager osztály"),
|
||||
("calculate_final_price", "calculate_final_price metódus"),
|
||||
("deduct_from_wallets", "deduct_from_wallets metódus"),
|
||||
("atomic_billing_transaction", "atomic_billing_transaction metódus"),
|
||||
("from app.models.identity import", "identity model import"),
|
||||
("from app.models.audit import", "audit model import"),
|
||||
]
|
||||
|
||||
all_passed = True
|
||||
for keyword, description in checks:
|
||||
if keyword in content:
|
||||
print(f"✓ {description} megtalálva")
|
||||
else:
|
||||
print(f"✗ {description} HIÁNYZIK")
|
||||
all_passed = False
|
||||
|
||||
# Ellenőrizzük a fájl végét
|
||||
lines = content.strip().split('\n')
|
||||
last_line = lines[-1].strip() if lines else ""
|
||||
|
||||
if last_line and not last_line.startswith('#'):
|
||||
print(f"✓ Fájl vége rendben: '{last_line[:50]}...'")
|
||||
else:
|
||||
print(f"✗ Fájl vége lehet hiányos: '{last_line}'")
|
||||
|
||||
print(f"✓ Fájl mérete: {len(content)} karakter, {len(lines)} sor")
|
||||
|
||||
if all_passed:
|
||||
print("✓ billing_engine.py fájl teljesség teszt sikeres!\n")
|
||||
else:
|
||||
print("✗ billing_engine.py fájl hiányos!\n")
|
||||
|
||||
|
||||
async def main():
|
||||
"""Fő tesztfolyamat."""
|
||||
print("🤖 Billing Engine tesztelés indítása...\n")
|
||||
|
||||
try:
|
||||
await test_file_completeness()
|
||||
await test_pricing_calculator()
|
||||
await test_smart_deduction_logic()
|
||||
await test_atomic_transaction_manager()
|
||||
|
||||
print("=" * 50)
|
||||
print("✅ ÖSSZES TESZT SIKERES!")
|
||||
print("A Billing Engine implementáció alapvetően működőképes.")
|
||||
print("\nKövetkező lépések:")
|
||||
print("1. Valódi adatbázis kapcsolattal tesztelés")
|
||||
print("2. Voucher kezelés tesztelése")
|
||||
print("3. Atomikus tranzakciók integrációs tesztje")
|
||||
print("4. API endpoint integráció")
|
||||
|
||||
except Exception as e:
|
||||
print(f"\n❌ TESZT SIKERTELEN: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
return 1
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
exit_code = asyncio.run(main())
|
||||
sys.exit(exit_code)
|
||||
Reference in New Issue
Block a user