billing: Add sponsorship request form to the billing page.

Previously this was only available on the upgrade page - meaning an
organization that already bought a plan wouldn't be able to request a
sponsorship to get a discount or such, even if qualified.
This commit is contained in:
Mateusz Mandera 2023-02-13 20:40:51 +01:00 committed by Tim Abbott
parent cf55e66c74
commit 684430faa2
10 changed files with 140 additions and 72 deletions

View File

@ -21,6 +21,7 @@ from typing import (
Tuple, Tuple,
TypeVar, TypeVar,
) )
from unittest import mock
from unittest.mock import Mock, patch from unittest.mock import Mock, patch
import orjson import orjson
@ -3828,6 +3829,47 @@ class StripeTest(StripeTestCase):
(invoice,) = stripe.Invoice.list(customer=stripe_customer_id) (invoice,) = stripe.Invoice.list(customer=stripe_customer_id)
self.assertEqual(invoice.amount_due, 7200) self.assertEqual(invoice.amount_due, 7200)
def test_request_sponsorship_available_on_upgrade_and_billing_pages(self) -> None:
"""
Verifies that the Request sponsorship form is available on both the upgrade
page and the billing page for already subscribed customers.
"""
realm = get_realm("zulip")
self.login("desdemona")
result = self.client_get("/upgrade/")
self.assert_in_success_response(["Request sponsorship"], result)
customer = Customer.objects.create(realm=realm, stripe_customer_id="cus_12345")
plan = CustomerPlan.objects.create(
customer=customer,
status=CustomerPlan.ACTIVE,
billing_cycle_anchor=timezone_now(),
billing_schedule=CustomerPlan.ANNUAL,
tier=CustomerPlan.STANDARD,
price_per_license=1000,
)
LicenseLedger.objects.create(
plan=plan,
is_renewal=True,
event_time=timezone_now(),
licenses=9,
licenses_at_next_renewal=9,
)
mock_stripe_customer = mock.MagicMock()
mock_stripe_customer.email = "desdemona@zulip.com"
with mock.patch(
"corporate.views.billing_page.stripe_get_customer", return_value=mock_stripe_customer
):
result = self.client_get("/billing/")
# Sanity assert to make sure we're testing the subscribed billing page.
self.assert_in_success_response(
["Your current plan is <strong>Zulip Cloud Standard</strong>."], result
)
self.assert_in_success_response(["Request sponsorship"], result)
def test_update_billing_method_of_current_plan(self) -> None: def test_update_billing_method_of_current_plan(self) -> None:
realm = get_realm("zulip") realm = get_realm("zulip")
customer = Customer.objects.create(realm=realm, stripe_customer_id="cus_12345") customer = Customer.objects.create(realm=realm, stripe_customer_id="cus_12345")

View File

