billing: Add option to request a sponsorship in /upgrade.

This commit is contained in:
Vishnu KS 2020-06-09 15:54:32 +05:30 committed by Tim Abbott
parent 606c2acefe
commit 4c6350fa4b
27 changed files with 416 additions and 21 deletions

View File

@ -10,6 +10,7 @@ from analytics.lib.counts import COUNT_STATS, CountStat
from analytics.lib.time_utils import time_range from analytics.lib.time_utils import time_range
from analytics.models import FillState, RealmCount, UserCount, last_successful_fill from analytics.models import FillState, RealmCount, UserCount, last_successful_fill
from analytics.views import rewrite_client_arrays, sort_by_totals, sort_client_labels from analytics.views import rewrite_client_arrays, sort_by_totals, sort_client_labels
from corporate.models import get_customer_by_realm
from zerver.lib.actions import do_create_multiuse_invite_link, do_send_realm_reactivation_email from zerver.lib.actions import do_create_multiuse_invite_link, do_send_realm_reactivation_email
from zerver.lib.test_classes import ZulipTestCase from zerver.lib.test_classes import ZulipTestCase
from zerver.lib.test_helpers import reset_emails_in_zulip_realm from zerver.lib.test_helpers import reset_emails_in_zulip_realm
@ -575,6 +576,35 @@ class TestSupportEndpoint(ZulipTestCase):
m.assert_called_once_with(get_realm("lear"), 25) m.assert_called_once_with(get_realm("lear"), 25)
self.assert_in_success_response(["Discount of Lear & Co. changed to 25 from None"], result) self.assert_in_success_response(["Discount of Lear & Co. changed to 25 from None"], result)
def test_change_sponsorship_status(self) -> None:
lear_realm = get_realm("lear")
self.assertIsNone(get_customer_by_realm(lear_realm))
cordelia = self.example_user('cordelia')
self.login_user(cordelia)
result = self.client_post("/activity/support", {"realm_id": f"{lear_realm.id}",
"sponsorship_pending": "true"})
self.assertEqual(result.status_code, 302)
self.assertEqual(result["Location"], "/login/")
iago = self.example_user("iago")
self.login_user(iago)
result = self.client_post("/activity/support", {"realm_id": f"{lear_realm.id}",
"sponsorship_pending": "true"})
self.assert_in_success_response(["Lear & Co. marked as pending sponsorship."], result)
customer = get_customer_by_realm(lear_realm)
assert(customer is not None)
self.assertTrue(customer.sponsorship_pending)
result = self.client_post("/activity/support", {"realm_id": f"{lear_realm.id}",
"sponsorship_pending": "false"})
self.assert_in_success_response(["Lear & Co. is no longer pending sponsorship."], result)
customer = get_customer_by_realm(lear_realm)
assert(customer is not None)
self.assertFalse(customer.sponsorship_pending)
def test_activate_or_deactivate_realm(self) -> None: def test_activate_or_deactivate_realm(self) -> None:
cordelia = self.example_user('cordelia') cordelia = self.example_user('cordelia')
lear_realm = get_realm('lear') lear_realm = get_realm('lear')

View File

@ -70,7 +70,12 @@ from zerver.models import (
from zerver.views.invite import get_invitee_emails_set from zerver.views.invite import get_invitee_emails_set
if settings.BILLING_ENABLED: if settings.BILLING_ENABLED:
from corporate.lib.stripe import attach_discount_to_realm, get_discount_for_realm from corporate.lib.stripe import (
attach_discount_to_realm,
get_customer_by_realm,
get_discount_for_realm,
update_sponsorship_status,
)
if settings.ZILENCER_ENABLED: if settings.ZILENCER_ENABLED:
from zilencer.models import RemoteInstallationCount, RemoteRealmCount, RemoteZulipServer from zilencer.models import RemoteInstallationCount, RemoteRealmCount, RemoteZulipServer
@ -1136,6 +1141,15 @@ def support(request: HttpRequest) -> HttpResponse:
do_deactivate_realm(realm, request.user) do_deactivate_realm(realm, request.user)
context["message"] = f"{realm.name} deactivated." context["message"] = f"{realm.name} deactivated."
sponsorship_pending = request.POST.get("sponsorship_pending", None)
if sponsorship_pending is not None:
if sponsorship_pending == "true":
update_sponsorship_status(realm, True)
context["message"] = f"{realm.name} marked as pending sponsorship."
elif sponsorship_pending == "false":
update_sponsorship_status(realm, False)
context["message"] = f"{realm.name} is no longer pending sponsorship."
scrub_realm = request.POST.get("scrub_realm", None) scrub_realm = request.POST.get("scrub_realm", None)
if scrub_realm is not None: if scrub_realm is not None:
if scrub_realm == "scrub_realm": if scrub_realm == "scrub_realm":
@ -1165,6 +1179,9 @@ def support(request: HttpRequest) -> HttpResponse:
except ValidationError: except ValidationError:
pass pass
for realm in realms:
realm.customer = get_customer_by_realm(realm)
context["realms"] = realms context["realms"] = realms
confirmations: List[Dict[str, Any]] = [] confirmations: List[Dict[str, Any]] = []

View File

@ -558,6 +558,11 @@ def invoice_plans_as_needed(event_time: datetime=timezone_now()) -> None:
def attach_discount_to_realm(realm: Realm, discount: Decimal) -> None: def attach_discount_to_realm(realm: Realm, discount: Decimal) -> None:
Customer.objects.update_or_create(realm=realm, defaults={'default_discount': discount}) Customer.objects.update_or_create(realm=realm, defaults={'default_discount': discount})
def update_sponsorship_status(realm: Realm, sponsorship_pending: bool) -> None:
customer, _ = Customer.objects.get_or_create(realm=realm)
customer.sponsorship_pending = sponsorship_pending
customer.save(update_fields=["sponsorship_pending"])
def get_discount_for_realm(realm: Realm) -> Optional[Decimal]: def get_discount_for_realm(realm: Realm) -> Optional[Decimal]:
customer = get_customer_by_realm(realm) customer = get_customer_by_realm(realm)
if customer is not None: if customer is not None:

View File

@ -0,0 +1,18 @@
# Generated by Django 2.2.13 on 2020-06-09 12:09
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('corporate', '0008_nullable_next_invoice_date'),
]
operations = [
migrations.AddField(
model_name='customer',
name='sponsorship_pending',
field=models.BooleanField(default=False),
),
]

