billing: Update billing system.

This commit is contained in:
Rishi Gupta 2018-03-30 19:13:44 -07:00 committed by Tim Abbott
parent 872e8c1d7b
commit 16334a1ba7
17 changed files with 909 additions and 479 deletions

View File

@ -0,0 +1,43 @@
# Billing
Zulip uses a third party (Stripe) for billing, so working on the billing
system requires a little bit of setup.
To set up the development environment to work on the billing code:
* Create a Stripe account
* Go to <https://dashboard.stripe.com/account/apikeys>, and add the
publishable key and secret key as `stripe_publishable_key` and
`stripe_secret_key` to `zproject/dev-secrets.conf`.
* Run `./manage.py setup_stripe`.
It is safe to run `manage.py setup_stripe` multiple times.
Nearly all the billing-relevant code lives in `zilencer/`.
## General architecture
Notes:
* Anything that talks directly to Stripe should go in
`zilencer/lib/stripe.py`.
* We generally try to store billing-related data in Stripe, rather than in
Zulip database tables. We'd rather pay the penalty of making extra stripe
API requests than deal with keeping two sources of data in sync.
The two main billing-related states for a realm are "have never had a
billing relationship with Zulip" and its opposite. This is determined by
`Customer.objects.filter(realm=realm).exists()`. If a realm doesn't have a
billing relationship, all the messaging, screens, etc. are geared towards
making it easy to upgrade. If a realm does have a billing relationship, all
the screens are geared toward making it easy to access current and
historical billing information.
Note that having a billing relationship doesn't necessarily mean they are on
a paid plan, or have been in the past. E.g. adding a coupon for a potential
customer requires creating a Customer object.
Notes:
* When manually testing, I find I often run `Customer.objects.all().delete()`
to reset the state.
* 4242424242424242 is Stripe's test credit card, also useful for manually
testing. You can put anything in the address fields, any future expiry
date, and anything for the CVV code.

View File

@ -39,4 +39,5 @@ Subsystems Documentation
input-pills input-pills
presence presence
unread_messages unread_messages
billing
user-docs user-docs

View File

@ -1,93 +1,34 @@
{% extends "zerver/portico.html" %} {% extends "zerver/base.html" %}
{% block customhead %} {% block customhead %}
<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') }}
{% endblock %} {% endblock %}
{% block portico_content %} {% block content %}
<div class="app portico-page">
{% include 'zerver/gradients.html' %} {{ render_bundle('translations') }}
{% include 'zerver/landing_nav.html' %}
<div class="portico-landing"> <div class="page-content">
<div class="main"> <h1>{{ _("Billing") }}</h1>
{% if error_message %} Plan<br/>
<div class="alert alert-danger"> {{ plan_name }}<br/>
{{ error_message }} <br/>
</div> You are paying for {{ seat_count }} users.<br/>
{% else %} Your plan will renew on {{ renewal_date }} for ${{ renewal_amount }}.<br/>
<div class="pricing-model"> {% if prorated_charges %}
<div class="padded-content"> You have ${{ prorated_charges }} in prorated charges that will be
<div class="pricing-container"> added to your next bill.
<div class="block"> {% elif prorated_credits %}
<div class="plan-title responsive-title"> You have ${{ prorated_credits }} in prorated credits that will be
Zulip Cloud subscription for {{ realm_name }} automatically applied to your next bill.
</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 %} {% endif %}
<br/>
<br/>
Payment method: {{ payment_method }}.<br/>
<br/>
Contact support@zulipchat.com for billing history or to make changes to your subscription.
</div> </div>
</div> </div>
{% endblock %} {% endblock %}

View File

@ -0,0 +1,50 @@
{% extends "zerver/base.html" %}
{% block customhead %}
{% stylesheet 'portico' %}
{% endblock %}
{% block content %}
<div class="app portico-page">
{{ render_bundle('translations') }}
<div class="page-content">
<h1>{% trans %}Upgrade to {{ plan }}{% endtrans %}</h1>
<form method="post">
{{ csrf_input }}
<input type="hidden" name="seat_count" value="{{ seat_count }}">
<div class="payment-schedule">
<h3>{{ _("Payment schedule") }}</h3>
<input type="radio" name="plan" value="{{ nickname_annual }}" checked />
{{ _("Pay annually") }} | $80/user/year <br/>
<input type="radio" name="plan" value="{{ nickname_monthly }}" />
{{ _("Pay monthly") }} | $8/user/month <br/>
</div>
<p>
You'll initially be charged <b><span id="charged_amount">XXX</span></b>
for <b>{{ seat_count }}</b> users. You'll receive prorated charges
and credits as users are added, deactivated, or become inactive.
</p>
<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-locale="auto"
data-zip-code="true"
data-billing-address="true"
data-panel-label="Make payment"
data-email="{{ email }}"
data-label="{{ _('Add card') }}"
data-allow-remember-me="false">
</script>
</form>
<p>
We can also bill by invoice for annual contracts over
$2000. Contact support@zulipchat.com to pay by invoice or for
any other billing questions.
</p>
</div>
</div>
{% endblock %}

View File

@ -623,7 +623,8 @@ 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/billing.html", "templates/zerver/hello.html"]), 'exclude': set(["templates/zilencer/billing.html", "templates/zerver/hello.html",
"templates/zilencer/upgrade.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

@ -313,9 +313,12 @@ if __name__ == "__main__":
full_suite = len(args) == 0 full_suite = len(args) == 0
if len(args) == 0: if len(args) == 0:
suites = ["zerver.tests", suites = [
"zerver.webhooks", "zerver.tests",
"analytics.tests"] "zerver.webhooks",
"analytics.tests",
"zilencer.tests",
]
else: else:
suites = args suites = args

View File

