mirror of https://github.com/zulip/zulip.git
billing: Apply a flat discount for self hosted plans.
This commit is contained in:
parent
0337c726d3
commit
e192aef23d
|
@ -588,6 +588,8 @@ class UpgradePageParams(TypedDict):
|
|||
seat_count: int
|
||||
billing_base_url: str
|
||||
tier: int
|
||||
flat_discount: int
|
||||
flat_discounted_months: int
|
||||
|
||||
|
||||
class UpgradePageSessionTypeSpecificContext(TypedDict):
|
||||
|
@ -706,9 +708,11 @@ class BillingSession(ABC):
|
|||
|
||||
def get_data_for_stripe_payment_intent(
|
||||
self,
|
||||
customer: Customer,
|
||||
price_per_license: int,
|
||||
licenses: int,
|
||||
plan_tier: int,
|
||||
billing_schedule: int,
|
||||
email: str,
|
||||
) -> StripePaymentIntentData:
|
||||
if hasattr(self, "support_session") and self.support_session: # nocoverage
|
||||
|
@ -721,6 +725,12 @@ class BillingSession(ABC):
|
|||
|
||||
plan_name = CustomerPlan.name_from_tier(plan_tier)
|
||||
description = f"Upgrade to {plan_name}, ${price_per_license/100} x {licenses}"
|
||||
if customer.flat_discounted_months > 0:
|
||||
num_months = 12 if billing_schedule == CustomerPlan.BILLING_SCHEDULE_ANNUAL else 1
|
||||
flat_discounted_months = min(customer.flat_discounted_months, num_months)
|
||||
amount -= customer.flat_discount * flat_discounted_months
|
||||
description += f" - ${customer.flat_discount/100} x {flat_discounted_months}"
|
||||
|
||||
return StripePaymentIntentData(
|
||||
amount=amount,
|
||||
description=description,
|
||||
|
@ -917,7 +927,12 @@ class BillingSession(ABC):
|
|||
customer = self.get_customer()
|
||||
assert customer is not None and customer.stripe_customer_id is not None
|
||||
payment_intent_data = self.get_data_for_stripe_payment_intent(
|
||||
price_per_license, licenses, metadata["plan_tier"], self.get_email()
|
||||
customer,
|
||||
price_per_license,
|
||||
licenses,
|
||||
metadata["plan_tier"],
|
||||
metadata["billing_schedule"],
|
||||
self.get_email(),
|
||||
)
|
||||
# Ensure customers have a default payment method set.
|
||||
stripe_customer = stripe_get_customer(customer.stripe_customer_id)
|
||||
|
@ -1021,14 +1036,18 @@ class BillingSession(ABC):
|
|||
plan.save(update_fields=["discount", "price_per_license"])
|
||||
|
||||
def attach_discount_to_customer(self, new_discount: Decimal) -> str:
|
||||
# Remove flat discount if giving customer a percentage discount.
|
||||
customer = self.get_customer()
|
||||
old_discount = None
|
||||
if customer is not None:
|
||||
old_discount = customer.default_discount
|
||||
customer.default_discount = new_discount
|
||||
customer.save(update_fields=["default_discount"])
|
||||
customer.flat_discounted_months = 0
|
||||
customer.save(update_fields=["default_discount", "flat_discounted_months"])
|
||||
else:
|
||||
customer = self.update_or_create_customer(defaults={"default_discount": new_discount})
|
||||
customer = self.update_or_create_customer(
|
||||
defaults={"default_discount": new_discount, "flat_discounted_months": 0}
|
||||
)
|
||||
plan = get_current_plan_by_customer(customer)
|
||||
if plan is not None:
|
||||
self.apply_discount_to_plan(plan, new_discount)
|
||||
|
@ -1305,6 +1324,21 @@ class BillingSession(ABC):
|
|||
unit_amount=price_per_license,
|
||||
)
|
||||
|
||||
if customer.flat_discounted_months > 0:
|
||||
num_months = 12 if billing_schedule == CustomerPlan.BILLING_SCHEDULE_ANNUAL else 1
|
||||
flat_discounted_months = min(customer.flat_discounted_months, num_months)
|
||||
discount = customer.flat_discount * flat_discounted_months
|
||||
customer.flat_discounted_months -= flat_discounted_months
|
||||
customer.save(update_fields=["flat_discounted_months"])
|
||||
|
||||
stripe.InvoiceItem.create(
|
||||
currency="usd",
|
||||
customer=customer.stripe_customer_id,
|
||||
description=f"${customer.flat_discount}/month new customer discount",
|
||||
# Negative value to apply discount.
|
||||
amount=(-1 * discount),
|
||||
)
|
||||
|
||||
if charge_automatically:
|
||||
collection_method = "charge_automatically"
|
||||
days_until_due = None
|
||||
|
@ -1717,18 +1751,23 @@ class BillingSession(ABC):
|
|||
billing_frequency = CustomerPlan.BILLING_SCHEDULES[plan.billing_schedule]
|
||||
|
||||
if switch_to_annual_at_end_of_cycle:
|
||||
num_months_next_cycle = 12
|
||||
annual_price_per_license = get_price_per_license(
|
||||
plan.tier, CustomerPlan.BILLING_SCHEDULE_ANNUAL, customer.default_discount
|
||||
)
|
||||
renewal_cents = annual_price_per_license * licenses_at_next_renewal
|
||||
price_per_license = format_money(annual_price_per_license / 12)
|
||||
elif switch_to_monthly_at_end_of_cycle:
|
||||
num_months_next_cycle = 1
|
||||
monthly_price_per_license = get_price_per_license(
|
||||
plan.tier, CustomerPlan.BILLING_SCHEDULE_MONTHLY, customer.default_discount
|
||||
)
|
||||
renewal_cents = monthly_price_per_license * licenses_at_next_renewal
|
||||
price_per_license = format_money(monthly_price_per_license)
|
||||
else:
|
||||
num_months_next_cycle = (
|
||||
12 if plan.billing_schedule == CustomerPlan.BILLING_SCHEDULE_ANNUAL else 1
|
||||
)
|
||||
renewal_cents = self.get_customer_plan_renewal_amount(plan, now, last_ledger_entry)
|
||||
|
||||
if plan.price_per_license is None:
|
||||
|
@ -1738,6 +1777,14 @@ class BillingSession(ABC):
|
|||
else:
|
||||
price_per_license = format_money(plan.price_per_license)
|
||||
|
||||
# TODO: Do this calculation in `invoice_plan` too.
|
||||
pre_discount_renewal_cents = renewal_cents
|
||||
flat_discount, flat_discounted_months = self.get_flat_discount_info(plan.customer)
|
||||
if flat_discounted_months > 0:
|
||||
flat_discounted_months = min(flat_discounted_months, num_months_next_cycle)
|
||||
discount = flat_discount * flat_discounted_months
|
||||
renewal_cents = renewal_cents - discount
|
||||
|
||||
charge_automatically = plan.charge_automatically
|
||||
assert customer.stripe_customer_id is not None # for mypy
|
||||
stripe_customer = stripe_get_customer(customer.stripe_customer_id)
|
||||
|
@ -1786,6 +1833,9 @@ class BillingSession(ABC):
|
|||
"legacy_remote_server_next_plan_name": legacy_remote_server_next_plan_name,
|
||||
"using_min_licenses_for_plan": using_min_licenses_for_plan,
|
||||
"min_licenses_for_plan": min_licenses_for_plan,
|
||||
"pre_discount_renewal_cents": cents_to_dollar_string(pre_discount_renewal_cents),
|
||||
"flat_discount": format_money(customer.flat_discount),
|
||||
"discounted_months_left": customer.flat_discounted_months,
|
||||
}
|
||||
return context
|
||||
|
||||
|
@ -1821,12 +1871,30 @@ class BillingSession(ABC):
|
|||
"price_per_license",
|
||||
"discount_percent",
|
||||
"using_min_licenses_for_plan",
|
||||
"min_licenses_for_plan",
|
||||
"pre_discount_renewal_cents",
|
||||
]
|
||||
|
||||
for key in keys:
|
||||
context[key] = next_plan_context[key]
|
||||
return context
|
||||
|
||||
def get_flat_discount_info(self, customer: Optional[Customer] = None) -> Tuple[int, int]:
|
||||
is_self_hosted_billing = not isinstance(self, RealmBillingSession)
|
||||
flat_discount = 0
|
||||
flat_discounted_months = 0
|
||||
if is_self_hosted_billing and (customer is None or customer.flat_discounted_months > 0):
|
||||
if customer is None:
|
||||
temp_customer = Customer()
|
||||
flat_discount = temp_customer.flat_discount
|
||||
flat_discounted_months = 12
|
||||
else:
|
||||
flat_discount = customer.flat_discount
|
||||
flat_discounted_months = customer.flat_discounted_months
|
||||
assert isinstance(flat_discount, int)
|
||||
assert isinstance(flat_discounted_months, int)
|
||||
return flat_discount, flat_discounted_months
|
||||
|
||||
def get_initial_upgrade_context(
|
||||
self, initial_upgrade_request: InitialUpgradeRequest
|
||||
) -> Tuple[Optional[str], Optional[UpgradePageContext]]:
|
||||
|
@ -1893,6 +1961,7 @@ class BillingSession(ABC):
|
|||
f"{free_trial_end:%B} {free_trial_end.day}, {free_trial_end.year}"
|
||||
)
|
||||
|
||||
flat_discount, flat_discounted_months = self.get_flat_discount_info(customer)
|
||||
context: UpgradePageContext = {
|
||||
"customer_name": customer_specific_context["customer_name"],
|
||||
"default_invoice_days_until_due": DEFAULT_INVOICE_DAYS_UNTIL_DUE,
|
||||
|
@ -1917,6 +1986,8 @@ class BillingSession(ABC):
|
|||
"seat_count": seat_count,
|
||||
"billing_base_url": self.billing_base_url,
|
||||
"tier": tier,
|
||||
"flat_discount": flat_discount,
|
||||
"flat_discounted_months": flat_discounted_months,
|
||||
},
|
||||
"using_min_licenses_for_plan": using_min_licenses_for_plan,
|
||||
"min_licenses_for_plan": min_licenses_for_plan,
|
||||
|
@ -3245,11 +3316,15 @@ class RemoteRealmBillingSession(BillingSession):
|
|||
remote_realm=self.remote_realm,
|
||||
defaults={"stripe_customer_id": stripe_customer_id},
|
||||
)
|
||||
return customer
|
||||
else:
|
||||
customer, created = Customer.objects.update_or_create(
|
||||
remote_realm=self.remote_realm, defaults=defaults
|
||||
)
|
||||
|
||||
if created and not customer.default_discount:
|
||||
customer.flat_discounted_months = 12
|
||||
customer.save(update_fields=["flat_discounted_months"])
|
||||
|
||||
return customer
|
||||
|
||||
@override
|
||||
|
@ -3634,11 +3709,15 @@ class RemoteServerBillingSession(BillingSession):
|
|||
remote_server=self.remote_server,
|
||||
defaults={"stripe_customer_id": stripe_customer_id},
|
||||
)
|
||||
return customer
|
||||
else:
|
||||
customer, created = Customer.objects.update_or_create(
|
||||
remote_server=self.remote_server, defaults=defaults
|
||||
)
|
||||
|
||||
if created and not customer.default_discount:
|
||||
customer.flat_discounted_months = 12
|
||||
customer.save(update_fields=["flat_discounted_months"])
|
||||
|
||||
return customer
|
||||
|
||||
@override
|
||||
|
|
|
@ -0,0 +1,22 @@
|
|||
# Generated by Django 4.2.8 on 2023-12-19 12:24
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("corporate", "0030_alter_zulipsponsorshiprequest_requested_plan"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="customer",
|
||||
name="flat_discount",
|
||||
field=models.IntegerField(default=2000),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="customer",
|
||||
name="flat_discounted_months",
|
||||
field=models.IntegerField(default=0),
|
||||
),
|
||||
]
|
|
@ -34,6 +34,11 @@ class Customer(models.Model):
|
|||
# they purchased.
|
||||
exempt_from_license_number_check = models.BooleanField(default=False)
|
||||
|
||||
# In cents.
|
||||
flat_discount = models.IntegerField(default=2000)
|
||||
# Number of months left in the flat discount period.
|
||||
flat_discounted_months = models.IntegerField(default=0)
|
||||
|
||||
class Meta:
|
||||
# Enforce that at least one of these is set.
|
||||
constraints = [
|
||||
|
|
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.
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.
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.
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.
|
@ -579,14 +579,14 @@ class StripeTestCase(ZulipTestCase):
|
|||
**kwargs: Any,
|
||||
) -> "TestHttpResponse":
|
||||
if upgrade_page_response is None:
|
||||
tier = kwargs.get("tier")
|
||||
upgrade_url = f"{self.billing_session.billing_base_url}/upgrade/"
|
||||
if tier:
|
||||
upgrade_url += f"?tier={tier}"
|
||||
if self.billing_session.billing_base_url:
|
||||
upgrade_page_response = self.client_get(
|
||||
f"{self.billing_session.billing_base_url}/upgrade/", {}, subdomain="selfhosting"
|
||||
)
|
||||
upgrade_page_response = self.client_get(upgrade_url, {}, subdomain="selfhosting")
|
||||
else:
|
||||
upgrade_page_response = self.client_get(
|
||||
f"{self.billing_session.billing_base_url}/upgrade/", {}
|
||||
)
|
||||
upgrade_page_response = self.client_get(upgrade_url, {})
|
||||
params: Dict[str, Any] = {
|
||||
"schedule": "annual",
|
||||
"signed_seat_count": self.get_signed_seat_count_from_response(upgrade_page_response),
|
||||
|
@ -5651,7 +5651,7 @@ class TestRemoteRealmBillingFlow(StripeTestCase, RemoteRealmBillingTestCase):
|
|||
|
||||
@responses.activate
|
||||
@mock_stripe()
|
||||
def test_non_sponsorship_billing(self, *mocks: Mock) -> None:
|
||||
def test_upgrade_user_to_business_plan(self, *mocks: Mock) -> None:
|
||||
self.login("hamlet")
|
||||
hamlet = self.example_user("hamlet")
|
||||
|
||||
|
@ -5659,6 +5659,7 @@ class TestRemoteRealmBillingFlow(StripeTestCase, RemoteRealmBillingTestCase):
|
|||
realm_user_count = UserProfile.objects.filter(
|
||||
realm=hamlet.realm, is_bot=False, is_active=True
|
||||
).count()
|
||||
self.assertEqual(realm_user_count, 11)
|
||||
|
||||
with time_machine.travel(self.now, tick=False):
|
||||
send_server_data_to_push_bouncer(consider_usage_statistics=False)
|
||||
|
@ -5673,7 +5674,24 @@ class TestRemoteRealmBillingFlow(StripeTestCase, RemoteRealmBillingTestCase):
|
|||
f"{self.billing_session.billing_base_url}/upgrade/", subdomain="selfhosting"
|
||||
)
|
||||
self.assertEqual(result.status_code, 200)
|
||||
self.assert_in_success_response(["Add card", "Purchase Zulip Business"], result)
|
||||
|
||||
# Min licenses used since org has less users.
|
||||
min_licenses = self.billing_session.min_licenses_for_plan(
|
||||
CustomerPlan.TIER_SELF_HOSTED_BUSINESS
|
||||
)
|
||||
self.assertEqual(min_licenses, 25)
|
||||
flat_discount, flat_discounted_months = self.billing_session.get_flat_discount_info()
|
||||
self.assertEqual(flat_discounted_months, 12)
|
||||
|
||||
self.assert_in_success_response(
|
||||
[
|
||||
"Minimum purchase for",
|
||||
f"{min_licenses} licenses",
|
||||
"Add card",
|
||||
"Purchase Zulip Business",
|
||||
],
|
||||
result,
|
||||
)
|
||||
|
||||
self.assertFalse(Customer.objects.exists())
|
||||
self.assertFalse(CustomerPlan.objects.exists())
|
||||
|
@ -5693,10 +5711,10 @@ class TestRemoteRealmBillingFlow(StripeTestCase, RemoteRealmBillingTestCase):
|
|||
for substring in [
|
||||
"Zulip Business",
|
||||
"Number of licenses",
|
||||
f"{realm_user_count} (managed automatically)",
|
||||
f"{min_licenses} (managed automatically)",
|
||||
"January 2, 2013",
|
||||
"Your plan will automatically renew on",
|
||||
f"${80 * realm_user_count:,.2f}",
|
||||
f"${80 * min_licenses:,.2f}",
|
||||
"Visa ending in 4242",
|
||||
"Update card",
|
||||
]:
|
||||
|
@ -5707,7 +5725,7 @@ class TestRemoteRealmBillingFlow(StripeTestCase, RemoteRealmBillingTestCase):
|
|||
self.assertEqual(LicenseLedger.objects.count(), 1)
|
||||
|
||||
with time_machine.travel(self.now + timedelta(days=2), tick=False):
|
||||
for count in range(4, 14):
|
||||
for count in range(realm_user_count, min_licenses + 10):
|
||||
do_create_user(
|
||||
f"email {count}",
|
||||
f"password {count}",
|
||||
|
@ -5722,11 +5740,140 @@ class TestRemoteRealmBillingFlow(StripeTestCase, RemoteRealmBillingTestCase):
|
|||
|
||||
self.assertEqual(
|
||||
RemoteRealmAuditLog.objects.count(),
|
||||
audit_log_count + 10,
|
||||
min_licenses + 10 - realm_user_count + audit_log_count,
|
||||
)
|
||||
latest_ledger = LicenseLedger.objects.last()
|
||||
assert latest_ledger is not None
|
||||
self.assertEqual(latest_ledger.licenses, realm_user_count + 10)
|
||||
self.assertEqual(latest_ledger.licenses, min_licenses + 10)
|
||||
|
||||
with time_machine.travel(self.now + timedelta(days=1), tick=False):
|
||||
response = self.client_get(
|
||||
f"{self.billing_session.billing_base_url}/billing/", subdomain="selfhosting"
|
||||
)
|
||||
|
||||
self.assertEqual(latest_ledger.licenses, 35)
|
||||
for substring in [
|
||||
"Zulip Business",
|
||||
"Number of licenses",
|
||||
f"{latest_ledger.licenses} (managed automatically)",
|
||||
"January 2, 2013",
|
||||
"Your plan will automatically renew on",
|
||||
f"${80 * latest_ledger.licenses:,.2f}",
|
||||
"Visa ending in 4242",
|
||||
"Update card",
|
||||
]:
|
||||
self.assert_in_response(substring, response)
|
||||
|
||||
@responses.activate
|
||||
@mock_stripe()
|
||||
def test_upgrade_user_to_monthly_basic_plan(self, *mocks: Mock) -> None:
|
||||
self.login("hamlet")
|
||||
hamlet = self.example_user("hamlet")
|
||||
|
||||
self.add_mock_response()
|
||||
realm_user_count = UserProfile.objects.filter(
|
||||
realm=hamlet.realm, is_bot=False, is_active=True
|
||||
).count()
|
||||
self.assertEqual(realm_user_count, 11)
|
||||
|
||||
with time_machine.travel(self.now, tick=False):
|
||||
send_server_data_to_push_bouncer(consider_usage_statistics=False)
|
||||
|
||||
result = self.execute_remote_billing_authentication_flow(hamlet)
|
||||
self.assertEqual(result.status_code, 302)
|
||||
self.assertEqual(result["Location"], f"{self.billing_session.billing_base_url}/plans/")
|
||||
|
||||
# upgrade to basic plan
|
||||
with time_machine.travel(self.now, tick=False):
|
||||
result = self.client_get(
|
||||
f"{self.billing_session.billing_base_url}/upgrade/?tier={CustomerPlan.TIER_SELF_HOSTED_BASIC}",
|
||||
subdomain="selfhosting",
|
||||
)
|
||||
self.assertEqual(result.status_code, 200)
|
||||
|
||||
min_licenses = self.billing_session.min_licenses_for_plan(
|
||||
CustomerPlan.TIER_SELF_HOSTED_BASIC
|
||||
)
|
||||
self.assertEqual(min_licenses, 10)
|
||||
flat_discount, flat_discounted_months = self.billing_session.get_flat_discount_info()
|
||||
self.assertEqual(flat_discounted_months, 12)
|
||||
|
||||
self.assert_in_success_response(
|
||||
[f"{realm_user_count}", "Add card", "Purchase Zulip Basic"], result
|
||||
)
|
||||
|
||||
self.assertFalse(Customer.objects.exists())
|
||||
self.assertFalse(CustomerPlan.objects.exists())
|
||||
self.assertFalse(LicenseLedger.objects.exists())
|
||||
|
||||
with time_machine.travel(self.now, tick=False):
|
||||
stripe_customer = self.add_card_and_upgrade(
|
||||
tier=CustomerPlan.TIER_SELF_HOSTED_BASIC, schedule="monthly"
|
||||
)
|
||||
|
||||
customer = Customer.objects.get(stripe_customer_id=stripe_customer.id)
|
||||
plan = CustomerPlan.objects.get(customer=customer)
|
||||
LicenseLedger.objects.get(plan=plan)
|
||||
|
||||
with time_machine.travel(self.now + timedelta(days=1), tick=False):
|
||||
response = self.client_get(
|
||||
f"{self.billing_session.billing_base_url}/billing/", subdomain="selfhosting"
|
||||
)
|
||||
for substring in [
|
||||
"Zulip Basic",
|
||||
"Number of licenses",
|
||||
f"{realm_user_count} (managed automatically)",
|
||||
"February 2, 2012",
|
||||
"Your plan will automatically renew on",
|
||||
f"${3.5 * realm_user_count - flat_discount // 100 * 1:,.2f}",
|
||||
"Visa ending in 4242",
|
||||
"Update card",
|
||||
]:
|
||||
self.assert_in_response(substring, response)
|
||||
|
||||
# Verify that change in user count updates LicenseLedger.
|
||||
audit_log_count = RemoteRealmAuditLog.objects.count()
|
||||
self.assertEqual(LicenseLedger.objects.count(), 1)
|
||||
|
||||
with time_machine.travel(self.now + timedelta(days=2), tick=False):
|
||||
for count in range(realm_user_count, min_licenses + 10):
|
||||
do_create_user(
|
||||
f"email {count}",
|
||||
f"password {count}",
|
||||
hamlet.realm,
|
||||
"name",
|
||||
role=UserProfile.ROLE_MEMBER,
|
||||
acting_user=None,
|
||||
)
|
||||
|
||||
with time_machine.travel(self.now + timedelta(days=3), tick=False):
|
||||
send_server_data_to_push_bouncer(consider_usage_statistics=False)
|
||||
|
||||
self.assertEqual(
|
||||
RemoteRealmAuditLog.objects.count(),
|
||||
min_licenses + 10 - realm_user_count + audit_log_count,
|
||||
)
|
||||
latest_ledger = LicenseLedger.objects.last()
|
||||
assert latest_ledger is not None
|
||||
self.assertEqual(latest_ledger.licenses, min_licenses + 10)
|
||||
|
||||
with time_machine.travel(self.now + timedelta(days=1), tick=False):
|
||||
response = self.client_get(
|
||||
f"{self.billing_session.billing_base_url}/billing/", subdomain="selfhosting"
|
||||
)
|
||||
|
||||
self.assertEqual(latest_ledger.licenses, 20)
|
||||
for substring in [
|
||||
"Zulip Basic",
|
||||
"Number of licenses",
|
||||
f"{latest_ledger.licenses} (managed automatically)",
|
||||
"February 2, 2012",
|
||||
"Your plan will automatically renew on",
|
||||
f"${3.5 * latest_ledger.licenses - flat_discount // 100 * 1:,.2f}",
|
||||
"Visa ending in 4242",
|
||||
"Update card",
|
||||
]:
|
||||
self.assert_in_response(substring, response)
|
||||
|
||||
@responses.activate
|
||||
def test_request_sponsorship(self) -> None:
|
||||
|
@ -5918,10 +6065,10 @@ class TestRemoteServerBillingFlow(StripeTestCase, RemoteServerTestCase):
|
|||
for substring in [
|
||||
"Zulip Business",
|
||||
"Number of licenses",
|
||||
f"{server_user_count} (managed automatically)",
|
||||
f"{25} (managed automatically)",
|
||||
"Your plan will automatically renew on",
|
||||
"January 2, 2013",
|
||||
f"${80 * server_user_count:,.2f}",
|
||||
f"${80 * 25:,.2f}",
|
||||
"Visa ending in 4242",
|
||||
"Update card",
|
||||
]:
|
||||
|
@ -6151,8 +6298,6 @@ class TestRemoteServerBillingFlow(StripeTestCase, RemoteServerTestCase):
|
|||
self.assertEqual(new_customer_plan.tier, CustomerPlan.TIER_SELF_HOSTED_BUSINESS)
|
||||
self.assertEqual(new_customer_plan.billing_cycle_anchor, end_date)
|
||||
|
||||
server_user_count = UserProfile.objects.filter(is_bot=False, is_active=True).count()
|
||||
|
||||
# Visit billing page
|
||||
with time_machine.travel(self.now, tick=False):
|
||||
response = self.client_get(f"{billing_base_url}/billing/", subdomain="selfhosting")
|
||||
|
@ -6160,7 +6305,8 @@ class TestRemoteServerBillingFlow(StripeTestCase, RemoteServerTestCase):
|
|||
"(legacy plan)",
|
||||
f"This is a legacy plan that ends on {end_date.strftime('%B %d, %Y')}",
|
||||
f"Your plan will automatically upgrade to Zulip Business on {end_date.strftime('%B %d, %Y')}",
|
||||
f"Expected charge: <strong>${80 * server_user_count:,.2f}</strong>",
|
||||
"Expected next charge",
|
||||
f"${80 * 25 - 20 * 12:,.2f}",
|
||||
"Visa ending in 4242",
|
||||
"Update card",
|
||||
]:
|
||||
|
@ -6189,3 +6335,114 @@ class TestRemoteServerBillingFlow(StripeTestCase, RemoteServerTestCase):
|
|||
m.output[1],
|
||||
f"INFO:corporate.stripe:Change plan status: Customer.id: {customer.id}, CustomerPlan.id: {customer_plan.id}, status: {CustomerPlan.ACTIVE}",
|
||||
)
|
||||
|
||||
@responses.activate
|
||||
@mock_stripe()
|
||||
def test_upgrade_user_to_monthly_basic_plan(self, *mocks: Mock) -> None:
|
||||
self.login("hamlet")
|
||||
hamlet = self.example_user("hamlet")
|
||||
|
||||
self.add_mock_response()
|
||||
realm_user_count = UserProfile.objects.filter(is_bot=False, is_active=True).count()
|
||||
self.assertEqual(realm_user_count, 18)
|
||||
|
||||
with time_machine.travel(self.now, tick=False):
|
||||
send_server_data_to_push_bouncer(consider_usage_statistics=False)
|
||||
|
||||
result = self.execute_remote_billing_authentication_flow(
|
||||
hamlet.delivery_email, hamlet.full_name
|
||||
)
|
||||
self.assertEqual(result.status_code, 302)
|
||||
self.assertEqual(result["Location"], f"{self.billing_session.billing_base_url}/plans/")
|
||||
|
||||
# upgrade to basic plan
|
||||
with time_machine.travel(self.now, tick=False):
|
||||
result = self.client_get(
|
||||
f"{self.billing_session.billing_base_url}/upgrade/?tier={CustomerPlan.TIER_SELF_HOSTED_BASIC}",
|
||||
subdomain="selfhosting",
|
||||
)
|
||||
self.assertEqual(result.status_code, 200)
|
||||
|
||||
min_licenses = self.billing_session.min_licenses_for_plan(
|
||||
CustomerPlan.TIER_SELF_HOSTED_BASIC
|
||||
)
|
||||
self.assertEqual(min_licenses, 10)
|
||||
flat_discount, flat_discounted_months = self.billing_session.get_flat_discount_info()
|
||||
self.assertEqual(flat_discounted_months, 12)
|
||||
|
||||
self.assert_in_success_response(
|
||||
[f"{min_licenses}", "Add card", "Purchase Zulip Basic"], result
|
||||
)
|
||||
|
||||
self.assertFalse(Customer.objects.exists())
|
||||
self.assertFalse(CustomerPlan.objects.exists())
|
||||
self.assertFalse(LicenseLedger.objects.exists())
|
||||
|
||||
with time_machine.travel(self.now, tick=False):
|
||||
stripe_customer = self.add_card_and_upgrade(
|
||||
tier=CustomerPlan.TIER_SELF_HOSTED_BASIC, schedule="monthly"
|
||||
)
|
||||
|
||||
customer = Customer.objects.get(stripe_customer_id=stripe_customer.id)
|
||||
plan = CustomerPlan.objects.get(customer=customer)
|
||||
LicenseLedger.objects.get(plan=plan)
|
||||
|
||||
with time_machine.travel(self.now + timedelta(days=1), tick=False):
|
||||
response = self.client_get(
|
||||
f"{self.billing_session.billing_base_url}/billing/", subdomain="selfhosting"
|
||||
)
|
||||
for substring in [
|
||||
"Zulip Basic",
|
||||
"Number of licenses",
|
||||
f"{realm_user_count} (managed automatically)",
|
||||
"February 2, 2012",
|
||||
"Your plan will automatically renew on",
|
||||
f"${3.5 * realm_user_count - flat_discount // 100 * 1:,.2f}",
|
||||
"Visa ending in 4242",
|
||||
"Update card",
|
||||
]:
|
||||
self.assert_in_response(substring, response)
|
||||
|
||||
# Verify that change in user count updates LicenseLedger.
|
||||
audit_log_count = RemoteRealmAuditLog.objects.count()
|
||||
self.assertEqual(LicenseLedger.objects.count(), 1)
|
||||
|
||||
with time_machine.travel(self.now + timedelta(days=2), tick=False):
|
||||
for count in range(realm_user_count, min_licenses + 10):
|
||||
do_create_user(
|
||||
f"email {count}",
|
||||
f"password {count}",
|
||||
hamlet.realm,
|
||||
"name",
|
||||
role=UserProfile.ROLE_MEMBER,
|
||||
acting_user=None,
|
||||
)
|
||||
|
||||
with time_machine.travel(self.now + timedelta(days=3), tick=False):
|
||||
send_server_data_to_push_bouncer(consider_usage_statistics=False)
|
||||
|
||||
self.assertEqual(
|
||||
RemoteRealmAuditLog.objects.count(),
|
||||
min_licenses + 10 - realm_user_count + audit_log_count,
|
||||
)
|
||||
latest_ledger = LicenseLedger.objects.last()
|
||||
assert latest_ledger is not None
|
||||
self.assertEqual(latest_ledger.licenses, min_licenses + 10)
|
||||
|
||||
with time_machine.travel(self.now + timedelta(days=1), tick=False):
|
||||
response = self.client_get(
|
||||
f"{self.billing_session.billing_base_url}/billing/", subdomain="selfhosting"
|
||||
)
|
||||
|
||||
self.assertEqual(latest_ledger.licenses, 20)
|
||||
for substring in [
|
||||
"Zulip Basic",
|
||||
"Number of licenses",
|
||||
f"{latest_ledger.licenses} (managed automatically)",
|
||||
"February 2, 2012",
|
||||
"Your plan will automatically renew on",
|
||||
f"${3.5 * latest_ledger.licenses - flat_discount // 100 * 1:,.2f}",
|
||||
"Visa ending in 4242",
|
||||
"Update card",
|
||||
]:
|
||||
self.assert_in_response(substring, response)
|
||||
|
|
|
@ -237,9 +237,15 @@
|
|||
Your next invoice is due on <strong>{{ renewal_date }}</strong>.
|
||||
{% endif %}
|
||||
<br />
|
||||
Expected charge: <strong>${{ renewal_amount }}</strong>
|
||||
<div class="input-box billing-page-field">
|
||||
<label for="expected-charge">Expected next charge</label>
|
||||
{% if not fixed_price %}
|
||||
(${{ price_per_license }} x {{ licenses_at_next_renewal }} {{ 'user' if licenses_at_next_renewal == 1 else 'users' }} x
|
||||
<div class="not-editable-realm-field">
|
||||
{% if using_min_licenses_for_plan %}
|
||||
<i>Minimum purchase for this plan: {{ min_licenses_for_plan }} licenses</i>
|
||||
<br />
|
||||
{% endif %}
|
||||
${{ pre_discount_renewal_cents }} (${{ price_per_license }} x {{ licenses_at_next_renewal }} {{ 'user' if licenses_at_next_renewal == 1 else 'users' }} x
|
||||
{% if switch_to_annual_at_end_of_cycle %}
|
||||
12 months
|
||||
{%- elif switch_to_monthly_at_end_of_cycle %}
|
||||
|
@ -247,15 +253,18 @@
|
|||
{%- else %}
|
||||
{{ "1 month" if billing_frequency == "Monthly" else "12 months" }}
|
||||
{%- endif -%})
|
||||
{% if discounted_months_left != 0 %}
|
||||
<br />
|
||||
Discount: ${{ flat_discount }}/month off <i class="billing-page-discount">({{ discounted_months_left }} {{ "month" if discounted_months_left == 1 else "months" }} remaining)</i>
|
||||
{% endif %}
|
||||
{% if discount_percent %}
|
||||
<br />
|
||||
<i class="billing-page-discount">Includes: {{ discount_percent }}% discount</i>
|
||||
{% endif %}
|
||||
{% if using_min_licenses_for_plan %}
|
||||
<br />
|
||||
<i>Minimum purchase for this plan: {{ min_licenses_for_plan }} licenses</i>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
<h1>${{ renewal_amount }}</h1>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
Your plan ends on <strong>{{ renewal_date }}</strong>, and does not renew.
|
||||
|
@ -498,12 +507,12 @@
|
|||
</header>
|
||||
<main class="modal__content">
|
||||
<p>
|
||||
Your organization will be not be upgraded to <strong>Zulip Business</strong>
|
||||
Your organization will be not be upgraded to <strong>{{ legacy_remote_server_next_plan_name }}</strong>
|
||||
on {{ remote_server_legacy_plan_end_date }}. If your organization has more than
|
||||
10 users at that time, you will lose access to the
|
||||
<a href="https://zulip.readthedocs.io/en/stable/production/mobile-push-notifications.html">Mobile Push Notification Service</a>.
|
||||
You will also not receive the <a href="{{ billing_base_url }}/plans/#self-hosted-plan-comparison">other benefits</a>
|
||||
of the Zulip Business plan. Are you sure you want to continue?
|
||||
of the {{ legacy_remote_server_next_plan_name }} plan. Are you sure you want to continue?
|
||||
</p>
|
||||
</main>
|
||||
<footer class="modal__footer">
|
||||
|
|
|
@ -125,7 +125,12 @@
|
|||
</span>
|
||||
</label>
|
||||
<div id="due-today" class="not-editable-realm-field">
|
||||
$<span class="due-today-unit-price"></span> x
|
||||
{% if not manual_license_management and using_min_licenses_for_plan %}
|
||||
<i>Minimum purchase for this plan: {{ min_licenses_for_plan }} licenses</i>
|
||||
<br />
|
||||
{% endif %}
|
||||
$<span id="pre-discount-renewal-cents"></span>
|
||||
($<span class="due-today-unit-price"></span> x
|
||||
{% if not manual_license_management %}
|
||||
{{ seat_count }}
|
||||
{% else %}
|
||||
|
@ -134,14 +139,14 @@
|
|||
<span class="due-today-license-count-user-plural">
|
||||
{{ 'user' if seat_count == 1 else 'users' }}
|
||||
</span> x
|
||||
<span class="due-today-duration"></span>
|
||||
<span class="due-today-duration"></span>)
|
||||
{% if discount_percent %}
|
||||
<br/>
|
||||
<i class="billing-page-discount">Includes: {{ discount_percent }}% discount</i>
|
||||
{% endif %}
|
||||
{% if not manual_license_management and using_min_licenses_for_plan %}
|
||||
{% if page_params.flat_discounted_months > 0 %}
|
||||
<br/>
|
||||
<i>Minimum purchase for this plan: {{ min_licenses_for_plan }} licenses</i>
|
||||
Discount: $<span class="flat-discounted-price"></span>/month off <i class="billing-page-discount">({{ page_params.flat_discounted_months }} {{ "month" if discounted_months_left == 1 else "months" }} remaining)</i>
|
||||
{% endif %}
|
||||
<h1>$<span class="due-today-price"></span></h1>
|
||||
{% if free_trial_days %}
|
||||
|
@ -157,7 +162,7 @@
|
|||
<p class="not-editable-realm-field">
|
||||
Your subscription will renew automatically. Your bill will vary based on the number
|
||||
of active users in your organization. You can also
|
||||
<a href="{{ page_params.billing_base_url }}/upgrade/?manual_license_management=true&tier%3D{{ page_params.tier }}">purchase a fixed number of licenses</a> instead. See
|
||||
<a href="{{ page_params.billing_base_url }}/upgrade/?manual_license_management=true&tier={{ page_params.tier }}">purchase a fixed number of licenses</a> instead. See
|
||||
<a target="_blank" href="https://zulip.com/help/zulip-cloud-billing">here</a> for details.
|
||||
</p>
|
||||
<input type="hidden" name="licenses" id="automatic_license_count" value="{{ seat_count }}" />
|
||||
|
@ -167,7 +172,7 @@
|
|||
<p class="not-editable-realm-field">
|
||||
Your subscription will renew automatically. You will be able to manage the number of licenses on
|
||||
your organization's billing page. You can also
|
||||
<a href="{{ page_params.billing_base_url }}/upgrade/?tier%3D{{ page_params.tier }}">choose automatic license management</a> instead. See
|
||||
<a href="{{ page_params.billing_base_url }}/upgrade/?tier={{ page_params.tier }}">choose automatic license management</a> instead. See
|
||||
<a href="https://zulip.com/help/zulip-cloud-billing">here</a> for details.
|
||||
</p>
|
||||
</div>
|
||||
|
|
|
@ -9,6 +9,8 @@ export const page_params: {
|
|||
seat_count: number;
|
||||
billing_base_url: string;
|
||||
tier: number;
|
||||
flat_discount: number;
|
||||
flat_discounted_months: number;
|
||||
} = $("#page-params").data("params");
|
||||
|
||||
if (!page_params) {
|
||||
|
|
|
@ -39,9 +39,14 @@ function update_due_today(schedule: string): void {
|
|||
}
|
||||
$("#due-today .due-today-duration").text(num_months === 1 ? "1 month" : "12 months");
|
||||
const schedule_typed = helpers.schedule_schema.parse(schedule);
|
||||
$(".due-today-price").text(
|
||||
helpers.format_money(current_license_count * prices[schedule_typed]),
|
||||
);
|
||||
const pre_flat_discount_price = prices[schedule_typed] * current_license_count;
|
||||
$("#pre-discount-renewal-cents").text(helpers.format_money(pre_flat_discount_price));
|
||||
const flat_discounted_months = Math.min(num_months, page_params.flat_discounted_months);
|
||||
const total_flat_discount = page_params.flat_discount * flat_discounted_months;
|
||||
const due_today = Math.max(0, pre_flat_discount_price - total_flat_discount);
|
||||
$(".flat-discounted-price").text(helpers.format_money(page_params.flat_discount));
|
||||
$(".due-today-price").text(helpers.format_money(due_today));
|
||||
|
||||
const unit_price = prices[schedule_typed] / num_months;
|
||||
$("#due-today .due-today-unit-price").text(helpers.format_money(unit_price));
|
||||
}
|
||||
|
|
Loading…
Reference in New Issue