feat: disable helpcenter on hacker plans (#12068)

This change blocks Help Center access for default/Hacker-plan accounts
and closes the downgrade gap that could leave `help_center` enabled
after a subscription falls back to the default cloud plan.

Fixes: none
Closes: none

## Why

Default-plan accounts should not be able to access the Help Center, but
the downgrade fallback path only reset the plan name and did not
reconcile premium feature flags. That meant some accounts could keep
`help_center` enabled even after landing back on the Hacker/default
plan.

## What this change does

- blocks Help Center portal and article access for default/Hacker-plan
accounts
- reconciles premium feature flags when a subscription falls back to the
default cloud plan, so `help_center` is disabled immediately instead of
waiting for a later webhook
- preserves existing account `custom_attributes` during Stripe customer
recreation instead of overwriting them
- adds Enterprise coverage for the default-plan access checks on hosted
and custom-domain Help Center routes
- fixes the public access check to use the resolved portal object so
blocked requests return the intended response instead of raising an
error

## Validation

1. Create or use an account on the default/Hacker cloud plan with an
active portal.
2. Visit the portal home page and a published article on both the
Chatwoot-hosted URL and a configured custom domain.
3. Confirm the Help Center is blocked for that account.
4. Downgrade a paid account back to the default/Hacker plan through the
Stripe webhook flow.
5. Confirm `help_center` is disabled right after the downgrade fallback
is processed and the account can no longer access the Help Center.

---------

Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
Co-authored-by: Sojan Jose <sojan@pepalo.com>
This commit is contained in:
Vishnu Narayanan
2026-03-27 12:18:46 +05:30
committed by GitHub
parent 127ac0a6b2
commit 4381be5f3e
13 changed files with 223 additions and 121 deletions

View File

@@ -1,6 +1,7 @@
class Public::Api::V1::Portals::ArticlesController < Public::Api::V1::Portals::BaseController
before_action :ensure_custom_domain_request, only: [:show, :index]
before_action :portal
before_action :ensure_portal_feature_enabled
before_action :set_category, except: [:index, :show, :tracking_pixel]
before_action :set_article, only: [:show]
layout 'portal'

View File

@@ -1,6 +1,7 @@
class Public::Api::V1::Portals::CategoriesController < Public::Api::V1::Portals::BaseController
before_action :ensure_custom_domain_request, only: [:show, :index]
before_action :portal
before_action :ensure_portal_feature_enabled
before_action :set_category, only: [:show]
layout 'portal'

View File

@@ -1,7 +1,8 @@
class Public::Api::V1::PortalsController < Public::Api::V1::Portals::BaseController
before_action :ensure_custom_domain_request, only: [:show]
before_action :portal
before_action :redirect_to_portal_with_locale, only: [:show]
before_action :portal
before_action :ensure_portal_feature_enabled
layout 'portal'
def show
@@ -24,6 +25,7 @@ class Public::Api::V1::PortalsController < Public::Api::V1::Portals::BaseControl
def redirect_to_portal_with_locale
return if params[:locale].present?
portal
redirect_to "/hc/#{@portal.slug}/#{@portal.default_locale}"
end
end

View File

@@ -18,4 +18,11 @@ class PublicController < ActionController::Base
Please send us an email at support@chatwoot.com with the custom domain name and account API key"
}, status: :unauthorized and return
end
def ensure_portal_feature_enabled
return unless ChatwootApp.chatwoot_cloud?
return if @portal.account.feature_enabled?('help_center')
render 'public/api/v1/portals/not_active', status: :payment_required
end
end

View File

@@ -0,0 +1,12 @@
<div class="max-w-6xl h-full w-full flex-grow flex flex-col items-center justify-center mx-auto py-16 px-4 relative">
<div class="text-center mb-12">
<div class="mx-auto flex h-20 w-20 items-center justify-center rounded-full bg-slate-100 text-slate-500 dark:bg-slate-800 dark:text-slate-300">
<span class="text-5xl font-medium">i</span>
</div>
</div>
<h1 class="text-6xl text-center font-semibold text-slate-800 dark:text-slate-100 leading-relaxed"><%= I18n.t('public_portal.not_active.title') %></h1>
<p class="text-center text-slate-700 dark:text-slate-300 my-1"><%= I18n.t('public_portal.not_active.description') %></p>
<div class="text-center my-8">
<p class="text-slate-600 dark:text-slate-400 text-sm"><%= I18n.t('public_portal.not_active.action') %></p>
</div>
</div>

View File

@@ -423,6 +423,10 @@ en:
title: Page not found
description: We couldn't find the page you were looking for.
back_to_home: Go to home page
not_active:
title: Help Center Unavailable
description: Please contact the site administrator for more information.
action: If you are the administrator, please upgrade your plan to restore access.
slack_unfurl:
fields:
name: Name

