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:
@@ -58,8 +58,7 @@ class Enterprise::Api::V1::AccountsController < Api::BaseController
|
||||
private
|
||||
|
||||
def check_cloud_env
|
||||
installation_config = InstallationConfig.find_by(name: 'DEPLOYMENT_ENV')
|
||||
render json: { error: 'Not found' }, status: :not_found unless installation_config&.value == 'cloud'
|
||||
render json: { error: 'Not found' }, status: :not_found unless ChatwootApp.chatwoot_cloud?
|
||||
end
|
||||
|
||||
def default_limits
|
||||
|
||||
@@ -1,12 +1,3 @@
|
||||
module Enterprise::Concerns::ApplicationControllerConcern
|
||||
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
|
||||
|
||||
@@ -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
|
||||
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
|
||||
|
||||
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
|
||||
7
enterprise/app/fields/account_limits_field.rb
Normal file
7
enterprise/app/fields/account_limits_field.rb
Normal file
@@ -0,0 +1,7 @@
|
||||
require 'administrate/field/base'
|
||||
|
||||
class AccountLimitsField < Administrate::Field::Base
|
||||
def to_s
|
||||
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
|
||||
|
||||
def perform(account)
|
||||
return if GlobalConfig.get_value('DEPLOYMENT_ENV') != 'cloud'
|
||||
return unless ChatwootApp.chatwoot_cloud?
|
||||
|
||||
Internal::AccountAnalysis::ThreatAnalyserService.new(account).perform
|
||||
end
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
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')
|
||||
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
|
||||
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:)
|
||||
ensure_event_context(event)
|
||||
@event = event
|
||||
|
||||
case @event.type
|
||||
when 'customer.subscription.updated'
|
||||
process_subscription_updated
|
||||
@@ -20,14 +45,12 @@ class Enterprise::Billing::HandleStripeEventService
|
||||
return if plan.blank? || account.blank?
|
||||
|
||||
update_account_attributes(subscription, plan)
|
||||
|
||||
change_plan_features
|
||||
update_plan_features
|
||||
reset_captain_usage
|
||||
end
|
||||
|
||||
def update_account_attributes(subscription, plan)
|
||||
# https://stripe.com/docs/api/subscriptions/object
|
||||
|
||||
account.update(
|
||||
custom_attributes: {
|
||||
stripe_customer_id: subscription.customer,
|
||||
@@ -48,25 +71,57 @@ class Enterprise::Billing::HandleStripeEventService
|
||||
Enterprise::Billing::CreateStripeCustomerService.new(account: account).perform
|
||||
end
|
||||
|
||||
def change_plan_features
|
||||
def update_plan_features
|
||||
if default_plan?
|
||||
account.disable_features(*features_to_update)
|
||||
disable_all_premium_features
|
||||
else
|
||||
account.enable_features(*features_to_update)
|
||||
enable_features_for_current_plan
|
||||
end
|
||||
|
||||
# Enable any manually managed features configured in internal_attributes
|
||||
enable_account_manually_managed_features
|
||||
|
||||
account.save!
|
||||
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
|
||||
account.reset_response_usage
|
||||
end
|
||||
|
||||
def ensure_event_context(event)
|
||||
@event = event
|
||||
end
|
||||
def enable_plan_specific_features
|
||||
plan_name = account.custom_attributes['plan_name']
|
||||
return if plan_name.blank?
|
||||
|
||||
def features_to_update
|
||||
%w[inbound_emails help_center campaigns team_management channel_twitter channel_facebook channel_email captain_integration]
|
||||
# Enable features based on plan hierarchy
|
||||
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
|
||||
|
||||
def subscription
|
||||
@@ -78,13 +133,22 @@ class Enterprise::Billing::HandleStripeEventService
|
||||
end
|
||||
|
||||
def find_plan(plan_id)
|
||||
installation_config = InstallationConfig.find_by(name: 'CHATWOOT_CLOUD_PLANS')
|
||||
installation_config.value.find { |config| config['product_id'].include?(plan_id) }
|
||||
cloud_plans = InstallationConfig.find_by(name: CLOUD_PLANS_CONFIG)&.value || []
|
||||
cloud_plans.find { |config| config['product_id'].include?(plan_id) }
|
||||
end
|
||||
|
||||
def default_plan?
|
||||
installation_config = InstallationConfig.find_by(name: 'CHATWOOT_CLOUD_PLANS')
|
||||
default_plan = installation_config.value.first
|
||||
@account.custom_attributes['plan_name'] == default_plan['name']
|
||||
cloud_plans = InstallationConfig.find_by(name: CLOUD_PLANS_CONFIG)&.value || []
|
||||
default_plan = cloud_plans.first || {}
|
||||
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
|
||||
|
||||
@@ -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,34 @@
|
||||
<div class="field-unit__label">
|
||||
<%= f.label field.attribute %>
|
||||
</div>
|
||||
<div class="field-unit__field feature-container">
|
||||
<% regular_features, premium_features = SuperAdmin::AccountFeaturesHelper.filtered_features(field.data).partition { |key_array, _val| !SuperAdmin::AccountFeaturesHelper.account_premium_features.include?(key_array.first) } %>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
<% regular_features.each do |key_array, val| %>
|
||||
<% feature_key, display_name = key_array %>
|
||||
<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 "enabled_features", "feature_#{feature_key}", { checked: val, class: "h-4 w-4 rounded border-slate-300 text-indigo-600 focus:ring-indigo-600" }, true, false %></span>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<hr class="my-8 boshadow-sm outline outline-1 outline-n-container">
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
<% premium_features.each do |key_array, val| %>
|
||||
<% feature_key, display_name = key_array %>
|
||||
<div class="flex items-center justify-between p-3 bg-white rounded-lg shadow-sm outline outline-1 outline-n-container">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-amber-500">
|
||||
<svg class="h-4 w-4" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><path d="M480 224l-186.828 7.487L401.688 64l-59.247-32L256 208 169.824 32l-59.496 32 108.5 167.487L32 224v64l185.537-10.066L113.65 448l55.969 32L256 304l86.381 176 55.949-32-103.867-170.066L480 288z" fill="currentColor"/></svg>
|
||||
</span>
|
||||
<span class="text-sm text-slate-700"><%= display_name %></span>
|
||||
</div>
|
||||
<% should_disable = ChatwootHub.pricing_plan == 'community' %>
|
||||
<span><%= check_box "enabled_features", "feature_#{feature_key}", { checked: val, disabled: should_disable, class: "h-4 w-4 rounded border-slate-300 text-indigo-600 focus:ring-indigo-600" }, true, false %></span>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,34 @@
|
||||
<div class="w-full">
|
||||
<% regular_features, premium_features = SuperAdmin::AccountFeaturesHelper.partition_features(field.data) %>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
|
||||
<% regular_features.each do |key_array, val| %>
|
||||
<% feature_key, display_name = key_array %>
|
||||
<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"><%= display_name %></span>
|
||||
<span class="<%= val.present? ? 'bg-green-400 text-white': 'bg-slate-50 text-slate-800' %> 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 class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
|
||||
<% premium_features.each do |key_array, val| %>
|
||||
<% feature_key, display_name = key_array %>
|
||||
<div class="flex items-center justify-between p-3 bg-white rounded-md outline outline-n-container outline-1 shadow-sm">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="bg-n-amber-3 text-n-amber-12 rounded-full p-1 inline-flex right-4 top-5">
|
||||
<svg width="12" height="12"><use xlink:href="#icon-lock-line" /></svg>
|
||||
</span>
|
||||
<span class="text-sm text-n-slate-12"><%= display_name %></span>
|
||||
</div>
|
||||
<span class="<%= val.present? ? 'bg-green-400 text-white': 'bg-slate-50 text-slate-800' %> 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>
|
||||
</div>
|
||||
@@ -0,0 +1,9 @@
|
||||
<div class="field-unit__label">
|
||||
<%= f.label field.attribute %>
|
||||
</div>
|
||||
<div class="field-unit__field">
|
||||
|
||||
<% JSON.parse(field.to_s).each do |key,val| %>
|
||||
<%= key %>: <%= number_field "account[limits]", key, value: val %> </br>
|
||||
<% end %>
|
||||
</div>
|
||||
@@ -0,0 +1 @@
|
||||
<%= field.to_s %>
|
||||
@@ -0,0 +1,3 @@
|
||||
<% JSON.parse(field.to_s).each do |k,v| %>
|
||||
<%= k %>: <%= v %> </br>
|
||||
<% 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 %>
|
||||
Reference in New Issue
Block a user