billing: Move billing-related views and urls to corporate.

This commit is contained in:
Vishnu Ks 2018-09-25 15:54:11 +05:30 committed by Rishi Gupta
parent 0c7be02b99
commit 6914ee126c
7 changed files with 210 additions and 188 deletions

View File

@ -1,10 +1,32 @@
from django.conf.urls import url from typing import Any
from django.views.generic import TemplateView from django.views.generic import TemplateView
from django.conf.urls import include, url
import corporate.views
from zerver.lib.rest import rest_dispatch
i18n_urlpatterns = [ i18n_urlpatterns = [
# Zephyr/MIT # Zephyr/MIT
url(r'^zephyr/$', TemplateView.as_view(template_name='corporate/zephyr.html')), url(r'^zephyr/$', TemplateView.as_view(template_name='corporate/zephyr.html')),
url(r'^zephyr-mirror/$', TemplateView.as_view(template_name='corporate/zephyr-mirror.html')), url(r'^zephyr-mirror/$', TemplateView.as_view(template_name='corporate/zephyr-mirror.html')),
# Billing
url(r'^billing/$', corporate.views.billing_home, name='corporate.views.billing_home'),
url(r'^upgrade/$', corporate.views.initial_upgrade, name='corporate.views.initial_upgrade'),
] # type: Any
v1_api_and_json_patterns = [
url(r'^billing/downgrade$', rest_dispatch,
{'POST': 'corporate.views.downgrade'}),
url(r'billing/sources/change', rest_dispatch,
{'POST': 'corporate.views.replace_payment_source'}),
] ]
urlpatterns = i18n_urlpatterns # Make a copy of i18n_urlpatterns so that they appear without prefix for English
urlpatterns = list(i18n_urlpatterns)
urlpatterns += [
url(r'^api/v1/', include(v1_api_and_json_patterns)),
url(r'^json/', include(v1_api_and_json_patterns)),
]

170
corporate/views.py Normal file
View File

