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

This commit is contained in:
upstream-mirror
2026-09-15 15:05:30 +05:00
commit 64898b2c79
8999 changed files with 1757358 additions and 0 deletions

0
corporate/__init__.py Normal file
View File

View File

422
corporate/lib/activity.py Normal file
View 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

View 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

View 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
View 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

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

View 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

File diff suppressed because it is too large Load Diff

View 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
View 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,
)

File diff suppressed because it is too large Load Diff

View File

View File

@@ -0,0 +1,88 @@
from typing import Any
from django.conf import settings
from django.core.management.base import CommandError, CommandParser
from typing_extensions import override
from zerver.lib.timestamp import timestamp_to_datetime
if settings.BILLING_ENABLED:
from corporate.lib.billing_management import BillingSessionCommand
from corporate.lib.stripe import BillingError, get_configured_fixed_price_plan_offer
from corporate.models.plans import CustomerPlan
class Command(BillingSessionCommand):
help = """
Initialize a paid fixed-price plan for a billing customer (Realm, RemoteRealm or RemoteZulipServer).
Defaults to `--dry-run=True` so that the billing changes are run in preview mode first.
"""
@override
def add_arguments(self, parser: CommandParser) -> None:
parser.add_argument(
"--plan-tier",
dest="plan_tier",
type=int,
required=True,
help="The CustomerPlan tier for the fixed-price plan.",
)
parser.add_argument(
"--billing-anchor",
dest="billing_cycle_anchor",
type=float,
required=False,
help="Adjusted billing cycle anchor timestamp. Must be in the past.",
)
self.add_billing_entity_args(parser)
parser.add_argument(
"--dry-run",
dest="dry_run",
action="store_true",
default=True,
required=False,
help="Check for errors before initializing paid fixed-price plan. Default value is True.",
)
@override
def handle(self, *args: Any, **options: Any) -> None:
if not settings.BILLING_ENABLED:
raise CommandError("Billing system not enabled.")
plan_tier = options["plan_tier"]
if plan_tier not in CustomerPlan.PAID_PLAN_TIERS:
raise CommandError("Invalid tier for paid plan.")
billing_cycle_anchor = None
if options["billing_cycle_anchor"]:
anchor_timestamp = options["billing_cycle_anchor"]
billing_cycle_anchor = timestamp_to_datetime(anchor_timestamp)
billing_session = self.get_billing_session_from_args(options)
if options["dry_run"]:
try:
billing_session.check_can_configure_prepaid_fixed_price_plan(plan_tier)
customer = billing_session.get_customer()
assert customer is not None
fixed_price_plan_offer = get_configured_fixed_price_plan_offer(customer, plan_tier)
assert fixed_price_plan_offer is not None
anchor_date_string = (
billing_cycle_anchor.strftime("%B %d, %Y") if billing_cycle_anchor else "today"
)
print(
f"Will initialize {fixed_price_plan_offer} with anchor date of {anchor_date_string}."
)
return
except BillingError as e:
raise CommandError(e.msg)
except AssertionError as e:
raise CommandError(e)
else:
try:
billing_session.initialize_prepaid_fixed_price_plan(plan_tier, billing_cycle_anchor)
print("Done! Check support panel for customer to review active fixed-price plan.")
except BillingError as e:
raise CommandError(e.msg)
except AssertionError as e:
raise CommandError(e)

View File

@@ -0,0 +1,78 @@
from typing import Any
from django.conf import settings
from django.core.management.base import CommandError, CommandParser
from typing_extensions import override
if settings.BILLING_ENABLED:
from corporate.lib.billing_management import BillingSessionCommand
from corporate.lib.stripe import BillingError, stripe_get_customer
from corporate.models.plans import get_current_plan_by_customer
class Command(BillingSessionCommand):
help = """Link a Customer object to a Stripe customer ID."""
@override
def add_arguments(self, parser: CommandParser) -> None:
parser.add_argument(
"--stripe-id",
dest="stripe_id",
required=True,
help="The ID of the customer in Stripe.",
)
self.add_billing_entity_args(parser)
@override
def handle(self, *args: Any, **options: Any) -> None:
if not settings.BILLING_ENABLED:
raise CommandError("Billing system not enabled.")
stripe_id = options["stripe_id"]
try:
stripe_get_customer(stripe_id)
except BillingError:
raise CommandError(f"Error checking for Stripe Customer with ID {stripe_id}. Aborting.")
billing_session = self.get_billing_session_from_args(options)
customer = billing_session.get_customer()
if customer is None:
print(f"No Customer object for {billing_session.billing_entity_display_name}.")
no_customer_object_prompt = input(
f"Do you want to create one and link it to Stripe customer with ID {stripe_id}? [Y/n]"
)
print()
if not (
no_customer_object_prompt.lower() == "y"
or no_customer_object_prompt.lower() == ""
or no_customer_object_prompt.lower() == "yes"
):
return
print("Creating Customer object...")
customer = billing_session.update_or_create_customer()
print(f"Linking {customer} to Stripe customer with ID {stripe_id}...")
billing_session.link_stripe_customer_id(stripe_id)
print("Done!")
return
plan = get_current_plan_by_customer(customer)
if plan is not None and plan.is_a_paid_plan():
raise CommandError(f"{customer} has an active paid plan! Aborting.")
if customer.stripe_customer_id is not None:
existing_id_prompt = input(
f"Do you want to overwrite the current stripe_customer_id for {customer}? [Y/n]"
)
print()
if not (
existing_id_prompt.lower() == "y"
or existing_id_prompt.lower() == ""
or existing_id_prompt.lower() == "yes"
):
return
print(f"Linking {customer} to Stripe customer with ID {stripe_id}...")
billing_session.link_stripe_customer_id(stripe_id)
print("Done!")

View File

@@ -0,0 +1,85 @@
# Generated by Django 1.11.14 on 2018-09-25 12:02
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
("zerver", "0189_userprofile_add_some_emojisets"),
]
operations = [
migrations.CreateModel(
name="BillingProcessor",
fields=[
(
"id",
models.AutoField(
auto_created=True, primary_key=True, serialize=False, verbose_name="ID"
),
),
("state", models.CharField(max_length=20)),
("last_modified", models.DateTimeField(auto_now=True)),
(
"log_row",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE, to="zerver.RealmAuditLog"
),
),
(
"realm",
models.OneToOneField(
null=True, on_delete=django.db.models.deletion.CASCADE, to="zerver.Realm"
),
),
],
),
migrations.CreateModel(
name="Coupon",
fields=[
(
"id",
models.AutoField(
auto_created=True, primary_key=True, serialize=False, verbose_name="ID"
),
),
("percent_off", models.SmallIntegerField(unique=True)),
("stripe_coupon_id", models.CharField(max_length=255, unique=True)),
],
),
migrations.CreateModel(
name="Customer",
fields=[
(
"id",
models.AutoField(
auto_created=True, primary_key=True, serialize=False, verbose_name="ID"
),
),
("stripe_customer_id", models.CharField(max_length=255, unique=True)),
("has_billing_relationship", models.BooleanField(default=False)),
(
"realm",
models.OneToOneField(
on_delete=django.db.models.deletion.CASCADE, to="zerver.Realm"
),
),
],
),
migrations.CreateModel(
name="Plan",
fields=[
(
"id",
models.AutoField(
auto_created=True, primary_key=True, serialize=False, verbose_name="ID"
),
),
("nickname", models.CharField(max_length=40, unique=True)),
("stripe_plan_id", models.CharField(max_length=255, unique=True)),
],
),
]

View File

@@ -0,0 +1,378 @@
# Generated by Django 5.0.7 on 2024-08-13 20:29
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
replaces = [
("corporate", "0001_initial"),
("corporate", "0002_customer_default_discount"),
("corporate", "0003_customerplan"),
("corporate", "0004_licenseledger"),
("corporate", "0005_customerplan_invoicing"),
("corporate", "0006_nullable_stripe_customer_id"),
("corporate", "0007_remove_deprecated_fields"),
("corporate", "0008_nullable_next_invoice_date"),
("corporate", "0009_customer_sponsorship_pending"),
("corporate", "0010_customerplan_exempt_from_from_license_number_check"),
("corporate", "0011_move_exempt_from_from_license_number_check_to_customer_model"),
("corporate", "0012_zulipsponsorshiprequest"),
("corporate", "0013_alter_zulipsponsorshiprequest_org_website"),
("corporate", "0014_customerplan_end_date"),
("corporate", "0015_event_paymentintent_session"),
("corporate", "0016_customer_add_remote_server_field"),
(
"corporate",
"0017_rename_exempt_from_from_license_number_check_customer_exempt_from_license_number_check",
),
("corporate", "0018_customer_cloud_xor_self_hosted"),
("corporate", "0019_zulipsponsorshiprequest_expected_total_users_and_more"),
("corporate", "0020_add_remote_realm_customers"),
("corporate", "0021_remove_session_payment_intent"),
("corporate", "0022_session_is_manual_license_management_upgrade_session"),
("corporate", "0023_zulipsponsorshiprequest_customer"),
("corporate", "0024_zulipsponsorshiprequest_fill_customer_data"),
("corporate", "0025_alter_zulipsponsorshiprequest_customer"),
("corporate", "0026_remove_zulipsponsorshiprequest_realm"),
("corporate", "0027_alter_zulipsponsorshiprequest_requested_by"),
("corporate", "0028_zulipsponsorshiprequest_requested_plan"),
("corporate", "0029_session_tier"),
("corporate", "0030_alter_zulipsponsorshiprequest_requested_plan"),
("corporate", "0031_customer_flat_discount_and_more"),
("corporate", "0032_customer_minimum_licenses"),
("corporate", "0033_customerplan_invoice_overdue_email_sent"),
("corporate", "0034_customer_discount_required_tier"),
("corporate", "0035_update_legacy_plan_next_invoice_date"),
("corporate", "0036_fix_customer_plans_scheduled_after_legacy_plan"),
("corporate", "0037_customerplanoffer"),
("corporate", "0038_customerplanoffer_sent_invoice_id_invoice"),
("corporate", "0039_backfill_end_date_for_fixed_price_plans"),
("corporate", "0040_customerplan_reminder_to_review_plan_email_sent"),
("corporate", "0041_fix_plans_on_free_trial_with_changes_in_schedule"),
("corporate", "0042_invoice_is_created_for_free_trial_upgrade_and_more"),
("corporate", "0043_remove_customer_default_discount_and_more"),
("corporate", "0044_convert_ids_to_bigints"),
]
initial = True
dependencies = [
("contenttypes", "0002_remove_content_type_name"),
("zerver", "0001_initial"),
# Requires RemoteRealm model for foreign keys.
("zilencer", "0035_remoterealmcount_remote_realm_and_more"),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name="Customer",
fields=[
(
"id",
models.BigAutoField(
auto_created=True, primary_key=True, serialize=False, verbose_name="ID"
),
),
("stripe_customer_id", models.CharField(max_length=255, null=True, unique=True)),
(
"realm",
models.OneToOneField(
null=True, on_delete=django.db.models.deletion.CASCADE, to="zerver.realm"
),
),
("sponsorship_pending", models.BooleanField(default=False)),
("exempt_from_license_number_check", models.BooleanField(default=False)),
(
"remote_server",
models.OneToOneField(
null=True,
on_delete=django.db.models.deletion.CASCADE,
to="zilencer.remotezulipserver",
),
),
(
"remote_realm",
models.OneToOneField(
null=True,
on_delete=django.db.models.deletion.CASCADE,
to="zilencer.remoterealm",
),
),
("flat_discount", models.IntegerField(default=2000)),
("flat_discounted_months", models.IntegerField(default=0)),
("minimum_licenses", models.PositiveIntegerField(null=True)),
("required_plan_tier", models.SmallIntegerField(null=True)),
("annual_discounted_price", models.IntegerField(default=0)),
("monthly_discounted_price", models.IntegerField(default=0)),
],
),
migrations.CreateModel(
name="CustomerPlan",
fields=[
(
"id",
models.BigAutoField(
auto_created=True, primary_key=True, serialize=False, verbose_name="ID"
),
),
("automanage_licenses", models.BooleanField(default=False)),
("charge_automatically", models.BooleanField(default=False)),
("price_per_license", models.IntegerField(null=True)),
("fixed_price", models.IntegerField(null=True)),
("discount", models.TextField(null=True)),
("billing_cycle_anchor", models.DateTimeField()),
("billing_schedule", models.SmallIntegerField()),
("next_invoice_date", models.DateTimeField(db_index=True, null=True)),
("tier", models.SmallIntegerField()),
("status", models.SmallIntegerField(default=1)),
(
"customer",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE, to="corporate.customer"
),
),
("invoicing_status", models.SmallIntegerField(default=1)),
("end_date", models.DateTimeField(null=True)),
("invoice_overdue_email_sent", models.BooleanField(default=False)),
("reminder_to_review_plan_email_sent", models.BooleanField(default=False)),
],
),
migrations.CreateModel(
name="CustomerPlanOffer",
fields=[
(
"id",
models.BigAutoField(
auto_created=True, primary_key=True, serialize=False, verbose_name="ID"
),
),
("fixed_price", models.IntegerField(null=True)),
("tier", models.SmallIntegerField()),
("status", models.SmallIntegerField()),
(
"customer",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE, to="corporate.customer"
),
),
("sent_invoice_id", models.CharField(max_length=255, null=True)),
],
options={
"abstract": False,
},
),
migrations.CreateModel(
name="Event",
fields=[
(
"id",
models.BigAutoField(
auto_created=True, primary_key=True, serialize=False, verbose_name="ID"
),
),
("stripe_event_id", models.CharField(max_length=255)),
("type", models.CharField(max_length=255)),
("status", models.SmallIntegerField(default=1)),
("object_id", models.PositiveIntegerField(db_index=True)),
("handler_error", models.JSONField(default=None, null=True)),
(
"content_type",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE, to="contenttypes.contenttype"
),
),
],
),
migrations.CreateModel(
name="Invoice",
fields=[
(
"id",
models.BigAutoField(
auto_created=True, primary_key=True, serialize=False, verbose_name="ID"
),
),
("stripe_invoice_id", models.CharField(max_length=255, unique=True)),
("status", models.SmallIntegerField()),
(
"customer",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE, to="corporate.customer"
),
),
("is_created_for_free_trial_upgrade", models.BooleanField(default=False)),
(
"plan",
models.ForeignKey(
default=None,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
to="corporate.customerplan",
),
),
],
),
migrations.CreateModel(
name="LicenseLedger",
fields=[
(
"id",
models.BigAutoField(
auto_created=True, primary_key=True, serialize=False, verbose_name="ID"
),
),
("is_renewal", models.BooleanField(default=False)),
("event_time", models.DateTimeField()),
("licenses", models.IntegerField()),
("licenses_at_next_renewal", models.IntegerField(null=True)),
(
"plan",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE, to="corporate.customerplan"
),
),
],
),
migrations.CreateModel(
name="PaymentIntent",
fields=[
(
"id",
models.BigAutoField(
auto_created=True, primary_key=True, serialize=False, verbose_name="ID"
),
),
("stripe_payment_intent_id", models.CharField(max_length=255, unique=True)),
("status", models.SmallIntegerField()),
("last_payment_error", models.JSONField(default=None, null=True)),
(
"customer",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE, to="corporate.customer"
),
),
],
),
migrations.CreateModel(
name="Session",
fields=[
(
"id",
models.BigAutoField(
auto_created=True, primary_key=True, serialize=False, verbose_name="ID"
),
),
("stripe_session_id", models.CharField(max_length=255, unique=True)),
("type", models.SmallIntegerField()),
("status", models.SmallIntegerField(default=1)),
(
"customer",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE, to="corporate.customer"
),
),
(
"is_manual_license_management_upgrade_session",
models.BooleanField(default=False),
),
("tier", models.SmallIntegerField(null=True)),
],
),
migrations.CreateModel(
name="ZulipSponsorshipRequest",
fields=[
(
"id",
models.BigAutoField(
auto_created=True, primary_key=True, serialize=False, verbose_name="ID"
),
),
(
"org_type",
models.PositiveSmallIntegerField(
choices=[
(0, "Unspecified"),
(10, "Business"),
(20, "Open-source project"),
(30, "Education (non-profit)"),
(35, "Education (for-profit)"),
(40, "Research"),
(50, "Event or conference"),
(60, "Non-profit (registered)"),
(70, "Government"),
(80, "Political group"),
(90, "Community"),
(100, "Personal"),
(1000, "Other"),
],
default=0,
),
),
("org_website", models.URLField(blank=True, null=True)),
("org_description", models.TextField(default="")),
(
"requested_by",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.CASCADE,
to=settings.AUTH_USER_MODEL,
),
),
("expected_total_users", models.TextField(default="")),
("paid_users_count", models.TextField(default="")),
("paid_users_description", models.TextField(default="")),
(
"customer",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE, to="corporate.customer"
),
),
(
"requested_plan",
models.CharField(
choices=[
("", "UNSPECIFIED"),
("Community", "COMMUNITY"),
("Basic", "BASIC"),
("Business", "BUSINESS"),
],
default="",
max_length=50,
),
),
],
),
# Operation cannot be squashed, because it's a reverse foreign
# key (`LicenseLedger` has a `CustomerPlan` column), and
# squashing it into the CreateModel would require creating a
# dependency loop.
migrations.AddField(
model_name="customerplan",
name="invoiced_through",
field=models.ForeignKey(
null=True,
on_delete=django.db.models.deletion.CASCADE,
related_name="+",
to="corporate.LicenseLedger",
),
),
# Django's squashmigrations tooling seems unable to squash
# this operation into CreateModel. Likely it's possible to do
# so manually, but it's just a single operation.
migrations.AddConstraint(
model_name="customer",
constraint=models.CheckConstraint(
condition=models.Q(
("realm__isnull", False),
("remote_server__isnull", False),
("remote_realm__isnull", False),
_connector="OR",
),
name="has_associated_model_object",
),
),
]

