# /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