diff --git a/stubs/stripe/__init__.pyi b/stubs/stripe/__init__.pyi index b12a7fe9e4..7f9021911c 100644 --- a/stubs/stripe/__init__.pyi +++ b/stubs/stripe/__init__.pyi @@ -2,7 +2,7 @@ import stripe.error as error import stripe.util as util from stripe.api_resources.list_object import SubscriptionListObject -from typing import Optional, Any, Dict, List +from typing import Optional, Any, Dict, List, Union api_key: Optional[str] @@ -13,6 +13,7 @@ class Customer: source: str subscriptions: SubscriptionListObject coupon: str + account_balance: int @staticmethod def retrieve(customer_id: str, expand: Optional[List[str]]) -> Customer: @@ -29,9 +30,11 @@ class Customer: class Invoice: amount_due: int + total: int @staticmethod - def upcoming(customer: str) -> Invoice: + def upcoming(customer: str=..., subscription: str=..., + subscription_items: List[Dict[str, Union[str, int]]]=...) -> Invoice: ... class Subscription: diff --git a/zerver/lib/actions.py b/zerver/lib/actions.py index 93e3b4fec5..f0ec783d17 100644 --- a/zerver/lib/actions.py +++ b/zerver/lib/actions.py @@ -3029,7 +3029,9 @@ def do_change_plan_type(user: UserProfile, plan_type: int) -> None: if plan_type == Realm.PREMIUM: realm.max_invites = Realm.MAX_INVITES_PREMIUM - realm.save(update_fields=['_max_invites']) + elif plan_type == Realm.LIMITED: + realm.max_invites = settings.INVITES_DEFAULT_REALM_DAILY_MAX + realm.save(update_fields=['_max_invites']) def do_change_default_sending_stream(user_profile: UserProfile, stream: Optional[Stream], log: bool=True) -> None: diff --git a/zilencer/lib/stripe.py b/zilencer/lib/stripe.py index 4c1864768e..20b62aa445 100644 --- a/zilencer/lib/stripe.py +++ b/zilencer/lib/stripe.py @@ -126,6 +126,29 @@ def stripe_get_upcoming_invoice(stripe_customer_id: str) -> stripe.Invoice: print(''.join(['"upcoming_invoice": ', str(stripe_invoice), ','])) # nocoverage return stripe_invoice +@catch_stripe_errors +def stripe_get_invoice_preview_for_downgrade( + stripe_customer_id: str, stripe_subscription_id: str, + stripe_subscriptionitem_id: str) -> stripe.Invoice: + return stripe.Invoice.upcoming( + customer=stripe_customer_id, subscription=stripe_subscription_id, + subscription_items=[{'id': stripe_subscriptionitem_id, 'quantity': 0}]) + +def preview_invoice_total_for_downgrade(stripe_customer: stripe.Customer) -> int: + stripe_subscription = extract_current_subscription(stripe_customer) + if stripe_subscription is None: + # Most likely situation is: user A goes to billing page, user B + # cancels subscription, user A clicks on "downgrade" or something + # else that calls this function. + billing_logger.error("Trying to extract subscription item that doesn't exist, for Stripe customer %s" + % (stripe_customer.id,)) + raise BillingError('downgrade without subscription', BillingError.TRY_RELOADING) + for item in stripe_subscription['items']: + # There should only be one item, but we can't index into stripe_subscription['items'] + stripe_subscriptionitem_id = item.id + return stripe_get_invoice_preview_for_downgrade( + stripe_customer.id, stripe_subscription.id, stripe_subscriptionitem_id).total + # Return type should be Optional[stripe.Subscription], which throws a mypy error. # Will fix once we add type stubs for the Stripe API. def extract_current_subscription(stripe_customer: stripe.Customer) -> Any: @@ -264,6 +287,22 @@ def attach_discount_to_realm(user: UserProfile, percent_off: int) -> None: else: do_replace_coupon(user, coupon) +@catch_stripe_errors +def process_downgrade(user: UserProfile) -> None: + stripe_customer = stripe_get_customer( + Customer.objects.filter(realm=user.realm).first().stripe_customer_id) + subscription_balance = preview_invoice_total_for_downgrade(stripe_customer) + # If subscription_balance > 0, they owe us money. This is likely due to + # people they added in the last day, so we can just forgive it. + # Stripe automatically forgives it when we delete the subscription, so nothing we need to do there. + if subscription_balance < 0: + stripe_customer.account_balance = stripe_customer.account_balance + subscription_balance + stripe_subscription = extract_current_subscription(stripe_customer) + # Wish these two could be transaction.atomic + stripe_subscription.delete() + stripe_customer.save() + do_change_plan_type(user, Realm.LIMITED) + ## Process RealmAuditLog def do_set_subscription_quantity( diff --git a/zilencer/tests/test_stripe.py b/zilencer/tests/test_stripe.py index 74fef4063a..2afa59a804 100644 --- a/zilencer/tests/test_stripe.py +++ b/zilencer/tests/test_stripe.py @@ -1,7 +1,7 @@ import datetime import mock import os -from typing import Any, Optional +from typing import Any, Callable, Dict, List, Optional import ujson import re @@ -47,9 +47,25 @@ def mock_customer_with_cancel_at_period_end_subscription(*args: Any, **kwargs: A customer.subscriptions.data[0].cancel_at_period_end = True return customer +def mock_customer_with_account_balance(account_balance: int) -> Callable[[str, List[str]], stripe.Customer]: + def customer_with_account_balance(stripe_customer_id: str, expand: List[str]) -> stripe.Customer: + stripe_customer = mock_customer_with_subscription() + stripe_customer.account_balance = account_balance + return stripe_customer + return customer_with_account_balance + def mock_upcoming_invoice(*args: Any, **kwargs: Any) -> stripe.Invoice: return stripe.util.convert_to_stripe_object(fixture_data["upcoming_invoice"]) +def mock_invoice_preview_for_downgrade(total: int=-1000) -> Callable[[str, str, Dict[str, Any]], stripe.Invoice]: + def invoice_preview(customer: str, subscription: str, + subscription_items: Dict[str, Any]) -> stripe.Invoice: + # TODO: Get a better fixture; this is not at all what these look like + stripe_invoice = stripe.util.convert_to_stripe_object(fixture_data["upcoming_invoice"]) + stripe_invoice.total = total + return stripe_invoice + return invoice_preview + # A Kandra is a fictional character that can become anything. Used as a # wildcard when testing for equality. class Kandra(object): @@ -382,6 +398,82 @@ class StripeTest(ZulipTestCase): attach_discount_to_realm(user, 25) mock_create_customer.assert_not_called() + @mock.patch("stripe.Subscription.delete") + @mock.patch("stripe.Customer.save") + @mock.patch("stripe.Invoice.upcoming", side_effect=mock_invoice_preview_for_downgrade()) + @mock.patch("stripe.Customer.retrieve", side_effect=mock_customer_with_subscription) + def test_downgrade(self, mock_retrieve_customer: mock.Mock, mock_upcoming_invoice: mock.Mock, + mock_save_customer: mock.Mock, mock_delete_subscription: mock.Mock) -> None: + realm = get_realm('zulip') + realm.plan_type = Realm.PREMIUM + realm.save(update_fields=['plan_type']) + Customer.objects.create( + realm=realm, stripe_customer_id=self.stripe_customer_id, has_billing_relationship=True) + self.login(self.example_email('iago')) + response = self.client_post("/json/billing/downgrade", {}) + self.assert_json_success(response) + + mock_delete_subscription.assert_called() + mock_save_customer.assert_called() + realm = get_realm('zulip') + self.assertEqual(realm.plan_type, Realm.LIMITED) + + @mock.patch("stripe.Customer.save") + @mock.patch("stripe.Customer.retrieve", side_effect=mock_create_customer) + def test_downgrade_with_no_subscription( + self, mock_retrieve_customer: mock.Mock, mock_save_customer: mock.Mock) -> None: + realm = get_realm('zulip') + Customer.objects.create( + realm=realm, stripe_customer_id=self.stripe_customer_id, has_billing_relationship=True) + self.login(self.example_email('iago')) + response = self.client_post("/json/billing/downgrade", {}) + self.assert_json_error_contains(response, 'Please reload') + self.assertEqual(ujson.loads(response.content)['error_description'], 'downgrade without subscription') + mock_save_customer.assert_not_called() + + def test_downgrade_permissions(self) -> None: + self.login(self.example_email('hamlet')) + response = self.client_post("/json/billing/downgrade", {}) + self.assert_json_error_contains(response, "Access denied") + # billing admin but not realm admin + user = self.example_user('hamlet') + user.is_billing_admin = True + user.save(update_fields=['is_billing_admin']) + with mock.patch('zilencer.views.process_downgrade') as mocked1: + self.client_post("/json/billing/downgrade", {}) + mocked1.assert_called() + # realm admin but not billing admin + user = self.example_user('hamlet') + user.is_billing_admin = False + user.is_realm_admin = True + user.save(update_fields=['is_billing_admin', 'is_realm_admin']) + with mock.patch('zilencer.views.process_downgrade') as mocked2: + self.client_post("/json/billing/downgrade", {}) + mocked2.assert_called() + + @mock.patch("stripe.Subscription.delete") + @mock.patch("stripe.Customer.retrieve", side_effect=mock_customer_with_account_balance(1234)) + def test_downgrade_credits(self, mock_retrieve_customer: mock.Mock, + mock_delete_subscription: mock.Mock) -> None: + user = self.example_user('iago') + self.login(user.email) + Customer.objects.create( + realm=user.realm, stripe_customer_id=self.stripe_customer_id, has_billing_relationship=True) + # Check that positive balance is forgiven + with mock.patch("stripe.Invoice.upcoming", side_effect=mock_invoice_preview_for_downgrade(1000)): + with mock.patch.object( + stripe.Customer, 'save', autospec=True, + side_effect=lambda customer: self.assertEqual(customer.account_balance, 1234)): + response = self.client_post("/json/billing/downgrade", {}) + self.assert_json_success(response) + # Check that negative balance is credited + with mock.patch("stripe.Invoice.upcoming", side_effect=mock_invoice_preview_for_downgrade(-1000)): + with mock.patch.object( + stripe.Customer, 'save', autospec=True, + side_effect=lambda customer: self.assertEqual(customer.account_balance, 234)): + response = self.client_post("/json/billing/downgrade", {}) + self.assert_json_success(response) + @mock.patch("stripe.Customer.create", side_effect=mock_create_customer) @mock.patch("stripe.Subscription.create", side_effect=mock_create_subscription) @mock.patch("stripe.Customer.retrieve", side_effect=mock_customer_with_subscription) diff --git a/zilencer/urls.py b/zilencer/urls.py index 7e460a351d..8fa1321bd1 100644 --- a/zilencer/urls.py +++ b/zilencer/urls.py @@ -21,6 +21,9 @@ v1_api_and_json_patterns = [ # Push signup doesn't use the REST API, since there's no auth. url('^remotes/server/register$', zilencer.views.register_remote_server), + + url(r'^billing/downgrade$', rest_dispatch, + {'POST': 'zilencer.views.downgrade'}), ] # Make a copy of i18n_urlpatterns so that they appear without prefix for English diff --git a/zilencer/views.py b/zilencer/views.py index 86076ecdcd..5fe423fa69 100644 --- a/zilencer/views.py +++ b/zilencer/views.py @@ -28,7 +28,7 @@ from zerver.views.push_notifications import validate_token from zilencer.lib.stripe import STRIPE_PUBLISHABLE_KEY, \ stripe_get_customer, stripe_get_upcoming_invoice, get_seat_count, \ extract_current_subscription, process_initial_upgrade, sign_string, \ - unsign_string, BillingError + unsign_string, BillingError, process_downgrade from zilencer.models import RemotePushDeviceToken, RemoteZulipServer, \ Customer, Plan @@ -282,3 +282,12 @@ def billing_home(request: HttpRequest) -> HttpResponse: }) return render(request, 'zilencer/billing.html', context=context) + +def downgrade(request: HttpRequest, user: UserProfile) -> HttpResponse: + if not user.is_realm_admin and not user.is_billing_admin: + return json_error(_('Access denied')) + try: + process_downgrade(user) + except BillingError as e: + return json_error(e.message, data={'error_description': e.description}) + return json_success()