View File

@@ -0,0 +1,17 @@
# Generated by Django 1.11.16 on 2018-12-12 20:19
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("corporate", "0001_initial"),
]
operations = [
migrations.AddField(
model_name="customer",
name="default_discount",
field=models.DecimalField(decimal_places=4, max_digits=7, null=True),
),
]

View File

@@ -0,0 +1,42 @@
# Generated by Django 1.11.16 on 2018-12-22 21:05
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("corporate", "0002_customer_default_discount"),
]
operations = [
migrations.CreateModel(
name="CustomerPlan",
fields=[
(
"id",
models.AutoField(
auto_created=True, primary_key=True, serialize=False, verbose_name="ID"
),
),
("licenses", models.IntegerField()),
("automanage_licenses", models.BooleanField(default=False)),
("charge_automatically", models.BooleanField(default=False)),
("price_per_license", models.IntegerField(null=True)),
("fixed_price", models.IntegerField(null=True)),
("discount", models.DecimalField(decimal_places=4, max_digits=6, null=True)),
("billing_cycle_anchor", models.DateTimeField()),
("billing_schedule", models.SmallIntegerField()),
("billed_through", models.DateTimeField()),
("next_billing_date", models.DateTimeField(db_index=True)),
("tier", models.SmallIntegerField()),
("status", models.SmallIntegerField(default=1)),
(
"customer",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE, to="corporate.Customer"
),
),
],
),
]

View File

@@ -0,0 +1,34 @@
# Generated by Django 1.11.18 on 2019-01-19 05:01
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("corporate", "0003_customerplan"),
]
operations = [
migrations.CreateModel(
name="LicenseLedger",
fields=[
(
"id",
models.AutoField(
auto_created=True, primary_key=True, serialize=False, verbose_name="ID"
),
),
("is_renewal", models.BooleanField(default=False)),
("event_time", models.DateTimeField()),
("licenses", models.IntegerField()),
("licenses_at_next_renewal", models.IntegerField(null=True)),
(
"plan",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE, to="corporate.CustomerPlan"
),
),
],
),
]

View File

@@ -0,0 +1,37 @@
# Generated by Django 1.11.18 on 2019-01-28 13:04
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("corporate", "0004_licenseledger"),
]
operations = [
migrations.RenameField(
model_name="customerplan",
old_name="next_billing_date",
new_name="next_invoice_date",
),
migrations.RemoveField(
model_name="customerplan",
name="billed_through",
),
migrations.AddField(
model_name="customerplan",
name="invoiced_through",
field=models.ForeignKey(
null=True,
on_delete=django.db.models.deletion.CASCADE,
related_name="+",
to="corporate.LicenseLedger",
),
),
migrations.AddField(
model_name="customerplan",
name="invoicing_status",
field=models.SmallIntegerField(default=1),
),
]

View File

@@ -0,0 +1,17 @@
# Generated by Django 1.11.18 on 2019-01-29 01:46
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("corporate", "0005_customerplan_invoicing"),
]
operations = [
migrations.AlterField(
model_name="customer",
name="stripe_customer_id",
field=models.CharField(max_length=255, null=True, unique=True),
),
]

View File

@@ -0,0 +1,37 @@
# Generated by Django 1.11.18 on 2019-01-31 22:16
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("corporate", "0006_nullable_stripe_customer_id"),
]
operations = [
migrations.RemoveField(
model_name="billingprocessor",
name="log_row",
),
migrations.RemoveField(
model_name="billingprocessor",
name="realm",
),
migrations.DeleteModel(
name="Coupon",
),
migrations.DeleteModel(
name="Plan",
),
migrations.RemoveField(
model_name="customer",
name="has_billing_relationship",
),
migrations.RemoveField(
model_name="customerplan",
name="licenses",
),
migrations.DeleteModel(
name="BillingProcessor",
),
]

View File

@@ -0,0 +1,17 @@
# Generated by Django 1.11.20 on 2019-04-11 00:45
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("corporate", "0007_remove_deprecated_fields"),
]
operations = [
migrations.AlterField(
model_name="customerplan",
name="next_invoice_date",
field=models.DateTimeField(db_index=True, null=True),
),
]

View File

@@ -0,0 +1,17 @@
# Generated by Django 2.2.13 on 2020-06-09 12:09
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("corporate", "0008_nullable_next_invoice_date"),
]
operations = [
migrations.AddField(
model_name="customer",
name="sponsorship_pending",
field=models.BooleanField(default=False),
),
]

View File

@@ -0,0 +1,17 @@
# Generated by Django 3.2.2 on 2021-06-08 08:57
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("corporate", "0009_customer_sponsorship_pending"),
]
operations = [
migrations.AddField(
model_name="customerplan",
name="exempt_from_from_license_number_check",
field=models.BooleanField(default=False),
),
]

View File

@@ -0,0 +1,27 @@
# Generated by Django 3.2.4 on 2021-06-18 18:39
from django.db import migrations, models
class Migration(migrations.Migration):
"""
We haven't set the values for this field for the relevant organizations
as of this moment, so we can simply drop the column from CustomerPlan
and add it to Customer without worrying about losing the values.
"""
dependencies = [
("corporate", "0010_customerplan_exempt_from_from_license_number_check"),
]
operations = [
migrations.RemoveField(
model_name="customerplan",
name="exempt_from_from_license_number_check",
),
migrations.AddField(
model_name="customer",
name="exempt_from_from_license_number_check",
field=models.BooleanField(default=False),
),
]

View File

@@ -0,0 +1,62 @@
# Generated by Django 3.2.5 on 2021-07-15 17:15
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
("zerver", "0333_alter_realm_org_type"),
("corporate", "0011_move_exempt_from_from_license_number_check_to_customer_model"),
]
operations = [
migrations.CreateModel(
name="ZulipSponsorshipRequest",
fields=[
(
"id",
models.AutoField(
auto_created=True, primary_key=True, serialize=False, verbose_name="ID"
),
),
(
"org_type",
models.PositiveSmallIntegerField(
choices=[
(0, "Unspecified"),
(10, "Business"),
(20, "Open-source project"),
(30, "Education (non-profit)"),
(35, "Education (for-profit)"),
(40, "Research"),
(50, "Event or conference"),
(60, "Non-profit (registered)"),
(70, "Government"),
(80, "Political group"),
(90, "Community"),
(100, "Personal"),
(1000, "Other"),
],
default=0,
),
),
("org_website", models.URLField()),
("org_description", models.TextField(default="")),
(
"realm",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE, to="zerver.realm"
),
),
(
"requested_by",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL
),
),
],
),
]

View File

@@ -0,0 +1,17 @@
# Generated by Django 3.2.5 on 2021-08-06 19:18
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("corporate", "0012_zulipsponsorshiprequest"),
]
operations = [
migrations.AlterField(
model_name="zulipsponsorshiprequest",
name="org_website",
field=models.URLField(blank=True, null=True),
),
]

View File

@@ -0,0 +1,17 @@
# Generated by Django 3.2.6 on 2021-09-17 10:52
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("corporate", "0013_alter_zulipsponsorshiprequest_org_website"),
]
operations = [
migrations.AddField(
model_name="customerplan",
name="end_date",
field=models.DateTimeField(null=True),
),
]

View File

@@ -0,0 +1,84 @@
# Generated by Django 3.2.9 on 2021-11-04 16:23
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("contenttypes", "0002_remove_content_type_name"),
("corporate", "0014_customerplan_end_date"),
]
operations = [
migrations.CreateModel(
name="PaymentIntent",
fields=[
(
"id",
models.AutoField(
auto_created=True, primary_key=True, serialize=False, verbose_name="ID"
),
),
("stripe_payment_intent_id", models.CharField(max_length=255, unique=True)),
("status", models.SmallIntegerField()),
("last_payment_error", models.JSONField(default=None, null=True)),
(
"customer",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE, to="corporate.customer"
),
),
],
),
migrations.CreateModel(
name="Session",
fields=[
(
"id",
models.AutoField(
auto_created=True, primary_key=True, serialize=False, verbose_name="ID"
),
),
("stripe_session_id", models.CharField(max_length=255, unique=True)),
("type", models.SmallIntegerField()),
("status", models.SmallIntegerField(default=1)),
(
"customer",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE, to="corporate.customer"
),
),
(
"payment_intent",
models.ForeignKey(
null=True,
on_delete=django.db.models.deletion.CASCADE,
to="corporate.paymentintent",
),
),
],
),
migrations.CreateModel(
name="Event",
fields=[
(
"id",
models.AutoField(
auto_created=True, primary_key=True, serialize=False, verbose_name="ID"
),
),
("stripe_event_id", models.CharField(max_length=255)),
("type", models.CharField(max_length=255)),
("status", models.SmallIntegerField(default=1)),
("object_id", models.PositiveIntegerField(db_index=True)),
("handler_error", models.JSONField(default=None, null=True)),
(
"content_type",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE, to="contenttypes.contenttype"
),
),
],
),
]

View File

@@ -0,0 +1,31 @@
# Generated by Django 3.2.9 on 2021-11-27 00:08
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("zilencer", "0018_remoterealmauditlog"),
("zerver", "0370_realm_enable_spectator_access"),
("corporate", "0015_event_paymentintent_session"),
]
operations = [
migrations.AddField(
model_name="customer",
name="remote_server",
field=models.OneToOneField(
null=True,
on_delete=django.db.models.deletion.CASCADE,
to="zilencer.remotezulipserver",
),
),
migrations.AlterField(
model_name="customer",
name="realm",
field=models.OneToOneField(
null=True, on_delete=django.db.models.deletion.CASCADE, to="zerver.realm"
),
),
]

View File

@@ -0,0 +1,17 @@
# Generated by Django 4.2 on 2023-04-10 18:22
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("corporate", "0016_customer_add_remote_server_field"),
]
operations = [
migrations.RenameField(
model_name="customer",
old_name="exempt_from_from_license_number_check",
new_name="exempt_from_license_number_check",
),
]

View File

@@ -0,0 +1,24 @@
# Generated by Django 4.2.1 on 2023-05-30 03:16
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
(
"corporate",
"0017_rename_exempt_from_from_license_number_check_customer_exempt_from_license_number_check",
),
]
operations = [
migrations.AddConstraint(
model_name="customer",
constraint=models.CheckConstraint(
condition=models.Q(
("realm__isnull", False), ("remote_server__isnull", False), _connector="XOR"
),
name="cloud_xor_self_hosted",
),
),
]

View File

@@ -0,0 +1,27 @@
# Generated by Django 4.2.6 on 2023-11-11 14:16
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("corporate", "0018_customer_cloud_xor_self_hosted"),
]
operations = [
migrations.AddField(
model_name="zulipsponsorshiprequest",
name="expected_total_users",
field=models.TextField(default=""),
),
migrations.AddField(
model_name="zulipsponsorshiprequest",
name="paid_users_count",
field=models.TextField(default=""),
),
migrations.AddField(
model_name="zulipsponsorshiprequest",
name="paid_users_description",
field=models.TextField(default=""),
),
]

View File

@@ -0,0 +1,37 @@
# Generated by Django 4.2.7 on 2023-11-17 20:11
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("zilencer", "0035_remoterealmcount_remote_realm_and_more"),
("corporate", "0019_zulipsponsorshiprequest_expected_total_users_and_more"),
]
operations = [
migrations.RemoveConstraint(
model_name="customer",
name="cloud_xor_self_hosted",
),
migrations.AddField(
model_name="customer",
name="remote_realm",
field=models.OneToOneField(
null=True, on_delete=django.db.models.deletion.CASCADE, to="zilencer.remoterealm"
),
),
migrations.AddConstraint(
model_name="customer",
constraint=models.CheckConstraint(
condition=models.Q(
("realm__isnull", False),
("remote_server__isnull", False),
("remote_realm__isnull", False),
_connector="OR",
),
name="has_associated_model_object",
),
),
]

View File

@@ -0,0 +1,16 @@
# Generated by Django 4.2.7 on 2023-11-18 14:54
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("corporate", "0020_add_remote_realm_customers"),
]
operations = [
migrations.RemoveField(
model_name="session",
name="payment_intent",
),
]

View File

@@ -0,0 +1,17 @@
# Generated by Django 4.2.7 on 2023-11-21 11:20
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("corporate", "0021_remove_session_payment_intent"),
]
operations = [
migrations.AddField(
model_name="session",
name="is_manual_license_management_upgrade_session",
field=models.BooleanField(default=False),
),
]

View File

@@ -0,0 +1,20 @@
# Generated by Django 4.2.7 on 2023-11-26 16:00
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("corporate", "0022_session_is_manual_license_management_upgrade_session"),
]
operations = [
migrations.AddField(
model_name="zulipsponsorshiprequest",
name="customer",
field=models.ForeignKey(
null=True, on_delete=django.db.models.deletion.CASCADE, to="corporate.customer"
),
),
]

View File

@@ -0,0 +1,19 @@
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("corporate", "0023_zulipsponsorshiprequest_customer"),
]
operations = [
migrations.RunSQL(
"""
UPDATE corporate_zulipsponsorshiprequest
SET customer_id = (
SELECT id FROM corporate_customer WHERE corporate_customer.realm_id = corporate_zulipsponsorshiprequest.realm_id
)
""",
elidable=True,
),
]

View File

@@ -0,0 +1,20 @@
# Generated by Django 4.2.7 on 2023-11-26 16:13
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("corporate", "0024_zulipsponsorshiprequest_fill_customer_data"),
]
operations = [
migrations.AlterField(
model_name="zulipsponsorshiprequest",
name="customer",
field=models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE, to="corporate.customer"
),
),
]

View File

@@ -0,0 +1,16 @@
# Generated by Django 4.2.7 on 2023-11-26 16:14
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("corporate", "0025_alter_zulipsponsorshiprequest_customer"),
]
operations = [
migrations.RemoveField(
model_name="zulipsponsorshiprequest",
name="realm",
),
]

View File

@@ -0,0 +1,25 @@
# Generated by Django 4.2.7 on 2023-11-28 16:00
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
("corporate", "0026_remove_zulipsponsorshiprequest_realm"),
]
operations = [
migrations.AlterField(
model_name="zulipsponsorshiprequest",
name="requested_by",
field=models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.CASCADE,
to=settings.AUTH_USER_MODEL,
),
),
]

View File

@@ -0,0 +1,21 @@
# Generated by Django 4.2.8 on 2023-12-08 18:03
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("corporate", "0027_alter_zulipsponsorshiprequest_requested_by"),
]
operations = [
migrations.AddField(
model_name="zulipsponsorshiprequest",
name="requested_plan",
field=models.CharField(
choices=[("", "UNSPECIFIED"), ("Community", "COMMUNITY"), ("Business", "BUSINESS")],
default="",
max_length=50,
),
),
]

View File

@@ -0,0 +1,17 @@
# Generated by Django 4.2.8 on 2023-12-18 09:39
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("corporate", "0028_zulipsponsorshiprequest_requested_plan"),
]
operations = [
migrations.AddField(
model_name="session",
name="tier",
field=models.SmallIntegerField(null=True),
),
]

View File

