stripe: Allow customer to switch license management type.

Fixes #28633

Added a button to switch license management type on billing page.

Tested that the plan switch works correctly.

Tested that when switching from manual to automatic license
management, customer is only billed for billable users for the
next billing cycle.
This commit is contained in:
Aman Agrawal 2024-09-26 02:23:56 +00:00 committed by Tim Abbott
parent b7e02436b8
commit 790d5c44a1
6 changed files with 137 additions and 16 deletions

View File

@ -558,6 +558,7 @@ class UpdatePlanRequest:
licenses: int | None
licenses_at_next_renewal: int | None
schedule: int | None
toggle_license_management: bool
@dataclass
@ -2896,6 +2897,16 @@ class BillingSession(ABC):
if last_ledger_entry is None:
raise JsonableError(_("Unable to update the plan. The plan has ended."))
if update_plan_request.toggle_license_management:
assert update_plan_request.status is None
assert update_plan_request.licenses is None
assert update_plan_request.licenses_at_next_renewal is None
assert update_plan_request.schedule is None
plan.automanage_licenses = not plan.automanage_licenses
plan.save(update_fields=["automanage_licenses"])
return
status = update_plan_request.status
if status is not None:
if status == CustomerPlan.ACTIVE:

View File

@ -44,6 +44,7 @@ from corporate.lib.stripe import (
SupportRequestError,
SupportType,
SupportViewRequest,
UpdatePlanRequest,
add_months,
catch_stripe_errors,
compute_plan_parameters,
@ -1000,7 +1001,7 @@ class StripeTest(StripeTestCase):
"Zulip Cloud Plus",
str(self.seat_count),
"Number of licenses",
f"{ self.seat_count } (managed automatically)",
f"{ self.seat_count }",
"Your plan will automatically renew on",
"January 2, 2013",
f"${120 * self.seat_count}.00",
@ -1270,7 +1271,7 @@ class StripeTest(StripeTestCase):
"Zulip Cloud Standard",
str(self.seat_count),
"Number of licenses",
f"{ self.seat_count } (managed automatically)",
f"{ self.seat_count }",
"Your plan will automatically renew on",
"January 2, 2013",
f"${80 * self.seat_count}.00",
@ -1526,7 +1527,7 @@ class StripeTest(StripeTestCase):
"Zulip Cloud Standard <i>(free trial)</i>",
str(self.seat_count),
"Number of licenses",
f"{self.seat_count} (managed automatically)",
f"{self.seat_count}",
"Your plan will automatically renew on",
"March 2, 2012",
f"${80 * self.seat_count}.00",
@ -5879,6 +5880,29 @@ class LicenseLedgerTest(StripeTestCase):
],
)
def test_toggle_license_management(self) -> None:
self.local_upgrade(self.seat_count, True, CustomerPlan.BILLING_SCHEDULE_ANNUAL, True, False)
plan = get_current_plan_by_realm(get_realm("zulip"))
assert plan is not None
self.assertEqual(plan.automanage_licenses, True)
self.assertEqual(plan.licenses(), self.seat_count)
self.assertEqual(plan.licenses_at_next_renewal(), self.seat_count)
billing_session = RealmBillingSession(user=None, realm=get_realm("zulip"))
update_plan_request = UpdatePlanRequest(
status=None,
licenses=None,
licenses_at_next_renewal=None,
schedule=None,
toggle_license_management=True,
)
billing_session.do_update_plan(update_plan_request)
plan.refresh_from_db()
self.assertEqual(plan.automanage_licenses, False)
billing_session.do_update_plan(update_plan_request)
plan.refresh_from_db()
self.assertEqual(plan.automanage_licenses, True)
class InvoiceTest(StripeTestCase):
def test_invoicing_status_is_started(self) -> None:
@ -6954,7 +6978,7 @@ class TestRemoteRealmBillingFlow(StripeTestCase, RemoteRealmBillingTestCase):
for substring in [
"Zulip Business",
"Number of licenses",
f"{min_licenses} (managed automatically)",
f"{min_licenses}",
"January 2, 2013",
"Your plan will automatically renew on",
f"${80 * min_licenses:,.2f}",
@ -6998,7 +7022,7 @@ class TestRemoteRealmBillingFlow(StripeTestCase, RemoteRealmBillingTestCase):
for substring in [
"Zulip Business",
"Number of licenses",
f"{latest_ledger.licenses} (managed automatically)",
f"{latest_ledger.licenses}",
"January 2, 2013",
"Your plan will automatically renew on",
f"${80 * latest_ledger.licenses:,.2f}",
@ -7181,7 +7205,7 @@ class TestRemoteRealmBillingFlow(StripeTestCase, RemoteRealmBillingTestCase):
"Zulip Basic",
"(free trial)",
"Number of licenses",
f"{realm_user_count} (managed automatically)",
f"{realm_user_count}",
"February 1, 2012",
"Your plan will automatically renew on",
f"${3.5 * realm_user_count - flat_discount // 100 * 1:,.2f}",
@ -7225,7 +7249,7 @@ class TestRemoteRealmBillingFlow(StripeTestCase, RemoteRealmBillingTestCase):
for substring in [
"Zulip Basic",
"Number of licenses",
f"{latest_ledger.licenses} (managed automatically)",
f"{latest_ledger.licenses}",
"February 1, 2012",
"Your plan will automatically renew on",
f"${3.5 * latest_ledger.licenses - flat_discount // 100 * 1:,.2f}",
@ -7389,7 +7413,7 @@ class TestRemoteRealmBillingFlow(StripeTestCase, RemoteRealmBillingTestCase):
for substring in [
"Zulip Basic",
"Number of licenses",
f"{realm_user_count} (managed automatically)",
f"{realm_user_count}",
"February 2, 2012",
"Your plan will automatically renew on",
f"${3.5 * realm_user_count - flat_discount // 100 * 1:,.2f}",
@ -7433,7 +7457,7 @@ class TestRemoteRealmBillingFlow(StripeTestCase, RemoteRealmBillingTestCase):
for substring in [
"Zulip Basic",
"Number of licenses",
f"{latest_ledger.licenses} (managed automatically)",
f"{latest_ledger.licenses}",
"February 2, 2012",
"Your plan will automatically renew on",
f"${3.5 * latest_ledger.licenses - flat_discount // 100 * 1:,.2f}",
@ -8292,7 +8316,7 @@ class TestRemoteRealmBillingFlow(StripeTestCase, RemoteRealmBillingTestCase):
for substring in [
"Zulip Business",
"Number of licenses",
f"{licenses} (managed automatically)",
f"{licenses}",
"Your plan will automatically renew on",
"January 2, 2013",
f"${80 * licenses:,.2f}",
@ -8641,7 +8665,7 @@ class TestRemoteServerBillingFlow(StripeTestCase, RemoteServerTestCase):
for substring in [
"Zulip Business",
"Number of licenses",
f"{25} (managed automatically)",
f"{25}",
"Your plan will automatically renew on",
"January 2, 2013",
f"${80 * 25:,.2f}",
@ -9075,7 +9099,7 @@ class TestRemoteServerBillingFlow(StripeTestCase, RemoteServerTestCase):
"Zulip Basic",
"(free trial)",
"Number of licenses",
f"{realm_user_count} (managed automatically)",
f"{realm_user_count}",
"February 1, 2012",
"Your plan will automatically renew on",
f"${3.5 * realm_user_count - flat_discount // 100 * 1:,.2f}",
@ -9119,7 +9143,7 @@ class TestRemoteServerBillingFlow(StripeTestCase, RemoteServerTestCase):
for substring in [
"Zulip Basic",
"Number of licenses",
f"{latest_ledger.licenses} (managed automatically)",
f"{latest_ledger.licenses}",
"February 1, 2012",
"Your plan will automatically renew on",
f"${3.5 * latest_ledger.licenses - flat_discount // 100 * 1:,.2f}",
@ -9285,7 +9309,7 @@ class TestRemoteServerBillingFlow(StripeTestCase, RemoteServerTestCase):
for substring in [
"Zulip Basic",
"Number of licenses",
f"{server_user_count} (managed automatically)",
f"{server_user_count}",
"February 2, 2012",
"Your plan will automatically renew on",
f"${3.5 * server_user_count - flat_discount // 100 * 1:,.2f}",
@ -9329,7 +9353,7 @@ class TestRemoteServerBillingFlow(StripeTestCase, RemoteServerTestCase):
for substring in [
"Zulip Basic",
"Number of licenses",
f"{latest_ledger.licenses} (managed automatically)",
f"{latest_ledger.licenses}",
"February 2, 2012",
"Your plan will automatically renew on",
f"${3.5 * latest_ledger.licenses - flat_discount // 100 * 1:,.2f}",

View File

@ -243,6 +243,7 @@ def update_plan(
licenses: Json[int] | None = None,
licenses_at_next_renewal: Json[int] | None = None,
schedule: Json[int] | None = None,
toggle_license_management: Json[bool] = False,
) -> HttpResponse:
from corporate.lib.stripe import RealmBillingSession, UpdatePlanRequest
@ -251,6 +252,7 @@ def update_plan(
licenses=licenses,
licenses_at_next_renewal=licenses_at_next_renewal,
schedule=schedule,
toggle_license_management=toggle_license_management,
)
billing_session = RealmBillingSession(user=user)
billing_session.do_update_plan(update_plan_request)
@ -271,6 +273,7 @@ def update_plan_for_remote_realm(
licenses: Json[int] | None = None,
licenses_at_next_renewal: Json[int] | None = None,
schedule: Json[int] | None = None,
toggle_license_management: Json[bool] = False,
) -> HttpResponse:
from corporate.lib.stripe import UpdatePlanRequest
@ -279,6 +282,7 @@ def update_plan_for_remote_realm(
licenses=licenses,
licenses_at_next_renewal=licenses_at_next_renewal,
schedule=schedule,
toggle_license_management=toggle_license_management,
)
billing_session.do_update_plan(update_plan_request)
return json_success(request)
@ -298,6 +302,7 @@ def update_plan_for_remote_server(
licenses: Json[int] | None = None,
licenses_at_next_renewal: Json[int] | None = None,
schedule: Json[int] | None = None,
toggle_license_management: Json[bool] = False,
) -> HttpResponse:
from corporate.lib.stripe import UpdatePlanRequest
@ -306,6 +311,7 @@ def update_plan_for_remote_server(
licenses=licenses,
licenses_at_next_renewal=licenses_at_next_renewal,
schedule=schedule,
toggle_license_management=toggle_license_management,
)
billing_session.do_update_plan(update_plan_request)
return json_success(request)

View File

@ -123,7 +123,13 @@
</label>
<div id="automatic-license-count" class="not-editable-realm-field">
{{ licenses }} (managed automatically)
{{ licenses }}
<br />
Licenses are managed
<a href="https://zulip.com/help/self-hosted-billing#how-does-automatic-license-management-work">automatically</a>.
You can
<a class="toggle-license-management" type="button">switch</a>
to manual license management.
</div>
</div>
{% else %}
@ -152,6 +158,13 @@
</button>
</div>
<div id="current-license-change-error" class="alert alert-danger billing-page-error"></div>
<div class="not-editable-realm-field billing-page-license-management-description">
Licenses are managed
<a href="https://zulip.com/help/self-hosted-billing#how-does-manual-license-management-work">manually</a>.
You can
<a class="toggle-license-management" type="button">switch</a>
to automatic license management.
</div>
</div>
{% endif %}
{% if not (downgrade_at_end_of_cycle or downgrade_at_end_of_free_trial) %}
@ -182,6 +195,10 @@
</div>
{% endif %}
{% endif %}
<form id="toggle-license-management-form">
<input name="toggle_license_management" type="hidden" value="true" />
</form>
<div id="toggle-license-management-error" class="alert alert-danger"></div>
<div class="input-box no-validation billing-page-field">
<label for="billing-contact" class="inline-block label-title">Billing contact</label>
<div id="billing-contact" class="not-editable-realm-field">
@ -571,4 +588,41 @@
</div>
</div>
</div>
<div id="confirm-toggle-license-management-modal" class="micromodal" aria-hidden="true">
<div class="modal__overlay" tabindex="-1">
<div class="modal__container" role="dialog" aria-modal="true" aria-labelledby="dialog_title">
<header class="modal__header">
<h1 class="modal__title dialog_heading">
Confirm switching to
{% if automanage_licenses %}
manual
{% else %}
automatic
{% endif %}
license management
</h1>
<button class="modal__close" aria-label="{{ _('Close modal') }}" data-micromodal-close></button>
</header>
<main class="modal__content">
<p>
Are you sure you want to switch to
<a href="https://zulip.com/help/self-hosted-billing#how-does-automatic-license-management-work">
{% if automanage_licenses %}
manual
{% else %}
automatic
{% endif %}
license management
</a>?
</p>
</main>
<footer class="modal__footer">
<button class="modal__btn dialog_exit_button" aria-label="{{ '(Close this dialog window)' }}" data-micromodal-close>{{ _('Never mind') }}</button>
<button class="modal__btn dialog_submit_button">
<span>{{ _('Confirm') }}</span>
</button>
</footer>
</div>
</div>
</div>
{% endblock %}

View File

@ -460,6 +460,24 @@ export function initialize(): void {
},
});
});
$(".toggle-license-management").on("click", (e) => {
e.preventDefault();
portico_modals.open("confirm-toggle-license-management-modal");
});
$("#confirm-toggle-license-management-modal").on("click", ".dialog_submit_button", (e) => {
helpers.create_ajax_request(
`/json${billing_base_url}/billing/plan`,
"toggle-license-management",
[],
"PATCH",
() => {
window.location.replace(`${billing_base_url}/billing/`);
},
);
e.preventDefault();
});
}
$(() => {

View File

@ -748,3 +748,11 @@ input[name="licenses"] {
.flat-discount-separator {
border-bottom: 1px solid hsl(0deg 0% 0%);
}
.toggle-license-management {
cursor: pointer;
}
#billing-page-details .billing-page-license-management-description {
padding-top: 5px;
}