billing: Enforce license limit for plans on manual license management.

This commit is contained in:
Vishnu KS 2021-05-28 19:27:08 +05:30 committed by Tim Abbott
parent 8c055107d9
commit 1938076f67
12 changed files with 367 additions and 6 deletions

View File

@ -0,0 +1,104 @@
from typing import Optional
from django.conf import settings
from django.utils.translation import gettext as _
from corporate.lib.stripe import LicenseLimitError, get_latest_seat_count
from corporate.models import get_current_plan_by_realm
from zerver.lib.actions import send_message_to_signup_notification_stream
from zerver.lib.exceptions import InvitationError
from zerver.models import Realm, get_system_bot
def generate_licenses_low_warning_message_if_required(realm: Realm) -> Optional[str]:
plan = get_current_plan_by_realm(realm)
if plan is None or plan.automanage_licenses:
return None
licenses_remaining = plan.licenses() - get_latest_seat_count(realm)
if licenses_remaining > 3:
return None
format_kwargs = {
"billing_page_link": "/billing/#settings",
"deactivate_user_help_page_link": "/help/deactivate-or-reactivate-a-user",
}
if licenses_remaining <= 0:
return _(
"Your organization has no Zulip licenses remaining and can no longer accept new users. "
"Please [increase the number of licenses]({billing_page_link}) or "
"[deactivate inactive users]({deactivate_user_help_page_link}) to allow new users to join."
).format(**format_kwargs)
return {
1: _(
"Your organization has only one Zulip license remaining. You can "
"[increase the number of licenses]({billing_page_link}) or [deactivate inactive users]({deactivate_user_help_page_link}) "
"to allow more than one user to join."
),
2: _(
"Your organization has only two Zulip licenses remaining. You can "
"[increase the number of licenses]({billing_page_link}) or [deactivate inactive users]({deactivate_user_help_page_link}) "
"to allow more than two users to join."
),
3: _(
"Your organization has only three Zulip licenses remaining. You can "
"[increase the number of licenses]({billing_page_link}) or [deactivate inactive users]({deactivate_user_help_page_link}) "
"to allow more than three users to join."
),
}[licenses_remaining].format(**format_kwargs)
def send_user_unable_to_signup_message_to_signup_notification_stream(
realm: Realm, user_email: str
) -> None:
message = _(
"A new member ({email}) was unable to join your organization because all Zulip licenses "
"are in use. Please [increase the number of licenses]({billing_page_link}) or "
"[deactivate inactive users]({deactivate_user_help_page_link}) to allow new members to join."
).format(
email=user_email,
billing_page_link="/billing/#settings",
deactivate_user_help_page_link="/help/deactivate-or-reactivate-a-user",
)
send_message_to_signup_notification_stream(
get_system_bot(settings.NOTIFICATION_BOT), realm, message
)
def check_spare_licenses_available_for_adding_new_users(
realm: Realm, number_of_users_to_add: int
) -> None:
if realm.string_id in settings.LICENSE_CHECK_EXEMPTED_REALMS_ON_MANUAL_PLAN:
return # nocoverage
plan = get_current_plan_by_realm(realm)
if plan is None or plan.automanage_licenses:
return
if plan.licenses() < get_latest_seat_count(realm) + number_of_users_to_add:
raise LicenseLimitError()
def check_spare_licenses_available_for_registering_new_user(
realm: Realm, user_email_to_add: str
) -> None:
try:
check_spare_licenses_available_for_adding_new_users(realm, 1)
except LicenseLimitError:
send_user_unable_to_signup_message_to_signup_notification_stream(realm, user_email_to_add)
raise
def check_spare_licenses_available_for_inviting_new_users(realm: Realm, num_invites: int) -> None:
try:
check_spare_licenses_available_for_adding_new_users(realm, num_invites)
except LicenseLimitError:
if num_invites == 1:
message = _("All Zulip licenses for this organization are currently in use.")
else:
message = _(
"Your organization does not have enough unused Zulip licenses to invite {num_invites} users."
).format(num_invites=num_invites)
raise InvitationError(message, [], sent_invitations=False, license_limit_reached=True)

View File

@ -203,6 +203,10 @@ class BillingError(Exception):
self.message = message self.message = message
class LicenseLimitError(Exception):
pass
class StripeCardError(BillingError): class StripeCardError(BillingError):
pass pass

View File

