Files
service-finder/backend/app/services/financial_orchestrator.py
2026-03-13 10:22:41 +00:00

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"""