From 0bca0286a14a0849cca848657cbbb9d9085843f1 Mon Sep 17 00:00:00 2001 From: Vishnu Ks Date: Sat, 13 Jan 2018 18:38:13 +0000 Subject: [PATCH] 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.] --- templates/zilencer/payment.html | 96 ++++++++++++++++++++++++++++++++ tools/linter_lib/custom_check.py | 1 + zilencer/urls.py | 4 +- zilencer/views.py | 82 ++++++++++++++++++++++++++- zproject/settings.py | 3 +- 5 files changed, 183 insertions(+), 3 deletions(-) create mode 100644 templates/zilencer/payment.html diff --git a/templates/zilencer/payment.html b/templates/zilencer/payment.html new file mode 100644 index 0000000000..9fc57f1036 --- /dev/null +++ b/templates/zilencer/payment.html @@ -0,0 +1,96 @@ +{% extends "zerver/portico.html" %} + +{% block customhead %} + +{% stylesheet 'portico' %} +{% stylesheet 'landing-page' %} +{{ render_bundle('landing-page') }} + +{% endblock %} + +{% block portico_content %} + +{% include 'zerver/gradients.html' %} +{% include 'zerver/landing_nav.html' %} + +
+
+ {% if error_message %} +
+ {{ error_message }} +
+ {% else %} +
+
+
+
+
+ Zulip Cloud subscription for {{ realm_name }} +
+ {% if payment_method_added %} +
+ The card has been saved successfully. +
+ {% endif %} + {% if num_cards %} +
+ {% if num_cards > 1 %} + You have {{ num_cards }} saved cards. + {% else %} + You have one saved card. + {% endif %} +
+ {% endif %} +
+
+

Premium

+
+ Make Zulip your home +
+
+
    +
  • Full search history
  • +
  • File storage up to 10 GB per user
  • +
  • Full access to enterprise features like Google and GitHub OAuth
  • +
  • Priority commercial support
  • +
  • Funds the Zulip open source project
  • +
+
+
+
+
+
8
+
+ per active user, per month +
+ "$80/year billed annually" +
+
+
+ {{ csrf_input }} + +
+
+
+
+
+
+
+
+ {% endif %} +
+
+{% endblock %} diff --git a/tools/linter_lib/custom_check.py b/tools/linter_lib/custom_check.py index 88212db794..28fa86afab 100644 --- a/tools/linter_lib/custom_check.py +++ b/tools/linter_lib/custom_check.py @@ -527,6 +527,7 @@ def build_custom_checkers(by_lang): 'bad_lines': ['']}, {'pattern': 'script src="http', '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') }}"], 'bad_lines': ['']}, {'pattern': "title='[^{]", diff --git a/zilencer/urls.py b/zilencer/urls.py index b69b1af6c7..8f3d8fb089 100644 --- a/zilencer/urls.py +++ b/zilencer/urls.py @@ -5,7 +5,9 @@ from django.conf.urls import include, url import zilencer.views 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 v1_api_and_json_patterns = [ diff --git a/zilencer/views.py b/zilencer/views.py index 2a01423515..be8a0b95e4 100644 --- a/zilencer/views.py +++ b/zilencer/views.py @@ -1,10 +1,19 @@ +import logging from typing import Any, Dict, Optional, Text, Union, cast from django.http import HttpRequest, HttpResponse from django.utils import timezone 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.push_notifications import send_android_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.models import UserProfile 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: 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) 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) diff --git a/zproject/settings.py b/zproject/settings.py index f29b08c568..a4bed7dc04 100644 --- a/zproject/settings.py +++ b/zproject/settings.py @@ -15,6 +15,7 @@ import os import platform import time import sys +from typing import Optional import configparser from zerver.lib.db import TimeTrackingConnection @@ -39,7 +40,7 @@ if PRODUCTION: else: 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): return secrets_file.get('secrets', key) return None