@ -1,189 +0,0 @@
{
"list_sources": {
"data": [
{
"address_city": null,
"address_country": null,
"address_line1": null,
"address_line1_check": null,
"address_line2": null,
"address_state": null,
"address_zip": null,
"address_zip_check": null,
"brand": "Visa",
"country": "US",
"customer": "cus_CFIUHRMRPADEsO",
"cvc_check": "pass",
"dynamic_last4": null,
"exp_month": 11,
"exp_year": 2023,
"fingerprint": "PbUZJH6QO0VTOjLX",
"funding": "credit",
"id": "card_1BqntOD2X8vgpBNGma3qYOvo",
"last4": "4242",
"metadata": {},
"name": "iago@zulip.com",
"object": "card",
"tokenization_method": null
},
{
"address_city": null,
"address_country": null,
"address_line1": null,
"address_line1_check": null,
"address_line2": null,
"address_state": null,
"address_zip": null,
"address_zip_check": null,
"brand": "Visa",
"country": "US",
"customer": "cus_CFIUHRMRPADEsO",
"cvc_check": "pass",
"dynamic_last4": null,
"exp_month": 8,
"exp_year": 2024,
"fingerprint": "PbUZJH6QO0VTOjLX",
"funding": "credit",
"id": "card_1BqntOD2X8vgpBNGma3qYOvo",
"last4": "4242",
"metadata": {},
"name": "iago@zulip.com",
"object": "card",
"tokenization_method": null
}
],
"has_more": false,
"object": "list",
"url": "/v1/customers/cus_CFIUHRMRPADEsO/sources"
},
"create_source": {
"address_city": null,
"address_country": null,
"address_line1": null,
"address_line1_check": null,
"address_line2": null,
"address_state": null,
"address_zip": null,
"address_zip_check": null,
"brand": "Visa",
"country": "US",
"customer": "cus_CFIUHRMRPADEsO",
"cvc_check": "pass",
"dynamic_last4": null,
"exp_month": 12,
"exp_year": 2019,
"fingerprint": "PbUZJH6QO0VTOjLX",
"funding": "credit",
"id": "card_1Br4yHD2X8vgpBNGGQsTMfKy",
"last4": "4242",
"metadata": {
"added_user_email": "iago@zulip.com",
"added_user_id": "5"
},
"name": null,
"object": "card",
"tokenization_method": null
},
"create_customer": {
"account_balance": 0,
"created": 1517513926,
"currency": null,
"default_source": "card_1BqntOD2X8vgpBNGma3qYOvo",
"delinquent": false,
"description": "Zulip Dev (zulip)",
"discount": null,
"email": null,
"id": "cus_CFIUHRMRPADEsO",
"livemode": false,
"metadata": {
"string_id": "zulip"
},
"object": "customer",
"shipping": null,
"sources": {
"data": [
{
"address_city": null,
"address_country": null,
"address_line1": null,
"address_line1_check": null,
"address_line2": null,
"address_state": null,
"address_zip": null,
"address_zip_check": null,
"brand": "Visa",
"country": "US",
"customer": "cus_CFIUHRMRPADEsO",
"cvc_check": "pass",
"dynamic_last4": null,
"exp_month": 11,
"exp_year": 2023,
"fingerprint": "PbUZJH6QO0VTOjLX",
"funding": "credit",
"id": "card_1BqntOD2X8vgpBNGma3qYOvo",
"last4": "4242",
"metadata": {},
"name": "iago@zulip.com",
"object": "card",
"tokenization_method": null
}
],
"has_more": false,
"object": "list",
"total_count": 1,
"url": "/v1/customers/cus_CFIUHRMRPADEsO/sources"
},
"subscriptions": {}
},
"retrieve_customer": {
"account_balance": 0,
"created": 1517513926,
"currency": null,
"default_source": "card_1BqntOD2X8vgpBNGma3qYOvo",
"delinquent": false,
"description": "Zulip Dev (zulip)",
"discount": null,
"email": null,
"id": "cus_CFIUHRMRPADEsO",
"livemode": false,
"metadata": {
"string_id": "zulip"
},
"object": "customer",
"shipping": null,
"sources": {
"data": [
{
"address_city": null,
"address_country": null,
"address_line1": null,
"address_line1_check": null,
"address_line2": null,
"address_state": null,
"address_zip": null,
"address_zip_check": null,
"brand": "Visa",
"country": "US",
"customer": "cus_CFIUHRMRPADEsO",
"cvc_check": "pass",
"dynamic_last4": null,
"exp_month": 11,
"exp_year": 2023,
"fingerprint": "PbUZJH6QO0VTOjLX",
"funding": "credit",
"id": "card_1BqntOD2X8vgpBNGma3qYOvo",
"last4": "4242",
"metadata": {},
"name": "iago@zulip.com",
"object": "card",
"tokenization_method": null
}
],
"has_more": false,
"object": "list",
"total_count": 1,
"url": "/v1/customers/cus_CFIUHRMRPADEsO/sources"
},
"subscriptions": {}
}
}

View File

