The Stripe Subscription Lifecycle
Most Stripe integration bugs come from misunderstanding the subscription lifecycle. Before writing a single line of code, understand the states a Stripe subscription moves through.
trialing -> active -> (payment fails) -> past_due -> (still failing) -> canceled
|
+-- (payment recovers) -> active
| Status | Access Granted? | Charge Attempted? |
|---|---|---|
| trialing | Yes | No |
| active | Yes | Yes β succeeded |
| past_due | Grace period | Yes β failed, retrying |
| canceled | No | No |
1. Django Models β Tracking Subscriptions
from django.db import models
from django.contrib.auth import get_user_model
User = get_user_model()
class Plan(models.Model):
TIER_CHOICES = [('FREE', 'Free'), ('PRO', 'Pro'), ('ENTERPRISE', 'Enterprise')]
name = models.CharField(max_length=50)
tier = models.CharField(max_length=20, choices=TIER_CHOICES)
stripe_price_id = models.CharField(max_length=100, unique=True)
price_monthly = models.DecimalField(max_digits=8, decimal_places=2)
features = models.JSONField(default=dict)
class Subscription(models.Model):
STATUS_CHOICES = [
('trialing', 'Trialing'), ('active', 'Active'),
('past_due', 'Past Due'), ('canceled', 'Canceled'),
]
user = models.OneToOneField(User, on_delete=models.CASCADE, related_name='subscription')
plan = models.ForeignKey(Plan, on_delete=models.PROTECT)
stripe_customer_id = models.CharField(max_length=100, blank=True)
stripe_subscription_id = models.CharField(max_length=100, blank=True)
status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='trialing')
current_period_end = models.DateTimeField(null=True, blank=True)
cancel_at_period_end = models.BooleanField(default=False)
@property
def is_active(self) -> bool:
return self.status in ('active', 'trialing')
2. Creating a Stripe Checkout Session
import stripe
from django.conf import settings
from django.http import JsonResponse
from django.views.decorators.http import require_POST
from django.contrib.auth.decorators import login_required
stripe.api_key = settings.STRIPE_SECRET_KEY
@login_required
@require_POST
def create_checkout_session(request):
plan_id = request.POST.get('plan_id')
plan = Plan.objects.get(id=plan_id, is_active=True)
sub, _ = Subscription.objects.get_or_create(
user=request.user,
defaults={'plan': Plan.objects.get(tier='FREE')}
)
if not sub.stripe_customer_id:
customer = stripe.Customer.create(
email=request.user.email,
metadata={'django_user_id': request.user.id}
)
sub.stripe_customer_id = customer.id
sub.save(update_fields=['stripe_customer_id'])
session = stripe.checkout.Session.create(
customer=sub.stripe_customer_id,
payment_method_types=['card'],
line_items=[{'price': plan.stripe_price_id, 'quantity': 1}],
mode='subscription',
subscription_data={'trial_period_days': 14},
success_url=settings.DOMAIN + '/billing/success/?session_id={CHECKOUT_SESSION_ID}',
cancel_url=settings.DOMAIN + '/pricing/',
)
return JsonResponse({'checkout_url': session.url})
3. Webhook Handler β The Critical Part
Never trust frontend callbacks to update subscription status. A user could close the browser, the callback could fail, or a clever user could fake it. Always use Stripe webhooks to update your database.
from django.views.decorators.csrf import csrf_exempt
from django.http import HttpResponse
import logging
logger = logging.getLogger(__name__)
@csrf_exempt
def stripe_webhook(request):
payload = request.body
sig_header = request.META.get('HTTP_STRIPE_SIGNATURE', '')
try:
event = stripe.Webhook.construct_event(
payload, sig_header, settings.STRIPE_WEBHOOK_SECRET
)
except stripe.error.SignatureVerificationError:
return HttpResponse(status=400)
event_type = event['type']
data = event['data']['object']
if event_type in ('customer.subscription.created', 'customer.subscription.updated'):
_sync_subscription(data)
elif event_type == 'customer.subscription.deleted':
_cancel_subscription(data)
return HttpResponse(status=200)
def _sync_subscription(stripe_sub: dict) -> None:
from datetime import datetime
from django.utils.timezone import make_aware
customer_id = stripe_sub['customer']
try:
sub = Subscription.objects.get(stripe_customer_id=customer_id)
except Subscription.DoesNotExist:
logger.error(f"No subscription for customer {customer_id}")
return
price_id = stripe_sub['items']['data'][0]['price']['id']
try:
plan = Plan.objects.get(stripe_price_id=price_id)
except Plan.DoesNotExist:
return
sub.stripe_subscription_id = stripe_sub['id']
sub.plan = plan
sub.status = stripe_sub['status']
sub.cancel_at_period_end = stripe_sub['cancel_at_period_end']
if stripe_sub.get('current_period_end'):
sub.current_period_end = make_aware(
datetime.fromtimestamp(stripe_sub['current_period_end'])
)
sub.save()
logger.info(f"Synced subscription for {sub.user.email}: {sub.status}")
4. Proration β Upgrading Plans Mid-Cycle
@login_required
@require_POST
def upgrade_plan(request):
new_plan = Plan.objects.get(id=request.POST.get('plan_id'))
sub = request.user.subscription
stripe_sub = stripe.Subscription.retrieve(sub.stripe_subscription_id)
current_item_id = stripe_sub['items']['data'][0]['id']
stripe.Subscription.modify(
sub.stripe_subscription_id,
items=[{'id': current_item_id, 'price': new_plan.stripe_price_id}],
proration_behavior='create_prorations',
)
sub.plan = new_plan
sub.save(update_fields=['plan'])
return JsonResponse({'success': True, 'new_plan': new_plan.name})
5. Customer Portal β Self-Service Billing
@login_required
def billing_portal(request):
sub = request.user.subscription
portal_session = stripe.billing_portal.Session.create(
customer=sub.stripe_customer_id,
return_url=settings.DOMAIN + '/dashboard/',
)
return redirect(portal_session.url)
The Stripe Customer Portal lets users update payment methods, download invoices, upgrade/downgrade plans, and cancel subscriptions β all hosted by Stripe with PCI compliance built in.