from fastapi import APIRouter, Depends, HTTPException, Request from fastapi.responses import JSONResponse from typing import List, Optional from datetime import datetime, date from ..auth import get_current_user from ..models import User from ..billing.models import ( Invoice, UserSubscription, PricingConfig, PaymentMethod, UsageAggregate, SubscriptionPlan, ) from ..billing.usage_tracker import UsageTracker from ..billing.invoice_generator import InvoiceGenerator from ..billing.stripe_client import StripeClient from pydantic import BaseModel router = APIRouter(prefix="/api/billing", tags=["billing"]) class UsageResponse(BaseModel): storage_gb_avg: float storage_gb_peak: float bandwidth_up_gb: float bandwidth_down_gb: float total_bandwidth_gb: float period: str class InvoiceResponse(BaseModel): id: int invoice_number: str period_start: date period_end: date subtotal: float tax: float total: float status: str due_date: Optional[date] paid_at: Optional[datetime] line_items: List[dict] class SubscriptionResponse(BaseModel): id: int billing_type: str plan_name: Optional[str] status: str current_period_start: Optional[datetime] current_period_end: Optional[datetime] @router.get("/usage/current") async def get_current_usage(current_user: User = Depends(get_current_user)): try: storage_bytes = await UsageTracker.get_current_storage(current_user) today = date.today() usage_today = await UsageAggregate.get_or_none(user=current_user, date=today) if usage_today: return { "storage_gb": round(storage_bytes / (1024**3), 4), "bandwidth_down_gb_today": round( usage_today.bandwidth_down_bytes / (1024**3), 4 ), "bandwidth_up_gb_today": round( usage_today.bandwidth_up_bytes / (1024**3), 4 ), "as_of": today.isoformat(), } return { "storage_gb": round(storage_bytes / (1024**3), 4), "bandwidth_down_gb_today": 0, "bandwidth_up_gb_today": 0, "as_of": today.isoformat(), } except Exception as e: raise HTTPException( status_code=500, detail=f"Failed to fetch usage data: {str(e)}" ) @router.get("/usage/monthly") async def get_monthly_usage( year: Optional[int] = None, month: Optional[int] = None, current_user: User = Depends(get_current_user), ) -> UsageResponse: try: if year is None or month is None: now = datetime.now() year = now.year month = now.month if not (1 <= month <= 12): raise HTTPException( status_code=400, detail="Month must be between 1 and 12" ) if not (2020 <= year <= 2100): raise HTTPException( status_code=400, detail="Year must be between 2020 and 2100" ) usage = await UsageTracker.get_monthly_usage(current_user, year, month) return UsageResponse(**usage, period=f"{year}-{month:02d}") except HTTPException: raise except Exception as e: raise HTTPException( status_code=500, detail=f"Failed to fetch monthly usage: {str(e)}" ) @router.get("/invoices") async def list_invoices( limit: int = 50, offset: int = 0, current_user: User = Depends(get_current_user) ) -> List[InvoiceResponse]: try: if limit < 1 or limit > 100: raise HTTPException( status_code=400, detail="Limit must be between 1 and 100" ) if offset < 0: raise HTTPException(status_code=400, detail="Offset must be non-negative") invoices = ( await Invoice.filter(user=current_user) .order_by("-created_at") .offset(offset) .limit(limit) .all() ) result = [] for invoice in invoices: line_items = await invoice.line_items.all() result.append( InvoiceResponse( id=invoice.id, invoice_number=invoice.invoice_number, period_start=invoice.period_start, period_end=invoice.period_end, subtotal=float(invoice.subtotal), tax=float(invoice.tax), total=float(invoice.total), status=invoice.status, due_date=invoice.due_date, paid_at=invoice.paid_at, line_items=[ { "description": item.description, "quantity": float(item.quantity), "unit_price": float(item.unit_price), "amount": float(item.amount), "type": item.item_type, } for item in line_items ], ) ) return result except HTTPException: raise except Exception as e: raise HTTPException( status_code=500, detail=f"Failed to fetch invoices: {str(e)}" ) @router.get("/invoices/{invoice_id}") async def get_invoice( invoice_id: int, current_user: User = Depends(get_current_user) ) -> InvoiceResponse: invoice = await Invoice.get_or_none(id=invoice_id, user=current_user) if not invoice: raise HTTPException(status_code=404, detail="Invoice not found") line_items = await invoice.line_items.all() return InvoiceResponse( id=invoice.id, invoice_number=invoice.invoice_number, period_start=invoice.period_start, period_end=invoice.period_end, subtotal=float(invoice.subtotal), tax=float(invoice.tax), total=float(invoice.total), status=invoice.status, due_date=invoice.due_date, paid_at=invoice.paid_at, line_items=[ { "description": item.description, "quantity": float(item.quantity), "unit_price": float(item.unit_price), "amount": float(item.amount), "type": item.item_type, } for item in line_items ], ) @router.get("/subscription") async def get_subscription( current_user: User = Depends(get_current_user), ) -> SubscriptionResponse: subscription = await UserSubscription.get_or_none(user=current_user) if not subscription: subscription = await UserSubscription.create( user=current_user, billing_type="pay_as_you_go", status="active" ) plan_name = None if subscription.plan: plan = await subscription.plan plan_name = plan.display_name return SubscriptionResponse( id=subscription.id, billing_type=subscription.billing_type, plan_name=plan_name, status=subscription.status, current_period_start=subscription.current_period_start, current_period_end=subscription.current_period_end, ) @router.post("/payment-methods/setup-intent") async def create_setup_intent(current_user: User = Depends(get_current_user)): try: from ..settings import settings if not settings.STRIPE_SECRET_KEY: raise HTTPException( status_code=503, detail="Payment processing not configured" ) subscription = await UserSubscription.get_or_none(user=current_user) if not subscription or not subscription.stripe_customer_id: customer_id = await StripeClient.create_customer( email=current_user.email, name=current_user.username, metadata={"user_id": str(current_user.id)}, ) if not subscription: subscription = await UserSubscription.create( user=current_user, billing_type="pay_as_you_go", stripe_customer_id=customer_id, status="active", ) else: subscription.stripe_customer_id = customer_id await subscription.save() import stripe StripeClient._ensure_api_key() setup_intent = stripe.SetupIntent.create( customer=subscription.stripe_customer_id, payment_method_types=["card"] ) return { "client_secret": setup_intent.client_secret, "customer_id": subscription.stripe_customer_id, } except HTTPException: raise except Exception as e: raise HTTPException( status_code=500, detail=f"Failed to create setup intent: {str(e)}" ) @router.get("/payment-methods") async def list_payment_methods(current_user: User = Depends(get_current_user)): methods = await PaymentMethod.filter(user=current_user).all() return [ { "id": m.id, "type": m.type, "last4": m.last4, "brand": m.brand, "exp_month": m.exp_month, "exp_year": m.exp_year, "is_default": m.is_default, } for m in methods ] @router.post("/webhooks/stripe") async def stripe_webhook(request: Request): import stripe from ..settings import settings from ..billing.models import BillingEvent try: payload = await request.body() sig_header = request.headers.get("stripe-signature") if not settings.STRIPE_WEBHOOK_SECRET: raise HTTPException(status_code=503, detail="Webhook secret not configured") try: event = stripe.Webhook.construct_event( payload, sig_header, settings.STRIPE_WEBHOOK_SECRET ) except ValueError as e: raise HTTPException(status_code=400, detail=f"Invalid payload: {str(e)}") except stripe.error.SignatureVerificationError as e: raise HTTPException(status_code=400, detail=f"Invalid signature: {str(e)}") event_id = event.get("id") existing_event = await BillingEvent.get_or_none(stripe_event_id=event_id) if existing_event: return JSONResponse(content={"status": "already_processed"}) await BillingEvent.create( event_type=event["type"], stripe_event_id=event_id, data=event["data"], processed=False, ) if event["type"] == "invoice.payment_succeeded": invoice_data = event["data"]["object"] mywebdav_invoice_id = invoice_data.get("metadata", {}).get( "mywebdav_invoice_id" ) if mywebdav_invoice_id: invoice = await Invoice.get_or_none(id=int(mywebdav_invoice_id)) if invoice: await InvoiceGenerator.mark_invoice_paid(invoice) elif event["type"] == "invoice.payment_failed": pass elif event["type"] == "payment_method.attached": payment_method = event["data"]["object"] customer_id = payment_method["customer"] subscription = await UserSubscription.get_or_none( stripe_customer_id=customer_id ) if subscription: await PaymentMethod.create( user=subscription.user, stripe_payment_method_id=payment_method["id"], type=payment_method["type"], last4=payment_method.get("card", {}).get("last4"), brand=payment_method.get("card", {}).get("brand"), exp_month=payment_method.get("card", {}).get("exp_month"), exp_year=payment_method.get("card", {}).get("exp_year"), is_default=True, ) await BillingEvent.filter(stripe_event_id=event_id).update(processed=True) return JSONResponse(content={"status": "success"}) except HTTPException: raise except Exception as e: raise HTTPException( status_code=500, detail=f"Webhook processing failed: {str(e)}" ) @router.get("/pricing") async def get_pricing(): configs = await PricingConfig.all() return { config.config_key: { "value": float(config.config_value), "description": config.description, "unit": config.unit, } for config in configs } @router.get("/plans") async def list_plans(): plans = await SubscriptionPlan.filter(is_active=True).all() return [ { "id": plan.id, "name": plan.name, "display_name": plan.display_name, "description": plan.description, "storage_gb": plan.storage_gb, "bandwidth_gb": plan.bandwidth_gb, "price_monthly": float(plan.price_monthly), "price_yearly": float(plan.price_yearly) if plan.price_yearly else None, } for plan in plans ] @router.get("/stripe-key") async def get_stripe_key(): from ..settings import settings if not settings.STRIPE_PUBLISHABLE_KEY: raise HTTPException(status_code=503, detail="Payment processing not configured") return {"publishable_key": settings.STRIPE_PUBLISHABLE_KEY}