support: Set discounted price instead percentage for customers.

This allows us to set the price of a plan exactly as discussed with
the customer.
This commit is contained in:
Aman Agrawal 2024-05-06 04:12:15 +00:00 committed by Tim Abbott
parent ed5e0fa141
commit 7203661d99
26 changed files with 722 additions and 287 deletions

View File

@ -2,7 +2,7 @@ from collections import defaultdict
from dataclasses import dataclass from dataclasses import dataclass
from datetime import datetime from datetime import datetime
from decimal import Decimal 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 urllib.parse import urlencode
from django.conf import settings from django.conf import settings
@ -20,10 +20,9 @@ from corporate.lib.stripe import (
RemoteRealmBillingSession, RemoteRealmBillingSession,
RemoteServerBillingSession, RemoteServerBillingSession,
) )
from corporate.models import Customer, CustomerPlan, LicenseLedger from corporate.models import CustomerPlan, LicenseLedger
from zerver.lib.pysa import mark_sanitized from zerver.lib.pysa import mark_sanitized
from zerver.lib.url_encoding import append_url_query_string 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 zerver.models import Realm
from zilencer.models import ( from zilencer.models import (
RemoteCustomerUserCount, RemoteCustomerUserCount,
@ -160,11 +159,12 @@ def remote_installation_support_link(hostname: str) -> Markup:
return Markup('<a href="{url}"><i class="fa fa-gear"></i></a>').format(url=url) return Markup('<a href="{url}"><i class="fa fa-gear"></i></a>').format(url=url)
def get_plan_rate_percentage(discount: Optional[Decimal]) -> str: def get_plan_rate_percentage(discount: Optional[str]) -> str:
if discount is None or discount == Decimal(0): # CustomerPlan.discount is a string field that stores the discount.
if discount is None or discount == "0":
return "100%" return "100%"
rate = 100 - discount rate = 100 - Decimal(discount)
if rate * 100 % 100 == 0: if rate * 100 % 100 == 0:
precision = 0 precision = 0
else: else:
@ -211,23 +211,11 @@ def get_remote_activity_plan_data(
) )
def get_realms_with_default_discount_dict() -> Dict[str, Decimal]: def get_estimated_arr_and_rate_by_realm() -> Tuple[Dict[str, int], Dict[str, str]]: # nocoverage
realms_with_default_discount: Dict[str, Any] = {} # NOTE: Customers without a plan might still have a discount attached to them which
customers = ( # are not included in `plan_rate`.
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
annual_revenue = {} annual_revenue = {}
plan_rate = {}
plans = ( plans = (
CustomerPlan.objects.filter( CustomerPlan.objects.filter(
status=CustomerPlan.ACTIVE, 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: if plan.billing_schedule == CustomerPlan.BILLING_SCHEDULE_MONTHLY:
renewal_cents *= 12 renewal_cents *= 12
annual_revenue[plan.customer.realm.string_id] = renewal_cents 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 def get_plan_data_by_remote_server() -> Dict[int, RemoteActivityPlanData]: # nocoverage

View File

@ -594,7 +594,8 @@ class SupportType(Enum):
class SupportViewRequest(TypedDict, total=False): class SupportViewRequest(TypedDict, total=False):
support_type: SupportType support_type: SupportType
sponsorship_status: Optional[bool] sponsorship_status: Optional[bool]
discount: Optional[Decimal] monthly_discounted_price: Optional[int]
annual_discounted_price: Optional[int]
billing_modality: Optional[str] billing_modality: Optional[str]
plan_modification: Optional[str] plan_modification: Optional[str]
new_plan_tier: Optional[int] new_plan_tier: Optional[int]
@ -646,6 +647,8 @@ class UpgradePageParams(TypedDict):
fixed_price: Optional[int] fixed_price: Optional[int]
setup_payment_by_invoice: bool setup_payment_by_invoice: bool
free_trial_days: Optional[int] free_trial_days: Optional[int]
percent_off_annual_price: Optional[str]
percent_off_monthly_price: Optional[str]
class UpgradePageSessionTypeSpecificContext(TypedDict): class UpgradePageSessionTypeSpecificContext(TypedDict):
@ -672,7 +675,6 @@ class SponsorshipRequestSessionSpecificContext(TypedDict):
class UpgradePageContext(TypedDict): class UpgradePageContext(TypedDict):
customer_name: str customer_name: str
discount_percent: Optional[str]
email: str email: str
exempt_from_license_number_check: bool exempt_from_license_number_check: bool
free_trial_end_date: Optional[str] free_trial_end_date: Optional[str]
@ -1276,13 +1278,20 @@ class BillingSession(ABC):
def apply_discount_to_plan( def apply_discount_to_plan(
self, self,
plan: CustomerPlan, plan: CustomerPlan,
discount: Decimal, customer: Customer,
) -> None: ) -> None:
plan.discount = discount 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, discount) plan.price_per_license = get_price_per_license(plan.tier, plan.billing_schedule, customer)
plan.save(update_fields=["discount", "price_per_license"])
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. # Remove flat discount if giving customer a percentage discount.
customer = self.get_customer() customer = self.get_customer()
@ -1290,35 +1299,44 @@ class BillingSession(ABC):
assert customer is not None assert customer is not None
assert customer.required_plan_tier is not None assert customer.required_plan_tier is not None
old_discount = customer.default_discount old_monthly_discounted_price = customer.monthly_discounted_price
customer.default_discount = new_discount 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.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) plan = get_current_plan_by_customer(customer)
if plan is not None and plan.tier == customer.required_plan_tier: 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. # 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 # Make this a check on CustomerPlan.SWITCH_PLAN_TIER_AT_PLAN_END status
# if we support this for other plans. # if we support this for other plans.
next_plan = self.get_legacy_remote_server_next_plan(customer) next_plan = self.get_legacy_remote_server_next_plan(customer)
if next_plan is not None and next_plan.tier == customer.required_plan_tier: 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( self.write_to_audit_log(
event_type=AuditLogEventType.DISCOUNT_CHANGED, event_type=AuditLogEventType.DISCOUNT_CHANGED,
event_time=timezone_now(), 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" 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}."
)
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}%."
def update_customer_minimum_licenses(self, new_minimum_license_count: int) -> str: def update_customer_minimum_licenses(self, new_minimum_license_count: int) -> str:
previous_minimum_license_count = None previous_minimum_license_count = None
@ -1327,7 +1345,7 @@ class BillingSession(ABC):
# Currently, the support admin view shows the form for adding # Currently, the support admin view shows the form for adding
# a minimum license count after a default discount has been set. # a minimum license count after a default discount has been set.
assert customer is not None 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( raise SupportRequestError(
f"Discount for {self.billing_entity_display_name} must be updated before setting a minimum number of licenses." 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}.") raise SupportRequestError(f"Invalid plan tier for {self.billing_entity_display_name}.")
if customer is not None: 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( raise SupportRequestError(
f"Discount for {self.billing_entity_display_name} must be 0 before setting required plan tier to None." 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, "days_until_due": days_until_due,
"current_plan_id": current_plan_id, "current_plan_id": current_plan_id,
} }
discount_for_plan = customer.get_discount_for_plan_tier(plan_tier)
( (
invoice_period_start, invoice_period_start,
_, _,
@ -1635,7 +1654,7 @@ class BillingSession(ABC):
) = compute_plan_parameters( ) = compute_plan_parameters(
plan_tier, plan_tier,
billing_schedule, billing_schedule,
discount_for_plan, customer,
on_free_trial, on_free_trial,
None, None,
not isinstance(self, RealmBillingSession), not isinstance(self, RealmBillingSession),
@ -1724,11 +1743,8 @@ class BillingSession(ABC):
assert remote_server_legacy_plan is not None assert remote_server_legacy_plan is not None
billing_cycle_anchor = remote_server_legacy_plan.end_date 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) fixed_price_plan_offer = get_configured_fixed_price_plan_offer(customer, plan_tier)
if fixed_price_plan_offer is None: if fixed_price_plan_offer is not None:
discount_for_plan = customer.get_discount_for_plan_tier(plan_tier)
else:
assert automanage_licenses is True assert automanage_licenses is True
( (
@ -1739,7 +1755,7 @@ class BillingSession(ABC):
) = compute_plan_parameters( ) = compute_plan_parameters(
plan_tier, plan_tier,
billing_schedule, billing_schedule,
discount_for_plan, customer,
free_trial, free_trial,
billing_cycle_anchor, billing_cycle_anchor,
is_self_hosted_billing, is_self_hosted_billing,
@ -1766,11 +1782,14 @@ class BillingSession(ABC):
if fixed_price_plan_offer is None: if fixed_price_plan_offer is None:
plan_params["price_per_license"] = price_per_license 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: if free_trial:
plan_params["status"] = CustomerPlan.FREE_TRIAL plan_params["status"] = CustomerPlan.FREE_TRIAL
if charge_automatically: if charge_automatically:
# Ensure free trial customers not paying via invoice have a default payment method set # Ensure free trial customers not paying via invoice have a default payment method set
assert customer.stripe_customer_id is not None # for mypy assert customer.stripe_customer_id is not None # for mypy
@ -2025,11 +2044,8 @@ class BillingSession(ABC):
plan.next_invoice_date = None plan.next_invoice_date = None
plan.save(update_fields=["status", "next_invoice_date"]) plan.save(update_fields=["status", "next_invoice_date"])
discount_for_current_plan = plan.discount price_per_license, discount_for_current_plan = get_price_per_license_and_discount(
_, _, _, price_per_license = compute_plan_parameters( plan.tier, schedule, plan.customer
tier=plan.tier,
billing_schedule=schedule,
discount=discount_for_current_plan,
) )
new_plan = CustomerPlan.objects.create( new_plan = CustomerPlan.objects.create(
@ -2185,11 +2201,8 @@ class BillingSession(ABC):
plan.status = CustomerPlan.ENDED plan.status = CustomerPlan.ENDED
plan.save(update_fields=["status"]) plan.save(update_fields=["status"])
discount_for_current_plan = plan.discount price_per_license, discount_for_current_plan = get_price_per_license_and_discount(
_, _, _, price_per_license = compute_plan_parameters( plan.tier, CustomerPlan.BILLING_SCHEDULE_ANNUAL, plan.customer
tier=plan.tier,
billing_schedule=CustomerPlan.BILLING_SCHEDULE_ANNUAL,
discount=discount_for_current_plan,
) )
new_plan = CustomerPlan.objects.create( new_plan = CustomerPlan.objects.create(
@ -2233,11 +2246,8 @@ class BillingSession(ABC):
plan.status = CustomerPlan.ENDED plan.status = CustomerPlan.ENDED
plan.save(update_fields=["status"]) plan.save(update_fields=["status"])
discount_for_current_plan = plan.discount price_per_license, discount_for_current_plan = get_price_per_license_and_discount(
_, _, _, price_per_license = compute_plan_parameters( plan.tier, CustomerPlan.BILLING_SCHEDULE_MONTHLY, plan.customer
tier=plan.tier,
billing_schedule=CustomerPlan.BILLING_SCHEDULE_MONTHLY,
discount=discount_for_current_plan,
) )
new_plan = CustomerPlan.objects.create( new_plan = CustomerPlan.objects.create(
@ -2366,19 +2376,18 @@ class BillingSession(ABC):
) )
billing_frequency = CustomerPlan.BILLING_SCHEDULES[plan.billing_schedule] billing_frequency = CustomerPlan.BILLING_SCHEDULES[plan.billing_schedule]
discount_for_current_plan = plan.discount
if switch_to_annual_at_end_of_cycle: if switch_to_annual_at_end_of_cycle:
num_months_next_cycle = 12 num_months_next_cycle = 12
annual_price_per_license = get_price_per_license( 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 renewal_cents = annual_price_per_license * licenses_at_next_renewal
price_per_license = format_money(annual_price_per_license / 12) price_per_license = format_money(annual_price_per_license / 12)
elif switch_to_monthly_at_end_of_cycle: elif switch_to_monthly_at_end_of_cycle:
num_months_next_cycle = 1 num_months_next_cycle = 1
monthly_price_per_license = get_price_per_license( 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 renewal_cents = monthly_price_per_license * licenses_at_next_renewal
price_per_license = format_money(monthly_price_per_license) price_per_license = format_money(monthly_price_per_license)
@ -2446,7 +2455,7 @@ class BillingSession(ABC):
"sponsorship_plan_name": self.get_sponsorship_plan_name( "sponsorship_plan_name": self.get_sponsorship_plan_name(
customer, is_self_hosted_billing 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_self_hosted_billing": is_self_hosted_billing,
"is_server_on_legacy_plan": remote_server_legacy_plan_end_date is not None, "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, "remote_server_legacy_plan_end_date": remote_server_legacy_plan_end_date,
@ -2592,11 +2601,12 @@ class BillingSession(ABC):
last_send_invoice.plan.name last_send_invoice.plan.name
) )
percent_off = Decimal(0) annual_price, percent_off_annual_price = get_price_per_license_and_discount(
if customer is not None: tier, CustomerPlan.BILLING_SCHEDULE_ANNUAL, customer
discount_for_plan_tier = customer.get_discount_for_plan_tier(tier) )
if discount_for_plan_tier is not None: monthly_price, percent_off_monthly_price = get_price_per_license_and_discount(
percent_off = discount_for_plan_tier tier, CustomerPlan.BILLING_SCHEDULE_MONTHLY, customer
)
customer_specific_context = self.get_upgrade_page_session_type_specific_context() customer_specific_context = self.get_upgrade_page_session_type_specific_context()
min_licenses_for_plan = self.min_licenses_for_plan(tier) 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) flat_discount, flat_discounted_months = self.get_flat_discount_info(customer)
context: UpgradePageContext = { context: UpgradePageContext = {
"customer_name": customer_specific_context["customer_name"], "customer_name": customer_specific_context["customer_name"],
"discount_percent": format_discount_percentage(percent_off),
"email": customer_specific_context["email"], "email": customer_specific_context["email"],
"exempt_from_license_number_check": exempt_from_license_number_check, "exempt_from_license_number_check": exempt_from_license_number_check,
"free_trial_end_date": free_trial_end_date, "free_trial_end_date": free_trial_end_date,
@ -2645,15 +2654,11 @@ class BillingSession(ABC):
"manual_license_management": initial_upgrade_request.manual_license_management, "manual_license_management": initial_upgrade_request.manual_license_management,
"page_params": { "page_params": {
"page_type": "upgrade", "page_type": "upgrade",
"annual_price": get_price_per_license( "annual_price": annual_price,
tier, CustomerPlan.BILLING_SCHEDULE_ANNUAL, percent_off
),
"demo_organization_scheduled_deletion_date": customer_specific_context[ "demo_organization_scheduled_deletion_date": customer_specific_context[
"demo_organization_scheduled_deletion_date" "demo_organization_scheduled_deletion_date"
], ],
"monthly_price": get_price_per_license( "monthly_price": monthly_price,
tier, CustomerPlan.BILLING_SCHEDULE_MONTHLY, percent_off
),
"seat_count": seat_count, "seat_count": seat_count,
"billing_base_url": self.billing_base_url, "billing_base_url": self.billing_base_url,
"tier": tier, "tier": tier,
@ -2662,6 +2667,8 @@ class BillingSession(ABC):
"fixed_price": fixed_price, "fixed_price": fixed_price,
"setup_payment_by_invoice": setup_payment_by_invoice, "setup_payment_by_invoice": setup_payment_by_invoice,
"free_trial_days": free_trial_days, "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, "using_min_licenses_for_plan": using_min_licenses_for_plan,
"min_licenses_for_plan": min_licenses_for_plan, "min_licenses_for_plan": min_licenses_for_plan,
@ -2708,7 +2715,7 @@ class BillingSession(ABC):
) -> int: ) -> int:
customer = self.get_customer() customer = self.get_customer()
if customer is not None and customer.minimum_licenses: 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 return customer.minimum_licenses
if tier == CustomerPlan.TIER_SELF_HOSTED_BASIC: if tier == CustomerPlan.TIER_SELF_HOSTED_BASIC:
@ -2945,9 +2952,8 @@ class BillingSession(ABC):
current_plan.status = CustomerPlan.ENDED current_plan.status = CustomerPlan.ENDED
current_plan.save(update_fields=["status", "end_date"]) 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, discount_for_new_plan_tier = get_price_per_license_and_discount(
new_price_per_license = get_price_per_license( new_plan_tier, current_plan.billing_schedule, current_plan.customer
new_plan_tier, current_plan.billing_schedule, discount_for_new_plan_tier
) )
new_plan_billing_cycle_anchor = current_plan.end_date.replace(microsecond=0) 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"] sponsorship_status = support_request["sponsorship_status"]
success_message = self.update_customer_sponsorship_status(sponsorship_status) success_message = self.update_customer_sponsorship_status(sponsorship_status)
elif support_type == SupportType.attach_discount: elif support_type == SupportType.attach_discount:
assert support_request["discount"] is not None monthly_discounted_price = support_request["monthly_discounted_price"]
new_discount = support_request["discount"] annual_discounted_price = support_request["annual_discounted_price"]
success_message = self.attach_discount_to_customer(new_discount) 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: elif support_type == SupportType.update_minimum_licenses:
assert support_request["minimum_licenses"] is not None assert support_request["minimum_licenses"] is not None
new_minimum_license_count = support_request["minimum_licenses"] new_minimum_license_count = support_request["minimum_licenses"]
@ -4266,7 +4276,11 @@ class RemoteRealmBillingSession(BillingSession):
remote_realm=self.remote_realm, defaults=defaults 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.flat_discounted_months = 12
customer.save(update_fields=["flat_discounted_months"]) customer.save(update_fields=["flat_discounted_months"])
@ -4699,7 +4713,11 @@ class RemoteServerBillingSession(BillingSession):
remote_server=self.remote_server, defaults=defaults 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.flat_discounted_months = 12
customer.save(update_fields=["flat_discounted_months"]) 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) 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( def get_price_per_license(
tier: int, billing_schedule: int, discount: Optional[Decimal] = None tier: int, billing_schedule: int, customer: Optional[Customer] = None
) -> int: ) -> 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]] = { price_map: Dict[int, Dict[str, int]] = {
CustomerPlan.TIER_CLOUD_STANDARD: {"Annual": 8000, "Monthly": 800}, CustomerPlan.TIER_CLOUD_STANDARD: {"Annual": 8000, "Monthly": 800},
CustomerPlan.TIER_CLOUD_PLUS: {"Annual": 12000, "Monthly": 1200}, CustomerPlan.TIER_CLOUD_PLUS: {"Annual": 12000, "Monthly": 1200},
@ -5012,15 +5029,30 @@ def get_price_per_license(
else: # nocoverage else: # nocoverage
raise InvalidBillingScheduleError(billing_schedule) 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 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( def compute_plan_parameters(
tier: int, tier: int,
billing_schedule: int, billing_schedule: int,
discount: Optional[Decimal], customer: Optional[Customer],
free_trial: bool = False, free_trial: bool = False,
billing_cycle_anchor: Optional[datetime] = None, billing_cycle_anchor: Optional[datetime] = None,
is_self_hosted_billing: bool = False, is_self_hosted_billing: bool = False,
@ -5039,7 +5071,7 @@ def compute_plan_parameters(
else: # nocoverage else: # nocoverage
raise InvalidBillingScheduleError(billing_schedule) 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. # `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. # It is always the next month regardless of the billing schedule / billing modality.

View File

@ -1,6 +1,5 @@
from dataclasses import dataclass from dataclasses import dataclass
from datetime import datetime, timedelta from datetime import datetime, timedelta
from decimal import Decimal
from typing import Optional, TypedDict, Union from typing import Optional, TypedDict, Union
from urllib.parse import urlencode, urljoin, urlunsplit from urllib.parse import urlencode, urljoin, urlunsplit
@ -16,6 +15,7 @@ from corporate.lib.stripe import (
RemoteRealmBillingSession, RemoteRealmBillingSession,
RemoteServerBillingSession, RemoteServerBillingSession,
get_configured_fixed_price_plan_offer, get_configured_fixed_price_plan_offer,
get_price_per_license,
get_push_status_for_remote_request, get_push_status_for_remote_request,
start_of_next_billing_cycle, start_of_next_billing_cycle,
) )
@ -58,7 +58,10 @@ class SponsorshipRequestDict(TypedDict):
@dataclass @dataclass
class SponsorshipData: class SponsorshipData:
sponsorship_pending: bool = False 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 minimum_licenses: Optional[int] = None
required_plan_tier: Optional[int] = None required_plan_tier: Optional[int] = None
latest_sponsorship_request: Optional[SponsorshipRequestDict] = 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: def get_customer_sponsorship_data(customer: Customer) -> SponsorshipData:
pending = customer.sponsorship_pending pending = customer.sponsorship_pending
discount = customer.default_discount
licenses = customer.minimum_licenses licenses = customer.minimum_licenses
plan_tier = customer.required_plan_tier plan_tier = customer.required_plan_tier
sponsorship_request = None 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: if pending:
last_sponsorship_request = ( last_sponsorship_request = (
ZulipSponsorshipRequest.objects.filter(customer=customer).order_by("id").last() ZulipSponsorshipRequest.objects.filter(customer=customer).order_by("id").last()
@ -151,7 +168,10 @@ def get_customer_sponsorship_data(customer: Customer) -> SponsorshipData:
return SponsorshipData( return SponsorshipData(
sponsorship_pending=pending, 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, minimum_licenses=licenses,
required_plan_tier=plan_tier, required_plan_tier=plan_tier,
latest_sponsorship_request=sponsorship_request, latest_sponsorship_request=sponsorship_request,

View File

@ -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),
),
]

View File

@ -1,4 +1,3 @@
from decimal import Decimal
from enum import Enum from enum import Enum
from typing import Any, Dict, Optional, Union 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) stripe_customer_id = models.CharField(max_length=255, null=True, unique=True)
sponsorship_pending = models.BooleanField(default=False) 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) 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. # to be used only for a particular CustomerPlan tier.
required_plan_tier = models.SmallIntegerField(null=True) required_plan_tier = models.SmallIntegerField(null=True)
# Some non-profit organizations on manual license management pay # Some non-profit organizations on manual license management pay
@ -64,11 +69,16 @@ class Customer(models.Model):
else: else:
return f"{self.remote_server!r} (with stripe_customer_id: {self.stripe_customer_id})" 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]: def get_discounted_price_for_plan(self, plan_tier: int, schedule: int) -> Optional[int]:
if self.required_plan_tier is None or self.required_plan_tier == plan_tier: if plan_tier != self.required_plan_tier:
return self.default_discount
return None 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]: def get_customer_by_realm(realm: Realm) -> Optional[Customer]:
return Customer.objects.filter(realm=realm).first() return Customer.objects.filter(realm=realm).first()
@ -325,8 +335,10 @@ class CustomerPlan(AbstractCustomerPlan):
# can't be set via the self-serve billing system. # can't be set via the self-serve billing system.
price_per_license = models.IntegerField(null=True) price_per_license = models.IntegerField(null=True)
# Discount that was applied. For display purposes only. # Discount for current `billing_schedule`. For display purposes only.
discount = models.DecimalField(decimal_places=4, max_digits=6, null=True) # 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 # Initialized with the time of plan creation. Used for calculating
# start of next billing cycle, next invoice date etc. This value # start of next billing cycle, next invoice date etc. This value

View File

@ -141,7 +141,7 @@ class ActivityTest(ZulipTestCase):
user_profile.is_staff = True user_profile.is_staff = True
user_profile.save(update_fields=["is_staff"]) 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") result = self.client_get("/activity")
self.assertEqual(result.status_code, 200) self.assertEqual(result.status_code, 200)

View File

@ -41,7 +41,6 @@ from django.utils.crypto import get_random_string
from django.utils.timezone import now as timezone_now from django.utils.timezone import now as timezone_now
from typing_extensions import ParamSpec, override from typing_extensions import ParamSpec, override
from corporate.lib.activity import get_realms_with_default_discount_dict
from corporate.lib.stripe import ( from corporate.lib.stripe import (
DEFAULT_INVOICE_DAYS_UNTIL_DUE, DEFAULT_INVOICE_DAYS_UNTIL_DUE,
MAX_INVOICED_LICENSES, MAX_INVOICED_LICENSES,
@ -4542,7 +4541,7 @@ class StripeTest(StripeTestCase):
user=self.example_user("iago"), realm=realm, support_session=True user=self.example_user("iago"), realm=realm, support_session=True
) )
billing_session.set_required_plan_tier(CustomerPlan.TIER_CLOUD_STANDARD) 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)) rows.append(Row(realm, Realm.PLAN_TYPE_SELF_HOSTED, None, None, 0, False))
# no active paid plan or invoices (no action) # 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) anchor = datetime(2019, 12, 31, 1, 2, 3, tzinfo=timezone.utc)
month_later = datetime(2020, 1, 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) 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_cases = [
# test all possibilities, since there aren't that many # Annual standard no customer
( (
( (
CustomerPlan.TIER_CLOUD_STANDARD, CustomerPlan.TIER_CLOUD_STANDARD,
@ -5132,34 +5138,34 @@ class BillingHelpersTest(ZulipTestCase):
), ),
(anchor, month_later, year_later, 8000), (anchor, month_later, year_later, 8000),
), ),
( # Annual standard with discount
(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),
),
( (
( (
CustomerPlan.TIER_CLOUD_STANDARD, CustomerPlan.TIER_CLOUD_STANDARD,
CustomerPlan.BILLING_SCHEDULE_ANNUAL, 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), (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, CustomerPlan.TIER_CLOUD_STANDARD,
@ -5168,43 +5174,56 @@ class BillingHelpersTest(ZulipTestCase):
), ),
(anchor, month_later, month_later, 800), (anchor, month_later, month_later, 800),
), ),
# Monthly standard with discount
( (
( (
CustomerPlan.TIER_CLOUD_STANDARD, CustomerPlan.TIER_CLOUD_STANDARD,
CustomerPlan.BILLING_SCHEDULE_MONTHLY, 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.TIER_CLOUD_STANDARD,
CustomerPlan.BILLING_SCHEDULE_MONTHLY, 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, 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): 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( output_ = compute_plan_parameters(
tier, tier,
billing_schedule, billing_schedule,
None if discount is None else Decimal(discount), customer,
) )
self.assertEqual(output_, output) self.assertEqual(output_, output)
def test_get_price_per_license(self) -> None: 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( self.assertEqual(
get_price_per_license( get_price_per_license(
CustomerPlan.TIER_CLOUD_STANDARD, CustomerPlan.BILLING_SCHEDULE_ANNUAL CustomerPlan.TIER_CLOUD_STANDARD, CustomerPlan.BILLING_SCHEDULE_ANNUAL
@ -5221,7 +5240,7 @@ class BillingHelpersTest(ZulipTestCase):
get_price_per_license( get_price_per_license(
CustomerPlan.TIER_CLOUD_STANDARD, CustomerPlan.TIER_CLOUD_STANDARD,
CustomerPlan.BILLING_SCHEDULE_MONTHLY, CustomerPlan.BILLING_SCHEDULE_MONTHLY,
discount=Decimal(50), standard_discounted_customer,
), ),
400, 400,
) )
@ -5242,7 +5261,16 @@ class BillingHelpersTest(ZulipTestCase):
get_price_per_license( get_price_per_license(
CustomerPlan.TIER_CLOUD_PLUS, CustomerPlan.TIER_CLOUD_PLUS,
CustomerPlan.BILLING_SCHEDULE_MONTHLY, 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, 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): class LicenseLedgerTest(StripeTestCase):
def test_add_plan_renewal_if_needed(self) -> None: def test_add_plan_renewal_if_needed(self) -> None:
with time_machine.travel(self.now, tick=False): with time_machine.travel(self.now, tick=False):
@ -6039,19 +6036,33 @@ class TestSupportBillingHelpers(StripeTestCase):
# Cannot attach discount without a required_plan_tier set. # Cannot attach discount without a required_plan_tier set.
with self.assertRaises(AssertionError): 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() billing_session.update_or_create_customer()
with self.assertRaises(AssertionError): 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.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( realm_audit_log = RealmAuditLog.objects.filter(
event_type=RealmAuditLog.REALM_DISCOUNT_CHANGED event_type=RealmAuditLog.REALM_DISCOUNT_CHANGED
).last() ).last()
assert realm_audit_log is not None 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.assertEqual(realm_audit_log.extra_data, expected_extra_data)
self.login_user(user) self.login_user(user)
# Check that the discount appears in page_params # Check that the discount appears in page_params
@ -6071,14 +6082,17 @@ class TestSupportBillingHelpers(StripeTestCase):
[item.amount for item in invoice.lines], [item.amount for item in invoice.lines],
) )
# Check CustomerPlan reflects the discount # 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 # Attach discount to existing Stripe customer
plan.status = CustomerPlan.ENDED plan.status = CustomerPlan.ENDED
plan.save(update_fields=["status"]) plan.save(update_fields=["status"])
billing_session = RealmBillingSession(support_admin, realm=user.realm, support_session=True) billing_session = RealmBillingSession(support_admin, realm=user.realm, support_session=True)
billing_session.set_required_plan_tier(CustomerPlan.TIER_CLOUD_STANDARD) 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): with time_machine.travel(self.now, tick=False):
self.add_card_and_upgrade( self.add_card_and_upgrade(
user, license_management="automatic", billing_modality="charge_automatically" 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)) plan = CustomerPlan.objects.get(price_per_license=6000, discount=Decimal(25))
billing_session = RealmBillingSession(support_admin, realm=user.realm, support_session=True) 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() plan.refresh_from_db()
self.assertEqual(plan.price_per_license, 4000) self.assertEqual(plan.price_per_license, 4000)
self.assertEqual(plan.discount, 50) self.assertEqual(plan.discount, "50")
customer.refresh_from_db() 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. # Fast forward the next_invoice_date to next year.
plan.next_invoice_date = self.next_year plan.next_invoice_date = self.next_year
plan.save(update_fields=["next_invoice_date"]) plan.save(update_fields=["next_invoice_date"])
@ -6114,8 +6132,10 @@ class TestSupportBillingHelpers(StripeTestCase):
).last() ).last()
assert realm_audit_log is not None assert realm_audit_log is not None
expected_extra_data = { expected_extra_data = {
"old_discount": str(Decimal("25.0000")), "new_annual_discounted_price": 4000,
"new_discount": str(Decimal("50")), "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.extra_data, expected_extra_data)
self.assertEqual(realm_audit_log.acting_user, support_admin) 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.process_support_view_request(support_view_request)
billing_session.set_required_plan_tier(CustomerPlan.TIER_CLOUD_STANDARD) 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) message = billing_session.process_support_view_request(support_view_request)
self.assertEqual("Minimum licenses for zulip changed to 25 from 0.", message) self.assertEqual("Minimum licenses for zulip changed to 25 from 0.", message)
realm_audit_log = RealmAuditLog.objects.filter( realm_audit_log = RealmAuditLog.objects.filter(
@ -6202,16 +6225,34 @@ class TestSupportBillingHelpers(StripeTestCase):
customer = billing_session.get_customer() customer = billing_session.get_customer()
assert customer is not None assert customer is not None
self.assertEqual(customer.required_plan_tier, valid_plan_tier) 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 # 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() customer.refresh_from_db()
self.assertEqual(customer.default_discount, Decimal(50)) self.assertEqual(customer.monthly_discounted_price, 400)
discount_for_standard_plan = customer.get_discount_for_plan_tier(valid_plan_tier) self.assertEqual(customer.annual_discounted_price, 4000)
self.assertEqual(discount_for_standard_plan, customer.default_discount)
discount_for_plus_plan = customer.get_discount_for_plan_tier(CustomerPlan.TIER_CLOUD_PLUS) monthly_discounted_price = customer.get_discounted_price_for_plan(
self.assertEqual(discount_for_plus_plan, None) 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 # Try to set invalid plan tier
invalid_plan_tier = CustomerPlan.TIER_SELF_HOSTED_BASE 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.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) message = billing_session.process_support_view_request(support_view_request)
self.assertEqual("Required plan tier for zulip set to None.", message) self.assertEqual("Required plan tier for zulip set to None.", message)
customer.refresh_from_db() customer.refresh_from_db()
self.assertIsNone(customer.required_plan_tier) self.assertIsNone(customer.required_plan_tier)
discount_for_standard_plan = customer.get_discount_for_plan_tier(valid_plan_tier) discount_for_standard_plan = customer.get_discounted_price_for_plan(
self.assertEqual(discount_for_standard_plan, customer.default_discount) valid_plan_tier, CustomerPlan.BILLING_SCHEDULE_MONTHLY
discount_for_plus_plan = customer.get_discount_for_plan_tier(CustomerPlan.TIER_CLOUD_PLUS) )
self.assertEqual(discount_for_plus_plan, customer.default_discount) 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( realm_audit_log = RealmAuditLog.objects.filter(
event_type=RealmAuditLog.CUSTOMER_PROPERTY_CHANGED event_type=RealmAuditLog.CUSTOMER_PROPERTY_CHANGED
).last() ).last()

View File

@ -1,5 +1,4 @@
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
from decimal import Decimal
from typing import TYPE_CHECKING, Any, Optional from typing import TYPE_CHECKING, Any, Optional
from unittest import mock from unittest import mock
@ -81,7 +80,6 @@ class TestRemoteServerSupportEndpoint(ZulipTestCase):
"automanage_licenses": True, "automanage_licenses": True,
"charge_automatically": False, "charge_automatically": False,
"price_per_license": 100, "price_per_license": 100,
"discount": legacy_plan.customer.default_discount,
"billing_cycle_anchor": legacy_plan.end_date, "billing_cycle_anchor": legacy_plan.end_date,
"billing_schedule": CustomerPlan.BILLING_SCHEDULE_MONTHLY, "billing_schedule": CustomerPlan.BILLING_SCHEDULE_MONTHLY,
"tier": CustomerPlan.TIER_SELF_HOSTED_BASIC, "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(plan.status, CustomerPlan.SWITCH_PLAN_TIER_AT_PLAN_END)
self.assertEqual(next_plan.status, CustomerPlan.NEVER_STARTED) 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(plan.discount)
self.assertIsNone(next_plan.discount) self.assertIsNone(next_plan.discount)
@ -558,18 +557,26 @@ class TestRemoteServerSupportEndpoint(ZulipTestCase):
) )
result = self.client_post( result = self.client_post(
"/activity/remote/support", "/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( 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() customer.refresh_from_db()
plan.refresh_from_db() plan.refresh_from_db()
next_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. # Discount for current plan stays None since it is not the same as required tier for discount.
self.assertEqual(plan.discount, None) 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(plan.tier, CustomerPlan.TIER_SELF_HOSTED_LEGACY)
self.assertEqual(next_plan.tier, CustomerPlan.TIER_SELF_HOSTED_BASIC) self.assertEqual(next_plan.tier, CustomerPlan.TIER_SELF_HOSTED_BASIC)
@ -765,7 +772,8 @@ class TestSupportEndpoint(ZulipTestCase):
"Zulip Dev</h3>", "Zulip Dev</h3>",
'<option value="1" selected>Self-hosted</option>', '<option value="1" selected>Self-hosted</option>',
'<option value="2">Limited</option>', '<option value="2">Limited</option>',
'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"',
'<option value="active" selected>Active</option>', '<option value="active" selected>Active</option>',
'<option value="deactivated" >Deactivated</option>', '<option value="deactivated" >Deactivated</option>',
f'<option value="{zulip_realm.org_type}" selected>', f'<option value="{zulip_realm.org_type}" selected>',
@ -782,7 +790,8 @@ class TestSupportEndpoint(ZulipTestCase):
"Lear &amp; Co.</h3>", "Lear &amp; Co.</h3>",
'<option value="1" selected>Self-hosted</option>', '<option value="1" selected>Self-hosted</option>',
'<option value="2">Limited</option>', '<option value="2">Limited</option>',
'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"',
'<option value="active" selected>Active</option>', '<option value="active" selected>Active</option>',
'<option value="deactivated" >Deactivated</option>', '<option value="deactivated" >Deactivated</option>',
'scrub-realm-button">', 'scrub-realm-button">',
@ -1112,9 +1121,12 @@ class TestSupportEndpoint(ZulipTestCase):
cordelia = self.example_user("cordelia") cordelia = self.example_user("cordelia")
self.login_user(cordelia) self.login_user(cordelia)
result = self.client_post( discount_change_data = {
"/activity/support", {"realm_id": f"{lear_realm.id}", "discount": "25"} "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.status_code, 302)
self.assertEqual(result["Location"], "/login/") self.assertEqual(result["Location"], "/login/")
@ -1132,19 +1144,181 @@ class TestSupportEndpoint(ZulipTestCase):
["Required plan tier for lear set to Zulip Cloud Standard."], ["Required plan tier for lear set to Zulip Cloud Standard."],
result, result,
) )
result = self.client_post( result = self.client_post("/activity/support", discount_change_data)
"/activity/support", {"realm_id": f"{lear_realm.id}", "discount": "25"} 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() customer.refresh_from_db()
plan = get_current_plan_by_customer(customer) plan = get_current_plan_by_customer(customer)
assert plan is not None assert plan is not None
self.assertEqual(customer.default_discount, Decimal(25)) self.assertEqual(customer.monthly_discounted_price, 600)
self.assertEqual(plan.discount, Decimal(25)) self.assertEqual(customer.annual_discounted_price, 6000)
self.assertEqual(plan.discount, "25")
start_next_billing_cycle = start_of_next_billing_cycle(plan, timezone_now()) start_next_billing_cycle = start_of_next_billing_cycle(plan, timezone_now())
billing_cycle_string = start_next_billing_cycle.strftime("%d %B %Y") billing_cycle_string = start_next_billing_cycle.strftime("%d %B %Y")
twenty_five_percent_discounted_response = [
"<b>Plan name</b>: Zulip Cloud Standard",
"<b>Status</b>: Active",
"<b>Discount</b>: 25%",
"<b>Billing schedule</b>: Monthly",
"<b>Licenses</b>: 2/10 (Manual)",
"<b>Price per license</b>: $6.00",
"<b>Annual recurring revenue</b>: $720.00",
f"<b>Start of next billing cycle</b>: {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 = [
"<b>Plan name</b>: Zulip Cloud Standard",
"<b>Status</b>: Active",
"<b>Billing schedule</b>: Monthly",
"<b>Licenses</b>: 2/10 (Manual)",
"<b>Price per license</b>: $8.00",
"<b>Annual recurring revenue</b>: $960.00",
f"<b>Start of next billing cycle</b>: {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 = [
"<b>Plan name</b>: Zulip Cloud Standard",
"<b>Status</b>: Active",
"<b>Discount</b>: 25%",
"<b>Billing schedule</b>: Monthly",
"<b>Licenses</b>: 2/10 (Manual)",
"<b>Price per license</b>: $6.00",
"<b>Annual recurring revenue</b>: $720.00",
f"<b>Start of next billing cycle</b>: {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(
[
"<b>Plan name</b>: Zulip Cloud Standard",
"<b>Status</b>: Active",
"<b>Discount</b>: 25%",
"<b>Billing schedule</b>: Annual",
"<b>Licenses</b>: 2/10 (Manual)",
"<b>Price per license</b>: $60.00",
"<b>Annual recurring revenue</b>: $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(
[
"<b>Plan name</b>: Zulip Cloud Standard",
"<b>Status</b>: Active",
"<b>Billing schedule</b>: Annual",
"<b>Licenses</b>: 2/10 (Manual)",
"<b>Price per license</b>: $80.00",
"<b>Annual recurring revenue</b>: $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"}) result = self.client_get("/activity/support", {"q": "lear"})
self.assert_in_success_response( self.assert_in_success_response(
[ [
@ -1155,7 +1329,6 @@ class TestSupportEndpoint(ZulipTestCase):
"<b>Licenses</b>: 2/10 (Manual)", "<b>Licenses</b>: 2/10 (Manual)",
"<b>Price per license</b>: $6.00", "<b>Price per license</b>: $6.00",
"<b>Annual recurring revenue</b>: $720.00", "<b>Annual recurring revenue</b>: $720.00",
f"<b>Start of next billing cycle</b>: {billing_cycle_string}",
], ],
result, result,
) )

View File

@ -13,12 +13,11 @@ from psycopg2.sql import SQL
from analytics.lib.counts import COUNT_STATS from analytics.lib.counts import COUNT_STATS
from corporate.lib.activity import ( from corporate.lib.activity import (
dictfetchall, dictfetchall,
estimate_annual_recurring_revenue_by_realm,
fix_rows, fix_rows,
format_datetime_as_date, format_datetime_as_date,
format_optional_datetime, format_optional_datetime,
get_estimated_arr_and_rate_by_realm,
get_query_data, get_query_data,
get_realms_with_default_discount_dict,
make_table, make_table,
realm_activity_link, realm_activity_link,
realm_stats_link, realm_stats_link,
@ -211,8 +210,7 @@ def realm_summary_table() -> str:
# estimate annual subscription revenue # estimate annual subscription revenue
total_arr = 0 total_arr = 0
if settings.BILLING_ENABLED: if settings.BILLING_ENABLED:
estimated_arrs = estimate_annual_recurring_revenue_by_realm() estimated_arrs, plan_rates = get_estimated_arr_and_rate_by_realm()
realms_with_default_discount = get_realms_with_default_discount_dict()
for row in rows: for row in rows:
row["plan_type_string"] = get_plan_type_string(row["plan_type"]) 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])}" row["arr"] = f"${cents_to_dollar_string(estimated_arrs[string_id])}"
if row["plan_type"] in [Realm.PLAN_TYPE_STANDARD, Realm.PLAN_TYPE_PLUS]: 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: elif row["plan_type"] == Realm.PLAN_TYPE_STANDARD_FREE:
row["effective_rate"] = 0 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: else:
row["effective_rate"] = "" row["effective_rate"] = ""

View File

@ -2,7 +2,6 @@ import uuid
from contextlib import suppress from contextlib import suppress
from dataclasses import dataclass from dataclasses import dataclass
from datetime import timedelta from datetime import timedelta
from decimal import Decimal
from operator import attrgetter from operator import attrgetter
from typing import Any, Dict, Iterable, List, Optional, Union from typing import Any, Dict, Iterable, List, Optional, Union
from urllib.parse import urlencode, urlsplit from urllib.parse import urlencode, urlsplit
@ -34,7 +33,6 @@ from corporate.lib.stripe import (
cents_to_dollar_string, cents_to_dollar_string,
do_deactivate_remote_server, do_deactivate_remote_server,
do_reactivate_remote_server, do_reactivate_remote_server,
format_discount_percentage,
) )
from corporate.lib.support import ( from corporate.lib.support import (
CloudSupportData, CloudSupportData,
@ -66,7 +64,6 @@ from zerver.lib.validator import (
check_date, check_date,
check_string, check_string,
check_string_in, check_string_in,
to_decimal,
to_non_negative_int, to_non_negative_int,
) )
from zerver.models import ( from zerver.models import (
@ -339,7 +336,8 @@ def support(
request: HttpRequest, request: HttpRequest,
realm_id: Optional[int] = REQ(default=None, converter=to_non_negative_int), realm_id: Optional[int] = REQ(default=None, converter=to_non_negative_int),
plan_type: 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), 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), required_plan_tier: Optional[int] = REQ(default=None, converter=to_non_negative_int),
new_subdomain: Optional[str] = REQ(default=None), new_subdomain: Optional[str] = REQ(default=None),
@ -371,7 +369,11 @@ def support(
keys = set(request.POST.keys()) keys = set(request.POST.keys())
if "csrfmiddlewaretoken" in keys: if "csrfmiddlewaretoken" in keys:
keys.remove("csrfmiddlewaretoken") 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")) raise JsonableError(_("Invalid parameters"))
assert realm_id is not None assert realm_id is not None
@ -386,10 +388,11 @@ def support(
support_type=SupportType.update_sponsorship_status, support_type=SupportType.update_sponsorship_status,
sponsorship_status=sponsorship_pending, 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_view_request = SupportViewRequest(
support_type=SupportType.attach_discount, 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: elif minimum_licenses is not None:
support_view_request = SupportViewRequest( 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_owner_emails_as_string"] = get_realm_owner_emails_as_string
context["get_realm_admin_emails_as_string"] = get_realm_admin_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["dollar_amount"] = cents_to_dollar_string
context["realm_icon_url"] = realm_icon_url context["realm_icon_url"] = realm_icon_url
context["Confirmation"] = Confirmation context["Confirmation"] = Confirmation
@ -625,7 +627,8 @@ def remote_servers_support(
query: Optional[str] = REQ("q", default=None), query: Optional[str] = REQ("q", default=None),
remote_server_id: Optional[int] = REQ(default=None, converter=to_non_negative_int), 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), 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), 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), 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), 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, support_type=SupportType.update_sponsorship_status,
sponsorship_status=sponsorship_pending, 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_view_request = SupportViewRequest(
support_type=SupportType.attach_discount, 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: elif minimum_licenses is not None:
support_view_request = SupportViewRequest( support_view_request = SupportViewRequest(
@ -826,7 +830,6 @@ def remote_servers_support(
context["remote_realms_support_data"] = realm_support_data context["remote_realms_support_data"] = realm_support_data
context["get_plan_type_name"] = get_plan_type_string context["get_plan_type_name"] = get_plan_type_string
context["get_org_type_display_name"] = get_org_type_display_name 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["format_optional_datetime"] = format_optional_datetime
context["dollar_amount"] = cents_to_dollar_string context["dollar_amount"] = cents_to_dollar_string
context["server_analytics_link"] = remote_installation_stats_link context["server_analytics_link"] = remote_installation_stats_link

View File

@ -176,14 +176,13 @@
{{ 'user' if seat_count == 1 else 'users' }} {{ 'user' if seat_count == 1 else 'users' }}
</span> x </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 page_params.flat_discounted_months > 0 %} {% if page_params.flat_discounted_months > 0 %}
<br/> <br/>
<span class="flat-discount-minus-sign"></span> <span class="flat-discount-minus-sign"></span>
<span class="flat-discount-separator">$<span class="flat-discounted-price"></span>/month off</span> <i class="billing-page-discount">({{ page_params.flat_discounted_months }} {{ "month" if page_params.flat_discounted_months == 1 else "months" }} remaining)</i> <span class="flat-discount-separator">$<span class="flat-discounted-price"></span>/month off</span> <i class="billing-page-discount">({{ page_params.flat_discounted_months }} {{ "month" if page_params.flat_discounted_months == 1 else "months" }} remaining)</i>
{% else %}
<br/>
<i class="billing-page-discount hide">Includes: <span class="billing-page-selected-schedule-discount"></span>% discount</i>
{% endif %} {% endif %}
<h1>$<span class="due-today-price"></span></h1> <h1>$<span class="due-today-price"></span></h1>
{% if page_params.free_trial_days and not manual_license_management %} {% if page_params.free_trial_days and not manual_license_management %}

View File

@ -11,7 +11,7 @@
<!-- Any data below doesn't makes sense for sponsored organizations. --> <!-- Any data below doesn't makes sense for sponsored organizations. -->
{% else %} {% else %}
{% if plan_data.current_plan.discount %} {% if plan_data.current_plan.discount %}
<b>Discount</b>: {{ format_discount(plan_data.current_plan.discount) }}%<br /> <b>Discount</b>: {{ plan_data.current_plan.discount }}%<br />
{% endif %} {% endif %}
{% if plan_data.is_legacy_plan %} {% if plan_data.is_legacy_plan %}
<b>End date</b>: {{ plan_data.current_plan.end_date.strftime('%d %B %Y') }}<br /> <b>End date</b>: {{ plan_data.current_plan.end_date.strftime('%d %B %Y') }}<br />

View File

@ -6,7 +6,7 @@
<b>Start date</b>: {{ plan_data.next_plan.billing_cycle_anchor.strftime('%d %B %Y') }}<br /> <b>Start date</b>: {{ plan_data.next_plan.billing_cycle_anchor.strftime('%d %B %Y') }}<br />
<b>Billing schedule</b>: {% if plan_data.next_plan.billing_schedule == plan_data.next_plan.BILLING_SCHEDULE_ANNUAL %}Annual{% else %}Monthly{% endif %}<br /> <b>Billing schedule</b>: {% if plan_data.next_plan.billing_schedule == plan_data.next_plan.BILLING_SCHEDULE_ANNUAL %}Annual{% else %}Monthly{% endif %}<br />
{% if plan_data.next_plan.discount %} {% if plan_data.next_plan.discount %}
<b>Discount</b>: {{ format_discount(plan_data.next_plan.discount) }}%<br /> <b>Discount</b>: {{ plan_data.next_plan.discount }}%<br />
{% endif %} {% endif %}
<b>Price per license</b>: ${{ dollar_amount(plan_data.next_plan.price_per_license) }}<br /> <b>Price per license</b>: ${{ dollar_amount(plan_data.next_plan.price_per_license) }}<br />
<b>Estimated billed licenses</b>: {{ plan_data.current_plan.licenses_at_next_renewal() }}<br /> <b>Estimated billed licenses</b>: {{ plan_data.current_plan.licenses_at_next_renewal() }}<br />

View File

@ -122,7 +122,6 @@
<div class="current-plan-container"> <div class="current-plan-container">
{% with %} {% with %}
{% set plan_data = realm_support_data[realm.id].plan_data %} {% set plan_data = realm_support_data[realm.id].plan_data %}
{% set format_discount = format_discount %}
{% set dollar_amount = dollar_amount %} {% set dollar_amount = dollar_amount %}
{% include 'corporate/support/current_plan_details.html' %} {% include 'corporate/support/current_plan_details.html' %}
{% endwith %} {% endwith %}

View File

@ -48,7 +48,6 @@
<div class="remote-support-sponsorship-container"> <div class="remote-support-sponsorship-container">
{% with %} {% with %}
{% set sponsorship_data = support_data[remote_realm.id].sponsorship_data %} {% set sponsorship_data = support_data[remote_realm.id].sponsorship_data %}
{% set format_discount = format_discount %}
{% include 'corporate/support/sponsorship_details.html' %} {% include 'corporate/support/sponsorship_details.html' %}
{% endwith %} {% endwith %}
</div> </div>
@ -58,7 +57,6 @@
{% set sponsorship_data = support_data[remote_realm.id].sponsorship_data %} {% set sponsorship_data = support_data[remote_realm.id].sponsorship_data %}
{% set remote_id = remote_realm.id %} {% set remote_id = remote_realm.id %}
{% set remote_type = "remote_realm_id" %} {% set remote_type = "remote_realm_id" %}
{% set format_discount = format_discount %}
{% set has_fixed_price = support_data[remote_realm.id].plan_data.has_fixed_price %} {% set has_fixed_price = support_data[remote_realm.id].plan_data.has_fixed_price %}
{% include 'corporate/support/sponsorship_forms_support.html' %} {% include 'corporate/support/sponsorship_forms_support.html' %}
{% endwith %} {% endwith %}
@ -69,7 +67,6 @@
<div class="current-plan-container"> <div class="current-plan-container">
{% with %} {% with %}
{% set plan_data = support_data[remote_realm.id].plan_data %} {% set plan_data = support_data[remote_realm.id].plan_data %}
{% set format_discount = format_discount %}
{% set dollar_amount = dollar_amount %} {% set dollar_amount = dollar_amount %}
{% include 'corporate/support/current_plan_details.html' %} {% include 'corporate/support/current_plan_details.html' %}
{% endwith %} {% endwith %}
@ -87,7 +84,6 @@
<div class="next-plan-container"> <div class="next-plan-container">
{% with %} {% with %}
{% set plan_data = support_data[remote_realm.id].plan_data %} {% set plan_data = support_data[remote_realm.id].plan_data %}
{% set format_discount = format_discount %}
{% set dollar_amount = dollar_amount %} {% set dollar_amount = dollar_amount %}
{% set remote_id = remote_realm.id %} {% set remote_id = remote_realm.id %}
{% set remote_type = "remote_realm_id" %} {% set remote_type = "remote_realm_id" %}

View File

@ -94,7 +94,6 @@
<div class="remote-support-sponsorship-container"> <div class="remote-support-sponsorship-container">
{% with %} {% with %}
{% set sponsorship_data = remote_servers_support_data[remote_server.id].sponsorship_data %} {% set sponsorship_data = remote_servers_support_data[remote_server.id].sponsorship_data %}
{% set format_discount = format_discount %}
{% include 'corporate/support/sponsorship_details.html' %} {% include 'corporate/support/sponsorship_details.html' %}
{% endwith %} {% endwith %}
</div> </div>
@ -104,7 +103,6 @@
{% set sponsorship_data = remote_servers_support_data[remote_server.id].sponsorship_data %} {% set sponsorship_data = remote_servers_support_data[remote_server.id].sponsorship_data %}
{% set remote_id = remote_server.id %} {% set remote_id = remote_server.id %}
{% set remote_type = "remote_server_id" %} {% set remote_type = "remote_server_id" %}
{% set format_discount = format_discount %}
{% set has_fixed_price = remote_servers_support_data[remote_server.id].plan_data.has_fixed_price %} {% set has_fixed_price = remote_servers_support_data[remote_server.id].plan_data.has_fixed_price %}
{% include 'corporate/support/sponsorship_forms_support.html' %} {% include 'corporate/support/sponsorship_forms_support.html' %}
{% endwith %} {% endwith %}
@ -115,7 +113,6 @@
<div class="current-plan-container"> <div class="current-plan-container">
{% with %} {% with %}
{% set plan_data = remote_servers_support_data[remote_server.id].plan_data %} {% set plan_data = remote_servers_support_data[remote_server.id].plan_data %}
{% set format_discount = format_discount %}
{% set dollar_amount = dollar_amount %} {% set dollar_amount = dollar_amount %}
{% include 'corporate/support/current_plan_details.html' %} {% include 'corporate/support/current_plan_details.html' %}
{% endwith %} {% endwith %}
@ -133,7 +130,6 @@
<div class="next-plan-container"> <div class="next-plan-container">
{% with %} {% with %}
{% set plan_data = remote_servers_support_data[remote_server.id].plan_data %} {% set plan_data = remote_servers_support_data[remote_server.id].plan_data %}
{% set format_discount = format_discount %}
{% set dollar_amount = dollar_amount %} {% set dollar_amount = dollar_amount %}
{% set remote_id = remote_server.id %} {% set remote_id = remote_server.id %}
{% set remote_type = "remote_server_id" %} {% set remote_type = "remote_server_id" %}
@ -180,7 +176,6 @@
{% set remote_server_deactivated = remote_server.deactivated %} {% set remote_server_deactivated = remote_server.deactivated %}
{% set support_data = remote_realms_support_data %} {% set support_data = remote_realms_support_data %}
{% set get_plan_type_name = get_plan_type_name %} {% set get_plan_type_name = get_plan_type_name %}
{% set format_discount = format_discount %}
{% set format_optional_datetime = format_optional_datetime %} {% set format_optional_datetime = format_optional_datetime %}
{% set dollar_amount = dollar_amount %} {% set dollar_amount = dollar_amount %}
{% include "corporate/support/remote_realm_details.html" %} {% include "corporate/support/remote_realm_details.html" %}

View File

@ -2,7 +2,7 @@
<p class="support-section-header">💸 Discount and sponsorship information:</p> <p class="support-section-header">💸 Discount and sponsorship information:</p>
<b>Sponsorship pending</b>: {{ sponsorship_data.sponsorship_pending }}<br /> <b>Sponsorship pending</b>: {{ sponsorship_data.sponsorship_pending }}<br />
{% if sponsorship_data.default_discount %} {% if sponsorship_data.default_discount %}
<b>Discount</b>: {{ format_discount(sponsorship_data.default_discount) }}%<br /> <b>Discount</b>: {{ sponsorship_data.default_discount }}%<br />
{% else %} {% else %}
<b>Discount</b>: None<br /> <b>Discount</b>: None<br />
{% endif %} {% endif %}

View File

@ -15,18 +15,31 @@
<button type="submit" class="support-submit-button">Update</button> <button type="submit" class="support-submit-button">Update</button>
</form> </form>
<form method="POST" class="remote-form"> <form method="POST" class="remote-form discounted-price-form">
<b>Discount percentage</b>:<br /> <b>Discounted price <i class="fa fa-question-circle-o" data-tippy-content="
<i>Needs required plan tier to be set.</i><br /> Needs required plan tier to be set.<br />
<i>Updates will change pre-existing plans and scheduled upgrades.</i><br /> Default price for tier will be used if discounted price for the schedule is not specified or is 0.<br />
<i>Any prorated licenses for the current billing cycle will be billed at the updated discounted rate.</i><br /> Updates will change pre-existing plans and scheduled upgrades.<br />
Any prorated licenses for the current billing cycle will be billed at the updated discounted rate.<br />
Customer will lose flat discounted months regardless of value specified.<br />
" data-tippy-allowHTML="true" data-tippy-maxWidth="auto"></i></b>
{{ csrf_input }} {{ csrf_input }}
<input type="hidden" name="{{ remote_type }}" value="{{ remote_id }}" /> <input type="hidden" name="{{ remote_type }}" value="{{ remote_id }}" />
{% if has_fixed_price %} {% if has_fixed_price %}
<input type="number" name="discount" value="{{ format_discount(sponsorship_data.default_discount) }}" step="0.01" min="0" max="99.99" disabled /> <input type="number" value="{{ sponsorship_data.monthly_discounted_price }}" placeholder="Monthly discounted price" disabled />
<input type="number" value="{{ sponsorship_data.annual_discounted_price }}" placeholder="Annual discounted price" disabled />
<button type="submit" class="support-submit-button" disabled>Update</button> <button type="submit" class="support-submit-button" disabled>Update</button>
{% else %} {% else %}
<input type="number" name="discount" value="{{ format_discount(sponsorship_data.default_discount) }}" step="0.01" min="0" max="99.99" <span>Monthly (cents)</span>
<input type="number" name="monthly_discounted_price" value="{{ sponsorship_data.monthly_discounted_price }}" placeholder="Monthly discounted price" data-original-monthly-price="{{ sponsorship_data.original_monthly_plan_price }}"
{% if sponsorship_data.required_plan_tier %}
required
{% else %}
disabled
{% endif %}
/>
<span>Annual (cents)</span>
<input type="number" name="annual_discounted_price" value="{{ sponsorship_data.annual_discounted_price }}" placeholder="Annual discounted price" data-original-annual-price="{{ sponsorship_data.original_annual_plan_price }}"
{% if sponsorship_data.required_plan_tier %} {% if sponsorship_data.required_plan_tier %}
required required
{% else %} {% else %}
@ -37,7 +50,7 @@
{% endif %} {% endif %}
</form> </form>
{% if not has_fixed_price and (sponsorship_data.default_discount or sponsorship_data.minimum_licenses) %} {% if not has_fixed_price and (sponsorship_data.monthly_discounted_price or sponsorship_data.annual_discounted_price or sponsorship_data.minimum_licenses) %}
<form method="POST" class="remote-form"> <form method="POST" class="remote-form">
<b>Minimum licenses</b>:<br /> <b>Minimum licenses</b>:<br />
{{ csrf_input }} {{ csrf_input }}

View File

@ -50,7 +50,6 @@
</div> </div>
<div class="user-realm-information-section"> <div class="user-realm-information-section">
{% with %} {% with %}
{% set format_discount = format_discount %}
{% set dollar_amount = dollar_amount %} {% set dollar_amount = dollar_amount %}
{% include "corporate/support/realm_details.html" %} {% include "corporate/support/realm_details.html" %}
{% endwith %} {% endwith %}
@ -61,7 +60,6 @@
{% for realm in realms %} {% for realm in realms %}
<div class="support-query-result"> <div class="support-query-result">
{% with %} {% with %}
{% set format_discount = format_discount %}
{% set dollar_amount = dollar_amount %} {% set dollar_amount = dollar_amount %}
{% include "corporate/support/realm_details.html" %} {% include "corporate/support/realm_details.html" %}
{% endwith %} {% endwith %}
@ -111,7 +109,6 @@
<div class="confirmation-realm-section"> <div class="confirmation-realm-section">
{% if show_realm_details %} {% if show_realm_details %}
{% with %} {% with %}
{% set format_discount = format_discount %}
{% set dollar_amount = dollar_amount %} {% set dollar_amount = dollar_amount %}
{% include "corporate/support/realm_details.html" %} {% include "corporate/support/realm_details.html" %}
{% endwith %} {% endwith %}

View File

@ -732,6 +732,9 @@ html_rules: List["Rule"] = [
{ {
"pattern": r"(?i:data-tippy-allowHTML)", "pattern": r"(?i:data-tippy-allowHTML)",
"description": "Never use data-tippy-allowHTML; for an HTML tooltip, set data-tooltip-template-id to the id of a <template> containing the tooltip content.", "description": "Never use data-tippy-allowHTML; for an HTML tooltip, set data-tooltip-template-id to the id of a <template> containing the tooltip content.",
"exclude": {
"templates/corporate/support/sponsorship_discount_forms.html",
},
}, },
] ]

View File

@ -93,6 +93,8 @@ const upgrade_params_schema = default_params_schema.extend({
fixed_price: z.number().nullable(), fixed_price: z.number().nullable(),
setup_payment_by_invoice: z.boolean(), setup_payment_by_invoice: z.boolean(),
free_trial_days: z.nullable(z.number()), free_trial_days: z.nullable(z.number()),
percent_off_annual_price: z.string().nullable(),
percent_off_monthly_price: z.string().nullable(),
}); });
const page_params_schema = z.discriminatedUnion("page_type", [ const page_params_schema = z.discriminatedUnion("page_type", [

View File

@ -71,6 +71,16 @@ function update_due_today(schedule: string): void {
const unit_price = prices[schedule_typed] / num_months; const unit_price = prices[schedule_typed] / num_months;
$("#due-today .due-today-unit-price").text(helpers.format_money(unit_price)); $("#due-today .due-today-unit-price").text(helpers.format_money(unit_price));
$(".billing-page-discount").hide();
if (schedule === "annual" && page_params.percent_off_annual_price) {
$(".billing-page-selected-schedule-discount").text(page_params.percent_off_annual_price);
$(".billing-page-discount").show();
}
if (schedule === "monthly" && page_params.percent_off_monthly_price) {
$(".billing-page-selected-schedule-discount").text(page_params.percent_off_monthly_price);
$(".billing-page-discount").show();
}
} }
function update_due_today_for_remote_server(start_date: string): void { function update_due_today_for_remote_server(start_date: string): void {

View File

@ -46,4 +46,58 @@ $(() => {
}); });
new ClipboardJS("a.copy-button"); new ClipboardJS("a.copy-button");
$("body").on(
"blur",
"input[name='monthly_discounted_price']",
function (this: HTMLInputElement) {
const input_monthly_price = $(this).val();
if (!input_monthly_price) {
return;
}
const monthly_price = Number.parseInt(input_monthly_price, 10);
const $annual_price = $(this).siblings("input[name='annual_discounted_price']");
// Update the annual price input if it's empty
if (!$annual_price.val()) {
const data_original_monthly_price = $(this).attr("data-original-monthly-price");
const data_original_annual_price = $annual_price.attr("data-original-annual-price");
if (data_original_monthly_price && data_original_annual_price) {
const original_monthly_price = Number.parseInt(data_original_monthly_price, 10);
const original_annual_price = Number.parseInt(data_original_annual_price, 10);
let derived_annual_price =
(original_annual_price / original_monthly_price) * monthly_price;
derived_annual_price = Math.round(derived_annual_price);
$annual_price.val(derived_annual_price);
}
}
},
);
$("body").on(
"blur",
"input[name='annual_discounted_price']",
function (this: HTMLInputElement) {
const input_annual_price = $(this).val();
if (!input_annual_price) {
return;
}
const annual_price = Number.parseInt(input_annual_price, 10);
const $monthly_price = $(this).siblings("input[name='monthly_discounted_price']");
// Update the monthly price input if it's empty
if (!$monthly_price.val()) {
const data_original_monthly_price = $monthly_price.attr(
"data-original-monthly-price",
);
const data_original_annual_price = $(this).attr("data-original-annual-price");
if (data_original_monthly_price && data_original_annual_price) {
const original_monthly_price = Number.parseInt(data_original_monthly_price, 10);
const original_annual_price = Number.parseInt(data_original_annual_price, 10);
let derived_monthly_price =
(original_monthly_price / original_annual_price) * annual_price;
derived_monthly_price = Math.round(derived_monthly_price);
$monthly_price.val(derived_monthly_price);
}
}
},
);
}); });

View File

@ -501,3 +501,14 @@ tr.admin td:first-child {
.current-plan-container { .current-plan-container {
background-color: hsl(31deg 100% 83%); background-color: hsl(31deg 100% 83%);
} }
.discounted-price-form {
display: flex;
flex-direction: column;
gap: 5px;
align-items: flex-start;
.support-submit-button {
margin-top: 5px;
}
}

View File

@ -109,7 +109,9 @@
"./src/bundles/common", "./src/bundles/common",
"sorttable", "sorttable",
"./styles/portico/activity.css", "./styles/portico/activity.css",
"./src/support/support" "./src/support/support",
"./src/portico/tippyjs",
"tippy.js/dist/tippy.css"
], ],
"desktop-login": ["./src/bundles/portico", "./src/portico/desktop-login"], "desktop-login": ["./src/bundles/portico", "./src/portico/desktop-login"],
"desktop-redirect": ["./src/bundles/portico", "./src/portico/desktop-redirect"], "desktop-redirect": ["./src/bundles/portico", "./src/portico/desktop-redirect"],

View File

@ -31,7 +31,6 @@ for any particular type of object.
import re import re
from dataclasses import dataclass from dataclasses import dataclass
from datetime import datetime, timezone from datetime import datetime, timezone
from decimal import Decimal
from typing import ( from typing import (
Any, Any,
Callable, Callable,
@ -605,10 +604,6 @@ def to_float(var_name: str, s: str) -> float:
return float(s) return float(s)
def to_decimal(var_name: str, s: str) -> Decimal:
return Decimal(s)
def to_timezone_or_empty(var_name: str, s: str) -> str: def to_timezone_or_empty(var_name: str, s: str) -> str:
try: try:
s = canonicalize_timezone(s) s = canonicalize_timezone(s)