@@ -0,0 +1,26 @@
# Generated by Django 4.2.8 on 2023-12-19 11:38
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("corporate", "0029_session_tier"),
]
operations = [
migrations.AlterField(
model_name="zulipsponsorshiprequest",
name="requested_plan",
field=models.CharField(
choices=[
("", "UNSPECIFIED"),
("Community", "COMMUNITY"),
("Basic", "BASIC"),
("Business", "BUSINESS"),
],
default="",
max_length=50,
),
),
]

View File

@@ -0,0 +1,22 @@
# Generated by Django 4.2.8 on 2023-12-19 12:24
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("corporate", "0030_alter_zulipsponsorshiprequest_requested_plan"),
]
operations = [
migrations.AddField(
model_name="customer",
name="flat_discount",
field=models.IntegerField(default=2000),
),
migrations.AddField(
model_name="customer",
name="flat_discounted_months",
field=models.IntegerField(default=0),
),
]

View File

@@ -0,0 +1,17 @@
# Generated by Django 4.2.8 on 2023-12-14 13:27
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("corporate", "0031_customer_flat_discount_and_more"),
]
operations = [
migrations.AddField(
model_name="customer",
name="minimum_licenses",
field=models.PositiveIntegerField(null=True),
),
]

View File

@@ -0,0 +1,17 @@
# Generated by Django 4.2.8 on 2024-01-10 07:34
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("corporate", "0032_customer_minimum_licenses"),
]
operations = [
migrations.AddField(
model_name="customerplan",
name="invoice_overdue_email_sent",
field=models.BooleanField(default=False),
),
]

View File

@@ -0,0 +1,17 @@
# Generated by Django 4.2.8 on 2024-01-09 14:28
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("corporate", "0033_customerplan_invoice_overdue_email_sent"),
]
operations = [
migrations.AddField(
model_name="customer",
name="required_plan_tier",
field=models.SmallIntegerField(null=True),
),
]

View File

@@ -0,0 +1,28 @@
# Generated by Django 4.2.8 on 2024-01-25 05:49
from django.db import migrations
from django.db.backends.base.schema import BaseDatabaseSchemaEditor
from django.db.migrations.state import StateApps
from django.db.models import F
def update_legacy_plan_next_invoice_date(
apps: StateApps, schema_editor: BaseDatabaseSchemaEditor
) -> None:
CustomerPlan = apps.get_model("corporate", "CustomerPlan")
CustomerPlan.TIER_SELF_HOSTED_LEGACY = 101
# For legacy plans, set next_invoice_date = end_date.
CustomerPlan.objects.filter(tier=CustomerPlan.TIER_SELF_HOSTED_LEGACY).update(
next_invoice_date=F("end_date")
)
class Migration(migrations.Migration):
dependencies = [
("corporate", "0034_customer_discount_required_tier"),
]
operations = [
migrations.RunPython(update_legacy_plan_next_invoice_date, elidable=True),
]

View File

@@ -0,0 +1,40 @@
# Generated by Django 4.2.8 on 2024-01-24 08:08
from django.db import migrations
from django.db.backends.base.schema import BaseDatabaseSchemaEditor
from django.db.migrations.state import StateApps
def fix_customer_plans_scheduled_after_legacy_plan(
apps: StateApps, schema_editor: BaseDatabaseSchemaEditor
) -> None:
CustomerPlan = apps.get_model("corporate", "CustomerPlan")
CustomerPlan.TIER_SELF_HOSTED_LEGACY = 101
CustomerPlan.SWITCH_PLAN_TIER_AT_PLAN_END = 8
CustomerPlan.NEVER_STARTED = 12
CustomerPlan.INVOICING_STATUS_INITIAL_INVOICE_TO_BE_SENT = 3
# Legacy plans scheduled to switch to a new plan.
legacy_plans = CustomerPlan.objects.filter(
tier=CustomerPlan.TIER_SELF_HOSTED_LEGACY, status=CustomerPlan.SWITCH_PLAN_TIER_AT_PLAN_END
)
for legacy_plan in legacy_plans:
CustomerPlan.objects.filter(
customer=legacy_plan.customer,
billing_cycle_anchor=legacy_plan.end_date,
status=CustomerPlan.NEVER_STARTED,
).update(
next_invoice_date=legacy_plan.end_date,
invoicing_status=CustomerPlan.INVOICING_STATUS_INITIAL_INVOICE_TO_BE_SENT,
)
class Migration(migrations.Migration):
dependencies = [
("corporate", "0035_update_legacy_plan_next_invoice_date"),
]
operations = [
migrations.RunPython(fix_customer_plans_scheduled_after_legacy_plan, elidable=True),
]

View File

@@ -0,0 +1,36 @@
# Generated by Django 4.2.8 on 2024-01-27 09:03
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("corporate", "0036_fix_customer_plans_scheduled_after_legacy_plan"),
]
operations = [
migrations.CreateModel(
name="CustomerPlanOffer",
fields=[
(
"id",
models.AutoField(
auto_created=True, primary_key=True, serialize=False, verbose_name="ID"
),
),
("fixed_price", models.IntegerField(null=True)),
("tier", models.SmallIntegerField()),
("status", models.SmallIntegerField()),
(
"customer",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE, to="corporate.customer"
),
),
],
options={
"abstract": False,
},
),
]

View File

@@ -0,0 +1,37 @@
# Generated by Django 4.2.9 on 2024-02-02 10:15
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("corporate", "0037_customerplanoffer"),
]
operations = [
migrations.AddField(
model_name="customerplanoffer",
name="sent_invoice_id",
field=models.CharField(max_length=255, null=True),
),
migrations.CreateModel(
name="Invoice",
fields=[
(
"id",
models.AutoField(
auto_created=True, primary_key=True, serialize=False, verbose_name="ID"
),
),
("stripe_invoice_id", models.CharField(max_length=255, unique=True)),
("status", models.SmallIntegerField()),
(
"customer",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE, to="corporate.customer"
),
),
],
),
]

View File

@@ -0,0 +1,63 @@
# Generated by Django 4.2.9 on 2024-02-21 11:53
from datetime import datetime
from django.db import migrations
from django.db.backends.base.schema import BaseDatabaseSchemaEditor
from django.db.migrations.state import StateApps
def add_months(dt: datetime, months: int) -> datetime:
assert months >= 0
# It's fine that the max day in Feb is 28 for leap years.
MAX_DAY_FOR_MONTH = {
1: 31,
2: 28,
3: 31,
4: 30,
5: 31,
6: 30,
7: 31,
8: 31,
9: 30,
10: 31,
11: 30,
12: 31,
}
year = dt.year
month = dt.month + months
while month > 12:
year += 1
month -= 12
day = min(dt.day, MAX_DAY_FOR_MONTH[month])
# datetimes don't support leap seconds, so don't need to worry about those
return dt.replace(year=year, month=month, day=day)
def backfill_end_date_for_fixed_price_plans(
apps: StateApps, schema_editor: BaseDatabaseSchemaEditor
) -> None:
CustomerPlan = apps.get_model("corporate", "CustomerPlan")
CustomerPlan.ACTIVE = 1
fixed_price_plans = CustomerPlan.objects.filter(
status=CustomerPlan.ACTIVE, fixed_price__isnull=False
)
for plan in fixed_price_plans:
assert plan.end_date is None
assert plan.billing_cycle_anchor is not None
plan.end_date = add_months(plan.billing_cycle_anchor, 12)
plan.save(update_fields=["end_date"])
class Migration(migrations.Migration):
dependencies = [
("corporate", "0038_customerplanoffer_sent_invoice_id_invoice"),
]
operations = [
migrations.RunPython(
backfill_end_date_for_fixed_price_plans,
elidable=True,
),
]

View File

@@ -0,0 +1,17 @@
# Generated by Django 4.2.9 on 2024-02-15 06:47
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("corporate", "0039_backfill_end_date_for_fixed_price_plans"),
]
operations = [
migrations.AddField(
model_name="customerplan",
name="reminder_to_review_plan_email_sent",
field=models.BooleanField(default=False),
),
]

View File

@@ -0,0 +1,51 @@
# Generated by Django 4.2.10 on 2024-03-01 14:05
from django.db import migrations
from django.db.backends.base.schema import BaseDatabaseSchemaEditor
from django.db.migrations.state import StateApps
def fix_plans_on_free_trial_with_changes_in_schedule(
apps: StateApps, schema_editor: BaseDatabaseSchemaEditor
) -> None:
CustomerPlan = apps.get_model("corporate", "CustomerPlan")
LicenseLedger = apps.get_model("corporate", "LicenseLedger")
CustomerPlan.FREE_TRIAL = 3
CustomerPlan.ENDED = 11
CustomerPlan.INVOICING_STATUS_DONE = 1
plans_on_free_trial_with_changed_schedule = CustomerPlan.objects.filter(
status=CustomerPlan.FREE_TRIAL, invoiced_through=None
)
for plan in plans_on_free_trial_with_changed_schedule:
assert plan.customer is not None
assert plan.billing_cycle_anchor is not None
ledger_entries = LicenseLedger.objects.filter(
plan=plan,
is_renewal=True,
event_time=plan.billing_cycle_anchor,
)
assert ledger_entries.count() == 1
plan.invoiced_through = ledger_entries.first()
plan.invoicing_status = CustomerPlan.INVOICING_STATUS_DONE
plan.save(update_fields=["invoiced_through", "invoicing_status"])
CustomerPlan.objects.filter(
customer=plan.customer,
status=CustomerPlan.ENDED,
billing_cycle_anchor=plan.billing_cycle_anchor,
).update(next_invoice_date=None)
class Migration(migrations.Migration):
dependencies = [
("corporate", "0040_customerplan_reminder_to_review_plan_email_sent"),
]
operations = [
migrations.RunPython(
fix_plans_on_free_trial_with_changes_in_schedule,
elidable=True,
),
]

View File

@@ -0,0 +1,28 @@
# Generated by Django 4.2.11 on 2024-04-10 03:17
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("corporate", "0041_fix_plans_on_free_trial_with_changes_in_schedule"),
]
operations = [
migrations.AddField(
model_name="invoice",
name="is_created_for_free_trial_upgrade",
field=models.BooleanField(default=False),
),
migrations.AddField(
model_name="invoice",
name="plan",
field=models.ForeignKey(
default=None,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
to="corporate.customerplan",
),
),
]

View File

@@ -0,0 +1,89 @@
# Generated by Django 5.0.5 on 2024-05-03 06:50
from decimal import Decimal
from django.db import migrations, models
from django.db.backends.base.schema import BaseDatabaseSchemaEditor
from django.db.migrations.state import StateApps
# It's generally unsafe to import product code from migrations,
# because the migration will be run with the **current** version of
# that code, not the version at the time of the migration. But because
# this migration will only be run for Zulip Cloud, this is OK.
from corporate.lib.stripe import get_price_per_license
def calculate_discounted_price_per_license(
original_price_per_license: int, discount: Decimal
) -> int:
# There are no fractional cents in Stripe, so round down to nearest integer.
return int(float(original_price_per_license * (1 - discount / 100)) + 0.00001)
def calculate_discounted_price(apps: StateApps, schema_editor: BaseDatabaseSchemaEditor) -> None:
"""Migrates existing customers with a configured default discount to
instead have discounted prices for their configured required_plan_tier.
Does not operate on CustomerPlan fields, since those are already
resolved into a price.
"""
BILLING_SCHEDULE_ANNUAL = 1
BILLING_SCHEDULE_MONTHLY = 2
Customer = apps.get_model("corporate", "Customer")
customers_to_update = []
for customer in Customer.objects.all().iterator():
if not customer.required_plan_tier or not customer.default_discount:
continue
annual_price_per_license = get_price_per_license(
customer.required_plan_tier, BILLING_SCHEDULE_ANNUAL
)
customer.annual_discounted_price = calculate_discounted_price_per_license(
annual_price_per_license, customer.default_discount
)
monthly_price_per_license = get_price_per_license(
customer.required_plan_tier, BILLING_SCHEDULE_MONTHLY
)
customer.monthly_discounted_price = calculate_discounted_price_per_license(
monthly_price_per_license, customer.default_discount
)
customers_to_update.append(customer)
print(
f"\nChanging price for {customer.id}: {customer.default_discount} => {customer.annual_discounted_price}/{customer.monthly_discounted_price}"
)
Customer.objects.bulk_update(
customers_to_update, ["annual_discounted_price", "monthly_discounted_price"]
)
class Migration(migrations.Migration):
dependencies = [
("corporate", "0042_invoice_is_created_for_free_trial_upgrade_and_more"),
]
operations = [
migrations.AddField(
model_name="customer",
name="annual_discounted_price",
field=models.IntegerField(default=0),
),
migrations.AddField(
model_name="customer",
name="monthly_discounted_price",
field=models.IntegerField(default=0),
),
# Populate the new discounted price fields based on existing default discount.
migrations.RunPython(calculate_discounted_price, elidable=True),
migrations.RemoveField(
model_name="customer",
name="default_discount",
),
# This automatically converts the decimal to string so we don't need to do anything here.
migrations.AlterField(
model_name="customerplan",
name="discount",
field=models.TextField(null=True),
),
]

View File

@@ -0,0 +1,75 @@
from django.db import migrations, models
class Migration(migrations.Migration):
atomic = False
dependencies = [
("corporate", "0043_remove_customer_default_discount_and_more"),
]
operations = [
migrations.AlterField(
model_name="customer",
name="id",
field=models.BigAutoField(
auto_created=True, primary_key=True, serialize=False, verbose_name="ID"
),
),
migrations.AlterField(
model_name="customerplan",
name="id",
field=models.BigAutoField(
auto_created=True, primary_key=True, serialize=False, verbose_name="ID"
),
),
migrations.AlterField(
model_name="customerplanoffer",
name="id",
field=models.BigAutoField(
auto_created=True, primary_key=True, serialize=False, verbose_name="ID"
),
),
migrations.AlterField(
model_name="event",
name="id",
field=models.BigAutoField(
auto_created=True, primary_key=True, serialize=False, verbose_name="ID"
),
),
migrations.AlterField(
model_name="invoice",
name="id",
field=models.BigAutoField(
auto_created=True, primary_key=True, serialize=False, verbose_name="ID"
),
),
migrations.AlterField(
model_name="licenseledger",
name="id",
field=models.BigAutoField(
auto_created=True, primary_key=True, serialize=False, verbose_name="ID"
),
),
migrations.AlterField(
model_name="paymentintent",
name="id",
field=models.BigAutoField(
auto_created=True, primary_key=True, serialize=False, verbose_name="ID"
),
),
migrations.AlterField(
model_name="session",
name="id",
field=models.BigAutoField(
auto_created=True, primary_key=True, serialize=False, verbose_name="ID"
),
),
migrations.AlterField(
model_name="zulipsponsorshiprequest",
name="id",
field=models.BigAutoField(
auto_created=True, primary_key=True, serialize=False, verbose_name="ID"
),
),
]

View File

@@ -0,0 +1,17 @@
# Generated by Django 5.0.10 on 2025-01-14 17:16
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("corporate", "0001_squashed_0044_convert_ids_to_bigints"),
]
operations = [
migrations.AddField(
model_name="zulipsponsorshiprequest",
name="plan_to_use_zulip",
field=models.TextField(default=""),
),
]

View File

@@ -0,0 +1,17 @@
# Generated by Django 5.1.8 on 2025-05-27 14:26
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("corporate", "0045_zulipsponsorshiprequest_plan_to_use_zulip"),
]
operations = [
migrations.RenameField(
model_name="customerplan",
old_name="invoice_overdue_email_sent",
new_name="stale_audit_log_data_email_sent",
),
]

View File

@@ -0,0 +1,33 @@
# Generated by Django 5.2.14 on 2026-05-19 13:59
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("corporate", "0046_rename_customerplan_invoice_overdue_email_sent"),
]
# CustomerPlanOffer and CustomerPlan status field values
# that we are setting to be unique for each Customer.
CONFIGURED = 1
NEVER_STARTED = 12
operations = [
migrations.AddConstraint(
model_name="customerplan",
constraint=models.UniqueConstraint(
condition=models.Q(("status", NEVER_STARTED)),
fields=("customer",),
name="unique_never_started_plan_customer",
),
),
migrations.AddConstraint(
model_name="customerplanoffer",
constraint=models.UniqueConstraint(
condition=models.Q(("status", CONFIGURED)),
fields=("customer",),
name="unique_configured_plan_offer_customer",
),
),
]

View File

View File

