Skip to content

reef-technologies/django-subscriptions-rt

Repository files navigation

django_subscriptions_rt: subscriptions and payments for your Django app

 Continuous Integration License python versions PyPI version

Supported versions

See ci.yml for matrix of supported Python and Django versions.

Important

Only Postgres database is supported (due to PG_ADVISORY_LOCK requirement).

Configuration

# settings.py
INSTALLED_APPS = [
    # ...,
    "subscriptions.v0.apps.AppConfig",
    "pgactivity",
    "pglock",
]

Important

This package uses ApiVer, make sure to import subscriptions.vX for guaranteed backward compatibility.

For possible settings and their defaults see defaults.py.

Cache

Cache is required for fast resource calculations.

settings.CACHES['subscriptions'] = {
    'BACKEND': 'django.core.cache.backends.locmem.LocMemCache',
    'LOCATION': 'subscriptions',
}

Usage

A Plan is some option which gives a user some benefits.

A Subscription is a user's belonging to a specific Plan for a defined period of time.

gantt
    dateFormat YYYY-MM-DD

    section One-time
        30 days :a1, 2025-01-01, 30d
    section Recurring
        30 days :rec1, 2025-01-01, 30d
        30 days :rec2, after rec1, 30d
        30 days :after rec2, 30d
    section Infinite:
        Infinite :2025-01-01, 90d
Loading
# Create different plans and "attach" them to some user

from dateutil.relativedelta import relativedelta
from django.contrib.auth.models import User

from subscriptions.v0.models import Plan, Subscription, INFINITY


one_time_plan = Plan.objects.create(
    codename="one-time",
    name="One-time plan",
    max_duration=relativedelta(months=1),
)

recurring_plan = Plan.objects.create(
    codename="recurring",
    name="Recurring plan",
    charge_period=relativedelta(months=1),
)

infinite_plan = Plan.objects.create(
    codename="infinite",
    name="Infinite plan",
    charge_period=INFINITY,
)

user = User.objects.create(username="test", email="test@localhost")
for plan in [one_time_plan, recurring_plan, infinite_plan]:
    Subscription.objects.create(user=user, plan=plan)

Plan immutability

Plan is a groundtruth for all calculations, meaning that once a plan has subscriptions attached, its core attributes (charge_amount and charge_period) should not change. Trying to change essential plan attributes while having attached subscriptions will raise a ValidationError.

One-time charge plans

These are plans that are charged only once (at the beginning of the subscription). Set charge_period=INFINITY (or None, or omit it) to never charge again:

from moneyed import Money
from subscriptions.v0.models import plan, INFINITY

infinite_plan = Plan.objects.create(
    name="Infinite plan",
    charge_amount=Money(100, "USD"),
    charge_period=INFINITY,
)

Free plans

Free plans are those that have no cost. Use NO_MONEY constant to indicate zero cost:

from subscriptions.v0.models import Plan, NO_MONEY

free_plan = Plan.objects.create(
    name="Free plan",
    charge_amount=NO_MONEY,
    charge_period=INFINITY,
)

Payments

SubscriptionPayment represents payment for subscription creation or prolongation, and the period "covered" by specific payment is represented by payment.paid_since and payment.paid_until.

Payments and subscriptions don't affect each other except one case: if a payment changed its status to Subscription.Status.COMPLETED, it will automatically extend the associated subscription (or create a new one if it doesn't exist or doesn't intersect with (paid_since, paid_until) period).

gantt
    title Subscription auto-extension
    dateFormat YYYY-MM-DD

    Section Before
        Subscription: 2025-01-01, 2025-01-31

    Section Pending
        Subscription: 2025-01-01, 2025-01-31
        Payment (PENDING): 2025-01-31, 2025-02-28

    Section Completed
        Subscription: 2025-01-01, 2025-02-28
      Payment (COMPLETED): 2025-01-31, 2025-02-28
Loading
gantt
    title Subscription auto-creation
    dateFormat YYYY-MM-DD

    Section Before
        Subscription: 2025-01-01, 2025-01-31

    Section Pending
        Subscription: 2025-01-01, 2025-01-31
        Payment (PENDING): 2025-03-01, 2025-03-31

    Section Completed
        Subscription: 2025-01-01, 2025-01-31
        Subscription: 2025-03-01, 2025-03-31
        Payment (COMPLETED): 2025-03-01, 2025-03-31
Loading

Recurring subscriptions

Recurring subscriptions are those that live for a limited period of time and require recharging when approaching its end.

