analytics: Create process_support_view_request BillingSession method.

Creates a process_support_view_request method for BillingSession
to process the various support requests that relate to the billing
system.

Moves approve_realm_sponsorship, update_realm_sponsorship_status,
and attach_discount_to_realm to this new BillingSession method.

Adds a new abstract property to BillingSession to have a string
value, billing_entity_display_name, to use for support messages
sent when these requests are processed.
This commit is contained in:
Lauryn Menard 2023-11-30 21:11:54 +01:00 committed by Tim Abbott
parent 0679bc044a
commit 5d25cab42b
5 changed files with 130 additions and 72 deletions

View File

@ -1,4 +1,5 @@
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
from decimal import Decimal
from typing import TYPE_CHECKING, Any, Optional from typing import TYPE_CHECKING, Any, Optional
from unittest import mock from unittest import mock
@ -7,8 +8,7 @@ import time_machine
from django.utils.timezone import now as timezone_now from django.utils.timezone import now as timezone_now
from typing_extensions import override from typing_extensions import override
from corporate.lib.stripe import add_months from corporate.lib.stripe import RealmBillingSession, add_months
from corporate.lib.support import update_realm_sponsorship_status
from corporate.models import Customer, CustomerPlan, LicenseLedger, get_customer_by_realm from corporate.models import Customer, CustomerPlan, LicenseLedger, get_customer_by_realm
from zerver.actions.invites import do_create_multiuse_invite_link from zerver.actions.invites import do_create_multiuse_invite_link
from zerver.actions.realm_settings import do_change_realm_org_type, do_send_realm_reactivation_email from zerver.actions.realm_settings import do_change_realm_org_type, do_send_realm_reactivation_email
@ -533,14 +533,15 @@ class TestSupportEndpoint(ZulipTestCase):
self.assertEqual(result["Location"], "/login/") self.assertEqual(result["Location"], "/login/")
iago = self.example_user("iago") iago = self.example_user("iago")
self.login("iago") self.login_user(iago)
with mock.patch("analytics.views.support.attach_discount_to_realm") as m:
result = self.client_post( result = self.client_post(
"/activity/support", {"realm_id": f"{lear_realm.id}", "discount": "25"} "/activity/support", {"realm_id": f"{lear_realm.id}", "discount": "25"}
) )
m.assert_called_once_with(get_realm("lear"), 25, acting_user=iago) self.assert_in_success_response(["Discount for lear changed to 25% from 0%"], result)
self.assert_in_success_response(["Discount of lear changed to 25% from 0%"], result) customer = get_customer_by_realm(lear_realm)
assert customer is not None
self.assertEqual(customer.default_discount, Decimal(25))
def test_change_sponsorship_status(self) -> None: def test_change_sponsorship_status(self) -> None:
lear_realm = get_realm("lear") lear_realm = get_realm("lear")
@ -577,7 +578,10 @@ class TestSupportEndpoint(ZulipTestCase):
def test_approve_sponsorship(self) -> None: def test_approve_sponsorship(self) -> None:
support_admin = self.example_user("iago") support_admin = self.example_user("iago")
lear_realm = get_realm("lear") lear_realm = get_realm("lear")
update_realm_sponsorship_status(lear_realm, True, acting_user=support_admin) billing_session = RealmBillingSession(
user=support_admin, realm=lear_realm, support_session=True
)
billing_session.update_customer_sponsorship_status(True)
king_user = self.lear_user("king") king_user = self.lear_user("king")
king_user.role = UserProfile.ROLE_REALM_OWNER king_user.role = UserProfile.ROLE_REALM_OWNER
king_user.save() king_user.save()

View File

