A django package for managing subscription states

kogan, updated 🕥 2022-01-21 20:42:54


A django package for managing the status and terms of a subscription.

PyPI version CircleCI (all branches) Code style: black PyPI - License


  • Django: 2.2 (LTS versions only)
  • Python: 3.6+

Other Django or Python versions may work, but that is totally cooincidental and no effort is made to maintain compatibility with versions other than those listed above.


bash $ pip install django-subscriptions

Then add the following packages to INSTALLED_APPS in your settings:

INSTALLED_APPS = [ ... "django_fsm_log", "subscriptions.apps.SubscriptionsConfig", ... ]

And of course, you'll need to run the migrations:

$ python manage.py migrate

You'll also need to setup the triggers, which can be scheduled with celery or run from a management task. See the Triggers section below.


Manages subscriptions in a single table. Pushes events (signals) so that consumers can do the actual work required for that subscription, like billing.

Subscriptions are built around a Finite State Machine model, where states and allowed transitions between states are well defined on the Model. To update from one state to another, the user calls methods on the Subscription instance. This way, all side-effects and actions are contained within the state methods.

Subscription State must not be modified directly.

When a state change is triggered, the subscription will publish relevant signals so that interested parties can, themselves, react to the state changes.

State Diagram


There are 3 major API components. State change methods, signals/events, and the triggers used to begin the state changes.

State Methods

| Method | Source States | Target State | Signal Emitted | |------------------------------------------------|--------------------------------- |-------------- |---------------------- | | cancel_autorenew() | ACTIVE | EXPIRING | autorenew_canceled | | enable_autorenew() | EXPIRING | ACTIVE | autorenew_enabled | | renew() | ACTIVE,SUSPENDED | RENEWING | subscription_due | | renewed(new_end, new_ref, description=None) | ACTIVE,RENEWING,ERROR | ACTIVE | subscription_renewed| | renewal_failed(description=None) | RENEWING,ERROR | SUSPENDED | renewal_failed | | end_subscription(description=None) | ACTIVE,SUSPENDED,EXPIRING,ERROR | ENDED | subscription_ended | | state_unknown(description=None) | RENEWING | ERROR | subscription_error |


subscription.renew() may only be called if subscription.state is either ACTIVE or SUSPENDED, and will cause subscription.state to move into the RENEWING state.

The description argument is a string that can be used to persist the reason for a state change in the StateLog table (and admin inlines).


There are a bunch of triggers that are used to update subscriptions as they become due or expire. Nothing is configured to run these triggers by default. You can either call them as part of your own process, or use celery beat to execute the triggers using the tasks provided in subscriptions.tasks.

Create a new subscription:

Subscription.objects.add_subscription(start_date, end_date, reference) -> Subscription

Trigger subscriptions that are due for renewal:

Subscription.objects.trigger_renewals() -> int # number of renewals sent

Trigger subscriptions that are due to expire:

Subscription.objects.trigger_expiring() -> int # number of expirations

Trigger subscriptions that are suspended:

Subscription.objects.trigger_suspended() -> int # number of renewals

Trigger subscriptions that have been suspended for longer than timeout_hours to end (uses subscription.end date, not subscription.last_updated):

Subscription.objects.trigger_suspended_timeout(timeout_hours=48) -> int # number of suspensions

Trigger subscriptions that have been stuck in renewing state for longer than timeout_hours to be marked as an error (uses subscription.last_updated to determine the timeout):

Subscription.objects.trigger_stuck(timeout_hours=2) -> int # number of error subscriptions

If settings.SUBSCRIPTIONS_STUCK_RETRY is True, then subscriptions are moved back into the SUSPENDED state, ready to be retried. This can be useful when you have an offline process that can resolve stuck subscription issues, and there is no issue retrying the subscription.


The following tasks are defined but are not scheduled:

subscriptions.tasks.trigger_renewals subscriptions.tasks.trigger_expiring subscriptions.tasks.trigger_suspended subscriptions.tasks.trigger_suspended_timeout subscriptions.tasks.trigger_stuck

If you'd like to schedule the tasks, do so with a celery beat configuration like this:



