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

$

{% if page_params.free_trial_days and not manual_license_management %} diff --git a/templates/corporate/support/current_plan_details.html b/templates/corporate/support/current_plan_details.html index c72c853848..0ee308eb1e 100644 --- a/templates/corporate/support/current_plan_details.html +++ b/templates/corporate/support/current_plan_details.html @@ -11,7 +11,7 @@ {% else %} {% if plan_data.current_plan.discount %} - Discount: {{ format_discount(plan_data.current_plan.discount) }}%
+ Discount: {{ plan_data.current_plan.discount }}%
{% endif %} {% if plan_data.is_legacy_plan %} End date: {{ plan_data.current_plan.end_date.strftime('%d %B %Y') }}
diff --git a/templates/corporate/support/next_plan_details.html b/templates/corporate/support/next_plan_details.html index ebeff58fc2..8df4259c77 100644 --- a/templates/corporate/support/next_plan_details.html +++ b/templates/corporate/support/next_plan_details.html @@ -6,7 +6,7 @@ Start date: {{ plan_data.next_plan.billing_cycle_anchor.strftime('%d %B %Y') }}
Billing schedule: {% if plan_data.next_plan.billing_schedule == plan_data.next_plan.BILLING_SCHEDULE_ANNUAL %}Annual{% else %}Monthly{% endif %}
{% if plan_data.next_plan.discount %} - Discount: {{ format_discount(plan_data.next_plan.discount) }}%
+ Discount: {{ plan_data.next_plan.discount }}%
{% endif %} Price per license: ${{ dollar_amount(plan_data.next_plan.price_per_license) }}
Estimated billed licenses: {{ plan_data.current_plan.licenses_at_next_renewal() }}
diff --git a/templates/corporate/support/realm_details.html b/templates/corporate/support/realm_details.html index 56b92d0f9a..80114d7b1f 100644 --- a/templates/corporate/support/realm_details.html +++ b/templates/corporate/support/realm_details.html @@ -122,7 +122,6 @@
{% with %} {% set plan_data = realm_support_data[realm.id].plan_data %} - {% set format_discount = format_discount %} {% set dollar_amount = dollar_amount %} {% include 'corporate/support/current_plan_details.html' %} {% endwith %} diff --git a/templates/corporate/support/remote_realm_details.html b/templates/corporate/support/remote_realm_details.html index e396ceaa6c..a1618d2508 100644 --- a/templates/corporate/support/remote_realm_details.html +++ b/templates/corporate/support/remote_realm_details.html @@ -48,7 +48,6 @@
{% with %} {% set sponsorship_data = support_data[remote_realm.id].sponsorship_data %} - {% set format_discount = format_discount %} {% include 'corporate/support/sponsorship_details.html' %} {% endwith %}
@@ -58,7 +57,6 @@ {% set sponsorship_data = support_data[remote_realm.id].sponsorship_data %} {% set remote_id = remote_realm.id %} {% set remote_type = "remote_realm_id" %} - {% set format_discount = format_discount %} {% set has_fixed_price = support_data[remote_realm.id].plan_data.has_fixed_price %} {% include 'corporate/support/sponsorship_forms_support.html' %} {% endwith %} @@ -69,7 +67,6 @@
{% with %} {% set plan_data = support_data[remote_realm.id].plan_data %} - {% set format_discount = format_discount %} {% set dollar_amount = dollar_amount %} {% include 'corporate/support/current_plan_details.html' %} {% endwith %} @@ -87,7 +84,6 @@
{% with %} {% set plan_data = support_data[remote_realm.id].plan_data %} - {% set format_discount = format_discount %} {% set dollar_amount = dollar_amount %} {% set remote_id = remote_realm.id %} {% set remote_type = "remote_realm_id" %} diff --git a/templates/corporate/support/remote_server_support.html b/templates/corporate/support/remote_server_support.html index f1d347c2ed..488ac457d7 100644 --- a/templates/corporate/support/remote_server_support.html +++ b/templates/corporate/support/remote_server_support.html @@ -94,7 +94,6 @@
{% with %} {% set sponsorship_data = remote_servers_support_data[remote_server.id].sponsorship_data %} - {% set format_discount = format_discount %} {% include 'corporate/support/sponsorship_details.html' %} {% endwith %}
@@ -104,7 +103,6 @@ {% set sponsorship_data = remote_servers_support_data[remote_server.id].sponsorship_data %} {% set remote_id = remote_server.id %} {% set remote_type = "remote_server_id" %} - {% set format_discount = format_discount %} {% set has_fixed_price = remote_servers_support_data[remote_server.id].plan_data.has_fixed_price %} {% include 'corporate/support/sponsorship_forms_support.html' %} {% endwith %} @@ -115,7 +113,6 @@
{% with %} {% set plan_data = remote_servers_support_data[remote_server.id].plan_data %} - {% set format_discount = format_discount %} {% set dollar_amount = dollar_amount %} {% include 'corporate/support/current_plan_details.html' %} {% endwith %} @@ -133,7 +130,6 @@
{% with %} {% set plan_data = remote_servers_support_data[remote_server.id].plan_data %} - {% set format_discount = format_discount %} {% set dollar_amount = dollar_amount %} {% set remote_id = remote_server.id %} {% set remote_type = "remote_server_id" %} @@ -180,7 +176,6 @@ {% set remote_server_deactivated = remote_server.deactivated %} {% set support_data = remote_realms_support_data %} {% set get_plan_type_name = get_plan_type_name %} - {% set format_discount = format_discount %} {% set format_optional_datetime = format_optional_datetime %} {% set dollar_amount = dollar_amount %} {% include "corporate/support/remote_realm_details.html" %} diff --git a/templates/corporate/support/sponsorship_details.html b/templates/corporate/support/sponsorship_details.html index 3f39465d66..3f442893a6 100644 --- a/templates/corporate/support/sponsorship_details.html +++ b/templates/corporate/support/sponsorship_details.html @@ -2,7 +2,7 @@

