billing: Allow free trial orgs to switch billing frequency.

Fixes #27855
This commit is contained in:
Aman Agrawal 2023-11-26 14:41:28 +00:00 committed by Tim Abbott
parent 1f8d3fc48f
commit b35a792623
31 changed files with 210 additions and 9 deletions

View File

@ -491,6 +491,7 @@ class UpdatePlanRequest:
status: Optional[int]
licenses: Optional[int]
licenses_at_next_renewal: Optional[int]
schedule: Optional[int]
@dataclass
@ -1035,6 +1036,72 @@ class BillingSession(ABC):
data["stripe_payment_intent_id"] = stripe_payment_intent_id
return data
def do_change_schedule_after_free_trial(self, plan: CustomerPlan, schedule: int) -> None:
# Change the billing frequency of the plan after the free trial ends.
assert schedule in (CustomerPlan.MONTHLY, CustomerPlan.ANNUAL)
last_ledger_entry = LicenseLedger.objects.filter(plan=plan).order_by("-id").first()
assert last_ledger_entry is not None
licenses_at_next_renewal = last_ledger_entry.licenses_at_next_renewal
assert licenses_at_next_renewal is not None
assert plan.next_invoice_date is not None
next_billing_cycle = plan.next_invoice_date
if plan.fixed_price is not None: # nocoverage
raise BillingError("Customer is already on monthly fixed plan.")
plan.status = CustomerPlan.ENDED
plan.save(update_fields=["status"])
discount = plan.customer.default_discount or plan.discount
_, _, _, price_per_license = compute_plan_parameters(
tier=plan.tier,
automanage_licenses=plan.automanage_licenses,
billing_schedule=schedule,
discount=plan.discount,
)
new_plan = CustomerPlan.objects.create(
customer=plan.customer,
billing_schedule=schedule,
automanage_licenses=plan.automanage_licenses,
charge_automatically=plan.charge_automatically,
price_per_license=price_per_license,
discount=discount,
billing_cycle_anchor=plan.billing_cycle_anchor,
tier=plan.tier,
status=CustomerPlan.FREE_TRIAL,
next_invoice_date=next_billing_cycle,
invoiced_through=None,
invoicing_status=CustomerPlan.INITIAL_INVOICE_TO_BE_SENT,
)
LicenseLedger.objects.create(
plan=new_plan,
is_renewal=True,
event_time=plan.billing_cycle_anchor,
licenses=licenses_at_next_renewal,
licenses_at_next_renewal=licenses_at_next_renewal,
)
if schedule == CustomerPlan.ANNUAL:
self.write_to_audit_log(
event_type=AuditLogEventType.CUSTOMER_SWITCHED_FROM_MONTHLY_TO_ANNUAL_PLAN,
event_time=timezone_now(),
extra_data={
"monthly_plan_id": plan.id,
"annual_plan_id": new_plan.id,
},
)
else:
self.write_to_audit_log(
event_type=AuditLogEventType.CUSTOMER_SWITCHED_FROM_ANNUAL_TO_MONTHLY_PLAN,
event_time=timezone_now(),
extra_data={
"annual_plan_id": plan.id,
"monthly_plan_id": new_plan.id,
},
)
def get_next_billing_cycle(self, plan: CustomerPlan) -> datetime:
last_ledger_renewal = (
LicenseLedger.objects.filter(plan=plan, is_renewal=True).order_by("-id").first()
@ -1061,8 +1128,9 @@ class BillingSession(ABC):
) -> Tuple[Optional[CustomerPlan], Optional[LicenseLedger]]:
last_ledger_entry = LicenseLedger.objects.filter(plan=plan).order_by("-id").first()
next_billing_cycle = self.get_next_billing_cycle(plan)
event_in_next_billing_cycle = next_billing_cycle <= event_time
if next_billing_cycle <= event_time and last_ledger_entry is not None:
if event_in_next_billing_cycle and last_ledger_entry is not None:
licenses_at_next_renewal = last_ledger_entry.licenses_at_next_renewal
assert licenses_at_next_renewal is not None
if plan.status == CustomerPlan.ACTIVE:
@ -1187,6 +1255,7 @@ class BillingSession(ABC):
if plan.status == CustomerPlan.DOWNGRADE_AT_END_OF_CYCLE:
self.process_downgrade(plan)
return None, None
return None, last_ledger_entry
@ -1441,8 +1510,11 @@ class BillingSession(ABC):
assert plan.is_free_trial()
do_change_plan_status(plan, status)
elif status == CustomerPlan.FREE_TRIAL:
assert plan.status == CustomerPlan.DOWNGRADE_AT_END_OF_FREE_TRIAL
do_change_plan_status(plan, status)
if update_plan_request.schedule is not None:
self.do_change_schedule_after_free_trial(plan, update_plan_request.schedule)
else:
assert plan.status == CustomerPlan.DOWNGRADE_AT_END_OF_FREE_TRIAL
do_change_plan_status(plan, status)
return
licenses = update_plan_request.licenses

