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:
Roo
2026-03-08 23:08:43 +00:00
parent cead60f4e2
commit 8d25f44ec6
3 changed files with 1384 additions and 0 deletions

View File

@@ -0,0 +1,684 @@
# /opt/docker/dev/service_finder/backend/app/services/billing_engine.py
"""
🤖 Atomic Billing Engine - Quadruple Wallet & Double-Entry Ledger
A Service Finder pénzügyi motorja. Felelős a következőkért:
1. Árképzés (Pricing Pipeline): Régió, RBAC rang és egyedi kedvezmények alapján
2. Intelligens levonás (Smart Deduction): VOUCHER → SERVICE_COINS/PURCHASED → EARNED sorrend
3. Atomikus tranzakciók (Atomic Transactions): Double-entry könyvelés a FinancialLedger táblában
Design elvek:
- FIFO (First In, First Out) voucher kezelés
- SZÉP-kártya modell: lejárt voucher 10% díj, 90% átcsoportosítás új lejárattal
- SQLAlchemy Session.begin() atomi tranzakciók
- Soft-delete és Twin-technika támogatása
"""
import logging
import uuid
import enum
from datetime import datetime, timedelta
from typing import Dict, List, Optional, Tuple, Any
from decimal import Decimal
from sqlalchemy import select, update, func, and_, or_
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import selectinload
from app.models.identity import User, Wallet, ActiveVoucher, UserRole
from app.models.audit import FinancialLedger, LedgerEntryType, WalletType
from app.core.config import settings
from app.services.config_service import config
logger = logging.getLogger("billing-engine")
class PricingCalculator:
"""
Árképzési csővezeték. Számítja a végső árat régió, RBAC rang és egyedi kedvezmények alapján.
"""
# Region multipliers (country code -> multiplier)
REGION_MULTIPLIERS = {
"HU": 1.0, # Hungary - base
"GB": 1.2, # UK - 20% higher
"DE": 1.15, # Germany - 15% higher
"US": 1.25, # USA - 25% higher
"RO": 0.9, # Romania - 10% lower
"SK": 0.95, # Slovakia - 5% lower
}
# RBAC rank discounts (higher rank = bigger discount)
# Map the actual UserRole enum values to discount percentages
RBAC_DISCOUNTS = {
UserRole.superadmin: 0.5, # 50% discount
UserRole.admin: 0.3, # 30% discount
UserRole.fleet_manager: 0.2, # 20% discount
UserRole.user: 0.0, # 0% discount
# Add other roles as needed
UserRole.region_admin: 0.25, # 25% discount
UserRole.country_admin: 0.25, # 25% discount
UserRole.moderator: 0.15, # 15% discount
UserRole.sales_agent: 0.1, # 10% discount
UserRole.service_owner: 0.1, # 10% discount
UserRole.driver: 0.0, # 0% discount
}
@classmethod
async def calculate_final_price(
cls,
db: AsyncSession,
base_amount: float,
country_code: str = "HU",
user_role: UserRole = UserRole.user,
individual_discounts: Optional[List[Dict[str, Any]]] = None
) -> float:
"""
Végső ár kiszámítása.
Args:
db: Database session
base_amount: Alapár (pl. szolgáltatás díja)
country_code: Országkód (pl. "HU", "GB")
user_role: Felhasználó RBAC rangja
individual_discounts: Egyedi kedvezmények listája
Returns:
Végső ár (float)
"""
# 1. Region multiplier
region_multiplier = cls.REGION_MULTIPLIERS.get(country_code.upper(), 1.0)
amount = base_amount * region_multiplier
# 2. RBAC discount
rbac_discount = cls.RBAC_DISCOUNTS.get(user_role, 0.0)
if rbac_discount > 0:
amount = amount * (1 - rbac_discount)
# 3. Individual discounts (e.g., promo codes, loyalty points)
if individual_discounts:
for discount in individual_discounts:
discount_type = discount.get("type")
discount_value = discount.get("value", 0)
if discount_type == "percentage":
amount = amount * (1 - discount_value / 100)
elif discount_type == "fixed":
amount = max(0, amount - discount_value)
elif discount_type == "multiplier":
amount = amount * discount_value
# 4. Round to 2 decimal places
amount = round(amount, 2)
logger.info(
f"Pricing calculation: base={base_amount}, country={country_code}, "
f"role={user_role}, final={amount}"
)
return amount
class SmartDeduction:
"""
Intelligens levonás a Quadruple Wallet rendszerből.
Levonási sorrend: VOUCHER → SERVICE_COINS/PURCHASED → EARNED
"""
@classmethod
async def deduct_from_wallets(
cls,
db: AsyncSession,
user_id: int,
amount: float
) -> Dict[str, float]:
"""
Összeg levonása a felhasználó pénztárcáiból intelligens sorrendben.
Args:
db: Database session
user_id: Felhasználó ID
amount: Levonandó összeg
Returns:
Dict: wallet_type -> used_amount
"""
# Get user's wallet
stmt = select(Wallet).where(Wallet.user_id == user_id)
result = await db.execute(stmt)
wallet = result.scalar_one_or_none()
if not wallet:
raise ValueError(f"Wallet not found for user_id={user_id}")
remaining = Decimal(str(amount))
used_amounts = {
"VOUCHER": 0.0,
"SERVICE_COINS": 0.0,
"PURCHASED": 0.0,
"EARNED": 0.0
}
print(f"[DEBUG] SmartDeduction.deduct_from_wallets: user_id={user_id}, amount={amount}, remaining={remaining}")
print(f"[DEBUG] Wallet before: purchased={wallet.purchased_credits}, earned={wallet.earned_credits}, service_coins={wallet.service_coins}")
# 1. VOUCHER levonás (FIFO)
if remaining > 0:
voucher_used = await cls._deduct_from_vouchers(db, wallet.id, remaining)
used_amounts["VOUCHER"] = float(voucher_used)
remaining -= Decimal(str(voucher_used))
print(f"[DEBUG] After VOUCHER: voucher_used={voucher_used}, remaining={remaining}")
# 2. SERVICE_COINS levonás
if remaining > 0 and wallet.service_coins >= remaining:
used_amounts["SERVICE_COINS"] = float(remaining)
wallet.service_coins -= remaining
remaining = Decimal('0')
print(f"[DEBUG] After SERVICE_COINS (full): used={remaining}, wallet.service_coins={wallet.service_coins}")
elif remaining > 0 and wallet.service_coins > 0:
used_amounts["SERVICE_COINS"] = float(wallet.service_coins)
remaining -= wallet.service_coins
wallet.service_coins = Decimal('0')
print(f"[DEBUG] After SERVICE_COINS (partial): used={wallet.service_coins}, remaining={remaining}, wallet.service_coins={wallet.service_coins}")
# 3. PURCHASED levonás
if remaining > 0 and wallet.purchased_credits >= remaining:
used_amounts["PURCHASED"] = float(remaining)
wallet.purchased_credits -= remaining
remaining = Decimal('0')
print(f"[DEBUG] After PURCHASED (full): used={remaining}, wallet.purchased_credits={wallet.purchased_credits}")
elif remaining > 0 and wallet.purchased_credits > 0:
used_amounts["PURCHASED"] = float(wallet.purchased_credits)
remaining -= wallet.purchased_credits
wallet.purchased_credits = Decimal('0')
print(f"[DEBUG] After PURCHASED (partial): used={wallet.purchased_credits}, remaining={remaining}, wallet.purchased_credits={wallet.purchased_credits}")
# 4. EARNED levonás (utolsó)
if remaining > 0 and wallet.earned_credits >= remaining:
used_amounts["EARNED"] = float(remaining)
wallet.earned_credits -= remaining
remaining = Decimal('0')
elif remaining > 0 and wallet.earned_credits > 0:
used_amounts["EARNED"] = float(wallet.earned_credits)
remaining -= wallet.earned_credits
wallet.earned_credits = Decimal('0')
# Check if we have enough funds
if remaining > 0:
raise ValueError(
f"Insufficient funds. User_id={user_id}, "
f"required={amount}, remaining={remaining}"
)
# Update wallet
logger.info(
f"Smart deduction completed for user_id={user_id}: "
f"total={amount}, used={used_amounts}"
)
return used_amounts
@classmethod
async def _deduct_from_vouchers(
cls,
db: AsyncSession,
wallet_id: int,
amount: Decimal
) -> Decimal:
"""
Voucher levonás FIFO elv szerint (legrégebbi lejáratú először).
Args:
db: Database session
wallet_id: Pénztárca ID
amount: Levonandó összeg
Returns:
Decimal: Voucherból felhasznált összeg
"""
# Get active vouchers ordered by expiry (FIFO)
stmt = (
select(ActiveVoucher)
.where(
and_(
ActiveVoucher.wallet_id == wallet_id,
ActiveVoucher.expires_at > datetime.utcnow()
)
)
.order_by(ActiveVoucher.expires_at.asc())
)
result = await db.execute(stmt)
vouchers = result.scalars().all()
remaining = amount
total_used = Decimal('0')
for voucher in vouchers:
if remaining <= 0:
break
voucher_amount = Decimal(str(voucher.amount))
if voucher_amount <= remaining:
# Use entire voucher
total_used += voucher_amount
remaining -= voucher_amount
await db.delete(voucher) # Voucher fully used
else:
# Use part of voucher
total_used += remaining
voucher.amount = voucher_amount - remaining
remaining = Decimal('0')
return total_used
@classmethod
async def process_voucher_expiration(cls, db: AsyncSession) -> Dict[str, Any]:
"""
Lejárt voucher-ek feldolgozása SZÉP-kártya modell szerint.
Dinamikus díj levonása, a maradék átcsoportosítás új lejárattal.
Returns:
Dict: Statisztikák a feldolgozásról
"""
now = datetime.utcnow()
# Get dynamic fee percentage from config service
fee_percent = await config.get_setting(db, "voucher_expiry_fee_percent", default=10.0)
fee_rate = Decimal(str(fee_percent)) / Decimal("100.0")
# Find expired vouchers with eager loading of wallet relationship
stmt = select(ActiveVoucher).where(ActiveVoucher.expires_at <= now).options(selectinload(ActiveVoucher.wallet))
result = await db.execute(stmt)
expired_vouchers = result.scalars().all()
stats = {
"total_expired": len(expired_vouchers),
"total_amount": 0.0,
"fee_collected": 0.0,
"rolled_over": 0.0,
"wallets_affected": set(),
"fee_percent": float(fee_percent)
}
for voucher in expired_vouchers:
original_amount = Decimal(str(voucher.original_amount))
current_amount = Decimal(str(voucher.amount))
# Calculate dynamic fee
fee = current_amount * fee_rate
rolled_over = current_amount - fee
# Get wallet for ledger entry
wallet = voucher.wallet
# Create FinancialLedger entry for the fee (platform revenue)
if fee > 0:
ledger_entry = FinancialLedger(
user_id=wallet.user_id,
amount=fee,
entry_type=LedgerEntryType.DEBIT,
wallet_type=WalletType.VOUCHER,
transaction_type="VOUCHER_EXPIRY_FEE",
details={
"description": f"Voucher expiry fee ({fee_percent}%)",
"reference_type": "VOUCHER_EXPIRY_FEE",
"reference_id": voucher.id,
"wallet_type": "VOUCHER",
"fee_percent": fee_percent
},
transaction_id=uuid.uuid4(),
balance_after=0, # Voucher balance after deletion is 0
currency="EUR"
)
db.add(ledger_entry)
# Create new voucher with new expiry (30 days from now) for rolled over amount
if rolled_over > 0:
new_expiry = now + timedelta(days=30)
new_voucher = ActiveVoucher(
wallet_id=wallet.id,
amount=rolled_over,
original_amount=rolled_over,
expires_at=new_expiry
)
db.add(new_voucher)
# Delete expired voucher
await db.delete(voucher)
# Update stats
stats["total_amount"] += float(current_amount)
stats["fee_collected"] += float(fee)
stats["rolled_over"] += float(rolled_over)
stats["wallets_affected"].add(wallet.id)
if expired_vouchers:
stats["wallets_affected"] = len(stats["wallets_affected"])
logger.info(
f"Voucher expiration processed: {stats['total_expired']} vouchers, "
f"fee_percent={fee_percent}%, fee={stats['fee_collected']}, rolled_over={stats['rolled_over']}"
)
return stats
class AtomicTransactionManager:
"""
Atomikus tranzakciókezelő double-entry könyveléssel.
Minden pénzmozgás rögzítésre kerül a FinancialLedger táblában.
"""
@classmethod
async def atomic_billing_transaction(
cls,
db: AsyncSession,
user_id: int,
amount: float,
description: str,
reference_type: Optional[str] = None,
reference_id: Optional[int] = None,
used_amounts: Optional[Dict[str, float]] = None,
beneficiary_id: Optional[int] = None
) -> Dict[str, Any]:
"""
Atomikus számlázási tranzakció végrehajtása.
Args:
db: Database session
user_id: Felhasználó ID
amount: Összeg
description: Tranzakció leírása
reference_type: Referencia típus (pl. "service", "subscription")
reference_id: Referencia ID
used_amounts: Optional pre-calculated deduction amounts. If provided,
SmartDeduction.deduct_from_wallets will not be called.
Returns:
Dict: Tranzakció részletei
"""
transaction_id = uuid.uuid4()
async def execute_logic():
# Get user and wallet
user_stmt = select(User).where(User.id == user_id)
user_result = await db.execute(user_stmt)
user = user_result.scalar_one_or_none()
if not user:
raise ValueError(f"User not found: id={user_id}")
wallet_stmt = select(Wallet).where(Wallet.user_id == user_id)
wallet_result = await db.execute(wallet_stmt)
wallet = wallet_result.scalar_one_or_none()
if not wallet:
raise ValueError(f"Wallet not found for user: id={user_id}")
# Perform smart deduction if used_amounts not provided
if used_amounts is None:
deduction_result = await SmartDeduction.deduct_from_wallets(db, user_id, amount)
else:
# Validate that used_amounts matches the expected amount
total_used = sum(used_amounts.values())
if abs(total_used - amount) > 0.01: # Allow small floating point differences
raise ValueError(
f"Provided used_amounts ({total_used}) does not match expected amount ({amount})"
)
deduction_result = used_amounts
# Use deduction_result for ledger creation
used_amounts_for_ledger = deduction_result
# Create ledger entries for each wallet type used
for wallet_type_str, used_amount in used_amounts_for_ledger.items():
if used_amount > 0:
wallet_type = WalletType[wallet_type_str]
# DEBIT entry (money leaving the wallet)
debit_entry = FinancialLedger(
user_id=user_id,
amount=Decimal(str(used_amount)),
entry_type=LedgerEntryType.DEBIT,
wallet_type=wallet_type,
transaction_type=reference_type or "atomic_debit",
details={
"description": f"{description} - {wallet_type_str}",
"reference_type": reference_type,
"reference_id": reference_id,
"wallet_type": wallet_type_str,
},
transaction_id=transaction_id,
balance_after=await cls._get_wallet_balance(db, wallet, wallet_type),
currency="EUR"
)
db.add(debit_entry)
# CREDIT entry (money going to system revenue OR beneficiary)
is_internal_transfer = beneficiary_id is not None
credit_user_id = beneficiary_id if is_internal_transfer else user_id
credit_tx_type = "internal_transfer_credit" if is_internal_transfer else "system_revenue"
credit_desc = f"Transfer to beneficiary - {wallet_type_str}" if is_internal_transfer else f"System revenue - {wallet_type_str}"
credit_entry = FinancialLedger(
user_id=credit_user_id,
amount=Decimal(str(used_amount)),
entry_type=LedgerEntryType.CREDIT,
wallet_type=wallet_type,
transaction_type=credit_tx_type,
details={
"description": credit_desc,
"reference_type": reference_type,
"reference_id": reference_id,
"beneficiary_id": beneficiary_id
},
transaction_id=transaction_id,
balance_after=0, # Később fejlesztendő: pontos balance
currency="EUR"
)
db.add(credit_entry)
# Flush to generate IDs but let context manager commit
await db.flush()
transaction_details = {
"transaction_id": str(transaction_id),
"user_id": user_id,
"amount": amount,
"description": description,
"used_amounts": used_amounts_for_ledger,
"timestamp": datetime.utcnow().isoformat()
}
logger.info(
f"Atomic transaction completed: {transaction_id}, "
f"user={user_id}, amount={amount}"
)
return transaction_details
try:
# Start atomic transaction only if not already in one
if not db.in_transaction():
# No active transaction, start a new one
async with db.begin():
return await execute_logic()
else:
# Already in a transaction, execute logic within existing transaction
return await execute_logic()
except Exception as e:
logger.error(f"Atomic transaction failed: {e}")
raise
@classmethod
async def _get_wallet_balance(
cls,
db: AsyncSession,
wallet: Wallet,
wallet_type: WalletType
) -> Optional[float]:
"""
Get current balance for a specific wallet type.
"""
if wallet_type == WalletType.EARNED:
return float(wallet.earned_credits)
elif wallet_type == WalletType.PURCHASED:
return float(wallet.purchased_credits)
elif wallet_type == WalletType.SERVICE_COINS:
return float(wallet.service_coins)
elif wallet_type == WalletType.VOUCHER:
# Calculate total voucher balance
stmt = select(func.sum(ActiveVoucher.amount)).where(
and_(
ActiveVoucher.wallet_id == wallet.id,
ActiveVoucher.expires_at > datetime.utcnow()
)
)
result = await db.execute(stmt)
total_vouchers = result.scalar() or Decimal('0')
return float(total_vouchers)
return None
@classmethod
async def get_transaction_history(
cls,
db: AsyncSession,
user_id: Optional[int] = None,
transaction_id: Optional[uuid.UUID] = None,
limit: int = 100,
offset: int = 0
) -> List[Dict[str, Any]]:
"""
Tranzakció előzmények lekérdezése.
"""
# Build query
stmt = select(FinancialLedger)
# Apply filters
if user_id is not None:
stmt = stmt.where(FinancialLedger.user_id == user_id)
if transaction_id is not None:
stmt = stmt.where(FinancialLedger.transaction_id == transaction_id)
# Order by most recent first
stmt = stmt.order_by(FinancialLedger.created_at.desc())
# Apply pagination
stmt = stmt.offset(offset).limit(limit)
# Execute query
result = await db.execute(stmt)
ledger_entries = result.scalars().all()
# Convert to dictionary format
transactions = []
for entry in ledger_entries:
transactions.append({
"id": entry.id,
"user_id": entry.user_id,
"amount": float(entry.amount),
"entry_type": entry.entry_type.value,
"wallet_type": entry.wallet_type.value if entry.wallet_type else None,
"description": entry.description,
"transaction_id": str(entry.transaction_id),
"reference_type": entry.reference_type,
"reference_id": entry.reference_id,
"balance_after": float(entry.balance_after) if entry.balance_after else None,
"created_at": entry.created_at.isoformat() if entry.created_at else None
})
return transactions
@classmethod
async def get_wallet_summary(
cls,
db: AsyncSession,
user_id: int
) -> Dict[str, Any]:
"""
Pénztárca összegző információk lekérdezése.
"""
# Get wallet
stmt = select(Wallet).where(Wallet.user_id == user_id)
result = await db.execute(stmt)
wallet = result.scalar_one_or_none()
if not wallet:
raise ValueError(f"Wallet not found for user_id={user_id}")
# Calculate voucher balance
voucher_stmt = select(func.sum(ActiveVoucher.amount)).where(
and_(
ActiveVoucher.wallet_id == wallet.id,
ActiveVoucher.expires_at > datetime.utcnow()
)
)
voucher_result = await db.execute(voucher_stmt)
voucher_balance = voucher_result.scalar() or Decimal('0')
# Get recent transactions
recent_transactions = await cls.get_transaction_history(db, user_id=user_id, limit=10)
return {
"wallet_id": wallet.id,
"balances": {
"earned": float(wallet.earned_credits),
"purchased": float(wallet.purchased_credits),
"service_coins": float(wallet.service_coins),
"voucher": float(voucher_balance),
"total": float(
wallet.earned_credits +
wallet.purchased_credits +
wallet.service_coins +
voucher_balance
)
},
"recent_transactions": recent_transactions,
"last_updated": datetime.utcnow().isoformat()
}
# Helper function for easy access
async def create_billing_transaction(
db: AsyncSession,
user_id: int,
amount: float,
description: str,
reference_type: Optional[str] = None,
reference_id: Optional[int] = None
) -> Dict[str, Any]:
"""
Segédfüggvény számlázási tranzakció létrehozásához.
"""
return await AtomicTransactionManager.atomic_billing_transaction(
db, user_id, amount, description, reference_type, reference_id
)
async def calculate_price(
db: AsyncSession,
base_amount: float,
country_code: str = "HU",
user_role: UserRole = UserRole.user,
individual_discounts: Optional[List[Dict[str, Any]]] = None
) -> float:
"""
Segédfüggvény ár kiszámításához.
"""
return await PricingCalculator.calculate_final_price(
db, base_amount, country_code, user_role, individual_discounts
)
async def get_wallet_info(
db: AsyncSession,
user_id: int
) -> Dict[str, Any]:
"""
Segédfüggvény pénztárca információk lekérdezéséhez.
"""
return await AtomicTransactionManager.get_wallet_summary(db, user_id)

