billing: Add backend support for downgrading.

This commit is contained in:
Rishi Gupta
2019-04-07 20:16:35 -07:00
parent babaaf82fe
commit 1a7a449572
6 changed files with 163 additions and 44 deletions

View File

@@ -82,7 +82,6 @@ def next_month(billing_cycle_anchor: datetime, dt: datetime) -> datetime:
raise AssertionError('Something wrong in next_month calculation with ' raise AssertionError('Something wrong in next_month calculation with '
'billing_cycle_anchor: %s, dt: %s' % (billing_cycle_anchor, dt)) 'billing_cycle_anchor: %s, dt: %s' % (billing_cycle_anchor, dt))
# TODO take downgrade into account
def start_of_next_billing_cycle(plan: CustomerPlan, event_time: datetime) -> datetime: def start_of_next_billing_cycle(plan: CustomerPlan, event_time: datetime) -> datetime:
months_per_period = { months_per_period = {
CustomerPlan.ANNUAL: 12, CustomerPlan.ANNUAL: 12,
@@ -95,8 +94,10 @@ def start_of_next_billing_cycle(plan: CustomerPlan, event_time: datetime) -> dat
periods += 1 periods += 1
return dt return dt
# TODO take downgrade into account def next_invoice_date(plan: CustomerPlan) -> Optional[datetime]:
def next_invoice_date(plan: CustomerPlan) -> datetime: if plan.status == CustomerPlan.ENDED:
return None
assert(plan.next_invoice_date is not None) # for mypy
months_per_period = { months_per_period = {
CustomerPlan.ANNUAL: 12, CustomerPlan.ANNUAL: 12,
CustomerPlan.MONTHLY: 1, CustomerPlan.MONTHLY: 1,
@@ -114,6 +115,8 @@ def renewal_amount(plan: CustomerPlan, event_time: datetime) -> int: # nocovera
if plan.fixed_price is not None: if plan.fixed_price is not None:
return plan.fixed_price return plan.fixed_price
last_ledger_entry = make_end_of_cycle_updates_if_needed(plan, event_time) last_ledger_entry = make_end_of_cycle_updates_if_needed(plan, event_time)
if last_ledger_entry is None:
return 0
if last_ledger_entry.licenses_at_next_renewal is None: if last_ledger_entry.licenses_at_next_renewal is None:
return 0 return 0
assert(plan.price_per_license is not None) # for mypy assert(plan.price_per_license is not None) # for mypy
@@ -215,17 +218,21 @@ def do_replace_payment_source(user: UserProfile, stripe_token: str,
# event_time should roughly be timezone_now(). Not designed to handle # event_time should roughly be timezone_now(). Not designed to handle
# event_times in the past or future # event_times in the past or future
# TODO handle downgrade def make_end_of_cycle_updates_if_needed(plan: CustomerPlan,
def make_end_of_cycle_updates_if_needed(plan: CustomerPlan, event_time: datetime) -> LicenseLedger: event_time: datetime) -> Optional[LicenseLedger]:
last_ledger_entry = LicenseLedger.objects.filter(plan=plan).order_by('-id').first() last_ledger_entry = LicenseLedger.objects.filter(plan=plan).order_by('-id').first()
last_renewal = LicenseLedger.objects.filter(plan=plan, is_renewal=True) \ last_renewal = LicenseLedger.objects.filter(plan=plan, is_renewal=True) \
.order_by('-id').first().event_time .order_by('-id').first().event_time
plan_renewal_date = start_of_next_billing_cycle(plan, last_renewal) next_billing_cycle = start_of_next_billing_cycle(plan, last_renewal)
if plan_renewal_date <= event_time: if next_billing_cycle <= event_time:
return LicenseLedger.objects.create( if plan.status == CustomerPlan.ACTIVE:
plan=plan, is_renewal=True, event_time=plan_renewal_date, return LicenseLedger.objects.create(
licenses=last_ledger_entry.licenses_at_next_renewal, plan=plan, is_renewal=True, event_time=next_billing_cycle,
licenses_at_next_renewal=last_ledger_entry.licenses_at_next_renewal) licenses=last_ledger_entry.licenses_at_next_renewal,
licenses_at_next_renewal=last_ledger_entry.licenses_at_next_renewal)
if plan.status == CustomerPlan.DOWNGRADE_AT_END_OF_CYCLE:
process_downgrade(plan)
return None
return last_ledger_entry return last_ledger_entry
# Returns Customer instead of stripe_customer so that we don't make a Stripe # Returns Customer instead of stripe_customer so that we don't make a Stripe
@@ -362,7 +369,8 @@ def process_initial_upgrade(user: UserProfile, licenses: int, automanage_license
def update_license_ledger_for_automanaged_plan(realm: Realm, plan: CustomerPlan, def update_license_ledger_for_automanaged_plan(realm: Realm, plan: CustomerPlan,
event_time: datetime) -> None: event_time: datetime) -> None:
last_ledger_entry = make_end_of_cycle_updates_if_needed(plan, event_time) last_ledger_entry = make_end_of_cycle_updates_if_needed(plan, event_time)
# todo: handle downgrade, where licenses_at_next_renewal should be 0 if last_ledger_entry is None:
return
licenses_at_next_renewal = get_seat_count(realm) licenses_at_next_renewal = get_seat_count(realm)
licenses = max(licenses_at_next_renewal, last_ledger_entry.licenses) licenses = max(licenses_at_next_renewal, last_ledger_entry.licenses)
LicenseLedger.objects.create( LicenseLedger.objects.create(
@@ -464,8 +472,17 @@ def get_discount_for_realm(realm: Realm) -> Optional[Decimal]:
return customer.default_discount return customer.default_discount
return None return None
def process_downgrade(user: UserProfile) -> None: # nocoverage def do_change_plan_status(plan: CustomerPlan, status: int) -> None:
pass plan.status = status
plan.save(update_fields=['status'])
billing_logger.info('Change plan status: Customer.id: %s, CustomerPlan.id: %s, status: %s' % (
plan.customer.id, plan.id, status))
def process_downgrade(plan: CustomerPlan) -> None:
from zerver.lib.actions import do_change_plan_type
do_change_plan_type(plan.customer.realm, Realm.LIMITED)
plan.status = CustomerPlan.ENDED
plan.save(update_fields=['status'])
def estimate_annual_recurring_revenue_by_realm() -> Dict[str, int]: # nocoverage def estimate_annual_recurring_revenue_by_realm() -> Dict[str, int]: # nocoverage
annual_revenue = {} annual_revenue = {}

View File

@@ -0,0 +1,20 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.11.20 on 2019-04-11 00:45
from __future__ import unicode_literals
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('corporate', '0007_remove_deprecated_fields'),
]
operations = [
migrations.AlterField(
model_name='customerplan',
name='next_invoice_date',
field=models.DateTimeField(db_index=True, null=True),
),
]

View File

@@ -35,7 +35,7 @@ class CustomerPlan(models.Model):
MONTHLY = 2 MONTHLY = 2
billing_schedule = models.SmallIntegerField() # type: int billing_schedule = models.SmallIntegerField() # type: int
next_invoice_date = models.DateTimeField(db_index=True) # type: datetime.datetime next_invoice_date = models.DateTimeField(db_index=True, null=True) # type: Optional[datetime.datetime]
invoiced_through = models.ForeignKey( invoiced_through = models.ForeignKey(
'LicenseLedger', null=True, on_delete=CASCADE, related_name='+') # type: Optional[LicenseLedger] 'LicenseLedger', null=True, on_delete=CASCADE, related_name='+') # type: Optional[LicenseLedger]
DONE = 1 DONE = 1
@@ -69,6 +69,5 @@ class LicenseLedger(models.Model):
event_time = models.DateTimeField() # type: datetime.datetime event_time = models.DateTimeField() # type: datetime.datetime
licenses = models.IntegerField() # type: int licenses = models.IntegerField() # type: int
# None means the plan does not automatically renew. # None means the plan does not automatically renew.
# 0 means the plan has been explicitly downgraded.
# This cannot be None if plan.automanage_licenses. # This cannot be None if plan.automanage_licenses.
licenses_at_next_renewal = models.IntegerField(null=True) # type: Optional[int] licenses_at_next_renewal = models.IntegerField(null=True) # type: Optional[int]

View File

@@ -873,6 +873,88 @@ class StripeTest(StripeTestCase):
self.assertEqual(2, RealmAuditLog.objects.filter( self.assertEqual(2, RealmAuditLog.objects.filter(
event_type=RealmAuditLog.STRIPE_CARD_CHANGED).count()) event_type=RealmAuditLog.STRIPE_CARD_CHANGED).count())
@patch("corporate.lib.stripe.billing_logger.info")
def test_downgrade(self, mock_: Mock) -> None:
user = self.example_user("hamlet")
self.login(user.email)
with patch("corporate.lib.stripe.timezone_now", return_value=self.now):
self.local_upgrade(self.seat_count, True, CustomerPlan.ANNUAL, 'token')
response = self.client_post("/json/billing/plan/change",
{'status': CustomerPlan.DOWNGRADE_AT_END_OF_CYCLE})
self.assert_json_success(response)
# Verify that we still write LicenseLedger rows during the remaining
# part of the cycle
with patch("corporate.lib.stripe.get_seat_count", return_value=20):
update_license_ledger_if_needed(user.realm, self.now)
self.assertEqual(LicenseLedger.objects.order_by('-id').values_list(
'licenses', 'licenses_at_next_renewal').first(), (20, 20))
# Verify that we invoice them for the additional users
from stripe import Invoice
Invoice.create = lambda **args: None # type: ignore # cleaner than mocking
Invoice.finalize_invoice = lambda *args: None # type: ignore # cleaner than mocking
with patch("stripe.InvoiceItem.create") as mocked:
invoice_plans_as_needed(self.next_month)
mocked.assert_called_once()
mocked.reset_mock()
# Check that we downgrade properly if the cycle is over
with patch("corporate.lib.stripe.get_seat_count", return_value=30):
update_license_ledger_if_needed(user.realm, self.next_year)
self.assertEqual(get_realm('zulip').plan_type, Realm.LIMITED)
self.assertEqual(CustomerPlan.objects.first().status, CustomerPlan.ENDED)
self.assertEqual(LicenseLedger.objects.order_by('-id').values_list(
'licenses', 'licenses_at_next_renewal').first(), (20, 20))
# Verify that we don't write LicenseLedger rows once we've downgraded
with patch("corporate.lib.stripe.get_seat_count", return_value=40):
update_license_ledger_if_needed(user.realm, self.next_year)
self.assertEqual(LicenseLedger.objects.order_by('-id').values_list(
'licenses', 'licenses_at_next_renewal').first(), (20, 20))
# Verify that we call invoice_plan once more after cycle end but
# don't invoice them for users added after the cycle end
self.assertIsNotNone(CustomerPlan.objects.first().next_invoice_date)
with patch("stripe.InvoiceItem.create") as mocked:
invoice_plans_as_needed(self.next_year + timedelta(days=32))
mocked.assert_not_called()
mocked.reset_mock()
# Check that we updated next_invoice_date in invoice_plan
self.assertIsNone(CustomerPlan.objects.first().next_invoice_date)
# Check that we don't call invoice_plan after that final call
with patch("corporate.lib.stripe.get_seat_count", return_value=50):
update_license_ledger_if_needed(user.realm, self.next_year + timedelta(days=80))
with patch("corporate.lib.stripe.invoice_plan") as mocked:
invoice_plans_as_needed(self.next_year + timedelta(days=400))
mocked.assert_not_called()
@patch("corporate.lib.stripe.billing_logger.info")
@patch("stripe.Invoice.create")
@patch("stripe.Invoice.finalize_invoice")
@patch("stripe.InvoiceItem.create")
def test_downgrade_during_invoicing(self, *mocks: Mock) -> None:
# The difference between this test and test_downgrade is that
# CustomerPlan.status is DOWNGRADE_AT_END_OF_CYCLE rather than ENDED
# when we call invoice_plans_as_needed
# This test is essentially checking that we call make_end_of_cycle_updates_if_needed
# during the invoicing process.
user = self.example_user("hamlet")
self.login(user.email)
with patch("corporate.lib.stripe.timezone_now", return_value=self.now):
self.local_upgrade(self.seat_count, True, CustomerPlan.ANNUAL, 'token')
self.client_post("/json/billing/plan/change",
{'status': CustomerPlan.DOWNGRADE_AT_END_OF_CYCLE})
plan = CustomerPlan.objects.first()
self.assertIsNotNone(plan.next_invoice_date)
self.assertEqual(plan.status, CustomerPlan.DOWNGRADE_AT_END_OF_CYCLE)
invoice_plans_as_needed(self.next_year)
plan = CustomerPlan.objects.first()
self.assertIsNone(plan.next_invoice_date)
self.assertEqual(plan.status, CustomerPlan.ENDED)
class RequiresBillingAccessTest(ZulipTestCase): class RequiresBillingAccessTest(ZulipTestCase):
def setUp(self) -> None: def setUp(self) -> None:
hamlet = self.example_user("hamlet") hamlet = self.example_user("hamlet")
@@ -888,7 +970,7 @@ class RequiresBillingAccessTest(ZulipTestCase):
def test_non_admins_blocked_from_json_endpoints(self) -> None: def test_non_admins_blocked_from_json_endpoints(self) -> None:
params = [ params = [
("/json/billing/sources/change", {'stripe_token': ujson.dumps('token')}), ("/json/billing/sources/change", {'stripe_token': ujson.dumps('token')}),
("/json/billing/downgrade", {}), ("/json/billing/plan/change", {'status': ujson.dumps(1)}),
] # type: List[Tuple[str, Dict[str, Any]]] ] # type: List[Tuple[str, Dict[str, Any]]]
for (url, data) in params: for (url, data) in params:

View File

@@ -19,8 +19,8 @@ i18n_urlpatterns = [
v1_api_and_json_patterns = [ v1_api_and_json_patterns = [
url(r'^billing/upgrade$', rest_dispatch, url(r'^billing/upgrade$', rest_dispatch,
{'POST': 'corporate.views.upgrade'}), {'POST': 'corporate.views.upgrade'}),
url(r'^billing/downgrade$', rest_dispatch, url(r'^billing/plan/change$', rest_dispatch,
{'POST': 'corporate.views.downgrade'}), {'POST': 'corporate.views.change_plan_at_end_of_cycle'}),
url(r'^billing/sources/change', rest_dispatch, url(r'^billing/sources/change', rest_dispatch,
{'POST': 'corporate.views.replace_payment_source'}), {'POST': 'corporate.views.replace_payment_source'}),
] ]

View File

@@ -19,7 +19,7 @@ from zerver.models import UserProfile
from corporate.lib.stripe import STRIPE_PUBLISHABLE_KEY, \ from corporate.lib.stripe import STRIPE_PUBLISHABLE_KEY, \
stripe_get_customer, get_seat_count, \ stripe_get_customer, get_seat_count, \
process_initial_upgrade, sign_string, \ process_initial_upgrade, sign_string, \
unsign_string, BillingError, process_downgrade, do_replace_payment_source, \ unsign_string, BillingError, do_change_plan_status, do_replace_payment_source, \
MIN_INVOICED_LICENSES, DEFAULT_INVOICE_DAYS_UNTIL_DUE, \ MIN_INVOICED_LICENSES, DEFAULT_INVOICE_DAYS_UNTIL_DUE, \
start_of_next_billing_cycle, renewal_amount, \ start_of_next_billing_cycle, renewal_amount, \
make_end_of_cycle_updates_if_needed make_end_of_cycle_updates_if_needed
@@ -160,6 +160,13 @@ def billing_home(request: HttpRequest) -> HttpResponse:
return render(request, 'corporate/billing.html', context=context) return render(request, 'corporate/billing.html', context=context)
context = {'admin_access': True} context = {'admin_access': True}
plan_name = "Zulip Free"
licenses = 0
renewal_date = ''
renewal_cents = 0
payment_method = ''
charge_automatically = False
stripe_customer = stripe_get_customer(customer.stripe_customer_id) stripe_customer = stripe_get_customer(customer.stripe_customer_id)
plan = get_current_plan(customer) plan = get_current_plan(customer)
if plan is not None: if plan is not None:
@@ -169,25 +176,17 @@ def billing_home(request: HttpRequest) -> HttpResponse:
}[plan.tier] }[plan.tier]
now = timezone_now() now = timezone_now()
last_ledger_entry = make_end_of_cycle_updates_if_needed(plan, now) last_ledger_entry = make_end_of_cycle_updates_if_needed(plan, now)
licenses = last_ledger_entry.licenses if last_ledger_entry is not None:
licenses_used = get_seat_count(user.realm) licenses = last_ledger_entry.licenses
# Should do this in javascript, using the user's timezone licenses_used = get_seat_count(user.realm)
renewal_date = '{dt:%B} {dt.day}, {dt.year}'.format(dt=start_of_next_billing_cycle(plan, now)) # Should do this in javascript, using the user's timezone
renewal_cents = renewal_amount(plan, now) renewal_date = '{dt:%B} {dt.day}, {dt.year}'.format(dt=start_of_next_billing_cycle(plan, now))
charge_automatically = plan.charge_automatically renewal_cents = renewal_amount(plan, now)
if charge_automatically: charge_automatically = plan.charge_automatically
payment_method = payment_method_string(stripe_customer) if charge_automatically:
else: payment_method = payment_method_string(stripe_customer)
payment_method = 'Billed by invoice' else:
# Can only get here by subscribing and then downgrading. We don't support downgrading payment_method = 'Billed by invoice'
# yet, but keeping this code here since we will soon.
else: # nocoverage
plan_name = "Zulip Free"
licenses = 0
renewal_date = ''
renewal_cents = 0
payment_method = ''
charge_automatically = False
context.update({ context.update({
'plan_name': plan_name, 'plan_name': plan_name,
@@ -203,11 +202,13 @@ def billing_home(request: HttpRequest) -> HttpResponse:
return render(request, 'corporate/billing.html', context=context) return render(request, 'corporate/billing.html', context=context)
@require_billing_access @require_billing_access
def downgrade(request: HttpRequest, user: UserProfile) -> HttpResponse: # nocoverage @has_request_variables
try: def change_plan_at_end_of_cycle(request: HttpRequest, user: UserProfile,
process_downgrade(user) status: int=REQ("status", validator=check_int)) -> HttpResponse:
except BillingError as e: assert(status in [CustomerPlan.ACTIVE, CustomerPlan.DOWNGRADE_AT_END_OF_CYCLE])
return json_error(e.message, data={'error_description': e.description}) plan = get_current_plan(Customer.objects.get(realm=user.realm))
assert(plan is not None) # for mypy
do_change_plan_status(plan, status)
return json_success() return json_success()
@require_billing_access @require_billing_access