From 1938076f6751b5a50532afff989c96aae7ef8b3d Mon Sep 17 00:00:00 2001 From: Vishnu KS Date: Fri, 28 May 2021 19:27:08 +0530 Subject: [PATCH] billing: Enforce license limit for plans on manual license management. --- corporate/lib/registration.py | 104 ++++++++++++++++++ corporate/lib/stripe.py | 4 + static/js/invite.js | 2 + static/templates/invitation_failed_error.hbs | 15 +++ templates/zerver/no_spare_licenses.html | 15 +++ zerver/forms.py | 15 +++ zerver/lib/actions.py | 18 +++- zerver/lib/exceptions.py | 9 +- zerver/tests/test_new_users.py | 72 ++++++++++++- zerver/tests/test_signup.py | 107 ++++++++++++++++++- zerver/tests/test_users.py | 2 +- zerver/views/registration.py | 10 ++ 12 files changed, 367 insertions(+), 6 deletions(-) create mode 100644 corporate/lib/registration.py create mode 100644 templates/zerver/no_spare_licenses.html diff --git a/corporate/lib/registration.py b/corporate/lib/registration.py new file mode 100644 index 0000000000..60f05946a3 --- /dev/null +++ b/corporate/lib/registration.py @@ -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) diff --git a/corporate/lib/stripe.py b/corporate/lib/stripe.py index b2a45da840..542e70732e 100644 --- a/corporate/lib/stripe.py +++ b/corporate/lib/stripe.py @@ -203,6 +203,10 @@ class BillingError(Exception): self.message = message +class LicenseLimitError(Exception): + pass + + class StripeCardError(BillingError): pass diff --git a/static/js/invite.js b/static/js/invite.js index d29b24c159..b0659ab60f 100644 --- a/static/js/invite.js +++ b/static/js/invite.js @@ -104,6 +104,8 @@ function submit_invitation_form() { error_list, is_admin: page_params.is_admin, 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"); invitee_emails_group.addClass("warning"); diff --git a/static/templates/invitation_failed_error.hbs b/static/templates/invitation_failed_error.hbs index 3aea0d82cf..126c53a6a1 100644 --- a/static/templates/invitation_failed_error.hbs +++ b/static/templates/invitation_failed_error.hbs @@ -16,3 +16,18 @@

{{t "Organization administrators can reactivate deactivated users." }}

{{/if}} {{/if}} +{{#if license_limit_reached}} + {{#if has_billing_access}} + {{#tr}} + To invite users, please increase the number of licenses or deactivate inactive users. + {{#*inline "z-link-billing"}}{{> @partial-block}}{{/inline}} + {{#*inline "z-link-help-page"}}{{> @partial-block}}{{/inline}} + {{/tr}} + {{else}} + {{#tr}} + Please ask a billing administrator to increase the number of licenses or deactivate inactive users, and try again. + {{#*inline "z-link-billing"}}{{> @partial-block}}{{/inline}} + {{#*inline "z-link-help-page"}}{{> @partial-block}}{{/inline}} + {{/tr}} + {{/if}} +{{/if}} diff --git a/templates/zerver/no_spare_licenses.html b/templates/zerver/no_spare_licenses.html new file mode 100644 index 0000000000..d7f77787cc --- /dev/null +++ b/templates/zerver/no_spare_licenses.html @@ -0,0 +1,15 @@ +{% extends "zerver/portico.html" %} +{% block portico_content %} +
+
+
+ +

{{ _("This organization cannot accept new members right now.") }}

+ +

+ {% 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 %} +

+{% endblock %} diff --git a/zerver/forms.py b/zerver/forms.py index 011d80c93a..63e10f4b2c 100644 --- a/zerver/forms.py +++ b/zerver/forms.py @@ -38,6 +38,10 @@ from zerver.models import ( ) 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 = ( "That user does not exist at MIT or is a " + 'mailing list. ' @@ -208,6 +212,17 @@ class HomepageForm(forms.Form): if realm.is_zephyr_mirror_realm: 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 diff --git a/zerver/lib/actions.py b/zerver/lib/actions.py index 891394bb6b..1eaf3b4e8f 100644 --- a/zerver/lib/actions.py +++ b/zerver/lib/actions.py @@ -363,6 +363,17 @@ def notify_new_user(user_profile: UserProfile) -> None: message = _("{user} just signed up for Zulip. (total: {user_count})").format( 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) # We also send a notification to the Zulip administrative realm @@ -6637,8 +6648,13 @@ def do_invite_users( streams: Collection[Stream], invite_as: int = PreregistrationUser.INVITE_AS["MEMBER"], ) -> 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 if not realm.invite_required: diff --git a/zerver/lib/exceptions.py b/zerver/lib/exceptions.py index 2aa090442d..79fbda000f 100644 --- a/zerver/lib/exceptions.py +++ b/zerver/lib/exceptions.py @@ -362,11 +362,16 @@ class ZephyrMessageAlreadySentException(Exception): class InvitationError(JsonableError): code = ErrorCode.INVITATION_FAILED - data_fields = ["errors", "sent_invitations"] + data_fields = ["errors", "sent_invitations", "license_limit_reached"] 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: self._msg: str = msg self.errors: List[Tuple[str, str, bool]] = errors self.sent_invitations: bool = sent_invitations + self.license_limit_reached: bool = license_limit_reached diff --git a/zerver/tests/test_new_users.py b/zerver/tests/test_new_users.py index 6b6d6f474b..501bce0722 100644 --- a/zerver/tests/test_new_users.py +++ b/zerver/tests/test_new_users.py @@ -1,4 +1,6 @@ import datetime +import random +from typing import Sequence from unittest import mock import pytz @@ -6,10 +8,11 @@ from django.conf import settings from django.core import mail 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.initial_password import initial_password 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 @@ -252,3 +255,70 @@ class TestNotifyNewUser(ZulipTestCase): f"@_**Cordelia, Lear's daughter|{new_user.id}** just signed up for Zulip.", 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", + ] + ) diff --git a/zerver/tests/test_signup.py b/zerver/tests/test_signup.py index a68484ea94..3f397c679d 100644 --- a/zerver/tests/test_signup.py +++ b/zerver/tests/test_signup.py @@ -27,6 +27,7 @@ from confirmation.models import ( get_object_from_key, one_click_unsubscribe_link, ) +from corporate.lib.stripe import get_latest_seat_count from zerver.context_processors import common_context from zerver.decorator import do_two_factor_login 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: self.register(self.nonreg_email("test"), "test") # 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 # 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) + 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: inviter = self.example_user("hamlet") 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}), ) + 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): 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) + 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: realm = get_realm("zulip") do_set_realm_property(realm, "invite_required", False, acting_user=None) diff --git a/zerver/tests/test_users.py b/zerver/tests/test_users.py index 7ed75edd7e..113d16c75c 100644 --- a/zerver/tests/test_users.py +++ b/zerver/tests/test_users.py @@ -771,7 +771,7 @@ class QueryCountTest(ZulipTestCase): acting_user=None, ) - self.assert_length(queries, 70) + self.assert_length(queries, 71) self.assert_length(cache_tries, 22) peer_add_events = [event for event in events if event["event"].get("op") == "peer_add"] diff --git a/zerver/views/registration.py b/zerver/views/registration.py index 8d5b070401..55efbb60b4 100644 --- a/zerver/views/registration.py +++ b/zerver/views/registration.py @@ -86,6 +86,10 @@ from zproject.backends import ( 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: confirmation = Confirmation.objects.filter(confirmation_key=confirmation_key).first() @@ -181,6 +185,12 @@ def accounts_register(request: HttpRequest) -> HttpResponse: except ValidationError: 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 full_name = None require_ldap_password = False