Subscriptions#
Overview#
Subscriptions in Pegasus have three components which must all be setup in order for them to work correctly.
Stripe Billing data. This is configured in Stripe.
Local Stripe models. These are synced automatically from Stripe to your local database, using
dj-stripe
.Pegasus metadata. This is configured in
apps/subscriptions/metadata.py
and used to augment the data from Stripe.
The easiest way to setup all three is to follow the guide below.
Getting Started#
Complete the following steps in order to setup your first subscription workflow.
If you haven’t already, setup Pegasus and create an account.
Setup your billing plans in your Stripe test account. See Stripe’s documentation for help on doing this.
Update the
STRIPE_*
variables insettings.py
or in your os environment variables to match the keys from Stripe. See this page to find your API keys.Run
./manage.py bootstrap_subscriptions
. If things are setup correctly, you should see output that includes “Synchronized plan plan_[plan_id]” for each plan you created, and an output starting withACTIVE_PRODUCTS =
containing the products you just created. This will also automatically update your API keys in the Django admin, as described in dj-stripe’s instructions.Paste the
ACTIVE_PRODUCTS
output from the previous step intoapps/subscriptions/metadata.py
overriding what is there. Update any other details you want, for example, the “description” and “features” fields.Optionally edit the
ACTIVE_PLAN_INTERVALS
variable inapps/subscriptions/metadata.py
if you don’t plan to include both monthly and annual offerings.
Now login and click the “Subscription” tab in the navigation. If you’ve set things up correctly you should see a page that looks like this:
If you want to change the contents of the page, just edit the details in metadata.py
. Watch how the page
changes when you make changes.
More background and details on this set up can be found in this Django Stripe Integration Guide.
Customer Portal#
Pegasus uses the Stripe Billing Customer Portal for subscription management after subscription creation.
To set up the portal, it’s recommended you follow along with Stripe’s integration guide.
To use the portal you will also need to set up webhooks as per below.
Pegasus ships with webhooks to handle some common actions taken in the billing portal, including:
Subscription upgrades and downgrades
Subscription cancellation (immediately)
Subscription cancellations (end of billing period)
In the Stripe dashboard, you will need to subscribe to a minimum of customer.subscription.updated
and customer.subscription.deleted
to ensure subscription changes through the portal make it to your app successfully.
Payment method updates are coming in a future release.
Webhooks#
Webhooks are used to notify your app about events that happen in Stripe, e.g. failed payments. More information can be found in Stripe’s webhook documentation.
Pegasus ships with webhook functionality ready to go, including default handling of many events taken in Stripe’s checkout and billing portals. That said, you are strongly encouraged to test locally using Stripe’s excellent guide.
The following minimum set of webhooks are connected by default:
For the billing portal:
customer.subscription.deleted
customer.subscription.updated
For Stripe Checkout:
checkout.session.completed
A few pieces of setup that are required:
For the webhook URL, it should be https://yourserver.com/stripe/webhook/. The trailing slash is required. If using the Stripe CLI in development you can use
stripe listen --forward-to localhost:8000/stripe/webhook/
Make sure to set
DJSTRIPE_WEBHOOK_SECRET
in yoursettings.py
or environment. This value can be found when configuring your webhook endpoint in the Stripe dashboard, or read from the console output in the Stripe CLI.
Once webhooks are properly setup, all the underlying Stripe data will be automatically synced from Stripe with no additional setup on your part.
Custom Webhook Handling#
You may want to do more than just update the underlying Stripe objects when processing webhooks, for example, notifying a customer or admin of a failed payment.
Pegasus ships with an example of executing custom logic from a webhook in apps/subscriptions/webhooks.py
.
This basic example will mail your project admins when a Subscription is canceled.
More details on custom webhooks can be found in the dj-stripe documentation.
Feature-Gating#
Pegasus ships with a demo page with a few feature-gating examples, which are available from a new Pegasus installation under the “Subscription Demo” tab.
These include:
Changing content on a page based on the user/team’s subscription.
Restricting access to an entire page based on the user/team’s subscription.
Showing subscription details like plan, payment details, and renewal date.
Using the active_subscription_required
decorator#
One common use-case is restricting access to a page based on the user’s subscription.
Pegasus ships with a decorator that allows you to do this. You can use it as follows:
@login_required
@active_subscription_required
def subscription_gated_page(request, subscription_holder=None):
return TemplateResponse(request, 'subscriptions/subscription_gated_page.html')
If the user doesn’t have an active subscription, they’ll be redirected to the subscription page to upgrade.
You can also restrict access based on a specific plan (or set of plans), as follows:
@login_required
@active_subscription_required(limit_to_plans=["pro", "enterprise"])
def subscription_gated_page(request, subscription_holder=None):
return TemplateResponse(request, 'subscriptions/subscription_gated_page.html')
In this case the user will only be allowed to view the page if they have a pro or enterprise plan.
Per-Unit / Per-Seat Billing#
As of version 0.20, Pegasus supports per-unit / per-seat billing. Choose this option when building your project to enable it.
Using per-seat billing requires using Stripe Checkout.
For Team-based builds the default unit is Team members. For non-Team builds you will have to implement your own definition of what to use for billing quantities.
Here is a short video walkthrough of this feature.
Choosing your billing model#
Refer to the Stripe documentation for how to set this up in your Price model. You can use any of:
Standard pricing (e.g. $10/user)
Package pricing (e.g. $50 / 5 new users)
Tiered pricing (graduated or volume) (e.g. $50 for up to 5 users, $5/user after that)
Displaying prices on the subscriptions page#
For per-unit billing you can no longer display a single upgrade price since it is dependent on the number of units.
To avoid displaying an “unknown” price when showing the subscription, you can add a price_displays
field to
your ProductMetadata
objects that takes the following format:
ProductMetadata(
stripe_id='<stripe id>',
name=_('Graduated Pricing'),
description='A Graduated Pricing plan',
price_displays={
PlanInterval.month: 'From $10 per user',
PlanInterval.year: 'From $100 per user',
}
),
This will show “From $10 per user” or “From $100 per user” when the monthly or annual plan is selected, respectively.
Keeping your Stripe data up to date#
When changes are made that impact a user’s pricing, you will need to notify Stripe of the change.
This can be done via a management command ./manage.py sync_subscriptions
or via a celery periodic task (TBD).
To ensure this command works properly, you must implement two pieces of business logic:
You must update the billing model’s
billing_details_last_changed
field any time the number of units has change.You must override the
get_quantity
function on your billing model to tell Stripe how many units it contains.
If you use Teams with per-seat billing this will be automatically handled for you by default. All you have to do is run the management command or connect it to a periodic task.
For User-based, or more complex billing models with Teams you will have to implement these changes yourself.
A User-based example#
Here’s a quick example of how you might do this with User-based billing.
Let’s say your app allows users to define workspaces and they are billed based on the number of workspaces they create.
You might have a workspace model that looks like this:
class Workspace(models.Model):
user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name='workspaces')
# other workspace fields here
Then you would want to update the billing_details_last_changed
field of the CustomUser
object every time a workspace
was added or removed (step 1, above). That might look something like this, using Django signals:
@receiver(post_save, sender=Workspace)
def update_billing_date_on_workspace_creation(sender, instance, created, **kwargs):
if created:
instance.team.billing_details_last_changed = timezone.now()
instance.team.save()
@receiver(post_delete, sender=Workspace)
def update_billing_date_on_workspace_deletion(sender, instance, **kwargs):
instance.team.billing_details_last_changed = timezone.now()
instance.team.save()
The other piece of code you would need to add is associating the get_quantity
function on the user with the number of
workspaces they have.
You’d want to add a method like this to CustomUser
:
class CustomUser(SubscriptionModelBase, AbstractUser):
# other stuff here
def get_quantity(self):
return self.workspaces.count()
Stripe in Production#
In development you will use your Stripe test account, but when it comes time to go to production, you will want to switch to the live account.
This entails:
Setting
STRIPE_LIVE_MODE
toTrue
in your settings/environment.Populating
STRIPE_LIVE_PUBLIC_KEY
andSTRIPE_LIVE_SECRET_KEY
in your environment.Updating your
ACTIVE_PRODUCTS
to support both test and live mode (see below)
Managing Test and Live Stripe Products#
When you run bootstrap_subscriptions
Pegasus will generate a list of your ACTIVE_PRODUCTS
that includes
hard-coded Stripe Product IDs.
This works great in development, but presents a problem when trying to enable live mode.
One way to workaround this is to replace the hard-coded product IDs with values from your django settings.
E.g. in apps/subscriptions/metadata.py
change from:
ACTIVE_PRODUCTS = [
ProductMetadata(
stripe_id='prod_abc', # change this line for every product
slug='starter',
...
To:
ACTIVE_PRODUCTS = [
ProductMetadata(
stripe_id=settings.STRIPE_PRICE_STARTER, # to something like this
slug='starter',
...
Then in your settings.py
file, you can define these values based on the STRIPE_LIVE_MODE
setting:
STRIPE_LIVE_MODE = env.bool("STRIPE_LIVE_MODE", False)
STRIPE_PRICE_STARTER = "prod_xyz" if STRIPE_LIVE_MODE else "prod_abc"
You will have to do this for each of your products.
Troubleshooting#
Stripe is not returning to the right site after accessing checkout or the billing portal.
There are two settings that determine how Stripe will call back to your site.
If Stripe is returning to the wrong site entirely it is likely a problem with your Django Site
configuration.
See the documentation on absolute URLs to fix this.
If Stripe is returning to the correct site, but over HTTP instead of HTTPS (or vice versa) then you
need to change the USE_HTTPS_IN_ABSOLUTE_URLS
setting in settings.py
or a production settings file.
Stripe webhooks are failing with a signature error.
If you get an error like “No signatures found matching the expected signature for payload” or similar there are a few things to check:
First, double check all of your API keys and secrets in your environment/settings files. These are:
STRIPE_LIVE_PUBLIC_KEY
andSTRIPE_LIVE_SECRET_KEY
(for live mode), orSTRIPE_TEST_PUBLIC_KEY
andSTRIPE_TEST_SECRET_KEY
(for test mode)STRIPE_LIVE_MODE
should match whether you’re in live / test mode.DJSTRIPE_WEBHOOK_SECRET
should match the secret from the Stripe dashboard.
If you have confirmed these are correct, also double check that you are on the latest Stripe API version. As of the time of this writing, that is 2022-08-01. Your API version can be found in the “Developers” section of the Stripe dashboard.