Skip to content

Commit ac1dc7c

Browse files
authored
Use djstripe models for organization subscriptions (#9486)
1 parent 4ff3917 commit ac1dc7c

File tree

6 files changed

+175
-76
lines changed

6 files changed

+175
-76
lines changed
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
# Generated by Django 3.2.13 on 2022-08-10 02:21
2+
3+
import django.db.models.deletion
4+
from django.db import migrations, models
5+
6+
7+
class Migration(migrations.Migration):
8+
9+
dependencies = [
10+
("djstripe", "0010_alter_customer_balance"),
11+
("organizations", "0009_update_meta_options"),
12+
]
13+
14+
operations = [
15+
migrations.AddField(
16+
model_name="historicalorganization",
17+
name="stripe_customer",
18+
field=models.ForeignKey(
19+
blank=True,
20+
db_constraint=False,
21+
null=True,
22+
on_delete=django.db.models.deletion.DO_NOTHING,
23+
related_name="+",
24+
to="djstripe.customer",
25+
verbose_name="Stripe customer",
26+
),
27+
),
28+
migrations.AddField(
29+
model_name="organization",
30+
name="stripe_customer",
31+
field=models.OneToOneField(
32+
blank=True,
33+
null=True,
34+
on_delete=django.db.models.deletion.SET_NULL,
35+
related_name="rtd_organization",
36+
to="djstripe.customer",
37+
verbose_name="Stripe customer",
38+
),
39+
),
40+
]

readthedocs/organizations/models.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,14 @@ class Organization(models.Model):
9393
blank=True,
9494
null=True,
9595
)
96+
stripe_customer = models.OneToOneField(
97+
"djstripe.Customer",
98+
verbose_name=_("Stripe customer"),
99+
on_delete=models.SET_NULL,
100+
related_name="rtd_organization",
101+
null=True,
102+
blank=True,
103+
)
96104

97105
# Managers
98106
objects = OrganizationQuerySet.as_manager()
@@ -122,8 +130,18 @@ def save(self, *args, **kwargs): # pylint: disable=signature-differs
122130
if not self.slug:
123131
self.slug = slugify(self.name)
124132

133+
if self.stripe_customer:
134+
self.stripe_id = self.stripe_customer.id
135+
125136
super().save(*args, **kwargs)
126137

