mirror of https://github.com/zulip/zulip.git
billing: Add command for switching plans from Standard to Plus.
This commit is contained in:
parent
87c1b9e3bc
commit
fcab2ea5f7
|
@ -376,12 +376,11 @@ def make_end_of_cycle_updates_if_needed(
|
||||||
assert last_ledger_renewal is not None
|
assert last_ledger_renewal is not None
|
||||||
last_renewal = last_ledger_renewal.event_time
|
last_renewal = last_ledger_renewal.event_time
|
||||||
|
|
||||||
if plan.is_free_trial():
|
if plan.is_free_trial() or plan.status == CustomerPlan.SWITCH_NOW_FROM_STANDARD_TO_PLUS:
|
||||||
assert plan.next_invoice_date is not None
|
assert plan.next_invoice_date is not None
|
||||||
next_billing_cycle = plan.next_invoice_date
|
next_billing_cycle = plan.next_invoice_date
|
||||||
else:
|
else:
|
||||||
next_billing_cycle = start_of_next_billing_cycle(plan, last_renewal)
|
next_billing_cycle = start_of_next_billing_cycle(plan, last_renewal)
|
||||||
|
|
||||||
if next_billing_cycle <= event_time and last_ledger_entry is not None:
|
if next_billing_cycle <= event_time and last_ledger_entry is not None:
|
||||||
licenses_at_next_renewal = last_ledger_entry.licenses_at_next_renewal
|
licenses_at_next_renewal = last_ledger_entry.licenses_at_next_renewal
|
||||||
assert licenses_at_next_renewal is not None
|
assert licenses_at_next_renewal is not None
|
||||||
|
@ -457,6 +456,47 @@ def make_end_of_cycle_updates_if_needed(
|
||||||
)
|
)
|
||||||
return new_plan, new_plan_ledger_entry
|
return new_plan, new_plan_ledger_entry
|
||||||
|
|
||||||
|
if plan.status == CustomerPlan.SWITCH_NOW_FROM_STANDARD_TO_PLUS:
|
||||||
|
standard_plan = plan
|
||||||
|
standard_plan.end_date = next_billing_cycle
|
||||||
|
standard_plan.status = CustomerPlan.ENDED
|
||||||
|
standard_plan.save(update_fields=["status", "end_date"])
|
||||||
|
|
||||||
|
(_, _, _, plus_plan_price_per_license) = compute_plan_parameters(
|
||||||
|
CustomerPlan.PLUS,
|
||||||
|
standard_plan.automanage_licenses,
|
||||||
|
standard_plan.billing_schedule,
|
||||||
|
standard_plan.customer.default_discount,
|
||||||
|
)
|
||||||
|
plus_plan_billing_cycle_anchor = standard_plan.end_date.replace(microsecond=0)
|
||||||
|
|
||||||
|
plus_plan = CustomerPlan.objects.create(
|
||||||
|
customer=standard_plan.customer,
|
||||||
|
status=CustomerPlan.ACTIVE,
|
||||||
|
automanage_licenses=standard_plan.automanage_licenses,
|
||||||
|
charge_automatically=standard_plan.charge_automatically,
|
||||||
|
price_per_license=plus_plan_price_per_license,
|
||||||
|
discount=standard_plan.customer.default_discount,
|
||||||
|
billing_schedule=standard_plan.billing_schedule,
|
||||||
|
tier=CustomerPlan.PLUS,
|
||||||
|
billing_cycle_anchor=plus_plan_billing_cycle_anchor,
|
||||||
|
invoicing_status=CustomerPlan.INITIAL_INVOICE_TO_BE_SENT,
|
||||||
|
next_invoice_date=plus_plan_billing_cycle_anchor,
|
||||||
|
)
|
||||||
|
|
||||||
|
standard_plan_last_ledger = (
|
||||||
|
LicenseLedger.objects.filter(plan=standard_plan).order_by("id").last()
|
||||||
|
)
|
||||||
|
licenses_for_plus_plan = standard_plan_last_ledger.licenses_at_next_renewal
|
||||||
|
plus_plan_ledger_entry = LicenseLedger.objects.create(
|
||||||
|
plan=plus_plan,
|
||||||
|
is_renewal=True,
|
||||||
|
event_time=plus_plan_billing_cycle_anchor,
|
||||||
|
licenses=licenses_for_plus_plan,
|
||||||
|
licenses_at_next_renewal=licenses_for_plus_plan,
|
||||||
|
)
|
||||||
|
return plus_plan, plus_plan_ledger_entry
|
||||||
|
|
||||||
if plan.status == CustomerPlan.DOWNGRADE_AT_END_OF_CYCLE:
|
if plan.status == CustomerPlan.DOWNGRADE_AT_END_OF_CYCLE:
|
||||||
process_downgrade(plan)
|
process_downgrade(plan)
|
||||||
return None, None
|
return None, None
|
||||||
|
@ -743,6 +783,14 @@ def update_license_ledger_if_needed(realm: Realm, event_time: datetime) -> None:
|
||||||
update_license_ledger_for_automanaged_plan(realm, plan, event_time)
|
update_license_ledger_for_automanaged_plan(realm, plan, event_time)
|
||||||
|
|
||||||
|
|
||||||
|
def get_plan_renewal_or_end_date(plan: CustomerPlan, event_time: datetime) -> datetime:
|
||||||
|
billing_period_end = start_of_next_billing_cycle(plan, event_time)
|
||||||
|
|
||||||
|
if plan.end_date is not None and plan.end_date < billing_period_end:
|
||||||
|
return plan.end_date
|
||||||
|
return billing_period_end
|
||||||
|
|
||||||
|
|
||||||
def invoice_plan(plan: CustomerPlan, event_time: datetime) -> None:
|
def invoice_plan(plan: CustomerPlan, event_time: datetime) -> None:
|
||||||
if plan.invoicing_status == CustomerPlan.STARTED:
|
if plan.invoicing_status == CustomerPlan.STARTED:
|
||||||
raise NotImplementedError("Plan with invoicing_status==STARTED needs manual resolution.")
|
raise NotImplementedError("Plan with invoicing_status==STARTED needs manual resolution.")
|
||||||
|
@ -777,7 +825,7 @@ def invoice_plan(plan: CustomerPlan, event_time: datetime) -> None:
|
||||||
}
|
}
|
||||||
description = f"{plan.name} - renewal"
|
description = f"{plan.name} - renewal"
|
||||||
elif licenses_base is not None and ledger_entry.licenses != licenses_base:
|
elif licenses_base is not None and ledger_entry.licenses != licenses_base:
|
||||||
assert plan.price_per_license and ledger_entry is not None
|
assert plan.price_per_license
|
||||||
last_ledger_entry_renewal = (
|
last_ledger_entry_renewal = (
|
||||||
LicenseLedger.objects.filter(
|
LicenseLedger.objects.filter(
|
||||||
plan=plan, is_renewal=True, event_time__lte=ledger_entry.event_time
|
plan=plan, is_renewal=True, event_time__lte=ledger_entry.event_time
|
||||||
|
@ -787,16 +835,18 @@ def invoice_plan(plan: CustomerPlan, event_time: datetime) -> None:
|
||||||
)
|
)
|
||||||
assert last_ledger_entry_renewal is not None
|
assert last_ledger_entry_renewal is not None
|
||||||
last_renewal = last_ledger_entry_renewal.event_time
|
last_renewal = last_ledger_entry_renewal.event_time
|
||||||
period_end = start_of_next_billing_cycle(plan, ledger_entry.event_time)
|
billing_period_end = start_of_next_billing_cycle(plan, ledger_entry.event_time)
|
||||||
proration_fraction = (period_end - ledger_entry.event_time) / (
|
plan_renewal_or_end_date = get_plan_renewal_or_end_date(plan, ledger_entry.event_time)
|
||||||
period_end - last_renewal
|
proration_fraction = (plan_renewal_or_end_date - ledger_entry.event_time) / (
|
||||||
|
billing_period_end - last_renewal
|
||||||
)
|
)
|
||||||
price_args = {
|
price_args = {
|
||||||
"unit_amount": int(plan.price_per_license * proration_fraction + 0.5),
|
"unit_amount": int(plan.price_per_license * proration_fraction + 0.5),
|
||||||
"quantity": ledger_entry.licenses - licenses_base,
|
"quantity": ledger_entry.licenses - licenses_base,
|
||||||
}
|
}
|
||||||
description = "Additional license ({} - {})".format(
|
description = "Additional license ({} - {})".format(
|
||||||
ledger_entry.event_time.strftime("%b %-d, %Y"), period_end.strftime("%b %-d, %Y")
|
ledger_entry.event_time.strftime("%b %-d, %Y"),
|
||||||
|
plan_renewal_or_end_date.strftime("%b %-d, %Y"),
|
||||||
)
|
)
|
||||||
|
|
||||||
if price_args:
|
if price_args:
|
||||||
|
@ -811,7 +861,7 @@ def invoice_plan(plan: CustomerPlan, event_time: datetime) -> None:
|
||||||
period={
|
period={
|
||||||
"start": datetime_to_timestamp(ledger_entry.event_time),
|
"start": datetime_to_timestamp(ledger_entry.event_time),
|
||||||
"end": datetime_to_timestamp(
|
"end": datetime_to_timestamp(
|
||||||
start_of_next_billing_cycle(plan, ledger_entry.event_time)
|
get_plan_renewal_or_end_date(plan, ledger_entry.event_time)
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
idempotency_key=get_idempotency_key(ledger_entry),
|
idempotency_key=get_idempotency_key(ledger_entry),
|
||||||
|
@ -1071,6 +1121,52 @@ def downgrade_small_realms_behind_on_payments_as_needed() -> None:
|
||||||
void_all_open_invoices(realm)
|
void_all_open_invoices(realm)
|
||||||
|
|
||||||
|
|
||||||
|
def switch_realm_from_standard_to_plus_plan(realm: Realm) -> None:
|
||||||
|
standard_plan = get_current_plan_by_realm(realm)
|
||||||
|
|
||||||
|
if (
|
||||||
|
not standard_plan
|
||||||
|
or standard_plan.status != CustomerPlan.ACTIVE
|
||||||
|
or standard_plan.tier != CustomerPlan.STANDARD
|
||||||
|
):
|
||||||
|
raise BillingError("Organization does not have an active Standard plan")
|
||||||
|
|
||||||
|
if not standard_plan.customer.stripe_customer_id:
|
||||||
|
raise BillingError("Organization missing Stripe customer.")
|
||||||
|
|
||||||
|
plan_switch_time = timezone_now()
|
||||||
|
|
||||||
|
standard_plan.status = CustomerPlan.SWITCH_NOW_FROM_STANDARD_TO_PLUS
|
||||||
|
standard_plan.next_invoice_date = plan_switch_time
|
||||||
|
standard_plan.save(update_fields=["status", "next_invoice_date"])
|
||||||
|
|
||||||
|
standard_plan_next_renewal_date = start_of_next_billing_cycle(standard_plan, plan_switch_time)
|
||||||
|
|
||||||
|
standard_plan_last_renewal_ledger = (
|
||||||
|
LicenseLedger.objects.filter(is_renewal=True, plan=standard_plan).order_by("id").last()
|
||||||
|
)
|
||||||
|
standard_plan_last_renewal_amount = (
|
||||||
|
standard_plan_last_renewal_ledger.licenses * standard_plan.price_per_license
|
||||||
|
)
|
||||||
|
standard_plan_last_renewal_date = standard_plan_last_renewal_ledger.event_time
|
||||||
|
unused_proration_fraction = 1 - (plan_switch_time - standard_plan_last_renewal_date) / (
|
||||||
|
standard_plan_next_renewal_date - standard_plan_last_renewal_date
|
||||||
|
)
|
||||||
|
amount_to_credit_back_to_realm = math.ceil(
|
||||||
|
standard_plan_last_renewal_amount * unused_proration_fraction
|
||||||
|
)
|
||||||
|
stripe.Customer.create_balance_transaction(
|
||||||
|
standard_plan.customer.stripe_customer_id,
|
||||||
|
amount=-1 * amount_to_credit_back_to_realm,
|
||||||
|
currency="usd",
|
||||||
|
description="Credit from early termination of Standard plan",
|
||||||
|
)
|
||||||
|
invoice_plan(standard_plan, plan_switch_time)
|
||||||
|
plus_plan = get_current_plan_by_realm(realm)
|
||||||
|
assert plus_plan is not None # for mypy
|
||||||
|
invoice_plan(plus_plan, plan_switch_time)
|
||||||
|
|
||||||
|
|
||||||
def update_billing_method_of_current_plan(
|
def update_billing_method_of_current_plan(
|
||||||
realm: Realm, charge_automatically: bool, *, acting_user: Optional[UserProfile]
|
realm: Realm, charge_automatically: bool, *, acting_user: Optional[UserProfile]
|
||||||
) -> None:
|
) -> None:
|
||||||
|
|
|
@ -0,0 +1,18 @@
|
||||||
|
# Generated by Django 3.2.6 on 2021-09-17 10:52
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("corporate", "0013_alter_zulipsponsorshiprequest_org_website"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="customerplan",
|
||||||
|
name="end_date",
|
||||||
|
field=models.DateTimeField(null=True),
|
||||||
|
),
|
||||||
|
]
|
|
@ -85,6 +85,7 @@ class CustomerPlan(models.Model):
|
||||||
invoiced_through: Optional["LicenseLedger"] = models.ForeignKey(
|
invoiced_through: Optional["LicenseLedger"] = models.ForeignKey(
|
||||||
"LicenseLedger", null=True, on_delete=CASCADE, related_name="+"
|
"LicenseLedger", null=True, on_delete=CASCADE, related_name="+"
|
||||||
)
|
)
|
||||||
|
end_date: Optional[datetime.datetime] = models.DateTimeField(null=True)
|
||||||
|
|
||||||
DONE = 1
|
DONE = 1
|
||||||
STARTED = 2
|
STARTED = 2
|
||||||
|
@ -103,6 +104,7 @@ class CustomerPlan(models.Model):
|
||||||
DOWNGRADE_AT_END_OF_CYCLE = 2
|
DOWNGRADE_AT_END_OF_CYCLE = 2
|
||||||
FREE_TRIAL = 3
|
FREE_TRIAL = 3
|
||||||
SWITCH_TO_ANNUAL_AT_END_OF_CYCLE = 4
|
SWITCH_TO_ANNUAL_AT_END_OF_CYCLE = 4
|
||||||
|
SWITCH_NOW_FROM_STANDARD_TO_PLUS = 5
|
||||||
# "Live" plans should have a value < LIVE_STATUS_THRESHOLD.
|
# "Live" plans should have a value < LIVE_STATUS_THRESHOLD.
|
||||||
# There should be at most one live plan per customer.
|
# There should be at most one live plan per customer.
|
||||||
LIVE_STATUS_THRESHOLD = 10
|
LIVE_STATUS_THRESHOLD = 10
|
||||||
|
|
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
@ -50,6 +50,7 @@ from corporate.lib.stripe import (
|
||||||
downgrade_small_realms_behind_on_payments_as_needed,
|
downgrade_small_realms_behind_on_payments_as_needed,
|
||||||
get_discount_for_realm,
|
get_discount_for_realm,
|
||||||
get_latest_seat_count,
|
get_latest_seat_count,
|
||||||
|
get_plan_renewal_or_end_date,
|
||||||
get_price_per_license,
|
get_price_per_license,
|
||||||
get_realms_to_default_discount_dict,
|
get_realms_to_default_discount_dict,
|
||||||
invoice_plan,
|
invoice_plan,
|
||||||
|
@ -62,6 +63,7 @@ from corporate.lib.stripe import (
|
||||||
sign_string,
|
sign_string,
|
||||||
stripe_customer_has_credit_card_as_default_source,
|
stripe_customer_has_credit_card_as_default_source,
|
||||||
stripe_get_customer,
|
stripe_get_customer,
|
||||||
|
switch_realm_from_standard_to_plus_plan,
|
||||||
unsign_string,
|
unsign_string,
|
||||||
update_billing_method_of_current_plan,
|
update_billing_method_of_current_plan,
|
||||||
update_license_ledger_for_automanaged_plan,
|
update_license_ledger_for_automanaged_plan,
|
||||||
|
@ -297,6 +299,7 @@ MOCKED_STRIPE_FUNCTION_NAMES = [
|
||||||
"Charge.list",
|
"Charge.list",
|
||||||
"Coupon.create",
|
"Coupon.create",
|
||||||
"Customer.create",
|
"Customer.create",
|
||||||
|
"Customer.create_balance_transaction",
|
||||||
"Customer.retrieve",
|
"Customer.retrieve",
|
||||||
"Customer.save",
|
"Customer.save",
|
||||||
"Invoice.create",
|
"Invoice.create",
|
||||||
|
@ -2977,6 +2980,44 @@ class StripeTest(StripeTestCase):
|
||||||
email_found = True
|
email_found = True
|
||||||
self.assertEqual(row.email_expected_to_be_sent, email_found)
|
self.assertEqual(row.email_expected_to_be_sent, email_found)
|
||||||
|
|
||||||
|
@mock_stripe()
|
||||||
|
def test_switch_realm_from_standard_to_plus_plan(self, *mock: Mock) -> None:
|
||||||
|
realm = get_realm("zulip")
|
||||||
|
|
||||||
|
# Test upgrading to Plus when realm has no Standard subscription
|
||||||
|
with self.assertRaises(BillingError) as billing_context:
|
||||||
|
switch_realm_from_standard_to_plus_plan(realm)
|
||||||
|
self.assertEqual(
|
||||||
|
"Organization does not have an active Standard plan",
|
||||||
|
billing_context.exception.error_description,
|
||||||
|
)
|
||||||
|
|
||||||
|
plan, ledger = self.subscribe_realm_to_manual_license_management_plan(
|
||||||
|
realm, 9, 9, CustomerPlan.MONTHLY
|
||||||
|
)
|
||||||
|
# Test upgrading to Plus when realm has no stripe_customer_id
|
||||||
|
with self.assertRaises(BillingError) as billing_context:
|
||||||
|
switch_realm_from_standard_to_plus_plan(realm)
|
||||||
|
self.assertEqual(
|
||||||
|
"Organization missing Stripe customer.", billing_context.exception.error_description
|
||||||
|
)
|
||||||
|
|
||||||
|
plan.customer.stripe_customer_id = "cus_12345"
|
||||||
|
plan.customer.save(update_fields=["stripe_customer_id"])
|
||||||
|
plan.price_per_license = get_price_per_license(CustomerPlan.STANDARD, CustomerPlan.MONTHLY)
|
||||||
|
plan.automanage_licenses = True
|
||||||
|
plan.invoiced_through = ledger
|
||||||
|
plan.save(update_fields=["price_per_license", "automanage_licenses", "invoiced_through"])
|
||||||
|
|
||||||
|
switch_realm_from_standard_to_plus_plan(realm)
|
||||||
|
|
||||||
|
plan.refresh_from_db()
|
||||||
|
self.assertEqual(plan.status, CustomerPlan.ENDED)
|
||||||
|
plus_plan = get_current_plan_by_realm(realm)
|
||||||
|
assert plus_plan is not None
|
||||||
|
self.assertEqual(plus_plan.tier, CustomerPlan.PLUS)
|
||||||
|
self.assertEqual(LicenseLedger.objects.filter(plan=plus_plan).count(), 1)
|
||||||
|
|
||||||
def test_update_billing_method_of_current_plan(self) -> None:
|
def test_update_billing_method_of_current_plan(self) -> None:
|
||||||
realm = get_realm("zulip")
|
realm = get_realm("zulip")
|
||||||
customer = Customer.objects.create(realm=realm, stripe_customer_id="cus_12345")
|
customer = Customer.objects.create(realm=realm, stripe_customer_id="cus_12345")
|
||||||
|
@ -3239,6 +3280,28 @@ class BillingHelpersTest(ZulipTestCase):
|
||||||
with self.assertRaisesRegex(InvalidTier, "Unknown tier: 10"):
|
with self.assertRaisesRegex(InvalidTier, "Unknown tier: 10"):
|
||||||
get_price_per_license(CustomerPlan.ENTERPRISE, CustomerPlan.ANNUAL)
|
get_price_per_license(CustomerPlan.ENTERPRISE, CustomerPlan.ANNUAL)
|
||||||
|
|
||||||
|
def test_get_plan_renewal_or_end_date(self) -> None:
|
||||||
|
realm = get_realm("zulip")
|
||||||
|
customer = Customer.objects.create(realm=realm, stripe_customer_id="cus_12345")
|
||||||
|
billing_cycle_anchor = timezone_now()
|
||||||
|
plan = CustomerPlan.objects.create(
|
||||||
|
customer=customer,
|
||||||
|
status=CustomerPlan.ACTIVE,
|
||||||
|
billing_cycle_anchor=billing_cycle_anchor,
|
||||||
|
billing_schedule=CustomerPlan.MONTHLY,
|
||||||
|
tier=CustomerPlan.STANDARD,
|
||||||
|
)
|
||||||
|
renewal_date = get_plan_renewal_or_end_date(plan, billing_cycle_anchor)
|
||||||
|
self.assertEqual(renewal_date, add_months(billing_cycle_anchor, 1))
|
||||||
|
|
||||||
|
# When the plan ends 2 days before the start of the next billing cycle,
|
||||||
|
# the function should return the end_date.
|
||||||
|
plan_end_date = add_months(billing_cycle_anchor, 1) - timedelta(days=2)
|
||||||
|
plan.end_date = plan_end_date
|
||||||
|
plan.save(update_fields=["end_date"])
|
||||||
|
renewal_date = get_plan_renewal_or_end_date(plan, billing_cycle_anchor)
|
||||||
|
self.assertEqual(renewal_date, plan_end_date)
|
||||||
|
|
||||||
def test_update_or_create_stripe_customer_logic(self) -> None:
|
def test_update_or_create_stripe_customer_logic(self) -> None:
|
||||||
user = self.example_user("hamlet")
|
user = self.example_user("hamlet")
|
||||||
# No existing Customer object
|
# No existing Customer object
|
||||||
|
|
|
@ -39,6 +39,10 @@ class Customer:
|
||||||
def delete_discount(customer: Customer) -> None: ...
|
def delete_discount(customer: Customer) -> None: ...
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def list(limit: Optional[int] = ...) -> List[Customer]: ...
|
def list(limit: Optional[int] = ...) -> List[Customer]: ...
|
||||||
|
@staticmethod
|
||||||
|
def create_balance_transaction(
|
||||||
|
customer_id: str, amount: int, currency: str, description: str
|
||||||
|
) -> None: ...
|
||||||
|
|
||||||
class Invoice:
|
class Invoice:
|
||||||
id: str
|
id: str
|
||||||
|
|
|
@ -0,0 +1,23 @@
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
|
from django.core.management.base import CommandError, CommandParser
|
||||||
|
|
||||||
|
from zerver.lib.management import ZulipBaseCommand
|
||||||
|
|
||||||
|
if settings.BILLING_ENABLED:
|
||||||
|
from corporate.lib.stripe import switch_realm_from_standard_to_plus_plan
|
||||||
|
|
||||||
|
|
||||||
|
class Command(ZulipBaseCommand):
|
||||||
|
def add_arguments(self, parser: CommandParser) -> None:
|
||||||
|
self.add_realm_args(parser)
|
||||||
|
|
||||||
|
def handle(self, *args: Any, **options: Any) -> None:
|
||||||
|
realm = self.get_realm(options)
|
||||||
|
|
||||||
|
if not realm:
|
||||||
|
raise CommandError("No realm found.")
|
||||||
|
|
||||||
|
if settings.BILLING_ENABLED:
|
||||||
|
switch_realm_from_standard_to_plus_plan(realm)
|
Loading…
Reference in New Issue