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 '
'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:
months_per_period = {
CustomerPlan.ANNUAL: 12,
@ -95,8 +94,10 @@ def start_of_next_billing_cycle(plan: CustomerPlan, event_time: datetime) -> dat
periods += 1
return dt
# TODO take downgrade into account
def next_invoice_date(plan: CustomerPlan) -> datetime:
def next_invoice_date(plan: CustomerPlan) -> Optional[datetime]:
if plan.status == CustomerPlan.ENDED:
return None
assert(plan.next_invoice_date is not None) # for mypy
months_per_period = {
CustomerPlan.ANNUAL: 12,
CustomerPlan.MONTHLY: 1,
@ -114,6 +115,8 @@ def renewal_amount(plan: CustomerPlan, event_time: datetime) -> int: # nocovera
if plan.fixed_price is not None:
return plan.fixed_price
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:
return 0
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_times in the past or future
# TODO handle downgrade
def make_end_of_cycle_updates_if_needed(plan: CustomerPlan, event_time: datetime) -> LicenseLedger:
def make_end_of_cycle_updates_if_needed(plan: CustomerPlan,
event_time: datetime) -> Optional[LicenseLedger]:
last_ledger_entry = LicenseLedger.objects.filter(plan=plan).order_by('-id').first()
last_renewal = LicenseLedger.objects.filter(plan=plan, is_renewal=True) \
.order_by('-id').first().event_time
plan_renewal_date = start_of_next_billing_cycle(plan, last_renewal)
if plan_renewal_date <= event_time:
next_billing_cycle = start_of_next_billing_cycle(plan, last_renewal)
if next_billing_cycle <= event_time:
if plan.status == CustomerPlan.ACTIVE:
return LicenseLedger.objects.create(
plan=plan, is_renewal=True, event_time=plan_renewal_date,
plan=plan, is_renewal=True, event_time=next_billing_cycle,
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
# 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,
event_time: datetime) -> None:
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 = max(licenses_at_next_renewal, last_ledger_entry.licenses)
LicenseLedger.objects.create(
@ -464,8 +472,17 @@ def get_discount_for_realm(realm: Realm) -> Optional[Decimal]:
return customer.default_discount
return None
def process_downgrade(user: UserProfile) -> None: # nocoverage
pass
def do_change_plan_status(plan: CustomerPlan, status: int) -> None:
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
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
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(
'LicenseLedger', null=True, on_delete=CASCADE, related_name='+') # type: Optional[LicenseLedger]
DONE = 1
@ -69,6 +69,5 @@ class LicenseLedger(models.Model):
event_time = models.DateTimeField() # type: datetime.datetime
licenses = models.IntegerField() # type: int
# None means the plan does not automatically renew.
# 0 means the plan has been explicitly downgraded.
# This cannot be None if plan.automanage_licenses.
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(
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):
def setUp(self) -> None:
hamlet = self.example_user("hamlet")
@ -888,7 +970,7 @@ class RequiresBillingAccessTest(ZulipTestCase):
def test_non_admins_blocked_from_json_endpoints(self) -> None:
params = [
("/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]]]
for (url, data) in params:

View File

@ -19,8 +19,8 @@ i18n_urlpatterns = [
v1_api_and_json_patterns = [
url(r'^billing/upgrade$', rest_dispatch,
{'POST': 'corporate.views.upgrade'}),
url(r'^billing/downgrade$', rest_dispatch,
{'POST': 'corporate.views.downgrade'}),
url(r'^billing/plan/change$', rest_dispatch,
{'POST': 'corporate.views.change_plan_at_end_of_cycle'}),
url(r'^billing/sources/change', rest_dispatch,
{'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, \
stripe_get_customer, get_seat_count, \
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, \
start_of_next_billing_cycle, renewal_amount, \
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)
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)
plan = get_current_plan(customer)
if plan is not None:
@ -169,6 +176,7 @@ def billing_home(request: HttpRequest) -> HttpResponse:
}[plan.tier]
now = timezone_now()
last_ledger_entry = make_end_of_cycle_updates_if_needed(plan, now)
if last_ledger_entry is not None:
licenses = last_ledger_entry.licenses
licenses_used = get_seat_count(user.realm)
# Should do this in javascript, using the user's timezone
@ -179,15 +187,6 @@ def billing_home(request: HttpRequest) -> HttpResponse:
payment_method = payment_method_string(stripe_customer)
else:
payment_method = 'Billed by invoice'
# Can only get here by subscribing and then downgrading. We don't support downgrading
# 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({
'plan_name': plan_name,
@ -203,11 +202,13 @@ def billing_home(request: HttpRequest) -> HttpResponse:
return render(request, 'corporate/billing.html', context=context)
@require_billing_access
def downgrade(request: HttpRequest, user: UserProfile) -> HttpResponse: # nocoverage
try:
process_downgrade(user)
except BillingError as e:
return json_error(e.message, data={'error_description': e.description})
@has_request_variables
def change_plan_at_end_of_cycle(request: HttpRequest, user: UserProfile,
status: int=REQ("status", validator=check_int)) -> HttpResponse:
assert(status in [CustomerPlan.ACTIVE, CustomerPlan.DOWNGRADE_AT_END_OF_CYCLE])
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()
@require_billing_access