Implement payment flow using Stripe Checkout (#15753)
This commit is contained in:
Родитель
6500418a09
Коммит
9e20c0e9b4
|
@ -508,3 +508,6 @@ cached-property==1.5.2 \
|
||||||
django-jsonfield-backport==1.0.2 \
|
django-jsonfield-backport==1.0.2 \
|
||||||
--hash=sha256:5574505967f6d7ada8c9269a5f873cfdca9812dc9502eee2b7a86be5c3798c76 \
|
--hash=sha256:5574505967f6d7ada8c9269a5f873cfdca9812dc9502eee2b7a86be5c3798c76 \
|
||||||
--hash=sha256:0286dcc1c112389d52096f269eed83a77364ea2b349fe1777f5e4464c3c36fa9
|
--hash=sha256:0286dcc1c112389d52096f269eed83a77364ea2b349fe1777f5e4464c3c36fa9
|
||||||
|
stripe==2.54.0 \
|
||||||
|
--hash=sha256:6daf0dcb7f9cb8d1af93e94eb7d91df4c0b112db517c8c446017ea0b7f037c83 \
|
||||||
|
--hash=sha256:a622bce0d6d0ac99a53b6e6a6475c39250207d644f28ad0ed276af7b96d12617
|
||||||
|
|
|
@ -0,0 +1,79 @@
|
||||||
|
{% extends "devhub/base_impala.html" %}
|
||||||
|
|
||||||
|
{% set title = _('Set Up Payment') %}
|
||||||
|
|
||||||
|
{% block js %}
|
||||||
|
{{ super() }}
|
||||||
|
<script src="https://js.stripe.com/v3/"></script>
|
||||||
|
<script src="{{ static('js/devhub/stripe.js') }}"></script>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block title %}
|
||||||
|
{{ dev_page_title(title, addon) }}
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<section class="primary full">
|
||||||
|
<div id="onboard-addon" class="devhub-form">
|
||||||
|
<h2>{{ _('Set Up Payment') }}</h2>
|
||||||
|
|
||||||
|
{% if stripe_checkout_completed %}
|
||||||
|
<p>
|
||||||
|
{% trans %}
|
||||||
|
You're almost done!
|
||||||
|
{% endtrans %}
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
{% if promoted_group == amo.promoted.SPONSORED %}
|
||||||
|
{% trans addon_name=addon.name %}
|
||||||
|
Please upload a new version of your add-on <em>{{ addon_name }}</em>.
|
||||||
|
Once that version passes review, your add-on will be published as
|
||||||
|
Sponsored and should become available shortly. If you have any
|
||||||
|
questions, don't hesitate to contact us.
|
||||||
|
{% endtrans %}
|
||||||
|
{% else %}
|
||||||
|
{% trans addon_name=addon.name %}
|
||||||
|
Please upload a new version of your add-on <em>{{ addon_name }}</em>.
|
||||||
|
Once that version passes review, your add-on will be published as
|
||||||
|
Verified and should become available shortly. If you have any
|
||||||
|
questions, don't hesitate to contact us.
|
||||||
|
{% endtrans %}
|
||||||
|
{% endif %}
|
||||||
|
</p>
|
||||||
|
{% else %}
|
||||||
|
{% if stripe_checkout_cancelled %}
|
||||||
|
<div class="notification-box error">
|
||||||
|
<p>
|
||||||
|
{% trans %}
|
||||||
|
There was an error while setting up payment for your add-on. If you're
|
||||||
|
experiencing problems with this process, please contact us at
|
||||||
|
<a href="mailto:amo-admins@mozilla.com">amo-admins@mozilla.com</a>.
|
||||||
|
{% endtrans %}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
<p>
|
||||||
|
{% trans %}
|
||||||
|
Thank you for joining the Promoted Add-ons Program!
|
||||||
|
{% endtrans %}
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
{% trans addon_name=addon.name %}
|
||||||
|
Your add-on <em>{{ addon_name }}</em> will be included in the program
|
||||||
|
once payment is set up through the Stripe system. Click on the button
|
||||||
|
below to continue:
|
||||||
|
{% endtrans %}
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<button
|
||||||
|
id="checkout-button"
|
||||||
|
data-sessionid="{{ stripe_session_id }}"
|
||||||
|
data-publickey="{{ stripe_api_public_key }}"
|
||||||
|
>
|
||||||
|
{{ _('Continue to Stripe Checkout') }}
|
||||||
|
</button>
|
||||||
|
</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
{% endblock %}
|
|
@ -0,0 +1,309 @@
|
||||||
|
import datetime
|
||||||
|
|
||||||
|
from unittest import mock
|
||||||
|
from waffle.testutils import override_switch
|
||||||
|
|
||||||
|
from olympia.amo.tests import TestCase, addon_factory, user_factory
|
||||||
|
from olympia.amo.urlresolvers import reverse
|
||||||
|
from olympia.constants.promoted import VERIFIED
|
||||||
|
from olympia.promoted.models import PromotedAddon, PromotedSubscription
|
||||||
|
|
||||||
|
|
||||||
|
@override_switch("enable-subscriptions-for-promoted-addons", active=True)
|
||||||
|
class OnboardingSubscriptionTestCase(TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
super().setUp()
|
||||||
|
|
||||||
|
self.user = user_factory()
|
||||||
|
self.addon = addon_factory(users=[self.user])
|
||||||
|
self.promoted_addon = PromotedAddon.objects.create(
|
||||||
|
addon=self.addon, group_id=VERIFIED.id
|
||||||
|
)
|
||||||
|
self.subscription = PromotedSubscription.objects.filter(
|
||||||
|
promoted_addon=self.promoted_addon
|
||||||
|
).get()
|
||||||
|
self.url = reverse(self.url_name, args=[self.addon.slug])
|
||||||
|
self.client.login(email=self.user.email)
|
||||||
|
|
||||||
|
|
||||||
|
class TestOnboardingSubscription(OnboardingSubscriptionTestCase):
|
||||||
|
url_name = "devhub.addons.onboarding_subscription"
|
||||||
|
|
||||||
|
@override_switch("enable-subscriptions-for-promoted-addons", active=False)
|
||||||
|
def test_returns_404_when_switch_is_disabled(self):
|
||||||
|
assert self.client.get(self.url).status_code == 404
|
||||||
|
|
||||||
|
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.get(url).status_code == 404
|
||||||
|
|
||||||
|
@mock.patch("olympia.devhub.views.create_stripe_checkout_session")
|
||||||
|
def test_get_for_the_first_time(self, create_mock):
|
||||||
|
create_mock.return_value = mock.MagicMock(id="session-id")
|
||||||
|
|
||||||
|
assert not self.subscription.link_visited_at
|
||||||
|
assert not self.subscription.stripe_session_id
|
||||||
|
|
||||||
|
response = self.client.get(self.url)
|
||||||
|
self.subscription.refresh_from_db()
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
create_mock.assert_called_with(
|
||||||
|
self.subscription, customer_email=self.user.email
|
||||||
|
)
|
||||||
|
assert self.subscription.link_visited_at is not None
|
||||||
|
assert self.subscription.stripe_session_id == "session-id"
|
||||||
|
assert (
|
||||||
|
response.context["stripe_session_id"] ==
|
||||||
|
self.subscription.stripe_session_id
|
||||||
|
)
|
||||||
|
assert response.context["addon"] == self.addon
|
||||||
|
assert not response.context["stripe_checkout_completed"]
|
||||||
|
assert not response.context["stripe_checkout_cancelled"]
|
||||||
|
assert response.context["promoted_group"] == self.promoted_addon.group
|
||||||
|
assert (
|
||||||
|
b"Thank you for joining the Promoted Add-ons Program!"
|
||||||
|
in response.content
|
||||||
|
)
|
||||||
|
|
||||||
|
@mock.patch("olympia.devhub.views.create_stripe_checkout_session")
|
||||||
|
def test_get(self, create_mock):
|
||||||
|
create_mock.side_effect = [
|
||||||
|
mock.MagicMock(id="session-id-1"),
|
||||||
|
mock.MagicMock(id="session-id-2"),
|
||||||
|
]
|
||||||
|
|
||||||
|
# Get the page.
|
||||||
|
queries = 31
|
||||||
|
with self.assertNumQueries(queries):
|
||||||
|
# 31 queries:
|
||||||
|
# - 3 users + groups
|
||||||
|
# - 2 savepoints (test)
|
||||||
|
# - 3 addon and its translations
|
||||||
|
# - 2 addon categories
|
||||||
|
# - 4 versions and translations
|
||||||
|
# - 2 application versions
|
||||||
|
# - 2 files
|
||||||
|
# - 5 addon users
|
||||||
|
# - 2 previews
|
||||||
|
# - 1 waffle switch
|
||||||
|
# - 1 promoted subscription
|
||||||
|
# - 1 promoted add-on
|
||||||
|
# - 1 UPDATE promoted subscription
|
||||||
|
# - 1 addons_collections
|
||||||
|
# - 1 config (site notice)
|
||||||
|
response = self.client.get(self.url)
|
||||||
|
self.subscription.refresh_from_db()
|
||||||
|
|
||||||
|
link_visited_at = self.subscription.link_visited_at
|
||||||
|
assert link_visited_at
|
||||||
|
assert self.subscription.stripe_session_id == "session-id-1"
|
||||||
|
|
||||||
|
# Get the page, again.
|
||||||
|
with self.assertNumQueries(queries - 1):
|
||||||
|
# - waffle switch is cached
|
||||||
|
response = self.client.get(self.url)
|
||||||
|
self.subscription.refresh_from_db()
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert self.subscription.link_visited_at == link_visited_at
|
||||||
|
assert self.subscription.stripe_session_id == "session-id-2"
|
||||||
|
|
||||||
|
@mock.patch("olympia.devhub.views.create_stripe_checkout_session")
|
||||||
|
def test_shows_page_with_admin(self, create_mock):
|
||||||
|
admin = user_factory()
|
||||||
|
self.grant_permission(admin, "*:*")
|
||||||
|
self.client.logout()
|
||||||
|
self.client.login(email=admin.email)
|
||||||
|
create_mock.return_value = mock.MagicMock(id="session-id")
|
||||||
|
|
||||||
|
assert not self.subscription.link_visited_at
|
||||||
|
|
||||||
|
response = self.client.get(self.url)
|
||||||
|
self.subscription.refresh_from_db()
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
# We don't set this date when the user is not the owner (in this case,
|
||||||
|
# the user is an admin).
|
||||||
|
assert not self.subscription.link_visited_at
|
||||||
|
|
||||||
|
@mock.patch("olympia.devhub.views.create_stripe_checkout_session")
|
||||||
|
def test_shows_error_message_when_payment_was_previously_cancelled(
|
||||||
|
self, create_mock
|
||||||
|
):
|
||||||
|
create_mock.return_value = mock.MagicMock(id="session-id")
|
||||||
|
self.subscription.update(payment_cancelled_at=datetime.datetime.now())
|
||||||
|
|
||||||
|
response = self.client.get(self.url)
|
||||||
|
|
||||||
|
assert (
|
||||||
|
b"There was an error while setting up payment for your add-on."
|
||||||
|
in response.content
|
||||||
|
)
|
||||||
|
create_mock.assert_called_with(
|
||||||
|
self.subscription, customer_email=self.user.email
|
||||||
|
)
|
||||||
|
|
||||||
|
@mock.patch("olympia.devhub.views.retrieve_stripe_checkout_session")
|
||||||
|
def test_shows_confirmation_after_payment(self, retrieve_mock):
|
||||||
|
stripe_session_id = "some session id"
|
||||||
|
retrieve_mock.return_value = mock.MagicMock(id=stripe_session_id)
|
||||||
|
self.subscription.update(
|
||||||
|
stripe_session_id=stripe_session_id,
|
||||||
|
paid_at=datetime.datetime.now(),
|
||||||
|
)
|
||||||
|
|
||||||
|
response = self.client.get(self.url)
|
||||||
|
|
||||||
|
assert b"You're almost done!" in response.content
|
||||||
|
retrieve_mock.assert_called_with(self.subscription)
|
||||||
|
|
||||||
|
@mock.patch("olympia.devhub.views.retrieve_stripe_checkout_session")
|
||||||
|
def test_get_returns_500_when_retrieve_has_failed(self, retrieve_mock):
|
||||||
|
stripe_session_id = "some session id"
|
||||||
|
self.subscription.update(
|
||||||
|
stripe_session_id=stripe_session_id,
|
||||||
|
paid_at=datetime.datetime.now(),
|
||||||
|
)
|
||||||
|
retrieve_mock.side_effect = Exception("stripe error")
|
||||||
|
|
||||||
|
response = self.client.get(self.url)
|
||||||
|
|
||||||
|
assert response.status_code == 500
|
||||||
|
|
||||||
|
@mock.patch("olympia.devhub.views.create_stripe_checkout_session")
|
||||||
|
def test_get_returns_500_when_create_has_failed(self, create_mock):
|
||||||
|
create_mock.side_effect = Exception("stripe error")
|
||||||
|
|
||||||
|
response = self.client.get(self.url)
|
||||||
|
|
||||||
|
assert response.status_code == 500
|
||||||
|
|
||||||
|
|
||||||
|
class TestOnboardingSubscriptionSuccess(OnboardingSubscriptionTestCase):
|
||||||
|
url_name = "devhub.addons.onboarding_subscription_success"
|
||||||
|
|
||||||
|
@override_switch("enable-subscriptions-for-promoted-addons", active=False)
|
||||||
|
def test_returns_404_when_switch_is_disabled(self):
|
||||||
|
assert self.client.get(self.url).status_code == 404
|
||||||
|
|
||||||
|
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.get(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.get(self.url)
|
||||||
|
|
||||||
|
assert response.status_code == 404
|
||||||
|
|
||||||
|
@mock.patch("olympia.devhub.views.retrieve_stripe_checkout_session")
|
||||||
|
def test_get_redirects_to_main_page(self, retrieve_mock):
|
||||||
|
retrieve_mock.return_value = mock.MagicMock(
|
||||||
|
id="session-id", payment_status="unpaid"
|
||||||
|
)
|
||||||
|
|
||||||
|
response = self.client.get(self.url)
|
||||||
|
|
||||||
|
assert response.status_code == 302
|
||||||
|
assert response["Location"].endswith("/onboarding-subscription")
|
||||||
|
|
||||||
|
@mock.patch("olympia.devhub.views.retrieve_stripe_checkout_session")
|
||||||
|
def test_get_records_payment_once(self, retrieve_mock):
|
||||||
|
retrieve_mock.return_value = mock.MagicMock(
|
||||||
|
id="session-id", payment_status="paid"
|
||||||
|
)
|
||||||
|
|
||||||
|
assert not self.subscription.paid_at
|
||||||
|
|
||||||
|
self.client.get(self.url)
|
||||||
|
self.subscription.refresh_from_db()
|
||||||
|
|
||||||
|
paid_at = self.subscription.paid_at
|
||||||
|
assert paid_at is not None
|
||||||
|
|
||||||
|
self.client.get(self.url)
|
||||||
|
self.subscription.refresh_from_db()
|
||||||
|
|
||||||
|
# Make sure we don't update this date again.
|
||||||
|
assert self.subscription.paid_at == paid_at
|
||||||
|
assert not self.subscription.payment_cancelled_at
|
||||||
|
|
||||||
|
@mock.patch("olympia.devhub.views.retrieve_stripe_checkout_session")
|
||||||
|
def test_get_resets_payment_cancelled_date_after_success(
|
||||||
|
self, retrieve_mock
|
||||||
|
):
|
||||||
|
retrieve_mock.return_value = mock.MagicMock(
|
||||||
|
id="session-id", payment_status="paid"
|
||||||
|
)
|
||||||
|
|
||||||
|
self.subscription.update(payment_cancelled_at=datetime.datetime.now())
|
||||||
|
|
||||||
|
self.client.get(self.url)
|
||||||
|
self.subscription.refresh_from_db()
|
||||||
|
|
||||||
|
assert not self.subscription.payment_cancelled_at
|
||||||
|
|
||||||
|
|
||||||
|
class TestOnboardingSubscriptionCancel(OnboardingSubscriptionTestCase):
|
||||||
|
url_name = "devhub.addons.onboarding_subscription_cancel"
|
||||||
|
|
||||||
|
@override_switch("enable-subscriptions-for-promoted-addons", active=False)
|
||||||
|
def test_returns_404_when_switch_is_disabled(self):
|
||||||
|
assert self.client.get(self.url).status_code == 404
|
||||||
|
|
||||||
|
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.get(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.get(self.url)
|
||||||
|
|
||||||
|
assert response.status_code == 404
|
||||||
|
|
||||||
|
@mock.patch("olympia.devhub.views.retrieve_stripe_checkout_session")
|
||||||
|
def test_get_redirects_to_main_page(self, retrieve_mock):
|
||||||
|
retrieve_mock.return_value = mock.MagicMock(id="session-id")
|
||||||
|
|
||||||
|
response = self.client.get(self.url)
|
||||||
|
|
||||||
|
assert response.status_code == 302
|
||||||
|
assert response["Location"].endswith("/onboarding-subscription")
|
||||||
|
|
||||||
|
@mock.patch("olympia.devhub.views.retrieve_stripe_checkout_session")
|
||||||
|
def test_get_sets_payment_cancelled_date(self, retrieve_mock):
|
||||||
|
stripe_session_id = "some session id"
|
||||||
|
self.subscription.update(stripe_session_id=stripe_session_id)
|
||||||
|
retrieve_mock.return_value = mock.MagicMock(id=stripe_session_id)
|
||||||
|
|
||||||
|
assert not self.subscription.payment_cancelled_at
|
||||||
|
|
||||||
|
self.client.get(self.url)
|
||||||
|
self.subscription.refresh_from_db()
|
||||||
|
|
||||||
|
assert self.subscription.payment_cancelled_at
|
||||||
|
|
||||||
|
@mock.patch("olympia.devhub.views.retrieve_stripe_checkout_session")
|
||||||
|
def test_get_does_not_set_payment_cancelled_date_when_already_paid(
|
||||||
|
self, retrieve_mock
|
||||||
|
):
|
||||||
|
retrieve_mock.return_value = mock.MagicMock(id="session-id")
|
||||||
|
|
||||||
|
self.subscription.update(paid_at=datetime.datetime.now())
|
||||||
|
assert not self.subscription.payment_cancelled_at
|
||||||
|
|
||||||
|
self.client.get(self.url)
|
||||||
|
self.subscription.refresh_from_db()
|
||||||
|
|
||||||
|
assert not self.subscription.payment_cancelled_at
|
|
@ -24,6 +24,14 @@ detail_patterns = [
|
||||||
name='devhub.addons.invitation'),
|
name='devhub.addons.invitation'),
|
||||||
re_path(r'^edit_(?P<section>[^/]+)(?:/(?P<editable>[^/]+))?$',
|
re_path(r'^edit_(?P<section>[^/]+)(?:/(?P<editable>[^/]+))?$',
|
||||||
views.addons_section, name='devhub.addons.section'),
|
views.addons_section, name='devhub.addons.section'),
|
||||||
|
re_path(r'^onboarding-subscription$', views.onboarding_subscription,
|
||||||
|
name='devhub.addons.onboarding_subscription'),
|
||||||
|
re_path(r'^onboarding-subscription/success$',
|
||||||
|
views.onboarding_subscription_success,
|
||||||
|
name='devhub.addons.onboarding_subscription_success'),
|
||||||
|
re_path(r'^onboarding-subscription/cancel$',
|
||||||
|
views.onboarding_subscription_cancel,
|
||||||
|
name='devhub.addons.onboarding_subscription_cancel'),
|
||||||
|
|
||||||
re_path(r'^upload_preview$', views.upload_image,
|
re_path(r'^upload_preview$', views.upload_image,
|
||||||
{'upload_type': 'preview'},
|
{'upload_type': 'preview'},
|
||||||
|
|
|
@ -45,6 +45,11 @@ from olympia.devhub.utils import (
|
||||||
UploadRestrictionChecker, wizard_unsupported_properties)
|
UploadRestrictionChecker, wizard_unsupported_properties)
|
||||||
from olympia.files.models import File, FileUpload
|
from olympia.files.models import File, FileUpload
|
||||||
from olympia.files.utils import parse_addon
|
from olympia.files.utils import parse_addon
|
||||||
|
from olympia.promoted.models import PromotedSubscription
|
||||||
|
from olympia.promoted.utils import (
|
||||||
|
create_stripe_checkout_session,
|
||||||
|
retrieve_stripe_checkout_session,
|
||||||
|
)
|
||||||
from olympia.reviewers.forms import PublicWhiteboardForm
|
from olympia.reviewers.forms import PublicWhiteboardForm
|
||||||
from olympia.reviewers.models import Whiteboard
|
from olympia.reviewers.models import Whiteboard
|
||||||
from olympia.reviewers.templatetags.code_manager import code_manager_url
|
from olympia.reviewers.templatetags.code_manager import code_manager_url
|
||||||
|
@ -1837,3 +1842,130 @@ def logout(request):
|
||||||
logout_user(request, response)
|
logout_user(request, response)
|
||||||
|
|
||||||
return response
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
def get_promoted_subscription_or_404(addon):
|
||||||
|
if not waffle.switch_is_active('enable-subscriptions-for-promoted-addons'):
|
||||||
|
log.info(
|
||||||
|
'cannot retrieve a promoted subscription because waffle switch '
|
||||||
|
'is disabled.'
|
||||||
|
)
|
||||||
|
raise http.Http404()
|
||||||
|
|
||||||
|
qs = PromotedSubscription.objects.select_related('promoted_addon')
|
||||||
|
return get_object_or_404(qs, promoted_addon__addon=addon)
|
||||||
|
|
||||||
|
|
||||||
|
@dev_required
|
||||||
|
@csp_update(
|
||||||
|
SCRIPT_SRC="https://js.stripe.com",
|
||||||
|
CONNECT_SRC="https://api.stripe.com",
|
||||||
|
FRAME_SRC=["https://js.stripe.com", "https://hooks.stripe.com"],
|
||||||
|
)
|
||||||
|
def onboarding_subscription(request, addon_id, addon):
|
||||||
|
sub = get_promoted_subscription_or_404(addon=addon)
|
||||||
|
|
||||||
|
fields_to_update = {}
|
||||||
|
if addon.has_author(request.user) and not sub.link_visited_at:
|
||||||
|
fields_to_update['link_visited_at'] = datetime.datetime.now()
|
||||||
|
|
||||||
|
try:
|
||||||
|
if sub.stripe_checkout_completed:
|
||||||
|
session = retrieve_stripe_checkout_session(sub)
|
||||||
|
log.debug(
|
||||||
|
'retrieved a stripe checkout session for PromotedSubscription'
|
||||||
|
' %s.',
|
||||||
|
sub.pk
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
session = create_stripe_checkout_session(
|
||||||
|
sub, customer_email=request.user.email
|
||||||
|
)
|
||||||
|
log.debug(
|
||||||
|
'created a stripe checkout session for PromotedSubscription'
|
||||||
|
' %s.',
|
||||||
|
sub.pk
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
log.exception(
|
||||||
|
'could not retrieve or create a Stripe Checkout session for '
|
||||||
|
'PromotedSubscription {}.'.format(sub.pk)
|
||||||
|
)
|
||||||
|
return http.HttpResponseServerError()
|
||||||
|
|
||||||
|
if sub.stripe_session_id != session.id:
|
||||||
|
fields_to_update['stripe_session_id'] = session.id
|
||||||
|
|
||||||
|
if len(fields_to_update) > 0:
|
||||||
|
sub.update(**fields_to_update)
|
||||||
|
|
||||||
|
data = {
|
||||||
|
"addon": addon,
|
||||||
|
"stripe_session_id": sub.stripe_session_id,
|
||||||
|
"stripe_api_public_key": settings.STRIPE_API_PUBLIC_KEY,
|
||||||
|
"stripe_checkout_completed": sub.stripe_checkout_completed,
|
||||||
|
"stripe_checkout_cancelled": sub.stripe_checkout_cancelled,
|
||||||
|
"promoted_group": sub.promoted_addon.group,
|
||||||
|
}
|
||||||
|
return render(request, "devhub/addons/onboarding_subscription.html", data)
|
||||||
|
|
||||||
|
|
||||||
|
@dev_required
|
||||||
|
def onboarding_subscription_success(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 (success).",
|
||||||
|
sub.pk
|
||||||
|
)
|
||||||
|
raise http.Http404()
|
||||||
|
|
||||||
|
if session.payment_status == "paid" and not sub.paid_at:
|
||||||
|
# When the user has completed the Stripe Checkout process, we record
|
||||||
|
# this event.
|
||||||
|
#
|
||||||
|
# We reset `payment_cancelled_at` because it does not matter if the
|
||||||
|
# user has cancelled or not in the past (this simply means the user
|
||||||
|
# opened the Checkout page and didn't subscribe), mainly because as of
|
||||||
|
# now, the user has finally subscribed.
|
||||||
|
#
|
||||||
|
# Note: "cancellation" of an active subscription is not supported yet.
|
||||||
|
sub.update(payment_cancelled_at=None, paid_at=datetime.datetime.now())
|
||||||
|
log.info('PromotedSubscription %s has been paid.', sub.pk)
|
||||||
|
|
||||||
|
return redirect(
|
||||||
|
reverse("devhub.addons.onboarding_subscription", args=[addon.id])
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dev_required
|
||||||
|
def onboarding_subscription_cancel(request, addon_id, addon):
|
||||||
|
sub = get_promoted_subscription_or_404(addon=addon)
|
||||||
|
|
||||||
|
try:
|
||||||
|
retrieve_stripe_checkout_session(sub)
|
||||||
|
except Exception:
|
||||||
|
log.exception(
|
||||||
|
"error while trying to retrieve a stripe checkout session for "
|
||||||
|
"PromotedSubscription %s (cancel).",
|
||||||
|
sub.pk
|
||||||
|
)
|
||||||
|
raise http.Http404()
|
||||||
|
|
||||||
|
if not sub.stripe_checkout_completed:
|
||||||
|
# We record this date only when the user has cancelled the Stripe
|
||||||
|
# Checkout process on the Checkout page (i.e. the user has not
|
||||||
|
# subscribed yet).
|
||||||
|
#
|
||||||
|
# If the user has completed the checkout process, then we prevent this
|
||||||
|
# date to be changed.
|
||||||
|
sub.update(payment_cancelled_at=datetime.datetime.now())
|
||||||
|
log.info('PromotedSubscription %s has been cancelled.', sub.pk)
|
||||||
|
|
||||||
|
return redirect(
|
||||||
|
reverse("devhub.addons.onboarding_subscription", args=[addon.id])
|
||||||
|
)
|
||||||
|
|
|
@ -1933,3 +1933,11 @@ ADZERK_URL = f'https://e-{ADZERK_NETWORK_ID}.adzerk.net/api/v2'
|
||||||
ADZERK_IMPRESSION_TIMEOUT = 60 # seconds
|
ADZERK_IMPRESSION_TIMEOUT = 60 # seconds
|
||||||
ADZERK_EVENT_URL = f'https://e-{ADZERK_NETWORK_ID}.adzerk.net/'
|
ADZERK_EVENT_URL = f'https://e-{ADZERK_NETWORK_ID}.adzerk.net/'
|
||||||
ADZERK_EVENT_TIMEOUT = 60 * 60 * 24 # seconds
|
ADZERK_EVENT_TIMEOUT = 60 * 60 * 24 # seconds
|
||||||
|
|
||||||
|
# Subscription
|
||||||
|
STRIPE_API_SECRET_KEY = env('STRIPE_API_SECRET_KEY', default=None)
|
||||||
|
STRIPE_API_PUBLIC_KEY = env('STRIPE_API_PUBLIC_KEY', default=None)
|
||||||
|
STRIPE_API_VERIFIED_PRICE_ID = env('STRIPE_API_VERIFIED_PRICE_ID',
|
||||||
|
default=None)
|
||||||
|
STRIPE_API_SPONSORED_PRICE_ID = env('STRIPE_API_SPONSORED_PRICE_ID',
|
||||||
|
default=None)
|
||||||
|
|
|
@ -0,0 +1,22 @@
|
||||||
|
# Generated by Django 2.2.16 on 2020-10-16 14:48
|
||||||
|
|
||||||
|
from django.db import migrations
|
||||||
|
|
||||||
|
|
||||||
|
def create_waffle_switch(apps, schema_editor):
|
||||||
|
Switch = apps.get_model("waffle", "Switch")
|
||||||
|
|
||||||
|
Switch.objects.create(
|
||||||
|
name="enable-subscriptions-for-promoted-addons",
|
||||||
|
active=False,
|
||||||
|
note="Enable the subscription feature for promoted add-ons.",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("promoted", "0010_promotedsubscription"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [migrations.RunPython(create_waffle_switch)]
|
|
@ -5,6 +5,7 @@ from urllib.parse import urljoin
|
||||||
|
|
||||||
from olympia.addons.models import Addon
|
from olympia.addons.models import Addon
|
||||||
from olympia.amo.models import ModelBase
|
from olympia.amo.models import ModelBase
|
||||||
|
from olympia.amo.urlresolvers import reverse
|
||||||
from olympia.constants.applications import APP_IDS, APPS_CHOICES, APP_USAGE
|
from olympia.constants.applications import APP_IDS, APPS_CHOICES, APP_USAGE
|
||||||
from olympia.constants.promoted import (
|
from olympia.constants.promoted import (
|
||||||
NOT_PROMOTED, PRE_REVIEW_GROUPS, PROMOTED_GROUPS, PROMOTED_GROUPS_BY_ID,
|
NOT_PROMOTED, PRE_REVIEW_GROUPS, PROMOTED_GROUPS, PROMOTED_GROUPS_BY_ID,
|
||||||
|
@ -190,11 +191,20 @@ class PromotedSubscription(ModelBase):
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return f'Subscription for {self.promoted_addon}'
|
return f'Subscription for {self.promoted_addon}'
|
||||||
|
|
||||||
def get_onboarding_url(self):
|
def get_onboarding_url(self, absolute=True):
|
||||||
if not self.id:
|
if not self.id:
|
||||||
return None
|
return None
|
||||||
return urljoin(
|
|
||||||
settings.EXTERNAL_SITE_URL,
|
url = reverse('devhub.addons.onboarding_subscription',
|
||||||
# TODO: replace with `reverse()` once we have a route/view.
|
args=[self.promoted_addon.addon.slug])
|
||||||
f'/{self.promoted_addon.addon.slug}/onboarding'
|
if absolute:
|
||||||
)
|
url = urljoin(settings.EXTERNAL_SITE_URL, url)
|
||||||
|
return url
|
||||||
|
|
||||||
|
@property
|
||||||
|
def stripe_checkout_completed(self):
|
||||||
|
return bool(self.paid_at)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def stripe_checkout_cancelled(self):
|
||||||
|
return bool(self.payment_cancelled_at)
|
||||||
|
|
|
@ -1,4 +1,9 @@
|
||||||
|
import datetime
|
||||||
|
|
||||||
|
from django.test.utils import override_settings
|
||||||
|
|
||||||
from olympia.amo.tests import addon_factory, TestCase
|
from olympia.amo.tests import addon_factory, TestCase
|
||||||
|
from olympia.amo.urlresolvers import reverse
|
||||||
from olympia.constants import applications, promoted
|
from olympia.constants import applications, promoted
|
||||||
from olympia.promoted.models import (
|
from olympia.promoted.models import (
|
||||||
PromotedAddon, PromotedApproval, PromotedSubscription)
|
PromotedAddon, PromotedApproval, PromotedSubscription)
|
||||||
|
@ -80,6 +85,19 @@ class TestPromotedSubscription(TestCase):
|
||||||
|
|
||||||
assert sub.get_onboarding_url() is None
|
assert sub.get_onboarding_url() is None
|
||||||
|
|
||||||
|
def test_get_relative_onboarding_url(self):
|
||||||
|
promoted_addon = PromotedAddon.objects.create(
|
||||||
|
addon=addon_factory(), group_id=promoted.SPONSORED.id
|
||||||
|
)
|
||||||
|
sub = PromotedSubscription.objects.filter(
|
||||||
|
promoted_addon=promoted_addon
|
||||||
|
).get()
|
||||||
|
|
||||||
|
assert sub.get_onboarding_url(absolute=False) == reverse(
|
||||||
|
"devhub.addons.onboarding_subscription",
|
||||||
|
args=[sub.promoted_addon.addon.slug],
|
||||||
|
)
|
||||||
|
|
||||||
def test_get_onboarding_url(self):
|
def test_get_onboarding_url(self):
|
||||||
promoted_addon = PromotedAddon.objects.create(
|
promoted_addon = PromotedAddon.objects.create(
|
||||||
addon=addon_factory(), group_id=promoted.SPONSORED.id
|
addon=addon_factory(), group_id=promoted.SPONSORED.id
|
||||||
|
@ -88,4 +106,30 @@ class TestPromotedSubscription(TestCase):
|
||||||
promoted_addon=promoted_addon
|
promoted_addon=promoted_addon
|
||||||
).get()
|
).get()
|
||||||
|
|
||||||
assert 'onboarding' in sub.get_onboarding_url()
|
external_site_url = "http://example.org"
|
||||||
|
with override_settings(EXTERNAL_SITE_URL=external_site_url):
|
||||||
|
assert sub.get_onboarding_url() == "{}{}".format(
|
||||||
|
external_site_url,
|
||||||
|
reverse(
|
||||||
|
"devhub.addons.onboarding_subscription",
|
||||||
|
args=[sub.promoted_addon.addon.slug],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_stripe_checkout_completed(self):
|
||||||
|
sub = PromotedSubscription()
|
||||||
|
|
||||||
|
assert not sub.stripe_checkout_completed
|
||||||
|
|
||||||
|
sub.update(paid_at=datetime.datetime.now())
|
||||||
|
|
||||||
|
assert sub.stripe_checkout_completed
|
||||||
|
|
||||||
|
def test_stripe_checkout_cancelled(self):
|
||||||
|
sub = PromotedSubscription()
|
||||||
|
|
||||||
|
assert not sub.stripe_checkout_cancelled
|
||||||
|
|
||||||
|
sub.update(payment_cancelled_at=datetime.datetime.now())
|
||||||
|
|
||||||
|
assert sub.stripe_checkout_cancelled
|
||||||
|
|
|
@ -0,0 +1,83 @@
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from unittest import mock
|
||||||
|
|
||||||
|
from django.test.utils import override_settings
|
||||||
|
|
||||||
|
from olympia.amo.templatetags.jinja_helpers import absolutify
|
||||||
|
from olympia.amo.tests import addon_factory
|
||||||
|
from olympia.amo.urlresolvers import reverse
|
||||||
|
from olympia.constants.promoted import SPONSORED, RECOMMENDED
|
||||||
|
from olympia.promoted.models import PromotedSubscription, PromotedAddon
|
||||||
|
from olympia.promoted.utils import (
|
||||||
|
create_stripe_checkout_session,
|
||||||
|
retrieve_stripe_checkout_session,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_retrieve_stripe_checkout_session():
|
||||||
|
stripe_session_id = "some stripe session id"
|
||||||
|
sub = PromotedSubscription(stripe_session_id=stripe_session_id)
|
||||||
|
|
||||||
|
with mock.patch(
|
||||||
|
"olympia.promoted.utils.stripe.checkout.Session.retrieve"
|
||||||
|
) as stripe_retrieve:
|
||||||
|
retrieve_stripe_checkout_session(subscription=sub)
|
||||||
|
|
||||||
|
stripe_retrieve.assert_called_once_with(stripe_session_id)
|
||||||
|
|
||||||
|
|
||||||
|
@override_settings(STRIPE_API_SPONSORED_PRICE_ID="sponsored-price-id")
|
||||||
|
def test_create_stripe_checkout_session():
|
||||||
|
addon = addon_factory()
|
||||||
|
promoted_addon = PromotedAddon.objects.create(
|
||||||
|
addon=addon, group_id=SPONSORED.id
|
||||||
|
)
|
||||||
|
sub = PromotedSubscription.objects.filter(
|
||||||
|
promoted_addon=promoted_addon
|
||||||
|
).get()
|
||||||
|
customer_email = "some-email@example.org"
|
||||||
|
fake_session = "fake session"
|
||||||
|
|
||||||
|
with mock.patch(
|
||||||
|
"olympia.promoted.utils.stripe.checkout.Session.create"
|
||||||
|
) as stripe_create:
|
||||||
|
stripe_create.return_value = fake_session
|
||||||
|
session = create_stripe_checkout_session(
|
||||||
|
subscription=sub, customer_email=customer_email
|
||||||
|
)
|
||||||
|
|
||||||
|
assert session == fake_session
|
||||||
|
stripe_create.assert_called_once_with(
|
||||||
|
payment_method_types=["card"],
|
||||||
|
mode="subscription",
|
||||||
|
cancel_url=absolutify(
|
||||||
|
reverse(
|
||||||
|
"devhub.addons.onboarding_subscription_cancel",
|
||||||
|
args=[addon.id],
|
||||||
|
)
|
||||||
|
),
|
||||||
|
success_url=absolutify(
|
||||||
|
reverse(
|
||||||
|
"devhub.addons.onboarding_subscription_success",
|
||||||
|
args=[addon.id],
|
||||||
|
)
|
||||||
|
),
|
||||||
|
line_items=[{"price": "sponsored-price-id", "quantity": 1}],
|
||||||
|
customer_email=customer_email,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_stripe_checkout_session_with_invalid_group_id():
|
||||||
|
promoted_addon = PromotedAddon.objects.create(
|
||||||
|
addon=addon_factory(), group_id=RECOMMENDED.id
|
||||||
|
)
|
||||||
|
# We create the promoted subscription because the promoted add-on above
|
||||||
|
# (recommended) does not create it automatically. This is because
|
||||||
|
# recommended add-ons should not have a subscription.
|
||||||
|
sub = PromotedSubscription.objects.create(promoted_addon=promoted_addon)
|
||||||
|
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
create_stripe_checkout_session(
|
||||||
|
subscription=sub, customer_email="doesnotmatter@example.org"
|
||||||
|
)
|
|
@ -0,0 +1,54 @@
|
||||||
|
import stripe
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
|
|
||||||
|
from olympia.amo.templatetags.jinja_helpers import absolutify
|
||||||
|
from olympia.amo.urlresolvers import reverse
|
||||||
|
from olympia.constants.promoted import SPONSORED, VERIFIED
|
||||||
|
|
||||||
|
|
||||||
|
def create_stripe_checkout_session(subscription, customer_email):
|
||||||
|
"""This function creates a Stripe Checkout Session object for a given
|
||||||
|
subscription. The `customer_email` is passed to Stripe to autofill the
|
||||||
|
input field on the Checkout page.
|
||||||
|
|
||||||
|
This function might raise if the promoted group isn't supported or the API
|
||||||
|
call has failed."""
|
||||||
|
price_id = {
|
||||||
|
SPONSORED.id: settings.STRIPE_API_SPONSORED_PRICE_ID,
|
||||||
|
VERIFIED.id: settings.STRIPE_API_VERIFIED_PRICE_ID,
|
||||||
|
}.get(subscription.promoted_addon.group_id)
|
||||||
|
|
||||||
|
if not price_id:
|
||||||
|
raise ValueError(
|
||||||
|
"No price ID for promoted group ID: {}.".format(
|
||||||
|
subscription.promoted_addon.group_id
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
stripe.api_key = settings.STRIPE_API_SECRET_KEY
|
||||||
|
return stripe.checkout.Session.create(
|
||||||
|
payment_method_types=["card"],
|
||||||
|
mode="subscription",
|
||||||
|
cancel_url=absolutify(
|
||||||
|
reverse(
|
||||||
|
"devhub.addons.onboarding_subscription_cancel",
|
||||||
|
args=[subscription.promoted_addon.addon_id],
|
||||||
|
)
|
||||||
|
),
|
||||||
|
success_url=absolutify(
|
||||||
|
reverse(
|
||||||
|
"devhub.addons.onboarding_subscription_success",
|
||||||
|
args=[subscription.promoted_addon.addon_id],
|
||||||
|
)
|
||||||
|
),
|
||||||
|
line_items=[{"price": price_id, "quantity": 1}],
|
||||||
|
customer_email=customer_email,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def retrieve_stripe_checkout_session(subscription):
|
||||||
|
"""This function returns a Stripe Checkout Session 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.checkout.Session.retrieve(subscription.stripe_session_id)
|
|
@ -0,0 +1,11 @@
|
||||||
|
const checkoutButton = document.getElementById('checkout-button');
|
||||||
|
|
||||||
|
if (checkoutButton && checkoutButton.dataset) {
|
||||||
|
const stripe = Stripe(checkoutButton.dataset['publickey']);
|
||||||
|
|
||||||
|
checkoutButton.addEventListener('click', function () {
|
||||||
|
stripe.redirectToCheckout({
|
||||||
|
sessionId: checkoutButton.dataset['sessionid'],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
Загрузка…
Ссылка в новой задаче