HyperSaaS
BackendSubscriptions

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 ID

Links 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 items
  • subscribed_products — Set of Product instances the user has access to
  • subscribed_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

StatusAccess GrantedDescription
activeYesPayment current
trialingYesIn free trial period
past_dueYesPayment failed, retrying
canceledNoSubscription cancelled
incompleteNoInitial payment failed
incomplete_expiredNoInitial payment window expired
unpaidNoAll retry attempts failed
endedNoSubscription terminated

API Endpoints

MethodEndpointAuthDescription
GET/api/subscriptions/my-subscription/YesList user's subscriptions
GET/api/subscriptions/my-subscription-items/YesList active subscription items
GET/api/subscriptions/subscribable-product/NoList the plans' prices on sale
POST/api/subscriptions/checkout/YesCreate checkout session for a workspace
POST/api/subscriptions/webhookSignatureStripe webhook receiver
POST/api/subscriptions/customer-portal/YesGet 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 True

See Plans for every limit, and AI Usage & Credit for how usage is counted.

Configuration

SettingDescription
STRIPE_API_SECRETStripe secret API key
STRIPE_PUBLISHABLE_KEYStripe publishable key
STRIPE_WEBHOOK_SECRETWebhook signature verification secret
FRONT_END_BASE_URLFrontend URL for checkout redirects
NEW_USER_FREE_TRIAL_DAYSDays of free trial (default: 7)
DEFAULT_PAYMENT_METHOD_TYPES["card"]
DEFAULT_CHECKOUT_MODE"subscription"
ALLOW_PROMOTION_CODEStrue

On this page