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-11-14 21:48:14 +01:00
|
|
|
from typing import Any, Callable, Dict, Generator, Optional, Tuple, TypedDict, TypeVar, Union
|
2023-12-06 15:00:23 +01:00
|
|
|
from urllib.parse import urlencode, urljoin
|
2018-01-30 20:49:25 +01:00
|
|
|
|
2020-06-11 00:54:34 +02:00
|
|
|
import stripe
|
2023-11-30 01:48:46 +01:00
|
|
|
from django import forms
|
2018-01-30 20:49:25 +01:00
|
|
|
from django.conf import settings
|
2023-11-14 11:59:48 +01:00
|
|
|
from django.core import signing
|
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,
|
2023-12-08 08:25:05 +01:00
|
|
|
SponsoredPlanTypes,
|
2023-11-30 01:48:46 +01:00
|
|
|
ZulipSponsorshipRequest,
|
2020-06-11 00:54:34 +02:00
|
|
|
get_current_plan_by_customer,
|
|
|
|
get_current_plan_by_realm,
|
|
|
|
get_customer_by_realm,
|
2023-11-09 20:40:42 +01:00
|
|
|
get_customer_by_remote_realm,
|
|
|
|
get_customer_by_remote_server,
|
2020-06-11 00:54:34 +02:00
|
|
|
)
|
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
|
2023-11-30 01:48:46 +01:00
|
|
|
from zerver.lib.send_email import (
|
|
|
|
FromAddress,
|
|
|
|
send_email,
|
|
|
|
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
|
2023-12-06 15:00:23 +01:00
|
|
|
from zerver.lib.url_encoding import append_url_query_string
|
2021-07-25 16:31:12 +02:00
|
|
|
from zerver.lib.utils import assert_is_not_none
|
2023-11-30 01:48:46 +01:00
|
|
|
from zerver.models import (
|
|
|
|
Realm,
|
|
|
|
RealmAuditLog,
|
|
|
|
UserProfile,
|
|
|
|
get_org_type_display_name,
|
2023-12-06 15:00:23 +01:00
|
|
|
get_realm,
|
2023-11-30 01:48:46 +01:00
|
|
|
get_system_bot,
|
|
|
|
)
|
2023-11-09 20:40:42 +01:00
|
|
|
from zilencer.models import (
|
|
|
|
RemoteRealm,
|
|
|
|
RemoteRealmAuditLog,
|
2023-12-07 17:01:29 +01:00
|
|
|
RemoteRealmBillingUser,
|
2023-11-09 20:40:42 +01:00
|
|
|
RemoteZulipServer,
|
|
|
|
RemoteZulipServerAuditLog,
|
2023-12-06 14:26:07 +01:00
|
|
|
get_remote_realm_guest_and_non_guest_count,
|
2023-12-07 01:39:05 +01:00
|
|
|
get_remote_server_guest_and_non_guest_count,
|
2023-11-09 20:40:42 +01:00
|
|
|
)
|
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
|
|
|
|
|
2023-11-14 11:59:48 +01:00
|
|
|
VALID_BILLING_MODALITY_VALUES = ["send_invoice", "charge_automatically"]
|
|
|
|
VALID_BILLING_SCHEDULE_VALUES = ["annual", "monthly"]
|
|
|
|
VALID_LICENSE_MANAGEMENT_VALUES = ["automatic", "manual"]
|
|
|
|
|
2023-11-16 16:14:43 +01:00
|
|
|
CARD_CAPITALIZATION = {
|
|
|
|
"amex": "American Express",
|
|
|
|
"diners": "Diners Club",
|
|
|
|
"discover": "Discover",
|
|
|
|
"jcb": "JCB",
|
|
|
|
"mastercard": "Mastercard",
|
|
|
|
"unionpay": "UnionPay",
|
|
|
|
"visa": "Visa",
|
|
|
|
}
|
|
|
|
|
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
|
|
|
|
2023-11-14 03:20:29 +01:00
|
|
|
# This function imitates the behavior of the format_money in billing/helpers.ts
|
|
|
|
def format_money(cents: float) -> str:
|
|
|
|
# allow for small floating point errors
|
|
|
|
cents = math.ceil(cents - 0.001)
|
|
|
|
if cents % 100 == 0:
|
|
|
|
precision = 0
|
|
|
|
else:
|
|
|
|
precision = 2
|
|
|
|
|
|
|
|
dollars = cents / 100
|
|
|
|
# Format the number as a string with the correct number of decimal places
|
|
|
|
return f"{dollars:.{precision}f}"
|
|
|
|
|
|
|
|
|
2023-11-23 18:05:06 +01:00
|
|
|
def format_discount_percentage(discount: Optional[Decimal]) -> Optional[str]:
|
2023-11-24 07:34:24 +01:00
|
|
|
if type(discount) is not Decimal or discount == Decimal(0):
|
2023-11-23 18:05:06 +01:00
|
|
|
return None
|
|
|
|
|
|
|
|
# Even though it looks like /activity/support only finds integers valid,
|
|
|
|
# this will look good for any custom discounts that we apply.
|
|
|
|
if discount * 100 % 100 == 0:
|
|
|
|
precision = 0
|
|
|
|
else:
|
|
|
|
precision = 2 # nocoverage
|
|
|
|
return f"{discount:.{precision}f}"
|
|
|
|
|
|
|
|
|
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-11-14 11:59:48 +01:00
|
|
|
def unsign_seat_count(signed_seat_count: str, salt: str) -> int:
|
|
|
|
try:
|
|
|
|
return int(unsign_string(signed_seat_count, salt))
|
|
|
|
except signing.BadSignature:
|
|
|
|
raise BillingError("tampered seat count")
|
|
|
|
|
|
|
|
|
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",
|
2023-11-12 04:13:36 +01:00
|
|
|
_(
|
|
|
|
"You must purchase licenses for all active users in your organization (minimum {min_licenses})."
|
|
|
|
).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)
|
|
|
|
|
|
|
|
|
2023-11-14 11:59:48 +01:00
|
|
|
def check_upgrade_parameters(
|
|
|
|
billing_modality: str,
|
|
|
|
schedule: str,
|
|
|
|
license_management: Optional[str],
|
|
|
|
licenses: Optional[int],
|
|
|
|
seat_count: int,
|
|
|
|
exempt_from_license_number_check: bool,
|
|
|
|
) -> None:
|
|
|
|
if billing_modality not in VALID_BILLING_MODALITY_VALUES: # nocoverage
|
|
|
|
raise BillingError("unknown billing_modality", "")
|
|
|
|
if schedule not in VALID_BILLING_SCHEDULE_VALUES: # nocoverage
|
|
|
|
raise BillingError("unknown schedule")
|
|
|
|
if license_management not in VALID_LICENSE_MANAGEMENT_VALUES: # nocoverage
|
|
|
|
raise BillingError("unknown license_management")
|
|
|
|
validate_licenses(
|
|
|
|
billing_modality == "charge_automatically",
|
|
|
|
licenses,
|
|
|
|
seat_count,
|
|
|
|
exempt_from_license_number_check,
|
|
|
|
)
|
|
|
|
|
|
|
|
|
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 = {
|
2023-11-30 07:55:53 +01:00
|
|
|
CustomerPlan.BILLING_SCHEDULE_ANNUAL: 12,
|
|
|
|
CustomerPlan.BILLING_SCHEDULE_MONTHLY: 1,
|
2018-12-15 09:33:25 +01:00
|
|
|
}[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 = {
|
2023-11-30 07:55:53 +01:00
|
|
|
CustomerPlan.BILLING_SCHEDULE_ANNUAL: 12,
|
|
|
|
CustomerPlan.BILLING_SCHEDULE_MONTHLY: 1,
|
2019-01-28 22:57:29 +01:00
|
|
|
}[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
|
|
|
|
2023-11-16 16:14:43 +01:00
|
|
|
def renewal_amount(
|
|
|
|
plan: CustomerPlan, event_time: datetime, last_ledger_entry: Optional[LicenseLedger] = None
|
|
|
|
) -> 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
|
2023-11-16 16:14:43 +01:00
|
|
|
new_plan = None
|
|
|
|
if last_ledger_entry is None:
|
|
|
|
realm = plan.customer.realm
|
|
|
|
billing_session = RealmBillingSession(user=None, realm=realm)
|
|
|
|
new_plan, last_ledger_entry = billing_session.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
|
|
|
|
2023-11-23 03:48:55 +01:00
|
|
|
def get_amount_to_credit_for_plan_tier_change(
|
|
|
|
current_plan: CustomerPlan, plan_change_date: datetime
|
|
|
|
) -> int:
|
|
|
|
last_renewal_ledger = (
|
|
|
|
LicenseLedger.objects.filter(is_renewal=True, plan=current_plan).order_by("id").last()
|
|
|
|
)
|
|
|
|
assert last_renewal_ledger is not None
|
|
|
|
assert current_plan.price_per_license is not None
|
|
|
|
|
|
|
|
next_renewal_date = start_of_next_billing_cycle(current_plan, plan_change_date)
|
|
|
|
|
|
|
|
last_renewal_amount = last_renewal_ledger.licenses * current_plan.price_per_license
|
|
|
|
last_renewal_date = last_renewal_ledger.event_time
|
|
|
|
|
|
|
|
prorated_fraction = 1 - (plan_change_date - last_renewal_date) / (
|
|
|
|
next_renewal_date - last_renewal_date
|
|
|
|
)
|
|
|
|
amount_to_credit_back = math.ceil(last_renewal_amount * prorated_fraction)
|
|
|
|
|
|
|
|
return amount_to_credit_back
|
|
|
|
|
|
|
|
|
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}"
|
|
|
|
|
|
|
|
|
2023-11-16 16:14:43 +01:00
|
|
|
# Should only be called if the customer is being charged automatically
|
|
|
|
def payment_method_string(stripe_customer: stripe.Customer) -> str:
|
|
|
|
assert stripe_customer.invoice_settings is not None
|
|
|
|
default_payment_method = stripe_customer.invoice_settings.default_payment_method
|
|
|
|
if default_payment_method is None:
|
|
|
|
return _("No payment method on file.")
|
|
|
|
|
|
|
|
assert isinstance(default_payment_method, stripe.PaymentMethod)
|
|
|
|
if default_payment_method.type == "card":
|
|
|
|
assert default_payment_method.card is not None
|
|
|
|
brand_name = default_payment_method.card.brand
|
|
|
|
if brand_name in CARD_CAPITALIZATION:
|
|
|
|
brand_name = CARD_CAPITALIZATION[default_payment_method.card.brand]
|
|
|
|
return _("{brand} ending in {last4}").format(
|
|
|
|
brand=brand_name,
|
|
|
|
last4=default_payment_method.card.last4,
|
|
|
|
)
|
|
|
|
# There might be one-off stuff we do for a particular customer that
|
|
|
|
# would land them here. E.g. by default we don't support ACH for
|
|
|
|
# automatic payments, but in theory we could add it for a customer via
|
|
|
|
# the Stripe dashboard.
|
|
|
|
return _("Unknown payment method. Please contact {email}.").format(
|
|
|
|
email=settings.ZULIP_ADMINISTRATOR,
|
|
|
|
) # nocoverage
|
|
|
|
|
|
|
|
|
2023-12-06 15:00:23 +01:00
|
|
|
def build_support_url(support_view: str, query_text: str) -> str:
|
|
|
|
support_realm_url = get_realm(settings.STAFF_SUBDOMAIN).uri
|
|
|
|
support_url = urljoin(support_realm_url, reverse(support_view))
|
|
|
|
query = urlencode({"q": query_text})
|
|
|
|
support_url = append_url_query_string(support_url, query)
|
|
|
|
return support_url
|
|
|
|
|
|
|
|
|
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.",
|
|
|
|
)
|
|
|
|
|
|
|
|
|
2023-12-05 07:39:21 +01:00
|
|
|
class InvalidPlanUpgradeError(BillingError): # nocoverage
|
|
|
|
def __init__(self, message: str) -> None:
|
|
|
|
super().__init__(
|
|
|
|
"invalid plan upgrade",
|
|
|
|
message,
|
|
|
|
)
|
|
|
|
|
|
|
|
|
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:
|
2023-11-14 21:48:14 +01:00
|
|
|
assert isinstance(e.json_body, dict)
|
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-12-01 03:50:13 +01:00
|
|
|
def sponsorship_org_type_key_helper(d: Any) -> int:
|
|
|
|
return d[1]["display_order"]
|
|
|
|
|
|
|
|
|
2023-11-30 14:49:10 +01:00
|
|
|
class PriceArgs(TypedDict, total=False):
|
|
|
|
amount: int
|
|
|
|
unit_amount: int
|
|
|
|
quantity: int
|
|
|
|
|
|
|
|
|
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-14 11:59:48 +01:00
|
|
|
@dataclass
|
|
|
|
class UpgradeRequest:
|
|
|
|
billing_modality: str
|
|
|
|
schedule: str
|
|
|
|
signed_seat_count: str
|
|
|
|
salt: str
|
|
|
|
license_management: Optional[str]
|
|
|
|
licenses: Optional[int]
|
2023-12-02 04:21:50 +01:00
|
|
|
tier: int
|
2023-12-04 14:20:08 +01:00
|
|
|
remote_server_plan_start_date: Optional[str]
|
2023-11-14 11:59:48 +01:00
|
|
|
|
|
|
|
|
2023-11-20 08:40:09 +01:00
|
|
|
@dataclass
|
|
|
|
class InitialUpgradeRequest:
|
|
|
|
manual_license_management: bool
|
2023-11-22 07:36:24 +01:00
|
|
|
tier: int
|
2023-12-06 14:17:13 +01:00
|
|
|
success_message: str = ""
|
2023-11-20 08:40:09 +01:00
|
|
|
|
|
|
|
|
2023-11-22 12:44:02 +01:00
|
|
|
@dataclass
|
|
|
|
class UpdatePlanRequest:
|
|
|
|
status: Optional[int]
|
|
|
|
licenses: Optional[int]
|
|
|
|
licenses_at_next_renewal: Optional[int]
|
2023-11-26 15:41:28 +01:00
|
|
|
schedule: Optional[int]
|
2023-11-22 12:44:02 +01:00
|
|
|
|
|
|
|
|
2023-11-27 11:07:03 +01:00
|
|
|
@dataclass
|
|
|
|
class EventStatusRequest:
|
|
|
|
stripe_session_id: Optional[str]
|
|
|
|
stripe_payment_intent_id: Optional[str]
|
|
|
|
|
|
|
|
|
2023-11-30 21:11:54 +01:00
|
|
|
class SupportType(Enum):
|
|
|
|
approve_sponsorship = 1
|
|
|
|
update_sponsorship_status = 2
|
|
|
|
attach_discount = 3
|
2023-12-01 12:23:31 +01:00
|
|
|
update_billing_modality = 4
|
2023-12-01 19:45:11 +01:00
|
|
|
modify_plan = 5
|
2023-11-30 21:11:54 +01:00
|
|
|
|
|
|
|
|
|
|
|
class SupportViewRequest(TypedDict, total=False):
|
|
|
|
support_type: SupportType
|
|
|
|
sponsorship_status: Optional[bool]
|
|
|
|
discount: Optional[Decimal]
|
2023-12-01 12:23:31 +01:00
|
|
|
billing_modality: Optional[str]
|
2023-12-01 19:45:11 +01:00
|
|
|
plan_modification: Optional[str]
|
|
|
|
new_plan_tier: Optional[int]
|
2023-11-30 21:11:54 +01:00
|
|
|
|
|
|
|
|
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-12-01 13:19:04 +01:00
|
|
|
BILLING_MODALITY_CHANGED = 7
|
2023-11-13 15:05:56 +01:00
|
|
|
CUSTOMER_SWITCHED_FROM_MONTHLY_TO_ANNUAL_PLAN = 8
|
2023-11-20 13:01:25 +01:00
|
|
|
CUSTOMER_SWITCHED_FROM_ANNUAL_TO_MONTHLY_PLAN = 9
|
2023-12-04 23:20:49 +01:00
|
|
|
BILLING_ENTITY_PLAN_TYPE_CHANGED = 10
|
2023-11-02 17:44:02 +01:00
|
|
|
|
|
|
|
|
2023-11-30 17:11:41 +01:00
|
|
|
class PlanTierChangeType(Enum):
|
|
|
|
INVALID = 1
|
|
|
|
UPGRADE = 2
|
|
|
|
DOWNGRADE = 3
|
|
|
|
|
|
|
|
|
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-11-24 07:29:06 +01:00
|
|
|
class UpgradePageParams(TypedDict):
|
|
|
|
annual_price: int
|
|
|
|
demo_organization_scheduled_deletion_date: Optional[datetime]
|
|
|
|
monthly_price: int
|
|
|
|
seat_count: int
|
2023-12-01 04:18:58 +01:00
|
|
|
billing_base_url: str
|
2023-11-24 07:29:06 +01:00
|
|
|
|
|
|
|
|
|
|
|
class UpgradePageSessionTypeSpecificContext(TypedDict):
|
|
|
|
customer_name: str
|
|
|
|
email: str
|
|
|
|
is_demo_organization: bool
|
|
|
|
demo_organization_scheduled_deletion_date: Optional[datetime]
|
|
|
|
is_self_hosting: bool
|
|
|
|
|
|
|
|
|
2023-11-30 01:48:46 +01:00
|
|
|
class SponsorshipApplicantInfo(TypedDict):
|
|
|
|
name: str
|
|
|
|
role: str
|
|
|
|
email: str
|
|
|
|
|
|
|
|
|
|
|
|
class SponsorshipRequestSessionSpecificContext(TypedDict):
|
|
|
|
# We don't store UserProfile for remote realms.
|
|
|
|
realm_user: Optional[UserProfile]
|
|
|
|
user_info: SponsorshipApplicantInfo
|
|
|
|
# TODO: Call this what we end up calling it for /support page.
|
|
|
|
realm_string_id: str
|
|
|
|
|
|
|
|
|
2023-11-24 07:29:06 +01:00
|
|
|
class UpgradePageContext(TypedDict):
|
|
|
|
customer_name: str
|
|
|
|
default_invoice_days_until_due: int
|
|
|
|
discount_percent: Optional[str]
|
|
|
|
email: str
|
|
|
|
exempt_from_license_number_check: bool
|
|
|
|
free_trial_days: Optional[int]
|
2023-11-25 15:18:56 +01:00
|
|
|
free_trial_end_date: Optional[str]
|
2023-11-24 07:29:06 +01:00
|
|
|
is_demo_organization: bool
|
|
|
|
manual_license_management: bool
|
|
|
|
page_params: UpgradePageParams
|
|
|
|
payment_method: Optional[str]
|
|
|
|
plan: str
|
2023-12-04 14:11:35 +01:00
|
|
|
remote_server_legacy_plan_end_date: Optional[str]
|
2023-11-24 07:29:06 +01:00
|
|
|
salt: str
|
|
|
|
seat_count: int
|
|
|
|
signed_seat_count: str
|
2023-12-06 14:17:13 +01:00
|
|
|
success_message: str
|
2023-11-24 07:29:06 +01:00
|
|
|
|
|
|
|
|
2023-11-30 01:48:46 +01:00
|
|
|
class SponsorshipRequestForm(forms.Form):
|
|
|
|
website = forms.URLField(max_length=ZulipSponsorshipRequest.MAX_ORG_URL_LENGTH, required=False)
|
|
|
|
organization_type = forms.IntegerField()
|
|
|
|
description = forms.CharField(widget=forms.Textarea)
|
|
|
|
expected_total_users = forms.CharField(widget=forms.Textarea)
|
|
|
|
paid_users_count = forms.CharField(widget=forms.Textarea)
|
|
|
|
paid_users_description = forms.CharField(widget=forms.Textarea, required=False)
|
2023-12-08 08:25:05 +01:00
|
|
|
requested_plan = forms.ChoiceField(
|
|
|
|
choices=[(plan.value, plan.name) for plan in SponsoredPlanTypes], required=False
|
|
|
|
)
|
2023-11-30 01:48:46 +01:00
|
|
|
|
|
|
|
|
2023-10-26 14:11:43 +02:00
|
|
|
class BillingSession(ABC):
|
2023-11-30 21:11:54 +01:00
|
|
|
@property
|
|
|
|
@abstractmethod
|
|
|
|
def billing_entity_display_name(self) -> str:
|
|
|
|
pass
|
|
|
|
|
2023-11-06 13:52:12 +01:00
|
|
|
@property
|
|
|
|
@abstractmethod
|
|
|
|
def billing_session_url(self) -> str:
|
|
|
|
pass
|
|
|
|
|
2023-12-01 04:18:58 +01:00
|
|
|
@property
|
|
|
|
@abstractmethod
|
|
|
|
def billing_base_url(self) -> str:
|
|
|
|
pass
|
|
|
|
|
2023-11-30 01:48:46 +01:00
|
|
|
@abstractmethod
|
|
|
|
def support_url(self) -> str:
|
|
|
|
pass
|
|
|
|
|
2023-10-26 14:11:43 +02:00
|
|
|
@abstractmethod
|
|
|
|
def get_customer(self) -> Optional[Customer]:
|
|
|
|
pass
|
|
|
|
|
2023-12-02 03:54:24 +01:00
|
|
|
@abstractmethod
|
|
|
|
def get_email(self) -> str:
|
|
|
|
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
|
|
|
|
|
|
|
|
def get_data_for_stripe_payment_intent(
|
2023-12-02 04:21:50 +01:00
|
|
|
self,
|
|
|
|
price_per_license: int,
|
|
|
|
licenses: int,
|
|
|
|
plan_tier: int,
|
|
|
|
email: str,
|
2023-11-06 15:51:54 +01:00
|
|
|
) -> StripePaymentIntentData:
|
2023-12-02 04:21:50 +01:00
|
|
|
if hasattr(self, "support_session") and self.support_session: # nocoverage
|
|
|
|
raise BillingError(
|
|
|
|
"invalid support session",
|
|
|
|
"Support requests do not set any stripe billing information.",
|
|
|
|
)
|
|
|
|
|
|
|
|
amount = price_per_license * licenses
|
|
|
|
|
|
|
|
plan_name = CustomerPlan.name_from_tier(plan_tier)
|
|
|
|
description = f"Upgrade to {plan_name}, ${price_per_license/100} x {licenses}"
|
|
|
|
return StripePaymentIntentData(
|
|
|
|
amount=amount,
|
|
|
|
description=description,
|
|
|
|
plan_name=plan_name,
|
|
|
|
email=email,
|
|
|
|
)
|
2023-11-06 15:51:54 +01:00
|
|
|
|
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-13 15:05:56 +01:00
|
|
|
@abstractmethod
|
|
|
|
def process_downgrade(self, plan: CustomerPlan) -> None:
|
|
|
|
pass
|
|
|
|
|
2023-11-02 15:23:35 +01:00
|
|
|
@abstractmethod
|
2023-11-30 21:11:54 +01:00
|
|
|
def approve_sponsorship(self) -> str:
|
2023-11-02 15:23:35 +01:00
|
|
|
pass
|
|
|
|
|
2023-11-27 13:25:11 +01:00
|
|
|
@abstractmethod
|
|
|
|
def is_sponsored(self) -> bool:
|
|
|
|
pass
|
|
|
|
|
2023-11-30 01:48:46 +01:00
|
|
|
@abstractmethod
|
|
|
|
def get_sponsorship_request_session_specific_context(
|
|
|
|
self,
|
|
|
|
) -> SponsorshipRequestSessionSpecificContext:
|
|
|
|
pass
|
|
|
|
|
|
|
|
@abstractmethod
|
|
|
|
def save_org_type_from_request_sponsorship_session(self, org_type: int) -> None:
|
|
|
|
pass
|
|
|
|
|
2023-11-20 08:40:09 +01:00
|
|
|
@abstractmethod
|
2023-11-24 07:29:06 +01:00
|
|
|
def get_upgrade_page_session_type_specific_context(
|
|
|
|
self,
|
|
|
|
) -> UpgradePageSessionTypeSpecificContext:
|
2023-11-20 08:40:09 +01:00
|
|
|
pass
|
|
|
|
|
2023-11-23 07:29:03 +01:00
|
|
|
@abstractmethod
|
2023-11-30 17:11:41 +01:00
|
|
|
def get_type_of_plan_tier_change(
|
|
|
|
self, current_plan_tier: int, new_plan_tier: int
|
|
|
|
) -> PlanTierChangeType:
|
2023-11-23 07:29:03 +01:00
|
|
|
pass
|
|
|
|
|
2023-11-27 11:07:03 +01:00
|
|
|
@abstractmethod
|
|
|
|
def has_billing_access(self) -> bool:
|
|
|
|
pass
|
|
|
|
|
2023-11-27 13:08:43 +01:00
|
|
|
@abstractmethod
|
|
|
|
def on_paid_plan(self) -> bool:
|
|
|
|
pass
|
|
|
|
|
|
|
|
@abstractmethod
|
|
|
|
def add_sponsorship_info_to_context(self, context: Dict[str, Any]) -> None:
|
|
|
|
pass
|
|
|
|
|
2023-12-01 03:51:05 +01:00
|
|
|
@abstractmethod
|
2023-12-02 09:05:20 +01:00
|
|
|
def get_metadata_for_stripe_update_card(self) -> Dict[str, Any]:
|
2023-12-01 03:51:05 +01:00
|
|
|
pass
|
|
|
|
|
2023-12-04 14:33:06 +01:00
|
|
|
def is_sponsored_or_pending(self, customer: Optional[Customer]) -> bool:
|
|
|
|
if (customer is not None and customer.sponsorship_pending) or self.is_sponsored():
|
|
|
|
return True
|
|
|
|
return False
|
|
|
|
|
2023-12-04 14:11:35 +01:00
|
|
|
def get_remote_server_legacy_plan(
|
|
|
|
self, customer: Optional[Customer], status: int = CustomerPlan.ACTIVE
|
|
|
|
) -> Optional[CustomerPlan]:
|
|
|
|
# status = CustomerPlan.ACTIVE means that the legacy plan is not scheduled for an upgrade.
|
|
|
|
# status = CustomerPlan.SWITCH_PLAN_TIER_AT_PLAN_END means that the legacy plan is scheduled for an upgrade.
|
|
|
|
if customer is None:
|
|
|
|
return None
|
|
|
|
|
|
|
|
return CustomerPlan.objects.filter(
|
|
|
|
customer=customer,
|
|
|
|
tier=CustomerPlan.TIER_SELF_HOSTED_LEGACY,
|
|
|
|
status=status,
|
|
|
|
next_invoice_date=None,
|
|
|
|
).first()
|
|
|
|
|
|
|
|
def get_formatted_remote_server_legacy_plan_end_date(
|
|
|
|
self, customer: Optional[Customer], status: int = CustomerPlan.ACTIVE
|
|
|
|
) -> Optional[str]: # nocoverage
|
|
|
|
plan = self.get_remote_server_legacy_plan(customer, status)
|
|
|
|
if plan is None:
|
|
|
|
return None
|
|
|
|
|
|
|
|
assert plan.end_date is not None
|
|
|
|
return plan.end_date.strftime("%B %d, %Y")
|
|
|
|
|
2023-12-04 14:21:07 +01:00
|
|
|
def get_legacy_remote_server_new_plan_name(
|
|
|
|
self, customer: Customer
|
|
|
|
) -> Optional[str]: # nocoverage
|
|
|
|
legacy_plan = self.get_remote_server_legacy_plan(
|
|
|
|
customer, CustomerPlan.SWITCH_PLAN_TIER_AT_PLAN_END
|
|
|
|
)
|
|
|
|
if legacy_plan is None:
|
|
|
|
return None
|
|
|
|
|
|
|
|
# This also asserts that such a plan should exist.
|
|
|
|
assert legacy_plan.end_date is not None
|
|
|
|
return CustomerPlan.objects.get(
|
|
|
|
customer=customer,
|
|
|
|
billing_cycle_anchor=legacy_plan.end_date,
|
|
|
|
status=CustomerPlan.NEVER_STARTED,
|
|
|
|
).name
|
|
|
|
|
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]
|
2023-11-18 11:29:04 +01:00
|
|
|
) -> str:
|
|
|
|
# NOTE: This charges users immediately.
|
2023-11-06 15:51:54 +01:00
|
|
|
customer = self.get_customer()
|
|
|
|
assert customer is not None and customer.stripe_customer_id is not None
|
2023-12-02 04:21:50 +01:00
|
|
|
payment_intent_data = self.get_data_for_stripe_payment_intent(
|
|
|
|
price_per_license, licenses, metadata["plan_tier"], self.get_email()
|
|
|
|
)
|
2023-11-18 11:29:04 +01:00
|
|
|
# Ensure customers have a default payment method set.
|
|
|
|
stripe_customer = stripe_get_customer(customer.stripe_customer_id)
|
|
|
|
if not stripe_customer_has_credit_card_as_default_payment_method(stripe_customer):
|
|
|
|
raise BillingError(
|
|
|
|
"no payment method",
|
|
|
|
"Please add a credit card before upgrading.",
|
|
|
|
)
|
|
|
|
|
|
|
|
assert stripe_customer.invoice_settings is not None
|
|
|
|
assert stripe_customer.invoice_settings.default_payment_method is not None
|
|
|
|
try:
|
|
|
|
# Try to charge user immediately, and if that fails, we inform the user about the failure.
|
|
|
|
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=True,
|
|
|
|
statement_descriptor=payment_intent_data.plan_name,
|
|
|
|
metadata=metadata,
|
|
|
|
off_session=True,
|
|
|
|
payment_method=stripe_customer.invoice_settings.default_payment_method,
|
|
|
|
)
|
|
|
|
except stripe.error.CardError as e:
|
|
|
|
raise StripeCardError("card error", e.user_message)
|
|
|
|
|
|
|
|
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 stripe_payment_intent.id
|
|
|
|
|
2023-12-02 09:09:43 +01:00
|
|
|
def create_card_update_session_for_upgrade(
|
2023-11-18 11:29:04 +01:00
|
|
|
self,
|
2023-11-21 12:41:38 +01:00
|
|
|
manual_license_management: bool,
|
2023-12-01 03:51:05 +01:00
|
|
|
) -> Dict[str, Any]:
|
2023-12-02 09:05:20 +01:00
|
|
|
metadata = self.get_metadata_for_stripe_update_card()
|
2023-11-18 11:29:04 +01:00
|
|
|
customer = self.update_or_create_stripe_customer()
|
2023-11-21 12:41:38 +01:00
|
|
|
cancel_url = f"{self.billing_session_url}/upgrade/"
|
|
|
|
if manual_license_management:
|
|
|
|
cancel_url = f"{self.billing_session_url}/upgrade/?manual_license_management=true"
|
|
|
|
|
2023-11-18 11:29:04 +01:00
|
|
|
stripe_session = stripe.checkout.Session.create(
|
2023-11-21 12:41:38 +01:00
|
|
|
cancel_url=cancel_url,
|
2023-11-06 15:51:54 +01:00
|
|
|
customer=customer.stripe_customer_id,
|
|
|
|
metadata=metadata,
|
2023-11-18 11:29:04 +01:00
|
|
|
mode="setup",
|
|
|
|
payment_method_types=["card"],
|
|
|
|
success_url=f"{self.billing_session_url}/billing/event_status?stripe_session_id={{CHECKOUT_SESSION_ID}}",
|
2023-11-06 15:51:54 +01:00
|
|
|
)
|
2023-11-18 11:29:04 +01:00
|
|
|
Session.objects.create(
|
|
|
|
stripe_session_id=stripe_session.id,
|
2023-11-06 15:51:54 +01:00
|
|
|
customer=customer,
|
2023-12-01 03:51:05 +01:00
|
|
|
type=Session.CARD_UPDATE_FROM_UPGRADE_PAGE,
|
2023-11-21 12:41:38 +01:00
|
|
|
is_manual_license_management_upgrade_session=manual_license_management,
|
2023-11-06 15:51:54 +01:00
|
|
|
)
|
2023-12-01 03:51:05 +01:00
|
|
|
return {
|
|
|
|
"stripe_session_url": stripe_session.url,
|
|
|
|
"stripe_session_id": stripe_session.id,
|
|
|
|
}
|
2023-11-06 15:51:54 +01:00
|
|
|
|
2023-12-02 09:09:43 +01:00
|
|
|
def create_card_update_session(self) -> Dict[str, Any]:
|
|
|
|
metadata = self.get_metadata_for_stripe_update_card()
|
2023-11-06 13:52:12 +01:00
|
|
|
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}}",
|
|
|
|
)
|
2023-11-18 11:29:04 +01:00
|
|
|
Session.objects.create(
|
2023-11-06 13:52:12 +01:00
|
|
|
stripe_session_id=stripe_session.id,
|
|
|
|
customer=customer,
|
2023-12-02 09:09:43 +01:00
|
|
|
type=Session.CARD_UPDATE_FROM_BILLING_PAGE,
|
2023-11-06 13:52:12 +01:00
|
|
|
)
|
2023-12-02 09:09:43 +01:00
|
|
|
return {
|
|
|
|
"stripe_session_url": stripe_session.url,
|
|
|
|
"stripe_session_id": stripe_session.id,
|
|
|
|
}
|
2023-11-06 13:52:12 +01:00
|
|
|
|
2023-11-30 21:11:54 +01:00
|
|
|
def attach_discount_to_customer(self, new_discount: Decimal) -> str:
|
2023-10-31 19:22:55 +01:00
|
|
|
customer = self.get_customer()
|
2023-11-30 21:11:54 +01:00
|
|
|
old_discount = None
|
2023-10-31 19:22:55 +01:00
|
|
|
if customer is not None:
|
|
|
|
old_discount = customer.default_discount
|
2023-11-30 21:11:54 +01:00
|
|
|
customer.default_discount = new_discount
|
2023-10-31 19:22:55 +01:00
|
|
|
customer.save(update_fields=["default_discount"])
|
|
|
|
else:
|
2023-11-30 21:11:54 +01:00
|
|
|
customer = self.update_or_create_customer(defaults={"default_discount": new_discount})
|
2023-10-31 19:22:55 +01:00
|
|
|
plan = get_current_plan_by_customer(customer)
|
|
|
|
if plan is not None:
|
|
|
|
plan.price_per_license = get_price_per_license(
|
2023-11-30 21:11:54 +01:00
|
|
|
plan.tier, plan.billing_schedule, new_discount
|
2023-10-31 19:22:55 +01:00
|
|
|
)
|
2023-11-30 21:11:54 +01:00
|
|
|
plan.discount = new_discount
|
2023-10-31 19:22:55 +01:00
|
|
|
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(),
|
2023-11-30 21:11:54 +01:00
|
|
|
extra_data={"old_discount": old_discount, "new_discount": new_discount},
|
2023-10-31 19:22:55 +01:00
|
|
|
)
|
2023-11-30 21:11:54 +01:00
|
|
|
if old_discount is None:
|
|
|
|
old_discount = Decimal(0)
|
|
|
|
return f"Discount for {self.billing_entity_display_name} changed to {new_discount}% from {old_discount}%."
|
2023-10-31 19:22:55 +01:00
|
|
|
|
2023-11-30 21:11:54 +01:00
|
|
|
def update_customer_sponsorship_status(self, sponsorship_pending: bool) -> str:
|
2023-11-02 18:17:08 +01:00
|
|
|
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-30 21:11:54 +01:00
|
|
|
if sponsorship_pending:
|
|
|
|
success_message = f"{self.billing_entity_display_name} marked as pending sponsorship."
|
|
|
|
else:
|
|
|
|
success_message = (
|
|
|
|
f"{self.billing_entity_display_name} is no longer pending sponsorship."
|
|
|
|
)
|
|
|
|
return success_message
|
|
|
|
|
2023-12-01 12:23:31 +01:00
|
|
|
def update_billing_modality_of_current_plan(self, charge_automatically: bool) -> str:
|
2023-11-02 18:42:04 +01:00
|
|
|
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(
|
2023-12-01 13:19:04 +01:00
|
|
|
event_type=AuditLogEventType.BILLING_MODALITY_CHANGED,
|
2023-11-02 18:42:04 +01:00
|
|
|
event_time=timezone_now(),
|
|
|
|
extra_data={"charge_automatically": charge_automatically},
|
|
|
|
)
|
2023-12-01 12:23:31 +01:00
|
|
|
if charge_automatically:
|
|
|
|
success_message = f"Billing collection method of {self.billing_entity_display_name} updated to charge automatically."
|
|
|
|
else:
|
|
|
|
success_message = f"Billing collection method of {self.billing_entity_display_name} updated to send invoice."
|
|
|
|
return success_message
|
2023-11-02 18:42:04 +01:00
|
|
|
|
2023-11-18 11:29:04 +01:00
|
|
|
def setup_upgrade_payment_intent_and_charge(
|
2023-11-10 14:03:56 +01:00
|
|
|
self,
|
|
|
|
plan_tier: int,
|
|
|
|
seat_count: int,
|
|
|
|
licenses: int,
|
|
|
|
license_management: str,
|
|
|
|
billing_schedule: int,
|
|
|
|
billing_modality: str,
|
2023-11-18 11:29:04 +01:00
|
|
|
) -> str:
|
2023-11-10 14:03:56 +01:00
|
|
|
customer = self.update_or_create_stripe_customer()
|
|
|
|
assert customer is not None # for mypy
|
|
|
|
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",
|
2023-12-02 04:21:50 +01:00
|
|
|
"plan_tier": plan_tier,
|
2023-11-10 14:03:56 +01:00
|
|
|
}
|
|
|
|
updated_metadata = self.update_data_for_checkout_session_and_payment_intent(
|
|
|
|
general_metadata
|
|
|
|
)
|
2023-11-18 11:29:04 +01:00
|
|
|
stripe_payment_intent_id = self.create_stripe_payment_intent(
|
|
|
|
price_per_license, licenses, updated_metadata
|
2023-11-10 14:03:56 +01:00
|
|
|
)
|
2023-11-18 11:29:04 +01:00
|
|
|
return stripe_payment_intent_id
|
2023-11-10 14:03:56 +01:00
|
|
|
|
2023-12-04 14:20:08 +01:00
|
|
|
def ensure_current_plan_is_upgradable(
|
|
|
|
self, customer: Customer, new_plan_tier: int
|
|
|
|
) -> None: # nocoverage
|
|
|
|
# Upgrade for customers with an existing plan is only supported for remote servers right now.
|
|
|
|
if not hasattr(self, "remote_server"):
|
|
|
|
ensure_customer_does_not_have_active_plan(customer)
|
|
|
|
return
|
|
|
|
|
|
|
|
plan = get_current_plan_by_customer(customer)
|
|
|
|
assert plan is not None
|
|
|
|
type_of_plan_change = self.get_type_of_plan_tier_change(plan.tier, new_plan_tier)
|
|
|
|
if type_of_plan_change != PlanTierChangeType.UPGRADE:
|
2023-12-05 07:39:21 +01:00
|
|
|
raise InvalidPlanUpgradeError(
|
2023-12-04 14:20:08 +01:00
|
|
|
f"Cannot upgrade from {plan.name} to {CustomerPlan.name_from_tier(new_plan_tier)}"
|
|
|
|
)
|
|
|
|
|
2023-11-13 07:55:57 +01:00
|
|
|
@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,
|
2023-12-04 14:20:08 +01:00
|
|
|
remote_server_legacy_plan: Optional[CustomerPlan] = None,
|
|
|
|
should_schedule_upgrade_for_legacy_remote_server: bool = False,
|
2023-11-13 07:55:57 +01:00
|
|
|
) -> None:
|
|
|
|
customer = self.update_or_create_stripe_customer()
|
|
|
|
assert customer.stripe_customer_id is not None # for mypy
|
2023-12-04 14:20:08 +01:00
|
|
|
self.ensure_current_plan_is_upgradable(customer, plan_tier)
|
|
|
|
billing_cycle_anchor = None
|
|
|
|
if should_schedule_upgrade_for_legacy_remote_server: # nocoverage
|
|
|
|
assert remote_server_legacy_plan is not None
|
|
|
|
billing_cycle_anchor = remote_server_legacy_plan.end_date
|
|
|
|
|
2023-11-13 07:55:57 +01:00
|
|
|
(
|
|
|
|
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,
|
2023-12-04 14:20:08 +01:00
|
|
|
billing_cycle_anchor,
|
2023-11-13 07:55:57 +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():
|
|
|
|
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
|
2023-11-18 11:29:04 +01:00
|
|
|
|
|
|
|
if charge_automatically:
|
|
|
|
# Ensure free trial customers not paying via invoice have a default payment method set
|
|
|
|
stripe_customer = stripe_get_customer(customer.stripe_customer_id)
|
|
|
|
if not stripe_customer_has_credit_card_as_default_payment_method(
|
|
|
|
stripe_customer
|
|
|
|
):
|
|
|
|
raise BillingError(
|
|
|
|
"no payment method",
|
|
|
|
_("Please add a credit card before starting your free trial."),
|
|
|
|
)
|
|
|
|
|
2023-12-04 14:20:08 +01:00
|
|
|
event_time = billing_cycle_anchor
|
|
|
|
if should_schedule_upgrade_for_legacy_remote_server: # nocoverage
|
|
|
|
# In this code path, we are currently on a legacy plan
|
|
|
|
# and are scheduling an upgrade to a non-legacy plan
|
|
|
|
# that should occur when the legacy plan expires.
|
|
|
|
#
|
|
|
|
# We will create a new NEVER_STARTED plan for the
|
|
|
|
# customer, scheduled to start when the current one
|
|
|
|
# expires.
|
|
|
|
assert remote_server_legacy_plan is not None
|
|
|
|
if charge_automatically:
|
|
|
|
# Ensure customers not paying via invoice have a default payment method set.
|
|
|
|
stripe_customer = stripe_get_customer(customer.stripe_customer_id)
|
|
|
|
if not stripe_customer_has_credit_card_as_default_payment_method(
|
|
|
|
stripe_customer
|
|
|
|
):
|
|
|
|
raise BillingError(
|
|
|
|
"no payment method",
|
|
|
|
_("Please add a credit card to schedule upgrade."),
|
|
|
|
)
|
|
|
|
|
|
|
|
# Settings status > CustomerPLan.LIVE_STATUS_THRESHOLD makes sure we don't have
|
|
|
|
# to worry about this plan being used for any other purpose.
|
|
|
|
# NOTE: This is the 2nd plan for the customer.
|
|
|
|
plan_params["status"] = CustomerPlan.NEVER_STARTED
|
|
|
|
event_time = timezone_now().replace(microsecond=0)
|
|
|
|
|
|
|
|
# Schedule switching to the new plan at plan end date.
|
|
|
|
assert remote_server_legacy_plan.end_date == billing_cycle_anchor
|
|
|
|
remote_server_legacy_plan.status = CustomerPlan.SWITCH_PLAN_TIER_AT_PLAN_END
|
2023-12-06 07:44:11 +01:00
|
|
|
remote_server_legacy_plan.save(update_fields=["status"])
|
2023-12-04 14:20:08 +01:00
|
|
|
elif remote_server_legacy_plan is not None: # nocoverage
|
|
|
|
remote_server_legacy_plan.status = CustomerPlan.ENDED
|
|
|
|
remote_server_legacy_plan.save(update_fields=["status"])
|
|
|
|
|
2023-11-13 07:55:57 +01:00
|
|
|
plan = CustomerPlan.objects.create(
|
|
|
|
customer=customer, next_invoice_date=next_invoice_date, **plan_params
|
|
|
|
)
|
2023-12-06 07:43:18 +01:00
|
|
|
|
|
|
|
if plan.status < CustomerPlan.LIVE_STATUS_THRESHOLD:
|
|
|
|
# LicenseLedger entries are way for us to charge customer and track their license usage.
|
|
|
|
# So, we should only create these entries for live plans.
|
|
|
|
ledger_entry = LicenseLedger.objects.create(
|
|
|
|
plan=plan,
|
|
|
|
is_renewal=True,
|
|
|
|
event_time=event_time,
|
|
|
|
licenses=billed_licenses,
|
|
|
|
licenses_at_next_renewal=billed_licenses,
|
|
|
|
)
|
|
|
|
plan.invoiced_through = ledger_entry
|
|
|
|
plan.save(update_fields=["invoiced_through"])
|
|
|
|
|
2023-11-13 07:55:57 +01:00
|
|
|
self.write_to_audit_log(
|
|
|
|
event_type=AuditLogEventType.CUSTOMER_PLAN_CREATED,
|
2023-12-04 14:20:08 +01:00
|
|
|
event_time=event_time,
|
2023-11-13 07:55:57 +01:00
|
|
|
extra_data=plan_params,
|
|
|
|
)
|
|
|
|
|
2023-12-04 14:20:08 +01:00
|
|
|
if not (free_trial or should_schedule_upgrade_for_legacy_remote_server):
|
|
|
|
assert plan is not None
|
2023-11-13 07:55:57 +01:00
|
|
|
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)
|
|
|
|
|
2023-12-06 13:35:35 +01:00
|
|
|
if plan.status < CustomerPlan.LIVE_STATUS_THRESHOLD:
|
|
|
|
# Tier and usage limit change will happen when plan becomes live.
|
|
|
|
self.do_change_plan_type(tier=plan_tier)
|
2023-11-13 07:55:57 +01:00
|
|
|
|
2023-11-14 11:59:48 +01:00
|
|
|
def do_upgrade(self, upgrade_request: UpgradeRequest) -> Dict[str, Any]:
|
|
|
|
customer = self.get_customer()
|
|
|
|
if customer is not None:
|
2023-12-04 14:20:08 +01:00
|
|
|
self.ensure_current_plan_is_upgradable(customer, upgrade_request.tier)
|
2023-11-14 11:59:48 +01:00
|
|
|
billing_modality = upgrade_request.billing_modality
|
|
|
|
schedule = upgrade_request.schedule
|
|
|
|
license_management = upgrade_request.license_management
|
|
|
|
licenses = upgrade_request.licenses
|
|
|
|
|
|
|
|
seat_count = unsign_seat_count(upgrade_request.signed_seat_count, upgrade_request.salt)
|
|
|
|
if billing_modality == "charge_automatically" and license_management == "automatic":
|
|
|
|
licenses = seat_count
|
|
|
|
if billing_modality == "send_invoice":
|
|
|
|
schedule = "annual"
|
|
|
|
license_management = "manual"
|
|
|
|
|
|
|
|
exempt_from_license_number_check = (
|
|
|
|
customer is not None and customer.exempt_from_license_number_check
|
|
|
|
)
|
|
|
|
check_upgrade_parameters(
|
|
|
|
billing_modality,
|
|
|
|
schedule,
|
|
|
|
license_management,
|
|
|
|
licenses,
|
|
|
|
seat_count,
|
|
|
|
exempt_from_license_number_check,
|
|
|
|
)
|
|
|
|
assert licenses is not None and license_management is not None
|
|
|
|
automanage_licenses = license_management == "automatic"
|
|
|
|
charge_automatically = billing_modality == "charge_automatically"
|
|
|
|
|
2023-11-30 07:55:53 +01:00
|
|
|
billing_schedule = {
|
|
|
|
"annual": CustomerPlan.BILLING_SCHEDULE_ANNUAL,
|
|
|
|
"monthly": CustomerPlan.BILLING_SCHEDULE_MONTHLY,
|
|
|
|
}[schedule]
|
2023-11-14 11:59:48 +01:00
|
|
|
data: Dict[str, Any] = {}
|
2023-11-18 11:29:04 +01:00
|
|
|
free_trial = is_free_trial_offer_enabled()
|
2023-12-04 14:20:08 +01:00
|
|
|
remote_server_legacy_plan = self.get_remote_server_legacy_plan(customer)
|
|
|
|
should_schedule_upgrade_for_legacy_remote_server = (
|
|
|
|
remote_server_legacy_plan is not None
|
|
|
|
and upgrade_request.remote_server_plan_start_date == "billing_cycle_end_date"
|
|
|
|
)
|
2023-11-18 11:29:04 +01:00
|
|
|
# Directly upgrade free trial orgs or invoice payment orgs to standard plan.
|
2023-12-04 14:20:08 +01:00
|
|
|
if (
|
|
|
|
should_schedule_upgrade_for_legacy_remote_server
|
|
|
|
or free_trial
|
|
|
|
or not charge_automatically
|
|
|
|
):
|
2023-11-18 11:29:04 +01:00
|
|
|
self.process_initial_upgrade(
|
2023-12-02 04:21:50 +01:00
|
|
|
upgrade_request.tier,
|
2023-11-14 11:59:48 +01:00
|
|
|
licenses,
|
2023-11-18 11:29:04 +01:00
|
|
|
automanage_licenses,
|
2023-11-14 11:59:48 +01:00
|
|
|
billing_schedule,
|
2023-11-18 11:29:04 +01:00
|
|
|
charge_automatically,
|
|
|
|
is_free_trial_offer_enabled(),
|
2023-12-04 14:20:08 +01:00
|
|
|
remote_server_legacy_plan,
|
|
|
|
should_schedule_upgrade_for_legacy_remote_server,
|
2023-11-14 11:59:48 +01:00
|
|
|
)
|
2023-11-18 11:29:04 +01:00
|
|
|
data["organization_upgrade_successful"] = True
|
2023-11-14 11:59:48 +01:00
|
|
|
else:
|
2023-11-18 11:29:04 +01:00
|
|
|
stripe_payment_intent_id = self.setup_upgrade_payment_intent_and_charge(
|
2023-12-02 04:21:50 +01:00
|
|
|
upgrade_request.tier,
|
2023-11-18 11:29:04 +01:00
|
|
|
seat_count,
|
2023-11-14 11:59:48 +01:00
|
|
|
licenses,
|
2023-11-18 11:29:04 +01:00
|
|
|
license_management,
|
2023-11-14 11:59:48 +01:00
|
|
|
billing_schedule,
|
2023-11-18 11:29:04 +01:00
|
|
|
billing_modality,
|
2023-11-14 11:59:48 +01:00
|
|
|
)
|
2023-11-18 11:29:04 +01:00
|
|
|
data["stripe_payment_intent_id"] = stripe_payment_intent_id
|
2023-11-14 11:59:48 +01:00
|
|
|
return data
|
|
|
|
|
2023-11-26 15:41:28 +01:00
|
|
|
def do_change_schedule_after_free_trial(self, plan: CustomerPlan, schedule: int) -> None:
|
|
|
|
# Change the billing frequency of the plan after the free trial ends.
|
2023-11-30 07:55:53 +01:00
|
|
|
assert schedule in (
|
|
|
|
CustomerPlan.BILLING_SCHEDULE_MONTHLY,
|
|
|
|
CustomerPlan.BILLING_SCHEDULE_ANNUAL,
|
|
|
|
)
|
2023-11-26 15:41:28 +01:00
|
|
|
last_ledger_entry = LicenseLedger.objects.filter(plan=plan).order_by("-id").first()
|
|
|
|
assert 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
|
|
|
|
assert plan.next_invoice_date is not None
|
|
|
|
next_billing_cycle = plan.next_invoice_date
|
|
|
|
|
|
|
|
if plan.fixed_price is not None: # nocoverage
|
|
|
|
raise BillingError("Customer is already on monthly fixed plan.")
|
|
|
|
|
|
|
|
plan.status = CustomerPlan.ENDED
|
|
|
|
plan.save(update_fields=["status"])
|
|
|
|
|
|
|
|
discount = plan.customer.default_discount or plan.discount
|
|
|
|
_, _, _, price_per_license = compute_plan_parameters(
|
|
|
|
tier=plan.tier,
|
|
|
|
automanage_licenses=plan.automanage_licenses,
|
|
|
|
billing_schedule=schedule,
|
|
|
|
discount=plan.discount,
|
|
|
|
)
|
|
|
|
|
|
|
|
new_plan = CustomerPlan.objects.create(
|
|
|
|
customer=plan.customer,
|
|
|
|
billing_schedule=schedule,
|
|
|
|
automanage_licenses=plan.automanage_licenses,
|
|
|
|
charge_automatically=plan.charge_automatically,
|
|
|
|
price_per_license=price_per_license,
|
|
|
|
discount=discount,
|
|
|
|
billing_cycle_anchor=plan.billing_cycle_anchor,
|
|
|
|
tier=plan.tier,
|
|
|
|
status=CustomerPlan.FREE_TRIAL,
|
|
|
|
next_invoice_date=next_billing_cycle,
|
|
|
|
invoiced_through=None,
|
2023-11-30 08:07:12 +01:00
|
|
|
invoicing_status=CustomerPlan.INVOICING_STATUS_INITIAL_INVOICE_TO_BE_SENT,
|
2023-11-26 15:41:28 +01:00
|
|
|
)
|
|
|
|
|
|
|
|
LicenseLedger.objects.create(
|
|
|
|
plan=new_plan,
|
|
|
|
is_renewal=True,
|
|
|
|
event_time=plan.billing_cycle_anchor,
|
|
|
|
licenses=licenses_at_next_renewal,
|
|
|
|
licenses_at_next_renewal=licenses_at_next_renewal,
|
|
|
|
)
|
|
|
|
|
2023-11-30 07:55:53 +01:00
|
|
|
if schedule == CustomerPlan.BILLING_SCHEDULE_ANNUAL:
|
2023-11-26 15:41:28 +01:00
|
|
|
self.write_to_audit_log(
|
|
|
|
event_type=AuditLogEventType.CUSTOMER_SWITCHED_FROM_MONTHLY_TO_ANNUAL_PLAN,
|
|
|
|
event_time=timezone_now(),
|
|
|
|
extra_data={
|
|
|
|
"monthly_plan_id": plan.id,
|
|
|
|
"annual_plan_id": new_plan.id,
|
|
|
|
},
|
|
|
|
)
|
|
|
|
else:
|
|
|
|
self.write_to_audit_log(
|
|
|
|
event_type=AuditLogEventType.CUSTOMER_SWITCHED_FROM_ANNUAL_TO_MONTHLY_PLAN,
|
|
|
|
event_time=timezone_now(),
|
|
|
|
extra_data={
|
|
|
|
"annual_plan_id": plan.id,
|
|
|
|
"monthly_plan_id": new_plan.id,
|
|
|
|
},
|
|
|
|
)
|
|
|
|
|
2023-11-26 11:33:50 +01:00
|
|
|
def get_next_billing_cycle(self, plan: CustomerPlan) -> datetime:
|
2023-11-13 15:05:56 +01:00
|
|
|
last_ledger_renewal = (
|
|
|
|
LicenseLedger.objects.filter(plan=plan, is_renewal=True).order_by("-id").first()
|
|
|
|
)
|
|
|
|
assert last_ledger_renewal is not None
|
|
|
|
last_renewal = last_ledger_renewal.event_time
|
|
|
|
|
2023-11-25 14:44:47 +01:00
|
|
|
if plan.status in (
|
|
|
|
CustomerPlan.FREE_TRIAL,
|
|
|
|
CustomerPlan.DOWNGRADE_AT_END_OF_FREE_TRIAL,
|
|
|
|
):
|
2023-11-13 15:05:56 +01:00
|
|
|
assert plan.next_invoice_date is not None
|
|
|
|
next_billing_cycle = plan.next_invoice_date
|
2023-12-04 14:20:08 +01:00
|
|
|
elif plan.status == CustomerPlan.SWITCH_PLAN_TIER_AT_PLAN_END: # nocoverage
|
|
|
|
assert plan.end_date is not None
|
|
|
|
next_billing_cycle = plan.end_date
|
2023-11-13 15:05:56 +01:00
|
|
|
else:
|
|
|
|
next_billing_cycle = start_of_next_billing_cycle(plan, last_renewal)
|
2023-11-26 11:33:50 +01:00
|
|
|
|
|
|
|
return next_billing_cycle
|
|
|
|
|
|
|
|
# event_time should roughly be timezone_now(). Not designed to handle
|
|
|
|
# event_times in the past or future
|
|
|
|
@transaction.atomic
|
|
|
|
def make_end_of_cycle_updates_if_needed(
|
|
|
|
self, plan: CustomerPlan, event_time: datetime
|
|
|
|
) -> Tuple[Optional[CustomerPlan], Optional[LicenseLedger]]:
|
|
|
|
last_ledger_entry = LicenseLedger.objects.filter(plan=plan).order_by("-id").first()
|
|
|
|
next_billing_cycle = self.get_next_billing_cycle(plan)
|
2023-11-26 15:41:28 +01:00
|
|
|
event_in_next_billing_cycle = next_billing_cycle <= event_time
|
2023-11-26 11:33:50 +01:00
|
|
|
|
2023-11-26 15:41:28 +01:00
|
|
|
if event_in_next_billing_cycle and last_ledger_entry is not None:
|
2023-11-13 15:05:56 +01:00
|
|
|
licenses_at_next_renewal = last_ledger_entry.licenses_at_next_renewal
|
|
|
|
assert licenses_at_next_renewal is not None
|
|
|
|
if plan.status == CustomerPlan.ACTIVE:
|
|
|
|
return None, LicenseLedger.objects.create(
|
|
|
|
plan=plan,
|
|
|
|
is_renewal=True,
|
|
|
|
event_time=next_billing_cycle,
|
|
|
|
licenses=licenses_at_next_renewal,
|
|
|
|
licenses_at_next_renewal=licenses_at_next_renewal,
|
|
|
|
)
|
|
|
|
if plan.is_free_trial():
|
|
|
|
plan.invoiced_through = last_ledger_entry
|
|
|
|
plan.billing_cycle_anchor = next_billing_cycle.replace(microsecond=0)
|
|
|
|
plan.status = CustomerPlan.ACTIVE
|
|
|
|
plan.save(update_fields=["invoiced_through", "billing_cycle_anchor", "status"])
|
|
|
|
return None, LicenseLedger.objects.create(
|
|
|
|
plan=plan,
|
|
|
|
is_renewal=True,
|
|
|
|
event_time=next_billing_cycle,
|
|
|
|
licenses=licenses_at_next_renewal,
|
|
|
|
licenses_at_next_renewal=licenses_at_next_renewal,
|
|
|
|
)
|
|
|
|
|
2023-12-04 14:20:08 +01:00
|
|
|
if plan.status == CustomerPlan.SWITCH_PLAN_TIER_AT_PLAN_END: # nocoverage
|
|
|
|
# Only plan tier we do this for right now.
|
|
|
|
assert plan.tier == CustomerPlan.TIER_SELF_HOSTED_LEGACY
|
|
|
|
plan.status = CustomerPlan.ENDED
|
|
|
|
plan.save(update_fields=["status"])
|
|
|
|
|
|
|
|
assert plan.end_date is not None
|
|
|
|
new_plan = CustomerPlan.objects.get(
|
|
|
|
customer=plan.customer,
|
|
|
|
billing_cycle_anchor=plan.end_date,
|
|
|
|
status=CustomerPlan.NEVER_STARTED,
|
|
|
|
)
|
|
|
|
new_plan.status = CustomerPlan.ACTIVE
|
|
|
|
new_plan.save(update_fields=["status"])
|
2023-12-06 13:35:35 +01:00
|
|
|
self.do_change_plan_type(tier=new_plan.tier)
|
2023-12-04 14:20:08 +01:00
|
|
|
return None, LicenseLedger.objects.create(
|
|
|
|
plan=new_plan,
|
|
|
|
is_renewal=True,
|
|
|
|
event_time=next_billing_cycle,
|
|
|
|
licenses=licenses_at_next_renewal,
|
|
|
|
licenses_at_next_renewal=licenses_at_next_renewal,
|
|
|
|
)
|
|
|
|
|
2023-11-13 15:05:56 +01: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(
|
|
|
|
tier=plan.tier,
|
|
|
|
automanage_licenses=plan.automanage_licenses,
|
2023-11-30 07:55:53 +01:00
|
|
|
billing_schedule=CustomerPlan.BILLING_SCHEDULE_ANNUAL,
|
2023-11-13 15:05:56 +01:00
|
|
|
discount=plan.discount,
|
|
|
|
)
|
|
|
|
|
|
|
|
new_plan = CustomerPlan.objects.create(
|
|
|
|
customer=plan.customer,
|
2023-11-30 07:55:53 +01:00
|
|
|
billing_schedule=CustomerPlan.BILLING_SCHEDULE_ANNUAL,
|
2023-11-13 15:05:56 +01:00
|
|
|
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,
|
2023-11-30 08:07:12 +01:00
|
|
|
invoicing_status=CustomerPlan.INVOICING_STATUS_INITIAL_INVOICE_TO_BE_SENT,
|
2023-11-13 15:05:56 +01:00
|
|
|
)
|
|
|
|
|
|
|
|
new_plan_ledger_entry = LicenseLedger.objects.create(
|
|
|
|
plan=new_plan,
|
|
|
|
is_renewal=True,
|
|
|
|
event_time=next_billing_cycle,
|
|
|
|
licenses=licenses_at_next_renewal,
|
|
|
|
licenses_at_next_renewal=licenses_at_next_renewal,
|
|
|
|
)
|
|
|
|
|
|
|
|
self.write_to_audit_log(
|
|
|
|
event_type=AuditLogEventType.CUSTOMER_SWITCHED_FROM_MONTHLY_TO_ANNUAL_PLAN,
|
|
|
|
event_time=event_time,
|
|
|
|
extra_data={
|
|
|
|
"monthly_plan_id": plan.id,
|
|
|
|
"annual_plan_id": new_plan.id,
|
|
|
|
},
|
|
|
|
)
|
|
|
|
return new_plan, new_plan_ledger_entry
|
|
|
|
|
2023-11-20 13:01:25 +01:00
|
|
|
if plan.status == CustomerPlan.SWITCH_TO_MONTHLY_AT_END_OF_CYCLE:
|
|
|
|
if plan.fixed_price is not None: # nocoverage
|
|
|
|
raise BillingError("Customer is already on monthly fixed plan.")
|
|
|
|
|
|
|
|
plan.status = CustomerPlan.ENDED
|
|
|
|
plan.save(update_fields=["status"])
|
|
|
|
|
|
|
|
discount = plan.customer.default_discount or plan.discount
|
|
|
|
_, _, _, price_per_license = compute_plan_parameters(
|
|
|
|
tier=plan.tier,
|
|
|
|
automanage_licenses=plan.automanage_licenses,
|
2023-11-30 07:55:53 +01:00
|
|
|
billing_schedule=CustomerPlan.BILLING_SCHEDULE_MONTHLY,
|
2023-11-20 13:01:25 +01:00
|
|
|
discount=plan.discount,
|
|
|
|
)
|
|
|
|
|
|
|
|
new_plan = CustomerPlan.objects.create(
|
|
|
|
customer=plan.customer,
|
2023-11-30 07:55:53 +01:00
|
|
|
billing_schedule=CustomerPlan.BILLING_SCHEDULE_MONTHLY,
|
2023-11-20 13:01:25 +01:00
|
|
|
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,
|
2023-11-30 08:07:12 +01:00
|
|
|
invoicing_status=CustomerPlan.INVOICING_STATUS_INITIAL_INVOICE_TO_BE_SENT,
|
2023-11-20 13:01:25 +01:00
|
|
|
)
|
|
|
|
|
|
|
|
new_plan_ledger_entry = LicenseLedger.objects.create(
|
|
|
|
plan=new_plan,
|
|
|
|
is_renewal=True,
|
|
|
|
event_time=next_billing_cycle,
|
|
|
|
licenses=licenses_at_next_renewal,
|
|
|
|
licenses_at_next_renewal=licenses_at_next_renewal,
|
|
|
|
)
|
|
|
|
|
|
|
|
self.write_to_audit_log(
|
|
|
|
event_type=AuditLogEventType.CUSTOMER_SWITCHED_FROM_ANNUAL_TO_MONTHLY_PLAN,
|
|
|
|
event_time=event_time,
|
|
|
|
extra_data={
|
|
|
|
"annual_plan_id": plan.id,
|
|
|
|
"monthly_plan_id": new_plan.id,
|
|
|
|
},
|
|
|
|
)
|
|
|
|
return new_plan, new_plan_ledger_entry
|
|
|
|
|
2023-11-25 14:44:47 +01:00
|
|
|
if plan.status == CustomerPlan.DOWNGRADE_AT_END_OF_FREE_TRIAL:
|
|
|
|
self.downgrade_now_without_creating_additional_invoices(plan)
|
|
|
|
|
2023-11-13 15:05:56 +01:00
|
|
|
if plan.status == CustomerPlan.DOWNGRADE_AT_END_OF_CYCLE:
|
|
|
|
self.process_downgrade(plan)
|
2023-11-26 15:41:28 +01:00
|
|
|
|
2023-11-13 15:05:56 +01:00
|
|
|
return None, None
|
|
|
|
return None, last_ledger_entry
|
|
|
|
|
2023-12-06 08:21:13 +01:00
|
|
|
def get_next_plan(self, plan: CustomerPlan) -> Optional[CustomerPlan]: # nocoverage
|
|
|
|
if plan.status == CustomerPlan.SWITCH_PLAN_TIER_AT_PLAN_END:
|
|
|
|
assert plan.end_date is not None
|
|
|
|
return CustomerPlan.objects.filter(
|
|
|
|
customer=plan.customer,
|
|
|
|
billing_cycle_anchor=plan.end_date,
|
|
|
|
status=CustomerPlan.NEVER_STARTED,
|
|
|
|
).first()
|
|
|
|
return None
|
|
|
|
|
2023-12-06 07:09:33 +01:00
|
|
|
def get_billing_context_from_plan(
|
|
|
|
self,
|
|
|
|
customer: Customer,
|
|
|
|
plan: CustomerPlan,
|
|
|
|
last_ledger_entry: LicenseLedger,
|
|
|
|
now: datetime,
|
|
|
|
) -> Dict[str, Any]:
|
2023-12-06 06:19:57 +01:00
|
|
|
downgrade_at_end_of_cycle = plan.status == CustomerPlan.DOWNGRADE_AT_END_OF_CYCLE
|
|
|
|
downgrade_at_end_of_free_trial = plan.status == CustomerPlan.DOWNGRADE_AT_END_OF_FREE_TRIAL
|
|
|
|
switch_to_annual_at_end_of_cycle = (
|
|
|
|
plan.status == CustomerPlan.SWITCH_TO_ANNUAL_AT_END_OF_CYCLE
|
|
|
|
)
|
|
|
|
switch_to_monthly_at_end_of_cycle = (
|
|
|
|
plan.status == CustomerPlan.SWITCH_TO_MONTHLY_AT_END_OF_CYCLE
|
|
|
|
)
|
|
|
|
licenses = last_ledger_entry.licenses
|
|
|
|
licenses_at_next_renewal = last_ledger_entry.licenses_at_next_renewal
|
|
|
|
assert licenses_at_next_renewal is not None
|
|
|
|
seat_count = self.current_count_for_billed_licenses()
|
|
|
|
|
|
|
|
# Should do this in JavaScript, using the user's time zone
|
|
|
|
if plan.is_free_trial() or downgrade_at_end_of_free_trial:
|
|
|
|
assert plan.next_invoice_date is not None
|
|
|
|
renewal_date = "{dt:%B} {dt.day}, {dt.year}".format(dt=plan.next_invoice_date)
|
|
|
|
else:
|
|
|
|
renewal_date = "{dt:%B} {dt.day}, {dt.year}".format(
|
|
|
|
dt=start_of_next_billing_cycle(plan, now)
|
2023-12-06 06:14:32 +01:00
|
|
|
)
|
2023-12-06 06:19:57 +01:00
|
|
|
|
|
|
|
billing_frequency = CustomerPlan.BILLING_SCHEDULES[plan.billing_schedule]
|
|
|
|
|
|
|
|
if switch_to_annual_at_end_of_cycle:
|
|
|
|
annual_price_per_license = get_price_per_license(
|
|
|
|
plan.tier, CustomerPlan.BILLING_SCHEDULE_ANNUAL, customer.default_discount
|
2023-12-06 06:14:32 +01:00
|
|
|
)
|
2023-12-06 06:19:57 +01:00
|
|
|
renewal_cents = annual_price_per_license * licenses_at_next_renewal
|
|
|
|
price_per_license = format_money(annual_price_per_license / 12)
|
|
|
|
elif switch_to_monthly_at_end_of_cycle:
|
|
|
|
monthly_price_per_license = get_price_per_license(
|
|
|
|
plan.tier, CustomerPlan.BILLING_SCHEDULE_MONTHLY, customer.default_discount
|
2023-12-06 06:14:32 +01:00
|
|
|
)
|
2023-12-06 06:19:57 +01:00
|
|
|
renewal_cents = monthly_price_per_license * licenses_at_next_renewal
|
|
|
|
price_per_license = format_money(monthly_price_per_license)
|
|
|
|
else:
|
|
|
|
renewal_cents = renewal_amount(plan, now, last_ledger_entry)
|
2023-12-06 06:14:32 +01:00
|
|
|
|
2023-12-06 06:19:57 +01:00
|
|
|
if plan.price_per_license is None:
|
|
|
|
price_per_license = ""
|
|
|
|
elif billing_frequency == "Annual":
|
|
|
|
price_per_license = format_money(plan.price_per_license / 12)
|
2023-12-06 06:14:32 +01:00
|
|
|
else:
|
2023-12-06 06:19:57 +01:00
|
|
|
price_per_license = format_money(plan.price_per_license)
|
2023-12-06 06:14:32 +01:00
|
|
|
|
2023-12-06 06:19:57 +01:00
|
|
|
charge_automatically = plan.charge_automatically
|
|
|
|
assert customer.stripe_customer_id is not None # for mypy
|
|
|
|
stripe_customer = stripe_get_customer(customer.stripe_customer_id)
|
|
|
|
if charge_automatically:
|
|
|
|
payment_method = payment_method_string(stripe_customer)
|
|
|
|
else:
|
|
|
|
payment_method = "Billed by invoice"
|
2023-12-06 06:14:32 +01:00
|
|
|
|
2023-12-06 06:19:57 +01:00
|
|
|
fixed_price = (
|
|
|
|
cents_to_dollar_string(plan.fixed_price) if plan.fixed_price is not None else None
|
|
|
|
)
|
|
|
|
remote_server_legacy_plan_end_date = self.get_formatted_remote_server_legacy_plan_end_date(
|
|
|
|
customer, status=CustomerPlan.SWITCH_PLAN_TIER_AT_PLAN_END
|
|
|
|
)
|
|
|
|
legacy_remote_server_new_plan_name = self.get_legacy_remote_server_new_plan_name(customer)
|
2023-12-08 08:25:05 +01:00
|
|
|
is_self_hosted_billing = not isinstance(self, RealmBillingSession)
|
2023-12-06 06:19:57 +01:00
|
|
|
context = {
|
|
|
|
"plan_name": plan.name,
|
|
|
|
"has_active_plan": True,
|
|
|
|
"free_trial": plan.is_free_trial(),
|
|
|
|
"downgrade_at_end_of_cycle": downgrade_at_end_of_cycle,
|
|
|
|
"downgrade_at_end_of_free_trial": downgrade_at_end_of_free_trial,
|
|
|
|
"automanage_licenses": plan.automanage_licenses,
|
|
|
|
"switch_to_annual_at_end_of_cycle": switch_to_annual_at_end_of_cycle,
|
|
|
|
"switch_to_monthly_at_end_of_cycle": switch_to_monthly_at_end_of_cycle,
|
|
|
|
"licenses": licenses,
|
|
|
|
"licenses_at_next_renewal": licenses_at_next_renewal,
|
|
|
|
"seat_count": seat_count,
|
|
|
|
"renewal_date": renewal_date,
|
|
|
|
"renewal_amount": cents_to_dollar_string(renewal_cents),
|
|
|
|
"payment_method": payment_method,
|
|
|
|
"charge_automatically": charge_automatically,
|
|
|
|
"stripe_email": stripe_customer.email,
|
|
|
|
"CustomerPlan": CustomerPlan,
|
|
|
|
"billing_frequency": billing_frequency,
|
|
|
|
"fixed_price": fixed_price,
|
|
|
|
"price_per_license": price_per_license,
|
|
|
|
"is_sponsorship_pending": customer.sponsorship_pending,
|
2023-12-08 08:25:05 +01:00
|
|
|
"sponsorship_plan_name": self.get_sponsorship_plan_name(
|
|
|
|
customer, is_self_hosted_billing
|
|
|
|
),
|
2023-12-06 06:19:57 +01:00
|
|
|
"discount_percent": format_discount_percentage(customer.default_discount),
|
2023-12-08 08:25:05 +01:00
|
|
|
"is_self_hosted_billing": is_self_hosted_billing,
|
2023-12-06 06:19:57 +01:00
|
|
|
"is_server_on_legacy_plan": remote_server_legacy_plan_end_date is not None,
|
|
|
|
"remote_server_legacy_plan_end_date": remote_server_legacy_plan_end_date,
|
|
|
|
"legacy_remote_server_new_plan_name": legacy_remote_server_new_plan_name,
|
|
|
|
}
|
2023-11-16 16:14:43 +01:00
|
|
|
return context
|
|
|
|
|
2023-12-06 07:09:33 +01:00
|
|
|
def get_billing_page_context(self) -> Dict[str, Any]:
|
|
|
|
now = timezone_now()
|
|
|
|
|
|
|
|
customer = self.get_customer()
|
|
|
|
assert customer is not None
|
|
|
|
|
|
|
|
plan = get_current_plan_by_customer(customer)
|
|
|
|
assert plan is not None
|
|
|
|
|
|
|
|
new_plan, last_ledger_entry = self.make_end_of_cycle_updates_if_needed(plan, now)
|
|
|
|
assert last_ledger_entry is not None
|
|
|
|
plan = new_plan if new_plan is not None else plan
|
|
|
|
|
|
|
|
context = self.get_billing_context_from_plan(customer, plan, last_ledger_entry, now)
|
2023-12-06 08:21:13 +01:00
|
|
|
|
|
|
|
next_plan = self.get_next_plan(plan)
|
|
|
|
if next_plan is not None: # nocoverage
|
|
|
|
next_plan_context = self.get_billing_context_from_plan(
|
|
|
|
customer, next_plan, last_ledger_entry, now
|
|
|
|
)
|
|
|
|
# Settings we want to display from the next plan instead of the current one.
|
|
|
|
# HACK: Our billing page is not designed to handle two plans, so while this is hacky,
|
|
|
|
# it's the easiest way to get the UI we want without making things too complicated for us.
|
|
|
|
keys = [
|
|
|
|
"renewal_amount",
|
|
|
|
"payment_method",
|
|
|
|
"charge_automatically",
|
|
|
|
"billing_frequency",
|
|
|
|
"fixed_price",
|
|
|
|
"price_per_license",
|
|
|
|
"discount_percent",
|
|
|
|
]
|
|
|
|
|
|
|
|
for key in keys:
|
|
|
|
context[key] = next_plan_context[key]
|
2023-12-06 07:09:33 +01:00
|
|
|
return context
|
|
|
|
|
2023-11-20 20:32:29 +01:00
|
|
|
def get_initial_upgrade_context(
|
2023-11-20 08:40:09 +01:00
|
|
|
self, initial_upgrade_request: InitialUpgradeRequest
|
2023-11-24 07:29:06 +01:00
|
|
|
) -> Tuple[Optional[str], Optional[UpgradePageContext]]:
|
2023-11-20 08:40:09 +01:00
|
|
|
customer = self.get_customer()
|
|
|
|
|
|
|
|
if self.is_sponsored_or_pending(customer):
|
2023-11-24 09:08:24 +01:00
|
|
|
return f"{self.billing_session_url}/sponsorship", None
|
2023-11-20 08:40:09 +01:00
|
|
|
|
2023-12-04 14:11:35 +01:00
|
|
|
remote_server_legacy_plan_end_date = self.get_formatted_remote_server_legacy_plan_end_date(
|
|
|
|
customer
|
|
|
|
)
|
|
|
|
# Show upgrade page for remote servers on legacy plan.
|
|
|
|
if customer is not None and remote_server_legacy_plan_end_date is None:
|
|
|
|
customer_plan = get_current_plan_by_customer(customer)
|
|
|
|
if customer_plan is not None:
|
|
|
|
return f"{self.billing_session_url}/billing", None
|
2023-11-20 08:40:09 +01:00
|
|
|
|
|
|
|
percent_off = Decimal(0)
|
|
|
|
if customer is not None and customer.default_discount is not None:
|
|
|
|
percent_off = customer.default_discount
|
|
|
|
|
|
|
|
exempt_from_license_number_check = (
|
|
|
|
customer is not None and customer.exempt_from_license_number_check
|
|
|
|
)
|
|
|
|
|
2023-11-24 07:29:06 +01:00
|
|
|
# Check if user was successful in adding a card and we are rendering the page again.
|
|
|
|
current_payment_method = None
|
|
|
|
if customer is not None and customer_has_credit_card_as_default_payment_method(customer):
|
|
|
|
assert customer.stripe_customer_id is not None
|
|
|
|
stripe_customer = stripe_get_customer(customer.stripe_customer_id)
|
|
|
|
payment_method = payment_method_string(stripe_customer)
|
|
|
|
# Show "Update card" button if user has already added a card.
|
|
|
|
current_payment_method = None if "ending in" not in payment_method else payment_method
|
|
|
|
|
|
|
|
customer_specific_context = self.get_upgrade_page_session_type_specific_context()
|
2023-11-20 08:40:09 +01:00
|
|
|
seat_count = self.current_count_for_billed_licenses()
|
|
|
|
signed_seat_count, salt = sign_string(str(seat_count))
|
2023-11-22 07:36:24 +01:00
|
|
|
tier = initial_upgrade_request.tier
|
2023-11-25 15:18:56 +01:00
|
|
|
|
2023-12-04 14:11:35 +01:00
|
|
|
free_trial_days = None
|
2023-11-25 15:18:56 +01:00
|
|
|
free_trial_end_date = None
|
2023-12-04 14:11:35 +01:00
|
|
|
# Don't show free trial for remote servers on legacy plan.
|
|
|
|
if remote_server_legacy_plan_end_date is None:
|
|
|
|
free_trial_days = settings.FREE_TRIAL_DAYS
|
|
|
|
if free_trial_days is not None:
|
|
|
|
_, _, free_trial_end, _ = compute_plan_parameters(
|
|
|
|
tier, False, CustomerPlan.BILLING_SCHEDULE_ANNUAL, None, True
|
|
|
|
)
|
|
|
|
free_trial_end_date = (
|
|
|
|
f"{free_trial_end:%B} {free_trial_end.day}, {free_trial_end.year}"
|
|
|
|
)
|
2023-11-25 15:18:56 +01:00
|
|
|
|
2023-11-24 07:29:06 +01:00
|
|
|
context: UpgradePageContext = {
|
|
|
|
"customer_name": customer_specific_context["customer_name"],
|
2023-11-20 08:40:09 +01:00
|
|
|
"default_invoice_days_until_due": DEFAULT_INVOICE_DAYS_UNTIL_DUE,
|
2023-11-24 06:26:20 +01:00
|
|
|
"discount_percent": format_discount_percentage(percent_off),
|
2023-11-24 07:29:06 +01:00
|
|
|
"email": customer_specific_context["email"],
|
2023-11-20 08:40:09 +01:00
|
|
|
"exempt_from_license_number_check": exempt_from_license_number_check,
|
2023-11-25 15:18:56 +01:00
|
|
|
"free_trial_days": free_trial_days,
|
|
|
|
"free_trial_end_date": free_trial_end_date,
|
2023-11-24 07:29:06 +01:00
|
|
|
"is_demo_organization": customer_specific_context["is_demo_organization"],
|
2023-12-04 14:11:35 +01:00
|
|
|
"remote_server_legacy_plan_end_date": remote_server_legacy_plan_end_date,
|
2023-11-24 06:26:20 +01:00
|
|
|
"manual_license_management": initial_upgrade_request.manual_license_management,
|
2023-11-20 08:40:09 +01:00
|
|
|
"page_params": {
|
2023-11-30 07:55:53 +01:00
|
|
|
"annual_price": get_price_per_license(
|
|
|
|
tier, CustomerPlan.BILLING_SCHEDULE_ANNUAL, percent_off
|
|
|
|
),
|
2023-11-24 07:29:06 +01:00
|
|
|
"demo_organization_scheduled_deletion_date": customer_specific_context[
|
|
|
|
"demo_organization_scheduled_deletion_date"
|
|
|
|
],
|
2023-11-30 07:55:53 +01:00
|
|
|
"monthly_price": get_price_per_license(
|
|
|
|
tier, CustomerPlan.BILLING_SCHEDULE_MONTHLY, percent_off
|
|
|
|
),
|
2023-11-24 06:26:20 +01:00
|
|
|
"seat_count": seat_count,
|
2023-12-01 04:18:58 +01:00
|
|
|
"billing_base_url": self.billing_base_url,
|
2023-11-20 08:40:09 +01:00
|
|
|
},
|
2023-11-24 07:29:06 +01:00
|
|
|
"payment_method": current_payment_method,
|
2023-12-01 12:47:09 +01:00
|
|
|
"plan": CustomerPlan.name_from_tier(tier),
|
2023-11-24 06:26:20 +01:00
|
|
|
"salt": salt,
|
|
|
|
"seat_count": seat_count,
|
|
|
|
"signed_seat_count": signed_seat_count,
|
2023-12-06 14:17:13 +01:00
|
|
|
"success_message": initial_upgrade_request.success_message,
|
2023-11-20 08:40:09 +01:00
|
|
|
}
|
2023-11-18 11:29:04 +01:00
|
|
|
|
2023-11-20 08:40:09 +01:00
|
|
|
return None, context
|
|
|
|
|
2023-11-22 12:44:02 +01:00
|
|
|
def downgrade_at_the_end_of_billing_cycle(self, plan: Optional[CustomerPlan] = None) -> None:
|
|
|
|
if plan is None: # nocoverage
|
|
|
|
# TODO: Add test coverage. Right now, this logic is used
|
|
|
|
# in production but mocked in tests.
|
|
|
|
customer = self.get_customer()
|
|
|
|
assert customer is not None
|
|
|
|
plan = get_current_plan_by_customer(customer)
|
|
|
|
assert plan is not None
|
|
|
|
do_change_plan_status(plan, CustomerPlan.DOWNGRADE_AT_END_OF_CYCLE)
|
|
|
|
|
2023-12-01 17:48:41 +01:00
|
|
|
def void_all_open_invoices(self) -> int:
|
|
|
|
customer = self.get_customer()
|
|
|
|
if customer is None:
|
|
|
|
return 0
|
|
|
|
invoices = get_all_invoices_for_customer(customer)
|
|
|
|
voided_invoices_count = 0
|
|
|
|
for invoice in invoices:
|
|
|
|
if invoice.status == "open":
|
|
|
|
assert invoice.id is not None
|
|
|
|
stripe.Invoice.void_invoice(invoice.id)
|
|
|
|
voided_invoices_count += 1
|
|
|
|
return voided_invoices_count
|
|
|
|
|
2023-11-22 12:44:02 +01:00
|
|
|
# During realm deactivation we instantly downgrade the plan to Limited.
|
|
|
|
# Extra users added in the final month are not charged. Also used
|
|
|
|
# for the cancellation of Free Trial.
|
|
|
|
def downgrade_now_without_creating_additional_invoices(
|
|
|
|
self,
|
|
|
|
plan: Optional[CustomerPlan] = None,
|
|
|
|
) -> None:
|
|
|
|
if plan is None:
|
|
|
|
customer = self.get_customer()
|
|
|
|
if customer is None:
|
|
|
|
return
|
|
|
|
plan = get_current_plan_by_customer(customer)
|
|
|
|
if plan is None:
|
|
|
|
return # nocoverage
|
|
|
|
|
|
|
|
self.process_downgrade(plan)
|
|
|
|
plan.invoiced_through = LicenseLedger.objects.filter(plan=plan).order_by("id").last()
|
|
|
|
plan.next_invoice_date = next_invoice_date(plan)
|
|
|
|
plan.save(update_fields=["invoiced_through", "next_invoice_date"])
|
|
|
|
|
|
|
|
def do_update_plan(self, update_plan_request: UpdatePlanRequest) -> None:
|
|
|
|
customer = self.get_customer()
|
|
|
|
assert customer is not None
|
|
|
|
plan = get_current_plan_by_customer(customer)
|
|
|
|
assert plan is not None # for mypy
|
|
|
|
|
|
|
|
new_plan, last_ledger_entry = self.make_end_of_cycle_updates_if_needed(plan, timezone_now())
|
|
|
|
if new_plan is not None:
|
|
|
|
raise JsonableError(
|
|
|
|
_(
|
|
|
|
"Unable to update the plan. The plan has been expired and replaced with a new plan."
|
|
|
|
)
|
|
|
|
)
|
|
|
|
|
|
|
|
if last_ledger_entry is None:
|
|
|
|
raise JsonableError(_("Unable to update the plan. The plan has ended."))
|
|
|
|
|
|
|
|
status = update_plan_request.status
|
|
|
|
if status is not None:
|
|
|
|
if status == CustomerPlan.ACTIVE:
|
|
|
|
assert plan.status < CustomerPlan.LIVE_STATUS_THRESHOLD
|
2023-12-06 13:37:19 +01:00
|
|
|
with transaction.atomic(): # nocoverage
|
|
|
|
# Switch to a different plan was cancelled. We end the next plan
|
|
|
|
# and set the current one as active.
|
|
|
|
if plan.status == CustomerPlan.SWITCH_PLAN_TIER_AT_PLAN_END:
|
|
|
|
next_plan = self.get_next_plan(plan)
|
|
|
|
assert next_plan is not None
|
|
|
|
do_change_plan_status(next_plan, CustomerPlan.ENDED)
|
|
|
|
do_change_plan_status(plan, status)
|
2023-11-22 12:44:02 +01:00
|
|
|
elif status == CustomerPlan.DOWNGRADE_AT_END_OF_CYCLE:
|
2023-11-25 11:40:13 +01:00
|
|
|
assert not plan.is_free_trial()
|
2023-11-22 12:44:02 +01:00
|
|
|
assert plan.status < CustomerPlan.LIVE_STATUS_THRESHOLD
|
|
|
|
self.downgrade_at_the_end_of_billing_cycle(plan=plan)
|
|
|
|
elif status == CustomerPlan.SWITCH_TO_ANNUAL_AT_END_OF_CYCLE:
|
2023-11-30 07:55:53 +01:00
|
|
|
assert plan.billing_schedule == CustomerPlan.BILLING_SCHEDULE_MONTHLY
|
2023-11-22 12:44:02 +01:00
|
|
|
assert plan.status < CustomerPlan.LIVE_STATUS_THRESHOLD
|
|
|
|
# Customer needs to switch to an active plan first to avoid unexpected behavior.
|
|
|
|
assert plan.status != CustomerPlan.DOWNGRADE_AT_END_OF_CYCLE
|
2023-11-25 11:40:13 +01:00
|
|
|
# Switching billing frequency for free trial should happen instantly.
|
|
|
|
assert not plan.is_free_trial()
|
2023-11-22 12:44:02 +01:00
|
|
|
assert plan.fixed_price is None
|
|
|
|
do_change_plan_status(plan, status)
|
|
|
|
elif status == CustomerPlan.SWITCH_TO_MONTHLY_AT_END_OF_CYCLE:
|
2023-11-30 07:55:53 +01:00
|
|
|
assert plan.billing_schedule == CustomerPlan.BILLING_SCHEDULE_ANNUAL
|
2023-11-22 12:44:02 +01:00
|
|
|
assert plan.status < CustomerPlan.LIVE_STATUS_THRESHOLD
|
|
|
|
# Customer needs to switch to an active plan first to avoid unexpected behavior.
|
|
|
|
assert plan.status != CustomerPlan.DOWNGRADE_AT_END_OF_CYCLE
|
2023-11-25 11:40:13 +01:00
|
|
|
# Switching billing frequency for free trial should happen instantly.
|
|
|
|
assert not plan.is_free_trial()
|
2023-11-22 12:44:02 +01:00
|
|
|
assert plan.fixed_price is None
|
|
|
|
do_change_plan_status(plan, status)
|
|
|
|
elif status == CustomerPlan.ENDED:
|
2023-11-25 14:44:47 +01:00
|
|
|
# Not used right now on billing page but kept in case we need it.
|
2023-11-22 12:44:02 +01:00
|
|
|
assert plan.is_free_trial()
|
|
|
|
self.downgrade_now_without_creating_additional_invoices(plan=plan)
|
2023-11-25 14:44:47 +01:00
|
|
|
elif status == CustomerPlan.DOWNGRADE_AT_END_OF_FREE_TRIAL:
|
|
|
|
assert plan.is_free_trial()
|
|
|
|
do_change_plan_status(plan, status)
|
|
|
|
elif status == CustomerPlan.FREE_TRIAL:
|
2023-11-26 15:41:28 +01:00
|
|
|
if update_plan_request.schedule is not None:
|
|
|
|
self.do_change_schedule_after_free_trial(plan, update_plan_request.schedule)
|
|
|
|
else:
|
|
|
|
assert plan.status == CustomerPlan.DOWNGRADE_AT_END_OF_FREE_TRIAL
|
|
|
|
do_change_plan_status(plan, status)
|
2023-11-22 12:44:02 +01:00
|
|
|
return
|
|
|
|
|
|
|
|
licenses = update_plan_request.licenses
|
|
|
|
if licenses is not None:
|
2023-12-08 02:45:11 +01:00
|
|
|
if plan.is_free_trial(): # nocoverage
|
|
|
|
raise JsonableError(
|
|
|
|
_("Cannot update licenses in the current billing period for free trial plan.")
|
|
|
|
)
|
2023-11-22 12:44:02 +01:00
|
|
|
if plan.automanage_licenses:
|
|
|
|
raise JsonableError(
|
|
|
|
_(
|
|
|
|
"Unable to update licenses manually. Your plan is on automatic license management."
|
|
|
|
)
|
|
|
|
)
|
|
|
|
if last_ledger_entry.licenses == licenses:
|
|
|
|
raise JsonableError(
|
|
|
|
_(
|
|
|
|
"Your plan is already on {licenses} licenses in the current billing period."
|
|
|
|
).format(licenses=licenses)
|
|
|
|
)
|
|
|
|
if last_ledger_entry.licenses > licenses:
|
|
|
|
raise JsonableError(
|
|
|
|
_("You cannot decrease the licenses in the current billing period.")
|
|
|
|
)
|
|
|
|
validate_licenses(
|
|
|
|
plan.charge_automatically,
|
|
|
|
licenses,
|
|
|
|
self.current_count_for_billed_licenses(),
|
|
|
|
plan.customer.exempt_from_license_number_check,
|
|
|
|
)
|
2023-12-05 19:47:32 +01:00
|
|
|
self.update_license_ledger_for_manual_plan(plan, timezone_now(), licenses=licenses)
|
2023-11-22 12:44:02 +01:00
|
|
|
return
|
|
|
|
|
|
|
|
licenses_at_next_renewal = update_plan_request.licenses_at_next_renewal
|
|
|
|
if licenses_at_next_renewal is not None:
|
|
|
|
if plan.automanage_licenses:
|
|
|
|
raise JsonableError(
|
|
|
|
_(
|
|
|
|
"Unable to update licenses manually. Your plan is on automatic license management."
|
|
|
|
)
|
|
|
|
)
|
2023-12-08 02:45:11 +01:00
|
|
|
if plan.status in (
|
|
|
|
CustomerPlan.DOWNGRADE_AT_END_OF_CYCLE,
|
|
|
|
CustomerPlan.DOWNGRADE_AT_END_OF_FREE_TRIAL,
|
|
|
|
): # nocoverage
|
|
|
|
raise JsonableError(
|
|
|
|
_(
|
|
|
|
"Cannot change the licenses for next billing cycle for a plan that is being downgraded."
|
|
|
|
)
|
|
|
|
)
|
2023-11-22 12:44:02 +01:00
|
|
|
if last_ledger_entry.licenses_at_next_renewal == licenses_at_next_renewal:
|
|
|
|
raise JsonableError(
|
|
|
|
_(
|
|
|
|
"Your plan is already scheduled to renew with {licenses_at_next_renewal} licenses."
|
|
|
|
).format(licenses_at_next_renewal=licenses_at_next_renewal)
|
|
|
|
)
|
|
|
|
validate_licenses(
|
|
|
|
plan.charge_automatically,
|
|
|
|
licenses_at_next_renewal,
|
|
|
|
self.current_count_for_billed_licenses(),
|
|
|
|
plan.customer.exempt_from_license_number_check,
|
|
|
|
)
|
2023-12-05 19:47:32 +01:00
|
|
|
self.update_license_ledger_for_manual_plan(
|
2023-11-22 12:44:02 +01:00
|
|
|
plan, timezone_now(), licenses_at_next_renewal=licenses_at_next_renewal
|
|
|
|
)
|
|
|
|
return
|
|
|
|
|
|
|
|
raise JsonableError(_("Nothing to change."))
|
|
|
|
|
2023-11-23 07:29:03 +01:00
|
|
|
def switch_plan_tier(self, current_plan: CustomerPlan, new_plan_tier: int) -> None:
|
|
|
|
assert current_plan.status == CustomerPlan.SWITCH_PLAN_TIER_NOW
|
|
|
|
assert current_plan.next_invoice_date is not None
|
|
|
|
next_billing_cycle = current_plan.next_invoice_date
|
|
|
|
|
|
|
|
current_plan.end_date = next_billing_cycle
|
|
|
|
current_plan.status = CustomerPlan.ENDED
|
|
|
|
current_plan.save(update_fields=["status", "end_date"])
|
|
|
|
|
|
|
|
new_price_per_license = get_price_per_license(
|
|
|
|
new_plan_tier, current_plan.billing_schedule, current_plan.customer.default_discount
|
|
|
|
)
|
|
|
|
|
|
|
|
new_plan_billing_cycle_anchor = current_plan.end_date.replace(microsecond=0)
|
|
|
|
|
|
|
|
new_plan = CustomerPlan.objects.create(
|
|
|
|
customer=current_plan.customer,
|
|
|
|
status=CustomerPlan.ACTIVE,
|
|
|
|
automanage_licenses=current_plan.automanage_licenses,
|
|
|
|
charge_automatically=current_plan.charge_automatically,
|
|
|
|
price_per_license=new_price_per_license,
|
|
|
|
discount=current_plan.customer.default_discount,
|
|
|
|
billing_schedule=current_plan.billing_schedule,
|
|
|
|
tier=new_plan_tier,
|
|
|
|
billing_cycle_anchor=new_plan_billing_cycle_anchor,
|
2023-11-30 08:07:12 +01:00
|
|
|
invoicing_status=CustomerPlan.INVOICING_STATUS_INITIAL_INVOICE_TO_BE_SENT,
|
2023-11-23 07:29:03 +01:00
|
|
|
next_invoice_date=new_plan_billing_cycle_anchor,
|
|
|
|
)
|
|
|
|
|
|
|
|
current_plan_last_ledger = (
|
|
|
|
LicenseLedger.objects.filter(plan=current_plan).order_by("id").last()
|
|
|
|
)
|
|
|
|
assert current_plan_last_ledger is not None
|
|
|
|
licenses_for_new_plan = current_plan_last_ledger.licenses_at_next_renewal
|
|
|
|
assert licenses_for_new_plan is not None
|
|
|
|
LicenseLedger.objects.create(
|
|
|
|
plan=new_plan,
|
|
|
|
is_renewal=True,
|
|
|
|
event_time=new_plan_billing_cycle_anchor,
|
|
|
|
licenses=licenses_for_new_plan,
|
|
|
|
licenses_at_next_renewal=licenses_for_new_plan,
|
|
|
|
)
|
|
|
|
|
2023-11-30 14:49:10 +01:00
|
|
|
def invoice_plan(self, plan: CustomerPlan, event_time: datetime) -> None:
|
|
|
|
if plan.invoicing_status == CustomerPlan.INVOICING_STATUS_STARTED:
|
|
|
|
raise NotImplementedError(
|
|
|
|
"Plan with invoicing_status==STARTED needs manual resolution."
|
|
|
|
)
|
|
|
|
if not plan.customer.stripe_customer_id:
|
|
|
|
raise BillingError(
|
|
|
|
f"Customer has a paid plan without a Stripe customer ID: {plan.customer!s}"
|
|
|
|
)
|
|
|
|
|
|
|
|
# Updating a CustomerPlan with a status to switch the plan tier,
|
|
|
|
# is done via switch_plan_tier, so we do not need to make end of
|
|
|
|
# cycle updates for that case.
|
|
|
|
if plan.status is not CustomerPlan.SWITCH_PLAN_TIER_NOW:
|
|
|
|
self.make_end_of_cycle_updates_if_needed(plan, event_time)
|
|
|
|
|
|
|
|
if plan.invoicing_status == CustomerPlan.INVOICING_STATUS_INITIAL_INVOICE_TO_BE_SENT:
|
|
|
|
invoiced_through_id = -1
|
|
|
|
licenses_base = None
|
|
|
|
else:
|
|
|
|
assert plan.invoiced_through is not None
|
|
|
|
licenses_base = plan.invoiced_through.licenses
|
|
|
|
invoiced_through_id = plan.invoiced_through.id
|
|
|
|
|
|
|
|
invoice_item_created = False
|
|
|
|
for ledger_entry in LicenseLedger.objects.filter(
|
|
|
|
plan=plan, id__gt=invoiced_through_id, event_time__lte=event_time
|
|
|
|
).order_by("id"):
|
|
|
|
price_args: PriceArgs = {}
|
|
|
|
if ledger_entry.is_renewal:
|
|
|
|
if plan.fixed_price is not None:
|
|
|
|
price_args = {"amount": plan.fixed_price}
|
|
|
|
else:
|
|
|
|
assert plan.price_per_license is not None # needed for mypy
|
|
|
|
price_args = {
|
|
|
|
"unit_amount": plan.price_per_license,
|
|
|
|
"quantity": ledger_entry.licenses,
|
|
|
|
}
|
|
|
|
description = f"{plan.name} - renewal"
|
|
|
|
elif licenses_base is not None and ledger_entry.licenses != licenses_base:
|
|
|
|
assert plan.price_per_license
|
|
|
|
last_ledger_entry_renewal = (
|
|
|
|
LicenseLedger.objects.filter(
|
|
|
|
plan=plan, is_renewal=True, event_time__lte=ledger_entry.event_time
|
|
|
|
)
|
|
|
|
.order_by("-id")
|
|
|
|
.first()
|
|
|
|
)
|
|
|
|
assert last_ledger_entry_renewal is not None
|
|
|
|
last_renewal = last_ledger_entry_renewal.event_time
|
|
|
|
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
|
|
|
|
)
|
|
|
|
price_args = {
|
|
|
|
"unit_amount": int(plan.price_per_license * proration_fraction + 0.5),
|
|
|
|
"quantity": ledger_entry.licenses - licenses_base,
|
|
|
|
}
|
|
|
|
description = "Additional license ({} - {})".format(
|
|
|
|
ledger_entry.event_time.strftime("%b %-d, %Y"),
|
|
|
|
plan_renewal_or_end_date.strftime("%b %-d, %Y"),
|
|
|
|
)
|
|
|
|
|
|
|
|
if price_args:
|
|
|
|
plan.invoiced_through = ledger_entry
|
|
|
|
plan.invoicing_status = CustomerPlan.INVOICING_STATUS_STARTED
|
|
|
|
plan.save(update_fields=["invoicing_status", "invoiced_through"])
|
|
|
|
stripe.InvoiceItem.create(
|
|
|
|
currency="usd",
|
|
|
|
customer=plan.customer.stripe_customer_id,
|
|
|
|
description=description,
|
|
|
|
discountable=False,
|
|
|
|
period={
|
|
|
|
"start": datetime_to_timestamp(ledger_entry.event_time),
|
|
|
|
"end": datetime_to_timestamp(
|
|
|
|
get_plan_renewal_or_end_date(plan, ledger_entry.event_time)
|
|
|
|
),
|
|
|
|
},
|
|
|
|
idempotency_key=get_idempotency_key(ledger_entry),
|
|
|
|
**price_args,
|
|
|
|
)
|
|
|
|
invoice_item_created = True
|
|
|
|
plan.invoiced_through = ledger_entry
|
|
|
|
plan.invoicing_status = CustomerPlan.INVOICING_STATUS_DONE
|
|
|
|
plan.save(update_fields=["invoicing_status", "invoiced_through"])
|
|
|
|
licenses_base = ledger_entry.licenses
|
|
|
|
|
|
|
|
if invoice_item_created:
|
|
|
|
if plan.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=plan.customer.stripe_customer_id,
|
|
|
|
days_until_due=days_until_due,
|
|
|
|
statement_descriptor=plan.name,
|
|
|
|
)
|
|
|
|
stripe.Invoice.finalize_invoice(stripe_invoice)
|
|
|
|
|
|
|
|
plan.next_invoice_date = next_invoice_date(plan)
|
|
|
|
plan.save(update_fields=["next_invoice_date"])
|
|
|
|
|
2023-12-01 19:45:11 +01:00
|
|
|
def do_change_plan_to_new_tier(self, new_plan_tier: int) -> str:
|
2023-11-23 07:29:03 +01:00
|
|
|
customer = self.get_customer()
|
|
|
|
assert customer is not None
|
|
|
|
current_plan = get_current_plan_by_customer(customer)
|
|
|
|
|
|
|
|
if not current_plan or current_plan.status != CustomerPlan.ACTIVE:
|
|
|
|
raise BillingError("Organization does not have an active plan")
|
|
|
|
|
|
|
|
if not current_plan.customer.stripe_customer_id:
|
|
|
|
raise BillingError("Organization missing Stripe customer.")
|
|
|
|
|
2023-11-30 17:11:41 +01:00
|
|
|
type_of_tier_change = self.get_type_of_plan_tier_change(current_plan.tier, new_plan_tier)
|
|
|
|
|
|
|
|
if type_of_tier_change == PlanTierChangeType.INVALID:
|
2023-11-23 07:29:03 +01:00
|
|
|
raise BillingError("Invalid change of customer plan tier.")
|
|
|
|
|
2023-11-30 17:11:41 +01:00
|
|
|
if type_of_tier_change == PlanTierChangeType.UPGRADE:
|
|
|
|
plan_switch_time = timezone_now()
|
|
|
|
current_plan.status = CustomerPlan.SWITCH_PLAN_TIER_NOW
|
|
|
|
current_plan.next_invoice_date = plan_switch_time
|
|
|
|
current_plan.save(update_fields=["status", "next_invoice_date"])
|
2023-11-23 07:29:03 +01:00
|
|
|
|
2023-11-30 17:11:41 +01:00
|
|
|
self.do_change_plan_type(tier=new_plan_tier)
|
2023-11-23 07:29:03 +01:00
|
|
|
|
2023-11-30 17:11:41 +01:00
|
|
|
amount_to_credit_for_early_termination = get_amount_to_credit_for_plan_tier_change(
|
|
|
|
current_plan, plan_switch_time
|
|
|
|
)
|
|
|
|
stripe.Customer.create_balance_transaction(
|
|
|
|
current_plan.customer.stripe_customer_id,
|
|
|
|
amount=-1 * amount_to_credit_for_early_termination,
|
|
|
|
currency="usd",
|
|
|
|
description="Credit from early termination of active plan",
|
|
|
|
)
|
|
|
|
self.switch_plan_tier(current_plan, new_plan_tier)
|
|
|
|
self.invoice_plan(current_plan, plan_switch_time)
|
|
|
|
new_plan = get_current_plan_by_customer(customer)
|
|
|
|
assert new_plan is not None # for mypy
|
|
|
|
self.invoice_plan(new_plan, plan_switch_time)
|
2023-12-01 19:45:11 +01:00
|
|
|
return f"{self.billing_entity_display_name} upgraded to {new_plan.name}"
|
2023-11-23 07:29:03 +01:00
|
|
|
|
2023-11-30 17:11:41 +01:00
|
|
|
# TODO: Implement downgrade that is a change from and to a paid plan
|
|
|
|
# tier. This should keep the same billing cycle schedule and change
|
|
|
|
# the plan when it's next invoiced vs immediately. Note this will need
|
2023-12-04 14:03:24 +01:00
|
|
|
# new CustomerPlan.status value, e.g. SWITCH_PLAN_TIER_AT_PLAN_END.
|
2023-11-30 17:11:41 +01:00
|
|
|
assert type_of_tier_change == PlanTierChangeType.DOWNGRADE # nocoverage
|
2023-12-01 19:45:11 +01:00
|
|
|
return "" # nocoverage
|
2023-11-23 07:29:03 +01:00
|
|
|
|
2023-11-27 11:07:03 +01:00
|
|
|
def get_event_status(self, event_status_request: EventStatusRequest) -> Dict[str, Any]:
|
|
|
|
customer = self.get_customer()
|
|
|
|
|
|
|
|
if customer is None:
|
|
|
|
raise JsonableError(_("No customer for this organization!"))
|
|
|
|
|
|
|
|
stripe_session_id = event_status_request.stripe_session_id
|
|
|
|
if stripe_session_id is not None:
|
|
|
|
try:
|
|
|
|
session = Session.objects.get(
|
|
|
|
stripe_session_id=stripe_session_id, customer=customer
|
|
|
|
)
|
|
|
|
except Session.DoesNotExist:
|
|
|
|
raise JsonableError(_("Session not found"))
|
|
|
|
|
|
|
|
if (
|
|
|
|
session.type == Session.CARD_UPDATE_FROM_BILLING_PAGE
|
|
|
|
and not self.has_billing_access()
|
|
|
|
):
|
|
|
|
raise JsonableError(_("Must be a billing administrator or an organization owner"))
|
|
|
|
return {"session": session.to_dict()}
|
|
|
|
|
|
|
|
stripe_payment_intent_id = event_status_request.stripe_payment_intent_id
|
|
|
|
if stripe_payment_intent_id is not None:
|
|
|
|
payment_intent = PaymentIntent.objects.filter(
|
|
|
|
stripe_payment_intent_id=stripe_payment_intent_id,
|
|
|
|
customer=customer,
|
|
|
|
).last()
|
|
|
|
|
|
|
|
if payment_intent is None:
|
|
|
|
raise JsonableError(_("Payment intent not found"))
|
|
|
|
return {"payment_intent": payment_intent.to_dict()}
|
|
|
|
|
|
|
|
raise JsonableError(_("Pass stripe_session_id or stripe_payment_intent_id"))
|
|
|
|
|
2023-12-08 08:25:05 +01:00
|
|
|
def get_sponsorship_plan_name(
|
|
|
|
self, customer: Optional[Customer], is_remotely_hosted: bool
|
|
|
|
) -> str:
|
|
|
|
if customer is not None and customer.sponsorship_pending:
|
|
|
|
# For sponsorship pending requests, we also show the type of sponsorship requested.
|
|
|
|
# In other cases, we just show the plan user is currently on.
|
|
|
|
sponsorship_request = (
|
|
|
|
ZulipSponsorshipRequest.objects.filter(customer=customer).order_by("-id").first()
|
|
|
|
)
|
|
|
|
# It's possible that we marked `customer.sponsorship_pending` via support page
|
|
|
|
# without user submitting a sponsorship request.
|
|
|
|
if sponsorship_request is not None and sponsorship_request.requested_plan not in (
|
|
|
|
None,
|
|
|
|
SponsoredPlanTypes.UNSPECIFIED.value,
|
|
|
|
): # nocoverage
|
|
|
|
return sponsorship_request.requested_plan
|
2023-12-05 07:41:34 +01:00
|
|
|
|
|
|
|
# We only support sponsorships for these plans.
|
|
|
|
sponsored_plan_name = CustomerPlan.name_from_tier(CustomerPlan.TIER_CLOUD_STANDARD)
|
|
|
|
if is_remotely_hosted:
|
|
|
|
sponsored_plan_name = CustomerPlan.name_from_tier(
|
|
|
|
CustomerPlan.TIER_SELF_HOSTED_COMMUNITY
|
|
|
|
)
|
|
|
|
|
2023-12-08 08:25:05 +01:00
|
|
|
return sponsored_plan_name
|
|
|
|
|
|
|
|
def get_sponsorship_request_context(self) -> Optional[Dict[str, Any]]:
|
|
|
|
customer = self.get_customer()
|
|
|
|
is_remotely_hosted = isinstance(
|
|
|
|
self, (RemoteRealmBillingSession, RemoteServerBillingSession)
|
|
|
|
)
|
|
|
|
|
2023-12-05 07:41:34 +01:00
|
|
|
plan_name = "Zulip Cloud Free"
|
|
|
|
if is_remotely_hosted:
|
|
|
|
plan_name = "Self-managed"
|
|
|
|
|
|
|
|
context: Dict[str, Any] = {
|
|
|
|
"billing_base_url": self.billing_base_url,
|
|
|
|
"is_remotely_hosted": is_remotely_hosted,
|
2023-12-08 08:25:05 +01:00
|
|
|
"sponsorship_plan_name": self.get_sponsorship_plan_name(customer, is_remotely_hosted),
|
2023-12-05 07:41:34 +01:00
|
|
|
"plan_name": plan_name,
|
|
|
|
}
|
2023-11-27 13:08:43 +01:00
|
|
|
|
|
|
|
if customer is not None and customer.sponsorship_pending:
|
|
|
|
if self.on_paid_plan():
|
|
|
|
return None
|
|
|
|
|
|
|
|
context["is_sponsorship_pending"] = True
|
|
|
|
|
|
|
|
if self.is_sponsored():
|
|
|
|
context["is_sponsored"] = True
|
|
|
|
|
|
|
|
if customer is not None:
|
|
|
|
plan = get_current_plan_by_customer(customer)
|
|
|
|
if plan is not None:
|
|
|
|
context["plan_name"] = plan.name
|
|
|
|
context["free_trial"] = plan.is_free_trial()
|
|
|
|
|
|
|
|
self.add_sponsorship_info_to_context(context)
|
|
|
|
return context
|
|
|
|
|
2023-11-30 01:48:46 +01:00
|
|
|
def request_sponsorship(self, form: SponsorshipRequestForm) -> None:
|
|
|
|
if not form.is_valid():
|
|
|
|
message = " ".join(
|
|
|
|
error["message"]
|
|
|
|
for error_list in form.errors.get_json_data().values()
|
|
|
|
for error in error_list
|
|
|
|
)
|
|
|
|
raise BillingError("Form validation error", message=message)
|
|
|
|
|
|
|
|
request_context = self.get_sponsorship_request_session_specific_context()
|
|
|
|
with transaction.atomic():
|
|
|
|
# Ensures customer is created first before updating sponsorship status.
|
|
|
|
self.update_customer_sponsorship_status(True)
|
|
|
|
sponsorship_request = ZulipSponsorshipRequest(
|
|
|
|
customer=self.get_customer(),
|
|
|
|
requested_by=request_context["realm_user"],
|
|
|
|
org_website=form.cleaned_data["website"],
|
|
|
|
org_description=form.cleaned_data["description"],
|
|
|
|
org_type=form.cleaned_data["organization_type"],
|
|
|
|
expected_total_users=form.cleaned_data["expected_total_users"],
|
|
|
|
paid_users_count=form.cleaned_data["paid_users_count"],
|
|
|
|
paid_users_description=form.cleaned_data["paid_users_description"],
|
2023-12-08 08:25:05 +01:00
|
|
|
requested_plan=form.cleaned_data["requested_plan"],
|
2023-11-30 01:48:46 +01:00
|
|
|
)
|
|
|
|
sponsorship_request.save()
|
|
|
|
|
|
|
|
org_type = form.cleaned_data["organization_type"]
|
|
|
|
self.save_org_type_from_request_sponsorship_session(org_type)
|
|
|
|
|
|
|
|
if request_context["realm_user"] is not None:
|
|
|
|
# TODO: Refactor to not create an import cycle.
|
|
|
|
from zerver.actions.users import do_change_is_billing_admin
|
|
|
|
|
|
|
|
do_change_is_billing_admin(request_context["realm_user"], True)
|
|
|
|
|
|
|
|
org_type_display_name = get_org_type_display_name(org_type)
|
|
|
|
|
|
|
|
user_info = request_context["user_info"]
|
|
|
|
support_url = self.support_url()
|
|
|
|
context = {
|
|
|
|
"requested_by": user_info["name"],
|
|
|
|
"user_role": user_info["role"],
|
2023-12-06 13:20:22 +01:00
|
|
|
"billing_entity": self.billing_entity_display_name,
|
2023-11-30 01:48:46 +01:00
|
|
|
"support_url": support_url,
|
|
|
|
"organization_type": org_type_display_name,
|
|
|
|
"website": sponsorship_request.org_website,
|
|
|
|
"description": sponsorship_request.org_description,
|
|
|
|
"expected_total_users": sponsorship_request.expected_total_users,
|
|
|
|
"paid_users_count": sponsorship_request.paid_users_count,
|
|
|
|
"paid_users_description": sponsorship_request.paid_users_description,
|
2023-12-08 08:25:05 +01:00
|
|
|
"requested_plan": sponsorship_request.requested_plan,
|
2023-11-30 01:48:46 +01:00
|
|
|
}
|
|
|
|
send_email(
|
|
|
|
"zerver/emails/sponsorship_request",
|
|
|
|
to_emails=[FromAddress.SUPPORT],
|
|
|
|
# Sent to the server's support team, so this email is not user-facing.
|
|
|
|
from_name="Zulip sponsorship request",
|
|
|
|
from_address=FromAddress.tokenized_no_reply_address(),
|
|
|
|
reply_to_email=user_info["email"],
|
|
|
|
context=context,
|
|
|
|
)
|
|
|
|
|
2023-11-30 21:11:54 +01:00
|
|
|
def process_support_view_request(self, support_request: SupportViewRequest) -> str:
|
|
|
|
support_type = support_request["support_type"]
|
|
|
|
success_message = ""
|
|
|
|
|
|
|
|
if support_type == SupportType.approve_sponsorship:
|
|
|
|
success_message = self.approve_sponsorship()
|
|
|
|
elif support_type == SupportType.update_sponsorship_status:
|
|
|
|
assert support_request["sponsorship_status"] is not None
|
|
|
|
sponsorship_status = support_request["sponsorship_status"]
|
|
|
|
success_message = self.update_customer_sponsorship_status(sponsorship_status)
|
|
|
|
elif support_type == SupportType.attach_discount:
|
|
|
|
assert support_request["discount"] is not None
|
|
|
|
new_discount = support_request["discount"]
|
|
|
|
success_message = self.attach_discount_to_customer(new_discount)
|
2023-12-01 12:23:31 +01:00
|
|
|
elif support_type == SupportType.update_billing_modality:
|
|
|
|
assert support_request["billing_modality"] is not None
|
|
|
|
assert support_request["billing_modality"] in VALID_BILLING_MODALITY_VALUES
|
|
|
|
charge_automatically = support_request["billing_modality"] == "charge_automatically"
|
|
|
|
success_message = self.update_billing_modality_of_current_plan(charge_automatically)
|
2023-12-01 19:45:11 +01:00
|
|
|
elif support_type == SupportType.modify_plan:
|
|
|
|
assert support_request["plan_modification"] is not None
|
|
|
|
plan_modification = support_request["plan_modification"]
|
|
|
|
if plan_modification == "downgrade_at_billing_cycle_end":
|
|
|
|
self.downgrade_at_the_end_of_billing_cycle()
|
|
|
|
success_message = f"{self.billing_entity_display_name} marked for downgrade at the end of billing cycle"
|
|
|
|
elif plan_modification == "downgrade_now_without_additional_licenses":
|
|
|
|
self.downgrade_now_without_creating_additional_invoices()
|
|
|
|
success_message = f"{self.billing_entity_display_name} downgraded without creating additional invoices"
|
|
|
|
elif plan_modification == "downgrade_now_void_open_invoices":
|
|
|
|
self.downgrade_now_without_creating_additional_invoices()
|
|
|
|
voided_invoices_count = self.void_all_open_invoices()
|
|
|
|
success_message = f"{self.billing_entity_display_name} downgraded and voided {voided_invoices_count} open invoices"
|
|
|
|
else:
|
|
|
|
assert plan_modification == "upgrade_plan_tier"
|
|
|
|
assert support_request["new_plan_tier"] is not None
|
|
|
|
new_plan_tier = support_request["new_plan_tier"]
|
|
|
|
success_message = self.do_change_plan_to_new_tier(new_plan_tier)
|
2023-11-30 21:11:54 +01:00
|
|
|
|
|
|
|
return success_message
|
|
|
|
|
2023-12-05 19:47:32 +01:00
|
|
|
def update_license_ledger_for_manual_plan(
|
|
|
|
self,
|
|
|
|
plan: CustomerPlan,
|
|
|
|
event_time: datetime,
|
|
|
|
licenses: Optional[int] = None,
|
|
|
|
licenses_at_next_renewal: Optional[int] = None,
|
|
|
|
) -> None:
|
|
|
|
if licenses is not None:
|
|
|
|
if not plan.customer.exempt_from_license_number_check:
|
|
|
|
assert self.current_count_for_billed_licenses() <= licenses
|
|
|
|
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:
|
|
|
|
if not plan.customer.exempt_from_license_number_check:
|
|
|
|
assert self.current_count_for_billed_licenses() <= licenses_at_next_renewal
|
|
|
|
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")
|
|
|
|
|
2023-12-05 21:09:28 +01:00
|
|
|
def update_license_ledger_for_automanaged_plan(
|
|
|
|
self, plan: CustomerPlan, event_time: datetime
|
|
|
|
) -> None:
|
|
|
|
new_plan, last_ledger_entry = self.make_end_of_cycle_updates_if_needed(plan, event_time)
|
|
|
|
if last_ledger_entry is None:
|
|
|
|
return
|
|
|
|
if new_plan is not None:
|
|
|
|
plan = new_plan
|
|
|
|
licenses_at_next_renewal = self.current_count_for_billed_licenses()
|
|
|
|
licenses = max(licenses_at_next_renewal, last_ledger_entry.licenses)
|
|
|
|
|
|
|
|
LicenseLedger.objects.create(
|
|
|
|
plan=plan,
|
|
|
|
event_time=event_time,
|
|
|
|
licenses=licenses,
|
|
|
|
licenses_at_next_renewal=licenses_at_next_renewal,
|
|
|
|
)
|
|
|
|
|
|
|
|
def update_license_ledger_if_needed(self, event_time: datetime) -> None:
|
|
|
|
customer = self.get_customer()
|
|
|
|
if customer is None:
|
|
|
|
return
|
|
|
|
plan = get_current_plan_by_customer(customer)
|
|
|
|
if plan is None:
|
|
|
|
return
|
|
|
|
if not plan.automanage_licenses:
|
|
|
|
return
|
|
|
|
self.update_license_ledger_for_automanaged_plan(plan, event_time)
|
|
|
|
|
2023-10-26 14:11:43 +02:00
|
|
|
|
|
|
|
class RealmBillingSession(BillingSession):
|
2023-11-23 17:34:58 +01:00
|
|
|
def __init__(
|
|
|
|
self,
|
|
|
|
user: Optional[UserProfile] = None,
|
|
|
|
realm: Optional[Realm] = None,
|
|
|
|
*,
|
|
|
|
support_session: bool = False,
|
|
|
|
) -> None:
|
2023-10-26 14:11:43 +02:00
|
|
|
self.user = user
|
2023-11-13 15:05:56 +01:00
|
|
|
assert user is not None or realm is not None
|
2023-11-23 17:34:58 +01:00
|
|
|
if support_session:
|
|
|
|
assert user is not None and user.is_staff
|
|
|
|
self.support_session = support_session
|
|
|
|
|
2023-11-13 15:05:56 +01:00
|
|
|
if user is not None and realm is not None:
|
2023-11-23 17:34:58 +01:00
|
|
|
assert user.is_staff or user.realm == realm
|
2023-10-31 19:22:55 +01:00
|
|
|
self.realm = realm
|
2023-11-13 15:05:56 +01:00
|
|
|
elif user is not None:
|
2023-10-31 19:22:55 +01:00
|
|
|
self.realm = user.realm
|
2023-11-13 15:05:56 +01:00
|
|
|
else:
|
|
|
|
assert realm is not None # for mypy
|
|
|
|
self.realm = realm
|
2023-10-26 14:11:43 +02:00
|
|
|
|
2023-11-27 17:31:39 +01:00
|
|
|
PAID_PLANS = [
|
|
|
|
Realm.PLAN_TYPE_STANDARD,
|
|
|
|
Realm.PLAN_TYPE_PLUS,
|
|
|
|
]
|
|
|
|
|
2023-11-30 21:11:54 +01:00
|
|
|
@override
|
|
|
|
@property
|
|
|
|
def billing_entity_display_name(self) -> str:
|
|
|
|
return self.realm.string_id
|
|
|
|
|
2023-11-06 13:52:12 +01:00
|
|
|
@override
|
|
|
|
@property
|
|
|
|
def billing_session_url(self) -> str:
|
|
|
|
return self.realm.uri
|
|
|
|
|
2023-12-01 04:18:58 +01:00
|
|
|
@override
|
|
|
|
@property
|
|
|
|
def billing_base_url(self) -> str:
|
|
|
|
return ""
|
|
|
|
|
2023-11-30 01:48:46 +01:00
|
|
|
@override
|
|
|
|
def support_url(self) -> str:
|
2023-12-06 15:00:23 +01:00
|
|
|
return build_support_url("support", self.realm.string_id)
|
2023-11-30 01:48:46 +01:00
|
|
|
|
2023-10-26 14:11:43 +02:00
|
|
|
@override
|
|
|
|
def get_customer(self) -> Optional[Customer]:
|
|
|
|
return get_customer_by_realm(self.realm)
|
|
|
|
|
2023-12-02 03:54:24 +01:00
|
|
|
@override
|
|
|
|
def get_email(self) -> str:
|
|
|
|
assert self.user is not None
|
|
|
|
return self.user.delivery_email
|
|
|
|
|
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-12-01 13:19:04 +01:00
|
|
|
elif event_type is AuditLogEventType.BILLING_MODALITY_CHANGED:
|
|
|
|
return RealmAuditLog.REALM_BILLING_MODALITY_CHANGED
|
2023-11-13 15:05:56 +01:00
|
|
|
elif event_type is AuditLogEventType.CUSTOMER_SWITCHED_FROM_MONTHLY_TO_ANNUAL_PLAN:
|
|
|
|
return RealmAuditLog.CUSTOMER_SWITCHED_FROM_MONTHLY_TO_ANNUAL_PLAN
|
2023-11-20 13:01:25 +01:00
|
|
|
elif event_type is AuditLogEventType.CUSTOMER_SWITCHED_FROM_ANNUAL_TO_MONTHLY_PLAN:
|
|
|
|
return RealmAuditLog.CUSTOMER_SWITCHED_FROM_ANNUAL_TO_MONTHLY_PLAN
|
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-11-13 15:05:56 +01:00
|
|
|
audit_log_data = {
|
|
|
|
"realm": self.realm,
|
|
|
|
"event_type": audit_log_event,
|
|
|
|
"event_time": event_time,
|
|
|
|
}
|
|
|
|
|
2023-10-26 14:11:43 +02:00
|
|
|
if extra_data:
|
2023-11-13 15:05:56 +01:00
|
|
|
audit_log_data["extra_data"] = extra_data
|
|
|
|
|
|
|
|
if self.user is not None:
|
|
|
|
audit_log_data["acting_user"] = self.user
|
|
|
|
|
|
|
|
RealmAuditLog.objects.create(**audit_log_data)
|
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-11-13 15:05:56 +01:00
|
|
|
assert self.user is not None
|
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})",
|
2023-12-02 03:54:24 +01:00
|
|
|
email=self.get_email(),
|
2023-10-31 15:51:51 +01:00
|
|
|
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]:
|
2023-11-13 15:05:56 +01:00
|
|
|
assert self.user is not None
|
2023-11-06 15:51:54 +01:00
|
|
|
updated_metadata = dict(
|
2023-12-02 03:54:24 +01:00
|
|
|
user_email=self.get_email(),
|
2023-11-06 15:51:54 +01:00
|
|
|
realm_id=self.realm.id,
|
|
|
|
realm_str=self.realm.string_id,
|
|
|
|
user_id=self.user.id,
|
|
|
|
**metadata,
|
|
|
|
)
|
|
|
|
return updated_metadata
|
|
|
|
|
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}
|
|
|
|
)
|
2023-11-15 22:30:08 +01:00
|
|
|
from zerver.actions.users import do_change_is_billing_admin
|
2021-05-28 12:36:41 +02:00
|
|
|
|
2023-11-13 15:05:56 +01:00
|
|
|
assert self.user is not None
|
2023-11-15 22:30:08 +01:00
|
|
|
do_change_is_billing_admin(self.user, True)
|
2023-10-31 19:22:55 +01:00
|
|
|
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
|
2023-11-30 07:43:06 +01:00
|
|
|
elif tier == CustomerPlan.TIER_CLOUD_STANDARD:
|
2023-11-08 17:15:40 +01:00
|
|
|
plan_type = Realm.PLAN_TYPE_STANDARD
|
2023-11-30 07:43:06 +01:00
|
|
|
elif (
|
|
|
|
tier == CustomerPlan.TIER_CLOUD_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-13 15:05:56 +01:00
|
|
|
@override
|
|
|
|
def process_downgrade(self, plan: CustomerPlan) -> None:
|
|
|
|
from zerver.actions.realm_settings import do_change_realm_plan_type
|
|
|
|
|
|
|
|
assert plan.customer.realm is not None
|
|
|
|
do_change_realm_plan_type(plan.customer.realm, Realm.PLAN_TYPE_LIMITED, acting_user=None)
|
|
|
|
plan.status = CustomerPlan.ENDED
|
|
|
|
plan.save(update_fields=["status"])
|
|
|
|
|
2023-11-02 15:23:35 +01:00
|
|
|
@override
|
2023-11-30 21:11:54 +01:00
|
|
|
def approve_sponsorship(self) -> str:
|
2023-11-02 15:23:35 +01:00
|
|
|
# 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(
|
2023-11-09 20:40:42 +01:00
|
|
|
# TODO: Don't hardcode plan names.
|
2023-11-02 15:23:35 +01:00
|
|
|
plan_name="Zulip Cloud Standard",
|
|
|
|
emoji=":tada:",
|
|
|
|
begin_link="[",
|
|
|
|
end_link="](/help/linking-to-zulip-website)",
|
|
|
|
)
|
|
|
|
internal_send_private_message(notification_bot, user, message)
|
2023-11-30 21:11:54 +01:00
|
|
|
return f"Sponsorship approved for {self.billing_entity_display_name}"
|
2023-11-02 15:23:35 +01:00
|
|
|
|
2023-11-27 13:25:11 +01:00
|
|
|
@override
|
|
|
|
def is_sponsored(self) -> bool:
|
|
|
|
return self.realm.plan_type == self.realm.PLAN_TYPE_STANDARD_FREE
|
|
|
|
|
2023-12-01 03:51:05 +01:00
|
|
|
@override
|
2023-12-02 09:05:20 +01:00
|
|
|
def get_metadata_for_stripe_update_card(self) -> Dict[str, Any]:
|
2023-12-01 03:51:05 +01:00
|
|
|
assert self.user is not None
|
|
|
|
return {
|
|
|
|
"type": "card_update",
|
|
|
|
"user_id": self.user.id,
|
|
|
|
}
|
|
|
|
|
2023-11-20 08:40:09 +01:00
|
|
|
@override
|
2023-11-24 07:29:06 +01:00
|
|
|
def get_upgrade_page_session_type_specific_context(
|
|
|
|
self,
|
|
|
|
) -> UpgradePageSessionTypeSpecificContext:
|
2023-11-20 08:40:09 +01:00
|
|
|
assert self.user is not None
|
2023-11-24 07:29:06 +01:00
|
|
|
return UpgradePageSessionTypeSpecificContext(
|
|
|
|
customer_name=self.realm.name,
|
2023-12-02 03:54:24 +01:00
|
|
|
email=self.get_email(),
|
2023-11-24 07:29:06 +01:00
|
|
|
is_demo_organization=self.realm.demo_organization_scheduled_deletion_date is not None,
|
|
|
|
demo_organization_scheduled_deletion_date=self.realm.demo_organization_scheduled_deletion_date,
|
|
|
|
is_self_hosting=False,
|
|
|
|
)
|
2023-11-20 08:40:09 +01:00
|
|
|
|
2023-11-23 07:29:03 +01:00
|
|
|
@override
|
2023-11-30 17:11:41 +01:00
|
|
|
def get_type_of_plan_tier_change(
|
|
|
|
self, current_plan_tier: int, new_plan_tier: int
|
|
|
|
) -> PlanTierChangeType:
|
|
|
|
valid_plan_tiers = [CustomerPlan.TIER_CLOUD_STANDARD, CustomerPlan.TIER_CLOUD_PLUS]
|
|
|
|
if (
|
|
|
|
current_plan_tier not in valid_plan_tiers
|
|
|
|
or new_plan_tier not in valid_plan_tiers
|
|
|
|
or current_plan_tier == new_plan_tier
|
|
|
|
):
|
|
|
|
return PlanTierChangeType.INVALID
|
|
|
|
if (
|
|
|
|
current_plan_tier == CustomerPlan.TIER_CLOUD_STANDARD
|
|
|
|
and new_plan_tier == CustomerPlan.TIER_CLOUD_PLUS
|
|
|
|
):
|
|
|
|
return PlanTierChangeType.UPGRADE
|
2023-11-23 07:29:03 +01:00
|
|
|
else: # nocoverage, not currently implemented
|
2023-11-30 07:43:06 +01:00
|
|
|
assert current_plan_tier == CustomerPlan.TIER_CLOUD_PLUS
|
2023-11-30 17:11:41 +01:00
|
|
|
assert new_plan_tier == CustomerPlan.TIER_CLOUD_STANDARD
|
|
|
|
return PlanTierChangeType.DOWNGRADE
|
2023-11-23 07:29:03 +01:00
|
|
|
|
2023-11-27 11:07:03 +01:00
|
|
|
@override
|
|
|
|
def has_billing_access(self) -> bool:
|
|
|
|
assert self.user is not None
|
|
|
|
return self.user.has_billing_access
|
|
|
|
|
2023-11-27 13:08:43 +01:00
|
|
|
@override
|
|
|
|
def on_paid_plan(self) -> bool:
|
2023-11-27 17:31:39 +01:00
|
|
|
return self.realm.plan_type in self.PAID_PLANS
|
2023-11-27 13:08:43 +01:00
|
|
|
|
|
|
|
@override
|
|
|
|
def add_sponsorship_info_to_context(self, context: Dict[str, Any]) -> None:
|
|
|
|
context.update(
|
|
|
|
realm_org_type=self.realm.org_type,
|
|
|
|
sorted_org_types=sorted(
|
|
|
|
(
|
|
|
|
[org_type_name, org_type]
|
|
|
|
for (org_type_name, org_type) in Realm.ORG_TYPES.items()
|
|
|
|
if not org_type.get("hidden")
|
|
|
|
),
|
2023-12-01 03:50:13 +01:00
|
|
|
key=sponsorship_org_type_key_helper,
|
2023-11-27 13:08:43 +01:00
|
|
|
),
|
|
|
|
)
|
2023-12-05 07:41:34 +01:00
|
|
|
context["org_name"] = self.realm.name
|
2023-11-27 13:08:43 +01:00
|
|
|
|
2023-11-30 01:48:46 +01:00
|
|
|
@override
|
|
|
|
def get_sponsorship_request_session_specific_context(
|
|
|
|
self,
|
|
|
|
) -> SponsorshipRequestSessionSpecificContext:
|
|
|
|
assert self.user is not None
|
|
|
|
return SponsorshipRequestSessionSpecificContext(
|
|
|
|
realm_user=self.user,
|
|
|
|
user_info=SponsorshipApplicantInfo(
|
|
|
|
name=self.user.full_name,
|
2023-12-02 03:54:24 +01:00
|
|
|
email=self.get_email(),
|
2023-11-30 01:48:46 +01:00
|
|
|
role=self.user.get_role_name(),
|
|
|
|
),
|
|
|
|
realm_string_id=self.realm.string_id,
|
|
|
|
)
|
|
|
|
|
|
|
|
@override
|
|
|
|
def save_org_type_from_request_sponsorship_session(self, org_type: int) -> None:
|
|
|
|
# TODO: Use the actions.py method for this.
|
|
|
|
if self.realm.org_type != org_type:
|
|
|
|
self.realm.org_type = org_type
|
|
|
|
self.realm.save(update_fields=["org_type"])
|
|
|
|
|
2021-02-12 08:19:30 +01:00
|
|
|
|
2023-11-09 20:40:42 +01:00
|
|
|
class RemoteRealmBillingSession(BillingSession): # nocoverage
|
|
|
|
def __init__(
|
2023-11-30 01:48:46 +01:00
|
|
|
self,
|
|
|
|
remote_realm: RemoteRealm,
|
|
|
|
support_staff: Optional[UserProfile] = None,
|
2023-11-09 20:40:42 +01:00
|
|
|
) -> None:
|
|
|
|
self.remote_realm = remote_realm
|
|
|
|
if support_staff is not None:
|
|
|
|
assert support_staff.is_staff
|
|
|
|
self.support_session = True
|
|
|
|
else:
|
|
|
|
self.support_session = False
|
|
|
|
|
2023-11-30 21:11:54 +01:00
|
|
|
@override
|
|
|
|
@property
|
|
|
|
def billing_entity_display_name(self) -> str:
|
|
|
|
return self.remote_realm.name
|
|
|
|
|
2023-11-09 20:40:42 +01:00
|
|
|
@override
|
|
|
|
@property
|
|
|
|
def billing_session_url(self) -> str:
|
2023-11-24 09:08:24 +01:00
|
|
|
return f"{settings.EXTERNAL_URI_SCHEME}{settings.SELF_HOSTING_MANAGEMENT_SUBDOMAIN}.{settings.EXTERNAL_HOST}/realm/{self.remote_realm.uuid}"
|
2023-11-09 20:40:42 +01:00
|
|
|
|
2023-12-01 04:18:58 +01:00
|
|
|
@override
|
|
|
|
@property
|
|
|
|
def billing_base_url(self) -> str:
|
|
|
|
return f"/realm/{self.remote_realm.uuid}"
|
|
|
|
|
2023-11-30 01:48:46 +01:00
|
|
|
@override
|
|
|
|
def support_url(self) -> str:
|
2023-12-06 15:00:23 +01:00
|
|
|
return build_support_url("remote_servers_support", self.remote_realm.server.hostname)
|
2023-11-30 01:48:46 +01:00
|
|
|
|
2023-11-09 20:40:42 +01:00
|
|
|
@override
|
|
|
|
def get_customer(self) -> Optional[Customer]:
|
|
|
|
return get_customer_by_remote_realm(self.remote_realm)
|
|
|
|
|
2023-12-02 03:54:24 +01:00
|
|
|
@override
|
|
|
|
def get_email(self) -> str:
|
|
|
|
# BUG: This is an email for the whole server. We probably
|
|
|
|
# need a separable field here.
|
|
|
|
return self.remote_realm.server.contact_email
|
|
|
|
|
2023-11-09 20:40:42 +01:00
|
|
|
@override
|
|
|
|
def current_count_for_billed_licenses(self) -> int:
|
2023-12-06 14:26:07 +01:00
|
|
|
remote_realm_counts = get_remote_realm_guest_and_non_guest_count(self.remote_realm)
|
|
|
|
return remote_realm_counts.non_guest_user_count + remote_realm_counts.guest_user_count
|
2023-11-09 20:40:42 +01:00
|
|
|
|
|
|
|
@override
|
|
|
|
def get_audit_log_event(self, event_type: AuditLogEventType) -> int:
|
|
|
|
if event_type is AuditLogEventType.STRIPE_CUSTOMER_CREATED:
|
|
|
|
return RemoteRealmAuditLog.STRIPE_CUSTOMER_CREATED
|
|
|
|
elif event_type is AuditLogEventType.STRIPE_CARD_CHANGED:
|
|
|
|
return RemoteRealmAuditLog.STRIPE_CARD_CHANGED
|
|
|
|
elif event_type is AuditLogEventType.CUSTOMER_PLAN_CREATED:
|
|
|
|
return RemoteRealmAuditLog.CUSTOMER_PLAN_CREATED
|
|
|
|
elif event_type is AuditLogEventType.DISCOUNT_CHANGED:
|
|
|
|
return RemoteRealmAuditLog.REMOTE_SERVER_DISCOUNT_CHANGED
|
|
|
|
elif event_type is AuditLogEventType.SPONSORSHIP_APPROVED:
|
|
|
|
return RemoteRealmAuditLog.REMOTE_SERVER_SPONSORSHIP_APPROVED
|
|
|
|
elif event_type is AuditLogEventType.SPONSORSHIP_PENDING_STATUS_CHANGED:
|
|
|
|
return RemoteRealmAuditLog.REMOTE_SERVER_SPONSORSHIP_PENDING_STATUS_CHANGED
|
2023-12-01 13:19:04 +01:00
|
|
|
elif event_type is AuditLogEventType.BILLING_MODALITY_CHANGED:
|
|
|
|
return RemoteRealmAuditLog.REMOTE_SERVER_BILLING_MODALITY_CHANGED
|
2023-12-04 23:20:49 +01:00
|
|
|
elif event_type is AuditLogEventType.BILLING_ENTITY_PLAN_TYPE_CHANGED:
|
|
|
|
return RemoteRealmAuditLog.REMOTE_SERVER_PLAN_TYPE_CHANGED
|
2023-12-08 02:59:35 +01:00
|
|
|
elif event_type is AuditLogEventType.CUSTOMER_SWITCHED_FROM_MONTHLY_TO_ANNUAL_PLAN:
|
|
|
|
return RemoteRealmAuditLog.CUSTOMER_SWITCHED_FROM_MONTHLY_TO_ANNUAL_PLAN
|
|
|
|
elif event_type is AuditLogEventType.CUSTOMER_SWITCHED_FROM_ANNUAL_TO_MONTHLY_PLAN:
|
|
|
|
return RemoteRealmAuditLog.CUSTOMER_SWITCHED_FROM_ANNUAL_TO_MONTHLY_PLAN
|
2023-11-09 20:40:42 +01:00
|
|
|
else:
|
|
|
|
raise BillingSessionAuditLogEventError(event_type)
|
|
|
|
|
|
|
|
@override
|
|
|
|
def write_to_audit_log(
|
|
|
|
self,
|
|
|
|
event_type: AuditLogEventType,
|
|
|
|
event_time: datetime,
|
|
|
|
*,
|
|
|
|
extra_data: Optional[Dict[str, Any]] = None,
|
|
|
|
) -> None:
|
2023-11-30 12:24:29 +01:00
|
|
|
# These audit logs don't use all the fields of `RemoteRealmAuditLog`:
|
|
|
|
#
|
|
|
|
# * remote_id is None because this is not synced from a remote table.
|
|
|
|
# * realm_id is None because we do not aim to store both remote_realm
|
|
|
|
# and the legacy realm_id field.
|
2023-11-09 20:40:42 +01:00
|
|
|
audit_log_event = self.get_audit_log_event(event_type)
|
2023-11-30 12:20:45 +01:00
|
|
|
log_data = {
|
|
|
|
"server": self.remote_realm.server,
|
|
|
|
"remote_realm": self.remote_realm,
|
|
|
|
"event_type": audit_log_event,
|
|
|
|
"event_time": event_time,
|
|
|
|
}
|
|
|
|
|
2023-11-09 20:40:42 +01:00
|
|
|
if extra_data:
|
2023-11-30 12:20:45 +01:00
|
|
|
log_data["extra_data"] = extra_data
|
|
|
|
|
|
|
|
RemoteRealmAuditLog.objects.create(**log_data)
|
2023-11-09 20:40:42 +01:00
|
|
|
|
|
|
|
@override
|
|
|
|
def get_data_for_stripe_customer(self) -> StripeCustomerData:
|
|
|
|
# Support requests do not set any stripe billing information.
|
|
|
|
assert self.support_session is False
|
|
|
|
metadata: Dict[str, Any] = {}
|
|
|
|
metadata["remote_realm_uuid"] = self.remote_realm.uuid
|
|
|
|
metadata["remote_realm_host"] = str(self.remote_realm.host)
|
|
|
|
realm_stripe_customer_data = StripeCustomerData(
|
|
|
|
description=str(self.remote_realm),
|
2023-12-02 03:54:24 +01:00
|
|
|
email=self.get_email(),
|
2023-11-09 20:40:42 +01:00
|
|
|
metadata=metadata,
|
|
|
|
)
|
|
|
|
return realm_stripe_customer_data
|
|
|
|
|
|
|
|
@override
|
|
|
|
def update_data_for_checkout_session_and_payment_intent(
|
|
|
|
self, metadata: Dict[str, Any]
|
|
|
|
) -> Dict[str, Any]:
|
|
|
|
# TODO: Figure out what this should do.
|
|
|
|
updated_metadata = dict(
|
|
|
|
**metadata,
|
|
|
|
)
|
|
|
|
return updated_metadata
|
|
|
|
|
|
|
|
@override
|
|
|
|
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(
|
|
|
|
remote_realm=self.remote_realm,
|
|
|
|
defaults={"stripe_customer_id": stripe_customer_id},
|
|
|
|
)
|
|
|
|
return customer
|
|
|
|
else:
|
|
|
|
customer, created = Customer.objects.update_or_create(
|
|
|
|
remote_realm=self.remote_realm, defaults=defaults
|
|
|
|
)
|
|
|
|
return customer
|
|
|
|
|
|
|
|
@override
|
|
|
|
def do_change_plan_type(self, *, tier: Optional[int], is_sponsored: bool = False) -> None:
|
|
|
|
if is_sponsored:
|
|
|
|
plan_type = RemoteRealm.PLAN_TYPE_COMMUNITY
|
2023-12-02 04:21:50 +01:00
|
|
|
elif tier == CustomerPlan.TIER_SELF_HOSTED_BUSINESS:
|
2023-11-09 20:40:42 +01:00
|
|
|
plan_type = RemoteRealm.PLAN_TYPE_BUSINESS
|
2023-11-30 07:43:06 +01:00
|
|
|
elif (
|
2023-12-02 04:21:50 +01:00
|
|
|
tier == CustomerPlan.TIER_SELF_HOSTED_PLUS
|
2023-11-30 07:43:06 +01:00
|
|
|
): # nocoverage # Plus plan doesn't use this code path yet.
|
2023-11-09 20:40:42 +01:00
|
|
|
plan_type = RemoteRealm.PLAN_TYPE_ENTERPRISE
|
|
|
|
else:
|
|
|
|
raise AssertionError("Unexpected tier")
|
|
|
|
|
2023-12-02 04:21:50 +01:00
|
|
|
# TODO: Audit logging and set usage limits.
|
2023-11-09 20:40:42 +01:00
|
|
|
|
|
|
|
self.remote_realm.plan_type = plan_type
|
|
|
|
self.remote_realm.save(update_fields=["plan_type"])
|
|
|
|
|
|
|
|
@override
|
2023-11-30 21:11:54 +01:00
|
|
|
def approve_sponsorship(self) -> str:
|
2023-12-02 18:21:04 +01:00
|
|
|
# Sponsorship approval is only a support admin action.
|
|
|
|
assert self.support_session
|
|
|
|
|
|
|
|
self.do_change_plan_type(tier=None, is_sponsored=True)
|
|
|
|
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()
|
|
|
|
)
|
2023-12-07 17:01:29 +01:00
|
|
|
emailed_string = ""
|
|
|
|
billing_emails = list(
|
|
|
|
RemoteRealmBillingUser.objects.filter(remote_realm_id=self.remote_realm.id).values_list(
|
|
|
|
"email", flat=True
|
|
|
|
)
|
|
|
|
)
|
|
|
|
if len(billing_emails) > 0:
|
|
|
|
send_email(
|
|
|
|
"zerver/emails/sponsorship_approved_community_plan",
|
|
|
|
to_emails=billing_emails,
|
|
|
|
from_address="sales@zulip.com",
|
|
|
|
context={
|
|
|
|
"billing_entity": self.billing_entity_display_name,
|
|
|
|
"plans_link": "https://zulip.com/plans/#self-hosted",
|
|
|
|
"link_to_zulip": "https://zulip.com/help/linking-to-zulip-website",
|
|
|
|
},
|
|
|
|
)
|
|
|
|
emailed_string = "Emailed existing billing users."
|
|
|
|
else:
|
|
|
|
emailed_string = "No billing users exist to email."
|
|
|
|
|
|
|
|
return f"Sponsorship approved for {self.billing_entity_display_name}; " + emailed_string
|
2023-11-09 20:40:42 +01:00
|
|
|
|
2023-11-27 13:25:11 +01:00
|
|
|
@override
|
|
|
|
def is_sponsored(self) -> bool:
|
|
|
|
return self.remote_realm.plan_type == self.remote_realm.PLAN_TYPE_COMMUNITY
|
|
|
|
|
2023-12-01 03:51:05 +01:00
|
|
|
@override
|
2023-12-02 09:05:20 +01:00
|
|
|
def get_metadata_for_stripe_update_card(self) -> Dict[str, Any]:
|
2023-12-01 03:51:05 +01:00
|
|
|
return {
|
|
|
|
"type": "card_update",
|
|
|
|
# TODO: Add user identity metadata from the remote realm identity
|
|
|
|
# "user_id": user.id,
|
|
|
|
}
|
|
|
|
|
2023-11-24 07:29:06 +01:00
|
|
|
@override
|
|
|
|
def get_upgrade_page_session_type_specific_context(
|
|
|
|
self,
|
|
|
|
) -> UpgradePageSessionTypeSpecificContext:
|
|
|
|
return UpgradePageSessionTypeSpecificContext(
|
|
|
|
customer_name=self.remote_realm.host,
|
2023-12-02 03:54:24 +01:00
|
|
|
email=self.get_email(),
|
2023-11-24 07:29:06 +01:00
|
|
|
is_demo_organization=False,
|
|
|
|
demo_organization_scheduled_deletion_date=None,
|
|
|
|
is_self_hosting=True,
|
|
|
|
)
|
|
|
|
|
2023-11-09 20:40:42 +01:00
|
|
|
@override
|
|
|
|
def process_downgrade(self, plan: CustomerPlan) -> None:
|
2023-12-04 23:20:49 +01:00
|
|
|
with transaction.atomic():
|
|
|
|
old_plan_type = self.remote_realm.plan_type
|
|
|
|
new_plan_type = RemoteRealm.PLAN_TYPE_SELF_HOSTED
|
|
|
|
self.remote_realm.plan_type = new_plan_type
|
|
|
|
self.remote_realm.save(update_fields=["plan_type"])
|
|
|
|
self.write_to_audit_log(
|
|
|
|
event_type=AuditLogEventType.BILLING_ENTITY_PLAN_TYPE_CHANGED,
|
|
|
|
event_time=timezone_now(),
|
|
|
|
extra_data={"old_value": old_plan_type, "new_value": new_plan_type},
|
|
|
|
)
|
2023-11-09 20:40:42 +01:00
|
|
|
|
|
|
|
plan.status = CustomerPlan.ENDED
|
|
|
|
plan.save(update_fields=["status"])
|
|
|
|
|
2023-11-23 07:29:03 +01:00
|
|
|
@override
|
2023-11-30 17:11:41 +01:00
|
|
|
def get_type_of_plan_tier_change(
|
|
|
|
self, current_plan_tier: int, new_plan_tier: int
|
|
|
|
) -> PlanTierChangeType:
|
2023-12-04 14:17:28 +01:00
|
|
|
valid_plan_tiers = [
|
|
|
|
CustomerPlan.TIER_SELF_HOSTED_BUSINESS,
|
|
|
|
CustomerPlan.TIER_SELF_HOSTED_PLUS,
|
|
|
|
]
|
|
|
|
if (
|
|
|
|
current_plan_tier not in valid_plan_tiers
|
|
|
|
or new_plan_tier not in valid_plan_tiers
|
|
|
|
or current_plan_tier == new_plan_tier
|
|
|
|
):
|
|
|
|
return PlanTierChangeType.INVALID
|
|
|
|
if (
|
|
|
|
current_plan_tier == CustomerPlan.TIER_SELF_HOSTED_BUSINESS
|
|
|
|
and new_plan_tier == CustomerPlan.TIER_SELF_HOSTED_PLUS
|
|
|
|
):
|
|
|
|
return PlanTierChangeType.UPGRADE
|
|
|
|
else:
|
|
|
|
assert current_plan_tier == CustomerPlan.TIER_SELF_HOSTED_PLUS
|
|
|
|
assert new_plan_tier == CustomerPlan.TIER_SELF_HOSTED_BUSINESS
|
|
|
|
return PlanTierChangeType.DOWNGRADE
|
2023-11-23 07:29:03 +01:00
|
|
|
|
2023-11-27 11:07:03 +01:00
|
|
|
@override
|
|
|
|
def has_billing_access(self) -> bool:
|
|
|
|
# We don't currently have a way to authenticate a remote
|
|
|
|
# session that isn't authorized for billing access.
|
|
|
|
return True
|
|
|
|
|
2023-12-01 10:43:04 +01:00
|
|
|
PAID_PLANS = [
|
|
|
|
RemoteRealm.PLAN_TYPE_BUSINESS,
|
|
|
|
RemoteRealm.PLAN_TYPE_ENTERPRISE,
|
|
|
|
]
|
|
|
|
|
2023-11-27 13:08:43 +01:00
|
|
|
@override
|
|
|
|
def on_paid_plan(self) -> bool:
|
2023-12-01 10:43:04 +01:00
|
|
|
return self.remote_realm.plan_type in self.PAID_PLANS
|
2023-11-27 13:08:43 +01:00
|
|
|
|
|
|
|
@override
|
|
|
|
def add_sponsorship_info_to_context(self, context: Dict[str, Any]) -> None:
|
2023-11-30 01:48:46 +01:00
|
|
|
context.update(
|
|
|
|
realm_org_type=self.remote_realm.org_type,
|
|
|
|
sorted_org_types=sorted(
|
|
|
|
(
|
|
|
|
[org_type_name, org_type]
|
|
|
|
for (org_type_name, org_type) in Realm.ORG_TYPES.items()
|
|
|
|
if not org_type.get("hidden")
|
|
|
|
),
|
2023-12-01 03:50:13 +01:00
|
|
|
key=sponsorship_org_type_key_helper,
|
2023-11-30 01:48:46 +01:00
|
|
|
),
|
|
|
|
)
|
2023-12-05 07:41:34 +01:00
|
|
|
context["org_name"] = self.remote_realm.host
|
2023-11-30 01:48:46 +01:00
|
|
|
|
|
|
|
@override
|
|
|
|
def get_sponsorship_request_session_specific_context(
|
|
|
|
self,
|
|
|
|
) -> SponsorshipRequestSessionSpecificContext:
|
|
|
|
return SponsorshipRequestSessionSpecificContext(
|
|
|
|
realm_user=None,
|
|
|
|
user_info=SponsorshipApplicantInfo(
|
|
|
|
# TODO: Plumb through the session data on the acting user.
|
|
|
|
name="Remote realm administrator",
|
2023-12-02 03:54:24 +01:00
|
|
|
email=self.get_email(),
|
2023-11-30 01:48:46 +01:00
|
|
|
# TODO: Set user_role when determining which set of users can access the page.
|
|
|
|
role="Remote realm administrator",
|
|
|
|
),
|
|
|
|
# TODO: Check if this works on support page.
|
|
|
|
realm_string_id=self.remote_realm.host,
|
|
|
|
)
|
|
|
|
|
|
|
|
@override
|
|
|
|
def save_org_type_from_request_sponsorship_session(self, org_type: int) -> None:
|
|
|
|
if self.remote_realm.org_type != org_type:
|
|
|
|
self.remote_realm.org_type = org_type
|
|
|
|
self.remote_realm.save(update_fields=["org_type"])
|
2023-11-27 13:08:43 +01:00
|
|
|
|
2023-11-09 20:40:42 +01:00
|
|
|
|
|
|
|
class RemoteServerBillingSession(BillingSession): # nocoverage
|
|
|
|
"""Billing session for pre-8.0 servers that do not yet support
|
|
|
|
creating RemoteRealm objects."""
|
|
|
|
|
|
|
|
def __init__(
|
2023-11-30 01:48:46 +01:00
|
|
|
self,
|
|
|
|
remote_server: RemoteZulipServer,
|
|
|
|
support_staff: Optional[UserProfile] = None,
|
2023-11-09 20:40:42 +01:00
|
|
|
) -> None:
|
|
|
|
self.remote_server = remote_server
|
|
|
|
if support_staff is not None:
|
|
|
|
assert support_staff.is_staff
|
|
|
|
self.support_session = True
|
|
|
|
else:
|
|
|
|
self.support_session = False
|
|
|
|
|
2023-11-30 21:11:54 +01:00
|
|
|
@override
|
|
|
|
@property
|
|
|
|
def billing_entity_display_name(self) -> str:
|
|
|
|
return self.remote_server.hostname
|
|
|
|
|
2023-11-09 20:40:42 +01:00
|
|
|
@override
|
|
|
|
@property
|
|
|
|
def billing_session_url(self) -> str:
|
2023-12-01 06:44:59 +01:00
|
|
|
return f"{settings.EXTERNAL_URI_SCHEME}{settings.SELF_HOSTING_MANAGEMENT_SUBDOMAIN}.{settings.EXTERNAL_HOST}/server/{self.remote_server.uuid}"
|
2023-11-09 20:40:42 +01:00
|
|
|
|
2023-12-01 04:18:58 +01:00
|
|
|
@override
|
|
|
|
@property
|
|
|
|
def billing_base_url(self) -> str:
|
2023-12-01 06:44:59 +01:00
|
|
|
return f"/server/{self.remote_server.uuid}"
|
2023-12-01 04:18:58 +01:00
|
|
|
|
2023-11-30 01:48:46 +01:00
|
|
|
@override
|
|
|
|
def support_url(self) -> str:
|
2023-12-06 15:00:23 +01:00
|
|
|
return build_support_url("remote_servers_support", self.remote_server.hostname)
|
2023-11-30 01:48:46 +01:00
|
|
|
|
2023-11-09 20:40:42 +01:00
|
|
|
@override
|
|
|
|
def get_customer(self) -> Optional[Customer]:
|
|
|
|
return get_customer_by_remote_server(self.remote_server)
|
|
|
|
|
2023-12-02 03:54:24 +01:00
|
|
|
@override
|
|
|
|
def get_email(self) -> str:
|
|
|
|
return self.remote_server.contact_email
|
|
|
|
|
2023-11-09 20:40:42 +01:00
|
|
|
@override
|
|
|
|
def current_count_for_billed_licenses(self) -> int:
|
2023-12-07 01:39:05 +01:00
|
|
|
remote_server_counts = get_remote_server_guest_and_non_guest_count(self.remote_server.id)
|
|
|
|
return remote_server_counts.non_guest_user_count + remote_server_counts.guest_user_count
|
2023-11-09 20:40:42 +01:00
|
|
|
|
|
|
|
@override
|
|
|
|
def get_audit_log_event(self, event_type: AuditLogEventType) -> int:
|
|
|
|
if event_type is AuditLogEventType.STRIPE_CUSTOMER_CREATED:
|
|
|
|
return RemoteZulipServerAuditLog.STRIPE_CUSTOMER_CREATED
|
|
|
|
elif event_type is AuditLogEventType.STRIPE_CARD_CHANGED:
|
|
|
|
return RemoteZulipServerAuditLog.STRIPE_CARD_CHANGED
|
|
|
|
elif event_type is AuditLogEventType.CUSTOMER_PLAN_CREATED:
|
|
|
|
return RemoteZulipServerAuditLog.CUSTOMER_PLAN_CREATED
|
|
|
|
elif event_type is AuditLogEventType.DISCOUNT_CHANGED:
|
|
|
|
return RemoteZulipServerAuditLog.REMOTE_SERVER_DISCOUNT_CHANGED
|
|
|
|
elif event_type is AuditLogEventType.SPONSORSHIP_APPROVED:
|
|
|
|
return RemoteZulipServerAuditLog.REMOTE_SERVER_SPONSORSHIP_APPROVED
|
|
|
|
elif event_type is AuditLogEventType.SPONSORSHIP_PENDING_STATUS_CHANGED:
|
|
|
|
return RemoteZulipServerAuditLog.REMOTE_SERVER_SPONSORSHIP_PENDING_STATUS_CHANGED
|
2023-12-01 13:19:04 +01:00
|
|
|
elif event_type is AuditLogEventType.BILLING_MODALITY_CHANGED:
|
|
|
|
return RemoteZulipServerAuditLog.REMOTE_SERVER_BILLING_MODALITY_CHANGED
|
2023-12-04 23:20:49 +01:00
|
|
|
elif event_type is AuditLogEventType.BILLING_ENTITY_PLAN_TYPE_CHANGED:
|
|
|
|
return RemoteZulipServerAuditLog.REMOTE_SERVER_PLAN_TYPE_CHANGED
|
2023-12-08 02:59:35 +01:00
|
|
|
elif event_type is AuditLogEventType.CUSTOMER_SWITCHED_FROM_MONTHLY_TO_ANNUAL_PLAN:
|
|
|
|
return RemoteZulipServerAuditLog.CUSTOMER_SWITCHED_FROM_MONTHLY_TO_ANNUAL_PLAN
|
|
|
|
elif event_type is AuditLogEventType.CUSTOMER_SWITCHED_FROM_ANNUAL_TO_MONTHLY_PLAN:
|
|
|
|
return RemoteZulipServerAuditLog.CUSTOMER_SWITCHED_FROM_ANNUAL_TO_MONTHLY_PLAN
|
2023-11-09 20:40:42 +01:00
|
|
|
else:
|
|
|
|
raise BillingSessionAuditLogEventError(event_type)
|
|
|
|
|
|
|
|
@override
|
|
|
|
def write_to_audit_log(
|
|
|
|
self,
|
|
|
|
event_type: AuditLogEventType,
|
|
|
|
event_time: datetime,
|
|
|
|
*,
|
|
|
|
extra_data: Optional[Dict[str, Any]] = None,
|
|
|
|
) -> None:
|
|
|
|
audit_log_event = self.get_audit_log_event(event_type)
|
2023-11-30 12:20:45 +01:00
|
|
|
log_data = {
|
|
|
|
"server": self.remote_server,
|
|
|
|
"event_type": audit_log_event,
|
|
|
|
"event_time": event_time,
|
|
|
|
}
|
|
|
|
|
2023-11-09 20:40:42 +01:00
|
|
|
if extra_data:
|
2023-11-30 12:20:45 +01:00
|
|
|
log_data["extra_data"] = extra_data
|
|
|
|
|
|
|
|
RemoteZulipServerAuditLog.objects.create(**log_data)
|
2023-11-09 20:40:42 +01:00
|
|
|
|
|
|
|
@override
|
|
|
|
def get_data_for_stripe_customer(self) -> StripeCustomerData:
|
|
|
|
# Support requests do not set any stripe billing information.
|
|
|
|
assert self.support_session is False
|
|
|
|
metadata: Dict[str, Any] = {}
|
|
|
|
metadata["remote_server_uuid"] = self.remote_server.uuid
|
|
|
|
metadata["remote_server_str"] = str(self.remote_server)
|
|
|
|
realm_stripe_customer_data = StripeCustomerData(
|
|
|
|
description=str(self.remote_server),
|
2023-12-02 03:54:24 +01:00
|
|
|
email=self.get_email(),
|
2023-11-09 20:40:42 +01:00
|
|
|
metadata=metadata,
|
|
|
|
)
|
|
|
|
return realm_stripe_customer_data
|
|
|
|
|
|
|
|
@override
|
|
|
|
def update_data_for_checkout_session_and_payment_intent(
|
|
|
|
self, metadata: Dict[str, Any]
|
|
|
|
) -> Dict[str, Any]:
|
|
|
|
updated_metadata = dict(
|
|
|
|
server=self.remote_server,
|
2023-12-02 03:54:24 +01:00
|
|
|
email=self.get_email(),
|
2023-11-09 20:40:42 +01:00
|
|
|
**metadata,
|
|
|
|
)
|
|
|
|
return updated_metadata
|
|
|
|
|
|
|
|
@override
|
|
|
|
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(
|
|
|
|
remote_server=self.remote_server,
|
|
|
|
defaults={"stripe_customer_id": stripe_customer_id},
|
|
|
|
)
|
|
|
|
return customer
|
|
|
|
else:
|
|
|
|
customer, created = Customer.objects.update_or_create(
|
|
|
|
remote_server=self.remote_server, defaults=defaults
|
|
|
|
)
|
|
|
|
return customer
|
|
|
|
|
|
|
|
@override
|
|
|
|
def do_change_plan_type(self, *, tier: Optional[int], is_sponsored: bool = False) -> None:
|
|
|
|
# TODO: Create actual plan types.
|
|
|
|
|
|
|
|
# This function needs to translate between the different
|
|
|
|
# formats of CustomerPlan.tier and RealmZulipServer.plan_type.
|
|
|
|
if is_sponsored:
|
|
|
|
plan_type = RemoteZulipServer.PLAN_TYPE_COMMUNITY
|
2023-12-02 04:21:50 +01:00
|
|
|
elif tier == CustomerPlan.TIER_SELF_HOSTED_BUSINESS:
|
2023-11-09 20:40:42 +01:00
|
|
|
plan_type = RemoteZulipServer.PLAN_TYPE_BUSINESS
|
2023-11-30 07:43:06 +01:00
|
|
|
elif (
|
2023-12-02 04:21:50 +01:00
|
|
|
tier == CustomerPlan.TIER_SELF_HOSTED_PLUS
|
2023-11-30 07:43:06 +01:00
|
|
|
): # nocoverage # Plus plan doesn't use this code path yet.
|
2023-11-09 20:40:42 +01:00
|
|
|
plan_type = RemoteZulipServer.PLAN_TYPE_ENTERPRISE
|
|
|
|
else:
|
|
|
|
raise AssertionError("Unexpected tier")
|
|
|
|
|
2023-12-02 04:21:50 +01:00
|
|
|
# TODO: Audit logging and set usage limits.
|
2023-11-09 20:40:42 +01:00
|
|
|
|
|
|
|
self.remote_server.plan_type = plan_type
|
|
|
|
self.remote_server.save(update_fields=["plan_type"])
|
|
|
|
|
|
|
|
@override
|
2023-11-30 21:11:54 +01:00
|
|
|
def approve_sponsorship(self) -> str:
|
2023-12-02 18:21:04 +01:00
|
|
|
# Sponsorship approval is only a support admin action.
|
|
|
|
assert self.support_session
|
|
|
|
|
|
|
|
self.do_change_plan_type(tier=None, is_sponsored=True)
|
|
|
|
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()
|
|
|
|
)
|
2023-12-07 17:01:29 +01:00
|
|
|
# TODO: Use the emails in RemoteZulipServerBillingUser for this email.
|
|
|
|
send_email(
|
|
|
|
"zerver/emails/sponsorship_approved_community_plan",
|
|
|
|
to_emails=[self.remote_server.contact_email],
|
|
|
|
from_address="sales@zulip.com",
|
|
|
|
context={
|
|
|
|
"billing_entity": self.billing_entity_display_name,
|
|
|
|
"plans_link": "https://zulip.com/plans/#self-hosted",
|
|
|
|
"link_to_zulip": "https://zulip.com/help/linking-to-zulip-website",
|
|
|
|
},
|
|
|
|
)
|
2023-12-02 18:21:04 +01:00
|
|
|
return f"Sponsorship approved for {self.billing_entity_display_name}"
|
2023-11-09 20:40:42 +01:00
|
|
|
|
|
|
|
@override
|
|
|
|
def process_downgrade(self, plan: CustomerPlan) -> None:
|
2023-12-04 23:20:49 +01:00
|
|
|
with transaction.atomic():
|
|
|
|
old_plan_type = self.remote_server.plan_type
|
|
|
|
new_plan_type = RemoteZulipServer.PLAN_TYPE_SELF_HOSTED
|
|
|
|
self.remote_server.plan_type = new_plan_type
|
|
|
|
self.remote_server.save(update_fields=["plan_type"])
|
|
|
|
self.write_to_audit_log(
|
|
|
|
event_type=AuditLogEventType.BILLING_ENTITY_PLAN_TYPE_CHANGED,
|
|
|
|
event_time=timezone_now(),
|
|
|
|
extra_data={"old_value": old_plan_type, "new_value": new_plan_type},
|
|
|
|
)
|
2023-11-09 20:40:42 +01:00
|
|
|
|
|
|
|
plan.status = CustomerPlan.ENDED
|
|
|
|
plan.save(update_fields=["status"])
|
|
|
|
|
2023-11-27 13:25:11 +01:00
|
|
|
@override
|
|
|
|
def is_sponsored(self) -> bool:
|
|
|
|
return self.remote_server.plan_type == self.remote_server.PLAN_TYPE_COMMUNITY
|
|
|
|
|
2023-12-01 03:51:05 +01:00
|
|
|
@override
|
2023-12-02 09:05:20 +01:00
|
|
|
def get_metadata_for_stripe_update_card(self) -> Dict[str, Any]:
|
2023-12-01 03:51:05 +01:00
|
|
|
return {
|
|
|
|
"type": "card_update",
|
|
|
|
# TODO: Maybe add some user identity metadata from the remote server identity
|
|
|
|
# "user_id": user.id,
|
|
|
|
}
|
|
|
|
|
2023-11-20 08:40:09 +01:00
|
|
|
@override
|
2023-11-24 07:29:06 +01:00
|
|
|
def get_upgrade_page_session_type_specific_context(
|
|
|
|
self,
|
|
|
|
) -> UpgradePageSessionTypeSpecificContext:
|
|
|
|
return UpgradePageSessionTypeSpecificContext(
|
|
|
|
customer_name=self.remote_server.hostname,
|
2023-12-02 03:54:24 +01:00
|
|
|
email=self.get_email(),
|
2023-11-24 07:29:06 +01:00
|
|
|
is_demo_organization=False,
|
|
|
|
demo_organization_scheduled_deletion_date=None,
|
|
|
|
is_self_hosting=True,
|
|
|
|
)
|
2023-11-20 08:40:09 +01:00
|
|
|
|
2023-11-23 07:29:03 +01:00
|
|
|
@override
|
2023-11-30 17:11:41 +01:00
|
|
|
def get_type_of_plan_tier_change(
|
|
|
|
self, current_plan_tier: int, new_plan_tier: int
|
|
|
|
) -> PlanTierChangeType:
|
2023-12-04 14:17:28 +01:00
|
|
|
valid_plan_tiers = [
|
|
|
|
CustomerPlan.TIER_SELF_HOSTED_LEGACY,
|
|
|
|
CustomerPlan.TIER_SELF_HOSTED_BUSINESS,
|
|
|
|
CustomerPlan.TIER_SELF_HOSTED_PLUS,
|
|
|
|
]
|
|
|
|
if (
|
|
|
|
current_plan_tier not in valid_plan_tiers
|
|
|
|
or new_plan_tier not in valid_plan_tiers
|
|
|
|
or current_plan_tier == new_plan_tier
|
|
|
|
):
|
|
|
|
return PlanTierChangeType.INVALID
|
|
|
|
|
|
|
|
if current_plan_tier == CustomerPlan.TIER_SELF_HOSTED_LEGACY and new_plan_tier in (
|
|
|
|
CustomerPlan.TIER_SELF_HOSTED_BUSINESS,
|
|
|
|
CustomerPlan.TIER_SELF_HOSTED_PLUS,
|
|
|
|
):
|
|
|
|
return PlanTierChangeType.UPGRADE
|
|
|
|
elif (
|
|
|
|
current_plan_tier == CustomerPlan.TIER_SELF_HOSTED_BUSINESS
|
|
|
|
and new_plan_tier == CustomerPlan.TIER_SELF_HOSTED_PLUS
|
|
|
|
):
|
|
|
|
return PlanTierChangeType.UPGRADE
|
|
|
|
elif (
|
|
|
|
current_plan_tier == CustomerPlan.TIER_SELF_HOSTED_BUSINESS
|
|
|
|
and new_plan_tier == CustomerPlan.TIER_SELF_HOSTED_LEGACY
|
|
|
|
):
|
|
|
|
return PlanTierChangeType.DOWNGRADE
|
|
|
|
else:
|
|
|
|
assert current_plan_tier == CustomerPlan.TIER_SELF_HOSTED_PLUS
|
|
|
|
assert new_plan_tier == CustomerPlan.TIER_SELF_HOSTED_BUSINESS
|
|
|
|
return PlanTierChangeType.DOWNGRADE
|
2023-11-23 07:29:03 +01:00
|
|
|
|
2023-11-27 11:07:03 +01:00
|
|
|
@override
|
|
|
|
def has_billing_access(self) -> bool:
|
|
|
|
# We don't currently have a way to authenticate a remote
|
|
|
|
# session that isn't authorized for billing access.
|
|
|
|
return True
|
|
|
|
|
2023-12-01 10:43:04 +01:00
|
|
|
PAID_PLANS = [
|
|
|
|
RemoteZulipServer.PLAN_TYPE_BUSINESS,
|
|
|
|
RemoteZulipServer.PLAN_TYPE_ENTERPRISE,
|
|
|
|
]
|
|
|
|
|
2023-11-27 13:08:43 +01:00
|
|
|
@override
|
|
|
|
def on_paid_plan(self) -> bool:
|
2023-12-01 10:43:04 +01:00
|
|
|
return self.remote_server.plan_type in self.PAID_PLANS
|
2023-11-27 13:08:43 +01:00
|
|
|
|
|
|
|
@override
|
|
|
|
def add_sponsorship_info_to_context(self, context: Dict[str, Any]) -> None:
|
2023-11-30 01:48:46 +01:00
|
|
|
context.update(
|
|
|
|
realm_org_type=self.remote_server.org_type,
|
|
|
|
sorted_org_types=sorted(
|
|
|
|
(
|
|
|
|
[org_type_name, org_type]
|
|
|
|
for (org_type_name, org_type) in Realm.ORG_TYPES.items()
|
|
|
|
if not org_type.get("hidden")
|
|
|
|
),
|
2023-12-01 03:50:13 +01:00
|
|
|
key=sponsorship_org_type_key_helper,
|
2023-11-30 01:48:46 +01:00
|
|
|
),
|
|
|
|
)
|
2023-12-05 07:41:34 +01:00
|
|
|
context["org_name"] = self.remote_server.hostname
|
2023-11-30 01:48:46 +01:00
|
|
|
|
|
|
|
@override
|
|
|
|
def get_sponsorship_request_session_specific_context(
|
|
|
|
self,
|
|
|
|
) -> SponsorshipRequestSessionSpecificContext:
|
|
|
|
return SponsorshipRequestSessionSpecificContext(
|
|
|
|
realm_user=None,
|
|
|
|
user_info=SponsorshipApplicantInfo(
|
|
|
|
# TODO: Figure out a better story here. We don't
|
|
|
|
# actually have a name or other details on the person
|
|
|
|
# doing this flow, but could ask for it in the login
|
|
|
|
# form if desired.
|
|
|
|
name="Remote server administrator",
|
2023-12-02 03:54:24 +01:00
|
|
|
email=self.get_email(),
|
2023-11-30 01:48:46 +01:00
|
|
|
role="Remote server administrator",
|
|
|
|
),
|
|
|
|
# TODO: Check if this works on support page.
|
|
|
|
realm_string_id=self.remote_server.hostname,
|
|
|
|
)
|
|
|
|
|
|
|
|
@override
|
|
|
|
def save_org_type_from_request_sponsorship_session(self, org_type: int) -> None:
|
|
|
|
if self.remote_server.org_type != org_type:
|
|
|
|
self.remote_server.org_type = org_type
|
|
|
|
self.remote_server.save(update_fields=["org_type"])
|
2023-11-27 13:08:43 +01:00
|
|
|
|
2023-12-04 14:03:24 +01:00
|
|
|
def add_server_to_legacy_plan(
|
|
|
|
self,
|
|
|
|
renewal_date: datetime,
|
|
|
|
end_date: datetime,
|
|
|
|
) -> None:
|
|
|
|
# Set stripe_customer_id to None to avoid customer being charged without a payment method.
|
|
|
|
customer = Customer.objects.create(
|
|
|
|
remote_server=self.remote_server, stripe_customer_id=None
|
|
|
|
)
|
|
|
|
|
|
|
|
# Servers on legacy plan which are scheduled to be upgraded have 2 plans.
|
|
|
|
# This plan will be used to track the current status of SWITCH_PLAN_TIER_AT_PLAN_END
|
|
|
|
# and will not charge the customer. The other plan will be used to track the new plan
|
|
|
|
# customer will move to the end of this plan.
|
|
|
|
legacy_plan_anchor = renewal_date
|
|
|
|
legacy_plan_params = {
|
|
|
|
"billing_cycle_anchor": legacy_plan_anchor,
|
|
|
|
"status": CustomerPlan.ACTIVE,
|
|
|
|
"tier": CustomerPlan.TIER_SELF_HOSTED_LEGACY,
|
|
|
|
# End when the new plan starts.
|
|
|
|
"end_date": end_date,
|
|
|
|
# The primary mechanism for preventing charges under this
|
|
|
|
# plan is setting a null `next_invoice_date`, but setting
|
|
|
|
# a 0 price is useful defense in depth here.
|
|
|
|
"next_invoice_date": None,
|
|
|
|
"price_per_license": 0,
|
|
|
|
"billing_schedule": CustomerPlan.BILLING_SCHEDULE_ANNUAL,
|
|
|
|
"automanage_licenses": True,
|
|
|
|
}
|
|
|
|
legacy_plan = CustomerPlan.objects.create(
|
|
|
|
customer=customer,
|
|
|
|
**legacy_plan_params,
|
|
|
|
)
|
|
|
|
|
|
|
|
# Create a ledger entry for the legacy plan for tracking purposes.
|
|
|
|
billed_licenses = self.current_count_for_billed_licenses()
|
|
|
|
ledger_entry = LicenseLedger.objects.create(
|
|
|
|
plan=legacy_plan,
|
|
|
|
is_renewal=True,
|
|
|
|
event_time=legacy_plan_anchor,
|
|
|
|
licenses=billed_licenses,
|
|
|
|
licenses_at_next_renewal=billed_licenses,
|
|
|
|
)
|
|
|
|
legacy_plan.invoiced_through = ledger_entry
|
|
|
|
legacy_plan.save(update_fields=["invoiced_through"])
|
|
|
|
self.write_to_audit_log(
|
|
|
|
event_type=AuditLogEventType.CUSTOMER_PLAN_CREATED,
|
|
|
|
event_time=legacy_plan_anchor,
|
|
|
|
extra_data=legacy_plan_params,
|
|
|
|
)
|
|
|
|
|
2023-11-09 20:40:42 +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:
|
2023-11-14 21:48:14 +01:00
|
|
|
assert stripe_customer.invoice_settings is not None
|
2021-08-29 15:33:29 +02:00
|
|
|
if not stripe_customer.invoice_settings.default_payment_method:
|
2020-10-14 12:17:03 +02:00
|
|
|
return False
|
2023-11-14 21:48:14 +01:00
|
|
|
assert isinstance(stripe_customer.invoice_settings.default_payment_method, stripe.PaymentMethod)
|
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
|
|
|
|
|
|
|
|
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:
|
2023-12-01 12:29:24 +01:00
|
|
|
price_map: Dict[int, Dict[str, int]] = {
|
|
|
|
CustomerPlan.TIER_CLOUD_STANDARD: {"Annual": 8000, "Monthly": 800},
|
|
|
|
CustomerPlan.TIER_CLOUD_PLUS: {"Annual": 16000, "Monthly": 1600},
|
2023-12-01 12:47:09 +01:00
|
|
|
# Placeholder self-hosted plan for development.
|
|
|
|
CustomerPlan.TIER_SELF_HOSTED_BUSINESS: {"Annual": 8000, "Monthly": 800},
|
2023-12-01 12:29:24 +01:00
|
|
|
}
|
2021-09-15 13:54:56 +02:00
|
|
|
|
2023-12-01 12:29:24 +01:00
|
|
|
try:
|
|
|
|
price_per_license = price_map[tier][CustomerPlan.BILLING_SCHEDULES[billing_schedule]]
|
|
|
|
except KeyError:
|
|
|
|
if tier not in price_map:
|
|
|
|
raise InvalidTierError(tier)
|
2021-09-15 13:54:56 +02:00
|
|
|
else: # nocoverage
|
2022-11-17 09:30:48 +01:00
|
|
|
raise InvalidBillingScheduleError(billing_schedule)
|
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,
|
2023-12-04 14:20:08 +01:00
|
|
|
billing_cycle_anchor: Optional[datetime] = None,
|
2021-02-12 08:19:30 +01:00
|
|
|
) -> 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?
|
2023-12-04 14:20:08 +01:00
|
|
|
if billing_cycle_anchor is None:
|
|
|
|
billing_cycle_anchor = timezone_now().replace(microsecond=0)
|
|
|
|
|
2023-11-30 07:55:53 +01:00
|
|
|
if billing_schedule == CustomerPlan.BILLING_SCHEDULE_ANNUAL:
|
2018-12-15 09:33:25 +01:00
|
|
|
period_end = add_months(billing_cycle_anchor, 12)
|
2023-11-30 07:55:53 +01:00
|
|
|
elif billing_schedule == CustomerPlan.BILLING_SCHEDULE_MONTHLY:
|
2018-12-15 09:33:25 +01:00
|
|
|
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(),
|
|
|
|
)
|
|
|
|
|
|
|
|
|
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
|
|
|
|
|
|
|
|
|
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()
|
2023-11-30 14:49:10 +01:00
|
|
|
# TODO: Add RemoteRealmBillingSession and RemoteServerBillingSession cases.
|
2019-01-28 22:57:29 +01:00
|
|
|
for plan in CustomerPlan.objects.filter(next_invoice_date__lte=event_time):
|
2023-11-30 14:49:10 +01:00
|
|
|
if plan.customer.realm is not None:
|
|
|
|
RealmBillingSession(realm=plan.customer.realm).invoice_plan(plan, event_time)
|
2023-12-04 14:03:24 +01:00
|
|
|
# TODO: Assert that we never invoice legacy plans.
|
2019-01-28 22:57:29 +01:00
|
|
|
|
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()
|
|
|
|
|
|
|
|
|
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
|
|
|
|
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
|
|
|
|
)
|
|
|
|
|
|
|
|
|
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.
|
2023-11-22 12:44:02 +01:00
|
|
|
billing_session = RealmBillingSession(user=None, realm=realm)
|
|
|
|
billing_session.downgrade_now_without_creating_additional_invoices()
|
2023-12-01 17:48:41 +01:00
|
|
|
billing_session.void_all_open_invoices()
|
2021-08-03 17:44:55 +02:00
|
|
|
context: Dict[str, Union[str, Realm]] = {
|
2023-11-23 09:31:46 +01:00
|
|
|
"upgrade_url": f"{realm.uri}{reverse('upgrade_page')}",
|
2021-07-16 17:13:49 +02:00
|
|
|
"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):
|
2023-12-01 17:48:41 +01:00
|
|
|
# If a small realm, without an active plan, has
|
|
|
|
# the last invoice open, void the open invoices.
|
|
|
|
billing_session = RealmBillingSession(user=None, realm=realm)
|
|
|
|
billing_session.void_all_open_invoices()
|