mirror of https://github.com/zulip/zulip.git
billing: Update billing system.
This commit is contained in:
parent
872e8c1d7b
commit
16334a1ba7
|
@ -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.
|
|
@ -39,4 +39,5 @@ Subsystems Documentation
|
|||
input-pills
|
||||
presence
|
||||
unread_messages
|
||||
billing
|
||||
user-docs
|
||||
|
|
|
@ -1,93 +1,34 @@
|
|||
{% extends "zerver/portico.html" %}
|
||||
{% extends "zerver/base.html" %}
|
||||
|
||||
{% block customhead %}
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
{{ render_bundle('landing-page') }}
|
||||
{% endblock %}
|
||||
|
||||
{% block portico_content %}
|
||||
{% block content %}
|
||||
<div class="app portico-page">
|
||||
|
||||
{% include 'zerver/gradients.html' %}
|
||||
{% include 'zerver/landing_nav.html' %}
|
||||
{{ render_bundle('translations') }}
|
||||
|
||||
<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 responsive-title">
|
||||
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>
|
||||
<div class="page-content">
|
||||
<h1>{{ _("Billing") }}</h1>
|
||||
Plan<br/>
|
||||
{{ plan_name }}<br/>
|
||||
<br/>
|
||||
You are paying for {{ seat_count }} users.<br/>
|
||||
Your plan will renew on {{ renewal_date }} for ${{ renewal_amount }}.<br/>
|
||||
{% if prorated_charges %}
|
||||
You have ${{ prorated_charges }} in prorated charges that will be
|
||||
added to your next bill.
|
||||
{% elif prorated_credits %}
|
||||
You have ${{ prorated_credits }} in prorated credits that will be
|
||||
automatically applied to your next bill.
|
||||
{% 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>
|
||||
{% endblock %}
|
||||
|
|
|
@ -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 %}
|
|
@ -623,7 +623,8 @@ def build_custom_checkers(by_lang):
|
|||
'bad_lines': ['<button aria-label="foo"></button>']},
|
||||
{'pattern': 'script src="http',
|
||||
'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') }}"],
|
||||
'bad_lines': ['<script src="https://ajax.googleapis.com/ajax/libs/jquery/3.2.1/jquery.min.js"></script>']},
|
||||
{'pattern': "title='[^{]",
|
||||
|
|
|
@ -313,9 +313,12 @@ if __name__ == "__main__":
|
|||
full_suite = len(args) == 0
|
||||
|
||||
if len(args) == 0:
|
||||
suites = ["zerver.tests",
|
||||
"zerver.webhooks",
|
||||
"analytics.tests"]
|
||||
suites = [
|
||||
"zerver.tests",
|
||||
"zerver.webhooks",
|
||||
"analytics.tests",
|
||||
"zilencer.tests",
|
||||
]
|
||||
else:
|
||||
suites = args
|
||||
|
||||
|
|
|
@ -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": {}
|
||||
}
|
||||
}
|
|
@ -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)
|
|
@ -1,7 +1,8 @@
|
|||
import datetime
|
||||
from functools import wraps
|
||||
import logging
|
||||
import os
|
||||
from typing import Any, Callable, TypeVar
|
||||
from typing import Any, Callable, Optional, TypeVar
|
||||
|
||||
from django.conf import settings
|
||||
from django.utils.translation import ugettext as _
|
||||
|
@ -9,13 +10,13 @@ import stripe
|
|||
|
||||
from zerver.lib.exceptions import JsonableError
|
||||
from zerver.lib.logging_util import log_to_file
|
||||
from zerver.lib.timestamp import datetime_to_timestamp
|
||||
from zerver.models import Realm, UserProfile
|
||||
from zilencer.models import Customer
|
||||
from zilencer.models import Customer, Plan
|
||||
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
|
||||
stripe.api_key = get_secret('stripe_secret_key')
|
||||
|
||||
BILLING_LOG_PATH = os.path.join('/var/log/zulip'
|
||||
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(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])
|
||||
|
||||
def get_seat_count(realm: Realm) -> int:
|
||||
return UserProfile.objects.filter(realm=realm, is_active=True, is_bot=False).count()
|
||||
|
||||
class StripeError(JsonableError):
|
||||
pass
|
||||
|
||||
|
@ -36,7 +59,7 @@ def catch_stripe_errors(func: CallableT) -> CallableT:
|
|||
if STRIPE_PUBLISHABLE_KEY is None:
|
||||
# Dev-only message; no translation needed.
|
||||
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:
|
||||
return func(*args, **kwargs)
|
||||
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
|
||||
|
||||
@catch_stripe_errors
|
||||
def count_stripe_cards(realm: Realm) -> int:
|
||||
try:
|
||||
customer_obj = Customer.objects.get(realm=realm)
|
||||
cards = stripe.Customer.retrieve(customer_obj.stripe_customer_id).sources.all(object="card")
|
||||
return len(cards["data"])
|
||||
except Customer.DoesNotExist:
|
||||
return 0
|
||||
def get_stripe_customer(stripe_customer_id: int) -> Any:
|
||||
stripe_customer = stripe.Customer.retrieve(stripe_customer_id)
|
||||
if PRINT_STRIPE_FIXTURE_DATA:
|
||||
print(''.join(['"retrieve_customer": ', str(stripe_customer), ','])) # nocoverage
|
||||
return stripe_customer
|
||||
|
||||
@catch_stripe_errors
|
||||
def save_stripe_token(user: UserProfile, token: str) -> int:
|
||||
"""Returns total number of cards."""
|
||||
# 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)
|
||||
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)
|
||||
def get_upcoming_invoice(stripe_customer_id: int) -> Any:
|
||||
stripe_invoice = stripe.Invoice.upcoming(customer=stripe_customer_id)
|
||||
if PRINT_STRIPE_FIXTURE_DATA:
|
||||
print(''.join(['"upcoming_invoice": ', str(stripe_invoice), ','])) # nocoverage
|
||||
return stripe_invoice
|
||||
|
||||
card = customer.sources.list(object="card")["data"][0]
|
||||
card.metadata = card_metadata
|
||||
card.save()
|
||||
Customer.objects.create(realm=user.realm, stripe_customer_id=customer.id)
|
||||
return 1
|
||||
@catch_stripe_errors
|
||||
def payment_source(stripe_customer: Any) -> Any:
|
||||
if stripe_customer.default_source is None:
|
||||
return None # nocoverage -- no way to get here yet
|
||||
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
|
||||
|
|
|
@ -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)
|
|
@ -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),
|
||||
),
|
||||
]
|
|
@ -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)),
|
||||
],
|
||||
),
|
||||
]
|
|
@ -2,7 +2,7 @@ import datetime
|
|||
|
||||
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':
|
||||
return RemoteZulipServer.objects.get(uuid=uuid)
|
||||
|
@ -36,5 +36,17 @@ class RemotePushDeviceToken(AbstractPushDeviceToken):
|
|||
return "<RemotePushDeviceToken %s %s>" % (self.server, self.user_id)
|
||||
|
||||
class Customer(models.Model):
|
||||
stripe_customer_id = models.CharField(max_length=255, unique=True)
|
||||
realm = models.OneToOneField(Realm, on_delete=models.CASCADE)
|
||||
realm = models.OneToOneField(Realm, on_delete=models.CASCADE) # type: Realm
|
||||
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
|
||||
|
|
|
@ -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
|
||||
},
|
||||
}
|
|
@ -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)
|
|
@ -6,7 +6,8 @@ import zilencer.views
|
|||
from zerver.lib.rest import rest_dispatch
|
||||
|
||||
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
|
||||
|
||||
# Zilencer views following the REST API style
|
||||
|
|
|
@ -1,13 +1,13 @@
|
|||
|
||||
from typing import Any, Dict, Optional, Union, cast
|
||||
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.core.validators import validate_email, URLValidator
|
||||
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.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.views.decorators.http import require_GET
|
||||
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.validator import check_int, check_string, check_url, \
|
||||
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.views.push_notifications import validate_token
|
||||
from zilencer.lib.stripe import STRIPE_PUBLISHABLE_KEY, count_stripe_cards, \
|
||||
save_stripe_token, StripeError
|
||||
from zilencer.models import RemotePushDeviceToken, RemoteZulipServer
|
||||
from zilencer.lib.stripe import STRIPE_PUBLISHABLE_KEY, StripeError, \
|
||||
do_create_customer_with_payment_source, do_subscribe_customer_to_plan, \
|
||||
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:
|
||||
if not isinstance(entity, RemoteZulipServer):
|
||||
|
@ -152,28 +156,88 @@ def remote_server_notify_push(request: HttpRequest, entity: Union[UserProfile, R
|
|||
return json_success()
|
||||
|
||||
@zulip_login_required
|
||||
def add_payment_method(request: HttpRequest) -> HttpResponse:
|
||||
def initial_upgrade(request: HttpRequest) -> HttpResponse:
|
||||
user = request.user
|
||||
ctx = {
|
||||
"publishable_key": STRIPE_PUBLISHABLE_KEY,
|
||||
"email": user.email,
|
||||
if Customer.objects.filter(realm=user.realm).exists():
|
||||
return HttpResponseRedirect(reverse('zilencer.views.billing_home'))
|
||||
|
||||
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]
|
||||
|
||||
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/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)
|
||||
return render(request, 'zilencer/billing.html', context=context)
|
||||
|
|
Loading…
Reference in New Issue