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

@@ -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