mirror of https://github.com/zulip/zulip.git
billing: Sign and verify the seat count during upgrade.
This commit is contained in:
parent
d75054fb15
commit
82fc82b7e2
|
@ -3178,6 +3178,11 @@ nav ul li.active::after {
|
||||||
-webkit-box-shadow: none;
|
-webkit-box-shadow: none;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#error-message-box {
|
||||||
|
margin-top: 10px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes box-shadow-pulse {
|
@keyframes box-shadow-pulse {
|
||||||
|
|
|
@ -17,10 +17,17 @@
|
||||||
|
|
||||||
<div class="page-content">
|
<div class="page-content">
|
||||||
<div class="main">
|
<div class="main">
|
||||||
|
{% if error_message %}
|
||||||
|
<div class="alert alert-danger" id="error-message-box">
|
||||||
|
{{ error_message }}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
<h1>{% trans %}Upgrade to {{ plan }}{% endtrans %}</h1>
|
<h1>{% trans %}Upgrade to {{ plan }}{% endtrans %}</h1>
|
||||||
<form method="post">
|
<form method="post">
|
||||||
{{ csrf_input }}
|
{{ csrf_input }}
|
||||||
<input type="hidden" name="seat_count" value="{{ seat_count }}">
|
<input type="hidden" name="seat_count" value="{{ seat_count }}">
|
||||||
|
<input type="hidden" name="signed_seat_count" value="{{ signed_seat_count }}">
|
||||||
|
<input type="hidden" name="salt" value="{{ salt }}">
|
||||||
<div class="payment-schedule">
|
<div class="payment-schedule">
|
||||||
<h3>{{ _("Payment schedule") }}</h3>
|
<h3>{{ _("Payment schedule") }}</h3>
|
||||||
<label>
|
<label>
|
||||||
|
|
|
@ -44,6 +44,8 @@ class StripeTest(ZulipTestCase):
|
||||||
self.stripe_plan_id = 'plan_D7Nh2BtpTvIzYp'
|
self.stripe_plan_id = 'plan_D7Nh2BtpTvIzYp'
|
||||||
self.subscription_created = 1529990751
|
self.subscription_created = 1529990751
|
||||||
self.quantity = 8
|
self.quantity = 8
|
||||||
|
|
||||||
|
self.signed_seat_count, self.salt = sign_string(str(self.quantity))
|
||||||
Plan.objects.create(nickname=Plan.CLOUD_ANNUAL, stripe_plan_id=self.stripe_plan_id)
|
Plan.objects.create(nickname=Plan.CLOUD_ANNUAL, stripe_plan_id=self.stripe_plan_id)
|
||||||
|
|
||||||
@mock.patch("zilencer.lib.stripe.STRIPE_PUBLISHABLE_KEY", "stripe_publishable_key")
|
@mock.patch("zilencer.lib.stripe.STRIPE_PUBLISHABLE_KEY", "stripe_publishable_key")
|
||||||
|
@ -96,7 +98,9 @@ class StripeTest(ZulipTestCase):
|
||||||
# Click "Make payment" in Stripe Checkout
|
# Click "Make payment" in Stripe Checkout
|
||||||
response = self.client_post("/upgrade/", {
|
response = self.client_post("/upgrade/", {
|
||||||
'stripeToken': self.token,
|
'stripeToken': self.token,
|
||||||
'seat_count': self.quantity,
|
# TODO: get these values from the response
|
||||||
|
'signed_seat_count': self.signed_seat_count,
|
||||||
|
'salt': self.salt,
|
||||||
'plan': Plan.CLOUD_ANNUAL})
|
'plan': Plan.CLOUD_ANNUAL})
|
||||||
# Check that we created a customer and subscription in stripe
|
# Check that we created a customer and subscription in stripe
|
||||||
mock_create_customer.assert_called_once_with(
|
mock_create_customer.assert_called_once_with(
|
||||||
|
@ -148,7 +152,8 @@ class StripeTest(ZulipTestCase):
|
||||||
self.assertEqual('/upgrade/', response.url)
|
self.assertEqual('/upgrade/', response.url)
|
||||||
# Check that non-admins can sign up and pay
|
# Check that non-admins can sign up and pay
|
||||||
self.client_post("/upgrade/", {'stripeToken': self.token,
|
self.client_post("/upgrade/", {'stripeToken': self.token,
|
||||||
'seat_count': self.quantity,
|
'signed_seat_count': self.signed_seat_count,
|
||||||
|
'salt': self.salt,
|
||||||
'plan': Plan.CLOUD_ANNUAL})
|
'plan': Plan.CLOUD_ANNUAL})
|
||||||
# Check that the non-admin hamlet can still access /billing
|
# Check that the non-admin hamlet can still access /billing
|
||||||
response = self.client_get("/billing/")
|
response = self.client_get("/billing/")
|
||||||
|
@ -173,7 +178,8 @@ class StripeTest(ZulipTestCase):
|
||||||
# Change the seat count while the user is going through the upgrade flow
|
# Change the seat count while the user is going through the upgrade flow
|
||||||
with mock.patch('zilencer.lib.stripe.get_seat_count', return_value=new_seat_count):
|
with mock.patch('zilencer.lib.stripe.get_seat_count', return_value=new_seat_count):
|
||||||
self.client_post("/upgrade/", {'stripeToken': self.token,
|
self.client_post("/upgrade/", {'stripeToken': self.token,
|
||||||
'seat_count': self.quantity,
|
'signed_seat_count': self.signed_seat_count,
|
||||||
|
'salt': self.salt,
|
||||||
'plan': Plan.CLOUD_ANNUAL})
|
'plan': Plan.CLOUD_ANNUAL})
|
||||||
# Check that the subscription call used the old quantity, not new_seat_count
|
# Check that the subscription call used the old quantity, not new_seat_count
|
||||||
mock_create_subscription.assert_called_once_with(
|
mock_create_subscription.assert_called_once_with(
|
||||||
|
@ -200,6 +206,18 @@ class StripeTest(ZulipTestCase):
|
||||||
event_type=RealmAuditLog.PLAN_QUANTITY_UPDATED).values_list('extra_data', flat=True).first()),
|
event_type=RealmAuditLog.PLAN_QUANTITY_UPDATED).values_list('extra_data', flat=True).first()),
|
||||||
{'quantity': new_seat_count})
|
{'quantity': new_seat_count})
|
||||||
|
|
||||||
|
@mock.patch("zilencer.lib.stripe.STRIPE_PUBLISHABLE_KEY", "stripe_publishable_key")
|
||||||
|
@mock.patch("zilencer.views.STRIPE_PUBLISHABLE_KEY", "stripe_publishable_key")
|
||||||
|
def test_upgrade_with_tampered_seat_count(self) -> None:
|
||||||
|
self.login(self.user.email)
|
||||||
|
result = self.client_post("/upgrade/", {
|
||||||
|
'stripeToken': self.token,
|
||||||
|
'signed_seat_count': "randomsalt",
|
||||||
|
'salt': self.salt,
|
||||||
|
'plan': Plan.CLOUD_ANNUAL
|
||||||
|
})
|
||||||
|
self.assert_in_success_response(["Something went wrong. Please contact"], result)
|
||||||
|
|
||||||
@mock.patch("zilencer.lib.stripe.STRIPE_PUBLISHABLE_KEY", "stripe_publishable_key")
|
@mock.patch("zilencer.lib.stripe.STRIPE_PUBLISHABLE_KEY", "stripe_publishable_key")
|
||||||
@mock.patch("zilencer.views.STRIPE_PUBLISHABLE_KEY", "stripe_publishable_key")
|
@mock.patch("zilencer.views.STRIPE_PUBLISHABLE_KEY", "stripe_publishable_key")
|
||||||
@mock.patch("stripe.Customer.retrieve", side_effect=mock_retrieve_customer)
|
@mock.patch("stripe.Customer.retrieve", side_effect=mock_retrieve_customer)
|
||||||
|
|
|
@ -1,7 +1,9 @@
|
||||||
from typing import Any, Dict, Optional, Union, cast
|
from typing import Any, Dict, Optional, Union, cast
|
||||||
|
import logging
|
||||||
|
|
||||||
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.core import signing
|
||||||
from django.db import IntegrityError
|
from django.db import IntegrityError
|
||||||
from django.http import HttpRequest, HttpResponse, HttpResponseRedirect
|
from django.http import HttpRequest, HttpResponse, HttpResponseRedirect
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
|
@ -26,10 +28,12 @@ from zerver.views.push_notifications import validate_token
|
||||||
from zilencer.lib.stripe import STRIPE_PUBLISHABLE_KEY, StripeError, \
|
from zilencer.lib.stripe import STRIPE_PUBLISHABLE_KEY, StripeError, \
|
||||||
do_create_customer_with_payment_source, do_subscribe_customer_to_plan, \
|
do_create_customer_with_payment_source, do_subscribe_customer_to_plan, \
|
||||||
get_stripe_customer, get_upcoming_invoice, payment_source, \
|
get_stripe_customer, get_upcoming_invoice, payment_source, \
|
||||||
get_seat_count, extract_current_subscription
|
get_seat_count, extract_current_subscription, sign_string, unsign_string
|
||||||
from zilencer.models import RemotePushDeviceToken, RemoteZulipServer, \
|
from zilencer.models import RemotePushDeviceToken, RemoteZulipServer, \
|
||||||
Customer, Plan
|
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):
|
||||||
raise JsonableError(err_("Must validate with valid Zulip server API key"))
|
raise JsonableError(err_("Must validate with valid Zulip server API key"))
|
||||||
|
@ -158,32 +162,43 @@ def remote_server_notify_push(request: HttpRequest, entity: Union[UserProfile, R
|
||||||
@zulip_login_required
|
@zulip_login_required
|
||||||
def initial_upgrade(request: HttpRequest) -> HttpResponse:
|
def initial_upgrade(request: HttpRequest) -> HttpResponse:
|
||||||
user = request.user
|
user = request.user
|
||||||
|
error_message = ""
|
||||||
|
|
||||||
if Customer.objects.filter(realm=user.realm).exists():
|
if Customer.objects.filter(realm=user.realm).exists():
|
||||||
return HttpResponseRedirect(reverse('zilencer.views.billing_home'))
|
return HttpResponseRedirect(reverse('zilencer.views.billing_home'))
|
||||||
|
|
||||||
if request.method == 'POST':
|
if request.method == 'POST':
|
||||||
stripe_customer = do_create_customer_with_payment_source(user, request.POST['stripeToken'])
|
try:
|
||||||
# TODO: the current way this is done is subject to tampering by the user.
|
seat_count = int(unsign_string(request.POST['signed_seat_count'], request.POST['salt']))
|
||||||
seat_count = int(request.POST['seat_count'])
|
except signing.BadSignature:
|
||||||
if seat_count < 1:
|
billing_logger.warning("Tampered seat count during realm upgrade. user: %s, realm: %s (%s)."
|
||||||
raise AssertionError('seat_count is less than 1')
|
% (user.id, user.realm.id, user.realm.string_id))
|
||||||
do_subscribe_customer_to_plan(
|
error_message = "Something went wrong. Please contact support@zulipchat.com"
|
||||||
stripe_customer=stripe_customer,
|
|
||||||
stripe_plan_id=Plan.objects.get(nickname=request.POST['plan']).stripe_plan_id,
|
|
||||||
seat_count=seat_count,
|
|
||||||
# TODO: billing address details are passed to us in the request;
|
|
||||||
# use that to calculate taxes.
|
|
||||||
tax_percent=0)
|
|
||||||
# TODO: check for errors and raise/send to frontend
|
|
||||||
return HttpResponseRedirect(reverse('zilencer.views.billing_home'))
|
|
||||||
|
|
||||||
|
if not error_message:
|
||||||
|
stripe_customer = do_create_customer_with_payment_source(user, request.POST['stripeToken'])
|
||||||
|
do_subscribe_customer_to_plan(
|
||||||
|
stripe_customer=stripe_customer,
|
||||||
|
stripe_plan_id=Plan.objects.get(nickname=request.POST['plan']).stripe_plan_id,
|
||||||
|
seat_count=seat_count,
|
||||||
|
# TODO: billing address details are passed to us in the request;
|
||||||
|
# use that to calculate taxes.
|
||||||
|
tax_percent=0)
|
||||||
|
# TODO: check for errors and raise/send to frontend
|
||||||
|
return HttpResponseRedirect(reverse('zilencer.views.billing_home'))
|
||||||
|
|
||||||
|
seat_count = get_seat_count(user.realm)
|
||||||
|
signed_seat_count, salt = sign_string(str(seat_count))
|
||||||
context = {
|
context = {
|
||||||
'publishable_key': STRIPE_PUBLISHABLE_KEY,
|
'publishable_key': STRIPE_PUBLISHABLE_KEY,
|
||||||
'email': user.email,
|
'email': user.email,
|
||||||
'seat_count': get_seat_count(user.realm),
|
'seat_count': seat_count,
|
||||||
|
'signed_seat_count': signed_seat_count,
|
||||||
|
'salt': salt,
|
||||||
'plan': "Zulip Premium",
|
'plan': "Zulip Premium",
|
||||||
'nickname_monthly': Plan.CLOUD_MONTHLY,
|
'nickname_monthly': Plan.CLOUD_MONTHLY,
|
||||||
'nickname_annual': Plan.CLOUD_ANNUAL,
|
'nickname_annual': Plan.CLOUD_ANNUAL,
|
||||||
|
'error_message': error_message,
|
||||||
} # type: Dict[str, Any]
|
} # type: Dict[str, Any]
|
||||||
return render(request, 'zilencer/upgrade.html', context=context)
|
return render(request, 'zilencer/upgrade.html', context=context)
|
||||||
|
|
||||||
|
|
Loading…
Reference in New Issue