mirror of https://github.com/zulip/zulip.git
billing: Move billing-related views and urls to corporate.
This commit is contained in:
parent
0c7be02b99
commit
6914ee126c
|
@ -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)),
|
||||||
|
]
|
||||||
|
|
|
@ -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()
|
|
@ -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*.*\)'
|
||||||
|
|
|
@ -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()
|
||||||
|
|
|
@ -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)),
|
||||||
]
|
]
|
||||||
|
|
|
@ -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()
|
|
||||||
|
|
|
@ -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 = ''
|
||||||
|
|
||||||
|
|
Loading…
Reference in New Issue