From c12b94aea42c0afcee43a1b93d4943a606a666d3 Mon Sep 17 00:00:00 2001 From: Ethan Mayer Date: Mon, 7 Apr 2025 15:51:39 -0500 Subject: [PATCH] models: Refactor corporate/models.py into models package. Fixes #34318. Seperated models file into a package with component files. --- corporate/lib/activity.py | 3 +- corporate/lib/registration.py | 2 +- corporate/lib/stripe.py | 20 +- corporate/lib/stripe_event_handler.py | 11 +- corporate/lib/support.py | 12 +- corporate/models.py | 587 ------------------ corporate/models/__init__.py | 11 + corporate/models/customers.py | 89 +++ corporate/models/licenses.py | 45 ++ corporate/models/plans.py | 264 ++++++++ corporate/models/sponsorships.py | 40 ++ corporate/models/stripe_state.py | 176 ++++++ corporate/tests/test_activity_views.py | 4 +- corporate/tests/test_remote_billing.py | 10 +- corporate/tests/test_stripe.py | 13 +- corporate/tests/test_support_views.py | 14 +- corporate/views/billing_page.py | 3 +- corporate/views/plan_activity.py | 4 +- corporate/views/portico.py | 3 +- corporate/views/remote_billing_page.py | 7 +- corporate/views/support.py | 2 +- corporate/views/upgrade.py | 2 +- corporate/views/webhook.py | 2 +- zerver/actions/realm_settings.py | 2 +- zerver/lib/events.py | 2 +- zerver/lib/test_classes.py | 4 +- zerver/management/commands/delete_realm.py | 3 +- zerver/tests/test_docs.py | 3 +- zerver/tests/test_home.py | 3 +- zerver/tests/test_push_notifications.py | 2 +- .../commands/populate_billing_realms.py | 4 +- ...switch_realm_from_standard_to_plus_plan.py | 2 +- zilencer/views.py | 7 +- 33 files changed, 690 insertions(+), 666 deletions(-) delete mode 100644 corporate/models.py create mode 100644 corporate/models/__init__.py create mode 100644 corporate/models/customers.py create mode 100644 corporate/models/licenses.py create mode 100644 corporate/models/plans.py create mode 100644 corporate/models/sponsorships.py create mode 100644 corporate/models/stripe_state.py diff --git a/corporate/lib/activity.py b/corporate/lib/activity.py index 1cd75e5a48..8be96d9e4f 100644 --- a/corporate/lib/activity.py +++ b/corporate/lib/activity.py @@ -16,7 +16,8 @@ from django.utils.timezone import now as timezone_now from markupsafe import Markup from psycopg2.sql import Composable -from corporate.models import CustomerPlan, LicenseLedger +from corporate.models.licenses import LicenseLedger +from corporate.models.plans import CustomerPlan from zerver.lib.pysa import mark_sanitized from zerver.lib.url_encoding import append_url_query_string from zerver.models import Realm diff --git a/corporate/lib/registration.py b/corporate/lib/registration.py index 826c7c7402..0716b6f5b7 100644 --- a/corporate/lib/registration.py +++ b/corporate/lib/registration.py @@ -2,7 +2,7 @@ from django.conf import settings from django.utils.translation import gettext as _ from corporate.lib.stripe import LicenseLimitError, get_latest_seat_count, get_seat_count -from corporate.models import CustomerPlan, get_current_plan_by_realm +from corporate.models.plans import CustomerPlan, get_current_plan_by_realm from zerver.actions.create_user import send_group_direct_message_to_admins from zerver.lib.exceptions import InvitationError, JsonableError from zerver.models import Realm, UserProfile diff --git a/corporate/lib/stripe.py b/corporate/lib/stripe.py index e7564a91ef..8231e5c099 100644 --- a/corporate/lib/stripe.py +++ b/corporate/lib/stripe.py @@ -28,21 +28,21 @@ from django.utils.translation import override as override_language from typing_extensions import ParamSpec, override from corporate.lib.billing_types import BillingModality, BillingSchedule, LicenseManagement -from corporate.models import ( +from corporate.models.customers import ( Customer, - CustomerPlan, - CustomerPlanOffer, - Invoice, - LicenseLedger, - Session, - SponsoredPlanTypes, - ZulipSponsorshipRequest, - get_current_plan_by_customer, - get_current_plan_by_realm, get_customer_by_realm, get_customer_by_remote_realm, get_customer_by_remote_server, ) +from corporate.models.licenses import LicenseLedger +from corporate.models.plans import ( + CustomerPlan, + CustomerPlanOffer, + get_current_plan_by_customer, + get_current_plan_by_realm, +) +from corporate.models.sponsorships import SponsoredPlanTypes, ZulipSponsorshipRequest +from corporate.models.stripe_state import Invoice, Session from zerver.lib.cache import cache_with_key, get_realm_seat_count_cache_key from zerver.lib.exceptions import JsonableError from zerver.lib.logging_util import log_to_file diff --git a/corporate/lib/stripe_event_handler.py b/corporate/lib/stripe_event_handler.py index 1b2411bbcd..c55eedd461 100644 --- a/corporate/lib/stripe_event_handler.py +++ b/corporate/lib/stripe_event_handler.py @@ -13,14 +13,9 @@ from corporate.lib.stripe import ( RemoteServerBillingSession, get_configured_fixed_price_plan_offer, ) -from corporate.models import ( - Customer, - CustomerPlan, - Event, - Invoice, - Session, - get_current_plan_by_customer, -) +from corporate.models.customers import Customer +from corporate.models.plans import CustomerPlan, get_current_plan_by_customer +from corporate.models.stripe_state import Event, Invoice, Session from zerver.lib.send_email import FromAddress, send_email from zerver.models.users import get_active_user_profile_by_id_in_realm diff --git a/corporate/lib/support.py b/corporate/lib/support.py index 16984a8970..5404c58567 100644 --- a/corporate/lib/support.py +++ b/corporate/lib/support.py @@ -17,14 +17,10 @@ from corporate.lib.stripe import ( get_push_status_for_remote_request, start_of_next_billing_cycle, ) -from corporate.models import ( - Customer, - CustomerPlan, - CustomerPlanOffer, - LicenseLedger, - ZulipSponsorshipRequest, - get_current_plan_by_customer, -) +from corporate.models.customers import Customer +from corporate.models.licenses import LicenseLedger +from corporate.models.plans import CustomerPlan, CustomerPlanOffer, get_current_plan_by_customer +from corporate.models.sponsorships import ZulipSponsorshipRequest from zerver.lib.timestamp import timestamp_to_datetime from zerver.models import Realm from zerver.models.realm_audit_logs import AuditLogEventType diff --git a/corporate/models.py b/corporate/models.py deleted file mode 100644 index 5aaf47bb2d..0000000000 --- a/corporate/models.py +++ /dev/null @@ -1,587 +0,0 @@ -from enum import Enum -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, Q -from typing_extensions import override - -from zerver.models import Realm, UserProfile -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} (with stripe_customer_id: {self.stripe_customer_id})" - elif self.remote_realm is not None: - return f"{self.remote_realm!r} (with stripe_customer_id: {self.stripe_customer_id})" - else: - return f"{self.remote_server!r} (with stripe_customer_id: {self.stripe_customer_id})" - - def get_discounted_price_for_plan(self, plan_tier: int, schedule: int) -> int | None: - 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() - - -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" - # 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]: - 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 - - -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 - # invoice overdue by >= one day. Helps to send an email only once - # and not every time when cron run. - invoice_overdue_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 go through ledger entries that were - # created after invoiced_through and process them by generating - # invoices for any additional users and/or plan renewal. Once the - # invoice is generated, we update the value of invoiced_through - # and set it to the last ledger entry we processed. - 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: - 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: - 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) - - -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.strftime("%Y-%m-%d %H:%M") - return f"License {ledger_type}, {self.licenses} purchased, {self.licenses_at_next_renewal} next cycle, {ledger_time} (id={self.id})" - - -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, - ) diff --git a/corporate/models/__init__.py b/corporate/models/__init__.py new file mode 100644 index 0000000000..a2c63ee0e9 --- /dev/null +++ b/corporate/models/__init__.py @@ -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 diff --git a/corporate/models/customers.py b/corporate/models/customers.py new file mode 100644 index 0000000000..b85d532e17 --- /dev/null +++ b/corporate/models/customers.py @@ -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} (with stripe_customer_id: {self.stripe_customer_id})" + elif self.remote_realm is not None: + return f"{self.remote_realm!r} (with stripe_customer_id: {self.stripe_customer_id})" + else: + return f"{self.remote_server!r} (with stripe_customer_id: {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() diff --git a/corporate/models/licenses.py b/corporate/models/licenses.py new file mode 100644 index 0000000000..12e3a45955 --- /dev/null +++ b/corporate/models/licenses.py @@ -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.strftime("%Y-%m-%d %H:%M") + return f"License {ledger_type}, {self.licenses} purchased, {self.licenses_at_next_renewal} next cycle, {ledger_time} (id={self.id})" diff --git a/corporate/models/plans.py b/corporate/models/plans.py new file mode 100644 index 0000000000..20057139c9 --- /dev/null +++ b/corporate/models/plans.py @@ -0,0 +1,264 @@ +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 + # invoice overdue by >= one day. Helps to send an email only once + # and not every time when cron run. + invoice_overdue_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 go through ledger entries that were + # created after invoiced_through and process them by generating + # invoices for any additional users and/or plan renewal. Once the + # invoice is generated, we update the value of invoiced_through + # and set it to the last ledger entry we processed. + 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) diff --git a/corporate/models/sponsorships.py b/corporate/models/sponsorships.py new file mode 100644 index 0000000000..e2588213de --- /dev/null +++ b/corporate/models/sponsorships.py @@ -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, + ) diff --git a/corporate/models/stripe_state.py b/corporate/models/stripe_state.py new file mode 100644 index 0000000000..5765232ac0 --- /dev/null +++ b/corporate/models/stripe_state.py @@ -0,0 +1,176 @@ +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" + # 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]: + 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 diff --git a/corporate/tests/test_activity_views.py b/corporate/tests/test_activity_views.py index 17ab2b52f6..ccc0f9ded0 100644 --- a/corporate/tests/test_activity_views.py +++ b/corporate/tests/test_activity_views.py @@ -6,7 +6,9 @@ from django.utils.timezone import now as timezone_now from corporate.lib.activity import get_remote_server_audit_logs from corporate.lib.stripe import add_months -from corporate.models import Customer, CustomerPlan, LicenseLedger +from corporate.models.customers import Customer +from corporate.models.licenses import LicenseLedger +from corporate.models.plans import CustomerPlan from zerver.lib.test_classes import ZulipTestCase from zerver.models import Client, UserActivity, UserProfile from zerver.models.realm_audit_logs import AuditLogEventType diff --git a/corporate/tests/test_remote_billing.py b/corporate/tests/test_remote_billing.py index 85a71f1082..46bc15de82 100644 --- a/corporate/tests/test_remote_billing.py +++ b/corporate/tests/test_remote_billing.py @@ -15,13 +15,9 @@ from corporate.lib.remote_billing_util import ( RemoteBillingUserDict, ) from corporate.lib.stripe import RemoteRealmBillingSession, RemoteServerBillingSession, add_months -from corporate.models import ( - CustomerPlan, - LicenseLedger, - get_current_plan_by_customer, - get_customer_by_remote_realm, - get_customer_by_remote_server, -) +from corporate.models.customers import get_customer_by_remote_realm, get_customer_by_remote_server +from corporate.models.licenses import LicenseLedger +from corporate.models.plans import CustomerPlan, get_current_plan_by_customer from corporate.views.remote_billing_page import generate_confirmation_link_for_server_deactivation from zerver.lib.exceptions import RemoteRealmServerMismatchError from zerver.lib.rate_limiter import RateLimitedIPAddr diff --git a/corporate/tests/test_stripe.py b/corporate/tests/test_stripe.py index 0e6b6d84a6..7e5281b9a5 100644 --- a/corporate/tests/test_stripe.py +++ b/corporate/tests/test_stripe.py @@ -65,19 +65,16 @@ from corporate.lib.stripe import ( stripe_get_customer, unsign_string, ) -from corporate.models import ( - Customer, +from corporate.models.customers import Customer, get_customer_by_realm, get_customer_by_remote_realm +from corporate.models.licenses import LicenseLedger +from corporate.models.plans import ( CustomerPlan, CustomerPlanOffer, - Event, - Invoice, - LicenseLedger, - ZulipSponsorshipRequest, get_current_plan_by_customer, get_current_plan_by_realm, - get_customer_by_realm, - get_customer_by_remote_realm, ) +from corporate.models.sponsorships import ZulipSponsorshipRequest +from corporate.models.stripe_state import Event, Invoice from corporate.tests.test_remote_billing import RemoteRealmBillingTestCase, RemoteServerTestCase from corporate.views.remote_billing_page import generate_confirmation_link_for_server_deactivation from zerver.actions.create_realm import do_create_realm diff --git a/corporate/tests/test_support_views.py b/corporate/tests/test_support_views.py index cdf27bf2e2..44350dc322 100644 --- a/corporate/tests/test_support_views.py +++ b/corporate/tests/test_support_views.py @@ -15,16 +15,10 @@ from corporate.lib.stripe import ( get_configured_fixed_price_plan_offer, start_of_next_billing_cycle, ) -from corporate.models import ( - Customer, - CustomerPlan, - CustomerPlanOffer, - LicenseLedger, - SponsoredPlanTypes, - ZulipSponsorshipRequest, - get_current_plan_by_customer, - get_customer_by_realm, -) +from corporate.models.customers import Customer, get_customer_by_realm +from corporate.models.licenses import LicenseLedger +from corporate.models.plans import CustomerPlan, CustomerPlanOffer, get_current_plan_by_customer +from corporate.models.sponsorships import SponsoredPlanTypes, ZulipSponsorshipRequest from zerver.actions.create_realm import do_create_realm from zerver.actions.invites import do_create_multiuse_invite_link from zerver.actions.realm_settings import do_change_realm_org_type, do_send_realm_reactivation_email diff --git a/corporate/views/billing_page.py b/corporate/views/billing_page.py index 4097d63ef3..de21e07159 100644 --- a/corporate/views/billing_page.py +++ b/corporate/views/billing_page.py @@ -11,7 +11,8 @@ from corporate.lib.decorator import ( authenticated_remote_realm_management_endpoint, authenticated_remote_server_management_endpoint, ) -from corporate.models import CustomerPlan, get_current_plan_by_customer, get_customer_by_realm +from corporate.models.customers import get_customer_by_realm +from corporate.models.plans import CustomerPlan, get_current_plan_by_customer from zerver.decorator import process_as_post, require_billing_access, zulip_login_required from zerver.lib.exceptions import JsonableError from zerver.lib.response import json_success diff --git a/corporate/views/plan_activity.py b/corporate/views/plan_activity.py index a030ce04f3..46c88dd78c 100644 --- a/corporate/views/plan_activity.py +++ b/corporate/views/plan_activity.py @@ -4,7 +4,9 @@ from django.http import HttpRequest, HttpResponse from django.shortcuts import render from corporate.lib.activity import ActivityHeaderEntry, format_optional_datetime, make_table -from corporate.models import Customer, CustomerPlan, LicenseLedger +from corporate.models.customers import Customer +from corporate.models.licenses import LicenseLedger +from corporate.models.plans import CustomerPlan from zerver.decorator import require_server_admin diff --git a/corporate/views/portico.py b/corporate/views/portico.py index e51e17af42..62c18fe853 100644 --- a/corporate/views/portico.py +++ b/corporate/views/portico.py @@ -14,7 +14,8 @@ from corporate.lib.decorator import ( authenticated_remote_realm_management_endpoint, authenticated_remote_server_management_endpoint, ) -from corporate.models import CustomerPlan, get_current_plan_by_customer, get_customer_by_realm +from corporate.models.customers import get_customer_by_realm +from corporate.models.plans import CustomerPlan, get_current_plan_by_customer from zerver.context_processors import get_realm_from_request, latest_info_context from zerver.decorator import add_google_analytics, zulip_login_required from zerver.lib.github import ( diff --git a/corporate/views/remote_billing_page.py b/corporate/views/remote_billing_page.py index d222978869..419c9fe6a1 100644 --- a/corporate/views/remote_billing_page.py +++ b/corporate/views/remote_billing_page.py @@ -32,11 +32,8 @@ from corporate.lib.remote_billing_util import ( RemoteBillingUserDict, get_remote_server_and_user_from_session, ) -from corporate.models import ( - CustomerPlan, - get_current_plan_by_customer, - get_customer_by_remote_server, -) +from corporate.models.customers import get_customer_by_remote_server +from corporate.models.plans import CustomerPlan, get_current_plan_by_customer from zerver.lib.exceptions import ( JsonableError, MissingRemoteRealmError, diff --git a/corporate/views/support.py b/corporate/views/support.py index 4ab3813067..545545551b 100644 --- a/corporate/views/support.py +++ b/corporate/views/support.py @@ -27,7 +27,7 @@ from corporate.lib.activity import ( remote_installation_stats_link, ) from corporate.lib.billing_types import BillingModality -from corporate.models import CustomerPlan +from corporate.models.plans import CustomerPlan from zerver.actions.create_realm import do_change_realm_subdomain from zerver.actions.realm_settings import ( do_change_realm_max_invites, diff --git a/corporate/views/upgrade.py b/corporate/views/upgrade.py index 1458e4419b..e3db7d2227 100644 --- a/corporate/views/upgrade.py +++ b/corporate/views/upgrade.py @@ -12,7 +12,7 @@ from corporate.lib.decorator import ( authenticated_remote_realm_management_endpoint, authenticated_remote_server_management_endpoint, ) -from corporate.models import CustomerPlan +from corporate.models.plans import CustomerPlan from zerver.decorator import require_organization_member, zulip_login_required from zerver.lib.response import json_success from zerver.lib.typed_endpoint import typed_endpoint diff --git a/corporate/views/webhook.py b/corporate/views/webhook.py index dcd6c9c8bf..abe26f2c4d 100644 --- a/corporate/views/webhook.py +++ b/corporate/views/webhook.py @@ -6,7 +6,7 @@ from django.contrib.contenttypes.models import ContentType from django.http import HttpRequest, HttpResponse from django.views.decorators.csrf import csrf_exempt -from corporate.models import Event, Invoice, Session +from corporate.models.stripe_state import Event, Invoice, Session from zproject.config import get_secret billing_logger = logging.getLogger("corporate.stripe") diff --git a/zerver/actions/realm_settings.py b/zerver/actions/realm_settings.py index 3b1022b0e3..b4a69f8f70 100644 --- a/zerver/actions/realm_settings.py +++ b/zerver/actions/realm_settings.py @@ -283,7 +283,7 @@ def get_realm_authentication_methods_for_page_params_api( # The rest of the function is only for the mechanism of restricting # certain backends based on the realm's plan type on Zulip Cloud. - from corporate.models import CustomerPlan + from corporate.models.plans import CustomerPlan for backend_name, backend_result in result_dict.items(): available_for = AUTH_BACKEND_NAME_MAP[backend_name].available_for_cloud_plans diff --git a/zerver/lib/events.py b/zerver/lib/events.py index aa8c35edf3..7be78c5215 100644 --- a/zerver/lib/events.py +++ b/zerver/lib/events.py @@ -136,7 +136,7 @@ def has_pending_sponsorship_request( user_has_billing_access = user_profile is not None and user_profile.has_billing_access if settings.CORPORATE_ENABLED and user_profile is not None and user_has_billing_access: - from corporate.models import get_customer_by_realm + from corporate.models.customers import get_customer_by_realm customer = get_customer_by_realm(user_profile.realm) if customer is not None: diff --git a/zerver/lib/test_classes.py b/zerver/lib/test_classes.py index dd5aa2d2b4..fe10c07e59 100644 --- a/zerver/lib/test_classes.py +++ b/zerver/lib/test_classes.py @@ -39,7 +39,9 @@ from requests import PreparedRequest from two_factor.plugins.phonenumber.models import PhoneDevice from typing_extensions import override -from corporate.models import Customer, CustomerPlan, LicenseLedger +from corporate.models.customers import Customer +from corporate.models.licenses import LicenseLedger +from corporate.models.plans import CustomerPlan from zerver.actions.message_send import check_send_message, check_send_stream_message from zerver.actions.realm_settings import do_change_realm_permission_group_setting from zerver.actions.streams import bulk_add_subscriptions, bulk_remove_subscriptions diff --git a/zerver/management/commands/delete_realm.py b/zerver/management/commands/delete_realm.py index 6b7dedb22f..5d1c7ff766 100644 --- a/zerver/management/commands/delete_realm.py +++ b/zerver/management/commands/delete_realm.py @@ -37,7 +37,8 @@ realms used for testing; consider using deactivate_realm instead.""" # Deleting a Realm object also deletes associating billing # metadata in an invariant-violating way, so we should # never use this tool for a realm with billing set up. - from corporate.models import CustomerPlan, get_customer_by_realm + from corporate.models.customers import get_customer_by_realm + from corporate.models.plans import CustomerPlan customer = get_customer_by_realm(realm) if customer and ( diff --git a/zerver/tests/test_docs.py b/zerver/tests/test_docs.py index 1a35813c3c..686fc6acd7 100644 --- a/zerver/tests/test_docs.py +++ b/zerver/tests/test_docs.py @@ -10,7 +10,8 @@ from django.conf import settings from django.test import override_settings from django.utils.timezone import now as timezone_now -from corporate.models import Customer, CustomerPlan +from corporate.models.customers import Customer +from corporate.models.plans import CustomerPlan from zerver.context_processors import get_apps_page_url from zerver.lib.integrations import CATEGORIES, INTEGRATIONS, META_CATEGORY from zerver.lib.test_classes import ZulipTestCase diff --git a/zerver/tests/test_home.py b/zerver/tests/test_home.py index 4553abaed6..0e81067d80 100644 --- a/zerver/tests/test_home.py +++ b/zerver/tests/test_home.py @@ -10,7 +10,8 @@ from django.conf import settings from django.test import override_settings from django.utils.timezone import now as timezone_now -from corporate.models import Customer, CustomerPlan +from corporate.models.customers import Customer +from corporate.models.plans import CustomerPlan from version import ZULIP_VERSION from zerver.actions.create_user import do_create_user from zerver.actions.realm_settings import do_change_realm_plan_type, do_set_realm_property diff --git a/zerver/tests/test_push_notifications.py b/zerver/tests/test_push_notifications.py index 67810ba893..b4999c2fb9 100644 --- a/zerver/tests/test_push_notifications.py +++ b/zerver/tests/test_push_notifications.py @@ -29,7 +29,7 @@ from typing_extensions import override from analytics.lib.counts import CountStat, LoggingCountStat from analytics.models import InstallationCount, RealmCount, UserCount from corporate.lib.stripe import RemoteRealmBillingSession -from corporate.models import CustomerPlan +from corporate.models.plans import CustomerPlan from version import ZULIP_VERSION from zerver.actions.create_realm import do_create_realm from zerver.actions.message_delete import do_delete_messages diff --git a/zilencer/management/commands/populate_billing_realms.py b/zilencer/management/commands/populate_billing_realms.py index 14af07a03f..40c0c86160 100644 --- a/zilencer/management/commands/populate_billing_realms.py +++ b/zilencer/management/commands/populate_billing_realms.py @@ -18,7 +18,9 @@ from corporate.lib.stripe import ( add_months, sign_string, ) -from corporate.models import Customer, CustomerPlan, LicenseLedger +from corporate.models.customers import Customer +from corporate.models.licenses import LicenseLedger +from corporate.models.plans import CustomerPlan from scripts.lib.zulip_tools import TIMESTAMP_FORMAT from zerver.actions.create_realm import do_create_realm from zerver.actions.create_user import do_create_user diff --git a/zilencer/management/commands/switch_realm_from_standard_to_plus_plan.py b/zilencer/management/commands/switch_realm_from_standard_to_plus_plan.py index cd337cd9a6..b011739f00 100644 --- a/zilencer/management/commands/switch_realm_from_standard_to_plus_plan.py +++ b/zilencer/management/commands/switch_realm_from_standard_to_plus_plan.py @@ -8,7 +8,7 @@ from zerver.lib.management import ZulipBaseCommand if settings.BILLING_ENABLED: from corporate.lib.stripe import RealmBillingSession - from corporate.models import CustomerPlan + from corporate.models.plans import CustomerPlan class Command(ZulipBaseCommand): diff --git a/zilencer/views.py b/zilencer/views.py index 48296e9b98..2a61970904 100644 --- a/zilencer/views.py +++ b/zilencer/views.py @@ -32,11 +32,8 @@ from analytics.lib.counts import ( REMOTE_INSTALLATION_COUNT_STATS, do_increment_logging_stat, ) -from corporate.models import ( - CustomerPlan, - get_current_plan_by_customer, - get_customer_by_remote_realm, -) +from corporate.models.customers import get_customer_by_remote_realm +from corporate.models.plans import CustomerPlan, get_current_plan_by_customer from zerver.decorator import require_post from zerver.lib.email_validation import validate_is_not_disposable from zerver.lib.exceptions import (