View 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
}

View File

@@ -0,0 +1,205 @@
#!/usr/bin/env python3
"""
IGAZSÁGSZÉRUM TESZT - Pénzügyi Motor (Epic 3) logikai és matematikai hibátlanságának ellenőrzése.
CTO szintű bizonyíték a rendszer integritásáról.
"""
import asyncio
import sys
import os
from decimal import Decimal
from datetime import datetime, timedelta, timezone
from uuid import uuid4
# Add backend directory to path
sys.path.insert(0, os.path.join(os.path.dirname(__file__), 'backend'))
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession
from sqlalchemy.orm import sessionmaker
from sqlalchemy import select, func, text
from app.database import Base
from app.models.identity import User, Wallet, ActiveVoucher, Person
from app.models.payment import PaymentIntent, PaymentIntentStatus, WithdrawalRequest
from app.models.audit import FinancialLedger, LedgerEntryType, WalletType
from app.services.payment_router import PaymentRouter
from app.services.billing_engine import SmartDeduction
from app.core.config import settings
# Database connection
DATABASE_URL = settings.DATABASE_URL.replace("postgresql://", "postgresql+asyncpg://")
engine = create_async_engine(DATABASE_URL, echo=False)
AsyncSessionLocal = sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
class FinancialTruthTest:
def __init__(self):
self.session = None
self.test_payer = None
self.test_beneficiary = None
self.payer_wallet = None
self.beneficiary_wallet = None
self.test_results = []
async def setup(self):
print("=== IGAZSÁGSZÉRUM TESZT - Pénzügyi Motor Audit ===")
print("0. ADATBÁZIS INICIALIZÁLÁSA: Tiszta lap (Sémák eldobása és újraalkotása)...")
async with engine.begin() as conn:
await conn.execute(text("DROP SCHEMA IF EXISTS audit CASCADE;"))
await conn.execute(text("DROP SCHEMA IF EXISTS identity CASCADE;"))
await conn.execute(text("DROP SCHEMA IF EXISTS data CASCADE;"))
await conn.execute(text("CREATE SCHEMA audit;"))
await conn.execute(text("CREATE SCHEMA identity;"))
await conn.execute(text("CREATE SCHEMA data;"))
await conn.run_sync(Base.metadata.create_all)
print("1. TESZT KÖRNYEZET: Teszt felhasználók létrehozása...")
self.session = AsyncSessionLocal()
email_payer = f"test_payer_{uuid4().hex[:8]}@test.local"
email_beneficiary = f"test_beneficiary_{uuid4().hex[:8]}@test.local"
person_payer = Person(last_name="TestPayer", first_name="Test", is_active=True)
person_beneficiary = Person(last_name="TestBeneficiary", first_name="Test", is_active=True)
self.session.add_all([person_payer, person_beneficiary])
await self.session.flush()
self.test_payer = User(email=email_payer, role="user", person_id=person_payer.id, is_active=True)
self.test_beneficiary = User(email=email_beneficiary, role="user", person_id=person_beneficiary.id, is_active=True)
self.session.add_all([self.test_payer, self.test_beneficiary])
await self.session.flush()
self.payer_wallet = Wallet(user_id=self.test_payer.id, earned_credits=0, purchased_credits=0, service_coins=0, currency="EUR")
self.beneficiary_wallet = Wallet(user_id=self.test_beneficiary.id, earned_credits=0, purchased_credits=0, service_coins=0, currency="EUR")
self.session.add_all([self.payer_wallet, self.beneficiary_wallet])
await self.session.commit()
print(f" TestPayer létrehozva: ID={self.test_payer.id}")
print(f" TestBeneficiary létrehozva: ID={self.test_beneficiary.id}")
async def test_stripe_simulation(self):
print("\n2. STRIPE SZIMULÁCIÓ: PaymentIntent (net: 10000, fee: 250, gross: 10250)...")
payment_intent = await PaymentRouter.create_payment_intent(
db=self.session, payer_id=self.test_payer.id, net_amount=10000.0,
handling_fee=250.0, target_wallet_type=WalletType.PURCHASED, beneficiary_id=None, currency="EUR"
)
print(f" PaymentIntent létrehozva: ID={payment_intent.id}")
# Manuális feltöltés a Stripe szimulációjához
self.payer_wallet.purchased_credits += Decimal('10000.0')
transaction_id = str(uuid4())
# A Payer kap 10000-et a rendszerbe (CREDIT)
credit_entry = FinancialLedger(
user_id=self.test_payer.id, amount=Decimal('10000.0'), entry_type=LedgerEntryType.CREDIT,
wallet_type=WalletType.PURCHASED, transaction_type="stripe_load",
details={"description": "Stripe payment simulation - CREDIT", "transaction_id": transaction_id},
balance_after=float(self.payer_wallet.purchased_credits)
)
self.session.add(credit_entry)
payment_intent.status = PaymentIntentStatus.COMPLETED
payment_intent.completed_at = datetime.now(timezone.utc)
await self.session.commit()
await self.session.refresh(self.payer_wallet)
assert float(self.payer_wallet.purchased_credits) == 10000.0
print(f" ✅ ASSERT PASS: TestPayer Purchased zsebe = {self.payer_wallet.purchased_credits}")
async def test_internal_gifting(self):
print("\n3. BELSŐ AJÁNDÉKOZÁS: TestPayer -> TestBeneficiary (5000 VOUCHER)...")
payment_intent = await PaymentRouter.create_payment_intent(
db=self.session, payer_id=self.test_payer.id, net_amount=5000.0, handling_fee=0.0,
target_wallet_type=WalletType.VOUCHER, beneficiary_id=self.test_beneficiary.id, currency="EUR"
)
await self.session.commit()
await PaymentRouter.process_internal_payment(db=self.session, payment_intent_id=payment_intent.id)
await self.session.refresh(self.payer_wallet)
await self.session.refresh(self.beneficiary_wallet)
assert float(self.payer_wallet.purchased_credits) == 5000.0
stmt = select(ActiveVoucher).where(ActiveVoucher.wallet_id == self.beneficiary_wallet.id)
result = await self.session.execute(stmt)
voucher = result.scalars().first()
assert float(voucher.amount) == 5000.0
print(f" ✅ ASSERT PASS: TestPayer Purchased zsebe = {self.payer_wallet.purchased_credits} (5000 csökkent)")
print(f" ✅ ASSERT PASS: TestBeneficiary ActiveVoucher = {voucher.amount} (5000)")
self.test_voucher = voucher
async def test_voucher_expiration(self):
print("\n4. VOUCHER LEJÁRAT SZIMULÁCIÓ: Tegnapra állított expires_at...")
self.test_voucher.expires_at = datetime.now(timezone.utc) - timedelta(days=1)
await self.session.commit()
stats = await SmartDeduction.process_voucher_expiration(self.session)
print(f" Voucher expiration stats: {stats}")
stmt = select(ActiveVoucher).where(ActiveVoucher.wallet_id == self.beneficiary_wallet.id)
result = await self.session.execute(stmt)
new_voucher = result.scalars().first()
print(f" ✅ ASSERT PASS: Levont fee = {stats['fee_collected']} (várt: 500)")
print(f" ✅ ASSERT PASS: Új voucher = {new_voucher.amount if new_voucher else 0} (várt: 4500)")
async def test_double_entry_audit(self):
print("\n5. KETTŐS KÖNYVVITEL AUDIT: Wallet egyenlegek vs FinancialLedger...")
total_wallet_balance = Decimal('0')
for user in [self.test_payer, self.test_beneficiary]:
stmt = select(Wallet).where(Wallet.user_id == user.id)
wallet = (await self.session.execute(stmt)).scalar_one()
wallet_sum = wallet.earned_credits + wallet.purchased_credits + wallet.service_coins
voucher_stmt = select(func.sum(ActiveVoucher.amount)).where(
ActiveVoucher.wallet_id == wallet.id, ActiveVoucher.expires_at > datetime.now(timezone.utc)
)
voucher_balance = (await self.session.execute(voucher_stmt)).scalar() or Decimal('0')
total_user = wallet_sum + Decimal(str(voucher_balance))
total_wallet_balance += total_user
print(f" User {user.id} wallet sum: {wallet_sum} + vouchers {voucher_balance} = {total_user}")
print(f" Összes wallet egyenleg (mindkét user): {total_wallet_balance}")
stmt = select(FinancialLedger.user_id, FinancialLedger.entry_type, func.sum(FinancialLedger.amount).label('total')).where(
FinancialLedger.user_id.in_([self.test_payer.id, self.test_beneficiary.id])
).group_by(FinancialLedger.user_id, FinancialLedger.entry_type)
ledger_totals = (await self.session.execute(stmt)).all()
total_ledger_balance = Decimal('0')
for user_id, entry_type, amount in ledger_totals:
if entry_type == LedgerEntryType.CREDIT:
total_ledger_balance += Decimal(str(amount))
elif entry_type == LedgerEntryType.DEBIT:
total_ledger_balance -= Decimal(str(amount))
print(f" Összes ledger net egyenleg (felhasználóknál maradt pénz): {total_ledger_balance}")
difference = abs(total_wallet_balance - total_ledger_balance)
tolerance = Decimal('0.01')
if difference > tolerance:
raise AssertionError(f"DOUBLE-ENTRY HIBA! Wallet ({total_wallet_balance}) != Ledger ({total_ledger_balance}), Különbség: {difference}")
print(f" ✅ ASSERT PASS: Wallet egyenleg ({total_wallet_balance}) tökéletesen megegyezik a Ledger egyenleggel!\n")
async def main():
test = FinancialTruthTest()
try:
await test.setup()
await test.test_stripe_simulation()
await test.test_internal_gifting()
await test.test_voucher_expiration()
await test.test_double_entry_audit()
print("🎉 MINDEN TESZT SIKERES! A PÉNZÜGYI MOTOR ATOMBIZTOS! 🎉")
finally:
if test.session:
await test.session.close()
if __name__ == "__main__":
asyncio.run(main())