View File

@ -11,6 +11,7 @@ from zerver.models import Realm
class Customer(models.Model): class Customer(models.Model):
realm: Realm = models.OneToOneField(Realm, on_delete=CASCADE) realm: Realm = models.OneToOneField(Realm, on_delete=CASCADE)
stripe_customer_id: str = models.CharField(max_length=255, null=True, unique=True) stripe_customer_id: str = models.CharField(max_length=255, null=True, unique=True)
sponsorship_pending: bool = models.BooleanField(default=False)
# A percentage, like 85. # A percentage, like 85.
default_discount: Optional[Decimal] = models.DecimalField(decimal_places=4, max_digits=7, null=True) default_discount: Optional[Decimal] = models.DecimalField(decimal_places=4, max_digits=7, null=True)

View File

@ -1068,6 +1068,48 @@ class StripeTest(StripeTestCase):
self.assert_json_error_contains(response, "Something went wrong. Please contact desdemona+admin@zulip.com.") self.assert_json_error_contains(response, "Something went wrong. Please contact desdemona+admin@zulip.com.")
self.assertEqual(ujson.loads(response.content)['error_description'], 'uncaught exception during upgrade') self.assertEqual(ujson.loads(response.content)['error_description'], 'uncaught exception during upgrade')
def test_request_sponsorship(self) -> None:
user = self.example_user("hamlet")
self.assertIsNone(get_customer_by_realm(user.realm))
self.login_user(user)
data = {
"organization-type": ujson.dumps("Open-source"),
"website": ujson.dumps("https://infinispan.org/"),
"description": ujson.dumps("Infinispan is a distributed in-memory key/value data store with optional schema."),
}
response = self.client_post("/json/billing/sponsorship", data)
self.assert_json_success(response)
customer = get_customer_by_realm(user.realm)
assert(customer is not None)
self.assertEqual(customer.sponsorship_pending, True)
from django.core.mail import outbox
self.assertEqual(len(outbox), 1)
for message in outbox:
self.assertEqual(len(message.to), 1)
self.assertEqual(message.to[0], "desdemona+admin@zulip.com")
self.assertEqual(message.subject, "Sponsorship request (Open-source) for zulip")
self.assertEqual(message.from_email, f'{user.full_name} <{user.delivery_email}>')
self.assertIn("User role: Member", message.body)
self.assertIn("Support URL: http://zulip.testserver/activity/support?q=zulip", message.body)
self.assertIn("Website: https://infinispan.org", message.body)
self.assertIn("Organization type: Open-source", message.body)
self.assertIn("Description:\nInfinispan is a distributed in-memory", message.body)
response = self.client_get("/upgrade/")
self.assertEqual(response.status_code, 302)
self.assertEqual(response.url, "/billing/")
response = self.client_get("/billing/")
self.assert_in_success_response(["Your organization has requested sponsored or discounted hosting."], response)
self.login_user(self.example_user("othello"))
response = self.client_get("/billing/")
self.assert_in_success_response(["You must be an organization administrator or a billing administrator to view this page."], response)
def test_redirect_for_billing_home(self) -> None: def test_redirect_for_billing_home(self) -> None:
user = self.example_user("iago") user = self.example_user("iago")
self.login_user(user) self.login_user(user)
@ -1796,8 +1838,9 @@ class RequiresBillingAccessTest(ZulipTestCase):
string_with_all_endpoints = str(get_resolver('corporate.urls').reverse_dict) string_with_all_endpoints = str(get_resolver('corporate.urls').reverse_dict)
json_endpoints = {word.strip("\"'()[],$") for word in string_with_all_endpoints.split() json_endpoints = {word.strip("\"'()[],$") for word in string_with_all_endpoints.split()
if 'json/' in word} if 'json/' in word}
# No need to test upgrade endpoint as it only requires user to be logged in. # No need to test upgrade and sponsorship endpoints as they only require user to be logged in.
json_endpoints.remove("json/billing/upgrade") json_endpoints.remove("json/billing/upgrade")
json_endpoints.remove("json/billing/sponsorship")
self.assertEqual(len(json_endpoints), len(params)) self.assertEqual(len(json_endpoints), len(params))

View File

