Implement Stripe Customer Portal (#15905)

This commit is contained in:
William Durand 2020-11-04 09:43:11 +01:00 коммит произвёл GitHub
Родитель f54ba21393
Коммит 4ebfb25bdd
Не найден ключ, соответствующий данной подписи
Идентификатор ключа GPG: 4AEE18F83AFDEB23
9 изменённых файлов: 208 добавлений и 7 удалений

Просмотреть файл

@ -1330,6 +1330,17 @@ class Addon(OnChangeMixin, ModelBase):
return PromotedTheme(addon=self, group_id=RECOMMENDED.id)
return None
@property
def promoted_subscription(self):
"""Returns a PromotedSubscription if it exists, None otherwise."""
from olympia.promoted.models import PromotedAddon, PromotedSubscription
try:
return self.promotedaddon.promotedsubscription
except (PromotedAddon.DoesNotExit, PromotedSubscription.DoesNotExist):
pass
return None
@cached_property
def tags_partitioned_by_developer(self):
"""Returns a tuple of developer tags and user tags for this addon."""

Просмотреть файл

@ -29,6 +29,15 @@
<li {% if url in request.path|urlencode %}class="selected"{% endif %}>
<a href="{{ url }}">{{ title }}</a></li>
{% endfor %}
{% if addon.promoted_subscription and addon.promoted_subscription.stripe_checkout_completed %}
<li class="stripe-customer-portal">
<form method="POST" action="{{ url('devhub.addons.subscription_customer_portal', addon.slug) }}">
{% csrf_token %}
<button class="link" type="submit">{{ _('Manage Billing on Stripe') }}</button>
</form>
</li>
{% endif %}
</ul>
<ul class="refinements">
{% if show_listed_fields %}

Просмотреть файл

@ -1,4 +1,5 @@
# -*- coding: utf-8 -*-
import datetime
import json
import os
from unittest import mock
@ -20,7 +21,9 @@ from olympia.amo.tests.test_helpers import get_image_path
from olympia.amo.urlresolvers import reverse
from olympia.amo.utils import image_size
from olympia.constants.categories import CATEGORIES_BY_ID
from olympia.constants.promoted import VERIFIED
from olympia.devhub.forms import DescribeForm
from olympia.promoted.models import PromotedAddon
from olympia.tags.models import AddonTag, Tag
from olympia.users.models import UserProfile
from olympia.versions.models import VersionPreview
@ -726,6 +729,34 @@ class TestEditDescribeListed(BaseTestEditDescribe, L10nTestsMixin):
assert addon.description_id
assert addon.description == u'Sométhing descriptive.'
def test_no_manage_billing_when_no_subscription(self):
response = self.client.get(self.url)
assert pq(response.content)('.stripe-customer-portal').length == 0
def test_no_manage_billing_when_subscription_process_not_completed(self):
PromotedAddon.objects.create(
addon=self.get_addon(), group_id=VERIFIED.id
)
response = self.client.get(self.url)
assert pq(response.content)('.stripe-customer-portal').length == 0
def test_show_manage_billing_when_subscription_process_completed(self):
promoted = PromotedAddon.objects.create(
addon=self.get_addon(), group_id=VERIFIED.id
)
promoted.promotedsubscription.update(
payment_completed_at=datetime.datetime.now()
)
assert self.get_addon().promoted_subscription
assert self.get_addon().promoted_subscription.stripe_checkout_completed
response = self.client.get(self.url)
assert pq(response.content)('.stripe-customer-portal').length == 1
class TestEditDescribeUnlisted(BaseTestEditDescribe, L10nTestsMixin):
listed = False

Просмотреть файл

@ -12,7 +12,7 @@ from olympia.promoted.models import PromotedAddon, PromotedSubscription
@override_switch("enable-subscriptions-for-promoted-addons", active=True)
class OnboardingSubscriptionTestCase(TestCase):
class SubscriptionTestCase(TestCase):
def setUp(self):
super().setUp()
@ -28,7 +28,7 @@ class OnboardingSubscriptionTestCase(TestCase):
self.client.login(email=self.user.email)
class TestOnboardingSubscription(OnboardingSubscriptionTestCase):
class TestOnboardingSubscription(SubscriptionTestCase):
url_name = "devhub.addons.onboarding_subscription"
@override_switch("enable-subscriptions-for-promoted-addons", active=False)
@ -239,7 +239,7 @@ class TestOnboardingSubscription(OnboardingSubscriptionTestCase):
retrieve_mock.assert_called_with(self.subscription)
class TestOnboardingSubscriptionSuccess(OnboardingSubscriptionTestCase):
class TestOnboardingSubscriptionSuccess(SubscriptionTestCase):
url_name = "devhub.addons.onboarding_subscription_success"
@override_switch("enable-subscriptions-for-promoted-addons", active=False)
@ -314,7 +314,7 @@ class TestOnboardingSubscriptionSuccess(OnboardingSubscriptionTestCase):
)
assert not self.subscription.promoted_addon.addon.promoted_group()
with mock.patch('olympia.lib.crypto.tasks.sign_addons') as sign_mock:
with mock.patch("olympia.lib.crypto.tasks.sign_addons") as sign_mock:
self.client.get(self.url)
sign_mock.assert_called()
self.subscription.refresh_from_db()
@ -331,7 +331,7 @@ class TestOnboardingSubscriptionSuccess(OnboardingSubscriptionTestCase):
promo = self.subscription.promoted_addon
promo.approve_for_version(promo.addon.current_version)
assert promo.addon.promoted_group() == VERIFIED # approved already
with mock.patch('olympia.lib.crypto.tasks.sign_addons') as sign_mock:
with mock.patch("olympia.lib.crypto.tasks.sign_addons") as sign_mock:
self.client.get(self.url)
sign_mock.assert_not_called() # no resigning needed
self.subscription.refresh_from_db()
@ -339,7 +339,7 @@ class TestOnboardingSubscriptionSuccess(OnboardingSubscriptionTestCase):
assert promo.addon.promoted_group() == VERIFIED # still approved
class TestOnboardingSubscriptionCancel(OnboardingSubscriptionTestCase):
class TestOnboardingSubscriptionCancel(SubscriptionTestCase):
url_name = "devhub.addons.onboarding_subscription_cancel"
@override_switch("enable-subscriptions-for-promoted-addons", active=False)
@ -395,3 +395,61 @@ class TestOnboardingSubscriptionCancel(OnboardingSubscriptionTestCase):
self.subscription.refresh_from_db()
assert not self.subscription.payment_cancelled_at
@override_switch("enable-subscriptions-for-promoted-addons", active=True)
class TestSubscriptionCustomerPortal(SubscriptionTestCase):
url_name = "devhub.addons.subscription_customer_portal"
@override_switch("enable-subscriptions-for-promoted-addons", active=False)
def test_returns_404_when_switch_is_disabled(self):
assert self.client.post(self.url).status_code == 404
def test_rejects_get_requests(self):
assert self.client.get(self.url).status_code == 405
def test_returns_404_when_subscription_is_not_found(self):
# Create an add-on without a subscription.
addon = addon_factory(users=[self.user])
url = reverse(self.url_name, args=[addon.slug])
assert self.client.post(url).status_code == 404
@mock.patch("olympia.devhub.views.retrieve_stripe_checkout_session")
def test_get_returns_404_when_session_not_found(self, retrieve_mock):
retrieve_mock.side_effect = Exception("stripe error")
response = self.client.post(self.url)
assert response.status_code == 404
@mock.patch("olympia.devhub.views.retrieve_stripe_checkout_session")
@mock.patch("olympia.devhub.views.create_stripe_customer_portal")
def test_redirects_to_stripe_customer_portal(
self, create_mock, retrieve_mock
):
customer_id = "some-customer-id"
portal_url = "https://stripe-portal.example.org"
retrieve_mock.return_value = {"customer": customer_id}
create_mock.return_value = {"url": portal_url}
response = self.client.post(self.url)
assert response.status_code == 302
assert response["Location"] == portal_url
retrieve_mock.assert_called_once_with(self.subscription)
create_mock.assert_called_once_with(
customer_id=customer_id, addon=self.addon
)
@mock.patch("olympia.devhub.views.retrieve_stripe_checkout_session")
@mock.patch("olympia.devhub.views.create_stripe_customer_portal")
def test_get_returns_500_when_create_has_failed(
self, create_mock, retrieve_mock
):
retrieve_mock.return_value = {"customer": "customer-id"}
create_mock.side_effect = Exception("stripe error")
response = self.client.post(self.url)
assert response.status_code == 500