View File

@@ -7,21 +7,12 @@ class Enterprise::Billing::CreateStripeCustomerService
return if existing_subscription?
customer_id = prepare_customer_id
subscription = Stripe::Subscription.create(
{
customer: customer_id,
items: [{ price: price_id, quantity: default_quantity }]
}
)
account.update!(
custom_attributes: {
stripe_customer_id: customer_id,
stripe_price_id: subscription['plan']['id'],
stripe_product_id: subscription['plan']['product'],
plan_name: default_plan['name'],
subscribed_quantity: subscription['quantity']
}
)
subscription = Stripe::Subscription.create(customer: customer_id, items: [{ price: price_id, quantity: default_quantity }])
custom_attributes = build_custom_attributes(customer_id, subscription)
custom_attributes.except!('is_creating_customer')
account.update!(custom_attributes: custom_attributes)
Enterprise::Billing::ReconcilePlanFeaturesService.new(account: account).perform
end
private
@@ -66,4 +57,23 @@ class Enterprise::Billing::CreateStripeCustomerService
)
subscriptions.data.present?
end
def build_custom_attributes(customer_id, subscription)
(account.custom_attributes || {}).merge(
'stripe_customer_id' => customer_id,
'stripe_price_id' => subscription['plan']['id'],
'stripe_product_id' => subscription['plan']['product'],
'plan_name' => default_plan['name'],
'subscribed_quantity' => subscription['quantity'],
'subscription_status' => subscription['status'],
'subscription_ends_on' => subscription_ends_on(subscription)
)
end
def subscription_ends_on(subscription)
period_end = subscription['current_period_end']
return if period_end.blank?
Time.zone.at(period_end)
end
end

View File

@@ -2,29 +2,9 @@ class Enterprise::Billing::HandleStripeEventService
CLOUD_PLANS_CONFIG = 'CHATWOOT_CLOUD_PLANS'.freeze
CAPTAIN_CLOUD_PLAN_LIMITS = 'CAPTAIN_CLOUD_PLAN_LIMITS'.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_facebook
channel_email
channel_instagram
captain_integration
advanced_search_indexing
advanced_search
linear_integration
].freeze
# Additional features available starting with the Business plan
BUSINESS_PLAN_FEATURES = %w[sla custom_roles csat_review_notes conversation_required_attributes advanced_assignment].freeze
# Additional features available only in the Enterprise plan
ENTERPRISE_PLAN_FEATURES = %w[audit_logs disable_branding saml].freeze
STARTUP_PLAN_FEATURES = Enterprise::Billing::ReconcilePlanFeaturesService::STARTUP_PLAN_FEATURES
BUSINESS_PLAN_FEATURES = Enterprise::Billing::ReconcilePlanFeaturesService::BUSINESS_PLAN_FEATURES
ENTERPRISE_PLAN_FEATURES = Enterprise::Billing::ReconcilePlanFeaturesService::ENTERPRISE_PLAN_FEATURES
def perform(event:)
@event = event
@@ -49,7 +29,7 @@ class Enterprise::Billing::HandleStripeEventService
previous_usage = capture_previous_usage
update_account_attributes(subscription, plan)
update_plan_features
Enterprise::Billing::ReconcilePlanFeaturesService.new(account: account).perform
if billing_period_renewed?
ActiveRecord::Base.transaction do
@@ -94,34 +74,6 @@ class Enterprise::Billing::HandleStripeEventService
Enterprise::Billing::CreateStripeCustomerService.new(account: account).perform
end
def update_plan_features
if default_plan?
disable_all_premium_features
else
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 handle_subscription_credits(plan, previous_usage)
current_limits = account.limits || {}
@@ -153,19 +105,6 @@ class Enterprise::Billing::HandleStripeEventService
config[plan_name.downcase]&.symbolize_keys
end
def enable_plan_specific_features
plan_name = account.custom_attributes['plan_name']
return if plan_name.blank?
case plan_name
when 'Startups' then account.enable_features(*STARTUP_PLAN_FEATURES)
when 'Business'
account.enable_features(*STARTUP_PLAN_FEATURES, *BUSINESS_PLAN_FEATURES)
when 'Enterprise'
account.enable_features(*STARTUP_PLAN_FEATURES, *BUSINESS_PLAN_FEATURES, *ENTERPRISE_PLAN_FEATURES)
end
end
def subscription
@subscription ||= @event.data.object
end
@@ -197,19 +136,4 @@ class Enterprise::Billing::HandleStripeEventService
cloud_plans = InstallationConfig.find_by(name: CLOUD_PLANS_CONFIG)&.value || []
cloud_plans.find { |config| config['product_id'].include?(plan_id) }
end
def default_plan?
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