Plan.charge_period defines how often subscription charges will happen. It expects either INFINITY or a relativedelta object, so you can specify complex time periods easily - for example, if starting a subscription on Nov 30st with charge_period=relativedelta(months=1), next charge dates will be Dec 30st, Jan 30st etc, so real charge period will be not exactly 30 days.

gantt
    title Charge period: relativedelta(months=1)
    dateFormat YYYY-MM-DD
    axisFormat %b %d
    1 month (30 days) :2025-11-30, 2025-12-30
    1 month (31 days) :2025-12-30, 2026-01-30
    1 month (28 days) :2026-01-30, 2026-02-28

    Dec 30th - end of period 1 :vert, 2025-12-30, 0
    Jan 30th - end of period 2 :vert, 2026-01-30, 0
    Feb 28th - end of period 3 :vert, 2026-02-28, 0
Loading

Near the end of subscription, it should be extended (prolonged) by charging user. The task charge_recurring_subscriptions does exactly that:

from subscriptions.v0.tasks import charge_recurring_subscriptions

charge_recurring_subscriptions()

This task should be called periodically (via celery or other task runner). Default schedule starts trying charging few days in advance (use SUBSCRIPTIONS_CHARGE_ATTEMPTS_SCHEDULE setting to change the default schedule).

Charging may be customized as needed - for example, having different charge attempts' periods for different subscriptions:

base_subscriptions = Subscription.objects.filter(plan=base_plan)
pro_subscriptions = Subscription.objects.filter(plan=pro_plan)

# start charging base subscriptions 7 days in advance, try each day until success
charge_recurring_subscriptions(
    subscriptions=base_subscriptions,
    schedule=[
        timedelta(days=-7),
        timedelta(days=-6),
        timedelta(days=-5),
        timedelta(days=-4),
        timedelta(days=-3),
        timedelta(days=-2),
        timedelta(days=-1),
        timedelta(days=0),
    ],
)

# try charging pro subscriptions 1 day in advance, if it fails then try to charge even after expiration, but only once
charge_recurring_subscriptions(
    subscriptions=pro_subscriptions,
    schedule=[
        timedelta(days=-1),
        timedelta(days=0),
        timedelta(days=1),
    ],
)

Only subscriptions with auto_prolong set to True will be charged and prolonged. When user cancels a subscription, we simply set auto_prolong = False, so that subscription still exists but is not prolonged at expiration.

Also charge won't be performed if it falls into charge_offset period.

Charge offset

It is possible to postpone subscription's first payment by some timedelta: charge_offset will shift charge schedule.

from dateutil.relativedelta import relativedelta

offset = relativedelta(days=7)
subscription = Subscription.objects.create(
    plan=plan,
    charge_offset=offset,
)

assert next(subscription.iter_charge_dates()) == subscription.start + offset

This is handy when unpausing a subscription or implementing trial period.

Please note that charge_recurring_subscriptions task won't try to charge within charge_offset period:

# try charging daily 3 days in advance,
# and 2 days past deadline
CHARGE_SCHEDULE = [
    timedelta(days=-3),
    timedelta(days=-2),
    timedelta(days=-1),
    timedelta(0),
    timedelta(days=1),
    timedelta(days=2),
]


# create a 7-days subscription with 6-days charge offset
now_ = now()
subscription = Subscription.objects.create(
    # ...
    start=now_,
    end=now_ + relativedelta(days=7),
    charge_offset=relativedelta(days=6),
)
gantt
    dateFormat YYYY-MM-DD

    Subscription: 2025-01-01, 2025-01-08
    Regular charge period: crit, 2025-01-05, 2025-01-10

    -3 days: vert, 2025-01-05, 0
    -2 days: vert, 2025-01-06, 0
    -1 day: vert, 2025-01-07, 0
    End: vert, 2025-01-08, 0
    +1 day: vert, 2025-01-09, 0
    +2 days: vert, 2025-01-10, 0

    Charge offset: 2025-01-01, 2025-01-07
    Real charge period: crit, 2025-01-07, 2025-01-10
Loading

Limited duration plans

These are plans that have a maximum duration and cannot be used indefinitely. For example, let's create a promo plan that can last only for 3 months and won't be auto-prolonged at the end of that period:

from subscriptions.v0.models import Plan
from dateutil.relativedelta import relativedelta

promo_plan = Plan.objects.create(
    name="Promo plan",
    charge_amount=Money(50, "USD"),
    charge_period=relativedelta(months=1),
    max_duration=relativedelta(months=3),
)

