From 9c444315a6e3325621032c6738d1ea75af482862 Mon Sep 17 00:00:00 2001 From: Shivam Mishra Date: Mon, 13 Jul 2026 18:20:36 +0530 Subject: [PATCH] feat: add `api_and_webhooks` feature flag reconciled from billing plan (#14972) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This introduces a new `api_and_webhooks` account feature flag that will control access to the token-authenticated API and account webhooks. The flag is part of the Startup plan features, so paid plans — including trials of paid plans — get it through the billing reconcile, while accounts on the default (Hacker) plan don't, with `manually_managed_features` available as a per-account override. The flag defaults to enabled, and nothing enforces it yet, so this PR is behavior-neutral — enforcement lands in a follow-up. ## What changed - Added `api_and_webhooks` to `features.yml` (first flag on the `feature_flags_ext_1` column, default enabled). - Added the flag to `STARTUP_PLAN_FEATURES` in `Enterprise::Billing::ReconcilePlanFeaturesService`, so all paid tiers get it and the default plan loses it on reconcile. - Added the flag to the manually manageable features list so it can be granted per account via Super Admin. ```rb # Enables the api_and_webhooks feature for all existing accounts and marks it # as manually managed so cloud billing reconciles never strip it. # # NOT committed to source control — run manually on production. # # Usage: # bundle exec rails runner enable_api_and_webhooks.rb # ACCOUNT_ID=123 bundle exec rails runner enable_api_and_webhooks.rb # # Idempotent: accounts already grandfathered are skipped; safe to re-run. probe = Internal::Accounts::InternalAttributesService.new(Account.new) abort 'api_and_webhooks is not in valid_feature_list — deploy the feature flag PR first.' unless probe.valid_feature_list.include?('api_and_webhooks') account_id = ENV.fetch('ACCOUNT_ID', nil) accounts = account_id.present? ? Account.where(id: account_id) : Account.all abort "Account with ID #{account_id} not found" if account_id.present? && accounts.empty? total = accounts.count puts "Grandfathering api_and_webhooks for #{total} account(s)..." puts "Started at: #{Time.current}" updated = 0 skipped = 0 errored = 0 accounts.find_each(batch_size: 500) do |account| service = Internal::Accounts::InternalAttributesService.new(account) features = service.manually_managed_features if features.include?('api_and_webhooks') && account.feature_enabled?('api_and_webhooks') skipped += 1 else service.manually_managed_features = features + ['api_and_webhooks'] unless features.include?('api_and_webhooks') account.enable_features!('api_and_webhooks') updated += 1 end processed = updated + skipped + errored puts "Processed #{processed}/#{total}..." if (processed % 1000).zero? rescue StandardError => e errored += 1 puts "Account #{account.id}: FAILED - #{e.message}" end puts "Done! Updated: #{updated}, Skipped: #{skipped}, Errored: #{errored}, Total: #{total}" ``` --- config/features.yml | 4 ++ .../reconcile_plan_features_service.rb | 1 + .../accounts/internal_attributes_service.rb | 2 +- lib/tasks/feature_defaults.rake | 64 +++++++++++++++++++ .../reconcile_plan_features_service_spec.rb | 53 +++++++++++++++ spec/models/account_spec.rb | 2 + 6 files changed, 125 insertions(+), 1 deletion(-) create mode 100644 lib/tasks/feature_defaults.rake create mode 100644 spec/enterprise/services/enterprise/billing/reconcile_plan_features_service_spec.rb diff --git a/config/features.yml b/config/features.yml index 62bcab2da..39c8a53af 100644 --- a/config/features.yml +++ b/config/features.yml @@ -257,3 +257,7 @@ display_name: Data Import enabled: false column: feature_flags_ext_1 +- name: api_and_webhooks + display_name: API and Webhooks + enabled: true + column: feature_flags_ext_1 diff --git a/enterprise/app/services/enterprise/billing/reconcile_plan_features_service.rb b/enterprise/app/services/enterprise/billing/reconcile_plan_features_service.rb index 205bc348e..435b1f3d1 100644 --- a/enterprise/app/services/enterprise/billing/reconcile_plan_features_service.rb +++ b/enterprise/app/services/enterprise/billing/reconcile_plan_features_service.rb @@ -18,6 +18,7 @@ class Enterprise::Billing::ReconcilePlanFeaturesService advanced_search linear_integration channel_voice + api_and_webhooks ].freeze BUSINESS_PLAN_FEATURES = %w[ diff --git a/enterprise/app/services/internal/accounts/internal_attributes_service.rb b/enterprise/app/services/internal/accounts/internal_attributes_service.rb index 593cea799..00c3d3636 100644 --- a/enterprise/app/services/internal/accounts/internal_attributes_service.rb +++ b/enterprise/app/services/internal/accounts/internal_attributes_service.rb @@ -54,7 +54,7 @@ class Internal::Accounts::InternalAttributesService def valid_feature_list Enterprise::Billing::ReconcilePlanFeaturesService::BUSINESS_PLAN_FEATURES + Enterprise::Billing::ReconcilePlanFeaturesService::ENTERPRISE_PLAN_FEATURES + - %w[inbound_emails] + %w[inbound_emails api_and_webhooks] end # Account notes functionality removed for now diff --git a/lib/tasks/feature_defaults.rake b/lib/tasks/feature_defaults.rake new file mode 100644 index 000000000..6b6e0443a --- /dev/null +++ b/lib/tasks/feature_defaults.rake @@ -0,0 +1,64 @@ +# frozen_string_literal: true + +# rubocop:disable Metrics/BlockLength +namespace :feature_defaults do + desc 'Interactively toggle a feature on/off in ACCOUNT_LEVEL_FEATURE_DEFAULTS (affects new account signups only)' + task toggle: :environment do + config = InstallationConfig.find_by!(name: 'ACCOUNT_LEVEL_FEATURE_DEFAULTS') + + loop do + features = config.value + print_feature_list(features) + + print "\nEnter the number of the feature to toggle (or 'q' to quit): " + input = $stdin.gets.chomp + break if input.casecmp('q').zero? + + feature = select_feature(features, input) + if feature.nil? + puts 'Invalid selection.' + next + end + + toggle_feature(config, features, feature) + end + + puts 'Done.' + end + + def print_feature_list(features) + puts "\n#{'#'.ljust(4)}#{'name'.ljust(35)}#{'display_name'.ljust(30)}enabled" + features.each_with_index do |feature, index| + puts "#{(index + 1).to_s.ljust(4)}#{feature['name'].to_s.ljust(35)}#{feature['display_name'].to_s.ljust(30)}#{feature['enabled']}" + end + end + + def select_feature(features, input) + index = Integer(input, exception: false) + return nil if index.nil? || !index.between?(1, features.length) + + features[index - 1] + end + + def toggle_feature(config, features, feature) + print "#{feature['name']} is currently enabled: #{feature['enabled']}. Type 'true' or 'false' to set (anything else cancels): " + input = $stdin.gets.chomp + + case input + when 'true' + new_state = true + when 'false' + new_state = false + else + puts 'Cancelled.' + return + end + + feature['enabled'] = new_state + config.value = features + config.save! + GlobalConfig.clear_cache + puts "Updated #{feature['name']} to enabled: #{new_state}" + end +end +# rubocop:enable Metrics/BlockLength diff --git a/spec/enterprise/services/enterprise/billing/reconcile_plan_features_service_spec.rb b/spec/enterprise/services/enterprise/billing/reconcile_plan_features_service_spec.rb new file mode 100644 index 000000000..64be87ff4 --- /dev/null +++ b/spec/enterprise/services/enterprise/billing/reconcile_plan_features_service_spec.rb @@ -0,0 +1,53 @@ +require 'rails_helper' + +describe Enterprise::Billing::ReconcilePlanFeaturesService do + let(:account) { create(:account) } + + before do + 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'] } + ] + }) + end + + describe '#perform' do + context 'with api_and_webhooks feature' do + it 'enables the feature for a paid plan with an active subscription' do + account.update!(custom_attributes: { 'plan_name' => 'Startups', 'subscription_status' => 'active' }) + + described_class.new(account: account).perform + + expect(account.reload).to be_feature_enabled('api_and_webhooks') + end + + it 'enables the feature for a paid plan on trial' do + account.update!(custom_attributes: { 'plan_name' => 'Startups', 'subscription_status' => 'trialing' }) + + described_class.new(account: account).perform + + expect(account.reload).to be_feature_enabled('api_and_webhooks') + end + + it 'disables the feature on the default plan' do + account.enable_features!('api_and_webhooks') + account.update!(custom_attributes: { 'plan_name' => 'Hacker', 'subscription_status' => 'active' }) + + described_class.new(account: account).perform + + expect(account.reload).not_to be_feature_enabled('api_and_webhooks') + end + + it 'keeps the feature enabled when manually managed' do + account.update!(custom_attributes: { 'plan_name' => 'Hacker', 'subscription_status' => 'trialing' }) + Internal::Accounts::InternalAttributesService.new(account).manually_managed_features = ['api_and_webhooks'] + + described_class.new(account: account).perform + + expect(account.reload).to be_feature_enabled('api_and_webhooks') + end + end + end +end diff --git a/spec/models/account_spec.rb b/spec/models/account_spec.rb index a4932d635..00b464f73 100644 --- a/spec/models/account_spec.rb +++ b/spec/models/account_spec.rb @@ -108,6 +108,8 @@ RSpec.describe Account do it 'configures the account feature flag extension column' do expect(described_class.flag_columns).to include('feature_flags', 'feature_flags_ext_1') + expect(described_class.flag_mapping['feature_flags_ext_1']).to eq(feature_whatsapp_manual_transfer: 1, feature_data_import: 1 << 1, + feature_api_and_webhooks: 1 << 2) expect(described_class.flag_mapping['feature_flags_ext_1'][:feature_whatsapp_manual_transfer]).to eq(1) expect(described_class.flag_mapping['feature_flags_ext_1'][:feature_data_import]).to eq(2) end