diff --git a/app/controllers/public/api/v1/portals/articles_controller.rb b/app/controllers/public/api/v1/portals/articles_controller.rb
index 664a1964f..a8e22d878 100644
--- a/app/controllers/public/api/v1/portals/articles_controller.rb
+++ b/app/controllers/public/api/v1/portals/articles_controller.rb
@@ -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'
diff --git a/app/controllers/public/api/v1/portals/categories_controller.rb b/app/controllers/public/api/v1/portals/categories_controller.rb
index ebfcb310a..3fb200269 100644
--- a/app/controllers/public/api/v1/portals/categories_controller.rb
+++ b/app/controllers/public/api/v1/portals/categories_controller.rb
@@ -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'
diff --git a/app/controllers/public/api/v1/portals_controller.rb b/app/controllers/public/api/v1/portals_controller.rb
index df4552432..a187ca8a8 100644
--- a/app/controllers/public/api/v1/portals_controller.rb
+++ b/app/controllers/public/api/v1/portals_controller.rb
@@ -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
diff --git a/app/controllers/public_controller.rb b/app/controllers/public_controller.rb
index 3b83a2210..b266b725b 100644
--- a/app/controllers/public_controller.rb
+++ b/app/controllers/public_controller.rb
@@ -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
diff --git a/app/views/public/api/v1/portals/not_active.html.erb b/app/views/public/api/v1/portals/not_active.html.erb
new file mode 100644
index 000000000..af3ecb43f
--- /dev/null
+++ b/app/views/public/api/v1/portals/not_active.html.erb
@@ -0,0 +1,12 @@
+
+
+
<%= I18n.t('public_portal.not_active.title') %>
+
<%= I18n.t('public_portal.not_active.description') %>
+
+
<%= I18n.t('public_portal.not_active.action') %>
+
+
diff --git a/config/locales/en.yml b/config/locales/en.yml
index b0f1f4e2e..12e76ae37 100644
--- a/config/locales/en.yml
+++ b/config/locales/en.yml
@@ -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
diff --git a/enterprise/app/services/enterprise/billing/create_stripe_customer_service.rb b/enterprise/app/services/enterprise/billing/create_stripe_customer_service.rb
index e4df1050b..ac1f74860 100644
--- a/enterprise/app/services/enterprise/billing/create_stripe_customer_service.rb
+++ b/enterprise/app/services/enterprise/billing/create_stripe_customer_service.rb
@@ -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
diff --git a/enterprise/app/services/enterprise/billing/handle_stripe_event_service.rb b/enterprise/app/services/enterprise/billing/handle_stripe_event_service.rb
index d3c5b15db..f69edeb45 100644
--- a/enterprise/app/services/enterprise/billing/handle_stripe_event_service.rb
+++ b/enterprise/app/services/enterprise/billing/handle_stripe_event_service.rb
@@ -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
diff --git a/enterprise/app/services/enterprise/billing/reconcile_plan_features_service.rb b/enterprise/app/services/enterprise/billing/reconcile_plan_features_service.rb
new file mode 100644
index 000000000..953ef0326
--- /dev/null
+++ b/enterprise/app/services/enterprise/billing/reconcile_plan_features_service.rb
@@ -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
diff --git a/enterprise/app/services/internal/accounts/internal_attributes_service.rb b/enterprise/app/services/internal/accounts/internal_attributes_service.rb
index 7bc69d0a4..116d0a3fc 100644
--- a/enterprise/app/services/internal/accounts/internal_attributes_service.rb
+++ b/enterprise/app/services/internal/accounts/internal_attributes_service.rb
@@ -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
diff --git a/spec/controllers/public/api/v1/portals_controller_spec.rb b/spec/controllers/public/api/v1/portals_controller_spec.rb
index c5fa94cff..3dd218c8e 100644
--- a/spec/controllers/public/api/v1/portals_controller_spec.rb
+++ b/spec/controllers/public/api/v1/portals_controller_spec.rb
@@ -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"
diff --git a/spec/enterprise/controllers/enterprise/public/api/v1/helpcenter_plan_access_spec.rb b/spec/enterprise/controllers/enterprise/public/api/v1/helpcenter_plan_access_spec.rb
new file mode 100644
index 000000000..ecba226d7
--- /dev/null
+++ b/spec/enterprise/controllers/enterprise/public/api/v1/helpcenter_plan_access_spec.rb
@@ -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
diff --git a/spec/enterprise/services/enterprise/billing/create_stripe_customer_service_spec.rb b/spec/enterprise/services/enterprise/billing/create_stripe_customer_service_spec.rb
index f5b0bbe86..0dd8189c4 100644
--- a/spec/enterprise/services/enterprise/billing/create_stripe_customer_service_spec.rb
+++ b/spec/enterprise/services/enterprise/billing/create_stripe_customer_service_spec.rb
@@ -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