example_app | ||
looper | ||
looper_example_project | ||
.coveragerc | ||
.gitignore | ||
DEVELOP.md | ||
LICENSE | ||
manage.py | ||
mypy.ini | ||
pyproject.toml | ||
README.md | ||
setup.cfg | ||
test.sh |
What is Looper?
Looper is the subscription and payment management system used for the Blender Development Fund and Blender Studio projects. It is implemented as a Django app, and comes with the following features:
- Subscriptions or memberships with three types of payment collection:
- Automatic, using Braintree as payment gateway
- Manual, using Braintree or direct bank transfer
- Managed, handled manually by an administrator on behalf ot the subscriber
- Multiple currencies
- Multiple products
- Multiple plans/levels per product
- Multiple variations/payment schemes per plan
- Optional per-customer pricing schemes
- Flexible support for group based subscriptions
- Payment flow with support for reCaptcha
What makes Looper different?
It aims to be relatively simple code-wise without sacrificing flexibility for the customer. This is achieved by not trying to tailor Looper to all possible use-cases directly (which would make Looper necessarily very generic, and thus complex). Instead, we defer all project-specific logic to the project where Looper will be integrated (allowing Looper to be specific, and thus simple).
As a result of this, Looper only directly provides the functionality which is hard to get right and project independent:
- Charging Customers
- Checkout pages for new subscriptions and existing orders
- Automatic payment processing and subscription deactivation
- Adding payment methods (e.g. credit cards)
All other functionality (in particular, permissions and settings pages) are considered to be too specific for Looper to implement. However, since Looper itself is relatively simple this should be simple too.
How does Looper work?
Since Looper does not provide a ready-made solution it is important to understand a little bit of how Looper works. The most important things to understand are what kind of data is kept inside Looper, how these kinds of data relate to each other, and how you can add semantics by tying in your own data.
The kinds of data inside Looper
Gateway
The different payment methods. Currently we assume that precisely Braintree and direct bank transfer are enabled.
Customer
A Customer is an abstract concept inside Looper. Making up the semantics of whether it corresponds to a user or to an organization or anything else is deferred to you. It contains information such as a name and a VAT number.
Address
The billing address for a Customer.
Product
A Product is nothing more than an identifier. You can point to a Product from your actual product model to add semantics.
Plan
A Plan is an identifier for various levels (e.g. Gold, Silver, Premium) of a Product. Linking semantics for your plans (e.g. permissions, number of seats) needs to be done by you.
PlanVariation
A PlanVariation is a pricing scheme for a Plan. PlanVariations can be considered a blueprint for a Subscription.
Subscription
A Subscription is a pricing scheme for a Plan for a specific Customer together with a status field describing whether the Subscription is active or not. Generally, the pricing scheme is a copy of the pricing scheme of a PlanVariation from when the Subscription was created.
Order
An Order binds payments made by the Customer to the Subscription. This includes the amount of money a Customer has to pay you for the next installment of his Subscription. Since payments can fail and be refunded, an Order is not a single payment and does not correspond to a single point in time. Instead, payments are modelled using Transactions.
Transaction
A Transaction represents a payment attempt on behalf of a Customer in order to fulfill payment for an Order.
PaymentMethod
A PaymentMethod stores credentials and other data that is needed to charge e.g. a credit card or PayPal account. The Customer can configure per Subscription which PaymentMethod is used.
Processes within Looper
A Customer checks out a new Subscription
Ignoring edge cases the process for checking out a new Subscription roughly looks like this:
- The Customer is redirected (e.g. from a product page you implemented) to the checkout page for a specific Plan.
- The Customer submits their Address, preferred PlanVariation and PaymentMethod.
- Looper copies the pricing scheme of the PlanVariation into a new Subscription.
- Looper creates an Order for the first installment of this new Subscription.
- Looper charges the Customer for this Order, creating a Transaction.
- If the payment was successful, the Subscription is activated.
A Customer checks out an existing Order
The Customer always has the ability to pay for an Order manually. This is especially important when the Customer prefers to pay using direct bank transfer, which does not allow automatic payments. The manual payment process roughly looks like so:
- The Customer is redirected to (e.g. from a pending payments page you implemented) the checkout page for a specific Order.
- The Customer submits their Address and PaymentMethod.
- Looper charges the Customer for this Order, creating a Transaction.
- If the payment was successful the Subscription corresponding to the Order is re-activated.
Automatic payment processing and subscription deactivation
The automatic payment processing and subscription deactivation system is
implemented as a manage.py
command, clock_tick
, which should be run at
regular intervals. It roughly does the following:
- For each Subscription that needs to be renewed:
- in case of automatic payment: create a new Order and try to charge the
Customer using his PaymentMethods in the order given by
PaymentMethod.fallback_order
, - in case of manual payment: create a new Order and put the Subscription on hold,
- in case of managed payment: send a
managed_subscription_notification
Signal.
- in case of automatic payment: create a new Order and try to charge the
Customer using his PaymentMethods in the order given by
- Cancel each Subscription that was pending cancellation.
A Customer adds a PaymentMethod
Since every project has a different style, menu structure and other requirements it is difficult to provide ready-made pages in Looper. Therefore, Looper defers pages for managing PaymentMethods to the downstream project. However, since adding a PaymentMethod requires communicating with Braintree (which is rather hard to get right) Looper does provide a page for adding a PaymentMethod. Note that in doing so we sacrifice the requirements of the downstream project for simplicity and correctness.
The process of adding a PaymentMethod is as follows:
- The Customer is redirected to (e.g. from a settings page you implemented) to the add PaymentMethod page.
- The Customer submits their PaymentMethod.
- Looper communicates with Braintree.
- Looper adds the PaymentMethod to the Customer.
Integrating Looper
First of all make sure you have Looper installed in your Python environment using your favorite package manager.
Configuring Looper
After that make sure you add 'looper'
to your Django INSTALLED_APPS
and
include the Looper URLs in your urls.py
:
# urls.py
import looper.urls
urlpatterns = [
...,
path('', include(looper.urls)),
...,
]
Now configure Looper by adding the following to settings.py
, adjusting as
needed:
GATEWAYS = {
'braintree': {
'environment': braintree.Environment.Sandbox,
'merchant_id': 'SECRET',
'public_key': 'SECRET',
'private_key': 'SECRET',
# Merchant Account IDs for different currencies.
# Configured within Braintree:
# Settings cog -> Business -> Merchant Accounts.
'merchant_account_ids': {
'EUR': 'merchant-account-id-for-eur',
'USD': 'merchant-account-id-for-usd',
},
},
# No settings, but a key is required here to activate the gateway.
'bank': {},
}
# Collection of automatically renewing subscriptions will be attempted this
# many times before giving up and setting the subscription status to 'on-hold'.
#
# This value is only used when automatic renewal fails, so setting it < 1 will
# be treated the same as 1 (one attempt is made, and failure is immediate, no
# retries).
LOOPER_CLOCK_MAX_AUTO_ATTEMPTS = 3
# Only retry collection of automatic renewals this long after the last failure.
# This separates the frequency of retrials from the frequency of the clock.
LOOPER_ORDER_RETRY_AFTER = relativedelta(days=2)
# This user is required for logging things in the admin history (those log
# entries always need to have a non-NULL user ID).
LOOPER_SYSTEM_USER_ID = 1
SUPPORTED_CURRENCIES = {'EUR', 'USD'}
# Conversion rates from the given rate to euros. This allows us to express the
# foreign currency in euros.
LOOPER_CONVERTION_RATES_FROM_EURO = {
'EUR': 1.0,
'USD': 1.15,
}
LOOPER_MONEY_LOCALE = 'en_US.UTF-8'
# Get the latest from https://dev.maxmind.com/geoip/geoip2/geolite2/ or your
# distro.
GEOIP2_DB = '../path/to/GeoLite2-Country.mmdb'
# Email address for customer support (displayed in form error messages)
EMAIL_SUPPORT = ""
# Obtain these from the ReCAPTCHA website.
GOOGLE_RECAPTCHA_SITE_KEY = 'SECRET'
GOOGLE_RECAPTCHA_SECRET_KEY = 'SECRET'
After running the migrations using manage.py migrate
you should be able to
access Looper in the Django Admin. Navigate there and add both a Braintree
and Bank Gateway.
To test everything is working as it should create a Customer and a Plan with a
PlanVariation in the admin. Now navigate to the 'looper:new_subscription'
page
and try checking out a new Subscription.
Do not forget to regularly run the manage.py clock_tick
command in production!
Adding project-specific semantics
Looper cannot define what a Subscription is in the context of your project (since it varies per project). Therefore, you will need to define this yourself. This section describes how to do that by connecting your actual users and products to Looper.
Adding semantics to Subscriptions
A straightforward way might be to create project-specific objects when a Subscription is created, e.g.:
@receiver(django_signals.post_save, sender=looper.models.Subscription)
def handle_created_subscription(sender, instance: looper.models.Subscription, **kwargs):
"""Handle a newly created Subscription in some project-specific way.
E.g. by creating projects-specific objects or assigning permissions
to Subscription.user.
"""
if not kwargs.get('created'):
return
...
We can get a Users active subscriptions using the following expression:
user.subscription_set.active()
This general pattern can be extended to include group based Subscriptions and hopefully anything else that your project requires.
Adding semantics to a Plan
We now have a way to access a Users active Subscriptions, but currently it is not of much use to use since there is no domain-specific data associated with the Subscriptions. The way to do this is similar to how we added semantics to a Customer; we create a new Model with a ForeignKey to (for instance) a Plan.
Say we are in the banana business, and thus we have a Product named Bananas with id 1. We may want to provide our customers with a different amount of bananas depending on their Plan. We can model this as follows:
class PlanProperties(models.Model):
plan = models.ForeignKey(Plan, on_delete=models.CASCADE, related_name='properties')
number_of_bananas = models.IntegerField()
Assuming a Customer can have multiple Subscriptions, we can get the number of bananas we need to ship to our User as follows
bananas_to_ship = sum(
subscription.plan.properties.number_of_bananas
for subscription in user.subscriber.customer.subscription_set.filter(
plan_id=1
).active()
)
This pattern can be extended to adding properties to Products, PlanVariations and even individual Subscriptions. This way you can add any properties you need.
See looper_example_project
and example_app
for more details on how to
setup templates for payment flows and billing settings.
Allowing Customers to manage their settings
PaymentMethods
TODO(sem): Document this.
Address
TODO(sem): Document this.
Contributing
See DEVELOP.md for starting out and development guidelines.