From ffd708ecafd09889e65000f0a823c3edfb3b0e08 Mon Sep 17 00:00:00 2001 From: Lauryn Menard Date: Thu, 21 Dec 2023 20:53:54 +0100 Subject: [PATCH] support: Display next plan data on remote support view. Currently, this will only be the case for legacy self-managed plans that have scheduled a switch to either the Basic or Business plan. --- analytics/tests/test_support_views.py | 60 ++++++++++++++++++- corporate/lib/support.py | 39 ++++++++++-- templates/analytics/next_plan_details.html | 15 +++++ templates/analytics/remote_realm_details.html | 11 ++++ .../analytics/remote_server_support.html | 11 ++++ 5 files changed, 129 insertions(+), 7 deletions(-) create mode 100644 templates/analytics/next_plan_details.html diff --git a/analytics/tests/test_support_views.py b/analytics/tests/test_support_views.py index 40b4eed76c..48ef850ccf 100644 --- a/analytics/tests/test_support_views.py +++ b/analytics/tests/test_support_views.py @@ -8,7 +8,7 @@ import time_machine from django.utils.timezone import now as timezone_now from typing_extensions import override -from corporate.lib.stripe import RealmBillingSession, add_months +from corporate.lib.stripe import RealmBillingSession, RemoteRealmBillingSession, add_months from corporate.models import ( Customer, CustomerPlan, @@ -54,6 +54,41 @@ class TestRemoteServerSupportEndpoint(ZulipTestCase): requested_plan=plan, ) + def add_legacy_plan_and_upgrade(name: str) -> None: + legacy_anchor = datetime(2050, 1, 1, tzinfo=timezone.utc) + next_plan_anchor = datetime(2050, 2, 1, tzinfo=timezone.utc) + billed_licenses = 10 + remote_realm = RemoteRealm.objects.get(name=name) + billing_session = RemoteRealmBillingSession(remote_realm) + + billing_session.migrate_customer_to_legacy_plan(legacy_anchor, next_plan_anchor) + customer = billing_session.get_customer() + assert customer is not None + legacy_plan = billing_session.get_remote_server_legacy_plan(customer) + assert legacy_plan is not None + assert legacy_plan.end_date is not None + last_ledger_entry = ( + LicenseLedger.objects.filter(plan=legacy_plan).order_by("-id").first() + ) + assert last_ledger_entry is not None + last_ledger_entry.licenses_at_next_renewal = billed_licenses + last_ledger_entry.save(update_fields=["licenses_at_next_renewal"]) + legacy_plan.status = CustomerPlan.SWITCH_PLAN_TIER_AT_PLAN_END + legacy_plan.save(update_fields=["status"]) + plan_params = { + "automanage_licenses": True, + "charge_automatically": False, + "price_per_license": 100, + "discount": customer.default_discount, + "billing_cycle_anchor": next_plan_anchor, + "billing_schedule": CustomerPlan.BILLING_SCHEDULE_MONTHLY, + "tier": CustomerPlan.TIER_SELF_HOSTED_BASIC, + "status": CustomerPlan.NEVER_STARTED, + } + CustomerPlan.objects.create( + customer=customer, next_invoice_date=next_plan_anchor, **plan_params + ) + super().setUp() # Set up some initial example data. @@ -93,6 +128,9 @@ class TestRemoteServerSupportEndpoint(ZulipTestCase): plan=SponsoredPlanTypes.COMMUNITY.value, ) + # Add expected legacy customer and plan data + add_legacy_plan_and_upgrade(name="realm-name-3") + def test_search(self) -> None: def assert_server_details_in_response( html_response: "TestHttpResponse", hostname: str @@ -170,6 +208,25 @@ class TestRemoteServerSupportEndpoint(ZulipTestCase): result, ) + def check_legacy_and_next_plan(result: "TestHttpResponse") -> None: + self.assert_in_success_response( + [ + "

📅 Current plan information:

", + "Plan name: Self-managed (legacy plan)
", + "Status: New plan scheduled
", + "End date: 01 February 2050
", + "

⏱️ Next plan information:

