mirror of https://github.com/zulip/zulip.git
billing: Add ability for users to change their card.
This commit is contained in:
parent
5e88b3013a
commit
5a6b2ebb1f
|
@ -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');
|
||||
});
|
||||
});
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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">
|
||||
<p>Your current payment method is <strong>{{ payment_method }}</strong>.</p>
|
||||
<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
|
||||
|
|
|
@ -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',
|
||||
|
|
|
@ -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": [
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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()
|
||||
|
|
Loading…
Reference in New Issue