Files
service-finder/backend/app/services/payment_router.py
Roo 8d25f44ec6 fix(finance): Implement strict P2P double-entry ledger logic and resolve transaction state errors
Fix atomic_billing_transaction double deduction bug; implement dynamic CREDIT handling for beneficiaries in Double-Entry Ledger; clean up audit test directory.
2026-03-08 23:08:43 +00:00

495 lines
21 KiB
Python

# /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.payment import PaymentIntent, PaymentIntentStatus
from app.models.audit 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
}