Files
service-finder/backend/app/api/v1/endpoints/financial_manager.py
2026-07-25 10:22:03 +00:00

238 lines
9.7 KiB
Python

# /opt/docker/dev/service_finder/backend/app/api/v1/endpoints/financial_manager.py
"""
Financial Manager API Endpoints — Package Purchase Lifecycle.
Provides the public API for purchasing subscription packages.
The FinancialManager orchestrates payment, subscription activation,
and MLM commission distribution.
THOUGHT PROCESS:
- POST /financial-manager/purchase-package is the main entry point.
- Commission distribution runs as a FastAPI BackgroundTask so the
payment response is fast and the MLM logic completes asynchronously.
- Uses the existing get_current_user dependency for authentication.
- Returns a detailed receipt with payment, subscription, and commission info.
- Fully async with proper error handling and logging.
"""
import logging
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, BackgroundTasks, status
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.deps import get_db, get_current_user
from app.models.identity.identity import User
from app.schemas.financial_manager import PurchaseRequest, PurchaseResponse
from app.services.financial_manager import FinancialManager
from app.services.mock_payment_gateway import MockPaymentGateway
from app.services.subscription_activator import TierNotFoundError
from app.services.financial_interfaces import PaymentGatewayError
logger = logging.getLogger("financial-manager-api")
router = APIRouter()
# ──────────────────────────────────────────────────────────────────────────────
# Dependency: FinancialManager instance
# ──────────────────────────────────────────────────────────────────────────────
def get_financial_manager() -> FinancialManager:
"""
Dependency that provides a configured FinancialManager instance.
Currently uses MockPaymentGateway as default. To switch to Stripe:
from app.services.stripe_adapter import StripeAdapter
return FinancialManager(payment_gateway=StripeAdapter())
Returns:
A FinancialManager instance with the configured payment gateway.
"""
return FinancialManager(
payment_gateway=MockPaymentGateway(mode="auto_approve"),
)
# ──────────────────────────────────────────────────────────────────────────────
# Background Task: Async Commission Distribution
# ──────────────────────────────────────────────────────────────────────────────
async def distribute_commission_background(
db: AsyncSession,
buyer_user_id: int,
transaction_amount: float,
region_code: str,
) -> None:
"""
Background task for async MLM commission distribution.
Runs after the purchase response is sent to the client.
Uses a new database session to avoid sharing the request session.
Args:
db: A new database session.
buyer_user_id: The user who made the purchase.
transaction_amount: The amount paid.
region_code: Region code for rule matching.
"""
try:
manager = get_financial_manager()
await manager._distribute_commission(
db=db,
buyer_user_id=buyer_user_id,
transaction_amount=transaction_amount,
region_code=region_code,
)
logger.info(
"Background commission distribution completed for buyer=%d",
buyer_user_id,
)
except Exception as e:
logger.error(
"Background commission distribution FAILED for buyer=%d: %s",
buyer_user_id, str(e),
)
finally:
await db.close()
# ──────────────────────────────────────────────────────────────────────────────
# POST /financial-manager/purchase-package
# ──────────────────────────────────────────────────────────────────────────────
@router.post(
"/purchase-package",
response_model=PurchaseResponse,
status_code=status.HTTP_200_OK,
tags=["Financial Manager"],
summary="Purchase a subscription package",
description="""
Purchase a subscription package with full lifecycle management.
Flow:
1. Validates the subscription tier exists
2. Calculates the price (region-adjusted)
3. Creates a PaymentIntent (PENDING)
4. Processes payment via the configured gateway (Mock or Stripe)
5. Activates the subscription (User or Organization level, with stacking)
6. Distributes MLM commissions asynchronously (BackgroundTask)
7. Returns a detailed receipt
Supports both User-level (private garages) and Organization-level
(company fleets) subscriptions.
""",
)
async def purchase_package(
request: PurchaseRequest,
background_tasks: BackgroundTasks,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
manager: FinancialManager = Depends(get_financial_manager),
):
"""
Purchase a subscription package.
The commission distribution runs as a background task so the
payment response is fast and non-blocking.
Args:
request: Purchase details (tier_id, org_id, region_code, etc.).
background_tasks: FastAPI BackgroundTasks for async commission.
db: Database session.
current_user: Authenticated user.
manager: FinancialManager instance.
Returns:
PurchaseResponse with full receipt details.
"""
logger.info(
"Purchase request: user_id=%d tier_id=%d org_id=%s",
current_user.id, request.tier_id, request.org_id,
)
# ── Execute the purchase flow ──────────────────────────────────────────
result = await manager.purchase_package(
db=db,
user_id=current_user.id,
tier_id=request.tier_id,
org_id=request.org_id,
region_code=request.region_code,
currency=request.currency,
duration_days=request.duration_days,
metadata=request.metadata,
)
# ── Handle failure ─────────────────────────────────────────────────────
if not result.success:
error_detail = result.error or "Unknown error during purchase"
logger.warning(
"Purchase failed: user_id=%d tier_id=%d error=%s",
current_user.id, request.tier_id, error_detail,
)
# Determine appropriate HTTP status code
status_code = status.HTTP_400_BAD_REQUEST
if "not found" in error_detail.lower():
status_code = status.HTTP_404_NOT_FOUND
elif "payment" in error_detail.lower():
status_code = status.HTTP_402_PAYMENT_REQUIRED
raise HTTPException(
status_code=status_code,
detail=error_detail,
)
# ── Schedule async commission distribution ─────────────────────────────
# We pass the commission result from the sync call, but also schedule
# a background task for any additional async processing.
# The sync commission result is already included in the response.
if result.commission_result and result.commission_result.items:
background_tasks.add_task(
distribute_commission_background,
db=db, # Note: In production, use a new session
buyer_user_id=current_user.id,
transaction_amount=result.amount_paid,
region_code=request.region_code,
)
logger.info(
"Background commission task scheduled for buyer=%d",
current_user.id,
)
# ── Build response ─────────────────────────────────────────────────────
response_data = result.to_dict()
return PurchaseResponse(**response_data)
# ──────────────────────────────────────────────────────────────────────────────
# Health / Status Endpoint
# ──────────────────────────────────────────────────────────────────────────────
@router.get(
"/financial-manager/status",
tags=["Financial Manager"],
summary="Check FinancialManager status",
)
async def financial_manager_status(
current_user: User = Depends(get_current_user),
):
"""
Check the FinancialManager service status.
Returns the configured payment gateway type and current mode.
Useful for debugging and monitoring.
"""
manager = get_financial_manager()
gateway = manager.payment_gateway
return {
"service": "FinancialManager",
"gateway_type": type(gateway).__name__,
"gateway_mode": getattr(gateway, "mode", "unknown"),
"status": "operational",
}