Files
lucidia-platform/api/routers/billing.py
Alexa Louise 9de11b5658 Add Stripe billing integration
- Add billing router with checkout, portal, and subscription endpoints
- Add webhook handler for Stripe events
- Create pricing page with plan selection
- Add frontend Stripe utilities
- Configure 7-day free trial for paid plans

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-30 08:56:58 -06:00

333 lines
10 KiB
Python

"""Stripe billing integration for Lucidia Platform."""
import os
from datetime import datetime
from typing import Optional
import stripe
from fastapi import APIRouter, HTTPException, Request, Header
from pydantic import BaseModel
router = APIRouter(prefix="/billing", tags=["billing"])
# Initialize Stripe
stripe.api_key = os.getenv("STRIPE_SECRET_KEY", "")
STRIPE_WEBHOOK_SECRET = os.getenv("STRIPE_WEBHOOK_SECRET", "")
# Price IDs (set these in Stripe Dashboard)
PRICE_IDS = {
"student_monthly": os.getenv("STRIPE_PRICE_STUDENT_MONTHLY", "price_student_monthly"),
"student_yearly": os.getenv("STRIPE_PRICE_STUDENT_YEARLY", "price_student_yearly"),
"family_monthly": os.getenv("STRIPE_PRICE_FAMILY_MONTHLY", "price_family_monthly"),
"family_yearly": os.getenv("STRIPE_PRICE_FAMILY_YEARLY", "price_family_yearly"),
}
class CreateCheckoutRequest(BaseModel):
"""Request to create a checkout session."""
plan: str # student_monthly, student_yearly, family_monthly, family_yearly
user_id: str
success_url: str = "https://lucidia.ai/dashboard?success=true"
cancel_url: str = "https://lucidia.ai/pricing?canceled=true"
class CreatePortalRequest(BaseModel):
"""Request to create a customer portal session."""
customer_id: str
return_url: str = "https://lucidia.ai/dashboard"
class SubscriptionStatus(BaseModel):
"""Subscription status response."""
user_id: str
status: str # active, canceled, past_due, trialing, none
plan: Optional[str] = None
current_period_end: Optional[datetime] = None
cancel_at_period_end: bool = False
# In-memory subscription storage (replace with database)
user_subscriptions: dict = {}
user_customers: dict = {} # user_id -> stripe_customer_id
@router.post("/create-checkout-session")
async def create_checkout_session(request: CreateCheckoutRequest):
"""
Create a Stripe Checkout session for subscription.
Returns a URL to redirect the user to Stripe Checkout.
"""
if not stripe.api_key:
raise HTTPException(500, "Stripe not configured")
price_id = PRICE_IDS.get(request.plan)
if not price_id:
raise HTTPException(400, f"Invalid plan: {request.plan}")
try:
# Get or create customer
customer_id = user_customers.get(request.user_id)
if not customer_id:
customer = stripe.Customer.create(
metadata={"user_id": request.user_id}
)
customer_id = customer.id
user_customers[request.user_id] = customer_id
# Create checkout session
session = stripe.checkout.Session.create(
customer=customer_id,
payment_method_types=["card"],
line_items=[
{
"price": price_id,
"quantity": 1,
}
],
mode="subscription",
success_url=request.success_url,
cancel_url=request.cancel_url,
metadata={
"user_id": request.user_id,
"plan": request.plan,
},
subscription_data={
"trial_period_days": 7, # 7-day free trial
"metadata": {
"user_id": request.user_id,
"plan": request.plan,
}
},
allow_promotion_codes=True,
)
return {
"checkout_url": session.url,
"session_id": session.id,
}
except stripe.error.StripeError as e:
raise HTTPException(400, str(e))
@router.post("/create-portal-session")
async def create_portal_session(request: CreatePortalRequest):
"""
Create a Stripe Customer Portal session.
Allows users to manage their subscription, update payment methods, etc.
"""
if not stripe.api_key:
raise HTTPException(500, "Stripe not configured")
try:
session = stripe.billing_portal.Session.create(
customer=request.customer_id,
return_url=request.return_url,
)
return {"portal_url": session.url}
except stripe.error.StripeError as e:
raise HTTPException(400, str(e))
@router.get("/subscription/{user_id}", response_model=SubscriptionStatus)
async def get_subscription_status(user_id: str):
"""Get subscription status for a user."""
# Check in-memory storage
if user_id in user_subscriptions:
sub = user_subscriptions[user_id]
return SubscriptionStatus(
user_id=user_id,
status=sub.get("status", "none"),
plan=sub.get("plan"),
current_period_end=sub.get("current_period_end"),
cancel_at_period_end=sub.get("cancel_at_period_end", False),
)
# Check Stripe directly if we have a customer
customer_id = user_customers.get(user_id)
if customer_id and stripe.api_key:
try:
subscriptions = stripe.Subscription.list(
customer=customer_id,
status="all",
limit=1,
)
if subscriptions.data:
sub = subscriptions.data[0]
return SubscriptionStatus(
user_id=user_id,
status=sub.status,
plan=sub.metadata.get("plan"),
current_period_end=datetime.fromtimestamp(sub.current_period_end),
cancel_at_period_end=sub.cancel_at_period_end,
)
except stripe.error.StripeError:
pass
return SubscriptionStatus(
user_id=user_id,
status="none",
)
@router.post("/webhook")
async def stripe_webhook(
request: Request,
stripe_signature: str = Header(None, alias="Stripe-Signature"),
):
"""
Handle Stripe webhooks.
Events:
- checkout.session.completed: Subscription started
- customer.subscription.updated: Subscription changed
- customer.subscription.deleted: Subscription canceled
- invoice.payment_failed: Payment failed
"""
if not STRIPE_WEBHOOK_SECRET:
raise HTTPException(500, "Webhook secret not configured")
payload = await request.body()
try:
event = stripe.Webhook.construct_event(
payload, stripe_signature, STRIPE_WEBHOOK_SECRET
)
except ValueError:
raise HTTPException(400, "Invalid payload")
except stripe.error.SignatureVerificationError:
raise HTTPException(400, "Invalid signature")
# Handle events
if event.type == "checkout.session.completed":
session = event.data.object
user_id = session.metadata.get("user_id")
plan = session.metadata.get("plan")
if user_id:
user_subscriptions[user_id] = {
"status": "active",
"plan": plan,
"customer_id": session.customer,
"subscription_id": session.subscription,
}
user_customers[user_id] = session.customer
print(f"Subscription started for user {user_id}: {plan}")
elif event.type == "customer.subscription.updated":
subscription = event.data.object
user_id = subscription.metadata.get("user_id")
if user_id and user_id in user_subscriptions:
user_subscriptions[user_id].update({
"status": subscription.status,
"cancel_at_period_end": subscription.cancel_at_period_end,
"current_period_end": datetime.fromtimestamp(
subscription.current_period_end
),
})
print(f"Subscription updated for user {user_id}: {subscription.status}")
elif event.type == "customer.subscription.deleted":
subscription = event.data.object
user_id = subscription.metadata.get("user_id")
if user_id and user_id in user_subscriptions:
user_subscriptions[user_id]["status"] = "canceled"
print(f"Subscription canceled for user {user_id}")
elif event.type == "invoice.payment_failed":
invoice = event.data.object
customer_id = invoice.customer
# Find user by customer ID
for uid, cid in user_customers.items():
if cid == customer_id:
user_subscriptions[uid]["status"] = "past_due"
print(f"Payment failed for user {uid}")
break
return {"status": "success"}
# ============================================================================
# Pricing Info Endpoint (public)
# ============================================================================
@router.get("/pricing")
async def get_pricing():
"""Get current pricing information."""
return {
"plans": [
{
"id": "free",
"name": "Free",
"price": 0,
"interval": None,
"features": [
"10 problems/month",
"Basic explanations",
"Text input only",
],
},
{
"id": "student_monthly",
"name": "Student",
"price": 9.99,
"interval": "month",
"features": [
"Unlimited problems",
"Visual explanations",
"Photo & voice upload",
"Persistent memory",
"All subjects",
],
"popular": True,
},
{
"id": "student_yearly",
"name": "Student (Annual)",
"price": 99.99,
"interval": "year",
"savings": "Save $20",
"features": [
"Everything in Student Monthly",
"2 months free",
],
},
{
"id": "family_monthly",
"name": "Family",
"price": 19.99,
"interval": "month",
"features": [
"Up to 5 users",
"Everything in Student",
"Parent dashboard",
"Progress tracking",
"Priority support",
],
},
{
"id": "family_yearly",
"name": "Family (Annual)",
"price": 199.99,
"interval": "year",
"savings": "Save $40",
"features": [
"Everything in Family Monthly",
"2 months free",
],
},
],
"trial_days": 7,
"currency": "usd",
}