From a01618d633c72ee61baea64b64ff9d69e93938c0 Mon Sep 17 00:00:00 2001 From: Tim Abbott Date: Wed, 29 Nov 2023 16:48:46 -0800 Subject: [PATCH] billing: Add BillingSession support for requesting sponsorship. --- corporate/lib/stripe.py | 233 ++++++++++++++++++++++++++++++-- corporate/tests/test_stripe.py | 2 +- corporate/urls.py | 39 ++++-- corporate/views/billing_page.py | 13 -- corporate/views/sponsorship.py | 93 +++++++++++++ corporate/views/upgrade.py | 90 +----------- web/src/billing/sponsorship.ts | 1 + 7 files changed, 352 insertions(+), 119 deletions(-) create mode 100644 corporate/views/sponsorship.py diff --git a/corporate/lib/stripe.py b/corporate/lib/stripe.py index 0e7b833b1f..e329addc9e 100644 --- a/corporate/lib/stripe.py +++ b/corporate/lib/stripe.py @@ -11,6 +11,7 @@ from functools import wraps from typing import Any, Callable, Dict, Generator, Optional, Tuple, TypedDict, TypeVar, Union import stripe +from django import forms from django.conf import settings from django.core import signing from django.core.signing import Signer @@ -28,6 +29,7 @@ from corporate.models import ( LicenseLedger, PaymentIntent, Session, + ZulipSponsorshipRequest, get_current_plan_by_customer, get_current_plan_by_realm, get_customer_by_realm, @@ -36,10 +38,20 @@ from corporate.models import ( ) from zerver.lib.exceptions import JsonableError from zerver.lib.logging_util import log_to_file -from zerver.lib.send_email import FromAddress, send_email_to_billing_admins_and_realm_owners +from zerver.lib.send_email import ( + FromAddress, + send_email, + send_email_to_billing_admins_and_realm_owners, +) from zerver.lib.timestamp import datetime_to_timestamp, timestamp_to_datetime from zerver.lib.utils import assert_is_not_none -from zerver.models import Realm, RealmAuditLog, UserProfile, get_system_bot +from zerver.models import ( + Realm, + RealmAuditLog, + UserProfile, + get_org_type_display_name, + get_system_bot, +) from zilencer.models import ( RemoteRealm, RemoteRealmAuditLog, @@ -533,6 +545,20 @@ class UpgradePageSessionTypeSpecificContext(TypedDict): is_self_hosting: bool +class SponsorshipApplicantInfo(TypedDict): + name: str + role: str + email: str + + +class SponsorshipRequestSessionSpecificContext(TypedDict): + # We don't store UserProfile for remote realms. + realm_user: Optional[UserProfile] + user_info: SponsorshipApplicantInfo + # TODO: Call this what we end up calling it for /support page. + realm_string_id: str + + class UpgradePageContext(TypedDict): customer_name: str default_invoice_days_until_due: int @@ -552,12 +578,25 @@ class UpgradePageContext(TypedDict): signed_seat_count: str +class SponsorshipRequestForm(forms.Form): + website = forms.URLField(max_length=ZulipSponsorshipRequest.MAX_ORG_URL_LENGTH, required=False) + organization_type = forms.IntegerField() + description = forms.CharField(widget=forms.Textarea) + expected_total_users = forms.CharField(widget=forms.Textarea) + paid_users_count = forms.CharField(widget=forms.Textarea) + paid_users_description = forms.CharField(widget=forms.Textarea, required=False) + + class BillingSession(ABC): @property @abstractmethod def billing_session_url(self) -> str: pass + @abstractmethod + def support_url(self) -> str: + pass + @abstractmethod def get_customer(self) -> Optional[Customer]: pass @@ -622,6 +661,16 @@ class BillingSession(ABC): def is_sponsored_or_pending(self, customer: Optional[Customer]) -> bool: pass + @abstractmethod + def get_sponsorship_request_session_specific_context( + self, + ) -> SponsorshipRequestSessionSpecificContext: + pass + + @abstractmethod + def save_org_type_from_request_sponsorship_session(self, org_type: int) -> None: + pass + @abstractmethod def get_upgrade_page_session_type_specific_context( self, @@ -1717,6 +1766,67 @@ class BillingSession(ABC): self.add_sponsorship_info_to_context(context) return context + def request_sponsorship(self, form: SponsorshipRequestForm) -> None: + if not form.is_valid(): + message = " ".join( + error["message"] + for error_list in form.errors.get_json_data().values() + for error in error_list + ) + raise BillingError("Form validation error", message=message) + + request_context = self.get_sponsorship_request_session_specific_context() + with transaction.atomic(): + # Ensures customer is created first before updating sponsorship status. + self.update_customer_sponsorship_status(True) + sponsorship_request = ZulipSponsorshipRequest( + customer=self.get_customer(), + requested_by=request_context["realm_user"], + org_website=form.cleaned_data["website"], + org_description=form.cleaned_data["description"], + org_type=form.cleaned_data["organization_type"], + expected_total_users=form.cleaned_data["expected_total_users"], + paid_users_count=form.cleaned_data["paid_users_count"], + paid_users_description=form.cleaned_data["paid_users_description"], + ) + sponsorship_request.save() + + org_type = form.cleaned_data["organization_type"] + self.save_org_type_from_request_sponsorship_session(org_type) + + if request_context["realm_user"] is not None: + # TODO: Refactor to not create an import cycle. + from zerver.actions.users import do_change_is_billing_admin + + do_change_is_billing_admin(request_context["realm_user"], True) + + org_type_display_name = get_org_type_display_name(org_type) + + user_info = request_context["user_info"] + support_url = self.support_url() + context = { + "requested_by": user_info["name"], + "user_role": user_info["role"], + # TODO: realm_string_id needs to be replaced by something more generic. + "string_id": request_context["realm_string_id"], + "support_url": support_url, + "organization_type": org_type_display_name, + "website": sponsorship_request.org_website, + "description": sponsorship_request.org_description, + "expected_total_users": sponsorship_request.expected_total_users, + "paid_users_count": sponsorship_request.paid_users_count, + "paid_users_description": sponsorship_request.paid_users_description, + } + send_email( + "zerver/emails/sponsorship_request", + to_emails=[FromAddress.SUPPORT], + # Sent to the server's support team, so this email is not user-facing. + from_name="Zulip sponsorship request", + from_address=FromAddress.tokenized_no_reply_address(), + reply_to_email=user_info["email"], + context=context, + ) + class RealmBillingSession(BillingSession): def __init__( @@ -1751,6 +1861,13 @@ class RealmBillingSession(BillingSession): def billing_session_url(self) -> str: return self.realm.uri + @override + def support_url(self) -> str: + # TODO: Refactor to not create an import cycle. + from corporate.lib.support import get_support_url + + return get_support_url(self.realm) + @override def get_customer(self) -> Optional[Customer]: return get_customer_by_realm(self.realm) @@ -1987,10 +2104,34 @@ class RealmBillingSession(BillingSession): ), ) + @override + def get_sponsorship_request_session_specific_context( + self, + ) -> SponsorshipRequestSessionSpecificContext: + assert self.user is not None + return SponsorshipRequestSessionSpecificContext( + realm_user=self.user, + user_info=SponsorshipApplicantInfo( + name=self.user.full_name, + email=self.user.delivery_email, + role=self.user.get_role_name(), + ), + realm_string_id=self.realm.string_id, + ) + + @override + def save_org_type_from_request_sponsorship_session(self, org_type: int) -> None: + # TODO: Use the actions.py method for this. + if self.realm.org_type != org_type: + self.realm.org_type = org_type + self.realm.save(update_fields=["org_type"]) + class RemoteRealmBillingSession(BillingSession): # nocoverage def __init__( - self, remote_realm: RemoteRealm, support_staff: Optional[UserProfile] = None + self, + remote_realm: RemoteRealm, + support_staff: Optional[UserProfile] = None, ) -> None: self.remote_realm = remote_realm if support_staff is not None: @@ -2004,6 +2145,10 @@ class RemoteRealmBillingSession(BillingSession): # nocoverage def billing_session_url(self) -> str: return f"{settings.EXTERNAL_URI_SCHEME}{settings.SELF_HOSTING_MANAGEMENT_SUBDOMAIN}.{settings.EXTERNAL_HOST}/realm/{self.remote_realm.uuid}" + @override + def support_url(self) -> str: + return "TODO:not-implemented" + @override def get_customer(self) -> Optional[Customer]: return get_customer_by_remote_realm(self.remote_realm) @@ -2195,8 +2340,40 @@ class RemoteRealmBillingSession(BillingSession): # nocoverage @override def add_sponsorship_info_to_context(self, context: Dict[str, Any]) -> None: - # TBD - pass + context.update( + realm_org_type=self.remote_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=self.sponsorship_org_type_key_helper, + ), + ) + + @override + def get_sponsorship_request_session_specific_context( + self, + ) -> SponsorshipRequestSessionSpecificContext: + return SponsorshipRequestSessionSpecificContext( + realm_user=None, + user_info=SponsorshipApplicantInfo( + # TODO: Plumb through the session data on the acting user. + name="Remote realm administrator", + email=self.remote_realm.server.contact_email, + # TODO: Set user_role when determining which set of users can access the page. + role="Remote realm administrator", + ), + # TODO: Check if this works on support page. + realm_string_id=self.remote_realm.host, + ) + + @override + def save_org_type_from_request_sponsorship_session(self, org_type: int) -> None: + if self.remote_realm.org_type != org_type: + self.remote_realm.org_type = org_type + self.remote_realm.save(update_fields=["org_type"]) class RemoteServerBillingSession(BillingSession): # nocoverage @@ -2204,7 +2381,9 @@ class RemoteServerBillingSession(BillingSession): # nocoverage creating RemoteRealm objects.""" def __init__( - self, remote_server: RemoteZulipServer, support_staff: Optional[UserProfile] = None + self, + remote_server: RemoteZulipServer, + support_staff: Optional[UserProfile] = None, ) -> None: self.remote_server = remote_server if support_staff is not None: @@ -2218,6 +2397,10 @@ class RemoteServerBillingSession(BillingSession): # nocoverage def billing_session_url(self) -> str: return "TBD" + @override + def support_url(self) -> str: + return "TODO:not-implemented" + @override def get_customer(self) -> Optional[Customer]: return get_customer_by_remote_server(self.remote_server) @@ -2401,8 +2584,42 @@ class RemoteServerBillingSession(BillingSession): # nocoverage @override def add_sponsorship_info_to_context(self, context: Dict[str, Any]) -> None: - # TBD - pass + context.update( + realm_org_type=self.remote_server.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=self.sponsorship_org_type_key_helper, + ), + ) + + @override + def get_sponsorship_request_session_specific_context( + self, + ) -> SponsorshipRequestSessionSpecificContext: + return SponsorshipRequestSessionSpecificContext( + realm_user=None, + user_info=SponsorshipApplicantInfo( + # TODO: Figure out a better story here. We don't + # actually have a name or other details on the person + # doing this flow, but could ask for it in the login + # form if desired. + name="Remote server administrator", + email=self.remote_server.contact_email, + role="Remote server administrator", + ), + # TODO: Check if this works on support page. + realm_string_id=self.remote_server.hostname, + ) + + @override + def save_org_type_from_request_sponsorship_session(self, org_type: int) -> None: + if self.remote_server.org_type != org_type: + self.remote_server.org_type = org_type + self.remote_server.save(update_fields=["org_type"]) def stripe_customer_has_credit_card_as_default_payment_method( diff --git a/corporate/tests/test_stripe.py b/corporate/tests/test_stripe.py index 09bb99363b..d21ed40212 100644 --- a/corporate/tests/test_stripe.py +++ b/corporate/tests/test_stripe.py @@ -4134,7 +4134,7 @@ class RequiresBillingAccessTest(StripeTestCase): pat for name in reverse_dict for matches, pat, defaults, converters in reverse_dict.getlist(name) - if pat.startswith(re.escape("json/")) + if pat.startswith("json/") and not (pat.startswith(("json/realm/", "json/server/"))) } self.assert_length(json_endpoints, len(tested_endpoints)) diff --git a/corporate/urls.py b/corporate/urls.py index 3ff3362bea..7536c2d9a7 100644 --- a/corporate/urls.py +++ b/corporate/urls.py @@ -4,7 +4,7 @@ from django.conf.urls import include from django.urls import path from django.views.generic import RedirectView, TemplateView -from corporate.views.billing_page import billing_home, sponsorship_request, update_plan +from corporate.views.billing_page import billing_home, update_plan from corporate.views.event_status import event_status, event_status_page from corporate.views.portico import ( app_download_link_redirect, @@ -27,8 +27,16 @@ from corporate.views.session import ( start_card_update_stripe_session, start_card_update_stripe_session_for_realm_upgrade, ) +from corporate.views.sponsorship import ( + remote_realm_sponsorship, + remote_realm_sponsorship_page, + remote_server_sponsorship, + remote_server_sponsorship_page, + sponsorship, + sponsorship_page, +) from corporate.views.support import support_request -from corporate.views.upgrade import remote_realm_upgrade_page, sponsorship, upgrade, upgrade_page +from corporate.views.upgrade import remote_realm_upgrade_page, upgrade, upgrade_page from corporate.views.webhook import stripe_webhook from zerver.lib.rest import rest_path from zerver.lib.url_redirects import LANDING_PAGE_REDIRECTS @@ -40,7 +48,7 @@ i18n_urlpatterns: Any = [ path("jobs/", TemplateView.as_view(template_name="corporate/jobs.html")), # Billing path("billing/", billing_home, name="billing_home"), - path("sponsorship/", sponsorship_request, name="sponsorship_request"), + path("sponsorship/", sponsorship_page, name="sponsorship_request"), path("upgrade/", upgrade_page, name="upgrade_page"), path("support/", support_request), path("billing/event_status/", event_status_page, name="event_status_page"), @@ -151,16 +159,11 @@ i18n_urlpatterns += landing_page_urls # Make a copy of i18n_urlpatterns so that they appear without prefix for English urlpatterns = list(i18n_urlpatterns) -urlpatterns += [ - path("api/v1/", include(v1_api_and_json_patterns)), - path("json/", include(v1_api_and_json_patterns)), -] - urlpatterns += [ path( "remote-billing-login/", remote_server_billing_finalize_login ), - # Remote server billling endpoints. + # Remote server billing endpoints. path("realm//plans", remote_billing_plans_realm, name="remote_billing_plans_realm"), path( "server//plans", @@ -170,9 +173,27 @@ urlpatterns += [ path("realm//billing", remote_billing_page_realm, name="remote_billing_page_realm"), path("server//", remote_billing_page_server, name="remote_billing_page_server"), path("realm//upgrade", remote_realm_upgrade_page, name="remote_realm_upgrade_page"), + path( + "realm//sponsorship", + remote_realm_sponsorship_page, + name="remote_realm_sponsorship_page", + ), + path( + "server//sponsorship", + remote_server_sponsorship_page, + name="remote_server_sponsorship_page", + ), path( "serverlogin/", remote_billing_legacy_server_login, name="remote_billing_legacy_server_login", ), + # Remote variants of above API endpoints. + path("json/realm//sponsorship", remote_realm_sponsorship), + path("json/server//sponsorship", remote_server_sponsorship), +] + +urlpatterns += [ + path("api/v1/", include(v1_api_and_json_patterns)), + path("json/", include(v1_api_and_json_patterns)), ] diff --git a/corporate/views/billing_page.py b/corporate/views/billing_page.py index eae76284eb..37fda56e8a 100644 --- a/corporate/views/billing_page.py +++ b/corporate/views/billing_page.py @@ -16,19 +16,6 @@ from zerver.models import UserProfile billing_logger = logging.getLogger("corporate.stripe") -@zulip_login_required -def sponsorship_request(request: HttpRequest) -> HttpResponse: - user = request.user - assert user.is_authenticated - - billing_session = RealmBillingSession(user) - context = billing_session.get_sponsorship_request_context() - if context is None: - return HttpResponseRedirect(reverse("billing_home")) - - return render(request, "corporate/sponsorship.html", context=context) - - @zulip_login_required @has_request_variables def billing_home( diff --git a/corporate/views/sponsorship.py b/corporate/views/sponsorship.py new file mode 100644 index 0000000000..71f0627479 --- /dev/null +++ b/corporate/views/sponsorship.py @@ -0,0 +1,93 @@ +from django.http import HttpRequest, HttpResponse, HttpResponseRedirect +from django.shortcuts import render +from django.urls import reverse + +from corporate.lib.decorator import ( + authenticated_remote_realm_management_endpoint, + authenticated_remote_server_management_endpoint, +) +from corporate.lib.stripe import ( + RealmBillingSession, + RemoteRealmBillingSession, + RemoteServerBillingSession, + SponsorshipRequestForm, +) +from zerver.decorator import require_organization_member, zulip_login_required +from zerver.lib.response import json_success +from zerver.models import UserProfile +from zilencer.models import RemoteRealm, RemoteZulipServer + + +@zulip_login_required +def sponsorship_page(request: HttpRequest) -> HttpResponse: + user = request.user + assert user.is_authenticated + + billing_session = RealmBillingSession(user) + context = billing_session.get_sponsorship_request_context() + if context is None: + return HttpResponseRedirect(reverse("billing_home")) + + return render(request, "corporate/sponsorship.html", context=context) + + +@authenticated_remote_realm_management_endpoint +def remote_realm_sponsorship_page( + request: HttpRequest, + remote_realm: RemoteRealm, +) -> HttpResponse: # nocoverage + billing_session = RemoteRealmBillingSession(remote_realm) + context = billing_session.get_sponsorship_request_context() + if context is None: + return HttpResponseRedirect(reverse("remote_billing_page_realm")) + + return render(request, "corporate/sponsorship.html", context=context) + + +@authenticated_remote_server_management_endpoint +def remote_server_sponsorship_page( + request: HttpRequest, + remote_server: RemoteZulipServer, +) -> HttpResponse: # nocoverage + billing_session = RemoteServerBillingSession(remote_server) + context = billing_session.get_sponsorship_request_context() + if context is None: + return HttpResponseRedirect(reverse("remote_billing_page_server")) + + return render(request, "corporate/sponsorship.html", context=context) + + +@require_organization_member +def sponsorship( + request: HttpRequest, + user: UserProfile, +) -> HttpResponse: + billing_session = RealmBillingSession(user) + post_data = request.POST.copy() + form = SponsorshipRequestForm(post_data) + billing_session.request_sponsorship(form) + return json_success(request) + + +@authenticated_remote_realm_management_endpoint +def remote_realm_sponsorship( + request: HttpRequest, + remote_realm: RemoteRealm, +) -> HttpResponse: # nocoverage + billing_session = RemoteRealmBillingSession(remote_realm) + post_data = request.POST.copy() + form = SponsorshipRequestForm(post_data) + billing_session.request_sponsorship(form) + return json_success(request) + + +@authenticated_remote_server_management_endpoint +def remote_server_sponsorship( + request: HttpRequest, + remote_server: RemoteZulipServer, +) -> HttpResponse: # nocoverage + billing_session = RemoteServerBillingSession(remote_server) + post_data = request.POST.copy() + form = SponsorshipRequestForm(post_data) + billing_session.request_sponsorship(form) + return json_success(request) diff --git a/corporate/views/upgrade.py b/corporate/views/upgrade.py index 606ae3db17..58539987e8 100644 --- a/corporate/views/upgrade.py +++ b/corporate/views/upgrade.py @@ -1,9 +1,7 @@ import logging from typing import Optional -from django import forms from django.conf import settings -from django.db import transaction from django.http import HttpRequest, HttpResponse, HttpResponseRedirect from django.shortcuts import render from pydantic import Json @@ -19,16 +17,13 @@ from corporate.lib.stripe import ( RemoteRealmBillingSession, UpgradeRequest, ) -from corporate.lib.support import get_support_url -from corporate.models import CustomerPlan, ZulipSponsorshipRequest -from zerver.actions.users import do_change_is_billing_admin +from corporate.models import CustomerPlan from zerver.decorator import require_organization_member, zulip_login_required 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.typed_endpoint import PathOnly, typed_endpoint from zerver.lib.validator import check_bool, check_int, check_string_in -from zerver.models import UserProfile, get_org_type_display_name +from zerver.models import UserProfile from zilencer.models import RemoteRealm billing_logger = logging.getLogger("corporate.stripe") @@ -128,84 +123,3 @@ def remote_realm_upgrade_page( response = render(request, "corporate/upgrade.html", context=context) return response - - -class SponsorshipRequestForm(forms.Form): - website = forms.URLField(max_length=ZulipSponsorshipRequest.MAX_ORG_URL_LENGTH, required=False) - organization_type = forms.IntegerField() - description = forms.CharField(widget=forms.Textarea) - expected_total_users = forms.CharField(widget=forms.Textarea) - paid_users_count = forms.CharField(widget=forms.Textarea) - paid_users_description = forms.CharField(widget=forms.Textarea, required=False) - - -@require_organization_member -def sponsorship( - request: HttpRequest, - user: UserProfile, -) -> HttpResponse: - realm = user.realm - billing_session = RealmBillingSession(user) - - requested_by = user.full_name - user_role = user.get_role_name() - support_url = get_support_url(realm) - - post_data = request.POST.copy() - form = SponsorshipRequestForm(post_data) - - if form.is_valid(): - with transaction.atomic(): - # Ensures customer is created first before updating sponsorship status. - billing_session.update_customer_sponsorship_status(True) - sponsorship_request = ZulipSponsorshipRequest( - customer=billing_session.get_customer(), - requested_by=user, - org_website=form.cleaned_data["website"], - org_description=form.cleaned_data["description"], - org_type=form.cleaned_data["organization_type"], - expected_total_users=form.cleaned_data["expected_total_users"], - paid_users_count=form.cleaned_data["paid_users_count"], - paid_users_description=form.cleaned_data["paid_users_description"], - ) - 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"]) - - do_change_is_billing_admin(user, True) - - 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": sponsorship_request.org_website, - "description": sponsorship_request.org_description, - "expected_total_users": sponsorship_request.expected_total_users, - "paid_users_count": sponsorship_request.paid_users_count, - "paid_users_description": sponsorship_request.paid_users_description, - } - # Sent to the server's support team, so this email is not user-facing. - send_email( - "zerver/emails/sponsorship_request", - to_emails=[FromAddress.SUPPORT], - from_name="Zulip sponsorship request", - from_address=FromAddress.tokenized_no_reply_address(), - reply_to_email=user.delivery_email, - context=context, - ) - - return json_success(request) - else: - message = " ".join( - error["message"] - for error_list in form.errors.get_json_data().values() - for error in error_list - ) - raise BillingError("Form validation error", message=message) diff --git a/web/src/billing/sponsorship.ts b/web/src/billing/sponsorship.ts index 44af76f1d9..c7f44174ff 100644 --- a/web/src/billing/sponsorship.ts +++ b/web/src/billing/sponsorship.ts @@ -55,6 +55,7 @@ function create_ajax_request(): void { void $.ajax({ type: "post", + // TODO: This needs to be conditional on billing session type url: "/json/billing/sponsorship", data, success() {