💸 Discount and sponsorship information:

Sponsorship pending: {{ sponsorship_data.sponsorship_pending }}
{% if sponsorship_data.default_discount %} - Discount: {{ format_discount(sponsorship_data.default_discount) }}%
+ Discount: {{ sponsorship_data.default_discount }}%
{% else %} Discount: None
{% endif %} diff --git a/templates/corporate/support/sponsorship_discount_forms.html b/templates/corporate/support/sponsorship_discount_forms.html index e7e6150ba1..65e4e00e5f 100644 --- a/templates/corporate/support/sponsorship_discount_forms.html +++ b/templates/corporate/support/sponsorship_discount_forms.html @@ -15,18 +15,31 @@ -
- Discount percentage:
- Needs required plan tier to be set.
- Updates will change pre-existing plans and scheduled upgrades.
- Any prorated licenses for the current billing cycle will be billed at the updated discounted rate.
+ + Discounted price {{ csrf_input }} {% if has_fixed_price %} - + + {% else %} - Monthly (cents) + + Annual (cents) + -{% 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) %} Minimum licenses:
{{ csrf_input }} diff --git a/templates/corporate/support/support.html b/templates/corporate/support/support.html index 4a176c90c7..5af60af3f7 100644 --- a/templates/corporate/support/support.html +++ b/templates/corporate/support/support.html @@ -50,7 +50,6 @@
{% with %} - {% set format_discount = format_discount %} {% set dollar_amount = dollar_amount %} {% include "corporate/support/realm_details.html" %} {% endwith %} @@ -61,7 +60,6 @@ {% for realm in realms %}
{% with %} - {% set format_discount = format_discount %} {% set dollar_amount = dollar_amount %} {% include "corporate/support/realm_details.html" %} {% endwith %} @@ -111,7 +109,6 @@
{% if show_realm_details %} {% with %} - {% set format_discount = format_discount %} {% set dollar_amount = dollar_amount %} {% include "corporate/support/realm_details.html" %} {% endwith %} diff --git a/tools/linter_lib/custom_check.py b/tools/linter_lib/custom_check.py index b10f5adc9c..fa29914feb 100644 --- a/tools/linter_lib/custom_check.py +++ b/tools/linter_lib/custom_check.py @@ -732,6 +732,9 @@ html_rules: List["Rule"] = [ { "pattern": r"(?i:data-tippy-allowHTML)", "description": "Never use data-tippy-allowHTML; for an HTML tooltip, set data-tooltip-template-id to the id of a