@ -22,6 +22,8 @@ i18n_urlpatterns: Any = [
v1_api_and_json_patterns = [ v1_api_and_json_patterns = [
path('billing/upgrade', rest_dispatch, path('billing/upgrade', rest_dispatch,
{'POST': 'corporate.views.upgrade'}), {'POST': 'corporate.views.upgrade'}),
path('billing/sponsorship', rest_dispatch,
{'POST': 'corporate.views.sponsorship'}),
path('billing/plan/change', rest_dispatch, path('billing/plan/change', rest_dispatch,
{'POST': 'corporate.views.change_plan_status'}), {'POST': 'corporate.views.change_plan_status'}),
path('billing/sources/change', rest_dispatch, path('billing/sources/change', rest_dispatch,

View File

@ -1,6 +1,7 @@
import logging import logging
from decimal import Decimal from decimal import Decimal
from typing import Any, Dict, Optional, Union from typing import Any, Dict, Optional, Union
from urllib.parse import urlencode, urljoin, urlunsplit
import stripe import stripe
from django.conf import settings from django.conf import settings
@ -28,6 +29,7 @@ from corporate.lib.stripe import (
start_of_next_billing_cycle, start_of_next_billing_cycle,
stripe_get_customer, stripe_get_customer,
unsign_string, unsign_string,
update_sponsorship_status,
) )
from corporate.models import ( from corporate.models import (
CustomerPlan, CustomerPlan,
@ -38,8 +40,9 @@ from corporate.models import (
from zerver.decorator import require_billing_access, zulip_login_required from zerver.decorator import require_billing_access, 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_error, json_success from zerver.lib.response import json_error, json_success
from zerver.lib.send_email import FromAddress, send_email
from zerver.lib.validator import check_int, check_string from zerver.lib.validator import check_int, check_string
from zerver.models import UserProfile from zerver.models import UserProfile, get_realm
billing_logger = logging.getLogger('corporate.stripe') billing_logger = logging.getLogger('corporate.stripe')
@ -145,8 +148,9 @@ def initial_upgrade(request: HttpRequest) -> HttpResponse:
return render(request, "404.html") return render(request, "404.html")
user = request.user user = request.user
customer = get_customer_by_realm(user.realm) customer = get_customer_by_realm(user.realm)
if customer is not None and get_current_plan_by_customer(customer) is not None: if customer is not None and (get_current_plan_by_customer(customer) is not None or customer.sponsorship_pending):
billing_page_url = reverse('corporate.views.billing_home') billing_page_url = reverse('corporate.views.billing_home')
if request.GET.get("onboarding") is not None: if request.GET.get("onboarding") is not None:
billing_page_url = f"{billing_page_url}?onboarding=true" billing_page_url = f"{billing_page_url}?onboarding=true"
@ -159,6 +163,7 @@ def initial_upgrade(request: HttpRequest) -> HttpResponse:
seat_count = get_latest_seat_count(user.realm) seat_count = get_latest_seat_count(user.realm)
signed_seat_count, salt = sign_string(str(seat_count)) signed_seat_count, salt = sign_string(str(seat_count))
context: Dict[str, Any] = { context: Dict[str, Any] = {
'realm': user.realm,
'publishable_key': STRIPE_PUBLISHABLE_KEY, 'publishable_key': STRIPE_PUBLISHABLE_KEY,
'email': user.delivery_email, 'email': user.delivery_email,
'seat_count': seat_count, 'seat_count': seat_count,
@ -179,17 +184,71 @@ def initial_upgrade(request: HttpRequest) -> HttpResponse:
response = render(request, 'corporate/upgrade.html', context=context) response = render(request, 'corporate/upgrade.html', context=context)
return response return response
@has_request_variables
def sponsorship(request: HttpRequest, user: UserProfile,
organization_type: str=REQ("organization-type", validator=check_string),
website: str=REQ("website", validator=check_string),
description: str=REQ("description", validator=check_string)) -> HttpResponse:
realm = user.realm
requested_by = user.full_name
role_id_to_name_map = {
UserProfile.ROLE_REALM_OWNER: "Realm owner",
UserProfile.ROLE_REALM_ADMINISTRATOR: "Realm adminstrator",
UserProfile.ROLE_MEMBER: "Member",
UserProfile.ROLE_GUEST: "Guest"
}
user_role = role_id_to_name_map[user.role]
support_realm_uri = get_realm(settings.STAFF_SUBDOMAIN).uri
support_url = urljoin(support_realm_uri, urlunsplit(("", "", reverse('analytics.views.support'),
urlencode({"q": realm.string_id}), "")))
context = {
"requested_by": requested_by,
"user_role": user_role,
"string_id": realm.string_id,
"support_url": support_url,
"organization_type": organization_type,
"website": website,
"description": description,
}
send_email(
"zerver/emails/sponsorship_request",
to_emails=[FromAddress.SUPPORT],
from_name=user.full_name,
from_address=user.delivery_email,
context=context,
)
update_sponsorship_status(realm, True)
user.is_billing_admin = True
user.save(update_fields=["is_billing_admin"])
return json_success()
@zulip_login_required @zulip_login_required
def billing_home(request: HttpRequest) -> HttpResponse: def billing_home(request: HttpRequest) -> HttpResponse:
user = request.user user = request.user
customer = get_customer_by_realm(user.realm) customer = get_customer_by_realm(user.realm)
context: Dict[str, Any] = {}
if customer is None: if customer is None:
return HttpResponseRedirect(reverse('corporate.views.initial_upgrade')) return HttpResponseRedirect(reverse('corporate.views.initial_upgrade'))
if customer.sponsorship_pending:
if user.has_billing_access:
context = {"admin_access": True, "sponsorship_pending": True}
else:
context = {"admin_access": False}
return render(request, 'corporate/billing.html', context=context)
if not CustomerPlan.objects.filter(customer=customer).exists(): if not CustomerPlan.objects.filter(customer=customer).exists():
return HttpResponseRedirect(reverse('corporate.views.initial_upgrade')) return HttpResponseRedirect(reverse('corporate.views.initial_upgrade'))
if not user.is_realm_admin and not user.is_billing_admin: if not user.has_billing_access:
context: Dict[str, Any] = {'admin_access': False} context = {'admin_access': False}
return render(request, 'corporate/billing.html', context=context) return render(request, 'corporate/billing.html', context=context)
context = { context = {

View File

@ -139,6 +139,11 @@ run_test('create_ajax_request', () => {
state.location_reload += 1; state.location_reload += 1;
}; };
window.location.replace = (reload_to) => {
state.location_reload += 1;
assert.equal(reload_to, "/billing");
};
success(); success();
assert.equal(state.location_reload, 1); assert.equal(state.location_reload, 1);

View File

@ -34,12 +34,22 @@ run_test("initialize", () => {
assert.equal(page_name, "upgrade"); assert.equal(page_name, "upgrade");
}; };
helpers.create_ajax_request = (url, form_name, stripe_token) => { helpers.create_ajax_request = (url, form_name, stripe_token, numeric_inputs, redirect_to) => {
assert.equal(url, "/json/billing/upgrade");
if (form_name === "autopay") { if (form_name === "autopay") {
assert.equal(url, "/json/billing/upgrade");
assert.equal(stripe_token, "stripe_add_card_token"); assert.equal(stripe_token, "stripe_add_card_token");
assert.deepEqual(numeric_inputs, ["licenses"]);
assert.equal(redirect_to, undefined);
} else if (form_name === "invoice") { } else if (form_name === "invoice") {
assert.equal(url, "/json/billing/upgrade");
assert.equal(stripe_token, undefined); assert.equal(stripe_token, undefined);
assert.deepEqual(numeric_inputs, ["licenses"]);
assert.equal(redirect_to, undefined);
} else if (form_name === "sponsorship") {
assert.equal(url, "/json/billing/sponsorship");
assert.equal(stripe_token, undefined);
assert.equal(numeric_inputs, undefined);
assert.equal(redirect_to, "/");
} else { } else {
throw Error("Unhandled case"); throw Error("Unhandled case");
} }
@ -98,6 +108,7 @@ run_test("initialize", () => {
const add_card_click_handler = $('#add-card-button').get_on_handler('click'); const add_card_click_handler = $('#add-card-button').get_on_handler('click');
const invoice_click_handler = $('#invoice-button').get_on_handler('click'); const invoice_click_handler = $('#invoice-button').get_on_handler('click');
const request_sponsorship_click_handler = $('#sponsorship-button').get_on_handler('click');
helpers.is_valid_input = () => { helpers.is_valid_input = () => {
return true; return true;
@ -112,6 +123,8 @@ run_test("initialize", () => {
add_card_click_handler(e); add_card_click_handler(e);
invoice_click_handler(e); invoice_click_handler(e);
request_sponsorship_click_handler(e);
helpers.show_license_section = (section) => { helpers.show_license_section = (section) => {
assert.equal(section, "manual"); assert.equal(section, "manual");
}; };
@ -131,6 +144,26 @@ run_test("initialize", () => {
assert.equal($("#autopay_monthly_price").text(), "6.40"); assert.equal($("#autopay_monthly_price").text(), "6.40");
assert.equal($("#invoice_annual_price").text(), "64"); assert.equal($("#invoice_annual_price").text(), "64");
assert.equal($("#invoice_annual_price_per_month").text(), "5.34"); assert.equal($("#invoice_annual_price_per_month").text(), "5.34");
const organization_type_change_handler = $('select[name=organization-type]').get_on_handler('change');
organization_type_change_handler.call({value: "open_source"});
assert.equal($("#sponsorship-discount-details").text(),
"Open source projects are eligible for fully sponsored (free) Zulip Standard.");
organization_type_change_handler.call({value: "research"});
assert.equal($("#sponsorship-discount-details").text(),
"Academic research organizations are eligible for fully sponsored (free) Zulip Standard.");
organization_type_change_handler.call({value: "event"});
assert.equal($("#sponsorship-discount-details").text(),
"Events are eligible for fully sponsored (free) Zulip Standard.");
organization_type_change_handler.call({value: "education"});
assert.equal($("#sponsorship-discount-details").text(),
"Education use is eligible for an 85%-100% discount.");
organization_type_change_handler.call({value: "non_profit"});
assert.equal($("#sponsorship-discount-details").text(),
"Nonprofits are eligible for an 85%-100% discount.");
organization_type_change_handler.call({value: "other"});
assert.equal($("#sponsorship-discount-details").text(),
"Your organization might be eligible for a discount or sponsorship.");
}); });
run_test("autopay_form_fields", () => { run_test("autopay_form_fields", () => {

View File

@ -1,4 +1,4 @@
exports.create_ajax_request = function (url, form_name, stripe_token = null, numeric_inputs = []) { exports.create_ajax_request = function (url, form_name, stripe_token = null, numeric_inputs = [], redirect_to = "/billing") {
const form = $("#" + form_name + "-form"); const form = $("#" + form_name + "-form");
const form_loading_indicator = "#" + form_name + "_loading_indicator"; const form_loading_indicator = "#" + form_name + "_loading_indicator";
const form_input_section = "#" + form_name + "-input-section"; const form_input_section = "#" + form_name + "-input-section";
@ -44,7 +44,7 @@ exports.create_ajax_request = function (url, form_name, stripe_token = null, num
location.hash = ""; location.hash = "";
} }
} }
location.reload(); window.location.replace(redirect_to);
}, },
error: function (xhr) { error: function (xhr) {
$(form_loading).hide(); $(form_loading).hide();
@ -75,6 +75,18 @@ exports.update_charged_amount = function (prices, schedule) {
); );
}; };
exports.update_discount_details = function (organization_type) {
const discount_details = {
open_source: "Open source projects are eligible for fully sponsored (free) Zulip Standard.",
research: "Academic research organizations are eligible for fully sponsored (free) Zulip Standard.",
non_profit: "Nonprofits are eligible for an 85%-100% discount.",
event: "Events are eligible for fully sponsored (free) Zulip Standard.",
education: "Education use is eligible for an 85%-100% discount.",
other: "Your organization might be eligible for a discount or sponsorship.",
};
$("#sponsorship-discount-details").text(discount_details[organization_type]);
};
exports.show_license_section = function (license) { exports.show_license_section = function (license) {
$("#license-automatic-section").hide(); $("#license-automatic-section").hide();
$("#license-manual-section").hide(); $("#license-manual-section").hide();

View File

@ -36,6 +36,11 @@ exports.initialize = () => {
helpers.create_ajax_request("/json/billing/upgrade", "invoice", undefined, ["licenses"]); helpers.create_ajax_request("/json/billing/upgrade", "invoice", undefined, ["licenses"]);
}); });
$("#sponsorship-button").on("click", function (e) {
e.preventDefault();
helpers.create_ajax_request("/json/billing/sponsorship", "sponsorship", undefined, undefined, "/");
});
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);
@ -48,6 +53,10 @@ exports.initialize = () => {
helpers.update_charged_amount(prices, this.value); helpers.update_charged_amount(prices, this.value);
}); });
$('select[name=organization-type]').on("change", function () {
helpers.update_discount_details(this.value);
});
$("#autopay_annual_price").text(helpers.format_money(prices.annual)); $("#autopay_annual_price").text(helpers.format_money(prices.annual));
$("#autopay_annual_price_per_month").text(helpers.format_money(prices.annual / 12)); $("#autopay_annual_price_per_month").text(helpers.format_money(prices.annual / 12));
$("#autopay_monthly_price").text(helpers.format_money(prices.monthly)); $("#autopay_monthly_price").text(helpers.format_money(prices.monthly));

View File

@ -79,6 +79,11 @@ tr.admin td:first-child {
top: -25px; top: -25px;
} }
.sponsorship-pending-form {
position: relative;
top: -25px;
}
.scrub-realm-form { .scrub-realm-form {
position: relative; position: relative;
top: -50px; top: -50px;

View File

@ -177,6 +177,7 @@
display: none; display: none;
} }
#sponsorship-loading,
#planchange-loading, #planchange-loading,
#cardchange-loading, #cardchange-loading,
#invoice-loading, #invoice-loading,
@ -186,7 +187,7 @@
text-align: center; text-align: center;
} }
#sponsorship-success,
#planchange-success, #planchange-success,
#cardchange-success, #cardchange-success,
#invoice-success, #invoice-success,
@ -195,6 +196,7 @@
display: none; display: none;
} }
#sponsorship-error,
#planchange-error, #planchange-error,
#cardchange-error, #cardchange-error,
#invoice-error, #invoice-error,
@ -224,6 +226,7 @@
stroke: hsl(0, 0%, 100%); stroke: hsl(0, 0%, 100%);
} }
#sponsorship_loading_indicator,
#planchange_loading_indicator, #planchange_loading_indicator,
#cardchange_loading_indicator, #cardchange_loading_indicator,
#invoice_loading_indicator, #invoice_loading_indicator,
@ -231,6 +234,7 @@
margin: 10px auto; margin: 10px auto;
} }
#sponsorship_loading_indicator_box_container,
#planchange_loading_indicator_box_container, #planchange_loading_indicator_box_container,
#cardchange_loading_indicator_box_container, #cardchange_loading_indicator_box_container,
#invoice_loading_indicator_box_container, #invoice_loading_indicator_box_container,
@ -239,6 +243,7 @@
left: 50%; left: 50%;
} }
#sponsorship_loading_indicator_box,
#planchange_loading_indicator_box, #planchange_loading_indicator_box,
#cardchange_loading_indicator_box, #cardchange_loading_indicator_box,
#invoice_loading_indicator_box, #invoice_loading_indicator_box,
@ -250,6 +255,7 @@
border-radius: 6px; border-radius: 6px;
} }
#sponsorship_loading_indicator .loading_indicator_text,
#planchange_loading_indicator .loading_indicator_text, #planchange_loading_indicator .loading_indicator_text,
#cardchange_loading_indicator .loading_indicator_text, #cardchange_loading_indicator .loading_indicator_text,
#invoice_loading_indicator .loading_indicator_text, #invoice_loading_indicator .loading_indicator_text,

