mirror of https://github.com/zulip/zulip.git
billing: Add option to request a sponsorship in /upgrade.
This commit is contained in:
parent
606c2acefe
commit
4c6350fa4b
|
@ -10,6 +10,7 @@ from analytics.lib.counts import COUNT_STATS, CountStat
|
|||
from analytics.lib.time_utils import time_range
|
||||
from analytics.models import FillState, RealmCount, UserCount, last_successful_fill
|
||||
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.test_classes import ZulipTestCase
|
||||
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)
|
||||
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:
|
||||
cordelia = self.example_user('cordelia')
|
||||
lear_realm = get_realm('lear')
|
||||
|
|
|
@ -70,7 +70,12 @@ from zerver.models import (
|
|||
from zerver.views.invite import get_invitee_emails_set
|
||||
|
||||
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:
|
||||
from zilencer.models import RemoteInstallationCount, RemoteRealmCount, RemoteZulipServer
|
||||
|
@ -1136,6 +1141,15 @@ def support(request: HttpRequest) -> HttpResponse:
|
|||
do_deactivate_realm(realm, request.user)
|
||||
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)
|
||||
if scrub_realm is not None:
|
||||
if scrub_realm == "scrub_realm":
|
||||
|
@ -1165,6 +1179,9 @@ def support(request: HttpRequest) -> HttpResponse:
|
|||
except ValidationError:
|
||||
pass
|
||||
|
||||
for realm in realms:
|
||||
realm.customer = get_customer_by_realm(realm)
|
||||
|
||||
context["realms"] = realms
|
||||
|
||||
confirmations: List[Dict[str, Any]] = []
|
||||
|
|
|
@ -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:
|
||||
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]:
|
||||
customer = get_customer_by_realm(realm)
|
||||
if customer is not None:
|
||||
|
|
|
@ -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),
|
||||
),
|
||||
]
|
|
@ -11,6 +11,7 @@ from zerver.models import Realm
|
|||
class Customer(models.Model):
|
||||
realm: Realm = models.OneToOneField(Realm, on_delete=CASCADE)
|
||||
stripe_customer_id: str = models.CharField(max_length=255, null=True, unique=True)
|
||||
sponsorship_pending: bool = models.BooleanField(default=False)
|
||||
# A percentage, like 85.
|
||||
default_discount: Optional[Decimal] = models.DecimalField(decimal_places=4, max_digits=7, null=True)
|
||||
|
||||
|
|
|
@ -1068,6 +1068,48 @@ class StripeTest(StripeTestCase):
|
|||
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')
|
||||
|
||||
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:
|
||||
user = self.example_user("iago")
|
||||
self.login_user(user)
|
||||
|
@ -1796,8 +1838,9 @@ class RequiresBillingAccessTest(ZulipTestCase):
|
|||
string_with_all_endpoints = str(get_resolver('corporate.urls').reverse_dict)
|
||||
json_endpoints = {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.
|
||||
# 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/sponsorship")
|
||||
|
||||
self.assertEqual(len(json_endpoints), len(params))
|
||||
|
||||
|
|
|
@ -22,6 +22,8 @@ i18n_urlpatterns: Any = [
|
|||
v1_api_and_json_patterns = [
|
||||
path('billing/upgrade', rest_dispatch,
|
||||
{'POST': 'corporate.views.upgrade'}),
|
||||
path('billing/sponsorship', rest_dispatch,
|
||||
{'POST': 'corporate.views.sponsorship'}),
|
||||
path('billing/plan/change', rest_dispatch,
|
||||
{'POST': 'corporate.views.change_plan_status'}),
|
||||
path('billing/sources/change', rest_dispatch,
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
import logging
|
||||
from decimal import Decimal
|
||||
from typing import Any, Dict, Optional, Union
|
||||
from urllib.parse import urlencode, urljoin, urlunsplit
|
||||
|
||||
import stripe
|
||||
from django.conf import settings
|
||||
|
@ -28,6 +29,7 @@ from corporate.lib.stripe import (
|
|||
start_of_next_billing_cycle,
|
||||
stripe_get_customer,
|
||||
unsign_string,
|
||||
update_sponsorship_status,
|
||||
)
|
||||
from corporate.models import (
|
||||
CustomerPlan,
|
||||
|
@ -38,8 +40,9 @@ from corporate.models import (
|
|||
from zerver.decorator import require_billing_access, zulip_login_required
|
||||
from zerver.lib.request import REQ, has_request_variables
|
||||
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.models import UserProfile
|
||||
from zerver.models import UserProfile, get_realm
|
||||
|
||||
billing_logger = logging.getLogger('corporate.stripe')
|
||||
|
||||
|
@ -145,8 +148,9 @@ def initial_upgrade(request: HttpRequest) -> HttpResponse:
|
|||
return render(request, "404.html")
|
||||
|
||||
user = request.user
|
||||
|
||||
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')
|
||||
if request.GET.get("onboarding") is not None:
|
||||
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)
|
||||
signed_seat_count, salt = sign_string(str(seat_count))
|
||||
context: Dict[str, Any] = {
|
||||
'realm': user.realm,
|
||||
'publishable_key': STRIPE_PUBLISHABLE_KEY,
|
||||
'email': user.delivery_email,
|
||||
'seat_count': seat_count,
|
||||
|
@ -179,17 +184,71 @@ def initial_upgrade(request: HttpRequest) -> HttpResponse:
|
|||
response = render(request, 'corporate/upgrade.html', context=context)
|
||||
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
|
||||
def billing_home(request: HttpRequest) -> HttpResponse:
|
||||
user = request.user
|
||||
customer = get_customer_by_realm(user.realm)
|
||||
context: Dict[str, Any] = {}
|
||||
|
||||
if customer is None:
|
||||
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():
|
||||
return HttpResponseRedirect(reverse('corporate.views.initial_upgrade'))
|
||||
|
||||
if not user.is_realm_admin and not user.is_billing_admin:
|
||||
context: Dict[str, Any] = {'admin_access': False}
|
||||
if not user.has_billing_access:
|
||||
context = {'admin_access': False}
|
||||
return render(request, 'corporate/billing.html', context=context)
|
||||
|
||||
context = {
|
||||
|
|
|
@ -139,6 +139,11 @@ run_test('create_ajax_request', () => {
|
|||
state.location_reload += 1;
|
||||
};
|
||||
|
||||
window.location.replace = (reload_to) => {
|
||||
state.location_reload += 1;
|
||||
assert.equal(reload_to, "/billing");
|
||||
};
|
||||
|
||||
success();
|
||||
|
||||
assert.equal(state.location_reload, 1);
|
||||
|
|
|
@ -34,12 +34,22 @@ run_test("initialize", () => {
|
|||
assert.equal(page_name, "upgrade");
|
||||
};
|
||||
|
||||
helpers.create_ajax_request = (url, form_name, stripe_token) => {
|
||||
assert.equal(url, "/json/billing/upgrade");
|
||||
helpers.create_ajax_request = (url, form_name, stripe_token, numeric_inputs, redirect_to) => {
|
||||
if (form_name === "autopay") {
|
||||
assert.equal(url, "/json/billing/upgrade");
|
||||
assert.equal(stripe_token, "stripe_add_card_token");
|
||||
assert.deepEqual(numeric_inputs, ["licenses"]);
|
||||
assert.equal(redirect_to, undefined);
|
||||
} else if (form_name === "invoice") {
|
||||
assert.equal(url, "/json/billing/upgrade");
|
||||
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 {
|
||||
throw Error("Unhandled case");
|
||||
}
|
||||
|
@ -98,6 +108,7 @@ run_test("initialize", () => {
|
|||
|
||||
const add_card_click_handler = $('#add-card-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 = () => {
|
||||
return true;
|
||||
|
@ -112,6 +123,8 @@ run_test("initialize", () => {
|
|||
add_card_click_handler(e);
|
||||
invoice_click_handler(e);
|
||||
|
||||
request_sponsorship_click_handler(e);
|
||||
|
||||
helpers.show_license_section = (section) => {
|
||||
assert.equal(section, "manual");
|
||||
};
|
||||
|
@ -131,6 +144,26 @@ run_test("initialize", () => {
|
|||
assert.equal($("#autopay_monthly_price").text(), "6.40");
|
||||
assert.equal($("#invoice_annual_price").text(), "64");
|
||||
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", () => {
|
||||
|
|
|
@ -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_loading_indicator = "#" + form_name + "_loading_indicator";
|
||||
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.reload();
|
||||
window.location.replace(redirect_to);
|
||||
},
|
||||
error: function (xhr) {
|
||||
$(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) {
|
||||
$("#license-automatic-section").hide();
|
||||
$("#license-manual-section").hide();
|
||||
|
|
|
@ -36,6 +36,11 @@ exports.initialize = () => {
|
|||
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 = {};
|
||||
prices.annual = page_params.annual_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);
|
||||
});
|
||||
|
||||
$('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_per_month").text(helpers.format_money(prices.annual / 12));
|
||||
$("#autopay_monthly_price").text(helpers.format_money(prices.monthly));
|
||||
|
|
|
@ -79,6 +79,11 @@ tr.admin td:first-child {
|
|||
top: -25px;
|
||||
}
|
||||
|
||||
.sponsorship-pending-form {
|
||||
position: relative;
|
||||
top: -25px;
|
||||
}
|
||||
|
||||
.scrub-realm-form {
|
||||
position: relative;
|
||||
top: -50px;
|
||||
|
|
|
@ -177,6 +177,7 @@
|
|||
display: none;
|
||||
}
|
||||
|
||||
#sponsorship-loading,
|
||||
#planchange-loading,
|
||||
#cardchange-loading,
|
||||
#invoice-loading,
|
||||
|
@ -186,7 +187,7 @@
|
|||
text-align: center;
|
||||
}
|
||||
|
||||
|
||||
#sponsorship-success,
|
||||
#planchange-success,
|
||||
#cardchange-success,
|
||||
#invoice-success,
|
||||
|
@ -195,6 +196,7 @@
|
|||
display: none;
|
||||
}
|
||||
|
||||
#sponsorship-error,
|
||||
#planchange-error,
|
||||
#cardchange-error,
|
||||
#invoice-error,
|
||||
|
@ -224,6 +226,7 @@
|
|||
stroke: hsl(0, 0%, 100%);
|
||||
}
|
||||
|
||||
#sponsorship_loading_indicator,
|
||||
#planchange_loading_indicator,
|
||||
#cardchange_loading_indicator,
|
||||
#invoice_loading_indicator,
|
||||
|
@ -231,6 +234,7 @@
|
|||
margin: 10px auto;
|
||||
}
|
||||
|
||||
#sponsorship_loading_indicator_box_container,
|
||||
#planchange_loading_indicator_box_container,
|
||||
#cardchange_loading_indicator_box_container,
|
||||
#invoice_loading_indicator_box_container,
|
||||
|
@ -239,6 +243,7 @@
|
|||
left: 50%;
|
||||
}
|
||||
|
||||
#sponsorship_loading_indicator_box,
|
||||
#planchange_loading_indicator_box,
|
||||
#cardchange_loading_indicator_box,
|
||||
#invoice_loading_indicator_box,
|
||||
|
@ -250,6 +255,7 @@
|
|||
border-radius: 6px;
|
||||
}
|
||||
|
||||
#sponsorship_loading_indicator .loading_indicator_text,
|
||||
#planchange_loading_indicator .loading_indicator_text,
|
||||
#cardchange_loading_indicator .loading_indicator_text,
|
||||
#invoice_loading_indicator .loading_indicator_text,
|
||||
|
|
|
@ -30,6 +30,16 @@
|
|||
</select>
|
||||
<button type="submit" class="button rounded small support-submit-button">Update</button>
|
||||
</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">
|
||||
<b>Discount (use 85 for nonprofits)</b>:<br>
|
||||
{{ csrf_input }}
|
||||
|
|
|
@ -152,6 +152,9 @@
|
|||
</div>
|
||||
{% elif admin_access and not has_active_plan %}
|
||||
<div class="tab-content">
|
||||
{% if sponsorship_pending %}
|
||||
<h3>Your organization has requested sponsored or discounted hosting.</h3>
|
||||
{% else %}
|
||||
<center>
|
||||
<p>
|
||||
<h2>Your organization is on the <b>Zulip Free</b> plan.</h2>
|
||||
|
@ -163,6 +166,7 @@
|
|||
</a>
|
||||
</p>
|
||||
</center>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="support-link">
|
||||
<p>
|
||||
|
|
|
@ -34,6 +34,7 @@
|
|||
<ul class="nav nav-tabs" id="upgrade-tabs">
|
||||
<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="#sponsorship">💚 Request sponsorship</a></li>
|
||||
</ul>
|
||||
|
||||
<input type="hidden" name="csrfmiddlewaretoken" value="{{ csrf_token }}">
|
||||
|
@ -218,6 +219,53 @@
|
|||
Upgrade complete! The page will now reload.
|
||||
</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 class="support-link">
|
||||
<p>
|
||||
|
|
|
@ -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 %}
|
|
@ -0,0 +1 @@
|
|||
{% trans %}Sponsorship request ({{ organization_type }}) for {{ string_id }}{% endtrans %}
|
|
@ -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 }}
|
|
@ -45,7 +45,7 @@
|
|||
<a href="/new/" class="button green">
|
||||
Sign up now
|
||||
</a>
|
||||
{% elif realm_plan_type == 2 %}
|
||||
{% elif realm_plan_type == 2 or sponsorship_pending %}
|
||||
<div class="pricing-details"></div>
|
||||
<a href='/upgrade' class="button black-current-value" type="button">
|
||||
Current plan
|
||||
|
@ -93,6 +93,10 @@
|
|||
Buy Standard
|
||||
{% endif %}
|
||||
</a>
|
||||
{% elif sponsorship_pending %}
|
||||
<a href="/billing" class="button black-current-value" type="button">
|
||||
Sponsorship pending
|
||||
</a>
|
||||
{% else %}
|
||||
<a href="/upgrade" class="button green">
|
||||
{% if free_trial_days %}
|
||||
|
|
|
@ -1188,6 +1188,10 @@ class UserProfile(AbstractBaseUser, PermissionsMixin):
|
|||
# ROLE_GUEST to ROLE_MEMBER here.
|
||||
self.role = UserProfile.ROLE_MEMBER
|
||||
|
||||
@property
|
||||
def has_billing_access(self) -> bool:
|
||||
return self.is_realm_admin or self.is_billing_admin
|
||||
|
||||
@property
|
||||
def is_realm_owner(self) -> bool:
|
||||
return self.role == UserProfile.ROLE_REALM_OWNER
|
||||
|
|
|
@ -8,6 +8,7 @@ from django.conf import settings
|
|||
from django.http import HttpResponse
|
||||
from django.test import override_settings
|
||||
|
||||
from corporate.models import Customer
|
||||
from zerver.lib.integrations import INTEGRATIONS
|
||||
from zerver.lib.test_classes import ZulipTestCase
|
||||
from zerver.lib.test_helpers import HostRequestMock
|
||||
|
@ -383,11 +384,12 @@ class PlansPageTest(ZulipTestCase):
|
|||
sign_up_now = "Sign up now"
|
||||
buy_standard = "Buy Standard"
|
||||
current_plan = "Current plan"
|
||||
sponsorship_pending = "Sponsorship pending"
|
||||
|
||||
# Root domain
|
||||
result = self.client_get("/plans/", subdomain="")
|
||||
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.plan_type = Realm.SELF_HOSTED
|
||||
|
@ -408,24 +410,32 @@ class PlansPageTest(ZulipTestCase):
|
|||
# But in the development environment, it renders a page
|
||||
result = self.client_get("/plans/", subdomain="zulip")
|
||||
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.save(update_fields=["plan_type"])
|
||||
result = self.client_get("/plans/", subdomain="zulip")
|
||||
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.save(update_fields=["plan_type"])
|
||||
result = self.client_get("/plans/", subdomain="zulip")
|
||||
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.save(update_fields=["plan_type"])
|
||||
result = self.client_get("/plans/", subdomain="zulip")
|
||||
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)
|
||||
|
||||
class AppsPageTest(ZulipTestCase):
|
||||
|
|
|
@ -697,17 +697,38 @@ class HomeTest(ZulipTestCase):
|
|||
self.assertIn('Billing', result_html)
|
||||
|
||||
# billing admin, with CustomerPlan -> show billing link
|
||||
user.role = UserProfile.ROLE_REALM_ADMINISTRATOR
|
||||
user.role = UserProfile.ROLE_MEMBER
|
||||
user.is_billing_admin = True
|
||||
user.save(update_fields=['role', 'is_billing_admin'])
|
||||
result_html = self._get_home_page().content.decode('utf-8')
|
||||
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
|
||||
user.role = UserProfile.ROLE_MEMBER
|
||||
user.is_billing_admin = True
|
||||
user.save(update_fields=['role', 'is_billing_admin'])
|
||||
CustomerPlan.objects.all().delete()
|
||||
result_html = self._get_home_page().content.decode('utf-8')
|
||||
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
|
||||
customer.delete()
|
||||
result = self._get_home_page()
|
||||
|
|
|
@ -291,10 +291,14 @@ def home_real(request: HttpRequest) -> HttpResponse:
|
|||
show_plans = False
|
||||
if settings.CORPORATE_ENABLED and user_profile is not None:
|
||||
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()
|
||||
if customer is not None and CustomerPlan.objects.filter(customer=customer).exists():
|
||||
show_billing = True
|
||||
if customer is not None:
|
||||
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:
|
||||
show_plans = True
|
||||
|
||||
|
|
|
@ -28,16 +28,25 @@ def plans_view(request: HttpRequest) -> HttpResponse:
|
|||
realm = get_realm_from_request(request)
|
||||
realm_plan_type = 0
|
||||
free_trial_days = settings.FREE_TRIAL_DAYS
|
||||
sponsorship_pending = False
|
||||
|
||||
if realm is not None:
|
||||
realm_plan_type = realm.plan_type
|
||||
if realm.plan_type == Realm.SELF_HOSTED and settings.PRODUCTION:
|
||||
return HttpResponseRedirect('https://zulip.com/plans')
|
||||
if not request.user.is_authenticated:
|
||||
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(
|
||||
request,
|
||||
"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
|
||||
|
|
|
@ -66,6 +66,7 @@ NOTIFICATION_BOT = "notification-bot@zulip.com"
|
|||
ERROR_BOT = "error-bot@zulip.com"
|
||||
EMAIL_GATEWAY_BOT = "emailgateway@zulip.com"
|
||||
PHYSICAL_ADDRESS = "Zulip Headquarters, 123 Octo Stream, South Pacific Ocean"
|
||||
STAFF_SUBDOMAIN = "zulip"
|
||||
EXTRA_INSTALLED_APPS = ["zilencer", "analytics", "corporate"]
|
||||
# Disable Camo in development
|
||||
CAMO_URI = ''
|
||||
|
|
Loading…
Reference in New Issue