138+
def get_stripe_metadata(self):
139+
"""Get metadata for the stripe account."""
140+
return {
141+
"org:id": self.id,
142+
"org:slug": self.slug,
143+
}
144+
127145
# pylint: disable=no-self-use
128146
def add_member(self, user, team):
129147
"""

readthedocs/settings/base.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -797,6 +797,12 @@ def DOCKER_LIMITS(self):
797797
DJSTRIPE_FOREIGN_KEY_TO_FIELD = "id"
798798
DJSTRIPE_USE_NATIVE_JSONFIELD = True # We recommend setting to True for new installations
799799

800+
# Disable adding djstripe metadata to the Customer objects.
801+
# We are managing the subscriber relationship by ourselves,
802+
# since we have subscriptions attached to an organization or gold user
803+
# we can't make use of the DJSTRIPE_SUBSCRIBER_MODEL setting.
804+
DJSTRIPE_SUBSCRIBER_CUSTOMER_KEY = None
805+
800806
# Do Not Track support
801807
DO_NOT_TRACK_ENABLED = False
802808

readthedocs/subscriptions/managers.py

Lines changed: 22 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,11 @@
22

33
from datetime import datetime
44

5-
import stripe
65
import structlog
76
from django.conf import settings
87
from django.db import models
98
from django.utils import timezone
9+
from djstripe.enums import SubscriptionStatus
1010

1111
from readthedocs.core.history import set_change_reason
1212
from readthedocs.subscriptions.utils import get_or_create_stripe_customer
@@ -44,33 +44,33 @@ def get_or_create_default_subscription(self, organization):
4444
return None
4545

4646
stripe_customer = get_or_create_stripe_customer(organization)
47-
stripe_subscription = stripe.Subscription.create(
48-
customer=stripe_customer.id,
49-
items=[{"price": plan.stripe_id}],
50-
trial_period_days=plan.trial,
51-
)
52-
# Stripe renamed ``start`` to ``start_date``,
53-
# our API calls will return the new object,
54-
# but webhooks will still return the old object
55-
# till we change the default version.
56-
# TODO: use stripe_subscription.start_date after the webhook version has been updated.
57-
start_date = getattr(
58-
stripe_subscription, "start", getattr(stripe_subscription, "start_date")
47+
stripe_subscriptions = stripe_customer.subscriptions.exclude(
48+
status=SubscriptionStatus.canceled
5949
)
50+
if stripe_subscriptions.count() > 1:
51+
log.warning(
52+
"Customer with more than one active subscription.",
53+
stripe_customer=stripe_customer.id,
54+
)
55+
56+
stripe_subscription = stripe_subscriptions.last()
57+
if not stripe_subscription:
58+
# TODO: djstripe 2.6.x doesn't return the subscription object
59+
# on subscribe(), but 2.7.x (unreleased) does!
60+
stripe_customer.subscribe(
61+
items=[{"price": plan.stripe_id}],
62+
trial_period_days=plan.trial,
63+
)
64+
stripe_subscription = stripe_customer.subscriptions.latest()
65+
6066
return self.create(
6167
plan=plan,
6268
organization=organization,
6369
stripe_id=stripe_subscription.id,
6470
status=stripe_subscription.status,
65-
start_date=timezone.make_aware(
66-
datetime.fromtimestamp(int(start_date)),
67-
),
68-
end_date=timezone.make_aware(
69-
datetime.fromtimestamp(int(stripe_subscription.current_period_end)),
70-
),
71-
trial_end_date=timezone.make_aware(
72-
datetime.fromtimestamp(int(stripe_subscription.trial_end)),
73-
),
71+
start_date=stripe_subscription.start_date,
72+
end_date=stripe_subscription.current_period_end,
73+
trial_end_date=stripe_subscription.trial_end,
7474
)
7575

7676
def update_from_stripe(self, *, rtd_subscription, stripe_subscription):

readthedocs/subscriptions/tests/test_views.py

Lines changed: 51 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,14 @@
11
from unittest import mock
22

33
import requests_mock
4-
import stripe
54
from django.conf import settings
65
from django.contrib.auth.models import User
76
from django.test import TestCase, override_settings
87
from django.urls import reverse
8+
from django.utils import timezone
99
from django_dynamic_fixture import get
10+
from djstripe import models as djstripe
11+
from djstripe.enums import SubscriptionStatus
1012

1113
from readthedocs.organizations.models import Organization
1214
from readthedocs.subscriptions.models import Plan, Subscription
@@ -56,65 +58,81 @@ def test_manage_subscription(self, mock_request):
5658
fetch_redirect_response=False,
5759
)
5860

59-
@mock.patch("readthedocs.subscriptions.managers.stripe.Subscription.create")
60-
@mock.patch("readthedocs.subscriptions.utils.stripe.Customer.retrieve")
61+
@mock.patch(
62+
"readthedocs.subscriptions.utils.stripe.Customer.modify", new=mock.MagicMock
63+
)
64+
@mock.patch("readthedocs.subscriptions.utils.djstripe.Customer._get_or_retrieve")
6165
@mock.patch("readthedocs.subscriptions.utils.stripe.Customer.create")
6266
def test_user_without_subscription(
63-
self, customer_create_mock, customer_retrieve_mock, subscription_create_mock
67+
self, customer_create_mock, customer_retrieve_mock
6468
):
65-
subscription_create_mock.return_value = stripe.Subscription.construct_from(
66-
values={
67-
"id": "sub_a1b2c3",
68-
"start_date": 1610532715.085267,
69-
"current_period_end": 1610532715.085267,
70-
"trial_end": 1610532715.085267,
71-
"status": "active",
72-
},
73-
key=None,
69+
stripe_customer = get(
70+
djstripe.Customer,
71+
id="cus_a1b2c3",
7472
)
75-
customer_retrieve_mock.return_value = stripe.Customer.construct_from(
76-
values={"id": "cus_a1b2c3"},
77-
key=None,
73+
stripe_subscription = get(
74+
djstripe.Subscription,
75+
id="sub_a1b2c3",
76+
start_date=timezone.now(),
77+
current_period_end=timezone.now() + timezone.timedelta(days=30),
78+
trial_end=timezone.now() + timezone.timedelta(days=30),
79+
status=SubscriptionStatus.active,
80+
customer=stripe_customer,
7881
)
82+
stripe_customer.subscribe = mock.MagicMock()
83+
stripe_customer.subscribe.return_value = stripe_subscription
84+
customer_retrieve_mock.return_value = stripe_customer
85+
7986
self.subscription.delete()
8087
self.organization.refresh_from_db()
8188
self.assertFalse(hasattr(self.organization, 'subscription'))
89+
self.assertIsNone(self.organization.stripe_customer)
90+
8291
resp = self.client.get(reverse('subscription_detail', args=[self.organization.slug]))
8392
self.assertEqual(resp.status_code, 200)
8493
self.organization.refresh_from_db()
8594
subscription = self.organization.subscription
8695
self.assertEqual(subscription.status, 'active')
8796
self.assertEqual(subscription.stripe_id, 'sub_a1b2c3')
97+
self.assertEqual(self.organization.stripe_customer, stripe_customer)
8898
customer_retrieve_mock.assert_called_once()
8999
customer_create_mock.assert_not_called()
90100

91-
@mock.patch("readthedocs.subscriptions.managers.stripe.Subscription.create")
92-
@mock.patch("readthedocs.subscriptions.utils.stripe.Customer.retrieve")
101+
@mock.patch(
102+
"readthedocs.subscriptions.utils.djstripe.Customer.sync_from_stripe_data"
103+
)
104+
@mock.patch("readthedocs.subscriptions.utils.djstripe.Customer._get_or_retrieve")
93105
@mock.patch("readthedocs.subscriptions.utils.stripe.Customer.create")
94106
def test_user_without_subscription_and_customer(
95-
self, customer_create_mock, customer_retrieve_mock, subscription_create_mock
107+
self, customer_create_mock, customer_retrieve_mock, sync_from_stripe_data_mock
96108
):
97-
subscription_create_mock.return_value = stripe.Subscription.construct_from(
98-
values={
99-
"id": "sub_a1b2c3",
100-
"start_date": 1610532715.085267,
101-
"current_period_end": 1610532715.085267,
102-
"trial_end": 1610532715.085267,
103-
"status": "active",
104-
},
105-
key=None,
109+
stripe_customer = get(
110+
djstripe.Customer,
111+
id="cus_a1b2c3",
106112
)
107-
customer_create_mock.return_value = stripe.Customer.construct_from(
108-
values={"id": "cus_a1b2c3"},
109-
key=None,
113+
stripe_subscription = get(
114+
djstripe.Subscription,
115+
id="sub_a1b2c3",
116+
start_date=timezone.now(),
117+
current_period_end=timezone.now() + timezone.timedelta(days=30),
118+
trial_end=timezone.now() + timezone.timedelta(days=30),
119+
status=SubscriptionStatus.active,
120+
customer=stripe_customer,
110121
)
122+
stripe_customer.subscribe = mock.MagicMock()
123+
stripe_customer.subscribe.return_value = stripe_subscription
124+
customer_retrieve_mock.return_value = None
125+
sync_from_stripe_data_mock.return_value = stripe_customer
126+
111127
# When stripe_id is None, a new customer is created.
112128
self.organization.stripe_id = None
113129
self.organization.save()
114130
self.subscription.delete()
115131
self.organization.refresh_from_db()
116132
self.assertFalse(hasattr(self.organization, 'subscription'))
117133
self.assertIsNone(self.organization.stripe_id)
134+
self.assertIsNone(self.organization.stripe_customer)
135+
118136
customer_retrieve_mock.reset_mock()
119137
resp = self.client.get(reverse('subscription_detail', args=[self.organization.slug]))
120138
self.assertEqual(resp.status_code, 200)
@@ -123,9 +141,9 @@ def test_user_without_subscription_and_customer(
123141
self.assertEqual(subscription.status, 'active')
124142
self.assertEqual(subscription.stripe_id, 'sub_a1b2c3')
125143
self.assertEqual(self.organization.stripe_id, 'cus_a1b2c3')
144+
self.assertEqual(self.organization.stripe_customer, stripe_customer)
126145
customer_create_mock.assert_called_once()
127-
# Called from a signal of .save()
128-
customer_retrieve_mock.assert_called_once()
146+
customer_retrieve_mock.assert_not_called()
129147

130148
def test_user_with_canceled_subscription(self):
131149
self.subscription.status = 'canceled'

readthedocs/subscriptions/utils.py

Lines changed: 38 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,53 +1,70 @@
11
"""Utilities to interact with subscriptions and stripe."""
22

3-
import structlog
4-
53
import stripe
4+
import structlog
5+
from djstripe import models as djstripe
66
from stripe.error import InvalidRequestError
77

88
log = structlog.get_logger(__name__)
99

1010

1111
def create_stripe_customer(organization):
1212
"""Create a stripe customer for organization."""
13-
stripe_customer = stripe.Customer.create(
13+
stripe_data = stripe.Customer.create(
1414
email=organization.email,
1515
description=organization.name,
16+
metadata=organization.get_stripe_metadata(),
1617
)
18+
stripe_customer = djstripe.Customer.sync_from_stripe_data(stripe_data)
1719
if organization.stripe_id:
1820
log.warning(
19-
'Overriding existing stripe customer. ',
21+
"Overriding existing stripe customer.",
2022
organization_slug=organization.slug,
2123
previous_stripe_customer=organization.stripe_id,
2224
stripe_customer=stripe_customer.id,
2325
)
24-
organization.stripe_id = stripe_customer.id
26+
organization.stripe_customer = stripe_customer
2527
organization.save()
2628
return stripe_customer
2729

2830

2931
def get_or_create_stripe_customer(organization):
3032
"""
31-
Retrieve the stripe customer from `organization.stripe_id` or create a new one.
33+
Retrieve the stripe customer or create a new one.
3234
33-
If `organization.stripe_id` is `None` or if the existing customer
34-
doesn't exist in stripe, a new customer is created.
35+
If `organization.stripe_customer` is `None`, a new customer is created.
36+
Not all models are migrated to use djstripe yet,
37+
so we retrieve the customer from the stripe_id attribute if the model has one.
3538
"""
3639
log.bind(
3740
organization_slug=organization.slug,
3841
stripe_customer=organization.stripe_id,
3942
)
40-
if not organization.stripe_id:
41-
log.info('No stripe customer found, creating one.')
42-
return create_stripe_customer(organization)
43-
44-
try:
45-
log.debug('Retrieving existing stripe customer.')
46-
stripe_customer = stripe.Customer.retrieve(organization.stripe_id)
47-
return stripe_customer
48-
except InvalidRequestError as exc:
49-
if exc.code == 'resource_missing':
50-
log.info('Invalid stripe customer, creating new one.')
43+
stripe_customer = organization.stripe_customer
44+
if not stripe_customer:
45+
stripe_customer = None
46+
if organization.stripe_id:
47+
try:
48+
# TODO: Don't fully trust on djstripe yet,
49+
# the customer may not be in our DB yet.
50+
# pylint: disable=protected-access
51+
stripe_customer = djstripe.Customer._get_or_retrieve(
52+
organization.stripe_id
53+
)
54+
except InvalidRequestError as exc:
55+
if exc.code == "resource_missing":
56+
log.info("Invalid stripe customer, creating new one.")
57+
58+
if stripe_customer:
59+
metadata = stripe_customer.metadata or {}
60+
metadata.update(organization.get_stripe_metadata())
61+
stripe.Customer.modify(
62+
stripe_customer.id,
63+
metadata=metadata,
64+
)
65+
organization.stripe_customer = stripe_customer
66+
organization.save()
67+
else:
68+
log.info("No stripe customer found, creating one.")
5169
return create_stripe_customer(organization)
52-
log.exception('Error while retrieving stripe customer.')
53-
raise
70+
return stripe_customer

0 commit comments

Comments
 (0)