@ -1,133 +0,0 @@
import mock
import os
from typing import Any
import ujson
import stripe
from stripe.api_resources.list_object import ListObject
from zerver.lib.test_classes import ZulipTestCase
from zerver.models import Realm, UserProfile
from zilencer.lib.stripe import StripeError, save_stripe_token, catch_stripe_errors
from zilencer.models import Customer
fixture_data_file = open(os.path.join(os.path.dirname(__file__), 'fixtures/stripe.json'), 'r')
fixture_data = ujson.load(fixture_data_file)
def mock_list_sources(*args: Any, **kwargs: Any) -> ListObject:
return stripe.util.convert_to_stripe_object(fixture_data["list_sources"])
def mock_create_source(*args: Any, **kwargs: Any) -> ListObject:
return stripe.util.convert_to_stripe_object(fixture_data["create_source"])
def mock_create_customer(*args: Any, **kwargs: Any) -> ListObject:
return stripe.util.convert_to_stripe_object(fixture_data["create_customer"])
def mock_retrieve_customer(*args: Any, **kwargs: Any) -> ListObject:
return stripe.util.convert_to_stripe_object(fixture_data["retrieve_customer"])
class StripeTest(ZulipTestCase):
def setUp(self) -> None:
self.token = "token"
self.user = self.example_user("iago")
self.realm = self.user.realm
@mock.patch("zilencer.lib.stripe.STRIPE_PUBLISHABLE_KEY", "stripe_publishable_key")
@mock.patch("zilencer.lib.stripe.billing_logger.info")
@mock.patch("stripe.api_resources.list_object.ListObject.create", side_effect=mock_create_source)
@mock.patch("stripe.api_resources.list_object.ListObject.list", side_effect=mock_list_sources)
@mock.patch("stripe.Customer.create", side_effect=mock_create_customer)
@mock.patch("stripe.Customer.retrieve", side_effect=mock_retrieve_customer)
@mock.patch("stripe.api_resources.card.Card.save")
@mock.patch("stripe.api_resources.customer.Customer.save")
def test_save_stripe_token(self, mock_save_customer: mock.Mock, mock_save_card: mock.Mock,
mock_retrieve_customer: mock.Mock, mock_create_customer: mock.Mock,
mock_list_sources: mock.Mock, mock_create_source: mock.Mock,
mock_billing_logger_info: mock.Mock) -> None:
self.assertFalse(Customer.objects.filter(realm=self.realm))
number_of_cards = save_stripe_token(self.user, self.token)
self.assertEqual(number_of_cards, 1)
description = "{} ({})".format(self.realm.name, self.realm.string_id)
mock_create_customer.assert_called_once_with(description=description, source=self.token,
metadata={'string_id': self.realm.string_id})
mock_list_sources.assert_called_once()
mock_save_card.assert_called_once()
mock_billing_logger_info.assert_called()
customer_object = Customer.objects.get(realm=self.realm)
# Add another card
number_of_cards = save_stripe_token(self.user, self.token)
# Note: customer.sources.list is mocked to return 2 cards all the time.
self.assertEqual(number_of_cards, 2)
mock_retrieve_customer.assert_called_once_with(customer_object.stripe_customer_id)
create_source_metadata = {'added_user_id': self.user.id, 'added_user_email': self.user.email}
mock_create_source.assert_called_once_with(metadata=create_source_metadata, source='token')
mock_save_customer.assert_called_once()
mock_billing_logger_info.assert_called()
@mock.patch("zilencer.lib.stripe.STRIPE_PUBLISHABLE_KEY", "stripe_publishable_key")
@mock.patch("zilencer.lib.stripe.billing_logger.error")
def test_errors(self, mock_billing_logger_error: mock.Mock) -> None:
@catch_stripe_errors
def raise_invalid_request_error() -> None:
raise stripe.error.InvalidRequestError("Request req_oJU621i6H6X4Ez: No such token: x",
None)
with self.assertRaisesRegex(StripeError, "Something went wrong. Please try again or "):
raise_invalid_request_error()
mock_billing_logger_error.assert_called()
@catch_stripe_errors
def raise_card_error() -> None:
error_message = "The card number is not a valid credit card number."
json_body = {"error": {"message": error_message}}
raise stripe.error.CardError(error_message, "number", "invalid_number",
json_body=json_body)
with self.assertRaisesRegex(StripeError,
"The card number is not a valid credit card number."):
raise_card_error()
mock_billing_logger_error.assert_called()
@catch_stripe_errors
def raise_exception() -> None:
raise Exception
with self.assertRaises(Exception):
raise_exception()
mock_billing_logger_error.assert_called()
@mock.patch("zilencer.views.STRIPE_PUBLISHABLE_KEY", "stripe_publishable_key")
@mock.patch("zilencer.lib.stripe.STRIPE_PUBLISHABLE_KEY", "stripe_publishable_key")
def test_billing_page_view_permissions(self) -> None:
result = self.client_get("/billing/")
self.assertEqual(result.status_code, 302)
self.assertEqual(result["Location"], "/login?next=/billing/")
self.login(self.example_email("hamlet"))
result = self.client_get("/billing/")
message = ("You should be an administrator of the organization {} to view this page."
.format(self.realm.name))
self.assert_in_success_response([message], result)
self.assert_not_in_success_response(["stripe_publishable_key"], result)
self.login(self.example_email("iago"))
result = self.client_get("/billing/")
self.assert_not_in_success_response([message], result)
self.assert_in_success_response(["stripe_publishable_key"], result)
def test_billing_page_view_add_card(self) -> None:
self.login(self.example_email("iago"))
with mock.patch("zilencer.views.save_stripe_token", side_effect=StripeError("Stripe error")):
result = self.client_post("/billing/", {"stripeToken": self.token})
self.assert_in_success_response(["Stripe error"], result)
self.assert_not_in_success_response(["The card has been saved successfully"], result)
with mock.patch("zilencer.views.save_stripe_token", return_value=1), \
mock.patch("zilencer.views.count_stripe_cards", return_value=1):
result = self.client_post("/billing/", {"stripeToken": self.token})
self.assert_in_success_response(["The card has been saved successfully"], result)
# Add another card
with mock.patch("zilencer.views.save_stripe_token", return_value=2), \
mock.patch("zilencer.views.count_stripe_cards", return_value=2):
result = self.client_post("/billing/", {"stripeToken": self.token})
self.assert_in_success_response(["The card has been saved successfully"], result)

View File

