## Summary Implements [#16](#16), [#17](#17), and [#36](#36) in one backend PR. - **#36 Multi-plan catalog**: Founders ($10, public), Standard ($15), Pro ($40), Business ($99), Backer ($0). Future tiers seeded but hidden/`is_selectable=false`. Backer email whitelist auto-assigns Founders-level access with no checkout. - **#36 Feature + prompt gating**: plan feature flags (text vs image); rolling **6h** prompt windows (100 / 200 / 300 / 300 / 300). Enforced in both chat consumers when `ENFORCE_SUBSCRIPTION_GATES=true`. - **#17 Token-period quotas**: optional `monthly_token_quota` on plans + per-user override; calendar-month aggregation from `PromptMetric`; warn/block when reported token totals exceed cap. Null provider usage never fabricated as 0; tracked via `turns_missing_token_usage`. - **#16 Token API exposure**: `tokens_in` / `tokens_out` on conversation + prompt serializers (null when unknown). `GET /api/finance/subscription/` returns plan + usage snapshot for the FE. - Checkout defaults to **Founders**; Stripe paid webhooks assign Founders. Registration/OAuth redeem Backer whitelist and return `needs_checkout`. Companion FE PR: `chat_web_app` branch `feature/plans-quotas-token-usage`. ## Test plan - [ ] `manage.py migrate` seeds five plans; admin can add Backer emails - [ ] Public `GET /api/finance/plans/` returns only Founders - [ ] Register with Backer email → active Backer, `needs_checkout=false`, checkout rejected - [ ] Founders checkout + paid webhook → active Founders subscription - [ ] Chat turn blocked without subscription / when prompt window exceeded / when token period exceeded - [ ] Standard plan denies image feature; Pro/Founders/Backer allow - [ ] Conversation/prompt API returns `null` tokens when unreported, sums when present - [ ] `finance.tests.test_plans_quotas` + existing finance/checkout tests passReviewed-on: #37
128 lines
4.1 KiB
Python
128 lines
4.1 KiB
Python
"""Stripe Checkout and Billing Portal session helpers."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from typing import Any
|
|
|
|
import stripe
|
|
from django.conf import settings
|
|
|
|
from finance.models import Invoice, SubscriptionPlan
|
|
from finance.services.plans import get_plan, seed_subscription_plans
|
|
|
|
|
|
class StripeNotConfiguredError(RuntimeError):
|
|
"""Raised when Stripe secret key is missing."""
|
|
|
|
|
|
def configure_stripe() -> str:
|
|
secret = settings.STRIPE_SECRET_KEY
|
|
if not secret:
|
|
raise StripeNotConfiguredError(
|
|
"STRIPE_SECRET_KEY is not configured. Set it in the environment."
|
|
)
|
|
stripe.api_key = secret
|
|
return secret
|
|
|
|
|
|
def resolve_checkout_plan(plan_slug: str | None = None) -> SubscriptionPlan:
|
|
"""Return the plan for Checkout (defaults to public Founders)."""
|
|
seed_subscription_plans(update_existing=False)
|
|
slug = (plan_slug or SubscriptionPlan.Slug.FOUNDERS).strip().lower()
|
|
plan = get_plan(slug)
|
|
if plan is None:
|
|
raise ValueError(f"Unknown plan: {slug}")
|
|
if not plan.is_selectable:
|
|
raise ValueError(f"Plan '{plan.slug}' is not available for checkout.")
|
|
return plan
|
|
|
|
|
|
def subscription_line_items(plan: SubscriptionPlan) -> list[dict[str, Any]]:
|
|
"""Build Checkout line_items from a SubscriptionPlan (or legacy settings)."""
|
|
price_id = (plan.stripe_price_id or "").strip() or (
|
|
settings.STRIPE_PRICE_ID if plan.slug == SubscriptionPlan.Slug.FOUNDERS else ""
|
|
)
|
|
if price_id:
|
|
return [{"price": price_id, "quantity": 1}]
|
|
|
|
# Founders without a plan stripe_price_id may still use legacy env amount.
|
|
unit_amount = plan.price_cents
|
|
product_name = plan.name
|
|
currency = plan.currency or settings.SUBSCRIPTION_PRICE_CURRENCY
|
|
interval = plan.interval or settings.SUBSCRIPTION_PRICE_INTERVAL
|
|
if plan.slug == SubscriptionPlan.Slug.FOUNDERS and not plan.stripe_price_id:
|
|
# Keep env overrides working for the live Founders price.
|
|
unit_amount = int(
|
|
getattr(settings, "SUBSCRIPTION_PRICE_AMOUNT_CENTS", None) or unit_amount
|
|
)
|
|
product_name = (
|
|
getattr(settings, "SUBSCRIPTION_PRODUCT_NAME", None) or product_name
|
|
)
|
|
|
|
return [
|
|
{
|
|
"price_data": {
|
|
"currency": currency,
|
|
"unit_amount": unit_amount,
|
|
"recurring": {"interval": interval},
|
|
"product_data": {"name": product_name},
|
|
},
|
|
"quantity": 1,
|
|
}
|
|
]
|
|
|
|
|
|
def create_checkout_session(
|
|
*,
|
|
user,
|
|
success_url: str | None = None,
|
|
cancel_url: str | None = None,
|
|
plan_slug: str | None = None,
|
|
):
|
|
"""Create a Stripe Checkout Session for a selectable subscription plan."""
|
|
configure_stripe()
|
|
plan = resolve_checkout_plan(plan_slug)
|
|
|
|
metadata = {
|
|
"user_id": str(user.pk),
|
|
"company_id": str(user.company_id) if user.company_id else "",
|
|
"plan_slug": plan.slug,
|
|
}
|
|
customer_email = getattr(user, "email", None) or None
|
|
|
|
session = stripe.checkout.Session.create(
|
|
mode="subscription",
|
|
line_items=subscription_line_items(plan),
|
|
success_url=success_url or settings.STRIPE_CHECKOUT_SUCCESS_URL,
|
|
cancel_url=cancel_url or settings.STRIPE_CHECKOUT_CANCEL_URL,
|
|
customer_email=customer_email,
|
|
client_reference_id=str(user.pk),
|
|
metadata=metadata,
|
|
subscription_data={"metadata": metadata},
|
|
)
|
|
return session, plan
|
|
|
|
|
|
def resolve_stripe_customer_id(*, user) -> str | None:
|
|
"""Return the most recent Stripe customer id stored on the user's invoices."""
|
|
return (
|
|
Invoice.objects.filter(user=user)
|
|
.exclude(stripe_customer_id="")
|
|
.order_by("-created")
|
|
.values_list("stripe_customer_id", flat=True)
|
|
.first()
|
|
)
|
|
|
|
|
|
def create_billing_portal_session(
|
|
*,
|
|
customer_id: str,
|
|
return_url: str | None = None,
|
|
):
|
|
"""Create a Stripe Customer Portal session for plan/payment/cancel management."""
|
|
configure_stripe()
|
|
return stripe.billing_portal.Session.create(
|
|
customer=customer_id,
|
|
return_url=return_url or settings.STRIPE_PORTAL_RETURN_URL,
|
|
)
|