When a user subscribes to this plan, they will be charged $50 USD at the start of the subscription and then every month for up to 3 months. After 3 months, the subscription will automatically end and will not be renewed.

Technically speaking, user could manually re-subscribe to the promo plan after it ends, but 1) it won't be automatic, and 2) we may configure validators to disallow subscribing to promo plan more than once.

Features & tiers

A Feature is a specific functionality or benefit that a user can access while subscribed to a plan. Plans may have Features attached to them through a Tier.

classDiagram

Base_Tier -- Base_Plan_Monthly
Base_Tier -- Base_Plan_Yearly
Pro_Tier -- Pro_Plan_Monthly
Pro_Tier -- Pro_Plan_Yearly

Base_Feature_1 -- Base_Tier
Base_Feature_2 -- Base_Tier
Pro_Feature_1 -- Pro_Tier
Pro_Feature_2 -- Pro_Tier
Loading
from subscriptions.v0.models import Plan, Feature, Tier

base1, base2, pro1, pro2 = Feature.objects.bulk_create([
    Feature(codename='base1'),
    Feature(codename='base2'),
    Feature(codename='pro1'),
    Feature(codename='pro2'),
])

base_tier, pro_tier = Tier.objects.bulk_create([
    Tier(codename='base'),
    Tier(codename='pro'),
])

base_tier.features.add(base1)
base_tier.features.add(base2)
pro_tier.features.add(pro1)
pro_tier.features.add(pro2)

plan_base_m, plan_base_y, plan_pro_m, plan_pro_y = Plan.objects.bulk_create([
    Plan(name='Base (monthly)', tier=base_tier, charge_period=relativedelta(months=1)),
    Plan(name='Base (yearly)',  tier=base_tier, charge_period=relativedelta(years=1)),
    Plan(name='Pro (monthly)',  tier=pro_tier,  charge_period=relativedelta(months=1)),
    Plan(name='Pro (yearly)',   tier=pro_tier,  charge_period=relativedelta(years=1)),
])

One should create business logic to handle different features provided by each subscription:

user_subscriptions = Subscription.objects.filter(user=user).active()
user_features = user_subscriptions.features()
if user_features.filter(codename='base1').exists():
    print("User has access to Base Feature 1")

Quotas

Unlike features, quotas are numeric limits imposed on a user's subscription. They define the maximum usage allowed for specific resources, such as API calls, storage space, or other measurable units. Quotas can be set at the plan level.

An example of some mobile plan :

from moneyed import Money
from dateutil.relativedelta import relativedelta
from subscriptions.v0.models import Resource, Plan, Quota

call = Resource.objects.create(codename='call', units='s')  # seconds
sms = Resource.objects.create(codename='sms', units='pcs')  # pieces
data = Resource.objects.create(codename='data', units='b')  # bytes

plan = Plan.objects.create(
    name='Default plan',
    charge_amount=Money(50, "USD"),
    charge_period=relativedelta(months=1),
)

Quota.objects.bulk_create([
    Quota(
        plan=plan,
        resource=call, limit=120*60,  # 120 mins of calls
        recharge_period=relativedelta(months=1),  # add another 120 mins each month
        burns_in=relativedelta(months=1),  # unused mins will be lost
    ),
    Quota(
        plan=plan,
        resource=sms, amount=20,  # 20 SMS messages
        recharge_period=relativedelta(weeks=2),  # add another 10 SMS each 2 weeks
        burns_in=relativedelta(weeks=2),  # unused SMS are lost
    ),
    Quota(
        plan=plan,
        resource=data, amount=5*1024**3, # 5 GB of data
        recharge_period=relativedelta(months=1),  # add another 5 GB each month
        burns_in=relativedelta(months=2),  # unused data will be preserved for the next month
),
])
gantt
    dateFormat YYYY-MM-DD

    section Calls
        120 mins: 2025-01-01, 2025-01-31
        120 mins: 2025-01-31, 2025-02-28
        120 mins: 2025-02-28, 2025-03-31

    section SMS
        20 SMS: 2025-01-01, 2025-01-15
        20 SMS: 2025-01-15, 2025-01-29
        20 SMS: 2025-01-29, 2025-02-12
        20 SMS: 2025-02-12, 2025-02-26
        20 SMS: 2025-02-26, 2025-03-12
        20 SMS: 2025-03-12, 2025-03-26
        20 SMS: 2025-03-26, 2025-03-31

    section Data:
        5 Gb: 2025-01-01, 2025-02-28
        5 Gb: 2025-01-31, 2025-03-31
        5 Gb: 2025-02-28, 2025-03-31

        Jan 31st - end of month: vert, 2025-01-31, 0
        Feb 28th - end of month: vert, 2025-02-28, 0
        Mar 31st - end of month: vert, 2025-03-31, 0