View File

@ -30,6 +30,16 @@
</select> </select>
<button type="submit" class="button rounded small support-submit-button">Update</button> <button type="submit" class="button rounded small support-submit-button">Update</button>
</form> </form>
<form method="POST" class="sponsorship-pending-form">
<b>Sponsorship pending</b>:<br>
{{ csrf_input }}
<input type="hidden" name="realm_id" value="{{ realm.id }}" />
<select name="sponsorship_pending">
<option value="true" {% if realm.customer and realm.customer.sponsorship_pending %}selected{% endif %}>Yes</option>
<option value="false" {% if not realm.customer or not realm.customer.sponsorship_pending %}selected{% endif %}>No</option>
</select>
<button type="submit" class="button rounded small support-submit-button">Update</button>
</form>
<form method="POST" class="support-discount-form"> <form method="POST" class="support-discount-form">
<b>Discount (use 85 for nonprofits)</b>:<br> <b>Discount (use 85 for nonprofits)</b>:<br>
{{ csrf_input }} {{ csrf_input }}

View File

@ -152,6 +152,9 @@
</div> </div>
{% elif admin_access and not has_active_plan %} {% elif admin_access and not has_active_plan %}
<div class="tab-content"> <div class="tab-content">
{% if sponsorship_pending %}
<h3>Your organization has requested sponsored or discounted hosting.</h3>
{% else %}
<center> <center>
<p> <p>
<h2>Your organization is on the <b>Zulip Free</b> plan.</h2> <h2>Your organization is on the <b>Zulip Free</b> plan.</h2>
@ -163,6 +166,7 @@
</a> </a>
</p> </p>
</center> </center>
{% endif %}
</div> </div>
<div class="support-link"> <div class="support-link">
<p> <p>

