From 8d9a7679bc8ba2e8ffce4f9d5999dbfb61ce4cbf Mon Sep 17 00:00:00 2001 From: Aman Agrawal Date: Tue, 5 Dec 2023 06:42:52 +0000 Subject: [PATCH] plans: Show buttons as per current context. Also show correct tab based on remote / cloud user. --- corporate/tests/test_remote_billing.py | 14 +-- corporate/urls.py | 4 +- corporate/views/portico.py | 147 ++++++++++++++++++++++--- corporate/views/remote_billing_page.py | 28 +---- templates/corporate/plans.html | 2 +- templates/corporate/pricing_model.html | 93 ++++++++++++++-- web/src/portico/landing-page.js | 12 +- web/styles/portico/pricing_plans.css | 4 + zerver/tests/test_docs.py | 25 ++--- 9 files changed, 248 insertions(+), 81 deletions(-) diff --git a/corporate/tests/test_remote_billing.py b/corporate/tests/test_remote_billing.py index 8bb37ddef8..bb70bc4a3b 100644 --- a/corporate/tests/test_remote_billing.py +++ b/corporate/tests/test_remote_billing.py @@ -106,8 +106,7 @@ class RemoteBillingAuthenticationTest(BouncerTestCase): # Go to the URL we're redirected to after authentication and assert # some basic expected content. result = self.client_get(result["Location"], subdomain="selfhosting") - self.assert_in_success_response(["Your remote user info:"], result) - self.assert_in_success_response([desdemona.delivery_email], result) + self.assert_in_success_response(["showing-self-hosted", "Retain full control"], result) @responses.activate def test_remote_billing_authentication_flow_realm_not_registered(self) -> None: @@ -143,8 +142,7 @@ class RemoteBillingAuthenticationTest(BouncerTestCase): self.assertEqual(result["Location"], f"/realm/{realm.uuid!s}/plans/") result = self.client_get(result["Location"], subdomain="selfhosting") - self.assert_in_success_response(["Your remote user info:"], result) - self.assert_in_success_response([desdemona.delivery_email], result) + self.assert_in_success_response(["showing-self-hosted", "Retain full control"], result) @responses.activate def test_remote_billing_authentication_flow_tos_consent_failure(self) -> None: @@ -227,8 +225,7 @@ class RemoteBillingAuthenticationTest(BouncerTestCase): tick=False, ): result = self.client_get(final_url, subdomain="selfhosting") - self.assert_in_success_response(["Your remote user info:"], result) - self.assert_in_success_response([desdemona.delivery_email], result) + self.assert_in_success_response(["showing-self-hosted", "Retain full control"], result) # Now go there again, simulating doing this after the session has expired. # We should be denied access and redirected to re-auth. @@ -257,8 +254,7 @@ class RemoteBillingAuthenticationTest(BouncerTestCase): ) self.assertEqual(result["Location"], f"/realm/{realm.uuid!s}/plans/") result = self.client_get(result["Location"], subdomain="selfhosting") - self.assert_in_success_response(["Your remote user info:"], result) - self.assert_in_success_response([desdemona.delivery_email], result) + self.assert_in_success_response(["showing-self-hosted", "Retain full control"], result) @responses.activate def test_remote_billing_unauthed_access(self) -> None: @@ -408,7 +404,7 @@ class LegacyServerLoginTest(BouncerTestCase): ) self.assertEqual(result.status_code, 302) - self.assertEqual(result["Location"], f"/server/{self.uuid}/upgrade/") + self.assertEqual(result["Location"], f"/server/{self.uuid}/plans/") # Verify the authed data that should have been stored in the session. identity_dict = LegacyServerIdentityDict( diff --git a/corporate/urls.py b/corporate/urls.py index 9edf182634..80bf939ddf 100644 --- a/corporate/urls.py +++ b/corporate/urls.py @@ -27,13 +27,13 @@ from corporate.views.portico import ( hello_view, landing_view, plans_view, + remote_realm_plans_page, + remote_server_plans_page, team_view, ) from corporate.views.remote_billing_page import ( remote_billing_legacy_server_login, remote_realm_billing_finalize_login, - remote_realm_plans_page, - remote_server_plans_page, ) from corporate.views.session import ( start_card_update_stripe_session, diff --git a/corporate/views/portico.py b/corporate/views/portico.py index b1c03e9b57..0cbf13e5b9 100644 --- a/corporate/views/portico.py +++ b/corporate/views/portico.py @@ -1,3 +1,4 @@ +from dataclasses import asdict, dataclass from typing import Optional from urllib.parse import urlencode @@ -6,9 +7,14 @@ from django.conf import settings from django.contrib.auth.views import redirect_to_login from django.http import HttpRequest, HttpResponse, HttpResponseRedirect from django.template.response import TemplateResponse +from django.urls import reverse -from corporate.lib.stripe import is_realm_on_free_trial -from corporate.models import get_customer_by_realm +from corporate.lib.decorator import ( + authenticated_remote_realm_management_endpoint, + authenticated_remote_server_management_endpoint, +) +from corporate.lib.stripe import RemoteRealmBillingSession, RemoteServerBillingSession +from corporate.models import CustomerPlan, get_current_plan_by_customer, get_customer_by_realm from zerver.context_processors import get_realm_from_request, latest_info_context from zerver.decorator import add_google_analytics from zerver.lib.github import ( @@ -47,16 +53,43 @@ def app_download_link_redirect(request: HttpRequest, platform: str) -> HttpRespo return TemplateResponse(request, "404.html", status=404) +def is_customer_on_free_trial(customer_plan: CustomerPlan) -> bool: + return customer_plan.status in ( + CustomerPlan.FREE_TRIAL, + CustomerPlan.DOWNGRADE_AT_END_OF_FREE_TRIAL, + ) + + +@dataclass +class PlansPageContext: + sponsorship_url: str + free_trial_days: Optional[int] + on_free_trial: bool = False + sponsorship_pending: bool = False + is_sponsored: bool = False + + is_cloud_realm: bool = False + is_self_hosted_realm: bool = False + + is_new_customer: bool = False + on_free_tier: bool = False + customer_plan: Optional[CustomerPlan] = None + + billing_base_url: str = "" + + @add_google_analytics def plans_view(request: HttpRequest) -> HttpResponse: realm = get_realm_from_request(request) - free_trial_days = settings.FREE_TRIAL_DAYS - sponsorship_pending = False - sponsorship_url = "/sponsorship/" + context = PlansPageContext( + is_cloud_realm=True, + sponsorship_url=reverse("sponsorship_request"), + free_trial_days=settings.FREE_TRIAL_DAYS, + is_sponsored=realm is not None and realm.plan_type == Realm.PLAN_TYPE_STANDARD_FREE, + ) if is_subdomain_root_or_alias(request): # If we're on the root domain, we make this link first ask you which organization. - sponsorship_url = f"/accounts/go/?{urlencode({'next': sponsorship_url})}" - realm_on_free_trial = False + context.sponsorship_url = f"/accounts/go/?{urlencode({'next': context.sponsorship_url})}" if realm is not None: if realm.plan_type == Realm.PLAN_TYPE_SELF_HOSTED and settings.PRODUCTION: @@ -65,21 +98,99 @@ def plans_view(request: HttpRequest) -> HttpResponse: return redirect_to_login(next="/plans/") if request.user.is_guest: return TemplateResponse(request, "404.html", status=404) - customer = get_customer_by_realm(realm) - if customer is not None: - sponsorship_pending = customer.sponsorship_pending - realm_on_free_trial = is_realm_on_free_trial(realm) + customer = get_customer_by_realm(realm) + context.on_free_tier = customer is None and not context.is_sponsored + if customer is not None: + context.sponsorship_pending = customer.sponsorship_pending + context.customer_plan = get_current_plan_by_customer(customer) + if context.customer_plan is None: + # Cloud realms on free tier don't have active customer plan unless they are sponsored. + context.on_free_tier = not context.is_sponsored + else: + context.on_free_trial = is_customer_on_free_trial(context.customer_plan) + + context.is_new_customer = ( + not context.on_free_tier and context.customer_plan is None and not context.is_sponsored + ) return TemplateResponse( request, "corporate/plans.html", - context={ - "realm": realm, - "free_trial_days": free_trial_days, - "realm_on_free_trial": realm_on_free_trial, - "sponsorship_pending": sponsorship_pending, - "sponsorship_url": sponsorship_url, - }, + context=asdict(context), + ) + + +@add_google_analytics +@authenticated_remote_realm_management_endpoint +def remote_realm_plans_page( + request: HttpRequest, billing_session: RemoteRealmBillingSession +) -> HttpResponse: # nocoverage + customer = billing_session.get_customer() + context = PlansPageContext( + is_self_hosted_realm=True, + sponsorship_url=reverse( + "remote_realm_sponsorship_page", args=(billing_session.remote_realm.uuid,) + ), + free_trial_days=settings.FREE_TRIAL_DAYS, + billing_base_url=billing_session.billing_base_url, + is_sponsored=billing_session.is_sponsored(), + ) + + context.on_free_tier = customer is None and not context.is_sponsored + if customer is not None: + context.sponsorship_pending = customer.sponsorship_pending + context.customer_plan = get_current_plan_by_customer(customer) + if context.customer_plan is None: + context.on_free_tier = not context.is_sponsored + else: + context.on_free_trial = is_customer_on_free_trial(context.customer_plan) + + context.is_new_customer = ( + not context.on_free_tier and context.customer_plan is None and not context.is_sponsored + ) + return TemplateResponse( + request, + "corporate/plans.html", + context=asdict(context), + ) + + +@add_google_analytics +@authenticated_remote_server_management_endpoint +def remote_server_plans_page( + request: HttpRequest, billing_session: RemoteServerBillingSession +) -> HttpResponse: # nocoverage + customer = billing_session.get_customer() + context = PlansPageContext( + is_self_hosted_realm=True, + sponsorship_url=reverse( + "remote_server_sponsorship_page", args=(billing_session.remote_server.uuid,) + ), + free_trial_days=settings.FREE_TRIAL_DAYS, + billing_base_url=billing_session.billing_base_url, + is_sponsored=billing_session.is_sponsored(), + ) + + context.on_free_tier = customer is None and not context.is_sponsored + if customer is not None: + context.sponsorship_pending = customer.sponsorship_pending + context.customer_plan = get_current_plan_by_customer(customer) + if context.customer_plan is None: + context.on_free_tier = not context.is_sponsored + else: + context.on_free_tier = context.customer_plan.tier in ( + CustomerPlan.TIER_SELF_HOSTED_LEGACY, + CustomerPlan.TIER_SELF_HOSTED_BASE, + ) + context.on_free_trial = is_customer_on_free_trial(context.customer_plan) + + context.is_new_customer = ( + not context.on_free_tier and context.customer_plan is None and not context.is_sponsored + ) + return TemplateResponse( + request, + "corporate/plans.html", + context=asdict(context), ) diff --git a/corporate/views/remote_billing_page.py b/corporate/views/remote_billing_page.py index c8e550a0f2..1063488e40 100644 --- a/corporate/views/remote_billing_page.py +++ b/corporate/views/remote_billing_page.py @@ -13,11 +13,7 @@ from django.utils.translation import gettext as _ from django.views.decorators.csrf import csrf_exempt from pydantic import Json -from corporate.lib.decorator import ( - authenticated_remote_realm_management_endpoint, - authenticated_remote_server_management_endpoint, - self_hosting_management_endpoint, -) +from corporate.lib.decorator import self_hosting_management_endpoint from corporate.lib.remote_billing_util import ( REMOTE_BILLING_SESSION_VALIDITY_SECONDS, LegacyServerIdentityDict, @@ -25,7 +21,6 @@ from corporate.lib.remote_billing_util import ( RemoteBillingUserDict, get_identity_dict_from_session, ) -from corporate.lib.stripe import RemoteRealmBillingSession, RemoteServerBillingSession from zerver.lib.exceptions import JsonableError, MissingRemoteRealmError from zerver.lib.remote_server import RealmDataForAnalytics, UserDataForRemoteBilling from zerver.lib.response import json_success @@ -293,22 +288,6 @@ def remote_billing_plans_common( return render_tmp_remote_billing_page(request, realm_uuid=realm_uuid, server_uuid=server_uuid) -@authenticated_remote_realm_management_endpoint -def remote_realm_plans_page( - request: HttpRequest, billing_session: RemoteRealmBillingSession -) -> HttpResponse: - realm_uuid = str(billing_session.remote_realm.uuid) - return remote_billing_plans_common(request, realm_uuid=realm_uuid, server_uuid=None) - - -@authenticated_remote_server_management_endpoint -def remote_server_plans_page( - request: HttpRequest, billing_session: RemoteServerBillingSession -) -> HttpResponse: - server_uuid = str(billing_session.remote_server.uuid) - return remote_billing_plans_common(request, server_uuid=server_uuid, realm_uuid=None) - - def remote_billing_page_common( request: HttpRequest, realm_uuid: Optional[str], server_uuid: Optional[str] ) -> HttpResponse: @@ -369,10 +348,7 @@ def remote_billing_legacy_server_login( reverse(f"remote_server_{next_page}_page", args=(remote_server_uuid,)) ) elif remote_server.plan_type == RemoteZulipServer.PLAN_TYPE_SELF_HOSTED: - # TODO: Take user to plans page once that is available. - return HttpResponseRedirect( - reverse("remote_server_upgrade_page", args=(remote_server_uuid,)) - ) + return HttpResponseRedirect(reverse("remote_server_plans_page", args=(remote_server_uuid,))) elif remote_server.plan_type == RemoteZulipServer.PLAN_TYPE_COMMUNITY: return HttpResponseRedirect( reverse("remote_server_sponsorship_page", args=(remote_server_uuid,)) diff --git a/templates/corporate/plans.html b/templates/corporate/plans.html index 64b1838da8..0cba67e7c2 100644 --- a/templates/corporate/plans.html +++ b/templates/corporate/plans.html @@ -13,7 +13,7 @@ {% include 'zerver/landing_nav.html' %} -
+
{% include "corporate/pricing_model.html" %}
diff --git a/templates/corporate/pricing_model.html b/templates/corporate/pricing_model.html index 55830dca7e..86de684bfc 100644 --- a/templates/corporate/pricing_model.html +++ b/templates/corporate/pricing_model.html @@ -33,15 +33,15 @@
- {% if not realm or realm.plan_type == realm.PLAN_TYPE_SELF_HOSTED %} - - Create organization - - {% elif realm.plan_type == realm.PLAN_TYPE_LIMITED or sponsorship_pending %} + {% if is_cloud_realm and on_free_tier %}
Current plan + {% elif not is_cloud_realm or is_new_customer %} + + Create organization + {% endif %}
@@ -69,7 +69,7 @@

