Overview
Plans defined in code, Stripe billing, and webhook sync.
The subscriptions module sells the plans defined in subscriptions/plans.py through Stripe: Checkout to buy one for a workspace, the billing portal to change or cancel it, and webhooks to keep the local database in step.
A subscription belongs to a workspace, and the workspace's plan decides its AI credit, models, documents and storage.
Architecture
Frontend Backend Stripe
│ │ │
│ POST /checkout/ │ │
│─────────────────────────►│ stripe.checkout.create() │
│ │───────────────────────────►│
│ ◄── session_id ─────── │ │
│ │ │
│ Redirect to Stripe ────────────────────────────────►│
│ │ │
│ │ ◄── webhook events ───────│
│ │ (subscription.created) │
│ │ Update local models │
│ │ │
│ POST /customer-portal/ │ │
│─────────────────────────►│ portal.create() │
│ ◄── portal URL ────────│───────────────────────────►│Core Models
StripeUser
class StripeUser(models.Model):
user = models.OneToOneField(AUTH_USER_MODEL, primary_key=True)
customer_id = models.CharField(max_length=128, null=True) # Stripe customer IDLinks Django users to Stripe customers. Created at checkout, with the user's id in the Stripe customer's metadata.user_id. A customer is never matched to an account by email.
Key properties:
current_subscription_items— Active/trialing/past_due subscription itemssubscribed_products— Set of Product instances the user has access tosubscribed_features— Set of Feature instances derived from subscribed products
Product & Price
class Product(models.Model):
product_id = models.CharField(primary_key=True) # Stripe Product ID
active = models.BooleanField()
name = models.CharField(max_length=256)
description = models.CharField(max_length=1024, null=True)
plan = models.CharField(max_length=32, blank=True) # from metadata.plan: "pro", "team", "business"
class Price(models.Model):
price_id = models.CharField(primary_key=True) # Stripe Price ID
product = models.ForeignKey(Product, related_name="prices")
nickname = models.CharField(max_length=256, null=True)
price = models.PositiveIntegerField() # Amount in cents
freq = models.CharField(max_length=64) # "month_1", "year_1"
active = models.BooleanField()
currency = models.CharField(max_length=3)Products and prices are created in Stripe by sync_stripe_plans, and synced back by webhooks or management commands. Product.plan is how a subscription finds its plan.
Feature & ProductFeature
class Feature(models.Model):
feature_id = models.CharField(max_length=64, primary_key=True)
description = models.CharField(max_length=256, null=True)
class ProductFeature(models.Model):
product = models.ForeignKey(Product, related_name="linked_features")
feature = models.ForeignKey(Feature, related_name="linked_products")Features are defined in Stripe Product metadata as a space-delimited string (e.g., "chat rag export"). The sync process creates Feature and ProductFeature records automatically.
Subscription & SubscriptionItem
class Subscription(models.Model):
subscription_id = models.CharField(primary_key=True) # Stripe Subscription ID
stripe_user = models.ForeignKey(StripeUser, related_name="subscriptions")
period_start = models.DateTimeField(null=True)
period_end = models.DateTimeField(null=True)
cancel_at = models.DateTimeField(null=True)
cancel_at_period_end = models.BooleanField()
ended_at = models.DateTimeField(null=True)
status = models.CharField(max_length=64)
trial_start = models.DateTimeField(null=True)
trial_end = models.DateTimeField(null=True)
class SubscriptionItem(models.Model):
sub_item_id = models.CharField(primary_key=True)
subscription = models.ForeignKey(Subscription, related_name="items")
price = models.ForeignKey(Price)
quantity = models.PositiveIntegerField()Subscription Statuses
| Status | Access Granted | Description |
|---|---|---|
active | Yes | Payment current |
trialing | Yes | In free trial period |
past_due | Yes | Payment failed, retrying |
canceled | No | Subscription cancelled |
incomplete | No | Initial payment failed |
incomplete_expired | No | Initial payment window expired |
unpaid | No | All retry attempts failed |
ended | No | Subscription terminated |
API Endpoints
| Method | Endpoint | Auth | Description |
|---|---|---|---|
| GET | /api/subscriptions/my-subscription/ | Yes | List user's subscriptions |
| GET | /api/subscriptions/my-subscription-items/ | Yes | List active subscription items |
| GET | /api/subscriptions/subscribable-product/ | No | List the plans' prices on sale |
| POST | /api/subscriptions/checkout/ | Yes | Create checkout session for a workspace |
| POST | /api/subscriptions/webhook | Signature | Stripe webhook receiver |
| POST | /api/subscriptions/customer-portal/ | Yes | Get billing portal URL |
Credit and Limits
Before AI work, has_sufficient_buffer checks the workspace's plan includes the model and that its credit isn't used up:
# subscriptions/utils.py
def has_sufficient_buffer(user, workspace, buffer=Decimal("0.01"), model=None):
plan = plan_for_workspace(workspace)
if model is not None and not plan.includes_model(model):
raise PermissionDenied(f"The {plan.name} plan doesn't include {model}. ...")
limit, used = workspace_credit(workspace, plan)
if used >= limit - buffer:
raise PermissionDenied(f"Insufficient credits remaining in workspace '{workspace.name}'. ...")
return TrueSee Plans for every limit, and AI Usage & Credit for how usage is counted.
Configuration
| Setting | Description |
|---|---|
STRIPE_API_SECRET | Stripe secret API key |
STRIPE_PUBLISHABLE_KEY | Stripe publishable key |
STRIPE_WEBHOOK_SECRET | Webhook signature verification secret |
FRONT_END_BASE_URL | Frontend URL for checkout redirects |
NEW_USER_FREE_TRIAL_DAYS | Days of free trial (default: 7) |
DEFAULT_PAYMENT_METHOD_TYPES | ["card"] |
DEFAULT_CHECKOUT_MODE | "subscription" |
ALLOW_PROMOTION_CODES | true |