@ -1,7 +1,8 @@
import datetime
from functools import wraps from functools import wraps
import logging import logging
import os import os
from typing import Any, Callable, TypeVar from typing import Any, Callable, Optional, TypeVar
from django.conf import settings from django.conf import settings
from django.utils.translation import ugettext as _ from django.utils.translation import ugettext as _
@ -9,13 +10,13 @@ import stripe
from zerver.lib.exceptions import JsonableError from zerver.lib.exceptions import JsonableError
from zerver.lib.logging_util import log_to_file from zerver.lib.logging_util import log_to_file
from zerver.lib.timestamp import datetime_to_timestamp
from zerver.models import Realm, UserProfile from zerver.models import Realm, UserProfile
from zilencer.models import Customer from zilencer.models import Customer, Plan
from zproject.settings import get_secret from zproject.settings import get_secret
STRIPE_SECRET_KEY = get_secret('stripe_secret_key')
STRIPE_PUBLISHABLE_KEY = get_secret('stripe_publishable_key') STRIPE_PUBLISHABLE_KEY = get_secret('stripe_publishable_key')
stripe.api_key = STRIPE_SECRET_KEY stripe.api_key = get_secret('stripe_secret_key')
BILLING_LOG_PATH = os.path.join('/var/log/zulip' BILLING_LOG_PATH = os.path.join('/var/log/zulip'
if not settings.DEVELOPMENT if not settings.DEVELOPMENT
@ -25,8 +26,30 @@ billing_logger = logging.getLogger('zilencer.stripe')
log_to_file(billing_logger, BILLING_LOG_PATH) log_to_file(billing_logger, BILLING_LOG_PATH)
log_to_file(logging.getLogger('stripe'), BILLING_LOG_PATH) log_to_file(logging.getLogger('stripe'), BILLING_LOG_PATH)
# To generate the fixture data in stripe_fixtures.json:
# * Set PRINT_STRIPE_FIXTURE_DATA to True
# * ./manage.py setup_stripe
# * Customer.objects.all().delete()
# * Log in as a user, and go to http://localhost:9991/upgrade/
# * Click Add card. Enter the following billing details:
# Name: Ada Starr, Street: Under the sea, City: Pacific,
# Zip: 33333, Country: United States
# Card number: 4242424242424242, Expiry: 03/33, CVV: 333
# * Click Make payment.
# * Copy out the 4 blobs of json from the dev console into stripe_fixtures.json.
# The contents of that file are '{\n' + concatenate the 4 json blobs + '\n}'.
# Then you can run e.g. `M-x mark-whole-buffer` and `M-x indent-region` in emacs
# to prettify the file (and make 4 space indents).
# * Copy out the customer id, plan id, and quantity values into
# zilencer.tests.test_stripe.StripeTest.setUp.
# * Set PRINT_STRIPE_FIXTURE_DATA to False
PRINT_STRIPE_FIXTURE_DATA = False
CallableT = TypeVar('CallableT', bound=Callable[..., Any]) CallableT = TypeVar('CallableT', bound=Callable[..., Any])
def get_seat_count(realm: Realm) -> int:
return UserProfile.objects.filter(realm=realm, is_active=True, is_bot=False).count()
class StripeError(JsonableError): class StripeError(JsonableError):
pass pass
@ -36,7 +59,7 @@ def catch_stripe_errors(func: CallableT) -> CallableT:
if STRIPE_PUBLISHABLE_KEY is None: if STRIPE_PUBLISHABLE_KEY is None:
# Dev-only message; no translation needed. # Dev-only message; no translation needed.
raise StripeError( raise StripeError(
"Missing Stripe config. In dev, add to zproject/dev-secrets.conf .") "Missing Stripe config. See https://zulip.readthedocs.io/en/latest/subsystems/billing.html.")
try: try:
return func(*args, **kwargs) return func(*args, **kwargs)
except stripe.error.StripeError as e: except stripe.error.StripeError as e:
@ -54,41 +77,53 @@ def catch_stripe_errors(func: CallableT) -> CallableT:
return wrapped # type: ignore # https://github.com/python/mypy/issues/1927 return wrapped # type: ignore # https://github.com/python/mypy/issues/1927
@catch_stripe_errors @catch_stripe_errors
def count_stripe_cards(realm: Realm) -> int: def get_stripe_customer(stripe_customer_id: int) -> Any:
try: stripe_customer = stripe.Customer.retrieve(stripe_customer_id)
customer_obj = Customer.objects.get(realm=realm) if PRINT_STRIPE_FIXTURE_DATA:
cards = stripe.Customer.retrieve(customer_obj.stripe_customer_id).sources.all(object="card") print(''.join(['"retrieve_customer": ', str(stripe_customer), ','])) # nocoverage
return len(cards["data"]) return stripe_customer
except Customer.DoesNotExist:
return 0
@catch_stripe_errors @catch_stripe_errors
def save_stripe_token(user: UserProfile, token: str) -> int: def get_upcoming_invoice(stripe_customer_id: int) -> Any:
"""Returns total number of cards.""" stripe_invoice = stripe.Invoice.upcoming(customer=stripe_customer_id)
# The card metadata doesn't show up in Dashboard but can be accessed if PRINT_STRIPE_FIXTURE_DATA:
# using the API. print(''.join(['"upcoming_invoice": ', str(stripe_invoice), ','])) # nocoverage
card_metadata = {"added_user_id": user.id, "added_user_email": user.email} return stripe_invoice
try:
customer_obj = Customer.objects.get(realm=user.realm)
customer = stripe.Customer.retrieve(customer_obj.stripe_customer_id)
billing_logger.info("Adding card on customer %s: source=%r, metadata=%r",
customer_obj.stripe_customer_id, token, card_metadata)
card = customer.sources.create(source=token, metadata=card_metadata)
customer.default_source = card.id
customer.save()
return len(customer.sources.list(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)
billing_logger.info("Creating customer: source=%r, description=%r, metadata=%r",
token, description, customer_metadata)
customer = stripe.Customer.create(source=token,
description=description,
metadata=customer_metadata)
card = customer.sources.list(object="card")["data"][0] @catch_stripe_errors
card.metadata = card_metadata def payment_source(stripe_customer: Any) -> Any:
card.save() if stripe_customer.default_source is None:
Customer.objects.create(realm=user.realm, stripe_customer_id=customer.id) return None # nocoverage -- no way to get here yet
return 1 for source in stripe_customer.sources.data:
if source.id == stripe_customer.default_source:
return source
raise AssertionError("Default source not in sources.")
@catch_stripe_errors
def do_create_customer_with_payment_source(user: UserProfile, stripe_token: str) -> Customer:
realm = user.realm
stripe_customer = stripe.Customer.create(
description="%s (%s)" % (realm.string_id, realm.name),
metadata={'realm_id': realm.id, 'realm_str': realm.string_id},
source=stripe_token)
if PRINT_STRIPE_FIXTURE_DATA:
print(''.join(['"create_customer": ', str(stripe_customer), ','])) # nocoverage
return Customer.objects.create(
realm=realm,
stripe_customer_id=stripe_customer.id,
billing_user=user)
@catch_stripe_errors
def do_subscribe_customer_to_plan(customer: Customer, stripe_plan_id: int,
seat_count: int, tax_percent: float) -> None:
stripe_subscription = stripe.Subscription.create(
customer=customer.stripe_customer_id,
billing='charge_automatically',
items=[{
'plan': stripe_plan_id,
'quantity': seat_count,
}],
prorate=True,
tax_percent=tax_percent)
if PRINT_STRIPE_FIXTURE_DATA:
print(''.join(['"create_subscription": ', str(stripe_subscription), ','])) # nocoverage

View File

@ -0,0 +1,41 @@
from zerver.lib.management import ZulipBaseCommand
from zilencer.models import Plan
from zproject.settings import get_secret
from typing import Any
import stripe
stripe.api_key = get_secret('stripe_secret_key')
class Command(ZulipBaseCommand):
help = """Script to add the appropriate products and plans to Stripe."""
def handle(self, *args: Any, **options: Any) -> None:
Plan.objects.all().delete()
# Zulip Cloud offerings
product = stripe.Product.create(
name="Zulip Cloud Premium",
type='service',
statement_descriptor="Zulip Cloud Premium",
unit_label="user")
plan = stripe.Plan.create(
currency='usd',
interval='month',
product=product.id,
amount=800,
billing_scheme='per_unit',
nickname=Plan.CLOUD_MONTHLY,
usage_type='licensed')
Plan.objects.create(nickname=Plan.CLOUD_MONTHLY, stripe_plan_id=plan.id)
plan = stripe.Plan.create(
currency='usd',
interval='year',
product=product.id,
amount=8000,
billing_scheme='per_unit',
nickname=Plan.CLOUD_ANNUAL,
usage_type='licensed')
Plan.objects.create(nickname=Plan.CLOUD_ANNUAL, stripe_plan_id=plan.id)

View File

@ -0,0 +1,23 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.11.11 on 2018-04-12 01:14
from __future__ import unicode_literals
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
('zilencer', '0007_remotezulipserver_fix_uniqueness'),
]
operations = [
migrations.AddField(
model_name='customer',
name='billing_user',
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL),
),
]

View File

@ -0,0 +1,23 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.11.11 on 2018-04-12 01:19
from __future__ import unicode_literals
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('zilencer', '0008_customer_billing_user'),
]
operations = [
migrations.CreateModel(
name='Plan',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('nickname', models.CharField(max_length=40, unique=True)),
('stripe_plan_id', models.CharField(max_length=255, unique=True)),
],
),
]

