mirror of
				https://github.com/zulip/zulip.git
				synced 2025-11-03 21:43:21 +00:00 
			
		
		
		
	billing: Add command for switching plans from Standard to Plus.
This commit is contained in:
		@@ -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:
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										18
									
								
								corporate/migrations/0014_customerplan_end_date.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										18
									
								
								corporate/migrations/0014_customerplan_end_date.py
									
									
									
									
									
										Normal 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),
 | 
			
		||||
        ),
 | 
			
		||||
    ]
 | 
			
		||||
@@ -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
 | 
			
		||||
 
 | 
			
		||||
@@ -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"
 | 
			
		||||
}
 | 
			
		||||
@@ -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
 | 
			
		||||
}
 | 
			
		||||
@@ -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
 | 
			
		||||
}
 | 
			
		||||
@@ -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"
 | 
			
		||||
}
 | 
			
		||||
@@ -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
 | 
			
		||||
 
 | 
			
		||||
@@ -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
 | 
			
		||||
 
 | 
			
		||||
@@ -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)
 | 
			
		||||
		Reference in New Issue
	
	Block a user