Files
orion/app/modules/billing/routes/api/merchant.py
Samir Boulahtit f20266167d
Some checks failed
CI / ruff (push) Failing after 7s
CI / pytest (push) Failing after 1s
CI / architecture (push) Failing after 9s
CI / dependency-scanning (push) Successful in 27s
CI / audit (push) Successful in 8s
CI / docs (push) Has been skipped
fix(lint): auto-fix ruff violations and tune lint rules
- Auto-fixed 4,496 lint issues (import sorting, modern syntax, etc.)
- Added ignore rules for patterns intentional in this codebase:
  E402 (late imports), E712 (SQLAlchemy filters), B904 (raise from),
  SIM108/SIM105/SIM117 (readability preferences)
- Added per-file ignores for tests and scripts
- Excluded broken scripts/rename_terminology.py (has curly quotes)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-12 23:10:42 +01:00

299 lines
9.3 KiB
Python

# app/modules/billing/routes/api/merchant.py
"""
Merchant billing API endpoints for the merchant portal.
Provides subscription management and billing operations for merchant owners:
- View subscriptions across all platforms
- Subscription detail and tier info per platform
- Stripe checkout session creation
- Invoice history
Authentication: Authorization header (API-only, no cookies for CSRF safety).
The user must own at least one active merchant (validated by
get_merchant_for_current_user).
Auto-discovered by the route system (merchant.py in routes/api/ triggers
registration under /api/v1/merchants/billing/*).
"""
import logging
from fastapi import APIRouter, Depends, Path, Query, Request
from sqlalchemy.orm import Session
from app.api.deps import get_merchant_for_current_user
from app.core.database import get_db
from app.modules.billing.schemas import (
ChangeTierRequest,
ChangeTierResponse,
CheckoutRequest,
CheckoutResponse,
MerchantPortalAvailableTiersResponse,
MerchantPortalInvoiceListResponse,
MerchantPortalSubscriptionDetailResponse,
MerchantPortalSubscriptionItem,
MerchantPortalSubscriptionListResponse,
MerchantSubscriptionResponse,
TierInfo,
)
from app.modules.billing.services.billing_service import billing_service
from app.modules.billing.services.subscription_service import subscription_service
logger = logging.getLogger(__name__)
ROUTE_CONFIG = {
"prefix": "/billing",
}
router = APIRouter()
# ============================================================================
# Subscription Endpoints
# ============================================================================
@router.get("/subscriptions", response_model=MerchantPortalSubscriptionListResponse)
def list_merchant_subscriptions(
request: Request,
merchant=Depends(get_merchant_for_current_user),
db: Session = Depends(get_db),
):
"""
List all subscriptions for the current merchant.
Returns subscriptions across all platforms the merchant is subscribed to,
including tier information and status.
"""
subscriptions = subscription_service.get_merchant_subscriptions(db, merchant.id)
items = []
for sub in subscriptions:
data = MerchantSubscriptionResponse.model_validate(sub).model_dump()
data["tier"] = sub.tier.code if sub.tier else None
data["tier_name"] = sub.tier.name if sub.tier else None
data["platform_name"] = sub.platform.name if sub.platform else ""
items.append(MerchantPortalSubscriptionItem(**data))
return MerchantPortalSubscriptionListResponse(
subscriptions=items, total=len(items)
)
@router.get(
"/subscriptions/{platform_id}",
response_model=MerchantPortalSubscriptionDetailResponse,
)
def get_merchant_subscription(
request: Request,
platform_id: int = Path(..., description="Platform ID"),
merchant=Depends(get_merchant_for_current_user),
db: Session = Depends(get_db),
):
"""
Get subscription detail for a specific platform.
Returns the subscription with tier information for the given platform.
"""
subscription = subscription_service.get_merchant_subscription(
db, merchant.id, platform_id
)
if not subscription:
from app.exceptions.base import ResourceNotFoundException
raise ResourceNotFoundException(
resource_type="Subscription",
identifier=f"merchant={merchant.id}, platform={platform_id}",
error_code="SUBSCRIPTION_NOT_FOUND",
)
sub_data = MerchantSubscriptionResponse.model_validate(subscription).model_dump()
sub_data["tier"] = subscription.tier.code if subscription.tier else None
sub_data["tier_name"] = subscription.tier.name if subscription.tier else None
sub_data["platform_name"] = (
subscription.platform.name if subscription.platform else ""
)
tier_info = None
if subscription.tier:
tier = subscription.tier
tier_info = TierInfo(
code=tier.code,
name=tier.name,
description=tier.description,
price_monthly_cents=tier.price_monthly_cents,
price_annual_cents=tier.price_annual_cents,
feature_codes=(
tier.get_feature_codes() if hasattr(tier, "get_feature_codes") else []
),
)
return MerchantPortalSubscriptionDetailResponse(
subscription=MerchantPortalSubscriptionItem(**sub_data),
tier=tier_info,
)
@router.get(
"/subscriptions/{platform_id}/tiers",
response_model=MerchantPortalAvailableTiersResponse,
)
def get_available_tiers(
request: Request,
platform_id: int = Path(..., description="Platform ID"),
merchant=Depends(get_merchant_for_current_user),
db: Session = Depends(get_db),
):
"""
Get available tiers for upgrade on a specific platform.
Returns all public tiers with upgrade/downgrade flags relative to
the merchant's current tier.
"""
subscription = subscription_service.get_merchant_subscription(
db, merchant.id, platform_id
)
current_tier_id = subscription.tier_id if subscription else None
tier_list, tier_order = billing_service.get_available_tiers(
db, current_tier_id, platform_id
)
current_tier_code = None
if subscription and subscription.tier:
current_tier_code = subscription.tier.code
return MerchantPortalAvailableTiersResponse(
tiers=tier_list,
current_tier=current_tier_code,
)
@router.post(
"/subscriptions/{platform_id}/change-tier",
response_model=ChangeTierResponse,
)
def change_subscription_tier(
request: Request,
tier_data: ChangeTierRequest,
platform_id: int = Path(..., description="Platform ID"),
merchant=Depends(get_merchant_for_current_user),
db: Session = Depends(get_db),
):
"""
Change the subscription tier for a specific platform.
Handles both Stripe-connected and non-Stripe subscriptions.
"""
result = billing_service.change_tier(
db, merchant.id, platform_id, tier_data.tier_code, tier_data.is_annual
)
db.commit()
logger.info(
f"Merchant {merchant.id} ({merchant.name}) changed tier to "
f"{tier_data.tier_code} on platform={platform_id}"
)
return result
@router.post(
"/subscriptions/{platform_id}/checkout",
response_model=CheckoutResponse,
)
def create_checkout_session(
request: Request,
checkout_data: CheckoutRequest,
platform_id: int = Path(..., description="Platform ID"),
merchant=Depends(get_merchant_for_current_user),
db: Session = Depends(get_db),
):
"""
Create a Stripe checkout session for the merchant's subscription.
Starts a new subscription or upgrades an existing one to the
requested tier.
"""
# Build success/cancel URLs from request
base_url = str(request.base_url).rstrip("/")
success_url = (
f"{base_url}/merchants/billing/subscriptions/{platform_id}?checkout=success"
)
cancel_url = (
f"{base_url}/merchants/billing/subscriptions/{platform_id}?checkout=cancelled"
)
result = billing_service.create_checkout_session(
db=db,
merchant_id=merchant.id,
platform_id=platform_id,
tier_code=checkout_data.tier_code,
is_annual=checkout_data.is_annual,
success_url=success_url,
cancel_url=cancel_url,
)
db.commit()
logger.info(
f"Merchant {merchant.id} ({merchant.name}) created checkout session "
f"for tier={checkout_data.tier_code} on platform={platform_id}"
)
return CheckoutResponse(
checkout_url=result["checkout_url"],
session_id=result["session_id"],
)
# ============================================================================
# Invoice Endpoints
# ============================================================================
@router.get("/invoices", response_model=MerchantPortalInvoiceListResponse)
def get_invoices(
request: Request,
skip: int = Query(0, ge=0, description="Number of records to skip"),
limit: int = Query(20, ge=1, le=100, description="Max records to return"),
merchant=Depends(get_merchant_for_current_user),
db: Session = Depends(get_db),
):
"""
Get invoice history for the current merchant.
Returns paginated billing history entries ordered by date descending.
"""
invoices, total = billing_service.get_invoices(
db, merchant.id, skip=skip, limit=limit
)
return MerchantPortalInvoiceListResponse(
invoices=[
{
"id": inv.id,
"invoice_number": inv.invoice_number,
"invoice_date": inv.invoice_date.isoformat(),
"due_date": inv.due_date.isoformat() if inv.due_date else None,
"subtotal_cents": inv.subtotal_cents,
"tax_cents": inv.tax_cents,
"total_cents": inv.total_cents,
"amount_paid_cents": inv.amount_paid_cents,
"currency": inv.currency,
"status": inv.status,
"pdf_url": inv.invoice_pdf_url,
"hosted_url": inv.hosted_invoice_url,
"description": inv.description,
"created_at": (
inv.created_at.isoformat() if inv.created_at else None
),
}
for inv in invoices
],
total=total,
skip=skip,
limit=limit,
)