diff --git a/corporate/lib/stripe.py b/corporate/lib/stripe.py index 7480dbae4a..3055bf2a43 100644 --- a/corporate/lib/stripe.py +++ b/corporate/lib/stripe.py @@ -1113,8 +1113,8 @@ class BillingSession(ABC): def ensure_current_plan_is_upgradable( self, customer: Customer, new_plan_tier: int ) -> None: # nocoverage - # Upgrade for customers with an existing plan is only supported for remote servers right now. - if not hasattr(self, "remote_server"): + # Upgrade for customers with an existing plan is only supported for remote realm / server right now. + if isinstance(self, RealmBillingSession): ensure_customer_does_not_have_active_plan(customer) return @@ -3032,6 +3032,7 @@ class RemoteRealmBillingSession(BillingSession): # nocoverage raise AssertionError("Unexpected tier") # TODO: Audit logging and set usage limits. + # TODO: Set the usage limit in handle_customer_migration_from_server_to_realms. self.remote_realm.plan_type = plan_type self.remote_realm.save(update_fields=["plan_type"]) @@ -3117,6 +3118,7 @@ class RemoteRealmBillingSession(BillingSession): # nocoverage self, current_plan_tier: int, new_plan_tier: int ) -> PlanTierChangeType: valid_plan_tiers = [ + CustomerPlan.TIER_SELF_HOSTED_LEGACY, CustomerPlan.TIER_SELF_HOSTED_BUSINESS, CustomerPlan.TIER_SELF_HOSTED_PLUS, ] @@ -3131,6 +3133,11 @@ class RemoteRealmBillingSession(BillingSession): # nocoverage and new_plan_tier == CustomerPlan.TIER_SELF_HOSTED_PLUS ): return PlanTierChangeType.UPGRADE + elif current_plan_tier == CustomerPlan.TIER_SELF_HOSTED_LEGACY and new_plan_tier in ( + CustomerPlan.TIER_SELF_HOSTED_BUSINESS, + CustomerPlan.TIER_SELF_HOSTED_PLUS, + ): + return PlanTierChangeType.UPGRADE else: assert current_plan_tier == CustomerPlan.TIER_SELF_HOSTED_PLUS assert new_plan_tier == CustomerPlan.TIER_SELF_HOSTED_BUSINESS diff --git a/corporate/views/billing_page.py b/corporate/views/billing_page.py index 272645801c..460e62be69 100644 --- a/corporate/views/billing_page.py +++ b/corporate/views/billing_page.py @@ -117,12 +117,16 @@ def remote_realm_billing_page( # If the realm is on a paid plan, show the sponsorship pending message context["sponsorship_pending"] = True - if billing_session.remote_realm.plan_type == RemoteRealm.PLAN_TYPE_SELF_HOSTED: + if ( + customer is None + or get_current_plan_by_customer(customer) is None + or ( + billing_session.get_legacy_remote_server_next_plan_name(customer) is None + and billing_session.remote_realm.plan_type == RemoteRealm.PLAN_TYPE_SELF_HOSTED + ) + ): return HttpResponseRedirect(reverse("remote_realm_plans_page", args=(realm_uuid,))) - if customer is None or get_current_plan_by_customer(customer) is None: - return HttpResponseRedirect(reverse("remote_realm_upgrade_page", args=(realm_uuid,))) - main_context = billing_session.get_billing_page_context() if main_context: context.update(main_context) diff --git a/corporate/views/portico.py b/corporate/views/portico.py index f938be2798..cf86f07f54 100644 --- a/corporate/views/portico.py +++ b/corporate/views/portico.py @@ -149,7 +149,21 @@ def remote_realm_plans_page( if context.customer_plan is None: context.on_free_tier = not context.is_sponsored else: + context.on_free_tier = context.customer_plan.tier in ( + CustomerPlan.TIER_SELF_HOSTED_LEGACY, + CustomerPlan.TIER_SELF_HOSTED_BASE, + ) context.on_free_trial = is_customer_on_free_trial(context.customer_plan) + context.is_legacy_server_with_scheduled_upgrade = ( + context.customer_plan.status == CustomerPlan.SWITCH_PLAN_TIER_AT_PLAN_END + ) + if context.is_legacy_server_with_scheduled_upgrade: + assert context.customer_plan.end_date is not None + context.legacy_server_new_plan = CustomerPlan.objects.get( + customer=customer, + billing_cycle_anchor=context.customer_plan.end_date, + status=CustomerPlan.NEVER_STARTED, + ) context.is_new_customer = ( not context.on_free_tier and context.customer_plan is None and not context.is_sponsored diff --git a/corporate/views/upgrade.py b/corporate/views/upgrade.py index de20e99b96..60e19b5af2 100644 --- a/corporate/views/upgrade.py +++ b/corporate/views/upgrade.py @@ -95,6 +95,7 @@ def remote_realm_upgrade( default=None, str_validator=check_string_in(VALID_LICENSE_MANAGEMENT_VALUES) ), licenses: Optional[int] = REQ(json_validator=check_int, default=None), + remote_server_plan_start_date: Optional[str] = REQ(default=None), ) -> HttpResponse: # nocoverage try: upgrade_request = UpgradeRequest( @@ -106,7 +107,7 @@ def remote_realm_upgrade( licenses=licenses, # TODO: tier should be a passed parameter. tier=CustomerPlan.TIER_SELF_HOSTED_BUSINESS, - remote_server_plan_start_date=None, + remote_server_plan_start_date=remote_server_plan_start_date, ) data = billing_session.do_upgrade(upgrade_request) return json_success(request, data) @@ -212,10 +213,12 @@ def remote_realm_upgrade_page( billing_session: RemoteRealmBillingSession, *, manual_license_management: Json[bool] = False, + success_message: str = "", ) -> HttpResponse: # nocoverage initial_upgrade_request = InitialUpgradeRequest( manual_license_management=manual_license_management, tier=CustomerPlan.TIER_SELF_HOSTED_BUSINESS, + success_message=success_message, ) redirect_url, context = billing_session.get_initial_upgrade_context(initial_upgrade_request) diff --git a/zerver/models.py b/zerver/models.py index 9d2228853f..9fccfbca1f 100644 --- a/zerver/models.py +++ b/zerver/models.py @@ -4858,6 +4858,7 @@ class AbstractRealmAuditLog(models.Model): # RemoteRealm model resulting from modified realm information sent to us # via send_analytics_to_push_bouncer. REMOTE_REALM_VALUE_UPDATED = 20001 + REMOTE_PLAN_TRANSFERRED_SERVER_TO_REALM = 20002 event_type = models.PositiveSmallIntegerField() diff --git a/zilencer/views.py b/zilencer/views.py index a66b9e5212..927de30f7c 100644 --- a/zilencer/views.py +++ b/zilencer/views.py @@ -5,6 +5,7 @@ from typing import Any, Dict, List, Optional, Type, TypeVar, Union from uuid import UUID import orjson +from django.conf import settings from django.core.exceptions import ValidationError from django.core.validators import URLValidator, validate_email from django.db import IntegrityError, transaction @@ -23,7 +24,11 @@ from analytics.lib.counts import ( REMOTE_INSTALLATION_COUNT_STATS, do_increment_logging_stat, ) -from corporate.lib.stripe import RemoteRealmBillingSession, do_deactivate_remote_server +from corporate.lib.stripe import ( + RemoteRealmBillingSession, + RemoteServerBillingSession, + do_deactivate_remote_server, +) from corporate.models import CustomerPlan, get_current_plan_by_customer from zerver.decorator import require_post from zerver.lib.exceptions import JsonableError, RemoteRealmServerMismatchError @@ -673,6 +678,101 @@ def update_remote_realm_data_for_server( RemoteRealmAuditLog.objects.bulk_create(remote_realm_audit_logs) +def get_human_user_realm_uuids(realms: List[RealmDataForAnalytics]) -> List[UUID]: # nocoverage + billable_realm_uuids = [] + for realm in realms: + # TODO: Remove the `zulipinternal` string_id check once no server is on 8.0-beta. + if ( + realm.is_system_bot_realm + or realm.host.startswith("zulipinternal.") + or (settings.DEVELOPMENT and realm.host.startswith("analytics.")) + ): + continue + billable_realm_uuids.append(realm.uuid) + + return billable_realm_uuids + + +@transaction.atomic +def handle_customer_migration_from_server_to_realms( + server: RemoteZulipServer, realms: List[RealmDataForAnalytics] +) -> None: # nocoverage + server_billing_session = RemoteServerBillingSession(server) + server_customer = server_billing_session.get_customer() + if server_customer is None: + return + server_plan = get_current_plan_by_customer(server_customer) + realm_uuids = get_human_user_realm_uuids(realms) + if not realm_uuids: + return + + event_time = timezone_now() + remote_realm_audit_logs = [] + if ( + server_plan is not None + and server_plan.tier == CustomerPlan.TIER_SELF_HOSTED_LEGACY + and server_plan.status == CustomerPlan.ACTIVE + ): + assert server.plan_type == RemoteZulipServer.PLAN_TYPE_SELF_HOSTED + assert server_plan.end_date is not None + remote_realms = RemoteRealm.objects.filter( + uuid__in=realm_uuids, server=server, plan_type=RemoteRealm.PLAN_TYPE_SELF_HOSTED + ) + + # Verify that all the realms are on self hosted plan. + assert remote_realms.count() == len(realm_uuids) + + # End existing plan for server. + server_plan.status = CustomerPlan.ENDED + server_plan.save(update_fields=["status"]) + + # Create new legacy plan for each remote realm. + for remote_realm in remote_realms: + RemoteRealmBillingSession(remote_realm).migrate_customer_to_legacy_plan( + server_plan.billing_cycle_anchor, server_plan.end_date + ) + remote_realm_audit_logs.append( + RemoteRealmAuditLog( + server=server, + remote_realm=remote_realm, + event_type=RemoteRealmAuditLog.REMOTE_PLAN_TRANSFERRED_SERVER_TO_REALM, + event_time=event_time, + # No extra_data since there was no real change in any RemoteRealm attribute. + ) + ) + + # We only do this migration if there is only one realm besides the system bot realm. + elif len(realm_uuids) == 1: + remote_realm = RemoteRealm.objects.get( + uuid=realm_uuids[0], plan_type=RemoteRealm.PLAN_TYPE_SELF_HOSTED + ) + # Migrate customer from server to remote realm if there is only one realm. + server_customer.remote_realm = remote_realm + server_customer.remote_server = None + server_customer.save(update_fields=["remote_realm", "remote_server"]) + # TODO: Set usage limits for remote realm and server. + # Might be better to call do_change_plan_type here. + remote_realm.plan_type = server.plan_type + remote_realm.save(update_fields=["plan_type"]) + server.plan_type = RemoteZulipServer.PLAN_TYPE_SELF_HOSTED + server.save(update_fields=["plan_type"]) + remote_realm_audit_logs.append( + RemoteRealmAuditLog( + server=server, + remote_realm=remote_realm, + event_type=RemoteRealmAuditLog.REMOTE_PLAN_TRANSFERRED_SERVER_TO_REALM, + event_time=event_time, + extra_data={ + "attr_name": "plan_type", + "old_value": RemoteRealm.PLAN_TYPE_SELF_HOSTED, + "new_value": remote_realm.plan_type, + }, + ) + ) + + RemoteRealmAuditLog.objects.bulk_create(remote_realm_audit_logs) + + @typed_endpoint @transaction.atomic def remote_server_post_analytics( @@ -725,6 +825,21 @@ def remote_server_post_analytics( if remote_server_version_updated: fix_remote_realm_foreign_keys(server, realms) + try: + handle_customer_migration_from_server_to_realms(server, realms) + except Exception: # nocoverage + logger.exception( + "%s: Failed to migrate customer from server (id: %s) to realms", + request.path, + server.id, + stack_info=True, + ) + raise JsonableError( + _( + "Failed to migrate customer from server to realms. Please contact support for assistance." + ) + ) + realm_id_to_remote_realm = build_realm_id_to_remote_realm_dict(server, realms) remote_realm_counts = [