diff --git a/docs/subsystems/billing.md b/docs/subsystems/billing.md new file mode 100644 index 0000000000..2eff65b9ef --- /dev/null +++ b/docs/subsystems/billing.md @@ -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 , 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. diff --git a/docs/subsystems/index.rst b/docs/subsystems/index.rst index 41cbb90638..6a00ec3e1b 100644 --- a/docs/subsystems/index.rst +++ b/docs/subsystems/index.rst @@ -39,4 +39,5 @@ Subsystems Documentation input-pills presence unread_messages + billing user-docs diff --git a/templates/zilencer/billing.html b/templates/zilencer/billing.html index 2746e8cde9..8c69cbd3e9 100644 --- a/templates/zilencer/billing.html +++ b/templates/zilencer/billing.html @@ -1,93 +1,34 @@ -{% extends "zerver/portico.html" %} +{% extends "zerver/base.html" %} {% block customhead %} {{ render_bundle('landing-page') }} {% endblock %} -{% block portico_content %} +{% block content %} +
-{% include 'zerver/gradients.html' %} -{% include 'zerver/landing_nav.html' %} + {{ render_bundle('translations') }} -
-
- {% if error_message %} -
- {{ error_message }} -
- {% else %} -
-
-
-
-
- Zulip Cloud subscription for {{ realm_name }} -
- {% if payment_method_added %} -
- The card has been saved successfully. -
- {% endif %} - {% if num_cards %} -
- {% if num_cards > 1 %} - You have {{ num_cards }} saved cards. - {% else %} - You have one saved card. - {% endif %} -
- {% endif %} -
-
-

Premium

-
- Make Zulip your home -
-
-
    -
  • Full search history
  • -
  • File storage up to 10 GB per user
  • -
  • Full access to enterprise features like Google and GitHub OAuth
  • -
  • Priority commercial support
  • -
  • Funds the Zulip open source project
  • -
-
-
-
-
-
8
-
- per active user, per month -
- "$80/year billed annually" -
-
-
- {{ csrf_input }} - -
-
-
-
-
-
-
-
+
+

{{ _("Billing") }}

+ Plan
+ {{ plan_name }}
+
+ You are paying for {{ seat_count }} users.
+ Your plan will renew on {{ renewal_date }} for ${{ renewal_amount }}.
+ {% 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 %} +
+
+ Payment method: {{ payment_method }}.
+
+ Contact support@zulipchat.com for billing history or to make changes to your subscription.
{% endblock %} diff --git a/templates/zilencer/upgrade.html b/templates/zilencer/upgrade.html new file mode 100644 index 0000000000..db855f40ed --- /dev/null +++ b/templates/zilencer/upgrade.html @@ -0,0 +1,50 @@ +{% extends "zerver/base.html" %} + +{% block customhead %} +{% stylesheet 'portico' %} +{% endblock %} + +{% block content %} +
+ + {{ render_bundle('translations') }} + +
+

{% trans %}Upgrade to {{ plan }}{% endtrans %}

+
+ {{ csrf_input }} + +
+

{{ _("Payment schedule") }}

+ + {{ _("Pay annually") }} | $80/user/year
+ + {{ _("Pay monthly") }} | $8/user/month
+
+

+ You'll initially be charged XXX + for {{ seat_count }} users. You'll receive prorated charges + and credits as users are added, deactivated, or become inactive. +

+ +
+

+ 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. +

+
+
+{% endblock %} diff --git a/tools/linter_lib/custom_check.py b/tools/linter_lib/custom_check.py index 61252844a0..a2a1c67946 100644 --- a/tools/linter_lib/custom_check.py +++ b/tools/linter_lib/custom_check.py @@ -623,7 +623,8 @@ def build_custom_checkers(by_lang): 'bad_lines': ['']}, {'pattern': 'script src="http', 'description': "Don't directly load dependencies from CDNs. See docs/subsystems/front-end-build-process.md", - 'exclude': set(["templates/zilencer/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': ['']}, {'pattern': "title='[^{]", diff --git a/tools/test-backend b/tools/test-backend index 00ba10a323..9dcc595531 100755 --- a/tools/test-backend +++ b/tools/test-backend @@ -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 diff --git a/zerver/tests/fixtures/stripe.json b/zerver/tests/fixtures/stripe.json deleted file mode 100644 index 74e03c7f46..0000000000 --- a/zerver/tests/fixtures/stripe.json +++ /dev/null @@ -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": {} - } -} diff --git a/zerver/tests/test_stripe.py b/zerver/tests/test_stripe.py deleted file mode 100644 index 7ef2af92f4..0000000000 --- a/zerver/tests/test_stripe.py +++ /dev/null @@ -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) diff --git a/zilencer/lib/stripe.py b/zilencer/lib/stripe.py index 95f3ac7b4d..c340a6bfd5 100644 --- a/zilencer/lib/stripe.py +++ b/zilencer/lib/stripe.py @@ -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 diff --git a/zilencer/management/commands/setup_stripe.py b/zilencer/management/commands/setup_stripe.py new file mode 100644 index 0000000000..8fcbca0af2 --- /dev/null +++ b/zilencer/management/commands/setup_stripe.py @@ -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) diff --git a/zilencer/migrations/0008_customer_billing_user.py b/zilencer/migrations/0008_customer_billing_user.py new file mode 100644 index 0000000000..f4df52d16f --- /dev/null +++ b/zilencer/migrations/0008_customer_billing_user.py @@ -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), + ), + ] diff --git a/zilencer/migrations/0009_plan.py b/zilencer/migrations/0009_plan.py new file mode 100644 index 0000000000..eea8955a7d --- /dev/null +++ b/zilencer/migrations/0009_plan.py @@ -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)), + ], + ), + ] diff --git a/zilencer/models.py b/zilencer/models.py index 101520935f..34f4c096fb 100644 --- a/zilencer/models.py +++ b/zilencer/models.py @@ -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 "" % (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 "" % (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 diff --git a/zilencer/tests/stripe_fixtures.json b/zilencer/tests/stripe_fixtures.json new file mode 100644 index 0000000000..a1c4a8679f --- /dev/null +++ b/zilencer/tests/stripe_fixtures.json @@ -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 + }, +} diff --git a/zilencer/tests/test_stripe.py b/zilencer/tests/test_stripe.py new file mode 100644 index 0000000000..dd868b51b7 --- /dev/null +++ b/zilencer/tests/test_stripe.py @@ -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) diff --git a/zilencer/urls.py b/zilencer/urls.py index db560f0c54..7e460a351d 100644 --- a/zilencer/urls.py +++ b/zilencer/urls.py @@ -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 diff --git a/zilencer/views.py b/zilencer/views.py index 66985d04a9..fc8f344227 100644 --- a/zilencer/views.py +++ b/zilencer/views.py @@ -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)