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
|
input-pills
|
||||||
presence
|
presence
|
||||||
unread_messages
|
unread_messages
|
||||||
|
billing
|
||||||
user-docs
|
user-docs
|
||||||
|
|
|
@ -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 %}
|
||||||
|
|
|
@ -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>']},
|
'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='[^{]",
|
||||||
|
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
@ -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
|
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
|
||||||
|
|
|
@ -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 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
|
||||||
|
|
|
@ -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
|
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
|
||||||
|
|
|
@ -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)
|
|
||||||
|
|
Loading…
Reference in New Issue