185 lines
6.1 KiB
Python
185 lines
6.1 KiB
Python
"""Billing routes: Stripe checkout, webhook, subscription management.
|
|
|
|
Subscription records are kept in-memory until Step 12 migrates them to
|
|
PostgreSQL (subscriptions table). Stripe calls are gracefully stubbed when
|
|
STRIPE_SECRET_KEY is not configured, allowing local development without keys.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from typing import Any
|
|
|
|
import stripe as stripe_lib
|
|
from fastapi import APIRouter, Depends, Header, HTTPException, Request, status
|
|
from pydantic import BaseModel
|
|
|
|
from app.api.deps import get_current_user
|
|
from app.config.settings import settings
|
|
from app.schemas import BillingTier, UserProfile
|
|
|
|
router = APIRouter(prefix="/billing", tags=["billing"])
|
|
|
|
# In-memory subscriptions — replaced by PostgreSQL subscriptions table in Step 12
|
|
_subscriptions: dict[str, dict[str, Any]] = {} # user_id → subscription record
|
|
|
|
_TIER_PRICE_IDS: dict[str, str] = {
|
|
"pro": "price_pro_monthly", # replace with real Stripe price IDs
|
|
"power": "price_power_monthly",
|
|
"team": "price_team_monthly",
|
|
}
|
|
|
|
|
|
# ── Helpers ────────────────────────────────────────────────────────────
|
|
|
|
def _stripe_configured() -> bool:
|
|
return bool(settings.STRIPE_SECRET_KEY)
|
|
|
|
|
|
def _stripe() -> Any:
|
|
stripe_lib.api_key = settings.STRIPE_SECRET_KEY
|
|
return stripe_lib
|
|
|
|
|
|
# ── Request bodies ─────────────────────────────────────────────────────
|
|
|
|
class _CheckoutRequest(BaseModel):
|
|
tier: BillingTier
|
|
|
|
|
|
# ── Routes ─────────────────────────────────────────────────────────────
|
|
|
|
@router.post("/checkout", response_model=dict)
|
|
async def create_checkout(
|
|
body: _CheckoutRequest,
|
|
current_user: UserProfile = Depends(get_current_user),
|
|
) -> dict[str, str]:
|
|
"""Create a Stripe checkout session for a tier upgrade.
|
|
|
|
Returns a stub URL when ``STRIPE_SECRET_KEY`` is not configured.
|
|
"""
|
|
if body.tier == "free":
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail="Cannot create a checkout session for the free tier",
|
|
)
|
|
|
|
if _stripe_configured():
|
|
price_id = _TIER_PRICE_IDS.get(body.tier)
|
|
if not price_id:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail=f"Unknown tier: {body.tier}",
|
|
)
|
|
s = _stripe()
|
|
session = s.checkout.Session.create(
|
|
payment_method_types=["card"],
|
|
mode="subscription",
|
|
line_items=[{"price": price_id, "quantity": 1}],
|
|
success_url=(
|
|
"https://app.adiuva.app/billing/success"
|
|
"?session_id={CHECKOUT_SESSION_ID}"
|
|
),
|
|
cancel_url="https://app.adiuva.app/billing/cancel",
|
|
metadata={"user_id": current_user.id, "tier": body.tier},
|
|
)
|
|
return {"checkout_url": session.url}
|
|
|
|
return {"checkout_url": "https://stripe.com/stub-checkout"}
|
|
|
|
|
|
@router.post("/webhook", response_model=dict)
|
|
async def stripe_webhook(
|
|
request: Request,
|
|
stripe_signature: str = Header(default="", alias="Stripe-Signature"),
|
|
) -> dict[str, bool]:
|
|
"""Handle Stripe webhook events.
|
|
|
|
No JWT auth — authenticated via Stripe signature verification instead.
|
|
Returns 200 immediately when Stripe is not configured (local dev).
|
|
"""
|
|
payload = await request.body()
|
|
|
|
if not _stripe_configured():
|
|
return {"ok": True}
|
|
|
|
try:
|
|
s = _stripe()
|
|
event = s.Webhook.construct_event(
|
|
payload, stripe_signature, settings.STRIPE_WEBHOOK_SECRET
|
|
)
|
|
except stripe_lib.error.SignatureVerificationError:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail="Invalid Stripe signature",
|
|
)
|
|
|
|
event_type: str = event["type"]
|
|
data: dict[str, Any] = event["data"]["object"]
|
|
|
|
if event_type == "checkout.session.completed":
|
|
user_id = data.get("metadata", {}).get("user_id")
|
|
tier = data.get("metadata", {}).get("tier", "free")
|
|
sub_id = data.get("subscription")
|
|
if user_id:
|
|
_subscriptions[user_id] = {
|
|
"tier": tier,
|
|
"stripe_subscription_id": sub_id,
|
|
"status": "active",
|
|
"current_period_end": None,
|
|
}
|
|
|
|
elif event_type == "customer.subscription.updated":
|
|
# TODO(Step12): look up user_id from stripe_customer_id in DB, then update tier
|
|
pass
|
|
|
|
elif event_type == "customer.subscription.deleted":
|
|
# TODO(Step12): look up user_id from stripe_customer_id in DB, set tier to free
|
|
pass
|
|
|
|
elif event_type == "invoice.payment_failed":
|
|
# TODO(Step12): flag subscription as past_due, notify user
|
|
pass
|
|
|
|
return {"ok": True}
|
|
|
|
|
|
@router.get("/subscription", response_model=dict)
|
|
async def get_subscription(
|
|
current_user: UserProfile = Depends(get_current_user),
|
|
) -> dict[str, Any]:
|
|
"""Return the current subscription info for the authenticated user."""
|
|
sub = _subscriptions.get(current_user.id)
|
|
if sub is None:
|
|
return {
|
|
"tier": current_user.tier,
|
|
"status": "free",
|
|
"stripe_subscription_id": None,
|
|
"current_period_end": None,
|
|
}
|
|
return sub
|
|
|
|
|
|
@router.delete("/subscription", response_model=dict)
|
|
async def cancel_subscription(
|
|
current_user: UserProfile = Depends(get_current_user),
|
|
) -> dict[str, bool]:
|
|
"""Cancel the active subscription."""
|
|
sub = _subscriptions.get(current_user.id)
|
|
if sub is None or not sub.get("stripe_subscription_id"):
|
|
raise HTTPException(
|
|
status_code=status.HTTP_404_NOT_FOUND,
|
|
detail="No active subscription found",
|
|
)
|
|
|
|
if _stripe_configured():
|
|
s = _stripe()
|
|
s.Subscription.cancel(sub["stripe_subscription_id"])
|
|
|
|
_subscriptions[current_user.id] = {
|
|
**sub,
|
|
"tier": "free",
|
|
"status": "canceled",
|
|
}
|
|
|
|
return {"ok": True}
|