2018-01-30 20:49:25 +01:00
|
|
|
import logging
|
2019-01-30 19:04:32 +01:00
|
|
|
import math
|
2018-01-30 20:49:25 +01:00
|
|
|
import os
|
2020-09-05 04:02:13 +02:00
|
|
|
import secrets
|
2023-10-26 14:11:43 +02:00
|
|
|
from abc import ABC, abstractmethod
|
2023-10-31 15:51:51 +01:00
|
|
|
from dataclasses import dataclass
|
2020-06-11 00:54:34 +02:00
|
|
|
from datetime import datetime, timedelta
|
|
|
|
from decimal import Decimal
|
2023-11-02 17:44:02 +01:00
|
|
|
from enum import Enum
|
2020-06-11 00:54:34 +02:00
|
|
|
from functools import wraps
|
2023-10-26 14:11:43 +02:00
|
|
|
from typing import Any, Callable, Dict, Generator, Optional, Tuple, TypeVar, Union
|
2018-01-30 20:49:25 +01:00
|
|
|
|
2020-06-11 00:54:34 +02:00
|
|
|
import stripe
|
2018-01-30 20:49:25 +01:00
|
|
|
from django.conf import settings
|
2020-06-11 00:54:34 +02:00
|
|
|
from django.core.signing import Signer
|
2018-06-28 00:48:51 +02:00
|
|
|
from django.db import transaction
|
2021-06-11 12:53:45 +02:00
|
|
|
from django.urls import reverse
|
2018-08-14 03:33:31 +02:00
|
|
|
from django.utils.timezone import now as timezone_now
|
2021-04-16 00:57:30 +02:00
|
|
|
from django.utils.translation import gettext as _
|
|
|
|
from django.utils.translation import gettext_lazy
|
2020-07-17 12:56:06 +02:00
|
|
|
from django.utils.translation import override as override_language
|
2023-10-26 14:11:43 +02:00
|
|
|
from typing_extensions import ParamSpec, override
|
2018-01-30 20:49:25 +01:00
|
|
|
|
2020-06-11 00:54:34 +02:00
|
|
|
from corporate.models import (
|
|
|
|
Customer,
|
|
|
|
CustomerPlan,
|
|
|
|
LicenseLedger,
|
2023-11-06 13:52:12 +01:00
|
|
|
PaymentIntent,
|
|
|
|
Session,
|
2020-06-11 00:54:34 +02:00
|
|
|
get_current_plan_by_customer,
|
|
|
|
get_current_plan_by_realm,
|
|
|
|
get_customer_by_realm,
|
|
|
|
)
|
2021-07-04 08:19:18 +02:00
|
|
|
from zerver.lib.exceptions import JsonableError
|
2018-01-30 20:49:25 +01:00
|
|
|
from zerver.lib.logging_util import log_to_file
|
2021-06-11 12:53:45 +02:00
|
|
|
from zerver.lib.send_email import FromAddress, send_email_to_billing_admins_and_realm_owners
|
2018-06-28 00:48:51 +02:00
|
|
|
from zerver.lib.timestamp import datetime_to_timestamp, timestamp_to_datetime
|
2021-07-25 16:31:12 +02:00
|
|
|
from zerver.lib.utils import assert_is_not_none
|
2023-11-02 17:44:02 +01:00
|
|
|
from zerver.models import Realm, RealmAuditLog, UserProfile, get_system_bot
|
2021-12-01 17:31:08 +01:00
|
|
|
from zilencer.models import RemoteZulipServer, RemoteZulipServerAuditLog
|
2019-11-13 01:11:56 +01:00
|
|
|
from zproject.config import get_secret
|
2018-01-30 20:49:25 +01:00
|
|
|
|
2021-02-12 08:20:45 +01:00
|
|
|
stripe.api_key = get_secret("stripe_secret_key")
|
2018-01-30 20:49:25 +01:00
|
|
|
|
2021-02-12 08:19:30 +01:00
|
|
|
BILLING_LOG_PATH = os.path.join(
|
2021-02-12 08:20:45 +01:00
|
|
|
"/var/log/zulip" if not settings.DEVELOPMENT else settings.DEVELOPMENT_LOG_DIRECTORY,
|
|
|
|
"billing.log",
|
2021-02-12 08:19:30 +01:00
|
|
|
)
|
2021-02-12 08:20:45 +01:00
|
|
|
billing_logger = logging.getLogger("corporate.stripe")
|
2018-01-30 20:49:25 +01:00
|
|
|
log_to_file(billing_logger, BILLING_LOG_PATH)
|
2021-02-12 08:20:45 +01:00
|
|
|
log_to_file(logging.getLogger("stripe"), BILLING_LOG_PATH)
|
2018-01-30 20:49:25 +01:00
|
|
|
|
2022-04-13 16:42:42 +02:00
|
|
|
ParamT = ParamSpec("ParamT")
|
|
|
|
ReturnT = TypeVar("ReturnT")
|
2018-01-30 21:03:59 +01:00
|
|
|
|
2018-12-22 01:43:44 +01:00
|
|
|
MIN_INVOICED_LICENSES = 30
|
2020-05-08 12:43:52 +02:00
|
|
|
MAX_INVOICED_LICENSES = 1000
|
2018-09-08 00:49:54 +02:00
|
|
|
DEFAULT_INVOICE_DAYS_UNTIL_DUE = 30
|
|
|
|
|
2021-09-07 13:59:44 +02:00
|
|
|
# The version of Stripe API the billing system supports.
|
|
|
|
STRIPE_API_VERSION = "2020-08-27"
|
|
|
|
|
2023-10-10 22:52:17 +02:00
|
|
|
stripe.api_version = STRIPE_API_VERSION
|
|
|
|
|
2021-02-12 08:19:30 +01:00
|
|
|
|
2019-10-07 19:21:29 +02:00
|
|
|
def get_latest_seat_count(realm: Realm) -> int:
|
2022-08-14 18:41:59 +02:00
|
|
|
return get_seat_count(realm, extra_non_guests_count=0, extra_guests_count=0)
|
|
|
|
|
|
|
|
|
|
|
|
def get_seat_count(
|
|
|
|
realm: Realm, extra_non_guests_count: int = 0, extra_guests_count: int = 0
|
|
|
|
) -> int:
|
2021-02-12 08:19:30 +01:00
|
|
|
non_guests = (
|
|
|
|
UserProfile.objects.filter(realm=realm, is_active=True, is_bot=False)
|
|
|
|
.exclude(role=UserProfile.ROLE_GUEST)
|
|
|
|
.count()
|
2022-08-14 18:41:59 +02:00
|
|
|
) + extra_non_guests_count
|
2022-10-10 21:22:46 +02:00
|
|
|
|
|
|
|
# This guest count calculation should match the similar query in render_stats().
|
2022-08-14 18:41:59 +02:00
|
|
|
guests = (
|
|
|
|
UserProfile.objects.filter(
|
|
|
|
realm=realm, is_active=True, is_bot=False, role=UserProfile.ROLE_GUEST
|
|
|
|
).count()
|
|
|
|
+ extra_guests_count
|
2021-02-12 08:19:30 +01:00
|
|
|
)
|
2022-08-01 10:44:41 +02:00
|
|
|
|
|
|
|
# This formula achieves the pricing of the first 5*N guests
|
|
|
|
# being free of charge (where N is the number of non-guests in the organization)
|
|
|
|
# and each consecutive one being worth 1/5 the non-guest price.
|
2019-01-30 19:04:32 +01:00
|
|
|
return max(non_guests, math.ceil(guests / 5))
|
2018-03-31 04:13:44 +02:00
|
|
|
|
2021-02-12 08:19:30 +01:00
|
|
|
|
2018-07-13 17:34:39 +02:00
|
|
|
def sign_string(string: str) -> Tuple[str, str]:
|
2020-09-05 04:02:13 +02:00
|
|
|
salt = secrets.token_hex(32)
|
2018-07-13 17:34:39 +02:00
|
|
|
signer = Signer(salt=salt)
|
|
|
|
return signer.sign(string), salt
|
|
|
|
|
2021-02-12 08:19:30 +01:00
|
|
|
|
2018-07-13 17:34:39 +02:00
|
|
|
def unsign_string(signed_string: str, salt: str) -> str:
|
|
|
|
signer = Signer(salt=salt)
|
|
|
|
return signer.unsign(signed_string)
|
|
|
|
|
2021-02-12 08:19:30 +01:00
|
|
|
|
2023-04-10 21:48:52 +02:00
|
|
|
def validate_licenses(
|
|
|
|
charge_automatically: bool,
|
|
|
|
licenses: Optional[int],
|
|
|
|
seat_count: int,
|
|
|
|
exempt_from_license_number_check: bool,
|
|
|
|
) -> None:
|
2020-12-17 16:33:19 +01:00
|
|
|
min_licenses = seat_count
|
|
|
|
max_licenses = None
|
|
|
|
if not charge_automatically:
|
|
|
|
min_licenses = max(seat_count, MIN_INVOICED_LICENSES)
|
|
|
|
max_licenses = MAX_INVOICED_LICENSES
|
|
|
|
|
2023-04-10 21:48:52 +02:00
|
|
|
if licenses is None or (not exempt_from_license_number_check and licenses < min_licenses):
|
2020-12-17 16:33:19 +01:00
|
|
|
raise BillingError(
|
2023-07-17 22:40:33 +02:00
|
|
|
"not enough licenses",
|
|
|
|
_("You must invoice for at least {min_licenses} users.").format(
|
|
|
|
min_licenses=min_licenses
|
|
|
|
),
|
2020-12-17 16:33:19 +01:00
|
|
|
)
|
|
|
|
|
|
|
|
if max_licenses is not None and licenses > max_licenses:
|
|
|
|
message = _(
|
2023-07-17 22:40:33 +02:00
|
|
|
"Invoices with more than {max_licenses} licenses can't be processed from this page. To"
|
|
|
|
" complete the upgrade, please contact {email}."
|
|
|
|
).format(max_licenses=max_licenses, email=settings.ZULIP_ADMINISTRATOR)
|
2020-12-17 16:33:19 +01:00
|
|
|
raise BillingError("too many licenses", message)
|
|
|
|
|
|
|
|
|
2018-12-15 09:33:25 +01:00
|
|
|
# Be extremely careful changing this function. Historical billing periods
|
|
|
|
# are not stored anywhere, and are just computed on the fly using this
|
|
|
|
# function. Any change you make here should return the same value (or be
|
|
|
|
# within a few seconds) for basically any value from when the billing system
|
|
|
|
# went online to within a year from now.
|
|
|
|
def add_months(dt: datetime, months: int) -> datetime:
|
2021-02-12 08:19:30 +01:00
|
|
|
assert months >= 0
|
2018-12-15 09:33:25 +01:00
|
|
|
# It's fine that the max day in Feb is 28 for leap years.
|
2021-02-12 08:19:30 +01:00
|
|
|
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,
|
|
|
|
}
|
2018-12-15 09:33:25 +01:00
|
|
|
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)
|
|
|
|
|
2021-02-12 08:19:30 +01:00
|
|
|
|
2018-12-15 09:33:25 +01:00
|
|
|
def next_month(billing_cycle_anchor: datetime, dt: datetime) -> datetime:
|
2021-02-12 08:19:30 +01:00
|
|
|
estimated_months = round((dt - billing_cycle_anchor).days * 12.0 / 365)
|
2018-12-15 09:33:25 +01:00
|
|
|
for months in range(max(estimated_months - 1, 0), estimated_months + 2):
|
|
|
|
proposed_next_month = add_months(billing_cycle_anchor, months)
|
|
|
|
if 20 < (proposed_next_month - dt).days < 40:
|
|
|
|
return proposed_next_month
|
2021-02-12 08:19:30 +01:00
|
|
|
raise AssertionError(
|
2021-02-12 08:20:45 +01:00
|
|
|
"Something wrong in next_month calculation with "
|
|
|
|
f"billing_cycle_anchor: {billing_cycle_anchor}, dt: {dt}"
|
2021-02-12 08:19:30 +01:00
|
|
|
)
|
|
|
|
|
2018-12-15 09:33:25 +01:00
|
|
|
|
2019-04-10 09:14:20 +02:00
|
|
|
def start_of_next_billing_cycle(plan: CustomerPlan, event_time: datetime) -> datetime:
|
2018-12-15 09:33:25 +01:00
|
|
|
months_per_period = {
|
|
|
|
CustomerPlan.ANNUAL: 12,
|
|
|
|
CustomerPlan.MONTHLY: 1,
|
|
|
|
}[plan.billing_schedule]
|
|
|
|
periods = 1
|
|
|
|
dt = plan.billing_cycle_anchor
|
2019-01-26 20:45:26 +01:00
|
|
|
while dt <= event_time:
|
2018-12-15 09:33:25 +01:00
|
|
|
dt = add_months(plan.billing_cycle_anchor, months_per_period * periods)
|
|
|
|
periods += 1
|
|
|
|
return dt
|
|
|
|
|
2021-02-12 08:19:30 +01:00
|
|
|
|
2019-04-08 05:16:35 +02:00
|
|
|
def next_invoice_date(plan: CustomerPlan) -> Optional[datetime]:
|
|
|
|
if plan.status == CustomerPlan.ENDED:
|
|
|
|
return None
|
2021-02-12 08:19:30 +01:00
|
|
|
assert plan.next_invoice_date is not None # for mypy
|
2019-01-28 22:57:29 +01:00
|
|
|
months_per_period = {
|
|
|
|
CustomerPlan.ANNUAL: 12,
|
|
|
|
CustomerPlan.MONTHLY: 1,
|
|
|
|
}[plan.billing_schedule]
|
|
|
|
if plan.automanage_licenses:
|
|
|
|
months_per_period = 1
|
|
|
|
periods = 1
|
|
|
|
dt = plan.billing_cycle_anchor
|
|
|
|
while dt <= plan.next_invoice_date:
|
|
|
|
dt = add_months(plan.billing_cycle_anchor, months_per_period * periods)
|
|
|
|
periods += 1
|
|
|
|
return dt
|
|
|
|
|
2021-02-12 08:19:30 +01:00
|
|
|
|
2019-04-10 23:08:47 +02:00
|
|
|
def renewal_amount(plan: CustomerPlan, event_time: datetime) -> int: # nocoverage: TODO
|
2018-12-15 09:33:25 +01:00
|
|
|
if plan.fixed_price is not None:
|
2019-01-25 02:14:07 +01:00
|
|
|
return plan.fixed_price
|
2020-06-15 20:09:24 +02:00
|
|
|
new_plan, last_ledger_entry = make_end_of_cycle_updates_if_needed(plan, event_time)
|
2019-04-08 05:16:35 +02:00
|
|
|
if last_ledger_entry is None:
|
|
|
|
return 0
|
2019-01-25 02:14:07 +01:00
|
|
|
if last_ledger_entry.licenses_at_next_renewal is None:
|
2019-04-10 23:08:47 +02:00
|
|
|
return 0
|
2020-06-15 20:09:24 +02:00
|
|
|
if new_plan is not None:
|
|
|
|
plan = new_plan
|
2021-02-12 08:19:30 +01:00
|
|
|
assert plan.price_per_license is not None # for mypy
|
2019-01-25 02:14:07 +01:00
|
|
|
return plan.price_per_license * last_ledger_entry.licenses_at_next_renewal
|
2018-12-15 09:33:25 +01:00
|
|
|
|
2021-02-12 08:19:30 +01:00
|
|
|
|
2020-06-15 20:09:24 +02:00
|
|
|
def get_idempotency_key(ledger_entry: LicenseLedger) -> Optional[str]:
|
|
|
|
if settings.TEST_SUITE:
|
|
|
|
return None
|
2021-02-12 08:20:45 +01:00
|
|
|
return f"ledger_entry:{ledger_entry.id}" # nocoverage
|
2020-06-15 20:09:24 +02:00
|
|
|
|
2021-02-12 08:19:30 +01:00
|
|
|
|
2020-10-20 15:46:04 +02:00
|
|
|
def cents_to_dollar_string(cents: int) -> str:
|
|
|
|
return f"{cents / 100.:,.2f}"
|
|
|
|
|
|
|
|
|
2021-07-04 08:19:18 +02:00
|
|
|
class BillingError(JsonableError):
|
|
|
|
data_fields = ["error_description"]
|
2018-08-06 06:16:29 +02:00
|
|
|
# error messages
|
2021-04-16 00:57:30 +02:00
|
|
|
CONTACT_SUPPORT = gettext_lazy("Something went wrong. Please contact {email}.")
|
|
|
|
TRY_RELOADING = gettext_lazy("Something went wrong. Please reload the page.")
|
2018-08-06 06:16:29 +02:00
|
|
|
|
|
|
|
# description is used only for tests
|
2020-10-17 03:42:50 +02:00
|
|
|
def __init__(self, description: str, message: Optional[str] = None) -> None:
|
2021-07-04 08:19:18 +02:00
|
|
|
self.error_description = description
|
2020-10-17 03:42:50 +02:00
|
|
|
if message is None:
|
|
|
|
message = BillingError.CONTACT_SUPPORT.format(email=settings.ZULIP_ADMINISTRATOR)
|
2021-07-04 08:19:18 +02:00
|
|
|
super().__init__(message)
|
2018-07-27 17:47:03 +02:00
|
|
|
|
2021-02-12 08:19:30 +01:00
|
|
|
|
2021-05-28 15:57:08 +02:00
|
|
|
class LicenseLimitError(Exception):
|
|
|
|
pass
|
|
|
|
|
|
|
|
|
2018-08-06 23:07:26 +02:00
|
|
|
class StripeCardError(BillingError):
|
|
|
|
pass
|
|
|
|
|
2021-02-12 08:19:30 +01:00
|
|
|
|
2018-08-06 23:07:26 +02:00
|
|
|
class StripeConnectionError(BillingError):
|
|
|
|
pass
|
|
|
|
|
2021-02-12 08:19:30 +01:00
|
|
|
|
2021-08-29 15:33:29 +02:00
|
|
|
class UpgradeWithExistingPlanError(BillingError):
|
|
|
|
def __init__(self) -> None:
|
|
|
|
super().__init__(
|
|
|
|
"subscribing with existing subscription",
|
|
|
|
"The organization is already subscribed to a plan. Please reload the billing page.",
|
|
|
|
)
|
|
|
|
|
|
|
|
|
2022-11-17 09:30:48 +01:00
|
|
|
class InvalidBillingScheduleError(Exception):
|
2020-12-04 12:56:58 +01:00
|
|
|
def __init__(self, billing_schedule: int) -> None:
|
|
|
|
self.message = f"Unknown billing_schedule: {billing_schedule}"
|
|
|
|
super().__init__(self.message)
|
|
|
|
|
2021-02-12 08:19:30 +01:00
|
|
|
|
2022-11-17 09:30:48 +01:00
|
|
|
class InvalidTierError(Exception):
|
2021-09-15 13:54:56 +02:00
|
|
|
def __init__(self, tier: int) -> None:
|
|
|
|
self.message = f"Unknown tier: {tier}"
|
|
|
|
super().__init__(self.message)
|
|
|
|
|
|
|
|
|
2022-04-13 16:42:42 +02:00
|
|
|
def catch_stripe_errors(func: Callable[ParamT, ReturnT]) -> Callable[ParamT, ReturnT]:
|
2018-01-30 21:03:59 +01:00
|
|
|
@wraps(func)
|
2022-04-13 16:42:42 +02:00
|
|
|
def wrapped(*args: ParamT.args, **kwargs: ParamT.kwargs) -> ReturnT:
|
2018-01-30 21:03:59 +01:00
|
|
|
try:
|
|
|
|
return func(*args, **kwargs)
|
2018-08-06 23:07:26 +02:00
|
|
|
# See https://stripe.com/docs/api/python#error_handling, though
|
|
|
|
# https://stripe.com/docs/api/ruby#error_handling suggests there are additional fields, and
|
|
|
|
# https://stripe.com/docs/error-codes gives a more detailed set of error codes
|
2018-01-30 21:03:59 +01:00
|
|
|
except stripe.error.StripeError as e:
|
2021-02-12 08:20:45 +01:00
|
|
|
err = e.json_body.get("error", {})
|
2020-09-23 22:56:56 +02:00
|
|
|
if isinstance(e, stripe.error.CardError):
|
|
|
|
billing_logger.info(
|
|
|
|
"Stripe card error: %s %s %s %s",
|
2021-02-12 08:19:30 +01:00
|
|
|
e.http_status,
|
2021-02-12 08:20:45 +01:00
|
|
|
err.get("type"),
|
|
|
|
err.get("code"),
|
|
|
|
err.get("param"),
|
2020-09-23 22:56:56 +02:00
|
|
|
)
|
|
|
|
# TODO: Look into i18n for this
|
2021-02-12 08:20:45 +01:00
|
|
|
raise StripeCardError("card error", err.get("message"))
|
2020-05-02 20:57:12 +02:00
|
|
|
billing_logger.error(
|
|
|
|
"Stripe error: %s %s %s %s",
|
2021-02-12 08:19:30 +01:00
|
|
|
e.http_status,
|
2021-02-12 08:20:45 +01:00
|
|
|
err.get("type"),
|
|
|
|
err.get("code"),
|
|
|
|
err.get("param"),
|
2020-05-02 20:57:12 +02:00
|
|
|
)
|
2021-02-12 08:19:30 +01:00
|
|
|
if isinstance(
|
|
|
|
e, (stripe.error.RateLimitError, stripe.error.APIConnectionError)
|
|
|
|
): # nocoverage TODO
|
2018-08-06 23:07:26 +02:00
|
|
|
raise StripeConnectionError(
|
2021-02-12 08:20:45 +01:00
|
|
|
"stripe connection error",
|
2021-02-12 08:19:30 +01:00
|
|
|
_("Something went wrong. Please wait a few seconds and try again."),
|
|
|
|
)
|
2021-02-12 08:20:45 +01:00
|
|
|
raise BillingError("other stripe error")
|
2021-02-12 08:19:30 +01:00
|
|
|
|
2022-04-13 16:42:42 +02:00
|
|
|
return wrapped
|
2018-01-30 21:03:59 +01:00
|
|
|
|
2021-02-12 08:19:30 +01:00
|
|
|
|
2018-01-30 21:03:59 +01:00
|
|
|
@catch_stripe_errors
|
2018-08-06 18:22:55 +02:00
|
|
|
def stripe_get_customer(stripe_customer_id: str) -> stripe.Customer:
|
2021-08-29 15:33:29 +02:00
|
|
|
return stripe.Customer.retrieve(
|
|
|
|
stripe_customer_id, expand=["invoice_settings", "invoice_settings.default_payment_method"]
|
|
|
|
)
|
2018-03-31 04:13:44 +02:00
|
|
|
|
2021-02-12 08:19:30 +01:00
|
|
|
|
2023-10-31 15:51:51 +01:00
|
|
|
@dataclass
|
|
|
|
class StripeCustomerData:
|
|
|
|
description: str
|
|
|
|
email: str
|
|
|
|
metadata: Dict[str, Any]
|
|
|
|
|
|
|
|
|
2023-11-06 15:51:54 +01:00
|
|
|
@dataclass
|
|
|
|
class StripePaymentIntentData:
|
|
|
|
amount: int
|
|
|
|
description: str
|
|
|
|
plan_name: str
|
|
|
|
email: str
|
|
|
|
|
|
|
|
|
2023-11-02 17:44:02 +01:00
|
|
|
class AuditLogEventType(Enum):
|
|
|
|
STRIPE_CUSTOMER_CREATED = 1
|
|
|
|
STRIPE_CARD_CHANGED = 2
|
|
|
|
CUSTOMER_PLAN_CREATED = 3
|
|
|
|
DISCOUNT_CHANGED = 4
|
2023-11-02 15:23:35 +01:00
|
|
|
SPONSORSHIP_APPROVED = 5
|
2023-11-02 18:17:08 +01:00
|
|
|
SPONSORSHIP_PENDING_STATUS_CHANGED = 6
|
2023-11-02 18:42:04 +01:00
|
|
|
BILLING_METHOD_CHANGED = 7
|
2023-11-02 17:44:02 +01:00
|
|
|
|
|
|
|
|
|
|
|
class BillingSessionAuditLogEventError(Exception):
|
|
|
|
def __init__(self, event_type: AuditLogEventType) -> None:
|
|
|
|
self.message = f"Unknown audit log event type: {event_type}"
|
|
|
|
super().__init__(self.message)
|
|
|
|
|
|
|
|
|
2023-10-26 14:11:43 +02:00
|
|
|
class BillingSession(ABC):
|
2023-11-06 13:52:12 +01:00
|
|
|
@property
|
|
|
|
@abstractmethod
|
|
|
|
def billing_session_url(self) -> str:
|
|
|
|
pass
|
|
|
|
|
2023-10-26 14:11:43 +02:00
|
|
|
@abstractmethod
|
|
|
|
def get_customer(self) -> Optional[Customer]:
|
|
|
|
pass
|
|
|
|
|
2023-11-08 17:02:31 +01:00
|
|
|
@abstractmethod
|
|
|
|
def current_count_for_billed_licenses(self) -> int:
|
|
|
|
pass
|
|
|
|
|
2023-11-02 17:44:02 +01:00
|
|
|
@abstractmethod
|
|
|
|
def get_audit_log_event(self, event_type: AuditLogEventType) -> int:
|
|
|
|
pass
|
|
|
|
|
2023-10-26 14:11:43 +02:00
|
|
|
@abstractmethod
|
|
|
|
def write_to_audit_log(
|
2023-11-02 17:44:02 +01:00
|
|
|
self,
|
|
|
|
event_type: AuditLogEventType,
|
|
|
|
event_time: datetime,
|
|
|
|
*,
|
|
|
|
extra_data: Optional[Dict[str, Any]] = None,
|
2023-10-26 14:11:43 +02:00
|
|
|
) -> None:
|
|
|
|
pass
|
|
|
|
|
|
|
|
@abstractmethod
|
2023-10-31 15:51:51 +01:00
|
|
|
def get_data_for_stripe_customer(self) -> StripeCustomerData:
|
2023-10-26 14:11:43 +02:00
|
|
|
pass
|
|
|
|
|
2023-11-06 15:51:54 +01:00
|
|
|
@abstractmethod
|
|
|
|
def update_data_for_checkout_session_and_payment_intent(
|
|
|
|
self, metadata: Dict[str, Any]
|
|
|
|
) -> Dict[str, Any]:
|
|
|
|
pass
|
|
|
|
|
|
|
|
@abstractmethod
|
|
|
|
def get_data_for_stripe_payment_intent(
|
|
|
|
self, price_per_license: int, licenses: int
|
|
|
|
) -> StripePaymentIntentData:
|
|
|
|
pass
|
|
|
|
|
2023-10-26 14:11:43 +02:00
|
|
|
@abstractmethod
|
2023-10-31 19:22:55 +01:00
|
|
|
def update_or_create_customer(
|
|
|
|
self, stripe_customer_id: Optional[str] = None, *, defaults: Optional[Dict[str, Any]] = None
|
|
|
|
) -> Customer:
|
2023-10-26 14:11:43 +02:00
|
|
|
pass
|
|
|
|
|
2023-11-08 17:15:40 +01:00
|
|
|
@abstractmethod
|
|
|
|
def do_change_plan_type(self, *, tier: Optional[int], is_sponsored: bool = False) -> None:
|
|
|
|
pass
|
|
|
|
|
2023-11-02 15:23:35 +01:00
|
|
|
@abstractmethod
|
|
|
|
def approve_sponsorship(self) -> None:
|
|
|
|
pass
|
|
|
|
|
2023-10-26 14:11:43 +02:00
|
|
|
@catch_stripe_errors
|
2023-11-09 14:46:39 +01:00
|
|
|
def create_stripe_customer(self) -> Customer:
|
2023-10-26 14:11:43 +02:00
|
|
|
stripe_customer_data = self.get_data_for_stripe_customer()
|
|
|
|
stripe_customer = stripe.Customer.create(
|
2023-10-31 15:51:51 +01:00
|
|
|
description=stripe_customer_data.description,
|
|
|
|
email=stripe_customer_data.email,
|
|
|
|
metadata=stripe_customer_data.metadata,
|
2021-02-12 08:19:30 +01:00
|
|
|
)
|
2023-10-26 14:11:43 +02:00
|
|
|
event_time = timestamp_to_datetime(stripe_customer.created)
|
|
|
|
with transaction.atomic():
|
2023-11-02 17:44:02 +01:00
|
|
|
self.write_to_audit_log(AuditLogEventType.STRIPE_CUSTOMER_CREATED, event_time)
|
2023-10-26 14:11:43 +02:00
|
|
|
customer = self.update_or_create_customer(stripe_customer.id)
|
|
|
|
return customer
|
|
|
|
|
|
|
|
@catch_stripe_errors
|
|
|
|
def replace_payment_method(
|
|
|
|
self, stripe_customer_id: str, payment_method: str, pay_invoices: bool = False
|
|
|
|
) -> None:
|
|
|
|
stripe.Customer.modify(
|
|
|
|
stripe_customer_id, invoice_settings={"default_payment_method": payment_method}
|
|
|
|
)
|
2023-11-02 17:44:02 +01:00
|
|
|
self.write_to_audit_log(AuditLogEventType.STRIPE_CARD_CHANGED, timezone_now())
|
2023-10-26 14:11:43 +02:00
|
|
|
if pay_invoices:
|
|
|
|
for stripe_invoice in stripe.Invoice.list(
|
|
|
|
collection_method="charge_automatically",
|
|
|
|
customer=stripe_customer_id,
|
|
|
|
status="open",
|
|
|
|
):
|
|
|
|
# The stripe customer with the associated ID will get either a receipt
|
|
|
|
# or a "failed payment" email, but the in-app messaging could be clearer
|
|
|
|
# here (e.g. it could explicitly tell the user that there were payment(s)
|
|
|
|
# and that they succeeded or failed). Worth fixing if we notice that a
|
|
|
|
# lot of cards end up failing at this step.
|
|
|
|
stripe.Invoice.pay(stripe_invoice)
|
|
|
|
|
|
|
|
# Returns Customer instead of stripe_customer so that we don't make a Stripe
|
|
|
|
# API call if there's nothing to update
|
|
|
|
@catch_stripe_errors
|
|
|
|
def update_or_create_stripe_customer(self, payment_method: Optional[str] = None) -> Customer:
|
|
|
|
customer = self.get_customer()
|
|
|
|
if customer is None or customer.stripe_customer_id is None:
|
2023-11-09 14:46:39 +01:00
|
|
|
# A stripe.PaymentMethod should be attached to a stripe.Customer via
|
|
|
|
# a stripe.SetupIntent or stripe.PaymentIntent. Here we just want to
|
|
|
|
# create a new stripe.Customer.
|
|
|
|
assert payment_method is None
|
2023-10-26 14:11:43 +02:00
|
|
|
# We could do a better job of handling race conditions here, but if two
|
|
|
|
# people try to upgrade at exactly the same time, the main bad thing that
|
2023-11-09 14:46:39 +01:00
|
|
|
# will happen is that we will create an extra stripe.Customer that we can
|
2023-10-26 14:11:43 +02:00
|
|
|
# delete or ignore.
|
2023-11-09 14:46:39 +01:00
|
|
|
return self.create_stripe_customer()
|
2021-08-29 15:33:29 +02:00
|
|
|
if payment_method is not None:
|
2023-10-26 14:11:43 +02:00
|
|
|
self.replace_payment_method(customer.stripe_customer_id, payment_method, True)
|
|
|
|
return customer
|
|
|
|
|
2023-11-06 15:51:54 +01:00
|
|
|
def create_stripe_payment_intent(
|
|
|
|
self, price_per_license: int, licenses: int, metadata: Dict[str, Any]
|
|
|
|
) -> PaymentIntent:
|
|
|
|
customer = self.get_customer()
|
|
|
|
assert customer is not None and customer.stripe_customer_id is not None
|
|
|
|
payment_intent_data = self.get_data_for_stripe_payment_intent(price_per_license, licenses)
|
|
|
|
stripe_payment_intent = stripe.PaymentIntent.create(
|
|
|
|
amount=payment_intent_data.amount,
|
|
|
|
currency="usd",
|
|
|
|
customer=customer.stripe_customer_id,
|
|
|
|
description=payment_intent_data.description,
|
|
|
|
receipt_email=payment_intent_data.email,
|
|
|
|
confirm=False,
|
|
|
|
statement_descriptor=payment_intent_data.plan_name,
|
|
|
|
metadata=metadata,
|
|
|
|
)
|
|
|
|
payment_intent = PaymentIntent.objects.create(
|
|
|
|
customer=customer,
|
|
|
|
stripe_payment_intent_id=stripe_payment_intent.id,
|
|
|
|
status=PaymentIntent.get_status_integer_from_status_text(stripe_payment_intent.status),
|
|
|
|
)
|
|
|
|
return payment_intent
|
|
|
|
|
2023-11-06 13:52:12 +01:00
|
|
|
def create_stripe_checkout_session(
|
|
|
|
self,
|
|
|
|
metadata: Dict[str, Any],
|
|
|
|
session_type: int,
|
|
|
|
payment_intent: Optional[PaymentIntent] = None,
|
|
|
|
) -> stripe.checkout.Session:
|
|
|
|
customer = self.get_customer()
|
|
|
|
assert customer is not None and customer.stripe_customer_id is not None
|
|
|
|
stripe_session = stripe.checkout.Session.create(
|
|
|
|
cancel_url=f"{self.billing_session_url}/billing/",
|
|
|
|
customer=customer.stripe_customer_id,
|
|
|
|
metadata=metadata,
|
|
|
|
mode="setup",
|
|
|
|
payment_method_types=["card"],
|
|
|
|
success_url=f"{self.billing_session_url}/billing/event_status?stripe_session_id={{CHECKOUT_SESSION_ID}}",
|
|
|
|
)
|
|
|
|
session = Session.objects.create(
|
|
|
|
stripe_session_id=stripe_session.id,
|
|
|
|
customer=customer,
|
|
|
|
type=session_type,
|
|
|
|
)
|
|
|
|
if payment_intent is not None:
|
|
|
|
session.payment_intent = payment_intent
|
|
|
|
session.save(update_fields=["payment_intent"])
|
|
|
|
session.save()
|
|
|
|
return stripe_session
|
|
|
|
|
2023-10-31 19:22:55 +01:00
|
|
|
def attach_discount_to_customer(self, discount: Decimal) -> None:
|
|
|
|
customer = self.get_customer()
|
|
|
|
old_discount: Optional[Decimal] = None
|
|
|
|
if customer is not None:
|
|
|
|
old_discount = customer.default_discount
|
|
|
|
customer.default_discount = discount
|
|
|
|
customer.save(update_fields=["default_discount"])
|
|
|
|
else:
|
|
|
|
customer = self.update_or_create_customer(defaults={"default_discount": discount})
|
|
|
|
plan = get_current_plan_by_customer(customer)
|
|
|
|
if plan is not None:
|
|
|
|
plan.price_per_license = get_price_per_license(
|
|
|
|
plan.tier, plan.billing_schedule, discount
|
|
|
|
)
|
|
|
|
plan.discount = discount
|
|
|
|
plan.save(update_fields=["price_per_license", "discount"])
|
|
|
|
self.write_to_audit_log(
|
2023-11-02 17:44:02 +01:00
|
|
|
event_type=AuditLogEventType.DISCOUNT_CHANGED,
|
2023-10-31 19:22:55 +01:00
|
|
|
event_time=timezone_now(),
|
|
|
|
extra_data={"old_discount": old_discount, "new_discount": discount},
|
|
|
|
)
|
|
|
|
|
2023-11-02 18:17:08 +01:00
|
|
|
def update_customer_sponsorship_status(self, sponsorship_pending: bool) -> None:
|
|
|
|
customer = self.get_customer()
|
|
|
|
if customer is None:
|
|
|
|
customer = self.update_or_create_customer()
|
|
|
|
customer.sponsorship_pending = sponsorship_pending
|
|
|
|
customer.save(update_fields=["sponsorship_pending"])
|
|
|
|
self.write_to_audit_log(
|
|
|
|
event_type=AuditLogEventType.SPONSORSHIP_PENDING_STATUS_CHANGED,
|
|
|
|
event_time=timezone_now(),
|
|
|
|
extra_data={"sponsorship_pending": sponsorship_pending},
|
|
|
|
)
|
|
|
|
|
2023-11-02 18:42:04 +01:00
|
|
|
def update_billing_method_of_current_plan(self, charge_automatically: bool) -> None:
|
|
|
|
customer = self.get_customer()
|
|
|
|
if customer is not None:
|
|
|
|
plan = get_current_plan_by_customer(customer)
|
|
|
|
if plan is not None:
|
|
|
|
plan.charge_automatically = charge_automatically
|
|
|
|
plan.save(update_fields=["charge_automatically"])
|
|
|
|
self.write_to_audit_log(
|
|
|
|
event_type=AuditLogEventType.BILLING_METHOD_CHANGED,
|
|
|
|
event_time=timezone_now(),
|
|
|
|
extra_data={"charge_automatically": charge_automatically},
|
|
|
|
)
|
|
|
|
|
2023-11-10 14:03:56 +01:00
|
|
|
def setup_upgrade_checkout_session_and_payment_intent(
|
|
|
|
self,
|
|
|
|
plan_tier: int,
|
|
|
|
seat_count: int,
|
|
|
|
licenses: int,
|
|
|
|
license_management: str,
|
|
|
|
billing_schedule: int,
|
|
|
|
billing_modality: str,
|
|
|
|
onboarding: bool,
|
|
|
|
) -> stripe.checkout.Session:
|
|
|
|
customer = self.update_or_create_stripe_customer()
|
|
|
|
assert customer is not None # for mypy
|
|
|
|
free_trial = is_free_trial_offer_enabled()
|
|
|
|
price_per_license = get_price_per_license(
|
|
|
|
plan_tier, billing_schedule, customer.default_discount
|
|
|
|
)
|
|
|
|
general_metadata = {
|
|
|
|
"billing_modality": billing_modality,
|
|
|
|
"billing_schedule": billing_schedule,
|
|
|
|
"licenses": licenses,
|
|
|
|
"license_management": license_management,
|
|
|
|
"price_per_license": price_per_license,
|
|
|
|
"seat_count": seat_count,
|
|
|
|
"type": "upgrade",
|
|
|
|
}
|
|
|
|
updated_metadata = self.update_data_for_checkout_session_and_payment_intent(
|
|
|
|
general_metadata
|
|
|
|
)
|
|
|
|
if free_trial:
|
|
|
|
if onboarding:
|
|
|
|
session_type = Session.FREE_TRIAL_UPGRADE_FROM_ONBOARDING_PAGE
|
|
|
|
else:
|
|
|
|
session_type = Session.FREE_TRIAL_UPGRADE_FROM_BILLING_PAGE
|
|
|
|
payment_intent = None
|
|
|
|
else:
|
|
|
|
session_type = Session.UPGRADE_FROM_BILLING_PAGE
|
|
|
|
payment_intent = self.create_stripe_payment_intent(
|
|
|
|
price_per_license, licenses, updated_metadata
|
|
|
|
)
|
|
|
|
stripe_session = self.create_stripe_checkout_session(
|
|
|
|
updated_metadata, session_type, payment_intent
|
|
|
|
)
|
|
|
|
return stripe_session
|
|
|
|
|
2023-11-13 07:55:57 +01:00
|
|
|
# Only used for cloud signups
|
|
|
|
@catch_stripe_errors
|
|
|
|
def process_initial_upgrade(
|
|
|
|
self,
|
|
|
|
plan_tier: int,
|
|
|
|
licenses: int,
|
|
|
|
automanage_licenses: bool,
|
|
|
|
billing_schedule: int,
|
|
|
|
charge_automatically: bool,
|
|
|
|
free_trial: bool,
|
|
|
|
) -> None:
|
|
|
|
customer = self.update_or_create_stripe_customer()
|
|
|
|
assert customer.stripe_customer_id is not None # for mypy
|
|
|
|
ensure_customer_does_not_have_active_plan(customer)
|
|
|
|
(
|
|
|
|
billing_cycle_anchor,
|
|
|
|
next_invoice_date,
|
|
|
|
period_end,
|
|
|
|
price_per_license,
|
|
|
|
) = compute_plan_parameters(
|
|
|
|
plan_tier,
|
|
|
|
automanage_licenses,
|
|
|
|
billing_schedule,
|
|
|
|
customer.default_discount,
|
|
|
|
free_trial,
|
|
|
|
)
|
|
|
|
|
|
|
|
# TODO: The correctness of this relies on user creation, deactivation, etc being
|
|
|
|
# in a transaction.atomic() with the relevant RealmAuditLog entries
|
|
|
|
with transaction.atomic():
|
|
|
|
if customer.exempt_from_license_number_check:
|
|
|
|
billed_licenses = licenses
|
|
|
|
else:
|
|
|
|
# billed_licenses can be greater than licenses if users are added between the start of
|
|
|
|
# this function (process_initial_upgrade) and now
|
|
|
|
current_licenses_count = self.current_count_for_billed_licenses()
|
|
|
|
billed_licenses = max(current_licenses_count, licenses)
|
|
|
|
plan_params = {
|
|
|
|
"automanage_licenses": automanage_licenses,
|
|
|
|
"charge_automatically": charge_automatically,
|
|
|
|
"price_per_license": price_per_license,
|
|
|
|
"discount": customer.default_discount,
|
|
|
|
"billing_cycle_anchor": billing_cycle_anchor,
|
|
|
|
"billing_schedule": billing_schedule,
|
|
|
|
"tier": plan_tier,
|
|
|
|
}
|
|
|
|
if free_trial:
|
|
|
|
plan_params["status"] = CustomerPlan.FREE_TRIAL
|
|
|
|
plan = CustomerPlan.objects.create(
|
|
|
|
customer=customer, next_invoice_date=next_invoice_date, **plan_params
|
|
|
|
)
|
|
|
|
ledger_entry = LicenseLedger.objects.create(
|
|
|
|
plan=plan,
|
|
|
|
is_renewal=True,
|
|
|
|
event_time=billing_cycle_anchor,
|
|
|
|
licenses=billed_licenses,
|
|
|
|
licenses_at_next_renewal=billed_licenses,
|
|
|
|
)
|
|
|
|
plan.invoiced_through = ledger_entry
|
|
|
|
plan.save(update_fields=["invoiced_through"])
|
|
|
|
self.write_to_audit_log(
|
|
|
|
event_type=AuditLogEventType.CUSTOMER_PLAN_CREATED,
|
|
|
|
event_time=billing_cycle_anchor,
|
|
|
|
extra_data=plan_params,
|
|
|
|
)
|
|
|
|
|
|
|
|
if not free_trial:
|
|
|
|
stripe.InvoiceItem.create(
|
|
|
|
currency="usd",
|
|
|
|
customer=customer.stripe_customer_id,
|
|
|
|
description=plan.name,
|
|
|
|
discountable=False,
|
|
|
|
period={
|
|
|
|
"start": datetime_to_timestamp(billing_cycle_anchor),
|
|
|
|
"end": datetime_to_timestamp(period_end),
|
|
|
|
},
|
|
|
|
quantity=billed_licenses,
|
|
|
|
unit_amount=price_per_license,
|
|
|
|
)
|
|
|
|
|
|
|
|
if charge_automatically:
|
|
|
|
collection_method = "charge_automatically"
|
|
|
|
days_until_due = None
|
|
|
|
else:
|
|
|
|
collection_method = "send_invoice"
|
|
|
|
days_until_due = DEFAULT_INVOICE_DAYS_UNTIL_DUE
|
|
|
|
|
|
|
|
stripe_invoice = stripe.Invoice.create(
|
|
|
|
auto_advance=True,
|
|
|
|
collection_method=collection_method,
|
|
|
|
customer=customer.stripe_customer_id,
|
|
|
|
days_until_due=days_until_due,
|
|
|
|
statement_descriptor=plan.name,
|
|
|
|
)
|
|
|
|
stripe.Invoice.finalize_invoice(stripe_invoice)
|
|
|
|
|
|
|
|
self.do_change_plan_type(tier=plan_tier)
|
|
|
|
|
2023-10-26 14:11:43 +02:00
|
|
|
|
|
|
|
class RealmBillingSession(BillingSession):
|
2023-10-31 19:22:55 +01:00
|
|
|
def __init__(self, user: UserProfile, realm: Optional[Realm] = None) -> None:
|
2023-10-26 14:11:43 +02:00
|
|
|
self.user = user
|
2023-10-31 19:22:55 +01:00
|
|
|
if realm is not None:
|
|
|
|
assert user.is_staff
|
|
|
|
self.realm = realm
|
|
|
|
self.support_session = True
|
|
|
|
else:
|
|
|
|
self.realm = user.realm
|
|
|
|
self.support_session = False
|
2023-10-26 14:11:43 +02:00
|
|
|
|
2023-11-06 13:52:12 +01:00
|
|
|
@override
|
|
|
|
@property
|
|
|
|
def billing_session_url(self) -> str:
|
|
|
|
return self.realm.uri
|
|
|
|
|
2023-10-26 14:11:43 +02:00
|
|
|
@override
|
|
|
|
def get_customer(self) -> Optional[Customer]:
|
|
|
|
return get_customer_by_realm(self.realm)
|
|
|
|
|
2023-11-08 17:02:31 +01:00
|
|
|
@override
|
|
|
|
def current_count_for_billed_licenses(self) -> int:
|
|
|
|
return get_latest_seat_count(self.realm)
|
|
|
|
|
2023-11-02 17:44:02 +01:00
|
|
|
@override
|
|
|
|
def get_audit_log_event(self, event_type: AuditLogEventType) -> int:
|
|
|
|
if event_type is AuditLogEventType.STRIPE_CUSTOMER_CREATED:
|
|
|
|
return RealmAuditLog.STRIPE_CUSTOMER_CREATED
|
|
|
|
elif event_type is AuditLogEventType.STRIPE_CARD_CHANGED:
|
|
|
|
return RealmAuditLog.STRIPE_CARD_CHANGED
|
|
|
|
elif event_type is AuditLogEventType.CUSTOMER_PLAN_CREATED:
|
|
|
|
return RealmAuditLog.CUSTOMER_PLAN_CREATED
|
|
|
|
elif event_type is AuditLogEventType.DISCOUNT_CHANGED:
|
|
|
|
return RealmAuditLog.REALM_DISCOUNT_CHANGED
|
2023-11-02 15:23:35 +01:00
|
|
|
elif event_type is AuditLogEventType.SPONSORSHIP_APPROVED:
|
|
|
|
return RealmAuditLog.REALM_SPONSORSHIP_APPROVED
|
2023-11-02 18:17:08 +01:00
|
|
|
elif event_type is AuditLogEventType.SPONSORSHIP_PENDING_STATUS_CHANGED:
|
|
|
|
return RealmAuditLog.REALM_SPONSORSHIP_PENDING_STATUS_CHANGED
|
2023-11-02 18:42:04 +01:00
|
|
|
elif event_type is AuditLogEventType.BILLING_METHOD_CHANGED:
|
|
|
|
return RealmAuditLog.REALM_BILLING_METHOD_CHANGED
|
2023-11-02 17:44:02 +01:00
|
|
|
else:
|
|
|
|
raise BillingSessionAuditLogEventError(event_type)
|
|
|
|
|
2023-10-26 14:11:43 +02:00
|
|
|
@override
|
|
|
|
def write_to_audit_log(
|
2023-11-02 17:44:02 +01:00
|
|
|
self,
|
|
|
|
event_type: AuditLogEventType,
|
|
|
|
event_time: datetime,
|
|
|
|
*,
|
|
|
|
extra_data: Optional[Dict[str, Any]] = None,
|
2023-10-26 14:11:43 +02:00
|
|
|
) -> None:
|
2023-11-02 17:44:02 +01:00
|
|
|
audit_log_event = self.get_audit_log_event(event_type)
|
2023-10-26 14:11:43 +02:00
|
|
|
if extra_data:
|
|
|
|
RealmAuditLog.objects.create(
|
|
|
|
realm=self.realm,
|
|
|
|
acting_user=self.user,
|
2023-11-02 17:44:02 +01:00
|
|
|
event_type=audit_log_event,
|
2023-10-26 14:11:43 +02:00
|
|
|
event_time=event_time,
|
|
|
|
extra_data=extra_data,
|
|
|
|
)
|
|
|
|
else:
|
2018-08-23 07:47:05 +02:00
|
|
|
RealmAuditLog.objects.create(
|
2023-10-26 14:11:43 +02:00
|
|
|
realm=self.realm,
|
|
|
|
acting_user=self.user,
|
2023-11-02 17:44:02 +01:00
|
|
|
event_type=audit_log_event,
|
2021-02-12 08:19:30 +01:00
|
|
|
event_time=event_time,
|
|
|
|
)
|
2023-10-26 14:11:43 +02:00
|
|
|
|
|
|
|
@override
|
2023-10-31 15:51:51 +01:00
|
|
|
def get_data_for_stripe_customer(self) -> StripeCustomerData:
|
2023-10-31 19:22:55 +01:00
|
|
|
# Support requests do not set any stripe billing information.
|
|
|
|
assert self.support_session is False
|
2023-10-31 15:51:51 +01:00
|
|
|
metadata: Dict[str, Any] = {}
|
|
|
|
metadata["realm_id"] = self.realm.id
|
|
|
|
metadata["realm_str"] = self.realm.string_id
|
|
|
|
realm_stripe_customer_data = StripeCustomerData(
|
|
|
|
description=f"{self.realm.string_id} ({self.realm.name})",
|
|
|
|
email=self.user.delivery_email,
|
|
|
|
metadata=metadata,
|
|
|
|
)
|
|
|
|
return realm_stripe_customer_data
|
2023-10-26 14:11:43 +02:00
|
|
|
|
2023-11-06 15:51:54 +01:00
|
|
|
@override
|
|
|
|
def update_data_for_checkout_session_and_payment_intent(
|
|
|
|
self, metadata: Dict[str, Any]
|
|
|
|
) -> Dict[str, Any]:
|
|
|
|
updated_metadata = dict(
|
|
|
|
user_email=self.user.delivery_email,
|
|
|
|
realm_id=self.realm.id,
|
|
|
|
realm_str=self.realm.string_id,
|
|
|
|
user_id=self.user.id,
|
|
|
|
**metadata,
|
|
|
|
)
|
|
|
|
return updated_metadata
|
|
|
|
|
|
|
|
@override
|
|
|
|
def get_data_for_stripe_payment_intent(
|
|
|
|
self, price_per_license: int, licenses: int
|
|
|
|
) -> StripePaymentIntentData:
|
|
|
|
# Support requests do not set any stripe billing information.
|
|
|
|
assert self.support_session is False
|
|
|
|
amount = price_per_license * licenses
|
|
|
|
description = f"Upgrade to Zulip Cloud Standard, ${price_per_license/100} x {licenses}"
|
|
|
|
plan_name = "Zulip Cloud Standard"
|
|
|
|
return StripePaymentIntentData(
|
|
|
|
amount=amount,
|
|
|
|
description=description,
|
|
|
|
plan_name=plan_name,
|
|
|
|
email=self.user.delivery_email,
|
|
|
|
)
|
|
|
|
|
2023-10-26 14:11:43 +02:00
|
|
|
@override
|
2023-10-31 19:22:55 +01:00
|
|
|
def update_or_create_customer(
|
|
|
|
self, stripe_customer_id: Optional[str] = None, *, defaults: Optional[Dict[str, Any]] = None
|
|
|
|
) -> Customer:
|
|
|
|
if stripe_customer_id is not None:
|
|
|
|
# Support requests do not set any stripe billing information.
|
|
|
|
assert self.support_session is False
|
|
|
|
customer, created = Customer.objects.update_or_create(
|
|
|
|
realm=self.realm, defaults={"stripe_customer_id": stripe_customer_id}
|
|
|
|
)
|
|
|
|
from zerver.actions.users import do_make_user_billing_admin
|
2021-05-28 12:36:41 +02:00
|
|
|
|
2023-10-31 19:22:55 +01:00
|
|
|
do_make_user_billing_admin(self.user)
|
|
|
|
return customer
|
|
|
|
else:
|
|
|
|
customer, created = Customer.objects.update_or_create(
|
|
|
|
realm=self.realm, defaults=defaults
|
|
|
|
)
|
|
|
|
return customer
|
2018-08-14 03:33:31 +02:00
|
|
|
|
2023-11-08 17:15:40 +01:00
|
|
|
@override
|
|
|
|
def do_change_plan_type(self, *, tier: Optional[int], is_sponsored: bool = False) -> None:
|
|
|
|
from zerver.actions.realm_settings import do_change_realm_plan_type
|
|
|
|
|
|
|
|
# This function needs to translate between the different
|
|
|
|
# formats of CustomerPlan.tier and Realm.plan_type.
|
|
|
|
if is_sponsored:
|
|
|
|
plan_type = Realm.PLAN_TYPE_STANDARD_FREE
|
|
|
|
elif tier == CustomerPlan.STANDARD:
|
|
|
|
plan_type = Realm.PLAN_TYPE_STANDARD
|
2023-11-09 19:09:02 +01:00
|
|
|
elif tier == CustomerPlan.PLUS: # nocoverage # Plus plan doesn't use this code path yet.
|
2023-11-08 17:15:40 +01:00
|
|
|
plan_type = Realm.PLAN_TYPE_PLUS
|
|
|
|
else:
|
|
|
|
raise AssertionError("Unexpected tier")
|
|
|
|
do_change_realm_plan_type(self.realm, plan_type, acting_user=self.user)
|
|
|
|
|
2023-11-02 15:23:35 +01:00
|
|
|
@override
|
|
|
|
def approve_sponsorship(self) -> None:
|
|
|
|
# Sponsorship approval is only a support admin action.
|
|
|
|
assert self.support_session
|
|
|
|
|
|
|
|
from zerver.actions.message_send import internal_send_private_message
|
|
|
|
|
2023-11-08 17:15:40 +01:00
|
|
|
self.do_change_plan_type(tier=None, is_sponsored=True)
|
2023-11-02 15:23:35 +01:00
|
|
|
customer = self.get_customer()
|
|
|
|
if customer is not None and customer.sponsorship_pending:
|
|
|
|
customer.sponsorship_pending = False
|
|
|
|
customer.save(update_fields=["sponsorship_pending"])
|
|
|
|
self.write_to_audit_log(
|
|
|
|
event_type=AuditLogEventType.SPONSORSHIP_APPROVED, event_time=timezone_now()
|
|
|
|
)
|
|
|
|
notification_bot = get_system_bot(settings.NOTIFICATION_BOT, self.realm.id)
|
|
|
|
for user in self.realm.get_human_billing_admin_and_realm_owner_users():
|
|
|
|
with override_language(user.default_language):
|
|
|
|
# Using variable to make life easier for translators if these details change.
|
|
|
|
message = _(
|
|
|
|
"Your organization's request for sponsored hosting has been approved! "
|
|
|
|
"You have been upgraded to {plan_name}, free of charge. {emoji}\n\n"
|
|
|
|
"If you could {begin_link}list Zulip as a sponsor on your website{end_link}, "
|
|
|
|
"we would really appreciate it!"
|
|
|
|
).format(
|
|
|
|
plan_name="Zulip Cloud Standard",
|
|
|
|
emoji=":tada:",
|
|
|
|
begin_link="[",
|
|
|
|
end_link="](/help/linking-to-zulip-website)",
|
|
|
|
)
|
|
|
|
internal_send_private_message(notification_bot, user, message)
|
|
|
|
|
2021-02-12 08:19:30 +01:00
|
|
|
|
2021-08-29 15:33:29 +02:00
|
|
|
def stripe_customer_has_credit_card_as_default_payment_method(
|
|
|
|
stripe_customer: stripe.Customer,
|
|
|
|
) -> bool:
|
|
|
|
if not stripe_customer.invoice_settings.default_payment_method:
|
2020-10-14 12:17:03 +02:00
|
|
|
return False
|
2021-08-29 15:33:29 +02:00
|
|
|
return stripe_customer.invoice_settings.default_payment_method.type == "card"
|
2020-10-14 12:17:03 +02:00
|
|
|
|
|
|
|
|
2021-08-29 15:33:29 +02:00
|
|
|
def customer_has_credit_card_as_default_payment_method(customer: Customer) -> bool:
|
2020-10-14 12:17:03 +02:00
|
|
|
if not customer.stripe_customer_id:
|
|
|
|
return False
|
|
|
|
stripe_customer = stripe_get_customer(customer.stripe_customer_id)
|
2021-08-29 15:33:29 +02:00
|
|
|
return stripe_customer_has_credit_card_as_default_payment_method(stripe_customer)
|
2020-10-14 12:17:03 +02:00
|
|
|
|
|
|
|
|
2018-12-28 07:20:30 +01:00
|
|
|
# event_time should roughly be timezone_now(). Not designed to handle
|
|
|
|
# event_times in the past or future
|
2020-06-18 17:57:27 +02:00
|
|
|
@transaction.atomic
|
2021-02-12 08:19:30 +01:00
|
|
|
def make_end_of_cycle_updates_if_needed(
|
|
|
|
plan: CustomerPlan, event_time: datetime
|
|
|
|
) -> Tuple[Optional[CustomerPlan], Optional[LicenseLedger]]:
|
2021-02-12 08:20:45 +01:00
|
|
|
last_ledger_entry = LicenseLedger.objects.filter(plan=plan).order_by("-id").first()
|
2021-07-24 18:16:48 +02:00
|
|
|
last_ledger_renewal = (
|
|
|
|
LicenseLedger.objects.filter(plan=plan, is_renewal=True).order_by("-id").first()
|
2021-02-12 08:19:30 +01:00
|
|
|
)
|
2021-07-24 18:16:48 +02:00
|
|
|
assert last_ledger_renewal is not None
|
|
|
|
last_renewal = last_ledger_renewal.event_time
|
2021-09-20 13:29:36 +02:00
|
|
|
|
2021-09-21 21:21:03 +02:00
|
|
|
if plan.is_free_trial() or plan.status == CustomerPlan.SWITCH_NOW_FROM_STANDARD_TO_PLUS:
|
2021-09-20 13:29:36 +02:00
|
|
|
assert plan.next_invoice_date is not None
|
|
|
|
next_billing_cycle = plan.next_invoice_date
|
|
|
|
else:
|
|
|
|
next_billing_cycle = start_of_next_billing_cycle(plan, last_renewal)
|
2021-07-24 18:16:48 +02:00
|
|
|
if next_billing_cycle <= event_time and last_ledger_entry is not None:
|
|
|
|
licenses_at_next_renewal = last_ledger_entry.licenses_at_next_renewal
|
|
|
|
assert licenses_at_next_renewal is not None
|
2019-04-08 05:16:35 +02:00
|
|
|
if plan.status == CustomerPlan.ACTIVE:
|
2020-06-15 20:09:24 +02:00
|
|
|
return None, LicenseLedger.objects.create(
|
2021-02-12 08:19:30 +01:00
|
|
|
plan=plan,
|
|
|
|
is_renewal=True,
|
|
|
|
event_time=next_billing_cycle,
|
2021-07-24 18:16:48 +02:00
|
|
|
licenses=licenses_at_next_renewal,
|
|
|
|
licenses_at_next_renewal=licenses_at_next_renewal,
|
2021-02-12 08:19:30 +01:00
|
|
|
)
|
2020-11-11 14:02:47 +01:00
|
|
|
if plan.is_free_trial():
|
2020-04-23 20:10:15 +02:00
|
|
|
plan.invoiced_through = last_ledger_entry
|
2021-09-20 13:29:36 +02:00
|
|
|
plan.billing_cycle_anchor = next_billing_cycle.replace(microsecond=0)
|
2020-04-23 20:10:15 +02:00
|
|
|
plan.status = CustomerPlan.ACTIVE
|
|
|
|
plan.save(update_fields=["invoiced_through", "billing_cycle_anchor", "status"])
|
2020-06-15 20:09:24 +02:00
|
|
|
return None, LicenseLedger.objects.create(
|
2021-02-12 08:19:30 +01:00
|
|
|
plan=plan,
|
|
|
|
is_renewal=True,
|
|
|
|
event_time=next_billing_cycle,
|
2021-07-24 18:16:48 +02:00
|
|
|
licenses=licenses_at_next_renewal,
|
|
|
|
licenses_at_next_renewal=licenses_at_next_renewal,
|
2021-02-12 08:19:30 +01:00
|
|
|
)
|
2020-06-15 20:09:24 +02:00
|
|
|
|
|
|
|
if plan.status == CustomerPlan.SWITCH_TO_ANNUAL_AT_END_OF_CYCLE:
|
|
|
|
if plan.fixed_price is not None: # nocoverage
|
|
|
|
raise NotImplementedError("Can't switch fixed priced monthly plan to annual.")
|
|
|
|
|
|
|
|
plan.status = CustomerPlan.ENDED
|
|
|
|
plan.save(update_fields=["status"])
|
|
|
|
|
|
|
|
discount = plan.customer.default_discount or plan.discount
|
|
|
|
_, _, _, price_per_license = compute_plan_parameters(
|
2021-09-15 13:10:27 +02:00
|
|
|
tier=plan.tier,
|
2021-02-12 08:19:30 +01:00
|
|
|
automanage_licenses=plan.automanage_licenses,
|
|
|
|
billing_schedule=CustomerPlan.ANNUAL,
|
|
|
|
discount=plan.discount,
|
2020-06-15 20:09:24 +02:00
|
|
|
)
|
|
|
|
|
|
|
|
new_plan = CustomerPlan.objects.create(
|
2021-02-12 08:19:30 +01:00
|
|
|
customer=plan.customer,
|
|
|
|
billing_schedule=CustomerPlan.ANNUAL,
|
|
|
|
automanage_licenses=plan.automanage_licenses,
|
|
|
|
charge_automatically=plan.charge_automatically,
|
|
|
|
price_per_license=price_per_license,
|
|
|
|
discount=discount,
|
|
|
|
billing_cycle_anchor=next_billing_cycle,
|
|
|
|
tier=plan.tier,
|
|
|
|
status=CustomerPlan.ACTIVE,
|
|
|
|
next_invoice_date=next_billing_cycle,
|
|
|
|
invoiced_through=None,
|
|
|
|
invoicing_status=CustomerPlan.INITIAL_INVOICE_TO_BE_SENT,
|
2020-06-15 20:09:24 +02:00
|
|
|
)
|
|
|
|
|
|
|
|
new_plan_ledger_entry = LicenseLedger.objects.create(
|
2021-02-12 08:19:30 +01:00
|
|
|
plan=new_plan,
|
|
|
|
is_renewal=True,
|
|
|
|
event_time=next_billing_cycle,
|
2021-07-24 18:16:48 +02:00
|
|
|
licenses=licenses_at_next_renewal,
|
|
|
|
licenses_at_next_renewal=licenses_at_next_renewal,
|
2020-06-15 20:09:24 +02:00
|
|
|
)
|
|
|
|
|
2021-11-20 14:50:14 +01:00
|
|
|
realm = new_plan.customer.realm
|
|
|
|
assert realm is not None
|
|
|
|
|
2020-06-15 20:09:24 +02:00
|
|
|
RealmAuditLog.objects.create(
|
2021-11-20 14:50:14 +01:00
|
|
|
realm=realm,
|
2021-02-12 08:19:30 +01:00
|
|
|
event_time=event_time,
|
2020-06-15 20:09:24 +02:00
|
|
|
event_type=RealmAuditLog.CUSTOMER_SWITCHED_FROM_MONTHLY_TO_ANNUAL_PLAN,
|
2023-07-13 19:46:06 +02:00
|
|
|
extra_data={
|
|
|
|
"monthly_plan_id": plan.id,
|
|
|
|
"annual_plan_id": new_plan.id,
|
|
|
|
},
|
2020-06-15 20:09:24 +02:00
|
|
|
)
|
|
|
|
return new_plan, new_plan_ledger_entry
|
|
|
|
|
2021-09-21 21:21:03 +02:00
|
|
|
if plan.status == CustomerPlan.SWITCH_NOW_FROM_STANDARD_TO_PLUS:
|
|
|
|
standard_plan = plan
|
|
|
|
standard_plan.end_date = next_billing_cycle
|
|
|
|
standard_plan.status = CustomerPlan.ENDED
|
|
|
|
standard_plan.save(update_fields=["status", "end_date"])
|
|
|
|
|
|
|
|
(_, _, _, plus_plan_price_per_license) = compute_plan_parameters(
|
|
|
|
CustomerPlan.PLUS,
|
|
|
|
standard_plan.automanage_licenses,
|
|
|
|
standard_plan.billing_schedule,
|
|
|
|
standard_plan.customer.default_discount,
|
|
|
|
)
|
|
|
|
plus_plan_billing_cycle_anchor = standard_plan.end_date.replace(microsecond=0)
|
|
|
|
|
|
|
|
plus_plan = CustomerPlan.objects.create(
|
|
|
|
customer=standard_plan.customer,
|
|
|
|
status=CustomerPlan.ACTIVE,
|
|
|
|
automanage_licenses=standard_plan.automanage_licenses,
|
|
|
|
charge_automatically=standard_plan.charge_automatically,
|
|
|
|
price_per_license=plus_plan_price_per_license,
|
|
|
|
discount=standard_plan.customer.default_discount,
|
|
|
|
billing_schedule=standard_plan.billing_schedule,
|
|
|
|
tier=CustomerPlan.PLUS,
|
|
|
|
billing_cycle_anchor=plus_plan_billing_cycle_anchor,
|
|
|
|
invoicing_status=CustomerPlan.INITIAL_INVOICE_TO_BE_SENT,
|
|
|
|
next_invoice_date=plus_plan_billing_cycle_anchor,
|
|
|
|
)
|
|
|
|
|
|
|
|
standard_plan_last_ledger = (
|
|
|
|
LicenseLedger.objects.filter(plan=standard_plan).order_by("id").last()
|
|
|
|
)
|
2022-05-31 01:34:34 +02:00
|
|
|
assert standard_plan_last_ledger is not None
|
2021-09-21 21:21:03 +02:00
|
|
|
licenses_for_plus_plan = standard_plan_last_ledger.licenses_at_next_renewal
|
2022-05-31 01:34:34 +02:00
|
|
|
assert licenses_for_plus_plan is not None
|
2021-09-21 21:21:03 +02:00
|
|
|
plus_plan_ledger_entry = LicenseLedger.objects.create(
|
|
|
|
plan=plus_plan,
|
|
|
|
is_renewal=True,
|
|
|
|
event_time=plus_plan_billing_cycle_anchor,
|
|
|
|
licenses=licenses_for_plus_plan,
|
|
|
|
licenses_at_next_renewal=licenses_for_plus_plan,
|
|
|
|
)
|
|
|
|
return plus_plan, plus_plan_ledger_entry
|
|
|
|
|
2019-04-08 05:16:35 +02:00
|
|
|
if plan.status == CustomerPlan.DOWNGRADE_AT_END_OF_CYCLE:
|
|
|
|
process_downgrade(plan)
|
2020-06-15 20:09:24 +02:00
|
|
|
return None, None
|
|
|
|
return None, last_ledger_entry
|
2018-12-28 07:20:30 +01:00
|
|
|
|
2021-02-12 08:19:30 +01:00
|
|
|
|
|
|
|
def calculate_discounted_price_per_license(
|
|
|
|
original_price_per_license: int, discount: Decimal
|
|
|
|
) -> int:
|
2020-12-04 15:13:31 +01:00
|
|
|
# There are no fractional cents in Stripe, so round down to nearest integer.
|
2021-02-12 08:19:30 +01:00
|
|
|
return int(float(original_price_per_license * (1 - discount / 100)) + 0.00001)
|
|
|
|
|
2020-12-04 15:13:31 +01:00
|
|
|
|
2021-02-12 08:19:30 +01:00
|
|
|
def get_price_per_license(
|
|
|
|
tier: int, billing_schedule: int, discount: Optional[Decimal] = None
|
|
|
|
) -> int:
|
2020-12-04 12:56:58 +01:00
|
|
|
price_per_license: Optional[int] = None
|
2021-09-15 13:54:56 +02:00
|
|
|
|
|
|
|
if tier == CustomerPlan.STANDARD:
|
|
|
|
if billing_schedule == CustomerPlan.ANNUAL:
|
|
|
|
price_per_license = 8000
|
|
|
|
elif billing_schedule == CustomerPlan.MONTHLY:
|
|
|
|
price_per_license = 800
|
|
|
|
else: # nocoverage
|
2022-11-17 09:30:48 +01:00
|
|
|
raise InvalidBillingScheduleError(billing_schedule)
|
2021-09-15 13:54:56 +02:00
|
|
|
elif tier == CustomerPlan.PLUS:
|
|
|
|
if billing_schedule == CustomerPlan.ANNUAL:
|
|
|
|
price_per_license = 16000
|
|
|
|
elif billing_schedule == CustomerPlan.MONTHLY:
|
|
|
|
price_per_license = 1600
|
|
|
|
else: # nocoverage
|
2022-11-17 09:30:48 +01:00
|
|
|
raise InvalidBillingScheduleError(billing_schedule)
|
2021-09-15 13:54:56 +02:00
|
|
|
else:
|
2022-11-17 09:30:48 +01:00
|
|
|
raise InvalidTierError(tier)
|
2021-09-15 13:54:56 +02:00
|
|
|
|
2020-12-04 12:56:58 +01:00
|
|
|
if discount is not None:
|
2020-12-04 15:13:31 +01:00
|
|
|
price_per_license = calculate_discounted_price_per_license(price_per_license, discount)
|
2020-12-04 12:56:58 +01:00
|
|
|
return price_per_license
|
|
|
|
|
2021-02-12 08:19:30 +01:00
|
|
|
|
2018-12-15 09:33:25 +01:00
|
|
|
def compute_plan_parameters(
|
2021-09-15 13:10:27 +02:00
|
|
|
tier: int,
|
2021-02-12 08:19:30 +01:00
|
|
|
automanage_licenses: bool,
|
|
|
|
billing_schedule: int,
|
|
|
|
discount: Optional[Decimal],
|
|
|
|
free_trial: bool = False,
|
|
|
|
) -> Tuple[datetime, datetime, datetime, int]:
|
2018-12-15 09:33:25 +01:00
|
|
|
# Everything in Stripe is stored as timestamps with 1 second resolution,
|
|
|
|
# so standardize on 1 second resolution.
|
2022-02-08 00:13:33 +01:00
|
|
|
# TODO talk about leap seconds?
|
2018-12-15 09:33:25 +01:00
|
|
|
billing_cycle_anchor = timezone_now().replace(microsecond=0)
|
|
|
|
if billing_schedule == CustomerPlan.ANNUAL:
|
|
|
|
period_end = add_months(billing_cycle_anchor, 12)
|
|
|
|
elif billing_schedule == CustomerPlan.MONTHLY:
|
|
|
|
period_end = add_months(billing_cycle_anchor, 1)
|
2020-12-04 12:56:58 +01:00
|
|
|
else: # nocoverage
|
2022-11-17 09:30:48 +01:00
|
|
|
raise InvalidBillingScheduleError(billing_schedule)
|
2020-12-04 12:56:58 +01:00
|
|
|
|
2021-09-15 13:10:27 +02:00
|
|
|
price_per_license = get_price_per_license(tier, billing_schedule, discount)
|
2020-12-04 12:56:58 +01:00
|
|
|
|
2019-01-28 14:18:21 +01:00
|
|
|
next_invoice_date = period_end
|
2018-12-15 09:33:25 +01:00
|
|
|
if automanage_licenses:
|
2019-01-28 14:18:21 +01:00
|
|
|
next_invoice_date = add_months(billing_cycle_anchor, 1)
|
2020-04-23 20:10:15 +02:00
|
|
|
if free_trial:
|
2021-07-25 16:31:12 +02:00
|
|
|
period_end = billing_cycle_anchor + timedelta(
|
|
|
|
days=assert_is_not_none(settings.FREE_TRIAL_DAYS)
|
|
|
|
)
|
2020-04-23 20:10:15 +02:00
|
|
|
next_invoice_date = period_end
|
2019-01-28 14:18:21 +01:00
|
|
|
return billing_cycle_anchor, next_invoice_date, period_end, price_per_license
|
2018-12-15 09:33:25 +01:00
|
|
|
|
2021-02-12 08:19:30 +01:00
|
|
|
|
2020-10-14 09:44:01 +02:00
|
|
|
def is_free_trial_offer_enabled() -> bool:
|
|
|
|
return settings.FREE_TRIAL_DAYS not in (None, 0)
|
|
|
|
|
|
|
|
|
2023-10-30 22:29:22 +01:00
|
|
|
def ensure_customer_does_not_have_active_plan(customer: Customer) -> None:
|
|
|
|
if get_current_plan_by_customer(customer) is not None:
|
2021-08-29 15:33:29 +02:00
|
|
|
# Unlikely race condition from two people upgrading (clicking "Make payment")
|
|
|
|
# at exactly the same time. Doesn't fully resolve the race condition, but having
|
|
|
|
# a check here reduces the likelihood.
|
|
|
|
billing_logger.warning(
|
|
|
|
"Upgrade of %s failed because of existing active plan.",
|
2023-10-30 22:29:22 +01:00
|
|
|
str(customer),
|
2021-08-29 15:33:29 +02:00
|
|
|
)
|
2023-02-04 02:07:20 +01:00
|
|
|
raise UpgradeWithExistingPlanError
|
2021-08-29 15:33:29 +02:00
|
|
|
|
|
|
|
|
2021-12-01 17:31:08 +01:00
|
|
|
@transaction.atomic
|
|
|
|
def do_change_remote_server_plan_type(remote_server: RemoteZulipServer, plan_type: int) -> None:
|
|
|
|
old_value = remote_server.plan_type
|
|
|
|
remote_server.plan_type = plan_type
|
|
|
|
remote_server.save(update_fields=["plan_type"])
|
|
|
|
RemoteZulipServerAuditLog.objects.create(
|
|
|
|
event_type=RealmAuditLog.REMOTE_SERVER_PLAN_TYPE_CHANGED,
|
|
|
|
server=remote_server,
|
|
|
|
event_time=timezone_now(),
|
2023-07-13 19:46:06 +02:00
|
|
|
extra_data={"old_value": old_value, "new_value": plan_type},
|
2021-12-01 17:31:08 +01:00
|
|
|
)
|
|
|
|
|
|
|
|
|
2021-12-15 18:53:58 +01:00
|
|
|
@transaction.atomic
|
|
|
|
def do_deactivate_remote_server(remote_server: RemoteZulipServer) -> None:
|
|
|
|
if remote_server.deactivated:
|
|
|
|
billing_logger.warning(
|
2023-02-04 01:42:19 +01:00
|
|
|
"Cannot deactivate remote server with ID %d, server has already been deactivated.",
|
|
|
|
remote_server.id,
|
2021-12-15 18:53:58 +01:00
|
|
|
)
|
|
|
|
return
|
|
|
|
|
|
|
|
remote_server.deactivated = True
|
|
|
|
remote_server.save(update_fields=["deactivated"])
|
|
|
|
RemoteZulipServerAuditLog.objects.create(
|
|
|
|
event_type=RealmAuditLog.REMOTE_SERVER_DEACTIVATED,
|
|
|
|
server=remote_server,
|
|
|
|
event_time=timezone_now(),
|
|
|
|
)
|
|
|
|
|
|
|
|
|
2020-12-30 18:56:57 +01:00
|
|
|
def update_license_ledger_for_manual_plan(
|
|
|
|
plan: CustomerPlan,
|
|
|
|
event_time: datetime,
|
|
|
|
licenses: Optional[int] = None,
|
|
|
|
licenses_at_next_renewal: Optional[int] = None,
|
|
|
|
) -> None:
|
|
|
|
if licenses is not None:
|
2021-11-20 14:50:14 +01:00
|
|
|
assert plan.customer.realm is not None
|
2023-09-26 01:57:58 +02:00
|
|
|
if not plan.customer.exempt_from_license_number_check:
|
|
|
|
assert get_latest_seat_count(plan.customer.realm) <= licenses
|
2020-12-30 18:56:57 +01:00
|
|
|
assert licenses > plan.licenses()
|
|
|
|
LicenseLedger.objects.create(
|
|
|
|
plan=plan, event_time=event_time, licenses=licenses, licenses_at_next_renewal=licenses
|
|
|
|
)
|
|
|
|
elif licenses_at_next_renewal is not None:
|
2021-11-20 14:50:14 +01:00
|
|
|
assert plan.customer.realm is not None
|
2023-09-26 01:57:58 +02:00
|
|
|
if not plan.customer.exempt_from_license_number_check:
|
|
|
|
assert get_latest_seat_count(plan.customer.realm) <= licenses_at_next_renewal
|
2020-12-30 18:56:57 +01:00
|
|
|
LicenseLedger.objects.create(
|
|
|
|
plan=plan,
|
|
|
|
event_time=event_time,
|
|
|
|
licenses=plan.licenses(),
|
|
|
|
licenses_at_next_renewal=licenses_at_next_renewal,
|
|
|
|
)
|
|
|
|
else:
|
|
|
|
raise AssertionError("Pass licenses or licenses_at_next_renewal")
|
|
|
|
|
|
|
|
|
2021-02-12 08:19:30 +01:00
|
|
|
def update_license_ledger_for_automanaged_plan(
|
|
|
|
realm: Realm, plan: CustomerPlan, event_time: datetime
|
|
|
|
) -> None:
|
2020-06-15 20:09:24 +02:00
|
|
|
new_plan, last_ledger_entry = make_end_of_cycle_updates_if_needed(plan, event_time)
|
2019-04-08 05:16:35 +02:00
|
|
|
if last_ledger_entry is None:
|
|
|
|
return
|
2020-06-15 20:09:24 +02:00
|
|
|
if new_plan is not None:
|
|
|
|
plan = new_plan
|
2019-10-07 19:21:29 +02:00
|
|
|
licenses_at_next_renewal = get_latest_seat_count(realm)
|
2019-01-26 02:36:37 +01:00
|
|
|
licenses = max(licenses_at_next_renewal, last_ledger_entry.licenses)
|
2020-06-15 20:09:24 +02:00
|
|
|
|
2019-01-26 02:36:37 +01:00
|
|
|
LicenseLedger.objects.create(
|
2021-02-12 08:19:30 +01:00
|
|
|
plan=plan,
|
|
|
|
event_time=event_time,
|
|
|
|
licenses=licenses,
|
|
|
|
licenses_at_next_renewal=licenses_at_next_renewal,
|
|
|
|
)
|
|
|
|
|
2019-01-26 02:36:37 +01:00
|
|
|
|
|
|
|
def update_license_ledger_if_needed(realm: Realm, event_time: datetime) -> None:
|
2020-03-24 14:22:27 +01:00
|
|
|
plan = get_current_plan_by_realm(realm)
|
2019-01-26 02:36:37 +01:00
|
|
|
if plan is None:
|
|
|
|
return
|
|
|
|
if not plan.automanage_licenses:
|
|
|
|
return
|
|
|
|
update_license_ledger_for_automanaged_plan(realm, plan, event_time)
|
|
|
|
|
2021-02-12 08:19:30 +01:00
|
|
|
|
2021-09-21 21:21:03 +02:00
|
|
|
def get_plan_renewal_or_end_date(plan: CustomerPlan, event_time: datetime) -> datetime:
|
|
|
|
billing_period_end = start_of_next_billing_cycle(plan, event_time)
|
|
|
|
|
|
|
|
if plan.end_date is not None and plan.end_date < billing_period_end:
|
|
|
|
return plan.end_date
|
|
|
|
return billing_period_end
|
|
|
|
|
|
|
|
|
2019-01-28 22:57:29 +01:00
|
|
|
def invoice_plan(plan: CustomerPlan, event_time: datetime) -> None:
|
|
|
|
if plan.invoicing_status == CustomerPlan.STARTED:
|
2021-02-12 08:20:45 +01:00
|
|
|
raise NotImplementedError("Plan with invoicing_status==STARTED needs manual resolution.")
|
2021-06-18 21:10:45 +02:00
|
|
|
if not plan.customer.stripe_customer_id:
|
2021-11-20 14:50:14 +01:00
|
|
|
assert plan.customer.realm is not None
|
2021-06-18 21:10:45 +02:00
|
|
|
raise BillingError(
|
|
|
|
f"Realm {plan.customer.realm.string_id} has a paid plan without a Stripe customer."
|
|
|
|
)
|
|
|
|
|
2019-04-11 00:24:45 +02:00
|
|
|
make_end_of_cycle_updates_if_needed(plan, event_time)
|
2020-06-15 20:09:24 +02:00
|
|
|
|
|
|
|
if plan.invoicing_status == CustomerPlan.INITIAL_INVOICE_TO_BE_SENT:
|
|
|
|
invoiced_through_id = -1
|
|
|
|
licenses_base = None
|
|
|
|
else:
|
2021-02-12 08:19:30 +01:00
|
|
|
assert plan.invoiced_through is not None
|
2020-06-15 20:09:24 +02:00
|
|
|
licenses_base = plan.invoiced_through.licenses
|
|
|
|
invoiced_through_id = plan.invoiced_through.id
|
|
|
|
|
2019-01-28 22:57:29 +01:00
|
|
|
invoice_item_created = False
|
2021-02-12 08:19:30 +01:00
|
|
|
for ledger_entry in LicenseLedger.objects.filter(
|
|
|
|
plan=plan, id__gt=invoiced_through_id, event_time__lte=event_time
|
2021-02-12 08:20:45 +01:00
|
|
|
).order_by("id"):
|
python: Convert assignment type annotations to Python 3.6 style.
This commit was split by tabbott; this piece covers the vast majority
of files in Zulip, but excludes scripts/, tools/, and puppet/ to help
ensure we at least show the right error messages for Xenial systems.
We can likely further refine the remaining pieces with some testing.
Generated by com2ann, with whitespace fixes and various manual fixes
for runtime issues:
- invoiced_through: Optional[LicenseLedger] = models.ForeignKey(
+ invoiced_through: Optional["LicenseLedger"] = models.ForeignKey(
-_apns_client: Optional[APNsClient] = None
+_apns_client: Optional["APNsClient"] = None
- notifications_stream: Optional[Stream] = models.ForeignKey('Stream', related_name='+', null=True, blank=True, on_delete=CASCADE)
- signup_notifications_stream: Optional[Stream] = models.ForeignKey('Stream', related_name='+', null=True, blank=True, on_delete=CASCADE)
+ notifications_stream: Optional["Stream"] = models.ForeignKey('Stream', related_name='+', null=True, blank=True, on_delete=CASCADE)
+ signup_notifications_stream: Optional["Stream"] = models.ForeignKey('Stream', related_name='+', null=True, blank=True, on_delete=CASCADE)
- author: Optional[UserProfile] = models.ForeignKey('UserProfile', blank=True, null=True, on_delete=CASCADE)
+ author: Optional["UserProfile"] = models.ForeignKey('UserProfile', blank=True, null=True, on_delete=CASCADE)
- bot_owner: Optional[UserProfile] = models.ForeignKey('self', null=True, on_delete=models.SET_NULL)
+ bot_owner: Optional["UserProfile"] = models.ForeignKey('self', null=True, on_delete=models.SET_NULL)
- default_sending_stream: Optional[Stream] = models.ForeignKey('zerver.Stream', null=True, related_name='+', on_delete=CASCADE)
- default_events_register_stream: Optional[Stream] = models.ForeignKey('zerver.Stream', null=True, related_name='+', on_delete=CASCADE)
+ default_sending_stream: Optional["Stream"] = models.ForeignKey('zerver.Stream', null=True, related_name='+', on_delete=CASCADE)
+ default_events_register_stream: Optional["Stream"] = models.ForeignKey('zerver.Stream', null=True, related_name='+', on_delete=CASCADE)
-descriptors_by_handler_id: Dict[int, ClientDescriptor] = {}
+descriptors_by_handler_id: Dict[int, "ClientDescriptor"] = {}
-worker_classes: Dict[str, Type[QueueProcessingWorker]] = {}
-queues: Dict[str, Dict[str, Type[QueueProcessingWorker]]] = {}
+worker_classes: Dict[str, Type["QueueProcessingWorker"]] = {}
+queues: Dict[str, Dict[str, Type["QueueProcessingWorker"]]] = {}
-AUTH_LDAP_REVERSE_EMAIL_SEARCH: Optional[LDAPSearch] = None
+AUTH_LDAP_REVERSE_EMAIL_SEARCH: Optional["LDAPSearch"] = None
Signed-off-by: Anders Kaseorg <anders@zulipchat.com>
2020-04-22 01:09:50 +02:00
|
|
|
price_args: Dict[str, int] = {}
|
2019-01-28 22:57:29 +01:00
|
|
|
if ledger_entry.is_renewal:
|
|
|
|
if plan.fixed_price is not None:
|
2021-02-12 08:20:45 +01:00
|
|
|
price_args = {"amount": plan.fixed_price}
|
2019-01-28 22:57:29 +01:00
|
|
|
else:
|
2021-02-12 08:19:30 +01:00
|
|
|
assert plan.price_per_license is not None # needed for mypy
|
|
|
|
price_args = {
|
2021-02-12 08:20:45 +01:00
|
|
|
"unit_amount": plan.price_per_license,
|
|
|
|
"quantity": ledger_entry.licenses,
|
2021-02-12 08:19:30 +01:00
|
|
|
}
|
2021-09-15 13:17:56 +02:00
|
|
|
description = f"{plan.name} - renewal"
|
2020-06-15 20:09:24 +02:00
|
|
|
elif licenses_base is not None and ledger_entry.licenses != licenses_base:
|
2021-09-21 21:21:03 +02:00
|
|
|
assert plan.price_per_license
|
2021-07-24 18:16:48 +02:00
|
|
|
last_ledger_entry_renewal = (
|
2021-02-12 08:19:30 +01:00
|
|
|
LicenseLedger.objects.filter(
|
|
|
|
plan=plan, is_renewal=True, event_time__lte=ledger_entry.event_time
|
|
|
|
)
|
2021-02-12 08:20:45 +01:00
|
|
|
.order_by("-id")
|
2021-02-12 08:19:30 +01:00
|
|
|
.first()
|
|
|
|
)
|
2021-07-24 18:16:48 +02:00
|
|
|
assert last_ledger_entry_renewal is not None
|
|
|
|
last_renewal = last_ledger_entry_renewal.event_time
|
2021-09-21 21:21:03 +02:00
|
|
|
billing_period_end = start_of_next_billing_cycle(plan, ledger_entry.event_time)
|
|
|
|
plan_renewal_or_end_date = get_plan_renewal_or_end_date(plan, ledger_entry.event_time)
|
|
|
|
proration_fraction = (plan_renewal_or_end_date - ledger_entry.event_time) / (
|
|
|
|
billing_period_end - last_renewal
|
2021-02-12 08:19:30 +01:00
|
|
|
)
|
|
|
|
price_args = {
|
2021-02-12 08:20:45 +01:00
|
|
|
"unit_amount": int(plan.price_per_license * proration_fraction + 0.5),
|
|
|
|
"quantity": ledger_entry.licenses - licenses_base,
|
2021-02-12 08:19:30 +01:00
|
|
|
}
|
2019-01-28 22:57:29 +01:00
|
|
|
description = "Additional license ({} - {})".format(
|
2021-09-21 21:21:03 +02:00
|
|
|
ledger_entry.event_time.strftime("%b %-d, %Y"),
|
|
|
|
plan_renewal_or_end_date.strftime("%b %-d, %Y"),
|
2021-02-12 08:19:30 +01:00
|
|
|
)
|
2019-01-28 22:57:29 +01:00
|
|
|
|
|
|
|
if price_args:
|
|
|
|
plan.invoiced_through = ledger_entry
|
|
|
|
plan.invoicing_status = CustomerPlan.STARTED
|
2021-02-12 08:20:45 +01:00
|
|
|
plan.save(update_fields=["invoicing_status", "invoiced_through"])
|
2019-01-28 22:57:29 +01:00
|
|
|
stripe.InvoiceItem.create(
|
2021-02-12 08:20:45 +01:00
|
|
|
currency="usd",
|
2019-01-28 22:57:29 +01:00
|
|
|
customer=plan.customer.stripe_customer_id,
|
|
|
|
description=description,
|
|
|
|
discountable=False,
|
2021-02-12 08:19:30 +01:00
|
|
|
period={
|
2021-02-12 08:20:45 +01:00
|
|
|
"start": datetime_to_timestamp(ledger_entry.event_time),
|
|
|
|
"end": datetime_to_timestamp(
|
2021-09-21 21:21:03 +02:00
|
|
|
get_plan_renewal_or_end_date(plan, ledger_entry.event_time)
|
2021-02-12 08:19:30 +01:00
|
|
|
),
|
|
|
|
},
|
2020-06-15 20:09:24 +02:00
|
|
|
idempotency_key=get_idempotency_key(ledger_entry),
|
2021-02-12 08:19:30 +01:00
|
|
|
**price_args,
|
|
|
|
)
|
2019-01-28 22:57:29 +01:00
|
|
|
invoice_item_created = True
|
|
|
|
plan.invoiced_through = ledger_entry
|
|
|
|
plan.invoicing_status = CustomerPlan.DONE
|
2021-02-12 08:20:45 +01:00
|
|
|
plan.save(update_fields=["invoicing_status", "invoiced_through"])
|
2019-01-28 22:57:29 +01:00
|
|
|
licenses_base = ledger_entry.licenses
|
|
|
|
|
|
|
|
if invoice_item_created:
|
|
|
|
if plan.charge_automatically:
|
2021-07-20 13:28:54 +02:00
|
|
|
collection_method = "charge_automatically"
|
2019-01-28 22:57:29 +01:00
|
|
|
days_until_due = None
|
|
|
|
else:
|
2021-07-20 13:28:54 +02:00
|
|
|
collection_method = "send_invoice"
|
2019-01-28 22:57:29 +01:00
|
|
|
days_until_due = DEFAULT_INVOICE_DAYS_UNTIL_DUE
|
|
|
|
stripe_invoice = stripe.Invoice.create(
|
|
|
|
auto_advance=True,
|
2021-07-20 13:28:54 +02:00
|
|
|
collection_method=collection_method,
|
2019-01-28 22:57:29 +01:00
|
|
|
customer=plan.customer.stripe_customer_id,
|
|
|
|
days_until_due=days_until_due,
|
2021-09-15 13:17:56 +02:00
|
|
|
statement_descriptor=plan.name,
|
2021-02-12 08:19:30 +01:00
|
|
|
)
|
2019-01-28 22:57:29 +01:00
|
|
|
stripe.Invoice.finalize_invoice(stripe_invoice)
|
|
|
|
|
|
|
|
plan.next_invoice_date = next_invoice_date(plan)
|
2021-02-12 08:20:45 +01:00
|
|
|
plan.save(update_fields=["next_invoice_date"])
|
2019-01-28 22:57:29 +01:00
|
|
|
|
2021-02-12 08:19:30 +01:00
|
|
|
|
2023-04-27 22:25:38 +02:00
|
|
|
def invoice_plans_as_needed(event_time: Optional[datetime] = None) -> None:
|
|
|
|
if event_time is None: # nocoverage
|
|
|
|
event_time = timezone_now()
|
2019-01-28 22:57:29 +01:00
|
|
|
for plan in CustomerPlan.objects.filter(next_invoice_date__lte=event_time):
|
|
|
|
invoice_plan(plan, event_time)
|
|
|
|
|
2021-02-12 08:19:30 +01:00
|
|
|
|
2020-11-11 14:09:30 +01:00
|
|
|
def is_realm_on_free_trial(realm: Realm) -> bool:
|
|
|
|
plan = get_current_plan_by_realm(realm)
|
|
|
|
return plan is not None and plan.is_free_trial()
|
|
|
|
|
|
|
|
|
2020-10-14 18:45:57 +02:00
|
|
|
def is_sponsored_realm(realm: Realm) -> bool:
|
2021-10-18 23:28:17 +02:00
|
|
|
return realm.plan_type == Realm.PLAN_TYPE_STANDARD_FREE
|
2020-10-14 18:45:57 +02:00
|
|
|
|
|
|
|
|
2019-04-08 05:16:35 +02:00
|
|
|
def do_change_plan_status(plan: CustomerPlan, status: int) -> None:
|
|
|
|
plan.status = status
|
2021-02-12 08:20:45 +01:00
|
|
|
plan.save(update_fields=["status"])
|
2020-05-02 20:57:12 +02:00
|
|
|
billing_logger.info(
|
2021-02-12 08:20:45 +01:00
|
|
|
"Change plan status: Customer.id: %s, CustomerPlan.id: %s, status: %s",
|
2021-02-12 08:19:30 +01:00
|
|
|
plan.customer.id,
|
|
|
|
plan.id,
|
|
|
|
status,
|
2020-05-02 20:57:12 +02:00
|
|
|
)
|
2019-04-08 05:16:35 +02:00
|
|
|
|
2021-02-12 08:19:30 +01:00
|
|
|
|
2019-04-08 05:16:35 +02:00
|
|
|
def process_downgrade(plan: CustomerPlan) -> None:
|
2022-04-14 23:57:15 +02:00
|
|
|
from zerver.actions.realm_settings import do_change_realm_plan_type
|
2021-02-12 08:19:30 +01:00
|
|
|
|
2021-11-20 14:50:14 +01:00
|
|
|
assert plan.customer.realm is not None
|
2021-12-01 02:10:40 +01:00
|
|
|
do_change_realm_plan_type(plan.customer.realm, Realm.PLAN_TYPE_LIMITED, acting_user=None)
|
2019-04-08 05:16:35 +02:00
|
|
|
plan.status = CustomerPlan.ENDED
|
2021-02-12 08:20:45 +01:00
|
|
|
plan.save(update_fields=["status"])
|
2018-12-15 09:33:25 +01:00
|
|
|
|
2021-02-12 08:19:30 +01:00
|
|
|
|
2020-03-20 14:58:38 +01:00
|
|
|
# During realm deactivation we instantly downgrade the plan to Limited.
|
2020-04-23 20:10:15 +02:00
|
|
|
# Extra users added in the final month are not charged. Also used
|
2020-08-11 01:47:44 +02:00
|
|
|
# for the cancellation of Free Trial.
|
2020-08-13 10:22:58 +02:00
|
|
|
def downgrade_now_without_creating_additional_invoices(realm: Realm) -> None:
|
2020-03-24 14:22:27 +01:00
|
|
|
plan = get_current_plan_by_realm(realm)
|
|
|
|
if plan is None:
|
|
|
|
return
|
|
|
|
|
|
|
|
process_downgrade(plan)
|
2021-02-12 08:20:45 +01:00
|
|
|
plan.invoiced_through = LicenseLedger.objects.filter(plan=plan).order_by("id").last()
|
2020-03-24 14:22:27 +01:00
|
|
|
plan.next_invoice_date = next_invoice_date(plan)
|
|
|
|
plan.save(update_fields=["invoiced_through", "next_invoice_date"])
|
2020-08-13 10:39:25 +02:00
|
|
|
|
2021-02-12 08:19:30 +01:00
|
|
|
|
2020-08-13 13:06:05 +02:00
|
|
|
def downgrade_at_the_end_of_billing_cycle(realm: Realm) -> None:
|
|
|
|
plan = get_current_plan_by_realm(realm)
|
2021-02-12 08:19:30 +01:00
|
|
|
assert plan is not None
|
2020-08-13 13:06:05 +02:00
|
|
|
do_change_plan_status(plan, CustomerPlan.DOWNGRADE_AT_END_OF_CYCLE)
|
|
|
|
|
2021-02-12 08:19:30 +01:00
|
|
|
|
2021-06-11 10:10:17 +02:00
|
|
|
def get_all_invoices_for_customer(customer: Customer) -> Generator[stripe.Invoice, None, None]:
|
|
|
|
if customer.stripe_customer_id is None:
|
|
|
|
return
|
|
|
|
|
|
|
|
invoices = stripe.Invoice.list(customer=customer.stripe_customer_id, limit=100)
|
|
|
|
while len(invoices):
|
|
|
|
for invoice in invoices:
|
|
|
|
yield invoice
|
|
|
|
last_invoice = invoice
|
|
|
|
invoices = stripe.Invoice.list(
|
|
|
|
customer=customer.stripe_customer_id, starting_after=last_invoice, limit=100
|
|
|
|
)
|
|
|
|
|
|
|
|
|
2020-08-13 10:39:25 +02:00
|
|
|
def void_all_open_invoices(realm: Realm) -> int:
|
|
|
|
customer = get_customer_by_realm(realm)
|
|
|
|
if customer is None:
|
|
|
|
return 0
|
2021-06-11 10:10:17 +02:00
|
|
|
invoices = get_all_invoices_for_customer(customer)
|
2020-08-13 10:39:25 +02:00
|
|
|
voided_invoices_count = 0
|
|
|
|
for invoice in invoices:
|
|
|
|
if invoice.status == "open":
|
2021-02-12 08:19:30 +01:00
|
|
|
stripe.Invoice.void_invoice(invoice.id)
|
2020-08-13 10:39:25 +02:00
|
|
|
voided_invoices_count += 1
|
|
|
|
return voided_invoices_count
|
2020-08-18 13:48:11 +02:00
|
|
|
|
2021-02-12 08:19:30 +01:00
|
|
|
|
2021-07-16 15:35:13 +02:00
|
|
|
def customer_has_last_n_invoices_open(customer: Customer, n: int) -> bool:
|
2021-07-16 17:25:32 +02:00
|
|
|
if customer.stripe_customer_id is None: # nocoverage
|
2021-07-16 15:35:13 +02:00
|
|
|
return False
|
|
|
|
|
|
|
|
open_invoice_count = 0
|
|
|
|
for invoice in stripe.Invoice.list(customer=customer.stripe_customer_id, limit=n):
|
|
|
|
if invoice.status == "open":
|
|
|
|
open_invoice_count += 1
|
|
|
|
return open_invoice_count == n
|
|
|
|
|
|
|
|
|
2021-06-11 12:53:45 +02:00
|
|
|
def downgrade_small_realms_behind_on_payments_as_needed() -> None:
|
2023-10-30 16:28:52 +01:00
|
|
|
customers = Customer.objects.all().exclude(stripe_customer_id=None).exclude(realm=None)
|
2021-06-11 12:53:45 +02:00
|
|
|
for customer in customers:
|
|
|
|
realm = customer.realm
|
2022-05-31 01:34:34 +02:00
|
|
|
assert realm is not None
|
2021-06-11 12:53:45 +02:00
|
|
|
|
|
|
|
# For larger realms, we generally want to talk to the customer
|
2021-07-16 17:13:49 +02:00
|
|
|
# before downgrading or cancelling invoices; so this logic only applies with 5.
|
2021-06-11 12:53:45 +02:00
|
|
|
if get_latest_seat_count(realm) >= 5:
|
|
|
|
continue
|
|
|
|
|
2021-07-16 17:13:49 +02:00
|
|
|
if get_current_plan_by_customer(customer) is not None:
|
|
|
|
# Only customers with last 2 invoices open should be downgraded.
|
|
|
|
if not customer_has_last_n_invoices_open(customer, 2):
|
|
|
|
continue
|
2021-06-11 12:53:45 +02:00
|
|
|
|
2021-07-16 17:13:49 +02:00
|
|
|
# We've now decided to downgrade this customer and void all invoices, and the below will execute this.
|
2021-06-11 12:53:45 +02:00
|
|
|
|
2021-07-16 17:13:49 +02:00
|
|
|
downgrade_now_without_creating_additional_invoices(realm)
|
|
|
|
void_all_open_invoices(realm)
|
2021-08-03 17:44:55 +02:00
|
|
|
context: Dict[str, Union[str, Realm]] = {
|
2021-07-16 17:13:49 +02:00
|
|
|
"upgrade_url": f"{realm.uri}{reverse('initial_upgrade')}",
|
|
|
|
"realm": realm,
|
|
|
|
}
|
|
|
|
send_email_to_billing_admins_and_realm_owners(
|
|
|
|
"zerver/emails/realm_auto_downgraded",
|
|
|
|
realm,
|
|
|
|
from_name=FromAddress.security_email_from_name(language=realm.default_language),
|
|
|
|
from_address=FromAddress.tokenized_no_reply_address(),
|
|
|
|
language=realm.default_language,
|
|
|
|
context=context,
|
|
|
|
)
|
|
|
|
else:
|
|
|
|
if customer_has_last_n_invoices_open(customer, 1):
|
|
|
|
void_all_open_invoices(realm)
|
2021-06-11 12:53:45 +02:00
|
|
|
|
|
|
|
|
2021-09-21 21:21:03 +02:00
|
|
|
def switch_realm_from_standard_to_plus_plan(realm: Realm) -> None:
|
|
|
|
standard_plan = get_current_plan_by_realm(realm)
|
|
|
|
|
|
|
|
if (
|
|
|
|
not standard_plan
|
|
|
|
or standard_plan.status != CustomerPlan.ACTIVE
|
|
|
|
or standard_plan.tier != CustomerPlan.STANDARD
|
|
|
|
):
|
|
|
|
raise BillingError("Organization does not have an active Standard plan")
|
|
|
|
|
|
|
|
if not standard_plan.customer.stripe_customer_id:
|
|
|
|
raise BillingError("Organization missing Stripe customer.")
|
|
|
|
|
|
|
|
plan_switch_time = timezone_now()
|
|
|
|
|
|
|
|
standard_plan.status = CustomerPlan.SWITCH_NOW_FROM_STANDARD_TO_PLUS
|
|
|
|
standard_plan.next_invoice_date = plan_switch_time
|
|
|
|
standard_plan.save(update_fields=["status", "next_invoice_date"])
|
|
|
|
|
2023-02-08 12:30:28 +01:00
|
|
|
from zerver.actions.realm_settings import do_change_realm_plan_type
|
|
|
|
|
|
|
|
do_change_realm_plan_type(realm, Realm.PLAN_TYPE_PLUS, acting_user=None)
|
|
|
|
|
2021-09-21 21:21:03 +02:00
|
|
|
standard_plan_next_renewal_date = start_of_next_billing_cycle(standard_plan, plan_switch_time)
|
|
|
|
|
|
|
|
standard_plan_last_renewal_ledger = (
|
|
|
|
LicenseLedger.objects.filter(is_renewal=True, plan=standard_plan).order_by("id").last()
|
|
|
|
)
|
2022-05-31 01:34:34 +02:00
|
|
|
assert standard_plan_last_renewal_ledger is not None
|
2022-06-15 05:17:23 +02:00
|
|
|
assert standard_plan.price_per_license is not None
|
2021-09-21 21:21:03 +02:00
|
|
|
standard_plan_last_renewal_amount = (
|
|
|
|
standard_plan_last_renewal_ledger.licenses * standard_plan.price_per_license
|
|
|
|
)
|
|
|
|
standard_plan_last_renewal_date = standard_plan_last_renewal_ledger.event_time
|
|
|
|
unused_proration_fraction = 1 - (plan_switch_time - standard_plan_last_renewal_date) / (
|
|
|
|
standard_plan_next_renewal_date - standard_plan_last_renewal_date
|
|
|
|
)
|
|
|
|
amount_to_credit_back_to_realm = math.ceil(
|
|
|
|
standard_plan_last_renewal_amount * unused_proration_fraction
|
|
|
|
)
|
|
|
|
stripe.Customer.create_balance_transaction(
|
|
|
|
standard_plan.customer.stripe_customer_id,
|
|
|
|
amount=-1 * amount_to_credit_back_to_realm,
|
|
|
|
currency="usd",
|
|
|
|
description="Credit from early termination of Standard plan",
|
|
|
|
)
|
|
|
|
invoice_plan(standard_plan, plan_switch_time)
|
|
|
|
plus_plan = get_current_plan_by_realm(realm)
|
|
|
|
assert plus_plan is not None # for mypy
|
|
|
|
invoice_plan(plus_plan, plan_switch_time)
|