Loading

One should call use_resource to safely subtract from user's quota:

from subscriptions.v0.functions import get_remaining_amount, use_resource

assert get_remaining_amount(user)[data] == 5 * 1024**3

with use_resource(user=user, resource=data, amount=1024**3) as remains:
    print(f"Used 1 GB of {data}, remaining: {remains}")

assert get_remaining_amount(user)[data] == 4 * 1024**3

try:
    with use_resource(user=user, resource=data, amount=5*1024**3) as remains:
        print(f"Used 5 GB of {data}, remaining: {remains}")
except QuotaLimitExceeded as exc:
    print(f"Failed to use {exc.amount_requested} of {exc.resource}, only {exc.amount_available} available")

Tracking changes

django-model-utils package is used to track changes in essential models, such as Subscription, SubscriptionPayment, and SubscriptionPaymentRefund. This allows to track changes in subscription status / dates, payment status, and other fields.

This is an example of how to do some processing when subscription payment status changes:

@receiver(post_save, sender=SubscriptionPayment)
def handler(sender, instance: SubscriptionPayment, **kwargs):
    if instance.tracker.has_changed("status"):
        old_status = instance.tracker.previous("status")
        new_status = instance.status
        print(f'Payment status changed from {old_status} to {new_status}')
        send_mail.delay(...)

Validators

Business logic constraints on subscriptions may be set via SUBSCRIPTIONS_VALIDATORS:

# settings.py
SUBSCRIPTIONS_VALIDATORS = [
    "subscriptions.v0.validators.plan_is_enabled",
    "subscriptions.v0.validators.not_recurring_requires_recurring",
    "subscriptions.v0.validators.exclusive_recurring_subscription",
]

Each validator is a function def foo(self: Subscription) -> None:, where self is subscription to be validated (may be not yet in the database). Subclass of SubscriptionsError and ValidationError is raised if any check fails.

from django.forms import ValidationError
from subscriptions.v0.exceptions import SubscriptionsError
from subscriptions.v0.models import Subscription


class PlanDisabled(SubscriptionsError, ValidationError):
    def __init__(self, plan: Plan) -> None:
        self.plan = plan


def plan_is_enabled(self: Subscription) -> None:
    # when creating new subscription, chosen plan should be enabled
    if self._state.adding and not self.plan.is_enabled:
        raise PlanDisabled(self.plan)

Validation is protected by advisory lock and SELECT FOR UPDATE, so other actions don't interfere the validation process. Remember that validation is done on application level, but for better data integrity one could additionally implement database-level constraints.

Grace period

Grace period is a period of time during which a user can fix a payment issue without losing access to their subscription, in other words it's an extension of subscription beyond its original end date.

There is no special functionality for grace period. Let's create a configuration for a 30-days recurring plan with a 3 days grace period:

plan_with_grace = Plan.objects.create(
    name="Plan with grace period",
    charge_amount=Money(100, "USD"),
    charge_period=relativedelta(months=1),
)

GRACE_PERIOD = timedelta(days=3)

@receiver(pre_save, sender=Subscription)
def handler(sender, subscription: Subscription, **kwargs):
    if subscription.plan != plan_with_grace:
        return

    try:
        charge_date = next(subscription.iter_charge_dates(since=subscription.end))
    except StopIteration:
        return

    # if subscription ends exactly on charge date, automatically extend it by grace period
    if subscription.end == charge_date:
        subscription.end += GRACE_PERIOD

REGULAR_CHARGE_SCHEDULE = [
    timedelta(days=-3),
    timedelta(days=-2),
    timedelta(days=-1),
    timedelta(0),
]
# now we should adjust charging schedule bc charge schedule is relative to subscription end
# and the latter is extended by grace period, so we should shift charging schedule back
CHARGE_SCHEDULE_WITH_GRACE = [delta - GRACE_PERIOD for delta in REGULAR_CHARGE_SCHEDULE] + [
    # we also need to try charging during grace period - let's try charging 3 times during grace period
    -GRACE_PERIOD * 2/3,  # -2 days
    -GRACE_PERIOD * 1/3,  # -1 days
    -GRACE_PERIOD * 0/3,  # end date
]

subscriptions_with_grace = Subscription.objects.filter(plan=plan_with_grace)

