diff --git a/app/controllers/dashboard_controller.rb b/app/controllers/dashboard_controller.rb index d81b4c9da..8aa7ed34a 100644 --- a/app/controllers/dashboard_controller.rb +++ b/app/controllers/dashboard_controller.rb @@ -35,7 +35,6 @@ class DashboardController < ActionController::Base 'HCAPTCHA_SITE_KEY', 'LOGOUT_REDIRECT_LINK', 'DISABLE_USER_PROFILE_UPDATE', - 'DEPLOYMENT_ENV', 'INSTALLATION_PRICING_PLAN' ).merge(app_config) end @@ -71,6 +70,7 @@ class DashboardController < ActionController::Base WHATSAPP_CONFIGURATION_ID: GlobalConfigService.load('WHATSAPP_CONFIGURATION_ID', ''), IS_ENTERPRISE: ChatwootApp.enterprise?, AZURE_APP_ID: GlobalConfigService.load('AZURE_APP_ID', ''), + DEPLOYMENT_ENV: GlobalConfigService.load('DEPLOYMENT_ENV', 'self-hosted'), GIT_SHA: GIT_HASH } end diff --git a/app/models/credit_transaction.rb b/app/models/credit_transaction.rb index aeadb46f3..048131653 100644 --- a/app/models/credit_transaction.rb +++ b/app/models/credit_transaction.rb @@ -6,7 +6,7 @@ # amount :integer not null # credit_type :string not null # description :string -# metadata :jsonb +# metadata :json # transaction_type :string not null # created_at :datetime not null # updated_at :datetime not null diff --git a/enterprise/app/controllers/enterprise/webhooks/stripe_controller.rb b/enterprise/app/controllers/enterprise/webhooks/stripe_controller.rb index 64fe190a3..bea502964 100644 --- a/enterprise/app/controllers/enterprise/webhooks/stripe_controller.rb +++ b/enterprise/app/controllers/enterprise/webhooks/stripe_controller.rb @@ -57,7 +57,7 @@ class Enterprise::Webhooks::StripeController < ActionController::API end end - def account_v2_enabled?(account) - account.custom_attributes&.[]('stripe_billing_version').to_i == 2 + def account_v2_enabled?(_account) + ENV.fetch('STRIPE_BILLING_V2_ENABLED', 'false') == 'true' end end diff --git a/enterprise/app/jobs/enterprise/create_stripe_customer_job.rb b/enterprise/app/jobs/enterprise/create_stripe_customer_job.rb index 6b90cbec5..a0bff153a 100644 --- a/enterprise/app/jobs/enterprise/create_stripe_customer_job.rb +++ b/enterprise/app/jobs/enterprise/create_stripe_customer_job.rb @@ -2,6 +2,19 @@ class Enterprise::CreateStripeCustomerJob < ApplicationJob queue_as :default def perform(account) - Enterprise::Billing::CreateStripeCustomerService.new(account: account).perform + # Check if this is a V2 billing account + if v2_billing_enabled? + # Use V2 service - creates customer and subscribes to free Hacker plan + Enterprise::Billing::V2::CustomerCreationService.new(account: account).perform + else + # Use V1 service - legacy billing + Enterprise::Billing::CreateStripeCustomerService.new(account: account).perform + end + end + + private + + def v2_billing_enabled? + ENV.fetch('STRIPE_BILLING_V2_ENABLED', 'false') == 'true' end end diff --git a/enterprise/app/services/enterprise/ai/captain_credit_service.rb b/enterprise/app/services/enterprise/ai/captain_credit_service.rb index 9ed7ebc51..8a2bd4d5a 100644 --- a/enterprise/app/services/enterprise/ai/captain_credit_service.rb +++ b/enterprise/app/services/enterprise/ai/captain_credit_service.rb @@ -18,6 +18,6 @@ class Enterprise::Ai::CaptainCreditService private def v2_enabled? - account.custom_attributes&.[]('stripe_billing_version').to_i == 2 + ENV.fetch('STRIPE_BILLING_V2_ENABLED', 'false') == 'true' end end diff --git a/enterprise/app/services/enterprise/billing/create_session_service.rb b/enterprise/app/services/enterprise/billing/create_session_service.rb index ad4f8f45b..f231e01bd 100644 --- a/enterprise/app/services/enterprise/billing/create_session_service.rb +++ b/enterprise/app/services/enterprise/billing/create_session_service.rb @@ -1,9 +1,12 @@ class Enterprise::Billing::CreateSessionService + PORTAL_CONFIGURATION_ID = 'bpc_1SI88PF3O6TPVU2azyI0ek2W'.freeze + def create_session(customer_id, return_url = ENV.fetch('FRONTEND_URL')) Stripe::BillingPortal::Session.create( { customer: customer_id, - return_url: return_url + return_url: return_url, + configuration: PORTAL_CONFIGURATION_ID } ) end diff --git a/enterprise/app/services/enterprise/billing/v2/base_service.rb b/enterprise/app/services/enterprise/billing/v2/base_service.rb index 685b19e9a..914b7255a 100644 --- a/enterprise/app/services/enterprise/billing/v2/base_service.rb +++ b/enterprise/app/services/enterprise/billing/v2/base_service.rb @@ -14,7 +14,7 @@ class Enterprise::Billing::V2::BaseService end def v2_enabled? - custom_attribute('stripe_billing_version').to_i == 2 + ENV.fetch('STRIPE_BILLING_V2_ENABLED', 'false') == 'true' end def monthly_credits diff --git a/enterprise/app/services/enterprise/billing/v2/customer_creation_service.rb b/enterprise/app/services/enterprise/billing/v2/customer_creation_service.rb new file mode 100644 index 000000000..ca9ee9d91 --- /dev/null +++ b/enterprise/app/services/enterprise/billing/v2/customer_creation_service.rb @@ -0,0 +1,68 @@ +# Service to create Stripe customer for V2 billing with default Hacker pricing plan +class Enterprise::Billing::V2::CustomerCreationService < Enterprise::Billing::V2::BaseService + def perform + return { success: false, message: 'Customer already exists' } if customer_exists? + return { success: false, message: 'Not a V2 billing account' } unless v2_enabled? + return { success: false, message: 'Hacker pricing plan not configured' } unless hacker_plan_id + + with_locked_account do + customer = create_stripe_customer + return { success: false, message: 'Failed to create customer' } unless customer + + save_customer_id(customer.id) + subscribe_to_hacker_pricing_plan(customer.id) + end + rescue Stripe::StripeError => e + { success: false, message: e.message } + end + + private + + def customer_exists? + custom_attribute('stripe_customer_id').present? + end + + def hacker_plan_id + # Get Hacker plan pricing_plan_id from environment or installation config + # Set via: ENV['STRIPE_HACKER_PRICING_PLAN_ID'] or InstallationConfig + config = InstallationConfig.find_by(name: 'STRIPE_HACKER_PRICING_PLAN_ID') + config&.value || ENV.fetch('STRIPE_HACKER_PRICING_PLAN_ID', nil) + end + + def create_stripe_customer + Stripe::Customer.create( + { + email: billing_email, + name: account.name, + description: "Chatwoot Account ##{account.id}", + metadata: { + account_id: account.id.to_s, + account_name: account.name, + billing_version: '2', + plan_type: 'hacker' + } + }, + { api_key: ENV.fetch('STRIPE_SECRET_KEY', nil), stripe_version: '2025-08-27.preview' } + ) + end + + def save_customer_id(customer_id) + update_custom_attributes( + 'stripe_customer_id' => customer_id, + 'stripe_billing_version' => 2 + ) + end + + def subscribe_to_hacker_pricing_plan(customer_id) + # Shared meter and event name are automatically loaded from ENV in SubscribeCustomerService + Enterprise::Billing::V2::SubscribeCustomerService.new(account: account) + .subscribe_to_pricing_plan( + pricing_plan_id: hacker_plan_id, + customer_id: customer_id + ) + end + + def billing_email + account.administrators.first&.email || "account_#{account.id}@chatwoot.com" + end +end diff --git a/enterprise/app/services/enterprise/billing/v2/pricing_plan_service.rb b/enterprise/app/services/enterprise/billing/v2/pricing_plan_service.rb index c01da49bf..ef066f4dc 100644 --- a/enterprise/app/services/enterprise/billing/v2/pricing_plan_service.rb +++ b/enterprise/app/services/enterprise/billing/v2/pricing_plan_service.rb @@ -35,15 +35,9 @@ class Enterprise::Billing::V2::PricingPlanService < Enterprise::Billing::V2::Bas end def create_complete_pricing_plan(config) - cpu = create_custom_pricing_unit( - display_name: config[:cpu_display_name], - lookup_key: config[:cpu_lookup_key] - ) - - meter = create_meter( - display_name: config[:meter_display_name], - event_name: config[:meter_event_name] - ) + # Use shared CPU and meter if available, otherwise create new ones + cpu = get_or_create_cpu(config) + meter = get_or_create_meter(config) plan = create_pricing_plan(display_name: config[:plan_display_name]) @@ -53,11 +47,47 @@ class Enterprise::Billing::V2::PricingPlanService < Enterprise::Billing::V2::Bas service_action = builder.add_service_action_component(plan, config, cpu) rate_card = builder.add_rate_card_component(plan, config, meter, cpu) - build_plan_result(plan, cpu, meter, rate_card, service_action) + result = build_plan_result(plan, cpu, meter, rate_card, service_action) + Enterprise::Billing::V2::PricingPlanCache.invalidate + result rescue StandardError => e { success: false, message: e.message } end + def get_or_create_cpu(config) + # Check for shared CPU first + shared_cpu_id = InstallationConfig.find_by(name: 'STRIPE_CUSTOM_PRICING_UNIT_ID')&.value || + ENV.fetch('STRIPE_CUSTOM_PRICING_UNIT_ID', nil) + + if shared_cpu_id + # Return existing CPU as OpenStruct to match create response + OpenStruct.new(id: shared_cpu_id) + else + # Create new CPU if not using shared + create_custom_pricing_unit( + display_name: config[:cpu_display_name], + lookup_key: config[:cpu_lookup_key] + ) + end + end + + def get_or_create_meter(config) + # Check for shared meter first + shared_meter_id = InstallationConfig.find_by(name: 'STRIPE_METER_ID')&.value || + ENV.fetch('STRIPE_METER_ID', nil) + + if shared_meter_id + # Return existing meter as OpenStruct to match create response + OpenStruct.new(id: shared_meter_id) + else + # Create new meter if not using shared + create_meter( + display_name: config[:meter_display_name], + event_name: config[:meter_event_name] + ) + end + end + private def component_builder diff --git a/enterprise/app/services/enterprise/billing/v2/subscribe_customer_service.rb b/enterprise/app/services/enterprise/billing/v2/subscribe_customer_service.rb index f8febf235..f4dddea52 100644 --- a/enterprise/app/services/enterprise/billing/v2/subscribe_customer_service.rb +++ b/enterprise/app/services/enterprise/billing/v2/subscribe_customer_service.rb @@ -1,11 +1,15 @@ +# rubocop:disable Metrics/ClassLength class Enterprise::Billing::V2::SubscribeCustomerService < Enterprise::Billing::V2::BaseService include Enterprise::Billing::V2::Concerns::PaymentIntentHandler + include Enterprise::Billing::Concerns::PlanFeatureManager + include Enterprise::Billing::Concerns::PlanFeatureManager def subscribe_to_pricing_plan(pricing_plan_id:, customer_id: nil, meter_id: nil, meter_event_name: nil) @pricing_plan_id = pricing_plan_id @customer_id = customer_id || stripe_customer_id - @meter_id = meter_id - @meter_event_name = meter_event_name + # Use shared meter from ENV if not explicitly provided + @meter_id = meter_id || shared_meter_id + @meter_event_name = meter_event_name || shared_meter_event_name validate_subscription_params execute_subscription_flow @@ -36,7 +40,9 @@ class Enterprise::Billing::V2::SubscribeCustomerService < Enterprise::Billing::V return { success: false, message: 'Failed to create billing intent' } unless intent reserve_and_commit_intent(intent.id) - update_account_subscription_info(pricing_plan) + update_account_subscription_info(pricing_plan, cadence.id) + refresh_monthly_credits(pricing_plan) + enable_plan_features(pricing_plan) build_subscription_result(cadence.id, intent.id) end @@ -147,15 +153,21 @@ class Enterprise::Billing::V2::SubscribeCustomerService < Enterprise::Billing::V end def commit_intent(intent_id) - ensure_payment_method - intent = fetch_billing_intent(intent_id) - payment_intent_id = create_payment_if_needed(intent, intent_id) + # Only require payment method if there's an amount due + ensure_payment_method(intent) if requires_payment?(intent) + + payment_intent_id = create_payment_if_needed(intent, intent_id) commit_billing_intent(intent_id, payment_intent_id) end - def ensure_payment_method + def requires_payment?(intent) + amount_due = intent.amount_details&.total || intent.amount_details.total + amount_due&.to_i&.positive? + end + + def ensure_payment_method(_intent) # Check if customer already has a payment method customer = Stripe::Customer.retrieve( @customer_id, @@ -170,11 +182,12 @@ class Enterprise::Billing::V2::SubscribeCustomerService < Enterprise::Billing::V 'Payment method required. Customer must add payment method via Stripe Checkout or SetupIntent before subscribing.' end - def update_account_subscription_info(pricing_plan) + def update_account_subscription_info(pricing_plan, cadence_id) attributes = { 'stripe_billing_version' => 2, 'stripe_customer_id' => @customer_id, 'stripe_pricing_plan_id' => @pricing_plan_id, + 'stripe_cadence_id' => cadence_id, 'plan_name' => extract_plan_name(pricing_plan), 'subscription_status' => 'active' } @@ -189,11 +202,121 @@ class Enterprise::Billing::V2::SubscribeCustomerService < Enterprise::Billing::V display_name = pricing_plan['display_name'] || pricing_plan[:display_name] return 'Business' unless display_name - # Extract plan name from display name like "Chatwoot Business - 2000 Credits" - display_name.split('-').first.strip.split.last || 'Business' + # Extract plan name from display name + # Supports formats: + # - "Chatwoot Business - 2000 Credits" → Business + # - "Chatwoot Startup Plan" → Startup + name_part = display_name.split('-').first.strip + + # Try to find known tier names in the string + %w[Enterprise Business Startup Hacker].each do |tier| + return tier if name_part.include?(tier) + end + + # Fallback: try to extract last word before "Plan" or just last word + name_part.gsub(/\s+Plan$/, '').strip.split.last || 'Business' + end + + def refresh_monthly_credits(pricing_plan) + credits = Enterprise::Billing::V2::PlanCatalog.monthly_credits_for(@pricing_plan_id) + credits ||= extract_credits_from_pricing_plan(pricing_plan) + return unless credits + + Enterprise::Billing::V2::CreditManagementService + .new(account: account) + .sync_monthly_credits(credits.to_i) + end + + def extract_credits_from_pricing_plan(pricing_plan) + components = get_plan_components(pricing_plan) + return unless components&.any? + + find_service_action_credits(components) + end + + def get_plan_components(pricing_plan) + components = pricing_plan['components'] + components&.any? ? components : fetch_plan_components + end + + def find_service_action_credits(components) + components.each do |component| + next unless safe_fetch(component, :type) == 'service_action' + + amount = service_action_credit_amount(component) + return amount if amount.present? + end + nil + end + + def fetch_plan_components + response = StripeV2Client.request( + :get, + "/v2/billing/pricing_plans/#{@pricing_plan_id}/components", + {}, + stripe_api_options + ) + response.data + rescue StandardError => e + Rails.logger.error("Failed to fetch plan components for #{@pricing_plan_id}: #{e.message}") + [] + end + + def service_action_credit_amount(component) + action_id = safe_fetch(component, :service_action, :id) + return unless action_id + + action_response = StripeV2Client.request( + :get, + "/v2/billing/service_actions/#{action_id}", + {}, + stripe_api_options + ) + + credit = action_response.credit_grant&.amount&.custom_pricing_unit&.value + credit&.to_i + rescue StandardError => e + Rails.logger.error("Failed to fetch service action #{action_id}: #{e.message}") + nil end def stripe_api_options { api_key: ENV.fetch('STRIPE_SECRET_KEY', nil), stripe_version: '2025-08-27.preview' } end + + def shared_meter_id + InstallationConfig.find_by(name: 'STRIPE_METER_ID')&.value || + ENV.fetch('STRIPE_METER_ID', nil) + end + + def shared_meter_event_name + InstallationConfig.find_by(name: 'STRIPE_METER_EVENT_NAME')&.value || + ENV.fetch('STRIPE_METER_EVENT_NAME', nil) + end + + def safe_fetch(object, *keys) + keys.reduce(object) do |memo, key| + break nil if memo.nil? + + if memo.respond_to?(key) + memo.public_send(key) + elsif memo.respond_to?(:[]) + memo[key.to_s] || memo[key] + end + end + end + + def enable_plan_features(pricing_plan) + plan_name = extract_plan_name(pricing_plan) + Rails.logger.info "Enabling features for V2 plan: #{plan_name} (account #{account.id})" + + # Use shared feature management logic + update_plan_features(plan_name) + + # Reset AI usage counters + reset_captain_usage + + Rails.logger.info "Features enabled for account #{account.id}: #{account.enabled_features.keys.join(', ')}" + end end +# rubocop:enable Metrics/ClassLength diff --git a/enterprise/app/services/enterprise/billing/v2/usage_reporter_service.rb b/enterprise/app/services/enterprise/billing/v2/usage_reporter_service.rb index 208968d30..299a8d0b7 100644 --- a/enterprise/app/services/enterprise/billing/v2/usage_reporter_service.rb +++ b/enterprise/app/services/enterprise/billing/v2/usage_reporter_service.rb @@ -16,13 +16,12 @@ class Enterprise::Billing::V2::UsageReporterService < Enterprise::Billing::V2::B private def valid_configuration? - custom_attribute('stripe_customer_id').present? && - custom_attribute('stripe_meter_event_name').present? + custom_attribute('stripe_customer_id').present? && meter_event_name.present? end def meter_event_params(credits_used) { - event_name: custom_attribute('stripe_meter_event_name'), + event_name: meter_event_name, payload: { value: credits_used.to_s, stripe_customer_id: custom_attribute('stripe_customer_id') @@ -31,6 +30,15 @@ class Enterprise::Billing::V2::UsageReporterService < Enterprise::Billing::V2::B } end + def meter_event_name + # Use shared meter event name from ENV/InstallationConfig (preferred) + # Falls back to account-specific event name for backward compatibility + shared_event_name = InstallationConfig.find_by(name: 'STRIPE_METER_EVENT_NAME')&.value || + ENV.fetch('STRIPE_METER_EVENT_NAME', nil) + + shared_event_name || custom_attribute('stripe_meter_event_name') + end + def stripe_api_options { api_key: ENV.fetch('STRIPE_SECRET_KEY', nil), stripe_version: '2025-08-27.preview' } end diff --git a/enterprise/lib/enterprise/integrations/openai_processor_service.rb b/enterprise/lib/enterprise/integrations/openai_processor_service.rb index 70428c97d..f9acdd06e 100644 --- a/enterprise/lib/enterprise/integrations/openai_processor_service.rb +++ b/enterprise/lib/enterprise/integrations/openai_processor_service.rb @@ -105,6 +105,6 @@ module Enterprise::Integrations::OpenaiProcessorService end def v2_enabled? - hook.account.custom_attributes&.[]('stripe_billing_version').to_i == 2 + ENV.fetch('STRIPE_BILLING_V2_ENABLED', 'false') == 'true' end end diff --git a/spec/enterprise/jobs/enterprise/create_stripe_customer_job_spec.rb b/spec/enterprise/jobs/enterprise/create_stripe_customer_job_spec.rb index b2c945800..770f5681d 100644 --- a/spec/enterprise/jobs/enterprise/create_stripe_customer_job_spec.rb +++ b/spec/enterprise/jobs/enterprise/create_stripe_customer_job_spec.rb @@ -12,16 +12,41 @@ RSpec.describe Enterprise::CreateStripeCustomerJob, type: :job do .on_queue('default') end - it 'executes perform' do - create_stripe_customer_service = double - allow(Enterprise::Billing::CreateStripeCustomerService) - .to receive(:new) - .with(account: account) - .and_return(create_stripe_customer_service) - allow(create_stripe_customer_service).to receive(:perform) + context 'when V1 billing' do + before do + allow(ENV).to receive(:fetch).with('STRIPE_BILLING_V2_ENABLED', 'false').and_return('false') + end - perform_enqueued_jobs { job } + it 'uses V1 customer creation service' do + create_stripe_customer_service = double + allow(Enterprise::Billing::CreateStripeCustomerService) + .to receive(:new) + .with(account: account) + .and_return(create_stripe_customer_service) + allow(create_stripe_customer_service).to receive(:perform) - expect(Enterprise::Billing::CreateStripeCustomerService).to have_received(:new).with(account: account) + perform_enqueued_jobs { job } + + expect(Enterprise::Billing::CreateStripeCustomerService).to have_received(:new).with(account: account) + end + end + + context 'when V2 billing' do + before do + allow(ENV).to receive(:fetch).with('STRIPE_BILLING_V2_ENABLED', 'false').and_return('true') + end + + it 'uses V2 customer creation service' do + v2_service = double + allow(Enterprise::Billing::V2::CustomerCreationService) + .to receive(:new) + .with(account: account) + .and_return(v2_service) + allow(v2_service).to receive(:perform) + + perform_enqueued_jobs { job } + + expect(Enterprise::Billing::V2::CustomerCreationService).to have_received(:new).with(account: account) + end end end