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