diff --git a/app/controllers/super_admin/accounts_controller.rb b/app/controllers/super_admin/accounts_controller.rb index 5de25f677..27ce587f7 100644 --- a/app/controllers/super_admin/accounts_controller.rb +++ b/app/controllers/super_admin/accounts_controller.rb @@ -66,3 +66,5 @@ class SuperAdmin::AccountsController < SuperAdmin::ApplicationController # rubocop:enable Rails/I18nLocaleTexts end end + +SuperAdmin::AccountsController.prepend_mod_with('SuperAdmin::AccountsController') diff --git a/app/dashboards/account_dashboard.rb b/app/dashboards/account_dashboard.rb index 0bf4e44ca..9be674f11 100644 --- a/app/dashboards/account_dashboard.rb +++ b/app/dashboards/account_dashboard.rb @@ -9,10 +9,17 @@ class AccountDashboard < Administrate::BaseDashboard # on pages throughout the dashboard. enterprise_attribute_types = if ChatwootApp.enterprise? - { - limits: Enterprise::AccountLimitsField, - all_features: Enterprise::AccountFeaturesField + attributes = { + limits: AccountLimitsField } + + # 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 {} end @@ -46,7 +53,14 @@ class AccountDashboard < Administrate::BaseDashboard # SHOW_PAGE_ATTRIBUTES # 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[ id name @@ -61,7 +75,14 @@ class AccountDashboard < Administrate::BaseDashboard # FORM_ATTRIBUTES # an array of attributes that will be displayed # 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[ name locale @@ -96,6 +117,11 @@ class AccountDashboard < Administrate::BaseDashboard # to prevent an error from being raised (wrong number of arguments) # Reference: https://github.com/thoughtbot/administrate/pull/2356/files#diff-4e220b661b88f9a19ac527c50d6f1577ef6ab7b0bed2bfdf048e22e6bfa74a05R204 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 diff --git a/app/fields/enterprise/account_features_field.rb b/app/fields/enterprise/account_features_field.rb deleted file mode 100644 index 7a9de5a59..000000000 --- a/app/fields/enterprise/account_features_field.rb +++ /dev/null @@ -1,7 +0,0 @@ -require 'administrate/field/base' - -class Enterprise::AccountFeaturesField < Administrate::Field::Base - def to_s - data - end -end diff --git a/app/helpers/super_admin/account_features_helper.rb b/app/helpers/super_admin/account_features_helper.rb index c24e65e68..9f02a72da 100644 --- a/app/helpers/super_admin/account_features_helper.rb +++ b/app/helpers/super_admin/account_features_helper.rb @@ -15,7 +15,7 @@ module SuperAdmin::AccountFeaturesHelper end 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') features.except(*internal_features) diff --git a/config/application.rb b/config/application.rb index 34fa37dc3..5316e65bf 100644 --- a/config/application.rb +++ b/config/application.rb @@ -44,6 +44,8 @@ module Chatwoot # rubocop:disable Rails/FilePath config.eager_load_paths += Dir["#{Rails.root}/enterprise/app/**"] # 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. # Application configuration can go into files in config/initializers diff --git a/enterprise/app/controllers/enterprise/api/v1/accounts_controller.rb b/enterprise/app/controllers/enterprise/api/v1/accounts_controller.rb index 70f1d177d..95b85a472 100644 --- a/enterprise/app/controllers/enterprise/api/v1/accounts_controller.rb +++ b/enterprise/app/controllers/enterprise/api/v1/accounts_controller.rb @@ -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 diff --git a/enterprise/app/controllers/enterprise/concerns/application_controller_concern.rb b/enterprise/app/controllers/enterprise/concerns/application_controller_concern.rb index a44a0eceb..40115ae02 100644 --- a/enterprise/app/controllers/enterprise/concerns/application_controller_concern.rb +++ b/enterprise/app/controllers/enterprise/concerns/application_controller_concern.rb @@ -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 diff --git a/enterprise/app/controllers/enterprise/super_admin/accounts_controller.rb b/enterprise/app/controllers/enterprise/super_admin/accounts_controller.rb new file mode 100644 index 000000000..305699dcb --- /dev/null +++ b/enterprise/app/controllers/enterprise/super_admin/accounts_controller.rb @@ -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 diff --git a/enterprise/app/controllers/super_admin/enterprise_base_controller.rb b/enterprise/app/controllers/super_admin/enterprise_base_controller.rb index b2108332a..503331107 100644 --- a/enterprise/app/controllers/super_admin/enterprise_base_controller.rb +++ b/enterprise/app/controllers/super_admin/enterprise_base_controller.rb @@ -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 diff --git a/enterprise/app/fields/account_features_field.rb b/enterprise/app/fields/account_features_field.rb new file mode 100644 index 000000000..2b933b8a3 --- /dev/null +++ b/enterprise/app/fields/account_features_field.rb @@ -0,0 +1,7 @@ +require 'administrate/field/base' + +class AccountFeaturesField < Administrate::Field::Base + def to_s + data + end +end diff --git a/app/fields/enterprise/account_limits_field.rb b/enterprise/app/fields/account_limits_field.rb similarity index 73% rename from app/fields/enterprise/account_limits_field.rb rename to enterprise/app/fields/account_limits_field.rb index b014435c6..b6aecd79f 100644 --- a/app/fields/enterprise/account_limits_field.rb +++ b/enterprise/app/fields/account_limits_field.rb @@ -1,6 +1,6 @@ require 'administrate/field/base' -class Enterprise::AccountLimitsField < 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 diff --git a/enterprise/app/fields/manually_managed_features_field.rb b/enterprise/app/fields/manually_managed_features_field.rb new file mode 100644 index 000000000..df76f4099 --- /dev/null +++ b/enterprise/app/fields/manually_managed_features_field.rb @@ -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 diff --git a/enterprise/app/jobs/internal/account_analysis_job.rb b/enterprise/app/jobs/internal/account_analysis_job.rb index 49ddaa2b5..76d04aa60 100644 --- a/enterprise/app/jobs/internal/account_analysis_job.rb +++ b/enterprise/app/jobs/internal/account_analysis_job.rb @@ -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 diff --git a/enterprise/app/models/enterprise/account.rb b/enterprise/app/models/enterprise/account.rb index 37bffc5a6..fae44dab6 100644 --- a/enterprise/app/models/enterprise/account.rb +++ b/enterprise/app/models/enterprise/account.rb @@ -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 diff --git a/enterprise/app/services/enterprise/billing/handle_stripe_event_service.rb b/enterprise/app/services/enterprise/billing/handle_stripe_event_service.rb index 220147a89..a21afff3c 100644 --- a/enterprise/app/services/enterprise/billing/handle_stripe_event_service.rb +++ b/enterprise/app/services/enterprise/billing/handle_stripe_event_service.rb @@ -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 diff --git a/enterprise/app/services/internal/accounts/internal_attributes_service.rb b/enterprise/app/services/internal/accounts/internal_attributes_service.rb new file mode 100644 index 000000000..7bc69d0a4 --- /dev/null +++ b/enterprise/app/services/internal/accounts/internal_attributes_service.rb @@ -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 diff --git a/app/views/fields/account_features_field/_form.html.erb b/enterprise/app/views/fields/account_features_field/_form.html.erb similarity index 100% rename from app/views/fields/account_features_field/_form.html.erb rename to enterprise/app/views/fields/account_features_field/_form.html.erb diff --git a/app/views/fields/account_features_field/_show.html.erb b/enterprise/app/views/fields/account_features_field/_show.html.erb similarity index 100% rename from app/views/fields/account_features_field/_show.html.erb rename to enterprise/app/views/fields/account_features_field/_show.html.erb diff --git a/app/views/fields/account_limits_field/_form.html.erb b/enterprise/app/views/fields/account_limits_field/_form.html.erb similarity index 100% rename from app/views/fields/account_limits_field/_form.html.erb rename to enterprise/app/views/fields/account_limits_field/_form.html.erb diff --git a/app/views/fields/account_limits_field/_index.html.erb b/enterprise/app/views/fields/account_limits_field/_index.html.erb similarity index 100% rename from app/views/fields/account_limits_field/_index.html.erb rename to enterprise/app/views/fields/account_limits_field/_index.html.erb diff --git a/app/views/fields/account_limits_field/_show.html.erb b/enterprise/app/views/fields/account_limits_field/_show.html.erb similarity index 100% rename from app/views/fields/account_limits_field/_show.html.erb rename to enterprise/app/views/fields/account_limits_field/_show.html.erb diff --git a/enterprise/app/views/fields/manually_managed_features_field/_form.html.erb b/enterprise/app/views/fields/manually_managed_features_field/_form.html.erb new file mode 100644 index 000000000..b84da3d5c --- /dev/null +++ b/enterprise/app/views/fields/manually_managed_features_field/_form.html.erb @@ -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 +%> + +
Features that remain enabled even when account plan is downgraded
+ +Features that remain enabled even when account plan is downgraded
+ +No manually managed features configured
+<% end %> \ No newline at end of file diff --git a/lib/chatwoot_app.rb b/lib/chatwoot_app.rb index 37c90c19d..8c64466ab 100644 --- a/lib/chatwoot_app.rb +++ b/lib/chatwoot_app.rb @@ -17,6 +17,10 @@ module ChatwootApp @enterprise ||= root.join('enterprise').exist? end + def self.chatwoot_cloud? + enterprise? && GlobalConfig.get_value('DEPLOYMENT_ENV') == 'cloud' + end + def self.custom? @custom ||= root.join('custom').exist? end diff --git a/spec/enterprise/services/enterprise/billing/handle_stripe_event_service_spec.rb b/spec/enterprise/services/enterprise/billing/handle_stripe_event_service_spec.rb index d6d2e187e..67ccd168b 100644 --- a/spec/enterprise/services/enterprise/billing/handle_stripe_event_service_spec.rb +++ b/spec/enterprise/services/enterprise/billing/handle_stripe_event_service_spec.rb @@ -9,120 +9,312 @@ describe Enterprise::Billing::HandleStripeEventService do let!(:account) { create(:account, custom_attributes: { stripe_customer_id: 'cus_123' }) } 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(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('status').and_return('active') allow(subscription).to receive(:[]).with('current_period_end').and_return(1_686_567_520) allow(subscription).to receive(:customer).and_return('cus_123') - create(:installation_config, { - 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'] - } - ] - }) + allow(event).to receive(:type).and_return('customer.subscription.updated') end - describe '#perform' do - context 'when it gets customer.subscription.updated event' do - it 'updates subscription attributes' do - allow(event).to receive(:type).and_return('customer.subscription.updated') - allow(subscription).to receive(:customer).and_return('cus_123') - stripe_event_service.new.perform(event: event) + describe 'subscription update handling' do + it 'updates account attributes and disables premium features for default plan' do + # Setup for default (Hacker) plan + allow(subscription).to receive(:[]).with('plan') + .and_return({ 'id' => 'test', 'product' => 'plan_id_hacker', 'name' => 'Hacker' }) - expect(account.reload.custom_attributes).to eq({ - 'captain_responses_usage' => 0, - 'stripe_customer_id' => 'cus_123', - 'stripe_price_id' => 'test', - 'stripe_product_id' => 'plan_id', - 'plan_name' => 'Hacker', - 'subscribed_quantity' => '10', - 'subscription_ends_on' => Time.zone.at(1_686_567_520).as_json, - 'subscription_status' => 'active' - }) - end - - it 'resets captain usage' do - 5.times { account.increment_response_usage } - expect(account.custom_attributes['captain_responses_usage']).to eq(5) - - allow(event).to receive(:type).and_return('customer.subscription.updated') - allow(subscription).to receive(:customer).and_return('cus_123') - stripe_event_service.new.perform(event: event) - - expect(account.reload.custom_attributes['captain_responses_usage']).to eq(0) - end - end - - it 'disable features on customer.subscription.updated for default plan' do - allow(event).to receive(:type).and_return('customer.subscription.updated') - allow(subscription).to receive(:customer).and_return('cus_123') stripe_event_service.new.perform(event: event) - expect(account.reload.custom_attributes).to eq({ - 'captain_responses_usage' => 0, - 'stripe_customer_id' => 'cus_123', - 'stripe_price_id' => 'test', - 'stripe_product_id' => 'plan_id', - 'plan_name' => 'Hacker', - 'subscribed_quantity' => '10', - 'subscription_ends_on' => Time.zone.at(1_686_567_520).as_json, - 'subscription_status' => 'active' - }) + + # Verify account attributes were updated + expect(account.reload.custom_attributes).to include( + 'plan_name' => 'Hacker', + 'stripe_product_id' => 'plan_id_hacker', + '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 - 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) + it 'resets captain usage on subscription update' do + # Prime the account with some usage + 5.times { account.increment_response_usage } + expect(account.custom_attributes['captain_responses_usage']).to eq(5) + + # Setup for any plan + allow(subscription).to receive(:[]).with('plan') + .and_return({ 'id' => 'test', 'product' => 'plan_id_startups', 'name' => 'Startups' }) + stripe_event_service.new.perform(event: event) - expect(Enterprise::Billing::CreateStripeCustomerService).to have_received(:new).with(account: account) + + # Verify usage was reset + expect(account.reload.custom_attributes['captain_responses_usage']).to eq(0) end end - describe '#perform for Startups plan' do - before do - allow(event).to receive(:data).and_return(data) - allow(data).to receive(:object).and_return(subscription) - allow(subscription).to receive(:[]).with('plan') - .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') + describe 'subscription deletion handling' do + it 'calls CreateStripeCustomerService on subscription deletion' do + 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) + + # Verify the service was called + expect(Enterprise::Billing::CreateStripeCustomerService).to have_received(:new) + .with(account: account) + expect(customer_service).to have_received(:perform) + end + end + + describe 'plan-specific feature management' do + context 'with default plan (Hacker)' do + 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 - it 'enable features on customer.subscription.updated' do - allow(event).to receive(:type).and_return('customer.subscription.updated') - allow(subscription).to receive(:customer).and_return('cus_123') - stripe_event_service.new.perform(event: event) - expect(account.reload.custom_attributes).to eq({ - 'captain_responses_usage' => 0, - 'stripe_customer_id' => 'cus_123', - 'stripe_price_id' => 'test', - 'stripe_product_id' => 'plan_id_2', - 'plan_name' => 'Startups', - 'subscribed_quantity' => '10', - 'subscription_ends_on' => Time.zone.at(1_686_567_520).as_json, - 'subscription_status' => 'active' - }) - expect(account).to be_feature_enabled('channel_email') - expect(account).to be_feature_enabled('help_center') + 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') + end + end + end + + describe 'downgrade handling' do + let(:service) { stripe_event_service.new } + + before do + # Setup internal attributes service mock to return no manually managed features + internal_attrs_service = instance_double(Internal::Accounts::InternalAttributesService) + allow(Internal::Accounts::InternalAttributesService).to receive(:new).with(account).and_return(internal_attrs_service) + allow(internal_attrs_service).to receive(:manually_managed_features).and_return([]) + end + + context 'when downgrading from Enterprise to Business plan' do + before do + # Start with Enterprise plan + allow(subscription).to receive(:[]).with('plan') + .and_return({ 'id' => 'test', 'product' => 'plan_id_enterprise', 'name' => 'Enterprise' }) + service.perform(event: event) + account.reload + end + + it 'retains business features but disables enterprise features' do + # Verify enterprise features were enabled + expect(account).to be_feature_enabled('audit_logs') + + # Downgrade to Business plan + allow(subscription).to receive(:[]).with('plan') + .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 diff --git a/spec/enterprise/services/internal/accounts/internal_attributes_service_spec.rb b/spec/enterprise/services/internal/accounts/internal_attributes_service_spec.rb new file mode 100644 index 000000000..8411bb823 --- /dev/null +++ b/spec/enterprise/services/internal/accounts/internal_attributes_service_spec.rb @@ -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