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 {
|
#error-message-box {
|
||||||
margin-top: 10px;
|
margin-top: 10px;
|
||||||
font-weight: 600;
|
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
|
subscriptions: SubscriptionListObject
|
||||||
coupon: str
|
coupon: str
|
||||||
account_balance: int
|
account_balance: int
|
||||||
|
email: str
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def retrieve(customer_id: str=..., expand: Optional[List[str]]=...) -> Customer:
|
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">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
{{ render_bundle('landing-page') }}
|
{{ render_bundle('landing-page') }}
|
||||||
{{ render_bundle('billing') }}
|
{{ render_bundle('billing') }}
|
||||||
|
<script src="https://checkout.stripe.com/checkout.js"></script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
|
@ -18,13 +19,7 @@
|
||||||
|
|
||||||
<div class="page-content">
|
<div class="page-content">
|
||||||
<div class="main">
|
<div class="main">
|
||||||
|
<div class="alert alert-danger" id="error-message-box"></div>
|
||||||
{% if error_message %}
|
|
||||||
<div class="alert alert-danger" id="error-message-box">
|
|
||||||
{{ error_message }}
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
<h1>{{ _("Billing") }}</h1>
|
<h1>{{ _("Billing") }}</h1>
|
||||||
{% if admin_access %}
|
{% if admin_access %}
|
||||||
<ul class="nav nav-tabs" id="billing-tabs">
|
<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>
|
<p>You have <strong>${{ prorated_credits }}</strong> in prorated credits that will be automatically applied to your next bill.</p>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</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>
|
<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>
|
||||||
</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>
|
<p>Contact <a href="mailto:support@zulipchat.com">support@zulipchat.com</a> for billing history or to make changes to your subscription.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script>
|
|
||||||
$('#billing-tabs a').click(function (e) {
|
|
||||||
e.preventDefault();
|
|
||||||
$(this).tab('show');
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
{% else %}
|
{% else %}
|
||||||
<p>
|
<p>
|
||||||
You must be an organization administrator or a
|
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
|
# External modules that don't include channel.js
|
||||||
'static/js/stats/',
|
'static/js/stats/',
|
||||||
'static/js/portico/',
|
'static/js/portico/',
|
||||||
|
'static/js/billing/',
|
||||||
]),
|
]),
|
||||||
'good_lines': ['channel.get(...)'],
|
'good_lines': ['channel.get(...)'],
|
||||||
'bad_lines': ['$.get()', '$.post()', '$.ajax()']},
|
'bad_lines': ['$.get()', '$.post()', '$.ajax()']},
|
||||||
|
@ -700,6 +701,7 @@ def build_custom_checkers(by_lang):
|
||||||
'templates/zerver/app/home.html',
|
'templates/zerver/app/home.html',
|
||||||
'templates/zerver/features.html',
|
'templates/zerver/features.html',
|
||||||
'templates/zerver/portico-header.html',
|
'templates/zerver/portico-header.html',
|
||||||
|
'templates/zilencer/billing.html',
|
||||||
|
|
||||||
# Miscellaneous violations to be cleaned up
|
# Miscellaneous violations to be cleaned up
|
||||||
'static/templates/user_info_popover_title.handlebars',
|
'static/templates/user_info_popover_title.handlebars',
|
||||||
|
|
|
@ -17,6 +17,11 @@
|
||||||
"./static/templates/compiled.js"
|
"./static/templates/compiled.js"
|
||||||
],
|
],
|
||||||
"billing": [
|
"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"
|
"./static/styles/billing.scss"
|
||||||
],
|
],
|
||||||
"portico": [
|
"portico": [
|
||||||
|
|
|
@ -482,6 +482,45 @@ class StripeTest(ZulipTestCase):
|
||||||
response = self.client_post("/json/billing/downgrade", {})
|
response = self.client_post("/json/billing/downgrade", {})
|
||||||
self.assert_json_success(response)
|
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.Customer.create", side_effect=mock_create_customer)
|
||||||
@mock.patch("stripe.Subscription.create", side_effect=mock_create_subscription)
|
@mock.patch("stripe.Subscription.create", side_effect=mock_create_subscription)
|
||||||
@mock.patch("stripe.Customer.retrieve", side_effect=mock_customer_with_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,
|
url(r'^billing/downgrade$', rest_dispatch,
|
||||||
{'POST': 'zilencer.views.downgrade'}),
|
{'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
|
# 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, \
|
from zilencer.lib.stripe import STRIPE_PUBLISHABLE_KEY, \
|
||||||
stripe_get_customer, stripe_get_upcoming_invoice, get_seat_count, \
|
stripe_get_customer, stripe_get_upcoming_invoice, get_seat_count, \
|
||||||
extract_current_subscription, process_initial_upgrade, sign_string, \
|
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, \
|
from zilencer.models import RemotePushDeviceToken, RemoteZulipServer, \
|
||||||
Customer, Plan
|
Customer, Plan
|
||||||
|
|
||||||
|
@ -279,6 +279,8 @@ def billing_home(request: HttpRequest) -> HttpResponse:
|
||||||
'payment_method': payment_method,
|
'payment_method': payment_method,
|
||||||
'prorated_charges': '{:,.2f}'.format(prorated_charges / 100.),
|
'prorated_charges': '{:,.2f}'.format(prorated_charges / 100.),
|
||||||
'prorated_credits': '{:,.2f}'.format(prorated_credits / 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)
|
return render(request, 'zilencer/billing.html', context=context)
|
||||||
|
@ -291,3 +293,14 @@ def downgrade(request: HttpRequest, user: UserProfile) -> HttpResponse:
|
||||||
except BillingError as e:
|
except BillingError as e:
|
||||||
return json_error(e.message, data={'error_description': e.description})
|
return json_error(e.message, data={'error_description': e.description})
|
||||||
return json_success()
|
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