billing: Add command for switching plans from Standard to Plus.

This commit is contained in:
Vishnu KS
2021-09-22 00:51:03 +05:30
committed by Tim Abbott
parent 87c1b9e3bc
commit fcab2ea5f7
10 changed files with 563 additions and 8 deletions

View File

@@ -376,12 +376,11 @@ def make_end_of_cycle_updates_if_needed(
assert last_ledger_renewal is not None
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
next_billing_cycle = plan.next_invoice_date
else:
next_billing_cycle = start_of_next_billing_cycle(plan, last_renewal)
if next_billing_cycle <= event_time and last_ledger_entry is not None:
licenses_at_next_renewal = last_ledger_entry.licenses_at_next_renewal
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
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:
process_downgrade(plan)
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)
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:
if plan.invoicing_status == CustomerPlan.STARTED:
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"
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 = (
LicenseLedger.objects.filter(
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
last_renewal = last_ledger_entry_renewal.event_time
period_end = start_of_next_billing_cycle(plan, ledger_entry.event_time)
proration_fraction = (period_end - ledger_entry.event_time) / (
period_end - last_renewal
billing_period_end = start_of_next_billing_cycle(plan, ledger_entry.event_time)
plan_renewal_or_end_date = get_plan_renewal_or_end_date(plan, ledger_entry.event_time)
proration_fraction = (plan_renewal_or_end_date - ledger_entry.event_time) / (
billing_period_end - last_renewal
)
price_args = {
"unit_amount": int(plan.price_per_license * proration_fraction + 0.5),
"quantity": ledger_entry.licenses - licenses_base,
}
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:
@@ -811,7 +861,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)
get_plan_renewal_or_end_date(plan, ledger_entry.event_time)
),
},
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)
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(
realm: Realm, charge_automatically: bool, *, acting_user: Optional[UserProfile]
) -> None:

View File

@@ -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),
),
]

View File

@@ -85,6 +85,7 @@ class CustomerPlan(models.Model):
invoiced_through: Optional["LicenseLedger"] = models.ForeignKey(
"LicenseLedger", null=True, on_delete=CASCADE, related_name="+"
)
end_date: Optional[datetime.datetime] = models.DateTimeField(null=True)
DONE = 1
STARTED = 2
@@ -103,6 +104,7 @@ class CustomerPlan(models.Model):
DOWNGRADE_AT_END_OF_CYCLE = 2
FREE_TRIAL = 3
SWITCH_TO_ANNUAL_AT_END_OF_CYCLE = 4
SWITCH_NOW_FROM_STANDARD_TO_PLUS = 5
# "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

@@ -0,0 +1,16 @@
{
"id": "cbtxn_1JkZzGHSaWXyvFpKI1U7dskx",
"object": "customer_balance_transaction",
"amount": -7200,
"created": 1634241190,
"credit_note": null,
"currency": "usd",
"customer": "cus_12345",
"description": "Credit from early termination of Standard plan",
"ending_balance": -7200,
"invoice": null,
"livemode": false,
"metadata": {
},
"type": "adjustment"
}

View File

