mirror of
https://github.com/zulip/zulip.git
synced 2025-10-23 04:52:12 +00:00
Renames invoice_overdue_email_sent to stale_audit_log_data_email_sent to better reflect the email and state that field is tracking for the CustomerPlan.
266 lines
9.8 KiB
Python
266 lines
9.8 KiB
Python
from django.db import models
|
|
from django.db.models import CASCADE
|
|
from typing_extensions import override
|
|
|
|
from corporate.models.customers import Customer, get_customer_by_realm
|
|
from zerver.models import Realm
|
|
|
|
|
|
class AbstractCustomerPlan(models.Model):
|
|
# A customer can only have one ACTIVE / CONFIGURED plan,
|
|
# but old, inactive / processed plans are preserved to allow
|
|
# auditing - so there can be multiple CustomerPlan / CustomerPlanOffer
|
|
# objects pointing to one Customer.
|
|
customer = models.ForeignKey(Customer, on_delete=CASCADE)
|
|
|
|
fixed_price = models.IntegerField(null=True)
|
|
|
|
class Meta:
|
|
abstract = True
|
|
|
|
|
|
class CustomerPlanOffer(AbstractCustomerPlan):
|
|
"""
|
|
This is for storing offers configured via /support which
|
|
the customer is yet to buy or schedule a purchase.
|
|
|
|
Once customer buys or schedules a purchase, we create a
|
|
CustomerPlan record. The record in this table stays for
|
|
audit purpose with status=PROCESSED.
|
|
"""
|
|
|
|
TIER_CLOUD_STANDARD = 1
|
|
TIER_CLOUD_PLUS = 2
|
|
TIER_SELF_HOSTED_BASIC = 103
|
|
TIER_SELF_HOSTED_BUSINESS = 104
|
|
tier = models.SmallIntegerField()
|
|
|
|
# Whether the offer is:
|
|
# * only configured
|
|
# * processed by the customer to buy or schedule a purchase.
|
|
CONFIGURED = 1
|
|
PROCESSED = 2
|
|
status = models.SmallIntegerField()
|
|
|
|
# ID of invoice sent when chose to 'Pay by invoice'.
|
|
sent_invoice_id = models.CharField(max_length=255, null=True)
|
|
|
|
@override
|
|
def __str__(self) -> str:
|
|
return f"{self.name} (status: {self.get_plan_status_as_text()})"
|
|
|
|
def get_plan_status_as_text(self) -> str:
|
|
return {
|
|
self.CONFIGURED: "Configured",
|
|
self.PROCESSED: "Processed",
|
|
}[self.status]
|
|
|
|
@staticmethod
|
|
def name_from_tier(tier: int) -> str:
|
|
return {
|
|
CustomerPlanOffer.TIER_CLOUD_STANDARD: "Zulip Cloud Standard",
|
|
CustomerPlanOffer.TIER_CLOUD_PLUS: "Zulip Cloud Plus",
|
|
CustomerPlanOffer.TIER_SELF_HOSTED_BASIC: "Zulip Basic",
|
|
CustomerPlanOffer.TIER_SELF_HOSTED_BUSINESS: "Zulip Business",
|
|
}[tier]
|
|
|
|
@property
|
|
def name(self) -> str:
|
|
return self.name_from_tier(self.tier)
|
|
|
|
|
|
class CustomerPlan(AbstractCustomerPlan):
|
|
"""
|
|
This is for storing most of the fiddly details
|
|
of the customer's plan.
|
|
"""
|
|
|
|
automanage_licenses = models.BooleanField(default=False)
|
|
charge_automatically = models.BooleanField(default=False)
|
|
|
|
# Both of the price_per_license and fixed_price are in cents. Exactly
|
|
# one of them should be set. fixed_price is only for manual deals, and
|
|
# can't be set via the self-serve billing system.
|
|
price_per_license = models.IntegerField(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
|
|
# should never be modified. The only exception is when we change
|
|
# the status of the plan from free trial to active and reset the
|
|
# billing_cycle_anchor.
|
|
billing_cycle_anchor = models.DateTimeField()
|
|
|
|
BILLING_SCHEDULE_ANNUAL = 1
|
|
BILLING_SCHEDULE_MONTHLY = 2
|
|
BILLING_SCHEDULES = {
|
|
BILLING_SCHEDULE_ANNUAL: "Annual",
|
|
BILLING_SCHEDULE_MONTHLY: "Monthly",
|
|
}
|
|
billing_schedule = models.SmallIntegerField()
|
|
|
|
# The next date the billing system should go through ledger
|
|
# entries and create invoices for additional users or plan
|
|
# renewal. Since we use a daily cron job for invoicing, the
|
|
# invoice will be generated the first time the cron job runs after
|
|
# next_invoice_date.
|
|
next_invoice_date = models.DateTimeField(db_index=True, null=True)
|
|
|
|
# Flag to track if an email has been sent to Zulip team for delay
|
|
# of invoicing by >= one day. Helps to send an email only once
|
|
# and not every time when cron run.
|
|
stale_audit_log_data_email_sent = models.BooleanField(default=False)
|
|
|
|
# Flag to track if an email has been sent to Zulip team to
|
|
# review the pricing, 60 days before the end date. Helps to send
|
|
# an email only once and not every time when cron run.
|
|
reminder_to_review_plan_email_sent = models.BooleanField(default=False)
|
|
|
|
# On next_invoice_date, we call invoice_plan, which goes through
|
|
# ledger entries that were created after invoiced_through and
|
|
# process them. An invoice will be generated for any additional
|
|
# users and/or plan renewal (if it's the end of the billing cycle).
|
|
# Once all new ledger entries have been processed, invoiced_through
|
|
# will be have been set to the last ledger entry we checked.
|
|
invoiced_through = models.ForeignKey(
|
|
"LicenseLedger", null=True, on_delete=CASCADE, related_name="+"
|
|
)
|
|
end_date = models.DateTimeField(null=True)
|
|
|
|
INVOICING_STATUS_DONE = 1
|
|
INVOICING_STATUS_STARTED = 2
|
|
INVOICING_STATUS_INITIAL_INVOICE_TO_BE_SENT = 3
|
|
# This status field helps ensure any errors encountered during the
|
|
# invoicing process do not leave our invoicing system in a broken
|
|
# state.
|
|
invoicing_status = models.SmallIntegerField(default=INVOICING_STATUS_DONE)
|
|
|
|
TIER_CLOUD_STANDARD = 1
|
|
TIER_CLOUD_PLUS = 2
|
|
# Reserved tier IDs for future use
|
|
TIER_CLOUD_COMMUNITY = 3
|
|
TIER_CLOUD_ENTERPRISE = 4
|
|
|
|
TIER_SELF_HOSTED_BASE = 100
|
|
TIER_SELF_HOSTED_LEGACY = 101
|
|
TIER_SELF_HOSTED_COMMUNITY = 102
|
|
TIER_SELF_HOSTED_BASIC = 103
|
|
TIER_SELF_HOSTED_BUSINESS = 104
|
|
TIER_SELF_HOSTED_ENTERPRISE = 105
|
|
tier = models.SmallIntegerField()
|
|
|
|
PAID_PLAN_TIERS = [
|
|
TIER_CLOUD_STANDARD,
|
|
TIER_CLOUD_PLUS,
|
|
TIER_SELF_HOSTED_BASIC,
|
|
TIER_SELF_HOSTED_BUSINESS,
|
|
TIER_SELF_HOSTED_ENTERPRISE,
|
|
]
|
|
|
|
COMPLIMENTARY_PLAN_TIERS = [TIER_SELF_HOSTED_LEGACY]
|
|
|
|
ACTIVE = 1
|
|
DOWNGRADE_AT_END_OF_CYCLE = 2
|
|
FREE_TRIAL = 3
|
|
SWITCH_TO_ANNUAL_AT_END_OF_CYCLE = 4
|
|
SWITCH_PLAN_TIER_NOW = 5
|
|
SWITCH_TO_MONTHLY_AT_END_OF_CYCLE = 6
|
|
DOWNGRADE_AT_END_OF_FREE_TRIAL = 7
|
|
SWITCH_PLAN_TIER_AT_PLAN_END = 8
|
|
# "Live" plans should have a value < LIVE_STATUS_THRESHOLD.
|
|
# There should be at most one live plan per customer.
|
|
LIVE_STATUS_THRESHOLD = 10
|
|
ENDED = 11
|
|
NEVER_STARTED = 12
|
|
status = models.SmallIntegerField(default=ACTIVE)
|
|
|
|
# Currently, all the fixed-price plans are configured for one year.
|
|
# In future, we might change this to a field.
|
|
FIXED_PRICE_PLAN_DURATION_MONTHS = 12
|
|
|
|
# TODO maybe override setattr to ensure billing_cycle_anchor, etc
|
|
# are immutable.
|
|
|
|
@override
|
|
def __str__(self) -> str:
|
|
return f"{self.name} (status: {self.get_plan_status_as_text()})"
|
|
|
|
@staticmethod
|
|
def name_from_tier(tier: int) -> str:
|
|
# NOTE: Check `statement_descriptor` values after updating this.
|
|
# Stripe has a 22 character limit on the statement descriptor length.
|
|
# https://stripe.com/docs/payments/account/statement-descriptors
|
|
return {
|
|
CustomerPlan.TIER_CLOUD_STANDARD: "Zulip Cloud Standard",
|
|
CustomerPlan.TIER_CLOUD_PLUS: "Zulip Cloud Plus",
|
|
CustomerPlan.TIER_CLOUD_ENTERPRISE: "Zulip Enterprise",
|
|
CustomerPlan.TIER_SELF_HOSTED_BASIC: "Zulip Basic",
|
|
CustomerPlan.TIER_SELF_HOSTED_BUSINESS: "Zulip Business",
|
|
CustomerPlan.TIER_SELF_HOSTED_COMMUNITY: "Community",
|
|
# Complimentary access plans should never be billed through Stripe,
|
|
# so the tier name can exceed the 22 character limit noted above.
|
|
CustomerPlan.TIER_SELF_HOSTED_LEGACY: "Zulip Basic (complimentary)",
|
|
}[tier]
|
|
|
|
@property
|
|
def name(self) -> str:
|
|
return self.name_from_tier(self.tier)
|
|
|
|
def get_plan_status_as_text(self) -> str:
|
|
return {
|
|
self.ACTIVE: "Active",
|
|
self.DOWNGRADE_AT_END_OF_CYCLE: "Downgrade end of cycle",
|
|
self.FREE_TRIAL: "Free trial",
|
|
self.SWITCH_TO_ANNUAL_AT_END_OF_CYCLE: "Scheduled switch to annual",
|
|
self.SWITCH_TO_MONTHLY_AT_END_OF_CYCLE: "Scheduled switch to monthly",
|
|
self.DOWNGRADE_AT_END_OF_FREE_TRIAL: "Downgrade end of free trial",
|
|
self.SWITCH_PLAN_TIER_AT_PLAN_END: "New plan scheduled",
|
|
self.ENDED: "Ended",
|
|
self.NEVER_STARTED: "Never started",
|
|
}[self.status]
|
|
|
|
def licenses(self) -> int:
|
|
from corporate.models.licenses import LicenseLedger
|
|
|
|
ledger_entry = LicenseLedger.objects.filter(plan=self).order_by("id").last()
|
|
assert ledger_entry is not None
|
|
return ledger_entry.licenses
|
|
|
|
def licenses_at_next_renewal(self) -> int | None:
|
|
from corporate.models.licenses import LicenseLedger
|
|
|
|
if self.status in (
|
|
CustomerPlan.DOWNGRADE_AT_END_OF_CYCLE,
|
|
CustomerPlan.DOWNGRADE_AT_END_OF_FREE_TRIAL,
|
|
):
|
|
return None
|
|
ledger_entry = LicenseLedger.objects.filter(plan=self).order_by("id").last()
|
|
assert ledger_entry is not None
|
|
return ledger_entry.licenses_at_next_renewal
|
|
|
|
def is_free_trial(self) -> bool:
|
|
return self.status == CustomerPlan.FREE_TRIAL
|
|
|
|
def is_complimentary_access_plan(self) -> bool:
|
|
return self.tier in self.COMPLIMENTARY_PLAN_TIERS
|
|
|
|
def is_a_paid_plan(self) -> bool:
|
|
return self.tier in self.PAID_PLAN_TIERS
|
|
|
|
|
|
def get_current_plan_by_customer(customer: Customer) -> CustomerPlan | None:
|
|
return CustomerPlan.objects.filter(
|
|
customer=customer, status__lt=CustomerPlan.LIVE_STATUS_THRESHOLD
|
|
).first()
|
|
|
|
|
|
def get_current_plan_by_realm(realm: Realm) -> CustomerPlan | None:
|
|
customer = get_customer_by_realm(realm)
|
|
if customer is None:
|
|
return None
|
|
return get_current_plan_by_customer(customer)
|