mirror of https://github.com/zulip/zulip.git
billing: Support switching from monthly to annual plan.
This commit is contained in:
parent
bbb07aed38
commit
cde4486f8c
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
@ -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,
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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>.
|
||||
|
|
|
@ -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()
|
||||
|
||||
|
|
Loading…
Reference in New Issue