snapshot of https://github.com/zulip/zulip.git @ 48c69911c7dd4e03b20552eb3bd77ad8151c1357 (2026-09-15)
Some checks failed
Code scanning / CodeQL (push) Failing after 15s
Zulip production suite / Ubuntu 22.04 production build (push) Failing after 26s
Zulip production suite / Debian 12 production install with custom db name and user (push) Has been skipped
Zulip production suite / Ubuntu 22.04 production install and PostgreSQL upgrade with pgroonga (push) Has been skipped
Zulip production suite / Ubuntu 24.04 production install (push) Has been skipped
Zulip production suite / Ubuntu 26.04 production install (push) Has been skipped
Zulip production suite / Debian 13 production install (push) Has been skipped
Zulip production suite / 7.0 Version Upgrade (push) Has been skipped
Zulip production suite / 8.0 Version Upgrade (push) Has been skipped
Zulip production suite / 6.0 Version Upgrade (push) Has been skipped
Zulip production suite / 10.0 Version Upgrade (push) Has been skipped
Zulip production suite / 9.0 Version Upgrade (push) Has been skipped
Zulip production suite / 12.0 Version Upgrade (push) Has been skipped
Zulip production suite / 11.0 Version Upgrade (push) Has been skipped
GitHub Actions Security Analysis with zizmor / zizmor (push) Failing after 3s
Zulip CI / Debian 12 (Python 3.11, backend + documentation) (push) Failing after 26s
Zulip CI / Ubuntu 22.04 (Python 3.10, backend + frontend) (push) Failing after 3s
Zulip CI / Ubuntu 24.04 (Python 3.12, backend) (push) Failing after 25s
Zulip CI / Ubuntu 26.04 (Python 3.14, backend) (push) Failing after 25s
Zulip CI / Debian 13 (Python 3.13, backend) (push) Failing after 28s
Zulip production suite / Required jobs (push) Has been cancelled
Zulip CI / Required jobs (push) Has been cancelled
API Documentation Update Check / check-feature-level-updated (push) Has been cancelled
API Documentation Update Check / notify-if-api-docs-changed (push) Has been cancelled
Some checks failed
Code scanning / CodeQL (push) Failing after 15s
Zulip production suite / Ubuntu 22.04 production build (push) Failing after 26s
Zulip production suite / Debian 12 production install with custom db name and user (push) Has been skipped
Zulip production suite / Ubuntu 22.04 production install and PostgreSQL upgrade with pgroonga (push) Has been skipped
Zulip production suite / Ubuntu 24.04 production install (push) Has been skipped
Zulip production suite / Ubuntu 26.04 production install (push) Has been skipped
Zulip production suite / Debian 13 production install (push) Has been skipped
Zulip production suite / 7.0 Version Upgrade (push) Has been skipped
Zulip production suite / 8.0 Version Upgrade (push) Has been skipped
Zulip production suite / 6.0 Version Upgrade (push) Has been skipped
Zulip production suite / 10.0 Version Upgrade (push) Has been skipped
Zulip production suite / 9.0 Version Upgrade (push) Has been skipped
Zulip production suite / 12.0 Version Upgrade (push) Has been skipped
Zulip production suite / 11.0 Version Upgrade (push) Has been skipped
GitHub Actions Security Analysis with zizmor / zizmor (push) Failing after 3s
Zulip CI / Debian 12 (Python 3.11, backend + documentation) (push) Failing after 26s
Zulip CI / Ubuntu 22.04 (Python 3.10, backend + frontend) (push) Failing after 3s
Zulip CI / Ubuntu 24.04 (Python 3.12, backend) (push) Failing after 25s
Zulip CI / Ubuntu 26.04 (Python 3.14, backend) (push) Failing after 25s
Zulip CI / Debian 13 (Python 3.13, backend) (push) Failing after 28s
Zulip production suite / Required jobs (push) Has been cancelled
Zulip CI / Required jobs (push) Has been cancelled
API Documentation Update Check / check-feature-level-updated (push) Has been cancelled
API Documentation Update Check / notify-if-api-docs-changed (push) Has been cancelled
This commit is contained in:
11
corporate/models/__init__.py
Normal file
11
corporate/models/__init__.py
Normal file
@@ -0,0 +1,11 @@
|
||||
from corporate.models.customers import Customer as Customer
|
||||
from corporate.models.licenses import LicenseLedger as LicenseLedger
|
||||
from corporate.models.plans import AbstractCustomerPlan as AbstractCustomerPlan
|
||||
from corporate.models.plans import CustomerPlan as CustomerPlan
|
||||
from corporate.models.plans import CustomerPlanOffer as CustomerPlanOffer
|
||||
from corporate.models.sponsorships import SponsoredPlanTypes as SponsoredPlanTypes
|
||||
from corporate.models.sponsorships import ZulipSponsorshipRequest as ZulipSponsorshipRequest
|
||||
from corporate.models.stripe_state import Event as Event
|
||||
from corporate.models.stripe_state import Invoice as Invoice
|
||||
from corporate.models.stripe_state import PaymentIntent as PaymentIntent
|
||||
from corporate.models.stripe_state import Session as Session
|
||||
89
corporate/models/customers.py
Normal file
89
corporate/models/customers.py
Normal file
@@ -0,0 +1,89 @@
|
||||
from django.db import models
|
||||
from django.db.models import CASCADE, Q
|
||||
from typing_extensions import override
|
||||
|
||||
from zerver.models import Realm
|
||||
from zilencer.models import RemoteRealm, RemoteZulipServer
|
||||
|
||||
|
||||
class Customer(models.Model):
|
||||
"""
|
||||
This model primarily serves to connect a Realm with
|
||||
the corresponding Stripe customer object for payment purposes
|
||||
and the active plan, if any.
|
||||
"""
|
||||
|
||||
# The actual model object that this customer is associated
|
||||
# with. Exactly one of the following will be non-null.
|
||||
realm = models.OneToOneField(Realm, on_delete=CASCADE, null=True)
|
||||
remote_realm = models.OneToOneField(RemoteRealm, on_delete=CASCADE, null=True)
|
||||
remote_server = models.OneToOneField(RemoteZulipServer, on_delete=CASCADE, null=True)
|
||||
|
||||
stripe_customer_id = models.CharField(max_length=255, null=True, unique=True)
|
||||
sponsorship_pending = models.BooleanField(default=False)
|
||||
|
||||
# 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 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
|
||||
# only for their paid employees. We don't prevent these
|
||||
# organizations from adding more users than the number of licenses
|
||||
# they purchased.
|
||||
exempt_from_license_number_check = models.BooleanField(default=False)
|
||||
|
||||
# In cents.
|
||||
flat_discount = models.IntegerField(default=2000)
|
||||
# Number of months left in the flat discount period.
|
||||
flat_discounted_months = models.IntegerField(default=0)
|
||||
|
||||
class Meta:
|
||||
# Enforce that at least one of these is set.
|
||||
constraints = [
|
||||
models.CheckConstraint(
|
||||
condition=Q(realm__isnull=False)
|
||||
| Q(remote_server__isnull=False)
|
||||
| Q(remote_realm__isnull=False),
|
||||
name="has_associated_model_object",
|
||||
)
|
||||
]
|
||||
|
||||
@override
|
||||
def __str__(self) -> str:
|
||||
if self.realm is not None:
|
||||
return f"{self.realm!r} (stripe: {self.stripe_customer_id})"
|
||||
elif self.remote_realm is not None:
|
||||
return f"{self.remote_realm!r} (stripe: {self.stripe_customer_id})"
|
||||
else:
|
||||
return f"{self.remote_server!r} (stripe: {self.stripe_customer_id})"
|
||||
|
||||
def get_discounted_price_for_plan(self, plan_tier: int, schedule: int) -> int | None:
|
||||
from corporate.models.plans import CustomerPlan
|
||||
|
||||
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) -> Customer | None:
|
||||
return Customer.objects.filter(realm=realm).first()
|
||||
|
||||
|
||||
def get_customer_by_remote_server(remote_server: RemoteZulipServer) -> Customer | None:
|
||||
return Customer.objects.filter(remote_server=remote_server).first()
|
||||
|
||||
|
||||
def get_customer_by_remote_realm(remote_realm: RemoteRealm) -> Customer | None: # nocoverage
|
||||
return Customer.objects.filter(remote_realm=remote_realm).first()
|
||||
45
corporate/models/licenses.py
Normal file
45
corporate/models/licenses.py
Normal file
@@ -0,0 +1,45 @@
|
||||
from django.db import models
|
||||
from django.db.models import CASCADE
|
||||
from typing_extensions import override
|
||||
|
||||
from corporate.models.plans import CustomerPlan
|
||||
|
||||
|
||||
class LicenseLedger(models.Model):
|
||||
"""
|
||||
This table's purpose is to store the current, and historical,
|
||||
count of "seats" purchased by the organization.
|
||||
|
||||
Because we want to keep historical data, when the purchased
|
||||
seat count changes, a new LicenseLedger object is created,
|
||||
instead of updating the old one. This lets us preserve
|
||||
the entire history of how the seat count changes, which is
|
||||
important for analytics as well as auditing and debugging
|
||||
in case of issues.
|
||||
"""
|
||||
|
||||
plan = models.ForeignKey(CustomerPlan, on_delete=CASCADE)
|
||||
|
||||
# Also True for the initial upgrade.
|
||||
is_renewal = models.BooleanField(default=False)
|
||||
|
||||
event_time = models.DateTimeField()
|
||||
|
||||
# The number of licenses ("seats") purchased by the organization at the time of ledger
|
||||
# entry creation. Normally, to add a user the organization needs at least one spare license.
|
||||
# Once a license is purchased, it is valid till the end of the billing period, irrespective
|
||||
# of whether the license is used or not. So the value of licenses will never decrease for
|
||||
# subsequent LicenseLedger entries in the same billing period.
|
||||
licenses = models.IntegerField()
|
||||
|
||||
# The number of licenses the organization needs in the next billing cycle. The value of
|
||||
# licenses_at_next_renewal can increase or decrease for subsequent LicenseLedger entries in
|
||||
# the same billing period. For plans on automatic license management this value is usually
|
||||
# equal to the number of activated users in the organization.
|
||||
licenses_at_next_renewal = models.IntegerField(null=True)
|
||||
|
||||
@override
|
||||
def __str__(self) -> str:
|
||||
ledger_type = "renewal" if self.is_renewal else "update"
|
||||
ledger_time = self.event_time.replace(tzinfo=None).isoformat(" ", "minutes")
|
||||
return f"License {ledger_type}, {self.licenses} purchased, {self.licenses_at_next_renewal} next cycle, {ledger_time} (id={self.id})"
|
||||
285
corporate/models/plans.py
Normal file
285
corporate/models/plans.py
Normal file
@@ -0,0 +1,285 @@
|
||||
from django.db import models
|
||||
from django.db.models import CASCADE, Q
|
||||
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)
|
||||
|
||||
class Meta:
|
||||
# Enforce that each customer has only one CONFIGURED plan offer at a time.
|
||||
constraints = [
|
||||
models.UniqueConstraint(
|
||||
fields=["customer"],
|
||||
condition=Q(status=1), # CustomerPlanOffer.CONFIGURED
|
||||
name="unique_configured_plan_offer_customer",
|
||||
)
|
||||
]
|
||||
|
||||
@override
|
||||
def __str__(self) -> str:
|
||||
return f"{self.name} for {self.customer!r} (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.
|
||||
|
||||
class Meta:
|
||||
# Enforce that each customer has only one NEVER_STARTED plan at a time.
|
||||
constraints = [
|
||||
models.UniqueConstraint(
|
||||
fields=["customer"],
|
||||
condition=Q(status=12), # CustomerPlan.NEVER_STARTED
|
||||
name="unique_never_started_plan_customer",
|
||||
)
|
||||
]
|
||||
|
||||
@override
|
||||
def __str__(self) -> str:
|
||||
return f"{self.name} for {self.customer!r} (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)
|
||||
40
corporate/models/sponsorships.py
Normal file
40
corporate/models/sponsorships.py
Normal file
@@ -0,0 +1,40 @@
|
||||
from enum import Enum
|
||||
|
||||
from django.db import models
|
||||
from django.db.models import CASCADE
|
||||
|
||||
from corporate.models.customers import Customer
|
||||
from zerver.models import Realm, UserProfile
|
||||
|
||||
|
||||
class SponsoredPlanTypes(Enum):
|
||||
# unspecified used for cloud sponsorship requests
|
||||
UNSPECIFIED = ""
|
||||
COMMUNITY = "Community"
|
||||
BASIC = "Basic"
|
||||
BUSINESS = "Business"
|
||||
|
||||
|
||||
class ZulipSponsorshipRequest(models.Model):
|
||||
customer = models.ForeignKey(Customer, on_delete=CASCADE)
|
||||
requested_by = models.ForeignKey(UserProfile, on_delete=CASCADE, null=True, blank=True)
|
||||
|
||||
org_type = models.PositiveSmallIntegerField(
|
||||
default=Realm.ORG_TYPES["unspecified"]["id"],
|
||||
choices=[(t["id"], t["name"]) for t in Realm.ORG_TYPES.values()],
|
||||
)
|
||||
|
||||
MAX_ORG_URL_LENGTH: int = 200
|
||||
org_website = models.URLField(max_length=MAX_ORG_URL_LENGTH, blank=True, null=True)
|
||||
|
||||
org_description = models.TextField(default="")
|
||||
expected_total_users = models.TextField(default="")
|
||||
plan_to_use_zulip = models.TextField(default="")
|
||||
paid_users_count = models.TextField(default="")
|
||||
paid_users_description = models.TextField(default="")
|
||||
|
||||
requested_plan = models.CharField(
|
||||
max_length=50,
|
||||
choices=[(plan.value, plan.name) for plan in SponsoredPlanTypes],
|
||||
default=SponsoredPlanTypes.UNSPECIFIED.value,
|
||||
)
|
||||
178
corporate/models/stripe_state.py
Normal file
178
corporate/models/stripe_state.py
Normal file
@@ -0,0 +1,178 @@
|
||||
from typing import Any, Union
|
||||
|
||||
from django.contrib.contenttypes.fields import GenericForeignKey
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.db import models
|
||||
from django.db.models import CASCADE, SET_NULL
|
||||
|
||||
from corporate.models.customers import Customer
|
||||
|
||||
|
||||
class Event(models.Model):
|
||||
stripe_event_id = models.CharField(max_length=255)
|
||||
|
||||
type = models.CharField(max_length=255)
|
||||
|
||||
RECEIVED = 1
|
||||
EVENT_HANDLER_STARTED = 30
|
||||
EVENT_HANDLER_FAILED = 40
|
||||
EVENT_HANDLER_SUCCEEDED = 50
|
||||
status = models.SmallIntegerField(default=RECEIVED)
|
||||
|
||||
content_type = models.ForeignKey(ContentType, on_delete=models.CASCADE)
|
||||
object_id = models.PositiveIntegerField(db_index=True)
|
||||
content_object = GenericForeignKey("content_type", "object_id")
|
||||
|
||||
handler_error = models.JSONField(default=None, null=True)
|
||||
|
||||
def get_event_handler_details_as_dict(self) -> dict[str, Any]:
|
||||
details_dict = {}
|
||||
details_dict["status"] = {
|
||||
Event.RECEIVED: "not_started",
|
||||
Event.EVENT_HANDLER_STARTED: "started",
|
||||
Event.EVENT_HANDLER_FAILED: "failed",
|
||||
Event.EVENT_HANDLER_SUCCEEDED: "succeeded",
|
||||
}[self.status]
|
||||
if self.handler_error:
|
||||
details_dict["error"] = self.handler_error
|
||||
return details_dict
|
||||
|
||||
|
||||
def get_last_associated_event_by_type(
|
||||
content_object: Union["Invoice", "PaymentIntent", "Session"], event_type: str
|
||||
) -> Event | None:
|
||||
content_type = ContentType.objects.get_for_model(type(content_object))
|
||||
return Event.objects.filter(
|
||||
content_type=content_type, object_id=content_object.id, type=event_type
|
||||
).last()
|
||||
|
||||
|
||||
class Session(models.Model):
|
||||
customer = models.ForeignKey(Customer, on_delete=CASCADE)
|
||||
stripe_session_id = models.CharField(max_length=255, unique=True)
|
||||
|
||||
CARD_UPDATE_FROM_BILLING_PAGE = 40
|
||||
CARD_UPDATE_FROM_UPGRADE_PAGE = 50
|
||||
type = models.SmallIntegerField()
|
||||
|
||||
CREATED = 1
|
||||
COMPLETED = 10
|
||||
status = models.SmallIntegerField(default=CREATED)
|
||||
|
||||
# Did the user opt to manually manage licenses before clicking on update button?
|
||||
is_manual_license_management_upgrade_session = models.BooleanField(default=False)
|
||||
|
||||
# CustomerPlan tier that the user is upgrading to.
|
||||
tier = models.SmallIntegerField(null=True)
|
||||
|
||||
def get_status_as_string(self) -> str:
|
||||
return {Session.CREATED: "created", Session.COMPLETED: "completed"}[self.status]
|
||||
|
||||
def get_type_as_string(self) -> str:
|
||||
return {
|
||||
Session.CARD_UPDATE_FROM_BILLING_PAGE: "card_update_from_billing_page",
|
||||
Session.CARD_UPDATE_FROM_UPGRADE_PAGE: "card_update_from_upgrade_page",
|
||||
}[self.type]
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
session_dict: dict[str, Any] = {}
|
||||
|
||||
session_dict["status"] = self.get_status_as_string()
|
||||
session_dict["type"] = self.get_type_as_string()
|
||||
session_dict["is_manual_license_management_upgrade_session"] = (
|
||||
self.is_manual_license_management_upgrade_session
|
||||
)
|
||||
session_dict["tier"] = self.tier
|
||||
event = self.get_last_associated_event()
|
||||
if event is not None:
|
||||
session_dict["event_handler"] = event.get_event_handler_details_as_dict()
|
||||
return session_dict
|
||||
|
||||
def get_last_associated_event(self) -> Event | None:
|
||||
if self.status == Session.CREATED:
|
||||
return None
|
||||
return get_last_associated_event_by_type(self, "checkout.session.completed")
|
||||
|
||||
|
||||
class PaymentIntent(models.Model): # nocoverage
|
||||
customer = models.ForeignKey(Customer, on_delete=CASCADE)
|
||||
stripe_payment_intent_id = models.CharField(max_length=255, unique=True)
|
||||
|
||||
REQUIRES_PAYMENT_METHOD = 1
|
||||
REQUIRES_CONFIRMATION = 20
|
||||
REQUIRES_ACTION = 30
|
||||
PROCESSING = 40
|
||||
REQUIRES_CAPTURE = 50
|
||||
CANCELLED = 60
|
||||
SUCCEEDED = 70
|
||||
|
||||
status = models.SmallIntegerField()
|
||||
last_payment_error = models.JSONField(default=None, null=True)
|
||||
|
||||
@classmethod
|
||||
def get_status_integer_from_status_text(cls, status_text: str) -> int:
|
||||
return getattr(cls, status_text.upper())
|
||||
|
||||
def get_status_as_string(self) -> str:
|
||||
return {
|
||||
PaymentIntent.REQUIRES_PAYMENT_METHOD: "requires_payment_method",
|
||||
PaymentIntent.REQUIRES_CONFIRMATION: "requires_confirmation",
|
||||
PaymentIntent.REQUIRES_ACTION: "requires_action",
|
||||
PaymentIntent.PROCESSING: "processing",
|
||||
PaymentIntent.REQUIRES_CAPTURE: "requires_capture",
|
||||
PaymentIntent.CANCELLED: "cancelled",
|
||||
PaymentIntent.SUCCEEDED: "succeeded",
|
||||
}[self.status]
|
||||
|
||||
def get_last_associated_event(self) -> Event | None:
|
||||
if self.status == PaymentIntent.SUCCEEDED:
|
||||
event_type = "payment_intent.succeeded"
|
||||
# TODO: Add test for this case. Not sure how to trigger naturally.
|
||||
else: # nocoverage
|
||||
return None # nocoverage
|
||||
return get_last_associated_event_by_type(self, event_type)
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
payment_intent_dict: dict[str, Any] = {}
|
||||
payment_intent_dict["status"] = self.get_status_as_string()
|
||||
event = self.get_last_associated_event()
|
||||
if event is not None:
|
||||
payment_intent_dict["event_handler"] = event.get_event_handler_details_as_dict()
|
||||
return payment_intent_dict
|
||||
|
||||
|
||||
class Invoice(models.Model):
|
||||
customer = models.ForeignKey(Customer, on_delete=CASCADE)
|
||||
stripe_invoice_id = models.CharField(max_length=255, unique=True)
|
||||
plan = models.ForeignKey("CustomerPlan", null=True, default=None, on_delete=SET_NULL)
|
||||
is_created_for_free_trial_upgrade = models.BooleanField(default=False)
|
||||
|
||||
SENT = 1
|
||||
PAID = 2
|
||||
VOID = 3
|
||||
status = models.SmallIntegerField()
|
||||
|
||||
def get_status_as_string(self) -> str:
|
||||
return {
|
||||
Invoice.SENT: "sent",
|
||||
Invoice.PAID: "paid",
|
||||
Invoice.VOID: "void",
|
||||
}[self.status]
|
||||
|
||||
def get_last_associated_event(self) -> Event | None:
|
||||
if self.status == Invoice.PAID:
|
||||
event_type = "invoice.paid"
|
||||
elif self.status == Invoice.VOID:
|
||||
event_type = "invoice.voided"
|
||||
# A SENT invoice has no associated event yet.
|
||||
else:
|
||||
return None
|
||||
return get_last_associated_event_by_type(self, event_type)
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
stripe_invoice_dict: dict[str, Any] = {}
|
||||
stripe_invoice_dict["status"] = self.get_status_as_string()
|
||||
event = self.get_last_associated_event()
|
||||
if event is not None:
|
||||
stripe_invoice_dict["event_handler"] = event.get_event_handler_details_as_dict()
|
||||
return stripe_invoice_dict
|
||||
Reference in New Issue
Block a user