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

View File

@ -594,7 +594,8 @@ class SupportType(Enum):
class SupportViewRequest(TypedDict, total=False):
support_type: SupportType
sponsorship_status: Optional[bool]
discount: Optional[Decimal]
monthly_discounted_price: Optional[int]
annual_discounted_price: Optional[int]
billing_modality: Optional[str]
plan_modification: Optional[str]
new_plan_tier: Optional[int]
@ -646,6 +647,8 @@ class UpgradePageParams(TypedDict):
fixed_price: Optional[int]
setup_payment_by_invoice: bool
free_trial_days: Optional[int]
percent_off_annual_price: Optional[str]
percent_off_monthly_price: Optional[str]
class UpgradePageSessionTypeSpecificContext(TypedDict):
@ -672,7 +675,6 @@ class SponsorshipRequestSessionSpecificContext(TypedDict):
class UpgradePageContext(TypedDict):
customer_name: str
discount_percent: Optional[str]
email: str
exempt_from_license_number_check: bool
free_trial_end_date: Optional[str]
@ -1276,13 +1278,20 @@ class BillingSession(ABC):
def apply_discount_to_plan(
self,
plan: CustomerPlan,
discount: Decimal,
customer: Customer,
) -> None:
plan.discount = discount
plan.price_per_license = get_price_per_license(plan.tier, plan.billing_schedule, discount)
plan.save(update_fields=["discount", "price_per_license"])
original_plan_price = get_price_per_license(plan.tier, plan.billing_schedule)
plan.price_per_license = get_price_per_license(plan.tier, plan.billing_schedule, customer)
def attach_discount_to_customer(self, new_discount: Decimal) -> str:
# For display purposes only.
plan.discount = format_discount_percentage(
Decimal((original_plan_price - plan.price_per_license) / original_plan_price * 100)
)
plan.save(update_fields=["price_per_license", "discount"])
def attach_discount_to_customer(
self, monthly_discounted_price: int, annual_discounted_price: int
) -> str:
# Remove flat discount if giving customer a percentage discount.
customer = self.get_customer()
@ -1290,35 +1299,44 @@ class BillingSession(ABC):
assert customer is not None
assert customer.required_plan_tier is not None
old_discount = customer.default_discount
customer.default_discount = new_discount
old_monthly_discounted_price = customer.monthly_discounted_price
customer.monthly_discounted_price = monthly_discounted_price
old_annual_discounted_price = customer.annual_discounted_price
customer.annual_discounted_price = annual_discounted_price
# Ideally we would have some way to restore flat discounted months
# if we applied discounted to a customer and reverted it but seems
# like an edge case and can be handled manually on request.
customer.flat_discounted_months = 0
customer.save(update_fields=["default_discount", "flat_discounted_months"])
customer.save(
update_fields=[
"monthly_discounted_price",
"annual_discounted_price",
"flat_discounted_months",
]
)
plan = get_current_plan_by_customer(customer)
if plan is not None and plan.tier == customer.required_plan_tier:
self.apply_discount_to_plan(plan, new_discount)
self.apply_discount_to_plan(plan, customer)
# If the customer has a next plan, apply discount to that plan as well.
# Make this a check on CustomerPlan.SWITCH_PLAN_TIER_AT_PLAN_END status
# if we support this for other plans.
next_plan = self.get_legacy_remote_server_next_plan(customer)
if next_plan is not None and next_plan.tier == customer.required_plan_tier:
self.apply_discount_to_plan(next_plan, new_discount)
self.apply_discount_to_plan(next_plan, customer)
self.write_to_audit_log(
event_type=AuditLogEventType.DISCOUNT_CHANGED,
event_time=timezone_now(),
extra_data={"old_discount": old_discount, "new_discount": new_discount},
extra_data={
"old_monthly_discounted_price": old_monthly_discounted_price,
"new_monthly_discounted_price": customer.monthly_discounted_price,
"old_annual_discounted_price": old_annual_discounted_price,
"new_annual_discounted_price": customer.annual_discounted_price,
},
)
new_discount_string = (
format_discount_percentage(new_discount) if (new_discount != Decimal(0)) else "0"
)
old_discount_string = (
format_discount_percentage(old_discount)
if (old_discount is not None and old_discount != Decimal(0))
else "0"
)
return f"Discount for {self.billing_entity_display_name} changed to {new_discount_string}% from {old_discount_string}%."
return f"Monthly price for {self.billing_entity_display_name} changed to {customer.monthly_discounted_price} from {old_monthly_discounted_price}. Annual price changed to {customer.annual_discounted_price} from {old_annual_discounted_price}."
def update_customer_minimum_licenses(self, new_minimum_license_count: int) -> str:
previous_minimum_license_count = None
@ -1327,7 +1345,7 @@ class BillingSession(ABC):
# Currently, the support admin view shows the form for adding
# a minimum license count after a default discount has been set.
assert customer is not None
if customer.default_discount is None or int(customer.default_discount) == 0:
if not (customer.monthly_discounted_price or customer.annual_discounted_price):
raise SupportRequestError(
f"Discount for {self.billing_entity_display_name} must be updated before setting a minimum number of licenses."
)
@ -1372,7 +1390,9 @@ class BillingSession(ABC):
raise SupportRequestError(f"Invalid plan tier for {self.billing_entity_display_name}.")
if customer is not None:
if new_plan_tier is None and customer.default_discount:
if new_plan_tier is None and (
customer.monthly_discounted_price or customer.annual_discounted_price
):
raise SupportRequestError(
f"Discount for {self.billing_entity_display_name} must be 0 before setting required plan tier to None."
)
@ -1626,7 +1646,6 @@ class BillingSession(ABC):
"days_until_due": days_until_due,
"current_plan_id": current_plan_id,
}
discount_for_plan = customer.get_discount_for_plan_tier(plan_tier)
(
invoice_period_start,
_,
@ -1635,7 +1654,7 @@ class BillingSession(ABC):
) = compute_plan_parameters(
plan_tier,
billing_schedule,
discount_for_plan,
customer,
on_free_trial,
None,
not isinstance(self, RealmBillingSession),
@ -1724,11 +1743,8 @@ class BillingSession(ABC):
assert remote_server_legacy_plan is not None
billing_cycle_anchor = remote_server_legacy_plan.end_date
discount_for_plan = None
fixed_price_plan_offer = get_configured_fixed_price_plan_offer(customer, plan_tier)
if fixed_price_plan_offer is None:
discount_for_plan = customer.get_discount_for_plan_tier(plan_tier)
else:
if fixed_price_plan_offer is not None:
assert automanage_licenses is True
(
@ -1739,7 +1755,7 @@ class BillingSession(ABC):
) = compute_plan_parameters(
plan_tier,
billing_schedule,
discount_for_plan,
customer,
free_trial,
billing_cycle_anchor,
is_self_hosted_billing,
@ -1766,11 +1782,14 @@ class BillingSession(ABC):
if fixed_price_plan_offer is None:
plan_params["price_per_license"] = price_per_license
plan_params["discount"] = discount_for_plan
_price_per_license, percent_off = get_price_per_license_and_discount(
plan_tier, billing_schedule, customer
)
plan_params["discount"] = percent_off
assert price_per_license == _price_per_license
if free_trial:
plan_params["status"] = CustomerPlan.FREE_TRIAL
if charge_automatically:
# Ensure free trial customers not paying via invoice have a default payment method set
assert customer.stripe_customer_id is not None # for mypy
@ -2025,11 +2044,8 @@ class BillingSession(ABC):
plan.next_invoice_date = None
plan.save(update_fields=["status", "next_invoice_date"])
discount_for_current_plan = plan.discount
_, _, _, price_per_license = compute_plan_parameters(
tier=plan.tier,
billing_schedule=schedule,
discount=discount_for_current_plan,
price_per_license, discount_for_current_plan = get_price_per_license_and_discount(
plan.tier, schedule, plan.customer
)
new_plan = CustomerPlan.objects.create(
@ -2185,11 +2201,8 @@ class BillingSession(ABC):
plan.status = CustomerPlan.ENDED
plan.save(update_fields=["status"])
discount_for_current_plan = plan.discount
_, _, _, price_per_license = compute_plan_parameters(
tier=plan.tier,
billing_schedule=CustomerPlan.BILLING_SCHEDULE_ANNUAL,
discount=discount_for_current_plan,
price_per_license, discount_for_current_plan = get_price_per_license_and_discount(
plan.tier, CustomerPlan.BILLING_SCHEDULE_ANNUAL, plan.customer
)
new_plan = CustomerPlan.objects.create(
@ -2233,11 +2246,8 @@ class BillingSession(ABC):
plan.status = CustomerPlan.ENDED
plan.save(update_fields=["status"])
discount_for_current_plan = plan.discount
_, _, _, price_per_license = compute_plan_parameters(
tier=plan.tier,
billing_schedule=CustomerPlan.BILLING_SCHEDULE_MONTHLY,
discount=discount_for_current_plan,
price_per_license, discount_for_current_plan = get_price_per_license_and_discount(
plan.tier, CustomerPlan.BILLING_SCHEDULE_MONTHLY, plan.customer
)
new_plan = CustomerPlan.objects.create(
@ -2366,19 +2376,18 @@ class BillingSession(ABC):
)
billing_frequency = CustomerPlan.BILLING_SCHEDULES[plan.billing_schedule]
discount_for_current_plan = plan.discount
if switch_to_annual_at_end_of_cycle:
num_months_next_cycle = 12
annual_price_per_license = get_price_per_license(
plan.tier, CustomerPlan.BILLING_SCHEDULE_ANNUAL, discount_for_current_plan
plan.tier, CustomerPlan.BILLING_SCHEDULE_ANNUAL, customer
)
renewal_cents = annual_price_per_license * licenses_at_next_renewal
price_per_license = format_money(annual_price_per_license / 12)
elif switch_to_monthly_at_end_of_cycle:
num_months_next_cycle = 1
monthly_price_per_license = get_price_per_license(
plan.tier, CustomerPlan.BILLING_SCHEDULE_MONTHLY, discount_for_current_plan
plan.tier, CustomerPlan.BILLING_SCHEDULE_MONTHLY, customer
)
renewal_cents = monthly_price_per_license * licenses_at_next_renewal
price_per_license = format_money(monthly_price_per_license)
@ -2446,7 +2455,7 @@ class BillingSession(ABC):
"sponsorship_plan_name": self.get_sponsorship_plan_name(
customer, is_self_hosted_billing
),
"discount_percent": format_discount_percentage(discount_for_current_plan),
"discount_percent": plan.discount,
"is_self_hosted_billing": is_self_hosted_billing,
"is_server_on_legacy_plan": remote_server_legacy_plan_end_date is not None,
"remote_server_legacy_plan_end_date": remote_server_legacy_plan_end_date,
@ -2592,11 +2601,12 @@ class BillingSession(ABC):
last_send_invoice.plan.name
)
percent_off = Decimal(0)
if customer is not None:
discount_for_plan_tier = customer.get_discount_for_plan_tier(tier)
if discount_for_plan_tier is not None:
percent_off = discount_for_plan_tier
annual_price, percent_off_annual_price = get_price_per_license_and_discount(
tier, CustomerPlan.BILLING_SCHEDULE_ANNUAL, customer
)
monthly_price, percent_off_monthly_price = get_price_per_license_and_discount(
tier, CustomerPlan.BILLING_SCHEDULE_MONTHLY, customer
)
customer_specific_context = self.get_upgrade_page_session_type_specific_context()
min_licenses_for_plan = self.min_licenses_for_plan(tier)
@ -2636,7 +2646,6 @@ class BillingSession(ABC):
flat_discount, flat_discounted_months = self.get_flat_discount_info(customer)
context: UpgradePageContext = {
"customer_name": customer_specific_context["customer_name"],
"discount_percent": format_discount_percentage(percent_off),
"email": customer_specific_context["email"],
"exempt_from_license_number_check": exempt_from_license_number_check,
"free_trial_end_date": free_trial_end_date,
@ -2645,15 +2654,11 @@ class BillingSession(ABC):
"manual_license_management": initial_upgrade_request.manual_license_management,
"page_params": {
"page_type": "upgrade",
"annual_price": get_price_per_license(
tier, CustomerPlan.BILLING_SCHEDULE_ANNUAL, percent_off
),
"annual_price": annual_price,
"demo_organization_scheduled_deletion_date": customer_specific_context[
"demo_organization_scheduled_deletion_date"
],
"monthly_price": get_price_per_license(
tier, CustomerPlan.BILLING_SCHEDULE_MONTHLY, percent_off
),
"monthly_price": monthly_price,
"seat_count": seat_count,
"billing_base_url": self.billing_base_url,
"tier": tier,
@ -2662,6 +2667,8 @@ class BillingSession(ABC):
"fixed_price": fixed_price,
"setup_payment_by_invoice": setup_payment_by_invoice,
"free_trial_days": free_trial_days,
"percent_off_annual_price": percent_off_annual_price,
"percent_off_monthly_price": percent_off_monthly_price,
},
"using_min_licenses_for_plan": using_min_licenses_for_plan,
"min_licenses_for_plan": min_licenses_for_plan,
@ -2708,7 +2715,7 @@ class BillingSession(ABC):
) -> int:
customer = self.get_customer()
if customer is not None and customer.minimum_licenses:
assert customer.default_discount is not None
assert customer.monthly_discounted_price or customer.annual_discounted_price
return customer.minimum_licenses
if tier == CustomerPlan.TIER_SELF_HOSTED_BASIC:
@ -2945,9 +2952,8 @@ class BillingSession(ABC):
current_plan.status = CustomerPlan.ENDED
current_plan.save(update_fields=["status", "end_date"])
discount_for_new_plan_tier = current_plan.customer.get_discount_for_plan_tier(new_plan_tier)
new_price_per_license = get_price_per_license(
new_plan_tier, current_plan.billing_schedule, discount_for_new_plan_tier
new_price_per_license, discount_for_new_plan_tier = get_price_per_license_and_discount(
new_plan_tier, current_plan.billing_schedule, current_plan.customer
)
new_plan_billing_cycle_anchor = current_plan.end_date.replace(microsecond=0)
@ -3355,9 +3361,13 @@ class BillingSession(ABC):
sponsorship_status = support_request["sponsorship_status"]
success_message = self.update_customer_sponsorship_status(sponsorship_status)
elif support_type == SupportType.attach_discount:
assert support_request["discount"] is not None
new_discount = support_request["discount"]
success_message = self.attach_discount_to_customer(new_discount)
monthly_discounted_price = support_request["monthly_discounted_price"]
annual_discounted_price = support_request["annual_discounted_price"]
assert monthly_discounted_price is not None
assert annual_discounted_price is not None
success_message = self.attach_discount_to_customer(
monthly_discounted_price, annual_discounted_price
)
elif support_type == SupportType.update_minimum_licenses:
assert support_request["minimum_licenses"] is not None
new_minimum_license_count = support_request["minimum_licenses"]
@ -4266,7 +4276,11 @@ class RemoteRealmBillingSession(BillingSession):
remote_realm=self.remote_realm, defaults=defaults
)
if created and not customer.default_discount:
if (
created
and not customer.annual_discounted_price
and not customer.monthly_discounted_price
):
customer.flat_discounted_months = 12
customer.save(update_fields=["flat_discounted_months"])
@ -4699,7 +4713,11 @@ class RemoteServerBillingSession(BillingSession):
remote_server=self.remote_server, defaults=defaults
)
if created and not customer.default_discount:
if (
created
and not customer.annual_discounted_price
and not customer.monthly_discounted_price
):
customer.flat_discounted_months = 12
customer.save(update_fields=["flat_discounted_months"])
@ -4985,16 +5003,15 @@ def customer_has_credit_card_as_default_payment_method(customer: Customer) -> bo
return stripe_customer_has_credit_card_as_default_payment_method(stripe_customer)
def calculate_discounted_price_per_license(
original_price_per_license: int, discount: Decimal
) -> int:
# There are no fractional cents in Stripe, so round down to nearest integer.
return int(float(original_price_per_license * (1 - discount / 100)) + 0.00001)
def get_price_per_license(
tier: int, billing_schedule: int, discount: Optional[Decimal] = None
tier: int, billing_schedule: int, customer: Optional[Customer] = None
) -> int:
if customer is not None:
price_per_license = customer.get_discounted_price_for_plan(tier, billing_schedule)
if price_per_license:
# We already have a set discounted price for the current tier.
return price_per_license
price_map: Dict[int, Dict[str, int]] = {
CustomerPlan.TIER_CLOUD_STANDARD: {"Annual": 8000, "Monthly": 800},
CustomerPlan.TIER_CLOUD_PLUS: {"Annual": 12000, "Monthly": 1200},
@ -5012,15 +5029,30 @@ def get_price_per_license(
else: # nocoverage
raise InvalidBillingScheduleError(billing_schedule)
if discount is not None:
price_per_license = calculate_discounted_price_per_license(price_per_license, discount)
return price_per_license
def get_price_per_license_and_discount(
tier: int, billing_schedule: int, customer: Optional[Customer]
) -> Tuple[int, Union[str, None]]:
original_price_per_license = get_price_per_license(tier, billing_schedule)
if customer is None:
return original_price_per_license, None
price_per_license = get_price_per_license(tier, billing_schedule, customer)
if price_per_license == original_price_per_license:
return price_per_license, None
discount = format_discount_percentage(
Decimal((original_price_per_license - price_per_license) / original_price_per_license * 100)
)
return price_per_license, discount
def compute_plan_parameters(
tier: int,
billing_schedule: int,
discount: Optional[Decimal],
customer: Optional[Customer],
free_trial: bool = False,
billing_cycle_anchor: Optional[datetime] = None,
is_self_hosted_billing: bool = False,
@ -5039,7 +5071,7 @@ def compute_plan_parameters(
else: # nocoverage
raise InvalidBillingScheduleError(billing_schedule)
price_per_license = get_price_per_license(tier, billing_schedule, discount)
price_per_license = get_price_per_license(tier, billing_schedule, customer)
# `next_invoice_date` is the date when we check if there are any invoices that need to be generated.
# It is always the next month regardless of the billing schedule / billing modality.

View File

@ -1,6 +1,5 @@
from dataclasses import dataclass
from datetime import datetime, timedelta
from decimal import Decimal
from typing import Optional, TypedDict, Union
from urllib.parse import urlencode, urljoin, urlunsplit
@ -16,6 +15,7 @@ from corporate.lib.stripe import (
RemoteRealmBillingSession,
RemoteServerBillingSession,
get_configured_fixed_price_plan_offer,
get_price_per_license,
get_push_status_for_remote_request,
start_of_next_billing_cycle,
)
@ -58,7 +58,10 @@ class SponsorshipRequestDict(TypedDict):
@dataclass
class SponsorshipData:
sponsorship_pending: bool = False
default_discount: Optional[Decimal] = None
monthly_discounted_price: Optional[int] = None
annual_discounted_price: Optional[int] = None
original_monthly_plan_price: Optional[int] = None
original_annual_plan_price: Optional[int] = None
minimum_licenses: Optional[int] = None
required_plan_tier: Optional[int] = None
latest_sponsorship_request: Optional[SponsorshipRequestDict] = None
@ -122,10 +125,24 @@ def get_realm_support_url(realm: Realm) -> str:
def get_customer_sponsorship_data(customer: Customer) -> SponsorshipData:
pending = customer.sponsorship_pending
discount = customer.default_discount
licenses = customer.minimum_licenses
plan_tier = customer.required_plan_tier
sponsorship_request = None
monthly_discounted_price = None
annual_discounted_price = None
original_monthly_plan_price = None
original_annual_plan_price = None
if customer.monthly_discounted_price:
monthly_discounted_price = customer.monthly_discounted_price
if customer.annual_discounted_price:
annual_discounted_price = customer.annual_discounted_price
if plan_tier is not None:
original_monthly_plan_price = get_price_per_license(
plan_tier, CustomerPlan.BILLING_SCHEDULE_MONTHLY
)
original_annual_plan_price = get_price_per_license(
plan_tier, CustomerPlan.BILLING_SCHEDULE_ANNUAL
)
if pending:
last_sponsorship_request = (
ZulipSponsorshipRequest.objects.filter(customer=customer).order_by("id").last()
@ -151,7 +168,10 @@ def get_customer_sponsorship_data(customer: Customer) -> SponsorshipData:
return SponsorshipData(
sponsorship_pending=pending,
default_discount=discount,
monthly_discounted_price=monthly_discounted_price,
annual_discounted_price=annual_discounted_price,
original_monthly_plan_price=original_monthly_plan_price,
original_annual_plan_price=original_annual_plan_price,
minimum_licenses=licenses,
required_plan_tier=plan_tier,
latest_sponsorship_request=sponsorship_request,

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 typing import Any, Dict, Optional, Union
@ -27,10 +26,16 @@ class Customer(models.Model):
stripe_customer_id = models.CharField(max_length=255, null=True, unique=True)
sponsorship_pending = models.BooleanField(default=False)
# A percentage, like 85.
default_discount = models.DecimalField(decimal_places=4, max_digits=7, null=True)
# Discounted price for required_plan_tier in cents.
# We treat 0 as no discount. Not using `null` here keeps the
# checks simpler and avoids the cases where we forget to
# check for both `null` and 0.
monthly_discounted_price = models.IntegerField(default=0, null=False)
annual_discounted_price = models.IntegerField(default=0, null=False)
minimum_licenses = models.PositiveIntegerField(null=True)
# Used for limiting a default_discount or a fixed_price
# Used for limiting discounted price or a fixed_price
# to be used only for a particular CustomerPlan tier.
required_plan_tier = models.SmallIntegerField(null=True)
# Some non-profit organizations on manual license management pay
@ -64,10 +69,15 @@ class Customer(models.Model):
else:
return f"{self.remote_server!r} (with stripe_customer_id: {self.stripe_customer_id})"
def get_discount_for_plan_tier(self, plan_tier: int) -> Optional[Decimal]:
if self.required_plan_tier is None or self.required_plan_tier == plan_tier:
return self.default_discount
return None
def get_discounted_price_for_plan(self, plan_tier: int, schedule: int) -> Optional[int]:
if plan_tier != self.required_plan_tier:
return None
if schedule == CustomerPlan.BILLING_SCHEDULE_ANNUAL:
return self.annual_discounted_price
assert schedule == CustomerPlan.BILLING_SCHEDULE_MONTHLY
return self.monthly_discounted_price
def get_customer_by_realm(realm: Realm) -> Optional[Customer]:
@ -325,8 +335,10 @@ class CustomerPlan(AbstractCustomerPlan):
# can't be set via the self-serve billing system.
price_per_license = models.IntegerField(null=True)
# Discount that was applied. For display purposes only.
discount = models.DecimalField(decimal_places=4, max_digits=6, null=True)
# Discount for current `billing_schedule`. For display purposes only.
# Explicitly set to be TextField to avoid being used in calculations.
# NOTE: This discount can be different for annual and monthly schedules.
discount = models.TextField(null=True)
# Initialized with the time of plan creation. Used for calculating
# start of next billing cycle, next invoice date etc. This value

View File

@ -141,7 +141,7 @@ class ActivityTest(ZulipTestCase):
user_profile.is_staff = True
user_profile.save(update_fields=["is_staff"])
with self.assert_database_query_count(12):
with self.assert_database_query_count(11):
result = self.client_get("/activity")
self.assertEqual(result.status_code, 200)

View File

@ -41,7 +41,6 @@ from django.utils.crypto import get_random_string
from django.utils.timezone import now as timezone_now
from typing_extensions import ParamSpec, override
from corporate.lib.activity import get_realms_with_default_discount_dict
from corporate.lib.stripe import (
DEFAULT_INVOICE_DAYS_UNTIL_DUE,
MAX_INVOICED_LICENSES,
@ -4542,7 +4541,7 @@ class StripeTest(StripeTestCase):
user=self.example_user("iago"), realm=realm, support_session=True
)
billing_session.set_required_plan_tier(CustomerPlan.TIER_CLOUD_STANDARD)
billing_session.attach_discount_to_customer(Decimal(20))
billing_session.attach_discount_to_customer(640, 6400)
rows.append(Row(realm, Realm.PLAN_TYPE_SELF_HOSTED, None, None, 0, False))
# no active paid plan or invoices (no action)
@ -5122,8 +5121,15 @@ class BillingHelpersTest(ZulipTestCase):
anchor = datetime(2019, 12, 31, 1, 2, 3, tzinfo=timezone.utc)
month_later = datetime(2020, 1, 31, 1, 2, 3, tzinfo=timezone.utc)
year_later = datetime(2020, 12, 31, 1, 2, 3, tzinfo=timezone.utc)
customer_with_discount = Customer.objects.create(
realm=get_realm("lear"),
monthly_discounted_price=600,
annual_discounted_price=6000,
required_plan_tier=CustomerPlan.TIER_CLOUD_STANDARD,
)
customer_no_discount = Customer.objects.create(realm=get_realm("zulip"))
test_cases = [
# test all possibilities, since there aren't that many
# Annual standard no customer
(
(
CustomerPlan.TIER_CLOUD_STANDARD,
@ -5132,34 +5138,34 @@ class BillingHelpersTest(ZulipTestCase):
),
(anchor, month_later, year_later, 8000),
),
(
(CustomerPlan.TIER_CLOUD_STANDARD, CustomerPlan.BILLING_SCHEDULE_ANNUAL, 85),
(anchor, month_later, year_later, 1200),
),
(
(
CustomerPlan.TIER_CLOUD_STANDARD,
CustomerPlan.BILLING_SCHEDULE_MONTHLY,
None,
),
(anchor, month_later, month_later, 800),
),
(
(CustomerPlan.TIER_CLOUD_STANDARD, CustomerPlan.BILLING_SCHEDULE_MONTHLY, 85),
(anchor, month_later, month_later, 120),
),
# Annual standard with discount
(
(
CustomerPlan.TIER_CLOUD_STANDARD,
CustomerPlan.BILLING_SCHEDULE_ANNUAL,
None,
customer_with_discount,
),
(anchor, month_later, year_later, 6000),
),
# Annual standard customer but no discount
(
(
CustomerPlan.TIER_CLOUD_STANDARD,
CustomerPlan.BILLING_SCHEDULE_ANNUAL,
customer_no_discount,
),
(anchor, month_later, year_later, 8000),
),
# Annual plus customer with discount but different tier than required for discount
(
(CustomerPlan.TIER_CLOUD_STANDARD, CustomerPlan.BILLING_SCHEDULE_ANNUAL, 85),
(anchor, month_later, year_later, 1200),
(
CustomerPlan.TIER_CLOUD_PLUS,
CustomerPlan.BILLING_SCHEDULE_ANNUAL,
customer_with_discount,
),
(anchor, month_later, year_later, 12000),
),
# Monthly standard no customer
(
(
CustomerPlan.TIER_CLOUD_STANDARD,
@ -5168,43 +5174,56 @@ class BillingHelpersTest(ZulipTestCase):
),
(anchor, month_later, month_later, 800),
),
# Monthly standard with discount
(
(
CustomerPlan.TIER_CLOUD_STANDARD,
CustomerPlan.BILLING_SCHEDULE_MONTHLY,
85,
customer_with_discount,
),
(anchor, month_later, month_later, 120),
(anchor, month_later, month_later, 600),
),
# test exact math of Decimals; 800 * (1 - 87.25) = 101.9999999..
# Monthly standard customer but no discount
(
(
CustomerPlan.TIER_CLOUD_STANDARD,
CustomerPlan.BILLING_SCHEDULE_MONTHLY,
87.25,
customer_no_discount,
),
(anchor, month_later, month_later, 102),
(anchor, month_later, month_later, 800),
),
# test dropping of fractional cents; without the int it's 102.8
# Monthly plus customer with discount but different tier than required for discount
(
(
CustomerPlan.TIER_CLOUD_STANDARD,
CustomerPlan.TIER_CLOUD_PLUS,
CustomerPlan.BILLING_SCHEDULE_MONTHLY,
87.15,
customer_with_discount,
),
(anchor, month_later, month_later, 102),
(anchor, month_later, month_later, 1200),
),
]
with time_machine.travel(anchor, tick=False):
for (tier, billing_schedule, discount), output in test_cases:
for (tier, billing_schedule, customer), output in test_cases:
output_ = compute_plan_parameters(
tier,
billing_schedule,
None if discount is None else Decimal(discount),
customer,
)
self.assertEqual(output_, output)
def test_get_price_per_license(self) -> None:
standard_discounted_customer = Customer.objects.create(
realm=get_realm("lear"),
monthly_discounted_price=400,
annual_discounted_price=4000,
required_plan_tier=CustomerPlan.TIER_CLOUD_STANDARD,
)
plus_discounted_customer = Customer.objects.create(
realm=get_realm("zulip"),
monthly_discounted_price=600,
annual_discounted_price=6000,
required_plan_tier=CustomerPlan.TIER_CLOUD_PLUS,
)
self.assertEqual(
get_price_per_license(
CustomerPlan.TIER_CLOUD_STANDARD, CustomerPlan.BILLING_SCHEDULE_ANNUAL
@ -5221,7 +5240,7 @@ class BillingHelpersTest(ZulipTestCase):
get_price_per_license(
CustomerPlan.TIER_CLOUD_STANDARD,
CustomerPlan.BILLING_SCHEDULE_MONTHLY,
discount=Decimal(50),
standard_discounted_customer,
),
400,
)
@ -5242,7 +5261,16 @@ class BillingHelpersTest(ZulipTestCase):
get_price_per_license(
CustomerPlan.TIER_CLOUD_PLUS,
CustomerPlan.BILLING_SCHEDULE_MONTHLY,
discount=Decimal(50),
# Wrong tier so discount not applied.
standard_discounted_customer,
),
1200,
)
self.assertEqual(
get_price_per_license(
CustomerPlan.TIER_CLOUD_PLUS,
CustomerPlan.BILLING_SCHEDULE_MONTHLY,
plus_discounted_customer,
),
600,
)
@ -5433,37 +5461,6 @@ class BillingHelpersTest(ZulipTestCase):
)
class AnalyticsHelpersTest(ZulipTestCase):
def test_get_realms_to_default_discount_dict(self) -> None:
Customer.objects.create(realm=get_realm("zulip"), stripe_customer_id="cus_1")
lear_customer = Customer.objects.create(realm=get_realm("lear"), stripe_customer_id="cus_2")
lear_customer.default_discount = Decimal(30)
lear_customer.save(update_fields=["default_discount"])
zephyr_customer = Customer.objects.create(
realm=get_realm("zephyr"), stripe_customer_id="cus_3"
)
zephyr_customer.default_discount = Decimal(0)
zephyr_customer.save(update_fields=["default_discount"])
remote_server = RemoteZulipServer.objects.create(
uuid=str(uuid.uuid4()),
api_key="magic_secret_api_key",
hostname="demo.example.com",
contact_email="email@example.com",
)
remote_customer = Customer.objects.create(
remote_server=remote_server, stripe_customer_id="cus_4"
)
remote_customer.default_discount = Decimal(50)
remote_customer.save(update_fields=["default_discount"])
self.assertEqual(
get_realms_with_default_discount_dict(),
{
"lear": Decimal("30.0000"),
},
)
class LicenseLedgerTest(StripeTestCase):
def test_add_plan_renewal_if_needed(self) -> None:
with time_machine.travel(self.now, tick=False):
@ -6039,19 +6036,33 @@ class TestSupportBillingHelpers(StripeTestCase):
# Cannot attach discount without a required_plan_tier set.
with self.assertRaises(AssertionError):
billing_session.attach_discount_to_customer(Decimal(85))
billing_session.attach_discount_to_customer(
monthly_discounted_price=120,
annual_discounted_price=1200,
)
billing_session.update_or_create_customer()
with self.assertRaises(AssertionError):
billing_session.attach_discount_to_customer(Decimal(85))
billing_session.attach_discount_to_customer(
monthly_discounted_price=120,
annual_discounted_price=1200,
)
billing_session.set_required_plan_tier(CustomerPlan.TIER_CLOUD_STANDARD)
billing_session.attach_discount_to_customer(Decimal(85))
billing_session.attach_discount_to_customer(
monthly_discounted_price=120,
annual_discounted_price=1200,
)
realm_audit_log = RealmAuditLog.objects.filter(
event_type=RealmAuditLog.REALM_DISCOUNT_CHANGED
).last()
assert realm_audit_log is not None
expected_extra_data = {"old_discount": None, "new_discount": str(Decimal("85"))}
expected_extra_data = {
"new_annual_discounted_price": 1200,
"new_monthly_discounted_price": 120,
"old_annual_discounted_price": 0,
"old_monthly_discounted_price": 0,
}
self.assertEqual(realm_audit_log.extra_data, expected_extra_data)
self.login_user(user)
# Check that the discount appears in page_params
@ -6071,14 +6082,17 @@ class TestSupportBillingHelpers(StripeTestCase):
[item.amount for item in invoice.lines],
)
# Check CustomerPlan reflects the discount
plan = CustomerPlan.objects.get(price_per_license=1200, discount=Decimal(85))
plan = CustomerPlan.objects.get(price_per_license=1200, discount="85")
# Attach discount to existing Stripe customer
plan.status = CustomerPlan.ENDED
plan.save(update_fields=["status"])
billing_session = RealmBillingSession(support_admin, realm=user.realm, support_session=True)
billing_session.set_required_plan_tier(CustomerPlan.TIER_CLOUD_STANDARD)
billing_session.attach_discount_to_customer(Decimal(25))
billing_session.attach_discount_to_customer(
monthly_discounted_price=600,
annual_discounted_price=6000,
)
with time_machine.travel(self.now, tick=False):
self.add_card_and_upgrade(
user, license_management="automatic", billing_modality="charge_automatically"
@ -6095,12 +6109,16 @@ class TestSupportBillingHelpers(StripeTestCase):
plan = CustomerPlan.objects.get(price_per_license=6000, discount=Decimal(25))
billing_session = RealmBillingSession(support_admin, realm=user.realm, support_session=True)
billing_session.attach_discount_to_customer(Decimal(50))
billing_session.attach_discount_to_customer(
monthly_discounted_price=400,
annual_discounted_price=4000,
)
plan.refresh_from_db()
self.assertEqual(plan.price_per_license, 4000)
self.assertEqual(plan.discount, 50)
self.assertEqual(plan.discount, "50")
customer.refresh_from_db()
self.assertEqual(customer.default_discount, 50)
self.assertEqual(customer.monthly_discounted_price, 400)
self.assertEqual(customer.annual_discounted_price, 4000)
# Fast forward the next_invoice_date to next year.
plan.next_invoice_date = self.next_year
plan.save(update_fields=["next_invoice_date"])
@ -6114,8 +6132,10 @@ class TestSupportBillingHelpers(StripeTestCase):
).last()
assert realm_audit_log is not None
expected_extra_data = {
"old_discount": str(Decimal("25.0000")),
"new_discount": str(Decimal("50")),
"new_annual_discounted_price": 4000,
"new_monthly_discounted_price": 400,
"old_annual_discounted_price": 6000,
"old_monthly_discounted_price": 600,
}
self.assertEqual(realm_audit_log.extra_data, expected_extra_data)
self.assertEqual(realm_audit_log.acting_user, support_admin)
@ -6146,7 +6166,10 @@ class TestSupportBillingHelpers(StripeTestCase):
billing_session.process_support_view_request(support_view_request)
billing_session.set_required_plan_tier(CustomerPlan.TIER_CLOUD_STANDARD)
billing_session.attach_discount_to_customer(Decimal(50))
billing_session.attach_discount_to_customer(
monthly_discounted_price=400,
annual_discounted_price=4000,
)
message = billing_session.process_support_view_request(support_view_request)
self.assertEqual("Minimum licenses for zulip changed to 25 from 0.", message)
realm_audit_log = RealmAuditLog.objects.filter(
@ -6202,16 +6225,34 @@ class TestSupportBillingHelpers(StripeTestCase):
customer = billing_session.get_customer()
assert customer is not None
self.assertEqual(customer.required_plan_tier, valid_plan_tier)
self.assertEqual(customer.default_discount, None)
self.assertEqual(customer.monthly_discounted_price, 0)
self.assertEqual(customer.annual_discounted_price, 0)
# Check that discount is only applied to set plan tier
billing_session.attach_discount_to_customer(Decimal(50))
billing_session.attach_discount_to_customer(
monthly_discounted_price=400,
annual_discounted_price=4000,
)
customer.refresh_from_db()
self.assertEqual(customer.default_discount, Decimal(50))
discount_for_standard_plan = customer.get_discount_for_plan_tier(valid_plan_tier)
self.assertEqual(discount_for_standard_plan, customer.default_discount)
discount_for_plus_plan = customer.get_discount_for_plan_tier(CustomerPlan.TIER_CLOUD_PLUS)
self.assertEqual(discount_for_plus_plan, None)
self.assertEqual(customer.monthly_discounted_price, 400)
self.assertEqual(customer.annual_discounted_price, 4000)
monthly_discounted_price = customer.get_discounted_price_for_plan(
valid_plan_tier, CustomerPlan.BILLING_SCHEDULE_MONTHLY
)
self.assertEqual(monthly_discounted_price, customer.monthly_discounted_price)
annual_discounted_price = customer.get_discounted_price_for_plan(
valid_plan_tier, CustomerPlan.BILLING_SCHEDULE_ANNUAL
)
self.assertEqual(annual_discounted_price, customer.annual_discounted_price)
monthly_discounted_price = customer.get_discounted_price_for_plan(
CustomerPlan.TIER_CLOUD_PLUS, CustomerPlan.BILLING_SCHEDULE_MONTHLY
)
self.assertEqual(monthly_discounted_price, None)
annual_discounted_price = customer.get_discounted_price_for_plan(
CustomerPlan.TIER_CLOUD_PLUS, CustomerPlan.BILLING_SCHEDULE_ANNUAL
)
self.assertEqual(annual_discounted_price, None)
# Try to set invalid plan tier
invalid_plan_tier = CustomerPlan.TIER_SELF_HOSTED_BASE
@ -6232,15 +6273,22 @@ class TestSupportBillingHelpers(StripeTestCase):
):
billing_session.process_support_view_request(support_view_request)
billing_session.attach_discount_to_customer(Decimal(0))
billing_session.attach_discount_to_customer(
monthly_discounted_price=0,
annual_discounted_price=0,
)
message = billing_session.process_support_view_request(support_view_request)
self.assertEqual("Required plan tier for zulip set to None.", message)
customer.refresh_from_db()
self.assertIsNone(customer.required_plan_tier)
discount_for_standard_plan = customer.get_discount_for_plan_tier(valid_plan_tier)
self.assertEqual(discount_for_standard_plan, customer.default_discount)
discount_for_plus_plan = customer.get_discount_for_plan_tier(CustomerPlan.TIER_CLOUD_PLUS)
self.assertEqual(discount_for_plus_plan, customer.default_discount)
discount_for_standard_plan = customer.get_discounted_price_for_plan(
valid_plan_tier, CustomerPlan.BILLING_SCHEDULE_MONTHLY
)
self.assertEqual(discount_for_standard_plan, None)
discount_for_plus_plan = customer.get_discounted_price_for_plan(
CustomerPlan.TIER_CLOUD_PLUS, CustomerPlan.BILLING_SCHEDULE_MONTHLY
)
self.assertEqual(discount_for_plus_plan, None)
realm_audit_log = RealmAuditLog.objects.filter(
event_type=RealmAuditLog.CUSTOMER_PROPERTY_CHANGED
).last()

View File

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

View File

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

View File

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

View File

@ -176,14 +176,13 @@
{{ 'user' if seat_count == 1 else 'users' }}
</span> x
<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 %}
<br/>
<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>
{% else %}
<br/>
<i class="billing-page-discount hide">Includes: <span class="billing-page-selected-schedule-discount"></span>% discount</i>
{% endif %}
<h1>$<span class="due-today-price"></span></h1>
{% 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. -->
{% else %}
{% 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 %}
{% if plan_data.is_legacy_plan %}
<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>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 %}
<b>Discount</b>: {{ format_discount(plan_data.next_plan.discount) }}%<br />
<b>Discount</b>: {{ plan_data.next_plan.discount }}%<br />
{% endif %}
<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 />

View File

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

View File

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

View File

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

View File

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

View File

@ -15,18 +15,31 @@
<button type="submit" class="support-submit-button">Update</button>
</form>
<form method="POST" class="remote-form">
<b>Discount percentage</b>:<br />
<i>Needs required plan tier to be set.</i><br />
<i>Updates will change pre-existing plans and scheduled upgrades.</i><br />
<i>Any prorated licenses for the current billing cycle will be billed at the updated discounted rate.</i><br />
<form method="POST" class="remote-form discounted-price-form">
<b>Discounted price <i class="fa fa-question-circle-o" data-tippy-content="
Needs required plan tier to be set.<br />
Default price for tier will be used if discounted price for the schedule is not specified or is 0.<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 }}
<input type="hidden" name="{{ remote_type }}" value="{{ remote_id }}" />
{% 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>
{% 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 %}
required
{% else %}
@ -37,7 +50,7 @@
{% endif %}
</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">
<b>Minimum licenses</b>:<br />
{{ csrf_input }}

View File

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

View File

@ -732,6 +732,9 @@ html_rules: List["Rule"] = [
{
"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.",
"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(),
setup_payment_by_invoice: z.boolean(),
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", [

View File

@ -71,6 +71,16 @@ function update_due_today(schedule: string): void {
const unit_price = prices[schedule_typed] / num_months;
$("#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 {

View File

@ -46,4 +46,58 @@ $(() => {
});
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 {
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",
"sorttable",
"./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-redirect": ["./src/bundles/portico", "./src/portico/desktop-redirect"],

View File

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