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

<%= 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