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}