Просмотреть файл

@ -32,6 +32,9 @@ detail_patterns = [
re_path(r'^onboarding-subscription/cancel$',
views.onboarding_subscription_cancel,
name='devhub.addons.onboarding_subscription_cancel'),
re_path(r'^subscription/customer-portal$',
views.subscription_customer_portal,
name='devhub.addons.subscription_customer_portal'),
re_path(r'^upload_preview$', views.upload_image,
{'upload_type': 'preview'},

Просмотреть файл

@ -50,6 +50,7 @@ from olympia.promoted.models import PromotedSubscription
from olympia.promoted.utils import (
create_stripe_checkout_session,
retrieve_stripe_checkout_session,
create_stripe_customer_portal,
)
from olympia.reviewers.forms import PublicWhiteboardForm
from olympia.reviewers.models import Whiteboard
@ -1990,3 +1991,33 @@ def onboarding_subscription_cancel(request, addon_id, addon):
return redirect(
reverse("devhub.addons.onboarding_subscription", args=[addon.id])
)
@dev_required
@post_required
def subscription_customer_portal(request, addon_id, addon):
sub = get_promoted_subscription_or_404(addon=addon)
try:
session = retrieve_stripe_checkout_session(sub)
except Exception:
log.exception(
"error while trying to retrieve a stripe checkout session for "
"PromotedSubscription %s (customer_portal).",
sub.pk
)
raise http.Http404()
try:
portal = create_stripe_customer_portal(
customer_id=session['customer'], addon=addon
)
except Exception:
log.exception(
'error while creating a stripe customer portal for '
'PromotedSubscription %s.',
sub.pk
)
return http.HttpResponseServerError()
return redirect(portal['url'])

Просмотреть файл

@ -11,6 +11,7 @@ from olympia.constants.promoted import SPONSORED, RECOMMENDED
from olympia.promoted.models import PromotedSubscription, PromotedAddon
from olympia.promoted.utils import (
create_stripe_checkout_session,
create_stripe_customer_portal,
retrieve_stripe_checkout_session,
)
@ -149,3 +150,26 @@ def test_create_stripe_checkout_session_with_custom_rate():
],
customer_email=customer_email,
)
def test_create_stripe_customer_portal():
addon = addon_factory()
customer_id = "some-customer-id"
fake_portal = "fake-return-value"
with mock.patch(
"olympia.promoted.utils.stripe.billing_portal.Session.create"
) as create_portal_mock:
create_portal_mock.return_value = fake_portal
portal = create_stripe_customer_portal(
customer_id=customer_id, addon=addon
)
assert portal == fake_portal
create_portal_mock.assert_called_once_with(
customer=customer_id,
return_url=absolutify(
reverse("devhub.addons.edit", args=[addon.slug])
),
)