View File

@ -2839,6 +2839,115 @@ class StripeTest(StripeTestCase):
self.assertIsNone(plan.next_invoice_date)
self.assertEqual(plan.status, CustomerPlan.ENDED)
@mock_stripe()
def test_switch_now_free_trial_from_monthly_to_annual(self, *mocks: Mock) -> None:
user = self.example_user("hamlet")
self.login_user(user)
free_trial_end_date = self.now + timedelta(days=60)
with self.settings(FREE_TRIAL_DAYS=60):
with patch("corporate.lib.stripe.timezone_now", return_value=self.now):
self.add_card_and_upgrade(user, schedule="monthly")
plan = CustomerPlan.objects.get()
self.assertEqual(plan.next_invoice_date, free_trial_end_date)
self.assertEqual(get_realm("zulip").plan_type, Realm.PLAN_TYPE_STANDARD)
self.assertEqual(plan.status, CustomerPlan.FREE_TRIAL)
customer = get_customer_by_realm(user.realm)
assert customer is not None
result = self.client_patch(
"/json/billing/plan",
{
"status": CustomerPlan.FREE_TRIAL,
"schedule": CustomerPlan.ANNUAL,
},
)
self.assert_json_success(result)
plan.refresh_from_db()
self.assertEqual(plan.status, CustomerPlan.ENDED)
plan = CustomerPlan.objects.get(
customer=customer,
automanage_licenses=True,
price_per_license=8000,
fixed_price=None,
discount=None,
billing_cycle_anchor=self.now,
billing_schedule=CustomerPlan.ANNUAL,
invoiced_through=None,
next_invoice_date=free_trial_end_date,
tier=CustomerPlan.STANDARD,
status=CustomerPlan.FREE_TRIAL,
charge_automatically=True,
)
LicenseLedger.objects.get(
plan=plan,
is_renewal=True,
event_time=self.now,
licenses=self.seat_count,
licenses_at_next_renewal=self.seat_count,
)
realm_audit_log = RealmAuditLog.objects.filter(
event_type=RealmAuditLog.CUSTOMER_SWITCHED_FROM_MONTHLY_TO_ANNUAL_PLAN
).last()
assert realm_audit_log is not None
@mock_stripe()
def test_switch_now_free_trial_from_annual_to_monthly(self, *mocks: Mock) -> None:
user = self.example_user("hamlet")
self.login_user(user)
free_trial_end_date = self.now + timedelta(days=60)
with self.settings(FREE_TRIAL_DAYS=60):
with patch("corporate.lib.stripe.timezone_now", return_value=self.now):
self.add_card_and_upgrade(user, schedule="annual")
plan = CustomerPlan.objects.get()
self.assertEqual(plan.next_invoice_date, free_trial_end_date)
self.assertEqual(get_realm("zulip").plan_type, Realm.PLAN_TYPE_STANDARD)
self.assertEqual(plan.status, CustomerPlan.FREE_TRIAL)
customer = get_customer_by_realm(user.realm)
assert customer is not None
result = self.client_patch(
"/json/billing/plan",
{
"status": CustomerPlan.FREE_TRIAL,
"schedule": CustomerPlan.MONTHLY,
},
)
self.assert_json_success(result)
plan.refresh_from_db()
self.assertEqual(plan.status, CustomerPlan.ENDED)
plan = CustomerPlan.objects.get(
customer=customer,
automanage_licenses=True,
price_per_license=800,
fixed_price=None,
discount=None,
billing_cycle_anchor=self.now,
billing_schedule=CustomerPlan.MONTHLY,
invoiced_through=None,
next_invoice_date=free_trial_end_date,
tier=CustomerPlan.STANDARD,
status=CustomerPlan.FREE_TRIAL,
charge_automatically=True,
)
LicenseLedger.objects.get(
plan=plan,
is_renewal=True,
event_time=self.now,
licenses=self.seat_count,
licenses_at_next_renewal=self.seat_count,
)
realm_audit_log = RealmAuditLog.objects.filter(
event_type=RealmAuditLog.CUSTOMER_SWITCHED_FROM_ANNUAL_TO_MONTHLY_PLAN
).last()
assert realm_audit_log is not None
def test_end_free_trial(self) -> None:
user = self.example_user("hamlet")