@ -33,7 +33,7 @@ from zerver.lib.exceptions import JsonableError
from zerver.lib.request import REQ, has_request_variables from zerver.lib.request import REQ, has_request_variables
from zerver.lib.response import json_success from zerver.lib.response import json_success
from zerver.lib.validator import check_bool, check_int, check_int_in from zerver.lib.validator import check_bool, check_int, check_int_in
from zerver.models import UserProfile from zerver.models import Realm, UserProfile
billing_logger = logging.getLogger("corporate.stripe") billing_logger = logging.getLogger("corporate.stripe")
@ -58,6 +58,23 @@ def payment_method_string(stripe_customer: stripe.Customer) -> str:
) # nocoverage ) # nocoverage
def add_sponsorship_info_to_context(context: Dict[str, Any], user_profile: UserProfile) -> None:
def key_helper(d: Any) -> int:
return d[1]["display_order"]
context.update(
realm_org_type=user_profile.realm.org_type,
sorted_org_types=sorted(
(
[org_type_name, org_type]
for (org_type_name, org_type) in Realm.ORG_TYPES.items()
if not org_type.get("hidden")
),
key=key_helper,
),
)
@zulip_login_required @zulip_login_required
@has_request_variables @has_request_variables
def billing_home( def billing_home(
@ -145,6 +162,7 @@ def billing_home(
CustomerPlan=CustomerPlan, CustomerPlan=CustomerPlan,
onboarding=onboarding, onboarding=onboarding,
) )
add_sponsorship_info_to_context(context, user)
return render(request, "corporate/billing.html", context=context) return render(request, "corporate/billing.html", context=context)

View File

@ -36,14 +36,14 @@ from corporate.models import (
get_current_plan_by_customer, get_current_plan_by_customer,
get_customer_by_realm, get_customer_by_realm,
) )
from corporate.views.billing_page import billing_home from corporate.views.billing_page import add_sponsorship_info_to_context, billing_home
from zerver.actions.users import do_make_user_billing_admin from zerver.actions.users import do_make_user_billing_admin
from zerver.decorator import require_organization_member, zulip_login_required from zerver.decorator import require_organization_member, zulip_login_required
from zerver.lib.request import REQ, has_request_variables from zerver.lib.request import REQ, has_request_variables
from zerver.lib.response import json_success from zerver.lib.response import json_success
from zerver.lib.send_email import FromAddress, send_email from zerver.lib.send_email import FromAddress, send_email
from zerver.lib.validator import check_bool, check_int, check_string_in from zerver.lib.validator import check_bool, check_int, check_string_in
from zerver.models import Realm, UserProfile, get_org_type_display_name from zerver.models import UserProfile, get_org_type_display_name
billing_logger = logging.getLogger("corporate.stripe") billing_logger = logging.getLogger("corporate.stripe")
@ -278,17 +278,10 @@ def initial_upgrade(
"percent_off": float(percent_off), "percent_off": float(percent_off),
"demo_organization_scheduled_deletion_date": user.realm.demo_organization_scheduled_deletion_date, "demo_organization_scheduled_deletion_date": user.realm.demo_organization_scheduled_deletion_date,
}, },
"realm_org_type": user.realm.org_type,
"sorted_org_types": sorted(
(
[org_type_name, org_type]
for (org_type_name, org_type) in Realm.ORG_TYPES.items()
if not org_type.get("hidden")
),
key=lambda d: d[1]["display_order"],
),
"is_demo_organization": user.realm.demo_organization_scheduled_deletion_date is not None, "is_demo_organization": user.realm.demo_organization_scheduled_deletion_date is not None,
} }
add_sponsorship_info_to_context(context, user)
response = render(request, "corporate/upgrade.html", context=context) response = render(request, "corporate/upgrade.html", context=context)
return response return response

View File

@ -27,6 +27,7 @@
<li class="active"><a data-toggle="tab" href="#overview">Overview</a></li> <li class="active"><a data-toggle="tab" href="#overview">Overview</a></li>
<li><a data-toggle="tab" href="#payment-method">Payment method</a></li> <li><a data-toggle="tab" href="#payment-method">Payment method</a></li>
<li><a data-toggle="tab" href="#settings">Settings</a></li> <li><a data-toggle="tab" href="#settings">Settings</a></li>
<li><a data-toggle="tab" href="#sponsorship">💚 Request sponsorship</a></li>
</ul> </ul>
<input type="hidden" name="csrfmiddlewaretoken" value="{{ csrf_token }}" /> <input type="hidden" name="csrfmiddlewaretoken" value="{{ csrf_token }}" />
@ -170,6 +171,8 @@
<div class="tab-pane" id="loading"> <div class="tab-pane" id="loading">
</div> </div>
</div> </div>
{% include "corporate/sponsorship.html" %}
</div> </div>
<div id="goto-zulip-organization-link"> <div id="goto-zulip-organization-link">

View File

