Skip to content

Commit

Permalink
billing: Add backend support for downgrading.
Browse files Browse the repository at this point in the history
  • Loading branch information
rishig committed Apr 12, 2019
1 parent babaaf8 commit 1a7a449
Show file tree
Hide file tree
Showing 6 changed files with 163 additions and 44 deletions.
45 changes: 31 additions & 14 deletions corporate/lib/stripe.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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,
Expand All @@ -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
Expand Down Expand Up @@ -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:
return LicenseLedger.objects.create(
plan=plan, is_renewal=True, event_time=plan_renewal_date,
licenses=last_ledger_entry.licenses_at_next_renewal,
licenses_at_next_renewal=last_ledger_entry.licenses_at_next_renewal)
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=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
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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 = {}
Expand Down
20 changes: 20 additions & 0 deletions corporate/migrations/0008_nullable_next_invoice_date.py
Original file line number Diff line number Diff line change
@@ -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),
),
]
3 changes: 1 addition & 2 deletions corporate/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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]
84 changes: 83 additions & 1 deletion corporate/tests/test_stripe.py
Original file line number Diff line number Diff line change
Expand Up @@ -873,6 +873,88 @@ def test_replace_payment_source(self, *mocks: Mock) -> None:
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")
Expand All @@ -888,7 +970,7 @@ def verify_non_admins_blocked_from_endpoint(
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:
Expand Down
4 changes: 2 additions & 2 deletions corporate/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,8 @@
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'}),
]
Expand Down
51 changes: 26 additions & 25 deletions corporate/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@
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
Expand Down Expand Up @@ -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:
Expand All @@ -169,25 +176,17 @@ def billing_home(request: HttpRequest) -> HttpResponse:
}[plan.tier]
now = timezone_now()
last_ledger_entry = make_end_of_cycle_updates_if_needed(plan, now)
licenses = last_ledger_entry.licenses
licenses_used = get_seat_count(user.realm)
# Should do this in javascript, using the user's timezone
renewal_date = '{dt:%B} {dt.day}, {dt.year}'.format(dt=start_of_next_billing_cycle(plan, now))
renewal_cents = renewal_amount(plan, now)
charge_automatically = plan.charge_automatically
if charge_automatically:
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
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
renewal_date = '{dt:%B} {dt.day}, {dt.year}'.format(dt=start_of_next_billing_cycle(plan, now))
renewal_cents = renewal_amount(plan, now)
charge_automatically = plan.charge_automatically
if charge_automatically:
payment_method = payment_method_string(stripe_customer)
else:
payment_method = 'Billed by invoice'

context.update({
'plan_name': plan_name,
Expand All @@ -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
Expand Down

0 comments on commit 1a7a449

Please sign in to comment.