mirror of
https://github.com/zulip/zulip.git
synced 2025-10-23 04:52:12 +00:00
This is required for upgrading to the basil version of stripe. Changes haven't been tested in this commit. It is just for easy verification of changes.
10495 lines
451 KiB
Python
10495 lines
451 KiB
Python
import itertools
|
|
import json
|
|
import operator
|
|
import os
|
|
import re
|
|
import sys
|
|
import typing
|
|
import uuid
|
|
from collections.abc import Callable, Mapping, Sequence
|
|
from dataclasses import dataclass
|
|
from datetime import datetime, timedelta, timezone
|
|
from decimal import Decimal
|
|
from functools import wraps
|
|
from typing import TYPE_CHECKING, Any, Literal, Optional, TypeVar, cast
|
|
from unittest import mock
|
|
from unittest.mock import MagicMock, Mock, patch
|
|
|
|
import orjson
|
|
import responses
|
|
import stripe
|
|
import time_machine
|
|
from django.conf import settings
|
|
from django.core import signing
|
|
from django.urls.resolvers import get_resolver
|
|
from django.utils.crypto import get_random_string
|
|
from django.utils.timezone import now as timezone_now
|
|
from typing_extensions import ParamSpec, override
|
|
|
|
from corporate.lib.stripe import (
|
|
DEFAULT_INVOICE_DAYS_UNTIL_DUE,
|
|
MAX_INVOICED_LICENSES,
|
|
MIN_INVOICED_LICENSES,
|
|
STRIPE_API_VERSION,
|
|
BillingError,
|
|
BillingSessionAuditLogEventError,
|
|
BillingSessionEventType,
|
|
InitialUpgradeRequest,
|
|
InvalidBillingScheduleError,
|
|
InvalidTierError,
|
|
RealmBillingSession,
|
|
RemoteRealmBillingSession,
|
|
RemoteServerBillingSession,
|
|
StripeCardError,
|
|
SupportRequestError,
|
|
SupportType,
|
|
SupportViewRequest,
|
|
UpdatePlanRequest,
|
|
add_months,
|
|
catch_stripe_errors,
|
|
compute_plan_parameters,
|
|
customer_has_credit_card_as_default_payment_method,
|
|
customer_has_last_n_invoices_open,
|
|
do_deactivate_remote_server,
|
|
do_reactivate_remote_server,
|
|
downgrade_small_realms_behind_on_payments_as_needed,
|
|
get_latest_seat_count,
|
|
get_plan_renewal_or_end_date,
|
|
get_price_per_license,
|
|
invoice_plans_as_needed,
|
|
is_free_trial_offer_enabled,
|
|
is_realm_on_free_trial,
|
|
next_month,
|
|
sign_string,
|
|
stripe_customer_has_credit_card_as_default_payment_method,
|
|
stripe_get_customer,
|
|
unsign_string,
|
|
)
|
|
from corporate.models.customers import Customer, get_customer_by_realm, get_customer_by_remote_realm
|
|
from corporate.models.licenses import LicenseLedger
|
|
from corporate.models.plans import (
|
|
CustomerPlan,
|
|
CustomerPlanOffer,
|
|
get_current_plan_by_customer,
|
|
get_current_plan_by_realm,
|
|
)
|
|
from corporate.models.sponsorships import ZulipSponsorshipRequest
|
|
from corporate.models.stripe_state import Event, Invoice
|
|
from corporate.tests.test_remote_billing import RemoteRealmBillingTestCase, RemoteServerTestCase
|
|
from corporate.views.remote_billing_page import generate_confirmation_link_for_server_deactivation
|
|
from zerver.actions.create_realm import do_create_realm
|
|
from zerver.actions.create_user import (
|
|
do_activate_mirror_dummy_user,
|
|
do_create_user,
|
|
do_reactivate_user,
|
|
)
|
|
from zerver.actions.realm_settings import do_deactivate_realm, do_reactivate_realm
|
|
from zerver.actions.users import change_user_is_active, do_change_user_role, do_deactivate_user
|
|
from zerver.lib.remote_server import send_server_data_to_push_bouncer
|
|
from zerver.lib.test_classes import ZulipTestCase
|
|
from zerver.lib.test_helpers import activate_push_notification_service
|
|
from zerver.lib.timestamp import datetime_to_timestamp, timestamp_to_datetime
|
|
from zerver.lib.utils import assert_is_not_none
|
|
from zerver.models import Message, Realm, RealmAuditLog, Recipient, UserProfile
|
|
from zerver.models.realm_audit_logs import AuditLogEventType
|
|
from zerver.models.realms import get_realm
|
|
from zerver.models.users import get_system_bot
|
|
from zilencer.lib.remote_counts import MissingDataError
|
|
from zilencer.models import (
|
|
RemoteRealm,
|
|
RemoteRealmAuditLog,
|
|
RemoteRealmBillingUser,
|
|
RemoteServerBillingUser,
|
|
RemoteZulipServer,
|
|
RemoteZulipServerAuditLog,
|
|
)
|
|
|
|
if TYPE_CHECKING:
|
|
from django.test.client import _MonkeyPatchedWSGIResponse as TestHttpResponse
|
|
|
|
CallableT = TypeVar("CallableT", bound=Callable[..., Any])
|
|
ParamT = ParamSpec("ParamT")
|
|
ReturnT = TypeVar("ReturnT")
|
|
|
|
STRIPE_FIXTURES_DIR = "corporate/tests/stripe_fixtures"
|
|
|
|
|
|
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
|
|
decorated_function_name = decorated_function_name.removeprefix("test_")
|
|
mocked_function_name = mocked_function_name.removeprefix("stripe.")
|
|
return (
|
|
f"{STRIPE_FIXTURES_DIR}/{decorated_function_name}--{mocked_function_name}.{call_count}.json"
|
|
)
|
|
|
|
|
|
def fixture_files_for_function(decorated_function: CallableT) -> list[str]: # nocoverage
|
|
decorated_function_name = decorated_function.__name__
|
|
decorated_function_name = decorated_function_name.removeprefix("test_")
|
|
return sorted(
|
|
f"{STRIPE_FIXTURES_DIR}/{f}"
|
|
for f in os.listdir(STRIPE_FIXTURES_DIR)
|
|
if f.startswith(decorated_function_name + "--")
|
|
)
|
|
|
|
|
|
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
|
|
)
|
|
try:
|
|
with responses.RequestsMock() as request_mock:
|
|
request_mock.add_passthru("https://api.stripe.com")
|
|
# Talk to Stripe
|
|
stripe_object = mocked_function(*args, **kwargs)
|
|
except stripe.StripeError as e:
|
|
with open(fixture_path, "w") as f:
|
|
assert e.headers is not None
|
|
error_dict = {**vars(e), "headers": dict(e.headers)}
|
|
# Add http_body to the error_dict, since it's not included in the vars(e) output.
|
|
# It should be same as e.json_body, but we include it since stripe expects it.
|
|
if e.http_body is None:
|
|
assert e.json_body is not None
|
|
# Convert e.json_body to be a JSON string, since that's what stripe expects.
|
|
error_dict["http_body"] = json.dumps(e.json_body)
|
|
f.write(
|
|
json.dumps(error_dict, indent=2, separators=(",", ": "), sort_keys=True) + "\n"
|
|
)
|
|
raise
|
|
with open(fixture_path, "w") as f:
|
|
if stripe_object is not None:
|
|
f.write(str(stripe_object) + "\n")
|
|
else:
|
|
f.write("{}\n")
|
|
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
|
|
)
|
|
with open(fixture_path, "rb") as f:
|
|
fixture = orjson.loads(f.read())
|
|
# Check for StripeError fixtures
|
|
if "json_body" in fixture:
|
|
requester = stripe._api_requestor._APIRequestor()
|
|
# This function will raise the relevant StripeError according to the fixture
|
|
requester._interpret_response(
|
|
fixture["http_body"], fixture["http_status"], fixture["headers"], "V1"
|
|
)
|
|
return stripe.convert_to_stripe_object(fixture)
|
|
|
|
return _read_stripe_fixture
|
|
|
|
|
|
def delete_fixture_data(decorated_function: CallableT) -> None: # nocoverage
|
|
for fixture_file in fixture_files_for_function(decorated_function):
|
|
os.remove(fixture_file)
|
|
|
|
|
|
def normalize_fixture_data(
|
|
decorated_function: CallableT, tested_timestamp_fields: Sequence[str] = []
|
|
) -> None: # nocoverage
|
|
# stripe ids are all of the form cus_D7OT2jf5YAtZQ2
|
|
id_lengths = [
|
|
("test", 12),
|
|
("cus", 14),
|
|
("prod", 14),
|
|
("req", 14),
|
|
("si", 14),
|
|
("sli", 14),
|
|
("sub", 14),
|
|
("acct", 16),
|
|
("card", 24),
|
|
("ch", 24),
|
|
("ii", 24),
|
|
("il", 24),
|
|
("in", 24),
|
|
("pi", 24),
|
|
("price", 24),
|
|
("src", 24),
|
|
("src_client_secret", 24),
|
|
("tok", 24),
|
|
("txn", 24),
|
|
("invst", 26),
|
|
("rcpt", 31),
|
|
("seti", 24),
|
|
("pm", 24),
|
|
("setatt", 24),
|
|
("bpc", 24),
|
|
("bps", 24),
|
|
]
|
|
|
|
# We'll replace "invoice_prefix": "A35BC4Q" with something like "invoice_prefix": "NORMA01"
|
|
# For patterns whose matches can be too generic like `[0-9]+`, include matching field in the translation
|
|
# to avoid it replacing other occurrences of the pattern. See `exp_month` for example.
|
|
pattern_translations = {
|
|
r'"exp_month": [0-9]+': '"exp_month": 1',
|
|
r'"exp_year": [0-9]+': '"exp_year": 9999',
|
|
r'"postal_code": "[0-9]+"': '"postal_code": "12345"',
|
|
r'"invoice_prefix": "[A-Za-z0-9]{7,8}"': '"invoice_prefix": "NORMALIZED"',
|
|
r'"fingerprint": "[A-Za-z0-9]{16}"': '"fingerprint": "NORMALIZED"',
|
|
r'"number": "[A-Za-z0-9]{7,8}-[A-Za-z0-9]{4}"': '"number": "NORMALIZED"',
|
|
r'"address": "[A-Za-z0-9]{9}-test_[A-Za-z0-9]{12}"': '"address": "000000000-test_NORMALIZED"',
|
|
r'"client_secret": "[\w]+"': '"client_secret": "NORMALIZED"',
|
|
r'"url": "https://billing.stripe.com/p/session/test_([\w]+)"': "NORMALIZED",
|
|
r'"url": "https://checkout.stripe.com/c/pay/cs_test_([\w#%]+)"': "NORMALIZED",
|
|
r'"receipt_url": "https://pay.stripe.com/receipts/invoices/([\w-]+)\?s=[\w]+"': "NORMALIZED",
|
|
r'"hosted_invoice_url": "https://invoice.stripe.com/i/acct_[\w]+/test_[\w,]+\?s=[\w]+"': '"hosted_invoice_url": "https://invoice.stripe.com/i/acct_NORMALIZED/test_NORMALIZED?s=ap"',
|
|
r'"invoice_pdf": "https://pay.stripe.com/invoice/acct_[\w]+/test_[\w,]+/pdf\?s=[\w]+"': '"invoice_pdf": "https://pay.stripe.com/invoice/acct_NORMALIZED/test_NORMALIZED/pdf?s=ap"',
|
|
r'"id": "([\w]+)"': "FILE_NAME", # Replace with file name later.
|
|
# Don't use (..) notation, since the matched strings may be small integers that will also match
|
|
# elsewhere in the file
|
|
r'"realm_id": "[0-9]+"': '"realm_id": "1"',
|
|
r'"account_name": "[\w\s]+"': '"account_name": "NORMALIZED"',
|
|
}
|
|
|
|
# We'll replace cus_D7OT2jf5YAtZQ2 with something like cus_NORMALIZED0001
|
|
pattern_translations.update(
|
|
{
|
|
rf"{prefix}_[A-Za-z0-9]{{{length}}}": f"{prefix}_NORMALIZED"
|
|
for prefix, length in id_lengths
|
|
}
|
|
)
|
|
# Normalizing across all timestamps still causes a lot of variance run to run, which is
|
|
# why we're doing something a bit more complicated
|
|
for i, timestamp_field in enumerate(tested_timestamp_fields):
|
|
# Don't use (..) notation, since the matched timestamp can easily appear in other fields
|
|
pattern_translations[rf'"{timestamp_field}": 1[5-9][0-9]{{8}}(?![0-9-])'] = (
|
|
f'"{timestamp_field}": {1000000000 + i}'
|
|
)
|
|
|
|
normalized_values: dict[str, dict[str, str]] = {pattern: {} for pattern in pattern_translations}
|
|
for fixture_file in fixture_files_for_function(decorated_function):
|
|
with open(fixture_file) 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]:
|
|
if pattern.startswith('"id": "'):
|
|
# Set file name as ID.
|
|
normalized_values[pattern][match] = fixture_file.split("/")[-1]
|
|
else:
|
|
normalized_values[pattern][match] = translation
|
|
file_content = file_content.replace(match, normalized_values[pattern][match])
|
|
file_content = re.sub(r'(?<="risk_score": )(\d+)', "0", file_content)
|
|
file_content = re.sub(r'(?<="times_redeemed": )(\d+)', "0", file_content)
|
|
file_content = re.sub(
|
|
r'(?<="idempotency_key": )"([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})"',
|
|
'"00000000-0000-0000-0000-000000000000"',
|
|
file_content,
|
|
)
|
|
# Dates
|
|
file_content = re.sub(r'(?<="Date": )"(.* GMT)"', '"NORMALIZED DATETIME"', file_content)
|
|
file_content = re.sub(r"[0-3]\d [A-Z][a-z]{2} 20[1-2]\d", "NORMALIZED DATE", file_content)
|
|
# 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)
|
|
# All timestamps not in tested_timestamp_fields
|
|
file_content = re.sub(r": (1[5-9][0-9]{8})(?![0-9-])", ": 1000000000", file_content)
|
|
|
|
with open(fixture_file, "w") as f:
|
|
f.write(file_content)
|
|
|
|
|
|
MOCKED_STRIPE_FUNCTION_NAMES = [
|
|
f"stripe.{name}"
|
|
for name in [
|
|
"billing_portal.Configuration.create",
|
|
"billing_portal.Session.create",
|
|
"checkout.Session.create",
|
|
"checkout.Session.list",
|
|
"Charge.create",
|
|
"Charge.list",
|
|
"Coupon.create",
|
|
"Customer.create",
|
|
"Customer.create_balance_transaction",
|
|
"Customer.list_balance_transactions",
|
|
"Customer.retrieve",
|
|
"Customer.save",
|
|
"Customer.list",
|
|
"Customer.modify",
|
|
"Event.list",
|
|
"Invoice.create",
|
|
"Invoice.finalize_invoice",
|
|
"Invoice.list",
|
|
"Invoice.pay",
|
|
"Invoice.refresh",
|
|
"Invoice.retrieve",
|
|
"Invoice.upcoming",
|
|
"Invoice.void_invoice",
|
|
"InvoiceItem.create",
|
|
"InvoiceItem.list",
|
|
"PaymentMethod.attach",
|
|
"PaymentMethod.create",
|
|
"PaymentMethod.detach",
|
|
"PaymentMethod.list",
|
|
"Plan.create",
|
|
"Product.create",
|
|
"SetupIntent.create",
|
|
"SetupIntent.list",
|
|
"SetupIntent.retrieve",
|
|
"Subscription.create",
|
|
"Subscription.delete",
|
|
"Subscription.retrieve",
|
|
"Subscription.save",
|
|
"Token.create",
|
|
]
|
|
]
|
|
|
|
|
|
def mock_stripe(
|
|
tested_timestamp_fields: Sequence[str] = [], generate: bool = settings.GENERATE_STRIPE_FIXTURES
|
|
) -> Callable[[Callable[ParamT, ReturnT]], Callable[ParamT, ReturnT]]:
|
|
def _mock_stripe(decorated_function: Callable[ParamT, ReturnT]) -> Callable[ParamT, ReturnT]:
|
|
generate_fixture = generate
|
|
if generate_fixture: # nocoverage
|
|
assert stripe.api_key
|
|
for mocked_function_name in MOCKED_STRIPE_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,
|
|
autospec=mocked_function_name.endswith(".refresh"),
|
|
)(decorated_function)
|
|
|
|
@wraps(decorated_function)
|
|
def wrapped(*args: ParamT.args, **kwargs: ParamT.kwargs) -> ReturnT:
|
|
if generate_fixture: # nocoverage
|
|
delete_fixture_data(decorated_function)
|
|
val = decorated_function(*args, **kwargs)
|
|
normalize_fixture_data(decorated_function, tested_timestamp_fields)
|
|
return val
|
|
else:
|
|
return decorated_function(*args, **kwargs)
|
|
|
|
return wrapped
|
|
|
|
return _mock_stripe
|
|
|
|
|
|
class StripeTestCase(ZulipTestCase):
|
|
@override
|
|
def setUp(self) -> None:
|
|
super().setUp()
|
|
realm = get_realm("zulip")
|
|
|
|
# Explicitly limit our active users to 6 regular users,
|
|
# to make seat_count less prone to changes in our test data.
|
|
# We also keep a guest user and a bot to make the data
|
|
# slightly realistic.
|
|
active_emails = [
|
|
self.example_email("AARON"),
|
|
self.example_email("cordelia"),
|
|
self.example_email("hamlet"),
|
|
self.example_email("iago"),
|
|
self.example_email("othello"),
|
|
self.example_email("desdemona"),
|
|
self.example_email("polonius"), # guest
|
|
self.example_email("default_bot"), # bot
|
|
]
|
|
|
|
# Deactivate all users in our realm that aren't in our whitelist.
|
|
for user_profile in UserProfile.objects.filter(realm_id=realm.id).exclude(
|
|
delivery_email__in=active_emails
|
|
):
|
|
do_deactivate_user(user_profile, acting_user=None)
|
|
|
|
# sanity check our 8 expected users are active
|
|
self.assertEqual(
|
|
UserProfile.objects.filter(realm=realm, is_active=True).count(),
|
|
8,
|
|
)
|
|
|
|
# Make sure we have active users outside our realm (to make
|
|
# sure relevant queries restrict on realm).
|
|
self.assertEqual(
|
|
UserProfile.objects.exclude(realm=realm).filter(is_active=True).count(),
|
|
10,
|
|
)
|
|
|
|
# Our seat count excludes our guest user and bot, and
|
|
# we want this to be predictable for certain tests with
|
|
# arithmetic calculations.
|
|
self.assertEqual(get_latest_seat_count(realm), 6)
|
|
self.seat_count = 6
|
|
self.signed_seat_count, self.salt = sign_string(str(self.seat_count))
|
|
# Choosing dates with corresponding timestamps below 1500000000 so that they are
|
|
# not caught by our timestamp normalization regex in normalize_fixture_data
|
|
self.now = datetime(2012, 1, 2, 3, 4, 5, tzinfo=timezone.utc)
|
|
self.next_month = datetime(2012, 2, 2, 3, 4, 5, tzinfo=timezone.utc)
|
|
self.next_year = datetime(2013, 1, 2, 3, 4, 5, tzinfo=timezone.utc)
|
|
|
|
# Add hamlet in `can_manage_billing_group` for testing.
|
|
hamlet = self.example_user("hamlet")
|
|
iago = self.example_user("iago")
|
|
do_change_user_role(hamlet, UserProfile.ROLE_REALM_OWNER, acting_user=None)
|
|
do_change_user_role(iago, UserProfile.ROLE_REALM_OWNER, acting_user=None)
|
|
|
|
self.billing_session: (
|
|
RealmBillingSession | RemoteRealmBillingSession | RemoteServerBillingSession
|
|
) = RealmBillingSession(user=hamlet, realm=realm)
|
|
|
|
def get_signed_seat_count_from_response(self, response: "TestHttpResponse") -> str | None:
|
|
match = re.search(r"name=\"signed_seat_count\" value=\"(.+)\"", response.content.decode())
|
|
return match.group(1) if match else None
|
|
|
|
def get_salt_from_response(self, response: "TestHttpResponse") -> str | None:
|
|
match = re.search(r"name=\"salt\" value=\"(\w+)\"", response.content.decode())
|
|
return match.group(1) if match else None
|
|
|
|
def get_test_card_token(
|
|
self,
|
|
attaches_to_customer: bool,
|
|
charge_succeeds: bool | None = None,
|
|
card_provider: str | None = None,
|
|
) -> str:
|
|
if attaches_to_customer:
|
|
assert charge_succeeds is not None
|
|
if charge_succeeds:
|
|
if card_provider == "visa":
|
|
return "tok_visa"
|
|
if card_provider == "mastercard":
|
|
return "tok_mastercard"
|
|
raise AssertionError("Unreachable code path")
|
|
else:
|
|
return "tok_chargeCustomerFail"
|
|
else:
|
|
return "tok_visa_chargeDeclined"
|
|
|
|
def assert_details_of_valid_session_from_event_status_endpoint(
|
|
self, stripe_session_id: str, expected_details: dict[str, Any]
|
|
) -> None:
|
|
json_response = self.client_billing_get(
|
|
"/billing/event/status",
|
|
{
|
|
"stripe_session_id": stripe_session_id,
|
|
},
|
|
)
|
|
response_dict = self.assert_json_success(json_response)
|
|
self.assertEqual(response_dict["session"], expected_details)
|
|
|
|
def assert_details_of_valid_invoice_payment_from_event_status_endpoint(
|
|
self,
|
|
stripe_invoice_id: str,
|
|
expected_details: dict[str, Any],
|
|
) -> None:
|
|
json_response = self.client_billing_get(
|
|
"/billing/event/status",
|
|
{
|
|
"stripe_invoice_id": stripe_invoice_id,
|
|
},
|
|
)
|
|
response_dict = self.assert_json_success(json_response)
|
|
self.assertEqual(response_dict["stripe_invoice"], expected_details)
|
|
|
|
def trigger_stripe_checkout_session_completed_webhook(
|
|
self,
|
|
token: str,
|
|
) -> None:
|
|
customer = self.billing_session.get_customer()
|
|
assert customer is not None
|
|
customer_stripe_id = customer.stripe_customer_id
|
|
assert customer_stripe_id is not None
|
|
[checkout_setup_intent] = iter(
|
|
stripe.SetupIntent.list(customer=customer_stripe_id, limit=1)
|
|
)
|
|
|
|
# Create a PaymentMethod using the token
|
|
payment_method = stripe.PaymentMethod.create(
|
|
type="card",
|
|
card={
|
|
"token": token,
|
|
},
|
|
billing_details={
|
|
"name": "John Doe",
|
|
"address": {
|
|
"line1": "123 Main St",
|
|
"city": "San Francisco",
|
|
"state": "CA",
|
|
"postal_code": "94105",
|
|
"country": "US",
|
|
},
|
|
},
|
|
)
|
|
assert isinstance(checkout_setup_intent.customer, str)
|
|
assert checkout_setup_intent.metadata is not None
|
|
assert checkout_setup_intent.usage in {"off_session", "on_session"}
|
|
usage = cast(
|
|
Literal["off_session", "on_session"], checkout_setup_intent.usage
|
|
) # https://github.com/python/mypy/issues/12535
|
|
stripe_setup_intent = stripe.SetupIntent.create(
|
|
payment_method=payment_method.id,
|
|
confirm=True,
|
|
payment_method_types=checkout_setup_intent.payment_method_types,
|
|
customer=checkout_setup_intent.customer,
|
|
metadata=checkout_setup_intent.metadata,
|
|
usage=usage,
|
|
)
|
|
[stripe_session] = iter(stripe.checkout.Session.list(customer=customer_stripe_id, limit=1))
|
|
stripe_session_dict = orjson.loads(orjson.dumps(stripe_session))
|
|
stripe_session_dict["setup_intent"] = stripe_setup_intent.id
|
|
|
|
event_payload = {
|
|
"id": f"evt_{get_random_string(24)}",
|
|
"object": "event",
|
|
"data": {"object": stripe_session_dict},
|
|
"type": "checkout.session.completed",
|
|
"api_version": STRIPE_API_VERSION,
|
|
}
|
|
|
|
response = self.client_post(
|
|
"/stripe/webhook/", event_payload, content_type="application/json"
|
|
)
|
|
assert response.status_code == 200
|
|
|
|
def send_stripe_webhook_event(self, event: stripe.Event) -> None:
|
|
response = self.client_post(
|
|
"/stripe/webhook/", orjson.loads(orjson.dumps(event)), content_type="application/json"
|
|
)
|
|
assert response.status_code == 200
|
|
|
|
def send_stripe_webhook_events(self, most_recent_event: stripe.Event) -> None:
|
|
while True:
|
|
events_old_to_new = list(
|
|
reversed(stripe.Event.list(ending_before=most_recent_event.id))
|
|
)
|
|
if len(events_old_to_new) == 0:
|
|
break
|
|
for event in events_old_to_new:
|
|
self.send_stripe_webhook_event(event)
|
|
most_recent_event = events_old_to_new[-1]
|
|
|
|
def add_card_to_customer_for_upgrade(self, charge_succeeds: bool = True) -> None:
|
|
start_session_json_response = self.client_billing_post(
|
|
"/upgrade/session/start_card_update_session",
|
|
{
|
|
"tier": 1,
|
|
},
|
|
)
|
|
response_dict = self.assert_json_success(start_session_json_response)
|
|
stripe_session_id = response_dict["stripe_session_id"]
|
|
self.assert_details_of_valid_session_from_event_status_endpoint(
|
|
stripe_session_id,
|
|
{
|
|
"type": "card_update_from_upgrade_page",
|
|
"status": "created",
|
|
"is_manual_license_management_upgrade_session": False,
|
|
"tier": 1,
|
|
},
|
|
)
|
|
self.trigger_stripe_checkout_session_completed_webhook(
|
|
self.get_test_card_token(
|
|
attaches_to_customer=True,
|
|
charge_succeeds=charge_succeeds,
|
|
card_provider="visa",
|
|
)
|
|
)
|
|
self.assert_details_of_valid_session_from_event_status_endpoint(
|
|
stripe_session_id,
|
|
{
|
|
"type": "card_update_from_upgrade_page",
|
|
"status": "completed",
|
|
"is_manual_license_management_upgrade_session": False,
|
|
"tier": 1,
|
|
"event_handler": {"status": "succeeded"},
|
|
},
|
|
)
|
|
|
|
def upgrade(
|
|
self,
|
|
invoice: bool = False,
|
|
talk_to_stripe: bool = True,
|
|
upgrade_page_response: Optional["TestHttpResponse"] = None,
|
|
del_args: Sequence[str] = [],
|
|
dont_confirm_payment: bool = False,
|
|
**kwargs: Any,
|
|
) -> "TestHttpResponse":
|
|
if upgrade_page_response is None:
|
|
tier = kwargs.get("tier")
|
|
upgrade_url = f"{self.billing_session.billing_base_url}/upgrade/"
|
|
if tier:
|
|
upgrade_url += f"?tier={tier}"
|
|
if self.billing_session.billing_base_url:
|
|
upgrade_page_response = self.client_get(upgrade_url, {}, subdomain="selfhosting")
|
|
else:
|
|
upgrade_page_response = self.client_get(upgrade_url, {})
|
|
params: dict[str, Any] = {
|
|
"schedule": "annual",
|
|
"signed_seat_count": self.get_signed_seat_count_from_response(upgrade_page_response),
|
|
"salt": self.get_salt_from_response(upgrade_page_response),
|
|
}
|
|
if invoice: # send_invoice
|
|
params.update(
|
|
billing_modality="send_invoice",
|
|
licenses=kwargs.get("licenses", 123),
|
|
)
|
|
else: # charge_automatically
|
|
params.update(
|
|
billing_modality="charge_automatically",
|
|
license_management="automatic",
|
|
)
|
|
|
|
remote_server_plan_start_date = kwargs.get("remote_server_plan_start_date")
|
|
if remote_server_plan_start_date:
|
|
params.update(
|
|
remote_server_plan_start_date=remote_server_plan_start_date,
|
|
)
|
|
|
|
params.update(kwargs)
|
|
for key in del_args:
|
|
params.pop(key, None)
|
|
|
|
if talk_to_stripe:
|
|
# Store the event after which we pay the invoice so that we can
|
|
# process all the events from this event to the latest.
|
|
[last_event] = iter(stripe.Event.list(limit=1))
|
|
|
|
existing_customer = self.billing_session.customer_plan_exists()
|
|
upgrade_json_response = self.client_billing_post("/billing/upgrade", params)
|
|
|
|
if upgrade_json_response.status_code != 200 or dont_confirm_payment:
|
|
# Return early if the upgrade request failed.
|
|
return upgrade_json_response
|
|
|
|
is_self_hosted_billing = not isinstance(self.billing_session, RealmBillingSession)
|
|
customer = self.billing_session.get_customer()
|
|
assert customer is not None
|
|
if not talk_to_stripe or (
|
|
is_free_trial_offer_enabled(is_self_hosted_billing)
|
|
and
|
|
# Free trial is not applicable for existing customers.
|
|
not existing_customer
|
|
):
|
|
# Upgrade already happened for free trial, invoice realms or schedule
|
|
# upgrade for customers on complimentary access plan.
|
|
return upgrade_json_response
|
|
|
|
last_sent_invoice = Invoice.objects.last()
|
|
assert last_sent_invoice is not None
|
|
|
|
response_dict = self.assert_json_success(upgrade_json_response)
|
|
self.assertEqual(
|
|
response_dict["stripe_invoice_id"],
|
|
last_sent_invoice.stripe_invoice_id,
|
|
)
|
|
|
|
# Verify that the Invoice was sent.
|
|
# Invoice is only marked as paid in our db after we receive `invoice.paid` event.
|
|
self.assert_details_of_valid_invoice_payment_from_event_status_endpoint(
|
|
last_sent_invoice.stripe_invoice_id,
|
|
{"status": "sent"},
|
|
)
|
|
|
|
if invoice:
|
|
# Mark the invoice as paid via stripe with the `invoice.paid` event.
|
|
stripe.Invoice.pay(last_sent_invoice.stripe_invoice_id, paid_out_of_band=True)
|
|
|
|
# Upgrade the organization.
|
|
# TODO: Fix `invoice.paid` event not being present in the events list even thought the invoice was
|
|
# paid. This is likely due to a latency between invoice being paid and the event being generated.
|
|
self.send_stripe_webhook_events(last_event)
|
|
return upgrade_json_response
|
|
|
|
def add_card_and_upgrade(
|
|
self, user: UserProfile | None = None, **kwargs: Any
|
|
) -> stripe.Customer:
|
|
# Add card
|
|
with time_machine.travel(self.now, tick=False):
|
|
self.add_card_to_customer_for_upgrade()
|
|
|
|
# Check that we correctly created a Customer object in Stripe
|
|
if user is not None:
|
|
stripe_customer = stripe_get_customer(
|
|
assert_is_not_none(Customer.objects.get(realm=user.realm).stripe_customer_id)
|
|
)
|
|
else:
|
|
customer = self.billing_session.get_customer()
|
|
assert customer is not None
|
|
stripe_customer = stripe_get_customer(assert_is_not_none(customer.stripe_customer_id))
|
|
self.assertTrue(stripe_customer_has_credit_card_as_default_payment_method(stripe_customer))
|
|
|
|
with time_machine.travel(self.now, tick=False):
|
|
response = self.upgrade(**kwargs)
|
|
self.assert_json_success(response)
|
|
|
|
return stripe_customer
|
|
|
|
# Upgrade without talking to Stripe
|
|
def local_upgrade(
|
|
self,
|
|
licenses: int,
|
|
automanage_licenses: bool,
|
|
billing_schedule: int,
|
|
charge_automatically: bool,
|
|
free_trial: bool,
|
|
stripe_invoice_paid: bool = False,
|
|
) -> None:
|
|
class StripeMock(Mock):
|
|
def __init__(self, depth: int = 1) -> None:
|
|
super().__init__(spec=stripe.Card)
|
|
self.id = "cus_123"
|
|
self.created = "1000"
|
|
self.last4 = "4242"
|
|
|
|
def upgrade_func(
|
|
licenses: int,
|
|
automanage_licenses: bool,
|
|
billing_schedule: int,
|
|
charge_automatically: bool,
|
|
free_trial: bool,
|
|
stripe_invoice_paid: bool,
|
|
*mock_args: Any,
|
|
) -> Any:
|
|
hamlet = self.example_user("hamlet")
|
|
billing_session = RealmBillingSession(hamlet)
|
|
return billing_session.process_initial_upgrade(
|
|
CustomerPlan.TIER_CLOUD_STANDARD,
|
|
licenses,
|
|
automanage_licenses,
|
|
billing_schedule,
|
|
charge_automatically,
|
|
free_trial,
|
|
stripe_invoice_paid=stripe_invoice_paid,
|
|
)
|
|
|
|
for mocked_function_name in MOCKED_STRIPE_FUNCTION_NAMES:
|
|
upgrade_func = patch(mocked_function_name, return_value=StripeMock())(upgrade_func)
|
|
upgrade_func(
|
|
licenses,
|
|
automanage_licenses,
|
|
billing_schedule,
|
|
charge_automatically,
|
|
free_trial,
|
|
stripe_invoice_paid,
|
|
)
|
|
|
|
def setup_mocked_stripe(self, callback: Callable[..., Any], *args: Any, **kwargs: Any) -> Mock:
|
|
with patch.multiple("stripe", Invoice=mock.DEFAULT, InvoiceItem=mock.DEFAULT) as mocked:
|
|
mocked["Invoice"].create.return_value = mock.Mock()
|
|
mocked["Invoice"].finalize_invoice.return_value = mock.Mock()
|
|
mocked["InvoiceItem"].create.return_value = mock.Mock()
|
|
callback(*args, **kwargs)
|
|
return mocked
|
|
|
|
def client_billing_get(self, url_suffix: str, info: Mapping[str, Any] = {}) -> Any:
|
|
url = f"/json{self.billing_session.billing_base_url}" + url_suffix
|
|
if self.billing_session.billing_base_url:
|
|
response = self.client_get(url, info, subdomain="selfhosting")
|
|
else:
|
|
response = self.client_get(url, info)
|
|
return response
|
|
|
|
def client_billing_post(self, url_suffix: str, info: Mapping[str, Any] = {}) -> Any:
|
|
url = f"/json{self.billing_session.billing_base_url}" + url_suffix
|
|
if self.billing_session.billing_base_url:
|
|
response = self.client_post(url, info, subdomain="selfhosting")
|
|
else:
|
|
response = self.client_post(url, info)
|
|
return response
|
|
|
|
def client_billing_patch(self, url_suffix: str, info: Mapping[str, Any] = {}) -> Any:
|
|
url = f"/json{self.billing_session.billing_base_url}" + url_suffix
|
|
if self.billing_session.billing_base_url:
|
|
response = self.client_patch(url, info, subdomain="selfhosting")
|
|
else:
|
|
response = self.client_patch(url, info)
|
|
return response
|
|
|
|
|
|
class StripeTest(StripeTestCase):
|
|
def test_catch_stripe_errors(self) -> None:
|
|
@catch_stripe_errors
|
|
def raise_invalid_request_error() -> None:
|
|
raise stripe.InvalidRequestError("message", "param", "code", json_body={})
|
|
|
|
with self.assertLogs("corporate.stripe", "ERROR") as error_log:
|
|
with self.assertRaises(BillingError) as billing_context:
|
|
raise_invalid_request_error()
|
|
self.assertEqual("other stripe error", billing_context.exception.error_description)
|
|
self.assertEqual(
|
|
error_log.output, ["ERROR:corporate.stripe:Stripe error: None None None None"]
|
|
)
|
|
|
|
@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.CardError(error_message, "number", "invalid_number", json_body=json_body)
|
|
|
|
with self.assertLogs("corporate.stripe", "INFO") as info_log:
|
|
with self.assertRaises(StripeCardError) as card_context:
|
|
raise_card_error()
|
|
self.assertIn("not a valid credit card", str(card_context.exception))
|
|
self.assertEqual("card error", card_context.exception.error_description)
|
|
self.assertEqual(
|
|
info_log.output, ["INFO:corporate.stripe:Stripe card error: None None None None"]
|
|
)
|
|
|
|
def test_billing_not_enabled(self) -> None:
|
|
iago = self.example_user("iago")
|
|
with self.settings(BILLING_ENABLED=False):
|
|
self.login_user(iago)
|
|
response = self.client_get("/upgrade/", follow=True)
|
|
self.assertEqual(response.status_code, 404)
|
|
|
|
@mock_stripe()
|
|
def test_stripe_billing_portal_urls(self, *mocks: Mock) -> None:
|
|
user = self.example_user("hamlet")
|
|
self.login_user(user)
|
|
self.add_card_to_customer_for_upgrade()
|
|
|
|
response = self.client_get(f"/customer_portal/?tier={CustomerPlan.TIER_CLOUD_STANDARD}")
|
|
self.assertEqual(response.status_code, 302)
|
|
self.assertTrue(response["Location"].startswith("https://billing.stripe.com"))
|
|
|
|
self.upgrade(invoice=True)
|
|
|
|
response = self.client_get("/customer_portal/?return_to_billing_page=true")
|
|
self.assertEqual(response.status_code, 302)
|
|
self.assertTrue(response["Location"].startswith("https://billing.stripe.com"))
|
|
|
|
response = self.client_get("/invoices/")
|
|
self.assertEqual(response.status_code, 302)
|
|
self.assertTrue(response["Location"].startswith("https://billing.stripe.com"))
|
|
|
|
@mock_stripe()
|
|
def test_upgrade_by_card_to_plus_plan(self, *mocks: Mock) -> None:
|
|
user = self.example_user("hamlet")
|
|
self.login_user(user)
|
|
response = self.client_get("/upgrade/?tier=2")
|
|
self.assert_in_success_response(
|
|
["Your subscription will renew automatically", "Zulip Cloud Plus"], response
|
|
)
|
|
self.assertEqual(user.realm.plan_type, Realm.PLAN_TYPE_SELF_HOSTED)
|
|
# This also means there is no card set as default payment method set for the user.
|
|
self.assertFalse(Customer.objects.filter(realm=user.realm).exists())
|
|
stripe_customer = self.add_card_and_upgrade(user, tier=CustomerPlan.TIER_CLOUD_PLUS)
|
|
|
|
self.assertEqual(stripe_customer.description, "zulip (Zulip Dev)")
|
|
self.assertEqual(stripe_customer.discount, None)
|
|
self.assertEqual(stripe_customer.email, user.delivery_email)
|
|
assert stripe_customer.metadata is not None
|
|
metadata_dict = dict(stripe_customer.metadata)
|
|
self.assertEqual(metadata_dict["realm_str"], "zulip")
|
|
try:
|
|
int(metadata_dict["realm_id"])
|
|
except ValueError: # nocoverage
|
|
raise AssertionError("realm_id is not a number")
|
|
|
|
# Check Charges in Stripe
|
|
[charge] = iter(stripe.Charge.list(customer=stripe_customer.id))
|
|
licenses_purchased = self.billing_session.min_licenses_for_plan(
|
|
CustomerPlan.TIER_CLOUD_PLUS
|
|
)
|
|
self.assertEqual(charge.amount, 12000 * licenses_purchased)
|
|
self.assertEqual(charge.description, "Payment for Invoice")
|
|
self.assertEqual(charge.receipt_email, user.delivery_email)
|
|
self.assertEqual(charge.statement_descriptor, "Zulip Cloud Plus")
|
|
# Check Invoices in Stripe
|
|
[invoice] = iter(stripe.Invoice.list(customer=stripe_customer.id))
|
|
self.assertIsNotNone(invoice.status_transitions.finalized_at)
|
|
invoice_params = {
|
|
# auto_advance is False because the invoice has been paid
|
|
"amount_due": 120000,
|
|
"amount_paid": 120000,
|
|
"auto_advance": False,
|
|
"collection_method": "charge_automatically",
|
|
"status": "paid",
|
|
"total": 120000,
|
|
}
|
|
self.assertIsNotNone(invoice.charge)
|
|
for key, value in invoice_params.items():
|
|
self.assertEqual(invoice.get(key), value)
|
|
# Check Line Items on Stripe Invoice
|
|
[item0] = iter(invoice.lines)
|
|
line_item_params = {
|
|
"amount": 12000 * licenses_purchased,
|
|
"description": "Zulip Cloud Plus",
|
|
"discountable": False,
|
|
# There's no unit_amount on Line Items, probably because it doesn't show up on the
|
|
# user-facing invoice. We could pull the Invoice Item instead and test unit_amount there,
|
|
# but testing the amount and quantity seems sufficient.
|
|
"plan": None,
|
|
"proration": False,
|
|
"quantity": licenses_purchased,
|
|
"period": {
|
|
"start": datetime_to_timestamp(self.now),
|
|
"end": datetime_to_timestamp(add_months(self.now, 12)),
|
|
},
|
|
}
|
|
for key, value in line_item_params.items():
|
|
self.assertEqual(item0.get(key), value)
|
|
|
|
# Check that we correctly populated Customer, CustomerPlan, and LicenseLedger in Zulip
|
|
customer = Customer.objects.get(stripe_customer_id=stripe_customer.id, realm=user.realm)
|
|
plan = CustomerPlan.objects.get(
|
|
customer=customer,
|
|
automanage_licenses=True,
|
|
price_per_license=12000,
|
|
fixed_price=None,
|
|
discount=None,
|
|
billing_cycle_anchor=self.now,
|
|
billing_schedule=CustomerPlan.BILLING_SCHEDULE_ANNUAL,
|
|
invoiced_through=LicenseLedger.objects.first(),
|
|
next_invoice_date=self.next_month,
|
|
tier=CustomerPlan.TIER_CLOUD_PLUS,
|
|
status=CustomerPlan.ACTIVE,
|
|
)
|
|
LicenseLedger.objects.get(
|
|
plan=plan,
|
|
is_renewal=True,
|
|
event_time=self.now,
|
|
licenses=licenses_purchased,
|
|
licenses_at_next_renewal=licenses_purchased,
|
|
)
|
|
# Check RealmAuditLog
|
|
audit_log_entries = list(
|
|
RealmAuditLog.objects.filter(acting_user=user)
|
|
.values_list("event_type", "event_time")
|
|
.order_by("id")
|
|
)
|
|
self.assertEqual(
|
|
audit_log_entries[:3],
|
|
[
|
|
(
|
|
AuditLogEventType.STRIPE_CUSTOMER_CREATED,
|
|
timestamp_to_datetime(stripe_customer.created),
|
|
),
|
|
(AuditLogEventType.STRIPE_CARD_CHANGED, self.now),
|
|
(AuditLogEventType.CUSTOMER_PLAN_CREATED, self.now),
|
|
],
|
|
)
|
|
self.assertEqual(audit_log_entries[3][0], AuditLogEventType.REALM_PLAN_TYPE_CHANGED)
|
|
first_audit_log_entry = (
|
|
RealmAuditLog.objects.filter(event_type=AuditLogEventType.CUSTOMER_PLAN_CREATED)
|
|
.values_list("extra_data", flat=True)
|
|
.first()
|
|
)
|
|
assert first_audit_log_entry is not None
|
|
self.assertTrue(first_audit_log_entry["automanage_licenses"])
|
|
# Check that we correctly updated Realm
|
|
realm = get_realm("zulip")
|
|
self.assertEqual(realm.plan_type, Realm.PLAN_TYPE_PLUS)
|
|
self.assertEqual(realm.max_invites, Realm.INVITES_STANDARD_REALM_DAILY_MAX)
|
|
# Check that we can no longer access /upgrade
|
|
response = self.client_get("/upgrade/")
|
|
self.assertEqual(response.status_code, 302)
|
|
self.assertEqual("http://zulip.testserver/billing", response["Location"])
|
|
|
|
# Check /billing/ has the correct information
|
|
with time_machine.travel(self.now, tick=False):
|
|
response = self.client_get("/billing/")
|
|
self.assert_not_in_success_response(["Pay annually"], response)
|
|
for substring in [
|
|
"Zulip Cloud Plus",
|
|
str(licenses_purchased),
|
|
"Number of licenses",
|
|
f"{licenses_purchased}",
|
|
"Your plan will automatically renew on",
|
|
"January 2, 2013",
|
|
"$1,200.00",
|
|
"Visa ending in 4242",
|
|
"Update card",
|
|
]:
|
|
self.assert_in_response(substring, response)
|
|
|
|
self.assert_not_in_success_response(
|
|
[
|
|
"Number of licenses for current billing period",
|
|
"You will receive an invoice for",
|
|
],
|
|
response,
|
|
)
|
|
|
|
@mock_stripe()
|
|
def test_upgrade_by_invoice_to_plus_plan(self, *mocks: Mock) -> None:
|
|
user = self.example_user("hamlet")
|
|
self.login_user(user)
|
|
# Click "Make payment" in Stripe Checkout
|
|
with time_machine.travel(self.now, tick=False):
|
|
self.upgrade(invoice=True, tier=CustomerPlan.TIER_CLOUD_PLUS)
|
|
# Check that we correctly created a Customer in Stripe
|
|
stripe_customer = stripe_get_customer(
|
|
assert_is_not_none(Customer.objects.get(realm=user.realm).stripe_customer_id)
|
|
)
|
|
self.assertFalse(stripe_customer_has_credit_card_as_default_payment_method(stripe_customer))
|
|
|
|
# Check Charges in Stripe
|
|
# There is no charge created for out of band payments which is used
|
|
# to test this method.
|
|
self.assertFalse(stripe.Charge.list(customer=stripe_customer.id))
|
|
# Check Invoices in Stripe
|
|
[invoice] = iter(stripe.Invoice.list(customer=stripe_customer.id))
|
|
self.assertIsNotNone(invoice.due_date)
|
|
self.assertIsNotNone(invoice.status_transitions.finalized_at)
|
|
invoice_params = {
|
|
"amount_due": 12000 * 123,
|
|
"amount_paid": 0,
|
|
"attempt_count": 0,
|
|
"auto_advance": False,
|
|
"collection_method": "send_invoice",
|
|
"statement_descriptor": "Zulip Cloud Plus",
|
|
"status": "paid",
|
|
"total": 12000 * 123,
|
|
}
|
|
for key, value in invoice_params.items():
|
|
self.assertEqual(invoice.get(key), value)
|
|
# Check Line Items on Stripe Invoice
|
|
[item] = iter(invoice.lines)
|
|
line_item_params = {
|
|
"amount": 12000 * 123,
|
|
"description": "Zulip Cloud Plus",
|
|
"discountable": False,
|
|
"plan": None,
|
|
"proration": False,
|
|
"quantity": 123,
|
|
"period": {
|
|
"start": datetime_to_timestamp(self.now),
|
|
"end": datetime_to_timestamp(add_months(self.now, 12)),
|
|
},
|
|
}
|
|
for key, value in line_item_params.items():
|
|
self.assertEqual(item.get(key), value)
|
|
|
|
# Check that we correctly populated Customer, CustomerPlan and LicenseLedger in Zulip
|
|
customer = Customer.objects.get(stripe_customer_id=stripe_customer.id, realm=user.realm)
|
|
plan = CustomerPlan.objects.get(
|
|
customer=customer,
|
|
automanage_licenses=False,
|
|
charge_automatically=False,
|
|
price_per_license=12000,
|
|
fixed_price=None,
|
|
discount=None,
|
|
billing_cycle_anchor=self.now,
|
|
billing_schedule=CustomerPlan.BILLING_SCHEDULE_ANNUAL,
|
|
invoiced_through=LicenseLedger.objects.first(),
|
|
next_invoice_date=self.next_month,
|
|
tier=CustomerPlan.TIER_CLOUD_PLUS,
|
|
status=CustomerPlan.ACTIVE,
|
|
)
|
|
LicenseLedger.objects.get(
|
|
plan=plan,
|
|
is_renewal=True,
|
|
event_time=self.now,
|
|
licenses=123,
|
|
licenses_at_next_renewal=123,
|
|
)
|
|
# Check RealmAuditLog
|
|
audit_log_entries = list(
|
|
RealmAuditLog.objects.filter(acting_user=user)
|
|
.values_list("event_type", "event_time")
|
|
.order_by("id")
|
|
)
|
|
self.assertEqual(
|
|
audit_log_entries[:3],
|
|
[
|
|
(
|
|
AuditLogEventType.STRIPE_CUSTOMER_CREATED,
|
|
timestamp_to_datetime(stripe_customer.created),
|
|
),
|
|
(AuditLogEventType.CUSTOMER_PLAN_CREATED, self.now),
|
|
(AuditLogEventType.REALM_PLAN_TYPE_CHANGED, self.now),
|
|
],
|
|
)
|
|
self.assertEqual(audit_log_entries[2][0], AuditLogEventType.REALM_PLAN_TYPE_CHANGED)
|
|
first_audit_log_entry = (
|
|
RealmAuditLog.objects.filter(event_type=AuditLogEventType.CUSTOMER_PLAN_CREATED)
|
|
.values_list("extra_data", flat=True)
|
|
.first()
|
|
)
|
|
assert first_audit_log_entry is not None
|
|
self.assertFalse(first_audit_log_entry["automanage_licenses"])
|
|
# Check that we correctly updated Realm
|
|
realm = get_realm("zulip")
|
|
self.assertEqual(realm.plan_type, Realm.PLAN_TYPE_PLUS)
|
|
self.assertEqual(realm.max_invites, Realm.INVITES_STANDARD_REALM_DAILY_MAX)
|
|
# Check that we can no longer access /upgrade
|
|
response = self.client_get("/upgrade/")
|
|
self.assertEqual(response.status_code, 302)
|
|
self.assertEqual("http://zulip.testserver/billing", response["Location"])
|
|
|
|
# Check /billing/ has the correct information
|
|
with time_machine.travel(self.now, tick=False):
|
|
response = self.client_get("/billing/")
|
|
self.assert_not_in_success_response(["Pay annually", "Update card"], response)
|
|
for substring in [
|
|
"Zulip Cloud Plus",
|
|
str(123),
|
|
"Number of licenses for current billing period",
|
|
f"licenses ({self.seat_count} in use)",
|
|
"You will receive an invoice for",
|
|
"January 2, 2013",
|
|
"$14,760.00", # 14760 = 120 * 123
|
|
]:
|
|
self.assert_in_response(substring, response)
|
|
|
|
@mock_stripe(tested_timestamp_fields=["created"])
|
|
def test_upgrade_by_card(self, *mocks: Mock) -> None:
|
|
user = self.example_user("hamlet")
|
|
self.login_user(user)
|
|
response = self.client_get("/upgrade/")
|
|
self.assert_in_success_response(["Your subscription will renew automatically"], response)
|
|
self.assertNotEqual(user.realm.plan_type, Realm.PLAN_TYPE_STANDARD)
|
|
# This also means there is no card set as default payment method set for the user.
|
|
self.assertFalse(Customer.objects.filter(realm=user.realm).exists())
|
|
|
|
# Click "Purchase Zulip Cloud Standard" without adding a card.
|
|
with self.assertLogs("corporate.stripe", "WARNING"):
|
|
response = self.upgrade()
|
|
self.assert_json_error(response, "Please add a credit card before upgrading.")
|
|
|
|
stripe_customer = self.add_card_and_upgrade(user)
|
|
|
|
self.assertEqual(stripe_customer.description, "zulip (Zulip Dev)")
|
|
self.assertEqual(stripe_customer.discount, None)
|
|
self.assertEqual(stripe_customer.email, user.delivery_email)
|
|
assert stripe_customer.metadata is not None
|
|
metadata_dict = dict(stripe_customer.metadata)
|
|
self.assertEqual(metadata_dict["realm_str"], "zulip")
|
|
try:
|
|
int(metadata_dict["realm_id"])
|
|
except ValueError: # nocoverage
|
|
raise AssertionError("realm_id is not a number")
|
|
|
|
# Check Charges in Stripe
|
|
[charge] = iter(stripe.Charge.list(customer=stripe_customer.id))
|
|
self.assertEqual(charge.amount, 8000 * self.seat_count)
|
|
self.assertEqual(charge.description, "Payment for Invoice")
|
|
self.assertEqual(charge.receipt_email, user.delivery_email)
|
|
self.assertEqual(charge.statement_descriptor, "Zulip Cloud Standard")
|
|
# Check Invoices in Stripe
|
|
[invoice] = iter(stripe.Invoice.list(customer=stripe_customer.id))
|
|
self.assertIsNotNone(invoice.status_transitions.finalized_at)
|
|
invoice_params = {
|
|
# auto_advance is False because the invoice has been paid
|
|
"amount_due": 48000,
|
|
"amount_paid": 48000,
|
|
"auto_advance": False,
|
|
"collection_method": "charge_automatically",
|
|
"status": "paid",
|
|
"total": 48000,
|
|
}
|
|
self.assertIsNotNone(invoice.charge)
|
|
for key, value in invoice_params.items():
|
|
self.assertEqual(invoice.get(key), value)
|
|
# Check Line Items on Stripe Invoice
|
|
[item0] = iter(invoice.lines)
|
|
line_item_params = {
|
|
"amount": 8000 * self.seat_count,
|
|
"description": "Zulip Cloud Standard",
|
|
"discountable": False,
|
|
# There's no unit_amount on Line Items, probably because it doesn't show up on the
|
|
# user-facing invoice. We could pull the Invoice Item instead and test unit_amount there,
|
|
# but testing the amount and quantity seems sufficient.
|
|
"plan": None,
|
|
"proration": False,
|
|
"quantity": self.seat_count,
|
|
"period": {
|
|
"start": datetime_to_timestamp(self.now),
|
|
"end": datetime_to_timestamp(add_months(self.now, 12)),
|
|
},
|
|
}
|
|
for key, value in line_item_params.items():
|
|
self.assertEqual(item0.get(key), value)
|
|
|
|
# Check that we correctly populated Customer, CustomerPlan, and LicenseLedger in Zulip
|
|
customer = Customer.objects.get(stripe_customer_id=stripe_customer.id, realm=user.realm)
|
|
plan = CustomerPlan.objects.get(
|
|
customer=customer,
|
|
automanage_licenses=True,
|
|
price_per_license=8000,
|
|
fixed_price=None,
|
|
discount=None,
|
|
billing_cycle_anchor=self.now,
|
|
billing_schedule=CustomerPlan.BILLING_SCHEDULE_ANNUAL,
|
|
invoiced_through=LicenseLedger.objects.first(),
|
|
next_invoice_date=self.next_month,
|
|
tier=CustomerPlan.TIER_CLOUD_STANDARD,
|
|
status=CustomerPlan.ACTIVE,
|
|
)
|
|
LicenseLedger.objects.get(
|
|
plan=plan,
|
|
is_renewal=True,
|
|
event_time=self.now,
|
|
licenses=self.seat_count,
|
|
licenses_at_next_renewal=self.seat_count,
|
|
)
|
|
# Check RealmAuditLog
|
|
audit_log_entries = list(
|
|
RealmAuditLog.objects.filter(acting_user=user)
|
|
.values_list("event_type", "event_time")
|
|
.order_by("id")
|
|
)
|
|
self.assertEqual(
|
|
audit_log_entries[:3],
|
|
[
|
|
(
|
|
AuditLogEventType.STRIPE_CUSTOMER_CREATED,
|
|
timestamp_to_datetime(stripe_customer.created),
|
|
),
|
|
(AuditLogEventType.STRIPE_CARD_CHANGED, self.now),
|
|
(AuditLogEventType.CUSTOMER_PLAN_CREATED, self.now),
|
|
],
|
|
)
|
|
self.assertEqual(audit_log_entries[3][0], AuditLogEventType.REALM_PLAN_TYPE_CHANGED)
|
|
first_audit_log_entry = (
|
|
RealmAuditLog.objects.filter(event_type=AuditLogEventType.CUSTOMER_PLAN_CREATED)
|
|
.values_list("extra_data", flat=True)
|
|
.first()
|
|
)
|
|
assert first_audit_log_entry is not None
|
|
self.assertTrue(first_audit_log_entry["automanage_licenses"])
|
|
# Check that we correctly updated Realm
|
|
realm = get_realm("zulip")
|
|
self.assertEqual(realm.plan_type, Realm.PLAN_TYPE_STANDARD)
|
|
self.assertEqual(realm.max_invites, Realm.INVITES_STANDARD_REALM_DAILY_MAX)
|
|
# Check that we can no longer access /upgrade
|
|
response = self.client_get("/upgrade/")
|
|
self.assertEqual(response.status_code, 302)
|
|
self.assertEqual("http://zulip.testserver/billing", response["Location"])
|
|
|
|
# Check /billing/ has the correct information
|
|
with time_machine.travel(self.now, tick=False):
|
|
response = self.client_get("/billing/")
|
|
self.assert_not_in_success_response(["Pay annually"], response)
|
|
for substring in [
|
|
"Zulip Cloud Standard",
|
|
str(self.seat_count),
|
|
"Number of licenses",
|
|
f"{self.seat_count}",
|
|
"Your plan will automatically renew on",
|
|
"January 2, 2013",
|
|
f"${80 * self.seat_count}.00",
|
|
"Visa ending in 4242",
|
|
"Update card",
|
|
]:
|
|
self.assert_in_response(substring, response)
|
|
|
|
self.assert_not_in_success_response(
|
|
[
|
|
"Number of licenses for current billing period",
|
|
"You will receive an invoice for",
|
|
],
|
|
response,
|
|
)
|
|
|
|
@mock_stripe(tested_timestamp_fields=["created"])
|
|
def test_card_attached_to_customer_but_payment_fails(self, *mocks: Mock) -> None:
|
|
user = self.example_user("hamlet")
|
|
self.login_user(user)
|
|
self.add_card_to_customer_for_upgrade(charge_succeeds=False)
|
|
with self.assertLogs("corporate.stripe", "WARNING"):
|
|
response = self.upgrade()
|
|
self.assert_json_error_contains(response, "Your card was declined.")
|
|
|
|
# Customer added a card which always requires authentication, we cannot
|
|
# use these cards for automatic payments.
|
|
# TODO: Add a test case for it here.
|
|
|
|
@mock_stripe(tested_timestamp_fields=["created"])
|
|
def test_upgrade_by_invoice(self, *mocks: Mock) -> None:
|
|
user = self.example_user("hamlet")
|
|
self.login_user(user)
|
|
# Click "Make payment" in Stripe Checkout
|
|
with time_machine.travel(self.now, tick=False):
|
|
self.upgrade(invoice=True)
|
|
# Check that we correctly created a Customer in Stripe
|
|
stripe_customer = stripe_get_customer(
|
|
assert_is_not_none(Customer.objects.get(realm=user.realm).stripe_customer_id)
|
|
)
|
|
self.assertFalse(stripe_customer_has_credit_card_as_default_payment_method(stripe_customer))
|
|
|
|
# Check Charges in Stripe
|
|
self.assertFalse(stripe.Charge.list(customer=stripe_customer.id))
|
|
# Check Invoices in Stripe
|
|
[invoice] = iter(stripe.Invoice.list(customer=stripe_customer.id))
|
|
self.assertIsNotNone(invoice.due_date)
|
|
self.assertIsNotNone(invoice.status_transitions.finalized_at)
|
|
invoice_params = {
|
|
"amount_due": 8000 * 123,
|
|
"amount_paid": 0,
|
|
"attempt_count": 0,
|
|
"auto_advance": False,
|
|
"collection_method": "send_invoice",
|
|
"statement_descriptor": "Zulip Cloud Standard",
|
|
"status": "paid",
|
|
"total": 8000 * 123,
|
|
}
|
|
for key, value in invoice_params.items():
|
|
self.assertEqual(invoice.get(key), value)
|
|
# Check Line Items on Stripe Invoice
|
|
[item] = iter(invoice.lines)
|
|
line_item_params = {
|
|
"amount": 8000 * 123,
|
|
"description": "Zulip Cloud Standard",
|
|
"discountable": False,
|
|
"plan": None,
|
|
"proration": False,
|
|
"quantity": 123,
|
|
"period": {
|
|
"start": datetime_to_timestamp(self.now),
|
|
"end": datetime_to_timestamp(add_months(self.now, 12)),
|
|
},
|
|
}
|
|
for key, value in line_item_params.items():
|
|
self.assertEqual(item.get(key), value)
|
|
|
|
# Check that we correctly populated Customer, CustomerPlan and LicenseLedger in Zulip
|
|
customer = Customer.objects.get(stripe_customer_id=stripe_customer.id, realm=user.realm)
|
|
plan = CustomerPlan.objects.get(
|
|
customer=customer,
|
|
automanage_licenses=False,
|
|
charge_automatically=False,
|
|
price_per_license=8000,
|
|
fixed_price=None,
|
|
discount=None,
|
|
billing_cycle_anchor=self.now,
|
|
billing_schedule=CustomerPlan.BILLING_SCHEDULE_ANNUAL,
|
|
invoiced_through=LicenseLedger.objects.first(),
|
|
next_invoice_date=self.next_month,
|
|
tier=CustomerPlan.TIER_CLOUD_STANDARD,
|
|
status=CustomerPlan.ACTIVE,
|
|
)
|
|
LicenseLedger.objects.get(
|
|
plan=plan,
|
|
is_renewal=True,
|
|
event_time=self.now,
|
|
licenses=123,
|
|
licenses_at_next_renewal=123,
|
|
)
|
|
# Check RealmAuditLog
|
|
audit_log_entries = list(
|
|
RealmAuditLog.objects.filter(acting_user=user)
|
|
.values_list("event_type", "event_time")
|
|
.order_by("id")
|
|
)
|
|
self.assertEqual(
|
|
audit_log_entries[:3],
|
|
[
|
|
(
|
|
AuditLogEventType.STRIPE_CUSTOMER_CREATED,
|
|
timestamp_to_datetime(stripe_customer.created),
|
|
),
|
|
(AuditLogEventType.CUSTOMER_PLAN_CREATED, self.now),
|
|
(AuditLogEventType.REALM_PLAN_TYPE_CHANGED, self.now),
|
|
],
|
|
)
|
|
self.assertEqual(audit_log_entries[2][0], AuditLogEventType.REALM_PLAN_TYPE_CHANGED)
|
|
first_audit_log_entry = (
|
|
RealmAuditLog.objects.filter(event_type=AuditLogEventType.CUSTOMER_PLAN_CREATED)
|
|
.values_list("extra_data", flat=True)
|
|
.first()
|
|
)
|
|
assert first_audit_log_entry is not None
|
|
self.assertFalse(first_audit_log_entry["automanage_licenses"])
|
|
# Check that we correctly updated Realm
|
|
realm = get_realm("zulip")
|
|
self.assertEqual(realm.plan_type, Realm.PLAN_TYPE_STANDARD)
|
|
self.assertEqual(realm.max_invites, Realm.INVITES_STANDARD_REALM_DAILY_MAX)
|
|
# Check that we can no longer access /upgrade
|
|
response = self.client_get("/upgrade/")
|
|
self.assertEqual(response.status_code, 302)
|
|
self.assertEqual("http://zulip.testserver/billing", response["Location"])
|
|
|
|
# Check /billing/ has the correct information
|
|
with time_machine.travel(self.now, tick=False):
|
|
response = self.client_get("/billing/")
|
|
self.assert_not_in_success_response(["Pay annually", "Update card"], response)
|
|
for substring in [
|
|
"Zulip Cloud Standard",
|
|
str(123),
|
|
"Number of licenses for current billing period",
|
|
f"licenses ({self.seat_count} in use)",
|
|
"You will receive an invoice for",
|
|
"January 2, 2013",
|
|
"$9,840.00", # 9840 = 80 * 123
|
|
]:
|
|
self.assert_in_response(substring, response)
|
|
|
|
@mock_stripe(tested_timestamp_fields=["created"])
|
|
def test_free_trial_upgrade_by_card(self, *mocks: Mock) -> None:
|
|
user = self.example_user("hamlet")
|
|
self.login_user(user)
|
|
|
|
with self.settings(CLOUD_FREE_TRIAL_DAYS=60):
|
|
response = self.client_get("/upgrade/")
|
|
free_trial_end_date = self.now + timedelta(days=60)
|
|
|
|
self.assert_in_success_response(
|
|
["Your card will not be charged", "free trial", "60-day"], response
|
|
)
|
|
self.assertNotEqual(user.realm.plan_type, Realm.PLAN_TYPE_STANDARD)
|
|
self.assertFalse(Customer.objects.filter(realm=user.realm).exists())
|
|
|
|
# Require free trial users to add a credit card.
|
|
with (
|
|
time_machine.travel(self.now, tick=False),
|
|
self.assertLogs("corporate.stripe", "WARNING"),
|
|
):
|
|
response = self.upgrade()
|
|
self.assert_json_error(
|
|
response, "Please add a credit card before starting your free trial."
|
|
)
|
|
|
|
stripe_customer = self.add_card_and_upgrade(user)
|
|
|
|
self.assertEqual(Invoice.objects.count(), 0)
|
|
self.assertEqual(stripe_customer.description, "zulip (Zulip Dev)")
|
|
self.assertEqual(stripe_customer.discount, None)
|
|
self.assertEqual(stripe_customer.email, user.delivery_email)
|
|
assert stripe_customer.metadata is not None
|
|
metadata_dict = dict(stripe_customer.metadata)
|
|
self.assertEqual(metadata_dict["realm_str"], "zulip")
|
|
try:
|
|
int(metadata_dict["realm_id"])
|
|
except ValueError: # nocoverage
|
|
raise AssertionError("realm_id is not a number")
|
|
|
|
self.assertFalse(stripe.Charge.list(customer=stripe_customer.id))
|
|
|
|
self.assertFalse(stripe.Invoice.list(customer=stripe_customer.id))
|
|
|
|
customer = Customer.objects.get(stripe_customer_id=stripe_customer.id, realm=user.realm)
|
|
plan = CustomerPlan.objects.get(
|
|
customer=customer,
|
|
automanage_licenses=True,
|
|
price_per_license=8000,
|
|
fixed_price=None,
|
|
discount=None,
|
|
billing_cycle_anchor=self.now,
|
|
billing_schedule=CustomerPlan.BILLING_SCHEDULE_ANNUAL,
|
|
invoiced_through=LicenseLedger.objects.first(),
|
|
next_invoice_date=free_trial_end_date,
|
|
tier=CustomerPlan.TIER_CLOUD_STANDARD,
|
|
status=CustomerPlan.FREE_TRIAL,
|
|
# For payment through card.
|
|
charge_automatically=True,
|
|
)
|
|
LicenseLedger.objects.get(
|
|
plan=plan,
|
|
is_renewal=True,
|
|
event_time=self.now,
|
|
licenses=self.seat_count,
|
|
licenses_at_next_renewal=self.seat_count,
|
|
)
|
|
audit_log_entries = list(
|
|
RealmAuditLog.objects.filter(acting_user=user)
|
|
.values_list("event_type", "event_time")
|
|
.order_by("id")
|
|
)
|
|
self.assertEqual(
|
|
audit_log_entries[:4],
|
|
[
|
|
(
|
|
AuditLogEventType.STRIPE_CUSTOMER_CREATED,
|
|
timestamp_to_datetime(stripe_customer.created),
|
|
),
|
|
(
|
|
AuditLogEventType.STRIPE_CARD_CHANGED,
|
|
self.now,
|
|
),
|
|
(AuditLogEventType.CUSTOMER_PLAN_CREATED, self.now),
|
|
(AuditLogEventType.REALM_PLAN_TYPE_CHANGED, self.now),
|
|
],
|
|
)
|
|
self.assertEqual(audit_log_entries[3][0], AuditLogEventType.REALM_PLAN_TYPE_CHANGED)
|
|
first_audit_log_entry = (
|
|
RealmAuditLog.objects.filter(event_type=AuditLogEventType.CUSTOMER_PLAN_CREATED)
|
|
.values_list("extra_data", flat=True)
|
|
.first()
|
|
)
|
|
assert first_audit_log_entry is not None
|
|
self.assertTrue(first_audit_log_entry["automanage_licenses"])
|
|
|
|
realm = get_realm("zulip")
|
|
self.assertEqual(realm.plan_type, Realm.PLAN_TYPE_STANDARD)
|
|
self.assertEqual(realm.max_invites, Realm.INVITES_STANDARD_REALM_DAILY_MAX)
|
|
|
|
with time_machine.travel(self.now, tick=False):
|
|
response = self.client_get("/billing/")
|
|
self.assert_not_in_success_response(["Pay annually"], response)
|
|
for substring in [
|
|
"Zulip Cloud Standard <i>(free trial)</i>",
|
|
str(self.seat_count),
|
|
"Number of licenses",
|
|
f"{self.seat_count}",
|
|
"Your plan will automatically renew on",
|
|
"March 2, 2012",
|
|
f"${80 * self.seat_count}.00",
|
|
"Visa ending in 4242",
|
|
"Update card",
|
|
]:
|
|
self.assert_in_response(substring, response)
|
|
self.assert_not_in_success_response(["Go to your Zulip organization"], response)
|
|
|
|
billing_session = RealmBillingSession(user=user, realm=realm)
|
|
with patch("corporate.lib.stripe.get_latest_seat_count", return_value=12):
|
|
billing_session.update_license_ledger_if_needed(self.now)
|
|
self.assertEqual(
|
|
LicenseLedger.objects.order_by("-id")
|
|
.values_list("licenses", "licenses_at_next_renewal")
|
|
.first(),
|
|
(12, 12),
|
|
)
|
|
|
|
with patch("corporate.lib.stripe.get_latest_seat_count", return_value=15):
|
|
billing_session.update_license_ledger_if_needed(self.next_month)
|
|
self.assertEqual(
|
|
LicenseLedger.objects.order_by("-id")
|
|
.values_list("licenses", "licenses_at_next_renewal")
|
|
.first(),
|
|
(15, 15),
|
|
)
|
|
|
|
invoice_plans_as_needed(self.next_month)
|
|
self.assertFalse(stripe.Invoice.list(customer=stripe_customer.id))
|
|
customer_plan = CustomerPlan.objects.get(customer=customer)
|
|
self.assertEqual(customer_plan.status, CustomerPlan.FREE_TRIAL)
|
|
self.assertEqual(customer_plan.next_invoice_date, free_trial_end_date)
|
|
|
|
invoice_plans_as_needed(free_trial_end_date)
|
|
customer_plan.refresh_from_db()
|
|
realm.refresh_from_db()
|
|
self.assertEqual(customer_plan.status, CustomerPlan.ACTIVE)
|
|
self.assertEqual(customer_plan.next_invoice_date, add_months(free_trial_end_date, 1))
|
|
self.assertEqual(realm.plan_type, Realm.PLAN_TYPE_STANDARD)
|
|
[invoice] = iter(stripe.Invoice.list(customer=stripe_customer.id))
|
|
invoice_params = {
|
|
"amount_due": 15 * 80 * 100,
|
|
"amount_paid": 0,
|
|
"amount_remaining": 15 * 80 * 100,
|
|
"auto_advance": True,
|
|
"collection_method": "charge_automatically",
|
|
"customer_email": self.example_email("hamlet"),
|
|
"discount": None,
|
|
"paid": False,
|
|
"status": "open",
|
|
"total": 15 * 80 * 100,
|
|
}
|
|
for key, value in invoice_params.items():
|
|
self.assertEqual(invoice.get(key), value)
|
|
[invoice_item] = iter(invoice.lines)
|
|
invoice_item_params = {
|
|
"amount": 15 * 80 * 100,
|
|
"description": "Zulip Cloud Standard - renewal",
|
|
"plan": None,
|
|
"quantity": 15,
|
|
"subscription": None,
|
|
"discountable": False,
|
|
"period": {
|
|
"start": datetime_to_timestamp(free_trial_end_date),
|
|
"end": datetime_to_timestamp(add_months(free_trial_end_date, 12)),
|
|
},
|
|
}
|
|
for key, value in invoice_item_params.items():
|
|
self.assertEqual(invoice_item[key], value)
|
|
|
|
invoice_plans_as_needed(add_months(free_trial_end_date, 1))
|
|
[invoice] = iter(stripe.Invoice.list(customer=stripe_customer.id))
|
|
|
|
with patch("corporate.lib.stripe.get_latest_seat_count", return_value=19):
|
|
billing_session.update_license_ledger_if_needed(add_months(free_trial_end_date, 10))
|
|
self.assertEqual(
|
|
LicenseLedger.objects.order_by("-id")
|
|
.values_list("licenses", "licenses_at_next_renewal")
|
|
.first(),
|
|
(19, 19),
|
|
)
|
|
# Fast forward next_invoice_date to 10 months from the free_trial_end_date
|
|
plan.next_invoice_date = add_months(free_trial_end_date, 10)
|
|
plan.save(update_fields=["next_invoice_date"])
|
|
invoice_plans_as_needed(add_months(free_trial_end_date, 10))
|
|
[invoice0, invoice1] = iter(stripe.Invoice.list(customer=stripe_customer.id))
|
|
invoice_params = {
|
|
"amount_due": 5172,
|
|
"auto_advance": True,
|
|
"collection_method": "charge_automatically",
|
|
"customer_email": "hamlet@zulip.com",
|
|
}
|
|
[invoice_item] = iter(invoice0.lines)
|
|
invoice_item_params = {
|
|
"amount": 5172,
|
|
"description": "Additional license (Jan 2, 2013 - Mar 2, 2013)",
|
|
"discountable": False,
|
|
"quantity": 4,
|
|
"period": {
|
|
"start": datetime_to_timestamp(add_months(free_trial_end_date, 10)),
|
|
"end": datetime_to_timestamp(add_months(free_trial_end_date, 12)),
|
|
},
|
|
}
|
|
|
|
# Fast forward next_invoice_date to one year from the free_trial_end_date
|
|
plan.next_invoice_date = add_months(free_trial_end_date, 12)
|
|
plan.save(update_fields=["next_invoice_date"])
|
|
invoice_plans_as_needed(add_months(free_trial_end_date, 12))
|
|
[invoice0, invoice1, invoice2] = iter(stripe.Invoice.list(customer=stripe_customer.id))
|
|
|
|
# Check /billing/ has correct information for fixed price customers.
|
|
plan.fixed_price = 127
|
|
plan.price_per_license = None
|
|
plan.save(update_fields=["fixed_price", "price_per_license"])
|
|
with time_machine.travel(self.now, tick=False):
|
|
response = self.client_get("/billing/")
|
|
self.assert_in_success_response(["$1.27"], response)
|
|
# Don't show price breakdown
|
|
self.assert_not_in_success_response(["{self.seat_count} x"], response)
|
|
|
|
@mock_stripe(tested_timestamp_fields=["created"])
|
|
def test_free_trial_upgrade_by_invoice(self, *mocks: Mock) -> None:
|
|
user = self.example_user("hamlet")
|
|
self.login_user(user)
|
|
|
|
free_trial_end_date = self.now + timedelta(days=60)
|
|
with self.settings(CLOUD_FREE_TRIAL_DAYS=60):
|
|
response = self.client_get("/upgrade/")
|
|
|
|
self.assert_in_success_response(
|
|
["Your card will not be charged", "free trial", "60-day"], response
|
|
)
|
|
self.assertNotEqual(user.realm.plan_type, Realm.PLAN_TYPE_STANDARD)
|
|
self.assertFalse(Customer.objects.filter(realm=user.realm).exists())
|
|
|
|
with time_machine.travel(self.now, tick=False):
|
|
self.upgrade(invoice=True)
|
|
|
|
stripe_customer = stripe_get_customer(
|
|
assert_is_not_none(Customer.objects.get(realm=user.realm).stripe_customer_id)
|
|
)
|
|
self.assertEqual(stripe_customer.discount, None)
|
|
self.assertEqual(stripe_customer.email, user.delivery_email)
|
|
assert stripe_customer.metadata is not None
|
|
metadata_dict = dict(stripe_customer.metadata)
|
|
self.assertEqual(metadata_dict["realm_str"], "zulip")
|
|
try:
|
|
int(metadata_dict["realm_id"])
|
|
except ValueError: # nocoverage
|
|
raise AssertionError("realm_id is not a number")
|
|
|
|
[invoice] = iter(stripe.Invoice.list(customer=stripe_customer.id))
|
|
invoice_params = {
|
|
"amount_due": 123 * 80 * 100,
|
|
"amount_paid": 0,
|
|
"amount_remaining": 123 * 80 * 100,
|
|
"auto_advance": True,
|
|
"collection_method": "send_invoice",
|
|
"customer_email": self.example_email("hamlet"),
|
|
"discount": None,
|
|
"paid": False,
|
|
"status": "open",
|
|
"total": 123 * 80 * 100,
|
|
}
|
|
for key, value in invoice_params.items():
|
|
self.assertEqual(invoice.get(key), value)
|
|
|
|
customer = Customer.objects.get(stripe_customer_id=stripe_customer.id, realm=user.realm)
|
|
plan = CustomerPlan.objects.get(
|
|
customer=customer,
|
|
automanage_licenses=False,
|
|
price_per_license=8000,
|
|
fixed_price=None,
|
|
discount=None,
|
|
billing_cycle_anchor=self.now,
|
|
billing_schedule=CustomerPlan.BILLING_SCHEDULE_ANNUAL,
|
|
invoiced_through=LicenseLedger.objects.first(),
|
|
next_invoice_date=free_trial_end_date,
|
|
tier=CustomerPlan.TIER_CLOUD_STANDARD,
|
|
status=CustomerPlan.FREE_TRIAL,
|
|
# For invoice billing.
|
|
charge_automatically=False,
|
|
)
|
|
|
|
LicenseLedger.objects.get(
|
|
plan=plan,
|
|
is_renewal=True,
|
|
event_time=self.now,
|
|
licenses=123,
|
|
licenses_at_next_renewal=123,
|
|
)
|
|
audit_log_entries = list(
|
|
RealmAuditLog.objects.filter(acting_user=user)
|
|
.values_list("event_type", "event_time")
|
|
.order_by("id")
|
|
)
|
|
self.assertEqual(
|
|
audit_log_entries[:3],
|
|
[
|
|
(
|
|
AuditLogEventType.STRIPE_CUSTOMER_CREATED,
|
|
timestamp_to_datetime(stripe_customer.created),
|
|
),
|
|
(AuditLogEventType.CUSTOMER_PLAN_CREATED, self.now),
|
|
(AuditLogEventType.REALM_PLAN_TYPE_CHANGED, self.now),
|
|
],
|
|
)
|
|
self.assertEqual(audit_log_entries[2][0], AuditLogEventType.REALM_PLAN_TYPE_CHANGED)
|
|
first_audit_log_entry = (
|
|
RealmAuditLog.objects.filter(event_type=AuditLogEventType.CUSTOMER_PLAN_CREATED)
|
|
.values_list("extra_data", flat=True)
|
|
.first()
|
|
)
|
|
assert first_audit_log_entry is not None
|
|
self.assertFalse(first_audit_log_entry["automanage_licenses"])
|
|
|
|
realm = get_realm("zulip")
|
|
self.assertEqual(realm.plan_type, Realm.PLAN_TYPE_STANDARD)
|
|
self.assertEqual(realm.max_invites, Realm.INVITES_STANDARD_REALM_DAILY_MAX)
|
|
|
|
with time_machine.travel(self.now, tick=False):
|
|
response = self.client_get("/billing/")
|
|
self.assert_not_in_success_response(["Pay annually"], response)
|
|
for substring in [
|
|
"Zulip Cloud Standard <i>(free trial)</i>",
|
|
str(self.seat_count),
|
|
"Number of licenses for next billing period",
|
|
f"{self.seat_count} in use",
|
|
"To ensure continuous access",
|
|
"please pay",
|
|
"before the end of your trial",
|
|
"March 2, 2012",
|
|
"Invoice",
|
|
]:
|
|
self.assert_in_response(substring, response)
|
|
|
|
[invoice_item] = iter(invoice.lines)
|
|
invoice_item_params = {
|
|
"amount": 123 * 80 * 100,
|
|
"description": "Zulip Cloud Standard",
|
|
"plan": None,
|
|
"quantity": 123,
|
|
"subscription": None,
|
|
"discountable": False,
|
|
"period": {
|
|
"start": datetime_to_timestamp(free_trial_end_date),
|
|
"end": datetime_to_timestamp(add_months(free_trial_end_date, 12)),
|
|
},
|
|
}
|
|
for key, value in invoice_item_params.items():
|
|
self.assertEqual(invoice_item[key], value)
|
|
|
|
with patch("corporate.lib.stripe.BillingSession.invoice_plan") as mocked:
|
|
invoice_plans_as_needed(self.next_month)
|
|
mocked.assert_not_called()
|
|
mocked.reset_mock()
|
|
customer_plan = CustomerPlan.objects.get(customer=customer)
|
|
self.assertEqual(customer_plan.status, CustomerPlan.FREE_TRIAL)
|
|
self.assertEqual(customer_plan.next_invoice_date, free_trial_end_date)
|
|
|
|
[last_event] = iter(stripe.Event.list(limit=1))
|
|
# Customer pays the invoice
|
|
assert invoice.id is not None
|
|
stripe.Invoice.pay(invoice.id, paid_out_of_band=True)
|
|
self.send_stripe_webhook_events(last_event)
|
|
|
|
with time_machine.travel(self.now, tick=False):
|
|
response = self.client_get("/billing/")
|
|
|
|
self.assert_in_success_response(["You have no outstanding invoices."], response)
|
|
|
|
invoice_plans_as_needed(free_trial_end_date)
|
|
customer_plan.refresh_from_db()
|
|
realm.refresh_from_db()
|
|
self.assertEqual(customer_plan.status, CustomerPlan.ACTIVE)
|
|
self.assertEqual(customer_plan.next_invoice_date, add_months(free_trial_end_date, 1))
|
|
self.assertEqual(realm.plan_type, Realm.PLAN_TYPE_STANDARD)
|
|
|
|
@mock_stripe()
|
|
def test_free_trial_upgrade_by_invoice_customer_fails_to_pay(self, *mocks: Mock) -> None:
|
|
user = self.example_user("hamlet")
|
|
self.login_user(user)
|
|
|
|
free_trial_end_date = self.now + timedelta(days=60)
|
|
with self.settings(CLOUD_FREE_TRIAL_DAYS=60):
|
|
response = self.client_get("/upgrade/")
|
|
|
|
self.assert_in_success_response(
|
|
["Your card will not be charged", "free trial", "60-day"], response
|
|
)
|
|
self.assertNotEqual(user.realm.plan_type, Realm.PLAN_TYPE_STANDARD)
|
|
self.assertFalse(Customer.objects.filter(realm=user.realm).exists())
|
|
|
|
with time_machine.travel(self.now, tick=False):
|
|
self.upgrade(invoice=True)
|
|
|
|
stripe_customer = stripe_get_customer(
|
|
assert_is_not_none(Customer.objects.get(realm=user.realm).stripe_customer_id)
|
|
)
|
|
self.assertEqual(stripe_customer.discount, None)
|
|
self.assertEqual(stripe_customer.email, user.delivery_email)
|
|
assert stripe_customer.metadata is not None
|
|
metadata_dict = dict(stripe_customer.metadata)
|
|
self.assertEqual(metadata_dict["realm_str"], "zulip")
|
|
try:
|
|
int(metadata_dict["realm_id"])
|
|
except ValueError: # nocoverage
|
|
raise AssertionError("realm_id is not a number")
|
|
|
|
[invoice] = iter(stripe.Invoice.list(customer=stripe_customer.id))
|
|
invoice_params = {
|
|
"amount_due": 123 * 80 * 100,
|
|
"amount_paid": 0,
|
|
"amount_remaining": 123 * 80 * 100,
|
|
"auto_advance": True,
|
|
"collection_method": "send_invoice",
|
|
"customer_email": self.example_email("hamlet"),
|
|
"discount": None,
|
|
"paid": False,
|
|
"status": "open",
|
|
"total": 123 * 80 * 100,
|
|
}
|
|
for key, value in invoice_params.items():
|
|
self.assertEqual(invoice.get(key), value)
|
|
|
|
customer = Customer.objects.get(stripe_customer_id=stripe_customer.id, realm=user.realm)
|
|
plan = CustomerPlan.objects.get(
|
|
customer=customer,
|
|
automanage_licenses=False,
|
|
price_per_license=8000,
|
|
fixed_price=None,
|
|
discount=None,
|
|
billing_cycle_anchor=self.now,
|
|
billing_schedule=CustomerPlan.BILLING_SCHEDULE_ANNUAL,
|
|
invoiced_through=LicenseLedger.objects.first(),
|
|
next_invoice_date=free_trial_end_date,
|
|
tier=CustomerPlan.TIER_CLOUD_STANDARD,
|
|
status=CustomerPlan.FREE_TRIAL,
|
|
# For invoice billing.
|
|
charge_automatically=False,
|
|
)
|
|
|
|
LicenseLedger.objects.get(
|
|
plan=plan,
|
|
is_renewal=True,
|
|
event_time=self.now,
|
|
licenses=123,
|
|
licenses_at_next_renewal=123,
|
|
)
|
|
audit_log_entries = list(
|
|
RealmAuditLog.objects.filter(acting_user=user)
|
|
.values_list("event_type", "event_time")
|
|
.order_by("id")
|
|
)
|
|
self.assertEqual(
|
|
audit_log_entries[:3],
|
|
[
|
|
(
|
|
AuditLogEventType.STRIPE_CUSTOMER_CREATED,
|
|
timestamp_to_datetime(stripe_customer.created),
|
|
),
|
|
(AuditLogEventType.CUSTOMER_PLAN_CREATED, self.now),
|
|
(AuditLogEventType.REALM_PLAN_TYPE_CHANGED, self.now),
|
|
],
|
|
)
|
|
self.assertEqual(audit_log_entries[2][0], AuditLogEventType.REALM_PLAN_TYPE_CHANGED)
|
|
first_audit_log_entry = (
|
|
RealmAuditLog.objects.filter(event_type=AuditLogEventType.CUSTOMER_PLAN_CREATED)
|
|
.values_list("extra_data", flat=True)
|
|
.first()
|
|
)
|
|
assert first_audit_log_entry is not None
|
|
self.assertFalse(first_audit_log_entry["automanage_licenses"])
|
|
|
|
realm = get_realm("zulip")
|
|
self.assertEqual(realm.plan_type, Realm.PLAN_TYPE_STANDARD)
|
|
self.assertEqual(realm.max_invites, Realm.INVITES_STANDARD_REALM_DAILY_MAX)
|
|
|
|
with time_machine.travel(self.now, tick=False):
|
|
response = self.client_get("/billing/")
|
|
self.assert_not_in_success_response(["Pay annually"], response)
|
|
for substring in [
|
|
"Zulip Cloud Standard <i>(free trial)</i>",
|
|
str(self.seat_count),
|
|
"Number of licenses for next billing period",
|
|
f"{self.seat_count} in use",
|
|
"To ensure continuous access",
|
|
"please pay",
|
|
"before the end of your trial",
|
|
"March 2, 2012",
|
|
"Invoice",
|
|
]:
|
|
self.assert_in_response(substring, response)
|
|
|
|
[invoice_item] = iter(invoice.lines)
|
|
invoice_item_params = {
|
|
"amount": 123 * 80 * 100,
|
|
"description": "Zulip Cloud Standard",
|
|
"plan": None,
|
|
"quantity": 123,
|
|
"subscription": None,
|
|
"discountable": False,
|
|
"period": {
|
|
"start": datetime_to_timestamp(free_trial_end_date),
|
|
"end": datetime_to_timestamp(add_months(free_trial_end_date, 12)),
|
|
},
|
|
}
|
|
for key, value in invoice_item_params.items():
|
|
self.assertEqual(invoice_item[key], value)
|
|
|
|
# We reached free trial end but customer didn't pay the invoice.
|
|
invoice_plans_as_needed(free_trial_end_date)
|
|
customer_plan = CustomerPlan.objects.get(customer=customer)
|
|
self.assertEqual(customer_plan.status, CustomerPlan.ENDED)
|
|
realm.refresh_from_db()
|
|
self.assertEqual(realm.plan_type, Realm.PLAN_TYPE_LIMITED)
|
|
|
|
response = self.client_get("/upgrade/")
|
|
self.assert_in_success_response(
|
|
["Your free trial", "has expired", "To reactivate", "please pay"], response
|
|
)
|
|
|
|
# Customer decides to pay later
|
|
[last_event] = iter(stripe.Event.list(limit=1))
|
|
assert invoice.id is not None
|
|
stripe.Invoice.pay(invoice.id, paid_out_of_band=True)
|
|
self.send_stripe_webhook_events(last_event)
|
|
|
|
invoice_plans_as_needed(free_trial_end_date)
|
|
CustomerPlan.objects.get(customer=customer, status=CustomerPlan.ACTIVE)
|
|
realm.refresh_from_db()
|
|
self.assertEqual(realm.plan_type, Realm.PLAN_TYPE_STANDARD)
|
|
|
|
@mock_stripe(tested_timestamp_fields=["created"])
|
|
def test_upgrade_by_card_with_outdated_seat_count(self, *mocks: Mock) -> None:
|
|
hamlet = self.example_user("hamlet")
|
|
self.login_user(hamlet)
|
|
# Higher than original seat count
|
|
new_seat_count = 23
|
|
initial_upgrade_request = InitialUpgradeRequest(
|
|
manual_license_management=False,
|
|
tier=CustomerPlan.TIER_CLOUD_STANDARD,
|
|
billing_modality="charge_automatically",
|
|
)
|
|
billing_session = RealmBillingSession(hamlet)
|
|
_, context_when_upgrade_page_is_rendered = billing_session.get_initial_upgrade_context(
|
|
initial_upgrade_request
|
|
)
|
|
# Change the seat count in upgrade flow: after do_upgrade, during process_initial_upgrade
|
|
with (
|
|
patch(
|
|
"corporate.lib.stripe.BillingSession.stale_seat_count_check",
|
|
return_value=self.seat_count,
|
|
),
|
|
patch("corporate.lib.stripe.get_latest_seat_count", return_value=new_seat_count),
|
|
patch(
|
|
"corporate.lib.stripe.RealmBillingSession.get_initial_upgrade_context",
|
|
return_value=(_, context_when_upgrade_page_is_rendered),
|
|
),
|
|
):
|
|
self.add_card_and_upgrade(hamlet)
|
|
|
|
customer = Customer.objects.first()
|
|
assert customer is not None
|
|
stripe_customer_id: str = assert_is_not_none(customer.stripe_customer_id)
|
|
# Check that the Charge used the old quantity, not new_seat_count
|
|
[charge] = iter(stripe.Charge.list(customer=stripe_customer_id))
|
|
self.assertEqual(8000 * self.seat_count, charge.amount)
|
|
# Check that the invoice has a credit for the old amount and a charge for the new one
|
|
[additional_license_invoice, upgrade_invoice] = iter(
|
|
stripe.Invoice.list(customer=stripe_customer_id)
|
|
)
|
|
self.assertEqual(
|
|
[8000 * self.seat_count],
|
|
[item.amount for item in upgrade_invoice.lines],
|
|
)
|
|
self.assertEqual(
|
|
[8000 * (new_seat_count - self.seat_count)],
|
|
[item.amount for item in additional_license_invoice.lines],
|
|
)
|
|
# Check LicenseLedger has the new amount
|
|
ledger_entry = LicenseLedger.objects.last()
|
|
assert ledger_entry is not None
|
|
self.assertEqual(ledger_entry.licenses, new_seat_count)
|
|
self.assertEqual(ledger_entry.licenses_at_next_renewal, new_seat_count)
|
|
|
|
@mock_stripe()
|
|
def test_upgrade_by_card_with_outdated_lower_seat_count(self, *mocks: Mock) -> None:
|
|
hamlet = self.example_user("hamlet")
|
|
self.login_user(hamlet)
|
|
new_seat_count = self.seat_count - 1
|
|
initial_upgrade_request = InitialUpgradeRequest(
|
|
manual_license_management=False,
|
|
tier=CustomerPlan.TIER_CLOUD_STANDARD,
|
|
billing_modality="charge_automatically",
|
|
)
|
|
billing_session = RealmBillingSession(hamlet)
|
|
_, context_when_upgrade_page_is_rendered = billing_session.get_initial_upgrade_context(
|
|
initial_upgrade_request
|
|
)
|
|
# Change the seat count in upgrade flow: after do_upgrade, during process_initial_upgrade
|
|
with (
|
|
patch(
|
|
"corporate.lib.stripe.BillingSession.stale_seat_count_check",
|
|
return_value=self.seat_count,
|
|
),
|
|
patch("corporate.lib.stripe.get_latest_seat_count", return_value=new_seat_count),
|
|
patch(
|
|
"corporate.lib.stripe.RealmBillingSession.get_initial_upgrade_context",
|
|
return_value=(_, context_when_upgrade_page_is_rendered),
|
|
),
|
|
):
|
|
self.add_card_and_upgrade(hamlet)
|
|
|
|
customer = Customer.objects.first()
|
|
assert customer is not None
|
|
stripe_customer_id: str = assert_is_not_none(customer.stripe_customer_id)
|
|
# Check that the Charge used the old quantity, not new_seat_count
|
|
[charge] = iter(stripe.Charge.list(customer=stripe_customer_id))
|
|
self.assertEqual(8000 * self.seat_count, charge.amount)
|
|
[upgrade_invoice] = iter(stripe.Invoice.list(customer=stripe_customer_id))
|
|
self.assertEqual(
|
|
[8000 * self.seat_count],
|
|
[item.amount for item in upgrade_invoice.lines],
|
|
)
|
|
# Check LicenseLedger has the reduced license count at renewal
|
|
ledger_entry = LicenseLedger.objects.last()
|
|
assert ledger_entry is not None
|
|
self.assertEqual(ledger_entry.licenses, self.seat_count)
|
|
self.assertEqual(ledger_entry.licenses_at_next_renewal, new_seat_count)
|
|
|
|
# Check that we informed the support team about the potential billing error.
|
|
from django.core.mail import outbox
|
|
|
|
self.assert_length(outbox, 1)
|
|
|
|
for message in outbox:
|
|
self.assert_length(message.to, 1)
|
|
self.assertEqual(message.to[0], "sales@zulip.com")
|
|
self.assertEqual(
|
|
message.subject,
|
|
f"Check initial licenses invoiced for {billing_session.billing_entity_display_name}",
|
|
)
|
|
self.assertEqual(self.email_envelope_from(message), settings.NOREPLY_EMAIL_ADDRESS)
|
|
|
|
@mock_stripe()
|
|
def test_upgrade_by_card_with_outdated_seat_count_and_minimum_for_plan_tier(
|
|
self, *mocks: Mock
|
|
) -> None:
|
|
hamlet = self.example_user("hamlet")
|
|
self.login_user(hamlet)
|
|
# New seat count is under the minimum for the plan tier
|
|
minimum_for_plan_tier = self.seat_count - 1
|
|
new_seat_count = self.seat_count - 2
|
|
initial_upgrade_request = InitialUpgradeRequest(
|
|
manual_license_management=False,
|
|
tier=CustomerPlan.TIER_CLOUD_STANDARD,
|
|
billing_modality="charge_automatically",
|
|
)
|
|
billing_session = RealmBillingSession(hamlet)
|
|
_, context_when_upgrade_page_is_rendered = billing_session.get_initial_upgrade_context(
|
|
initial_upgrade_request
|
|
)
|
|
assert context_when_upgrade_page_is_rendered is not None
|
|
assert context_when_upgrade_page_is_rendered.get("seat_count") == self.seat_count
|
|
# Change the current and minimum license counts in do_upgrade
|
|
with (
|
|
patch(
|
|
"corporate.lib.stripe.BillingSession.min_licenses_for_plan",
|
|
return_value=minimum_for_plan_tier,
|
|
),
|
|
patch("corporate.lib.stripe.get_latest_seat_count", return_value=new_seat_count),
|
|
patch(
|
|
"corporate.lib.stripe.RealmBillingSession.get_initial_upgrade_context",
|
|
return_value=(_, context_when_upgrade_page_is_rendered),
|
|
),
|
|
):
|
|
self.add_card_and_upgrade(hamlet)
|
|
|
|
customer = Customer.objects.first()
|
|
assert customer is not None
|
|
stripe_customer_id: str = assert_is_not_none(customer.stripe_customer_id)
|
|
# Check that the Charge used the minimum seat count
|
|
[charge] = iter(stripe.Charge.list(customer=stripe_customer_id))
|
|
self.assertEqual(8000 * minimum_for_plan_tier, charge.amount)
|
|
[upgrade_invoice] = iter(stripe.Invoice.list(customer=stripe_customer_id))
|
|
self.assertEqual(
|
|
[8000 * minimum_for_plan_tier],
|
|
[item.amount for item in upgrade_invoice.lines],
|
|
)
|
|
# Check LicenseLedger has the minimum license count
|
|
ledger_entry = LicenseLedger.objects.last()
|
|
assert ledger_entry is not None
|
|
self.assertEqual(ledger_entry.licenses, minimum_for_plan_tier)
|
|
self.assertEqual(ledger_entry.licenses_at_next_renewal, minimum_for_plan_tier)
|
|
|
|
@mock_stripe()
|
|
def test_customer_minimum_licenses_for_plan(self, *mocks: Mock) -> None:
|
|
hamlet = self.example_user("hamlet")
|
|
self.login_user(hamlet)
|
|
# We set set a 1 license minimum the initial upgrade.
|
|
minimum_for_plan_tier = 1
|
|
with (
|
|
patch(
|
|
"corporate.lib.stripe.BillingSession.min_licenses_for_plan",
|
|
return_value=minimum_for_plan_tier,
|
|
),
|
|
):
|
|
self.add_card_and_upgrade(hamlet, tier=CustomerPlan.TIER_CLOUD_PLUS)
|
|
|
|
customer = Customer.objects.first()
|
|
assert customer is not None
|
|
assert customer.stripe_customer_id is not None
|
|
# Check LicenseLedger has the current seat count.
|
|
ledger_entry = LicenseLedger.objects.last()
|
|
assert ledger_entry is not None
|
|
self.assertEqual(ledger_entry.licenses, self.seat_count)
|
|
self.assertEqual(ledger_entry.licenses_at_next_renewal, self.seat_count)
|
|
|
|
# We manually set customer.minimum_licenses to the current seat count,
|
|
# which is below the general Plus plan minimum licenses.
|
|
customer.minimum_licenses = self.seat_count
|
|
customer.save()
|
|
|
|
# Next year, they are still invoiced for the current seat count.
|
|
invoice_plans_as_needed(self.next_year)
|
|
# Check both invoices (initial and renewal)
|
|
[invoice0, invoice1] = iter(stripe.Invoice.list(customer=customer.stripe_customer_id))
|
|
self.assertEqual(
|
|
[12000 * self.seat_count],
|
|
[item.amount for item in invoice0.lines],
|
|
)
|
|
self.assertEqual(
|
|
[12000 * self.seat_count],
|
|
[item.amount for item in invoice1.lines],
|
|
)
|
|
|
|
# Without the minimum_licenses set on the customer, a BillingError is raised when
|
|
# invoicing plans.
|
|
customer.minimum_licenses = None
|
|
customer.save()
|
|
|
|
with self.assertLogs("corporate.stripe", level="ERROR") as m:
|
|
invoice_plans_as_needed(self.next_year + timedelta(days=366))
|
|
self.assertIn(
|
|
f"ERROR:corporate.stripe:Invoicing failed: Customer.id: {customer.id}",
|
|
m.output[0],
|
|
)
|
|
self.assertIn(
|
|
"Renewal licenses (6) less than minimum licenses (10) required for plan Zulip Cloud Plus.",
|
|
m.output[0],
|
|
)
|
|
|
|
def test_upgrade_with_tampered_seat_count(self) -> None:
|
|
hamlet = self.example_user("hamlet")
|
|
self.login_user(hamlet)
|
|
with self.assertLogs("corporate.stripe", "WARNING"):
|
|
response = self.upgrade(talk_to_stripe=False, salt="badsalt")
|
|
self.assert_json_error_contains(response, "Something went wrong. Please contact")
|
|
self.assertEqual(orjson.loads(response.content)["error_description"], "tampered seat count")
|
|
|
|
@mock_stripe()
|
|
def test_upgrade_race_condition_during_card_upgrade(self, *mocks: Mock) -> None:
|
|
hamlet = self.example_user("hamlet")
|
|
iago = self.example_user("iago")
|
|
self.login_user(iago)
|
|
iago_upgrade_page_response = self.client_get("/upgrade/")
|
|
|
|
self.login_user(hamlet)
|
|
self.add_card_to_customer_for_upgrade()
|
|
[stripe_event_before_upgrade] = iter(stripe.Event.list(limit=1))
|
|
hamlet_upgrade_page_response = self.client_get("/upgrade/")
|
|
self.client_billing_post(
|
|
"/billing/upgrade",
|
|
{
|
|
"billing_modality": "charge_automatically",
|
|
"schedule": "annual",
|
|
"signed_seat_count": self.get_signed_seat_count_from_response(
|
|
hamlet_upgrade_page_response
|
|
),
|
|
"salt": self.get_salt_from_response(hamlet_upgrade_page_response),
|
|
"license_management": "automatic",
|
|
},
|
|
)
|
|
|
|
# Get the last generated invoice for Hamlet
|
|
customer = get_customer_by_realm(get_realm("zulip"))
|
|
assert customer is not None
|
|
assert customer.stripe_customer_id is not None
|
|
[hamlet_invoice] = iter(stripe.Invoice.list(customer=customer.stripe_customer_id))
|
|
|
|
self.login_user(iago)
|
|
with self.settings(CLOUD_FREE_TRIAL_DAYS=60):
|
|
# Iago completed the upgrade while we were waiting on success payment event for Hamlet.
|
|
# NOTE: Used free trial to avoid creating any stripe invoice events.
|
|
self.client_billing_post(
|
|
"/billing/upgrade",
|
|
{
|
|
"billing_modality": "charge_automatically",
|
|
"schedule": "annual",
|
|
"signed_seat_count": self.get_signed_seat_count_from_response(
|
|
iago_upgrade_page_response
|
|
),
|
|
"salt": self.get_salt_from_response(iago_upgrade_page_response),
|
|
"license_management": "automatic",
|
|
},
|
|
)
|
|
|
|
with self.assertLogs("corporate.stripe", "WARNING"):
|
|
self.send_stripe_webhook_events(stripe_event_before_upgrade)
|
|
|
|
assert hamlet_invoice.id is not None
|
|
self.assert_details_of_valid_invoice_payment_from_event_status_endpoint(
|
|
hamlet_invoice.id,
|
|
{
|
|
"status": "paid",
|
|
"event_handler": {
|
|
"status": "failed",
|
|
"error": {
|
|
"message": "The organization is already subscribed to a plan. Please reload the billing page.",
|
|
"description": "subscribing with existing subscription",
|
|
},
|
|
},
|
|
},
|
|
)
|
|
|
|
# Check that we informed the support team about the failure.
|
|
from django.core.mail import outbox
|
|
|
|
self.assert_length(outbox, 1)
|
|
|
|
for message in outbox:
|
|
self.assert_length(message.to, 1)
|
|
self.assertEqual(message.to[0], "sales@zulip.com")
|
|
self.assertEqual(message.subject, "Error processing paid customer invoice")
|
|
self.assertEqual(self.email_envelope_from(message), settings.NOREPLY_EMAIL_ADDRESS)
|
|
|
|
def test_upgrade_race_condition_during_invoice_upgrade(self) -> None:
|
|
hamlet = self.example_user("hamlet")
|
|
self.login_user(hamlet)
|
|
self.local_upgrade(self.seat_count, True, CustomerPlan.BILLING_SCHEDULE_ANNUAL, True, False)
|
|
with (
|
|
self.assertLogs("corporate.stripe", "WARNING") as m,
|
|
self.assertRaises(BillingError) as context,
|
|
):
|
|
self.local_upgrade(
|
|
self.seat_count, True, CustomerPlan.BILLING_SCHEDULE_ANNUAL, True, False
|
|
)
|
|
self.assertEqual(
|
|
"subscribing with existing subscription", context.exception.error_description
|
|
)
|
|
self.assertEqual(
|
|
m.output[0],
|
|
"WARNING:corporate.stripe:Upgrade of <Realm: zulip 2> (with stripe_customer_id: cus_123) failed because of existing active plan.",
|
|
)
|
|
self.assert_length(m.output, 1)
|
|
|
|
@mock_stripe(tested_timestamp_fields=["created"])
|
|
def test_check_upgrade_parameters(self, *mocks: Mock) -> None:
|
|
# Tests all the error paths except 'not enough licenses'
|
|
def check_error(
|
|
error_message: str,
|
|
error_description: str,
|
|
upgrade_params: Mapping[str, Any],
|
|
del_args: Sequence[str] = [],
|
|
) -> None:
|
|
self.add_card_to_customer_for_upgrade()
|
|
if error_description:
|
|
with self.assertLogs("corporate.stripe", "WARNING"):
|
|
response = self.upgrade(
|
|
talk_to_stripe=False, del_args=del_args, **upgrade_params
|
|
)
|
|
self.assertEqual(
|
|
orjson.loads(response.content)["error_description"], error_description
|
|
)
|
|
else:
|
|
response = self.upgrade(talk_to_stripe=False, del_args=del_args, **upgrade_params)
|
|
self.assert_json_error_contains(response, error_message)
|
|
|
|
hamlet = self.example_user("hamlet")
|
|
self.login_user(hamlet)
|
|
check_error("Invalid billing_modality", "", {"billing_modality": "invalid"})
|
|
check_error("Invalid schedule", "", {"schedule": "invalid"})
|
|
check_error("Invalid license_management", "", {"license_management": "invalid"})
|
|
|
|
check_error(
|
|
"You must purchase licenses for all active users in your organization (minimum 30).",
|
|
"not enough licenses",
|
|
{"billing_modality": "send_invoice", "licenses": -1},
|
|
)
|
|
check_error(
|
|
"You must purchase licenses for all active users in your organization (minimum 30).",
|
|
"not enough licenses",
|
|
{"billing_modality": "send_invoice"},
|
|
)
|
|
check_error(
|
|
"You must purchase licenses for all active users in your organization (minimum 30).",
|
|
"not enough licenses",
|
|
{"billing_modality": "send_invoice", "licenses": 25},
|
|
)
|
|
check_error(
|
|
"Invoices with more than 1000 licenses can't be processed from this page",
|
|
"too many licenses",
|
|
{"billing_modality": "send_invoice", "licenses": 10000},
|
|
)
|
|
|
|
check_error(
|
|
"You must purchase licenses for all active users in your organization (minimum 6).",
|
|
"not enough licenses",
|
|
{"billing_modality": "charge_automatically", "license_management": "manual"},
|
|
)
|
|
|
|
check_error(
|
|
"You must purchase licenses for all active users in your organization (minimum 6).",
|
|
"not enough licenses",
|
|
{
|
|
"billing_modality": "charge_automatically",
|
|
"license_management": "manual",
|
|
"licenses": 3,
|
|
},
|
|
)
|
|
|
|
@mock_stripe(tested_timestamp_fields=["created"])
|
|
def test_upgrade_license_counts(self, *mocks: Mock) -> None:
|
|
hamlet = self.example_user("hamlet")
|
|
self.login_user(hamlet)
|
|
self.add_card_to_customer_for_upgrade()
|
|
|
|
def check_min_licenses_error(
|
|
invoice: bool,
|
|
licenses: int | None,
|
|
min_licenses_in_response: int,
|
|
upgrade_params: Mapping[str, Any] = {},
|
|
) -> None:
|
|
upgrade_params = dict(upgrade_params)
|
|
if licenses is None:
|
|
del_args = ["licenses"]
|
|
else:
|
|
del_args = []
|
|
upgrade_params["licenses"] = licenses
|
|
with self.assertLogs("corporate.stripe", "WARNING"):
|
|
response = self.upgrade(
|
|
invoice=invoice, talk_to_stripe=False, del_args=del_args, **upgrade_params
|
|
)
|
|
self.assert_json_error_contains(response, f"minimum {min_licenses_in_response}")
|
|
self.assertEqual(
|
|
orjson.loads(response.content)["error_description"], "not enough licenses"
|
|
)
|
|
|
|
def check_max_licenses_error(licenses: int) -> None:
|
|
with self.assertLogs("corporate.stripe", "WARNING"):
|
|
response = self.upgrade(invoice=True, talk_to_stripe=False, licenses=licenses)
|
|
self.assert_json_error_contains(
|
|
response, f"with more than {MAX_INVOICED_LICENSES} licenses"
|
|
)
|
|
self.assertEqual(
|
|
orjson.loads(response.content)["error_description"], "too many licenses"
|
|
)
|
|
|
|
def check_success(
|
|
invoice: bool, licenses: int | None, upgrade_params: Mapping[str, Any] = {}
|
|
) -> None:
|
|
upgrade_params = dict(upgrade_params)
|
|
if licenses is None:
|
|
del_args = ["licenses"]
|
|
else:
|
|
del_args = []
|
|
upgrade_params["licenses"] = licenses
|
|
with (
|
|
patch("corporate.lib.stripe.BillingSession.process_initial_upgrade"),
|
|
patch(
|
|
"corporate.lib.stripe.BillingSession.create_stripe_invoice_and_charge",
|
|
return_value="fake_stripe_invoice_id",
|
|
),
|
|
):
|
|
response = self.upgrade(
|
|
invoice=invoice, talk_to_stripe=False, del_args=del_args, **upgrade_params
|
|
)
|
|
self.assert_json_success(response)
|
|
|
|
# Autopay with licenses < seat count
|
|
check_min_licenses_error(
|
|
False, self.seat_count - 1, self.seat_count, {"license_management": "manual"}
|
|
)
|
|
# Autopay with not setting licenses
|
|
check_min_licenses_error(False, None, self.seat_count, {"license_management": "manual"})
|
|
# Invoice with licenses < MIN_INVOICED_LICENSES
|
|
check_min_licenses_error(True, MIN_INVOICED_LICENSES - 1, MIN_INVOICED_LICENSES)
|
|
# Invoice with licenses < seat count
|
|
with patch("corporate.lib.stripe.MIN_INVOICED_LICENSES", 3):
|
|
check_min_licenses_error(True, 4, self.seat_count)
|
|
# Invoice with not setting licenses
|
|
check_min_licenses_error(True, None, MIN_INVOICED_LICENSES)
|
|
# Invoice exceeding max licenses
|
|
check_max_licenses_error(MAX_INVOICED_LICENSES + 1)
|
|
with patch(
|
|
"corporate.lib.stripe.get_latest_seat_count", return_value=MAX_INVOICED_LICENSES + 5
|
|
):
|
|
check_max_licenses_error(MAX_INVOICED_LICENSES + 5)
|
|
|
|
# Autopay with automatic license_management
|
|
check_success(False, None)
|
|
# Autopay with automatic license_management, should just ignore the licenses entry
|
|
check_success(False, self.seat_count)
|
|
# Autopay
|
|
check_success(False, self.seat_count, {"license_management": "manual"})
|
|
# Autopay has no limit on max licenses
|
|
check_success(False, MAX_INVOICED_LICENSES + 1, {"license_management": "manual"})
|
|
# Invoice
|
|
check_success(True, self.seat_count + MIN_INVOICED_LICENSES)
|
|
# Invoice
|
|
check_success(True, MAX_INVOICED_LICENSES)
|
|
|
|
# By default, an organization on a "Pay by card" plan with Manual license
|
|
# management cannot purchase less licenses than the current seat count.
|
|
# If exempt_from_license_number_check is enabled, they should be able to though.
|
|
customer = Customer.objects.get_or_create(realm=hamlet.realm)[0]
|
|
customer.exempt_from_license_number_check = True
|
|
customer.save()
|
|
check_success(False, self.seat_count - 1, {"license_management": "manual"})
|
|
|
|
@mock_stripe(tested_timestamp_fields=["created"])
|
|
def test_upgrade_with_uncaught_exception(self, *mock_args: Any) -> None:
|
|
hamlet = self.example_user("hamlet")
|
|
self.login_user(hamlet)
|
|
self.add_card_to_customer_for_upgrade()
|
|
with (
|
|
patch(
|
|
"corporate.lib.stripe.BillingSession.create_stripe_invoice_and_charge",
|
|
side_effect=Exception,
|
|
),
|
|
self.assertLogs("corporate.stripe", "WARNING") as m,
|
|
):
|
|
response = self.upgrade(talk_to_stripe=False)
|
|
self.assertIn("ERROR:corporate.stripe:Uncaught exception in billing", m.output[0])
|
|
self.assertIn(m.records[0].stack_info, m.output[0])
|
|
self.assert_json_error_contains(
|
|
response, "Something went wrong. Please contact desdemona+admin@zulip.com."
|
|
)
|
|
self.assertEqual(
|
|
orjson.loads(response.content)["error_description"], "uncaught exception during upgrade"
|
|
)
|
|
|
|
@mock_stripe(tested_timestamp_fields=["created"])
|
|
def test_invoice_payment_succeeded_event_with_uncaught_exception(self, *mock_args: Any) -> None:
|
|
hamlet = self.example_user("hamlet")
|
|
self.login_user(hamlet)
|
|
self.add_card_to_customer_for_upgrade()
|
|
|
|
with (
|
|
patch(
|
|
"corporate.lib.stripe.BillingSession.process_initial_upgrade", side_effect=Exception
|
|
),
|
|
self.assertLogs("corporate.stripe", "WARNING"),
|
|
):
|
|
response = self.upgrade()
|
|
|
|
response_dict = self.assert_json_success(response)
|
|
|
|
self.assert_details_of_valid_invoice_payment_from_event_status_endpoint(
|
|
response_dict["stripe_invoice_id"],
|
|
{
|
|
"status": "paid",
|
|
"event_handler": {
|
|
"status": "failed",
|
|
"error": {
|
|
"message": "Something went wrong. Please contact desdemona+admin@zulip.com.",
|
|
"description": "uncaught exception in invoice.paid event handler",
|
|
},
|
|
},
|
|
},
|
|
)
|
|
|
|
def test_request_sponsorship_form_with_invalid_url(self) -> None:
|
|
user = self.example_user("hamlet")
|
|
self.login_user(user)
|
|
data = {
|
|
"organization_type": Realm.ORG_TYPES["opensource"]["id"],
|
|
"website": "invalid-url",
|
|
"description": "Infinispan is a distributed in-memory key/value data store with optional schema.",
|
|
"expected_total_users": "10 users",
|
|
"plan_to_use_zulip": "For communication on moon.",
|
|
"paid_users_count": "1 user",
|
|
"paid_users_description": "We have 1 paid user.",
|
|
}
|
|
|
|
response = self.client_billing_post("/billing/sponsorship", data)
|
|
|
|
self.assert_json_error(response, "Enter a valid URL.")
|
|
|
|
def test_request_sponsorship_form_with_blank_url(self) -> None:
|
|
user = self.example_user("hamlet")
|
|
self.login_user(user)
|
|
data = {
|
|
"organization_type": Realm.ORG_TYPES["opensource"]["id"],
|
|
"website": "",
|
|
"description": "Infinispan is a distributed in-memory key/value data store with optional schema.",
|
|
"expected_total_users": "10 users",
|
|
"plan_to_use_zulip": "For communication on moon.",
|
|
"paid_users_count": "1 user",
|
|
"paid_users_description": "We have 1 paid user.",
|
|
}
|
|
|
|
response = self.client_billing_post("/billing/sponsorship", data)
|
|
|
|
self.assert_json_success(response)
|
|
|
|
@mock_stripe()
|
|
def test_sponsorship_access_for_realms_on_paid_plan(self, *mocks: Mock) -> None:
|
|
user = self.example_user("hamlet")
|
|
self.login_user(user)
|
|
self.add_card_and_upgrade(user)
|
|
response = self.client_get("/sponsorship/")
|
|
self.assert_in_success_response(
|
|
[
|
|
"How many paid staff does your organization have?",
|
|
],
|
|
response,
|
|
)
|
|
|
|
def test_demo_request(self) -> None:
|
|
result = self.client_get("/request-demo/")
|
|
self.assertEqual(result.status_code, 200)
|
|
self.assert_in_success_response(["Request a demo"], result)
|
|
|
|
data = {
|
|
"full_name": "King Hamlet",
|
|
"email": "test@zulip.com",
|
|
"role": "Manager",
|
|
"organization_name": "Zulip",
|
|
"organization_type": "Business",
|
|
"organization_website": "https://example.com",
|
|
"expected_user_count": "10 (2 unpaid members)",
|
|
"message": "Need help!",
|
|
}
|
|
result = self.client_post("/request-demo/", data)
|
|
self.assert_in_success_response(["Thanks for contacting us!"], result)
|
|
|
|
from django.core.mail import outbox
|
|
|
|
self.assert_length(outbox, 1)
|
|
|
|
for message in outbox:
|
|
self.assert_length(message.to, 1)
|
|
self.assertEqual(message.to[0], "sales@zulip.com")
|
|
self.assertEqual(message.subject, "Demo request for Zulip")
|
|
self.assertEqual(message.reply_to, ["test@zulip.com"])
|
|
self.assertEqual(self.email_envelope_from(message), settings.NOREPLY_EMAIL_ADDRESS)
|
|
self.assertIn("Zulip demo request <noreply-", self.email_display_from(message))
|
|
self.assertIn("Full name: King Hamlet", message.body)
|
|
|
|
def test_support_request(self) -> None:
|
|
user = self.example_user("hamlet")
|
|
self.assertIsNone(get_customer_by_realm(user.realm))
|
|
|
|
self.login_user(user)
|
|
|
|
result = self.client_get("/support/")
|
|
self.assertEqual(result.status_code, 200)
|
|
self.assert_in_success_response(["Contact support"], result)
|
|
|
|
data = {
|
|
"request_subject": "Not getting messages.",
|
|
"request_message": "Running into this weird issue.",
|
|
}
|
|
result = self.client_post("/support/", data)
|
|
self.assert_in_success_response(["Thanks for contacting us!"], result)
|
|
|
|
from django.core.mail import outbox
|
|
|
|
self.assert_length(outbox, 1)
|
|
|
|
for message in outbox:
|
|
self.assert_length(message.to, 1)
|
|
self.assertEqual(message.to[0], "desdemona+admin@zulip.com")
|
|
self.assertEqual(message.subject, "Support request for zulip")
|
|
self.assertEqual(message.reply_to, ["hamlet@zulip.com"])
|
|
self.assertEqual(self.email_envelope_from(message), settings.NOREPLY_EMAIL_ADDRESS)
|
|
self.assertIn("Zulip support request <noreply-", self.email_display_from(message))
|
|
self.assertIn("Requested by: King Hamlet (Organization owner)", message.body)
|
|
self.assertIn(
|
|
"Support URL: http://zulip.testserver/activity/support?q=zulip", message.body
|
|
)
|
|
self.assertIn("Subject: Not getting messages.", message.body)
|
|
self.assertIn("Message:\nRunning into this weird issue", message.body)
|
|
|
|
def test_request_sponsorship(self) -> None:
|
|
user = self.example_user("hamlet")
|
|
self.assertIsNone(get_customer_by_realm(user.realm))
|
|
|
|
self.login_user(user)
|
|
|
|
data = {
|
|
"organization_type": Realm.ORG_TYPES["opensource"]["id"],
|
|
"website": "https://infinispan.org/",
|
|
"description": "Infinispan is a distributed in-memory key/value data store with optional schema.",
|
|
"expected_total_users": "10 users",
|
|
"plan_to_use_zulip": "For communication on moon.",
|
|
"paid_users_count": "1 user",
|
|
"paid_users_description": "We have 1 paid user.",
|
|
}
|
|
response = self.client_billing_post("/billing/sponsorship", data)
|
|
self.assert_json_success(response)
|
|
|
|
customer = get_customer_by_realm(user.realm)
|
|
assert customer is not None
|
|
sponsorship_request = ZulipSponsorshipRequest.objects.filter(
|
|
customer=customer, requested_by=user
|
|
).first()
|
|
assert sponsorship_request is not None
|
|
self.assertEqual(sponsorship_request.org_website, data["website"])
|
|
self.assertEqual(sponsorship_request.org_description, data["description"])
|
|
self.assertEqual(
|
|
sponsorship_request.org_type,
|
|
Realm.ORG_TYPES["opensource"]["id"],
|
|
)
|
|
|
|
customer = get_customer_by_realm(user.realm)
|
|
assert customer is not None
|
|
self.assertEqual(customer.sponsorship_pending, True)
|
|
from django.core.mail import outbox
|
|
|
|
self.assert_length(outbox, 1)
|
|
|
|
for message in outbox:
|
|
self.assert_length(message.to, 1)
|
|
self.assertEqual(message.to[0], "sales@zulip.com")
|
|
self.assertEqual(message.subject, "Sponsorship request for zulip")
|
|
self.assertEqual(message.reply_to, ["hamlet@zulip.com"])
|
|
self.assertEqual(self.email_envelope_from(message), settings.NOREPLY_EMAIL_ADDRESS)
|
|
self.assertIn("Zulip sponsorship request <noreply-", self.email_display_from(message))
|
|
self.assertIn("Requested by: King Hamlet (Organization owner)", message.body)
|
|
self.assertIn(
|
|
"Support URL: http://zulip.testserver/activity/support?q=zulip", message.body
|
|
)
|
|
self.assertIn("Website: https://infinispan.org", message.body)
|
|
self.assertIn("Organization type: Open-source", message.body)
|
|
self.assertIn("Description:\nInfinispan is a distributed in-memory", message.body)
|
|
|
|
response = self.client_get("/upgrade/")
|
|
self.assertEqual(response.status_code, 302)
|
|
self.assertEqual(response["Location"], "http://zulip.testserver/sponsorship")
|
|
|
|
response = self.client_get("/billing/")
|
|
self.assertEqual(response.status_code, 302)
|
|
self.assertEqual(response["Location"], "/sponsorship/")
|
|
|
|
response = self.client_get("/sponsorship/")
|
|
self.assert_in_success_response(
|
|
[
|
|
"This organization has requested sponsorship for a",
|
|
'<a href="/plans/">Zulip Cloud Standard</a>',
|
|
'plan.<br/><a href="mailto:support@zulip.com">Contact Zulip support</a> with any questions or updates.',
|
|
],
|
|
response,
|
|
)
|
|
|
|
self.login_user(self.example_user("othello"))
|
|
response = self.client_get("/billing/")
|
|
self.assert_in_success_response(
|
|
["You do not have permission to view this page."],
|
|
response,
|
|
)
|
|
|
|
response = self.client_get("/invoices/")
|
|
self.assertEqual(response.status_code, 302)
|
|
self.assertEqual(response["Location"], "/billing/")
|
|
|
|
response = self.client_get("/customer_portal/")
|
|
self.assertEqual(response.status_code, 302)
|
|
self.assertEqual(response["Location"], "/billing/")
|
|
|
|
user.realm.plan_type = Realm.PLAN_TYPE_PLUS
|
|
user.realm.save()
|
|
response = self.client_get("/sponsorship/")
|
|
self.assertEqual(response.status_code, 302)
|
|
self.assertEqual(response["Location"], "/billing/")
|
|
|
|
user.realm.plan_type = Realm.PLAN_TYPE_STANDARD_FREE
|
|
user.realm.save()
|
|
self.login_user(self.example_user("hamlet"))
|
|
response = self.client_get("/sponsorship/")
|
|
self.assert_in_success_response(
|
|
[
|
|
'Zulip is sponsoring a free <a href="/plans/">Zulip Cloud Standard</a> plan for this organization. 🎉'
|
|
],
|
|
response,
|
|
)
|
|
|
|
def test_redirect_for_billing_page(self) -> None:
|
|
user = self.example_user("othello")
|
|
self.login_user(user)
|
|
response = self.client_get("/billing/")
|
|
not_admin_message = "You do not have permission to view this page."
|
|
self.assert_in_success_response([not_admin_message], response)
|
|
|
|
user.realm.plan_type = Realm.PLAN_TYPE_STANDARD_FREE
|
|
user.realm.save()
|
|
response = self.client_get("/billing/")
|
|
self.assert_in_success_response([not_admin_message], response)
|
|
|
|
# Billing page redirects to sponsorship page for standard free admins.
|
|
user = self.example_user("hamlet")
|
|
self.login_user(user)
|
|
response = self.client_get("/billing/")
|
|
self.assertEqual(response.status_code, 302)
|
|
self.assertEqual("/sponsorship/", response["Location"])
|
|
|
|
user.realm.plan_type = Realm.PLAN_TYPE_LIMITED
|
|
user.realm.save()
|
|
customer = Customer.objects.create(realm=user.realm, stripe_customer_id="cus_123")
|
|
response = self.client_get("/billing/")
|
|
self.assertEqual(response.status_code, 302)
|
|
self.assertEqual("/plans/", response["Location"])
|
|
|
|
# Check redirects for sponsorship pending
|
|
customer.sponsorship_pending = True
|
|
customer.save()
|
|
response = self.client_get("/billing/")
|
|
self.assertEqual(response.status_code, 302)
|
|
self.assertEqual("/sponsorship/", response["Location"])
|
|
|
|
# Don't redirect to sponsorship for paid plans.
|
|
user.realm.plan_type = Realm.PLAN_TYPE_STANDARD
|
|
user.realm.save()
|
|
response = self.client_get("/billing/")
|
|
self.assertNotEqual("/sponsorship/", response["Location"])
|
|
|
|
user.realm.plan_type = Realm.PLAN_TYPE_PLUS
|
|
user.realm.save()
|
|
response = self.client_get("/billing/")
|
|
self.assertNotEqual("/sponsorship/", response["Location"])
|
|
|
|
@mock_stripe(tested_timestamp_fields=["created"])
|
|
def test_redirect_for_billing_page_downgrade_at_free_trial_end(self, *mocks: Mock) -> None:
|
|
user = self.example_user("hamlet")
|
|
self.login_user(user)
|
|
|
|
with self.settings(CLOUD_FREE_TRIAL_DAYS=30):
|
|
response = self.client_get("/upgrade/")
|
|
free_trial_end_date = self.now + timedelta(days=30)
|
|
|
|
self.assert_in_success_response(
|
|
["Your card will not be charged", "free trial", "30-day"], response
|
|
)
|
|
self.assertNotEqual(user.realm.plan_type, Realm.PLAN_TYPE_STANDARD)
|
|
self.assertFalse(Customer.objects.filter(realm=user.realm).exists())
|
|
|
|
stripe_customer = self.add_card_and_upgrade(user)
|
|
customer = Customer.objects.get(stripe_customer_id=stripe_customer.id, realm=user.realm)
|
|
plan = CustomerPlan.objects.get(
|
|
customer=customer,
|
|
automanage_licenses=True,
|
|
price_per_license=8000,
|
|
fixed_price=None,
|
|
discount=None,
|
|
billing_cycle_anchor=self.now,
|
|
billing_schedule=CustomerPlan.BILLING_SCHEDULE_ANNUAL,
|
|
invoiced_through=LicenseLedger.objects.first(),
|
|
next_invoice_date=free_trial_end_date,
|
|
tier=CustomerPlan.TIER_CLOUD_STANDARD,
|
|
status=CustomerPlan.FREE_TRIAL,
|
|
# For payment through card.
|
|
charge_automatically=True,
|
|
)
|
|
LicenseLedger.objects.get(
|
|
plan=plan,
|
|
is_renewal=True,
|
|
event_time=self.now,
|
|
licenses=self.seat_count,
|
|
licenses_at_next_renewal=self.seat_count,
|
|
)
|
|
|
|
realm = get_realm("zulip")
|
|
self.assertEqual(realm.plan_type, Realm.PLAN_TYPE_STANDARD)
|
|
|
|
with time_machine.travel(self.now, tick=False):
|
|
response = self.client_get("/billing/")
|
|
self.assert_not_in_success_response(["Pay annually"], response)
|
|
for substring in [
|
|
"Zulip Cloud Standard <i>(free trial)</i>",
|
|
"Your plan will automatically renew on",
|
|
"February 1, 2012",
|
|
"Visa ending in 4242",
|
|
"Update card",
|
|
]:
|
|
self.assert_in_response(substring, response)
|
|
|
|
# schedule downgrade
|
|
with (
|
|
time_machine.travel(self.now + timedelta(days=3), tick=False),
|
|
self.assertLogs("corporate.stripe", "INFO") as m,
|
|
):
|
|
response = self.client_billing_patch(
|
|
"/billing/plan",
|
|
{"status": CustomerPlan.DOWNGRADE_AT_END_OF_FREE_TRIAL},
|
|
)
|
|
self.assert_json_success(response)
|
|
plan.refresh_from_db()
|
|
self.assertEqual(plan.status, CustomerPlan.DOWNGRADE_AT_END_OF_FREE_TRIAL)
|
|
expected_log = f"INFO:corporate.stripe:Change plan status: Customer.id: {customer.id}, CustomerPlan.id: {plan.id}, status: {CustomerPlan.DOWNGRADE_AT_END_OF_FREE_TRIAL}"
|
|
self.assertEqual(m.output[0], expected_log)
|
|
|
|
# Visit /billing on free-trial end date before the invoice cron runs.
|
|
with time_machine.travel(free_trial_end_date, tick=False):
|
|
response = self.client_get("/billing/")
|
|
self.assertEqual(response.status_code, 302)
|
|
self.assertEqual("/plans/", response["Location"])
|
|
|
|
def test_upgrade_page_for_demo_organizations(self) -> None:
|
|
user = self.example_user("hamlet")
|
|
user.realm.demo_organization_scheduled_deletion_date = timezone_now() + timedelta(days=30)
|
|
user.realm.save()
|
|
self.login_user(user)
|
|
|
|
response = self.client_get("/billing/", follow=True)
|
|
self.assert_in_success_response(
|
|
["Demo organizations cannot be directly upgraded to a paid plan."], response
|
|
)
|
|
|
|
def test_sponsorship_page_for_demo_organizations(self) -> None:
|
|
user = self.example_user("hamlet")
|
|
user.realm.demo_organization_scheduled_deletion_date = timezone_now() + timedelta(days=30)
|
|
user.realm.save()
|
|
self.login_user(user)
|
|
|
|
response = self.client_get("/sponsorship/", follow=True)
|
|
self.assert_in_success_response(
|
|
["Demo organizations cannot apply for sponsorship."], response
|
|
)
|
|
|
|
def test_redirect_for_upgrade_page(self) -> None:
|
|
user = self.example_user("iago")
|
|
cordelia = self.example_user("cordelia")
|
|
self.login_user(cordelia)
|
|
# Cordelia is not in `can_manage_billing_group`, so can't access the page.
|
|
response = self.client_get("/upgrade/")
|
|
self.assertEqual(response.status_code, 302)
|
|
self.assertEqual(response["Location"], "/billing/")
|
|
|
|
self.login_user(user)
|
|
|
|
response = self.client_get("/upgrade/")
|
|
self.assertEqual(response.status_code, 200)
|
|
|
|
user.realm.plan_type = Realm.PLAN_TYPE_STANDARD_FREE
|
|
user.realm.save()
|
|
response = self.client_get("/upgrade/")
|
|
self.assertEqual(response.status_code, 302)
|
|
self.assertEqual(response["Location"], "http://zulip.testserver/sponsorship")
|
|
|
|
stripe_customer_id = "cus_123"
|
|
# Avoid contacting stripe as we only want to check redirects here.
|
|
with (
|
|
patch(
|
|
"corporate.lib.stripe.customer_has_credit_card_as_default_payment_method",
|
|
return_value=False,
|
|
),
|
|
patch(
|
|
"stripe.Customer.retrieve",
|
|
return_value=Mock(id=stripe_customer_id, email="test@zulip.com"),
|
|
),
|
|
):
|
|
user.realm.plan_type = Realm.PLAN_TYPE_LIMITED
|
|
user.realm.save()
|
|
customer = Customer.objects.create(
|
|
realm=user.realm, stripe_customer_id=stripe_customer_id
|
|
)
|
|
response = self.client_get("/upgrade/")
|
|
self.assertEqual(response.status_code, 200)
|
|
|
|
CustomerPlan.objects.create(
|
|
customer=customer,
|
|
billing_cycle_anchor=timezone_now(),
|
|
billing_schedule=CustomerPlan.BILLING_SCHEDULE_ANNUAL,
|
|
tier=CustomerPlan.TIER_CLOUD_STANDARD,
|
|
)
|
|
response = self.client_get("/upgrade/")
|
|
self.assertEqual(response.status_code, 302)
|
|
self.assertEqual(response["Location"], "http://zulip.testserver/billing")
|
|
|
|
with self.settings(CLOUD_FREE_TRIAL_DAYS=30):
|
|
response = self.client_get("/upgrade/")
|
|
self.assertEqual(response.status_code, 302)
|
|
self.assertEqual(response["Location"], "http://zulip.testserver/billing")
|
|
|
|
def test_get_latest_seat_count(self) -> None:
|
|
realm = get_realm("zulip")
|
|
initial_count = get_latest_seat_count(realm)
|
|
user1 = UserProfile.objects.create(
|
|
realm=realm, email="user1@zulip.com", delivery_email="user1@zulip.com"
|
|
)
|
|
user2 = UserProfile.objects.create(
|
|
realm=realm, email="user2@zulip.com", delivery_email="user2@zulip.com"
|
|
)
|
|
self.assertEqual(get_latest_seat_count(realm), initial_count + 2)
|
|
|
|
# Test that bots aren't counted
|
|
user1.is_bot = True
|
|
user1.save(update_fields=["is_bot"])
|
|
self.assertEqual(get_latest_seat_count(realm), initial_count + 1)
|
|
|
|
# Test that inactive users aren't counted
|
|
do_deactivate_user(user2, acting_user=None)
|
|
self.assertEqual(get_latest_seat_count(realm), initial_count)
|
|
|
|
# Test guests
|
|
# Adding a guest to a realm with a lot of members shouldn't change anything
|
|
UserProfile.objects.create(
|
|
realm=realm,
|
|
email="user3@zulip.com",
|
|
delivery_email="user3@zulip.com",
|
|
role=UserProfile.ROLE_GUEST,
|
|
)
|
|
self.assertEqual(get_latest_seat_count(realm), initial_count)
|
|
# Test 1 member and 5 guests
|
|
realm = do_create_realm(string_id="second", name="second")
|
|
UserProfile.objects.create(
|
|
realm=realm, email="member@second.com", delivery_email="member@second.com"
|
|
)
|
|
for i in range(5):
|
|
UserProfile.objects.create(
|
|
realm=realm,
|
|
email=f"guest{i}@second.com",
|
|
delivery_email=f"guest{i}@second.com",
|
|
role=UserProfile.ROLE_GUEST,
|
|
)
|
|
self.assertEqual(get_latest_seat_count(realm), 1)
|
|
# Test 1 member and 6 guests
|
|
UserProfile.objects.create(
|
|
realm=realm,
|
|
email="guest5@second.com",
|
|
delivery_email="guest5@second.com",
|
|
role=UserProfile.ROLE_GUEST,
|
|
)
|
|
self.assertEqual(get_latest_seat_count(realm), 2)
|
|
|
|
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")
|
|
|
|
@mock_stripe()
|
|
def test_payment_method_string(self, *mocks: Mock) -> None:
|
|
# If you pay by invoice, your payment method should be
|
|
# "Invoice", even if you have a card on file.
|
|
user = self.example_user("hamlet")
|
|
billing_session = RealmBillingSession(user)
|
|
billing_session.create_stripe_customer()
|
|
self.login_user(user)
|
|
self.add_card_to_customer_for_upgrade()
|
|
self.upgrade(invoice=True)
|
|
response = self.client_get("/billing/")
|
|
self.assert_not_in_success_response(["Visa ending in"], response)
|
|
self.assert_in_success_response(["Invoice", "You will receive an invoice for"], response)
|
|
|
|
@mock_stripe()
|
|
def test_replace_payment_method(self, *mocks: Mock) -> None:
|
|
user = self.example_user("hamlet")
|
|
self.login_user(user)
|
|
self.add_card_and_upgrade(user)
|
|
|
|
# Check that the card is displayed on the billing page.
|
|
response = self.client_get("/billing/")
|
|
self.assert_in_success_response(["Visa ending in 4242"], response)
|
|
|
|
# Create an open invoice
|
|
customer = Customer.objects.first()
|
|
assert customer is not None
|
|
stripe_customer_id = customer.stripe_customer_id
|
|
assert stripe_customer_id is not None
|
|
stripe.InvoiceItem.create(amount=5000, currency="usd", customer=stripe_customer_id)
|
|
stripe_invoice = stripe.Invoice.create(customer=stripe_customer_id)
|
|
stripe.Invoice.finalize_invoice(stripe_invoice)
|
|
RealmAuditLog.objects.filter(event_type=AuditLogEventType.STRIPE_CARD_CHANGED).delete()
|
|
|
|
start_session_json_response = self.client_billing_post(
|
|
"/billing/session/start_card_update_session"
|
|
)
|
|
response_dict = self.assert_json_success(start_session_json_response)
|
|
self.assert_details_of_valid_session_from_event_status_endpoint(
|
|
response_dict["stripe_session_id"],
|
|
{
|
|
"type": "card_update_from_billing_page",
|
|
"status": "created",
|
|
"is_manual_license_management_upgrade_session": False,
|
|
"tier": None,
|
|
},
|
|
)
|
|
with self.assertRaises(stripe.CardError):
|
|
# We don't have to handle this since the Stripe Checkout page would
|
|
# ask Customer to enter a valid card number. trigger_stripe_checkout_session_completed_webhook
|
|
# emulates what happens in the Stripe Checkout page. Adding this check mostly for coverage of
|
|
# create_payment_method.
|
|
self.trigger_stripe_checkout_session_completed_webhook(
|
|
self.get_test_card_token(attaches_to_customer=False)
|
|
)
|
|
|
|
start_session_json_response = self.client_billing_post(
|
|
"/billing/session/start_card_update_session"
|
|
)
|
|
response_dict = self.assert_json_success(start_session_json_response)
|
|
self.assert_details_of_valid_session_from_event_status_endpoint(
|
|
response_dict["stripe_session_id"],
|
|
{
|
|
"type": "card_update_from_billing_page",
|
|
"status": "created",
|
|
"is_manual_license_management_upgrade_session": False,
|
|
"tier": None,
|
|
},
|
|
)
|
|
with self.assertLogs("corporate.stripe", "INFO") as m:
|
|
self.trigger_stripe_checkout_session_completed_webhook(
|
|
self.get_test_card_token(attaches_to_customer=True, charge_succeeds=False)
|
|
)
|
|
self.assertEqual(
|
|
m.output[0],
|
|
"INFO:corporate.stripe:Stripe card error: 402 card_error card_declined None",
|
|
)
|
|
response_dict = self.assert_json_success(start_session_json_response)
|
|
self.assert_details_of_valid_session_from_event_status_endpoint(
|
|
response_dict["stripe_session_id"],
|
|
{
|
|
"type": "card_update_from_billing_page",
|
|
"status": "completed",
|
|
"is_manual_license_management_upgrade_session": False,
|
|
"tier": None,
|
|
"event_handler": {
|
|
"status": "failed",
|
|
"error": {"message": "Your card was declined.", "description": "card error"},
|
|
},
|
|
},
|
|
)
|
|
|
|
response = self.client_get("/billing/")
|
|
self.assert_in_success_response(["Visa ending in 0341"], response)
|
|
assert RealmAuditLog.objects.filter(
|
|
event_type=AuditLogEventType.STRIPE_CARD_CHANGED
|
|
).exists()
|
|
stripe_payment_methods = stripe.PaymentMethod.list(customer=stripe_customer_id, type="card")
|
|
self.assert_length(stripe_payment_methods, 2)
|
|
|
|
for stripe_payment_method in stripe_payment_methods:
|
|
stripe.PaymentMethod.detach(stripe_payment_method.id)
|
|
response = self.client_get("/billing/")
|
|
self.assert_in_success_response(["No payment method on file."], response)
|
|
|
|
start_session_json_response = self.client_billing_post(
|
|
"/billing/session/start_card_update_session"
|
|
)
|
|
self.assert_json_success(start_session_json_response)
|
|
self.trigger_stripe_checkout_session_completed_webhook(
|
|
self.get_test_card_token(
|
|
attaches_to_customer=True, charge_succeeds=True, card_provider="mastercard"
|
|
)
|
|
)
|
|
response_dict = self.assert_json_success(start_session_json_response)
|
|
self.assert_details_of_valid_session_from_event_status_endpoint(
|
|
response_dict["stripe_session_id"],
|
|
{
|
|
"type": "card_update_from_billing_page",
|
|
"status": "completed",
|
|
"is_manual_license_management_upgrade_session": False,
|
|
"tier": None,
|
|
"event_handler": {"status": "succeeded"},
|
|
},
|
|
)
|
|
|
|
self.login_user(self.example_user("othello"))
|
|
response = self.client_billing_get(
|
|
"/billing/event/status",
|
|
{"stripe_session_id": response_dict["stripe_session_id"]},
|
|
)
|
|
self.assert_json_error_contains(response, "Insufficient permission")
|
|
|
|
self.login_user(self.example_user("hamlet"))
|
|
response = self.client_get("/billing/")
|
|
self.assert_in_success_response(["Mastercard ending in 4444"], response)
|
|
self.assert_length(stripe.PaymentMethod.list(customer=stripe_customer_id, type="card"), 1)
|
|
# Ideally we'd also test that we don't pay invoices with collection_method=='send_invoice'
|
|
for stripe_invoice in stripe.Invoice.list(customer=stripe_customer_id):
|
|
self.assertEqual(stripe_invoice.status, "paid")
|
|
self.assertEqual(
|
|
2,
|
|
RealmAuditLog.objects.filter(event_type=AuditLogEventType.STRIPE_CARD_CHANGED).count(),
|
|
)
|
|
|
|
# Test if manual license management upgrade session is created and is successfully recovered.
|
|
start_session_json_response = self.client_billing_post(
|
|
"/upgrade/session/start_card_update_session",
|
|
{
|
|
"manual_license_management": "true",
|
|
"tier": 1,
|
|
},
|
|
)
|
|
response_dict = self.assert_json_success(start_session_json_response)
|
|
self.assert_details_of_valid_session_from_event_status_endpoint(
|
|
response_dict["stripe_session_id"],
|
|
{
|
|
"type": "card_update_from_upgrade_page",
|
|
"status": "created",
|
|
"is_manual_license_management_upgrade_session": True,
|
|
"tier": 1,
|
|
},
|
|
)
|
|
|
|
def test_downgrade(self) -> None:
|
|
user = self.example_user("hamlet")
|
|
self.login_user(user)
|
|
with time_machine.travel(self.now, tick=False):
|
|
self.local_upgrade(
|
|
self.seat_count, True, CustomerPlan.BILLING_SCHEDULE_ANNUAL, True, False
|
|
)
|
|
plan = get_current_plan_by_realm(user.realm)
|
|
assert plan is not None
|
|
self.assertEqual(plan.licenses(), self.seat_count)
|
|
self.assertEqual(plan.licenses_at_next_renewal(), self.seat_count)
|
|
with (
|
|
self.assertLogs("corporate.stripe", "INFO") as m,
|
|
time_machine.travel(self.now, tick=False),
|
|
):
|
|
response = self.client_billing_patch(
|
|
"/billing/plan",
|
|
{"status": CustomerPlan.DOWNGRADE_AT_END_OF_CYCLE},
|
|
)
|
|
stripe_customer_id = Customer.objects.get(realm=user.realm).id
|
|
new_plan = get_current_plan_by_realm(user.realm)
|
|
assert new_plan is not None
|
|
expected_log = f"INFO:corporate.stripe:Change plan status: Customer.id: {stripe_customer_id}, CustomerPlan.id: {new_plan.id}, status: {CustomerPlan.DOWNGRADE_AT_END_OF_CYCLE}"
|
|
self.assertEqual(m.output[0], expected_log)
|
|
self.assert_json_success(response)
|
|
plan.refresh_from_db()
|
|
self.assertEqual(plan.licenses(), self.seat_count)
|
|
self.assertEqual(plan.licenses_at_next_renewal(), None)
|
|
|
|
with time_machine.travel(self.now, tick=False):
|
|
mock_customer = Mock(email=user.delivery_email)
|
|
mock_customer.invoice_settings.default_payment_method = Mock(
|
|
spec=stripe.PaymentMethod, type=Mock()
|
|
)
|
|
with patch("corporate.lib.stripe.stripe_get_customer", return_value=mock_customer):
|
|
response = self.client_get("/billing/")
|
|
self.assert_in_success_response(
|
|
[
|
|
"Your organization will be downgraded to <strong>Zulip Cloud Free</strong> at the end of the current billing",
|
|
"<strong>January 2, 2013</strong>",
|
|
"Reactivate subscription",
|
|
],
|
|
response,
|
|
)
|
|
|
|
# Verify that we still write LicenseLedger rows during the remaining
|
|
# part of the cycle
|
|
billing_session = RealmBillingSession(user=user, realm=user.realm)
|
|
with patch("corporate.lib.stripe.get_latest_seat_count", return_value=20):
|
|
billing_session.update_license_ledger_if_needed(self.now)
|
|
self.assertEqual(
|
|
LicenseLedger.objects.order_by("-id")
|
|
.values_list("licenses", "licenses_at_next_renewal")
|
|
.first(),
|
|
(20, 20),
|
|
)
|
|
|
|
# Verify that we invoice them for the additional users
|
|
mocked = self.setup_mocked_stripe(invoice_plans_as_needed, self.next_month)
|
|
mocked["InvoiceItem"].create.assert_called_once()
|
|
mocked["Invoice"].finalize_invoice.assert_called_once()
|
|
mocked["Invoice"].create.assert_called_once()
|
|
|
|
# Check that we downgrade properly if the cycle is over
|
|
with patch("corporate.lib.stripe.get_latest_seat_count", return_value=30):
|
|
billing_session.update_license_ledger_if_needed(self.next_year)
|
|
plan = CustomerPlan.objects.first()
|
|
assert plan is not None
|
|
self.assertEqual(get_realm("zulip").plan_type, Realm.PLAN_TYPE_LIMITED)
|
|
self.assertEqual(plan.status, CustomerPlan.ENDED)
|
|
self.assertEqual(
|
|
LicenseLedger.objects.order_by("-id")
|
|
.values_list("licenses", "licenses_at_next_renewal")
|
|
.first(),
|
|
(20, 20),
|
|
)
|
|
realm_audit_log = RealmAuditLog.objects.latest("id")
|
|
self.assertEqual(realm_audit_log.event_type, AuditLogEventType.REALM_PLAN_TYPE_CHANGED)
|
|
self.assertEqual(realm_audit_log.acting_user, None)
|
|
|
|
# Verify that we don't write LicenseLedger rows once we've downgraded
|
|
with patch("corporate.lib.stripe.get_latest_seat_count", return_value=40):
|
|
billing_session.update_license_ledger_if_needed(self.next_year)
|
|
self.assertEqual(
|
|
LicenseLedger.objects.order_by("-id")
|
|
.values_list("licenses", "licenses_at_next_renewal")
|
|
.first(),
|
|
(20, 20),
|
|
)
|
|
|
|
# Verify that we call invoice_plan once more after cycle end but
|
|
# don't invoice them for users added after the cycle end
|
|
plan = CustomerPlan.objects.first()
|
|
assert plan is not None
|
|
self.assertIsNotNone(plan.next_invoice_date)
|
|
|
|
mocked = self.setup_mocked_stripe(
|
|
invoice_plans_as_needed, self.next_year + timedelta(days=32)
|
|
)
|
|
mocked["InvoiceItem"].create.assert_not_called()
|
|
mocked["Invoice"].finalize_invoice.assert_not_called()
|
|
mocked["Invoice"].create.assert_not_called()
|
|
|
|
# Check that we updated next_invoice_date in invoice_plan
|
|
plan = CustomerPlan.objects.first()
|
|
assert plan is not None
|
|
self.assertIsNone(plan.next_invoice_date)
|
|
|
|
# Check that we don't call invoice_plan after that final call
|
|
with patch("corporate.lib.stripe.get_latest_seat_count", return_value=50):
|
|
billing_session.update_license_ledger_if_needed(self.next_year + timedelta(days=80))
|
|
|
|
mocked = self.setup_mocked_stripe(
|
|
invoice_plans_as_needed, self.next_year + timedelta(days=400)
|
|
)
|
|
mocked["InvoiceItem"].create.assert_not_called()
|
|
mocked["Invoice"].finalize_invoice.assert_not_called()
|
|
mocked["Invoice"].create.assert_not_called()
|
|
|
|
@mock_stripe()
|
|
def test_switch_from_monthly_plan_to_annual_plan_for_automatic_license_management(
|
|
self, *mocks: Mock
|
|
) -> None:
|
|
user = self.example_user("hamlet")
|
|
|
|
self.login_user(user)
|
|
self.add_card_and_upgrade(user, schedule="monthly")
|
|
monthly_plan = get_current_plan_by_realm(user.realm)
|
|
assert monthly_plan is not None
|
|
self.assertEqual(monthly_plan.automanage_licenses, True)
|
|
self.assertEqual(monthly_plan.billing_schedule, CustomerPlan.BILLING_SCHEDULE_MONTHLY)
|
|
|
|
stripe_customer_id = Customer.objects.get(realm=user.realm).id
|
|
new_plan = get_current_plan_by_realm(user.realm)
|
|
assert new_plan is not None
|
|
|
|
with (
|
|
self.assertLogs("corporate.stripe", "INFO") as m,
|
|
time_machine.travel(self.now, tick=False),
|
|
):
|
|
response = self.client_billing_patch(
|
|
"/billing/plan",
|
|
{"status": CustomerPlan.SWITCH_TO_ANNUAL_AT_END_OF_CYCLE},
|
|
)
|
|
expected_log = f"INFO:corporate.stripe:Change plan status: Customer.id: {stripe_customer_id}, CustomerPlan.id: {new_plan.id}, status: {CustomerPlan.SWITCH_TO_ANNUAL_AT_END_OF_CYCLE}"
|
|
self.assertEqual(m.output[0], expected_log)
|
|
self.assert_json_success(response)
|
|
monthly_plan.refresh_from_db()
|
|
self.assertEqual(monthly_plan.status, CustomerPlan.SWITCH_TO_ANNUAL_AT_END_OF_CYCLE)
|
|
with time_machine.travel(self.now, tick=False):
|
|
response = self.client_get("/billing/")
|
|
self.assert_in_success_response(
|
|
["Your plan will switch to annual billing on February 2, 2012"], response
|
|
)
|
|
|
|
billing_session = RealmBillingSession(user=user, realm=user.realm)
|
|
with patch("corporate.lib.stripe.get_latest_seat_count", return_value=20):
|
|
billing_session.update_license_ledger_if_needed(self.now)
|
|
self.assertEqual(LicenseLedger.objects.filter(plan=monthly_plan).count(), 2)
|
|
self.assertEqual(
|
|
LicenseLedger.objects.order_by("-id")
|
|
.values_list("licenses", "licenses_at_next_renewal")
|
|
.first(),
|
|
(20, 20),
|
|
)
|
|
|
|
with (
|
|
time_machine.travel(self.next_month, tick=False),
|
|
patch("corporate.lib.stripe.get_latest_seat_count", return_value=25),
|
|
):
|
|
billing_session.update_license_ledger_if_needed(self.next_month)
|
|
self.assertEqual(LicenseLedger.objects.filter(plan=monthly_plan).count(), 2)
|
|
customer = get_customer_by_realm(user.realm)
|
|
assert customer is not None
|
|
self.assertEqual(CustomerPlan.objects.filter(customer=customer).count(), 2)
|
|
monthly_plan.refresh_from_db()
|
|
self.assertEqual(monthly_plan.status, CustomerPlan.ENDED)
|
|
self.assertEqual(monthly_plan.next_invoice_date, self.next_month)
|
|
annual_plan = get_current_plan_by_realm(user.realm)
|
|
assert annual_plan is not None
|
|
self.assertEqual(annual_plan.status, CustomerPlan.ACTIVE)
|
|
self.assertEqual(annual_plan.billing_schedule, CustomerPlan.BILLING_SCHEDULE_ANNUAL)
|
|
self.assertEqual(
|
|
annual_plan.invoicing_status, CustomerPlan.INVOICING_STATUS_INITIAL_INVOICE_TO_BE_SENT
|
|
)
|
|
self.assertEqual(annual_plan.billing_cycle_anchor, self.next_month)
|
|
self.assertEqual(annual_plan.next_invoice_date, self.next_month)
|
|
self.assertEqual(annual_plan.invoiced_through, None)
|
|
annual_ledger_entries = LicenseLedger.objects.filter(plan=annual_plan).order_by("id")
|
|
self.assert_length(annual_ledger_entries, 2)
|
|
self.assertEqual(annual_ledger_entries[0].is_renewal, True)
|
|
self.assertEqual(
|
|
annual_ledger_entries.values_list("licenses", "licenses_at_next_renewal")[0], (20, 20)
|
|
)
|
|
self.assertEqual(annual_ledger_entries[1].is_renewal, False)
|
|
self.assertEqual(
|
|
annual_ledger_entries.values_list("licenses", "licenses_at_next_renewal")[1], (25, 25)
|
|
)
|
|
audit_log = RealmAuditLog.objects.get(
|
|
event_type=AuditLogEventType.CUSTOMER_SWITCHED_FROM_MONTHLY_TO_ANNUAL_PLAN
|
|
)
|
|
self.assertEqual(audit_log.realm, user.realm)
|
|
self.assertEqual(audit_log.extra_data["monthly_plan_id"], monthly_plan.id)
|
|
self.assertEqual(audit_log.extra_data["annual_plan_id"], annual_plan.id)
|
|
|
|
invoice_plans_as_needed(self.next_month)
|
|
|
|
annual_ledger_entries = LicenseLedger.objects.filter(plan=annual_plan).order_by("id")
|
|
self.assert_length(annual_ledger_entries, 2)
|
|
annual_plan.refresh_from_db()
|
|
self.assertEqual(annual_plan.invoicing_status, CustomerPlan.INVOICING_STATUS_DONE)
|
|
self.assertEqual(annual_plan.invoiced_through, annual_ledger_entries[1])
|
|
self.assertEqual(annual_plan.billing_cycle_anchor, self.next_month)
|
|
self.assertEqual(annual_plan.next_invoice_date, add_months(self.next_month, 1))
|
|
monthly_plan.refresh_from_db()
|
|
self.assertEqual(monthly_plan.next_invoice_date, None)
|
|
|
|
assert customer.stripe_customer_id
|
|
[invoice0, invoice1, invoice2] = iter(
|
|
stripe.Invoice.list(customer=customer.stripe_customer_id)
|
|
)
|
|
|
|
[invoice_item0, invoice_item1] = iter(invoice0.lines)
|
|
annual_plan_invoice_item_params = {
|
|
"amount": 5 * 80 * 100,
|
|
"description": "Additional license (Feb 2, 2012 - Feb 2, 2013)",
|
|
"plan": None,
|
|
"quantity": 5,
|
|
"subscription": None,
|
|
"discountable": False,
|
|
"period": {
|
|
"start": datetime_to_timestamp(self.next_month),
|
|
"end": datetime_to_timestamp(add_months(self.next_month, 12)),
|
|
},
|
|
}
|
|
for key, value in annual_plan_invoice_item_params.items():
|
|
self.assertEqual(invoice_item0[key], value)
|
|
|
|
annual_plan_invoice_item_params = {
|
|
"amount": 20 * 80 * 100,
|
|
"description": "Zulip Cloud Standard - renewal",
|
|
"plan": None,
|
|
"quantity": 20,
|
|
"subscription": None,
|
|
"discountable": False,
|
|
"period": {
|
|
"start": datetime_to_timestamp(self.next_month),
|
|
"end": datetime_to_timestamp(add_months(self.next_month, 12)),
|
|
},
|
|
}
|
|
for key, value in annual_plan_invoice_item_params.items():
|
|
self.assertEqual(invoice_item1[key], value)
|
|
|
|
[monthly_plan_invoice_item] = iter(invoice1.lines)
|
|
monthly_plan_invoice_item_params = {
|
|
"amount": 14 * 8 * 100,
|
|
"description": "Additional license (Jan 2, 2012 - Feb 2, 2012)",
|
|
"plan": None,
|
|
"quantity": 14,
|
|
"subscription": None,
|
|
"discountable": False,
|
|
"period": {
|
|
"start": datetime_to_timestamp(self.now),
|
|
"end": datetime_to_timestamp(self.next_month),
|
|
},
|
|
}
|
|
for key, value in monthly_plan_invoice_item_params.items():
|
|
self.assertEqual(monthly_plan_invoice_item[key], value)
|
|
|
|
with patch("corporate.lib.stripe.get_latest_seat_count", return_value=30):
|
|
billing_session.update_license_ledger_if_needed(add_months(self.next_month, 1))
|
|
invoice_plans_as_needed(add_months(self.next_month, 1))
|
|
|
|
[invoice0, invoice1, invoice2, invoice3] = iter(
|
|
stripe.Invoice.list(customer=customer.stripe_customer_id)
|
|
)
|
|
|
|
[monthly_plan_invoice_item] = iter(invoice0.lines)
|
|
monthly_plan_invoice_item_params = {
|
|
"amount": 5 * 7366,
|
|
"description": "Additional license (Mar 2, 2012 - Feb 2, 2013)",
|
|
"plan": None,
|
|
"quantity": 5,
|
|
"subscription": None,
|
|
"discountable": False,
|
|
"period": {
|
|
"start": datetime_to_timestamp(add_months(self.next_month, 1)),
|
|
"end": datetime_to_timestamp(add_months(self.next_month, 12)),
|
|
},
|
|
}
|
|
for key, value in monthly_plan_invoice_item_params.items():
|
|
self.assertEqual(monthly_plan_invoice_item[key], value)
|
|
|
|
# Fast forward next_invoice_date to one year from the day we switched to annual plan.
|
|
annual_plan.next_invoice_date = add_months(self.now, 13)
|
|
annual_plan.save(update_fields=["next_invoice_date"])
|
|
invoice_plans_as_needed(add_months(self.now, 13))
|
|
|
|
[invoice0, invoice1, invoice2, invoice3, invoice4] = iter(
|
|
stripe.Invoice.list(customer=customer.stripe_customer_id)
|
|
)
|
|
|
|
[invoice_item] = iter(invoice0.lines)
|
|
annual_plan_invoice_item_params = {
|
|
"amount": 30 * 80 * 100,
|
|
"description": "Zulip Cloud Standard - renewal",
|
|
"plan": None,
|
|
"quantity": 30,
|
|
"subscription": None,
|
|
"discountable": False,
|
|
"period": {
|
|
"start": datetime_to_timestamp(add_months(self.next_month, 12)),
|
|
"end": datetime_to_timestamp(add_months(self.next_month, 24)),
|
|
},
|
|
}
|
|
for key, value in annual_plan_invoice_item_params.items():
|
|
self.assertEqual(invoice_item[key], value)
|
|
|
|
@mock_stripe()
|
|
def test_switch_from_monthly_plan_to_annual_plan_for_manual_license_management(
|
|
self, *mocks: Mock
|
|
) -> None:
|
|
user = self.example_user("hamlet")
|
|
num_licenses = 35
|
|
|
|
self.login_user(user)
|
|
self.add_card_and_upgrade(
|
|
user, schedule="monthly", license_management="manual", licenses=num_licenses
|
|
)
|
|
monthly_plan = get_current_plan_by_realm(user.realm)
|
|
assert monthly_plan is not None
|
|
self.assertEqual(monthly_plan.automanage_licenses, False)
|
|
self.assertEqual(monthly_plan.billing_schedule, CustomerPlan.BILLING_SCHEDULE_MONTHLY)
|
|
stripe_customer_id = Customer.objects.get(realm=user.realm).id
|
|
new_plan = get_current_plan_by_realm(user.realm)
|
|
assert new_plan is not None
|
|
with (
|
|
self.assertLogs("corporate.stripe", "INFO") as m,
|
|
time_machine.travel(self.now, tick=False),
|
|
):
|
|
response = self.client_billing_patch(
|
|
"/billing/plan",
|
|
{"status": CustomerPlan.SWITCH_TO_ANNUAL_AT_END_OF_CYCLE},
|
|
)
|
|
self.assertEqual(
|
|
m.output[0],
|
|
f"INFO:corporate.stripe:Change plan status: Customer.id: {stripe_customer_id}, CustomerPlan.id: {new_plan.id}, status: {CustomerPlan.SWITCH_TO_ANNUAL_AT_END_OF_CYCLE}",
|
|
)
|
|
self.assert_json_success(response)
|
|
monthly_plan.refresh_from_db()
|
|
self.assertEqual(monthly_plan.status, CustomerPlan.SWITCH_TO_ANNUAL_AT_END_OF_CYCLE)
|
|
with time_machine.travel(self.now, tick=False):
|
|
response = self.client_get("/billing/")
|
|
self.assert_in_success_response(
|
|
["Your plan will switch to annual billing on February 2, 2012"], response
|
|
)
|
|
|
|
invoice_plans_as_needed(self.next_month)
|
|
|
|
self.assertEqual(LicenseLedger.objects.filter(plan=monthly_plan).count(), 1)
|
|
customer = get_customer_by_realm(user.realm)
|
|
assert customer is not None
|
|
self.assertEqual(CustomerPlan.objects.filter(customer=customer).count(), 2)
|
|
monthly_plan.refresh_from_db()
|
|
self.assertEqual(monthly_plan.status, CustomerPlan.ENDED)
|
|
self.assertEqual(monthly_plan.next_invoice_date, None)
|
|
annual_plan = get_current_plan_by_realm(user.realm)
|
|
assert annual_plan is not None
|
|
self.assertEqual(annual_plan.status, CustomerPlan.ACTIVE)
|
|
self.assertEqual(annual_plan.billing_schedule, CustomerPlan.BILLING_SCHEDULE_ANNUAL)
|
|
self.assertEqual(
|
|
annual_plan.invoicing_status, CustomerPlan.INVOICING_STATUS_INITIAL_INVOICE_TO_BE_SENT
|
|
)
|
|
self.assertEqual(annual_plan.billing_cycle_anchor, self.next_month)
|
|
self.assertEqual(annual_plan.next_invoice_date, self.next_month)
|
|
annual_ledger_entries = LicenseLedger.objects.filter(plan=annual_plan).order_by("id")
|
|
self.assert_length(annual_ledger_entries, 1)
|
|
self.assertEqual(annual_ledger_entries[0].is_renewal, True)
|
|
self.assertEqual(
|
|
annual_ledger_entries.values_list("licenses", "licenses_at_next_renewal")[0],
|
|
(num_licenses, num_licenses),
|
|
)
|
|
self.assertEqual(annual_plan.invoiced_through, None)
|
|
|
|
# First call of invoice_plans_as_needed creates the new plan. Second call
|
|
# calls invoice_plan on the newly created plan.
|
|
invoice_plans_as_needed(self.next_month + timedelta(days=1))
|
|
|
|
annual_plan.refresh_from_db()
|
|
self.assertEqual(annual_plan.invoiced_through, annual_ledger_entries[0])
|
|
self.assertEqual(annual_plan.next_invoice_date, add_months(self.next_month, 1))
|
|
self.assertEqual(annual_plan.invoicing_status, CustomerPlan.INVOICING_STATUS_DONE)
|
|
|
|
assert customer.stripe_customer_id
|
|
[invoice0, invoice1] = iter(stripe.Invoice.list(customer=customer.stripe_customer_id))
|
|
|
|
[invoice_item] = iter(invoice0.lines)
|
|
annual_plan_invoice_item_params = {
|
|
"amount": num_licenses * 80 * 100,
|
|
"description": "Zulip Cloud Standard - renewal",
|
|
"plan": None,
|
|
"quantity": num_licenses,
|
|
"subscription": None,
|
|
"discountable": False,
|
|
"period": {
|
|
"start": datetime_to_timestamp(self.next_month),
|
|
"end": datetime_to_timestamp(add_months(self.next_month, 12)),
|
|
},
|
|
}
|
|
for key, value in annual_plan_invoice_item_params.items():
|
|
self.assertEqual(invoice_item[key], value)
|
|
|
|
invoice_plans_as_needed(add_months(self.now, 13))
|
|
|
|
[invoice0, invoice1, invoice2] = iter(
|
|
stripe.Invoice.list(customer=customer.stripe_customer_id)
|
|
)
|
|
|
|
[invoice_item] = iter(invoice0.lines)
|
|
annual_plan_invoice_item_params = {
|
|
"amount": num_licenses * 80 * 100,
|
|
"description": "Zulip Cloud Standard - renewal",
|
|
"plan": None,
|
|
"quantity": num_licenses,
|
|
"subscription": None,
|
|
"discountable": False,
|
|
"period": {
|
|
"start": datetime_to_timestamp(add_months(self.next_month, 12)),
|
|
"end": datetime_to_timestamp(add_months(self.next_month, 24)),
|
|
},
|
|
}
|
|
for key, value in annual_plan_invoice_item_params.items():
|
|
self.assertEqual(invoice_item[key], value)
|
|
|
|
@mock_stripe()
|
|
def test_switch_from_annual_plan_to_monthly_plan_for_automatic_license_management(
|
|
self, *mocks: Mock
|
|
) -> None:
|
|
user = self.example_user("hamlet")
|
|
self.login_user(user)
|
|
self.add_card_and_upgrade(user, schedule="annual")
|
|
annual_plan = get_current_plan_by_realm(user.realm)
|
|
assert annual_plan is not None
|
|
self.assertEqual(annual_plan.automanage_licenses, True)
|
|
self.assertEqual(annual_plan.billing_schedule, CustomerPlan.BILLING_SCHEDULE_ANNUAL)
|
|
|
|
stripe_customer_id = Customer.objects.get(realm=user.realm).id
|
|
new_plan = get_current_plan_by_realm(user.realm)
|
|
assert new_plan is not None
|
|
|
|
assert self.now is not None
|
|
with (
|
|
self.assertLogs("corporate.stripe", "INFO") as m,
|
|
time_machine.travel(self.now, tick=False),
|
|
):
|
|
response = self.client_billing_patch(
|
|
"/billing/plan",
|
|
{"status": CustomerPlan.SWITCH_TO_MONTHLY_AT_END_OF_CYCLE},
|
|
)
|
|
expected_log = f"INFO:corporate.stripe:Change plan status: Customer.id: {stripe_customer_id}, CustomerPlan.id: {new_plan.id}, status: {CustomerPlan.SWITCH_TO_MONTHLY_AT_END_OF_CYCLE}"
|
|
self.assertEqual(m.output[0], expected_log)
|
|
self.assert_json_success(response)
|
|
annual_plan.refresh_from_db()
|
|
self.assertEqual(annual_plan.status, CustomerPlan.SWITCH_TO_MONTHLY_AT_END_OF_CYCLE)
|
|
with time_machine.travel(self.now, tick=False):
|
|
response = self.client_get("/billing/")
|
|
self.assert_in_success_response(
|
|
["Your plan will switch to monthly billing on January 2, 2013"], response
|
|
)
|
|
|
|
billing_session = RealmBillingSession(user=user, realm=user.realm)
|
|
with patch("corporate.lib.stripe.get_latest_seat_count", return_value=20):
|
|
billing_session.update_license_ledger_if_needed(self.now)
|
|
self.assertEqual(LicenseLedger.objects.filter(plan=annual_plan).count(), 2)
|
|
self.assertEqual(
|
|
LicenseLedger.objects.order_by("-id")
|
|
.values_list("licenses", "licenses_at_next_renewal")
|
|
.first(),
|
|
(20, 20),
|
|
)
|
|
|
|
# Check that we don't switch to monthly plan at next invoice date (which is used to charge user for
|
|
# additional licenses) but at the end of current billing cycle.
|
|
self.assertEqual(annual_plan.next_invoice_date, self.next_month)
|
|
assert annual_plan.next_invoice_date is not None
|
|
with (
|
|
time_machine.travel(annual_plan.next_invoice_date, tick=False),
|
|
patch("corporate.lib.stripe.get_latest_seat_count", return_value=25),
|
|
):
|
|
billing_session.update_license_ledger_if_needed(annual_plan.next_invoice_date)
|
|
|
|
annual_plan.refresh_from_db()
|
|
self.assertEqual(annual_plan.status, CustomerPlan.SWITCH_TO_MONTHLY_AT_END_OF_CYCLE)
|
|
self.assertEqual(annual_plan.next_invoice_date, self.next_month)
|
|
self.assertEqual(annual_plan.billing_schedule, CustomerPlan.BILLING_SCHEDULE_ANNUAL)
|
|
self.assertEqual(LicenseLedger.objects.filter(plan=annual_plan).count(), 3)
|
|
|
|
invoice_plans_as_needed(self.next_month + timedelta(days=1))
|
|
|
|
annual_plan.refresh_from_db()
|
|
self.assertEqual(annual_plan.next_invoice_date, add_months(self.next_month, 1))
|
|
self.assertEqual(annual_plan.invoicing_status, CustomerPlan.INVOICING_STATUS_DONE)
|
|
self.assertEqual(LicenseLedger.objects.filter(plan=annual_plan).count(), 3)
|
|
|
|
customer = get_customer_by_realm(user.realm)
|
|
assert customer is not None
|
|
assert customer.stripe_customer_id
|
|
[invoice0, invoice1] = iter(stripe.Invoice.list(customer=customer.stripe_customer_id))
|
|
[invoice_item1, invoice_item2] = iter(invoice0.lines)
|
|
annual_plan_invoice_item_params = {
|
|
"amount": 7322 * 5,
|
|
"description": "Additional license (Feb 2, 2012 - Jan 2, 2013)",
|
|
"plan": None,
|
|
"quantity": 5,
|
|
"subscription": None,
|
|
"discountable": False,
|
|
"period": {
|
|
"start": datetime_to_timestamp(self.next_month),
|
|
"end": datetime_to_timestamp(self.next_year),
|
|
},
|
|
}
|
|
|
|
for key, value in annual_plan_invoice_item_params.items():
|
|
self.assertEqual(invoice_item1[key], value)
|
|
|
|
annual_plan_invoice_item_params = {
|
|
"amount": 14 * 80 * 1 * 100,
|
|
"description": "Additional license (Jan 2, 2012 - Jan 2, 2013)",
|
|
"plan": None,
|
|
"quantity": 14,
|
|
"subscription": None,
|
|
"discountable": False,
|
|
"period": {
|
|
"start": datetime_to_timestamp(self.now),
|
|
"end": datetime_to_timestamp(self.next_year),
|
|
},
|
|
}
|
|
|
|
for key, value in annual_plan_invoice_item_params.items():
|
|
self.assertEqual(invoice_item2[key], value)
|
|
|
|
# Check that we switch to monthly plan at the end of current billing cycle.
|
|
with (
|
|
time_machine.travel(self.next_year, tick=False),
|
|
patch("corporate.lib.stripe.get_latest_seat_count", return_value=25),
|
|
):
|
|
billing_session.update_license_ledger_if_needed(self.next_year)
|
|
self.assertEqual(LicenseLedger.objects.filter(plan=annual_plan).count(), 3)
|
|
customer = get_customer_by_realm(user.realm)
|
|
assert customer is not None
|
|
annual_plan.refresh_from_db()
|
|
self.assertEqual(annual_plan.status, CustomerPlan.ENDED)
|
|
self.assertEqual(annual_plan.next_invoice_date, add_months(self.next_month, 1))
|
|
monthly_plan = get_current_plan_by_realm(user.realm)
|
|
assert monthly_plan is not None
|
|
self.assertEqual(monthly_plan.status, CustomerPlan.ACTIVE)
|
|
self.assertEqual(monthly_plan.billing_schedule, CustomerPlan.BILLING_SCHEDULE_MONTHLY)
|
|
self.assertEqual(
|
|
monthly_plan.invoicing_status, CustomerPlan.INVOICING_STATUS_INITIAL_INVOICE_TO_BE_SENT
|
|
)
|
|
self.assertEqual(monthly_plan.billing_cycle_anchor, self.next_year)
|
|
self.assertEqual(monthly_plan.next_invoice_date, self.next_year)
|
|
self.assertEqual(monthly_plan.invoiced_through, None)
|
|
monthly_ledger_entries = LicenseLedger.objects.filter(plan=monthly_plan).order_by("id")
|
|
self.assert_length(monthly_ledger_entries, 2)
|
|
self.assertEqual(monthly_ledger_entries[0].is_renewal, True)
|
|
self.assertEqual(
|
|
monthly_ledger_entries.values_list("licenses", "licenses_at_next_renewal")[0], (25, 25)
|
|
)
|
|
self.assertEqual(monthly_ledger_entries[1].is_renewal, False)
|
|
self.assertEqual(
|
|
monthly_ledger_entries.values_list("licenses", "licenses_at_next_renewal")[1], (25, 25)
|
|
)
|
|
audit_log = RealmAuditLog.objects.get(
|
|
event_type=AuditLogEventType.CUSTOMER_SWITCHED_FROM_ANNUAL_TO_MONTHLY_PLAN
|
|
)
|
|
self.assertEqual(audit_log.realm, user.realm)
|
|
self.assertEqual(audit_log.extra_data["annual_plan_id"], annual_plan.id)
|
|
self.assertEqual(audit_log.extra_data["monthly_plan_id"], monthly_plan.id)
|
|
|
|
invoice_plans_as_needed(self.next_year)
|
|
|
|
monthly_ledger_entries = LicenseLedger.objects.filter(plan=monthly_plan).order_by("id")
|
|
self.assert_length(monthly_ledger_entries, 2)
|
|
monthly_plan.refresh_from_db()
|
|
self.assertEqual(monthly_plan.invoicing_status, CustomerPlan.INVOICING_STATUS_DONE)
|
|
self.assertEqual(monthly_plan.invoiced_through, monthly_ledger_entries[1])
|
|
self.assertEqual(monthly_plan.billing_cycle_anchor, self.next_year)
|
|
self.assertEqual(monthly_plan.next_invoice_date, add_months(self.next_year, 1))
|
|
annual_plan.refresh_from_db()
|
|
self.assertEqual(annual_plan.next_invoice_date, None)
|
|
|
|
assert customer.stripe_customer_id
|
|
[invoice0, invoice1, invoice2] = iter(
|
|
stripe.Invoice.list(customer=customer.stripe_customer_id)
|
|
)
|
|
|
|
[invoice_item0] = iter(invoice0.lines)
|
|
|
|
monthly_plan_invoice_item_params = {
|
|
"amount": 25 * 8 * 100,
|
|
"description": "Zulip Cloud Standard - renewal",
|
|
"plan": None,
|
|
"quantity": 25,
|
|
"subscription": None,
|
|
"discountable": False,
|
|
"period": {
|
|
"start": datetime_to_timestamp(self.next_year),
|
|
"end": datetime_to_timestamp(add_months(self.next_year, 1)),
|
|
},
|
|
}
|
|
for key, value in monthly_plan_invoice_item_params.items():
|
|
self.assertEqual(invoice_item0[key], value)
|
|
|
|
with time_machine.travel(self.next_year, tick=False):
|
|
response = self.client_get("/billing/")
|
|
self.assert_not_in_success_response(
|
|
["Your plan will switch to annual billing on February 2, 2012"], response
|
|
)
|
|
|
|
def test_reupgrade_after_plan_status_changed_to_downgrade_at_end_of_cycle(self) -> None:
|
|
user = self.example_user("hamlet")
|
|
self.login_user(user)
|
|
with time_machine.travel(self.now, tick=False):
|
|
self.local_upgrade(
|
|
self.seat_count, True, CustomerPlan.BILLING_SCHEDULE_ANNUAL, True, False
|
|
)
|
|
with (
|
|
self.assertLogs("corporate.stripe", "INFO") as m,
|
|
time_machine.travel(self.now, tick=False),
|
|
):
|
|
response = self.client_billing_patch(
|
|
"/billing/plan",
|
|
{"status": CustomerPlan.DOWNGRADE_AT_END_OF_CYCLE},
|
|
)
|
|
stripe_customer_id = Customer.objects.get(realm=user.realm).id
|
|
new_plan = get_current_plan_by_realm(user.realm)
|
|
assert new_plan is not None
|
|
expected_log = f"INFO:corporate.stripe:Change plan status: Customer.id: {stripe_customer_id}, CustomerPlan.id: {new_plan.id}, status: {CustomerPlan.DOWNGRADE_AT_END_OF_CYCLE}"
|
|
self.assertEqual(m.output[0], expected_log)
|
|
self.assert_json_success(response)
|
|
plan = CustomerPlan.objects.first()
|
|
assert plan is not None
|
|
self.assertEqual(plan.status, CustomerPlan.DOWNGRADE_AT_END_OF_CYCLE)
|
|
with (
|
|
self.assertLogs("corporate.stripe", "INFO") as m,
|
|
time_machine.travel(self.now, tick=False),
|
|
):
|
|
response = self.client_billing_patch(
|
|
"/billing/plan",
|
|
{"status": CustomerPlan.ACTIVE},
|
|
)
|
|
expected_log = f"INFO:corporate.stripe:Change plan status: Customer.id: {stripe_customer_id}, CustomerPlan.id: {new_plan.id}, status: {CustomerPlan.ACTIVE}"
|
|
self.assertEqual(m.output[0], expected_log)
|
|
self.assert_json_success(response)
|
|
plan = CustomerPlan.objects.first()
|
|
assert plan is not None
|
|
self.assertEqual(plan.status, CustomerPlan.ACTIVE)
|
|
|
|
@patch("stripe.Invoice.create")
|
|
@patch("stripe.Invoice.finalize_invoice")
|
|
@patch("stripe.InvoiceItem.create")
|
|
def test_downgrade_during_invoicing(self, *mocks: Mock) -> None:
|
|
# The difference between this test and test_downgrade is that
|
|
# CustomerPlan.status is DOWNGRADE_AT_END_OF_CYCLE rather than ENDED
|
|
# when we call invoice_plans_as_needed
|
|
# This test is essentially checking that we call make_end_of_cycle_updates_if_needed
|
|
# during the invoicing process.
|
|
user = self.example_user("hamlet")
|
|
self.login_user(user)
|
|
with time_machine.travel(self.now, tick=False):
|
|
self.local_upgrade(
|
|
self.seat_count, True, CustomerPlan.BILLING_SCHEDULE_ANNUAL, True, False
|
|
)
|
|
with self.assertLogs("corporate.stripe", "INFO") as m:
|
|
stripe_customer_id = Customer.objects.get(realm=user.realm).id
|
|
new_plan = get_current_plan_by_realm(user.realm)
|
|
assert new_plan is not None
|
|
with time_machine.travel(self.now, tick=False):
|
|
self.client_billing_patch(
|
|
"/billing/plan",
|
|
{"status": CustomerPlan.DOWNGRADE_AT_END_OF_CYCLE},
|
|
)
|
|
expected_log = f"INFO:corporate.stripe:Change plan status: Customer.id: {stripe_customer_id}, CustomerPlan.id: {new_plan.id}, status: {CustomerPlan.DOWNGRADE_AT_END_OF_CYCLE}"
|
|
self.assertEqual(m.output[0], expected_log)
|
|
|
|
plan = CustomerPlan.objects.first()
|
|
assert plan is not None
|
|
self.assertIsNotNone(plan.next_invoice_date)
|
|
self.assertEqual(plan.status, CustomerPlan.DOWNGRADE_AT_END_OF_CYCLE)
|
|
# Fast forward the next_invoice_date to next year.
|
|
plan.next_invoice_date = self.next_year
|
|
plan.save(update_fields=["next_invoice_date"])
|
|
invoice_plans_as_needed(self.next_year)
|
|
plan = CustomerPlan.objects.first()
|
|
assert plan is not None
|
|
self.assertIsNone(plan.next_invoice_date)
|
|
self.assertEqual(plan.status, CustomerPlan.ENDED)
|
|
|
|
@mock_stripe()
|
|
def test_switch_now_free_trial_from_monthly_to_annual(self, *mocks: Mock) -> None:
|
|
user = self.example_user("hamlet")
|
|
self.login_user(user)
|
|
|
|
free_trial_end_date = self.now + timedelta(days=60)
|
|
with self.settings(CLOUD_FREE_TRIAL_DAYS=60), time_machine.travel(self.now, tick=False):
|
|
self.add_card_and_upgrade(user, schedule="monthly")
|
|
plan = CustomerPlan.objects.get()
|
|
self.assertEqual(plan.next_invoice_date, free_trial_end_date)
|
|
self.assertEqual(get_realm("zulip").plan_type, Realm.PLAN_TYPE_STANDARD)
|
|
self.assertEqual(plan.status, CustomerPlan.FREE_TRIAL)
|
|
|
|
customer = get_customer_by_realm(user.realm)
|
|
assert customer is not None
|
|
result = self.client_billing_patch(
|
|
"/billing/plan",
|
|
{
|
|
"status": CustomerPlan.FREE_TRIAL,
|
|
"schedule": CustomerPlan.BILLING_SCHEDULE_ANNUAL,
|
|
},
|
|
)
|
|
self.assert_json_success(result)
|
|
|
|
plan.refresh_from_db()
|
|
self.assertEqual(plan.status, CustomerPlan.ENDED)
|
|
self.assertIsNone(plan.next_invoice_date)
|
|
|
|
new_plan = CustomerPlan.objects.get(
|
|
customer=customer,
|
|
automanage_licenses=True,
|
|
price_per_license=8000,
|
|
fixed_price=None,
|
|
discount=None,
|
|
billing_cycle_anchor=self.now,
|
|
billing_schedule=CustomerPlan.BILLING_SCHEDULE_ANNUAL,
|
|
next_invoice_date=free_trial_end_date,
|
|
tier=CustomerPlan.TIER_CLOUD_STANDARD,
|
|
status=CustomerPlan.FREE_TRIAL,
|
|
charge_automatically=True,
|
|
)
|
|
ledger_entry = LicenseLedger.objects.get(
|
|
plan=new_plan,
|
|
is_renewal=True,
|
|
event_time=self.now,
|
|
licenses=self.seat_count,
|
|
licenses_at_next_renewal=self.seat_count,
|
|
)
|
|
self.assertEqual(new_plan.invoiced_through, ledger_entry)
|
|
|
|
realm_audit_log = RealmAuditLog.objects.filter(
|
|
event_type=AuditLogEventType.CUSTOMER_SWITCHED_FROM_MONTHLY_TO_ANNUAL_PLAN
|
|
).last()
|
|
assert realm_audit_log is not None
|
|
|
|
@mock_stripe()
|
|
def test_switch_now_free_trial_from_annual_to_monthly(self, *mocks: Mock) -> None:
|
|
user = self.example_user("hamlet")
|
|
self.login_user(user)
|
|
|
|
free_trial_end_date = self.now + timedelta(days=60)
|
|
with self.settings(CLOUD_FREE_TRIAL_DAYS=60), time_machine.travel(self.now, tick=False):
|
|
self.add_card_and_upgrade(user, schedule="annual")
|
|
plan = CustomerPlan.objects.get()
|
|
self.assertEqual(plan.next_invoice_date, free_trial_end_date)
|
|
self.assertEqual(get_realm("zulip").plan_type, Realm.PLAN_TYPE_STANDARD)
|
|
self.assertEqual(plan.status, CustomerPlan.FREE_TRIAL)
|
|
|
|
customer = get_customer_by_realm(user.realm)
|
|
assert customer is not None
|
|
result = self.client_billing_patch(
|
|
"/billing/plan",
|
|
{
|
|
"status": CustomerPlan.FREE_TRIAL,
|
|
"schedule": CustomerPlan.BILLING_SCHEDULE_MONTHLY,
|
|
},
|
|
)
|
|
self.assert_json_success(result)
|
|
plan.refresh_from_db()
|
|
self.assertEqual(plan.status, CustomerPlan.ENDED)
|
|
self.assertIsNone(plan.next_invoice_date)
|
|
|
|
new_plan = CustomerPlan.objects.get(
|
|
customer=customer,
|
|
automanage_licenses=True,
|
|
price_per_license=800,
|
|
fixed_price=None,
|
|
discount=None,
|
|
billing_cycle_anchor=self.now,
|
|
billing_schedule=CustomerPlan.BILLING_SCHEDULE_MONTHLY,
|
|
next_invoice_date=free_trial_end_date,
|
|
tier=CustomerPlan.TIER_CLOUD_STANDARD,
|
|
status=CustomerPlan.FREE_TRIAL,
|
|
charge_automatically=True,
|
|
)
|
|
ledger_entry = LicenseLedger.objects.get(
|
|
plan=new_plan,
|
|
is_renewal=True,
|
|
event_time=self.now,
|
|
licenses=self.seat_count,
|
|
licenses_at_next_renewal=self.seat_count,
|
|
)
|
|
self.assertEqual(new_plan.invoiced_through, ledger_entry)
|
|
|
|
realm_audit_log = RealmAuditLog.objects.filter(
|
|
event_type=AuditLogEventType.CUSTOMER_SWITCHED_FROM_ANNUAL_TO_MONTHLY_PLAN
|
|
).last()
|
|
assert realm_audit_log is not None
|
|
|
|
@mock_stripe()
|
|
def test_end_free_trial(self, *mocks: Mock) -> None:
|
|
user = self.example_user("hamlet")
|
|
self.login_user(user)
|
|
|
|
free_trial_end_date = self.now + timedelta(days=60)
|
|
with self.settings(CLOUD_FREE_TRIAL_DAYS=60):
|
|
with time_machine.travel(self.now, tick=False):
|
|
self.add_card_and_upgrade(user, schedule="annual")
|
|
|
|
plan = CustomerPlan.objects.get()
|
|
self.assertEqual(plan.next_invoice_date, free_trial_end_date)
|
|
self.assertEqual(get_realm("zulip").plan_type, Realm.PLAN_TYPE_STANDARD)
|
|
self.assertEqual(plan.status, CustomerPlan.FREE_TRIAL)
|
|
|
|
# Add some extra users before the realm is deactivated
|
|
billing_session = RealmBillingSession(user=user, realm=user.realm)
|
|
with patch("corporate.lib.stripe.get_latest_seat_count", return_value=21):
|
|
billing_session.update_license_ledger_if_needed(self.now)
|
|
|
|
last_ledger_entry = LicenseLedger.objects.order_by("id").last()
|
|
assert last_ledger_entry is not None
|
|
self.assertEqual(last_ledger_entry.licenses, 21)
|
|
self.assertEqual(last_ledger_entry.licenses_at_next_renewal, 21)
|
|
|
|
self.login_user(user)
|
|
|
|
with time_machine.travel(self.now, tick=False):
|
|
self.client_billing_patch(
|
|
"/billing/plan",
|
|
{"status": CustomerPlan.ENDED},
|
|
)
|
|
|
|
plan.refresh_from_db()
|
|
self.assertEqual(get_realm("zulip").plan_type, Realm.PLAN_TYPE_LIMITED)
|
|
self.assertEqual(plan.status, CustomerPlan.ENDED)
|
|
self.assertEqual(plan.invoiced_through, last_ledger_entry)
|
|
self.assertIsNone(plan.next_invoice_date)
|
|
|
|
self.login_user(user)
|
|
response = self.client_get("/billing/")
|
|
self.assertEqual(response.status_code, 302)
|
|
self.assertEqual("/plans/", response["Location"])
|
|
|
|
# The extra users added in the final month are not charged
|
|
with patch("corporate.lib.stripe.BillingSession.invoice_plan") as mocked:
|
|
invoice_plans_as_needed(self.next_month)
|
|
mocked.assert_not_called()
|
|
|
|
# The plan is not renewed after an year
|
|
with patch("corporate.lib.stripe.BillingSession.invoice_plan") as mocked:
|
|
invoice_plans_as_needed(self.next_year)
|
|
mocked.assert_not_called()
|
|
|
|
@mock_stripe()
|
|
def test_downgrade_at_end_of_free_trial(self, *mocks: Mock) -> None:
|
|
user = self.example_user("hamlet")
|
|
self.login_user(user)
|
|
|
|
free_trial_end_date = self.now + timedelta(days=60)
|
|
with self.settings(CLOUD_FREE_TRIAL_DAYS=60):
|
|
with time_machine.travel(self.now, tick=False):
|
|
self.add_card_and_upgrade(user, schedule="annual")
|
|
plan = get_current_plan_by_realm(user.realm)
|
|
assert plan is not None
|
|
self.assertEqual(plan.next_invoice_date, free_trial_end_date)
|
|
self.assertEqual(get_realm("zulip").plan_type, Realm.PLAN_TYPE_STANDARD)
|
|
self.assertEqual(plan.status, CustomerPlan.FREE_TRIAL)
|
|
self.assertEqual(plan.licenses(), self.seat_count)
|
|
self.assertEqual(plan.licenses_at_next_renewal(), self.seat_count)
|
|
|
|
# Schedule downgrade
|
|
with (
|
|
self.assertLogs("corporate.stripe", "INFO") as m,
|
|
time_machine.travel(self.now, tick=False),
|
|
):
|
|
response = self.client_billing_patch(
|
|
"/billing/plan",
|
|
{"status": CustomerPlan.DOWNGRADE_AT_END_OF_FREE_TRIAL},
|
|
)
|
|
stripe_customer_id = Customer.objects.get(realm=user.realm).id
|
|
new_plan = get_current_plan_by_realm(user.realm)
|
|
assert new_plan is not None
|
|
expected_log = f"INFO:corporate.stripe:Change plan status: Customer.id: {stripe_customer_id}, CustomerPlan.id: {new_plan.id}, status: {CustomerPlan.DOWNGRADE_AT_END_OF_FREE_TRIAL}"
|
|
self.assertEqual(m.output[0], expected_log)
|
|
self.assert_json_success(response)
|
|
plan.refresh_from_db()
|
|
self.assertEqual(plan.next_invoice_date, free_trial_end_date)
|
|
self.assertEqual(get_realm("zulip").plan_type, Realm.PLAN_TYPE_STANDARD)
|
|
self.assertEqual(plan.status, CustomerPlan.DOWNGRADE_AT_END_OF_FREE_TRIAL)
|
|
self.assertEqual(plan.licenses(), self.seat_count)
|
|
self.assertEqual(plan.licenses_at_next_renewal(), None)
|
|
|
|
with time_machine.travel(self.now, tick=False):
|
|
mock_customer = Mock(email=user.delivery_email)
|
|
mock_customer.invoice_settings.default_payment_method = Mock(
|
|
spec=stripe.PaymentMethod, type=Mock()
|
|
)
|
|
with patch("corporate.lib.stripe.stripe_get_customer", return_value=mock_customer):
|
|
response = self.client_get("/billing/")
|
|
self.assert_in_success_response(
|
|
[
|
|
"Your organization will be downgraded to <strong>Zulip Cloud Free</strong> at the end of the free trial",
|
|
"<strong>March 2, 2012</strong>",
|
|
],
|
|
response,
|
|
)
|
|
|
|
billing_session = RealmBillingSession(user=user, realm=user.realm)
|
|
# Verify that we still write LicenseLedger rows during the remaining
|
|
# part of the cycle
|
|
with patch("corporate.lib.stripe.get_latest_seat_count", return_value=20):
|
|
billing_session.update_license_ledger_if_needed(self.now)
|
|
self.assertEqual(
|
|
LicenseLedger.objects.order_by("-id")
|
|
.values_list("licenses", "licenses_at_next_renewal")
|
|
.first(),
|
|
(20, 20),
|
|
)
|
|
|
|
# Verify that we don't invoice them for the additional users during free trial.
|
|
mocked = self.setup_mocked_stripe(invoice_plans_as_needed, self.next_month)
|
|
mocked["InvoiceItem"].create.assert_not_called()
|
|
mocked["Invoice"].finalize_invoice.assert_not_called()
|
|
mocked["Invoice"].create.assert_not_called()
|
|
|
|
# Check that we downgrade properly if the cycle is over
|
|
with patch("corporate.lib.stripe.get_latest_seat_count", return_value=30):
|
|
billing_session.update_license_ledger_if_needed(free_trial_end_date)
|
|
plan = CustomerPlan.objects.first()
|
|
assert plan is not None
|
|
self.assertIsNone(plan.next_invoice_date)
|
|
self.assertEqual(plan.status, CustomerPlan.ENDED)
|
|
self.assertEqual(get_realm("zulip").plan_type, Realm.PLAN_TYPE_LIMITED)
|
|
self.assertEqual(
|
|
LicenseLedger.objects.order_by("-id")
|
|
.values_list("licenses", "licenses_at_next_renewal")
|
|
.first(),
|
|
(20, 20),
|
|
)
|
|
|
|
# Verify that we don't write LicenseLedger rows once we've downgraded
|
|
with patch("corporate.lib.stripe.get_latest_seat_count", return_value=40):
|
|
billing_session.update_license_ledger_if_needed(self.next_year)
|
|
self.assertEqual(
|
|
LicenseLedger.objects.order_by("-id")
|
|
.values_list("licenses", "licenses_at_next_renewal")
|
|
.first(),
|
|
(20, 20),
|
|
)
|
|
|
|
self.login_user(user)
|
|
response = self.client_get("/billing/")
|
|
self.assertEqual(response.status_code, 302)
|
|
self.assertEqual("/plans/", response["Location"])
|
|
|
|
# The extra users added in the final month are not charged
|
|
with patch("corporate.lib.stripe.BillingSession.invoice_plan") as mocked:
|
|
invoice_plans_as_needed(self.next_month)
|
|
mocked.assert_not_called()
|
|
|
|
# The plan is not renewed after an year
|
|
with patch("corporate.lib.stripe.BillingSession.invoice_plan") as mocked:
|
|
invoice_plans_as_needed(self.next_year)
|
|
mocked.assert_not_called()
|
|
|
|
@mock_stripe()
|
|
def test_cancel_downgrade_at_end_of_free_trial(self, *mocks: Mock) -> None:
|
|
user = self.example_user("hamlet")
|
|
self.login_user(user)
|
|
|
|
free_trial_end_date = self.now + timedelta(days=60)
|
|
with self.settings(CLOUD_FREE_TRIAL_DAYS=60):
|
|
with time_machine.travel(self.now, tick=False):
|
|
self.add_card_and_upgrade(user, schedule="annual")
|
|
plan = get_current_plan_by_realm(user.realm)
|
|
assert plan is not None
|
|
self.assertEqual(plan.next_invoice_date, free_trial_end_date)
|
|
self.assertEqual(get_realm("zulip").plan_type, Realm.PLAN_TYPE_STANDARD)
|
|
self.assertEqual(plan.status, CustomerPlan.FREE_TRIAL)
|
|
self.assertEqual(plan.licenses(), self.seat_count)
|
|
self.assertEqual(plan.licenses_at_next_renewal(), self.seat_count)
|
|
|
|
# Schedule downgrade
|
|
with (
|
|
self.assertLogs("corporate.stripe", "INFO") as m,
|
|
time_machine.travel(self.now, tick=False),
|
|
):
|
|
response = self.client_billing_patch(
|
|
"/billing/plan",
|
|
{"status": CustomerPlan.DOWNGRADE_AT_END_OF_FREE_TRIAL},
|
|
)
|
|
stripe_customer_id = Customer.objects.get(realm=user.realm).id
|
|
new_plan = get_current_plan_by_realm(user.realm)
|
|
assert new_plan is not None
|
|
expected_log = f"INFO:corporate.stripe:Change plan status: Customer.id: {stripe_customer_id}, CustomerPlan.id: {new_plan.id}, status: {CustomerPlan.DOWNGRADE_AT_END_OF_FREE_TRIAL}"
|
|
self.assertEqual(m.output[0], expected_log)
|
|
self.assert_json_success(response)
|
|
plan.refresh_from_db()
|
|
self.assertEqual(plan.next_invoice_date, free_trial_end_date)
|
|
self.assertEqual(get_realm("zulip").plan_type, Realm.PLAN_TYPE_STANDARD)
|
|
self.assertEqual(plan.status, CustomerPlan.DOWNGRADE_AT_END_OF_FREE_TRIAL)
|
|
self.assertEqual(plan.licenses(), self.seat_count)
|
|
self.assertEqual(plan.licenses_at_next_renewal(), None)
|
|
|
|
# Cancel downgrade
|
|
with (
|
|
self.assertLogs("corporate.stripe", "INFO") as m,
|
|
time_machine.travel(self.now, tick=False),
|
|
):
|
|
response = self.client_billing_patch(
|
|
"/billing/plan",
|
|
{"status": CustomerPlan.FREE_TRIAL},
|
|
)
|
|
stripe_customer_id = Customer.objects.get(realm=user.realm).id
|
|
new_plan = get_current_plan_by_realm(user.realm)
|
|
assert new_plan is not None
|
|
expected_log = f"INFO:corporate.stripe:Change plan status: Customer.id: {stripe_customer_id}, CustomerPlan.id: {new_plan.id}, status: {CustomerPlan.FREE_TRIAL}"
|
|
self.assertEqual(m.output[0], expected_log)
|
|
self.assert_json_success(response)
|
|
plan.refresh_from_db()
|
|
self.assertEqual(plan.next_invoice_date, free_trial_end_date)
|
|
self.assertEqual(get_realm("zulip").plan_type, Realm.PLAN_TYPE_STANDARD)
|
|
self.assertEqual(plan.status, CustomerPlan.FREE_TRIAL)
|
|
self.assertEqual(plan.licenses(), self.seat_count)
|
|
self.assertEqual(plan.licenses_at_next_renewal(), self.seat_count)
|
|
|
|
def test_reupgrade_by_billing_admin_after_downgrade(self) -> None:
|
|
user = self.example_user("hamlet")
|
|
|
|
with time_machine.travel(self.now, tick=False):
|
|
self.local_upgrade(
|
|
self.seat_count, True, CustomerPlan.BILLING_SCHEDULE_ANNUAL, True, False
|
|
)
|
|
|
|
self.login_user(user)
|
|
with self.assertLogs("corporate.stripe", "INFO") as m:
|
|
with time_machine.travel(self.now, tick=False):
|
|
self.client_billing_patch(
|
|
"/billing/plan",
|
|
{"status": CustomerPlan.DOWNGRADE_AT_END_OF_CYCLE},
|
|
)
|
|
stripe_customer_id = Customer.objects.get(realm=user.realm).id
|
|
new_plan = get_current_plan_by_realm(user.realm)
|
|
assert new_plan is not None
|
|
expected_log = f"INFO:corporate.stripe:Change plan status: Customer.id: {stripe_customer_id}, CustomerPlan.id: {new_plan.id}, status: {CustomerPlan.DOWNGRADE_AT_END_OF_CYCLE}"
|
|
self.assertEqual(m.output[0], expected_log)
|
|
|
|
with (
|
|
self.assertRaises(BillingError) as context,
|
|
self.assertLogs("corporate.stripe", "WARNING") as m,
|
|
time_machine.travel(self.now, tick=False),
|
|
):
|
|
self.local_upgrade(
|
|
self.seat_count, True, CustomerPlan.BILLING_SCHEDULE_ANNUAL, True, False
|
|
)
|
|
self.assertEqual(
|
|
m.output[0],
|
|
"WARNING:corporate.stripe:Upgrade of <Realm: zulip 2> (with stripe_customer_id: cus_123) failed because of existing active plan.",
|
|
)
|
|
self.assertEqual(
|
|
context.exception.error_description, "subscribing with existing subscription"
|
|
)
|
|
|
|
# Fast forward the next_invoice_date to next year.
|
|
new_plan.next_invoice_date = self.next_year
|
|
new_plan.save(update_fields=["next_invoice_date"])
|
|
invoice_plans_as_needed(self.next_year)
|
|
|
|
with time_machine.travel(self.next_year, tick=False):
|
|
response = self.client_get("/billing/")
|
|
self.assertEqual(response.status_code, 302)
|
|
self.assertEqual("/plans/", response["Location"])
|
|
|
|
with time_machine.travel(self.next_year, tick=False):
|
|
self.local_upgrade(
|
|
self.seat_count, True, CustomerPlan.BILLING_SCHEDULE_ANNUAL, True, False
|
|
)
|
|
|
|
self.assertEqual(Customer.objects.count(), 1)
|
|
self.assertEqual(CustomerPlan.objects.count(), 2)
|
|
|
|
current_plan = CustomerPlan.objects.all().order_by("id").last()
|
|
assert current_plan is not None
|
|
next_invoice_date = add_months(self.next_year, 1)
|
|
self.assertEqual(current_plan.next_invoice_date, next_invoice_date)
|
|
self.assertEqual(get_realm("zulip").plan_type, Realm.PLAN_TYPE_STANDARD)
|
|
self.assertEqual(current_plan.status, CustomerPlan.ACTIVE)
|
|
|
|
old_plan = CustomerPlan.objects.all().order_by("id").first()
|
|
assert old_plan is not None
|
|
self.assertEqual(old_plan.next_invoice_date, None)
|
|
self.assertEqual(old_plan.status, CustomerPlan.ENDED)
|
|
|
|
@mock_stripe()
|
|
def test_update_licenses_of_manual_plan_from_billing_page(self, *mocks: Mock) -> None:
|
|
user = self.example_user("hamlet")
|
|
self.login_user(user)
|
|
|
|
with time_machine.travel(self.now, tick=False):
|
|
self.upgrade(invoice=True, licenses=100)
|
|
|
|
with time_machine.travel(self.now, tick=False):
|
|
result = self.client_billing_patch("/billing/plan", {"licenses": 100})
|
|
self.assert_json_error_contains(
|
|
result, "Your plan is already on 100 licenses in the current billing period."
|
|
)
|
|
|
|
with time_machine.travel(self.now, tick=False):
|
|
result = self.client_billing_patch(
|
|
"/billing/plan",
|
|
{"licenses_at_next_renewal": 100},
|
|
)
|
|
self.assert_json_error_contains(
|
|
result, "Your plan is already scheduled to renew with 100 licenses."
|
|
)
|
|
|
|
with time_machine.travel(self.now, tick=False):
|
|
result = self.client_billing_patch("/billing/plan", {"licenses": 50})
|
|
self.assert_json_error_contains(
|
|
result, "You cannot decrease the licenses in the current billing period."
|
|
)
|
|
|
|
with time_machine.travel(self.now, tick=False):
|
|
result = self.client_billing_patch(
|
|
"/billing/plan",
|
|
{"licenses_at_next_renewal": 25},
|
|
)
|
|
self.assert_json_error_contains(
|
|
result,
|
|
"You must purchase licenses for all active users in your organization (minimum 30).",
|
|
)
|
|
|
|
with time_machine.travel(self.now, tick=False):
|
|
result = self.client_billing_patch("/billing/plan", {"licenses": 2000})
|
|
self.assert_json_error_contains(
|
|
result, "Invoices with more than 1000 licenses can't be processed from this page."
|
|
)
|
|
|
|
with time_machine.travel(self.now, tick=False):
|
|
result = self.client_billing_patch("/billing/plan", {"licenses": 150})
|
|
self.assert_json_success(result)
|
|
invoice_plans_as_needed(self.next_year)
|
|
stripe_customer = stripe_get_customer(
|
|
assert_is_not_none(Customer.objects.get(realm=user.realm).stripe_customer_id)
|
|
)
|
|
|
|
[renewal_invoice, additional_licenses_invoice, _old_renewal_invoice] = iter(
|
|
stripe.Invoice.list(customer=stripe_customer.id)
|
|
)
|
|
|
|
invoice_params = {
|
|
"amount_due": 8000 * 150,
|
|
"amount_paid": 0,
|
|
"attempt_count": 0,
|
|
"auto_advance": True,
|
|
"collection_method": "send_invoice",
|
|
"statement_descriptor": "Zulip Cloud Standard",
|
|
"status": "open",
|
|
"total": 8000 * 150,
|
|
}
|
|
for key, value in invoice_params.items():
|
|
self.assertEqual(renewal_invoice.get(key), value)
|
|
[renewal_item] = iter(renewal_invoice.lines)
|
|
|
|
line_item_params = {
|
|
"amount": 8000 * 150,
|
|
"description": "Zulip Cloud Standard - renewal",
|
|
"discountable": False,
|
|
"period": {
|
|
"end": datetime_to_timestamp(self.next_year + timedelta(days=365)),
|
|
"start": datetime_to_timestamp(self.next_year),
|
|
},
|
|
"plan": None,
|
|
"proration": False,
|
|
"quantity": 150,
|
|
}
|
|
for key, value in line_item_params.items():
|
|
self.assertEqual(renewal_item.get(key), value)
|
|
|
|
invoice_params = {
|
|
"amount_due": 8000 * 50,
|
|
"amount_paid": 0,
|
|
"attempt_count": 0,
|
|
"auto_advance": True,
|
|
"collection_method": "send_invoice",
|
|
"statement_descriptor": "Zulip Cloud Standard",
|
|
"status": "open",
|
|
"total": 8000 * 50,
|
|
}
|
|
for key, value in invoice_params.items():
|
|
self.assertEqual(additional_licenses_invoice.get(key), value)
|
|
[extra_license_item] = iter(additional_licenses_invoice.lines)
|
|
|
|
line_item_params = {
|
|
"amount": 8000 * 50,
|
|
"description": "Additional license (Jan 2, 2012 - Jan 2, 2013)",
|
|
"discountable": False,
|
|
"period": {
|
|
"end": datetime_to_timestamp(self.next_year),
|
|
"start": datetime_to_timestamp(self.now),
|
|
},
|
|
"plan": None,
|
|
"proration": False,
|
|
"quantity": 50,
|
|
}
|
|
for key, value in line_item_params.items():
|
|
self.assertEqual(extra_license_item.get(key), value)
|
|
|
|
with time_machine.travel(self.next_year, tick=False):
|
|
result = self.client_billing_patch(
|
|
"/billing/plan",
|
|
{"licenses_at_next_renewal": 120},
|
|
)
|
|
self.assert_json_success(result)
|
|
invoice_plans_as_needed(self.next_year + timedelta(days=365))
|
|
[renewal_invoice, _, _, _] = iter(stripe.Invoice.list(customer=stripe_customer.id))
|
|
|
|
invoice_params = {
|
|
"amount_due": 8000 * 120,
|
|
"amount_paid": 0,
|
|
"attempt_count": 0,
|
|
"auto_advance": True,
|
|
"collection_method": "send_invoice",
|
|
"statement_descriptor": "Zulip Cloud Standard",
|
|
"status": "open",
|
|
"total": 8000 * 120,
|
|
}
|
|
for key, value in invoice_params.items():
|
|
self.assertEqual(renewal_invoice.get(key), value)
|
|
[renewal_item] = iter(renewal_invoice.lines)
|
|
|
|
line_item_params = {
|
|
"amount": 8000 * 120,
|
|
"description": "Zulip Cloud Standard - renewal",
|
|
"discountable": False,
|
|
"period": {
|
|
"end": datetime_to_timestamp(self.next_year + timedelta(days=2 * 365)),
|
|
"start": datetime_to_timestamp(self.next_year + timedelta(days=365)),
|
|
},
|
|
"plan": None,
|
|
"proration": False,
|
|
"quantity": 120,
|
|
}
|
|
for key, value in line_item_params.items():
|
|
self.assertEqual(renewal_item.get(key), value)
|
|
|
|
def test_update_licenses_of_manual_plan_from_billing_page_exempt_from_license_number_check(
|
|
self,
|
|
) -> None:
|
|
"""
|
|
Verifies that an organization exempt from the license number check can reduce their number
|
|
of licenses.
|
|
"""
|
|
user = self.example_user("hamlet")
|
|
self.login_user(user)
|
|
|
|
customer = Customer.objects.get_or_create(realm=user.realm)[0]
|
|
customer.exempt_from_license_number_check = True
|
|
customer.save()
|
|
|
|
with time_machine.travel(self.now, tick=False):
|
|
self.local_upgrade(100, False, CustomerPlan.BILLING_SCHEDULE_ANNUAL, True, False)
|
|
|
|
with time_machine.travel(self.now, tick=False):
|
|
result = self.client_billing_patch(
|
|
"/billing/plan",
|
|
{"licenses_at_next_renewal": get_latest_seat_count(user.realm) - 2},
|
|
)
|
|
|
|
self.assert_json_success(result)
|
|
latest_license_ledger = LicenseLedger.objects.last()
|
|
assert latest_license_ledger is not None
|
|
self.assertEqual(
|
|
latest_license_ledger.licenses_at_next_renewal, get_latest_seat_count(user.realm) - 2
|
|
)
|
|
|
|
def test_upgrade_exempt_from_license_number_check_realm_less_licenses_than_seat_count(
|
|
self,
|
|
) -> None:
|
|
"""
|
|
Verifies that an organization exempt from the license number check can upgrade their plan,
|
|
specifying a number of licenses less than their current number of licenses and be charged
|
|
for the number of licenses specified. Tests against a former bug, where the organization
|
|
was charged for the current seat count, despite specifying a lower number of licenses.
|
|
"""
|
|
user = self.example_user("hamlet")
|
|
self.login_user(user)
|
|
|
|
customer = Customer.objects.get_or_create(realm=user.realm)[0]
|
|
customer.exempt_from_license_number_check = True
|
|
customer.save()
|
|
|
|
reduced_seat_count = get_latest_seat_count(user.realm) - 2
|
|
|
|
with time_machine.travel(self.now, tick=False):
|
|
self.local_upgrade(
|
|
reduced_seat_count, False, CustomerPlan.BILLING_SCHEDULE_ANNUAL, True, False
|
|
)
|
|
|
|
latest_license_ledger = LicenseLedger.objects.last()
|
|
assert latest_license_ledger is not None
|
|
self.assertEqual(latest_license_ledger.licenses_at_next_renewal, reduced_seat_count)
|
|
self.assertEqual(latest_license_ledger.licenses, reduced_seat_count)
|
|
|
|
def test_update_licenses_of_automatic_plan_from_billing_page(self) -> None:
|
|
user = self.example_user("hamlet")
|
|
self.login_user(user)
|
|
|
|
with time_machine.travel(self.now, tick=False):
|
|
self.local_upgrade(
|
|
self.seat_count, True, CustomerPlan.BILLING_SCHEDULE_ANNUAL, True, False
|
|
)
|
|
|
|
with time_machine.travel(self.now, tick=False):
|
|
result = self.client_billing_patch("/billing/plan", {"licenses": 100})
|
|
self.assert_json_error_contains(result, "Your plan is on automatic license management.")
|
|
|
|
with time_machine.travel(self.now, tick=False):
|
|
result = self.client_billing_patch(
|
|
"/billing/plan",
|
|
{"licenses_at_next_renewal": 100},
|
|
)
|
|
self.assert_json_error_contains(result, "Your plan is on automatic license management.")
|
|
|
|
def test_update_plan_with_invalid_status(self) -> None:
|
|
with time_machine.travel(self.now, tick=False):
|
|
self.local_upgrade(
|
|
self.seat_count, True, CustomerPlan.BILLING_SCHEDULE_ANNUAL, True, False
|
|
)
|
|
self.login_user(self.example_user("hamlet"))
|
|
|
|
response = self.client_billing_patch(
|
|
"/billing/plan",
|
|
{"status": CustomerPlan.NEVER_STARTED},
|
|
)
|
|
self.assert_json_error_contains(response, "Invalid status")
|
|
|
|
def test_update_plan_without_any_params(self) -> None:
|
|
with time_machine.travel(self.now, tick=False):
|
|
self.local_upgrade(
|
|
self.seat_count, True, CustomerPlan.BILLING_SCHEDULE_ANNUAL, True, False
|
|
)
|
|
|
|
self.login_user(self.example_user("hamlet"))
|
|
with time_machine.travel(self.now, tick=False):
|
|
response = self.client_billing_patch("/billing/plan", {})
|
|
self.assert_json_error_contains(response, "Nothing to change")
|
|
|
|
def test_update_plan_that_which_is_due_for_expiry(self) -> None:
|
|
with time_machine.travel(self.now, tick=False):
|
|
self.local_upgrade(
|
|
self.seat_count, True, CustomerPlan.BILLING_SCHEDULE_ANNUAL, True, False
|
|
)
|
|
|
|
self.login_user(self.example_user("hamlet"))
|
|
with (
|
|
self.assertLogs("corporate.stripe", "INFO") as m,
|
|
time_machine.travel(self.now, tick=False),
|
|
):
|
|
result = self.client_billing_patch(
|
|
"/billing/plan",
|
|
{"status": CustomerPlan.DOWNGRADE_AT_END_OF_CYCLE},
|
|
)
|
|
self.assert_json_success(result)
|
|
self.assertRegex(
|
|
m.output[0],
|
|
r"INFO:corporate.stripe:Change plan status: Customer.id: \d*, CustomerPlan.id: \d*, status: 2",
|
|
)
|
|
|
|
with time_machine.travel(self.next_year, tick=False):
|
|
result = self.client_billing_patch(
|
|
"/billing/plan",
|
|
{"status": CustomerPlan.ACTIVE},
|
|
)
|
|
self.assert_json_error_contains(
|
|
result, "Unable to update the plan. The plan has ended."
|
|
)
|
|
|
|
def test_update_plan_that_which_is_due_for_replacement(self) -> None:
|
|
with time_machine.travel(self.now, tick=False):
|
|
self.local_upgrade(
|
|
self.seat_count, True, CustomerPlan.BILLING_SCHEDULE_MONTHLY, True, False
|
|
)
|
|
|
|
self.login_user(self.example_user("hamlet"))
|
|
with (
|
|
self.assertLogs("corporate.stripe", "INFO") as m,
|
|
time_machine.travel(self.now, tick=False),
|
|
):
|
|
result = self.client_billing_patch(
|
|
"/billing/plan",
|
|
{"status": CustomerPlan.SWITCH_TO_ANNUAL_AT_END_OF_CYCLE},
|
|
)
|
|
self.assert_json_success(result)
|
|
self.assertRegex(
|
|
m.output[0],
|
|
r"INFO:corporate.stripe:Change plan status: Customer.id: \d*, CustomerPlan.id: \d*, status: 4",
|
|
)
|
|
|
|
with time_machine.travel(self.next_month, tick=False):
|
|
result = self.client_billing_patch("/billing/plan", {})
|
|
self.assert_json_error_contains(
|
|
result,
|
|
"Unable to update the plan. The plan has been expired and replaced with a new plan.",
|
|
)
|
|
|
|
@patch("corporate.lib.stripe.billing_logger.info")
|
|
def test_deactivate_realm(self, mock_: Mock) -> None:
|
|
user = self.example_user("hamlet")
|
|
with time_machine.travel(self.now, tick=False):
|
|
self.local_upgrade(
|
|
self.seat_count, True, CustomerPlan.BILLING_SCHEDULE_ANNUAL, True, False
|
|
)
|
|
|
|
plan = CustomerPlan.objects.get()
|
|
self.assertEqual(plan.next_invoice_date, self.next_month)
|
|
self.assertEqual(get_realm("zulip").plan_type, Realm.PLAN_TYPE_STANDARD)
|
|
self.assertEqual(plan.status, CustomerPlan.ACTIVE)
|
|
|
|
# Add some extra users before the realm is deactivated
|
|
billing_session = RealmBillingSession(user=user, realm=user.realm)
|
|
with patch("corporate.lib.stripe.get_latest_seat_count", return_value=20):
|
|
billing_session.update_license_ledger_if_needed(self.now)
|
|
|
|
last_ledger_entry = LicenseLedger.objects.order_by("id").last()
|
|
assert last_ledger_entry is not None
|
|
self.assertEqual(last_ledger_entry.licenses, 20)
|
|
self.assertEqual(last_ledger_entry.licenses_at_next_renewal, 20)
|
|
|
|
do_deactivate_realm(
|
|
get_realm("zulip"),
|
|
acting_user=None,
|
|
deactivation_reason="owner_request",
|
|
email_owners=False,
|
|
)
|
|
|
|
plan.refresh_from_db()
|
|
self.assertTrue(get_realm("zulip").deactivated)
|
|
self.assertEqual(get_realm("zulip").plan_type, Realm.PLAN_TYPE_LIMITED)
|
|
self.assertEqual(plan.status, CustomerPlan.ENDED)
|
|
self.assertEqual(plan.invoiced_through, last_ledger_entry)
|
|
self.assertIsNone(plan.next_invoice_date)
|
|
|
|
do_reactivate_realm(get_realm("zulip"))
|
|
|
|
self.login_user(user)
|
|
response = self.client_get("/billing/")
|
|
self.assertEqual(response.status_code, 302)
|
|
self.assertEqual("/plans/", response["Location"])
|
|
|
|
# The extra users added in the final month are not charged
|
|
with patch("corporate.lib.stripe.BillingSession.invoice_plan") as mocked:
|
|
invoice_plans_as_needed(self.next_month)
|
|
mocked.assert_not_called()
|
|
|
|
# The plan is not renewed after an year
|
|
with patch("corporate.lib.stripe.BillingSession.invoice_plan") as mocked:
|
|
invoice_plans_as_needed(self.next_year)
|
|
mocked.assert_not_called()
|
|
|
|
def test_reupgrade_by_billing_admin_after_realm_deactivation(self) -> None:
|
|
user = self.example_user("hamlet")
|
|
|
|
with time_machine.travel(self.now, tick=False):
|
|
self.local_upgrade(
|
|
self.seat_count, True, CustomerPlan.BILLING_SCHEDULE_ANNUAL, True, False
|
|
)
|
|
|
|
do_deactivate_realm(
|
|
get_realm("zulip"),
|
|
acting_user=None,
|
|
deactivation_reason="owner_request",
|
|
email_owners=False,
|
|
)
|
|
self.assertTrue(get_realm("zulip").deactivated)
|
|
do_reactivate_realm(get_realm("zulip"))
|
|
|
|
self.login_user(user)
|
|
response = self.client_get("/billing/")
|
|
self.assertEqual(response.status_code, 302)
|
|
self.assertEqual("/plans/", response["Location"])
|
|
|
|
with time_machine.travel(self.now, tick=False):
|
|
self.local_upgrade(
|
|
self.seat_count, True, CustomerPlan.BILLING_SCHEDULE_ANNUAL, True, False
|
|
)
|
|
|
|
self.assertEqual(Customer.objects.count(), 1)
|
|
|
|
self.assertEqual(CustomerPlan.objects.count(), 2)
|
|
|
|
current_plan = CustomerPlan.objects.all().order_by("id").last()
|
|
assert current_plan is not None
|
|
self.assertEqual(current_plan.next_invoice_date, self.next_month)
|
|
self.assertEqual(get_realm("zulip").plan_type, Realm.PLAN_TYPE_STANDARD)
|
|
self.assertEqual(current_plan.status, CustomerPlan.ACTIVE)
|
|
|
|
old_plan = CustomerPlan.objects.all().order_by("id").first()
|
|
assert old_plan is not None
|
|
self.assertEqual(old_plan.next_invoice_date, None)
|
|
self.assertEqual(old_plan.status, CustomerPlan.ENDED)
|
|
|
|
@mock_stripe()
|
|
def test_void_all_open_invoices(self, *mock: Mock) -> None:
|
|
iago = self.example_user("iago")
|
|
king = self.lear_user("king")
|
|
|
|
voided_invoice_count = RealmBillingSession(
|
|
user=None, realm=iago.realm
|
|
).void_all_open_invoices()
|
|
self.assertEqual(voided_invoice_count, 0)
|
|
|
|
zulip_customer = RealmBillingSession(iago).update_or_create_stripe_customer()
|
|
lear_customer = RealmBillingSession(king).update_or_create_stripe_customer()
|
|
|
|
assert zulip_customer.stripe_customer_id
|
|
stripe.InvoiceItem.create(
|
|
currency="usd",
|
|
customer=zulip_customer.stripe_customer_id,
|
|
description="Zulip Cloud Standard upgrade",
|
|
discountable=False,
|
|
unit_amount=800,
|
|
quantity=8,
|
|
)
|
|
stripe_invoice = stripe.Invoice.create(
|
|
auto_advance=True,
|
|
collection_method="send_invoice",
|
|
customer=zulip_customer.stripe_customer_id,
|
|
days_until_due=30,
|
|
statement_descriptor="Zulip Cloud Standard",
|
|
)
|
|
stripe.Invoice.finalize_invoice(stripe_invoice)
|
|
|
|
assert lear_customer.stripe_customer_id
|
|
stripe.InvoiceItem.create(
|
|
currency="usd",
|
|
customer=lear_customer.stripe_customer_id,
|
|
description="Zulip Cloud Standard upgrade",
|
|
discountable=False,
|
|
unit_amount=800,
|
|
quantity=8,
|
|
)
|
|
stripe_invoice = stripe.Invoice.create(
|
|
auto_advance=True,
|
|
collection_method="send_invoice",
|
|
customer=lear_customer.stripe_customer_id,
|
|
days_until_due=30,
|
|
statement_descriptor="Zulip Cloud Standard",
|
|
)
|
|
stripe.Invoice.finalize_invoice(stripe_invoice)
|
|
|
|
voided_invoice_count = RealmBillingSession(
|
|
user=None, realm=iago.realm
|
|
).void_all_open_invoices()
|
|
self.assertEqual(voided_invoice_count, 1)
|
|
invoices = stripe.Invoice.list(customer=zulip_customer.stripe_customer_id)
|
|
self.assert_length(invoices, 1)
|
|
for invoice in invoices:
|
|
self.assertEqual(invoice.status, "void")
|
|
|
|
lear_stripe_customer_id = lear_customer.stripe_customer_id
|
|
lear_customer.stripe_customer_id = None
|
|
lear_customer.save(update_fields=["stripe_customer_id"])
|
|
voided_invoice_count = RealmBillingSession(
|
|
user=None, realm=king.realm
|
|
).void_all_open_invoices()
|
|
self.assertEqual(voided_invoice_count, 0)
|
|
|
|
lear_customer.stripe_customer_id = lear_stripe_customer_id
|
|
lear_customer.save(update_fields=["stripe_customer_id"])
|
|
voided_invoice_count = RealmBillingSession(
|
|
user=None, realm=king.realm
|
|
).void_all_open_invoices()
|
|
self.assertEqual(voided_invoice_count, 1)
|
|
invoices = stripe.Invoice.list(customer=lear_customer.stripe_customer_id)
|
|
self.assert_length(invoices, 1)
|
|
for invoice in invoices:
|
|
self.assertEqual(invoice.status, "void")
|
|
|
|
def create_invoices(self, customer: Customer, num_invoices: int) -> list[stripe.Invoice]:
|
|
invoices = []
|
|
assert customer.stripe_customer_id is not None
|
|
for _ in range(num_invoices):
|
|
invoice = stripe.Invoice.create(
|
|
auto_advance=True,
|
|
collection_method="send_invoice",
|
|
customer=customer.stripe_customer_id,
|
|
days_until_due=DEFAULT_INVOICE_DAYS_UNTIL_DUE,
|
|
statement_descriptor="Zulip Cloud Standard",
|
|
)
|
|
assert invoice.id is not None
|
|
stripe.InvoiceItem.create(
|
|
invoice=invoice.id,
|
|
amount=10000,
|
|
currency="usd",
|
|
customer=customer.stripe_customer_id,
|
|
description="Zulip Cloud Standard",
|
|
discountable=False,
|
|
)
|
|
|
|
stripe.Invoice.finalize_invoice(invoice)
|
|
invoices.append(invoice)
|
|
return invoices
|
|
|
|
@mock_stripe()
|
|
def test_downgrade_small_realms_behind_on_payments_as_needed(self, *mock: Mock) -> None:
|
|
test_realm_count = 0
|
|
|
|
def create_realm(
|
|
users_to_create: int,
|
|
create_stripe_customer: bool,
|
|
create_plan: bool,
|
|
num_invoices: int | None = None,
|
|
) -> tuple[Realm, CustomerPlan | None, list[stripe.Invoice]]:
|
|
nonlocal test_realm_count
|
|
test_realm_count += 1
|
|
realm_string_id = "test-realm-" + str(test_realm_count)
|
|
realm = do_create_realm(
|
|
string_id=realm_string_id,
|
|
name=realm_string_id,
|
|
plan_type=Realm.PLAN_TYPE_SELF_HOSTED,
|
|
)
|
|
users = []
|
|
for i in range(users_to_create):
|
|
user = UserProfile.objects.create(
|
|
delivery_email=f"user-{i}-{realm_string_id}@zulip.com",
|
|
email=f"user-{i}-{realm_string_id}@zulip.com",
|
|
realm=realm,
|
|
)
|
|
users.append(user)
|
|
|
|
user = users[0]
|
|
user.role = UserProfile.ROLE_REALM_OWNER
|
|
user.save(update_fields=["role"])
|
|
|
|
customer = None
|
|
if create_stripe_customer:
|
|
billing_session = RealmBillingSession(users[0])
|
|
customer = billing_session.create_stripe_customer()
|
|
plan = None
|
|
if create_plan:
|
|
plan, _ = self.subscribe_realm_to_monthly_plan_on_manual_license_management(
|
|
realm, users_to_create, users_to_create
|
|
)
|
|
invoices = []
|
|
if num_invoices is not None:
|
|
assert customer is not None
|
|
invoices = self.create_invoices(customer, num_invoices)
|
|
return realm, plan, invoices
|
|
|
|
@dataclass
|
|
class Row:
|
|
realm: Realm
|
|
expected_plan_type: int
|
|
plan: CustomerPlan | None
|
|
expected_plan_status: int | None
|
|
expected_invoice_count: int
|
|
email_expected_to_be_sent: bool
|
|
|
|
rows: list[Row] = []
|
|
|
|
# no stripe customer ID (excluded from query)
|
|
realm, _, _ = create_realm(
|
|
users_to_create=1, create_stripe_customer=False, create_plan=False
|
|
)
|
|
billing_session = RealmBillingSession(
|
|
user=self.example_user("iago"), realm=realm, support_session=True
|
|
)
|
|
billing_session.set_required_plan_tier(CustomerPlan.TIER_CLOUD_STANDARD)
|
|
billing_session.attach_discount_to_customer(640, 6400)
|
|
rows.append(Row(realm, Realm.PLAN_TYPE_SELF_HOSTED, None, None, 0, False))
|
|
|
|
# no active paid plan or invoices (no action)
|
|
realm, _, _ = create_realm(
|
|
users_to_create=1, create_stripe_customer=True, create_plan=False
|
|
)
|
|
rows.append(Row(realm, Realm.PLAN_TYPE_SELF_HOSTED, None, None, 0, False))
|
|
|
|
# no active plan, one unpaid invoice (will be voided, no downgrade or email)
|
|
realm, _, _ = create_realm(
|
|
users_to_create=1, create_stripe_customer=True, create_plan=False, num_invoices=1
|
|
)
|
|
rows.append(Row(realm, Realm.PLAN_TYPE_SELF_HOSTED, None, None, 0, False))
|
|
|
|
# active plan, no invoices (no action)
|
|
realm, plan, _ = create_realm(
|
|
users_to_create=1, create_stripe_customer=True, create_plan=True
|
|
)
|
|
rows.append(Row(realm, Realm.PLAN_TYPE_STANDARD, plan, CustomerPlan.ACTIVE, 0, False))
|
|
|
|
# active plan, only one unpaid invoice (not downgraded or voided)
|
|
realm, plan, _ = create_realm(
|
|
users_to_create=1, create_stripe_customer=True, create_plan=True, num_invoices=1
|
|
)
|
|
rows.append(Row(realm, Realm.PLAN_TYPE_STANDARD, plan, CustomerPlan.ACTIVE, 1, False))
|
|
|
|
# active plan, two unpaid invoices (will be downgraded, voided and emailed)
|
|
realm, plan, _ = create_realm(
|
|
users_to_create=3, create_stripe_customer=True, create_plan=True, num_invoices=2
|
|
)
|
|
rows.append(Row(realm, Realm.PLAN_TYPE_LIMITED, plan, CustomerPlan.ENDED, 0, True))
|
|
|
|
# active plan, two paid invoices (not downgraded)
|
|
realm, plan, invoices = create_realm(
|
|
users_to_create=1, create_stripe_customer=True, create_plan=True, num_invoices=2
|
|
)
|
|
for invoice in invoices:
|
|
stripe.Invoice.pay(invoice, paid_out_of_band=True)
|
|
rows.append(Row(realm, Realm.PLAN_TYPE_STANDARD, plan, CustomerPlan.ACTIVE, 0, False))
|
|
|
|
# not a small realm, two unpaid invoices (not downgraded or voided)
|
|
realm, plan, _ = create_realm(
|
|
users_to_create=20, create_stripe_customer=True, create_plan=True, num_invoices=2
|
|
)
|
|
rows.append(Row(realm, Realm.PLAN_TYPE_STANDARD, plan, CustomerPlan.ACTIVE, 2, False))
|
|
|
|
# Customer objects without a realm should be excluded from query.
|
|
remote_server = RemoteZulipServer.objects.create(
|
|
uuid=str(uuid.uuid4()),
|
|
api_key="magic_secret_api_key",
|
|
hostname="demo.example.com",
|
|
contact_email="email@example.com",
|
|
)
|
|
Customer.objects.create(remote_server=remote_server, stripe_customer_id="cus_xxx")
|
|
|
|
downgrade_small_realms_behind_on_payments_as_needed()
|
|
|
|
from django.core.mail import outbox
|
|
|
|
for row in rows:
|
|
row.realm.refresh_from_db()
|
|
self.assertEqual(row.realm.plan_type, row.expected_plan_type)
|
|
if row.plan is not None:
|
|
row.plan.refresh_from_db()
|
|
self.assertEqual(row.plan.status, row.expected_plan_status)
|
|
customer = get_customer_by_realm(row.realm)
|
|
if customer is not None and customer.stripe_customer_id is not None:
|
|
open_invoices = customer_has_last_n_invoices_open(
|
|
customer, row.expected_invoice_count
|
|
)
|
|
self.assertTrue(open_invoices)
|
|
|
|
email_found = False
|
|
for email in outbox:
|
|
recipient = UserProfile.objects.get(email=email.to[0])
|
|
if recipient.realm == row.realm:
|
|
self.assertIn(
|
|
f"Your organization, http://{row.realm.string_id}.testserver, has been downgraded",
|
|
outbox[0].body,
|
|
)
|
|
self.assert_length(email.to, 1)
|
|
email_found = True
|
|
self.assertEqual(row.email_expected_to_be_sent, email_found)
|
|
|
|
@mock_stripe()
|
|
def test_upgrade_pay_by_invoice(self, *mock: Mock) -> None:
|
|
hamlet = self.example_user("hamlet")
|
|
self.login_user(hamlet)
|
|
response = self.client_get("/upgrade/?setup_payment_by_invoice=true")
|
|
self.assert_in_success_response(["pay by card", "Send invoice"], response)
|
|
|
|
# Send invoice
|
|
response = self.client_billing_post(
|
|
"/billing/upgrade",
|
|
{
|
|
"billing_modality": "send_invoice",
|
|
"schedule": "annual",
|
|
"signed_seat_count": self.get_signed_seat_count_from_response(response),
|
|
"salt": self.get_salt_from_response(response),
|
|
"license_management": "manual",
|
|
"licenses": 40,
|
|
},
|
|
)
|
|
self.assert_json_success(response)
|
|
|
|
response = self.client_get("/upgrade/?setup_payment_by_invoice=true")
|
|
self.assert_in_success_response(["An invoice", "has been sent"], response)
|
|
|
|
@mock_stripe()
|
|
def test_change_plan_tier_from_standard_to_plus(self, *mock: Mock) -> None:
|
|
iago = self.example_user("iago")
|
|
realm = iago.realm
|
|
iago_billing_session = RealmBillingSession(iago)
|
|
iago_billing_session.update_or_create_customer()
|
|
|
|
# Test upgrading to Plus when realm has no active subscription
|
|
with self.assertRaises(BillingError) as billing_context:
|
|
iago_billing_session.do_change_plan_to_new_tier(CustomerPlan.TIER_CLOUD_PLUS)
|
|
self.assertEqual(
|
|
"Organization does not have an active plan",
|
|
billing_context.exception.error_description,
|
|
)
|
|
|
|
plan, ledger = self.subscribe_realm_to_manual_license_management_plan(
|
|
realm, 9, 9, CustomerPlan.BILLING_SCHEDULE_MONTHLY
|
|
)
|
|
# Test upgrading to Plus when realm has no stripe_customer_id
|
|
with self.assertRaises(BillingError) as billing_context:
|
|
iago_billing_session.do_change_plan_to_new_tier(CustomerPlan.TIER_CLOUD_PLUS)
|
|
self.assertEqual(
|
|
"Organization missing Stripe customer.", billing_context.exception.error_description
|
|
)
|
|
|
|
king = self.lear_user("king")
|
|
realm = king.realm
|
|
king_billing_session = RealmBillingSession(king)
|
|
customer = king_billing_session.update_or_create_stripe_customer()
|
|
plan = CustomerPlan.objects.create(
|
|
customer=customer,
|
|
automanage_licenses=True,
|
|
billing_cycle_anchor=timezone_now(),
|
|
billing_schedule=CustomerPlan.BILLING_SCHEDULE_MONTHLY,
|
|
tier=CustomerPlan.TIER_CLOUD_STANDARD,
|
|
)
|
|
ledger = LicenseLedger.objects.create(
|
|
plan=plan,
|
|
is_renewal=True,
|
|
event_time=timezone_now(),
|
|
licenses=9,
|
|
licenses_at_next_renewal=9,
|
|
)
|
|
realm.plan_type = Realm.PLAN_TYPE_STANDARD
|
|
realm.save(update_fields=["plan_type"])
|
|
plan.invoiced_through = ledger
|
|
plan.price_per_license = get_price_per_license(
|
|
CustomerPlan.TIER_CLOUD_STANDARD, CustomerPlan.BILLING_SCHEDULE_MONTHLY
|
|
)
|
|
plan.save(update_fields=["invoiced_through", "price_per_license"])
|
|
|
|
with self.assertRaises(BillingError) as billing_context:
|
|
king_billing_session.do_change_plan_to_new_tier(CustomerPlan.TIER_CLOUD_STANDARD)
|
|
self.assertEqual(
|
|
"Invalid change of customer plan tier.", billing_context.exception.error_description
|
|
)
|
|
|
|
king_billing_session.do_change_plan_to_new_tier(CustomerPlan.TIER_CLOUD_PLUS)
|
|
|
|
plan.refresh_from_db()
|
|
self.assertEqual(plan.status, CustomerPlan.ENDED)
|
|
plus_plan = get_current_plan_by_realm(realm)
|
|
assert plus_plan is not None
|
|
self.assertEqual(plus_plan.tier, CustomerPlan.TIER_CLOUD_PLUS)
|
|
self.assertEqual(LicenseLedger.objects.filter(plan=plus_plan).count(), 1)
|
|
|
|
realm.refresh_from_db()
|
|
self.assertEqual(realm.plan_type, Realm.PLAN_TYPE_PLUS)
|
|
|
|
# There are 9 licenses and the realm is on the Standard monthly plan.
|
|
# Therefore, the customer has already paid 800 * 9 = 7200 = $72 for
|
|
# the month. Once they upgrade to Plus, they will have to pay for 10
|
|
# licenses as that is the minimum licenses for that plan.
|
|
# The new price for their 10 licenses will be 1200 * 10 = 12000 = $120.
|
|
# Since the customer has already paid $72 for a month, -7200 = -$72 will
|
|
# be credited to the customer's balance.
|
|
stripe_customer_id = customer.stripe_customer_id
|
|
assert stripe_customer_id is not None
|
|
_, cb_txn = iter(stripe.Customer.list_balance_transactions(stripe_customer_id))
|
|
self.assertEqual(cb_txn.amount, -7200)
|
|
self.assertEqual(
|
|
cb_txn.description,
|
|
"Credit from early termination of active plan",
|
|
)
|
|
self.assertEqual(cb_txn.type, "adjustment")
|
|
|
|
# The customer now only pays the difference 12000 - 7200 = 4800 = $48,
|
|
# since the unused proration is for the whole month.
|
|
(invoice,) = iter(stripe.Invoice.list(customer=stripe_customer_id))
|
|
self.assertEqual(invoice.amount_due, 4800)
|
|
|
|
@mock_stripe()
|
|
def test_customer_has_credit_card_as_default_payment_method(self, *mocks: Mock) -> None:
|
|
iago = self.example_user("iago")
|
|
customer = Customer.objects.create(realm=iago.realm)
|
|
self.assertFalse(customer_has_credit_card_as_default_payment_method(customer))
|
|
|
|
billing_session = RealmBillingSession(iago)
|
|
customer = billing_session.update_or_create_stripe_customer()
|
|
self.assertFalse(customer_has_credit_card_as_default_payment_method(customer))
|
|
|
|
self.login_user(iago)
|
|
self.add_card_and_upgrade(iago)
|
|
self.assertTrue(customer_has_credit_card_as_default_payment_method(customer))
|
|
|
|
|
|
class StripeWebhookEndpointTest(ZulipTestCase):
|
|
def test_stripe_webhook_with_invalid_data(self) -> None:
|
|
result = self.client_post(
|
|
"/stripe/webhook/",
|
|
'["dsdsds"]',
|
|
content_type="application/json",
|
|
)
|
|
self.assertEqual(result.status_code, 400)
|
|
|
|
def test_stripe_webhook_endpoint_invalid_api_version(self) -> None:
|
|
event_data = {
|
|
"id": "stripe_event_id",
|
|
"api_version": "1991-02-20",
|
|
"type": "event_type",
|
|
"data": {"object": {"object": "checkout.session", "id": "stripe_session_id"}},
|
|
}
|
|
|
|
expected_error_message = rf"Mismatch between billing system Stripe API version({STRIPE_API_VERSION}) and Stripe webhook event API version(1991-02-20)."
|
|
with self.assertLogs("corporate.stripe", "ERROR") as error_log:
|
|
self.client_post(
|
|
"/stripe/webhook/",
|
|
event_data,
|
|
content_type="application/json",
|
|
)
|
|
self.assertEqual(error_log.output, [f"ERROR:corporate.stripe:{expected_error_message}"])
|
|
|
|
def test_stripe_webhook_for_session_completed_event(self) -> None:
|
|
# We don't process sessions for which we don't have a `Session` entry.
|
|
valid_session_event_data = {
|
|
"id": "stripe_event_id",
|
|
"api_version": STRIPE_API_VERSION,
|
|
"type": "checkout.session.completed",
|
|
"data": {"object": {"object": "checkout.session", "id": "stripe_session_id"}},
|
|
}
|
|
with patch(
|
|
"corporate.lib.stripe_event_handler.handle_checkout_session_completed_event"
|
|
) as m:
|
|
result = self.client_post(
|
|
"/stripe/webhook/",
|
|
valid_session_event_data,
|
|
content_type="application/json",
|
|
)
|
|
self.assert_length(Event.objects.all(), 0)
|
|
self.assertEqual(result.status_code, 200)
|
|
m.assert_not_called()
|
|
|
|
def test_stripe_webhook_for_invoice_payment_events(self) -> None:
|
|
customer = Customer.objects.create(realm=get_realm("zulip"))
|
|
|
|
stripe_event_id = "stripe_event_id"
|
|
stripe_invoice_id = "stripe_invoice_id"
|
|
valid_session_event_data = {
|
|
"id": stripe_event_id,
|
|
"type": "invoice.paid",
|
|
"api_version": STRIPE_API_VERSION,
|
|
"data": {"object": {"object": "invoice", "id": stripe_invoice_id}},
|
|
}
|
|
|
|
with patch("corporate.lib.stripe_event_handler.handle_invoice_paid_event") as m:
|
|
result = self.client_post(
|
|
"/stripe/webhook/",
|
|
valid_session_event_data,
|
|
content_type="application/json",
|
|
)
|
|
self.assert_length(Event.objects.filter(stripe_event_id=stripe_event_id), 0)
|
|
self.assertEqual(result.status_code, 200)
|
|
m.assert_not_called()
|
|
|
|
Invoice.objects.create(
|
|
stripe_invoice_id=stripe_invoice_id,
|
|
customer=customer,
|
|
status=Invoice.SENT,
|
|
)
|
|
|
|
self.assert_length(Event.objects.filter(stripe_event_id=stripe_event_id), 0)
|
|
with patch("corporate.lib.stripe_event_handler.handle_invoice_paid_event") as m:
|
|
result = self.client_post(
|
|
"/stripe/webhook/",
|
|
valid_session_event_data,
|
|
content_type="application/json",
|
|
)
|
|
[event] = Event.objects.filter(stripe_event_id=stripe_event_id)
|
|
self.assertEqual(result.status_code, 200)
|
|
strip_event = stripe.Event.construct_from(valid_session_event_data, stripe.api_key)
|
|
m.assert_called_once_with(strip_event.data.object, event)
|
|
|
|
with patch("corporate.lib.stripe_event_handler.handle_invoice_paid_event") as m:
|
|
result = self.client_post(
|
|
"/stripe/webhook/",
|
|
valid_session_event_data,
|
|
content_type="application/json",
|
|
)
|
|
self.assert_length(Event.objects.filter(stripe_event_id=stripe_event_id), 1)
|
|
self.assertEqual(result.status_code, 200)
|
|
m.assert_not_called()
|
|
|
|
def test_stripe_webhook_for_invoice_paid_events(self) -> None:
|
|
customer = Customer.objects.create(realm=get_realm("zulip"))
|
|
|
|
stripe_event_id = "stripe_event_id"
|
|
stripe_invoice_id = "stripe_invoice_id"
|
|
valid_invoice_paid_event_data = {
|
|
"id": stripe_event_id,
|
|
"type": "invoice.paid",
|
|
"api_version": STRIPE_API_VERSION,
|
|
"data": {"object": {"object": "invoice", "id": stripe_invoice_id}},
|
|
}
|
|
|
|
with patch("corporate.lib.stripe_event_handler.handle_invoice_paid_event") as m:
|
|
result = self.client_post(
|
|
"/stripe/webhook/",
|
|
valid_invoice_paid_event_data,
|
|
content_type="application/json",
|
|
)
|
|
self.assert_length(Event.objects.filter(stripe_event_id=stripe_event_id), 0)
|
|
self.assertEqual(result.status_code, 200)
|
|
m.assert_not_called()
|
|
|
|
Invoice.objects.create(
|
|
stripe_invoice_id=stripe_invoice_id,
|
|
customer=customer,
|
|
status=Invoice.SENT,
|
|
)
|
|
|
|
self.assert_length(Event.objects.filter(stripe_event_id=stripe_event_id), 0)
|
|
with patch("corporate.lib.stripe_event_handler.handle_invoice_paid_event") as m:
|
|
result = self.client_post(
|
|
"/stripe/webhook/",
|
|
valid_invoice_paid_event_data,
|
|
content_type="application/json",
|
|
)
|
|
[event] = Event.objects.filter(stripe_event_id=stripe_event_id)
|
|
self.assertEqual(result.status_code, 200)
|
|
strip_event = stripe.Event.construct_from(valid_invoice_paid_event_data, stripe.api_key)
|
|
m.assert_called_once_with(strip_event.data.object, event)
|
|
|
|
with patch("corporate.lib.stripe_event_handler.handle_invoice_paid_event") as m:
|
|
result = self.client_post(
|
|
"/stripe/webhook/",
|
|
valid_invoice_paid_event_data,
|
|
content_type="application/json",
|
|
)
|
|
self.assert_length(Event.objects.filter(stripe_event_id=stripe_event_id), 1)
|
|
self.assertEqual(result.status_code, 200)
|
|
m.assert_not_called()
|
|
|
|
|
|
class EventStatusTest(StripeTestCase):
|
|
def test_event_status_json_endpoint_errors(self) -> None:
|
|
self.login_user(self.example_user("iago"))
|
|
|
|
response = self.client_get("/json/billing/event/status")
|
|
self.assert_json_error_contains(response, "No customer for this organization!")
|
|
|
|
Customer.objects.create(realm=get_realm("zulip"), stripe_customer_id="cus_123")
|
|
response = self.client_get(
|
|
"/json/billing/event/status", {"stripe_session_id": "invalid_session_id"}
|
|
)
|
|
self.assert_json_error_contains(response, "Session not found")
|
|
|
|
response = self.client_get(
|
|
"/json/billing/event/status", {"stripe_invoice_id": "invalid_invoice_id"}
|
|
)
|
|
self.assert_json_error_contains(response, "Payment intent not found")
|
|
|
|
response = self.client_get(
|
|
"/json/billing/event/status",
|
|
)
|
|
self.assert_json_error_contains(response, "Pass stripe_session_id or stripe_invoice_id")
|
|
|
|
def test_event_status_page(self) -> None:
|
|
self.login_user(self.example_user("polonius"))
|
|
|
|
stripe_session_id = "cs_test_9QCz62mPTJQUwvhcwZHBpJMHmMZiLU512AQHU9g5znkx6NweU3j7kJvY"
|
|
response = self.client_get(
|
|
"/billing/event_status/", {"stripe_session_id": stripe_session_id}
|
|
)
|
|
self.assert_in_success_response([f'data-stripe-session-id="{stripe_session_id}"'], response)
|
|
|
|
stripe_invoice_id = "pi_1JGLpnA4KHR4JzRvUfkF9Tn7"
|
|
response = self.client_get(
|
|
"/billing/event_status/", {"stripe_invoice_id": stripe_invoice_id}
|
|
)
|
|
self.assert_in_success_response([f'data-stripe-invoice-id="{stripe_invoice_id}"'], response)
|
|
|
|
|
|
class RequiresBillingAccessTest(StripeTestCase):
|
|
@override
|
|
def setUp(self, *mocks: Mock) -> None:
|
|
super().setUp()
|
|
desdemona = self.example_user("desdemona")
|
|
desdemona.role = UserProfile.ROLE_REALM_OWNER
|
|
desdemona.save(update_fields=["role"])
|
|
|
|
def test_json_endpoints_permissions(self) -> None:
|
|
guest = self.example_user("polonius")
|
|
member = self.example_user("othello")
|
|
|
|
tested_endpoints = set()
|
|
|
|
def check_users_cant_access(
|
|
users: list[UserProfile],
|
|
error_message: str,
|
|
url: str,
|
|
method: str,
|
|
data: dict[str, Any],
|
|
) -> None:
|
|
tested_endpoints.add(url)
|
|
for user in users:
|
|
self.login_user(user)
|
|
if method == "POST":
|
|
client_func: Any = self.client_post
|
|
elif method == "GET":
|
|
client_func = self.client_get
|
|
else:
|
|
client_func = self.client_patch
|
|
result = client_func(
|
|
url,
|
|
data,
|
|
content_type="application/json",
|
|
)
|
|
self.assert_json_error_contains(result, error_message)
|
|
|
|
check_users_cant_access(
|
|
[guest],
|
|
"Must be an organization member",
|
|
"/json/billing/upgrade",
|
|
"POST",
|
|
{},
|
|
)
|
|
|
|
check_users_cant_access(
|
|
[guest],
|
|
"Must be an organization member",
|
|
"/json/billing/sponsorship",
|
|
"POST",
|
|
{},
|
|
)
|
|
|
|
check_users_cant_access(
|
|
[guest, member],
|
|
"Insufficient permission",
|
|
"/json/billing/plan",
|
|
"PATCH",
|
|
{},
|
|
)
|
|
|
|
check_users_cant_access(
|
|
[guest, member],
|
|
"Insufficient permission",
|
|
"/json/billing/session/start_card_update_session",
|
|
"POST",
|
|
{},
|
|
)
|
|
|
|
check_users_cant_access(
|
|
[guest],
|
|
"Must be an organization member",
|
|
"/json/upgrade/session/start_card_update_session",
|
|
"POST",
|
|
{},
|
|
)
|
|
|
|
check_users_cant_access(
|
|
[guest],
|
|
"Must be an organization member",
|
|
"/json/billing/event/status",
|
|
"GET",
|
|
{},
|
|
)
|
|
|
|
# Make sure that we are testing all the JSON endpoints
|
|
# Quite a hack, but probably fine for now
|
|
reverse_dict = get_resolver("corporate.urls").reverse_dict
|
|
json_endpoints = {
|
|
pat
|
|
for name in reverse_dict
|
|
for matches, pat, defaults, converters in reverse_dict.getlist(name)
|
|
if pat.startswith("json/") and not (pat.startswith(("json/realm/", "json/server/")))
|
|
}
|
|
self.assert_length(json_endpoints, len(tested_endpoints))
|
|
|
|
@mock_stripe()
|
|
def test_billing_page_permissions(self, *mocks: Mock) -> None:
|
|
# Guest users can't access /upgrade/ page
|
|
self.login_user(self.example_user("polonius"))
|
|
response = self.client_get("/upgrade/", follow=True)
|
|
self.assertEqual(response.status_code, 404)
|
|
|
|
# Check user in `can_manage_billing_group` has access
|
|
desdemona = self.example_user("desdemona")
|
|
desdemona.role = UserProfile.ROLE_REALM_OWNER
|
|
desdemona.save(update_fields=["role"])
|
|
self.login_user(self.example_user("desdemona"))
|
|
self.add_card_and_upgrade(desdemona)
|
|
response = self.client_get("/billing/")
|
|
self.assert_in_success_response(["Zulip Cloud Standard"], response)
|
|
|
|
# Check that member who is not in `can_manage_billing_group` does not have access
|
|
self.login_user(self.example_user("cordelia"))
|
|
response = self.client_get("/billing/")
|
|
self.assert_in_success_response(["You do not have permission to view this page."], response)
|
|
|
|
|
|
class BillingHelpersTest(ZulipTestCase):
|
|
def test_next_month(self) -> None:
|
|
anchor = datetime(2019, 12, 31, 1, 2, 3, tzinfo=timezone.utc)
|
|
period_boundaries = [
|
|
anchor,
|
|
datetime(2020, 1, 31, 1, 2, 3, tzinfo=timezone.utc),
|
|
# Test that this is the 28th even during leap years
|
|
datetime(2020, 2, 28, 1, 2, 3, tzinfo=timezone.utc),
|
|
datetime(2020, 3, 31, 1, 2, 3, tzinfo=timezone.utc),
|
|
datetime(2020, 4, 30, 1, 2, 3, tzinfo=timezone.utc),
|
|
datetime(2020, 5, 31, 1, 2, 3, tzinfo=timezone.utc),
|
|
datetime(2020, 6, 30, 1, 2, 3, tzinfo=timezone.utc),
|
|
datetime(2020, 7, 31, 1, 2, 3, tzinfo=timezone.utc),
|
|
datetime(2020, 8, 31, 1, 2, 3, tzinfo=timezone.utc),
|
|
datetime(2020, 9, 30, 1, 2, 3, tzinfo=timezone.utc),
|
|
datetime(2020, 10, 31, 1, 2, 3, tzinfo=timezone.utc),
|
|
datetime(2020, 11, 30, 1, 2, 3, tzinfo=timezone.utc),
|
|
datetime(2020, 12, 31, 1, 2, 3, tzinfo=timezone.utc),
|
|
datetime(2021, 1, 31, 1, 2, 3, tzinfo=timezone.utc),
|
|
datetime(2021, 2, 28, 1, 2, 3, tzinfo=timezone.utc),
|
|
]
|
|
with self.assertRaises(AssertionError):
|
|
add_months(anchor, -1)
|
|
# Explicitly test add_months for each value of MAX_DAY_FOR_MONTH and
|
|
# for crossing a year boundary
|
|
for i, boundary in enumerate(period_boundaries):
|
|
self.assertEqual(add_months(anchor, i), boundary)
|
|
# Test next_month for small values
|
|
for last, next_ in itertools.pairwise(period_boundaries):
|
|
self.assertEqual(next_month(anchor, last), next_)
|
|
# Test next_month for large values
|
|
period_boundaries = [dt.replace(year=dt.year + 100) for dt in period_boundaries]
|
|
for last, next_ in itertools.pairwise(period_boundaries):
|
|
self.assertEqual(next_month(anchor, last), next_)
|
|
|
|
def test_compute_plan_parameters(self) -> None:
|
|
# TODO: test rounding down microseconds
|
|
anchor = datetime(2019, 12, 31, 1, 2, 3, tzinfo=timezone.utc)
|
|
month_later = datetime(2020, 1, 31, 1, 2, 3, tzinfo=timezone.utc)
|
|
year_later = datetime(2020, 12, 31, 1, 2, 3, tzinfo=timezone.utc)
|
|
customer_with_discount = Customer.objects.create(
|
|
realm=get_realm("lear"),
|
|
monthly_discounted_price=600,
|
|
annual_discounted_price=6000,
|
|
required_plan_tier=CustomerPlan.TIER_CLOUD_STANDARD,
|
|
)
|
|
customer_no_discount = Customer.objects.create(realm=get_realm("zulip"))
|
|
test_cases = [
|
|
# Annual standard no customer
|
|
(
|
|
(
|
|
CustomerPlan.TIER_CLOUD_STANDARD,
|
|
CustomerPlan.BILLING_SCHEDULE_ANNUAL,
|
|
None,
|
|
),
|
|
(anchor, month_later, year_later, 8000),
|
|
),
|
|
# Annual standard with discount
|
|
(
|
|
(
|
|
CustomerPlan.TIER_CLOUD_STANDARD,
|
|
CustomerPlan.BILLING_SCHEDULE_ANNUAL,
|
|
customer_with_discount,
|
|
),
|
|
(anchor, month_later, year_later, 6000),
|
|
),
|
|
# Annual standard customer but no discount
|
|
(
|
|
(
|
|
CustomerPlan.TIER_CLOUD_STANDARD,
|
|
CustomerPlan.BILLING_SCHEDULE_ANNUAL,
|
|
customer_no_discount,
|
|
),
|
|
(anchor, month_later, year_later, 8000),
|
|
),
|
|
# Annual plus customer with discount but different tier than required for discount
|
|
(
|
|
(
|
|
CustomerPlan.TIER_CLOUD_PLUS,
|
|
CustomerPlan.BILLING_SCHEDULE_ANNUAL,
|
|
customer_with_discount,
|
|
),
|
|
(anchor, month_later, year_later, 12000),
|
|
),
|
|
# Monthly standard no customer
|
|
(
|
|
(
|
|
CustomerPlan.TIER_CLOUD_STANDARD,
|
|
CustomerPlan.BILLING_SCHEDULE_MONTHLY,
|
|
None,
|
|
),
|
|
(anchor, month_later, month_later, 800),
|
|
),
|
|
# Monthly standard with discount
|
|
(
|
|
(
|
|
CustomerPlan.TIER_CLOUD_STANDARD,
|
|
CustomerPlan.BILLING_SCHEDULE_MONTHLY,
|
|
customer_with_discount,
|
|
),
|
|
(anchor, month_later, month_later, 600),
|
|
),
|
|
# Monthly standard customer but no discount
|
|
(
|
|
(
|
|
CustomerPlan.TIER_CLOUD_STANDARD,
|
|
CustomerPlan.BILLING_SCHEDULE_MONTHLY,
|
|
customer_no_discount,
|
|
),
|
|
(anchor, month_later, month_later, 800),
|
|
),
|
|
# Monthly plus customer with discount but different tier than required for discount
|
|
(
|
|
(
|
|
CustomerPlan.TIER_CLOUD_PLUS,
|
|
CustomerPlan.BILLING_SCHEDULE_MONTHLY,
|
|
customer_with_discount,
|
|
),
|
|
(anchor, month_later, month_later, 1200),
|
|
),
|
|
]
|
|
with time_machine.travel(anchor, tick=False):
|
|
for (tier, billing_schedule, customer), output in test_cases:
|
|
output_ = compute_plan_parameters(
|
|
tier,
|
|
billing_schedule,
|
|
customer,
|
|
)
|
|
self.assertEqual(output_, output)
|
|
|
|
def test_get_price_per_license(self) -> None:
|
|
standard_discounted_customer = Customer.objects.create(
|
|
realm=get_realm("lear"),
|
|
monthly_discounted_price=400,
|
|
annual_discounted_price=4000,
|
|
required_plan_tier=CustomerPlan.TIER_CLOUD_STANDARD,
|
|
)
|
|
plus_discounted_customer = Customer.objects.create(
|
|
realm=get_realm("zulip"),
|
|
monthly_discounted_price=600,
|
|
annual_discounted_price=6000,
|
|
required_plan_tier=CustomerPlan.TIER_CLOUD_PLUS,
|
|
)
|
|
self.assertEqual(
|
|
get_price_per_license(
|
|
CustomerPlan.TIER_CLOUD_STANDARD, CustomerPlan.BILLING_SCHEDULE_ANNUAL
|
|
),
|
|
8000,
|
|
)
|
|
self.assertEqual(
|
|
get_price_per_license(
|
|
CustomerPlan.TIER_CLOUD_STANDARD, CustomerPlan.BILLING_SCHEDULE_MONTHLY
|
|
),
|
|
800,
|
|
)
|
|
self.assertEqual(
|
|
get_price_per_license(
|
|
CustomerPlan.TIER_CLOUD_STANDARD,
|
|
CustomerPlan.BILLING_SCHEDULE_MONTHLY,
|
|
standard_discounted_customer,
|
|
),
|
|
400,
|
|
)
|
|
|
|
self.assertEqual(
|
|
get_price_per_license(
|
|
CustomerPlan.TIER_CLOUD_PLUS, CustomerPlan.BILLING_SCHEDULE_ANNUAL
|
|
),
|
|
12000,
|
|
)
|
|
self.assertEqual(
|
|
get_price_per_license(
|
|
CustomerPlan.TIER_CLOUD_PLUS, CustomerPlan.BILLING_SCHEDULE_MONTHLY
|
|
),
|
|
1200,
|
|
)
|
|
self.assertEqual(
|
|
get_price_per_license(
|
|
CustomerPlan.TIER_CLOUD_PLUS,
|
|
CustomerPlan.BILLING_SCHEDULE_MONTHLY,
|
|
# Wrong tier so discount not applied.
|
|
standard_discounted_customer,
|
|
),
|
|
1200,
|
|
)
|
|
self.assertEqual(
|
|
get_price_per_license(
|
|
CustomerPlan.TIER_CLOUD_PLUS,
|
|
CustomerPlan.BILLING_SCHEDULE_MONTHLY,
|
|
plus_discounted_customer,
|
|
),
|
|
600,
|
|
)
|
|
|
|
with self.assertRaisesRegex(InvalidBillingScheduleError, "Unknown billing_schedule: 1000"):
|
|
get_price_per_license(CustomerPlan.TIER_CLOUD_STANDARD, 1000)
|
|
|
|
with self.assertRaisesRegex(InvalidTierError, "Unknown tier: 4"):
|
|
get_price_per_license(
|
|
CustomerPlan.TIER_CLOUD_ENTERPRISE, CustomerPlan.BILLING_SCHEDULE_ANNUAL
|
|
)
|
|
|
|
def test_get_plan_renewal_or_end_date(self) -> None:
|
|
realm = get_realm("zulip")
|
|
customer = Customer.objects.create(realm=realm, stripe_customer_id="cus_12345")
|
|
billing_cycle_anchor = timezone_now()
|
|
plan = CustomerPlan.objects.create(
|
|
customer=customer,
|
|
status=CustomerPlan.ACTIVE,
|
|
billing_cycle_anchor=billing_cycle_anchor,
|
|
billing_schedule=CustomerPlan.BILLING_SCHEDULE_MONTHLY,
|
|
tier=CustomerPlan.TIER_CLOUD_STANDARD,
|
|
)
|
|
renewal_date = get_plan_renewal_or_end_date(plan, billing_cycle_anchor)
|
|
self.assertEqual(renewal_date, add_months(billing_cycle_anchor, 1))
|
|
|
|
# When the plan ends 2 days before the start of the next billing cycle,
|
|
# the function should return the end_date.
|
|
plan_end_date = add_months(billing_cycle_anchor, 1) - timedelta(days=2)
|
|
plan.end_date = plan_end_date
|
|
plan.save(update_fields=["end_date"])
|
|
renewal_date = get_plan_renewal_or_end_date(plan, billing_cycle_anchor)
|
|
self.assertEqual(renewal_date, plan_end_date)
|
|
|
|
def test_update_or_create_stripe_customer_logic(self) -> None:
|
|
user = self.example_user("hamlet")
|
|
# No existing Customer object
|
|
with patch(
|
|
"corporate.lib.stripe.BillingSession.create_stripe_customer", return_value="returned"
|
|
) as mocked1:
|
|
billing_session = RealmBillingSession(user)
|
|
returned = billing_session.update_or_create_stripe_customer()
|
|
mocked1.assert_called_once()
|
|
self.assertEqual(returned, "returned")
|
|
|
|
customer = Customer.objects.create(realm=get_realm("zulip"))
|
|
# Customer exists but stripe_customer_id is None
|
|
with patch(
|
|
"corporate.lib.stripe.BillingSession.create_stripe_customer", return_value="returned"
|
|
) as mocked2:
|
|
billing_session = RealmBillingSession(user)
|
|
returned = billing_session.update_or_create_stripe_customer()
|
|
mocked2.assert_called_once()
|
|
self.assertEqual(returned, "returned")
|
|
|
|
customer.stripe_customer_id = "cus_12345"
|
|
customer.save()
|
|
# Customer exists, replace payment source
|
|
with patch("corporate.lib.stripe.BillingSession.replace_payment_method") as mocked3:
|
|
billing_session = RealmBillingSession(user)
|
|
returned_customer = billing_session.update_or_create_stripe_customer("pm_card_visa")
|
|
mocked3.assert_called_once()
|
|
self.assertEqual(returned_customer, customer)
|
|
|
|
# Customer exists, do nothing
|
|
with patch("corporate.lib.stripe.BillingSession.replace_payment_method") as mocked4:
|
|
billing_session = RealmBillingSession(user)
|
|
returned_customer = billing_session.update_or_create_stripe_customer(None)
|
|
mocked4.assert_not_called()
|
|
self.assertEqual(returned_customer, customer)
|
|
|
|
def test_get_customer_by_realm(self) -> None:
|
|
realm = get_realm("zulip")
|
|
|
|
self.assertEqual(get_customer_by_realm(realm), None)
|
|
|
|
customer = Customer.objects.create(realm=realm, stripe_customer_id="cus_12345")
|
|
self.assertEqual(get_customer_by_realm(realm), customer)
|
|
|
|
def test_get_current_plan_by_customer(self) -> None:
|
|
realm = get_realm("zulip")
|
|
customer = Customer.objects.create(realm=realm, stripe_customer_id="cus_12345")
|
|
|
|
self.assertEqual(get_current_plan_by_customer(customer), None)
|
|
|
|
plan = CustomerPlan.objects.create(
|
|
customer=customer,
|
|
status=CustomerPlan.ACTIVE,
|
|
billing_cycle_anchor=timezone_now(),
|
|
billing_schedule=CustomerPlan.BILLING_SCHEDULE_ANNUAL,
|
|
tier=CustomerPlan.TIER_CLOUD_STANDARD,
|
|
)
|
|
self.assertEqual(get_current_plan_by_customer(customer), plan)
|
|
|
|
plan.status = CustomerPlan.DOWNGRADE_AT_END_OF_CYCLE
|
|
plan.save(update_fields=["status"])
|
|
self.assertEqual(get_current_plan_by_customer(customer), plan)
|
|
|
|
plan.status = CustomerPlan.ENDED
|
|
plan.save(update_fields=["status"])
|
|
self.assertEqual(get_current_plan_by_customer(customer), None)
|
|
|
|
plan.status = CustomerPlan.NEVER_STARTED
|
|
plan.save(update_fields=["status"])
|
|
self.assertEqual(get_current_plan_by_customer(customer), None)
|
|
|
|
def test_get_current_plan_by_realm(self) -> None:
|
|
realm = get_realm("zulip")
|
|
|
|
self.assertEqual(get_current_plan_by_realm(realm), None)
|
|
|
|
customer = Customer.objects.create(realm=realm, stripe_customer_id="cus_12345")
|
|
self.assertEqual(get_current_plan_by_realm(realm), None)
|
|
|
|
plan = CustomerPlan.objects.create(
|
|
customer=customer,
|
|
status=CustomerPlan.ACTIVE,
|
|
billing_cycle_anchor=timezone_now(),
|
|
billing_schedule=CustomerPlan.BILLING_SCHEDULE_ANNUAL,
|
|
tier=CustomerPlan.TIER_CLOUD_STANDARD,
|
|
)
|
|
self.assertEqual(get_current_plan_by_realm(realm), plan)
|
|
|
|
def test_is_realm_on_free_trial(self) -> None:
|
|
realm = get_realm("zulip")
|
|
self.assertFalse(is_realm_on_free_trial(realm))
|
|
|
|
customer = Customer.objects.create(realm=realm, stripe_customer_id="cus_12345")
|
|
plan = CustomerPlan.objects.create(
|
|
customer=customer,
|
|
status=CustomerPlan.ACTIVE,
|
|
billing_cycle_anchor=timezone_now(),
|
|
billing_schedule=CustomerPlan.BILLING_SCHEDULE_ANNUAL,
|
|
tier=CustomerPlan.TIER_CLOUD_STANDARD,
|
|
)
|
|
self.assertFalse(is_realm_on_free_trial(realm))
|
|
|
|
plan.status = CustomerPlan.FREE_TRIAL
|
|
plan.save(update_fields=["status"])
|
|
self.assertTrue(is_realm_on_free_trial(realm))
|
|
|
|
def test_deactivate_reactivate_remote_server(self) -> None:
|
|
server_uuid = str(uuid.uuid4())
|
|
remote_server = RemoteZulipServer.objects.create(
|
|
uuid=server_uuid,
|
|
api_key="magic_secret_api_key",
|
|
hostname="demo.example.com",
|
|
contact_email="email@example.com",
|
|
)
|
|
self.assertFalse(remote_server.deactivated)
|
|
|
|
billing_session = RemoteServerBillingSession(remote_server)
|
|
do_deactivate_remote_server(remote_server, billing_session)
|
|
|
|
remote_server = RemoteZulipServer.objects.get(uuid=server_uuid)
|
|
remote_realm_audit_log = RemoteZulipServerAuditLog.objects.filter(
|
|
event_type=AuditLogEventType.REMOTE_SERVER_DEACTIVATED
|
|
).last()
|
|
assert remote_realm_audit_log is not None
|
|
self.assertTrue(remote_server.deactivated)
|
|
|
|
# Try to deactivate a remote server that is already deactivated
|
|
with self.assertLogs("corporate.stripe", "WARN") as warning_log:
|
|
do_deactivate_remote_server(remote_server, billing_session)
|
|
self.assertEqual(
|
|
warning_log.output,
|
|
[
|
|
"WARNING:corporate.stripe:Cannot deactivate remote server with ID "
|
|
f"{remote_server.id}, server has already been deactivated."
|
|
],
|
|
)
|
|
|
|
do_reactivate_remote_server(remote_server)
|
|
remote_server.refresh_from_db()
|
|
self.assertFalse(remote_server.deactivated)
|
|
remote_realm_audit_log = RemoteZulipServerAuditLog.objects.latest("id")
|
|
self.assertEqual(
|
|
remote_realm_audit_log.event_type, AuditLogEventType.REMOTE_SERVER_REACTIVATED
|
|
)
|
|
self.assertEqual(remote_realm_audit_log.server, remote_server)
|
|
|
|
with self.assertLogs("corporate.stripe", "WARN") as warning_log:
|
|
do_reactivate_remote_server(remote_server)
|
|
self.assertEqual(
|
|
warning_log.output,
|
|
[
|
|
"WARNING:corporate.stripe:Cannot reactivate remote server with ID "
|
|
f"{remote_server.id}, server is already active."
|
|
],
|
|
)
|
|
|
|
|
|
class LicenseLedgerTest(StripeTestCase):
|
|
def test_add_plan_renewal_if_needed(self) -> None:
|
|
with time_machine.travel(self.now, tick=False):
|
|
self.local_upgrade(
|
|
self.seat_count, True, CustomerPlan.BILLING_SCHEDULE_ANNUAL, True, False
|
|
)
|
|
self.assertEqual(LicenseLedger.objects.count(), 1)
|
|
plan = CustomerPlan.objects.get()
|
|
# Plan hasn't renewed yet
|
|
realm = plan.customer.realm
|
|
billing_session = RealmBillingSession(user=None, realm=realm)
|
|
billing_session.make_end_of_cycle_updates_if_needed(
|
|
plan, self.next_year - timedelta(days=1)
|
|
)
|
|
self.assertEqual(LicenseLedger.objects.count(), 1)
|
|
# Plan needs to renew
|
|
# TODO: do_deactivate_user for a user, so that licenses_at_next_renewal != licenses
|
|
new_plan, ledger_entry = billing_session.make_end_of_cycle_updates_if_needed(
|
|
plan, self.next_year
|
|
)
|
|
self.assertIsNone(new_plan)
|
|
self.assertEqual(LicenseLedger.objects.count(), 2)
|
|
ledger_params = {
|
|
"plan": plan,
|
|
"is_renewal": True,
|
|
"event_time": self.next_year,
|
|
"licenses": self.seat_count,
|
|
"licenses_at_next_renewal": self.seat_count,
|
|
}
|
|
for key, value in ledger_params.items():
|
|
self.assertEqual(getattr(ledger_entry, key), value)
|
|
# Plan needs to renew, but we already added the plan_renewal ledger entry
|
|
billing_session.make_end_of_cycle_updates_if_needed(
|
|
plan, self.next_year + timedelta(days=1)
|
|
)
|
|
self.assertEqual(LicenseLedger.objects.count(), 2)
|
|
|
|
def test_update_license_ledger_if_needed(self) -> None:
|
|
realm = get_realm("zulip")
|
|
billing_session = RealmBillingSession(user=None, realm=realm)
|
|
# Test no Customer
|
|
billing_session.update_license_ledger_if_needed(self.now)
|
|
self.assertFalse(LicenseLedger.objects.exists())
|
|
# Test plan not automanaged
|
|
with time_machine.travel(self.now, tick=False):
|
|
self.local_upgrade(
|
|
self.seat_count + 1, False, CustomerPlan.BILLING_SCHEDULE_ANNUAL, True, False
|
|
)
|
|
plan = CustomerPlan.objects.get()
|
|
self.assertEqual(LicenseLedger.objects.count(), 1)
|
|
self.assertEqual(plan.licenses(), self.seat_count + 1)
|
|
self.assertEqual(plan.licenses_at_next_renewal(), self.seat_count + 1)
|
|
billing_session.update_license_ledger_if_needed(self.now)
|
|
self.assertEqual(LicenseLedger.objects.count(), 1)
|
|
# Test no active plan
|
|
plan.automanage_licenses = True
|
|
plan.status = CustomerPlan.ENDED
|
|
plan.save(update_fields=["automanage_licenses", "status"])
|
|
billing_session.update_license_ledger_if_needed(self.now)
|
|
self.assertEqual(LicenseLedger.objects.count(), 1)
|
|
# Test update needed
|
|
plan.status = CustomerPlan.ACTIVE
|
|
plan.save(update_fields=["status"])
|
|
billing_session.update_license_ledger_if_needed(self.now)
|
|
self.assertEqual(LicenseLedger.objects.count(), 2)
|
|
|
|
def test_update_license_ledger_for_automanaged_plan(self) -> None:
|
|
realm = get_realm("zulip")
|
|
with time_machine.travel(self.now, tick=False):
|
|
self.local_upgrade(
|
|
self.seat_count, True, CustomerPlan.BILLING_SCHEDULE_ANNUAL, True, False
|
|
)
|
|
plan = CustomerPlan.objects.first()
|
|
assert plan is not None
|
|
self.assertEqual(plan.licenses(), self.seat_count)
|
|
self.assertEqual(plan.licenses_at_next_renewal(), self.seat_count)
|
|
|
|
billing_session = RealmBillingSession(user=None, realm=realm)
|
|
# Simple increase
|
|
with patch("corporate.lib.stripe.get_latest_seat_count", return_value=23):
|
|
billing_session.update_license_ledger_for_automanaged_plan(plan, self.now)
|
|
self.assertEqual(plan.licenses(), 23)
|
|
self.assertEqual(plan.licenses_at_next_renewal(), 23)
|
|
# Decrease
|
|
with patch("corporate.lib.stripe.get_latest_seat_count", return_value=20):
|
|
billing_session.update_license_ledger_for_automanaged_plan(plan, self.now)
|
|
self.assertEqual(plan.licenses(), 23)
|
|
self.assertEqual(plan.licenses_at_next_renewal(), 20)
|
|
# Increase, but not past high watermark
|
|
with patch("corporate.lib.stripe.get_latest_seat_count", return_value=21):
|
|
billing_session.update_license_ledger_for_automanaged_plan(plan, self.now)
|
|
self.assertEqual(plan.licenses(), 23)
|
|
self.assertEqual(plan.licenses_at_next_renewal(), 21)
|
|
# Increase, but after renewal date, and below last year's high watermark
|
|
with patch("corporate.lib.stripe.get_latest_seat_count", return_value=22):
|
|
billing_session.update_license_ledger_for_automanaged_plan(
|
|
plan, self.next_year + timedelta(seconds=1)
|
|
)
|
|
self.assertEqual(plan.licenses(), 22)
|
|
self.assertEqual(plan.licenses_at_next_renewal(), 22)
|
|
|
|
ledger_entries = list(
|
|
LicenseLedger.objects.values_list(
|
|
"is_renewal", "event_time", "licenses", "licenses_at_next_renewal"
|
|
).order_by("id")
|
|
)
|
|
self.assertEqual(
|
|
ledger_entries,
|
|
[
|
|
(True, self.now, self.seat_count, self.seat_count),
|
|
(False, self.now, 23, 23),
|
|
(False, self.now, 23, 20),
|
|
(False, self.now, 23, 21),
|
|
(True, self.next_year, 21, 21),
|
|
(False, self.next_year + timedelta(seconds=1), 22, 22),
|
|
],
|
|
)
|
|
|
|
def test_update_license_ledger_for_manual_plan(self) -> None:
|
|
realm = get_realm("zulip")
|
|
|
|
with time_machine.travel(self.now, tick=False):
|
|
self.local_upgrade(
|
|
self.seat_count + 1, False, CustomerPlan.BILLING_SCHEDULE_ANNUAL, True, False
|
|
)
|
|
|
|
billing_session = RealmBillingSession(user=None, realm=realm)
|
|
plan = get_current_plan_by_realm(realm)
|
|
assert plan is not None
|
|
|
|
with patch("corporate.lib.stripe.get_latest_seat_count", return_value=self.seat_count):
|
|
billing_session.update_license_ledger_for_manual_plan(
|
|
plan, self.now, licenses=self.seat_count + 3
|
|
)
|
|
self.assertEqual(plan.licenses(), self.seat_count + 3)
|
|
self.assertEqual(plan.licenses_at_next_renewal(), self.seat_count + 3)
|
|
|
|
with (
|
|
patch("corporate.lib.stripe.get_latest_seat_count", return_value=self.seat_count),
|
|
self.assertRaises(AssertionError),
|
|
):
|
|
billing_session.update_license_ledger_for_manual_plan(
|
|
plan, self.now, licenses=self.seat_count
|
|
)
|
|
|
|
with patch("corporate.lib.stripe.get_latest_seat_count", return_value=self.seat_count):
|
|
billing_session.update_license_ledger_for_manual_plan(
|
|
plan, self.now, licenses_at_next_renewal=self.seat_count
|
|
)
|
|
self.assertEqual(plan.licenses(), self.seat_count + 3)
|
|
self.assertEqual(plan.licenses_at_next_renewal(), self.seat_count)
|
|
|
|
with (
|
|
patch("corporate.lib.stripe.get_latest_seat_count", return_value=self.seat_count),
|
|
self.assertRaises(AssertionError),
|
|
):
|
|
billing_session.update_license_ledger_for_manual_plan(
|
|
plan, self.now, licenses_at_next_renewal=self.seat_count - 1
|
|
)
|
|
|
|
with patch("corporate.lib.stripe.get_latest_seat_count", return_value=self.seat_count):
|
|
billing_session.update_license_ledger_for_manual_plan(
|
|
plan, self.now, licenses=self.seat_count + 10
|
|
)
|
|
self.assertEqual(plan.licenses(), self.seat_count + 10)
|
|
self.assertEqual(plan.licenses_at_next_renewal(), self.seat_count + 10)
|
|
|
|
billing_session.make_end_of_cycle_updates_if_needed(plan, self.next_year)
|
|
self.assertEqual(plan.licenses(), self.seat_count + 10)
|
|
|
|
ledger_entries = list(
|
|
LicenseLedger.objects.values_list(
|
|
"is_renewal", "event_time", "licenses", "licenses_at_next_renewal"
|
|
).order_by("id")
|
|
)
|
|
|
|
self.assertEqual(
|
|
ledger_entries,
|
|
[
|
|
(True, self.now, self.seat_count + 1, self.seat_count + 1),
|
|
(False, self.now, self.seat_count + 3, self.seat_count + 3),
|
|
(False, self.now, self.seat_count + 3, self.seat_count),
|
|
(False, self.now, self.seat_count + 10, self.seat_count + 10),
|
|
(True, self.next_year, self.seat_count + 10, self.seat_count + 10),
|
|
],
|
|
)
|
|
|
|
with self.assertRaises(AssertionError):
|
|
billing_session.update_license_ledger_for_manual_plan(plan, self.now)
|
|
|
|
def test_user_changes(self) -> None:
|
|
self.local_upgrade(self.seat_count, True, CustomerPlan.BILLING_SCHEDULE_ANNUAL, True, False)
|
|
user = do_create_user("email", "password", get_realm("zulip"), "name", acting_user=None)
|
|
do_deactivate_user(user, acting_user=None)
|
|
do_reactivate_user(user, acting_user=None)
|
|
|
|
# Not a proper use of do_activate_mirror_dummy_user, but fine for this test
|
|
change_user_is_active(user, False)
|
|
user.is_mirror_dummy = True
|
|
user.save(update_fields=["is_mirror_dummy"])
|
|
do_activate_mirror_dummy_user(user, acting_user=None)
|
|
# Add a guest user
|
|
guest = do_create_user(
|
|
"guest_email",
|
|
"guest_password",
|
|
get_realm("zulip"),
|
|
"guest_name",
|
|
role=UserProfile.ROLE_GUEST,
|
|
acting_user=None,
|
|
)
|
|
# Change guest user role to member
|
|
do_change_user_role(guest, UserProfile.ROLE_MEMBER, acting_user=None)
|
|
# Change again to moderator, no LicenseLedger created
|
|
do_change_user_role(guest, UserProfile.ROLE_MODERATOR, acting_user=None)
|
|
ledger_entries = list(
|
|
LicenseLedger.objects.values_list(
|
|
"is_renewal", "licenses", "licenses_at_next_renewal"
|
|
).order_by("id")
|
|
)
|
|
self.assertEqual(
|
|
ledger_entries,
|
|
[
|
|
(True, self.seat_count, self.seat_count),
|
|
(False, self.seat_count + 1, self.seat_count + 1),
|
|
(False, self.seat_count + 1, self.seat_count),
|
|
(False, self.seat_count + 1, self.seat_count + 1),
|
|
(False, self.seat_count + 1, self.seat_count + 1),
|
|
(False, self.seat_count + 1, self.seat_count + 1),
|
|
(False, self.seat_count + 2, self.seat_count + 2),
|
|
],
|
|
)
|
|
|
|
def test_toggle_license_management(self) -> None:
|
|
self.local_upgrade(self.seat_count, True, CustomerPlan.BILLING_SCHEDULE_ANNUAL, True, False)
|
|
plan = get_current_plan_by_realm(get_realm("zulip"))
|
|
assert plan is not None
|
|
self.assertEqual(plan.automanage_licenses, True)
|
|
self.assertEqual(plan.licenses(), self.seat_count)
|
|
self.assertEqual(plan.licenses_at_next_renewal(), self.seat_count)
|
|
billing_session = RealmBillingSession(user=None, realm=get_realm("zulip"))
|
|
update_plan_request = UpdatePlanRequest(
|
|
status=None,
|
|
licenses=None,
|
|
licenses_at_next_renewal=None,
|
|
schedule=None,
|
|
toggle_license_management=True,
|
|
)
|
|
billing_session.do_update_plan(update_plan_request)
|
|
plan.refresh_from_db()
|
|
self.assertEqual(plan.automanage_licenses, False)
|
|
|
|
billing_session.do_update_plan(update_plan_request)
|
|
plan.refresh_from_db()
|
|
self.assertEqual(plan.automanage_licenses, True)
|
|
|
|
|
|
class InvoiceTest(StripeTestCase):
|
|
def test_invoicing_status_is_started(self) -> None:
|
|
# local_upgrade uses hamlet as user, therefore realm is zulip.
|
|
self.local_upgrade(self.seat_count, True, CustomerPlan.BILLING_SCHEDULE_ANNUAL, True, False)
|
|
plan = CustomerPlan.objects.first()
|
|
assert plan is not None
|
|
plan.invoicing_status = CustomerPlan.INVOICING_STATUS_STARTED
|
|
plan.save(update_fields=["invoicing_status"])
|
|
with self.assertRaises(NotImplementedError):
|
|
billing_session = RealmBillingSession(realm=get_realm("zulip"))
|
|
billing_session.invoice_plan(assert_is_not_none(CustomerPlan.objects.first()), self.now)
|
|
|
|
def test_invoice_plan_without_stripe_customer(self) -> None:
|
|
# local_upgrade uses hamlet as user, therefore realm is zulip.
|
|
realm = get_realm("zulip")
|
|
self.local_upgrade(
|
|
self.seat_count, True, CustomerPlan.BILLING_SCHEDULE_ANNUAL, False, False
|
|
)
|
|
plan = get_current_plan_by_realm(realm)
|
|
assert plan is not None
|
|
plan.customer.stripe_customer_id = None
|
|
plan.customer.save(update_fields=["stripe_customer_id"])
|
|
with self.assertRaises(BillingError) as context:
|
|
billing_session = RealmBillingSession(realm=realm)
|
|
billing_session.invoice_plan(plan, timezone_now())
|
|
self.assertRegex(
|
|
context.exception.error_description,
|
|
"Customer has a paid plan without a Stripe customer ID:",
|
|
)
|
|
|
|
@mock_stripe()
|
|
def test_validate_licenses_for_manual_plan_management(self, *mocks: Mock) -> None:
|
|
user = self.example_user("hamlet")
|
|
self.login_user(user)
|
|
|
|
# Upgrade with one extra license
|
|
with (
|
|
time_machine.travel(self.now, tick=False),
|
|
patch("corporate.lib.stripe.MIN_INVOICED_LICENSES", 3),
|
|
):
|
|
self.upgrade(invoice=True, licenses=self.seat_count + 1)
|
|
|
|
# Set renewal licenses to current seat count
|
|
with (
|
|
time_machine.travel(self.now, tick=False),
|
|
patch("corporate.lib.stripe.MIN_INVOICED_LICENSES", 3),
|
|
):
|
|
result = self.client_billing_patch(
|
|
"/billing/plan",
|
|
{"licenses_at_next_renewal": self.seat_count},
|
|
)
|
|
self.assert_json_success(result)
|
|
|
|
# Add an extra user
|
|
do_create_user(
|
|
"email-extra-user",
|
|
"password-extra-user",
|
|
get_realm("zulip"),
|
|
"name-extra-user",
|
|
acting_user=None,
|
|
)
|
|
with self.assertLogs("corporate.stripe", level="ERROR") as m:
|
|
invoice_plans_as_needed(self.next_year)
|
|
self.assertIn(
|
|
"ERROR:corporate.stripe:Invoicing failed: Customer.id:",
|
|
m.output[0],
|
|
)
|
|
self.assertIn(
|
|
"Customer has not manually updated plan for current license count:",
|
|
m.output[0],
|
|
)
|
|
|
|
@mock_stripe()
|
|
def test_invoice_plan(self, *mocks: Mock) -> None:
|
|
user = self.example_user("hamlet")
|
|
self.login_user(user)
|
|
with time_machine.travel(self.now, tick=False):
|
|
self.add_card_and_upgrade(user)
|
|
realm = get_realm("zulip")
|
|
billing_session = RealmBillingSession(user=user, realm=realm)
|
|
# Increase
|
|
with patch("corporate.lib.stripe.get_latest_seat_count", return_value=self.seat_count + 3):
|
|
billing_session.update_license_ledger_if_needed(self.now + timedelta(days=100))
|
|
# Decrease
|
|
with patch("corporate.lib.stripe.get_latest_seat_count", return_value=self.seat_count):
|
|
billing_session.update_license_ledger_if_needed(self.now + timedelta(days=200))
|
|
# Increase, but not past high watermark
|
|
with patch("corporate.lib.stripe.get_latest_seat_count", return_value=self.seat_count + 1):
|
|
billing_session.update_license_ledger_if_needed(self.now + timedelta(days=300))
|
|
# Increase, but after renewal date, and below last year's high watermark
|
|
with patch("corporate.lib.stripe.get_latest_seat_count", return_value=self.seat_count + 2):
|
|
billing_session.update_license_ledger_if_needed(self.now + timedelta(days=400))
|
|
# Increase, but after event_time
|
|
with patch("corporate.lib.stripe.get_latest_seat_count", return_value=self.seat_count + 3):
|
|
billing_session.update_license_ledger_if_needed(self.now + timedelta(days=500))
|
|
plan = CustomerPlan.objects.first()
|
|
assert plan is not None
|
|
billing_session.invoice_plan(plan, self.now + timedelta(days=400))
|
|
stripe_customer_id = plan.customer.stripe_customer_id
|
|
assert stripe_customer_id is not None
|
|
[invoice0, invoice1] = iter(stripe.Invoice.list(customer=stripe_customer_id))
|
|
self.assertIsNotNone(invoice0.status_transitions.finalized_at)
|
|
[item0, item1, item2] = iter(invoice0.lines)
|
|
line_item_params = {
|
|
"amount": int(8000 * (1 - ((400 - 366) / 365)) + 0.5),
|
|
"description": "Additional license (Feb 5, 2013 - Jan 2, 2014)",
|
|
"discountable": False,
|
|
"period": {
|
|
"start": datetime_to_timestamp(self.now + timedelta(days=400)),
|
|
"end": datetime_to_timestamp(self.now + timedelta(days=2 * 365 + 1)),
|
|
},
|
|
"quantity": 1,
|
|
}
|
|
for key, value in line_item_params.items():
|
|
self.assertEqual(item0.get(key), value)
|
|
line_item_params = {
|
|
"amount": 8000 * (self.seat_count + 1),
|
|
"description": "Zulip Cloud Standard - renewal",
|
|
"discountable": False,
|
|
"period": {
|
|
"start": datetime_to_timestamp(self.now + timedelta(days=366)),
|
|
"end": datetime_to_timestamp(self.now + timedelta(days=2 * 365 + 1)),
|
|
},
|
|
"quantity": self.seat_count + 1,
|
|
}
|
|
for key, value in line_item_params.items():
|
|
self.assertEqual(item1.get(key), value)
|
|
line_item_params = {
|
|
"amount": 3 * int(8000 * (366 - 100) / 366 + 0.5),
|
|
"description": "Additional license (Apr 11, 2012 - Jan 2, 2013)",
|
|
"discountable": False,
|
|
"period": {
|
|
"start": datetime_to_timestamp(self.now + timedelta(days=100)),
|
|
"end": datetime_to_timestamp(self.now + timedelta(days=366)),
|
|
},
|
|
"quantity": 3,
|
|
}
|
|
for key, value in line_item_params.items():
|
|
self.assertEqual(item2.get(key), value)
|
|
|
|
@mock_stripe()
|
|
def test_fixed_price_plans(self, *mocks: Mock) -> None:
|
|
# Also tests charge_automatically=False
|
|
user = self.example_user("hamlet")
|
|
self.login_user(user)
|
|
with time_machine.travel(self.now, tick=False):
|
|
self.upgrade(invoice=True)
|
|
plan = CustomerPlan.objects.first()
|
|
assert plan is not None
|
|
plan.fixed_price = 100
|
|
plan.price_per_license = 0
|
|
plan.save(update_fields=["fixed_price", "price_per_license"])
|
|
user.realm.refresh_from_db()
|
|
billing_session = RealmBillingSession(realm=user.realm)
|
|
billing_session.invoice_plan(plan, self.next_year)
|
|
stripe_customer_id = plan.customer.stripe_customer_id
|
|
assert stripe_customer_id is not None
|
|
[invoice0, invoice1] = iter(stripe.Invoice.list(customer=stripe_customer_id))
|
|
self.assertEqual(invoice0.collection_method, "send_invoice")
|
|
[item] = iter(invoice0.lines)
|
|
line_item_params = {
|
|
"amount": 100,
|
|
"description": "Zulip Cloud Standard - renewal",
|
|
"discountable": False,
|
|
"period": {
|
|
"start": datetime_to_timestamp(self.next_year),
|
|
"end": datetime_to_timestamp(self.next_year + timedelta(days=365)),
|
|
},
|
|
"quantity": 1,
|
|
}
|
|
for key, value in line_item_params.items():
|
|
self.assertEqual(item.get(key), value)
|
|
|
|
@mock_stripe()
|
|
def test_upgrade_to_fixed_price_plus_plan(self, *mocks: Mock) -> None:
|
|
iago = self.example_user("iago")
|
|
hamlet = self.example_user("hamlet")
|
|
realm = get_realm("zulip")
|
|
self.assertEqual(realm.plan_type, Realm.PLAN_TYPE_SELF_HOSTED)
|
|
|
|
self.login_user(hamlet)
|
|
with time_machine.travel(self.now, tick=False):
|
|
self.upgrade(invoice=True)
|
|
plan = CustomerPlan.objects.first()
|
|
assert plan is not None
|
|
self.assertIsNone(plan.end_date)
|
|
self.assertEqual(plan.tier, CustomerPlan.TIER_CLOUD_STANDARD)
|
|
realm.refresh_from_db()
|
|
self.assertEqual(realm.plan_type, Realm.PLAN_TYPE_STANDARD)
|
|
|
|
billing_session = RealmBillingSession(user=hamlet, realm=realm)
|
|
next_billing_cycle = billing_session.get_next_billing_cycle(plan)
|
|
plan_end_date_string = next_billing_cycle.strftime("%Y-%m-%d")
|
|
plan_end_date = datetime.strptime(plan_end_date_string, "%Y-%m-%d").replace(
|
|
tzinfo=timezone.utc
|
|
)
|
|
|
|
self.logout()
|
|
self.login_user(iago)
|
|
|
|
result = self.client_post(
|
|
"/activity/support",
|
|
{
|
|
"realm_id": f"{realm.id}",
|
|
"required_plan_tier": f"{CustomerPlanOffer.TIER_CLOUD_PLUS}",
|
|
},
|
|
)
|
|
self.assert_in_success_response(
|
|
["Required plan tier for zulip set to Zulip Cloud Plus."],
|
|
result,
|
|
)
|
|
|
|
with time_machine.travel(self.now, tick=False):
|
|
result = self.client_post(
|
|
"/activity/support",
|
|
{
|
|
"realm_id": f"{realm.id}",
|
|
"plan_end_date": plan_end_date_string,
|
|
},
|
|
)
|
|
self.assert_in_success_response(
|
|
[f"Current plan for zulip updated to end on {plan_end_date_string}."],
|
|
result,
|
|
)
|
|
|
|
plan.refresh_from_db()
|
|
self.assertEqual(plan.end_date, plan_end_date)
|
|
|
|
result = self.client_post(
|
|
"/activity/support",
|
|
{
|
|
"realm_id": f"{realm.id}",
|
|
"fixed_price": 360,
|
|
},
|
|
)
|
|
self.assert_in_success_response(
|
|
[f"Fixed price Zulip Cloud Plus plan scheduled to start on {plan_end_date_string}."],
|
|
result,
|
|
)
|
|
|
|
plan.refresh_from_db()
|
|
self.assertEqual(plan.status, CustomerPlan.SWITCH_PLAN_TIER_AT_PLAN_END)
|
|
self.assertEqual(plan.next_invoice_date, plan_end_date)
|
|
new_plan = CustomerPlan.objects.filter(fixed_price__isnull=False).first()
|
|
assert new_plan is not None
|
|
self.assertEqual(new_plan.next_invoice_date, plan_end_date)
|
|
self.assertEqual(
|
|
new_plan.invoicing_status, CustomerPlan.INVOICING_STATUS_INITIAL_INVOICE_TO_BE_SENT
|
|
)
|
|
|
|
with time_machine.travel(next_billing_cycle, tick=False):
|
|
invoice_plans_as_needed()
|
|
|
|
plan.refresh_from_db()
|
|
self.assertEqual(plan.status, CustomerPlan.ENDED)
|
|
self.assertEqual(plan.next_invoice_date, None)
|
|
|
|
new_plan.refresh_from_db()
|
|
self.assertEqual(new_plan.tier, CustomerPlan.TIER_CLOUD_PLUS)
|
|
self.assertIsNotNone(new_plan.fixed_price)
|
|
self.assertIsNone(new_plan.price_per_license)
|
|
|
|
realm.refresh_from_db()
|
|
self.assertEqual(realm.plan_type, Realm.PLAN_TYPE_PLUS)
|
|
|
|
# Visit /billing
|
|
self.logout()
|
|
self.login_user(hamlet)
|
|
with time_machine.travel(plan_end_date + timedelta(days=1), tick=False):
|
|
response = self.client_get(f"{self.billing_session.billing_base_url}/billing/")
|
|
for substring in [
|
|
"Zulip Cloud Plus",
|
|
"Annual",
|
|
"Invoice",
|
|
"This is a fixed-price plan",
|
|
"You will be contacted by Zulip Sales",
|
|
]:
|
|
self.assert_in_response(substring, response)
|
|
self.assert_not_in_success_response(["Update card"], response)
|
|
|
|
def test_no_invoice_needed(self) -> None:
|
|
# local_upgrade uses hamlet as user, therefore realm is zulip.
|
|
with time_machine.travel(self.now, tick=False):
|
|
self.local_upgrade(
|
|
self.seat_count, True, CustomerPlan.BILLING_SCHEDULE_ANNUAL, True, False
|
|
)
|
|
plan = CustomerPlan.objects.first()
|
|
assert plan is not None
|
|
self.assertEqual(plan.next_invoice_date, self.next_month)
|
|
# Test this doesn't make any calls to stripe.Invoice or stripe.InvoiceItem
|
|
assert plan.customer.realm is not None
|
|
billing_session = RealmBillingSession(realm=plan.customer.realm)
|
|
billing_session.invoice_plan(plan, self.next_month)
|
|
plan = CustomerPlan.objects.first()
|
|
# Test that we still update next_invoice_date
|
|
assert plan is not None
|
|
self.assertEqual(plan.next_invoice_date, self.next_month + timedelta(days=29))
|
|
|
|
def test_invoice_plans_as_needed(self) -> None:
|
|
with time_machine.travel(self.now, tick=False):
|
|
self.local_upgrade(
|
|
self.seat_count, True, CustomerPlan.BILLING_SCHEDULE_ANNUAL, True, False
|
|
)
|
|
plan = CustomerPlan.objects.first()
|
|
assert plan is not None
|
|
self.assertEqual(plan.next_invoice_date, self.next_month)
|
|
# Test nothing needed to be done
|
|
with patch("corporate.lib.stripe.BillingSession.invoice_plan") as mocked:
|
|
invoice_plans_as_needed(self.next_month - timedelta(days=1))
|
|
mocked.assert_not_called()
|
|
# Test something needing to be done
|
|
invoice_plans_as_needed(self.next_month)
|
|
plan = CustomerPlan.objects.first()
|
|
assert plan is not None
|
|
self.assertEqual(plan.next_invoice_date, self.next_month + timedelta(days=29))
|
|
|
|
@mock_stripe()
|
|
def test_invoice_for_additional_license(self, *mocks: Mock) -> None:
|
|
user = self.example_user("hamlet")
|
|
self.login_user(user)
|
|
with time_machine.travel(self.now, tick=False):
|
|
self.add_card_and_upgrade(user)
|
|
plan = CustomerPlan.objects.first()
|
|
assert plan is not None
|
|
self.assertEqual(plan.next_invoice_date, self.next_month)
|
|
assert plan.customer.realm is not None
|
|
realm = plan.customer.realm
|
|
|
|
# Adding a guest user and then changing their role to member
|
|
# should invoice for a pro-rated license at the next invoice
|
|
# date on a plan with annual billing.
|
|
with time_machine.travel(self.now + timedelta(days=5), tick=False):
|
|
user = do_create_user(
|
|
"email",
|
|
"password",
|
|
realm,
|
|
"name",
|
|
role=UserProfile.ROLE_GUEST,
|
|
acting_user=None,
|
|
)
|
|
|
|
with time_machine.travel(self.now + timedelta(days=10), tick=False):
|
|
do_change_user_role(user, UserProfile.ROLE_MEMBER, acting_user=None)
|
|
|
|
billing_session = RealmBillingSession(realm=realm)
|
|
billing_session.invoice_plan(plan, self.next_month)
|
|
plan = CustomerPlan.objects.first()
|
|
assert plan is not None
|
|
self.assertEqual(plan.next_invoice_date, self.next_month + timedelta(days=29))
|
|
stripe_customer_id = plan.customer.stripe_customer_id
|
|
assert stripe_customer_id is not None
|
|
[invoice0, invoice1] = iter(stripe.Invoice.list(customer=stripe_customer_id))
|
|
self.assertIsNotNone(invoice0.status_transitions.finalized_at)
|
|
[item0] = iter(invoice0.lines)
|
|
line_item_params = {
|
|
"amount": int(8000 * (1 - ((366 - 356) / 366)) + 0.5),
|
|
"description": "Additional license (Jan 12, 2012 - Jan 2, 2013)",
|
|
"quantity": 1,
|
|
}
|
|
for key, value in line_item_params.items():
|
|
self.assertEqual(item0.get(key), value)
|
|
|
|
|
|
class TestTestClasses(ZulipTestCase):
|
|
def test_subscribe_realm_to_manual_license_management_plan(self) -> None:
|
|
realm = get_realm("zulip")
|
|
plan, ledger = self.subscribe_realm_to_manual_license_management_plan(
|
|
realm, 50, 60, CustomerPlan.BILLING_SCHEDULE_ANNUAL
|
|
)
|
|
|
|
plan.refresh_from_db()
|
|
self.assertEqual(plan.automanage_licenses, False)
|
|
self.assertEqual(plan.billing_schedule, CustomerPlan.BILLING_SCHEDULE_ANNUAL)
|
|
self.assertEqual(plan.tier, CustomerPlan.TIER_CLOUD_STANDARD)
|
|
self.assertEqual(plan.licenses(), 50)
|
|
self.assertEqual(plan.licenses_at_next_renewal(), 60)
|
|
|
|
ledger.refresh_from_db()
|
|
self.assertEqual(ledger.plan, plan)
|
|
self.assertEqual(ledger.licenses, 50)
|
|
self.assertEqual(ledger.licenses_at_next_renewal, 60)
|
|
|
|
realm.refresh_from_db()
|
|
self.assertEqual(realm.plan_type, Realm.PLAN_TYPE_STANDARD)
|
|
|
|
def test_subscribe_realm_to_monthly_plan_on_manual_license_management(self) -> None:
|
|
realm = get_realm("zulip")
|
|
plan, ledger = self.subscribe_realm_to_monthly_plan_on_manual_license_management(
|
|
realm, 20, 30
|
|
)
|
|
|
|
plan.refresh_from_db()
|
|
self.assertEqual(plan.automanage_licenses, False)
|
|
self.assertEqual(plan.billing_schedule, CustomerPlan.BILLING_SCHEDULE_MONTHLY)
|
|
self.assertEqual(plan.tier, CustomerPlan.TIER_CLOUD_STANDARD)
|
|
self.assertEqual(plan.licenses(), 20)
|
|
self.assertEqual(plan.licenses_at_next_renewal(), 30)
|
|
|
|
ledger.refresh_from_db()
|
|
self.assertEqual(ledger.plan, plan)
|
|
self.assertEqual(ledger.licenses, 20)
|
|
self.assertEqual(ledger.licenses_at_next_renewal, 30)
|
|
|
|
realm.refresh_from_db()
|
|
self.assertEqual(realm.plan_type, Realm.PLAN_TYPE_STANDARD)
|
|
|
|
|
|
class TestRealmBillingSession(StripeTestCase):
|
|
def test_get_audit_log_error(self) -> None:
|
|
user = self.example_user("hamlet")
|
|
billing_session = RealmBillingSession(user)
|
|
fake_audit_log = typing.cast(BillingSessionEventType, 0)
|
|
with self.assertRaisesRegex(
|
|
BillingSessionAuditLogEventError, "Unknown audit log event type: 0"
|
|
):
|
|
billing_session.get_audit_log_event(event_type=fake_audit_log)
|
|
|
|
def test_get_customer(self) -> None:
|
|
user = self.example_user("hamlet")
|
|
billing_session = RealmBillingSession(user)
|
|
customer = billing_session.get_customer()
|
|
self.assertEqual(customer, None)
|
|
|
|
customer = Customer.objects.create(realm=user.realm, stripe_customer_id="cus_12345")
|
|
self.assertEqual(billing_session.get_customer(), customer)
|
|
|
|
|
|
class TestRemoteRealmBillingSession(StripeTestCase):
|
|
def test_current_count_for_billed_licenses(self) -> None:
|
|
server_uuid = str(uuid.uuid4())
|
|
remote_server = RemoteZulipServer.objects.create(
|
|
uuid=server_uuid,
|
|
api_key="magic_secret_api_key",
|
|
hostname="demo.example.com",
|
|
contact_email="email@example.com",
|
|
)
|
|
realm_uuid = str(uuid.uuid4())
|
|
remote_realm = RemoteRealm.objects.create(
|
|
server=remote_server,
|
|
uuid=realm_uuid,
|
|
uuid_owner_secret="dummy-owner-secret",
|
|
host="dummy-hostname",
|
|
realm_date_created=timezone_now(),
|
|
)
|
|
billing_session = RemoteRealmBillingSession(remote_realm=remote_realm)
|
|
|
|
# remote server never uploaded statistics. 'last_audit_log_update' is None.
|
|
with self.assertRaises(MissingDataError):
|
|
billing_session.current_count_for_billed_licenses()
|
|
|
|
# Available statistics is stale.
|
|
remote_server.last_audit_log_update = timezone_now() - timedelta(days=5)
|
|
remote_server.save()
|
|
with self.assertRaises(MissingDataError):
|
|
billing_session.current_count_for_billed_licenses()
|
|
|
|
# Available statistics is not stale.
|
|
event_time = timezone_now() - timedelta(days=1)
|
|
data_list = [
|
|
{
|
|
"server": remote_server,
|
|
"remote_realm": remote_realm,
|
|
"event_type": AuditLogEventType.USER_CREATED,
|
|
"event_time": event_time,
|
|
"extra_data": {
|
|
RemoteRealmAuditLog.ROLE_COUNT: {
|
|
RemoteRealmAuditLog.ROLE_COUNT_HUMANS: {
|
|
UserProfile.ROLE_REALM_ADMINISTRATOR: 10,
|
|
UserProfile.ROLE_REALM_OWNER: 10,
|
|
UserProfile.ROLE_MODERATOR: 10,
|
|
UserProfile.ROLE_MEMBER: 10,
|
|
UserProfile.ROLE_GUEST: 10,
|
|
}
|
|
}
|
|
},
|
|
},
|
|
{
|
|
"server": remote_server,
|
|
"remote_realm": remote_realm,
|
|
"event_type": AuditLogEventType.USER_ROLE_CHANGED,
|
|
"event_time": event_time,
|
|
"extra_data": {
|
|
RemoteRealmAuditLog.ROLE_COUNT: {
|
|
RemoteRealmAuditLog.ROLE_COUNT_HUMANS: {
|
|
UserProfile.ROLE_REALM_ADMINISTRATOR: 20,
|
|
UserProfile.ROLE_REALM_OWNER: 10,
|
|
UserProfile.ROLE_MODERATOR: 0,
|
|
UserProfile.ROLE_MEMBER: 30,
|
|
UserProfile.ROLE_GUEST: 10,
|
|
}
|
|
}
|
|
},
|
|
},
|
|
]
|
|
RemoteRealmAuditLog.objects.bulk_create([RemoteRealmAuditLog(**data) for data in data_list])
|
|
remote_server.last_audit_log_update = timezone_now() - timedelta(days=1)
|
|
remote_server.save()
|
|
|
|
self.assertEqual(billing_session.current_count_for_billed_licenses(), 70)
|
|
|
|
|
|
class TestRemoteServerBillingSession(StripeTestCase):
|
|
def test_get_audit_log_error(self) -> None:
|
|
server_uuid = str(uuid.uuid4())
|
|
remote_server = RemoteZulipServer.objects.create(
|
|
uuid=server_uuid,
|
|
api_key="magic_secret_api_key",
|
|
hostname="demo.example.com",
|
|
contact_email="email@example.com",
|
|
)
|
|
billing_session = RemoteServerBillingSession(remote_server)
|
|
fake_audit_log = typing.cast(BillingSessionEventType, 0)
|
|
with self.assertRaisesRegex(
|
|
BillingSessionAuditLogEventError, "Unknown audit log event type: 0"
|
|
):
|
|
billing_session.get_audit_log_event(event_type=fake_audit_log)
|
|
|
|
def test_get_customer(self) -> None:
|
|
server_uuid = str(uuid.uuid4())
|
|
remote_server = RemoteZulipServer.objects.create(
|
|
uuid=server_uuid,
|
|
api_key="magic_secret_api_key",
|
|
hostname="demo.example.com",
|
|
contact_email="email@example.com",
|
|
)
|
|
billing_session = RemoteServerBillingSession(remote_server)
|
|
customer = billing_session.get_customer()
|
|
self.assertEqual(customer, None)
|
|
|
|
customer = Customer.objects.create(
|
|
remote_server=remote_server, stripe_customer_id="cus_12345"
|
|
)
|
|
self.assertEqual(billing_session.get_customer(), customer)
|
|
|
|
# @mock_stripe
|
|
# def test_update_or_create_stripe_customer(self) -> None:
|
|
# server_uuid = str(uuid.uuid4())
|
|
# remote_server = RemoteZulipServer.objects.create(
|
|
# uuid=server_uuid,
|
|
# api_key="magic_secret_api_key",
|
|
# hostname="demo.example.com",
|
|
# contact_email="email@example.com",
|
|
# )
|
|
# billing_session = RemoteServerBillingSession(remote_server)
|
|
# # We need to generate stripe fixture for this type of test.
|
|
# customer = billing_session.update_or_create_stripe_customer()
|
|
# assert customer.stripe_customer_id
|
|
# # Confirm audit log, etc.
|
|
|
|
|
|
class TestSupportBillingHelpers(StripeTestCase):
|
|
@mock_stripe()
|
|
def test_attach_discount_to_realm(self, *mocks: Mock) -> None:
|
|
# Attach discount before Stripe customer exists
|
|
support_admin = self.example_user("iago")
|
|
user = self.example_user("hamlet")
|
|
billing_session = RealmBillingSession(support_admin, realm=user.realm, support_session=True)
|
|
|
|
# Cannot attach discount without a required_plan_tier set.
|
|
with self.assertRaises(AssertionError):
|
|
billing_session.attach_discount_to_customer(
|
|
monthly_discounted_price=120,
|
|
annual_discounted_price=1200,
|
|
)
|
|
billing_session.update_or_create_customer()
|
|
|
|
with self.assertRaises(AssertionError):
|
|
billing_session.attach_discount_to_customer(
|
|
monthly_discounted_price=120,
|
|
annual_discounted_price=1200,
|
|
)
|
|
|
|
billing_session.set_required_plan_tier(CustomerPlan.TIER_CLOUD_STANDARD)
|
|
billing_session.attach_discount_to_customer(
|
|
monthly_discounted_price=120,
|
|
annual_discounted_price=1200,
|
|
)
|
|
realm_audit_log = RealmAuditLog.objects.filter(
|
|
event_type=AuditLogEventType.REALM_DISCOUNT_CHANGED
|
|
).last()
|
|
assert realm_audit_log is not None
|
|
expected_extra_data = {
|
|
"new_annual_discounted_price": 1200,
|
|
"new_monthly_discounted_price": 120,
|
|
"old_annual_discounted_price": 0,
|
|
"old_monthly_discounted_price": 0,
|
|
}
|
|
self.assertEqual(realm_audit_log.extra_data, expected_extra_data)
|
|
self.login_user(user)
|
|
# Check that the discount appears in page_params
|
|
self.assert_in_success_response(["85"], self.client_get("/upgrade/"))
|
|
# Check that the customer was charged the discounted amount
|
|
self.add_card_and_upgrade(user)
|
|
customer = Customer.objects.first()
|
|
assert customer is not None
|
|
assert customer.stripe_customer_id is not None
|
|
[charge] = iter(stripe.Charge.list(customer=customer.stripe_customer_id))
|
|
self.assertEqual(1200 * self.seat_count, charge.amount)
|
|
stripe_customer_id = customer.stripe_customer_id
|
|
assert stripe_customer_id is not None
|
|
[invoice] = iter(stripe.Invoice.list(customer=stripe_customer_id))
|
|
self.assertEqual(
|
|
[1200 * self.seat_count],
|
|
[item.amount for item in invoice.lines],
|
|
)
|
|
# Check CustomerPlan reflects the discount
|
|
plan = CustomerPlan.objects.get(price_per_license=1200, discount="85")
|
|
|
|
# Attach discount to existing Stripe customer
|
|
plan.status = CustomerPlan.ENDED
|
|
plan.save(update_fields=["status"])
|
|
billing_session = RealmBillingSession(support_admin, realm=user.realm, support_session=True)
|
|
billing_session.set_required_plan_tier(CustomerPlan.TIER_CLOUD_STANDARD)
|
|
billing_session.attach_discount_to_customer(
|
|
monthly_discounted_price=600,
|
|
annual_discounted_price=6000,
|
|
)
|
|
with time_machine.travel(self.now, tick=False):
|
|
self.add_card_and_upgrade(
|
|
user, license_management="automatic", billing_modality="charge_automatically"
|
|
)
|
|
[charge, _] = iter(stripe.Charge.list(customer=customer.stripe_customer_id))
|
|
self.assertEqual(6000 * self.seat_count, charge.amount)
|
|
stripe_customer_id = customer.stripe_customer_id
|
|
assert stripe_customer_id is not None
|
|
[invoice, _] = iter(stripe.Invoice.list(customer=stripe_customer_id))
|
|
self.assertEqual(
|
|
[6000 * self.seat_count],
|
|
[item.amount for item in invoice.lines],
|
|
)
|
|
plan = CustomerPlan.objects.get(price_per_license=6000, discount=Decimal(25))
|
|
|
|
billing_session = RealmBillingSession(support_admin, realm=user.realm, support_session=True)
|
|
billing_session.attach_discount_to_customer(
|
|
monthly_discounted_price=400,
|
|
annual_discounted_price=4000,
|
|
)
|
|
plan.refresh_from_db()
|
|
self.assertEqual(plan.price_per_license, 4000)
|
|
self.assertEqual(plan.discount, "50")
|
|
customer.refresh_from_db()
|
|
self.assertEqual(customer.monthly_discounted_price, 400)
|
|
self.assertEqual(customer.annual_discounted_price, 4000)
|
|
# Fast forward the next_invoice_date to next year.
|
|
plan.next_invoice_date = self.next_year
|
|
plan.save(update_fields=["next_invoice_date"])
|
|
invoice_plans_as_needed(self.next_year + timedelta(days=10))
|
|
stripe_customer_id = customer.stripe_customer_id
|
|
assert stripe_customer_id is not None
|
|
[invoice, _, _] = iter(stripe.Invoice.list(customer=stripe_customer_id))
|
|
self.assertEqual([4000 * self.seat_count], [item.amount for item in invoice.lines])
|
|
realm_audit_log = RealmAuditLog.objects.filter(
|
|
event_type=AuditLogEventType.REALM_DISCOUNT_CHANGED
|
|
).last()
|
|
assert realm_audit_log is not None
|
|
expected_extra_data = {
|
|
"new_annual_discounted_price": 4000,
|
|
"new_monthly_discounted_price": 400,
|
|
"old_annual_discounted_price": 6000,
|
|
"old_monthly_discounted_price": 600,
|
|
}
|
|
self.assertEqual(realm_audit_log.extra_data, expected_extra_data)
|
|
self.assertEqual(realm_audit_log.acting_user, support_admin)
|
|
|
|
# Confirm that once a plan has been purchased and is active,
|
|
# approving a full sponsorship (our version of 100% discount) fails.
|
|
with self.assertRaisesRegex(
|
|
SupportRequestError,
|
|
"Customer on plan Zulip Cloud Standard. Please end current plan before approving sponsorship!",
|
|
):
|
|
billing_session.approve_sponsorship()
|
|
|
|
@mock_stripe()
|
|
def test_add_minimum_licenses(self, *mocks: Mock) -> None:
|
|
min_licenses = 25
|
|
support_view_request = SupportViewRequest(
|
|
support_type=SupportType.update_minimum_licenses, minimum_licenses=min_licenses
|
|
)
|
|
support_admin = self.example_user("iago")
|
|
user = self.example_user("hamlet")
|
|
billing_session = RealmBillingSession(support_admin, realm=user.realm, support_session=True)
|
|
|
|
billing_session.update_or_create_customer()
|
|
with self.assertRaisesRegex(
|
|
SupportRequestError,
|
|
"Discount for zulip must be updated before setting a minimum number of licenses.",
|
|
):
|
|
billing_session.process_support_view_request(support_view_request)
|
|
|
|
billing_session.set_required_plan_tier(CustomerPlan.TIER_CLOUD_STANDARD)
|
|
billing_session.attach_discount_to_customer(
|
|
monthly_discounted_price=400,
|
|
annual_discounted_price=4000,
|
|
)
|
|
message = billing_session.process_support_view_request(support_view_request)
|
|
self.assertEqual("Minimum licenses for zulip changed to 25 from 0.", message)
|
|
realm_audit_log = RealmAuditLog.objects.filter(
|
|
event_type=AuditLogEventType.CUSTOMER_PROPERTY_CHANGED
|
|
).last()
|
|
assert realm_audit_log is not None
|
|
expected_extra_data = {"old_value": None, "new_value": 25, "property": "minimum_licenses"}
|
|
self.assertEqual(realm_audit_log.extra_data, expected_extra_data)
|
|
|
|
self.login_user(user)
|
|
self.add_card_and_upgrade(user)
|
|
customer = billing_session.get_customer()
|
|
assert customer is not None
|
|
assert customer.stripe_customer_id is not None
|
|
[charge] = iter(stripe.Charge.list(customer=customer.stripe_customer_id))
|
|
self.assertEqual(4000 * min_licenses, charge.amount)
|
|
|
|
min_licenses = 50
|
|
support_view_request = SupportViewRequest(
|
|
support_type=SupportType.update_minimum_licenses, minimum_licenses=min_licenses
|
|
)
|
|
with self.assertRaisesRegex(
|
|
SupportRequestError,
|
|
"Cannot set minimum licenses; active plan already exists for zulip.",
|
|
):
|
|
billing_session.process_support_view_request(support_view_request)
|
|
|
|
def test_set_required_plan_tier(self) -> None:
|
|
valid_plan_tier = CustomerPlan.TIER_CLOUD_STANDARD
|
|
support_view_request = SupportViewRequest(
|
|
support_type=SupportType.update_required_plan_tier,
|
|
required_plan_tier=valid_plan_tier,
|
|
)
|
|
support_admin = self.example_user("iago")
|
|
user = self.example_user("hamlet")
|
|
billing_session = RealmBillingSession(support_admin, realm=user.realm, support_session=True)
|
|
customer = billing_session.get_customer()
|
|
assert customer is None
|
|
|
|
# Set valid plan tier - creates Customer object
|
|
message = billing_session.process_support_view_request(support_view_request)
|
|
self.assertEqual("Required plan tier for zulip set to Zulip Cloud Standard.", message)
|
|
realm_audit_log = RealmAuditLog.objects.filter(
|
|
event_type=AuditLogEventType.CUSTOMER_PROPERTY_CHANGED
|
|
).last()
|
|
assert realm_audit_log is not None
|
|
expected_extra_data = {
|
|
"old_value": None,
|
|
"new_value": valid_plan_tier,
|
|
"property": "required_plan_tier",
|
|
}
|
|
self.assertEqual(realm_audit_log.extra_data, expected_extra_data)
|
|
customer = billing_session.get_customer()
|
|
assert customer is not None
|
|
self.assertEqual(customer.required_plan_tier, valid_plan_tier)
|
|
self.assertEqual(customer.monthly_discounted_price, 0)
|
|
self.assertEqual(customer.annual_discounted_price, 0)
|
|
|
|
# Check that discount is only applied to set plan tier
|
|
billing_session.attach_discount_to_customer(
|
|
monthly_discounted_price=400,
|
|
annual_discounted_price=4000,
|
|
)
|
|
customer.refresh_from_db()
|
|
self.assertEqual(customer.monthly_discounted_price, 400)
|
|
self.assertEqual(customer.annual_discounted_price, 4000)
|
|
|
|
monthly_discounted_price = customer.get_discounted_price_for_plan(
|
|
valid_plan_tier, CustomerPlan.BILLING_SCHEDULE_MONTHLY
|
|
)
|
|
self.assertEqual(monthly_discounted_price, customer.monthly_discounted_price)
|
|
annual_discounted_price = customer.get_discounted_price_for_plan(
|
|
valid_plan_tier, CustomerPlan.BILLING_SCHEDULE_ANNUAL
|
|
)
|
|
self.assertEqual(annual_discounted_price, customer.annual_discounted_price)
|
|
monthly_discounted_price = customer.get_discounted_price_for_plan(
|
|
CustomerPlan.TIER_CLOUD_PLUS, CustomerPlan.BILLING_SCHEDULE_MONTHLY
|
|
)
|
|
self.assertEqual(monthly_discounted_price, None)
|
|
annual_discounted_price = customer.get_discounted_price_for_plan(
|
|
CustomerPlan.TIER_CLOUD_PLUS, CustomerPlan.BILLING_SCHEDULE_ANNUAL
|
|
)
|
|
self.assertEqual(annual_discounted_price, None)
|
|
|
|
# Try to set invalid plan tier
|
|
invalid_plan_tier = CustomerPlan.TIER_SELF_HOSTED_BASE
|
|
support_view_request = SupportViewRequest(
|
|
support_type=SupportType.update_required_plan_tier,
|
|
required_plan_tier=invalid_plan_tier,
|
|
)
|
|
with self.assertRaisesRegex(SupportRequestError, "Invalid plan tier for zulip."):
|
|
billing_session.process_support_view_request(support_view_request)
|
|
|
|
# Cannot set required plan tier to None before setting discount to 0.
|
|
support_view_request = SupportViewRequest(
|
|
support_type=SupportType.update_required_plan_tier, required_plan_tier=0
|
|
)
|
|
with self.assertRaisesRegex(
|
|
SupportRequestError,
|
|
"Discount for zulip must be 0 before setting required plan tier to None.",
|
|
):
|
|
billing_session.process_support_view_request(support_view_request)
|
|
|
|
billing_session.attach_discount_to_customer(
|
|
monthly_discounted_price=0,
|
|
annual_discounted_price=0,
|
|
)
|
|
message = billing_session.process_support_view_request(support_view_request)
|
|
self.assertEqual("Required plan tier for zulip set to None.", message)
|
|
customer.refresh_from_db()
|
|
self.assertIsNone(customer.required_plan_tier)
|
|
discount_for_standard_plan = customer.get_discounted_price_for_plan(
|
|
valid_plan_tier, CustomerPlan.BILLING_SCHEDULE_MONTHLY
|
|
)
|
|
self.assertEqual(discount_for_standard_plan, None)
|
|
discount_for_plus_plan = customer.get_discounted_price_for_plan(
|
|
CustomerPlan.TIER_CLOUD_PLUS, CustomerPlan.BILLING_SCHEDULE_MONTHLY
|
|
)
|
|
self.assertEqual(discount_for_plus_plan, None)
|
|
realm_audit_log = RealmAuditLog.objects.filter(
|
|
event_type=AuditLogEventType.CUSTOMER_PROPERTY_CHANGED
|
|
).last()
|
|
assert realm_audit_log is not None
|
|
expected_extra_data = {
|
|
"old_value": valid_plan_tier,
|
|
"new_value": None,
|
|
"property": "required_plan_tier",
|
|
}
|
|
self.assertEqual(realm_audit_log.extra_data, expected_extra_data)
|
|
|
|
def test_approve_realm_sponsorship(self) -> None:
|
|
realm = get_realm("zulip")
|
|
self.assertNotEqual(realm.plan_type, Realm.PLAN_TYPE_STANDARD_FREE)
|
|
|
|
support_admin = self.example_user("iago")
|
|
billing_session = RealmBillingSession(user=support_admin, realm=realm, support_session=True)
|
|
billing_session.approve_sponsorship()
|
|
self.assertEqual(realm.plan_type, Realm.PLAN_TYPE_STANDARD_FREE)
|
|
|
|
expected_message = (
|
|
"Your organization's request for sponsored hosting has been approved! You have been upgraded to Zulip Cloud Standard, free of charge. :tada:"
|
|
"\n\nIf you could [list Zulip as a sponsor on your website](/help/linking-to-zulip-website), we would really appreciate it!"
|
|
)
|
|
sender = get_system_bot(settings.NOTIFICATION_BOT, realm.id)
|
|
|
|
# Organization owners get the notification bot message
|
|
desdemona_recipient = self.example_user("desdemona").recipient
|
|
message_to_owner = Message.objects.filter(
|
|
realm_id=realm.id, sender=sender.id, recipient=desdemona_recipient
|
|
).first()
|
|
assert message_to_owner is not None
|
|
self.assertEqual(message_to_owner.content, expected_message)
|
|
self.assertEqual(message_to_owner.recipient.type, Recipient.PERSONAL)
|
|
|
|
# Hamlet is in `can_manage_billing_group` so should get the notification bot message
|
|
hamlet_recipient = self.example_user("hamlet").recipient
|
|
message_to_hamlet = Message.objects.filter(
|
|
realm_id=realm.id, sender=sender.id, recipient=hamlet_recipient
|
|
).first()
|
|
assert message_to_hamlet is not None
|
|
self.assertEqual(message_to_hamlet.content, expected_message)
|
|
self.assertEqual(message_to_hamlet.recipient.type, Recipient.PERSONAL)
|
|
|
|
def test_update_realm_sponsorship_status(self) -> None:
|
|
lear = get_realm("lear")
|
|
iago = self.example_user("iago")
|
|
billing_session = RealmBillingSession(user=iago, realm=lear, support_session=True)
|
|
billing_session.update_customer_sponsorship_status(True)
|
|
customer = get_customer_by_realm(realm=lear)
|
|
assert customer is not None
|
|
self.assertTrue(customer.sponsorship_pending)
|
|
realm_audit_log = RealmAuditLog.objects.filter(
|
|
event_type=AuditLogEventType.REALM_SPONSORSHIP_PENDING_STATUS_CHANGED
|
|
).last()
|
|
assert realm_audit_log is not None
|
|
expected_extra_data = {"sponsorship_pending": True}
|
|
self.assertEqual(realm_audit_log.extra_data, expected_extra_data)
|
|
self.assertEqual(realm_audit_log.acting_user, iago)
|
|
|
|
def test_update_realm_billing_modality(self) -> None:
|
|
realm = get_realm("zulip")
|
|
customer = Customer.objects.create(realm=realm, stripe_customer_id="cus_12345")
|
|
plan = CustomerPlan.objects.create(
|
|
customer=customer,
|
|
status=CustomerPlan.ACTIVE,
|
|
billing_cycle_anchor=timezone_now(),
|
|
billing_schedule=CustomerPlan.BILLING_SCHEDULE_ANNUAL,
|
|
tier=CustomerPlan.TIER_CLOUD_STANDARD,
|
|
)
|
|
self.assertEqual(plan.charge_automatically, False)
|
|
|
|
support_admin = self.example_user("iago")
|
|
billing_session = RealmBillingSession(user=support_admin, realm=realm, support_session=True)
|
|
billing_session.update_billing_modality_of_current_plan(True)
|
|
plan.refresh_from_db()
|
|
self.assertEqual(plan.charge_automatically, True)
|
|
realm_audit_log = RealmAuditLog.objects.filter(
|
|
event_type=AuditLogEventType.REALM_BILLING_MODALITY_CHANGED
|
|
).last()
|
|
assert realm_audit_log is not None
|
|
expected_extra_data = {"charge_automatically": plan.charge_automatically}
|
|
self.assertEqual(realm_audit_log.acting_user, support_admin)
|
|
self.assertEqual(realm_audit_log.extra_data, expected_extra_data)
|
|
|
|
billing_session.update_billing_modality_of_current_plan(False)
|
|
plan.refresh_from_db()
|
|
self.assertEqual(plan.charge_automatically, False)
|
|
realm_audit_log = RealmAuditLog.objects.filter(
|
|
event_type=AuditLogEventType.REALM_BILLING_MODALITY_CHANGED
|
|
).last()
|
|
assert realm_audit_log is not None
|
|
expected_extra_data = {"charge_automatically": plan.charge_automatically}
|
|
self.assertEqual(realm_audit_log.acting_user, support_admin)
|
|
self.assertEqual(realm_audit_log.extra_data, expected_extra_data)
|
|
|
|
@mock_stripe()
|
|
def test_switch_realm_from_standard_to_plus_plan(self, *mocks: Mock) -> None:
|
|
user = self.example_user("hamlet")
|
|
self.login_user(user)
|
|
self.add_card_and_upgrade(user)
|
|
customer = get_customer_by_realm(user.realm)
|
|
assert customer is not None
|
|
original_plan = get_current_plan_by_customer(customer)
|
|
assert original_plan is not None
|
|
self.assertEqual(original_plan.tier, CustomerPlan.TIER_CLOUD_STANDARD)
|
|
|
|
support_admin = self.example_user("iago")
|
|
billing_session = RealmBillingSession(
|
|
user=support_admin, realm=user.realm, support_session=True
|
|
)
|
|
support_request = SupportViewRequest(
|
|
support_type=SupportType.modify_plan,
|
|
plan_modification="upgrade_plan_tier",
|
|
new_plan_tier=CustomerPlan.TIER_CLOUD_PLUS,
|
|
)
|
|
success_message = billing_session.process_support_view_request(support_request)
|
|
self.assertEqual(success_message, "zulip upgraded to Zulip Cloud Plus")
|
|
customer.refresh_from_db()
|
|
new_plan = get_current_plan_by_customer(customer)
|
|
assert new_plan is not None
|
|
self.assertEqual(new_plan.tier, CustomerPlan.TIER_CLOUD_PLUS)
|
|
|
|
@mock_stripe()
|
|
def test_downgrade_realm_and_void_open_invoices(self, *mocks: Mock) -> None:
|
|
user = self.example_user("hamlet")
|
|
self.login_user(user)
|
|
with time_machine.travel(self.now, tick=False):
|
|
self.upgrade(invoice=True)
|
|
customer = get_customer_by_realm(user.realm)
|
|
assert customer is not None
|
|
original_plan = get_current_plan_by_customer(customer)
|
|
assert original_plan is not None
|
|
self.assertEqual(original_plan.status, CustomerPlan.ACTIVE)
|
|
|
|
support_admin = self.example_user("iago")
|
|
billing_session = RealmBillingSession(
|
|
user=support_admin, realm=user.realm, support_session=True
|
|
)
|
|
|
|
# Send renewal invoice.
|
|
invoice_plans_as_needed(self.now + timedelta(days=367))
|
|
|
|
support_request = SupportViewRequest(
|
|
support_type=SupportType.modify_plan,
|
|
plan_modification="downgrade_now_void_open_invoices",
|
|
)
|
|
success_message = billing_session.process_support_view_request(support_request)
|
|
self.assertEqual(success_message, "zulip downgraded and voided 1 open invoices")
|
|
original_plan.refresh_from_db()
|
|
self.assertEqual(original_plan.status, CustomerPlan.ENDED)
|
|
|
|
|
|
class TestRemoteBillingWriteAuditLog(StripeTestCase):
|
|
def test_write_audit_log(self) -> None:
|
|
support_admin = self.example_user("iago")
|
|
server_uuid = str(uuid.uuid4())
|
|
remote_server = RemoteZulipServer.objects.create(
|
|
uuid=server_uuid,
|
|
api_key="magic_secret_api_key",
|
|
hostname="demo.example.com",
|
|
contact_email="email@example.com",
|
|
)
|
|
realm_uuid = str(uuid.uuid4())
|
|
remote_realm = RemoteRealm.objects.create(
|
|
server=remote_server,
|
|
uuid=realm_uuid,
|
|
uuid_owner_secret="dummy-owner-secret",
|
|
host="dummy-hostname",
|
|
realm_date_created=timezone_now(),
|
|
)
|
|
remote_realm_billing_user = RemoteRealmBillingUser.objects.create(
|
|
remote_realm=remote_realm, email="admin@example.com", user_uuid=uuid.uuid4()
|
|
)
|
|
remote_server_billing_user = RemoteServerBillingUser.objects.create(
|
|
remote_server=remote_server, email="admin@example.com"
|
|
)
|
|
event_time = timezone_now()
|
|
|
|
def assert_audit_log(
|
|
audit_log: RemoteRealmAuditLog | RemoteZulipServerAuditLog,
|
|
acting_remote_user: RemoteRealmBillingUser | RemoteServerBillingUser | None,
|
|
acting_support_user: UserProfile | None,
|
|
event_type: int,
|
|
event_time: datetime,
|
|
) -> None:
|
|
self.assertEqual(audit_log.event_type, event_type)
|
|
self.assertEqual(audit_log.event_time, event_time)
|
|
self.assertEqual(audit_log.acting_remote_user, acting_remote_user)
|
|
self.assertEqual(audit_log.acting_support_user, acting_support_user)
|
|
|
|
for session_class, audit_log_class, remote_object, remote_user in [
|
|
(
|
|
RemoteRealmBillingSession,
|
|
RemoteRealmAuditLog,
|
|
remote_realm,
|
|
remote_realm_billing_user,
|
|
),
|
|
(
|
|
RemoteServerBillingSession,
|
|
RemoteZulipServerAuditLog,
|
|
remote_server,
|
|
remote_server_billing_user,
|
|
),
|
|
]:
|
|
# Necessary cast or mypy doesn't understand that we can use Django's
|
|
# model .objects. style queries on this.
|
|
audit_log_model = cast(
|
|
type[RemoteRealmAuditLog] | type[RemoteZulipServerAuditLog], audit_log_class
|
|
)
|
|
assert isinstance(remote_user, RemoteRealmBillingUser | RemoteServerBillingUser)
|
|
# No acting user:
|
|
session = session_class(remote_object)
|
|
session.write_to_audit_log(
|
|
# This "ordinary billing" event type value gets translated by write_to_audit_log
|
|
# into a AuditLogEventType.CUSTOMER_PLAN_CREATED value.
|
|
event_type=BillingSessionEventType.CUSTOMER_PLAN_CREATED,
|
|
event_time=event_time,
|
|
)
|
|
audit_log = audit_log_model.objects.latest("id")
|
|
assert_audit_log(
|
|
audit_log, None, None, AuditLogEventType.CUSTOMER_PLAN_CREATED, event_time
|
|
)
|
|
|
|
session = session_class(remote_object, remote_billing_user=remote_user)
|
|
session.write_to_audit_log(
|
|
event_type=BillingSessionEventType.CUSTOMER_PLAN_CREATED,
|
|
event_time=event_time,
|
|
)
|
|
audit_log = audit_log_model.objects.latest("id")
|
|
assert_audit_log(
|
|
audit_log, remote_user, None, AuditLogEventType.CUSTOMER_PLAN_CREATED, event_time
|
|
)
|
|
|
|
session = session_class(
|
|
remote_object, remote_billing_user=None, support_staff=support_admin
|
|
)
|
|
session.write_to_audit_log(
|
|
event_type=BillingSessionEventType.CUSTOMER_PLAN_CREATED,
|
|
event_time=event_time,
|
|
)
|
|
audit_log = audit_log_model.objects.latest("id")
|
|
assert_audit_log(
|
|
audit_log, None, support_admin, AuditLogEventType.CUSTOMER_PLAN_CREATED, event_time
|
|
)
|
|
|
|
|
|
@activate_push_notification_service()
|
|
class TestRemoteRealmBillingFlow(StripeTestCase, RemoteRealmBillingTestCase):
|
|
@override
|
|
def setUp(self) -> None:
|
|
super().setUp()
|
|
|
|
# Reset already created audit logs for this test as they have
|
|
# event_time=timezone_now() that will affects the LicenseLedger
|
|
# queries as their event_time would be more recent than other
|
|
# operations we perform in this test.
|
|
zulip_realm = get_realm("zulip")
|
|
RealmAuditLog.objects.filter(
|
|
realm=zulip_realm, event_type__in=RealmAuditLog.SYNCED_BILLING_EVENTS
|
|
).delete()
|
|
with time_machine.travel(self.now, tick=False):
|
|
for count in range(4):
|
|
do_create_user(
|
|
f"email {count}",
|
|
f"password {count}",
|
|
zulip_realm,
|
|
"name",
|
|
acting_user=None,
|
|
)
|
|
|
|
self.remote_realm = RemoteRealm.objects.get(uuid=zulip_realm.uuid)
|
|
self.billing_session = RemoteRealmBillingSession(remote_realm=self.remote_realm)
|
|
|
|
@responses.activate
|
|
@mock_stripe()
|
|
def test_upgrade_user_to_business_plan(self, *mocks: Mock) -> None:
|
|
self.login("hamlet")
|
|
hamlet = self.example_user("hamlet")
|
|
|
|
self.add_mock_response()
|
|
realm_user_count = UserProfile.objects.filter(
|
|
realm=hamlet.realm, is_bot=False, is_active=True
|
|
).count()
|
|
self.assertEqual(realm_user_count, 11)
|
|
|
|
with time_machine.travel(self.now, tick=False):
|
|
send_server_data_to_push_bouncer(consider_usage_statistics=False)
|
|
|
|
result = self.execute_remote_billing_authentication_flow(hamlet)
|
|
self.assertEqual(result.status_code, 302)
|
|
self.assertEqual(result["Location"], f"{self.billing_session.billing_base_url}/plans/")
|
|
|
|
# upgrade to business plan
|
|
with time_machine.travel(self.now, tick=False):
|
|
result = self.client_get(
|
|
f"{self.billing_session.billing_base_url}/upgrade/", subdomain="selfhosting"
|
|
)
|
|
self.assertEqual(result.status_code, 200)
|
|
|
|
# Min licenses used since org has less users.
|
|
min_licenses = self.billing_session.min_licenses_for_plan(
|
|
CustomerPlan.TIER_SELF_HOSTED_BUSINESS
|
|
)
|
|
self.assertEqual(min_licenses, 25)
|
|
flat_discount, flat_discounted_months = self.billing_session.get_flat_discount_info()
|
|
self.assertEqual(flat_discounted_months, 12)
|
|
|
|
self.assert_in_success_response(
|
|
[
|
|
"Minimum purchase for",
|
|
f"{min_licenses} licenses",
|
|
"Add card",
|
|
"Purchase Zulip Business",
|
|
],
|
|
result,
|
|
)
|
|
|
|
# Same result even with free trial enabled for self hosted customers since we don't
|
|
# offer free trial for business plan.
|
|
with (
|
|
self.settings(SELF_HOSTING_FREE_TRIAL_DAYS=30),
|
|
time_machine.travel(self.now, tick=False),
|
|
):
|
|
result = self.client_get(
|
|
f"{self.billing_session.billing_base_url}/upgrade/", subdomain="selfhosting"
|
|
)
|
|
|
|
self.assert_in_success_response(
|
|
[
|
|
"Minimum purchase for",
|
|
f"{min_licenses} licenses",
|
|
"Add card",
|
|
"Purchase Zulip Business",
|
|
],
|
|
result,
|
|
)
|
|
|
|
# Check that cloud free trials don't affect self hosted customers.
|
|
with self.settings(CLOUD_FREE_TRIAL_DAYS=30), time_machine.travel(self.now, tick=False):
|
|
result = self.client_get(
|
|
f"{self.billing_session.billing_base_url}/upgrade/", subdomain="selfhosting"
|
|
)
|
|
|
|
self.assert_in_success_response(
|
|
[
|
|
"Minimum purchase for",
|
|
f"{min_licenses} licenses",
|
|
"Add card",
|
|
"Purchase Zulip Business",
|
|
],
|
|
result,
|
|
)
|
|
|
|
self.assertFalse(Customer.objects.exists())
|
|
self.assertFalse(CustomerPlan.objects.exists())
|
|
self.assertFalse(LicenseLedger.objects.exists())
|
|
|
|
with time_machine.travel(self.now, tick=False):
|
|
stripe_customer = self.add_card_and_upgrade()
|
|
|
|
customer = Customer.objects.get(stripe_customer_id=stripe_customer.id)
|
|
plan = CustomerPlan.objects.get(customer=customer)
|
|
LicenseLedger.objects.get(plan=plan)
|
|
|
|
with time_machine.travel(self.now + timedelta(days=1), tick=False):
|
|
response = self.client_get(
|
|
f"{self.billing_session.billing_base_url}/billing/", subdomain="selfhosting"
|
|
)
|
|
for substring in [
|
|
"Zulip Business",
|
|
"Number of licenses",
|
|
f"{min_licenses}",
|
|
"January 2, 2013",
|
|
"Your plan will automatically renew on",
|
|
f"${80 * min_licenses:,.2f}",
|
|
"Visa ending in 4242",
|
|
"Update card",
|
|
]:
|
|
self.assert_in_response(substring, response)
|
|
|
|
# Verify that change in user count updates LicenseLedger.
|
|
audit_log_count = RemoteRealmAuditLog.objects.count()
|
|
self.assertEqual(LicenseLedger.objects.count(), 1)
|
|
|
|
with time_machine.travel(self.now + timedelta(days=2), tick=False):
|
|
for count in range(realm_user_count, min_licenses + 10):
|
|
do_create_user(
|
|
f"email {count}",
|
|
f"password {count}",
|
|
hamlet.realm,
|
|
"name",
|
|
role=UserProfile.ROLE_MEMBER,
|
|
acting_user=None,
|
|
)
|
|
|
|
with time_machine.travel(self.now + timedelta(days=3), tick=False):
|
|
send_server_data_to_push_bouncer(consider_usage_statistics=False)
|
|
|
|
self.assertEqual(
|
|
RemoteRealmAuditLog.objects.count(),
|
|
min_licenses + 10 - realm_user_count + audit_log_count,
|
|
)
|
|
latest_ledger = LicenseLedger.objects.last()
|
|
assert latest_ledger is not None
|
|
self.assertEqual(latest_ledger.licenses, min_licenses + 10)
|
|
|
|
with time_machine.travel(self.now + timedelta(days=3), tick=False):
|
|
response = self.client_get(
|
|
f"{self.billing_session.billing_base_url}/billing/", subdomain="selfhosting"
|
|
)
|
|
|
|
self.assertEqual(latest_ledger.licenses, 35)
|
|
for substring in [
|
|
"Zulip Business",
|
|
"Number of licenses",
|
|
f"{latest_ledger.licenses}",
|
|
"January 2, 2013",
|
|
"Your plan will automatically renew on",
|
|
f"${80 * latest_ledger.licenses:,.2f}",
|
|
"Visa ending in 4242",
|
|
"Update card",
|
|
]:
|
|
self.assert_in_response(substring, response)
|
|
|
|
@responses.activate
|
|
@mock_stripe()
|
|
def test_stripe_billing_portal_urls_for_remote_realm(self, *mocks: Mock) -> None:
|
|
self.login("hamlet")
|
|
hamlet = self.example_user("hamlet")
|
|
|
|
self.add_mock_response()
|
|
with time_machine.travel(self.now, tick=False):
|
|
send_server_data_to_push_bouncer(consider_usage_statistics=False)
|
|
|
|
self.execute_remote_billing_authentication_flow(hamlet)
|
|
self.add_card_and_upgrade()
|
|
|
|
response = self.client_get(
|
|
f"{self.billing_session.billing_base_url}/invoices/", subdomain="selfhosting"
|
|
)
|
|
self.assertEqual(response.status_code, 302)
|
|
self.assertTrue(response["Location"].startswith("https://billing.stripe.com"))
|
|
|
|
response = self.client_get(
|
|
f"{self.billing_session.billing_base_url}/customer_portal/?return_to_billing_page=true",
|
|
subdomain="selfhosting",
|
|
)
|
|
self.assertEqual(response.status_code, 302)
|
|
self.assertTrue(response["Location"].startswith("https://billing.stripe.com"))
|
|
|
|
@responses.activate
|
|
@mock_stripe()
|
|
def test_upgrade_user_to_basic_plan_free_trial_fails_special_case(self, *mocks: Mock) -> None:
|
|
# Here we test if server had a complimentary access plan that ended before we could migrate
|
|
# it to a remote realm resulting in the upgrade for remote realm creating a new customer which
|
|
# doesn't have any complimentary access plan associated with it. In this case, a free trial
|
|
# should not be offered.
|
|
with self.settings(SELF_HOSTING_FREE_TRIAL_DAYS=30):
|
|
self.login("hamlet")
|
|
hamlet = self.example_user("hamlet")
|
|
|
|
self.add_mock_response()
|
|
with time_machine.travel(self.now, tick=False):
|
|
send_server_data_to_push_bouncer(consider_usage_statistics=False)
|
|
|
|
result = self.execute_remote_billing_authentication_flow(hamlet)
|
|
self.assertEqual(result.status_code, 302)
|
|
self.assertEqual(result["Location"], f"{self.billing_session.billing_base_url}/plans/")
|
|
|
|
# Test under normal circumstances it will show free trial.
|
|
with time_machine.travel(self.now, tick=False):
|
|
result = self.client_get(
|
|
f"{self.billing_session.billing_base_url}/upgrade/?tier={CustomerPlan.TIER_SELF_HOSTED_BASIC}",
|
|
subdomain="selfhosting",
|
|
)
|
|
|
|
self.assert_in_success_response(
|
|
[
|
|
"Start free trial",
|
|
"Zulip Basic",
|
|
"Start 30-day free trial",
|
|
],
|
|
result,
|
|
)
|
|
|
|
# Add ended complimentary access plan for remote realm server.
|
|
new_server_customer = Customer.objects.create(remote_server=self.remote_realm.server)
|
|
CustomerPlan.objects.create(
|
|
customer=new_server_customer,
|
|
status=CustomerPlan.ENDED,
|
|
tier=CustomerPlan.TIER_SELF_HOSTED_LEGACY,
|
|
billing_cycle_anchor=timezone_now(),
|
|
billing_schedule=CustomerPlan.BILLING_SCHEDULE_ANNUAL,
|
|
)
|
|
|
|
# No longer eligible for free trial
|
|
with time_machine.travel(self.now, tick=False):
|
|
result = self.client_get(
|
|
f"{self.billing_session.billing_base_url}/upgrade/?tier={CustomerPlan.TIER_SELF_HOSTED_BASIC}",
|
|
subdomain="selfhosting",
|
|
)
|
|
|
|
self.assert_not_in_success_response(
|
|
[
|
|
"Start free trial",
|
|
"Start 30-day free trial",
|
|
],
|
|
result,
|
|
)
|
|
|
|
self.assert_in_success_response(
|
|
[
|
|
"Purchase Zulip Basic",
|
|
],
|
|
result,
|
|
)
|
|
|
|
result = self.client_get(
|
|
f"{self.billing_session.billing_base_url}/plans/",
|
|
subdomain="selfhosting",
|
|
)
|
|
|
|
self.assert_not_in_success_response(
|
|
[
|
|
"Start 30-day free trial",
|
|
],
|
|
result,
|
|
)
|
|
|
|
@responses.activate
|
|
@mock_stripe()
|
|
def test_upgrade_user_to_basic_plan_free_trial(self, *mocks: Mock) -> None:
|
|
with self.settings(SELF_HOSTING_FREE_TRIAL_DAYS=30):
|
|
self.login("hamlet")
|
|
hamlet = self.example_user("hamlet")
|
|
|
|
self.add_mock_response()
|
|
realm_user_count = UserProfile.objects.filter(
|
|
realm=hamlet.realm, is_bot=False, is_active=True
|
|
).count()
|
|
self.assertEqual(realm_user_count, 11)
|
|
|
|
with time_machine.travel(self.now, tick=False):
|
|
send_server_data_to_push_bouncer(consider_usage_statistics=False)
|
|
|
|
result = self.execute_remote_billing_authentication_flow(hamlet)
|
|
self.assertEqual(result.status_code, 302)
|
|
self.assertEqual(result["Location"], f"{self.billing_session.billing_base_url}/plans/")
|
|
|
|
# upgrade to basic plan
|
|
with time_machine.travel(self.now, tick=False):
|
|
result = self.client_get(
|
|
f"{self.billing_session.billing_base_url}/upgrade/?tier={CustomerPlan.TIER_SELF_HOSTED_BASIC}",
|
|
subdomain="selfhosting",
|
|
)
|
|
self.assertEqual(result.status_code, 200)
|
|
|
|
min_licenses = self.billing_session.min_licenses_for_plan(
|
|
CustomerPlan.TIER_SELF_HOSTED_BASIC
|
|
)
|
|
self.assertEqual(min_licenses, 6)
|
|
flat_discount, flat_discounted_months = self.billing_session.get_flat_discount_info()
|
|
self.assertEqual(flat_discounted_months, 12)
|
|
|
|
self.assert_in_success_response(
|
|
[
|
|
"Start free trial",
|
|
"Zulip Basic",
|
|
"Due",
|
|
"on February 1, 2012",
|
|
f"{min_licenses}",
|
|
"Add card",
|
|
"Start 30-day free trial",
|
|
],
|
|
result,
|
|
)
|
|
|
|
self.assertFalse(Customer.objects.exists())
|
|
self.assertFalse(CustomerPlan.objects.exists())
|
|
self.assertFalse(LicenseLedger.objects.exists())
|
|
|
|
with time_machine.travel(self.now, tick=False):
|
|
stripe_customer = self.add_card_and_upgrade(
|
|
tier=CustomerPlan.TIER_SELF_HOSTED_BASIC, schedule="monthly"
|
|
)
|
|
|
|
self.assertEqual(Invoice.objects.count(), 0)
|
|
customer = Customer.objects.get(stripe_customer_id=stripe_customer.id)
|
|
plan = CustomerPlan.objects.get(customer=customer)
|
|
LicenseLedger.objects.get(plan=plan)
|
|
|
|
with time_machine.travel(self.now + timedelta(days=1), tick=False):
|
|
response = self.client_get(
|
|
f"{self.billing_session.billing_base_url}/billing/", subdomain="selfhosting"
|
|
)
|
|
for substring in [
|
|
"Zulip Basic",
|
|
"(free trial)",
|
|
"Number of licenses",
|
|
f"{realm_user_count}",
|
|
"February 1, 2012",
|
|
"Your plan will automatically renew on",
|
|
f"${3.5 * realm_user_count - flat_discount // 100 * 1:,.2f}",
|
|
"Visa ending in 4242",
|
|
"Update card",
|
|
]:
|
|
self.assert_in_response(substring, response)
|
|
|
|
# Verify that change in user count updates LicenseLedger.
|
|
audit_log_count = RemoteRealmAuditLog.objects.count()
|
|
self.assertEqual(LicenseLedger.objects.count(), 1)
|
|
|
|
with time_machine.travel(self.now + timedelta(days=2), tick=False):
|
|
for count in range(realm_user_count, min_licenses + 10):
|
|
do_create_user(
|
|
f"email {count}",
|
|
f"password {count}",
|
|
hamlet.realm,
|
|
"name",
|
|
role=UserProfile.ROLE_MEMBER,
|
|
acting_user=None,
|
|
)
|
|
|
|
with time_machine.travel(self.now + timedelta(days=3), tick=False):
|
|
send_server_data_to_push_bouncer(consider_usage_statistics=False)
|
|
|
|
self.assertEqual(
|
|
RemoteRealmAuditLog.objects.count(),
|
|
min_licenses + 10 - realm_user_count + audit_log_count,
|
|
)
|
|
latest_ledger = LicenseLedger.objects.last()
|
|
assert latest_ledger is not None
|
|
self.assertEqual(latest_ledger.licenses, min_licenses + 10)
|
|
|
|
with time_machine.travel(self.now + timedelta(days=3), tick=False):
|
|
response = self.client_get(
|
|
f"{self.billing_session.billing_base_url}/billing/", subdomain="selfhosting"
|
|
)
|
|
|
|
self.assertEqual(latest_ledger.licenses, min_licenses + 10)
|
|
for substring in [
|
|
"Zulip Basic",
|
|
"Number of licenses",
|
|
f"{latest_ledger.licenses}",
|
|
"February 1, 2012",
|
|
"Your plan will automatically renew on",
|
|
f"${3.5 * latest_ledger.licenses - flat_discount // 100 * 1:,.2f}",
|
|
"Visa ending in 4242",
|
|
"Update card",
|
|
]:
|
|
self.assert_in_response(substring, response)
|
|
|
|
# Check minimum licenses is 0 after flat discounted months is over.
|
|
customer.flat_discounted_months = 0
|
|
customer.save(update_fields=["flat_discounted_months"])
|
|
self.assertEqual(
|
|
self.billing_session.min_licenses_for_plan(CustomerPlan.TIER_SELF_HOSTED_BASIC), 1
|
|
)
|
|
|
|
# TODO: Add test for invoice generation once that's implemented.
|
|
|
|
@responses.activate
|
|
@mock_stripe()
|
|
def test_redirect_for_remote_realm_billing_page_downgrade_at_free_trial_end(
|
|
self, *mocks: Mock
|
|
) -> None:
|
|
with self.settings(SELF_HOSTING_FREE_TRIAL_DAYS=30):
|
|
self.login("hamlet")
|
|
hamlet = self.example_user("hamlet")
|
|
|
|
self.add_mock_response()
|
|
with time_machine.travel(self.now, tick=False):
|
|
send_server_data_to_push_bouncer(consider_usage_statistics=False)
|
|
|
|
result = self.execute_remote_billing_authentication_flow(hamlet)
|
|
self.assertEqual(result.status_code, 302)
|
|
self.assertEqual(result["Location"], f"{self.billing_session.billing_base_url}/plans/")
|
|
|
|
# upgrade to basic plan
|
|
with time_machine.travel(self.now, tick=False):
|
|
result = self.client_get(
|
|
f"{self.billing_session.billing_base_url}/upgrade/?tier={CustomerPlan.TIER_SELF_HOSTED_BASIC}",
|
|
subdomain="selfhosting",
|
|
)
|
|
self.assertEqual(result.status_code, 200)
|
|
|
|
self.assert_in_success_response(
|
|
[
|
|
"Start free trial",
|
|
"Zulip Basic",
|
|
"Due",
|
|
"on February 1, 2012",
|
|
"Add card",
|
|
"Start 30-day free trial",
|
|
],
|
|
result,
|
|
)
|
|
|
|
self.assertFalse(Customer.objects.exists())
|
|
self.assertFalse(CustomerPlan.objects.exists())
|
|
self.assertFalse(LicenseLedger.objects.exists())
|
|
|
|
with time_machine.travel(self.now, tick=False):
|
|
stripe_customer = self.add_card_and_upgrade(
|
|
tier=CustomerPlan.TIER_SELF_HOSTED_BASIC, schedule="monthly"
|
|
)
|
|
|
|
self.assertEqual(Invoice.objects.count(), 0)
|
|
customer = Customer.objects.get(stripe_customer_id=stripe_customer.id)
|
|
plan = CustomerPlan.objects.get(customer=customer)
|
|
LicenseLedger.objects.get(plan=plan)
|
|
|
|
with time_machine.travel(self.now + timedelta(days=1), tick=False):
|
|
response = self.client_get(
|
|
f"{self.billing_session.billing_base_url}/billing/", subdomain="selfhosting"
|
|
)
|
|
for substring in [
|
|
"Zulip Basic",
|
|
"(free trial)",
|
|
"Your plan will automatically renew on",
|
|
"February 1, 2012",
|
|
]:
|
|
self.assert_in_response(substring, response)
|
|
|
|
# schedule downgrade
|
|
with (
|
|
time_machine.travel(self.now + timedelta(days=3), tick=False),
|
|
self.assertLogs("corporate.stripe", "INFO") as m,
|
|
):
|
|
response = self.client_billing_patch(
|
|
"/billing/plan",
|
|
{"status": CustomerPlan.DOWNGRADE_AT_END_OF_FREE_TRIAL},
|
|
)
|
|
self.assert_json_success(response)
|
|
plan.refresh_from_db()
|
|
self.assertEqual(plan.status, CustomerPlan.DOWNGRADE_AT_END_OF_FREE_TRIAL)
|
|
expected_log = f"INFO:corporate.stripe:Change plan status: Customer.id: {customer.id}, CustomerPlan.id: {plan.id}, status: {CustomerPlan.DOWNGRADE_AT_END_OF_FREE_TRIAL}"
|
|
self.assertEqual(m.output[0], expected_log)
|
|
|
|
# Visit /billing on free-trial end date before the invoice cron runs.
|
|
with time_machine.travel(self.now + timedelta(days=30), tick=False):
|
|
response = self.client_get(
|
|
f"{self.billing_session.billing_base_url}/billing/", subdomain="selfhosting"
|
|
)
|
|
self.assertEqual(response.status_code, 302)
|
|
self.assertEqual(
|
|
f"{self.billing_session.billing_base_url}/plans/", response["Location"]
|
|
)
|
|
|
|
@responses.activate
|
|
@mock_stripe()
|
|
def test_upgrade_remote_realm_user_to_monthly_basic_plan(self, *mocks: Mock) -> None:
|
|
self.login("hamlet")
|
|
hamlet = self.example_user("hamlet")
|
|
|
|
self.add_mock_response()
|
|
realm_user_count = UserProfile.objects.filter(
|
|
realm=hamlet.realm, is_bot=False, is_active=True
|
|
).count()
|
|
self.assertEqual(realm_user_count, 11)
|
|
|
|
with time_machine.travel(self.now, tick=False):
|
|
send_server_data_to_push_bouncer(consider_usage_statistics=False)
|
|
|
|
result = self.execute_remote_billing_authentication_flow(hamlet)
|
|
self.assertEqual(result.status_code, 302)
|
|
self.assertEqual(result["Location"], f"{self.billing_session.billing_base_url}/plans/")
|
|
|
|
# upgrade to basic plan
|
|
with time_machine.travel(self.now, tick=False):
|
|
result = self.client_get(
|
|
f"{self.billing_session.billing_base_url}/upgrade/?tier={CustomerPlan.TIER_SELF_HOSTED_BASIC}",
|
|
subdomain="selfhosting",
|
|
)
|
|
self.assertEqual(result.status_code, 200)
|
|
|
|
min_licenses = self.billing_session.min_licenses_for_plan(
|
|
CustomerPlan.TIER_SELF_HOSTED_BASIC
|
|
)
|
|
self.assertEqual(min_licenses, 6)
|
|
flat_discount, flat_discounted_months = self.billing_session.get_flat_discount_info()
|
|
self.assertEqual(flat_discounted_months, 12)
|
|
|
|
self.assert_in_success_response(
|
|
[f"{realm_user_count}", "Add card", "Purchase Zulip Basic"], result
|
|
)
|
|
|
|
self.assertFalse(Customer.objects.exists())
|
|
self.assertFalse(CustomerPlan.objects.exists())
|
|
self.assertFalse(LicenseLedger.objects.exists())
|
|
|
|
with time_machine.travel(self.now, tick=False):
|
|
stripe_customer = self.add_card_and_upgrade(
|
|
tier=CustomerPlan.TIER_SELF_HOSTED_BASIC, schedule="monthly"
|
|
)
|
|
|
|
customer = Customer.objects.get(stripe_customer_id=stripe_customer.id)
|
|
plan = CustomerPlan.objects.get(customer=customer)
|
|
LicenseLedger.objects.get(plan=plan)
|
|
|
|
with time_machine.travel(self.now + timedelta(days=1), tick=False):
|
|
response = self.client_get(
|
|
f"{self.billing_session.billing_base_url}/billing/", subdomain="selfhosting"
|
|
)
|
|
for substring in [
|
|
"Zulip Basic",
|
|
"Number of licenses",
|
|
f"{realm_user_count}",
|
|
"February 2, 2012",
|
|
"Your plan will automatically renew on",
|
|
f"${3.5 * realm_user_count - flat_discount // 100 * 1:,.2f}",
|
|
"Visa ending in 4242",
|
|
"Update card",
|
|
]:
|
|
self.assert_in_response(substring, response)
|
|
|
|
# Verify that change in user count updates LicenseLedger.
|
|
audit_log_count = RemoteRealmAuditLog.objects.count()
|
|
self.assertEqual(LicenseLedger.objects.count(), 1)
|
|
|
|
with time_machine.travel(self.now + timedelta(days=2), tick=False):
|
|
for count in range(realm_user_count, min_licenses + 10):
|
|
do_create_user(
|
|
f"email {count}",
|
|
f"password {count}",
|
|
hamlet.realm,
|
|
"name",
|
|
role=UserProfile.ROLE_MEMBER,
|
|
acting_user=None,
|
|
)
|
|
|
|
with time_machine.travel(self.now + timedelta(days=3), tick=False):
|
|
send_server_data_to_push_bouncer(consider_usage_statistics=False)
|
|
|
|
self.assertEqual(
|
|
RemoteRealmAuditLog.objects.count(),
|
|
min_licenses + 10 - realm_user_count + audit_log_count,
|
|
)
|
|
latest_ledger = LicenseLedger.objects.last()
|
|
assert latest_ledger is not None
|
|
self.assertEqual(latest_ledger.licenses, min_licenses + 10)
|
|
|
|
with time_machine.travel(self.now + timedelta(days=3), tick=False):
|
|
response = self.client_get(
|
|
f"{self.billing_session.billing_base_url}/billing/", subdomain="selfhosting"
|
|
)
|
|
|
|
self.assertEqual(latest_ledger.licenses, min_licenses + 10)
|
|
for substring in [
|
|
"Zulip Basic",
|
|
"Number of licenses",
|
|
f"{latest_ledger.licenses}",
|
|
"February 2, 2012",
|
|
"Your plan will automatically renew on",
|
|
f"${3.5 * latest_ledger.licenses - flat_discount // 100 * 1:,.2f}",
|
|
"Visa ending in 4242",
|
|
"Update card",
|
|
]:
|
|
self.assert_in_response(substring, response)
|
|
|
|
# Check minimum licenses is 0 after flat discounted months is over.
|
|
customer.flat_discounted_months = 0
|
|
customer.save(update_fields=["flat_discounted_months"])
|
|
self.assertEqual(
|
|
self.billing_session.min_licenses_for_plan(CustomerPlan.TIER_SELF_HOSTED_BASIC), 1
|
|
)
|
|
|
|
@responses.activate
|
|
@mock_stripe()
|
|
def test_upgrade_user_to_fixed_price_monthly_basic_plan(self, *mocks: Mock) -> None:
|
|
self.login("iago")
|
|
|
|
self.add_mock_response()
|
|
with time_machine.travel(self.now, tick=False):
|
|
send_server_data_to_push_bouncer(consider_usage_statistics=False)
|
|
|
|
self.assertFalse(CustomerPlanOffer.objects.exists())
|
|
|
|
# Set fixed_price without configuring required_plan_tier.
|
|
annual_fixed_price = 1200
|
|
result = self.client_post(
|
|
"/activity/remote/support",
|
|
{"remote_realm_id": f"{self.remote_realm.id}", "fixed_price": annual_fixed_price},
|
|
)
|
|
self.assert_in_success_response(["Required plan tier should not be set to None"], result)
|
|
|
|
# Configure required_plan_tier and fixed_price.
|
|
result = self.client_post(
|
|
"/activity/remote/support",
|
|
{
|
|
"remote_realm_id": f"{self.remote_realm.id}",
|
|
"required_plan_tier": CustomerPlan.TIER_SELF_HOSTED_BASIC,
|
|
},
|
|
)
|
|
self.assert_in_success_response(
|
|
["Required plan tier for Zulip Dev set to Zulip Basic."], result
|
|
)
|
|
|
|
result = self.client_post(
|
|
"/activity/remote/support",
|
|
{"remote_realm_id": f"{self.remote_realm.id}", "fixed_price": annual_fixed_price},
|
|
)
|
|
self.assert_in_success_response(
|
|
["Customer can now buy a fixed price Zulip Basic plan."], result
|
|
)
|
|
fixed_price_plan_offer = CustomerPlanOffer.objects.filter(
|
|
status=CustomerPlanOffer.CONFIGURED
|
|
).first()
|
|
assert fixed_price_plan_offer is not None
|
|
self.assertEqual(fixed_price_plan_offer.tier, CustomerPlanOffer.TIER_SELF_HOSTED_BASIC)
|
|
self.assertEqual(fixed_price_plan_offer.fixed_price, annual_fixed_price * 100)
|
|
self.assertEqual(fixed_price_plan_offer.get_plan_status_as_text(), "Configured")
|
|
|
|
result = self.client_get("/activity/remote/support", {"q": "example.com"})
|
|
self.assert_in_success_response(
|
|
["Next plan information:", "Zulip Basic", "Configured", "Plan has a fixed price."],
|
|
result,
|
|
)
|
|
|
|
self.logout()
|
|
self.login("hamlet")
|
|
hamlet = self.example_user("hamlet")
|
|
|
|
# Visit /upgrade
|
|
self.execute_remote_billing_authentication_flow(hamlet)
|
|
with time_machine.travel(self.now, tick=False):
|
|
result = self.client_get(
|
|
f"{self.billing_session.billing_base_url}/upgrade/?tier={CustomerPlan.TIER_SELF_HOSTED_BASIC}",
|
|
subdomain="selfhosting",
|
|
)
|
|
self.assertEqual(result.status_code, 200)
|
|
self.assert_in_success_response(
|
|
["Add card", "Purchase Zulip Basic", "This is a fixed-price plan."], result
|
|
)
|
|
|
|
self.assertFalse(CustomerPlan.objects.filter(status=CustomerPlan.ACTIVE).exists())
|
|
|
|
# Upgrade to fixed-price Zulip Basic plan.
|
|
with time_machine.travel(self.now, tick=False):
|
|
stripe_customer = self.add_card_and_upgrade(
|
|
tier=CustomerPlan.TIER_SELF_HOSTED_BASIC, schedule="monthly"
|
|
)
|
|
|
|
customer = Customer.objects.get(stripe_customer_id=stripe_customer.id)
|
|
current_plan = CustomerPlan.objects.get(customer=customer, status=CustomerPlan.ACTIVE)
|
|
self.assertIsNotNone(current_plan.fixed_price)
|
|
self.assertIsNone(current_plan.price_per_license)
|
|
fixed_price_plan_offer.refresh_from_db()
|
|
self.assertEqual(fixed_price_plan_offer.status, CustomerPlanOffer.PROCESSED)
|
|
|
|
# Visit /billing
|
|
with time_machine.travel(self.now + timedelta(days=1), tick=False):
|
|
response = self.client_get(
|
|
f"{self.billing_session.billing_base_url}/billing/", subdomain="selfhosting"
|
|
)
|
|
for substring in [
|
|
"Zulip Basic",
|
|
"Monthly",
|
|
"February 2, 2012",
|
|
"This is a fixed-price plan",
|
|
"Your plan will automatically renew on",
|
|
f"${int(annual_fixed_price / 12)}",
|
|
"Visa ending in 4242",
|
|
"Update card",
|
|
]:
|
|
self.assert_in_response(substring, response)
|
|
|
|
@responses.activate
|
|
def test_delete_configured_fixed_price_plan_offer_no_active_plan(self) -> None:
|
|
self.login("iago")
|
|
|
|
self.add_mock_response()
|
|
with time_machine.travel(self.now, tick=False):
|
|
send_server_data_to_push_bouncer(consider_usage_statistics=False)
|
|
|
|
self.assertFalse(CustomerPlanOffer.objects.exists())
|
|
|
|
annual_fixed_price = 1200
|
|
# Configure required_plan_tier and fixed_price.
|
|
result = self.client_post(
|
|
"/activity/remote/support",
|
|
{
|
|
"remote_realm_id": f"{self.remote_realm.id}",
|
|
"required_plan_tier": CustomerPlan.TIER_SELF_HOSTED_BASIC,
|
|
},
|
|
)
|
|
self.assert_in_success_response(
|
|
["Required plan tier for Zulip Dev set to Zulip Basic."], result
|
|
)
|
|
|
|
result = self.client_post(
|
|
"/activity/remote/support",
|
|
{"remote_realm_id": f"{self.remote_realm.id}", "fixed_price": annual_fixed_price},
|
|
)
|
|
self.assert_in_success_response(
|
|
["Customer can now buy a fixed price Zulip Basic plan."], result
|
|
)
|
|
fixed_price_plan_offer = CustomerPlanOffer.objects.filter(
|
|
status=CustomerPlanOffer.CONFIGURED
|
|
).first()
|
|
assert fixed_price_plan_offer is not None
|
|
self.assertEqual(fixed_price_plan_offer.tier, CustomerPlanOffer.TIER_SELF_HOSTED_BASIC)
|
|
self.assertEqual(fixed_price_plan_offer.fixed_price, annual_fixed_price * 100)
|
|
self.assertEqual(fixed_price_plan_offer.get_plan_status_as_text(), "Configured")
|
|
|
|
result = self.client_get("/activity/remote/support", {"q": "example.com"})
|
|
self.assert_in_success_response(
|
|
["Next plan information:", "Zulip Basic", "Configured", "Plan has a fixed price."],
|
|
result,
|
|
)
|
|
|
|
billing_session = RemoteRealmBillingSession(remote_realm=self.remote_realm)
|
|
support_request = SupportViewRequest(
|
|
support_type=SupportType.delete_fixed_price_next_plan,
|
|
)
|
|
success_message = billing_session.process_support_view_request(support_request)
|
|
self.assertEqual(success_message, "Fixed-price plan offer deleted")
|
|
result = self.client_get("/activity/remote/support", {"q": "example.com"})
|
|
self.assert_not_in_success_response(["Next plan information:"], result)
|
|
self.assert_in_success_response(
|
|
["Configure fixed price plan", "Annual amount in dollars"], result
|
|
)
|
|
|
|
@responses.activate
|
|
def test_delete_configured_fixed_price_plan_offer_on_complimentary_access_plan(self) -> None:
|
|
self.login("iago")
|
|
|
|
self.add_mock_response()
|
|
with time_machine.travel(self.now, tick=False):
|
|
send_server_data_to_push_bouncer(consider_usage_statistics=False)
|
|
|
|
self.assertFalse(CustomerPlanOffer.objects.exists())
|
|
annual_fixed_price = 1200
|
|
|
|
# Configure complimentary access plan
|
|
complimentary_access_plan_end = self.next_year.strftime("%Y-%m-%d")
|
|
billing_session = RemoteRealmBillingSession(remote_realm=self.remote_realm)
|
|
support_request = SupportViewRequest(
|
|
support_type=SupportType.configure_complimentary_access_plan,
|
|
plan_end_date=complimentary_access_plan_end,
|
|
)
|
|
|
|
with time_machine.travel(self.now, tick=False):
|
|
success_message = billing_session.process_support_view_request(support_request)
|
|
self.assertEqual(
|
|
success_message,
|
|
f"Complimentary access plan for Zulip Dev configured to end on {complimentary_access_plan_end}.",
|
|
)
|
|
|
|
# Configure required_plan_tier and fixed_price.
|
|
result = self.client_post(
|
|
"/activity/remote/support",
|
|
{
|
|
"remote_realm_id": f"{self.remote_realm.id}",
|
|
"required_plan_tier": CustomerPlan.TIER_SELF_HOSTED_BASIC,
|
|
},
|
|
)
|
|
self.assert_in_success_response(
|
|
["Required plan tier for Zulip Dev set to Zulip Basic."], result
|
|
)
|
|
|
|
result = self.client_post(
|
|
"/activity/remote/support",
|
|
{"remote_realm_id": f"{self.remote_realm.id}", "fixed_price": annual_fixed_price},
|
|
)
|
|
self.assert_in_success_response(
|
|
["Customer can now buy a fixed price Zulip Basic plan."], result
|
|
)
|
|
fixed_price_plan_offer = CustomerPlanOffer.objects.filter(
|
|
status=CustomerPlanOffer.CONFIGURED
|
|
).first()
|
|
assert fixed_price_plan_offer is not None
|
|
self.assertEqual(fixed_price_plan_offer.tier, CustomerPlanOffer.TIER_SELF_HOSTED_BASIC)
|
|
self.assertEqual(fixed_price_plan_offer.fixed_price, annual_fixed_price * 100)
|
|
self.assertEqual(fixed_price_plan_offer.get_plan_status_as_text(), "Configured")
|
|
|
|
result = self.client_get("/activity/remote/support", {"q": "example.com"})
|
|
self.assert_in_success_response(
|
|
[
|
|
"Next plan information:",
|
|
"Zulip Basic",
|
|
"Configured",
|
|
"Plan has a fixed price.",
|
|
"Zulip Basic (complimentary)",
|
|
],
|
|
result,
|
|
)
|
|
|
|
# Delete configured fixed price plan.
|
|
billing_session = RemoteRealmBillingSession(remote_realm=self.remote_realm)
|
|
support_request = SupportViewRequest(
|
|
support_type=SupportType.delete_fixed_price_next_plan,
|
|
)
|
|
success_message = billing_session.process_support_view_request(support_request)
|
|
self.assertEqual(success_message, "Fixed-price plan offer deleted")
|
|
result = self.client_get("/activity/remote/support", {"q": "example.com"})
|
|
self.assert_not_in_success_response(["Next plan information:"], result)
|
|
self.assert_in_success_response(
|
|
[
|
|
"Configure fixed price plan",
|
|
"Annual amount in dollars",
|
|
"Zulip Basic (complimentary)",
|
|
],
|
|
result,
|
|
)
|
|
self.assertFalse(CustomerPlanOffer.objects.exists())
|
|
|
|
@responses.activate
|
|
@mock_stripe()
|
|
def test_upgrade_user_to_fixed_price_plan_pay_by_invoice(self, *mocks: Mock) -> None:
|
|
self.login("iago")
|
|
|
|
self.add_mock_response()
|
|
with time_machine.travel(self.now, tick=False):
|
|
send_server_data_to_push_bouncer(consider_usage_statistics=False)
|
|
|
|
self.assertFalse(CustomerPlanOffer.objects.exists())
|
|
|
|
# Configure required_plan_tier.
|
|
result = self.client_post(
|
|
"/activity/remote/support",
|
|
{
|
|
"remote_realm_id": f"{self.remote_realm.id}",
|
|
"required_plan_tier": CustomerPlan.TIER_SELF_HOSTED_BASIC,
|
|
},
|
|
)
|
|
self.assert_in_success_response(
|
|
["Required plan tier for Zulip Dev set to Zulip Basic."], result
|
|
)
|
|
|
|
# Configure fixed-price plan with ID of manually sent invoice.
|
|
# Invalid 'sent_invoice_id' entered.
|
|
annual_fixed_price = 1200
|
|
with mock.patch("stripe.Invoice.retrieve", side_effect=Exception):
|
|
result = self.client_post(
|
|
"/activity/remote/support",
|
|
{
|
|
"remote_realm_id": f"{self.remote_realm.id}",
|
|
"fixed_price": annual_fixed_price,
|
|
"sent_invoice_id": "invalid_sent_invoice_id",
|
|
},
|
|
)
|
|
self.assert_not_in_success_response(
|
|
["Customer can now buy a fixed price Zulip Basic plan."], result
|
|
)
|
|
|
|
# Invoice status is not 'open'.
|
|
mock_invoice = MagicMock()
|
|
mock_invoice.status = "paid"
|
|
mock_invoice.sent_invoice_id = "paid_invoice_id"
|
|
with mock.patch("stripe.Invoice.retrieve", return_value=mock_invoice):
|
|
result = self.client_post(
|
|
"/activity/remote/support",
|
|
{
|
|
"remote_realm_id": f"{self.remote_realm.id}",
|
|
"fixed_price": annual_fixed_price,
|
|
"sent_invoice_id": mock_invoice.sent_invoice_id,
|
|
},
|
|
)
|
|
self.assert_in_success_response(
|
|
["Invoice status should be open. Please verify sent_invoice_id."], result
|
|
)
|
|
|
|
hamlet = self.example_user("hamlet")
|
|
sent_invoice_id = "test_sent_invoice_id"
|
|
stripe_customer_id = "cus_123"
|
|
mock_invoice = MagicMock()
|
|
mock_invoice.status = "open"
|
|
mock_invoice.sent_invoice_id = sent_invoice_id
|
|
with (
|
|
patch(
|
|
"stripe.Customer.retrieve",
|
|
return_value=Mock(id=stripe_customer_id, email=hamlet.delivery_email),
|
|
),
|
|
patch("stripe.Invoice.retrieve", return_value=mock_invoice),
|
|
):
|
|
result = self.client_post(
|
|
"/activity/remote/support",
|
|
{
|
|
"remote_realm_id": f"{self.remote_realm.id}",
|
|
"fixed_price": annual_fixed_price,
|
|
"sent_invoice_id": sent_invoice_id,
|
|
},
|
|
)
|
|
self.assert_in_success_response(
|
|
["Customer can now buy a fixed price Zulip Basic plan."], result
|
|
)
|
|
fixed_price_plan_offer = CustomerPlanOffer.objects.filter(
|
|
status=CustomerPlanOffer.CONFIGURED
|
|
).first()
|
|
assert fixed_price_plan_offer is not None
|
|
self.assertEqual(fixed_price_plan_offer.tier, CustomerPlanOffer.TIER_SELF_HOSTED_BASIC)
|
|
self.assertEqual(fixed_price_plan_offer.fixed_price, annual_fixed_price * 100)
|
|
self.assertEqual(fixed_price_plan_offer.sent_invoice_id, sent_invoice_id)
|
|
self.assertEqual(fixed_price_plan_offer.get_plan_status_as_text(), "Configured")
|
|
|
|
invoice = Invoice.objects.get(stripe_invoice_id=sent_invoice_id)
|
|
self.assertEqual(invoice.status, Invoice.SENT)
|
|
|
|
self.logout()
|
|
self.login("hamlet")
|
|
|
|
# Customer don't need to visit /upgrade to buy plan.
|
|
# In case they visit, we inform them about the mail to which
|
|
# invoice was sent and also display the link for payment.
|
|
self.execute_remote_billing_authentication_flow(hamlet)
|
|
mock_invoice = MagicMock()
|
|
mock_invoice.hosted_invoice_url = "payments_page_url"
|
|
with (
|
|
time_machine.travel(self.now, tick=False),
|
|
patch(
|
|
"corporate.lib.stripe.customer_has_credit_card_as_default_payment_method",
|
|
return_value=False,
|
|
),
|
|
patch(
|
|
"stripe.Customer.retrieve",
|
|
return_value=Mock(id=stripe_customer_id, email=hamlet.delivery_email),
|
|
),
|
|
patch("stripe.Invoice.retrieve", return_value=mock_invoice),
|
|
):
|
|
result = self.client_get(
|
|
f"{self.billing_session.billing_base_url}/upgrade/?tier={CustomerPlan.TIER_SELF_HOSTED_BASIC}",
|
|
subdomain="selfhosting",
|
|
)
|
|
self.assertEqual(result.status_code, 200)
|
|
self.assert_in_success_response(["payments_page_url", hamlet.delivery_email], result)
|
|
|
|
# When customer makes a payment, 'stripe_webhook' handles 'invoice.paid' event.
|
|
stripe_event_id = "stripe_event_id"
|
|
valid_invoice_paid_event_data = {
|
|
"id": stripe_event_id,
|
|
"type": "invoice.paid",
|
|
"api_version": STRIPE_API_VERSION,
|
|
"data": {
|
|
"object": {
|
|
"object": "invoice",
|
|
"id": sent_invoice_id,
|
|
"collection_method": "send_invoice",
|
|
}
|
|
},
|
|
}
|
|
with time_machine.travel(self.now, tick=False):
|
|
result = self.client_post(
|
|
"/stripe/webhook/",
|
|
valid_invoice_paid_event_data,
|
|
content_type="application/json",
|
|
)
|
|
self.assertEqual(result.status_code, 200)
|
|
|
|
# Verify that the customer is upgraded after payment.
|
|
customer = self.billing_session.get_customer()
|
|
current_plan = CustomerPlan.objects.get(customer=customer, status=CustomerPlan.ACTIVE)
|
|
self.assertEqual(current_plan.fixed_price, annual_fixed_price * 100)
|
|
self.assertIsNone(current_plan.price_per_license)
|
|
|
|
invoice.refresh_from_db()
|
|
fixed_price_plan_offer.refresh_from_db()
|
|
self.assertEqual(invoice.status, Invoice.PAID)
|
|
self.assertEqual(fixed_price_plan_offer.status, CustomerPlanOffer.PROCESSED)
|
|
|
|
# Visit /billing
|
|
self.execute_remote_billing_authentication_flow(
|
|
hamlet, expect_tos=False, first_time_login=False
|
|
)
|
|
with (
|
|
time_machine.travel(self.now + timedelta(days=1), tick=False),
|
|
patch(
|
|
"corporate.lib.stripe.customer_has_credit_card_as_default_payment_method",
|
|
return_value=False,
|
|
),
|
|
patch(
|
|
"stripe.Customer.retrieve",
|
|
return_value=Mock(id=stripe_customer_id, email=hamlet.delivery_email),
|
|
),
|
|
patch("stripe.Invoice.retrieve", return_value=mock_invoice),
|
|
):
|
|
response = self.client_get(
|
|
f"{self.billing_session.billing_base_url}/billing/", subdomain="selfhosting"
|
|
)
|
|
for substring in [
|
|
"Zulip Basic",
|
|
hamlet.delivery_email,
|
|
"Annual",
|
|
"This is a fixed-price plan",
|
|
"You will be contacted by Zulip Sales",
|
|
]:
|
|
self.assert_in_response(substring, response)
|
|
|
|
@responses.activate
|
|
@mock_stripe()
|
|
def test_schedule_upgrade_to_fixed_price_annual_business_plan(self, *mocks: Mock) -> None:
|
|
self.login("hamlet")
|
|
hamlet = self.example_user("hamlet")
|
|
|
|
self.add_mock_response()
|
|
with time_machine.travel(self.now, tick=False):
|
|
send_server_data_to_push_bouncer(consider_usage_statistics=False)
|
|
|
|
# Upgrade to fixed-price Zulip Basic plan.
|
|
self.execute_remote_billing_authentication_flow(hamlet)
|
|
with time_machine.travel(self.now, tick=False):
|
|
stripe_customer = self.add_card_and_upgrade(
|
|
tier=CustomerPlan.TIER_SELF_HOSTED_BASIC,
|
|
)
|
|
|
|
customer = Customer.objects.get(stripe_customer_id=stripe_customer.id)
|
|
assert customer.remote_realm is not None
|
|
self.assertEqual(customer.remote_realm.plan_type, RemoteRealm.PLAN_TYPE_BASIC)
|
|
current_plan = CustomerPlan.objects.get(customer=customer, status=CustomerPlan.ACTIVE)
|
|
self.assertEqual(current_plan.tier, CustomerPlan.TIER_SELF_HOSTED_BASIC)
|
|
self.assertIsNone(current_plan.fixed_price)
|
|
self.assertIsNotNone(current_plan.price_per_license)
|
|
|
|
self.logout()
|
|
self.login("iago")
|
|
|
|
# Schedule a fixed-price business plan at current plan end_date.
|
|
result = self.client_post(
|
|
"/activity/remote/support",
|
|
{
|
|
"remote_realm_id": f"{self.remote_realm.id}",
|
|
"required_plan_tier": CustomerPlan.TIER_SELF_HOSTED_BUSINESS,
|
|
},
|
|
)
|
|
self.assert_in_success_response(
|
|
["Required plan tier for Zulip Dev set to Zulip Business."], result
|
|
)
|
|
|
|
annual_fixed_price = 1200
|
|
result = self.client_post(
|
|
"/activity/remote/support",
|
|
{"remote_realm_id": f"{self.remote_realm.id}", "fixed_price": annual_fixed_price},
|
|
)
|
|
self.assert_in_success_response(
|
|
[
|
|
f"Configure {self.billing_session.billing_entity_display_name} current plan end-date, before scheduling a new plan."
|
|
],
|
|
result,
|
|
)
|
|
|
|
current_plan_end_date = add_months(self.now, 2)
|
|
current_plan.end_date = current_plan_end_date
|
|
current_plan.save(update_fields=["end_date"])
|
|
|
|
self.assertFalse(CustomerPlan.objects.filter(fixed_price__isnull=False).exists())
|
|
|
|
result = self.client_post(
|
|
"/activity/remote/support",
|
|
{"remote_realm_id": f"{self.remote_realm.id}", "fixed_price": annual_fixed_price},
|
|
)
|
|
self.assert_in_success_response(
|
|
[
|
|
f"Fixed price Zulip Business plan scheduled to start on {current_plan_end_date.date()}."
|
|
],
|
|
result,
|
|
)
|
|
current_plan.refresh_from_db()
|
|
self.assertEqual(current_plan.status, CustomerPlan.SWITCH_PLAN_TIER_AT_PLAN_END)
|
|
self.assertEqual(current_plan.next_invoice_date, current_plan_end_date)
|
|
new_plan = CustomerPlan.objects.filter(fixed_price__isnull=False).first()
|
|
assert new_plan is not None
|
|
self.assertEqual(new_plan.next_invoice_date, current_plan_end_date)
|
|
self.assertEqual(
|
|
new_plan.invoicing_status, CustomerPlan.INVOICING_STATUS_INITIAL_INVOICE_TO_BE_SENT
|
|
)
|
|
|
|
# Invoice cron runs and switches plan to BUSINESS
|
|
with time_machine.travel(current_plan_end_date, tick=False):
|
|
send_server_data_to_push_bouncer(consider_usage_statistics=False)
|
|
invoice_plans_as_needed()
|
|
|
|
current_plan.refresh_from_db()
|
|
self.assertEqual(current_plan.status, CustomerPlan.ENDED)
|
|
self.assertEqual(current_plan.next_invoice_date, None)
|
|
|
|
new_plan.refresh_from_db()
|
|
self.assertEqual(new_plan.tier, CustomerPlan.TIER_SELF_HOSTED_BUSINESS)
|
|
self.assertIsNotNone(new_plan.fixed_price)
|
|
self.assertIsNone(new_plan.price_per_license)
|
|
|
|
customer.refresh_from_db()
|
|
self.assertEqual(customer.remote_realm.plan_type, RemoteRealm.PLAN_TYPE_BUSINESS)
|
|
|
|
self.logout()
|
|
self.login("hamlet")
|
|
hamlet = self.example_user("hamlet")
|
|
|
|
# Visit /billing
|
|
self.execute_remote_billing_authentication_flow(
|
|
hamlet, expect_tos=False, first_time_login=False
|
|
)
|
|
with time_machine.travel(current_plan_end_date + timedelta(days=1), tick=False):
|
|
response = self.client_get(
|
|
f"{self.billing_session.billing_base_url}/billing/", subdomain="selfhosting"
|
|
)
|
|
for substring in [
|
|
"Zulip Business",
|
|
"Annual",
|
|
"March 2, 2013",
|
|
"This is a fixed-price plan",
|
|
"Your plan ends on <strong>March 2, 2013</strong>",
|
|
"You will be contacted by Zulip Sales",
|
|
"Visa ending in 4242",
|
|
"Update card",
|
|
]:
|
|
self.assert_in_response(substring, response)
|
|
|
|
@responses.activate
|
|
@mock_stripe()
|
|
def test_schedule_complimentary_access_plan_upgrade_to_fixed_price_plan(
|
|
self, *mocks: Mock
|
|
) -> None:
|
|
hamlet = self.example_user("hamlet")
|
|
|
|
remote_realm = RemoteRealm.objects.get(uuid=hamlet.realm.uuid)
|
|
remote_realm_billing_session = RemoteRealmBillingSession(remote_realm=remote_realm)
|
|
|
|
# Create complimentary access plan for realm.
|
|
with time_machine.travel(self.now, tick=False):
|
|
start_date = timezone_now()
|
|
end_date = add_months(start_date, months=3)
|
|
remote_realm_billing_session.create_complimentary_access_plan(start_date, end_date)
|
|
|
|
self.add_mock_response()
|
|
with time_machine.travel(self.now, tick=False):
|
|
send_server_data_to_push_bouncer(consider_usage_statistics=False)
|
|
|
|
customer = Customer.objects.get(remote_realm=self.remote_realm)
|
|
complimentary_access_plan = get_current_plan_by_customer(customer)
|
|
assert complimentary_access_plan is not None
|
|
self.assertEqual(complimentary_access_plan.tier, CustomerPlan.TIER_SELF_HOSTED_LEGACY)
|
|
self.assertEqual(complimentary_access_plan.next_invoice_date, end_date)
|
|
|
|
self.login("iago")
|
|
|
|
# Schedule a fixed-price business plan at current plan end_date.
|
|
self.assertFalse(CustomerPlanOffer.objects.exists())
|
|
|
|
# Configure required_plan_tier and fixed_price.
|
|
annual_fixed_price = 1200
|
|
result = self.client_post(
|
|
"/activity/remote/support",
|
|
{
|
|
"remote_realm_id": f"{self.remote_realm.id}",
|
|
"required_plan_tier": CustomerPlan.TIER_SELF_HOSTED_BUSINESS,
|
|
},
|
|
)
|
|
self.assert_in_success_response(
|
|
["Required plan tier for Zulip Dev set to Zulip Business."], result
|
|
)
|
|
|
|
result = self.client_post(
|
|
"/activity/remote/support",
|
|
{"remote_realm_id": f"{self.remote_realm.id}", "fixed_price": annual_fixed_price},
|
|
)
|
|
self.assert_in_success_response(
|
|
["Customer can now buy a fixed price Zulip Business plan."],
|
|
result,
|
|
)
|
|
fixed_price_plan_offer = CustomerPlanOffer.objects.get(customer=customer)
|
|
self.assertEqual(fixed_price_plan_offer.status, CustomerPlanOffer.CONFIGURED)
|
|
self.assertEqual(fixed_price_plan_offer.tier, CustomerPlanOffer.TIER_SELF_HOSTED_BUSINESS)
|
|
|
|
self.logout()
|
|
self.login("hamlet")
|
|
self.execute_remote_billing_authentication_flow(
|
|
hamlet, expect_tos=False, confirm_tos=True, first_time_login=True
|
|
)
|
|
|
|
# Schedule upgrade to business plan
|
|
with time_machine.travel(self.now, tick=False):
|
|
self.add_card_and_upgrade(
|
|
remote_server_plan_start_date="billing_cycle_end_date", talk_to_stripe=False
|
|
)
|
|
|
|
complimentary_access_plan.refresh_from_db()
|
|
self.assertEqual(
|
|
complimentary_access_plan.status, CustomerPlan.SWITCH_PLAN_TIER_AT_PLAN_END
|
|
)
|
|
fixed_price_plan_offer.refresh_from_db()
|
|
self.assertEqual(fixed_price_plan_offer.status, CustomerPlanOffer.PROCESSED)
|
|
|
|
# Invoice cron runs and switches plan to BUSINESS
|
|
with time_machine.travel(end_date, tick=False):
|
|
send_server_data_to_push_bouncer(consider_usage_statistics=False)
|
|
invoice_plans_as_needed()
|
|
|
|
complimentary_access_plan.refresh_from_db()
|
|
current_plan = get_current_plan_by_customer(customer)
|
|
assert current_plan is not None
|
|
self.assertEqual(current_plan.tier, CustomerPlan.TIER_SELF_HOSTED_BUSINESS)
|
|
self.assertIsNotNone(current_plan.fixed_price)
|
|
self.assertIsNone(current_plan.price_per_license)
|
|
self.assertEqual(current_plan.next_invoice_date, add_months(end_date, 1))
|
|
self.assertEqual(complimentary_access_plan.status, CustomerPlan.ENDED)
|
|
self.assertEqual(complimentary_access_plan.next_invoice_date, None)
|
|
|
|
@responses.activate
|
|
@mock_stripe()
|
|
def test_schedule_fixed_price_plan_upgrade_to_another_fixed_price_plan(
|
|
self, *mocks: Mock
|
|
) -> None:
|
|
self.login("iago")
|
|
|
|
self.add_mock_response()
|
|
with time_machine.travel(self.now, tick=False):
|
|
send_server_data_to_push_bouncer(consider_usage_statistics=False)
|
|
|
|
# Configure required_plan_tier and fixed_price.
|
|
annual_fixed_price = 1200
|
|
result = self.client_post(
|
|
"/activity/remote/support",
|
|
{
|
|
"remote_realm_id": f"{self.remote_realm.id}",
|
|
"required_plan_tier": CustomerPlan.TIER_SELF_HOSTED_BASIC,
|
|
},
|
|
)
|
|
result = self.client_post(
|
|
"/activity/remote/support",
|
|
{"remote_realm_id": f"{self.remote_realm.id}", "fixed_price": annual_fixed_price},
|
|
)
|
|
self.assert_in_success_response(
|
|
["Customer can now buy a fixed price Zulip Basic plan."], result
|
|
)
|
|
|
|
self.logout()
|
|
self.login("hamlet")
|
|
hamlet = self.example_user("hamlet")
|
|
|
|
# Upgrade to fixed-price Zulip Basic plan with monthly billing_schedule.
|
|
self.execute_remote_billing_authentication_flow(hamlet)
|
|
with time_machine.travel(self.now, tick=False):
|
|
stripe_customer = self.add_card_and_upgrade(
|
|
tier=CustomerPlan.TIER_SELF_HOSTED_BASIC, schedule="monthly"
|
|
)
|
|
|
|
customer = Customer.objects.get(stripe_customer_id=stripe_customer.id)
|
|
current_plan = CustomerPlan.objects.get(customer=customer, status=CustomerPlan.ACTIVE)
|
|
end_date = add_months(self.now, 12)
|
|
self.assertIsNotNone(current_plan.fixed_price)
|
|
self.assertEqual(current_plan.billing_schedule, CustomerPlan.BILLING_SCHEDULE_MONTHLY)
|
|
self.assertEqual(current_plan.end_date, end_date)
|
|
|
|
# Invoice for february to october
|
|
for invoice_count in range(1, 10):
|
|
with time_machine.travel(add_months(self.now, invoice_count), tick=False):
|
|
send_server_data_to_push_bouncer(consider_usage_statistics=False)
|
|
invoice_plans_as_needed()
|
|
|
|
billing_entity = self.billing_session.billing_entity_display_name
|
|
|
|
# Cron runs 60 days before the end date (november) & sends a reminder email.
|
|
self.assertFalse(current_plan.reminder_to_review_plan_email_sent)
|
|
with time_machine.travel(add_months(self.now, 10), tick=False):
|
|
send_server_data_to_push_bouncer(consider_usage_statistics=False)
|
|
invoice_plans_as_needed()
|
|
current_plan.refresh_from_db()
|
|
self.assertTrue(current_plan.reminder_to_review_plan_email_sent)
|
|
|
|
from django.core.mail import outbox
|
|
|
|
messages_count = len(outbox)
|
|
message = outbox[-1]
|
|
self.assert_length(message.to, 1)
|
|
self.assertEqual(message.to[0], "sales@zulip.com")
|
|
self.assertIn(
|
|
f"Support URL: {self.billing_session.support_url()}",
|
|
message.body,
|
|
)
|
|
self.assertIn(
|
|
f"Internal billing notice for {self.billing_session.billing_entity_display_name}.",
|
|
message.body,
|
|
)
|
|
self.assertIn(
|
|
"Reminder to re-evaluate the pricing and configure a new fixed-price plan accordingly.",
|
|
message.body,
|
|
)
|
|
self.assertEqual(
|
|
f"Fixed-price plan for {billing_entity} ends on {end_date.strftime('%Y-%m-%d')}",
|
|
message.subject,
|
|
)
|
|
|
|
self.logout()
|
|
self.login("iago")
|
|
|
|
# Verify that we can't schedule a new fixed-price plan until invoice for 12th month is processed.
|
|
result = self.client_post(
|
|
"/activity/remote/support",
|
|
{"remote_realm_id": f"{self.remote_realm.id}", "fixed_price": annual_fixed_price + 200},
|
|
)
|
|
self.assert_in_success_response(
|
|
[
|
|
f"New plan for {billing_entity} cannot be scheduled until all the invoices of the current plan are processed."
|
|
],
|
|
result,
|
|
)
|
|
|
|
# Customer is charged for the last month of current plan.
|
|
with time_machine.travel(add_months(self.now, 11), tick=False):
|
|
send_server_data_to_push_bouncer(consider_usage_statistics=False)
|
|
invoice_plans_as_needed()
|
|
|
|
# Verify that we don't send another email to Zulip team.
|
|
self.assert_length(outbox, messages_count)
|
|
|
|
# All the monthly invoices are processed, now we can schedule a plan.
|
|
updated_annual_fixed_price = annual_fixed_price + 500
|
|
result = self.client_post(
|
|
"/activity/remote/support",
|
|
{
|
|
"remote_realm_id": f"{self.remote_realm.id}",
|
|
"fixed_price": updated_annual_fixed_price,
|
|
},
|
|
)
|
|
self.assert_in_success_response(
|
|
[f"Fixed price Zulip Basic plan scheduled to start on {end_date.date()}."],
|
|
result,
|
|
)
|
|
|
|
# Cron runs on end_date and customer switches to the new plan
|
|
with time_machine.travel(end_date, tick=False):
|
|
send_server_data_to_push_bouncer(consider_usage_statistics=False)
|
|
invoice_plans_as_needed()
|
|
current_plan.refresh_from_db()
|
|
self.assertEqual(current_plan.status, CustomerPlan.ENDED)
|
|
new_plan = get_current_plan_by_customer(customer)
|
|
assert new_plan is not None
|
|
self.assertEqual(new_plan.status, CustomerPlan.ACTIVE)
|
|
self.assertEqual(new_plan.fixed_price, updated_annual_fixed_price * 100)
|
|
|
|
@responses.activate
|
|
def test_request_sponsorship(self) -> None:
|
|
self.login("hamlet")
|
|
hamlet = self.example_user("hamlet")
|
|
realm = hamlet.realm
|
|
|
|
self.add_mock_response()
|
|
send_server_data_to_push_bouncer(consider_usage_statistics=False)
|
|
remote_realm = RemoteRealm.objects.get(uuid=hamlet.realm.uuid)
|
|
billing_base_url = self.billing_session.billing_base_url
|
|
|
|
self.assertEqual(remote_realm.plan_type, RemoteRealm.PLAN_TYPE_SELF_MANAGED)
|
|
self.assertIsNone(self.billing_session.get_customer())
|
|
result = self.execute_remote_billing_authentication_flow(hamlet)
|
|
|
|
# User has no plan, so we redirect to /plans by default.
|
|
self.assertEqual(result["Location"], f"/realm/{realm.uuid!s}/plans/")
|
|
|
|
# Check strings on plans page.
|
|
result = self.client_get(result["Location"], subdomain="selfhosting")
|
|
self.assert_not_in_success_response(["Sponsorship pending"], result)
|
|
|
|
# Navigate to request sponsorship page.
|
|
result = self.client_get(f"{billing_base_url}/sponsorship/", subdomain="selfhosting")
|
|
self.assert_in_success_response(
|
|
["Description of your organization", "Requested plan"], result
|
|
)
|
|
|
|
# Submit form data.
|
|
data = {
|
|
"organization_type": Realm.ORG_TYPES["opensource"]["id"],
|
|
"website": "https://infinispan.org/",
|
|
"description": "Infinispan is a distributed in-memory key/value data store with optional schema.",
|
|
"expected_total_users": "10 users",
|
|
"plan_to_use_zulip": "For communication on moon.",
|
|
"paid_users_count": "1 user",
|
|
"paid_users_description": "We have 1 paid user.",
|
|
"requested_plan": "Community",
|
|
}
|
|
response = self.client_billing_post("/billing/sponsorship", data)
|
|
self.assert_json_success(response)
|
|
|
|
customer = self.billing_session.get_customer()
|
|
assert customer is not None
|
|
|
|
sponsorship_request = ZulipSponsorshipRequest.objects.get(customer=customer)
|
|
self.assertEqual(sponsorship_request.requested_plan, data["requested_plan"])
|
|
self.assertEqual(sponsorship_request.org_website, data["website"])
|
|
self.assertEqual(sponsorship_request.org_description, data["description"])
|
|
self.assertEqual(
|
|
sponsorship_request.org_type,
|
|
Realm.ORG_TYPES["opensource"]["id"],
|
|
)
|
|
|
|
from django.core.mail import outbox
|
|
|
|
# First email is remote user email confirmation, second email is for sponsorship
|
|
message = outbox[1]
|
|
self.assert_length(outbox, 2)
|
|
self.assert_length(message.to, 1)
|
|
self.assertEqual(message.to[0], "sales@zulip.com")
|
|
self.assertEqual(message.subject, "Sponsorship request for Zulip Dev")
|
|
self.assertEqual(message.reply_to, ["hamlet@zulip.com"])
|
|
self.assertEqual(self.email_envelope_from(message), settings.NOREPLY_EMAIL_ADDRESS)
|
|
self.assertIn("Zulip sponsorship request <noreply-", self.email_display_from(message))
|
|
self.assertIn(
|
|
f"Support URL: http://zulip.testserver/activity/remote/support?q={remote_realm.uuid!s}",
|
|
message.body,
|
|
)
|
|
self.assertIn("Website: https://infinispan.org", message.body)
|
|
self.assertIn("Organization type: Open-source", message.body)
|
|
self.assertIn("Description:\nInfinispan is a distributed in-memory", message.body)
|
|
|
|
# Check /billing redirects you to sponsorship page.
|
|
response = self.client_get(f"{billing_base_url}/billing/", subdomain="selfhosting")
|
|
self.assertEqual(response.status_code, 302)
|
|
self.assertEqual(response["Location"], f"/realm/{realm.uuid!s}/sponsorship/")
|
|
|
|
# Check sponsorship page shows sponsorship pending banner.
|
|
result = self.client_get(f"{billing_base_url}/sponsorship/", subdomain="selfhosting")
|
|
self.assert_in_success_response(
|
|
["This organization has requested sponsorship for a", "Community"], result
|
|
)
|
|
|
|
# Approve sponsorship
|
|
billing_session = RemoteRealmBillingSession(
|
|
remote_realm=remote_realm, support_staff=self.example_user("iago")
|
|
)
|
|
billing_session.approve_sponsorship()
|
|
remote_realm.refresh_from_db()
|
|
self.assertEqual(remote_realm.plan_type, RemoteRealm.PLAN_TYPE_COMMUNITY)
|
|
# Assert such a plan exists
|
|
CustomerPlan.objects.get(
|
|
customer=customer,
|
|
tier=CustomerPlan.TIER_SELF_HOSTED_COMMUNITY,
|
|
status=CustomerPlan.ACTIVE,
|
|
next_invoice_date=None,
|
|
price_per_license=0,
|
|
)
|
|
|
|
# Check email sent.
|
|
expected_message = (
|
|
"Your request for Zulip sponsorship has been approved! Your organization has been upgraded to the Zulip Community plan."
|
|
"\n\nIf you could list Zulip as a sponsor on your website, we would really appreciate it!"
|
|
)
|
|
self.assert_length(outbox, 3)
|
|
message = outbox[2]
|
|
self.assert_length(message.to, 1)
|
|
self.assertEqual(message.to[0], "hamlet@zulip.com")
|
|
self.assertEqual(message.subject, "Community plan sponsorship approved for Zulip Dev!")
|
|
self.assertEqual(message.from_email, "noreply@testserver")
|
|
self.assertIn(expected_message[0], message.body)
|
|
self.assertIn(expected_message[1], message.body)
|
|
|
|
# Check sponsorship approved banner.
|
|
result = self.client_get(f"{billing_base_url}/sponsorship/", subdomain="selfhosting")
|
|
self.assert_in_success_response(["Zulip is sponsoring a free", "Community"], result)
|
|
|
|
@responses.activate
|
|
@mock_stripe()
|
|
def test_migrate_customer_server_to_realms_and_upgrade(self, *mocks: Mock) -> None:
|
|
remote_server = RemoteZulipServer.objects.get(hostname="demo.example.com")
|
|
server_billing_session = RemoteServerBillingSession(remote_server=remote_server)
|
|
|
|
# Create complimentary access plan for server.
|
|
with time_machine.travel(self.now, tick=False):
|
|
start_date = timezone_now()
|
|
end_date = add_months(start_date, months=3)
|
|
server_billing_session.create_complimentary_access_plan(start_date, end_date)
|
|
|
|
server_customer = server_billing_session.get_customer()
|
|
assert server_customer is not None
|
|
server_customer_plan = get_current_plan_by_customer(server_customer)
|
|
assert server_customer_plan is not None
|
|
self.assertEqual(server_customer_plan.tier, CustomerPlan.TIER_SELF_HOSTED_LEGACY)
|
|
self.assertEqual(server_customer_plan.status, CustomerPlan.ACTIVE)
|
|
self.assertEqual(remote_server.plan_type, RemoteZulipServer.PLAN_TYPE_SELF_MANAGED_LEGACY)
|
|
|
|
# The plan gets migrated if there's only a single human realm.
|
|
Realm.objects.exclude(string_id__in=["zulip", "zulipinternal"]).delete()
|
|
|
|
# First, set a sponsorship as pending.
|
|
# TODO: Ideally, we'd submit a proper sponsorship request.
|
|
server_customer.sponsorship_pending = True
|
|
server_customer.save()
|
|
|
|
# Upload data.
|
|
with time_machine.travel(self.now, tick=False):
|
|
self.add_mock_response()
|
|
send_server_data_to_push_bouncer(consider_usage_statistics=False)
|
|
|
|
self.login("hamlet")
|
|
hamlet = self.example_user("hamlet")
|
|
billing_base_url = self.billing_session.billing_base_url
|
|
|
|
# Login. The server has a pending sponsorship, in which case migrating
|
|
# can't be done, as that'd be a fairly confusing process.
|
|
result = self.execute_remote_billing_authentication_flow(hamlet, return_from_auth_url=True)
|
|
|
|
self.assertEqual(result.status_code, 200)
|
|
self.assert_in_response("Plan management not available", result)
|
|
# Server's plan should not have been migrated yet.
|
|
self.server.refresh_from_db()
|
|
self.assertEqual(self.server.plan_type, RemoteZulipServer.PLAN_TYPE_SELF_MANAGED_LEGACY)
|
|
|
|
# Now clear the pending sponsorship state, which will allow login
|
|
# and migration to proceed.
|
|
# TODO: Ideally, this would approve the sponsorship and then be testing
|
|
# the migration of the Community plan.
|
|
server_customer.sponsorship_pending = False
|
|
server_customer.save()
|
|
|
|
# Login. Performs customer migration from server to realm.
|
|
result = self.execute_remote_billing_authentication_flow(hamlet)
|
|
|
|
self.assertEqual(result.status_code, 302)
|
|
self.assertEqual(result["Location"], f"{billing_base_url}/plans/")
|
|
|
|
remote_server.refresh_from_db()
|
|
remote_realm = RemoteRealm.objects.get(uuid=hamlet.realm.uuid)
|
|
# The customer object was moved, together with the plan, from server to realm.
|
|
customer = get_customer_by_remote_realm(remote_realm)
|
|
assert customer is not None
|
|
self.assertEqual(server_customer, customer)
|
|
self.assertEqual(remote_server.plan_type, RemoteZulipServer.PLAN_TYPE_SELF_MANAGED)
|
|
self.assertEqual(remote_realm.plan_type, RemoteRealm.PLAN_TYPE_SELF_MANAGED_LEGACY)
|
|
|
|
customer_plan = get_current_plan_by_customer(customer)
|
|
assert customer_plan is not None
|
|
self.assertEqual(customer_plan.tier, CustomerPlan.TIER_SELF_HOSTED_LEGACY)
|
|
self.assertEqual(customer_plan.status, CustomerPlan.ACTIVE)
|
|
|
|
# upgrade to business plan
|
|
with time_machine.travel(self.now, tick=False):
|
|
result = self.client_get(f"{billing_base_url}/upgrade/", subdomain="selfhosting")
|
|
self.assertEqual(result.status_code, 200)
|
|
self.assert_in_success_response(["Add card", "Purchase Zulip Business"], result)
|
|
|
|
with time_machine.travel(self.now, tick=False):
|
|
stripe_customer = self.add_card_and_upgrade()
|
|
|
|
self.assertEqual(customer, Customer.objects.get(stripe_customer_id=stripe_customer.id))
|
|
business_plan = CustomerPlan.objects.get(customer=customer, status=CustomerPlan.ACTIVE)
|
|
self.assertEqual(business_plan.tier, CustomerPlan.TIER_SELF_HOSTED_BUSINESS)
|
|
|
|
realm_user_count = UserProfile.objects.filter(
|
|
realm=hamlet.realm, is_bot=False, is_active=True
|
|
).count()
|
|
licenses = max(
|
|
realm_user_count, self.billing_session.min_licenses_for_plan(business_plan.tier)
|
|
)
|
|
with time_machine.travel(self.now + timedelta(days=1), tick=False):
|
|
response = self.client_get(f"{billing_base_url}/billing/", subdomain="selfhosting")
|
|
for substring in [
|
|
"Zulip Business",
|
|
"Number of licenses",
|
|
f"{licenses}",
|
|
"Your plan will automatically renew on",
|
|
"January 2, 2013",
|
|
f"${80 * licenses:,.2f}",
|
|
"Visa ending in 4242",
|
|
"Update card",
|
|
]:
|
|
self.assert_in_response(substring, response)
|
|
|
|
# Login again
|
|
result = self.execute_remote_billing_authentication_flow(
|
|
hamlet, first_time_login=False, expect_tos=False, confirm_tos=False
|
|
)
|
|
self.assertEqual(result.status_code, 302)
|
|
self.assertEqual(result["Location"], f"{billing_base_url}/billing/")
|
|
|
|
# Downgrade
|
|
with (
|
|
self.assertLogs("corporate.stripe", "INFO") as m,
|
|
time_machine.travel(self.now + timedelta(days=7), tick=False),
|
|
):
|
|
response = self.client_billing_patch(
|
|
"/billing/plan",
|
|
{"status": CustomerPlan.DOWNGRADE_AT_END_OF_CYCLE},
|
|
)
|
|
expected_log = f"INFO:corporate.stripe:Change plan status: Customer.id: {customer.id}, CustomerPlan.id: {business_plan.id}, status: {CustomerPlan.DOWNGRADE_AT_END_OF_CYCLE}"
|
|
self.assertEqual(m.output[0], expected_log)
|
|
self.assert_json_success(response)
|
|
business_plan.refresh_from_db()
|
|
self.assertEqual(business_plan.licenses_at_next_renewal(), None)
|
|
|
|
@responses.activate
|
|
@mock_stripe()
|
|
def test_invoice_initial_remote_realm_upgrade(self, *mocks: Mock) -> None:
|
|
self.login("hamlet")
|
|
hamlet = self.example_user("hamlet")
|
|
|
|
realm_user_count = UserProfile.objects.filter(
|
|
realm=hamlet.realm, is_bot=False, is_active=True
|
|
).count()
|
|
|
|
self.add_mock_response()
|
|
with time_machine.travel(self.now, tick=False):
|
|
send_server_data_to_push_bouncer(consider_usage_statistics=False)
|
|
|
|
self.execute_remote_billing_authentication_flow(hamlet)
|
|
with time_machine.travel(self.now, tick=False):
|
|
stripe_customer = self.add_card_and_upgrade(
|
|
tier=CustomerPlan.TIER_SELF_HOSTED_BASIC, schedule="monthly"
|
|
)
|
|
|
|
[invoice0] = iter(stripe.Invoice.list(customer=stripe_customer.id))
|
|
|
|
[invoice_item0, invoice_item1] = iter(invoice0.lines)
|
|
invoice_item_params = {
|
|
"amount": -2000,
|
|
"description": "$20.00/month new customer discount",
|
|
"quantity": 1,
|
|
}
|
|
for key, value in invoice_item_params.items():
|
|
self.assertEqual(invoice_item0[key], value)
|
|
|
|
invoice_item_params = {
|
|
"amount": realm_user_count * 3.5 * 100,
|
|
"description": "Zulip Basic",
|
|
"quantity": realm_user_count,
|
|
}
|
|
for key, value in invoice_item_params.items():
|
|
self.assertEqual(invoice_item1[key], value)
|
|
|
|
@responses.activate
|
|
@mock_stripe()
|
|
def test_invoice_plans_as_needed(self, *mocks: Mock) -> None:
|
|
self.login("hamlet")
|
|
hamlet = self.example_user("hamlet")
|
|
|
|
self.add_mock_response()
|
|
with time_machine.travel(self.now, tick=False):
|
|
send_server_data_to_push_bouncer(consider_usage_statistics=False)
|
|
|
|
self.execute_remote_billing_authentication_flow(hamlet)
|
|
with time_machine.travel(self.now, tick=False):
|
|
stripe_customer = self.add_card_and_upgrade(
|
|
tier=CustomerPlan.TIER_SELF_HOSTED_BASIC, schedule="monthly"
|
|
)
|
|
|
|
customer = Customer.objects.get(stripe_customer_id=stripe_customer.id)
|
|
plan = CustomerPlan.objects.get(customer=customer)
|
|
assert plan.customer.remote_realm is not None
|
|
self.assertEqual(plan.next_invoice_date, self.next_month)
|
|
|
|
with time_machine.travel(self.now + timedelta(days=2), tick=False):
|
|
for count in range(5):
|
|
do_create_user(
|
|
f"email - {count}",
|
|
f"password {count}",
|
|
hamlet.realm,
|
|
"name",
|
|
role=UserProfile.ROLE_MEMBER,
|
|
acting_user=None,
|
|
)
|
|
|
|
# Data upload was 25 days before the invoice date.
|
|
last_audit_log_update = self.now + timedelta(days=5)
|
|
with time_machine.travel(last_audit_log_update, tick=False):
|
|
send_server_data_to_push_bouncer(consider_usage_statistics=False)
|
|
invoice_plans_as_needed(self.next_month)
|
|
plan.refresh_from_db()
|
|
self.assertEqual(plan.next_invoice_date, self.next_month)
|
|
self.assertTrue(plan.invoice_overdue_email_sent)
|
|
|
|
from django.core.mail import outbox
|
|
|
|
messages_count = len(outbox)
|
|
message = outbox[-1]
|
|
self.assert_length(message.to, 1)
|
|
self.assertEqual(message.to[0], "sales@zulip.com")
|
|
self.assertEqual(
|
|
message.subject,
|
|
f"Invoice overdue for {self.billing_session.billing_entity_display_name} due to stale data",
|
|
)
|
|
self.assertIn(
|
|
f"Support URL: {self.billing_session.support_url()}",
|
|
message.body,
|
|
)
|
|
self.assertIn(
|
|
f"Internal billing notice for {self.billing_session.billing_entity_display_name}.",
|
|
message.body,
|
|
)
|
|
self.assertIn("Recent invoice is overdue for payment.", message.body)
|
|
self.assertIn(
|
|
f"Last data upload: {last_audit_log_update.strftime('%Y-%m-%d')}", message.body
|
|
)
|
|
|
|
# Cron runs again, don't send another email to Zulip team.
|
|
invoice_plans_as_needed(self.next_month + timedelta(days=1))
|
|
self.assert_length(outbox, messages_count)
|
|
|
|
# Ledger is up-to-date. Plan invoiced.
|
|
with time_machine.travel(self.next_month, tick=False):
|
|
send_server_data_to_push_bouncer(consider_usage_statistics=False)
|
|
invoice_plans_as_needed(self.next_month)
|
|
plan.refresh_from_db()
|
|
self.assertEqual(plan.next_invoice_date, add_months(self.next_month, 1))
|
|
self.assertFalse(plan.invoice_overdue_email_sent)
|
|
|
|
assert customer.stripe_customer_id
|
|
[invoice0, invoice1] = iter(stripe.Invoice.list(customer=customer.stripe_customer_id))
|
|
|
|
[invoice_item0, invoice_item1, invoice_item2] = iter(invoice0.lines)
|
|
invoice_item_params = {
|
|
"amount": 16 * 3.5 * 100,
|
|
"description": "Zulip Basic - renewal",
|
|
"quantity": 16,
|
|
"period": {
|
|
"start": datetime_to_timestamp(self.next_month),
|
|
"end": datetime_to_timestamp(add_months(self.next_month, 1)),
|
|
},
|
|
}
|
|
for key, value in invoice_item_params.items():
|
|
self.assertEqual(invoice_item1[key], value)
|
|
|
|
invoice_item_params = {
|
|
"description": "Additional license (Jan 4, 2012 - Feb 2, 2012)",
|
|
"quantity": 5,
|
|
"period": {
|
|
"start": datetime_to_timestamp(self.now + timedelta(days=2)),
|
|
"end": datetime_to_timestamp(self.next_month),
|
|
},
|
|
}
|
|
for key, value in invoice_item_params.items():
|
|
self.assertEqual(invoice_item2[key], value)
|
|
|
|
# Verify Zulip team receives mail for the next cycle.
|
|
invoice_plans_as_needed(add_months(self.next_month, 1))
|
|
self.assert_length(outbox, messages_count + 1)
|
|
|
|
@responses.activate
|
|
@mock_stripe()
|
|
def test_invoice_scheduled_upgrade_realm_complimentary_access_plan(self, *mocks: Mock) -> None:
|
|
hamlet = self.example_user("hamlet")
|
|
|
|
remote_realm = RemoteRealm.objects.get(uuid=hamlet.realm.uuid)
|
|
remote_realm_billing_session = RemoteRealmBillingSession(remote_realm=remote_realm)
|
|
|
|
# Create complimentary access plan for realm.
|
|
with time_machine.travel(self.now, tick=False):
|
|
start_date = timezone_now()
|
|
end_date = add_months(start_date, months=3)
|
|
remote_realm_billing_session.create_complimentary_access_plan(start_date, end_date)
|
|
|
|
# Upload data.
|
|
self.add_mock_response()
|
|
with time_machine.travel(self.now, tick=False):
|
|
send_server_data_to_push_bouncer(consider_usage_statistics=False)
|
|
|
|
self.login("hamlet")
|
|
|
|
# Login.
|
|
self.execute_remote_billing_authentication_flow(hamlet)
|
|
|
|
# Schedule upgrade to business plan
|
|
with time_machine.travel(self.now, tick=False):
|
|
stripe_customer = self.add_card_and_upgrade(
|
|
remote_server_plan_start_date="billing_cycle_end_date", talk_to_stripe=False
|
|
)
|
|
|
|
zulip_realm_customer = Customer.objects.get(stripe_customer_id=stripe_customer.id)
|
|
assert zulip_realm_customer is not None
|
|
realm_complimentary_access_plan = get_current_plan_by_customer(zulip_realm_customer)
|
|
assert realm_complimentary_access_plan is not None
|
|
self.assertEqual(realm_complimentary_access_plan.tier, CustomerPlan.TIER_SELF_HOSTED_LEGACY)
|
|
self.assertEqual(
|
|
realm_complimentary_access_plan.status, CustomerPlan.SWITCH_PLAN_TIER_AT_PLAN_END
|
|
)
|
|
self.assertEqual(realm_complimentary_access_plan.next_invoice_date, end_date)
|
|
|
|
new_plan = self.billing_session.get_next_plan(realm_complimentary_access_plan)
|
|
assert new_plan is not None
|
|
self.assertEqual(new_plan.tier, CustomerPlan.TIER_SELF_HOSTED_BUSINESS)
|
|
self.assertEqual(new_plan.status, CustomerPlan.NEVER_STARTED)
|
|
self.assertEqual(
|
|
new_plan.invoicing_status, CustomerPlan.INVOICING_STATUS_INITIAL_INVOICE_TO_BE_SENT
|
|
)
|
|
self.assertEqual(new_plan.next_invoice_date, end_date)
|
|
self.assertEqual(new_plan.billing_cycle_anchor, end_date)
|
|
|
|
realm_user_count = UserProfile.objects.filter(
|
|
realm=hamlet.realm, is_bot=False, is_active=True
|
|
).count()
|
|
licenses = max(
|
|
realm_user_count,
|
|
self.billing_session.min_licenses_for_plan(CustomerPlan.TIER_SELF_HOSTED_BUSINESS),
|
|
)
|
|
|
|
with time_machine.travel(end_date, tick=False):
|
|
send_server_data_to_push_bouncer(consider_usage_statistics=False)
|
|
invoice_plans_as_needed()
|
|
# 'invoice_plan()' is called with both complimentary access & new plan, but
|
|
# invoice is created only for new plan. The complimentary access plan only goes
|
|
# through the end of cycle updates.
|
|
|
|
realm_complimentary_access_plan.refresh_from_db()
|
|
new_plan.refresh_from_db()
|
|
self.assertEqual(realm_complimentary_access_plan.status, CustomerPlan.ENDED)
|
|
self.assertEqual(realm_complimentary_access_plan.next_invoice_date, None)
|
|
self.assertEqual(new_plan.status, CustomerPlan.ACTIVE)
|
|
self.assertEqual(new_plan.invoicing_status, CustomerPlan.INVOICING_STATUS_DONE)
|
|
self.assertEqual(new_plan.next_invoice_date, add_months(end_date, 1))
|
|
|
|
[invoice0] = iter(stripe.Invoice.list(customer=stripe_customer.id))
|
|
|
|
[invoice_item0, invoice_item1] = iter(invoice0.lines)
|
|
invoice_item_params = {
|
|
"amount": -2000 * 12,
|
|
"description": "$20.00/month new customer discount",
|
|
"quantity": 1,
|
|
}
|
|
for key, value in invoice_item_params.items():
|
|
self.assertEqual(invoice_item0[key], value)
|
|
|
|
invoice_item_params = {
|
|
"amount": licenses * 80 * 100,
|
|
"description": "Zulip Business - renewal",
|
|
"quantity": licenses,
|
|
}
|
|
for key, value in invoice_item_params.items():
|
|
self.assertEqual(invoice_item1[key], value)
|
|
|
|
|
|
@activate_push_notification_service()
|
|
class TestRemoteServerBillingFlow(StripeTestCase, RemoteServerTestCase):
|
|
@override
|
|
def setUp(self) -> None:
|
|
super().setUp()
|
|
|
|
# Reset already created audit logs for this test as they have
|
|
# event_time=timezone_now() that will affects the LicenseLedger
|
|
# queries as their event_time would be more recent than other
|
|
# operations we perform in this test.
|
|
RealmAuditLog.objects.filter(event_type__in=RealmAuditLog.SYNCED_BILLING_EVENTS).delete()
|
|
zulip_realm = get_realm("zulip")
|
|
lear_realm = get_realm("lear")
|
|
zephyr_realm = get_realm("zephyr")
|
|
with time_machine.travel(self.now, tick=False):
|
|
for count in range(2):
|
|
for realm in [zulip_realm, zephyr_realm, lear_realm]:
|
|
do_create_user(
|
|
f"email {count}",
|
|
f"password {count}",
|
|
realm,
|
|
"name",
|
|
acting_user=None,
|
|
)
|
|
|
|
self.remote_server = RemoteZulipServer.objects.get(hostname="demo.example.com")
|
|
self.billing_session = RemoteServerBillingSession(remote_server=self.remote_server)
|
|
|
|
@responses.activate
|
|
@mock_stripe()
|
|
def test_non_sponsorship_billing(self, *mocks: Mock) -> None:
|
|
server_user_count = UserProfile.objects.filter(is_bot=False, is_active=True).count()
|
|
|
|
# Upload data
|
|
self.add_mock_response()
|
|
with time_machine.travel(self.now, tick=False):
|
|
send_server_data_to_push_bouncer(consider_usage_statistics=False)
|
|
|
|
self.login("hamlet")
|
|
hamlet = self.example_user("hamlet")
|
|
billing_base_url = self.billing_session.billing_base_url
|
|
|
|
result = self.execute_remote_billing_authentication_flow(
|
|
hamlet.delivery_email, hamlet.full_name, expect_tos=True, confirm_tos=True
|
|
)
|
|
self.assertEqual(result.status_code, 302)
|
|
self.assertEqual(result["Location"], f"{billing_base_url}/plans/")
|
|
|
|
# upgrade to business plan
|
|
with time_machine.travel(self.now, tick=False):
|
|
result = self.client_get(f"{billing_base_url}/upgrade/", subdomain="selfhosting")
|
|
self.assertEqual(result.status_code, 200)
|
|
self.assert_in_success_response(["Add card", "Purchase Zulip Business"], result)
|
|
|
|
# Same result even with free trial enabled for self hosted customers since we don't
|
|
# offer free trial for business plan.
|
|
with (
|
|
self.settings(SELF_HOSTING_FREE_TRIAL_DAYS=30),
|
|
time_machine.travel(self.now, tick=False),
|
|
):
|
|
result = self.client_get(f"{billing_base_url}/upgrade/", subdomain="selfhosting")
|
|
|
|
self.assert_in_success_response(["Add card", "Purchase Zulip Business"], result)
|
|
|
|
self.assertFalse(Customer.objects.exists())
|
|
self.assertFalse(CustomerPlan.objects.exists())
|
|
self.assertFalse(LicenseLedger.objects.exists())
|
|
|
|
with time_machine.travel(self.now, tick=False):
|
|
stripe_customer = self.add_card_and_upgrade()
|
|
|
|
customer = Customer.objects.get(stripe_customer_id=stripe_customer.id)
|
|
plan = CustomerPlan.objects.get(customer=customer)
|
|
LicenseLedger.objects.get(plan=plan)
|
|
|
|
# Visit billing page
|
|
with time_machine.travel(self.now + timedelta(days=1), tick=False):
|
|
response = self.client_get(f"{billing_base_url}/billing/", subdomain="selfhosting")
|
|
for substring in [
|
|
"Zulip Business",
|
|
"Number of licenses",
|
|
f"{25}",
|
|
"Your plan will automatically renew on",
|
|
"January 2, 2013",
|
|
f"${80 * 25:,.2f}",
|
|
"Visa ending in 4242",
|
|
"Update card",
|
|
]:
|
|
self.assert_in_response(substring, response)
|
|
|
|
# Verify that change in user count of any realm collectively updates LicenseLedger.
|
|
audit_log_count = RemoteRealmAuditLog.objects.count()
|
|
self.assertEqual(LicenseLedger.objects.count(), 1)
|
|
|
|
with time_machine.travel(self.now + timedelta(days=2), tick=False):
|
|
# Create 4 new users in each lear and zulip realm.
|
|
for count in range(2, 6):
|
|
for realm in [get_realm("lear"), get_realm("zulip")]:
|
|
do_create_user(
|
|
f"email {count}",
|
|
f"password {count}",
|
|
realm,
|
|
"name",
|
|
acting_user=None,
|
|
)
|
|
|
|
with time_machine.travel(self.now + timedelta(days=3), tick=False):
|
|
send_server_data_to_push_bouncer(consider_usage_statistics=False)
|
|
|
|
self.assertEqual(
|
|
RemoteRealmAuditLog.objects.count(),
|
|
audit_log_count + 8,
|
|
)
|
|
latest_ledger = LicenseLedger.objects.last()
|
|
assert latest_ledger is not None
|
|
self.assertEqual(latest_ledger.licenses, server_user_count + 8)
|
|
|
|
# Login again
|
|
result = self.execute_remote_billing_authentication_flow(
|
|
hamlet.delivery_email, hamlet.full_name, expect_tos=False, confirm_tos=False
|
|
)
|
|
self.assertEqual(result.status_code, 302)
|
|
self.assertEqual(result["Location"], f"{billing_base_url}/billing/")
|
|
|
|
# Downgrade
|
|
with (
|
|
self.assertLogs("corporate.stripe", "INFO") as m,
|
|
time_machine.travel(self.now + timedelta(days=7), tick=False),
|
|
):
|
|
response = self.client_billing_patch(
|
|
"/billing/plan",
|
|
{"status": CustomerPlan.DOWNGRADE_AT_END_OF_CYCLE},
|
|
)
|
|
customer = Customer.objects.get(remote_server=self.remote_server)
|
|
new_plan = get_current_plan_by_customer(customer)
|
|
assert new_plan is not None
|
|
expected_log = f"INFO:corporate.stripe:Change plan status: Customer.id: {customer.id}, CustomerPlan.id: {new_plan.id}, status: {CustomerPlan.DOWNGRADE_AT_END_OF_CYCLE}"
|
|
self.assertEqual(m.output[0], expected_log)
|
|
self.assert_json_success(response)
|
|
self.assertEqual(new_plan.licenses_at_next_renewal(), None)
|
|
|
|
@responses.activate
|
|
def test_request_sponsorship(self) -> None:
|
|
hamlet = self.example_user("hamlet")
|
|
now = timezone_now()
|
|
with time_machine.travel(now, tick=False):
|
|
result = self.execute_remote_billing_authentication_flow(
|
|
hamlet.delivery_email, hamlet.full_name, expect_tos=True, confirm_tos=True
|
|
)
|
|
|
|
self.add_mock_response()
|
|
send_server_data_to_push_bouncer(consider_usage_statistics=False)
|
|
billing_base_url = self.billing_session.billing_base_url
|
|
|
|
self.assertEqual(self.remote_server.plan_type, RemoteZulipServer.PLAN_TYPE_SELF_MANAGED)
|
|
self.assertIsNone(self.billing_session.get_customer())
|
|
|
|
# User has no plan, so we redirect to /plans by default.
|
|
self.assertEqual(result["Location"], f"/server/{self.remote_server.uuid!s}/plans/")
|
|
|
|
# Check strings on plans page.
|
|
result = self.client_get(result["Location"], subdomain="selfhosting")
|
|
self.assert_not_in_success_response(["Sponsorship pending"], result)
|
|
|
|
# Navigate to request sponsorship page.
|
|
result = self.client_get(f"{billing_base_url}/sponsorship/", subdomain="selfhosting")
|
|
self.assert_in_success_response(
|
|
["Description of your organization", "Requested plan"], result
|
|
)
|
|
|
|
# Submit form data.
|
|
data = {
|
|
"organization_type": Realm.ORG_TYPES["opensource"]["id"],
|
|
"website": "https://infinispan.org/",
|
|
"description": "Infinispan is a distributed in-memory key/value data store with optional schema.",
|
|
"expected_total_users": "10 users",
|
|
"plan_to_use_zulip": "For communication on moon.",
|
|
"paid_users_count": "1 user",
|
|
"paid_users_description": "We have 1 paid user.",
|
|
"requested_plan": "Community",
|
|
}
|
|
response = self.client_billing_post("/billing/sponsorship", data)
|
|
self.assert_json_success(response)
|
|
|
|
customer = self.billing_session.get_customer()
|
|
assert customer is not None
|
|
|
|
sponsorship_request = ZulipSponsorshipRequest.objects.get(customer=customer)
|
|
self.assertEqual(sponsorship_request.requested_plan, data["requested_plan"])
|
|
self.assertEqual(sponsorship_request.org_website, data["website"])
|
|
self.assertEqual(sponsorship_request.org_description, data["description"])
|
|
self.assertEqual(
|
|
sponsorship_request.org_type,
|
|
Realm.ORG_TYPES["opensource"]["id"],
|
|
)
|
|
|
|
from django.core.mail import outbox
|
|
|
|
# First email is remote user email confirmation, second email is for sponsorship
|
|
message = outbox[1]
|
|
self.assert_length(outbox, 2)
|
|
self.assert_length(message.to, 1)
|
|
self.assertEqual(message.to[0], "sales@zulip.com")
|
|
self.assertEqual(message.subject, "Sponsorship request for demo.example.com")
|
|
self.assertEqual(message.reply_to, ["hamlet@zulip.com"])
|
|
self.assertEqual(self.email_envelope_from(message), settings.NOREPLY_EMAIL_ADDRESS)
|
|
self.assertIn("Zulip sponsorship request <noreply-", self.email_display_from(message))
|
|
self.assertIn(
|
|
f"Support URL: http://zulip.testserver/activity/remote/support?q={self.remote_server.uuid!s}",
|
|
message.body,
|
|
)
|
|
self.assertIn("Website: https://infinispan.org", message.body)
|
|
self.assertIn("Organization type: Open-source", message.body)
|
|
self.assertIn("Description:\nInfinispan is a distributed in-memory", message.body)
|
|
|
|
# Check /billing redirects you to sponsorship page.
|
|
response = self.client_get(f"{billing_base_url}/billing/", subdomain="selfhosting")
|
|
self.assertEqual(response.status_code, 302)
|
|
self.assertEqual(response["Location"], f"/server/{self.remote_server.uuid!s}/sponsorship/")
|
|
|
|
# Check sponsorship page shows sponsorship pending banner.
|
|
result = self.client_get(f"{billing_base_url}/sponsorship/", subdomain="selfhosting")
|
|
self.assert_in_success_response(
|
|
["This organization has requested sponsorship for a", "Community"], result
|
|
)
|
|
|
|
# Approve sponsorship
|
|
billing_session = RemoteServerBillingSession(
|
|
remote_server=self.remote_server, support_staff=self.example_user("iago")
|
|
)
|
|
billing_session.approve_sponsorship()
|
|
self.remote_server.refresh_from_db()
|
|
self.assertEqual(self.remote_server.plan_type, RemoteZulipServer.PLAN_TYPE_COMMUNITY)
|
|
# Assert such a plan exists
|
|
CustomerPlan.objects.get(
|
|
customer=customer,
|
|
tier=CustomerPlan.TIER_SELF_HOSTED_COMMUNITY,
|
|
status=CustomerPlan.ACTIVE,
|
|
next_invoice_date=None,
|
|
price_per_license=0,
|
|
)
|
|
|
|
# Check email sent.
|
|
expected_message = (
|
|
"Your request for Zulip sponsorship has been approved! Your organization has been upgraded to the Zulip Community plan."
|
|
"\n\nIf you could list Zulip as a sponsor on your website, we would really appreciate it!"
|
|
)
|
|
self.assert_length(outbox, 3)
|
|
message = outbox[2]
|
|
self.assert_length(message.to, 1)
|
|
self.assertEqual(message.to[0], "hamlet@zulip.com")
|
|
self.assertEqual(
|
|
message.subject, "Community plan sponsorship approved for demo.example.com!"
|
|
)
|
|
self.assertEqual(message.from_email, "noreply@testserver")
|
|
self.assertIn(expected_message[0], message.body)
|
|
self.assertIn(expected_message[1], message.body)
|
|
|
|
# Check sponsorship approved banner.
|
|
result = self.client_get(f"{billing_base_url}/sponsorship/", subdomain="selfhosting")
|
|
self.assert_in_success_response(["Zulip is sponsoring a free", "Community"], result)
|
|
|
|
@responses.activate
|
|
@mock_stripe()
|
|
def test_upgrade_complimentary_access_plan(self, *mocks: Mock) -> None:
|
|
# Upload data
|
|
with time_machine.travel(self.now, tick=False):
|
|
self.add_mock_response()
|
|
send_server_data_to_push_bouncer(consider_usage_statistics=False)
|
|
|
|
# Create complimentary access plan for server.
|
|
with time_machine.travel(self.now, tick=False):
|
|
start_date = timezone_now()
|
|
end_date = add_months(start_date, months=3)
|
|
self.billing_session.create_complimentary_access_plan(start_date, end_date)
|
|
|
|
customer = self.billing_session.get_customer()
|
|
assert customer is not None
|
|
customer_plan = get_current_plan_by_customer(customer)
|
|
assert customer_plan is not None
|
|
self.assertEqual(customer_plan.tier, CustomerPlan.TIER_SELF_HOSTED_LEGACY)
|
|
self.assertEqual(customer_plan.status, CustomerPlan.ACTIVE)
|
|
|
|
self.login("hamlet")
|
|
hamlet = self.example_user("hamlet")
|
|
billing_base_url = self.billing_session.billing_base_url
|
|
|
|
# Login
|
|
with time_machine.travel(self.now, tick=False):
|
|
result = self.execute_remote_billing_authentication_flow(
|
|
hamlet.delivery_email, hamlet.full_name, expect_tos=True, confirm_tos=True
|
|
)
|
|
self.assertEqual(result.status_code, 302)
|
|
self.assertEqual(result["Location"], f"{billing_base_url}/plans/")
|
|
|
|
# Visit '/upgrade'
|
|
with time_machine.travel(self.now, tick=False):
|
|
result = self.client_get(f"{billing_base_url}/upgrade/", subdomain="selfhosting")
|
|
self.assertEqual(result.status_code, 200)
|
|
self.assert_in_success_response(["Add card", "Schedule upgrade to Zulip Business"], result)
|
|
|
|
# Add card and schedule upgrade
|
|
with time_machine.travel(self.now, tick=False):
|
|
self.add_card_and_upgrade(
|
|
remote_server_plan_start_date="billing_cycle_end_date", talk_to_stripe=False
|
|
)
|
|
customer_plan.refresh_from_db()
|
|
self.assertEqual(customer_plan.status, CustomerPlan.SWITCH_PLAN_TIER_AT_PLAN_END)
|
|
self.assertEqual(customer_plan.end_date, end_date)
|
|
new_customer_plan = self.billing_session.get_next_plan(customer_plan)
|
|
assert new_customer_plan is not None
|
|
self.assertEqual(new_customer_plan.tier, CustomerPlan.TIER_SELF_HOSTED_BUSINESS)
|
|
self.assertEqual(new_customer_plan.status, CustomerPlan.NEVER_STARTED)
|
|
self.assertEqual(new_customer_plan.billing_cycle_anchor, end_date)
|
|
|
|
# Visit billing page
|
|
with time_machine.travel(self.now, tick=False):
|
|
response = self.client_get(f"{billing_base_url}/billing/", subdomain="selfhosting")
|
|
for substring in [
|
|
"(complimentary access)",
|
|
f"Your complimentary access to Zulip Basic ends on {end_date.strftime('%B %d, %Y')}",
|
|
f"Your plan will automatically upgrade to Zulip Business on {end_date.strftime('%B %d, %Y')}",
|
|
"Expected next charge",
|
|
f"${80 * 25 - 20 * 12:,.2f}",
|
|
"Visa ending in 4242",
|
|
"Update card",
|
|
]:
|
|
self.assert_in_response(substring, response)
|
|
|
|
# Login again
|
|
result = self.execute_remote_billing_authentication_flow(
|
|
hamlet.delivery_email, hamlet.full_name, expect_tos=False, confirm_tos=False
|
|
)
|
|
self.assertEqual(result.status_code, 302)
|
|
self.assertEqual(result["Location"], f"{billing_base_url}/billing/")
|
|
|
|
# Downgrade
|
|
with (
|
|
self.assertLogs("corporate.stripe", "INFO") as m,
|
|
time_machine.travel(self.now + timedelta(days=7), tick=False),
|
|
):
|
|
response = self.client_billing_patch(
|
|
"/billing/plan",
|
|
{"status": CustomerPlan.ACTIVE},
|
|
)
|
|
self.assert_json_success(response)
|
|
self.assertEqual(
|
|
m.output[0],
|
|
f"INFO:corporate.stripe:Change plan status: Customer.id: {customer.id}, CustomerPlan.id: {new_customer_plan.id}, status: {CustomerPlan.ENDED}",
|
|
)
|
|
self.assertEqual(
|
|
m.output[1],
|
|
f"INFO:corporate.stripe:Change plan status: Customer.id: {customer.id}, CustomerPlan.id: {customer_plan.id}, status: {CustomerPlan.ACTIVE}",
|
|
)
|
|
|
|
@responses.activate
|
|
@mock_stripe()
|
|
def test_free_trial_not_available_for_complimentary_access_customer(self, *mocks: Mock) -> None:
|
|
with self.settings(SELF_HOSTING_FREE_TRIAL_DAYS=30):
|
|
self.login("hamlet")
|
|
hamlet = self.example_user("hamlet")
|
|
|
|
self.add_mock_response()
|
|
|
|
with time_machine.travel(self.now, tick=False):
|
|
send_server_data_to_push_bouncer(consider_usage_statistics=False)
|
|
# Free trial is not available for customers with active complimentary access plan.
|
|
end_date = add_months(self.now, months=3)
|
|
self.billing_session.create_complimentary_access_plan(self.now, end_date)
|
|
|
|
result = self.execute_remote_billing_authentication_flow(
|
|
hamlet.delivery_email, hamlet.full_name
|
|
)
|
|
self.assertEqual(result.status_code, 302)
|
|
self.assertEqual(result["Location"], f"{self.billing_session.billing_base_url}/plans/")
|
|
with time_machine.travel(self.now, tick=False):
|
|
response = self.client_get(
|
|
f"{self.billing_session.billing_base_url}/plans/", subdomain="selfhosting"
|
|
)
|
|
self.assert_not_in_success_response(["free trial"], response)
|
|
|
|
with time_machine.travel(self.now, tick=False):
|
|
result = self.client_get(
|
|
f"{self.billing_session.billing_base_url}/upgrade/?tier={CustomerPlan.TIER_SELF_HOSTED_BASIC}",
|
|
subdomain="selfhosting",
|
|
)
|
|
self.assert_not_in_success_response(["free trial"], response)
|
|
|
|
with time_machine.travel(self.now, tick=False):
|
|
self.add_card_and_upgrade(
|
|
tier=CustomerPlan.TIER_SELF_HOSTED_BASIC, schedule="monthly"
|
|
)
|
|
|
|
with time_machine.travel(self.now + timedelta(days=1), tick=False):
|
|
response = self.client_get(
|
|
f"{self.billing_session.billing_base_url}/billing/", subdomain="selfhosting"
|
|
)
|
|
self.assert_not_in_success_response(["(free trial)"], response)
|
|
|
|
@responses.activate
|
|
@mock_stripe()
|
|
def test_free_trial_not_available_for_previous_complimentary_access_customer(
|
|
self, *mocks: Mock
|
|
) -> None:
|
|
with self.settings(SELF_HOSTING_FREE_TRIAL_DAYS=30):
|
|
self.login("hamlet")
|
|
hamlet = self.example_user("hamlet")
|
|
|
|
self.add_mock_response()
|
|
|
|
with time_machine.travel(self.now, tick=False):
|
|
send_server_data_to_push_bouncer(consider_usage_statistics=False)
|
|
# Free trial is not available for customers with active complimentary access plan.
|
|
end_date = add_months(self.now, months=3)
|
|
self.billing_session.create_complimentary_access_plan(self.now, end_date)
|
|
CustomerPlan.objects.filter(customer__remote_server=self.remote_server).update(
|
|
status=CustomerPlan.ENDED
|
|
)
|
|
|
|
result = self.execute_remote_billing_authentication_flow(
|
|
hamlet.delivery_email, hamlet.full_name
|
|
)
|
|
self.assertEqual(result.status_code, 302)
|
|
self.assertEqual(result["Location"], f"{self.billing_session.billing_base_url}/plans/")
|
|
with time_machine.travel(self.now, tick=False):
|
|
response = self.client_get(
|
|
f"{self.billing_session.billing_base_url}/plans/", subdomain="selfhosting"
|
|
)
|
|
self.assert_not_in_success_response(["free trial"], response)
|
|
|
|
with time_machine.travel(self.now, tick=False):
|
|
result = self.client_get(
|
|
f"{self.billing_session.billing_base_url}/upgrade/?tier={CustomerPlan.TIER_SELF_HOSTED_BASIC}",
|
|
subdomain="selfhosting",
|
|
)
|
|
self.assert_not_in_success_response(["free trial"], response)
|
|
|
|
with time_machine.travel(self.now, tick=False):
|
|
self.add_card_and_upgrade(
|
|
tier=CustomerPlan.TIER_SELF_HOSTED_BASIC, schedule="monthly"
|
|
)
|
|
|
|
with time_machine.travel(self.now + timedelta(days=1), tick=False):
|
|
response = self.client_get(
|
|
f"{self.billing_session.billing_base_url}/billing/", subdomain="selfhosting"
|
|
)
|
|
self.assert_not_in_success_response(["(free trial)"], response)
|
|
|
|
@responses.activate
|
|
@mock_stripe()
|
|
def test_upgrade_user_to_basic_plan_free_trial_remote_server(self, *mocks: Mock) -> None:
|
|
with self.settings(SELF_HOSTING_FREE_TRIAL_DAYS=30):
|
|
self.login("hamlet")
|
|
hamlet = self.example_user("hamlet")
|
|
|
|
self.add_mock_response()
|
|
realm_user_count = UserProfile.objects.filter(is_bot=False, is_active=True).count()
|
|
self.assertEqual(realm_user_count, 18)
|
|
|
|
with time_machine.travel(self.now, tick=False):
|
|
send_server_data_to_push_bouncer(consider_usage_statistics=False)
|
|
|
|
result = self.execute_remote_billing_authentication_flow(
|
|
hamlet.delivery_email, hamlet.full_name
|
|
)
|
|
self.assertEqual(result.status_code, 302)
|
|
self.assertEqual(result["Location"], f"{self.billing_session.billing_base_url}/plans/")
|
|
|
|
# upgrade to basic plan
|
|
with time_machine.travel(self.now, tick=False):
|
|
result = self.client_get(
|
|
f"{self.billing_session.billing_base_url}/upgrade/?tier={CustomerPlan.TIER_SELF_HOSTED_BASIC}",
|
|
subdomain="selfhosting",
|
|
)
|
|
self.assertEqual(result.status_code, 200)
|
|
|
|
min_licenses = self.billing_session.min_licenses_for_plan(
|
|
CustomerPlan.TIER_SELF_HOSTED_BASIC
|
|
)
|
|
self.assertEqual(min_licenses, 6)
|
|
flat_discount, flat_discounted_months = self.billing_session.get_flat_discount_info()
|
|
self.assertEqual(flat_discounted_months, 12)
|
|
|
|
self.assert_in_success_response(
|
|
[
|
|
"Start free trial",
|
|
"Zulip Basic",
|
|
"Due",
|
|
"on February 1, 2012",
|
|
f"{min_licenses}",
|
|
"Add card",
|
|
"Start 30-day free trial",
|
|
],
|
|
result,
|
|
)
|
|
|
|
self.assertFalse(Customer.objects.exists())
|
|
self.assertFalse(CustomerPlan.objects.exists())
|
|
self.assertFalse(LicenseLedger.objects.exists())
|
|
|
|
with time_machine.travel(self.now, tick=False):
|
|
stripe_customer = self.add_card_and_upgrade(
|
|
tier=CustomerPlan.TIER_SELF_HOSTED_BASIC, schedule="monthly"
|
|
)
|
|
self.assertEqual(Invoice.objects.count(), 0)
|
|
|
|
customer = Customer.objects.get(stripe_customer_id=stripe_customer.id)
|
|
plan = CustomerPlan.objects.get(customer=customer)
|
|
LicenseLedger.objects.get(plan=plan)
|
|
|
|
with time_machine.travel(self.now + timedelta(days=1), tick=False):
|
|
response = self.client_get(
|
|
f"{self.billing_session.billing_base_url}/billing/", subdomain="selfhosting"
|
|
)
|
|
for substring in [
|
|
"Zulip Basic",
|
|
"(free trial)",
|
|
"Number of licenses",
|
|
f"{realm_user_count}",
|
|
"February 1, 2012",
|
|
"Your plan will automatically renew on",
|
|
f"${3.5 * realm_user_count - flat_discount // 100 * 1:,.2f}",
|
|
"Visa ending in 4242",
|
|
"Update card",
|
|
]:
|
|
self.assert_in_response(substring, response)
|
|
|
|
# Verify that change in user count updates LicenseLedger.
|
|
audit_log_count = RemoteRealmAuditLog.objects.count()
|
|
self.assertEqual(LicenseLedger.objects.count(), 1)
|
|
|
|
with time_machine.travel(self.now + timedelta(days=2), tick=False):
|
|
for count in range(realm_user_count, realm_user_count + 10):
|
|
do_create_user(
|
|
f"email {count}",
|
|
f"password {count}",
|
|
hamlet.realm,
|
|
"name",
|
|
role=UserProfile.ROLE_MEMBER,
|
|
acting_user=None,
|
|
)
|
|
|
|
with time_machine.travel(self.now + timedelta(days=3), tick=False):
|
|
send_server_data_to_push_bouncer(consider_usage_statistics=False)
|
|
|
|
self.assertEqual(
|
|
RemoteRealmAuditLog.objects.count(),
|
|
audit_log_count + 10,
|
|
)
|
|
latest_ledger = LicenseLedger.objects.last()
|
|
assert latest_ledger is not None
|
|
self.assertEqual(latest_ledger.licenses, 28)
|
|
|
|
with time_machine.travel(self.now + timedelta(days=3), tick=False):
|
|
response = self.client_get(
|
|
f"{self.billing_session.billing_base_url}/billing/", subdomain="selfhosting"
|
|
)
|
|
|
|
self.assertEqual(latest_ledger.licenses, 28)
|
|
for substring in [
|
|
"Zulip Basic",
|
|
"Number of licenses",
|
|
f"{latest_ledger.licenses}",
|
|
"February 1, 2012",
|
|
"Your plan will automatically renew on",
|
|
f"${3.5 * latest_ledger.licenses - flat_discount // 100 * 1:,.2f}",
|
|
"Visa ending in 4242",
|
|
"Update card",
|
|
]:
|
|
self.assert_in_response(substring, response)
|
|
|
|
# Check minimum licenses is 0 after flat discounted months is over.
|
|
customer.flat_discounted_months = 0
|
|
customer.save(update_fields=["flat_discounted_months"])
|
|
self.assertEqual(
|
|
self.billing_session.min_licenses_for_plan(CustomerPlan.TIER_SELF_HOSTED_BASIC), 1
|
|
)
|
|
|
|
# TODO: Add test for invoice generation once that's implemented.
|
|
|
|
@responses.activate
|
|
@mock_stripe()
|
|
def test_redirect_for_remote_server_billing_page_downgrade_at_free_trial_end(
|
|
self, *mocks: Mock
|
|
) -> None:
|
|
with self.settings(SELF_HOSTING_FREE_TRIAL_DAYS=30):
|
|
self.login("hamlet")
|
|
hamlet = self.example_user("hamlet")
|
|
|
|
self.add_mock_response()
|
|
with time_machine.travel(self.now, tick=False):
|
|
send_server_data_to_push_bouncer(consider_usage_statistics=False)
|
|
|
|
result = self.execute_remote_billing_authentication_flow(
|
|
hamlet.delivery_email, hamlet.full_name
|
|
)
|
|
self.assertEqual(result.status_code, 302)
|
|
self.assertEqual(result["Location"], f"{self.billing_session.billing_base_url}/plans/")
|
|
|
|
# upgrade to basic plan
|
|
with time_machine.travel(self.now, tick=False):
|
|
result = self.client_get(
|
|
f"{self.billing_session.billing_base_url}/upgrade/?tier={CustomerPlan.TIER_SELF_HOSTED_BASIC}",
|
|
subdomain="selfhosting",
|
|
)
|
|
self.assertEqual(result.status_code, 200)
|
|
|
|
self.assert_in_success_response(
|
|
[
|
|
"Start free trial",
|
|
"Zulip Basic",
|
|
"Due",
|
|
"on February 1, 2012",
|
|
"Add card",
|
|
"Start 30-day free trial",
|
|
],
|
|
result,
|
|
)
|
|
|
|
self.assertFalse(Customer.objects.exists())
|
|
self.assertFalse(CustomerPlan.objects.exists())
|
|
self.assertFalse(LicenseLedger.objects.exists())
|
|
|
|
with time_machine.travel(self.now, tick=False):
|
|
stripe_customer = self.add_card_and_upgrade(
|
|
tier=CustomerPlan.TIER_SELF_HOSTED_BASIC, schedule="monthly"
|
|
)
|
|
|
|
self.assertEqual(Invoice.objects.count(), 0)
|
|
customer = Customer.objects.get(stripe_customer_id=stripe_customer.id)
|
|
plan = CustomerPlan.objects.get(customer=customer)
|
|
LicenseLedger.objects.get(plan=plan)
|
|
|
|
with time_machine.travel(self.now + timedelta(days=1), tick=False):
|
|
response = self.client_get(
|
|
f"{self.billing_session.billing_base_url}/billing/", subdomain="selfhosting"
|
|
)
|
|
for substring in [
|
|
"Zulip Basic",
|
|
"(free trial)",
|
|
"Your plan will automatically renew on",
|
|
"February 1, 2012",
|
|
]:
|
|
self.assert_in_response(substring, response)
|
|
|
|
# schedule downgrade
|
|
with (
|
|
time_machine.travel(self.now + timedelta(days=3), tick=False),
|
|
self.assertLogs("corporate.stripe", "INFO") as m,
|
|
):
|
|
response = self.client_billing_patch(
|
|
"/billing/plan",
|
|
{"status": CustomerPlan.DOWNGRADE_AT_END_OF_FREE_TRIAL},
|
|
)
|
|
self.assert_json_success(response)
|
|
plan.refresh_from_db()
|
|
self.assertEqual(plan.status, CustomerPlan.DOWNGRADE_AT_END_OF_FREE_TRIAL)
|
|
expected_log = f"INFO:corporate.stripe:Change plan status: Customer.id: {customer.id}, CustomerPlan.id: {plan.id}, status: {CustomerPlan.DOWNGRADE_AT_END_OF_FREE_TRIAL}"
|
|
self.assertEqual(m.output[0], expected_log)
|
|
|
|
# Visit /billing on free-trial end date before the invoice cron runs.
|
|
with time_machine.travel(self.now + timedelta(days=30), tick=False):
|
|
response = self.client_get(
|
|
f"{self.billing_session.billing_base_url}/billing/", subdomain="selfhosting"
|
|
)
|
|
self.assertEqual(response.status_code, 302)
|
|
self.assertEqual(
|
|
f"{self.billing_session.billing_base_url}/plans/", response["Location"]
|
|
)
|
|
|
|
@responses.activate
|
|
@mock_stripe()
|
|
def test_upgrade_server_user_to_monthly_basic_plan(self, *mocks: Mock) -> None:
|
|
self.login("hamlet")
|
|
hamlet = self.example_user("hamlet")
|
|
|
|
self.add_mock_response()
|
|
server_user_count = UserProfile.objects.filter(is_bot=False, is_active=True).count()
|
|
self.assertEqual(server_user_count, 18)
|
|
|
|
with time_machine.travel(self.now, tick=False):
|
|
send_server_data_to_push_bouncer(consider_usage_statistics=False)
|
|
|
|
result = self.execute_remote_billing_authentication_flow(
|
|
hamlet.delivery_email, hamlet.full_name
|
|
)
|
|
self.assertEqual(result.status_code, 302)
|
|
self.assertEqual(result["Location"], f"{self.billing_session.billing_base_url}/plans/")
|
|
|
|
# upgrade to basic plan
|
|
with time_machine.travel(self.now, tick=False):
|
|
result = self.client_get(
|
|
f"{self.billing_session.billing_base_url}/upgrade/?tier={CustomerPlan.TIER_SELF_HOSTED_BASIC}",
|
|
subdomain="selfhosting",
|
|
)
|
|
self.assertEqual(result.status_code, 200)
|
|
|
|
min_licenses = self.billing_session.min_licenses_for_plan(
|
|
CustomerPlan.TIER_SELF_HOSTED_BASIC
|
|
)
|
|
self.assertEqual(min_licenses, 6)
|
|
flat_discount, flat_discounted_months = self.billing_session.get_flat_discount_info()
|
|
self.assertEqual(flat_discounted_months, 12)
|
|
|
|
self.assert_in_success_response(
|
|
[f"{min_licenses}", "Add card", "Purchase Zulip Basic"], result
|
|
)
|
|
|
|
self.assertFalse(Customer.objects.exists())
|
|
self.assertFalse(CustomerPlan.objects.exists())
|
|
self.assertFalse(LicenseLedger.objects.exists())
|
|
|
|
with time_machine.travel(self.now, tick=False):
|
|
stripe_customer = self.add_card_and_upgrade(
|
|
tier=CustomerPlan.TIER_SELF_HOSTED_BASIC, schedule="monthly"
|
|
)
|
|
|
|
customer = Customer.objects.get(stripe_customer_id=stripe_customer.id)
|
|
plan = CustomerPlan.objects.get(customer=customer)
|
|
LicenseLedger.objects.get(plan=plan)
|
|
|
|
with time_machine.travel(self.now + timedelta(days=1), tick=False):
|
|
response = self.client_get(
|
|
f"{self.billing_session.billing_base_url}/billing/", subdomain="selfhosting"
|
|
)
|
|
for substring in [
|
|
"Zulip Basic",
|
|
"Number of licenses",
|
|
f"{server_user_count}",
|
|
"February 2, 2012",
|
|
"Your plan will automatically renew on",
|
|
f"${3.5 * server_user_count - flat_discount // 100 * 1:,.2f}",
|
|
"Visa ending in 4242",
|
|
"Update card",
|
|
]:
|
|
self.assert_in_response(substring, response)
|
|
|
|
# Verify that change in user count updates LicenseLedger.
|
|
audit_log_count = RemoteRealmAuditLog.objects.count()
|
|
self.assertEqual(LicenseLedger.objects.count(), 1)
|
|
|
|
with time_machine.travel(self.now + timedelta(days=2), tick=False):
|
|
for count in range(server_user_count, server_user_count + 10):
|
|
do_create_user(
|
|
f"email {count}",
|
|
f"password {count}",
|
|
hamlet.realm,
|
|
"name",
|
|
role=UserProfile.ROLE_MEMBER,
|
|
acting_user=None,
|
|
)
|
|
|
|
with time_machine.travel(self.now + timedelta(days=3), tick=False):
|
|
send_server_data_to_push_bouncer(consider_usage_statistics=False)
|
|
|
|
self.assertEqual(
|
|
RemoteRealmAuditLog.objects.count(),
|
|
audit_log_count + 10,
|
|
)
|
|
latest_ledger = LicenseLedger.objects.last()
|
|
assert latest_ledger is not None
|
|
self.assertEqual(latest_ledger.licenses, 28)
|
|
|
|
with time_machine.travel(self.now + timedelta(days=3), tick=False):
|
|
response = self.client_get(
|
|
f"{self.billing_session.billing_base_url}/billing/", subdomain="selfhosting"
|
|
)
|
|
|
|
self.assertEqual(latest_ledger.licenses, 28)
|
|
for substring in [
|
|
"Zulip Basic",
|
|
"Number of licenses",
|
|
f"{latest_ledger.licenses}",
|
|
"February 2, 2012",
|
|
"Your plan will automatically renew on",
|
|
f"${3.5 * latest_ledger.licenses - flat_discount // 100 * 1:,.2f}",
|
|
"Visa ending in 4242",
|
|
"Update card",
|
|
]:
|
|
self.assert_in_response(substring, response)
|
|
|
|
# Check minimum licenses is 0 after flat discounted months is over.
|
|
customer.flat_discounted_months = 0
|
|
customer.save(update_fields=["flat_discounted_months"])
|
|
self.assertEqual(
|
|
self.billing_session.min_licenses_for_plan(CustomerPlan.TIER_SELF_HOSTED_BASIC), 1
|
|
)
|
|
|
|
@responses.activate
|
|
@mock_stripe()
|
|
def test_stripe_billing_portal_urls_for_remote_server(self, *mocks: Mock) -> None:
|
|
self.login("hamlet")
|
|
hamlet = self.example_user("hamlet")
|
|
|
|
self.add_mock_response()
|
|
with time_machine.travel(self.now, tick=False):
|
|
send_server_data_to_push_bouncer(consider_usage_statistics=False)
|
|
|
|
self.execute_remote_billing_authentication_flow(hamlet.delivery_email, hamlet.full_name)
|
|
self.add_card_and_upgrade()
|
|
|
|
response = self.client_get(
|
|
f"{self.billing_session.billing_base_url}/invoices/", subdomain="selfhosting"
|
|
)
|
|
self.assertEqual(response.status_code, 302)
|
|
self.assertTrue(response["Location"].startswith("https://billing.stripe.com"))
|
|
|
|
response = self.client_get(
|
|
f"{self.billing_session.billing_base_url}/customer_portal/?return_to_billing_page=true",
|
|
subdomain="selfhosting",
|
|
)
|
|
self.assertEqual(response.status_code, 302)
|
|
self.assertTrue(response["Location"].startswith("https://billing.stripe.com"))
|
|
|
|
@responses.activate
|
|
@mock_stripe()
|
|
def test_upgrade_server_to_fixed_price_monthly_basic_plan(self, *mocks: Mock) -> None:
|
|
self.login("iago")
|
|
|
|
self.add_mock_response()
|
|
with time_machine.travel(self.now, tick=False):
|
|
send_server_data_to_push_bouncer(consider_usage_statistics=False)
|
|
|
|
self.assertFalse(CustomerPlanOffer.objects.exists())
|
|
|
|
# Configure required_plan_tier and fixed_price.
|
|
annual_fixed_price = 1200
|
|
result = self.client_post(
|
|
"/activity/remote/support",
|
|
{
|
|
"remote_server_id": f"{self.remote_server.id}",
|
|
"required_plan_tier": CustomerPlan.TIER_SELF_HOSTED_BASIC,
|
|
},
|
|
)
|
|
self.assert_in_success_response(
|
|
["Required plan tier for demo.example.com set to Zulip Basic."], result
|
|
)
|
|
|
|
result = self.client_post(
|
|
"/activity/remote/support",
|
|
{"remote_server_id": f"{self.remote_server.id}", "fixed_price": annual_fixed_price},
|
|
)
|
|
self.assert_in_success_response(
|
|
["Customer can now buy a fixed price Zulip Basic plan."], result
|
|
)
|
|
fixed_price_plan_offer = CustomerPlanOffer.objects.filter(
|
|
status=CustomerPlanOffer.CONFIGURED
|
|
).first()
|
|
assert fixed_price_plan_offer is not None
|
|
self.assertEqual(fixed_price_plan_offer.tier, CustomerPlanOffer.TIER_SELF_HOSTED_BASIC)
|
|
self.assertEqual(fixed_price_plan_offer.fixed_price, annual_fixed_price * 100)
|
|
|
|
self.logout()
|
|
self.login("hamlet")
|
|
hamlet = self.example_user("hamlet")
|
|
|
|
# Visit /upgrade
|
|
self.execute_remote_billing_authentication_flow(hamlet.delivery_email, hamlet.full_name)
|
|
with time_machine.travel(self.now, tick=False):
|
|
result = self.client_get(
|
|
f"{self.billing_session.billing_base_url}/upgrade/?tier={CustomerPlan.TIER_SELF_HOSTED_BASIC}",
|
|
subdomain="selfhosting",
|
|
)
|
|
self.assertEqual(result.status_code, 200)
|
|
self.assert_in_success_response(
|
|
["Add card", "Purchase Zulip Basic", "This is a fixed-price plan."], result
|
|
)
|
|
|
|
self.assertFalse(CustomerPlan.objects.filter(status=CustomerPlan.ACTIVE).exists())
|
|
|
|
# Upgrade to fixed-price Zulip Basic plan.
|
|
with time_machine.travel(self.now, tick=False):
|
|
stripe_customer = self.add_card_and_upgrade(
|
|
tier=CustomerPlan.TIER_SELF_HOSTED_BASIC, schedule="monthly"
|
|
)
|
|
|
|
customer = Customer.objects.get(stripe_customer_id=stripe_customer.id)
|
|
current_plan = CustomerPlan.objects.get(customer=customer, status=CustomerPlan.ACTIVE)
|
|
self.assertIsNotNone(current_plan.fixed_price)
|
|
self.assertIsNone(current_plan.price_per_license)
|
|
fixed_price_plan_offer.refresh_from_db()
|
|
self.assertEqual(fixed_price_plan_offer.status, CustomerPlanOffer.PROCESSED)
|
|
|
|
# Visit /billing
|
|
with time_machine.travel(self.now + timedelta(days=1), tick=False):
|
|
response = self.client_get(
|
|
f"{self.billing_session.billing_base_url}/billing/", subdomain="selfhosting"
|
|
)
|
|
for substring in [
|
|
"Zulip Basic",
|
|
"Monthly",
|
|
"February 2, 2012",
|
|
"This is a fixed-price plan",
|
|
"Your plan will automatically renew on",
|
|
f"${int(annual_fixed_price / 12)}",
|
|
"Visa ending in 4242",
|
|
"Update card",
|
|
]:
|
|
self.assert_in_response(substring, response)
|
|
|
|
@responses.activate
|
|
@mock_stripe()
|
|
def test_upgrade_server_to_fixed_price_plan_pay_by_invoice(self, *mocks: Mock) -> None:
|
|
self.login("iago")
|
|
|
|
self.add_mock_response()
|
|
with time_machine.travel(self.now, tick=False):
|
|
send_server_data_to_push_bouncer(consider_usage_statistics=False)
|
|
|
|
self.assertFalse(CustomerPlanOffer.objects.exists())
|
|
|
|
# Configure required_plan_tier.
|
|
result = self.client_post(
|
|
"/activity/remote/support",
|
|
{
|
|
"remote_server_id": f"{self.remote_server.id}",
|
|
"required_plan_tier": CustomerPlan.TIER_SELF_HOSTED_BASIC,
|
|
},
|
|
)
|
|
self.assert_in_success_response(
|
|
["Required plan tier for demo.example.com set to Zulip Basic."], result
|
|
)
|
|
|
|
# Configure fixed-price plan with ID of manually sent invoice.
|
|
hamlet = self.example_user("hamlet")
|
|
sent_invoice_id = "test_sent_invoice_id"
|
|
stripe_customer_id = "cus_123"
|
|
annual_fixed_price = 1200
|
|
mock_invoice = MagicMock()
|
|
mock_invoice.status = "open"
|
|
mock_invoice.sent_invoice_id = sent_invoice_id
|
|
with (
|
|
patch(
|
|
"stripe.Customer.retrieve",
|
|
return_value=Mock(id=stripe_customer_id, email=hamlet.delivery_email),
|
|
),
|
|
patch("stripe.Invoice.retrieve", return_value=mock_invoice),
|
|
):
|
|
result = self.client_post(
|
|
"/activity/remote/support",
|
|
{
|
|
"remote_server_id": f"{self.remote_server.id}",
|
|
"fixed_price": annual_fixed_price,
|
|
"sent_invoice_id": sent_invoice_id,
|
|
},
|
|
)
|
|
self.assert_in_success_response(
|
|
["Customer can now buy a fixed price Zulip Basic plan."], result
|
|
)
|
|
fixed_price_plan_offer = CustomerPlanOffer.objects.filter(
|
|
status=CustomerPlanOffer.CONFIGURED
|
|
).first()
|
|
assert fixed_price_plan_offer is not None
|
|
self.assertEqual(fixed_price_plan_offer.tier, CustomerPlanOffer.TIER_SELF_HOSTED_BASIC)
|
|
self.assertEqual(fixed_price_plan_offer.fixed_price, annual_fixed_price * 100)
|
|
self.assertEqual(fixed_price_plan_offer.sent_invoice_id, sent_invoice_id)
|
|
self.assertEqual(fixed_price_plan_offer.get_plan_status_as_text(), "Configured")
|
|
|
|
invoice = Invoice.objects.get(stripe_invoice_id=sent_invoice_id)
|
|
self.assertEqual(invoice.status, Invoice.SENT)
|
|
|
|
self.logout()
|
|
self.login("hamlet")
|
|
|
|
# Customer don't need to visit /upgrade to buy plan.
|
|
# In case they visit, we inform them about the mail to which
|
|
# invoice was sent and also display the link for payment.
|
|
self.execute_remote_billing_authentication_flow(hamlet.delivery_email, hamlet.full_name)
|
|
mock_invoice = MagicMock()
|
|
mock_invoice.hosted_invoice_url = "payments_page_url"
|
|
with (
|
|
time_machine.travel(self.now, tick=False),
|
|
patch(
|
|
"corporate.lib.stripe.customer_has_credit_card_as_default_payment_method",
|
|
return_value=False,
|
|
),
|
|
patch(
|
|
"stripe.Customer.retrieve",
|
|
return_value=Mock(id=stripe_customer_id, email=hamlet.delivery_email),
|
|
),
|
|
patch("stripe.Invoice.retrieve", return_value=mock_invoice),
|
|
):
|
|
result = self.client_get(
|
|
f"{self.billing_session.billing_base_url}/upgrade/?tier={CustomerPlan.TIER_SELF_HOSTED_BASIC}",
|
|
subdomain="selfhosting",
|
|
)
|
|
self.assertEqual(result.status_code, 200)
|
|
self.assert_in_success_response(["payments_page_url", hamlet.delivery_email], result)
|
|
|
|
# When customer makes a payment, 'stripe_webhook' handles 'invoice.paid' event.
|
|
stripe_event_id = "stripe_event_id"
|
|
valid_invoice_paid_event_data = {
|
|
"id": stripe_event_id,
|
|
"type": "invoice.paid",
|
|
"api_version": STRIPE_API_VERSION,
|
|
"data": {
|
|
"object": {
|
|
"object": "invoice",
|
|
"id": sent_invoice_id,
|
|
"collection_method": "send_invoice",
|
|
}
|
|
},
|
|
}
|
|
with time_machine.travel(self.now, tick=False):
|
|
result = self.client_post(
|
|
"/stripe/webhook/",
|
|
valid_invoice_paid_event_data,
|
|
content_type="application/json",
|
|
)
|
|
self.assertEqual(result.status_code, 200)
|
|
|
|
# Verify that the customer is upgraded after payment.
|
|
customer = self.billing_session.get_customer()
|
|
current_plan = CustomerPlan.objects.get(customer=customer, status=CustomerPlan.ACTIVE)
|
|
self.assertEqual(current_plan.fixed_price, annual_fixed_price * 100)
|
|
self.assertIsNone(current_plan.price_per_license)
|
|
|
|
invoice.refresh_from_db()
|
|
fixed_price_plan_offer.refresh_from_db()
|
|
self.assertEqual(invoice.status, Invoice.PAID)
|
|
self.assertEqual(fixed_price_plan_offer.status, CustomerPlanOffer.PROCESSED)
|
|
|
|
# Visit /billing
|
|
self.execute_remote_billing_authentication_flow(
|
|
hamlet.delivery_email, hamlet.full_name, expect_tos=False
|
|
)
|
|
with (
|
|
time_machine.travel(self.now + timedelta(days=1), tick=False),
|
|
patch(
|
|
"corporate.lib.stripe.customer_has_credit_card_as_default_payment_method",
|
|
return_value=False,
|
|
),
|
|
patch(
|
|
"stripe.Customer.retrieve",
|
|
return_value=Mock(id=stripe_customer_id, email=hamlet.delivery_email),
|
|
),
|
|
patch("stripe.Invoice.retrieve", return_value=mock_invoice),
|
|
):
|
|
response = self.client_get(
|
|
f"{self.billing_session.billing_base_url}/billing/", subdomain="selfhosting"
|
|
)
|
|
for substring in [
|
|
"Zulip Basic",
|
|
hamlet.delivery_email,
|
|
"Annual",
|
|
"This is a fixed-price plan",
|
|
"You will be contacted by Zulip Sales",
|
|
]:
|
|
self.assert_in_response(substring, response)
|
|
|
|
@responses.activate
|
|
@mock_stripe()
|
|
def test_schedule_server_upgrade_to_fixed_price_business_plan(self, *mocks: Mock) -> None:
|
|
self.login("hamlet")
|
|
hamlet = self.example_user("hamlet")
|
|
|
|
self.add_mock_response()
|
|
with time_machine.travel(self.now, tick=False):
|
|
send_server_data_to_push_bouncer(consider_usage_statistics=False)
|
|
|
|
# Upgrade to fixed-price Zulip Basic plan.
|
|
self.execute_remote_billing_authentication_flow(hamlet.delivery_email, hamlet.full_name)
|
|
with time_machine.travel(self.now, tick=False):
|
|
stripe_customer = self.add_card_and_upgrade(
|
|
tier=CustomerPlan.TIER_SELF_HOSTED_BASIC,
|
|
)
|
|
|
|
customer = Customer.objects.get(stripe_customer_id=stripe_customer.id)
|
|
assert customer.remote_server is not None
|
|
self.assertEqual(customer.remote_server.plan_type, RemoteZulipServer.PLAN_TYPE_BASIC)
|
|
current_plan = CustomerPlan.objects.get(customer=customer, status=CustomerPlan.ACTIVE)
|
|
self.assertEqual(current_plan.tier, CustomerPlan.TIER_SELF_HOSTED_BASIC)
|
|
self.assertIsNone(current_plan.fixed_price)
|
|
self.assertIsNotNone(current_plan.price_per_license)
|
|
|
|
self.logout()
|
|
self.login("iago")
|
|
|
|
# Schedule a fixed-price business plan at current plan end_date.
|
|
current_plan_end_date = add_months(self.now, 2)
|
|
current_plan.end_date = current_plan_end_date
|
|
current_plan.save(update_fields=["end_date"])
|
|
|
|
self.assertFalse(CustomerPlan.objects.filter(fixed_price__isnull=False).exists())
|
|
|
|
# Configure required_plan_tier and fixed_price.
|
|
annual_fixed_price = 1200
|
|
result = self.client_post(
|
|
"/activity/remote/support",
|
|
{
|
|
"remote_server_id": f"{self.remote_server.id}",
|
|
"required_plan_tier": CustomerPlan.TIER_SELF_HOSTED_BUSINESS,
|
|
},
|
|
)
|
|
self.assert_in_success_response(
|
|
["Required plan tier for demo.example.com set to Zulip Business."], result
|
|
)
|
|
|
|
result = self.client_post(
|
|
"/activity/remote/support",
|
|
{"remote_server_id": f"{self.remote_server.id}", "fixed_price": annual_fixed_price},
|
|
)
|
|
self.assert_in_success_response(
|
|
[
|
|
f"Fixed price Zulip Business plan scheduled to start on {current_plan_end_date.date()}."
|
|
],
|
|
result,
|
|
)
|
|
current_plan.refresh_from_db()
|
|
self.assertEqual(current_plan.status, CustomerPlan.SWITCH_PLAN_TIER_AT_PLAN_END)
|
|
self.assertEqual(current_plan.next_invoice_date, current_plan_end_date)
|
|
new_plan = CustomerPlan.objects.filter(fixed_price__isnull=False).first()
|
|
assert new_plan is not None
|
|
self.assertEqual(new_plan.next_invoice_date, current_plan_end_date)
|
|
self.assertEqual(
|
|
new_plan.invoicing_status, CustomerPlan.INVOICING_STATUS_INITIAL_INVOICE_TO_BE_SENT
|
|
)
|
|
|
|
# Invoice cron runs and switches plan to BUSINESS
|
|
with time_machine.travel(current_plan_end_date, tick=False):
|
|
send_server_data_to_push_bouncer(consider_usage_statistics=False)
|
|
invoice_plans_as_needed()
|
|
|
|
current_plan.refresh_from_db()
|
|
self.assertEqual(current_plan.status, CustomerPlan.ENDED)
|
|
self.assertEqual(current_plan.next_invoice_date, None)
|
|
|
|
new_plan.refresh_from_db()
|
|
self.assertEqual(new_plan.tier, CustomerPlan.TIER_SELF_HOSTED_BUSINESS)
|
|
self.assertIsNotNone(new_plan.fixed_price)
|
|
self.assertIsNone(new_plan.price_per_license)
|
|
|
|
customer.refresh_from_db()
|
|
self.assertEqual(customer.remote_server.plan_type, RemoteZulipServer.PLAN_TYPE_BUSINESS)
|
|
|
|
self.logout()
|
|
self.login("hamlet")
|
|
hamlet = self.example_user("hamlet")
|
|
|
|
# Visit /billing
|
|
self.execute_remote_billing_authentication_flow(
|
|
hamlet.delivery_email, hamlet.full_name, expect_tos=False, confirm_tos=False
|
|
)
|
|
with time_machine.travel(current_plan_end_date + timedelta(days=1), tick=False):
|
|
response = self.client_get(
|
|
f"{self.billing_session.billing_base_url}/billing/", subdomain="selfhosting"
|
|
)
|
|
for substring in [
|
|
"Zulip Business",
|
|
"Annual",
|
|
"March 2, 2013",
|
|
"This is a fixed-price plan",
|
|
"Your plan ends on <strong>March 2, 2013</strong>",
|
|
"You will be contacted by Zulip Sales",
|
|
"Visa ending in 4242",
|
|
"Update card",
|
|
]:
|
|
self.assert_in_response(substring, response)
|
|
|
|
@responses.activate
|
|
@mock_stripe()
|
|
def test_schedule_server_complimentary_access_plan_upgrade_to_fixed_price_plan(
|
|
self, *mocks: Mock
|
|
) -> None:
|
|
self.login("hamlet")
|
|
hamlet = self.example_user("hamlet")
|
|
|
|
# Create complimentary access plan for server.
|
|
end_date = add_months(self.now, months=3)
|
|
self.billing_session.create_complimentary_access_plan(self.now, end_date)
|
|
|
|
self.add_mock_response()
|
|
with time_machine.travel(self.now, tick=False):
|
|
send_server_data_to_push_bouncer(consider_usage_statistics=False)
|
|
|
|
customer = self.billing_session.get_customer()
|
|
assert customer is not None
|
|
complimentary_access_plan = get_current_plan_by_customer(customer)
|
|
assert complimentary_access_plan is not None
|
|
self.assertEqual(complimentary_access_plan.tier, CustomerPlan.TIER_SELF_HOSTED_LEGACY)
|
|
self.assertEqual(complimentary_access_plan.next_invoice_date, end_date)
|
|
|
|
self.logout()
|
|
self.login("iago")
|
|
|
|
# Schedule a fixed-price business plan at current plan end_date.
|
|
self.assertFalse(CustomerPlanOffer.objects.exists())
|
|
|
|
# Configure required_plan_tier and fixed_price.
|
|
annual_fixed_price = 1200
|
|
result = self.client_post(
|
|
"/activity/remote/support",
|
|
{
|
|
"remote_server_id": f"{self.remote_server.id}",
|
|
"required_plan_tier": CustomerPlan.TIER_SELF_HOSTED_BUSINESS,
|
|
},
|
|
)
|
|
self.assert_in_success_response(
|
|
["Required plan tier for demo.example.com set to Zulip Business."], result
|
|
)
|
|
|
|
result = self.client_post(
|
|
"/activity/remote/support",
|
|
{"remote_server_id": f"{self.remote_server.id}", "fixed_price": annual_fixed_price},
|
|
)
|
|
self.assert_in_success_response(
|
|
["Customer can now buy a fixed price Zulip Business plan."],
|
|
result,
|
|
)
|
|
fixed_price_plan_offer = CustomerPlanOffer.objects.get(customer=customer)
|
|
self.assertEqual(fixed_price_plan_offer.status, CustomerPlanOffer.CONFIGURED)
|
|
self.assertEqual(fixed_price_plan_offer.tier, CustomerPlanOffer.TIER_SELF_HOSTED_BUSINESS)
|
|
|
|
self.logout()
|
|
self.login("hamlet")
|
|
self.execute_remote_billing_authentication_flow(hamlet.delivery_email, hamlet.full_name)
|
|
|
|
# Schedule upgrade to business plan
|
|
with time_machine.travel(self.now, tick=False):
|
|
self.add_card_and_upgrade(
|
|
remote_server_plan_start_date="billing_cycle_end_date", talk_to_stripe=False
|
|
)
|
|
|
|
complimentary_access_plan.refresh_from_db()
|
|
self.assertEqual(
|
|
complimentary_access_plan.status, CustomerPlan.SWITCH_PLAN_TIER_AT_PLAN_END
|
|
)
|
|
fixed_price_plan_offer.refresh_from_db()
|
|
self.assertEqual(fixed_price_plan_offer.status, CustomerPlanOffer.PROCESSED)
|
|
|
|
# Invoice cron runs and switches plan to BUSINESS
|
|
with time_machine.travel(end_date, tick=False):
|
|
send_server_data_to_push_bouncer(consider_usage_statistics=False)
|
|
invoice_plans_as_needed()
|
|
|
|
complimentary_access_plan.refresh_from_db()
|
|
current_plan = get_current_plan_by_customer(customer)
|
|
assert current_plan is not None
|
|
self.assertEqual(current_plan.tier, CustomerPlan.TIER_SELF_HOSTED_BUSINESS)
|
|
self.assertIsNotNone(current_plan.fixed_price)
|
|
self.assertIsNone(current_plan.price_per_license)
|
|
self.assertEqual(current_plan.next_invoice_date, add_months(end_date, 1))
|
|
self.assertEqual(complimentary_access_plan.status, CustomerPlan.ENDED)
|
|
self.assertEqual(complimentary_access_plan.next_invoice_date, None)
|
|
|
|
def test_deactivate_registration_with_push_notification_service(self) -> None:
|
|
self.login("hamlet")
|
|
hamlet = self.example_user("hamlet")
|
|
billing_base_url = self.billing_session.billing_base_url
|
|
|
|
# Get server deactivation confirmation link
|
|
with self.settings(EXTERNAL_HOST="zulipdev.com:9991"):
|
|
confirmation_link = generate_confirmation_link_for_server_deactivation(
|
|
self.remote_server, 10
|
|
)
|
|
|
|
# confirmation link takes user to login page
|
|
result = self.client_get(confirmation_link, subdomain="selfhosting")
|
|
self.assertEqual(result.status_code, 200)
|
|
self.assert_in_success_response(["Log in to deactivate registration for"], result)
|
|
|
|
# Login redirects to '/deactivate'
|
|
result = self.client_post(
|
|
confirmation_link,
|
|
{"full_name": hamlet.full_name, "tos_consent": "true"},
|
|
subdomain="selfhosting",
|
|
)
|
|
self.assertEqual(result.status_code, 302)
|
|
self.assertEqual(result["Location"], f"{billing_base_url}/deactivate/")
|
|
|
|
# Deactivate via UI
|
|
result = self.client_get(f"{billing_base_url}/deactivate/", subdomain="selfhosting")
|
|
self.assertEqual(result.status_code, 200)
|
|
self.assert_in_success_response(
|
|
["Deactivate registration for", "Deactivate registration"], result
|
|
)
|
|
|
|
result = self.client_post(
|
|
f"{billing_base_url}/deactivate/", {"confirmed": "true"}, subdomain="selfhosting"
|
|
)
|
|
self.assertEqual(result.status_code, 200)
|
|
self.assert_in_success_response(
|
|
["Registration deactivated for", "Your server's registration has been deactivated."],
|
|
result,
|
|
)
|
|
|
|
# Verify login fails
|
|
payload = {
|
|
"zulip_org_id": self.remote_server.uuid,
|
|
"zulip_org_key": self.remote_server.api_key,
|
|
}
|
|
result = self.client_post("/serverlogin/", payload, subdomain="selfhosting")
|
|
self.assertEqual(result.status_code, 200)
|
|
self.assert_in_success_response(["Your server registration has been deactivated."], result)
|
|
|
|
@responses.activate
|
|
@mock_stripe()
|
|
def test_invoice_initial_remote_server_upgrade(self, *mocks: Mock) -> None:
|
|
self.login("hamlet")
|
|
hamlet = self.example_user("hamlet")
|
|
|
|
server_user_count = UserProfile.objects.filter(is_bot=False, is_active=True).count()
|
|
|
|
self.add_mock_response()
|
|
with time_machine.travel(self.now, tick=False):
|
|
send_server_data_to_push_bouncer(consider_usage_statistics=False)
|
|
|
|
self.execute_remote_billing_authentication_flow(hamlet.delivery_email, hamlet.full_name)
|
|
with time_machine.travel(self.now, tick=False):
|
|
stripe_customer = self.add_card_and_upgrade(
|
|
tier=CustomerPlan.TIER_SELF_HOSTED_BASIC, schedule="monthly"
|
|
)
|
|
|
|
[invoice0] = iter(stripe.Invoice.list(customer=stripe_customer.id))
|
|
|
|
[invoice_item0, invoice_item1] = iter(invoice0.lines)
|
|
invoice_item_params = {
|
|
"amount": -2000,
|
|
"description": "$20.00/month new customer discount",
|
|
"quantity": 1,
|
|
}
|
|
for key, value in invoice_item_params.items():
|
|
self.assertEqual(invoice_item0[key], value)
|
|
|
|
invoice_item_params = {
|
|
"amount": server_user_count * 3.5 * 100,
|
|
"description": "Zulip Basic",
|
|
"quantity": server_user_count,
|
|
}
|
|
for key, value in invoice_item_params.items():
|
|
self.assertEqual(invoice_item1[key], value)
|
|
|
|
self.assertEqual(invoice0.total, server_user_count * 3.5 * 100 - 2000)
|
|
self.assertEqual(invoice0.status, "paid")
|
|
|
|
@responses.activate
|
|
@mock_stripe()
|
|
def test_invoice_plans_as_needed_server(self, *mocks: Mock) -> None:
|
|
self.login("hamlet")
|
|
hamlet = self.example_user("hamlet")
|
|
|
|
server_user_count = UserProfile.objects.filter(is_bot=False, is_active=True).count()
|
|
|
|
self.add_mock_response()
|
|
with time_machine.travel(self.now, tick=False):
|
|
send_server_data_to_push_bouncer(consider_usage_statistics=False)
|
|
|
|
self.execute_remote_billing_authentication_flow(hamlet.delivery_email, hamlet.full_name)
|
|
with time_machine.travel(self.now, tick=False):
|
|
stripe_customer = self.add_card_and_upgrade(
|
|
tier=CustomerPlan.TIER_SELF_HOSTED_BASIC, schedule="monthly"
|
|
)
|
|
|
|
customer = Customer.objects.get(stripe_customer_id=stripe_customer.id)
|
|
plan = CustomerPlan.objects.get(customer=customer)
|
|
assert plan.customer.remote_server is not None
|
|
self.assertEqual(plan.next_invoice_date, self.next_month)
|
|
|
|
with time_machine.travel(self.now + timedelta(days=2), tick=False):
|
|
for count in range(5):
|
|
do_create_user(
|
|
f"email - {count}",
|
|
f"password {count}",
|
|
hamlet.realm,
|
|
"name",
|
|
role=UserProfile.ROLE_MEMBER,
|
|
acting_user=None,
|
|
)
|
|
server_user_count += 5
|
|
|
|
# Data upload was 25 days before the invoice date.
|
|
last_audit_log_upload = self.now + timedelta(days=5)
|
|
with time_machine.travel(last_audit_log_upload, tick=False):
|
|
send_server_data_to_push_bouncer(consider_usage_statistics=False)
|
|
invoice_plans_as_needed(self.next_month)
|
|
plan.refresh_from_db()
|
|
self.assertEqual(plan.next_invoice_date, self.next_month)
|
|
self.assertTrue(plan.invoice_overdue_email_sent)
|
|
|
|
from django.core.mail import outbox
|
|
|
|
messages_count = len(outbox)
|
|
message = outbox[-1]
|
|
self.assert_length(message.to, 1)
|
|
self.assertEqual(message.to[0], "sales@zulip.com")
|
|
self.assertEqual(
|
|
message.subject,
|
|
f"Invoice overdue for {self.billing_session.billing_entity_display_name} due to stale data",
|
|
)
|
|
self.assertIn(
|
|
f"Support URL: {self.billing_session.support_url()}",
|
|
message.body,
|
|
)
|
|
self.assertIn(
|
|
f"Internal billing notice for {self.billing_session.billing_entity_display_name}.",
|
|
message.body,
|
|
)
|
|
self.assertIn("Recent invoice is overdue for payment.", message.body)
|
|
self.assertIn(
|
|
f"Last data upload: {last_audit_log_upload.strftime('%Y-%m-%d')}", message.body
|
|
)
|
|
|
|
# Cron runs again, don't send another email to Zulip team.
|
|
invoice_plans_as_needed(self.next_month + timedelta(days=1))
|
|
self.assert_length(outbox, messages_count)
|
|
|
|
# Ledger is up-to-date
|
|
with time_machine.travel(self.next_month, tick=False):
|
|
send_server_data_to_push_bouncer(consider_usage_statistics=False)
|
|
invoice_plans_as_needed(self.next_month)
|
|
plan.refresh_from_db()
|
|
self.assertEqual(plan.next_invoice_date, add_months(self.next_month, 1))
|
|
self.assertFalse(plan.invoice_overdue_email_sent)
|
|
|
|
assert customer.stripe_customer_id
|
|
[invoice0, invoice1] = iter(stripe.Invoice.list(customer=customer.stripe_customer_id))
|
|
|
|
[invoice_item0, invoice_item1, invoice_item2] = iter(invoice0.lines)
|
|
invoice_item_params = {
|
|
"amount": server_user_count * 3.5 * 100,
|
|
"description": "Zulip Basic - renewal",
|
|
"quantity": server_user_count,
|
|
"period": {
|
|
"start": datetime_to_timestamp(self.next_month),
|
|
"end": datetime_to_timestamp(add_months(self.next_month, 1)),
|
|
},
|
|
}
|
|
for key, value in invoice_item_params.items():
|
|
self.assertEqual(invoice_item1[key], value)
|
|
|
|
invoice_item_params = {
|
|
"description": "Additional license (Jan 4, 2012 - Feb 2, 2012)",
|
|
"quantity": 5,
|
|
"period": {
|
|
"start": datetime_to_timestamp(self.now + timedelta(days=2)),
|
|
"end": datetime_to_timestamp(self.next_month),
|
|
},
|
|
}
|
|
for key, value in invoice_item_params.items():
|
|
self.assertEqual(invoice_item2[key], value)
|
|
|
|
# Verify Zulip team receives mail for the next cycle.
|
|
invoice_plans_as_needed(add_months(self.next_month, 1))
|
|
self.assert_length(outbox, messages_count + 1)
|
|
|
|
@responses.activate
|
|
def test_complimentary_access_plan_ends_on_plan_end_date(self, *mocks: Mock) -> None:
|
|
self.login("hamlet")
|
|
|
|
self.add_mock_response()
|
|
with time_machine.travel(self.now, tick=False):
|
|
send_server_data_to_push_bouncer(consider_usage_statistics=False)
|
|
|
|
plan_end_date = add_months(self.now, 3)
|
|
self.billing_session.create_complimentary_access_plan(self.now, plan_end_date)
|
|
|
|
# Complimentary access plan ends on plan end date.
|
|
customer = self.billing_session.get_customer()
|
|
assert customer is not None
|
|
plan = get_current_plan_by_customer(customer)
|
|
assert plan is not None
|
|
self.assertEqual(plan.end_date, plan_end_date)
|
|
self.assertEqual(plan.next_invoice_date, plan_end_date)
|
|
self.assertEqual(plan.status, CustomerPlan.ACTIVE)
|
|
self.assertEqual(
|
|
self.remote_server.plan_type, RemoteZulipServer.PLAN_TYPE_SELF_MANAGED_LEGACY
|
|
)
|
|
|
|
with (
|
|
mock.patch("stripe.Invoice.create") as invoice_create,
|
|
mock.patch("corporate.lib.stripe.send_email") as send_email,
|
|
time_machine.travel(plan_end_date, tick=False),
|
|
):
|
|
invoice_plans_as_needed()
|
|
# Verify that for complimentary access plan with no next plan scheduled,
|
|
# invoice overdue email is not sent even if the last audit log update was
|
|
# 3 months ago.
|
|
send_email.assert_not_called()
|
|
# The complimentary access plan is downgraded, no invoice created.
|
|
invoice_create.assert_not_called()
|
|
|
|
plan.refresh_from_db()
|
|
self.remote_server.refresh_from_db()
|
|
self.assertEqual(self.remote_server.plan_type, RemoteZulipServer.PLAN_TYPE_SELF_MANAGED)
|
|
self.assertEqual(plan.next_invoice_date, None)
|
|
self.assertEqual(plan.status, CustomerPlan.ENDED)
|
|
|
|
@responses.activate
|
|
@mock_stripe()
|
|
def test_invoice_scheduled_upgrade_server_complimentary_access_plan(self, *mocks: Mock) -> None:
|
|
# Upload data
|
|
self.add_mock_response()
|
|
with time_machine.travel(self.now, tick=False):
|
|
send_server_data_to_push_bouncer(consider_usage_statistics=False)
|
|
|
|
# Create complimentary access plan for server.
|
|
with time_machine.travel(self.now, tick=False):
|
|
start_date = timezone_now()
|
|
end_date = add_months(start_date, months=3)
|
|
self.billing_session.create_complimentary_access_plan(start_date, end_date)
|
|
|
|
customer = self.billing_session.get_customer()
|
|
assert customer is not None
|
|
complimentary_access_plan = get_current_plan_by_customer(customer)
|
|
assert complimentary_access_plan is not None
|
|
self.assertEqual(complimentary_access_plan.tier, CustomerPlan.TIER_SELF_HOSTED_LEGACY)
|
|
self.assertEqual(complimentary_access_plan.status, CustomerPlan.ACTIVE)
|
|
self.assertEqual(complimentary_access_plan.next_invoice_date, end_date)
|
|
|
|
self.login("hamlet")
|
|
hamlet = self.example_user("hamlet")
|
|
|
|
self.execute_remote_billing_authentication_flow(hamlet.delivery_email, hamlet.full_name)
|
|
# Add card and schedule upgrade
|
|
with time_machine.travel(self.now, tick=False):
|
|
stripe_customer = self.add_card_and_upgrade(
|
|
remote_server_plan_start_date="billing_cycle_end_date", talk_to_stripe=False
|
|
)
|
|
complimentary_access_plan.refresh_from_db()
|
|
self.assertEqual(
|
|
complimentary_access_plan.status, CustomerPlan.SWITCH_PLAN_TIER_AT_PLAN_END
|
|
)
|
|
new_plan = self.billing_session.get_next_plan(complimentary_access_plan)
|
|
assert new_plan is not None
|
|
self.assertEqual(new_plan.tier, CustomerPlan.TIER_SELF_HOSTED_BUSINESS)
|
|
self.assertEqual(new_plan.status, CustomerPlan.NEVER_STARTED)
|
|
self.assertEqual(
|
|
new_plan.invoicing_status,
|
|
CustomerPlan.INVOICING_STATUS_INITIAL_INVOICE_TO_BE_SENT,
|
|
)
|
|
self.assertEqual(new_plan.next_invoice_date, end_date)
|
|
self.assertEqual(new_plan.billing_cycle_anchor, end_date)
|
|
|
|
server_user_count = UserProfile.objects.filter(is_bot=False, is_active=True).count()
|
|
min_licenses = self.billing_session.min_licenses_for_plan(
|
|
CustomerPlan.TIER_SELF_HOSTED_BUSINESS
|
|
)
|
|
licenses = max(min_licenses, server_user_count)
|
|
|
|
with (
|
|
mock.patch("stripe.Invoice.finalize_invoice") as invoice_create,
|
|
mock.patch("corporate.lib.stripe.send_email") as send_email,
|
|
time_machine.travel(end_date, tick=False),
|
|
):
|
|
invoice_plans_as_needed()
|
|
# Verify that for complimentary access plan with next plan scheduled,
|
|
# invoice overdue email is sent if the last audit log is stale.
|
|
send_email.assert_called()
|
|
invoice_create.assert_not_called()
|
|
|
|
with time_machine.travel(end_date, tick=False):
|
|
send_server_data_to_push_bouncer(consider_usage_statistics=False)
|
|
invoice_plans_as_needed()
|
|
# 'invoice_plan()' is called with both complimentary access & new plan, but
|
|
# invoice is created only for new plan. The complimentary access plan only
|
|
# goes through the end of cycle updates.
|
|
|
|
complimentary_access_plan.refresh_from_db()
|
|
new_plan.refresh_from_db()
|
|
self.assertEqual(complimentary_access_plan.status, CustomerPlan.ENDED)
|
|
self.assertEqual(complimentary_access_plan.next_invoice_date, None)
|
|
self.assertEqual(new_plan.status, CustomerPlan.ACTIVE)
|
|
self.assertEqual(new_plan.invoicing_status, CustomerPlan.INVOICING_STATUS_DONE)
|
|
self.assertEqual(new_plan.next_invoice_date, add_months(end_date, 1))
|
|
|
|
[invoice0] = iter(stripe.Invoice.list(customer=stripe_customer.id))
|
|
|
|
[invoice_item0, invoice_item1] = iter(invoice0.lines)
|
|
invoice_item_params = {
|
|
"amount": -2000 * 12,
|
|
"description": "$20.00/month new customer discount",
|
|
"quantity": 1,
|
|
}
|
|
for key, value in invoice_item_params.items():
|
|
self.assertEqual(invoice_item0[key], value)
|
|
|
|
invoice_item_params = {
|
|
"amount": licenses * 80 * 100,
|
|
"description": "Zulip Business - renewal",
|
|
"quantity": licenses,
|
|
}
|
|
for key, value in invoice_item_params.items():
|
|
self.assertEqual(invoice_item1[key], value)
|