|
| 1 | +""" |
| 2 | +Stripe payment API routes. |
| 3 | +
|
| 4 | +Handles checkout session creation and webhook processing for premium |
| 5 | +user upgrades. The checkout flow redirects users to Stripe's hosted |
| 6 | +payment page, and webhooks handle fulfillment after successful payment. |
| 7 | +""" |
| 8 | + |
| 9 | +import logging |
| 10 | +from uuid import UUID |
| 11 | + |
| 12 | +from fastapi import APIRouter, HTTPException, Request, status |
| 13 | +from pydantic import BaseModel |
| 14 | +from sqlmodel import Session, select |
| 15 | + |
| 16 | +from app.api.deps import CurrentUser, SessionDep |
| 17 | +from app.core.config import settings |
| 18 | +from app.premium_users.models import PremiumUser, utc_now |
| 19 | +from app.premium_users.stripe import get_stripe_client |
| 20 | + |
| 21 | +logger = logging.getLogger(__name__) |
| 22 | + |
| 23 | +router = APIRouter(prefix="/stripe", tags=["stripe"]) |
| 24 | + |
| 25 | + |
| 26 | +class CheckoutSessionResponse(BaseModel): |
| 27 | + """Response containing Stripe Checkout URL.""" |
| 28 | + |
| 29 | + checkout_url: str |
| 30 | + |
| 31 | + |
| 32 | +@router.post("/create-checkout-session", response_model=CheckoutSessionResponse) |
| 33 | +def create_checkout_session( |
| 34 | + session: SessionDep, |
| 35 | + current_user: CurrentUser, |
| 36 | +) -> CheckoutSessionResponse: |
| 37 | + """ |
| 38 | + Create a Stripe Checkout Session for premium upgrade. |
| 39 | +
|
| 40 | + Returns a URL to redirect the user to Stripe's hosted checkout page. |
| 41 | + After payment, Stripe redirects back to our success URL and sends |
| 42 | + a webhook to fulfill the order. |
| 43 | + """ |
| 44 | + logger.debug(f"Creating checkout session for user {current_user.id}") |
| 45 | + |
| 46 | + # Check if user is already premium |
| 47 | + existing = session.exec( |
| 48 | + select(PremiumUser).where(PremiumUser.user_id == current_user.id) |
| 49 | + ).first() |
| 50 | + |
| 51 | + if existing and existing.is_premium: |
| 52 | + logger.debug(f"User {current_user.id} is already premium") |
| 53 | + raise HTTPException( |
| 54 | + status_code=status.HTTP_400_BAD_REQUEST, |
| 55 | + detail="User is already premium", |
| 56 | + ) |
| 57 | + |
| 58 | + stripe = get_stripe_client() |
| 59 | + |
| 60 | + try: |
| 61 | + checkout_session = stripe.checkout.Session.create( |
| 62 | + payment_method_types=["card"], |
| 63 | + line_items=[ |
| 64 | + { |
| 65 | + "price_data": { |
| 66 | + "currency": "usd", |
| 67 | + "product_data": { |
| 68 | + "name": "Premium Upgrade", |
| 69 | + "description": "Unlock unlimited items and premium badge", |
| 70 | + }, |
| 71 | + "unit_amount": settings.STRIPE_PREMIUM_PRICE_CENTS, |
| 72 | + }, |
| 73 | + "quantity": 1, |
| 74 | + } |
| 75 | + ], |
| 76 | + mode="payment", |
| 77 | + success_url=f"{settings.FRONTEND_HOST}/payment-success?session_id={{CHECKOUT_SESSION_ID}}", |
| 78 | + cancel_url=f"{settings.FRONTEND_HOST}/settings", |
| 79 | + metadata={ |
| 80 | + "user_id": str(current_user.id), |
| 81 | + }, |
| 82 | + ) |
| 83 | + |
| 84 | + logger.debug(f"Created checkout session {checkout_session.id} for user {current_user.id}") |
| 85 | + |
| 86 | + return CheckoutSessionResponse(checkout_url=checkout_session.url) |
| 87 | + |
| 88 | + except stripe.error.StripeError as e: |
| 89 | + logger.error(f"Stripe error creating checkout session: {e}") |
| 90 | + raise HTTPException( |
| 91 | + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, |
| 92 | + detail="Failed to create checkout session", |
| 93 | + ) |
| 94 | + |
| 95 | + |
| 96 | +@router.post("/webhook") |
| 97 | +async def stripe_webhook(request: Request, session: SessionDep) -> dict: |
| 98 | + """ |
| 99 | + Handle Stripe webhook events. |
| 100 | +
|
| 101 | + Stripe sends events here when payment status changes. We use this |
| 102 | + to fulfill the premium upgrade after successful payment. |
| 103 | +
|
| 104 | + Note: This endpoint is PUBLIC (no auth) - Stripe calls it directly. |
| 105 | + Security comes from signature verification. |
| 106 | + """ |
| 107 | + payload = await request.body() |
| 108 | + sig_header = request.headers.get("stripe-signature") |
| 109 | + |
| 110 | + stripe = get_stripe_client() |
| 111 | + |
| 112 | + try: |
| 113 | + event = stripe.webhooks.Webhook.construct_event( |
| 114 | + payload, sig_header, settings.STRIPE_WEBHOOK_SECRET |
| 115 | + ) |
| 116 | + except ValueError as e: |
| 117 | + logger.error(f"Invalid webhook payload: {e}") |
| 118 | + raise HTTPException(status_code=400, detail="Invalid payload") |
| 119 | + except stripe.error.SignatureVerificationError as e: |
| 120 | + logger.error(f"Invalid webhook signature: {e}") |
| 121 | + raise HTTPException(status_code=400, detail="Invalid signature") |
| 122 | + |
| 123 | + logger.debug(f"Received Stripe webhook: {event['type']}") |
| 124 | + |
| 125 | + # Handle checkout session completed |
| 126 | + if event["type"] == "checkout.session.completed": |
| 127 | + checkout_session = event["data"]["object"] |
| 128 | + _handle_checkout_completed(session, checkout_session) |
| 129 | + |
| 130 | + return {"status": "success"} |
| 131 | + |
| 132 | + |
| 133 | +def _handle_checkout_completed( |
| 134 | + session: Session, |
| 135 | + checkout_session: dict, |
| 136 | +) -> None: |
| 137 | + """ |
| 138 | + Process successful checkout - grant premium status to user. |
| 139 | +
|
| 140 | + Creates or updates PremiumUser record with payment details. |
| 141 | + Idempotent: safe to call multiple times for same checkout session. |
| 142 | + """ |
| 143 | + user_id_str = checkout_session.get("metadata", {}).get("user_id") |
| 144 | + if not user_id_str: |
| 145 | + logger.error("No user_id in checkout session metadata") |
| 146 | + return |
| 147 | + |
| 148 | + user_id = UUID(user_id_str) |
| 149 | + stripe_customer_id = checkout_session.get("customer") |
| 150 | + payment_intent_id = checkout_session.get("payment_intent") |
| 151 | + |
| 152 | + logger.debug(f"Processing premium upgrade for user {user_id}") |
| 153 | + |
| 154 | + # Check for existing record (idempotency) |
| 155 | + existing = session.exec( |
| 156 | + select(PremiumUser).where(PremiumUser.user_id == user_id) |
| 157 | + ).first() |
| 158 | + |
| 159 | + if existing: |
| 160 | + if existing.is_premium: |
| 161 | + logger.debug(f"User {user_id} already premium, skipping") |
| 162 | + return |
| 163 | + |
| 164 | + # Update existing record |
| 165 | + existing.is_premium = True |
| 166 | + existing.stripe_customer_id = stripe_customer_id |
| 167 | + existing.payment_intent_id = payment_intent_id |
| 168 | + existing.paid_at = utc_now() |
| 169 | + existing.updated_at = utc_now() |
| 170 | + session.add(existing) |
| 171 | + else: |
| 172 | + # Create new record |
| 173 | + premium_user = PremiumUser( |
| 174 | + user_id=user_id, |
| 175 | + stripe_customer_id=stripe_customer_id, |
| 176 | + is_premium=True, |
| 177 | + payment_intent_id=payment_intent_id, |
| 178 | + paid_at=utc_now(), |
| 179 | + ) |
| 180 | + session.add(premium_user) |
| 181 | + |
| 182 | + session.commit() |
| 183 | + logger.debug(f"User {user_id} upgraded to premium") |
0 commit comments