@@ -0,0 +1,142 @@
{
"id": "in_1JkZzHHSaWXyvFpKS2T8uAoF",
"object": "invoice",
"account_country": "US",
"account_name": "Random Tech Company Inc",
"account_tax_ids": null,
"amount_due": 7200,
"amount_paid": 0,
"amount_remaining": 7200,
"application_fee_amount": null,
"attempt_count": 0,
"attempted": false,
"auto_advance": true,
"automatic_tax": {
"enabled": false,
"status": null
},
"billing_reason": "manual",
"charge": null,
"collection_method": "charge_automatically",
"created": 1634241191,
"currency": "usd",
"custom_fields": null,
"customer": "cus_12345",
"customer_address": null,
"customer_email": "desdemona@zulip.com",
"customer_name": null,
"customer_phone": null,
"customer_shipping": null,
"customer_tax_exempt": "none",
"customer_tax_ids": [
],
"default_payment_method": null,
"default_source": null,
"default_tax_rates": [
],
"description": null,
"discount": null,
"discounts": [
],
"due_date": null,
"ending_balance": null,
"footer": null,
"hosted_invoice_url": null,
"invoice_pdf": null,
"last_finalization_error": null,
"lines": {
"object": "list",
"data": [
{
"id": "il_1JkZzGHSaWXyvFpKhn6ouXWE",
"object": "line_item",
"amount": 14400,
"currency": "usd",
"description": "Zulip Plus - renewal",
"discount_amounts": [
],
"discountable": false,
"discounts": [
],
"invoice_item": "ii_1JkZzGHSaWXyvFpKltjY9eDZ",
"livemode": false,
"metadata": {
},
"period": {
"end": 1636919590,
"start": 1634241190
},
"plan": null,
"price": {
"id": "price_1JkZzGHSaWXyvFpK2ipHpRqs",
"object": "price",
"active": false,
"billing_scheme": "per_unit",
"created": 1634241190,
"currency": "usd",
"livemode": false,
"lookup_key": null,
"metadata": {
},
"nickname": null,
"product": "prod_KPOmOPVZ6YxUJA",
"recurring": null,
"tax_behavior": "unspecified",
"tiers_mode": null,
"transform_quantity": null,
"type": "one_time",
"unit_amount": 1600,
"unit_amount_decimal": "1600"
},
"proration": false,
"quantity": 9,
"subscription": null,
"tax_amounts": [
],
"tax_rates": [
],
"type": "invoiceitem"
}
],
"has_more": false,
"total_count": 1,
"url": "/v1/invoices/in_1JkZzHHSaWXyvFpKS2T8uAoF/lines"
},
"livemode": false,
"metadata": {
},
"next_payment_attempt": 1634244791,
"number": null,
"on_behalf_of": null,
"paid": false,
"payment_intent": null,
"payment_settings": {
"payment_method_options": null,
"payment_method_types": null
},
"period_end": 1634241191,
"period_start": 1634241191,
"post_payment_credit_notes_amount": 0,
"pre_payment_credit_notes_amount": 0,
"quote": null,
"receipt_number": null,
"starting_balance": -7200,
"statement_descriptor": "Zulip Plus",
"status": "draft",
"status_transitions": {
"finalized_at": null,
"marked_uncollectible_at": null,
"paid_at": null,
"voided_at": null
},
"subscription": null,
"subtotal": 14400,
"tax": null,
"total": 14400,
"total_discount_amounts": [
],
"total_tax_amounts": [
],
"transfer_data": null,
"webhooks_delivered_at": null
}

View File

@@ -0,0 +1,142 @@
{
"id": "in_1JkZzHHSaWXyvFpKS2T8uAoF",
"object": "invoice",
"account_country": "US",
"account_name": "Random Tech Company Inc",
"account_tax_ids": null,
"amount_due": 7200,
"amount_paid": 0,
"amount_remaining": 7200,
"application_fee_amount": null,
"attempt_count": 0,
"attempted": false,
"auto_advance": true,
"automatic_tax": {
"enabled": false,
"status": null
},
"billing_reason": "manual",
"charge": null,
"collection_method": "charge_automatically",
"created": 1634241191,
"currency": "usd",
"custom_fields": null,
"customer": "cus_12345",
"customer_address": null,
"customer_email": "desdemona@zulip.com",
"customer_name": null,
"customer_phone": null,
"customer_shipping": null,
"customer_tax_exempt": "none",
"customer_tax_ids": [
],
"default_payment_method": null,
"default_source": null,
"default_tax_rates": [
],
"description": null,
"discount": null,
"discounts": [
],
"due_date": null,
"ending_balance": 0,
"footer": null,
"hosted_invoice_url": "https://invoice.stripe.com/i/acct_1CGwp5HSaWXyvFpK/test_YWNjdF8xQ0d3cDVIU2FXWHl2RnBLLF9LUE9tRk8wd3o0cDVoeTV3VDUycmt2MVRuT2xjZzlC0100uIwzi9rE",
"invoice_pdf": "https://pay.stripe.com/invoice/acct_1CGwp5HSaWXyvFpK/test_YWNjdF8xQ0d3cDVIU2FXWHl2RnBLLF9LUE9tRk8wd3o0cDVoeTV3VDUycmt2MVRuT2xjZzlC0100uIwzi9rE/pdf",
"last_finalization_error": null,
"lines": {
"object": "list",
"data": [
{
"id": "il_1JkZzGHSaWXyvFpKhn6ouXWE",
"object": "line_item",
"amount": 14400,
"currency": "usd",
"description": "Zulip Plus - renewal",
"discount_amounts": [
],
"discountable": false,
"discounts": [
],
"invoice_item": "ii_1JkZzGHSaWXyvFpKltjY9eDZ",
"livemode": false,
"metadata": {
},
"period": {
"end": 1636919590,
"start": 1634241190
},
"plan": null,
"price": {
"id": "price_1JkZzGHSaWXyvFpK2ipHpRqs",
"object": "price",
"active": false,
"billing_scheme": "per_unit",
"created": 1634241190,
"currency": "usd",
"livemode": false,
"lookup_key": null,
"metadata": {
},
"nickname": null,
"product": "prod_KPOmOPVZ6YxUJA",
"recurring": null,
"tax_behavior": "unspecified",
"tiers_mode": null,
"transform_quantity": null,
"type": "one_time",
"unit_amount": 1600,
"unit_amount_decimal": "1600"
},
"proration": false,
"quantity": 9,
"subscription": null,
"tax_amounts": [
],
"tax_rates": [
],
"type": "invoiceitem"
}
],
"has_more": false,
"total_count": 1,
"url": "/v1/invoices/in_1JkZzHHSaWXyvFpKS2T8uAoF/lines"
},
"livemode": false,
"metadata": {
},
"next_payment_attempt": 1634244791,
"number": "C17F3DE7-0002",
"on_behalf_of": null,
"paid": false,
"payment_intent": "pi_3JkZzIHSaWXyvFpK1vzlINnH",
"payment_settings": {
"payment_method_options": null,
"payment_method_types": null
},
"period_end": 1634241191,
"period_start": 1634241191,
"post_payment_credit_notes_amount": 0,
"pre_payment_credit_notes_amount": 0,
"quote": null,
"receipt_number": null,
"starting_balance": -7200,
"statement_descriptor": "Zulip Plus",
"status": "open",
"status_transitions": {
"finalized_at": 1634241191,
"marked_uncollectible_at": null,
"paid_at": null,
"voided_at": null
},
"subscription": null,
"subtotal": 14400,
"tax": null,
"total": 14400,
"total_discount_amounts": [
],
"total_tax_amounts": [
],
"transfer_data": null,
"webhooks_delivered_at": null
}