View File

@ -34,6 +34,7 @@
<ul class="nav nav-tabs" id="upgrade-tabs"> <ul class="nav nav-tabs" id="upgrade-tabs">
<li class="active"><a data-toggle="tab" href="#autopay">💳 Pay automatically</a></li> <li class="active"><a data-toggle="tab" href="#autopay">💳 Pay automatically</a></li>
<li><a data-toggle="tab" href="#invoice">🧾 Pay by invoice</a></li> <li><a data-toggle="tab" href="#invoice">🧾 Pay by invoice</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 }}">
@ -218,6 +219,53 @@
Upgrade complete! The page will now reload. Upgrade complete! The page will now reload.
</div> </div>
</div> </div>
<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">
<label>
<h4>Organization type</h4>
</label>
<select name="organization-type" required style="width: 100%;">
<option disabled selected> -- select --</option>
<option value="open_source">{{_('Open source')}}</option>
<option value="research">{{_('Academic research')}}</option>
<option value="education">{{_('Education')}}</option>
<option value="non_profit">{{_('Non-Profit')}}</option>
<option value="event">{{_('Event (hackathons, conferences, etc.)')}}</option>
<option value="other">{{_('Other')}}</option>
</select>
<br>
<label>
<h4>Organization website</h4>
</label>
<input name="website" style="width: 100%;" type="text" class="input-large" required/>
<label>
<h4>Describe your organization briefly</h4>
</label>
<textarea name="description" style="width: 100%;" cols="100" rows="5" required></textarea>
<br>
<p id="sponsorship-discount-details"></p>
<button type="submit" id="sponsorship-button" class="stripe-button-el invoice-button">Submit</button>
</form>
</div>
<div id="sponsorship-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>
<div id="sponsorship_loading_indicator"></div>
</div>
<div id="sponsorship-success" class="alert alert-success">
Request received! The page will now reload.
</div>
</div>
</div> </div>
<div class="support-link"> <div class="support-link">
<p> <p>

