support: Add updating minimum licenses requirement after discount.

Adds a support action for updating the minimum licenses on a
customer object once a default discount has also been set.

In the case that the current billing entity has a current active
plan or a scheduled upgrade to a new plan, then the minimum
licenses will not be updated.
This commit is contained in:
Lauryn Menard 2023-12-14 19:55:38 +01:00 committed by Tim Abbott
parent deaf734488
commit fb29a35262
30 changed files with 123 additions and 0 deletions

View File

@ -409,6 +409,7 @@ def remote_servers_support(
remote_server_id: Optional[int] = REQ(default=None, converter=to_non_negative_int),
remote_realm_id: Optional[int] = REQ(default=None, converter=to_non_negative_int),
discount: Optional[Decimal] = REQ(default=None, converter=to_decimal),
minimum_licenses: Optional[int] = REQ(default=None, converter=to_non_negative_int),
sponsorship_pending: Optional[bool] = REQ(default=None, json_validator=check_bool),
approve_sponsorship: bool = REQ(default=False, json_validator=check_bool),
billing_modality: Optional[str] = REQ(
@ -458,6 +459,11 @@ def remote_servers_support(
support_type=SupportType.attach_discount,
discount=discount,
)
elif minimum_licenses is not None:
support_view_request = SupportViewRequest(
support_type=SupportType.update_minimum_licenses,
minimum_licenses=minimum_licenses,
)
elif billing_modality is not None:
support_view_request = SupportViewRequest(
support_type=SupportType.update_billing_modality,

View File

@ -553,6 +553,7 @@ class SupportType(Enum):
attach_discount = 3
update_billing_modality = 4
modify_plan = 5
update_minimum_licenses = 6
class SupportViewRequest(TypedDict, total=False):
@ -562,6 +563,7 @@ class SupportViewRequest(TypedDict, total=False):
billing_modality: Optional[str]
plan_modification: Optional[str]
new_plan_tier: Optional[int]
minimum_licenses: Optional[int]
class AuditLogEventType(Enum):
@ -575,6 +577,7 @@ class AuditLogEventType(Enum):
CUSTOMER_SWITCHED_FROM_MONTHLY_TO_ANNUAL_PLAN = 8
CUSTOMER_SWITCHED_FROM_ANNUAL_TO_MONTHLY_PLAN = 9
BILLING_ENTITY_PLAN_TYPE_CHANGED = 10
MINIMUM_LICENSES_CHANGED = 11
class PlanTierChangeType(Enum):
@ -1085,6 +1088,46 @@ class BillingSession(ABC):
)
return f"Discount for {self.billing_entity_display_name} changed to {new_discount_string}% from {old_discount_string}%."
def update_customer_minimum_licenses(self, new_minimum_license_count: int) -> str:
previous_minimum_license_count = None
customer = self.get_customer()
# Currently, the support admin view shows the form for adding
# a minimum license count after a default discount has been set.
assert customer is not None
if customer.default_discount is None or int(customer.default_discount) == 0:
raise SupportRequestError(
f"Discount for {self.billing_entity_display_name} must be updated before setting a minimum number of licenses."
)
plan = get_current_plan_by_customer(customer)
if plan is not None and plan.tier != CustomerPlan.TIER_SELF_HOSTED_LEGACY:
raise SupportRequestError(
f"Cannot set minimum licenses; active plan already exists for {self.billing_entity_display_name}."
)
next_plan = self.get_legacy_remote_server_next_plan(customer)
if next_plan is not None: # nocoverage
raise SupportRequestError(
f"Cannot set minimum licenses; upgrade to new plan already scheduled for {self.billing_entity_display_name}."
)
previous_minimum_license_count = customer.minimum_licenses
customer.minimum_licenses = new_minimum_license_count
customer.save(update_fields=["minimum_licenses"])
self.write_to_audit_log(
event_type=AuditLogEventType.MINIMUM_LICENSES_CHANGED,
event_time=timezone_now(),
extra_data={
"old_minimum_licenses": previous_minimum_license_count,
"new_minimum_licenses": new_minimum_license_count,
},
)
if previous_minimum_license_count is None:
previous_minimum_license_count = 0
return f"Minimum licenses for {self.billing_entity_display_name} changed to {new_minimum_license_count} from {previous_minimum_license_count}."
def update_customer_sponsorship_status(self, sponsorship_pending: bool) -> str:
customer = self.get_customer()
if customer is None:
@ -2027,6 +2070,11 @@ class BillingSession(ABC):
return None, context
def min_licenses_for_plan(self, tier: int) -> int:
customer = self.get_customer()
if customer is not None and customer.minimum_licenses:
assert customer.default_discount is not None
return customer.minimum_licenses
if tier == CustomerPlan.TIER_SELF_HOSTED_BASIC:
return 10
if tier == CustomerPlan.TIER_SELF_HOSTED_BUSINESS:
@ -2587,6 +2635,10 @@ class BillingSession(ABC):
assert support_request["discount"] is not None
new_discount = support_request["discount"]
success_message = self.attach_discount_to_customer(new_discount)
elif support_type == SupportType.update_minimum_licenses:
assert support_request["minimum_licenses"] is not None
new_minimum_license_count = support_request["minimum_licenses"]
success_message = self.update_customer_minimum_licenses(new_minimum_license_count)
elif support_type == SupportType.update_billing_modality:
assert support_request["billing_modality"] is not None
assert support_request["billing_modality"] in VALID_BILLING_MODALITY_VALUES
@ -2909,6 +2961,8 @@ class RealmBillingSession(BillingSession):
return RealmAuditLog.CUSTOMER_PLAN_CREATED
elif event_type is AuditLogEventType.DISCOUNT_CHANGED:
return RealmAuditLog.REALM_DISCOUNT_CHANGED
elif event_type is AuditLogEventType.MINIMUM_LICENSES_CHANGED:
return RealmAuditLog.CUSTOMER_MINIMUM_LICENSES_CHANGED
elif event_type is AuditLogEventType.SPONSORSHIP_APPROVED:
return RealmAuditLog.REALM_SPONSORSHIP_APPROVED
elif event_type is AuditLogEventType.SPONSORSHIP_PENDING_STATUS_CHANGED:
@ -3265,6 +3319,8 @@ class RemoteRealmBillingSession(BillingSession):
return RemoteRealmAuditLog.CUSTOMER_PLAN_CREATED
elif event_type is AuditLogEventType.DISCOUNT_CHANGED: # nocoverage
return RemoteRealmAuditLog.REMOTE_SERVER_DISCOUNT_CHANGED
elif event_type is AuditLogEventType.MINIMUM_LICENSES_CHANGED:
return RemoteRealmAuditLog.CUSTOMER_MINIMUM_LICENSES_CHANGED # nocoverage
elif event_type is AuditLogEventType.SPONSORSHIP_APPROVED:
return RemoteRealmAuditLog.REMOTE_SERVER_SPONSORSHIP_APPROVED
elif event_type is AuditLogEventType.SPONSORSHIP_PENDING_STATUS_CHANGED:
@ -3675,6 +3731,8 @@ class RemoteServerBillingSession(BillingSession):
return RemoteZulipServerAuditLog.CUSTOMER_PLAN_CREATED
elif event_type is AuditLogEventType.DISCOUNT_CHANGED:
return RemoteZulipServerAuditLog.REMOTE_SERVER_DISCOUNT_CHANGED # nocoverage
elif event_type is AuditLogEventType.MINIMUM_LICENSES_CHANGED:
return RemoteZulipServerAuditLog.CUSTOMER_MINIMUM_LICENSES_CHANGED # nocoverage
elif event_type is AuditLogEventType.SPONSORSHIP_APPROVED:
return RemoteZulipServerAuditLog.REMOTE_SERVER_SPONSORSHIP_APPROVED
elif event_type is AuditLogEventType.SPONSORSHIP_PENDING_STATUS_CHANGED:

View File

@ -44,6 +44,7 @@ class SponsorshipRequestDict(TypedDict):
class SponsorshipData:
sponsorship_pending: bool = False
default_discount: Optional[Decimal] = None
minimum_licenses: Optional[int] = None
latest_sponsorship_request: Optional[SponsorshipRequestDict] = None
@ -89,6 +90,7 @@ def get_customer_discount_for_support_view(
def get_customer_sponsorship_data(customer: Customer) -> SponsorshipData:
pending = customer.sponsorship_pending
discount = customer.default_discount
licenses = customer.minimum_licenses
sponsorship_request = None
if pending:
last_sponsorship_request = (
@ -116,6 +118,7 @@ def get_customer_sponsorship_data(customer: Customer) -> SponsorshipData:
return SponsorshipData(
sponsorship_pending=pending,
default_discount=discount,
minimum_licenses=licenses,
latest_sponsorship_request=sponsorship_request,
)

View File

@ -5402,6 +5402,50 @@ class TestSupportBillingHelpers(StripeTestCase):
):
billing_session.approve_sponsorship()
@mock_stripe()
def test_add_minimum_licenses(self, *mocks: Mock) -> None:
min_licenses = 25
support_view_request = SupportViewRequest(
support_type=SupportType.update_minimum_licenses, minimum_licenses=min_licenses
)
support_admin = self.example_user("iago")
user = self.example_user("hamlet")
billing_session = RealmBillingSession(support_admin, realm=user.realm, support_session=True)
billing_session.update_or_create_customer()
with self.assertRaisesRegex(
SupportRequestError,
"Discount for zulip must be updated before setting a minimum number of licenses.",
):
billing_session.process_support_view_request(support_view_request)
billing_session.attach_discount_to_customer(Decimal(50))
message = billing_session.process_support_view_request(support_view_request)
self.assertEqual("Minimum licenses for zulip changed to 25 from 0.", message)
realm_audit_log = RealmAuditLog.objects.filter(
event_type=RealmAuditLog.CUSTOMER_MINIMUM_LICENSES_CHANGED
).last()
assert realm_audit_log is not None
expected_extra_data = {"old_minimum_licenses": None, "new_minimum_licenses": 25}
self.assertEqual(realm_audit_log.extra_data, expected_extra_data)
self.login_user(user)
self.add_card_and_upgrade(user)
customer = billing_session.get_customer()
assert customer is not None
[charge] = iter(stripe.Charge.list(customer=customer.stripe_customer_id))
self.assertEqual(4000 * min_licenses, charge.amount)
min_licenses = 50
support_view_request = SupportViewRequest(
support_type=SupportType.update_minimum_licenses, minimum_licenses=min_licenses
)
with self.assertRaisesRegex(
SupportRequestError,
"Cannot set minimum licenses; active plan already exists for zulip.",
):
billing_session.process_support_view_request(support_view_request)
def test_approve_realm_sponsorship(self) -> None:
realm = get_realm("zulip")
self.assertNotEqual(realm.plan_type, Realm.PLAN_TYPE_STANDARD_FREE)

View File

@ -33,6 +33,17 @@
{% endif %}
</form>
{% if not has_fixed_price and (sponsorship_data.default_discount or sponsorship_data.minimum_licenses) %}
<form method="POST" class="remote-form">
<b>Minimum licenses</b>:<br />
{{ csrf_input }}
<input type="hidden" name="{{ remote_type }}" value="{{ remote_id }}" />
<input type="number" name="minimum_licenses" value="{{ sponsorship_data.minimum_licenses }}" required />
<button type="submit" class="btn btn-default support-submit-button">Update</button>
</form>
{% endif %}
{% if sponsorship_data.sponsorship_pending %}
<div class="">
<h4>Sponsorship request information:</h4>

View File

@ -101,6 +101,7 @@ class AbstractRealmAuditLog(models.Model):
CUSTOMER_PLAN_CREATED = 502
CUSTOMER_SWITCHED_FROM_MONTHLY_TO_ANNUAL_PLAN = 503
CUSTOMER_SWITCHED_FROM_ANNUAL_TO_MONTHLY_PLAN = 504
CUSTOMER_MINIMUM_LICENSES_CHANGED = 505
STREAM_CREATED = 601
STREAM_DEACTIVATED = 602