feat: add api_and_webhooks feature flag reconciled from billing plan (#14972)
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}"
```
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -18,6 +18,7 @@ class Enterprise::Billing::ReconcilePlanFeaturesService
|
||||
advanced_search
|
||||
linear_integration
|
||||
channel_voice
|
||||
api_and_webhooks
|
||||
].freeze
|
||||
|
||||
BUSINESS_PLAN_FEATURES = %w[
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user