340 lines
12 KiB
Markdown
340 lines
12 KiB
Markdown
# 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:
|
|
|
|
1. The Customer is redirected (e.g. from a product page you implemented) to the
|
|
checkout page for a specific Plan.
|
|
2. The Customer submits their Address, preferred PlanVariation and
|
|
PaymentMethod.
|
|
3. Looper copies the pricing scheme of the PlanVariation into a new
|
|
Subscription.
|
|
4. Looper creates an Order for the first installment of this new Subscription.
|
|
5. Looper charges the Customer for this Order, creating a Transaction.
|
|
6. 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:
|
|
|
|
1. The Customer is redirected to (e.g. from a pending payments page you
|
|
implemented) the checkout page for a specific Order.
|
|
2. The Customer submits their Address and PaymentMethod.
|
|
3. Looper charges the Customer for this Order, creating a Transaction.
|
|
4. 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:
|
|
|
|
1. For each Subscription that needs to be renewed:
|
|
1. 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`,
|
|
2. in case of manual payment: create a new Order and put the Subscription
|
|
on hold,
|
|
3. in case of managed payment: send a `managed_subscription_notification`
|
|
Signal.
|
|
2. 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:
|
|
|
|
1. The Customer is redirected to (e.g. from a settings page you implemented) to
|
|
the add PaymentMethod page.
|
|
2. The Customer submits their PaymentMethod.
|
|
3. Looper communicates with Braintree.
|
|
4. 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`:
|
|
```python
|
|
# urls.py
|
|
import looper.urls
|
|
|
|
urlpatterns = [
|
|
...,
|
|
path('', include(looper.urls)),
|
|
...,
|
|
]
|
|
```
|
|
|
|
Now configure Looper by adding the following to `settings.py`, adjusting as
|
|
needed:
|
|
```python
|
|
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.:
|
|
```python
|
|
@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:
|
|
```python
|
|
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:
|
|
```python
|
|
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
|
|
```python
|
|
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](DEVELOP.md) for starting out and development guidelines.
|