mirror of
https://github.com/zulip/zulip.git
synced 2026-06-21 21:32:29 +08:00
This specifically fixes an issue where a customer chose monthly payment plan instead of annual, getting stuck with the higher monthly payments. With this change, the Customer plan can be set to ENDED and they can go through /upgrade/ again and choose annual payments. A proper follow-up should be to make it possible to change between monthly/annual on the /billing/ page.
223 lines
10 KiB
Python
223 lines
10 KiB
Python
import logging
|
|
import stripe
|
|
from typing import Any, Dict, cast, Optional, Union
|
|
|
|
from django.core import signing
|
|
from django.http import HttpRequest, HttpResponse, HttpResponseRedirect
|
|
from django.utils.timezone import now as timezone_now
|
|
from django.utils.translation import ugettext as _
|
|
from django.shortcuts import render
|
|
from django.urls import reverse
|
|
from django.conf import settings
|
|
|
|
from zerver.decorator import zulip_login_required, require_billing_access
|
|
from zerver.lib.request import REQ, has_request_variables
|
|
from zerver.lib.response import json_error, json_success
|
|
from zerver.lib.validator import check_string, check_int
|
|
from zerver.models import UserProfile
|
|
from corporate.lib.stripe import STRIPE_PUBLISHABLE_KEY, \
|
|
stripe_get_customer, get_latest_seat_count, \
|
|
process_initial_upgrade, sign_string, \
|
|
unsign_string, BillingError, do_change_plan_status, do_replace_payment_source, \
|
|
MIN_INVOICED_LICENSES, DEFAULT_INVOICE_DAYS_UNTIL_DUE, \
|
|
start_of_next_billing_cycle, renewal_amount, \
|
|
make_end_of_cycle_updates_if_needed
|
|
from corporate.models import Customer, CustomerPlan, \
|
|
get_current_plan
|
|
|
|
billing_logger = logging.getLogger('corporate.stripe')
|
|
|
|
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')
|
|
|
|
def check_upgrade_parameters(
|
|
billing_modality: str, schedule: str, license_management: Optional[str], licenses: Optional[int],
|
|
has_stripe_token: bool, seat_count: int) -> None:
|
|
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':
|
|
if not has_stripe_token:
|
|
raise BillingError('autopay with no card')
|
|
|
|
min_licenses = seat_count
|
|
if billing_modality == 'send_invoice':
|
|
min_licenses = max(seat_count, MIN_INVOICED_LICENSES)
|
|
if licenses is None or licenses < min_licenses:
|
|
raise BillingError('not enough licenses',
|
|
_("You must invoice for at least {} users.").format(min_licenses))
|
|
|
|
# Should only be called if the customer is being charged automatically
|
|
def payment_method_string(stripe_customer: stripe.Customer) -> str:
|
|
stripe_source = stripe_customer.default_source # type: Optional[Union[stripe.Card, stripe.Source]]
|
|
# 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":
|
|
return _("%(brand)s ending in %(last4)s") % {
|
|
'brand': cast(stripe.Card, stripe_source).brand,
|
|
'last4': cast(stripe.Card, stripe_source).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 %s.") % (settings.ZULIP_ADMINISTRATOR,) # nocoverage
|
|
|
|
@has_request_variables
|
|
def upgrade(request: HttpRequest, user: UserProfile,
|
|
billing_modality: str=REQ(validator=check_string),
|
|
schedule: str=REQ(validator=check_string),
|
|
license_management: Optional[str]=REQ(validator=check_string, default=None),
|
|
licenses: Optional[int]=REQ(validator=check_int, default=None),
|
|
stripe_token: Optional[str]=REQ(validator=check_string, default=None),
|
|
signed_seat_count: str=REQ(validator=check_string),
|
|
salt: str=REQ(validator=check_string)) -> HttpResponse:
|
|
try:
|
|
seat_count = unsign_seat_count(signed_seat_count, salt)
|
|
if billing_modality == 'charge_automatically' and license_management == 'automatic':
|
|
licenses = seat_count
|
|
if billing_modality == 'send_invoice':
|
|
schedule = 'annual'
|
|
license_management = 'manual'
|
|
check_upgrade_parameters(
|
|
billing_modality, schedule, license_management, licenses,
|
|
stripe_token is not None, seat_count)
|
|
assert licenses is not None
|
|
automanage_licenses = license_management == 'automatic'
|
|
|
|
billing_schedule = {'annual': CustomerPlan.ANNUAL,
|
|
'monthly': CustomerPlan.MONTHLY}[schedule]
|
|
process_initial_upgrade(user, licenses, automanage_licenses, billing_schedule, stripe_token)
|
|
except BillingError as e:
|
|
if not settings.TEST_SUITE: # nocoverage
|
|
billing_logger.warning(
|
|
("BillingError during upgrade: %s. user=%s, realm=%s (%s), billing_modality=%s, "
|
|
"schedule=%s, license_management=%s, licenses=%s, has stripe_token: %s")
|
|
% (e.description, user.id, user.realm.id, user.realm.string_id, billing_modality,
|
|
schedule, license_management, licenses, stripe_token is not None))
|
|
return json_error(e.message, data={'error_description': e.description})
|
|
except Exception as e:
|
|
billing_logger.exception("Uncaught exception in billing: %s" % (e,))
|
|
error_message = BillingError.CONTACT_SUPPORT
|
|
error_description = "uncaught exception during upgrade"
|
|
return json_error(error_message, data={'error_description': error_description})
|
|
else:
|
|
return json_success()
|
|
|
|
@zulip_login_required
|
|
def initial_upgrade(request: HttpRequest) -> HttpResponse:
|
|
if not settings.BILLING_ENABLED:
|
|
return render(request, "404.html")
|
|
|
|
user = request.user
|
|
customer = Customer.objects.filter(realm=user.realm).first()
|
|
if customer is not None and get_current_plan(customer) is not None:
|
|
return HttpResponseRedirect(reverse('corporate.views.billing_home'))
|
|
|
|
percent_off = 0
|
|
if customer is not None and customer.default_discount is not None:
|
|
percent_off = customer.default_discount
|
|
|
|
seat_count = get_latest_seat_count(user.realm)
|
|
signed_seat_count, salt = sign_string(str(seat_count))
|
|
context = {
|
|
'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",
|
|
'page_params': {
|
|
'seat_count': seat_count,
|
|
'annual_price': 8000,
|
|
'monthly_price': 800,
|
|
'percent_off': float(percent_off),
|
|
},
|
|
} # type: Dict[str, Any]
|
|
response = render(request, 'corporate/upgrade.html', context=context)
|
|
return response
|
|
|
|
@zulip_login_required
|
|
def billing_home(request: HttpRequest) -> HttpResponse:
|
|
user = request.user
|
|
customer = Customer.objects.filter(realm=user.realm).first()
|
|
if customer is None:
|
|
return HttpResponseRedirect(reverse('corporate.views.initial_upgrade'))
|
|
if not CustomerPlan.objects.filter(customer=customer).exists():
|
|
return HttpResponseRedirect(reverse('corporate.views.initial_upgrade'))
|
|
|
|
if not user.is_realm_admin and not user.is_billing_admin:
|
|
context = {'admin_access': False} # type: Dict[str, Any]
|
|
return render(request, 'corporate/billing.html', context=context)
|
|
context = {'admin_access': True}
|
|
|
|
plan_name = "Zulip Free"
|
|
licenses = 0
|
|
renewal_date = ''
|
|
renewal_cents = 0
|
|
payment_method = ''
|
|
charge_automatically = False
|
|
|
|
stripe_customer = stripe_get_customer(customer.stripe_customer_id)
|
|
plan = get_current_plan(customer)
|
|
if plan is not None:
|
|
plan_name = {
|
|
CustomerPlan.STANDARD: 'Zulip Standard',
|
|
CustomerPlan.PLUS: 'Zulip Plus',
|
|
}[plan.tier]
|
|
now = timezone_now()
|
|
last_ledger_entry = make_end_of_cycle_updates_if_needed(plan, now)
|
|
if last_ledger_entry is not None:
|
|
licenses = last_ledger_entry.licenses
|
|
licenses_used = get_latest_seat_count(user.realm)
|
|
# Should do this in javascript, using the user's timezone
|
|
renewal_date = '{dt:%B} {dt.day}, {dt.year}'.format(dt=start_of_next_billing_cycle(plan, now))
|
|
renewal_cents = renewal_amount(plan, now)
|
|
charge_automatically = plan.charge_automatically
|
|
if charge_automatically:
|
|
payment_method = payment_method_string(stripe_customer)
|
|
else:
|
|
payment_method = 'Billed by invoice'
|
|
|
|
context.update({
|
|
'plan_name': plan_name,
|
|
'licenses': licenses,
|
|
'licenses_used': licenses_used,
|
|
'renewal_date': renewal_date,
|
|
'renewal_amount': '{:,.2f}'.format(renewal_cents / 100.),
|
|
'payment_method': payment_method,
|
|
'charge_automatically': charge_automatically,
|
|
'publishable_key': STRIPE_PUBLISHABLE_KEY,
|
|
'stripe_email': stripe_customer.email,
|
|
})
|
|
return render(request, 'corporate/billing.html', context=context)
|
|
|
|
@require_billing_access
|
|
@has_request_variables
|
|
def change_plan_at_end_of_cycle(request: HttpRequest, user: UserProfile,
|
|
status: int=REQ("status", validator=check_int)) -> HttpResponse:
|
|
assert(status in [CustomerPlan.ACTIVE, CustomerPlan.DOWNGRADE_AT_END_OF_CYCLE])
|
|
plan = get_current_plan(Customer.objects.get(realm=user.realm))
|
|
assert(plan is not None) # for mypy
|
|
do_change_plan_status(plan, status)
|
|
return json_success()
|
|
|
|
@require_billing_access
|
|
@has_request_variables
|
|
def replace_payment_source(request: HttpRequest, user: UserProfile,
|
|
stripe_token: str=REQ("stripe_token", validator=check_string)) -> HttpResponse:
|
|
try:
|
|
do_replace_payment_source(user, stripe_token, pay_invoices=True)
|
|
except BillingError as e:
|
|
return json_error(e.message, data={'error_description': e.description})
|
|
return json_success()
|