View File

@ -2,7 +2,7 @@ import datetime
from django.db import models from django.db import models
from zerver.models import AbstractPushDeviceToken, Realm from zerver.models import AbstractPushDeviceToken, Realm, UserProfile
def get_remote_server_by_uuid(uuid: str) -> 'RemoteZulipServer': def get_remote_server_by_uuid(uuid: str) -> 'RemoteZulipServer':
return RemoteZulipServer.objects.get(uuid=uuid) return RemoteZulipServer.objects.get(uuid=uuid)
@ -36,5 +36,17 @@ class RemotePushDeviceToken(AbstractPushDeviceToken):
return "<RemotePushDeviceToken %s %s>" % (self.server, self.user_id) return "<RemotePushDeviceToken %s %s>" % (self.server, self.user_id)
class Customer(models.Model): class Customer(models.Model):
stripe_customer_id = models.CharField(max_length=255, unique=True) realm = models.OneToOneField(Realm, on_delete=models.CASCADE) # type: Realm
realm = models.OneToOneField(Realm, on_delete=models.CASCADE) stripe_customer_id = models.CharField(max_length=255, unique=True) # type: str
billing_user = models.ForeignKey(UserProfile, on_delete=models.SET_NULL, null=True)
def __str__(self) -> str:
return "<Customer %s %s>" % (self.realm, self.stripe_customer_id)
class Plan(models.Model):
# The two possible values for nickname
CLOUD_MONTHLY = 'monthly'
CLOUD_ANNUAL = 'annual'
nickname = models.CharField(max_length=40, unique=True) # type: str
stripe_plan_id = models.CharField(max_length=255, unique=True) # type: str

View File

