449 lines
19 KiB
Python
449 lines
19 KiB
Python
"""
|
|
Financial Orchestrator - Unit of Work mintával a pénzügyi tranzakciók atomi kezeléséhez.
|
|
|
|
Ez a szolgáltatás koordinálja a fizetési folyamatokat, a számlázást és a pénztárca
|
|
műveleteket egyetlen atomi tranzakcióban (Unit of Work minta).
|
|
|
|
Kulcsfontosságú funkciók:
|
|
1. Vetésforgó (select_issuer) - kiválasztja a megfelelő számlakiállítót
|
|
2. Unit of Work - minden adatbázis művelet egy tranzakcióban
|
|
3. Hibatűrés - rollback hiba esetén
|
|
"""
|
|
|
|
import logging
|
|
from decimal import Decimal
|
|
from typing import Optional, Dict, Any
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
from sqlalchemy import select, update, and_
|
|
|
|
from app.models.audit import FinancialLedger, WalletType, LedgerStatus, LedgerEntryType
|
|
from app.models.identity import Wallet
|
|
from app.models.finance import Issuer, IssuerType
|
|
from app.services.financial_interfaces import (
|
|
BasePaymentGateway, BaseInvoicingService,
|
|
PaymentGatewayError, InvoicingError, InsufficientFundsError
|
|
)
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
class FinancialOrchestrator:
|
|
"""
|
|
Pénzügyi tranzakciók koordinálója Unit of Work mintával.
|
|
|
|
Ez az osztály felelős a következőkért:
|
|
- Számlakiállító kiválasztása (vetésforgó logika)
|
|
- FinancialLedger bejegyzés létrehozása
|
|
- Pénztárca egyenleg frissítése
|
|
- Tranzakció atomi végrehajtása (commit/rollback)
|
|
"""
|
|
|
|
def __init__(
|
|
self,
|
|
payment_gateway: Optional[BasePaymentGateway] = None,
|
|
invoicing_service: Optional[BaseInvoicingService] = None
|
|
):
|
|
"""
|
|
Inicializálás opcionális külső szolgáltatásokkal.
|
|
|
|
Args:
|
|
payment_gateway: Fizetési átjáró implementáció (pl. Stripe)
|
|
invoicing_service: Számlázási szolgáltatás implementáció
|
|
"""
|
|
self.payment_gateway = payment_gateway
|
|
self.invoicing_service = invoicing_service
|
|
|
|
async def select_issuer(
|
|
self,
|
|
db: AsyncSession,
|
|
amount: Decimal,
|
|
is_company: bool = False
|
|
) -> Issuer:
|
|
"""
|
|
Vetésforgó logika: kiválasztja a megfelelő számlakiállítót.
|
|
|
|
Logika:
|
|
1. Keressen egy aktív 'EV' típusú Issuert
|
|
2. Ha az `current_revenue + amount < revenue_limit` ÉS a vevő nem cég
|
|
(`is_company == False`), térjen vissza az EV-vel
|
|
3. Minden más esetben térjen vissza az aktív 'KFT' típusú Issuerrel
|
|
|
|
Args:
|
|
db: Adatbázis munkamenet
|
|
amount: A tranzakció összege
|
|
is_company: A vevő cég-e (True esetén nem választható EV)
|
|
|
|
Returns:
|
|
A kiválasztott Issuer objektum
|
|
|
|
Raises:
|
|
ValueError: Ha nincs aktív számlakiállító
|
|
"""
|
|
# 1. EV típusú aktív számlakiállító keresése
|
|
ev_query = select(Issuer).where(
|
|
and_(
|
|
Issuer.type == IssuerType.EV,
|
|
Issuer.is_active == True
|
|
)
|
|
).order_by(Issuer.id)
|
|
|
|
ev_result = await db.execute(ev_query)
|
|
ev_issuer_obj = ev_result.scalars().first()
|
|
|
|
logger.debug(f"EV számlakiállító keresés: talált={ev_issuer_obj is not None}, is_company={is_company}")
|
|
|
|
# 2. Ellenőrizzük, hogy az EV használható-e
|
|
if ev_issuer_obj and not is_company:
|
|
# Számoljuk ki az új bevételt
|
|
new_revenue = ev_issuer_obj.current_revenue + amount
|
|
logger.debug(f"EV ellenőrzés: current_revenue={ev_issuer_obj.current_revenue}, amount={amount}, new_revenue={new_revenue}, limit={ev_issuer_obj.revenue_limit}")
|
|
if new_revenue < ev_issuer_obj.revenue_limit:
|
|
logger.info(f"EV számlakiállító kiválasztva: {ev_issuer_obj.id} "
|
|
f"(új bevétel: {new_revenue}, limit: {ev_issuer_obj.revenue_limit})")
|
|
return ev_issuer_obj
|
|
else:
|
|
logger.debug(f"EV limit túllépve: {new_revenue} >= {ev_issuer_obj.revenue_limit}")
|
|
|
|
# 3. KFT típusú aktív számlakiállító keresése
|
|
kft_query = select(Issuer).where(
|
|
and_(
|
|
Issuer.type == IssuerType.KFT,
|
|
Issuer.is_active == True
|
|
)
|
|
).order_by(Issuer.id)
|
|
|
|
kft_result = await db.execute(kft_query)
|
|
kft_issuer_obj = kft_result.scalars().first()
|
|
|
|
logger.debug(f"KFT számlakiállító keresés: talált={kft_issuer_obj is not None}")
|
|
|
|
if kft_issuer_obj:
|
|
logger.info(f"KFT számlakiállító kiválasztva: {kft_issuer_obj.id}")
|
|
return kft_issuer_obj
|
|
|
|
# 4. Ha egyik sem található, hiba
|
|
raise ValueError("Nincs aktív számlakiállító (sem EV, sem KFT)")
|
|
|
|
async def process_payment(
|
|
self,
|
|
db: AsyncSession,
|
|
user_id: int,
|
|
amount: Decimal,
|
|
wallet_type: WalletType,
|
|
description: str = "",
|
|
metadata: Optional[Dict[str, Any]] = None,
|
|
is_company: bool = False
|
|
) -> Dict[str, Any]:
|
|
"""
|
|
Fő fizetési folyamat Unit of Work mintával.
|
|
|
|
A folyamat egyetlen nagy try...except...finally blokkban fut:
|
|
1. Kiválasztja a számlakiállítót (vetésforgó)
|
|
2. Létrehoz egy bejegyzést a FinancialLedger-ben (PENDING státusszal)
|
|
3. Frissíti a megfelelő Wallet egyenlegét
|
|
4. Csak a legvégén hív egyetlen db.commit()-ot
|
|
5. Hiba esetén KÖTELEZŐ a db.rollback()
|
|
|
|
Args:
|
|
db: Adatbázis munkamenet
|
|
user_id: A felhasználó azonosítója
|
|
amount: A fizetendő összeg (pozitív)
|
|
wallet_type: A cél pénztárca típusa
|
|
description: Tranzakció leírása
|
|
metadata: Egyéni metaadatok
|
|
is_company: A felhasználó cég-e
|
|
|
|
Returns:
|
|
Szótár a tranzakció részleteivel
|
|
|
|
Raises:
|
|
InsufficientFundsError: Ha nincs elég egyenleg
|
|
PaymentGatewayError: Ha a fizetési átjáró hibát jelez
|
|
ValueError: Ha érvénytelen paraméterek
|
|
"""
|
|
if amount <= 0:
|
|
raise ValueError("Az összegnek pozitívnak kell lennie")
|
|
|
|
# Unit of Work: egyetlen tranzakció
|
|
try:
|
|
logger.info(f"Payment process indítása: user={user_id}, amount={amount}, "
|
|
f"wallet_type={wallet_type}, is_company={is_company}")
|
|
|
|
# 1. Számlakiállító kiválasztása
|
|
issuer = await self.select_issuer(db, amount, is_company)
|
|
logger.info(f"Személyi számlakiállító kiválasztva: {issuer.id} ({issuer.type})")
|
|
|
|
# 2. FinancialLedger bejegyzés létrehozása (PENDING státusszal)
|
|
ledger_entry = FinancialLedger(
|
|
user_id=user_id,
|
|
amount=float(amount), # Convert Decimal to float for Numeric field
|
|
wallet_type=wallet_type,
|
|
status=LedgerStatus.PENDING,
|
|
issuer_id=issuer.id,
|
|
entry_type=LedgerEntryType.DEBIT, # Payment is a DEBIT
|
|
currency="HUF", # Default currency
|
|
transaction_type=description or "Payment via FinancialOrchestrator",
|
|
details=metadata or {} # Store metadata in details JSON field
|
|
)
|
|
|
|
db.add(ledger_entry)
|
|
await db.flush() # Megkapjuk az ID-t, de még nincs commit
|
|
|
|
logger.info(f"FinancialLedger bejegyzés létrehozva: {ledger_entry.id}")
|
|
|
|
# 3. Pénztárca egyenleg frissítése
|
|
# Először lekérjük a pénztárcát zárolással (minden usernek csak egy walletje van)
|
|
wallet_query = select(Wallet).where(
|
|
Wallet.user_id == user_id
|
|
).with_for_update() # Sorzárolás a konkurrens hozzáférés megelőzésére
|
|
|
|
wallet_result = await db.execute(wallet_query)
|
|
wallet = wallet_result.scalar_one_or_none()
|
|
|
|
if not wallet:
|
|
raise ValueError(f"Nincs pénztárca a user {user_id} számára")
|
|
|
|
# Ellenőrizzük az egyenleget (ha kivételről van szó)
|
|
# Megjegyzés: A valós implementációban itt ellenőriznénk, hogy van-e elég egyenleg
|
|
# de a specifikáció szerint csak frissítjük az egyenleget
|
|
|
|
# A Wallet modellben nincs 'balance' mező, hanem külön mezők vannak a különböző credit típusokhoz
|
|
# Frissítjük a megfelelő credit mezőt a wallet_type alapján
|
|
# MEGJEGYZÉS: Payment (DEBIT) csökkenti a pénztárca egyenlegét!
|
|
update_values = {}
|
|
current_balance = Decimal('0')
|
|
|
|
if wallet_type == WalletType.EARNED:
|
|
current_balance = Decimal(str(wallet.earned_credits))
|
|
new_balance = current_balance - amount # DEBIT csökkenti az egyenleget
|
|
update_values['earned_credits'] = float(new_balance)
|
|
elif wallet_type == WalletType.PURCHASED:
|
|
current_balance = Decimal(str(wallet.purchased_credits))
|
|
new_balance = current_balance - amount # DEBIT csökkenti az egyenleget
|
|
update_values['purchased_credits'] = float(new_balance)
|
|
elif wallet_type == WalletType.SERVICE_COINS:
|
|
current_balance = Decimal(str(wallet.service_coins))
|
|
new_balance = current_balance - amount # DEBIT csökkenti az egyenleget
|
|
update_values['service_coins'] = float(new_balance)
|
|
elif wallet_type == WalletType.VOUCHER:
|
|
# VOUCHER típusnál nincs dedikált mező a Wallet modellben
|
|
# Kezeljük mint SERVICE_COINS vagy dobjunk hibát
|
|
current_balance = Decimal(str(wallet.service_coins))
|
|
new_balance = current_balance - amount # DEBIT csökkenti az egyenleget
|
|
update_values['service_coins'] = float(new_balance)
|
|
logger.warning(f"VOUCHER wallet_type használva, SERVICE_COINS frissítve")
|
|
else:
|
|
raise ValueError(f"Ismeretlen wallet_type: {wallet_type}")
|
|
|
|
# Frissítjük a pénztárcát
|
|
await db.execute(
|
|
update(Wallet)
|
|
.where(Wallet.id == wallet.id)
|
|
.values(**update_values)
|
|
)
|
|
|
|
logger.info(f"Pénztárca frissítve: {wallet.id}, wallet_type={wallet_type}, új egyenleg: {new_balance} (korábbi: {current_balance})")
|
|
|
|
# 4. FinancialLedger státusz frissítése SUCCESS-re
|
|
ledger_entry.status = LedgerStatus.SUCCESS
|
|
|
|
# 5. Számlakiállító bevételének frissítése
|
|
issuer.current_revenue += amount
|
|
db.add(issuer)
|
|
|
|
# 6. Külső szolgáltatások meghívása (ha vannak)
|
|
external_results = {}
|
|
|
|
if self.payment_gateway:
|
|
try:
|
|
payment_result = await self.payment_gateway.create_intent(
|
|
amount=amount,
|
|
currency="HUF",
|
|
metadata={
|
|
"ledger_id": ledger_entry.id,
|
|
"user_id": user_id,
|
|
"issuer_id": issuer.id,
|
|
**(metadata or {})
|
|
}
|
|
)
|
|
external_results["payment"] = payment_result
|
|
logger.info(f"Fizetési szándék létrehozva: {payment_result.get('id')}")
|
|
except PaymentGatewayError as e:
|
|
logger.error(f"Fizetési átjáró hiba: {e}")
|
|
# Döntés: tovább dobjuk a hibát, ami rollback-et okoz
|
|
raise
|
|
|
|
if self.invoicing_service:
|
|
try:
|
|
# Ügyfél adatok gyűjtése (egyszerűsített)
|
|
customer_data = {
|
|
"user_id": user_id,
|
|
"amount": float(amount),
|
|
"description": description
|
|
}
|
|
|
|
invoice_result = await self.invoicing_service.issue_invoice(
|
|
issuer_id=issuer.id,
|
|
customer_data=customer_data,
|
|
items=[{
|
|
"description": description or "Szolgáltatás díja",
|
|
"quantity": 1,
|
|
"unit_price": float(amount),
|
|
"vat_rate": 27.0 # ÁFA kulcs
|
|
}]
|
|
)
|
|
external_results["invoice"] = invoice_result
|
|
logger.info(f"Szála kiállítva: {invoice_result.get('invoice_number')}")
|
|
except InvoicingError as e:
|
|
logger.error(f"Számlázási hiba: {e}")
|
|
# Döntés: tovább dobjuk a hibát, ami rollback-et okoz
|
|
raise
|
|
|
|
# 7. COMMIT - minden művelet sikeres, atomi mentés
|
|
await db.commit()
|
|
logger.info(f"Tranzakció sikeresen commitálva: ledger_id={ledger_entry.id}")
|
|
|
|
# Visszatérési érték
|
|
return {
|
|
"success": True,
|
|
"ledger_id": ledger_entry.id,
|
|
"issuer_id": issuer.id,
|
|
"issuer_type": issuer.type,
|
|
"wallet_id": wallet.id,
|
|
"new_balance": new_balance,
|
|
"external_results": external_results,
|
|
"message": "Payment processed successfully"
|
|
}
|
|
|
|
except Exception as e:
|
|
# 8. ROLLBACK - bármilyen hiba esetén
|
|
logger.error(f"Hiba a tranzakcióban: {e}", exc_info=True)
|
|
await db.rollback()
|
|
|
|
# Speciális hibák újradobása
|
|
if isinstance(e, (InsufficientFundsError, PaymentGatewayError, InvoicingError)):
|
|
raise
|
|
|
|
# Általános hiba
|
|
raise FinancialOrchestratorError(f"Payment processing failed: {e}") from e
|
|
|
|
finally:
|
|
# 9. További takarítás (ha szükséges)
|
|
# Jelenleg nincs extra takarítási logika
|
|
pass
|
|
|
|
async def refund_payment(
|
|
self,
|
|
db: AsyncSession,
|
|
ledger_id: int,
|
|
reason: str = ""
|
|
) -> Dict[str, Any]:
|
|
"""
|
|
Visszatérítés folyamata Unit of Work mintával.
|
|
|
|
Ez a metódus visszafordítja egy korábbi tranzakciót:
|
|
1. Megkeresi az eredeti FinancialLedger bejegyzést
|
|
2. Létrehoz egy negatív összegű bejegyzést (REFUND státusszal)
|
|
3. Visszaállítja a pénztárca egyenlegét
|
|
4. Visszaállítja a számlakiállító bevételét
|
|
|
|
Args:
|
|
db: Adatbázis munkamenet
|
|
ledger_id: Az eredeti FinancialLedger bejegyzés azonosítója
|
|
reason: Visszatérítés oka
|
|
|
|
Returns:
|
|
Szótár a visszatérítés részleteivel
|
|
"""
|
|
try:
|
|
logger.info(f"Visszatérítés indítása: ledger_id={ledger_id}")
|
|
|
|
# 1. Eredeti bejegyzés lekérdezése
|
|
original_query = select(FinancialLedger).where(
|
|
FinancialLedger.id == ledger_id
|
|
).with_for_update()
|
|
|
|
original_result = await db.execute(original_query)
|
|
original_entry = original_result.scalar_one_or_none()
|
|
|
|
if not original_entry:
|
|
raise ValueError(f"Nincs FinancialLedger bejegyzés a következő ID-val: {ledger_id}")
|
|
|
|
if original_entry.status != LedgerStatus.SUCCESS:
|
|
raise ValueError(f"Csak SUCCESS státuszú bejegyzések téríthetők vissza. "
|
|
f"Jelenlegi státusz: {original_entry.status}")
|
|
|
|
# 2. Visszatérítési bejegyzés létrehozása
|
|
refund_entry = FinancialLedger(
|
|
user_id=original_entry.user_id,
|
|
amount=-original_entry.amount, # Negatív összeg
|
|
wallet_type=original_entry.wallet_type,
|
|
status=LedgerStatus.REFUND,
|
|
issuer_id=original_entry.issuer_id,
|
|
description=f"Visszatérítés: {reason}" if reason else "Visszatérítés",
|
|
metadata={
|
|
"original_ledger_id": ledger_id,
|
|
"reason": reason,
|
|
"refund_type": "full"
|
|
}
|
|
)
|
|
|
|
db.add(refund_entry)
|
|
await db.flush()
|
|
|
|
# 3. Pénztárca egyenleg visszaállítása
|
|
wallet_query = select(Wallet).where(
|
|
and_(
|
|
Wallet.user_id == original_entry.user_id,
|
|
Wallet.wallet_type == original_entry.wallet_type
|
|
)
|
|
).with_for_update()
|
|
|
|
wallet_result = await db.execute(wallet_query)
|
|
wallet = wallet_result.scalar_one_or_none()
|
|
|
|
if wallet:
|
|
new_balance = wallet.balance - original_entry.amount
|
|
await db.execute(
|
|
update(Wallet)
|
|
.where(Wallet.id == wallet.id)
|
|
.values(balance=new_balance)
|
|
)
|
|
|
|
# 4. Számlakiállító bevételének csökkentése
|
|
issuer_query = select(Issuer).where(Issuer.id == original_entry.issuer_id)
|
|
issuer_result = await db.execute(issuer_query)
|
|
issuer = issuer_result.scalar_one()
|
|
|
|
issuer.current_revenue -= original_entry.amount
|
|
db.add(issuer)
|
|
|
|
# 5. Eredeti bejegyzés státuszának frissítése
|
|
original_entry.status = LedgerStatus.REFUNDED
|
|
original_entry.metadata = {
|
|
**(original_entry.metadata or {}),
|
|
"refund_ledger_id": refund_entry.id,
|
|
"refund_reason": reason
|
|
}
|
|
|
|
# 6. COMMIT
|
|
await db.commit()
|
|
|
|
logger.info(f"Visszatérítés sikeres: refund_ledger_id={refund_entry.id}")
|
|
|
|
return {
|
|
"success": True,
|
|
"refund_ledger_id": refund_entry.id,
|
|
"original_ledger_id": ledger_id,
|
|
"amount_refunded": original_entry.amount,
|
|
"message": "Refund processed successfully"
|
|
}
|
|
|
|
except Exception as e:
|
|
logger.error(f"Hiba a visszatérítésben: {e}", exc_info=True)
|
|
await db.rollback()
|
|
raise FinancialOrchestratorError(f"Refund processing failed: {e}") from e
|
|
|
|
|
|
class FinancialOrchestratorError(Exception):
|
|
"""Kivétel a FinancialOrchestrator hibáinak kezelés""" |