billing: Add ability for users to change their card.

This commit is contained in:
Vishnu Ks 2018-09-06 18:44:54 +05:30 committed by Rishi Gupta
parent 5e88b3013a
commit 5a6b2ebb1f
9 changed files with 193 additions and 16 deletions

View File

@ -0,0 +1,50 @@
$(function () {
var stripe_key = $("#payment-method").data("key");
var handler = StripeCheckout.configure({ // eslint-disable-line no-undef
key: stripe_key,
image: '/static/images/logo/zulip-icon-128x128.png',
locale: 'auto',
token: function (stripe_token) {
var csrf_token = $("#payment-method").data("csrf");
loading.make_indicator($('#updating_card_indicator'),
{text: 'Updating card. Please wait ...', abs_positioned: true});
$("#payment-section").hide();
$("#loading-section").show();
$.post({
url: "/json/billing/sources/change",
data: {
stripe_token: JSON.stringify(stripe_token.id),
csrfmiddlewaretoken: csrf_token,
},
success: function () {
$("#loading-section").hide();
$("#card-updated-message").show();
location.reload();
},
error: function (xhr) {
$("#loading-section").hide();
$('#error-message-box').show().text(JSON.parse(xhr.responseText).msg);
},
});
},
});
$('#update-card-button').on('click', function (e) {
var email = $("#payment-method").data("email");
handler.open({
name: 'Zulip',
zipCode: true,
billingAddress: true,
panelLabel: "Update card",
email: email,
label: "Update card",
allowRememberMe: false,
});
e.preventDefault();
});
$('#billing-tabs a').click(function (e) {
e.preventDefault();
$(this).tab('show');
});
});

View File

@ -149,5 +149,60 @@
#error-message-box {
margin-top: 10px;
font-weight: 600;
display: none;
}
#loading-section {
display: none;
min-height: 55px;
}
#card-updated-message {
text-align: center;
display: none;
}
#update-card-button-span {
display: block;
min-height: 30px;
}
.updating-card-logo {
margin: 0 auto;
width: 24px;
height: 24px;
}
.updating-card-logo svg circle {
fill: #444;
stroke: #444;
}
.updating-card-logo svg path {
fill: #fff;
stroke: #fff;
}
#updating_card_indicator {
margin: 10px auto;
}
#updating_card_indicator_box_container {
position: absolute;
left: 50%;
}
#updating_card_indicator_box {
position: relative;
left: -50%;
top: -41px;
z-index: 1;
-webkit-border-radius: 6px;
-moz-border-radius: 6px;
border-radius: 6px;
}
.loading_indicator_text {
margin-left: -75px;
}
}

View File

@ -14,6 +14,7 @@ class Customer:
subscriptions: SubscriptionListObject
coupon: str
account_balance: int
email: str
@staticmethod
def retrieve(customer_id: str=..., expand: Optional[List[str]]=...) -> Customer:

View File

@ -4,6 +4,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0">
{{ render_bundle('landing-page') }}
{{ render_bundle('billing') }}
<script src="https://checkout.stripe.com/checkout.js"></script>
{% endblock %}
{% block content %}
@ -18,13 +19,7 @@
<div class="page-content">
<div class="main">
{% if error_message %}
<div class="alert alert-danger" id="error-message-box">
{{ error_message }}
</div>
{% endif %}
<div class="alert alert-danger" id="error-message-box"></div>
<h1>{{ _("Billing") }}</h1>
{% if admin_access %}
<ul class="nav nav-tabs" id="billing-tabs">
@ -43,8 +38,29 @@
<p>You have <strong>${{ prorated_credits }}</strong> in prorated credits that will be automatically applied to your next bill.</p>
{% endif %}
</div>
<div class="tab-pane" id="payment-method">
<div class="tab-pane" id="payment-method" data-email="{{stripe_email}}" data-csrf="{{csrf_token}}" data-key="{{publishable_key}}">
<div id="payment-section">
<p>Your current payment method is <strong>{{ payment_method }}</strong>.</p>
<button id="update-card-button" class="stripe-button-el">
<span id="update-card-button-span">Update card</span>
</button>
</div>
<div id="loading-section">
<div class="updating-card-logo">
<svg xmlns="http://www.w3.org/2000/svg" width="100%" height="100%" viewBox="0 0 40 40" version="1.1">
<g transform="translate(-297.14285,-466.64792)">
<circle cx="317.14285" cy="486.64792" r="19.030317" style="stroke-width:1.93936479;"/>
<path d="m309.24286 477.14791 14.2 0 1.6 3.9-11.2 11.9 9.6 0 1.6 3.2-14.2 0-1.6-3.9 11.2-11.9-9.6 0z"/>
</g>
</svg>
</div>
<div id="updating_card_indicator"></div>
</div>
<div id="card-updated-message" class="alert alert-success">
Card updated. The page will now reload.
</div>
</div>
<div class="tab-pane" id="loading">
</div>
</div>
@ -52,12 +68,6 @@
<p>Contact <a href="mailto:support@zulipchat.com">support@zulipchat.com</a> for billing history or to make changes to your subscription.</p>
</div>
<script>
$('#billing-tabs a').click(function (e) {
e.preventDefault();
$(this).tab('show');
})
</script>
{% else %}
<p>
You must be an organization administrator or a

