Add multi-plan subscriptions, quotas, and token usage APIs
CI / test (pull_request) Successful in 10s
Unit Tests / test (pull_request) Successful in 10s

Implements #16/#17/#36: Founders/Standard/Pro/Business/Backer catalog,
Backer email whitelist, prompt-window + token-period gates, and
tokens_in/out on conversation/prompt + subscription usage APIs.
This commit is contained in:
2026-07-31 06:21:54 -05:00
parent 67f16565e9
commit a6c45b0882
23 changed files with 1580 additions and 36 deletions
+80 -5
View File
@@ -6,13 +6,20 @@ from rest_framework import permissions, status
from rest_framework.response import Response
from rest_framework.views import APIView
from finance.models import Invoice, Payment
from finance.models import Invoice, Payment, SubscriptionPlan, UserSubscription
from finance.serializers import (
CheckoutSessionSerializer,
InvoiceSerializer,
PaymentSerializer,
PortalSessionSerializer,
SubscriptionPlanSerializer,
)
from finance.services.plans import (
needs_checkout,
plan_to_dict,
seed_subscription_plans,
)
from finance.services.quotas import get_usage_snapshot
from finance.services.stripe_service import (
StripeNotConfiguredError,
create_billing_portal_session,
@@ -32,11 +39,34 @@ class CreateCheckoutSessionView(APIView):
serializer.is_valid(raise_exception=True)
try:
session = create_checkout_session(
sub = request.user.subscription
except UserSubscription.DoesNotExist:
sub = None
if (
sub is not None
and sub.is_active
and sub.source == UserSubscription.Source.BACKER
):
return Response(
{
"detail": (
"This account has complimentary Backer access and "
"does not require payment."
),
"needs_checkout": False,
},
status=status.HTTP_400_BAD_REQUEST,
)
try:
session, plan = create_checkout_session(
user=request.user,
success_url=serializer.validated_data.get("success_url"),
cancel_url=serializer.validated_data.get("cancel_url"),
plan_slug=serializer.validated_data.get("plan_slug"),
)
except ValueError as exc:
return Response({"detail": str(exc)}, status=status.HTTP_400_BAD_REQUEST)
except StripeNotConfiguredError as exc:
return Response(
{"detail": str(exc)},
@@ -58,11 +88,11 @@ class CreateCheckoutSessionView(APIView):
"company": request.user.company,
"provider": Invoice.Provider.STRIPE,
"status": Invoice.Status.OPEN,
"currency": settings.SUBSCRIPTION_PRICE_CURRENCY,
"amount_due": settings.SUBSCRIPTION_PRICE_AMOUNT_CENTS,
"currency": plan.currency or settings.SUBSCRIPTION_PRICE_CURRENCY,
"amount_due": plan.price_cents,
"amount_paid": 0,
"stripe_customer_id": getattr(session, "customer", None) or "",
"description": settings.SUBSCRIPTION_PRODUCT_NAME,
"description": plan.name,
},
)
@@ -70,6 +100,7 @@ class CreateCheckoutSessionView(APIView):
{
"checkout_url": session.url,
"session_id": session.id,
"plan_slug": plan.slug,
},
status=status.HTTP_201_CREATED,
)
@@ -129,6 +160,50 @@ class PaymentListView(APIView):
return Response(PaymentSerializer(payments, many=True).data)
class PlanListView(APIView):
"""Public-facing plan catalog (only `is_public` rows by default)."""
permission_classes = (permissions.AllowAny,)
authentication_classes = ()
def get(self, request):
seed_subscription_plans(update_existing=False)
include_all = (
request.user
and request.user.is_authenticated
and request.user.is_staff
and request.query_params.get("all") == "1"
)
qs = SubscriptionPlan.objects.all()
if not include_all:
qs = qs.filter(is_public=True)
return Response(SubscriptionPlanSerializer(qs, many=True).data)
class SubscriptionMeView(APIView):
"""Current user's plan, checkout need, and usage snapshot (#16/#17/#36)."""
def get(self, request):
seed_subscription_plans(update_existing=False)
try:
sub = request.user.subscription
except UserSubscription.DoesNotExist:
sub = None
usage = get_usage_snapshot(request.user)
payload = {
"plan": plan_to_dict(sub.plan) if sub and sub.plan_id else None,
"status": sub.status if sub else UserSubscription.Status.NONE,
"source": sub.source if sub else UserSubscription.Source.NONE,
"needs_checkout": needs_checkout(request.user),
"stripe_subscription_id": (
sub.stripe_subscription_id if sub else ""
),
"usage": usage.to_dict(),
}
return Response(payload)
class StripeWebhookView(APIView):
"""Verify Stripe signatures and upsert local invoice/payment rows."""