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 \
|
||||
--hash=sha256:5574505967f6d7ada8c9269a5f873cfdca9812dc9502eee2b7a86be5c3798c76 \
|
||||
--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'),
|
||||
re_path(r'^edit_(?P<section>[^/]+)(?:/(?P<editable>[^/]+))?$',
|
||||
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,
|
||||
{'upload_type': 'preview'},
|
||||
|
|
|
@ -45,6 +45,11 @@ from olympia.devhub.utils import (
|
|||
UploadRestrictionChecker, wizard_unsupported_properties)
|
||||
from olympia.files.models import File, FileUpload
|
||||
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.models import Whiteboard
|
||||
from olympia.reviewers.templatetags.code_manager import code_manager_url
|
||||
|
@ -1837,3 +1842,130 @@ def logout(request):
|
|||
logout_user(request, 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_EVENT_URL = f'https://e-{ADZERK_NETWORK_ID}.adzerk.net/'
|
||||
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.amo.models import ModelBase
|
||||
from olympia.amo.urlresolvers import reverse
|
||||
from olympia.constants.applications import APP_IDS, APPS_CHOICES, APP_USAGE
|
||||
from olympia.constants.promoted import (
|
||||
NOT_PROMOTED, PRE_REVIEW_GROUPS, PROMOTED_GROUPS, PROMOTED_GROUPS_BY_ID,
|
||||
|
@ -190,11 +191,20 @@ class PromotedSubscription(ModelBase):
|
|||
def __str__(self):
|
||||
return f'Subscription for {self.promoted_addon}'
|
||||
|
||||
def get_onboarding_url(self):
|
||||
def get_onboarding_url(self, absolute=True):
|
||||
if not self.id:
|
||||
return None
|
||||
return urljoin(
|
||||
settings.EXTERNAL_SITE_URL,
|
||||
# TODO: replace with `reverse()` once we have a route/view.
|
||||
f'/{self.promoted_addon.addon.slug}/onboarding'
|
||||
)
|
||||
|
||||
url = reverse('devhub.addons.onboarding_subscription',
|
||||
args=[self.promoted_addon.addon.slug])
|
||||
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.urlresolvers import reverse
|
||||
from olympia.constants import applications, promoted
|
||||
from olympia.promoted.models import (
|
||||
PromotedAddon, PromotedApproval, PromotedSubscription)
|
||||
|
@ -80,6 +85,19 @@ class TestPromotedSubscription(TestCase):
|
|||
|
||||
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):
|
||||
promoted_addon = PromotedAddon.objects.create(
|
||||
addon=addon_factory(), group_id=promoted.SPONSORED.id
|
||||
|
@ -88,4 +106,30 @@ class TestPromotedSubscription(TestCase):
|
|||
promoted_addon=promoted_addon
|
||||
).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'],
|
||||
});
|
||||
});
|
||||
}
|
Загрузка…
Ссылка в новой задаче