View File

@ -254,6 +254,7 @@ def build_custom_checkers(by_lang):
# External modules that don't include channel.js
'static/js/stats/',
'static/js/portico/',
'static/js/billing/',
]),
'good_lines': ['channel.get(...)'],
'bad_lines': ['$.get()', '$.post()', '$.ajax()']},
@ -700,6 +701,7 @@ def build_custom_checkers(by_lang):
'templates/zerver/app/home.html',
'templates/zerver/features.html',
'templates/zerver/portico-header.html',
'templates/zilencer/billing.html',
# Miscellaneous violations to be cleaned up
'static/templates/user_info_popover_title.handlebars',

View File

@ -17,6 +17,11 @@
"./static/templates/compiled.js"
],
"billing": [
"./static/js/billing/billing.js",
"./node_modules/handlebars/dist/handlebars.runtime.js",
"./static/js/templates.js",
"./static/templates/compiled.js",
"./static/js/loading.js",
"./static/styles/billing.scss"
],
"portico": [

View File

@ -482,6 +482,45 @@ class StripeTest(ZulipTestCase):
response = self.client_post("/json/billing/downgrade", {})
self.assert_json_success(response)
@mock.patch("stripe.Customer.retrieve", side_effect=mock_customer_with_subscription)
def test_replace_payment_source(self, mock_retrieve_customer: mock.Mock) -> None:
user = self.example_user("iago")
self.login(user.email)
Customer.objects.create(realm=user.realm, stripe_customer_id=self.stripe_customer_id)
with mock.patch.object(stripe.Customer, 'save', autospec=True,
side_effect=lambda customer: self.assertEqual(customer.source, "new_token")):
result = self.client_post("/json/billing/sources/change",
{'stripe_token': ujson.dumps("new_token")})
self.assert_json_success(result)
log_entry = RealmAuditLog.objects.order_by('-id').first()
self.assertEqual(user, log_entry.acting_user)
self.assertEqual(RealmAuditLog.STRIPE_CARD_CHANGED, log_entry.event_type)
def test_update_payment_source_permissions(self) -> None:
# This can be removed / merged with e.g. test_downgrade_permissions
# once we have a decorator that handles billing page permissions
self.login(self.example_email('hamlet'))
response = self.client_post("/json/billing/sources/change",
{'stripe_token': ujson.dumps('token')})
self.assert_json_error_contains(response, "Access denied")
# billing admin but not realm admin
user = self.example_user('hamlet')
user.is_billing_admin = True
user.save(update_fields=['is_billing_admin'])
with mock.patch('zilencer.views.do_replace_payment_source') as mocked1:
self.client_post("/json/billing/sources/change",
{'stripe_token': ujson.dumps('token')})
mocked1.assert_called()
# realm admin but not billing admin
user = self.example_user('hamlet')
user.is_billing_admin = False
user.is_realm_admin = True
user.save(update_fields=['is_billing_admin', 'is_realm_admin'])
with mock.patch('zilencer.views.do_replace_payment_source') as mocked2:
self.client_post("/json/billing/sources/change",
{'stripe_token': ujson.dumps('token')})
mocked2.assert_called()
@mock.patch("stripe.Customer.create", side_effect=mock_create_customer)
@mock.patch("stripe.Subscription.create", side_effect=mock_create_subscription)
@mock.patch("stripe.Customer.retrieve", side_effect=mock_customer_with_subscription)

View File

@ -24,6 +24,8 @@ v1_api_and_json_patterns = [
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

View File

@ -28,7 +28,7 @@ from zerver.views.push_notifications import validate_token
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
unsign_string, BillingError, process_downgrade, do_replace_payment_source
from zilencer.models import RemotePushDeviceToken, RemoteZulipServer, \
Customer, Plan
@ -279,6 +279,8 @@ def billing_home(request: HttpRequest) -> HttpResponse:
'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)
@ -291,3 +293,14 @@ def downgrade(request: HttpRequest, user: UserProfile) -> HttpResponse:
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()