admin_nyelvi_javítas
This commit is contained in:
519
backend/app/services/financial_manager.py
Normal file
519
backend/app/services/financial_manager.py
Normal file
@@ -0,0 +1,519 @@
|
||||
# /opt/docker/dev/service_finder/backend/app/services/financial_manager.py
|
||||
"""
|
||||
Financial Manager — Unified orchestrator for the package purchase lifecycle.
|
||||
|
||||
Coordinates the complete flow:
|
||||
1. Validate tier exists and is purchasable
|
||||
2. Calculate price (via PricingCalculator)
|
||||
3. Create PaymentIntent (PENDING)
|
||||
4. Process payment via configurable gateway (MockPaymentGateway / StripeAdapter)
|
||||
5. Activate subscription (User or Organization level, with stacking)
|
||||
6. Distribute MLM commissions (async via background task)
|
||||
7. Return full purchase receipt
|
||||
|
||||
THOUGHT PROCESS:
|
||||
- Fully decoupled from payment gateway implementation (Strategy Pattern).
|
||||
- Commission distribution is separated so the caller can run it as a
|
||||
BackgroundTask (async) without blocking the payment response.
|
||||
- Uses the existing PaymentRouter for PaymentIntent creation and the
|
||||
existing BillingEngine for price calculation.
|
||||
- All database mutations happen within a single session for atomicity.
|
||||
- Returns a PurchaseResult DTO with all relevant details.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from datetime import datetime, date
|
||||
from decimal import Decimal
|
||||
from typing import Optional, Dict, Any, List
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.core_logic import SubscriptionTier
|
||||
from app.models.identity.identity import User
|
||||
from app.models.marketplace.payment import PaymentIntent, PaymentIntentStatus
|
||||
from app.models.system.audit import WalletType
|
||||
|
||||
from app.services.financial_interfaces import (
|
||||
BasePaymentGateway,
|
||||
PaymentGatewayError,
|
||||
InsufficientFundsError,
|
||||
)
|
||||
from app.services.mock_payment_gateway import MockPaymentGateway
|
||||
from app.services.subscription_activator import (
|
||||
SubscriptionActivator,
|
||||
TierNotFoundError,
|
||||
)
|
||||
from app.services.billing_engine import PricingCalculator
|
||||
from app.services.payment_router import PaymentRouter
|
||||
from app.schemas.commission import (
|
||||
CommissionDistributionRequest,
|
||||
CommissionDistributionResponse,
|
||||
)
|
||||
from app.services import commission_service
|
||||
|
||||
logger = logging.getLogger("financial-manager")
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
# Data Transfer Objects
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class PurchaseResult:
|
||||
"""
|
||||
Result DTO for a completed package purchase.
|
||||
|
||||
Contains all information needed for the API response and receipt.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
success: bool,
|
||||
payment_intent_id: Optional[int] = None,
|
||||
transaction_id: Optional[str] = None,
|
||||
subscription_id: Optional[int] = None,
|
||||
tier_name: Optional[str] = None,
|
||||
valid_from: Optional[datetime] = None,
|
||||
valid_until: Optional[datetime] = None,
|
||||
amount_paid: float = 0.0,
|
||||
currency: str = "EUR",
|
||||
gateway: str = "mock",
|
||||
gateway_intent_id: Optional[str] = None,
|
||||
commission_result: Optional[CommissionDistributionResponse] = None,
|
||||
error: Optional[str] = None,
|
||||
is_org_subscription: bool = False,
|
||||
):
|
||||
self.success = success
|
||||
self.payment_intent_id = payment_intent_id
|
||||
self.transaction_id = transaction_id
|
||||
self.subscription_id = subscription_id
|
||||
self.tier_name = tier_name
|
||||
self.valid_from = valid_from
|
||||
self.valid_until = valid_until
|
||||
self.amount_paid = amount_paid
|
||||
self.currency = currency
|
||||
self.gateway = gateway
|
||||
self.gateway_intent_id = gateway_intent_id
|
||||
self.commission_result = commission_result
|
||||
self.error = error
|
||||
self.is_org_subscription = is_org_subscription
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Serialize to a dict for API response."""
|
||||
result = {
|
||||
"success": self.success,
|
||||
"payment_intent_id": self.payment_intent_id,
|
||||
"transaction_id": self.transaction_id,
|
||||
"subscription_id": self.subscription_id,
|
||||
"tier_name": self.tier_name,
|
||||
"valid_from": self.valid_from.isoformat() if self.valid_from else None,
|
||||
"valid_until": self.valid_until.isoformat() if self.valid_until else None,
|
||||
"amount_paid": self.amount_paid,
|
||||
"currency": self.currency,
|
||||
"gateway": self.gateway,
|
||||
"gateway_intent_id": self.gateway_intent_id,
|
||||
"is_org_subscription": self.is_org_subscription,
|
||||
}
|
||||
if self.commission_result:
|
||||
result["commission"] = {
|
||||
"total_commission": self.commission_result.total_commission,
|
||||
"items": [
|
||||
{
|
||||
"level": item.level,
|
||||
"user_id": item.user_id,
|
||||
"commission_percent": item.commission_percent,
|
||||
"commission_amount": item.commission_amount,
|
||||
}
|
||||
for item in self.commission_result.items
|
||||
],
|
||||
}
|
||||
if self.error:
|
||||
result["error"] = self.error
|
||||
return result
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
# Financial Manager
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class FinancialManager:
|
||||
"""
|
||||
Unified orchestrator for the package purchase lifecycle.
|
||||
|
||||
Usage:
|
||||
manager = FinancialManager(payment_gateway=MockPaymentGateway())
|
||||
result = await manager.purchase_package(db, user_id=1, tier_id=2)
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
payment_gateway: Optional[BasePaymentGateway] = None,
|
||||
subscription_activator: Optional[SubscriptionActivator] = None,
|
||||
):
|
||||
"""
|
||||
Initialize the FinancialManager.
|
||||
|
||||
Args:
|
||||
payment_gateway: Payment gateway implementation. Defaults to
|
||||
MockPaymentGateway (auto_approve mode).
|
||||
subscription_activator: Subscription activator. Defaults to a new
|
||||
SubscriptionActivator instance.
|
||||
"""
|
||||
self.payment_gateway = payment_gateway or MockPaymentGateway()
|
||||
self.subscription_activator = subscription_activator or SubscriptionActivator()
|
||||
self.pricing_calculator = PricingCalculator()
|
||||
logger.info(
|
||||
"FinancialManager initialized with gateway=%s",
|
||||
type(self.payment_gateway).__name__,
|
||||
)
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────
|
||||
# Main Purchase Flow
|
||||
# ──────────────────────────────────────────────────────────────────────────
|
||||
|
||||
async def purchase_package(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
user_id: int,
|
||||
tier_id: int,
|
||||
org_id: Optional[int] = None,
|
||||
region_code: str = "GLOBAL",
|
||||
currency: str = "EUR",
|
||||
duration_days: Optional[int] = None,
|
||||
metadata: Optional[Dict[str, Any]] = None,
|
||||
) -> PurchaseResult:
|
||||
"""
|
||||
Execute the full package purchase lifecycle.
|
||||
|
||||
Flow:
|
||||
1. Validate the tier exists and is purchasable
|
||||
2. Calculate the price
|
||||
3. Create a PaymentIntent (PENDING)
|
||||
4. Process payment via the configured gateway
|
||||
5. Activate the subscription (User or Org level)
|
||||
6. Distribute MLM commissions (returned for async execution)
|
||||
7. Return the PurchaseResult
|
||||
|
||||
Args:
|
||||
db: Database session.
|
||||
user_id: The purchasing user.
|
||||
tier_id: The SubscriptionTier ID to purchase.
|
||||
org_id: Optional organization ID for org-level subscriptions.
|
||||
region_code: Region code for pricing (default: GLOBAL).
|
||||
currency: Currency code (default: EUR).
|
||||
duration_days: Override duration in days (default: from tier.rules).
|
||||
metadata: Optional metadata for the PaymentIntent.
|
||||
|
||||
Returns:
|
||||
PurchaseResult with full purchase details.
|
||||
|
||||
Raises:
|
||||
TierNotFoundError: If the tier does not exist.
|
||||
PaymentGatewayError: If payment processing fails.
|
||||
"""
|
||||
logger.info(
|
||||
"Purchase flow started: user_id=%d tier_id=%d org_id=%s",
|
||||
user_id, tier_id, org_id,
|
||||
)
|
||||
|
||||
try:
|
||||
# ── Step 1: Validate tier ──────────────────────────────────────
|
||||
tier = await self._validate_tier(db, tier_id)
|
||||
|
||||
# ── Step 2: Calculate price ────────────────────────────────────
|
||||
price = await self._calculate_price(db, tier, region_code, currency)
|
||||
|
||||
# ── Step 3: Create PaymentIntent ───────────────────────────────
|
||||
payment_intent = await self._create_payment_intent(
|
||||
db=db,
|
||||
user_id=user_id,
|
||||
amount=price,
|
||||
currency=currency,
|
||||
metadata=metadata,
|
||||
)
|
||||
|
||||
# ── Step 4: Process payment via gateway ────────────────────────
|
||||
gateway_result = await self.payment_gateway.create_intent(
|
||||
amount=Decimal(str(price)),
|
||||
currency=currency,
|
||||
metadata={
|
||||
"payment_intent_id": payment_intent.id,
|
||||
"tier_id": tier_id,
|
||||
"user_id": user_id,
|
||||
**(metadata or {}),
|
||||
},
|
||||
)
|
||||
|
||||
# Mark PaymentIntent as COMPLETED
|
||||
payment_intent.status = PaymentIntentStatus.COMPLETED
|
||||
payment_intent.stripe_session_id = gateway_result.get("id")
|
||||
payment_intent.completed_at = datetime.utcnow()
|
||||
|
||||
# ── Step 5: Activate subscription ──────────────────────────────
|
||||
if org_id:
|
||||
subscription = await self.subscription_activator.activate_org_subscription(
|
||||
db=db,
|
||||
org_id=org_id,
|
||||
tier_id=tier_id,
|
||||
duration_days=duration_days,
|
||||
)
|
||||
is_org = True
|
||||
else:
|
||||
subscription = await self.subscription_activator.activate_user_subscription(
|
||||
db=db,
|
||||
user_id=user_id,
|
||||
tier_id=tier_id,
|
||||
duration_days=duration_days,
|
||||
)
|
||||
is_org = False
|
||||
|
||||
# ── Step 6: Prepare commission distribution (for async caller) ─
|
||||
commission_result = await self._distribute_commission(
|
||||
db=db,
|
||||
buyer_user_id=user_id,
|
||||
transaction_amount=price,
|
||||
region_code=region_code,
|
||||
)
|
||||
|
||||
# ── Step 7: Commit all changes ─────────────────────────────────
|
||||
await db.commit()
|
||||
|
||||
logger.info(
|
||||
"Purchase flow COMPLETED: user_id=%d tier=%s amount=%.2f "
|
||||
"subscription_id=%d commission_total=%.2f",
|
||||
user_id, tier.name, price,
|
||||
subscription.id,
|
||||
commission_result.total_commission if commission_result else 0,
|
||||
)
|
||||
|
||||
return PurchaseResult(
|
||||
success=True,
|
||||
payment_intent_id=payment_intent.id,
|
||||
transaction_id=str(payment_intent.transaction_id) if payment_intent.transaction_id else None,
|
||||
subscription_id=subscription.id,
|
||||
tier_name=tier.name,
|
||||
valid_from=subscription.valid_from,
|
||||
valid_until=subscription.valid_until,
|
||||
amount_paid=price,
|
||||
currency=currency,
|
||||
gateway=type(self.payment_gateway).__name__,
|
||||
gateway_intent_id=gateway_result.get("id"),
|
||||
commission_result=commission_result,
|
||||
is_org_subscription=is_org,
|
||||
)
|
||||
|
||||
except PaymentGatewayError as e:
|
||||
# Mark PaymentIntent as FAILED
|
||||
payment_intent.status = PaymentIntentStatus.FAILED
|
||||
await db.commit()
|
||||
|
||||
logger.error(
|
||||
"Purchase flow FAILED (payment): user_id=%d tier_id=%d error=%s",
|
||||
user_id, tier_id, str(e),
|
||||
)
|
||||
|
||||
return PurchaseResult(
|
||||
success=False,
|
||||
payment_intent_id=payment_intent.id,
|
||||
error=f"Payment failed: {str(e)}",
|
||||
amount_paid=0,
|
||||
currency=currency,
|
||||
)
|
||||
|
||||
except TierNotFoundError as e:
|
||||
# Tier not found — no PaymentIntent was created, just return error
|
||||
logger.error(
|
||||
"Purchase flow FAILED (tier not found): user_id=%d tier_id=%d error=%s",
|
||||
user_id, tier_id, str(e),
|
||||
)
|
||||
|
||||
return PurchaseResult(
|
||||
success=False,
|
||||
error=str(e),
|
||||
amount_paid=0,
|
||||
currency=currency,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
# Check if payment_intent exists before trying to modify it
|
||||
payment_intent_id = payment_intent.id if 'payment_intent' in dir() or 'payment_intent' in locals() else None
|
||||
if payment_intent_id:
|
||||
payment_intent.status = PaymentIntentStatus.FAILED
|
||||
await db.commit()
|
||||
|
||||
logger.exception(
|
||||
"Purchase flow FAILED (unexpected): user_id=%d tier_id=%d",
|
||||
user_id, tier_id,
|
||||
)
|
||||
|
||||
return PurchaseResult(
|
||||
success=False,
|
||||
payment_intent_id=payment_intent_id,
|
||||
error=f"Unexpected error: {str(e)}",
|
||||
amount_paid=0,
|
||||
currency=currency,
|
||||
)
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────
|
||||
# Internal Steps
|
||||
# ──────────────────────────────────────────────────────────────────────────
|
||||
|
||||
async def _validate_tier(self, db: AsyncSession, tier_id: int) -> SubscriptionTier:
|
||||
"""
|
||||
Validate that the tier exists and is purchasable.
|
||||
|
||||
Raises TierNotFoundError if the tier doesn't exist.
|
||||
Additional validation (e.g., is_public, available_until) can be added here.
|
||||
"""
|
||||
stmt = select(SubscriptionTier).where(SubscriptionTier.id == tier_id)
|
||||
result = await db.execute(stmt)
|
||||
tier = result.scalar_one_or_none()
|
||||
if not tier:
|
||||
raise TierNotFoundError(f"SubscriptionTier with id={tier_id} not found")
|
||||
return tier
|
||||
|
||||
async def _calculate_price(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
tier: SubscriptionTier,
|
||||
region_code: str,
|
||||
currency: str,
|
||||
) -> float:
|
||||
"""
|
||||
Calculate the price for this tier using the PricingCalculator.
|
||||
|
||||
Pricing is extracted from tier.rules["pricing_zones"] using the following priority:
|
||||
1. Exact region_code match (e.g., "HU")
|
||||
2. Currency match within pricing_zones
|
||||
3. "DEFAULT" zone fallback
|
||||
4. Legacy tier.rules["price"] fallback
|
||||
|
||||
Falls back to tier.rules["price"] if PricingCalculator returns 0.
|
||||
"""
|
||||
price = 0.0
|
||||
|
||||
if tier.rules:
|
||||
# ── Extract from pricing_zones (P0 standard) ──────────────
|
||||
pricing_zones = tier.rules.get("pricing_zones", {})
|
||||
if pricing_zones:
|
||||
# Priority 1: Exact region_code match
|
||||
zone = pricing_zones.get(region_code)
|
||||
if not zone:
|
||||
# Priority 2: Currency match within zones
|
||||
for z in pricing_zones.values():
|
||||
if z.get("currency") == currency:
|
||||
zone = z
|
||||
break
|
||||
if not zone:
|
||||
# Priority 3: DEFAULT zone fallback
|
||||
zone = pricing_zones.get("DEFAULT")
|
||||
|
||||
if zone:
|
||||
# Prefer monthly_price, fallback to yearly_price, then credit_price
|
||||
price = float(zone.get("monthly_price", 0.0))
|
||||
if price <= 0:
|
||||
price = float(zone.get("yearly_price", 0.0))
|
||||
if price <= 0:
|
||||
price = float(zone.get("credit_price", 0.0))
|
||||
|
||||
# ── Legacy fallback: tier.rules["price"] ──────────────────
|
||||
if price <= 0:
|
||||
price = float(tier.rules.get("price", 0.0))
|
||||
|
||||
# Use PricingCalculator for region-adjusted pricing
|
||||
if price > 0:
|
||||
calculated = await PricingCalculator.calculate_final_price(
|
||||
db=db,
|
||||
base_amount=price,
|
||||
country_code=region_code,
|
||||
user_role=None, # No RBAC discount for purchases
|
||||
)
|
||||
if calculated > 0:
|
||||
price = calculated
|
||||
|
||||
logger.debug(
|
||||
"Price calculated: tier=%s region=%s currency=%s final=%.2f",
|
||||
tier.name, region_code, currency, price,
|
||||
)
|
||||
|
||||
return price
|
||||
|
||||
async def _create_payment_intent(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
user_id: int,
|
||||
amount: float,
|
||||
currency: str = "EUR",
|
||||
metadata: Optional[Dict[str, Any]] = None,
|
||||
) -> PaymentIntent:
|
||||
"""
|
||||
Create a PENDING PaymentIntent for the purchase.
|
||||
|
||||
Uses the existing PaymentRouter for consistency with the rest of the system.
|
||||
"""
|
||||
payment_intent = await PaymentRouter.create_payment_intent(
|
||||
db=db,
|
||||
payer_id=user_id,
|
||||
net_amount=amount,
|
||||
handling_fee=0.0,
|
||||
target_wallet_type=WalletType.PURCHASED,
|
||||
currency=currency,
|
||||
metadata={
|
||||
"source": "financial_manager",
|
||||
"purchase_type": "subscription",
|
||||
**(metadata or {}),
|
||||
},
|
||||
)
|
||||
|
||||
logger.debug(
|
||||
"PaymentIntent created: id=%d user_id=%d amount=%.2f %s",
|
||||
payment_intent.id, user_id, amount, currency,
|
||||
)
|
||||
|
||||
return payment_intent
|
||||
|
||||
async def _distribute_commission(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
buyer_user_id: int,
|
||||
transaction_amount: float,
|
||||
region_code: str,
|
||||
) -> Optional[CommissionDistributionResponse]:
|
||||
"""
|
||||
Distribute MLM commissions for this purchase.
|
||||
|
||||
Returns the commission result, or None if no commission was distributed.
|
||||
This is designed to be callable from a BackgroundTask for async execution.
|
||||
|
||||
Args:
|
||||
db: Database session.
|
||||
buyer_user_id: The user who made the purchase.
|
||||
transaction_amount: The amount paid.
|
||||
region_code: Region code for rule matching.
|
||||
|
||||
Returns:
|
||||
CommissionDistributionResponse or None.
|
||||
"""
|
||||
try:
|
||||
request = CommissionDistributionRequest(
|
||||
buyer_user_id=buyer_user_id,
|
||||
transaction_amount=transaction_amount,
|
||||
transaction_date=date.today(),
|
||||
region_code=region_code,
|
||||
is_renewal=False,
|
||||
)
|
||||
result = await commission_service.distribute_commission(db, request)
|
||||
logger.info(
|
||||
"Commission distributed: buyer=%d total=%.2f items=%d",
|
||||
buyer_user_id, result.total_commission, len(result.items),
|
||||
)
|
||||
return result
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
"Commission distribution failed for buyer=%d: %s",
|
||||
buyer_user_id, str(e),
|
||||
)
|
||||
# Commission failure should not block the purchase
|
||||
return None
|
||||
Reference in New Issue
Block a user