mirror of
				https://github.com/zulip/zulip.git
				synced 2025-11-04 05:53:43 +00:00 
			
		
		
		
	billing: Add backend for downgrading.
This commit is contained in:
		@@ -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:
 | 
			
		||||
 
 | 
			
		||||
@@ -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:
 | 
			
		||||
 
 | 
			
		||||
@@ -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(
 | 
			
		||||
 
 | 
			
		||||
@@ -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)
 | 
			
		||||
 
 | 
			
		||||
@@ -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
 | 
			
		||||
 
 | 
			
		||||
@@ -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()
 | 
			
		||||
 
 | 
			
		||||
		Reference in New Issue
	
	Block a user