Просмотреть файл

@ -77,3 +77,15 @@ def retrieve_stripe_checkout_session(subscription):
error when the session does not exist or the API call has failed."""
stripe.api_key = settings.STRIPE_API_SECRET_KEY
return stripe.checkout.Session.retrieve(subscription.stripe_session_id)
def create_stripe_customer_portal(customer_id, addon):
"""This function creates a Stripe Customer Portal object or raises an error
when the session does not exist or the API call has failed."""
stripe.api_key = settings.STRIPE_API_SECRET_KEY
return stripe.billing_portal.Session.create(
customer=customer_id,
return_url=absolutify(
reverse("devhub.addons.edit", args=[addon.slug])
),
)

Просмотреть файл

@ -67,7 +67,8 @@
right: 0;
}
}
ul.refinements li a {
ul.refinements li a,
ul.refinements li button {
padding: 0 10px 0 0;
}
@ -264,8 +265,29 @@
li a {
color: #3d6db5;
font-size: .923em;
font-weight: inherit;
padding: 0 0 0 10px;
}
li.stripe-customer-portal {
form {
margin-bottom: 0;
}
button {
color: #3d6db5;
font-family: inherit;
font-size: .923em;
font-weight: inherit;
padding: 0 0 0 10px;
&:active,
&:focus,
&:hover {
box-shadow: none;
top: initial;
}
}
}
li.addon-manage a {
display: inline;
&:last-child {