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
|
|
|
|
from django.utils.translation import ugettext 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,
|
|
|
|
MAX_INVOICED_LICENSES,
|
|
|
|
MIN_INVOICED_LICENSES,
|
|
|
|
STRIPE_PUBLISHABLE_KEY,
|
|
|
|
BillingError,
|
|
|
|
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,
|
|
|
|
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-06-09 12:24:32 +02:00
|
|
|
update_sponsorship_status,
|
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,
|
|
|
|
)
|
2018-09-25 12:24:11 +02:00
|
|
|
from zerver.lib.request import REQ, has_request_variables
|
|
|
|
from zerver.lib.response import json_error, json_success
|
2020-06-09 12:24:32 +02:00
|
|
|
from zerver.lib.send_email import FromAddress, send_email
|
2020-06-11 00:54:34 +02:00
|
|
|
from zerver.lib.validator import check_int, check_string
|
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-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-02-12 08:20:45 +01:00
|
|
|
if billing_modality not in ["send_invoice", "charge_automatically"]:
|
|
|
|
raise BillingError("unknown billing_modality")
|
|
|
|
if schedule not in ["annual", "monthly"]:
|
|
|
|
raise BillingError("unknown schedule")
|
|
|
|
if license_management not in ["automatic", "manual"]:
|
|
|
|
raise BillingError("unknown license_management")
|
|
|
|
|
|
|
|
if billing_modality == "charge_automatically":
|
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
|
|
|
|
|
|
|
min_licenses = seat_count
|
2020-05-08 12:43:52 +02:00
|
|
|
max_licenses = None
|
2021-02-12 08:20:45 +01:00
|
|
|
if billing_modality == "send_invoice":
|
2018-12-22 05:29:25 +01:00
|
|
|
min_licenses = max(seat_count, MIN_INVOICED_LICENSES)
|
2020-05-08 12:43:52 +02:00
|
|
|
max_licenses = MAX_INVOICED_LICENSES
|
|
|
|
|
2018-12-22 05:29:25 +01:00
|
|
|
if licenses is None or licenses < min_licenses:
|
2021-02-12 08:19:30 +01:00
|
|
|
raise BillingError(
|
2021-02-12 08:20:45 +01:00
|
|
|
"not enough licenses", _("You must invoice for at least {} users.").format(min_licenses)
|
2021-02-12 08:19:30 +01:00
|
|
|
)
|
2018-09-25 12:24:11 +02:00
|
|
|
|
2020-05-08 12:43:52 +02:00
|
|
|
if max_licenses is not None and licenses > max_licenses:
|
2021-02-12 08:19:30 +01:00
|
|
|
message = _(
|
|
|
|
"Invoices with more than {} licenses can't be processed from this page. To complete "
|
|
|
|
"the upgrade, please contact {}."
|
|
|
|
).format(max_licenses, settings.ZULIP_ADMINISTRATOR)
|
2021-02-12 08:20:45 +01:00
|
|
|
raise BillingError("too many licenses", message)
|
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-07 22:00:44 +02:00
|
|
|
billing_modality: str = REQ(json_validator=check_string),
|
|
|
|
schedule: str = REQ(json_validator=check_string),
|
|
|
|
license_management: Optional[str] = REQ(json_validator=check_string, default=None),
|
|
|
|
licenses: Optional[int] = REQ(json_validator=check_int, default=None),
|
|
|
|
stripe_token: Optional[str] = REQ(json_validator=check_string, default=None),
|
|
|
|
signed_seat_count: str = REQ(json_validator=check_string),
|
|
|
|
salt: str = REQ(json_validator=check_string),
|
2021-02-12 08:19:30 +01:00
|
|
|
) -> HttpResponse:
|
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-02-12 08:19:30 +01:00
|
|
|
e.description,
|
|
|
|
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-02-12 08:20:45 +01:00
|
|
|
return json_error(e.message, data={"error_description": e.description})
|
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-02-12 08:20:45 +01:00
|
|
|
return json_error(error_message, data={"error_description": error_description})
|
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-08-21 14:45:43 +02:00
|
|
|
if user.realm.plan_type == user.realm.STANDARD_FREE:
|
|
|
|
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-07 22:00:44 +02:00
|
|
|
organization_type: str = REQ("organization-type", json_validator=check_string),
|
|
|
|
website: str = REQ("website", json_validator=check_string),
|
|
|
|
description: str = REQ("description", json_validator=check_string),
|
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)
|
2020-06-09 12:24:32 +02:00
|
|
|
user.is_billing_admin = True
|
|
|
|
user.save(update_fields=["is_billing_admin"])
|
|
|
|
|
|
|
|
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-23 20:10:15 +02:00
|
|
|
free_trial = plan.status == CustomerPlan.FREE_TRIAL
|
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
|
2019-10-07 19:21:29 +02:00
|
|
|
licenses_used = 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
|
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,
|
|
|
|
free_trial=free_trial,
|
|
|
|
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,
|
|
|
|
licenses_used=licenses_used,
|
|
|
|
renewal_date=renewal_date,
|
2021-02-12 08:20:45 +01:00
|
|
|
renewal_amount=f"{renewal_cents / 100.:,.2f}",
|
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
|
2021-02-12 08:19:30 +01:00
|
|
|
def change_plan_status(
|
2021-04-07 22:00:44 +02:00
|
|
|
request: HttpRequest, user: UserProfile, status: int = REQ("status", json_validator=check_int)
|
2021-02-12 08:19:30 +01:00
|
|
|
) -> HttpResponse:
|
|
|
|
assert status in [
|
|
|
|
CustomerPlan.ACTIVE,
|
|
|
|
CustomerPlan.DOWNGRADE_AT_END_OF_CYCLE,
|
|
|
|
CustomerPlan.SWITCH_TO_ANNUAL_AT_END_OF_CYCLE,
|
|
|
|
CustomerPlan.ENDED,
|
|
|
|
]
|
2020-04-23 20:10:15 +02:00
|
|
|
|
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
|
|
|
|
|
|
|
if status == CustomerPlan.ACTIVE:
|
2021-02-12 08:19:30 +01:00
|
|
|
assert plan.status == CustomerPlan.DOWNGRADE_AT_END_OF_CYCLE
|
2020-04-23 20:10:15 +02:00
|
|
|
do_change_plan_status(plan, status)
|
|
|
|
elif status == CustomerPlan.DOWNGRADE_AT_END_OF_CYCLE:
|
2021-02-12 08:19:30 +01:00
|
|
|
assert plan.status == CustomerPlan.ACTIVE
|
2020-08-13 13:06:05 +02:00
|
|
|
downgrade_at_the_end_of_billing_cycle(user.realm)
|
2020-06-15 20:09:24 +02:00
|
|
|
elif status == CustomerPlan.SWITCH_TO_ANNUAL_AT_END_OF_CYCLE:
|
2021-02-12 08:19:30 +01:00
|
|
|
assert plan.billing_schedule == CustomerPlan.MONTHLY
|
|
|
|
assert plan.status == CustomerPlan.ACTIVE
|
|
|
|
assert plan.fixed_price is None
|
2020-06-15 20:09:24 +02:00
|
|
|
do_change_plan_status(plan, status)
|
2020-04-23 20:10:15 +02:00
|
|
|
elif status == CustomerPlan.ENDED:
|
2021-02-12 08:19:30 +01:00
|
|
|
assert plan.status == CustomerPlan.FREE_TRIAL
|
2020-08-13 10:22:58 +02:00
|
|
|
downgrade_now_without_creating_additional_invoices(user.realm)
|
2018-09-25 12:24:11 +02:00
|
|
|
return json_success()
|
|
|
|
|
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-07 22:00:44 +02:00
|
|
|
stripe_token: str = REQ("stripe_token", json_validator=check_string),
|
2021-02-12 08:19:30 +01:00
|
|
|
) -> HttpResponse:
|
2018-09-25 12:24:11 +02:00
|
|
|
try:
|
2019-04-04 10:02:49 +02:00
|
|
|
do_replace_payment_source(user, stripe_token, pay_invoices=True)
|
2018-09-25 12:24:11 +02:00
|
|
|
except BillingError as e:
|
2021-02-12 08:20:45 +01:00
|
|
|
return json_error(e.message, data={"error_description": e.description})
|
2018-09-25 12:24:11 +02:00
|
|
|
return json_success()
|