View File

@@ -0,0 +1,61 @@
class Enterprise::Billing::ReconcilePlanFeaturesService
CLOUD_PLANS_CONFIG = 'CHATWOOT_CLOUD_PLANS'.freeze
# Plan hierarchy: Hacker (default) -> Startups -> Business -> Enterprise
# Each higher tier includes all features from the lower tiers
STARTUP_PLAN_FEATURES = %w[
inbound_emails
help_center
campaigns
team_management
channel_facebook
channel_email
channel_instagram
captain_integration
advanced_search_indexing
advanced_search
linear_integration
].freeze
BUSINESS_PLAN_FEATURES = %w[sla custom_roles csat_review_notes conversation_required_attributes advanced_assignment].freeze
ENTERPRISE_PLAN_FEATURES = %w[audit_logs disable_branding saml].freeze
PREMIUM_PLAN_FEATURES = (STARTUP_PLAN_FEATURES + BUSINESS_PLAN_FEATURES + ENTERPRISE_PLAN_FEATURES).freeze
pattr_initialize [:account!]
def perform
account.disable_features(*PREMIUM_PLAN_FEATURES)
account.enable_features(*current_plan_features)
account.enable_features(*manually_managed_features)
account.save!
end
private
def current_plan_features
return [] if default_plan?
case account.custom_attributes['plan_name']
when 'Startups' then STARTUP_PLAN_FEATURES
when 'Business' then STARTUP_PLAN_FEATURES + BUSINESS_PLAN_FEATURES
when 'Enterprise' then PREMIUM_PLAN_FEATURES
else []
end
end
def default_plan?
default_plan_name = cloud_plans.first&.dig('name')
return false if default_plan_name.blank?
plan_name = account.custom_attributes['plan_name']
plan_name.blank? || plan_name == default_plan_name
end
def cloud_plans
@cloud_plans ||= InstallationConfig.find_by(name: CLOUD_PLANS_CONFIG)&.value || []
end
def manually_managed_features
@manually_managed_features ||= Internal::Accounts::InternalAttributesService.new(account).manually_managed_features
end
end

View File

@@ -53,8 +53,8 @@ class Internal::Accounts::InternalAttributesService
# 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
Enterprise::Billing::ReconcilePlanFeaturesService::BUSINESS_PLAN_FEATURES +
Enterprise::Billing::ReconcilePlanFeaturesService::ENTERPRISE_PLAN_FEATURES
end
# Account notes functionality removed for now

View File

@@ -13,6 +13,12 @@ RSpec.describe Public::Api::V1::PortalsController, type: :request do
end
describe 'GET /public/api/v1/portals/{portal_slug}' do
it 'redirects to the portal default locale when locale is not present' do
get "/hc/#{portal.slug}"
expect(response).to redirect_to("/hc/#{portal.slug}/#{portal.default_locale}")
end
it 'Show portal and categories belonging to the portal' do
get "/hc/#{portal.slug}/en"

View File

@@ -0,0 +1,46 @@
require 'rails_helper'
RSpec.describe 'Public Help Center Access', type: :request do
let(:plan_name) { 'Startups' }
let!(:account) { create(:account, custom_attributes: { 'plan_name' => plan_name }) }
let!(:agent) { create(:user, account: account, role: :agent) }
let!(:portal) { create(:portal, account: account, custom_domain: 'docs-helpcenter.example.com') }
let!(:category) { create(:category, portal: portal, account: account, locale: 'en', slug: 'category-slug') }
let!(:article) { create(:article, category: category, portal: portal, account: account, author: agent, status: :published) }
around do |example|
with_modified_env FRONTEND_URL: 'https://app.chatwoot.com', HELPCENTER_URL: 'https://help.chatwoot.com' do
previous_deployment_env = InstallationConfig.find_by(name: 'DEPLOYMENT_ENV')&.value
InstallationConfig.where(name: 'DEPLOYMENT_ENV').first_or_initialize.update!(value: 'cloud')
example.run
ensure
config = InstallationConfig.where(name: 'DEPLOYMENT_ENV').first_or_initialize
previous_deployment_env.present? ? config.update!(value: previous_deployment_env) : config.destroy!
host! 'www.example.com'
end
end
it 'blocks chatwoot-hosted portal pages when the help center feature is disabled' do
account.disable_features!(:help_center)
host! 'help.chatwoot.com'
get "/hc/#{portal.slug}/en"
expect(response).to have_http_status(:payment_required)
expect(response.body).to include('Help Center Unavailable')
end
context 'when the account is on the default plan' do
let(:plan_name) { 'Hacker' }
it 'still allows access if the feature flag is enabled' do
account.enable_features!(:help_center)
host! portal.custom_domain
get "/hc/#{portal.slug}/articles/#{article.slug}"
expect(response).to have_http_status(:ok)
end
end
end