View File

@@ -0,0 +1,49 @@
{
"id": "ii_1JkZzGHSaWXyvFpKltjY9eDZ",
"object": "invoiceitem",
"amount": 14400,
"currency": "usd",
"customer": "cus_12345",
"date": 1634241190,
"description": "Zulip Plus - renewal",
"discountable": false,
"discounts": [
],
"invoice": null,
"livemode": false,
"metadata": {
},
"period": {
"end": 1636919590,
"start": 1634241190
},
"plan": null,
"price": {
"id": "price_1JkZzGHSaWXyvFpK2ipHpRqs",
"object": "price",
"active": false,
"billing_scheme": "per_unit",
"created": 1634241190,
"currency": "usd",
"livemode": false,
"lookup_key": null,
"metadata": {
},
"nickname": null,
"product": "prod_KPOmOPVZ6YxUJA",
"recurring": null,
"tax_behavior": "unspecified",
"tiers_mode": null,
"transform_quantity": null,
"type": "one_time",
"unit_amount": 1600,
"unit_amount_decimal": "1600"
},
"proration": false,
"quantity": 9,
"subscription": null,
"tax_rates": [
],
"unit_amount": 1600,
"unit_amount_decimal": "1600"
}

View File

@@ -50,6 +50,7 @@ from corporate.lib.stripe import (
downgrade_small_realms_behind_on_payments_as_needed,
get_discount_for_realm,
get_latest_seat_count,
get_plan_renewal_or_end_date,
get_price_per_license,
get_realms_to_default_discount_dict,
invoice_plan,
@@ -62,6 +63,7 @@ from corporate.lib.stripe import (
sign_string,
stripe_customer_has_credit_card_as_default_source,
stripe_get_customer,
switch_realm_from_standard_to_plus_plan,
unsign_string,
update_billing_method_of_current_plan,
update_license_ledger_for_automanaged_plan,
@@ -297,6 +299,7 @@ MOCKED_STRIPE_FUNCTION_NAMES = [
"Charge.list",
"Coupon.create",
"Customer.create",
"Customer.create_balance_transaction",
"Customer.retrieve",
"Customer.save",
"Invoice.create",
@@ -2977,6 +2980,44 @@ class StripeTest(StripeTestCase):
email_found = True
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:
realm = get_realm("zulip")
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"):
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:
user = self.example_user("hamlet")
# No existing Customer object

View File

@@ -39,6 +39,10 @@ class Customer:
def delete_discount(customer: Customer) -> None: ...
@staticmethod
def list(limit: Optional[int] = ...) -> List[Customer]: ...
@staticmethod
def create_balance_transaction(
customer_id: str, amount: int, currency: str, description: str
) -> None: ...
class Invoice:
id: str

View File

@@ -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)