CELERYBEAT_SCHEDULE = { "subscriptions_renewals": { "task": "subscriptions.tasks.trigger_renewals", "schedule": crontab(hour=0, minute=10), }, "subscriptions_expiring": { "task": "subscriptions.tasks.trigger_expiring", "schedule": crontab(hour=0, minute=15), }, "subscriptions_suspended": { "task": "subscriptions.tasks.trigger_suspended", "schedule": crontab(hour="3,6,9", minute=30), }, "subscriptions_suspended_timeout": { "task": "subscriptions.tasks.trigger_suspended_timeout", "schedule": crontab(hour=0, minute=40), "kwargs": {"hours": 48}, }, "subscriptions_stuck": { "task": "subscriptions.tasks.trigger_stuck", "schedule": crontab(hour="*/2", minute=50), "kwargs": {"hours": 2}, }, } ```


We use pre-commit <https://pre-commit.com/> to enforce our code style rules locally before you commit them into git. Once you install the pre-commit library (locally via pip is fine), just install the hooks::

pre-commit install -f --install-hooks

The same checks are executed on the build server, so skipping the local linting (with git commit --no-verify) will only result in a failed test build.

Current style checking tools:

  • flake8: python linting
  • isort: python import sorting
  • black: python code formatting


You must have python3.6 available on your path, as it is required for some
of the hooks.

Generating Migrations

After installing all dependencies, you can generate required migration files like so:

bash $ poetry run ipython migrate.py <nameofmigration>

Publishing a new version

  1. Bump the version number in pyproject.toml and src/subscriptions/init.py
  2. Commit and push to master
  3. From github, create a new release
  4. Name the release "v" using the version number from step 1.
  5. Publish the release
  6. If the release successfully builds, circleci will publish the new package to pypi


Bump ipython from 7.13.0 to 7.16.3

opened on 2022-01-21 20:42:53 by dependabot[bot]

Bumps ipython from 7.13.0 to 7.16.3.


Dependabot compatibility score

Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting @dependabot rebase.

Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot merge` will merge this PR after your CI passes on it - `@dependabot squash and merge` will squash and merge this PR after your CI passes on it - `@dependabot cancel merge` will cancel a previously requested merge and block automerging - `@dependabot reopen` will reopen this PR if it is closed - `@dependabot close` will close this PR and stop Dependabot recreating it. You can achieve the same result by closing it manually - `@dependabot ignore this major version` will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this minor version` will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this dependency` will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself) - `@dependabot use these labels` will set the current labels as the default for future PRs for this repo and language - `@dependabot use these reviewers` will set the current reviewers as the default for future PRs for this repo and language - `@dependabot use these assignees` will set the current assignees as the default for future PRs for this repo and language - `@dependabot use this milestone` will set the current milestone as the default for future PRs for this repo and language You can disable automated security fix PRs for this repo from the [Security Alerts page](https://github.com/kogan/django-subscriptions/network/alerts).


django-subscriptions-2.1.1 2020-12-29 04:52:03

v2.1.1 (2020-12-29)

  • Subscriptions in SUSPENDED state can now be renewed() which solves an issue with payment callbacks correctly identifying a payment success.

django-subscriptions-2.1.0 2020-06-01 10:59:07

  • Introduced a boolean setting SUBSCRIPTIONS_STUCK_RETRY, defaulting to False, that will cause stuck subscriptions to be marked as renewal_failed rather than state_unknown.

django-subscriptions-2.0.0 2020-04-19 05:35:43

v2.0.0 (2020-04-19)

  • Dropped support for Django < 2.2
  • Dropped support for Python 2
  • Made dependencies >= rather than ^ to support a greater range
  • Changed the log messages generated by the celery tasks
  • Added the pk of the subscription to the str/repr to aid debugging

django-subscriptions-1.1.0 2019-10-04 12:07:25

v1.1.0 (2019-10-04)

  • Transition methods that received a reason argument now accept a description argument, which is stored on the state log, so that reasons aren't lost. Reason is still valid, but will not be persisted to the state log. Migrate to using description, and it will log in the state log AND the reason field. state_unknown, renewal_failed, end_subscription are affected.

django-subscriptions-1.0.1 2019-09-13 05:56:16

v1.0.1 (2019-09-13)

  • SubscriptionState.choices is now sorted, so that the order is consistent between python versions, and migrations are not generated by applications.

django-subscriptions-1.0.0 2019-07-04 00:20:47

v1.0.0 (2019-07-03)

  • Breaking Change suspended_timeout now triggers for subscriptions in SUSPENDED state that are timeout_hours past the subscription.end time. It used to trigger if subscription.last_updated hadn't changed for timeout_hours, but if trigger_suspended was running daily, the subscription was constantly being updated, and trigger_suspended_timeout would never find a record to end().