View File

@@ -7,6 +7,16 @@ describe Enterprise::Billing::CreateStripeCustomerService do
let!(:admin1) { create(:user, account: account, role: :administrator) }
let(:admin2) { create(:user, account: account, role: :administrator) }
let(:subscriptions_list) { double }
let(:current_period_end) { 1_686_567_520 }
let(:subscription_ends_on) { Time.zone.at(current_period_end).as_json }
let(:created_subscription) do
{
plan: { id: 'price_random_number', product: 'prod_random_number' },
quantity: 2,
status: 'active',
current_period_end: current_period_end
}.with_indifferent_access
end
describe '#perform' do
before do
@@ -18,18 +28,44 @@ describe Enterprise::Billing::CreateStripeCustomerService do
)
end
it 'preserves unrelated custom attributes, clears is_creating_customer, and reconciles default-plan features' do
account.update!(
custom_attributes: {
'is_creating_customer' => true,
'onboarding_source' => 'billing_page',
'subscription_status' => 'past_due',
'subscription_ends_on' => 1.day.ago
}
)
account.enable_features!(:help_center)
customer = double
allow(Stripe::Customer).to receive(:create).and_return(customer)
allow(customer).to receive(:id).and_return('cus_random_number')
allow(Stripe::Subscription).to receive(:create).and_return(created_subscription)
create_stripe_customer_service.new(account: account).perform
expect(account.reload.custom_attributes).to include(
'stripe_customer_id' => customer.id,
'stripe_price_id' => 'price_random_number',
'stripe_product_id' => 'prod_random_number',
'subscribed_quantity' => 2,
'plan_name' => 'A Plan Name',
'onboarding_source' => 'billing_page',
'subscription_status' => 'active',
'subscription_ends_on' => subscription_ends_on
)
expect(account.custom_attributes).not_to have_key('is_creating_customer')
expect(account).not_to be_feature_enabled('help_center')
end
it 'does not call stripe methods if customer id is present' do
account.update!(custom_attributes: { stripe_customer_id: 'cus_random_number' })
allow(subscriptions_list).to receive(:data).and_return([])
allow(Stripe::Customer).to receive(:create)
allow(Stripe::Subscription).to receive(:list).and_return(subscriptions_list)
allow(Stripe::Subscription).to receive(:create)
.and_return(
{
plan: { id: 'price_random_number', product: 'prod_random_number' },
quantity: 2
}.with_indifferent_access
)
allow(Stripe::Subscription).to receive(:create).and_return(created_subscription)
create_stripe_customer_service.new(account: account).perform
@@ -44,7 +80,9 @@ describe Enterprise::Billing::CreateStripeCustomerService do
stripe_price_id: 'price_random_number',
stripe_product_id: 'prod_random_number',
subscribed_quantity: 2,
plan_name: 'A Plan Name'
plan_name: 'A Plan Name',
subscription_status: 'active',
subscription_ends_on: subscription_ends_on
}.with_indifferent_access
)
end
@@ -53,14 +91,7 @@ describe Enterprise::Billing::CreateStripeCustomerService do
customer = double
allow(Stripe::Customer).to receive(:create).and_return(customer)
allow(customer).to receive(:id).and_return('cus_random_number')
allow(Stripe::Subscription)
.to receive(:create)
.and_return(
{
plan: { id: 'price_random_number', product: 'prod_random_number' },
quantity: 2
}.with_indifferent_access
)
allow(Stripe::Subscription).to receive(:create).and_return(created_subscription)
create_stripe_customer_service.new(account: account).perform
@@ -75,7 +106,9 @@ describe Enterprise::Billing::CreateStripeCustomerService do
stripe_price_id: 'price_random_number',
stripe_product_id: 'prod_random_number',
subscribed_quantity: 2,
plan_name: 'A Plan Name'
plan_name: 'A Plan Name',
subscription_status: 'active',
subscription_ends_on: subscription_ends_on
}.with_indifferent_access
)
end
@@ -96,12 +129,7 @@ describe Enterprise::Billing::CreateStripeCustomerService do
customer = double
allow(Stripe::Customer).to receive(:create).and_return(customer)
allow(customer).to receive(:id).and_return('cus_random_number')
allow(Stripe::Subscription).to receive(:create).and_return(
{
plan: { id: 'price_random_number', product: 'prod_random_number' },
quantity: 2
}.with_indifferent_access
)
allow(Stripe::Subscription).to receive(:create).and_return(created_subscription)
create_stripe_customer_service.new(account: account).perform