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.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')

View File

@ -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]] = []

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

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

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.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))

View File

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

View File

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

View File

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

View File

@ -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", () => {

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_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();

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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">
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 %}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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