View File

@ -0,0 +1,24 @@
{% extends "zerver/emails/email_base_messages.html" %}
{% block content %}
<b>Support URL</b>: <a href="{{ support_url }}">{{ support_url }}</a>
<br><br>
<b>Website</b>: <a href="{{ website }}">{{ website }}</a>
<br><br>
<b>Organization type</b>: {{ organization_type }}
<br><br>
<b>Description</b>:
<br>
{{ description }}
<br><br>
<b>Requested by</b>: {{ requested_by }} · <b>User role</b>: {{ user_role }} · <b>String ID</b>: {{ string_id }}
{% endblock %}

View File

@ -0,0 +1 @@
{% trans %}Sponsorship request ({{ organization_type }}) for {{ string_id }}{% endtrans %}

View File

@ -0,0 +1,10 @@
Support URL: {{ support_url }}
Website: {{ website }}
Organization type: {{ organization_type }}
Description:
{{ description }}
Requested by: {{ requested_by }} · User role: {{ user_role }} · String ID: {{ string_id }}

View File

@ -45,7 +45,7 @@
<a href="/new/" class="button green"> <a href="/new/" class="button green">
Sign up now Sign up now
</a> </a>
{% elif realm_plan_type == 2 %} {% elif realm_plan_type == 2 or sponsorship_pending %}
<div class="pricing-details"></div> <div class="pricing-details"></div>
<a href='/upgrade' class="button black-current-value" type="button"> <a href='/upgrade' class="button black-current-value" type="button">
Current plan Current plan
@ -93,6 +93,10 @@
Buy Standard Buy Standard
{% endif %} {% endif %}
</a> </a>
{% elif sponsorship_pending %}
<a href="/billing" class="button black-current-value" type="button">
Sponsorship pending
</a>
{% else %} {% else %}
<a href="/upgrade" class="button green"> <a href="/upgrade" class="button green">
{% if free_trial_days %} {% if free_trial_days %}