- {% if not realm %} + {% if is_cloud_realm and on_free_tier and not sponsorship_pending %} {% if free_trial_days %} Start {{ free_trial_days }}-day free trial @@ -77,16 +77,17 @@ Upgrade to Standard {% endif %} - {% elif realm.plan_type in [realm.PLAN_TYPE_STANDARD, realm.PLAN_TYPE_STANDARD_FREE] %} + + {% elif (is_cloud_realm and is_sponsored) or (customer_plan and customer_plan.tier == customer_plan.TIER_CLOUD_STANDARD) %} - {% if realm_on_free_trial %} + {% if on_free_trial %} Current plan (free trial) {% else %} Current plan {% endif %} - {% elif sponsorship_pending %} + {% elif is_cloud_realm and sponsorship_pending %} Sponsorship pending @@ -158,13 +159,80 @@
+ {% if is_self_hosted_realm and on_free_tier %} + + + Current plan + + {% elif not is_self_hosted_realm %} Self-host Zulip + {% endif %}
+ {% if development_environment %} +
+
+

Business

+
    +
  • All Free features included
  • +
  • Professional support with SLAs
  • +
  • High availability
  • +
  • Incident collaboration
  • +
  • Advanced compliance
  • +
  • Funds the Zulip open source project
  • +
+
+
+
+ {% if is_self_hosted_realm and on_free_tier and not sponsorship_pending %} + + Request sponsorship + + + {% if free_trial_days %} + Start {{ free_trial_days }}-day free trial + {% else %} + Upgrade to Business + {% endif %} + + {% elif is_self_hosted_realm and (is_sponsored or (customer_plan and customer_plan.tier == customer_plan.TIER_SELF_HOSTED_BUSINESS)) %} + + + {% if on_free_trial %} + Current plan (free trial) + {% else %} + Current plan + {% endif %} + + {% elif is_self_hosted_realm and sponsorship_pending %} + + Sponsorship pending + + {% elif is_self_hosted_realm %} + + Request sponsorship + + + {% if free_trial_days %} + Start {{ free_trial_days }}-day free trial + {% else %} + Upgrade to Business + {% endif %} + + {% else %} + + Contact sales + + {% endif %} +
+
+
+ {% endif %} +

