billing: Integrate Stripe, using Stripe Checkout.

Stripe Checkout means using JS code provided by Stripe to handle
almost all of the UI, which is great for us.

There are more features we should add to this page and changes we
should make, but this gives us an MVP.

[greg: expanded commit message; fixed import ordering and some types.]
This commit is contained in:
Vishnu Ks 2018-01-13 18:38:13 +00:00 committed by Greg Price
parent a978336765
commit 0bca0286a1
5 changed files with 183 additions and 3 deletions

View File

@ -0,0 +1,96 @@
{% extends "zerver/portico.html" %}
{% block customhead %}
<meta name="viewport" content="width=device-width, initial-scale=1.0">
{% stylesheet 'portico' %}
{% stylesheet 'landing-page' %}
{{ render_bundle('landing-page') }}
{% endblock %}
{% block portico_content %}
{% include 'zerver/gradients.html' %}
{% include 'zerver/landing_nav.html' %}
<div class="portico-landing">
<div class="main">
{% if error_message %}
<div class="alert alert-danger">
{{ error_message }}
</div>
{% else %}
<div class="pricing-model">
<div class="padded-content">
<div class="pricing-container">
<div class="block">
<div class="plan-title zulip-cloud">
Zulip Cloud subscription for {{ realm_name }}
</div>
{% if payment_method_added %}
<div class="alert alert-info">
The card has been saved successfully.
</div>
{% endif %}
{% if num_cards %}
<div class="alert alert-info">
{% if num_cards > 1 %}
You have {{ num_cards }} saved cards.
{% else %}
You have one saved card.
{% endif %}
</div>
{% endif %}
<div class="price-box" tabindex="-1">
<div class="text-content">
<h2>Premium</h2>
<div class="description">
Make Zulip your home
</div>
<hr />
<ul class="feature-list">
<li>Full search history</li>
<li>File storage up to 10 GB per user</li>
<li>Full access to enterprise features like Google and GitHub OAuth</li>
<li>Priority commercial support</li>
<li>Funds the Zulip open source project</li>
</ul>
</div>
<div class="bottom">
<div class="text-content">
<div class="">
<div class="price">8</div>
<div class="details">
per active user, per month
<br />
"$80/year billed annually"
</div>
</div>
<form method="post">
{{ csrf_input }}
<script src="https://checkout.stripe.com/checkout.js" class="stripe-button"
data-key="{{ publishable_key }}"
data-image="/static/images/logo/zulip-icon-128x128.png"
data-name="Zulip"
data-description="Zulip Cloud Premium"
data-panel-label="Save card"
{% if num_cards %}
data-label="Add another card"
{% else %}
data-label="Add card"
{% endif %}
data-email="{{ email }}"
data-locale="auto">
</script>
</form>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
{% endif %}
</div>
</div>
{% endblock %}

View File