View File

@ -1188,6 +1188,10 @@ class UserProfile(AbstractBaseUser, PermissionsMixin):
# ROLE_GUEST to ROLE_MEMBER here. # ROLE_GUEST to ROLE_MEMBER here.
self.role = UserProfile.ROLE_MEMBER self.role = UserProfile.ROLE_MEMBER
@property
def has_billing_access(self) -> bool:
return self.is_realm_admin or self.is_billing_admin
@property @property
def is_realm_owner(self) -> bool: def is_realm_owner(self) -> bool:
return self.role == UserProfile.ROLE_REALM_OWNER return self.role == UserProfile.ROLE_REALM_OWNER

View File

@ -8,6 +8,7 @@ from django.conf import settings
from django.http import HttpResponse from django.http import HttpResponse
from django.test import override_settings from django.test import override_settings
from corporate.models import Customer
from zerver.lib.integrations import INTEGRATIONS from zerver.lib.integrations import INTEGRATIONS
from zerver.lib.test_classes import ZulipTestCase from zerver.lib.test_classes import ZulipTestCase
from zerver.lib.test_helpers import HostRequestMock from zerver.lib.test_helpers import HostRequestMock
@ -383,11 +384,12 @@ class PlansPageTest(ZulipTestCase):
sign_up_now = "Sign up now" sign_up_now = "Sign up now"
buy_standard = "Buy Standard" buy_standard = "Buy Standard"
current_plan = "Current plan" current_plan = "Current plan"
sponsorship_pending = "Sponsorship pending"
# Root domain # Root domain
result = self.client_get("/plans/", subdomain="") result = self.client_get("/plans/", subdomain="")
self.assert_in_success_response([sign_up_now, buy_standard], result) self.assert_in_success_response([sign_up_now, buy_standard], result)
self.assert_not_in_success_response([current_plan], result) self.assert_not_in_success_response([current_plan, sponsorship_pending], result)
realm = get_realm("zulip") realm = get_realm("zulip")
realm.plan_type = Realm.SELF_HOSTED realm.plan_type = Realm.SELF_HOSTED
@ -408,24 +410,32 @@ class PlansPageTest(ZulipTestCase):
# But in the development environment, it renders a page # But in the development environment, it renders a page
result = self.client_get("/plans/", subdomain="zulip") result = self.client_get("/plans/", subdomain="zulip")
self.assert_in_success_response([sign_up_now, buy_standard], result) self.assert_in_success_response([sign_up_now, buy_standard], result)
self.assert_not_in_success_response([current_plan], result) self.assert_not_in_success_response([current_plan, sponsorship_pending], result)
realm.plan_type = Realm.LIMITED realm.plan_type = Realm.LIMITED
realm.save(update_fields=["plan_type"]) realm.save(update_fields=["plan_type"])
result = self.client_get("/plans/", subdomain="zulip") result = self.client_get("/plans/", subdomain="zulip")
self.assert_in_success_response([current_plan, buy_standard], result) self.assert_in_success_response([current_plan, buy_standard], result)
self.assert_not_in_success_response([sign_up_now], result) self.assert_not_in_success_response([sign_up_now, sponsorship_pending], result)
realm.plan_type = Realm.STANDARD_FREE realm.plan_type = Realm.STANDARD_FREE
realm.save(update_fields=["plan_type"]) realm.save(update_fields=["plan_type"])
result = self.client_get("/plans/", subdomain="zulip") result = self.client_get("/plans/", subdomain="zulip")
self.assert_in_success_response([current_plan], result) self.assert_in_success_response([current_plan], result)
self.assert_not_in_success_response([sign_up_now, buy_standard], result) self.assert_not_in_success_response([sign_up_now, buy_standard, sponsorship_pending], result)
realm.plan_type = Realm.STANDARD realm.plan_type = Realm.STANDARD
realm.save(update_fields=["plan_type"]) realm.save(update_fields=["plan_type"])
result = self.client_get("/plans/", subdomain="zulip") result = self.client_get("/plans/", subdomain="zulip")
self.assert_in_success_response([current_plan], result) self.assert_in_success_response([current_plan], result)
self.assert_not_in_success_response([sign_up_now, buy_standard, sponsorship_pending], result)
realm.plan_type = Realm.LIMITED
realm.save()
Customer.objects.create(realm=get_realm("zulip"), stripe_customer_id="cus_id", sponsorship_pending=True)
result = self.client_get("/plans/", subdomain="zulip")
self.assert_in_success_response([current_plan], result)
self.assert_in_success_response([current_plan, sponsorship_pending], result)
self.assert_not_in_success_response([sign_up_now, buy_standard], result) self.assert_not_in_success_response([sign_up_now, buy_standard], result)
class AppsPageTest(ZulipTestCase): class AppsPageTest(ZulipTestCase):

View File