@ -55,16 +55,15 @@ if settings.ZILENCER_ENABLED:
if settings.BILLING_ENABLED: if settings.BILLING_ENABLED:
from corporate.lib.stripe import ( from corporate.lib.stripe import (
RealmBillingSession, RealmBillingSession,
SupportType,
SupportViewRequest,
get_latest_seat_count, get_latest_seat_count,
void_all_open_invoices, void_all_open_invoices,
) )
from corporate.lib.support import ( from corporate.lib.support import (
approve_realm_sponsorship,
attach_discount_to_realm,
get_discount_for_realm, get_discount_for_realm,
switch_realm_from_standard_to_plus_plan, switch_realm_from_standard_to_plus_plan,
update_realm_billing_modality, update_realm_billing_modality,
update_realm_sponsorship_status,
) )
from corporate.models import ( from corporate.models import (
Customer, Customer,
@ -197,7 +196,21 @@ def support(
assert realm_id is not None assert realm_id is not None
realm = Realm.objects.get(id=realm_id) realm = Realm.objects.get(id=realm_id)
if plan_type is not None: support_view_request = None
if approve_sponsorship:
support_view_request = SupportViewRequest(support_type=SupportType.approve_sponsorship)
elif sponsorship_pending is not None:
support_view_request = SupportViewRequest(
support_type=SupportType.update_sponsorship_status,
sponsorship_status=sponsorship_pending,
)
elif discount is not None:
support_view_request = SupportViewRequest(
support_type=SupportType.attach_discount,
discount=discount,
)
elif plan_type is not None:
current_plan_type = realm.plan_type current_plan_type = realm.plan_type
do_change_realm_plan_type(realm, plan_type, acting_user=acting_user) do_change_realm_plan_type(realm, plan_type, acting_user=acting_user)
msg = f"Plan type of {realm.string_id} changed from {get_plan_name(current_plan_type)} to {get_plan_name(plan_type)} " msg = f"Plan type of {realm.string_id} changed from {get_plan_name(current_plan_type)} to {get_plan_name(plan_type)} "
@ -207,12 +220,6 @@ def support(
do_change_realm_org_type(realm, org_type, acting_user=acting_user) do_change_realm_org_type(realm, org_type, acting_user=acting_user)
msg = f"Org type of {realm.string_id} changed from {get_org_type_display_name(current_realm_type)} to {get_org_type_display_name(org_type)} " msg = f"Org type of {realm.string_id} changed from {get_org_type_display_name(current_realm_type)} to {get_org_type_display_name(org_type)} "
context["success_message"] = msg context["success_message"] = msg
elif discount is not None:
current_discount = get_discount_for_realm(realm) or 0
attach_discount_to_realm(realm, discount, acting_user=acting_user)
context[
"success_message"
] = f"Discount of {realm.string_id} changed to {discount}% from {current_discount}%."
elif new_subdomain is not None: elif new_subdomain is not None:
old_subdomain = realm.string_id old_subdomain = realm.string_id
try: try:
@ -251,16 +258,6 @@ def support(
context[ context[
"success_message" "success_message"
] = f"Billing collection method of {realm.string_id} updated to charge automatically." ] = f"Billing collection method of {realm.string_id} updated to charge automatically."
elif sponsorship_pending is not None:
if sponsorship_pending:
update_realm_sponsorship_status(realm, True, acting_user=acting_user)
context["success_message"] = f"{realm.string_id} marked as pending sponsorship."
else:
update_realm_sponsorship_status(realm, False, acting_user=acting_user)
context["success_message"] = f"{realm.string_id} is no longer pending sponsorship."
elif approve_sponsorship:
approve_realm_sponsorship(realm, acting_user=acting_user)
context["success_message"] = f"Sponsorship approved for {realm.string_id}"
elif modify_plan is not None: elif modify_plan is not None:
billing_session = RealmBillingSession( billing_session = RealmBillingSession(
user=acting_user, realm=realm, support_session=True user=acting_user, realm=realm, support_session=True
@ -294,6 +291,13 @@ def support(
do_delete_user_preserving_messages(user_profile_for_deletion) do_delete_user_preserving_messages(user_profile_for_deletion)
context["success_message"] = f"{user_email} in {realm.subdomain} deleted." context["success_message"] = f"{user_email} in {realm.subdomain} deleted."
if support_view_request is not None:
billing_session = RealmBillingSession(
user=acting_user, realm=realm, support_session=True
)
success_message = billing_session.process_support_view_request(support_view_request)
context["success_message"] = success_message
if query: if query:
key_words = get_invitee_emails_set(query) key_words = get_invitee_emails_set(query)

View File

@ -522,6 +522,18 @@ class EventStatusRequest:
stripe_payment_intent_id: Optional[str] stripe_payment_intent_id: Optional[str]
class SupportType(Enum):
approve_sponsorship = 1
update_sponsorship_status = 2
attach_discount = 3
class SupportViewRequest(TypedDict, total=False):
support_type: SupportType
sponsorship_status: Optional[bool]
discount: Optional[Decimal]
class AuditLogEventType(Enum): class AuditLogEventType(Enum):
STRIPE_CUSTOMER_CREATED = 1 STRIPE_CUSTOMER_CREATED = 1
STRIPE_CARD_CHANGED = 2 STRIPE_CARD_CHANGED = 2
@ -605,6 +617,11 @@ class SponsorshipRequestForm(forms.Form):
class BillingSession(ABC): class BillingSession(ABC):
@property
@abstractmethod
def billing_entity_display_name(self) -> str:
pass
@property @property
@abstractmethod @abstractmethod
def billing_session_url(self) -> str: def billing_session_url(self) -> str:
@ -672,7 +689,7 @@ class BillingSession(ABC):
pass pass
@abstractmethod @abstractmethod
def approve_sponsorship(self) -> None: def approve_sponsorship(self) -> str:
pass pass
@abstractmethod @abstractmethod
@ -868,29 +885,32 @@ class BillingSession(ABC):
) )
return stripe_session return stripe_session
def attach_discount_to_customer(self, discount: Decimal) -> None: def attach_discount_to_customer(self, new_discount: Decimal) -> str:
customer = self.get_customer() customer = self.get_customer()
old_discount: Optional[Decimal] = None old_discount = None
if customer is not None: if customer is not None:
old_discount = customer.default_discount old_discount = customer.default_discount
customer.default_discount = discount customer.default_discount = new_discount
customer.save(update_fields=["default_discount"]) customer.save(update_fields=["default_discount"])
else: else:
customer = self.update_or_create_customer(defaults={"default_discount": discount}) customer = self.update_or_create_customer(defaults={"default_discount": new_discount})
plan = get_current_plan_by_customer(customer) plan = get_current_plan_by_customer(customer)
if plan is not None: if plan is not None:
plan.price_per_license = get_price_per_license( plan.price_per_license = get_price_per_license(
plan.tier, plan.billing_schedule, discount plan.tier, plan.billing_schedule, new_discount
) )
plan.discount = discount plan.discount = new_discount
plan.save(update_fields=["price_per_license", "discount"]) plan.save(update_fields=["price_per_license", "discount"])
self.write_to_audit_log( self.write_to_audit_log(
event_type=AuditLogEventType.DISCOUNT_CHANGED, event_type=AuditLogEventType.DISCOUNT_CHANGED,
event_time=timezone_now(), event_time=timezone_now(),
extra_data={"old_discount": old_discount, "new_discount": discount}, extra_data={"old_discount": old_discount, "new_discount": new_discount},
) )
if old_discount is None:
old_discount = Decimal(0)
return f"Discount for {self.billing_entity_display_name} changed to {new_discount}% from {old_discount}%."
def update_customer_sponsorship_status(self, sponsorship_pending: bool) -> None: def update_customer_sponsorship_status(self, sponsorship_pending: bool) -> str:
customer = self.get_customer() customer = self.get_customer()
if customer is None: if customer is None:
customer = self.update_or_create_customer() customer = self.update_or_create_customer()
@ -902,6 +922,14 @@ class BillingSession(ABC):
extra_data={"sponsorship_pending": sponsorship_pending}, extra_data={"sponsorship_pending": sponsorship_pending},
) )
if sponsorship_pending:
success_message = f"{self.billing_entity_display_name} marked as pending sponsorship."
else:
success_message = (
f"{self.billing_entity_display_name} is no longer pending sponsorship."
)
return success_message
def update_billing_modality_of_current_plan(self, charge_automatically: bool) -> None: def update_billing_modality_of_current_plan(self, charge_automatically: bool) -> None:
customer = self.get_customer() customer = self.get_customer()
if customer is not None: if customer is not None:
@ -1981,6 +2009,23 @@ class BillingSession(ABC):
context=context, context=context,
) )
def process_support_view_request(self, support_request: SupportViewRequest) -> str:
support_type = support_request["support_type"]
success_message = ""
if support_type == SupportType.approve_sponsorship:
success_message = self.approve_sponsorship()
elif support_type == SupportType.update_sponsorship_status:
assert support_request["sponsorship_status"] is not None
sponsorship_status = support_request["sponsorship_status"]
success_message = self.update_customer_sponsorship_status(sponsorship_status)
elif support_type == SupportType.attach_discount:
assert support_request["discount"] is not None
new_discount = support_request["discount"]
success_message = self.attach_discount_to_customer(new_discount)
return success_message
class RealmBillingSession(BillingSession): class RealmBillingSession(BillingSession):
def __init__( def __init__(
@ -2010,6 +2055,11 @@ class RealmBillingSession(BillingSession):
Realm.PLAN_TYPE_PLUS, Realm.PLAN_TYPE_PLUS,
] ]
@override
@property
def billing_entity_display_name(self) -> str:
return self.realm.string_id
@override @override
@property @property
def billing_session_url(self) -> str: def billing_session_url(self) -> str:
@ -2179,7 +2229,7 @@ class RealmBillingSession(BillingSession):
plan.save(update_fields=["status"]) plan.save(update_fields=["status"])
@override @override
def approve_sponsorship(self) -> None: def approve_sponsorship(self) -> str:
# Sponsorship approval is only a support admin action. # Sponsorship approval is only a support admin action.
assert self.support_session assert self.support_session
@ -2210,6 +2260,7 @@ class RealmBillingSession(BillingSession):
end_link="](/help/linking-to-zulip-website)", end_link="](/help/linking-to-zulip-website)",
) )
internal_send_private_message(notification_bot, user, message) internal_send_private_message(notification_bot, user, message)
return f"Sponsorship approved for {self.billing_entity_display_name}"
@override @override
def is_sponsored(self) -> bool: def is_sponsored(self) -> bool:
@ -2322,6 +2373,11 @@ class RemoteRealmBillingSession(BillingSession): # nocoverage
else: else:
self.support_session = False self.support_session = False
@override
@property
def billing_entity_display_name(self) -> str:
return self.remote_realm.name
@override @override
@property @property
def billing_session_url(self) -> str: def billing_session_url(self) -> str:
@ -2476,9 +2532,9 @@ class RemoteRealmBillingSession(BillingSession): # nocoverage
self.remote_realm.save(update_fields=["plan_type"]) self.remote_realm.save(update_fields=["plan_type"])
@override @override
def approve_sponsorship(self) -> None: def approve_sponsorship(self) -> str:
# TBD # TBD
pass return ""
@override @override
def is_sponsored(self) -> bool: def is_sponsored(self) -> bool:
@ -2595,6 +2651,11 @@ class RemoteServerBillingSession(BillingSession): # nocoverage
else: else:
self.support_session = False self.support_session = False
@override
@property
def billing_entity_display_name(self) -> str:
return self.remote_server.hostname
@override @override
@property @property
def billing_session_url(self) -> str: def billing_session_url(self) -> str:
@ -2739,9 +2800,9 @@ class RemoteServerBillingSession(BillingSession): # nocoverage
self.remote_server.save(update_fields=["plan_type"]) self.remote_server.save(update_fields=["plan_type"])
@override @override
def approve_sponsorship(self) -> None: def approve_sponsorship(self) -> str:
# TBD # TBD
pass return ""
@override @override
def process_downgrade(self, plan: CustomerPlan) -> None: def process_downgrade(self, plan: CustomerPlan) -> None:

