mirror of https://github.com/zulip/zulip.git
billing: Add backend support for downgrading.
This commit is contained in:
parent
babaaf82fe
commit
1a7a449572
|
@ -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:
|
||||
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
|
||||
|
@ -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 = {}
|
||||
|
|
|
@ -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),
|
||||
),
|
||||
]
|
|
@ -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]
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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'}),
|
||||
]
|
||||
|
|
|
@ -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,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,
|
||||
|
@ -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
|
||||
|
|
Loading…
Reference in New Issue