@ -104,6 +104,8 @@ function submit_invitation_form() {
error_list, error_list,
is_admin: page_params.is_admin, is_admin: page_params.is_admin,
is_invitee_deactivated, is_invitee_deactivated,
license_limit_reached: arr.license_limit_reached,
has_billing_access: page_params.is_owner || page_params.is_billing_admin,
}); });
ui_report.message(error_response, invite_status, "alert-warning"); ui_report.message(error_response, invite_status, "alert-warning");
invitee_emails_group.addClass("warning"); invitee_emails_group.addClass("warning");

View File

@ -16,3 +16,18 @@
<p id="invitation_non_admin_message">{{t "Organization administrators can reactivate deactivated users." }}</p> <p id="invitation_non_admin_message">{{t "Organization administrators can reactivate deactivated users." }}</p>
{{/if}} {{/if}}
{{/if}} {{/if}}
{{#if license_limit_reached}}
{{#if has_billing_access}}
{{#tr}}
To invite users, please <z-link-billing>increase the number of licenses</z-link-billing> or <z-link-help-page>deactivate inactive users</z-link-help-page>.
{{#*inline "z-link-billing"}}<a href="/billing/#settings">{{> @partial-block}}</a>{{/inline}}
{{#*inline "z-link-help-page"}}<a href="/help/deactivate-or-reactivate-a-user">{{> @partial-block}}</a>{{/inline}}
{{/tr}}
{{else}}
{{#tr}}
Please ask a billing administrator to <z-link-billing>increase the number of licenses</z-link-billing> or <z-link-help-page>deactivate inactive users</z-link-help-page>, and try again.
{{#*inline "z-link-billing"}}<a href="/billing/#settings">{{> @partial-block}}</a>{{/inline}}
{{#*inline "z-link-help-page"}}<a href="/help/deactivate-or-reactivate-a-user">{{> @partial-block}}</a>{{/inline}}
{{/tr}}
{{/if}}
{{/if}}

View File

@ -0,0 +1,15 @@
{% extends "zerver/portico.html" %}
{% block portico_content %}
<br/>
<br/>
<br/>
<h3>{{ _("This organization cannot accept new members right now.") }}</h3>
<p>
{% trans %}
New members cannot join this organization because all Zulip licenses are currently in use. Please contact the person who
invited you and ask them to increase the number of licenses, then try again.
{% endtrans %}
</p>
{% endblock %}

View File

@ -38,6 +38,10 @@ from zerver.models import (
) )
from zproject.backends import check_password_strength, email_auth_enabled, email_belongs_to_ldap from zproject.backends import check_password_strength, email_auth_enabled, email_belongs_to_ldap
if settings.BILLING_ENABLED:
from corporate.lib.registration import check_spare_licenses_available_for_registering_new_user
from corporate.lib.stripe import LicenseLimitError
MIT_VALIDATION_ERROR = ( MIT_VALIDATION_ERROR = (
"That user does not exist at MIT or is a " "That user does not exist at MIT or is a "
+ '<a href="https://ist.mit.edu/email-lists">mailing list</a>. ' + '<a href="https://ist.mit.edu/email-lists">mailing list</a>. '
@ -208,6 +212,17 @@ class HomepageForm(forms.Form):
if realm.is_zephyr_mirror_realm: if realm.is_zephyr_mirror_realm:
email_is_not_mit_mailing_list(email) email_is_not_mit_mailing_list(email)
if settings.BILLING_ENABLED:
try:
check_spare_licenses_available_for_registering_new_user(realm, email)
except LicenseLimitError:
raise ValidationError(
_(
"New members cannot join this organization because all Zulip licenses are in use. Please contact the person who "
"invited you and ask them to increase the number of licenses, then try again."
)
)
return email return email

View File

@ -363,6 +363,17 @@ def notify_new_user(user_profile: UserProfile) -> None:
message = _("{user} just signed up for Zulip. (total: {user_count})").format( message = _("{user} just signed up for Zulip. (total: {user_count})").format(
user=f"@_**{user_profile.full_name}|{user_profile.id}**", user_count=user_count user=f"@_**{user_profile.full_name}|{user_profile.id}**", user_count=user_count
) )
if settings.BILLING_ENABLED:
from corporate.lib.registration import generate_licenses_low_warning_message_if_required
licenses_low_warning_message = generate_licenses_low_warning_message_if_required(
user_profile.realm
)
if licenses_low_warning_message is not None:
message += "\n"
message += licenses_low_warning_message
send_message_to_signup_notification_stream(sender, user_profile.realm, message) send_message_to_signup_notification_stream(sender, user_profile.realm, message)
# We also send a notification to the Zulip administrative realm # We also send a notification to the Zulip administrative realm
@ -6637,8 +6648,13 @@ def do_invite_users(
streams: Collection[Stream], streams: Collection[Stream],
invite_as: int = PreregistrationUser.INVITE_AS["MEMBER"], invite_as: int = PreregistrationUser.INVITE_AS["MEMBER"],
) -> None: ) -> None:
num_invites = len(invitee_emails)
check_invite_limit(user_profile.realm, len(invitee_emails)) check_invite_limit(user_profile.realm, num_invites)
if settings.BILLING_ENABLED:
from corporate.lib.registration import check_spare_licenses_available_for_inviting_new_users
check_spare_licenses_available_for_inviting_new_users(user_profile.realm, num_invites)
realm = user_profile.realm realm = user_profile.realm
if not realm.invite_required: if not realm.invite_required:

View File

@ -362,11 +362,16 @@ class ZephyrMessageAlreadySentException(Exception):
class InvitationError(JsonableError): class InvitationError(JsonableError):
code = ErrorCode.INVITATION_FAILED code = ErrorCode.INVITATION_FAILED
data_fields = ["errors", "sent_invitations"] data_fields = ["errors", "sent_invitations", "license_limit_reached"]
def __init__( def __init__(
self, msg: str, errors: List[Tuple[str, str, bool]], sent_invitations: bool self,
msg: str,
errors: List[Tuple[str, str, bool]],
sent_invitations: bool,
license_limit_reached: bool = False,
) -> None: ) -> None:
self._msg: str = msg self._msg: str = msg
self.errors: List[Tuple[str, str, bool]] = errors self.errors: List[Tuple[str, str, bool]] = errors
self.sent_invitations: bool = sent_invitations self.sent_invitations: bool = sent_invitations
self.license_limit_reached: bool = license_limit_reached

View File

@ -1,4 +1,6 @@
import datetime import datetime
import random
from typing import Sequence
from unittest import mock from unittest import mock
import pytz import pytz
@ -6,10 +8,11 @@ from django.conf import settings
from django.core import mail from django.core import mail
from django.test import override_settings from django.test import override_settings
from corporate.lib.stripe import get_latest_seat_count
from zerver.lib.actions import do_change_notification_settings, notify_new_user from zerver.lib.actions import do_change_notification_settings, notify_new_user
from zerver.lib.initial_password import initial_password from zerver.lib.initial_password import initial_password
from zerver.lib.test_classes import ZulipTestCase from zerver.lib.test_classes import ZulipTestCase
from zerver.models import Realm, Recipient, Stream from zerver.models import Realm, Recipient, Stream, UserProfile, get_realm
from zerver.signals import JUST_CREATED_THRESHOLD, get_device_browser, get_device_os from zerver.signals import JUST_CREATED_THRESHOLD, get_device_browser, get_device_os
@ -252,3 +255,70 @@ class TestNotifyNewUser(ZulipTestCase):
f"@_**Cordelia, Lear's daughter|{new_user.id}** just signed up for Zulip.", f"@_**Cordelia, Lear's daughter|{new_user.id}** just signed up for Zulip.",
message.content, message.content,
) )
def test_notify_realm_of_new_user_in_manual_license_management(self) -> None:
stream = self.make_stream(Realm.INITIAL_PRIVATE_STREAM_NAME)
realm = get_realm("zulip")
realm.signup_notifications_stream_id = stream.id
realm.save()
user_count = get_latest_seat_count(realm)
self.subscribe_realm_to_monthly_plan_on_manual_license_management(
realm, user_count + 5, user_count + 5
)
def create_new_user_and_verify_strings_in_notification_message(
strings_present: Sequence[str] = [], strings_absent: Sequence[str] = []
) -> None:
user_no = random.randrange(100000)
new_user = UserProfile.objects.create(
realm=realm,
full_name=f"new user {user_no}",
email=f"user-{user_no}-email@zulip.com",
delivery_email=f"user-{user_no}-delivery-email@zulip.com",
)
notify_new_user(new_user)
message = self.get_last_message()
actual_stream = Stream.objects.get(id=message.recipient.type_id)
self.assertEqual(actual_stream, stream)
self.assertIn(
f"@_**new user {user_no}|{new_user.id}** just signed up for Zulip.",
message.content,
)
for string_present in strings_present:
self.assertIn(
string_present,
message.content,
)
for string_absent in strings_absent:
self.assertNotIn(string_absent, message.content)
create_new_user_and_verify_strings_in_notification_message(
strings_absent=["Your organization has"]
)
create_new_user_and_verify_strings_in_notification_message(
strings_present=[
"Your organization has only three Zulip licenses remaining",
"to allow more than three users to",
],
)
create_new_user_and_verify_strings_in_notification_message(
strings_present=[
"Your organization has only two Zulip licenses remaining",
"to allow more than two users to",
],
)
create_new_user_and_verify_strings_in_notification_message(
strings_present=[
"Your organization has only one Zulip license remaining",
"to allow more than one user to",
],
)
create_new_user_and_verify_strings_in_notification_message(
strings_present=[
"Your organization has no Zulip licenses remaining",
"to allow new users to",
]
)

View File

@ -27,6 +27,7 @@ from confirmation.models import (
get_object_from_key, get_object_from_key,
one_click_unsubscribe_link, one_click_unsubscribe_link,
) )
from corporate.lib.stripe import get_latest_seat_count
from zerver.context_processors import common_context from zerver.context_processors import common_context
from zerver.decorator import do_two_factor_login from zerver.decorator import do_two_factor_login
from zerver.forms import HomepageForm, check_subdomain_available from zerver.forms import HomepageForm, check_subdomain_available
@ -773,7 +774,7 @@ class LoginTest(ZulipTestCase):
with queries_captured() as queries, cache_tries_captured() as cache_tries: with queries_captured() as queries, cache_tries_captured() as cache_tries:
self.register(self.nonreg_email("test"), "test") self.register(self.nonreg_email("test"), "test")
# Ensure the number of queries we make is not O(streams) # Ensure the number of queries we make is not O(streams)
self.assert_length(queries, 70) self.assert_length(queries, 73)
# We can probably avoid a couple cache hits here, but there doesn't # We can probably avoid a couple cache hits here, but there doesn't
# seem to be any O(N) behavior. Some of the cache hits are related # seem to be any O(N) behavior. Some of the cache hits are related
@ -1098,6 +1099,40 @@ class InviteUserTest(InviteUserBase):
self.assert_json_success(result) self.assert_json_success(result)
def test_invite_user_to_realm_on_manual_license_plan(self) -> None:
user = self.example_user("hamlet")
self.login_user(user)
_, ledger = self.subscribe_realm_to_monthly_plan_on_manual_license_management(
user.realm, 50, 50
)
with self.settings(BILLING_ENABLED=True):
result = self.invite(self.nonreg_email("alice"), ["Denmark"])
self.assert_json_success(result)
ledger.licenses_at_next_renewal = 5
ledger.save(update_fields=["licenses_at_next_renewal"])
with self.settings(BILLING_ENABLED=True):
result = self.invite(self.nonreg_email("bob"), ["Denmark"])
self.assert_json_success(result)
ledger.licenses = get_latest_seat_count(user.realm) + 1
ledger.save(update_fields=["licenses"])
with self.settings(BILLING_ENABLED=True):
invitee_emails = self.nonreg_email("bob") + "," + self.nonreg_email("alice")
result = self.invite(invitee_emails, ["Denmark"])
self.assert_json_error_contains(
result, "Your organization does not have enough unused Zulip licenses to invite 2 users"
)
ledger.licenses = get_latest_seat_count(user.realm)
ledger.save(update_fields=["licenses"])
with self.settings(BILLING_ENABLED=True):
result = self.invite(self.nonreg_email("bob"), ["Denmark"])
self.assert_json_error_contains(
result, "All Zulip licenses for this organization are currently in use"
)
def test_cross_realm_bot(self) -> None: def test_cross_realm_bot(self) -> None:
inviter = self.example_user("hamlet") inviter = self.example_user("hamlet")
self.login_user(inviter) self.login_user(inviter)
@ -2004,6 +2039,40 @@ so we didn't send them an invitation. We did send invitations to everyone else!"
reverse("login") + "?" + urlencode({"email": email, "already_registered": 1}), reverse("login") + "?" + urlencode({"email": email, "already_registered": 1}),
) )
def test_confirmation_link_in_manual_license_plan(self) -> None:
inviter = self.example_user("iago")
realm = get_realm("zulip")
email = self.nonreg_email("alice")
realm = get_realm("zulip")
prereg_user = PreregistrationUser.objects.create(
email=email, referred_by=inviter, realm=realm
)
confirmation_link = create_confirmation_link(prereg_user, Confirmation.USER_REGISTRATION)
registration_key = confirmation_link.split("/")[-1]
url = "/accounts/register/"
self.client_post(
url, {"key": registration_key, "from_confirmation": 1, "full_name": "alice"}
)
response = self.submit_reg_form_for_user(email, "password", key=registration_key)
self.assertEqual(response.status_code, 302)
self.assertEqual(response.url, "http://zulip.testserver/")
self.subscribe_realm_to_monthly_plan_on_manual_license_management(realm, 5, 5)
email = self.nonreg_email("bob")
prereg_user = PreregistrationUser.objects.create(
email=email, referred_by=inviter, realm=realm
)
confirmation_link = create_confirmation_link(prereg_user, Confirmation.USER_REGISTRATION)
registration_key = confirmation_link.split("/")[-1]
url = "/accounts/register/"
self.client_post(url, {"key": registration_key, "from_confirmation": 1, "full_name": "bob"})
response = self.submit_reg_form_for_user(email, "password", key=registration_key)
self.assert_in_success_response(
["New members cannot join this organization because all Zulip licenses are"], response
)
class InvitationsTestCase(InviteUserBase): class InvitationsTestCase(InviteUserBase):
def test_do_get_user_invites(self) -> None: def test_do_get_user_invites(self) -> None:
@ -3806,6 +3875,42 @@ class UserSignUpTest(InviteUserBase):
) )
self.assert_in_success_response(["We couldn't find your confirmation link"], result) self.assert_in_success_response(["We couldn't find your confirmation link"], result)
def test_signup_to_realm_on_manual_license_plan(self) -> None:
realm = get_realm("zulip")
denmark_stream = get_stream("Denmark", realm)
realm.signup_notifications_stream = denmark_stream
realm.save(update_fields=["signup_notifications_stream"])
_, ledger = self.subscribe_realm_to_monthly_plan_on_manual_license_management(realm, 5, 5)
with self.settings(BILLING_ENABLED=True):
form = HomepageForm({"email": self.nonreg_email("test")}, realm=realm)
self.assertIn(
"New members cannot join this organization because all Zulip licenses",
form.errors["email"][0],
)
last_message = Message.objects.last()
self.assertIn(
f"A new member ({self.nonreg_email('test')}) was unable to join your organization because all Zulip",
last_message.content,
)
self.assertEqual(last_message.recipient.type_id, denmark_stream.id)
ledger.licenses_at_next_renewal = 50
ledger.save(update_fields=["licenses_at_next_renewal"])
with self.settings(BILLING_ENABLED=True):
form = HomepageForm({"email": self.nonreg_email("test")}, realm=realm)
self.assertIn(
"New members cannot join this organization because all Zulip licenses",
form.errors["email"][0],
)
ledger.licenses = 50
ledger.save(update_fields=["licenses"])
with self.settings(BILLING_ENABLED=True):
form = HomepageForm({"email": self.nonreg_email("test")}, realm=realm)
self.assertEqual(form.errors, {})
def test_failed_signup_due_to_restricted_domain(self) -> None: def test_failed_signup_due_to_restricted_domain(self) -> None:
realm = get_realm("zulip") realm = get_realm("zulip")
do_set_realm_property(realm, "invite_required", False, acting_user=None) do_set_realm_property(realm, "invite_required", False, acting_user=None)

View File

@ -771,7 +771,7 @@ class QueryCountTest(ZulipTestCase):
acting_user=None, acting_user=None,
) )
self.assert_length(queries, 70) self.assert_length(queries, 71)
self.assert_length(cache_tries, 22) self.assert_length(cache_tries, 22)
peer_add_events = [event for event in events if event["event"].get("op") == "peer_add"] peer_add_events = [event for event in events if event["event"].get("op") == "peer_add"]

View File

@ -86,6 +86,10 @@ from zproject.backends import (
password_auth_enabled, password_auth_enabled,
) )
if settings.BILLING_ENABLED:
from corporate.lib.registration import check_spare_licenses_available_for_registering_new_user
from corporate.lib.stripe import LicenseLimitError
def check_prereg_key_and_redirect(request: HttpRequest, confirmation_key: str) -> HttpResponse: def check_prereg_key_and_redirect(request: HttpRequest, confirmation_key: str) -> HttpResponse:
confirmation = Confirmation.objects.filter(confirmation_key=confirmation_key).first() confirmation = Confirmation.objects.filter(confirmation_key=confirmation_key).first()
@ -181,6 +185,12 @@ def accounts_register(request: HttpRequest) -> HttpResponse:
except ValidationError: except ValidationError:
return redirect_to_email_login_url(email) return redirect_to_email_login_url(email)
if settings.BILLING_ENABLED:
try:
check_spare_licenses_available_for_registering_new_user(realm, email)
except LicenseLimitError:
return render(request, "zerver/no_spare_licenses.html")
name_validated = False name_validated = False
full_name = None full_name = None
require_ldap_password = False require_ldap_password = False