billing: Migrate /upgrade endpoint to JSON.

The fixture changes are because self.upgrade formerly used to cause a page load
of /billing, which in turn calls Customer.retrieve.

If we ran the full test suite with GENERATE_STRIPE_FIXTURES=True, we would
likely see several more Customer.retrieve.N.json's being deleted. But
keeping them there for now to keep the diff small.
This commit is contained in:
Vishnu Ks 2018-12-07 23:13:22 +05:30 committed by Rishi Gupta
parent 647103a4e0
commit 0fd6ff722b
16 changed files with 325 additions and 179 deletions

View File

@ -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):

View File

@ -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,

View File

@ -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 = {

View File

@ -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);

View File

@ -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;
}
}

View File

@ -48,7 +48,7 @@
{% endif %}
</div>
<div id="loading-section">
<div class="updating-card-logo">
<div class="zulip-loading-logo">
<svg xmlns="http://www.w3.org/2000/svg" width="100%" height="100%" viewBox="0 0 40 40" version="1.1">
<g transform="translate(-297.14285,-466.64792)">
<circle cx="317.14285" cy="486.64792" r="19.030317" style="stroke-width:1.93936479;"/>

View File

@ -13,6 +13,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0">
{{ render_bundle('landing-page') }}
{{ render_bundle('billing') }}
<script src="https://checkout.stripe.com/checkout.js"></script>
{% endblock %}
{% block content %}
@ -42,85 +43,109 @@
<div class="tab-content">
<div class="tab-pane active" id="autopay">
<form method="post">
{{ csrf_input }}
<input type="hidden" name="seat_count" value="{{ seat_count }}">
<input type="hidden" name="signed_seat_count" value="{{ signed_seat_count }}">
<input type="hidden" name="salt" value="{{ salt }}">
<input type="hidden" name="billing_modality" value="charge_automatically">
<div class="payment-schedule">
<h3>{{ _("Payment schedule") }}</h3>
<label>
<input type="radio" name="plan" value="{{ nickname_annual }}" checked />
<div class="box">
<div class="schedule-time annually">{{ _("Pay annually") }}</div>
<div class="schedule-amount">
$<span id="autopay_annual_price_per_month"></span>/user/month
<div class="schedule-amount-2">
($<span id="autopay_annual_price"></span>/user/year)
<div id="autopay-input-section">
<form id="autopay-form" data-key="{{ publishable_key }}" data-email="{{email}}" method="post">
<input type="hidden" name="seat_count" value="{{ seat_count }}">
<input type="hidden" name="signed_seat_count" value="{{ signed_seat_count }}">
<input type="hidden" name="salt" value="{{ salt }}">
<input type="hidden" name="billing_modality" value="charge_automatically">
<input type="hidden" name="csrf" value="{{ csrf_token }}">
<div class="payment-schedule">
<h3>{{ _("Payment schedule") }}</h3>
<label>
<input type="radio" name="plan" value="{{ nickname_annual }}" checked />
<div class="box">
<div class="schedule-time annually">{{ _("Pay annually") }}</div>
<div class="schedule-amount">
$<span id="autopay_annual_price_per_month"></span>/user/month
<div class="schedule-amount-2">
($<span id="autopay_annual_price"></span>/user/year)
</div>
</div>
</div>
</div>
</label>
<label>
<input type="radio" name="plan" value="{{ nickname_monthly }}" />
<div class="box">
<div class="schedule-time">{{ _("Pay monthly") }}</div>
<div class="schedule-amount">$<span id="autopay_monthly_price"></span>/user/month</div>
</div>
</label>
</label>
<label>
<input type="radio" name="plan" value="{{ nickname_monthly }}" />
<div class="box">
<div class="schedule-time">{{ _("Pay monthly") }}</div>
<div class="schedule-amount">$<span id="autopay_monthly_price"></span>/user/month</div>
</div>
</label>
</div>
<p>
You&rsquo;ll initially be charged
<b>$<span id="charged_amount"></span></b>
for <b>{{ seat_count }}</b> users. Well automatically charge you
when new users are added, and give you credit when users are deactivated.
</p>
<button id="add-card-button" class="stripe-button-el">
<span id="add-card-button-span">Add card</span>
</button>
</form>
</div>
<div id="autopay-loading">
<div class="zulip-loading-logo">
<svg xmlns="http://www.w3.org/2000/svg" width="100%" height="100%" viewBox="0 0 40 40" version="1.1">
<g transform="translate(-297.14285,-466.64792)">
<circle cx="317.14285" cy="486.64792" r="19.030317" style="stroke-width:1.93936479;"/>
<path d="m309.24286 477.14791 14.2 0 1.6 3.9-11.2 11.9 9.6 0 1.6 3.2-14.2 0-1.6-3.9 11.2-11.9-9.6 0z"/>
</g>
</svg>
</div>
<p>
You&rsquo;ll initially be charged
<b>$<span id="charged_amount"></span></b>
for <b>{{ seat_count }}</b> users. Well automatically charge you
when new users are added, and give you credit when users are deactivated.
</p>
<script src="https://checkout.stripe.com/checkout.js" class="stripe-button"
data-key="{{ publishable_key }}"
data-image="/static/images/logo/zulip-icon-128x128.png"
data-name="Zulip"
data-description="Zulip Cloud Standard"
data-locale="auto"
data-zip-code="true"
data-billing-address="true"
data-panel-label="Make payment"
data-email="{{ email }}"
data-label="{{ _('Add card') }}"
data-allow-remember-me="false"
>
</script>
</form>
<div id="autopay_loading_indicator"></div>
</div>
<div id="autopay-success" class="alert alert-success">
Upgrade complete! The page will now reload.
</div>
<div id="autopay-error" class="alert alert-danger"></div>
</div>
<div class="tab-pane" id="invoice">
<form method="post">
{{ csrf_input }}
<input type="hidden" name="signed_seat_count" value="{{ signed_seat_count }}">
<input type="hidden" name="salt" value="{{ salt }}">
<input type="hidden" name="billing_modality" value="send_invoice">
<div class="payment-schedule">
<h3>{{ _("Payment schedule") }}</h3>
<label>
<input type="radio" name="plan" value="{{ nickname_annual }}" checked />
<div class="box">
<div class="schedule-time annually">{{ _("Pay annually") }}</div>
<div class="schedule-amount">
$<span id="invoice_annual_price_per_month"></span>/user/month
<div class="schedule-amount-2">
($<span id="invoice_annual_price"></span>/user/year)
<div id="invoice-error" class="alert alert-danger"></div>
<div id="invoice-input-section">
<form id="invoice-form" method="post">
<input type="hidden" name="csrfmiddlewaretoken" value="{{ csrf_token }}">
<input type="hidden" name="signed_seat_count" value="{{ signed_seat_count }}">
<input type="hidden" name="salt" value="{{ salt }}">
<input type="hidden" name="billing_modality" value="send_invoice">
<div class="payment-schedule">
<h3>{{ _("Payment schedule") }}</h3>
<label>
<input type="radio" name="plan" value="{{ nickname_annual }}" checked />
<div class="box">
<div class="schedule-time annually">{{ _("Pay annually") }}</div>
<div class="schedule-amount">
$<span id="invoice_annual_price_per_month"></span>/user/month
<div class="schedule-amount-2">
($<span id="invoice_annual_price"></span>/user/year)
</div>
</div>
</div>
</div>
</label>
</label>
</div>
<p>
Tell us ahead of time how many users you're planning for. We'll email you an
invoice in 1-2 hours. Invoices can be paid by ACH transfer or credit card.
</p>
<h4>Number of users (minimum {{ min_seat_count_for_invoice }})</h4>
<input pattern="\d*" oninvalid="this.setCustomValidity('Invalid input')" oninput="this.setCustomValidity('')" type="text" autocomplete="off" id="invoiced_seat_count" name="invoiced_seat_count" required/><br>
<button type="submit" id="invoice-button" class="stripe-button-el invoice-button">Buy Standard</button>
</form>
</div>
<div id="invoice-loading">
<div class="zulip-loading-logo">
<svg xmlns="http://www.w3.org/2000/svg" width="100%" height="100%" viewBox="0 0 40 40" version="1.1">
<g transform="translate(-297.14285,-466.64792)">
<circle cx="317.14285" cy="486.64792" r="19.030317" style="stroke-width:1.93936479;"/>
<path d="m309.24286 477.14791 14.2 0 1.6 3.9-11.2 11.9 9.6 0 1.6 3.2-14.2 0-1.6-3.9 11.2-11.9-9.6 0z"/>
</g>
</svg>
</div>
<p>
Tell us ahead of time how many users you're planning for. We'll email you an
invoice in 1-2 hours. Invoices can be paid by ACH transfer or credit card.
</p>
<h4>Number of users (minimum {{ min_seat_count_for_invoice }})</h4>
<input type="text" id="invoiced_seat_count" name="invoiced_seat_count" value=""/><br>
<button type="submit" class="stripe-button-el invoice-button">Buy Standard</button>
</form>
<div id="invoice_loading_indicator"></div>
</div>
<div id="invoice-success" class="alert alert-success">
Upgrade complete! The page will now reload.
</div>
</div>
</div>
<div class="support-link">

View File

@ -750,7 +750,7 @@ def build_custom_checkers(by_lang):
'description': "Don't use inline event handlers (onclick=, etc. attributes) in HTML. Instead,"
"attach a jQuery event handler ($('#foo').on('click', function () {...})) when "
"the DOM is ready (inside a $(function () {...}) block).",
'exclude': set(['templates/zerver/dev_login.html']),
'exclude': set(['templates/zerver/dev_login.html', 'templates/corporate/upgrade.html']),
'good_lines': ["($('#foo').on('click', function () {}"],
'bad_lines': ["<button id='foo' onclick='myFunction()'>Foo</button>", "<input onchange='myFunction()'>"]},
{'pattern': 'style ?=',
@ -795,6 +795,7 @@ def build_custom_checkers(by_lang):
'templates/zerver/features.html',
'templates/zerver/portico-header.html',
'templates/corporate/billing.html',
'templates/corporate/upgrade.html',
# Miscellaneous violations to be cleaned up
'static/templates/user_info_popover_title.handlebars',