## 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
This commit was merged in pull request #37.
This commit is contained in:
+80
-5
@@ -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."""
|
||||
|
||||
|
||||
Reference in New Issue
Block a user