520 lines
21 KiB
Python
520 lines
21 KiB
Python
# /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
|