2018-09-25 12:24:11 +02:00
|
|
|
import logging
|
2018-09-08 00:49:54 +02:00
|
|
|
import stripe
|
|
|
|
from typing import Any, Dict, Optional, Tuple, cast
|
2018-09-25 12:24:11 +02:00
|
|
|
|
|
|
|
from django.core import signing
|
|
|
|
from django.http import HttpRequest, HttpResponse, HttpResponseRedirect
|
2018-12-28 07:20:30 +01:00
|
|
|
from django.utils.timezone import now as timezone_now
|
2018-09-25 12:24:11 +02:00
|
|
|
from django.utils.translation import ugettext as _, ugettext as err_
|
|
|
|
from django.shortcuts import redirect, render
|
|
|
|
from django.urls import reverse
|
|
|
|
from django.conf import settings
|
|
|
|
|
2018-11-01 11:26:29 +01:00
|
|
|
from zerver.decorator import zulip_login_required, require_billing_access
|
2018-11-29 08:51:53 +01:00
|
|
|
from zerver.lib.json_encoder_for_html import JSONEncoderForHTML
|
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
|
2018-12-15 09:33:25 +01:00
|
|
|
from zerver.lib.validator import check_string, check_int, check_bool
|
2018-09-25 12:24:11 +02:00
|
|
|
from zerver.lib.timestamp import timestamp_to_datetime
|
|
|
|
from zerver.models import UserProfile, Realm
|
2018-09-25 12:33:30 +02:00
|
|
|
from corporate.lib.stripe import STRIPE_PUBLISHABLE_KEY, \
|
2018-12-15 09:33:25 +01:00
|
|
|
stripe_get_customer, get_seat_count, \
|
|
|
|
process_initial_upgrade, sign_string, \
|
2018-09-08 00:49:54 +02:00
|
|
|
unsign_string, BillingError, process_downgrade, do_replace_payment_source, \
|
2018-12-15 09:33:25 +01:00
|
|
|
MIN_INVOICED_LICENSES, DEFAULT_INVOICE_DAYS_UNTIL_DUE, \
|
2018-12-28 07:20:30 +01:00
|
|
|
next_renewal_date, renewal_amount, \
|
|
|
|
add_plan_renewal_to_license_ledger_if_needed
|
|
|
|
from corporate.models import Customer, CustomerPlan, LicenseLedger, \
|
|
|
|
get_active_plan
|
2018-09-25 12:24:11 +02:00
|
|
|
|
|
|
|
billing_logger = logging.getLogger('corporate.stripe')
|
|
|
|
|
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:
|
2018-12-22 05:29:25 +01:00
|
|
|
raise BillingError('tampered seat count')
|
|
|
|
|
|
|
|
def check_upgrade_parameters(
|
|
|
|
billing_modality: str, schedule: str, license_management: str, licenses: 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', 'mix']:
|
|
|
|
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)))
|
2018-09-25 12:24:11 +02: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:
|
2018-09-08 00:49:54 +02:00
|
|
|
stripe_source = stripe_customer.default_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":
|
2018-12-23 09:10:57 +01:00
|
|
|
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.
|
2018-11-30 06:07:39 +01:00
|
|
|
return _("Unknown payment method. Please contact %s." % (settings.ZULIP_ADMINISTRATOR,)) # nocoverage
|
2018-09-08 00:49:54 +02:00
|
|
|
|
2018-12-07 18:43:22 +01:00
|
|
|
@has_request_variables
|
|
|
|
def upgrade(request: HttpRequest, user: UserProfile,
|
2018-12-22 05:29:25 +01:00
|
|
|
billing_modality: str=REQ(validator=check_string),
|
2018-12-12 23:23:15 +01:00
|
|
|
schedule: str=REQ(validator=check_string),
|
2018-12-21 18:44:24 +01:00
|
|
|
license_management: str=REQ(validator=check_string, default=None),
|
2018-12-22 01:43:44 +01:00
|
|
|
licenses: int=REQ(validator=check_int, default=None),
|
2018-12-22 05:29:25 +01:00
|
|
|
stripe_token: str=REQ(validator=check_string, default=None),
|
|
|
|
signed_seat_count: str=REQ(validator=check_string),
|
|
|
|
salt: str=REQ(validator=check_string)) -> 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)
|
|
|
|
if billing_modality == 'charge_automatically' and license_management == 'automatic':
|
2018-12-22 01:43:44 +01:00
|
|
|
licenses = seat_count
|
2018-12-22 05:29:25 +01:00
|
|
|
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)
|
2018-12-15 09:33:25 +01:00
|
|
|
automanage_licenses = license_management in ['automatic', 'mix']
|
2018-12-22 05:29:25 +01:00
|
|
|
|
|
|
|
billing_schedule = {'annual': CustomerPlan.ANNUAL,
|
|
|
|
'monthly': CustomerPlan.MONTHLY}[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
|
|
|
|
billing_logger.info(
|
|
|
|
("BillingError during upgrade: %s. user=%s, billing_modality=%s, schedule=%s, "
|
|
|
|
"license_management=%s, licenses=%s, has stripe_token: %s")
|
|
|
|
% (e.description, user.id, billing_modality, schedule, license_management, licenses,
|
|
|
|
stripe_token is not None))
|
2018-12-07 18:43:22 +01:00
|
|
|
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()
|
|
|
|
|
2018-09-25 12:24:11 +02:00
|
|
|
@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()
|
2018-12-15 09:33:25 +01:00
|
|
|
if customer is not None and CustomerPlan.objects.filter(customer=customer).exists():
|
2018-09-25 12:24:11 +02:00
|
|
|
return HttpResponseRedirect(reverse('corporate.views.billing_home'))
|
|
|
|
|
2018-11-30 02:27:01 +01:00
|
|
|
percent_off = 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
|
|
|
|
2018-09-25 12:24:11 +02:00
|
|
|
seat_count = get_seat_count(user.realm)
|
|
|
|
signed_seat_count, salt = sign_string(str(seat_count))
|
|
|
|
context = {
|
|
|
|
'publishable_key': STRIPE_PUBLISHABLE_KEY,
|
|
|
|
'email': user.email,
|
|
|
|
'seat_count': seat_count,
|
|
|
|
'signed_seat_count': signed_seat_count,
|
|
|
|
'salt': salt,
|
2018-12-22 01:43:44 +01:00
|
|
|
'min_invoiced_licenses': max(seat_count, MIN_INVOICED_LICENSES),
|
2018-11-18 10:18:14 +01:00
|
|
|
'default_invoice_days_until_due': DEFAULT_INVOICE_DAYS_UNTIL_DUE,
|
2018-10-24 06:09:01 +02:00
|
|
|
'plan': "Zulip Standard",
|
2018-11-29 08:51:53 +01:00
|
|
|
'page_params': JSONEncoderForHTML().encode({
|
|
|
|
'seat_count': seat_count,
|
|
|
|
'annual_price': 8000,
|
|
|
|
'monthly_price': 800,
|
2018-12-12 19:41:03 +01:00
|
|
|
'percent_off': float(percent_off),
|
2018-11-29 08:51:53 +01:00
|
|
|
}),
|
2018-09-25 12:24:11 +02:00
|
|
|
} # type: Dict[str, Any]
|
2018-09-25 12:30:17 +02:00
|
|
|
response = render(request, 'corporate/upgrade.html', context=context)
|
2018-09-25 12:24:11 +02:00
|
|
|
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'))
|
2018-12-15 09:33:25 +01:00
|
|
|
if not CustomerPlan.objects.filter(customer=customer).exists():
|
2018-09-25 12:24:11 +02:00
|
|
|
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]
|
2018-09-25 12:30:17 +02:00
|
|
|
return render(request, 'corporate/billing.html', context=context)
|
2018-09-25 12:24:11 +02:00
|
|
|
context = {'admin_access': True}
|
|
|
|
|
2018-12-23 09:10:57 +01:00
|
|
|
stripe_customer = stripe_get_customer(customer.stripe_customer_id)
|
2018-12-15 09:33:25 +01:00
|
|
|
plan = get_active_plan(customer)
|
|
|
|
if plan is not None:
|
|
|
|
plan_name = {
|
|
|
|
CustomerPlan.STANDARD: 'Zulip Standard',
|
|
|
|
CustomerPlan.PLUS: 'Zulip Plus',
|
|
|
|
}[plan.tier]
|
2018-12-28 07:20:30 +01:00
|
|
|
last_ledger_entry = add_plan_renewal_to_license_ledger_if_needed(plan, timezone_now())
|
|
|
|
# TODO: this is not really correct; need to give the situation as of the "fillstate"
|
|
|
|
licenses = last_ledger_entry.licenses
|
2018-12-23 09:10:57 +01:00
|
|
|
# Should do this in javascript, using the user's timezone
|
2018-12-15 09:33:25 +01:00
|
|
|
renewal_date = '{dt:%B} {dt.day}, {dt.year}'.format(dt=next_renewal_date(plan))
|
|
|
|
renewal_cents = renewal_amount(plan)
|
2018-12-28 07:20:30 +01:00
|
|
|
# TODO: this is the case where the plan doesn't automatically renew
|
|
|
|
if renewal_cents is None: # nocoverage
|
|
|
|
renewal_cents = 0
|
2018-12-15 09:33:25 +01:00
|
|
|
charge_automatically = plan.charge_automatically
|
2018-12-23 09:10:57 +01:00
|
|
|
if charge_automatically:
|
|
|
|
payment_method = payment_method_string(stripe_customer)
|
|
|
|
else:
|
2018-12-15 09:33:25 +01:00
|
|
|
payment_method = 'Billed by invoice'
|
2018-09-25 12:24:11 +02:00
|
|
|
# Can only get here by subscribing and then downgrading. We don't support downgrading
|
|
|
|
# yet, but keeping this code here since we will soon.
|
|
|
|
else: # nocoverage
|
|
|
|
plan_name = "Zulip Free"
|
2018-12-22 01:43:44 +01:00
|
|
|
licenses = 0
|
2018-09-25 12:24:11 +02:00
|
|
|
renewal_date = ''
|
2018-12-15 09:33:25 +01:00
|
|
|
renewal_cents = 0
|
|
|
|
payment_method = ''
|
2018-12-23 09:10:57 +01:00
|
|
|
charge_automatically = False
|
2018-09-25 12:24:11 +02:00
|
|
|
|
|
|
|
context.update({
|
|
|
|
'plan_name': plan_name,
|
2018-12-22 01:43:44 +01:00
|
|
|
'licenses': licenses,
|
2018-09-25 12:24:11 +02:00
|
|
|
'renewal_date': renewal_date,
|
2018-12-15 09:33:25 +01:00
|
|
|
'renewal_amount': '{:,.2f}'.format(renewal_cents / 100.),
|
|
|
|
'payment_method': payment_method,
|
2018-12-23 09:10:57 +01:00
|
|
|
'charge_automatically': charge_automatically,
|
2018-09-25 12:24:11 +02:00
|
|
|
'publishable_key': STRIPE_PUBLISHABLE_KEY,
|
2018-12-23 09:10:57 +01:00
|
|
|
'stripe_email': stripe_customer.email,
|
2018-09-25 12:24:11 +02:00
|
|
|
})
|
2018-09-25 12:30:17 +02:00
|
|
|
return render(request, 'corporate/billing.html', context=context)
|
2018-09-25 12:24:11 +02:00
|
|
|
|
2018-11-01 11:26:29 +01:00
|
|
|
@require_billing_access
|
2018-12-12 07:47:53 +01:00
|
|
|
def downgrade(request: HttpRequest, user: UserProfile) -> HttpResponse: # nocoverage
|
2018-09-25 12:24:11 +02:00
|
|
|
try:
|
|
|
|
process_downgrade(user)
|
|
|
|
except BillingError as e:
|
|
|
|
return json_error(e.message, data={'error_description': e.description})
|
|
|
|
return json_success()
|
|
|
|
|
2018-11-01 11:26:29 +01:00
|
|
|
@require_billing_access
|
2018-09-25 12:24:11 +02:00
|
|
|
@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)
|
|
|
|
except BillingError as e:
|
|
|
|
return json_error(e.message, data={'error_description': e.description})
|
|
|
|
return json_success()
|