238 lines
9.7 KiB
Python
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",
|
|
}
|