@ -0,0 +1,51 @@
<div class="tab-pane" id="sponsorship">
<div id="sponsorship-error" class="alert alert-danger"></div>
<div id="sponsorship-input-section">
<form id="sponsorship-form" method="post">
<input type="hidden" name="csrfmiddlewaretoken" value="{{ csrf_token }}" />
<label>
<h4>Organization type</h4>
</label>
<select name="organization-type" class="bootstrap-focus-style">
{% for org_type in sorted_org_types %}
{% if not org_type[1].hidden %}
<option data-string-value="{{ org_type[0] }}"
{% if org_type[1].id == realm_org_type %}selected{% endif %}
value="{{ org_type[1].id }}">
{{ _(org_type[1].name) }}
</option>
{% endif %}
{% endfor %}
</select>
<br />
<label>
<h4>Organization website</h4>
</label>
<input name="website" type="text" class="input-large" placeholder="{{ _('Leave blank if your organization does not have a website.') }}"/>
<label>
<h4>Describe your organization briefly</h4>
</label>
<textarea name="description" cols="100" rows="5" required></textarea>
<br />
<p id="sponsorship-discount-details"></p>
<!-- Disabled buttons do not fire any events, so we need a container div that isn't disabled for tippyjs to work -->
<div class="upgrade-button-container" {% if is_demo_organization %}data-tippy-content="{% trans %}Convert demo organization before upgrading.{% endtrans %}"{% endif %}>
<button type="submit" id="sponsorship-button" class="stripe-button-el invoice-button" {% if is_demo_organization %}disabled{% endif %}>
Submit
</button>
</div>
</form>
</div>
<div id="sponsorship-loading">
<div class="zulip-loading-logo">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 773.12 773.12">
<circle cx="386.56" cy="386.56" r="386.56"/>
<path d="M566.66 527.25c0 33.03-24.23 60.05-53.84 60.05H260.29c-29.61 0-53.84-27.02-53.84-60.05 0-20.22 9.09-38.2 22.93-49.09l134.37-120c2.5-2.14 5.74 1.31 3.94 4.19l-49.29 98.69c-1.38 2.76.41 6.16 3.25 6.16h191.18c29.61 0 53.83 27.03 53.83 60.05zm0-281.39c0 20.22-9.09 38.2-22.93 49.09l-134.37 120c-2.5 2.14-5.74-1.31-3.94-4.19l49.29-98.69c1.38-2.76-.41-6.16-3.25-6.16H260.29c-29.61 0-53.84-27.02-53.84-60.05s24.23-60.05 53.84-60.05h252.54c29.61 0 53.83 27.02 53.83 60.05z"/>
</svg>
</div>
<div id="sponsorship_loading_indicator"></div>
</div>
<div id="sponsorship-success" class="alert alert-info">
Request received! The page will now reload.
</div>
</div>

View File

@ -241,56 +241,7 @@
</div> </div>
</div> </div>
<div class="tab-pane" id="sponsorship"> {% include "corporate/sponsorship.html" %}
<div id="sponsorship-error" class="alert alert-danger"></div>
<div id="sponsorship-input-section">
<form id="sponsorship-form" method="post">
<label>
<h4>Organization type</h4>
</label>
<select name="organization-type" class="bootstrap-focus-style">
{% for org_type in sorted_org_types %}
{% if not org_type[1].hidden %}
<option data-string-value="{{ org_type[0] }}"
{% if org_type[1].id == realm_org_type %}selected{% endif %}
value="{{ org_type[1].id }}">
{{ _(org_type[1].name) }}
</option>
{% endif %}
{% endfor %}
</select>
<br />
<label>
<h4>Organization website</h4>
</label>
<input name="website" type="text" class="input-large" placeholder="{{ _('Leave blank if your organization does not have a website.') }}"/>
<label>
<h4>Describe your organization briefly</h4>
</label>
<textarea name="description" cols="100" rows="5" required></textarea>
<br />
<p id="sponsorship-discount-details"></p>
<!-- Disabled buttons do not fire any events, so we need a container div that isn't disabled for tippyjs to work -->
<div class="upgrade-button-container" {% if is_demo_organization %}data-tippy-content="{% trans %}Convert demo organization before upgrading.{% endtrans %}"{% endif %}>
<button type="submit" id="sponsorship-button" class="stripe-button-el invoice-button" {% if is_demo_organization %}disabled{% endif %}>
Submit
</button>
</div>
</form>
</div>
<div id="sponsorship-loading">
<div class="zulip-loading-logo">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 773.12 773.12">
<circle cx="386.56" cy="386.56" r="386.56"/>
<path d="M566.66 527.25c0 33.03-24.23 60.05-53.84 60.05H260.29c-29.61 0-53.84-27.02-53.84-60.05 0-20.22 9.09-38.2 22.93-49.09l134.37-120c2.5-2.14 5.74 1.31 3.94 4.19l-49.29 98.69c-1.38 2.76.41 6.16 3.25 6.16h191.18c29.61 0 53.83 27.03 53.83 60.05zm0-281.39c0 20.22-9.09 38.2-22.93 49.09l-134.37 120c-2.5 2.14-5.74-1.31-3.94-4.19l49.29-98.69c1.38-2.76-.41-6.16-3.25-6.16H260.29c-29.61 0-53.84-27.02-53.84-60.05s24.23-60.05 53.84-60.05h252.54c29.61 0 53.83 27.02 53.83 60.05z"/>
</svg>
</div>
<div id="sponsorship_loading_indicator"></div>
</div>
<div id="sponsorship-success" class="alert alert-info">
Request received! The page will now reload.
</div>
</div>
</div> </div>
<div class="support-link"> <div class="support-link">

