mirror of https://github.com/zulip/zulip.git
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:
parent
cf55e66c74
commit
684430faa2
|
@ -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")
|
||||||
|
|
|
@ -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)
|
||||||
|
|
||||||
|
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
@ -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">
|
||||||
|
|
|
@ -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>
|
|
@ -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">
|
||||||
|
|
|
@ -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) => {
|
||||||
|
|
|
@ -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();
|
||||||
}
|
}
|
||||||
|
|
|
@ -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);
|
||||||
|
|
|
@ -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}) => {
|
||||||
|
|
Loading…
Reference in New Issue