", + "Plan name: Zulip Basic
", + "Status: Never started
", + "Start date: 01 February 2050
", + "Billing schedule: Monthly
", + "Price per license: $1.00
", + "Estimated billed licenses: 10
", + "Estimated annual revenue: $120.00
", + ], + result, + ) + self.login("cordelia") result = self.client_get("/activity/remote/support") @@ -228,6 +285,7 @@ class TestRemoteServerSupportEndpoint(ZulipTestCase): assert_server_details_in_response(result, f"zulip-{server}.example.com") assert_realm_details_in_response(result, f"realm-name-{server}", f"realm-host-{server}") check_no_sponsorship_request(result) + check_legacy_and_next_plan(result) class TestSupportEndpoint(ZulipTestCase): diff --git a/corporate/lib/support.py b/corporate/lib/support.py index e8911acdec..598155d023 100644 --- a/corporate/lib/support.py +++ b/corporate/lib/support.py @@ -49,12 +49,14 @@ class SponsorshipData: class PlanData: customer: Optional["Customer"] = None current_plan: Optional["CustomerPlan"] = None + next_plan: Optional["CustomerPlan"] = None licenses: Optional[int] = None licenses_used: Optional[int] = None is_legacy_plan: bool = False has_fixed_price: bool = False warning: Optional[str] = None annual_recurring_revenue: Optional[int] = None + estimated_next_plan_revenue: Optional[int] = None @dataclass @@ -115,6 +117,13 @@ def get_customer_sponsorship_data(customer: Customer) -> SponsorshipData: ) +def get_annual_invoice_count(billing_schedule: int) -> int: + if billing_schedule == CustomerPlan.BILLING_SCHEDULE_MONTHLY: + return 12 + else: + return 1 + + def get_current_plan_data_for_support_view(billing_session: BillingSession) -> PlanData: customer = billing_session.get_customer() plan = None @@ -136,18 +145,36 @@ def get_current_plan_data_for_support_view(billing_session: BillingSession) -> P plan_data.licenses_used = billing_session.current_count_for_billed_licenses() except MissingDataError: # nocoverage plan_data.warning = "Recent data missing: No information for used licenses" + assert plan_data.current_plan is not None # for mypy + + plan_data.next_plan = billing_session.get_next_plan(plan_data.current_plan) + + if plan_data.next_plan is not None: + if plan_data.next_plan.fixed_price is not None: # nocoverage + plan_data.estimated_next_plan_revenue = plan_data.next_plan.fixed_price + elif plan_data.current_plan.licenses_at_next_renewal() is not None: + next_plan_licenses = plan_data.current_plan.licenses_at_next_renewal() + assert next_plan_licenses is not None + assert plan_data.next_plan.price_per_license is not None + invoice_count = get_annual_invoice_count(plan_data.next_plan.billing_schedule) + plan_data.estimated_next_plan_revenue = ( + plan_data.next_plan.price_per_license * next_plan_licenses * invoice_count + ) + else: + plan_data.estimated_next_plan_revenue = 0 # nocoverage + plan_data.is_legacy_plan = ( plan_data.current_plan.tier == CustomerPlan.TIER_SELF_HOSTED_LEGACY ) plan_data.has_fixed_price = plan_data.current_plan.fixed_price is not None - plan_revenue = billing_session.get_customer_plan_renewal_amount( - plan_data.current_plan, timezone_now(), last_ledger_entry + annual_invoice_count = get_annual_invoice_count(plan_data.current_plan.billing_schedule) + plan_data.annual_recurring_revenue = ( + billing_session.get_customer_plan_renewal_amount( + plan_data.current_plan, timezone_now(), last_ledger_entry + ) + * annual_invoice_count ) - if plan_data.current_plan.billing_schedule == CustomerPlan.BILLING_SCHEDULE_MONTHLY: - plan_data.annual_recurring_revenue = plan_revenue * 12 - else: - plan_data.annual_recurring_revenue = plan_revenue return plan_data diff --git a/templates/analytics/next_plan_details.html b/templates/analytics/next_plan_details.html new file mode 100644 index 0000000000..0d103fc971 --- /dev/null +++ b/templates/analytics/next_plan_details.html @@ -0,0 +1,15 @@ +

⏱️ Next plan information:

+Plan name: {{ plan_data.next_plan.name }}
+Status: {{ plan_data.next_plan.get_plan_status_as_text() }}
+Start date: {{ plan_data.next_plan.billing_cycle_anchor.strftime('%d %B %Y') }}
+Billing schedule: {% if plan_data.next_plan.billing_schedule == plan_data.next_plan.BILLING_SCHEDULE_ANNUAL %}Annual{% else %}Monthly{% endif %}
+{% if plan_data.next_plan.discount %} + Discount: {{ format_discount(plan_data.next_plan.discount) }}%
+{% endif %} +{% if plan_data.next_plan.price_per_license %} + Price per license: ${{ dollar_amount(plan_data.next_plan.price_per_license) }}
+ Estimated billed licenses: {{ plan_data.current_plan.licenses_at_next_renewal() }}
+{% elif plan_data.next_plan.fixed_price %} + Plan has a fixed price. +{% endif %} +Estimated annual revenue: ${{ dollar_amount(plan_data.estimated_next_plan_revenue) }}
diff --git a/templates/analytics/remote_realm_details.html b/templates/analytics/remote_realm_details.html index 08dfefc439..ae8185680f 100644 --- a/templates/analytics/remote_realm_details.html +++ b/templates/analytics/remote_realm_details.html @@ -42,3 +42,14 @@ {% include 'analytics/current_plan_forms_support.html' %} {% endwith %} {% endif %} + +{% if support_data[remote_realm.id].plan_data.next_plan %} +
+ {% with %} + {% set plan_data = support_data[remote_realm.id].plan_data %} + {% set format_discount = format_discount %} + {% set dollar_amount = dollar_amount %} + {% include 'analytics/next_plan_details.html' %} + {% endwith %} +
+{% endif %} diff --git a/templates/analytics/remote_server_support.html b/templates/analytics/remote_server_support.html index b625955cb7..9b8f3c70d9 100644 --- a/templates/analytics/remote_server_support.html +++ b/templates/analytics/remote_server_support.html @@ -86,6 +86,17 @@ {% endwith %} {% endif %} + {% if remote_servers_support_data[remote_server.id].plan_data.next_plan %} +
+ {% with %} + {% set plan_data = remote_servers_support_data[remote_server.id].plan_data %} + {% set format_discount = format_discount %} + {% set dollar_amount = dollar_amount %} + {% include 'analytics/next_plan_details.html' %} + {% endwith %} +
+ {% endif %} + {% for remote_realm in remote_realms[remote_server.id] %}