Spaces:
Running
Running
| """Author RAG β Public Billing API. | |
| Routes: | |
| GET /api/billing/plans β Public: active pricing plans (for Next.js website) | |
| POST /api/billing/checkout β Public: create Stripe Checkout URL | |
| POST /api/billing/portal β Auth: self-service billing portal link | |
| POST /api/billing/webhook β Public (Stripe-signed): handle Stripe events | |
| GET /api/billing/status β Auth: current subscription status | |
| """ | |
| import json | |
| from datetime import datetime, timezone | |
| import structlog | |
| from fastapi import APIRouter, Depends, Header, HTTPException, Query, Request | |
| from pydantic import BaseModel, Field | |
| from sqlalchemy import select | |
| from sqlalchemy.ext.asyncio import AsyncSession | |
| from app.config import get_settings | |
| from app.dependencies import get_current_author, get_db, get_redis | |
| from app.models.pricing_plan import PricingPlan | |
| from app.models.stripe_event import StripeEvent | |
| from app.services import billing_service as bs | |
| logger = structlog.get_logger(__name__) | |
| cfg = get_settings() | |
| router = APIRouter(prefix="/billing", tags=["billing"]) | |
| # ββ Schemas βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| class CheckoutRequest(BaseModel): | |
| plan_id: str = Field(..., description="Our internal plan ID, e.g. 'starter'") | |
| billing: str = Field("monthly", pattern="^(monthly|annual)$") | |
| coupon_code: str | None = Field(None, description="Referral/affiliate coupon code") | |
| class PortalRequest(BaseModel): | |
| return_url: str = Field(..., description="URL to redirect back to after portal visit") | |
| # ββ Public: Plan Listing ββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| async def list_plans( | |
| billing: str = Query("monthly", pattern="^(monthly|annual)$"), | |
| db: AsyncSession = Depends(get_db), | |
| ): | |
| """Return all active pricing plans β consumed by the Next.js website's Pricing section. | |
| The website fetches this on page load so prices are always in sync | |
| with whatever the SuperAdmin has configured. | |
| """ | |
| result = await db.execute( | |
| select(PricingPlan) | |
| .where(PricingPlan.is_active == True) # noqa: E712 | |
| .order_by(PricingPlan.sort_order) | |
| ) | |
| plans = result.scalars().all() | |
| return {"plans": [p.to_public_dict(billing=billing) for p in plans]} | |
| # ββ Public: Create Checkout Session ββββββββββββββββββββββββββββββββββββββββββ | |
| async def create_checkout( | |
| body: CheckoutRequest, | |
| db: AsyncSession = Depends(get_db), | |
| ): | |
| """Create a Stripe Checkout session for the requested plan. | |
| Returns the Stripe Checkout URL β the client redirects the browser there. | |
| Card is always required upfront (no free access). | |
| """ | |
| # Fetch the plan and its Stripe Price ID | |
| result = await db.execute( | |
| select(PricingPlan).where( | |
| PricingPlan.id == body.plan_id, | |
| PricingPlan.is_active == True, # noqa: E712 | |
| ) | |
| ) | |
| plan = result.scalar_one_or_none() | |
| if not plan: | |
| raise HTTPException(404, f"Plan '{body.plan_id}' not found or inactive") | |
| stripe_price_id = ( | |
| plan.stripe_price_id_monthly | |
| if body.billing == "monthly" | |
| else plan.stripe_price_id_annual | |
| ) | |
| if not stripe_price_id: | |
| raise HTTPException( | |
| 503, | |
| f"Plan '{body.plan_id}' is not yet configured with a Stripe Price ID. " | |
| "Contact support or try again later.", | |
| ) | |
| # Build success/cancel URLs | |
| success_url = ( | |
| f"{cfg.FRONTEND_URL}/success?session_id={{CHECKOUT_SESSION_ID}}" | |
| ) | |
| cancel_url = f"{cfg.FRONTEND_URL}/pricing" | |
| try: | |
| checkout_url = await bs.create_checkout_session( | |
| plan_id=body.plan_id, | |
| stripe_price_id=stripe_price_id, | |
| success_url=success_url, | |
| cancel_url=cancel_url, | |
| coupon_code=body.coupon_code, | |
| ) | |
| except Exception as e: | |
| logger.error("Checkout session creation failed", error=str(e)) | |
| raise HTTPException(502, "Failed to create checkout session. Please try again.") | |
| return {"checkout_url": checkout_url} | |
| # ββ Auth: Customer Portal βββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| async def create_portal( | |
| body: PortalRequest, | |
| author=Depends(get_current_author), | |
| ): | |
| """Create a Stripe Customer Portal session for the authenticated author. | |
| Authors can: update card, view invoices, cancel, upgrade/downgrade. | |
| """ | |
| if not author.stripe_customer_id: | |
| raise HTTPException(400, "No billing account found. Please contact support.") | |
| try: | |
| portal_url = await bs.create_portal_session( | |
| stripe_customer_id=author.stripe_customer_id, | |
| return_url=body.return_url or f"{cfg.FRONTEND_URL}/dashboard", | |
| ) | |
| except Exception as e: | |
| logger.error("Portal session creation failed", author_id=author.id, error=str(e)) | |
| raise HTTPException(502, "Failed to open billing portal. Please try again.") | |
| return {"portal_url": portal_url} | |
| # ββ Auth: Subscription Status βββββββββββββββββββββββββββββββββββββββββββββββββ | |
| async def billing_status(author=Depends(get_current_author)): | |
| """Return the current author's subscription status.""" | |
| return { | |
| "plan_id": author.stripe_plan_id, | |
| "stripe_customer_id": author.stripe_customer_id, | |
| "stripe_subscription_id": author.stripe_subscription_id, | |
| "payment_grace_until": author.payment_grace_until, | |
| "in_grace_period": ( | |
| author.payment_grace_until is not None | |
| and author.payment_grace_until > datetime.now(timezone.utc) | |
| ), | |
| } | |
| # ββ Public: Stripe Webhook ββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| async def stripe_webhook( | |
| request: Request, | |
| stripe_signature: str = Header(..., alias="stripe-signature"), | |
| db: AsyncSession = Depends(get_db), | |
| ): | |
| """Handle Stripe webhook events. | |
| Security: | |
| - No JWT auth β Stripe signs the payload with STRIPE_WEBHOOK_SECRET | |
| - We verify the signature before any processing | |
| - Each event is logged to StripeEvent with its Stripe ID as PK | |
| so duplicate deliveries are safely ignored (idempotent) | |
| Supported events: | |
| checkout.session.completed β activate account (handled by auth router) | |
| customer.subscription.updated β plan change | |
| customer.subscription.deleted β cancel access | |
| invoice.payment_succeeded β reset token counter | |
| invoice.payment_failed β set grace period + send dunning email | |
| """ | |
| payload = await request.body() | |
| # 1. Verify Stripe signature | |
| try: | |
| event = bs.verify_webhook_signature(payload, stripe_signature) | |
| except Exception: | |
| raise HTTPException(400, "Invalid webhook signature") | |
| event_id = event["id"] | |
| event_type = event["type"] | |
| event_obj = event.get("data", {}).get("object", {}) | |
| # 2. Idempotency check β skip if already processed | |
| existing = await db.get(StripeEvent, event_id) | |
| if existing and existing.status == "processed": | |
| logger.info("Stripe event already processed, skipping", event_id=event_id) | |
| return {"received": True, "skipped": True} | |
| # 3. Log the event (upsert β handles retries) | |
| now = datetime.now(timezone.utc) | |
| if not existing: | |
| db.add(StripeEvent( | |
| id=event_id, | |
| event_type=event_type, | |
| related_object_id=event_obj.get("id"), | |
| related_object_type=event_obj.get("object"), | |
| received_at=now, | |
| status="pending", | |
| raw_payload=json.dumps(dict(event)), | |
| )) | |
| await db.flush() | |
| # 4. Route to handler | |
| billing_svc = bs.BillingService(db) | |
| error: str | None = None | |
| try: | |
| if event_type == "invoice.payment_succeeded": | |
| sub_id = event_obj.get("subscription") | |
| if sub_id: | |
| await billing_svc.handle_payment_succeeded(sub_id) | |
| elif event_type == "invoice.payment_failed": | |
| sub_id = event_obj.get("subscription") | |
| if sub_id: | |
| await billing_svc.handle_payment_failed(sub_id, grace_days=7) | |
| # TODO Phase 1 Week 4: trigger dunning email via email service | |
| elif event_type == "customer.subscription.deleted": | |
| sub_id = event_obj.get("id") | |
| if sub_id: | |
| await billing_svc.handle_subscription_cancelled(sub_id) | |
| # TODO Phase 1 Week 4: trigger cancellation email | |
| elif event_type == "customer.subscription.updated": | |
| sub_id = event_obj.get("id") | |
| # Extract the new plan_id from subscription metadata | |
| metadata = event_obj.get("metadata", {}) | |
| new_plan_id = metadata.get("plan_id") | |
| if sub_id and new_plan_id: | |
| await billing_svc.handle_plan_changed(sub_id, new_plan_id) | |
| # checkout.session.completed is handled by POST /api/auth/activate | |
| # (the Next.js success page POSTs the session_id there directly) | |
| else: | |
| logger.debug("Unhandled Stripe event type", event_type=event_type) | |
| except Exception as exc: | |
| error = str(exc)[:500] | |
| logger.error("Stripe webhook handler error", event_id=event_id, event_type=event_type, error=error) | |
| # 5. Update event log status | |
| event_record = await db.get(StripeEvent, event_id) | |
| if event_record: | |
| event_record.processed_at = datetime.now(timezone.utc) | |
| event_record.status = "failed" if error else "processed" | |
| event_record.processing_error = error | |
| await db.commit() | |
| # Always return 200 to Stripe β errors are logged, not returned | |
| # (returning 4xx/5xx causes Stripe to retry indefinitely) | |
| return {"received": True} | |