@@ -0,0 +1,11 @@
from corporate.models.customers import Customer as Customer
from corporate.models.licenses import LicenseLedger as LicenseLedger
from corporate.models.plans import AbstractCustomerPlan as AbstractCustomerPlan
from corporate.models.plans import CustomerPlan as CustomerPlan
from corporate.models.plans import CustomerPlanOffer as CustomerPlanOffer
from corporate.models.sponsorships import SponsoredPlanTypes as SponsoredPlanTypes
from corporate.models.sponsorships import ZulipSponsorshipRequest as ZulipSponsorshipRequest
from corporate.models.stripe_state import Event as Event
from corporate.models.stripe_state import Invoice as Invoice
from corporate.models.stripe_state import PaymentIntent as PaymentIntent
from corporate.models.stripe_state import Session as Session

View File

@@ -0,0 +1,89 @@
from django.db import models
from django.db.models import CASCADE, Q
from typing_extensions import override
from zerver.models import Realm
from zilencer.models import RemoteRealm, RemoteZulipServer
class Customer(models.Model):
"""
This model primarily serves to connect a Realm with
the corresponding Stripe customer object for payment purposes
and the active plan, if any.
"""
# The actual model object that this customer is associated
# with. Exactly one of the following will be non-null.
realm = models.OneToOneField(Realm, on_delete=CASCADE, null=True)
remote_realm = models.OneToOneField(RemoteRealm, on_delete=CASCADE, null=True)
remote_server = models.OneToOneField(RemoteZulipServer, on_delete=CASCADE, null=True)
stripe_customer_id = models.CharField(max_length=255, null=True, unique=True)
sponsorship_pending = models.BooleanField(default=False)
# Discounted price for required_plan_tier in cents.
# We treat 0 as no discount. Not using `null` here keeps the
# checks simpler and avoids the cases where we forget to
# check for both `null` and 0.
monthly_discounted_price = models.IntegerField(default=0, null=False)
annual_discounted_price = models.IntegerField(default=0, null=False)
minimum_licenses = models.PositiveIntegerField(null=True)
# Used for limiting discounted price or a fixed_price
# to be used only for a particular CustomerPlan tier.
required_plan_tier = models.SmallIntegerField(null=True)
# Some non-profit organizations on manual license management pay
# only for their paid employees. We don't prevent these
# organizations from adding more users than the number of licenses
# they purchased.
exempt_from_license_number_check = models.BooleanField(default=False)
# In cents.
flat_discount = models.IntegerField(default=2000)
# Number of months left in the flat discount period.
flat_discounted_months = models.IntegerField(default=0)
class Meta:
# Enforce that at least one of these is set.
constraints = [
models.CheckConstraint(
condition=Q(realm__isnull=False)
| Q(remote_server__isnull=False)
| Q(remote_realm__isnull=False),
name="has_associated_model_object",
)
]
@override
def __str__(self) -> str:
if self.realm is not None:
return f"{self.realm!r} (stripe: {self.stripe_customer_id})"
elif self.remote_realm is not None:
return f"{self.remote_realm!r} (stripe: {self.stripe_customer_id})"
else:
return f"{self.remote_server!r} (stripe: {self.stripe_customer_id})"
def get_discounted_price_for_plan(self, plan_tier: int, schedule: int) -> int | None:
from corporate.models.plans import CustomerPlan
if plan_tier != self.required_plan_tier:
return None
if schedule == CustomerPlan.BILLING_SCHEDULE_ANNUAL:
return self.annual_discounted_price
assert schedule == CustomerPlan.BILLING_SCHEDULE_MONTHLY
return self.monthly_discounted_price
def get_customer_by_realm(realm: Realm) -> Customer | None:
return Customer.objects.filter(realm=realm).first()
def get_customer_by_remote_server(remote_server: RemoteZulipServer) -> Customer | None:
return Customer.objects.filter(remote_server=remote_server).first()
def get_customer_by_remote_realm(remote_realm: RemoteRealm) -> Customer | None: # nocoverage
return Customer.objects.filter(remote_realm=remote_realm).first()

View File

@@ -0,0 +1,45 @@
from django.db import models
from django.db.models import CASCADE
from typing_extensions import override
from corporate.models.plans import CustomerPlan
class LicenseLedger(models.Model):
"""
This table's purpose is to store the current, and historical,
count of "seats" purchased by the organization.
Because we want to keep historical data, when the purchased
seat count changes, a new LicenseLedger object is created,
instead of updating the old one. This lets us preserve
the entire history of how the seat count changes, which is
important for analytics as well as auditing and debugging
in case of issues.
"""
plan = models.ForeignKey(CustomerPlan, on_delete=CASCADE)
# Also True for the initial upgrade.
is_renewal = models.BooleanField(default=False)
event_time = models.DateTimeField()
# The number of licenses ("seats") purchased by the organization at the time of ledger
# entry creation. Normally, to add a user the organization needs at least one spare license.
# Once a license is purchased, it is valid till the end of the billing period, irrespective
# of whether the license is used or not. So the value of licenses will never decrease for
# subsequent LicenseLedger entries in the same billing period.
licenses = models.IntegerField()
# The number of licenses the organization needs in the next billing cycle. The value of
# licenses_at_next_renewal can increase or decrease for subsequent LicenseLedger entries in
# the same billing period. For plans on automatic license management this value is usually
# equal to the number of activated users in the organization.
licenses_at_next_renewal = models.IntegerField(null=True)
@override
def __str__(self) -> str:
ledger_type = "renewal" if self.is_renewal else "update"
ledger_time = self.event_time.replace(tzinfo=None).isoformat(" ", "minutes")
return f"License {ledger_type}, {self.licenses} purchased, {self.licenses_at_next_renewal} next cycle, {ledger_time} (id={self.id})"

285
corporate/models/plans.py Normal file
View File

@@ -0,0 +1,285 @@
from django.db import models
from django.db.models import CASCADE, Q
from typing_extensions import override
from corporate.models.customers import Customer, get_customer_by_realm
from zerver.models import Realm
class AbstractCustomerPlan(models.Model):
# A customer can only have one ACTIVE / CONFIGURED plan,
# but old, inactive / processed plans are preserved to allow
# auditing - so there can be multiple CustomerPlan / CustomerPlanOffer
# objects pointing to one Customer.
customer = models.ForeignKey(Customer, on_delete=CASCADE)
fixed_price = models.IntegerField(null=True)
class Meta:
abstract = True
class CustomerPlanOffer(AbstractCustomerPlan):
"""
This is for storing offers configured via /support which
the customer is yet to buy or schedule a purchase.
Once customer buys or schedules a purchase, we create a
CustomerPlan record. The record in this table stays for
audit purpose with status=PROCESSED.
"""
TIER_CLOUD_STANDARD = 1
TIER_CLOUD_PLUS = 2
TIER_SELF_HOSTED_BASIC = 103
TIER_SELF_HOSTED_BUSINESS = 104
tier = models.SmallIntegerField()
# Whether the offer is:
# * only configured
# * processed by the customer to buy or schedule a purchase.
CONFIGURED = 1
PROCESSED = 2
status = models.SmallIntegerField()
# ID of invoice sent when chose to 'Pay by invoice'.
sent_invoice_id = models.CharField(max_length=255, null=True)
class Meta:
# Enforce that each customer has only one CONFIGURED plan offer at a time.
constraints = [
models.UniqueConstraint(
fields=["customer"],
condition=Q(status=1), # CustomerPlanOffer.CONFIGURED
name="unique_configured_plan_offer_customer",
)
]
@override
def __str__(self) -> str:
return f"{self.name} for {self.customer!r} (status: {self.get_plan_status_as_text()})"
def get_plan_status_as_text(self) -> str:
return {
self.CONFIGURED: "Configured",
self.PROCESSED: "Processed",
}[self.status]
@staticmethod
def name_from_tier(tier: int) -> str:
return {
CustomerPlanOffer.TIER_CLOUD_STANDARD: "Zulip Cloud Standard",
CustomerPlanOffer.TIER_CLOUD_PLUS: "Zulip Cloud Plus",
CustomerPlanOffer.TIER_SELF_HOSTED_BASIC: "Zulip Basic",
CustomerPlanOffer.TIER_SELF_HOSTED_BUSINESS: "Zulip Business",
}[tier]
@property
def name(self) -> str:
return self.name_from_tier(self.tier)
class CustomerPlan(AbstractCustomerPlan):
"""
This is for storing most of the fiddly details
of the customer's plan.
"""
automanage_licenses = models.BooleanField(default=False)
charge_automatically = models.BooleanField(default=False)
# Both of the price_per_license and fixed_price are in cents. Exactly
# one of them should be set. fixed_price is only for manual deals, and
# can't be set via the self-serve billing system.
price_per_license = models.IntegerField(null=True)
# Discount for current `billing_schedule`. For display purposes only.
# Explicitly set to be TextField to avoid being used in calculations.
# NOTE: This discount can be different for annual and monthly schedules.
discount = models.TextField(null=True)
# Initialized with the time of plan creation. Used for calculating
# start of next billing cycle, next invoice date etc. This value
# should never be modified. The only exception is when we change
# the status of the plan from free trial to active and reset the
# billing_cycle_anchor.
billing_cycle_anchor = models.DateTimeField()
BILLING_SCHEDULE_ANNUAL = 1
BILLING_SCHEDULE_MONTHLY = 2
BILLING_SCHEDULES = {
BILLING_SCHEDULE_ANNUAL: "Annual",
BILLING_SCHEDULE_MONTHLY: "Monthly",
}
billing_schedule = models.SmallIntegerField()
# The next date the billing system should go through ledger
# entries and create invoices for additional users or plan
# renewal. Since we use a daily cron job for invoicing, the
# invoice will be generated the first time the cron job runs after
# next_invoice_date.
next_invoice_date = models.DateTimeField(db_index=True, null=True)
# Flag to track if an email has been sent to Zulip team for delay
# of invoicing by >= one day. Helps to send an email only once
# and not every time when cron run.
stale_audit_log_data_email_sent = models.BooleanField(default=False)
# Flag to track if an email has been sent to Zulip team to
# review the pricing, 60 days before the end date. Helps to send
# an email only once and not every time when cron run.
reminder_to_review_plan_email_sent = models.BooleanField(default=False)
# On next_invoice_date, we call invoice_plan, which goes through
# ledger entries that were created after invoiced_through and
# process them. An invoice will be generated for any additional
# users and/or plan renewal (if it's the end of the billing cycle).
# Once all new ledger entries have been processed, invoiced_through
# will be have been set to the last ledger entry we checked.
invoiced_through = models.ForeignKey(
"LicenseLedger", null=True, on_delete=CASCADE, related_name="+"
)
end_date = models.DateTimeField(null=True)
INVOICING_STATUS_DONE = 1
INVOICING_STATUS_STARTED = 2
INVOICING_STATUS_INITIAL_INVOICE_TO_BE_SENT = 3
# This status field helps ensure any errors encountered during the
# invoicing process do not leave our invoicing system in a broken
# state.
invoicing_status = models.SmallIntegerField(default=INVOICING_STATUS_DONE)
TIER_CLOUD_STANDARD = 1
TIER_CLOUD_PLUS = 2
# Reserved tier IDs for future use
TIER_CLOUD_COMMUNITY = 3
TIER_CLOUD_ENTERPRISE = 4
TIER_SELF_HOSTED_BASE = 100
TIER_SELF_HOSTED_LEGACY = 101
TIER_SELF_HOSTED_COMMUNITY = 102
TIER_SELF_HOSTED_BASIC = 103
TIER_SELF_HOSTED_BUSINESS = 104
TIER_SELF_HOSTED_ENTERPRISE = 105
tier = models.SmallIntegerField()
PAID_PLAN_TIERS = [
TIER_CLOUD_STANDARD,
TIER_CLOUD_PLUS,
TIER_SELF_HOSTED_BASIC,
TIER_SELF_HOSTED_BUSINESS,
TIER_SELF_HOSTED_ENTERPRISE,
]
COMPLIMENTARY_PLAN_TIERS = [TIER_SELF_HOSTED_LEGACY]
ACTIVE = 1
DOWNGRADE_AT_END_OF_CYCLE = 2
FREE_TRIAL = 3
SWITCH_TO_ANNUAL_AT_END_OF_CYCLE = 4
SWITCH_PLAN_TIER_NOW = 5
SWITCH_TO_MONTHLY_AT_END_OF_CYCLE = 6
DOWNGRADE_AT_END_OF_FREE_TRIAL = 7
SWITCH_PLAN_TIER_AT_PLAN_END = 8
# "Live" plans should have a value < LIVE_STATUS_THRESHOLD.
# There should be at most one live plan per customer.
LIVE_STATUS_THRESHOLD = 10
ENDED = 11
NEVER_STARTED = 12
status = models.SmallIntegerField(default=ACTIVE)
# Currently, all the fixed-price plans are configured for one year.
# In future, we might change this to a field.
FIXED_PRICE_PLAN_DURATION_MONTHS = 12
# TODO maybe override setattr to ensure billing_cycle_anchor, etc
# are immutable.
class Meta:
# Enforce that each customer has only one NEVER_STARTED plan at a time.
constraints = [
models.UniqueConstraint(
fields=["customer"],
condition=Q(status=12), # CustomerPlan.NEVER_STARTED
name="unique_never_started_plan_customer",
)
]
@override
def __str__(self) -> str:
return f"{self.name} for {self.customer!r} (status: {self.get_plan_status_as_text()})"
@staticmethod
def name_from_tier(tier: int) -> str:
# NOTE: Check `statement_descriptor` values after updating this.
# Stripe has a 22 character limit on the statement descriptor length.
# https://stripe.com/docs/payments/account/statement-descriptors
return {
CustomerPlan.TIER_CLOUD_STANDARD: "Zulip Cloud Standard",
CustomerPlan.TIER_CLOUD_PLUS: "Zulip Cloud Plus",
CustomerPlan.TIER_CLOUD_ENTERPRISE: "Zulip Enterprise",
CustomerPlan.TIER_SELF_HOSTED_BASIC: "Zulip Basic",
CustomerPlan.TIER_SELF_HOSTED_BUSINESS: "Zulip Business",
CustomerPlan.TIER_SELF_HOSTED_COMMUNITY: "Community",
# Complimentary access plans should never be billed through Stripe,
# so the tier name can exceed the 22 character limit noted above.
CustomerPlan.TIER_SELF_HOSTED_LEGACY: "Zulip Basic (complimentary)",
}[tier]
@property
def name(self) -> str:
return self.name_from_tier(self.tier)
def get_plan_status_as_text(self) -> str:
return {
self.ACTIVE: "Active",
self.DOWNGRADE_AT_END_OF_CYCLE: "Downgrade end of cycle",
self.FREE_TRIAL: "Free trial",
self.SWITCH_TO_ANNUAL_AT_END_OF_CYCLE: "Scheduled switch to annual",
self.SWITCH_TO_MONTHLY_AT_END_OF_CYCLE: "Scheduled switch to monthly",
self.DOWNGRADE_AT_END_OF_FREE_TRIAL: "Downgrade end of free trial",
self.SWITCH_PLAN_TIER_AT_PLAN_END: "New plan scheduled",
self.ENDED: "Ended",
self.NEVER_STARTED: "Never started",
}[self.status]
def licenses(self) -> int:
from corporate.models.licenses import LicenseLedger
ledger_entry = LicenseLedger.objects.filter(plan=self).order_by("id").last()
assert ledger_entry is not None
return ledger_entry.licenses
def licenses_at_next_renewal(self) -> int | None:
from corporate.models.licenses import LicenseLedger
if self.status in (
CustomerPlan.DOWNGRADE_AT_END_OF_CYCLE,
CustomerPlan.DOWNGRADE_AT_END_OF_FREE_TRIAL,
):
return None
ledger_entry = LicenseLedger.objects.filter(plan=self).order_by("id").last()
assert ledger_entry is not None
return ledger_entry.licenses_at_next_renewal
def is_free_trial(self) -> bool:
return self.status == CustomerPlan.FREE_TRIAL
def is_complimentary_access_plan(self) -> bool:
return self.tier in self.COMPLIMENTARY_PLAN_TIERS
def is_a_paid_plan(self) -> bool:
return self.tier in self.PAID_PLAN_TIERS
def get_current_plan_by_customer(customer: Customer) -> CustomerPlan | None:
return CustomerPlan.objects.filter(
customer=customer, status__lt=CustomerPlan.LIVE_STATUS_THRESHOLD
).first()
def get_current_plan_by_realm(realm: Realm) -> CustomerPlan | None:
customer = get_customer_by_realm(realm)
if customer is None:
return None
return get_current_plan_by_customer(customer)

View File

