From 55a9a019a0ffd9827e0898567b53f0c91aa4b40b Mon Sep 17 00:00:00 2001 From: Vishnu KS Date: Thu, 15 Jul 2021 14:38:37 +0000 Subject: [PATCH] billing: Split views into upgrade and billing page views. This is a prep commit for the Stripe checkout migration. The Stripe migration commit adds a lot of new view functions. Keeping all of the views in one view file makes it super hard for readbability. So creating a new views folder and splitting the existing view file into two so that we minimize the changes in the big migration commit. --- corporate/tests/test_stripe.py | 70 ++--- corporate/urls.py | 10 +- corporate/views/__init__.py | 0 corporate/{views.py => views/billing_page.py} | 261 +---------------- corporate/views/upgrade.py | 273 ++++++++++++++++++ 5 files changed, 318 insertions(+), 296 deletions(-) create mode 100644 corporate/views/__init__.py rename corporate/{views.py => views/billing_page.py} (51%) create mode 100644 corporate/views/upgrade.py diff --git a/corporate/tests/test_stripe.py b/corporate/tests/test_stripe.py index 6f2c151945..5db2e09f10 100644 --- a/corporate/tests/test_stripe.py +++ b/corporate/tests/test_stripe.py @@ -653,7 +653,7 @@ class StripeTest(StripeTestCase): self.assertEqual("/billing/", response.url) # Check /billing has the correct information - with patch("corporate.views.timezone_now", return_value=self.now): + with patch("corporate.views.billing_page.timezone_now", return_value=self.now): response = self.client_get("/billing/") self.assert_not_in_success_response(["Pay annually"], response) for substring in [ @@ -792,7 +792,7 @@ class StripeTest(StripeTestCase): self.assertEqual("/billing/", response.url) # Check /billing has the correct information - with patch("corporate.views.timezone_now", return_value=self.now): + with patch("corporate.views.billing_page.timezone_now", return_value=self.now): response = self.client_get("/billing/") self.assert_not_in_success_response(["Pay annually", "Update card"], response) for substring in [ @@ -900,7 +900,7 @@ class StripeTest(StripeTestCase): self.assertEqual(realm.plan_type, Realm.STANDARD) self.assertEqual(realm.max_invites, Realm.INVITES_STANDARD_REALM_DAILY_MAX) - with patch("corporate.views.timezone_now", return_value=self.now): + with patch("corporate.views.billing_page.timezone_now", return_value=self.now): response = self.client_get("/billing/") self.assert_not_in_success_response(["Pay annually"], response) for substring in [ @@ -919,7 +919,7 @@ class StripeTest(StripeTestCase): self.assert_in_response(substring, response) self.assert_not_in_success_response(["Go to your Zulip organization"], response) - with patch("corporate.views.timezone_now", return_value=self.now): + with patch("corporate.views.billing_page.timezone_now", return_value=self.now): response = self.client_get("/billing/", {"onboarding": "true"}) self.assert_in_success_response(["Go to your Zulip organization"], response) @@ -1101,7 +1101,7 @@ class StripeTest(StripeTestCase): self.assertEqual(realm.plan_type, Realm.STANDARD) self.assertEqual(realm.max_invites, Realm.INVITES_STANDARD_REALM_DAILY_MAX) - with patch("corporate.views.timezone_now", return_value=self.now): + with patch("corporate.views.billing_page.timezone_now", return_value=self.now): response = self.client_get("/billing/") self.assert_not_in_success_response(["Pay annually"], response) for substring in [ @@ -1270,7 +1270,7 @@ class StripeTest(StripeTestCase): # Try again, with a valid card, after they added a few users with patch("corporate.lib.stripe.get_latest_seat_count", return_value=23): - with patch("corporate.views.get_latest_seat_count", return_value=23): + with patch("corporate.views.upgrade.get_latest_seat_count", return_value=23): self.upgrade() customer = Customer.objects.get(realm=get_realm("zulip")) # It's impossible to create two Customers, but check that we didn't @@ -1397,7 +1397,7 @@ class StripeTest(StripeTestCase): else: del_args = [] upgrade_params["licenses"] = licenses - with patch("corporate.views.process_initial_upgrade"): + with patch("corporate.views.upgrade.process_initial_upgrade"): response = self.upgrade( invoice=invoice, talk_to_stripe=False, del_args=del_args, **upgrade_params ) @@ -1442,7 +1442,7 @@ class StripeTest(StripeTestCase): hamlet = self.example_user("hamlet") self.login_user(hamlet) with patch( - "corporate.views.process_initial_upgrade", side_effect=Exception + "corporate.views.upgrade.process_initial_upgrade", side_effect=Exception ), self.assertLogs("corporate.stripe", "WARNING") as m: response = self.upgrade(talk_to_stripe=False) self.assertIn("ERROR:corporate.stripe:Uncaught exception in billing", m.output[0]) @@ -1844,7 +1844,7 @@ class StripeTest(StripeTestCase): self.assertEqual(plan.licenses(), self.seat_count) self.assertEqual(plan.licenses_at_next_renewal(), self.seat_count) with self.assertLogs("corporate.stripe", "INFO") as m: - with patch("corporate.views.timezone_now", return_value=self.now): + with patch("corporate.views.billing_page.timezone_now", return_value=self.now): response = self.client_patch( "/json/billing/plan", {"status": CustomerPlan.DOWNGRADE_AT_END_OF_CYCLE} ) @@ -1858,9 +1858,11 @@ class StripeTest(StripeTestCase): self.assertEqual(plan.licenses(), self.seat_count) self.assertEqual(plan.licenses_at_next_renewal(), None) - with patch("corporate.views.timezone_now", return_value=self.now): + with patch("corporate.views.billing_page.timezone_now", return_value=self.now): mock_customer = Mock(email=user.delivery_email, default_source=None) - with patch("corporate.views.stripe_get_customer", return_value=mock_customer): + with patch( + "corporate.views.billing_page.stripe_get_customer", return_value=mock_customer + ): response = self.client_get("/billing/") self.assert_in_success_response( [ @@ -1951,7 +1953,7 @@ class StripeTest(StripeTestCase): assert new_plan is not None with self.assertLogs("corporate.stripe", "INFO") as m: - with patch("corporate.views.timezone_now", return_value=self.now): + with patch("corporate.views.billing_page.timezone_now", return_value=self.now): response = self.client_patch( "/json/billing/plan", {"status": CustomerPlan.SWITCH_TO_ANNUAL_AT_END_OF_CYCLE}, @@ -1961,7 +1963,7 @@ class StripeTest(StripeTestCase): self.assert_json_success(response) monthly_plan.refresh_from_db() self.assertEqual(monthly_plan.status, CustomerPlan.SWITCH_TO_ANNUAL_AT_END_OF_CYCLE) - with patch("corporate.views.timezone_now", return_value=self.now): + with patch("corporate.views.billing_page.timezone_now", return_value=self.now): response = self.client_get("/billing/") self.assert_in_success_response( ["be switched from monthly to annual billing on February 2, 2012"], response @@ -2137,7 +2139,7 @@ class StripeTest(StripeTestCase): new_plan = get_current_plan_by_realm(user.realm) assert new_plan is not None with self.assertLogs("corporate.stripe", "INFO") as m: - with patch("corporate.views.timezone_now", return_value=self.now): + with patch("corporate.views.billing_page.timezone_now", return_value=self.now): response = self.client_patch( "/json/billing/plan", {"status": CustomerPlan.SWITCH_TO_ANNUAL_AT_END_OF_CYCLE}, @@ -2149,7 +2151,7 @@ class StripeTest(StripeTestCase): self.assert_json_success(response) monthly_plan.refresh_from_db() self.assertEqual(monthly_plan.status, CustomerPlan.SWITCH_TO_ANNUAL_AT_END_OF_CYCLE) - with patch("corporate.views.timezone_now", return_value=self.now): + with patch("corporate.views.billing_page.timezone_now", return_value=self.now): response = self.client_get("/billing/") self.assert_in_success_response( ["be switched from monthly to annual billing on February 2, 2012"], response @@ -2238,7 +2240,7 @@ class StripeTest(StripeTestCase): with patch("corporate.lib.stripe.timezone_now", return_value=self.now): self.local_upgrade(self.seat_count, True, CustomerPlan.ANNUAL, "token") with self.assertLogs("corporate.stripe", "INFO") as m: - with patch("corporate.views.timezone_now", return_value=self.now): + with patch("corporate.views.billing_page.timezone_now", return_value=self.now): response = self.client_patch( "/json/billing/plan", {"status": CustomerPlan.DOWNGRADE_AT_END_OF_CYCLE} ) @@ -2252,7 +2254,7 @@ class StripeTest(StripeTestCase): CustomerPlan.objects.first().status, CustomerPlan.DOWNGRADE_AT_END_OF_CYCLE ) with self.assertLogs("corporate.stripe", "INFO") as m: - with patch("corporate.views.timezone_now", return_value=self.now): + with patch("corporate.views.billing_page.timezone_now", return_value=self.now): response = self.client_patch("/json/billing/plan", {"status": CustomerPlan.ACTIVE}) expected_log = f"INFO:corporate.stripe:Change plan status: Customer.id: {stripe_customer_id}, CustomerPlan.id: {new_plan.id}, status: {CustomerPlan.ACTIVE}" self.assertEqual(m.output[0], expected_log) @@ -2276,7 +2278,7 @@ class StripeTest(StripeTestCase): stripe_customer_id = Customer.objects.get(realm=user.realm).id new_plan = get_current_plan_by_realm(user.realm) assert new_plan is not None - with patch("corporate.views.timezone_now", return_value=self.now): + with patch("corporate.views.billing_page.timezone_now", return_value=self.now): self.client_patch( "/json/billing/plan", {"status": CustomerPlan.DOWNGRADE_AT_END_OF_CYCLE} ) @@ -2314,7 +2316,7 @@ class StripeTest(StripeTestCase): self.login_user(user) - with patch("corporate.views.timezone_now", return_value=self.now): + with patch("corporate.views.billing_page.timezone_now", return_value=self.now): self.client_patch("/json/billing/plan", {"status": CustomerPlan.ENDED}) plan.refresh_from_db() @@ -2347,7 +2349,7 @@ class StripeTest(StripeTestCase): self.login_user(user) with self.assertLogs("corporate.stripe", "INFO") as m: - with patch("corporate.views.timezone_now", return_value=self.now): + with patch("corporate.views.billing_page.timezone_now", return_value=self.now): self.client_patch( "/json/billing/plan", {"status": CustomerPlan.DOWNGRADE_AT_END_OF_CYCLE} ) @@ -2399,35 +2401,35 @@ class StripeTest(StripeTestCase): with patch("corporate.lib.stripe.timezone_now", return_value=self.now): self.upgrade(invoice=True, licenses=100) - with patch("corporate.views.timezone_now", return_value=self.now): + with patch("corporate.views.billing_page.timezone_now", return_value=self.now): result = self.client_patch("/json/billing/plan", {"licenses": 100}) self.assert_json_error_contains( result, "Your plan is already on 100 licenses in the current billing period." ) - with patch("corporate.views.timezone_now", return_value=self.now): + with patch("corporate.views.billing_page.timezone_now", return_value=self.now): result = self.client_patch("/json/billing/plan", {"licenses_at_next_renewal": 100}) self.assert_json_error_contains( result, "Your plan is already scheduled to renew with 100 licenses." ) - with patch("corporate.views.timezone_now", return_value=self.now): + with patch("corporate.views.billing_page.timezone_now", return_value=self.now): result = self.client_patch("/json/billing/plan", {"licenses": 50}) self.assert_json_error_contains( result, "You cannot decrease the licenses in the current billing period." ) - with patch("corporate.views.timezone_now", return_value=self.now): + with patch("corporate.views.billing_page.timezone_now", return_value=self.now): result = self.client_patch("/json/billing/plan", {"licenses_at_next_renewal": 25}) self.assert_json_error_contains(result, "You must invoice for at least 30 users.") - with patch("corporate.views.timezone_now", return_value=self.now): + with patch("corporate.views.billing_page.timezone_now", return_value=self.now): result = self.client_patch("/json/billing/plan", {"licenses": 2000}) self.assert_json_error_contains( result, "Invoices with more than 1000 licenses can't be processed from this page." ) - with patch("corporate.views.timezone_now", return_value=self.now): + with patch("corporate.views.billing_page.timezone_now", return_value=self.now): result = self.client_patch("/json/billing/plan", {"licenses": 150}) self.assert_json_success(result) invoice_plans_as_needed(self.next_year) @@ -2477,7 +2479,7 @@ class StripeTest(StripeTestCase): for key, value in line_item_params.items(): self.assertEqual(extra_license_item.get(key), value) - with patch("corporate.views.timezone_now", return_value=self.next_year): + with patch("corporate.views.billing_page.timezone_now", return_value=self.next_year): result = self.client_patch("/json/billing/plan", {"licenses_at_next_renewal": 120}) self.assert_json_success(result) invoice_plans_as_needed(self.next_year + timedelta(days=365)) @@ -2520,11 +2522,11 @@ class StripeTest(StripeTestCase): with patch("corporate.lib.stripe.timezone_now", return_value=self.now): self.local_upgrade(self.seat_count, True, CustomerPlan.ANNUAL, "token") - with patch("corporate.views.timezone_now", return_value=self.now): + with patch("corporate.views.billing_page.timezone_now", return_value=self.now): result = self.client_patch("/json/billing/plan", {"licenses": 100}) self.assert_json_error_contains(result, "Your plan is on automatic license management.") - with patch("corporate.views.timezone_now", return_value=self.now): + with patch("corporate.views.billing_page.timezone_now", return_value=self.now): result = self.client_patch("/json/billing/plan", {"licenses_at_next_renewal": 100}) self.assert_json_error_contains(result, "Your plan is on automatic license management.") @@ -2544,7 +2546,7 @@ class StripeTest(StripeTestCase): self.local_upgrade(self.seat_count, True, CustomerPlan.ANNUAL, "token") self.login_user(self.example_user("hamlet")) - with patch("corporate.views.timezone_now", return_value=self.now): + with patch("corporate.views.billing_page.timezone_now", return_value=self.now): response = self.client_patch("/json/billing/plan", {}) self.assert_json_error_contains(response, "Nothing to change") @@ -2554,7 +2556,7 @@ class StripeTest(StripeTestCase): self.login_user(self.example_user("hamlet")) with self.assertLogs("corporate.stripe", "INFO") as m: - with patch("corporate.views.timezone_now", return_value=self.now): + with patch("corporate.views.billing_page.timezone_now", return_value=self.now): result = self.client_patch( "/json/billing/plan", {"status": CustomerPlan.DOWNGRADE_AT_END_OF_CYCLE} ) @@ -2576,7 +2578,7 @@ class StripeTest(StripeTestCase): self.login_user(self.example_user("hamlet")) with self.assertLogs("corporate.stripe", "INFO") as m: - with patch("corporate.views.timezone_now", return_value=self.now): + with patch("corporate.views.billing_page.timezone_now", return_value=self.now): result = self.client_patch( "/json/billing/plan", {"status": CustomerPlan.SWITCH_TO_ANNUAL_AT_END_OF_CYCLE} ) @@ -2921,14 +2923,14 @@ class RequiresBillingAccessTest(ZulipTestCase): def test_who_can_access_json_endpoints(self) -> None: # Billing admins have access self.login_user(self.example_user("hamlet")) - with patch("corporate.views.do_replace_payment_source") as mocked1: + with patch("corporate.views.billing_page.do_replace_payment_source") as mocked1: response = self.client_post("/json/billing/sources/change", {"stripe_token": "token"}) self.assert_json_success(response) mocked1.assert_called_once() # Realm owners have access, even if they are not billing admins self.login_user(self.example_user("desdemona")) - with patch("corporate.views.do_replace_payment_source") as mocked2: + with patch("corporate.views.billing_page.do_replace_payment_source") as mocked2: response = self.client_post("/json/billing/sources/change", {"stripe_token": "token"}) self.assert_json_success(response) mocked2.assert_called_once() diff --git a/corporate/urls.py b/corporate/urls.py index d700b51617..cd52ad888a 100644 --- a/corporate/urls.py +++ b/corporate/urls.py @@ -4,14 +4,8 @@ from django.conf.urls import include from django.urls import path from django.views.generic import TemplateView -from corporate.views import ( - billing_home, - initial_upgrade, - replace_payment_source, - sponsorship, - update_plan, - upgrade, -) +from corporate.views.billing_page import billing_home, replace_payment_source, update_plan +from corporate.views.upgrade import initial_upgrade, sponsorship, upgrade from zerver.lib.rest import rest_path i18n_urlpatterns: Any = [ diff --git a/corporate/views/__init__.py b/corporate/views/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/corporate/views.py b/corporate/views/billing_page.py similarity index 51% rename from corporate/views.py rename to corporate/views/billing_page.py index 485c698e4f..ebb5ea9847 100644 --- a/corporate/views.py +++ b/corporate/views/billing_page.py @@ -1,13 +1,8 @@ import logging -from decimal import Decimal from typing import Any, Dict, Optional, Union -from urllib.parse import urlencode, urljoin, urlunsplit import stripe -from django import forms from django.conf import settings -from django.core import signing -from django.db import transaction from django.http import HttpRequest, HttpResponse, HttpResponseRedirect from django.shortcuts import render from django.urls import reverse @@ -15,85 +10,35 @@ from django.utils.timezone import now as timezone_now from django.utils.translation import gettext as _ from corporate.lib.stripe import ( - DEFAULT_INVOICE_DAYS_UNTIL_DUE, - MIN_INVOICED_LICENSES, STRIPE_PUBLISHABLE_KEY, - BillingError, cents_to_dollar_string, do_change_plan_status, do_replace_payment_source, downgrade_at_the_end_of_billing_cycle, downgrade_now_without_creating_additional_invoices, get_latest_seat_count, - is_sponsored_realm, make_end_of_cycle_updates_if_needed, - process_initial_upgrade, renewal_amount, - sign_string, start_of_next_billing_cycle, stripe_get_customer, - unsign_string, update_license_ledger_for_manual_plan, - update_sponsorship_status, validate_licenses, ) from corporate.models import ( CustomerPlan, - ZulipSponsorshipRequest, get_current_plan_by_customer, get_current_plan_by_realm, get_customer_by_realm, ) -from zerver.decorator import ( - require_billing_access, - require_organization_member, - zulip_login_required, -) -from zerver.lib.actions import do_make_user_billing_admin +from zerver.decorator import require_billing_access, zulip_login_required from zerver.lib.exceptions import JsonableError from zerver.lib.request import REQ, has_request_variables from zerver.lib.response import json_success -from zerver.lib.send_email import FromAddress, send_email -from zerver.lib.validator import check_int, check_int_in, check_string_in -from zerver.models import Realm, UserProfile, get_org_type_display_name, get_realm +from zerver.lib.validator import check_int, check_int_in +from zerver.models import UserProfile billing_logger = logging.getLogger("corporate.stripe") -VALID_BILLING_MODALITY_VALUES = ["send_invoice", "charge_automatically"] -VALID_BILLING_SCHEDULE_VALUES = ["annual", "monthly"] -VALID_LICENSE_MANAGEMENT_VALUES = ["automatic", "manual"] - - -def unsign_seat_count(signed_seat_count: str, salt: str) -> int: - try: - return int(unsign_string(signed_seat_count, salt)) - except signing.BadSignature: - raise BillingError("tampered seat count") - - -def check_upgrade_parameters( - billing_modality: str, - schedule: str, - license_management: Optional[str], - licenses: Optional[int], - has_stripe_token: bool, - seat_count: int, -) -> None: - if billing_modality not in VALID_BILLING_MODALITY_VALUES: # nocoverage - raise BillingError("unknown billing_modality") - if schedule not in VALID_BILLING_SCHEDULE_VALUES: # nocoverage - raise BillingError("unknown schedule") - if license_management not in VALID_LICENSE_MANAGEMENT_VALUES: # nocoverage - raise BillingError("unknown license_management") - - charge_automatically = False - if billing_modality == "charge_automatically": - charge_automatically = True - if not has_stripe_token: - raise BillingError("autopay with no card") - - validate_licenses(charge_automatically, licenses, seat_count) - # Should only be called if the customer is being charged automatically def payment_method_string(stripe_customer: stripe.Customer) -> str: @@ -116,202 +61,6 @@ def payment_method_string(stripe_customer: stripe.Customer) -> str: ) # nocoverage -@require_organization_member -@has_request_variables -def upgrade( - request: HttpRequest, - user: UserProfile, - billing_modality: str = REQ(str_validator=check_string_in(VALID_BILLING_MODALITY_VALUES)), - schedule: str = REQ(str_validator=check_string_in(VALID_BILLING_SCHEDULE_VALUES)), - signed_seat_count: str = REQ(), - salt: str = REQ(), - license_management: Optional[str] = REQ( - default=None, str_validator=check_string_in(VALID_LICENSE_MANAGEMENT_VALUES) - ), - licenses: Optional[int] = REQ(json_validator=check_int, default=None), - stripe_token: Optional[str] = REQ(default=None), -) -> HttpResponse: - - try: - seat_count = unsign_seat_count(signed_seat_count, salt) - if billing_modality == "charge_automatically" and license_management == "automatic": - licenses = seat_count - if billing_modality == "send_invoice": - schedule = "annual" - license_management = "manual" - check_upgrade_parameters( - billing_modality, - schedule, - license_management, - licenses, - stripe_token is not None, - seat_count, - ) - assert licenses is not None - automanage_licenses = license_management == "automatic" - - billing_schedule = {"annual": CustomerPlan.ANNUAL, "monthly": CustomerPlan.MONTHLY}[ - schedule - ] - process_initial_upgrade(user, licenses, automanage_licenses, billing_schedule, stripe_token) - except BillingError as e: - if not settings.TEST_SUITE: # nocoverage - billing_logger.warning( - "BillingError during upgrade: %s. user=%s, realm=%s (%s), billing_modality=%s, " - "schedule=%s, license_management=%s, licenses=%s, has stripe_token: %s", - e.error_description, - user.id, - user.realm.id, - user.realm.string_id, - billing_modality, - schedule, - license_management, - licenses, - stripe_token is not None, - ) - raise - except Exception: - billing_logger.exception("Uncaught exception in billing:", stack_info=True) - error_message = BillingError.CONTACT_SUPPORT.format(email=settings.ZULIP_ADMINISTRATOR) - error_description = "uncaught exception during upgrade" - raise BillingError(error_description, error_message) - else: - return json_success() - - -@zulip_login_required -def initial_upgrade(request: HttpRequest) -> HttpResponse: - user = request.user - - if not settings.BILLING_ENABLED or user.is_guest: - return render(request, "404.html", status=404) - - billing_page_url = reverse(billing_home) - - customer = get_customer_by_realm(user.realm) - if customer is not None and ( - get_current_plan_by_customer(customer) is not None or customer.sponsorship_pending - ): - if request.GET.get("onboarding") is not None: - billing_page_url = f"{billing_page_url}?onboarding=true" - return HttpResponseRedirect(billing_page_url) - - if is_sponsored_realm(user.realm): - return HttpResponseRedirect(billing_page_url) - - percent_off = Decimal(0) - if customer is not None and customer.default_discount is not None: - percent_off = customer.default_discount - - 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, - "signed_seat_count": signed_seat_count, - "salt": salt, - "min_invoiced_licenses": max(seat_count, MIN_INVOICED_LICENSES), - "default_invoice_days_until_due": DEFAULT_INVOICE_DAYS_UNTIL_DUE, - "plan": "Zulip Standard", - "free_trial_days": settings.FREE_TRIAL_DAYS, - "onboarding": request.GET.get("onboarding") is not None, - "page_params": { - "seat_count": seat_count, - "annual_price": 8000, - "monthly_price": 800, - "percent_off": float(percent_off), - }, - "realm_org_type": user.realm.org_type, - "sorted_org_types": sorted( - [ - [org_type_name, org_type] - for (org_type_name, org_type) in Realm.ORG_TYPES.items() - if not org_type.get("hidden") - ], - key=lambda d: d[1]["display_order"], - ), - } - response = render(request, "corporate/upgrade.html", context=context) - return response - - -class SponsorshipRequestForm(forms.Form): - website = forms.URLField(max_length=ZulipSponsorshipRequest.MAX_ORG_URL_LENGTH) - organization_type = forms.IntegerField() - description = forms.CharField(widget=forms.Textarea) - - -@require_organization_member -@has_request_variables -def sponsorship( - request: HttpRequest, - user: UserProfile, - organization_type: str = REQ("organization-type"), - website: str = REQ(), - description: str = REQ(), -) -> HttpResponse: - realm = user.realm - - requested_by = user.full_name - user_role = user.get_role_name() - - support_realm_uri = get_realm(settings.STAFF_SUBDOMAIN).uri - support_url = urljoin( - support_realm_uri, - urlunsplit(("", "", reverse("support"), urlencode({"q": realm.string_id}), "")), - ) - - post_data = request.POST.copy() - # We need to do this because the field name in the template - # for organization type contains a hyphen and the form expects - # an underscore. - post_data.update(organization_type=organization_type) - form = SponsorshipRequestForm(post_data) - - with transaction.atomic(): - if form.is_valid(): - sponsorship_request = ZulipSponsorshipRequest( - realm=realm, - requested_by=user, - org_website=form.cleaned_data["website"], - org_description=form.cleaned_data["description"], - org_type=form.cleaned_data["organization_type"], - ) - sponsorship_request.save() - - org_type = form.cleaned_data["organization_type"] - if realm.org_type != org_type: - realm.org_type = org_type - realm.save(update_fields=["org_type"]) - - update_sponsorship_status(realm, True, acting_user=user) - do_make_user_billing_admin(user) - - org_type_display_name = get_org_type_display_name(org_type) - - context = { - "requested_by": requested_by, - "user_role": user_role, - "string_id": realm.string_id, - "support_url": support_url, - "organization_type": org_type_display_name, - "website": website, - "description": description, - } - send_email( - "zerver/emails/sponsorship_request", - to_emails=[FromAddress.SUPPORT], - from_name="Zulip sponsorship", - from_address=FromAddress.tokenized_no_reply_address(), - reply_to_email=user.delivery_email, - context=context, - ) - - return json_success() - - @zulip_login_required def billing_home(request: HttpRequest) -> HttpResponse: user = request.user @@ -326,6 +75,8 @@ def billing_home(request: HttpRequest) -> HttpResponse: return render(request, "corporate/billing.html", context=context) if customer is None: + from corporate.views.upgrade import initial_upgrade + return HttpResponseRedirect(reverse(initial_upgrade)) if customer.sponsorship_pending: @@ -333,6 +84,8 @@ def billing_home(request: HttpRequest) -> HttpResponse: return render(request, "corporate/billing.html", context=context) if not CustomerPlan.objects.filter(customer=customer).exists(): + from corporate.views.upgrade import initial_upgrade + return HttpResponseRedirect(reverse(initial_upgrade)) if not user.has_billing_access: diff --git a/corporate/views/upgrade.py b/corporate/views/upgrade.py new file mode 100644 index 0000000000..a2f7f7e2b0 --- /dev/null +++ b/corporate/views/upgrade.py @@ -0,0 +1,273 @@ +import logging +from decimal import Decimal +from typing import Any, Dict, Optional +from urllib.parse import urlencode, urljoin, urlunsplit + +from django import forms +from django.conf import settings +from django.core import signing +from django.db import transaction +from django.http import HttpRequest, HttpResponse, HttpResponseRedirect +from django.shortcuts import render +from django.urls import reverse + +from corporate.lib.stripe import ( + DEFAULT_INVOICE_DAYS_UNTIL_DUE, + MIN_INVOICED_LICENSES, + STRIPE_PUBLISHABLE_KEY, + BillingError, + get_latest_seat_count, + is_sponsored_realm, + process_initial_upgrade, + sign_string, + unsign_string, + update_sponsorship_status, + validate_licenses, +) +from corporate.models import ( + CustomerPlan, + ZulipSponsorshipRequest, + get_current_plan_by_customer, + get_customer_by_realm, +) +from corporate.views.billing_page import billing_home +from zerver.decorator import require_organization_member, zulip_login_required +from zerver.lib.actions import do_make_user_billing_admin +from zerver.lib.request import REQ, has_request_variables +from zerver.lib.response import json_success +from zerver.lib.send_email import FromAddress, send_email +from zerver.lib.validator import check_int, check_string_in +from zerver.models import Realm, UserProfile, get_org_type_display_name, get_realm + +billing_logger = logging.getLogger("corporate.stripe") + +VALID_BILLING_MODALITY_VALUES = ["send_invoice", "charge_automatically"] +VALID_BILLING_SCHEDULE_VALUES = ["annual", "monthly"] +VALID_LICENSE_MANAGEMENT_VALUES = ["automatic", "manual"] + + +def unsign_seat_count(signed_seat_count: str, salt: str) -> int: + try: + return int(unsign_string(signed_seat_count, salt)) + except signing.BadSignature: + raise BillingError("tampered seat count") + + +def check_upgrade_parameters( + billing_modality: str, + schedule: str, + license_management: Optional[str], + licenses: Optional[int], + has_stripe_token: bool, + seat_count: int, +) -> None: + if billing_modality not in VALID_BILLING_MODALITY_VALUES: # nocoverage + raise BillingError("unknown billing_modality") + if schedule not in VALID_BILLING_SCHEDULE_VALUES: # nocoverage + raise BillingError("unknown schedule") + if license_management not in VALID_LICENSE_MANAGEMENT_VALUES: # nocoverage + raise BillingError("unknown license_management") + + charge_automatically = False + if billing_modality == "charge_automatically": + charge_automatically = True + if not has_stripe_token: + raise BillingError("autopay with no card") + + validate_licenses(charge_automatically, licenses, seat_count) + + +@require_organization_member +@has_request_variables +def upgrade( + request: HttpRequest, + user: UserProfile, + billing_modality: str = REQ(str_validator=check_string_in(VALID_BILLING_MODALITY_VALUES)), + schedule: str = REQ(str_validator=check_string_in(VALID_BILLING_SCHEDULE_VALUES)), + signed_seat_count: str = REQ(), + salt: str = REQ(), + license_management: Optional[str] = REQ( + default=None, str_validator=check_string_in(VALID_LICENSE_MANAGEMENT_VALUES) + ), + licenses: Optional[int] = REQ(json_validator=check_int, default=None), + stripe_token: Optional[str] = REQ(default=None), +) -> HttpResponse: + + try: + seat_count = unsign_seat_count(signed_seat_count, salt) + if billing_modality == "charge_automatically" and license_management == "automatic": + licenses = seat_count + if billing_modality == "send_invoice": + schedule = "annual" + license_management = "manual" + check_upgrade_parameters( + billing_modality, + schedule, + license_management, + licenses, + stripe_token is not None, + seat_count, + ) + assert licenses is not None + automanage_licenses = license_management == "automatic" + + billing_schedule = {"annual": CustomerPlan.ANNUAL, "monthly": CustomerPlan.MONTHLY}[ + schedule + ] + process_initial_upgrade(user, licenses, automanage_licenses, billing_schedule, stripe_token) + except BillingError as e: + if not settings.TEST_SUITE: # nocoverage + billing_logger.warning( + "BillingError during upgrade: %s. user=%s, realm=%s (%s), billing_modality=%s, " + "schedule=%s, license_management=%s, licenses=%s, has stripe_token: %s", + e.error_description, + user.id, + user.realm.id, + user.realm.string_id, + billing_modality, + schedule, + license_management, + licenses, + stripe_token is not None, + ) + raise + except Exception: + billing_logger.exception("Uncaught exception in billing:", stack_info=True) + error_message = BillingError.CONTACT_SUPPORT.format(email=settings.ZULIP_ADMINISTRATOR) + error_description = "uncaught exception during upgrade" + raise BillingError(error_description, error_message) + else: + return json_success() + + +@zulip_login_required +def initial_upgrade(request: HttpRequest) -> HttpResponse: + user = request.user + + if not settings.BILLING_ENABLED or user.is_guest: + return render(request, "404.html", status=404) + + billing_page_url = reverse(billing_home) + + customer = get_customer_by_realm(user.realm) + if customer is not None and ( + get_current_plan_by_customer(customer) is not None or customer.sponsorship_pending + ): + if request.GET.get("onboarding") is not None: + billing_page_url = f"{billing_page_url}?onboarding=true" + return HttpResponseRedirect(billing_page_url) + + if is_sponsored_realm(user.realm): + return HttpResponseRedirect(billing_page_url) + + percent_off = Decimal(0) + if customer is not None and customer.default_discount is not None: + percent_off = customer.default_discount + + 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, + "signed_seat_count": signed_seat_count, + "salt": salt, + "min_invoiced_licenses": max(seat_count, MIN_INVOICED_LICENSES), + "default_invoice_days_until_due": DEFAULT_INVOICE_DAYS_UNTIL_DUE, + "plan": "Zulip Standard", + "free_trial_days": settings.FREE_TRIAL_DAYS, + "onboarding": request.GET.get("onboarding") is not None, + "page_params": { + "seat_count": seat_count, + "annual_price": 8000, + "monthly_price": 800, + "percent_off": float(percent_off), + }, + "realm_org_type": user.realm.org_type, + "sorted_org_types": sorted( + [ + [org_type_name, org_type] + for (org_type_name, org_type) in Realm.ORG_TYPES.items() + if not org_type.get("hidden") + ], + key=lambda d: d[1]["display_order"], + ), + } + response = render(request, "corporate/upgrade.html", context=context) + return response + + +class SponsorshipRequestForm(forms.Form): + website = forms.URLField(max_length=ZulipSponsorshipRequest.MAX_ORG_URL_LENGTH) + organization_type = forms.IntegerField() + description = forms.CharField(widget=forms.Textarea) + + +@require_organization_member +@has_request_variables +def sponsorship( + request: HttpRequest, + user: UserProfile, + organization_type: str = REQ("organization-type"), + website: str = REQ(), + description: str = REQ(), +) -> HttpResponse: + realm = user.realm + + requested_by = user.full_name + user_role = user.get_role_name() + + support_realm_uri = get_realm(settings.STAFF_SUBDOMAIN).uri + support_url = urljoin( + support_realm_uri, + urlunsplit(("", "", reverse("support"), urlencode({"q": realm.string_id}), "")), + ) + + post_data = request.POST.copy() + # We need to do this because the field name in the template + # for organization type contains a hyphen and the form expects + # an underscore. + post_data.update(organization_type=organization_type) + form = SponsorshipRequestForm(post_data) + + with transaction.atomic(): + if form.is_valid(): + sponsorship_request = ZulipSponsorshipRequest( + realm=realm, + requested_by=user, + org_website=form.cleaned_data["website"], + org_description=form.cleaned_data["description"], + org_type=form.cleaned_data["organization_type"], + ) + sponsorship_request.save() + + org_type = form.cleaned_data["organization_type"] + if realm.org_type != org_type: + realm.org_type = org_type + realm.save(update_fields=["org_type"]) + + update_sponsorship_status(realm, True, acting_user=user) + do_make_user_billing_admin(user) + + org_type_display_name = get_org_type_display_name(org_type) + + context = { + "requested_by": requested_by, + "user_role": user_role, + "string_id": realm.string_id, + "support_url": support_url, + "organization_type": org_type_display_name, + "website": website, + "description": description, + } + send_email( + "zerver/emails/sponsorship_request", + to_emails=[FromAddress.SUPPORT], + from_name="Zulip sponsorship", + from_address=FromAddress.tokenized_no_reply_address(), + reply_to_email=user.delivery_email, + context=context, + ) + + return json_success()