diff --git a/app/controllers/dashboard_controller.rb b/app/controllers/dashboard_controller.rb index 8aa7ed34a..d81b4c9da 100644 --- a/app/controllers/dashboard_controller.rb +++ b/app/controllers/dashboard_controller.rb @@ -35,6 +35,7 @@ class DashboardController < ActionController::Base 'HCAPTCHA_SITE_KEY', 'LOGOUT_REDIRECT_LINK', 'DISABLE_USER_PROFILE_UPDATE', + 'DEPLOYMENT_ENV', 'INSTALLATION_PRICING_PLAN' ).merge(app_config) end @@ -70,7 +71,6 @@ 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/account.rb b/app/models/account.rb index ae7d570ee..a8eaa04ff 100644 --- a/app/models/account.rb +++ b/app/models/account.rb @@ -108,7 +108,6 @@ class Account < ApplicationRecord before_validation :validate_limit_keys after_create_commit :notify_creation - after_create :initialize_v2_billing after_destroy :remove_account_sequences def agents @@ -166,16 +165,6 @@ class Account < ApplicationRecord Rails.configuration.dispatcher.dispatch(ACCOUNT_CREATED, Time.zone.now, account: self) end - def initialize_v2_billing - # Initialize V2 billing for Chatwoot Cloud accounts - return unless ChatwootApp.chatwoot_cloud? - - # Set stripe_billing_version to 2 for all new accounts - # rubocop:disable Rails/SkipsModelValidations - update_columns(custom_attributes: (custom_attributes || {}).merge('stripe_billing_version' => 2)) - # rubocop:enable Rails/SkipsModelValidations - end - trigger.after(:insert).for_each(:row) do "execute format('create sequence IF NOT EXISTS conv_dpid_seq_%s', NEW.id);" end diff --git a/app/policies/account_policy.rb b/app/policies/account_policy.rb index a5ebc54d9..17e4f7564 100644 --- a/app/policies/account_policy.rb +++ b/app/policies/account_policy.rb @@ -47,10 +47,6 @@ class AccountPolicy < ApplicationPolicy @account_user.administrator? end - def v2_buy? - @account_user.administrator? - end - def v2_subscribe? @account_user.administrator? end diff --git a/app/views/api/v1/models/_account.json.jbuilder b/app/views/api/v1/models/_account.json.jbuilder index d4d1c45ee..af9235b4b 100644 --- a/app/views/api/v1/models/_account.json.jbuilder +++ b/app/views/api/v1/models/_account.json.jbuilder @@ -16,7 +16,6 @@ if resource.custom_attributes.present? json.marked_for_deletion_reason resource.custom_attributes['marked_for_deletion_reason'] end # V2 Billing attributes - json.stripe_billing_version resource.custom_attributes['stripe_billing_version'] if resource.custom_attributes['stripe_billing_version'].present? json.stripe_customer_id resource.custom_attributes['stripe_customer_id'] if resource.custom_attributes['stripe_customer_id'].present? json.stripe_pricing_plan_id resource.custom_attributes['stripe_pricing_plan_id'] if resource.custom_attributes['stripe_pricing_plan_id'].present? json.stripe_cadence_id resource.custom_attributes['stripe_cadence_id'] if resource.custom_attributes['stripe_cadence_id'].present? diff --git a/enterprise/app/controllers/enterprise/api/v1/accounts_controller.rb b/enterprise/app/controllers/enterprise/api/v1/accounts_controller.rb index 66add3777..c372d857e 100644 --- a/enterprise/app/controllers/enterprise/api/v1/accounts_controller.rb +++ b/enterprise/app/controllers/enterprise/api/v1/accounts_controller.rb @@ -3,6 +3,7 @@ class Enterprise::Api::V1::AccountsController < Api::BaseController before_action :fetch_account before_action :check_authorization before_action :check_cloud_env, only: [:limits, :toggle_deletion] + before_action :validate_topup_amount, only: [:v2_topup] def subscription if stripe_customer_id.blank? && @account.custom_attributes['is_creating_customer'].blank? @@ -72,7 +73,6 @@ class Enterprise::Api::V1::AccountsController < Api::BaseController def v2_pricing_plans plans = Enterprise::Billing::V2::PlanCatalog.plans - render json: { pricing_plans: plans } end @@ -82,11 +82,8 @@ class Enterprise::Api::V1::AccountsController < Api::BaseController end def v2_topup - amount = params[:credits].to_i - return render json: { error: 'Topup amount must be greater than 0' }, status: :unprocessable_entity if amount <= 0 - service = Enterprise::Billing::V2::TopupService.new(account: @account) - result = service.create_topup(credits: amount) + result = service.create_topup(credits: params[:credits].to_i) if result[:success] render json: { success: true, message: result[:message] } @@ -114,12 +111,7 @@ class Enterprise::Api::V1::AccountsController < Api::BaseController result = service.cancel_subscription if result[:success] - render json: { - success: true, - message: result[:message], - cancel_at_period_end: result[:cancel_at_period_end], - period_end: result[:period_end] - } + render json: result else render json: { error: result[:message] }, status: :unprocessable_entity end @@ -199,6 +191,13 @@ class Enterprise::Api::V1::AccountsController < Api::BaseController quantity.positive? ? quantity : 1 end + def validate_topup_amount + amount = params[:credits].to_i + return if amount.positive? + + render json: { error: 'Topup amount must be greater than 0' }, status: :unprocessable_entity + end + def pundit_user { user: current_user, diff --git a/enterprise/app/controllers/enterprise/webhooks/stripe_controller.rb b/enterprise/app/controllers/enterprise/webhooks/stripe_controller.rb index 6aa882a0a..747f70d69 100644 --- a/enterprise/app/controllers/enterprise/webhooks/stripe_controller.rb +++ b/enterprise/app/controllers/enterprise/webhooks/stripe_controller.rb @@ -13,7 +13,7 @@ class Enterprise::Webhooks::StripeController < ActionController::API # Check if this is a V2 billing event if v2_billing_event?(event) - handle_v2_event(event) + ::Enterprise::Billing::V2::WebhookHandlerService.new.perform(event: event) else ::Enterprise::Billing::HandleStripeEventService.new.perform(event: event) end @@ -35,62 +35,14 @@ class Enterprise::Webhooks::StripeController < ActionController::API parsed_payload = JSON.parse(payload) event_type = parsed_payload['type'] - # Use V2 webhook secret for V2 events, V1 secret for everything else - if event_type&.start_with?('v2.') + if v2_billing_event?(event_type) ENV.fetch('STRIPE_WEBHOOK_SECRET_V2', nil) else ENV.fetch('STRIPE_WEBHOOK_SECRET', nil) end end - def v2_billing_event?(event) - event.type.start_with?('v2.') - end - - def handle_v2_event(event) - account = find_account_for_v2_event(event) - return unless account && account_v2_enabled?(account) - - service = ::Enterprise::Billing::V2::WebhookHandlerService.new(account: account) - service.process(event) - end - - def find_account_for_v2_event(event) - related_object = event.related_object - subscription_id = related_object.id - - customer_id = fetch_customer_id_from_subscription(subscription_id) - if customer_id.present? - account = Account.find_by("custom_attributes->>'stripe_customer_id' = ?", customer_id) - return account if account - end - - Rails.logger.warn "Could not find account for subscription #{subscription_id}" - nil - end - - def fetch_customer_id_from_subscription(subscription_id) - # Step 1: Fetch subscription - subscription = StripeV2Client.request( - :get, - "/v2/billing/pricing_plan_subscriptions/#{subscription_id}", - {}, - { api_key: ENV.fetch('STRIPE_SECRET_KEY', nil), stripe_version: '2025-08-27.preview' } - ) - return nil unless subscription&.billing_cadence - - # Step 2: Fetch billing cadence - cadence = StripeV2Client.request( - :get, - "/v2/billing/cadences/#{subscription.billing_cadence}", - {}, - { api_key: ENV.fetch('STRIPE_SECRET_KEY', nil), stripe_version: '2025-08-27.preview' } - ) - # Step 3: Extract customer from payer - cadence.payer&.customer - end - - def account_v2_enabled?(_account) - ENV.fetch('STRIPE_BILLING_V2_ENABLED', 'false') == 'true' + def v2_billing_event?(event_type) + event_type.start_with?('v2.') end end diff --git a/enterprise/app/jobs/captain/conversation/response_builder_job.rb b/enterprise/app/jobs/captain/conversation/response_builder_job.rb index 15f2ace56..cd781041e 100644 --- a/enterprise/app/jobs/captain/conversation/response_builder_job.rb +++ b/enterprise/app/jobs/captain/conversation/response_builder_job.rb @@ -114,6 +114,7 @@ class Captain::Conversation::ResponseBuilderJob < ApplicationJob def handle_error(error) log_error(error) + refund_credit process_action('handoff') true end @@ -125,4 +126,18 @@ class Captain::Conversation::ResponseBuilderJob < ApplicationJob def captain_v2_enabled? return account.feature_enabled?('captain_integration_v2') end + + def refund_credit + credit_service = Enterprise::Ai::CaptainCreditService.new(conversation: @conversation) + credit_service.check_and_use_credits( + feature: 'ai_captain_conversation', + amount: -1, # Negative to refund + metadata: { + 'assistant_id' => @assistant.id, + 'conversation_id' => @conversation.id, + 'inbox_id' => @inbox.id, + 'refund' => 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 6ccaa2f33..6b90cbec5 100644 --- a/enterprise/app/jobs/enterprise/create_stripe_customer_job.rb +++ b/enterprise/app/jobs/enterprise/create_stripe_customer_job.rb @@ -2,7 +2,6 @@ class Enterprise::CreateStripeCustomerJob < ApplicationJob queue_as :default def perform(account) - # Use V1 service - creates customer and stores customer_id Enterprise::Billing::CreateStripeCustomerService.new(account: account).perform end end diff --git a/enterprise/app/services/captain/copilot/chat_service.rb b/enterprise/app/services/captain/copilot/chat_service.rb index 00c6f6320..d439a6e49 100644 --- a/enterprise/app/services/captain/copilot/chat_service.rb +++ b/enterprise/app/services/captain/copilot/chat_service.rb @@ -25,7 +25,6 @@ class Captain::Copilot::ChatService < Llm::BaseOpenAiService unless credit_result[:success] error_message = credit_result[:message] || 'Insufficient credits' - Rails.logger.warn("#{self.class.name} Credit check failed for account #{@account.id}: #{error_message}") return { error: error_message, error_code: 402 } end diff --git a/enterprise/app/services/enterprise/billing/concerns/plan_feature_manager.rb b/enterprise/app/services/enterprise/billing/concerns/plan_feature_manager.rb index e487d5bcd..228209a1f 100644 --- a/enterprise/app/services/enterprise/billing/concerns/plan_feature_manager.rb +++ b/enterprise/app/services/enterprise/billing/concerns/plan_feature_manager.rb @@ -82,14 +82,10 @@ module Enterprise::Billing::Concerns::PlanFeatureManager def enable_account_manually_managed_features # Get manually managed features from internal attributes using the service - return unless defined?(Internal::Accounts::InternalAttributesService) - service = Internal::Accounts::InternalAttributesService.new(account) features = service.manually_managed_features # Enable each feature account.enable_features(*features) if features.present? - rescue StandardError - # Silently handle errors - account will continue with plan-specific features 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 f231e01bd..ad4f8f45b 100644 --- a/enterprise/app/services/enterprise/billing/create_session_service.rb +++ b/enterprise/app/services/enterprise/billing/create_session_service.rb @@ -1,12 +1,9 @@ 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, - configuration: PORTAL_CONFIGURATION_ID + return_url: return_url } ) end 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 594032200..e4df1050b 100644 --- a/enterprise/app/services/enterprise/billing/create_stripe_customer_service.rb +++ b/enterprise/app/services/enterprise/billing/create_stripe_customer_service.rb @@ -1,33 +1,69 @@ class Enterprise::Billing::CreateStripeCustomerService pattr_initialize [:account!] - def perform - return if customer_exists? + DEFAULT_QUANTITY = 2 - customer_id = create_customer - save_customer_id(customer_id) + def perform + 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'] + } + ) end private - def customer_exists? - account.custom_attributes['stripe_customer_id'].present? + def prepare_customer_id + customer_id = account.custom_attributes['stripe_customer_id'] + if customer_id.blank? + customer = Stripe::Customer.create({ name: account.name, email: billing_email }) + customer_id = customer.id + end + customer_id end - def create_customer - customer = Stripe::Customer.create({ name: account.name, email: billing_email }) - customer.id - end - - def save_customer_id(customer_id) - account.update!( - custom_attributes: { - stripe_customer_id: customer_id - } - ) + def default_quantity + default_plan['default_quantity'] || DEFAULT_QUANTITY end def billing_email account.administrators.first.email end + + def default_plan + installation_config = InstallationConfig.find_by(name: 'CHATWOOT_CLOUD_PLANS') + @default_plan ||= installation_config.value.first + end + + def price_id + price_ids = default_plan['price_ids'] + price_ids.first + end + + def existing_subscription? + stripe_customer_id = account.custom_attributes['stripe_customer_id'] + return false if stripe_customer_id.blank? + + subscriptions = Stripe::Subscription.list( + { + customer: stripe_customer_id, + status: 'active', + limit: 1 + } + ) + subscriptions.data.present? + 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 e2ab8aff2..bb1df4f54 100644 --- a/enterprise/app/services/enterprise/billing/handle_stripe_event_service.rb +++ b/enterprise/app/services/enterprise/billing/handle_stripe_event_service.rb @@ -15,6 +15,8 @@ class Enterprise::Billing::HandleStripeEventService process_credit_grant_created when 'billing.credit_grant.updated' process_credit_grant_updated + else + Rails.logger.debug { "Unhandled event type: #{event.type}" } end end diff --git a/enterprise/app/services/enterprise/billing/v2/checkout_session_service.rb b/enterprise/app/services/enterprise/billing/v2/checkout_session_service.rb index 3271a76df..739285721 100644 --- a/enterprise/app/services/enterprise/billing/v2/checkout_session_service.rb +++ b/enterprise/app/services/enterprise/billing/v2/checkout_session_service.rb @@ -28,15 +28,11 @@ class Enterprise::Billing::V2::CheckoutSessionService < Enterprise::Billing::V2: create_checkout_session rescue Stripe::StripeError => e { success: false, message: "Stripe API error: #{e.message}", error: e } - rescue StandardError => e - { success: false, message: "Checkout error: #{e.message}", error: e } end private def validate_params - raise StandardError, 'Pricing Plan ID required' if @pricing_plan_id.blank? - customer_id = custom_attribute('stripe_customer_id') raise StandardError, 'Customer ID required. Please create a Stripe customer first.' if customer_id.blank? end diff --git a/enterprise/app/services/enterprise/billing/v2/concerns/payment_intent_handler.rb b/enterprise/app/services/enterprise/billing/v2/concerns/payment_intent_handler.rb deleted file mode 100644 index 8fb6a137b..000000000 --- a/enterprise/app/services/enterprise/billing/v2/concerns/payment_intent_handler.rb +++ /dev/null @@ -1,66 +0,0 @@ -module Enterprise::Billing::V2::Concerns::PaymentIntentHandler - extend ActiveSupport::Concern - - private - - def create_payment_if_needed(intent, intent_id) - amount_due = intent.amount_details&.total || intent.amount_details.total - return nil unless amount_due&.to_i&.positive? - - payment_method_id = fetch_default_payment_method - create_upfront_payment_intent(amount_due, intent.currency, payment_method_id, intent_id) - end - - def fetch_default_payment_method - customer = Stripe::Customer.retrieve( - @customer_id, - { api_key: ENV.fetch('STRIPE_SECRET_KEY', nil), stripe_version: '2025-08-27.preview' } - ) - customer.invoice_settings&.default_payment_method - end - - def create_upfront_payment_intent(amount_due, currency, payment_method_id, intent_id) - payment_intent = Stripe::PaymentIntent.create( - payment_intent_params(amount_due, currency, payment_method_id, intent_id), - { api_key: ENV.fetch('STRIPE_SECRET_KEY', nil), stripe_version: '2025-08-27.preview' } - ) - - payment_intent.id - end - - def payment_intent_params(amount_due, currency, payment_method_id, intent_id) - { - amount: amount_due, - currency: currency || 'usd', - customer: @customer_id, - payment_method: payment_method_id, - automatic_payment_methods: { - enabled: true, - allow_redirects: 'never' - }, - confirm: true, - off_session: true, - metadata: { billing_intent_id: intent_id } - } - end - - def fetch_billing_intent(intent_id) - StripeV2Client.request( - :get, - "/v2/billing/intents/#{intent_id}", - {}, - stripe_api_options - ) - end - - def commit_billing_intent(intent_id, payment_intent_id) - commit_params = payment_intent_id ? { payment_intent: payment_intent_id } : {} - - StripeV2Client.request( - :post, - "/v2/billing/intents/#{intent_id}/commit", - commit_params, - stripe_api_options - ) - end -end diff --git a/enterprise/app/services/enterprise/billing/v2/subscription_provisioning_service.rb b/enterprise/app/services/enterprise/billing/v2/subscription_provisioning_service.rb index 79b3152ef..6bd595e40 100644 --- a/enterprise/app/services/enterprise/billing/v2/subscription_provisioning_service.rb +++ b/enterprise/app/services/enterprise/billing/v2/subscription_provisioning_service.rb @@ -1,21 +1,5 @@ class Enterprise::Billing::V2::SubscriptionProvisioningService < Enterprise::Billing::V2::BaseService - # Features for each plan tier (matching the plan hierarchy) - STARTUP_FEATURES = %w[ - inbound_emails - help_center - campaigns - team_management - channel_twitter - channel_facebook - channel_email - channel_instagram - captain_integration - advanced_search_indexing - ].freeze - - BUSINESS_FEATURES = (STARTUP_FEATURES + %w[sla custom_roles]).freeze - - ENTERPRISE_FEATURES = (BUSINESS_FEATURES + %w[audit_logs disable_branding saml]).freeze + include Enterprise::Billing::Concerns::PlanFeatureManager def provision(subscription_id:) # Retrieve pricing plan subscription details from Stripe V2 API @@ -102,8 +86,15 @@ class Enterprise::Billing::V2::SubscriptionProvisioningService < Enterprise::Bil # Sync monthly credits based on plan sync_plan_credits(pricing_plan_id) - # Enable plan features based on plan - enable_plan_features(pricing_plan_id) + # Extract plan name and enable features using PlanFeatureManager + plan_definition = Enterprise::Billing::V2::PlanCatalog.definition_for(pricing_plan_id) + if plan_definition + plan_name = extract_plan_name(plan_definition) + enable_plan_specific_features(plan_name) if plan_name.present? + end + + # Reset captain usage after provisioning + reset_captain_usage end def sync_plan_credits(pricing_plan_id) @@ -115,35 +106,10 @@ class Enterprise::Billing::V2::SubscriptionProvisioningService < Enterprise::Bil .sync_monthly_credits(plan_credits.to_i) end - def enable_plan_features(pricing_plan_id) - plan_definition = Enterprise::Billing::V2::PlanCatalog.definition_for(pricing_plan_id) - return unless plan_definition - - plan_tier = extract_plan_tier(plan_definition) - return unless plan_tier - - features_to_enable = features_for_plan_tier(plan_tier) - return if features_to_enable.empty? - - # Enable each feature using the account method - account.enable_features(*features_to_enable) - end - - def extract_plan_tier(plan_definition) - plan_definition[:display_name].split.find { |word| %w[Startup Business Enterprise].include?(word) } - end - - def features_for_plan_tier(plan_tier) - case plan_tier - when 'Startup' - STARTUP_FEATURES - when 'Business' - BUSINESS_FEATURES - when 'Enterprise' - ENTERPRISE_FEATURES - else - [] - end + def extract_plan_name(plan_definition) + # Extract plan name like "Startup", "Business", or "Enterprise" from display_name + # e.g., "Chatwoot Startup" -> "Startup" + plan_definition[:display_name].split.find { |word| %w[Startup Startups Business Enterprise].include?(word) } end def stripe_api_options diff --git a/enterprise/app/services/enterprise/billing/v2/subscription_service.rb b/enterprise/app/services/enterprise/billing/v2/subscription_service.rb deleted file mode 100644 index 6118296df..000000000 --- a/enterprise/app/services/enterprise/billing/v2/subscription_service.rb +++ /dev/null @@ -1,44 +0,0 @@ -class Enterprise::Billing::V2::SubscriptionService < Enterprise::Billing::V2::BaseService - def migrate_to_v2(plan_type: 'startup', monthly_credits: 100) - return { success: false, message: 'Already on V2' } if v2_enabled? - - with_locked_account do - apply_migration_attributes(plan_type, monthly_credits) - log_migration_grant(plan_type, monthly_credits) - end - - { success: true, message: 'Successfully migrated to V2 billing' } - rescue StandardError => e - { success: false, message: e.message } - end - - def update_plan(plan_type) - return { success: false, message: 'Not on V2 billing' } unless v2_enabled? - - update_custom_attributes('plan_name' => plan_type.capitalize) - - { success: true, plan: plan_type } - end - - private - - def apply_migration_attributes(plan_type, credits) - update_custom_attributes( - 'stripe_billing_version' => 2, - 'monthly_credits' => credits, - 'topup_credits' => 0, - 'plan_name' => plan_type.capitalize, - 'subscription_status' => 'active' - ) - end - - def log_migration_grant(plan_type, credits) - log_credit_transaction( - type: 'grant', - amount: credits, - credit_type: 'monthly', - description: "Initial V2 migration grant - #{plan_type} plan", - metadata: { 'source' => 'migration', 'plan_type' => plan_type } - ) - end -end diff --git a/enterprise/app/services/enterprise/billing/v2/topup_service.rb b/enterprise/app/services/enterprise/billing/v2/topup_service.rb index 547cdab2f..0b2249a99 100644 --- a/enterprise/app/services/enterprise/billing/v2/topup_service.rb +++ b/enterprise/app/services/enterprise/billing/v2/topup_service.rb @@ -12,8 +12,6 @@ class Enterprise::Billing::V2::TopupService < Enterprise::Billing::V2::BaseServi end rescue Stripe::StripeError => e { success: false, message: "Stripe error: #{e.message}" } - rescue StandardError => e - { success: false, message: "Topup error: #{e.message}" } end private diff --git a/enterprise/app/services/enterprise/billing/v2/webhook_handler_service.rb b/enterprise/app/services/enterprise/billing/v2/webhook_handler_service.rb index 39e191347..8f1f0c4b8 100644 --- a/enterprise/app/services/enterprise/billing/v2/webhook_handler_service.rb +++ b/enterprise/app/services/enterprise/billing/v2/webhook_handler_service.rb @@ -1,22 +1,62 @@ -class Enterprise::Billing::V2::WebhookHandlerService < Enterprise::Billing::V2::BaseService - def process(event) - case event.type +class Enterprise::Billing::V2::WebhookHandlerService + def perform(event:) + return { success: false, message: 'Account not found' } if account.blank? + + @event = event + + case @event.type when 'v2.billing.pricing_plan_subscription.servicing_activated' - Rails.logger.info "Handling subscription servicing activated event: #{event.related_object.id}" - Rails.logger.info "Related object: #{event.related_object.inspect}" - handle_subscription_servicing_activated(event.related_object.id) + Rails.logger.info "Handling subscription servicing activated event: #{@event.related_object.id}" + handle_subscription_servicing_activated(@event.related_object.id) else { success: true } end rescue StandardError => e + Rails.logger.error "Error processing V2 webhook: #{e.message}" { success: false, error: e.message } end private + def account + @account ||= begin + related_object = @event.related_object + subscription_id = related_object.id + + customer_id = fetch_customer_id_from_subscription(subscription_id) + found_account = Account.find_by("custom_attributes->>'stripe_customer_id' = ?", customer_id) if customer_id.present? + + Rails.logger.warn "Could not find account for subscription #{subscription_id}" if found_account.blank? + + found_account + end + end + + def fetch_customer_id_from_subscription(subscription_id) + subscription = StripeV2Client.request( + :get, + "/v2/billing/pricing_plan_subscriptions/#{subscription_id}", + {}, + stripe_api_options + ) + return nil unless subscription&.billing_cadence + + cadence = StripeV2Client.request( + :get, + "/v2/billing/cadences/#{subscription.billing_cadence}", + {}, + stripe_api_options + ) + cadence.payer&.customer + end + def handle_subscription_servicing_activated(subscription_id) Enterprise::Billing::V2::SubscriptionProvisioningService .new(account: account) .provision(subscription_id: subscription_id) end + + def stripe_api_options + { api_key: ENV.fetch('STRIPE_SECRET_KEY', nil), stripe_version: '2025-08-27.preview' } + end end diff --git a/enterprise/app/services/messages/audio_transcription_service.rb b/enterprise/app/services/messages/audio_transcription_service.rb index 278f41d71..725d5ef0e 100644 --- a/enterprise/app/services/messages/audio_transcription_service.rb +++ b/enterprise/app/services/messages/audio_transcription_service.rb @@ -15,6 +15,10 @@ class Messages::AudioTranscriptionService < Llm::BaseOpenAiService transcriptions = transcribe_audio Rails.logger.info "Audio transcription successful: #{transcriptions}" { success: true, transcriptions: transcriptions } + rescue StandardError => e + # Refund credit if transcription failed + refund_credit + raise e end private @@ -26,6 +30,20 @@ class Messages::AudioTranscriptionService < Llm::BaseOpenAiService account.usage_limits[:captain][:responses][:current_available].positive? end + def refund_credit + credit_service = Enterprise::Ai::CaptainCreditService.new(account: account) + credit_service.check_and_use_credits( + feature: 'ai_audio_transcription', + amount: -1, # Negative to refund + metadata: { + 'message_id' => message.id, + 'attachment_id' => attachment.id, + 'conversation_id' => message.conversation_id, + 'refund' => true + } + ) + end + def fetch_audio_file temp_dir = Rails.root.join('tmp/uploads') FileUtils.mkdir_p(temp_dir) diff --git a/spec/enterprise/controllers/enterprise/webooks/stripe_controller_spec.rb b/spec/enterprise/controllers/enterprise/webooks/stripe_controller_spec.rb index c8e4b3f00..3b5b68e46 100644 --- a/spec/enterprise/controllers/enterprise/webooks/stripe_controller_spec.rb +++ b/spec/enterprise/controllers/enterprise/webooks/stripe_controller_spec.rb @@ -19,24 +19,16 @@ RSpec.describe 'Enterprise::Webhooks::StripeController', type: :request do end it 'delegates v2 billing events to the v2 webhook handler' do - account = create(:account) - account.update!(custom_attributes: (account.custom_attributes || {}).merge( - 'stripe_billing_version' => 2, - 'stripe_customer_id' => 'cus_123' - )) - - event_object = double('StripeObject', customer: 'cus_123') - event = double('Stripe::Event', type: 'billing.credit_grant.created', data: double(object: event_object)) - handler_double = instance_double(Enterprise::Billing::V2::WebhookHandlerService, process: { success: true }) + event = double('Stripe::Event', type: 'v2.billing.pricing_plan_subscription.servicing_activated') + handler_double = instance_double(Enterprise::Billing::V2::WebhookHandlerService, perform: { success: true }) allow(Stripe::Webhook).to receive(:construct_event).and_return(event) - allow(Enterprise::Billing::HandleStripeEventService).to receive(:new) - allow(Enterprise::Billing::V2::WebhookHandlerService).to receive(:new).with(account: account).and_return(handler_double) + allow(Enterprise::Billing::V2::WebhookHandlerService).to receive(:new).and_return(handler_double) post '/enterprise/webhooks/stripe', headers: { 'Stripe-Signature': 'test' }, params: params - expect(Enterprise::Billing::V2::WebhookHandlerService).to have_received(:new).with(account: account) - expect(handler_double).to have_received(:process).with(event) + expect(Enterprise::Billing::V2::WebhookHandlerService).to have_received(:new) + expect(handler_double).to have_received(:perform).with(event: event) expect(response).to have_http_status(:ok) end diff --git a/spec/enterprise/services/enterprise/billing/v2/subscription_service_spec.rb b/spec/enterprise/services/enterprise/billing/v2/subscription_service_spec.rb deleted file mode 100644 index b9bb36a27..000000000 --- a/spec/enterprise/services/enterprise/billing/v2/subscription_service_spec.rb +++ /dev/null @@ -1,61 +0,0 @@ -require 'rails_helper' - -describe Enterprise::Billing::V2::SubscriptionService do - let(:account) { create(:account) } - let(:service) { described_class.new(account: account) } - - describe '#migrate_to_v2' do - it 'updates account attributes and logs initial credit grant' do # rubocop:disable RSpec/MultipleExpectations - result = nil - - expect do - result = service.migrate_to_v2(plan_type: 'startup', monthly_credits: 800) - end.to change { account.reload.credit_transactions.count }.by(1) - - account.reload - expect(result).to include(success: true) - expect(account.custom_attributes['stripe_billing_version']).to eq(2) - expect(account.custom_attributes['monthly_credits']).to eq(800) - expect(account.custom_attributes['plan_name']).to eq('Startup') - - transaction = account.credit_transactions.order(created_at: :desc).first - expect(transaction.amount).to eq(800) - expect(transaction.metadata['source']).to eq('migration') - expect(transaction.metadata['plan_type']).to eq('startup') - end - - it 'uses default credits when not specified' do - result = service.migrate_to_v2 - - expect(result).to include(success: true) - expect(account.reload.custom_attributes['monthly_credits']).to eq(100) - end - - it 'returns error when already on v2' do - account.update!(custom_attributes: (account.custom_attributes || {}).merge('stripe_billing_version' => 2)) - - result = service.migrate_to_v2(monthly_credits: 500) - - expect(result[:success]).to be(false) - expect(result[:message]).to eq('Already on V2') - end - end - - describe '#update_plan' do - it 'updates plan name when on v2 billing' do - account.update!(custom_attributes: (account.custom_attributes || {}).merge('stripe_billing_version' => 2)) - - result = service.update_plan('business') - - expect(result).to include(success: true, plan: 'business') - expect(account.reload.custom_attributes['plan_name']).to eq('Business') - end - - it 'returns error when account not migrated' do - result = service.update_plan('business') - - expect(result[:success]).to be(false) - expect(result[:message]).to eq('Not on V2 billing') - end - end -end diff --git a/spec/enterprise/services/enterprise/billing/v2/webhook_handler_service_spec.rb b/spec/enterprise/services/enterprise/billing/v2/webhook_handler_service_spec.rb index 589fe1e80..34ce4e194 100644 --- a/spec/enterprise/services/enterprise/billing/v2/webhook_handler_service_spec.rb +++ b/spec/enterprise/services/enterprise/billing/v2/webhook_handler_service_spec.rb @@ -2,25 +2,44 @@ require 'rails_helper' # rubocop:disable RSpec/VerifiedDoubles describe Enterprise::Billing::V2::WebhookHandlerService do - let(:account) { create(:account) } - let(:service) { described_class.new(account: account) } + let(:account) do + create(:account, custom_attributes: { 'stripe_customer_id' => 'cus_123', 'stripe_billing_version' => 2, 'pending_subscription_quantity' => 5 }) + end + let(:service) { described_class.new } let(:credit_service) { instance_double(Enterprise::Billing::V2::CreditManagementService) } before do - account.update!(custom_attributes: { 'stripe_billing_version' => 2, 'pending_subscription_quantity' => 5 }) allow(Enterprise::Billing::V2::CreditManagementService).to receive(:new).with(account: account).and_return(credit_service) + allow(ENV).to receive(:fetch).and_call_original end - describe '#process' do + describe '#perform' do context 'when handling subscription servicing activated' do let(:subscription_response) do OpenStruct.new( id: 'bpps_subscription_123', pricing_plan: 'bpp_business_plan_123', + billing_cadence: 'cadence_123', component_values: [{ 'type' => 'license_fee', 'quantity' => 5 }] ) end + let(:cadence_response) do + OpenStruct.new( + id: 'cadence_123', + payer: OpenStruct.new(customer: 'cus_123') + ) + end + + let(:provisioning_response) do + { + success: true, + subscription_id: 'bpps_subscription_123', + pricing_plan_id: 'bpp_business_plan_123', + quantity: 5 + } + end + let(:event) do double( 'Stripe::Event', @@ -36,49 +55,47 @@ describe Enterprise::Billing::V2::WebhookHandlerService do before do create(:installation_config, name: 'STRIPE_BUSINESS_PLAN_ID', value: 'bpp_business_plan_123') - allow(StripeV2Client).to receive(:request).and_return(subscription_response) + + # Mock account lookup via subscription and cadence + allow(StripeV2Client).to receive(:request) + .with(:get, '/v2/billing/pricing_plan_subscriptions/bpps_subscription_123', anything, anything) + .and_return(subscription_response) + allow(StripeV2Client).to receive(:request) + .with(:get, '/v2/billing/cadences/cadence_123', anything, anything) + .and_return(cadence_response) + + # Mock provisioning service + provisioning_service = instance_double(Enterprise::Billing::V2::SubscriptionProvisioningService) + allow(Enterprise::Billing::V2::SubscriptionProvisioningService).to receive(:new).with(account: account).and_return(provisioning_service) + allow(provisioning_service).to receive(:provision).and_return(provisioning_response) + allow(credit_service).to receive(:sync_monthly_credits) end - it 'returns success with subscription details' do - result = service.process(event) + it 'delegates to subscription provisioning service' do + result = service.perform(event: event) expect(result[:success]).to be(true), "Expected success but got: #{result.inspect}" - expect(result[:subscription_id]).to eq('bpps_subscription_123') - expect(result[:pricing_plan_id]).to eq('bpp_business_plan_123') - expect(result[:quantity]).to eq(5) - end - - it 'updates account custom attributes' do - service.process(event) - - expect(account.custom_attributes['stripe_subscription_id']).to eq('bpps_subscription_123') - expect(account.custom_attributes['stripe_pricing_plan_id']).to eq('bpp_business_plan_123') - expect(account.custom_attributes['subscribed_quantity']).to eq(5) - expect(account.custom_attributes['subscription_status']).to eq('active') - expect(account.custom_attributes['plan_name']).to eq('Chatwoot Business') - end - - it 'syncs monthly credits' do - service.process(event) - - expect(credit_service).to have_received(:sync_monthly_credits).with(50_000) - end - - it 'provisions features for the plan' do - service.process(event) - - enabled_feature_names = account.enabled_features.map(&:first) - expect(enabled_feature_names).to match_array(expected_features) + expect(Enterprise::Billing::V2::SubscriptionProvisioningService).to have_received(:new).with(account: account) end end context 'when handling unknown event' do it 'returns success' do - related_object = OpenStruct.new(id: 'unknown_id') + related_object = OpenStruct.new(id: 'bpps_subscription_123') event = double('Stripe::Event', type: 'unknown.event', related_object: related_object) - result = service.process(event) + # Mock account lookup + subscription_response = OpenStruct.new(billing_cadence: 'cadence_123') + cadence_response = OpenStruct.new(payer: OpenStruct.new(customer: 'cus_123')) + allow(StripeV2Client).to receive(:request) + .with(:get, '/v2/billing/pricing_plan_subscriptions/bpps_subscription_123', anything, anything) + .and_return(subscription_response) + allow(StripeV2Client).to receive(:request) + .with(:get, '/v2/billing/cadences/cadence_123', anything, anything) + .and_return(cadence_response) + + result = service.perform(event: event) expect(result[:success]).to be(true) end