billing: Support switching from monthly to annual plan.

This commit is contained in:
Vishnu KS 2020-06-15 23:39:24 +05:30 committed by Tim Abbott
parent bbb07aed38
commit cde4486f8c
46 changed files with 325 additions and 18 deletions

View File

@ -125,14 +125,21 @@ def next_invoice_date(plan: CustomerPlan) -> Optional[datetime]:
def renewal_amount(plan: CustomerPlan, event_time: datetime) -> int: # nocoverage: TODO
if plan.fixed_price is not None:
return plan.fixed_price
last_ledger_entry = make_end_of_cycle_updates_if_needed(plan, event_time)
new_plan, 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
if new_plan is not None:
plan = new_plan
assert(plan.price_per_license is not None) # for mypy
return plan.price_per_license * last_ledger_entry.licenses_at_next_renewal
def get_idempotency_key(ledger_entry: LicenseLedger) -> Optional[str]:
if settings.TEST_SUITE:
return None
return f'ledger_entry:{ledger_entry.id}' # nocoverage
class BillingError(Exception):
# error messages
CONTACT_SUPPORT = _("Something went wrong. Please contact {email}.").format(
@ -237,14 +244,14 @@ 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
def make_end_of_cycle_updates_if_needed(plan: CustomerPlan,
event_time: datetime) -> Optional[LicenseLedger]:
event_time: datetime) -> Tuple[Optional[CustomerPlan], 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
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(
return None, 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)
@ -254,14 +261,52 @@ def make_end_of_cycle_updates_if_needed(plan: CustomerPlan,
plan.billing_cycle_anchor = plan.next_invoice_date.replace(microsecond=0)
plan.status = CustomerPlan.ACTIVE
plan.save(update_fields=["invoiced_through", "billing_cycle_anchor", "status"])
return LicenseLedger.objects.create(
return None, 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.SWITCH_TO_ANNUAL_AT_END_OF_CYCLE:
if plan.fixed_price is not None: # nocoverage
raise NotImplementedError("Can't switch fixed priced monthly plan to annual.")
plan.status = CustomerPlan.ENDED
plan.save(update_fields=["status"])
discount = plan.customer.default_discount or plan.discount
_, _, _, price_per_license = compute_plan_parameters(
automanage_licenses=plan.automanage_licenses, billing_schedule=CustomerPlan.ANNUAL,
discount=plan.discount
)
new_plan = CustomerPlan.objects.create(
customer=plan.customer, billing_schedule=CustomerPlan.ANNUAL, automanage_licenses=plan.automanage_licenses,
charge_automatically=plan.charge_automatically, price_per_license=price_per_license,
discount=discount, billing_cycle_anchor=next_billing_cycle,
tier=plan.tier, status=CustomerPlan.ACTIVE, next_invoice_date=next_billing_cycle,
invoiced_through=None, invoicing_status=CustomerPlan.INITIAL_INVOICE_TO_BE_SENT,
)
new_plan_ledger_entry = LicenseLedger.objects.create(
plan=new_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
)
RealmAuditLog.objects.create(
realm=new_plan.customer.realm, event_time=event_time,
event_type=RealmAuditLog.CUSTOMER_SWITCHED_FROM_MONTHLY_TO_ANNUAL_PLAN,
extra_data=ujson.dumps({
"monthly_plan_id": plan.id,
"annual_plan_id": new_plan.id,
})
)
return new_plan, new_plan_ledger_entry
if plan.status == CustomerPlan.DOWNGRADE_AT_END_OF_CYCLE:
process_downgrade(plan)
return None
return last_ledger_entry
return None, None
return None, last_ledger_entry
# Returns Customer instead of stripe_customer so that we don't make a Stripe
# API call if there's nothing to update
@ -410,11 +455,14 @@ 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)
new_plan, last_ledger_entry = make_end_of_cycle_updates_if_needed(plan, event_time)
if last_ledger_entry is None:
return
if new_plan is not None:
plan = new_plan
licenses_at_next_renewal = get_latest_seat_count(realm)
licenses = max(licenses_at_next_renewal, last_ledger_entry.licenses)
LicenseLedger.objects.create(
plan=plan, event_time=event_time, licenses=licenses,
licenses_at_next_renewal=licenses_at_next_renewal)
@ -431,10 +479,17 @@ def invoice_plan(plan: CustomerPlan, event_time: datetime) -> None:
if plan.invoicing_status == CustomerPlan.STARTED:
raise NotImplementedError('Plan with invoicing_status==STARTED needs manual resolution.')
make_end_of_cycle_updates_if_needed(plan, event_time)
assert(plan.invoiced_through is not None)
licenses_base = plan.invoiced_through.licenses
if plan.invoicing_status == CustomerPlan.INITIAL_INVOICE_TO_BE_SENT:
invoiced_through_id = -1
licenses_base = None
else:
assert(plan.invoiced_through is not None)
licenses_base = plan.invoiced_through.licenses
invoiced_through_id = plan.invoiced_through.id
invoice_item_created = False
for ledger_entry in LicenseLedger.objects.filter(plan=plan, id__gt=plan.invoiced_through.id,
for ledger_entry in LicenseLedger.objects.filter(plan=plan, id__gt=invoiced_through_id,
event_time__lte=event_time).order_by('id'):
price_args: Dict[str, int] = {}
if ledger_entry.is_renewal:
@ -445,7 +500,7 @@ def invoice_plan(plan: CustomerPlan, event_time: datetime) -> None:
price_args = {'unit_amount': plan.price_per_license,
'quantity': ledger_entry.licenses}
description = "Zulip Standard - renewal"
elif ledger_entry.licenses != licenses_base:
elif licenses_base is not None and ledger_entry.licenses != licenses_base:
assert(plan.price_per_license)
last_renewal = LicenseLedger.objects.filter(
plan=plan, is_renewal=True, event_time__lte=ledger_entry.event_time) \
@ -461,9 +516,6 @@ def invoice_plan(plan: CustomerPlan, event_time: datetime) -> None:
plan.invoiced_through = ledger_entry
plan.invoicing_status = CustomerPlan.STARTED
plan.save(update_fields=['invoicing_status', 'invoiced_through'])
idempotency_key: Optional[str] = f'ledger_entry:{ledger_entry.id}'
if settings.TEST_SUITE:
idempotency_key = None
stripe.InvoiceItem.create(
currency='usd',
customer=plan.customer.stripe_customer_id,
@ -472,7 +524,7 @@ def invoice_plan(plan: CustomerPlan, event_time: datetime) -> None:
period = {'start': datetime_to_timestamp(ledger_entry.event_time),
'end': datetime_to_timestamp(
start_of_next_billing_cycle(plan, ledger_entry.event_time))},
idempotency_key=idempotency_key,
idempotency_key=get_idempotency_key(ledger_entry),
**price_args)
invoice_item_created = True
plan.invoiced_through = ledger_entry

View File

@ -44,6 +44,7 @@ class CustomerPlan(models.Model):
'LicenseLedger', null=True, on_delete=CASCADE, related_name='+')
DONE = 1
STARTED = 2
INITIAL_INVOICE_TO_BE_SENT = 3
invoicing_status: int = models.SmallIntegerField(default=DONE)
STANDARD = 1
@ -54,6 +55,7 @@ class CustomerPlan(models.Model):
ACTIVE = 1
DOWNGRADE_AT_END_OF_CYCLE = 2
FREE_TRIAL = 3
SWITCH_TO_ANNUAL_AT_END_OF_CYCLE = 4
# "Live" plans should have a value < LIVE_STATUS_THRESHOLD.
# There should be at most one live plan per customer.
LIVE_STATUS_THRESHOLD = 10

View File

@ -1322,6 +1322,245 @@ class StripeTest(StripeTestCase):
invoice_plans_as_needed(self.next_year + timedelta(days=400))
mocked.assert_not_called()
@mock_stripe()
@patch("corporate.lib.stripe.billing_logger.info")
def test_switch_from_monthly_plan_to_annual_plan_for_automatic_license_management(self, *mocks: Mock) -> None:
user = self.example_user("hamlet")
self.login_user(user)
with patch('corporate.lib.stripe.timezone_now', return_value=self.now):
self.upgrade(schedule='monthly')
monthly_plan = get_current_plan_by_realm(user.realm)
assert(monthly_plan is not None)
self.assertEqual(monthly_plan.automanage_licenses, True)
self.assertEqual(monthly_plan.billing_schedule, CustomerPlan.MONTHLY)
response = self.client_post("/json/billing/plan/change",
{'status': CustomerPlan.SWITCH_TO_ANNUAL_AT_END_OF_CYCLE})
self.assert_json_success(response)
monthly_plan.refresh_from_db()
self.assertEqual(monthly_plan.status, CustomerPlan.SWITCH_TO_ANNUAL_AT_END_OF_CYCLE)
with patch('corporate.views.timezone_now', return_value=self.now):
response = self.client_get("/billing/")
self.assert_in_success_response(["be switched from monthly to annual billing on <strong>February 2, 2012"], response)
with patch("corporate.lib.stripe.get_latest_seat_count", return_value=20):
update_license_ledger_if_needed(user.realm, self.now)
self.assertEqual(LicenseLedger.objects.filter(plan=monthly_plan).count(), 2)
self.assertEqual(LicenseLedger.objects.order_by('-id').values_list(
'licenses', 'licenses_at_next_renewal').first(), (20, 20))
with patch('corporate.lib.stripe.timezone_now', return_value=self.next_month):
with patch("corporate.lib.stripe.get_latest_seat_count", return_value=25):
update_license_ledger_if_needed(user.realm, self.next_month)
self.assertEqual(LicenseLedger.objects.filter(plan=monthly_plan).count(), 2)
customer = get_customer_by_realm(user.realm)
assert(customer is not None)
self.assertEqual(CustomerPlan.objects.filter(customer=customer).count(), 2)
monthly_plan.refresh_from_db()
self.assertEqual(monthly_plan.status, CustomerPlan.ENDED)
self.assertEqual(monthly_plan.next_invoice_date, self.next_month)
annual_plan = get_current_plan_by_realm(user.realm)
assert(annual_plan is not None)
self.assertEqual(annual_plan.status, CustomerPlan.ACTIVE)
self.assertEqual(annual_plan.billing_schedule, CustomerPlan.ANNUAL)
self.assertEqual(annual_plan.invoicing_status, CustomerPlan.INITIAL_INVOICE_TO_BE_SENT)
self.assertEqual(annual_plan.billing_cycle_anchor, self.next_month)
self.assertEqual(annual_plan.next_invoice_date, self.next_month)
self.assertEqual(annual_plan.invoiced_through, None)
annual_ledger_entries = LicenseLedger.objects.filter(plan=annual_plan).order_by('id')
self.assertEqual(len(annual_ledger_entries), 2)
self.assertEqual(annual_ledger_entries[0].is_renewal, True)
self.assertEqual(annual_ledger_entries.values_list('licenses', 'licenses_at_next_renewal')[0], (20, 20))
self.assertEqual(annual_ledger_entries[1].is_renewal, False)
self.assertEqual(annual_ledger_entries.values_list('licenses', 'licenses_at_next_renewal')[1], (25, 25))
invoice_plans_as_needed(self.next_month)
annual_ledger_entries = LicenseLedger.objects.filter(plan=annual_plan).order_by('id')
self.assertEqual(len(annual_ledger_entries), 2)
annual_plan.refresh_from_db()
self.assertEqual(annual_plan.invoicing_status, CustomerPlan.DONE)
self.assertEqual(annual_plan.invoiced_through, annual_ledger_entries[1])
self.assertEqual(annual_plan.billing_cycle_anchor, self.next_month)
self.assertEqual(annual_plan.next_invoice_date, add_months(self.next_month, 1))
monthly_plan.refresh_from_db()
self.assertEqual(monthly_plan.next_invoice_date, None)
invoices = [invoice for invoice in stripe.Invoice.list(customer=customer.stripe_customer_id)]
self.assertEqual(len(invoices), 3)
annual_plan_invoice_items = [invoice_item for invoice_item in invoices[0].get("lines")]
self.assertEqual(len(annual_plan_invoice_items), 2)
annual_plan_invoice_item_params = {
"amount": 5 * 80 * 100,
"description": "Additional license (Feb 2, 2012 - Feb 2, 2013)",
"plan": None, "quantity": 5, "subscription": None, "discountable": False,
"period": {
"start": datetime_to_timestamp(self.next_month),
"end": datetime_to_timestamp(add_months(self.next_month, 12))
},
}
for key, value in annual_plan_invoice_item_params.items():
self.assertEqual(annual_plan_invoice_items[0][key], value)
annual_plan_invoice_item_params = {
"amount": 20 * 80 * 100, "description": "Zulip Standard - renewal",
"plan": None, "quantity": 20, "subscription": None, "discountable": False,
"period": {
"start": datetime_to_timestamp(self.next_month),
"end": datetime_to_timestamp(add_months(self.next_month, 12))
},
}
for key, value in annual_plan_invoice_item_params.items():
self.assertEqual(annual_plan_invoice_items[1][key], value)
monthly_plan_invoice_items = [invoice_item for invoice_item in invoices[1].get("lines")]
self.assertEqual(len(monthly_plan_invoice_items), 1)
monthly_plan_invoice_item_params = {
"amount": 14 * 8 * 100,
"description": "Additional license (Jan 2, 2012 - Feb 2, 2012)",
"plan": None, "quantity": 14, "subscription": None, "discountable": False,
"period": {
"start": datetime_to_timestamp(self.now),
"end": datetime_to_timestamp(self.next_month)
},
}
for key, value in monthly_plan_invoice_item_params.items():
self.assertEqual(monthly_plan_invoice_items[0][key], value)
with patch("corporate.lib.stripe.get_latest_seat_count", return_value=30):
update_license_ledger_if_needed(user.realm, add_months(self.next_month, 1))
invoice_plans_as_needed(add_months(self.next_month, 1))
invoices = [invoice for invoice in stripe.Invoice.list(customer=customer.stripe_customer_id)]
self.assertEqual(len(invoices), 4)
monthly_plan_invoice_items = [invoice_item for invoice_item in invoices[0].get("lines")]
self.assertEqual(len(monthly_plan_invoice_items), 1)
monthly_plan_invoice_item_params = {
"amount": 5 * 7366,
"description": "Additional license (Mar 2, 2012 - Feb 2, 2013)",
"plan": None, "quantity": 5, "subscription": None, "discountable": False,
"period": {
"start": datetime_to_timestamp(add_months(self.next_month, 1)),
"end": datetime_to_timestamp(add_months(self.next_month, 12))
},
}
for key, value in monthly_plan_invoice_item_params.items():
self.assertEqual(monthly_plan_invoice_items[0][key], value)
invoice_plans_as_needed(add_months(self.now, 13))
invoices = [invoice for invoice in stripe.Invoice.list(customer=customer.stripe_customer_id)]
self.assertEqual(len(invoices), 5)
annual_plan_invoice_items = [invoice_item for invoice_item in invoices[0].get("lines")]
self.assertEqual(len(annual_plan_invoice_items), 1)
annual_plan_invoice_item_params = {
"amount": 30 * 80 * 100,
"description": "Zulip Standard - renewal",
"plan": None, "quantity": 30, "subscription": None, "discountable": False,
"period": {
"start": datetime_to_timestamp(add_months(self.next_month, 12)),
"end": datetime_to_timestamp(add_months(self.next_month, 24))
},
}
for key, value in annual_plan_invoice_item_params.items():
self.assertEqual(annual_plan_invoice_items[0][key], value)
@mock_stripe()
@patch("corporate.lib.stripe.billing_logger.info")
def test_switch_from_monthly_plan_to_annual_plan_for_manual_license_management(self, *mocks: Mock) -> None:
user = self.example_user("hamlet")
num_licenses = 35
self.login_user(user)
with patch('corporate.lib.stripe.timezone_now', return_value=self.now):
self.upgrade(schedule='monthly', license_management='manual', licenses=num_licenses)
monthly_plan = get_current_plan_by_realm(user.realm)
assert(monthly_plan is not None)
self.assertEqual(monthly_plan.automanage_licenses, False)
self.assertEqual(monthly_plan.billing_schedule, CustomerPlan.MONTHLY)
response = self.client_post("/json/billing/plan/change",
{'status': CustomerPlan.SWITCH_TO_ANNUAL_AT_END_OF_CYCLE})
self.assert_json_success(response)
monthly_plan.refresh_from_db()
self.assertEqual(monthly_plan.status, CustomerPlan.SWITCH_TO_ANNUAL_AT_END_OF_CYCLE)
with patch('corporate.views.timezone_now', return_value=self.now):
response = self.client_get("/billing/")
self.assert_in_success_response(["be switched from monthly to annual billing on <strong>February 2, 2012"], response)
with patch('corporate.lib.stripe.timezone_now', return_value=self.next_month):
invoice_plans_as_needed(self.next_month)
self.assertEqual(LicenseLedger.objects.filter(plan=monthly_plan).count(), 1)
customer = get_customer_by_realm(user.realm)
assert(customer is not None)
self.assertEqual(CustomerPlan.objects.filter(customer=customer).count(), 2)
monthly_plan.refresh_from_db()
self.assertEqual(monthly_plan.status, CustomerPlan.ENDED)
self.assertEqual(monthly_plan.next_invoice_date, None)
annual_plan = get_current_plan_by_realm(user.realm)
assert(annual_plan is not None)
self.assertEqual(annual_plan.status, CustomerPlan.ACTIVE)
self.assertEqual(annual_plan.billing_schedule, CustomerPlan.ANNUAL)
self.assertEqual(annual_plan.invoicing_status, CustomerPlan.INITIAL_INVOICE_TO_BE_SENT)
self.assertEqual(annual_plan.billing_cycle_anchor, self.next_month)
self.assertEqual(annual_plan.next_invoice_date, self.next_month)
annual_ledger_entries = LicenseLedger.objects.filter(plan=annual_plan).order_by('id')
self.assertEqual(len(annual_ledger_entries), 1)
self.assertEqual(annual_ledger_entries[0].is_renewal, True)
self.assertEqual(annual_ledger_entries.values_list('licenses', 'licenses_at_next_renewal')[0], (num_licenses, num_licenses))
self.assertEqual(annual_plan.invoiced_through, None)
with patch('corporate.lib.stripe.timezone_now', return_value=self.next_month):
invoice_plans_as_needed(self.next_month + timedelta(days=1))
annual_plan.refresh_from_db()
self.assertEqual(annual_plan.invoiced_through, annual_ledger_entries[0])
self.assertEqual(annual_plan.next_invoice_date, add_months(self.next_month, 12))
self.assertEqual(annual_plan.invoicing_status, CustomerPlan.DONE)
invoices = [invoice for invoice in stripe.Invoice.list(customer=customer.stripe_customer_id)]
self.assertEqual(len(invoices), 2)
annual_plan_invoice_items = [invoice_item for invoice_item in invoices[0].get("lines")]
self.assertEqual(len(annual_plan_invoice_items), 1)
annual_plan_invoice_item_params = {
"amount": num_licenses * 80 * 100, "description": "Zulip Standard - renewal",
"plan": None, "quantity": num_licenses, "subscription": None, "discountable": False,
"period": {
"start": datetime_to_timestamp(self.next_month),
"end": datetime_to_timestamp(add_months(self.next_month, 12))
},
}
for key, value in annual_plan_invoice_item_params.items():
self.assertEqual(annual_plan_invoice_items[0][key], value)
with patch('corporate.lib.stripe.invoice_plan') as m:
invoice_plans_as_needed(add_months(self.now, 2))
m.assert_not_called()
invoice_plans_as_needed(add_months(self.now, 13))
invoices = [invoice for invoice in stripe.Invoice.list(customer=customer.stripe_customer_id)]
self.assertEqual(len(invoices), 3)
annual_plan_invoice_items = [invoice_item for invoice_item in invoices[0].get("lines")]
self.assertEqual(len(annual_plan_invoice_items), 1)
annual_plan_invoice_item_params = {
"amount": num_licenses * 80 * 100,
"description": "Zulip Standard - renewal",
"plan": None, "quantity": num_licenses, "subscription": None, "discountable": False,
"period": {
"start": datetime_to_timestamp(add_months(self.next_month, 12)),
"end": datetime_to_timestamp(add_months(self.next_month, 24))
},
}
for key, value in annual_plan_invoice_item_params.items():
self.assertEqual(annual_plan_invoice_items[0][key], value)
@patch("corporate.lib.stripe.billing_logger.info")
def test_reupgrade_after_plan_status_changed_to_downgrade_at_end_of_cycle(self, mock_: Mock) -> None:
user = self.example_user("hamlet")
@ -1719,7 +1958,8 @@ class LicenseLedgerTest(StripeTestCase):
self.assertEqual(LicenseLedger.objects.count(), 1)
# Plan needs to renew
# TODO: do_deactivate_user for a user, so that licenses_at_next_renewal != licenses
ledger_entry = make_end_of_cycle_updates_if_needed(plan, self.next_year)
new_plan, ledger_entry = make_end_of_cycle_updates_if_needed(plan, self.next_year)
self.assertIsNone(new_plan)
self.assertEqual(LicenseLedger.objects.count(), 2)
ledger_params = {
'plan': plan, 'is_renewal': True, 'event_time': self.next_year,

View File

@ -200,14 +200,17 @@ def billing_home(request: HttpRequest) -> HttpResponse:
plan = get_current_plan_by_customer(customer)
if plan is not None:
now = timezone_now()
last_ledger_entry = make_end_of_cycle_updates_if_needed(plan, now)
new_plan, last_ledger_entry = make_end_of_cycle_updates_if_needed(plan, now)
if last_ledger_entry is not None:
if new_plan is not None: # nocoverage
plan = new_plan
plan_name = {
CustomerPlan.STANDARD: 'Zulip Standard',
CustomerPlan.PLUS: 'Zulip Plus',
}[plan.tier]
free_trial = plan.status == CustomerPlan.FREE_TRIAL
downgrade_at_end_of_cycle = plan.status == CustomerPlan.DOWNGRADE_AT_END_OF_CYCLE
switch_to_annual_at_end_of_cycle = plan.status == CustomerPlan.SWITCH_TO_ANNUAL_AT_END_OF_CYCLE
licenses = last_ledger_entry.licenses
licenses_used = get_latest_seat_count(user.realm)
# Should do this in javascript, using the user's timezone
@ -226,6 +229,7 @@ def billing_home(request: HttpRequest) -> HttpResponse:
'free_trial': free_trial,
'downgrade_at_end_of_cycle': downgrade_at_end_of_cycle,
'automanage_licenses': plan.automanage_licenses,
'switch_to_annual_at_end_of_cycle': switch_to_annual_at_end_of_cycle,
'licenses': licenses,
'licenses_used': licenses_used,
'renewal_date': renewal_date,
@ -244,7 +248,8 @@ def billing_home(request: HttpRequest) -> HttpResponse:
@has_request_variables
def change_plan_status(request: HttpRequest, user: UserProfile,
status: int=REQ("status", validator=check_int)) -> HttpResponse:
assert(status in [CustomerPlan.ACTIVE, CustomerPlan.DOWNGRADE_AT_END_OF_CYCLE, CustomerPlan.ENDED])
assert(status in [CustomerPlan.ACTIVE, CustomerPlan.DOWNGRADE_AT_END_OF_CYCLE,
CustomerPlan.SWITCH_TO_ANNUAL_AT_END_OF_CYCLE, CustomerPlan.ENDED])
plan = get_current_plan_by_realm(user.realm)
assert(plan is not None) # for mypy
@ -255,6 +260,11 @@ def change_plan_status(request: HttpRequest, user: UserProfile,
elif status == CustomerPlan.DOWNGRADE_AT_END_OF_CYCLE:
assert(plan.status == CustomerPlan.ACTIVE)
do_change_plan_status(plan, status)
elif status == CustomerPlan.SWITCH_TO_ANNUAL_AT_END_OF_CYCLE:
assert(plan.billing_schedule == CustomerPlan.MONTHLY)
assert(plan.status == CustomerPlan.ACTIVE)
assert(plan.fixed_price is None)
do_change_plan_status(plan, status)
elif status == CustomerPlan.ENDED:
assert(plan.status == CustomerPlan.FREE_TRIAL)
downgrade_now(user.realm)

View File

@ -51,6 +51,8 @@
<strong>${{ renewal_amount }}</strong>.
{% elif downgrade_at_end_of_cycle %}
Your plan will be downgraded to <strong>Zulip Limited</strong> on <strong>{{ renewal_date }}</strong>.
{% elif switch_to_annual_at_end_of_cycle %}
Your plan will be switched from monthly to annual billing on <strong>{{ renewal_date }}</strong>.
{% else %}
Your plan will renew on <strong>{{ renewal_date }}</strong> for
<strong>${{ renewal_amount }}</strong>.

View File

@ -2674,6 +2674,7 @@ class AbstractRealmAuditLog(models.Model):
CUSTOMER_CREATED = 501
CUSTOMER_PLAN_CREATED = 502
CUSTOMER_SWITCHED_FROM_MONTHLY_TO_ANNUAL_PLAN = 503
event_type: int = models.PositiveSmallIntegerField()