@ -527,6 +527,7 @@ def build_custom_checkers(by_lang):
'bad_lines': ['<button aria-label="foo"></button>']}, 'bad_lines': ['<button aria-label="foo"></button>']},
{'pattern': 'script src="http', {'pattern': 'script src="http',
'description': "Don't directly load dependencies from CDNs. See docs/subsystems/front-end-build-process.md", 'description': "Don't directly load dependencies from CDNs. See docs/subsystems/front-end-build-process.md",
'exclude': set(["templates/zilencer/payment.html"]),
'good_lines': ["{{ render_bundle('landing-page') }}"], 'good_lines': ["{{ render_bundle('landing-page') }}"],
'bad_lines': ['<script src="https://ajax.googleapis.com/ajax/libs/jquery/3.2.1/jquery.min.js"></script>']}, 'bad_lines': ['<script src="https://ajax.googleapis.com/ajax/libs/jquery/3.2.1/jquery.min.js"></script>']},
{'pattern': "title='[^{]", {'pattern': "title='[^{]",

View File

@ -5,7 +5,9 @@ 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 = [] # type: Any i18n_urlpatterns = [
url(r'^billing/$', zilencer.views.add_payment_method),
] # 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 = [

View File

@ -1,10 +1,19 @@
import logging
from typing import Any, Dict, Optional, Text, Union, cast from typing import Any, Dict, Optional, Text, Union, cast
from django.http import HttpRequest, HttpResponse from django.http import HttpRequest, HttpResponse
from django.utils import timezone from django.utils import timezone
from django.utils.translation import ugettext as _ from django.utils.translation import ugettext as _
from django.shortcuts import render
from django.conf import settings
from django.views.decorators.http import require_GET
from django.views.decorators.csrf import csrf_exempt
import stripe
from stripe.error import CardError, RateLimitError, InvalidRequestError, \
AuthenticationError, APIConnectionError, StripeError
from zerver.decorator import require_post, zulip_login_required
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
@ -13,7 +22,12 @@ from zerver.lib.response import json_error, json_success
from zerver.lib.validator import check_int from zerver.lib.validator import check_int
from zerver.models import UserProfile from zerver.models import UserProfile
from zerver.views.push_notifications import validate_token from zerver.views.push_notifications import validate_token
from zilencer.models import RemotePushDeviceToken, RemoteZulipServer from zilencer.models import RemotePushDeviceToken, RemoteZulipServer, Customer
from zproject.settings import get_secret
STRIPE_SECRET_KEY = get_secret('stripe_secret_key')
STRIPE_PUBLISHABLE_KEY = get_secret('stripe_publishable_key')
stripe.api_key = STRIPE_SECRET_KEY
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):
@ -96,3 +110,69 @@ def remote_server_notify_push(request: HttpRequest, entity: Union[UserProfile, R
send_apple_push_notification(user_id, apple_devices, apns_payload) send_apple_push_notification(user_id, apple_devices, apns_payload)
return json_success() return json_success()
@zulip_login_required
def add_payment_method(request: HttpRequest) -> HttpResponse:
user = request.user
ctx = {
"publishable_key": STRIPE_PUBLISHABLE_KEY,
"email": user.email,
} # type: Dict[str, Any]
if not user.is_realm_admin:
ctx["error_message"] = _("You should be an administrator of the organization %s to view this page."
% (user.realm.name,))
return render(request, 'zilencer/payment.html', context=ctx)
try:
if request.method == "GET":
try:
customer_obj = Customer.objects.get(realm=user.realm)
cards = stripe.Customer.retrieve(customer_obj.stripe_customer_id).sources.all(object="card")
ctx["num_cards"] = len(cards["data"])
except Customer.DoesNotExist:
ctx["num_cards"] = 0
return render(request, 'zilencer/payment.html', context=ctx)
if request.method == "POST":
token = request.POST.get("stripeToken", "")
# The card metadata doesn't show up in Dashboard but can be accessed
# using the API.
card_metadata = {"added_user_id": user.id, "added_user_email": user.email}
try:
customer_obj = Customer.objects.get(realm=user.realm)
customer = stripe.Customer.retrieve(customer_obj.stripe_customer_id)
customer.sources.create(source=token, metadata=card_metadata)
ctx["num_cards"] = len(customer.sources.all(object="card")["data"])
except Customer.DoesNotExist:
customer_metadata = {"string_id": user.realm.string_id}
# Description makes it easier to identify customers in Stripe dashboard
description = "{} ({})".format(user.realm.name, user.realm.string_id)
customer = stripe.Customer.create(source=token,
description=description,
metadata=customer_metadata)
card = customer.sources.all(object="card")["data"][0]
card.metadata = card_metadata
card.save()
Customer.objects.create(realm=user.realm, stripe_customer_id=customer.id)
ctx["num_cards"] = 1
ctx["payment_method_added"] = True
return render(request, 'zilencer/payment.html', context=ctx)
except (CardError, RateLimitError, APIConnectionError) as e:
err = e.json_body.get('error', {})
logging.error("Stripe error - Status: {}, Type: {}, Code: {}, Param: {}, Message: {}".format(
e.http_status, err.get('type'), err.get('code'), err.get('param'), err.get('message')
))
ctx["error_message"] = err.get('message')
return render(request, 'zilencer/payment.html', context=ctx)
except (InvalidRequestError, AuthenticationError, StripeError) as e:
err = e.json_body.get('error', {})
logging.error("Stripe error - Status: {}, Type: {}, Code: {}, Param: {}, Message: {}".format(
e.http_status, err.get('type'), err.get('code'), err.get('param'), err.get('message')
))
except Exception as e:
logging.error('Stripe error: %s' % (str(e),))
ctx["error_message"] = _("Something went wrong. Please try again or email us at %s."
% (settings.ZULIP_ADMINISTRATOR,))
return render(request, 'zilencer/payment.html', context=ctx)

View File

@ -15,6 +15,7 @@ import os
import platform import platform
import time import time
import sys import sys
from typing import Optional
import configparser import configparser
from zerver.lib.db import TimeTrackingConnection from zerver.lib.db import TimeTrackingConnection
@ -39,7 +40,7 @@ if PRODUCTION:
else: else:
secrets_file.read(os.path.join(DEPLOY_ROOT, "zproject/dev-secrets.conf")) secrets_file.read(os.path.join(DEPLOY_ROOT, "zproject/dev-secrets.conf"))
def get_secret(key: str) -> None: def get_secret(key: str) -> Optional[str]:
if secrets_file.has_option('secrets', key): if secrets_file.has_option('secrets', key):
return secrets_file.get('secrets', key) return secrets_file.get('secrets', key)
return None return None