charge_recurring_subscriptions(
    subscriptions=subscriptions_with_grace,
    schedule=CHARGE_SCHEDULE_WITH_GRACE,
)
gantt

dateFormat YYYY-MM-DD

    Section Subscription
        ... original subscription (truncated from start): 2025-01-26, 2025-01-31
        Grace period: active, 2025-01-31, 2025-02-03


    Section Charge attempts
        #1: 2025-01-28, 2025-01-29
        #2: 2025-01-29, 2025-01-30
        #3: 2025-01-30, 2025-01-31
        #4: 2025-01-31, 2025-02-01
        #5: 2025-02-01, 2025-02-02
        #6: 2025-02-02, 2025-02-03

    -6 days: vert, 2025-01-28, 0
    -5 days: vert, 2025-01-29, 0
    -4 days: vert, 2025-01-30, 0
    Original end: vert, 2025-01-31, 0
    -2 days: vert, 2025-02-01, 0
    -1 days: vert, 2025-02-02, 0
    End of grace: vert, 2025-02-03, 0
Loading

Pausing subscriptions

Subscriptions are continuous, i.e. they last from start till end with no gaps in between. Thus "pausing a subscription" is simply ending current subscription and creating a new one with adjusted start and end dates.

For example, this is an already-lasting subscription:

plan = Plan.objects.create(
    name="Monthly plan",
    charge_period=relativedelta(days=30),
)

subscription = Subscription.objects.create(
    user=user,
    plan=plan,
    start=now() - timedelta(days=15),
)
gantt
    dateFormat YYYY-MM-DD

    Subscription: 2025-01-01, 2025-01-30

    Initial charge: vert, 2025-01-01, 0
    Now: vert, 2025-01-15, 0
    Planned charge: vert, 2025-01-30, 0
Loading

Pause the subscription, leaving 15 "unused" days of subscription:

subscription.end = now()
subscription.save()
gantt
    dateFormat YYYY-MM-DD

    Subscription: 2025-01-01, 2025-01-15
    unused:active, 2025-01-15, 2025-01-30

    Initial charge: vert, 2025-01-01, 0
    Now: vert, 2025-01-15, 0
    Planned charge: vert, 2025-01-30, 0
Loading

Resume the subscription. Since the new subscription starts with a leftover period, initial charge for this subscription should be shifted by that period.

planned_charge_date = next(subscription.iter_charge_dates(since=subscription.end))
unused_subscription_time = planned_charge_date - subscription.end

now_ = now()
resumed_subscription = Subscription.objects.create(
    user=subscription.user,
    plan=subscription.plan,
    start=now_,
    end=now_ + unused_subscription_time,
    charge_offset=unused_subscription_time,
)
gantt
    dateFormat YYYY-MM-DD

    Subscription: 2025-01-01, 2025-01-15
    Subscription: active, 2025-01-16, 2025-01-31

    Initial charge: vert, 2025-01-01, 0
    Now: vert, 2025-01-16, 0
    New planned charge: vert, 2025-01-31, 0
Loading

Discounts

Discounts are not supported out-of-box but can be achieved by using additional payment plans:

plan = Plan.objects.create(
    name="Default plan",
    charge_period=relativedelta(months=1),
    charge_amount=Money(100, "USD"),
)

discount_plan = Plan.objects.create(
    name="Discount plan",
    charge_period=plan.charge_period,
    charge_amount=plan.charge_amount - Money(20, "USD"),
)

Default plan

Default plan is a plan that is applied automatically to every subscription unless there is some recurring subscription which then substitutes the default plan.

Use pip install django-subscriptions-rt[default_plan] to install constance as dependency.

Setup default plan like this:

# settings.py
INSTALLED_APPS = [
    ...
    'constance',
    'constance.backends.database',
]

CONSTANCE_BACKEND = 'constance.backends.database.DatabaseBackend'
CONSTANCE_CONFIG = {
    'SUBSCRIPTIONS_DEFAULT_PLAN_ID': (0, 'Default plan ID', int),
}

0 value means "no default plan".

As soon as constance config is saved, the default plan will be applied to every user. When new user is created, default infinite subscription will be automatically attached.

Adding default plan

If switching from no default plan to a specific plan, all users will be assigned a default subscription effective now or after last subscription.

gantt
    title No active subscription (default applied now)
    dateFormat YYYY-MM-DD
    Now: vert, 2025-01-16, 0

    Section Default ID: 0
        Subscription: 2025-01-01, 2025-01-14

    Section Default ID: N
        Subscription: 2025-01-01, 2025-01-14
        Default: 2025-01-16, 2025-01-31