@@ -0,0 +1,40 @@
from enum import Enum
from django.db import models
from django.db.models import CASCADE
from corporate.models.customers import Customer
from zerver.models import Realm, UserProfile
class SponsoredPlanTypes(Enum):
# unspecified used for cloud sponsorship requests
UNSPECIFIED = ""
COMMUNITY = "Community"
BASIC = "Basic"
BUSINESS = "Business"
class ZulipSponsorshipRequest(models.Model):
customer = models.ForeignKey(Customer, on_delete=CASCADE)
requested_by = models.ForeignKey(UserProfile, on_delete=CASCADE, null=True, blank=True)
org_type = models.PositiveSmallIntegerField(
default=Realm.ORG_TYPES["unspecified"]["id"],
choices=[(t["id"], t["name"]) for t in Realm.ORG_TYPES.values()],
)
MAX_ORG_URL_LENGTH: int = 200
org_website = models.URLField(max_length=MAX_ORG_URL_LENGTH, blank=True, null=True)
org_description = models.TextField(default="")
expected_total_users = models.TextField(default="")
plan_to_use_zulip = models.TextField(default="")
paid_users_count = models.TextField(default="")
paid_users_description = models.TextField(default="")
requested_plan = models.CharField(
max_length=50,
choices=[(plan.value, plan.name) for plan in SponsoredPlanTypes],
default=SponsoredPlanTypes.UNSPECIFIED.value,
)

View File

@@ -0,0 +1,178 @@
from typing import Any, Union
from django.contrib.contenttypes.fields import GenericForeignKey
from django.contrib.contenttypes.models import ContentType
from django.db import models
from django.db.models import CASCADE, SET_NULL
from corporate.models.customers import Customer
class Event(models.Model):
stripe_event_id = models.CharField(max_length=255)
type = models.CharField(max_length=255)
RECEIVED = 1
EVENT_HANDLER_STARTED = 30
EVENT_HANDLER_FAILED = 40
EVENT_HANDLER_SUCCEEDED = 50
status = models.SmallIntegerField(default=RECEIVED)
content_type = models.ForeignKey(ContentType, on_delete=models.CASCADE)
object_id = models.PositiveIntegerField(db_index=True)
content_object = GenericForeignKey("content_type", "object_id")
handler_error = models.JSONField(default=None, null=True)
def get_event_handler_details_as_dict(self) -> dict[str, Any]:
details_dict = {}
details_dict["status"] = {
Event.RECEIVED: "not_started",
Event.EVENT_HANDLER_STARTED: "started",
Event.EVENT_HANDLER_FAILED: "failed",
Event.EVENT_HANDLER_SUCCEEDED: "succeeded",
}[self.status]
if self.handler_error:
details_dict["error"] = self.handler_error
return details_dict
def get_last_associated_event_by_type(
content_object: Union["Invoice", "PaymentIntent", "Session"], event_type: str
) -> Event | None:
content_type = ContentType.objects.get_for_model(type(content_object))
return Event.objects.filter(
content_type=content_type, object_id=content_object.id, type=event_type
).last()
class Session(models.Model):
customer = models.ForeignKey(Customer, on_delete=CASCADE)
stripe_session_id = models.CharField(max_length=255, unique=True)
CARD_UPDATE_FROM_BILLING_PAGE = 40
CARD_UPDATE_FROM_UPGRADE_PAGE = 50
type = models.SmallIntegerField()
CREATED = 1
COMPLETED = 10
status = models.SmallIntegerField(default=CREATED)
# Did the user opt to manually manage licenses before clicking on update button?
is_manual_license_management_upgrade_session = models.BooleanField(default=False)
# CustomerPlan tier that the user is upgrading to.
tier = models.SmallIntegerField(null=True)
def get_status_as_string(self) -> str:
return {Session.CREATED: "created", Session.COMPLETED: "completed"}[self.status]
def get_type_as_string(self) -> str:
return {
Session.CARD_UPDATE_FROM_BILLING_PAGE: "card_update_from_billing_page",
Session.CARD_UPDATE_FROM_UPGRADE_PAGE: "card_update_from_upgrade_page",
}[self.type]
def to_dict(self) -> dict[str, Any]:
session_dict: dict[str, Any] = {}
session_dict["status"] = self.get_status_as_string()
session_dict["type"] = self.get_type_as_string()
session_dict["is_manual_license_management_upgrade_session"] = (
self.is_manual_license_management_upgrade_session
)
session_dict["tier"] = self.tier
event = self.get_last_associated_event()
if event is not None:
session_dict["event_handler"] = event.get_event_handler_details_as_dict()
return session_dict
def get_last_associated_event(self) -> Event | None:
if self.status == Session.CREATED:
return None
return get_last_associated_event_by_type(self, "checkout.session.completed")
class PaymentIntent(models.Model): # nocoverage
customer = models.ForeignKey(Customer, on_delete=CASCADE)
stripe_payment_intent_id = models.CharField(max_length=255, unique=True)
REQUIRES_PAYMENT_METHOD = 1
REQUIRES_CONFIRMATION = 20
REQUIRES_ACTION = 30
PROCESSING = 40
REQUIRES_CAPTURE = 50
CANCELLED = 60
SUCCEEDED = 70
status = models.SmallIntegerField()
last_payment_error = models.JSONField(default=None, null=True)
@classmethod
def get_status_integer_from_status_text(cls, status_text: str) -> int:
return getattr(cls, status_text.upper())
def get_status_as_string(self) -> str:
return {
PaymentIntent.REQUIRES_PAYMENT_METHOD: "requires_payment_method",
PaymentIntent.REQUIRES_CONFIRMATION: "requires_confirmation",
PaymentIntent.REQUIRES_ACTION: "requires_action",
PaymentIntent.PROCESSING: "processing",
PaymentIntent.REQUIRES_CAPTURE: "requires_capture",
PaymentIntent.CANCELLED: "cancelled",
PaymentIntent.SUCCEEDED: "succeeded",
}[self.status]
def get_last_associated_event(self) -> Event | None:
if self.status == PaymentIntent.SUCCEEDED:
event_type = "payment_intent.succeeded"
# TODO: Add test for this case. Not sure how to trigger naturally.
else: # nocoverage
return None # nocoverage
return get_last_associated_event_by_type(self, event_type)
def to_dict(self) -> dict[str, Any]:
payment_intent_dict: dict[str, Any] = {}
payment_intent_dict["status"] = self.get_status_as_string()
event = self.get_last_associated_event()
if event is not None:
payment_intent_dict["event_handler"] = event.get_event_handler_details_as_dict()
return payment_intent_dict
class Invoice(models.Model):
customer = models.ForeignKey(Customer, on_delete=CASCADE)
stripe_invoice_id = models.CharField(max_length=255, unique=True)
plan = models.ForeignKey("CustomerPlan", null=True, default=None, on_delete=SET_NULL)
is_created_for_free_trial_upgrade = models.BooleanField(default=False)
SENT = 1
PAID = 2
VOID = 3
status = models.SmallIntegerField()
def get_status_as_string(self) -> str:
return {
Invoice.SENT: "sent",
Invoice.PAID: "paid",
Invoice.VOID: "void",
}[self.status]
def get_last_associated_event(self) -> Event | None:
if self.status == Invoice.PAID:
event_type = "invoice.paid"
elif self.status == Invoice.VOID:
event_type = "invoice.voided"
# A SENT invoice has no associated event yet.
else:
return None
return get_last_associated_event_by_type(self, event_type)
def to_dict(self) -> dict[str, Any]:
stripe_invoice_dict: dict[str, Any] = {}
stripe_invoice_dict["status"] = self.get_status_as_string()
event = self.get_last_associated_event()
if event is not None:
stripe_invoice_dict["event_handler"] = event.get_event_handler_details_as_dict()
return stripe_invoice_dict

View File

View File

@@ -0,0 +1,121 @@
{
"data": [
{
"amount": 100000,
"amount_captured": 100000,
"amount_refunded": 0,
"application": null,
"application_fee": null,
"application_fee_amount": null,
"balance_transaction": "txn_NORMALIZED",
"billing_details": {
"address": {
"city": "San Francisco",
"country": "US",
"line1": "123 Main St",
"line2": null,
"postal_code": "12345",
"state": "CA"
},
"email": null,
"name": "John Doe",
"phone": null,
"tax_id": null
},
"calculated_statement_descriptor": "ZULIP CLOUD STANDARD",
"captured": true,
"created": 1000000000,
"currency": "usd",
"customer": "cus_UNRECORDED",
"description": "Payment for Invoice",
"destination": null,
"dispute": null,
"disputed": false,
"failure_balance_transaction": null,
"failure_code": null,
"failure_message": null,
"fraud_details": {},
"id": "add_minimum_licenses--Charge.list.1.json",
"livemode": false,
"metadata": {},
"object": "charge",
"on_behalf_of": null,
"order": null,
"outcome": {
"advice_code": null,
"network_advice_code": null,
"network_decline_code": null,
"network_status": "approved_by_network",
"reason": null,
"risk_level": "normal",
"risk_score": 0,
"seller_message": "Payment complete.",
"type": "authorized"
},
"paid": true,
"payment_intent": "pi_NORMALIZED",
"payment_method": "pm_NORMALIZED",
"payment_method_details": {
"card": {
"amount_authorized": 100000,
"authorization_code": "000000",
"brand": "visa",
"checks": {
"address_line1_check": "pass",
"address_postal_code_check": "pass",
"cvc_check": "pass"
},
"country": "US",
"ds_transaction_id": null,
"exp_month": 1,
"exp_year": 9999,
"extended_authorization": {
"status": "disabled"
},
"fingerprint": "NORMALIZED",
"funding": "credit",
"incremental_authorization": {
"status": "unavailable"
},
"installments": null,
"last4": "4242",
"mandate": null,
"multicapture": {
"status": "unavailable"
},
"network": "visa",
"network_token": {
"used": false
},
"network_transaction_id": "000000000000000",
"overcapture": {
"maximum_amount_capturable": 100000,
"status": "unavailable"
},
"regulated_status": "unregulated",
"three_d_secure": null,
"transaction_link_id": null,
"wallet": null
},
"type": "card"
},
"radar_options": {},
"receipt_email": "hamlet@zulip.com",
"receipt_number": null,
"receipt_url": "https://pay.stripe.com/receipts/invoices/NORMALIZED?s=ap",
"refunded": false,
"review": null,
"shipping": null,
"source": null,
"source_transfer": null,
"statement_descriptor": "Zulip Cloud Standard",
"statement_descriptor_suffix": null,
"status": "succeeded",
"transfer_data": null,
"transfer_group": null
}
],
"has_more": false,
"object": "list",
"url": "/v1/charges"
}

View File

@@ -0,0 +1,33 @@
{
"address": null,
"balance": 0,
"created": 1000000000,
"currency": null,
"customer_account": null,
"default_source": null,
"delinquent": false,
"description": "zulip (Zulip Dev)",
"discount": null,
"email": "hamlet@zulip.com",
"id": "add_minimum_licenses--Customer.create.1.json",
"invoice_prefix": "NORMALIZED",
"invoice_settings": {
"custom_fields": null,
"default_payment_method": null,
"footer": null,
"rendering_options": null
},
"livemode": false,
"metadata": {
"realm_id": "1",
"realm_str": "zulip"
},
"name": null,
"next_invoice_sequence": 1,
"object": "customer",
"phone": null,
"preferred_locales": [],
"shipping": null,
"tax_exempt": "none",
"test_clock": null
}

View File

@@ -0,0 +1,33 @@
{
"address": null,
"balance": 0,
"created": 1000000000,
"currency": null,
"customer_account": null,
"default_source": null,
"delinquent": false,
"description": "zulip (Zulip Dev)",
"discount": null,
"email": "hamlet@zulip.com",
"id": "add_minimum_licenses--Customer.create.1.json",
"invoice_prefix": "NORMALIZED",
"invoice_settings": {
"custom_fields": null,
"default_payment_method": "pm_NORMALIZED",
"footer": null,
"rendering_options": null
},
"livemode": false,
"metadata": {
"realm_id": "1",
"realm_str": "zulip"
},
"name": null,
"next_invoice_sequence": 1,
"object": "customer",
"phone": null,
"preferred_locales": [],
"shipping": null,
"tax_exempt": "none",
"test_clock": null
}

View File

@@ -0,0 +1,85 @@
{
"address": null,
"balance": 0,
"created": 1000000000,
"currency": null,
"customer_account": null,
"default_source": null,
"delinquent": false,
"description": "zulip (Zulip Dev)",
"discount": null,
"email": "hamlet@zulip.com",
"id": "add_minimum_licenses--Customer.create.1.json",
"invoice_prefix": "NORMALIZED",
"invoice_settings": {
"custom_fields": null,
"default_payment_method": {
"allow_redisplay": "unspecified",
"billing_details": {
"address": {
"city": "San Francisco",
"country": "US",
"line1": "123 Main St",
"line2": null,
"postal_code": "12345",
"state": "CA"
},
"email": null,
"name": "John Doe",
"phone": null,
"tax_id": null
},
"card": {
"brand": "visa",
"checks": {
"address_line1_check": "pass",
"address_postal_code_check": "pass",
"cvc_check": "pass"
},
"country": "US",
"display_brand": "visa",
"exp_month": 1,
"exp_year": 9999,
"fingerprint": "NORMALIZED",
"funding": "credit",
"generated_from": null,
"last4": "4242",
"networks": {
"available": [
"visa"
],
"preferred": null
},
"regulated_status": "unregulated",
"three_d_secure_usage": {
"supported": true
},
"wallet": null
},
"created": 1000000000,
"customer": "add_minimum_licenses--Customer.create.1.json",
"customer_account": null,
"id": "add_minimum_licenses--Customer.retrieve.1.json",
"livemode": false,
"metadata": {},
"object": "payment_method",
"shared_payment_granted_token": null,
"type": "card"
},
"footer": null,
"rendering_options": null
},
"livemode": false,
"metadata": {
"realm_id": "1",
"realm_str": "zulip"
},
"name": null,
"next_invoice_sequence": 1,
"object": "customer",
"phone": null,
"preferred_locales": [],
"shipping": null,
"tax_exempt": "none",
"test_clock": null
}

View File

@@ -0,0 +1,85 @@
{
"address": null,
"balance": 0,
"created": 1000000000,
"currency": null,
"customer_account": null,
"default_source": null,
"delinquent": false,
"description": "zulip (Zulip Dev)",
"discount": null,
"email": "hamlet@zulip.com",
"id": "add_minimum_licenses--Customer.create.1.json",
"invoice_prefix": "NORMALIZED",
"invoice_settings": {
"custom_fields": null,
"default_payment_method": {
"allow_redisplay": "unspecified",
"billing_details": {
"address": {
"city": "San Francisco",
"country": "US",
"line1": "123 Main St",
"line2": null,
"postal_code": "12345",
"state": "CA"
},
"email": null,
"name": "John Doe",
"phone": null,
"tax_id": null
},
"card": {
"brand": "visa",
"checks": {
"address_line1_check": "pass",
"address_postal_code_check": "pass",
"cvc_check": "pass"
},
"country": "US",
"display_brand": "visa",
"exp_month": 1,
"exp_year": 9999,
"fingerprint": "NORMALIZED",
"funding": "credit",
"generated_from": null,
"last4": "4242",
"networks": {
"available": [
"visa"
],
"preferred": null
},
"regulated_status": "unregulated",
"three_d_secure_usage": {
"supported": true
},
"wallet": null
},
"created": 1000000000,
"customer": "add_minimum_licenses--Customer.create.1.json",
"customer_account": null,
"id": "add_minimum_licenses--Customer.retrieve.1.json",
"livemode": false,
"metadata": {},
"object": "payment_method",
"shared_payment_granted_token": null,
"type": "card"
},
"footer": null,
"rendering_options": null
},
"livemode": false,
"metadata": {
"realm_id": "1",
"realm_str": "zulip"
},
"name": null,
"next_invoice_sequence": 1,
"object": "customer",
"phone": null,
"preferred_locales": [],
"shipping": null,
"tax_exempt": "none",
"test_clock": null
}

View File

