2018-07-03 21:49:55 +02:00
|
|
|
import datetime
|
2018-10-17 08:23:13 +02:00
|
|
|
from functools import wraps
|
2018-10-18 05:50:52 +02:00
|
|
|
from mock import Mock, patch
|
2018-10-17 08:23:13 +02:00
|
|
|
import operator
|
2018-03-31 04:13:44 +02:00
|
|
|
import os
|
2018-08-08 16:35:33 +02:00
|
|
|
import re
|
2018-10-17 08:23:13 +02:00
|
|
|
import sys
|
2018-11-12 17:18:36 +01:00
|
|
|
from typing import Any, Callable, Dict, List, Optional, TypeVar, Tuple, cast
|
2018-10-17 08:23:13 +02:00
|
|
|
import ujson
|
2018-10-29 07:36:50 +01:00
|
|
|
import json
|
2018-03-31 04:13:44 +02:00
|
|
|
|
2018-07-13 17:34:39 +02:00
|
|
|
from django.core import signing
|
2018-10-17 08:23:13 +02:00
|
|
|
from django.core.management import call_command
|
2018-11-01 11:26:29 +01:00
|
|
|
from django.core.urlresolvers import get_resolver
|
2018-08-08 16:35:33 +02:00
|
|
|
from django.http import HttpResponse
|
2018-07-03 21:49:55 +02:00
|
|
|
from django.utils.timezone import utc as timezone_utc
|
2018-07-13 17:34:39 +02:00
|
|
|
|
2018-03-31 04:13:44 +02:00
|
|
|
import stripe
|
|
|
|
|
2018-06-28 00:48:51 +02:00
|
|
|
from zerver.lib.actions import do_deactivate_user, do_create_user, \
|
2018-09-08 00:49:54 +02:00
|
|
|
do_activate_user, do_reactivate_user, activity_change_requires_seat_update, \
|
|
|
|
do_create_realm
|
2018-03-31 04:13:44 +02:00
|
|
|
from zerver.lib.test_classes import ZulipTestCase
|
2018-07-03 21:49:55 +02:00
|
|
|
from zerver.lib.timestamp import timestamp_to_datetime, datetime_to_timestamp
|
2018-06-28 00:48:51 +02:00
|
|
|
from zerver.models import Realm, UserProfile, get_realm, RealmAuditLog
|
2018-09-25 12:33:30 +02:00
|
|
|
from corporate.lib.stripe import catch_stripe_errors, \
|
2018-08-23 07:45:19 +02:00
|
|
|
do_subscribe_customer_to_plan, attach_discount_to_realm, \
|
2018-07-27 17:47:03 +02:00
|
|
|
get_seat_count, extract_current_subscription, sign_string, unsign_string, \
|
2018-07-03 21:49:55 +02:00
|
|
|
get_next_billing_log_entry, run_billing_processor_one_step, \
|
2018-09-08 00:49:54 +02:00
|
|
|
BillingError, StripeCardError, StripeConnectionError, stripe_get_customer, \
|
2018-09-08 00:49:54 +02:00
|
|
|
DEFAULT_INVOICE_DAYS_UNTIL_DUE, MIN_INVOICED_SEAT_COUNT, do_create_customer, \
|
|
|
|
process_downgrade
|
2018-09-25 14:02:43 +02:00
|
|
|
from corporate.models import Customer, Plan, Coupon, BillingProcessor
|
2018-09-08 00:49:54 +02:00
|
|
|
from corporate.views import payment_method_string
|
2018-11-01 11:26:29 +01:00
|
|
|
import corporate.urls
|
2018-03-31 04:13:44 +02:00
|
|
|
|
2018-10-17 08:23:13 +02:00
|
|
|
CallableT = TypeVar('CallableT', bound=Callable[..., Any])
|
|
|
|
|
|
|
|
GENERATE_STRIPE_FIXTURES = False
|
2018-11-09 08:15:44 +01:00
|
|
|
STRIPE_FIXTURES_DIR = "corporate/tests/stripe_fixtures"
|
2018-07-26 16:10:07 +02:00
|
|
|
|
2018-10-17 08:23:13 +02:00
|
|
|
# TODO: check that this creates a token similar to what is created by our
|
|
|
|
# actual Stripe Checkout flows
|
2018-10-18 20:04:45 +02:00
|
|
|
def stripe_create_token(card_number: str="4242424242424242") -> stripe.Token:
|
2018-10-17 08:23:13 +02:00
|
|
|
return stripe.Token.create(
|
|
|
|
card={
|
2018-10-18 20:04:45 +02:00
|
|
|
"number": card_number,
|
2018-10-17 08:23:13 +02:00
|
|
|
"exp_month": 3,
|
|
|
|
"exp_year": 2033,
|
|
|
|
"cvc": "333",
|
|
|
|
"name": "Ada Starr",
|
|
|
|
"address_line1": "Under the sea,",
|
|
|
|
"address_city": "Pacific",
|
|
|
|
"address_zip": "33333",
|
|
|
|
"address_country": "United States",
|
|
|
|
})
|
|
|
|
|
|
|
|
def stripe_fixture_path(decorated_function_name: str, mocked_function_name: str, call_count: int) -> str:
|
|
|
|
# Make the eventual filename a bit shorter, and also we conventionally
|
|
|
|
# use test_* for the python test files
|
|
|
|
if decorated_function_name[:5] == 'test_':
|
|
|
|
decorated_function_name = decorated_function_name[5:]
|
2018-11-09 08:15:44 +01:00
|
|
|
return "{}/{}:{}.{}.json".format(
|
|
|
|
STRIPE_FIXTURES_DIR, decorated_function_name, mocked_function_name[7:], call_count)
|
|
|
|
|
|
|
|
def fixture_files_for_function(decorated_function: CallableT) -> List[str]: # nocoverage
|
|
|
|
decorated_function_name = decorated_function.__name__
|
|
|
|
if decorated_function_name[:5] == 'test_':
|
|
|
|
decorated_function_name = decorated_function_name[5:]
|
|
|
|
return sorted(['{}/{}'.format(STRIPE_FIXTURES_DIR, f) for f in os.listdir(STRIPE_FIXTURES_DIR)
|
2018-11-26 23:58:10 +01:00
|
|
|
if f.startswith(decorated_function_name + ':')])
|
2018-10-17 08:23:13 +02:00
|
|
|
|
|
|
|
def generate_and_save_stripe_fixture(decorated_function_name: str, mocked_function_name: str,
|
|
|
|
mocked_function: CallableT) -> Callable[[Any, Any], Any]: # nocoverage
|
|
|
|
def _generate_and_save_stripe_fixture(*args: Any, **kwargs: Any) -> Any:
|
|
|
|
# Note that mock is not the same as mocked_function, even though their
|
|
|
|
# definitions look the same
|
|
|
|
mock = operator.attrgetter(mocked_function_name)(sys.modules[__name__])
|
|
|
|
fixture_path = stripe_fixture_path(decorated_function_name, mocked_function_name, mock.call_count)
|
2018-10-29 07:36:50 +01:00
|
|
|
try:
|
|
|
|
# Talk to Stripe
|
|
|
|
stripe_object = mocked_function(*args, **kwargs)
|
|
|
|
except stripe.error.StripeError as e:
|
|
|
|
with open(fixture_path, 'w') as f:
|
|
|
|
error_dict = e.__dict__
|
|
|
|
error_dict["headers"] = dict(error_dict["headers"])
|
|
|
|
f.write(json.dumps(error_dict, indent=2, separators=(',', ': '), sort_keys=True) + "\n")
|
|
|
|
raise e
|
2018-10-17 08:23:13 +02:00
|
|
|
with open(fixture_path, 'w') as f:
|
2018-11-16 16:49:40 +01:00
|
|
|
if stripe_object is not None:
|
|
|
|
f.write(str(stripe_object) + "\n")
|
|
|
|
else:
|
|
|
|
f.write("{}\n")
|
2018-10-17 08:23:13 +02:00
|
|
|
return stripe_object
|
|
|
|
return _generate_and_save_stripe_fixture
|
|
|
|
|
|
|
|
def read_stripe_fixture(decorated_function_name: str,
|
|
|
|
mocked_function_name: str) -> Callable[[Any, Any], Any]:
|
|
|
|
def _read_stripe_fixture(*args: Any, **kwargs: Any) -> Any:
|
|
|
|
mock = operator.attrgetter(mocked_function_name)(sys.modules[__name__])
|
|
|
|
fixture_path = stripe_fixture_path(decorated_function_name, mocked_function_name, mock.call_count)
|
2018-10-29 07:36:50 +01:00
|
|
|
fixture = ujson.load(open(fixture_path, 'r'))
|
|
|
|
# Check for StripeError fixtures
|
|
|
|
if "json_body" in fixture:
|
|
|
|
requestor = stripe.api_requestor.APIRequestor()
|
|
|
|
# This function will raise the relevant StripeError according to the fixture
|
|
|
|
requestor.interpret_response(fixture["http_body"], fixture["http_status"], fixture["headers"])
|
|
|
|
return stripe.util.convert_to_stripe_object(fixture)
|
2018-10-17 08:23:13 +02:00
|
|
|
return _read_stripe_fixture
|
|
|
|
|
2018-11-26 23:07:36 +01:00
|
|
|
def delete_fixture_data(decorated_function: CallableT) -> None: # nocoverage
|
|
|
|
for fixture_file in fixture_files_for_function(decorated_function):
|
|
|
|
os.remove(fixture_file)
|
|
|
|
|
2018-11-09 08:15:44 +01:00
|
|
|
def normalize_fixture_data(decorated_function: CallableT) -> None: # nocoverage
|
|
|
|
# stripe ids are all of the form cus_D7OT2jf5YAtZQ2
|
|
|
|
id_lengths = [
|
|
|
|
('cus', 14), ('sub', 14), ('si', 14), ('sli', 14), ('req', 14), ('tok', 24), ('card', 24)]
|
|
|
|
# We'll replace cus_D7OT2jf5YAtZQ2 with something like cus_NORMALIZED0001
|
|
|
|
pattern_translations = {
|
|
|
|
"%s_[A-Za-z0-9]{%d}" % (prefix, length): "%s_NORMALIZED%%0%dd" % (prefix, length - 10)
|
|
|
|
for prefix, length in id_lengths
|
|
|
|
}
|
|
|
|
# We'll replace "invoice_prefix": "A35BC4Q" with something like "invoice_prefix": "NORMA01"
|
|
|
|
pattern_translations.update({
|
|
|
|
'"invoice_prefix": "[A-Za-z0-9]{7}"': '"invoice_prefix": "NORMA%02d"',
|
|
|
|
'"fingerprint": "[A-Za-z0-9]{16}"': '"fingerprint": "NORMALIZED%06d"',
|
|
|
|
'"number": "[A-Za-z0-9]{7}-[A-Za-z0-9]{4}"': '"number": "NORMALI-%04d"',
|
|
|
|
})
|
|
|
|
|
|
|
|
normalized_values = {pattern: {}
|
|
|
|
for pattern in pattern_translations.keys()} # type: Dict[str, Dict[str, str]]
|
|
|
|
for fixture_file in fixture_files_for_function(decorated_function):
|
|
|
|
with open(fixture_file, "r") as f:
|
|
|
|
file_content = f.read()
|
|
|
|
for pattern, translation in pattern_translations.items():
|
|
|
|
for match in re.findall(pattern, file_content):
|
|
|
|
if match not in normalized_values[pattern]:
|
|
|
|
normalized_values[pattern][match] = translation % (len(normalized_values[pattern]) + 1,)
|
|
|
|
file_content = file_content.replace(match, normalized_values[pattern][match])
|
|
|
|
# Overwrite all IP addresses
|
|
|
|
file_content = re.sub(r'"\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}"', '"0.0.0.0"', file_content)
|
|
|
|
with open(fixture_file, "w") as f:
|
|
|
|
f.write(file_content)
|
|
|
|
|
2018-11-07 11:28:36 +01:00
|
|
|
def mock_stripe(*mocked_function_names: str,
|
2018-11-12 17:18:36 +01:00
|
|
|
generate: Optional[bool]=None) -> Callable[[CallableT], CallableT]:
|
|
|
|
def _mock_stripe(decorated_function: CallableT) -> CallableT:
|
2018-11-07 11:28:36 +01:00
|
|
|
generate_fixture = generate
|
2018-10-30 11:33:24 +01:00
|
|
|
if generate_fixture is None:
|
|
|
|
generate_fixture = GENERATE_STRIPE_FIXTURES
|
2018-11-07 11:28:36 +01:00
|
|
|
mocked_function_names_ = ["stripe.{}".format(name) for name in mocked_function_names]
|
|
|
|
for mocked_function_name in mocked_function_names_:
|
|
|
|
mocked_function = operator.attrgetter(mocked_function_name)(sys.modules[__name__])
|
|
|
|
if generate_fixture:
|
|
|
|
side_effect = generate_and_save_stripe_fixture(
|
|
|
|
decorated_function.__name__, mocked_function_name, mocked_function) # nocoverage
|
|
|
|
else:
|
|
|
|
side_effect = read_stripe_fixture(decorated_function.__name__, mocked_function_name)
|
|
|
|
decorated_function = patch(mocked_function_name, side_effect=side_effect)(decorated_function)
|
2018-10-17 08:23:13 +02:00
|
|
|
|
|
|
|
@wraps(decorated_function)
|
|
|
|
def wrapped(*args: Any, **kwargs: Any) -> Any:
|
2018-11-09 08:15:44 +01:00
|
|
|
if generate_fixture: # nocoverage
|
2018-11-26 23:07:36 +01:00
|
|
|
delete_fixture_data(decorated_function)
|
|
|
|
val = decorated_function(*args, **kwargs)
|
2018-11-09 08:15:44 +01:00
|
|
|
normalize_fixture_data(decorated_function)
|
2018-11-26 23:07:36 +01:00
|
|
|
return val
|
|
|
|
else:
|
|
|
|
return decorated_function(*args, **kwargs)
|
2018-11-12 17:18:36 +01:00
|
|
|
return cast(CallableT, wrapped)
|
2018-10-17 08:23:13 +02:00
|
|
|
return _mock_stripe
|
|
|
|
|
2018-08-09 21:38:22 +02:00
|
|
|
# A Kandra is a fictional character that can become anything. Used as a
|
|
|
|
# wildcard when testing for equality.
|
|
|
|
class Kandra(object):
|
|
|
|
def __eq__(self, other: Any) -> bool:
|
|
|
|
return True
|
|
|
|
|
2018-09-08 00:49:54 +02:00
|
|
|
def process_all_billing_log_entries() -> None:
|
|
|
|
assert not RealmAuditLog.objects.get(pk=1).requires_billing_update
|
|
|
|
processor = BillingProcessor.objects.create(
|
|
|
|
log_row=RealmAuditLog.objects.get(pk=1), realm=None, state=BillingProcessor.DONE)
|
|
|
|
while run_billing_processor_one_step(processor):
|
|
|
|
pass
|
|
|
|
|
2018-03-31 04:13:44 +02:00
|
|
|
class StripeTest(ZulipTestCase):
|
2018-11-07 11:28:36 +01:00
|
|
|
@mock_stripe("Product.create", "Plan.create", "Coupon.create", generate=False)
|
2018-10-18 05:50:52 +02:00
|
|
|
def setUp(self, mock3: Mock, mock2: Mock, mock1: Mock) -> None:
|
2018-10-17 08:23:13 +02:00
|
|
|
call_command("setup_stripe")
|
2018-11-29 03:24:19 +01:00
|
|
|
# Unfortunately this test suite is likely not robust to users being
|
|
|
|
# added in populate_db. A quick hack if you're adding a user and
|
|
|
|
# these tests are failing is to set the user to be a bot in this setUp function.
|
|
|
|
# The correct fix is probably to patch get_seat_count for the class, but that may
|
|
|
|
# require some care.
|
|
|
|
self.seat_count = 8
|
|
|
|
self.signed_seat_count, self.salt = sign_string(str(self.seat_count))
|
2018-03-31 04:13:44 +02:00
|
|
|
|
2018-08-08 16:35:33 +02:00
|
|
|
def get_signed_seat_count_from_response(self, response: HttpResponse) -> Optional[str]:
|
|
|
|
match = re.search(r'name=\"signed_seat_count\" value=\"(.+)\"', response.content.decode("utf-8"))
|
|
|
|
return match.group(1) if match else None
|
|
|
|
|
|
|
|
def get_salt_from_response(self, response: HttpResponse) -> Optional[str]:
|
|
|
|
match = re.search(r'name=\"salt\" value=\"(\w+)\"', response.content.decode("utf-8"))
|
|
|
|
return match.group(1) if match else None
|
|
|
|
|
2018-11-29 03:15:27 +01:00
|
|
|
def upgrade(self, invoice: bool=False, talk_to_stripe: bool=True,
|
|
|
|
realm: Optional[Realm]=None, **kwargs: Any) -> HttpResponse:
|
|
|
|
host_args = {}
|
|
|
|
if realm is not None:
|
|
|
|
host_args['HTTP_HOST'] = realm.host
|
|
|
|
response = self.client_get("/upgrade/", **host_args)
|
|
|
|
params = {
|
|
|
|
'signed_seat_count': self.get_signed_seat_count_from_response(response),
|
|
|
|
'salt': self.get_salt_from_response(response),
|
|
|
|
'plan': Plan.CLOUD_ANNUAL} # type: Dict[str, Any]
|
|
|
|
if invoice: # send_invoice
|
|
|
|
params.update({
|
|
|
|
'invoiced_seat_count': 123,
|
|
|
|
'billing_modality': 'send_invoice'})
|
|
|
|
else: # charge_automatically
|
|
|
|
stripe_token = None
|
|
|
|
if not talk_to_stripe:
|
|
|
|
stripe_token = 'token'
|
|
|
|
stripe_token = kwargs.get('stripe_token', stripe_token)
|
|
|
|
if stripe_token is None:
|
|
|
|
stripe_token = stripe_create_token().id
|
|
|
|
params.update({
|
|
|
|
'stripeToken': stripe_token,
|
|
|
|
'billing_modality': 'charge_automatically',
|
|
|
|
})
|
|
|
|
params.update(kwargs)
|
|
|
|
return self.client_post("/upgrade/", params, **host_args)
|
|
|
|
|
2018-10-18 05:50:52 +02:00
|
|
|
@patch("corporate.lib.stripe.billing_logger.error")
|
|
|
|
def test_catch_stripe_errors(self, mock_billing_logger_error: Mock) -> None:
|
2018-03-31 04:13:44 +02:00
|
|
|
@catch_stripe_errors
|
|
|
|
def raise_invalid_request_error() -> None:
|
2018-08-06 23:07:26 +02:00
|
|
|
raise stripe.error.InvalidRequestError(
|
2018-10-22 10:13:41 +02:00
|
|
|
"message", "param", "code", json_body={})
|
2018-08-06 06:16:29 +02:00
|
|
|
with self.assertRaises(BillingError) as context:
|
2018-03-31 04:13:44 +02:00
|
|
|
raise_invalid_request_error()
|
2018-08-06 06:16:29 +02:00
|
|
|
self.assertEqual('other stripe error', context.exception.description)
|
2018-03-31 04:13:44 +02:00
|
|
|
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)
|
2018-08-06 23:07:26 +02:00
|
|
|
with self.assertRaises(StripeCardError) as context:
|
2018-03-31 04:13:44 +02:00
|
|
|
raise_card_error()
|
2018-08-06 06:16:29 +02:00
|
|
|
self.assertIn('not a valid credit card', context.exception.message)
|
|
|
|
self.assertEqual('card error', context.exception.description)
|
2018-03-31 04:13:44 +02:00
|
|
|
mock_billing_logger_error.assert_called()
|
|
|
|
|
2018-10-18 20:31:01 +02:00
|
|
|
def test_billing_not_enabled(self) -> None:
|
|
|
|
with self.settings(BILLING_ENABLED=False):
|
|
|
|
self.login(self.example_email("iago"))
|
|
|
|
response = self.client_get("/upgrade/")
|
|
|
|
self.assert_in_success_response(["Page not found (404)"], response)
|
|
|
|
|
2018-11-07 11:28:36 +01:00
|
|
|
@mock_stripe("Customer.retrieve", "Subscription.create", "Customer.create", "Token.create", "Invoice.upcoming")
|
2018-11-05 22:37:22 +01:00
|
|
|
def test_initial_upgrade(self, mock5: Mock, mock4: Mock, mock3: Mock, mock2: Mock, mock1: Mock) -> None:
|
2018-07-25 16:37:07 +02:00
|
|
|
user = self.example_user("hamlet")
|
|
|
|
self.login(user.email)
|
2018-03-31 04:13:44 +02:00
|
|
|
response = self.client_get("/upgrade/")
|
2018-11-18 10:18:14 +01:00
|
|
|
self.assert_in_success_response(['Pay annually'], response)
|
2018-07-25 16:37:07 +02:00
|
|
|
self.assertFalse(user.realm.has_seat_based_plan)
|
2018-10-24 06:09:01 +02:00
|
|
|
self.assertNotEqual(user.realm.plan_type, Realm.STANDARD)
|
2018-10-17 08:23:13 +02:00
|
|
|
self.assertFalse(Customer.objects.filter(realm=user.realm).exists())
|
2018-08-08 16:35:33 +02:00
|
|
|
|
2018-03-31 04:13:44 +02:00
|
|
|
# Click "Make payment" in Stripe Checkout
|
2018-11-29 03:15:27 +01:00
|
|
|
self.upgrade()
|
2018-10-17 08:23:13 +02:00
|
|
|
|
|
|
|
# Check that we correctly created Customer and Subscription objects in Stripe
|
|
|
|
stripe_customer = stripe_get_customer(Customer.objects.get(realm=user.realm).stripe_customer_id)
|
|
|
|
self.assertEqual(stripe_customer.default_source.id[:5], 'card_')
|
|
|
|
self.assertEqual(stripe_customer.description, "zulip (Zulip Dev)")
|
|
|
|
self.assertEqual(stripe_customer.discount, None)
|
|
|
|
self.assertEqual(stripe_customer.email, user.email)
|
|
|
|
self.assertEqual(dict(stripe_customer.metadata),
|
|
|
|
{'realm_id': str(user.realm.id), 'realm_str': 'zulip'})
|
|
|
|
|
|
|
|
stripe_subscription = extract_current_subscription(stripe_customer)
|
|
|
|
self.assertEqual(stripe_subscription.billing, 'charge_automatically')
|
|
|
|
self.assertEqual(stripe_subscription.days_until_due, None)
|
|
|
|
self.assertEqual(stripe_subscription.plan.id,
|
|
|
|
Plan.objects.get(nickname=Plan.CLOUD_ANNUAL).stripe_plan_id)
|
2018-11-29 03:24:19 +01:00
|
|
|
self.assertEqual(stripe_subscription.quantity, self.seat_count)
|
2018-10-17 08:23:13 +02:00
|
|
|
self.assertEqual(stripe_subscription.status, 'active')
|
|
|
|
self.assertEqual(stripe_subscription.tax_percent, 0)
|
|
|
|
|
2018-06-28 00:48:51 +02:00
|
|
|
# Check that we correctly populated Customer and RealmAuditLog in Zulip
|
2018-10-17 08:23:13 +02:00
|
|
|
self.assertEqual(1, Customer.objects.filter(stripe_customer_id=stripe_customer.id,
|
2018-08-22 08:35:00 +02:00
|
|
|
realm=user.realm).count())
|
2018-07-25 16:37:07 +02:00
|
|
|
audit_log_entries = list(RealmAuditLog.objects.filter(acting_user=user)
|
2018-06-28 00:48:51 +02:00
|
|
|
.values_list('event_type', 'event_time').order_by('id'))
|
|
|
|
self.assertEqual(audit_log_entries, [
|
2018-10-17 08:23:13 +02:00
|
|
|
(RealmAuditLog.STRIPE_CUSTOMER_CREATED, timestamp_to_datetime(stripe_customer.created)),
|
|
|
|
(RealmAuditLog.STRIPE_CARD_CHANGED, timestamp_to_datetime(stripe_customer.created)),
|
|
|
|
# TODO: Add a test where stripe_customer.created != stripe_subscription.created
|
|
|
|
(RealmAuditLog.STRIPE_PLAN_CHANGED, timestamp_to_datetime(stripe_subscription.created)),
|
2018-08-09 21:38:22 +02:00
|
|
|
(RealmAuditLog.REALM_PLAN_TYPE_CHANGED, Kandra()),
|
2018-06-28 00:48:51 +02:00
|
|
|
])
|
|
|
|
# Check that we correctly updated Realm
|
|
|
|
realm = get_realm("zulip")
|
|
|
|
self.assertTrue(realm.has_seat_based_plan)
|
2018-10-24 06:09:01 +02:00
|
|
|
self.assertEqual(realm.plan_type, Realm.STANDARD)
|
|
|
|
self.assertEqual(realm.max_invites, Realm.INVITES_STANDARD_REALM_DAILY_MAX)
|
2018-03-31 04:13:44 +02:00
|
|
|
# Check that we can no longer access /upgrade
|
|
|
|
response = self.client_get("/upgrade/")
|
|
|
|
self.assertEqual(response.status_code, 302)
|
|
|
|
self.assertEqual('/billing/', response.url)
|
|
|
|
|
2018-11-05 22:37:22 +01:00
|
|
|
# Check /billing has the correct information
|
|
|
|
response = self.client_get("/billing/")
|
2018-11-18 10:18:14 +01:00
|
|
|
self.assert_not_in_success_response(['Pay annually'], response)
|
2018-11-29 03:24:19 +01:00
|
|
|
for substring in ['Your plan will renew on', '$%s.00' % (80 * self.seat_count,),
|
2018-11-18 10:18:14 +01:00
|
|
|
'Card ending in 4242', 'Update card']:
|
2018-11-05 22:37:22 +01:00
|
|
|
self.assert_in_response(substring, response)
|
|
|
|
|
2018-11-07 11:28:36 +01:00
|
|
|
@mock_stripe("Token.create", "Invoice.upcoming", "Customer.retrieve", "Customer.create", "Subscription.create")
|
2018-10-18 18:49:50 +02:00
|
|
|
def test_billing_page_permissions(self, mock5: Mock, mock4: Mock, mock3: Mock,
|
|
|
|
mock2: Mock, mock1: Mock) -> None:
|
2018-07-11 16:36:52 +02:00
|
|
|
# Check that non-admins can access /upgrade via /billing, when there is no Customer object
|
|
|
|
self.login(self.example_email('hamlet'))
|
|
|
|
response = self.client_get("/billing/")
|
|
|
|
self.assertEqual(response.status_code, 302)
|
|
|
|
self.assertEqual('/upgrade/', response.url)
|
|
|
|
# Check that non-admins can sign up and pay
|
2018-11-29 03:15:27 +01:00
|
|
|
self.upgrade()
|
2018-07-11 16:36:52 +02:00
|
|
|
# Check that the non-admin hamlet can still access /billing
|
|
|
|
response = self.client_get("/billing/")
|
2018-06-29 16:51:36 +02:00
|
|
|
self.assert_in_success_response(["for billing history or to make changes"], response)
|
2018-08-22 07:49:48 +02:00
|
|
|
# Check admins can access billing, even though they are not a billing admin
|
2018-07-11 16:36:52 +02:00
|
|
|
self.login(self.example_email('iago'))
|
|
|
|
response = self.client_get("/billing/")
|
2018-06-29 16:51:36 +02:00
|
|
|
self.assert_in_success_response(["for billing history or to make changes"], response)
|
2018-08-22 07:49:48 +02:00
|
|
|
# Check that a non-admin, non-billing admin user does not have access
|
2018-07-11 16:36:52 +02:00
|
|
|
self.login(self.example_email("cordelia"))
|
|
|
|
response = self.client_get("/billing/")
|
|
|
|
self.assert_in_success_response(["You must be an organization administrator"], response)
|
|
|
|
|
2018-11-07 11:28:36 +01:00
|
|
|
@mock_stripe("Token.create", "Customer.create", "Subscription.create", "Customer.retrieve")
|
2018-10-18 19:08:05 +02:00
|
|
|
def test_upgrade_with_outdated_seat_count(
|
|
|
|
self, mock4: Mock, mock3: Mock, mock2: Mock, mock1: Mock) -> None:
|
2018-07-25 16:37:07 +02:00
|
|
|
self.login(self.example_email("hamlet"))
|
2018-06-28 00:48:51 +02:00
|
|
|
new_seat_count = 123
|
|
|
|
# Change the seat count while the user is going through the upgrade flow
|
2018-10-18 05:50:52 +02:00
|
|
|
with patch('corporate.lib.stripe.get_seat_count', return_value=new_seat_count):
|
2018-11-29 03:15:27 +01:00
|
|
|
self.upgrade()
|
2018-06-28 00:48:51 +02:00
|
|
|
# Check that the subscription call used the old quantity, not new_seat_count
|
2018-10-18 19:08:05 +02:00
|
|
|
stripe_customer = stripe_get_customer(
|
|
|
|
Customer.objects.get(realm=get_realm('zulip')).stripe_customer_id)
|
|
|
|
stripe_subscription = extract_current_subscription(stripe_customer)
|
2018-11-29 03:24:19 +01:00
|
|
|
self.assertEqual(stripe_subscription.quantity, self.seat_count)
|
2018-10-18 19:08:05 +02:00
|
|
|
|
2018-08-11 00:51:18 +02:00
|
|
|
# Check that we have the STRIPE_PLAN_QUANTITY_RESET entry, and that we
|
2018-06-28 00:48:51 +02:00
|
|
|
# correctly handled the requires_billing_update field
|
|
|
|
audit_log_entries = list(RealmAuditLog.objects.order_by('-id')
|
|
|
|
.values_list('event_type', 'event_time',
|
2018-08-09 21:38:22 +02:00
|
|
|
'requires_billing_update')[:5])[::-1]
|
2018-06-28 00:48:51 +02:00
|
|
|
self.assertEqual(audit_log_entries, [
|
2018-10-18 19:08:05 +02:00
|
|
|
(RealmAuditLog.STRIPE_CUSTOMER_CREATED, timestamp_to_datetime(stripe_customer.created), False),
|
|
|
|
(RealmAuditLog.STRIPE_CARD_CHANGED, timestamp_to_datetime(stripe_customer.created), False),
|
|
|
|
# TODO: Ideally this test would force stripe_customer.created != stripe_subscription.created
|
|
|
|
(RealmAuditLog.STRIPE_PLAN_CHANGED, timestamp_to_datetime(stripe_subscription.created), False),
|
|
|
|
(RealmAuditLog.STRIPE_PLAN_QUANTITY_RESET, timestamp_to_datetime(stripe_subscription.created), True),
|
2018-08-09 21:38:22 +02:00
|
|
|
(RealmAuditLog.REALM_PLAN_TYPE_CHANGED, Kandra(), False),
|
2018-06-28 00:48:51 +02:00
|
|
|
])
|
|
|
|
self.assertEqual(ujson.loads(RealmAuditLog.objects.filter(
|
2018-08-11 00:51:18 +02:00
|
|
|
event_type=RealmAuditLog.STRIPE_PLAN_QUANTITY_RESET).values_list('extra_data', flat=True).first()),
|
2018-06-28 00:48:51 +02:00
|
|
|
{'quantity': new_seat_count})
|
|
|
|
|
2018-11-07 11:28:36 +01:00
|
|
|
@mock_stripe("Token.create", "Customer.create", "Subscription.create", "Customer.retrieve", "Customer.save")
|
2018-10-18 20:04:45 +02:00
|
|
|
def test_upgrade_where_subscription_save_fails_at_first(
|
|
|
|
self, mock5: Mock, mock4: Mock, mock3: Mock, mock2: Mock, mock1: Mock) -> None:
|
2018-08-14 03:33:31 +02:00
|
|
|
user = self.example_user("hamlet")
|
|
|
|
self.login(user.email)
|
2018-10-18 20:04:45 +02:00
|
|
|
# From https://stripe.com/docs/testing#cards: Attaching this card to
|
|
|
|
# a Customer object succeeds, but attempts to charge the customer fail.
|
2018-11-29 03:15:27 +01:00
|
|
|
self.upgrade(stripe_token=stripe_create_token('4000000000000341').id)
|
2018-10-18 20:04:45 +02:00
|
|
|
# Check that we created a Customer object with has_billing_relationship False
|
|
|
|
customer = Customer.objects.get(realm=get_realm('zulip'))
|
|
|
|
self.assertFalse(customer.has_billing_relationship)
|
|
|
|
original_stripe_customer_id = customer.stripe_customer_id
|
|
|
|
# Check that we created a customer in stripe, with no subscription
|
|
|
|
stripe_customer = stripe_get_customer(customer.stripe_customer_id)
|
|
|
|
self.assertFalse(extract_current_subscription(stripe_customer))
|
2018-08-14 03:33:31 +02:00
|
|
|
# Check that we correctly populated RealmAuditLog
|
|
|
|
audit_log_entries = list(RealmAuditLog.objects.filter(acting_user=user)
|
|
|
|
.values_list('event_type', flat=True).order_by('id'))
|
|
|
|
self.assertEqual(audit_log_entries, [RealmAuditLog.STRIPE_CUSTOMER_CREATED,
|
2018-09-05 09:40:29 +02:00
|
|
|
RealmAuditLog.STRIPE_CARD_CHANGED])
|
2018-08-14 03:33:31 +02:00
|
|
|
# Check that we did not update Realm
|
|
|
|
realm = get_realm("zulip")
|
|
|
|
self.assertFalse(realm.has_seat_based_plan)
|
|
|
|
# Check that we still get redirected to /upgrade
|
|
|
|
response = self.client_get("/billing/")
|
|
|
|
self.assertEqual(response.status_code, 302)
|
|
|
|
self.assertEqual('/upgrade/', response.url)
|
|
|
|
|
2018-10-18 20:04:45 +02:00
|
|
|
# Try again, with a valid card
|
2018-11-29 03:15:27 +01:00
|
|
|
self.upgrade()
|
2018-10-18 20:04:45 +02:00
|
|
|
customer = Customer.objects.get(realm=get_realm('zulip'))
|
|
|
|
# Impossible to create two Customers, but check that we didn't
|
|
|
|
# change stripe_customer_id and that we updated has_billing_relationship
|
|
|
|
self.assertEqual(customer.stripe_customer_id, original_stripe_customer_id)
|
|
|
|
self.assertTrue(customer.has_billing_relationship)
|
|
|
|
# Check that we successfully added a subscription
|
|
|
|
stripe_customer = stripe_get_customer(customer.stripe_customer_id)
|
|
|
|
self.assertTrue(extract_current_subscription(stripe_customer))
|
2018-08-14 03:33:31 +02:00
|
|
|
# Check that we correctly populated RealmAuditLog
|
|
|
|
audit_log_entries = list(RealmAuditLog.objects.filter(acting_user=user)
|
|
|
|
.values_list('event_type', flat=True).order_by('id'))
|
|
|
|
self.assertEqual(audit_log_entries, [RealmAuditLog.STRIPE_CUSTOMER_CREATED,
|
2018-09-05 09:40:29 +02:00
|
|
|
RealmAuditLog.STRIPE_CARD_CHANGED,
|
|
|
|
RealmAuditLog.STRIPE_CARD_CHANGED,
|
2018-08-09 21:38:22 +02:00
|
|
|
RealmAuditLog.STRIPE_PLAN_CHANGED,
|
|
|
|
RealmAuditLog.REALM_PLAN_TYPE_CHANGED])
|
2018-08-14 03:33:31 +02:00
|
|
|
# Check that we correctly updated Realm
|
|
|
|
realm = get_realm("zulip")
|
|
|
|
self.assertTrue(realm.has_seat_based_plan)
|
|
|
|
# Check that we can no longer access /upgrade
|
|
|
|
response = self.client_get("/upgrade/")
|
|
|
|
self.assertEqual(response.status_code, 302)
|
|
|
|
self.assertEqual('/billing/', response.url)
|
|
|
|
|
2018-07-13 13:33:05 +02:00
|
|
|
def test_upgrade_with_tampered_seat_count(self) -> None:
|
2018-07-25 16:37:07 +02:00
|
|
|
self.login(self.example_email("hamlet"))
|
2018-11-29 03:15:27 +01:00
|
|
|
response = self.upgrade(talk_to_stripe=False, salt='badsalt')
|
2018-10-24 06:09:01 +02:00
|
|
|
self.assert_in_success_response(["Upgrade to Zulip Standard"], response)
|
2018-08-06 06:16:29 +02:00
|
|
|
self.assertEqual(response['error_description'], 'tampered seat count')
|
2018-07-13 13:33:05 +02:00
|
|
|
|
2018-07-22 17:23:57 +02:00
|
|
|
def test_upgrade_with_tampered_plan(self) -> None:
|
2018-11-18 10:18:14 +01:00
|
|
|
# Test with an unknown plan
|
2018-07-25 16:37:07 +02:00
|
|
|
self.login(self.example_email("hamlet"))
|
2018-11-29 03:15:27 +01:00
|
|
|
response = self.upgrade(talk_to_stripe=False, plan='badplan')
|
2018-10-24 06:09:01 +02:00
|
|
|
self.assert_in_success_response(["Upgrade to Zulip Standard"], response)
|
2018-08-06 06:16:29 +02:00
|
|
|
self.assertEqual(response['error_description'], 'tampered plan')
|
2018-11-18 10:18:14 +01:00
|
|
|
# Test with a plan that's valid, but not if you're paying by invoice
|
2018-11-29 03:15:27 +01:00
|
|
|
response = self.upgrade(invoice=True, talk_to_stripe=False, plan=Plan.CLOUD_MONTHLY)
|
2018-11-18 10:18:14 +01:00
|
|
|
self.assert_in_success_response(["Upgrade to Zulip Standard"], response)
|
|
|
|
self.assertEqual(response['error_description'], 'tampered plan')
|
2018-07-22 17:23:57 +02:00
|
|
|
|
2018-09-08 00:49:54 +02:00
|
|
|
def test_upgrade_with_insufficient_invoiced_seat_count(self) -> None:
|
|
|
|
self.login(self.example_email("hamlet"))
|
|
|
|
# Test invoicing for less than MIN_INVOICED_SEAT_COUNT
|
2018-11-29 03:15:27 +01:00
|
|
|
response = self.upgrade(invoice=True, talk_to_stripe=False,
|
|
|
|
invoiced_seat_count=MIN_INVOICED_SEAT_COUNT - 1)
|
2018-09-08 00:49:54 +02:00
|
|
|
self.assert_in_success_response(["Upgrade to Zulip Standard",
|
|
|
|
"at least %d users" % (MIN_INVOICED_SEAT_COUNT,)], response)
|
|
|
|
self.assertEqual(response['error_description'], 'lowball seat count')
|
|
|
|
# Test invoicing for less than your user count
|
|
|
|
with patch("corporate.views.MIN_INVOICED_SEAT_COUNT", 3):
|
2018-11-29 03:15:27 +01:00
|
|
|
response = self.upgrade(invoice=True, talk_to_stripe=False, invoiced_seat_count=4)
|
2018-09-08 00:49:54 +02:00
|
|
|
self.assert_in_success_response(["Upgrade to Zulip Standard",
|
2018-11-29 03:24:19 +01:00
|
|
|
"at least %d users" % (self.seat_count,)], response)
|
2018-09-08 00:49:54 +02:00
|
|
|
self.assertEqual(response['error_description'], 'lowball seat count')
|
2018-11-18 10:18:14 +01:00
|
|
|
# Test not setting an invoiced_seat_count
|
2018-11-29 03:15:27 +01:00
|
|
|
response = self.upgrade(invoice=True, talk_to_stripe=False, invoiced_seat_count=None)
|
2018-11-18 10:18:14 +01:00
|
|
|
self.assert_in_success_response(["Upgrade to Zulip Standard",
|
|
|
|
"at least %d users" % (MIN_INVOICED_SEAT_COUNT,)], response)
|
|
|
|
self.assertEqual(response['error_description'], 'lowball seat count')
|
2018-09-08 00:49:54 +02:00
|
|
|
|
2018-10-22 12:41:14 +02:00
|
|
|
@patch("corporate.lib.stripe.billing_logger.error")
|
|
|
|
def test_upgrade_with_uncaught_exception(self, mock1: Mock) -> None:
|
|
|
|
self.login(self.example_email("hamlet"))
|
|
|
|
with patch("corporate.views.process_initial_upgrade", side_effect=Exception):
|
2018-11-29 03:15:27 +01:00
|
|
|
response = self.upgrade(talk_to_stripe=False)
|
2018-10-22 12:41:14 +02:00
|
|
|
self.assert_in_success_response(["Upgrade to Zulip Standard",
|
|
|
|
"Something went wrong. Please contact"], response)
|
|
|
|
self.assertEqual(response['error_description'], 'uncaught exception during upgrade')
|
|
|
|
|
2018-09-08 00:49:54 +02:00
|
|
|
@mock_stripe("Customer.create", "Subscription.create", "Subscription.save",
|
2018-11-18 10:18:14 +01:00
|
|
|
"Customer.retrieve", "Invoice.list", "Invoice.upcoming")
|
|
|
|
def test_upgrade_billing_by_invoice(self, mock6: Mock, mock5: Mock, mock4: Mock, mock3: Mock,
|
2018-09-08 00:49:54 +02:00
|
|
|
mock2: Mock, mock1: Mock) -> None:
|
|
|
|
user = self.example_user("hamlet")
|
|
|
|
self.login(user.email)
|
2018-11-29 03:15:27 +01:00
|
|
|
self.upgrade(invoice=True)
|
2018-09-08 00:49:54 +02:00
|
|
|
process_all_billing_log_entries()
|
|
|
|
|
|
|
|
# Check that we correctly created a Customer in Stripe
|
|
|
|
stripe_customer = stripe_get_customer(Customer.objects.get(realm=user.realm).stripe_customer_id)
|
|
|
|
self.assertEqual(stripe_customer.email, user.email)
|
|
|
|
# It can take a second for Stripe to attach the source to the
|
|
|
|
# customer, and in particular it may not be attached at the time
|
|
|
|
# stripe_get_customer is called above, causing test flakes.
|
|
|
|
# So commenting the next line out, but leaving it here so future readers know what
|
|
|
|
# is supposed to happen here (e.g. the default_source is not None as it would be if
|
|
|
|
# we had not added a Subscription).
|
|
|
|
# self.assertEqual(stripe_customer.default_source.type, 'ach_credit_transfer')
|
|
|
|
|
|
|
|
# Check that we correctly created a Subscription in Stripe
|
|
|
|
stripe_subscription = extract_current_subscription(stripe_customer)
|
|
|
|
self.assertEqual(stripe_subscription.billing, 'send_invoice')
|
|
|
|
self.assertEqual(stripe_subscription.days_until_due, DEFAULT_INVOICE_DAYS_UNTIL_DUE)
|
|
|
|
self.assertEqual(stripe_subscription.plan.id,
|
|
|
|
Plan.objects.get(nickname=Plan.CLOUD_ANNUAL).stripe_plan_id)
|
|
|
|
self.assertEqual(stripe_subscription.quantity, get_seat_count(user.realm))
|
|
|
|
self.assertEqual(stripe_subscription.status, 'active')
|
|
|
|
# Check that we correctly created an initial Invoice in Stripe
|
|
|
|
for stripe_invoice in stripe.Invoice.list(customer=stripe_customer.id, limit=1):
|
|
|
|
self.assertTrue(stripe_invoice.auto_advance)
|
|
|
|
self.assertEqual(stripe_invoice.billing, 'send_invoice')
|
|
|
|
self.assertEqual(stripe_invoice.billing_reason, 'subscription_create')
|
|
|
|
# Transitions to 'open' after 1-2 hours
|
|
|
|
self.assertEqual(stripe_invoice.status, 'draft')
|
|
|
|
# Very important. Check that we're invoicing for 123, and not get_seat_count
|
|
|
|
self.assertEqual(stripe_invoice.amount_due, 8000*123)
|
|
|
|
|
|
|
|
# Check that we correctly updated Realm
|
|
|
|
realm = get_realm("zulip")
|
|
|
|
self.assertTrue(realm.has_seat_based_plan)
|
|
|
|
self.assertEqual(realm.plan_type, Realm.STANDARD)
|
|
|
|
# Check that we created a Customer in Zulip
|
|
|
|
self.assertEqual(1, Customer.objects.filter(stripe_customer_id=stripe_customer.id,
|
|
|
|
realm=realm).count())
|
|
|
|
# Check that RealmAuditLog has STRIPE_PLAN_QUANTITY_RESET, and doesn't have STRIPE_CARD_CHANGED
|
|
|
|
audit_log_entries = list(RealmAuditLog.objects.order_by('-id')
|
|
|
|
.values_list('event_type', 'event_time',
|
|
|
|
'requires_billing_update')[:4])[::-1]
|
|
|
|
self.assertEqual(audit_log_entries, [
|
|
|
|
(RealmAuditLog.STRIPE_CUSTOMER_CREATED, timestamp_to_datetime(stripe_customer.created), False),
|
|
|
|
(RealmAuditLog.STRIPE_PLAN_CHANGED, timestamp_to_datetime(stripe_subscription.created), False),
|
|
|
|
(RealmAuditLog.STRIPE_PLAN_QUANTITY_RESET, timestamp_to_datetime(stripe_subscription.created), True),
|
|
|
|
(RealmAuditLog.REALM_PLAN_TYPE_CHANGED, Kandra(), False),
|
|
|
|
])
|
|
|
|
self.assertEqual(ujson.loads(RealmAuditLog.objects.filter(
|
|
|
|
event_type=RealmAuditLog.STRIPE_PLAN_QUANTITY_RESET).values_list('extra_data', flat=True).first()),
|
2018-11-29 03:24:19 +01:00
|
|
|
{'quantity': self.seat_count})
|
2018-09-08 00:49:54 +02:00
|
|
|
|
2018-11-18 10:18:14 +01:00
|
|
|
# Check /billing has the correct information
|
|
|
|
response = self.client_get("/billing/")
|
|
|
|
self.assert_not_in_success_response(['Pay annually', 'Update card'], response)
|
|
|
|
for substring in ['Your plan will renew on', 'Billed by invoice']:
|
|
|
|
self.assert_in_response(substring, response)
|
|
|
|
|
2018-11-28 10:49:16 +01:00
|
|
|
def test_redirect_for_billing_home(self) -> None:
|
2018-08-22 07:49:48 +02:00
|
|
|
user = self.example_user("iago")
|
2018-07-25 16:37:07 +02:00
|
|
|
self.login(user.email)
|
2018-03-31 04:13:44 +02:00
|
|
|
# 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)
|
|
|
|
|
2018-11-28 10:49:16 +01:00
|
|
|
# Customer, but no billing relationship; check that we are still redirected to /upgrade
|
|
|
|
Customer.objects.create(
|
2018-11-28 23:23:03 +01:00
|
|
|
realm=user.realm, stripe_customer_id='cus_123', has_billing_relationship=False)
|
2018-11-05 22:37:22 +01:00
|
|
|
response = self.client_get("/billing/")
|
|
|
|
self.assertEqual(response.status_code, 302)
|
|
|
|
self.assertEqual('/upgrade/', response.url)
|
|
|
|
|
2018-03-31 04:13:44 +02:00
|
|
|
def test_get_seat_count(self) -> None:
|
2018-07-25 16:37:07 +02:00
|
|
|
realm = get_realm("zulip")
|
|
|
|
initial_count = get_seat_count(realm)
|
|
|
|
user1 = UserProfile.objects.create(realm=realm, email='user1@zulip.com', pointer=-1)
|
|
|
|
user2 = UserProfile.objects.create(realm=realm, email='user2@zulip.com', pointer=-1)
|
|
|
|
self.assertEqual(get_seat_count(realm), initial_count + 2)
|
2018-03-31 04:13:44 +02:00
|
|
|
|
|
|
|
# Test that bots aren't counted
|
|
|
|
user1.is_bot = True
|
|
|
|
user1.save(update_fields=['is_bot'])
|
2018-07-25 16:37:07 +02:00
|
|
|
self.assertEqual(get_seat_count(realm), initial_count + 1)
|
2018-03-31 04:13:44 +02:00
|
|
|
|
|
|
|
# Test that inactive users aren't counted
|
|
|
|
do_deactivate_user(user2)
|
2018-07-25 16:37:07 +02:00
|
|
|
self.assertEqual(get_seat_count(realm), initial_count)
|
2018-06-28 00:48:51 +02:00
|
|
|
|
2018-07-13 17:34:39 +02:00
|
|
|
def test_sign_string(self) -> None:
|
|
|
|
string = "abc"
|
|
|
|
signed_string, salt = sign_string(string)
|
|
|
|
self.assertEqual(string, unsign_string(signed_string, salt))
|
|
|
|
|
|
|
|
with self.assertRaises(signing.BadSignature):
|
|
|
|
unsign_string(signed_string, "randomsalt")
|
|
|
|
|
2018-09-08 00:49:54 +02:00
|
|
|
# This tests both the payment method string, and also is a very basic
|
|
|
|
# test that the various upgrade paths involving non-standard payment
|
|
|
|
# histories don't throw errors
|
|
|
|
@mock_stripe("Token.create", "Customer.retrieve", "Customer.create", "Subscription.create",
|
|
|
|
"Subscription.delete")
|
|
|
|
def test_payment_method_string(self, mock5: Mock, mock4: Mock, mock3: Mock, mock2: Mock,
|
|
|
|
mock1: Mock) -> None:
|
|
|
|
# If you signup with a card, we should show your card as the payment method
|
|
|
|
# Already tested in test_initial_upgrade
|
|
|
|
|
|
|
|
# If you pay by invoice, your payment method should be
|
|
|
|
# "Billed by invoice", even if you have a card on file
|
|
|
|
user = self.example_user("hamlet")
|
|
|
|
do_create_customer(user, stripe_create_token().id)
|
|
|
|
self.login(user.email)
|
2018-11-29 03:15:27 +01:00
|
|
|
self.upgrade(invoice=True)
|
2018-09-08 00:49:54 +02:00
|
|
|
stripe_customer = stripe_get_customer(Customer.objects.get(realm=user.realm).stripe_customer_id)
|
|
|
|
self.assertEqual('Billed by invoice', payment_method_string(stripe_customer))
|
|
|
|
|
|
|
|
# If you signup with a card and then downgrade, we still have your
|
|
|
|
# card on file, and should show it
|
|
|
|
realm = do_create_realm('realm1', 'realm1')
|
|
|
|
user = do_create_user('name@realm1.com', 'password', realm, 'name', 'name')
|
|
|
|
self.login(user.email, password='password', realm=realm)
|
2018-11-29 03:15:27 +01:00
|
|
|
self.upgrade(realm=realm)
|
2018-09-08 00:49:54 +02:00
|
|
|
with patch('corporate.lib.stripe.preview_invoice_total_for_downgrade', return_value=1):
|
|
|
|
process_downgrade(user)
|
|
|
|
stripe_customer = stripe_get_customer(Customer.objects.get(realm=user.realm).stripe_customer_id)
|
|
|
|
self.assertEqual('Card ending in 4242', payment_method_string(stripe_customer))
|
|
|
|
|
|
|
|
# If you signup via invoice, and then downgrade immediately, the
|
|
|
|
# default_source is in a weird intermediate state.
|
|
|
|
realm = do_create_realm('realm2', 'realm2')
|
|
|
|
user = do_create_user('name@realm2.com', 'password', realm, 'name', 'name')
|
|
|
|
self.login(user.email, password='password', realm=realm)
|
2018-11-29 03:15:27 +01:00
|
|
|
self.upgrade(invoice=True, realm=realm)
|
2018-09-08 00:49:54 +02:00
|
|
|
with patch('corporate.lib.stripe.preview_invoice_total_for_downgrade', return_value=1):
|
|
|
|
process_downgrade(user)
|
|
|
|
stripe_customer = stripe_get_customer(Customer.objects.get(realm=user.realm).stripe_customer_id)
|
|
|
|
self.assertIn('Unknown payment method.', payment_method_string(stripe_customer))
|
|
|
|
|
2018-11-23 12:37:01 +01:00
|
|
|
@mock_stripe("Customer.save", "Customer.retrieve", "Customer.create", "Invoice.upcoming",
|
|
|
|
"Token.create", "Charge.list", "Subscription.create")
|
|
|
|
def test_attach_discount_to_realm(self, mock7: Mock, mock6: Mock, mock5: Mock, mock4: Mock,
|
|
|
|
mock3: Mock, mock2: Mock, mock1: Mock) -> None:
|
|
|
|
# Attach discount before Stripe customer exists
|
2018-08-23 07:45:19 +02:00
|
|
|
user = self.example_user('hamlet')
|
|
|
|
attach_discount_to_realm(user, 85)
|
2018-11-23 12:37:01 +01:00
|
|
|
self.login(user.email)
|
2018-11-29 03:15:27 +01:00
|
|
|
self.upgrade()
|
2018-11-23 12:37:01 +01:00
|
|
|
stripe_customer = stripe_get_customer(Customer.objects.get(realm=user.realm).stripe_customer_id)
|
|
|
|
assert(stripe_customer.discount is not None) # for mypy
|
|
|
|
self.assertEqual(stripe_customer.discount.coupon.percent_off, 85.0)
|
|
|
|
# Check that the customer was charged the discounted amount
|
|
|
|
charges = stripe.Charge.list(customer=stripe_customer.id)
|
|
|
|
for charge in charges:
|
|
|
|
self.assertEqual(charge.amount, get_seat_count(user.realm) * 80 * 15)
|
|
|
|
# Check upcoming invoice reflects the discount
|
|
|
|
upcoming_invoice = stripe.Invoice.upcoming(customer=stripe_customer.id)
|
|
|
|
self.assertEqual(upcoming_invoice.amount_due, get_seat_count(user.realm) * 80 * 15)
|
|
|
|
|
|
|
|
# Attach discount to existing Stripe customer
|
|
|
|
attach_discount_to_realm(user, 25)
|
|
|
|
# Check upcoming invoice reflects the new discount
|
|
|
|
upcoming_invoice = stripe.Invoice.upcoming(customer=stripe_customer.id)
|
|
|
|
self.assertEqual(upcoming_invoice.amount_due, get_seat_count(user.realm) * 80 * 75)
|
2018-08-23 07:45:19 +02:00
|
|
|
|
2018-11-25 16:58:04 +01:00
|
|
|
# Tests upgrade followed by immediate downgrade. Doesn't test the
|
|
|
|
# calculations for how much credit they should get if they had the
|
|
|
|
# subscription for more than 0 time.
|
|
|
|
@mock_stripe("Customer.create", "Customer.retrieve", "Customer.save", "Invoice.upcoming",
|
|
|
|
"Subscription.create", "Subscription.retrieve", "Subscription.save",
|
|
|
|
"Subscription.delete", "Token.create")
|
|
|
|
def test_downgrade(self, mock9: Mock, mock8: Mock, mock7: Mock, mock6: Mock, mock5: Mock,
|
|
|
|
mock4: Mock, mock3: Mock, mock2: Mock, mock1: Mock) -> None:
|
2018-09-01 06:41:58 +02:00
|
|
|
user = self.example_user('iago')
|
|
|
|
self.login(user.email)
|
2018-11-29 03:15:27 +01:00
|
|
|
self.upgrade()
|
2018-11-25 16:58:04 +01:00
|
|
|
realm = get_realm('zulip')
|
|
|
|
self.assertEqual(realm.has_seat_based_plan, True)
|
|
|
|
self.assertEqual(realm.plan_type, Realm.STANDARD)
|
|
|
|
RealmAuditLog.objects.filter(realm=realm).delete()
|
|
|
|
|
|
|
|
stripe_customer = stripe_get_customer(Customer.objects.get(realm=user.realm).stripe_customer_id)
|
|
|
|
self.assertEqual(stripe_customer.account_balance, 0)
|
|
|
|
# Change subscription in Stripe, but don't pay for it
|
|
|
|
stripe_subscription = extract_current_subscription(stripe_customer)
|
|
|
|
stripe_subscription.quantity = 123
|
|
|
|
stripe.Subscription.save(stripe_subscription)
|
|
|
|
|
2018-08-31 20:09:36 +02:00
|
|
|
response = self.client_post("/json/billing/downgrade", {})
|
|
|
|
self.assert_json_success(response)
|
2018-11-25 16:58:04 +01:00
|
|
|
stripe_customer = stripe_get_customer(stripe_customer.id)
|
2018-11-29 03:24:19 +01:00
|
|
|
self.assertEqual(stripe_customer.account_balance, self.seat_count * -8000)
|
2018-11-25 16:58:04 +01:00
|
|
|
self.assertIsNone(extract_current_subscription(stripe_customer))
|
|
|
|
stripe_subscription = stripe.Subscription.retrieve(stripe_subscription.id)
|
|
|
|
self.assertEqual(stripe_subscription.status, "canceled")
|
2018-08-31 20:09:36 +02:00
|
|
|
|
|
|
|
realm = get_realm('zulip')
|
2018-11-25 16:58:04 +01:00
|
|
|
self.assertEqual(realm.plan_type, Realm.LIMITED)
|
2018-09-01 06:41:58 +02:00
|
|
|
self.assertFalse(realm.has_seat_based_plan)
|
2018-11-25 16:58:04 +01:00
|
|
|
|
2018-09-01 06:41:58 +02:00
|
|
|
audit_log_entries = list(RealmAuditLog.objects.filter(acting_user=user)
|
2018-11-25 16:58:04 +01:00
|
|
|
.values_list('event_type', 'event_time',
|
|
|
|
'requires_billing_update').order_by('id'))
|
|
|
|
self.assertEqual(audit_log_entries, [
|
|
|
|
(RealmAuditLog.STRIPE_PLAN_CHANGED,
|
|
|
|
timestamp_to_datetime(stripe_subscription.canceled_at), False),
|
|
|
|
(RealmAuditLog.REALM_PLAN_TYPE_CHANGED, Kandra(), False),
|
|
|
|
])
|
|
|
|
self.assertEqual(ujson.loads(RealmAuditLog.objects.filter(
|
|
|
|
event_type=RealmAuditLog.STRIPE_PLAN_CHANGED).values_list('extra_data', flat=True).first()),
|
|
|
|
{'plan': None, 'quantity': 123})
|
2018-08-31 20:09:36 +02:00
|
|
|
|
2018-11-25 17:17:17 +01:00
|
|
|
@mock_stripe("Customer.retrieve", "Customer.create")
|
|
|
|
def test_downgrade_with_no_subscription(self, mock2: Mock, mock1: Mock) -> None:
|
|
|
|
user = self.example_user("iago")
|
|
|
|
do_create_customer(user)
|
|
|
|
self.login(user.email)
|
|
|
|
with patch("stripe.Customer.save") as mock_save_customer:
|
|
|
|
with patch("corporate.lib.stripe.billing_logger.error"):
|
|
|
|
response = self.client_post("/json/billing/downgrade", {})
|
|
|
|
mock_save_customer.assert_not_called()
|
2018-08-31 20:09:36 +02:00
|
|
|
self.assert_json_error_contains(response, 'Please reload')
|
|
|
|
self.assertEqual(ujson.loads(response.content)['error_description'], 'downgrade without subscription')
|
|
|
|
|
2018-11-25 19:54:54 +01:00
|
|
|
@mock_stripe("Customer.create", "Customer.retrieve", "Customer.save", "Invoice.upcoming",
|
|
|
|
"Subscription.create", "Subscription.retrieve", "Subscription.save",
|
|
|
|
"Subscription.delete", "Token.create", "InvoiceItem.create")
|
|
|
|
def test_downgrade_with_money_owed(self, mock10: Mock, mock9: Mock, mock8: Mock, mock7: Mock,
|
|
|
|
mock6: Mock, mock5: Mock, mock4: Mock, mock3: Mock,
|
|
|
|
mock2: Mock, mock1: Mock) -> None:
|
2018-08-31 20:09:36 +02:00
|
|
|
user = self.example_user('iago')
|
|
|
|
self.login(user.email)
|
2018-11-29 03:15:27 +01:00
|
|
|
self.upgrade()
|
2018-11-25 19:54:54 +01:00
|
|
|
stripe_customer = stripe_get_customer(Customer.objects.get(realm=user.realm).stripe_customer_id)
|
|
|
|
self.assertEqual(stripe_customer.account_balance, 0)
|
|
|
|
stripe_subscription = extract_current_subscription(stripe_customer)
|
|
|
|
# Create a situation where customer net owes us money
|
|
|
|
stripe.InvoiceItem.create(
|
|
|
|
amount=100000,
|
|
|
|
currency='usd',
|
|
|
|
customer=stripe_customer,
|
|
|
|
subscription=stripe_subscription)
|
|
|
|
|
|
|
|
response = self.client_post("/json/billing/downgrade", {})
|
2018-08-31 20:09:36 +02:00
|
|
|
self.assert_json_success(response)
|
2018-11-25 19:54:54 +01:00
|
|
|
stripe_customer = stripe_get_customer(stripe_customer.id)
|
|
|
|
# Check that positive balance was forgiven
|
|
|
|
self.assertEqual(stripe_customer.account_balance, 0)
|
|
|
|
self.assertIsNone(extract_current_subscription(stripe_customer))
|
|
|
|
stripe_subscription = stripe.Subscription.retrieve(stripe_subscription.id)
|
|
|
|
self.assertEqual(stripe_subscription.status, "canceled")
|
2018-08-31 20:09:36 +02:00
|
|
|
|
2018-11-28 09:07:21 +01:00
|
|
|
@mock_stripe("Customer.create", "Customer.retrieve", "Customer.save",
|
|
|
|
"Subscription.create", "Token.create")
|
|
|
|
def test_replace_payment_source(self, mock5: Mock, mock4: Mock, mock3: Mock,
|
|
|
|
mock2: Mock, mock1: Mock) -> None:
|
|
|
|
user = self.example_user("hamlet")
|
2018-10-22 14:21:48 +02:00
|
|
|
self.login(user.email)
|
2018-11-29 03:15:27 +01:00
|
|
|
self.upgrade()
|
2018-11-28 09:07:21 +01:00
|
|
|
# Try replacing with a valid card
|
|
|
|
stripe_token = stripe_create_token(card_number='5555555555554444').id
|
|
|
|
response = self.client_post("/json/billing/sources/change",
|
|
|
|
{'stripe_token': ujson.dumps(stripe_token)})
|
|
|
|
self.assert_json_success(response)
|
|
|
|
number_of_sources = 0
|
|
|
|
for stripe_source in stripe_get_customer(Customer.objects.first().stripe_customer_id).sources:
|
|
|
|
self.assertEqual(cast(stripe.Card, stripe_source).last4, '4444')
|
|
|
|
number_of_sources += 1
|
|
|
|
self.assertEqual(number_of_sources, 1)
|
|
|
|
audit_log_entry = RealmAuditLog.objects.order_by('-id') \
|
|
|
|
.values_list('acting_user', 'event_type').first()
|
|
|
|
self.assertEqual(audit_log_entry, (user.id, RealmAuditLog.STRIPE_CARD_CHANGED))
|
|
|
|
RealmAuditLog.objects.filter(acting_user=user).delete()
|
|
|
|
|
|
|
|
# Try replacing with an invalid card
|
|
|
|
stripe_token = stripe_create_token(card_number='4000000000009987').id
|
|
|
|
with patch("corporate.lib.stripe.billing_logger.error") as mock_billing_logger:
|
2018-10-22 14:21:48 +02:00
|
|
|
response = self.client_post("/json/billing/sources/change",
|
2018-11-28 09:07:21 +01:00
|
|
|
{'stripe_token': ujson.dumps(stripe_token)})
|
|
|
|
mock_billing_logger.assert_called()
|
|
|
|
self.assertEqual(ujson.loads(response.content)['error_description'], 'card error')
|
|
|
|
self.assert_json_error_contains(response, 'Your card was declined')
|
|
|
|
number_of_sources = 0
|
|
|
|
for stripe_source in stripe_get_customer(Customer.objects.first().stripe_customer_id).sources:
|
|
|
|
self.assertEqual(cast(stripe.Card, stripe_source).last4, '4444')
|
|
|
|
number_of_sources += 1
|
|
|
|
self.assertEqual(number_of_sources, 1)
|
|
|
|
self.assertFalse(RealmAuditLog.objects.filter(event_type=RealmAuditLog.STRIPE_CARD_CHANGED).exists())
|
2018-10-22 14:21:48 +02:00
|
|
|
|
2018-11-28 22:13:00 +01:00
|
|
|
@mock_stripe("Subscription.create", "Customer.create", "Customer.retrieve", "Token.create")
|
|
|
|
def test_billing_quantity_changes_end_to_end(self, mock4: Mock, mock3: Mock, mock2: Mock,
|
|
|
|
mock1: Mock) -> None:
|
|
|
|
# A full end to end check would check the InvoiceItems, but this test is partway there
|
2018-07-03 21:49:55 +02:00
|
|
|
self.login(self.example_email("hamlet"))
|
|
|
|
processor = BillingProcessor.objects.create(
|
|
|
|
log_row=RealmAuditLog.objects.order_by('id').first(), state=BillingProcessor.DONE)
|
|
|
|
|
|
|
|
def check_billing_processor_update(event_type: str, quantity: int) -> None:
|
|
|
|
def check_subscription_save(subscription: stripe.Subscription, idempotency_key: str) -> None:
|
|
|
|
self.assertEqual(subscription.quantity, quantity)
|
|
|
|
log_row = RealmAuditLog.objects.filter(
|
|
|
|
event_type=event_type, requires_billing_update=True).order_by('-id').first()
|
|
|
|
self.assertEqual(idempotency_key, 'process_billing_log_entry:%s' % (log_row.id,))
|
|
|
|
self.assertEqual(subscription.proration_date, datetime_to_timestamp(log_row.event_time))
|
2018-10-18 05:50:52 +02:00
|
|
|
with patch.object(stripe.Subscription, 'save', autospec=True,
|
|
|
|
side_effect=check_subscription_save):
|
2018-07-03 21:49:55 +02:00
|
|
|
run_billing_processor_one_step(processor)
|
|
|
|
|
|
|
|
# Test STRIPE_PLAN_QUANTITY_RESET
|
|
|
|
new_seat_count = 123
|
|
|
|
# change the seat count while the user is going through the upgrade flow
|
2018-10-18 05:50:52 +02:00
|
|
|
with patch('corporate.lib.stripe.get_seat_count', return_value=new_seat_count):
|
2018-11-29 03:15:27 +01:00
|
|
|
self.upgrade()
|
2018-07-03 21:49:55 +02:00
|
|
|
check_billing_processor_update(RealmAuditLog.STRIPE_PLAN_QUANTITY_RESET, new_seat_count)
|
|
|
|
|
|
|
|
# Test USER_CREATED
|
|
|
|
user = do_create_user('newuser@zulip.com', 'password', get_realm('zulip'), 'full name', 'short name')
|
2018-11-29 03:24:19 +01:00
|
|
|
check_billing_processor_update(RealmAuditLog.USER_CREATED, self.seat_count + 1)
|
2018-07-03 21:49:55 +02:00
|
|
|
|
|
|
|
# Test USER_DEACTIVATED
|
|
|
|
do_deactivate_user(user)
|
2018-11-29 03:24:19 +01:00
|
|
|
check_billing_processor_update(RealmAuditLog.USER_DEACTIVATED, self.seat_count - 1)
|
2018-07-03 21:49:55 +02:00
|
|
|
|
|
|
|
# Test USER_REACTIVATED
|
|
|
|
do_reactivate_user(user)
|
2018-11-29 03:24:19 +01:00
|
|
|
check_billing_processor_update(RealmAuditLog.USER_REACTIVATED, self.seat_count + 1)
|
2018-07-03 21:49:55 +02:00
|
|
|
|
|
|
|
# Test USER_ACTIVATED
|
|
|
|
# Not a proper use of do_activate_user, but it's fine to call it like this for this test
|
|
|
|
do_activate_user(user)
|
2018-11-29 03:24:19 +01:00
|
|
|
check_billing_processor_update(RealmAuditLog.USER_ACTIVATED, self.seat_count + 1)
|
2018-07-03 21:49:55 +02:00
|
|
|
|
|
|
|
class RequiresBillingUpdateTest(ZulipTestCase):
|
2018-06-28 00:48:51 +02:00
|
|
|
def test_activity_change_requires_seat_update(self) -> None:
|
|
|
|
# Realm doesn't have a seat based plan
|
2018-07-25 16:37:07 +02:00
|
|
|
self.assertFalse(activity_change_requires_seat_update(self.example_user("hamlet")))
|
|
|
|
realm = get_realm("zulip")
|
|
|
|
realm.has_seat_based_plan = True
|
|
|
|
realm.save(update_fields=['has_seat_based_plan'])
|
2018-06-28 00:48:51 +02:00
|
|
|
# seat based plan + user not a bot
|
2018-07-25 16:37:07 +02:00
|
|
|
user = self.example_user("hamlet")
|
|
|
|
self.assertTrue(activity_change_requires_seat_update(user))
|
|
|
|
user.is_bot = True
|
|
|
|
user.save(update_fields=['is_bot'])
|
2018-06-28 00:48:51 +02:00
|
|
|
# seat based plan but user is a bot
|
2018-07-25 16:37:07 +02:00
|
|
|
self.assertFalse(activity_change_requires_seat_update(user))
|
2018-06-28 00:48:51 +02:00
|
|
|
|
|
|
|
def test_requires_billing_update_for_is_active_changes(self) -> None:
|
|
|
|
count = RealmAuditLog.objects.count()
|
2018-07-25 16:37:07 +02:00
|
|
|
realm = get_realm("zulip")
|
|
|
|
user1 = do_create_user('user1@zulip.com', 'password', realm, 'full name', 'short name')
|
2018-06-28 00:48:51 +02:00
|
|
|
do_deactivate_user(user1)
|
|
|
|
do_reactivate_user(user1)
|
|
|
|
# Not a proper use of do_activate_user, but it's fine to call it like this for this test
|
|
|
|
do_activate_user(user1)
|
|
|
|
self.assertEqual(count + 4,
|
|
|
|
RealmAuditLog.objects.filter(requires_billing_update=False).count())
|
|
|
|
|
2018-07-25 16:37:07 +02:00
|
|
|
realm.has_seat_based_plan = True
|
|
|
|
realm.save(update_fields=['has_seat_based_plan'])
|
|
|
|
user2 = do_create_user('user2@zulip.com', 'password', realm, 'full name', 'short name')
|
2018-06-28 00:48:51 +02:00
|
|
|
do_deactivate_user(user2)
|
|
|
|
do_reactivate_user(user2)
|
|
|
|
do_activate_user(user2)
|
|
|
|
self.assertEqual(4, RealmAuditLog.objects.filter(requires_billing_update=True).count())
|
2018-07-03 21:49:55 +02:00
|
|
|
|
2018-11-01 11:26:29 +01:00
|
|
|
class RequiresBillingAccessTest(ZulipTestCase):
|
|
|
|
def setUp(self) -> None:
|
|
|
|
hamlet = self.example_user("hamlet")
|
|
|
|
hamlet.is_billing_admin = True
|
|
|
|
hamlet.save(update_fields=["is_billing_admin"])
|
|
|
|
|
|
|
|
# mocked_function_name will typically be something imported from
|
|
|
|
# stripe.py. In theory we could have endpoints that need to mock
|
|
|
|
# multiple functions, but we'll cross that bridge when we get there.
|
|
|
|
def _test_endpoint(self, url: str, mocked_function_name: str,
|
|
|
|
request_data: Optional[Dict[str, Any]]={}) -> None:
|
|
|
|
# Normal users do not have access
|
|
|
|
self.login(self.example_email('cordelia'))
|
|
|
|
response = self.client_post(url, request_data)
|
2018-11-01 22:17:36 +01:00
|
|
|
self.assert_json_error_contains(response, "Must be a billing administrator or an organization")
|
2018-11-01 11:26:29 +01:00
|
|
|
|
|
|
|
# Billing admins have access
|
|
|
|
self.login(self.example_email('hamlet'))
|
|
|
|
with patch("corporate.views.{}".format(mocked_function_name)) as mocked1:
|
|
|
|
response = self.client_post(url, request_data)
|
|
|
|
self.assert_json_success(response)
|
|
|
|
mocked1.assert_called()
|
|
|
|
|
|
|
|
# Realm admins have access, even if they are not billing admins
|
|
|
|
self.login(self.example_email('iago'))
|
|
|
|
with patch("corporate.views.{}".format(mocked_function_name)) as mocked2:
|
|
|
|
response = self.client_post(url, request_data)
|
|
|
|
self.assert_json_success(response)
|
|
|
|
mocked2.assert_called()
|
|
|
|
|
|
|
|
def test_json_endpoints(self) -> None:
|
|
|
|
params = [
|
|
|
|
("/json/billing/sources/change", "do_replace_payment_source",
|
|
|
|
{'stripe_token': ujson.dumps('token')}),
|
|
|
|
("/json/billing/downgrade", "process_downgrade", {})
|
|
|
|
] # type: List[Tuple[str, str, Dict[str, Any]]]
|
|
|
|
|
|
|
|
for (url, mocked_function_name, data) in params:
|
|
|
|
self._test_endpoint(url, mocked_function_name, data)
|
|
|
|
|
|
|
|
# Make sure that we are testing all the JSON endpoints
|
|
|
|
# Quite a hack, but probably fine for now
|
|
|
|
string_with_all_endpoints = str(get_resolver('corporate.urls').reverse_dict)
|
|
|
|
json_endpoints = set([word.strip("\"'()[],$") for word in string_with_all_endpoints.split()
|
|
|
|
if 'json' in word])
|
|
|
|
self.assertEqual(len(json_endpoints), len(params))
|
|
|
|
|
2018-07-03 21:49:55 +02:00
|
|
|
class BillingProcessorTest(ZulipTestCase):
|
|
|
|
def add_log_entry(self, realm: Realm=get_realm('zulip'),
|
|
|
|
event_type: str=RealmAuditLog.USER_CREATED,
|
|
|
|
requires_billing_update: bool=True) -> RealmAuditLog:
|
|
|
|
return RealmAuditLog.objects.create(
|
|
|
|
realm=realm, event_time=datetime.datetime(2001, 2, 3, 4, 5, 6).replace(tzinfo=timezone_utc),
|
|
|
|
event_type=event_type, requires_billing_update=requires_billing_update)
|
|
|
|
|
|
|
|
def test_get_next_billing_log_entry(self) -> None:
|
|
|
|
second_realm = Realm.objects.create(string_id='second', name='second')
|
|
|
|
entry1 = self.add_log_entry(realm=second_realm)
|
|
|
|
realm_processor = BillingProcessor.objects.create(
|
|
|
|
realm=second_realm, log_row=entry1, state=BillingProcessor.DONE)
|
|
|
|
entry2 = self.add_log_entry()
|
|
|
|
# global processor
|
|
|
|
processor = BillingProcessor.objects.create(
|
|
|
|
log_row=entry2, state=BillingProcessor.STARTED)
|
|
|
|
|
|
|
|
# Test STARTED, STALLED, and typo'ed state entry
|
|
|
|
self.assertEqual(entry2, get_next_billing_log_entry(processor))
|
|
|
|
processor.state = BillingProcessor.STALLED
|
|
|
|
processor.save()
|
|
|
|
with self.assertRaises(AssertionError):
|
|
|
|
get_next_billing_log_entry(processor)
|
|
|
|
processor.state = 'typo'
|
|
|
|
processor.save()
|
|
|
|
with self.assertRaisesRegex(BillingError, 'unknown processor state'):
|
|
|
|
get_next_billing_log_entry(processor)
|
|
|
|
|
|
|
|
# Test global processor is handled correctly
|
|
|
|
processor.state = BillingProcessor.DONE
|
|
|
|
processor.save()
|
|
|
|
# test it ignores entries with requires_billing_update=False
|
|
|
|
entry3 = self.add_log_entry(requires_billing_update=False)
|
|
|
|
# test it ignores entries with realm processors
|
|
|
|
entry4 = self.add_log_entry(realm=second_realm)
|
|
|
|
self.assertIsNone(get_next_billing_log_entry(processor))
|
|
|
|
# test it does catch entries it should
|
|
|
|
entry5 = self.add_log_entry()
|
|
|
|
self.assertEqual(entry5, get_next_billing_log_entry(processor))
|
|
|
|
|
|
|
|
# Test realm processor is handled correctly
|
|
|
|
# test it gets the entry with its realm, and ignores the entry with
|
|
|
|
# requires_billing_update=False, when global processor is up ahead
|
|
|
|
processor.log_row = entry5
|
|
|
|
processor.save()
|
|
|
|
self.assertEqual(entry4, get_next_billing_log_entry(realm_processor))
|
|
|
|
|
|
|
|
# test it doesn't run past the global processor
|
|
|
|
processor.log_row = entry3
|
|
|
|
processor.save()
|
|
|
|
self.assertIsNone(get_next_billing_log_entry(realm_processor))
|
|
|
|
|
|
|
|
def test_run_billing_processor_logic_when_no_errors(self) -> None:
|
|
|
|
second_realm = Realm.objects.create(string_id='second', name='second')
|
|
|
|
entry1 = self.add_log_entry(realm=second_realm)
|
|
|
|
realm_processor = BillingProcessor.objects.create(
|
|
|
|
realm=second_realm, log_row=entry1, state=BillingProcessor.DONE)
|
|
|
|
entry2 = self.add_log_entry()
|
|
|
|
# global processor
|
|
|
|
processor = BillingProcessor.objects.create(
|
|
|
|
log_row=entry2, state=BillingProcessor.DONE)
|
|
|
|
|
|
|
|
# Test nothing to process
|
|
|
|
# test nothing changes, for global processor
|
|
|
|
self.assertFalse(run_billing_processor_one_step(processor))
|
|
|
|
self.assertEqual(2, BillingProcessor.objects.count())
|
|
|
|
# test realm processor gets deleted
|
|
|
|
self.assertFalse(run_billing_processor_one_step(realm_processor))
|
|
|
|
self.assertEqual(1, BillingProcessor.objects.count())
|
|
|
|
self.assertEqual(1, BillingProcessor.objects.filter(realm=None).count())
|
|
|
|
|
|
|
|
# Test something to process
|
|
|
|
processor.state = BillingProcessor.STARTED
|
|
|
|
processor.save()
|
|
|
|
realm_processor = BillingProcessor.objects.create(
|
|
|
|
realm=second_realm, log_row=entry1, state=BillingProcessor.STARTED)
|
|
|
|
Customer.objects.create(realm=get_realm('zulip'), stripe_customer_id='cust_1')
|
|
|
|
Customer.objects.create(realm=second_realm, stripe_customer_id='cust_2')
|
2018-10-18 05:50:52 +02:00
|
|
|
with patch('corporate.lib.stripe.do_adjust_subscription_quantity'):
|
2018-07-03 21:49:55 +02:00
|
|
|
# test return values
|
|
|
|
self.assertTrue(run_billing_processor_one_step(processor))
|
|
|
|
self.assertTrue(run_billing_processor_one_step(realm_processor))
|
|
|
|
# test no processors get added or deleted
|
|
|
|
self.assertEqual(2, BillingProcessor.objects.count())
|
|
|
|
|
2018-10-18 05:50:52 +02:00
|
|
|
@patch("corporate.lib.stripe.billing_logger.error")
|
|
|
|
def test_run_billing_processor_with_card_error(self, mock_billing_logger_error: Mock) -> None:
|
2018-07-03 21:49:55 +02:00
|
|
|
second_realm = Realm.objects.create(string_id='second', name='second')
|
|
|
|
entry1 = self.add_log_entry(realm=second_realm)
|
|
|
|
# global processor
|
|
|
|
processor = BillingProcessor.objects.create(
|
|
|
|
log_row=entry1, state=BillingProcessor.STARTED)
|
|
|
|
Customer.objects.create(realm=second_realm, stripe_customer_id='cust_2')
|
|
|
|
|
|
|
|
# card error on global processor should create a new realm processor
|
2018-10-18 05:50:52 +02:00
|
|
|
with patch('corporate.lib.stripe.do_adjust_subscription_quantity',
|
|
|
|
side_effect=stripe.error.CardError('message', 'param', 'code', json_body={})):
|
2018-07-03 21:49:55 +02:00
|
|
|
self.assertTrue(run_billing_processor_one_step(processor))
|
|
|
|
self.assertEqual(2, BillingProcessor.objects.count())
|
|
|
|
self.assertTrue(BillingProcessor.objects.filter(
|
|
|
|
realm=None, log_row=entry1, state=BillingProcessor.SKIPPED).exists())
|
|
|
|
self.assertTrue(BillingProcessor.objects.filter(
|
|
|
|
realm=second_realm, log_row=entry1, state=BillingProcessor.STALLED).exists())
|
|
|
|
mock_billing_logger_error.assert_called()
|
|
|
|
|
|
|
|
# card error on realm processor should change state to STALLED
|
|
|
|
realm_processor = BillingProcessor.objects.filter(realm=second_realm).first()
|
|
|
|
realm_processor.state = BillingProcessor.STARTED
|
|
|
|
realm_processor.save()
|
2018-10-18 05:50:52 +02:00
|
|
|
with patch('corporate.lib.stripe.do_adjust_subscription_quantity',
|
|
|
|
side_effect=stripe.error.CardError('message', 'param', 'code', json_body={})):
|
2018-07-03 21:49:55 +02:00
|
|
|
self.assertTrue(run_billing_processor_one_step(realm_processor))
|
|
|
|
self.assertEqual(2, BillingProcessor.objects.count())
|
|
|
|
self.assertTrue(BillingProcessor.objects.filter(
|
|
|
|
realm=second_realm, log_row=entry1, state=BillingProcessor.STALLED).exists())
|
|
|
|
mock_billing_logger_error.assert_called()
|
|
|
|
|
2018-10-18 05:50:52 +02:00
|
|
|
@patch("corporate.lib.stripe.billing_logger.error")
|
|
|
|
def test_run_billing_processor_with_uncaught_error(self, mock_billing_logger_error: Mock) -> None:
|
2018-07-03 21:49:55 +02:00
|
|
|
# This tests three different things:
|
|
|
|
# * That run_billing_processor_one_step passes through exceptions that
|
|
|
|
# are not StripeCardError
|
|
|
|
# * That process_billing_log_entry catches StripeErrors and re-raises them as BillingErrors
|
|
|
|
# * That processor.state=STARTED for non-StripeCardError exceptions
|
|
|
|
entry1 = self.add_log_entry()
|
|
|
|
entry2 = self.add_log_entry()
|
|
|
|
processor = BillingProcessor.objects.create(
|
|
|
|
log_row=entry1, state=BillingProcessor.DONE)
|
|
|
|
Customer.objects.create(realm=get_realm('zulip'), stripe_customer_id='cust_1')
|
2018-10-18 05:50:52 +02:00
|
|
|
with patch('corporate.lib.stripe.do_adjust_subscription_quantity',
|
2018-10-22 10:13:41 +02:00
|
|
|
side_effect=stripe.error.StripeError('message', json_body={})):
|
2018-07-03 21:49:55 +02:00
|
|
|
with self.assertRaises(BillingError):
|
|
|
|
run_billing_processor_one_step(processor)
|
|
|
|
mock_billing_logger_error.assert_called()
|
|
|
|
# check processor.state is STARTED
|
|
|
|
self.assertTrue(BillingProcessor.objects.filter(
|
|
|
|
log_row=entry2, state=BillingProcessor.STARTED).exists())
|