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
|
2020-06-11 00:54:34 +02:00
|
|
|
from datetime import datetime, timedelta
|
|
|
|
from decimal import Decimal
|
|
|
|
from functools import wraps
|
2020-06-24 02:10:50 +02:00
|
|
|
from typing import Callable, Dict, Optional, Tuple, TypeVar, cast
|
2018-01-30 20:49:25 +01:00
|
|
|
|
2020-08-07 01:09:47 +02:00
|
|
|
import orjson
|
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
|
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
|
2018-01-30 20:49:25 +01:00
|
|
|
|
2020-06-11 00:54:34 +02:00
|
|
|
from corporate.models import (
|
|
|
|
Customer,
|
|
|
|
CustomerPlan,
|
|
|
|
LicenseLedger,
|
|
|
|
get_current_plan_by_customer,
|
|
|
|
get_current_plan_by_realm,
|
|
|
|
get_customer_by_realm,
|
|
|
|
)
|
2018-01-30 20:49:25 +01:00
|
|
|
from zerver.lib.logging_util import log_to_file
|
2018-06-28 00:48:51 +02:00
|
|
|
from zerver.lib.timestamp import datetime_to_timestamp, timestamp_to_datetime
|
2020-07-17 12:56:06 +02:00
|
|
|
from zerver.models import Realm, RealmAuditLog, UserProfile, get_system_bot
|
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_PUBLISHABLE_KEY = get_secret("stripe_publishable_key")
|
|
|
|
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
|
|
|
|
2021-02-12 08:20:45 +01:00
|
|
|
CallableT = TypeVar("CallableT", bound=Callable[..., object])
|
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-02-12 08:19:30 +01:00
|
|
|
|
2019-10-07 19:21:29 +02:00
|
|
|
def get_latest_seat_count(realm: Realm) -> 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()
|
|
|
|
)
|
2019-01-30 19:04:32 +01:00
|
|
|
guests = UserProfile.objects.filter(
|
2021-02-12 08:19:30 +01:00
|
|
|
realm=realm, is_active=True, is_bot=False, role=UserProfile.ROLE_GUEST
|
|
|
|
).count()
|
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
|
|
|
|
2020-12-17 16:33:19 +01:00
|
|
|
def validate_licenses(charge_automatically: bool, licenses: Optional[int], seat_count: int) -> None:
|
|
|
|
min_licenses = seat_count
|
|
|
|
max_licenses = None
|
|
|
|
if not charge_automatically:
|
|
|
|
min_licenses = max(seat_count, MIN_INVOICED_LICENSES)
|
|
|
|
max_licenses = MAX_INVOICED_LICENSES
|
|
|
|
|
|
|
|
if licenses is None or licenses < min_licenses:
|
|
|
|
raise BillingError(
|
|
|
|
"not enough licenses", _("You must invoice for at least {} users.").format(min_licenses)
|
|
|
|
)
|
|
|
|
|
|
|
|
if max_licenses is not None and licenses > max_licenses:
|
|
|
|
message = _(
|
|
|
|
"Invoices with more than {} licenses can't be processed from this page. To complete "
|
|
|
|
"the upgrade, please contact {}."
|
|
|
|
).format(max_licenses, settings.ZULIP_ADMINISTRATOR)
|
|
|
|
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:
|
2020-04-23 20:10:15 +02:00
|
|
|
if plan.status == CustomerPlan.FREE_TRIAL:
|
2021-02-12 08:19:30 +01:00
|
|
|
assert plan.next_invoice_date is not None # for mypy
|
2020-04-23 20:10:15 +02:00
|
|
|
return plan.next_invoice_date
|
|
|
|
|
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
|
|
|
|
2018-07-27 17:47:03 +02:00
|
|
|
class BillingError(Exception):
|
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:
|
2018-08-06 06:16:29 +02:00
|
|
|
self.description = description
|
2020-10-17 03:42:50 +02:00
|
|
|
if message is None:
|
|
|
|
message = BillingError.CONTACT_SUPPORT.format(email=settings.ZULIP_ADMINISTRATOR)
|
2018-08-06 06:16:29 +02:00
|
|
|
self.message = message
|
2018-07-27 17:47:03 +02:00
|
|
|
|
2021-02-12 08:19:30 +01:00
|
|
|
|
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
|
|
|
|
2020-12-04 12:56:58 +01:00
|
|
|
class InvalidBillingSchedule(Exception):
|
|
|
|
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
|
|
|
|
2018-01-30 21:03:59 +01:00
|
|
|
def catch_stripe_errors(func: CallableT) -> CallableT:
|
|
|
|
@wraps(func)
|
2020-06-24 02:10:50 +02:00
|
|
|
def wrapped(*args: object, **kwargs: object) -> object:
|
2018-07-26 10:16:20 +02:00
|
|
|
if settings.DEVELOPMENT and not settings.TEST_SUITE: # nocoverage
|
|
|
|
if STRIPE_PUBLISHABLE_KEY is None:
|
2021-02-12 08:19:30 +01:00
|
|
|
raise BillingError(
|
2021-02-12 08:20:45 +01:00
|
|
|
"missing stripe config",
|
2021-02-12 08:19:30 +01:00
|
|
|
"Missing Stripe config. "
|
|
|
|
"See https://zulip.readthedocs.io/en/latest/subsystems/billing.html.",
|
|
|
|
)
|
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
|
|
|
|
2020-06-24 02:10:50 +02:00
|
|
|
return cast(CallableT, 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:
|
2018-11-17 04:41:42 +01:00
|
|
|
return stripe.Customer.retrieve(stripe_customer_id, expand=["default_source"])
|
2018-03-31 04:13:44 +02:00
|
|
|
|
2021-02-12 08:19:30 +01:00
|
|
|
|
2018-03-31 04:13:44 +02:00
|
|
|
@catch_stripe_errors
|
2021-02-12 08:19:30 +01:00
|
|
|
def do_create_stripe_customer(user: UserProfile, stripe_token: Optional[str] = None) -> Customer:
|
2018-03-31 04:13:44 +02:00
|
|
|
realm = user.realm
|
2018-08-23 06:44:00 +02:00
|
|
|
# We could do a better job of handling race conditions here, but if two
|
|
|
|
# people from a realm try to upgrade at exactly the same time, the main
|
|
|
|
# bad thing that will happen is that we will create an extra stripe
|
|
|
|
# customer that we can delete or ignore.
|
2018-03-31 04:13:44 +02:00
|
|
|
stripe_customer = stripe.Customer.create(
|
2020-06-10 06:41:04 +02:00
|
|
|
description=f"{realm.string_id} ({realm.name})",
|
2019-11-19 02:02:57 +01:00
|
|
|
email=user.delivery_email,
|
2021-02-12 08:20:45 +01:00
|
|
|
metadata={"realm_id": realm.id, "realm_str": realm.string_id},
|
2021-02-12 08:19:30 +01:00
|
|
|
source=stripe_token,
|
|
|
|
)
|
2018-06-28 00:48:51 +02:00
|
|
|
event_time = timestamp_to_datetime(stripe_customer.created)
|
2018-08-23 03:40:38 +02:00
|
|
|
with transaction.atomic():
|
|
|
|
RealmAuditLog.objects.create(
|
2021-02-12 08:19:30 +01:00
|
|
|
realm=user.realm,
|
|
|
|
acting_user=user,
|
|
|
|
event_type=RealmAuditLog.STRIPE_CUSTOMER_CREATED,
|
|
|
|
event_time=event_time,
|
|
|
|
)
|
2018-08-23 07:47:05 +02:00
|
|
|
if stripe_token is not None:
|
|
|
|
RealmAuditLog.objects.create(
|
2021-02-12 08:19:30 +01:00
|
|
|
realm=user.realm,
|
|
|
|
acting_user=user,
|
|
|
|
event_type=RealmAuditLog.STRIPE_CARD_CHANGED,
|
|
|
|
event_time=event_time,
|
|
|
|
)
|
|
|
|
customer, created = Customer.objects.update_or_create(
|
2021-02-12 08:20:45 +01:00
|
|
|
realm=realm, defaults={"stripe_customer_id": stripe_customer.id}
|
2021-02-12 08:19:30 +01:00
|
|
|
)
|
2018-08-22 07:49:48 +02:00
|
|
|
user.is_billing_admin = True
|
|
|
|
user.save(update_fields=["is_billing_admin"])
|
2018-12-15 09:33:25 +01:00
|
|
|
return customer
|
2018-03-31 04:13:44 +02:00
|
|
|
|
2021-02-12 08:19:30 +01:00
|
|
|
|
2018-08-14 03:33:31 +02:00
|
|
|
@catch_stripe_errors
|
2021-02-12 08:19:30 +01:00
|
|
|
def do_replace_payment_source(
|
|
|
|
user: UserProfile, stripe_token: str, pay_invoices: bool = False
|
|
|
|
) -> stripe.Customer:
|
2020-03-23 13:35:04 +01:00
|
|
|
customer = get_customer_by_realm(user.realm)
|
2021-02-12 08:19:30 +01:00
|
|
|
assert customer is not None # for mypy
|
2020-03-23 13:35:04 +01:00
|
|
|
|
|
|
|
stripe_customer = stripe_get_customer(customer.stripe_customer_id)
|
2018-08-14 03:33:31 +02:00
|
|
|
stripe_customer.source = stripe_token
|
|
|
|
# Deletes existing card: https://stripe.com/docs/api#update_customer-source
|
2018-10-18 19:56:17 +02:00
|
|
|
updated_stripe_customer = stripe.Customer.save(stripe_customer)
|
2018-08-14 03:33:31 +02:00
|
|
|
RealmAuditLog.objects.create(
|
2021-02-12 08:19:30 +01:00
|
|
|
realm=user.realm,
|
|
|
|
acting_user=user,
|
|
|
|
event_type=RealmAuditLog.STRIPE_CARD_CHANGED,
|
|
|
|
event_time=timezone_now(),
|
|
|
|
)
|
2019-04-04 10:02:49 +02:00
|
|
|
if pay_invoices:
|
|
|
|
for stripe_invoice in stripe.Invoice.list(
|
2021-02-12 08:20:45 +01:00
|
|
|
billing="charge_automatically", customer=stripe_customer.id, status="open"
|
2021-02-12 08:19:30 +01:00
|
|
|
):
|
2019-04-04 10:02:49 +02:00
|
|
|
# The user will get either a receipt or a "failed payment" email, but the in-app
|
2020-03-28 01:25:56 +01:00
|
|
|
# messaging could be clearer here (e.g. it could explicitly tell the user that there
|
2019-04-04 10:02:49 +02:00
|
|
|
# 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)
|
2018-08-14 03:33:31 +02:00
|
|
|
return updated_stripe_customer
|
|
|
|
|
2021-02-12 08:19:30 +01: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-02-12 08:19:30 +01:00
|
|
|
last_renewal = (
|
2021-02-12 08:20:45 +01:00
|
|
|
LicenseLedger.objects.filter(plan=plan, is_renewal=True).order_by("-id").first().event_time
|
2021-02-12 08:19:30 +01:00
|
|
|
)
|
2019-04-08 05:16:35 +02:00
|
|
|
next_billing_cycle = start_of_next_billing_cycle(plan, last_renewal)
|
|
|
|
if next_billing_cycle <= event_time:
|
|
|
|
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,
|
2019-04-08 05:16:35 +02:00
|
|
|
licenses=last_ledger_entry.licenses_at_next_renewal,
|
2021-02-12 08:19:30 +01:00
|
|
|
licenses_at_next_renewal=last_ledger_entry.licenses_at_next_renewal,
|
|
|
|
)
|
2020-04-23 20:10:15 +02:00
|
|
|
if plan.status == CustomerPlan.FREE_TRIAL:
|
|
|
|
plan.invoiced_through = last_ledger_entry
|
2021-02-12 08:19:30 +01:00
|
|
|
assert plan.next_invoice_date is not None
|
2020-04-23 20:10:15 +02:00
|
|
|
plan.billing_cycle_anchor = plan.next_invoice_date.replace(microsecond=0)
|
|
|
|
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,
|
2020-04-23 20:10:15 +02:00
|
|
|
licenses=last_ledger_entry.licenses_at_next_renewal,
|
2021-02-12 08:19:30 +01:00
|
|
|
licenses_at_next_renewal=last_ledger_entry.licenses_at_next_renewal,
|
|
|
|
)
|
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-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,
|
2020-06-15 20:09:24 +02:00
|
|
|
licenses=last_ledger_entry.licenses_at_next_renewal,
|
2021-02-12 08:19:30 +01:00
|
|
|
licenses_at_next_renewal=last_ledger_entry.licenses_at_next_renewal,
|
2020-06-15 20:09:24 +02:00
|
|
|
)
|
|
|
|
|
|
|
|
RealmAuditLog.objects.create(
|
2021-02-12 08:19:30 +01:00
|
|
|
realm=new_plan.customer.realm,
|
|
|
|
event_time=event_time,
|
2020-06-15 20:09:24 +02:00
|
|
|
event_type=RealmAuditLog.CUSTOMER_SWITCHED_FROM_MONTHLY_TO_ANNUAL_PLAN,
|
2021-02-12 08:19:30 +01:00
|
|
|
extra_data=orjson.dumps(
|
|
|
|
{
|
|
|
|
"monthly_plan_id": plan.id,
|
|
|
|
"annual_plan_id": new_plan.id,
|
|
|
|
}
|
|
|
|
).decode(),
|
2020-06-15 20:09:24 +02:00
|
|
|
)
|
|
|
|
return new_plan, new_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
|
|
|
|
2018-12-15 09:33:25 +01:00
|
|
|
# Returns Customer instead of stripe_customer so that we don't make a Stripe
|
|
|
|
# API call if there's nothing to update
|
2021-02-12 08:19:30 +01:00
|
|
|
def update_or_create_stripe_customer(
|
|
|
|
user: UserProfile, stripe_token: Optional[str] = None
|
|
|
|
) -> Customer:
|
2018-12-15 09:33:25 +01:00
|
|
|
realm = user.realm
|
2020-03-23 13:35:04 +01:00
|
|
|
customer = get_customer_by_realm(realm)
|
2019-01-29 06:34:31 +01:00
|
|
|
if customer is None or customer.stripe_customer_id is None:
|
|
|
|
return do_create_stripe_customer(user, stripe_token=stripe_token)
|
2018-12-15 09:33:25 +01:00
|
|
|
if stripe_token is not None:
|
|
|
|
do_replace_payment_source(user, stripe_token)
|
|
|
|
return customer
|
|
|
|
|
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
|
|
|
# TODO use variables to account for Zulip Plus
|
2021-02-12 08:19:30 +01:00
|
|
|
assert tier == CustomerPlan.STANDARD
|
2020-12-04 12:56:58 +01:00
|
|
|
|
|
|
|
price_per_license: Optional[int] = None
|
|
|
|
if billing_schedule == CustomerPlan.ANNUAL:
|
|
|
|
price_per_license = 8000
|
|
|
|
elif billing_schedule == CustomerPlan.MONTHLY:
|
|
|
|
price_per_license = 800
|
|
|
|
else: # nocoverage
|
|
|
|
raise InvalidBillingSchedule(billing_schedule)
|
|
|
|
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-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.
|
|
|
|
# TODO talk about leapseconds?
|
|
|
|
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
|
|
|
|
raise InvalidBillingSchedule(billing_schedule)
|
|
|
|
|
|
|
|
price_per_license = get_price_per_license(CustomerPlan.STANDARD, billing_schedule, discount)
|
|
|
|
|
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:
|
2020-05-14 18:21:23 +02:00
|
|
|
period_end = billing_cycle_anchor + timedelta(days=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-08-07 01:09:47 +02:00
|
|
|
def decimal_to_float(obj: object) -> object:
|
|
|
|
if isinstance(obj, Decimal):
|
|
|
|
return float(obj)
|
|
|
|
raise TypeError # nocoverage
|
|
|
|
|
2021-02-12 08:19:30 +01:00
|
|
|
|
2018-12-15 09:33:25 +01:00
|
|
|
# Only used for cloud signups
|
2018-03-31 04:13:44 +02:00
|
|
|
@catch_stripe_errors
|
2021-02-12 08:19:30 +01:00
|
|
|
def process_initial_upgrade(
|
|
|
|
user: UserProfile,
|
|
|
|
licenses: int,
|
|
|
|
automanage_licenses: bool,
|
|
|
|
billing_schedule: int,
|
|
|
|
stripe_token: Optional[str],
|
|
|
|
) -> None:
|
2018-12-15 09:33:25 +01:00
|
|
|
realm = user.realm
|
|
|
|
customer = update_or_create_stripe_customer(user, stripe_token=stripe_token)
|
2020-04-23 20:10:15 +02:00
|
|
|
charge_automatically = stripe_token is not None
|
2020-05-14 18:21:23 +02:00
|
|
|
free_trial = settings.FREE_TRIAL_DAYS not in (None, 0)
|
2020-04-23 20:10:15 +02:00
|
|
|
|
2020-03-24 14:14:03 +01:00
|
|
|
if get_current_plan_by_customer(customer) is not None:
|
2018-11-28 00:20:58 +01: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.
|
2018-12-15 09:33:25 +01:00
|
|
|
billing_logger.warning(
|
2021-02-12 08:19:30 +01:00
|
|
|
"Customer %s trying to upgrade, but has an active subscription",
|
|
|
|
customer,
|
|
|
|
)
|
|
|
|
raise BillingError(
|
2021-02-12 08:20:45 +01:00
|
|
|
"subscribing with existing subscription", str(BillingError.TRY_RELOADING)
|
2020-05-02 20:57:12 +02:00
|
|
|
)
|
2018-12-15 09:33:25 +01:00
|
|
|
|
2021-02-12 08:19:30 +01:00
|
|
|
(
|
|
|
|
billing_cycle_anchor,
|
|
|
|
next_invoice_date,
|
|
|
|
period_end,
|
|
|
|
price_per_license,
|
|
|
|
) = compute_plan_parameters(
|
|
|
|
automanage_licenses, billing_schedule, customer.default_discount, free_trial
|
|
|
|
)
|
2018-12-15 09:33:25 +01:00
|
|
|
# The main design constraint in this function is that if you upgrade with a credit card, and the
|
|
|
|
# charge fails, everything should be rolled back as if nothing had happened. This is because we
|
|
|
|
# expect frequent card failures on initial signup.
|
|
|
|
# Hence, if we're going to charge a card, do it at the beginning, even if we later may have to
|
|
|
|
# adjust the number of licenses.
|
|
|
|
if charge_automatically:
|
2020-04-23 20:10:15 +02:00
|
|
|
if not free_trial:
|
|
|
|
stripe_charge = stripe.Charge.create(
|
|
|
|
amount=price_per_license * licenses,
|
2021-02-12 08:20:45 +01:00
|
|
|
currency="usd",
|
2020-04-23 20:10:15 +02:00
|
|
|
customer=customer.stripe_customer_id,
|
2020-06-09 00:25:09 +02:00
|
|
|
description=f"Upgrade to Zulip Standard, ${price_per_license/100} x {licenses}",
|
2020-04-23 20:10:15 +02:00
|
|
|
receipt_email=user.delivery_email,
|
2021-02-12 08:20:45 +01:00
|
|
|
statement_descriptor="Zulip Standard",
|
2021-02-12 08:19:30 +01:00
|
|
|
)
|
2020-04-23 20:10:15 +02:00
|
|
|
# Not setting a period start and end, but maybe we should? Unclear what will make things
|
|
|
|
# most similar to the renewal case from an accounting perspective.
|
2020-06-23 00:31:30 +02:00
|
|
|
assert isinstance(stripe_charge.source, stripe.Card)
|
|
|
|
description = f"Payment (Card ending in {stripe_charge.source.last4})"
|
2020-04-23 20:10:15 +02:00
|
|
|
stripe.InvoiceItem.create(
|
|
|
|
amount=price_per_license * licenses * -1,
|
2021-02-12 08:20:45 +01:00
|
|
|
currency="usd",
|
2020-04-23 20:10:15 +02:00
|
|
|
customer=customer.stripe_customer_id,
|
|
|
|
description=description,
|
2021-02-12 08:19:30 +01:00
|
|
|
discountable=False,
|
|
|
|
)
|
2018-12-15 09:33:25 +01:00
|
|
|
|
|
|
|
# TODO: The correctness of this relies on user creation, deactivation, etc being
|
|
|
|
# in a transaction.atomic() with the relevant RealmAuditLog entries
|
|
|
|
with transaction.atomic():
|
|
|
|
# billed_licenses can greater than licenses if users are added between the start of
|
|
|
|
# this function (process_initial_upgrade) and now
|
2019-10-07 19:21:29 +02:00
|
|
|
billed_licenses = max(get_latest_seat_count(realm), licenses)
|
2018-12-15 09:33:25 +01:00
|
|
|
plan_params = {
|
2021-02-12 08:20:45 +01:00
|
|
|
"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": CustomerPlan.STANDARD,
|
2021-02-12 08:19:30 +01:00
|
|
|
}
|
2020-04-23 20:10:15 +02:00
|
|
|
if free_trial:
|
2021-02-12 08:20:45 +01:00
|
|
|
plan_params["status"] = CustomerPlan.FREE_TRIAL
|
2018-12-28 07:20:30 +01:00
|
|
|
plan = CustomerPlan.objects.create(
|
2021-02-12 08:19:30 +01:00
|
|
|
customer=customer, next_invoice_date=next_invoice_date, **plan_params
|
|
|
|
)
|
2019-01-28 14:18:21 +01:00
|
|
|
ledger_entry = LicenseLedger.objects.create(
|
2018-12-28 07:20:30 +01:00
|
|
|
plan=plan,
|
|
|
|
is_renewal=True,
|
|
|
|
event_time=billing_cycle_anchor,
|
|
|
|
licenses=billed_licenses,
|
2021-02-12 08:19:30 +01:00
|
|
|
licenses_at_next_renewal=billed_licenses,
|
|
|
|
)
|
2019-01-28 14:18:21 +01:00
|
|
|
plan.invoiced_through = ledger_entry
|
2021-02-12 08:20:45 +01:00
|
|
|
plan.save(update_fields=["invoiced_through"])
|
2018-12-15 09:33:25 +01:00
|
|
|
RealmAuditLog.objects.create(
|
2021-02-12 08:19:30 +01:00
|
|
|
realm=realm,
|
|
|
|
acting_user=user,
|
|
|
|
event_time=billing_cycle_anchor,
|
2018-12-15 09:33:25 +01:00
|
|
|
event_type=RealmAuditLog.CUSTOMER_PLAN_CREATED,
|
2021-02-12 08:19:30 +01:00
|
|
|
extra_data=orjson.dumps(plan_params, default=decimal_to_float).decode(),
|
|
|
|
)
|
2018-12-15 09:33:25 +01:00
|
|
|
|
2020-04-23 20:10:15 +02:00
|
|
|
if not free_trial:
|
|
|
|
stripe.InvoiceItem.create(
|
2021-02-12 08:20:45 +01:00
|
|
|
currency="usd",
|
2020-04-23 20:10:15 +02:00
|
|
|
customer=customer.stripe_customer_id,
|
2021-02-12 08:20:45 +01:00
|
|
|
description="Zulip Standard",
|
2020-04-23 20:10:15 +02:00
|
|
|
discountable=False,
|
2021-02-12 08:19:30 +01:00
|
|
|
period={
|
2021-02-12 08:20:45 +01:00
|
|
|
"start": datetime_to_timestamp(billing_cycle_anchor),
|
|
|
|
"end": datetime_to_timestamp(period_end),
|
2021-02-12 08:19:30 +01:00
|
|
|
},
|
2020-04-23 20:10:15 +02:00
|
|
|
quantity=billed_licenses,
|
2021-02-12 08:19:30 +01:00
|
|
|
unit_amount=price_per_license,
|
|
|
|
)
|
2020-04-23 20:10:15 +02:00
|
|
|
|
|
|
|
if charge_automatically:
|
2021-02-12 08:20:45 +01:00
|
|
|
billing_method = "charge_automatically"
|
2020-04-23 20:10:15 +02:00
|
|
|
days_until_due = None
|
|
|
|
else:
|
2021-02-12 08:20:45 +01:00
|
|
|
billing_method = "send_invoice"
|
2020-04-23 20:10:15 +02:00
|
|
|
days_until_due = DEFAULT_INVOICE_DAYS_UNTIL_DUE
|
|
|
|
|
|
|
|
stripe_invoice = stripe.Invoice.create(
|
|
|
|
auto_advance=True,
|
|
|
|
billing=billing_method,
|
|
|
|
customer=customer.stripe_customer_id,
|
|
|
|
days_until_due=days_until_due,
|
2021-02-12 08:20:45 +01:00
|
|
|
statement_descriptor="Zulip Standard",
|
2021-02-12 08:19:30 +01:00
|
|
|
)
|
2020-04-23 20:10:15 +02:00
|
|
|
stripe.Invoice.finalize_invoice(stripe_invoice)
|
2018-07-27 15:37:04 +02:00
|
|
|
|
2019-01-26 02:36:37 +01:00
|
|
|
from zerver.lib.actions import do_change_plan_type
|
2021-02-12 08:19:30 +01:00
|
|
|
|
2020-12-04 10:54:15 +01:00
|
|
|
do_change_plan_type(realm, Realm.STANDARD, acting_user=user)
|
2018-07-03 21:49:55 +02:00
|
|
|
|
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
|
|
|
|
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.")
|
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
|
|
|
}
|
2019-01-28 22:57:29 +01:00
|
|
|
description = "Zulip Standard - renewal"
|
2020-06-15 20:09:24 +02:00
|
|
|
elif licenses_base is not None and ledger_entry.licenses != licenses_base:
|
2021-02-12 08:19:30 +01:00
|
|
|
assert plan.price_per_license
|
|
|
|
last_renewal = (
|
|
|
|
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()
|
|
|
|
.event_time
|
|
|
|
)
|
2019-04-10 09:14:20 +02:00
|
|
|
period_end = start_of_next_billing_cycle(plan, ledger_entry.event_time)
|
2021-02-12 08:19:30 +01:00
|
|
|
proration_fraction = (period_end - ledger_entry.event_time) / (
|
|
|
|
period_end - last_renewal
|
|
|
|
)
|
|
|
|
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-02-12 08:20:45 +01:00
|
|
|
ledger_entry.event_time.strftime("%b %-d, %Y"), period_end.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-02-12 08:19:30 +01:00
|
|
|
start_of_next_billing_cycle(plan, ledger_entry.event_time)
|
|
|
|
),
|
|
|
|
},
|
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-02-12 08:20:45 +01:00
|
|
|
billing_method = "charge_automatically"
|
2019-01-28 22:57:29 +01:00
|
|
|
days_until_due = None
|
|
|
|
else:
|
2021-02-12 08:20:45 +01:00
|
|
|
billing_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,
|
|
|
|
billing=billing_method,
|
|
|
|
customer=plan.customer.stripe_customer_id,
|
|
|
|
days_until_due=days_until_due,
|
2021-02-12 08:20:45 +01:00
|
|
|
statement_descriptor="Zulip Standard",
|
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
|
|
|
|
|
|
|
def invoice_plans_as_needed(event_time: datetime = timezone_now()) -> None:
|
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-12-04 11:08:10 +01:00
|
|
|
def attach_discount_to_realm(
|
|
|
|
realm: Realm, discount: Decimal, *, acting_user: Optional[UserProfile]
|
|
|
|
) -> None:
|
|
|
|
customer = get_customer_by_realm(realm)
|
|
|
|
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.objects.create(realm=realm, default_discount=discount)
|
2020-12-04 15:14:59 +01:00
|
|
|
plan = get_current_plan_by_realm(realm)
|
|
|
|
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"])
|
2020-12-04 11:08:10 +01:00
|
|
|
RealmAuditLog.objects.create(
|
|
|
|
realm=realm,
|
|
|
|
acting_user=acting_user,
|
|
|
|
event_type=RealmAuditLog.REALM_DISCOUNT_CHANGED,
|
|
|
|
event_time=timezone_now(),
|
|
|
|
extra_data={"old_discount": old_discount, "new_discount": discount},
|
|
|
|
)
|
2018-08-23 07:45:19 +02:00
|
|
|
|
2021-02-12 08:19:30 +01:00
|
|
|
|
2020-12-04 12:14:51 +01:00
|
|
|
def update_sponsorship_status(
|
|
|
|
realm: Realm, sponsorship_pending: bool, *, acting_user: Optional[UserProfile]
|
|
|
|
) -> None:
|
2020-06-09 12:24:32 +02:00
|
|
|
customer, _ = Customer.objects.get_or_create(realm=realm)
|
|
|
|
customer.sponsorship_pending = sponsorship_pending
|
|
|
|
customer.save(update_fields=["sponsorship_pending"])
|
2020-12-04 12:14:51 +01:00
|
|
|
RealmAuditLog.objects.create(
|
|
|
|
realm=realm,
|
|
|
|
acting_user=acting_user,
|
|
|
|
event_type=RealmAuditLog.REALM_SPONSORSHIP_PENDING_STATUS_CHANGED,
|
|
|
|
event_time=timezone_now(),
|
|
|
|
extra_data={
|
|
|
|
"sponsorship_pending": sponsorship_pending,
|
|
|
|
},
|
|
|
|
)
|
2020-06-09 12:24:32 +02:00
|
|
|
|
2021-02-12 08:19:30 +01:00
|
|
|
|
2020-12-04 11:16:33 +01:00
|
|
|
def approve_sponsorship(realm: Realm, *, acting_user: Optional[UserProfile]) -> None:
|
2020-07-17 12:56:06 +02:00
|
|
|
from zerver.lib.actions import do_change_plan_type, internal_send_private_message
|
2021-02-12 08:19:30 +01:00
|
|
|
|
2020-12-04 10:54:15 +01:00
|
|
|
do_change_plan_type(realm, Realm.STANDARD_FREE, acting_user=acting_user)
|
2020-07-17 12:56:06 +02:00
|
|
|
customer = get_customer_by_realm(realm)
|
|
|
|
if customer is not None and customer.sponsorship_pending:
|
|
|
|
customer.sponsorship_pending = False
|
|
|
|
customer.save(update_fields=["sponsorship_pending"])
|
2020-12-04 11:16:33 +01:00
|
|
|
RealmAuditLog.objects.create(
|
|
|
|
realm=realm,
|
|
|
|
acting_user=acting_user,
|
|
|
|
event_type=RealmAuditLog.REALM_SPONSORSHIP_APPROVED,
|
|
|
|
event_time=timezone_now(),
|
|
|
|
)
|
2020-07-17 12:56:06 +02:00
|
|
|
notification_bot = get_system_bot(settings.NOTIFICATION_BOT)
|
|
|
|
for billing_admin in realm.get_human_billing_admin_users():
|
|
|
|
with override_language(billing_admin.default_language):
|
|
|
|
# Using variable to make life easier for translators if these details change.
|
|
|
|
plan_name = "Zulip Cloud Standard"
|
|
|
|
emoji = ":tada:"
|
|
|
|
message = _(
|
|
|
|
f"Your organization's request for sponsored hosting has been approved! {emoji}.\n"
|
2021-02-12 08:19:30 +01:00
|
|
|
f"You have been upgraded to {plan_name}, free of charge."
|
|
|
|
)
|
2021-02-18 19:58:04 +01:00
|
|
|
internal_send_private_message(notification_bot, billing_admin, message)
|
2021-02-12 08:19:30 +01:00
|
|
|
|
2020-07-17 12:56:06 +02:00
|
|
|
|
2019-03-06 13:01:56 +01:00
|
|
|
def get_discount_for_realm(realm: Realm) -> Optional[Decimal]:
|
2020-03-23 13:35:04 +01:00
|
|
|
customer = get_customer_by_realm(realm)
|
2019-03-06 13:01:56 +01:00
|
|
|
if customer is not None:
|
|
|
|
return customer.default_discount
|
|
|
|
return None
|
|
|
|
|
2021-02-12 08:19:30 +01: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:
|
|
|
|
from zerver.lib.actions import do_change_plan_type
|
2021-02-12 08:19:30 +01:00
|
|
|
|
2020-12-04 10:54:15 +01:00
|
|
|
do_change_plan_type(plan.customer.realm, Realm.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
|
|
|
|
2018-12-15 09:33:25 +01:00
|
|
|
def estimate_annual_recurring_revenue_by_realm() -> Dict[str, int]: # nocoverage
|
|
|
|
annual_revenue = {}
|
2021-02-12 08:19:30 +01:00
|
|
|
for plan in CustomerPlan.objects.filter(status=CustomerPlan.ACTIVE).select_related(
|
2021-02-12 08:20:45 +01:00
|
|
|
"customer__realm"
|
2021-02-12 08:19:30 +01:00
|
|
|
):
|
2018-12-28 07:20:30 +01:00
|
|
|
# TODO: figure out what to do for plans that don't automatically
|
|
|
|
# renew, but which probably will renew
|
2019-04-10 23:08:47 +02:00
|
|
|
renewal_cents = renewal_amount(plan, timezone_now())
|
2018-12-15 09:33:25 +01:00
|
|
|
if plan.billing_schedule == CustomerPlan.MONTHLY:
|
|
|
|
renewal_cents *= 12
|
|
|
|
# TODO: Decimal stuff
|
|
|
|
annual_revenue[plan.customer.realm.string_id] = int(renewal_cents / 100)
|
|
|
|
return annual_revenue
|
2020-03-20 14:58:38 +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
|
|
|
|
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
|
|
|
|
invoices = stripe.Invoice.list(customer=customer.stripe_customer_id)
|
|
|
|
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
|
|
|
|
2020-12-04 11:29:02 +01:00
|
|
|
def update_billing_method_of_current_plan(
|
|
|
|
realm: Realm, charge_automatically: bool, *, acting_user: Optional[UserProfile]
|
|
|
|
) -> None:
|
2020-08-18 13:48:11 +02:00
|
|
|
plan = get_current_plan_by_realm(realm)
|
|
|
|
if plan is not None:
|
|
|
|
plan.charge_automatically = charge_automatically
|
|
|
|
plan.save(update_fields=["charge_automatically"])
|
2020-12-04 11:29:02 +01:00
|
|
|
RealmAuditLog.objects.create(
|
|
|
|
realm=realm,
|
|
|
|
acting_user=acting_user,
|
|
|
|
event_type=RealmAuditLog.REALM_BILLING_METHOD_CHANGED,
|
|
|
|
event_time=timezone_now(),
|
|
|
|
extra_data={
|
|
|
|
"charge_automatically": charge_automatically,
|
|
|
|
},
|
|
|
|
)
|