zulip/corporate/views/billing_page.py

349 lines
12 KiB
Python

import logging
from typing import Annotated, Any, Literal
from django.http import HttpRequest, HttpResponse, HttpResponseNotAllowed, HttpResponseRedirect
from django.shortcuts import render
from django.urls import reverse
from django.utils.translation import gettext as _
from pydantic import AfterValidator, Json
from corporate.lib.decorator import (
authenticated_remote_realm_management_endpoint,
authenticated_remote_server_management_endpoint,
)
from corporate.lib.stripe import (
RealmBillingSession,
RemoteRealmBillingSession,
RemoteServerBillingSession,
ServerDeactivateWithExistingPlanError,
UpdatePlanRequest,
do_deactivate_remote_server,
)
from corporate.models import CustomerPlan, get_current_plan_by_customer, get_customer_by_realm
from zerver.decorator import process_as_post, require_billing_access, zulip_login_required
from zerver.lib.exceptions import JsonableError
from zerver.lib.response import json_success
from zerver.lib.typed_endpoint import typed_endpoint
from zerver.lib.typed_endpoint_validators import check_int_in
from zerver.models import UserProfile
from zilencer.lib.remote_counts import MissingDataError
from zilencer.models import RemoteRealm, RemoteZulipServer
billing_logger = logging.getLogger("corporate.stripe")
ALLOWED_PLANS_API_STATUS_VALUES = [
CustomerPlan.ACTIVE,
CustomerPlan.DOWNGRADE_AT_END_OF_CYCLE,
CustomerPlan.SWITCH_TO_ANNUAL_AT_END_OF_CYCLE,
CustomerPlan.SWITCH_TO_MONTHLY_AT_END_OF_CYCLE,
CustomerPlan.FREE_TRIAL,
CustomerPlan.DOWNGRADE_AT_END_OF_FREE_TRIAL,
CustomerPlan.ENDED,
]
@zulip_login_required
@typed_endpoint
def billing_page(
request: HttpRequest,
*,
success_message: str = "",
) -> HttpResponse:
user = request.user
assert user.is_authenticated
billing_session = RealmBillingSession(user=user, realm=user.realm)
context: dict[str, Any] = {
"admin_access": user.has_billing_access,
"has_active_plan": False,
"org_name": billing_session.org_name(),
"billing_base_url": "",
}
if not user.has_billing_access:
return render(request, "corporate/billing/billing.html", context=context)
if user.realm.plan_type == user.realm.PLAN_TYPE_STANDARD_FREE:
return HttpResponseRedirect(reverse("sponsorship_request"))
customer = get_customer_by_realm(user.realm)
if customer is not None and customer.sponsorship_pending:
# Don't redirect to sponsorship page if the realm is on a paid plan
if not billing_session.on_paid_plan():
return HttpResponseRedirect(reverse("sponsorship_request"))
# If the realm is on a paid plan, show the sponsorship pending message
context["sponsorship_pending"] = True
if user.realm.plan_type == user.realm.PLAN_TYPE_LIMITED:
return HttpResponseRedirect(reverse("plans"))
if customer is None or get_current_plan_by_customer(customer) is None:
return HttpResponseRedirect(reverse("upgrade_page"))
main_context = billing_session.get_billing_page_context()
if main_context:
if main_context.get("current_plan_downgraded") is True:
return HttpResponseRedirect(reverse("plans"))
context.update(main_context)
context["success_message"] = success_message
return render(request, "corporate/billing/billing.html", context=context)
@authenticated_remote_realm_management_endpoint
@typed_endpoint
def remote_realm_billing_page(
request: HttpRequest,
billing_session: RemoteRealmBillingSession,
*,
success_message: str = "",
) -> HttpResponse:
realm_uuid = billing_session.remote_realm.uuid
context: dict[str, Any] = {
# We wouldn't be here if user didn't have access.
"admin_access": billing_session.has_billing_access(),
"has_active_plan": False,
"org_name": billing_session.org_name(),
"billing_base_url": billing_session.billing_base_url,
}
if billing_session.remote_realm.plan_type == RemoteRealm.PLAN_TYPE_COMMUNITY: # nocoverage
return HttpResponseRedirect(reverse("remote_realm_sponsorship_page", args=(realm_uuid,)))
customer = billing_session.get_customer()
if customer is not None and customer.sponsorship_pending: # nocoverage
# Don't redirect to sponsorship page if the remote realm is on a paid plan or scheduled for an upgrade.
if (
not billing_session.on_paid_plan()
and billing_session.get_legacy_remote_server_next_plan_name(customer) is None
):
return HttpResponseRedirect(
reverse("remote_realm_sponsorship_page", args=(realm_uuid,))
)
# If the realm is on a paid plan, show the sponsorship pending message
context["sponsorship_pending"] = True
if (
customer is None
or get_current_plan_by_customer(customer) is None
or (
billing_session.get_legacy_remote_server_next_plan_name(customer) is None
and billing_session.remote_realm.plan_type
in [
RemoteRealm.PLAN_TYPE_SELF_MANAGED,
RemoteRealm.PLAN_TYPE_SELF_MANAGED_LEGACY,
]
)
): # nocoverage
return HttpResponseRedirect(reverse("remote_realm_plans_page", args=(realm_uuid,)))
try:
main_context = billing_session.get_billing_page_context()
except MissingDataError: # nocoverage
return billing_session.missing_data_error_page(request)
if main_context:
if main_context.get("current_plan_downgraded") is True:
return HttpResponseRedirect(reverse("remote_realm_plans_page", args=(realm_uuid,)))
context.update(main_context)
context["success_message"] = success_message
return render(request, "corporate/billing/billing.html", context=context)
@authenticated_remote_server_management_endpoint
@typed_endpoint
def remote_server_billing_page(
request: HttpRequest,
billing_session: RemoteServerBillingSession,
*,
success_message: str = "",
) -> HttpResponse:
context: dict[str, Any] = {
# We wouldn't be here if user didn't have access.
"admin_access": billing_session.has_billing_access(),
"has_active_plan": False,
"org_name": billing_session.org_name(),
"billing_base_url": billing_session.billing_base_url,
}
if (
billing_session.remote_server.plan_type == RemoteZulipServer.PLAN_TYPE_COMMUNITY
): # nocoverage
return HttpResponseRedirect(
reverse(
"remote_server_sponsorship_page",
kwargs={"server_uuid": billing_session.remote_server.uuid},
)
)
customer = billing_session.get_customer()
if customer is not None and customer.sponsorship_pending:
# Don't redirect to sponsorship page if the remote realm is on a paid plan or scheduled for an upgrade.
if (
not billing_session.on_paid_plan()
and billing_session.get_legacy_remote_server_next_plan_name(customer) is None
):
return HttpResponseRedirect(
reverse(
"remote_server_sponsorship_page",
kwargs={"server_uuid": billing_session.remote_server.uuid},
)
)
# If the realm is on a paid plan, show the sponsorship pending message
context["sponsorship_pending"] = True # nocoverage
if (
customer is None
or get_current_plan_by_customer(customer) is None
or (
billing_session.get_legacy_remote_server_next_plan_name(customer) is None
and billing_session.remote_server.plan_type
in [
RemoteZulipServer.PLAN_TYPE_SELF_MANAGED,
RemoteZulipServer.PLAN_TYPE_SELF_MANAGED_LEGACY,
]
)
):
return HttpResponseRedirect(
reverse(
"remote_server_upgrade_page",
kwargs={"server_uuid": billing_session.remote_server.uuid},
)
)
try:
main_context = billing_session.get_billing_page_context()
except MissingDataError: # nocoverage
return billing_session.missing_data_error_page(request)
if main_context:
if main_context.get("current_plan_downgraded") is True:
return HttpResponseRedirect(
reverse(
"remote_server_plans_page",
kwargs={"server_uuid": billing_session.remote_server.uuid},
)
)
context.update(main_context)
context["success_message"] = success_message
return render(request, "corporate/billing/billing.html", context=context)
@require_billing_access
@typed_endpoint
def update_plan(
request: HttpRequest,
user: UserProfile,
*,
status: Annotated[
Json[int], AfterValidator(lambda x: check_int_in(x, ALLOWED_PLANS_API_STATUS_VALUES))
]
| None = None,
licenses: Json[int] | None = None,
licenses_at_next_renewal: Json[int] | None = None,
schedule: Json[int] | None = None,
) -> HttpResponse:
update_plan_request = UpdatePlanRequest(
status=status,
licenses=licenses,
licenses_at_next_renewal=licenses_at_next_renewal,
schedule=schedule,
)
billing_session = RealmBillingSession(user=user)
billing_session.do_update_plan(update_plan_request)
return json_success(request)
@authenticated_remote_realm_management_endpoint
@process_as_post
@typed_endpoint
def update_plan_for_remote_realm(
request: HttpRequest,
billing_session: RemoteRealmBillingSession,
*,
status: Annotated[
Json[int], AfterValidator(lambda x: check_int_in(x, ALLOWED_PLANS_API_STATUS_VALUES))
]
| None = None,
licenses: Json[int] | None = None,
licenses_at_next_renewal: Json[int] | None = None,
schedule: Json[int] | None = None,
) -> HttpResponse:
update_plan_request = UpdatePlanRequest(
status=status,
licenses=licenses,
licenses_at_next_renewal=licenses_at_next_renewal,
schedule=schedule,
)
billing_session.do_update_plan(update_plan_request)
return json_success(request)
@authenticated_remote_server_management_endpoint
@process_as_post
@typed_endpoint
def update_plan_for_remote_server(
request: HttpRequest,
billing_session: RemoteServerBillingSession,
*,
status: Annotated[
Json[int], AfterValidator(lambda x: check_int_in(x, ALLOWED_PLANS_API_STATUS_VALUES))
]
| None = None,
licenses: Json[int] | None = None,
licenses_at_next_renewal: Json[int] | None = None,
schedule: Json[int] | None = None,
) -> HttpResponse:
update_plan_request = UpdatePlanRequest(
status=status,
licenses=licenses,
licenses_at_next_renewal=licenses_at_next_renewal,
schedule=schedule,
)
billing_session.do_update_plan(update_plan_request)
return json_success(request)
@authenticated_remote_server_management_endpoint
@typed_endpoint
def remote_server_deactivate_page(
request: HttpRequest,
billing_session: RemoteServerBillingSession,
*,
confirmed: Literal[None, "true"] = None,
) -> HttpResponse:
if request.method not in ["GET", "POST"]: # nocoverage
return HttpResponseNotAllowed(["GET", "POST"])
remote_server = billing_session.remote_server
context = {
"server_hostname": remote_server.hostname,
"action_url": reverse(remote_server_deactivate_page, args=[str(remote_server.uuid)]),
}
if request.method == "GET":
return render(
request, "corporate/billing/remote_billing_server_deactivate.html", context=context
)
assert request.method == "POST"
if confirmed is None: # nocoverage
# Should be impossible if the user is using the UI.
raise JsonableError(_("Parameter 'confirmed' is required"))
try:
do_deactivate_remote_server(remote_server, billing_session)
except ServerDeactivateWithExistingPlanError: # nocoverage
context["show_existing_plan_error"] = "true"
return render(
request, "corporate/billing/remote_billing_server_deactivate.html", context=context
)
return render(
request,
"corporate/billing/remote_billing_server_deactivated_success.html",
context={"server_hostname": remote_server.hostname},
)