@@ -0,0 +1,85 @@
{
"address": null,
"balance": 0,
"created": 1000000000,
"currency": null,
"customer_account": null,
"default_source": null,
"delinquent": false,
"description": "zulip (Zulip Dev)",
"discount": null,
"email": "hamlet@zulip.com",
"id": "add_minimum_licenses--Customer.create.1.json",
"invoice_prefix": "NORMALIZED",
"invoice_settings": {
"custom_fields": null,
"default_payment_method": {
"allow_redisplay": "unspecified",
"billing_details": {
"address": {
"city": "San Francisco",
"country": "US",
"line1": "123 Main St",
"line2": null,
"postal_code": "12345",
"state": "CA"
},
"email": null,
"name": "John Doe",
"phone": null,
"tax_id": null
},
"card": {
"brand": "visa",
"checks": {
"address_line1_check": "pass",
"address_postal_code_check": "pass",
"cvc_check": "pass"
},
"country": "US",
"display_brand": "visa",
"exp_month": 1,
"exp_year": 9999,
"fingerprint": "NORMALIZED",
"funding": "credit",
"generated_from": null,
"last4": "4242",
"networks": {
"available": [
"visa"
],
"preferred": null
},
"regulated_status": "unregulated",
"three_d_secure_usage": {
"supported": true
},
"wallet": null
},
"created": 1000000000,
"customer": "add_minimum_licenses--Customer.create.1.json",
"customer_account": null,
"id": "add_minimum_licenses--Customer.retrieve.1.json",
"livemode": false,
"metadata": {},
"object": "payment_method",
"shared_payment_granted_token": null,
"type": "card"
},
"footer": null,
"rendering_options": null
},
"livemode": false,
"metadata": {
"realm_id": "1",
"realm_str": "zulip"
},
"name": null,
"next_invoice_sequence": 1,
"object": "customer",
"phone": null,
"preferred_locales": [],
"shipping": null,
"tax_exempt": "none",
"test_clock": null
}

View File

@@ -0,0 +1,85 @@
{
"address": null,
"balance": 0,
"created": 1000000000,
"currency": null,
"customer_account": null,
"default_source": null,
"delinquent": false,
"description": "zulip (Zulip Dev)",
"discount": null,
"email": "hamlet@zulip.com",
"id": "add_minimum_licenses--Customer.create.1.json",
"invoice_prefix": "NORMALIZED",
"invoice_settings": {
"custom_fields": null,
"default_payment_method": {
"allow_redisplay": "unspecified",
"billing_details": {
"address": {
"city": "San Francisco",
"country": "US",
"line1": "123 Main St",
"line2": null,
"postal_code": "12345",
"state": "CA"
},
"email": null,
"name": "John Doe",
"phone": null,
"tax_id": null
},
"card": {
"brand": "visa",
"checks": {
"address_line1_check": "pass",
"address_postal_code_check": "pass",
"cvc_check": "pass"
},
"country": "US",
"display_brand": "visa",
"exp_month": 1,
"exp_year": 9999,
"fingerprint": "NORMALIZED",
"funding": "credit",
"generated_from": null,
"last4": "4242",
"networks": {
"available": [
"visa"
],
"preferred": null
},
"regulated_status": "unregulated",
"three_d_secure_usage": {
"supported": true
},
"wallet": null
},
"created": 1000000000,
"customer": "add_minimum_licenses--Customer.create.1.json",
"customer_account": null,
"id": "add_minimum_licenses--Customer.retrieve.1.json",
"livemode": false,
"metadata": {},
"object": "payment_method",
"shared_payment_granted_token": null,
"type": "card"
},
"footer": null,
"rendering_options": null
},
"livemode": false,
"metadata": {
"realm_id": "1",
"realm_str": "zulip"
},
"name": null,
"next_invoice_sequence": 1,
"object": "customer",
"phone": null,
"preferred_locales": [],
"shipping": null,
"tax_exempt": "none",
"test_clock": null
}

View File

@@ -0,0 +1,85 @@
{
"address": null,
"balance": 0,
"created": 1000000000,
"currency": null,
"customer_account": null,
"default_source": null,
"delinquent": false,
"description": "zulip (Zulip Dev)",
"discount": null,
"email": "hamlet@zulip.com",
"id": "add_minimum_licenses--Customer.create.1.json",
"invoice_prefix": "NORMALIZED",
"invoice_settings": {
"custom_fields": null,
"default_payment_method": {
"allow_redisplay": "unspecified",
"billing_details": {
"address": {
"city": "San Francisco",
"country": "US",
"line1": "123 Main St",
"line2": null,
"postal_code": "12345",
"state": "CA"
},
"email": null,
"name": "John Doe",
"phone": null,
"tax_id": null
},
"card": {
"brand": "visa",
"checks": {
"address_line1_check": "pass",
"address_postal_code_check": "pass",
"cvc_check": "pass"
},
"country": "US",
"display_brand": "visa",
"exp_month": 1,
"exp_year": 9999,
"fingerprint": "NORMALIZED",
"funding": "credit",
"generated_from": null,
"last4": "4242",
"networks": {
"available": [
"visa"
],
"preferred": null
},
"regulated_status": "unregulated",
"three_d_secure_usage": {
"supported": true
},
"wallet": null
},
"created": 1000000000,
"customer": "add_minimum_licenses--Customer.create.1.json",
"customer_account": null,
"id": "add_minimum_licenses--Customer.retrieve.1.json",
"livemode": false,
"metadata": {},
"object": "payment_method",
"shared_payment_granted_token": null,
"type": "card"
},
"footer": null,
"rendering_options": null
},
"livemode": false,
"metadata": {
"realm_id": "1",
"realm_str": "zulip"
},
"name": null,
"next_invoice_sequence": 1,
"object": "customer",
"phone": null,
"preferred_locales": [],
"shipping": null,
"tax_exempt": "none",
"test_clock": null
}

View File

@@ -0,0 +1,180 @@
{
"data": [
{
"api_version": "2025-11-17.clover",
"created": 1000000000,
"data": {
"object": {
"account_country": "US",
"account_name": "NORMALIZED",
"account_tax_ids": null,
"amount_due": 100000,
"amount_overpaid": 0,
"amount_paid": 100000,
"amount_remaining": 0,
"amount_shipping": 0,
"application": null,
"attempt_count": 1,
"attempted": true,
"auto_advance": false,
"automatic_tax": {
"disabled_reason": null,
"enabled": false,
"liability": null,
"provider": null,
"status": null
},
"automatically_finalizes_at": null,
"billing_reason": "manual",
"collection_method": "charge_automatically",
"created": 1000000000,
"currency": "usd",
"custom_fields": null,
"customer": "add_minimum_licenses--Customer.create.1.json",
"customer_account": null,
"customer_address": null,
"customer_email": "hamlet@zulip.com",
"customer_name": null,
"customer_phone": null,
"customer_shipping": null,
"customer_tax_exempt": "none",
"customer_tax_ids": [],
"default_payment_method": null,
"default_source": null,
"default_tax_rates": [],
"description": "If paying by bank transfer outside of the US, use the OUR option for who should pay bank transfer fees, to make sure that your invoice is paid in full.",
"discounts": [],
"due_date": null,
"effective_at": 1000000000,
"ending_balance": 0,
"footer": null,
"from_invoice": null,
"hosted_invoice_url": "https://invoice.stripe.com/i/acct_NORMALIZED/test_NORMALIZED?s=ap",
"id": "add_minimum_licenses--Event.list.1.json",
"invoice_pdf": "https://pay.stripe.com/invoice/acct_NORMALIZED/test_NORMALIZED/pdf?s=ap",
"issuer": {
"type": "self"
},
"last_finalization_error": null,
"latest_revision": null,
"lines": {
"data": [
{
"amount": 100000,
"currency": "usd",
"description": "Zulip Cloud Standard",
"discount_amounts": [],
"discountable": false,
"discounts": [],
"id": "add_minimum_licenses--Event.list.1.json",
"invoice": "add_minimum_licenses--Event.list.1.json",
"livemode": false,
"metadata": {},
"object": "line_item",
"parent": {
"invoice_item_details": {
"invoice_item": "ii_NORMALIZED",
"proration": false,
"proration_details": {
"credited_items": null
},
"subscription": null
},
"license_fee_subscription_details": null,
"subscription_item_details": null,
"type": "invoice_item_details"
},
"period": {
"end": 1357095845,
"start": 1325473445
},
"pretax_credit_amounts": [],
"pricing": {
"price_details": {
"price": "price_NORMALIZED",
"product": "prod_NORMALIZED"
},
"type": "price_details",
"unit_amount_decimal": "4000"
},
"quantity": 25,
"quantity_decimal": "25",
"subtotal": 100000,
"taxes": []
}
],
"has_more": false,
"object": "list",
"total_count": 1,
"url": "/v1/invoices/add_minimum_licenses--Event.list.1.json/lines"
},
"livemode": false,
"metadata": {
"billing_schedule": "1",
"current_plan_id": "None",
"license_management": "automatic",
"licenses": "25",
"on_free_trial": "False",
"plan_tier": "1",
"user_id": "10"
},
"next_payment_attempt": null,
"number": "NORMALIZED",
"object": "invoice",
"on_behalf_of": null,
"parent": null,
"payment_settings": {
"default_mandate": null,
"payment_method_options": null,
"payment_method_types": null
},
"period_end": 1000000000,
"period_start": 1000000000,
"post_payment_credit_notes_amount": 0,
"pre_payment_credit_notes_amount": 0,
"receipt_number": null,
"rendering": {
"amount_tax_display": null,
"pdf": {
"page_size": "letter"
},
"template": null,
"template_version": null
},
"shipping_cost": null,
"shipping_details": null,
"starting_balance": 0,
"statement_descriptor": "Zulip Cloud Standard",
"status": "paid",
"status_transitions": {
"finalized_at": 1000000000,
"marked_uncollectible_at": null,
"paid_at": 1000000000,
"voided_at": null
},
"subtotal": 100000,
"subtotal_excluding_tax": 100000,
"test_clock": null,
"total": 100000,
"total_discount_amounts": [],
"total_excluding_tax": 100000,
"total_pretax_credit_amounts": [],
"total_taxes": [],
"webhooks_delivered_at": null
}
},
"id": "add_minimum_licenses--Event.list.1.json",
"livemode": false,
"object": "event",
"pending_webhooks": 0,
"request": {
"id": "add_minimum_licenses--Event.list.1.json",
"idempotency_key": "00000000-0000-0000-0000-000000000000"
},
"type": "invoice.paid"
}
],
"has_more": false,
"object": "list",
"url": "/v1/events"
}

View File

@@ -0,0 +1,114 @@
{
"account_country": "US",
"account_name": "NORMALIZED",
"account_tax_ids": null,
"amount_due": 0,
"amount_overpaid": 0,
"amount_paid": 0,
"amount_remaining": 0,
"amount_shipping": 0,
"application": null,
"attempt_count": 0,
"attempted": false,
"auto_advance": false,
"automatic_tax": {
"disabled_reason": null,
"enabled": false,
"liability": null,
"provider": null,
"status": null
},
"automatically_finalizes_at": null,
"billing_reason": "manual",
"collection_method": "charge_automatically",
"created": 1000000000,
"currency": "usd",
"custom_fields": null,
"customer": "add_minimum_licenses--Customer.create.1.json",
"customer_account": null,
"customer_address": null,
"customer_email": "hamlet@zulip.com",
"customer_name": null,
"customer_phone": null,
"customer_shipping": null,
"customer_tax_exempt": "none",
"customer_tax_ids": [],
"default_payment_method": null,
"default_source": null,
"default_tax_rates": [],
"description": "If paying by bank transfer outside of the US, use the OUR option for who should pay bank transfer fees, to make sure that your invoice is paid in full.",
"discounts": [],
"due_date": null,
"effective_at": null,
"ending_balance": null,
"footer": null,
"from_invoice": null,
"hosted_invoice_url": null,
"id": "add_minimum_licenses--Event.list.1.json",
"invoice_pdf": null,
"issuer": {
"type": "self"
},
"last_finalization_error": null,
"latest_revision": null,
"lines": {
"data": [],
"has_more": false,
"object": "list",
"total_count": 0,
"url": "/v1/invoices/add_minimum_licenses--Event.list.1.json/lines"
},
"livemode": false,
"metadata": {
"billing_schedule": "1",
"current_plan_id": "None",
"license_management": "automatic",
"licenses": "25",
"on_free_trial": "False",
"plan_tier": "1",
"user_id": "10"
},
"next_payment_attempt": null,
"number": null,
"object": "invoice",
"on_behalf_of": null,
"parent": null,
"payment_settings": {
"default_mandate": null,
"payment_method_options": null,
"payment_method_types": null
},
"period_end": 1000000000,
"period_start": 1000000000,
"post_payment_credit_notes_amount": 0,
"pre_payment_credit_notes_amount": 0,
"receipt_number": null,
"rendering": {
"amount_tax_display": null,
"pdf": {
"page_size": "auto"
},
"template": null,
"template_version": null
},
"shipping_cost": null,
"shipping_details": null,
"starting_balance": 0,
"statement_descriptor": "Zulip Cloud Standard",
"status": "draft",
"status_transitions": {
"finalized_at": null,
"marked_uncollectible_at": null,
"paid_at": null,
"voided_at": null
},
"subtotal": 0,
"subtotal_excluding_tax": 0,
"test_clock": null,
"total": 0,
"total_discount_amounts": [],
"total_excluding_tax": 0,
"total_pretax_credit_amounts": [],
"total_taxes": [],
"webhooks_delivered_at": null
}

View File

@@ -0,0 +1,158 @@
{
"account_country": "US",
"account_name": "NORMALIZED",
"account_tax_ids": null,
"amount_due": 100000,
"amount_overpaid": 0,
"amount_paid": 0,
"amount_remaining": 100000,
"amount_shipping": 0,
"application": null,
"attempt_count": 0,
"attempted": false,
"auto_advance": false,
"automatic_tax": {
"disabled_reason": null,
"enabled": false,
"liability": null,
"provider": null,
"status": null
},
"automatically_finalizes_at": null,
"billing_reason": "manual",
"collection_method": "charge_automatically",
"created": 1000000000,
"currency": "usd",
"custom_fields": null,
"customer": "add_minimum_licenses--Customer.create.1.json",
"customer_account": null,
"customer_address": null,
"customer_email": "hamlet@zulip.com",
"customer_name": null,
"customer_phone": null,
"customer_shipping": null,
"customer_tax_exempt": "none",
"customer_tax_ids": [],
"default_payment_method": null,
"default_source": null,
"default_tax_rates": [],
"description": "If paying by bank transfer outside of the US, use the OUR option for who should pay bank transfer fees, to make sure that your invoice is paid in full.",
"discounts": [],
"due_date": null,
"effective_at": 1000000000,
"ending_balance": 0,
"footer": null,
"from_invoice": null,
"hosted_invoice_url": "https://invoice.stripe.com/i/acct_NORMALIZED/test_NORMALIZED?s=ap",
"id": "add_minimum_licenses--Event.list.1.json",
"invoice_pdf": "https://pay.stripe.com/invoice/acct_NORMALIZED/test_NORMALIZED/pdf?s=ap",
"issuer": {
"type": "self"
},
"last_finalization_error": null,
"latest_revision": null,
"lines": {
"data": [
{
"amount": 100000,
"currency": "usd",
"description": "Zulip Cloud Standard",
"discount_amounts": [],
"discountable": false,
"discounts": [],
"id": "add_minimum_licenses--Event.list.1.json",
"invoice": "add_minimum_licenses--Event.list.1.json",
"livemode": false,
"metadata": {},
"object": "line_item",
"parent": {
"invoice_item_details": {
"invoice_item": "ii_NORMALIZED",
"proration": false,
"proration_details": {
"credited_items": null
},
"subscription": null
},
"license_fee_subscription_details": null,
"subscription_item_details": null,
"type": "invoice_item_details"
},
"period": {
"end": 1357095845,
"start": 1325473445
},
"pretax_credit_amounts": [],
"pricing": {
"price_details": {
"price": "price_NORMALIZED",
"product": "prod_NORMALIZED"
},
"type": "price_details",
"unit_amount_decimal": "4000"
},
"quantity": 25,
"quantity_decimal": "25",
"subtotal": 100000,
"taxes": []
}
],
"has_more": false,
"object": "list",
"total_count": 1,
"url": "/v1/invoices/add_minimum_licenses--Event.list.1.json/lines"
},
"livemode": false,
"metadata": {
"billing_schedule": "1",
"current_plan_id": "None",
"license_management": "automatic",
"licenses": "25",
"on_free_trial": "False",
"plan_tier": "1",
"user_id": "10"
},
"next_payment_attempt": null,
"number": "NORMALIZED",
"object": "invoice",
"on_behalf_of": null,
"parent": null,
"payment_settings": {
"default_mandate": null,
"payment_method_options": null,
"payment_method_types": null
},
"period_end": 1000000000,
"period_start": 1000000000,
"post_payment_credit_notes_amount": 0,
"pre_payment_credit_notes_amount": 0,
"receipt_number": null,
"rendering": {
"amount_tax_display": null,
"pdf": {
"page_size": "letter"
},
"template": null,
"template_version": null
},
"shipping_cost": null,
"shipping_details": null,
"starting_balance": 0,
"statement_descriptor": "Zulip Cloud Standard",
"status": "open",
"status_transitions": {
"finalized_at": 1000000000,
"marked_uncollectible_at": null,
"paid_at": null,
"voided_at": null
},
"subtotal": 100000,
"subtotal_excluding_tax": 100000,
"test_clock": null,
"total": 100000,
"total_discount_amounts": [],
"total_excluding_tax": 100000,
"total_pretax_credit_amounts": [],
"total_taxes": [],
"webhooks_delivered_at": null
}

