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.
This commit is contained in:
Vishnu KS 2021-07-15 14:38:37 +00:00 committed by Tim Abbott
parent 199d3859fb
commit 55a9a019a0
5 changed files with 318 additions and 296 deletions

View File

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

View File

@ -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 = [

View File

View File

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

273
corporate/views/upgrade.py Normal file
View File

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