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
+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,
}
+257
View File
@@ -0,0 +1,257 @@
"""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.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:
from django.conf import settings
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."""
from django.conf import settings
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