mirror of https://github.com/zulip/zulip.git
billing: Add backend for downgrading.
This commit is contained in:
parent
b7c326a161
commit
31ed4492ce
|
@ -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()
|
||||
|
|
Loading…
Reference in New Issue