Loading
gantt
    title With active subscription (default scheduled after current subscription)
    dateFormat YYYY-MM-DD
    Now: vert, 2025-01-13, 0

    Section Default ID: 0
        Subscription: 2025-01-01, 2025-01-14

    Section Default ID: N
        Subscription: 2025-01-01, 2025-01-14
        Default: 2025-01-14, 2025-01-31
Loading

Switching default plan

If changing default plan from one to another, all current and future default subscriptions will be stopped and replaced with new default subscriptions:

gantt
    title Current default subscription changing
    dateFormat YYYY-MM-DD
    Now: vert, 2025-01-13, 0

    Section Default ID: M
        Default (M): 2025-01-01, 2025-01-31

    Section Default ID: N
        Default (M): 2025-01-01, 2025-01-13
        Default (N): 2025-01-13, 2025-01-31
Loading
gantt
    title Future default subscription changing
    dateFormat YYYY-MM-DD
    Now: vert, 2025-01-13, 0

    Section Default ID: M
        Subscription: 2025-01-01, 2025-01-20
        Default (M): 2025-01-20, 2025-01-31

    Section Default ID: N
        Subscription: 2025-01-01, 2025-01-20
        Default (N): 2025-01-20, 2025-01-31
Loading

Disabling default plan

Setting default plan to 0 (== disabling default plan) will immediately stop all current & future default subscriptions.

gantt
    title Future default subscription stop
    dateFormat YYYY-MM-DD
    Now: vert, 2025-01-13, 0

    Section Default ID: M
        Subscription: 2025-01-01, 2025-01-20
        Default (M): 2025-01-20, 2025-01-31

    Section Default ID: 0
        Subscription: 2025-01-01, 2025-01-20
Loading
gantt
    title Current default subscription stop
    dateFormat YYYY-MM-DD
    Now: vert, 2025-01-13, 0

    Section Default ID: M
        Default (M): 2025-01-01, 2025-01-31

    Section Default ID: 0
        Default (M): 2025-01-01, 2025-01-13
Loading

Interaction with recurring subscriptions

Any recurring subscription will push out default subscription:

gantt
    title Pushing out default subscription
    dateFormat YYYY-MM-DD
    Now: vert, 2025-01-13, 0

    Section Before
        Default: 2025-01-01, 2025-01-31

    Section After
        Default: 2025-01-01, 2025-01-13
        Subscription: active, 2025-01-13, 2025-01-25
        Default: 2025-01-25, 2025-01-31
Loading

A gap after shrinking recurring subscription will be filled with default one:

gantt
    title Shrinking recurring subscription
    dateFormat YYYY-MM-DD
    Now: vert, 2025-01-13, 0

    Section Before
        Subscription: 2025-01-01, 2025-01-20
        Default: 2025-01-20, 2025-01-31

    Section After
        Subscription: 2025-01-01, 2025-01-10
        Default: 2025-01-10, 2025-01-31
Loading

Trial period

Trial period is a feature that allows users to try out a subscription plan for a limited time without being charged. It may be implemented like this:

  • create subscription with either
    • no payment (and later ask user to pay)
    • a zero-amount payment (and later just use payment details to auto-charge)
  • set charge_offset to the desired trial period length
# settings.py
from dateutil.relativedelta import relativedelta

SUBSCRIPTIONS_TRIAL_PERIOD = relativedelta(days=7)
# views.py
class PlanTrialSubscriptionView(LoginRequiredMixin, FormView):
    template_name = "subscriptions/subscribe.html"
    form_class = SubscriptionSelectionForm

    def post(self, request: HttpRequest, *args, **kwargs) -> HttpResponse:
        if (form := self.get_form()).is_valid():
            plan = form.cleaned_data["plan"]
            quantity = form.cleaned_data["quantity"]

            try:
                Subscription(user=request.user, plan=plan, quantity=quantity).run_validators()

                provider = get_provider_by_codename(form.cleaned_data["provider"])
                now_ = now()
                _, redirect_url = provider.charge(
                    user=request.user,
                    plan=plan,
                    amount=plan.charge_amount * 0,  # zero amount (but currency is preserved)
                    quantity=quantity,
                    since=now_,
                    until=now_ + settings.SUBSCRIPTIONS_TRIAL_PERIOD,
                )
                return HttpResponseRedirect(redirect_url)
            except ValidationError as exc:
                form.add_error("plan", exc)
            except PaymentError as exc:
                form.add_error(None, ValidationError(exc.user_message))

        return super().get(request, *args, **kwargs)
