See ci.yml for matrix of supported Python and Django versions.
Important
Only Postgres database is supported (due to PG_ADVISORY_LOCK requirement).
# 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 is required for fast resource calculations.
settings.CACHES['subscriptions'] = {
'BACKEND': 'django.core.cache.backends.locmem.LocMemCache',
'LOCATION': 'subscriptions',
}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
# 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 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.
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 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,
)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
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
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
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.
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 + offsetThis 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
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.
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
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")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
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")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(...)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 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
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
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
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
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 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.
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
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
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
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
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
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
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
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
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_offsetto 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 = [
# ...
"subscriptions.v0.middleware.SubscriptionsMiddleware",
]This will add
request.user.active_subscriptions: QuerySet[Subscription]andrequest.user.quotas: dict[Resource, int]
for each authenticated user's request, so it may be handy for development but not ready for production.
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.
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.
Workflow from the mobile application perspective:
- App fetches data from backend's
/plans/endpoint. Each plan will have metadata entryapple_in_app->string, wherestringrepresents an Apple Product identifier - When user performs a purchase, app fetches the receipt data
- 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 - If everything is fine, new
SubscriptionPaymentandSubscriptionwill be created and returned to the app. The json returned to the app is the same as when querying/payments/<uid>/endpoint - 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
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:
- Server side part of the validation process is performed
- User provided from the authentication is the one having a
SubscriptionandSubscriptionPaymentcreated for - 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.
- 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
- 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
- New
SubscriptionPaymentis created, using expiration date provided in the notification
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:
- Backend pushes actual plans to Google Play using Subscriptions and In-app Purchases API.
- Mobile app fetches data from backend's
/plans/endpoint. Each plan will have metadata entrygoogle_in_app->dict, wheredictis a Subscription. - 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. - Backend fetches data from Purchases.subscriptionsv2:get and verifies that the purchase is legit.
- If everything is fine, new
SubscriptionPaymentandSubscriptionare created and returned to the app. The json returned to the app is the same as when querying/payments/<uid>/endpoint. - App can now use the credits included in the plan.
- Any other subscription changes are handled by backend automatically by interacting with google APIs.
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.
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.
- uv
- nox - install globally with
uv tool install --with pyyaml nox - docker and docker compose
To install required and optional dependencies into .venv, run:
uv sync --all-groupsTo fix formatting issues before committing, run:
uvx nox -s formatTo run tests:
sudo docker compose -f tests/docker-compose.yml up # start postgres database in docker
uvx nox -s lint
uvx nox -s testuvx nox -s make_release -- X.Y.Zwhere X.Y.Z is the version you're releasing and follow the printed instructions.