diff --git a/corporate/tests/stripe_fixtures/payment_method_string:Customer.retrieve.3.json b/corporate/tests/stripe_fixtures/payment_method_string:Customer.retrieve.3.json index 69c2603298..e5e5d74652 100644 Binary files a/corporate/tests/stripe_fixtures/payment_method_string:Customer.retrieve.3.json and b/corporate/tests/stripe_fixtures/payment_method_string:Customer.retrieve.3.json differ diff --git a/corporate/tests/stripe_fixtures/payment_method_string:Customer.retrieve.4.json b/corporate/tests/stripe_fixtures/payment_method_string:Customer.retrieve.4.json index e5e5d74652..a88ef2aa56 100644 Binary files a/corporate/tests/stripe_fixtures/payment_method_string:Customer.retrieve.4.json and b/corporate/tests/stripe_fixtures/payment_method_string:Customer.retrieve.4.json differ diff --git a/corporate/tests/stripe_fixtures/payment_method_string:Customer.retrieve.5.json b/corporate/tests/stripe_fixtures/payment_method_string:Customer.retrieve.5.json index a88ef2aa56..b0d5dfb087 100644 Binary files a/corporate/tests/stripe_fixtures/payment_method_string:Customer.retrieve.5.json and b/corporate/tests/stripe_fixtures/payment_method_string:Customer.retrieve.5.json differ diff --git a/corporate/tests/stripe_fixtures/payment_method_string:Customer.retrieve.6.json b/corporate/tests/stripe_fixtures/payment_method_string:Customer.retrieve.6.json index b0d5dfb087..cb6c2a7131 100644 Binary files a/corporate/tests/stripe_fixtures/payment_method_string:Customer.retrieve.6.json and b/corporate/tests/stripe_fixtures/payment_method_string:Customer.retrieve.6.json differ diff --git a/corporate/tests/stripe_fixtures/payment_method_string:Customer.retrieve.7.json b/corporate/tests/stripe_fixtures/payment_method_string:Customer.retrieve.7.json index cb6c2a7131..fa52968bb9 100644 Binary files a/corporate/tests/stripe_fixtures/payment_method_string:Customer.retrieve.7.json and b/corporate/tests/stripe_fixtures/payment_method_string:Customer.retrieve.7.json differ diff --git a/corporate/tests/stripe_fixtures/payment_method_string:Customer.retrieve.8.json b/corporate/tests/stripe_fixtures/payment_method_string:Customer.retrieve.8.json deleted file mode 100644 index d5e15f9757..0000000000 Binary files a/corporate/tests/stripe_fixtures/payment_method_string:Customer.retrieve.8.json and /dev/null differ diff --git a/corporate/tests/stripe_fixtures/upgrade_where_subscription_save_fails_at_first:Customer.retrieve.4.json b/corporate/tests/stripe_fixtures/upgrade_where_subscription_save_fails_at_first:Customer.retrieve.4.json index df30e12fe7..ec42d58bb9 100644 Binary files a/corporate/tests/stripe_fixtures/upgrade_where_subscription_save_fails_at_first:Customer.retrieve.4.json and b/corporate/tests/stripe_fixtures/upgrade_where_subscription_save_fails_at_first:Customer.retrieve.4.json differ diff --git a/corporate/tests/stripe_fixtures/upgrade_where_subscription_save_fails_at_first:Customer.retrieve.5.json b/corporate/tests/stripe_fixtures/upgrade_where_subscription_save_fails_at_first:Customer.retrieve.5.json deleted file mode 100644 index ec42d58bb9..0000000000 Binary files a/corporate/tests/stripe_fixtures/upgrade_where_subscription_save_fails_at_first:Customer.retrieve.5.json and /dev/null differ diff --git a/corporate/tests/test_stripe.py b/corporate/tests/test_stripe.py index 3700432a09..5b87fd8575 100644 --- a/corporate/tests/test_stripe.py +++ b/corporate/tests/test_stripe.py @@ -271,8 +271,11 @@ class StripeTest(ZulipTestCase): 'stripe_token': stripe_token, 'billing_modality': 'charge_automatically', }) + params.update(kwargs) - return self.client_post("/upgrade/", params, **host_args) + for key, value in params.items(): + params[key] = ujson.dumps(value) + return self.client_post("/json/billing/upgrade", params, **host_args) @patch("corporate.lib.stripe.billing_logger.error") def test_catch_stripe_errors(self, mock_billing_logger_error: Mock) -> None: @@ -471,48 +474,43 @@ class StripeTest(ZulipTestCase): def test_upgrade_with_tampered_seat_count(self) -> None: self.login(self.example_email("hamlet")) response = self.upgrade(talk_to_stripe=False, salt='badsalt') - self.assert_in_success_response(["Upgrade to Zulip Standard"], response) - self.assertEqual(response['error_description'], 'tampered seat count') + self.assert_json_error_contains(response, "Something went wrong. Please contact") + self.assertEqual(ujson.loads(response.content)['error_description'], 'tampered seat count') def test_upgrade_with_tampered_plan(self) -> None: # Test with an unknown plan self.login(self.example_email("hamlet")) response = self.upgrade(talk_to_stripe=False, plan='badplan') - self.assert_in_success_response(["Upgrade to Zulip Standard"], response) - self.assertEqual(response['error_description'], 'tampered plan') + self.assert_json_error_contains(response, "Something went wrong. Please contact") + self.assertEqual(ujson.loads(response.content)['error_description'], 'tampered plan') # Test with a plan that's valid, but not if you're paying by invoice response = self.upgrade(invoice=True, talk_to_stripe=False, plan=Plan.CLOUD_MONTHLY) - self.assert_in_success_response(["Upgrade to Zulip Standard"], response) - self.assertEqual(response['error_description'], 'tampered plan') + self.assert_json_error_contains(response, "Something went wrong. Please contact") + self.assertEqual(ujson.loads(response.content)['error_description'], 'tampered plan') def test_upgrade_with_insufficient_invoiced_seat_count(self) -> None: self.login(self.example_email("hamlet")) # Test invoicing for less than MIN_INVOICED_SEAT_COUNT response = self.upgrade(invoice=True, talk_to_stripe=False, invoiced_seat_count=MIN_INVOICED_SEAT_COUNT - 1) - self.assert_in_success_response(["Upgrade to Zulip Standard", - "at least %d users" % (MIN_INVOICED_SEAT_COUNT,)], response) - self.assertEqual(response['error_description'], 'lowball seat count') + self.assert_json_error_contains(response, "at least {} users.".format(MIN_INVOICED_SEAT_COUNT)) + self.assertEqual(ujson.loads(response.content)['error_description'], 'lowball seat count') # Test invoicing for less than your user count with patch("corporate.views.MIN_INVOICED_SEAT_COUNT", 3): response = self.upgrade(invoice=True, talk_to_stripe=False, invoiced_seat_count=4) - self.assert_in_success_response(["Upgrade to Zulip Standard", - "at least %d users" % (self.seat_count,)], response) - self.assertEqual(response['error_description'], 'lowball seat count') - # Test not setting an invoiced_seat_count + self.assert_json_error_contains(response, "at least {} users.".format(self.seat_count)) + self.assertEqual(ujson.loads(response.content)['error_description'], 'lowball seat count') + # Test not setting invoiced_seat_count response = self.upgrade(invoice=True, talk_to_stripe=False, invoiced_seat_count=None) - self.assert_in_success_response(["Upgrade to Zulip Standard", - "at least %d users" % (MIN_INVOICED_SEAT_COUNT,)], response) - self.assertEqual(response['error_description'], 'lowball seat count') + self.assert_json_error_contains(response, "invoiced_seat_count is not an integer") @patch("corporate.lib.stripe.billing_logger.error") def test_upgrade_with_uncaught_exception(self, mock_: Mock) -> None: self.login(self.example_email("hamlet")) with patch("corporate.views.process_initial_upgrade", side_effect=Exception): response = self.upgrade(talk_to_stripe=False) - self.assert_in_success_response(["Upgrade to Zulip Standard", - "Something went wrong. Please contact"], response) - self.assertEqual(response['error_description'], 'uncaught exception during upgrade') + self.assert_json_error_contains(response, "Something went wrong. Please contact zulip-admin@example.com.") + self.assertEqual(ujson.loads(response.content)['error_description'], 'uncaught exception during upgrade') @mock_stripe(tested_timestamp_fields=["created"]) def test_upgrade_billing_by_invoice(self, *mocks: Mock) -> None: @@ -908,7 +906,7 @@ class RequiresBillingAccessTest(ZulipTestCase): params = [ ("/json/billing/sources/change", "do_replace_payment_source", {'stripe_token': ujson.dumps('token')}), - ("/json/billing/downgrade", "process_downgrade", {}) + ("/json/billing/downgrade", "process_downgrade", {}), ] # type: List[Tuple[str, str, Dict[str, Any]]] for (url, mocked_function_name, data) in params: @@ -919,6 +917,9 @@ class RequiresBillingAccessTest(ZulipTestCase): string_with_all_endpoints = str(get_resolver('corporate.urls').reverse_dict) json_endpoints = set([word.strip("\"'()[],$") for word in string_with_all_endpoints.split() if 'json' in word]) + # No need to test upgrade endpoint as it only requires user to be logged in. + json_endpoints.remove("json/billing/upgrade") + self.assertEqual(len(json_endpoints), len(params)) class BillingProcessorTest(ZulipTestCase): diff --git a/corporate/urls.py b/corporate/urls.py index 1f7d6f3ab4..f010a3f3d3 100644 --- a/corporate/urls.py +++ b/corporate/urls.py @@ -17,6 +17,8 @@ i18n_urlpatterns = [ ] # type: Any v1_api_and_json_patterns = [ + url(r'^billing/upgrade$', rest_dispatch, + {'POST': 'corporate.views.upgrade'}), url(r'^billing/downgrade$', rest_dispatch, {'POST': 'corporate.views.downgrade'}), url(r'^billing/sources/change', rest_dispatch, diff --git a/corporate/views.py b/corporate/views.py index 0ecf1ca994..382a4651a1 100644 --- a/corporate/views.py +++ b/corporate/views.py @@ -14,7 +14,7 @@ from zerver.decorator import zulip_login_required, require_billing_access from zerver.lib.json_encoder_for_html import JSONEncoderForHTML from zerver.lib.request import REQ, has_request_variables from zerver.lib.response import json_error, json_success -from zerver.lib.validator import check_string +from zerver.lib.validator import check_string, check_int from zerver.lib.timestamp import timestamp_to_datetime from zerver.models import UserProfile, Realm from corporate.lib.stripe import STRIPE_PUBLISHABLE_KEY, \ @@ -66,15 +66,41 @@ def payment_method_string(stripe_customer: stripe.Customer) -> str: # a customer via the Stripe dashboard. return _("Unknown payment method. Please contact %s." % (settings.ZULIP_ADMINISTRATOR,)) # nocoverage +@has_request_variables +def upgrade(request: HttpRequest, user: UserProfile, + plan: str=REQ(validator=check_string), + signed_seat_count: str=REQ(validator=check_string), + salt: str=REQ(validator=check_string), + billing_modality: str=REQ(validator=check_string), + invoiced_seat_count: int=REQ(validator=check_int, default=-1), + stripe_token: str=REQ(validator=check_string, default=None)) -> HttpResponse: + try: + plan, seat_count = unsign_and_check_upgrade_parameters(user, plan, signed_seat_count, + salt, billing_modality) + if billing_modality == 'send_invoice': + min_required_seat_count = max(seat_count, MIN_INVOICED_SEAT_COUNT) + if invoiced_seat_count < min_required_seat_count: + raise BillingError( + 'lowball seat count', + "You must invoice for at least %d users." % (min_required_seat_count,)) + seat_count = invoiced_seat_count + process_initial_upgrade(user, plan, seat_count, stripe_token) + except BillingError as e: + return json_error(e.message, data={'error_description': e.description}) + except Exception as e: + billing_logger.exception("Uncaught exception in billing: %s" % (e,)) + error_message = BillingError.CONTACT_SUPPORT + error_description = "uncaught exception during upgrade" + return json_error(error_message, data={'error_description': error_description}) + else: + return json_success() + @zulip_login_required def initial_upgrade(request: HttpRequest) -> HttpResponse: if not settings.BILLING_ENABLED: return render(request, "404.html") user = request.user - error_message = "" - error_description = "" # only used in tests - customer = Customer.objects.filter(realm=user.realm).first() if customer is not None and customer.has_billing_relationship: return HttpResponseRedirect(reverse('corporate.views.billing_home')) @@ -85,33 +111,6 @@ def initial_upgrade(request: HttpRequest) -> HttpResponse: if stripe_customer.discount is not None: percent_off = stripe_customer.discount.coupon.percent_off - if request.method == 'POST': - try: - plan, seat_count = unsign_and_check_upgrade_parameters( - user, request.POST['plan'], request.POST['signed_seat_count'], request.POST['salt'], - request.POST['billing_modality']) - if request.POST['billing_modality'] == 'send_invoice': - try: - invoiced_seat_count = int(request.POST['invoiced_seat_count']) - except (KeyError, ValueError): - invoiced_seat_count = -1 - min_required_seat_count = max(seat_count, MIN_INVOICED_SEAT_COUNT) - if invoiced_seat_count < min_required_seat_count: - raise BillingError( - 'lowball seat count', - "You must invoice for at least %d users." % (min_required_seat_count,)) - seat_count = invoiced_seat_count - process_initial_upgrade(user, plan, seat_count, request.POST.get('stripe_token', None)) - except BillingError as e: - error_message = e.message - error_description = e.description - except Exception as e: - billing_logger.exception("Uncaught exception in billing: %s" % (e,)) - error_message = BillingError.CONTACT_SUPPORT - error_description = "uncaught exception during upgrade" - else: - return HttpResponseRedirect(reverse('corporate.views.billing_home')) - seat_count = get_seat_count(user.realm) signed_seat_count, salt = sign_string(str(seat_count)) context = { @@ -125,7 +124,6 @@ def initial_upgrade(request: HttpRequest) -> HttpResponse: 'plan': "Zulip Standard", 'nickname_monthly': Plan.CLOUD_MONTHLY, 'nickname_annual': Plan.CLOUD_ANNUAL, - 'error_message': error_message, 'page_params': JSONEncoderForHTML().encode({ 'seat_count': seat_count, 'nickname_annual': Plan.CLOUD_ANNUAL, @@ -136,7 +134,6 @@ def initial_upgrade(request: HttpRequest) -> HttpResponse: }), } # type: Dict[str, Any] response = render(request, 'corporate/upgrade.html', context=context) - response['error_description'] = error_description return response PLAN_NAMES = { diff --git a/static/js/billing/billing.js b/static/js/billing/billing.js index 47cdea14c8..595e12cb59 100644 --- a/static/js/billing/billing.js +++ b/static/js/billing/billing.js @@ -1,47 +1,49 @@ $(function () { - var stripe_key = $("#payment-method").data("key"); - var handler = StripeCheckout.configure({ // eslint-disable-line no-undef - key: stripe_key, - image: '/static/images/logo/zulip-icon-128x128.png', - locale: 'auto', - token: function (stripe_token) { - var csrf_token = $("#payment-method").data("csrf"); - loading.make_indicator($('#updating_card_indicator'), - {text: 'Updating card. Please wait ...', abs_positioned: true}); - $("#payment-section").hide(); - $("#loading-section").show(); - $.post({ - url: "/json/billing/sources/change", - data: { - stripe_token: JSON.stringify(stripe_token.id), - csrfmiddlewaretoken: csrf_token, - }, - success: function () { - $("#loading-section").hide(); - $("#card-updated-message").show(); - location.reload(); - }, - error: function (xhr) { - $("#loading-section").hide(); - $('#error-message-box').show().text(JSON.parse(xhr.responseText).msg); - }, - }); - }, - }); - - $('#update-card-button').on('click', function (e) { - var email = $("#payment-method").data("email"); - handler.open({ - name: 'Zulip', - zipCode: true, - billingAddress: true, - panelLabel: "Update card", - email: email, - label: "Update card", - allowRememberMe: false, + if (window.location.pathname === '/billing/') { + var stripe_key = $("#payment-method").data("key"); + var card_change_handler = StripeCheckout.configure({ // eslint-disable-line no-undef + key: stripe_key, + image: '/static/images/logo/zulip-icon-128x128.png', + locale: 'auto', + token: function (stripe_token) { + var csrf_token = $("#payment-method").data("csrf"); + loading.make_indicator($('#updating_card_indicator'), + {text: 'Updating card. Please wait ...', abs_positioned: true}); + $("#payment-section").hide(); + $("#loading-section").show(); + $.post({ + url: "/json/billing/sources/change", + data: { + stripe_token: JSON.stringify(stripe_token.id), + csrfmiddlewaretoken: csrf_token, + }, + success: function () { + $("#loading-section").hide(); + $("#card-updated-message").show(); + location.reload(); + }, + error: function (xhr) { + $("#loading-section").hide(); + $('#error-message-box').show().text(JSON.parse(xhr.responseText).msg); + }, + }); + }, }); - e.preventDefault(); - }); + + $('#update-card-button').on('click', function (e) { + var email = $("#payment-method").data("email"); + card_change_handler.open({ + name: 'Zulip', + zipCode: true, + billingAddress: true, + panelLabel: "Update card", + email: email, + label: "Update card", + allowRememberMe: false, + }); + e.preventDefault(); + }); + } var hash = window.location.hash; if (hash) { @@ -76,6 +78,101 @@ $(function () { } if (window.location.pathname === '/upgrade/') { + var add_card_handler = StripeCheckout.configure({ // eslint-disable-line no-undef + key: $("#autopay-form").data("key"), + image: '/static/images/logo/zulip-icon-128x128.png', + locale: 'auto', + token: function (stripe_token) { + function get_form_input(name) { + return JSON.stringify($("#autopay-form input[name='" + name + "']").val()); + } + + loading.make_indicator($('#autopay_loading_indicator'), + {text: 'Processing ...', abs_positioned: true}); + $("#autopay-input-section").hide(); + $('#autopay-error').hide(); + $("#autopay-loading").show(); + $.post({ + url: "/json/billing/upgrade", + data: { + stripe_token: JSON.stringify(stripe_token.id), + csrfmiddlewaretoken: $("#autopay-form input[name='csrf']").val(), + signed_seat_count: get_form_input("signed_seat_count"), + salt: get_form_input("salt"), + plan: get_form_input("plan"), + billing_modality: get_form_input("billing_modality"), + }, + success: function () { + $("#autopay-loading").hide(); + $('#autopay-error').hide(); + $("#autopay-success").show(); + location.reload(); + }, + error: function (xhr) { + $("#autopay-loading").hide(); + $('#autopay-error').show().text(JSON.parse(xhr.responseText).msg); + $("#autopay-input-section").show(); + }, + }); + }, + }); + + $('#add-card-button').on('click', function (e) { + add_card_handler.open({ + name: 'Zulip', + zipCode: true, + billingAddress: true, + panelLabel: "Make payment", + email: $("#autopay-form").data("email"), + label: "Add card", + allowRememberMe: false, + description: "Zulip Cloud Standard", + }); + e.preventDefault(); + }); + + $("#invoice-button").on("click", function (e) { + if ($("#invoiced_seat_count")[0].checkValidity() === false) { + return; + } + e.preventDefault(); + + function get_form_input(name, stringify = true) { + var value = $("#invoice-form input[name='" + name + "']").val(); + if (stringify) { + value = JSON.stringify(value); + } + return value; + } + loading.make_indicator($('#invoice_loading_indicator'), + {text: 'Processing ...', abs_positioned: true}); + $("#invoice-input-section").hide(); + $('#invoice-error').hide(); + $("#invoice-loading").show(); + $.post({ + url: "/json/billing/upgrade", + data: { + csrfmiddlewaretoken: get_form_input("csrfmiddlewaretoken", false), + signed_seat_count: get_form_input("signed_seat_count"), + salt: get_form_input("salt"), + plan: get_form_input("plan"), + billing_modality: get_form_input("billing_modality"), + invoiced_seat_count: get_form_input("invoiced_seat_count", false), + }, + success: function () { + $("#invoice-loading").hide(); + $('#invoice-error').hide(); + $("#invoice-success").show(); + location.reload(); + }, + error: function (xhr) { + $("#invoice-loading").hide(); + $('#invoice-error').show().text(JSON.parse(xhr.responseText).msg); + $("#invoice-input-section").show(); + }, + }); + }); + var prices = {}; prices[page_params.nickname_annual] = page_params.annual_price * (1 - page_params.percent_off / 100); diff --git a/static/styles/billing.scss b/static/styles/billing.scss index ba4d17add9..c5a2bdfcf9 100644 --- a/static/styles/billing.scss +++ b/static/styles/billing.scss @@ -167,12 +167,24 @@ display: none; } - #loading-section { + #loading-section, + #invoice-loading, + #autopay-loading { display: none; min-height: 55px; + text-align: center; } - #card-updated-message { + + #card-updated-message, + #invoice-success, + #autopay-success { + text-align: center; + display: none; + } + + #invoice-error, + #autopay-error { text-align: center; display: none; } @@ -182,32 +194,42 @@ min-height: 30px; } - .updating-card-logo { + .zulip-loading-logo { margin: 0 auto; width: 24px; height: 24px; } - .updating-card-logo svg circle { + .zulip-loading-logo svg circle { fill: hsl(0, 0%, 27%); stroke: hsl(0, 0%, 27%); } - .updating-card-logo svg path { + .zulip-loading-logo svg path { fill: hsl(0, 0%, 100%); stroke: hsl(0, 0%, 100%); } - #updating_card_indicator { + #updating_card_indicator, + #invoice_loading_indicator, + #autopay_loading_indicator { margin: 10px auto; } - #updating_card_indicator_box_container { + #updating_card_indicator .loading_indicator_text { + margin-left: -75px; + } + + #updating_card_indicator_box_container, + #invoice_loading_indicator_box_container, + #autopay_loading_indicator_box_container { position: absolute; left: 50%; } - #updating_card_indicator_box { + #updating_card_indicator_box, + #invoice_loading_indicator_box, + #autopay_loading_indicator_box { position: relative; left: -50%; top: -41px; @@ -217,7 +239,8 @@ border-radius: 6px; } - .loading_indicator_text { - margin-left: -75px; + #invoice_loading_indicator .loading_indicator_text, + #autopay_loading_indicator .loading_indicator_text { + margin-left: -115px; } } diff --git a/templates/corporate/billing.html b/templates/corporate/billing.html index a3a7a87142..29eff023d8 100644 --- a/templates/corporate/billing.html +++ b/templates/corporate/billing.html @@ -48,7 +48,7 @@ {% endif %}