@ -0,0 +1,364 @@
{
"create_customer": {
"account_balance": 0,
"created": 1529990750,
"currency": null,
"default_source": "card_1Ch9gVGh0CmXqmnwv94RombT",
"delinquent": false,
"description": "zulip (Zulip Dev)",
"discount": null,
"email": null,
"id": "cus_D7OT2jf5YAtZQL",
"invoice_prefix": "23ABC45",
"livemode": false,
"metadata": {
"realm_id": "1",
"realm_str": "zulip"
},
"object": "customer",
"shipping": null,
"sources": {
"data": [
{
"address_city": "Pacific",
"address_country": "United States",
"address_line1": "Under the sea",
"address_line1_check": "pass",
"address_line2": null,
"address_state": "FL",
"address_zip": "33333",
"address_zip_check": "pass",
"brand": "Visa",
"country": "US",
"customer": "cus_D7OT2jf5YAtZQL",
"cvc_check": "pass",
"dynamic_last4": null,
"exp_month": 3,
"exp_year": 2033,
"fingerprint": "6dAXT9VZvwro65EK",
"funding": "credit",
"id": "card_1Ch9gVGh0CmXqmnwv94RombT",
"last4": "4242",
"metadata": {},
"name": "Ada Starr",
"object": "card",
"tokenization_method": null
}
],
"has_more": false,
"object": "list",
"total_count": 1,
"url": "/v1/customers/cus_D7OT2jf5YAtZQL/sources"
},
"subscriptions": {}
},
"create_subscription": {
"application_fee_percent": null,
"billing": "charge_automatically",
"billing_cycle_anchor": 1529990751,
"cancel_at_period_end": false,
"canceled_at": null,
"created": 1529990751,
"current_period_end": 1561526751,
"current_period_start": 1529990751,
"customer": "cus_D7OT2jf5YAtZQL",
"days_until_due": null,
"discount": null,
"ended_at": null,
"id": "sub_D7OTT8FZbOPxah",
"items": {
"data": [
{
"created": 1529990751,
"id": "si_D7OTEItF5ZLN2R",
"metadata": {},
"object": "subscription_item",
"plan": {
"active": true,
"aggregate_usage": null,
"amount": 8000,
"billing_scheme": "per_unit",
"created": 1529987890,
"currency": "usd",
"id": "plan_D7Nh2BtpTvIzYp",
"interval": "year",
"interval_count": 1,
"livemode": false,
"metadata": {},
"nickname": "annual",
"object": "plan",
"product": "prod_D7NhmicJvX2edE",
"tiers": null,
"tiers_mode": null,
"transform_usage": null,
"trial_period_days": null,
"usage_type": "licensed"
},
"quantity": 8,
"subscription": "sub_D7OTT8FZbOPxah"
}
],
"has_more": false,
"object": "list",
"total_count": 1,
"url": "/v1/subscription_items?subscription=sub_D7OTT8FZbOPxah"
},
"livemode": false,
"metadata": {},
"object": "subscription",
"plan": {
"active": true,
"aggregate_usage": null,
"amount": 8000,
"billing_scheme": "per_unit",
"created": 1529987890,
"currency": "usd",
"id": "plan_D7Nh2BtpTvIzYp",
"interval": "year",
"interval_count": 1,
"livemode": false,
"metadata": {},
"nickname": "annual",
"object": "plan",
"product": "prod_D7NhmicJvX2edE",
"tiers": null,
"tiers_mode": null,
"transform_usage": null,
"trial_period_days": null,
"usage_type": "licensed"
},
"quantity": 8,
"start": 1529990751,
"status": "active",
"tax_percent": 0.0,
"trial_end": null,
"trial_start": null
},
"retrieve_customer": {
"account_balance": 0,
"created": 1529990750,
"currency": "usd",
"default_source": "card_1Ch9gVGh0CmXqmnwv94RombT",
"delinquent": false,
"description": "zulip (Zulip Dev)",
"discount": null,
"email": null,
"id": "cus_D7OT2jf5YAtZQL",
"invoice_prefix": "23ABC45",
"livemode": false,
"metadata": {
"realm_id": "1",
"realm_str": "zulip"
},
"object": "customer",
"shipping": null,
"sources": {
"data": [
{
"address_city": "Pacific",
"address_country": "United States",
"address_line1": "Under the sea",
"address_line1_check": "pass",
"address_line2": null,
"address_state": "FL",
"address_zip": "33333",
"address_zip_check": "pass",
"brand": "Visa",
"country": "US",
"customer": "cus_D7OT2jf5YAtZQL",
"cvc_check": "pass",
"dynamic_last4": null,
"exp_month": 3,
"exp_year": 2033,
"fingerprint": "6dAXT9VZvwro65EK",
"funding": "credit",
"id": "card_1Ch9gVGh0CmXqmnwv94RombT",
"last4": "4242",
"metadata": {},
"name": "Ada Starr",
"object": "card",
"tokenization_method": null
}
],
"has_more": false,
"object": "list",
"total_count": 1,
"url": "/v1/customers/cus_D7OT2jf5YAtZQL/sources"
},
"subscriptions": {
"data": [
{
"application_fee_percent": null,
"billing": "charge_automatically",
"billing_cycle_anchor": 1529990751,
"cancel_at_period_end": false,
"canceled_at": null,
"created": 1529990751,
"current_period_end": 1561526751,
"current_period_start": 1529990751,
"customer": "cus_D7OT2jf5YAtZQL",
"days_until_due": null,
"discount": null,
"ended_at": null,
"id": "sub_D7OTT8FZbOPxah",
"items": {
"data": [
{
"created": 1529990751,
"id": "si_D7OTEItF5ZLN2R",
"metadata": {},
"object": "subscription_item",
"plan": {
"active": true,
"aggregate_usage": null,
"amount": 8000,
"billing_scheme": "per_unit",
"created": 1529987890,
"currency": "usd",
"id": "plan_D7Nh2BtpTvIzYp",
"interval": "year",
"interval_count": 1,
"livemode": false,
"metadata": {},
"nickname": "annual",
"object": "plan",
"product": "prod_D7NhmicJvX2edE",
"tiers": null,
"tiers_mode": null,
"transform_usage": null,
"trial_period_days": null,
"usage_type": "licensed"
},
"quantity": 8,
"subscription": "sub_D7OTT8FZbOPxah"
}
],
"has_more": false,
"object": "list",
"total_count": 1,
"url": "/v1/subscription_items?subscription=sub_D7OTT8FZbOPxah"
},
"livemode": false,
"metadata": {},
"object": "subscription",
"plan": {
"active": true,
"aggregate_usage": null,
"amount": 8000,
"billing_scheme": "per_unit",
"created": 1529987890,
"currency": "usd",
"id": "plan_D7Nh2BtpTvIzYp",
"interval": "year",
"interval_count": 1,
"livemode": false,
"metadata": {},
"nickname": "annual",
"object": "plan",
"product": "prod_D7NhmicJvX2edE",
"tiers": null,
"tiers_mode": null,
"transform_usage": null,
"trial_period_days": null,
"usage_type": "licensed"
},
"quantity": 8,
"start": 1529990751,
"status": "active",
"tax_percent": 0.0,
"trial_end": null,
"trial_start": null
}
],
"has_more": false,
"object": "list",
"total_count": 1,
"url": "/v1/customers/cus_D7OT2jf5YAtZQL/subscriptions"
}
},
"upcoming_invoice": {
"amount_due": 64000,
"amount_paid": 0,
"amount_remaining": 64000,
"application_fee": null,
"attempt_count": 0,
"attempted": false,
"billing": "charge_automatically",
"billing_reason": "upcoming",
"charge": null,
"closed": false,
"currency": "usd",
"customer": "cus_D7OT2jf5YAtZQL",
"date": 1561526751,
"description": "",
"discount": null,
"due_date": null,
"ending_balance": null,
"forgiven": false,
"lines": {
"data": [
{
"amount": 64000,
"currency": "usd",
"description": "8 user \u00d7 Zulip Cloud Premium (at $80.00 / year)",
"discountable": true,
"id": "sub_D7OTT8FZbOPxah",
"livemode": false,
"metadata": {},
"object": "line_item",
"period": {
"end": 1593149151,
"start": 1561526751
},
"plan": {
"active": true,
"aggregate_usage": null,
"amount": 8000,
"billing_scheme": "per_unit",
"created": 1529987890,
"currency": "usd",
"id": "plan_D7Nh2BtpTvIzYp",
"interval": "year",
"interval_count": 1,
"livemode": false,
"metadata": {},
"nickname": "annual",
"object": "plan",
"product": "prod_D7NhmicJvX2edE",
"tiers": null,
"tiers_mode": null,
"transform_usage": null,
"trial_period_days": null,
"usage_type": "licensed"
},
"proration": false,
"quantity": 8,
"subscription": null,
"subscription_item": "si_D7OTEItF5ZLN2R",
"type": "subscription"
}
],
"has_more": false,
"object": "list",
"total_count": 1,
"url": "/v1/invoices/upcoming/lines?customer=cus_D7OT2jf5YAtZQL"
},
"livemode": false,
"metadata": {},
"next_payment_attempt": 1561530351,
"number": "23ABC45-0002",
"object": "invoice",
"paid": false,
"period_end": 1561526751,
"period_start": 1529990751,
"receipt_number": null,
"starting_balance": 0,
"statement_descriptor": null,
"subscription": "sub_D7OTT8FZbOPxah",
"subtotal": 64000,
"tax": 0,
"tax_percent": 0.0,
"total": 64000,
"webhooks_delivered_at": null
},
}

View File

