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.
This commit is contained in:
495
backend/app/services/payment_router.py
Normal file
495
backend/app/services/payment_router.py
Normal file
@@ -0,0 +1,495 @@
|
||||
# /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
|
||||
}
|
||||
Reference in New Issue
Block a user