Implement Stripe Customer Portal (#15905)
This commit is contained in:
Родитель
f54ba21393
Коммит
4ebfb25bdd
|
@ -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 {
|
||||
|
|
Загрузка…
Ссылка в новой задаче