diff --git a/corporate/lib/activity.py b/corporate/lib/activity.py
index fd534ac609..0a1da331a9 100644
--- a/corporate/lib/activity.py
+++ b/corporate/lib/activity.py
@@ -2,7 +2,7 @@ from collections import defaultdict
from dataclasses import dataclass
from datetime import datetime
from decimal import Decimal
-from typing import Any, Callable, Dict, List, Optional, Sequence, Union
+from typing import Any, Callable, Dict, List, Optional, Sequence, Tuple, Union
from urllib.parse import urlencode
from django.conf import settings
@@ -20,10 +20,9 @@ from corporate.lib.stripe import (
RemoteRealmBillingSession,
RemoteServerBillingSession,
)
-from corporate.models import Customer, CustomerPlan, LicenseLedger
+from corporate.models import CustomerPlan, LicenseLedger
from zerver.lib.pysa import mark_sanitized
from zerver.lib.url_encoding import append_url_query_string
-from zerver.lib.utils import assert_is_not_none
from zerver.models import Realm
from zilencer.models import (
RemoteCustomerUserCount,
@@ -160,11 +159,12 @@ def remote_installation_support_link(hostname: str) -> Markup:
return Markup('').format(url=url)
-def get_plan_rate_percentage(discount: Optional[Decimal]) -> str:
- if discount is None or discount == Decimal(0):
+def get_plan_rate_percentage(discount: Optional[str]) -> str:
+ # CustomerPlan.discount is a string field that stores the discount.
+ if discount is None or discount == "0":
return "100%"
- rate = 100 - discount
+ rate = 100 - Decimal(discount)
if rate * 100 % 100 == 0:
precision = 0
else:
@@ -211,23 +211,11 @@ def get_remote_activity_plan_data(
)
-def get_realms_with_default_discount_dict() -> Dict[str, Decimal]:
- realms_with_default_discount: Dict[str, Any] = {}
- customers = (
- Customer.objects.exclude(default_discount=None)
- .exclude(default_discount=0)
- .exclude(realm=None)
- )
- for customer in customers:
- assert customer.realm is not None
- realms_with_default_discount[customer.realm.string_id] = assert_is_not_none(
- customer.default_discount
- )
- return realms_with_default_discount
-
-
-def estimate_annual_recurring_revenue_by_realm() -> Dict[str, int]: # nocoverage
+def get_estimated_arr_and_rate_by_realm() -> Tuple[Dict[str, int], Dict[str, str]]: # nocoverage
+ # NOTE: Customers without a plan might still have a discount attached to them which
+ # are not included in `plan_rate`.
annual_revenue = {}
+ plan_rate = {}
plans = (
CustomerPlan.objects.filter(
status=CustomerPlan.ACTIVE,
@@ -254,7 +242,8 @@ def estimate_annual_recurring_revenue_by_realm() -> Dict[str, int]: # nocoverag
if plan.billing_schedule == CustomerPlan.BILLING_SCHEDULE_MONTHLY:
renewal_cents *= 12
annual_revenue[plan.customer.realm.string_id] = renewal_cents
- return annual_revenue
+ plan_rate[plan.customer.realm.string_id] = get_plan_rate_percentage(plan.discount)
+ return annual_revenue, plan_rate
def get_plan_data_by_remote_server() -> Dict[int, RemoteActivityPlanData]: # nocoverage
diff --git a/corporate/lib/stripe.py b/corporate/lib/stripe.py
index c6590f9320..2fa2d4d4ef 100644
--- a/corporate/lib/stripe.py
+++ b/corporate/lib/stripe.py
@@ -594,7 +594,8 @@ class SupportType(Enum):
class SupportViewRequest(TypedDict, total=False):
support_type: SupportType
sponsorship_status: Optional[bool]
- discount: Optional[Decimal]
+ monthly_discounted_price: Optional[int]
+ annual_discounted_price: Optional[int]
billing_modality: Optional[str]
plan_modification: Optional[str]
new_plan_tier: Optional[int]
@@ -646,6 +647,8 @@ class UpgradePageParams(TypedDict):
fixed_price: Optional[int]
setup_payment_by_invoice: bool
free_trial_days: Optional[int]
+ percent_off_annual_price: Optional[str]
+ percent_off_monthly_price: Optional[str]
class UpgradePageSessionTypeSpecificContext(TypedDict):
@@ -672,7 +675,6 @@ class SponsorshipRequestSessionSpecificContext(TypedDict):
class UpgradePageContext(TypedDict):
customer_name: str
- discount_percent: Optional[str]
email: str
exempt_from_license_number_check: bool
free_trial_end_date: Optional[str]
@@ -1276,13 +1278,20 @@ class BillingSession(ABC):
def apply_discount_to_plan(
self,
plan: CustomerPlan,
- discount: Decimal,
+ customer: Customer,
) -> None:
- plan.discount = discount
- plan.price_per_license = get_price_per_license(plan.tier, plan.billing_schedule, discount)
- plan.save(update_fields=["discount", "price_per_license"])
+ original_plan_price = get_price_per_license(plan.tier, plan.billing_schedule)
+ plan.price_per_license = get_price_per_license(plan.tier, plan.billing_schedule, customer)
- def attach_discount_to_customer(self, new_discount: Decimal) -> str:
+ # For display purposes only.
+ plan.discount = format_discount_percentage(
+ Decimal((original_plan_price - plan.price_per_license) / original_plan_price * 100)
+ )
+ plan.save(update_fields=["price_per_license", "discount"])
+
+ def attach_discount_to_customer(
+ self, monthly_discounted_price: int, annual_discounted_price: int
+ ) -> str:
# Remove flat discount if giving customer a percentage discount.
customer = self.get_customer()
@@ -1290,35 +1299,44 @@ class BillingSession(ABC):
assert customer is not None
assert customer.required_plan_tier is not None
- old_discount = customer.default_discount
- customer.default_discount = new_discount
+ old_monthly_discounted_price = customer.monthly_discounted_price
+ customer.monthly_discounted_price = monthly_discounted_price
+ old_annual_discounted_price = customer.annual_discounted_price
+ customer.annual_discounted_price = annual_discounted_price
+ # Ideally we would have some way to restore flat discounted months
+ # if we applied discounted to a customer and reverted it but seems
+ # like an edge case and can be handled manually on request.
customer.flat_discounted_months = 0
- customer.save(update_fields=["default_discount", "flat_discounted_months"])
+ customer.save(
+ update_fields=[
+ "monthly_discounted_price",
+ "annual_discounted_price",
+ "flat_discounted_months",
+ ]
+ )
plan = get_current_plan_by_customer(customer)
if plan is not None and plan.tier == customer.required_plan_tier:
- self.apply_discount_to_plan(plan, new_discount)
+ self.apply_discount_to_plan(plan, customer)
# If the customer has a next plan, apply discount to that plan as well.
# Make this a check on CustomerPlan.SWITCH_PLAN_TIER_AT_PLAN_END status
# if we support this for other plans.
next_plan = self.get_legacy_remote_server_next_plan(customer)
if next_plan is not None and next_plan.tier == customer.required_plan_tier:
- self.apply_discount_to_plan(next_plan, new_discount)
+ self.apply_discount_to_plan(next_plan, customer)
self.write_to_audit_log(
event_type=AuditLogEventType.DISCOUNT_CHANGED,
event_time=timezone_now(),
- extra_data={"old_discount": old_discount, "new_discount": new_discount},
+ extra_data={
+ "old_monthly_discounted_price": old_monthly_discounted_price,
+ "new_monthly_discounted_price": customer.monthly_discounted_price,
+ "old_annual_discounted_price": old_annual_discounted_price,
+ "new_annual_discounted_price": customer.annual_discounted_price,
+ },
)
- new_discount_string = (
- format_discount_percentage(new_discount) if (new_discount != Decimal(0)) else "0"
- )
- old_discount_string = (
- format_discount_percentage(old_discount)
- if (old_discount is not None and old_discount != Decimal(0))
- else "0"
- )
- return f"Discount for {self.billing_entity_display_name} changed to {new_discount_string}% from {old_discount_string}%."
+
+ return f"Monthly price for {self.billing_entity_display_name} changed to {customer.monthly_discounted_price} from {old_monthly_discounted_price}. Annual price changed to {customer.annual_discounted_price} from {old_annual_discounted_price}."
def update_customer_minimum_licenses(self, new_minimum_license_count: int) -> str:
previous_minimum_license_count = None
@@ -1327,7 +1345,7 @@ class BillingSession(ABC):
# Currently, the support admin view shows the form for adding
# a minimum license count after a default discount has been set.
assert customer is not None
- if customer.default_discount is None or int(customer.default_discount) == 0:
+ if not (customer.monthly_discounted_price or customer.annual_discounted_price):
raise SupportRequestError(
f"Discount for {self.billing_entity_display_name} must be updated before setting a minimum number of licenses."
)
@@ -1372,7 +1390,9 @@ class BillingSession(ABC):
raise SupportRequestError(f"Invalid plan tier for {self.billing_entity_display_name}.")
if customer is not None:
- if new_plan_tier is None and customer.default_discount:
+ if new_plan_tier is None and (
+ customer.monthly_discounted_price or customer.annual_discounted_price
+ ):
raise SupportRequestError(
f"Discount for {self.billing_entity_display_name} must be 0 before setting required plan tier to None."
)
@@ -1626,7 +1646,6 @@ class BillingSession(ABC):
"days_until_due": days_until_due,
"current_plan_id": current_plan_id,
}
- discount_for_plan = customer.get_discount_for_plan_tier(plan_tier)
(
invoice_period_start,
_,
@@ -1635,7 +1654,7 @@ class BillingSession(ABC):
) = compute_plan_parameters(
plan_tier,
billing_schedule,
- discount_for_plan,
+ customer,
on_free_trial,
None,
not isinstance(self, RealmBillingSession),
@@ -1724,11 +1743,8 @@ class BillingSession(ABC):
assert remote_server_legacy_plan is not None
billing_cycle_anchor = remote_server_legacy_plan.end_date
- discount_for_plan = None
fixed_price_plan_offer = get_configured_fixed_price_plan_offer(customer, plan_tier)
- if fixed_price_plan_offer is None:
- discount_for_plan = customer.get_discount_for_plan_tier(plan_tier)
- else:
+ if fixed_price_plan_offer is not None:
assert automanage_licenses is True
(
@@ -1739,7 +1755,7 @@ class BillingSession(ABC):
) = compute_plan_parameters(
plan_tier,
billing_schedule,
- discount_for_plan,
+ customer,
free_trial,
billing_cycle_anchor,
is_self_hosted_billing,
@@ -1766,11 +1782,14 @@ class BillingSession(ABC):
if fixed_price_plan_offer is None:
plan_params["price_per_license"] = price_per_license
- plan_params["discount"] = discount_for_plan
+ _price_per_license, percent_off = get_price_per_license_and_discount(
+ plan_tier, billing_schedule, customer
+ )
+ plan_params["discount"] = percent_off
+ assert price_per_license == _price_per_license
if free_trial:
plan_params["status"] = CustomerPlan.FREE_TRIAL
-
if charge_automatically:
# Ensure free trial customers not paying via invoice have a default payment method set
assert customer.stripe_customer_id is not None # for mypy
@@ -2025,11 +2044,8 @@ class BillingSession(ABC):
plan.next_invoice_date = None
plan.save(update_fields=["status", "next_invoice_date"])
- discount_for_current_plan = plan.discount
- _, _, _, price_per_license = compute_plan_parameters(
- tier=plan.tier,
- billing_schedule=schedule,
- discount=discount_for_current_plan,
+ price_per_license, discount_for_current_plan = get_price_per_license_and_discount(
+ plan.tier, schedule, plan.customer
)
new_plan = CustomerPlan.objects.create(
@@ -2185,11 +2201,8 @@ class BillingSession(ABC):
plan.status = CustomerPlan.ENDED
plan.save(update_fields=["status"])
- discount_for_current_plan = plan.discount
- _, _, _, price_per_license = compute_plan_parameters(
- tier=plan.tier,
- billing_schedule=CustomerPlan.BILLING_SCHEDULE_ANNUAL,
- discount=discount_for_current_plan,
+ price_per_license, discount_for_current_plan = get_price_per_license_and_discount(
+ plan.tier, CustomerPlan.BILLING_SCHEDULE_ANNUAL, plan.customer
)
new_plan = CustomerPlan.objects.create(
@@ -2233,11 +2246,8 @@ class BillingSession(ABC):
plan.status = CustomerPlan.ENDED
plan.save(update_fields=["status"])
- discount_for_current_plan = plan.discount
- _, _, _, price_per_license = compute_plan_parameters(
- tier=plan.tier,
- billing_schedule=CustomerPlan.BILLING_SCHEDULE_MONTHLY,
- discount=discount_for_current_plan,
+ price_per_license, discount_for_current_plan = get_price_per_license_and_discount(
+ plan.tier, CustomerPlan.BILLING_SCHEDULE_MONTHLY, plan.customer
)
new_plan = CustomerPlan.objects.create(
@@ -2366,19 +2376,18 @@ class BillingSession(ABC):
)
billing_frequency = CustomerPlan.BILLING_SCHEDULES[plan.billing_schedule]
- discount_for_current_plan = plan.discount
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, discount_for_current_plan
+ plan.tier, CustomerPlan.BILLING_SCHEDULE_ANNUAL, customer
)
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, discount_for_current_plan
+ plan.tier, CustomerPlan.BILLING_SCHEDULE_MONTHLY, customer
)
renewal_cents = monthly_price_per_license * licenses_at_next_renewal
price_per_license = format_money(monthly_price_per_license)
@@ -2446,7 +2455,7 @@ class BillingSession(ABC):
"sponsorship_plan_name": self.get_sponsorship_plan_name(
customer, is_self_hosted_billing
),
- "discount_percent": format_discount_percentage(discount_for_current_plan),
+ "discount_percent": plan.discount,
"is_self_hosted_billing": is_self_hosted_billing,
"is_server_on_legacy_plan": remote_server_legacy_plan_end_date is not None,
"remote_server_legacy_plan_end_date": remote_server_legacy_plan_end_date,
@@ -2592,11 +2601,12 @@ class BillingSession(ABC):
last_send_invoice.plan.name
)
- percent_off = Decimal(0)
- if customer is not None:
- discount_for_plan_tier = customer.get_discount_for_plan_tier(tier)
- if discount_for_plan_tier is not None:
- percent_off = discount_for_plan_tier
+ annual_price, percent_off_annual_price = get_price_per_license_and_discount(
+ tier, CustomerPlan.BILLING_SCHEDULE_ANNUAL, customer
+ )
+ monthly_price, percent_off_monthly_price = get_price_per_license_and_discount(
+ tier, CustomerPlan.BILLING_SCHEDULE_MONTHLY, customer
+ )
customer_specific_context = self.get_upgrade_page_session_type_specific_context()
min_licenses_for_plan = self.min_licenses_for_plan(tier)
@@ -2636,7 +2646,6 @@ class BillingSession(ABC):
flat_discount, flat_discounted_months = self.get_flat_discount_info(customer)
context: UpgradePageContext = {
"customer_name": customer_specific_context["customer_name"],
- "discount_percent": format_discount_percentage(percent_off),
"email": customer_specific_context["email"],
"exempt_from_license_number_check": exempt_from_license_number_check,
"free_trial_end_date": free_trial_end_date,
@@ -2645,15 +2654,11 @@ class BillingSession(ABC):
"manual_license_management": initial_upgrade_request.manual_license_management,
"page_params": {
"page_type": "upgrade",
- "annual_price": get_price_per_license(
- tier, CustomerPlan.BILLING_SCHEDULE_ANNUAL, percent_off
- ),
+ "annual_price": annual_price,
"demo_organization_scheduled_deletion_date": customer_specific_context[
"demo_organization_scheduled_deletion_date"
],
- "monthly_price": get_price_per_license(
- tier, CustomerPlan.BILLING_SCHEDULE_MONTHLY, percent_off
- ),
+ "monthly_price": monthly_price,
"seat_count": seat_count,
"billing_base_url": self.billing_base_url,
"tier": tier,
@@ -2662,6 +2667,8 @@ class BillingSession(ABC):
"fixed_price": fixed_price,
"setup_payment_by_invoice": setup_payment_by_invoice,
"free_trial_days": free_trial_days,
+ "percent_off_annual_price": percent_off_annual_price,
+ "percent_off_monthly_price": percent_off_monthly_price,
},
"using_min_licenses_for_plan": using_min_licenses_for_plan,
"min_licenses_for_plan": min_licenses_for_plan,
@@ -2708,7 +2715,7 @@ class BillingSession(ABC):
) -> int:
customer = self.get_customer()
if customer is not None and customer.minimum_licenses:
- assert customer.default_discount is not None
+ assert customer.monthly_discounted_price or customer.annual_discounted_price
return customer.minimum_licenses
if tier == CustomerPlan.TIER_SELF_HOSTED_BASIC:
@@ -2945,9 +2952,8 @@ class BillingSession(ABC):
current_plan.status = CustomerPlan.ENDED
current_plan.save(update_fields=["status", "end_date"])
- discount_for_new_plan_tier = current_plan.customer.get_discount_for_plan_tier(new_plan_tier)
- new_price_per_license = get_price_per_license(
- new_plan_tier, current_plan.billing_schedule, discount_for_new_plan_tier
+ new_price_per_license, discount_for_new_plan_tier = get_price_per_license_and_discount(
+ new_plan_tier, current_plan.billing_schedule, current_plan.customer
)
new_plan_billing_cycle_anchor = current_plan.end_date.replace(microsecond=0)
@@ -3355,9 +3361,13 @@ class BillingSession(ABC):
sponsorship_status = support_request["sponsorship_status"]
success_message = self.update_customer_sponsorship_status(sponsorship_status)
elif support_type == SupportType.attach_discount:
- assert support_request["discount"] is not None
- new_discount = support_request["discount"]
- success_message = self.attach_discount_to_customer(new_discount)
+ monthly_discounted_price = support_request["monthly_discounted_price"]
+ annual_discounted_price = support_request["annual_discounted_price"]
+ assert monthly_discounted_price is not None
+ assert annual_discounted_price is not None
+ success_message = self.attach_discount_to_customer(
+ monthly_discounted_price, annual_discounted_price
+ )
elif support_type == SupportType.update_minimum_licenses:
assert support_request["minimum_licenses"] is not None
new_minimum_license_count = support_request["minimum_licenses"]
@@ -4266,7 +4276,11 @@ class RemoteRealmBillingSession(BillingSession):
remote_realm=self.remote_realm, defaults=defaults
)
- if created and not customer.default_discount:
+ if (
+ created
+ and not customer.annual_discounted_price
+ and not customer.monthly_discounted_price
+ ):
customer.flat_discounted_months = 12
customer.save(update_fields=["flat_discounted_months"])
@@ -4699,7 +4713,11 @@ class RemoteServerBillingSession(BillingSession):
remote_server=self.remote_server, defaults=defaults
)
- if created and not customer.default_discount:
+ if (
+ created
+ and not customer.annual_discounted_price
+ and not customer.monthly_discounted_price
+ ):
customer.flat_discounted_months = 12
customer.save(update_fields=["flat_discounted_months"])
@@ -4985,16 +5003,15 @@ def customer_has_credit_card_as_default_payment_method(customer: Customer) -> bo
return stripe_customer_has_credit_card_as_default_payment_method(stripe_customer)
-def calculate_discounted_price_per_license(
- original_price_per_license: int, discount: Decimal
-) -> int:
- # There are no fractional cents in Stripe, so round down to nearest integer.
- return int(float(original_price_per_license * (1 - discount / 100)) + 0.00001)
-
-
def get_price_per_license(
- tier: int, billing_schedule: int, discount: Optional[Decimal] = None
+ tier: int, billing_schedule: int, customer: Optional[Customer] = None
) -> int:
+ if customer is not None:
+ price_per_license = customer.get_discounted_price_for_plan(tier, billing_schedule)
+ if price_per_license:
+ # We already have a set discounted price for the current tier.
+ return price_per_license
+
price_map: Dict[int, Dict[str, int]] = {
CustomerPlan.TIER_CLOUD_STANDARD: {"Annual": 8000, "Monthly": 800},
CustomerPlan.TIER_CLOUD_PLUS: {"Annual": 12000, "Monthly": 1200},
@@ -5012,15 +5029,30 @@ def get_price_per_license(
else: # nocoverage
raise InvalidBillingScheduleError(billing_schedule)
- if discount is not None:
- price_per_license = calculate_discounted_price_per_license(price_per_license, discount)
return price_per_license
+def get_price_per_license_and_discount(
+ tier: int, billing_schedule: int, customer: Optional[Customer]
+) -> Tuple[int, Union[str, None]]:
+ original_price_per_license = get_price_per_license(tier, billing_schedule)
+ if customer is None:
+ return original_price_per_license, None
+
+ price_per_license = get_price_per_license(tier, billing_schedule, customer)
+ if price_per_license == original_price_per_license:
+ return price_per_license, None
+
+ discount = format_discount_percentage(
+ Decimal((original_price_per_license - price_per_license) / original_price_per_license * 100)
+ )
+ return price_per_license, discount
+
+
def compute_plan_parameters(
tier: int,
billing_schedule: int,
- discount: Optional[Decimal],
+ customer: Optional[Customer],
free_trial: bool = False,
billing_cycle_anchor: Optional[datetime] = None,
is_self_hosted_billing: bool = False,
@@ -5039,7 +5071,7 @@ def compute_plan_parameters(
else: # nocoverage
raise InvalidBillingScheduleError(billing_schedule)
- price_per_license = get_price_per_license(tier, billing_schedule, discount)
+ price_per_license = get_price_per_license(tier, billing_schedule, customer)
# `next_invoice_date` is the date when we check if there are any invoices that need to be generated.
# It is always the next month regardless of the billing schedule / billing modality.
diff --git a/corporate/lib/support.py b/corporate/lib/support.py
index 2f08f552ad..f45d6d61b5 100644
--- a/corporate/lib/support.py
+++ b/corporate/lib/support.py
@@ -1,6 +1,5 @@
from dataclasses import dataclass
from datetime import datetime, timedelta
-from decimal import Decimal
from typing import Optional, TypedDict, Union
from urllib.parse import urlencode, urljoin, urlunsplit
@@ -16,6 +15,7 @@ from corporate.lib.stripe import (
RemoteRealmBillingSession,
RemoteServerBillingSession,
get_configured_fixed_price_plan_offer,
+ get_price_per_license,
get_push_status_for_remote_request,
start_of_next_billing_cycle,
)
@@ -58,7 +58,10 @@ class SponsorshipRequestDict(TypedDict):
@dataclass
class SponsorshipData:
sponsorship_pending: bool = False
- default_discount: Optional[Decimal] = None
+ monthly_discounted_price: Optional[int] = None
+ annual_discounted_price: Optional[int] = None
+ original_monthly_plan_price: Optional[int] = None
+ original_annual_plan_price: Optional[int] = None
minimum_licenses: Optional[int] = None
required_plan_tier: Optional[int] = None
latest_sponsorship_request: Optional[SponsorshipRequestDict] = None
@@ -122,10 +125,24 @@ def get_realm_support_url(realm: Realm) -> str:
def get_customer_sponsorship_data(customer: Customer) -> SponsorshipData:
pending = customer.sponsorship_pending
- discount = customer.default_discount
licenses = customer.minimum_licenses
plan_tier = customer.required_plan_tier
sponsorship_request = None
+ monthly_discounted_price = None
+ annual_discounted_price = None
+ original_monthly_plan_price = None
+ original_annual_plan_price = None
+ if customer.monthly_discounted_price:
+ monthly_discounted_price = customer.monthly_discounted_price
+ if customer.annual_discounted_price:
+ annual_discounted_price = customer.annual_discounted_price
+ if plan_tier is not None:
+ original_monthly_plan_price = get_price_per_license(
+ plan_tier, CustomerPlan.BILLING_SCHEDULE_MONTHLY
+ )
+ original_annual_plan_price = get_price_per_license(
+ plan_tier, CustomerPlan.BILLING_SCHEDULE_ANNUAL
+ )
if pending:
last_sponsorship_request = (
ZulipSponsorshipRequest.objects.filter(customer=customer).order_by("id").last()
@@ -151,7 +168,10 @@ def get_customer_sponsorship_data(customer: Customer) -> SponsorshipData:
return SponsorshipData(
sponsorship_pending=pending,
- default_discount=discount,
+ monthly_discounted_price=monthly_discounted_price,
+ annual_discounted_price=annual_discounted_price,
+ original_monthly_plan_price=original_monthly_plan_price,
+ original_annual_plan_price=original_annual_plan_price,
minimum_licenses=licenses,
required_plan_tier=plan_tier,
latest_sponsorship_request=sponsorship_request,
diff --git a/corporate/migrations/0043_remove_customer_default_discount_and_more.py b/corporate/migrations/0043_remove_customer_default_discount_and_more.py
new file mode 100644
index 0000000000..21e7d2c4af
--- /dev/null
+++ b/corporate/migrations/0043_remove_customer_default_discount_and_more.py
@@ -0,0 +1,89 @@
+# Generated by Django 5.0.5 on 2024-05-03 06:50
+
+from decimal import Decimal
+
+from django.db import migrations, models
+from django.db.backends.base.schema import BaseDatabaseSchemaEditor
+from django.db.migrations.state import StateApps
+
+# It's generally unsafe to import product code from migrations,
+# because the migration will be run with the **current** version of
+# that code, not the version at the time of the migration. But because
+# this migration will only be run for Zulip Cloud, this is OK.
+from corporate.lib.stripe import get_price_per_license
+
+
+def calculate_discounted_price_per_license(
+ original_price_per_license: int, discount: Decimal
+) -> int:
+ # There are no fractional cents in Stripe, so round down to nearest integer.
+ return int(float(original_price_per_license * (1 - discount / 100)) + 0.00001)
+
+
+def calculate_discounted_price(apps: StateApps, schema_editor: BaseDatabaseSchemaEditor) -> None:
+ """Migrates existing customers with a configured default discount to
+ instead have discounted prices for their configured required_plan_tier.
+
+ Does not operate on CustomerPlan fields, since those are already
+ resolved into a price.
+ """
+ BILLING_SCHEDULE_ANNUAL = 1
+ BILLING_SCHEDULE_MONTHLY = 2
+
+ Customer = apps.get_model("corporate", "Customer")
+ customers_to_update = []
+ for customer in Customer.objects.all():
+ if not customer.required_plan_tier or not customer.default_discount:
+ continue
+
+ annual_price_per_license = get_price_per_license(
+ customer.required_plan_tier, BILLING_SCHEDULE_ANNUAL
+ )
+ customer.annual_discounted_price = calculate_discounted_price_per_license(
+ annual_price_per_license, customer.default_discount
+ )
+ monthly_price_per_license = get_price_per_license(
+ customer.required_plan_tier, BILLING_SCHEDULE_MONTHLY
+ )
+ customer.monthly_discounted_price = calculate_discounted_price_per_license(
+ monthly_price_per_license, customer.default_discount
+ )
+ customers_to_update.append(customer)
+ print(
+ f"\nChanging price for {customer.id}: {customer.default_discount} => {customer.annual_discounted_price}/{customer.monthly_discounted_price}"
+ )
+
+ Customer.objects.bulk_update(
+ customers_to_update, ["annual_discounted_price", "monthly_discounted_price"]
+ )
+
+
+class Migration(migrations.Migration):
+ dependencies = [
+ ("corporate", "0042_invoice_is_created_for_free_trial_upgrade_and_more"),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name="customer",
+ name="annual_discounted_price",
+ field=models.IntegerField(default=0),
+ ),
+ migrations.AddField(
+ model_name="customer",
+ name="monthly_discounted_price",
+ field=models.IntegerField(default=0),
+ ),
+ # Populate the new discounted price fields based on existing default discount.
+ migrations.RunPython(calculate_discounted_price),
+ migrations.RemoveField(
+ model_name="customer",
+ name="default_discount",
+ ),
+ # This automatically converts the decimal to string so we don't need to do anything here.
+ migrations.AlterField(
+ model_name="customerplan",
+ name="discount",
+ field=models.TextField(null=True),
+ ),
+ ]
diff --git a/corporate/models.py b/corporate/models.py
index ab4a1abeea..6256862835 100644
--- a/corporate/models.py
+++ b/corporate/models.py
@@ -1,4 +1,3 @@
-from decimal import Decimal
from enum import Enum
from typing import Any, Dict, Optional, Union
@@ -27,10 +26,16 @@ class Customer(models.Model):
stripe_customer_id = models.CharField(max_length=255, null=True, unique=True)
sponsorship_pending = models.BooleanField(default=False)
- # A percentage, like 85.
- default_discount = models.DecimalField(decimal_places=4, max_digits=7, null=True)
+
+ # Discounted price for required_plan_tier in cents.
+ # We treat 0 as no discount. Not using `null` here keeps the
+ # checks simpler and avoids the cases where we forget to
+ # check for both `null` and 0.
+ monthly_discounted_price = models.IntegerField(default=0, null=False)
+ annual_discounted_price = models.IntegerField(default=0, null=False)
+
minimum_licenses = models.PositiveIntegerField(null=True)
- # Used for limiting a default_discount or a fixed_price
+ # Used for limiting discounted price or a fixed_price
# to be used only for a particular CustomerPlan tier.
required_plan_tier = models.SmallIntegerField(null=True)
# Some non-profit organizations on manual license management pay
@@ -64,10 +69,15 @@ class Customer(models.Model):
else:
return f"{self.remote_server!r} (with stripe_customer_id: {self.stripe_customer_id})"
- def get_discount_for_plan_tier(self, plan_tier: int) -> Optional[Decimal]:
- if self.required_plan_tier is None or self.required_plan_tier == plan_tier:
- return self.default_discount
- return None
+ def get_discounted_price_for_plan(self, plan_tier: int, schedule: int) -> Optional[int]:
+ if plan_tier != self.required_plan_tier:
+ return None
+
+ if schedule == CustomerPlan.BILLING_SCHEDULE_ANNUAL:
+ return self.annual_discounted_price
+
+ assert schedule == CustomerPlan.BILLING_SCHEDULE_MONTHLY
+ return self.monthly_discounted_price
def get_customer_by_realm(realm: Realm) -> Optional[Customer]:
@@ -325,8 +335,10 @@ class CustomerPlan(AbstractCustomerPlan):
# can't be set via the self-serve billing system.
price_per_license = models.IntegerField(null=True)
- # Discount that was applied. For display purposes only.
- discount = models.DecimalField(decimal_places=4, max_digits=6, null=True)
+ # Discount for current `billing_schedule`. For display purposes only.
+ # Explicitly set to be TextField to avoid being used in calculations.
+ # NOTE: This discount can be different for annual and monthly schedules.
+ discount = models.TextField(null=True)
# Initialized with the time of plan creation. Used for calculating
# start of next billing cycle, next invoice date etc. This value
diff --git a/corporate/tests/test_activity_views.py b/corporate/tests/test_activity_views.py
index cb066bd87f..0bd21d05eb 100644
--- a/corporate/tests/test_activity_views.py
+++ b/corporate/tests/test_activity_views.py
@@ -141,7 +141,7 @@ class ActivityTest(ZulipTestCase):
user_profile.is_staff = True
user_profile.save(update_fields=["is_staff"])
- with self.assert_database_query_count(12):
+ with self.assert_database_query_count(11):
result = self.client_get("/activity")
self.assertEqual(result.status_code, 200)
diff --git a/corporate/tests/test_stripe.py b/corporate/tests/test_stripe.py
index 2a3ccf5494..22a11eb8f5 100644
--- a/corporate/tests/test_stripe.py
+++ b/corporate/tests/test_stripe.py
@@ -41,7 +41,6 @@ from django.utils.crypto import get_random_string
from django.utils.timezone import now as timezone_now
from typing_extensions import ParamSpec, override
-from corporate.lib.activity import get_realms_with_default_discount_dict
from corporate.lib.stripe import (
DEFAULT_INVOICE_DAYS_UNTIL_DUE,
MAX_INVOICED_LICENSES,
@@ -4542,7 +4541,7 @@ class StripeTest(StripeTestCase):
user=self.example_user("iago"), realm=realm, support_session=True
)
billing_session.set_required_plan_tier(CustomerPlan.TIER_CLOUD_STANDARD)
- billing_session.attach_discount_to_customer(Decimal(20))
+ billing_session.attach_discount_to_customer(640, 6400)
rows.append(Row(realm, Realm.PLAN_TYPE_SELF_HOSTED, None, None, 0, False))
# no active paid plan or invoices (no action)
@@ -5122,8 +5121,15 @@ class BillingHelpersTest(ZulipTestCase):
anchor = datetime(2019, 12, 31, 1, 2, 3, tzinfo=timezone.utc)
month_later = datetime(2020, 1, 31, 1, 2, 3, tzinfo=timezone.utc)
year_later = datetime(2020, 12, 31, 1, 2, 3, tzinfo=timezone.utc)
+ customer_with_discount = Customer.objects.create(
+ realm=get_realm("lear"),
+ monthly_discounted_price=600,
+ annual_discounted_price=6000,
+ required_plan_tier=CustomerPlan.TIER_CLOUD_STANDARD,
+ )
+ customer_no_discount = Customer.objects.create(realm=get_realm("zulip"))
test_cases = [
- # test all possibilities, since there aren't that many
+ # Annual standard no customer
(
(
CustomerPlan.TIER_CLOUD_STANDARD,
@@ -5132,34 +5138,34 @@ class BillingHelpersTest(ZulipTestCase):
),
(anchor, month_later, year_later, 8000),
),
- (
- (CustomerPlan.TIER_CLOUD_STANDARD, CustomerPlan.BILLING_SCHEDULE_ANNUAL, 85),
- (anchor, month_later, year_later, 1200),
- ),
- (
- (
- CustomerPlan.TIER_CLOUD_STANDARD,
- CustomerPlan.BILLING_SCHEDULE_MONTHLY,
- None,
- ),
- (anchor, month_later, month_later, 800),
- ),
- (
- (CustomerPlan.TIER_CLOUD_STANDARD, CustomerPlan.BILLING_SCHEDULE_MONTHLY, 85),
- (anchor, month_later, month_later, 120),
- ),
+ # Annual standard with discount
(
(
CustomerPlan.TIER_CLOUD_STANDARD,
CustomerPlan.BILLING_SCHEDULE_ANNUAL,
- None,
+ customer_with_discount,
+ ),
+ (anchor, month_later, year_later, 6000),
+ ),
+ # Annual standard customer but no discount
+ (
+ (
+ CustomerPlan.TIER_CLOUD_STANDARD,
+ CustomerPlan.BILLING_SCHEDULE_ANNUAL,
+ customer_no_discount,
),
(anchor, month_later, year_later, 8000),
),
+ # Annual plus customer with discount but different tier than required for discount
(
- (CustomerPlan.TIER_CLOUD_STANDARD, CustomerPlan.BILLING_SCHEDULE_ANNUAL, 85),
- (anchor, month_later, year_later, 1200),
+ (
+ CustomerPlan.TIER_CLOUD_PLUS,
+ CustomerPlan.BILLING_SCHEDULE_ANNUAL,
+ customer_with_discount,
+ ),
+ (anchor, month_later, year_later, 12000),
),
+ # Monthly standard no customer
(
(
CustomerPlan.TIER_CLOUD_STANDARD,
@@ -5168,43 +5174,56 @@ class BillingHelpersTest(ZulipTestCase):
),
(anchor, month_later, month_later, 800),
),
+ # Monthly standard with discount
(
(
CustomerPlan.TIER_CLOUD_STANDARD,
CustomerPlan.BILLING_SCHEDULE_MONTHLY,
- 85,
+ customer_with_discount,
),
- (anchor, month_later, month_later, 120),
+ (anchor, month_later, month_later, 600),
),
- # test exact math of Decimals; 800 * (1 - 87.25) = 101.9999999..
+ # Monthly standard customer but no discount
(
(
CustomerPlan.TIER_CLOUD_STANDARD,
CustomerPlan.BILLING_SCHEDULE_MONTHLY,
- 87.25,
+ customer_no_discount,
),
- (anchor, month_later, month_later, 102),
+ (anchor, month_later, month_later, 800),
),
- # test dropping of fractional cents; without the int it's 102.8
+ # Monthly plus customer with discount but different tier than required for discount
(
(
- CustomerPlan.TIER_CLOUD_STANDARD,
+ CustomerPlan.TIER_CLOUD_PLUS,
CustomerPlan.BILLING_SCHEDULE_MONTHLY,
- 87.15,
+ customer_with_discount,
),
- (anchor, month_later, month_later, 102),
+ (anchor, month_later, month_later, 1200),
),
]
with time_machine.travel(anchor, tick=False):
- for (tier, billing_schedule, discount), output in test_cases:
+ for (tier, billing_schedule, customer), output in test_cases:
output_ = compute_plan_parameters(
tier,
billing_schedule,
- None if discount is None else Decimal(discount),
+ customer,
)
self.assertEqual(output_, output)
def test_get_price_per_license(self) -> None:
+ standard_discounted_customer = Customer.objects.create(
+ realm=get_realm("lear"),
+ monthly_discounted_price=400,
+ annual_discounted_price=4000,
+ required_plan_tier=CustomerPlan.TIER_CLOUD_STANDARD,
+ )
+ plus_discounted_customer = Customer.objects.create(
+ realm=get_realm("zulip"),
+ monthly_discounted_price=600,
+ annual_discounted_price=6000,
+ required_plan_tier=CustomerPlan.TIER_CLOUD_PLUS,
+ )
self.assertEqual(
get_price_per_license(
CustomerPlan.TIER_CLOUD_STANDARD, CustomerPlan.BILLING_SCHEDULE_ANNUAL
@@ -5221,7 +5240,7 @@ class BillingHelpersTest(ZulipTestCase):
get_price_per_license(
CustomerPlan.TIER_CLOUD_STANDARD,
CustomerPlan.BILLING_SCHEDULE_MONTHLY,
- discount=Decimal(50),
+ standard_discounted_customer,
),
400,
)
@@ -5242,7 +5261,16 @@ class BillingHelpersTest(ZulipTestCase):
get_price_per_license(
CustomerPlan.TIER_CLOUD_PLUS,
CustomerPlan.BILLING_SCHEDULE_MONTHLY,
- discount=Decimal(50),
+ # Wrong tier so discount not applied.
+ standard_discounted_customer,
+ ),
+ 1200,
+ )
+ self.assertEqual(
+ get_price_per_license(
+ CustomerPlan.TIER_CLOUD_PLUS,
+ CustomerPlan.BILLING_SCHEDULE_MONTHLY,
+ plus_discounted_customer,
),
600,
)
@@ -5433,37 +5461,6 @@ class BillingHelpersTest(ZulipTestCase):
)
-class AnalyticsHelpersTest(ZulipTestCase):
- def test_get_realms_to_default_discount_dict(self) -> None:
- Customer.objects.create(realm=get_realm("zulip"), stripe_customer_id="cus_1")
- lear_customer = Customer.objects.create(realm=get_realm("lear"), stripe_customer_id="cus_2")
- lear_customer.default_discount = Decimal(30)
- lear_customer.save(update_fields=["default_discount"])
- zephyr_customer = Customer.objects.create(
- realm=get_realm("zephyr"), stripe_customer_id="cus_3"
- )
- zephyr_customer.default_discount = Decimal(0)
- zephyr_customer.save(update_fields=["default_discount"])
- remote_server = RemoteZulipServer.objects.create(
- uuid=str(uuid.uuid4()),
- api_key="magic_secret_api_key",
- hostname="demo.example.com",
- contact_email="email@example.com",
- )
- remote_customer = Customer.objects.create(
- remote_server=remote_server, stripe_customer_id="cus_4"
- )
- remote_customer.default_discount = Decimal(50)
- remote_customer.save(update_fields=["default_discount"])
-
- self.assertEqual(
- get_realms_with_default_discount_dict(),
- {
- "lear": Decimal("30.0000"),
- },
- )
-
-
class LicenseLedgerTest(StripeTestCase):
def test_add_plan_renewal_if_needed(self) -> None:
with time_machine.travel(self.now, tick=False):
@@ -6039,19 +6036,33 @@ class TestSupportBillingHelpers(StripeTestCase):
# Cannot attach discount without a required_plan_tier set.
with self.assertRaises(AssertionError):
- billing_session.attach_discount_to_customer(Decimal(85))
+ billing_session.attach_discount_to_customer(
+ monthly_discounted_price=120,
+ annual_discounted_price=1200,
+ )
billing_session.update_or_create_customer()
with self.assertRaises(AssertionError):
- billing_session.attach_discount_to_customer(Decimal(85))
+ billing_session.attach_discount_to_customer(
+ monthly_discounted_price=120,
+ annual_discounted_price=1200,
+ )
billing_session.set_required_plan_tier(CustomerPlan.TIER_CLOUD_STANDARD)
- billing_session.attach_discount_to_customer(Decimal(85))
+ billing_session.attach_discount_to_customer(
+ monthly_discounted_price=120,
+ annual_discounted_price=1200,
+ )
realm_audit_log = RealmAuditLog.objects.filter(
event_type=RealmAuditLog.REALM_DISCOUNT_CHANGED
).last()
assert realm_audit_log is not None
- expected_extra_data = {"old_discount": None, "new_discount": str(Decimal("85"))}
+ expected_extra_data = {
+ "new_annual_discounted_price": 1200,
+ "new_monthly_discounted_price": 120,
+ "old_annual_discounted_price": 0,
+ "old_monthly_discounted_price": 0,
+ }
self.assertEqual(realm_audit_log.extra_data, expected_extra_data)
self.login_user(user)
# Check that the discount appears in page_params
@@ -6071,14 +6082,17 @@ class TestSupportBillingHelpers(StripeTestCase):
[item.amount for item in invoice.lines],
)
# Check CustomerPlan reflects the discount
- plan = CustomerPlan.objects.get(price_per_license=1200, discount=Decimal(85))
+ plan = CustomerPlan.objects.get(price_per_license=1200, discount="85")
# Attach discount to existing Stripe customer
plan.status = CustomerPlan.ENDED
plan.save(update_fields=["status"])
billing_session = RealmBillingSession(support_admin, realm=user.realm, support_session=True)
billing_session.set_required_plan_tier(CustomerPlan.TIER_CLOUD_STANDARD)
- billing_session.attach_discount_to_customer(Decimal(25))
+ billing_session.attach_discount_to_customer(
+ monthly_discounted_price=600,
+ annual_discounted_price=6000,
+ )
with time_machine.travel(self.now, tick=False):
self.add_card_and_upgrade(
user, license_management="automatic", billing_modality="charge_automatically"
@@ -6095,12 +6109,16 @@ class TestSupportBillingHelpers(StripeTestCase):
plan = CustomerPlan.objects.get(price_per_license=6000, discount=Decimal(25))
billing_session = RealmBillingSession(support_admin, realm=user.realm, support_session=True)
- billing_session.attach_discount_to_customer(Decimal(50))
+ billing_session.attach_discount_to_customer(
+ monthly_discounted_price=400,
+ annual_discounted_price=4000,
+ )
plan.refresh_from_db()
self.assertEqual(plan.price_per_license, 4000)
- self.assertEqual(plan.discount, 50)
+ self.assertEqual(plan.discount, "50")
customer.refresh_from_db()
- self.assertEqual(customer.default_discount, 50)
+ self.assertEqual(customer.monthly_discounted_price, 400)
+ self.assertEqual(customer.annual_discounted_price, 4000)
# Fast forward the next_invoice_date to next year.
plan.next_invoice_date = self.next_year
plan.save(update_fields=["next_invoice_date"])
@@ -6114,8 +6132,10 @@ class TestSupportBillingHelpers(StripeTestCase):
).last()
assert realm_audit_log is not None
expected_extra_data = {
- "old_discount": str(Decimal("25.0000")),
- "new_discount": str(Decimal("50")),
+ "new_annual_discounted_price": 4000,
+ "new_monthly_discounted_price": 400,
+ "old_annual_discounted_price": 6000,
+ "old_monthly_discounted_price": 600,
}
self.assertEqual(realm_audit_log.extra_data, expected_extra_data)
self.assertEqual(realm_audit_log.acting_user, support_admin)
@@ -6146,7 +6166,10 @@ class TestSupportBillingHelpers(StripeTestCase):
billing_session.process_support_view_request(support_view_request)
billing_session.set_required_plan_tier(CustomerPlan.TIER_CLOUD_STANDARD)
- billing_session.attach_discount_to_customer(Decimal(50))
+ billing_session.attach_discount_to_customer(
+ monthly_discounted_price=400,
+ annual_discounted_price=4000,
+ )
message = billing_session.process_support_view_request(support_view_request)
self.assertEqual("Minimum licenses for zulip changed to 25 from 0.", message)
realm_audit_log = RealmAuditLog.objects.filter(
@@ -6202,16 +6225,34 @@ class TestSupportBillingHelpers(StripeTestCase):
customer = billing_session.get_customer()
assert customer is not None
self.assertEqual(customer.required_plan_tier, valid_plan_tier)
- self.assertEqual(customer.default_discount, None)
+ self.assertEqual(customer.monthly_discounted_price, 0)
+ self.assertEqual(customer.annual_discounted_price, 0)
# Check that discount is only applied to set plan tier
- billing_session.attach_discount_to_customer(Decimal(50))
+ billing_session.attach_discount_to_customer(
+ monthly_discounted_price=400,
+ annual_discounted_price=4000,
+ )
customer.refresh_from_db()
- self.assertEqual(customer.default_discount, Decimal(50))
- discount_for_standard_plan = customer.get_discount_for_plan_tier(valid_plan_tier)
- self.assertEqual(discount_for_standard_plan, customer.default_discount)
- discount_for_plus_plan = customer.get_discount_for_plan_tier(CustomerPlan.TIER_CLOUD_PLUS)
- self.assertEqual(discount_for_plus_plan, None)
+ self.assertEqual(customer.monthly_discounted_price, 400)
+ self.assertEqual(customer.annual_discounted_price, 4000)
+
+ monthly_discounted_price = customer.get_discounted_price_for_plan(
+ valid_plan_tier, CustomerPlan.BILLING_SCHEDULE_MONTHLY
+ )
+ self.assertEqual(monthly_discounted_price, customer.monthly_discounted_price)
+ annual_discounted_price = customer.get_discounted_price_for_plan(
+ valid_plan_tier, CustomerPlan.BILLING_SCHEDULE_ANNUAL
+ )
+ self.assertEqual(annual_discounted_price, customer.annual_discounted_price)
+ monthly_discounted_price = customer.get_discounted_price_for_plan(
+ CustomerPlan.TIER_CLOUD_PLUS, CustomerPlan.BILLING_SCHEDULE_MONTHLY
+ )
+ self.assertEqual(monthly_discounted_price, None)
+ annual_discounted_price = customer.get_discounted_price_for_plan(
+ CustomerPlan.TIER_CLOUD_PLUS, CustomerPlan.BILLING_SCHEDULE_ANNUAL
+ )
+ self.assertEqual(annual_discounted_price, None)
# Try to set invalid plan tier
invalid_plan_tier = CustomerPlan.TIER_SELF_HOSTED_BASE
@@ -6232,15 +6273,22 @@ class TestSupportBillingHelpers(StripeTestCase):
):
billing_session.process_support_view_request(support_view_request)
- billing_session.attach_discount_to_customer(Decimal(0))
+ billing_session.attach_discount_to_customer(
+ monthly_discounted_price=0,
+ annual_discounted_price=0,
+ )
message = billing_session.process_support_view_request(support_view_request)
self.assertEqual("Required plan tier for zulip set to None.", message)
customer.refresh_from_db()
self.assertIsNone(customer.required_plan_tier)
- discount_for_standard_plan = customer.get_discount_for_plan_tier(valid_plan_tier)
- self.assertEqual(discount_for_standard_plan, customer.default_discount)
- discount_for_plus_plan = customer.get_discount_for_plan_tier(CustomerPlan.TIER_CLOUD_PLUS)
- self.assertEqual(discount_for_plus_plan, customer.default_discount)
+ discount_for_standard_plan = customer.get_discounted_price_for_plan(
+ valid_plan_tier, CustomerPlan.BILLING_SCHEDULE_MONTHLY
+ )
+ self.assertEqual(discount_for_standard_plan, None)
+ discount_for_plus_plan = customer.get_discounted_price_for_plan(
+ CustomerPlan.TIER_CLOUD_PLUS, CustomerPlan.BILLING_SCHEDULE_MONTHLY
+ )
+ self.assertEqual(discount_for_plus_plan, None)
realm_audit_log = RealmAuditLog.objects.filter(
event_type=RealmAuditLog.CUSTOMER_PROPERTY_CHANGED
).last()
diff --git a/corporate/tests/test_support_views.py b/corporate/tests/test_support_views.py
index f0737c6d1e..7e452815a4 100644
--- a/corporate/tests/test_support_views.py
+++ b/corporate/tests/test_support_views.py
@@ -1,5 +1,4 @@
from datetime import datetime, timedelta, timezone
-from decimal import Decimal
from typing import TYPE_CHECKING, Any, Optional
from unittest import mock
@@ -81,7 +80,6 @@ class TestRemoteServerSupportEndpoint(ZulipTestCase):
"automanage_licenses": True,
"charge_automatically": False,
"price_per_license": 100,
- "discount": legacy_plan.customer.default_discount,
"billing_cycle_anchor": legacy_plan.end_date,
"billing_schedule": CustomerPlan.BILLING_SCHEDULE_MONTHLY,
"tier": CustomerPlan.TIER_SELF_HOSTED_BASIC,
@@ -516,7 +514,8 @@ class TestRemoteServerSupportEndpoint(ZulipTestCase):
self.assertEqual(plan.status, CustomerPlan.SWITCH_PLAN_TIER_AT_PLAN_END)
self.assertEqual(next_plan.status, CustomerPlan.NEVER_STARTED)
- self.assertIsNone(customer.default_discount)
+ self.assertEqual(customer.monthly_discounted_price, 0)
+ self.assertEqual(customer.annual_discounted_price, 0)
self.assertIsNone(plan.discount)
self.assertIsNone(next_plan.discount)
@@ -558,18 +557,26 @@ class TestRemoteServerSupportEndpoint(ZulipTestCase):
)
result = self.client_post(
"/activity/remote/support",
- {"remote_realm_id": f"{remote_realm.id}", "discount": "50"},
+ {
+ "remote_realm_id": f"{remote_realm.id}",
+ "monthly_discounted_price": "50",
+ "annual_discounted_price": "500",
+ },
)
self.assert_in_success_response(
- ["Discount for realm-name-4 changed to 50% from 0%."], result
+ [
+ "Monthly price for realm-name-4 changed to 50 from 0. Annual price changed to 500 from 0."
+ ],
+ result,
)
customer.refresh_from_db()
plan.refresh_from_db()
next_plan.refresh_from_db()
- self.assertEqual(customer.default_discount, Decimal(50))
+ self.assertEqual(customer.monthly_discounted_price, 50)
+ self.assertEqual(customer.annual_discounted_price, 500)
# Discount for current plan stays None since it is not the same as required tier for discount.
self.assertEqual(plan.discount, None)
- self.assertEqual(next_plan.discount, Decimal(50))
+ self.assertEqual(next_plan.discount, "85.71")
self.assertEqual(plan.tier, CustomerPlan.TIER_SELF_HOSTED_LEGACY)
self.assertEqual(next_plan.tier, CustomerPlan.TIER_SELF_HOSTED_BASIC)
@@ -765,7 +772,8 @@ class TestSupportEndpoint(ZulipTestCase):
"Zulip Dev",
'',
'',
- 'input type="number" name="discount" value="None"',
+ 'input type="number" name="monthly_discounted_price" value="None"',
+ 'input type="number" name="annual_discounted_price" value="None"',
'',
'',
f'',
'',
- 'input type="number" name="discount" value="None"',
+ 'input type="number" name="monthly_discounted_price" value="None"',
+ 'input type="number" name="annual_discounted_price" value="None"',
'',
'',
'scrub-realm-button">',
@@ -1112,9 +1121,12 @@ class TestSupportEndpoint(ZulipTestCase):
cordelia = self.example_user("cordelia")
self.login_user(cordelia)
- result = self.client_post(
- "/activity/support", {"realm_id": f"{lear_realm.id}", "discount": "25"}
- )
+ discount_change_data = {
+ "realm_id": f"{lear_realm.id}",
+ "monthly_discounted_price": "600",
+ "annual_discounted_price": "6000",
+ }
+ result = self.client_post("/activity/support", discount_change_data)
self.assertEqual(result.status_code, 302)
self.assertEqual(result["Location"], "/login/")
@@ -1132,19 +1144,181 @@ class TestSupportEndpoint(ZulipTestCase):
["Required plan tier for lear set to Zulip Cloud Standard."],
result,
)
- result = self.client_post(
- "/activity/support", {"realm_id": f"{lear_realm.id}", "discount": "25"}
+ result = self.client_post("/activity/support", discount_change_data)
+ self.assert_in_success_response(
+ ["Monthly price for lear changed to 600 from 0. Annual price changed to 6000 from 0."],
+ result,
)
- self.assert_in_success_response(["Discount for lear changed to 25% from 0%"], result)
customer.refresh_from_db()
plan = get_current_plan_by_customer(customer)
assert plan is not None
- self.assertEqual(customer.default_discount, Decimal(25))
- self.assertEqual(plan.discount, Decimal(25))
+ self.assertEqual(customer.monthly_discounted_price, 600)
+ self.assertEqual(customer.annual_discounted_price, 6000)
+ self.assertEqual(plan.discount, "25")
start_next_billing_cycle = start_of_next_billing_cycle(plan, timezone_now())
billing_cycle_string = start_next_billing_cycle.strftime("%d %B %Y")
+ twenty_five_percent_discounted_response = [
+ "Plan name: Zulip Cloud Standard",
+ "Status: Active",
+ "Discount: 25%",
+ "Billing schedule: Monthly",
+ "Licenses: 2/10 (Manual)",
+ "Price per license: $6.00",
+ "Annual recurring revenue: $720.00",
+ f"Start of next billing cycle: {billing_cycle_string}",
+ ]
+ result = self.client_get("/activity/support", {"q": "lear"})
+ self.assert_in_success_response(
+ twenty_five_percent_discounted_response,
+ result,
+ )
+
+ # Set price back to original price to reset discount.
+ result = self.client_post(
+ "/activity/support",
+ {
+ "realm_id": f"{lear_realm.id}",
+ "monthly_discounted_price": "800",
+ "annual_discounted_price": "8000",
+ },
+ )
+
+ no_discount_response = [
+ "Plan name: Zulip Cloud Standard",
+ "Status: Active",
+ "Billing schedule: Monthly",
+ "Licenses: 2/10 (Manual)",
+ "Price per license: $8.00",
+ "Annual recurring revenue: $960.00",
+ f"Start of next billing cycle: {billing_cycle_string}",
+ ]
+ result = self.client_get("/activity/support", {"q": "lear"})
+ self.assert_in_success_response(
+ no_discount_response,
+ result,
+ )
+
+ # Apply 25% discount again.
+ self.client_post("/activity/support", discount_change_data)
+ result = self.client_get("/activity/support", {"q": "lear"})
+ self.assert_in_success_response(twenty_five_percent_discounted_response, result)
+
+ # Set discount price to 0 to reset discount
+ result = self.client_post(
+ "/activity/support",
+ {
+ "realm_id": f"{lear_realm.id}",
+ "monthly_discounted_price": "0",
+ "annual_discounted_price": "0",
+ },
+ )
+
+ result = self.client_get("/activity/support", {"q": "lear"})
+ self.assert_in_success_response(
+ no_discount_response,
+ result,
+ )
+
+ # Apply monthly discount but no annual discount.
+ result = self.client_post(
+ "/activity/support",
+ {
+ "realm_id": f"{lear_realm.id}",
+ "monthly_discounted_price": "600",
+ "annual_discounted_price": "0",
+ },
+ )
+
+ monthly_discounted_response = [
+ "Plan name: Zulip Cloud Standard",
+ "Status: Active",
+ "Discount: 25%",
+ "Billing schedule: Monthly",
+ "Licenses: 2/10 (Manual)",
+ "Price per license: $6.00",
+ "Annual recurring revenue: $720.00",
+ f"Start of next billing cycle: {billing_cycle_string}",
+ ]
+ result = self.client_get("/activity/support", {"q": "lear"})
+ self.assert_in_success_response(
+ monthly_discounted_response,
+ result,
+ )
+
+ # Apply annual discount but no monthly discount.
+ result = self.client_post(
+ "/activity/support",
+ {
+ "realm_id": f"{lear_realm.id}",
+ "monthly_discounted_price": "0",
+ "annual_discounted_price": "6000",
+ },
+ )
+
+ # Since user is on monthly schedule no discount is applied.
+ result = self.client_get("/activity/support", {"q": "lear"})
+ self.assert_in_success_response(
+ no_discount_response,
+ result,
+ )
+
+ # Switch user to annual plan and the discount should be automatically applied.
+ plan.status = CustomerPlan.SWITCH_TO_ANNUAL_AT_END_OF_CYCLE
+ plan.save(update_fields=["status"])
+ support_admin = self.example_user("iago")
+ assert plan.next_invoice_date is not None
+ RealmBillingSession(
+ support_admin, lear_realm, support_session=True
+ ).make_end_of_cycle_updates_if_needed(plan, plan.next_invoice_date)
+ result = self.client_get("/activity/support", {"q": "lear"})
+ self.assert_in_success_response(
+ [
+ "Plan name: Zulip Cloud Standard",
+ "Status: Active",
+ "Discount: 25%",
+ "Billing schedule: Annual",
+ "Licenses: 2/10 (Manual)",
+ "Price per license: $60.00",
+ "Annual recurring revenue: $600.00",
+ ],
+ result,
+ )
+
+ # Apply a monthly discount but no annual discount.
+ result = self.client_post(
+ "/activity/support",
+ {
+ "realm_id": f"{lear_realm.id}",
+ "monthly_discounted_price": "600",
+ "annual_discounted_price": "0",
+ },
+ )
+
+ result = self.client_get("/activity/support", {"q": "lear"})
+ self.assert_in_success_response(
+ [
+ "Plan name: Zulip Cloud Standard",
+ "Status: Active",
+ "Billing schedule: Annual",
+ "Licenses: 2/10 (Manual)",
+ "Price per license: $80.00",
+ "Annual recurring revenue: $800.00",
+ ],
+ result,
+ )
+
+ # Switch customer to monthly plan and the discount should be automatically applied.
+ plan = get_current_plan_by_customer(customer)
+ assert plan is not None
+ plan.status = CustomerPlan.SWITCH_TO_MONTHLY_AT_END_OF_CYCLE
+ plan.save(update_fields=["status"])
+ support_admin = self.example_user("iago")
+ assert plan.next_invoice_date is not None
+ RealmBillingSession(
+ support_admin, lear_realm, support_session=True
+ ).make_end_of_cycle_updates_if_needed(plan, add_months(plan.next_invoice_date, 12))
result = self.client_get("/activity/support", {"q": "lear"})
self.assert_in_success_response(
[
@@ -1155,7 +1329,6 @@ class TestSupportEndpoint(ZulipTestCase):
"Licenses: 2/10 (Manual)",
"Price per license: $6.00",
"Annual recurring revenue: $720.00",
- f"Start of next billing cycle: {billing_cycle_string}",
],
result,
)
diff --git a/corporate/views/installation_activity.py b/corporate/views/installation_activity.py
index 3cc2431728..cfff9fee69 100644
--- a/corporate/views/installation_activity.py
+++ b/corporate/views/installation_activity.py
@@ -13,12 +13,11 @@ from psycopg2.sql import SQL
from analytics.lib.counts import COUNT_STATS
from corporate.lib.activity import (
dictfetchall,
- estimate_annual_recurring_revenue_by_realm,
fix_rows,
format_datetime_as_date,
format_optional_datetime,
+ get_estimated_arr_and_rate_by_realm,
get_query_data,
- get_realms_with_default_discount_dict,
make_table,
realm_activity_link,
realm_stats_link,
@@ -211,8 +210,7 @@ def realm_summary_table() -> str:
# estimate annual subscription revenue
total_arr = 0
if settings.BILLING_ENABLED:
- estimated_arrs = estimate_annual_recurring_revenue_by_realm()
- realms_with_default_discount = get_realms_with_default_discount_dict()
+ estimated_arrs, plan_rates = get_estimated_arr_and_rate_by_realm()
for row in rows:
row["plan_type_string"] = get_plan_type_string(row["plan_type"])
@@ -223,14 +221,9 @@ def realm_summary_table() -> str:
row["arr"] = f"${cents_to_dollar_string(estimated_arrs[string_id])}"
if row["plan_type"] in [Realm.PLAN_TYPE_STANDARD, Realm.PLAN_TYPE_PLUS]:
- row["effective_rate"] = 100 - int(realms_with_default_discount.get(string_id, 0))
+ row["effective_rate"] = plan_rates.get(string_id, "")
elif row["plan_type"] == Realm.PLAN_TYPE_STANDARD_FREE:
row["effective_rate"] = 0
- elif (
- row["plan_type"] == Realm.PLAN_TYPE_LIMITED
- and string_id in realms_with_default_discount
- ):
- row["effective_rate"] = 100 - int(realms_with_default_discount[string_id])
else:
row["effective_rate"] = ""
diff --git a/corporate/views/support.py b/corporate/views/support.py
index 7a5e2cfd0d..978eba6c46 100644
--- a/corporate/views/support.py
+++ b/corporate/views/support.py
@@ -2,7 +2,6 @@ import uuid
from contextlib import suppress
from dataclasses import dataclass
from datetime import timedelta
-from decimal import Decimal
from operator import attrgetter
from typing import Any, Dict, Iterable, List, Optional, Union
from urllib.parse import urlencode, urlsplit
@@ -34,7 +33,6 @@ from corporate.lib.stripe import (
cents_to_dollar_string,
do_deactivate_remote_server,
do_reactivate_remote_server,
- format_discount_percentage,
)
from corporate.lib.support import (
CloudSupportData,
@@ -66,7 +64,6 @@ from zerver.lib.validator import (
check_date,
check_string,
check_string_in,
- to_decimal,
to_non_negative_int,
)
from zerver.models import (
@@ -339,7 +336,8 @@ def support(
request: HttpRequest,
realm_id: Optional[int] = REQ(default=None, converter=to_non_negative_int),
plan_type: Optional[int] = REQ(default=None, converter=to_non_negative_int),
- discount: Optional[Decimal] = REQ(default=None, converter=to_decimal),
+ monthly_discounted_price: Optional[int] = REQ(default=None, converter=to_non_negative_int),
+ annual_discounted_price: Optional[int] = REQ(default=None, converter=to_non_negative_int),
minimum_licenses: Optional[int] = REQ(default=None, converter=to_non_negative_int),
required_plan_tier: Optional[int] = REQ(default=None, converter=to_non_negative_int),
new_subdomain: Optional[str] = REQ(default=None),
@@ -371,7 +369,11 @@ def support(
keys = set(request.POST.keys())
if "csrfmiddlewaretoken" in keys:
keys.remove("csrfmiddlewaretoken")
- if len(keys) != 2:
+ REQUIRED_KEYS = 2
+ if monthly_discounted_price is not None or annual_discounted_price is not None:
+ REQUIRED_KEYS = 3
+
+ if len(keys) != REQUIRED_KEYS:
raise JsonableError(_("Invalid parameters"))
assert realm_id is not None
@@ -386,10 +388,11 @@ def support(
support_type=SupportType.update_sponsorship_status,
sponsorship_status=sponsorship_pending,
)
- elif discount is not None:
+ elif monthly_discounted_price is not None or annual_discounted_price is not None:
support_view_request = SupportViewRequest(
support_type=SupportType.attach_discount,
- discount=discount,
+ monthly_discounted_price=monthly_discounted_price,
+ annual_discounted_price=annual_discounted_price,
)
elif minimum_licenses is not None:
support_view_request = SupportViewRequest(
@@ -563,7 +566,6 @@ def support(
context["get_realm_owner_emails_as_string"] = get_realm_owner_emails_as_string
context["get_realm_admin_emails_as_string"] = get_realm_admin_emails_as_string
- context["format_discount"] = format_discount_percentage
context["dollar_amount"] = cents_to_dollar_string
context["realm_icon_url"] = realm_icon_url
context["Confirmation"] = Confirmation
@@ -625,7 +627,8 @@ def remote_servers_support(
query: Optional[str] = REQ("q", default=None),
remote_server_id: Optional[int] = REQ(default=None, converter=to_non_negative_int),
remote_realm_id: Optional[int] = REQ(default=None, converter=to_non_negative_int),
- discount: Optional[Decimal] = REQ(default=None, converter=to_decimal),
+ monthly_discounted_price: Optional[int] = REQ(default=None, converter=to_non_negative_int),
+ annual_discounted_price: Optional[int] = REQ(default=None, converter=to_non_negative_int),
minimum_licenses: Optional[int] = REQ(default=None, converter=to_non_negative_int),
required_plan_tier: Optional[int] = REQ(default=None, converter=to_non_negative_int),
fixed_price: Optional[int] = REQ(default=None, converter=to_non_negative_int),
@@ -674,10 +677,11 @@ def remote_servers_support(
support_type=SupportType.update_sponsorship_status,
sponsorship_status=sponsorship_pending,
)
- elif discount is not None:
+ elif monthly_discounted_price is not None or annual_discounted_price is not None:
support_view_request = SupportViewRequest(
support_type=SupportType.attach_discount,
- discount=discount,
+ monthly_discounted_price=monthly_discounted_price,
+ annual_discounted_price=annual_discounted_price,
)
elif minimum_licenses is not None:
support_view_request = SupportViewRequest(
@@ -826,7 +830,6 @@ def remote_servers_support(
context["remote_realms_support_data"] = realm_support_data
context["get_plan_type_name"] = get_plan_type_string
context["get_org_type_display_name"] = get_org_type_display_name
- context["format_discount"] = format_discount_percentage
context["format_optional_datetime"] = format_optional_datetime
context["dollar_amount"] = cents_to_dollar_string
context["server_analytics_link"] = remote_installation_stats_link
diff --git a/templates/corporate/billing/upgrade.html b/templates/corporate/billing/upgrade.html
index b5435981af..23a8ca68a0 100644
--- a/templates/corporate/billing/upgrade.html
+++ b/templates/corporate/billing/upgrade.html
@@ -176,14 +176,13 @@
{{ 'user' if seat_count == 1 else 'users' }}
x
)
- {% if discount_percent %}
-
- Includes: {{ discount_percent }}% discount
- {% endif %}
{% if page_params.flat_discounted_months > 0 %}
−
$/month off ({{ page_params.flat_discounted_months }} {{ "month" if page_params.flat_discounted_months == 1 else "months" }} remaining)
+ {% else %}
+
+ Includes: % discount
{% endif %}