Enterprise

@@ -179,9 +247,16 @@
+ {% if is_self_hosted_realm and customer_plan and customer_plan.tier == customer_plan.TIER_SELF_HOSTED_ENTERPRISE %} + + + Current plan + + {% else %} Contact sales + {% endif %}
diff --git a/web/src/portico/landing-page.js b/web/src/portico/landing-page.js index bf516b51c6..67ecc0b174 100644 --- a/web/src/portico/landing-page.js +++ b/web/src/portico/landing-page.js @@ -136,12 +136,18 @@ $(() => { render_tabs(contributors); } - if (window.location.pathname === "/plans/" && window.location.hash === "#self-hosted") { + if (window.location.pathname.endsWith("/plans/")) { + const tabs = ["#cloud", "#self-hosted"]; + if (!tabs.includes(window.location.hash)) { + return; + } + const tab_to_show = window.location.hash; + // Don't scroll to the target element window.scroll({top: 0}); const $pricing_wrapper = $(".portico-pricing"); - $pricing_wrapper.removeClass("showing-cloud"); - $pricing_wrapper.addClass("showing-self-hosted"); + $pricing_wrapper.removeClass("showing-cloud showing-self-hosted"); + $pricing_wrapper.addClass(`showing-${tab_to_show.slice(1)}`); } }); diff --git a/web/styles/portico/pricing_plans.css b/web/styles/portico/pricing_plans.css index 76c6c30bdf..741747659e 100644 --- a/web/styles/portico/pricing_plans.css +++ b/web/styles/portico/pricing_plans.css @@ -145,6 +145,10 @@ } } + .request-sponsorship { + margin-bottom: 10px; + } + .pricing-pane-scroll-container { grid-area: pricing; overflow-x: auto; diff --git a/zerver/tests/test_docs.py b/zerver/tests/test_docs.py index e67e788cdc..187da5a5a3 100644 --- a/zerver/tests/test_docs.py +++ b/zerver/tests/test_docs.py @@ -562,11 +562,6 @@ class PlansPageTest(ZulipTestCase): self.assertEqual(result.status_code, 302) self.assertEqual(result["Location"], "https://zulip.com/plans/") - # But in the development environment, it renders a page - result = self.client_get("/plans/", subdomain="zulip") - self.assert_in_success_response([sign_up_now, upgrade_to_standard], result) - self.assert_not_in_success_response([current_plan, sponsorship_pending], result) - realm.plan_type = Realm.PLAN_TYPE_LIMITED realm.save(update_fields=["plan_type"]) result = self.client_get("/plans/", subdomain="zulip") @@ -580,6 +575,8 @@ class PlansPageTest(ZulipTestCase): [sign_up_now, sponsorship_pending, upgrade_to_standard], result ) + # Sponsored realms always have Customer entry. + customer = Customer.objects.create(realm=get_realm("zulip"), stripe_customer_id="cus_id") realm.plan_type = Realm.PLAN_TYPE_STANDARD_FREE realm.save(update_fields=["plan_type"]) result = self.client_get("/plans/", subdomain="zulip") @@ -588,6 +585,14 @@ class PlansPageTest(ZulipTestCase): [sign_up_now, upgrade_to_standard, sponsorship_pending], result ) + plan = CustomerPlan.objects.create( + customer=customer, + tier=CustomerPlan.TIER_CLOUD_STANDARD, + status=CustomerPlan.ACTIVE, + billing_cycle_anchor=timezone_now(), + billing_schedule=CustomerPlan.BILLING_SCHEDULE_MONTHLY, + ) + realm.plan_type = Realm.PLAN_TYPE_STANDARD realm.save(update_fields=["plan_type"]) result = self.client_get("/plans/", subdomain="zulip") @@ -596,14 +601,8 @@ class PlansPageTest(ZulipTestCase): [sign_up_now, upgrade_to_standard, sponsorship_pending], result ) - customer = Customer.objects.create(realm=get_realm("zulip"), stripe_customer_id="cus_id") - plan = CustomerPlan.objects.create( - customer=customer, - tier=CustomerPlan.TIER_CLOUD_STANDARD, - status=CustomerPlan.FREE_TRIAL, - billing_cycle_anchor=timezone_now(), - billing_schedule=CustomerPlan.BILLING_SCHEDULE_MONTHLY, - ) + plan.status = CustomerPlan.FREE_TRIAL + plan.save(update_fields=["status"]) result = self.client_get("/plans/", subdomain="zulip") self.assert_in_success_response(["Current plan (free trial)"], result) self.assert_not_in_success_response(