2018-09-25 12:24:11 +02:00
|
|
|
import logging
|
2020-03-23 13:35:04 +01:00
|
|
|
from decimal import Decimal
|
2020-06-15 23:22:24 +02:00
|
|
|
from typing import Any, Dict, Optional, Union
|
2020-06-09 12:24:32 +02:00
|
|
|
from urllib.parse import urlencode, urljoin, urlunsplit
|
2018-09-25 12:24:11 +02:00
|
|
|
|
2020-06-11 00:54:34 +02:00
|
|
|
import stripe
|
|
|
|
from django.conf import settings
|
2018-09-25 12:24:11 +02:00
|
|
|
from django.core import signing
|
|
|
|
from django.http import HttpRequest, HttpResponse, HttpResponseRedirect
|
2019-02-02 23:53:22 +01:00
|
|
|
from django.shortcuts import render
|
2018-09-25 12:24:11 +02:00
|
|
|
from django.urls import reverse
|
2020-06-11 00:54:34 +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 _
|
2018-09-25 12:24:11 +02:00
|
|
|
|
2020-06-11 00:54:34 +02:00
|
|
|
from corporate.lib.stripe import (
|
|
|
|
DEFAULT_INVOICE_DAYS_UNTIL_DUE,
|
|
|
|
MIN_INVOICED_LICENSES,
|
|
|
|
STRIPE_PUBLISHABLE_KEY,
|
|
|
|
BillingError,
|
2020-10-20 15:46:04 +02:00
|
|
|
cents_to_dollar_string,
|
2020-06-11 00:54:34 +02:00
|
|
|
do_change_plan_status,
|
|
|
|
do_replace_payment_source,
|
2020-08-13 13:06:05 +02:00
|
|
|
downgrade_at_the_end_of_billing_cycle,
|
2020-08-13 10:22:58 +02:00
|
|
|
downgrade_now_without_creating_additional_invoices,
|
2020-06-11 00:54:34 +02:00
|
|
|
get_latest_seat_count,
|
2020-10-14 18:45:57 +02:00
|
|
|
is_sponsored_realm,
|
2020-06-11 00:54:34 +02:00
|
|
|
make_end_of_cycle_updates_if_needed,
|
|
|
|
process_initial_upgrade,
|
|
|
|
renewal_amount,
|
|
|
|
sign_string,
|
|
|
|
start_of_next_billing_cycle,
|
|
|
|
stripe_get_customer,
|
|
|
|
unsign_string,
|
2020-12-23 17:08:27 +01:00
|
|
|
update_license_ledger_for_manual_plan,
|
2020-06-09 12:24:32 +02:00
|
|
|
update_sponsorship_status,
|
2020-12-17 16:33:19 +01:00
|
|
|
validate_licenses,
|
2020-06-11 00:54:34 +02:00
|
|
|
)
|
|
|
|
from corporate.models import (
|
|
|
|
CustomerPlan,
|
|
|
|
get_current_plan_by_customer,
|
|
|
|
get_current_plan_by_realm,
|
|
|
|
get_customer_by_realm,
|
|
|
|
)
|
2020-07-15 22:18:32 +02:00
|
|
|
from zerver.decorator import (
|
|
|
|
require_billing_access,
|
|
|
|
require_organization_member,
|
|
|
|
zulip_login_required,
|
|
|
|
)
|
2021-05-28 12:36:41 +02:00
|
|
|
from zerver.lib.actions import do_make_user_billing_admin
|
2021-06-30 18:35:50 +02:00
|
|
|
from zerver.lib.exceptions import JsonableError
|
2018-09-25 12:24:11 +02:00
|
|
|
from zerver.lib.request import REQ, has_request_variables
|
2021-07-04 08:19:18 +02:00
|
|
|
from zerver.lib.response import json_success
|
2020-06-09 12:24:32 +02:00
|
|
|
from zerver.lib.send_email import FromAddress, send_email
|
2021-04-14 15:50:40 +02:00
|
|
|
from zerver.lib.validator import check_int, check_int_in, check_string_in
|
2020-06-09 12:24:32 +02:00
|
|
|
from zerver.models import UserProfile, get_realm
|
2018-09-25 12:24:11 +02:00
|
|
|
|
2021-02-12 08:20:45 +01:00
|
|
|
billing_logger = logging.getLogger("corporate.stripe")
|
2018-09-25 12:24:11 +02:00
|
|
|
|
2021-04-09 12:31:07 +02:00
|
|
|
VALID_BILLING_MODALITY_VALUES = ["send_invoice", "charge_automatically"]
|
2021-04-09 12:36:46 +02:00
|
|
|
VALID_BILLING_SCHEDULE_VALUES = ["annual", "monthly"]
|
2021-04-09 11:14:05 +02:00
|
|
|
VALID_LICENSE_MANAGEMENT_VALUES = ["automatic", "manual"]
|
|
|
|
|
2021-02-12 08:19:30 +01:00
|
|
|
|
2018-12-22 05:29:25 +01:00
|
|
|
def unsign_seat_count(signed_seat_count: str, salt: str) -> int:
|
2018-09-25 12:24:11 +02:00
|
|
|
try:
|
2018-12-22 05:29:25 +01:00
|
|
|
return int(unsign_string(signed_seat_count, salt))
|
2018-09-25 12:24:11 +02:00
|
|
|
except signing.BadSignature:
|
2021-02-12 08:20:45 +01:00
|
|
|
raise BillingError("tampered seat count")
|
2018-12-22 05:29:25 +01:00
|
|
|
|
2021-02-12 08:19:30 +01:00
|
|
|
|
2018-12-22 05:29:25 +01:00
|
|
|
def check_upgrade_parameters(
|
2021-02-12 08:19:30 +01:00
|
|
|
billing_modality: str,
|
|
|
|
schedule: str,
|
|
|
|
license_management: Optional[str],
|
|
|
|
licenses: Optional[int],
|
|
|
|
has_stripe_token: bool,
|
|
|
|
seat_count: int,
|
|
|
|
) -> None:
|
2021-05-07 18:51:15 +02:00
|
|
|
if billing_modality not in VALID_BILLING_MODALITY_VALUES: # nocoverage
|
2021-02-12 08:20:45 +01:00
|
|
|
raise BillingError("unknown billing_modality")
|
2021-05-07 18:51:15 +02:00
|
|
|
if schedule not in VALID_BILLING_SCHEDULE_VALUES: # nocoverage
|
2021-02-12 08:20:45 +01:00
|
|
|
raise BillingError("unknown schedule")
|
2021-05-07 18:51:15 +02:00
|
|
|
if license_management not in VALID_LICENSE_MANAGEMENT_VALUES: # nocoverage
|
2021-02-12 08:20:45 +01:00
|
|
|
raise BillingError("unknown license_management")
|
|
|
|
|
2020-12-17 16:33:19 +01:00
|
|
|
charge_automatically = False
|
2021-02-12 08:20:45 +01:00
|
|
|
if billing_modality == "charge_automatically":
|
2020-12-17 16:33:19 +01:00
|
|
|
charge_automatically = True
|
2018-12-22 05:29:25 +01:00
|
|
|
if not has_stripe_token:
|
2021-02-12 08:20:45 +01:00
|
|
|
raise BillingError("autopay with no card")
|
2018-12-22 05:29:25 +01:00
|
|
|
|
2020-12-17 16:33:19 +01:00
|
|
|
validate_licenses(charge_automatically, licenses, seat_count)
|
2020-05-08 12:43:52 +02:00
|
|
|
|
2021-02-12 08:19:30 +01:00
|
|
|
|
2018-12-23 09:10:57 +01:00
|
|
|
# Should only be called if the customer is being charged automatically
|
|
|
|
def payment_method_string(stripe_customer: stripe.Customer) -> str:
|
python: Convert assignment type annotations to Python 3.6 style.
This commit was split by tabbott; this piece covers the vast majority
of files in Zulip, but excludes scripts/, tools/, and puppet/ to help
ensure we at least show the right error messages for Xenial systems.
We can likely further refine the remaining pieces with some testing.
Generated by com2ann, with whitespace fixes and various manual fixes
for runtime issues:
- invoiced_through: Optional[LicenseLedger] = models.ForeignKey(
+ invoiced_through: Optional["LicenseLedger"] = models.ForeignKey(
-_apns_client: Optional[APNsClient] = None
+_apns_client: Optional["APNsClient"] = None
- notifications_stream: Optional[Stream] = models.ForeignKey('Stream', related_name='+', null=True, blank=True, on_delete=CASCADE)
- signup_notifications_stream: Optional[Stream] = models.ForeignKey('Stream', related_name='+', null=True, blank=True, on_delete=CASCADE)
+ notifications_stream: Optional["Stream"] = models.ForeignKey('Stream', related_name='+', null=True, blank=True, on_delete=CASCADE)
+ signup_notifications_stream: Optional["Stream"] = models.ForeignKey('Stream', related_name='+', null=True, blank=True, on_delete=CASCADE)
- author: Optional[UserProfile] = models.ForeignKey('UserProfile', blank=True, null=True, on_delete=CASCADE)
+ author: Optional["UserProfile"] = models.ForeignKey('UserProfile', blank=True, null=True, on_delete=CASCADE)
- bot_owner: Optional[UserProfile] = models.ForeignKey('self', null=True, on_delete=models.SET_NULL)
+ bot_owner: Optional["UserProfile"] = models.ForeignKey('self', null=True, on_delete=models.SET_NULL)
- default_sending_stream: Optional[Stream] = models.ForeignKey('zerver.Stream', null=True, related_name='+', on_delete=CASCADE)
- default_events_register_stream: Optional[Stream] = models.ForeignKey('zerver.Stream', null=True, related_name='+', on_delete=CASCADE)
+ default_sending_stream: Optional["Stream"] = models.ForeignKey('zerver.Stream', null=True, related_name='+', on_delete=CASCADE)
+ default_events_register_stream: Optional["Stream"] = models.ForeignKey('zerver.Stream', null=True, related_name='+', on_delete=CASCADE)
-descriptors_by_handler_id: Dict[int, ClientDescriptor] = {}
+descriptors_by_handler_id: Dict[int, "ClientDescriptor"] = {}
-worker_classes: Dict[str, Type[QueueProcessingWorker]] = {}
-queues: Dict[str, Dict[str, Type[QueueProcessingWorker]]] = {}
+worker_classes: Dict[str, Type["QueueProcessingWorker"]] = {}
+queues: Dict[str, Dict[str, Type["QueueProcessingWorker"]]] = {}
-AUTH_LDAP_REVERSE_EMAIL_SEARCH: Optional[LDAPSearch] = None
+AUTH_LDAP_REVERSE_EMAIL_SEARCH: Optional["LDAPSearch"] = None
Signed-off-by: Anders Kaseorg <anders@zulipchat.com>
2020-04-22 01:09:50 +02:00
|
|
|
stripe_source: Optional[Union[stripe.Card, stripe.Source]] = stripe_customer.default_source
|
2018-09-08 00:49:54 +02:00
|
|
|
# In case of e.g. an expired card
|
|
|
|
if stripe_source is None: # nocoverage
|
|
|
|
return _("No payment method on file")
|
|
|
|
if stripe_source.object == "card":
|
2020-06-15 23:22:24 +02:00
|
|
|
assert isinstance(stripe_source, stripe.Card)
|
|
|
|
return _("{brand} ending in {last4}").format(
|
2021-02-12 08:19:30 +01:00
|
|
|
brand=stripe_source.brand,
|
|
|
|
last4=stripe_source.last4,
|
2020-06-15 23:22:24 +02:00
|
|
|
)
|
2018-12-23 09:10:57 +01:00
|
|
|
# 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.
|
2020-06-15 23:22:24 +02:00
|
|
|
return _("Unknown payment method. Please contact {email}.").format(
|
|
|
|
email=settings.ZULIP_ADMINISTRATOR,
|
|
|
|
) # nocoverage
|
2018-09-08 00:49:54 +02:00
|
|
|
|
2021-02-12 08:19:30 +01:00
|
|
|
|
2020-07-15 22:18:32 +02:00
|
|
|
@require_organization_member
|
2018-12-07 18:43:22 +01:00
|
|
|
@has_request_variables
|
2021-02-12 08:19:30 +01:00
|
|
|
def upgrade(
|
|
|
|
request: HttpRequest,
|
|
|
|
user: UserProfile,
|
2021-04-09 12:43:44 +02:00
|
|
|
billing_modality: str = REQ(str_validator=check_string_in(VALID_BILLING_MODALITY_VALUES)),
|
|
|
|
schedule: str = REQ(str_validator=check_string_in(VALID_BILLING_SCHEDULE_VALUES)),
|
|
|
|
signed_seat_count: str = REQ(),
|
|
|
|
salt: str = REQ(),
|
|
|
|
license_management: Optional[str] = REQ(
|
|
|
|
default=None, str_validator=check_string_in(VALID_LICENSE_MANAGEMENT_VALUES)
|
|
|
|
),
|
2021-04-07 22:00:44 +02:00
|
|
|
licenses: Optional[int] = REQ(json_validator=check_int, default=None),
|
2021-04-09 12:43:44 +02:00
|
|
|
stripe_token: Optional[str] = REQ(default=None),
|
2021-02-12 08:19:30 +01:00
|
|
|
) -> HttpResponse:
|
2021-04-09 12:43:44 +02:00
|
|
|
|
2018-12-07 18:43:22 +01:00
|
|
|
try:
|
2018-12-22 05:29:25 +01:00
|
|
|
seat_count = unsign_seat_count(signed_seat_count, salt)
|
2021-02-12 08:20:45 +01:00
|
|
|
if billing_modality == "charge_automatically" and license_management == "automatic":
|
2018-12-22 01:43:44 +01:00
|
|
|
licenses = seat_count
|
2021-02-12 08:20:45 +01:00
|
|
|
if billing_modality == "send_invoice":
|
|
|
|
schedule = "annual"
|
|
|
|
license_management = "manual"
|
2018-12-22 05:29:25 +01:00
|
|
|
check_upgrade_parameters(
|
2021-02-12 08:19:30 +01:00
|
|
|
billing_modality,
|
|
|
|
schedule,
|
|
|
|
license_management,
|
|
|
|
licenses,
|
|
|
|
stripe_token is not None,
|
|
|
|
seat_count,
|
|
|
|
)
|
2019-11-13 08:17:49 +01:00
|
|
|
assert licenses is not None
|
2021-02-12 08:20:45 +01:00
|
|
|
automanage_licenses = license_management == "automatic"
|
2018-12-22 05:29:25 +01:00
|
|
|
|
2021-02-12 08:20:45 +01:00
|
|
|
billing_schedule = {"annual": CustomerPlan.ANNUAL, "monthly": CustomerPlan.MONTHLY}[
|
2021-02-12 08:19:30 +01:00
|
|
|
schedule
|
|
|
|
]
|
2018-12-15 09:33:25 +01:00
|
|
|
process_initial_upgrade(user, licenses, automanage_licenses, billing_schedule, stripe_token)
|
2018-12-07 18:43:22 +01:00
|
|
|
except BillingError as e:
|
2018-12-24 00:35:48 +01:00
|
|
|
if not settings.TEST_SUITE: # nocoverage
|
2019-01-29 16:46:10 +01:00
|
|
|
billing_logger.warning(
|
2020-05-02 20:57:12 +02:00
|
|
|
"BillingError during upgrade: %s. user=%s, realm=%s (%s), billing_modality=%s, "
|
|
|
|
"schedule=%s, license_management=%s, licenses=%s, has stripe_token: %s",
|
2021-07-04 08:19:18 +02:00
|
|
|
e.error_description,
|
2021-02-12 08:19:30 +01:00
|
|
|
user.id,
|
|
|
|
user.realm.id,
|
|
|
|
user.realm.string_id,
|
|
|
|
billing_modality,
|
|
|
|
schedule,
|
|
|
|
license_management,
|
|
|
|
licenses,
|
|
|
|
stripe_token is not None,
|
2020-05-02 20:57:12 +02:00
|
|
|
)
|
2021-07-04 08:19:18 +02:00
|
|
|
raise
|
2020-06-12 01:35:37 +02:00
|
|
|
except Exception:
|
2020-08-11 03:19:00 +02:00
|
|
|
billing_logger.exception("Uncaught exception in billing:", stack_info=True)
|
2020-10-17 03:42:50 +02:00
|
|
|
error_message = BillingError.CONTACT_SUPPORT.format(email=settings.ZULIP_ADMINISTRATOR)
|
2018-12-07 18:43:22 +01:00
|
|
|
error_description = "uncaught exception during upgrade"
|
2021-07-04 08:19:18 +02:00
|
|
|
raise BillingError(error_description, error_message)
|
2018-12-07 18:43:22 +01:00
|
|
|
else:
|
|
|
|
return json_success()
|
|
|
|
|
2021-02-12 08:19:30 +01:00
|
|
|
|
2018-09-25 12:24:11 +02:00
|
|
|
@zulip_login_required
|
|
|
|
def initial_upgrade(request: HttpRequest) -> HttpResponse:
|
|
|
|
user = request.user
|
2020-06-09 12:24:32 +02:00
|
|
|
|
2020-07-15 22:18:32 +02:00
|
|
|
if not settings.BILLING_ENABLED or user.is_guest:
|
|
|
|
return render(request, "404.html", status=404)
|
|
|
|
|
2020-09-22 02:54:44 +02:00
|
|
|
billing_page_url = reverse(billing_home)
|
2020-08-21 14:45:43 +02:00
|
|
|
|
2020-03-23 13:35:04 +01:00
|
|
|
customer = get_customer_by_realm(user.realm)
|
2021-02-12 08:19:30 +01:00
|
|
|
if customer is not None and (
|
|
|
|
get_current_plan_by_customer(customer) is not None or customer.sponsorship_pending
|
|
|
|
):
|
2020-05-22 15:42:46 +02:00
|
|
|
if request.GET.get("onboarding") is not None:
|
2020-06-09 00:25:09 +02:00
|
|
|
billing_page_url = f"{billing_page_url}?onboarding=true"
|
2020-05-22 15:42:46 +02:00
|
|
|
return HttpResponseRedirect(billing_page_url)
|
2018-09-25 12:24:11 +02:00
|
|
|
|
2020-10-14 18:45:57 +02:00
|
|
|
if is_sponsored_realm(user.realm):
|
2020-08-21 14:45:43 +02:00
|
|
|
return HttpResponseRedirect(billing_page_url)
|
|
|
|
|
2020-03-23 13:35:04 +01:00
|
|
|
percent_off = Decimal(0)
|
2018-12-12 19:41:03 +01:00
|
|
|
if customer is not None and customer.default_discount is not None:
|
|
|
|
percent_off = customer.default_discount
|
2018-11-30 02:27:01 +01:00
|
|
|
|
2019-10-07 19:21:29 +02:00
|
|
|
seat_count = get_latest_seat_count(user.realm)
|
2018-09-25 12:24:11 +02:00
|
|
|
signed_seat_count, salt = sign_string(str(seat_count))
|
python: Convert assignment type annotations to Python 3.6 style.
This commit was split by tabbott; this piece covers the vast majority
of files in Zulip, but excludes scripts/, tools/, and puppet/ to help
ensure we at least show the right error messages for Xenial systems.
We can likely further refine the remaining pieces with some testing.
Generated by com2ann, with whitespace fixes and various manual fixes
for runtime issues:
- invoiced_through: Optional[LicenseLedger] = models.ForeignKey(
+ invoiced_through: Optional["LicenseLedger"] = models.ForeignKey(
-_apns_client: Optional[APNsClient] = None
+_apns_client: Optional["APNsClient"] = None
- notifications_stream: Optional[Stream] = models.ForeignKey('Stream', related_name='+', null=True, blank=True, on_delete=CASCADE)
- signup_notifications_stream: Optional[Stream] = models.ForeignKey('Stream', related_name='+', null=True, blank=True, on_delete=CASCADE)
+ notifications_stream: Optional["Stream"] = models.ForeignKey('Stream', related_name='+', null=True, blank=True, on_delete=CASCADE)
+ signup_notifications_stream: Optional["Stream"] = models.ForeignKey('Stream', related_name='+', null=True, blank=True, on_delete=CASCADE)
- author: Optional[UserProfile] = models.ForeignKey('UserProfile', blank=True, null=True, on_delete=CASCADE)
+ author: Optional["UserProfile"] = models.ForeignKey('UserProfile', blank=True, null=True, on_delete=CASCADE)
- bot_owner: Optional[UserProfile] = models.ForeignKey('self', null=True, on_delete=models.SET_NULL)
+ bot_owner: Optional["UserProfile"] = models.ForeignKey('self', null=True, on_delete=models.SET_NULL)
- default_sending_stream: Optional[Stream] = models.ForeignKey('zerver.Stream', null=True, related_name='+', on_delete=CASCADE)
- default_events_register_stream: Optional[Stream] = models.ForeignKey('zerver.Stream', null=True, related_name='+', on_delete=CASCADE)
+ default_sending_stream: Optional["Stream"] = models.ForeignKey('zerver.Stream', null=True, related_name='+', on_delete=CASCADE)
+ default_events_register_stream: Optional["Stream"] = models.ForeignKey('zerver.Stream', null=True, related_name='+', on_delete=CASCADE)
-descriptors_by_handler_id: Dict[int, ClientDescriptor] = {}
+descriptors_by_handler_id: Dict[int, "ClientDescriptor"] = {}
-worker_classes: Dict[str, Type[QueueProcessingWorker]] = {}
-queues: Dict[str, Dict[str, Type[QueueProcessingWorker]]] = {}
+worker_classes: Dict[str, Type["QueueProcessingWorker"]] = {}
+queues: Dict[str, Dict[str, Type["QueueProcessingWorker"]]] = {}
-AUTH_LDAP_REVERSE_EMAIL_SEARCH: Optional[LDAPSearch] = None
+AUTH_LDAP_REVERSE_EMAIL_SEARCH: Optional["LDAPSearch"] = None
Signed-off-by: Anders Kaseorg <anders@zulipchat.com>
2020-04-22 01:09:50 +02:00
|
|
|
context: Dict[str, Any] = {
|
2021-02-12 08:20:45 +01:00
|
|
|
"realm": user.realm,
|
|
|
|
"publishable_key": STRIPE_PUBLISHABLE_KEY,
|
|
|
|
"email": user.delivery_email,
|
|
|
|
"seat_count": seat_count,
|
|
|
|
"signed_seat_count": signed_seat_count,
|
|
|
|
"salt": salt,
|
|
|
|
"min_invoiced_licenses": max(seat_count, MIN_INVOICED_LICENSES),
|
|
|
|
"default_invoice_days_until_due": DEFAULT_INVOICE_DAYS_UNTIL_DUE,
|
|
|
|
"plan": "Zulip Standard",
|
2020-05-14 18:21:23 +02:00
|
|
|
"free_trial_days": settings.FREE_TRIAL_DAYS,
|
2020-05-22 15:42:46 +02:00
|
|
|
"onboarding": request.GET.get("onboarding") is not None,
|
2021-02-12 08:20:45 +01:00
|
|
|
"page_params": {
|
|
|
|
"seat_count": seat_count,
|
|
|
|
"annual_price": 8000,
|
|
|
|
"monthly_price": 800,
|
|
|
|
"percent_off": float(percent_off),
|
2019-09-13 01:15:53 +02:00
|
|
|
},
|
python: Convert assignment type annotations to Python 3.6 style.
This commit was split by tabbott; this piece covers the vast majority
of files in Zulip, but excludes scripts/, tools/, and puppet/ to help
ensure we at least show the right error messages for Xenial systems.
We can likely further refine the remaining pieces with some testing.
Generated by com2ann, with whitespace fixes and various manual fixes
for runtime issues:
- invoiced_through: Optional[LicenseLedger] = models.ForeignKey(
+ invoiced_through: Optional["LicenseLedger"] = models.ForeignKey(
-_apns_client: Optional[APNsClient] = None
+_apns_client: Optional["APNsClient"] = None
- notifications_stream: Optional[Stream] = models.ForeignKey('Stream', related_name='+', null=True, blank=True, on_delete=CASCADE)
- signup_notifications_stream: Optional[Stream] = models.ForeignKey('Stream', related_name='+', null=True, blank=True, on_delete=CASCADE)
+ notifications_stream: Optional["Stream"] = models.ForeignKey('Stream', related_name='+', null=True, blank=True, on_delete=CASCADE)
+ signup_notifications_stream: Optional["Stream"] = models.ForeignKey('Stream', related_name='+', null=True, blank=True, on_delete=CASCADE)
- author: Optional[UserProfile] = models.ForeignKey('UserProfile', blank=True, null=True, on_delete=CASCADE)
+ author: Optional["UserProfile"] = models.ForeignKey('UserProfile', blank=True, null=True, on_delete=CASCADE)
- bot_owner: Optional[UserProfile] = models.ForeignKey('self', null=True, on_delete=models.SET_NULL)
+ bot_owner: Optional["UserProfile"] = models.ForeignKey('self', null=True, on_delete=models.SET_NULL)
- default_sending_stream: Optional[Stream] = models.ForeignKey('zerver.Stream', null=True, related_name='+', on_delete=CASCADE)
- default_events_register_stream: Optional[Stream] = models.ForeignKey('zerver.Stream', null=True, related_name='+', on_delete=CASCADE)
+ default_sending_stream: Optional["Stream"] = models.ForeignKey('zerver.Stream', null=True, related_name='+', on_delete=CASCADE)
+ default_events_register_stream: Optional["Stream"] = models.ForeignKey('zerver.Stream', null=True, related_name='+', on_delete=CASCADE)
-descriptors_by_handler_id: Dict[int, ClientDescriptor] = {}
+descriptors_by_handler_id: Dict[int, "ClientDescriptor"] = {}
-worker_classes: Dict[str, Type[QueueProcessingWorker]] = {}
-queues: Dict[str, Dict[str, Type[QueueProcessingWorker]]] = {}
+worker_classes: Dict[str, Type["QueueProcessingWorker"]] = {}
+queues: Dict[str, Dict[str, Type["QueueProcessingWorker"]]] = {}
-AUTH_LDAP_REVERSE_EMAIL_SEARCH: Optional[LDAPSearch] = None
+AUTH_LDAP_REVERSE_EMAIL_SEARCH: Optional["LDAPSearch"] = None
Signed-off-by: Anders Kaseorg <anders@zulipchat.com>
2020-04-22 01:09:50 +02:00
|
|
|
}
|
2021-02-12 08:20:45 +01:00
|
|
|
response = render(request, "corporate/upgrade.html", context=context)
|
2018-09-25 12:24:11 +02:00
|
|
|
return response
|
|
|
|
|
2021-02-12 08:19:30 +01:00
|
|
|
|
2020-07-15 22:18:32 +02:00
|
|
|
@require_organization_member
|
2020-06-09 12:24:32 +02:00
|
|
|
@has_request_variables
|
2021-02-12 08:19:30 +01:00
|
|
|
def sponsorship(
|
|
|
|
request: HttpRequest,
|
|
|
|
user: UserProfile,
|
2021-04-09 12:43:44 +02:00
|
|
|
organization_type: str = REQ("organization-type"),
|
|
|
|
website: str = REQ(),
|
|
|
|
description: str = REQ(),
|
2021-02-12 08:19:30 +01:00
|
|
|
) -> HttpResponse:
|
2020-06-09 12:24:32 +02:00
|
|
|
realm = user.realm
|
|
|
|
|
|
|
|
requested_by = user.full_name
|
2020-07-21 02:25:28 +02:00
|
|
|
user_role = user.get_role_name()
|
2020-06-09 12:24:32 +02:00
|
|
|
|
|
|
|
support_realm_uri = get_realm(settings.STAFF_SUBDOMAIN).uri
|
2021-02-12 08:19:30 +01:00
|
|
|
support_url = urljoin(
|
|
|
|
support_realm_uri,
|
|
|
|
urlunsplit(("", "", reverse("support"), urlencode({"q": realm.string_id}), "")),
|
|
|
|
)
|
2020-06-09 12:24:32 +02:00
|
|
|
|
|
|
|
context = {
|
|
|
|
"requested_by": requested_by,
|
|
|
|
"user_role": user_role,
|
|
|
|
"string_id": realm.string_id,
|
|
|
|
"support_url": support_url,
|
|
|
|
"organization_type": organization_type,
|
|
|
|
"website": website,
|
|
|
|
"description": description,
|
|
|
|
}
|
|
|
|
send_email(
|
|
|
|
"zerver/emails/sponsorship_request",
|
|
|
|
to_emails=[FromAddress.SUPPORT],
|
2020-07-14 08:17:00 +02:00
|
|
|
from_name="Zulip sponsorship",
|
|
|
|
from_address=FromAddress.tokenized_no_reply_address(),
|
|
|
|
reply_to_email=user.delivery_email,
|
2020-06-09 12:24:32 +02:00
|
|
|
context=context,
|
|
|
|
)
|
|
|
|
|
2020-12-04 12:14:51 +01:00
|
|
|
update_sponsorship_status(realm, True, acting_user=user)
|
2021-05-28 12:36:41 +02:00
|
|
|
do_make_user_billing_admin(user)
|
2020-06-09 12:24:32 +02:00
|
|
|
|
|
|
|
return json_success()
|
|
|
|
|
2021-02-12 08:19:30 +01:00
|
|
|
|
2018-09-25 12:24:11 +02:00
|
|
|
@zulip_login_required
|
|
|
|
def billing_home(request: HttpRequest) -> HttpResponse:
|
|
|
|
user = request.user
|
2020-03-23 13:35:04 +01:00
|
|
|
customer = get_customer_by_realm(user.realm)
|
2020-08-21 14:45:43 +02:00
|
|
|
context: Dict[str, Any] = {
|
|
|
|
"admin_access": user.has_billing_access,
|
2021-02-12 08:20:45 +01:00
|
|
|
"has_active_plan": False,
|
2020-08-21 14:45:43 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
if user.realm.plan_type == user.realm.STANDARD_FREE:
|
|
|
|
context["is_sponsored"] = True
|
2021-02-12 08:20:45 +01:00
|
|
|
return render(request, "corporate/billing.html", context=context)
|
2020-06-09 12:24:32 +02:00
|
|
|
|
2018-09-25 12:24:11 +02:00
|
|
|
if customer is None:
|
2020-09-22 02:54:44 +02:00
|
|
|
return HttpResponseRedirect(reverse(initial_upgrade))
|
2020-06-09 12:24:32 +02:00
|
|
|
|
|
|
|
if customer.sponsorship_pending:
|
2020-08-21 14:45:43 +02:00
|
|
|
context["sponsorship_pending"] = True
|
2021-02-12 08:20:45 +01:00
|
|
|
return render(request, "corporate/billing.html", context=context)
|
2020-06-09 12:24:32 +02:00
|
|
|
|
2018-12-15 09:33:25 +01:00
|
|
|
if not CustomerPlan.objects.filter(customer=customer).exists():
|
2020-09-22 02:54:44 +02:00
|
|
|
return HttpResponseRedirect(reverse(initial_upgrade))
|
2018-09-25 12:24:11 +02:00
|
|
|
|
2020-06-09 12:24:32 +02:00
|
|
|
if not user.has_billing_access:
|
2021-02-12 08:20:45 +01:00
|
|
|
return render(request, "corporate/billing.html", context=context)
|
2018-09-25 12:24:11 +02:00
|
|
|
|
2020-03-24 14:14:03 +01:00
|
|
|
plan = get_current_plan_by_customer(customer)
|
2018-12-15 09:33:25 +01:00
|
|
|
if plan is not None:
|
2019-01-26 20:45:26 +01:00
|
|
|
now = timezone_now()
|
2020-06-15 20:09:24 +02:00
|
|
|
new_plan, last_ledger_entry = make_end_of_cycle_updates_if_needed(plan, now)
|
2019-04-08 05:16:35 +02:00
|
|
|
if last_ledger_entry is not None:
|
2020-06-15 20:09:24 +02:00
|
|
|
if new_plan is not None: # nocoverage
|
|
|
|
plan = new_plan
|
2021-02-12 08:19:30 +01:00
|
|
|
assert plan is not None # for mypy
|
2020-04-24 17:38:13 +02:00
|
|
|
downgrade_at_end_of_cycle = plan.status == CustomerPlan.DOWNGRADE_AT_END_OF_CYCLE
|
2021-02-12 08:19:30 +01:00
|
|
|
switch_to_annual_at_end_of_cycle = (
|
|
|
|
plan.status == CustomerPlan.SWITCH_TO_ANNUAL_AT_END_OF_CYCLE
|
|
|
|
)
|
2019-04-08 05:16:35 +02:00
|
|
|
licenses = last_ledger_entry.licenses
|
2020-12-23 17:08:27 +01:00
|
|
|
licenses_at_next_renewal = last_ledger_entry.licenses_at_next_renewal
|
2020-12-17 16:34:24 +01:00
|
|
|
seat_count = get_latest_seat_count(user.realm)
|
|
|
|
|
2019-04-08 05:16:35 +02:00
|
|
|
# Should do this in javascript, using the user's timezone
|
2021-02-12 08:20:45 +01:00
|
|
|
renewal_date = "{dt:%B} {dt.day}, {dt.year}".format(
|
2021-02-12 08:19:30 +01:00
|
|
|
dt=start_of_next_billing_cycle(plan, now)
|
|
|
|
)
|
2019-04-08 05:16:35 +02:00
|
|
|
renewal_cents = renewal_amount(plan, now)
|
|
|
|
charge_automatically = plan.charge_automatically
|
2021-06-18 21:10:45 +02:00
|
|
|
assert customer.stripe_customer_id is not None # for mypy
|
2020-03-26 19:08:00 +01:00
|
|
|
stripe_customer = stripe_get_customer(customer.stripe_customer_id)
|
2019-04-08 05:16:35 +02:00
|
|
|
if charge_automatically:
|
|
|
|
payment_method = payment_method_string(stripe_customer)
|
|
|
|
else:
|
2021-02-12 08:20:45 +01:00
|
|
|
payment_method = "Billed by invoice"
|
2018-09-25 12:24:11 +02:00
|
|
|
|
2020-09-03 05:32:15 +02:00
|
|
|
context.update(
|
|
|
|
plan_name=plan.name,
|
|
|
|
has_active_plan=True,
|
2020-11-11 14:02:47 +01:00
|
|
|
free_trial=plan.is_free_trial(),
|
2020-09-03 05:32:15 +02:00
|
|
|
downgrade_at_end_of_cycle=downgrade_at_end_of_cycle,
|
|
|
|
automanage_licenses=plan.automanage_licenses,
|
|
|
|
switch_to_annual_at_end_of_cycle=switch_to_annual_at_end_of_cycle,
|
|
|
|
licenses=licenses,
|
2020-12-23 17:08:27 +01:00
|
|
|
licenses_at_next_renewal=licenses_at_next_renewal,
|
2020-12-17 16:34:24 +01:00
|
|
|
seat_count=seat_count,
|
2020-09-03 05:32:15 +02:00
|
|
|
renewal_date=renewal_date,
|
2020-10-20 15:46:04 +02:00
|
|
|
renewal_amount=cents_to_dollar_string(renewal_cents),
|
2020-09-03 05:32:15 +02:00
|
|
|
payment_method=payment_method,
|
|
|
|
charge_automatically=charge_automatically,
|
|
|
|
publishable_key=STRIPE_PUBLISHABLE_KEY,
|
|
|
|
stripe_email=stripe_customer.email,
|
|
|
|
CustomerPlan=CustomerPlan,
|
|
|
|
onboarding=request.GET.get("onboarding") is not None,
|
|
|
|
)
|
2020-04-03 16:17:34 +02:00
|
|
|
|
2021-02-12 08:20:45 +01:00
|
|
|
return render(request, "corporate/billing.html", context=context)
|
2018-09-25 12:24:11 +02:00
|
|
|
|
2021-02-12 08:19:30 +01:00
|
|
|
|
2018-11-01 11:26:29 +01:00
|
|
|
@require_billing_access
|
2019-04-08 05:16:35 +02:00
|
|
|
@has_request_variables
|
2020-12-10 18:15:09 +01:00
|
|
|
def update_plan(
|
2021-04-14 15:50:40 +02:00
|
|
|
request: HttpRequest,
|
|
|
|
user: UserProfile,
|
2020-12-10 18:15:09 +01:00
|
|
|
status: Optional[int] = REQ(
|
2021-04-14 15:50:40 +02:00
|
|
|
"status",
|
|
|
|
json_validator=check_int_in(
|
|
|
|
[
|
|
|
|
CustomerPlan.ACTIVE,
|
|
|
|
CustomerPlan.DOWNGRADE_AT_END_OF_CYCLE,
|
|
|
|
CustomerPlan.SWITCH_TO_ANNUAL_AT_END_OF_CYCLE,
|
|
|
|
CustomerPlan.ENDED,
|
|
|
|
]
|
|
|
|
),
|
2020-12-10 18:15:09 +01:00
|
|
|
default=None,
|
2021-04-14 15:50:40 +02:00
|
|
|
),
|
2020-12-23 17:08:27 +01:00
|
|
|
licenses: Optional[int] = REQ("licenses", json_validator=check_int, default=None),
|
|
|
|
licenses_at_next_renewal: Optional[int] = REQ(
|
|
|
|
"licenses_at_next_renewal", json_validator=check_int, default=None
|
|
|
|
),
|
2021-02-12 08:19:30 +01:00
|
|
|
) -> HttpResponse:
|
2020-03-24 14:22:27 +01:00
|
|
|
plan = get_current_plan_by_realm(user.realm)
|
2021-02-12 08:19:30 +01:00
|
|
|
assert plan is not None # for mypy
|
2020-04-23 20:10:15 +02:00
|
|
|
|
2020-12-17 16:35:33 +01:00
|
|
|
new_plan, last_ledger_entry = make_end_of_cycle_updates_if_needed(plan, timezone_now())
|
|
|
|
if new_plan is not None:
|
2021-06-30 18:35:50 +02:00
|
|
|
raise JsonableError(
|
2020-12-17 16:35:33 +01:00
|
|
|
_("Unable to update the plan. The plan has been expired and replaced with a new plan.")
|
|
|
|
)
|
|
|
|
|
|
|
|
if last_ledger_entry is None:
|
2021-06-30 18:35:50 +02:00
|
|
|
raise JsonableError(_("Unable to update the plan. The plan has ended."))
|
2020-12-17 16:35:33 +01:00
|
|
|
|
2020-12-10 18:15:09 +01:00
|
|
|
if status is not None:
|
|
|
|
if status == CustomerPlan.ACTIVE:
|
|
|
|
assert plan.status == CustomerPlan.DOWNGRADE_AT_END_OF_CYCLE
|
|
|
|
do_change_plan_status(plan, status)
|
|
|
|
elif status == CustomerPlan.DOWNGRADE_AT_END_OF_CYCLE:
|
|
|
|
assert plan.status == CustomerPlan.ACTIVE
|
|
|
|
downgrade_at_the_end_of_billing_cycle(user.realm)
|
|
|
|
elif status == CustomerPlan.SWITCH_TO_ANNUAL_AT_END_OF_CYCLE:
|
|
|
|
assert plan.billing_schedule == CustomerPlan.MONTHLY
|
|
|
|
assert plan.status == CustomerPlan.ACTIVE
|
|
|
|
assert plan.fixed_price is None
|
|
|
|
do_change_plan_status(plan, status)
|
|
|
|
elif status == CustomerPlan.ENDED:
|
2020-11-11 14:02:47 +01:00
|
|
|
assert plan.is_free_trial()
|
2020-12-10 18:15:09 +01:00
|
|
|
downgrade_now_without_creating_additional_invoices(user.realm)
|
|
|
|
return json_success()
|
|
|
|
|
2020-12-23 17:08:27 +01:00
|
|
|
if licenses is not None:
|
|
|
|
if plan.automanage_licenses:
|
2021-06-30 18:35:50 +02:00
|
|
|
raise JsonableError(
|
2020-12-23 17:08:27 +01:00
|
|
|
_(
|
|
|
|
"Unable to update licenses manually. Your plan is on automatic license management."
|
|
|
|
)
|
|
|
|
)
|
|
|
|
if last_ledger_entry.licenses == licenses:
|
2021-06-30 18:35:50 +02:00
|
|
|
raise JsonableError(
|
2020-12-23 17:08:27 +01:00
|
|
|
_(
|
|
|
|
"Your plan is already on {licenses} licenses in the current billing period."
|
|
|
|
).format(licenses=licenses)
|
|
|
|
)
|
|
|
|
if last_ledger_entry.licenses > licenses:
|
2021-06-30 18:35:50 +02:00
|
|
|
raise JsonableError(
|
2020-12-23 17:08:27 +01:00
|
|
|
_("You cannot decrease the licenses in the current billing period.").format(
|
|
|
|
licenses=licenses
|
|
|
|
)
|
|
|
|
)
|
2021-07-04 08:19:18 +02:00
|
|
|
validate_licenses(plan.charge_automatically, licenses, get_latest_seat_count(user.realm))
|
2020-12-23 17:08:27 +01:00
|
|
|
update_license_ledger_for_manual_plan(plan, timezone_now(), licenses=licenses)
|
|
|
|
return json_success()
|
|
|
|
|
|
|
|
if licenses_at_next_renewal is not None:
|
|
|
|
if plan.automanage_licenses:
|
2021-06-30 18:35:50 +02:00
|
|
|
raise JsonableError(
|
2020-12-23 17:08:27 +01:00
|
|
|
_(
|
|
|
|
"Unable to update licenses manually. Your plan is on automatic license management."
|
|
|
|
)
|
|
|
|
)
|
|
|
|
if last_ledger_entry.licenses_at_next_renewal == licenses_at_next_renewal:
|
2021-06-30 18:35:50 +02:00
|
|
|
raise JsonableError(
|
2020-12-23 17:08:27 +01:00
|
|
|
_(
|
|
|
|
"Your plan is already scheduled to renew with {licenses_at_next_renewal} licenses."
|
|
|
|
).format(licenses_at_next_renewal=licenses_at_next_renewal)
|
|
|
|
)
|
2021-07-04 08:19:18 +02:00
|
|
|
validate_licenses(
|
|
|
|
plan.charge_automatically,
|
|
|
|
licenses_at_next_renewal,
|
|
|
|
get_latest_seat_count(user.realm),
|
|
|
|
)
|
2020-12-23 17:08:27 +01:00
|
|
|
update_license_ledger_for_manual_plan(
|
|
|
|
plan, timezone_now(), licenses_at_next_renewal=licenses_at_next_renewal
|
|
|
|
)
|
|
|
|
return json_success()
|
|
|
|
|
2021-06-30 18:35:50 +02:00
|
|
|
raise JsonableError(_("Nothing to change."))
|
2018-09-25 12:24:11 +02:00
|
|
|
|
2021-02-12 08:19:30 +01:00
|
|
|
|
2018-11-01 11:26:29 +01:00
|
|
|
@require_billing_access
|
2018-09-25 12:24:11 +02:00
|
|
|
@has_request_variables
|
2021-02-12 08:19:30 +01:00
|
|
|
def replace_payment_source(
|
|
|
|
request: HttpRequest,
|
|
|
|
user: UserProfile,
|
2021-04-09 12:43:44 +02:00
|
|
|
stripe_token: str = REQ(),
|
2021-02-12 08:19:30 +01:00
|
|
|
) -> HttpResponse:
|
2021-07-04 08:19:18 +02:00
|
|
|
do_replace_payment_source(user, stripe_token, pay_invoices=True)
|
2018-09-25 12:24:11 +02:00
|
|
|
return json_success()
|