mirror of https://github.com/zulip/zulip.git
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:
parent
ed5e0fa141
commit
7203661d99
|
@ -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
|
||||||
|
|
|
@ -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.
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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),
|
||||||
|
),
|
||||||
|
]
|
|
@ -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
|
||||||
|
|
|
@ -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)
|
||||||
|
|
||||||
|
|
|
@ -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()
|
||||||
|
|
|
@ -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 & Co.</h3>",
|
"Lear & 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,
|
||||||
)
|
)
|
||||||
|
|
|
@ -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"] = ""
|
||||||
|
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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 %}
|
||||||
|
|
|
@ -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 />
|
||||||
|
|
|
@ -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 />
|
||||||
|
|
|
@ -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 %}
|
||||||
|
|
|
@ -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" %}
|
||||||
|
|
|
@ -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" %}
|
||||||
|
|
|
@ -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 %}
|
||||||
|
|
|
@ -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 }}
|
||||||
|
|
|
@ -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 %}
|
||||||
|
|
|
@ -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",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
|
@ -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", [
|
||||||
|
|
|
@ -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 {
|
||||||
|
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -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"],
|
||||||
|
|
|
@ -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)
|
||||||
|
|
Loading…
Reference in New Issue