chore: improve plan-based feature handling with plan hierarchy (#11335)
- Refactor HandleStripeEventService to better manage features by plan - Add constants for features available in each plan tier (Startup, Business, Enterprise) - Add channel_instagram to Startup plan features - Improve downgrade handling to properly disable higher-tier features - Clean up and optimize tests for maintainability - Add comprehensive test coverage for plan upgrades and downgrades --------- Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
This commit is contained in:
@@ -66,3 +66,5 @@ class SuperAdmin::AccountsController < SuperAdmin::ApplicationController
|
|||||||
# rubocop:enable Rails/I18nLocaleTexts
|
# rubocop:enable Rails/I18nLocaleTexts
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
SuperAdmin::AccountsController.prepend_mod_with('SuperAdmin::AccountsController')
|
||||||
|
|||||||
@@ -9,10 +9,17 @@ class AccountDashboard < Administrate::BaseDashboard
|
|||||||
# on pages throughout the dashboard.
|
# on pages throughout the dashboard.
|
||||||
|
|
||||||
enterprise_attribute_types = if ChatwootApp.enterprise?
|
enterprise_attribute_types = if ChatwootApp.enterprise?
|
||||||
{
|
attributes = {
|
||||||
limits: Enterprise::AccountLimitsField,
|
limits: AccountLimitsField
|
||||||
all_features: Enterprise::AccountFeaturesField
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Only show manually managed features in Chatwoot Cloud deployment
|
||||||
|
attributes[:manually_managed_features] = ManuallyManagedFeaturesField if ChatwootApp.chatwoot_cloud?
|
||||||
|
|
||||||
|
# Add all_features last so it appears after manually_managed_features
|
||||||
|
attributes[:all_features] = AccountFeaturesField
|
||||||
|
|
||||||
|
attributes
|
||||||
else
|
else
|
||||||
{}
|
{}
|
||||||
end
|
end
|
||||||
@@ -46,7 +53,14 @@ class AccountDashboard < Administrate::BaseDashboard
|
|||||||
|
|
||||||
# SHOW_PAGE_ATTRIBUTES
|
# SHOW_PAGE_ATTRIBUTES
|
||||||
# an array of attributes that will be displayed on the model's show page.
|
# an array of attributes that will be displayed on the model's show page.
|
||||||
enterprise_show_page_attributes = ChatwootApp.enterprise? ? %i[custom_attributes limits all_features] : []
|
enterprise_show_page_attributes = if ChatwootApp.enterprise?
|
||||||
|
attrs = %i[custom_attributes limits]
|
||||||
|
attrs << :manually_managed_features if ChatwootApp.chatwoot_cloud?
|
||||||
|
attrs << :all_features
|
||||||
|
attrs
|
||||||
|
else
|
||||||
|
[]
|
||||||
|
end
|
||||||
SHOW_PAGE_ATTRIBUTES = (%i[
|
SHOW_PAGE_ATTRIBUTES = (%i[
|
||||||
id
|
id
|
||||||
name
|
name
|
||||||
@@ -61,7 +75,14 @@ class AccountDashboard < Administrate::BaseDashboard
|
|||||||
# FORM_ATTRIBUTES
|
# FORM_ATTRIBUTES
|
||||||
# an array of attributes that will be displayed
|
# an array of attributes that will be displayed
|
||||||
# on the model's form (`new` and `edit`) pages.
|
# on the model's form (`new` and `edit`) pages.
|
||||||
enterprise_form_attributes = ChatwootApp.enterprise? ? %i[limits all_features] : []
|
enterprise_form_attributes = if ChatwootApp.enterprise?
|
||||||
|
attrs = %i[limits]
|
||||||
|
attrs << :manually_managed_features if ChatwootApp.chatwoot_cloud?
|
||||||
|
attrs << :all_features
|
||||||
|
attrs
|
||||||
|
else
|
||||||
|
[]
|
||||||
|
end
|
||||||
FORM_ATTRIBUTES = (%i[
|
FORM_ATTRIBUTES = (%i[
|
||||||
name
|
name
|
||||||
locale
|
locale
|
||||||
@@ -96,6 +117,11 @@ class AccountDashboard < Administrate::BaseDashboard
|
|||||||
# to prevent an error from being raised (wrong number of arguments)
|
# to prevent an error from being raised (wrong number of arguments)
|
||||||
# Reference: https://github.com/thoughtbot/administrate/pull/2356/files#diff-4e220b661b88f9a19ac527c50d6f1577ef6ab7b0bed2bfdf048e22e6bfa74a05R204
|
# Reference: https://github.com/thoughtbot/administrate/pull/2356/files#diff-4e220b661b88f9a19ac527c50d6f1577ef6ab7b0bed2bfdf048e22e6bfa74a05R204
|
||||||
def permitted_attributes(action)
|
def permitted_attributes(action)
|
||||||
super + [limits: {}]
|
attrs = super + [limits: {}]
|
||||||
|
|
||||||
|
# Add manually_managed_features to permitted attributes only for Chatwoot Cloud
|
||||||
|
attrs << { manually_managed_features: [] } if ChatwootApp.chatwoot_cloud?
|
||||||
|
|
||||||
|
attrs
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -1,7 +0,0 @@
|
|||||||
require 'administrate/field/base'
|
|
||||||
|
|
||||||
class Enterprise::AccountFeaturesField < Administrate::Field::Base
|
|
||||||
def to_s
|
|
||||||
data
|
|
||||||
end
|
|
||||||
end
|
|
||||||
@@ -15,7 +15,7 @@ module SuperAdmin::AccountFeaturesHelper
|
|||||||
end
|
end
|
||||||
|
|
||||||
def self.filter_internal_features(features)
|
def self.filter_internal_features(features)
|
||||||
return features if GlobalConfig.get_value('DEPLOYMENT_ENV') == 'cloud'
|
return features if ChatwootApp.chatwoot_cloud?
|
||||||
|
|
||||||
internal_features = account_features.select { |f| f['chatwoot_internal'] }.pluck('name')
|
internal_features = account_features.select { |f| f['chatwoot_internal'] }.pluck('name')
|
||||||
features.except(*internal_features)
|
features.except(*internal_features)
|
||||||
|
|||||||
@@ -44,6 +44,8 @@ module Chatwoot
|
|||||||
# rubocop:disable Rails/FilePath
|
# rubocop:disable Rails/FilePath
|
||||||
config.eager_load_paths += Dir["#{Rails.root}/enterprise/app/**"]
|
config.eager_load_paths += Dir["#{Rails.root}/enterprise/app/**"]
|
||||||
# rubocop:enable Rails/FilePath
|
# rubocop:enable Rails/FilePath
|
||||||
|
# Add enterprise views to the view paths
|
||||||
|
config.paths['app/views'].unshift('enterprise/app/views')
|
||||||
|
|
||||||
# Settings in config/environments/* take precedence over those specified here.
|
# Settings in config/environments/* take precedence over those specified here.
|
||||||
# Application configuration can go into files in config/initializers
|
# Application configuration can go into files in config/initializers
|
||||||
|
|||||||
@@ -58,8 +58,7 @@ class Enterprise::Api::V1::AccountsController < Api::BaseController
|
|||||||
private
|
private
|
||||||
|
|
||||||
def check_cloud_env
|
def check_cloud_env
|
||||||
installation_config = InstallationConfig.find_by(name: 'DEPLOYMENT_ENV')
|
render json: { error: 'Not found' }, status: :not_found unless ChatwootApp.chatwoot_cloud?
|
||||||
render json: { error: 'Not found' }, status: :not_found unless installation_config&.value == 'cloud'
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def default_limits
|
def default_limits
|
||||||
|
|||||||
@@ -1,12 +1,3 @@
|
|||||||
module Enterprise::Concerns::ApplicationControllerConcern
|
module Enterprise::Concerns::ApplicationControllerConcern
|
||||||
extend ActiveSupport::Concern
|
extend ActiveSupport::Concern
|
||||||
|
|
||||||
included do
|
|
||||||
before_action :prepend_view_paths
|
|
||||||
end
|
|
||||||
|
|
||||||
# Prepend the view path to the enterprise/app/views won't be available by default
|
|
||||||
def prepend_view_paths
|
|
||||||
prepend_view_path 'enterprise/app/views/'
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
module Enterprise::SuperAdmin::AccountsController
|
||||||
|
def update
|
||||||
|
# Handle manually managed features from form submission
|
||||||
|
if params[:account] && params[:account][:manually_managed_features].present?
|
||||||
|
# Update using the service - it will handle array conversion and validation
|
||||||
|
service = ::Internal::Accounts::InternalAttributesService.new(requested_resource)
|
||||||
|
service.manually_managed_features = params[:account][:manually_managed_features]
|
||||||
|
|
||||||
|
# Remove the manually_managed_features from params to prevent ActiveModel::UnknownAttributeError
|
||||||
|
params[:account].delete(:manually_managed_features)
|
||||||
|
end
|
||||||
|
|
||||||
|
super
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -1,8 +1,2 @@
|
|||||||
class SuperAdmin::EnterpriseBaseController < SuperAdmin::ApplicationController
|
class SuperAdmin::EnterpriseBaseController < SuperAdmin::ApplicationController
|
||||||
before_action :prepend_view_paths
|
|
||||||
|
|
||||||
# Prepend the view path to the enterprise/app/views won't be available by default
|
|
||||||
def prepend_view_paths
|
|
||||||
prepend_view_path 'enterprise/app/views/'
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|||||||
7
enterprise/app/fields/account_features_field.rb
Normal file
7
enterprise/app/fields/account_features_field.rb
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
require 'administrate/field/base'
|
||||||
|
|
||||||
|
class AccountFeaturesField < Administrate::Field::Base
|
||||||
|
def to_s
|
||||||
|
data
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
require 'administrate/field/base'
|
require 'administrate/field/base'
|
||||||
|
|
||||||
class Enterprise::AccountLimitsField < Administrate::Field::Base
|
class AccountLimitsField < Administrate::Field::Base
|
||||||
def to_s
|
def to_s
|
||||||
data.present? ? data.to_json : { agents: nil, inboxes: nil, captain_responses: nil, captain_documents: nil }.to_json
|
data.present? ? data.to_json : { agents: nil, inboxes: nil, captain_responses: nil, captain_documents: nil }.to_json
|
||||||
end
|
end
|
||||||
31
enterprise/app/fields/manually_managed_features_field.rb
Normal file
31
enterprise/app/fields/manually_managed_features_field.rb
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
require 'administrate/field/base'
|
||||||
|
|
||||||
|
class ManuallyManagedFeaturesField < Administrate::Field::Base
|
||||||
|
def data
|
||||||
|
Internal::Accounts::InternalAttributesService.new(resource).manually_managed_features
|
||||||
|
end
|
||||||
|
|
||||||
|
def to_s
|
||||||
|
data.is_a?(Array) ? data.join(', ') : '[]'
|
||||||
|
end
|
||||||
|
|
||||||
|
def all_features
|
||||||
|
# Business and Enterprise plan features only
|
||||||
|
Enterprise::Billing::HandleStripeEventService::BUSINESS_PLAN_FEATURES +
|
||||||
|
Enterprise::Billing::HandleStripeEventService::ENTERPRISE_PLAN_FEATURES
|
||||||
|
end
|
||||||
|
|
||||||
|
def selected_features
|
||||||
|
# If we have direct array data, use it (for rendering after form submission)
|
||||||
|
return data if data.is_a?(Array)
|
||||||
|
|
||||||
|
# Otherwise, use the service to retrieve the data from internal_attributes
|
||||||
|
if resource.respond_to?(:internal_attributes)
|
||||||
|
service = Internal::Accounts::InternalAttributesService.new(resource)
|
||||||
|
return service.manually_managed_features
|
||||||
|
end
|
||||||
|
|
||||||
|
# Fallback to empty array if no data available
|
||||||
|
[]
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -2,7 +2,7 @@ class Internal::AccountAnalysisJob < ApplicationJob
|
|||||||
queue_as :low
|
queue_as :low
|
||||||
|
|
||||||
def perform(account)
|
def perform(account)
|
||||||
return if GlobalConfig.get_value('DEPLOYMENT_ENV') != 'cloud'
|
return unless ChatwootApp.chatwoot_cloud?
|
||||||
|
|
||||||
Internal::AccountAnalysis::ThreatAnalyserService.new(account).perform
|
Internal::AccountAnalysis::ThreatAnalyserService.new(account).perform
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -1,4 +1,8 @@
|
|||||||
module Enterprise::Account
|
module Enterprise::Account
|
||||||
|
# TODO: Remove this when we upgrade administrate gem to the latest version
|
||||||
|
# this is a temporary method since current administrate doesn't support virtual attributes
|
||||||
|
def manually_managed_features; end
|
||||||
|
|
||||||
def mark_for_deletion(reason = 'manual_deletion')
|
def mark_for_deletion(reason = 'manual_deletion')
|
||||||
result = custom_attributes.merge!('marked_for_deletion_at' => 7.days.from_now.iso8601, 'marked_for_deletion_reason' => reason) && save
|
result = custom_attributes.merge!('marked_for_deletion_at' => 7.days.from_now.iso8601, 'marked_for_deletion_reason' => reason) && save
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,31 @@
|
|||||||
class Enterprise::Billing::HandleStripeEventService
|
class Enterprise::Billing::HandleStripeEventService
|
||||||
|
CLOUD_PLANS_CONFIG = 'CHATWOOT_CLOUD_PLANS'.freeze
|
||||||
|
|
||||||
|
# Plan hierarchy: Hacker (default) -> Startups -> Business -> Enterprise
|
||||||
|
# Each higher tier includes all features from the lower tiers
|
||||||
|
|
||||||
|
# Basic features available starting with the Startups plan
|
||||||
|
STARTUP_PLAN_FEATURES = %w[
|
||||||
|
inbound_emails
|
||||||
|
help_center
|
||||||
|
campaigns
|
||||||
|
team_management
|
||||||
|
channel_twitter
|
||||||
|
channel_facebook
|
||||||
|
channel_email
|
||||||
|
channel_instagram
|
||||||
|
captain_integration
|
||||||
|
].freeze
|
||||||
|
|
||||||
|
# Additional features available starting with the Business plan
|
||||||
|
BUSINESS_PLAN_FEATURES = %w[sla custom_roles].freeze
|
||||||
|
|
||||||
|
# Additional features available only in the Enterprise plan
|
||||||
|
ENTERPRISE_PLAN_FEATURES = %w[audit_logs disable_branding].freeze
|
||||||
|
|
||||||
def perform(event:)
|
def perform(event:)
|
||||||
ensure_event_context(event)
|
@event = event
|
||||||
|
|
||||||
case @event.type
|
case @event.type
|
||||||
when 'customer.subscription.updated'
|
when 'customer.subscription.updated'
|
||||||
process_subscription_updated
|
process_subscription_updated
|
||||||
@@ -20,14 +45,12 @@ class Enterprise::Billing::HandleStripeEventService
|
|||||||
return if plan.blank? || account.blank?
|
return if plan.blank? || account.blank?
|
||||||
|
|
||||||
update_account_attributes(subscription, plan)
|
update_account_attributes(subscription, plan)
|
||||||
|
update_plan_features
|
||||||
change_plan_features
|
|
||||||
reset_captain_usage
|
reset_captain_usage
|
||||||
end
|
end
|
||||||
|
|
||||||
def update_account_attributes(subscription, plan)
|
def update_account_attributes(subscription, plan)
|
||||||
# https://stripe.com/docs/api/subscriptions/object
|
# https://stripe.com/docs/api/subscriptions/object
|
||||||
|
|
||||||
account.update(
|
account.update(
|
||||||
custom_attributes: {
|
custom_attributes: {
|
||||||
stripe_customer_id: subscription.customer,
|
stripe_customer_id: subscription.customer,
|
||||||
@@ -48,25 +71,57 @@ class Enterprise::Billing::HandleStripeEventService
|
|||||||
Enterprise::Billing::CreateStripeCustomerService.new(account: account).perform
|
Enterprise::Billing::CreateStripeCustomerService.new(account: account).perform
|
||||||
end
|
end
|
||||||
|
|
||||||
def change_plan_features
|
def update_plan_features
|
||||||
if default_plan?
|
if default_plan?
|
||||||
account.disable_features(*features_to_update)
|
disable_all_premium_features
|
||||||
else
|
else
|
||||||
account.enable_features(*features_to_update)
|
enable_features_for_current_plan
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Enable any manually managed features configured in internal_attributes
|
||||||
|
enable_account_manually_managed_features
|
||||||
|
|
||||||
account.save!
|
account.save!
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def disable_all_premium_features
|
||||||
|
# Disable all features (for default Hacker plan)
|
||||||
|
account.disable_features(*STARTUP_PLAN_FEATURES)
|
||||||
|
account.disable_features(*BUSINESS_PLAN_FEATURES)
|
||||||
|
account.disable_features(*ENTERPRISE_PLAN_FEATURES)
|
||||||
|
end
|
||||||
|
|
||||||
|
def enable_features_for_current_plan
|
||||||
|
# First disable all premium features to handle downgrades
|
||||||
|
disable_all_premium_features
|
||||||
|
|
||||||
|
# Then enable features based on the current plan
|
||||||
|
enable_plan_specific_features
|
||||||
|
end
|
||||||
|
|
||||||
def reset_captain_usage
|
def reset_captain_usage
|
||||||
account.reset_response_usage
|
account.reset_response_usage
|
||||||
end
|
end
|
||||||
|
|
||||||
def ensure_event_context(event)
|
def enable_plan_specific_features
|
||||||
@event = event
|
plan_name = account.custom_attributes['plan_name']
|
||||||
end
|
return if plan_name.blank?
|
||||||
|
|
||||||
def features_to_update
|
# Enable features based on plan hierarchy
|
||||||
%w[inbound_emails help_center campaigns team_management channel_twitter channel_facebook channel_email captain_integration]
|
case plan_name
|
||||||
|
when 'Startups'
|
||||||
|
# Startups plan gets the basic features
|
||||||
|
account.enable_features(*STARTUP_PLAN_FEATURES)
|
||||||
|
when 'Business'
|
||||||
|
# Business plan gets Startups features + Business features
|
||||||
|
account.enable_features(*STARTUP_PLAN_FEATURES)
|
||||||
|
account.enable_features(*BUSINESS_PLAN_FEATURES)
|
||||||
|
when 'Enterprise'
|
||||||
|
# Enterprise plan gets all features
|
||||||
|
account.enable_features(*STARTUP_PLAN_FEATURES)
|
||||||
|
account.enable_features(*BUSINESS_PLAN_FEATURES)
|
||||||
|
account.enable_features(*ENTERPRISE_PLAN_FEATURES)
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def subscription
|
def subscription
|
||||||
@@ -78,13 +133,22 @@ class Enterprise::Billing::HandleStripeEventService
|
|||||||
end
|
end
|
||||||
|
|
||||||
def find_plan(plan_id)
|
def find_plan(plan_id)
|
||||||
installation_config = InstallationConfig.find_by(name: 'CHATWOOT_CLOUD_PLANS')
|
cloud_plans = InstallationConfig.find_by(name: CLOUD_PLANS_CONFIG)&.value || []
|
||||||
installation_config.value.find { |config| config['product_id'].include?(plan_id) }
|
cloud_plans.find { |config| config['product_id'].include?(plan_id) }
|
||||||
end
|
end
|
||||||
|
|
||||||
def default_plan?
|
def default_plan?
|
||||||
installation_config = InstallationConfig.find_by(name: 'CHATWOOT_CLOUD_PLANS')
|
cloud_plans = InstallationConfig.find_by(name: CLOUD_PLANS_CONFIG)&.value || []
|
||||||
default_plan = installation_config.value.first
|
default_plan = cloud_plans.first || {}
|
||||||
@account.custom_attributes['plan_name'] == default_plan['name']
|
account.custom_attributes['plan_name'] == default_plan['name']
|
||||||
|
end
|
||||||
|
|
||||||
|
def enable_account_manually_managed_features
|
||||||
|
# Get manually managed features from internal attributes using the service
|
||||||
|
service = Internal::Accounts::InternalAttributesService.new(account)
|
||||||
|
features = service.manually_managed_features
|
||||||
|
|
||||||
|
# Enable each feature
|
||||||
|
account.enable_features(*features) if features.present?
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -0,0 +1,68 @@
|
|||||||
|
class Internal::Accounts::InternalAttributesService
|
||||||
|
attr_reader :account
|
||||||
|
|
||||||
|
# List of keys that can be managed through this service
|
||||||
|
# TODO: Add account_notes field in future
|
||||||
|
# This field can be used to store notes about account on Chatwoot cloud
|
||||||
|
VALID_KEYS = %w[manually_managed_features].freeze
|
||||||
|
|
||||||
|
def initialize(account)
|
||||||
|
@account = account
|
||||||
|
end
|
||||||
|
|
||||||
|
# Get a value from internal_attributes
|
||||||
|
def get(key)
|
||||||
|
validate_key!(key)
|
||||||
|
account.internal_attributes[key]
|
||||||
|
end
|
||||||
|
|
||||||
|
# Set a value in internal_attributes
|
||||||
|
def set(key, value)
|
||||||
|
validate_key!(key)
|
||||||
|
|
||||||
|
# Create a new hash to avoid modifying the original
|
||||||
|
new_attrs = account.internal_attributes.dup || {}
|
||||||
|
new_attrs[key] = value
|
||||||
|
|
||||||
|
# Update the account
|
||||||
|
account.internal_attributes = new_attrs
|
||||||
|
account.save
|
||||||
|
end
|
||||||
|
|
||||||
|
# Get manually managed features
|
||||||
|
def manually_managed_features
|
||||||
|
get('manually_managed_features') || []
|
||||||
|
end
|
||||||
|
|
||||||
|
# Set manually managed features
|
||||||
|
def manually_managed_features=(features)
|
||||||
|
features = [] if features.nil?
|
||||||
|
features = [features] unless features.is_a?(Array)
|
||||||
|
|
||||||
|
# Clean up the array: remove empty strings, whitespace, and validate against valid features
|
||||||
|
valid_features = valid_feature_list
|
||||||
|
features = features.compact
|
||||||
|
.map(&:strip)
|
||||||
|
.reject(&:empty?)
|
||||||
|
.select { |f| valid_features.include?(f) }
|
||||||
|
.uniq
|
||||||
|
|
||||||
|
set('manually_managed_features', features)
|
||||||
|
end
|
||||||
|
|
||||||
|
# Get list of valid features that can be manually managed
|
||||||
|
def valid_feature_list
|
||||||
|
# Business and Enterprise plan features only
|
||||||
|
Enterprise::Billing::HandleStripeEventService::BUSINESS_PLAN_FEATURES +
|
||||||
|
Enterprise::Billing::HandleStripeEventService::ENTERPRISE_PLAN_FEATURES
|
||||||
|
end
|
||||||
|
|
||||||
|
# Account notes functionality removed for now
|
||||||
|
# Will be re-implemented when UI is ready
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def validate_key!(key)
|
||||||
|
raise ArgumentError, "Invalid internal attribute key: #{key}" unless VALID_KEYS.include?(key)
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
<%
|
||||||
|
# Get all feature names and their display names
|
||||||
|
all_feature_display_names = SuperAdmin::AccountFeaturesHelper.feature_display_names
|
||||||
|
|
||||||
|
# Business and Enterprise plan features only
|
||||||
|
premium_features = Enterprise::Billing::HandleStripeEventService::BUSINESS_PLAN_FEATURES +
|
||||||
|
Enterprise::Billing::HandleStripeEventService::ENTERPRISE_PLAN_FEATURES
|
||||||
|
|
||||||
|
# Get only premium features with display names
|
||||||
|
premium_features_with_display = premium_features.map do |feature|
|
||||||
|
[feature, all_feature_display_names[feature] || feature.humanize]
|
||||||
|
end.sort_by { |_, display_name| display_name }
|
||||||
|
|
||||||
|
# Get already selected features
|
||||||
|
selected_features = field.selected_features
|
||||||
|
%>
|
||||||
|
|
||||||
|
<div class="field-unit__label">
|
||||||
|
<%= f.label :manually_managed_features %>
|
||||||
|
</div>
|
||||||
|
<div class="field-unit__field feature-container">
|
||||||
|
<p class="text-gray-400 text-xs italic mb-4">Features that remain enabled even when account plan is downgraded</p>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
|
<% premium_features_with_display.each do |feature_key, display_name| %>
|
||||||
|
<div class="flex items-center justify-between p-3 bg-white rounded-lg shadow-sm outline outline-1 outline-n-container">
|
||||||
|
<span class="text-sm text-slate-700"><%= display_name %></span>
|
||||||
|
<span>
|
||||||
|
<%= check_box_tag "account[manually_managed_features][]",
|
||||||
|
feature_key,
|
||||||
|
selected_features.include?(feature_key),
|
||||||
|
class: "h-4 w-4 rounded border-slate-300 text-indigo-600 focus:ring-indigo-600" %>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<hr class="my-8 border-t border-n-weak">
|
||||||
|
|
||||||
|
<%= hidden_field_tag "account[manually_managed_features][]", "", id: nil %>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
<%
|
||||||
|
selected_features = field.selected_features
|
||||||
|
|
||||||
|
# Get all feature names and their display names
|
||||||
|
all_feature_display_names = SuperAdmin::AccountFeaturesHelper.feature_display_names
|
||||||
|
|
||||||
|
# Business and Enterprise plan features only
|
||||||
|
premium_features = Enterprise::Billing::HandleStripeEventService::BUSINESS_PLAN_FEATURES +
|
||||||
|
Enterprise::Billing::HandleStripeEventService::ENTERPRISE_PLAN_FEATURES
|
||||||
|
%>
|
||||||
|
|
||||||
|
<% if selected_features.present? %>
|
||||||
|
<div class="w-full">
|
||||||
|
<p class="text-gray-400 text-xs italic mb-2">Features that remain enabled even when account plan is downgraded</p>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
|
||||||
|
<% selected_features.each do |feature| %>
|
||||||
|
<div class="flex items-center justify-between p-3 bg-white rounded-md outline outline-n-container outline-1 shadow-sm">
|
||||||
|
<span class="text-sm text-n-slate-12"><%= all_feature_display_names[feature] || feature.humanize %></span>
|
||||||
|
<span class="bg-green-400 text-white rounded-full p-1 inline-flex right-4 top-5">
|
||||||
|
<svg width="12" height="12"><use xlink:href="#icon-tick-line" /></svg>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<hr class="my-8 border-t border-n-weak">
|
||||||
|
</div>
|
||||||
|
<% else %>
|
||||||
|
<p class="text-gray-400 text-xs italic">No manually managed features configured</p>
|
||||||
|
<% end %>
|
||||||
@@ -17,6 +17,10 @@ module ChatwootApp
|
|||||||
@enterprise ||= root.join('enterprise').exist?
|
@enterprise ||= root.join('enterprise').exist?
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def self.chatwoot_cloud?
|
||||||
|
enterprise? && GlobalConfig.get_value('DEPLOYMENT_ENV') == 'cloud'
|
||||||
|
end
|
||||||
|
|
||||||
def self.custom?
|
def self.custom?
|
||||||
@custom ||= root.join('custom').exist?
|
@custom ||= root.join('custom').exist?
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -9,120 +9,312 @@ describe Enterprise::Billing::HandleStripeEventService do
|
|||||||
let!(:account) { create(:account, custom_attributes: { stripe_customer_id: 'cus_123' }) }
|
let!(:account) { create(:account, custom_attributes: { stripe_customer_id: 'cus_123' }) }
|
||||||
|
|
||||||
before do
|
before do
|
||||||
|
# Create cloud plans configuration
|
||||||
|
create(:installation_config, {
|
||||||
|
name: 'CHATWOOT_CLOUD_PLANS',
|
||||||
|
value: [
|
||||||
|
{ 'name' => 'Hacker', 'product_id' => ['plan_id_hacker'], 'price_ids' => ['price_hacker'] },
|
||||||
|
{ 'name' => 'Startups', 'product_id' => ['plan_id_startups'], 'price_ids' => ['price_startups'] },
|
||||||
|
{ 'name' => 'Business', 'product_id' => ['plan_id_business'], 'price_ids' => ['price_business'] },
|
||||||
|
{ 'name' => 'Enterprise', 'product_id' => ['plan_id_enterprise'], 'price_ids' => ['price_enterprise'] }
|
||||||
|
]
|
||||||
|
})
|
||||||
|
# Setup common subscription mocks
|
||||||
allow(event).to receive(:data).and_return(data)
|
allow(event).to receive(:data).and_return(data)
|
||||||
allow(data).to receive(:object).and_return(subscription)
|
allow(data).to receive(:object).and_return(subscription)
|
||||||
allow(subscription).to receive(:[]).with('plan')
|
|
||||||
.and_return({
|
|
||||||
'id' => 'test', 'product' => 'plan_id', 'name' => 'plan_name'
|
|
||||||
})
|
|
||||||
allow(subscription).to receive(:[]).with('quantity').and_return('10')
|
allow(subscription).to receive(:[]).with('quantity').and_return('10')
|
||||||
allow(subscription).to receive(:[]).with('status').and_return('active')
|
allow(subscription).to receive(:[]).with('status').and_return('active')
|
||||||
allow(subscription).to receive(:[]).with('current_period_end').and_return(1_686_567_520)
|
allow(subscription).to receive(:[]).with('current_period_end').and_return(1_686_567_520)
|
||||||
allow(subscription).to receive(:customer).and_return('cus_123')
|
allow(subscription).to receive(:customer).and_return('cus_123')
|
||||||
create(:installation_config, {
|
allow(event).to receive(:type).and_return('customer.subscription.updated')
|
||||||
name: 'CHATWOOT_CLOUD_PLANS',
|
|
||||||
value: [
|
|
||||||
{
|
|
||||||
'name' => 'Hacker',
|
|
||||||
'product_id' => ['plan_id'],
|
|
||||||
'price_ids' => ['price_1']
|
|
||||||
},
|
|
||||||
{
|
|
||||||
'name' => 'Startups',
|
|
||||||
'product_id' => ['plan_id_2'],
|
|
||||||
'price_ids' => ['price_2']
|
|
||||||
}
|
|
||||||
]
|
|
||||||
})
|
|
||||||
end
|
end
|
||||||
|
|
||||||
describe '#perform' do
|
describe 'subscription update handling' do
|
||||||
context 'when it gets customer.subscription.updated event' do
|
it 'updates account attributes and disables premium features for default plan' do
|
||||||
it 'updates subscription attributes' do
|
# Setup for default (Hacker) plan
|
||||||
allow(event).to receive(:type).and_return('customer.subscription.updated')
|
allow(subscription).to receive(:[]).with('plan')
|
||||||
allow(subscription).to receive(:customer).and_return('cus_123')
|
.and_return({ 'id' => 'test', 'product' => 'plan_id_hacker', 'name' => 'Hacker' })
|
||||||
|
|
||||||
stripe_event_service.new.perform(event: event)
|
stripe_event_service.new.perform(event: event)
|
||||||
|
|
||||||
expect(account.reload.custom_attributes).to eq({
|
# Verify account attributes were updated
|
||||||
'captain_responses_usage' => 0,
|
expect(account.reload.custom_attributes).to include(
|
||||||
'stripe_customer_id' => 'cus_123',
|
|
||||||
'stripe_price_id' => 'test',
|
|
||||||
'stripe_product_id' => 'plan_id',
|
|
||||||
'plan_name' => 'Hacker',
|
'plan_name' => 'Hacker',
|
||||||
'subscribed_quantity' => '10',
|
'stripe_product_id' => 'plan_id_hacker',
|
||||||
'subscription_ends_on' => Time.zone.at(1_686_567_520).as_json,
|
|
||||||
'subscription_status' => 'active'
|
'subscription_status' => 'active'
|
||||||
})
|
)
|
||||||
|
|
||||||
|
# Verify premium features are disabled for default plan
|
||||||
|
expect(account).not_to be_feature_enabled('channel_email')
|
||||||
|
expect(account).not_to be_feature_enabled('help_center')
|
||||||
|
expect(account).not_to be_feature_enabled('sla')
|
||||||
|
expect(account).not_to be_feature_enabled('custom_roles')
|
||||||
|
expect(account).not_to be_feature_enabled('audit_logs')
|
||||||
end
|
end
|
||||||
|
|
||||||
it 'resets captain usage' do
|
it 'resets captain usage on subscription update' do
|
||||||
|
# Prime the account with some usage
|
||||||
5.times { account.increment_response_usage }
|
5.times { account.increment_response_usage }
|
||||||
expect(account.custom_attributes['captain_responses_usage']).to eq(5)
|
expect(account.custom_attributes['captain_responses_usage']).to eq(5)
|
||||||
|
|
||||||
allow(event).to receive(:type).and_return('customer.subscription.updated')
|
# Setup for any plan
|
||||||
allow(subscription).to receive(:customer).and_return('cus_123')
|
allow(subscription).to receive(:[]).with('plan')
|
||||||
|
.and_return({ 'id' => 'test', 'product' => 'plan_id_startups', 'name' => 'Startups' })
|
||||||
|
|
||||||
stripe_event_service.new.perform(event: event)
|
stripe_event_service.new.perform(event: event)
|
||||||
|
|
||||||
|
# Verify usage was reset
|
||||||
expect(account.reload.custom_attributes['captain_responses_usage']).to eq(0)
|
expect(account.reload.custom_attributes['captain_responses_usage']).to eq(0)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
it 'disable features on customer.subscription.updated for default plan' do
|
describe 'subscription deletion handling' do
|
||||||
allow(event).to receive(:type).and_return('customer.subscription.updated')
|
it 'calls CreateStripeCustomerService on subscription deletion' do
|
||||||
allow(subscription).to receive(:customer).and_return('cus_123')
|
allow(event).to receive(:type).and_return('customer.subscription.deleted')
|
||||||
|
|
||||||
|
# Create a double for the service
|
||||||
|
customer_service = double
|
||||||
|
allow(Enterprise::Billing::CreateStripeCustomerService).to receive(:new)
|
||||||
|
.with(account: account).and_return(customer_service)
|
||||||
|
allow(customer_service).to receive(:perform)
|
||||||
|
|
||||||
stripe_event_service.new.perform(event: event)
|
stripe_event_service.new.perform(event: event)
|
||||||
expect(account.reload.custom_attributes).to eq({
|
|
||||||
'captain_responses_usage' => 0,
|
# Verify the service was called
|
||||||
'stripe_customer_id' => 'cus_123',
|
expect(Enterprise::Billing::CreateStripeCustomerService).to have_received(:new)
|
||||||
'stripe_price_id' => 'test',
|
.with(account: account)
|
||||||
'stripe_product_id' => 'plan_id',
|
expect(customer_service).to have_received(:perform)
|
||||||
'plan_name' => 'Hacker',
|
end
|
||||||
'subscribed_quantity' => '10',
|
end
|
||||||
'subscription_ends_on' => Time.zone.at(1_686_567_520).as_json,
|
|
||||||
'subscription_status' => 'active'
|
describe 'plan-specific feature management' do
|
||||||
})
|
context 'with default plan (Hacker)' do
|
||||||
expect(account).not_to be_feature_enabled('channel_email')
|
it 'disables all premium features' do
|
||||||
|
allow(subscription).to receive(:[]).with('plan')
|
||||||
|
.and_return({ 'id' => 'test', 'product' => 'plan_id_hacker', 'name' => 'Hacker' })
|
||||||
|
|
||||||
|
# Enable features first
|
||||||
|
described_class::STARTUP_PLAN_FEATURES.each do |feature|
|
||||||
|
account.enable_features(feature)
|
||||||
|
end
|
||||||
|
account.enable_features(*described_class::BUSINESS_PLAN_FEATURES)
|
||||||
|
account.enable_features(*described_class::ENTERPRISE_PLAN_FEATURES)
|
||||||
|
account.save!
|
||||||
|
|
||||||
|
account.reload
|
||||||
|
expect(account).to be_feature_enabled(described_class::STARTUP_PLAN_FEATURES.first)
|
||||||
|
|
||||||
|
stripe_event_service.new.perform(event: event)
|
||||||
|
|
||||||
|
account.reload
|
||||||
|
|
||||||
|
all_features = described_class::STARTUP_PLAN_FEATURES +
|
||||||
|
described_class::BUSINESS_PLAN_FEATURES +
|
||||||
|
described_class::ENTERPRISE_PLAN_FEATURES
|
||||||
|
|
||||||
|
all_features.each do |feature|
|
||||||
|
expect(account).not_to be_feature_enabled(feature)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'with Startups plan' do
|
||||||
|
it 'enables common features but not premium features' do
|
||||||
|
allow(subscription).to receive(:[]).with('plan')
|
||||||
|
.and_return({ 'id' => 'test', 'product' => 'plan_id_startups', 'name' => 'Startups' })
|
||||||
|
|
||||||
|
stripe_event_service.new.perform(event: event)
|
||||||
|
|
||||||
|
# Verify basic (Startups) features are enabled
|
||||||
|
account.reload
|
||||||
|
described_class::STARTUP_PLAN_FEATURES.each do |feature|
|
||||||
|
expect(account).to be_feature_enabled(feature)
|
||||||
|
end
|
||||||
|
|
||||||
|
# But business and enterprise features should be disabled
|
||||||
|
described_class::BUSINESS_PLAN_FEATURES.each do |feature|
|
||||||
|
expect(account).not_to be_feature_enabled(feature)
|
||||||
|
end
|
||||||
|
|
||||||
|
described_class::ENTERPRISE_PLAN_FEATURES.each do |feature|
|
||||||
|
expect(account).not_to be_feature_enabled(feature)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'with Business plan' do
|
||||||
|
it 'enables business-specific features' do
|
||||||
|
allow(subscription).to receive(:[]).with('plan')
|
||||||
|
.and_return({ 'id' => 'test', 'product' => 'plan_id_business', 'name' => 'Business' })
|
||||||
|
|
||||||
|
stripe_event_service.new.perform(event: event)
|
||||||
|
|
||||||
|
account.reload
|
||||||
|
described_class::STARTUP_PLAN_FEATURES.each do |feature|
|
||||||
|
expect(account).to be_feature_enabled(feature)
|
||||||
|
end
|
||||||
|
|
||||||
|
described_class::BUSINESS_PLAN_FEATURES.each do |feature|
|
||||||
|
expect(account).to be_feature_enabled(feature)
|
||||||
|
end
|
||||||
|
|
||||||
|
described_class::ENTERPRISE_PLAN_FEATURES.each do |feature|
|
||||||
|
expect(account).not_to be_feature_enabled(feature)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'with Enterprise plan' do
|
||||||
|
it 'enables all business and enterprise features' do
|
||||||
|
allow(subscription).to receive(:[]).with('plan')
|
||||||
|
.and_return({ 'id' => 'test', 'product' => 'plan_id_enterprise', 'name' => 'Enterprise' })
|
||||||
|
|
||||||
|
stripe_event_service.new.perform(event: event)
|
||||||
|
|
||||||
|
account.reload
|
||||||
|
described_class::STARTUP_PLAN_FEATURES.each do |feature|
|
||||||
|
expect(account).to be_feature_enabled(feature)
|
||||||
|
end
|
||||||
|
|
||||||
|
described_class::BUSINESS_PLAN_FEATURES.each do |feature|
|
||||||
|
expect(account).to be_feature_enabled(feature)
|
||||||
|
end
|
||||||
|
|
||||||
|
described_class::ENTERPRISE_PLAN_FEATURES.each do |feature|
|
||||||
|
expect(account).to be_feature_enabled(feature)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe 'manually managed features' do
|
||||||
|
let(:service) { stripe_event_service.new }
|
||||||
|
let(:internal_attrs_service) { instance_double(Internal::Accounts::InternalAttributesService) }
|
||||||
|
|
||||||
|
before do
|
||||||
|
# Mock the internal attributes service
|
||||||
|
allow(Internal::Accounts::InternalAttributesService).to receive(:new).with(account).and_return(internal_attrs_service)
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'when downgrading with manually managed features' do
|
||||||
|
it 'preserves manually managed features even when downgrading plans' do
|
||||||
|
# Setup: account has Enterprise plan with manually managed features
|
||||||
|
allow(subscription).to receive(:[]).with('plan')
|
||||||
|
.and_return({ 'id' => 'test', 'product' => 'plan_id_enterprise', 'name' => 'Enterprise' })
|
||||||
|
|
||||||
|
# Mock manually managed features
|
||||||
|
allow(internal_attrs_service).to receive(:manually_managed_features).and_return(%w[audit_logs custom_roles])
|
||||||
|
|
||||||
|
# First run to apply enterprise plan
|
||||||
|
service.perform(event: event)
|
||||||
|
account.reload
|
||||||
|
|
||||||
|
# Verify features are enabled
|
||||||
|
expect(account).to be_feature_enabled('audit_logs')
|
||||||
|
expect(account).to be_feature_enabled('custom_roles')
|
||||||
|
|
||||||
|
# Now downgrade to Hacker plan (which normally wouldn't have these features)
|
||||||
|
allow(subscription).to receive(:[]).with('plan')
|
||||||
|
.and_return({ 'id' => 'test', 'product' => 'plan_id_hacker', 'name' => 'Hacker' })
|
||||||
|
|
||||||
|
service.perform(event: event)
|
||||||
|
account.reload
|
||||||
|
|
||||||
|
# Manually managed features should still be enabled despite plan downgrade
|
||||||
|
expect(account).to be_feature_enabled('audit_logs')
|
||||||
|
expect(account).to be_feature_enabled('custom_roles')
|
||||||
|
|
||||||
|
# But other premium features should be disabled
|
||||||
|
expect(account).not_to be_feature_enabled('channel_instagram')
|
||||||
expect(account).not_to be_feature_enabled('help_center')
|
expect(account).not_to be_feature_enabled('help_center')
|
||||||
end
|
end
|
||||||
|
|
||||||
it 'handles customer.subscription.deleted' do
|
|
||||||
stripe_customer_service = double
|
|
||||||
allow(event).to receive(:type).and_return('customer.subscription.deleted')
|
|
||||||
allow(Enterprise::Billing::CreateStripeCustomerService).to receive(:new).and_return(stripe_customer_service)
|
|
||||||
allow(stripe_customer_service).to receive(:perform)
|
|
||||||
stripe_event_service.new.perform(event: event)
|
|
||||||
expect(Enterprise::Billing::CreateStripeCustomerService).to have_received(:new).with(account: account)
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
describe '#perform for Startups plan' do
|
describe 'downgrade handling' do
|
||||||
|
let(:service) { stripe_event_service.new }
|
||||||
|
|
||||||
before do
|
before do
|
||||||
allow(event).to receive(:data).and_return(data)
|
# Setup internal attributes service mock to return no manually managed features
|
||||||
allow(data).to receive(:object).and_return(subscription)
|
internal_attrs_service = instance_double(Internal::Accounts::InternalAttributesService)
|
||||||
allow(subscription).to receive(:[]).with('plan')
|
allow(Internal::Accounts::InternalAttributesService).to receive(:new).with(account).and_return(internal_attrs_service)
|
||||||
.and_return({
|
allow(internal_attrs_service).to receive(:manually_managed_features).and_return([])
|
||||||
'id' => 'test', 'product' => 'plan_id_2', 'name' => 'plan_name'
|
|
||||||
})
|
|
||||||
allow(subscription).to receive(:[]).with('quantity').and_return('10')
|
|
||||||
allow(subscription).to receive(:customer).and_return('cus_123')
|
|
||||||
end
|
end
|
||||||
|
|
||||||
it 'enable features on customer.subscription.updated' do
|
context 'when downgrading from Enterprise to Business plan' do
|
||||||
allow(event).to receive(:type).and_return('customer.subscription.updated')
|
before do
|
||||||
allow(subscription).to receive(:customer).and_return('cus_123')
|
# Start with Enterprise plan
|
||||||
stripe_event_service.new.perform(event: event)
|
allow(subscription).to receive(:[]).with('plan')
|
||||||
expect(account.reload.custom_attributes).to eq({
|
.and_return({ 'id' => 'test', 'product' => 'plan_id_enterprise', 'name' => 'Enterprise' })
|
||||||
'captain_responses_usage' => 0,
|
service.perform(event: event)
|
||||||
'stripe_customer_id' => 'cus_123',
|
account.reload
|
||||||
'stripe_price_id' => 'test',
|
end
|
||||||
'stripe_product_id' => 'plan_id_2',
|
|
||||||
'plan_name' => 'Startups',
|
it 'retains business features but disables enterprise features' do
|
||||||
'subscribed_quantity' => '10',
|
# Verify enterprise features were enabled
|
||||||
'subscription_ends_on' => Time.zone.at(1_686_567_520).as_json,
|
expect(account).to be_feature_enabled('audit_logs')
|
||||||
'subscription_status' => 'active'
|
|
||||||
})
|
# Downgrade to Business plan
|
||||||
expect(account).to be_feature_enabled('channel_email')
|
allow(subscription).to receive(:[]).with('plan')
|
||||||
expect(account).to be_feature_enabled('help_center')
|
.and_return({ 'id' => 'test', 'product' => 'plan_id_business', 'name' => 'Business' })
|
||||||
|
service.perform(event: event)
|
||||||
|
|
||||||
|
account.reload
|
||||||
|
expect(account).to be_feature_enabled('sla')
|
||||||
|
expect(account).to be_feature_enabled('custom_roles')
|
||||||
|
expect(account).not_to be_feature_enabled('audit_logs')
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'when downgrading from Business to Startups plan' do
|
||||||
|
before do
|
||||||
|
# Start with Business plan
|
||||||
|
allow(subscription).to receive(:[]).with('plan')
|
||||||
|
.and_return({ 'id' => 'test', 'product' => 'plan_id_business', 'name' => 'Business' })
|
||||||
|
service.perform(event: event)
|
||||||
|
account.reload
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'retains startup features but disables business features' do
|
||||||
|
# Verify business features were enabled
|
||||||
|
expect(account).to be_feature_enabled('sla')
|
||||||
|
|
||||||
|
# Downgrade to Startups plan
|
||||||
|
allow(subscription).to receive(:[]).with('plan')
|
||||||
|
.and_return({ 'id' => 'test', 'product' => 'plan_id_startups', 'name' => 'Startups' })
|
||||||
|
service.perform(event: event)
|
||||||
|
|
||||||
|
account.reload
|
||||||
|
# Spot check one startup feature
|
||||||
|
expect(account).to be_feature_enabled('channel_instagram')
|
||||||
|
expect(account).not_to be_feature_enabled('sla')
|
||||||
|
expect(account).not_to be_feature_enabled('custom_roles')
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'when downgrading from Startups to Hacker plan' do
|
||||||
|
before do
|
||||||
|
# Start with Startups plan
|
||||||
|
allow(subscription).to receive(:[]).with('plan')
|
||||||
|
.and_return({ 'id' => 'test', 'product' => 'plan_id_startups', 'name' => 'Startups' })
|
||||||
|
service.perform(event: event)
|
||||||
|
account.reload
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'disables all premium features' do
|
||||||
|
# Verify startup features were enabled
|
||||||
|
expect(account).to be_feature_enabled('channel_instagram')
|
||||||
|
|
||||||
|
# Downgrade to Hacker (default) plan
|
||||||
|
allow(subscription).to receive(:[]).with('plan')
|
||||||
|
.and_return({ 'id' => 'test', 'product' => 'plan_id_hacker', 'name' => 'Hacker' })
|
||||||
|
service.perform(event: event)
|
||||||
|
|
||||||
|
account.reload
|
||||||
|
# Spot check that premium features are disabled
|
||||||
|
expect(account).not_to be_feature_enabled('channel_instagram')
|
||||||
|
expect(account).not_to be_feature_enabled('help_center')
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -0,0 +1,134 @@
|
|||||||
|
require 'rails_helper'
|
||||||
|
|
||||||
|
RSpec.describe Internal::Accounts::InternalAttributesService do
|
||||||
|
let!(:account) { create(:account, internal_attributes: { 'test_key' => 'test_value' }) }
|
||||||
|
let(:service) { described_class.new(account) }
|
||||||
|
let(:business_features) { Enterprise::Billing::HandleStripeEventService::BUSINESS_PLAN_FEATURES }
|
||||||
|
let(:enterprise_features) { Enterprise::Billing::HandleStripeEventService::ENTERPRISE_PLAN_FEATURES }
|
||||||
|
|
||||||
|
describe '#initialize' do
|
||||||
|
it 'sets the account' do
|
||||||
|
expect(service.account).to eq(account)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe '#get' do
|
||||||
|
it 'returns the value for a valid key' do
|
||||||
|
# Manually set the value first since the key needs to be in VALID_KEYS
|
||||||
|
allow(service).to receive(:validate_key!).and_return(true)
|
||||||
|
account.internal_attributes['manually_managed_features'] = ['test']
|
||||||
|
|
||||||
|
expect(service.get('manually_managed_features')).to eq(['test'])
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'raises an error for an invalid key' do
|
||||||
|
expect { service.get('invalid_key') }.to raise_error(ArgumentError, 'Invalid internal attribute key: invalid_key')
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe '#set' do
|
||||||
|
it 'sets the value for a valid key' do
|
||||||
|
# Stub the validation to allow our test key
|
||||||
|
allow(service).to receive(:validate_key!).and_return(true)
|
||||||
|
|
||||||
|
service.set('manually_managed_features', %w[feature1 feature2])
|
||||||
|
account.reload
|
||||||
|
|
||||||
|
expect(account.internal_attributes['manually_managed_features']).to eq(%w[feature1 feature2])
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'raises an error for an invalid key' do
|
||||||
|
expect { service.set('invalid_key', 'value') }.to raise_error(ArgumentError, 'Invalid internal attribute key: invalid_key')
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'creates internal_attributes hash if it is empty' do
|
||||||
|
account.update(internal_attributes: {})
|
||||||
|
|
||||||
|
# Stub the validation to allow our test key
|
||||||
|
allow(service).to receive(:validate_key!).and_return(true)
|
||||||
|
|
||||||
|
service.set('manually_managed_features', ['feature1'])
|
||||||
|
account.reload
|
||||||
|
|
||||||
|
expect(account.internal_attributes['manually_managed_features']).to eq(['feature1'])
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe '#manually_managed_features' do
|
||||||
|
it 'returns an empty array when no features are set' do
|
||||||
|
expect(service.manually_managed_features).to eq([])
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'returns the features when they are set' do
|
||||||
|
account.update(internal_attributes: { 'manually_managed_features' => %w[feature1 feature2] })
|
||||||
|
|
||||||
|
expect(service.manually_managed_features).to eq(%w[feature1 feature2])
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe '#manually_managed_features=' do
|
||||||
|
# Use a real SLA feature which is in the BUSINESS_PLAN_FEATURES
|
||||||
|
let(:valid_feature) { 'sla' }
|
||||||
|
|
||||||
|
before do
|
||||||
|
# Make sure the feature is allowed through validation
|
||||||
|
allow(service).to receive(:valid_feature_list).and_return([valid_feature, 'custom_roles'])
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'saves features as an array' do
|
||||||
|
service.manually_managed_features = valid_feature
|
||||||
|
account.reload
|
||||||
|
|
||||||
|
expect(account.internal_attributes['manually_managed_features']).to eq([valid_feature])
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'handles nil input' do
|
||||||
|
service.manually_managed_features = nil
|
||||||
|
account.reload
|
||||||
|
|
||||||
|
expect(account.internal_attributes['manually_managed_features']).to eq([])
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'handles array input' do
|
||||||
|
service.manually_managed_features = [valid_feature, 'custom_roles']
|
||||||
|
account.reload
|
||||||
|
|
||||||
|
expect(account.internal_attributes['manually_managed_features']).to eq([valid_feature, 'custom_roles'])
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'filters out invalid features' do
|
||||||
|
service.manually_managed_features = [valid_feature, 'invalid_feature']
|
||||||
|
account.reload
|
||||||
|
|
||||||
|
expect(account.internal_attributes['manually_managed_features']).to eq([valid_feature])
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'removes duplicates' do
|
||||||
|
service.manually_managed_features = [valid_feature, valid_feature]
|
||||||
|
account.reload
|
||||||
|
|
||||||
|
expect(account.internal_attributes['manually_managed_features']).to eq([valid_feature])
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'removes empty strings' do
|
||||||
|
service.manually_managed_features = [valid_feature, '', ' ']
|
||||||
|
account.reload
|
||||||
|
|
||||||
|
expect(account.internal_attributes['manually_managed_features']).to eq([valid_feature])
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'trims whitespace' do
|
||||||
|
service.manually_managed_features = [" #{valid_feature} "]
|
||||||
|
account.reload
|
||||||
|
|
||||||
|
expect(account.internal_attributes['manually_managed_features']).to eq([valid_feature])
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe '#valid_feature_list' do
|
||||||
|
it 'returns a combination of business and enterprise features' do
|
||||||
|
expect(service.valid_feature_list).to include(*business_features)
|
||||||
|
expect(service.valid_feature_list).to include(*enterprise_features)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
Reference in New Issue
Block a user