diff --git a/config/locales/en.yml b/config/locales/en.yml index d0f256146..ea7d7c678 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -119,6 +119,12 @@ en: invalid_token: Invalid or expired MFA token invalid_credentials: Invalid credentials or verification code feature_unavailable: MFA feature is not available. Please configure encryption keys. + enterprise: + billing: + topup_amount_invalid: Topup amount must be greater than 0 + stripe_customer_required: Customer ID required. Please create a Stripe customer first. + lookup_key_not_found: Lookup key not found for pricing plan %{pricing_plan_id} + v2_configuration_required: V2 billing configuration is required. Please configure STRIPE_HACKER_PLAN_ID. profile: mfa: enabled: MFA enabled successfully @@ -435,3 +441,8 @@ en: subject: 'Finish setting up %{custom_domain}' ssl_status: custom_domain_not_configured: 'Custom domain is not configured' + enterprise: + billing: + topup_successful: Topup successful + subscription_cancelled: Subscription cancelled + pricing_plan_changed: Pricing plan changed diff --git a/config/routes.rb b/config/routes.rb index d886d004a..4d9ef8221 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -431,12 +431,18 @@ Rails.application.routes.draw do post :subscription get :limits post :toggle_deletion - # V2 Billing endpoints + end + end + end + + namespace :v2 do + resources :accounts, only: [] do + resource :billing, only: [] do get :credit_grants - get :v2_pricing_plans - get :v2_topup_options - post :v2_topup - post :v2_subscribe + get :pricing_plans + get :topup_options + post :topup + post :subscribe post :cancel_subscription post :change_pricing_plan end diff --git a/enterprise/app/controllers/enterprise/api/v1/accounts/concerns/billing_v2.rb b/enterprise/app/controllers/enterprise/api/v1/accounts/concerns/billing_v2.rb deleted file mode 100644 index c20c5f54c..000000000 --- a/enterprise/app/controllers/enterprise/api/v1/accounts/concerns/billing_v2.rb +++ /dev/null @@ -1,62 +0,0 @@ -module Enterprise::Api::V1::Accounts::Concerns::BillingV2 - extend ActiveSupport::Concern - - included do - before_action :validate_topup_amount, only: [:v2_topup] - end - - def credit_grants - service = Enterprise::Billing::V2::CreditManagementService.new(account: @account) - grants = service.fetch_credit_grants - - render json: { credit_grants: grants } - end - - def v2_pricing_plans - plans = Enterprise::Billing::V2::PlanCatalog.plans - render json: { pricing_plans: plans } - end - - def v2_topup_options - options = Enterprise::Billing::V2::TopupCatalog.options - render json: { topup_options: options } - end - - def v2_topup - render json: { success: true, message: 'Topup successful.' } - end - - def v2_subscribe - service = Enterprise::Billing::V2::CheckoutSessionService.new(account: @account) - result = service.create_subscription_checkout( - pricing_plan_id: params[:pricing_plan_id], - quantity: subscription_quantity - ) - - if result[:success] - render json: { success: true, redirect_url: result[:redirect_url] } - else - render json: { error: result[:message] }, status: :unprocessable_entity - end - end - - def cancel_subscription - render json: { success: true, message: 'Subscription cancelled.' } - end - - def change_pricing_plan - render json: { success: true, message: 'Pricing plan changed.' } - end - - private - - def subscription_quantity - [params[:quantity].to_i, 1].max - end - - def validate_topup_amount - return if params[:credits].to_i.positive? - - render json: { error: 'Topup amount must be greater than 0' }, status: :unprocessable_entity - end -end diff --git a/enterprise/app/controllers/enterprise/api/v1/accounts_controller.rb b/enterprise/app/controllers/enterprise/api/v1/accounts_controller.rb index 7fed4dfcf..77791cf4d 100644 --- a/enterprise/app/controllers/enterprise/api/v1/accounts_controller.rb +++ b/enterprise/app/controllers/enterprise/api/v1/accounts_controller.rb @@ -1,6 +1,5 @@ class Enterprise::Api::V1::AccountsController < Api::BaseController include BillingHelper - include Enterprise::Api::V1::Accounts::Concerns::BillingV2 before_action :fetch_account before_action :check_authorization diff --git a/enterprise/app/controllers/enterprise/api/v2/billing_controller.rb b/enterprise/app/controllers/enterprise/api/v2/billing_controller.rb new file mode 100644 index 000000000..892b6ea10 --- /dev/null +++ b/enterprise/app/controllers/enterprise/api/v2/billing_controller.rb @@ -0,0 +1,80 @@ +class Enterprise::Api::V2::BillingController < Api::BaseController + before_action :fetch_account + before_action :check_authorization + before_action :validate_topup_amount, only: [:topup] + + rescue_from StandardError, with: :render_error + rescue_from NotImplementedError, with: :render_not_implemented + + def credit_grants + service = Enterprise::Billing::V2::CreditManagementService.new(account: @account) + grants = service.fetch_credit_grants + + render json: { credit_grants: grants } + end + + def pricing_plans + plans = Enterprise::Billing::V2::PlanCatalog.plans + render json: { pricing_plans: plans } + end + + def topup_options + options = Enterprise::Billing::V2::TopupCatalog.options + render json: { topup_options: options } + end + + def topup + raise NotImplementedError, 'Topup functionality not yet implemented' + end + + def subscribe + service = Enterprise::Billing::V2::CheckoutSessionService.new(account: @account) + redirect_url = service.create_subscription_checkout( + pricing_plan_id: params[:pricing_plan_id], + quantity: subscription_quantity + ) + + render json: { redirect_url: redirect_url } + end + + def cancel_subscription + raise NotImplementedError, 'Cancel subscription functionality not yet implemented' + end + + def change_pricing_plan + raise NotImplementedError, 'Change pricing plan functionality not yet implemented' + end + + private + + def fetch_account + @account = current_user.accounts.find(params[:account_id]) + @current_account_user = @account.account_users.find_by(user_id: current_user.id) + end + + def subscription_quantity + [params[:quantity].to_i, 1].max + end + + def validate_topup_amount + return if params[:credits].to_i.positive? + + render json: { error: I18n.t('errors.enterprise.billing.topup_amount_invalid') }, status: :unprocessable_entity + end + + def pundit_user + { + user: current_user, + account: @account, + account_user: @current_account_user + } + end + + def render_error(exception) + render json: { error: exception.message }, status: :unprocessable_entity + end + + def render_not_implemented(exception) + render json: { error: exception.message }, status: :not_implemented + end +end diff --git a/enterprise/app/controllers/enterprise/webhooks/stripe_controller.rb b/enterprise/app/controllers/enterprise/webhooks/stripe_controller.rb index 0cc523889..f3bcf7f4d 100644 --- a/enterprise/app/controllers/enterprise/webhooks/stripe_controller.rb +++ b/enterprise/app/controllers/enterprise/webhooks/stripe_controller.rb @@ -35,6 +35,8 @@ class Enterprise::Webhooks::StripeController < ActionController::API parsed_payload = JSON.parse(payload) event_type = parsed_payload['type'] + return ENV.fetch('STRIPE_WEBHOOK_SECRET', nil) if event_type.blank? + if v2_billing_event?(event_type) ENV.fetch('STRIPE_WEBHOOK_SECRET_V2', nil) else @@ -43,7 +45,9 @@ class Enterprise::Webhooks::StripeController < ActionController::API end def v2_billing_event?(event_type) + return false if event_type.blank? + Rails.logger.debug { "V2 billing event: #{event_type}" } - event_type.start_with?('v2.') if event_type.present? + event_type.start_with?('v2.') end 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 5a5e955fe..a7e971730 100644 --- a/enterprise/app/services/enterprise/billing/concerns/plan_feature_manager.rb +++ b/enterprise/app/services/enterprise/billing/concerns/plan_feature_manager.rb @@ -46,10 +46,7 @@ module Enterprise::Billing::Concerns::PlanFeatureManager end def enable_features_for_current_plan(plan_name) - # First disable all premium features to handle downgrades disable_all_premium_features - - # Then enable features based on the current plan enable_plan_specific_features(plan_name) end @@ -58,7 +55,7 @@ module Enterprise::Billing::Concerns::PlanFeatureManager # Enable features based on plan hierarchy case plan_name - when 'Startup', 'Startups' + when 'Startups' # Startup plan gets the basic features account.enable_features(*STARTUP_PLAN_FEATURES) when 'Business' diff --git a/enterprise/app/services/enterprise/billing/concerns/plan_provisioning_helper.rb b/enterprise/app/services/enterprise/billing/concerns/plan_provisioning_helper.rb index d2cb238ad..68c6b5850 100644 --- a/enterprise/app/services/enterprise/billing/concerns/plan_provisioning_helper.rb +++ b/enterprise/app/services/enterprise/billing/concerns/plan_provisioning_helper.rb @@ -12,7 +12,7 @@ module Enterprise::Billing::Concerns::PlanProvisioningHelper end def extract_plan_name(plan_definition) - plan_definition[:display_name].split.find { |word| %w[Startup Startups Business Enterprise].include?(word) } + plan_definition[:display_name].split.find { |word| %w[Startups Business Enterprise].include?(word) } end def update_account_plan(new_pricing_plan_id, quantity, next_billing_date) diff --git a/enterprise/app/services/enterprise/billing/concerns/stripe_v2_client_helper.rb b/enterprise/app/services/enterprise/billing/concerns/stripe_v2_client_helper.rb index f48501efc..2673ff31b 100644 --- a/enterprise/app/services/enterprise/billing/concerns/stripe_v2_client_helper.rb +++ b/enterprise/app/services/enterprise/billing/concerns/stripe_v2_client_helper.rb @@ -60,6 +60,8 @@ module Enterprise::Billing::Concerns::StripeV2ClientHelper end def extract_attribute(object, key) - object.respond_to?(key) ? object.public_send(key) : object[key.to_s] + return object.public_send(key) if object.respond_to?(key) + + object[key.to_s] end 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 05f8048cf..26407ffc3 100644 --- a/enterprise/app/services/enterprise/billing/create_stripe_customer_service.rb +++ b/enterprise/app/services/enterprise/billing/create_stripe_customer_service.rb @@ -35,7 +35,7 @@ class Enterprise::Billing::CreateStripeCustomerService end def raise_config_error - raise StandardError, 'V2 billing configuration is required. Please configure STRIPE_HACKER_PLAN_ID.' + raise StandardError, I18n.t('errors.enterprise.billing.v2_configuration_required') end def existing_subscription? diff --git a/enterprise/app/services/enterprise/billing/v2/base_service.rb b/enterprise/app/services/enterprise/billing/v2/base_service.rb index 7ab1ccd9c..e3792915e 100644 --- a/enterprise/app/services/enterprise/billing/v2/base_service.rb +++ b/enterprise/app/services/enterprise/billing/v2/base_service.rb @@ -28,7 +28,7 @@ class Enterprise::Billing::V2::BaseService # Update response credits (monthly/topup with auto-calculation of total) def update_response_credits(monthly: nil, topup: nil) # Calculate and update total in limits hash ONLY - return unless monthly || topup + return if monthly.nil? && topup.nil? new_monthly = monthly || response_monthly_credits new_topup = topup || response_topup_credits @@ -44,23 +44,13 @@ class Enterprise::Billing::V2::BaseService def update_limits(updates) return if updates.blank? - current_limits = account.limits.present? ? account.limits.deep_dup : {} - updates.each do |key, value| - current_limits[key.to_s] = value - end - - account.update!(limits: current_limits) + account.update!(limits: (account.limits || {}).merge(updates.transform_keys(&:to_s))) end def update_custom_attributes(updates) return if updates.blank? - current_attributes = account.custom_attributes.present? ? account.custom_attributes.deep_dup : {} - updates.each do |key, value| - current_attributes[key.to_s] = value - end - - account.update!(custom_attributes: current_attributes) + account.update!(custom_attributes: (account.custom_attributes || {}).merge(updates.transform_keys(&:to_s))) end def custom_attribute(key) 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 4e21bf0e5..6b5766927 100644 --- a/enterprise/app/services/enterprise/billing/v2/checkout_session_service.rb +++ b/enterprise/app/services/enterprise/billing/v2/checkout_session_service.rb @@ -13,13 +13,13 @@ class Enterprise::Billing::V2::CheckoutSessionService < Enterprise::Billing::V2: validate_params store_pending_subscription_quantity session = create_checkout_session(checkout_session_params) - { success: true, redirect_url: session.url } + session.url end private def validate_params - raise StandardError, 'Customer ID required. Please create a Stripe customer first.' if stripe_customer_id.blank? + raise StandardError, I18n.t('errors.enterprise.billing.stripe_customer_required') if stripe_customer_id.blank? end def store_pending_subscription_quantity @@ -50,7 +50,7 @@ class Enterprise::Billing::V2::CheckoutSessionService < Enterprise::Billing::V2: def build_checkout_items lookup_key = extract_license_lookup_key - raise StandardError, "Lookup key not found for pricing plan #{@pricing_plan_id}" unless lookup_key + raise StandardError, I18n.t('errors.enterprise.billing.lookup_key_not_found', pricing_plan_id: @pricing_plan_id) unless lookup_key [ { diff --git a/enterprise/app/services/enterprise/billing/v2/credit_management_service.rb b/enterprise/app/services/enterprise/billing/v2/credit_management_service.rb index e4d9a8b39..6ebc5c7ef 100644 --- a/enterprise/app/services/enterprise/billing/v2/credit_management_service.rb +++ b/enterprise/app/services/enterprise/billing/v2/credit_management_service.rb @@ -32,7 +32,7 @@ class Enterprise::Billing::V2::CreditManagementService < Enterprise::Billing::V2 end grants.reject { |grant| grant[:credits].zero? } rescue Stripe::StripeError => e - Rails.logger.error("Failed to fetch credit grants: #{e.message}") + ChatwootExceptionTracker.new(e, account: account).capture_exception [] end diff --git a/enterprise/app/services/enterprise/billing/v2/plan_catalog.rb b/enterprise/app/services/enterprise/billing/v2/plan_catalog.rb index 12655104e..c45a79f38 100644 --- a/enterprise/app/services/enterprise/billing/v2/plan_catalog.rb +++ b/enterprise/app/services/enterprise/billing/v2/plan_catalog.rb @@ -13,7 +13,7 @@ module Enterprise::Billing::V2::PlanCatalog }, { key: :startup, - display_name: 'Chatwoot Startup', + display_name: 'Chatwoot Startups', base_fee: 19.0, monthly_credits: 300, config_key: 'STRIPE_STARTUP_PLAN_ID', 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 9bdc106ec..17ba5c5ed 100644 --- a/enterprise/app/services/enterprise/billing/v2/subscription_provisioning_service.rb +++ b/enterprise/app/services/enterprise/billing/v2/subscription_provisioning_service.rb @@ -49,6 +49,8 @@ class Enterprise::Billing::V2::SubscriptionProvisioningService < Enterprise::Bil def cancel_subscription hacker_plan_config = InstallationConfig.find_by(name: 'STRIPE_HACKER_PLAN_ID') + return if hacker_plan_config.nil? + pricing_plan_id = hacker_plan_config.value # Update subscription status and plan details 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 ee569b217..bd227df2e 100644 --- a/enterprise/app/services/enterprise/billing/v2/webhook_handler_service.rb +++ b/enterprise/app/services/enterprise/billing/v2/webhook_handler_service.rb @@ -3,9 +3,8 @@ class Enterprise::Billing::V2::WebhookHandlerService def perform(event:) @event = event - return { success: false, message: 'Event is required' } if @event.blank? - - return { success: false, message: 'Account not found' } if account.blank? + raise StandardError, 'Event is required' if @event.blank? + raise StandardError, 'Account not found' if account.blank? case @event.type when 'v2.billing.pricing_plan_subscription.servicing_activated' @@ -14,12 +13,10 @@ class Enterprise::Billing::V2::WebhookHandlerService when 'v2.billing.cadence.billed' Rails.logger.info "Handling cadence billed event: #{@event.related_object.id}" refresh_account_subscription_details(@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 } + ChatwootExceptionTracker.new(e, account: account).capture_exception + raise end private