# models.py
@receiver(post_save, sender=SubscriptionPayment)
def handler(sender, payment: SubscriptionPayment, **kwargs):
    if (
        payment.tracker.has_changed("status") and
        payment.status == SubscriptionPayment.Status.COMPLETED and
        payment.amount.amount == 0
    ):
        payment.subscription.charge_offset = settings.SUBSCRIPTIONS_TRIAL_PERIOD
        payment.subscription.save()

Middleware

MIDDLEWARE = [
    # ...
    "subscriptions.v0.middleware.SubscriptionsMiddleware",
]

This will add

  • request.user.active_subscriptions: QuerySet[Subscription] and
  • request.user.quotas: dict[Resource, int]

for each authenticated user's request, so it may be handy for development but not ready for production.

Payment providers

Different payment providers allow different level of control.

  • You may ask some of them to do one-off charges by demand, and handle subscriptions and quotas yourself using this framework;
  • Some providers allow advanced features like subscriptions and trial period, you may use this framework as addition to their capabilities;
  • Other providers don't allow anything and handle everything on their side - you have no control over the process but at least can fetch information about subscriptions and their status into local database.

This framework comes with reference implementations for several payment providers.

Paddle

Uses paddle.com as payment provider.

This implementation exploits an undocumented opportunity to create a zero-cost subscription with infinite charge period. After this subscription is created, user can be charged by backend at any moment with any amount.

App store

Workflow from the mobile application perspective:

  1. App fetches data from backend's /plans/ endpoint. Each plan will have metadata entry apple_in_app -> string, where string represents an Apple Product identifier
  2. When user performs a purchase, app fetches the receipt data
  3. Receipt is sent to backend using POST /webhook/apple_in_app/ {"transaction_receipt": "<base64 encoded receipt>"}. This request needs to be authenticated on behalf of the user that this operation is performed for
  4. If everything is fine, new SubscriptionPayment and Subscription will be created and returned to the app. The json returned to the app is the same as when querying /payments/<uid>/ endpoint
  5. In case of an error, a retry should be performed with an exponentially increased timeout and after each application restart. Utmost care should be taken when handling the receipt. It should be stored on the device until the server accepts the operation. Failure to comply will result in client dissatisfaction and a potential refund

WARNING

Server is unable to reject any payment coming from Apple. Receipt that we receive means that the user has already paid. Thus, it is in the best interest of the frontend application to prohibit user that is already on a paid plan from purchasing another one

Workflow from the backend perspective – handling client operation:

  1. Server side part of the validation process is performed
  2. User provided from the authentication is the one having a Subscription and SubscriptionPayment created for
  3. Transaction id for this operation is kept with SubscriptionPayment

Workflow from the backend perspective – handling renewals:

This assumes that the /webhook/apple_in_app/ endpoint is assigned as notifications service Currently, only version 2 of the notifications is supported.

  1. Whenever a notification is received from Apple, we discard anything that's not a renewal operation. It is assumed that we, ourselves, can handle expiration, and other events are to be handled in the future
  2. Renewal operation contains original transaction id (the first transaction that initiated the whole subscription) – this is used (in turns) to fetch user for which this operation is performed
  3. New SubscriptionPayment is created, using expiration date provided in the notification

Google in-app purchase

Automatic sync of plans between Google Play and the backend (BE) is not implemented yet, so operator should keep both in sync manually:

  • Plans on BE and Subscriptions on Google <- how to sync
  • Changes made on BE won't affect Google Play purchases, so don't touch it. All information is pulled from Google Play to BE automatically, and BE admin should only be used to read data.
  • Subscription pause is not supported by BE and should be disabled in Google Play.
  • Grace period is not supported by BE and should be disabled in Google Play.

Workflow:

(inspired by)

  1. Backend pushes actual plans to Google Play using Subscriptions and In-app Purchases API⁠.
  2. Mobile app fetches data from backend's /plans/ endpoint. Each plan will have metadata entry google_in_app -> dict, where dict is a Subscription.
  3. User purchases desired product (is it possible to associate custom metadata with purchase on google servers?) and mobile app receives "purchase token" from google play. This token is sent to backend: POST /webhook/google_in_app/ {'purchase_token': <value>}. App should not forget to send user authentication info along with the request. This is an essential part of connecting subscription to particular user, so this POST action should have retry logic in case of network error.
  4. Backend fetches data from Purchases.subscriptionsv2:get and verifies that the purchase is legit.
  5. If everything is fine, new SubscriptionPayment and Subscription are created and returned to the app. The json returned to the app is the same as when querying /payments/<uid>/ endpoint.
  6. App can now use the credits included in the plan.
  7. Any other subscription changes are handled by backend automatically by interacting with google APIs.