View File

@ -26,23 +26,6 @@ def get_discount_for_realm(realm: Realm) -> Optional[Decimal]:
return None return None
def attach_discount_to_realm(realm: Realm, discount: Decimal, *, acting_user: UserProfile) -> None:
billing_session = RealmBillingSession(acting_user, realm, support_session=True)
billing_session.attach_discount_to_customer(discount)
def approve_realm_sponsorship(realm: Realm, *, acting_user: UserProfile) -> None:
billing_session = RealmBillingSession(acting_user, realm, support_session=True)
billing_session.approve_sponsorship()
def update_realm_sponsorship_status(
realm: Realm, sponsorship_pending: bool, *, acting_user: UserProfile
) -> None:
billing_session = RealmBillingSession(acting_user, realm, support_session=True)
billing_session.update_customer_sponsorship_status(sponsorship_pending)
def update_realm_billing_modality( def update_realm_billing_modality(
realm: Realm, charge_automatically: bool, *, acting_user: UserProfile realm: Realm, charge_automatically: bool, *, acting_user: UserProfile
) -> None: ) -> None:

View File

@ -75,12 +75,9 @@ from corporate.lib.stripe import (
void_all_open_invoices, void_all_open_invoices,
) )
from corporate.lib.support import ( from corporate.lib.support import (
approve_realm_sponsorship,
attach_discount_to_realm,
get_discount_for_realm, get_discount_for_realm,
switch_realm_from_standard_to_plus_plan, switch_realm_from_standard_to_plus_plan,
update_realm_billing_modality, update_realm_billing_modality,
update_realm_sponsorship_status,
) )
from corporate.models import ( from corporate.models import (
Customer, Customer,
@ -3756,7 +3753,10 @@ class StripeTest(StripeTestCase):
users_to_create=1, create_stripe_customer=False, create_plan=False users_to_create=1, create_stripe_customer=False, create_plan=False
) )
# To create local Customer object but no Stripe customer. # To create local Customer object but no Stripe customer.
attach_discount_to_realm(realm, Decimal(20), acting_user=self.example_user("iago")) billing_session = RealmBillingSession(
user=self.example_user("iago"), realm=realm, support_session=True
)
billing_session.attach_discount_to_customer(Decimal(20))
rows.append(Row(realm, Realm.PLAN_TYPE_SELF_HOSTED, None, None, False, False)) rows.append(Row(realm, Realm.PLAN_TYPE_SELF_HOSTED, None, None, False, False))
realm, _, _ = create_realm( realm, _, _ = create_realm(
@ -5108,11 +5108,12 @@ class TestRemoteServerBillingSession(StripeTestCase):
class TestSupportBillingHelpers(StripeTestCase): class TestSupportBillingHelpers(StripeTestCase):
def test_get_discount_for_realm(self) -> None: def test_get_discount_for_realm(self) -> None:
iago = self.example_user("iago") support_admin = self.example_user("iago")
user = self.example_user("hamlet") user = self.example_user("hamlet")
self.assertEqual(get_discount_for_realm(user.realm), None) self.assertEqual(get_discount_for_realm(user.realm), None)
attach_discount_to_realm(user.realm, Decimal(85), acting_user=iago) billing_session = RealmBillingSession(support_admin, realm=user.realm, support_session=True)
billing_session.attach_discount_to_customer(Decimal(85))
self.assertEqual(get_discount_for_realm(user.realm), 85) self.assertEqual(get_discount_for_realm(user.realm), 85)
@mock_stripe() @mock_stripe()
@ -5120,7 +5121,8 @@ class TestSupportBillingHelpers(StripeTestCase):
# Attach discount before Stripe customer exists # Attach discount before Stripe customer exists
support_admin = self.example_user("iago") support_admin = self.example_user("iago")
user = self.example_user("hamlet") user = self.example_user("hamlet")
attach_discount_to_realm(user.realm, Decimal(85), acting_user=support_admin) billing_session = RealmBillingSession(support_admin, realm=user.realm, support_session=True)
billing_session.attach_discount_to_customer(Decimal(85))
realm_audit_log = RealmAuditLog.objects.filter( realm_audit_log = RealmAuditLog.objects.filter(
event_type=RealmAuditLog.REALM_DISCOUNT_CHANGED event_type=RealmAuditLog.REALM_DISCOUNT_CHANGED
).last() ).last()
@ -5149,7 +5151,8 @@ class TestSupportBillingHelpers(StripeTestCase):
# Attach discount to existing Stripe customer # Attach discount to existing Stripe customer
plan.status = CustomerPlan.ENDED plan.status = CustomerPlan.ENDED
plan.save(update_fields=["status"]) plan.save(update_fields=["status"])
attach_discount_to_realm(user.realm, Decimal(25), acting_user=support_admin) billing_session = RealmBillingSession(support_admin, realm=user.realm, support_session=True)
billing_session.attach_discount_to_customer(Decimal(25))
with time_machine.travel(self.now, tick=False): with time_machine.travel(self.now, tick=False):
self.add_card_and_upgrade( self.add_card_and_upgrade(
user, license_management="automatic", billing_modality="charge_automatically" user, license_management="automatic", billing_modality="charge_automatically"
@ -5165,7 +5168,8 @@ class TestSupportBillingHelpers(StripeTestCase):
) )
plan = CustomerPlan.objects.get(price_per_license=6000, discount=Decimal(25)) plan = CustomerPlan.objects.get(price_per_license=6000, discount=Decimal(25))
attach_discount_to_realm(user.realm, Decimal(50), acting_user=support_admin) billing_session = RealmBillingSession(support_admin, realm=user.realm, support_session=True)
billing_session.attach_discount_to_customer(Decimal(50))
plan.refresh_from_db() plan.refresh_from_db()
self.assertEqual(plan.price_per_license, 4000) self.assertEqual(plan.price_per_license, 4000)
self.assertEqual(plan.discount, 50) self.assertEqual(plan.discount, 50)
@ -5192,7 +5196,8 @@ class TestSupportBillingHelpers(StripeTestCase):
self.assertNotEqual(realm.plan_type, Realm.PLAN_TYPE_STANDARD_FREE) self.assertNotEqual(realm.plan_type, Realm.PLAN_TYPE_STANDARD_FREE)
support_admin = self.example_user("iago") support_admin = self.example_user("iago")
approve_realm_sponsorship(realm, acting_user=support_admin) billing_session = RealmBillingSession(user=support_admin, realm=realm, support_session=True)
billing_session.approve_sponsorship()
self.assertEqual(realm.plan_type, Realm.PLAN_TYPE_STANDARD_FREE) self.assertEqual(realm.plan_type, Realm.PLAN_TYPE_STANDARD_FREE)
expected_message = ( expected_message = (
@ -5222,7 +5227,8 @@ class TestSupportBillingHelpers(StripeTestCase):
def test_update_realm_sponsorship_status(self) -> None: def test_update_realm_sponsorship_status(self) -> None:
lear = get_realm("lear") lear = get_realm("lear")
iago = self.example_user("iago") iago = self.example_user("iago")
update_realm_sponsorship_status(lear, True, acting_user=iago) billing_session = RealmBillingSession(user=iago, realm=lear, support_session=True)
billing_session.update_customer_sponsorship_status(True)
customer = get_customer_by_realm(realm=lear) customer = get_customer_by_realm(realm=lear)
assert customer is not None assert customer is not None
self.assertTrue(customer.sponsorship_pending) self.assertTrue(customer.sponsorship_pending)