View File

@ -14,6 +14,7 @@ export function create_update_license_request() {
export function initialize() { export function initialize() {
helpers.set_tab("billing"); helpers.set_tab("billing");
helpers.set_sponsorship_form();
$("#update-card-button").on("click", (e) => { $("#update-card-button").on("click", (e) => {
const success_callback = (response) => { const success_callback = (response) => {

View File

@ -136,6 +136,18 @@ export function set_tab(page) {
window.addEventListener("hashchange", handle_hashchange); window.addEventListener("hashchange", handle_hashchange);
} }
export function set_sponsorship_form() {
$("#sponsorship-button").on("click", (e) => {
if (!is_valid_input($("#sponsorship-form"))) {
return;
}
e.preventDefault();
create_ajax_request("/json/billing/sponsorship", "sponsorship", [], "POST", () =>
window.location.replace("/"),
);
});
}
export function is_valid_input(elem) { export function is_valid_input(elem) {
return elem[0].checkValidity(); return elem[0].checkValidity();
} }

View File

@ -6,6 +6,7 @@ import * as helpers from "./helpers";
export const initialize = () => { export const initialize = () => {
helpers.set_tab("upgrade"); helpers.set_tab("upgrade");
helpers.set_sponsorship_form();
$("#add-card-button").on("click", (e) => { $("#add-card-button").on("click", (e) => {
const license_management = $("input[type=radio][name=license_management]:checked").val(); const license_management = $("input[type=radio][name=license_management]:checked").val();
if ( if (
@ -36,16 +37,6 @@ export const initialize = () => {
); );
}); });
$("#sponsorship-button").on("click", (e) => {
if (!helpers.is_valid_input($("#sponsorship-form"))) {
return;
}
e.preventDefault();
helpers.create_ajax_request("/json/billing/sponsorship", "sponsorship", [], "POST", () =>
window.location.replace("/"),
);
});
const prices = {}; const prices = {};
prices.annual = page_params.annual_price * (1 - page_params.percent_off / 100); prices.annual = page_params.annual_price * (1 - page_params.percent_off / 100);
prices.monthly = page_params.monthly_price * (1 - page_params.percent_off / 100); prices.monthly = page_params.monthly_price * (1 - page_params.percent_off / 100);

View File

@ -20,6 +20,7 @@ const location = set_global("location", {});
const helpers = mock_esm("../src/billing/helpers", { const helpers = mock_esm("../src/billing/helpers", {
set_tab() {}, set_tab() {},
set_sponsorship_form() {},
}); });
zrequire("billing/billing"); zrequire("billing/billing");
@ -30,8 +31,13 @@ run_test("initialize", ({override}) => {
assert.equal(page_name, "billing"); assert.equal(page_name, "billing");
set_tab_called = true; set_tab_called = true;
}); });
let set_sponsorship_form_called = false;
override(helpers, "set_sponsorship_form", () => {
set_sponsorship_form_called = true;
});
$.get_initialize_function()(); $.get_initialize_function()();
assert.ok(set_tab_called); assert.ok(set_tab_called);
assert.ok(set_sponsorship_form_called);
}); });
run_test("card_update", ({override}) => { run_test("card_update", ({override}) => {