mirror of https://github.com/zulip/zulip.git
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:
parent
a978336765
commit
0bca0286a1
|
@ -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 %}
|
|
@ -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='[^{]",
|
||||||
|
|
|
@ -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 = [
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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
|
||||||
|
|
Loading…
Reference in New Issue