How to test

Reports

This app comes with basic reporting functionality, including (but not limited to) completed and incompleted payments during selected period of time, as well as estimated recurring charges' dates and amounts in past and future.

Below is an example how to use reporting functionality. Please pay attention that arguments are converted to a period [since, until) (until is not included).

from django.utils.timezone import now
from datetime import timedelta
from subscriptions.v0 import SubscriptionsReport, TransactionsReport


subscriptions = SubscriptionsReport(
   since=now()-timedelta(days=30),
   until=now(),
)

print('New subscriptions count:', subscriptions.get_new_count())
print('New subscriptions dates:', subscriptions.get_new_datetimes())

print('Ended subscriptions count:', subscriptions.get_ended_count())
print('Ended subscriptions dates:', subscriptions.get_ended_datetimes())
print('Ended subscriptions ages:', subscriptions.get_ended_or_ending_ages())

print('Active subscriptions count:', subscriptions.get_active_count())
print('Active users count:', subscriptions.get_active_users_count())
print('Active subscriptions ages:', subscriptions.get_active_ages())
print('Active plans & quantities:', subscriptions.get_active_plans_and_quantities())
print('Active plans -> quantity total:', subscriptions.get_active_plans_total())

transactions = TransactionsReport(
   provider_codename="paddle",
   since=now()-timedelta(days=30),
   until=now(),
)

print('Status -> num payments:', transactions.get_payments_count_by_status())

print('Completed payments amounts:', transactions.get_completed_payments_amounts())
print('Completed payments average:', transactions.get_completed_payments_average())
print('Completed payments total:', transactions.get_completed_payments_total())

print('Inompleted payments amounts:', transactions.get_incompleted_payments_amounts())
print('Incompleted payments total:', transactions.get_incompleted_payments_total())

print('Refunds count:', transactions.get_refunds_count())
print('Refunds amounts:', transactions.get_refunds_amounts())
print('Refunds average:', transactions.get_refunds_average())
print('Refunds total:', transactions.get_refunds_total())

print('Datetime -> estimated charge amount:', transactions.get_estimated_recurring_charge_amounts_by_time())
print('Estimated charge total:', transactions.get_estimated_recurring_charge_total())

Usually it is handy to generate reports for some periods e.g. weeks, months, or years. There is a class method which will auto-generate subsequent reports with desired frequency:

from subscriptions.v0 import SubscriptionsReport, TransactionsReport, MONTHLY, DAILY

for report in SubscriptionsReport.iter_periods(
   frequency=MONTHLY,
   since=now()-timedelta(days=90),
   until=now(),
):
   print(f'New subscriptions count for {report.since}-{report.until}: {report.get_new_count()}')

for report in TransactionsReport.iter_periods(
   frequency=DAILY,
   since=now()-timedelta(days=30),
   until=now(),
   provider_codename="paddle",
):
   print(f'Completed payments amount for {report.since}-{report.until}: {report.get_completed_payments_total()}')

Reports may be extended by subclassing the above classes.

Versioning

This package uses Semantic Versioning. TL;DR you are safe to use compatible release version specifier ~=MAJOR.MINOR in your pyproject.toml or requirements.txt.

Additionally, this package uses ApiVer to further reduce the risk of breaking changes. This means, the public API of this package is explicitly versioned, e.g. subscriptions.v0, and will not change in a backwards-incompatible way even when subscriptions.v1 is released.

Internal packages, i.e. prefixed by subscriptions._ do not share these guarantees and may change in a backwards-incompatible way at any time even in patch releases.

Development

Pre-requisites

To install required and optional dependencies into .venv, run:

uv sync --all-groups

Running tests

To fix formatting issues before committing, run:

uvx nox -s format

To run tests:

sudo docker compose -f tests/docker-compose.yml up  # start postgres database in docker

uvx nox -s lint
uvx nox -s test

Release process

uvx nox -s make_release -- X.Y.Z

where X.Y.Z is the version you're releasing and follow the printed instructions.

About

No description, website, or topics provided.

Resources

Security policy

Stars

Watchers

Forks

Releases

No releases published

Packages

No packages published

Contributors 7