@ -0,0 +1,150 @@
import mock
import os
from typing import Any
import ujson
import stripe
from stripe.api_resources.list_object import ListObject
from zerver.lib.actions import do_deactivate_user
from zerver.lib.test_classes import ZulipTestCase
from zerver.models import Realm, UserProfile, get_realm
from zilencer.lib.stripe import StripeError, catch_stripe_errors, \
do_create_customer_with_payment_source, do_subscribe_customer_to_plan, \
get_seat_count
from zilencer.models import Customer, Plan
fixture_data_file = open(os.path.join(os.path.dirname(__file__), 'stripe_fixtures.json'), 'r')
fixture_data = ujson.load(fixture_data_file)
def mock_create_customer(*args: Any, **kwargs: Any) -> ListObject:
return stripe.util.convert_to_stripe_object(fixture_data["create_customer"])
def mock_create_subscription(*args: Any, **kwargs: Any) -> ListObject:
return stripe.util.convert_to_stripe_object(fixture_data["create_subscription"])
def mock_retrieve_customer(*args: Any, **kwargs: Any) -> ListObject:
return stripe.util.convert_to_stripe_object(fixture_data["retrieve_customer"])
def mock_upcoming_invoice(*args: Any, **kwargs: Any) -> ListObject:
return stripe.util.convert_to_stripe_object(fixture_data["upcoming_invoice"])
class StripeTest(ZulipTestCase):
def setUp(self) -> None:
self.user = self.example_user("hamlet")
self.realm = self.user.realm
self.token = 'token'
# The values below should be copied from stripe_fixtures.json
self.stripe_customer_id = 'cus_D7OT2jf5YAtZQL'
self.stripe_plan_id = 'plan_D7Nh2BtpTvIzYp'
self.quantity = 8
Plan.objects.create(nickname=Plan.CLOUD_ANNUAL, stripe_plan_id=self.stripe_plan_id)
@mock.patch("zilencer.lib.stripe.STRIPE_PUBLISHABLE_KEY", "stripe_publishable_key")
@mock.patch("zilencer.lib.stripe.billing_logger.error")
def test_errors(self, mock_billing_logger_error: mock.Mock) -> None:
@catch_stripe_errors
def raise_invalid_request_error() -> None:
raise stripe.error.InvalidRequestError("Request req_oJU621i6H6X4Ez: No such token: x",
None)
with self.assertRaisesRegex(StripeError, "Something went wrong. Please try again or "):
raise_invalid_request_error()
mock_billing_logger_error.assert_called()
@catch_stripe_errors
def raise_card_error() -> None:
error_message = "The card number is not a valid credit card number."
json_body = {"error": {"message": error_message}}
raise stripe.error.CardError(error_message, "number", "invalid_number",
json_body=json_body)
with self.assertRaisesRegex(StripeError,
"The card number is not a valid credit card number."):
raise_card_error()
mock_billing_logger_error.assert_called()
@catch_stripe_errors
def raise_exception() -> None:
raise Exception
with self.assertRaises(Exception):
raise_exception()
mock_billing_logger_error.assert_called()
@mock.patch("zilencer.lib.stripe.STRIPE_PUBLISHABLE_KEY", None)
def test_no_stripe_keys(self) -> None:
@catch_stripe_errors
def foo() -> None:
pass # nocoverage
with self.assertRaisesRegex(StripeError, "Missing Stripe config."):
foo()
@mock.patch("zilencer.lib.stripe.STRIPE_PUBLISHABLE_KEY", "stripe_publishable_key")
@mock.patch("zilencer.views.STRIPE_PUBLISHABLE_KEY", "stripe_publishable_key")
@mock.patch("stripe.Customer.create", side_effect=mock_create_customer)
@mock.patch("stripe.Subscription.create", side_effect=mock_create_subscription)
def test_initial_upgrade(self, mock_create_subscription: mock.Mock,
mock_create_customer: mock.Mock) -> None:
self.login(self.user.email)
response = self.client_get("/upgrade/")
self.assert_in_success_response(['We can also bill by invoice'], response)
# Click "Make payment" in Stripe Checkout
response = self.client_post("/upgrade/", {
'stripeToken': self.token,
'seat_count': self.quantity,
'plan': Plan.CLOUD_ANNUAL})
# Check that we created a customer and subscription in stripe, and a
# Customer object in zulip
mock_create_customer.assert_called_once_with(
description="zulip (Zulip Dev)",
metadata={'realm_id': self.realm.id, 'realm_str': 'zulip'},
source=self.token)
mock_create_subscription.assert_called_once_with(
customer=self.stripe_customer_id,
billing='charge_automatically',
items=[{
'plan': self.stripe_plan_id,
'quantity': self.quantity,
}],
prorate=True,
tax_percent=0)
self.assertEqual(1, Customer.objects.filter(realm=self.realm,
stripe_customer_id=self.stripe_customer_id,
billing_user=self.user).count())
# Check that we can no longer access /upgrade
response = self.client_get("/upgrade/")
self.assertEqual(response.status_code, 302)
self.assertEqual('/billing/', response.url)
@mock.patch("zilencer.lib.stripe.STRIPE_PUBLISHABLE_KEY", "stripe_publishable_key")
@mock.patch("zilencer.views.STRIPE_PUBLISHABLE_KEY", "stripe_publishable_key")
@mock.patch("stripe.Customer.retrieve", side_effect=mock_retrieve_customer)
@mock.patch("stripe.Invoice.upcoming", side_effect=mock_upcoming_invoice)
def test_billing_home(self, mock_upcoming_invoice: mock.Mock,
mock_retrieve_customer: mock.Mock) -> None:
self.login(self.user.email)
# No Customer yet; check that we are redirected to /upgrade
response = self.client_get("/billing/")
self.assertEqual(response.status_code, 302)
self.assertEqual('/upgrade/', response.url)
Customer.objects.create(
realm=self.realm, stripe_customer_id=self.stripe_customer_id, billing_user=self.user)
response = self.client_get("/billing/")
self.assert_not_in_success_response(['We can also bill by invoice'], response)
for substring in ['Your plan will renew on', 'for $%s.00' % (80 * self.quantity,),
'Card ending in 4242']:
self.assert_in_response(substring, response)
def test_get_seat_count(self) -> None:
initial_count = get_seat_count(self.realm)
user1 = UserProfile.objects.create(realm=self.realm, email='user1@zulip.com', pointer=-1)
user2 = UserProfile.objects.create(realm=self.realm, email='user2@zulip.com', pointer=-1)
self.assertEqual(get_seat_count(self.realm), initial_count + 2)
# Test that bots aren't counted
user1.is_bot = True
user1.save(update_fields=['is_bot'])
self.assertEqual(get_seat_count(self.realm), initial_count + 1)
# Test that inactive users aren't counted
do_deactivate_user(user2)
self.assertEqual(get_seat_count(self.realm), initial_count)

View File

