mirror of
https://github.com/zulip/zulip.git
synced 2025-11-04 14:03:30 +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
|
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:
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
Reference in New Issue
Block a user