diff --git a/readthedocs/organizations/querysets.py b/readthedocs/organizations/querysets.py index c8a61bfeb28..5f4ae437a91 100644 --- a/readthedocs/organizations/querysets.py +++ b/readthedocs/organizations/querysets.py @@ -104,7 +104,7 @@ def subscription_trial_plan_ending(self): date_next_week = date_now + timedelta(days=7) return self.filter( - subscription__plan__slug=settings.ORG_DEFAULT_SUBSCRIPTION_PLAN_SLUG, + subscription__plan__stripe_id=settings.RTD_ORG_DEFAULT_STRIPE_SUBSCRIPTION_PRICE, subscription__status='trialing', subscription__trial_end_date__lt=date_next_week, subscription__trial_end_date__gt=date_now, @@ -123,9 +123,12 @@ def subscription_trial_plan_ended(self): """ date_now = timezone.now() return self.filter( - ~Q(subscription__status='trialing'), - Q(subscription__plan__slug=settings.ORG_DEFAULT_SUBSCRIPTION_PLAN_SLUG), - Q(subscription__end_date__lt=date_now) | Q(subscription__trial_end_date__lt=date_now), + ~Q(subscription__status="trialing"), + Q( + subscription__plan__stripe_id=settings.RTD_ORG_DEFAULT_STRIPE_SUBSCRIPTION_PRICE + ), + Q(subscription__end_date__lt=date_now) + | Q(subscription__trial_end_date__lt=date_now), ) def subscription_trial_ended(self): diff --git a/readthedocs/settings/base.py b/readthedocs/settings/base.py index c1af4307f6a..f812e3ab735 100644 --- a/readthedocs/settings/base.py +++ b/readthedocs/settings/base.py @@ -718,7 +718,8 @@ def DOCKER_LIMITS(self): # Organization settings RTD_ALLOW_ORGANIZATIONS = False - ORG_DEFAULT_SUBSCRIPTION_PLAN_SLUG = 'trial-v2-monthly' + RTD_ORG_DEFAULT_STRIPE_SUBSCRIPTION_PRICE = 'trial-v2-monthly' + RTD_ORG_TRIAL_PERIOD_DAYS = 30 # Elasticsearch settings. ES_HOSTS = ['search:9200'] diff --git a/readthedocs/subscriptions/managers.py b/readthedocs/subscriptions/managers.py index 7f3a762bd2f..228ddf37d99 100644 --- a/readthedocs/subscriptions/managers.py +++ b/readthedocs/subscriptions/managers.py @@ -6,10 +6,9 @@ from django.conf import settings from django.db import models from django.utils import timezone -from djstripe.enums import SubscriptionStatus from readthedocs.core.history import set_change_reason -from readthedocs.subscriptions.utils import get_or_create_stripe_customer +from readthedocs.subscriptions.utils import get_or_create_stripe_subscription log = structlog.get_logger(__name__) @@ -34,7 +33,10 @@ def get_or_create_default_subscription(self, organization): return organization.subscription from readthedocs.subscriptions.models import Plan - plan = Plan.objects.filter(slug=settings.ORG_DEFAULT_SUBSCRIPTION_PLAN_SLUG).first() + + plan = Plan.objects.filter( + stripe_id=settings.RTD_ORG_DEFAULT_STRIPE_SUBSCRIPTION_PRICE + ).first() # This should happen only on development. if not plan: log.warning( @@ -43,25 +45,7 @@ def get_or_create_default_subscription(self, organization): ) return None - stripe_customer = get_or_create_stripe_customer(organization) - stripe_subscriptions = stripe_customer.subscriptions.exclude( - status=SubscriptionStatus.canceled - ) - if stripe_subscriptions.count() > 1: - log.warning( - "Customer with more than one active subscription.", - stripe_customer=stripe_customer.id, - ) - - stripe_subscription = stripe_subscriptions.last() - if not stripe_subscription: - # TODO: djstripe 2.6.x doesn't return the subscription object - # on subscribe(), but 2.7.x (unreleased) does! - stripe_customer.subscribe( - items=[{"price": plan.stripe_id}], - trial_period_days=plan.trial, - ) - stripe_subscription = stripe_customer.subscriptions.latest() + stripe_subscription = get_or_create_stripe_subscription(organization) return self.create( plan=plan, diff --git a/readthedocs/subscriptions/signals.py b/readthedocs/subscriptions/signals.py index a74e1514540..799004a0fcd 100644 --- a/readthedocs/subscriptions/signals.py +++ b/readthedocs/subscriptions/signals.py @@ -34,23 +34,42 @@ def remove_stripe_subscription(sender, instance, using, **kwargs): # pylint: disable=unused-argument @receiver(post_save, sender=Organization) -def update_billing_information(sender, instance, created, **kwargs): - """Update billing email information.""" +def update_stripe_customer(sender, instance, created, **kwargs): + """Update email and metadata attached to the stripe customer.""" if created: return organization = instance log.bind(organization_slug=organization.slug) - # pylint: disable=broad-except - try: - s_customer = stripe.Customer.retrieve(organization.stripe_id) - if s_customer.email != organization.email: - s_customer.email = organization.email - s_customer.save() - except stripe.error.StripeError: - log.exception('Unable to update the Organization billing email on Stripe.') - except Exception: - log.exception('Unknown error when updating Organization billing email on Stripe.') + + stripe_customer = organization.stripe_customer + if not stripe_customer: + log.warning("Organization doesn't have a stripe customer attached.") + return + + fields_to_update = {} + if organization.email != stripe_customer.email: + fields_to_update["email"] = organization.email + + org_metadata = organization.get_stripe_metadata() + current_metadata = stripe_customer.metadata or {} + for key, value in org_metadata.items(): + if current_metadata.get(key) != value: + current_metadata.update(org_metadata) + fields_to_update["metadata"] = current_metadata + break + + if fields_to_update: + # pylint: disable=broad-except + try: + stripe.Customer.modify( + stripe_customer.id, + **fields_to_update, + ) + except stripe.error.StripeError: + log.exception("Unable to update stripe customer.") + except Exception: + log.exception("Unknown error when updating stripe customer.") # pylint: disable=unused-argument diff --git a/readthedocs/subscriptions/tests/test_signals.py b/readthedocs/subscriptions/tests/test_signals.py new file mode 100644 index 00000000000..1e147babb2b --- /dev/null +++ b/readthedocs/subscriptions/tests/test_signals.py @@ -0,0 +1,53 @@ +from unittest import mock + +from django.contrib.auth.models import User +from django.test import TestCase +from django_dynamic_fixture import get +from djstripe import models as djstripe + +from readthedocs.organizations.models import Organization + + +class TestSignals(TestCase): + def setUp(self): + email = "test@example.com" + self.user = get(User) + self.stripe_customer = get( + djstripe.Customer, + email=email, + ) + self.organization = get( + Organization, + slug="org", + owners=[self.user], + email=email, + stripe_customer=self.stripe_customer, + ) + self.stripe_customer.metadata = self.organization.get_stripe_metadata() + self.stripe_customer.save() + + @mock.patch("readthedocs.subscriptions.signals.stripe.Customer") + def test_update_organization_email(self, customer): + new_email = "new@example.com" + self.organization.email = new_email + self.organization.save() + customer.modify.assert_called_once_with( + self.stripe_customer.id, + email=new_email, + ) + + @mock.patch("readthedocs.subscriptions.signals.stripe.Customer") + def test_update_organization_slug(self, customer): + new_slug = "new-org" + self.organization.slug = new_slug + self.organization.save() + new_metadata = self.organization.get_stripe_metadata() + customer.modify.assert_called_once_with( + self.stripe_customer.id, + metadata=new_metadata, + ) + + @mock.patch("readthedocs.subscriptions.signals.stripe.Customer") + def test_save_organization_no_changes(self, customer): + self.organization.save() + customer.modify.assert_not_called() diff --git a/readthedocs/subscriptions/tests/test_views.py b/readthedocs/subscriptions/tests/test_views.py index 4f366f2363f..8f2f3f9a10f 100644 --- a/readthedocs/subscriptions/tests/test_views.py +++ b/readthedocs/subscriptions/tests/test_views.py @@ -21,8 +21,12 @@ class SubscriptionViewTests(TestCase): def setUp(self): self.user = get(User) - self.organization = get(Organization, stripe_id='123', owners=[self.user]) - self.plan = get(Plan, published=True, slug=settings.ORG_DEFAULT_SUBSCRIPTION_PLAN_SLUG) + self.organization = get(Organization, stripe_id="123", owners=[self.user]) + self.plan = get( + Plan, + published=True, + stripe_id=settings.RTD_ORG_DEFAULT_STRIPE_SUBSCRIPTION_PRICE, + ) self.stripe_subscription = self._create_stripe_subscription( customer_id=self.organization.stripe_id, subscription_id="sub_a1b2c3d4", diff --git a/readthedocs/subscriptions/utils.py b/readthedocs/subscriptions/utils.py index 8785103b192..e5334d50c68 100644 --- a/readthedocs/subscriptions/utils.py +++ b/readthedocs/subscriptions/utils.py @@ -1,7 +1,7 @@ """Utilities to interact with subscriptions and stripe.""" - import stripe import structlog +from django.conf import settings from djstripe import models as djstripe from stripe.error import InvalidRequestError @@ -68,3 +68,22 @@ def get_or_create_stripe_customer(organization): log.info("No stripe customer found, creating one.") return create_stripe_customer(organization) return stripe_customer + + +def get_or_create_stripe_subscription(organization): + """ + Get the stripe subscription attached to the organization or create one. + + The subscription will be created with the default price and a trial period. + """ + stripe_customer = get_or_create_stripe_customer(organization) + stripe_subscription = stripe_customer.subscriptions.order_by("created").last() + if not stripe_subscription: + # TODO: djstripe 2.6.x doesn't return the subscription object + # on subscribe(), but 2.7.x (unreleased) does! + stripe_customer.subscribe( + items=[{"price": settings.RTD_ORG_DEFAULT_STRIPE_SUBSCRIPTION_PRICE}], + trial_period_days=settings.RTD_ORG_TRIAL_PERIOD_DAYS, + ) + stripe_subscription = stripe_customer.subscriptions.latest() + return stripe_subscription