View File

@@ -0,0 +1,6 @@
{
"data": [],
"has_more": false,
"object": "list",
"url": "/v1/invoices"
}

View File

@@ -0,0 +1,158 @@
{
"account_country": "US",
"account_name": "NORMALIZED",
"account_tax_ids": null,
"amount_due": 100000,
"amount_overpaid": 0,
"amount_paid": 100000,
"amount_remaining": 0,
"amount_shipping": 0,
"application": null,
"attempt_count": 1,
"attempted": true,
"auto_advance": false,
"automatic_tax": {
"disabled_reason": null,
"enabled": false,
"liability": null,
"provider": null,
"status": null
},
"automatically_finalizes_at": null,
"billing_reason": "manual",
"collection_method": "charge_automatically",
"created": 1000000000,
"currency": "usd",
"custom_fields": null,
"customer": "add_minimum_licenses--Customer.create.1.json",
"customer_account": null,
"customer_address": null,
"customer_email": "hamlet@zulip.com",
"customer_name": null,
"customer_phone": null,
"customer_shipping": null,
"customer_tax_exempt": "none",
"customer_tax_ids": [],
"default_payment_method": null,
"default_source": null,
"default_tax_rates": [],
"description": "If paying by bank transfer outside of the US, use the OUR option for who should pay bank transfer fees, to make sure that your invoice is paid in full.",
"discounts": [],
"due_date": null,
"effective_at": 1000000000,
"ending_balance": 0,
"footer": null,
"from_invoice": null,
"hosted_invoice_url": "https://invoice.stripe.com/i/acct_NORMALIZED/test_NORMALIZED?s=ap",
"id": "add_minimum_licenses--Event.list.1.json",
"invoice_pdf": "https://pay.stripe.com/invoice/acct_NORMALIZED/test_NORMALIZED/pdf?s=ap",
"issuer": {
"type": "self"
},
"last_finalization_error": null,
"latest_revision": null,
"lines": {
"data": [
{
"amount": 100000,
"currency": "usd",
"description": "Zulip Cloud Standard",
"discount_amounts": [],
"discountable": false,
"discounts": [],
"id": "add_minimum_licenses--Event.list.1.json",
"invoice": "add_minimum_licenses--Event.list.1.json",
"livemode": false,
"metadata": {},
"object": "line_item",
"parent": {
"invoice_item_details": {
"invoice_item": "ii_NORMALIZED",
"proration": false,
"proration_details": {
"credited_items": null
},
"subscription": null
},
"license_fee_subscription_details": null,
"subscription_item_details": null,
"type": "invoice_item_details"
},
"period": {
"end": 1357095845,
"start": 1325473445
},
"pretax_credit_amounts": [],
"pricing": {
"price_details": {
"price": "price_NORMALIZED",
"product": "prod_NORMALIZED"
},
"type": "price_details",
"unit_amount_decimal": "4000"
},
"quantity": 25,
"quantity_decimal": "25",
"subtotal": 100000,
"taxes": []
}
],
"has_more": false,
"object": "list",
"total_count": 1,
"url": "/v1/invoices/add_minimum_licenses--Event.list.1.json/lines"
},
"livemode": false,
"metadata": {
"billing_schedule": "1",
"current_plan_id": "None",
"license_management": "automatic",
"licenses": "25",
"on_free_trial": "False",
"plan_tier": "1",
"user_id": "10"
},
"next_payment_attempt": null,
"number": "NORMALIZED",
"object": "invoice",
"on_behalf_of": null,
"parent": null,
"payment_settings": {
"default_mandate": null,
"payment_method_options": null,
"payment_method_types": null
},
"period_end": 1000000000,
"period_start": 1000000000,
"post_payment_credit_notes_amount": 0,
"pre_payment_credit_notes_amount": 0,
"receipt_number": null,
"rendering": {
"amount_tax_display": null,
"pdf": {
"page_size": "letter"
},
"template": null,
"template_version": null
},
"shipping_cost": null,
"shipping_details": null,
"starting_balance": 0,
"statement_descriptor": "Zulip Cloud Standard",
"status": "paid",
"status_transitions": {
"finalized_at": 1000000000,
"marked_uncollectible_at": null,
"paid_at": 1000000000,
"voided_at": null
},
"subtotal": 100000,
"subtotal_excluding_tax": 100000,
"test_clock": null,
"total": 100000,
"total_discount_amounts": [],
"total_excluding_tax": 100000,
"total_pretax_credit_amounts": [],
"total_taxes": [],
"webhooks_delivered_at": null
}

View File

@@ -0,0 +1,34 @@
{
"amount": 100000,
"currency": "usd",
"customer": "add_minimum_licenses--Customer.create.1.json",
"customer_account": null,
"date": 1000000000,
"description": "Zulip Cloud Standard",
"discountable": false,
"discounts": [],
"id": "add_minimum_licenses--InvoiceItem.create.1.json",
"invoice": "add_minimum_licenses--Event.list.1.json",
"livemode": false,
"metadata": {},
"net_amount": 100000,
"object": "invoiceitem",
"parent": null,
"period": {
"end": 1357095845,
"start": 1325473445
},
"pricing": {
"price_details": {
"price": "price_NORMALIZED",
"product": "prod_NORMALIZED"
},
"type": "price_details",
"unit_amount_decimal": "4000"
},
"proration": false,
"quantity": 25,
"quantity_decimal": "25",
"tax_rates": [],
"test_clock": null
}

View File

@@ -0,0 +1,53 @@
{
"allow_redisplay": "unspecified",
"billing_details": {
"address": {
"city": "San Francisco",
"country": "US",
"line1": "123 Main St",
"line2": null,
"postal_code": "12345",
"state": "CA"
},
"email": null,
"name": "John Doe",
"phone": null,
"tax_id": null
},
"card": {
"brand": "visa",
"checks": {
"address_line1_check": "unchecked",
"address_postal_code_check": "unchecked",
"cvc_check": "unchecked"
},
"country": "US",
"display_brand": "visa",
"exp_month": 1,
"exp_year": 9999,
"fingerprint": "NORMALIZED",
"funding": "credit",
"generated_from": null,
"last4": "4242",
"networks": {
"available": [
"visa"
],
"preferred": null
},
"regulated_status": "unregulated",
"three_d_secure_usage": {
"supported": true
},
"wallet": null
},
"created": 1000000000,
"customer": null,
"customer_account": null,
"id": "add_minimum_licenses--Customer.retrieve.1.json",
"livemode": false,
"metadata": {},
"object": "payment_method",
"shared_payment_granted_token": null,
"type": "card"
}

View File

@@ -0,0 +1,39 @@
{
"application": null,
"automatic_payment_methods": null,
"cancellation_reason": null,
"client_secret": "NORMALIZED",
"created": 1000000000,
"customer": "add_minimum_licenses--Customer.create.1.json",
"customer_account": null,
"description": null,
"excluded_payment_method_types": null,
"flow_directions": null,
"id": "add_minimum_licenses--SetupIntent.create.1.json",
"last_setup_error": null,
"latest_attempt": "setatt_NORMALIZED",
"livemode": false,
"managed_payments": {
"enabled": false
},
"mandate": null,
"metadata": {},
"next_action": null,
"object": "setup_intent",
"on_behalf_of": null,
"payment_method": "add_minimum_licenses--Customer.retrieve.1.json",
"payment_method_configuration_details": null,
"payment_method_options": {
"card": {
"mandate_options": null,
"network": null,
"request_three_d_secure": "automatic"
}
},
"payment_method_types": [
"card"
],
"single_use_mandate": null,
"status": "succeeded",
"usage": "off_session"
}

View File

@@ -0,0 +1,46 @@
{
"data": [
{
"application": null,
"automatic_payment_methods": null,
"cancellation_reason": null,
"client_secret": "NORMALIZED",
"created": 1000000000,
"customer": "add_minimum_licenses--Customer.create.1.json",
"customer_account": null,
"description": null,
"excluded_payment_method_types": null,
"flow_directions": null,
"id": "add_minimum_licenses--SetupIntent.list.1.json",
"last_setup_error": null,
"latest_attempt": null,
"livemode": false,
"managed_payments": {
"enabled": false
},
"mandate": null,
"metadata": {},
"next_action": null,
"object": "setup_intent",
"on_behalf_of": null,
"payment_method": null,
"payment_method_configuration_details": null,
"payment_method_options": {
"card": {
"mandate_options": null,
"network": null,
"request_three_d_secure": "automatic"
}
},
"payment_method_types": [
"card"
],
"single_use_mandate": null,
"status": "requires_payment_method",
"usage": "off_session"
}
],
"has_more": false,
"object": "list",
"url": "/v1/setup_intents"
}

View File

@@ -0,0 +1,39 @@
{
"application": null,
"automatic_payment_methods": null,
"cancellation_reason": null,
"client_secret": "NORMALIZED",
"created": 1000000000,
"customer": "add_minimum_licenses--Customer.create.1.json",
"customer_account": null,
"description": null,
"excluded_payment_method_types": null,
"flow_directions": null,
"id": "add_minimum_licenses--SetupIntent.create.1.json",
"last_setup_error": null,
"latest_attempt": "setatt_NORMALIZED",
"livemode": false,
"managed_payments": {
"enabled": false
},
"mandate": null,
"metadata": {},
"next_action": null,
"object": "setup_intent",
"on_behalf_of": null,
"payment_method": "add_minimum_licenses--Customer.retrieve.1.json",
"payment_method_configuration_details": null,
"payment_method_options": {
"card": {
"mandate_options": null,
"network": null,
"request_three_d_secure": "automatic"
}
},
"payment_method_types": [
"card"
],
"single_use_mandate": null,
"status": "succeeded",
"usage": "off_session"
}

View File

@@ -0,0 +1,112 @@
{
"adaptive_pricing": {
"enabled": false
},
"after_expiration": null,
"allow_promotion_codes": null,
"amount_subtotal": null,
"amount_total": null,
"automatic_tax": {
"enabled": false,
"liability": null,
"provider": null,
"status": null
},
"billing_address_collection": "required",
"branding_settings": {
"background_color": "#dbedff",
"border_style": "rounded",
"button_color": "#3e6bfe",
"display_name": "STRIPE SANDBOX",
"font_family": "source_sans_pro",
"icon": {
"file": "file_NORMALIZED",
"type": "file"
},
"logo": {
"file": "file_NORMALIZED",
"type": "file"
}
},
"cancel_url": "http://zulip.testserver/upgrade/?manual_license_management=false&tier=1",
"client_reference_id": null,
"client_secret": null,
"collected_information": {
"business_name": null,
"individual_name": null,
"shipping_details": null
},
"consent": null,
"consent_collection": null,
"created": 1000000000,
"currency": null,
"currency_conversion": null,
"custom_fields": [],
"custom_text": {
"after_submit": null,
"shipping_address": null,
"submit": null,
"terms_of_service_acceptance": null
},
"customer": "add_minimum_licenses--Customer.create.1.json",
"customer_account": null,
"customer_creation": null,
"customer_details": {
"address": null,
"business_name": null,
"email": "hamlet@zulip.com",
"individual_name": null,
"name": null,
"phone": null,
"tax_exempt": null,
"tax_ids": null
},
"customer_email": null,
"discounts": null,
"expires_at": 1000000000,
"id": "add_minimum_licenses--checkout.Session.create.1.json",
"integration_identifier": null,
"invoice": null,
"invoice_creation": null,
"livemode": false,
"locale": null,
"managed_payments": null,
"metadata": {
"type": "card_update",
"user_id": "10"
},
"mode": "setup",
"object": "checkout.session",
"origin_context": null,
"payment_intent": null,
"payment_link": null,
"payment_method_collection": "always",
"payment_method_configuration_details": null,
"payment_method_options": {
"card": {
"request_three_d_secure": "automatic"
}
},
"payment_method_types": [
"card"
],
"payment_status": "no_payment_required",
"permissions": null,
"phone_number_collection": {
"enabled": false
},
"recovered_from": null,
"saved_payment_method_options": null,
"setup_intent": "add_minimum_licenses--SetupIntent.list.1.json",
"shipping_address_collection": null,
"shipping_cost": null,
"shipping_options": [],
"status": "open",
"submit_type": null,
"subscription": null,
"success_url": "http://zulip.testserver/billing/event_status/?stripe_session_id={CHECKOUT_SESSION_ID}",
"total_details": null,
"ui_mode": "hosted",
"url": "https://checkout.stripe.com/c/pay/cs_test_NORMALIZED",
"wallet_options": null
}

View File

@@ -0,0 +1,119 @@
{
"data": [
{
"adaptive_pricing": {
"enabled": false
},
"after_expiration": null,
"allow_promotion_codes": null,
"amount_subtotal": null,
"amount_total": null,
"automatic_tax": {
"enabled": false,
"liability": null,
"provider": null,
"status": null
},
"billing_address_collection": "required",
"branding_settings": {
"background_color": "#dbedff",
"border_style": "rounded",
"button_color": "#3e6bfe",
"display_name": "STRIPE SANDBOX",
"font_family": "source_sans_pro",
"icon": {
"file": "file_NORMALIZED",
"type": "file"
},
"logo": {
"file": "file_NORMALIZED",
"type": "file"
}
},
"cancel_url": "http://zulip.testserver/upgrade/?manual_license_management=false&tier=1",
"client_reference_id": null,
"client_secret": null,
"collected_information": {
"business_name": null,
"individual_name": null,
"shipping_details": null
},
"consent": null,
"consent_collection": null,
"created": 1000000000,
"currency": null,
"currency_conversion": null,
"custom_fields": [],
"custom_text": {
"after_submit": null,
"shipping_address": null,
"submit": null,
"terms_of_service_acceptance": null
},
"customer": "add_minimum_licenses--Customer.create.1.json",
"customer_account": null,
"customer_creation": null,
"customer_details": {
"address": null,
"business_name": null,
"email": "hamlet@zulip.com",
"individual_name": null,
"name": null,
"phone": null,
"tax_exempt": null,
"tax_ids": null
},
"customer_email": null,
"discounts": null,
"expires_at": 1000000000,
"id": "add_minimum_licenses--checkout.Session.create.1.json",
"integration_identifier": null,
"invoice": null,
"invoice_creation": null,
"livemode": false,
"locale": null,
"managed_payments": null,
"metadata": {
"type": "card_update",
"user_id": "10"
},
"mode": "setup",
"object": "checkout.session",
"origin_context": null,
"payment_intent": null,
"payment_link": null,
"payment_method_collection": "always",
"payment_method_configuration_details": null,
"payment_method_options": {
"card": {
"request_three_d_secure": "automatic"
}
},
"payment_method_types": [
"card"
],
"payment_status": "no_payment_required",
"permissions": null,
"phone_number_collection": {
"enabled": false
},
"recovered_from": null,
"saved_payment_method_options": null,
"setup_intent": "add_minimum_licenses--SetupIntent.list.1.json",
"shipping_address_collection": null,
"shipping_cost": null,
"shipping_options": [],
"status": "open",
"submit_type": null,
"subscription": null,
"success_url": "http://zulip.testserver/billing/event_status/?stripe_session_id={CHECKOUT_SESSION_ID}",
"total_details": null,
"ui_mode": "hosted",
"url": "https://checkout.stripe.com/c/pay/cs_test_NORMALIZED",
"wallet_options": null
}
],
"has_more": false,
"object": "list",
"url": "/v1/checkout/sessions"
}

View File

