Package: django-subscriptions-rt
Django-subscriptions-rt: subscriptions and payments for your Django appSupported versionsSeenoxfile.pyfor list of Python and Django versions supported (variablesPYTHON_VERSIONSandDJANGO_VERSIONS).FeaturesTODOUsageSubscriptions\|--------subscription-------------------------------------------->
start             (subscription duration)                end or inf
#
|-----------------------------|---------------------------|------>
charge   (charge period)    charge                      charge
#
|------------------------------x
quota   (quota lifetime)       quota burned
#
(quota recharge period) |------------------------------x
#
(quota recharge period) (quota recharge period) |----------------->CasesBasic Recurring. Subscription Plans are defined for the same products/services on a fixed, recurring charge.Good-Better-Best. Subscription Plans are defined with products or services sold in feature tiers, with escalating features and pricing for each tier.Per-Seat. Subscription Plans are defined with products or services sold in user/license quantity tiers, with volume pricing for each tier of user/license quantities.Metered Billing. Subscription Plans are defined where customers are billed in arrears based on usage of your product/service as calculated by your platform.TODOSupport more granular plans, as described inGoogle Play docsGrace periodHold / pausePlans, tiers & featuresDefault planUsepip install django-subscriptions-rt[default_plan]to install constance as dependency.Setup default plan like this:INSTALLED_APPS=[...'constance','constance.backends.database',]CONSTANCE_BACKEND='constance.backends.database.DatabaseBackend'CONSTANCE_CONFIG={'SUBSCRIPTIONS_DEFAULT_PLAN_ID':(0,'Default plan ID',int),}Changing default plan value will adjust default subscriptions automatically.Trial periodIt is possible to postpone user's first payment by some timedelta. When creating a subscription, setinitial_charge_offset, which will shift all charge dates by this offset:fromdateutil.relativedeltaimportrelativedeltafromdjango.utils.timezoneimportnownow_=now()offset=relativedelta(days=7)subscription=Subscription.objects.create(# ...start=now_,initial_charge_offset=offset,)assertnext(subscription.iter_charge_dates())==now_+offsetThere is a handy settingSUBSCRIPTIONS_TRIAL_PERIOD:fromdateutil.relativedeltaimportrelativedeltaSUBSCRIPTIONS_TRIAL_PERIOD=relativedelta(days=7)Ifsubscription is created viaPOSTrequest toSubscriptionSelectView(which by default is at path/subscribe/),selected plan'scharge_amountis non-zero,selected plan is recurring,user hasn't completed ANY payments (even zero ones),user never had any recurring subscriptions,then charge amount will become zero, thus user only enters CC info without paying anything. The set of rules mentioned above may be changed by redefiningget_trial_periodmethod.Remember that "external" providers may not respect this setting at all.Internally, trial period works like this:User asks API to create new subscriptionIf situation is eligible for trial period, a new zero-amountSubscriptionPaymentis requested, as well as correspondingSubscriptionis created with zero duration:------[=======(Payment, cost=0)=======]-------->
                                      ^- trial period end
------[]--------------------------------------->
      ^--- Subscription with initial_charge_offset = trial_periodUsers enters CC details for the payment -> it gets confirmed; when the payment is confirmed, its subscription is extended till the end of trial period:------[=======(Payment, cost=0)=======]-------->
                                      ^- trial period end
------[===Subscription================]-------->
                                       ^--- Here real price will be chargedCacheCache is required for fast resource calculations.settings.CACHES['subscriptions']={'BACKEND':'django.core.cache.backends.locmem.LocMemCache','LOCATION':'subscriptions',}MiddlewareIt is costy - calculates resources for each authenticated user's request! May be handy in html templates, but better not to use it too much.HumanizePayment providersThere is a clear distinction between "self-hosted" vs "external" subscription solutions. "Self-hosted" means that everything (plans, upgrade/downgrade rules, charging schedules etc) is set up on backend side, and payment provider is only used to store CC information and charge money when  backend asks to do so. However, some payment providers don't allow that, thus it is called "external" - everything is configured in provider dashboard, and it's a provider who does periodic charges etc. The only thing backend does - receives webhook (or periodically fetches information itself, or both altogether), and updates subscriptions (changes timespan, pauses, resumes etc)."Self-hosted" provider means that backend is the command center, and it is capable of implementing custom logic of any complexity. The drawback here is that backend is responsible for periodic checks and charges, which requires monitoring and extra setup."External" providers are limited to whatever logic is provided by third-party developers. However, it is much easier to setup and maintain it.PaddleUsespaddle.comas payment provider.This implementation is self-hosted. Paddle does not provide a lot of control over charges, however it has 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 storeWorkflow from the mobile application perspective:App fetches data from backend's/plans/endpoint. Each plan will have metadata entryapple_in_app->string, wherestringrepresents an
AppleProduct identifierWhen user performs a purchase,
appfetches the receipt dataReceipt is sent to backend usingPOST /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 forIf everything is fine, newSubscriptionPaymentandSubscriptionwill be created and returned to the app. The json
returned to the app is the same as when querying/payments/<uid>/endpointIn 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 refundWARNINGServer 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 oneWorkflow from the backend perspective – handling client operation:Server side part of
thevalidationprocess is performedUser provided from the authentication is the one having aSubscriptionandSubscriptionPaymentcreated forTransaction idfor this operation is kept withSubscriptionPaymentWorkflow from the backend perspective – handling renewals:This assumes that the/webhook/apple_in_app/endpoint is assigned
asnotifications serviceCurrently, onlyversion 2of 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 futureRenewal 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 performedNewSubscriptionPaymentis created, using expiration date provided in the notificationGoogle in-app purchaseAutomatic 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 syncChanges made on BE won't affect Google Play purchases, so don't touch it. All information ispulledfrom 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)Backend pushes actual plans to Google Play usingSubscriptions and In-app Purchases API⁠.Mobile app fetches data from backend's/plans/endpoint. Each plan will have metadata entrygoogle_in_app->dict, wheredictis aSubscription.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 fromPurchases.subscriptionsv2:getand verifies that the purchase is legit.If everything is fine, newSubscriptionPaymentandSubscriptionare created and returned to the app. The json returned to the app is the same as when quering/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.How to testReportsThis 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)(untilis not included).fromdjango.utils.timezoneimportnowfromdatetimeimporttimedeltafromsubscriptions.reportsimportSubscriptionsReport,TransactionsReportfromsubscriptions.providersimportget_providersubscriptions=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=get_provider().codename,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:fromsubscriptions.reportsimportSubscriptionsReport,TransactionsReport,MONTHLY,DAILYforreportinSubscriptionsReport.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()}')forreportinTransactionsReport.iter_periods(frequency=DAILY,since=now()-timedelta(days=30),until=now(),provider_codename=get_provider().codename,):print(f'Completed payments amount for{report.since}-{report.until}:{report.get_completed_payments_total()}')Reports may be extended by subclassing the above classes.Development setupInstall thesubscriptionsas a development module usingpipinstall-e.Set environmental variablesexportPOSTGRES_DB=postgresexportPOSTGRES_HOST=localhostexportPOSTGRES_PORT=8432exportPOSTGRES_USER=postgresexportPOSTGRES_PASSWORD=12345Start database (in another terminal with the same variables) withdocker-compose-fdemo/docker-compose.ymlupPrepare everything by runningcddemo
pythonmanage.pymigrate
pythonmanage.pyrunserverRun tests withnox-stest