@ -0,0 +1,170 @@
from typing import Any, Dict, Optional, Tuple
import logging
from django.core import signing
from django.http import HttpRequest, HttpResponse, HttpResponseRedirect
from django.utils import timezone
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
from zerver.decorator import zulip_login_required
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
from zerver.lib.timestamp import timestamp_to_datetime
from zerver.models import UserProfile, Realm
from zilencer.lib.stripe import STRIPE_PUBLISHABLE_KEY, \
stripe_get_customer, stripe_get_upcoming_invoice, get_seat_count, \
extract_current_subscription, process_initial_upgrade, sign_string, \
unsign_string, BillingError, process_downgrade, do_replace_payment_source
from zilencer.models import Customer, Plan
billing_logger = logging.getLogger('corporate.stripe')
def unsign_and_check_upgrade_parameters(user: UserProfile, plan_nickname: str,
signed_seat_count: str, salt: str) -> Tuple[Plan, int]:
if plan_nickname not in [Plan.CLOUD_ANNUAL, Plan.CLOUD_MONTHLY]:
billing_logger.warning("Tampered plan during realm upgrade. user: %s, realm: %s (%s)."
% (user.id, user.realm.id, user.realm.string_id))
raise BillingError('tampered plan', BillingError.CONTACT_SUPPORT)
plan = Plan.objects.get(nickname=plan_nickname)
try:
seat_count = int(unsign_string(signed_seat_count, salt))
except signing.BadSignature:
billing_logger.warning("Tampered seat count during realm upgrade. user: %s, realm: %s (%s)."
% (user.id, user.realm.id, user.realm.string_id))
raise BillingError('tampered seat count', BillingError.CONTACT_SUPPORT)
return plan, seat_count
@zulip_login_required
def initial_upgrade(request: HttpRequest) -> HttpResponse:
if not settings.BILLING_ENABLED:
return render(request, "404.html")
user = request.user
error_message = ""
error_description = "" # only used in tests
customer = Customer.objects.filter(realm=user.realm).first()
if customer is not None and customer.has_billing_relationship:
return HttpResponseRedirect(reverse('corporate.views.billing_home'))
if request.method == 'POST':
try:
plan, seat_count = unsign_and_check_upgrade_parameters(
user, request.POST['plan'], request.POST['signed_seat_count'], request.POST['salt'])
process_initial_upgrade(user, plan, seat_count, request.POST['stripeToken'])
except BillingError as e:
error_message = e.message
error_description = e.description
except Exception as e:
billing_logger.exception("Uncaught exception in billing: %s" % (e,))
error_message = BillingError.CONTACT_SUPPORT
else:
return HttpResponseRedirect(reverse('corporate.views.billing_home'))
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,
'plan': "Zulip Premium",
'nickname_monthly': Plan.CLOUD_MONTHLY,
'nickname_annual': Plan.CLOUD_ANNUAL,
'error_message': error_message,
'cloud_monthly_price': 8,
'cloud_annual_price': 80,
'cloud_annual_price_per_month': 6.67,
} # type: Dict[str, Any]
response = render(request, 'zilencer/upgrade.html', context=context)
response['error_description'] = error_description
return response
PLAN_NAMES = {
Plan.CLOUD_ANNUAL: "Zulip Premium (billed annually)",
Plan.CLOUD_MONTHLY: "Zulip Premium (billed monthly)",
}
@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 customer.has_billing_relationship:
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, 'zilencer/billing.html', context=context)
context = {'admin_access': True}
stripe_customer = stripe_get_customer(customer.stripe_customer_id)
subscription = extract_current_subscription(stripe_customer)
prorated_charges = stripe_customer.account_balance
if subscription:
plan_name = PLAN_NAMES[Plan.objects.get(stripe_plan_id=subscription.plan.id).nickname]
seat_count = subscription.quantity
# Need user's timezone to do this properly
renewal_date = '{dt:%B} {dt.day}, {dt.year}'.format(
dt=timestamp_to_datetime(subscription.current_period_end))
upcoming_invoice = stripe_get_upcoming_invoice(customer.stripe_customer_id)
renewal_amount = subscription.plan.amount * subscription.quantity
prorated_charges += upcoming_invoice.total - renewal_amount
# 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"
seat_count = 0
renewal_date = ''
renewal_amount = 0
prorated_credits = 0
if prorated_charges < 0: # nocoverage
prorated_credits = -prorated_charges
prorated_charges = 0
payment_method = None
if stripe_customer.default_source is not None:
payment_method = "Card ending in %(last4)s" % {'last4': stripe_customer.default_source.last4}
context.update({
'plan_name': plan_name,
'seat_count': seat_count,
'renewal_date': renewal_date,
'renewal_amount': '{:,.2f}'.format(renewal_amount / 100.),
'payment_method': payment_method,
'prorated_charges': '{:,.2f}'.format(prorated_charges / 100.),
'prorated_credits': '{:,.2f}'.format(prorated_credits / 100.),
'publishable_key': STRIPE_PUBLISHABLE_KEY,
'stripe_email': stripe_customer.email,
})
return render(request, 'zilencer/billing.html', context=context)
def downgrade(request: HttpRequest, user: UserProfile) -> HttpResponse:
if not user.is_realm_admin and not user.is_billing_admin:
return json_error(_('Access denied'))
try:
process_downgrade(user)
except BillingError as e:
return json_error(e.message, data={'error_description': e.description})
return json_success()
@has_request_variables
def replace_payment_source(request: HttpRequest, user: UserProfile,
stripe_token: str=REQ("stripe_token", validator=check_string)) -> HttpResponse:
if not user.is_realm_admin and not user.is_billing_admin:
return json_error(_("Access denied"))
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()

View File

@ -10,7 +10,8 @@ def check_urls():
'zproject/dev_urls.py', 'zproject/dev_urls.py',
'zproject/legacy_urls.py', 'zproject/legacy_urls.py',
'analytics/urls.py', 'analytics/urls.py',
'zilencer/urls.py'] 'zilencer/urls.py',
'corporate/urls.py']
pattern_1 = r"\s+\[?url\(.+,\s*'.+'\s*,\s*.*\)" pattern_1 = r"\s+\[?url\(.+,\s*'.+'\s*,\s*.*\)"
pattern_2 = r'\s+\[?url\(.+,\s*".+"\s*,\s*.*\)' pattern_2 = r'\s+\[?url\(.+,\s*".+"\s*,\s*.*\)'

View File

