Skip to content

Commit 9e7787c

Browse files
authored
Subscriptions: use stripe price instead of relying on plan object (#9640)
1 parent 04662de commit 9e7787c

File tree

7 files changed

+125
-42
lines changed

7 files changed

+125
-42
lines changed

readthedocs/organizations/querysets.py

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -104,7 +104,7 @@ def subscription_trial_plan_ending(self):
104104
date_next_week = date_now + timedelta(days=7)
105105

106106
return self.filter(
107-
subscription__plan__slug=settings.ORG_DEFAULT_SUBSCRIPTION_PLAN_SLUG,
107+
subscription__plan__stripe_id=settings.RTD_ORG_DEFAULT_STRIPE_SUBSCRIPTION_PRICE,
108108
subscription__status='trialing',
109109
subscription__trial_end_date__lt=date_next_week,
110110
subscription__trial_end_date__gt=date_now,
@@ -123,9 +123,12 @@ def subscription_trial_plan_ended(self):
123123
"""
124124
date_now = timezone.now()
125125
return self.filter(
126-
~Q(subscription__status='trialing'),
127-
Q(subscription__plan__slug=settings.ORG_DEFAULT_SUBSCRIPTION_PLAN_SLUG),
128-
Q(subscription__end_date__lt=date_now) | Q(subscription__trial_end_date__lt=date_now),
126+
~Q(subscription__status="trialing"),
127+
Q(
128+
subscription__plan__stripe_id=settings.RTD_ORG_DEFAULT_STRIPE_SUBSCRIPTION_PRICE
129+
),
130+
Q(subscription__end_date__lt=date_now)
131+
| Q(subscription__trial_end_date__lt=date_now),
129132
)
130133

131134
def subscription_trial_ended(self):

readthedocs/settings/base.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -718,7 +718,8 @@ def DOCKER_LIMITS(self):
718718

719719
# Organization settings
720720
RTD_ALLOW_ORGANIZATIONS = False
721-
ORG_DEFAULT_SUBSCRIPTION_PLAN_SLUG = 'trial-v2-monthly'
721+
RTD_ORG_DEFAULT_STRIPE_SUBSCRIPTION_PRICE = 'trial-v2-monthly'
722+
RTD_ORG_TRIAL_PERIOD_DAYS = 30
722723

723724
# Elasticsearch settings.
724725
ES_HOSTS = ['search:9200']

readthedocs/subscriptions/managers.py

Lines changed: 6 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,9 @@
66
from django.conf import settings
77
from django.db import models
88
from django.utils import timezone
9-
from djstripe.enums import SubscriptionStatus
109

1110
from readthedocs.core.history import set_change_reason
12-
from readthedocs.subscriptions.utils import get_or_create_stripe_customer
11+
from readthedocs.subscriptions.utils import get_or_create_stripe_subscription
1312

1413
log = structlog.get_logger(__name__)
1514

@@ -34,7 +33,10 @@ def get_or_create_default_subscription(self, organization):
3433
return organization.subscription
3534

3635
from readthedocs.subscriptions.models import Plan
37-
plan = Plan.objects.filter(slug=settings.ORG_DEFAULT_SUBSCRIPTION_PLAN_SLUG).first()
36+
37+
plan = Plan.objects.filter(
38+
stripe_id=settings.RTD_ORG_DEFAULT_STRIPE_SUBSCRIPTION_PRICE
39+
).first()
3840
# This should happen only on development.
3941
if not plan:
4042
log.warning(
@@ -43,25 +45,7 @@ def get_or_create_default_subscription(self, organization):
4345
)
4446
return None
4547

46-
stripe_customer = get_or_create_stripe_customer(organization)
47-
stripe_subscriptions = stripe_customer.subscriptions.exclude(
48-
status=SubscriptionStatus.canceled
49-
)
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()
48+
stripe_subscription = get_or_create_stripe_subscription(organization)
6549

6650
return self.create(
6751
plan=plan,

readthedocs/subscriptions/signals.py

Lines changed: 31 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -34,23 +34,42 @@ def remove_stripe_subscription(sender, instance, using, **kwargs):
3434

3535
# pylint: disable=unused-argument
3636
@receiver(post_save, sender=Organization)
37-
def update_billing_information(sender, instance, created, **kwargs):
38-
"""Update billing email information."""
37+
def update_stripe_customer(sender, instance, created, **kwargs):
38+
"""Update email and metadata attached to the stripe customer."""
3939
if created:
4040
return
4141

4242
organization = instance
4343
log.bind(organization_slug=organization.slug)
44-
# pylint: disable=broad-except
45-
try:
46-
s_customer = stripe.Customer.retrieve(organization.stripe_id)
47-
if s_customer.email != organization.email:
48-
s_customer.email = organization.email
49-
s_customer.save()
50-
except stripe.error.StripeError:
51-
log.exception('Unable to update the Organization billing email on Stripe.')
52-
except Exception:
53-
log.exception('Unknown error when updating Organization billing email on Stripe.')
44+
45+
stripe_customer = organization.stripe_customer
46+
if not stripe_customer:
47+
log.warning("Organization doesn't have a stripe customer attached.")
48+
return
49+
50+
fields_to_update = {}
51+
if organization.email != stripe_customer.email:
52+
fields_to_update["email"] = organization.email
53+
54+
org_metadata = organization.get_stripe_metadata()
55+
current_metadata = stripe_customer.metadata or {}
56+
for key, value in org_metadata.items():
57+
if current_metadata.get(key) != value:
58+
current_metadata.update(org_metadata)
59+
fields_to_update["metadata"] = current_metadata
60+
break
61+
62+
if fields_to_update:
63+
# pylint: disable=broad-except
64+
try:
65+
stripe.Customer.modify(
66+
stripe_customer.id,
67+
**fields_to_update,
68+
)
69+
except stripe.error.StripeError:
70+
log.exception("Unable to update stripe customer.")
71+
except Exception:
72+
log.exception("Unknown error when updating stripe customer.")
5473

5574

5675
# pylint: disable=unused-argument
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
from unittest import mock
2+
3+
from django.contrib.auth.models import User
4+
from django.test import TestCase
5+
from django_dynamic_fixture import get
6+
from djstripe import models as djstripe
7+
8+
from readthedocs.organizations.models import Organization
9+
10+
11+
class TestSignals(TestCase):
12+
def setUp(self):
13+
14+
self.user = get(User)
15+
self.stripe_customer = get(
16+
djstripe.Customer,
17+
email=email,
18+
)
19+
self.organization = get(
20+
Organization,
21+
slug="org",
22+
owners=[self.user],
23+
email=email,
24+
stripe_customer=self.stripe_customer,
25+
)
26+
self.stripe_customer.metadata = self.organization.get_stripe_metadata()
27+
self.stripe_customer.save()
28+
29+
@mock.patch("readthedocs.subscriptions.signals.stripe.Customer")
30+
def test_update_organization_email(self, customer):
31+
new_email = "[email protected]"
32+
self.organization.email = new_email
33+
self.organization.save()
34+
customer.modify.assert_called_once_with(
35+
self.stripe_customer.id,
36+
email=new_email,
37+
)
38+
39+
@mock.patch("readthedocs.subscriptions.signals.stripe.Customer")
40+
def test_update_organization_slug(self, customer):
41+
new_slug = "new-org"
42+
self.organization.slug = new_slug
43+
self.organization.save()
44+
new_metadata = self.organization.get_stripe_metadata()
45+
customer.modify.assert_called_once_with(
46+
self.stripe_customer.id,
47+
metadata=new_metadata,
48+
)
49+
50+
@mock.patch("readthedocs.subscriptions.signals.stripe.Customer")
51+
def test_save_organization_no_changes(self, customer):
52+
self.organization.save()
53+
customer.modify.assert_not_called()

readthedocs/subscriptions/tests/test_views.py

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,8 +21,12 @@ class SubscriptionViewTests(TestCase):
2121

2222
def setUp(self):
2323
self.user = get(User)
24-
self.organization = get(Organization, stripe_id='123', owners=[self.user])
25-
self.plan = get(Plan, published=True, slug=settings.ORG_DEFAULT_SUBSCRIPTION_PLAN_SLUG)
24+
self.organization = get(Organization, stripe_id="123", owners=[self.user])
25+
self.plan = get(
26+
Plan,
27+
published=True,
28+
stripe_id=settings.RTD_ORG_DEFAULT_STRIPE_SUBSCRIPTION_PRICE,
29+
)
2630
self.stripe_subscription = self._create_stripe_subscription(
2731
customer_id=self.organization.stripe_id,
2832
subscription_id="sub_a1b2c3d4",

readthedocs/subscriptions/utils.py

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
"""Utilities to interact with subscriptions and stripe."""
2-
32
import stripe
43
import structlog
4+
from django.conf import settings
55
from djstripe import models as djstripe
66
from stripe.error import InvalidRequestError
77

@@ -68,3 +68,22 @@ def get_or_create_stripe_customer(organization):
6868
log.info("No stripe customer found, creating one.")
6969
return create_stripe_customer(organization)
7070
return stripe_customer
71+
72+
73+
def get_or_create_stripe_subscription(organization):
74+
"""
75+
Get the stripe subscription attached to the organization or create one.
76+
77+
The subscription will be created with the default price and a trial period.
78+
"""
79+
stripe_customer = get_or_create_stripe_customer(organization)
80+
stripe_subscription = stripe_customer.subscriptions.order_by("created").last()
81+
if not stripe_subscription:
82+
# TODO: djstripe 2.6.x doesn't return the subscription object
83+
# on subscribe(), but 2.7.x (unreleased) does!
84+
stripe_customer.subscribe(
85+
items=[{"price": settings.RTD_ORG_DEFAULT_STRIPE_SUBSCRIPTION_PRICE}],
86+
trial_period_days=settings.RTD_ORG_TRIAL_PERIOD_DAYS,
87+
)
88+
stripe_subscription = stripe_customer.subscriptions.latest()
89+
return stripe_subscription

0 commit comments

Comments
 (0)