mirror of https://github.com/zulip/zulip.git
billing: Allow free trial orgs to switch billing frequency.
Fixes #27855
This commit is contained in:
parent
1f8d3fc48f
commit
b35a792623
|
@ -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
|
||||
|
|
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
@ -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")
|
||||
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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=" +
|
||||
|
|
Loading…
Reference in New Issue