@ -447,7 +447,7 @@ class StripeTest(ZulipTestCase):
user = self.example_user('hamlet') user = self.example_user('hamlet')
user.is_billing_admin = True user.is_billing_admin = True
user.save(update_fields=['is_billing_admin']) user.save(update_fields=['is_billing_admin'])
with mock.patch('zilencer.views.process_downgrade') as mocked1: with mock.patch('corporate.views.process_downgrade') as mocked1:
self.client_post("/json/billing/downgrade", {}) self.client_post("/json/billing/downgrade", {})
mocked1.assert_called() mocked1.assert_called()
# realm admin but not billing admin # realm admin but not billing admin
@ -455,7 +455,7 @@ class StripeTest(ZulipTestCase):
user.is_billing_admin = False user.is_billing_admin = False
user.is_realm_admin = True user.is_realm_admin = True
user.save(update_fields=['is_billing_admin', 'is_realm_admin']) user.save(update_fields=['is_billing_admin', 'is_realm_admin'])
with mock.patch('zilencer.views.process_downgrade') as mocked2: with mock.patch('corporate.views.process_downgrade') as mocked2:
self.client_post("/json/billing/downgrade", {}) self.client_post("/json/billing/downgrade", {})
mocked2.assert_called() mocked2.assert_called()
@ -507,7 +507,7 @@ class StripeTest(ZulipTestCase):
user = self.example_user('hamlet') user = self.example_user('hamlet')
user.is_billing_admin = True user.is_billing_admin = True
user.save(update_fields=['is_billing_admin']) user.save(update_fields=['is_billing_admin'])
with mock.patch('zilencer.views.do_replace_payment_source') as mocked1: with mock.patch('corporate.views.do_replace_payment_source') as mocked1:
self.client_post("/json/billing/sources/change", self.client_post("/json/billing/sources/change",
{'stripe_token': ujson.dumps('token')}) {'stripe_token': ujson.dumps('token')})
mocked1.assert_called() mocked1.assert_called()
@ -516,7 +516,7 @@ class StripeTest(ZulipTestCase):
user.is_billing_admin = False user.is_billing_admin = False
user.is_realm_admin = True user.is_realm_admin = True
user.save(update_fields=['is_billing_admin', 'is_realm_admin']) user.save(update_fields=['is_billing_admin', 'is_realm_admin'])
with mock.patch('zilencer.views.do_replace_payment_source') as mocked2: with mock.patch('corporate.views.do_replace_payment_source') as mocked2:
self.client_post("/json/billing/sources/change", self.client_post("/json/billing/sources/change",
{'stripe_token': ujson.dumps('token')}) {'stripe_token': ujson.dumps('token')})
mocked2.assert_called() mocked2.assert_called()

View File

@ -5,10 +5,7 @@ from django.conf.urls import include, url
import zilencer.views import zilencer.views
from zerver.lib.rest import rest_dispatch from zerver.lib.rest import rest_dispatch
i18n_urlpatterns = [ i18n_urlpatterns = [] # type: Any
url(r'^billing/$', zilencer.views.billing_home, name='zilencer.views.billing_home'),
url(r'^upgrade/$', zilencer.views.initial_upgrade, name='zilencer.views.initial_upgrade'),
] # type: Any
# Zilencer views following the REST API style # Zilencer views following the REST API style
v1_api_and_json_patterns = [ v1_api_and_json_patterns = [
@ -21,17 +18,9 @@ v1_api_and_json_patterns = [
# Push signup doesn't use the REST API, since there's no auth. # Push signup doesn't use the REST API, since there's no auth.
url('^remotes/server/register$', zilencer.views.register_remote_server), url('^remotes/server/register$', zilencer.views.register_remote_server),
url(r'^billing/downgrade$', rest_dispatch,
{'POST': 'zilencer.views.downgrade'}),
url(r'billing/sources/change', rest_dispatch,
{'POST': 'zilencer.views.replace_payment_source'}),
] ]
# Make a copy of i18n_urlpatterns so that they appear without prefix for English urlpatterns = [
urlpatterns = list(i18n_urlpatterns)
urlpatterns += [
url(r'^api/v1/', include(v1_api_and_json_patterns)), url(r'^api/v1/', include(v1_api_and_json_patterns)),
url(r'^json/', include(v1_api_and_json_patterns)), url(r'^json/', include(v1_api_and_json_patterns)),
] ]

View File

