Skip to content

Commit f64b0d1

Browse files
authored
Subscriptions: use djstripe for products/features (#10238)
Instead of relying on our subscription models, we are now relying on the djstripe models, but the features of each subscription are still attached to our subscription models. In the new modeling, we have features attached to stripe products. I'm using some data classes to make the representation instead of using plain dictionaries, they are more verbose than a plain dictionary, but are easier to manipulate in the code. After this change has been deployed, we won't be needing our subscription models anymore. The available products are defined in .com, since in .org we don't have subscriptions. If we feel like we can have this in the DB, we can also switch the data classes to be Django models. Closes #9313
1 parent e766968 commit f64b0d1

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

41 files changed

+649
-306
lines changed

docs/dev/index.rst

+1
Original file line numberDiff line numberDiff line change
@@ -27,5 +27,6 @@ or taking the open source Read the Docs codebase for your own custom installatio
2727
i18n
2828
server-side-search
2929
search-integration
30+
subscriptions
3031
settings
3132
tests

docs/dev/subscriptions.rst

+91
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
Subscriptions
2+
=============
3+
4+
Subscriptions are available on |com_brand|,
5+
we make use of Stripe to handle the payments and subscriptions.
6+
We use dj-stripe to handle the integration with Stripe.
7+
8+
Local testing
9+
-------------
10+
11+
To test subscriptions locally, you need to have access to the Stripe account,
12+
and define the following settings with the keys from Stripe test mode:
13+
14+
- ``STRIPE_SECRET``: https://dashboard.stripe.com/test/apikeys
15+
- ``STRIPE_TEST_SECRET_KEY``: https://dashboard.stripe.com/test/apikeys
16+
- ``DJSTRIPE_WEBHOOK_SECRET``: https://dashboard.stripe.com/test/webhooks
17+
18+
To test the webhook locally, you need to run your local instance with ngrok, for example:
19+
20+
.. code-block:: bash
21+
22+
ngrok http 80
23+
inv docker.up --http-domain xxx.ngrok.io
24+
25+
If this is your first time setting up subscriptions, you will to re-sync djstripe with Stripe:
26+
27+
.. code-block:: bash
28+
29+
inv docker.manage djstripe_sync_models
30+
31+
The subscription settings (``RTD_PRODUCTS``) already mapped to match the Stripe prices from the test mode.
32+
To subscribe to any plan, you can use any `test card from Stripe <https://stripe.com/docs/testing>`__,
33+
for example: ``4242 4242 4242 4242`` (use any future date and any value for the other fields).
34+
35+
Modeling
36+
--------
37+
38+
Subscriptions are attached to an organization (customer),
39+
and can have multiple products attached to it.
40+
A product can have multiple prices, usually monthly and yearly.
41+
42+
When a user subscribes to a plan (product), they are subscribing to a price of a product,
43+
for example, the monthly price of the "Basic plan" product.
44+
45+
A subscription has a "main" product (``RTDProduct(extra=False)``),
46+
and can have several "extra" products (``RTDProduct(extra=True)``).
47+
For example, an organization can have a subscription with a "Basic Plan" product, and an "Extra builder" product.
48+
49+
Each product is mapped to a set of features (``RTD_PRODUCTS``) that the user will have access to
50+
(different prices of the same product have the same features).
51+
If a subscription has multiple products, the features are multiplied by the quantity and added together.
52+
For example, if a subscription has a "Basic Plan" product with a two concurrent builders,
53+
and an "Extra builder" product with quantity three, the total number of concurrent builders the
54+
organization has will be five.
55+
56+
Life cycle of a subscription
57+
----------------------------
58+
59+
When a new organization is created, a stripe customer is created for that organization,
60+
and this customer is subscribed to the trial product (``RTD_ORG_DEFAULT_STRIPE_SUBSCRIPTION_PRICE``).
61+
62+
After the trial period is over, the subscription is canceled,
63+
and their organization is disabled.
64+
65+
During or after the trial a user can upgrade their subscription to a paid plan
66+
(``RTDProduct(listed=True)``).
67+
68+
Custom products
69+
---------------
70+
71+
We provide 3 paid plans that users can subscribe to: Basic, Advanced and Pro.
72+
Additionally, we provide an Enterprise plan, this plan is customized for each customer,
73+
and it's manually created by the RTD core team.
74+
75+
To create a custom plan, you need to create a new product in Stripe,
76+
and add the product id to the ``RTD_PRODUCTS`` setting mapped to the features that the plan will provide.
77+
After that, you can create a subscription for the organization with the custom product,
78+
our appliction will automatically relate this new product to the organization.
79+
80+
Extra products
81+
--------------
82+
83+
We have one extra product: Extra builder.
84+
85+
To create a new extra product, you need to create a new product in Stripe,
86+
and add the product id to the ``RTD_PRODUCTS`` setting mapped to the features that the
87+
extra product will provide, this product should have the ``extra`` attribute set to ``True``.
88+
89+
To subscribe an organization to an extra product,
90+
you just need to add the product to its subscription with the desired quantity,
91+
our appliction will automatically relate this new product to the organization.

readthedocs/api/v3/permissions.py

+2-2
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
from readthedocs.core.utils.extend import SettingsOverrideObject
44
from readthedocs.subscriptions.constants import TYPE_EMBED_API
5-
from readthedocs.subscriptions.models import PlanFeature
5+
from readthedocs.subscriptions.products import get_feature
66

77

88
class HasEmbedAPIAccess(BasePermission):
@@ -24,7 +24,7 @@ class HasEmbedAPIAccess(BasePermission):
2424
def has_permission(self, request, view):
2525
project = view._get_project()
2626
# The project is None when the is requesting a section from an external site.
27-
if project and not PlanFeature.objects.has_feature(project, TYPE_EMBED_API):
27+
if project and not get_feature(project, feature_type=TYPE_EMBED_API):
2828
return False
2929
return True
3030

readthedocs/api/v3/tests/test_builds.py

+4-3
Original file line numberDiff line numberDiff line change
@@ -4,16 +4,17 @@
44
from django.urls import reverse
55

66
from readthedocs.subscriptions.constants import TYPE_CONCURRENT_BUILDS
7+
from readthedocs.subscriptions.products import RTDProductFeature
78

89
from .mixins import APIEndpointMixin
910

1011

1112
@override_settings(
1213
RTD_ALLOW_ORGANIZATIONS=False,
1314
ALLOW_PRIVATE_REPOS=False,
14-
RTD_DEFAULT_FEATURES={
15-
TYPE_CONCURRENT_BUILDS: 4,
16-
},
15+
RTD_DEFAULT_FEATURES=dict(
16+
[RTDProductFeature(TYPE_CONCURRENT_BUILDS, value=4).to_item()]
17+
),
1718
)
1819
@mock.patch('readthedocs.projects.tasks.builds.update_docs_task', mock.MagicMock())
1920
class BuildsEndpointTests(APIEndpointMixin):

readthedocs/builds/tests/test_build_queryset.py

+4-3
Original file line numberDiff line numberDiff line change
@@ -5,15 +5,16 @@
55
from readthedocs.organizations.models import Organization
66
from readthedocs.projects.models import Project
77
from readthedocs.subscriptions.constants import TYPE_CONCURRENT_BUILDS
8+
from readthedocs.subscriptions.products import RTDProductFeature
89

910

1011
@pytest.mark.django_db
1112
class TestBuildQuerySet:
1213
@pytest.fixture(autouse=True)
1314
def setup_method(self, settings):
14-
settings.RTD_DEFAULT_FEATURES = {
15-
TYPE_CONCURRENT_BUILDS: 4,
16-
}
15+
settings.RTD_DEFAULT_FEATURES = dict(
16+
[RTDProductFeature(type=TYPE_CONCURRENT_BUILDS, value=4).to_item()]
17+
)
1718

1819
def test_concurrent_builds(self):
1920
project = fixture.get(

readthedocs/core/resolver.py

+2-2
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
from readthedocs.core.utils.extend import SettingsOverrideObject
99
from readthedocs.core.utils.url import unsafe_join_url_path
1010
from readthedocs.subscriptions.constants import TYPE_CNAME
11-
from readthedocs.subscriptions.models import PlanFeature
11+
from readthedocs.subscriptions.products import get_feature
1212

1313
log = structlog.get_logger(__name__)
1414

@@ -416,7 +416,7 @@ def _use_subdomain(self):
416416

417417
def _use_cname(self, project):
418418
"""Test if to allow direct serving for project on CNAME."""
419-
return PlanFeature.objects.has_feature(project, type=TYPE_CNAME)
419+
return bool(get_feature(project, feature_type=TYPE_CNAME))
420420

421421

422422
class Resolver(SettingsOverrideObject):

readthedocs/embed/tests/test_api.py

+5-4
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
from readthedocs.projects.constants import MKDOCS, PUBLIC
1414
from readthedocs.projects.models import Project
1515
from readthedocs.subscriptions.constants import TYPE_EMBED_API
16+
from readthedocs.subscriptions.products import RTDProductFeature
1617

1718
data_path = Path(__file__).parent.resolve() / 'data'
1819

@@ -34,10 +35,10 @@ def setup_method(self, settings):
3435
self.version.save()
3536

3637
settings.USE_SUBDOMAIN = True
37-
settings.PUBLIC_DOMAIN = 'readthedocs.io'
38-
settings.RTD_DEFAULT_FEATURES = {
39-
TYPE_EMBED_API: 1,
40-
}
38+
settings.PUBLIC_DOMAIN = "readthedocs.io"
39+
settings.RTD_DEFAULT_FEATURES = dict(
40+
[RTDProductFeature(TYPE_EMBED_API).to_item()]
41+
)
4142

4243
def get(self, client, *args, **kwargs):
4344
"""Wrapper around ``client.get`` to be overridden in the proxied api tests."""

readthedocs/embed/v3/tests/test_access.py

+2-3
Original file line numberDiff line numberDiff line change
@@ -12,15 +12,14 @@
1212
from readthedocs.projects.constants import PRIVATE, PUBLIC
1313
from readthedocs.projects.models import Project
1414
from readthedocs.subscriptions.constants import TYPE_EMBED_API
15+
from readthedocs.subscriptions.products import RTDProductFeature
1516

1617

1718
@override_settings(
1819
USE_SUBDOMAIN=True,
1920
PUBLIC_DOMAIN="readthedocs.io",
2021
RTD_ALLOW_ORGAZATIONS=False,
21-
RTD_DEFAULT_FEATURES={
22-
TYPE_EMBED_API: 1,
23-
},
22+
RTD_DEFAULT_FEATURES=dict([RTDProductFeature(TYPE_EMBED_API).to_item()]),
2423
)
2524
@mock.patch("readthedocs.embed.v3.views.build_media_storage")
2625
class TestEmbedAPIV3Access(TestCase):

readthedocs/embed/v3/tests/test_internal_pages.py

+4-3
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111

1212
from readthedocs.projects.models import Project
1313
from readthedocs.subscriptions.constants import TYPE_EMBED_API
14+
from readthedocs.subscriptions.products import RTDProductFeature
1415

1516
from .utils import srcdir
1617

@@ -24,9 +25,9 @@ def setup_method(self, settings):
2425
settings.USE_SUBDOMAIN = True
2526
settings.PUBLIC_DOMAIN = 'readthedocs.io'
2627
settings.RTD_EMBED_API_EXTERNAL_DOMAINS = []
27-
settings.RTD_DEFAULT_FEATURES = {
28-
TYPE_EMBED_API: 1,
29-
}
28+
settings.RTD_DEFAULT_FEATURES = dict(
29+
[RTDProductFeature(TYPE_EMBED_API).to_item()]
30+
)
3031

3132
self.api_url = reverse('embed_api_v3')
3233

readthedocs/organizations/models.py

+12-11
Original file line numberDiff line numberDiff line change
@@ -136,15 +136,17 @@ def get_or_create_stripe_subscription(self):
136136
# This only happens during development.
137137
log.warning("No default subscription created.")
138138
return None
139+
return self.get_stripe_subscription()
139140

141+
def get_stripe_subscription(self):
140142
# Active subscriptions take precedence over non-active subscriptions,
141143
# otherwise we return the must recently created subscription.
142144
active_subscription = self.stripe_customer.subscriptions.filter(
143145
status=SubscriptionStatus.active
144146
).first()
145147
if active_subscription:
146148
return active_subscription
147-
return self.stripe_customer.subscriptions.latest()
149+
return self.stripe_customer.subscriptions.order_by("created").last()
148150

149151
def get_absolute_url(self):
150152
return reverse('organization_detail', args=(self.slug,))
@@ -157,7 +159,7 @@ def users(self):
157159
def members(self):
158160
return AdminPermission.members(self)
159161

160-
def save(self, *args, **kwargs): # pylint: disable=signature-differs
162+
def save(self, *args, **kwargs):
161163
if not self.slug:
162164
self.slug = slugify(self.name)
163165

@@ -173,7 +175,6 @@ def get_stripe_metadata(self):
173175
"org:slug": self.slug,
174176
}
175177

176-
# pylint: disable=no-self-use
177178
def add_member(self, user, team):
178179
"""
179180
Add member to organization team.
@@ -278,7 +279,7 @@ def get_absolute_url(self):
278279
def __str__(self):
279280
return self.name
280281

281-
def save(self, *args, **kwargs): # pylint: disable=signature-differs
282+
def save(self, *args, **kwargs):
282283
if not self.slug:
283284
self.slug = slugify(self.name)
284285
super().save(*args, **kwargs)
@@ -319,7 +320,7 @@ def __str__(self):
319320
team=self.team,
320321
)
321322

322-
def save(self, *args, **kwargs): # pylint: disable=signature-differs
323+
def save(self, *args, **kwargs):
323324
hash_ = salted_hmac(
324325
# HMAC key per applications
325326
'.'.join([self.__module__, self.__class__.__name__]),
@@ -345,12 +346,12 @@ def migrate(self):
345346
content_type = ContentType.objects.get_for_model(self.team)
346347
invitation, created = Invitation.objects.get_or_create(
347348
token=self.hash,
348-
defaults=dict(
349-
from_user=owner,
350-
to_email=self.email,
351-
content_type=content_type,
352-
object_id=self.team.pk,
353-
),
349+
defaults={
350+
"from_user": owner,
351+
"to_email": self.email,
352+
"content_type": content_type,
353+
"object_id": self.team.pk,
354+
},
354355
)
355356
self.teammember_set.all().delete()
356357
return invitation, created

readthedocs/organizations/tests/test_views.py

+7-12
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
from readthedocs.projects.models import Project
1818
from readthedocs.rtd_tests.base import RequestFactoryTestMixin
1919
from readthedocs.subscriptions.constants import TYPE_AUDIT_LOGS
20+
from readthedocs.subscriptions.products import RTDProductFeature
2021

2122

2223
@override_settings(RTD_ALLOW_ORGANIZATIONS=True)
@@ -150,9 +151,9 @@ def test_add_owner(self):
150151

151152
@override_settings(
152153
RTD_ALLOW_ORGANIZATIONS=True,
153-
RTD_DEFAULT_FEATURES={
154-
TYPE_AUDIT_LOGS: 90,
155-
},
154+
RTD_DEFAULT_FEATURES=dict(
155+
[RTDProductFeature(type=TYPE_AUDIT_LOGS, value=90).to_item()]
156+
),
156157
)
157158
class OrganizationSecurityLogTests(TestCase):
158159

@@ -380,9 +381,7 @@ def test_organization_signup(self):
380381

381382
@override_settings(
382383
RTD_ALLOW_ORGANIZATIONS=True,
383-
RTD_DEFAULT_FEATURES={
384-
TYPE_AUDIT_LOGS: 90,
385-
},
384+
RTD_DEFAULT_FEATURES=dict([RTDProductFeature(TYPE_AUDIT_LOGS, value=90).to_item()]),
386385
)
387386
class OrganizationUnspecifiedChooser(TestCase):
388387
def setUp(self):
@@ -428,9 +427,7 @@ def test_choose_organization_edit(self):
428427

429428
@override_settings(
430429
RTD_ALLOW_ORGANIZATIONS=True,
431-
RTD_DEFAULT_FEATURES={
432-
TYPE_AUDIT_LOGS: 90,
433-
},
430+
RTD_DEFAULT_FEATURES=dict([RTDProductFeature(TYPE_AUDIT_LOGS, value=90).to_item()]),
434431
)
435432
class OrganizationUnspecifiedSingleOrganizationRedirect(TestCase):
436433
def setUp(self):
@@ -456,9 +453,7 @@ def test_unspecified_slug_redirects_to_organization_edit(self):
456453

457454
@override_settings(
458455
RTD_ALLOW_ORGANIZATIONS=True,
459-
RTD_DEFAULT_FEATURES={
460-
TYPE_AUDIT_LOGS: 90,
461-
},
456+
RTD_DEFAULT_FEATURES=dict([RTDProductFeature(TYPE_AUDIT_LOGS, value=90).to_item()]),
462457
)
463458
class OrganizationUnspecifiedNoOrganizationRedirect(TestCase):
464459
def setUp(self):

0 commit comments

Comments
 (0)