@ -6,7 +6,8 @@ import zilencer.views
from zerver.lib.rest import rest_dispatch from zerver.lib.rest import rest_dispatch
i18n_urlpatterns = [ i18n_urlpatterns = [
url(r'^billing/$', zilencer.views.add_payment_method), url(r'^billing/$', zilencer.views.billing_home, name='zilencer.views.billing_home'),
url(r'^upgrade/$', zilencer.views.initial_upgrade, name='zilencer.views.initial_upgrade'),
] # type: Any ] # type: Any
# Zilencer views following the REST API style # Zilencer views following the REST API style

View File

@ -1,13 +1,13 @@
from typing import Any, Dict, Optional, Union, cast from typing import Any, Dict, Optional, Union, cast
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.core.validators import validate_email, URLValidator from django.core.validators import validate_email, URLValidator
from django.db import IntegrityError from django.db import IntegrityError
from django.http import HttpRequest, HttpResponse from django.http import HttpRequest, HttpResponse, HttpResponseRedirect
from django.utils import timezone from django.utils import timezone
from django.utils.translation import ugettext as _, ugettext as err_ from django.utils.translation import ugettext as _, ugettext as err_
from django.shortcuts import render from django.shortcuts import redirect, render
from django.urls import reverse
from django.conf import settings from django.conf import settings
from django.views.decorators.http import require_GET from django.views.decorators.http import require_GET
from django.views.decorators.csrf import csrf_exempt from django.views.decorators.csrf import csrf_exempt
@ -20,11 +20,15 @@ from zerver.lib.request import REQ, has_request_variables
from zerver.lib.response import json_error, json_success from zerver.lib.response import json_error, json_success
from zerver.lib.validator import check_int, check_string, check_url, \ from zerver.lib.validator import check_int, check_string, check_url, \
validate_login_email, check_capped_string, check_string_fixed_length validate_login_email, check_capped_string, check_string_fixed_length
from zerver.lib.timestamp import timestamp_to_datetime
from zerver.models import UserProfile, Realm from zerver.models import UserProfile, Realm
from zerver.views.push_notifications import validate_token from zerver.views.push_notifications import validate_token
from zilencer.lib.stripe import STRIPE_PUBLISHABLE_KEY, count_stripe_cards, \ from zilencer.lib.stripe import STRIPE_PUBLISHABLE_KEY, StripeError, \
save_stripe_token, StripeError do_create_customer_with_payment_source, do_subscribe_customer_to_plan, \
from zilencer.models import RemotePushDeviceToken, RemoteZulipServer get_stripe_customer, get_upcoming_invoice, payment_source, \
get_seat_count
from zilencer.models import RemotePushDeviceToken, RemoteZulipServer, \
Customer, Plan
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):
@ -152,28 +156,88 @@ def remote_server_notify_push(request: HttpRequest, entity: Union[UserProfile, R
return json_success() return json_success()
@zulip_login_required @zulip_login_required
def add_payment_method(request: HttpRequest) -> HttpResponse: def initial_upgrade(request: HttpRequest) -> HttpResponse:
user = request.user user = request.user
ctx = { if Customer.objects.filter(realm=user.realm).exists():
"publishable_key": STRIPE_PUBLISHABLE_KEY, return HttpResponseRedirect(reverse('zilencer.views.billing_home'))
"email": user.email,
if request.method == 'POST':
customer = do_create_customer_with_payment_source(user, request.POST['stripeToken'])
# TODO: the current way this is done is subject to tampering by the user.
seat_count = int(request.POST['seat_count'])
if seat_count < 1:
raise AssertionError('seat_count is less than 1')
do_subscribe_customer_to_plan(
customer=customer,
stripe_plan_id=Plan.objects.get(nickname=request.POST['plan']).stripe_plan_id,
seat_count=seat_count,
# TODO: billing address details are passed to us in the request;
# use that to calculate taxes.
tax_percent=0)
# TODO: check for errors and raise/send to frontend
return HttpResponseRedirect(reverse('zilencer.views.billing_home'))
context = {
'publishable_key': STRIPE_PUBLISHABLE_KEY,
'email': user.email,
'seat_count': get_seat_count(user.realm),
'plan': "Zulip Premium",
'nickname_monthly': Plan.CLOUD_MONTHLY,
'nickname_annual': Plan.CLOUD_ANNUAL,
} # type: Dict[str, Any]
return render(request, 'zilencer/upgrade.html', context=context)
PLAN_NAMES = {
Plan.CLOUD_ANNUAL: "Zulip Premium (billed annually)",
Plan.CLOUD_MONTHLY: "Zulip Premium (billed monthly)",
}
@zulip_login_required
def billing_home(request: HttpRequest) -> HttpResponse:
user = request.user
customer = Customer.objects.filter(realm=user.realm).first()
if customer is None:
return HttpResponseRedirect(reverse('zilencer.views.initial_upgrade'))
# TODO
# if not user.is_realm_admin and not user == customer.billing_user:
# context['error_message'] = _("You must be an administrator to view this page.")
# return render(request, 'zilencer/billing.html', context=context)
stripe_customer = get_stripe_customer(customer.stripe_customer_id)
if stripe_customer.subscriptions:
subscription = stripe_customer.subscriptions.data[0]
plan_name = PLAN_NAMES[Plan.objects.get(stripe_plan_id=subscription.plan.id).nickname]
seat_count = subscription.quantity
# Need user's timezone to do this properly
renewal_date = '{dt:%B} {dt.day}, {dt.year}'.format(
dt=timestamp_to_datetime(subscription.current_period_end))
renewal_amount = subscription.plan.amount * subscription.quantity / 100.
else:
plan_name = "Zulip Free" # nocoverage -- no way to get here yet
renewal_date = '' # nocoverage -- no way to get here yet
renewal_amount = 0 # nocoverage -- no way to get here yet
prorated_credits = 0
prorated_charges = get_upcoming_invoice(customer.stripe_customer_id).amount_due / 100. - renewal_amount
if prorated_charges < 0:
prorated_credits = -prorated_charges # nocoverage -- no way to get here yet
prorated_charges = 0 # nocoverage -- no way to get here yet
payment_method = None
source = payment_source(stripe_customer)
if source is not None:
payment_method = "Card ending in %(last4)s" % {'last4': source.last4}
context = {
'plan_name': plan_name,
'seat_count': seat_count,
'renewal_date': renewal_date,
'renewal_amount': '{:,.2f}'.format(renewal_amount),
'payment_method': payment_method,
'prorated_charges': '{:,.2f}'.format(prorated_charges),
'prorated_credits': '{:,.2f}'.format(prorated_credits),
} # type: Dict[str, Any] } # type: Dict[str, Any]
if not user.is_realm_admin: return render(request, 'zilencer/billing.html', context=context)
ctx["error_message"] = (
_("You should be an administrator of the organization %s to view this page.")
% (user.realm.name,))
return render(request, 'zilencer/billing.html', context=ctx)
try:
if request.method == "GET":
ctx["num_cards"] = count_stripe_cards(user.realm)
return render(request, 'zilencer/billing.html', context=ctx)
if request.method == "POST":
token = request.POST.get("stripeToken", "")
ctx["num_cards"] = save_stripe_token(user, token)
ctx["payment_method_added"] = True
return render(request, 'zilencer/billing.html', context=ctx)
except StripeError as e:
ctx["error_message"] = e.msg
return render(request, 'zilencer/billing.html', context=ctx)