@ -1,38 +1,24 @@
from typing import Any, Dict, Optional, Tuple, Union, cast from typing import Any, Dict, Optional, Union, cast
import logging import logging
from django.core import signing
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.core.validators import validate_email, URLValidator from django.core.validators import validate_email, URLValidator
from django.db import IntegrityError from django.http import HttpRequest, HttpResponse
from django.http import HttpRequest, HttpResponse, HttpResponseRedirect
from django.utils import timezone from django.utils import timezone
from django.utils.translation import ugettext as _, ugettext as err_ 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
from django.views.decorators.http import require_GET
from django.views.decorators.csrf import csrf_exempt from django.views.decorators.csrf import csrf_exempt
from zerver.decorator import require_post, zulip_login_required, InvalidZulipServerKeyError from zerver.decorator import require_post, InvalidZulipServerKeyError
from zerver.lib.exceptions import JsonableError from zerver.lib.exceptions import JsonableError
from zerver.lib.push_notifications import send_android_push_notification, \ from zerver.lib.push_notifications import send_android_push_notification, \
send_apple_push_notification send_apple_push_notification
from zerver.lib.request import REQ, has_request_variables from zerver.lib.request import REQ, has_request_variables
from zerver.lib.response import json_error, json_success from zerver.lib.response import json_error, json_success
from zerver.lib.validator import check_int, check_string, check_url, \ from zerver.lib.validator import check_int, check_string, \
validate_login_email, check_capped_string, check_string_fixed_length check_capped_string, check_string_fixed_length
from zerver.lib.timestamp import timestamp_to_datetime from zerver.models import UserProfile
from zerver.models import UserProfile, Realm
from zerver.views.push_notifications import validate_token from zerver.views.push_notifications import validate_token
from zilencer.lib.stripe import STRIPE_PUBLISHABLE_KEY, \ from zilencer.models import RemotePushDeviceToken, RemoteZulipServer
stripe_get_customer, stripe_get_upcoming_invoice, get_seat_count, \
extract_current_subscription, process_initial_upgrade, sign_string, \
unsign_string, BillingError, process_downgrade, do_replace_payment_source
from zilencer.models import RemotePushDeviceToken, RemoteZulipServer, \
Customer, Plan
billing_logger = logging.getLogger('zilencer.stripe')
def validate_entity(entity: Union[UserProfile, RemoteZulipServer]) -> None: def validate_entity(entity: Union[UserProfile, RemoteZulipServer]) -> None:
if not isinstance(entity, RemoteZulipServer): if not isinstance(entity, RemoteZulipServer):
@ -158,149 +144,3 @@ def remote_server_notify_push(request: HttpRequest, entity: Union[UserProfile, R
send_apple_push_notification(user_id, apple_devices, apns_payload, remote=True) send_apple_push_notification(user_id, apple_devices, apns_payload, remote=True)
return json_success() return json_success()
def unsign_and_check_upgrade_parameters(user: UserProfile, plan_nickname: str,
signed_seat_count: str, salt: str) -> Tuple[Plan, int]:
if plan_nickname not in [Plan.CLOUD_ANNUAL, Plan.CLOUD_MONTHLY]:
billing_logger.warning("Tampered plan during realm upgrade. user: %s, realm: %s (%s)."
% (user.id, user.realm.id, user.realm.string_id))
raise BillingError('tampered plan', BillingError.CONTACT_SUPPORT)
plan = Plan.objects.get(nickname=plan_nickname)
try:
seat_count = int(unsign_string(signed_seat_count, salt))
except signing.BadSignature:
billing_logger.warning("Tampered seat count during realm upgrade. user: %s, realm: %s (%s)."
% (user.id, user.realm.id, user.realm.string_id))
raise BillingError('tampered seat count', BillingError.CONTACT_SUPPORT)
return plan, seat_count
@zulip_login_required
def initial_upgrade(request: HttpRequest) -> HttpResponse:
if not settings.BILLING_ENABLED:
return render(request, "404.html")
user = request.user
error_message = ""
error_description = "" # only used in tests
customer = Customer.objects.filter(realm=user.realm).first()
if customer is not None and customer.has_billing_relationship:
return HttpResponseRedirect(reverse('zilencer.views.billing_home'))
if request.method == 'POST':
try:
plan, seat_count = unsign_and_check_upgrade_parameters(
user, request.POST['plan'], request.POST['signed_seat_count'], request.POST['salt'])
process_initial_upgrade(user, plan, seat_count, request.POST['stripeToken'])
except BillingError as e:
error_message = e.message
error_description = e.description
except Exception as e:
billing_logger.exception("Uncaught exception in billing: %s" % (e,))
error_message = BillingError.CONTACT_SUPPORT
else:
return HttpResponseRedirect(reverse('zilencer.views.billing_home'))
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,
'plan': "Zulip Premium",
'nickname_monthly': Plan.CLOUD_MONTHLY,
'nickname_annual': Plan.CLOUD_ANNUAL,
'error_message': error_message,
'cloud_monthly_price': 8,
'cloud_annual_price': 80,
'cloud_annual_price_per_month': 6.67,
} # type: Dict[str, Any]
response = render(request, 'zilencer/upgrade.html', context=context)
response['error_description'] = error_description
return response
PLAN_NAMES = {
Plan.CLOUD_ANNUAL: "Zulip Premium (billed annually)",
Plan.CLOUD_MONTHLY: "Zulip Premium (billed monthly)",
}
@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('zilencer.views.initial_upgrade'))
if not customer.has_billing_relationship:
return HttpResponseRedirect(reverse('zilencer.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, 'zilencer/billing.html', context=context)
context = {'admin_access': True}
stripe_customer = stripe_get_customer(customer.stripe_customer_id)
subscription = extract_current_subscription(stripe_customer)
prorated_charges = stripe_customer.account_balance
if subscription:
plan_name = PLAN_NAMES[Plan.objects.get(stripe_plan_id=subscription.plan.id).nickname]
seat_count = subscription.quantity
# Need user's timezone to do this properly
renewal_date = '{dt:%B} {dt.day}, {dt.year}'.format(
dt=timestamp_to_datetime(subscription.current_period_end))
upcoming_invoice = stripe_get_upcoming_invoice(customer.stripe_customer_id)
renewal_amount = subscription.plan.amount * subscription.quantity
prorated_charges += upcoming_invoice.total - renewal_amount
# 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"
seat_count = 0
renewal_date = ''
renewal_amount = 0
prorated_credits = 0
if prorated_charges < 0: # nocoverage
prorated_credits = -prorated_charges
prorated_charges = 0
payment_method = None
if stripe_customer.default_source is not None:
payment_method = "Card ending in %(last4)s" % {'last4': stripe_customer.default_source.last4}
context.update({
'plan_name': plan_name,
'seat_count': seat_count,
'renewal_date': renewal_date,
'renewal_amount': '{:,.2f}'.format(renewal_amount / 100.),
'payment_method': payment_method,
'prorated_charges': '{:,.2f}'.format(prorated_charges / 100.),
'prorated_credits': '{:,.2f}'.format(prorated_credits / 100.),
'publishable_key': STRIPE_PUBLISHABLE_KEY,
'stripe_email': stripe_customer.email,
})
return render(request, 'zilencer/billing.html', context=context)
def downgrade(request: HttpRequest, user: UserProfile) -> HttpResponse:
if not user.is_realm_admin and not user.is_billing_admin:
return json_error(_('Access denied'))
try:
process_downgrade(user)
except BillingError as e:
return json_error(e.message, data={'error_description': e.description})
return json_success()
@has_request_variables
def replace_payment_source(request: HttpRequest, user: UserProfile,
stripe_token: str=REQ("stripe_token", validator=check_string)) -> HttpResponse:
if not user.is_realm_admin and not user.is_billing_admin:
return json_error(_("Access denied"))
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()

View File

@ -49,7 +49,7 @@ ERROR_BOT = "error-bot@zulip.com"
# SLOW_QUERY_LOGS_STREAM = "errors" # SLOW_QUERY_LOGS_STREAM = "errors"
EMAIL_GATEWAY_BOT = "emailgateway@zulip.com" EMAIL_GATEWAY_BOT = "emailgateway@zulip.com"
PHYSICAL_ADDRESS = "Zulip Headquarters, 123 Octo Stream, South Pacific Ocean" PHYSICAL_ADDRESS = "Zulip Headquarters, 123 Octo Stream, South Pacific Ocean"
EXTRA_INSTALLED_APPS = ["zilencer", "analytics"] EXTRA_INSTALLED_APPS = ["zilencer", "analytics", "corporate"]
# Disable Camo in development # Disable Camo in development
CAMO_URI = '' CAMO_URI = ''