# app/api/v1/vendor/billing.py """ Vendor billing and subscription management endpoints. Provides: - Subscription status and usage - Tier listing and comparison - Stripe checkout and portal access - Invoice history - Add-on management """ import logging from fastapi import APIRouter, Depends, Query from pydantic import BaseModel from sqlalchemy.orm import Session from app.api.deps import get_current_vendor_api from app.core.config import settings from app.core.database import get_db from app.services.billing_service import billing_service from app.services.subscription_service import subscription_service from models.database.user import User router = APIRouter(prefix="/billing") logger = logging.getLogger(__name__) # ============================================================================ # Schemas # ============================================================================ class SubscriptionStatusResponse(BaseModel): """Current subscription status and usage.""" tier_code: str tier_name: str status: str is_trial: bool trial_ends_at: str | None = None period_start: str | None = None period_end: str | None = None cancelled_at: str | None = None cancellation_reason: str | None = None # Usage orders_this_period: int orders_limit: int | None orders_remaining: int | None products_count: int products_limit: int | None products_remaining: int | None team_count: int team_limit: int | None team_remaining: int | None # Payment has_payment_method: bool last_payment_error: str | None = None class Config: from_attributes = True class TierResponse(BaseModel): """Subscription tier information.""" code: str name: str description: str | None = None price_monthly_cents: int price_annual_cents: int | None = None orders_per_month: int | None = None products_limit: int | None = None team_members: int | None = None features: list[str] = [] is_current: bool = False can_upgrade: bool = False can_downgrade: bool = False class TierListResponse(BaseModel): """List of available tiers.""" tiers: list[TierResponse] current_tier: str class CheckoutRequest(BaseModel): """Request to create a checkout session.""" tier_code: str is_annual: bool = False class CheckoutResponse(BaseModel): """Checkout session response.""" checkout_url: str session_id: str class PortalResponse(BaseModel): """Customer portal session response.""" portal_url: str class InvoiceResponse(BaseModel): """Invoice information.""" id: int invoice_number: str | None = None invoice_date: str due_date: str | None = None total_cents: int amount_paid_cents: int currency: str status: str pdf_url: str | None = None hosted_url: str | None = None class InvoiceListResponse(BaseModel): """List of invoices.""" invoices: list[InvoiceResponse] total: int class AddOnResponse(BaseModel): """Add-on product information.""" id: int code: str name: str description: str | None = None category: str price_cents: int billing_period: str quantity_unit: str | None = None quantity_value: int | None = None class VendorAddOnResponse(BaseModel): """Vendor's purchased add-on.""" id: int addon_code: str addon_name: str status: str domain_name: str | None = None quantity: int period_start: str | None = None period_end: str | None = None class AddOnPurchaseRequest(BaseModel): """Request to purchase an add-on.""" addon_code: str domain_name: str | None = None # For domain add-ons quantity: int = 1 class CancelRequest(BaseModel): """Request to cancel subscription.""" reason: str | None = None immediately: bool = False class CancelResponse(BaseModel): """Cancellation response.""" message: str effective_date: str class UpcomingInvoiceResponse(BaseModel): """Upcoming invoice preview.""" amount_due_cents: int currency: str next_payment_date: str | None = None line_items: list[dict] = [] class ChangeTierRequest(BaseModel): """Request to change subscription tier.""" tier_code: str is_annual: bool = False class ChangeTierResponse(BaseModel): """Response for tier change.""" message: str new_tier: str effective_immediately: bool class AddOnCancelResponse(BaseModel): """Response for add-on cancellation.""" message: str addon_code: str # ============================================================================ # Endpoints # ============================================================================ @router.get("/subscription", response_model=SubscriptionStatusResponse) def get_subscription_status( current_user: User = Depends(get_current_vendor_api), db: Session = Depends(get_db), ): """Get current subscription status and usage metrics.""" vendor_id = current_user.token_vendor_id usage = subscription_service.get_usage_summary(db, vendor_id) subscription, tier = billing_service.get_subscription_with_tier(db, vendor_id) return SubscriptionStatusResponse( tier_code=subscription.tier, tier_name=tier.name if tier else subscription.tier.title(), status=subscription.status.value, is_trial=subscription.is_in_trial(), trial_ends_at=subscription.trial_ends_at.isoformat() if subscription.trial_ends_at else None, period_start=subscription.period_start.isoformat() if subscription.period_start else None, period_end=subscription.period_end.isoformat() if subscription.period_end else None, cancelled_at=subscription.cancelled_at.isoformat() if subscription.cancelled_at else None, cancellation_reason=subscription.cancellation_reason, orders_this_period=usage.orders_this_period, orders_limit=usage.orders_limit, orders_remaining=usage.orders_remaining, products_count=usage.products_count, products_limit=usage.products_limit, products_remaining=usage.products_remaining, team_count=usage.team_count, team_limit=usage.team_limit, team_remaining=usage.team_remaining, has_payment_method=bool(subscription.stripe_payment_method_id), last_payment_error=subscription.last_payment_error, ) @router.get("/tiers", response_model=TierListResponse) def get_available_tiers( current_user: User = Depends(get_current_vendor_api), db: Session = Depends(get_db), ): """Get available subscription tiers for upgrade/downgrade.""" vendor_id = current_user.token_vendor_id subscription = subscription_service.get_or_create_subscription(db, vendor_id) current_tier = subscription.tier tier_list, _ = billing_service.get_available_tiers(db, current_tier) tier_responses = [TierResponse(**tier_data) for tier_data in tier_list] return TierListResponse(tiers=tier_responses, current_tier=current_tier) @router.post("/checkout", response_model=CheckoutResponse) def create_checkout_session( request: CheckoutRequest, current_user: User = Depends(get_current_vendor_api), db: Session = Depends(get_db), ): """Create a Stripe checkout session for subscription.""" vendor_id = current_user.token_vendor_id vendor = billing_service.get_vendor(db, vendor_id) # Build URLs base_url = f"https://{settings.platform_domain}" success_url = f"{base_url}/vendor/{vendor.vendor_code}/billing?success=true" cancel_url = f"{base_url}/vendor/{vendor.vendor_code}/billing?cancelled=true" result = billing_service.create_checkout_session( db=db, vendor_id=vendor_id, tier_code=request.tier_code, is_annual=request.is_annual, success_url=success_url, cancel_url=cancel_url, ) db.commit() return CheckoutResponse(checkout_url=result["checkout_url"], session_id=result["session_id"]) @router.post("/portal", response_model=PortalResponse) def create_portal_session( current_user: User = Depends(get_current_vendor_api), db: Session = Depends(get_db), ): """Create a Stripe customer portal session.""" vendor_id = current_user.token_vendor_id vendor = billing_service.get_vendor(db, vendor_id) return_url = f"https://{settings.platform_domain}/vendor/{vendor.vendor_code}/billing" result = billing_service.create_portal_session(db, vendor_id, return_url) return PortalResponse(portal_url=result["portal_url"]) @router.get("/invoices", response_model=InvoiceListResponse) def get_invoices( skip: int = Query(0, ge=0), limit: int = Query(20, ge=1, le=100), current_user: User = Depends(get_current_vendor_api), db: Session = Depends(get_db), ): """Get invoice history.""" vendor_id = current_user.token_vendor_id invoices, total = billing_service.get_invoices(db, vendor_id, skip=skip, limit=limit) invoice_responses = [ InvoiceResponse( 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, 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, ) for inv in invoices ] return InvoiceListResponse(invoices=invoice_responses, total=total) @router.get("/addons", response_model=list[AddOnResponse]) def get_available_addons( category: str | None = Query(None, description="Filter by category"), current_user: User = Depends(get_current_vendor_api), db: Session = Depends(get_db), ): """Get available add-on products.""" addons = billing_service.get_available_addons(db, category=category) return [ AddOnResponse( id=addon.id, code=addon.code, name=addon.name, description=addon.description, category=addon.category, price_cents=addon.price_cents, billing_period=addon.billing_period, quantity_unit=addon.quantity_unit, quantity_value=addon.quantity_value, ) for addon in addons ] @router.get("/my-addons", response_model=list[VendorAddOnResponse]) def get_vendor_addons( current_user: User = Depends(get_current_vendor_api), db: Session = Depends(get_db), ): """Get vendor's purchased add-ons.""" vendor_id = current_user.token_vendor_id vendor_addons = billing_service.get_vendor_addons(db, vendor_id) return [ VendorAddOnResponse( id=va.id, addon_code=va.addon_product.code, addon_name=va.addon_product.name, status=va.status, domain_name=va.domain_name, quantity=va.quantity, period_start=va.period_start.isoformat() if va.period_start else None, period_end=va.period_end.isoformat() if va.period_end else None, ) for va in vendor_addons ] @router.post("/cancel", response_model=CancelResponse) def cancel_subscription( request: CancelRequest, current_user: User = Depends(get_current_vendor_api), db: Session = Depends(get_db), ): """Cancel subscription.""" vendor_id = current_user.token_vendor_id result = billing_service.cancel_subscription( db=db, vendor_id=vendor_id, reason=request.reason, immediately=request.immediately, ) db.commit() return CancelResponse( message=result["message"], effective_date=result["effective_date"], ) @router.post("/reactivate") def reactivate_subscription( current_user: User = Depends(get_current_vendor_api), db: Session = Depends(get_db), ): """Reactivate a cancelled subscription.""" vendor_id = current_user.token_vendor_id result = billing_service.reactivate_subscription(db, vendor_id) db.commit() return result @router.get("/upcoming-invoice", response_model=UpcomingInvoiceResponse) def get_upcoming_invoice( current_user: User = Depends(get_current_vendor_api), db: Session = Depends(get_db), ): """Preview the upcoming invoice.""" vendor_id = current_user.token_vendor_id result = billing_service.get_upcoming_invoice(db, vendor_id) return UpcomingInvoiceResponse( amount_due_cents=result.get("amount_due_cents", 0), currency=result.get("currency", "EUR"), next_payment_date=result.get("next_payment_date"), line_items=result.get("line_items", []), ) @router.post("/change-tier", response_model=ChangeTierResponse) def change_tier( request: ChangeTierRequest, current_user: User = Depends(get_current_vendor_api), db: Session = Depends(get_db), ): """Change subscription tier (upgrade/downgrade).""" vendor_id = current_user.token_vendor_id result = billing_service.change_tier( db=db, vendor_id=vendor_id, new_tier_code=request.tier_code, is_annual=request.is_annual, ) db.commit() return ChangeTierResponse( message=result["message"], new_tier=result["new_tier"], effective_immediately=result["effective_immediately"], ) @router.post("/addons/purchase") def purchase_addon( request: AddOnPurchaseRequest, current_user: User = Depends(get_current_vendor_api), db: Session = Depends(get_db), ): """Purchase an add-on product.""" vendor_id = current_user.token_vendor_id vendor = billing_service.get_vendor(db, vendor_id) # Build URLs base_url = f"https://{settings.platform_domain}" success_url = f"{base_url}/vendor/{vendor.vendor_code}/billing?addon_success=true" cancel_url = f"{base_url}/vendor/{vendor.vendor_code}/billing?addon_cancelled=true" result = billing_service.purchase_addon( db=db, vendor_id=vendor_id, addon_code=request.addon_code, domain_name=request.domain_name, quantity=request.quantity, success_url=success_url, cancel_url=cancel_url, ) db.commit() return result @router.delete("/addons/{addon_id}", response_model=AddOnCancelResponse) def cancel_addon( addon_id: int, current_user: User = Depends(get_current_vendor_api), db: Session = Depends(get_db), ): """Cancel a purchased add-on.""" vendor_id = current_user.token_vendor_id result = billing_service.cancel_addon(db, vendor_id, addon_id) db.commit() return AddOnCancelResponse( message=result["message"], addon_code=result["addon_code"], )