diff --git a/analytics/tests/test_support_views.py b/analytics/tests/test_support_views.py index 1487e03130..b94b1b827e 100644 --- a/analytics/tests/test_support_views.py +++ b/analytics/tests/test_support_views.py @@ -336,6 +336,44 @@ class TestRemoteServerSupportEndpoint(ZulipTestCase): check_no_sponsorship_request(result) check_legacy_plan_without_upgrade(result) + def test_extend_current_plan_end_date(self) -> None: + remote_realm = RemoteRealm.objects.get(name="realm-name-5") + customer = Customer.objects.get(remote_realm=remote_realm) + plan = get_current_plan_by_customer(customer) + assert plan is not None + self.assertEqual(plan.status, CustomerPlan.ACTIVE) + self.assertEqual(plan.end_date, datetime(2050, 2, 1, tzinfo=timezone.utc)) + + cordelia = self.example_user("cordelia") + self.login_user(cordelia) + result = self.client_post( + "/activity/remote/support", + {"realm_id": f"{remote_realm.id}", "end_date": "2040-01-01"}, + ) + self.assertEqual(result.status_code, 302) + self.assertEqual(result["Location"], "/login/") + + iago = self.example_user("iago") + self.login_user(iago) + + result = self.client_post( + "/activity/remote/support", + {"remote_realm_id": f"{remote_realm.id}", "plan_end_date": "2040-01-01"}, + ) + self.assert_in_success_response( + ["Current plan for realm-name-5 updated to end on 2040-01-01."], result + ) + plan.refresh_from_db() + self.assertEqual(plan.end_date, datetime(2040, 1, 1, tzinfo=timezone.utc)) + + result = self.client_post( + "/activity/remote/support", + {"remote_realm_id": f"{remote_realm.id}", "plan_end_date": "2020-01-01"}, + ) + self.assert_in_success_response( + ["Cannot update current plan for realm-name-5 to end on 2020-01-01."], result + ) + class TestSupportEndpoint(ZulipTestCase): def create_customer_and_plan(self, realm: Realm, monthly: bool = False) -> Customer: diff --git a/analytics/views/support.py b/analytics/views/support.py index ab1f561ea0..b70c08d136 100644 --- a/analytics/views/support.py +++ b/analytics/views/support.py @@ -33,7 +33,13 @@ from zerver.lib.exceptions import JsonableError from zerver.lib.realm_icon import realm_icon_url from zerver.lib.request import REQ, has_request_variables from zerver.lib.subdomains import get_subdomain_from_hostname -from zerver.lib.validator import check_bool, check_string_in, to_decimal, to_non_negative_int +from zerver.lib.validator import ( + check_bool, + check_date, + check_string_in, + to_decimal, + to_non_negative_int, +) from zerver.models import ( MultiuseInvite, PreregistrationRealm, @@ -416,6 +422,7 @@ def remote_servers_support( billing_modality: Optional[str] = REQ( default=None, str_validator=check_string_in(VALID_BILLING_MODALITY_VALUES) ), + plan_end_date: Optional[str] = REQ(default=None, str_validator=check_date), modify_plan: Optional[str] = REQ( default=None, str_validator=check_string_in(VALID_MODIFY_PLAN_METHODS) ), @@ -470,6 +477,11 @@ def remote_servers_support( support_type=SupportType.update_billing_modality, billing_modality=billing_modality, ) + elif plan_end_date is not None: + support_view_request = SupportViewRequest( + support_type=SupportType.update_plan_end_date, + plan_end_date=plan_end_date, + ) elif modify_plan is not None: support_view_request = SupportViewRequest( support_type=SupportType.modify_plan, diff --git a/corporate/lib/stripe.py b/corporate/lib/stripe.py index 4072e71654..9b1db6caa7 100644 --- a/corporate/lib/stripe.py +++ b/corporate/lib/stripe.py @@ -4,7 +4,7 @@ import os import secrets from abc import ABC, abstractmethod from dataclasses import dataclass -from datetime import datetime, timedelta +from datetime import datetime, timedelta, timezone from decimal import Decimal from enum import Enum from functools import wraps @@ -554,6 +554,7 @@ class SupportType(Enum): update_billing_modality = 4 modify_plan = 5 update_minimum_licenses = 6 + update_plan_end_date = 7 class SupportViewRequest(TypedDict, total=False): @@ -564,6 +565,7 @@ class SupportViewRequest(TypedDict, total=False): plan_modification: Optional[str] new_plan_tier: Optional[int] minimum_licenses: Optional[int] + plan_end_date: Optional[str] class AuditLogEventType(Enum): @@ -578,6 +580,7 @@ class AuditLogEventType(Enum): CUSTOMER_SWITCHED_FROM_ANNUAL_TO_MONTHLY_PLAN = 9 BILLING_ENTITY_PLAN_TYPE_CHANGED = 10 MINIMUM_LICENSES_CHANGED = 11 + CUSTOMER_PLAN_END_DATE_CHANGED = 12 class PlanTierChangeType(Enum): @@ -1166,6 +1169,31 @@ class BillingSession(ABC): success_message = f"Billing collection method of {self.billing_entity_display_name} updated to send invoice." return success_message + def update_end_date_of_current_plan(self, end_date_string: str) -> str: + new_end_date = datetime.strptime(end_date_string, "%Y-%m-%d").replace(tzinfo=timezone.utc) + if new_end_date.date() <= timezone_now().date(): + raise SupportRequestError( + f"Cannot update current plan for {self.billing_entity_display_name} to end on {end_date_string}." + ) + customer = self.get_customer() + if customer is not None: + plan = get_current_plan_by_customer(customer) + if plan is not None: + assert plan.end_date is not None + assert plan.status == CustomerPlan.ACTIVE + old_end_date = plan.end_date.strftime("%Y-%m-%d") + plan.end_date = new_end_date + plan.save(update_fields=["end_date"]) + self.write_to_audit_log( + event_type=AuditLogEventType.CUSTOMER_PLAN_END_DATE_CHANGED, + event_time=timezone_now(), + extra_data={"old_end_date": old_end_date, "new_end_date": end_date_string}, + ) + return f"Current plan for {self.billing_entity_display_name} updated to end on {end_date_string}." + raise SupportRequestError( + f"No current plan for {self.billing_entity_display_name}." + ) # nocoverage + def setup_upgrade_payment_intent_and_charge( self, plan_tier: int, @@ -2661,6 +2689,10 @@ class BillingSession(ABC): assert support_request["billing_modality"] in VALID_BILLING_MODALITY_VALUES charge_automatically = support_request["billing_modality"] == "charge_automatically" success_message = self.update_billing_modality_of_current_plan(charge_automatically) + elif support_type == SupportType.update_plan_end_date: + assert support_request["plan_end_date"] is not None + new_plan_end_date = support_request["plan_end_date"] + success_message = self.update_end_date_of_current_plan(new_plan_end_date) elif support_type == SupportType.modify_plan: assert support_request["plan_modification"] is not None plan_modification = support_request["plan_modification"] @@ -2986,6 +3018,8 @@ class RealmBillingSession(BillingSession): return RealmAuditLog.REALM_SPONSORSHIP_PENDING_STATUS_CHANGED elif event_type is AuditLogEventType.BILLING_MODALITY_CHANGED: return RealmAuditLog.REALM_BILLING_MODALITY_CHANGED + elif event_type is AuditLogEventType.CUSTOMER_PLAN_END_DATE_CHANGED: + return RealmAuditLog.CUSTOMER_PLAN_END_DATE_CHANGED # nocoverage elif event_type is AuditLogEventType.CUSTOMER_SWITCHED_FROM_MONTHLY_TO_ANNUAL_PLAN: return RealmAuditLog.CUSTOMER_SWITCHED_FROM_MONTHLY_TO_ANNUAL_PLAN elif event_type is AuditLogEventType.CUSTOMER_SWITCHED_FROM_ANNUAL_TO_MONTHLY_PLAN: @@ -3344,6 +3378,8 @@ class RemoteRealmBillingSession(BillingSession): return RemoteRealmAuditLog.REMOTE_SERVER_SPONSORSHIP_PENDING_STATUS_CHANGED elif event_type is AuditLogEventType.BILLING_MODALITY_CHANGED: return RemoteRealmAuditLog.REMOTE_SERVER_BILLING_MODALITY_CHANGED # nocoverage + elif event_type is AuditLogEventType.CUSTOMER_PLAN_END_DATE_CHANGED: + return RemoteRealmAuditLog.CUSTOMER_PLAN_END_DATE_CHANGED elif event_type is AuditLogEventType.BILLING_ENTITY_PLAN_TYPE_CHANGED: return RemoteRealmAuditLog.REMOTE_SERVER_PLAN_TYPE_CHANGED elif ( @@ -3756,6 +3792,8 @@ class RemoteServerBillingSession(BillingSession): return RemoteZulipServerAuditLog.REMOTE_SERVER_SPONSORSHIP_PENDING_STATUS_CHANGED elif event_type is AuditLogEventType.BILLING_MODALITY_CHANGED: return RemoteZulipServerAuditLog.REMOTE_SERVER_BILLING_MODALITY_CHANGED # nocoverage + elif event_type is AuditLogEventType.CUSTOMER_PLAN_END_DATE_CHANGED: + return RemoteZulipServerAuditLog.CUSTOMER_PLAN_END_DATE_CHANGED # nocoverage elif event_type is AuditLogEventType.BILLING_ENTITY_PLAN_TYPE_CHANGED: return RemoteZulipServerAuditLog.REMOTE_SERVER_PLAN_TYPE_CHANGED elif ( diff --git a/templates/analytics/current_plan_forms_support.html b/templates/analytics/current_plan_forms_support.html index ecd2ff9ac2..dca9541adb 100644 --- a/templates/analytics/current_plan_forms_support.html +++ b/templates/analytics/current_plan_forms_support.html @@ -9,6 +9,16 @@ +{% if current_plan.end_date and current_plan.status == current_plan.ACTIVE %} +
+{% endif %} +