billing: Add backend for downgrading.

This commit is contained in:
Rishi Gupta
2018-08-31 11:09:36 -07:00
parent b7c326a161
commit 31ed4492ce
6 changed files with 153 additions and 5 deletions

View File

@@ -2,7 +2,7 @@ import stripe.error as error
import stripe.util as util import stripe.util as util
from stripe.api_resources.list_object import SubscriptionListObject 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] api_key: Optional[str]
@@ -13,6 +13,7 @@ class Customer:
source: str source: str
subscriptions: SubscriptionListObject subscriptions: SubscriptionListObject
coupon: str coupon: str
account_balance: int
@staticmethod @staticmethod
def retrieve(customer_id: str, expand: Optional[List[str]]) -> Customer: def retrieve(customer_id: str, expand: Optional[List[str]]) -> Customer:
@@ -29,9 +30,11 @@ class Customer:
class Invoice: class Invoice:
amount_due: int amount_due: int
total: int
@staticmethod @staticmethod
def upcoming(customer: str) -> Invoice: def upcoming(customer: str=..., subscription: str=...,
subscription_items: List[Dict[str, Union[str, int]]]=...) -> Invoice:
... ...
class Subscription: class Subscription:

View File

@@ -3029,7 +3029,9 @@ def do_change_plan_type(user: UserProfile, plan_type: int) -> None:
if plan_type == Realm.PREMIUM: if plan_type == Realm.PREMIUM:
realm.max_invites = Realm.MAX_INVITES_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], def do_change_default_sending_stream(user_profile: UserProfile, stream: Optional[Stream],
log: bool=True) -> None: log: bool=True) -> None:

View File

@@ -126,6 +126,29 @@ def stripe_get_upcoming_invoice(stripe_customer_id: str) -> stripe.Invoice:
print(''.join(['"upcoming_invoice": ', str(stripe_invoice), ','])) # nocoverage print(''.join(['"upcoming_invoice": ', str(stripe_invoice), ','])) # nocoverage
return stripe_invoice 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. # Return type should be Optional[stripe.Subscription], which throws a mypy error.
# Will fix once we add type stubs for the Stripe API. # Will fix once we add type stubs for the Stripe API.
def extract_current_subscription(stripe_customer: stripe.Customer) -> Any: 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: else:
do_replace_coupon(user, coupon) 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 ## Process RealmAuditLog
def do_set_subscription_quantity( def do_set_subscription_quantity(

View File

@@ -1,7 +1,7 @@
import datetime import datetime
import mock import mock
import os import os
from typing import Any, Optional from typing import Any, Callable, Dict, List, Optional
import ujson import ujson
import re 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 customer.subscriptions.data[0].cancel_at_period_end = True
return customer 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: def mock_upcoming_invoice(*args: Any, **kwargs: Any) -> stripe.Invoice:
return stripe.util.convert_to_stripe_object(fixture_data["upcoming_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 # A Kandra is a fictional character that can become anything. Used as a
# wildcard when testing for equality. # wildcard when testing for equality.
class Kandra(object): class Kandra(object):
@@ -382,6 +398,82 @@ class StripeTest(ZulipTestCase):
attach_discount_to_realm(user, 25) attach_discount_to_realm(user, 25)
mock_create_customer.assert_not_called() 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.Customer.create", side_effect=mock_create_customer)
@mock.patch("stripe.Subscription.create", side_effect=mock_create_subscription) @mock.patch("stripe.Subscription.create", side_effect=mock_create_subscription)
@mock.patch("stripe.Customer.retrieve", side_effect=mock_customer_with_subscription) @mock.patch("stripe.Customer.retrieve", side_effect=mock_customer_with_subscription)

View File

@@ -21,6 +21,9 @@ v1_api_and_json_patterns = [
# Push signup doesn't use the REST API, since there's no auth. # Push signup doesn't use the REST API, since there's no auth.
url('^remotes/server/register$', zilencer.views.register_remote_server), 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 # Make a copy of i18n_urlpatterns so that they appear without prefix for English

View File

@@ -28,7 +28,7 @@ from zerver.views.push_notifications import validate_token
from zilencer.lib.stripe import STRIPE_PUBLISHABLE_KEY, \ from zilencer.lib.stripe import STRIPE_PUBLISHABLE_KEY, \
stripe_get_customer, stripe_get_upcoming_invoice, get_seat_count, \ stripe_get_customer, stripe_get_upcoming_invoice, get_seat_count, \
extract_current_subscription, process_initial_upgrade, sign_string, \ extract_current_subscription, process_initial_upgrade, sign_string, \
unsign_string, BillingError unsign_string, BillingError, process_downgrade
from zilencer.models import RemotePushDeviceToken, RemoteZulipServer, \ from zilencer.models import RemotePushDeviceToken, RemoteZulipServer, \
Customer, Plan Customer, Plan
@@ -282,3 +282,12 @@ def billing_home(request: HttpRequest) -> HttpResponse:
}) })
return render(request, 'zilencer/billing.html', context=context) 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()