@ -697,17 +697,38 @@ class HomeTest(ZulipTestCase):
self.assertIn('Billing', result_html) self.assertIn('Billing', result_html)
# billing admin, with CustomerPlan -> show billing link # billing admin, with CustomerPlan -> show billing link
user.role = UserProfile.ROLE_REALM_ADMINISTRATOR user.role = UserProfile.ROLE_MEMBER
user.is_billing_admin = True user.is_billing_admin = True
user.save(update_fields=['role', 'is_billing_admin']) user.save(update_fields=['role', 'is_billing_admin'])
result_html = self._get_home_page().content.decode('utf-8') result_html = self._get_home_page().content.decode('utf-8')
self.assertIn('Billing', result_html) self.assertIn('Billing', result_html)
# member, with CustomerPlan -> no billing link
user.is_billing_admin = False
user.save(update_fields=['is_billing_admin'])
result_html = self._get_home_page().content.decode('utf-8')
self.assertNotIn('Billing', result_html)
# guest, with CustomerPlan -> no billing link
user.role = UserProfile.ROLE_GUEST
user.save(update_fields=['role'])
result_html = self._get_home_page().content.decode('utf-8')
self.assertNotIn('Billing', result_html)
# billing admin, but no CustomerPlan -> no billing link # billing admin, but no CustomerPlan -> no billing link
user.role = UserProfile.ROLE_MEMBER
user.is_billing_admin = True
user.save(update_fields=['role', 'is_billing_admin'])
CustomerPlan.objects.all().delete() CustomerPlan.objects.all().delete()
result_html = self._get_home_page().content.decode('utf-8') result_html = self._get_home_page().content.decode('utf-8')
self.assertNotIn('Billing', result_html) self.assertNotIn('Billing', result_html)
# billing admin, with sponsorship pending -> show billing link
customer.sponsorship_pending = True
customer.save(update_fields=["sponsorship_pending"])
result_html = self._get_home_page().content.decode('utf-8')
self.assertIn('Billing', result_html)
# billing admin, no customer object -> make sure it doesn't crash # billing admin, no customer object -> make sure it doesn't crash
customer.delete() customer.delete()
result = self._get_home_page() result = self._get_home_page()

View File

@ -291,10 +291,14 @@ def home_real(request: HttpRequest) -> HttpResponse:
show_plans = False show_plans = False
if settings.CORPORATE_ENABLED and user_profile is not None: if settings.CORPORATE_ENABLED and user_profile is not None:
from corporate.models import Customer, CustomerPlan from corporate.models import Customer, CustomerPlan
if user_profile.is_billing_admin or user_profile.is_realm_admin: if user_profile.has_billing_access:
customer = Customer.objects.filter(realm=user_profile.realm).first() customer = Customer.objects.filter(realm=user_profile.realm).first()
if customer is not None and CustomerPlan.objects.filter(customer=customer).exists(): if customer is not None:
show_billing = True if customer.sponsorship_pending:
show_billing = True
elif CustomerPlan.objects.filter(customer=customer).exists():
show_billing = True
if user_profile.realm.plan_type == Realm.LIMITED: if user_profile.realm.plan_type == Realm.LIMITED:
show_plans = True show_plans = True

View File

@ -28,16 +28,25 @@ def plans_view(request: HttpRequest) -> HttpResponse:
realm = get_realm_from_request(request) realm = get_realm_from_request(request)
realm_plan_type = 0 realm_plan_type = 0
free_trial_days = settings.FREE_TRIAL_DAYS free_trial_days = settings.FREE_TRIAL_DAYS
sponsorship_pending = False
if realm is not None: if realm is not None:
realm_plan_type = realm.plan_type realm_plan_type = realm.plan_type
if realm.plan_type == Realm.SELF_HOSTED and settings.PRODUCTION: if realm.plan_type == Realm.SELF_HOSTED and settings.PRODUCTION:
return HttpResponseRedirect('https://zulip.com/plans') return HttpResponseRedirect('https://zulip.com/plans')
if not request.user.is_authenticated: if not request.user.is_authenticated:
return redirect_to_login(next="plans") return redirect_to_login(next="plans")
if settings.CORPORATE_ENABLED:
from corporate.models import get_customer_by_realm
customer = get_customer_by_realm(realm)
if customer is not None:
sponsorship_pending = customer.sponsorship_pending
return TemplateResponse( return TemplateResponse(
request, request,
"zerver/plans.html", "zerver/plans.html",
context={"realm_plan_type": realm_plan_type, 'free_trial_days': free_trial_days}, context={"realm_plan_type": realm_plan_type, 'free_trial_days': free_trial_days, 'sponsorship_pending': sponsorship_pending},
) )
@add_google_analytics @add_google_analytics

View File

@ -66,6 +66,7 @@ NOTIFICATION_BOT = "notification-bot@zulip.com"
ERROR_BOT = "error-bot@zulip.com" ERROR_BOT = "error-bot@zulip.com"
EMAIL_GATEWAY_BOT = "emailgateway@zulip.com" EMAIL_GATEWAY_BOT = "emailgateway@zulip.com"
PHYSICAL_ADDRESS = "Zulip Headquarters, 123 Octo Stream, South Pacific Ocean" PHYSICAL_ADDRESS = "Zulip Headquarters, 123 Octo Stream, South Pacific Ocean"
STAFF_SUBDOMAIN = "zulip"
EXTRA_INSTALLED_APPS = ["zilencer", "analytics", "corporate"] EXTRA_INSTALLED_APPS = ["zilencer", "analytics", "corporate"]
# Disable Camo in development # Disable Camo in development
CAMO_URI = '' CAMO_URI = ''