From 39af360a18e605b9fc00ee29240fe0729e2a1b71 Mon Sep 17 00:00:00 2001 From: Tanmay Sharma Date: Mon, 27 Oct 2025 00:28:52 +0530 Subject: [PATCH] implement checkout endpoint for subscription in a better way --- .gitignore | 5 + app/models/account.rb | 11 + app/policies/account_policy.rb | 30 +- .../api/v1/models/_account.json.jbuilder | 5 + config/routes.rb | 6 + create_plans.rb | 142 ++++++++ .../enterprise/api/v1/accounts_controller.rb | 75 +++- .../enterprise/webhooks/stripe_controller.rb | 67 +++- .../enterprise/create_stripe_customer_job.rb | 16 +- .../services/captain/copilot/chat_service.rb | 22 ++ .../billing/concerns/plan_feature_manager.rb | 95 ++++++ .../billing/create_stripe_customer_service.rb | 68 +--- .../billing/handle_stripe_event_service.rb | 181 +++++----- .../billing/v2/cancel_subscription_service.rb | 85 +++++ .../billing/v2/checkout_session_service.rb | 140 ++++++++ .../v2/concerns/payment_intent_handler.rb | 1 - .../billing/v2/credit_management_service.rb | 35 +- .../billing/v2/customer_creation_service.rb | 68 ---- .../enterprise/billing/v2/plan_catalog.rb | 113 ++++++ .../v2/pricing_plan_component_builder.rb | 29 +- .../billing/v2/pricing_plan_service.rb | 47 ++- .../billing/v2/subscribe_customer_service.rb | 322 ------------------ .../v2/subscription_provisioning_service.rb | 152 +++++++++ .../enterprise/billing/v2/topup_catalog.rb | 34 ++ .../enterprise/billing/v2/topup_service.rb | 172 ++++++++++ .../billing/v2/update_subscription_service.rb | 131 +++++++ .../billing/v2/webhook_handler_service.rb | 56 +-- enterprise/lib/stripe_v2_client.rb | 31 +- .../create_stripe_customer_job_spec.rb | 43 +-- .../create_stripe_customer_service_spec.rb | 130 +------ .../handle_stripe_event_service_spec.rb | 156 +++++++++ .../billing/v2/pricing_plan_service_spec.rb | 68 +++- .../subscription_provisioning_service_spec.rb | 217 ++++++++++++ .../billing/v2/topup_service_spec.rb | 135 ++++++++ .../v2/webhook_handler_service_spec.rb | 92 ++--- 35 files changed, 2130 insertions(+), 850 deletions(-) create mode 100644 create_plans.rb create mode 100644 enterprise/app/services/enterprise/billing/concerns/plan_feature_manager.rb create mode 100644 enterprise/app/services/enterprise/billing/v2/cancel_subscription_service.rb create mode 100644 enterprise/app/services/enterprise/billing/v2/checkout_session_service.rb delete mode 100644 enterprise/app/services/enterprise/billing/v2/customer_creation_service.rb create mode 100644 enterprise/app/services/enterprise/billing/v2/plan_catalog.rb delete mode 100644 enterprise/app/services/enterprise/billing/v2/subscribe_customer_service.rb create mode 100644 enterprise/app/services/enterprise/billing/v2/subscription_provisioning_service.rb create mode 100644 enterprise/app/services/enterprise/billing/v2/topup_catalog.rb create mode 100644 enterprise/app/services/enterprise/billing/v2/topup_service.rb create mode 100644 enterprise/app/services/enterprise/billing/v2/update_subscription_service.rb create mode 100644 spec/enterprise/services/enterprise/billing/v2/subscription_provisioning_service_spec.rb create mode 100644 spec/enterprise/services/enterprise/billing/v2/topup_service_spec.rb diff --git a/.gitignore b/.gitignore index 7ca033f87..dde03c2e8 100644 --- a/.gitignore +++ b/.gitignore @@ -99,3 +99,8 @@ CLAUDE.local.md # Histoire deployment .netlify .histoire + +.pnpm-store/ + +# External documentation +\[External\]* diff --git a/app/models/account.rb b/app/models/account.rb index a8eaa04ff..ae7d570ee 100644 --- a/app/models/account.rb +++ b/app/models/account.rb @@ -108,6 +108,7 @@ 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 @@ -165,6 +166,16 @@ 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 8eca0e7b3..a5ebc54d9 100644 --- a/app/policies/account_policy.rb +++ b/app/policies/account_policy.rb @@ -32,6 +32,34 @@ class AccountPolicy < ApplicationPolicy end def credits_balance? - @account_user.administrator? || @account_user.agent? + @account_user.administrator? + end + + def v2_pricing_plans? + @account_user.administrator? + end + + def v2_topup_options? + @account_user.administrator? + end + + def v2_topup? + @account_user.administrator? + end + + def v2_buy? + @account_user.administrator? + end + + def v2_subscribe? + @account_user.administrator? + end + + def cancel_subscription? + @account_user.administrator? + end + + def update_subscription? + @account_user.administrator? end end diff --git a/app/views/api/v1/models/_account.json.jbuilder b/app/views/api/v1/models/_account.json.jbuilder index efeff7db5..d4d1c45ee 100644 --- a/app/views/api/v1/models/_account.json.jbuilder +++ b/app/views/api/v1/models/_account.json.jbuilder @@ -15,6 +15,11 @@ if resource.custom_attributes.present? if resource.custom_attributes['marked_for_deletion_reason'].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? end end json.domain @account.domain diff --git a/config/routes.rb b/config/routes.rb index 6712ce6e3..897bc757c 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -433,6 +433,12 @@ Rails.application.routes.draw do post :toggle_deletion # V2 Billing endpoints get :credits_balance + get :v2_pricing_plans + get :v2_topup_options + post :v2_topup + post :v2_subscribe + post :cancel_subscription + post :update_subscription end end end diff --git a/create_plans.rb b/create_plans.rb new file mode 100644 index 000000000..3b3974af6 --- /dev/null +++ b/create_plans.rb @@ -0,0 +1,142 @@ +# Validate environment before starting +if ENV['STRIPE_SECRET_KEY'].blank? + puts '❌ ERROR: STRIPE_SECRET_KEY environment variable is not set' + puts 'Please set it with: export STRIPE_SECRET_KEY=sk_test_...' + exit 1 +end + +account = Account.first +if account.nil? + puts '❌ ERROR: No accounts found in database' + puts 'Please create an account first' + exit 1 +end + +puts "Using account: #{account.name} (ID: #{account.id})" +puts "Stripe API: #{ENV.fetch('STRIPE_SECRET_KEY', nil)[0..10]}..." +puts '' + +configs = [ + { + cpu_display_name: 'Credits', + cpu_lookup_key: 'cpu_credits', + meter_display_name: 'Cw meter', + meter_event_name: 'chatwoot.usage', + plan_display_name: 'Chatwoot Hacker', + plan_lookup_key: 'chatwoot_hacker_v2', + lookup_key: 'chatwoot_hacker_license_fee_v2', + service_action_lookup_key: 'chatwoot_hacker_plan_v2_credits', + metered_item_lookup_key: 'chatwoot_hacker_plan_v2_usage', + licensed_item_display_name: 'Seat', + licensed_item_unit_label: 'per agent', + license_fee_display_name: 'Fee', + license_fee_amount: '0', + monthly_credit_amount: 0, + rate_card_display_name: 'Rates', + metered_item_display_name: 'Prompt', + rate_value: 1, + config_key: 'STRIPE_HACKER_PLAN_ID' + }, + { + cpu_display_name: 'Credits', + cpu_lookup_key: 'cpu_credits', + meter_display_name: 'Cw meter', + meter_event_name: 'chatwoot.usage', + plan_display_name: 'Chatwoot Startup', + plan_lookup_key: 'chatwoot_startup_v2', + lookup_key: 'chatwoot_startup_license_fee_v2', + service_action_lookup_key: 'chatwoot_startup_plan_v2_credits', + metered_item_lookup_key: 'chatwoot_startup_plan_v2_usage', + licensed_item_display_name: 'Seat', + licensed_item_unit_label: 'per agent', + license_fee_display_name: 'Fee', + license_fee_amount: '1900', + monthly_credit_amount: 10_000, + rate_card_display_name: 'Rates', + metered_item_display_name: 'Prompt', + rate_value: 1, + config_key: 'STRIPE_STARTUP_PLAN_ID' + }, + { + cpu_display_name: 'Credits', + cpu_lookup_key: 'cpu_credits', + meter_display_name: 'Cw meter', + meter_event_name: 'chatwoot.usage', + plan_display_name: 'Chatwoot Business', + plan_lookup_key: 'chatwoot_business_v2', + lookup_key: 'chatwoot_business_plan_license_fee_v2', + service_action_lookup_key: 'chatwoot_business_plan_v2_credits', + metered_item_lookup_key: 'chatwoot_business_plan_v2_usage', + licensed_item_display_name: 'Seat', + licensed_item_unit_label: 'per agent', + license_fee_display_name: 'Fee', + license_fee_amount: '3900', + monthly_credit_amount: 50_000, + rate_card_display_name: 'Rates', + metered_item_display_name: 'Prompt', + rate_value: 1, + config_key: 'STRIPE_BUSINESS_PLAN_ID' + }, + { + cpu_display_name: 'Credits', + cpu_lookup_key: 'cpu_credits', + meter_display_name: 'Cw meter', + meter_event_name: 'chatwoot.usage', + plan_display_name: 'Chatwoot Enterprise', + plan_lookup_key: 'chatwoot_enterprise_plan_v2', + lookup_key: 'chatwoot_enterprise_license_fee_v2', + service_action_lookup_key: 'chatwoot_enterprise_plan_v2_credits', + metered_item_lookup_key: 'chatwoot_enterprise_plan_v2_usage', + licensed_item_display_name: 'Seat', + licensed_item_unit_label: 'per agent', + license_fee_display_name: 'Fee', + license_fee_amount: '9900', + monthly_credit_amount: 200_000, + rate_card_display_name: 'Rates', + metered_item_display_name: 'Prompt', + rate_value: 1, + config_key: 'STRIPE_ENTERPRISE_PLAN_ID' + } +] + +service = Enterprise::Billing::V2::PricingPlanService.new(account: account) + +puts 'Creating pricing plans...' +puts '' + +configs.each do |config| + begin + result = service.create_complete_pricing_plan(config) + rescue StandardError => e + result = { success: false, error: e.message } + puts "Exception occurred: #{e.class}" + puts "Message: #{e.message}" + puts e.backtrace.first(5).join("\n") + end + + if result[:success] + # Save or update the pricing plan ID in InstallationConfig + installation_config = InstallationConfig.find_or_initialize_by(name: config[:config_key]) + installation_config.value = result[:pricing_plan].id + installation_config.save! + + puts "✓ Created #{config[:plan_display_name]}" + puts " Plan ID: #{result[:pricing_plan].id}" + puts " CPU ID: #{result[:custom_pricing_unit].id}" + puts " Meter ID: #{result[:meter].id}" + else + puts "✗ Failed to create #{config[:plan_display_name]}" + puts " Error: #{result[:error]}" if result[:error] + end + puts '' +end + +puts 'Summary:' +puts '--------' +puts "CPU ID saved to: STRIPE_CUSTOM_PRICING_UNIT_ID = #{InstallationConfig.find_by(name: 'STRIPE_CUSTOM_PRICING_UNIT_ID')&.value}" +puts "Meter ID saved to: STRIPE_METER_ID = #{InstallationConfig.find_by(name: 'STRIPE_METER_ID')&.value}" +puts '' +configs.each do |config| + plan_id = InstallationConfig.find_by(name: config[:config_key])&.value + puts "#{config[:config_key]} = #{plan_id}" +end diff --git a/enterprise/app/controllers/enterprise/api/v1/accounts_controller.rb b/enterprise/app/controllers/enterprise/api/v1/accounts_controller.rb index dbb9f6627..66add3777 100644 --- a/enterprise/app/controllers/enterprise/api/v1/accounts_controller.rb +++ b/enterprise/app/controllers/enterprise/api/v1/accounts_controller.rb @@ -64,10 +64,78 @@ class Enterprise::Api::V1::AccountsController < Api::BaseController id: @account.id, monthly_credits: balance[:monthly], topup_credits: balance[:topup], - total_credits: balance[:total] + total_credits: balance[:total], + usage_this_month: balance[:usage_this_month], + usage_total: balance[:usage_total] } 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 + 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) + + if result[:success] + render json: { success: true, message: result[:message] } + else + render json: { error: result[:message] }, status: :unprocessable_entity + end + 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], checkout_session_id: result[:checkout_session_id] } + else + render json: { error: result[:message] }, status: :unprocessable_entity + end + end + + def cancel_subscription + service = Enterprise::Billing::V2::CancelSubscriptionService.new(account: @account) + 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] + } + else + render json: { error: result[:message] }, status: :unprocessable_entity + end + end + + def update_subscription + service = Enterprise::Billing::V2::UpdateSubscriptionService.new(account: @account) + result = service.update_subscription(pricing_plan_id: params[:pricing_plan_id], quantity: params[:quantity]) + + if result[:success] + render json: result + else + render json: { error: result[:message] }, status: :unprocessable_entity + end + end + private def check_cloud_env @@ -126,6 +194,11 @@ class Enterprise::Api::V1::AccountsController < Api::BaseController render json: { redirect_url: redirect_url } end + def subscription_quantity + quantity = params[:quantity].to_i + quantity.positive? ? quantity : 1 + 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 bea502964..6aa882a0a 100644 --- a/enterprise/app/controllers/enterprise/webhooks/stripe_controller.rb +++ b/enterprise/app/controllers/enterprise/webhooks/stripe_controller.rb @@ -6,13 +6,15 @@ class Enterprise::Webhooks::StripeController < ActionController::API # Attempt to verify the signature. If successful, we'll handle the event begin - event = Stripe::Webhook.construct_event(payload, sig_header, ENV.fetch('STRIPE_WEBHOOK_SECRET', nil)) + # Determine which webhook secret to use based on event type + webhook_secret = determine_webhook_secret(payload) + + event = Stripe::Webhook.construct_event(payload, sig_header, webhook_secret) # Check if this is a V2 billing event if v2_billing_event?(event) handle_v2_event(event) else - # Handle V1 events with existing service ::Enterprise::Billing::HandleStripeEventService.new.perform(event: event) end # If we fail to verify the signature, then something was wrong with the request @@ -28,33 +30,64 @@ class Enterprise::Webhooks::StripeController < ActionController::API private + def determine_webhook_secret(payload) + # Parse the payload to check event type without full verification + 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.') + ENV.fetch('STRIPE_WEBHOOK_SECRET_V2', nil) + else + ENV.fetch('STRIPE_WEBHOOK_SECRET', nil) + end + end + def v2_billing_event?(event) - %w[ - billing.credit_grant.created - billing.credit_grant.updated - ].include?(event.type) + event.type.start_with?('v2.') end def handle_v2_event(event) - customer_id = extract_customer_id(event) - return if customer_id.blank? - - account = Account.find_by("custom_attributes->>'stripe_customer_id' = ?", customer_id) + 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 extract_customer_id(event) - data = event.data.object + def find_account_for_v2_event(event) + related_object = event.related_object + subscription_id = related_object.id - # Credit grants have customer field - if data.respond_to?(:customer) - data.customer - elsif data.respond_to?(:[]) - data['customer'] + 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) diff --git a/enterprise/app/jobs/enterprise/create_stripe_customer_job.rb b/enterprise/app/jobs/enterprise/create_stripe_customer_job.rb index a0bff153a..6ccaa2f33 100644 --- a/enterprise/app/jobs/enterprise/create_stripe_customer_job.rb +++ b/enterprise/app/jobs/enterprise/create_stripe_customer_job.rb @@ -2,19 +2,7 @@ class Enterprise::CreateStripeCustomerJob < ApplicationJob queue_as :default def perform(account) - # 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' + # 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 ca7057c86..00c6f6320 100644 --- a/enterprise/app/services/captain/copilot/chat_service.rb +++ b/enterprise/app/services/captain/copilot/chat_service.rb @@ -20,6 +20,15 @@ class Captain::Copilot::ChatService < Llm::BaseOpenAiService end def generate_response(input) + # Check and use credits before making API call + credit_result = check_and_use_credits + + 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 + @messages << { role: 'user', content: input } if input.present? response = request_chat_completion @@ -113,4 +122,17 @@ class Captain::Copilot::ChatService < Llm::BaseOpenAiService message_type: message_type ) end + + def check_and_use_credits + credit_service = Enterprise::Ai::CaptainCreditService.new(account: @account) + credit_service.check_and_use_credits( + feature: 'ai_copilot', + amount: 1, + metadata: { + 'assistant_id' => @assistant.id, + 'user_id' => @user&.id, + 'copilot_thread_id' => @copilot_thread&.id + } + ) + 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 new file mode 100644 index 000000000..e487d5bcd --- /dev/null +++ b/enterprise/app/services/enterprise/billing/concerns/plan_feature_manager.rb @@ -0,0 +1,95 @@ +# frozen_string_literal: true + +module Enterprise::Billing::Concerns::PlanFeatureManager + extend ActiveSupport::Concern + + # Plan hierarchy: Hacker (default) -> Startup -> Business -> Enterprise + # Each higher tier includes all features from the lower tiers + + # Basic features available starting with the Startup plan + STARTUP_PLAN_FEATURES = %w[ + inbound_emails + help_center + campaigns + team_management + channel_twitter + channel_facebook + channel_email + channel_instagram + captain_integration + advanced_search_indexing + ].freeze + + # Additional features available starting with the Business plan + BUSINESS_PLAN_FEATURES = %w[sla custom_roles].freeze + + # Additional features available only in the Enterprise plan + ENTERPRISE_PLAN_FEATURES = %w[audit_logs disable_branding saml].freeze + + def update_plan_features(plan_name) + if plan_name.blank? || plan_name == 'Hacker' + disable_all_premium_features + else + enable_features_for_current_plan(plan_name) + 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 or during plan changes) + 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(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 + + def enable_plan_specific_features(plan_name) + return if plan_name.blank? + + # Enable features based on plan hierarchy + case plan_name + when 'Startup', 'Startups' + # Startup plan gets the basic features + account.enable_features(*STARTUP_PLAN_FEATURES) + when 'Business' + # Business plan gets Startup features + Business features + account.enable_features(*STARTUP_PLAN_FEATURES) + account.enable_features(*BUSINESS_PLAN_FEATURES) + when 'Enterprise' + # Enterprise plan gets all features + account.enable_features(*STARTUP_PLAN_FEATURES) + account.enable_features(*BUSINESS_PLAN_FEATURES) + account.enable_features(*ENTERPRISE_PLAN_FEATURES) + end + end + + def reset_captain_usage + account.reset_response_usage if account.respond_to?(:reset_response_usage) + end + + private + + 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_stripe_customer_service.rb b/enterprise/app/services/enterprise/billing/create_stripe_customer_service.rb index e4df1050b..594032200 100644 --- a/enterprise/app/services/enterprise/billing/create_stripe_customer_service.rb +++ b/enterprise/app/services/enterprise/billing/create_stripe_customer_service.rb @@ -1,69 +1,33 @@ class Enterprise::Billing::CreateStripeCustomerService pattr_initialize [:account!] - DEFAULT_QUANTITY = 2 - def perform - return if existing_subscription? + return if customer_exists? - 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'] - } - ) + customer_id = create_customer + save_customer_id(customer_id) end private - 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 + def customer_exists? + account.custom_attributes['stripe_customer_id'].present? end - def default_quantity - default_plan['default_quantity'] || DEFAULT_QUANTITY + 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 + } + ) 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 d0d2f73d8..e2ab8aff2 100644 --- a/enterprise/app/services/enterprise/billing/handle_stripe_event_service.rb +++ b/enterprise/app/services/enterprise/billing/handle_stripe_event_service.rb @@ -1,29 +1,8 @@ class Enterprise::Billing::HandleStripeEventService + include Enterprise::Billing::Concerns::PlanFeatureManager + CLOUD_PLANS_CONFIG = 'CHATWOOT_CLOUD_PLANS'.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_twitter - channel_facebook - channel_email - channel_instagram - captain_integration - advanced_search_indexing - ].freeze - - # Additional features available starting with the Business plan - BUSINESS_PLAN_FEATURES = %w[sla custom_roles].freeze - - # Additional features available only in the Enterprise plan - ENTERPRISE_PLAN_FEATURES = %w[audit_logs disable_branding saml].freeze - def perform(event:) @event = event @@ -32,8 +11,10 @@ class Enterprise::Billing::HandleStripeEventService process_subscription_updated when 'customer.subscription.deleted' process_subscription_deleted - else - Rails.logger.debug { "Unhandled event type: #{event.type}" } + when 'billing.credit_grant.created' + process_credit_grant_created + when 'billing.credit_grant.updated' + process_credit_grant_updated end end @@ -46,7 +27,8 @@ class Enterprise::Billing::HandleStripeEventService return if plan.blank? || account.blank? update_account_attributes(subscription, plan) - update_plan_features + plan_name = account.custom_attributes['plan_name'] + update_plan_features(plan_name) reset_captain_usage end @@ -72,65 +54,22 @@ 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 reset_captain_usage - account.reset_response_usage - end - - def enable_plan_specific_features - plan_name = account.custom_attributes['plan_name'] - return if plan_name.blank? - - # Enable features based on plan hierarchy - case plan_name - when 'Startups' - # Startups plan gets the basic features - account.enable_features(*STARTUP_PLAN_FEATURES) - when 'Business' - # Business plan gets Startups features + Business features - account.enable_features(*STARTUP_PLAN_FEATURES) - account.enable_features(*BUSINESS_PLAN_FEATURES) - when 'Enterprise' - # Enterprise plan gets all features - account.enable_features(*STARTUP_PLAN_FEATURES) - account.enable_features(*BUSINESS_PLAN_FEATURES) - account.enable_features(*ENTERPRISE_PLAN_FEATURES) - end - end - def subscription @subscription ||= @event.data.object end def account - @account ||= Account.where("custom_attributes->>'stripe_customer_id' = ?", subscription.customer).first + @account ||= begin + customer_id = if @event.type.start_with?('billing.credit_grant') + # Credit grant events have customer directly on the object + @event.data.object.respond_to?(:customer) ? @event.data.object.customer : @event.data.object['customer'] + else + # Subscription events have customer on subscription + subscription.customer + end + + Account.where("custom_attributes->>'stripe_customer_id' = ?", customer_id).first + end end def find_plan(plan_id) @@ -144,12 +83,82 @@ class Enterprise::Billing::HandleStripeEventService 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 + def process_credit_grant_created + grant_id = extract_credit_grant_id(@event.data.object) + return if grant_id.blank? - # Enable each feature - account.enable_features(*features) if features.present? + # Retrieve the full credit grant object from Stripe API + grant = retrieve_credit_grant(grant_id) + return if grant.blank? + + amount = extract_credit_amount(grant) + return if amount.zero? + + service = Enterprise::Billing::V2::CreditManagementService.new(account: account) + + if grant.expires_at.present? + service.sync_monthly_credits(amount) + else + service.add_topup_credits(amount) + end + end + + def process_credit_grant_updated + grant = @event.data.object + # Check if grant has expired + return unless grant.respond_to?(:expired_at) && grant.expired_at + + # Grant has expired + handle_credit_grant_expired + + # Other updates (voided, amount changes, etc) - do nothing + end + + def handle_credit_grant_expired + Enterprise::Billing::V2::CreditManagementService.new(account: account).expire_monthly_credits + end + + def extract_credit_grant_id(grant_object) + grant_object.respond_to?(:id) ? grant_object.id : grant_object['id'] + end + + def retrieve_credit_grant(grant_id) + StripeV2Client.request(:get, "/v1/billing/credit_grants/#{grant_id}") + end + + def extract_credit_amount(grant) + # First, try to get credits from metadata + metadata = extract_attribute(grant, :metadata) + if metadata + credits = extract_attribute(metadata, :credits) + return credits.to_i if credits.present? && credits.to_i.positive? + end + + # Fallback: extract from amount object + amount_data = extract_attribute(grant, :amount) + return 0 unless amount_data + + amount_type = extract_attribute(amount_data, :type) + + case amount_type + when 'monetary' + extract_amount_value(amount_data, :monetary) + when 'custom_pricing_unit' + extract_amount_value(amount_data, :custom_pricing_unit) + else + 0 + end + end + + def extract_attribute(object, attribute) + object.respond_to?(attribute) ? object.public_send(attribute) : object[attribute.to_s] + end + + def extract_amount_value(amount_data, unit_type) + unit = extract_attribute(amount_data, unit_type) + return 0 unless unit + + value = extract_attribute(unit, :value) + value.to_i end end diff --git a/enterprise/app/services/enterprise/billing/v2/cancel_subscription_service.rb b/enterprise/app/services/enterprise/billing/v2/cancel_subscription_service.rb new file mode 100644 index 000000000..5f7fde2ae --- /dev/null +++ b/enterprise/app/services/enterprise/billing/v2/cancel_subscription_service.rb @@ -0,0 +1,85 @@ +class Enterprise::Billing::V2::CancelSubscriptionService < Enterprise::Billing::V2::BaseService + include Enterprise::Billing::Concerns::PlanFeatureManager + + # Cancel subscription at period end using Stripe's V1 API + # Subscription remains active until the end of the current billing period + # Customer receives no refund/credit for remaining time + # + # @return [Hash] { success:, cancel_at_period_end:, period_end:, message: } + # + def cancel_subscription + return { success: false, message: 'Not a V2 billing account' } unless v2_enabled? + return { success: false, message: 'No active subscription' } unless active_subscription? + + with_locked_account do + subscription_data = cancel_at_period_end + update_account_status(subscription_data) + success_response = build_success_response(subscription_data) + success_response + end + rescue Stripe::StripeError => e + { success: false, message: "Stripe error: #{e.message}" } + rescue StandardError => e + { success: false, message: "Cancellation error: #{e.message}" } + end + + private + + def cancel_at_period_end + subscription_id = custom_attribute('stripe_subscription_id') + raise StandardError, 'No subscription ID found' if subscription_id.blank? + + # Build update parameters to cancel at period end + params = { + cancel_at_period_end: true + } + + StripeV2Client.request( + :post, + "/v1/subscriptions/#{subscription_id}", + params, + stripe_api_options + ) + end + + def update_account_status(subscription_data) + # Extract period end from subscription data + current_period_end = extract_attribute(subscription_data, :current_period_end) + period_end_time = current_period_end ? Time.zone.at(current_period_end).iso8601 : nil + + # Mark subscription as cancelling (will be cancelled at period end) + update_custom_attributes({ + 'subscription_status' => 'cancel_at_period_end', + 'subscription_cancelled_at' => Time.current.iso8601, + 'subscription_period_end' => period_end_time + }) + end + + def build_success_response(subscription_data) + current_period_end = extract_attribute(subscription_data, :current_period_end) + period_end_time = current_period_end ? Time.zone.at(current_period_end) : nil + + { + success: true, + cancel_at_period_end: true, + period_end: period_end_time, + message: 'Subscription will be cancelled at the end of the current billing period.' + } + end + + def v2_enabled? + custom_attribute('stripe_billing_version')&.to_i == 2 + end + + def active_subscription? + custom_attribute('subscription_status') == 'active' + end + + def stripe_api_options + { api_key: ENV.fetch('STRIPE_SECRET_KEY', nil), stripe_version: '2025-08-27.preview' } + end + + def extract_attribute(object, key) + object.respond_to?(key) ? object.public_send(key) : object[key.to_s] + 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 new file mode 100644 index 000000000..3271a76df --- /dev/null +++ b/enterprise/app/services/enterprise/billing/v2/checkout_session_service.rb @@ -0,0 +1,140 @@ +# V2 Billing Checkout Service +# +# This service creates Checkout Sessions for V2 Billing subscriptions using +# checkout_items with pricing_plan_subscription_item. +# +class Enterprise::Billing::V2::CheckoutSessionService < Enterprise::Billing::V2::BaseService + include Enterprise::Billing::Concerns::PlanFeatureManager + + # Create a subscription checkout session + # + # Creates a Checkout Session with checkout_items containing the V2 Pricing Plan + # and component configurations for the license fee. + # + # @param pricing_plan_id [String] V2 Pricing Plan ID + # @param quantity [Integer] Number of licenses/seats + # @return [Hash] { success:, session_id:, redirect_url: } or error + # + def create_subscription_checkout(pricing_plan_id:, quantity: 1) + @pricing_plan_id = pricing_plan_id + @quantity = quantity.to_i.positive? ? quantity.to_i : 1 + + base_url = ENV.fetch('FRONTEND_URL') + @success_url = "#{base_url}/app/accounts/#{@account.id}/settings/billing?session_id={CHECKOUT_SESSION_ID}" + @cancel_url = "#{base_url}/app/accounts/#{@account.id}/settings/billing" + + validate_params + store_pending_subscription_quantity + 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 + + def store_pending_subscription_quantity + # Store quantity in custom_attributes for webhook to use + # This is more reliable than extracting from subscription component_values + update_custom_attributes({ + 'pending_subscription_quantity' => @quantity, + 'pending_subscription_pricing_plan' => @pricing_plan_id + }) + end + + # Create Checkout Session with checkout_items + # + # Uses V2 Pricing Plans directly via checkout_items parameter. + # This creates a subscription with V2 Billing features automatically. + # + def create_checkout_session + customer_id = custom_attribute('stripe_customer_id') + + session = StripeV2Client.request( + :post, + '/v1/checkout/sessions', + checkout_session_params(customer_id), + stripe_api_options + ) + + build_success_response(session) + end + + def checkout_session_params(customer_id) + { + customer: customer_id, + checkout_items: build_checkout_items, + automatic_tax: { + enabled: true + }, + customer_update: { + address: 'auto', + shipping: 'auto' + }, + success_url: @success_url, + cancel_url: @cancel_url, + metadata: session_metadata + } + end + + 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 + + [ + { + type: 'pricing_plan_subscription_item', + pricing_plan_subscription_item: { + pricing_plan: @pricing_plan_id, + component_configurations: { + lookup_key => { + type: 'license_fee_component', + license_fee_component: { + quantity: @quantity + } + } + } + } + } + ] + end + + def extract_license_lookup_key + Enterprise::Billing::V2::PlanCatalog.lookup_key_for_plan(@pricing_plan_id) + end + + def session_metadata + { + account_id: account.id, + pricing_plan_id: @pricing_plan_id, + quantity: @quantity, + billing_version: 'v2' + } + end + + def build_success_response(session) + session_id = session.respond_to?(:id) ? session.id : session['id'] + session_url = session.respond_to?(:url) ? session.url : session['url'] + + { + success: true, + session_id: session_id, + redirect_url: session_url + } + end + + def stripe_api_options + { + api_key: ENV.fetch('STRIPE_SECRET_KEY', nil), + stripe_version: '2025-08-27.preview;checkout_product_catalog_preview=v1' + } + end +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 index 75dcb02f6..8fb6a137b 100644 --- a/enterprise/app/services/enterprise/billing/v2/concerns/payment_intent_handler.rb +++ b/enterprise/app/services/enterprise/billing/v2/concerns/payment_intent_handler.rb @@ -25,7 +25,6 @@ module Enterprise::Billing::V2::Concerns::PaymentIntentHandler { api_key: ENV.fetch('STRIPE_SECRET_KEY', nil), stripe_version: '2025-08-27.preview' } ) - Rails.logger.info("Created payment intent: #{payment_intent.id} for amount: #{amount_due}") payment_intent.id end 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 3d499b02c..4fe25253a 100644 --- a/enterprise/app/services/enterprise/billing/v2/credit_management_service.rb +++ b/enterprise/app/services/enterprise/billing/v2/credit_management_service.rb @@ -25,7 +25,7 @@ class Enterprise::Billing::V2::CreditManagementService < Enterprise::Billing::V2 end end - def sync_monthly_credits(amount) + def sync_monthly_credits(amount, metadata: {}) with_locked_account do update_credits(monthly: amount) if amount.positive? @@ -33,20 +33,22 @@ class Enterprise::Billing::V2::CreditManagementService < Enterprise::Billing::V2 type: 'grant', amount: amount, credit_type: 'monthly', - description: 'Monthly credits from Stripe' + description: 'Monthly credits from Stripe', + metadata: metadata ) end end end - def add_topup_credits(amount) + def add_topup_credits(amount, metadata: {}) with_locked_account do update_credits(topup: topup_credits + amount) log_credit_transaction( type: 'topup', amount: amount, credit_type: 'topup', - description: 'Topup credits added' + description: 'Topup credits added', + metadata: metadata ) end end @@ -60,10 +62,33 @@ class Enterprise::Billing::V2::CreditManagementService < Enterprise::Billing::V2 end def credit_balance + usage_stats = calculate_usage_stats + { monthly: monthly_credits, topup: topup_credits, - total: total_credits + total: total_credits, + usage_this_month: usage_stats[:this_month], + usage_total: usage_stats[:total] + } + end + + def calculate_usage_stats + month_start = Time.current.beginning_of_month + + this_month_usage = account.credit_transactions + .where(transaction_type: 'use', created_at: month_start..Time.current) + .sum(:amount) + .abs + + total_usage = account.credit_transactions + .where(transaction_type: 'use') + .sum(:amount) + .abs + + { + this_month: this_month_usage, + total: total_usage } end diff --git a/enterprise/app/services/enterprise/billing/v2/customer_creation_service.rb b/enterprise/app/services/enterprise/billing/v2/customer_creation_service.rb deleted file mode 100644 index ca9ee9d91..000000000 --- a/enterprise/app/services/enterprise/billing/v2/customer_creation_service.rb +++ /dev/null @@ -1,68 +0,0 @@ -# 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/plan_catalog.rb b/enterprise/app/services/enterprise/billing/v2/plan_catalog.rb new file mode 100644 index 000000000..390049eba --- /dev/null +++ b/enterprise/app/services/enterprise/billing/v2/plan_catalog.rb @@ -0,0 +1,113 @@ +module Enterprise::Billing::V2::PlanCatalog + DEFAULT_CURRENCY = 'usd'.freeze + CREDIT_UNIT = 'Credits'.freeze + + PLAN_DEFINITIONS = [ + { + key: :free, + display_name: 'Chatwoot Hacker', + base_fee: 0.0, + monthly_credits: 0, + config_key: 'STRIPE_HACKER_PLAN_ID', + licensed_item_lookup_key: 'chatwoot_hacker_license_fee_v2' + }, + { + key: :startup, + display_name: 'Chatwoot Startup', + base_fee: 19.0, + monthly_credits: 10_000, + config_key: 'STRIPE_STARTUP_PLAN_ID', + licensed_item_lookup_key: 'chatwoot_startup_license_fee_v2' + }, + { + key: :business, + display_name: 'Chatwoot Business', + base_fee: 39.0, + monthly_credits: 50_000, + config_key: 'STRIPE_BUSINESS_PLAN_ID', + licensed_item_lookup_key: 'chatwoot_business_license_fee_v2' + }, + { + key: :enterprise, + display_name: 'Chatwoot Enterprise', + base_fee: 99.0, + monthly_credits: 200_000, + config_key: 'STRIPE_ENTERPRISE_PLAN_ID', + licensed_item_lookup_key: 'chatwoot_enterprise_license_fee_v2' + } + ].freeze + + module_function + + def plans + PLAN_DEFINITIONS.map do |definition| + plan_id = plan_id_for(definition) + build_plan(definition, plan_id) + end + end + + def definition_for(plan_id) + PLAN_DEFINITIONS.each do |definition| + return definition if plan_id_for(definition) == plan_id + end + nil + end + + def monthly_credits_for(plan_id) + definition = definition_for(plan_id) + definition ? definition[:monthly_credits] : nil + end + + def plan_id_for(definition) + InstallationConfig.find_by(name: definition[:config_key])&.value + end + + def lookup_key_for_plan(plan_id) + # Returns the licensed_item_lookup_key for checkout sessions + definition = definition_for(plan_id) + definition&.dig(:licensed_item_lookup_key) + end + + def build_plan(definition, plan_id) + { + id: plan_id, + display_name: definition[:display_name], + currency: DEFAULT_CURRENCY, + tax_behavior: 'exclusive', + components: build_components(definition) + } + end + + def build_components(definition) + components = [service_action_component(definition), rate_card_component(definition)] + components << license_fee_component(definition) if definition[:base_fee]&.positive? + components + end + + def service_action_component(definition) + { + type: 'service_action', + name: 'Monthly Credits', + credit_amount: definition[:monthly_credits], + credit_unit: CREDIT_UNIT + } + end + + def rate_card_component(definition) + { + type: 'rate_card', + name: 'Overage Rate', + overage_rate: definition[:overage_rate], + rate_unit: CREDIT_UNIT, + meter_id: nil + } + end + + def license_fee_component(definition) + { + type: 'license_fee', + name: 'Base Fee', + unit_amount: definition[:base_fee].round(2) + } + end +end diff --git a/enterprise/app/services/enterprise/billing/v2/pricing_plan_component_builder.rb b/enterprise/app/services/enterprise/billing/v2/pricing_plan_component_builder.rb index da2eb93cc..909b97112 100644 --- a/enterprise/app/services/enterprise/billing/v2/pricing_plan_component_builder.rb +++ b/enterprise/app/services/enterprise/billing/v2/pricing_plan_component_builder.rb @@ -2,20 +2,22 @@ class Enterprise::Billing::V2::PricingPlanComponentBuilder < Enterprise::Billing def add_license_fee_component(plan, config) licensed_item = create_licensed_item( display_name: config[:licensed_item_display_name], - lookup_key: config[:licensed_item_lookup_key], + lookup_key: config[:lookup_key], unit_label: config[:licensed_item_unit_label] ) license_fee = create_license_fee( display_name: config[:license_fee_display_name], unit_amount: config[:license_fee_amount], - licensed_item_id: licensed_item.id + licensed_item_id: licensed_item.id, + lookup_key: config[:lookup_key] ) add_component( plan_id: plan.id, type: 'license_fee', - data: { id: license_fee.id, version: license_fee.latest_version } + data: { id: license_fee.id, version: license_fee.latest_version }, + lookup_key: config[:lookup_key] ) end @@ -29,7 +31,8 @@ class Enterprise::Billing::V2::PricingPlanComponentBuilder < Enterprise::Billing add_component( plan_id: plan.id, type: 'service_action', - data: { id: action.id } + data: { id: action.id }, + lookup_key: config[:service_action_lookup_key] ) action @@ -49,7 +52,8 @@ class Enterprise::Billing::V2::PricingPlanComponentBuilder < Enterprise::Billing add_component( plan_id: plan.id, type: 'rate_card', - data: { id: card.id, version: card.latest_version } + data: { id: card.id, version: card.latest_version }, + lookup_key: config[:metered_item_lookup_key] ) card @@ -66,7 +70,7 @@ class Enterprise::Billing::V2::PricingPlanComponentBuilder < Enterprise::Billing ) end - def create_license_fee(display_name:, unit_amount:, licensed_item_id:) + def create_license_fee(display_name:, unit_amount:, licensed_item_id:, lookup_key:) StripeV2Client.request( :post, '/v2/billing/license_fees', @@ -76,8 +80,9 @@ class Enterprise::Billing::V2::PricingPlanComponentBuilder < Enterprise::Billing service_interval: 'month', service_interval_count: 1, tax_behavior: 'exclusive', - unit_amount: unit_amount, - licensed_item: licensed_item_id + unit_amount: unit_amount.to_s, + licensed_item: licensed_item_id, + lookup_key: lookup_key }, { api_key: ENV.fetch('STRIPE_SECRET_KEY', nil), stripe_version: '2025-08-27.preview' } ) @@ -154,14 +159,14 @@ class Enterprise::Billing::V2::PricingPlanComponentBuilder < Enterprise::Billing ) end - def add_component(plan_id:, type:, data:) + def add_component(plan_id:, type:, data:, lookup_key:) params = case type when 'license_fee' - { type: 'license_fee', license_fee: data } + { type: 'license_fee', license_fee: data, lookup_key: lookup_key } when 'service_action' - { type: 'service_action', service_action: data } + { type: 'service_action', service_action: data, lookup_key: lookup_key } when 'rate_card' - { type: 'rate_card', rate_card: data } + { type: 'rate_card', rate_card: data, lookup_key: lookup_key } end StripeV2Client.request( 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 ef066f4dc..edebdce0c 100644 --- a/enterprise/app/services/enterprise/billing/v2/pricing_plan_service.rb +++ b/enterprise/app/services/enterprise/billing/v2/pricing_plan_service.rb @@ -25,11 +25,11 @@ class Enterprise::Billing::V2::PricingPlanService < Enterprise::Billing::V2::Bas ) end - def create_pricing_plan(display_name:, currency: 'usd', tax_behavior: 'exclusive') + def create_pricing_plan(display_name:, lookup_key:, currency: 'usd', tax_behavior: 'exclusive') StripeV2Client.request( :post, '/v2/billing/pricing_plans', - { display_name: display_name, currency: currency, tax_behavior: tax_behavior }, + { display_name: display_name, currency: currency, tax_behavior: tax_behavior, lookup_key: lookup_key }, { api_key: ENV.fetch('STRIPE_SECRET_KEY', nil), stripe_version: '2025-08-27.preview' } ) end @@ -39,35 +39,37 @@ class Enterprise::Billing::V2::PricingPlanService < Enterprise::Billing::V2::Bas cpu = get_or_create_cpu(config) meter = get_or_create_meter(config) - plan = create_pricing_plan(display_name: config[:plan_display_name]) + plan = create_pricing_plan(display_name: config[:plan_display_name], lookup_key: config[:plan_lookup_key]) builder = component_builder - - builder.add_license_fee_component(plan, config) if config[:include_license_fee] + builder.add_license_fee_component(plan, config) service_action = builder.add_service_action_component(plan, config, cpu) rate_card = builder.add_rate_card_component(plan, config, meter, cpu) - 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 } + # Make the latest version live + make_plan_version_live(plan.id) + + build_plan_result(plan, cpu, meter, rate_card, service_action) 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) + shared_cpu_id = InstallationConfig.find_by(name: 'STRIPE_CUSTOM_PRICING_UNIT_ID')&.value 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( + cpu = create_custom_pricing_unit( display_name: config[:cpu_display_name], lookup_key: config[:cpu_lookup_key] ) + + installation_config = InstallationConfig.find_or_initialize_by(name: 'STRIPE_CUSTOM_PRICING_UNIT_ID') + installation_config.value = cpu.id + installation_config.save! + + cpu end end @@ -81,10 +83,16 @@ class Enterprise::Billing::V2::PricingPlanService < Enterprise::Billing::V2::Bas OpenStruct.new(id: shared_meter_id) else # Create new meter if not using shared - create_meter( + meter = create_meter( display_name: config[:meter_display_name], event_name: config[:meter_event_name] ) + + installation_config = InstallationConfig.find_or_initialize_by(name: 'STRIPE_METER_ID') + installation_config.value = meter.id + installation_config.save! + + meter end end @@ -94,6 +102,15 @@ class Enterprise::Billing::V2::PricingPlanService < Enterprise::Billing::V2::Bas @component_builder ||= Enterprise::Billing::V2::PricingPlanComponentBuilder.new(account: account) end + def make_plan_version_live(plan_id) + StripeV2Client.request( + :post, + "/v2/billing/pricing_plans/#{plan_id}", + { live_version: 'latest' }, + { api_key: ENV.fetch('STRIPE_SECRET_KEY', nil), stripe_version: '2025-08-27.preview' } + ) + end + def build_plan_result(plan, cpu, meter, rate_card, service_action) { success: true, diff --git a/enterprise/app/services/enterprise/billing/v2/subscribe_customer_service.rb b/enterprise/app/services/enterprise/billing/v2/subscribe_customer_service.rb deleted file mode 100644 index f4dddea52..000000000 --- a/enterprise/app/services/enterprise/billing/v2/subscribe_customer_service.rb +++ /dev/null @@ -1,322 +0,0 @@ -# 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 - # 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 - rescue Stripe::StripeError => e - { success: false, message: "Stripe API error: #{e.message}", error: e } - rescue StandardError => e - { success: false, message: "Subscription error: #{e.message}", error: e } - end - - private - - def validate_subscription_params - return { success: false, message: 'Customer ID required' } if @customer_id.blank? - return { success: false, message: 'Pricing Plan ID required' } if @pricing_plan_id.blank? - - nil - end - - def execute_subscription_flow - with_locked_account do - cadence = create_billing_cadence - return { success: false, message: 'Failed to create billing cadence' } unless cadence - - pricing_plan = pricing_plan_details - return { success: false, message: 'Failed to get pricing plan details' } unless pricing_plan - - intent = create_billing_intent(cadence.id, pricing_plan) - return { success: false, message: 'Failed to create billing intent' } unless intent - - reserve_and_commit_intent(intent.id) - 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 - end - - def reserve_and_commit_intent(intent_id) - reserved_intent = reserve_intent(intent_id) - return { success: false, message: 'Failed to reserve intent' } unless reserved_intent - - committed_intent = commit_intent(intent_id) - return { success: false, message: 'Failed to commit intent' } unless committed_intent - - committed_intent - end - - def build_subscription_result(cadence_id, intent_id) - { - success: true, - customer_id: @customer_id, - pricing_plan_id: @pricing_plan_id, - cadence_id: cadence_id, - intent_id: intent_id, - status: 'subscribed' - } - end - - def stripe_customer_id - custom_attribute('stripe_customer_id') - end - - def create_billing_cadence - cadence_params = { - payer: { - type: 'customer', - customer: @customer_id - }, - billing_cycle: { - type: 'month', - interval_count: 1, - month: { - day_of_month: 1 - } - } - } - - StripeV2Client.request( - :post, - '/v2/billing/cadences', - cadence_params, - stripe_api_options - ) - end - - def pricing_plan_details - StripeV2Client.request( - :get, - "/v2/billing/pricing_plans/#{@pricing_plan_id}", - {}, - stripe_api_options - ) - end - - def create_billing_intent(cadence_id, pricing_plan) - plan_version = extract_plan_version(pricing_plan) - intent_params = build_intent_params(cadence_id, plan_version) - - StripeV2Client.request( - :post, - '/v2/billing/intents', - intent_params, - stripe_api_options - ) - end - - def extract_plan_version(pricing_plan) - pricing_plan['latest_version'] || pricing_plan['live_version'] || pricing_plan['version'] - end - - def build_intent_params(cadence_id, plan_version) - { - currency: 'usd', - cadence: cadence_id, - actions: [build_subscription_action(plan_version)] - } - end - - def build_subscription_action(plan_version) - { - type: 'subscribe', - subscribe: { - type: 'pricing_plan_subscription_details', - pricing_plan_subscription_details: { - pricing_plan: @pricing_plan_id, - pricing_plan_version: plan_version, - component_configurations: [] - } - } - } - end - - def reserve_intent(intent_id) - StripeV2Client.request( - :post, - "/v2/billing/intents/#{intent_id}/reserve", - {}, - stripe_api_options - ) - end - - def commit_intent(intent_id) - intent = fetch_billing_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 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, - { api_key: ENV.fetch('STRIPE_SECRET_KEY', nil), stripe_version: '2025-08-27.preview' } - ) - - return if customer.invoice_settings&.default_payment_method.present? - - # In production, payment methods must be added via Checkout or SetupIntent - # This ensures proper customer authentication and PCI compliance - raise Stripe::StripeError, - 'Payment method required. Customer must add payment method via Stripe Checkout or SetupIntent before subscribing.' - end - - 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' - } - # Store meter configuration if provided - attributes['stripe_meter_id'] = @meter_id if @meter_id.present? - attributes['stripe_meter_event_name'] = @meter_event_name if @meter_event_name.present? - - update_custom_attributes(attributes) - end - - def extract_plan_name(pricing_plan) - display_name = pricing_plan['display_name'] || pricing_plan[:display_name] - return 'Business' unless display_name - - # 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/subscription_provisioning_service.rb b/enterprise/app/services/enterprise/billing/v2/subscription_provisioning_service.rb new file mode 100644 index 000000000..79b3152ef --- /dev/null +++ b/enterprise/app/services/enterprise/billing/v2/subscription_provisioning_service.rb @@ -0,0 +1,152 @@ +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 + + def provision(subscription_id:) + # Retrieve pricing plan subscription details from Stripe V2 API + subscription = retrieve_pricing_plan_subscription(subscription_id) + # Extract details from the subscription + pricing_plan_id = extract_pricing_plan_id(subscription) + quantity = extract_subscription_quantity(subscription) + # Update account with subscription details + update_subscription_details(subscription_id, pricing_plan_id, quantity) + + # Provision the subscription: sync credits and enable features + provision_subscription(pricing_plan_id) if pricing_plan_id.present? + + build_success_response(subscription_id, pricing_plan_id, quantity) + rescue Stripe::StripeError => e + { success: false, message: "Stripe error: #{e.message}" } + rescue StandardError => e + { success: false, message: "Provisioning error: #{e.message}" } + end + + private + + def build_success_response(subscription_id, pricing_plan_id, quantity) + { + success: true, + subscription_id: subscription_id, + pricing_plan_id: pricing_plan_id, + quantity: quantity, + message: 'Subscription provisioned successfully' + } + end + + def retrieve_pricing_plan_subscription(subscription_id) + StripeV2Client.request( + :get, + "/v2/billing/pricing_plan_subscriptions/#{subscription_id}", + {}, + stripe_api_options + ) + end + + def extract_pricing_plan_id(subscription) + # Extract pricing_plan from the subscription object + pricing_plan = subscription.respond_to?(:pricing_plan) ? subscription.pricing_plan : subscription['pricing_plan'] + pricing_plan.is_a?(String) ? pricing_plan : pricing_plan&.[]('id') || pricing_plan&.dig(:id) + end + + def extract_subscription_quantity(_subscription) + # Get quantity from account custom_attributes (set during checkout) + pending_quantity = account.custom_attributes['pending_subscription_quantity'] + if pending_quantity.present? && pending_quantity.to_i.positive? + Rails.logger.info "[V2 Billing] Using quantity from custom_attributes: #{pending_quantity}" + return pending_quantity.to_i + end + + Rails.logger.warn '[V2 Billing] No pending_subscription_quantity found in custom_attributes, defaulting to quantity=1' + 1 + end + + def update_subscription_details(subscription_id, pricing_plan_id, quantity) + Rails.logger.info "[V2 Billing] Updating subscription details: subscription_id=#{subscription_id}, " \ + "pricing_plan_id=#{pricing_plan_id}, quantity=#{quantity}" + + attributes = { + 'stripe_billing_version' => 2, + 'stripe_subscription_id' => subscription_id, + 'subscribed_quantity' => quantity, + 'subscription_status' => 'active', + 'pending_subscription_quantity' => nil, + 'pending_subscription_pricing_plan' => nil + } + attributes['stripe_pricing_plan_id'] = pricing_plan_id if pricing_plan_id.present? + + # Add plan name from catalog + if pricing_plan_id.present? + plan_definition = Enterprise::Billing::V2::PlanCatalog.definition_for(pricing_plan_id) + attributes['plan_name'] = plan_definition[:display_name] if plan_definition + end + + update_custom_attributes(attributes) + end + + def provision_subscription(pricing_plan_id) + # Sync monthly credits based on plan + sync_plan_credits(pricing_plan_id) + + # Enable plan features based on plan + enable_plan_features(pricing_plan_id) + end + + def sync_plan_credits(pricing_plan_id) + plan_credits = Enterprise::Billing::V2::PlanCatalog.monthly_credits_for(pricing_plan_id) + return unless plan_credits + + Enterprise::Billing::V2::CreditManagementService + .new(account: account) + .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 + 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/enterprise/billing/v2/topup_catalog.rb b/enterprise/app/services/enterprise/billing/v2/topup_catalog.rb new file mode 100644 index 000000000..c840fb1af --- /dev/null +++ b/enterprise/app/services/enterprise/billing/v2/topup_catalog.rb @@ -0,0 +1,34 @@ +module Enterprise::Billing::V2::TopupCatalog + DEFAULT_TOPUPS = [ + { credits: 100, amount: 5.0 }, + { credits: 400, amount: 10.0 }, + { credits: 1000, amount: 20.0 }, + { credits: 5000, amount: 50.0 } + ].freeze + + module_function + + def options + custom_options = InstallationConfig.find_by(name: 'STRIPE_TOPUP_OPTIONS')&.value + parsed = parse_options(custom_options) + (parsed.presence || DEFAULT_TOPUPS).sort_by { |opt| opt[:credits] }.map do |option| + { + credits: option[:credits], + amount: option[:amount], + currency: option[:currency] || 'usd' + } + end + end + + def parse_options(raw) + return [] if raw.blank? + + JSON.parse(raw, symbolize_names: true) + rescue JSON::ParserError + [] + end + + def find_option(credits) + options.find { |option| option[:credits].to_i == credits.to_i } + end +end diff --git a/enterprise/app/services/enterprise/billing/v2/topup_service.rb b/enterprise/app/services/enterprise/billing/v2/topup_service.rb new file mode 100644 index 000000000..547cdab2f --- /dev/null +++ b/enterprise/app/services/enterprise/billing/v2/topup_service.rb @@ -0,0 +1,172 @@ +class Enterprise::Billing::V2::TopupService < Enterprise::Billing::V2::BaseService + def create_topup(credits:) + validation_result = validate_topup_request(credits) + return validation_result unless validation_result[:valid] + + topup_definition = validation_result[:topup_definition] + amount_cents = (topup_definition[:amount] * 100).to_i + currency = topup_definition[:currency] || 'usd' + + with_locked_account do + process_topup_transaction(credits, amount_cents, currency, topup_definition[:amount]) + end + rescue Stripe::StripeError => e + { success: false, message: "Stripe error: #{e.message}" } + rescue StandardError => e + { success: false, message: "Topup error: #{e.message}" } + end + + private + + def validate_topup_request(credits) + return { valid: false, success: false, message: 'Invalid topup amount' } unless credits.to_i.positive? + + topup_definition = Enterprise::Billing::V2::TopupCatalog.find_option(credits) + return { valid: false, success: false, message: 'Unsupported topup amount' } unless topup_definition + return { valid: false, success: false, message: 'Stripe customer not configured' } if stripe_customer_id.blank? + + { valid: true, topup_definition: topup_definition } + end + + def process_topup_transaction(credits, amount_cents, currency, amount) + invoice = create_topup_invoice(currency) + return { success: false, message: 'Failed to create invoice' } unless invoice + + invoice_item = create_topup_invoice_item(invoice.id, amount_cents, currency, credits) + return { success: false, message: 'Failed to create invoice item' } unless invoice_item + + finalized_invoice = finalize_topup_invoice(invoice.id) + return { success: false, message: 'Failed to finalize invoice' } unless finalized_invoice + + paid_invoice = pay_invoice(invoice.id) + return { success: false, message: 'Failed to pay invoice' } unless paid_invoice + + credit_grant = create_stripe_credit_grant(amount_cents, currency, credits) + return { success: false, message: 'Failed to create credit grant in Stripe' } unless credit_grant + + # Credits will be added by webhook when Stripe sends billing.credit_grant.created event + build_success_response(credits, amount, currency, invoice.id, credit_grant['id']) + end + + def build_success_response(credits, amount, currency, invoice_id, credit_grant_id) + { + success: true, + message: 'Top-up purchased successfully', + credits: credits, + amount: amount, + currency: currency, + invoice_id: invoice_id, + credit_grant_id: credit_grant_id + } + end + + # Create Invoice following Stripe UBB Integration Guide + def create_topup_invoice(currency) + Stripe::Invoice.create( + { + customer: stripe_customer_id, + currency: currency, + collection_method: 'charge_automatically', + auto_advance: false, # We'll finalize it manually + metadata: { + account_id: account.id.to_s, + topup: 'true' + } + }, + stripe_api_options + ) + end + + # Create Invoice Item with topup amount + def create_topup_invoice_item(invoice_id, amount_cents, currency, credits) + Stripe::InvoiceItem.create( + { + customer: stripe_customer_id, + amount: amount_cents, + currency: currency, + invoice: invoice_id, + description: "Credit Topup: #{credits} credits", + metadata: { + account_id: account.id.to_s, + credits: credits.to_s, + topup: 'true' + } + }, + stripe_api_options + ) + end + + # Finalize Invoice for payment + def finalize_topup_invoice(invoice_id) + Stripe::Invoice.finalize_invoice( + invoice_id, + { auto_advance: false }, # We'll pay it explicitly + stripe_api_options + ) + end + + # Pay the invoice explicitly + def pay_invoice(invoice_id) + Stripe::Invoice.pay( + invoice_id, + {}, + stripe_api_options + ) + end + + # Create Credit Grant in Stripe using monetary amount (not custom_pricing_unit) + # Following Stripe UBB Integration Guide section 8 + def create_stripe_credit_grant(amount_cents, currency, credits) + Stripe::Billing::CreditGrant.create( + credit_grant_params(amount_cents, currency, credits), + stripe_api_options + ) + end + + def credit_grant_params(amount_cents, currency, credits) + { + customer: stripe_customer_id, + name: "Topup: #{credits} credits", + amount: credit_grant_amount(amount_cents, currency), + applicability_config: credit_grant_applicability, + category: 'paid', + metadata: credit_grant_metadata(credits) + } + end + + def credit_grant_amount(amount_cents, currency) + { + type: 'monetary', + monetary: { + currency: currency, + value: amount_cents + } + } + end + + def credit_grant_applicability + # Apply credit grant to all metered usage for this customer + # This ensures topup credits offset meter-based billing + { + scope: { + price_type: 'metered' + } + } + end + + def credit_grant_metadata(credits) + { + account_id: account.id.to_s, + source: 'topup', + credits: credits.to_s + } + end + + def stripe_customer_id + custom_attribute('stripe_customer_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/enterprise/billing/v2/update_subscription_service.rb b/enterprise/app/services/enterprise/billing/v2/update_subscription_service.rb new file mode 100644 index 000000000..70b2caae1 --- /dev/null +++ b/enterprise/app/services/enterprise/billing/v2/update_subscription_service.rb @@ -0,0 +1,131 @@ +class Enterprise::Billing::V2::UpdateSubscriptionService < Enterprise::Billing::V2::BaseService + include Enterprise::Billing::Concerns::PlanFeatureManager + + # Update subscription to a new plan with proration + # + # @param pricing_plan_id [String] New V2 Pricing Plan ID + # @param quantity [Integer] Number of licenses/seats + # @return [Hash] { success:, subscription_id:, prorated: } or error + # + def update_subscription(pricing_plan_id:, quantity: 1) + @pricing_plan_id = pricing_plan_id + @quantity = quantity.to_i.positive? ? quantity.to_i : 1 + + validate_params + store_pending_subscription_details + update_stripe_subscription + rescue Stripe::StripeError => e + { success: false, message: "Stripe API error: #{e.message}", error: e } + rescue StandardError => e + { success: false, message: "Update error: #{e.message}", error: e } + end + + private + + def validate_params + raise StandardError, 'Pricing Plan ID required' if @pricing_plan_id.blank? + raise StandardError, 'Not a V2 billing account' unless v2_enabled? + raise StandardError, 'No active subscription to update. Please subscribe to a plan first.' unless active_subscription? + end + + def v2_enabled? + custom_attribute('stripe_billing_version')&.to_i == 2 + end + + def active_subscription? + # Both subscription_status and subscription_id must be present + custom_attribute('subscription_status') == 'active' && + custom_attribute('stripe_subscription_id').present? + end + + def store_pending_subscription_details + # Store for webhook to use when it fires + update_custom_attributes({ + 'pending_subscription_quantity' => @quantity, + 'pending_subscription_pricing_plan' => @pricing_plan_id + }) + end + + def update_stripe_subscription + subscription_id = custom_attribute('stripe_subscription_id') + lookup_key = extract_license_lookup_key + raise StandardError, "Lookup key not found for pricing plan #{@pricing_plan_id}" unless lookup_key + + # Get the current subscription to find the subscription item ID + current_subscription = fetch_current_subscription(subscription_id) + item_id = extract_subscription_item_id(current_subscription) + + # Update the subscription with new price and quantity + updated_subscription = StripeV2Client.request( + :post, + "/v1/subscriptions/#{subscription_id}", + subscription_update_params(item_id, lookup_key), + stripe_api_options + ) + + build_success_response(updated_subscription) + end + + def fetch_current_subscription(subscription_id) + StripeV2Client.request( + :get, + "/v1/subscriptions/#{subscription_id}", + {}, + stripe_api_options + ) + end + + def extract_subscription_item_id(subscription) + # Get the first subscription item (we only have one item per subscription) + items = subscription.respond_to?(:items) ? subscription.items : subscription['items'] + data = items.respond_to?(:data) ? items.data : items['data'] + first_item = data&.first + + raise StandardError, 'No subscription items found' unless first_item + + first_item.respond_to?(:id) ? first_item.id : first_item['id'] + end + + def subscription_update_params(item_id, lookup_key) + price_id = "price_#{lookup_key}" + + { + items: [ + { + id: item_id, + price: price_id, + quantity: @quantity + } + ], + proration_behavior: 'create_prorations', + metadata: { + account_id: account.id, + pricing_plan_id: @pricing_plan_id, + quantity: @quantity, + billing_version: 'v2' + } + } + end + + def extract_license_lookup_key + Enterprise::Billing::V2::PlanCatalog.lookup_key_for_plan(@pricing_plan_id) + end + + def build_success_response(subscription) + subscription_id = subscription.respond_to?(:id) ? subscription.id : subscription['id'] + + { + success: true, + subscription_id: subscription_id, + prorated: true, + message: 'Subscription updated successfully with prorations' + } + 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/enterprise/billing/v2/webhook_handler_service.rb b/enterprise/app/services/enterprise/billing/v2/webhook_handler_service.rb index ea96d1444..39e191347 100644 --- a/enterprise/app/services/enterprise/billing/v2/webhook_handler_service.rb +++ b/enterprise/app/services/enterprise/billing/v2/webhook_handler_service.rb @@ -1,10 +1,10 @@ class Enterprise::Billing::V2::WebhookHandlerService < Enterprise::Billing::V2::BaseService def process(event) case event.type - when 'billing.credit_grant.created' - handle_credit_grant_created(event.data.object) - when 'billing.credit_grant.updated' - handle_credit_grant_updated(event.data.object) + 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) else { success: true } end @@ -14,49 +14,9 @@ class Enterprise::Billing::V2::WebhookHandlerService < Enterprise::Billing::V2:: private - def handle_credit_grant_created(grant) - amount = extract_credit_amount(grant) - return { success: true } if amount.zero? - - service = Enterprise::Billing::V2::CreditManagementService.new(account: account) - - if grant.expires_at.present? - service.sync_monthly_credits(amount) - else - service.add_topup_credits(amount) - end - - { success: true } - end - - def handle_credit_grant_updated(grant) - # Check if grant has expired - if grant.respond_to?(:expired_at) && grant.expired_at - # Grant has expired - handle_credit_grant_expired - else - # Other updates (voided, amount changes, etc) - { success: true } - end - end - - def handle_credit_grant_expired - Enterprise::Billing::V2::CreditManagementService.new(account: account).expire_monthly_credits - { success: true } - end - - def extract_credit_amount(grant) - # Handle both Hash and OpenStruct response formats - amount_data = grant.respond_to?(:amount) ? grant.amount : grant['amount'] - return 0 unless amount_data - - # Extract value from nested structure - if amount_data.is_a?(Hash) - amount_data.dig('custom_pricing_unit', 'value').to_i - elsif amount_data.respond_to?(:custom_pricing_unit) - amount_data.custom_pricing_unit&.value.to_i - else - 0 - end + def handle_subscription_servicing_activated(subscription_id) + Enterprise::Billing::V2::SubscriptionProvisioningService + .new(account: account) + .provision(subscription_id: subscription_id) end end diff --git a/enterprise/lib/stripe_v2_client.rb b/enterprise/lib/stripe_v2_client.rb index 256489863..33ed255a5 100644 --- a/enterprise/lib/stripe_v2_client.rb +++ b/enterprise/lib/stripe_v2_client.rb @@ -26,10 +26,13 @@ module StripeV2Client when :post req = Net::HTTP::Post.new(uri) if v2_endpoint?(path) + # V2 endpoints use JSON req.body = params.to_json unless params.empty? req['Content-Type'] = 'application/json' else - req.set_form_data(params) unless params.empty? + # V1 endpoints (including checkout sessions) use form data + # even with preview API versions + req.body = encode_nested_params(params) unless params.empty? req['Content-Type'] = 'application/x-www-form-urlencoded' end req @@ -42,6 +45,32 @@ module StripeV2Client path.start_with?('/v2/') end + # Encode nested parameters for form submission + # Stripe expects nested params like: checkout_items[0][type]=value + def encode_nested_params(params, prefix = nil) + pairs = [] + params.each do |key, value| + full_key = prefix ? "#{prefix}[#{key}]" : key.to_s + pairs.concat(encode_param_value(full_key, value)) + end + pairs.join('&') + end + + def encode_param_value(key, value) + case value + when Hash + [encode_nested_params(value, key)] + when Array + value.each_with_index.map { |item, index| encode_nested_params(item, "#{key}[#{index}]") } + when true, false + ["#{CGI.escape(key)}=#{value}"] + when nil + [] + else + ["#{CGI.escape(key)}=#{CGI.escape(value.to_s)}"] + end + end + def parse_response(response) body = JSON.parse(response.body) 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 770f5681d..ccf89fa35 100644 --- a/spec/enterprise/jobs/enterprise/create_stripe_customer_job_spec.rb +++ b/spec/enterprise/jobs/enterprise/create_stripe_customer_job_spec.rb @@ -12,41 +12,16 @@ RSpec.describe Enterprise::CreateStripeCustomerJob, type: :job do .on_queue('default') end - context 'when V1 billing' do - before do - allow(ENV).to receive(:fetch).with('STRIPE_BILLING_V2_ENABLED', 'false').and_return('false') - end + 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) - 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) + perform_enqueued_jobs { job } - 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 + expect(Enterprise::Billing::CreateStripeCustomerService).to have_received(:new).with(account: account) 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..d1f81da0e 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 @@ -5,137 +5,37 @@ describe Enterprise::Billing::CreateStripeCustomerService do let(:account) { create(:account) } let!(:admin1) { create(:user, account: account, role: :administrator) } - let(:admin2) { create(:user, account: account, role: :administrator) } - let(:subscriptions_list) { double } describe '#perform' do - before do - create( - :installation_config, - { name: 'CHATWOOT_CLOUD_PLANS', value: [ - { 'name' => 'A Plan Name', 'product_id' => ['prod_hacker_random'], 'price_ids' => ['price_hacker_random'] } - ] } - ) - 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 - ) - - create_stripe_customer_service.new(account: account).perform - - expect(Stripe::Customer).not_to have_received(:create) - expect(Stripe::Subscription) - .to have_received(:create) - .with({ customer: 'cus_random_number', items: [{ price: 'price_hacker_random', quantity: 2 }] }) - - expect(account.reload.custom_attributes).to eq( - { - stripe_customer_id: 'cus_random_number', - stripe_price_id: 'price_random_number', - stripe_product_id: 'prod_random_number', - subscribed_quantity: 2, - plan_name: 'A Plan Name' - }.with_indifferent_access - ) - end - - it 'calls stripe methods to create a customer and updates the account' 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 - ) - - create_stripe_customer_service.new(account: account).perform - - expect(Stripe::Customer).to have_received(:create).with({ name: account.name, email: admin1.email }) - expect(Stripe::Subscription) - .to have_received(:create) - .with({ customer: customer.id, items: [{ price: 'price_hacker_random', quantity: 2 }] }) - - expect(account.reload.custom_attributes).to eq( - { - 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' - }.with_indifferent_access - ) - end - end - - describe 'when checking for existing subscriptions' do - before do - create( - :installation_config, - { name: 'CHATWOOT_CLOUD_PLANS', value: [ - { 'name' => 'A Plan Name', 'product_id' => ['prod_hacker_random'], 'price_ids' => ['price_hacker_random'] } - ] } - ) - end - - context 'when account has no stripe_customer_id' do - it 'creates a new subscription' do + context 'when customer does not exist' do + it 'creates a stripe customer and saves the customer_id' 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 - ) create_stripe_customer_service.new(account: account).perform - expect(Stripe::Customer).to have_received(:create) - expect(Stripe::Subscription).to have_received(:create) + expect(Stripe::Customer).to have_received(:create).with({ name: account.name, email: admin1.email }) + expect(account.reload.custom_attributes).to eq( + { + stripe_customer_id: 'cus_random_number' + }.with_indifferent_access + ) end end - context 'when account has stripe_customer_id' do - let(:stripe_customer_id) { 'cus_random_number' } - + context 'when customer already exists' do before do - account.update!(custom_attributes: { stripe_customer_id: stripe_customer_id }) + account.update!(custom_attributes: { stripe_customer_id: 'cus_existing_customer' }) end - context 'when customer has active subscriptions' do - before do - allow(Stripe::Subscription).to receive(:list).and_return(subscriptions_list) - allow(subscriptions_list).to receive(:data).and_return(['subscription']) - allow(Stripe::Subscription).to receive(:create) - end + it 'does not create a new customer' do + allow(Stripe::Customer).to receive(:create) - it 'does not create a new subscription' do - create_stripe_customer_service.new(account: account).perform + create_stripe_customer_service.new(account: account).perform - expect(Stripe::Subscription).not_to have_received(:create) - expect(Stripe::Subscription).to have_received(:list).with( - { - customer: stripe_customer_id, - status: 'active', - limit: 1 - } - ) - end + expect(Stripe::Customer).not_to have_received(:create) + expect(account.reload.custom_attributes['stripe_customer_id']).to eq('cus_existing_customer') end end end diff --git a/spec/enterprise/services/enterprise/billing/handle_stripe_event_service_spec.rb b/spec/enterprise/services/enterprise/billing/handle_stripe_event_service_spec.rb index 67ccd168b..23a194aa9 100644 --- a/spec/enterprise/services/enterprise/billing/handle_stripe_event_service_spec.rb +++ b/spec/enterprise/services/enterprise/billing/handle_stripe_event_service_spec.rb @@ -317,4 +317,160 @@ describe Enterprise::Billing::HandleStripeEventService do end end end + + describe 'credit grant handling' do + let(:credit_service) { instance_double(Enterprise::Billing::V2::CreditManagementService) } + + before do + allow(Enterprise::Billing::V2::CreditManagementService).to receive(:new) + .with(account: account).and_return(credit_service) + end + + context 'when handling monthly credit grant' do + it 'syncs monthly credits from Stripe' do + allow(credit_service).to receive(:sync_monthly_credits) + + # Webhook event object (minimal, just has ID) + grant_event_object = OpenStruct.new( + id: 'credgr_test_123', + customer: 'cus_123' + ) + allow(event).to receive(:type).and_return('billing.credit_grant.created') + allow(data).to receive(:object).and_return(grant_event_object) + + # Full grant object from API (has complete amount structure) + api_grant_response = OpenStruct.new( + id: 'credgr_test_123', + customer: 'cus_123', + amount: OpenStruct.new( + type: 'custom_pricing_unit', + custom_pricing_unit: OpenStruct.new(value: 2000) + ), + expires_at: Time.current + ) + allow(StripeV2Client).to receive(:request) + .with(:get, '/v1/billing/credit_grants/credgr_test_123') + .and_return(api_grant_response) + + stripe_event_service.new.perform(event: event) + + expect(credit_service).to have_received(:sync_monthly_credits).with(2000) + end + end + + context 'when handling topup credit grant' do + it 'adds topup credits' do + allow(credit_service).to receive(:add_topup_credits) + + # Webhook event object (minimal, just has ID) + grant_event_object = OpenStruct.new( + id: 'credgr_test_456', + customer: 'cus_123' + ) + allow(event).to receive(:type).and_return('billing.credit_grant.created') + allow(data).to receive(:object).and_return(grant_event_object) + + # Full grant object from API (has complete amount structure) + api_grant_response = OpenStruct.new( + id: 'credgr_test_456', + customer: 'cus_123', + amount: OpenStruct.new( + type: 'custom_pricing_unit', + custom_pricing_unit: OpenStruct.new(value: 500) + ), + expires_at: nil + ) + allow(StripeV2Client).to receive(:request) + .with(:get, '/v1/billing/credit_grants/credgr_test_456') + .and_return(api_grant_response) + + stripe_event_service.new.perform(event: event) + + expect(credit_service).to have_received(:add_topup_credits).with(500) + end + end + + context 'when handling credit grant update with expiration' do + it 'expires monthly credits when grant is expired' do + allow(credit_service).to receive(:expire_monthly_credits).and_return(100) + + grant = OpenStruct.new( + customer: 'cus_123', + expired_at: Time.current + ) + allow(event).to receive(:type).and_return('billing.credit_grant.updated') + allow(data).to receive(:object).and_return(grant) + + stripe_event_service.new.perform(event: event) + + expect(credit_service).to have_received(:expire_monthly_credits) + end + end + + context 'when handling monetary type credit grant' do + it 'syncs monthly credits from monetary grant' do + allow(credit_service).to receive(:sync_monthly_credits) + + # Webhook event object (minimal, just has ID) + grant_event_object = OpenStruct.new( + id: 'credgr_test_monetary', + customer: 'cus_123' + ) + allow(event).to receive(:type).and_return('billing.credit_grant.created') + allow(data).to receive(:object).and_return(grant_event_object) + + # Full grant object from API with monetary amount + api_grant_response = OpenStruct.new( + id: 'credgr_test_monetary', + customer: 'cus_123', + amount: OpenStruct.new( + type: 'monetary', + monetary: OpenStruct.new( + currency: 'usd', + value: 1000 + ) + ), + expires_at: Time.current + ) + allow(StripeV2Client).to receive(:request) + .with(:get, '/v1/billing/credit_grants/credgr_test_monetary') + .and_return(api_grant_response) + + stripe_event_service.new.perform(event: event) + + expect(credit_service).to have_received(:sync_monthly_credits).with(1000) + end + end + + context 'when handling credit grant with zero amount' do + it 'does not call credit service' do + # Webhook event object (minimal, just has ID) + grant_event_object = OpenStruct.new( + id: 'credgr_test_zero', + customer: 'cus_123' + ) + allow(event).to receive(:type).and_return('billing.credit_grant.created') + allow(data).to receive(:object).and_return(grant_event_object) + + # Full grant object from API with zero amount + api_grant_response = OpenStruct.new( + id: 'credgr_test_zero', + customer: 'cus_123', + amount: OpenStruct.new( + type: 'custom_pricing_unit', + custom_pricing_unit: OpenStruct.new(value: 0) + ), + expires_at: Time.current + ) + allow(StripeV2Client).to receive(:request) + .with(:get, '/v1/billing/credit_grants/credgr_test_zero') + .and_return(api_grant_response) + + stripe_event_service.new.perform(event: event) + + # Ensure we don't accidentally call these methods + expect(Enterprise::Billing::V2::CreditManagementService).not_to have_received(:new) + end + end + end end diff --git a/spec/enterprise/services/enterprise/billing/v2/pricing_plan_service_spec.rb b/spec/enterprise/services/enterprise/billing/v2/pricing_plan_service_spec.rb index 0c01561aa..af3584f02 100644 --- a/spec/enterprise/services/enterprise/billing/v2/pricing_plan_service_spec.rb +++ b/spec/enterprise/services/enterprise/billing/v2/pricing_plan_service_spec.rb @@ -48,58 +48,88 @@ RSpec.describe Enterprise::Billing::V2::PricingPlanService do plan_response = OpenStruct.new(id: 'plan_123') allow(StripeV2Client).to receive(:request).and_return(plan_response) - result = service.create_pricing_plan(display_name: 'Business Plan') + result = service.create_pricing_plan(display_name: 'Business Plan', lookup_key: 'business_plan') expect(result.id).to eq('plan_123') expect(StripeV2Client).to have_received(:request).with( :post, '/v2/billing/pricing_plans', - { display_name: 'Business Plan', currency: 'usd', tax_behavior: 'exclusive' }, + { display_name: 'Business Plan', currency: 'usd', tax_behavior: 'exclusive', lookup_key: 'business_plan' }, { api_key: 'sk_test_123', stripe_version: '2025-08-27.preview' } ) end end describe '#create_complete_pricing_plan' do + # Clean up any existing InstallationConfig entries before each test + before do + InstallationConfig.where(name: %w[STRIPE_CUSTOM_PRICING_UNIT_ID STRIPE_METER_ID]).destroy_all + # Also ensure ENV doesn't have STRIPE_METER_ID + allow(ENV).to receive(:fetch).with('STRIPE_METER_ID', nil).and_return(nil) + end + + let(:cpu) { OpenStruct.new(id: 'cpu_123') } + let(:meter) { OpenStruct.new(id: 'meter_123') } + let(:plan) { OpenStruct.new(id: 'plan_123') } + let(:service_action) { OpenStruct.new(id: 'sa_123') } + let(:rate_card) { OpenStruct.new(id: 'rc_123', latest_version: 'v1') } + let(:live_plan) { OpenStruct.new(id: 'plan_123', live_version: 'latest') } + let(:config) do { + # Shared resources cpu_display_name: 'Credits', cpu_lookup_key: 'cpu_001', meter_display_name: 'Prompts', meter_event_name: 'prompts_001', + + # Plan details plan_display_name: 'Business Plan', - include_license_fee: true, + plan_lookup_key: 'business_plan', + + # Component-specific lookup keys (each must be unique) + lookup_key: 'chatwoot_business_agents', # Used for license fee + service_action_lookup_key: 'chatwoot_business_credits', + metered_item_lookup_key: 'chatwoot_business_usage', + + # License fee licensed_item_display_name: 'Seat', - licensed_item_lookup_key: 'seat_001', licensed_item_unit_label: 'per agent', license_fee_display_name: 'Fee', - license_fee_amount: '3900', - service_action_lookup_key: 'credits_001', + license_fee_amount: 3900, + + # Service action (monthly credits) monthly_credit_amount: 2000, + + # Rate card (overage) rate_card_display_name: 'Rates', metered_item_display_name: 'Prompt', - metered_item_lookup_key: 'prompt_001', rate_value: 1 } end - it 'creates a complete pricing plan with all components' do - cpu = OpenStruct.new(id: 'cpu_123') - meter = OpenStruct.new(id: 'meter_123') - plan = OpenStruct.new(id: 'plan_123') - service_action = OpenStruct.new(id: 'sa_123') - rate_card = OpenStruct.new(id: 'rc_123', latest_version: 'v1') + def stub_stripe_client + allow(StripeV2Client).to receive(:request) do |_method, path, *_args| + case path + when '/v2/billing/custom_pricing_units' then cpu + when '/v1/billing/meters' then meter + when '/v2/billing/pricing_plans' then plan + when '/v2/billing/pricing_plans/plan_123' then live_plan + end + end + end - allow(StripeV2Client).to receive(:request).and_return(cpu, meter, plan, service_action, rate_card) - - # Create a double for the component builder + def stub_component_builder component_builder = instance_double(Enterprise::Billing::V2::PricingPlanComponentBuilder) allow(component_builder).to receive(:add_license_fee_component).and_return(true) allow(component_builder).to receive(:add_service_action_component).and_return(service_action) allow(component_builder).to receive(:add_rate_card_component).and_return(rate_card) - - # Stub the private component_builder method to return our double allow(service).to receive(:component_builder).and_return(component_builder) + end + + it 'creates a complete pricing plan with all components' do + stub_stripe_client + stub_component_builder result = service.create_complete_pricing_plan(config) @@ -108,6 +138,8 @@ RSpec.describe Enterprise::Billing::V2::PricingPlanService do expect(result[:custom_pricing_unit]).to eq(cpu) expect(result[:meter]).to eq(meter) expect(result[:service_action]).to eq(service_action) + expect(InstallationConfig.find_by(name: 'STRIPE_CUSTOM_PRICING_UNIT_ID')&.value).to eq('cpu_123') + expect(InstallationConfig.find_by(name: 'STRIPE_METER_ID')&.value).to eq('meter_123') end end end diff --git a/spec/enterprise/services/enterprise/billing/v2/subscription_provisioning_service_spec.rb b/spec/enterprise/services/enterprise/billing/v2/subscription_provisioning_service_spec.rb new file mode 100644 index 000000000..c3a371949 --- /dev/null +++ b/spec/enterprise/services/enterprise/billing/v2/subscription_provisioning_service_spec.rb @@ -0,0 +1,217 @@ +require 'rails_helper' + +describe Enterprise::Billing::V2::SubscriptionProvisioningService do + let(:account) { create(:account) } + let(:service) { described_class.new(account: account) } + let(:subscription_id) { 'bpps_subscription_123' } + let(:pricing_plan_id) { 'bpp_business_plan_123' } + + before do + account.update!(custom_attributes: { 'stripe_billing_version' => 2, 'pending_subscription_quantity' => 5 }) + create(:installation_config, name: 'STRIPE_BUSINESS_PLAN_ID', value: pricing_plan_id) + end + + describe '#provision' do + let(:subscription_response) do + OpenStruct.new( + id: subscription_id, + pricing_plan: pricing_plan_id, + component_values: [ + { 'type' => 'license_fee', 'quantity' => 5 } + ] + ) + end + + before do + allow(StripeV2Client).to receive(:request).and_return(subscription_response) + end + + it 'retrieves subscription details from Stripe' do + service.provision(subscription_id: subscription_id) + + expect(StripeV2Client).to have_received(:request).with( + :get, + "/v2/billing/pricing_plan_subscriptions/#{subscription_id}", + {}, + hash_including(api_key: ENV.fetch('STRIPE_SECRET_KEY', nil), stripe_version: '2025-08-27.preview') + ) + end + + it 'updates account custom attributes with subscription details' do + result = service.provision(subscription_id: subscription_id) + + expect(result[:success]).to be(true) + expect(account.custom_attributes['stripe_billing_version']).to eq(2) + expect(account.custom_attributes['stripe_subscription_id']).to eq(subscription_id) + expect(account.custom_attributes['stripe_pricing_plan_id']).to eq(pricing_plan_id) + 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 based on plan' do + credit_service = instance_double(Enterprise::Billing::V2::CreditManagementService) + allow(Enterprise::Billing::V2::CreditManagementService) + .to receive(:new) + .with(account: account) + .and_return(credit_service) + allow(credit_service).to receive(:sync_monthly_credits) + + service.provision(subscription_id: subscription_id) + + expect(credit_service).to have_received(:sync_monthly_credits).with(50_000) + end + + it 'enables plan-specific features' do + service.provision(subscription_id: subscription_id) + + expected_features = %w[ + inbound_emails + help_center + campaigns + team_management + channel_twitter + channel_facebook + channel_email + channel_instagram + captain_integration + advanced_search_indexing + sla + custom_roles + ] + enabled_feature_names = account.enabled_features.map(&:first) + expect(enabled_feature_names).to match_array(expected_features) + end + + it 'returns success response with subscription details' do + result = service.provision(subscription_id: subscription_id) + + expect(result[:success]).to be(true) + expect(result[:subscription_id]).to eq(subscription_id) + expect(result[:pricing_plan_id]).to eq(pricing_plan_id) + expect(result[:quantity]).to eq(5) + expect(result[:message]).to eq('Subscription provisioned successfully') + end + + context 'when pricing plan has no license fee component' do + let(:subscription_response) do + OpenStruct.new( + id: subscription_id, + pricing_plan: pricing_plan_id, + component_values: [] + ) + end + + before do + # Clear pending_subscription_quantity to test default behavior + account.update!(custom_attributes: account.custom_attributes.except('pending_subscription_quantity')) + end + + it 'defaults quantity to 1' do + result = service.provision(subscription_id: subscription_id) + + expect(result[:quantity]).to eq(1) + expect(account.custom_attributes['subscribed_quantity']).to eq(1) + end + end + + context 'when Stripe API returns an error' do + before do + allow(StripeV2Client).to receive(:request).and_raise(Stripe::StripeError.new('API error')) + end + + it 'returns error response' do + result = service.provision(subscription_id: subscription_id) + + expect(result[:success]).to be(false) + expect(result[:message]).to include('Stripe error') + end + end + + context 'when an unexpected error occurs' do + before do + allow(StripeV2Client).to receive(:request).and_raise(StandardError.new('Unexpected error')) + end + + it 'returns error response' do + result = service.provision(subscription_id: subscription_id) + + expect(result[:success]).to be(false) + expect(result[:message]).to include('Provisioning error') + end + end + + context 'with Startup plan' do + let(:startup_plan_id) { 'bpp_startup_plan_123' } + let(:subscription_response) do + OpenStruct.new( + id: subscription_id, + pricing_plan: startup_plan_id, + component_values: [{ 'type' => 'license_fee', 'quantity' => 3 }] + ) + end + + before do + create(:installation_config, name: 'STRIPE_STARTUP_PLAN_ID', value: startup_plan_id) + end + + it 'enables only Startup features' do + service.provision(subscription_id: subscription_id) + + expected_features = %w[ + inbound_emails + help_center + campaigns + team_management + channel_twitter + channel_facebook + channel_email + channel_instagram + captain_integration + advanced_search_indexing + ] + enabled_feature_names = account.enabled_features.map(&:first) + expect(enabled_feature_names).to match_array(expected_features) + end + end + + context 'with Enterprise plan' do + let(:enterprise_plan_id) { 'bpp_enterprise_plan_123' } + let(:subscription_response) do + OpenStruct.new( + id: subscription_id, + pricing_plan: enterprise_plan_id, + component_values: [{ 'type' => 'license_fee', 'quantity' => 10 }] + ) + end + + before do + create(:installation_config, name: 'STRIPE_ENTERPRISE_PLAN_ID', value: enterprise_plan_id) + end + + it 'enables all features including Enterprise-specific ones' do + service.provision(subscription_id: subscription_id) + + expected_features = %w[ + inbound_emails + help_center + campaigns + team_management + channel_twitter + channel_facebook + channel_email + channel_instagram + captain_integration + advanced_search_indexing + sla + custom_roles + audit_logs + disable_branding + saml + ] + enabled_feature_names = account.enabled_features.map(&:first) + expect(enabled_feature_names).to match_array(expected_features) + end + end + end +end diff --git a/spec/enterprise/services/enterprise/billing/v2/topup_service_spec.rb b/spec/enterprise/services/enterprise/billing/v2/topup_service_spec.rb new file mode 100644 index 000000000..e9594fe85 --- /dev/null +++ b/spec/enterprise/services/enterprise/billing/v2/topup_service_spec.rb @@ -0,0 +1,135 @@ +require 'rails_helper' +require 'ostruct' + +describe Enterprise::Billing::V2::TopupService do + let(:account) { create(:account, custom_attributes: { 'stripe_customer_id' => 'cus_123' }) } + let(:service) { described_class.new(account: account) } + let(:invoice) { Stripe::Invoice.construct_from(id: 'in_123', customer: 'cus_123', currency: 'usd') } + let(:invoice_item) do + Stripe::InvoiceItem.construct_from( + id: 'ii_123', + invoice: 'in_123', + amount: 5000, + currency: 'usd' + ) + end + let(:finalized_invoice) { Stripe::Invoice.construct_from(id: 'in_123', status: 'open') } + let(:paid_invoice) { Stripe::Invoice.construct_from(id: 'in_123', status: 'paid') } + let(:credit_grant) do + { + 'id' => 'credgr_123', + 'customer' => 'cus_123', + 'amount' => { 'type' => 'monetary', 'monetary' => { 'value' => 5000, 'currency' => 'usd' } } + } + end + + before do + allow(Enterprise::Billing::V2::TopupCatalog).to receive(:find_option).with(500).and_return( + credits: 500, + amount: 50.0, + currency: 'usd' + ) + + # Mock Stripe Invoice creation + allow(Stripe::Invoice).to receive(:create).and_return(invoice) + + # Mock Stripe InvoiceItem creation + allow(Stripe::InvoiceItem).to receive(:create).and_return(invoice_item) + + # Mock Stripe Invoice finalization + allow(Stripe::Invoice).to receive(:finalize_invoice).and_return(finalized_invoice) + + # Mock Stripe Invoice payment + allow(Stripe::Invoice).to receive(:pay).and_return(paid_invoice) + + # Mock Stripe Credit Grant creation (monetary amount) + allow(Stripe::Billing::CreditGrant).to receive(:create).and_return(credit_grant) + end + + describe '#create_topup' do + context 'when successful' do + before do + service.create_topup(credits: 500) + end + + it 'creates invoice with correct parameters' do + expect(Stripe::Invoice).to have_received(:create).with( + hash_including( + customer: 'cus_123', + currency: 'usd', + collection_method: 'charge_automatically' + ), + hash_including(:api_key) + ) + end + + it 'creates invoice item with topup amount' do + expect(Stripe::InvoiceItem).to have_received(:create).with( + hash_including( + customer: 'cus_123', + amount: 5000, + currency: 'usd', + invoice: 'in_123', + description: 'Credit Topup: 500 credits' + ), + hash_including(:api_key) + ) + end + + it 'finalizes invoice for payment' do + expect(Stripe::Invoice).to have_received(:finalize_invoice).with( + 'in_123', + hash_including(auto_advance: false), + hash_including(:api_key) + ) + end + + it 'pays the invoice explicitly' do + expect(Stripe::Invoice).to have_received(:pay).with( + 'in_123', + {}, + hash_including(:api_key) + ) + end + + it 'creates credit grant with monetary amount' do + expect(Stripe::Billing::CreditGrant).to have_received(:create).with( + hash_including( + customer: 'cus_123', + name: 'Topup: 500 credits', + amount: hash_including( + type: 'monetary', + monetary: hash_including(currency: 'usd', value: 5000) + ), + applicability_config: hash_including(scope: hash_including(price_type: 'metered')), + category: 'paid', + metadata: hash_including( + account_id: account.id.to_s, + source: 'topup', + credits: '500' + ) + ), + hash_including(:api_key) + ) + end + + it 'returns success response with all ids' do + result = service.create_topup(credits: 500) + + expect(result[:success]).to be true + expect(result[:credits]).to eq(500) + expect(result[:invoice_id]).to eq('in_123') + expect(result[:credit_grant_id]).to eq('credgr_123') + end + end + + it 'returns error when option missing' do + allow(Enterprise::Billing::V2::TopupCatalog).to receive(:find_option).and_return(nil) + + result = service.create_topup(credits: 999) + + expect(result[:success]).to be false + expect(result[:message]).to eq('Unsupported topup amount') + 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 4d87c590a..589fe1e80 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 @@ -7,64 +7,76 @@ describe Enterprise::Billing::V2::WebhookHandlerService do let(:credit_service) { instance_double(Enterprise::Billing::V2::CreditManagementService) } before do - account.update!(custom_attributes: { 'stripe_billing_version' => 2 }) + 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) end - def build_event(type:, object:) - data = double('Stripe::Event::Data', object: object) - double('Stripe::Event', type: type, data: data) - end - describe '#process' do - context 'when handling monthly credit grant' do - it 'syncs monthly credits from Stripe' do + context 'when handling subscription servicing activated' do + let(:subscription_response) do + OpenStruct.new( + id: 'bpps_subscription_123', + pricing_plan: 'bpp_business_plan_123', + component_values: [{ 'type' => 'license_fee', 'quantity' => 5 }] + ) + end + + let(:event) do + double( + 'Stripe::Event', + type: 'v2.billing.pricing_plan_subscription.servicing_activated', + related_object: OpenStruct.new(id: 'bpps_subscription_123') + ) + end + + let(:expected_features) do + %w[inbound_emails help_center campaigns team_management channel_twitter channel_facebook + channel_email channel_instagram captain_integration advanced_search_indexing sla custom_roles] + end + + before do + create(:installation_config, name: 'STRIPE_BUSINESS_PLAN_ID', value: 'bpp_business_plan_123') + allow(StripeV2Client).to receive(:request).and_return(subscription_response) allow(credit_service).to receive(:sync_monthly_credits) - grant = OpenStruct.new( - amount: { 'custom_pricing_unit' => { 'value' => '2000' } }, - expires_at: Time.current - ) - event = build_event(type: 'billing.credit_grant.created', object: grant) - - result = service.process(event) - - expect(result[:success]).to be(true) - expect(credit_service).to have_received(:sync_monthly_credits).with(2000) end - end - - context 'when handling topup credit grant' do - it 'adds topup credits' do - allow(credit_service).to receive(:add_topup_credits) - grant = OpenStruct.new( - amount: { 'custom_pricing_unit' => { 'value' => '500' } }, - expires_at: nil - ) - event = build_event(type: 'billing.credit_grant.created', object: grant) + it 'returns success with subscription details' do result = service.process(event) - expect(result[:success]).to be(true) - expect(credit_service).to have_received(:add_topup_credits).with(500) + 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 - end - context 'when handling credit grant update with expiration' do - it 'expires monthly credits when grant is expired' do - allow(credit_service).to receive(:expire_monthly_credits).and_return(100) - grant = OpenStruct.new(expired_at: Time.current) - event = build_event(type: 'billing.credit_grant.updated', object: grant) + it 'updates account custom attributes' do + service.process(event) - result = 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 - expect(result[:success]).to be(true) - expect(credit_service).to have_received(:expire_monthly_credits) + 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) end end context 'when handling unknown event' do it 'returns success' do - event = build_event(type: 'unknown.event', object: {}) + related_object = OpenStruct.new(id: 'unknown_id') + event = double('Stripe::Event', type: 'unknown.event', related_object: related_object) result = service.process(event)