@@ -0,0 +1,121 @@
{
"data": [
{
"amount": 7200,
"amount_captured": 7200,
"amount_refunded": 0,
"application": null,
"application_fee": null,
"application_fee_amount": null,
"balance_transaction": "txn_NORMALIZED",
"billing_details": {
"address": {
"city": "San Francisco",
"country": "US",
"line1": "123 Main St",
"line2": null,
"postal_code": "12345",
"state": "CA"
},
"email": null,
"name": "John Doe",
"phone": null,
"tax_id": null
},
"calculated_statement_descriptor": "ZULIP CLOUD STANDARD",
"captured": true,
"created": 1000000000,
"currency": "usd",
"customer": "cus_UNRECORDED",
"description": "Payment for Invoice",
"destination": null,
"dispute": null,
"disputed": false,
"failure_balance_transaction": null,
"failure_code": null,
"failure_message": null,
"fraud_details": {},
"id": "attach_discount_to_realm--Charge.list.1.json",
"livemode": false,
"metadata": {},
"object": "charge",
"on_behalf_of": null,
"order": null,
"outcome": {
"advice_code": null,
"network_advice_code": null,
"network_decline_code": null,
"network_status": "approved_by_network",
"reason": null,
"risk_level": "normal",
"risk_score": 0,
"seller_message": "Payment complete.",
"type": "authorized"
},
"paid": true,
"payment_intent": "pi_NORMALIZED",
"payment_method": "pm_NORMALIZED",
"payment_method_details": {
"card": {
"amount_authorized": 7200,
"authorization_code": "000000",
"brand": "visa",
"checks": {
"address_line1_check": "pass",
"address_postal_code_check": "pass",
"cvc_check": "pass"
},
"country": "US",
"ds_transaction_id": null,
"exp_month": 1,
"exp_year": 9999,
"extended_authorization": {
"status": "disabled"
},
"fingerprint": "NORMALIZED",
"funding": "credit",
"incremental_authorization": {
"status": "unavailable"
},
"installments": null,
"last4": "4242",
"mandate": null,
"multicapture": {
"status": "unavailable"
},
"network": "visa",
"network_token": {
"used": false
},
"network_transaction_id": "000000000000000",
"overcapture": {
"maximum_amount_capturable": 7200,
"status": "unavailable"
},
"regulated_status": "unregulated",
"three_d_secure": null,
"transaction_link_id": null,
"wallet": null
},
"type": "card"
},
"radar_options": {},
"receipt_email": "hamlet@zulip.com",
"receipt_number": null,
"receipt_url": "https://pay.stripe.com/receipts/invoices/NORMALIZED?s=ap",
"refunded": false,
"review": null,
"shipping": null,
"source": null,
"source_transfer": null,
"statement_descriptor": "Zulip Cloud Standard",
"statement_descriptor_suffix": null,
"status": "succeeded",
"transfer_data": null,
"transfer_group": null
}
],
"has_more": false,
"object": "list",
"url": "/v1/charges"
}

View File

@@ -0,0 +1,235 @@
{
"data": [
{
"amount": 36000,
"amount_captured": 36000,
"amount_refunded": 0,
"application": null,
"application_fee": null,
"application_fee_amount": null,
"balance_transaction": "txn_NORMALIZED",
"billing_details": {
"address": {
"city": "San Francisco",
"country": "US",
"line1": "123 Main St",
"line2": null,
"postal_code": "12345",
"state": "CA"
},
"email": null,
"name": "John Doe",
"phone": null,
"tax_id": null
},
"calculated_statement_descriptor": "ZULIP CLOUD STANDARD",
"captured": true,
"created": 1000000000,
"currency": "usd",
"customer": "cus_UNRECORDED",
"description": "Payment for Invoice",
"destination": null,
"dispute": null,
"disputed": false,
"failure_balance_transaction": null,
"failure_code": null,
"failure_message": null,
"fraud_details": {},
"id": "attach_discount_to_realm--Charge.list.2.json",
"livemode": false,
"metadata": {},
"object": "charge",
"on_behalf_of": null,
"order": null,
"outcome": {
"advice_code": null,
"network_advice_code": null,
"network_decline_code": null,
"network_status": "approved_by_network",
"reason": null,
"risk_level": "normal",
"risk_score": 0,
"seller_message": "Payment complete.",
"type": "authorized"
},
"paid": true,
"payment_intent": "pi_NORMALIZED",
"payment_method": "pm_NORMALIZED",
"payment_method_details": {
"card": {
"amount_authorized": 36000,
"authorization_code": "000000",
"brand": "visa",
"checks": {
"address_line1_check": "pass",
"address_postal_code_check": "pass",
"cvc_check": "pass"
},
"country": "US",
"ds_transaction_id": null,
"exp_month": 1,
"exp_year": 9999,
"extended_authorization": {
"status": "disabled"
},
"fingerprint": "NORMALIZED",
"funding": "credit",
"incremental_authorization": {
"status": "unavailable"
},
"installments": null,
"last4": "4242",
"mandate": null,
"multicapture": {
"status": "unavailable"
},
"network": "visa",
"network_token": {
"used": false
},
"network_transaction_id": "000000000000000",
"overcapture": {
"maximum_amount_capturable": 36000,
"status": "unavailable"
},
"regulated_status": "unregulated",
"three_d_secure": null,
"transaction_link_id": null,
"wallet": null
},
"type": "card"
},
"radar_options": {},
"receipt_email": "hamlet@zulip.com",
"receipt_number": null,
"receipt_url": "https://pay.stripe.com/receipts/invoices/NORMALIZED?s=ap",
"refunded": false,
"review": null,
"shipping": null,
"source": null,
"source_transfer": null,
"statement_descriptor": "Zulip Cloud Standard",
"statement_descriptor_suffix": null,
"status": "succeeded",
"transfer_data": null,
"transfer_group": null
},
{
"amount": 7200,
"amount_captured": 7200,
"amount_refunded": 0,
"application": null,
"application_fee": null,
"application_fee_amount": null,
"balance_transaction": "txn_NORMALIZED",
"billing_details": {
"address": {
"city": "San Francisco",
"country": "US",
"line1": "123 Main St",
"line2": null,
"postal_code": "12345",
"state": "CA"
},
"email": null,
"name": "John Doe",
"phone": null,
"tax_id": null
},
"calculated_statement_descriptor": "ZULIP CLOUD STANDARD",
"captured": true,
"created": 1000000000,
"currency": "usd",
"customer": "cus_UNRECORDED",
"description": "Payment for Invoice",
"destination": null,
"dispute": null,
"disputed": false,
"failure_balance_transaction": null,
"failure_code": null,
"failure_message": null,
"fraud_details": {},
"id": "attach_discount_to_realm--Charge.list.1.json",
"livemode": false,
"metadata": {},
"object": "charge",
"on_behalf_of": null,
"order": null,
"outcome": {
"advice_code": null,
"network_advice_code": null,
"network_decline_code": null,
"network_status": "approved_by_network",
"reason": null,
"risk_level": "normal",
"risk_score": 0,
"seller_message": "Payment complete.",
"type": "authorized"
},
"paid": true,
"payment_intent": "pi_NORMALIZED",
"payment_method": "pm_NORMALIZED",
"payment_method_details": {
"card": {
"amount_authorized": 7200,
"authorization_code": "000000",
"brand": "visa",
"checks": {
"address_line1_check": "pass",
"address_postal_code_check": "pass",
"cvc_check": "pass"
},
"country": "US",
"ds_transaction_id": null,
"exp_month": 1,
"exp_year": 9999,
"extended_authorization": {
"status": "disabled"
},
"fingerprint": "NORMALIZED",
"funding": "credit",
"incremental_authorization": {
"status": "unavailable"
},
"installments": null,
"last4": "4242",
"mandate": null,
"multicapture": {
"status": "unavailable"
},
"network": "visa",
"network_token": {
"used": false
},
"network_transaction_id": "000000000000000",
"overcapture": {
"maximum_amount_capturable": 7200,
"status": "unavailable"
},
"regulated_status": "unregulated",
"three_d_secure": null,
"transaction_link_id": null,
"wallet": null
},
"type": "card"
},
"radar_options": {},
"receipt_email": "hamlet@zulip.com",
"receipt_number": null,
"receipt_url": "https://pay.stripe.com/receipts/invoices/NORMALIZED?s=ap",
"refunded": false,
"review": null,
"shipping": null,
"source": null,
"source_transfer": null,
"statement_descriptor": "Zulip Cloud Standard",
"statement_descriptor_suffix": null,
"status": "succeeded",
"transfer_data": null,
"transfer_group": null
}
],
"has_more": false,
"object": "list",
"url": "/v1/charges"
}

View File

@@ -0,0 +1,33 @@
{
"address": null,
"balance": 0,
"created": 1000000000,
"currency": null,
"customer_account": null,
"default_source": null,
"delinquent": false,
"description": "zulip (Zulip Dev)",
"discount": null,
"email": "hamlet@zulip.com",
"id": "attach_discount_to_realm--Customer.create.1.json",
"invoice_prefix": "NORMALIZED",
"invoice_settings": {
"custom_fields": null,
"default_payment_method": null,
"footer": null,
"rendering_options": null
},
"livemode": false,
"metadata": {
"realm_id": "1",
"realm_str": "zulip"
},
"name": null,
"next_invoice_sequence": 1,
"object": "customer",
"phone": null,
"preferred_locales": [],
"shipping": null,
"tax_exempt": "none",
"test_clock": null
}

View File

@@ -0,0 +1,33 @@
{
"address": null,
"balance": 0,
"created": 1000000000,
"currency": null,
"customer_account": null,
"default_source": null,
"delinquent": false,
"description": "zulip (Zulip Dev)",
"discount": null,
"email": "hamlet@zulip.com",
"id": "attach_discount_to_realm--Customer.create.1.json",
"invoice_prefix": "NORMALIZED",
"invoice_settings": {
"custom_fields": null,
"default_payment_method": "pm_NORMALIZED",
"footer": null,
"rendering_options": null
},
"livemode": false,
"metadata": {
"realm_id": "1",
"realm_str": "zulip"
},
"name": null,
"next_invoice_sequence": 1,
"object": "customer",
"phone": null,
"preferred_locales": [],
"shipping": null,
"tax_exempt": "none",
"test_clock": null
}

View File

@@ -0,0 +1,33 @@
{
"address": null,
"balance": 0,
"created": 1000000000,
"currency": "usd",
"customer_account": null,
"default_source": null,
"delinquent": false,
"description": "zulip (Zulip Dev)",
"discount": null,
"email": "hamlet@zulip.com",
"id": "attach_discount_to_realm--Customer.create.1.json",
"invoice_prefix": "NORMALIZED",
"invoice_settings": {
"custom_fields": null,
"default_payment_method": "pm_NORMALIZED",
"footer": null,
"rendering_options": null
},
"livemode": false,
"metadata": {
"realm_id": "1",
"realm_str": "zulip"
},
"name": null,
"next_invoice_sequence": 2,
"object": "customer",
"phone": null,
"preferred_locales": [],
"shipping": null,
"tax_exempt": "none",
"test_clock": null
}

View File

@@ -0,0 +1,85 @@
{
"address": null,
"balance": 0,
"created": 1000000000,
"currency": null,
"customer_account": null,
"default_source": null,
"delinquent": false,
"description": "zulip (Zulip Dev)",
"discount": null,
"email": "hamlet@zulip.com",
"id": "attach_discount_to_realm--Customer.create.1.json",
"invoice_prefix": "NORMALIZED",
"invoice_settings": {
"custom_fields": null,
"default_payment_method": {
"allow_redisplay": "unspecified",
"billing_details": {
"address": {
"city": "San Francisco",
"country": "US",
"line1": "123 Main St",
"line2": null,
"postal_code": "12345",
"state": "CA"
},
"email": null,
"name": "John Doe",
"phone": null,
"tax_id": null
},
"card": {
"brand": "visa",
"checks": {
"address_line1_check": "pass",
"address_postal_code_check": "pass",
"cvc_check": "pass"
},
"country": "US",
"display_brand": "visa",
"exp_month": 1,
"exp_year": 9999,
"fingerprint": "NORMALIZED",
"funding": "credit",
"generated_from": null,
"last4": "4242",
"networks": {
"available": [
"visa"
],
"preferred": null
},
"regulated_status": "unregulated",
"three_d_secure_usage": {
"supported": true
},
"wallet": null
},
"created": 1000000000,
"customer": "attach_discount_to_realm--Customer.create.1.json",
"customer_account": null,
"id": "attach_discount_to_realm--Customer.retrieve.1.json",
"livemode": false,
"metadata": {},
"object": "payment_method",
"shared_payment_granted_token": null,
"type": "card"
},
"footer": null,
"rendering_options": null
},
"livemode": false,
"metadata": {
"realm_id": "1",
"realm_str": "zulip"
},
"name": null,
"next_invoice_sequence": 1,
"object": "customer",
"phone": null,
"preferred_locales": [],
"shipping": null,
"tax_exempt": "none",
"test_clock": null
}

View File

@@ -0,0 +1,85 @@
{
"address": null,
"balance": 0,
"created": 1000000000,
"currency": "usd",
"customer_account": null,
"default_source": null,
"delinquent": false,
"description": "zulip (Zulip Dev)",
"discount": null,
"email": "hamlet@zulip.com",
"id": "attach_discount_to_realm--Customer.create.1.json",
"invoice_prefix": "NORMALIZED",
"invoice_settings": {
"custom_fields": null,
"default_payment_method": {
"allow_redisplay": "unspecified",
"billing_details": {
"address": {
"city": "San Francisco",
"country": "US",
"line1": "123 Main St",
"line2": null,
"postal_code": "12345",
"state": "CA"
},
"email": null,
"name": "John Doe",
"phone": null,
"tax_id": null
},
"card": {
"brand": "visa",
"checks": {
"address_line1_check": "pass",
"address_postal_code_check": "pass",
"cvc_check": "pass"
},
"country": "US",
"display_brand": "visa",
"exp_month": 1,
"exp_year": 9999,
"fingerprint": "NORMALIZED",
"funding": "credit",
"generated_from": null,
"last4": "4242",
"networks": {
"available": [
"visa"
],
"preferred": null
},
"regulated_status": "unregulated",
"three_d_secure_usage": {
"supported": true
},
"wallet": null
},
"created": 1000000000,
"customer": "attach_discount_to_realm--Customer.create.1.json",
"customer_account": null,
"id": "attach_discount_to_realm--Customer.retrieve.6.json",
"livemode": false,
"metadata": {},
"object": "payment_method",
"shared_payment_granted_token": null,
"type": "card"
},
"footer": null,
"rendering_options": null
},
"livemode": false,
"metadata": {
"realm_id": "1",
"realm_str": "zulip"
},
"name": null,
"next_invoice_sequence": 2,
"object": "customer",
"phone": null,
"preferred_locales": [],
"shipping": null,
"tax_exempt": "none",
"test_clock": null
}

View File

@@ -0,0 +1,85 @@
{
"address": null,
"balance": 0,
"created": 1000000000,
"currency": null,
"customer_account": null,
"default_source": null,
"delinquent": false,
"description": "zulip (Zulip Dev)",
"discount": null,
"email": "hamlet@zulip.com",
"id": "attach_discount_to_realm--Customer.create.1.json",
"invoice_prefix": "NORMALIZED",
"invoice_settings": {
"custom_fields": null,
"default_payment_method": {
"allow_redisplay": "unspecified",
"billing_details": {
"address": {
"city": "San Francisco",
"country": "US",
"line1": "123 Main St",
"line2": null,
"postal_code": "12345",
"state": "CA"
},
"email": null,
"name": "John Doe",
"phone": null,
"tax_id": null
},
"card": {
"brand": "visa",
"checks": {
"address_line1_check": "pass",
"address_postal_code_check": "pass",
"cvc_check": "pass"
},
"country": "US",
"display_brand": "visa",
"exp_month": 1,
"exp_year": 9999,
"fingerprint": "NORMALIZED",
"funding": "credit",
"generated_from": null,
"last4": "4242",
"networks": {
"available": [
"visa"
],
"preferred": null
},
"regulated_status": "unregulated",
"three_d_secure_usage": {
"supported": true
},
"wallet": null
},
"created": 1000000000,
"customer": "attach_discount_to_realm--Customer.create.1.json",
"customer_account": null,
"id": "attach_discount_to_realm--Customer.retrieve.1.json",
"livemode": false,
"metadata": {},
"object": "payment_method",
"shared_payment_granted_token": null,
"type": "card"
},
"footer": null,
"rendering_options": null
},
"livemode": false,
"metadata": {
"realm_id": "1",
"realm_str": "zulip"
},
"name": null,
"next_invoice_sequence": 1,
"object": "customer",
"phone": null,
"preferred_locales": [],
"shipping": null,
"tax_exempt": "none",
"test_clock": null
}

Some files were not shown because too many files have changed in this diff Show More