snapshot of https://github.com/zulip/zulip.git @ 48c69911c7dd4e03b20552eb3bd77ad8151c1357 (2026-09-15)
Some checks failed
Code scanning / CodeQL (push) Failing after 15s
Zulip production suite / Ubuntu 22.04 production build (push) Failing after 26s
Zulip production suite / Debian 12 production install with custom db name and user (push) Has been skipped
Zulip production suite / Ubuntu 22.04 production install and PostgreSQL upgrade with pgroonga (push) Has been skipped
Zulip production suite / Ubuntu 24.04 production install (push) Has been skipped
Zulip production suite / Ubuntu 26.04 production install (push) Has been skipped
Zulip production suite / Debian 13 production install (push) Has been skipped
Zulip production suite / 7.0 Version Upgrade (push) Has been skipped
Zulip production suite / 8.0 Version Upgrade (push) Has been skipped
Zulip production suite / 6.0 Version Upgrade (push) Has been skipped
Zulip production suite / 10.0 Version Upgrade (push) Has been skipped
Zulip production suite / 9.0 Version Upgrade (push) Has been skipped
Zulip production suite / 12.0 Version Upgrade (push) Has been skipped
Zulip production suite / 11.0 Version Upgrade (push) Has been skipped
GitHub Actions Security Analysis with zizmor / zizmor (push) Failing after 3s
Zulip CI / Debian 12 (Python 3.11, backend + documentation) (push) Failing after 26s
Zulip CI / Ubuntu 22.04 (Python 3.10, backend + frontend) (push) Failing after 3s
Zulip CI / Ubuntu 24.04 (Python 3.12, backend) (push) Failing after 25s
Zulip CI / Ubuntu 26.04 (Python 3.14, backend) (push) Failing after 25s
Zulip CI / Debian 13 (Python 3.13, backend) (push) Failing after 28s
Zulip production suite / Required jobs (push) Has been cancelled
Zulip CI / Required jobs (push) Has been cancelled
API Documentation Update Check / check-feature-level-updated (push) Has been cancelled
API Documentation Update Check / notify-if-api-docs-changed (push) Has been cancelled
Some checks failed
Code scanning / CodeQL (push) Failing after 15s
Zulip production suite / Ubuntu 22.04 production build (push) Failing after 26s
Zulip production suite / Debian 12 production install with custom db name and user (push) Has been skipped
Zulip production suite / Ubuntu 22.04 production install and PostgreSQL upgrade with pgroonga (push) Has been skipped
Zulip production suite / Ubuntu 24.04 production install (push) Has been skipped
Zulip production suite / Ubuntu 26.04 production install (push) Has been skipped
Zulip production suite / Debian 13 production install (push) Has been skipped
Zulip production suite / 7.0 Version Upgrade (push) Has been skipped
Zulip production suite / 8.0 Version Upgrade (push) Has been skipped
Zulip production suite / 6.0 Version Upgrade (push) Has been skipped
Zulip production suite / 10.0 Version Upgrade (push) Has been skipped
Zulip production suite / 9.0 Version Upgrade (push) Has been skipped
Zulip production suite / 12.0 Version Upgrade (push) Has been skipped
Zulip production suite / 11.0 Version Upgrade (push) Has been skipped
GitHub Actions Security Analysis with zizmor / zizmor (push) Failing after 3s
Zulip CI / Debian 12 (Python 3.11, backend + documentation) (push) Failing after 26s
Zulip CI / Ubuntu 22.04 (Python 3.10, backend + frontend) (push) Failing after 3s
Zulip CI / Ubuntu 24.04 (Python 3.12, backend) (push) Failing after 25s
Zulip CI / Ubuntu 26.04 (Python 3.14, backend) (push) Failing after 25s
Zulip CI / Debian 13 (Python 3.13, backend) (push) Failing after 28s
Zulip production suite / Required jobs (push) Has been cancelled
Zulip CI / Required jobs (push) Has been cancelled
API Documentation Update Check / check-feature-level-updated (push) Has been cancelled
API Documentation Update Check / notify-if-api-docs-changed (push) Has been cancelled
This commit is contained in:
0
corporate/lib/__init__.py
Normal file
0
corporate/lib/__init__.py
Normal file
422
corporate/lib/activity.py
Normal file
422
corporate/lib/activity.py
Normal file
@@ -0,0 +1,422 @@
|
||||
from collections import defaultdict
|
||||
from collections.abc import Callable, Sequence
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from decimal import Decimal
|
||||
from typing import Any
|
||||
|
||||
from django.conf import settings
|
||||
from django.db import connection
|
||||
from django.db.backends.utils import CursorWrapper
|
||||
from django.db.models import Prefetch
|
||||
from django.template import loader
|
||||
from django.urls import reverse
|
||||
from django.utils.timezone import now as timezone_now
|
||||
from markupsafe import Markup
|
||||
from psycopg2.sql import Composable
|
||||
|
||||
from corporate.models.licenses import LicenseLedger
|
||||
from corporate.models.plans import CustomerPlan
|
||||
from zerver.lib.pysa import mark_sanitized
|
||||
from zerver.models import Realm
|
||||
from zilencer.models import (
|
||||
RemoteCustomerUserCount,
|
||||
RemoteRealm,
|
||||
RemoteRealmAuditLog,
|
||||
RemoteZulipServer,
|
||||
get_remote_customer_user_count,
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class RemoteActivityPlanData:
|
||||
current_status: str
|
||||
current_plan_name: str
|
||||
annual_revenue: int
|
||||
rate: str
|
||||
|
||||
|
||||
@dataclass
|
||||
class ActivityHeaderEntry:
|
||||
name: str
|
||||
value: str | Markup
|
||||
|
||||
|
||||
def make_table(
|
||||
title: str,
|
||||
cols: Sequence[str],
|
||||
rows: Sequence[Any],
|
||||
*,
|
||||
header: list[ActivityHeaderEntry] | None = None,
|
||||
totals: Any | None = None,
|
||||
title_link: Markup | None = None,
|
||||
has_row_class: bool = False,
|
||||
) -> str:
|
||||
if not has_row_class:
|
||||
|
||||
def fix_row(row: Any) -> dict[str, Any]:
|
||||
return dict(cells=row, row_class=None)
|
||||
|
||||
rows = list(map(fix_row, rows))
|
||||
|
||||
data = dict(
|
||||
title=title, cols=cols, rows=rows, header=header, totals=totals, title_link=title_link
|
||||
)
|
||||
|
||||
content = loader.render_to_string(
|
||||
"corporate/activity/activity_table.html",
|
||||
dict(data=data),
|
||||
)
|
||||
|
||||
return content
|
||||
|
||||
|
||||
def fix_rows(
|
||||
rows: list[list[Any]],
|
||||
i: int,
|
||||
fixup_func: Callable[[str], Markup] | Callable[[datetime], str] | Callable[[int], int],
|
||||
) -> None:
|
||||
for row in rows:
|
||||
row[i] = fixup_func(row[i])
|
||||
|
||||
|
||||
def get_query_data(query: Composable) -> list[list[Any]]:
|
||||
cursor = connection.cursor()
|
||||
cursor.execute(query)
|
||||
rows = cursor.fetchall()
|
||||
rows = list(map(list, rows))
|
||||
cursor.close()
|
||||
return rows
|
||||
|
||||
|
||||
def dictfetchall(cursor: CursorWrapper) -> list[dict[str, Any]]:
|
||||
"""Returns all rows from a cursor as a dict"""
|
||||
desc = cursor.description
|
||||
return [dict(zip((col[0] for col in desc), row, strict=False)) for row in cursor.fetchall()]
|
||||
|
||||
|
||||
def format_optional_datetime(date: datetime | None, display_none: bool = False) -> str:
|
||||
if date:
|
||||
return date.replace(tzinfo=None).isoformat(" ", "minutes")
|
||||
elif display_none:
|
||||
return "None"
|
||||
else:
|
||||
return ""
|
||||
|
||||
|
||||
def format_datetime_as_date(date: datetime) -> str:
|
||||
return date.date().isoformat()
|
||||
|
||||
|
||||
def format_none_as_zero(value: int | None) -> int:
|
||||
if value:
|
||||
return value
|
||||
else:
|
||||
return 0
|
||||
|
||||
|
||||
def user_activity_link(link_text: str, user_profile_id: int) -> Markup:
|
||||
from corporate.views.user_activity import get_user_activity
|
||||
|
||||
url = reverse(get_user_activity, kwargs=dict(user_profile_id=user_profile_id))
|
||||
if link_text == "":
|
||||
return Markup('<a href="{url}"><i class="fa fa-user-circle"></i></a>').format(url=url)
|
||||
return Markup('<a href="{url}">{link_text}</a>').format(url=url, link_text=link_text)
|
||||
|
||||
|
||||
def realm_activity_link(realm_str: str) -> Markup:
|
||||
from corporate.views.realm_activity import get_realm_activity
|
||||
|
||||
url = reverse(get_realm_activity, kwargs=dict(realm_str=realm_str))
|
||||
return Markup('<a href="{url}"><i class="fa fa-table"></i></a>').format(url=url)
|
||||
|
||||
|
||||
def realm_stats_link(realm_str: str) -> Markup:
|
||||
from analytics.views.stats import stats_for_realm
|
||||
|
||||
url = reverse(stats_for_realm, kwargs=dict(realm_str=realm_str))
|
||||
return Markup('<a href="{url}"><i class="fa fa-pie-chart"></i></a>').format(url=url)
|
||||
|
||||
|
||||
def user_support_link(email: str) -> Markup:
|
||||
url = reverse("support", query={"q": email})
|
||||
return Markup('<a href="{url}"><i class="fa fa-gear"></i></a>').format(url=url)
|
||||
|
||||
|
||||
def realm_support_link(realm_str: str) -> Markup:
|
||||
url = reverse("support", query={"q": realm_str})
|
||||
return Markup('<a href="{url}">{realm}</i></a>').format(url=url, realm=realm_str)
|
||||
|
||||
|
||||
def realm_url_link(realm_str: str) -> Markup:
|
||||
host = Realm.host_for_subdomain(realm_str)
|
||||
url = settings.EXTERNAL_URI_SCHEME + mark_sanitized(host)
|
||||
return Markup('<a href="{url}"><i class="fa fa-home"></i></a>').format(url=url)
|
||||
|
||||
|
||||
def remote_installation_stats_link(server_id: int) -> Markup:
|
||||
from analytics.views.stats import stats_for_remote_installation
|
||||
|
||||
url = reverse(stats_for_remote_installation, kwargs=dict(remote_server_id=server_id))
|
||||
return Markup('<a href="{url}"><i class="fa fa-pie-chart"></i></a>').format(url=url)
|
||||
|
||||
|
||||
def remote_installation_support_link(hostname: str) -> Markup:
|
||||
url = reverse("remote_servers_support", query={"q": hostname})
|
||||
return Markup('<a href="{url}"><i class="fa fa-gear"></i></a>').format(url=url)
|
||||
|
||||
|
||||
def get_plan_rate_percentage(discount: str | None, has_fixed_price: bool) -> str:
|
||||
# We want to clearly note plans with a fixed price, and not show
|
||||
# them as paying 100%, as they are usually a special, negotiated
|
||||
# rate with the customer.
|
||||
if has_fixed_price:
|
||||
return "Fixed"
|
||||
|
||||
# CustomerPlan.discount is a string field that stores the discount.
|
||||
if discount is None or discount == "0":
|
||||
return "100%"
|
||||
|
||||
rate = 100 - Decimal(discount)
|
||||
if rate * 100 % 100 == 0:
|
||||
precision = 0
|
||||
else:
|
||||
precision = 2
|
||||
return f"{rate:.{precision}f}%"
|
||||
|
||||
|
||||
def get_remote_activity_plan_data(
|
||||
plan: CustomerPlan,
|
||||
license_ledger: LicenseLedger,
|
||||
*,
|
||||
remote_realm: RemoteRealm | None = None,
|
||||
remote_server: RemoteZulipServer | None = None,
|
||||
) -> RemoteActivityPlanData:
|
||||
from corporate.lib.stripe import RemoteRealmBillingSession, RemoteServerBillingSession
|
||||
|
||||
has_fixed_price = plan.fixed_price is not None
|
||||
if plan.tier == CustomerPlan.TIER_SELF_HOSTED_LEGACY or plan.status in (
|
||||
CustomerPlan.DOWNGRADE_AT_END_OF_FREE_TRIAL,
|
||||
CustomerPlan.DOWNGRADE_AT_END_OF_CYCLE,
|
||||
):
|
||||
renewal_cents = 0
|
||||
current_rate = "---"
|
||||
elif plan.tier == CustomerPlan.TIER_SELF_HOSTED_COMMUNITY:
|
||||
renewal_cents = 0
|
||||
current_rate = "0%"
|
||||
elif remote_realm is not None:
|
||||
renewal_cents = RemoteRealmBillingSession(
|
||||
remote_realm=remote_realm
|
||||
).get_annual_recurring_revenue_for_support_data(plan, license_ledger)
|
||||
current_rate = get_plan_rate_percentage(plan.discount, has_fixed_price)
|
||||
else:
|
||||
assert remote_server is not None
|
||||
renewal_cents = RemoteServerBillingSession(
|
||||
remote_server=remote_server
|
||||
).get_annual_recurring_revenue_for_support_data(plan, license_ledger)
|
||||
current_rate = get_plan_rate_percentage(plan.discount, has_fixed_price)
|
||||
|
||||
return RemoteActivityPlanData(
|
||||
current_status=plan.get_plan_status_as_text(),
|
||||
current_plan_name=plan.name,
|
||||
annual_revenue=renewal_cents,
|
||||
rate=current_rate,
|
||||
)
|
||||
|
||||
|
||||
def get_estimated_arr_and_rate_by_realm() -> tuple[dict[str, int], dict[str, str]]: # nocoverage
|
||||
from corporate.lib.stripe import RealmBillingSession
|
||||
|
||||
# NOTE: Customers without a plan might still have a discount attached to them which
|
||||
# are not included in `plan_rate`.
|
||||
annual_revenue = {}
|
||||
plan_rate = {}
|
||||
plans = (
|
||||
CustomerPlan.objects.filter(
|
||||
status=CustomerPlan.ACTIVE,
|
||||
customer__remote_realm__isnull=True,
|
||||
customer__remote_server__isnull=True,
|
||||
)
|
||||
.prefetch_related(
|
||||
Prefetch(
|
||||
"licenseledger_set",
|
||||
queryset=LicenseLedger.objects.order_by("plan", "-id").distinct("plan"),
|
||||
to_attr="latest_ledger_entry",
|
||||
)
|
||||
)
|
||||
.select_related("customer__realm")
|
||||
)
|
||||
|
||||
for plan in plans:
|
||||
assert plan.customer.realm is not None
|
||||
latest_ledger_entry = plan.latest_ledger_entry[0]
|
||||
assert latest_ledger_entry is not None
|
||||
renewal_cents = RealmBillingSession(
|
||||
realm=plan.customer.realm
|
||||
).get_annual_recurring_revenue_for_support_data(plan, latest_ledger_entry)
|
||||
annual_revenue[plan.customer.realm.string_id] = renewal_cents
|
||||
has_fixed_price = plan.fixed_price is not None
|
||||
plan_rate[plan.customer.realm.string_id] = get_plan_rate_percentage(
|
||||
plan.discount, has_fixed_price
|
||||
)
|
||||
return annual_revenue, plan_rate
|
||||
|
||||
|
||||
def get_plan_data_by_remote_server() -> dict[int, RemoteActivityPlanData]: # nocoverage
|
||||
remote_server_plan_data: dict[int, RemoteActivityPlanData] = {}
|
||||
plans = (
|
||||
CustomerPlan.objects.filter(
|
||||
status__lt=CustomerPlan.LIVE_STATUS_THRESHOLD,
|
||||
customer__realm__isnull=True,
|
||||
customer__remote_realm__isnull=True,
|
||||
customer__remote_server__deactivated=False,
|
||||
)
|
||||
.prefetch_related(
|
||||
Prefetch(
|
||||
"licenseledger_set",
|
||||
queryset=LicenseLedger.objects.order_by("plan", "-id").distinct("plan"),
|
||||
to_attr="latest_ledger_entry",
|
||||
)
|
||||
)
|
||||
.select_related("customer__remote_server")
|
||||
)
|
||||
|
||||
for plan in plans:
|
||||
server_id = None
|
||||
assert plan.customer.remote_server is not None
|
||||
server_id = plan.customer.remote_server.id
|
||||
assert server_id is not None
|
||||
|
||||
latest_ledger_entry = plan.latest_ledger_entry[0]
|
||||
assert latest_ledger_entry is not None
|
||||
|
||||
plan_data = get_remote_activity_plan_data(
|
||||
plan, latest_ledger_entry, remote_server=plan.customer.remote_server
|
||||
)
|
||||
|
||||
current_data = remote_server_plan_data.get(server_id)
|
||||
if current_data is not None:
|
||||
current_revenue = remote_server_plan_data[server_id].annual_revenue
|
||||
current_plans = remote_server_plan_data[server_id].current_plan_name
|
||||
# There should only ever be one CustomerPlan for a remote server with
|
||||
# a status that is less than the CustomerPlan.LIVE_STATUS_THRESHOLD.
|
||||
remote_server_plan_data[server_id] = RemoteActivityPlanData(
|
||||
current_status="ERROR: MULTIPLE PLANS",
|
||||
current_plan_name=f"{current_plans}, {plan_data.current_plan_name}",
|
||||
annual_revenue=current_revenue + plan_data.annual_revenue,
|
||||
rate="",
|
||||
)
|
||||
else:
|
||||
remote_server_plan_data[server_id] = plan_data
|
||||
return remote_server_plan_data
|
||||
|
||||
|
||||
def get_plan_data_by_remote_realm() -> dict[int, dict[int, RemoteActivityPlanData]]: # nocoverage
|
||||
remote_server_plan_data_by_realm: dict[int, dict[int, RemoteActivityPlanData]] = {}
|
||||
plans = (
|
||||
CustomerPlan.objects.filter(
|
||||
status__lt=CustomerPlan.LIVE_STATUS_THRESHOLD,
|
||||
customer__realm__isnull=True,
|
||||
customer__remote_server__isnull=True,
|
||||
customer__remote_realm__is_system_bot_realm=False,
|
||||
customer__remote_realm__realm_deactivated=False,
|
||||
)
|
||||
.prefetch_related(
|
||||
Prefetch(
|
||||
"licenseledger_set",
|
||||
queryset=LicenseLedger.objects.order_by("plan", "-id").distinct("plan"),
|
||||
to_attr="latest_ledger_entry",
|
||||
)
|
||||
)
|
||||
.select_related("customer__remote_realm")
|
||||
)
|
||||
|
||||
for plan in plans:
|
||||
server_id = None
|
||||
assert plan.customer.remote_realm is not None
|
||||
server_id = plan.customer.remote_realm.server_id
|
||||
assert server_id is not None
|
||||
|
||||
latest_ledger_entry = plan.latest_ledger_entry[0]
|
||||
assert latest_ledger_entry is not None
|
||||
|
||||
plan_data = get_remote_activity_plan_data(
|
||||
plan, latest_ledger_entry, remote_realm=plan.customer.remote_realm
|
||||
)
|
||||
|
||||
current_server_data = remote_server_plan_data_by_realm.get(server_id)
|
||||
realm_id = plan.customer.remote_realm.id
|
||||
|
||||
if current_server_data is None:
|
||||
realm_dict = {realm_id: plan_data}
|
||||
remote_server_plan_data_by_realm[server_id] = realm_dict
|
||||
else:
|
||||
assert current_server_data is not None
|
||||
current_realm_data = current_server_data.get(realm_id)
|
||||
if current_realm_data is not None:
|
||||
# There should only ever be one CustomerPlan for a remote realm with
|
||||
# a status that is less than the CustomerPlan.LIVE_STATUS_THRESHOLD.
|
||||
current_revenue = current_realm_data.annual_revenue
|
||||
current_plans = current_realm_data.current_plan_name
|
||||
current_server_data[realm_id] = RemoteActivityPlanData(
|
||||
current_status="ERROR: MULTIPLE PLANS",
|
||||
current_plan_name=f"{current_plans}, {plan_data.current_plan_name}",
|
||||
annual_revenue=current_revenue + plan_data.annual_revenue,
|
||||
rate="",
|
||||
)
|
||||
else:
|
||||
current_server_data[realm_id] = plan_data
|
||||
|
||||
return remote_server_plan_data_by_realm
|
||||
|
||||
|
||||
def get_remote_realm_user_counts(
|
||||
event_time: datetime | None = None,
|
||||
) -> dict[int, RemoteCustomerUserCount]: # nocoverage
|
||||
user_counts_by_realm: dict[int, RemoteCustomerUserCount] = {}
|
||||
for log in (
|
||||
RemoteRealmAuditLog.objects.filter(
|
||||
event_type__in=RemoteRealmAuditLog.SYNCED_BILLING_EVENTS,
|
||||
event_time__lte=timezone_now() if event_time is None else event_time,
|
||||
remote_realm__isnull=False,
|
||||
)
|
||||
# Important: extra_data is empty for some pre-2020 audit logs
|
||||
# prior to the introduction of realm_user_count_by_role
|
||||
# logging. Meanwhile, modern Zulip servers using
|
||||
# bulk_create_users to create the users in the system bot
|
||||
# realm also generate such audit logs. Such audit logs should
|
||||
# never be the latest in a normal realm.
|
||||
.exclude(extra_data={})
|
||||
.order_by("remote_realm", "-event_time")
|
||||
.distinct("remote_realm")
|
||||
.select_related("remote_realm")
|
||||
):
|
||||
assert log.remote_realm is not None
|
||||
user_counts_by_realm[log.remote_realm.id] = get_remote_customer_user_count([log])
|
||||
|
||||
return user_counts_by_realm
|
||||
|
||||
|
||||
def get_remote_server_audit_logs(
|
||||
event_time: datetime | None = None,
|
||||
) -> dict[int, list[RemoteRealmAuditLog]]:
|
||||
logs_per_server: dict[int, list[RemoteRealmAuditLog]] = defaultdict(list)
|
||||
for log in (
|
||||
RemoteRealmAuditLog.objects.filter(
|
||||
event_type__in=RemoteRealmAuditLog.SYNCED_BILLING_EVENTS,
|
||||
event_time__lte=timezone_now() if event_time is None else event_time,
|
||||
)
|
||||
# Important: extra_data is empty for some pre-2020 audit logs
|
||||
# prior to the introduction of realm_user_count_by_role
|
||||
# logging. Meanwhile, modern Zulip servers using
|
||||
# bulk_create_users to create the users in the system bot
|
||||
# realm also generate such audit logs. Such audit logs should
|
||||
# never be the latest in a normal realm.
|
||||
.exclude(extra_data={})
|
||||
.order_by("server_id", "realm_id", "-event_time")
|
||||
.distinct("server_id", "realm_id")
|
||||
.select_related("server")
|
||||
):
|
||||
logs_per_server[log.server.id].append(log)
|
||||
|
||||
return logs_per_server
|
||||
75
corporate/lib/billing_management.py
Normal file
75
corporate/lib/billing_management.py
Normal file
@@ -0,0 +1,75 @@
|
||||
from argparse import ArgumentParser
|
||||
from typing import Any
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.management.base import CommandError
|
||||
|
||||
from zerver.lib.management import ZulipBaseCommand
|
||||
from zerver.models.realms import Realm
|
||||
from zilencer.models import RemoteRealm, RemoteZulipServer
|
||||
|
||||
if settings.BILLING_ENABLED:
|
||||
from corporate.lib.stripe import (
|
||||
BillingSession,
|
||||
RealmBillingSession,
|
||||
RemoteRealmBillingSession,
|
||||
RemoteServerBillingSession,
|
||||
)
|
||||
|
||||
|
||||
class BillingSessionCommand(ZulipBaseCommand):
|
||||
def add_billing_entity_args(self, parser: ArgumentParser) -> None:
|
||||
parser.add_argument(
|
||||
"--remote-server",
|
||||
dest="remote_server_uuid",
|
||||
required=False,
|
||||
help="The UUID of the registered remote Zulip server to modify.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--remote-realm",
|
||||
dest="remote_realm_uuid",
|
||||
required=False,
|
||||
help="The UUID of the remote realm to modify.",
|
||||
)
|
||||
self.add_realm_args(parser)
|
||||
|
||||
def get_billing_session_from_args(self, options: dict[str, Any]) -> BillingSession:
|
||||
realm: Realm | None = None
|
||||
remote_realm: RemoteRealm | None = None
|
||||
remote_server: RemoteZulipServer | None = None
|
||||
billing_session: BillingSession | None = None
|
||||
if options["realm_id"]:
|
||||
realm = self.get_realm(options)
|
||||
if realm is None:
|
||||
raise CommandError("No realm found.")
|
||||
billing_session = RealmBillingSession(user=None, realm=realm)
|
||||
elif options["remote_realm_uuid"]:
|
||||
remote_realm_uuid = options["remote_realm_uuid"]
|
||||
try:
|
||||
remote_realm = RemoteRealm.objects.get(uuid=remote_realm_uuid)
|
||||
billing_session = RemoteRealmBillingSession(remote_realm=remote_realm)
|
||||
except RemoteRealm.DoesNotExist:
|
||||
raise CommandError(
|
||||
"There is no remote realm with uuid '{}'. Aborting.".format(
|
||||
options["remote_realm_uuid"]
|
||||
)
|
||||
)
|
||||
elif options["remote_server_uuid"]:
|
||||
remote_server_uuid = options["remote_server_uuid"]
|
||||
try:
|
||||
remote_server = RemoteZulipServer.objects.get(uuid=remote_server_uuid)
|
||||
billing_session = RemoteServerBillingSession(remote_server=remote_server)
|
||||
except RemoteZulipServer.DoesNotExist:
|
||||
raise CommandError(
|
||||
"There is no remote server with uuid '{}'. Aborting.".format(
|
||||
options["remote_server_uuid"]
|
||||
)
|
||||
)
|
||||
|
||||
if realm is None and remote_realm is None and remote_server is None:
|
||||
raise CommandError(
|
||||
"No billing entity (Realm, RemoteRealm or RemoteZulipServer) specified."
|
||||
)
|
||||
|
||||
assert billing_session is not None
|
||||
return billing_session
|
||||
5
corporate/lib/billing_types.py
Normal file
5
corporate/lib/billing_types.py
Normal file
@@ -0,0 +1,5 @@
|
||||
from typing import Literal
|
||||
|
||||
BillingModality = Literal["send_invoice", "charge_automatically"]
|
||||
BillingSchedule = Literal["annual", "monthly"]
|
||||
LicenseManagement = Literal["automatic", "manual"]
|
||||
234
corporate/lib/decorator.py
Normal file
234
corporate/lib/decorator.py
Normal file
@@ -0,0 +1,234 @@
|
||||
import inspect
|
||||
from collections.abc import Callable
|
||||
from functools import wraps
|
||||
from typing import TYPE_CHECKING, Concatenate
|
||||
from urllib.parse import urlencode, urljoin
|
||||
|
||||
from django.conf import settings
|
||||
from django.http import HttpRequest, HttpResponse, HttpResponseRedirect, JsonResponse
|
||||
from django.shortcuts import render
|
||||
from django.urls import reverse
|
||||
from typing_extensions import ParamSpec
|
||||
|
||||
from corporate.lib.remote_billing_util import (
|
||||
RemoteBillingIdentityExpiredError,
|
||||
get_remote_realm_and_user_from_session,
|
||||
get_remote_server_and_user_from_session,
|
||||
)
|
||||
from zerver.lib.exceptions import RemoteBillingAuthenticationError
|
||||
from zerver.lib.subdomains import get_subdomain
|
||||
from zerver.lib.url_encoding import append_url_query_string
|
||||
from zilencer.models import RemoteRealm
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from corporate.lib.stripe import RemoteRealmBillingSession, RemoteServerBillingSession
|
||||
|
||||
ParamT = ParamSpec("ParamT")
|
||||
|
||||
|
||||
def session_expired_ajax_response(login_url: str) -> JsonResponse: # nocoverage
|
||||
return JsonResponse(
|
||||
{
|
||||
"error_message": "Remote billing authentication expired",
|
||||
"login_url": login_url,
|
||||
},
|
||||
status=401,
|
||||
)
|
||||
|
||||
|
||||
def is_self_hosting_management_subdomain(request: HttpRequest) -> bool:
|
||||
subdomain = get_subdomain(request)
|
||||
return subdomain == settings.SELF_HOSTING_MANAGEMENT_SUBDOMAIN
|
||||
|
||||
|
||||
def self_hosting_management_endpoint(
|
||||
view_func: Callable[Concatenate[HttpRequest, ParamT], HttpResponse],
|
||||
) -> Callable[Concatenate[HttpRequest, ParamT], HttpResponse]:
|
||||
@wraps(view_func)
|
||||
def _wrapped_view_func(
|
||||
request: HttpRequest, /, *args: ParamT.args, **kwargs: ParamT.kwargs
|
||||
) -> HttpResponse:
|
||||
if not is_self_hosting_management_subdomain(request): # nocoverage
|
||||
return render(request, "404.html", status=404)
|
||||
return view_func(request, *args, **kwargs)
|
||||
|
||||
return _wrapped_view_func
|
||||
|
||||
|
||||
def authenticated_remote_realm_management_endpoint(
|
||||
view_func: Callable[
|
||||
Concatenate[HttpRequest, "RemoteRealmBillingSession", ParamT], HttpResponse
|
||||
],
|
||||
) -> Callable[Concatenate[HttpRequest, ParamT], HttpResponse]:
|
||||
@wraps(view_func)
|
||||
def _wrapped_view_func(
|
||||
request: HttpRequest,
|
||||
/,
|
||||
*args: ParamT.args,
|
||||
**kwargs: ParamT.kwargs,
|
||||
) -> HttpResponse:
|
||||
from corporate.lib.stripe import RemoteRealmBillingSession
|
||||
|
||||
if not is_self_hosting_management_subdomain(request): # nocoverage
|
||||
return render(request, "404.html", status=404)
|
||||
|
||||
realm_uuid = kwargs.pop("realm_uuid")
|
||||
if realm_uuid is not None and not isinstance(realm_uuid, str): # nocoverage
|
||||
raise TypeError("realm_uuid must be a string or None")
|
||||
|
||||
try:
|
||||
remote_realm, remote_billing_user = get_remote_realm_and_user_from_session(
|
||||
request, realm_uuid
|
||||
)
|
||||
except RemoteBillingIdentityExpiredError as e:
|
||||
# The user had an authenticated session with an identity_dict,
|
||||
# but it expired.
|
||||
# We want to redirect back to the start of their login flow
|
||||
# at their {realm.host}/self-hosted-billing/ with a proper
|
||||
# next parameter to take them back to what they're trying
|
||||
# to access after re-authing.
|
||||
# Note: Theoretically we could take the realm_uuid from the request
|
||||
# path or params to figure out the remote_realm.host for the redirect,
|
||||
# but that would mean leaking that .host value to anyone who knows
|
||||
# the uuid. Therefore we limit ourselves to taking the realm_uuid
|
||||
# from the identity_dict - since that proves that the user at least
|
||||
# previously was successfully authenticated as a billing admin of that
|
||||
# realm.
|
||||
realm_uuid = e.realm_uuid
|
||||
server_uuid = e.server_uuid
|
||||
uri_scheme = e.uri_scheme
|
||||
if realm_uuid is None:
|
||||
# This doesn't make sense - if get_remote_realm_and_user_from_session
|
||||
# found an expired identity dict, it should have had a realm_uuid.
|
||||
raise AssertionError
|
||||
|
||||
assert server_uuid is not None, "identity_dict with realm_uuid must have server_uuid"
|
||||
assert uri_scheme is not None, "identity_dict with realm_uuid must have uri_scheme"
|
||||
|
||||
try:
|
||||
remote_realm = RemoteRealm.objects.get(uuid=realm_uuid, server__uuid=server_uuid)
|
||||
except RemoteRealm.DoesNotExist:
|
||||
# This should be impossible - unless the RemoteRealm existed and somehow the row
|
||||
# was deleted.
|
||||
raise AssertionError
|
||||
|
||||
# Using EXTERNAL_URI_SCHEME means we'll do https:// in production, which is
|
||||
# the sane default - while having http:// in development, which will allow
|
||||
# these redirects to work there for testing.
|
||||
url = urljoin(uri_scheme + remote_realm.host, "/self-hosted-billing/")
|
||||
|
||||
page_type = get_next_page_param_from_request_path(request)
|
||||
if page_type is not None:
|
||||
query = urlencode({"next_page": page_type})
|
||||
url = append_url_query_string(url, query)
|
||||
|
||||
# Return error for AJAX requests with url.
|
||||
if (
|
||||
request.get_preferred_type(["application/json", "text/html"]) != "text/html"
|
||||
): # nocoverage
|
||||
return session_expired_ajax_response(url)
|
||||
|
||||
return HttpResponseRedirect(url)
|
||||
|
||||
billing_session = RemoteRealmBillingSession(
|
||||
remote_realm, remote_billing_user=remote_billing_user
|
||||
)
|
||||
return view_func(request, billing_session, *args, **kwargs)
|
||||
|
||||
signature = inspect.signature(view_func)
|
||||
request_parameter, billing_session_parameter, *other_parameters = signature.parameters.values()
|
||||
_wrapped_view_func.__signature__ = signature.replace( # type: ignore[attr-defined] # too magic
|
||||
parameters=[request_parameter, *other_parameters]
|
||||
)
|
||||
_wrapped_view_func.__annotations__ = {
|
||||
k: v for k, v in view_func.__annotations__.items() if k != billing_session_parameter.name
|
||||
}
|
||||
|
||||
return _wrapped_view_func
|
||||
|
||||
|
||||
def get_next_page_param_from_request_path(request: HttpRequest) -> str | None:
|
||||
# Our endpoint URLs in this subsystem end with something like
|
||||
# /sponsorship or /plans etc.
|
||||
# Therefore we can use this nice property to figure out easily what
|
||||
# kind of page the user is trying to access and find the right value
|
||||
# for the `next` query parameter.
|
||||
path = request.path.removesuffix("/")
|
||||
page_type = path.split("/")[-1]
|
||||
|
||||
from corporate.views.remote_billing_page import (
|
||||
VALID_NEXT_PAGES as REMOTE_BILLING_VALID_NEXT_PAGES,
|
||||
)
|
||||
|
||||
if page_type in REMOTE_BILLING_VALID_NEXT_PAGES:
|
||||
return page_type
|
||||
|
||||
# page_type is not where we want user to go after a login, so just render the default page.
|
||||
return None # nocoverage
|
||||
|
||||
|
||||
def authenticated_remote_server_management_endpoint(
|
||||
view_func: Callable[
|
||||
Concatenate[HttpRequest, "RemoteServerBillingSession", ParamT], HttpResponse
|
||||
],
|
||||
) -> Callable[Concatenate[HttpRequest, ParamT], HttpResponse]:
|
||||
@wraps(view_func)
|
||||
def _wrapped_view_func(
|
||||
request: HttpRequest,
|
||||
/,
|
||||
*args: ParamT.args,
|
||||
**kwargs: ParamT.kwargs,
|
||||
) -> HttpResponse:
|
||||
from corporate.lib.stripe import RemoteServerBillingSession
|
||||
|
||||
if not is_self_hosting_management_subdomain(request): # nocoverage
|
||||
return render(request, "404.html", status=404)
|
||||
|
||||
server_uuid = kwargs.pop("server_uuid")
|
||||
if not isinstance(server_uuid, str):
|
||||
raise TypeError("server_uuid must be a string") # nocoverage
|
||||
|
||||
try:
|
||||
remote_server, remote_billing_user = get_remote_server_and_user_from_session(
|
||||
request, server_uuid=server_uuid
|
||||
)
|
||||
if remote_billing_user is None:
|
||||
# This should only be possible if the user hasn't finished the confirmation flow
|
||||
# and doesn't have a fully authenticated session yet. They should not be attempting
|
||||
# to access this endpoint yet.
|
||||
raise RemoteBillingAuthenticationError
|
||||
except (RemoteBillingIdentityExpiredError, RemoteBillingAuthenticationError):
|
||||
# In this flow, we can only redirect to our local "legacy server flow login" page.
|
||||
# That means that we can do it universally whether the user has an expired
|
||||
# identity_dict, or just lacks any form of authentication info at all - there
|
||||
# are no security concerns since this is just a local redirect.
|
||||
page_type = get_next_page_param_from_request_path(request)
|
||||
url = reverse(
|
||||
"remote_billing_legacy_server_login",
|
||||
query=None if page_type is None else {"next_page": page_type},
|
||||
)
|
||||
|
||||
# Return error for AJAX requests with url.
|
||||
if (
|
||||
request.get_preferred_type(["application/json", "text/html"]) != "text/html"
|
||||
): # nocoverage
|
||||
return session_expired_ajax_response(url)
|
||||
|
||||
return HttpResponseRedirect(url)
|
||||
|
||||
assert remote_billing_user is not None
|
||||
billing_session = RemoteServerBillingSession(
|
||||
remote_server, remote_billing_user=remote_billing_user
|
||||
)
|
||||
return view_func(request, billing_session, *args, **kwargs)
|
||||
|
||||
signature = inspect.signature(view_func)
|
||||
request_parameter, billing_session_parameter, *other_parameters = signature.parameters.values()
|
||||
_wrapped_view_func.__signature__ = signature.replace( # type: ignore[attr-defined] # too magic
|
||||
parameters=[request_parameter, *other_parameters]
|
||||
)
|
||||
_wrapped_view_func.__annotations__ = {
|
||||
k: v for k, v in view_func.__annotations__.items() if k != billing_session_parameter.name
|
||||
}
|
||||
|
||||
return _wrapped_view_func
|
||||
135
corporate/lib/registration.py
Normal file
135
corporate/lib/registration.py
Normal file
@@ -0,0 +1,135 @@
|
||||
from django.conf import settings
|
||||
from django.utils.translation import gettext as _
|
||||
|
||||
from corporate.lib.stripe import LicenseLimitError, get_latest_seat_count, get_seat_count
|
||||
from corporate.models.plans import CustomerPlan, get_current_plan_by_realm
|
||||
from zerver.actions.create_user import send_group_direct_message_to_admins
|
||||
from zerver.lib.exceptions import InvitationError, JsonableError
|
||||
from zerver.models import Realm, UserProfile
|
||||
from zerver.models.users import get_system_bot
|
||||
|
||||
|
||||
def get_plan_if_manual_license_management_enforced(realm: Realm) -> CustomerPlan | None:
|
||||
plan = get_current_plan_by_realm(realm)
|
||||
if plan is None or plan.automanage_licenses or plan.customer.exempt_from_license_number_check:
|
||||
return None
|
||||
return plan
|
||||
|
||||
|
||||
def generate_licenses_low_warning_message_if_required(realm: Realm) -> str | None:
|
||||
plan = get_plan_if_manual_license_management_enforced(realm)
|
||||
if plan is None:
|
||||
return None
|
||||
|
||||
licenses_remaining = plan.licenses() - get_latest_seat_count(realm)
|
||||
if licenses_remaining > 3:
|
||||
return None
|
||||
|
||||
format_kwargs = {
|
||||
"billing_page_link": "/billing/",
|
||||
"deactivate_user_help_page_link": "/help/deactivate-or-reactivate-a-user",
|
||||
}
|
||||
|
||||
if licenses_remaining <= 0:
|
||||
return _(
|
||||
"Your organization has no Zulip licenses remaining and can no longer accept new users. "
|
||||
"Please [increase the number of licenses]({billing_page_link}) or "
|
||||
"[deactivate inactive users]({deactivate_user_help_page_link}) to allow new users to join."
|
||||
).format(**format_kwargs)
|
||||
|
||||
return {
|
||||
1: _(
|
||||
"Your organization has only one Zulip license remaining. You can "
|
||||
"[increase the number of licenses]({billing_page_link}) or [deactivate inactive users]({deactivate_user_help_page_link}) "
|
||||
"to allow more than one user to join."
|
||||
),
|
||||
2: _(
|
||||
"Your organization has only two Zulip licenses remaining. You can "
|
||||
"[increase the number of licenses]({billing_page_link}) or [deactivate inactive users]({deactivate_user_help_page_link}) "
|
||||
"to allow more than two users to join."
|
||||
),
|
||||
3: _(
|
||||
"Your organization has only three Zulip licenses remaining. You can "
|
||||
"[increase the number of licenses]({billing_page_link}) or [deactivate inactive users]({deactivate_user_help_page_link}) "
|
||||
"to allow more than three users to join."
|
||||
),
|
||||
}[licenses_remaining].format(**format_kwargs)
|
||||
|
||||
|
||||
def send_user_unable_to_signup_group_direct_message_to_admins(
|
||||
realm: Realm, user_email: str
|
||||
) -> None:
|
||||
message = _(
|
||||
"A new user ({email}) was unable to join because your organization does not have enough "
|
||||
"Zulip licenses. To allow new users to join, make sure that the [number of licenses for "
|
||||
"the current and next billing period]({billing_page_link}) is greater than the current "
|
||||
"number of users."
|
||||
).format(
|
||||
email=user_email,
|
||||
billing_page_link="/billing/",
|
||||
)
|
||||
|
||||
send_group_direct_message_to_admins(
|
||||
get_system_bot(settings.NOTIFICATION_BOT, realm.id), realm, message
|
||||
)
|
||||
|
||||
|
||||
def check_spare_licenses_available(
|
||||
realm: Realm, plan: CustomerPlan, extra_non_guests_count: int = 0, extra_guests_count: int = 0
|
||||
) -> None:
|
||||
seat_count = get_seat_count(
|
||||
realm, extra_non_guests_count=extra_non_guests_count, extra_guests_count=extra_guests_count
|
||||
)
|
||||
current_licenses = plan.licenses()
|
||||
renewal_licenses = plan.licenses_at_next_renewal()
|
||||
if current_licenses < seat_count or (renewal_licenses and renewal_licenses < seat_count):
|
||||
raise LicenseLimitError
|
||||
|
||||
|
||||
def check_spare_licenses_available_for_registering_new_user(
|
||||
realm: Realm,
|
||||
user_email_to_add: str,
|
||||
role: int,
|
||||
) -> None:
|
||||
plan = get_plan_if_manual_license_management_enforced(realm)
|
||||
if plan is None:
|
||||
return
|
||||
|
||||
try:
|
||||
if role == UserProfile.ROLE_GUEST:
|
||||
check_spare_licenses_available(realm, plan, extra_guests_count=1)
|
||||
else:
|
||||
check_spare_licenses_available(realm, plan, extra_non_guests_count=1)
|
||||
except LicenseLimitError:
|
||||
send_user_unable_to_signup_group_direct_message_to_admins(realm, user_email_to_add)
|
||||
raise
|
||||
|
||||
|
||||
def check_spare_licenses_available_for_inviting_new_users(
|
||||
realm: Realm, extra_non_guests_count: int = 0, extra_guests_count: int = 0
|
||||
) -> None:
|
||||
plan = get_plan_if_manual_license_management_enforced(realm)
|
||||
if plan is None:
|
||||
return
|
||||
|
||||
try:
|
||||
check_spare_licenses_available(realm, plan, extra_non_guests_count, extra_guests_count)
|
||||
except LicenseLimitError:
|
||||
message = _(
|
||||
"Your organization does not have enough Zulip licenses. Invitations were not sent."
|
||||
)
|
||||
raise InvitationError(message, [], sent_invitations=False, license_limit_reached=True)
|
||||
|
||||
|
||||
def check_spare_license_available_for_changing_guest_user_role(realm: Realm) -> None:
|
||||
plan = get_plan_if_manual_license_management_enforced(realm)
|
||||
if plan is None:
|
||||
return
|
||||
|
||||
try:
|
||||
check_spare_licenses_available(realm, plan, extra_non_guests_count=1)
|
||||
except LicenseLimitError:
|
||||
error_message = _(
|
||||
"Your organization does not have enough Zulip licenses to change a guest user's role."
|
||||
)
|
||||
raise JsonableError(error_message)
|
||||
193
corporate/lib/remote_billing_util.py
Normal file
193
corporate/lib/remote_billing_util.py
Normal file
@@ -0,0 +1,193 @@
|
||||
import logging
|
||||
from typing import Literal, TypedDict, overload
|
||||
|
||||
from django.http import HttpRequest
|
||||
from django.utils.timezone import now as timezone_now
|
||||
from django.utils.translation import gettext as _
|
||||
|
||||
from zerver.lib.exceptions import JsonableError, RemoteBillingAuthenticationError
|
||||
from zerver.lib.timestamp import datetime_to_timestamp
|
||||
from zilencer.models import (
|
||||
RemoteRealm,
|
||||
RemoteRealmBillingUser,
|
||||
RemoteServerBillingUser,
|
||||
RemoteZulipServer,
|
||||
)
|
||||
|
||||
billing_logger = logging.getLogger("corporate.stripe")
|
||||
|
||||
# The sessions are relatively short-lived, so that we can avoid issues
|
||||
# with users who have their privileges revoked on the remote server
|
||||
# maintaining access to the billing page for too long.
|
||||
REMOTE_BILLING_SESSION_VALIDITY_SECONDS = 2 * 60 * 60
|
||||
|
||||
|
||||
class RemoteBillingUserDict(TypedDict):
|
||||
user_uuid: str
|
||||
user_email: str
|
||||
user_full_name: str
|
||||
|
||||
|
||||
class RemoteBillingIdentityDict(TypedDict):
|
||||
user: RemoteBillingUserDict
|
||||
remote_server_uuid: str
|
||||
remote_realm_uuid: str
|
||||
|
||||
remote_billing_user_id: int | None
|
||||
authenticated_at: int
|
||||
uri_scheme: Literal["http://", "https://"]
|
||||
|
||||
next_page: str | None
|
||||
|
||||
|
||||
class LegacyServerIdentityDict(TypedDict):
|
||||
# Currently this has only one field. We can extend this
|
||||
# to add more information as appropriate.
|
||||
remote_server_uuid: str
|
||||
|
||||
remote_billing_user_id: int | None
|
||||
authenticated_at: int
|
||||
|
||||
|
||||
class RemoteBillingIdentityExpiredError(Exception):
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
realm_uuid: str | None = None,
|
||||
server_uuid: str | None = None,
|
||||
uri_scheme: Literal["http://", "https://"] | None = None,
|
||||
) -> None:
|
||||
self.realm_uuid = realm_uuid
|
||||
self.server_uuid = server_uuid
|
||||
self.uri_scheme = uri_scheme
|
||||
|
||||
|
||||
@overload
|
||||
def get_identity_dict_from_session(
|
||||
request: HttpRequest,
|
||||
*,
|
||||
realm_uuid: str | None,
|
||||
server_uuid: None,
|
||||
) -> RemoteBillingIdentityDict | None: ...
|
||||
@overload
|
||||
def get_identity_dict_from_session(
|
||||
request: HttpRequest,
|
||||
*,
|
||||
realm_uuid: None,
|
||||
server_uuid: str | None,
|
||||
) -> LegacyServerIdentityDict | None: ...
|
||||
def get_identity_dict_from_session(
|
||||
request: HttpRequest,
|
||||
*,
|
||||
realm_uuid: str | None,
|
||||
server_uuid: str | None,
|
||||
) -> RemoteBillingIdentityDict | LegacyServerIdentityDict | None:
|
||||
if not (realm_uuid or server_uuid):
|
||||
return None
|
||||
|
||||
identity_dicts = request.session.get("remote_billing_identities")
|
||||
if identity_dicts is None:
|
||||
return None
|
||||
|
||||
if realm_uuid is not None:
|
||||
result = identity_dicts.get(f"remote_realm:{realm_uuid}")
|
||||
else:
|
||||
assert server_uuid is not None
|
||||
result = identity_dicts.get(f"remote_server:{server_uuid}")
|
||||
|
||||
if result is None:
|
||||
return None
|
||||
if (
|
||||
datetime_to_timestamp(timezone_now()) - result["authenticated_at"]
|
||||
> REMOTE_BILLING_SESSION_VALIDITY_SECONDS
|
||||
):
|
||||
# In this case we raise, because callers want to catch this as an explicitly
|
||||
# different scenario from the user not being authenticated, to handle it nicely
|
||||
# by redirecting them to their login page.
|
||||
raise RemoteBillingIdentityExpiredError(
|
||||
realm_uuid=result.get("remote_realm_uuid"),
|
||||
server_uuid=result.get("remote_server_uuid"),
|
||||
uri_scheme=result.get("uri_scheme"),
|
||||
)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def get_remote_realm_and_user_from_session(
|
||||
request: HttpRequest,
|
||||
realm_uuid: str | None,
|
||||
) -> tuple[RemoteRealm, RemoteRealmBillingUser]:
|
||||
identity_dict: RemoteBillingIdentityDict | None = get_identity_dict_from_session(
|
||||
request, realm_uuid=realm_uuid, server_uuid=None
|
||||
)
|
||||
|
||||
if identity_dict is None:
|
||||
raise RemoteBillingAuthenticationError
|
||||
|
||||
remote_server_uuid = identity_dict["remote_server_uuid"]
|
||||
remote_realm_uuid = identity_dict["remote_realm_uuid"]
|
||||
|
||||
try:
|
||||
remote_realm = RemoteRealm.objects.get(
|
||||
uuid=remote_realm_uuid, server__uuid=remote_server_uuid
|
||||
)
|
||||
except RemoteRealm.DoesNotExist:
|
||||
raise AssertionError(
|
||||
"The remote realm is missing despite being in the RemoteBillingIdentityDict"
|
||||
)
|
||||
|
||||
if (
|
||||
remote_realm.registration_deactivated
|
||||
or remote_realm.realm_deactivated
|
||||
or remote_realm.server.deactivated
|
||||
):
|
||||
raise JsonableError(_("Registration is deactivated"))
|
||||
|
||||
remote_billing_user_id = identity_dict["remote_billing_user_id"]
|
||||
# We only put IdentityDicts with remote_billing_user_id in the session in this flow,
|
||||
# because the RemoteRealmBillingUser already exists when this is inserted into the session
|
||||
# at the end of authentication.
|
||||
assert remote_billing_user_id is not None
|
||||
|
||||
try:
|
||||
remote_billing_user = RemoteRealmBillingUser.objects.get(
|
||||
id=remote_billing_user_id, remote_realm=remote_realm
|
||||
)
|
||||
except RemoteRealmBillingUser.DoesNotExist:
|
||||
raise AssertionError
|
||||
|
||||
return remote_realm, remote_billing_user
|
||||
|
||||
|
||||
def get_remote_server_and_user_from_session(
|
||||
request: HttpRequest,
|
||||
server_uuid: str,
|
||||
) -> tuple[RemoteZulipServer, RemoteServerBillingUser | None]:
|
||||
identity_dict: LegacyServerIdentityDict | None = get_identity_dict_from_session(
|
||||
request, realm_uuid=None, server_uuid=server_uuid
|
||||
)
|
||||
|
||||
if identity_dict is None:
|
||||
raise RemoteBillingAuthenticationError
|
||||
|
||||
remote_server_uuid = identity_dict["remote_server_uuid"]
|
||||
try:
|
||||
remote_server = RemoteZulipServer.objects.get(uuid=remote_server_uuid)
|
||||
except RemoteZulipServer.DoesNotExist:
|
||||
raise JsonableError(_("Invalid remote server."))
|
||||
|
||||
if remote_server.deactivated:
|
||||
raise JsonableError(_("Registration is deactivated"))
|
||||
|
||||
remote_billing_user_id = identity_dict.get("remote_billing_user_id")
|
||||
if remote_billing_user_id is None:
|
||||
return remote_server, None
|
||||
|
||||
try:
|
||||
remote_billing_user = RemoteServerBillingUser.objects.get(
|
||||
id=remote_billing_user_id, remote_server=remote_server
|
||||
)
|
||||
except RemoteServerBillingUser.DoesNotExist:
|
||||
remote_billing_user = None
|
||||
|
||||
return remote_server, remote_billing_user
|
||||
6221
corporate/lib/stripe.py
Normal file
6221
corporate/lib/stripe.py
Normal file
File diff suppressed because it is too large
Load Diff
222
corporate/lib/stripe_event_handler.py
Normal file
222
corporate/lib/stripe_event_handler.py
Normal file
@@ -0,0 +1,222 @@
|
||||
import logging
|
||||
from collections.abc import Callable
|
||||
from typing import Any
|
||||
|
||||
import stripe
|
||||
from django.conf import settings
|
||||
|
||||
from corporate.lib.stripe import (
|
||||
BILLING_SUPPORT_EMAIL,
|
||||
BillingError,
|
||||
RealmBillingSession,
|
||||
RemoteRealmBillingSession,
|
||||
RemoteServerBillingSession,
|
||||
get_configured_fixed_price_plan_offer,
|
||||
)
|
||||
from corporate.models.customers import Customer
|
||||
from corporate.models.plans import CustomerPlan, get_current_plan_by_customer
|
||||
from corporate.models.stripe_state import Event, Invoice, Session
|
||||
from zerver.lib.send_email import FromAddress, send_email
|
||||
from zerver.models.users import get_active_user_profile_by_id_in_realm
|
||||
|
||||
billing_logger = logging.getLogger("corporate.stripe")
|
||||
|
||||
|
||||
def stripe_event_handler_decorator(
|
||||
func: Callable[[Any, Any], None],
|
||||
) -> Callable[[stripe.checkout.Session | stripe.Invoice, Event], None]:
|
||||
def wrapper(
|
||||
stripe_object: stripe.checkout.Session | stripe.Invoice,
|
||||
event: Event,
|
||||
) -> None:
|
||||
event.status = Event.EVENT_HANDLER_STARTED
|
||||
event.save(update_fields=["status"])
|
||||
|
||||
try:
|
||||
func(stripe_object, event.content_object)
|
||||
except BillingError as e:
|
||||
billing_logger.warning(
|
||||
"BillingError in %s event handler: %s. stripe_object_id=%s, customer_id=%s metadata=%s",
|
||||
event.type,
|
||||
e.error_description,
|
||||
stripe_object.id,
|
||||
stripe_object.customer,
|
||||
(
|
||||
None
|
||||
if stripe_object.metadata is None
|
||||
else stripe_object.metadata.to_dict(for_json=True)
|
||||
),
|
||||
)
|
||||
event.status = Event.EVENT_HANDLER_FAILED
|
||||
event.handler_error = {
|
||||
"message": e.msg,
|
||||
"description": e.error_description,
|
||||
}
|
||||
event.save(update_fields=["status", "handler_error"])
|
||||
if isinstance(stripe_object, stripe.Invoice):
|
||||
# For Invoice processing errors, send email to billing support.
|
||||
message = f"BillingError in {event.type} event handler: {e.error_description}. stripe_object_id={stripe_object.id}, customer_id={stripe_object.customer} metadata={stripe_object.metadata}"
|
||||
send_email(
|
||||
"zerver/emails/error_processing_invoice",
|
||||
to_emails=[BILLING_SUPPORT_EMAIL],
|
||||
from_address=FromAddress.tokenized_no_reply_address(),
|
||||
context={
|
||||
"message": message,
|
||||
},
|
||||
)
|
||||
except Exception:
|
||||
billing_logger.exception(
|
||||
"Uncaught exception in %s event handler:",
|
||||
event.type,
|
||||
stack_info=True,
|
||||
)
|
||||
event.status = Event.EVENT_HANDLER_FAILED
|
||||
event.handler_error = {
|
||||
"description": f"uncaught exception in {event.type} event handler",
|
||||
"message": BillingError.CONTACT_SUPPORT.format(email=settings.ZULIP_ADMINISTRATOR),
|
||||
}
|
||||
event.save(update_fields=["status", "handler_error"])
|
||||
else:
|
||||
event.status = Event.EVENT_HANDLER_SUCCEEDED
|
||||
event.save()
|
||||
|
||||
return wrapper
|
||||
|
||||
|
||||
def get_billing_session_for_stripe_webhook(
|
||||
customer: Customer, user_id: str | None
|
||||
) -> RealmBillingSession | RemoteRealmBillingSession | RemoteServerBillingSession:
|
||||
if customer.remote_realm is not None:
|
||||
return RemoteRealmBillingSession(customer.remote_realm)
|
||||
elif customer.remote_server is not None:
|
||||
return RemoteServerBillingSession(customer.remote_server)
|
||||
else:
|
||||
assert customer.realm is not None
|
||||
if user_id:
|
||||
user = get_active_user_profile_by_id_in_realm(int(user_id), customer.realm)
|
||||
return RealmBillingSession(user)
|
||||
else:
|
||||
return RealmBillingSession(user=None, realm=customer.realm) # nocoverage
|
||||
|
||||
|
||||
@stripe_event_handler_decorator
|
||||
def handle_checkout_session_completed_event(
|
||||
stripe_session: stripe.checkout.Session, session: Session
|
||||
) -> None:
|
||||
session.status = Session.COMPLETED
|
||||
session.save()
|
||||
|
||||
assert isinstance(stripe_session.setup_intent, str)
|
||||
assert stripe_session.metadata is not None
|
||||
stripe_setup_intent = stripe.SetupIntent.retrieve(stripe_session.setup_intent)
|
||||
billing_session = get_billing_session_for_stripe_webhook(
|
||||
session.customer,
|
||||
stripe_session.metadata.user_id if "user_id" in stripe_session.metadata else None,
|
||||
)
|
||||
payment_method = stripe_setup_intent.payment_method
|
||||
assert isinstance(payment_method, str | None)
|
||||
|
||||
if session.type in [
|
||||
Session.CARD_UPDATE_FROM_BILLING_PAGE,
|
||||
Session.CARD_UPDATE_FROM_UPGRADE_PAGE,
|
||||
]:
|
||||
billing_session.update_or_create_stripe_customer(payment_method)
|
||||
|
||||
|
||||
@stripe_event_handler_decorator
|
||||
def handle_invoice_paid_event(stripe_invoice: stripe.Invoice, invoice: Invoice) -> None:
|
||||
invoice.status = Invoice.PAID
|
||||
invoice.save(update_fields=["status"])
|
||||
|
||||
customer = invoice.customer
|
||||
|
||||
configured_fixed_price_plan = None
|
||||
if customer.required_plan_tier:
|
||||
configured_fixed_price_plan = get_configured_fixed_price_plan_offer(
|
||||
customer, customer.required_plan_tier
|
||||
)
|
||||
|
||||
if (
|
||||
stripe_invoice.collection_method == "send_invoice"
|
||||
and configured_fixed_price_plan
|
||||
and configured_fixed_price_plan.sent_invoice_id == invoice.stripe_invoice_id
|
||||
):
|
||||
billing_session = get_billing_session_for_stripe_webhook(customer, user_id=None)
|
||||
complimentary_access_plan = billing_session.get_complimentary_access_plan(customer)
|
||||
assert customer.required_plan_tier is not None
|
||||
billing_session.process_initial_upgrade(
|
||||
plan_tier=customer.required_plan_tier,
|
||||
# TODO: Currently licenses don't play any role for fixed price plan.
|
||||
# We plan to introduce max_licenses allowed soon.
|
||||
licenses=0,
|
||||
automanage_licenses=True,
|
||||
billing_schedule=CustomerPlan.BILLING_SCHEDULE_ANNUAL,
|
||||
charge_automatically=False,
|
||||
free_trial=False,
|
||||
complimentary_access_plan=complimentary_access_plan,
|
||||
stripe_invoice_paid=True,
|
||||
)
|
||||
else:
|
||||
metadata = stripe_invoice.metadata
|
||||
# Only process upgrade required if metadata has the required keys.
|
||||
# This is a safeguard to avoid processing custom invoices.
|
||||
if (
|
||||
metadata is None or "billing_schedule" not in metadata or "plan_tier" not in metadata
|
||||
): # nocoverage
|
||||
return
|
||||
|
||||
billing_session = get_billing_session_for_stripe_webhook(
|
||||
customer, metadata.user_id if "user_id" in metadata else None
|
||||
)
|
||||
complimentary_access_plan = billing_session.get_complimentary_access_plan(customer)
|
||||
billing_schedule = int(metadata["billing_schedule"])
|
||||
plan_tier = int(metadata["plan_tier"])
|
||||
charge_automatically = stripe_invoice.collection_method != "send_invoice"
|
||||
if configured_fixed_price_plan and customer.required_plan_tier == plan_tier:
|
||||
assert customer.required_plan_tier is not None
|
||||
billing_session.process_initial_upgrade(
|
||||
plan_tier=customer.required_plan_tier,
|
||||
# TODO: Currently licenses don't play any role for fixed price plan.
|
||||
# We plan to introduce max_licenses allowed soon.
|
||||
licenses=0,
|
||||
automanage_licenses=True,
|
||||
billing_schedule=billing_schedule,
|
||||
charge_automatically=charge_automatically,
|
||||
free_trial=False,
|
||||
complimentary_access_plan=complimentary_access_plan,
|
||||
stripe_invoice_paid=True,
|
||||
)
|
||||
return
|
||||
elif (
|
||||
"on_free_trial" in metadata
|
||||
and metadata.on_free_trial == "True"
|
||||
and invoice.is_created_for_free_trial_upgrade
|
||||
):
|
||||
free_trial_plan = invoice.plan
|
||||
assert free_trial_plan is not None
|
||||
if free_trial_plan.is_free_trial():
|
||||
# We don't need to do anything here. When the free trial ends we will
|
||||
# check if user has paid the invoice, if not we downgrade the user.
|
||||
return
|
||||
|
||||
# If customer paid after end of free trial, we just upgrade via default method below.
|
||||
assert free_trial_plan.status == CustomerPlan.ENDED
|
||||
# Also check if customer is not on any other active plan.
|
||||
assert get_current_plan_by_customer(customer) is None
|
||||
|
||||
billing_session.process_initial_upgrade(
|
||||
plan_tier=plan_tier,
|
||||
licenses=int(metadata["licenses"]),
|
||||
automanage_licenses=metadata["license_management"] == "automatic",
|
||||
billing_schedule=billing_schedule,
|
||||
charge_automatically=charge_automatically,
|
||||
free_trial=False,
|
||||
complimentary_access_plan=complimentary_access_plan,
|
||||
stripe_invoice_paid=True,
|
||||
)
|
||||
|
||||
|
||||
@stripe_event_handler_decorator
|
||||
def handle_invoice_voided_event(stripe_invoice: stripe.Invoice, invoice: Invoice) -> None:
|
||||
invoice.status = Invoice.VOID
|
||||
invoice.save(update_fields=["status"])
|
||||
570
corporate/lib/support.py
Normal file
570
corporate/lib/support.py
Normal file
@@ -0,0 +1,570 @@
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Optional, TypedDict, Union
|
||||
|
||||
from django.db.models import Sum
|
||||
from django.utils.timezone import now as timezone_now
|
||||
|
||||
from corporate.lib.stripe import (
|
||||
BillingSession,
|
||||
RealmBillingSession,
|
||||
RemoteRealmBillingSession,
|
||||
RemoteServerBillingSession,
|
||||
get_configured_fixed_price_plan_offer,
|
||||
get_guest_user_count,
|
||||
get_non_guest_user_count,
|
||||
get_price_per_license,
|
||||
get_push_status_for_remote_request,
|
||||
start_of_next_billing_cycle,
|
||||
)
|
||||
from corporate.models.customers import Customer
|
||||
from corporate.models.licenses import LicenseLedger
|
||||
from corporate.models.plans import CustomerPlan, CustomerPlanOffer, get_current_plan_by_customer
|
||||
from corporate.models.sponsorships import ZulipSponsorshipRequest
|
||||
from zerver.actions.realm_settings import RealmDeactivationReasonType
|
||||
from zerver.lib.timestamp import timestamp_to_datetime
|
||||
from zerver.models import Realm, UserProfile
|
||||
from zerver.models.realm_audit_logs import AuditLogEventType, RealmAuditLog
|
||||
from zerver.models.realms import get_org_type_display_name
|
||||
from zilencer.lib.remote_counts import MissingDataError
|
||||
from zilencer.models import (
|
||||
RemoteCustomerUserCount,
|
||||
RemoteInstallationCount,
|
||||
RemotePushDeviceToken,
|
||||
RemoteRealm,
|
||||
RemoteRealmCount,
|
||||
RemoteServerBillingUser,
|
||||
RemoteServerDeactivationReasonType,
|
||||
RemoteZulipServer,
|
||||
RemoteZulipServerAuditLog,
|
||||
get_remote_realm_guest_and_non_guest_count,
|
||||
get_remote_server_guest_and_non_guest_count,
|
||||
has_stale_audit_log,
|
||||
)
|
||||
|
||||
USER_DATA_STALE_WARNING = "Recent audit log missing: No data for used licenses."
|
||||
|
||||
|
||||
class SponsorshipRequestDict(TypedDict):
|
||||
org_type: str
|
||||
org_website: str
|
||||
org_description: str
|
||||
plan_to_use_zulip: str
|
||||
total_users: str
|
||||
paid_users: str
|
||||
paid_users_description: str
|
||||
requested_plan: str
|
||||
|
||||
|
||||
@dataclass
|
||||
class SponsorshipData:
|
||||
sponsorship_pending: bool = False
|
||||
has_discount: bool = False
|
||||
monthly_discounted_price: int | None = None
|
||||
annual_discounted_price: int | None = None
|
||||
original_monthly_plan_price: int | None = None
|
||||
original_annual_plan_price: int | None = None
|
||||
minimum_licenses: int | None = None
|
||||
required_plan_tier: int | None = None
|
||||
latest_sponsorship_request: SponsorshipRequestDict | None = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class NextPlanData:
|
||||
plan: Union["CustomerPlan", "CustomerPlanOffer", None] = None
|
||||
estimated_revenue: int | None = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class PlanData:
|
||||
customer: Optional["Customer"] = None
|
||||
current_plan: Optional["CustomerPlan"] = None
|
||||
next_plan: Union["CustomerPlan", "CustomerPlanOffer", None] = None
|
||||
licenses: int | None = None
|
||||
licenses_used: int | None = None
|
||||
next_billing_cycle_start: datetime | None = None
|
||||
is_complimentary_access_plan: bool = False
|
||||
has_fixed_price: bool = False
|
||||
is_current_plan_billable: bool = False
|
||||
stripe_customer_url: str | None = None
|
||||
warning: str = ""
|
||||
annual_recurring_revenue: int | None = None
|
||||
estimated_next_plan_revenue: int | None = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class PushNotificationsStatus:
|
||||
can_push: bool
|
||||
expected_end: datetime | None
|
||||
message: str
|
||||
|
||||
|
||||
@dataclass
|
||||
class MobilePushData:
|
||||
total_mobile_users: int
|
||||
push_notification_status: PushNotificationsStatus
|
||||
uncategorized_mobile_users: int | None = None
|
||||
mobile_pushes_forwarded: int | None = None
|
||||
last_mobile_push_sent: str = ""
|
||||
|
||||
|
||||
@dataclass
|
||||
class DeactivationData:
|
||||
event_time: datetime
|
||||
acting_user: UserProfile | None
|
||||
billing_user: RemoteServerBillingUser | None
|
||||
reason: RealmDeactivationReasonType | RemoteServerDeactivationReasonType | None
|
||||
|
||||
|
||||
@dataclass
|
||||
class RemoteSupportData:
|
||||
date_created: datetime
|
||||
has_stale_audit_log: bool
|
||||
plan_data: PlanData
|
||||
sponsorship_data: SponsorshipData
|
||||
user_data: RemoteCustomerUserCount
|
||||
mobile_push_data: MobilePushData
|
||||
deactivation_data: DeactivationData | None
|
||||
|
||||
|
||||
@dataclass
|
||||
class UserData:
|
||||
guest_user_count: int
|
||||
non_guest_user_count: int
|
||||
|
||||
|
||||
@dataclass
|
||||
class CloudSupportData:
|
||||
plan_data: PlanData
|
||||
sponsorship_data: SponsorshipData
|
||||
user_data: UserData
|
||||
file_upload_usage: str
|
||||
is_scrubbed: bool
|
||||
deactivation_data: DeactivationData | None
|
||||
|
||||
|
||||
def get_stripe_customer_url(stripe_id: str) -> str:
|
||||
return f"https://dashboard.stripe.com/customers/{stripe_id}" # nocoverage
|
||||
|
||||
|
||||
def get_formatted_realm_upload_space_used(realm: Realm) -> str: # nocoverage
|
||||
realm_bytes_used = realm.currently_used_upload_space_bytes()
|
||||
files_uploaded = realm_bytes_used > 0
|
||||
|
||||
realm_uploads = "No uploads"
|
||||
if files_uploaded:
|
||||
realm_uploads = str(round(realm_bytes_used / 1024 / 1024, 2))
|
||||
|
||||
quota = realm.upload_quota_bytes()
|
||||
if quota is None:
|
||||
if files_uploaded:
|
||||
return f"{realm_uploads} MiB / No quota"
|
||||
return f"{realm_uploads} / No quota"
|
||||
if quota == 0:
|
||||
return f"{realm_uploads} / 0.0 MiB"
|
||||
quota_mb = round(quota / 1024 / 1024, 2)
|
||||
return f"{realm_uploads} / {quota_mb} MiB"
|
||||
|
||||
|
||||
def get_realm_user_data(realm: Realm) -> UserData:
|
||||
non_guests = get_non_guest_user_count(realm)
|
||||
guests = get_guest_user_count(realm)
|
||||
return UserData(
|
||||
guest_user_count=guests,
|
||||
non_guest_user_count=non_guests,
|
||||
)
|
||||
|
||||
|
||||
def get_customer_sponsorship_data(customer: Customer) -> SponsorshipData:
|
||||
pending = customer.sponsorship_pending
|
||||
licenses = customer.minimum_licenses
|
||||
plan_tier = customer.required_plan_tier
|
||||
has_discount = False
|
||||
sponsorship_request = None
|
||||
monthly_discounted_price = None
|
||||
annual_discounted_price = None
|
||||
original_monthly_plan_price = None
|
||||
original_annual_plan_price = None
|
||||
if customer.monthly_discounted_price:
|
||||
has_discount = True
|
||||
monthly_discounted_price = customer.monthly_discounted_price
|
||||
if customer.annual_discounted_price:
|
||||
has_discount = True
|
||||
annual_discounted_price = customer.annual_discounted_price
|
||||
if plan_tier is not None:
|
||||
original_monthly_plan_price = get_price_per_license(
|
||||
plan_tier, CustomerPlan.BILLING_SCHEDULE_MONTHLY
|
||||
)
|
||||
original_annual_plan_price = get_price_per_license(
|
||||
plan_tier, CustomerPlan.BILLING_SCHEDULE_ANNUAL
|
||||
)
|
||||
if pending:
|
||||
last_sponsorship_request = (
|
||||
ZulipSponsorshipRequest.objects.filter(customer=customer).order_by("id").last()
|
||||
)
|
||||
if last_sponsorship_request is not None:
|
||||
org_type_name = get_org_type_display_name(last_sponsorship_request.org_type)
|
||||
if (
|
||||
last_sponsorship_request.org_website is None
|
||||
or last_sponsorship_request.org_website == ""
|
||||
):
|
||||
website = "No website submitted"
|
||||
else:
|
||||
website = last_sponsorship_request.org_website
|
||||
sponsorship_request = SponsorshipRequestDict(
|
||||
org_type=org_type_name,
|
||||
org_website=website,
|
||||
org_description=last_sponsorship_request.org_description,
|
||||
total_users=last_sponsorship_request.expected_total_users,
|
||||
plan_to_use_zulip=last_sponsorship_request.plan_to_use_zulip,
|
||||
paid_users=last_sponsorship_request.paid_users_count,
|
||||
paid_users_description=last_sponsorship_request.paid_users_description,
|
||||
requested_plan=last_sponsorship_request.requested_plan,
|
||||
)
|
||||
|
||||
return SponsorshipData(
|
||||
sponsorship_pending=pending,
|
||||
has_discount=has_discount,
|
||||
monthly_discounted_price=monthly_discounted_price,
|
||||
annual_discounted_price=annual_discounted_price,
|
||||
original_monthly_plan_price=original_monthly_plan_price,
|
||||
original_annual_plan_price=original_annual_plan_price,
|
||||
minimum_licenses=licenses,
|
||||
required_plan_tier=plan_tier,
|
||||
latest_sponsorship_request=sponsorship_request,
|
||||
)
|
||||
|
||||
|
||||
def get_annual_invoice_count(billing_schedule: int) -> int:
|
||||
if billing_schedule == CustomerPlan.BILLING_SCHEDULE_MONTHLY:
|
||||
return 12
|
||||
else: # nocoverage
|
||||
return 1
|
||||
|
||||
|
||||
def get_next_plan_data(
|
||||
billing_session: BillingSession,
|
||||
customer: Customer,
|
||||
current_plan: CustomerPlan | None = None,
|
||||
) -> NextPlanData:
|
||||
plan_offer: CustomerPlanOffer | None = None
|
||||
|
||||
# A customer can have a CustomerPlanOffer with or without a current plan.
|
||||
if customer.required_plan_tier:
|
||||
plan_offer = get_configured_fixed_price_plan_offer(customer, customer.required_plan_tier)
|
||||
|
||||
if plan_offer is not None:
|
||||
next_plan_data = NextPlanData(plan=plan_offer)
|
||||
elif current_plan is not None:
|
||||
next_plan_data = NextPlanData(plan=billing_session.get_next_plan(current_plan))
|
||||
else:
|
||||
next_plan_data = NextPlanData()
|
||||
|
||||
if next_plan_data.plan is not None:
|
||||
if next_plan_data.plan.fixed_price is not None:
|
||||
next_plan_data.estimated_revenue = next_plan_data.plan.fixed_price
|
||||
return next_plan_data
|
||||
|
||||
if current_plan is not None:
|
||||
licenses_at_next_renewal = current_plan.licenses_at_next_renewal()
|
||||
if licenses_at_next_renewal is not None:
|
||||
assert type(next_plan_data.plan) is CustomerPlan
|
||||
assert next_plan_data.plan.price_per_license is not None
|
||||
invoice_count = get_annual_invoice_count(next_plan_data.plan.billing_schedule)
|
||||
next_plan_data.estimated_revenue = (
|
||||
next_plan_data.plan.price_per_license * licenses_at_next_renewal * invoice_count
|
||||
)
|
||||
else:
|
||||
next_plan_data.estimated_revenue = 0 # nocoverage
|
||||
return next_plan_data
|
||||
|
||||
return next_plan_data
|
||||
|
||||
|
||||
def get_plan_data_for_support_view(
|
||||
billing_session: BillingSession, user_count: int | None = None, stale_user_data: bool = False
|
||||
) -> PlanData:
|
||||
customer = billing_session.get_customer()
|
||||
plan = None
|
||||
if customer is not None:
|
||||
plan = get_current_plan_by_customer(customer)
|
||||
plan_data = PlanData(
|
||||
customer=customer,
|
||||
current_plan=plan,
|
||||
)
|
||||
|
||||
if plan_data.current_plan is not None:
|
||||
last_ledger_entry = (
|
||||
LicenseLedger.objects.filter(
|
||||
plan=plan_data.current_plan, event_time__lte=timezone_now()
|
||||
)
|
||||
.order_by("-id")
|
||||
.first()
|
||||
)
|
||||
|
||||
if last_ledger_entry is None: # nocoverage
|
||||
# This shouldn't be possible because at least one
|
||||
# license ledger entry should exist when a plan's
|
||||
# status is less than CustomerPlan.LIVE_STATUS_THRESHOLD.
|
||||
# But since we have a warning feature in the support
|
||||
# view for plan data, we use that instead of raising
|
||||
# an assertion error so that support staff can debug
|
||||
# if this does ever occur.
|
||||
plan_data.warning += "License ledger missing: No data for total licenses and revenue. "
|
||||
plan_data.licenses = None
|
||||
plan_data.annual_recurring_revenue = 0
|
||||
else:
|
||||
plan_data.licenses = last_ledger_entry.licenses
|
||||
plan_data.annual_recurring_revenue = (
|
||||
billing_session.get_annual_recurring_revenue_for_support_data(
|
||||
plan_data.current_plan, last_ledger_entry
|
||||
)
|
||||
)
|
||||
|
||||
# If we already have user count data, we use that
|
||||
# instead of querying the database again to get
|
||||
# the number of currently used licenses.
|
||||
if stale_user_data:
|
||||
plan_data.warning += USER_DATA_STALE_WARNING
|
||||
plan_data.licenses_used = None
|
||||
elif user_count is None:
|
||||
try:
|
||||
plan_data.licenses_used = (
|
||||
billing_session.current_counts_for_billed_users().workplace_users
|
||||
)
|
||||
except MissingDataError: # nocoverage
|
||||
plan_data.warning += USER_DATA_STALE_WARNING
|
||||
plan_data.licenses_used = None
|
||||
else: # nocoverage
|
||||
assert user_count is not None
|
||||
plan_data.licenses_used = user_count
|
||||
|
||||
if plan_data.current_plan.status in (
|
||||
CustomerPlan.FREE_TRIAL,
|
||||
CustomerPlan.DOWNGRADE_AT_END_OF_FREE_TRIAL,
|
||||
): # nocoverage
|
||||
assert plan_data.current_plan.next_invoice_date is not None
|
||||
plan_data.next_billing_cycle_start = plan_data.current_plan.next_invoice_date
|
||||
else:
|
||||
plan_data.next_billing_cycle_start = start_of_next_billing_cycle(
|
||||
plan_data.current_plan, timezone_now()
|
||||
)
|
||||
|
||||
if isinstance(billing_session, RealmBillingSession):
|
||||
# TODO implement a complimentary access plan/tier for Zulip Cloud.
|
||||
plan_data.is_complimentary_access_plan = False
|
||||
else:
|
||||
plan_data.is_complimentary_access_plan = (
|
||||
plan_data.current_plan.tier == CustomerPlan.TIER_SELF_HOSTED_LEGACY
|
||||
)
|
||||
plan_data.has_fixed_price = plan_data.current_plan.fixed_price is not None
|
||||
plan_data.is_current_plan_billable = billing_session.check_plan_tier_is_billable(
|
||||
plan_tier=plan_data.current_plan.tier
|
||||
)
|
||||
|
||||
# Check for a non-active/scheduled CustomerPlan or CustomerPlanOffer
|
||||
if customer is not None:
|
||||
next_plan_data = get_next_plan_data(billing_session, customer, plan_data.current_plan)
|
||||
plan_data.next_plan = next_plan_data.plan
|
||||
plan_data.estimated_next_plan_revenue = next_plan_data.estimated_revenue
|
||||
|
||||
# If customer has a stripe ID, add link to stripe customer dashboard
|
||||
if customer is not None and customer.stripe_customer_id is not None:
|
||||
plan_data.stripe_customer_url = get_stripe_customer_url(
|
||||
customer.stripe_customer_id
|
||||
) # nocoverage
|
||||
|
||||
return plan_data
|
||||
|
||||
|
||||
def get_mobile_push_data(remote_entity: RemoteZulipServer | RemoteRealm) -> MobilePushData:
|
||||
if isinstance(remote_entity, RemoteZulipServer):
|
||||
total_users = (
|
||||
RemotePushDeviceToken.objects.filter(server=remote_entity)
|
||||
.distinct("user_id", "user_uuid")
|
||||
.count()
|
||||
)
|
||||
uncategorized_users = (
|
||||
RemotePushDeviceToken.objects.filter(server=remote_entity, remote_realm__isnull=True)
|
||||
.distinct("user_id", "user_uuid")
|
||||
.count()
|
||||
)
|
||||
mobile_pushes = RemoteInstallationCount.objects.filter(
|
||||
server=remote_entity,
|
||||
property="mobile_pushes_forwarded::day",
|
||||
subgroup=None,
|
||||
end_time__gte=timezone_now() - timedelta(days=7),
|
||||
).aggregate(total_forwarded=Sum("value", default=0))
|
||||
latest_remote_server_push_forwarded_count = RemoteInstallationCount.objects.filter(
|
||||
server=remote_entity,
|
||||
subgroup=None,
|
||||
property="mobile_pushes_forwarded::day",
|
||||
).last()
|
||||
if latest_remote_server_push_forwarded_count is not None: # nocoverage
|
||||
# mobile_pushes_forwarded is a CountStat with a day frequency,
|
||||
# so we want to show the start of the latest day interval.
|
||||
push_forwarded_interval_start = (
|
||||
(latest_remote_server_push_forwarded_count.end_time - timedelta(days=1))
|
||||
.date()
|
||||
.isoformat()
|
||||
)
|
||||
else:
|
||||
push_forwarded_interval_start = "None"
|
||||
push_status = get_push_status_for_remote_request(
|
||||
remote_server=remote_entity, remote_realm=None
|
||||
)
|
||||
push_notification_status = PushNotificationsStatus(
|
||||
can_push=push_status.can_push,
|
||||
expected_end=timestamp_to_datetime(push_status.expected_end_timestamp)
|
||||
if push_status.expected_end_timestamp
|
||||
else None,
|
||||
message=push_status.message,
|
||||
)
|
||||
return MobilePushData(
|
||||
total_mobile_users=total_users,
|
||||
push_notification_status=push_notification_status,
|
||||
uncategorized_mobile_users=uncategorized_users,
|
||||
mobile_pushes_forwarded=mobile_pushes["total_forwarded"],
|
||||
last_mobile_push_sent=push_forwarded_interval_start,
|
||||
)
|
||||
else:
|
||||
assert isinstance(remote_entity, RemoteRealm)
|
||||
mobile_users = (
|
||||
RemotePushDeviceToken.objects.filter(remote_realm=remote_entity)
|
||||
.distinct("user_id", "user_uuid")
|
||||
.count()
|
||||
)
|
||||
mobile_pushes = RemoteRealmCount.objects.filter(
|
||||
remote_realm=remote_entity,
|
||||
property="mobile_pushes_forwarded::day",
|
||||
subgroup=None,
|
||||
end_time__gte=timezone_now() - timedelta(days=7),
|
||||
).aggregate(total_forwarded=Sum("value", default=0))
|
||||
latest_remote_realm_push_forwarded_count = RemoteRealmCount.objects.filter(
|
||||
remote_realm=remote_entity,
|
||||
subgroup=None,
|
||||
property="mobile_pushes_forwarded::day",
|
||||
).last()
|
||||
if latest_remote_realm_push_forwarded_count is not None: # nocoverage
|
||||
# mobile_pushes_forwarded is a CountStat with a day frequency,
|
||||
# so we want to show the start of the latest day interval.
|
||||
push_forwarded_interval_start = (
|
||||
(latest_remote_realm_push_forwarded_count.end_time - timedelta(days=1))
|
||||
.date()
|
||||
.isoformat()
|
||||
)
|
||||
else:
|
||||
push_forwarded_interval_start = "None"
|
||||
push_status = get_push_status_for_remote_request(remote_entity.server, remote_entity)
|
||||
push_notification_status = PushNotificationsStatus(
|
||||
can_push=push_status.can_push,
|
||||
expected_end=timestamp_to_datetime(push_status.expected_end_timestamp)
|
||||
if push_status.expected_end_timestamp
|
||||
else None,
|
||||
message=push_status.message,
|
||||
)
|
||||
return MobilePushData(
|
||||
total_mobile_users=mobile_users,
|
||||
push_notification_status=push_notification_status,
|
||||
uncategorized_mobile_users=None,
|
||||
mobile_pushes_forwarded=mobile_pushes["total_forwarded"],
|
||||
last_mobile_push_sent=push_forwarded_interval_start,
|
||||
)
|
||||
|
||||
|
||||
def get_deactivation_data(audit_log: RealmAuditLog | RemoteZulipServerAuditLog) -> DeactivationData:
|
||||
event_time = audit_log.event_time
|
||||
|
||||
acting_user = None
|
||||
billing_user = None
|
||||
reason = None
|
||||
if isinstance(audit_log, RealmAuditLog):
|
||||
if audit_log.acting_user:
|
||||
acting_user = audit_log.acting_user
|
||||
if audit_log.extra_data:
|
||||
reason = audit_log.extra_data.get("deactivation_reason", None)
|
||||
else:
|
||||
assert isinstance(audit_log, RemoteZulipServerAuditLog)
|
||||
if audit_log.acting_remote_user:
|
||||
billing_user = audit_log.acting_remote_user
|
||||
elif audit_log.acting_support_user:
|
||||
acting_user = audit_log.acting_support_user
|
||||
if audit_log.extra_data:
|
||||
reason = audit_log.extra_data.get("deactivation_reason", None)
|
||||
|
||||
return DeactivationData(
|
||||
event_time=event_time,
|
||||
acting_user=acting_user,
|
||||
billing_user=billing_user,
|
||||
reason=reason,
|
||||
)
|
||||
|
||||
|
||||
def get_data_for_remote_support_view(billing_session: BillingSession) -> RemoteSupportData:
|
||||
deactivation_data = None
|
||||
if isinstance(billing_session, RemoteServerBillingSession):
|
||||
user_data = get_remote_server_guest_and_non_guest_count(billing_session.remote_server.id)
|
||||
stale_audit_log_data = has_stale_audit_log(billing_session.remote_server)
|
||||
date_created = RemoteZulipServerAuditLog.objects.get(
|
||||
event_type=AuditLogEventType.REMOTE_SERVER_CREATED,
|
||||
server__id=billing_session.remote_server.id,
|
||||
).event_time
|
||||
mobile_data = get_mobile_push_data(billing_session.remote_server)
|
||||
if billing_session.remote_server.deactivated:
|
||||
deactivation_audit_log = RemoteZulipServerAuditLog.objects.filter(
|
||||
server=billing_session.remote_server,
|
||||
event_type=AuditLogEventType.REMOTE_SERVER_DEACTIVATED,
|
||||
).last()
|
||||
if deactivation_audit_log:
|
||||
deactivation_data = get_deactivation_data(deactivation_audit_log)
|
||||
else:
|
||||
assert isinstance(billing_session, RemoteRealmBillingSession)
|
||||
user_data = get_remote_realm_guest_and_non_guest_count(billing_session.remote_realm)
|
||||
stale_audit_log_data = has_stale_audit_log(billing_session.remote_realm.server)
|
||||
date_created = billing_session.remote_realm.realm_date_created
|
||||
mobile_data = get_mobile_push_data(billing_session.remote_realm)
|
||||
user_count = user_data.guest_user_count + user_data.non_guest_user_count
|
||||
plan_data = get_plan_data_for_support_view(billing_session, user_count, stale_audit_log_data)
|
||||
if plan_data.customer is not None:
|
||||
sponsorship_data = get_customer_sponsorship_data(plan_data.customer)
|
||||
else:
|
||||
sponsorship_data = SponsorshipData()
|
||||
|
||||
return RemoteSupportData(
|
||||
date_created=date_created,
|
||||
has_stale_audit_log=stale_audit_log_data,
|
||||
plan_data=plan_data,
|
||||
sponsorship_data=sponsorship_data,
|
||||
user_data=user_data,
|
||||
mobile_push_data=mobile_data,
|
||||
deactivation_data=deactivation_data,
|
||||
)
|
||||
|
||||
|
||||
def get_data_for_cloud_support_view(billing_session: BillingSession) -> CloudSupportData:
|
||||
assert isinstance(billing_session, RealmBillingSession)
|
||||
user_data = get_realm_user_data(billing_session.realm)
|
||||
plan_data = get_plan_data_for_support_view(billing_session)
|
||||
if plan_data.customer is not None:
|
||||
sponsorship_data = get_customer_sponsorship_data(plan_data.customer)
|
||||
else:
|
||||
sponsorship_data = SponsorshipData()
|
||||
|
||||
deactivation_data = None
|
||||
if billing_session.realm.deactivated:
|
||||
deactivation_audit_log = RealmAuditLog.objects.filter(
|
||||
realm=billing_session.realm, event_type=AuditLogEventType.REALM_DEACTIVATED
|
||||
).last()
|
||||
if deactivation_audit_log:
|
||||
deactivation_data = get_deactivation_data(deactivation_audit_log)
|
||||
|
||||
return CloudSupportData(
|
||||
plan_data=plan_data,
|
||||
sponsorship_data=sponsorship_data,
|
||||
user_data=user_data,
|
||||
file_upload_usage=get_formatted_realm_upload_space_used(billing_session.realm),
|
||||
is_scrubbed=RealmAuditLog.objects.filter(
|
||||
realm=billing_session.realm, event_type=AuditLogEventType.REALM_SCRUBBED
|
||||
).exists(),
|
||||
deactivation_data=deactivation_data,
|
||||
)
|
||||
1059
corporate/lib/test_stripe_class.py
Normal file
1059
corporate/lib/test_stripe_class.py
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user