Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

support: Set discounted price instead percentage for customers. #29957

Merged
merged 1 commit into from
May 16, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
35 changes: 12 additions & 23 deletions corporate/lib/activity.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
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
Expand All @@ -20,10 +20,9 @@
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,
Expand Down Expand Up @@ -160,11 +159,12 @@ def remote_installation_support_link(hostname: str) -> Markup:
return Markup('<a href="{url}"><i class="fa fa-gear"></i></a>').format(url=url)


def get_plan_rate_percentage(discount: Optional[Decimal]) -> str:
if discount is None or discount == Decimal(0):
def get_plan_rate_percentage(discount: Optional[str]) -> str:
# CustomerPlan.discount is a string field that stores the discount.
if discount is None or discount == "0":
return "100%"

rate = 100 - discount
rate = 100 - Decimal(discount)
if rate * 100 % 100 == 0:
precision = 0
else:
Expand Down Expand Up @@ -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`.
Copy link
Sponsor Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This would be basically a stale discount percent display value? I guess we could do a migration to clear those if it was a priority.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No, we don't want to do anything here. I just wanted to note that even if there is no discount displayed for the customer in the support table, that doesn't mean there is no discount applied to them. I am not sure where to best reflect that.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Okay, I guess that won't be valid after #29957 (comment)

annual_revenue = {}
plan_rate = {}
plans = (
CustomerPlan.objects.filter(
status=CustomerPlan.ACTIVE,
Expand All @@ -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
Expand Down
202 changes: 117 additions & 85 deletions corporate/lib/stripe.py

Large diffs are not rendered by default.

28 changes: 24 additions & 4 deletions corporate/lib/support.py
Original file line number Diff line number Diff line change
@@ -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

Expand All @@ -16,6 +15,7 @@
RemoteRealmBillingSession,
RemoteServerBillingSession,
get_configured_fixed_price_plan_offer,
get_price_per_license,
get_push_status_for_remote_request,
start_of_next_billing_cycle,
)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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()
Expand All @@ -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,
Expand Down
Original file line number Diff line number Diff line change
@@ -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
amanagr marked this conversation as resolved.
Show resolved Hide resolved


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)
amanagr marked this conversation as resolved.
Show resolved Hide resolved
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"]
)
amanagr marked this conversation as resolved.
Show resolved Hide resolved


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),
),
]
32 changes: 22 additions & 10 deletions corporate/models.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
from decimal import Decimal
from enum import Enum
from typing import Any, Dict, Optional, Union

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -64,10 +69,15 @@ def __str__(self) -> str:
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]:
Expand Down Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion corporate/tests/test_activity_views.py
Original file line number Diff line number Diff line change
Expand Up @@ -141,7 +141,7 @@ def test_activity(self, unused_mock: mock.Mock) -> None:
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)

Expand Down