View File

@ -110,11 +110,13 @@ def update_plan(
licenses_at_next_renewal: Optional[int] = REQ(
"licenses_at_next_renewal", json_validator=check_int, default=None
),
schedule: Optional[int] = REQ("schedule", json_validator=check_int, default=None),
) -> HttpResponse:
update_plan_request = UpdatePlanRequest(
status=status,
licenses=licenses,
licenses_at_next_renewal=licenses_at_next_renewal,
schedule=schedule,
)
billing_session = RealmBillingSession(user=user)
billing_session.do_update_plan(update_plan_request)

View File

@ -41,7 +41,7 @@
{%if switch_to_monthly_at_end_of_cycle %}data-switch-to-monthly-eoc="true"{% endif %}
{%if switch_to_annual_at_end_of_cycle %}data-switch-to-annual-eoc="true"{% endif %}>
<label for="org-billing-frequency">Billing frequency</label>
{% if free_trial or downgrade_at_end_of_free_trial or downgrade_at_end_of_cycle %}
{% if downgrade_at_end_of_free_trial or downgrade_at_end_of_cycle %}
<div class="not-editable-realm-field">
{{ billing_frequency }}
</div>

View File

@ -7,6 +7,12 @@ import * as helpers from "./helpers";
const billing_frequency_schema = z.enum(["Monthly", "Annual"]);
// Matches the CustomerPlan model in the backend.
enum BillingFrequency {
ANNUAL = 1,
MONTHLY = 2,
}
enum CustomerPlanStatus {
ACTIVE = 1,
DOWNGRADE_AT_END_OF_CYCLE = 2,
@ -290,24 +296,36 @@ export function initialize(): void {
$("#org-billing-frequency-confirm-button").attr("data-status", new_status);
} else if (current_billing_frequency !== billing_frequency_selected) {
$("#org-billing-frequency-confirm-button").toggleClass("hide", false);
let new_status = CustomerPlanStatus.SWITCH_TO_ANNUAL_AT_END_OF_CYCLE;
let new_status = free_trial
? CustomerPlanStatus.FREE_TRIAL
: CustomerPlanStatus.SWITCH_TO_ANNUAL_AT_END_OF_CYCLE;
let new_schedule = BillingFrequency.ANNUAL;
if (billing_frequency_selected === "Monthly") {
new_status = CustomerPlanStatus.SWITCH_TO_MONTHLY_AT_END_OF_CYCLE;
new_status = free_trial
? CustomerPlanStatus.FREE_TRIAL
: CustomerPlanStatus.SWITCH_TO_MONTHLY_AT_END_OF_CYCLE;
new_schedule = BillingFrequency.MONTHLY;
}
$("#org-billing-frequency-confirm-button").attr("data-status", new_status);
if (free_trial) {
// Only set schedule for free trial since it is a different process to update the frequency immediately.
$("#org-billing-frequency-confirm-button").attr("data-schedule", new_schedule);
}
} else {
$("#org-billing-frequency-confirm-button").toggleClass("hide", true);
}
});
$("#org-billing-frequency-confirm-button").on("click", (e) => {
const data = {
status: $("#org-billing-frequency-confirm-button").attr("data-status"),
schedule: $("#org-billing-frequency-confirm-button").attr("data-schedule"),
};
e.preventDefault();
void $.ajax({
type: "patch",
url: "/json/billing/plan",
data: {
status: $("#org-billing-frequency-confirm-button").attr("data-status"),
},
data,
success() {
window.location.replace(
"/billing/?success_message=" +