billing: Add BillingSession support for requesting sponsorship.

This commit is contained in:
Tim Abbott 2023-11-29 16:48:46 -08:00
parent 1691205306
commit a01618d633
7 changed files with 352 additions and 119 deletions

View File

@ -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(

View File

@ -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))

View File

@ -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/<signed_billing_access_token>", remote_server_billing_finalize_login
),
# Remote server billling endpoints.
# Remote server billing endpoints.
path("realm/<realm_uuid>/plans", remote_billing_plans_realm, name="remote_billing_plans_realm"),
path(
"server/<server_uuid>/plans",
@ -170,9 +173,27 @@ urlpatterns += [
path("realm/<realm_uuid>/billing", remote_billing_page_realm, name="remote_billing_page_realm"),
path("server/<server_uuid>/", remote_billing_page_server, name="remote_billing_page_server"),
path("realm/<realm_uuid>/upgrade", remote_realm_upgrade_page, name="remote_realm_upgrade_page"),
path(
"realm/<realm_uuid>/sponsorship",
remote_realm_sponsorship_page,
name="remote_realm_sponsorship_page",
),
path(
"server/<server_uuid>/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/<realm_uuid>/sponsorship", remote_realm_sponsorship),
path("json/server/<server_uuid>/sponsorship", remote_server_sponsorship),
]
urlpatterns += [
path("api/v1/", include(v1_api_and_json_patterns)),
path("json/", include(v1_api_and_json_patterns)),
]

View File

@ -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(

View File

@ -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)

View File

@ -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)

View File

@ -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() {