2018-09-25 12:24:11 +02:00
|
|
|
import logging
|
2020-06-15 23:22:24 +02:00
|
|
|
from typing import Any, Dict, Optional, Union
|
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.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 (
|
|
|
|
STRIPE_PUBLISHABLE_KEY,
|
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,
|
|
|
|
make_end_of_cycle_updates_if_needed,
|
|
|
|
renewal_amount,
|
|
|
|
start_of_next_billing_cycle,
|
|
|
|
stripe_get_customer,
|
2020-12-23 17:08:27 +01:00
|
|
|
update_license_ledger_for_manual_plan,
|
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,
|
|
|
|
)
|
2021-07-15 16:38:37 +02:00
|
|
|
from zerver.decorator import require_billing_access, zulip_login_required
|
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
|
2021-07-29 19:01:39 +02:00
|
|
|
from zerver.lib.validator import check_bool, check_int, check_int_in
|
2021-07-15 16:38:37 +02:00
|
|
|
from zerver.models import UserProfile
|
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-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
|
|
|
|
2018-09-25 12:24:11 +02:00
|
|
|
@zulip_login_required
|
2021-07-29 19:01:39 +02:00
|
|
|
@has_request_variables
|
|
|
|
def billing_home(
|
|
|
|
request: HttpRequest, onboarding: bool = REQ(default=False, json_validator=check_bool)
|
|
|
|
) -> HttpResponse:
|
2018-09-25 12:24:11 +02:00
|
|
|
user = request.user
|
2021-07-24 20:37:35 +02:00
|
|
|
assert user.is_authenticated
|
|
|
|
|
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:
|
2021-07-15 16:38:37 +02:00
|
|
|
from corporate.views.upgrade import initial_upgrade
|
|
|
|
|
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():
|
2021-07-15 16:38:37 +02:00
|
|
|
from corporate.views.upgrade import initial_upgrade
|
|
|
|
|
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,
|
2021-07-29 19:01:39 +02:00
|
|
|
onboarding=onboarding,
|
2020-09-03 05:32:15 +02:00
|
|
|
)
|
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()
|