Multi-plan subscriptions, quotas, and token usage APIs (#16 #17 #36) (#37)
Deploy Beta / unit-tests (push) Successful in 10s
Unit Tests / test (push) Successful in 9s
Deploy Beta / docker (push) Successful in 18s
Deploy Beta / deploy-beta (push) Successful in 46s

## 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:
2026-07-31 04:24:20 -07:00
parent 67f16565e9
commit 841c0962d9
23 changed files with 1577 additions and 36 deletions
+258
View File
@@ -0,0 +1,258 @@
"""Subscription plan catalog helpers, seeding, and user assignment."""
from __future__ import annotations
import logging
from typing import Any
from django.db import transaction
from django.utils import timezone
from finance.models import BackerEmail, SubscriptionPlan, UserSubscription
logger = logging.getLogger(__name__)
# Seed catalog for #36. Standard/Pro/Business stay hidden until explicitly enabled.
PLAN_SEED: list[dict[str, Any]] = [
{
"slug": SubscriptionPlan.Slug.FOUNDERS,
"name": "Founders",
"description": (
"Unlimited product access for early supporters: text plus all future "
"capabilities as they ship. $10/mo."
),
"price_cents": 1000,
"is_public": True,
"is_selectable": True,
"allows_text_generation": True,
"allows_image_generation": True,
"allows_all_future_features": True,
"prompt_quota_per_window": 300,
"prompt_window_hours": 6,
"monthly_token_quota": None,
"sort_order": 10,
},
{
"slug": SubscriptionPlan.Slug.STANDARD,
"name": "Standard",
"description": (
"Secure conversational chat and coding assistance for developers "
"and privacy-conscious individuals."
),
"price_cents": 1500,
"is_public": False,
"is_selectable": False,
"allows_text_generation": True,
"allows_image_generation": False,
"allows_all_future_features": False,
"prompt_quota_per_window": 100,
"prompt_window_hours": 6,
"monthly_token_quota": 1_000_000,
"sort_order": 20,
},
{
"slug": SubscriptionPlan.Slug.PRO,
"name": "Pro / Creator",
"description": (
"Higher message caps and multi-modal workflows for heavy users, "
"including image generation when available."
),
"price_cents": 4000,
"is_public": False,
"is_selectable": False,
"allows_text_generation": True,
"allows_image_generation": True,
"allows_all_future_features": False,
"prompt_quota_per_window": 200,
"prompt_window_hours": 6,
"monthly_token_quota": 3_000_000,
"sort_order": 30,
},
{
"slug": SubscriptionPlan.Slug.BUSINESS,
"name": "Business Team",
"description": (
"Team seats, centralized auth, priority support, and absolute data "
"privacy for local companies handling sensitive data."
),
"price_cents": 9900,
"is_public": False,
"is_selectable": False,
"allows_text_generation": True,
"allows_image_generation": True,
"allows_all_future_features": False,
"prompt_quota_per_window": 300,
"prompt_window_hours": 6,
"monthly_token_quota": 5_000_000,
"sort_order": 40,
},
{
"slug": SubscriptionPlan.Slug.BACKER,
"name": "Backer",
"description": (
"Complimentary Founders-level access for pre-approved emails. "
"Not shown at checkout."
),
"price_cents": 0,
"is_public": False,
"is_selectable": False,
"allows_text_generation": True,
"allows_image_generation": True,
"allows_all_future_features": True,
"prompt_quota_per_window": 300,
"prompt_window_hours": 6,
"monthly_token_quota": None,
"sort_order": 5,
},
]
def seed_subscription_plans(*, update_existing: bool = True) -> list[SubscriptionPlan]:
"""Idempotently create/update the canonical plan catalog."""
plans: list[SubscriptionPlan] = []
for row in PLAN_SEED:
slug = row["slug"]
defaults = {k: v for k, v in row.items() if k != "slug"}
plan, created = SubscriptionPlan.objects.get_or_create(
slug=slug,
defaults=defaults,
)
if not created and update_existing:
for key, value in defaults.items():
setattr(plan, key, value)
plan.save()
plans.append(plan)
return plans
def get_plan(slug: str) -> SubscriptionPlan | None:
return SubscriptionPlan.objects.filter(slug=slug).first()
def get_or_create_user_subscription(user) -> UserSubscription:
sub, _ = UserSubscription.objects.get_or_create(user=user)
return sub
def assign_plan(
user,
*,
plan: SubscriptionPlan,
source: str,
status: str = UserSubscription.Status.ACTIVE,
stripe_subscription_id: str = "",
) -> UserSubscription:
sub = get_or_create_user_subscription(user)
sub.plan = plan
sub.source = source
sub.status = status
if stripe_subscription_id:
sub.stripe_subscription_id = stripe_subscription_id
sub.save()
return sub
def user_has_active_plan(user) -> bool:
try:
sub = user.subscription
except UserSubscription.DoesNotExist:
return False
return sub.is_active
def needs_checkout(user) -> bool:
"""True when the user must complete paid Checkout to use the product."""
try:
sub = user.subscription
except UserSubscription.DoesNotExist:
return True
if not sub.is_active:
return True
# Complimentary / already-paid tiers skip Checkout.
if sub.source in (
UserSubscription.Source.BACKER,
UserSubscription.Source.ADMIN,
UserSubscription.Source.STRIPE,
):
return False
return True
@transaction.atomic
def try_redeem_backer_email(user) -> UserSubscription | None:
"""
If the user's email is on the Backer whitelist and unused, assign Backer plan.
Returns the UserSubscription when redeemed, else None.
"""
email = (getattr(user, "email", "") or "").strip().lower()
if not email:
return None
entry = (
BackerEmail.objects.select_for_update()
.filter(email__iexact=email, redeemed_at__isnull=True)
.first()
)
if entry is None:
return None
seed_subscription_plans(update_existing=False)
plan = get_plan(SubscriptionPlan.Slug.BACKER)
if plan is None:
logger.error("Backer plan missing from catalog; cannot redeem %s", email)
return None
sub = assign_plan(
user,
plan=plan,
source=UserSubscription.Source.BACKER,
status=UserSubscription.Status.ACTIVE,
)
entry.redeemed_at = timezone.now()
entry.redeemed_user = user
entry.save(update_fields=["redeemed_at", "redeemed_user", "last_modified"])
logger.info("Redeemed Backer email %s for user %s", email, user.pk)
return sub
def assign_founders_from_stripe(
user,
*,
stripe_subscription_id: str = "",
) -> UserSubscription:
seed_subscription_plans(update_existing=False)
plan = get_plan(SubscriptionPlan.Slug.FOUNDERS)
if plan is None:
raise RuntimeError("Founders plan missing from catalog")
return assign_plan(
user,
plan=plan,
source=UserSubscription.Source.STRIPE,
status=UserSubscription.Status.ACTIVE,
stripe_subscription_id=stripe_subscription_id or "",
)
def plan_to_dict(plan: SubscriptionPlan | None) -> dict[str, Any] | None:
if plan is None:
return None
return {
"slug": plan.slug,
"name": plan.name,
"description": plan.description,
"price_cents": plan.price_cents,
"currency": plan.currency,
"interval": plan.interval,
"is_public": plan.is_public,
"is_selectable": plan.is_selectable,
"features": {
"text_generation": plan.allows_feature("text_generation"),
"image_generation": plan.allows_feature("image_generation"),
"all_future_features": plan.allows_all_future_features,
},
"prompt_quota_per_window": plan.prompt_quota_per_window,
"prompt_window_hours": plan.prompt_window_hours,
"monthly_token_quota": plan.monthly_token_quota,
"sort_order": plan.sort_order,
}
+254
View File
@@ -0,0 +1,254 @@
"""Prompt-window and token-period quota checks (shared by chat + finance APIs)."""
from __future__ import annotations
from dataclasses import dataclass
from datetime import timedelta
from typing import Any
from django.conf import settings
from django.db.models import Count, Q, Sum
from django.utils import timezone
from chat_backend.models import PromptMetric
from finance.models import UserSubscription
from finance.services.plans import get_or_create_user_subscription, seed_subscription_plans
class QuotaExceeded(Exception):
"""Raised when a generation turn is blocked by quota."""
def __init__(self, code: str, message: str, *, details: dict | None = None):
super().__init__(message)
self.code = code
self.message = message
self.details = details or {}
class FeatureNotAllowed(Exception):
"""Raised when the user's plan cannot use a feature."""
def __init__(self, code: str, message: str, *, details: dict | None = None):
super().__init__(message)
self.code = code
self.message = message
self.details = details or {}
@dataclass
class UsageSnapshot:
prompts_in_window: int
prompt_quota: int | None
prompts_remaining: int | None
window_hours: int
tokens_in_period: int | None
tokens_out_period: int | None
tokens_total_period: int | None
turns_missing_token_usage: int
monthly_token_quota: int | None
tokens_remaining: int | None
period_start: Any
period_end: Any
def to_dict(self) -> dict[str, Any]:
return {
"prompts_in_window": self.prompts_in_window,
"prompt_quota": self.prompt_quota,
"prompts_remaining": self.prompts_remaining,
"window_hours": self.window_hours,
"tokens_in_period": self.tokens_in_period,
"tokens_out_period": self.tokens_out_period,
"tokens_total_period": self.tokens_total_period,
"turns_missing_token_usage": self.turns_missing_token_usage,
"monthly_token_quota": self.monthly_token_quota,
"tokens_remaining": self.tokens_remaining,
"period_start": self.period_start.isoformat() if self.period_start else None,
"period_end": self.period_end.isoformat() if self.period_end else None,
}
def _user_conversation_ids(user) -> list[int]:
from chat_backend.models import Conversation
return list(
Conversation.objects.filter(user=user, deleted=False).values_list("id", flat=True)
)
def _billing_period_bounds():
"""Calendar-month UTC window for token-period aggregation (#17)."""
now = timezone.now()
start = now.replace(day=1, hour=0, minute=0, second=0, microsecond=0)
if start.month == 12:
end = start.replace(year=start.year + 1, month=1)
else:
end = start.replace(month=start.month + 1)
return start, end
def _sum_tokens(qs) -> tuple[int | None, int | None]:
"""
Sum tokens_in / tokens_out.
Returns (None, None) when *no* rows reported usage — never fabricate 0.
When some rows reported usage, sum only those (nulls ignored by Sum).
"""
agg = qs.aggregate(
tin=Sum("tokens_in"),
tout=Sum("tokens_out"),
with_in=Count("id", filter=Q(tokens_in__isnull=False)),
with_out=Count("id", filter=Q(tokens_out__isnull=False)),
)
tokens_in = agg["tin"] if agg["with_in"] else None
tokens_out = agg["tout"] if agg["with_out"] else None
return tokens_in, tokens_out
def get_usage_snapshot(user) -> UsageSnapshot:
seed_subscription_plans(update_existing=False)
sub = (
UserSubscription.objects.select_related("plan")
.filter(user_id=user.pk)
.first()
)
if sub is None:
sub = get_or_create_user_subscription(user)
plan = sub.plan if sub.is_active else None
window_hours = plan.prompt_window_hours if plan else 6
prompt_quota = plan.prompt_quota_per_window if plan else None
monthly_token_quota = sub.effective_monthly_token_quota() if sub.is_active else None
conversation_ids = _user_conversation_ids(user)
now = timezone.now()
window_start = now - timedelta(hours=window_hours)
period_start, period_end = _billing_period_bounds()
base = PromptMetric.objects.filter(conversation_id__in=conversation_ids)
prompts_in_window = base.filter(created__gte=window_start).count()
period_qs = base.filter(created__gte=period_start, created__lt=period_end)
tokens_in, tokens_out = _sum_tokens(period_qs)
missing = period_qs.filter(
Q(tokens_in__isnull=True) | Q(tokens_out__isnull=True)
).count()
if tokens_in is None and tokens_out is None:
tokens_total = None
else:
tokens_total = (tokens_in or 0) + (tokens_out or 0)
prompts_remaining = None
if prompt_quota is not None:
prompts_remaining = max(prompt_quota - prompts_in_window, 0)
tokens_remaining = None
if monthly_token_quota is not None and tokens_total is not None:
tokens_remaining = max(monthly_token_quota - tokens_total, 0)
elif monthly_token_quota is not None and tokens_total is None:
# No provider usage yet — do not treat as 0 consumed.
tokens_remaining = monthly_token_quota
return UsageSnapshot(
prompts_in_window=prompts_in_window,
prompt_quota=prompt_quota,
prompts_remaining=prompts_remaining,
window_hours=window_hours,
tokens_in_period=tokens_in,
tokens_out_period=tokens_out,
tokens_total_period=tokens_total,
turns_missing_token_usage=missing,
monthly_token_quota=monthly_token_quota,
tokens_remaining=tokens_remaining,
period_start=period_start,
period_end=period_end,
)
def assert_feature_allowed(user, feature: str) -> None:
if not getattr(settings, "ENFORCE_SUBSCRIPTION_GATES", True):
return
seed_subscription_plans(update_existing=False)
sub = (
UserSubscription.objects.select_related("plan")
.filter(user_id=user.pk)
.first()
)
if sub is None or not sub.is_active or sub.plan is None:
raise FeatureNotAllowed(
"subscription_required",
"An active subscription is required to use this feature.",
details={"feature": feature},
)
if not sub.plan.allows_feature(feature):
raise FeatureNotAllowed(
"feature_not_allowed",
f"Your plan ({sub.plan.name}) does not include {feature.replace('_', ' ')}.",
details={
"feature": feature,
"plan": sub.plan.slug,
},
)
def assert_within_quotas(user) -> UsageSnapshot:
"""
Enforce prompt-window (#36) and token-period (#17) limits.
Precedence: either limit may block. Missing provider token usage does not
silently under-count toward a token cap — turns with null tokens are tracked
in `turns_missing_token_usage` and token-cap enforcement only uses reported
sums; if quota is set and usage is entirely unknown, we allow the turn but
surface the gap (callers/admin can tighten later).
"""
seed_subscription_plans(update_existing=False)
sub = (
UserSubscription.objects.select_related("plan")
.filter(user_id=user.pk)
.first()
)
if sub is None or not sub.is_active or sub.plan is None:
raise QuotaExceeded(
"subscription_required",
"An active subscription is required before sending prompts.",
)
usage = get_usage_snapshot(user)
if usage.prompt_quota is not None and usage.prompts_in_window >= usage.prompt_quota:
raise QuotaExceeded(
"prompt_quota_exceeded",
(
f"Prompt limit reached ({usage.prompt_quota} per "
f"{usage.window_hours} hours). Try again later."
),
details=usage.to_dict(),
)
if (
usage.monthly_token_quota is not None
and usage.tokens_total_period is not None
and usage.tokens_total_period >= usage.monthly_token_quota
):
raise QuotaExceeded(
"token_quota_exceeded",
(
f"Monthly token limit reached ({usage.monthly_token_quota}). "
"Upgrade or wait for the next billing period."
),
details=usage.to_dict(),
)
return usage
def check_generation_allowed(user, *, feature: str = "text_generation") -> UsageSnapshot:
"""Combined feature + quota gate for a chat turn."""
if not getattr(settings, "ENFORCE_SUBSCRIPTION_GATES", True):
return get_usage_snapshot(user)
assert_feature_allowed(user, feature)
return assert_within_quotas(user)
+43 -13
View File
@@ -7,7 +7,8 @@ from typing import Any
import stripe
from django.conf import settings
from finance.models import Invoice
from finance.models import Invoice, SubscriptionPlan
from finance.services.plans import get_plan, seed_subscription_plans
class StripeNotConfiguredError(RuntimeError):
@@ -24,21 +25,47 @@ def configure_stripe() -> str:
return secret
def subscription_line_items() -> list[dict[str, Any]]:
"""Build Checkout line_items from settings-backed subscription pricing."""
price_id = settings.STRIPE_PRICE_ID
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": settings.SUBSCRIPTION_PRICE_CURRENCY,
"unit_amount": settings.SUBSCRIPTION_PRICE_AMOUNT_CENTS,
"recurring": {"interval": settings.SUBSCRIPTION_PRICE_INTERVAL},
"product_data": {
"name": settings.SUBSCRIPTION_PRODUCT_NAME,
},
"currency": currency,
"unit_amount": unit_amount,
"recurring": {"interval": interval},
"product_data": {"name": product_name},
},
"quantity": 1,
}
@@ -50,19 +77,22 @@ 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 the subscription plan."""
"""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(),
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,
@@ -70,7 +100,7 @@ def create_checkout_session(
metadata=metadata,
subscription_data={"metadata": metadata},
)
return session
return session, plan
def resolve_stripe_customer_id(*, user) -> str | None:
+10
View File
@@ -11,6 +11,7 @@ from django.db import transaction
from django.utils import timezone
from finance.models import Invoice, Payment
from finance.services.plans import assign_founders_from_stripe
logger = logging.getLogger(__name__)
User = get_user_model()
@@ -211,6 +212,11 @@ def handle_checkout_session_completed(session: dict[str, Any]) -> Invoice | None
),
paid_at=timezone.now(),
)
if session.get("payment_status") == "paid" or session.get("subscription"):
assign_founders_from_stripe(
user,
stripe_subscription_id=session.get("subscription") or "",
)
return invoice
@@ -270,6 +276,10 @@ def handle_invoice_paid(stripe_invoice: dict[str, Any]) -> Invoice | None:
stripe_charge_id=charge if isinstance(charge, str) else None,
paid_at=paid_at,
)
assign_founders_from_stripe(
user,
stripe_subscription_id=stripe_invoice.get("subscription") or "",
)
return invoice