# /opt/docker/dev/service_finder/backend/app/services/payment_router.py """ Payment Router - Fizetési irányító a Kettős Lakat (Double Lock) biztonsággal. Felelős: 1. PaymentIntent létrehozása (Prior Intent) 2. Stripe Checkout Session indítása vagy belső ajándékozás kezelése 3. Webhook validáció és atomi tranzakció végrehajtása 4. SmartDeduction integráció belső fizetésekhez """ import logging import uuid from datetime import datetime, timedelta from typing import Dict, Any, Optional, Tuple from decimal import Decimal from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy import select, update from app.models.marketplace.payment import PaymentIntent, PaymentIntentStatus from app.models import WalletType from app.models.identity import User, Wallet, ActiveVoucher from app.services.billing_engine import AtomicTransactionManager, SmartDeduction from app.services.stripe_adapter import stripe_adapter logger = logging.getLogger("payment-router") class PaymentRouter: """Fizetési irányító a Kettős Lakat biztonsággal.""" @classmethod async def create_payment_intent( cls, db: AsyncSession, payer_id: int, net_amount: float, handling_fee: float, target_wallet_type: WalletType, beneficiary_id: Optional[int] = None, currency: str = "EUR", metadata: Optional[Dict[str, Any]] = None ) -> PaymentIntent: """ PaymentIntent létrehozása (Prior Intent - Kettős Lakat 1. lépés). Args: db: Database session payer_id: Fizető felhasználó ID net_amount: Kedvezményezett által kapott összeg handling_fee: Kényelmi díj target_wallet_type: Cél pénztárca típus beneficiary_id: Kedvezményezett felhasználó ID (opcionális) currency: Pénznem metadata: Extra metadata Returns: PaymentIntent: Létrehozott PaymentIntent """ # Összeg validáció if net_amount <= 0: raise ValueError("net_amount pozitív szám kell legyen") if handling_fee < 0: raise ValueError("handling_fee nem lehet negatív") gross_amount = net_amount + handling_fee # Felhasználó ellenőrzése payer_stmt = select(User).where(User.id == payer_id) payer_result = await db.execute(payer_stmt) payer = payer_result.scalar_one_or_none() if not payer: raise ValueError(f"Payer nem található: id={payer_id}") # Beneficiary ellenőrzése (ha meg van adva) if beneficiary_id: beneficiary_stmt = select(User).where(User.id == beneficiary_id) beneficiary_result = await db.execute(beneficiary_stmt) beneficiary = beneficiary_result.scalar_one_or_none() if not beneficiary: raise ValueError(f"Beneficiary nem található: id={beneficiary_id}") # PaymentIntent létrehozása payment_intent = PaymentIntent( payer_id=payer_id, beneficiary_id=beneficiary_id, target_wallet_type=target_wallet_type, net_amount=Decimal(str(net_amount)), handling_fee=Decimal(str(handling_fee)), gross_amount=Decimal(str(gross_amount)), currency=currency, status=PaymentIntentStatus.PENDING, expires_at=datetime.utcnow() + timedelta(hours=24), metadata=metadata or {}, ) db.add(payment_intent) await db.flush() await db.refresh(payment_intent) logger.info( f"PaymentIntent létrehozva: id={payment_intent.id}, " f"token={payment_intent.intent_token}, " f"net={net_amount}, fee={handling_fee}, gross={gross_amount}" ) return payment_intent @classmethod async def initiate_stripe_payment( cls, db: AsyncSession, payment_intent_id: int, success_url: str, cancel_url: str ) -> Dict[str, Any]: """ Stripe fizetés indítása PaymentIntent alapján. Args: db: Database session payment_intent_id: PaymentIntent ID success_url: Sikeres fizetés URL cancel_url: Megszakítás URL Returns: Dict: Stripe Checkout Session adatai """ # PaymentIntent lekérdezése stmt = select(PaymentIntent).where(PaymentIntent.id == payment_intent_id) result = await db.execute(stmt) payment_intent = result.scalar_one_or_none() if not payment_intent: raise ValueError(f"PaymentIntent nem található: id={payment_intent_id}") if payment_intent.status != PaymentIntentStatus.PENDING: raise ValueError(f"PaymentIntent nem PENDING státuszú: {payment_intent.status}") # Stripe Checkout Session létrehozása session_data = await stripe_adapter.create_checkout_session( payment_intent=payment_intent, success_url=success_url, cancel_url=cancel_url, metadata={ "payment_intent_id": payment_intent.id, "payer_email": payment_intent.payer.email if payment_intent.payer else None, } ) # PaymentIntent frissítése Stripe adatokkal payment_intent.stripe_session_id = session_data["session_id"] payment_intent.stripe_payment_intent_id = session_data.get("payment_intent_id") payment_intent.expires_at = session_data["expires_at"] logger.info( f"Stripe fizetés indítva: payment_intent={payment_intent.id}, " f"session={session_data['session_id']}" ) return { "payment_intent_id": payment_intent.id, "intent_token": str(payment_intent.intent_token), "stripe_session_id": session_data["session_id"], "checkout_url": session_data["url"], "expires_at": session_data["expires_at"].isoformat(), } @classmethod async def process_internal_payment( cls, db: AsyncSession, payment_intent_id: int ) -> Dict[str, Any]: """ Belső ajándékozás feldolgozása (SmartDeduction használatával). Args: db: Database session payment_intent_id: PaymentIntent ID Returns: Dict: Tranzakció eredménye """ logger.info(f"process_internal_payment kezdődik: payment_intent_id={payment_intent_id}") # PaymentIntent lekérdezése zárolással stmt = select(PaymentIntent).where( PaymentIntent.id == payment_intent_id, PaymentIntent.status == PaymentIntentStatus.PENDING ) result = await db.execute(stmt) payment_intent = result.scalar_one_or_none() if not payment_intent: logger.error(f"PaymentIntent nem található vagy nem PENDING: id={payment_intent_id}") raise ValueError(f"PaymentIntent nem található vagy nem PENDING: id={payment_intent_id}") logger.info(f"PaymentIntent megtalálva: id={payment_intent.id}, payer={payment_intent.payer_id}, beneficiary={payment_intent.beneficiary_id}, net_amount={payment_intent.net_amount}, target_wallet_type={payment_intent.target_wallet_type}") # Státusz frissítése PROCESSING-re payment_intent.status = PaymentIntentStatus.PROCESSING async def execute_logic(): logger.info("execute_logic: SmartDeduction.deduct_from_wallets hívása...") # SmartDeduction használata a fizető pénztárcájából used_amounts = await SmartDeduction.deduct_from_wallets( db=db, user_id=payment_intent.payer_id, amount=float(payment_intent.net_amount) ) logger.info(f"SmartDeduction eredmény: {used_amounts}") # Ha van beneficiary, akkor hozzáadjuk a net_amount-ot a cél pénztárcájához if payment_intent.beneficiary_id: # Beneficiary wallet lekérdezése wallet_stmt = select(Wallet).where(Wallet.user_id == payment_intent.beneficiary_id) wallet_result = await db.execute(wallet_stmt) beneficiary_wallet = wallet_result.scalar_one_or_none() if not beneficiary_wallet: logger.error(f"Beneficiary wallet nem található: user_id={payment_intent.beneficiary_id}") raise ValueError(f"Beneficiary wallet nem található: user_id={payment_intent.beneficiary_id}") logger.info(f"Beneficiary wallet megtalálva: id={beneficiary_wallet.id}") # Összeg hozzáadása a megfelelő wallet típushoz amount_decimal = Decimal(str(payment_intent.net_amount)) if payment_intent.target_wallet_type == WalletType.EARNED: beneficiary_wallet.earned_credits += amount_decimal logger.info(f"Beneficiary earned_credits növelve: +{amount_decimal}") elif payment_intent.target_wallet_type == WalletType.PURCHASED: beneficiary_wallet.purchased_credits += amount_decimal logger.info(f"Beneficiary purchased_credits növelve: +{amount_decimal}") elif payment_intent.target_wallet_type == WalletType.SERVICE_COINS: beneficiary_wallet.service_coins += amount_decimal logger.info(f"Beneficiary service_coins növelve: +{amount_decimal}") elif payment_intent.target_wallet_type == WalletType.VOUCHER: # VOUCHER esetén ActiveVoucher rekordot hozunk létre voucher = ActiveVoucher( wallet_id=beneficiary_wallet.id, amount=float(payment_intent.net_amount), original_amount=float(payment_intent.net_amount), expires_at=datetime.utcnow() + timedelta(days=365) ) db.add(voucher) logger.info(f"ActiveVoucher létrehozva: wallet_id={beneficiary_wallet.id}, amount={payment_intent.net_amount}") # Ha más típus, nem csinálunk semmit logger.info("AtomicTransactionManager.atomic_billing_transaction hívása a used_amounts-szel...") # Atomi tranzakció létrehozása a főkönyvbe, már meglévő used_amounts átadásával transaction_details = await AtomicTransactionManager.atomic_billing_transaction( db=db, user_id=payment_intent.payer_id, amount=float(payment_intent.net_amount), description=f"Internal payment to {payment_intent.beneficiary_id or 'system'}", reference_type="internal_payment", reference_id=payment_intent.id, used_amounts=used_amounts, # Pass the already calculated used_amounts beneficiary_id=payment_intent.beneficiary_id ) # PaymentIntent befejezése payment_intent.mark_completed(transaction_id=transaction_details["transaction_id"]) logger.info( f"Belső fizetés sikeres: payment_intent={payment_intent.id}, " f"transaction={transaction_details['transaction_id']}, " f"amount={payment_intent.net_amount}" ) return { "success": True, "payment_intent_id": payment_intent.id, "transaction_id": transaction_details["transaction_id"], "used_amounts": used_amounts, "beneficiary_credited": payment_intent.beneficiary_id is not None, } try: # Start atomic transaction only if not already in one if not db.in_transaction(): logger.info("Nincs aktív tranzakció, új tranzakció indítása...") # No active transaction, start a new one async with db.begin(): result = await execute_logic() logger.info(f"Tranzakció sikeresen commitálva: payment_intent_id={payment_intent_id}") return result else: logger.info("Már van aktív tranzakció, a meglévőn belül fut...") # Already in a transaction, execute logic within existing transaction return await execute_logic() except Exception as e: logger.error(f"Belső fizetés sikertelen: {e}", exc_info=True) payment_intent.mark_failed(reason=str(e)) raise @classmethod async def process_stripe_webhook( cls, db: AsyncSession, payload: bytes, signature: str ) -> Dict[str, Any]: """ Stripe webhook feldolgozása Kettős Lakat validációval. Args: db: Database session payload: HTTP request body signature: Stripe-Signature header Returns: Dict: Webhook feldolgozási eredmény """ # 1. HMAC aláírás validálása (Kettős Lakat - 1. lépés) is_valid, event = await stripe_adapter.verify_webhook_signature(payload, signature) if not is_valid or not event: logger.error("Stripe webhook aláírás érvénytelen") return {"success": False, "error": "Invalid signature"} event_type = event.get("type") logger.info(f"Stripe webhook fogadva: {event_type}") # 2. checkout.session.completed esemény feldolgozása if event_type == "checkout.session.completed": return await cls._handle_checkout_completed(db, event) # 3. payment_intent.succeeded esemény feldolgozása elif event_type == "payment_intent.succeeded": return await cls._handle_payment_intent_succeeded(db, event) # Egyéb események naplózása else: logger.info(f"Stripe webhook esemény nem feldolgozva: {event_type}") return {"success": True, "event": event_type, "processed": False} @classmethod async def _handle_checkout_completed( cls, db: AsyncSession, event: Dict[str, Any] ) -> Dict[str, Any]: """checkout.session.completed esemény feldolgozása.""" # Stripe adatok kinyerése stripe_data = await stripe_adapter.handle_checkout_completed(event) if not stripe_data["success"]: return stripe_data intent_token = stripe_data["intent_token"] stripe_amount = stripe_data["amount_total"] # 2. PaymentIntent keresése intent_token alapján (Kettős Lakat - 2. lépés) stmt = select(PaymentIntent).where( PaymentIntent.intent_token == uuid.UUID(intent_token), PaymentIntent.status == PaymentIntentStatus.PENDING ) result = await db.execute(stmt) payment_intent = result.scalar_one_or_none() if not payment_intent: logger.error(f"PaymentIntent nem található intent_token alapján: {intent_token}") return {"success": False, "error": "PaymentIntent not found or not PENDING"} # 3. Összeg egyezés ellenőrzése (Kettős Lakat - 3. lépés) # Stripe centben küldi, mi pedig gross_amount-ban tároljuk if abs(float(payment_intent.gross_amount) - stripe_amount) > 0.01: # 1 cent tolerancia logger.error( f"Összeg nem egyezik: PaymentIntent={payment_intent.gross_amount}, " f"Stripe={stripe_amount}" ) payment_intent.mark_failed(reason="Amount mismatch with Stripe") return {"success": False, "error": "Amount mismatch"} # 4. Stripe adatok frissítése payment_intent.stripe_session_id = stripe_data["session_id"] payment_intent.stripe_payment_intent_id = stripe_data.get("payment_intent_id") try: # 5. Atomi tranzakció végrehajtása (Kettős Lakat - 4. lépés) transaction_details = await AtomicTransactionManager.atomic_billing_transaction( db=db, user_id=payment_intent.payer_id, amount=float(payment_intent.net_amount), description=f"Stripe payment for {payment_intent.target_wallet_type.value}", reference_type="stripe_payment", reference_id=payment_intent.id ) # 6. PaymentIntent befejezése payment_intent.mark_completed(transaction_id=transaction_details["transaction_id"]) logger.info( f"Stripe webhook sikeresen feldolgozva: payment_intent={payment_intent.id}, " f"transaction={transaction_details['transaction_id']}" ) return { "success": True, "payment_intent_id": payment_intent.id, "transaction_id": transaction_details["transaction_id"], "amount": float(payment_intent.net_amount), "handling_fee": float(payment_intent.handling_fee), "event": "checkout.session.completed", } except Exception as e: logger.error(f"Atomi tranzakció sikertelen: {e}") payment_intent.mark_failed(reason=str(e)) return {"success": False, "error": f"Transaction failed: {str(e)}"} @classmethod async def _handle_payment_intent_succeeded( cls, db: AsyncSession, event: Dict[str, Any] ) -> Dict[str, Any]: """payment_intent.succeeded esemény feldolgozása.""" stripe_data = await stripe_adapter.handle_payment_intent_succeeded(event) if not stripe_data["success"]: return stripe_data # PaymentIntent keresése stripe_payment_intent_id alapján stripe_payment_intent_id = stripe_data["payment_intent_id"] stmt = select(PaymentIntent).where( PaymentIntent.stripe_payment_intent_id == stripe_payment_intent_id ) result = await db.execute(stmt) payment_intent = result.scalar_one_or_none() if payment_intent and payment_intent.status == PaymentIntentStatus.PENDING: # Ha még PENDING, akkor frissítsük COMPLETED-re payment_intent.status = PaymentIntentStatus.COMPLETED payment_intent.completed_at = datetime.utcnow() logger.info(f"Stripe payment intent succeeded: {stripe_payment_intent_id}") return { "success": True, "event": "payment_intent.succeeded", "stripe_payment_intent_id": stripe_payment_intent_id, "payment_intent_id": payment_intent.id if payment_intent else None, } @classmethod async def get_payment_intent_status( cls, db: AsyncSession, payment_intent_id: int ) -> Dict[str, Any]: """ PaymentIntent státusz lekérdezése. Args: db: Database session payment_intent_id: PaymentIntent ID Returns: Dict: PaymentIntent adatai és státusza """ stmt = select(PaymentIntent).where(PaymentIntent.id == payment_intent_id) result = await db.execute(stmt) payment_intent = result.scalar_one_or_none() if not payment_intent: raise ValueError(f"PaymentIntent nem található: id={payment_intent_id}") return { "id": payment_intent.id, "intent_token": str(payment_intent.intent_token), "status": payment_intent.status.value, "payer_id": payment_intent.payer_id, "beneficiary_id": payment_intent.beneficiary_id, "target_wallet_type": payment_intent.target_wallet_type.value, "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, "created_at": payment_intent.created_at.isoformat() if payment_intent.created_at else None, "updated_at": payment_intent.updated_at.isoformat() if payment_intent.updated_at else None, "completed_at": payment_intent.completed_at.isoformat() if payment_intent.completed_at else None, "stripe_session_id": payment_intent.stripe_session_id, "stripe_payment_intent_id": payment_intent.stripe_payment_intent_id, "transaction_id": str(payment_intent.transaction_id) if payment_intent.transaction_id else None }