diff --git a/corporate/lib/stripe.py b/corporate/lib/stripe.py index de9effd72a..2496db84fa 100644 --- a/corporate/lib/stripe.py +++ b/corporate/lib/stripe.py @@ -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 diff --git a/corporate/tests/stripe_fixtures/switch_now_free_trial_from_annual_to_monthly--Customer.create.1.json b/corporate/tests/stripe_fixtures/switch_now_free_trial_from_annual_to_monthly--Customer.create.1.json new file mode 100644 index 0000000000..e6bfc068d2 Binary files /dev/null and b/corporate/tests/stripe_fixtures/switch_now_free_trial_from_annual_to_monthly--Customer.create.1.json differ diff --git a/corporate/tests/stripe_fixtures/switch_now_free_trial_from_annual_to_monthly--Customer.modify.1.json b/corporate/tests/stripe_fixtures/switch_now_free_trial_from_annual_to_monthly--Customer.modify.1.json new file mode 100644 index 0000000000..f506b12496 Binary files /dev/null and b/corporate/tests/stripe_fixtures/switch_now_free_trial_from_annual_to_monthly--Customer.modify.1.json differ diff --git a/corporate/tests/stripe_fixtures/switch_now_free_trial_from_annual_to_monthly--Customer.retrieve.1.json b/corporate/tests/stripe_fixtures/switch_now_free_trial_from_annual_to_monthly--Customer.retrieve.1.json new file mode 100644 index 0000000000..da4fd36213 Binary files /dev/null and b/corporate/tests/stripe_fixtures/switch_now_free_trial_from_annual_to_monthly--Customer.retrieve.1.json differ diff --git a/corporate/tests/stripe_fixtures/switch_now_free_trial_from_annual_to_monthly--Customer.retrieve.2.json b/corporate/tests/stripe_fixtures/switch_now_free_trial_from_annual_to_monthly--Customer.retrieve.2.json new file mode 100644 index 0000000000..da4fd36213 Binary files /dev/null and b/corporate/tests/stripe_fixtures/switch_now_free_trial_from_annual_to_monthly--Customer.retrieve.2.json differ diff --git a/corporate/tests/stripe_fixtures/switch_now_free_trial_from_annual_to_monthly--Customer.retrieve.3.json b/corporate/tests/stripe_fixtures/switch_now_free_trial_from_annual_to_monthly--Customer.retrieve.3.json new file mode 100644 index 0000000000..da4fd36213 Binary files /dev/null and b/corporate/tests/stripe_fixtures/switch_now_free_trial_from_annual_to_monthly--Customer.retrieve.3.json differ diff --git a/corporate/tests/stripe_fixtures/switch_now_free_trial_from_annual_to_monthly--Customer.retrieve.4.json b/corporate/tests/stripe_fixtures/switch_now_free_trial_from_annual_to_monthly--Customer.retrieve.4.json new file mode 100644 index 0000000000..da4fd36213 Binary files /dev/null and b/corporate/tests/stripe_fixtures/switch_now_free_trial_from_annual_to_monthly--Customer.retrieve.4.json differ diff --git a/corporate/tests/stripe_fixtures/switch_now_free_trial_from_annual_to_monthly--Event.list.1.json b/corporate/tests/stripe_fixtures/switch_now_free_trial_from_annual_to_monthly--Event.list.1.json new file mode 100644 index 0000000000..2f0d7c7ef2 Binary files /dev/null and b/corporate/tests/stripe_fixtures/switch_now_free_trial_from_annual_to_monthly--Event.list.1.json differ diff --git a/corporate/tests/stripe_fixtures/switch_now_free_trial_from_annual_to_monthly--Invoice.list.1.json b/corporate/tests/stripe_fixtures/switch_now_free_trial_from_annual_to_monthly--Invoice.list.1.json new file mode 100644 index 0000000000..e39960ab72 Binary files /dev/null and b/corporate/tests/stripe_fixtures/switch_now_free_trial_from_annual_to_monthly--Invoice.list.1.json differ diff --git a/corporate/tests/stripe_fixtures/switch_now_free_trial_from_annual_to_monthly--SetupIntent.create.1.json b/corporate/tests/stripe_fixtures/switch_now_free_trial_from_annual_to_monthly--SetupIntent.create.1.json new file mode 100644 index 0000000000..6caff42c11 Binary files /dev/null and b/corporate/tests/stripe_fixtures/switch_now_free_trial_from_annual_to_monthly--SetupIntent.create.1.json differ diff --git a/corporate/tests/stripe_fixtures/switch_now_free_trial_from_annual_to_monthly--SetupIntent.list.1.json b/corporate/tests/stripe_fixtures/switch_now_free_trial_from_annual_to_monthly--SetupIntent.list.1.json new file mode 100644 index 0000000000..c8b8146b34 Binary files /dev/null and b/corporate/tests/stripe_fixtures/switch_now_free_trial_from_annual_to_monthly--SetupIntent.list.1.json differ diff --git a/corporate/tests/stripe_fixtures/switch_now_free_trial_from_annual_to_monthly--SetupIntent.retrieve.1.json b/corporate/tests/stripe_fixtures/switch_now_free_trial_from_annual_to_monthly--SetupIntent.retrieve.1.json new file mode 100644 index 0000000000..6caff42c11 Binary files /dev/null and b/corporate/tests/stripe_fixtures/switch_now_free_trial_from_annual_to_monthly--SetupIntent.retrieve.1.json differ diff --git a/corporate/tests/stripe_fixtures/switch_now_free_trial_from_annual_to_monthly--checkout.Session.create.1.json b/corporate/tests/stripe_fixtures/switch_now_free_trial_from_annual_to_monthly--checkout.Session.create.1.json new file mode 100644 index 0000000000..d977a670ff Binary files /dev/null and b/corporate/tests/stripe_fixtures/switch_now_free_trial_from_annual_to_monthly--checkout.Session.create.1.json differ diff --git a/corporate/tests/stripe_fixtures/switch_now_free_trial_from_annual_to_monthly--checkout.Session.list.1.json b/corporate/tests/stripe_fixtures/switch_now_free_trial_from_annual_to_monthly--checkout.Session.list.1.json new file mode 100644 index 0000000000..80924c51f9 Binary files /dev/null and b/corporate/tests/stripe_fixtures/switch_now_free_trial_from_annual_to_monthly--checkout.Session.list.1.json differ diff --git a/corporate/tests/stripe_fixtures/switch_now_free_trial_from_monthly_to_annual--Customer.create.1.json b/corporate/tests/stripe_fixtures/switch_now_free_trial_from_monthly_to_annual--Customer.create.1.json new file mode 100644 index 0000000000..593a07ed0c Binary files /dev/null and b/corporate/tests/stripe_fixtures/switch_now_free_trial_from_monthly_to_annual--Customer.create.1.json differ diff --git a/corporate/tests/stripe_fixtures/switch_now_free_trial_from_monthly_to_annual--Customer.modify.1.json b/corporate/tests/stripe_fixtures/switch_now_free_trial_from_monthly_to_annual--Customer.modify.1.json new file mode 100644 index 0000000000..ed6c986db1 Binary files /dev/null and b/corporate/tests/stripe_fixtures/switch_now_free_trial_from_monthly_to_annual--Customer.modify.1.json differ diff --git a/corporate/tests/stripe_fixtures/switch_now_free_trial_from_monthly_to_annual--Customer.retrieve.1.json b/corporate/tests/stripe_fixtures/switch_now_free_trial_from_monthly_to_annual--Customer.retrieve.1.json new file mode 100644 index 0000000000..dd3c063386 Binary files /dev/null and b/corporate/tests/stripe_fixtures/switch_now_free_trial_from_monthly_to_annual--Customer.retrieve.1.json differ diff --git a/corporate/tests/stripe_fixtures/switch_now_free_trial_from_monthly_to_annual--Customer.retrieve.2.json b/corporate/tests/stripe_fixtures/switch_now_free_trial_from_monthly_to_annual--Customer.retrieve.2.json new file mode 100644 index 0000000000..dd3c063386 Binary files /dev/null and b/corporate/tests/stripe_fixtures/switch_now_free_trial_from_monthly_to_annual--Customer.retrieve.2.json differ diff --git a/corporate/tests/stripe_fixtures/switch_now_free_trial_from_monthly_to_annual--Customer.retrieve.3.json b/corporate/tests/stripe_fixtures/switch_now_free_trial_from_monthly_to_annual--Customer.retrieve.3.json new file mode 100644 index 0000000000..dd3c063386 Binary files /dev/null and b/corporate/tests/stripe_fixtures/switch_now_free_trial_from_monthly_to_annual--Customer.retrieve.3.json differ diff --git a/corporate/tests/stripe_fixtures/switch_now_free_trial_from_monthly_to_annual--Customer.retrieve.4.json b/corporate/tests/stripe_fixtures/switch_now_free_trial_from_monthly_to_annual--Customer.retrieve.4.json new file mode 100644 index 0000000000..dd3c063386 Binary files /dev/null and b/corporate/tests/stripe_fixtures/switch_now_free_trial_from_monthly_to_annual--Customer.retrieve.4.json differ diff --git a/corporate/tests/stripe_fixtures/switch_now_free_trial_from_monthly_to_annual--Event.list.1.json b/corporate/tests/stripe_fixtures/switch_now_free_trial_from_monthly_to_annual--Event.list.1.json new file mode 100644 index 0000000000..ea63ba1bb6 Binary files /dev/null and b/corporate/tests/stripe_fixtures/switch_now_free_trial_from_monthly_to_annual--Event.list.1.json differ diff --git a/corporate/tests/stripe_fixtures/switch_now_free_trial_from_monthly_to_annual--Invoice.list.1.json b/corporate/tests/stripe_fixtures/switch_now_free_trial_from_monthly_to_annual--Invoice.list.1.json new file mode 100644 index 0000000000..e39960ab72 Binary files /dev/null and b/corporate/tests/stripe_fixtures/switch_now_free_trial_from_monthly_to_annual--Invoice.list.1.json differ diff --git a/corporate/tests/stripe_fixtures/switch_now_free_trial_from_monthly_to_annual--SetupIntent.create.1.json b/corporate/tests/stripe_fixtures/switch_now_free_trial_from_monthly_to_annual--SetupIntent.create.1.json new file mode 100644 index 0000000000..9df6d27ac0 Binary files /dev/null and b/corporate/tests/stripe_fixtures/switch_now_free_trial_from_monthly_to_annual--SetupIntent.create.1.json differ diff --git a/corporate/tests/stripe_fixtures/switch_now_free_trial_from_monthly_to_annual--SetupIntent.list.1.json b/corporate/tests/stripe_fixtures/switch_now_free_trial_from_monthly_to_annual--SetupIntent.list.1.json new file mode 100644 index 0000000000..1dd7684452 Binary files /dev/null and b/corporate/tests/stripe_fixtures/switch_now_free_trial_from_monthly_to_annual--SetupIntent.list.1.json differ diff --git a/corporate/tests/stripe_fixtures/switch_now_free_trial_from_monthly_to_annual--SetupIntent.retrieve.1.json b/corporate/tests/stripe_fixtures/switch_now_free_trial_from_monthly_to_annual--SetupIntent.retrieve.1.json new file mode 100644 index 0000000000..9df6d27ac0 Binary files /dev/null and b/corporate/tests/stripe_fixtures/switch_now_free_trial_from_monthly_to_annual--SetupIntent.retrieve.1.json differ diff --git a/corporate/tests/stripe_fixtures/switch_now_free_trial_from_monthly_to_annual--checkout.Session.create.1.json b/corporate/tests/stripe_fixtures/switch_now_free_trial_from_monthly_to_annual--checkout.Session.create.1.json new file mode 100644 index 0000000000..edfd3178ae Binary files /dev/null and b/corporate/tests/stripe_fixtures/switch_now_free_trial_from_monthly_to_annual--checkout.Session.create.1.json differ diff --git a/corporate/tests/stripe_fixtures/switch_now_free_trial_from_monthly_to_annual--checkout.Session.list.1.json b/corporate/tests/stripe_fixtures/switch_now_free_trial_from_monthly_to_annual--checkout.Session.list.1.json new file mode 100644 index 0000000000..2be3f3f1e5 Binary files /dev/null and b/corporate/tests/stripe_fixtures/switch_now_free_trial_from_monthly_to_annual--checkout.Session.list.1.json differ diff --git a/corporate/tests/test_stripe.py b/corporate/tests/test_stripe.py index 71efcdbb9a..e7e046d363 100644 --- a/corporate/tests/test_stripe.py +++ b/corporate/tests/test_stripe.py @@ -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") diff --git a/corporate/views/billing_page.py b/corporate/views/billing_page.py index 41bd4f7025..401ea19445 100644 --- a/corporate/views/billing_page.py +++ b/corporate/views/billing_page.py @@ -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) diff --git a/templates/corporate/billing.html b/templates/corporate/billing.html index 7816ce1dbe..9ec3b0a768 100644 --- a/templates/corporate/billing.html +++ b/templates/corporate/billing.html @@ -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 %}> - {% 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 %}