diff --git a/.gitignore b/.gitignore index dde03c2e8..8f8e6d1a0 100644 --- a/.gitignore +++ b/.gitignore @@ -104,3 +104,7 @@ CLAUDE.local.md # External documentation \[External\]* + + +pricing-plans/ +scripts/ \ No newline at end of file diff --git a/Gemfile b/Gemfile index abbd3332f..666ffea54 100644 --- a/Gemfile +++ b/Gemfile @@ -159,7 +159,7 @@ gem 'working_hours' gem 'pg_search' # Subscriptions, Billing -gem 'stripe' +gem 'stripe', '17.2.0.pre.beta.1' ## - helper gems --## ## to populate db with sample data diff --git a/Gemfile.lock b/Gemfile.lock index cf26b94a9..4a62aeab7 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -902,7 +902,7 @@ GEM squasher (0.7.2) stackprof (0.2.25) statsd-ruby (1.5.0) - stripe (16.0.0) + stripe (17.2.0.pre.beta.1) telephone_number (1.4.20) test-prof (1.2.1) thor (1.4.0) @@ -1110,7 +1110,7 @@ DEPENDENCIES spring-watcher-listen squasher stackprof - stripe + stripe (= 17.2.0.pre.beta.1) telephone_number test-prof tidewave diff --git a/app/controllers/dashboard_controller.rb b/app/controllers/dashboard_controller.rb index f154cc315..d81b4c9da 100644 --- a/app/controllers/dashboard_controller.rb +++ b/app/controllers/dashboard_controller.rb @@ -71,7 +71,6 @@ class DashboardController < ActionController::Base WHATSAPP_CONFIGURATION_ID: GlobalConfigService.load('WHATSAPP_CONFIGURATION_ID', ''), IS_ENTERPRISE: ChatwootApp.enterprise?, AZURE_APP_ID: GlobalConfigService.load('AZURE_APP_ID', ''), - STRIPE_BILLING_V2_ENABLED: GlobalConfigService.load('STRIPE_BILLING_V2_ENABLED', 'false'), GIT_SHA: GIT_HASH } end diff --git a/app/views/api/v1/models/_account.json.jbuilder b/app/views/api/v1/models/_account.json.jbuilder index ba74e5c94..0cd9bf038 100644 --- a/app/views/api/v1/models/_account.json.jbuilder +++ b/app/views/api/v1/models/_account.json.jbuilder @@ -9,6 +9,7 @@ if resource.custom_attributes.present? json.stripe_subscription_id resource.custom_attributes['stripe_subscription_id'] if resource.custom_attributes['stripe_subscription_id'].present? json.pending_stripe_pricing_plan_id resource.custom_attributes['pending_stripe_pricing_plan_id'] if resource.custom_attributes['pending_stripe_pricing_plan_id'].present? json.pending_subscription_quantity resource.custom_attributes['pending_subscription_quantity'] if resource.custom_attributes['pending_subscription_quantity'].present? + json.stripe_pricing_plan_id resource.custom_attributes['stripe_pricing_plan_id'] if resource.custom_attributes['stripe_pricing_plan_id'].present? json.next_billing_date resource.custom_attributes['next_billing_date'] if resource.custom_attributes['next_billing_date'].present? json.industry resource.custom_attributes['industry'] if resource.custom_attributes['industry'].present? json.company_size resource.custom_attributes['company_size'] if resource.custom_attributes['company_size'].present? @@ -19,10 +20,6 @@ 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_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/schedule_v2_billing.yml b/config/schedule_v2_billing.yml index 6c0ca2062..0a7ae44dd 100644 --- a/config/schedule_v2_billing.yml +++ b/config/schedule_v2_billing.yml @@ -2,7 +2,7 @@ # Add these to your config/sidekiq_cron.yml or config/schedule.yml v2_credit_sync: - cron: "0 */6 * * *" # Every 6 hours + cron: "0 * * * *" # Every hour class: "Enterprise::Billing::CreditSyncJob" queue: low description: "Sync V2 billing credits with Stripe" diff --git a/create_plans.rb b/create_plans.rb deleted file mode 100644 index 3e3a85d71..000000000 --- a/create_plans.rb +++ /dev/null @@ -1,142 +0,0 @@ -# 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_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/concerns/billing_v2.rb b/enterprise/app/controllers/enterprise/api/v1/accounts/concerns/billing_v2.rb index af1ab46aa..045699b62 100644 --- a/enterprise/app/controllers/enterprise/api/v1/accounts/concerns/billing_v2.rb +++ b/enterprise/app/controllers/enterprise/api/v1/accounts/concerns/billing_v2.rb @@ -41,7 +41,7 @@ module Enterprise::Api::V1::Accounts::Concerns::BillingV2 ) if result[:success] - render json: { success: true, redirect_url: result[:redirect_url], checkout_session_id: result[:checkout_session_id] } + render json: { success: true, redirect_url: result[:redirect_url], session_id: result[:session_id] } else render json: { error: result[:message] }, status: :unprocessable_entity end diff --git a/enterprise/app/jobs/enterprise/billing/credit_sync_job.rb b/enterprise/app/jobs/enterprise/billing/credit_sync_job.rb new file mode 100644 index 000000000..bfcb7d4a5 --- /dev/null +++ b/enterprise/app/jobs/enterprise/billing/credit_sync_job.rb @@ -0,0 +1,96 @@ +class Enterprise::Billing::CreditSyncJob < ApplicationJob + queue_as :low + + def perform(account = nil) + if account + sync_single_account(account) + else + sync_all_accounts + end + end + + private + + def sync_all_accounts + Rails.logger.info '[CreditSyncJob] Starting credit sync for all accounts' + + accounts_with_stripe = Account.where( + "custom_attributes->>'stripe_customer_id' IS NOT NULL AND custom_attributes->>'stripe_billing_version' = '2'" + ) + synced_count = 0 + failed_count = 0 + + accounts_with_stripe.find_each do |account| + result = sync_account_credits(account) + if result[:success] + synced_count += 1 if result[:credits_reported].to_i.positive? + else + failed_count += 1 + Rails.logger.error "[CreditSyncJob] Failed to sync account #{account.id}: #{result[:message]}" + end + end + + Rails.logger.info "[CreditSyncJob] Completed. Synced: #{synced_count}, Failed: #{failed_count}" + { synced: synced_count, failed: failed_count } + end + + def sync_single_account(account) + Rails.logger.info "[CreditSyncJob] Syncing credits for account #{account.id}" + result = sync_account_credits(account) + + if result[:success] + Rails.logger.info "[CreditSyncJob] Successfully synced account #{account.id}" + else + Rails.logger.error "[CreditSyncJob] Failed to sync account #{account.id}: #{result[:message]}" + end + + result + end + + def sync_account_credits(account) + consumed_credits = account.custom_attributes&.[]('captain_responses_usage').to_i + last_synced_credits = account.custom_attributes&.[]('stripe_last_synced_credits').to_i + credits_to_report = consumed_credits - last_synced_credits + + if credits_to_report.positive? + handle_positive_credits(account, credits_to_report, consumed_credits) + elsif credits_to_report.negative? + handle_negative_credits(account, credits_to_report, consumed_credits) + else + { success: true, message: 'Already in sync', credits_reported: 0 } + end + rescue StandardError => e + handle_sync_error(account, e) + end + + def handle_positive_credits(account, credits_to_report, consumed_credits) + reporter = Enterprise::Billing::V2::UsageReporterService.new(account: account) + result = reporter.report(credits_to_report) + + return result unless result[:success] + + update_last_synced_credits(account, consumed_credits) + Rails.logger.info "[CreditSyncJob] Account #{account.id}: reported #{credits_to_report} credits (total: #{consumed_credits})" + result.merge(credits_reported: credits_to_report) + end + + def handle_negative_credits(account, credits_to_report, consumed_credits) + Rails.logger.warn "[CreditSyncJob] Account #{account.id} has negative difference: #{credits_to_report}" + update_last_synced_credits(account, consumed_credits) + { success: true, message: 'Reset sync point due to negative difference', credits_reported: 0 } + end + + def handle_sync_error(account, error) + Rails.logger.error "[CreditSyncJob] Error syncing account #{account.id}: #{error.message}" + Rails.logger.error error.backtrace.join("\n") + { success: false, message: error.message } + end + + def update_last_synced_credits(account, credits) + account.with_lock do + current_attributes = account.custom_attributes.present? ? account.custom_attributes.deep_dup : {} + current_attributes['stripe_last_synced_credits'] = credits + account.update!(custom_attributes: current_attributes) + end + end +end diff --git a/enterprise/app/services/enterprise/billing/concerns/billing_intent_workflow.rb b/enterprise/app/services/enterprise/billing/concerns/billing_intent_workflow.rb new file mode 100644 index 000000000..0237e7bc6 --- /dev/null +++ b/enterprise/app/services/enterprise/billing/concerns/billing_intent_workflow.rb @@ -0,0 +1,14 @@ +module Enterprise::Billing::Concerns::BillingIntentWorkflow + extend ActiveSupport::Concern + + private + + # Execute a billing intent with automatic reserve and commit + def execute_billing_intent(intent_params) + intent = create_billing_intent(intent_params) + reserve_billing_intent(intent) + yield(intent) if block_given? + commit_billing_intent(intent) + intent + end +end diff --git a/enterprise/app/services/enterprise/billing/concerns/plan_data_helper.rb b/enterprise/app/services/enterprise/billing/concerns/plan_data_helper.rb new file mode 100644 index 000000000..cce80756c --- /dev/null +++ b/enterprise/app/services/enterprise/billing/concerns/plan_data_helper.rb @@ -0,0 +1,24 @@ +module Enterprise::Billing::Concerns::PlanDataHelper + extend ActiveSupport::Concern + + private + + def fetch_plan_version(plan_id) + plan = retrieve_pricing_plan(plan_id) + version = extract_attribute(plan, :latest_version) + raise StandardError, "No version found for pricing plan #{plan_id}" if version.blank? + + version + end + + def fetch_plan_lookup_key(plan_id) + lookup_key = Enterprise::Billing::V2::PlanCatalog.lookup_key_for_plan(plan_id) + raise StandardError, "Lookup key not found for pricing plan #{plan_id}" unless lookup_key + + lookup_key + end + + def plan_display_name(plan_id) + Enterprise::Billing::V2::PlanCatalog.definition_for(plan_id)&.dig(:display_name) || 'Unknown Plan' + end +end diff --git a/enterprise/app/services/enterprise/billing/concerns/proration_line_item_builder.rb b/enterprise/app/services/enterprise/billing/concerns/proration_line_item_builder.rb index fa04dfeb8..b6b81fb29 100644 --- a/enterprise/app/services/enterprise/billing/concerns/proration_line_item_builder.rb +++ b/enterprise/app/services/enterprise/billing/concerns/proration_line_item_builder.rb @@ -1,5 +1,6 @@ module Enterprise::Billing::Concerns::ProrationLineItemBuilder extend ActiveSupport::Concern + include Enterprise::Billing::Concerns::PlanDataHelper private @@ -32,10 +33,6 @@ module Enterprise::Billing::Concerns::ProrationLineItemBuilder line_items end - def plan_display_name(plan_id) - Enterprise::Billing::V2::PlanCatalog.definition_for(plan_id)&.dig(:display_name) || 'Unknown Plan' - end - def build_credit_line_item(context, proration_data, old_plan_name) { amount: -(proration_data[:credit_amount] * 100).to_i, @@ -60,26 +57,6 @@ module Enterprise::Billing::Concerns::ProrationLineItemBuilder "Prorated charge for #{plan_name} (#{quantity} seat#{quantity > 1 ? 's' : ''})" end - def credit_metadata(plan_name, quantity, days_remaining) - { - type: 'proration_credit', - old_plan: plan_name, - old_quantity: quantity, - days_remaining: days_remaining, - billing_version: 'v2' - } - end - - def charge_metadata(plan_name, quantity, days_remaining) - { - type: 'proration_charge', - new_plan: plan_name, - new_quantity: quantity, - days_remaining: days_remaining, - billing_version: 'v2' - } - end - def build_seat_change_description(context) plan_name = plan_display_name(context[:target_plan_id]) change_type = context[:target_quantity] > context[:old_quantity] ? 'increase' : 'decrease' @@ -89,15 +66,27 @@ module Enterprise::Billing::Concerns::ProrationLineItemBuilder "seats (#{quantity_diff} seat#{quantity_diff > 1 ? 's' : ''})" end - def build_seat_change_metadata(context, proration_data) + def base_metadata(type, days_remaining, **additional_fields) { - type: 'seat_change', - plan_name: plan_display_name(context[:target_plan_id]), - old_quantity: context[:old_quantity], - new_quantity: context[:target_quantity], - quantity_change: context[:target_quantity] - context[:old_quantity], - days_remaining: proration_data[:days_remaining], + type: type, + days_remaining: days_remaining, billing_version: 'v2' - } + }.merge(additional_fields) + end + + def credit_metadata(plan_name, quantity, days_remaining) + base_metadata('proration_credit', days_remaining, old_plan: plan_name, old_quantity: quantity) + end + + def charge_metadata(plan_name, quantity, days_remaining) + base_metadata('proration_charge', days_remaining, new_plan: plan_name, new_quantity: quantity) + end + + def build_seat_change_metadata(context, proration_data) + base_metadata('seat_change', proration_data[:days_remaining], + plan_name: plan_display_name(context[:target_plan_id]), + old_quantity: context[:old_quantity], + new_quantity: context[:target_quantity], + quantity_change: context[:target_quantity] - context[:old_quantity]) end end diff --git a/enterprise/app/services/enterprise/billing/concerns/stripe_v2_client_helper.rb b/enterprise/app/services/enterprise/billing/concerns/stripe_v2_client_helper.rb index bd04b0312..fe27cf2d3 100644 --- a/enterprise/app/services/enterprise/billing/concerns/stripe_v2_client_helper.rb +++ b/enterprise/app/services/enterprise/billing/concerns/stripe_v2_client_helper.rb @@ -3,99 +3,57 @@ module Enterprise::Billing::Concerns::StripeV2ClientHelper private - # Generic Stripe V2 API request wrapper + # Stripe client instance with API key + def stripe_client + @stripe_client ||= Stripe::StripeClient.new(ENV.fetch('STRIPE_SECRET_KEY', nil)) + end + + # Generic Stripe V2 API request wrapper (for methods not available in gem) def stripe_v2_request(method, path, params = {}, api_version: nil) StripeV2Client.request(method, path, params, stripe_api_options(api_version)) end # Pricing Plan Subscriptions def retrieve_pricing_plan_subscription(subscription_id) - stripe_v2_request(:get, "/v2/billing/pricing_plan_subscriptions/#{subscription_id}") + stripe_client.v2.billing.pricing_plan_subscriptions.retrieve(subscription_id) end # Pricing Plans def retrieve_pricing_plan(pricing_plan_id) - stripe_v2_request(:get, "/v2/billing/pricing_plans/#{pricing_plan_id}") - end - - def create_pricing_plan(params) - stripe_v2_request(:post, '/v2/billing/pricing_plans', params) + stripe_client.v2.billing.pricing_plans.retrieve(pricing_plan_id) end + # gem not available - using custom HTTP client def update_pricing_plan(plan_id, params) stripe_v2_request(:post, "/v2/billing/pricing_plans/#{plan_id}", params) end # Billing Cadences def retrieve_billing_cadence(cadence_id) - stripe_v2_request(:get, "/v2/billing/cadences/#{cadence_id}") + stripe_client.v2.billing.cadences.retrieve(cadence_id) end - # Billing Intents + # gem not available - using custom HTTP client def create_billing_intent(params) stripe_v2_request(:post, '/v2/billing/intents', params) end def reserve_billing_intent(billing_intent) - stripe_v2_request(:post, "/v2/billing/intents/#{billing_intent.id}/reserve") + stripe_client.v2.billing.intents.reserve(billing_intent.id) end def commit_billing_intent(billing_intent) - stripe_v2_request(:post, "/v2/billing/intents/#{billing_intent.id}/commit") - end - - # Custom Pricing Units - def create_custom_pricing_unit(params) - stripe_v2_request(:post, '/v2/billing/custom_pricing_units', params) - end - - # Licensed Items - def create_licensed_item(params) - stripe_v2_request(:post, '/v2/billing/licensed_items', params) - end - - # License Fees - def create_license_fee(params) - stripe_v2_request(:post, '/v2/billing/license_fees', params) - end - - # Service Actions - def create_service_action(params) - stripe_v2_request(:post, '/v2/billing/service_actions', params) - end - - # Rate Cards - def create_rate_card(params) - stripe_v2_request(:post, '/v2/billing/rate_cards', params) - end - - def add_rate_to_card(card_id, params) - stripe_v2_request(:post, "/v2/billing/rate_cards/#{card_id}/rates", params) - end - - # Metered Items - def create_metered_item(params) - stripe_v2_request(:post, '/v2/billing/metered_items', params) - end - - # Meters (V1 API but used with V2) - def create_meter(params) - stripe_v2_request(:post, '/v1/billing/meters', params) - end - - # Pricing Plan Components - def add_pricing_plan_component(plan_id, params) - stripe_v2_request(:post, "/v2/billing/pricing_plans/#{plan_id}/components", params) + stripe_client.v2.billing.intents.commit(billing_intent.id) end # Checkout Sessions (V1 API but used with V2 plans) - def create_checkout_session(params, api_version: nil) - stripe_v2_request(:post, '/v1/checkout/sessions', params, api_version: api_version) + def create_checkout_session(params) + Stripe::Checkout::Session.create(params, { stripe_version: checkout_stripe_version }) end # Credit Grants (V1 API but used with V2) def retrieve_credit_grant(grant_id) - stripe_v2_request(:get, "/v1/billing/credit_grants/#{grant_id}") + Stripe::Billing::CreditGrant.retrieve(grant_id) end # API Options with support for custom versions diff --git a/enterprise/app/services/enterprise/billing/concerns/subscription_data_manager.rb b/enterprise/app/services/enterprise/billing/concerns/subscription_data_manager.rb new file mode 100644 index 000000000..9c517a285 --- /dev/null +++ b/enterprise/app/services/enterprise/billing/concerns/subscription_data_manager.rb @@ -0,0 +1,41 @@ +module Enterprise::Billing::Concerns::SubscriptionDataManager + extend ActiveSupport::Concern + + private + + def fetch_subscription_metadata + subscription_id = fetch_subscription_id + subscription = retrieve_pricing_plan_subscription(subscription_id) + cadence_id = extract_cadence_id(subscription) + cadence = retrieve_billing_cadence(cadence_id) + next_billing_date = extract_attribute(cadence, :next_billing_date) + + store_next_billing_date(next_billing_date) + + { + subscription_id: subscription_id, + subscription: subscription, + cadence_id: cadence_id, + cadence: cadence, + next_billing_date: next_billing_date + } + end + + def fetch_subscription_id + subscription_id = custom_attribute('stripe_subscription_id') + raise StandardError, 'No pricing plan subscription ID found' if subscription_id.blank? + + subscription_id + end + + def extract_cadence_id(subscription) + cadence_id = extract_attribute(subscription, :billing_cadence) + raise StandardError, 'No billing cadence found in subscription' if cadence_id.blank? + + cadence_id + end + + def store_next_billing_date(next_billing_date) + update_custom_attributes({ 'next_billing_date' => next_billing_date }) + 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 337fde1ce..15be677d2 100644 --- a/enterprise/app/services/enterprise/billing/create_stripe_customer_service.rb +++ b/enterprise/app/services/enterprise/billing/create_stripe_customer_service.rb @@ -8,26 +8,23 @@ class Enterprise::Billing::CreateStripeCustomerService def perform return if existing_subscription? - customer_id = prepare_customer_id + raise_config_error unless v2_configs_present? - if billing_v2_enabled? && v2_configs_present? - update_account_for_v2_billing(customer_id) - enable_plan_specific_features(default_plan['name']) - elsif default_plan.present? - subscription = create_subscription(customer_id) - update_account_with_subscription(customer_id, subscription) - else - # Minimal setup when configs are missing - account.update!(custom_attributes: { stripe_customer_id: customer_id }) - end + customer_id = prepare_customer_id + update_account_for_v2_billing(customer_id) + enable_plan_specific_features(default_plan['name']) end private def prepare_customer_id customer_id = account.custom_attributes['stripe_customer_id'] + test_clock = Stripe::TestHelpers::TestClock.create({ + frozen_time: Time.now.to_i, + name: 'Test Clock for Account' + }) if customer_id.blank? - customer = Stripe::Customer.create({ name: account.name, email: billing_email }) + customer = Stripe::Customer.create({ name: account.name, email: billing_email, test_clock: test_clock.id }) customer_id = customer.id end customer_id @@ -46,20 +43,15 @@ class Enterprise::Billing::CreateStripeCustomerService @default_plan ||= installation_config&.value&.first end - def price_id - price_ids = default_plan['price_ids'] - price_ids.first - end - - def billing_v2_enabled? - ENV.fetch('STRIPE_BILLING_V2_ENABLED', 'false') == 'true' - end - def v2_configs_present? InstallationConfig.find_by(name: 'STRIPE_HACKER_PLAN_ID').present? && default_plan.present? end + def raise_config_error + raise StandardError, 'V2 billing configuration is required. Please configure STRIPE_HACKER_PLAN_ID and CHATWOOT_CLOUD_PLANS.' + end + def existing_subscription? stripe_customer_id = account.custom_attributes['stripe_customer_id'] return false if stripe_customer_id.blank? @@ -74,17 +66,13 @@ class Enterprise::Billing::CreateStripeCustomerService subscriptions.data.present? end - def create_subscription(customer_id) - Stripe::Subscription.create( - customer: customer_id, - items: [{ price: price_id, quantity: default_quantity }] - ) - end - def update_account_for_v2_billing(customer_id) hacker_plan_config = InstallationConfig.find_by(name: 'STRIPE_HACKER_PLAN_ID') - attributes = { stripe_customer_id: customer_id } + attributes = { + stripe_customer_id: customer_id, + stripe_billing_version: 'v2' + } if hacker_plan_config&.value.present? attributes.merge!( @@ -96,16 +84,4 @@ class Enterprise::Billing::CreateStripeCustomerService account.update!(custom_attributes: attributes) end - - def update_account_with_subscription(customer_id, subscription) - account.update!( - custom_attributes: { - stripe_customer_id: customer_id, - stripe_price_id: subscription['plan']['id'], - stripe_product_id: subscription['plan']['product'], - plan_name: default_plan['name'], - subscribed_quantity: subscription['quantity'] - } - ) - end end diff --git a/enterprise/app/services/enterprise/billing/v2/base_service.rb b/enterprise/app/services/enterprise/billing/v2/base_service.rb index 500101b28..7ab1ccd9c 100644 --- a/enterprise/app/services/enterprise/billing/v2/base_service.rb +++ b/enterprise/app/services/enterprise/billing/v2/base_service.rb @@ -13,10 +13,6 @@ class Enterprise::Billing::V2::BaseService ) end - def v2_enabled? - ENV.fetch('STRIPE_BILLING_V2_ENABLED', 'false') == 'true' - end - def response_monthly_credits account.limits&.[]('captain_responses_monthly').to_i end @@ -75,9 +71,20 @@ class Enterprise::Billing::V2::BaseService account.with_lock(&) end - def with_stripe_error_handling - yield - rescue Stripe::StripeError => e - { success: false, message: e.message } + # Convenient accessors for common attributes + def stripe_customer_id + custom_attribute('stripe_customer_id') + end + + def stripe_subscription_id + custom_attribute('stripe_subscription_id') + end + + def pricing_plan_id + custom_attribute('stripe_pricing_plan_id') + end + + def subscribed_quantity + custom_attribute('subscribed_quantity').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 index 9c4e163ae..82f5d1b0b 100644 --- a/enterprise/app/services/enterprise/billing/v2/cancel_subscription_service.rb +++ b/enterprise/app/services/enterprise/billing/v2/cancel_subscription_service.rb @@ -1,6 +1,8 @@ class Enterprise::Billing::V2::CancelSubscriptionService < Enterprise::Billing::V2::BaseService include Enterprise::Billing::Concerns::PlanFeatureManager include Enterprise::Billing::Concerns::StripeV2ClientHelper + include Enterprise::Billing::Concerns::BillingIntentWorkflow + include Enterprise::Billing::Concerns::SubscriptionDataManager # Cancel subscription using Stripe's V2 Billing Intent API # Creates a deactivate billing intent for the pricing plan subscription @@ -10,12 +12,11 @@ class Enterprise::Billing::V2::CancelSubscriptionService < Enterprise::Billing:: # def cancel_subscription with_locked_account do - billing_intent = create_deactivate_intent - reserve_billing_intent(billing_intent) - commit_billing_intent(billing_intent) - update_account_status(billing_intent) - success_response = build_success_response(billing_intent) - success_response + metadata = fetch_subscription_metadata + intent_params = build_deactivate_params(metadata[:subscription_id], metadata[:cadence_id]) + execute_billing_intent(intent_params) + update_account_status(metadata[:next_billing_date]) + build_success_response end rescue Stripe::StripeError => e { success: false, message: "Stripe error: #{e.message}" } @@ -25,35 +26,6 @@ class Enterprise::Billing::V2::CancelSubscriptionService < Enterprise::Billing:: private - def create_deactivate_intent - pricing_plan_subscription_id = fetch_subscription_id - billing_cadence_id = fetch_billing_cadence_id(pricing_plan_subscription_id) - store_next_billing_date(billing_cadence_id) - - create_billing_intent( - build_deactivate_params(pricing_plan_subscription_id, billing_cadence_id) - ) - end - - def fetch_subscription_id - custom_attribute('stripe_subscription_id').tap do |id| - raise StandardError, 'No pricing plan subscription ID found' if id.blank? - end - end - - def fetch_billing_cadence_id(subscription_id) - subscription = retrieve_pricing_plan_subscription(subscription_id) - extract_attribute(subscription, :billing_cadence).tap do |cadence_id| - raise StandardError, 'No billing cadence found in subscription' if cadence_id.blank? - end - end - - def store_next_billing_date(cadence_id) - cadence = retrieve_billing_cadence(cadence_id) - Rails.logger.info("Cadence: #{cadence}") - @next_billing_date = extract_attribute(cadence, :next_billing_date) - end - def build_deactivate_params(subscription_id, cadence_id) { cadence: cadence_id, @@ -68,17 +40,17 @@ class Enterprise::Billing::V2::CancelSubscriptionService < Enterprise::Billing:: } end - def update_account_status(_billing_intent) + def update_account_status(next_billing_date) # Mark subscription as cancelling (will be cancelled at period end) # Store next_billing_date so the UI can show when the subscription ends update_custom_attributes({ 'subscription_status' => 'cancel_at_period_end', 'subscription_cancelled_at' => Time.current.iso8601, - 'subscription_ends_at' => @next_billing_date + 'subscription_ends_at' => next_billing_date }) end - def build_success_response(_billing_intent) + def build_success_response { success: true, cancel_at_period_end: true, diff --git a/enterprise/app/services/enterprise/billing/v2/change_plan_service.rb b/enterprise/app/services/enterprise/billing/v2/change_plan_service.rb index 9d97771a7..44fcc0b45 100644 --- a/enterprise/app/services/enterprise/billing/v2/change_plan_service.rb +++ b/enterprise/app/services/enterprise/billing/v2/change_plan_service.rb @@ -3,15 +3,10 @@ class Enterprise::Billing::V2::ChangePlanService < Enterprise::Billing::V2::Base include Enterprise::Billing::Concerns::ProrationLineItemBuilder include Enterprise::Billing::Concerns::StripeV2ClientHelper include Enterprise::Billing::Concerns::PlanProvisioningHelper + include Enterprise::Billing::Concerns::BillingIntentWorkflow + include Enterprise::Billing::Concerns::SubscriptionDataManager + include Enterprise::Billing::Concerns::PlanDataHelper - # Change customer's pricing plan and/or seat quantity using invoice line items for proration - # Instantly applies the change and creates pending invoice line items - # for prorated charges that will be added to the next invoice - # - # @param new_pricing_plan_id [String, nil] The new Stripe pricing plan ID (nil to keep current plan) - # @param quantity [Integer] The seat quantity for the plan - # @return [Hash] { success:, message:, proration:, line_items: } - # def change_plan(new_pricing_plan_id: nil, quantity: nil) validation_error = validate_parameters(new_pricing_plan_id, quantity) return validation_error if validation_error @@ -19,8 +14,6 @@ class Enterprise::Billing::V2::ChangePlanService < Enterprise::Billing::V2::Base with_locked_account do perform_subscription_change(new_pricing_plan_id, quantity) end - rescue Stripe::StripeError => e - { success: false, message: "Stripe error: #{e.message}" } end private @@ -45,8 +38,8 @@ class Enterprise::Billing::V2::ChangePlanService < Enterprise::Billing::V2::Base end def build_change_context(new_pricing_plan_id, quantity) - old_plan_id = custom_attribute('stripe_pricing_plan_id') - old_quantity = custom_attribute('subscribed_quantity').to_i + old_plan_id = pricing_plan_id + old_quantity = subscribed_quantity target_plan_id = new_pricing_plan_id || old_plan_id target_quantity = quantity || old_quantity @@ -65,15 +58,14 @@ class Enterprise::Billing::V2::ChangePlanService < Enterprise::Billing::V2::Base end def no_change_response(context) - plan_name = Enterprise::Billing::V2::PlanCatalog.definition_for(context[:target_plan_id])&.dig(:display_name) || 'Unknown Plan' + plan_name = plan_display_name(context[:target_plan_id]) { success: false, message: "Subscription already has plan #{plan_name} with #{context[:target_quantity]} seat(s)" } end def execute_change(context) - # Create billing intent to update the Stripe subscription - billing_intent = create_change_plan_intent(context[:target_plan_id], context[:target_quantity]) - reserve_billing_intent(billing_intent) - commit_billing_intent(billing_intent) + # Create and execute billing intent to update the Stripe subscription + intent_params = build_change_plan_params(context[:target_plan_id], context[:target_quantity]) + execute_billing_intent(intent_params) next_billing_date = custom_attribute('next_billing_date') proration_data = calculate_proration( @@ -84,76 +76,39 @@ class Enterprise::Billing::V2::ChangePlanService < Enterprise::Billing::V2::Base next_billing_date: next_billing_date ) line_items = build_proration_line_items(context, proration_data) - invoice_result = create_and_charge_invoice(line_items) + create_and_charge_invoice(line_items) update_account_plan(context[:target_plan_id], context[:target_quantity], next_billing_date) provision_new_plan(context[:target_plan_id]) if context[:plan_changed] - success_response(context, proration_data, line_items, invoice_result) + { success: true, message: 'Plan change successful' } end - def fetch_subscription_id - custom_attribute('stripe_subscription_id').tap do |id| - raise StandardError, 'No pricing plan subscription ID found' if id.blank? - end - end - - def fetch_cadence_from_subscription(subscription_id) - subscription = retrieve_pricing_plan_subscription(subscription_id) - extract_attribute(subscription, :billing_cadence).tap do |cadence_id| - raise StandardError, 'No billing cadence found in subscription' if cadence_id.blank? - end - end - - def create_change_plan_intent(new_pricing_plan_id, quantity) - subscription_id = fetch_subscription_id - cadence_id = fetch_cadence_from_subscription(subscription_id) - store_next_billing_date(cadence_id) - plan_version = fetch_new_plan_version(new_pricing_plan_id) + def build_change_plan_params(new_pricing_plan_id, quantity) + metadata = fetch_subscription_metadata + plan_version = fetch_plan_version(new_pricing_plan_id) lookup_key = fetch_plan_lookup_key(new_pricing_plan_id) - component_config = { lookup_key: lookup_key, quantity: quantity } - create_billing_intent( - build_change_plan_params(subscription_id, cadence_id, new_pricing_plan_id, plan_version, component_config) - ) - end - - def build_change_plan_params(subscription_id, cadence_id, plan_id, plan_version, component_config) { - cadence: cadence_id, + cadence: metadata[:cadence_id], currency: 'usd', - actions: [{ - type: 'modify', - modify: { - type: 'pricing_plan_subscription_details', - pricing_plan_subscription_details: { - pricing_plan_subscription: subscription_id, - new_pricing_plan: plan_id, - new_pricing_plan_version: plan_version, - component_configurations: [component_config] - } - } - }] + actions: [build_modify_action(metadata[:subscription_id], new_pricing_plan_id, plan_version, lookup_key, quantity)] } end - def store_next_billing_date(cadence_id) - cadence = retrieve_billing_cadence(cadence_id) - @next_billing_date = extract_attribute(cadence, :next_billing_date) - update_custom_attributes({ 'next_billing_date' => @next_billing_date }) - end - - def fetch_new_plan_version(plan_id) - plan = retrieve_pricing_plan(plan_id) - extract_attribute(plan, :latest_version).tap do |version| - raise StandardError, "No version found for pricing plan #{plan_id}" if version.blank? - end - end - - def fetch_plan_lookup_key(plan_id) - Enterprise::Billing::V2::PlanCatalog.lookup_key_for_plan(plan_id).tap do |key| - raise StandardError, "Lookup key not found for pricing plan #{plan_id}" unless key - end + def build_modify_action(subscription_id, plan_id, plan_version, lookup_key, quantity) + { + type: 'modify', + modify: { + type: 'pricing_plan_subscription_details', + pricing_plan_subscription_details: { + pricing_plan_subscription: subscription_id, + new_pricing_plan: plan_id, + new_pricing_plan_version: plan_version, + component_configurations: [{ lookup_key: lookup_key, quantity: quantity }] + } + } + } end def calculate_proration(old_plan_id:, new_plan_id:, old_quantity:, new_quantity:, next_billing_date:) @@ -184,34 +139,4 @@ class Enterprise::Billing::V2::ChangePlanService < Enterprise::Billing::V2::Base } ) end - - def success_response(context, proration_data, line_items, invoice_result) - { - success: true, - pricing_plan_id: context[:target_plan_id], - quantity: context[:target_quantity], - old_pricing_plan_id: context[:old_plan_id], - old_quantity: context[:old_quantity], - plan_changed: context[:plan_changed], - seats_changed: context[:seats_changed], - proration: proration_data, - line_items: line_items, - invoice: invoice_result, - total_proration_amount: proration_data[:net_amount], - message: build_change_message(context) - } - end - - def build_change_message(context) - plan_name = Enterprise::Billing::V2::PlanCatalog.definition_for(context[:target_plan_id])&.dig(:display_name) || 'Unknown Plan' - base_message = if context[:plan_changed] && context[:seats_changed] - "Plan changed to #{plan_name} and seats updated to #{context[:target_quantity]}" - elsif context[:plan_changed] - "Plan changed to #{plan_name}" - else - "Seats updated from #{context[:old_quantity]} to #{context[:target_quantity]}" - end - - "#{base_message} - billing intent committed and invoice line items created" - end end diff --git a/enterprise/app/services/enterprise/billing/v2/checkout_session_service.rb b/enterprise/app/services/enterprise/billing/v2/checkout_session_service.rb index 45708616c..502545f92 100644 --- a/enterprise/app/services/enterprise/billing/v2/checkout_session_service.rb +++ b/enterprise/app/services/enterprise/billing/v2/checkout_session_service.rb @@ -1,21 +1,7 @@ -# 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 include Enterprise::Billing::Concerns::StripeV2ClientHelper - # 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 @@ -26,16 +12,14 @@ class Enterprise::Billing::V2::CheckoutSessionService < Enterprise::Billing::V2: validate_params store_pending_subscription_quantity - create_checkout_session - rescue Stripe::StripeError => e - { success: false, message: "Stripe API error: #{e.message}", error: e } + session = create_checkout_session + { success: true, redirect_url: session.url } end private def validate_params - customer_id = custom_attribute('stripe_customer_id') - raise StandardError, 'Customer ID required. Please create a Stripe customer first.' if customer_id.blank? + raise StandardError, 'Customer ID required. Please create a Stripe customer first.' if stripe_customer_id.blank? end def store_pending_subscription_quantity @@ -47,20 +31,9 @@ class Enterprise::Billing::V2::CheckoutSessionService < Enterprise::Billing::V2: }) 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 = super(checkout_session_params(customer_id), api_version: checkout_stripe_version) - build_success_response(session) - end - - def checkout_session_params(customer_id) + def checkout_session_params { - customer: customer_id, + customer: stripe_customer_id, checkout_items: build_checkout_items, automatic_tax: { enabled: true @@ -109,15 +82,4 @@ class Enterprise::Billing::V2::CheckoutSessionService < Enterprise::Billing::V2: 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 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 adcc84589..e4d9a8b39 100644 --- a/enterprise/app/services/enterprise/billing/v2/credit_management_service.rb +++ b/enterprise/app/services/enterprise/billing/v2/credit_management_service.rb @@ -21,12 +21,10 @@ class Enterprise::Billing::V2::CreditManagementService < Enterprise::Billing::V2 end def fetch_credit_grants - customer_id = stripe_customer_id - return [] if customer_id.blank? + return [] if stripe_customer_id.blank? response = Stripe::Billing::CreditGrant.list( - { customer: customer_id, limit: 100 }, - stripe_api_options + { customer: stripe_customer_id, limit: 100 } ) grants = response.data.map do |grant| @@ -83,12 +81,4 @@ class Enterprise::Billing::V2::CreditManagementService < Enterprise::Billing::V2 Time.zone.at(timestamp) 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/invoice_payment_service.rb b/enterprise/app/services/enterprise/billing/v2/invoice_payment_service.rb index 0c380904c..8b3ceaa8c 100644 --- a/enterprise/app/services/enterprise/billing/v2/invoice_payment_service.rb +++ b/enterprise/app/services/enterprise/billing/v2/invoice_payment_service.rb @@ -11,10 +11,9 @@ class Enterprise::Billing::V2::InvoicePaymentService < Enterprise::Billing::V2:: # Validate that customer has a default payment method # @return [Hash, nil] Returns error hash if validation fails, nil if success def validate_payment_method - customer_id = custom_attribute('stripe_customer_id') - return { success: false, message: 'No Stripe customer ID found' } if customer_id.blank? + return { success: false, message: 'No Stripe customer ID found' } if stripe_customer_id.blank? - customer = Stripe::Customer.retrieve(customer_id, stripe_api_options) + customer = Stripe::Customer.retrieve(stripe_customer_id) if customer.invoice_settings.default_payment_method.nil? && customer.default_source.nil? return { @@ -37,9 +36,8 @@ class Enterprise::Billing::V2::InvoicePaymentService < Enterprise::Billing::V2:: # @param metadata [Hash] Invoice metadata # @return [Hash] { success:, invoice_id:, invoice_url:, amount:, status: } def create_and_pay_invoice(line_items:, description:, currency: 'usd', metadata: {}) - customer_id = custom_attribute('stripe_customer_id') - invoice = create_invoice(customer_id, currency, description, metadata) - add_line_items_to_invoice(invoice.id, customer_id, line_items, currency) + invoice = create_invoice(stripe_customer_id, currency, description, metadata) + add_line_items_to_invoice(invoice.id, stripe_customer_id, line_items, currency) finalize_and_pay_invoice(invoice.id) rescue Stripe::StripeError => e Rails.logger.error("Error creating invoice: #{e.message}") @@ -56,7 +54,7 @@ class Enterprise::Billing::V2::InvoicePaymentService < Enterprise::Billing::V2:: auto_advance: false, description: description, metadata: metadata.stringify_keys - }, stripe_api_options) + }) end def add_line_items_to_invoice(invoice_id, customer_id, line_items, currency) @@ -68,7 +66,7 @@ class Enterprise::Billing::V2::InvoicePaymentService < Enterprise::Billing::V2:: invoice: invoice_id, description: item[:description], metadata: (item[:metadata] || {}).stringify_keys - }, stripe_api_options) + }) end end @@ -79,15 +77,14 @@ class Enterprise::Billing::V2::InvoicePaymentService < Enterprise::Billing::V2:: # Finalize the invoice finalized_invoice = Stripe::Invoice.finalize_invoice( invoice_id, - { auto_advance: false }, - stripe_api_options + { auto_advance: false } ) # Pay the invoice immediately if not already paid if finalized_invoice.status == 'paid' build_invoice_response(finalized_invoice) else - paid_invoice = Stripe::Invoice.pay(invoice_id, {}, stripe_api_options) + paid_invoice = Stripe::Invoice.pay(invoice_id, {}) build_invoice_response(paid_invoice) end rescue Stripe::StripeError => e @@ -104,8 +101,4 @@ class Enterprise::Billing::V2::InvoicePaymentService < Enterprise::Billing::V2:: status: invoice.status } 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/plan_catalog.rb b/enterprise/app/services/enterprise/billing/v2/plan_catalog.rb index 390049eba..50f38d4a0 100644 --- a/enterprise/app/services/enterprise/billing/v2/plan_catalog.rb +++ b/enterprise/app/services/enterprise/billing/v2/plan_catalog.rb @@ -93,11 +93,10 @@ module Enterprise::Billing::V2::PlanCatalog } end - def rate_card_component(definition) + def rate_card_component(_definition) { type: 'rate_card', name: 'Overage Rate', - overage_rate: definition[:overage_rate], rate_unit: CREDIT_UNIT, meter_id: nil } 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 deleted file mode 100644 index c3a04ac14..000000000 --- a/enterprise/app/services/enterprise/billing/v2/pricing_plan_component_builder.rb +++ /dev/null @@ -1,142 +0,0 @@ -class Enterprise::Billing::V2::PricingPlanComponentBuilder < Enterprise::Billing::V2::BaseService - include Enterprise::Billing::Concerns::StripeV2ClientHelper - - def add_license_fee_component(plan, config) - licensed_item = create_licensed_item( - display_name: config[:licensed_item_display_name], - 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, - lookup_key: config[:lookup_key] - ) - - add_component( - plan_id: plan.id, - type: 'license_fee', - data: { id: license_fee.id, version: license_fee.latest_version }, - lookup_key: config[:lookup_key] - ) - end - - def add_service_action_component(plan, config, cpu) - action = create_service_action( - lookup_key: config[:service_action_lookup_key], - credit_amount: config[:monthly_credit_amount], - cpu_id: cpu.id - ) - - add_component( - plan_id: plan.id, - type: 'service_action', - data: { id: action.id }, - lookup_key: config[:service_action_lookup_key] - ) - - action - end - - def add_rate_card_component(plan, config, meter, cpu) - card = create_rate_card(display_name: config[:rate_card_display_name]) - - item = create_metered_item( - display_name: config[:metered_item_display_name], - lookup_key: config[:metered_item_lookup_key], - meter_id: meter.id - ) - - add_rate(card_id: card.id, item_id: item.id, cpu_id: cpu.id, value: config[:rate_value] || 1) - - add_component( - plan_id: plan.id, - type: 'rate_card', - data: { id: card.id, version: card.latest_version }, - lookup_key: config[:metered_item_lookup_key] - ) - - card - end - - private - - def create_licensed_item(display_name:, lookup_key:, unit_label:) - super({ display_name: display_name, lookup_key: lookup_key, unit_label: unit_label }) - end - - def create_license_fee(display_name:, unit_amount:, licensed_item_id:, lookup_key:) - super({ - display_name: display_name, - currency: 'usd', - service_interval: 'month', - service_interval_count: 1, - tax_behavior: 'exclusive', - unit_amount: unit_amount.to_s, - licensed_item: licensed_item_id, - lookup_key: lookup_key - }) - end - - def create_service_action(lookup_key:, credit_amount:, cpu_id:) - super(service_action_params(lookup_key, credit_amount, cpu_id)) - end - - def service_action_params(lookup_key, credit_amount, cpu_id) - { - lookup_key: lookup_key, - service_interval: 'month', - service_interval_count: 1, - type: 'credit_grant', - credit_grant: credit_grant_config(credit_amount, cpu_id) - } - end - - def credit_grant_config(credit_amount, cpu_id) - { - name: 'Monthly Credits', - amount: { - type: 'custom_pricing_unit', - custom_pricing_unit: { id: cpu_id, value: credit_amount.to_s } - }, - expiry_config: { type: 'end_of_service_period' }, - applicability_config: { scope: { price_type: 'metered' } } - } - end - - def create_rate_card(display_name:) - super({ - display_name: display_name, - currency: 'usd', - service_interval: 'month', - service_interval_count: 1, - tax_behavior: 'exclusive' - }) - end - - def create_metered_item(display_name:, lookup_key:, meter_id:) - super({ display_name: display_name, lookup_key: lookup_key, meter: meter_id }) - end - - def add_rate(card_id:, item_id:, cpu_id:, value:) - add_rate_to_card(card_id, { - metered_item: item_id, - custom_pricing_unit_amount: { id: cpu_id, value: value.to_s } - }) - end - - def add_component(plan_id:, type:, data:, lookup_key:) - params = case type - when 'license_fee' - { type: 'license_fee', license_fee: data, lookup_key: lookup_key } - when 'service_action' - { type: 'service_action', service_action: data, lookup_key: lookup_key } - when 'rate_card' - { type: 'rate_card', rate_card: data, lookup_key: lookup_key } - end - - add_pricing_plan_component(plan_id, params) - end -end diff --git a/enterprise/app/services/enterprise/billing/v2/pricing_plan_service.rb b/enterprise/app/services/enterprise/billing/v2/pricing_plan_service.rb deleted file mode 100644 index 56d6c73d4..000000000 --- a/enterprise/app/services/enterprise/billing/v2/pricing_plan_service.rb +++ /dev/null @@ -1,104 +0,0 @@ -class Enterprise::Billing::V2::PricingPlanService < Enterprise::Billing::V2::BaseService - include Enterprise::Billing::Concerns::StripeV2ClientHelper - - def create_custom_pricing_unit(display_name:, lookup_key:) - super({ display_name: display_name, lookup_key: lookup_key }) - end - - def create_meter(display_name:, event_name:) - params = { - 'display_name' => display_name, - 'event_name' => event_name, - 'default_aggregation[formula]' => 'sum', - 'customer_mapping[type]' => 'by_id', - 'customer_mapping[event_payload_key]' => 'stripe_customer_id', - 'value_settings[event_payload_key]' => 'value' - } - super(params) - end - - def create_pricing_plan(display_name:, lookup_key:, currency: 'usd', tax_behavior: 'exclusive') - super({ display_name: display_name, currency: currency, tax_behavior: tax_behavior, lookup_key: lookup_key }) - end - - def create_complete_pricing_plan(config) - # Use shared CPU and meter if available, otherwise create new ones - cpu = get_or_create_cpu(config) - meter = get_or_create_meter(config) - - plan = create_pricing_plan(display_name: config[:plan_display_name], lookup_key: config[:plan_lookup_key]) - - builder = component_builder - builder.add_license_fee_component(plan, config) if config[:license_fee_amount].to_i.positive? - builder.add_service_action_component(plan, config, cpu) if config[:monthly_credit_amount].to_i.positive? - builder.add_rate_card_component(plan, config, meter, cpu) - - # Make the latest version live - make_plan_version_live(plan.id) - - build_plan_result(plan, cpu, meter) - end - - def get_or_create_cpu(config) - 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 - 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 - - def get_or_create_meter(config) - # Check for shared meter first - shared_meter_id = InstallationConfig.find_by(name: 'STRIPE_METER_ID')&.value || - ENV.fetch('STRIPE_METER_ID', nil) - - if shared_meter_id - # Return existing meter as OpenStruct to match create response - OpenStruct.new(id: shared_meter_id) - else - # Create new meter if not using shared - 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 - - private - - def component_builder - @component_builder ||= Enterprise::Billing::V2::PricingPlanComponentBuilder.new(account: account) - end - - def make_plan_version_live(plan_id) - update_pricing_plan(plan_id, { live_version: 'latest' }) - end - - def build_plan_result(plan, cpu, meter) - { - success: true, - pricing_plan: plan, - custom_pricing_unit: cpu, - meter: meter - } - end -end diff --git a/enterprise/app/services/enterprise/billing/v2/subscription_provisioning_service.rb b/enterprise/app/services/enterprise/billing/v2/subscription_provisioning_service.rb index ef853aa12..6b0018e0d 100644 --- a/enterprise/app/services/enterprise/billing/v2/subscription_provisioning_service.rb +++ b/enterprise/app/services/enterprise/billing/v2/subscription_provisioning_service.rb @@ -4,54 +4,47 @@ class Enterprise::Billing::V2::SubscriptionProvisioningService < Enterprise::Bil include Enterprise::Billing::Concerns::StripeV2ClientHelper def provision(subscription_id:) + process_subscription(subscription_id) + end + + def refresh + return if stripe_subscription_id.blank? + + process_subscription(stripe_subscription_id) + end + + private + + def process_subscription(subscription_id) # Retrieve pricing plan subscription details from Stripe V2 API subscription = retrieve_pricing_plan_subscription(subscription_id) + + # Check if subscription is canceled + if servicing_status(subscription) == 'canceled' + cancel_subscription + reset_captain_usage + return { pricing_plan_id: nil, quantity: nil } + end + # Extract details from the subscription pricing_plan_id = extract_pricing_plan_id(subscription) quantity = extract_subscription_quantity(subscription) billing_cadence = extract_billing_cadence(subscription) + # Update account with subscription details update_subscription_details(subscription_id, pricing_plan_id, quantity, billing_cadence) # Provision the subscription: sync credits and enable features provision_new_plan(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}" } - end - - def refresh - subscription_id = account.custom_attributes['stripe_subscription_id'] - return if subscription_id.blank? - - subscription_plan = retrieve_pricing_plan_subscription(subscription_id) - servicing_status = servicing_status(subscription_plan) - pricing_plan_id = extract_pricing_plan_id(subscription_plan) - if servicing_status == 'canceled' - cancel_subscription - else - quantity = account.custom_attributes['subscribed_quantity'] - billing_cadence = extract_billing_cadence(subscription_plan) - update_subscription_details(subscription_id, pricing_plan_id, quantity, billing_cadence) - end + # Reset usage for the new billing cycle reset_captain_usage - 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' - } + { pricing_plan_id: pricing_plan_id, quantity: quantity } end def servicing_status(subscription_plan) - subscription_plan.respond_to?(:servicing_status) ? subscription_plan.servicing_status : subscription_plan['servicing_status'] + extract_attribute(subscription_plan, :servicing_status) end def cancel_subscription @@ -78,26 +71,20 @@ class Enterprise::Billing::V2::SubscriptionProvisioningService < Enterprise::Bil end def extract_pricing_plan_id(subscription) - # Extract pricing_plan from the subscription object - subscription.respond_to?(:pricing_plan) ? subscription.pricing_plan : subscription['pricing_plan'] + extract_attribute(subscription, :pricing_plan) end def extract_billing_cadence(subscription) - subscription.respond_to?(:billing_cadence) ? subscription.billing_cadence : subscription['billing_cadence'] + extract_attribute(subscription, :billing_cadence) 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 - subscribed_quantity = account.custom_attributes['subscribed_quantity'] - if subscribed_quantity.present? && subscribed_quantity.to_i.positive? - Rails.logger.info "[V2 Billing] Using quantity from custom_attributes: #{subscribed_quantity}" - return subscribed_quantity.to_i - end + pending_quantity = custom_attribute('pending_subscription_quantity') + return pending_quantity.to_i if pending_quantity.present? && pending_quantity.to_i.positive? + + return subscribed_quantity if subscribed_quantity.positive? + 1 end @@ -113,7 +100,8 @@ class Enterprise::Billing::V2::SubscriptionProvisioningService < Enterprise::Bil 'pending_subscription_pricing_plan' => nil, 'billing_cadence' => billing_cadence, 'next_billing_date' => nil, - 'pending_stripe_pricing_plan_id' => nil + 'pending_stripe_pricing_plan_id' => nil, + 'stripe_billing_version' => 2 } attributes['stripe_pricing_plan_id'] = pricing_plan_id if pricing_plan_id.present? diff --git a/enterprise/app/services/enterprise/billing/v2/topup_service.rb b/enterprise/app/services/enterprise/billing/v2/topup_service.rb index 018b204b9..8f54b7562 100644 --- a/enterprise/app/services/enterprise/billing/v2/topup_service.rb +++ b/enterprise/app/services/enterprise/billing/v2/topup_service.rb @@ -19,7 +19,6 @@ class Enterprise::Billing::V2::TopupService < Enterprise::Billing::V2::BaseServi def validate_topup_request(credits) return { valid: false, success: false, message: 'Invalid topup amount' } unless credits.to_i.positive? - # Check if account has a valid subscription plan plan_validation = validate_subscription_plan return plan_validation unless plan_validation[:valid] @@ -27,7 +26,6 @@ class Enterprise::Billing::V2::TopupService < Enterprise::Billing::V2::BaseServi 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? - # Check if customer has a default payment method using common service payment_service = Enterprise::Billing::V2::InvoicePaymentService.new(account: account) payment_method_validation = payment_service.validate_payment_method return payment_method_validation.merge(valid: false) if payment_method_validation @@ -102,8 +100,7 @@ class Enterprise::Billing::V2::TopupService < Enterprise::Billing::V2::BaseServi # 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 + credit_grant_params(amount_cents, currency, credits) ) end @@ -145,12 +142,4 @@ class Enterprise::Billing::V2::TopupService < Enterprise::Billing::V2::BaseServi 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/usage_reporter_service.rb b/enterprise/app/services/enterprise/billing/v2/usage_reporter_service.rb index ed2ec2802..989acc9dd 100644 --- a/enterprise/app/services/enterprise/billing/v2/usage_reporter_service.rb +++ b/enterprise/app/services/enterprise/billing/v2/usage_reporter_service.rb @@ -1,6 +1,5 @@ class Enterprise::Billing::V2::UsageReporterService < Enterprise::Billing::V2::BaseService def report(credits_used, _feature = nil) - return { success: false, message: 'V2 billing not enabled' } unless v2_enabled? return { success: false, message: 'Missing Stripe configuration' } unless valid_configuration? event = Stripe::Billing::MeterEvent.create( @@ -16,7 +15,7 @@ class Enterprise::Billing::V2::UsageReporterService < Enterprise::Billing::V2::B private def valid_configuration? - custom_attribute('stripe_customer_id').present? + stripe_customer_id.present? end def meter_event_params(credits_used) @@ -24,7 +23,7 @@ class Enterprise::Billing::V2::UsageReporterService < Enterprise::Billing::V2::B event_name: 'chatwoot.usage', payload: { value: credits_used.to_s, - stripe_customer_id: custom_attribute('stripe_customer_id') + stripe_customer_id: stripe_customer_id }, identifier: "#{account.id}_#{Time.current.to_i}_#{SecureRandom.hex(4)}" } diff --git a/spec/enterprise/jobs/enterprise/billing/credit_sync_job_spec.rb b/spec/enterprise/jobs/enterprise/billing/credit_sync_job_spec.rb new file mode 100644 index 000000000..d8c24fc97 --- /dev/null +++ b/spec/enterprise/jobs/enterprise/billing/credit_sync_job_spec.rb @@ -0,0 +1,59 @@ +require 'rails_helper' + +RSpec.describe Enterprise::Billing::CreditSyncJob, type: :job do + include ActiveJob::TestHelper + + before do + allow(ENV).to receive(:fetch).and_call_original + allow(ENV).to receive(:fetch).with('STRIPE_SECRET_KEY', nil).and_return('sk_test_123') + allow(InstallationConfig).to receive(:find_by).and_call_original + allow(InstallationConfig).to receive(:find_by).with(name: 'STRIPE_METER_ID') + .and_return(instance_double(InstallationConfig, value: 'mtr_test_123')) + end + + describe '#perform' do + context 'with no arguments' do + let!(:account) do + create(:account, custom_attributes: { + 'stripe_customer_id' => 'cus_123', + 'stripe_billing_version' => 2, + 'captain_responses_usage' => 100, + 'stripe_last_synced_credits' => 50 + }) + end + + it 'syncs all accounts with Stripe customer ID' do + usage_reporter = instance_double(Enterprise::Billing::V2::UsageReporterService) + allow(Enterprise::Billing::V2::UsageReporterService).to receive(:new).and_return(usage_reporter) + allow(usage_reporter).to receive(:report).with(50).and_return({ success: true, event_id: 'evt_123' }) + + result = described_class.new.perform + + expect(result).to eq({ synced: 1, failed: 0 }) + expect(account.reload.custom_attributes['stripe_last_synced_credits']).to eq(100) + end + end + + context 'with account argument' do + let(:account) do + create(:account, custom_attributes: { + 'stripe_customer_id' => 'cus_123', + 'captain_responses_usage' => 100, + 'stripe_last_synced_credits' => 30 + }) + end + + it 'syncs single account' do + usage_reporter = instance_double(Enterprise::Billing::V2::UsageReporterService) + allow(Enterprise::Billing::V2::UsageReporterService).to receive(:new).and_return(usage_reporter) + allow(usage_reporter).to receive(:report).and_return({ success: true, event_id: 'evt_123' }) + + result = described_class.new.perform(account) + + expect(result[:success]).to be true + expect(result[:credits_reported]).to eq(70) + expect(account.reload.custom_attributes['stripe_last_synced_credits']).to eq(100) + end + end + end +end diff --git a/spec/enterprise/services/enterprise/ai/captain_credit_service_spec.rb b/spec/enterprise/services/enterprise/ai/captain_credit_service_spec.rb deleted file mode 100644 index 14ae0506f..000000000 --- a/spec/enterprise/services/enterprise/ai/captain_credit_service_spec.rb +++ /dev/null @@ -1,39 +0,0 @@ -require 'rails_helper' - -describe Enterprise::Ai::CaptainCreditService do - let(:account) { create(:account) } - let(:service) { described_class.new(account: account) } - let(:credit_service) { instance_double(Enterprise::Billing::V2::CreditManagementService) } - - before do - allow(Enterprise::Billing::V2::CreditManagementService).to receive(:new).and_return(credit_service) - end - - describe '#check_and_use_credits' do - context 'when account is not on v2 billing' do - it 'returns success without invoking credit service' do - result = service.check_and_use_credits - - expect(result[:success]).to be(true) - expect(Enterprise::Billing::V2::CreditManagementService).not_to have_received(:new) - end - end - - context 'when account uses v2 billing' do - before do - account.update!(custom_attributes: (account.custom_attributes || {}).merge('stripe_billing_version' => 2)) - allow(credit_service).to receive(:use_credit).and_return(success: true) - end - - it 'delegates to credit management service with metadata' do - metadata = { 'feature' => 'ai_reply', 'conversation_id' => 123 } - - result = service.check_and_use_credits(feature: 'ai_reply', metadata: metadata) - - expect(result[:success]).to be(true) - expect(credit_service).to have_received(:use_credit) - .with(feature: 'ai_reply', amount: 1, metadata: metadata) - end - end - end -end diff --git a/spec/enterprise/services/enterprise/billing/create_stripe_customer_service_spec.rb b/spec/enterprise/services/enterprise/billing/create_stripe_customer_service_spec.rb index b1daed6a3..a2fac700f 100644 --- a/spec/enterprise/services/enterprise/billing/create_stripe_customer_service_spec.rb +++ b/spec/enterprise/services/enterprise/billing/create_stripe_customer_service_spec.rb @@ -7,42 +7,15 @@ describe Enterprise::Billing::CreateStripeCustomerService do let!(:admin1) { create(:user, account: account, role: :administrator) } describe '#perform' 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') - - create_stripe_customer_service.new(account: account).perform - - 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 - ) + context 'when V2 configs are missing' do + it 'raises a configuration error' do + expect do + create_stripe_customer_service.new(account: account).perform + end.to raise_error(StandardError, /V2 billing configuration is required/) end end - context 'when customer already exists' do - before do - account.update!(custom_attributes: { stripe_customer_id: 'cus_existing_customer' }) - end - - it 'does not create a new customer' do - allow(Stripe::Customer).to receive(:create) - # Stub the subscription check to return no subscriptions - subscriptions_response = OpenStruct.new(data: []) - allow(Stripe::Subscription).to receive(:list).and_return(subscriptions_response) - - create_stripe_customer_service.new(account: account).perform - - expect(Stripe::Customer).not_to have_received(:create) - expect(account.reload.custom_attributes['stripe_customer_id']).to eq('cus_existing_customer') - end - end - - context 'with V2 billing enabled' do + context 'with V2 billing' do let(:cloud_plans_config) do create(:installation_config, name: 'CHATWOOT_CLOUD_PLANS', @@ -65,8 +38,6 @@ describe Enterprise::Billing::CreateStripeCustomerService do # Setup configs cloud_plans_config hacker_plan_config - # Enable V2 billing - allow(ENV).to receive(:fetch).with('STRIPE_BILLING_V2_ENABLED', 'false').and_return('true') end it 'creates a stripe customer and sets up V2 billing' do @@ -83,6 +54,7 @@ describe Enterprise::Billing::CreateStripeCustomerService do expect(Stripe::Customer).to have_received(:create).with({ name: account.name, email: admin1.email }) expect(account.reload.custom_attributes).to include( 'stripe_customer_id' => 'cus_random_number', + 'stripe_billing_version' => 'v2', 'stripe_pricing_plan_id' => 'bpp_hacker_123', 'plan_name' => 'Hacker', 'subscribed_quantity' => 2 @@ -107,6 +79,7 @@ describe Enterprise::Billing::CreateStripeCustomerService do expect(Stripe::Customer).not_to have_received(:create) expect(account.reload.custom_attributes).to include( 'stripe_customer_id' => 'cus_existing_v2', + 'stripe_billing_version' => 'v2', 'stripe_pricing_plan_id' => 'bpp_hacker_123', 'plan_name' => 'Hacker', 'subscribed_quantity' => 2 @@ -128,48 +101,5 @@ describe Enterprise::Billing::CreateStripeCustomerService do expect(account.reload.custom_attributes['stripe_customer_id']).to eq('cus_existing_v2') end end - - context 'with V1 billing (traditional subscription)' do - let(:cloud_plans_config) do - create(:installation_config, - name: 'CHATWOOT_CLOUD_PLANS', - value: [ - { - 'name' => 'Startup', - 'price_ids' => ['price_startup_123'], - 'default_quantity' => 2 - } - ]) - end - - before do - cloud_plans_config - allow(ENV).to receive(:fetch).with('STRIPE_BILLING_V2_ENABLED', 'false').and_return('false') - end - - it 'creates a stripe customer and traditional subscription' do - customer = double - allow(Stripe::Customer).to receive(:create).and_return(customer) - allow(customer).to receive(:id).and_return('cus_random_number') - - subscription = { - 'plan' => { 'id' => 'price_startup_123', 'product' => 'prod_startup' }, - 'quantity' => 2 - } - allow(Stripe::Subscription).to receive(:create).and_return(subscription) - - 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) - expect(account.reload.custom_attributes).to include( - 'stripe_customer_id' => 'cus_random_number', - 'stripe_price_id' => 'price_startup_123', - 'stripe_product_id' => 'prod_startup', - 'plan_name' => 'Startup', - 'subscribed_quantity' => 2 - ) - end - end end end diff --git a/spec/enterprise/services/enterprise/billing/v2/credit_management_service_spec.rb b/spec/enterprise/services/enterprise/billing/v2/credit_management_service_spec.rb index 6cf600209..e9fe52be6 100644 --- a/spec/enterprise/services/enterprise/billing/v2/credit_management_service_spec.rb +++ b/spec/enterprise/services/enterprise/billing/v2/credit_management_service_spec.rb @@ -6,104 +6,26 @@ describe Enterprise::Billing::V2::CreditManagementService do before do account.update!( - custom_attributes: { - 'stripe_billing_version' => 2, - 'stripe_customer_id' => 'cus_test_123', - 'stripe_meter_event_name' => 'ai_prompts', - 'captain_responses_monthly' => 100, - 'captain_responses_topup' => 50, - 'captain_responses_usage' => 0, - 'captain_documents_usage' => 0 - }, - limits: { - 'captain_responses' => 150, - 'captain_documents' => 200 - } + custom_attributes: { 'captain_responses_usage' => 0 }, + limits: { 'captain_responses_monthly' => 100, 'captain_responses_topup' => 50 } ) end describe '#sync_monthly_response_credits' do - it 'updates monthly credits and syncs total' do + it 'updates monthly credits' do service.sync_monthly_response_credits(500) - account.reload - expect(account.custom_attributes['captain_responses_monthly']).to eq(500) - expect(account.limits['captain_responses']).to eq(550) # 500 + 50 topup (stored in limits ONLY) - end - - it 'preserves topup credits capped at remaining balance' do - # Set usage to 80 - account.update!(custom_attributes: account.custom_attributes.merge('captain_responses_usage' => 80)) - - service.sync_monthly_response_credits(100) - - account.reload - # Current: monthly=100, topup=50, total=150, usage=80 - # Remaining: 150 - 80 = 70 - # Topup should be capped at min(50, 70) = 50 - expect(account.custom_attributes['captain_responses_topup']).to eq(50) - expect(account.limits['captain_responses']).to eq(150) - end - - it 'caps topup when usage exceeds new monthly allocation' do - # Usage is 120, new monthly is 100, topup is 50 - account.update!(custom_attributes: account.custom_attributes.merge('captain_responses_usage' => 120)) - - service.sync_monthly_response_credits(100) - - account.reload - # New total would be: monthly=100 + topup=50 = 150 - # Usage: 120 - # Remaining: 150 - 120 = 30 - # Topup should be capped at min(50, 30) = 30 - expect(account.custom_attributes['captain_responses_topup']).to eq(30) - expect(account.limits['captain_responses']).to eq(130) # 100 + 30 - end - - it 'zeros topup when usage exceeds new total' do - # Usage is 160, new monthly is 100, topup is 50 - account.update!(custom_attributes: account.custom_attributes.merge('captain_responses_usage' => 160)) - - service.sync_monthly_response_credits(100) - - account.reload - # New total would be: monthly=100 + topup=50 = 150 - # Usage: 160 (exceeds total) - # Remaining: max(0, 150 - 160) = 0 - # Topup should be: min(50, 0) = 0 - expect(account.custom_attributes['captain_responses_topup']).to eq(0) - expect(account.limits['captain_responses']).to eq(100) # 100 + 0 - end - end - - describe '#sync_document_credits' do - it 'updates document credits total' do - service.sync_document_credits(500) - - account.reload - expect(account.limits['captain_documents']).to eq(500) # Stored in limits hash ONLY - end - end - - describe '#expire_monthly_response_credits' do - it 'expires monthly credits and preserves topup' do - expired = service.expire_monthly_response_credits - - expect(expired).to eq(100) - account.reload - expect(account.custom_attributes['captain_responses_monthly']).to eq(0) - expect(account.custom_attributes['captain_responses_topup']).to eq(50) - expect(account.limits['captain_responses']).to eq(50) + expect(account.reload.limits['captain_responses_monthly']).to eq(500) + expect(account.limits['captain_responses']).to eq(550) end end describe '#add_response_topup_credits' do - it 'adds topup credits and updates total' do + it 'adds topup credits' do service.add_response_topup_credits(100) - account.reload - expect(account.custom_attributes['captain_responses_topup']).to eq(150) # 50 + 100 - expect(account.limits['captain_responses']).to eq(250) # 100 monthly + 150 topup + expect(account.reload.limits['captain_responses_topup']).to eq(150) + expect(account.limits['captain_responses']).to eq(250) 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 deleted file mode 100644 index af3584f02..000000000 --- a/spec/enterprise/services/enterprise/billing/v2/pricing_plan_service_spec.rb +++ /dev/null @@ -1,145 +0,0 @@ -require 'rails_helper' - -RSpec.describe Enterprise::Billing::V2::PricingPlanService do - let(:account) { create(:account) } - let(:service) { described_class.new(account: account) } - - before do - allow(ENV).to receive(:fetch).and_call_original - allow(ENV).to receive(:fetch).with('STRIPE_SECRET_KEY', nil).and_return('sk_test_123') - end - - describe '#create_custom_pricing_unit' do - it 'creates a custom pricing unit' do - cpu_response = OpenStruct.new(id: 'cpu_123') - allow(StripeV2Client).to receive(:request).and_return(cpu_response) - - result = service.create_custom_pricing_unit(display_name: 'Credits', lookup_key: 'credits_001') - - expect(result.id).to eq('cpu_123') - expect(StripeV2Client).to have_received(:request).with( - :post, - '/v2/billing/custom_pricing_units', - { display_name: 'Credits', lookup_key: 'credits_001' }, - { api_key: 'sk_test_123', stripe_version: '2025-08-27.preview' } - ) - end - end - - describe '#create_meter' do - it 'creates a billing meter' do - meter_response = OpenStruct.new(id: 'meter_123') - allow(StripeV2Client).to receive(:request).and_return(meter_response) - - result = service.create_meter(display_name: 'Prompts', event_name: 'prompts_001') - - expect(result.id).to eq('meter_123') - expect(StripeV2Client).to have_received(:request).with( - :post, - '/v1/billing/meters', - kind_of(Hash), - { api_key: 'sk_test_123', stripe_version: '2025-08-27.preview' } - ) - end - end - - describe '#create_pricing_plan' do - it 'creates a pricing plan' 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', 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', 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', - 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_unit_label: 'per agent', - license_fee_display_name: 'Fee', - 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', - rate_value: 1 - } - end - - 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 - - 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) - 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) - - expect(result[:success]).to be true - expect(result[:pricing_plan]).to eq(plan) - 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 index a8f785009..b07b5b2d9 100644 --- a/spec/enterprise/services/enterprise/billing/v2/subscription_provisioning_service_spec.rb +++ b/spec/enterprise/services/enterprise/billing/v2/subscription_provisioning_service_spec.rb @@ -7,7 +7,7 @@ describe Enterprise::Billing::V2::SubscriptionProvisioningService do let(:pricing_plan_id) { 'bpp_business_plan_123' } before do - account.update!(custom_attributes: { 'stripe_billing_version' => 2, 'pending_subscription_quantity' => 5 }) + account.update!(custom_attributes: { 'pending_subscription_quantity' => 5 }) create(:installation_config, name: 'STRIPE_BUSINESS_PLAN_ID', value: pricing_plan_id) end @@ -16,9 +16,8 @@ describe Enterprise::Billing::V2::SubscriptionProvisioningService do OpenStruct.new( id: subscription_id, pricing_plan: pricing_plan_id, - component_values: [ - { 'type' => 'license_fee', 'quantity' => 5 } - ] + billing_cadence: 'bpc_cadence_123', + servicing_status: 'active' ) end @@ -26,179 +25,33 @@ describe Enterprise::Billing::V2::SubscriptionProvisioningService do allow(StripeV2Client).to receive(:request).and_return(subscription_response) end - it 'retrieves subscription details from Stripe' do - service.provision(subscription_id: subscription_id) + it 'provisions subscription' do + result = 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') + expect(result[:pricing_plan_id]).to eq(pricing_plan_id) + expect(result[:quantity]).to eq(5) + end + end + + describe '#refresh' do + let(:subscription_response) do + OpenStruct.new( + id: 'bpps_sub_456', + pricing_plan: pricing_plan_id, + billing_cadence: 'bpc_cadence_123', + servicing_status: 'active' ) 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') + before do + account.update!(custom_attributes: { 'stripe_subscription_id' => 'bpps_sub_456' }) + allow(StripeV2Client).to receive(:request).and_return(subscription_response) 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) + it 'refreshes subscription' do + result = service.refresh - 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 '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 index 270324dd0..c4d7cfda6 100644 --- a/spec/enterprise/services/enterprise/billing/v2/topup_service_spec.rb +++ b/spec/enterprise/services/enterprise/billing/v2/topup_service_spec.rb @@ -1,33 +1,8 @@ require 'rails_helper' -require 'ostruct' describe Enterprise::Billing::V2::TopupService do - let(:account) { create(:account, custom_attributes: { 'stripe_customer_id' => 'cus_123', 'plan_name' => 'Startups' }) } + let(:account) { create(:account, custom_attributes: { 'stripe_customer_id' => 'cus_123', 'plan_name' => 'Business' }) } let(:service) { described_class.new(account: account) } - let(:invoice_settings) { instance_double(Stripe::InvoiceSettings, default_payment_method: 'pm_123') } - let(:stripe_customer) do - instance_double(Stripe::Customer, - invoice_settings: invoice_settings, - default_source: nil) - end - 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( @@ -35,110 +10,26 @@ describe Enterprise::Billing::V2::TopupService do amount: 50.0, currency: 'usd' ) - - # Mock Stripe Customer retrieval for payment method check - allow(Stripe::Customer).to receive(:retrieve).and_return(stripe_customer) - - # 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) + allow(Stripe::Customer).to receive(:retrieve).and_return( + OpenStruct.new(invoice_settings: OpenStruct.new(default_payment_method: 'pm_123'), default_source: nil) + ) + allow(Stripe::Invoice).to receive(:create).and_return(Stripe::Invoice.construct_from(id: 'in_123')) + allow(Stripe::InvoiceItem).to receive(:create) + allow(Stripe::Invoice).to receive(:finalize_invoice).and_return( + Stripe::Invoice.construct_from(id: 'in_123', status: 'open') + ) + allow(Stripe::Invoice).to receive(:pay).and_return( + Stripe::Invoice.construct_from(id: 'in_123', status: 'paid', total: 5000, hosted_invoice_url: 'https://invoice.stripe.com') + ) + allow(Stripe::Billing::CreditGrant).to receive(:create).and_return({ 'id' => 'credgr_123' }) end describe '#create_topup' do - context 'when successful' do - before do - service.create_topup(credits: 500) - end + it 'creates topup' do + result = service.create_topup(credits: 500) - 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') + expect(result[:success]).to be true + expect(result[:credits]).to eq(500) end end end diff --git a/spec/enterprise/services/enterprise/billing/v2/usage_reporter_service_spec.rb b/spec/enterprise/services/enterprise/billing/v2/usage_reporter_service_spec.rb index 25697b66a..354463bb7 100644 --- a/spec/enterprise/services/enterprise/billing/v2/usage_reporter_service_spec.rb +++ b/spec/enterprise/services/enterprise/billing/v2/usage_reporter_service_spec.rb @@ -1,55 +1,18 @@ require 'rails_helper' describe Enterprise::Billing::V2::UsageReporterService do - let(:account) { create(:account) } + let(:account) { create(:account, custom_attributes: { 'stripe_billing_version' => 2, 'stripe_customer_id' => 'cus_123' }) } let(:service) { described_class.new(account: account) } - before do - account.update!( - custom_attributes: { - 'stripe_billing_version' => 2, - 'stripe_customer_id' => 'cus_test_123', - 'stripe_meter_event_name' => 'ai_prompts' - } - ) - end + describe '#report' do + it 'posts usage to Stripe' do + allow(Stripe::Billing::MeterEvent).to receive(:create).and_return(OpenStruct.new(identifier: 'me_123')) - it 'posts usage events to Stripe meters' do - meter_event = OpenStruct.new(identifier: 'me_test_123') - allow(Stripe::Billing::MeterEvent).to receive(:create).and_return(meter_event) - - result = service.report(5, 'ai_test') - - expect(result).to include(success: true, event_id: 'me_test_123') - expect(Stripe::Billing::MeterEvent).to have_received(:create) do |params, options| - expect(params[:event_name]).to eq('ai_prompts') - expect(params[:payload][:value]).to eq('5') - expect(params[:payload][:stripe_customer_id]).to eq('cus_test_123') - expect(options[:stripe_version]).to eq('2025-08-27.preview') - end - end - - context 'when V2 billing not enabled' do - before do - account.update!(custom_attributes: { 'stripe_billing_version' => 1 }) - end - - it 'returns error' do result = service.report(5, 'ai_test') - expect(result).to include(success: false, message: 'V2 billing not enabled') - end - end - - context 'when no Stripe customer' do - before do - account.update!(custom_attributes: { 'stripe_billing_version' => 2 }) - end - - it 'returns error' do - result = service.report(5, 'ai_test') - - expect(result).to include(success: false, message: 'Missing Stripe configuration') + expect(result[:success]).to be true + expect(result[:event_id]).to eq('me_123') + expect(Stripe::Billing::MeterEvent).to have_received(:create) 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 34ce4e194..8a339af23 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 @@ -1,105 +1,40 @@ require 'rails_helper' -# rubocop:disable RSpec/VerifiedDoubles describe Enterprise::Billing::V2::WebhookHandlerService do - let(:account) do - create(:account, custom_attributes: { 'stripe_customer_id' => 'cus_123', 'stripe_billing_version' => 2, 'pending_subscription_quantity' => 5 }) - end + let(:account) { create(:account, custom_attributes: { 'stripe_customer_id' => 'cus_123' }) } let(:service) { described_class.new } - 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) - allow(ENV).to receive(:fetch).and_call_original - end + let(:pricing_plan_id) { 'bpp_plan_123' } describe '#perform' do - context 'when handling subscription servicing activated' do - let(:subscription_response) do - OpenStruct.new( - id: 'bpps_subscription_123', - pricing_plan: 'bpp_business_plan_123', - billing_cadence: 'cadence_123', - component_values: [{ 'type' => 'license_fee', 'quantity' => 5 }] - ) - end - - let(:cadence_response) do - OpenStruct.new( - id: 'cadence_123', - payer: OpenStruct.new(customer: 'cus_123') - ) - end - - let(:provisioning_response) do - { - success: true, - subscription_id: 'bpps_subscription_123', - pricing_plan_id: 'bpp_business_plan_123', - quantity: 5 - } - end - - let(:event) do - double( - 'Stripe::Event', - 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') - - # Mock account lookup via subscription and cadence - allow(StripeV2Client).to receive(:request) - .with(:get, '/v2/billing/pricing_plan_subscriptions/bpps_subscription_123', anything, anything) - .and_return(subscription_response) - allow(StripeV2Client).to receive(:request) - .with(:get, '/v2/billing/cadences/cadence_123', anything, anything) - .and_return(cadence_response) - - # Mock provisioning service - provisioning_service = instance_double(Enterprise::Billing::V2::SubscriptionProvisioningService) - allow(Enterprise::Billing::V2::SubscriptionProvisioningService).to receive(:new).with(account: account).and_return(provisioning_service) - allow(provisioning_service).to receive(:provision).and_return(provisioning_response) - - allow(credit_service).to receive(:sync_monthly_credits) - end - - it 'delegates to subscription provisioning service' do - result = service.perform(event: event) - - expect(result[:success]).to be(true), "Expected success but got: #{result.inspect}" - expect(Enterprise::Billing::V2::SubscriptionProvisioningService).to have_received(:new).with(account: account) - end + let(:subscription_response) do + OpenStruct.new( + id: 'bpps_sub_123', + billing_cadence: 'cad_123', + pricing_plan: pricing_plan_id, + servicing_status: 'active' + ) + end + let(:cadence_response) { OpenStruct.new(payer: OpenStruct.new(customer: 'cus_123')) } + let(:event) do + double('Stripe::Event', type: 'v2.billing.pricing_plan_subscription.servicing_activated', related_object: OpenStruct.new(id: 'bpps_sub_123')) # rubocop:disable RSpec/VerifiedDoubles end - context 'when handling unknown event' do - it 'returns success' do - related_object = OpenStruct.new(id: 'bpps_subscription_123') - event = double('Stripe::Event', type: 'unknown.event', related_object: related_object) + before do + account # Ensure account is created + create(:installation_config, name: 'STRIPE_BUSINESS_PLAN_ID', value: pricing_plan_id) + # Mock the Stripe API calls + allow(StripeV2Client).to receive(:request) + .with(:get, '/v2/billing/pricing_plan_subscriptions/bpps_sub_123', anything, anything) + .and_return(subscription_response) + allow(StripeV2Client).to receive(:request) + .with(:get, '/v2/billing/cadences/cad_123', anything, anything) + .and_return(cadence_response) + end - # Mock account lookup - subscription_response = OpenStruct.new(billing_cadence: 'cadence_123') - cadence_response = OpenStruct.new(payer: OpenStruct.new(customer: 'cus_123')) - allow(StripeV2Client).to receive(:request) - .with(:get, '/v2/billing/pricing_plan_subscriptions/bpps_subscription_123', anything, anything) - .and_return(subscription_response) - allow(StripeV2Client).to receive(:request) - .with(:get, '/v2/billing/cadences/cadence_123', anything, anything) - .and_return(cadence_response) + it 'handles subscription activation' do + result = service.perform(event: event) - result = service.perform(event: event) - - expect(result[:success]).to be(true) - end + expect(result[:pricing_plan_id]).to eq(pricing_plan_id) end end end -# rubocop:enable RSpec/VerifiedDoubles