Recurring billing system, used in Blender Development Fund and Blender Studio website.
Go to file
2024-09-03 17:40:54 +02:00
example_app Fix billing address and subscription lookups 2024-06-20 13:38:35 +02:00
looper Fix USE_THOUSAND_SEPARATOR breaking loaddata of Money values 2024-09-03 17:40:54 +02:00
looper_example_project Fix billing address and subscription lookups 2024-06-20 13:38:35 +02:00
.coveragerc Tests: add coverage config 2023-11-23 11:56:19 +01:00
.gitignore Bring back the usual way of running tests D10398 2021-02-16 15:19:01 +01:00
DEVELOP.md Remove poetry; package with setuptools (#93019) 2024-07-01 17:03:19 +02:00
LICENSE Add LICENSE 2020-03-26 15:16:44 +01:00
manage.py Bring back the usual way of running tests D10398 2021-02-16 15:19:01 +01:00
mypy.ini Update mypy python_version 2023-01-18 00:44:17 +01:00
pyproject.toml Fix USE_THOUSAND_SEPARATOR breaking loaddata of Money values 2024-09-03 17:40:54 +02:00
README.md Fix typo 2023-01-17 23:45:03 +01:00
setup.cfg Use devfund looper 2021-03-18 16:06:14 +01:00
test.sh Remove poetry; package with setuptools (#93019) 2024-07-01 17:03:19 +02:00

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:

# 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.