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:
Shivam Mishra
2026-07-13 18:20:36 +05:30
committed by GitHub
parent df7f137657
commit 9c444315a6
6 changed files with 125 additions and 1 deletions
+4
View File
@@ -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
+64
View File
@@ -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
+2
View File
@@ -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